feat(firmware): ESP32-C6 target — Wi-Fi 6 / 802.15.4 / TWT / LP-core (ADR-110)

`firmware/esp32-csi-node` now builds for both `esp32s3` (existing
production) and `esp32c6` (new research / battery-seed target) from
the same source tree. ESP-IDF auto-applies `sdkconfig.defaults.esp32c6`
when the target is set to esp32c6; every C6 module is gated on
CONFIG_IDF_TARGET_ESP32C6 (or the SOC_WIFI_HE_SUPPORT capability) so
the S3 build path is byte-identical to today.

New modules (all #ifdef-gated, no-op stubs on S3):
- c6_twt.{h,c}      — iTWT wrapper, graceful AP-NACK fallback
- c6_timesync.{h,c} — 802.15.4 beacon-based mesh time-sync, EUI-64
                      leader election, c6_timesync_get_epoch_us()
- c6_lp_core.{h,c}  — wake-on-motion deep-sleep helper (ext1 path
                      this cut; real LP-core polling deferred)

ADR-018 frame extension:
- byte 18: PPDU type (0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB)
- byte 19: bandwidth + STBC + 802.15.4-sync-valid flags
- Magic 0xC5110001 unchanged — backwards compatible
- Dual-branch encoding handles both struct variants of
  wifi_pkt_rx_ctrl_t (legacy S3 / HE C6) per CONFIG_SOC_WIFI_HE_SUPPORT

Critical bug fixed during live witness collection (verified across 3
boards on COM6/COM9/COM12):
- c6_timesync.c read MAC into a 6-byte buffer and ran MAC-48->EUI-64
  conversion. But esp_read_mac(ESP_MAC_IEEE802154) returns 8 bytes
  already in EUI-64 form on C6 — code was double-inserting FFFE.
  Boot log was 206ef1fffefffe17, fix yields 206ef1fffe17278c which
  matches esptool's eFuse reading exactly.

Tooling:
- CI workflow (firmware-ci.yml) extended with c6-4mb matrix row +
  ADR-110 host-unit-test step
- Host unit tests for pure functions (mac48_to_eui64,
  eui64_bytes_to_u64, PPDU encoding both branches) — runs on Ubuntu CI
- Multi-board live-capture harness (test/capture-3board-experiment.py)
- Witness bundle script records SHA-256s for s3-adr110, c6-adr110, and
  s3-fair-adr110 (apples-to-apples) binary archives

Honest empirical findings (full report in docs/WITNESS-LOG-110.md):
- Verified live on 3 C6 boards: boot, 802.15.4 init w/ correct EUIs,
  WiFi STA reaching assoc->run on ruv.net, TWT setup attempted +
  gracefully NACKed (AP is 11n-only, TWT Responder:0), HE-MAC firmware
  loaded
- NOT verified (need 11ax AP / second-channel exp / INA meter):
  HE-LTF subcarrier expansion, TWT cadence determinism, ±100 µs sync
  alignment, 5 µA hibernation
- Bug found: leader election doesn't step down under live WiFi load —
  likely 2.4 GHz radio coex preemption (WiFi ch 5 vs 15.4 ch 15);
  follow-up task #30
- Apples-to-apples size: S3-no-display = 886 KB, C6 = 1003 KB
  (C6 is 13% LARGER for equivalent CSI features; the extra is the
  802.15.4 + OpenThread stack that S3 lacks)

Tracking: ruvnet/RuView#762

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-05-22 20:10:30 -04:00
parent 68abb385ae
commit f23e34ee5c
35 changed files with 1720 additions and 13 deletions
+16 -3
View File
@@ -37,9 +37,22 @@ MAIN_DIR = ../main
FUZZ_DURATION ?= 30
FUZZ_JOBS ?= 1
.PHONY: all clean run_serialize run_edge run_nvs run_all
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 host_tests
all: fuzz_serialize fuzz_edge fuzz_nvs
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110
# --- ADR-110 encoding unit tests ---
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
# for mac_to_eui64() and PPDU-type → ADR-018 byte 18 mapping.
# Builds with stock cc/gcc/clang — runs in CI on Ubuntu.
test_adr110: test_adr110_encoding.c
cc -std=c99 -Wall -Wextra -o $@ $<
run_adr110: test_adr110
./test_adr110
host_tests: run_adr110
@echo "ADR-110 host tests passed"
# --- Serialize fuzzer ---
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
@@ -75,5 +88,5 @@ run_nvs: fuzz_nvs
run_all: run_serialize run_edge run_nvs
clean:
rm -f fuzz_serialize fuzz_edge fuzz_nvs
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/
@@ -0,0 +1,125 @@
"""ADR-110 multi-board live capture — 802.15.4 sync + TWT + HE-LTF.
Captures from up to 3 ESP32-C6 boards simultaneously, resets them
together so the leader election starts from a clean slate, then
records 35 s of serial output to per-port log files and prints
a summary of the time-sync state machine, TWT events, and CSI
metadata at the end.
"""
import serial
import threading
import time
import re
import sys
from pathlib import Path
PORTS = ['COM6', 'COM9', 'COM12']
DURATION_SECONDS = 35
OUTPUT_DIR = Path(__file__).parent / 'witness-3board'
OUTPUT_DIR.mkdir(exist_ok=True)
def capture(port: str, results: dict):
"""Reset and capture from one port for DURATION_SECONDS."""
try:
ser = serial.Serial(port, 115200, timeout=1)
# Hard reset via DTR/RTS pulse.
ser.setDTR(False); ser.setRTS(True); time.sleep(0.05)
ser.setDTR(False); ser.setRTS(False)
ser.reset_input_buffer()
buf = bytearray()
start = time.time()
while time.time() - start < DURATION_SECONDS:
data = ser.read(4096)
if data:
buf.extend(data)
ser.close()
log_path = OUTPUT_DIR / f'{port}.log'
log_path.write_bytes(bytes(buf))
text = bytes(buf).decode('utf-8', errors='replace')
results[port] = text
print(f'[{port}] {len(buf)} bytes captured -> {log_path}')
except Exception as e:
print(f'[{port}] ERROR: {e}')
results[port] = None
# Launch 3 capture threads — actual concurrent reset + capture.
results = {}
threads = [threading.Thread(target=capture, args=(p, results)) for p in PORTS]
for t in threads:
t.start()
for t in threads:
t.join()
# ── Analyze ────────────────────────────────────────────────────────────
def grep_pattern(text: str, pattern: str, n: int = 8):
rx = re.compile(pattern)
return [L.strip() for L in (text or '').split('\n') if rx.search(L)][:n]
print('\n' + '='*78)
print('ADR-110 multi-board capture summary')
print('='*78)
for port in PORTS:
text = results.get(port)
if not text:
print(f'\n--- {port}: NO DATA ---')
continue
print(f'\n--- {port} ---')
# Boot banner
for L in grep_pattern(text, r'main: ESP32-C6.*Node ID', 2):
print(f' banner : {L}')
# Time-sync init
for L in grep_pattern(text, r'c6_ts:.*(init done|promot|stepping down|tx fail)', 4):
print(f' c6_ts : {L}')
# WiFi mode + connect status
for L in grep_pattern(text, r'(wifi:mode|wifi:state|Retrying WiFi|got ip|Connected to WiFi)', 6):
print(f' wifi : {L}')
# TWT events
for L in grep_pattern(text, r'c6_twt|itwt|TWT', 5):
print(f' twt : {L}')
# CSI callbacks
for L in grep_pattern(text, r'CSI cb #\d+.*len=', 5):
print(f' csi_cb : {L}')
# 11ax MAC firmware
for L in grep_pattern(text, r'mac_version:HAL_MAC_ESP32AX', 2):
print(f' he-mac : {L}')
# Cross-board leader election summary
print('\n' + '='*78)
print('Leader election analysis')
print('='*78)
eui_re = re.compile(r'EUI=([0-9a-fA-F]+)')
euis = {}
for port in PORTS:
text = results.get(port) or ''
m = eui_re.search(text)
if m:
euis[port] = int(m.group(1), 16)
print(f' {port} EUI=0x{m.group(1).lower()} -> {"LEADER" if False else "candidate"}')
if len(euis) >= 2:
lowest_port = min(euis, key=euis.get)
print(f'\n lowest EUI -> expected leader: {lowest_port} (0x{euis[lowest_port]:016x})')
# Did a "stepping down" log appear on the non-lowest boards?
for port in PORTS:
if port == lowest_port:
continue
text = results.get(port) or ''
if 'stepping down' in text:
print(f' {port}: [OK] stepped down (heard leader beacon)')
elif port in euis:
print(f' {port}: [FAIL] did NOT step down — investigate (own EUI=0x{euis[port]:016x}, expected leader=0x{euis[lowest_port]:016x})')
@@ -0,0 +1,242 @@
/**
* @file test_adr110_encoding.c
* @brief Host-side unit tests for ADR-110 pure functions.
*
* Covers the two encoding paths that don't need ESP-IDF runtime:
* 1. mac_to_eui64() — IEEE EUI-64 from MAC-48 (c6_timesync.c)
* 2. PPDU-type → ADR-018 byte 18 mapping for both HE-capable and
* legacy paths (csi_collector.c)
*
* Build (Linux/macOS/Windows with any C99 compiler):
* cc -std=c99 -Wall -o test_adr110 test_adr110_encoding.c && ./test_adr110
*
* Or in WSL on this Windows box:
* gcc -std=c99 -Wall -o test_adr110 test_adr110_encoding.c && ./test_adr110
*
* Exits 0 on all-pass, prints which assertion failed otherwise.
*
* Why a separate host test file rather than extending the existing fuzz
* harness: fuzzers want random bytes; these are deterministic table-driven
* checks for tiny pure functions where libFuzzer adds no signal.
*/
#include <stdint.h>
#include <stdio.h>
#include <string.h>
/* ──────────────────────────────────────────────────────────────────────
* System under test — copied verbatim from the firmware. If the
* firmware copy changes, this test must be updated and the new behavior
* attested by re-running the test before the firmware change merges.
* ────────────────────────────────────────────────────────────────────── */
/* From firmware/esp32-csi-node/main/c6_timesync.c — fallback path used only
* when esp_read_mac(..., ESP_MAC_IEEE802154) fails. The primary C6 path
* reads 8 bytes directly (the eFuse-provided EUI-64). */
static uint64_t mac48_to_eui64(const uint8_t mac[6])
{
return ((uint64_t)mac[0] << 56) | ((uint64_t)mac[1] << 48) |
((uint64_t)mac[2] << 40) | ((uint64_t)0xFF << 32) |
((uint64_t)0xFE << 24) | ((uint64_t)mac[3] << 16) |
((uint64_t)mac[4] << 8 ) | (uint64_t)mac[5];
}
/* Pack 8-byte EUI-64 buffer (as returned by ESP_MAC_IEEE802154) into u64. */
static uint64_t eui64_bytes_to_u64(const uint8_t eui[8])
{
return ((uint64_t)eui[0] << 56) | ((uint64_t)eui[1] << 48) |
((uint64_t)eui[2] << 40) | ((uint64_t)eui[3] << 32) |
((uint64_t)eui[4] << 24) | ((uint64_t)eui[5] << 16) |
((uint64_t)eui[6] << 8 ) | (uint64_t)eui[7];
}
/* From firmware/esp32-csi-node/main/csi_collector.c — HE-capable branch.
* Returns the ADR-018 byte-18 PPDU type. */
static uint8_t ppdu_type_he(uint8_t cur_bb_format)
{
switch (cur_bb_format) {
case 0:
case 1:
case 2: return 0; /* 11b/g/a/HT bucket */
case 3: return 0; /* VHT */
case 4: return 1; /* HE-SU */
case 5: return 2; /* HE-MU */
case 6: return 1; /* HE-ER-SU collapses to HE-SU */
case 7: return 3; /* HE-TB */
default: return 0xFF;
}
}
/* From csi_collector.c — legacy (non-HE) branch. */
static uint8_t ppdu_type_legacy(uint8_t sig_mode)
{
switch (sig_mode) {
case 0: return 0; /* non-HT */
case 1: return 0; /* HT */
case 3: return 0; /* VHT */
default: return 0xFF;
}
}
/* ──────────────────────────────────────────────────────────────────────
* Test harness
* ────────────────────────────────────────────────────────────────────── */
static int g_failed = 0;
static int g_passed = 0;
#define CHECK_EQ_U64(label, got, expected) do { \
if ((got) == (expected)) { g_passed++; } \
else { \
g_failed++; \
printf("FAIL: %s — got=0x%016llx expected=0x%016llx\n", \
(label), (unsigned long long)(got), \
(unsigned long long)(expected)); \
} \
} while (0)
#define CHECK_EQ_U8(label, got, expected) do { \
if ((uint8_t)(got) == (uint8_t)(expected)) { g_passed++; } \
else { \
g_failed++; \
printf("FAIL: %s — got=0x%02x expected=0x%02x\n", \
(label), (unsigned)(got), (unsigned)(expected)); \
} \
} while (0)
/* ──────────────────────────────────────────────────────────────────────
* EUI-64 tests
*
* IEEE 802 MAC-48 → EUI-64 spec: insert 0xFFFE between bytes 3 and 4
* of the MAC. ADR-110's c6_timesync.c does exactly that, leaving the
* U/L bit in byte 0 untouched (the c6 EUI then matches what `esp_read_mac
* ESP_MAC_IEEE802154` returns).
* ────────────────────────────────────────────────────────────────────── */
static void test_eui64_fallback_zero_mac(void)
{
uint8_t mac[6] = {0, 0, 0, 0, 0, 0};
/* mac48_to_eui64 inserts FFFE → 00 00 00 FF FE 00 00 00 */
CHECK_EQ_U64("mac48->eui64 zero", mac48_to_eui64(mac), 0x000000FFFE000000ULL);
}
static void test_eui64_fallback_all_ones(void)
{
uint8_t mac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
/* FF FF FF FF FE FF FF FF */
CHECK_EQ_U64("mac48->eui64 all-ones", mac48_to_eui64(mac), 0xFFFFFFFFFEFFFFFFULL);
}
static void test_eui64_fallback_byte_order(void)
{
uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
CHECK_EQ_U64("mac48->eui64 byte order", mac48_to_eui64(mac), 0x112233FFFE445566ULL);
}
/* Primary path: 8-byte EUI-64 from ESP_MAC_IEEE802154 packed unchanged.
* Verified by esptool's chip_id output on the real C6 hardware:
* COM6: BASE MAC 20:6e:f1:17:27:8c, MAC_EXT ff:fe →
* full EUI: 20:6e:f1:ff:fe:17:27:8c → 0x206EF1FFFE17278C
* COM9: BASE MAC 20:6e:f1:17:05:3c, MAC_EXT ff:fe →
* full EUI: 20:6e:f1:ff:fe:17:05:3c → 0x206EF1FFFE17053C
*
* Note COM9's EUI is numerically smaller — it wins the leader election. */
static void test_eui64_from_native_com6(void)
{
uint8_t eui[8] = {0x20, 0x6e, 0xf1, 0xff, 0xfe, 0x17, 0x27, 0x8c};
CHECK_EQ_U64("native eui64 COM6", eui64_bytes_to_u64(eui), 0x206EF1FFFE17278CULL);
}
static void test_eui64_from_native_com9(void)
{
uint8_t eui[8] = {0x20, 0x6e, 0xf1, 0xff, 0xfe, 0x17, 0x05, 0x3c};
CHECK_EQ_U64("native eui64 COM9", eui64_bytes_to_u64(eui), 0x206EF1FFFE17053CULL);
}
static void test_eui64_leader_election_order(void)
{
uint8_t com6[8] = {0x20, 0x6e, 0xf1, 0xff, 0xfe, 0x17, 0x27, 0x8c};
uint8_t com9[8] = {0x20, 0x6e, 0xf1, 0xff, 0xfe, 0x17, 0x05, 0x3c};
uint64_t a = eui64_bytes_to_u64(com6);
uint64_t b = eui64_bytes_to_u64(com9);
/* Lowest EUI wins → COM9 should be leader when both boards online. */
if (b < a) { g_passed++; }
else { g_failed++; printf("FAIL: leader-election order — expected COM9 < COM6\n"); }
}
/* ──────────────────────────────────────────────────────────────────────
* PPDU-type encoding tests — HE-capable branch (C6/C5)
* ────────────────────────────────────────────────────────────────────── */
static void test_ppdu_he_legacy_bucket(void)
{
CHECK_EQ_U8("he 0 → 0 (11b)", ppdu_type_he(0), 0);
CHECK_EQ_U8("he 1 → 0 (11g/a)", ppdu_type_he(1), 0);
CHECK_EQ_U8("he 2 → 0 (HT)", ppdu_type_he(2), 0);
CHECK_EQ_U8("he 3 → 0 (VHT)", ppdu_type_he(3), 0);
}
static void test_ppdu_he_su(void)
{
CHECK_EQ_U8("he 4 → 1 (HE-SU)", ppdu_type_he(4), 1);
CHECK_EQ_U8("he 6 → 1 (HE-ER-SU)", ppdu_type_he(6), 1);
}
static void test_ppdu_he_mu(void)
{
CHECK_EQ_U8("he 5 → 2 (HE-MU)", ppdu_type_he(5), 2);
}
static void test_ppdu_he_tb(void)
{
CHECK_EQ_U8("he 7 → 3 (HE-TB)", ppdu_type_he(7), 3);
}
static void test_ppdu_he_out_of_range(void)
{
CHECK_EQ_U8("he 8 → 0xFF (unknown)", ppdu_type_he(8), 0xFF);
CHECK_EQ_U8("he 15 → 0xFF (unknown)", ppdu_type_he(15), 0xFF);
}
/* ──────────────────────────────────────────────────────────────────────
* PPDU-type encoding tests — legacy (S3/etc) branch
* ────────────────────────────────────────────────────────────────────── */
static void test_ppdu_legacy_known(void)
{
CHECK_EQ_U8("legacy sig_mode 0 → 0 (non-HT)", ppdu_type_legacy(0), 0);
CHECK_EQ_U8("legacy sig_mode 1 → 0 (HT)", ppdu_type_legacy(1), 0);
CHECK_EQ_U8("legacy sig_mode 3 → 0 (VHT)", ppdu_type_legacy(3), 0);
}
static void test_ppdu_legacy_unknown(void)
{
CHECK_EQ_U8("legacy sig_mode 2 → 0xFF", ppdu_type_legacy(2), 0xFF);
CHECK_EQ_U8("legacy sig_mode 5 → 0xFF", ppdu_type_legacy(5), 0xFF);
}
/* ──────────────────────────────────────────────────────────────────────
* main
* ────────────────────────────────────────────────────────────────────── */
int main(void)
{
test_eui64_fallback_zero_mac();
test_eui64_fallback_all_ones();
test_eui64_fallback_byte_order();
test_eui64_from_native_com6();
test_eui64_from_native_com9();
test_eui64_leader_election_order();
test_ppdu_he_legacy_bucket();
test_ppdu_he_su();
test_ppdu_he_mu();
test_ppdu_he_tb();
test_ppdu_he_out_of_range();
test_ppdu_legacy_known();
test_ppdu_legacy_unknown();
printf("\n%d passed, %d failed\n", g_passed, g_failed);
return g_failed == 0 ? 0 : 1;
}