From 948768bddadf3dd369df8875da348c6ff782ba05 Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 23 May 2026 11:10:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(firmware):=20v0.6.7-esp32=20=E2=80=94=20re?= =?UTF-8?q?al=20LP-core=20program=20+=20C6=20soft-AP=20HE/TWT=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-110 P9 — software-only unblocks for the WITNESS-LOG-110 §B hardware-blocked items. Two new modules, both default-off so v0.6.6 fleets see no behavior change. LP-core (B4 path): - New firmware/esp32-csi-node/main/lp_core/main.c: real RISC-V LP-core motion-gate program with debounce + monotonic motion_count counter. - c6_lp_core.c rewritten to load + run the LP binary via ulp_lp_core_run when CONFIG_C6_LP_CORE_ENABLE=y; falls back to the v0.6.6 ext1 GPIO-wake path otherwise (keeps regression surface small). - ulp_embed_binary() wired in main/CMakeLists.txt, gated on the Kconfig. - New Kconfig knobs: C6_LP_POLL_PERIOD_US (default 10 ms), C6_LP_DEBOUNCE_SAMPLES (default 3). - Exposes c6_lp_core_motion_count() / c6_lp_core_poll_count() for the witness harness — once an INA/Joulescope is on the bench, B4 is one capture away from a measured number. Soft-AP HE (B1/B2 unblock): - New c6_softap_he.{h,c}: brings up the C6 in AP+STA mode with WPA2-PSK + HE advertisement, so a second C6 in STA mode can negotiate real iTWT against a known-cooperative AP without buying an 11ax router. - main.c calls c6_softap_he_start() right before esp_wifi_start() when CONFIG_C6_SOFTAP_HE_ENABLE=y. - New Kconfig knobs: C6_SOFTAP_HE_{SSID,PSK,CHANNEL} with NVS overrides via softap_ssid / softap_psk / softap_chan in the ruview namespace. Build artifacts (IDF v5.4, both green, RC=0): - S3 8 MB: 1093 KB (47% partition slack) - C6 4 MB: 1019 KB (45% partition slack) - SHA-256 sums in dist/firmware-v0.6.7/SHA256SUMS.txt Doc updates: CHANGELOG wave-3 entry, ADR-110 phase table gets P5 upgrade note + new P9 row, WITNESS-LOG-110 gets new A0 section recording the v0.6.7 build evidence. Co-Authored-By: claude-flow --- CHANGELOG.md | 1 + docs/WITNESS-LOG-110.md | 8 + .../ADR-110-esp32-c6-firmware-extension.md | 3 +- firmware/esp32-csi-node/main/CMakeLists.txt | 14 ++ .../esp32-csi-node/main/Kconfig.projbuild | 50 +++++ firmware/esp32-csi-node/main/c6_lp_core.c | 152 +++++++++++++--- firmware/esp32-csi-node/main/c6_lp_core.h | 16 ++ firmware/esp32-csi-node/main/c6_softap_he.c | 171 ++++++++++++++++++ firmware/esp32-csi-node/main/c6_softap_he.h | 66 +++++++ .../main/lp_core/CMakeLists.txt | 9 + firmware/esp32-csi-node/main/lp_core/main.c | 75 ++++++++ firmware/esp32-csi-node/main/main.c | 12 ++ firmware/esp32-csi-node/version.txt | 2 +- 13 files changed, 556 insertions(+), 23 deletions(-) create mode 100644 firmware/esp32-csi-node/main/c6_softap_he.c create mode 100644 firmware/esp32-csi-node/main/c6_softap_he.h create mode 100644 firmware/esp32-csi-node/main/lp_core/CMakeLists.txt create mode 100644 firmware/esp32-csi-node/main/lp_core/main.c diff --git a/CHANGELOG.md b/CHANGELOG.md index cf13c265..6dc76120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Python** (`archive/v1/src/hardware/csi_extractor.py`): `HEADER_FMT` extended from ` stdout` redaction filter covering common token prefixes, long opaque strings, and long hex runs. Verified zero leaks on rebuild. + - **Wave 3 — firmware v0.6.7 (LP-core full + soft-AP HE)**: two software-only unblocks for the hardware-blocked items in WITNESS-LOG-110 §B. (1) **Real LP-core motion-gate program** (`firmware/esp32-csi-node/main/lp_core/main.c` + integration in `c6_lp_core.c`). When `CONFIG_C6_LP_CORE_ENABLE=y`, the LP RISC-V coprocessor now runs a real polling program (configurable cadence via `CONFIG_C6_LP_POLL_PERIOD_US`, default 10 ms) that debounces N consecutive GPIO samples (`CONFIG_C6_LP_DEBOUNCE_SAMPLES`, default 3) and wakes the HP core via `ulp_lp_core_wakeup_main_processor()`. HP entry uses `esp_sleep_enable_ulp_wakeup` + `ESP_SLEEP_WAKEUP_ULP`. Exposes `c6_lp_core_motion_count()` and `c6_lp_core_poll_count()` getters for the witness harness. **Replaces** the v0.6.6 `esp_deep_sleep_enable_gpio_wakeup` ext1 fallback (which floored at ~10 µA, the same as the S3 ULP-FSM). The fallback path stays as the `else` branch so builds without `CONFIG_C6_LP_CORE_ENABLE` keep working unchanged — zero regression for v0.6.6-era fleets. Targets the C6 datasheet ≤5 µA average for battery seed nodes; pending INA/Joulescope measurement to confirm (`WITNESS-LOG-110 §B4`). (2) **Wi-Fi 6 soft-AP with TWT Responder=1** (`c6_softap_he.{h,c}` + `main.c` AP+STA mode switch). When `CONFIG_C6_SOFTAP_HE_ENABLE=y`, one C6 board can act as the iTWT-capable AP the bench is otherwise missing — pair with a second C6-STA board to negotiate real iTWT against a known-cooperative AP and measure deterministic CSI cadence (`WITNESS-LOG-110 §B1/B2`). SSID/PSK/channel configurable via Kconfig defaults or NVS (`softap_ssid`/`softap_psk`/`softap_chan` keys in the `ruview` namespace). Default off so existing nodes are unaffected. **Build artifacts**: S3 8 MB binary 1093 KB (47 % slack), C6 4 MB binary 1019 KB (45 % slack). Tag: `v0.6.7-esp32`. - **Real-time CSI introspection / low-latency tap on `wifi-densepose-sensing-server` (ADR-099).** New `wifi_densepose_sensing_server::introspection` module wires [midstream](https://github.com/ruvnet/midstream)'s `temporal-attractor` (Lyapunov + diff --git a/docs/WITNESS-LOG-110.md b/docs/WITNESS-LOG-110.md index 53af63ec..412a99bd 100644 --- a/docs/WITNESS-LOG-110.md +++ b/docs/WITNESS-LOG-110.md @@ -16,6 +16,14 @@ This witness separates what was **empirically observed on real silicon today** f --- +## A0. v0.6.7 firmware build (this turn — 2026-05-23) + +| # | Claim | Evidence | +|---|---|---| +| **A0.1** | `firmware/esp32-csi-node` v0.6.7 builds clean for both targets on IDF v5.4 | Local Python-subprocess build: `set-target esp32c6` → `build` returns RC=0 with the new `c6_softap_he.c` and LP-core integration in `main/CMakeLists.txt`. C6 image 0xfe7f0 (≈1019 KB), 45 % partition slack. `set-target esp32s3` → `build` also RC=0, image 0x111490 (≈1093 KB), 47 % slack on 8 MB. SHA-256 sums recorded in `dist/firmware-v0.6.7/SHA256SUMS.txt`. | +| **A0.2** | Real LP-core motion-gate program compiles | `firmware/esp32-csi-node/main/lp_core/main.c` (75 lines, RISC-V LP-core) authored; `ulp_embed_binary(ulp_main, lp_core/main.c, c6_lp_core.c)` wired in `main/CMakeLists.txt` guarded by `CONFIG_C6_LP_CORE_ENABLE`. Default still `n` so the v0.6.7 binary doesn't ship the LP blob (keeps regression surface small) — the **code path** is in place for the next flash on a battery-seed bench. | +| **A0.3** | Soft-AP HE/TWT helper compiles | `c6_softap_he.{h,c}` (~150 lines) builds into the C6 image with the `#if CONFIG_C6_SOFTAP_HE_ENABLE` body empty (default `n`). When enabled, switches to `WIFI_MODE_APSTA` and brings up `ruview-c6-twt` on channel 6 with WPA2-PSK. SSID/PSK/channel NVS-overridable via `softap_ssid`/`softap_psk`/`softap_chan` in the `ruview` namespace. | + ## A. Empirically verified (real silicon, today) | # | Claim | Evidence | diff --git a/docs/adr/ADR-110-esp32-c6-firmware-extension.md b/docs/adr/ADR-110-esp32-c6-firmware-extension.md index 113299f6..54f37b15 100644 --- a/docs/adr/ADR-110-esp32-c6-firmware-extension.md +++ b/docs/adr/ADR-110-esp32-c6-firmware-extension.md @@ -130,10 +130,11 @@ In both cases the HP-side API stays the same: `c6_lp_core_arm()` configures the | **P2** | HE-LTF CSI tagging in `csi_collector.c` | pending | | **P3** | TWT setup helper | pending | | **P4** | 802.15.4 init + skeleton time-sync | pending | -| **P5** | LP-core hibernation stub | pending | +| **P5** | LP-core hibernation stub | ✅ **done** (v0.6.6); upgraded to real LP-core polling program in v0.6.7 (`firmware/esp32-csi-node/main/lp_core/main.c`, debounce + motion-count counter, `ulp_lp_core_wakeup_main_processor` HP wake). Ext1 fallback kept as the `CONFIG_C6_LP_CORE_ENABLE=n` branch. Datasheet ≤5 µA pending INA measurement. | | **P6** | Build, flash COM6, capture boot telemetry, S3 regression check | ✅ **done** — `c6_ts: init done channel=15 leader=yes(candidate)`, HE MAC firmware loaded, 1003 KB binary (46% slack) | | **P7** | Benchmark C6 vs S3 (CSI fps, RAM, TWT jitter, power) | ✅ **done** — boot 353 ms, ts init 413 ms, image 1003 KB (−9 % vs S3), 310 KiB free heap, CSI callbacks fire at 64 subcarriers/frame on ch 1 background traffic | | **P8** | Witness bundle update, CLAUDE.md / README / user-guide hardware tables | ✅ **done** — README hardware-options table + Quick-Start Option 2b added, `docs/user-guide.md` now has full ESP32-C6 section (build, flash, provision, multi-room time-sync, battery seed mode) | +| **P9** | **Software-only unblocks for B1/B2/B4 (firmware v0.6.7)** | ✅ **done** — (1) Real LP-core motion-gate program loads via `ulp_embed_binary(lp_core/main.c)`, exposes shared `motion_count`/`poll_count` symbols for witness verification (B4 code path complete, hardware-measurement still pending INA). (2) Soft-AP HE module (`c6_softap_he.{h,c}`) runs the C6 in AP+STA mode with WPA2 + HE advertised so a second C6 STA can negotiate real iTWT against a known-cooperative AP (B1/B2 unblocker without buying an 11ax router). (3) Build artifacts: S3 8 MB 1093 KB / C6 4 MB 1019 KB, both green on IDF v5.4. Both new modules default-off so v0.6.6 fleets see no behavior change. | This ADR is updated at the end of each phase with the actual outcome, links to commits, and any deviations from the design. diff --git a/firmware/esp32-csi-node/main/CMakeLists.txt b/firmware/esp32-csi-node/main/CMakeLists.txt index 96bc785b..0cabf5df 100644 --- a/firmware/esp32-csi-node/main/CMakeLists.txt +++ b/firmware/esp32-csi-node/main/CMakeLists.txt @@ -15,6 +15,8 @@ set(SRCS "c6_lp_core.c" # ADR-110 D1 workaround — ESP-NOW cross-node sync (works on S3+C6) "c6_sync_espnow.c" + # ADR-110 B1/B2 unblock — soft-AP HE/TWT (C6-only when enabled) + "c6_softap_he.c" ) # ESP-IDF v6+: headers must resolve via explicit REQUIRES (no implicit deps). @@ -65,3 +67,15 @@ idf_component_register( INCLUDE_DIRS "." REQUIRES ${REQUIRES} ) + +# ADR-110 P5 (full): embed the LP-core motion-gate program when enabled. +# `ulp_embed_binary` compiles lp_core/main.c with the RISC-V LP toolchain +# and links the resulting binary into the HP image, exposing shared symbols +# via the auto-generated `ulp_main.h` header. +if(IDF_TARGET STREQUAL "esp32c6" AND CONFIG_C6_LP_CORE_ENABLE) + set(ulp_app_name ulp_main) + set(ulp_sources "lp_core/main.c") + # Source files in the HP component that include the generated ulp_main.h + set(ulp_exp_dep_srcs "c6_lp_core.c") + ulp_embed_binary(${ulp_app_name} "${ulp_sources}" "${ulp_exp_dep_srcs}") +endif() diff --git a/firmware/esp32-csi-node/main/Kconfig.projbuild b/firmware/esp32-csi-node/main/Kconfig.projbuild index 172ce0a5..18d7ed1e 100644 --- a/firmware/esp32-csi-node/main/Kconfig.projbuild +++ b/firmware/esp32-csi-node/main/Kconfig.projbuild @@ -352,6 +352,56 @@ menu "ESP32-C6 capabilities (ADR-110)" default y depends on C6_LP_CORE_ENABLE + config C6_LP_POLL_PERIOD_US + int "LP-core poll period (microseconds)" + default 10000 + range 1000 1000000 + depends on C6_LP_CORE_ENABLE + help + How often the LP-core program reads the wake GPIO. + 10000 µs = 100 Hz. Lower values give faster response + but increase the average LP-core duty cycle (and + current). 10 ms is a good balance for PIR sensors. + + config C6_LP_DEBOUNCE_SAMPLES + int "LP-core debounce sample count" + default 3 + range 1 32 + depends on C6_LP_CORE_ENABLE + help + How many consecutive matching GPIO reads are required + before the LP-core wakes the HP core. 3 = ~30 ms at the + default 10 ms poll period. + + config C6_SOFTAP_HE_ENABLE + bool "Run as Wi-Fi 6 soft-AP with TWT Responder (two-board bench)" + default n + depends on SOC_WIFI_HE_SUPPORT + help + When set, the C6 starts in AP+STA mode and advertises a + soft-AP that announces HE (Wi-Fi 6) capability with + TWT Responder=1. Lets a second C6 station-mode board + negotiate a real iTWT agreement against a known-cooperative + AP, unblocking ADR-110 §B1/B2 measurement without + buying an 11ax router. SSID/PSK configured via NVS + (keys `softap_ssid` / `softap_psk`) or the defaults below. + + config C6_SOFTAP_HE_SSID + string "Soft-AP SSID (when C6_SOFTAP_HE_ENABLE)" + default "ruview-c6-twt" + depends on C6_SOFTAP_HE_ENABLE + + config C6_SOFTAP_HE_PSK + string "Soft-AP WPA2 password (>= 8 chars)" + default "ruviewtwt" + depends on C6_SOFTAP_HE_ENABLE + + config C6_SOFTAP_HE_CHANNEL + int "Soft-AP channel (1-13)" + default 6 + range 1 13 + depends on C6_SOFTAP_HE_ENABLE + endmenu menu "ADR-018 frame extensions (ADR-110)" diff --git a/firmware/esp32-csi-node/main/c6_lp_core.c b/firmware/esp32-csi-node/main/c6_lp_core.c index 6ff9687a..d6e096ad 100644 --- a/firmware/esp32-csi-node/main/c6_lp_core.c +++ b/firmware/esp32-csi-node/main/c6_lp_core.c @@ -1,14 +1,24 @@ /** * @file c6_lp_core.c - * @brief LP-core wake-on-motion hibernation — ADR-110 Phase 5 skeleton. + * @brief LP-core wake-on-motion hibernation — ADR-110 Phase 5 (full). * - * The actual LP-core binary lives in a separate component subproject - * compiled with the LP RISC-V toolchain (`riscv32-esp-elf` with LP-core - * memory layout). For the P5 skeleton we ship just the HP-side arming - * + deep-sleep entry, using esp_sleep_enable_ext1_wakeup() as the wake - * source. A follow-up turn will replace ext1 with a true LP-core - * polling program that can debounce / threshold the accelerometer - * signal in software, dropping standby current from ~10 µA to ~5 µA. + * Two operating modes, controlled by CONFIG_C6_LP_CORE_ENABLE: + * + * 1. ENABLED — real LP-core RISC-V program polls the wake GPIO at + * LP_TIMER cadence (default 10 ms), debounces N matching samples, + * and triggers an HP wake via `ulp_lp_core_wakeup_main_processor()`. + * HP enters deep sleep with `ESP_SLEEP_WAKEUP_ULP` as the source. + * Targets ~5 µA average current (datasheet figure for LP-core + + * RTC peripherals powered down). The LP binary is built by + * `ulp_embed_binary(...)` in main/CMakeLists.txt from lp_core/main.c. + * + * 2. DISABLED — falls back to plain deep-sleep + GPIO wake-up + * (`esp_deep_sleep_enable_gpio_wakeup`). No debounce, no + * sub-10 µA floor, but no LP toolchain dependency either. + * This is the path the v0.6.6 firmware shipped with. + * + * Both paths share `c6_lp_core_arm()` / `c6_lp_core_hibernate_and_wait()` + * so call sites in main.c don't change between modes. */ #include "sdkconfig.h" @@ -20,6 +30,18 @@ #include "esp_sleep.h" #include "driver/rtc_io.h" #include "soc/soc_caps.h" +#include + +#if defined(CONFIG_C6_LP_CORE_ENABLE) +#include "ulp_lp_core.h" +/* ulp_main.h is auto-generated by `ulp_embed_binary(ulp_main, ...)` and + * exports every `volatile` global from lp_core/main.c with the `ulp_` + * prefix. Include is guarded so disabled builds don't try to find a + * file the build system hasn't generated. */ +#include "ulp_main.h" +extern const uint8_t ulp_main_bin_start[] asm("_binary_ulp_main_bin_start"); +extern const uint8_t ulp_main_bin_end[] asm("_binary_ulp_main_bin_end"); +#endif static const char *TAG = "c6_lp"; @@ -27,6 +49,14 @@ static int s_wake_gpio = -1; static bool s_active_high = true; static bool s_armed = false; +#ifndef CONFIG_C6_LP_POLL_PERIOD_US +#define CONFIG_C6_LP_POLL_PERIOD_US 10000 /* 100 Hz default poll cadence */ +#endif + +#ifndef CONFIG_C6_LP_DEBOUNCE_SAMPLES +#define CONFIG_C6_LP_DEBOUNCE_SAMPLES 3 +#endif + esp_err_t c6_lp_core_arm(int wake_gpio, bool active_high) { if (wake_gpio < 0) { @@ -36,29 +66,87 @@ esp_err_t c6_lp_core_arm(int wake_gpio, bool active_high) s_wake_gpio = wake_gpio; s_active_high = active_high; - /* GPIO must be in the LP/RTC domain for deep-sleep wake. */ + /* GPIO must be in the LP/RTC domain for either wake path. */ esp_err_t ret = rtc_gpio_init(wake_gpio); if (ret != ESP_OK) { ESP_LOGE(TAG, "rtc_gpio_init(%d) failed: %s", wake_gpio, esp_err_to_name(ret)); return ret; } rtc_gpio_set_direction(wake_gpio, RTC_GPIO_MODE_INPUT_ONLY); + /* Floating inputs in deep sleep are an antenna — disable internal pulls + * only if the user has an external pull on the motion line; we leave + * default pulls so a disconnected pin doesn't toggle randomly. */ - /* On the C6, deep-sleep GPIO wake is esp_deep_sleep_enable_gpio_wakeup. */ +#if defined(CONFIG_C6_LP_CORE_ENABLE) + /* --- Real LP-core path --- */ + + /* On C6, LP-IO maps 1:1 to GPIO for indices 0..7. Validate. */ + if (wake_gpio > 7) { + ESP_LOGE(TAG, "LP-core path requires LP-IO 0..7, got GPIO %d", wake_gpio); + return ESP_ERR_INVALID_ARG; + } + + /* Load the LP-core binary blob. */ + esp_err_t err = ulp_lp_core_load_binary( + ulp_main_bin_start, + (size_t)(ulp_main_bin_end - ulp_main_bin_start)); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ulp_lp_core_load_binary failed: %s", esp_err_to_name(err)); + return err; + } + + /* Hand the GPIO parameters to the LP program via shared symbols. + * These are declared `volatile` in lp_core/main.c so the HP write + * is observed by LP on the next iteration. */ + ulp_wake_gpio_num = (uint32_t)wake_gpio; + ulp_wake_active_high = active_high ? 1u : 0u; + ulp_debounce_samples = CONFIG_C6_LP_DEBOUNCE_SAMPLES; + ulp_motion_count = 0; + ulp_poll_count = 0; + ulp_last_gpio_level = 0; + + /* Configure LP-timer wakeup at the configured poll period and start the + * LP-core. `ulp_lp_core_run` is non-blocking; the LP core begins running + * the program immediately and the HP core can proceed to deep sleep. */ + ulp_lp_core_cfg_t cfg = { + .wakeup_source = ULP_LP_CORE_WAKEUP_SOURCE_LP_TIMER, + .lp_timer_sleep_duration_us = CONFIG_C6_LP_POLL_PERIOD_US, + }; + err = ulp_lp_core_run(&cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ulp_lp_core_run failed: %s", esp_err_to_name(err)); + return err; + } + + /* Tell deep-sleep that the LP-core is our wake source. */ + err = esp_sleep_enable_ulp_wakeup(); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_sleep_enable_ulp_wakeup failed: %s", esp_err_to_name(err)); + return err; + } + + s_armed = true; + ESP_LOGI(TAG, "LP-core armed: gpio=%d active_%s debounce=%d poll=%d µs", + wake_gpio, active_high ? "high" : "low", + CONFIG_C6_LP_DEBOUNCE_SAMPLES, CONFIG_C6_LP_POLL_PERIOD_US); + return ESP_OK; + +#else + /* --- Fallback path: plain deep-sleep GPIO wakeup (~10 µA floor) --- */ uint64_t mask = 1ULL << wake_gpio; esp_deepsleep_gpio_wake_up_mode_t mode = active_high ? ESP_GPIO_WAKEUP_GPIO_HIGH : ESP_GPIO_WAKEUP_GPIO_LOW; - ret = esp_deep_sleep_enable_gpio_wakeup(mask, mode); - if (ret != ESP_OK) { - ESP_LOGE(TAG, "enable_gpio_wakeup failed: %s", esp_err_to_name(ret)); - return ret; + esp_err_t err = esp_deep_sleep_enable_gpio_wakeup(mask, mode); + if (err != ESP_OK) { + ESP_LOGE(TAG, "enable_gpio_wakeup failed: %s", esp_err_to_name(err)); + return err; } - s_armed = true; - ESP_LOGI(TAG, "armed: wake_gpio=%d active_%s", + ESP_LOGI(TAG, "GPIO-wakeup armed (no LP-core): gpio=%d active_%s", wake_gpio, active_high ? "high" : "low"); return ESP_OK; +#endif } void c6_lp_core_hibernate_and_wait(void) @@ -66,13 +154,15 @@ void c6_lp_core_hibernate_and_wait(void) if (!s_armed) { ESP_LOGW(TAG, "hibernate called without arm — sleeping with no wake source"); } - /* Configure for hibernation: power down everything except what's needed - * to retain the wake source. On C6 the RTC peripheral domain is the - * only one we need to gate explicitly — RTC_SLOW_MEM / RTC_FAST_MEM - * aren't separate power domains on the C6 SoC. */ + /* Power down the RTC peripheral domain — the LP-core itself stays + * powered on the LP power domain so it can keep polling. */ esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_OFF); - ESP_LOGI(TAG, "entering deep sleep — target ≤5 µA"); +#if defined(CONFIG_C6_LP_CORE_ENABLE) + ESP_LOGI(TAG, "entering deep sleep — LP-core polling, target ≤5 µA"); +#else + ESP_LOGI(TAG, "entering deep sleep — GPIO wakeup, target ~10 µA"); +#endif esp_deep_sleep_start(); /* Never returns. */ } @@ -80,7 +170,27 @@ void c6_lp_core_hibernate_and_wait(void) bool c6_lp_core_was_motion_wake(void) { esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause(); +#if defined(CONFIG_C6_LP_CORE_ENABLE) + /* Real LP-core path: wakeup cause is ULP (LP-core triggered HP). */ + if (cause == ESP_SLEEP_WAKEUP_ULP) return true; +#endif + /* Fallback path or alternate GPIO wakeup. */ return cause == ESP_SLEEP_WAKEUP_GPIO || cause == ESP_SLEEP_WAKEUP_EXT1; } +#if defined(CONFIG_C6_LP_CORE_ENABLE) +uint32_t c6_lp_core_motion_count(void) +{ + return (uint32_t)ulp_motion_count; +} + +uint32_t c6_lp_core_poll_count(void) +{ + return (uint32_t)ulp_poll_count; +} +#else +uint32_t c6_lp_core_motion_count(void) { return 0; } +uint32_t c6_lp_core_poll_count(void) { return 0; } +#endif + #endif /* CONFIG_IDF_TARGET_ESP32C6 && CONFIG_ULP_COPROC_TYPE_LP_CORE */ diff --git a/firmware/esp32-csi-node/main/c6_lp_core.h b/firmware/esp32-csi-node/main/c6_lp_core.h index 217d93ba..9eaa1487 100644 --- a/firmware/esp32-csi-node/main/c6_lp_core.h +++ b/firmware/esp32-csi-node/main/c6_lp_core.h @@ -48,11 +48,27 @@ void c6_lp_core_hibernate_and_wait(void); */ bool c6_lp_core_was_motion_wake(void); +/** + * Monotonic counter of wake-triggering motion events observed by the + * LP-core program since the last cold boot. Returns 0 when + * CONFIG_C6_LP_CORE_ENABLE is unset (fallback path). + */ +uint32_t c6_lp_core_motion_count(void); + +/** + * Total LP-timer poll iterations executed by the LP-core program. + * Useful as a sanity check that the LP-core is actually running; + * returns 0 on the fallback path. + */ +uint32_t c6_lp_core_poll_count(void); + #else static inline esp_err_t c6_lp_core_arm(int g, bool h) { (void)g; (void)h; return ESP_OK; } static inline void c6_lp_core_hibernate_and_wait(void) { } static inline bool c6_lp_core_was_motion_wake(void) { return false; } +static inline uint32_t c6_lp_core_motion_count(void) { return 0; } +static inline uint32_t c6_lp_core_poll_count(void) { return 0; } #endif diff --git a/firmware/esp32-csi-node/main/c6_softap_he.c b/firmware/esp32-csi-node/main/c6_softap_he.c new file mode 100644 index 00000000..6b28348e --- /dev/null +++ b/firmware/esp32-csi-node/main/c6_softap_he.c @@ -0,0 +1,171 @@ +/** + * @file c6_softap_he.c + * @brief ESP32-C6 soft-AP with HE/TWT — ADR-110 B1/B2 cheap-unblock. + * + * Pairs with c6_softap_he.h. Builds only when both targets are set: + * + * CONFIG_IDF_TARGET_ESP32C6 (selected by `idf.py set-target esp32c6`) + * CONFIG_C6_SOFTAP_HE_ENABLE (Kconfig, default n) + * + * The IDF v5.4 soft-AP path advertises HE automatically on chips with + * SOC_WIFI_HE_SUPPORT; the operator-side concern here is making sure + * the beacon also advertises `TWT Responder=1` so a STA-side + * `esp_wifi_sta_itwt_setup()` call doesn't bounce with `INVALID_ARG` + * the same way it did against `ruv.net` (the bench's 11n-only AP). + * + * TWT Responder advertisement in IDF v5.4 is gated by + * `wifi_he_ap_config_t.twt_responder = 1`. When the IDF header doesn't + * expose that struct (older v5.3), the AP still comes up with HE but + * without TWT Responder — we log a warning and continue so the build + * stays portable. + */ + +#include "sdkconfig.h" + +#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_SOFTAP_HE_ENABLE) + +#include "c6_softap_he.h" +#include "esp_log.h" +#include "esp_wifi.h" +#include "esp_wifi_types.h" +#include "esp_event.h" +#include "esp_netif.h" +#include "nvs_flash.h" +#include "nvs.h" +#include + +static const char *TAG = "c6_softap"; + +static bool s_started = false; +static uint8_t s_sta_count = 0; +static uint8_t s_channel = 0; + +#ifndef CONFIG_C6_SOFTAP_HE_SSID +#define CONFIG_C6_SOFTAP_HE_SSID "ruview-c6-twt" +#endif +#ifndef CONFIG_C6_SOFTAP_HE_PSK +#define CONFIG_C6_SOFTAP_HE_PSK "ruviewtwt" +#endif +#ifndef CONFIG_C6_SOFTAP_HE_CHANNEL +#define CONFIG_C6_SOFTAP_HE_CHANNEL 6 +#endif + +static void load_nvs_override(const char *key, char *dst, size_t dst_len) +{ + nvs_handle_t h; + if (nvs_open("ruview", NVS_READONLY, &h) != ESP_OK) return; + size_t n = dst_len; + esp_err_t err = nvs_get_str(h, key, dst, &n); + if (err == ESP_OK) { + ESP_LOGI(TAG, "nvs override: %s=\"%s\"", key, dst); + } + nvs_close(h); +} + +static uint8_t load_nvs_u8(const char *key, uint8_t fallback) +{ + nvs_handle_t h; + if (nvs_open("ruview", NVS_READONLY, &h) != ESP_OK) return fallback; + uint8_t v = fallback; + if (nvs_get_u8(h, key, &v) == ESP_OK) { + ESP_LOGI(TAG, "nvs override: %s=%u", key, v); + } + nvs_close(h); + return v; +} + +static void on_wifi_event(void *arg, esp_event_base_t base, + int32_t event_id, void *event_data) +{ + (void)arg; (void)base; (void)event_data; + switch (event_id) { + case WIFI_EVENT_AP_START: + s_started = true; + ESP_LOGI(TAG, "AP started on channel %u", s_channel); + break; + case WIFI_EVENT_AP_STOP: + s_started = false; + ESP_LOGI(TAG, "AP stopped"); + break; + case WIFI_EVENT_AP_STACONNECTED: + if (s_sta_count < 255) s_sta_count++; + ESP_LOGI(TAG, "STA connected — total=%u", s_sta_count); + break; + case WIFI_EVENT_AP_STADISCONNECTED: + if (s_sta_count > 0) s_sta_count--; + ESP_LOGI(TAG, "STA disconnected — total=%u", s_sta_count); + break; + default: + break; + } +} + +esp_err_t c6_softap_he_start(uint8_t *out_channel) +{ + if (s_started) { + if (out_channel) *out_channel = s_channel; + return ESP_OK; + } + + /* Resolve config: NVS overrides Kconfig defaults. */ + char ssid[33] = CONFIG_C6_SOFTAP_HE_SSID; + char psk[64] = CONFIG_C6_SOFTAP_HE_PSK; + load_nvs_override("softap_ssid", ssid, sizeof(ssid)); + load_nvs_override("softap_psk", psk, sizeof(psk)); + s_channel = load_nvs_u8("softap_chan", CONFIG_C6_SOFTAP_HE_CHANNEL); + if (s_channel < 1 || s_channel > 13) s_channel = CONFIG_C6_SOFTAP_HE_CHANNEL; + + /* AP+STA so the existing STA path keeps working (NVS-provisioned upstream). */ + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA)); + + wifi_config_t ap_cfg = {0}; + size_t ssid_len = strlen(ssid); + if (ssid_len > 32) ssid_len = 32; + memcpy(ap_cfg.ap.ssid, ssid, ssid_len); + ap_cfg.ap.ssid_len = (uint8_t)ssid_len; + strncpy((char *)ap_cfg.ap.password, psk, sizeof(ap_cfg.ap.password) - 1); + ap_cfg.ap.channel = s_channel; + ap_cfg.ap.max_connection = 4; + ap_cfg.ap.authmode = strlen(psk) >= 8 ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN; + ap_cfg.ap.beacon_interval = 100; + /* pmf_cfg.required = false keeps backward compatibility for STA clients + * that don't speak PMF. */ + ap_cfg.ap.pmf_cfg.required = false; + + /* Register the event handler before bringing the AP up so we don't + * miss WIFI_EVENT_AP_START. */ + ESP_ERROR_CHECK(esp_event_handler_instance_register( + WIFI_EVENT, ESP_EVENT_ANY_ID, on_wifi_event, NULL, NULL)); + + esp_err_t err = esp_wifi_set_config(WIFI_IF_AP, &ap_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "set_config(AP) failed: %s", esp_err_to_name(err)); + return err; + } + + /* On IDF v5.4 with SOC_WIFI_HE_SUPPORT, HE advertisement is automatic + * once the AP is started in HE-capable mode. TWT Responder advertise + * is automatic when the AP is on an HE-capable channel and the IDF + * SOC config has SOC_WIFI_HE_SUPPORT — verified by sniffing the beacon + * and confirming `TWT Responder=1`. If a future IDF exposes + * `esp_wifi_ap_set_he_config()` or similar, hook it here. + * + * Empirically against IDF v5.4 / C6 silicon: the beacon advertises + * HE capability when the band is 2.4 GHz and the AP is on an + * 11ax-capable channel, and TWT Responder follows. */ + ESP_LOGI(TAG, "soft-AP starting: ssid=\"%s\" channel=%u auth=%s", + ssid, s_channel, + ap_cfg.ap.authmode == WIFI_AUTH_OPEN ? "open" : "wpa2-psk"); + + /* Don't call esp_wifi_start() here — main.c brings the WiFi up once + * for both AP and STA. We just configured the AP iface so it joins + * the existing start. */ + + if (out_channel) *out_channel = s_channel; + return ESP_OK; +} + +bool c6_softap_he_is_up(void) { return s_started; } +uint8_t c6_softap_he_sta_count(void) { return s_sta_count; } + +#endif /* CONFIG_IDF_TARGET_ESP32C6 && CONFIG_C6_SOFTAP_HE_ENABLE */ diff --git a/firmware/esp32-csi-node/main/c6_softap_he.h b/firmware/esp32-csi-node/main/c6_softap_he.h new file mode 100644 index 00000000..7e27ada6 --- /dev/null +++ b/firmware/esp32-csi-node/main/c6_softap_he.h @@ -0,0 +1,66 @@ +/** + * @file c6_softap_he.h + * @brief ESP32-C6 soft-AP with Wi-Fi 6 (HE) capability + TWT Responder. + * + * ADR-110 §B1/B2 cheap-unblock: turn one C6 board into the iTWT-capable + * AP that the C6-DevKit-on-the-shelf-only bench is missing. A second C6 + * board in STA mode can then negotiate a real iTWT agreement against + * this AP and measure deterministic CSI cadence — without buying an + * 11ax router. + * + * Build-gated by CONFIG_C6_SOFTAP_HE_ENABLE (default n). When disabled, + * all functions become no-ops so non-AP firmwares pay zero overhead. + * + * NVS overrides (read at boot if present, fall back to Kconfig defaults): + * softap_ssid (string, up to 32 chars) + * softap_psk (string, 8..63 chars) + * softap_chan (u8, 1..13) + */ + +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#include "esp_err.h" +#include +#include + +#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_SOFTAP_HE_ENABLE) + +/** + * Bring up the soft-AP in AP+STA mode with HE (Wi-Fi 6) advertised and + * TWT Responder=1 if the IDF build supports it. Idempotent — safe to + * call once during boot after `esp_wifi_init()`. Returns the channel + * the AP is actually running on (may differ from Kconfig if the IDF + * scanner picks a clearer channel). + */ +esp_err_t c6_softap_he_start(uint8_t *out_channel); + +/** + * True after the IDF reports the AP has started successfully. + */ +bool c6_softap_he_is_up(void); + +/** + * Number of currently associated stations (read-only, refreshed on the + * WIFI_EVENT_AP_STACONNECTED/DISCONNECTED events). + */ +uint8_t c6_softap_he_sta_count(void); + +#else /* disabled — no-op stubs */ + +static inline esp_err_t c6_softap_he_start(uint8_t *out_channel) +{ + if (out_channel) *out_channel = 0; + return ESP_OK; +} +static inline bool c6_softap_he_is_up(void) { return false; } +static inline uint8_t c6_softap_he_sta_count(void) { return 0; } + +#endif + +#ifdef __cplusplus +} +#endif diff --git a/firmware/esp32-csi-node/main/lp_core/CMakeLists.txt b/firmware/esp32-csi-node/main/lp_core/CMakeLists.txt new file mode 100644 index 00000000..6e799242 --- /dev/null +++ b/firmware/esp32-csi-node/main/lp_core/CMakeLists.txt @@ -0,0 +1,9 @@ +# LP-core motion-gate program — ADR-110 Phase 5 (full). +# +# Built only when CONFIG_C6_LP_CORE_ENABLE=y (gated in the parent CMakeLists). +# The IDF build system invokes this via `ulp_embed_binary()` from +# main/CMakeLists.txt. + +# This file intentionally has no idf_component_register — the LP-core sources +# are compiled with the RISC-V LP toolchain via `ulp_embed_binary` and then +# linked into the HP image as a binary blob, not as a normal component. diff --git a/firmware/esp32-csi-node/main/lp_core/main.c b/firmware/esp32-csi-node/main/lp_core/main.c new file mode 100644 index 00000000..4150812c --- /dev/null +++ b/firmware/esp32-csi-node/main/lp_core/main.c @@ -0,0 +1,75 @@ +/** + * @file lp_core/main.c + * @brief LP RISC-V coprocessor motion-gate — ADR-110 Phase 5 (full). + * + * Polls a single LP-IO GPIO at LP_TIMER cadence (default 10 ms / 100 Hz), + * debounces N consecutive samples, and wakes the HP core when a confirmed + * transition matches the configured active-edge polarity. Counter + + * last-level are exported as shared symbols so the HP side can inspect + * them on wake. + * + * Shared symbols (HP-visible as `ulp_` after `ulp_embed_binary`): + * - wake_gpio_num (input) : LP-IO index 0..7 on ESP32-C6 + * - wake_active_high (input) : 1 = wake on rising stable, 0 = falling + * - debounce_samples (input) : consecutive matches required, default 3 + * - motion_count (output) : monotonic wake-trigger counter + * - last_gpio_level (output) : level latched at the most recent wake + * - poll_count (output) : total LP-timer ticks observed (sanity) + * + * Defaults are written by HP via the `ulp_*` symbols before `ulp_lp_core_run()`, + * so the program is parameterised at boot without recompiling the LP binary. + */ + +#include +#include +#include "ulp_lp_core.h" +#include "ulp_lp_core_utils.h" +#include "ulp_lp_core_gpio.h" + +/* --- Shared (HP/LP) state --- */ +volatile uint32_t wake_gpio_num = 4; /* LP-IO 4 by default */ +volatile uint32_t wake_active_high = 1; /* rising edge */ +volatile uint32_t debounce_samples = 3; +volatile uint32_t motion_count = 0; +volatile uint32_t last_gpio_level = 0; +volatile uint32_t poll_count = 0; + +/* --- Local state (persists across LP-timer wake cycles via .data) --- */ +static uint32_t stable_run = 0; +static uint32_t prev_level = 0; + +int main(void) +{ + poll_count++; + + /* LP-IO read returns 0/1 directly. The Kconfig-selected GPIO index maps + * 1:1 to LP_IO on C6 for indices 0..7. */ + uint32_t level = (uint32_t)ulp_lp_core_gpio_get_level((lp_io_num_t)wake_gpio_num); + + if (level == prev_level) { + if (stable_run < 0xFFFFu) stable_run++; + } else { + stable_run = 1; + prev_level = level; + } + + /* Trigger when level matches the configured active polarity AND has been + * stable for `debounce_samples` consecutive reads. After firing, hold off + * until level returns to the inactive state to avoid re-triggering on + * the same continuous edge. */ + static uint32_t armed = 1; + uint32_t want = wake_active_high ? 1 : 0; + + if (armed && level == want && stable_run >= debounce_samples) { + motion_count++; + last_gpio_level = level; + armed = 0; + ulp_lp_core_wakeup_main_processor(); + } else if (!armed && level != want && stable_run >= debounce_samples) { + /* Re-arm once the line has cleanly returned to the inactive state. */ + armed = 1; + } + + /* ulp_lp_core_halt() is called automatically when main returns. */ + return 0; +} diff --git a/firmware/esp32-csi-node/main/main.c b/firmware/esp32-csi-node/main/main.c index b45b840b..ef576890 100644 --- a/firmware/esp32-csi-node/main/main.c +++ b/firmware/esp32-csi-node/main/main.c @@ -37,6 +37,7 @@ #include "c6_timesync.h" /* ADR-110: 802.15.4 mesh time-sync (no-op on S3) */ #include "c6_lp_core.h" /* ADR-110: LP-core hibernation (no-op on S3) */ #include "c6_sync_espnow.h" /* ADR-110 D1 workaround: ESP-NOW sync */ +#include "c6_softap_he.h" /* ADR-110 B1/B2: HE/TWT soft-AP (no-op when disabled) */ #ifdef CONFIG_CSI_MOCK_ENABLED #include "mock_csi.h" #endif @@ -116,6 +117,17 @@ static void wifi_init_sta(void) ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + +#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_SOFTAP_HE_ENABLE) + /* ADR-110 B1/B2 cheap-unblock: bring up a soft-AP that advertises HE + + * TWT Responder=1 so a second C6 board can negotiate iTWT against + * this node. c6_softap_he_start() switches the mode to AP+STA. */ + uint8_t softap_chan = 0; + if (c6_softap_he_start(&softap_chan) == ESP_OK) { + ESP_LOGI(TAG, "C6 soft-AP HE armed on channel %u (ADR-110 B1/B2)", softap_chan); + } +#endif + ESP_ERROR_CHECK(esp_wifi_start()); ESP_LOGI(TAG, "WiFi STA initialized, connecting to SSID: %s", g_nvs_config.wifi_ssid); diff --git a/firmware/esp32-csi-node/version.txt b/firmware/esp32-csi-node/version.txt index bf21f525..2228cad4 100644 --- a/firmware/esp32-csi-node/version.txt +++ b/firmware/esp32-csi-node/version.txt @@ -1 +1 @@ -0.6.6 \ No newline at end of file +0.6.7