diff --git a/firmware/esp32-csi-node/main/Kconfig.projbuild b/firmware/esp32-csi-node/main/Kconfig.projbuild index 404745c5e..067a5c32c 100644 --- a/firmware/esp32-csi-node/main/Kconfig.projbuild +++ b/firmware/esp32-csi-node/main/Kconfig.projbuild @@ -673,3 +673,20 @@ config UPLINK_WATCHDOG_TIMEOUT_S packet's health field before assuming the watchdog is responsible. 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. diff --git a/firmware/esp32-csi-node/main/csi_collector.c b/firmware/esp32-csi-node/main/csi_collector.c index dfa6398bb..57c5b49f8 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 ---- */ @@ -285,9 +288,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; }