From 7cdf5714e8141265fb4f4e48dc20938e2e7ce1d3 Mon Sep 17 00:00:00 2001 From: Joe Date: Sun, 30 Aug 2026 20:57:26 -0400 Subject: [PATCH 1/2] 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 (cherry picked from commit 4b54ef8ecb2a28d173d411a064809dc081c43f52) --- firmware/esp32-csi-node/main/csi_collector.c | 51 +++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/firmware/esp32-csi-node/main/csi_collector.c b/firmware/esp32-csi-node/main/csi_collector.c index 6a15443fa..52b3a9002 100644 --- a/firmware/esp32-csi-node/main/csi_collector.c +++ b/firmware/esp32-csi-node/main/csi_collector.c @@ -114,6 +114,9 @@ static int64_t s_last_send_us = 0; */ #define CSI_MIN_PROCESS_INTERVAL_US (20 * 1000) /* 50 Hz */ static int64_t s_last_process_us = 0; +/* Mesh-epoch window index of the last accepted frame. UINT64_MAX means "none + * yet", which no real bucket can collide with. */ +static uint64_t s_last_gate_bucket = UINT64_MAX; static uint32_t s_early_drop = 0; /* ---- ADR-029: Channel-hop state ---- */ @@ -277,9 +280,53 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info) (void)ctx; /* Early rate gate: drop excess callbacks to ~50 Hz to prevent - * SPI flash cache crash in WiFi ISR (wDev_ProcessFiq). */ + * SPI flash cache crash in WiFi ISR (wDev_ProcessFiq). + * + * HOW the rate is limited decides whether cross-node fusion is possible. + * + * The original gate is "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 and both hit + * 50 Hz, yet they accept disjoint frames and nothing can be paired. + * + * MEASURED 2026-08-30, two boards side by side: 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. + * + * So gate on a bucket of the MESH-ALIGNED epoch instead: every node takes + * the first frame it hears in the same absolute 20 ms window. Two nodes + * that heard the same frame now both accept it. 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 mesh sync is not + * valid (no leader heard yet, or a node that just booted), because an + * unsynced epoch would otherwise gate on a meaningless number. A node + * running unsynced simply gets the old behaviour rather than no gate. + * + * The half-interval floor below keeps the crash protection honest: bucket + * gating alone could accept at the end of one window and the start of the + * next, back to back. The floor bounds that to ~100 Hz for a single pair + * while the average stays at 50 Hz. Do not remove it; the gate exists for + * a crash, not for tidiness. */ int64_t now_us = esp_timer_get_time(); - if ((now_us - s_last_process_us) < CSI_MIN_PROCESS_INTERVAL_US) { + bool take; +#ifdef CONFIG_CSI_GATE_MESH_ALIGNED + if (c6_sync_espnow_is_valid()) { + uint64_t bucket = c6_sync_espnow_get_epoch_us() + / (uint64_t)CSI_MIN_PROCESS_INTERVAL_US; + take = (bucket != s_last_gate_bucket) + && ((now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US / 2); + if (take) { + s_last_gate_bucket = bucket; + } + } else { + take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US; + } +#else + take = (now_us - s_last_process_us) >= CSI_MIN_PROCESS_INTERVAL_US; +#endif + if (!take) { s_early_drop++; return; } From e8e4e5cf67f18fcd3c4b7db4409466598eb93371 Mon Sep 17 00:00:00 2001 From: Joe Date: Mon, 7 Sep 2026 12:28:57 -0400 Subject: [PATCH 2/2] 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 Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo --- firmware/esp32-csi-node/main/Kconfig.projbuild | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/firmware/esp32-csi-node/main/Kconfig.projbuild b/firmware/esp32-csi-node/main/Kconfig.projbuild index 6f4f5bfd8..aa3ccaaae 100644 --- a/firmware/esp32-csi-node/main/Kconfig.projbuild +++ b/firmware/esp32-csi-node/main/Kconfig.projbuild @@ -517,3 +517,20 @@ menu "Onboard LED (ADR-183)" colormap, in milli-units (250 = 0.25). Lower = more sensitive (reaches yellow with less motion). endmenu + +config CSI_GATE_MESH_ALIGNED + bool "Align the CSI rate gate to mesh time (enables cross-node pairing)" + default y + help + The per-node elapsed gate ("20 ms since MY last accept") gives every node + an independent phase, so two healthy nodes at 50 Hz accept disjoint + frames and nothing can be paired across them. This aligns the 20 ms + window to mesh time so nodes choose the same windows. + +config CSI_SEQ_DIAG + bool "Log (source MAC, rx_seq) for every CSI callback (ADR-345 phase 2 probe)" + default n + help + Diagnostic. Emits the transmitter address and 802.11 sequence number for + each CSI callback so the effect of the gate above can be measured rather + than assumed. Verbose; leave off in normal operation.