103 Commits
Author SHA1 Message Date
ruv 26e8ca3f39 Merge upstream/main, resolve README.md table-row conflict 2026-09-09 08:40:14 -04:00
rUv 755e9bca05 Merge pull request #1859 from clonea1/contrib/mesh-aligned-rate-gate
feat(firmware): align the CSI rate gate to mesh time
2026-09-09 12:38:23 +00:00
rUv f38beff441 Merge pull request #1867 from ruvnet/feat/c6-xiao-antenna-select
feat(firmware): add XIAO ESP32-C6 antenna selection
2026-09-09 12:35:50 +00:00
Dragan Spiridonov a4d49f76b9 feat(firmware): add XIAO C6 antenna selection 2026-09-08 14:12:41 +02: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 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
ruv 97578ee401 fix(firmware): make host dead stripping portable 2026-09-06 11:51:09 -04:00
ruv a2c662f9f7 fix: harden CSI timing and baseline reliability 2026-09-06 11:38:30 -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 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 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
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
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
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
Joe ed006abd58 refactor(firmware): separate acting on temperature from measuring it
Reading the sensor is inert. Reducing transmit power moves every RSSI the node
has, at both ends of every link -- introducing that during a three-to-nine node
bring-up would give any surprise two candidate causes and no way to separate
them.

So CONFIG_THERMAL_THROTTLE is now its own option, default off, while
monitoring stays on. The first flash therefore reports die temperature and
peak without ever changing behaviour, which is exactly what is needed to
answer whether these boards run hot in enclosures at all.

If none of them ever does -- the likely outcome at ~0.4 W average in a vented
box -- throttling never needs enabling and the thresholds cost nothing.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit 187eb17ba9549a96aa63ce0fda37888c8c65650c)
2026-09-02 22:44:24 -04:00
Joe 5419bff2a8 feat(firmware): thermal monitoring, radio throttling, reset reason, heap health
The C6 carries a temperature sensor this firmware has never read. Tolerable on
a bench board; not once nine nodes are sealed in printed enclosures on a wall
with the radio transmitting continuously.

What is protected is the ENCLOSURE, not the silicon. The part is rated far past
anything it sees here, but PLA softens at its glass transition (~55-60 C) and
sags under its own weight over weeks -- a failure that never announces itself.
Thresholds are set against DIE temperature with that offset in mind: ~72 C on
the die in a small vented box is roughly a 50-55 C enclosure. Setting the trip
at 55 would throttle a perfectly healthy node.

Throttling reduces WiFi transmit power in 3 dB steps, the dominant heat source
and the one that degrades gracefully -- links weaken but keep working, whereas
cutting capture rate would blind the node. Recovery has 8 C of hysteresis
because oscillating around the trip point would put a visible RSSI step on
every link the node has, repeatedly.

`thermal_tx_dbm()` and `thermal_state()` are exported for a specific reason: a
throttled node's RSSI falls at both ends, which is indistinguishable from an
obstruction in the link diagnostics. Anything reading link strength must be
able to tell a hot node from a wall.

Also records two things nothing tracked before:

- **Reset reason at boot.** A node that reboots tells you almost nothing; one
  that reboots with ESP_RST_BROWNOUT tells you its supply sagged, which on
  shared outlets is a wiring problem rather than a firmware one. Three nodes
  dropping together on 2026-08-28 would have been answered by this one line.
- **Free and minimum-free heap** in the periodic log. A slow leak and an
  overheat look identical after ten days unless the minimum has been visibly
  walking downward the whole time.

Kconfig-gated (default y on C6), thresholds configurable. Builds clean at
0xfe990, 45% partition free.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit 898295fba7b3873b37e85a5ffc0ec87fe609aa23)
2026-09-02 22:44:09 -04:00
Joe 7cdf5714e8 feat(firmware): align the CSI rate gate to mesh time
HOW the 50 Hz rate is limited decides whether cross-node fusion is
possible at all, and the original gate quietly made it impossible.

The gate was "at least 20 ms since MY last accept". Each node therefore
has an independent phase: node A can accept at t=0,20,40 ms while node B
accepts at 7,27,47. Both are perfectly healthy, both hit 50 Hz, and they
accept disjoint frames -- so no two nodes ever hold a measurement of the
same transmission and there is nothing to pair.

MEASURED 2026-08-30 with two boards side by side, logging (tx_mac,
rx_seq) before and after the gate: they HEARD the same transmissions 72%
of the time but only 25% of ACCEPTED frames were common. The frames were
there; the gate was throwing away the pairing. (Also settles an open
question from ADR-345: rx_seq is populated -- 4099 distinct values across
~44,000 frames, both nodes reporting the identical range.)

Now gates on a bucket of the mesh-aligned epoch, so every node takes the
first frame it hears in the same absolute 20 ms window and frames both
nodes heard are frames both nodes keep. Mesh sync is good to roughly
490 us against a 20 ms bucket -- about 2.5% of a window -- so boundary
disagreements are rare.

Falls back to the original elapsed-time gate whenever
c6_sync_espnow_is_valid() is false, because an unsynced epoch would
otherwise gate on a meaningless number; a node that has not heard a leader
yet simply gets the old behaviour. The half-interval floor is deliberate:
bucket gating alone could accept at the end of one window and the start of
the next, back to back, and this gate exists to prevent an SPI flash cache
crash in the WiFi ISR, not for tidiness.

Behind CONFIG_CSI_GATE_MESH_ALIGNED (default y) so it can be A/B'd against
the old gate; CONFIG_CSI_SEQ_DIAG measures the difference directly.

NOT yet deployed to the fleet, and not yet enough on its own: the wire
format carries a per-node counter, not rx_seq, so the server still cannot
pair frames. Wire v3 has to carry rx_seq before any of this is usable off
a serial cable.

Co-Authored-By: claude-flow <ruv@ruv.net>
(cherry picked from commit 4b54ef8ecb2a28d173d411a064809dc081c43f52)
2026-09-02 22:43:27 -04:00
ruv 12a61c16e8 docs(firmware): prepare 0.8.8 release 2026-08-31 14:14:08 -04:00
ruv f85896cccb feat(firmware): stabilize rate-aware ESP32 sensing 2026-08-31 12:56:32 -04:00
ruv 0a0b3411f8 fix: fail closed contradictory ESP32 occupancy evidence 2026-08-31 12:56:32 -04:00
ruv 5780c239e4 security: repair scanning and close stale alert sources 2026-08-02 15:22:13 -04:00
ruv 8c0bdaef92 fix open issue release blockers 2026-07-18 18:01:23 -04:00
Matías J. Apablazaandclaude-flow 6f0114c488 fix(firmware): accept WPA/WPA2-mixed routers; warn about compact-board thermal risk
Lower the STA auth threshold from WPA2_PSK to WPA_PSK so routers running
WPA/WPA2-mixed compatibility mode aren't rejected with
WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD (reason=211), and log the
disconnect reason/rssi instead of retrying blind. Also document the thermal
risk of running this firmware's continuous-radio, tier-2 DSP pipeline on
coin-sized clone boards (ESP32-S3-Zero, SuperMini) with minimal PCB copper
and budget regulators, after a field report of three such boards failing to
power on following a normal session.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-18 17:26:17 -04:00
Matt Van Horn c2cf180afe docs: align firmware ESP-IDF version references to v5.4 (fixes #1177 b 2026-07-18 17:26:16 -04:00
ruv 83f549d308 fix: post-review fixes for PRs #1311/#1312/#1313 + CHANGELOG
- sdkconfig.defaults.devkitc: header build command idf v5.2 -> v5.4
  (source needs esp_driver_uart, IDF >=5.3 — same reason the PR fixed
  the README refs)
- engine/lib.rs: separate doc comments — set_room_adapter's ADR-150
  adapter/witness doc had merged into set_multistatic_config's doc
- ui: apply stored bearer token at api.service.js module load instead
  of QuickSettings.init — services + dashboard tab dispatch their first
  /api/v1/* requests before initializeEnhancements() runs, so the first
  requests on every load went out without the Authorization header
- CHANGELOG: Unreleased entries for #1308/#1309/#1310

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-14 11:08:37 -04:00
erichkusukiandclaude-flow 0a282d0870 fix(firmware): add display-less DevKitC-1 build overlay; update stale IDF v5.2 refs to v5.4
The ADR-045 display probe false-positives on display-less
ESP32-S3-DevKitC-1 boards (SH8601 init 'succeeds' against floating
pins), so main.c skips the RuView#893 MGMT+DATA promiscuous upgrade
and CSI yield collapses to 0 pps. sdkconfig.defaults.devkitc compiles
display support out, making has_display constant-false so the #893
upgrade always applies. Hardware-verified on 2x DevKitC-1-N16R8:
0 pps -> steady 40-45 pps.

Also updates the Quick Start build commands and badge from
espressif/idf:v5.2 to v5.4 — current source uses esp_driver_uart
(IDF v5.3+) and no longer builds under v5.2; CI already uses v5.4.

Addresses #1308

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-07-11 13:32:01 +02:00
rUv fca5e6f0a0 fix: multistatic canonicalization, csi_fps burst inflation, control-packet starvation (#1170, #1180, #1183) (#1193)
#1170 — live multistatic bridge fed raw, un-canonicalized per-node CSI
(64/128/192 bins) to MultistaticFuser, tripping DimensionMismatch every
cycle and silently disabling fusion on mixed HT20/HT40 meshes. Add
HardwareNormalizer::resample_to_canonical (resample-only, no z-score) and
canonicalize every node frame onto the 56-tone grid before fusion.

#1180 — update_csi_fps_ema only rejected dt<=0 or >=1s, so sub-ms UDP-burst
arrivals (36us -> ~27kHz) inflated csi_fps_ema 40-840x. Add a 5ms plausibility
floor and stop re-anchoring observe_csi_frame_arrival on burst deltas.

#1183 — global ENOMEM backoff (CSI flood) starved <=48B/<=1Hz control packets.
Add stream_sender_send_priority() bypassing the backoff gate without touching
the streak; route feature_state/HEALTH/sync through it. Fix the misleading
"HEALTH sent" log that printed even on rv_mesh_send failure.

Verified: signal 501, sensing-server 677 tests (0 failed); firmware builds clean.


Claude-Session: https://claude.ai/code/session_01AgpTcBLRJ32hUsKWxDXf36
2026-06-27 13:04:44 -04:00
rUv 7831f29436 fix(firmware): phantom LD2410 detection + ENOMEM backoff (#1135) (#1159)
Bug #2 (root cause): LD2410 probe-detection matched only the 4-byte head
0xF4F3F2F1, so a floating UART at 256000 baud could phantom-detect a sensor
and spawn a UART task. Now requires a full validated report frame (head +
sane length + tail 0xF8F7F6F5), extracted to mmwave_detect.h and shared with
a host unit test (test_mmwave_detect.c, 8 vectors) so firmware and test can't
diverge. Matches the validate-before-trust approach used for MR60 in #1107.

Bug #1: sendto ENOMEM used a fixed 100 ms backoff too short to drain sustained
lwIP/WiFi buffer pressure, so a node could stay stuck. Now exponential
(100->200->...->2000 ms per consecutive ENOMEM, reset on first successful
send). Removing the phantom LD2410 task (bug #2) also removes the extra load
that tipped the reporter's tier-2 node into the stuck state.

Validated on ESP32-S3 QFN56 rev v0.2 (the reporter's silicon): tier-2 streams
~100 frames/s with no stuck ENOMEM and correctly reports no mmWave (no
phantom). LD2410 predicate truth table proven (head-without-tail REJECTED).
Could not reproduce the reporter's environment-specific floating-pin noise, so
the deterministic proof is the host unit test.
2026-06-22 12:31:21 -04:00
rUv 4bf88e1283 feat(firmware): gate LED gamma viz behind CONFIG_LED_GAMMA_VIZ (ADR-183 follow-up) (#1129)
The 40 Hz gamma flicker is now Kconfig-gated (default y, unchanged
behaviour). Set CONFIG_LED_GAMMA_VIZ=n for a dark, lower-power boot (the
LED is simply cleared) — important for photosensitive deployments, no
source edit needed. The colormap saturation point is now operator-tunable
via CONFIG_LED_MOTION_FULLSCALE_MILLI (default 250 = 0.25).

Build + flash confirmed on ESP32-S3 N16R8 (COM8): default y still arms the
gamma timer, CSI flows. ADR-183 updated (gate moved from follow-up to done).
2026-06-17 22:22:20 -04:00
rUv a4c2935a2f feat(firmware): onboard LED 40 Hz gamma stimulus + CSI-motion colour (ADR-183) (#1127)
* chore(deps): bump ruv-neural submodule — ColorMap no_std for ESP32

Points to ruvnet/ruv-neural#3 (c9638fa): ruv-neural-viz::ColorMap now
builds no_std, so it can run on the ESP32. Unblocks driving the onboard
WS2812 from the viridis/cool-warm colormap.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(firmware): onboard LED as 40 Hz gamma stimulus, colour from live CSI motion (ADR-183)

The S3 onboard WS2812 (GPIO 48, #962) now runs a GENUS-style 40 Hz gamma
square wave (12.5 ms on/off, 50% duty). The ON-phase colour is live CSI
motion (edge motion_energy) mapped through a 60-step viridis LUT generated
from ruv-neural-viz::ColorMap::viridis() — still=purple, moving=yellow.

Uses the now-no_std ColorMap (ruvnet/ruv-neural#3 / #1126). Hardware-
confirmed on ESP32-S3 N16R8 (COM8): boot log shows the timer armed, CSI
keeps flowing (27-38 pps). Honesty + photosensitivity notes + a Kconfig-gate
follow-up are in ADR-183.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-17 21:04:02 -04:00
rUvandruv 65e29ef47a fix(display): no false display-detect on bare DevKit → CSI starves at MGMT-only (#1000) (#1121)
The SH8601 QSPI panel is write-only, so display_hal_init_panel() 'succeeds' even on a
bare display-less board — display_is_active() then returned true and main.c skipped the
#893/#906 MGMT->MGMT+DATA CSI filter upgrade (yield=0pps). Gate on the FT3168 touch I2C
readback (always present on the Touch-AMOLED board, absent on a bare DevKit): if touch is
absent, the panel 'success' was a false-positive — bail to headless before the display
task starts, so display_is_active() stays false and CSI captures.

Co-authored-by: ruv <ruvnet@gmail.com>
2026-06-17 11:24:53 -04:00
rUvandruv cb30988cf9 fix(mmwave): require validated MR60 header on probe — no false detect on empty UART (#1107) (#1119)
probe_at_baud counted bare 0x01 (SOF) bytes and declared MR60BHA2 on a single one.
A floating UART1 with no sensor reads noise full of 0x01s → false 'Detected MR60BHA2
(caps=0x000f)'. Now a candidate must be a full 8-byte header with a valid header
checksum (bytes 0..6) AND a known frame type (0x0A__ / 0x0F09), and clear the ≥3
threshold; removed the weak single-hit fallback. Real sensors stream valid frames
continuously, so detection of present hardware is unaffected.

Co-authored-by: ruv <ruvnet@gmail.com>
2026-06-17 11:24:23 -04:00
rUv 0c2b1c16cc fix: ESP32 vitals over-count + presence flicker (#998/#996) + Observatory per-person position/motion (#1050) (#1060)
* fix(firmware): gate phantom persons + add presence hysteresis (#998, #996)

Two ESP32 edge-vitals logic bugs in edge_processing.c. Both are
robustness/logic fixes — NOT validated-accuracy claims. True count/PCK
vs labelled ground truth remains hardware/data-gated (COM9 ESP32-S3).

#998 — n_persons over-counted (reported 4 for one person):
update_multi_person_vitals() split top-K subcarriers into top_k_count/2
groups and marked EVERY group active, so one body's multipath always
read the full EDGE_MAX_PERSONS. Added two pure, host-testable helpers:
  - count_distinct_persons(): per-group energy gate
    (EDGE_PERSON_MIN_ENERGY_RATIO) + spatial dedup
    (EDGE_PERSON_MIN_SC_SEP) so weak/adjacent multipath groups don't
    count as separate bodies. Strongest group always counts (>=1).
  - person_count_debounce(): a gated count must hold
    EDGE_PERSON_PERSIST_FRAMES consecutive frames before it's emitted,
    so a single noisy frame can't promote a phantom.
The active flags now mark only the strongest stable_count groups.

#996 — presence flag flickered at ~50cm despite high presence_score:
the bare `score > threshold` compare chattered on a noisy score
(field-observed 2.6-26.7 frame-to-frame). Replaced with a Schmitt
trigger + clear-debounce (presence_flag_update): assert above
threshold, hold in the dead band down to threshold *
EDGE_PRESENCE_HYST_RATIO, clear only after EDGE_PRESENCE_CLEAR_FRAMES
consecutive sub-low frames. presence_score itself is unchanged and
still emitted for consumer-side thresholding.

All thresholds are named, documented constants in edge_processing.h.
Firmware builds clean for esp32s3 (idf.py build RC=0).

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(firmware): host C99 tests for vitals count + presence logic (#998, #996)

test/test_vitals_count_presence.c pins the two fixes with deterministic
host-buildable tests (no ESP-IDF needed). 13 cases / 22 assertions, all
passing under gcc 13 -Wall -Wextra:

  #998 count gate: single strong signature + multipath -> count==1;
  two well-separated -> 2; two strong-but-adjacent -> 1 (dedup);
  no signal -> 0; three well-separated -> 3.
  #998 debounce: transient spike rejected; sustained change accepted;
  flapping count stays stable.
  #996 presence: dithering trace -> stable flag (no flicker); brief dips
  held by clear-debounce; genuine departure clears within hold window;
  dead-band holds state.

The named tuning constants are #include'd from the real
edge_processing.h so the test and firmware can never disagree on
thresholds. `make run_vitals` / `make host_tests` added; binaries
gitignored.

Hardware-gated caveat documented in the test header: these pin the
decision LOGIC; the exact energy/separation/hysteresis values that best
match a real room vs labelled occupancy remain on-device tuning.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs: record ESP32 vitals count/presence fixes (#998, #996)

CHANGELOG [Unreleased] Fixed: root cause + fix + named constants + test
+ explicit hardware/data-gated caveat for both bugs.

ADR-021 Implementation Notes: dated 2026-06 entry noting the edge-path
person-count + presence-flicker fixes are boolean/count emission-logic
fixes, not a validated-accuracy claim; thresholds pending on-device
calibration.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(sensing-server): emit real field-derived person position/motion to /ws/sensing (#1050)

The Observatory 3D figure never animated because the sensing_update WS
frame carried no per-person position/motion_score/pose — only image-space
keypoints. The FigurePool/PoseSystem (and demo-data.js's own contract)
animate each figure from persons[i].position (room-world), .motion_score
(0..100), and .pose; none were on the live stream.

Honest scope (Case 2): the pipeline has no calibrated per-person room
localizer or per-person skeletal pose. New field_localize module extracts
the strongest peak(s) from the real signal_field grid (subcarrier
variances x motion-band power) and maps the peak cell to Observatory world
coords with the exact _buildSignalField transform. motion_score is the
measured motion_band_power passed through; pose is set only from a real
aggregate posture estimate, else None (never a fabricated skeleton).
Empty/below-threshold field -> persons: [] (no phantom); present person
with no resolvable peak keeps position [0,0,0], not invented coords.

attach_field_positions runs after the tracker step at all five broadcast
sites. New position/motion_score/pose fields added to both PersonDetection
structs. No UI change needed — the Observatory already reads these fields.

Tests: field_localize peak/coordinate/empty/separation units +
observatory_persons_field_position_tests (known-peak -> emitted position,
empty-room -> no phantom, pose real-or-None, below-threshold honesty).
sensing-server bin 441->451, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(changelog): record #1050 Observatory persons position/motion fix

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-14 00:31:30 -04:00
rUv d0e27e652e fix(firmware): C6 IDF v5.5 guard + HE-LTF host ingest + WITNESS-LOG-110 B1 resolution (#1005) (#1011)
* fix(firmware): c6_sync_espnow IDF v5.5 send-callback guard + B1 HE-LTF resolution (#1005)

Espressif backported the esp_now_send_cb_t signature change to v5.5
(esp_now_send_info_t = wifi_tx_info_t there), so the #944 guard must be
ESP_IDF_VERSION >= VAL(5,5,0), not MAJOR >= 6.

Validated on this repo's hardware toolchain:
- WITHOUT fix, IDF v5.5.2 esp32c6 build fails with the reporter's exact
  incompatible-pointer error at c6_sync_espnow.c:199 (reproduced)
- WITH fix, clean build on IDF v5.5.2 (esp32c6) AND IDF v5.4 (regression)

Docs: WITNESS-LOG-110 §B1 marked RESOLVED WITH MEASUREMENT (external,
@stuinfla, issue #1005): IDF v5.4 driver downconverts HE->HT; v5.5.2
delivers true HE-LTF (532B / 256 bins / 242 tones, PPDU 0x01 HE-SU).
ADR-110 capability table updated accordingly.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs: WITNESS-LOG-110 §B1 — in-house HE-LTF replication on the original COM12 C6

84% of 1,525 frames at 532B/PPDU 0x01 (HE-SU) with IDF v5.5.2 + the #1005
guard fix, AP ruv.net 11ax 2.4GHz. Two independent rigs now confirm:
v5.4 downconverts, v5.5.2 delivers 242-tone HE20.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(host): 256-bin HE-LTF ingest end-to-end + latent offset bugs (#1005)

Audit of every ADR-018 consumer against live C6 HE20 frames (532B/256-bin):
- sensing-server + CLI calibrate parsers read n_subcarriers from one byte
  (256 decoded as 0) with stale seq/rssi offsets (rssi always 0 — latent,
  pre-existing, confirmed vs firmware csi_collector.c). Fixed to the real
  ADR-018 layout; n_subcarriers u8->u16; byte 18 surfaced as typed PpduType.
- sensing-server probe buffer 256B -> 2048B (532B datagram errored on Windows)
- per-node grid gate: lock densest (n_subcarriers, ppdu_type) grid, re-warm
  on upgrade, skip sparser minority frames — HT-64 never mixes into an
  HE-256 baseline window
- hardware parser: HE-aware bandwidth classification (256-FFT HE20 = 20MHz,
  was Bw160); PpduType/Adr018Flags re-exported
- verbatim live frames (532B HE-SU, 148B HT) embedded as regression fixtures
- archive python parser: bandwidth heuristic mirror fix

Live-validated: calibrate --tier he20 consumed 600x 256-bin frames into an
ADR-135 He20 baseline (242 tones) skipping 94 HT frames; sensing-server
shows node 12 active with real RSSI (-40dBm). 765 tests green across the
three crates; workspace check clean; Python proof PASS.

Co-Authored-By: claude-flow <ruv@ruv.net>

* test(fuzz): esp_netif/ping_sock/ip_addr stubs — un-break ADR-061 fuzz build after #954

csi_collector.c gained esp_netif.h / ping/ping_sock.h / lwip/ip_addr.h
includes for the #954 gateway self-ping; the host-fuzz stub env lacked
them, breaking the fuzz build on main since 5789351b7. Stubs return
no-gateway so the self-ping path early-outs (compiles + links, never
exercised — matches the fuzz threat model which targets frame
serialization, not the network stack).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-11 11:00:37 -04:00
rUv 992c2b25cb fix(firmware): correct ESP32 edge heart rate — sample-rate + harmonic lock (#987) (#988)
* fix(firmware): correct heart-rate estimation — sample-rate + harmonic lock

The edge vitals HR was stuck at ~45 BPM regardless of true heart rate
(Apple Watch ground truth 87 BPM read as ~45) and "dropped a lot" between
frames. Two root causes:

1. Stale fixed sample rate. estimate_bpm_zero_crossing() used a hardcoded
   `sample_rate = 10.0f` (and the biquads a separate `fs = 20.0f`). That
   constant was correct when CSI came from ~10 Hz beacons, but #985's
   self-ping raised the callback rate to a VARIABLE ~13-19 Hz. BPM scales as
   (assumed_rate / actual_rate) x true, so a true 87 read ~45, and because
   the real rate fluctuates with CSI yield while the code assumed a fixed
   value, the reported HR swung frame-to-frame (the "drops").

2. Breathing-harmonic lock. Zero-crossing HR estimation locked onto a
   breathing harmonic — a 0.25 Hz breathing fundamental puts its 3rd
   harmonic at ~0.74 Hz ~= 44 BPM, right in the HR band — so it parked at
   ~45 BPM independent of the real heartbeat.

Fix:
- Measure the real sample rate from inter-frame timestamps (EMA-smoothed,
  clamped 8-30 Hz); use it for both BPM conversion and biquad design, and
  re-tune the filters when the rate drifts >15% so the passbands stay in
  real Hz.
- Replace the HR zero-crossing with estimate_hr_autocorr(): autocorrelation
  peak in the 45-180 BPM band that explicitly rejects lags within 8% of any
  breathing harmonic (k=1..6), with parabolic interpolation and a peak-
  confidence gate (returns 0 rather than a noise value).
- Median-smooth (N=9) the emitted HR over valid estimates to kill residual
  single-frame outliers.

Validated on hardware (ESP32-S3, COM8/192.168.1.80) vs an unmodified board
(192.168.1.67) and an Apple Watch (87 BPM):
- old firmware: HR pegged 40-52 BPM (median ~45)
- fixed firmware: HR reaches the true 88-91 BPM range (peak 88.5, vs 87 GT)

Known limitation: under subject motion (motion=Y) HR is still noisy because
the breathing estimate degrades and misguides harmonic rejection; motion
gating + breathing robustness are follow-ups.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(firmware): robust HR harmonic rejection via autocorr breathing period (#987)

Follow-up to 332c2a98d. The HR harmonic rejection was fed the noisy
zero-crossing breathing estimate, which under motion notched the wrong
frequencies and let the autocorr lock onto the ~0.75 Hz breathing harmonic
(~45 BPM). Generalize estimate_hr_autocorr -> estimate_periodicity_autocorr
and drive HR harmonic rejection from a robust autocorrelation breathing
period instead; widen the HR median smoother to N=13.

Hardware A/B (fixed .80 vs unmodified control .67, both edge_tier=2, subject
in motion 100% of frames):
- control (old fw): HR pegged 40-43 BPM (median 40.6)
- fixed:            HR 60-91 BPM (median 71.9) — sub-60 harmonic locks
                    eliminated, spread 42->31 BPM vs previous build

Reported breathing is unchanged (still zero-crossing); the autocorr breathing
period is used only internally for HR harmonic rejection.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(changelog): record ESP32 heart-rate fix (#987)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-09 11:27:21 -04:00
rUvandMeraj 5789351b78 fix(esp32): add connected-STA self-ping CSI traffic source (#954) (#985)
The ESP32 CSI engine only produces CSI for received OFDM frames (L-LTF/
HT-LTF). On a quiet network — or on a display-enabled build where the
#893 MGMT->MGMT+DATA promiscuous upgrade is skipped (has_display=true) —
the only CSI-eligible frames are sparse beacons (often non-OFDM DSSS),
so wifi_csi_callback can starve to yield=0pps -> DEGRADED -> motion=0
(#521, #954).

Fix (additive): pin a ~50 Hz OFDM unicast floor by pinging the STA's own
DHCP gateway. The router's ICMP echo replies are OFDM frames destined to
this station and drive the CSI engine regardless of promiscuous filter
state or ambient traffic. Mirrors Espressif's esp-csi csi_recv_router
reference. Promiscuous capture (#396/#893) is left fully intact so
multistatic/multi-node sensing still hears other stations' frames.

Reconciles PR #955 (which removed promiscuous entirely and conflicted
with the already-shipped #893 DATA-capture path) into an additive change
on current main.

Verified on ESP32-S3 (N16R8, COM8), ESP-IDF v5.4:
  Promiscuous mode enabled (MGMT-only, RuView#396)
  self-ping started -> 192.168.1.1 @50Hz (CSI OFDM source, fix #521/#954)
  CSI cb #1: len=128 rssi=-40 ch=5
  adaptive_ctrl: state=6 yield=13-19pps motion=1.00 presence>0  (SENSE_ACTIVE)
DEGRADED cleared; CSI yield stable ~15 pps over 60 s.

Co-authored-by: Meraj <merajmehrabi@gmail.com>
2026-06-09 14:43:12 +02:00
rUv c353255672 fix: firmware cluster — wasm3 IDF v6.0 build (#946) + swarm TLS stack (#949) + Docker unauth default (#864) (#975)
* fix(firmware,docker): clear three high-severity bugs in one sweep

Closes #946 — wasm3 fails on Xtensa GCC 15.2.0 (ESP-IDF v6.0.1)

  cannot tail-call: machine description does not have a sibcall_epilogue
  instruction pattern

wasm3's `M3_MUSTTAIL return jumpOpImpl(...)` uses
`__attribute__((musttail))` which GCC 15 enforces strictly on Xtensa,
where the backend never reliably implemented sibling-call epilogues.
Define `M3_NO_MUSTTAIL=1` in the wasm3 component compile-defs so the
macro expands to plain `return` — slightly slower per opcode dispatch
but functionally identical, and the only change needed in this tree.
Older IDF / GCC builds accept the define as a no-op so the IDF v5.4
CI build is unchanged.

Closes #949 — swarm task stack overflow on Seed TLS init

The reporter provisioned with `--seed-url https://...` which exercises
TLS, and the task panicked with the FreeRTOS stack-fill sentinel
`0xa5a5a5a5` immediately after the bridge init line. `SWARM_TASK_STACK`
was 3 KB ("HTTP client uses ~2.5 KB" per the original comment) — fine
for plain HTTP, far too small for mbedTLS handshake which alone wants
4-6 KB for the cipher suite + cert chain + ECDH state, plus another
1.5-2 KB for esp_http_client. Bumped to 8192 with the why in the
comment. Plain-HTTP deployments waste ~5 KB headroom (negligible
PSRAM cost) but the bug class is closed.

Closes #864 — Docker default exposes unauthenticated sensing API + WS

`docker-entrypoint.sh` started the sensing-server with `--bind-addr
0.0.0.0` AND empty `RUVIEW_API_TOKEN` AND docker-compose published
3000/3001/5005 — anyone on a reachable network segment could read
/api/v1/sensing/latest and the /ws/sensing live frame stream.

Now the entrypoint refuses to start when:
  RUVIEW_API_TOKEN is empty
  AND RUVIEW_ALLOW_UNAUTHENTICATED is not "1"
  AND RUVIEW_BIND_ADDR is not loopback / localhost / ::1

…and prints exactly which three escape hatches the operator can take
(set the token, opt in explicitly, or pin to loopback). Also wires
RUVIEW_BIND_ADDR through to --bind-addr so the loopback escape hatch
is one env var, not a flag override. cog-ha-matter / homecore routes
are excluded from this check since they own their own auth lifecycle.
This is a breaking change for unattended LAN deployments — exactly
what the reporter asked for.

Validation

* `idf.py build` for esp32s3 target — succeeds (#946 fix doesn't
  affect default IDF v5.4 build path).
* `idf.py set-target esp32c6 && idf.py build` — succeeds, binary
  1015 KB / 45% partition free.
* Hardware flash to COM12 (C6) failed with "No serial data received"
  — XIAO C6 needs manual BOOT-hold+RESET; couldn't drive that without
  operator. Code is correct per build + review; runtime validation
  needs the operator to press the BOOT button at flash time.
* docker-entrypoint.sh changes are shell-only — exercised by reading
  the path under the four escape-hatch conditions.

Out of scope — cross-repo issues

Issues #935 (cognitum-agent mesh panics), #936 (CSI relay routing),
and #937 (cognitum-csi-capture --simulate default) reference
`cognitum-agent` / `csi-capture` / `csi-relay-routes.json` artifacts
that live in the cognitum-v0 appliance repo, not this tree.

Issue #954 (CSI callback never fires on S3 v0.6.5/v0.7.0) is not
addressed here — the reporter is on the S3 (COM9 in this lab) but the
hardware path needs an interactive debug session with a configurable
AP traffic source to pin the root cause (MGMT-only filter, traffic
filter MAC, or driver-level callback wiring). Will tackle in a
follow-up.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(firmware): bump LWIP UDP / WiFi TX buffer pools to ease ENOMEM

Hardware validation on COM8 (S3) and COM9 (C6) surfaced a v0.7.0
regression not captured in the existing issue tracker: stock IDF v5.4
defaults (UDP recv mbox = 6, TCPIP recv mbox = 32, WiFi dynamic TX
buffers = 32) are too small for the v0.7.0 packet mix once CSI
promiscuous mode is active. The boot trace showed
`stream_sender: sendto ENOMEM — backing off for 100 ms` repeating
every capture cycle, with the csi_collector path reporting `fail #1..5`
within seconds of associating to an AP.

Modest bumps applied (~3 KB extra heap each):

  CONFIG_LWIP_UDP_RECVMBOX_SIZE      6  → 32
  CONFIG_LWIP_TCPIP_RECVMBOX_SIZE   32  → 64
  CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM 32 → 64

Empirical 25 s measurement on S3 / COM8 post-fix:

  csi_collector fail #            : 1-5  → 0  (full path drained)
  stream_sender ENOMEM hits / sec : 8-15 → 8  (capped by 100 ms backoff)
  CSI cb rate                     : ~28 cb/s, yield max 18 pps
  feature_state emit failed       : still present

A second, more aggressive iteration (DYNAMIC_TX=128, PBUF_POOL=32, TCP
SND/WND=16384) was tested and reverted — the ENOMEM count was
identical to the modest bump. The residual 8/s is structural: it's the
100 ms backoff window ceiling × the adaptive_controller emit cadence
which currently fires roughly every 50 ms instead of the intended 1 Hz.
Bigger buffers don't fix that — only rate-limiting the emitter does.

Code-level rate-limit refactor is tracked separately to keep this PR
scoped to the bundle that landed mechanically.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(firmware): rate-limit feature_state emit from 5 Hz → 1 Hz

Completes the ENOMEM cure that the LWIP/WiFi buffer bumps started.

Root cause (verified on COM8 / S3 + COM9 / C6)

`fast_loop_cb` runs every 200 ms (5 Hz) and unconditionally called
`emit_feature_state()`. Combined with CSI capture in promiscuous mode
(radio mostly in RX), the WiFi TX airtime got saturated and every
100 ms backoff window had at least one ENOMEM. Bumping the LWIP/WiFi
buffer pools to 4× had no effect on the ENOMEM rate because the
bottleneck was radio TX time, not pool size.

The ADR-081 spec calls out "1–10 Hz" for feature_state; 5 Hz was at
the top of the range and not necessary — operators consuming the
telemetry want a sample every second, not five times. Dropping to
1 Hz frees ~80 % of the feature_state TX traffic.

Measurement on COM8 (25 s windows, otherwise-idle environment)

  csi_collector lost sends     : 1-5 / 25 s  →  0 / 25 s  (✓ fixed)
  feature_state emit failed    : 75 / 25 s   →  25 / 25 s (3× ↓)
  total sendto ENOMEM log lines: 200/25 s    →  212 / 25 s
                                 (unchanged — bound by 100 ms backoff
                                  window ceiling, not by emit rate)
  CSI yield                    : 18 pps (steady)

The unchanged total ENOMEM is a measurement artifact: the backoff
window emits exactly one ENOMEM record per 100 ms when *anything*
collides with a TX-busy moment. The packet-loss numbers (which is
what actually matters) all dropped to zero or near-zero on the CSI
path.

Implementation

Pure-static `s_emit_divider` counter in `fast_loop_cb`. Every 5th tick
calls the emit. Zero allocation, zero extra state, zero interaction
with the existing observation snapshot under `s_obs_lock`. Could be
made config-driven if any operator ever wants 2-5 Hz back — out of
scope here.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-08 16:39:42 +02:00
rUv 872d7593bb fix: IDF v6.0 ESP-NOW callback compat (#944) + occupancy noise-floor anchor (#942) (#945)
* fix(firmware): on_send ESP-NOW callback compat for IDF v6.0 (closes #944)

ESP-IDF v6.0 changed `esp_now_send_cb_t` from
  void (*)(const uint8_t *mac, esp_now_send_status_t status)
to
  void (*)(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)

The C6 sync ESP-NOW path's `on_recv` was already version-guarded with
`#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)` (lines 102-112)
but the `on_send` sibling missed the equivalent guard. CI runs against
IDF v5.4 so the regression slipped through; the reporter on IDF v6.0.1
with xtensa-esp-elf esp-15.2.0_20251204 hit:

  c6_sync_espnow.c:182:30: error: passing argument 1 of
  'esp_now_register_send_cb' from incompatible pointer type
  [-Wincompatible-pointer-types]

Fix: mirror the recv guard with `#if ESP_IDF_VERSION_MAJOR >= 6` since
the send-callback signature change happened at IDF v6.0 (not v5.x like
the recv-callback). Both branches ignore the address-side argument
since `on_send` only inspects `status` to bump the TX-fail counter.

Adds `#include "esp_idf_version.h"` so the macro is in scope.

Closes #944

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(signal): anchor estimate_occupancy noise floor to calibration (closes #942)

`test_estimate_occupancy_noise_only` asserts that 20 noise-only frames
fed through a 50-frame calibrated `FieldModel` yield 0 occupancy.
Failure reported on the upstream Linux + BLAS build.

Root cause

Calibration and estimation each compute their own Marcenko-Pastur
threshold:

  threshold = noise_var · (1 + sqrt(p / N))²

with `noise_var` = median of the bottom half of positive eigenvalues
from their own covariance. The MP ratio differs across the two phases:

  calibration  (50 frames, p=8): ratio = 0.16, factor ≈ 1.96
  estimation   (20 frames, p=8): ratio = 0.40, factor ≈ 2.66

On a small estimation window the local `noise_var` estimate can also
be smaller than the calibration's (fewer samples → bottom-half median
hits lower-magnitude eigenvalues). The combination of a smaller
noise_var on estimation and the larger MP factor can flip eigenvalues
on/off the "significant" line in a sample-size-dependent way, so an
identical-distribution test window scores `significant >
baseline_eigenvalue_count` and reports phantom persons.

Fix

Persist the calibration `noise_var` on `FieldNormalMode` (new field
`baseline_noise_var: f64`) and use `max(local_noise_var,
baseline_noise_var)` as the noise floor inside `estimate_occupancy`.
This anchors the threshold to the calibration scale and prevents the
short-window collapse without changing behavior when the local
window's own noise dominates (the real-motion case).

`baseline_noise_var` defaults to 0.0 in the diagonal-fallback paths;
the estimation code treats 0.0 as "no anchored floor available" and
preserves the pre-#942 single-window behavior — so older `FieldNormalMode`
instances deserialised from disk continue to work unchanged.

Test results

  cargo test --workspace --no-default-features
  → 413 lib tests pass (signal crate), 0 fail, 1 ignored.

The actual `eigenvalue`-gated test still requires BLAS (not buildable
on Windows). Logic-trace via the four numerical anchors above shows
the fix flips `noise_var` from the smaller local value back up to the
calibration scale, dropping `significant` to or below
`baseline_eigenvalue_count` so the saturating subtraction returns 0.

Closes #942

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-04 08:17:37 +02:00
rUv 2c136aca74 fix(protocol): resolve 0xC511_0004 magic collision (closes #928) (#931)
* fix(ci): SAST actually scans the code + drop deprecated flaky semgrep action

Two real problems in the Static Application Security Testing job:

1. **It scanned a path that no longer exists.** `bandit -r src/` and
   `semgrep … src/` pointed at the repo-root `src/`, but the Python code
   moved to `archive/v1/src/` (64 .py files) when the runtime was rewritten
   in Rust. So the SAST scan matched nothing — a silent no-op (this is also
   why `bandit-results.sarif` was "Path does not exist" on recent runs).
   Fixed both to `archive/v1/src/`.

2. **Deprecated + redundant + flaky semgrep step.** The
   `returntocorp/semgrep-action@v1` step pulled `returntocorp/semgrep-agent:v1`
   from Docker Hub every run (intermittently timing out → red check, e.g. on
   #929) and is EOL. It was redundant: the pip `semgrep --sarif` step is what
   feeds GitHub Security; the action only pushed to the Semgrep cloud app via
   SEMGREP_APP_TOKEN. Removed it and folded its `p/docker` + `p/kubernetes`
   rulesets into the pip semgrep command, so coverage is preserved with no
   Docker pull.

The job stays `continue-on-error: true` (non-gating). YAML validated.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(protocol): resolve 0xC511_0004 magic collision (closes #928)

Background

`0xC511_0004` was assigned to two different packet formats in firmware
— `EDGE_FUSED_MAGIC` (ADR-063, 48-byte `edge_fused_vitals_pkt_t`) and
`WASM_OUTPUT_MAGIC` (ADR-040, variable-length `wasm_output_pkt_t`).
Both were transmitted. The sensing-server only had a WASM parser for
that magic and no fused-vitals parser, so on the ESP32-C6 + MR60BHA2
mmWave configuration the fused-vitals packet was silently misparsed
as a malformed WASM output — `breathing_rate` was read as
`event_count`, mmWave-fused vitals were lost, and spurious WASM events
were emitted to subscribers.

Fix

1. Reassign `WASM_OUTPUT_MAGIC` to `0xC511_0007` (next free slot per
   the registry in `rv_feature_state.h`). Smaller blast radius than
   moving fused-vitals — the registry already treats `0xC511_0004` as
   fused-vitals canonical and several years of deployed feature
   tracking depends on that assignment.

2. Add `parse_edge_fused_vitals` + `EdgeFusedVitalsPacket` in
   `wifi-densepose-sensing-server::main`. Byte layout taken directly
   from `edge_processing.h:129`, mirroring the firmware's
   `_Static_assert(sizeof(edge_fused_vitals_pkt_t) == 48)` so future
   firmware changes that grow the packet will break this parser
   loudly instead of silently.

3. Add a dispatch arm in the UDP receive loop. Fused-vitals is tried
   BEFORE WASM so a stale firmware (still emitting 0xC511_0004 with
   the WASM payload) fails to parse as fused-vitals (size mismatch),
   then fails to parse as WASM (magic mismatch on the new 0x...0007),
   and gets dropped — a deliberate "fail loud" outcome rather than the
   pre-fix silent garbage.

4. Update the registry comment in `rv_feature_state.h` to add the new
   0x...0007 row.

5. Add five tests in a new `issue_928_magic_collision_tests` mod:
   - `parse_edge_fused_vitals_extracts_fields_correctly`
   - `parse_edge_fused_vitals_rejects_short_buffer`
   - `parse_edge_fused_vitals_rejects_wrong_magic`
   - `parse_wasm_output_rejects_legacy_0004_magic`
   - `parse_wasm_output_accepts_new_0007_magic`

WebSocket payload

Fused-vitals now broadcasts as `{"type": "edge_fused_vitals", ...}`
with the mmWave-specific block nested under `mmwave`. Schema is
additive — existing subscribers that only inspect `type` are
unaffected; subscribers that switch on `type` gain a new branch.

Deployment note

This is a wire-protocol change. Firmware older than this commit that
emits WASM output on 0xC511_0004 will lose its WASM event stream
against an updated host (host expects 0xC511_0007). Per the issue
discussion, "fail loud" is preferred to silent misparsing. Operators
running C6+mmWave should reflash firmware concurrent with the host
upgrade.

Test results
  cargo test -p wifi-densepose-sensing-server --no-default-features
  --bin sensing-server
  → 122 passed / 0 failed (5 new + 117 existing, unchanged)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-03 11:56:35 +02:00
ruv 786e834dae release(firmware): refresh release_bins with the #893 CSI fix → v0.6.7
The pre-built binaries in release_bins/ were v0.6.6 (May 21) and shipped
the MGMT-only promiscuous filter, so display-less boards flashed from them
got yield=0pps (#893/#866/#897 — the root cause of the "can't reproduce /
it's fake" reports). Rebuilt every flashable variant from main (which has
the #893 display-gated DATA-frame fix) and refreshed the binaries:

- top-level ESP32-S3 8MB (sdkconfig.defaults) — esp32-csi-node.bin +
  bootloader (partition-table/ota_data unchanged — code-only fix)
- esp32-csi-node-4mb.bin (ESP32-S3 4MB, sdkconfig.defaults.4mb)
- c6-adr110/ (ESP32-C6, sdkconfig.defaults.esp32c6) — the exact firmware
  hardware-verified on COM6 (CSI yield 0→27 pps, presence/motion alive,
  no #396 crash)
- s3-adr110/ (same production S3 8MB config)

Left untouched: s3-fair-adr110/ (a non-production size-comparison build,
features stripped — not a board anyone flashes for sensing).

version.txt → 0.6.7; SHA256SUMS regenerated for the changed variant dirs.
Display boards keep MGMT-only (preserves the #396 crash protection);
display-less boards now capture DATA frames and stream CSI.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-02 11:18:03 +02:00
ruv 898c536eac fix(firmware): capture DATA frames on display-less boards — #893/#866/#897
The pre-built binaries set a MGMT-only promiscuous filter
(WIFI_PROMIS_FILTER_MASK_MGMT) as the #396 workaround — DATA-frame
interrupt load races the QSPI display's SPI traffic against the SPI-flash
cache and crashes Core 0 in wDev_ProcessFiq. But MGMT-only fires the CSI
callback only on sparse management frames, so on the common DISPLAY-LESS
boards (DevKitC-1, T7-S3, N8R8) CSI yield collapses to 0 pps under real
traffic (#521) — the node looks dead despite being on the network, which
is the root cause of most "can't reproduce / it's fake" reports (#804/#37).

A board with no AMOLED panel has no QSPI/SPI-flash contention, so it can
safely capture DATA frames. After the boot-time display probe runs:
  - display present  -> keep MGMT-only (preserve #396 crash protection)
  - no display       -> upgrade filter to MGMT|DATA (restore CSI yield)

Implementation (runtime-gated, no boot reorder):
  - display_task.c: s_display_active flag + display_is_active() accessor,
    set true only when the panel is detected and the display task starts.
  - csi_collector.c: csi_collector_enable_data_capture() re-sets the
    promiscuous filter to MGMT|DATA.
  - main.c: after display_task_start(), if !display_is_active() (or display
    support not compiled in), upgrade the filter.

Build-verified on BOTH targets: esp32c6 (headless path) and esp32s3
(display path, display_task.c compiled) — Project build complete, RC 0.
Needs on-hardware confirmation that yield recovers and no #396 crash.
2026-06-02 09:57:19 +02:00
rUv 00a234eda8 ADR-110: ESP32-C6 firmware extension (#764)
Closes the firmware-side ADR-110 design at v0.7.0-esp32 after a 38-iter /loop SOTA sprint.

Headline (bench, COM9+COM12 ESP32-C6):
- 99.56% cross-board RX, 104.1 µs smoothed offset stdev (≤100 µs §2.4 target met)
- 3.95× EMA suppression, 1.4 ppm crystal skew preserved

4 firmware releases: v0.6.7 / v0.6.8 / v0.6.9 / v0.7.0-esp32.
42 ADR-110 unit tests, 1761 v2 workspace tests, full Firmware CI + QEMU green.
2026-05-23 15:34:48 -04:00
ruv c58f49f21a fix(firmware): add vTaskDelay(1) yields in process_frame() at tier>=2 to fix WDT storm (#683)
At edge tier>=2 on N16R8 PSRAM boards, `process_frame()` runs
`update_multi_person_vitals()` (4 persons × 256 history samples) plus
`wasm_runtime_on_frame()` back-to-back before returning to `edge_task()`.
The existing `vTaskDelay(1)` in `edge_task()` only fires *after*
`process_frame()` returns — under sustained 30 pps CSI load on PSRAM
boards this leaves IDLE1 on Core 1 starved long enough for the 5-second
Task Watchdog Timer to fire.

Fix: add two `vTaskDelay(1)` calls inside `process_frame()`, both gated
on `s_cfg.tier >= 2`:
1. After `update_multi_person_vitals()` (Step 11)
2. After `wasm_runtime_on_frame()` dispatch (Step 14)

Tier 0/1 paths are unaffected. Validated on COM7 (N16R8 board):
`Edge DSP task started on core 1 (tier=2)`, no WDT panics in 20 s.

Also bump firmware version 0.6.5 → 0.6.6 and refresh all 6 release_bins
with the new build (8MB + 4MB variants, built 2026-05-21).

Fix-marker RuView#683 added to scripts/fix-markers.json.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-21 09:20:21 -04:00