Commit Graph
27 Commits
Author SHA1 Message Date
Joe 6dfd19ea28 Merge contrib/remote-config into contrib/link-reassoc 2026-10-16 11:34:00 -04:00
Joe 852f6fc2e9 Merge upstream main into contrib/remote-config 2026-10-16 11:30:57 -04:00
JoeandClaude Opus 5.5 13d62b98c0 feat(firmware): POST /link/reassoc -- rejoin the AP when the uplink rate collapses
An ESP32-C6 node whose uplink PHY rate drops to 1-2 Mbps stays there for
24-425 min without help, and that one slow node fills the channel for every
node on it. A fresh association restarts rate control and restores the rate.

POST /link/reassoc (same PSK auth as /config) replies, then drops the link
200 ms later; the existing STA_DISCONNECTED path reconnects with its usual
backoff. A 60 s floor returns 429 to a runaway caller. GET /config reports
reassoc_count and reassoc_last_s_ago. Detection stays with the caller.

Measured on a 9-node C6 fleet, 10-09..10-16: 15 of 15 requests that reached
the node restored its rate within 3 minutes.

Stacked on #1827 (remote configuration).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:34:45 -04:00
Joe 7633794d41 Merge upstream main into contrib/remote-config (led_strip 3.0.x pin)
# Conflicts:
#	firmware/esp32-csi-node/main/idf_component.yml
2026-10-10 17:09:33 -04:00
JoeandClaude Opus 5.5 f1ff58afe7 fix(firmware): pin led_strip to 3.0.x; 3.1.0 breaks ESP-IDF 5.4 builds
led_strip 3.1.0~1 calls rmt_tx_switch_gpio (IDF >= 5.5 API), which is
undeclared on ESP-IDF 5.4 (src/led_strip_rmt_dev.c:189), so every
clean-checkout firmware build fails. Constrain to ~3.0.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:28:05 -04:00
JoeandClaude Opus 5.5 01c16ccab3 Merge origin/main into contrib/remote-config
Resolved conflicts keeping both sides:
- README.md: upstream's provision.py password-file paragraph stays under
  Provisioning Script; the Remote Configuration section follows it.
- ota_update.c: upstream's httpd stack_size (CONFIG_OTA_HTTPD_STACK_SIZE)
  and its comment kept; max_uri_handlers stays at 14 for /config.
- ota_update.h: ota_auth_check() (config_api) and upstream's
  ota_update_log_httpd_stack() both declared.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:20 -04:00
Joeandclaude-flow da77161fcc Merge origin/main: pick up dbaa3c7f's firmware size-gate raise to 1200 KiB
Unblocks the "Verify binary size budget" step of Build firmware
(esp32s3 / 8mb) -- this branch's head already carries the image growth
from 130afaba but was still checked against the old 1152 KiB limit.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-19 00:54:25 -04:00
Joe 95804695fe Merge remote-tracking branch 'origin/main' into contrib/remote-config
# Conflicts:
#	firmware/esp32-csi-node/main/main.c
2026-09-13 22:02:23 -04:00
Joeandclaude-flow 2ab3300fd9 fix(firmware): source cJSON from the component manager on ESP-IDF 6
cJSON ships as the bundled `json` component through 5.x and moved out to the
component manager in v6, where requiring it fails outright:

    Failed to resolve component 'json' required by component 'main': unknown name.

Requiring it unconditionally kept the 5.4 lane green while breaking the 6.0.2
lane -- one toolchain is not the build matrix. `json` is now required only when
IDF_VERSION_MAJOR < 6, and `espressif/cjson` is declared in idf_component.yml
under an `idf_version >=6.0` rule so the two never coexist in one build.

Verified building on esp32c6/16MB with ESP-IDF 5.4.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ
2026-09-10 14:13:46 -04:00
Joeandclaude-flow 36e4df95b7 feat(firmware): give node_log an 8MB home, compile it out on 4MB
node_log currently only has a FAT volume on the 16MB table
(partitions_16mb.csv), so it silently does nothing on the two chips
most of the fleet's OTA/CI surface actually exercises.

partitions_display.csv (8MB) ends at 0x600000 with 2.00 MB of flash
never allocated. Add a storage/fat partition filling exactly that
tail (0x600000 + 0x200000 = 0x800000, the full 8 MB with nothing left
over), so node_log has somewhere to mount on the s3/8mb profile.

partitions_4mb.csv has no equivalent room -- two 1.81 MB app slots
leave only 256 KB free, not enough for a FAT volume of any useful
size. Guard node_log.c's whole implementation on
CONFIG_ESPTOOLPY_FLASHSIZE_4MB and provide no-op stubs for every
entry point, so main.c's call sites need no #ifdef of their own.
Deliberately did not touch main/CMakeLists.txt: fatfs/wear_levelling
stay in REQUIRES, and with nothing left calling into them on 4MB,
--gc-sections drops both archives the same way it already drops
libespcoredump.a when coredump-to-flash is unused (measured at 0
bytes contribution).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ
2026-09-10 13:21:59 -04:00
Joe f06c644b36 Merge remote-tracking branch 'origin/main' into contrib/remote-config
# Conflicts:
#	firmware/esp32-csi-node/main/CMakeLists.txt
2026-09-10 09:52:16 -04:00
Joeandclaude-flow 169597a7d2 fix(signal): the derived soft guard flagged the schedule it was derived from
`MultistaticConfig::for_tdm_schedule` set the soft guard to `cycle_us / 3`,
reasoned about in its own doc comment as "a normal adjacent-slot pair fuses
cleanly". But `fuse_scored` does not compare pairs -- it takes
`max(timestamp) - min(timestamp)` across the whole cohort, so the spread it
measures is always the first-to-last offset of `(N - 1)` slots.

Against that bound:

    soft < stagger  <=>  N*slot/3 < (N-1)*slot  <=>  N/3 < N-1  <=>  N > 1.5

which holds for every N >= 2. The derived soft guard never covered the schedule
it was derived from, at any fleet size, so a complete healthy cycle was recorded
as a `TimestampMismatch` contradiction -- which demotes the BFLD privacy class
one step (ADR-137 §2.7 -> ADR-141) and suppresses raw outputs.

The constructor's own doc example is the smallest demonstration. It exists to
accommodate a MEASURED 18,194 us spread on a 2-slot mesh, and asserts the hard
guard clears it -- but the soft guard it derives for that same mesh is
36,000/3 = 12,000 us, below the measurement that motivated the constructor.

Soft is now the designed stagger, `(slots - 1) * slot_duration_us`, plus the
same 20% jitter headroom the hard guard already takes. The headroom is not
cosmetic: the nominal 2-slot stagger is 18,000 us while the observed spread is
18,194 us, so a bare nominal bound would still flag its own motivating cycle.
`stagger < cycle` always, so the result stays strictly below the hard guard and
a spread approaching a full cycle is still flagged as loose alignment.

Why this survived: every existing assertion on `soft_guard_us` is relational
(`soft < hard`, `soft >= 1`). None ever compared it to a spread the schedule
actually produces. Three tests are added -- the derived guard clears the stagger
across 2..13 slots, a full evenly-staggered cycle raises no contradiction, and a
spread beyond the stagger is still flagged, so the fix cannot be mistaken for
disabling the check. The first two fail against `cycle / 3`; the third passes
either way by design.

Scope: this corrects the DERIVED path only. The hand-set defaults (60 ms hard /
20 ms soft) are unchanged and remain correct for the 2-slot mesh they were sized
against.

Software-only scope. No hardware-accuracy claim is made.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-09 00:23:58 -04:00
Joeandclaude-flow 0d1a247bfe fix(sensing-server): a silent node no longer moves its neighbours' coordinates
`MultistaticFuser::fuse` addressed node positions by the frame's index in the
cohort it was handed. That index is a rank, not an identity: the cohort from
`node_frames_from_states_with_guard` is compacted -- stale, out-of-guard and
empty-history nodes are dropped before `fuse` ever sees them. So one board
missing a beacon re-ranked every node above it and handed each of them its
neighbour's coordinates, silently and fleet-wide.

`MultiBandCsiFrame` already carries its own `node_id`, so the identity was
present at the boundary and simply unused. Positions are now stored keyed by
node id and resolved per frame by that id.

- `set_node_positions_by_id` takes the keyed map; `main` passes the same
  `node_positions_by_id` map that already feeds `NodeInfo`, so one parsed
  identity now serves both the metadata and the effectful path (#1860 fixed
  only the former).
- `set_node_positions(Vec)` is kept as a documented compatibility mapping:
  entry `i` is node `i`, which is exactly what a bare `--node-positions` list
  has always meant. `node_positions()` returns that dense view unchanged.
- `FusedSensingFrame::node_positions` stays parallel to `node_frames`, but each
  entry is resolved from its own frame's id, so the pairing survives compaction.

Behaviour change: with a bare (unprefixed) `--node-positions` list on a fleet
whose ids are not dense and zero-based, a node now reads the entry declared for
its id rather than the entry at its rank. That is the defect being fixed; ids
given explicitly as `node_id:x,y,z` are unaffected, and so is any fleet whose
ids are dense from zero with every node reporting.

Tests: sparse ids, reversed configuration order, a configured-but-absent node,
an unconfigured node, join/leave churn, the preserved legacy positional
mapping, and a negative control pinning the compacted-cohort case. Six of the
eight fail against the pre-fix lookup; the legacy-mapping test is expected to
pass both ways, and order-independence became structural with keyed storage.

Software-only scope. No hardware-accuracy claim is made.

Closes #1866
Refs #1860

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-08 19:55:07 -04:00
Joeandclaude-flow 51102b3baf docs(adr): ADR-345 — per-link CSI attribution and node-to-node ranging
Records why device-free position kept failing here, measured rather than
argued, and the join key built in response. The implementing PRs land without
this and are harder to read for it.

**Phase is unusable on this hardware.** The bistatic tier needed a signed
per-link radial velocity, which requires the common-mode component of CSI
phase. Measured across three ESP32-C6 nodes over ~40,000 frames, the
frame-to-frame change in common-mode phase is statistically indistinguishable
from uniform random: resultant length R = 0.011-0.032 against a 0.000
reference, sd 1.78-1.81 against pi/sqrt(3) = 1.814, no lag-1 autocorrelation.
A second, unwrap-free estimator agreed.

The mechanism is packet-detection timing quantization. Common-mode phase is
-2*pi*f_c*tau, and one ADC sample of jitter at 20 MHz is about 758 rad -- some
120 full wraps, re-randomized every packet. Not calibratable. The bistatic tier
is default-off as a result.

**Amplitude works but had no parallax.** The three AP->node links leave a single
distant transmitter inside a 36.5 degree fan; scalar per-node energy weighting
closed with a clean negative result -- magnitude responds to proximity,
direction does not.

What follows from both: a position tier needs several transmitters seen from
several receivers, and fusing those requires knowing that two nodes observed
the SAME transmission. Arrival timestamps cannot say so -- the sink sees network
jitter, not capture time -- and the per-node sequence counter is private to each
node. Hence (tx_mac, rx_seq): the 802.11 sequence control field is assigned by
the transmitter and is identical at every receiver, so it names one
transmission without any coordination between receivers, no shared clock and no
negotiation.

Status is Proposed, deliberately. The measurement layer is implemented and
validated on hardware; localization is NOT built, and the ADR says so along
with the rest of its known limitations -- peer links are ~10x weaker than data
frames, three links is below the threshold for tomographic localization
regardless of quality, and no position tier consumes the link table.

References only ADR-018, ADR-060 and ADR-110, all already present here.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 13:38:40 -04:00
Joeandclaude-flow 977fa26731 docs: list the firmware runbook in the documentation index
Same reason as the operational content itself: a procedure nobody can find is a
procedure nobody follows. The README's documentation table is where a reader
looks, and the runbook was reachable only by browsing the firmware directory.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 13:03:09 -04:00
Joeandclaude-flow 2841b22cc9 docs: list the frame-selection study in the documentation index
The study was reachable only by knowing the file existed. Nothing in the repo
pointed at it, so a reader following the README would never learn that the
question had been measured at all.

Adds a row to the documentation table with the headline result and the fact
that the harness ships with it. Deliberately states both numbers -- the +31.7 pp
that seq bought and the +0.5 pp that mesh alignment did not -- so the index
entry cannot be read as advocacy for one setting.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 13:02:52 -04:00
Joeandclaude-flow 3fd3851633 docs(firmware): say why seq needs no sync and mesh alignment does
The two options differ in kind. Mesh alignment is an agreement between
receivers and inherits whatever the sync topology does; selecting by rx_seq
needs no agreement, because the 802.11 sequence control field is assigned by
the transmitter and is identical at every receiver. Nodes that have never heard
of each other, sit in different sync domains, or run with no mesh at all still
keep the same subset.

The code shows it: the seq test runs before any sync check and reads only the
frame, while only the mesh branch calls c6_sync_espnow_is_valid(). That
asymmetry is why seq had nothing to degrade in our runs, and it is the thing to
weigh if a fleet's sync is fragile -- seq is not just the better performer
there, it is the option with no dependency that can quietly fail.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:55:38 -04:00
Joeandclaude-flow d6b940184e docs: state that the mesh arm was partly an elapsed arm
The mesh gate uses mesh-time buckets only while c6_sync_espnow_is_valid(); an
unsynced node falls back to the elapsed gate. Three of nine nodes never
synchronised during these runs, so for those three the mesh and mesh+seq arms
silently ran the elapsed gate.

+0.5 pp is therefore not a clean fleet-wide measurement of window alignment.
Reported as observed rather than reconstructed: the arms were assigned before
we understood the fallback, and a post-hoc per-node reanalysis would not be the
experiment we ran.

Names the cause as environmental rather than a defect. Sync is leader-elected
and hub-and-spoke, so whether every node hears the leader is a property of the
building -- and a house that fragments into sync domains degrades any
shared-time option quietly, for exactly the nodes whose data you would most
want.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:50:49 -04:00
Joeandclaude-flow 60adac05b7 docs(firmware): state that our mesh arm was partly an elapsed arm
The mesh gate uses mesh-time buckets only while c6_sync_espnow_is_valid(); an
unsynced node falls back to the elapsed gate rather than gating on a
meaningless epoch. Correct behaviour, but it contaminates the comparison we
published: three of nine nodes never synchronised, so for those three the
setting under test was silently not in effect. The +0.5 pp should be read as
"mesh where sync held, elapsed where it did not", not as a clean fleet-wide
measurement.

Records the house-dependent cause as well. Time sync is leader-elected and
hub-and-spoke: one node broadcasts and the rest follow, so whether every node
hears the leader is a property of the building. In a larger or more divided
house the fleet fragments into sync domains and any option depending on shared
time degrades quietly for the nodes that fell out -- which is exactly the case
where someone would most want to trust the measurement.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:50:28 -04:00
Joeandclaude-flow ec9da648b8 docs(firmware): add the gate comparison as a worked example of house-dependence
The tunables table says which options depend on the building. This shows what
that means with real numbers, and is explicit that they are ours.

Nine-node C6 fleet, 2x2 factorial, three Latin-square blocks: seq selection
doubled cross-node pairing (0.5605 vs 0.2434, +31.7 pp, ~70x the drift floor)
while mesh-time alignment bought +0.5 pp, inside noise.

The point is the reasoning, not the ranking. It records WHY that happened here
-- these nodes already overheard 72% of transmissions in common, so there was a
large shared pool for seq to select from and little left for alignment to
recover -- and says plainly that a house with fewer shared transmitters or
thicker walls has less for seq to work with and may find alignment matters
more. We did not measure such a house and do not claim to know.

Also keeps the trade (pairing bought with raw frame rate; edge processing
unaffected because separately rate-limited, so the cost lands on the uplink)
and the prediction we got wrong: seq is NOT a workaround for broken time sync,
which means repairing our mesh split would not have closed the gap.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:46:35 -04:00
Joeandclaude-flow 0fd1c63563 docs(firmware): document the tunables, and which ones depend on the house
The Kconfig options were discoverable only by opening menuconfig and reading
help text, which requires already knowing they exist. Two of them --
CSI_GATE_MESH_ALIGNED and the thermal throttle pair -- have no universally
right value: they depend on walls, transmitters, enclosure and placement.

Lists all eight with their defaults, marks which are house-dependent, and says
what to measure for each. Notes explicitly that in one measured fleet window
alignment bought +0.5 pp of cross-node pairing while rx_seq selection bought
+31.7 pp, so nobody inherits the assumption that the default is doing the work.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:43:35 -04:00
Joeandclaude-flow 521b29ea21 Merge origin/main into contrib/mesh-aligned-rate-gate
Both sides appended to the end of Kconfig.projbuild -- upstream added the
thermal monitoring menu, this branch adds the two CSI gate options. Kept both.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:29:56 -04:00
Joeandclaude-flow e8e4e5cf67 fix(firmware): define the Kconfig options this gate is guarded by
csi_collector.c guards the mesh-aligned gate on CONFIG_CSI_GATE_MESH_ALIGNED,
but nothing defined it. Upstream's Kconfig.projbuild has no such symbol and
this branch did not add one, so the #ifdef would never be true and the gate
would ship dead: present in the diff, absent from the binary, and silent about
it. The A/B this PR offers would have compared the old gate with the old gate.

Adds CSI_GATE_MESH_ALIGNED (default y, the gate) and CSI_SEQ_DIAG (default n,
the probe that measures its effect rather than assuming it).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:28:57 -04:00
Joeandclaude-flow 9af1cc6644 docs(firmware): add the ESP32-C6 operational runbook
README.md explains the project; there was nothing that said what to run, in
what order, and what to check. This is that, written from the procedures that
were actually used on a nine-node C6 fleet.

Everything in it was learned by getting it wrong at least once, and the
failures it prevents are the silent kind:

- The build must concatenate THREE defaults files. IDF discovers
  sdkconfig.defaults and sdkconfig.defaults.<target> on its own but NOT
  sdkconfig.defaults.16mb, which is where CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
  lives. Build without it and the image is correct in every visible respect and
  cannot roll back a bad OTA, while ota_rollback_boot_check() sits in the app
  expecting the capability.
- A five-key pre-flash gate, because a stale sdkconfig reading FLASHSIZE=4MB
  against a 16MB fleet produces an image that silently is not the configuration
  under test.
- What OTA can and cannot reach, per flash region. Bootloader and partition
  table are USB-only, and there is no bootloader version on the wire -- so
  "every node reports health" proves the app is current and proves nothing
  about the bootloader.
- A single-board diagnostic flash with a revert path: parse otadata first, back
  up the 8 KB, and reverting is writing it back rather than rebuilding.
- min_heap_kib is a minimum-since-boot watermark, not an instantaneous reading.
  Sampling it within a minute of a restart produced a wrong "no effect" verdict
  once; the real answer, once settled, was -56 KiB.
- /api/v1/nodes rssi_dbm is not the AP link and swings 20+ dB with no physical
  change.

No code changes. One file.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 12:26:55 -04:00
Joeandclaude-flow 56fcc49104 Merge origin/main into contrib/server-node-positions
Upstream added a `node_positions_config` of its own while this branch was
waiting, modelled as an ordered `Vec` with a `node_positions_by_active_id`
helper that gives the Nth-lowest ACTIVE node id the Nth entry.

That is the same class of bug this branch exists to fix, one level up: a
position now depends on which OTHER nodes happen to be alive. One node going
quiet re-ranks every node above it and silently moves their coordinates.

Resolved by keying the map by node id throughout -- the struct field, its
initialiser, the local in main() and the helper -- and keeping upstream's
active-node filter unchanged.

Upstream's three helper tests are ported to the keyed form, and one is added
that pins the difference: a stale node must not move its neighbours.

Verified: cargo test -p wifi-densepose-sensing-server --no-default-features
passes.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 10:42:33 -04:00
Joeandclaude-flow 11c014485e fix(server): recover the true CSI frame rate through bursty UDP delivery
`csi_fps_ema` currently under-reports whenever more than one frame arrives in a
burst, and the under-report is proportional to the burst size.

The present scheme rejects sub-5 ms deltas and, crucially, does NOT re-anchor
on them. Holding the anchor means the next accepted delta spans the whole
burst while contributing a single sample -- so a burst carrying N frames is
counted as one. That is invisible when a burst holds one frame, which is the
case the current comment describes, and an exact halving when it holds two.

Two changes, both in the same direction:

- Average in the DELTA domain and invert, rather than averaging 1/dt. The
  reciprocal is unbounded as dt approaches zero, so one 36 us arrival can
  outweigh hundreds of nominal ones; an average of deltas cannot be dominated
  that way. This is what makes it safe to stop discarding short deltas.
- Re-anchor on every arrival. One delta per frame means the deltas sum to the
  elapsed time, which is the condition that makes the estimator equal
  frames/elapsed.

Simulated against the same arrival stream, a node genuinely producing 40 fps
whose frames are delivered in pairs (four frames per 100 ms):

    clean 40 fps, evenly delivered   before 40.0   after 40.0
    bursty 40 fps, pairs per 50 ms   before 20.0   after 40.6

The clean-cadence control is unchanged; the bursty case stops halving.

MAX_PHYSICAL_CSI_FPS is retained and still enforced at the consumption
boundary in `measured_sample_rate_hz`, so restored or malformed state cannot
overclock the DSP math. The estimator itself is left unclamped so that a rate
above the ceiling remains visible as the anomaly it is rather than being
silently flattened to 50.

TRADE-OFF, stated plainly: alpha moves from 1/8 to 1/32, so the estimate is
about three times slower to settle after a genuine rate change. That is the
price of not letting a single short delta swing it, and the steady-state test
horizon is widened to match.

Tests: `observe_csi_frame_arrival_ignores_subms_bursts` is replaced. It fed
three arrivals per 25 ms group -- 120 frames per second, which no single node
can emit under the firmware's own 50 fps send ceiling -- and asserted the
result should read 40, which only holds if two of every three real frames are
discarded. The replacement uses a physically consistent stream and asserts the
same 40 fps ground truth.

Full suite passes.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 09:57:16 -04:00
Joeandclaude-flow 86e45980fe chore: ignore host-test binaries by shape, not by a list that goes stale
`make host_tests` writes its binaries beside their sources, inside a tracked
directory. .gitignore named five of them individually, and the list had fallen
behind the Makefile: test_csi_sanitize, test_mmwave_detect and test_thermal
were built by the default target, ignored by nothing, and got committed by
accident on two contribution branches.

Enumerating targets cannot hold -- every new host test needs someone to
remember this file. So ignore by shape instead: in that directory every source
carries an extension and no binary does.

    firmware/esp32-csi-node/test/test_*
    firmware/esp32-csi-node/test/fuzz_*
    !firmware/esp32-csi-node/test/*.*

Verified rather than reasoned about. After building every host test,
`git status --porcelain` reports only this file. `git check-ignore` confirms
all ten binaries are ignored -- including test_edge_grid_he and
test_edge_grid_pre_he, which are not on main yet -- while the .c sources, the
Makefile, the README and the corpus seeds stay tracked.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
2026-09-07 08:15:00 -04:00