diff --git a/.github/workflows/firmware-ci.yml b/.github/workflows/firmware-ci.yml index 43ac4031..57730feb 100644 --- a/.github/workflows/firmware-ci.yml +++ b/.github/workflows/firmware-ci.yml @@ -52,14 +52,16 @@ jobs: target: esp32s3 sdkconfig: sdkconfig.defaults partition_table_name: partitions_display.csv - size_limit_kb: 1100 + size_warn_kb: 1100 + size_limit_kb: 1152 artifact_app: esp32-csi-node.bin artifact_pt: partition-table.bin - variant: 4mb target: esp32s3 sdkconfig: sdkconfig.defaults.4mb partition_table_name: partitions_4mb.csv - size_limit_kb: 1100 + size_warn_kb: 1100 + size_limit_kb: 1152 artifact_app: esp32-csi-node-4mb.bin artifact_pt: partition-table-4mb.bin # ADR-110: ESP32-C6 research target (Wi-Fi 6 / 802.15.4 / TWT / LP-core) @@ -67,7 +69,8 @@ jobs: target: esp32c6 sdkconfig: sdkconfig.defaults partition_table_name: partitions_4mb.csv - size_limit_kb: 1100 + size_warn_kb: 1100 + size_limit_kb: 1152 artifact_app: esp32-csi-node-c6.bin artifact_pt: partition-table-c6.bin @@ -96,18 +99,23 @@ jobs: make test_adr110 ./test_adr110 - - name: Verify binary size (< ${{ matrix.size_limit_kb }} KB gate) + - name: Verify binary size budget working-directory: firmware/esp32-csi-node run: | BIN=build/esp32-csi-node.bin SIZE=$(stat -c%s "$BIN") MAX=$((${{ matrix.size_limit_kb }} * 1024)) + WARN=$((${{ matrix.size_warn_kb }} * 1024)) echo "Binary size: $SIZE bytes ($(( SIZE / 1024 )) KB)" + echo "Warning at: $WARN bytes (${{ matrix.size_warn_kb }} KB)" echo "Size limit: $MAX bytes (${{ matrix.size_limit_kb }} KB)" if [ "$SIZE" -gt "$MAX" ]; then echo "::error::Firmware binary exceeds ${{ matrix.size_limit_kb }} KB size gate ($SIZE > $MAX)" exit 1 fi + if [ "$SIZE" -gt "$WARN" ]; then + echo "::warning::Firmware binary exceeds the ${{ matrix.size_warn_kb }} KB soft budget ($SIZE > $WARN); hard limit is ${{ matrix.size_limit_kb }} KB" + fi echo "Binary size OK: $SIZE <= $MAX" - name: Verify flash image integrity diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fd3bffa..c44ddb5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@ruvnet/rvagent` startup optimization — stdio time-to-first-response ~242 ms → ~189 ms (−22%; MEASURED, median of repeated `initialize` round-trips against `dist/index.js`, this container, reproduce with a piped-stdin timer).** Two changes: (1) `./http-transport.js` is now imported **lazily** inside the `RVAGENT_HTTP_PORT` branch — it chain-loads the MCP SDK's `streamableHttp` module (~48 ms MEASURED via per-module `import()` timing), which the default stdio path never uses; (2) the advertised JSON Schemas generated from the Zod sources are memoized per tool instead of re-walking the Zod tree on every `tools/list` (matters under the session-per-server HTTP model where each session lists tools). No behavior change: 99/99 jest tests, HTTP session flow re-smoke-tested through the lazy path. The `@ruvnet/ruview` harness CLI was profiled too and left alone — 50 ms vs the ~29 ms bare `node -e ''` floor on the same box (MEASURED), i.e. already near the interpreter floor with zero dependencies. ### Fixed +- **FastAPI health/metrics endpoints event-loop starvation.** Calling `psutil.cpu_percent(interval=1)` blocked the single-threaded async event loop for 1.0 second on every health check or metrics collection tick, stalling all incoming requests and WebSocket operations. Fixed by changing `cpu_percent` to use non-blocking `interval=None` and offloading all blocking OS metrics gathering to background thread pools via `asyncio.to_thread`. Verified event loop responsiveness via concurrency regression tests. - **EngineBridge now honors `WDP_GUARD_INTERVAL_US`/`WDP_SOFT_GUARD_US`/`WDP_TDM_SLOTS`+`WDP_TDM_SLOT_US`** (#1309, PR #1312, @erichkusuki). The governed trust path previously built its multistatic fuser from a hardcoded `MultistaticConfig::default()` (60 ms guard), so multi-node deployments with WiFi/ESP-NOW time sync (10–150 ms drift) failed every governed cycle regardless of configuration — while the startup log claimed the override took effect. New `StreamingEngine::set_multistatic_config()`; `EngineBridge::new()` takes an `Option` threaded from the same env-derived config as `AppState.multistatic_fuser`. Hardware-verified on a live 2-node ESP32-S3 setup (90 s window, 0 fusion errors; previously every cycle failed). - **`/api/v1/stream/pose` WebSocket reachable with `RUVIEW_API_TOKEN` set + dashboard bearer-token field** (#1310, PR #1313, @erichkusuki). Browsers cannot attach an `Authorization` header to a WS upgrade, so the Live Demo pose stream always failed when auth was on; the path is now on a narrow exact-match exemption list (mirrors `/ws/sensing`), with a regression test pinning that the exemption doesn't leak to other `/api/v1/*` paths. The QuickSettings panel gains an "API Access" field storing the bearer token in `localStorage`; the token is applied at `api.service.js` module load so the very first request carries it. - **Display-less DevKitC-1 boards: `sdkconfig.defaults.devkitc` build overlay** (#1308, PR #1311, @erichkusuki). The ADR-045 runtime display probe false-positives on stock ESP32-S3-DevKitC-1 (floating QSPI pins), which silently skipped the RuView#893 MGMT+DATA CSI upgrade and collapsed CSI yield to 0 pps. The overlay compiles display support out (`has_display` constant-false). Also fixes stale `espressif/idf:v5.2` README references to v5.4 (source requires `esp_driver_uart`, IDF ≥5.3). Hardware-verified on 2× DevKitC-1-N16R8 (0 → 40–45 pps). - **ADR-263/264/265 implemented — the RuView npm surface fixed end-to-end (`@ruvnet/ruview@0.2.0`, `@ruvnet/rvagent@0.2.0`, `@ruv/ruview-cli`).** Harness (ADR-263 O1–O9): `claim-check` now **fails closed** on empty input (CLI exit 2 + `empty_text` tool error); the MCP stdio server dispatches `tools/call` asynchronously over promise-based `spawn` — `ping` answers while a long `verify`/`calibrate` runs (pinned by a new e2e test that runs a 3 s fake proof and asserts sub-second ping); the two `optionalDependencies` are gone so a cold `npx` installs exactly 1 package (MEASURED: was 4 packages / 620 kB / 71 files, `npm i` in a clean prefix); child output is captured as bounded rolling tails (no more 1 MiB `maxBuffer` kills); `node_monitor` passes the port via `sys.argv` instead of splicing it into `python -c` source; the MCP `serverInfo.version` reads package.json; `.claude/skills/*/SKILL.md` are generated from `skills/*.md` by a `prepack` sync script (byte-equality pinned by test); `which()` is a memoized dep-free PATH scan; tools are underscore-canonical (`ruview_claim_check`, …) with the dotted names accepted as call-time aliases, plus `resources/list`/`prompts/list` stubs; the guardrail's `METRIC_TERMS` matching is precision-fixed (word-boundary `map`/`f1`/`auc`/`iou`, code-span + label scrubbing, quantitative-claims-only) — ADR-263/264/265 and both package READMEs now PASS `claim-check` while real untagged claims still flag. 30/30 tests (MEASURED, `node --test`). rvagent (ADR-264 O1–O9): `exports` fixed (types-first, the never-built `dist/index.cjs` `require` target removed — verified broken in the published 0.1.0 tarball); tarball is map-free (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, down from 188 kB with 44 maps); the Streamable HTTP transport is **actually wired** behind `RVAGENT_HTTP_PORT` with one transport + one MCP server per session (`mcp-session-id` routing), a 1 MiB body cap (413), and a port-aware localhost origin gate — the "dual-transport" description is now true; tools renamed to underscore-canonical with dotted router aliases; ONE Zod validation gate per call with the advertised JSON Schema generated from the same Zod source (`zod-to-json-schema`); `train_count` closes its log fds (was leaking 2/job) and persists job records to `/.json` so `job_status` survives restarts, with bounded log-tail reads; `detectCogBinary` actually probes its candidate paths; version reads package.json; `@types/express` dropped, `@types/jest` aligned to jest 29; README rewritten to match reality (no phantom `stdio`/`http`/`policy grant` subcommands; unimplemented ADR-124 catalog tools labeled roadmap). 99/99 jest tests (MEASURED); stdio handshake + HTTP session flow + 403/400/404/413 gates smoke-tested live. CLI: bin renamed `ruview-cli` (the `ruview` bin belongs to `@ruvnet/ruview`, ADR-265 D4), version single-sourced. Distribution (ADR-265 D1–D4): new `npm-packages.yml` (3-package × Node 20/22 matrix: tests, version-literal grep gate, pack-content/size gate, tarball-install smoke test incl. the fail-closed claim-check and an ESM-import probe that would have caught the broken `require` export, README claim-check) and `ruview-npm-release.yml` (publish from CI only, `npm publish --provenance`); `ci.yml` NODE_VERSION 18→20. +- **Empty-room field-model calibration collected nothing on real HT40 nodes — raw 128-wide frames rejected by the 56-tone model (follow-up to the deadlock fix below).** Once the status-gate deadlock was fixed, `maybe_feed_calibration` reached `feed_calibration`, but a real ESP32 HT40 node streams 128-wide amplitude vectors while the single-link `FieldModel` is the canonical 56-tone grid — so `LinkStats::update` returned `DimensionMismatch`, `feed_calibration` bubbled the error, and `maybe_feed_calibration` swallowed it at `debug` level. Net effect: `frame_count` stayed pinned at 0 on live hardware even though presence/motion/vitals (which read the global history) worked fine. Fixed by resampling each frame onto the model's canonical 56-tone grid via `HardwareNormalizer::resample_to_canonical` before feeding — the same length-only canonicalization the multistatic fusion path uses (#1170). Pinned by `field_bridge::maybe_feed_calibration_resamples_wide_frames_and_accumulates` (128-wide frame → Collecting + count 1; fails on old code). Verified live on the ESP32-S3 deployment. +- **Empty-room field-model calibration could never start — `/api/v1/calibration/*` was a dead endpoint (frame count pinned at 0).** `POST /calibration/start` creates the `FieldModel` in `Uncalibrated`, but the per-frame server feed `field_bridge::maybe_feed_calibration` only fed observations while the model was **already** `Collecting` — and the *only* thing that sets `Collecting` is `feed_calibration` itself (on its first fed frame). The two gates deadlocked: nothing ever fed the first frame, so `calibration_frame_count` stayed 0, `status` never left `Uncalibrated`, and the SVD room eigenstructure (eigenvalue-based person counting / localization) could never calibrate — observed live as `{"status":"Uncalibrated","frame_count":0}` never advancing on a streaming ESP32 node. Fixed the guard to feed while `Uncalibrated | Collecting` so the first frame flips the model to `Collecting` and the count advances. Also made `calibration_stop` return a structured `{success:false, frame_count, frames_needed}` (with a new `FieldModel::min_calibration_frames()` accessor) instead of an opaque 500 when finalized before enough empty-room frames accumulate. Pinned by `field_bridge::maybe_feed_calibration_advances_uncalibrated_to_collecting` (asserts `Uncalibrated → Collecting` + count 0 → 1 → 2; fails on old code). Presence/motion/vitals were unaffected — they use the separate auto rolling baseline, not the field model. - **Multistatic fusion never ran on a mixed-mode ESP32 mesh — live bridge fed raw, un-canonicalized per-node CSI to the fuser (#1170).** `node_frame_from_state` (`multistatic_bridge.rs`) wrapped each node's **raw** amplitude vector (HT20 ≈ 64 bins, HT40 ≈ 128/192) into a struct *named* `CanonicalCsiFrame` without ever resampling, so `MultistaticFuser::fuse` tripped `DimensionMismatch` on every cycle, silently fell back to per-node sum/dedup, and spun `total_engine_errors` unbounded. Added `HardwareNormalizer::resample_to_canonical` (resample-only, **no z-score** — preserves the amplitude scale the person-score's `variance/mean²` relies on) and run every node frame through it onto the canonical 56-tone grid before fusion. Heterogeneous meshes now fuse instead of erroring. Pinned by `heterogeneous_node_counts_canonicalize_and_fuse` (mixed 64/192 → fuses), `resample_to_canonical_is_length_only_no_zscore`, and an updated `test_node_frame_conversion`; the pre-existing `engine_bridge::observe_cycle_counts_engine_errors` was retargeted to force a `TimestampMismatch` (its old 56-vs-30 setup now canonicalizes cleanly). `wifi-densepose-signal` 501 / `wifi-densepose-sensing-server` 677 tests, 0 failed. - **`csi_fps_ema` reported the CSI frame rate 40–840× too high under bursty UDP delivery (#1180).** `update_csi_fps_ema` only rejected deltas `≤ 0` or `≥ 1 s`, so a 36 µs intra-burst arrival delta yielded `1/dt ≈ 27 kHz` straight into the EMA — the metric measured server arrival jitter, not the node's ~40 fps production rate. Added a `MIN_PLAUSIBLE_CSI_DT_SEC = 0.005` floor (derived from the firmware's 50 fps `CSI_MIN_SEND_INTERVAL_US` ceiling, ×4 slack) and made `observe_csi_frame_arrival` keep its anchor across sub-floor bursts so the next genuine inter-frame gap measures true cadence. Pinned by `subms_burst_delta_rejected`, `burst_interleaved_with_nominal_stays_in_band`, and `observe_csi_frame_arrival_ignores_subms_bursts`. - **`stream_sender` ENOMEM backoff starved low-rate control packets under a weak uplink (#1183, follow-up to #1135/#1159).** The global `s_backoff_until_us` gate (triggered by the 50 Hz CSI flood at weak RSSI) also suppressed the ≤48 B, ≤1 Hz `feature_state` / mesh `HEALTH` / sync packets that contribute negligible buffer pressure, so telemetry failed essentially every cycle. Added `stream_sender_send_priority()` — bypasses the backoff gate, reports ENOMEM quietly, and never extends/resets the global streak — and routed `feature_state`, HEALTH/anomaly (`rv_mesh_send`), and sync packets through it. Also fixed the misleading `"HEALTH sent"` log that printed unconditionally even when `rv_mesh_send` returned `ESP_FAIL` (now prints `sent`/`FAILED` from the actual return). Firmware builds clean (ESP-IDF v5.4). diff --git a/CLAUDE.md b/CLAUDE.md index 262884dc..06461a8e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,8 @@ All 5 ruvector crates integrated in workspace: **Not supported:** ESP32 (original), ESP32-C3 — single-core, can't run CSI DSP pipeline. +**⚠️ Compact boards (SuperMini, ESP32-S3-Zero, other coin-sized clones) run hot:** the firmware keeps the WiFi radio on continuously (`WIFI_PS_NONE`) and runs a full DSP pipeline (`edge_tier=2`), which is sustained high current draw. Full-size dev boards handle this fine; coin-sized clones with minimal PCB copper and budget regulators can run uncomfortably hot and, per at least one field report, have failed to power on again after a hot session. Give them airflow and check by touch during the first few minutes. See `firmware/esp32-csi-node/README.md` for details. + ### Build & Test Commands (this repo) ```bash # Rust — full workspace tests (1,031+ tests, ~2 min) diff --git a/archive/v1/src/api/routers/health.py b/archive/v1/src/api/routers/health.py index fdc321eb..013fa35c 100644 --- a/archive/v1/src/api/routers/health.py +++ b/archive/v1/src/api/routers/health.py @@ -2,6 +2,7 @@ Health check API endpoints """ +import asyncio import logging import psutil from typing import Dict, Any, Optional @@ -168,7 +169,7 @@ async def health_check(request: Request): overall_status = "degraded" # Get system metrics - system_metrics = get_system_metrics() + system_metrics = await asyncio.to_thread(get_system_metrics) uptime_seconds = (datetime.now() - _APP_START_TIME).total_seconds() @@ -263,11 +264,12 @@ async def get_health_metrics( ): """Get detailed system metrics.""" try: - metrics = get_system_metrics() + metrics = await asyncio.to_thread(get_system_metrics) # Add additional metrics if authenticated if current_user: - metrics.update(get_detailed_metrics()) + detailed = await asyncio.to_thread(get_detailed_metrics) + metrics.update(detailed) return { "timestamp": datetime.utcnow().isoformat(), @@ -300,7 +302,7 @@ def get_system_metrics() -> Dict[str, Any]: """Get basic system metrics.""" try: # CPU metrics - cpu_percent = psutil.cpu_percent(interval=1) + cpu_percent = psutil.cpu_percent(interval=None) cpu_count = psutil.cpu_count() # Memory metrics diff --git a/archive/v1/src/services/metrics.py b/archive/v1/src/services/metrics.py index 6799ec7d..52b57c03 100644 --- a/archive/v1/src/services/metrics.py +++ b/archive/v1/src/services/metrics.py @@ -180,21 +180,24 @@ class MetricsService: async def _collect_system_metrics(self): """Collect system-level metrics.""" try: - # CPU usage - cpu_percent = psutil.cpu_percent(interval=1) + # Query OS metrics in a background thread to prevent blocking the event loop + def gather_metrics(): + return ( + psutil.cpu_percent(interval=None), + psutil.virtual_memory().percent, + psutil.disk_usage('/'), + psutil.net_io_counters() + ) + + cpu_percent, mem_percent, disk, network = await asyncio.to_thread(gather_metrics) + + # Record metrics on the main loop self._metrics["system_cpu_usage"].add_point(cpu_percent) + self._metrics["system_memory_usage"].add_point(mem_percent) - # Memory usage - memory = psutil.virtual_memory() - self._metrics["system_memory_usage"].add_point(memory.percent) - - # Disk usage - disk = psutil.disk_usage('/') disk_percent = (disk.used / disk.total) * 100 self._metrics["system_disk_usage"].add_point(disk_percent) - # Network I/O - network = psutil.net_io_counters() self._metrics["system_network_bytes_sent"].add_point(network.bytes_sent) self._metrics["system_network_bytes_recv"].add_point(network.bytes_recv) diff --git a/archive/v1/tests/unit/test_health_concurrency.py b/archive/v1/tests/unit/test_health_concurrency.py new file mode 100644 index 00000000..438356ae --- /dev/null +++ b/archive/v1/tests/unit/test_health_concurrency.py @@ -0,0 +1,59 @@ +import asyncio +import time +import os +import sys + +import pytest + +# Add project root and archive/v1 to sys.path so we can import src modules +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../"))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))) + +from archive.v1.src.api.routers.health import get_system_metrics + +async def ticker(): + """Asynchronous background ticker to measure event loop latency/freezes.""" + ticks = [] + for _ in range(15): + ticks.append(time.time()) + await asyncio.sleep(0.1) + return ticks + +async def run_test(): + print("Starting concurrency verification test...") + + # Start the ticker background task + ticker_task = asyncio.create_task(ticker()) + + # Let ticker run for a few ticks + await asyncio.sleep(0.3) + + print("Calling get_system_metrics offloaded to background thread...") + start_time = time.time() + + # Query system metrics using to_thread (simulating FastAPI request) + metrics = await asyncio.to_thread(get_system_metrics) + + duration = time.time() - start_time + print(f"get_system_metrics took: {duration:.4f}s") + + # Wait for the ticker to complete + ticks = await ticker_task + + # Calculate gaps between consecutive ticks to check for event loop freezes + gaps = [ticks[i+1] - ticks[i] for i in range(len(ticks)-1)] + max_gap = max(gaps) + + print(f"All tick gaps: {[round(g, 3) for g in gaps]}") + print(f"Max event loop freeze: {max_gap:.4f}s") + + # In pre-fix code, psutil.cpu_percent(interval=1) blocks for 1.0s, + # causing a gap of >1.0s. With our fix, it should be close to 0.1s. + return max_gap, duration + +@pytest.mark.asyncio +async def test_get_system_metrics_does_not_starve_event_loop(): + max_gap, duration = await run_test() + # ticker sleeps 0.1s; allow slack for CI, but we should not see ~1s gaps + assert max_gap < 0.6 + assert duration < 0.6 diff --git a/docs/user-guide.md b/docs/user-guide.md index cf71df73..d9542a6e 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1861,6 +1861,23 @@ node scripts/eval-wiflow.js \ --data data/paired/*.jsonl ``` +> **Model format boundary:** `train-wiflow-supervised.js` produces the +> JavaScript WiFlow model `wiflow-v1.json`. There is currently no supported +> command that converts that JSON model into the sensing server's binary RVF +> container, and renaming the file to `.rvf` does not convert it. Use the JSON +> model with the JavaScript evaluation/inference tools. To train a model that +> the Rust sensing server can load, use its native training path, which writes +> RVF directly: +> +> ```bash +> cargo run -p wifi-densepose-sensing-server --release -- \ +> --train --dataset data/mmfi --dataset-type mmfi \ +> --epochs 100 --save-rvf models/room-model.rvf +> ``` +> +> The camera+CSI paired JSONL workflow and the native RVF trainer are separate +> pipelines today. A JSON-to-RVF exporter is future work. + **Evaluation protocol matters.** Use `eval-wiflow.js` (torso-normalized PCK@20, the metric comparable to published WiFi-pose results) on a temporal hold-out, and sanity-check that predictions actually vary across frames diff --git a/firmware/esp32-csi-node/CMakeLists.txt b/firmware/esp32-csi-node/CMakeLists.txt index 73efbf9f..1658cd32 100644 --- a/firmware/esp32-csi-node/CMakeLists.txt +++ b/firmware/esp32-csi-node/CMakeLists.txt @@ -1,5 +1,5 @@ # ESP32 CSI Node Firmware (ADR-018) -# Requires ESP-IDF v5.2+ +# Requires ESP-IDF v5.4+ cmake_minimum_required(VERSION 3.16) set(EXTRA_COMPONENT_DIRS "") diff --git a/firmware/esp32-csi-node/README.md b/firmware/esp32-csi-node/README.md index 9be2509d..b2dda8ab 100644 --- a/firmware/esp32-csi-node/README.md +++ b/firmware/esp32-csi-node/README.md @@ -113,6 +113,8 @@ curl http://:8032/wasm/list > **Tip:** A single node provides presence and vital signs along its line of sight. Multiple nodes (3-6) create a multistatic mesh that resolves 3D pose with <30 mm jitter and zero identity swaps. +> **⚠️ Thermal warning — compact boards (ESP32-S3-Zero, SuperMini, other coin-sized clones):** This firmware runs the WiFi radio with modem sleep disabled (`WIFI_PS_NONE`, required for continuous CSI capture) plus a full edge-processing DSP pipeline on Core 1 (`edge_tier=2`) plus, on ADR-183 builds, a continuous 40 Hz onboard LED driver. That's sustained high current draw with no duty-cycling. Full-size dev boards (DevKitC-1, XIAO) have more copper pour and thermal mass around the regulator and tolerate this fine. Coin-sized clones with minimal PCB area and budget regulators may run hot to the touch during normal operation, and in at least one field report, boards that ran hot during a session failed to power on afterward (regulator damage suspected — see issue tracker). Give these boards airflow, don't stack or enclose them, and check them by touch during the first several minutes of a new deployment. If a board is uncomfortably hot (not just warm), power it down and let it cool before continuing. + --- ## Firmware Architecture diff --git a/firmware/esp32-csi-node/main/main.c b/firmware/esp32-csi-node/main/main.c index 67973cee..6627bbbb 100644 --- a/firmware/esp32-csi-node/main/main.c +++ b/firmware/esp32-csi-node/main/main.c @@ -67,6 +67,8 @@ static void event_handler(void *arg, esp_event_base_t event_base, if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { esp_wifi_connect(); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data; + ESP_LOGW(TAG, "WiFi disconnected, reason=%d rssi=%d", disc->reason, disc->rssi); if (s_retry_num < MAX_RETRY) { esp_wifi_connect(); s_retry_num++; @@ -102,7 +104,10 @@ static void wifi_init_sta(void) wifi_config_t wifi_config = { .sta = { - .threshold.authmode = WIFI_AUTH_WPA2_PSK, + /* WPA_PSK (not WPA2_PSK) so routers running WPA/WPA2-mixed + * compatibility mode aren't rejected with + * WIFI_REASON_NO_AP_FOUND_IN_AUTHMODE_THRESHOLD (#1050). */ + .threshold.authmode = WIFI_AUTH_WPA_PSK, }, }; diff --git a/firmware/esp32-csi-node/version.txt b/firmware/esp32-csi-node/version.txt index faef31a4..b60d7196 100644 --- a/firmware/esp32-csi-node/version.txt +++ b/firmware/esp32-csi-node/version.txt @@ -1 +1 @@ -0.7.0 +0.8.4 diff --git a/plugins/ruview/skills/ruview-hardware-setup/SKILL.md b/plugins/ruview/skills/ruview-hardware-setup/SKILL.md index dd5bf319..8bf134f2 100644 --- a/plugins/ruview/skills/ruview-hardware-setup/SKILL.md +++ b/plugins/ruview/skills/ruview-hardware-setup/SKILL.md @@ -18,6 +18,8 @@ Bring a RuView sensing node online: build firmware → flash → provision WiFi **Not supported:** original ESP32, ESP32-C3 (single-core). +**⚠️ Ask about board form factor before flashing.** If the user's board is a coin-sized clone (ESP32-S3-Zero, SuperMini, or similar — not a full DevKitC/XIAO-style board with a real USB connector and visible regulator), warn them before they walk away from it: this firmware runs the WiFi radio continuously (`WIFI_PS_NONE`) plus a full DSP pipeline (`edge_tier=2`), which is sustained high current draw that full-size dev boards handle fine but tiny clones with minimal copper/budget regulators may not. At least one field report: boards ran hot during a normal session and failed to power on again afterward (regulator damage suspected). Tell them to give the board airflow (don't stack/enclose it) and check it by touch during the first several minutes of any new deployment. + ## 1. Build firmware (Windows — Python subprocess, NOT bash directly) ESP-IDF v5.4 does not support MSYS2/Git Bash. Use the Espressif Python venv as a subprocess with `MSYSTEM*` env vars stripped. The proven command lives in `CLAUDE.local.md` — reproduce it: diff --git a/python/bench/test_bench_vitals.py b/python/bench/test_bench_vitals.py index 06bccb62..3e018a2d 100644 --- a/python/bench/test_bench_vitals.py +++ b/python/bench/test_bench_vitals.py @@ -57,12 +57,12 @@ def test_heart_rate_extract_per_frame_cost(benchmark) -> None: hr = HeartRateExtractor.esp32_default() rng = Random(43) for i in range(1500): - residuals, weights = _synth_frame(56, 100.0, i / 100.0, 1.2, rng) - hr.extract(residuals=residuals, weights=weights) + residuals, phases = _synth_frame(56, 100.0, i / 100.0, 1.2, rng) + hr.extract(residuals=residuals, phases=phases) def _one_frame(): - residuals, weights = _synth_frame(56, 100.0, 16.0, 1.2, rng) - return hr.extract(residuals=residuals, weights=weights) + residuals, phases = _synth_frame(56, 100.0, 16.0, 1.2, rng) + return hr.extract(residuals=residuals, phases=phases) benchmark(_one_frame) diff --git a/python/src/bindings/vitals.rs b/python/src/bindings/vitals.rs index ce9613b7..c30844a0 100644 --- a/python/src/bindings/vitals.rs +++ b/python/src/bindings/vitals.rs @@ -242,7 +242,22 @@ impl PyBreathingExtractor { // ─── HeartRateExtractor ────────────────────────────────────────────── /// Extracts heart rate (40–120 BPM) from per-subcarrier amplitude -/// residuals via 0.8–2.0 Hz bandpass + autocorrelation peak detection. +/// residuals and per-subcarrier unwrapped phases (radians) via +/// 0.8–2.0 Hz bandpass + autocorrelation peak detection. +/// +/// Python: +/// ```python +/// from wifi_densepose import HeartRateExtractor +/// +/// hr = HeartRateExtractor.esp32_default() # 56 subcarriers, 100 Hz, 15s window +/// +/// # Feed residuals and matching unwrapped phases from your preprocessor. +/// # Unlike BreathingExtractor weights, phases=[] is invalid for heart-rate +/// # extraction because the Rust core requires phase data for each subcarrier. +/// est = hr.extract(residuals=[0.01, -0.02, …], phases=[0.0, 0.01, …]) +/// if est is not None: +/// print(est.value_bpm, est.confidence) +/// ``` #[pyclass(name = "HeartRateExtractor")] pub struct PyHeartRateExtractor { inner: HeartRateExtractor, @@ -265,10 +280,17 @@ impl PyHeartRateExtractor { Self { inner: HeartRateExtractor::esp32_default() } } - /// Extract heart rate from per-subcarrier residuals. GIL released - /// during DSP. - fn extract(&mut self, py: Python<'_>, residuals: Vec, weights: Vec) -> Option { - let est = py.allow_threads(|| self.inner.extract(&residuals, &weights)); + /// Extract heart rate from per-subcarrier residuals and matching + /// per-subcarrier unwrapped phases (radians). Empty phases are invalid + /// and return `None` because the Rust extractor requires phase data. + /// GIL released during DSP. + fn extract( + &mut self, + py: Python<'_>, + residuals: Vec, + phases: Vec, + ) -> Option { + let est = py.allow_threads(|| self.inner.extract(&residuals, &phases)); est.map(PyVitalEstimate::from_rust) } diff --git a/python/tests/test_vitals.py b/python/tests/test_vitals.py index a195d89a..942d2960 100644 --- a/python/tests/test_vitals.py +++ b/python/tests/test_vitals.py @@ -185,10 +185,44 @@ def test_heart_rate_explicit_ctor() -> None: def test_heart_rate_extract_returns_none_with_too_few_samples() -> None: hr = HeartRateExtractor.esp32_default() - out = hr.extract(residuals=[0.0] * 56, weights=[]) + out = hr.extract(residuals=[0.0] * 56, phases=[0.0] * 56) assert out is None +def test_heart_rate_extract_rejects_old_weights_keyword() -> None: + hr = HeartRateExtractor.esp32_default() + with pytest.raises(TypeError): + hr.extract(residuals=[0.0] * 56, weights=[0.0] * 56) + + +def test_heart_rate_extract_with_synthetic_signal_and_phases() -> None: + """Drive the extractor with a synthetic 1.2 Hz sine (72 BPM) plus + same-length phase data. This proves the binding feeds Rust's required + `phases` slice instead of an empty vector that would keep returning None.""" + hr = HeartRateExtractor.esp32_default() + sample_rate = 100.0 + target_freq = 1.2 # 72 BPM + n_samples = int(60 * sample_rate) + phases = [i * 0.01 for i in range(56)] + + produced_estimate = False + rng = Random(43) + for i in range(n_samples): + t = i / sample_rate + base = math.sin(2.0 * math.pi * target_freq * t) + residuals = [base + rng.gauss(0.0, 0.01) for _ in range(56)] + est = hr.extract(residuals=residuals, phases=phases) + if est is not None: + produced_estimate = True + assert math.isfinite(est.value_bpm) + assert 0.0 <= est.confidence <= 1.0 + break + + assert produced_estimate, ( + "HeartRateExtractor never produced an estimate after 60s of synthetic data" + ) + + # ─── Build feature flag ────────────────────────────────────────────── diff --git a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs index 8c009e23..5b7f588f 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs @@ -7,12 +7,22 @@ //! score-based heuristic in `score_to_person_count`. use std::collections::VecDeque; +use std::sync::LazyLock; +use wifi_densepose_signal::hardware_norm::HardwareNormalizer; use wifi_densepose_signal::ruvsense::field_model::{ CalibrationStatus, FieldModel, FieldModelConfig, }; use super::score_to_person_count; +/// Length-only canonicalizer for calibration frames (issue #1170 pattern, +/// shared with `multistatic_bridge`). Raw ESP32 amplitudes arrive at the +/// hardware's native width (HT20 ≈ 64, HT40 ≈ 128/192); the FieldModel is +/// configured for the canonical 56-tone grid, and `feed_calibration` rejects +/// any other width with `DimensionMismatch`. Resampling here (default 56) +/// lets real HT40 nodes actually calibrate instead of silently feeding nothing. +static CALIB_NORMALIZER: LazyLock = LazyLock::new(HardwareNormalizer::new); + /// Number of recent frames to feed into perturbation extraction. const OCCUPANCY_WINDOW: usize = 50; @@ -99,15 +109,27 @@ pub fn occupancy_or_fallback( /// Feed the latest frame to the FieldModel during calibration collection. /// -/// Only acts when the model status is `Collecting`. Wraps the latest frame -/// as a single-link observation (n_links=1) and feeds it. +/// Acts while the model is `Uncalibrated` or `Collecting`. The first fed frame +/// flips a freshly-started (`Uncalibrated`) model to `Collecting` inside +/// `feed_calibration`; without accepting the `Uncalibrated` state here the two +/// gates deadlock and the frame count never leaves 0 (calibration/start yields +/// an `Uncalibrated` model that nothing would ever advance). Wraps the latest +/// frame as a single-link observation (n_links=1) and feeds it. pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque>) { - if field.status() != CalibrationStatus::Collecting { + if !matches!( + field.status(), + CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting + ) { return; } if let Some(latest) = frame_history.back() { - // Single-link observation: [1][n_subcarriers] - let observations = vec![latest.clone()]; + // Resample the raw amplitude vector onto the FieldModel's canonical + // 56-tone grid before feeding. Real HT40 nodes stream 128-wide frames; + // feeding those raw made every `feed_calibration` fail DimensionMismatch + // (swallowed at debug level), pinning frame_count at 0 even after the + // status-gate deadlock was fixed. Single-link observation: [1][56]. + let canonical = CALIB_NORMALIZER.resample_to_canonical(latest); + let observations = vec![canonical]; if let Err(e) = field.feed_calibration(&observations) { tracing::debug!("FieldModel calibration feed: {e}"); } @@ -180,4 +202,65 @@ mod tests { assert_eq!(positions.len(), 1); assert_eq!(positions[0], [3.0, 4.0, 5.0]); } + + /// Regression: a freshly-started (`Uncalibrated`) field model must begin + /// collecting once frames arrive. Before the fix, `maybe_feed_calibration` + /// only fed while already `Collecting`, but only `feed_calibration` sets + /// `Collecting` — so the first frame was never fed and the count stayed 0. + #[test] + fn maybe_feed_calibration_advances_uncalibrated_to_collecting() { + let mut field = FieldModel::new(single_link_config()).expect("field model"); + assert_eq!(field.status(), CalibrationStatus::Uncalibrated); + assert_eq!(field.calibration_frame_count(), 0); + + // n_subcarriers defaults to 56; one single-link frame of that width. + let frame = vec![0.5_f64; 56]; + let mut history: VecDeque> = VecDeque::new(); + history.push_back(frame); + + maybe_feed_calibration(&mut field, &history); + + assert_eq!( + field.status(), + CalibrationStatus::Collecting, + "first frame must flip Uncalibrated -> Collecting" + ); + assert_eq!( + field.calibration_frame_count(), + 1, + "frame count must advance past 0" + ); + + // Subsequent frames keep accumulating while Collecting. + maybe_feed_calibration(&mut field, &history); + assert_eq!(field.calibration_frame_count(), 2); + } + + /// Regression (#1170 pattern): a real HT40 node streams 128-wide amplitude + /// frames, but the FieldModel is a 56-tone grid. Before canonicalization, + /// `feed_calibration` rejected every frame with DimensionMismatch (swallowed + /// at debug), so frame_count stayed 0 even with the deadlock fixed. The feed + /// must resample 128 → 56 and actually accumulate. + #[test] + fn maybe_feed_calibration_resamples_wide_frames_and_accumulates() { + let mut field = FieldModel::new(single_link_config()).expect("field model"); + + // 128-wide frame (HT40), NOT the model's 56 — would DimensionMismatch raw. + let wide = vec![0.5_f64; 128]; + let mut history: VecDeque> = VecDeque::new(); + history.push_back(wide); + + maybe_feed_calibration(&mut field, &history); + + assert_eq!( + field.status(), + CalibrationStatus::Collecting, + "128-wide frame must resample to 56 and be accepted" + ); + assert_eq!( + field.calibration_frame_count(), + 1, + "wide frame must accumulate, not be silently dropped" + ); + } } diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 120eaf5d..419c5b10 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -4991,6 +4991,20 @@ async fn calibration_start(State(state): State) -> Json) -> Json { let mut s = state.write().await; if let Some(ref mut fm) = s.field_model { + // Guard: finalizing before enough empty-room frames have accumulated + // is a client-side sequencing error, not a server fault. Return a + // clear, structured message (with progress) instead of a 500 so the + // caller knows to keep the room empty and poll /calibration/status. + let have = fm.calibration_frame_count(); + let need = fm.min_calibration_frames() as u64; + if have < need { + return Json(serde_json::json!({ + "success": false, + "error": "Not enough calibration frames yet — keep the room empty and poll /calibration/status until frame_count reaches the target.", + "frame_count": have, + "frames_needed": need, + })); + } let ts = chrono::Utc::now().timestamp_micros() as u64; match fm.finalize_calibration(ts, 0) { Ok(modes) => { @@ -5030,6 +5044,18 @@ async fn calibration_status(State(state): State) -> Json Json { + Json(serde_json::json!({ + "activities": [], + "total": 0, + "persisted": false, + "message": "Activity history is not persisted by the Rust sensing server.", + })) +} + /// Generate a simple timestamp string (epoch seconds) for recording IDs. fn chrono_timestamp() -> u64 { std::time::SystemTime::now() @@ -7768,6 +7794,13 @@ async fn main() { .route("/api/v1/pose/current", get(pose_current)) .route("/api/v1/pose/stats", get(pose_stats)) .route("/api/v1/pose/zones/summary", get(pose_zones_summary)) + .route("/api/v1/pose/activities", get(pose_activities)) + // Dashboard-compatible aliases for the field-model calibration API. + .route("/api/v1/pose/calibrate", post(calibration_start)) + .route( + "/api/v1/pose/calibration/status", + get(calibration_status), + ) // Stream endpoints .route("/api/v1/stream/status", get(stream_status)) .route("/api/v1/stream/pose", get(ws_pose_handler)) diff --git a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs index 2653fef1..210e6d0a 100644 --- a/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs +++ b/v2/crates/wifi-densepose-signal/src/ruvsense/field_model.rs @@ -449,6 +449,11 @@ impl FieldModel { .map_or(0, |ls| ls.observation_count()) } + /// Minimum frames required before `finalize_calibration` will succeed. + pub fn min_calibration_frames(&self) -> usize { + self.config.min_calibration_frames + } + /// Feed a calibration frame (one CSI observation per link during empty room). /// /// `observations` is `[n_links][n_subcarriers]` amplitude data.