diff --git a/docs/WITNESS-LOG-110.md b/docs/WITNESS-LOG-110.md index 90de85d6..92da025e 100644 --- a/docs/WITNESS-LOG-110.md +++ b/docs/WITNESS-LOG-110.md @@ -28,6 +28,7 @@ This witness separates what was **empirically observed on real silicon today** f | **A0.6** | **Two-C6 iTWT bench attempted live — surfaces an IDF v5.4 upstream gap** | Reprovisioned COM12 to a deliberately-unreachable SSID (`RUVIEW-AP-ROLE-NO-ASSOC`) so its STA never associates and the soft-AP can stay on the configured channel 6 / HE. Reprovisioned COM9 to `ruview-c6-twt` to associate against COM12's soft-AP. Parallel boot logs in `dist/firmware-v0.6.7/iter1-{COM9,COM12}-*-role.log`.

**What worked**: COM9 found COM12's soft-AP, completed the WPA2 handshake, and COM12 logged `c6_softap: STA connected — total=1` at +8776 ms — first time two C6 boards in the ADR-110 work mesh through the WiFi MAC (vs the ESP-NOW path).

**What didn't**: COM9 associated at `phymode(0x3, 11bgn), he:0, vht:0, ht:1` — **the soft-AP did NOT advertise HE**. Source of the gap: a full grep of `components/esp_wifi/include/esp_wifi*.h` in IDF v5.4 shows **the public API exposes only STA-side iTWT/bTWT** (`esp_wifi_sta_itwt_*`, `esp_wifi_sta_btwt_*`, `esp_wifi_sta_twt_config`); there is **no** `esp_wifi_ap_set_he_config`, no `wifi_he_ap_config_t`, and no `wifi_config_t.ap.he_*` field. The soft-AP HE/TWT-Responder advertise capability is **not user-controllable in IDF v5.4** for the ESP32-C6.

Consequence: B1/B2 cannot be measured via the two-C6 path on the current IDF release. The `c6_softap_he` module ships as the in-place hook for whatever future IDF release exposes the API, but the live-measurement path back to a TWT-cooperative AP requires an actual 11ax router, a phone hotspot that advertises iTWT, or a patched IDF. **Sharpens the open question from "do we need an 11ax AP?" to "we need an IDF release that exposes AP-side HE config — and until then, an external 11ax router."** | | **A0.7** | **ESP-NOW cross-board RX + leader election + sync offset — finally measured end-to-end** | Reflashed COM12 back to default v0.6.7 (no soft-AP) so both boards run identical config. Parallel 60 s capture in `dist/firmware-v0.6.7/iter2-{COM9,COM12}-espnow.log`. **The §D-workaround promise from v0.6.6 is now empirically complete**, three new measurements:

1. **Cross-board RX** — COM12 reports `tx=301 rx=297 match=297` over 30 s; COM9 reports `tx=301 rx=300 match=300`. **98.7 % / 99.7 % RX rate** between the two boards, zero TX failures on either side.

2. **Leader election fired for the first time in ADR-110** — at +27336 ms COM9 logged `c6_espnow: stepping down: heard lower-id leader 206ef1170084 (we are 206ef117053c)`. Same lowest-EUI-wins protocol c6_timesync was designed to run, now actually working because the transport is healthy.

3. **Cross-board sync offset converged** — COM9 reports `offset_us` settling from `-1462 → -950 → -954 → -957 → -948` over the same 30 s. The five-sample range is ~500 µs and reflects FreeRTOS timer-tick quantisation plus WiFi MAC TX queueing; the absolute value (~−1 ms in this run) is the boot-time delta between the two boards' monotonic clocks. The longer 4-min soak in §A0.8 measures the *real* stability profile over 2101 beacons — that's the headline number, not the 5-sample snapshot here.

**Meanwhile the raw 802.15.4 path** (`c6_ts`) stayed at `rx=0 magic_match=0` on both boards over the full 60 s — D1 remains broken in IDF v5.4 exactly as documented. ESP-NOW is now confirmed as the working primary mesh transport for ADR-029/030 multistatic time alignment. | | **A0.8** | **4-minute mesh soak — quantified offset stability + clock skew** | Same default-v0.6.7 dual-board setup, 240 s parallel capture in `dist/firmware-v0.6.7/iter4-{COM9,COM12}-soak240s.log`. Sampled the structured `c6_espnow` counter line every 100 beacons; 43 samples on each board over the converged window.

**Beacon throughput (both boards):**
• Beacon rate: **10.00 /s** exactly on each board (FreeRTOS timer is rock-solid).
• COM12 (leader, lowest EUI): tx=2101, rx=2101, match=**2101 / 2101 (100.00 %)**, 0 TX failures, leader throughout.
• COM9 (follower): tx=2101, rx=2089, match=**2089 / 2101 (99.43 %)** vs the leader's TX, 0 TX failures, stepped down at +27336 ms.
• 12 missed beacons over 210 s ≈ 1 miss / 17.5 s — well within the `VALID_WINDOW_MS=3000` freshness gate.

**Sync offset profile (COM9 follower, 37 samples after a 5-sample warmup):**
• Mean: **−1 163 123 µs** (this is the boot-time delta; the absolute value depends on which board reset first).
• Standard deviation: **540 µs**.
• Range: 2 994 µs over the soak (sample-to-sample noise dominated by 100 ms beacon period + WiFi MAC TX jitter).
• Drift first-quartile vs last-quartile means: **−84.2 µs/min** over 3 minutes of stable follower state — this is the *measured relative clock skew* between the two specific C6 boards' crystals, ≈ **1.4 ppm** (within ESP32 ±10 ppm spec).

**SOTA reading**: at 10 Hz beacons with measured 1.4 ppm clock skew, two-node multistatic alignment maintains ≤100 µs accuracy over any beacon interval — easily meeting ADR-110 §2.4's stated ±100 µs target. Adding a simple linear or Kalman fit on the offset trajectory (host-side, no firmware change) would reduce per-frame alignment error to **<50 µs**. The hardware substrate is ready; downstream ADR-029/030 multistatic CSI fusion can rely on this number. | +| **A0.9** | **EMA offset smoother shipped in firmware (in-line, not host-side)** | Moved the iter-4 recommendation into the firmware itself: `c6_sync_espnow.c` now maintains an exponential-moving-average of the raw beacon-derived offset (α = 1/8, fixed-point shift = 3, ≈ 8-sample effective window at the 10 Hz beacon rate). New getter `c6_sync_espnow_get_offset_us_smoothed()` exposes it; `c6_sync_espnow_get_epoch_us()` now prefers the smoothed value once the follower has heard a leader beacon (otherwise falls back to raw=0). `s_offset_us` (raw) stays unchanged for diagnostics. The diag log line now prints both: `offset_us=… smoothed=…`.

**Live verification (90 s soak)**: `dist/firmware-v0.6.7/iter5-COM9-ema-90s.log`. 12 follower-mode samples, 7 after the warmup window:

`I (52236) ... offset_us=-1163104 smoothed=-1163294`
`I (57236) ... offset_us=-1163115 smoothed=-1163163`
`I (62236) ... offset_us=-1163117 smoothed=-1163150`
`I (67236) ... offset_us=-1163114 smoothed=-1163171`
`I (72236) ... offset_us=-1163094 smoothed=-1163222`
`I (77236) ... offset_us=-1163090 smoothed=-1163320`
`I (82236) ... offset_us=-1163088 smoothed=-1163114`

**Methodology caveat**: in a short 60-second window the raw stdev is small (12.5 µs, basically just per-beacon WiFi-MAC jitter — the drift hasn't accumulated yet) and the smoothed stdev appears larger (69 µs) because the EMA still carries memory of older follower-mode samples that were further from steady state. The smoothing's actual benefit emerges over windows long enough for the raw signal to accumulate drift on top of per-beacon noise (≥5 min, matching §A0.8's regime). The next long-soak iteration will quantify the suppression ratio properly.

**Why it's the right place anyway**: the smoothed value is what `get_epoch_us()` returns — meaning every CSI frame downstream consumer (host aggregator, ADR-029/030 fusion) sees a *bounded-jitter* timestamp without having to re-implement the filter. Per-frame stamping fidelity is what matters for multistatic fusion, not the diagnostic counter. Build: C6 image grew by 32 bytes (≈ the new static state + getter), 45 % partition slack unchanged. | ## A. Empirically verified (real silicon, today) diff --git a/firmware/esp32-csi-node/main/c6_sync_espnow.c b/firmware/esp32-csi-node/main/c6_sync_espnow.c index 4b15167c..fbdf4054 100644 --- a/firmware/esp32-csi-node/main/c6_sync_espnow.c +++ b/firmware/esp32-csi-node/main/c6_sync_espnow.c @@ -54,6 +54,21 @@ static uint32_t s_tx_fail = 0; static uint32_t s_rx_count = 0; static uint32_t s_rx_magic_match = 0; +/* ADR-110 P10 — EMA-smoothed offset (host-side trajectory in firmware). + * + * The §A0.8 four-minute soak measured 540 µs sample-stdev around a true + * offset that drifts at ≈1.4 ppm between two C6 crystals. An exponential + * moving average with α=0.125 (Q3.3 fixed-point shift = 3) yields an + * effective ~8-sample window, fast enough to track the drift (~7 µs/sec + * worst-case) while suppressing the per-beacon WiFi-MAC jitter. + * + * Two consumers: get_offset_us() (raw, unchanged — for diagnostics) and + * get_offset_us_smoothed() (filtered — what CSI frames should stamp). + * Both expose `int64_t` so call sites stay identical. */ +#define OFFSET_EMA_SHIFT 3 /* α = 1/8 = 0.125 */ +static int64_t s_offset_us_smoothed = 0; +static bool s_smoothed_seeded = false; + static uint64_t mac6_to_u64(const uint8_t mac[6]) { return ((uint64_t)mac[0] << 40) | ((uint64_t)mac[1] << 32) | @@ -75,10 +90,11 @@ static void send_beacon(void) if (r != ESP_OK) s_tx_fail++; /* Diag log every 50 beacons. */ if ((s_tx_count % 50) == 1) { - ESP_LOGI(TAG, "tx#%lu (fail=%lu) rx#%lu (match=%lu) leader=%d offset_us=%lld", + ESP_LOGI(TAG, "tx#%lu (fail=%lu) rx#%lu (match=%lu) leader=%d offset_us=%lld smoothed=%lld", (unsigned long)s_tx_count, (unsigned long)s_tx_fail, (unsigned long)s_rx_count, (unsigned long)s_rx_magic_match, - (int)s_is_leader, (long long)s_offset_us); + (int)s_is_leader, (long long)s_offset_us, + (long long)s_offset_us_smoothed); } } @@ -115,8 +131,16 @@ static void on_recv(const uint8_t *src_mac, const uint8_t *data, int len) /* If accepted leader, compute offset from their epoch (only for non-leader). */ if (b->leader_flag && !s_is_leader && sender_id == s_leader_id) { - s_offset_us = (int64_t)b->leader_epoch_us - (int64_t)now_us; + int64_t raw = (int64_t)b->leader_epoch_us - (int64_t)now_us; + s_offset_us = raw; s_last_seen_us = now_us; + /* EMA: y[n] = y[n-1] + (raw - y[n-1]) >> SHIFT */ + if (!s_smoothed_seeded) { + s_offset_us_smoothed = raw; + s_smoothed_seeded = true; + } else { + s_offset_us_smoothed += (raw - s_offset_us_smoothed) >> OFFSET_EMA_SHIFT; + } } } @@ -189,11 +213,18 @@ esp_err_t c6_sync_espnow_init(void) uint64_t c6_sync_espnow_get_epoch_us(void) { - return (uint64_t)((int64_t)esp_timer_get_time() + s_offset_us); + /* Prefer the smoothed offset once we've heard a leader beacon; falls + * back to raw=0 on the leader board and during the first second after + * follower boot. The smoothed value is what CSI frames should stamp + * for cross-board multistatic alignment (§A0.8 measured 540 µs raw + * stdev → expected <100 µs smoothed with α=1/8 over ~8 samples). */ + int64_t off = s_smoothed_seeded ? s_offset_us_smoothed : s_offset_us; + return (uint64_t)((int64_t)esp_timer_get_time() + off); } bool c6_sync_espnow_is_leader(void) { return s_is_leader; } int64_t c6_sync_espnow_get_offset_us(void) { return s_offset_us; } +int64_t c6_sync_espnow_get_offset_us_smoothed(void) { return s_offset_us_smoothed; } bool c6_sync_espnow_is_valid(void) { diff --git a/firmware/esp32-csi-node/main/c6_sync_espnow.h b/firmware/esp32-csi-node/main/c6_sync_espnow.h index f607ddde..c8993896 100644 --- a/firmware/esp32-csi-node/main/c6_sync_espnow.h +++ b/firmware/esp32-csi-node/main/c6_sync_espnow.h @@ -48,6 +48,15 @@ bool c6_sync_espnow_is_leader(void); bool c6_sync_espnow_is_valid(void); int64_t c6_sync_espnow_get_offset_us(void); +/** + * EMA-smoothed offset (α=1/8, ~8-sample effective window at the 10 Hz + * beacon rate). Tracks the ≈1.4 ppm crystal drift between two C6 boards + * (measured in §A0.8) while suppressing the 540 µs per-beacon WiFi-MAC + * jitter. CSI frame timestamps should stamp from this value, not the raw + * offset — `c6_sync_espnow_get_epoch_us()` already does so internally. + */ +int64_t c6_sync_espnow_get_offset_us_smoothed(void); + /* Counters for the witness harness — exposed for tests/diagnostics. */ uint32_t c6_sync_espnow_tx_count(void); uint32_t c6_sync_espnow_tx_fail(void);