- 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
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
* 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>
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.
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ
Co-authored-by: Joe <joe@GitDev.hedgehog>
Co-authored-by: claude-flow <ruv@ruv.net>
A-B-A capture from the overnight run, ten 30 s windows per arm: pairing
fraction rises 0.5668 -> 0.7287 (Welch t=13.26, p<0.0001) when the fleet is
filtered to one transmitter, but paired observations/s falls 44% because
tx/s falls 56%. Fraction was the wrong figure of merit; not adopted.
Firmware changes (persistent on-node log, live filter_mac, and the node-3
pilot fixes) that produced this capture are held back pending a port against
main's restructured firmware/main/ tree (config_api.c and ota_update.c are
gone there).
Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ
Co-authored-by: Joe <joe@GitDev.hedgehog>
Co-authored-by: claude-flow <ruv@ruv.net>
The ESP32 edge-vitals packet (0xC511_0002) carries a raw i8 RSSI byte with
no "valid" flag. When the edge pipeline hasn't sampled a real reading yet,
it has been observed sending near-zero sentinel values (-1, -2 dBm) that
are physically implausible for a WiFi link. These were pushed straight
into the node's rssi_history and the room's fused mean_rssi, producing a
one-tick reading of ~-2 dBm on node 3 while its own CSI path simultaneously
showed a real ~-50 dBm signal.
Add an is_plausible_rssi() guard (mirroring the existing
update_csi_fps_ema() plausibility-filter pattern) so an implausible RSSI
falls back to the node's last known-good reading instead of overwriting it.
StreamingEngine owns its own MultistaticFuser instance -- a different one
from wifi-densepose-sensing-server's AppState, which was fixed for this
same class of bug in #1860. Nothing ever called set_node_positions /
set_node_positions_by_id on this instance, so every FusedSensingFrame
produced by the governed path (fuse_scored_calibrated, which feeds
provenance and the witness) carried every node's position as the origin
regardless of deployment configuration.
Adds StreamingEngine::set_node_positions(HashMap<u8, [f32;3]>), delegating
to the fuser's existing set_node_positions_by_id. Also fixes a related
hazard in set_multistatic_config: it rebuilt the fuser wholesale
(MultistaticFuser::with_config(cfg)), which silently discarded any
positions configured before it. It now preserves them across the rebuild,
so callers don't need to remember a specific ordering between the two
setters.
Two regression tests per the issue's own acceptance criteria: one calling
fuse_scored_calibrated directly and asserting each fused contribution
carries its own node's configured position, and one asserting positions
survive a later set_multistatic_config call.
Fixes#1870.
scripts/train-ruvllm.js ran two separate computations during contrastive
pretraining: ContrastiveTrainer.train() (a baseline pass that computes loss
but never updates the encoder) and a hand-written loop that actually applies
gradient updates to encoder.w2. The exported training-metrics.json took
lossHistory and contrastive.durationMs from the first (no-op) pass instead
of the second (real) one, so the published curve was flat/meaningless
(~0.1352 for all 20 epochs) and the duration was ~30x too short.
This did not affect finalLoss/improvement or the held-out temporal-triplet
accuracy metric published for this model on HuggingFace
(ruvnet/wifi-densepose-pretrained) -- those already came from the real loop.
It only affected the diagnostic curve/duration anyone auditing convergence
from the published JSON would see.
Also: config.json's training.steps field (epochs * triplets, technically
consistent but reads like an optimizer-step count) is now reported as
separate epochs/tripletUpdatesPerEpoch fields, and the exported version
bumped 1.0.0 -> 2.0.1 to match the "v2" naming already used elsewhere for
this file set.
Verified by re-running against the real capture
(data/recordings/overnight-1775217646.csi.jsonl): lossHistory now shows a
genuine decreasing curve (0.1037 -> 0.0654 across 20 epochs, matching the
already-published finalLoss exactly), durationMs reports the real ~40s
gradient-loop time instead of ~12s.
Model card updated with the same disclosure (docs/huggingface/MODEL_CARD.md)
plus a real, previously-undisclosed EWC forgetting-rate figure (32.97%,
present in training-metrics.json but not surfaced in the human-readable
card) and a corrected repro-script pointer.
`MultistaticConfig::for_tdm_schedule` set the soft guard to `cycle_us / 3`,
reasoned about in its own doc comment as "a normal adjacent-slot pair fuses
cleanly". But `fuse_scored` does not compare pairs -- it takes
`max(timestamp) - min(timestamp)` across the whole cohort, so the spread it
measures is always the first-to-last offset of `(N - 1)` slots.
Against that bound:
soft < stagger <=> N*slot/3 < (N-1)*slot <=> N/3 < N-1 <=> N > 1.5
which holds for every N >= 2. The derived soft guard never covered the schedule
it was derived from, at any fleet size, so a complete healthy cycle was recorded
as a `TimestampMismatch` contradiction -- which demotes the BFLD privacy class
one step (ADR-137 §2.7 -> ADR-141) and suppresses raw outputs.
The constructor's own doc example is the smallest demonstration. It exists to
accommodate a MEASURED 18,194 us spread on a 2-slot mesh, and asserts the hard
guard clears it -- but the soft guard it derives for that same mesh is
36,000/3 = 12,000 us, below the measurement that motivated the constructor.
Soft is now the designed stagger, `(slots - 1) * slot_duration_us`, plus the
same 20% jitter headroom the hard guard already takes. The headroom is not
cosmetic: the nominal 2-slot stagger is 18,000 us while the observed spread is
18,194 us, so a bare nominal bound would still flag its own motivating cycle.
`stagger < cycle` always, so the result stays strictly below the hard guard and
a spread approaching a full cycle is still flagged as loose alignment.
Why this survived: every existing assertion on `soft_guard_us` is relational
(`soft < hard`, `soft >= 1`). None ever compared it to a spread the schedule
actually produces. Three tests are added -- the derived guard clears the stagger
across 2..13 slots, a full evenly-staggered cycle raises no contradiction, and a
spread beyond the stagger is still flagged, so the fix cannot be mistaken for
disabling the check. The first two fail against `cycle / 3`; the third passes
either way by design.
Scope: this corrects the DERIVED path only. The hand-set defaults (60 ms hard /
20 ms soft) are unchanged and remain correct for the 2-slot mesh they were sized
against.
Software-only scope. No hardware-accuracy claim is made.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
`MultistaticFuser::fuse` addressed node positions by the frame's index in the
cohort it was handed. That index is a rank, not an identity: the cohort from
`node_frames_from_states_with_guard` is compacted -- stale, out-of-guard and
empty-history nodes are dropped before `fuse` ever sees them. So one board
missing a beacon re-ranked every node above it and handed each of them its
neighbour's coordinates, silently and fleet-wide.
`MultiBandCsiFrame` already carries its own `node_id`, so the identity was
present at the boundary and simply unused. Positions are now stored keyed by
node id and resolved per frame by that id.
- `set_node_positions_by_id` takes the keyed map; `main` passes the same
`node_positions_by_id` map that already feeds `NodeInfo`, so one parsed
identity now serves both the metadata and the effectful path (#1860 fixed
only the former).
- `set_node_positions(Vec)` is kept as a documented compatibility mapping:
entry `i` is node `i`, which is exactly what a bare `--node-positions` list
has always meant. `node_positions()` returns that dense view unchanged.
- `FusedSensingFrame::node_positions` stays parallel to `node_frames`, but each
entry is resolved from its own frame's id, so the pairing survives compaction.
Behaviour change: with a bare (unprefixed) `--node-positions` list on a fleet
whose ids are not dense and zero-based, a node now reads the entry declared for
its id rather than the entry at its rank. That is the defect being fixed; ids
given explicitly as `node_id:x,y,z` are unaffected, and so is any fleet whose
ids are dense from zero with every node reporting.
Tests: sparse ids, reversed configuration order, a configured-but-absent node,
an unconfigured node, join/leave churn, the preserved legacy positional
mapping, and a negative control pinning the compacted-cohort case. Six of the
eight fail against the pre-fix lookup; the legacy-mapping test is expected to
pass both ways, and order-independence became structural with keyed storage.
Software-only scope. No hardware-accuracy claim is made.
Closes#1866
Refs #1860
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
Records why device-free position kept failing here, measured rather than
argued, and the join key built in response. The implementing PRs land without
this and are harder to read for it.
**Phase is unusable on this hardware.** The bistatic tier needed a signed
per-link radial velocity, which requires the common-mode component of CSI
phase. Measured across three ESP32-C6 nodes over ~40,000 frames, the
frame-to-frame change in common-mode phase is statistically indistinguishable
from uniform random: resultant length R = 0.011-0.032 against a 0.000
reference, sd 1.78-1.81 against pi/sqrt(3) = 1.814, no lag-1 autocorrelation.
A second, unwrap-free estimator agreed.
The mechanism is packet-detection timing quantization. Common-mode phase is
-2*pi*f_c*tau, and one ADC sample of jitter at 20 MHz is about 758 rad -- some
120 full wraps, re-randomized every packet. Not calibratable. The bistatic tier
is default-off as a result.
**Amplitude works but had no parallax.** The three AP->node links leave a single
distant transmitter inside a 36.5 degree fan; scalar per-node energy weighting
closed with a clean negative result -- magnitude responds to proximity,
direction does not.
What follows from both: a position tier needs several transmitters seen from
several receivers, and fusing those requires knowing that two nodes observed
the SAME transmission. Arrival timestamps cannot say so -- the sink sees network
jitter, not capture time -- and the per-node sequence counter is private to each
node. Hence (tx_mac, rx_seq): the 802.11 sequence control field is assigned by
the transmitter and is identical at every receiver, so it names one
transmission without any coordination between receivers, no shared clock and no
negotiation.
Status is Proposed, deliberately. The measurement layer is implemented and
validated on hardware; localization is NOT built, and the ADR says so along
with the rest of its known limitations -- peer links are ~10x weaker than data
frames, three links is below the threshold for tomographic localization
regardless of quality, and no position tier consumes the link table.
References only ADR-018, ADR-060 and ADR-110, all already present here.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
Same reason as the operational content itself: a procedure nobody can find is a
procedure nobody follows. The README's documentation table is where a reader
looks, and the runbook was reachable only by browsing the firmware directory.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
The study was reachable only by knowing the file existed. Nothing in the repo
pointed at it, so a reader following the README would never learn that the
question had been measured at all.
Adds a row to the documentation table with the headline result and the fact
that the harness ships with it. Deliberately states both numbers -- the +31.7 pp
that seq bought and the +0.5 pp that mesh alignment did not -- so the index
entry cannot be read as advocacy for one setting.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
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
The mesh gate uses mesh-time buckets only while c6_sync_espnow_is_valid(); an
unsynced node falls back to the elapsed gate. Three of nine nodes never
synchronised during these runs, so for those three the mesh and mesh+seq arms
silently ran the elapsed gate.
+0.5 pp is therefore not a clean fleet-wide measurement of window alignment.
Reported as observed rather than reconstructed: the arms were assigned before
we understood the fallback, and a post-hoc per-node reanalysis would not be the
experiment we ran.
Names the cause as environmental rather than a defect. Sync is leader-elected
and hub-and-spoke, so whether every node hears the leader is a property of the
building -- and a house that fragments into sync domains degrades any
shared-time option quietly, for exactly the nodes whose data you would most
want.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
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
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
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
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
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
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
Upstream added a `node_positions_config` of its own while this branch was
waiting, modelled as an ordered `Vec` with a `node_positions_by_active_id`
helper that gives the Nth-lowest ACTIVE node id the Nth entry.
That is the same class of bug this branch exists to fix, one level up: a
position now depends on which OTHER nodes happen to be alive. One node going
quiet re-ranks every node above it and silently moves their coordinates.
Resolved by keying the map by node id throughout -- the struct field, its
initialiser, the local in main() and the helper -- and keeping upstream's
active-node filter unchanged.
Upstream's three helper tests are ported to the keyed form, and one is added
that pins the difference: a stale node must not move its neighbours.
Verified: cargo test -p wifi-densepose-sensing-server --no-default-features
passes.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
`csi_fps_ema` currently under-reports whenever more than one frame arrives in a
burst, and the under-report is proportional to the burst size.
The present scheme rejects sub-5 ms deltas and, crucially, does NOT re-anchor
on them. Holding the anchor means the next accepted delta spans the whole
burst while contributing a single sample -- so a burst carrying N frames is
counted as one. That is invisible when a burst holds one frame, which is the
case the current comment describes, and an exact halving when it holds two.
Two changes, both in the same direction:
- Average in the DELTA domain and invert, rather than averaging 1/dt. The
reciprocal is unbounded as dt approaches zero, so one 36 us arrival can
outweigh hundreds of nominal ones; an average of deltas cannot be dominated
that way. This is what makes it safe to stop discarding short deltas.
- Re-anchor on every arrival. One delta per frame means the deltas sum to the
elapsed time, which is the condition that makes the estimator equal
frames/elapsed.
Simulated against the same arrival stream, a node genuinely producing 40 fps
whose frames are delivered in pairs (four frames per 100 ms):
clean 40 fps, evenly delivered before 40.0 after 40.0
bursty 40 fps, pairs per 50 ms before 20.0 after 40.6
The clean-cadence control is unchanged; the bursty case stops halving.
MAX_PHYSICAL_CSI_FPS is retained and still enforced at the consumption
boundary in `measured_sample_rate_hz`, so restored or malformed state cannot
overclock the DSP math. The estimator itself is left unclamped so that a rate
above the ceiling remains visible as the anomaly it is rather than being
silently flattened to 50.
TRADE-OFF, stated plainly: alpha moves from 1/8 to 1/32, so the estimate is
about three times slower to settle after a genuine rate change. That is the
price of not letting a single short delta swing it, and the steady-state test
horizon is widened to match.
Tests: `observe_csi_frame_arrival_ignores_subms_bursts` is replaced. It fed
three arrivals per 25 ms group -- 120 frames per second, which no single node
can emit under the firmware's own 50 fps send ceiling -- and asserted the
result should read 40, which only holds if two of every three real frames are
discarded. The replacement uses a physically consistent stream and asserts the
same 40 fps ground truth.
Full suite passes.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
`make host_tests` writes its binaries beside their sources, inside a tracked
directory. .gitignore named five of them individually, and the list had fallen
behind the Makefile: test_csi_sanitize, test_mmwave_detect and test_thermal
were built by the default target, ignored by nothing, and got committed by
accident on two contribution branches.
Enumerating targets cannot hold -- every new host test needs someone to
remember this file. So ignore by shape instead: in that directory every source
carries an extension and no binary does.
firmware/esp32-csi-node/test/test_*
firmware/esp32-csi-node/test/fuzz_*
!firmware/esp32-csi-node/test/*.*
Verified rather than reasoned about. After building every host test,
`git status --porcelain` reports only this file. `git check-ignore` confirms
all ten binaries are ignored -- including test_edge_grid_he and
test_edge_grid_pre_he, which are not on main yet -- while the .c sources, the
Makefile, the README and the corpus seeds stay tracked.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo