Skip to content

hil: flood the bench with unique BLE addresses, and watch heap - #9

Merged
DTTerastar merged 3 commits into
mainfrom
hil/ble-flood
Aug 1, 2026
Merged

hil: flood the bench with unique BLE addresses, and watch heap#9
DTTerastar merged 3 commits into
mainfrom
hil/ble-flood

Conversation

@DTTerastar

@DTTerastar DTTerastar commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The bench cannot currently reproduce ESPresense#2309, and could not detect it if it did. Two halves: generate the load, then actually watch for the failure.

Load — scripts/ble_flood.py

Advertises from a fresh static random address ~40x/sec off a USB Bluetooth adapter on the bench host, driven raw over HCI_CHANNEL_USER (so BlueZ is not required and should not be installed — the kernel hands over the controller exclusively and bluetoothd would only fight for it).

Each advert carries a name containing its own MAC, so every rotation is a distinct identity to ESPresense. That detail came from hardware, not theory — see below.

Detection — scripts/hil_monitor.py

  • Restart mid-run (exit 7). Nothing noticed a node going down and coming back, because every other signal here is satisfied by the fresh boot. An S3 power-cycling every ~6h under the #2414 low-heap watchdog passes the current 8h window completely clean — which is exactly what happened on #2309.
  • Free heap decline (exit 8). Sampled from /json/tele (ESPresense#2430) once a minute. Every sample is logged, so a run answers leak vs fragmentation vs churn on its own. Judged only on runs ≥30 min.
  • Missing /json/tele fails the run. A heap check that silently samples nothing and still reports PASS is how #2309 survived an 8h soak. An unreachable node stays a skip — that is not a firmware fault.

What the hardware corrected

Three bugs in the first version, all found by running it on the bench rather than reasoning about it:

  1. Identities were collapsing. With a constant advertised name, a live node reported two different MACs both as id name:hil-flood — ESPresense ranks ID_TYPE_NAME (35) above ID_TYPE_RAND_STATIC_MAC (5). The slot pool still churned, but the id space this exists to exercise did not. The name now carries the full MAC; a 3-byte suffix would collide tens of thousands of times over an 8h soak and quietly merge ids again.
  2. The heap check was sampling a field that does not exist. /json only ever carried room/ver/firm — the heap numbers are in the MQTT telemetry path. It would have collected nothing and skipped silently forever. Hence /json/tele.
  3. send() ignored command status. A wedged or unplugged adapter would swallow every HCI command while the flood cheerfully reported thousands of addresses. It now waits for each completion event and checks status.

Gating

The flood runs only while a HIL step asks for it, via a request file in the already-mounted /var/lock/woodpecker. A directory rather than a single flag because the four device steps finish at different times and the first one out must not cut the flood out from under the rest. Stale requests expire (9h) so a hard-killed container cannot leave the bench spraying junk MACs at every ESPresense node in the house.

Verified on the bench

USB adapter (Intel 8087:0a2b) passed through to tsi-ha:

[flood] hci0 claimed, rotating at 40.0/s
[flood] 3586 unique addresses in 90s (39.8/s)
[flood] stopped after 4777 unique addresses

A production node in range (esp32s3-cdc, Bathroom) picked the adverts up and fingerprinted them, which is what caught bug #1 above.

$ python3 scripts/test_heap_trend.py
leak      -> Free heap fell 61% (freeHeap 84710->33289, maxHeap 28236->11096, fingerprints 29->29, over 6h)
healthy   -> pass
dip       -> pass
threshold -> 20% passes, 30% fails
no endpoint -> /json/tele is missing from this firmware, so heap was never sampled over 6h
all heap trend checks passed

The leak case is Garkus98's actual S3 curve from #2309; the healthy case is the C3 from the same report. --selftest covers HCI framing, completion-status parsing, address rules and both directions of the gate without hardware. Existing test_json_check.py still passes.

Still to validate

The monitor changes have not run against a live node end-to-end — that needs this image plus ESPresense#2430 firmware on the bench. Note merging here does not disturb the running HIL: hil.yml pins firmware-tester:1, which only moves on a v1.* release tag, not on a merge to main.

Summary by CodeRabbit

  • New Features

    • Added a BLE advertisement flooding utility with configurable rate, duration, adapter, and request controls.
    • Added optional systemd support for continuous BLE flooding during hardware-in-the-loop runs.
    • Enhanced monitoring to detect device restarts and sustained free-memory declines.
  • Bug Fixes

    • Hardware-in-the-loop monitoring now reports failures when heap telemetry is unavailable or memory degradation exceeds configured thresholds.
  • Documentation

    • Added setup and usage guidance for the BLE flooding utility and host-side hardware-in-the-loop testing.
  • Tests

    • Added regression coverage for heap trend detection across stable, recovering, declining, and incomplete telemetry scenarios.

The HIL bench cannot currently reproduce ESPresense#2309, and could not detect
it if it did.

Load: ble_flood.py advertises from a fresh static random address ~40x/sec off a
USB Bluetooth adapter on the bench host. ESPresense fingerprints those by MAC
(ID_TYPE_RAND_STATIC_MAC), so every rotation costs a node a slot against a pool
of 100-200 — the churn of a busy room, compressed. Driven raw over
HCI_CHANNEL_USER so BlueZ is neither required nor able to fight for the adapter.

Detection: hil_monitor.py gains two checks it was missing.

  - Restart mid-run (exit 7). Nothing noticed a node going down and coming back,
    because every other signal here is satisfied by the fresh boot. An S3
    power-cycling every ~6h under the low-heap watchdog passes the current 8h
    window clean, which is exactly what happened on #2309.
  - Free heap decline (exit 8). /json already reports freeHeap/maxHeap/
    fingerprints, so sampling it needs no firmware change. Every sample is
    logged, so the run itself answers leak vs fragmentation vs churn — the
    question #2309 took months to settle by hand.

Gating: the flood only runs while a HIL step asks for it, via a request file in
the already-mounted /var/lock/woodpecker. A directory rather than one flag
because the four device steps finish at different times, and stale requests
expire so a killed container cannot leave the bench spraying junk MACs at every
ESPresense node in the house.

Checks: test_heap_trend.py runs the real #2309 shapes through the verdict (S3
slide fails, C3 hold passes, transient dip passes); ble_flood.py --selftest
covers HCI framing, static-random address rules, and both directions of the gate
without hardware.

Not yet run against a real adapter — needs the USB dongle passed through to the
bench VM first.
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0b9df274-4d10-43b3-9447-33ffa935257c

📥 Commits

Reviewing files that changed from the base of the PR and between d55f609 and b9498de.

📒 Files selected for processing (1)
  • scripts/hil_monitor.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • scripts/hil_monitor.py

Walkthrough

Changes

The PR adds a raw HCI BLE flood utility with systemd host integration. It extends HIL monitoring with restart detection, heap sampling, trend evaluation, and regression tests.

BLE flood utility

Layer / File(s) Summary
BLE flooder implementation
scripts/ble_flood.py
Adds raw HCI advertising setup, static random address rotation, request-file gating, cleanup, CLI options, and hardware-free self-tests.
Host service and setup
bench/ble-flood.service, bench/README.md
Adds the systemd unit and documents installation, activation, request handling, stale-file expiry, and manual controls.

HIL monitoring checks

Layer / File(s) Summary
Heap sampling and verdicts
scripts/hil_monitor.py
Adds periodic /json/tele sampling, median heap comparisons, trend thresholds, and heap-related configuration.
Monitor lifecycle and regression coverage
scripts/hil_monitor.py, scripts/test_heap_trend.py
Integrates sampling with monitor startup and shutdown, adds exit codes 7 and 8, detects restart patterns, and tests heap verdicts.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the two main changes: unique BLE address flooding and heap monitoring.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hil/ble-flood

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/hil_monitor.py`:
- Around line 58-63: Update the HEAP_TREND_EDGE comment to state that the
configured number of samples is taken at each end and summarized using the
median, matching the statistics.median behavior in edges().
- Around line 228-236: Update the summary construction in the monitor analysis
function to calculate the sampled span from the timestamps in `samples`, using
the first and last sample times, and pass that span to `format_duration` instead
of the full `duration` argument. Keep the existing heap and fingerprint summary
fields unchanged.
- Around line 187-213: Update heap_sampler’s JSON handling to require
isinstance(doc, dict) before accessing freeHeap, maxHeap, or fingerprints,
matching the guard used by json_endpoint_check. Skip non-dict bodies such as
null and continue sampling without allowing an AttributeError to terminate the
sampler thread.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 0fd7d21d-6f31-4826-9d85-c782ab568c76

📥 Commits

Reviewing files that changed from the base of the PR and between 5e7fe77 and 0344a0e.

📒 Files selected for processing (5)
  • bench/README.md
  • bench/ble-flood.service
  • scripts/ble_flood.py
  • scripts/hil_monitor.py
  • scripts/test_heap_trend.py
👮 Files not reviewed due to content moderation or server errors (3)
  • scripts/ble_flood.py
  • bench/ble-flood.service
  • bench/README.md

Comment thread scripts/hil_monitor.py
Comment thread scripts/hil_monitor.py Outdated
Comment thread scripts/hil_monitor.py
Two corrections found by running this against real hardware.

Unique identities: the flood advertised a constant name, and a live node
reported two different MACs both as id "name:hil-flood" — ESPresense ranks a
name (ID_TYPE_NAME, 35) above a static random address (5), so every advert
collapsed onto one logical id. The slot pool still churned, but the id space
this exists to exercise did not. The name now carries the full MAC; a short
suffix would collide tens of thousands of times over an 8h soak and quietly
merge ids again.

Sampling: /json only ever carried room/ver/firm, so the heap check would have
collected nothing and skipped silently forever. It now reads /json/tele
(ESPresense#2430), which is allocation-free and answers under low heap —
unlike /json, which refuses with 429 when it cannot afford a 12KB document and
would therefore hide the numbers exactly as a node degraded.

A missing /json/tele now fails the run rather than skipping. A heap check that
samples nothing and still reports PASS is how #2309 survived an 8h soak.
Unreachable nodes stay a skip: that is not a firmware fault.

Also: send() now waits for each command's completion event and checks status,
so a wedged adapter can no longer report thousands of addresses while
radiating nothing.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
scripts/test_heap_trend.py (1)

61-67: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Cover propagation to exit code 8.

Lines 61-67 call heap_verdict directly. They verify the returned failure string, but they do not verify that the monitor converts a long-run missing /json/tele result into exit code 8. Add a lifecycle assertion, or confirm equivalent coverage elsewhere in scripts/hil_monitor.py.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test_heap_trend.py` around lines 61 - 67, Extend the tests around
heap_verdict and the monitor lifecycle to verify that a long-run missing
/json/tele result propagates through scripts/hil_monitor.py to exit code 8, not
just a failure string. Add a lifecycle assertion or reuse equivalent existing
coverage, while preserving the current skip behavior for unreachable nodes and
short runs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@scripts/test_heap_trend.py`:
- Around line 61-67: Extend the tests around heap_verdict and the monitor
lifecycle to verify that a long-run missing /json/tele result propagates through
scripts/hil_monitor.py to exit code 8, not just a failure string. Add a
lifecycle assertion or reuse equivalent existing coverage, while preserving the
current skip behavior for unreachable nodes and short runs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 64566c9b-632d-42bd-a2c4-f3c2828c6cee

📥 Commits

Reviewing files that changed from the base of the PR and between 0344a0e and d55f609.

📒 Files selected for processing (3)
  • scripts/ble_flood.py
  • scripts/hil_monitor.py
  • scripts/test_heap_trend.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • scripts/hil_monitor.py
  • scripts/ble_flood.py

DTTerastar added a commit to ESPresense/ESPresense that referenced this pull request Aug 1, 2026
* ci(hil): request the bench BLE flood during device tests

Each step drops a request file in the already-mounted /var/lock/woodpecker and
removes it on exit, so ble-flood.service on the bench host advertises unique
addresses for the duration of the test and stops afterwards.

A directory of per-step requests rather than one on/off flag: the four device
steps finish at different times, and the first one out must not cut the flood
out from under the other three.

Needs ESPresense/firmware-tester#9.

* feat(json): add /json/tele reporting freeHeap, maxHeap and fingerprints

These three numbers already go out over MQTT telemetry, but HTTP only carried
room/ver/firm — so diagnosing a heap complaint meant broker access and
correlating graphs by hand, which is how #2309 stayed unexplained for months.
With them on an endpoint the three failure modes are distinguishable with curl:
freeHeap falling while fingerprints holds steady is a leak, maxHeap falling
while freeHeap holds is fragmentation, both moving with the device count is
churn.

Its own endpoint rather than serializeInfo, for two reasons.

Availability: serveJson refuses with 429 when it cannot afford a 12KB document,
so heap numbers hung off /json would vanish exactly when the node is in the
trouble they describe. serveTele writes a fixed stack buffer and answers at any
heap level.

Cost: /json is polled by the UI, and serializeInfo runs on every /json and
/json/devices request. Putting these there would add a fingerprintMutex
acquisition (contending the BLE scan task) and a second walk of the free-block
list to every poll.

Registered before "/json" because that handler also matches path prefixes —
which is how /json/devices reaches serveJson — and the first match wins.

Size(false) so a GET reports what is there rather than expiring fingerprints as
a side effect of being observed.

Costs 170 bytes of flash on esp32c3; RAM unchanged.

* Apply suggestion from @DTTerastar

* Apply suggestion from @DTTerastar

* fix(json): escape room in /json/tele and refuse rather than truncate

serveTele hand-formatted its body with snprintf, which was wrong twice over.

room is user-set free text (main.cpp: HeadlessWiFiSettings.string("room", ...)),
so a quote, backslash or control character in it produced a malformed body —
and the body is parsed by machines: Home Assistant, the companion, the HIL
monitor.

Worse, snprintf silently truncates. An over-long room yielded half a JSON
object served as 200, which is precisely the "success that lies" failure this
endpoint exists to help people diagnose. It now refuses with 500 instead.

Escaping is ArduinoJson's job, not ours — the builder moves to TeleJson.h and
uses a StaticJsonDocument, keeping the endpoint off the heap so it still
answers when /json cannot. The length check measures the *encoded* size, since
escaping can double it.

Costs 172 bytes of flash on esp32c3 over the snprintf version; RAM unchanged.

Covered by test/test_native_tele: quotes, backslashes, control characters, a
key-injection attempt, oversize, escaping growth, the exact-fit boundary and
degenerate buffers. Needed an [env:native]; it is not in default_envs, so
firmware builds are unaffected (pio run still builds esp32 alone).
CodeRabbit on #9:
- heap_sampler: guard that the /json/tele body is a dict before doc.get(). A
  null/non-object body (the low-heap path) made None.get() raise AttributeError,
  which isn't in the caught tuple — so the sampler thread died silently exactly
  when heap was declining, the one thing this check exists to catch.
- heap_verdict: report the actual sampled span (from sample timestamps) rather
  than the full monitor duration, which overstates the window near the 30-min floor.
- Fix the HEAP_TREND_EDGE comment: median at each end, not average.
@DTTerastar
DTTerastar merged commit 4c4ca9c into main Aug 1, 2026
2 checks passed
@DTTerastar
DTTerastar deleted the hil/ble-flood branch August 1, 2026 18:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant