Commit Graph
73 Commits
Author SHA1 Message Date
rUv 6524712900 Merge pull request #1890: storage partition for 8MB, compile node_log out on 4MB
Verified on a physical ESP32-C6 (4 MB, esp32c6 defaults, ESP-IDF v5.4):
- App image: 1,085,424 B on main, 1,042,368 B with this change (43,056 B smaller).
- The 'Failed to find FATFS partition storage' error seen on every 4 MB C6 boot is gone.
- Wi-Fi join, CSI init and CSI UDP delivery to the host were confirmed.
CI: 35/35 green.
2026-09-26 12:36:57 -05:00
rUv 33a9e90896 Merge pull request #1902 from ruvnet/feat/firmware-usb-onboarding-c6
firmware: USB onboarding on main + C6 console RX fix (0.8.12)
2026-09-11 13:32:02 +00:00
ruv 4fbe58f736 fix(firmware): keep onboarding digest compatible with ESP-IDF 6 2026-09-11 09:19:33 -04:00
ruvandClaude Fable 5.1 a64e9e98fb fix(firmware): keep the C6 console RX alive and ship USB onboarding (0.8.12)
- mmwave_sensor: the default probe pins (UART1 TX=17/RX=18) put UART1 TX on
  GPIO17, which is U0RXD on the ESP32-C6. Console input was dead on every C6
  node even when no sensor was found, because uart_driver_delete() leaves the
  GPIO matrix routing in place. C6 now defaults to GPIO4/5 and any pin pair
  overlapping the console UART is refused.
- serial_onboarding_protocol: _POSIX_C_SOURCE for strtok_r under -std=c11
  -pedantic (host tests on glibc).
- main/CMakeLists: explicit led_strip REQUIRES so builds work with the
  component manager disabled (vendored components, offline builds).
- release_bins/c6-onboarding: 0.8.12 C6 images built with ESP-IDF v5.4.

Hardware-verified on an ESP32-C6 over cu.usbserial-3120 from the RuView Mac
app: flash -> RUVIEW_HELLO_V1 receipt -> RUVIEW_CONFIG_V1 -> node 3 live at
-34 dBm on the sensing server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rqz356q3Qz6sscTrpy5Jrv
2026-09-11 08:58:46 -04:00
ruv 130afabae8 feat(firmware): add secure macOS USB onboarding
(cherry picked from commit 3373e79f38afef3a1430feeb78ae034e78777c66)
2026-09-10 19:25:13 -04:00
Joeandclaude-flow 36e4df95b7 feat(firmware): give node_log an 8MB home, compile it out on 4MB
node_log currently only has a FAT volume on the 16MB table
(partitions_16mb.csv), so it silently does nothing on the two chips
most of the fleet's OTA/CI surface actually exercises.

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

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

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ
2026-09-10 13:21:59 -04:00
500cfe4787 feat(firmware): persistent on-node log and coredump-to-flash (#1878)
* feat(firmware): add the 16MB flash profile the C6 fleet runs on

The 4MB and display layouts have 1.875MB and 2MB OTA slots, sized for 4MB
and 8MB parts. A 16MB C6 (DevKitC-1 N16) has no layout in the tree at all,
so building for one meant hand-editing sdkconfig -- and a smaller table
written over a deployed 16MB node relocates NVS and destroys its
provisioning, which is why this belongs in the repo rather than in a
workstation's untracked sdkconfig.

Two 4MB OTA slots, plus a 64K coredump partition and an 8000K FAT volume
for on-node storage. Rollback is enabled so an OTA'd image that cannot
confirm itself is reverted by the bootloader.

Layered, not inherited: sdkconfig.defaults.esp32c6 keeps its 4MB layout so
the common 4MB devkit is never silently repartitioned.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ

* feat(firmware): persistent on-node log and coredump-to-flash

A remote fault currently leaves no post-mortem. main.c decodes the reset
reason at boot and logs it to the CONSOLE -- which is exactly what a power
cycle destroys, and a power cycle is how a wedged node gets recovered.

The flash was already provisioned for this and the features were simply off:
partitions_16mb.csv has reserved a 64K `coredump` partition and an 8000K
`storage` FAT volume since the 16MB layout was written, neither had ever been
mounted or written, and the build defaulted to CONFIG_ESP_COREDUMP_ENABLE_TO_NONE.

- node_log.c/h: a 128 B-record ring on the storage volume. Boot records
  (reset reason + the previous session's final uptime + a health snapshot),
  periodic health samples, and event records. Ordering is boot_id + uptime_s
  because these boards have no RTC and nothing can be timestamped absolutely.
  Counters are stored RAW and differenced at read time, so a cadence change
  never invalidates old records. Retention, not wear, binds: 128 B every
  300 s is ~208 days in 8 MB, and the write rate is bounded explicitly.
- ap_rssi_dbm, the association link from esp_wifi_sta_get_ap_info. The
  adaptive controller's rssi_median_dbm is a CSI statistic, not the uplink,
  so a node with a bad uplink currently shows no signal for it anywhere.
- CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH, complementary rather than a
  substitute: a coredump catches a CRASH, the boot record catches a HANG
  followed by a watchdog reset.

node_log_init failing is non-fatal by design -- the module disables itself
and every entry point becomes a no-op. A logging volume that will not mount
must never take the fleet down with it.

csi_collector gains two gate accessors. This build selects its frame gate at
compile time, so both return CSI_GATE_NOT_CONFIGURABLE rather than a
plausible number: the log keeps a field of fixed width and meaning either
way, and an explicit "not applicable" is stored instead of something a
reader would later reason from as a real setting.

Built with ESP-IDF v5.4 for esp32c6 using
defaults + .esp32c6 + .16mb. Verified from the resulting artifacts: 16MB
table with coredump and storage present, COREDUMP_ENABLE_TO_FLASH set,
rollback enabled, image 0x10abd0 bytes in a 4M slot. NOT flashed to hardware
from this branch -- the equivalent code was exercised on a node-3 pilot, but
that is not evidence for this build.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ

* docs(adr-362): reading the on-node log is on-demand, never streamed

The log engine lands with no consumer, so the access pattern was open. Records
the decision to pull a node's log only when a human asks -- after a hang or a
crash, or while troubleshooting that node -- and to keep nothing server-side.

The reasoning that decided it: the ring already survives a power cycle on the
node (~208 days of retention), so a server copy duplicates a durability
property that is already met; and airtime is this fleet's binding constraint,
measured at 91% consumed by two slow nodes, so a periodic drain across nine
boards would spend exactly the resource whose exhaustion you would open the
log to investigate. An instrument must not perturb what it measures.

Leaves the decode-where question open on purpose, with the argument for a raw
proxy stated: decoding server-side defines the record layout in both C and
Rust, and the pilot already paid once for a host/firmware disagreement about a
field's meaning.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ

---------

Co-authored-by: Joe <joe@GitDev.hedgehog>
Co-authored-by: claude-flow <ruv@ruv.net>
2026-09-09 23:50:34 +00: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
Dragan Spiridonov a4d49f76b9 feat(firmware): add XIAO C6 antenna selection 2026-09-08 14:12:41 +02: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
ruv a2c662f9f7 fix: harden CSI timing and baseline reliability 2026-09-06 11:38:30 -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
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 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 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
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
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 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
rUv f5e2b5474b release: ESP32-S3 firmware v0.6.5 — Tmr Svc stack + OTA init refactor (#628)
Three fixes wrapped for the v0.6.5-esp32 release tag:

1. **`sdkconfig.defaults` adds `CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192`**.
   The fix was already in `sdkconfig.defaults.template` (ADR-081, prevents
   "stack overflow in task Tmr Svc" bootloop when adaptive_controller emits
   feature_state from inside a Timer Svc callback). It was MISSING from the
   canonical `sdkconfig.defaults` file used by the build, so any fresh
   build picked up the 2 KiB FreeRTOS default and bootlooped on hardware.
   Verified on COM7: with the fix, no panics in 30 s of operation; without
   it, "***ERROR*** A stack overflow in task Tmr Svc has been detected."
   followed by sustained bootloop.

2. **`ota_update.c` extracts `ota_load_psk_from_nvs()` and calls it from
   both `ota_update_init()` and `ota_update_init_ex()`.** `main.c:230` uses
   the `_ex` variant, but only `ota_update_init()` was loading the PSK
   from NVS. Result: `s_ota_psk` stayed empty regardless of NVS contents,
   so the RuView#596 fail-closed posture rejected every request — but the
   diagnostic warning never printed at boot, leaving operators no signal
   about why their OTA uploads were 403'ing. Verified on COM7:
       W (3126) ota_update: NVS namespace 'security' not found —
       OTA upload endpoint will REJECT all requests until provisioned.
       Fail-closed per RuView#596.

3. **`version.txt`: 0.6.4 → 0.6.5**, paired with the v0.6.5-esp32 tag so the
   firmware-ci version-guard job (RuView#505 fix-marker) stays happy.

Both validations done end-to-end on hardware (COM7, ESP32-S3 8MB,
provisioned with --edge-tier 2 to also incidentally re-verify #438 is not
reproducible on current main).
2026-05-18 17:05:35 -04:00
rUv 281c4cb0ce fix(firmware): OTA upload fails closed when no PSK in NVS (RuView#596 audit) (#623)
ota_check_auth() previously returned true when s_ota_psk[0] == '\0'
("permissive for dev"). A freshly-flashed node — or any node where
nobody had provisioned an OTA PSK yet — accepted attacker-controlled
firmware over plain HTTP on port 8032 from any host on the WiFi. No
Secure Boot V2, no signed-image verification, no transport encryption.
Single LAN call could brick or backdoor a node.

This was flagged in the deep security review of PR #596 but was a
PRE-EXISTING bug in main, not new code from that PR — so it stood as
a critical-severity production issue until this commit.

Fix:
- ota_check_auth() now returns false when no PSK is provisioned, with
  ESP_LOGW("OTA rejected: no PSK in NVS …") at the call site so the
  operator can diagnose the rejection from serial logs
- ota_update_init() ESP_LOGW message updated to surface the new posture
  at boot ("upload endpoint will REJECT all requests until provisioned")
- Doc comment on ota_check_auth() rewritten to make the contract
  explicit and reference the audit

The OTA HTTP server itself still starts even when no PSK is set. That
lets the operator run `provision.py --ota-psk <hex>` over USB-CDC to
write the NVS key without reflashing the firmware. The upload endpoint
just refuses every request in the meantime.

Breaking change for any deployment that depended on the unauthenticated
OTA path working out of the box. Documented in CHANGELOG under
[Unreleased] / Security so it's visible at the next release cut.

Fix-marker RuView#596-ota-fail-closed (scripts/fix-markers.json)
requires the new behaviour and forbids the old "permissive for dev"
fallback strings, so a future revert fails CI.
2026-05-18 08:56:07 -04:00
Mathew005 fa28318bae fix(led): disable onboard WS2812 LED during CSI collection (#273) 2026-05-17 18:18:10 -04:00
OrbisAI Security acbd3ff13c refactor(mmwave): use sizeof() in mr60_process_frame bounds checks (#414)
Automated security fix generated by Orbis Security AI
2026-05-17 18:15:01 -04:00
Chaitanya TataandCursor cee414f3c0 firmware/esp32-csi-node: IDF 6 build, HE CSI config, unicore DSP, provision chip detect (#522)
* firmware/esp32-csi-node: fix IDF 6 build (PSA SHA-256, explicit REQUIRES)

- rvf_parser: use psa_hash_* / psa_hash_compute; mbedTLS 4 has no public
  mbedtls/sha256.h on the IDF include path.
- main/CMakeLists: declare REQUIRES for WiFi, netif, HTTP, OTA, drivers, lwip,
  mbedtls per ESP-IDF v6 component dependency checks; optional wasm3 when
  CONFIG_WASM_ENABLE.

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* firmware/esp32-csi-node: fix CSI config for Wi-Fi 6 (ESP32-C6)

When CONFIG_SOC_WIFI_HE_SUPPORT is set, wifi_csi_config_t is the
wifi_csi_acquire_config_t bitfield layout. The legacy bool fields
(lltf_en, htltf_en, ...) only apply to ESP32-S3-class targets.

Initialize acquire fields for HE targets; add MAC v3-only members when
CONFIG_SOC_WIFI_MAC_VERSION_NUM >= 3.

Verified: idf.py build for esp32c6 and esp32s3 (ESP-IDF v6.1).

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* firmware/esp32-csi-node: pin edge DSP task for unicore (ESP32-C6)

edge_processing_init used xTaskCreatePinnedToCore(..., core 1). ESP32-C6
runs FreeRTOS unicore (portNUM_PROCESSORS == 1), so core 1 trips the
xTaskCreatePinnedToCore range assert right after CSI init.

Use core 1 only when SMP is available; otherwise pin to core 0.

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* firmware/esp32-csi-node: provision NVS with chip auto-detect

provision.py always passed --chip esp32s3 to esptool, so flashing NVS on
ESP32-C6 failed. Default --chip to auto (esptool v5) and add an explicit
--chip override. Use write-flash instead of deprecated write_flash.

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Signed-off-by: Chaitanya Tata <chaitanya@dotstarconsulting.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 18:00:40 -04:00
ruv 3f55c95b34 fix(esp32): disable WiFi modem sleep so CSI capture isn't starved (#521)
csi_collector_init() never called esp_wifi_set_ps(), leaving the radio on
the ESP-IDF STA default WIFI_PS_MIN_MODEM. The modem then sleeps between
DTIM beacons; combined with the MGMT-only promiscuous filter (#396) the
CSI callback is starved and the per-second yield collapses toward 0 pps,
which is what users on a clean multi-node setup were seeing
(motion=0.00 presence=0.00 yield=0pps).

Force WIFI_PS_NONE before enabling promiscuous mode — the textbook
requirement for reliable CSI capture (every ESP-IDF CSI example does it).
New boot line: "csi_collector: WiFi modem sleep disabled (WIFI_PS_NONE)
for CSI capture". Battery duty-cycling is unaffected: power_mgmt_init()
runs after this and re-enables modem sleep when provision.py is given
--duty-cycle <100.

Builds clean for esp32s3 (idf.py build, 48% flash free).

Closes #521

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-11 10:47:48 -04:00
rUvandDragan Spiridonov f06d0c6ab5 fix(firmware): SPI cache crash fix + node_id/filter_mac defensive copies + esptool v5 (rebased #397)
* fix(firmware): move defensive node_id capture before wifi_init_sta()

The original defensive copy in csi_collector_init() (line 172 of main.c)
runs AFTER wifi_init_sta() (line 147), which on some ESP32-S3 devices
corrupts g_nvs_config.node_id back to the Kconfig default of 1.

Reproduced on device 80:b5:4e:c1:be:b8 (ESP32-S3 QFN56 rev v0.2):
  - NVS provisioned with node_id=5
  - Release firmware (no fix): seed receives node_id=1 (clobbered)
  - This patch: seed receives node_id=5 (correct)

Changes:
  - Add csi_collector_set_node_id() called from main.c immediately
    after nvs_config_load(), before wifi_init_sta() runs
  - csi_collector_init() now detects and logs the clobber if early
    capture disagrees with current g_nvs_config value
  - Fallback path preserved: if set_node_id() is never called,
    init() still captures from g_nvs_config (backwards compatible)

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

* fix(firmware): defensive copy of filter_mac to prevent callback crash

The CSI callback reads g_nvs_config.filter_mac_set and filter_mac on
every invocation (100-500 Hz). If wifi_init_sta() corrupts g_nvs_config
(same root cause as the node_id clobber), the callback reads garbage
from the struct, leading to Core 0 LoadProhibited panic after ~2400
callbacks (~70 seconds of operation).

Extends the early-capture pattern from the node_id fix to also copy
filter_mac_set and filter_mac into module-local statics before WiFi
init runs. Adds canary logging to detect filter_mac corruption.

Observed on device 80:b5:4e:c1:be:b8 via serial:
  CSI cb #2400 → Guru Meditation Error: Core 0 panic'ed (LoadProhibited)
  → TG0WDT_SYS_RST → reboot → crash again at ~2900 callbacks

Refs #232 #375 #385 #386 #390

Co-Authored-By: Ruflo & AQE

* fix(firmware): MGMT-only promiscuous filter to prevent SPI cache crash

The WiFi driver's wDev_ProcessFiq interrupt handler crashes with
LoadProhibited in cache_ll_l1_resume_icache when promiscuous mode
captures MGMT+DATA frames (100-500 interrupts/sec). The high interrupt
rate races with SPI flash cache operations, corrupting cache state.

Changes:
- Promiscuous filter: MGMT+DATA → MGMT-only (~10 Hz beacons)
- CSI config: disable htltf_en and stbc_htltf2_en (LLTF-only)

LLTF provides 64 subcarriers (HT20) — sufficient for presence,
breathing, and fall detection. The 10 Hz beacon rate eliminates
the SPI flash cache contention that caused the crash.

Verified on device 80:b5:4e:c1:be:b8:
- Before: LoadProhibited crash at ~1600-2400 callbacks (every ~70s)
- After: 2700+ callbacks over 4.7 minutes, zero crashes

Backtrace decode confirmed crash in ESP-IDF closed-source WiFi blob:
  _xt_lowint1 → wDev_ProcessFiq → spi_flash_restore_cache
  → cache_ll_l1_resume_icache → EXCVADDR=0x00000004 (NULL deref)

Co-Authored-By: Ruflo & AQE

* fix(provision): write-flash → write_flash for esptool v5 compat

esptool v5+ rejects hyphenated subcommands. The provision script
used 'write-flash' which fails with "invalid choice". Changed to
'write_flash' (underscore) which works with both old and new esptool.

Co-Authored-By: Ruflo & AQE

* fix(firmware): 50 Hz callback rate gate + sdkconfig extra IRAM opt

- Add early rate gate in wifi_csi_callback at 50 Hz (defense-in-depth,
  does not prevent crash alone but reduces callback execution time)
- Add null-data injection timer infrastructure (disabled — TX adds
  interrupt pressure that triggers the SPI cache crash, RuView#396)
- sdkconfig.defaults: add CONFIG_ESP_WIFI_EXTRA_IRAM_OPT=y
- sdkconfig.defaults: document SPIRAM XIP attempt (crashes differently)

Co-Authored-By: Ruflo & AQE

* fix(firmware): address PR #397 review feedback

Applies @ruvnet's five review requests on PR #397 (RuView#397 comment
4289417527):

1. **Inline comment on `provision.py` `write_flash`** — ESP-IDF v5.4
   bundles esptool 4.10.0 (underscore-only). #391's hyphen swap broke
   the documented venv flow; kept the underscore form and added a
   three-line comment warning future maintainers not to "re-fix" it.

2. **Correct `edge_processing.c` sample_rate** (blocking) — changed
   hard-coded `20.0f` → `10.0f` at line 718 so
   `estimate_bpm_zero_crossing()` matches the MGMT-only CSI rate.
   Without this, breathing and heart-rate reports were 2× the true
   value. Added a comment tying the constant to the callback rate gate.

3. **Removed disabled probe-injection infrastructure** — dropped the
   forward declaration, the `CSI_PROBE_INTERVAL_MS` define, six static
   variables (`s_probe_timer`, `s_probe_tx_count`, `s_probe_tx_fail`,
   `s_ap_bssid`, `s_ap_bssid_known`), and three functions
   (`csi_send_probe_request`, `probe_timer_cb`,
   `csi_collector_start_probe_timer`). None were reachable.
   `csi_inject_ndp_frame()` reverted to the original ADR-029 stub.
   Can be revived from this commit's parent if needed.

4. **Cleaned `sdkconfig.defaults`** — removed the SPIRAM prose and
   commented-out `# CONFIG_SPIRAM is not set` line. Kept only the live
   `CONFIG_ESP_WIFI_EXTRA_IRAM_OPT=y` with a concise rationale.

5. **Bumped firmware version 0.6.1 → 0.6.2** and added four
   `[Unreleased]` CHANGELOG entries covering the SPI cache crash fix,
   the `filter_mac` / `node_id` clobber defense, the sample-rate
   correction, and the `write_flash` command-form revert.

Net: +39 / -128 across six files.

Validation in this devcontainer:
- Static sanity on modified C files: braces balance (csi_collector.c
  59/59; edge_processing.c 96/96), zero dangling references to removed
  probe-injection symbols.
- Rust workspace tests and Python proof not executed here — cargo not
  installed and pip blocked by PEP 668. Deferring hardware build +
  flash + miniterm verification to @ruvnet's COM7 per his offer in
  the review comment.

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

---------

Co-authored-by: Dragan Spiridonov <spiridonovdragan@gmail.com>
2026-04-28 08:41:49 -04:00
rUvandClaude 5a7f431b0e ADR-081: Implement 5-layer adaptive CSI mesh firmware kernel (#404)
* ADR-081: adaptive CSI mesh firmware kernel + scaffolding

Introduces a 5-layer firmware kernel that reframes the existing ESP32
modules as components of a chipset-agnostic architecture and authorizes
adaptive control + a compact feature-state stream as the default upstream.

Layers:
  L1 Radio Abstraction Layer  — rv_radio_ops_t vtable + ESP32 binding
  L2 Adaptive Controller      — fast/medium/slow loops (200ms/1s/30s)
  L3 Mesh Sensing Plane       — anchor/observer/relay/coordinator (spec)
  L4 On-device Feature Extr.  — rv_feature_state_t (magic 0xC5110006)
  L5 Rust handoff             — feature_state default; debug raw gated

Files:
  docs/adr/ADR-081-adaptive-csi-mesh-firmware-kernel.md  (new)
  firmware/esp32-csi-node/main/rv_radio_ops.h            (new)
  firmware/esp32-csi-node/main/rv_radio_ops_esp32.c      (new)
  firmware/esp32-csi-node/main/rv_feature_state.{h,c}    (new)
  firmware/esp32-csi-node/main/adaptive_controller.{h,c} (new)
  firmware/esp32-csi-node/main/main.c                    (wire L1+L2)
  firmware/esp32-csi-node/main/CMakeLists.txt            (add 4 sources)
  firmware/esp32-csi-node/main/Kconfig.projbuild         (controller knobs)
  CHANGELOG.md                                           (Unreleased)

Default policy is conservative: enable_channel_switch and
enable_role_change are off, so behavior matches today's firmware
unless an operator opts in via menuconfig. The pure
adaptive_controller_decide() is exposed for offline unit tests.

Reuses (does not rewrite): csi_collector, edge_processing (ADR-039),
swarm_bridge (ADR-066), secure_tdm (ADR-032), wasm_runtime (ADR-040).

* ADR-081: implement Layers 1/2/4 end-to-end + host tests + QEMU hooks

Turns the ADR-081 scaffolding into a working adaptive CSI mesh kernel:
Layer 1 radio abstraction has an ESP32 binding and a mock binding; Layer 2
adaptive controller runs on FreeRTOS timers; Layer 4 feature-state packet
is emitted at 5 Hz by default, replacing raw ADR-018 CSI as the default
upstream.

New files:
  firmware/esp32-csi-node/main/adaptive_controller_decide.c  (pure policy)
  firmware/esp32-csi-node/main/rv_radio_ops_mock.c           (QEMU binding)
  firmware/esp32-csi-node/tests/host/Makefile                (host tests)
  firmware/esp32-csi-node/tests/host/test_adaptive_controller.c
  firmware/esp32-csi-node/tests/host/test_rv_feature_state.c
  firmware/esp32-csi-node/tests/host/esp_err.h               (shim)
  firmware/esp32-csi-node/tests/host/.gitignore

Modified:
  adaptive_controller.c         — includes pure decide.c; emit_feature_state()
                                  wired into fast loop (200 ms = 5 Hz)
  rv_radio_ops_esp32.c          — get_health() fills pkt_yield + send_fail
  csi_collector.{c,h}           — pkt_yield/send_fail accessors (ADR-081 L1)
  rv_feature_state.h            — packed size corrected to 60 bytes
                                  (was incorrectly 80 in initial commit)
  main.c                        — mock binding registered under mock CSI
  CMakeLists.txt                — rv_radio_ops_mock.c under CSI_MOCK_ENABLED
  scripts/validate_qemu_output.py — 3 new ADR-081 checks (17/18/19)
  docs/adr/ADR-081-*.md         — status → Accepted (partial);
                                  implementation-status matrix; measured
                                  benchmarks (decide 3.2 ns, CRC32 614 ns);
                                  bandwidth 300 B/s @ 5 Hz (99.7% vs raw);
                                  verification section
  CHANGELOG.md                  — artifact-level entries

Tests (host, gcc -O2 -std=c11):
  test_adaptive_controller:  18/18 pass, decide() = 3.2 ns/call
  test_rv_feature_state:     15/15 pass, CRC32(56 B) = 614 ns/pkt, 87 MB/s
                             sizeof(rv_feature_state_t) == 60 asserted
                             IEEE CRC32 known vectors verified

Deferred (tracked in ADR-081 roadmap Phase 3/4):
  Layer 3 mesh-plane message types, role-assignment FSM, Rust-side mirror
  trait in crates/wifi-densepose-hardware/src/radio_ops.rs.

* ADR-081: Layer 3 mesh plane + Rust mirror trait — all 5 layers landed

Fully implements the remaining deferred pieces of the adaptive CSI mesh
firmware kernel. All 5 layers (Radio Abstraction, Adaptive Controller,
Mesh Sensing Plane, On-device Feature Extraction, Rust handoff) are
now implemented and host-tested end-to-end.

Layer 3 — Mesh Sensing Plane (firmware/esp32-csi-node/main/rv_mesh.{h,c}):
  * 4 node roles: Unassigned / Anchor / Observer / FusionRelay / Coordinator
  * 7 message types: TIME_SYNC, ROLE_ASSIGN, CHANNEL_PLAN,
    CALIBRATION_START, FEATURE_DELTA, HEALTH, ANOMALY_ALERT
  * 3 auth classes: None / HMAC-SHA256-session / Ed25519-batch
  * Payload types: rv_node_status_t (28 B), rv_anomaly_alert_t (28 B),
    rv_time_sync_t (16 B), rv_role_assign_t (16 B),
    rv_channel_plan_t (24 B), rv_calibration_start_t (20 B)
  * 16-byte envelope + payload + IEEE CRC32 trailer
  * Pure rv_mesh_encode()/rv_mesh_decode() plus typed convenience encoders
  * rv_mesh_send_health() + rv_mesh_send_anomaly() helpers

Controller wiring (adaptive_controller.c):
  * Slow loop (30 s default) now emits HEALTH
  * apply_decision() emits ANOMALY_ALERT on transitions to ALERT /
    DEGRADED
  * Role + mesh epoch tracked in module state; epoch bumps on role
    change

Layer 5 — Rust mirror (crates/wifi-densepose-hardware/src/radio_ops.rs):
  * RadioOps trait mirrors rv_radio_ops_t vtable
  * MockRadio backend for offline tests
  * MeshHeader / NodeStatus / AnomalyAlert types mirror rv_mesh.h
  * Byte-identical IEEE CRC32 (poly 0xEDB88320) verified against
    firmware test vectors (0xCBF43926 for "123456789")
  * decode_mesh / decode_node_status / decode_anomaly_alert / encode_health
  * 8 unit tests, including mesh_constants_match_firmware which asserts
    MESH_MAGIC/VERSION/HEADER_SIZE/MAX_PAYLOAD match rv_mesh.h
    byte-for-byte
  * Exported from lib.rs
  * signal/ruvector/train/mat crates untouched — satisfies ADR-081
    portability acceptance test

Tests (all passing):
  test_adaptive_controller:   18/18   (C, decide() 3.2 ns/call)
  test_rv_feature_state:      15/15   (C, CRC32 87 MB/s)
  test_rv_mesh:               27/27   (C, roundtrip 1.0 µs)
  radio_ops::tests (Rust):     8/8
  --- total:                 68/68 assertions green ---

Docs:
  * ADR-081 status flipped to Accepted
  * Implementation-status matrix updated; L3 + Rust mirror both
    marked Implemented
  * Benchmarks table extended with rv_mesh encode+decode roundtrip
  * Verification section updated with cargo test invocation
  * CHANGELOG: two new entries for L3 mesh plane + Rust mirror

Remaining follow-ups (Phase 3.5 polish, not blocking):
  * Mesh RX path (UDP listener + dispatch) on the firmware
  * Ed25519 signing for CHANNEL_PLAN / CALIBRATION_START
  * Hardware validation on COM7

* Add test_rv_mesh to host-test .gitignore

Fixes an untracked-file warning from the repo stop-hook: the compiled
binary was built by make but the .gitignore update was missed in
8dfb031. No source changes.

* Fix implicit decl of emit_feature_state in adaptive_controller

fast_loop_cb calls emit_feature_state() at line 224, but the static
definition is at line 256. GCC treats the implicit declaration as
non-static, then the real static definition conflicts, and
-Werror=all promotes both to hard build errors.

Add a forward declaration above the first use. Unblocks ESP32-S3
firmware build and all QEMU matrix jobs.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 10:38:23 -04:00
ruv 425f0e6aac fix(firmware): defensive node_id capture prevents runtime clobber (#390)
Users on multi-node ESP32 deployments have been reporting for months
that their provisioned `node_id` reverts to the Kconfig default of `1`
in UDP frames and the `csi_collector` init log, despite boot showing:

    nvs_config: NVS override: node_id=4
    main: ESP32-S3 CSI Node (ADR-018) - Node ID: 4
    csi_collector: CSI collection initialized (node_id=1, channel=11)

See #232, #375, #385, #386, #390. The root memory-corruption path for
the `g_nvs_config.node_id` byte has not been definitively isolated
(does not reproduce on my attached ESP32-S3 running current source
and the v0.6.0 release binary), but the UDP frame header can be made
tamper-proof regardless:

1. `csi_collector_init()` now captures `g_nvs_config.node_id` into a
   module-local `static uint8_t s_node_id` at init time.
2. `csi_serialize_frame()` reads `buf[4]` from `s_node_id`, not from
   the global - so any later corruption of `g_nvs_config` cannot
   affect outgoing CSI frames.
3. All other consumers (`edge_processing.c` x3, `wasm_runtime.c`,
   `display_ui.c`, `main.c swarm_bridge_init`) now go through a new
   `csi_collector_get_node_id()` accessor instead of reading the
   global directly.
4. A canary at end-of-init logs `WARN` if `g_nvs_config.node_id`
   already diverges from the captured value - this will pinpoint
   the corruption path if it happens on a user's device.

Hardware validation on attached ESP32-S3 (COM8):
  - NVS loads node_id=2
  - Boot log: `main: ... Node ID: 2`
  - NEW log: `csi_collector: Captured node_id=2 at init (defensive
    copy for #232/#375/#385/#390)`
  - Init log: `csi_collector: CSI collection initialized (node_id=2)`
  - UDP frame byte[4] = 2 (verified via socket sniffer, 15/15 packets)

This is defense in depth - it shields the UDP frame from whatever
upstream bug is clobbering the struct. When a user hits the original
bug, the canary WARN will help isolate the root cause.

Refs #232 #375 #385 #386 #390

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-15 13:47:34 -04:00
ruv b5e924cd72 fix: embed firmware version from version.txt, log at boot (#354)
- Add version.txt (0.6.0) read by CMakeLists.txt so
  esp_app_get_description()->version matches the release tag
- Log firmware version on boot: "v0.6.0 — Node ID: X"
- Remove stale Kconfig help text (said default 2.0, actual is 15.0)

Fixes the version mismatch reported in #354 where flashing v0.5.3
binaries showed v0.4.3 because PROJECT_VER was never set.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-06 11:26:58 -04:00