Commit Graph
1381 Commits
Author SHA1 Message Date
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
rUv d613a576ea Merge pull request #1849 from ruvnet/codex/calibration-grid-integrity-20260906
fix(calibration): harden empty room bootstrap integrity
v2567
2026-09-06 12:25:17 -04:00
ruv 97578ee401 fix(firmware): make host dead stripping portable 2026-09-06 11:51:09 -04:00
ruv e54b4171ca fix(calibration): bind field models to stable CSI grids 2026-09-06 11:38:31 -04:00
ruv 338c523217 fix(calibration): tolerate measured UDP reordering 2026-09-06 11:38:31 -04:00
ruv 224c9eb671 feat(calibration): expose CSI admission receipts 2026-09-06 11:38:31 -04:00
ruv 2deb6d2d64 fix(calibration): require unique raw CSI samples 2026-09-06 11:38:31 -04:00
ruv 82e05d0664 fix(calibration): expire live field models safely 2026-09-06 11:38:30 -04:00
ruv a2c662f9f7 fix: harden CSI timing and baseline reliability 2026-09-06 11:38:30 -04:00
ruv 48c6244d37 fix(calibration): learn held out empty residuals 2026-09-06 11:38:30 -04:00
ruv 30404dd9cf feat(calibration): select explicit baseline source 2026-09-06 11:38:30 -04:00
rUv c4021f9884 docs: add WiFlow reproduction protocol (#1787) v2552 2026-09-05 09:42:43 -04:00
rUv 2227b42841 feat(calibration): learn empty room startup confidence (#1840) v2547 2026-09-04 23:02:06 -04:00
Joeandclaude-flow 767c36ad12 docs: measured comparison of three CSI frame-selection gates, and the harness
How a node rate-limits CSI callbacks decides whether cross-node fusion works at
all, because it decides whether two nodes keep the SAME frames or merely the
same NUMBER of frames. We measured three gates on a nine-node ESP32-C6 fleet as
a 2x2 factorial, three Latin-square blocks, every arm verified live on every
node.

Selecting frames by 802.11 rx_seq roughly doubled cross-node pairing over the
elapsed-time gate: 0.5605 against 0.2434, +31.7 pp, reproduced in all three
blocks at ~70x the measured drift floor.

Two results we did not want and are reporting anyway:

- Mesh-time alignment -- a gate we built and expected to be the answer -- bought
  +0.5 pp, about one standard deviation of noise. Indistinguishable from doing
  nothing.
- We predicted rx_seq selection would win most on node pairs spanning a broken
  sync domain. Per-pair yield says the opposite: never-synced nodes gained
  LEAST. So it is not a workaround for broken time sync, and repairing our mesh
  split would probably not have closed the gap.

The document leads with the method rather than the numbers, because the numbers
are one house. It records the three mistakes that made an earlier attempt
unreadable -- comparing different node subsets, not verifying the gate was
actually live, and using frame rate as the check that a gate is working when a
bucket gate makes frame rate nearly insensitive to it -- and the null run that
establishes the noise floor without which none of the contrasts can be judged.

run_arms.py is included so a reader can measure their own environment instead
of trusting ours. That is the point of the contribution; our figures are the
worked example.

Stated as a trade, not a win: pairing is bought with raw frame rate. On-device
edge processing was unaffected here (separately rate-limited), so the cost fell
on the uplink -- but whether that is acceptable depends on what consumes the
stream.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-04 17:46:00 -04:00
rUv 2e9e87c65c feat(calibration): persist safe empty room bootstrap prior (#1826) 2026-09-04 16:31:38 -04:00
rUv 8d1a67d31b Merge pull request #1793 from clonea1/contrib/server-classification-stability
fix(sensing-server): scale smoothing to the measured CSI rate, and debounce the room
v2494 v2493 v2495 v2492 c6-fleet-reliability-2026-09-04 v2491 v2498 v2497 v2496
2026-09-04 12:19:54 -04:00
rUv c19b846394 Merge pull request #1791 from clonea1/contrib/server-node-positions
fix(sensing-server): wire --node-positions into live NodeInfo output
2026-09-04 12:18:22 -04:00
rUv cf6e955196 Merge pull request #1795 from clonea1/contrib/wifi-retry-watchdog
fix(firmware): retry WiFi forever, and add an uplink watchdog (stacked on #1797)
2026-09-04 12:17:41 -04:00
rUv dddc214a25 Merge pull request #1797 from clonea1/contrib/thermal
feat(firmware): thermal monitoring, radio throttling and node health
2026-09-04 12:17:06 -04:00
rUv 5da64c6fc5 Merge pull request #1800 from TsinbeiLabs/fix/calibration-wall-clock-gate
fix(calibration): gate finalize on wall-clock duration, expose elapsed_s and frames/s
2026-09-04 12:16:45 -04:00
rUv 1c088e67c2 Merge pull request #1794 from clonea1/contrib/ui-no-fabricated-data
fix(ui): never present invented data as measured
2026-09-04 12:16:41 -04:00
rUv 1231088427 Merge pull request #1796 from clonea1/contrib/disable-unused-154-radio
perf(firmware): stop the C6 sharing its radio with a stack that cannot receive
2026-09-04 12:16:36 -04:00
rUv 4d857a908f Merge pull request #1792 from clonea1/contrib/subcarrier-grids-256
fix(firmware): stop truncating HE20 CSI to half its subcarriers on C6/C5
2026-09-04 12:16:32 -04:00
Joeandclaude-flow d6347fe12e fix(sensing-server): key node positions by node id, not by list position
Fixes the indexing mismatch found in review. Positions were inserted keyed by
the entry's index in the parsed list and read back by the node's actual
node_id:

    for (idx, p) in positions.iter().enumerate() {
        node_positions_config.insert(idx as u8, *p);   // key = index
    }
    ...
    s.node_positions_config.get(&id)                   // lookup = node_id

On any fleet not numbered 0,1,2,... every lookup misses and falls through to the
hardcoded [2.0, 0.0, 1.5] that --node-positions exists to replace. The config
reads as applied and does nothing, which is worse than not having wired it up.
It passed here only because these nine boards happen to be 0..8.

--node-positions now accepts an explicit form alongside the positional one:

    x,y,z;...           positional, identity is the index (unchanged)
    node_id:x,y,z;...   explicit, identity is the stated id

Both are accepted and may be mixed, so existing invocations keep working.
Coordinates never contain a colon, so the split is unambiguous. An unparseable
or out-of-range id is skipped rather than guessed at -- falling back to
positional there would reintroduce the ambiguity being removed, and 256 must
not wrap to node 0.

Explicit ids are the better form on a real fleet: with the positional one, the
binding of a position to a board is implied only by argument order, which is
exactly the fragility that produced this bug.

MultistaticFuser::set_node_positions() is deliberately left alone. It consumes
the list positionally, matched to per-frame iteration order rather than to node
id -- a real and separate concern, but this change is about the NodeInfo output
and quietly altering fusion semantics inside it would be the wrong scope. The
fuser receives the same list, in the order given, as before.

The map is now built by field_bridge::node_positions_by_id() rather than a loop
in main(). That is the point: the defect lived in a loop no test could reach.
Nineteen tests cover it, including non-sequential ids (11,12,13), a fleet
re-homed to 0,2,4,...,16 with two-digit ids, a fleet that does not start at
zero, and an assertion that the list indices are NOT keys -- which is the bug
stated directly. The four pre-existing parse tests are untouched and still pass,
so the positional path is unchanged.

265 server tests pass.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-04 11:53:40 -04:00
ruv 9b595b8333 fix(sensing-server): key --node-positions by active-node rank, not node_id
The prior fix stored the parsed --node-positions list keyed by list index
under the assumption that node_id == index, then looked it up by the
node's real node_id. Real fleets use non-sequential logical node IDs
(11, 12, 13, ...), so that lookup silently misses for every realistic
deployment and falls through to the same [2.0, 0.0, 1.5] placeholder the
original bug report was about.

MultistaticFuser::fuse assigns node_positions[i] to the i-th frame in its
node_frames argument, which callers build sorted ascending by node_id.
Match that same convention in the NodeInfo JSON builder: rank currently-
active node_ids ascending and index into the configured position list by
rank, so live position output agrees with what fusion actually used.

Adds node_positions_by_active_id() (shared by both call sites) plus
regression tests for non-sequential IDs, stale-node exclusion from rank
assignment, and the missing-config fallback.
2026-09-04 11:48:11 -04:00
Joe 944b7bea77 fix(firmware): retry WiFi forever; add an uplink watchdog
A node that lost WiFi could give up permanently. The disconnect handler
counted retries in s_retry_num, which is only ever cleared on a successful
IP_EVENT_STA_GOT_IP, so it accumulated across the node's entire uptime.
After ten cumulative disconnects it set WIFI_FAIL_BIT and never called
esp_wifi_connect() again. No backoff reset, no periodic retry, no reboot.
Only a power cycle cleared it.

MEASURED 2026-08-30: the AP serving the fleet took a firmware update and
rebooted at ~03:38. All three wall-mounted nodes stopped delivering within
three minutes of each other (nodes 0 and 1 at 03:37:03, to the second) and
none recovered for 5.5-7.5 hours, until each was physically unplugged. CSI
capture ran the whole time -- node 0's sync sequence advanced 16600 ->
46340 at a steady ~30/s while it was invisible to the server -- because
capture is driver-level and needs no IP. The boards looked alive on a
bench and dead to the aggregator.

Retry is now unbounded with 500 ms -> 30 s exponential backoff, scheduled
on an esp_timer one-shot so esp_wifi_connect() is never reached through a
delay inside the event handler. Two things that were conflated are now
separate: WIFI_BOOT_WAIT_ATTEMPTS only releases app_main from its startup
wait so the rest of the node can boot; nothing stops the node retrying.

The uplink watchdog covers the failures we have not diagnosed: no
successful send for CONFIG_UPLINK_WATCHDOG_TIMEOUT_S restarts the node.
The signal is a successful sendto(), which on UDP means the stack accepted
the datagram, NOT that the server received it -- so stopping the
aggregator cannot reboot the fleet. It arms on the first successful send
rather than at boot, so a node powered up while the AP is down does not
reboot-loop; the unbounded reconnect owns that case.

The timeout is 1200 s, sized from a 112-minute soak rather than a guess:
30 observed silences across two nodes, median 225 s, one 510 s outlier
that looked like two cycles running together. An earlier 120 s would have
rebooted healthy nodes every few minutes, and even 600 s left only 90 s of
margin over an event already seen once. Do not lower it below ~900 s while
the ~225 s stalls are unexplained; a needless reboot re-runs ESP-NOW
leader election and discards mesh time-sync state.

Also guards thermal.c's apply_tx_power behind CONFIG_THERMAL_THROTTLE so a
monitor-only build (the default, and what is on the wall) stops warning
about an unused function.

Kconfig gains CSI_GATE_MESH_ALIGNED here alongside the watchdog options;
its implementation lands in a later commit and the option is inert until
then.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit bb6d954de68fd4d542e191fd5e82c753f67a9874)
2026-09-04 11:31:58 -04:00
Joeandclaude-flow 7f9df37380 fix(firmware): damp every thermal step down, not just the one into OK
Follow-up to the review on #1797. The CRITICAL -> THROTTLED edge was
unconditional: any reading below critical_c dropped the state immediately, so a
node sitting on the critical boundary flapped between TXP_MIN and TXP_STEP2
indefinitely. Every oscillation moves that node's RSSI at both ends, which a
reader cannot distinguish from an obstruction -- exactly the failure the
hysteresis on the other edges was added to prevent.

Now every falling edge requires cooling a full hysteresis band below the
threshold it crossed on the way up; rising edges stay immediate, because heat is
a reason to act now. Verified by dithering across the boundary: six readings
either side of 80 C previously produced five state changes, now zero.

The decision moved into thermal.h as a pure function, following the convention
edge_processing.h already uses for edge_evidence_person_count -- a host test
then exercises the exact function the device build runs rather than a copy free
to drift.

BEHAVIOUR CHANGE worth calling out, beyond what the review asked for. The old
`recover` point was a single global threshold (throttle_c - hyst) that every
state fell back through, so leaving WARN required cooling to 64 C -- a bound
derived from the throttle threshold, not WARN's own. Hysteresis is now relative
to each state's own entry point, so WARN releases at 57 C instead. Leaving
THROTTLED is unchanged at 64 C. Kconfig help updated, since it described the old
throttle-relative semantics.

One consequence the test pins explicitly: with the default constants the
critical exit point (80 - 8 = 72) coincides exactly with the throttle threshold,
so a node cool enough to leave CRITICAL is already below THROTTLED's entry and
lands in WARN. THROTTLED is unreachable descending from CRITICAL at hysteresis
8. That falls out of the chosen constants rather than the logic, and the test
covers hysteresis 4 as well to show the intermediate step reappears when the
bands do not coincide. My first version of the test asserted THROTTLED there and
was simply wrong -- running it caught that.

Verified in espressif/idf:v5.4: against the unfixed transition the test fails
five checks including the dither, passes with the fix, and the C6 firmware
builds. Still no hardware evidence for this PR -- a real boot log is coming
before merge, as promised earlier in the thread.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-04 11:29:27 -04:00
Hsukqi Lee f574450516 fix(calibration): gate finalize on wall-clock duration, expose elapsed_s and frames/s
Signed-off-by: Hsukqi Lee <team@tsinbei.com>
2026-09-04 20:15:38 +08:00
Joeandclaude-flow 3621a211bd fix(sensing-server): debounce the room-level classification too
`fuse_room` recomputes its plurality vote fresh every cycle with no memory of
its own, unlike each node's classification, which is debounced individually.

With per-node confidences sitting near the classification boundary -- 44%, 83%
and 53% observed simultaneously across three nodes -- whichever two of three
happened to agree on a given cycle could flip the room-level result while every
node's own reading was individually stable. The room read
absent / present_still / absent / present_still in rapid succession.

That cascaded into everything gating on presence: the persons array, the
rendered figure appearing and disappearing, the live position dot. Each of
those looked like its own bug, and none of them was.

Adds the missing room-level hysteresis. A new candidate classification must be
observed continuously for ROOM_DEBOUNCE_DURATION_SECS before it is adopted; an
interrupted run restarts it. The threshold is a DURATION rather than a cycle
count, for the same reason as the per-node fix in the previous commit: a count
means a different real interval at every rate.

Debouncing at the room level does not make the per-node debounce redundant.
They answer different questions -- "is this node's reading stable" and "do the
nodes agree stably" -- and the observed failure was agreement flapping while
every individual reading held steady.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-03 17:16:02 -04:00
Joe 5eb8ddde12 fix(sensing-server): scale per-node smoothing to actual CSI rate
DEBOUNCE_FRAMES, MOTION_EMA_ALPHA, and BASELINE_EMA_ALPHA were tuned
assuming CSI frames arrive at ~10 FPS (per their own doc comments).
smooth_and_classify_node() runs once per raw incoming UDP frame, and
ESP32-C6 boards measured in the field run at ~48-50 FPS - about 5x
faster. That silently compressed a ~0.4s debounce window to ~0.08s
and a ~1s motion EMA to ~0.2s of real smoothing, letting per-frame
CSI noise flip the reported motion_level almost directly instead of
being smoothed - the reported UI "seizure" of rapid label flicker.

Rather than hardcode a different fixed rate (which would just move
the same bug to whatever chip/capture-mode/config next diverges from
it), derive each frame's effective alpha and frame-count thresholds
from the node's own measured csi_fps_ema, which is already tracked
and burst-filtered elsewhere (observe_csi_frame_arrival). Time
constants are back-derived from the original alphas at the assumed
10 FPS point, so behavior is unchanged for a node at exactly 10 FPS
and now scales correctly to whatever rate a node actually runs at.

smooth_and_classify() (simulated/WiFi-scan sources, already tick-
gated at a known fixed cadence) is unaffected - not exhibiting this
bug, left as-is.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit d29d9fb02078b066be8557af24023c22e99fa3d1)
2026-09-03 17:12:18 -04:00
Joeandclaude-flow fbecae4e02 fix(ui): never present invented data as measured
The dashboard flip-flopped between "absent" and "present_still" after
navigating away and back. The cause was not sensing. On a dropped socket the
client fell back to a built-in simulation and rendered synthetic values that
were indistinguishable from live ones, so the UI was showing made-up readings
and saying nothing about it.

That is the most damaging failure this UI can have. A sensing system that
invents data silently is worse than one that shows nothing, because every
downstream judgement -- including which nodes to move and whether a change
helped -- is then made against fiction.

Changes:

  - Client-side simulation is default-OFF and must be opted into explicitly.
  - An unreachable server retries forever instead of substituting data.
  - A banner states plainly whenever what is displayed is not live. The
    labels say what they mean: "NO DATA - SERVER UNREACHABLE" for an
    unreachable server, and "INVENTED DATA - NOT MEASURED" for simulation,
    replacing "OFFLINE - CLIENT SIMULATION", which read like a status rather
    than a warning.

Also fixes a reconnect race in the same path: two in-flight connects could each
install handlers, so a stale socket kept overwriting fresher state. That race is
what made the flip-flop intermittent and hard to attribute.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-03 11:42:16 -04:00
rUv de099c1f2d feat: train real fal.ai synthetic visual teacher LoRA (ADR-353)
feat: train real fal.ai synthetic visual teacher LoRA (ADR-353)
v2475 v2473
2026-09-03 10:35:12 -04:00
rUv 01460b98b0 research: real zero-shot pretrained-checkpoint eval beats naive baselines (RuForecast)
research: real zero-shot pretrained-checkpoint eval beats naive baselines (RuForecast)
2026-09-03 10:35:09 -04:00
rUv 26f444138e chore(forecast): bump ruforecast submodule to governance/spend enforcement fix
chore(forecast): bump ruforecast submodule to governance/spend enforcement fix
2026-09-03 10:34:57 -04:00
Joeandclaude-flow 00737441c4 perf(firmware): stop the C6 sharing its radio with a stack that cannot receive
Every C6 node spends coexistence time-slicing on 802.15.4 -- a single 2.4 GHz
transceiver is shared between WiFi, BLE and 15.4 -- against a receive path that
provably delivers nothing. That time competes directly with CSI capture, which
is the node's entire purpose, and it matters most on a large fleet where the
50 Hz receive gate is already the binding constraint.

The evidence that it cannot receive is upstream's own (ADR-110 D1,
ruvnet/RuView#762): five configurations tested -- channel 15 and 26, OpenThread
on and off, promiscuous on and off, PAN-matched and broadcast -- and every one
gave rx=0 while TX stayed 100% reliable on IDF v5.4. ESP-NOW shipped as the
workaround and is what this firmware actually syncs on. The stack was
nonetheless still initialised at boot and beaconing every 100 ms.

Safe to remove: the ADR-018 byte 19 bit 4 "sync valid" flag is ORed from both
transports and the 15.4 branch is #ifdef-gated on this symbol, so the working
ESP-NOW path keeps setting it. c6_timesync_init is gated on the same symbol and
is no longer called.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-03 08:53:09 -04:00
Joe 25d7be3c25 fix(sensing-server): wire --node-positions into live NodeInfo output
Node position parsing/storage for multistatic fusion worked correctly,
but the per-node JSON builder (both the vitals-only and governed/
privacy-gated ESP32 live-data paths) never read the parsed positions -
it always emitted the hardcoded fallback [2.0, 0.0, 1.5] regardless of
--node-positions. Store the parsed positions in AppStateInner, keyed
by 0-based index (= node_id), and read from it at both call sites.

Matches upstream issues #228, #249, and the "Known Issues" #301 symptom
class ("same output regardless of position").

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit d34d76c54e7ba4a466cf9dc27060684f49756b6b)
2026-09-03 08:00:30 -04:00
Joeandclaude-flow 29b3968731 fix(firmware): size the subcarrier buffers for HE20, not just pre-HE
EDGE_MAX_SUBCARRIERS is a flat 128, which silently truncates CSI on any part
that carries HE20. An ESP32-C6 or C5 reports 256 subcarriers per frame; every
buffer sized by this constant holds half of them, and edge_process_frame()
returns early on n_subcarriers > EDGE_MAX_SUBCARRIERS, so on those parts the
edge pipeline stops entirely -- no log past init and no vitals packet ever
reaching the sink.

Gated on CONFIG_SOC_WIFI_HE_SUPPORT, the same switch csi_collector.c already
uses to pick its rx_ctrl layout, so pre-HE parts keep 128 and do not pay ~3.5 KB
of .bss they can never use.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-02 23:53:02 -04:00