feat(firmware): v0.6.7-esp32 — real LP-core program + C6 soft-AP HE/TWT helper

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 <ruv@ruv.net>
This commit is contained in:
ruv 2026-05-23 11:10:34 -04:00
parent 561647b3af
commit 948768bdda
13 changed files with 556 additions and 23 deletions

View File

@ -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 `<IBBHIIBB2x` to `<IBBHIIBBBB`; new metadata fields (`ppdu_type`, `he_capable`, `bw40`, `stbc`, `ldpc`, `ieee802154_sync_valid`). 5 new `TestAdr110ByteEncoding` cases; **11/11 parser tests pass**. - **Python** (`archive/v1/src/hardware/csi_extractor.py`): `HEADER_FMT` extended from `<IBBHIIBB2x` to `<IBBHIIBBBB`; new metadata fields (`ppdu_type`, `he_capable`, `bw40`, `stbc`, `ldpc`, `ieee802154_sync_valid`). 5 new `TestAdr110ByteEncoding` cases; **11/11 parser tests pass**.
- Both decoders match the firmware encoder bit-for-bit. Pre-ADR-110 firmware sends zeros that round-trip as `HtLegacy` + default flags — fully backwards compatible. - Both decoders match the firmware encoder bit-for-bit. Pre-ADR-110 firmware sends zeros that round-trip as `HtLegacy` + default flags — fully backwards compatible.
- **Security fix** (`scripts/redact-secrets.py` + `generate-witness-bundle.sh`): the Python proof step was echoing `.env` contents into the bundled `verification-output.log` via Pydantic validation errors. Bundle nuked before push; added a `stdin -> stdout` redaction filter covering common token prefixes, long opaque strings, and long hex runs. Verified zero leaks on rebuild. - **Security fix** (`scripts/redact-secrets.py` + `generate-witness-bundle.sh`): the Python proof step was echoing `.env` contents into the bundled `verification-output.log` via Pydantic validation errors. Bundle nuked before push; added a `stdin -> 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).** - **Real-time CSI introspection / low-latency tap on `wifi-densepose-sensing-server` (ADR-099).**
New `wifi_densepose_sensing_server::introspection` module wires New `wifi_densepose_sensing_server::introspection` module wires
[midstream](https://github.com/ruvnet/midstream)'s `temporal-attractor` (Lyapunov + [midstream](https://github.com/ruvnet/midstream)'s `temporal-attractor` (Lyapunov +

View File

@ -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) ## A. Empirically verified (real silicon, today)
| # | Claim | Evidence | | # | Claim | Evidence |

View File

@ -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 | | **P2** | HE-LTF CSI tagging in `csi_collector.c` | pending |
| **P3** | TWT setup helper | pending | | **P3** | TWT setup helper | pending |
| **P4** | 802.15.4 init + skeleton time-sync | 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) | | **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 | | **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) | | **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. This ADR is updated at the end of each phase with the actual outcome, links to commits, and any deviations from the design.

View File

@ -15,6 +15,8 @@ set(SRCS
"c6_lp_core.c" "c6_lp_core.c"
# ADR-110 D1 workaround ESP-NOW cross-node sync (works on S3+C6) # ADR-110 D1 workaround ESP-NOW cross-node sync (works on S3+C6)
"c6_sync_espnow.c" "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). # ESP-IDF v6+: headers must resolve via explicit REQUIRES (no implicit deps).
@ -65,3 +67,15 @@ idf_component_register(
INCLUDE_DIRS "." INCLUDE_DIRS "."
REQUIRES ${REQUIRES} 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()

View File

@ -352,6 +352,56 @@ menu "ESP32-C6 capabilities (ADR-110)"
default y default y
depends on C6_LP_CORE_ENABLE 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 endmenu
menu "ADR-018 frame extensions (ADR-110)" menu "ADR-018 frame extensions (ADR-110)"

View File

@ -1,14 +1,24 @@
/** /**
* @file c6_lp_core.c * @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 * Two operating modes, controlled by CONFIG_C6_LP_CORE_ENABLE:
* 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 * 1. ENABLED real LP-core RISC-V program polls the wake GPIO at
* + deep-sleep entry, using esp_sleep_enable_ext1_wakeup() as the wake * LP_TIMER cadence (default 10 ms), debounces N matching samples,
* source. A follow-up turn will replace ext1 with a true LP-core * and triggers an HP wake via `ulp_lp_core_wakeup_main_processor()`.
* polling program that can debounce / threshold the accelerometer * HP enters deep sleep with `ESP_SLEEP_WAKEUP_ULP` as the source.
* signal in software, dropping standby current from ~10 µA to ~5 µA. * 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" #include "sdkconfig.h"
@ -20,6 +30,18 @@
#include "esp_sleep.h" #include "esp_sleep.h"
#include "driver/rtc_io.h" #include "driver/rtc_io.h"
#include "soc/soc_caps.h" #include "soc/soc_caps.h"
#include <string.h>
#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"; 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_active_high = true;
static bool s_armed = false; 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) esp_err_t c6_lp_core_arm(int wake_gpio, bool active_high)
{ {
if (wake_gpio < 0) { 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_wake_gpio = wake_gpio;
s_active_high = active_high; 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); esp_err_t ret = rtc_gpio_init(wake_gpio);
if (ret != ESP_OK) { if (ret != ESP_OK) {
ESP_LOGE(TAG, "rtc_gpio_init(%d) failed: %s", wake_gpio, esp_err_to_name(ret)); ESP_LOGE(TAG, "rtc_gpio_init(%d) failed: %s", wake_gpio, esp_err_to_name(ret));
return ret; return ret;
} }
rtc_gpio_set_direction(wake_gpio, RTC_GPIO_MODE_INPUT_ONLY); 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; uint64_t mask = 1ULL << wake_gpio;
esp_deepsleep_gpio_wake_up_mode_t mode = active_high esp_deepsleep_gpio_wake_up_mode_t mode = active_high
? ESP_GPIO_WAKEUP_GPIO_HIGH ? ESP_GPIO_WAKEUP_GPIO_HIGH
: ESP_GPIO_WAKEUP_GPIO_LOW; : ESP_GPIO_WAKEUP_GPIO_LOW;
ret = esp_deep_sleep_enable_gpio_wakeup(mask, mode); esp_err_t err = esp_deep_sleep_enable_gpio_wakeup(mask, mode);
if (ret != ESP_OK) { if (err != ESP_OK) {
ESP_LOGE(TAG, "enable_gpio_wakeup failed: %s", esp_err_to_name(ret)); ESP_LOGE(TAG, "enable_gpio_wakeup failed: %s", esp_err_to_name(err));
return ret; return err;
} }
s_armed = true; 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"); wake_gpio, active_high ? "high" : "low");
return ESP_OK; return ESP_OK;
#endif
} }
void c6_lp_core_hibernate_and_wait(void) void c6_lp_core_hibernate_and_wait(void)
@ -66,13 +154,15 @@ void c6_lp_core_hibernate_and_wait(void)
if (!s_armed) { if (!s_armed) {
ESP_LOGW(TAG, "hibernate called without arm — sleeping with no wake source"); ESP_LOGW(TAG, "hibernate called without arm — sleeping with no wake source");
} }
/* Configure for hibernation: power down everything except what's needed /* Power down the RTC peripheral domain — the LP-core itself stays
* to retain the wake source. On C6 the RTC peripheral domain is the * powered on the LP power domain so it can keep polling. */
* only one we need to gate explicitly RTC_SLOW_MEM / RTC_FAST_MEM
* aren't separate power domains on the C6 SoC. */
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_OFF); 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(); esp_deep_sleep_start();
/* Never returns. */ /* Never returns. */
} }
@ -80,7 +170,27 @@ void c6_lp_core_hibernate_and_wait(void)
bool c6_lp_core_was_motion_wake(void) bool c6_lp_core_was_motion_wake(void)
{ {
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause(); 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; 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 */ #endif /* CONFIG_IDF_TARGET_ESP32C6 && CONFIG_ULP_COPROC_TYPE_LP_CORE */

View File

@ -48,11 +48,27 @@ void c6_lp_core_hibernate_and_wait(void);
*/ */
bool c6_lp_core_was_motion_wake(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 #else
static inline esp_err_t c6_lp_core_arm(int g, bool h) { (void)g; (void)h; return ESP_OK; } 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 void c6_lp_core_hibernate_and_wait(void) { }
static inline bool c6_lp_core_was_motion_wake(void) { return false; } 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 #endif

View File

@ -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 <string.h>
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 */

View File

@ -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 <stdint.h>
#include <stdbool.h>
#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

View File

@ -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.

View File

@ -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_<name>` 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 <stdint.h>
#include <stdbool.h>
#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;
}

View File

@ -37,6 +37,7 @@
#include "c6_timesync.h" /* ADR-110: 802.15.4 mesh time-sync (no-op on S3) */ #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_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_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 #ifdef CONFIG_CSI_MOCK_ENABLED
#include "mock_csi.h" #include "mock_csi.h"
#endif #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_mode(WIFI_MODE_STA));
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); 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_ERROR_CHECK(esp_wifi_start());
ESP_LOGI(TAG, "WiFi STA initialized, connecting to SSID: %s", g_nvs_config.wifi_ssid); ESP_LOGI(TAG, "WiFi STA initialized, connecting to SSID: %s", g_nvs_config.wifi_ssid);

View File

@ -1 +1 @@
0.6.6 0.6.7