Merge pull request #1355 from ruvnet/agent/open-issues-release

fix: open issue sweep and ESP32 v0.8.4 release
This commit is contained in:
rUv 2026-07-18 18:17:38 -04:00 committed by GitHub
commit c7b3f0bcc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 316 additions and 36 deletions

View File

@ -52,14 +52,16 @@ jobs:
target: esp32s3 target: esp32s3
sdkconfig: sdkconfig.defaults sdkconfig: sdkconfig.defaults
partition_table_name: partitions_display.csv 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_app: esp32-csi-node.bin
artifact_pt: partition-table.bin artifact_pt: partition-table.bin
- variant: 4mb - variant: 4mb
target: esp32s3 target: esp32s3
sdkconfig: sdkconfig.defaults.4mb sdkconfig: sdkconfig.defaults.4mb
partition_table_name: partitions_4mb.csv 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_app: esp32-csi-node-4mb.bin
artifact_pt: partition-table-4mb.bin artifact_pt: partition-table-4mb.bin
# ADR-110: ESP32-C6 research target (Wi-Fi 6 / 802.15.4 / TWT / LP-core) # ADR-110: ESP32-C6 research target (Wi-Fi 6 / 802.15.4 / TWT / LP-core)
@ -67,7 +69,8 @@ jobs:
target: esp32c6 target: esp32c6
sdkconfig: sdkconfig.defaults sdkconfig: sdkconfig.defaults
partition_table_name: partitions_4mb.csv 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_app: esp32-csi-node-c6.bin
artifact_pt: partition-table-c6.bin artifact_pt: partition-table-c6.bin
@ -96,18 +99,23 @@ jobs:
make test_adr110 make test_adr110
./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 working-directory: firmware/esp32-csi-node
run: | run: |
BIN=build/esp32-csi-node.bin BIN=build/esp32-csi-node.bin
SIZE=$(stat -c%s "$BIN") SIZE=$(stat -c%s "$BIN")
MAX=$((${{ matrix.size_limit_kb }} * 1024)) MAX=$((${{ matrix.size_limit_kb }} * 1024))
WARN=$((${{ matrix.size_warn_kb }} * 1024))
echo "Binary size: $SIZE bytes ($(( SIZE / 1024 )) KB)" 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)" echo "Size limit: $MAX bytes (${{ matrix.size_limit_kb }} KB)"
if [ "$SIZE" -gt "$MAX" ]; then if [ "$SIZE" -gt "$MAX" ]; then
echo "::error::Firmware binary exceeds ${{ matrix.size_limit_kb }} KB size gate ($SIZE > $MAX)" echo "::error::Firmware binary exceeds ${{ matrix.size_limit_kb }} KB size gate ($SIZE > $MAX)"
exit 1 exit 1
fi 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" echo "Binary size OK: $SIZE <= $MAX"
- name: Verify flash image integrity - name: Verify flash image integrity

View File

@ -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. - **`@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 ### 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 (10150 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<MultistaticConfig>` 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). - **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 (10150 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<MultistaticConfig>` 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. - **`/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 → 4045 pps). - **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 → 4045 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 O1O9): `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 O1O9): `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 `<jobsDir>/<id>.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 D1D4): 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. - **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 O1O9): `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 O1O9): `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 `<jobsDir>/<id>.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 D1D4): 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. - **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 40840× 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`. - **`csi_fps_ema` reported the CSI frame rate 40840× 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). - **`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).

View File

@ -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. **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) ### Build & Test Commands (this repo)
```bash ```bash
# Rust — full workspace tests (1,031+ tests, ~2 min) # Rust — full workspace tests (1,031+ tests, ~2 min)

View File

@ -2,6 +2,7 @@
Health check API endpoints Health check API endpoints
""" """
import asyncio
import logging import logging
import psutil import psutil
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
@ -168,7 +169,7 @@ async def health_check(request: Request):
overall_status = "degraded" overall_status = "degraded"
# Get system metrics # 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() uptime_seconds = (datetime.now() - _APP_START_TIME).total_seconds()
@ -263,11 +264,12 @@ async def get_health_metrics(
): ):
"""Get detailed system metrics.""" """Get detailed system metrics."""
try: try:
metrics = get_system_metrics() metrics = await asyncio.to_thread(get_system_metrics)
# Add additional metrics if authenticated # Add additional metrics if authenticated
if current_user: if current_user:
metrics.update(get_detailed_metrics()) detailed = await asyncio.to_thread(get_detailed_metrics)
metrics.update(detailed)
return { return {
"timestamp": datetime.utcnow().isoformat(), "timestamp": datetime.utcnow().isoformat(),
@ -300,7 +302,7 @@ def get_system_metrics() -> Dict[str, Any]:
"""Get basic system metrics.""" """Get basic system metrics."""
try: try:
# CPU metrics # CPU metrics
cpu_percent = psutil.cpu_percent(interval=1) cpu_percent = psutil.cpu_percent(interval=None)
cpu_count = psutil.cpu_count() cpu_count = psutil.cpu_count()
# Memory metrics # Memory metrics

View File

@ -180,21 +180,24 @@ class MetricsService:
async def _collect_system_metrics(self): async def _collect_system_metrics(self):
"""Collect system-level metrics.""" """Collect system-level metrics."""
try: try:
# CPU usage # Query OS metrics in a background thread to prevent blocking the event loop
cpu_percent = psutil.cpu_percent(interval=1) 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_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 disk_percent = (disk.used / disk.total) * 100
self._metrics["system_disk_usage"].add_point(disk_percent) 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_sent"].add_point(network.bytes_sent)
self._metrics["system_network_bytes_recv"].add_point(network.bytes_recv) self._metrics["system_network_bytes_recv"].add_point(network.bytes_recv)

View File

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

View File

@ -1861,6 +1861,23 @@ node scripts/eval-wiflow.js \
--data data/paired/*.jsonl --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 **Evaluation protocol matters.** Use `eval-wiflow.js` (torso-normalized
PCK@20, the metric comparable to published WiFi-pose results) on a temporal PCK@20, the metric comparable to published WiFi-pose results) on a temporal
hold-out, and sanity-check that predictions actually vary across frames hold-out, and sanity-check that predictions actually vary across frames

View File

@ -1,5 +1,5 @@
# ESP32 CSI Node Firmware (ADR-018) # ESP32 CSI Node Firmware (ADR-018)
# Requires ESP-IDF v5.2+ # Requires ESP-IDF v5.4+
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.16)
set(EXTRA_COMPONENT_DIRS "") set(EXTRA_COMPONENT_DIRS "")

View File

@ -113,6 +113,8 @@ curl http://<ESP32_IP>: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. > **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 ## Firmware Architecture

View File

@ -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) { if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
esp_wifi_connect(); esp_wifi_connect();
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { } 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) { if (s_retry_num < MAX_RETRY) {
esp_wifi_connect(); esp_wifi_connect();
s_retry_num++; s_retry_num++;
@ -102,7 +104,10 @@ static void wifi_init_sta(void)
wifi_config_t wifi_config = { wifi_config_t wifi_config = {
.sta = { .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,
}, },
}; };

View File

@ -1 +1 @@
0.7.0 0.8.4

View File

@ -18,6 +18,8 @@ Bring a RuView sensing node online: build firmware → flash → provision WiFi
**Not supported:** original ESP32, ESP32-C3 (single-core). **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) ## 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: 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:

View File

@ -57,12 +57,12 @@ def test_heart_rate_extract_per_frame_cost(benchmark) -> None:
hr = HeartRateExtractor.esp32_default() hr = HeartRateExtractor.esp32_default()
rng = Random(43) rng = Random(43)
for i in range(1500): for i in range(1500):
residuals, weights = _synth_frame(56, 100.0, i / 100.0, 1.2, rng) residuals, phases = _synth_frame(56, 100.0, i / 100.0, 1.2, rng)
hr.extract(residuals=residuals, weights=weights) hr.extract(residuals=residuals, phases=phases)
def _one_frame(): def _one_frame():
residuals, weights = _synth_frame(56, 100.0, 16.0, 1.2, rng) residuals, phases = _synth_frame(56, 100.0, 16.0, 1.2, rng)
return hr.extract(residuals=residuals, weights=weights) return hr.extract(residuals=residuals, phases=phases)
benchmark(_one_frame) benchmark(_one_frame)

View File

@ -242,7 +242,22 @@ impl PyBreathingExtractor {
// ─── HeartRateExtractor ────────────────────────────────────────────── // ─── HeartRateExtractor ──────────────────────────────────────────────
/// Extracts heart rate (40120 BPM) from per-subcarrier amplitude /// Extracts heart rate (40120 BPM) from per-subcarrier amplitude
/// residuals via 0.82.0 Hz bandpass + autocorrelation peak detection. /// residuals and per-subcarrier unwrapped phases (radians) via
/// 0.82.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")] #[pyclass(name = "HeartRateExtractor")]
pub struct PyHeartRateExtractor { pub struct PyHeartRateExtractor {
inner: HeartRateExtractor, inner: HeartRateExtractor,
@ -265,10 +280,17 @@ impl PyHeartRateExtractor {
Self { inner: HeartRateExtractor::esp32_default() } Self { inner: HeartRateExtractor::esp32_default() }
} }
/// Extract heart rate from per-subcarrier residuals. GIL released /// Extract heart rate from per-subcarrier residuals and matching
/// during DSP. /// per-subcarrier unwrapped phases (radians). Empty phases are invalid
fn extract(&mut self, py: Python<'_>, residuals: Vec<f64>, weights: Vec<f64>) -> Option<PyVitalEstimate> { /// and return `None` because the Rust extractor requires phase data.
let est = py.allow_threads(|| self.inner.extract(&residuals, &weights)); /// GIL released during DSP.
fn extract(
&mut self,
py: Python<'_>,
residuals: Vec<f64>,
phases: Vec<f64>,
) -> Option<PyVitalEstimate> {
let est = py.allow_threads(|| self.inner.extract(&residuals, &phases));
est.map(PyVitalEstimate::from_rust) est.map(PyVitalEstimate::from_rust)
} }

View File

@ -185,10 +185,44 @@ def test_heart_rate_explicit_ctor() -> None:
def test_heart_rate_extract_returns_none_with_too_few_samples() -> None: def test_heart_rate_extract_returns_none_with_too_few_samples() -> None:
hr = HeartRateExtractor.esp32_default() 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 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 ────────────────────────────────────────────── # ─── Build feature flag ──────────────────────────────────────────────

View File

@ -7,12 +7,22 @@
//! score-based heuristic in `score_to_person_count`. //! score-based heuristic in `score_to_person_count`.
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::LazyLock;
use wifi_densepose_signal::hardware_norm::HardwareNormalizer;
use wifi_densepose_signal::ruvsense::field_model::{ use wifi_densepose_signal::ruvsense::field_model::{
CalibrationStatus, FieldModel, FieldModelConfig, CalibrationStatus, FieldModel, FieldModelConfig,
}; };
use super::score_to_person_count; 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<HardwareNormalizer> = LazyLock::new(HardwareNormalizer::new);
/// Number of recent frames to feed into perturbation extraction. /// Number of recent frames to feed into perturbation extraction.
const OCCUPANCY_WINDOW: usize = 50; const OCCUPANCY_WINDOW: usize = 50;
@ -99,15 +109,27 @@ pub fn occupancy_or_fallback(
/// Feed the latest frame to the FieldModel during calibration collection. /// Feed the latest frame to the FieldModel during calibration collection.
/// ///
/// Only acts when the model status is `Collecting`. Wraps the latest frame /// Acts while the model is `Uncalibrated` or `Collecting`. The first fed frame
/// as a single-link observation (n_links=1) and feeds it. /// 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<Vec<f64>>) { pub fn maybe_feed_calibration(field: &mut FieldModel, frame_history: &VecDeque<Vec<f64>>) {
if field.status() != CalibrationStatus::Collecting { if !matches!(
field.status(),
CalibrationStatus::Uncalibrated | CalibrationStatus::Collecting
) {
return; return;
} }
if let Some(latest) = frame_history.back() { if let Some(latest) = frame_history.back() {
// Single-link observation: [1][n_subcarriers] // Resample the raw amplitude vector onto the FieldModel's canonical
let observations = vec![latest.clone()]; // 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) { if let Err(e) = field.feed_calibration(&observations) {
tracing::debug!("FieldModel calibration feed: {e}"); tracing::debug!("FieldModel calibration feed: {e}");
} }
@ -180,4 +202,65 @@ mod tests {
assert_eq!(positions.len(), 1); assert_eq!(positions.len(), 1);
assert_eq!(positions[0], [3.0, 4.0, 5.0]); 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<Vec<f64>> = 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<Vec<f64>> = 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"
);
}
} }

View File

@ -4991,6 +4991,20 @@ async fn calibration_start(State(state): State<SharedState>) -> Json<serde_json:
async fn calibration_stop(State(state): State<SharedState>) -> Json<serde_json::Value> { async fn calibration_stop(State(state): State<SharedState>) -> Json<serde_json::Value> {
let mut s = state.write().await; let mut s = state.write().await;
if let Some(ref mut fm) = s.field_model { 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; let ts = chrono::Utc::now().timestamp_micros() as u64;
match fm.finalize_calibration(ts, 0) { match fm.finalize_calibration(ts, 0) {
Ok(modes) => { Ok(modes) => {
@ -5030,6 +5044,18 @@ async fn calibration_status(State(state): State<SharedState>) -> Json<serde_json
} }
} }
/// Compatibility surface used by the bundled dashboard. Activity history is
/// not persisted by the Rust server yet, so return an honest empty collection
/// instead of advertising the endpoint and responding with 404.
async fn pose_activities() -> Json<serde_json::Value> {
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. /// Generate a simple timestamp string (epoch seconds) for recording IDs.
fn chrono_timestamp() -> u64 { fn chrono_timestamp() -> u64 {
std::time::SystemTime::now() std::time::SystemTime::now()
@ -7768,6 +7794,13 @@ async fn main() {
.route("/api/v1/pose/current", get(pose_current)) .route("/api/v1/pose/current", get(pose_current))
.route("/api/v1/pose/stats", get(pose_stats)) .route("/api/v1/pose/stats", get(pose_stats))
.route("/api/v1/pose/zones/summary", get(pose_zones_summary)) .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 // Stream endpoints
.route("/api/v1/stream/status", get(stream_status)) .route("/api/v1/stream/status", get(stream_status))
.route("/api/v1/stream/pose", get(ws_pose_handler)) .route("/api/v1/stream/pose", get(ws_pose_handler))

View File

@ -449,6 +449,11 @@ impl FieldModel {
.map_or(0, |ls| ls.observation_count()) .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). /// Feed a calibration frame (one CSI observation per link during empty room).
/// ///
/// `observations` is `[n_links][n_subcarriers]` amplitude data. /// `observations` is `[n_links][n_subcarriers]` amplitude data.