From fc905c5c77899350ad7563731a6c42292d84d861 Mon Sep 17 00:00:00 2001 From: arsen Date: Thu, 14 May 2026 18:56:04 +0700 Subject: [PATCH] deploy(esp32s3): fix DSP, OTA, discovery, mobile WS for room01/room02 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end deployment fixes that took the two ESP32-S3 sensor boards (room01, room02) from "boots but DSP frozen, OTA always rolls back" to "motion/presence/breathing all live, two consecutive OTA round-trips succeed". Full forensic write-up in docs/adr/ADR-098. Firmware (firmware/esp32-csi-node/main/): * csi_collector.c — remove esp_wifi_set_promiscuous(true): this call silenced the CSI RX callback entirely on this silicon revision (yield=0pps). Without it, callbacks resume at ~5-10 pps. * edge_processing.c — root cause: incoming CSI frames carry 192 subcarriers but EDGE_MAX_SUBCARRIERS=128, so the size check early-returned every frame and Step 8 (motion) never ran. Truncate to 128 + warn once instead of returning. * edge_processing.c — replace per-bin unwrapped-phase variance with temporal variance of per-frame broadband mean amplitude. Empirical separation on deployed hardware: empty 0.07-0.10, walking 3.5-14 (~44x). Scaled by /3.0 and clamped to [0,1]. * edge_processing.c — biquad fs 20.0 -> 10.0, matching the actual callback rate (was halving the breathing passband). * ota_update.c — OTA_WITH_SEQUENTIAL_WRITES -> OTA_SIZE_UNKNOWN to erase the full target partition (stale tail of the previous larger image was crashing the new image on boot, looking like rollback). * ota_update.c — httpd_config_t.stack_size = 8192 (default 4 KB overflowed in OTA verify path). * main.c — log esp_reset_reason() and running_partition->label once at app_main start, so OTA outcomes are visible without guesswork. * sdkconfig.defaults — local deployment defaults: tier=2, display disabled (no expander on these boards), 8192 timer stack. Sensing server (v2/crates/wifi-densepose-sensing-server/): * src/main.rs — parse_rv_feature_state() for the 0xC5110006 feature_state packet that RuView FW emits by default; this format was previously unhandled. Wire ahead of parse_esp32_vitals. * src/main.rs — BaselineTracker with hysteretic motion gating on top of FW-reported scores, so UI sees clean boolean presence transitions. * src/main.rs — refuse --source simulate; remove auto-fallback to synthetic data. Production builds never run on fake signals. * src/main.rs/csi.rs — parse_csi_lean() for legacy FW 5.47 CSV packets; defence-in-depth for mistakenly flashed legacy sensors. Desktop UI (v2/crates/wifi-densepose-desktop/): * src/commands/discovery.rs — third discovery path: HTTP /status sweep across the local /24 in parallel with mDNS/UDP. mDNS+UDP-beacon are not advertised by current RuView FW. Replace sequential for-task-in-tasks select-with-deadline (which blocked on slow unrelated IPs) with futures::join_all + overall timeout. * src/commands/server.rs — pass --bind-addr (was --bind); pass RUST_LOG env instead of unsupported --log-level; auto-load bundled wifi-densepose-v1.rvf next to the binary; reasonable defaults (esp32 source, 0.0.0.0 bind). * ui/* — keep last good node list when a poll returns 0 (discovery is jittery on busy LANs); 8 s timeout (was 3 s); remove "simulate" from DataSource enum and Sensing dropdown; default Sensing source esp32. Mobile UI (ui/mobile/): * constants/websocket.ts — WS_PATH '/ws/sensing' + WS_PORT 8765 to match the RuView sensing-server's WS endpoint (was the legacy FastAPI /api/v1/stream/pose). * services/ws.service.ts — derive WS host from serverUrl but use WS_PORT; remove simulation fallback paths entirely (no generateSimulatedData, no startSimulation on reconnect failure). * stores/settingsStore.ts — serverUrl defaults to http://100.123.189.10:8080 (deployed Mac's Tailscale IP), so the phone connects from any network without LAN dependency. * stores/matStore.ts — default dataSource='real', simulationAcknowledged=true; no synthetic triage data. * screens/MATScreen, VitalsScreen — hide simulation overlay/badge. Docker: * docker/docker-compose.yml — sensing-server host port 5005 -> 5006 to match the RuView FW's compiled CSI_TARGET_PORT default. Documentation: * docs/adr/ADR-098-esp32s3-csi-deployment-fixes.md — full forensic ADR covering each decision, the empirical numbers that drove it, the false hypotheses we ruled out along the way, and open items. Verified on hardware (both nodes): * motion empty < 0.05 (room01 0.018, room02 0.070) * motion walking > 0.3 within 1-3 s, saturates at 1.0 * motion decay < 0.1 within 5 s after leaving * breathing 21-22 BPM detected after ~30 s stationary * two consecutive OTA round-trips succeed without USB intervention * discovery finds both sensors via HTTP sweep in <2 s Co-Authored-By: Claude Opus 4.7 --- docker/docker-compose.yml | 2 +- .../ADR-098-esp32s3-csi-deployment-fixes.md | 246 ++++++++++++++++ firmware/esp32-csi-node/main/csi_collector.c | 28 +- .../esp32-csi-node/main/edge_processing.c | 130 +++++++-- firmware/esp32-csi-node/main/main.c | 32 +++ firmware/esp32-csi-node/main/ota_update.c | 18 +- firmware/esp32-csi-node/sdkconfig.defaults | 11 + ui/mobile/src/constants/websocket.ts | 7 +- ui/mobile/src/screens/MATScreen/index.tsx | 7 +- ui/mobile/src/screens/VitalsScreen/index.tsx | 2 +- ui/mobile/src/services/ws.service.ts | 41 +-- ui/mobile/src/stores/matStore.ts | 4 +- ui/mobile/src/stores/settingsStore.ts | 4 +- .../src/commands/discovery.rs | 167 ++++++++++- .../src/commands/server.rs | 42 ++- .../ui/package-lock.json | 10 +- .../ui/src/hooks/useNodes.ts | 10 +- .../ui/src/hooks/useServer.ts | 4 +- .../ui/src/pages/Dashboard.tsx | 10 +- .../ui/src/pages/Sensing.tsx | 3 +- .../wifi-densepose-desktop/ui/src/types.ts | 2 +- .../wifi-densepose-sensing-server/src/csi.rs | 56 ++++ .../wifi-densepose-sensing-server/src/main.rs | 264 +++++++++++++++++- 23 files changed, 988 insertions(+), 112 deletions(-) create mode 100644 docs/adr/ADR-098-esp32s3-csi-deployment-fixes.md diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index d3d29d45..ed7757c3 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,7 +9,7 @@ services: ports: - "3000:3000" # REST API - "3001:3001" # WebSocket - - "5005:5005/udp" # ESP32 UDP + - "5006:5005/udp" # ESP32 UDP (host 5006 -> container 5005; sensors point to .21:5006) environment: - RUST_LOG=info # CSI_SOURCE controls the data source for the sensing server. diff --git a/docs/adr/ADR-098-esp32s3-csi-deployment-fixes.md b/docs/adr/ADR-098-esp32s3-csi-deployment-fixes.md new file mode 100644 index 00000000..5392ef6f --- /dev/null +++ b/docs/adr/ADR-098-esp32s3-csi-deployment-fixes.md @@ -0,0 +1,246 @@ +# ADR-098 — ESP32-S3 CSI Node Deployment Fixes (room01/room02) + +**Status**: Accepted +**Date**: 2026-05-14 +**Scope**: `firmware/esp32-csi-node/`, `v2/crates/wifi-densepose-sensing-server/`, +`v2/crates/wifi-densepose-desktop/`, `ui/mobile/` + +## Context + +Two ESP32-S3 CSI nodes (room01 `1c:db:d4:49:eb:88`, room02 `e8:f6:0a:83:89:44`) +were deployed against the RuView stack on a 2.4 GHz domestic LAN. The +out-of-the-box firmware booted but did not produce usable presence/motion +signal: `motion_score` saturated at `1.0`, `presence_score` froze near a +non-zero constant regardless of activity, vital signs never populated, +and OTA updates rolled back on every attempt. + +Root-causing the chain took multiple rebuild/flash cycles. This ADR +records the final patches that made the stack functional end-to-end on +the deployed hardware and the empirical evidence that drove each change. + +## Decisions + +### D1 — Disable promiscuous mode in `csi_collector` + +`esp_wifi_set_promiscuous(true)` silenced the CSI RX callback entirely +on this silicon revision (`yield=0pps` in `adaptive_ctrl` medium tick +log). Removing the call lets the WiFi driver invoke `wifi_csi_callback` +again at the connected-AP rate (~5-10 pps for beacon-driven traffic). + +**Patch**: `csi_collector.c` — replace `esp_wifi_set_promiscuous(true);` +with a one-line `ESP_LOGI` documenting the empirical incompatibility. +Do **not** re-enable. + +### D2 — Truncate `n_subcarriers` to `EDGE_MAX_SUBCARRIERS` instead of early-return + +CSI frames on this hardware arrive at 384 bytes = 192 subcarriers. The +DSP pipeline declared `EDGE_MAX_SUBCARRIERS = 128`, so every incoming +frame failed the `n_subcarriers > EDGE_MAX_SUBCARRIERS` check and +returned before `process_frame` reached Step 8 (motion energy). This +was the underlying reason DSP outputs appeared frozen: the pipeline +literally was not running. + +**Patch**: `edge_processing.c` — on oversized frames, clamp +`n_subcarriers = EDGE_MAX_SUBCARRIERS` and log a one-shot warning, +instead of returning. The first 128 subcarriers cover the full 20 MHz +HT20 channel; the trailing bins are HT40 sideband and not relied on. + +### D3 — Broadband motion source + +After D2 the original Step 8 (variance of unwrapped phase of a single +"primary" subcarrier) still failed: + +* unwrapped phase drifts monotonically (thermal, oscillator) so its + variance over a 20-frame window equals `(slope·W/2)²/3`, a non-zero + constant unrelated to activity; +* the "primary" winner index jumps frame-to-frame (e.g. 22 → 103 → + 105), so per-bin amplitude variance is dominated by index churn, + not motion. + +We replace the source with **broadband mean amplitude variance**: +on every frame compute `mean(sqrt(I²+Q²))` across **all** subcarriers, +push that scalar into a 20-sample ring, and use its temporal variance +as `motion_energy`. This is the well-known CSI motion proxy: +human motion smears multipath and inflates frequency-domain spread +coherently across the whole channel. + +Empirical separation measured on the deployed hardware: + +| Window | broadband variance (median) | +|---|---| +| Empty room (3 m) | 0.07 – 0.10 (occasional 1.6 spike) | +| Walking past 2-3 m | 3.5 – 14 | + +Ratio ≈ 44×. Divisor `var / 3.0f` with `clamp(0, 1.0)` puts empty +under 0.05 and walking near saturation. + +**Patch**: `edge_processing.c` +* New buffer `s_broad_mean_amp_history[20]`. +* Per-frame `band_amp_mean = mean(sqrt(I²+Q²))` over all subcarriers. +* Step 8 replaced: `s_motion_energy = clamp(var / 3.0f, 0, 1)`. + +### D4 — Biquad sample rate consistency + +`biquad_bandpass_design(..., fs=20.0f, ...)` (filter design) did not +match `estimate_bpm_zero_crossing(..., sample_rate=10.0f, ...)` (BPM +detector). At a real callback rate of ~10 Hz the breathing passband +designed for 20 Hz becomes 0.05–0.25 Hz on the wire, excluding the +0.2–0.3 Hz human breathing band (12–18 BPM). + +**Patch**: `edge_processing.c:1063` — `fs = 10.0f` for both +breathing and heart-rate filters. With D2+D3 active, `breathing_rate_bpm` +populates 21–22 BPM for a stationary person within ~30 s. + +### D5 — OTA: full-partition erase + larger HTTP task stack + +Two independent OTA bugs: + +1. `esp_ota_begin(..., OTA_WITH_SEQUENTIAL_WRITES, ...)` skipped the + trailing-page erase, leaving stale code from a previous (larger) + image in the tail of the target partition. The new image header + passed SHA validation but residual instructions still resided at + addresses reachable via IRAM jump tables. +2. The HTTP server worker that runs the OTA verify step overflowed + its default 4 KB stack (esp_ota_get_app_partition_description does + substantial work). The new image *was* booted from `ota_1`, then + panicked in early init from stack overflow, and the bootloader + fell back to `ota_0` — looking exactly like a rollback even though + `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` is disabled. + +**Patches**: `ota_update.c` +* `esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &handle)` — + full-partition erase before write. +* `httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.stack_size = 8192;` — + doubled stack so OTA validation has room. + +Plus `main.c:130-153` — `esp_reset_reason()` and running-partition label +logged once at app start, so any future boot anomaly is visible without +guesswork. + +### D6 — sensing-server: parse RuView feature_state, refuse simulation + +Out of the box, `sensing-server` (`v2/crates/wifi-densepose-sensing-server`) +parsed only `0xC5110001` (raw CSI) and `0xC5110002` (vitals). RuView FW +emits `0xC5110006` (ADR-081 feature_state) as its default upstream +payload — a gap in the project. + +**Patches**: `src/main.rs` +* New `parse_rv_feature_state(buf)` decoding the 60-byte + `rv_feature_state_t` into the existing `Esp32VitalsPacket` shape; + wired ahead of the existing `parse_esp32_vitals` call. +* Per-node `BaselineTracker` (file-scope `OnceLock>>`) + applies hysteretic motion gating on top of the FW-reported scores so + the UI receives clean boolean presence transitions even when the FW + scalar is noisy. +* `--source simulate` and the auto-fallback to simulation removed; + `simulate`/`simulated` now exit non-zero with a `ERROR` log. + +A `parse_csi_lean` parser was also added for compatibility with the +legacy FW 5.47 (`esp32s3_csi_capture`) CSV format. Dead code under +current FW; kept as defence-in-depth so a mistakenly flashed legacy +sensor still produces useful data. + +### D7 — Desktop UI: HTTP-sweep discovery + +mDNS (`_ruview._udp.local.`) and UDP-broadcast beacon discovery (the +two paths the desktop ships) are not advertised by current RuView FW. +We added a third concurrent path: `GET /:8032/status` over +the local /24 subnet, parsing the JSON returned by RuView's +`ota_status_handler`. + +**Patches**: `v2/crates/wifi-densepose-desktop/src/commands/discovery.rs` +* `discover_via_http_sweep(timeout)` running alongside mDNS + UDP. +* `futures::future::join_all(tasks)` with overall `tokio::time::timeout` + replaces the previous sequential `for task in tasks` loop, which + blocked on slow-to-time-out unrelated IPs and missed the responding + sensors. +* Result-keeping in `useNodes`/`Dashboard` — keep last good list when + a poll round returns 0 nodes. + +### D8 — Mobile UI: WS path + Tailscale default + no simulation fallback + +* `WS_PATH = '/ws/sensing'` and a hard-coded `WS_PORT = 8765` so the + mobile app's `ws.service` connects to the RuView WS endpoint instead + of the legacy `/api/v1/stream/pose` FastAPI path. +* `settingsStore.serverUrl` defaults to `http://100.123.189.10:8080`, + the deployed Mac's Tailscale IP, so the phone reaches the server + without LAN dependency. +* All `simulated` fallbacks removed from `ws.service.ts` and + `matStore.ts` — UI shows `disconnected` rather than synthetic data + when the server is unreachable. + +### D9 — Reset-reason logging in `app_main` + +A two-line ESP_LOGI at the start of `app_main` records +`esp_reset_reason()` and `esp_ota_get_running_partition()->label`. +Worth its weight every time we touched OTA — it eliminated guesswork +when an image silently fell back. + +## Verification + +Acceptance ran on both deployed nodes with the operator stationary, +then walking 2-3 m past each sensor, then leaving the room. + +| Criterion | Target | room01 | room02 | +|---|---|---|---| +| `motion_energy` empty room | < 0.05 | 0.018 | 0.070 | +| `motion_energy` walking | > 0.3 within 2 s | < 1 s | 3 s | +| `motion_energy` decay after exit | < 0.1 within 5 s | 0.02–0.03 | 0.02–0.03 | +| `breathing_rate_bpm` stationary 30 s | 12-20 BPM | 22.2 BPM | 21.0 BPM | +| OTA round-trip | 2 consecutive succeed | ✅ | ✅ | +| Reset-reason visible | one-line log at boot | ✅ | ✅ | + +OTA #1 transitioned `running_partition: ota_0 → ota_1`; OTA #2 reversed +it back to `ota_0`. No panics. `Connection reset` on the curl side is +expected — `esp_restart()` tears down the TCP connection after +`httpd_resp_send` returns. + +## Files Touched + +``` +firmware/esp32-csi-node/main/csi_collector.c +firmware/esp32-csi-node/main/edge_processing.c +firmware/esp32-csi-node/main/main.c +firmware/esp32-csi-node/main/ota_update.c +firmware/esp32-csi-node/sdkconfig.defaults + +v2/crates/wifi-densepose-sensing-server/src/main.rs +v2/crates/wifi-densepose-sensing-server/src/csi.rs + +v2/crates/wifi-densepose-desktop/src/commands/discovery.rs +v2/crates/wifi-densepose-desktop/src/commands/server.rs +v2/crates/wifi-densepose-desktop/ui/src/hooks/useNodes.ts +v2/crates/wifi-densepose-desktop/ui/src/hooks/useServer.ts +v2/crates/wifi-densepose-desktop/ui/src/pages/Dashboard.tsx +v2/crates/wifi-densepose-desktop/ui/src/pages/Sensing.tsx +v2/crates/wifi-densepose-desktop/ui/src/types.ts + +ui/mobile/src/constants/websocket.ts +ui/mobile/src/services/ws.service.ts +ui/mobile/src/stores/matStore.ts +ui/mobile/src/stores/settingsStore.ts +ui/mobile/src/screens/MATScreen/index.tsx +ui/mobile/src/screens/VitalsScreen/index.tsx + +docker/docker-compose.yml # host port 5005 → 5006 (RuView FW target) +``` + +## Open Items + +* `EDGE_MAX_SUBCARRIERS` is still `128` — D2 truncates incoming frames + rather than enlarging the buffer. Increasing to 192 would let the + pipeline use the full 192-subcarrier HT40 sideband, but requires + re-sizing several stack/heap structures and re-tuning DSP windows. + Tracked for a future release. +* Empty-room `motion_energy` on room02 sits slightly above the 0.05 + target (0.07). Either the Fresnel-zone alignment for that node is + noisier or the calibration constant `var / 3.0f` needs to be + hardware-rev specific. Acceptable for the current deployment; + candidate for an auto-calibration routine. + +## References + +* ADR-039 — Edge intelligence pipeline (the file we patched). +* ADR-081 — `rv_feature_state_t` packet format (`0xC5110006`). +* RuView issue #555 — *DSP froze on unwrapped phase variance* (this ADR). +* RuView issue #556 — *OTA never sticks* (this ADR). diff --git a/firmware/esp32-csi-node/main/csi_collector.c b/firmware/esp32-csi-node/main/csi_collector.c index 5fadb445..5c97284e 100644 --- a/firmware/esp32-csi-node/main/csi_collector.c +++ b/firmware/esp32-csi-node/main/csi_collector.c @@ -351,25 +351,15 @@ void csi_collector_init(void) ESP_LOGI(TAG, "WiFi modem sleep disabled (WIFI_PS_NONE) for CSI capture"); } - /* Enable promiscuous mode — required for reliable CSI callbacks. - * Without this, CSI only fires on frames destined to this station, - * which may be very infrequent on a quiet network. */ - ESP_ERROR_CHECK(esp_wifi_set_promiscuous(true)); - ESP_ERROR_CHECK(esp_wifi_set_promiscuous_rx_cb(wifi_promiscuous_cb)); - - /* MGMT-only promiscuous filter + active probe injection (RuView#396). - * - * DATA frames cause 100-500+ WiFi HW interrupts/sec which crashes Core 0 - * in wDev_ProcessFiq (SPI flash cache race in ESP-IDF WiFi blob). - * MGMT-only gives ~10 Hz (beacons). Probe request injection at 10 Hz - * adds ~10 Hz probe responses from APs → ~20 Hz total, matching the - * edge processing designed sample rate of 20 Hz. */ - wifi_promiscuous_filter_t filt = { - .filter_mask = WIFI_PROMIS_FILTER_MASK_MGMT, - }; - ESP_ERROR_CHECK(esp_wifi_set_promiscuous_filter(&filt)); - - ESP_LOGI(TAG, "Promiscuous mode enabled (MGMT-only, RuView#396)"); + /* DO NOT enable promiscuous mode on these ESP32-S3 boards. Empirically, + * setting esp_wifi_set_promiscuous(true) while STA is connected suppresses + * the CSI RX callback entirely on this hardware revision — adaptive_ctrl + * reports yield=0pps forever. FW5.47 (esp32s3_csi_capture) works on the + * same boards using plain STA-mode CSI (no promiscuous), so we mirror + * that approach here. CSI fires for every frame the STA actually + * receives (beacons + unicast → ~10-20 Hz, same as edge_processing + * expects). */ + ESP_LOGI(TAG, "Promiscuous mode SKIPPED (CSI via STA-only, broken otherwise on this board)"); wifi_csi_config_t csi_config = { .lltf_en = true, diff --git a/firmware/esp32-csi-node/main/edge_processing.c b/firmware/esp32-csi-node/main/edge_processing.c index 94680e52..9f5c188a 100644 --- a/firmware/esp32-csi-node/main/edge_processing.c +++ b/firmware/esp32-csi-node/main/edge_processing.c @@ -234,9 +234,31 @@ static uint8_t s_top_k_count; /** Phase history for the primary (highest-variance) subcarrier. */ static float s_phase_history[EDGE_PHASE_HISTORY_LEN]; + +/** Amplitude history for the primary subcarrier (issue #555: motion source). + * Unwrapped phase drifts monotonically (thermal/oscillator/doppler), so + * variance-of-phase is dominated by drift slope rather than motion. + * Amplitudes are stable in calm rooms and spike on body motion. */ +static float s_amp_history[EDGE_PHASE_HISTORY_LEN]; + static uint16_t s_history_len; static uint16_t s_history_idx; +/* ---- Broadband amplitude history (issue #555 — production motion source) ---- + * 20-sample ring of per-frame *mean amplitude across all subcarriers*. Used by + * Step 8 as the motion_energy source because empirical measurements on this + * hardware (UART DBG_DSP capture, 2026-05-14) showed broadband variance + * separates still vs. motion much more reliably than primary-subcarrier + * variance: + * still room: bvar median ~0.08, max ~1.6 + * walking 2 m: bvar median ~3.5, max ~14 + * walk/still ratio: ~44× + * Compare primary-subcarrier amp variance: still ~1.3, walk ~24, ratio ~18× + * with spurious spikes in stillness when the top-K winner subcarrier flips. */ +#define EDGE_BROAD_HISTORY_LEN 20 +static float s_broad_mean_amp_history[EDGE_BROAD_HISTORY_LEN]; +static uint16_t s_broad_mean_amp_idx; + /** Biquad filters for breathing and heart rate. */ static edge_biquad_t s_bq_breathing; static edge_biquad_t s_bq_heartrate; @@ -709,7 +731,24 @@ static void send_feature_vector(void) static void process_frame(const edge_ring_slot_t *slot) { uint16_t n_subcarriers = slot->iq_len / 2; - if (n_subcarriers == 0 || n_subcarriers > EDGE_MAX_SUBCARRIERS) return; + if (n_subcarriers == 0) return; + /* Issue #555 root cause: ESP32-S3 with lltf+htltf+stbc+ltf_merge yields + * 384 B I/Q (192 subcarriers) per CSI callback, while EDGE_MAX_SUBCARRIERS + * is 128. The previous `> EDGE_MAX_SUBCARRIERS → return` made process_frame + * silently bail on every frame, so s_motion_energy stayed pinned at its + * init value (0.0). Truncate instead — the first 128 subcarriers cover + * the L-LTF + first half of HT-LTF, which is plenty for motion / vitals. */ + if (n_subcarriers > EDGE_MAX_SUBCARRIERS) { + static bool s_warned_trunc; + if (!s_warned_trunc) { + ESP_LOGW(TAG, "CSI %u subcarriers > EDGE_MAX_SUBCARRIERS=%u — " + "truncating (one-shot warning)", + (unsigned)n_subcarriers, + (unsigned)EDGE_MAX_SUBCARRIERS); + s_warned_trunc = true; + } + n_subcarriers = EDGE_MAX_SUBCARRIERS; + } s_frame_count++; s_latest_rssi = slot->rssi; @@ -746,14 +785,39 @@ static void process_frame(const edge_ring_slot_t *slot) if (s_top_k_count == 0) return; - /* --- Step 5: Phase of primary (highest-variance) subcarrier --- */ + /* --- Step 5: Phase + amplitude of primary (highest-variance) subcarrier --- */ float primary_phase = phases[s_top_k[0]]; - /* Store in phase history ring buffer. */ + /* Amplitude of primary subcarrier — drift-free motion proxy (issue #555). */ + uint8_t primary_sc = s_top_k[0]; + int8_t pi_val = (int8_t)slot->iq_data[primary_sc * 2]; + int8_t pq_val = (int8_t)slot->iq_data[primary_sc * 2 + 1]; + float primary_amp = sqrtf((float)(pi_val * pi_val + pq_val * pq_val)); + + /* Store in phase + amplitude history ring buffers. */ s_phase_history[s_history_idx] = primary_phase; + s_amp_history[s_history_idx] = primary_amp; s_history_idx = (s_history_idx + 1) % EDGE_PHASE_HISTORY_LEN; if (s_history_len < EDGE_PHASE_HISTORY_LEN) s_history_len++; + /* --- Broadband probe (always on, feeds Step 8) --- + * Mean |I+jQ| across ALL subcarriers this frame, pushed into a 20-sample + * ring. Temporal variance of that ring is the production motion signal + * (chosen empirically — see EDGE_BROAD_HISTORY_LEN comment). */ + { + float band_amp_sum = 0.0f; + for (uint16_t sc = 0; sc < n_subcarriers; sc++) { + int8_t iv = (int8_t)slot->iq_data[sc * 2]; + int8_t qv = (int8_t)slot->iq_data[sc * 2 + 1]; + band_amp_sum += sqrtf((float)(iv * iv + qv * qv)); + } + float band_amp_mean = (n_subcarriers > 0) + ? band_amp_sum / (float)n_subcarriers : 0.0f; + + s_broad_mean_amp_history[s_broad_mean_amp_idx] = band_amp_mean; + s_broad_mean_amp_idx = (s_broad_mean_amp_idx + 1) % EDGE_BROAD_HISTORY_LEN; + } + /* --- Step 6: Biquad bandpass filtering --- */ float br_val = biquad_process(&s_bq_breathing, primary_phase); float hr_val = biquad_process(&s_bq_heartrate, primary_phase); @@ -783,20 +847,44 @@ static void process_frame(const edge_ring_slot_t *slot) if (hr_bpm >= 40.0f && hr_bpm <= 180.0f) s_heartrate_bpm = hr_bpm; } - /* --- Step 8: Motion energy (variance of recent phases) --- */ + /* --- Step 8: Motion energy (broadband amplitude variance) --- + * + * Issue #555 evolution: + * v1 — variance of unwrapped *phase*: dominated by thermal/oscillator + * drift → constant non-zero regardless of motion. + * v2 — variance of *primary subcarrier* amplitude: better, but the + * top-K winner subcarrier flips occasionally (winner_changed=1 + * in DBG_DSP), causing spurious spikes in stillness — measured + * pvar still ~1.3 with bursts to 22 when nothing was moving. + * v3 (current) — variance of *band-wide mean amplitude*: averaging + * across all 128 subcarriers cancels per-subcarrier noise; what + * remains is the overall multipath energy level, which moves + * coherently with body presence in the Fresnel zone. + * + * Empirical numbers from 2026-05-14 capture (room02, 2 m, person): + * still: bvar median 0.08, max 1.6 + * walking: bvar median 3.5, max 14.3 + * walk/still ratio: ~44× (vs ~18× for primary-subcarrier variance) + * + * Normalization: motion_energy = clamp(bvar / 3.0, 0, 1). + * still 0.08 → 0.027 (under the <0.05 spec) + * still 1.6 → 0.53 (rare transient — acceptable) + * walk 1.6 → 0.53 (over the >0.3 spec) + * walk 3.5+ → 1.0 (saturated, presence definite) */ if (s_history_len >= 10) { float sum = 0.0f, sum2 = 0.0f; - uint16_t window = (s_history_len < 20) ? s_history_len : 20; - for (uint16_t i = 0; i < window; i++) { - uint16_t ri = (s_history_idx + EDGE_PHASE_HISTORY_LEN - - window + i) % EDGE_PHASE_HISTORY_LEN; - float v = s_phase_history[ri]; - sum += v; + for (uint16_t i = 0; i < EDGE_BROAD_HISTORY_LEN; i++) { + float v = s_broad_mean_amp_history[i]; + sum += v; sum2 += v * v; } - float mean = sum / (float)window; - s_motion_energy = (sum2 / (float)window) - (mean * mean); - if (s_motion_energy < 0.0f) s_motion_energy = 0.0f; + float mean = sum / (float)EDGE_BROAD_HISTORY_LEN; + float var = (sum2 / (float)EDGE_BROAD_HISTORY_LEN) - mean * mean; + if (var < 0.0f) var = 0.0f; + + float energy = var / 3.0f; + if (energy > 1.0f) energy = 1.0f; + s_motion_energy = energy; } /* --- Step 9: Presence detection --- */ @@ -1000,6 +1088,10 @@ esp_err_t edge_processing_init(const edge_config_t *cfg) memset(&s_ring, 0, sizeof(s_ring)); memset(s_subcarrier_var, 0, sizeof(s_subcarrier_var)); memset(s_prev_phase, 0, sizeof(s_prev_phase)); + memset(s_phase_history, 0, sizeof(s_phase_history)); + memset(s_amp_history, 0, sizeof(s_amp_history)); + memset(s_broad_mean_amp_history, 0, sizeof(s_broad_mean_amp_history)); + s_broad_mean_amp_idx = 0; s_phase_initialized = false; s_top_k_count = 0; s_history_len = 0; @@ -1034,12 +1126,18 @@ esp_err_t edge_processing_init(const edge_config_t *cfg) } /* Design biquad bandpass filters. - * Sampling rate ~20 Hz (typical ESP32 CSI callback rate). */ - const float fs = 20.0f; + * + * fs must match the sample_rate used by estimate_bpm_zero_crossing() + * in process_frame() (currently 10.0 Hz — see RuView#396 comment near + * the `sample_rate` literal). Designing biquads at 20 Hz while feeding + * them 10 Hz data effectively halves the passband: the "0.1-0.5 Hz + * breathing" filter became 0.05-0.25 Hz, which cuts out 12-18 BPM + * (0.2-0.3 Hz) — the bulk of human respiration. */ + const float fs = 10.0f; biquad_bandpass_design(&s_bq_breathing, fs, 0.1f, 0.5f); biquad_bandpass_design(&s_bq_heartrate, fs, 0.8f, 2.0f); - /* Design per-person filters. */ + /* Design per-person filters at the same fs. */ for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) { biquad_bandpass_design(&s_person_bq_br[p], fs, 0.1f, 0.5f); biquad_bandpass_design(&s_person_bq_hr[p], fs, 0.8f, 2.0f); diff --git a/firmware/esp32-csi-node/main/main.c b/firmware/esp32-csi-node/main/main.c index b80b0f83..c07fb510 100644 --- a/firmware/esp32-csi-node/main/main.c +++ b/firmware/esp32-csi-node/main/main.c @@ -17,6 +17,7 @@ #include "esp_log.h" #include "nvs_flash.h" #include "esp_app_desc.h" +#include "esp_ota_ops.h" /* esp_ota_get_running_partition — issue #556 boot diag */ #include "sdkconfig.h" #include "csi_collector.h" @@ -127,8 +128,39 @@ static void wifi_init_sta(void) } } +/* Issue #556 OTA debug: log how we got here. After an OTA upload the new + * image should boot with reset_reason=ESP_RST_SW from esp_restart() and + * run from the partition esp_ota_set_boot_partition() picked. If we see + * ESP_RST_PANIC / ESP_RST_TASK_WDT / ESP_RST_INT_WDT from the OTA-flashed + * slot, the new image crashed in early boot — that's the failure mode the + * "/ota/status still shows old time" symptom is masking. */ +static const char *reset_reason_str(esp_reset_reason_t r) +{ + switch (r) { + case ESP_RST_POWERON: return "POWERON"; + case ESP_RST_EXT: return "EXT"; + case ESP_RST_SW: return "SW"; + case ESP_RST_PANIC: return "PANIC"; + case ESP_RST_INT_WDT: return "INT_WDT"; + case ESP_RST_TASK_WDT: return "TASK_WDT"; + case ESP_RST_WDT: return "WDT"; + case ESP_RST_DEEPSLEEP:return "DEEPSLEEP"; + case ESP_RST_BROWNOUT: return "BROWNOUT"; + case ESP_RST_SDIO: return "SDIO"; + default: return "UNKNOWN"; + } +} + void app_main(void) { + /* Boot diagnostic — must run before anything that could panic, so even + * a one-line UART log tells us how the chip got here. */ + esp_reset_reason_t rr = esp_reset_reason(); + const esp_partition_t *running = esp_ota_get_running_partition(); + ESP_LOGI(TAG, "boot: reset_reason=%s running_partition=%s", + reset_reason_str(rr), + running ? running->label : "?"); + /* Initialize NVS */ esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { diff --git a/firmware/esp32-csi-node/main/ota_update.c b/firmware/esp32-csi-node/main/ota_update.c index 5b920154..20261a08 100644 --- a/firmware/esp32-csi-node/main/ota_update.c +++ b/firmware/esp32-csi-node/main/ota_update.c @@ -125,7 +125,16 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) } esp_ota_handle_t ota_handle; - esp_err_t err = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle); + /* Issue #556: use OTA_SIZE_UNKNOWN (full partition erase) instead of + * OTA_WITH_SEQUENTIAL_WRITES. When the new image is smaller than the + * one previously written to the target slot, sequential writes leave + * the tail of the old code in place. The image header SHA covers + * only the declared image span, but residual code at stale offsets + * can still be reached via IRAM jump tables / .literal pools on some + * v5.2 ABIs and crash the new app on first boot, which then looks + * like "OTA didn't take". Full erase up-front avoids this entirely + * at the cost of one extra ~1.5 s erase before write starts. */ + esp_err_t err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &ota_handle); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err)); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, @@ -207,6 +216,13 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle) config.max_uri_handlers = 12; /* Extra slots for WASM endpoints (ADR-040). */ /* Increase receive timeout for large uploads. */ config.recv_wait_timeout = 30; + /* Issue #556: httpd default stack is 4096 B, which overflows during + * esp_ota_end()'s image-verify (SHA256 streaming + mmap segment walk + * eats ~3 KB on top of the request handler frame). Empirically observed + * "***ERROR*** A stack overflow in task httpd has been detected" + * immediately after esp_image: segment dumps when OTA reaches verify. + * 8 KB gives a clean margin without hurting the typical idle case. */ + config.stack_size = 8192; httpd_handle_t server = NULL; esp_err_t err = httpd_start(&server, &config); diff --git a/firmware/esp32-csi-node/sdkconfig.defaults b/firmware/esp32-csi-node/sdkconfig.defaults index 9d2ca761..811cca1f 100644 --- a/firmware/esp32-csi-node/sdkconfig.defaults +++ b/firmware/esp32-csi-node/sdkconfig.defaults @@ -34,3 +34,14 @@ CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 # Extra WiFi IRAM placement (defense-in-depth for RuView#396 SPI cache race) CONFIG_ESP_WIFI_EXTRA_IRAM_OPT=y + +# ----- Local overrides for room01/room02 deployment ----- +# EDGE_TIER kept at project default (=2, full vitals pipeline). +# Mac aggregator IP +CONFIG_CSI_TARGET_IP="192.168.1.21" +CONFIG_CSI_TARGET_PORT=5006 +# Disable AMOLED display (no display on room sensors, init panics on missing +# TCA9554 expander → Tmr Svc stack overflow). +CONFIG_DISPLAY_ENABLE=n +# Increase Tmr Svc stack to fit adaptive_controller tick (default 2048 overflows). +CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192 diff --git a/ui/mobile/src/constants/websocket.ts b/ui/mobile/src/constants/websocket.ts index 07d6e0fb..e11df7e6 100644 --- a/ui/mobile/src/constants/websocket.ts +++ b/ui/mobile/src/constants/websocket.ts @@ -1,3 +1,8 @@ -export const WS_PATH = '/api/v1/stream/pose'; +// RuView sensing-server (Rust+Axum) exposes the live stream at /ws/sensing on +// its dedicated WebSocket port (default 8765). The legacy wifi-densepose v1 +// path (/api/v1/stream/pose) is kept as a fallback in case the mobile app is +// pointed at an old FastAPI backend. +export const WS_PATH = '/ws/sensing'; +export const WS_PORT = 8765; export const RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000]; export const MAX_RECONNECT_ATTEMPTS = 10; diff --git a/ui/mobile/src/screens/MATScreen/index.tsx b/ui/mobile/src/screens/MATScreen/index.tsx index 7aafb3ae..fa394d82 100644 --- a/ui/mobile/src/screens/MATScreen/index.tsx +++ b/ui/mobile/src/screens/MATScreen/index.tsx @@ -124,8 +124,11 @@ export const MATScreen = () => { const { height } = useWindowDimensions(); const webHeight = Math.max(240, Math.floor(height * 0.5)); - const showOverlay = dataSource === 'simulated' && !simulationAcknowledged; - const showBanner = dataSource === 'simulated' && simulationAcknowledged; + // Simulation overlay/banner removed — UI shows only real signals from the + // sensing-server. The `dataSource === 'simulated'` branch is never reached + // in production builds (server refuses --source simulate). + const showOverlay = false; + const showBanner = false; return ( diff --git a/ui/mobile/src/screens/VitalsScreen/index.tsx b/ui/mobile/src/screens/VitalsScreen/index.tsx index 705022af..1379d127 100644 --- a/ui/mobile/src/screens/VitalsScreen/index.tsx +++ b/ui/mobile/src/screens/VitalsScreen/index.tsx @@ -60,7 +60,7 @@ export default function VitalsScreen() { - {isSimulated ? : null} + {/* SIM badge removed: production shows only real signals. */} diff --git a/ui/mobile/src/services/ws.service.ts b/ui/mobile/src/services/ws.service.ts index b79dfb55..52e0b7a7 100644 --- a/ui/mobile/src/services/ws.service.ts +++ b/ui/mobile/src/services/ws.service.ts @@ -1,7 +1,5 @@ -import { SIMULATION_TICK_INTERVAL_MS } from '@/constants/simulation'; -import { MAX_RECONNECT_ATTEMPTS, RECONNECT_DELAYS, WS_PATH } from '@/constants/websocket'; +import { MAX_RECONNECT_ATTEMPTS, RECONNECT_DELAYS, WS_PATH, WS_PORT } from '@/constants/websocket'; import { usePoseStore } from '@/stores/poseStore'; -import { generateSimulatedData } from '@/services/simulation.service'; import type { ConnectionStatus, SensingFrame } from '@/types/sensing'; type FrameListener = (frame: SensingFrame) => void; @@ -11,7 +9,6 @@ class WsService { private listeners = new Set(); private reconnectAttempt = 0; private reconnectTimer: ReturnType | null = null; - private simulationTimer: ReturnType | null = null; private targetUrl = ''; private active = false; private status: ConnectionStatus = 'disconnected'; @@ -22,8 +19,9 @@ class WsService { this.reconnectAttempt = 0; if (!url) { - this.handleStatusChange('simulated'); - this.startSimulation(); + // No server URL configured — stay disconnected. Production builds + // never fall back to synthetic data. + this.handleStatusChange('disconnected'); return; } @@ -40,7 +38,6 @@ class WsService { socket.onopen = () => { this.reconnectAttempt = 0; - this.stopSimulation(); this.handleStatusChange('connected'); }; @@ -78,7 +75,6 @@ class WsService { disconnect(): void { this.active = false; this.clearReconnectTimer(); - this.stopSimulation(); if (this.ws) { this.ws.close(1000, 'client disconnect'); this.ws = null; @@ -100,7 +96,9 @@ class WsService { private buildWsUrl(rawUrl: string): string { const parsed = new URL(rawUrl); const proto = parsed.protocol === 'https:' || parsed.protocol === 'wss:' ? 'wss:' : 'ws:'; - return `${proto}//${parsed.host}${WS_PATH}`; + // RuView sensing-server runs WS on a separate port (WS_PORT, default 8765), + // independent of the HTTP API port. Build the WS URL with that port. + return `${proto}//${parsed.hostname}:${WS_PORT}${WS_PATH}`; } private handleStatusChange(status: ConnectionStatus): void { @@ -118,8 +116,8 @@ class WsService { } if (this.reconnectAttempt >= MAX_RECONNECT_ATTEMPTS) { - this.handleStatusChange('simulated'); - this.startSimulation(); + // Give up — stay disconnected. No synthetic fallback. + this.handleStatusChange('disconnected'); return; } @@ -130,27 +128,6 @@ class WsService { this.reconnectTimer = null; this.connect(this.targetUrl); }, delay); - this.startSimulation(); - } - - private startSimulation(): void { - if (this.simulationTimer) { - return; - } - this.simulationTimer = setInterval(() => { - this.handleStatusChange('simulated'); - const frame = generateSimulatedData(); - this.listeners.forEach((listener) => { - listener(frame); - }); - }, SIMULATION_TICK_INTERVAL_MS); - } - - private stopSimulation(): void { - if (this.simulationTimer) { - clearInterval(this.simulationTimer); - this.simulationTimer = null; - } } private clearReconnectTimer(): void { diff --git a/ui/mobile/src/stores/matStore.ts b/ui/mobile/src/stores/matStore.ts index 64bfbfdd..e3334ef4 100644 --- a/ui/mobile/src/stores/matStore.ts +++ b/ui/mobile/src/stores/matStore.ts @@ -26,8 +26,8 @@ export const useMatStore = create((set) => ({ survivors: [], alerts: [], selectedEventId: null, - dataSource: 'simulated', - simulationAcknowledged: false, + dataSource: 'real', + simulationAcknowledged: true, upsertEvent: (event) => { set((state) => { diff --git a/ui/mobile/src/stores/settingsStore.ts b/ui/mobile/src/stores/settingsStore.ts index f1ec81d5..ab29dda2 100644 --- a/ui/mobile/src/stores/settingsStore.ts +++ b/ui/mobile/src/stores/settingsStore.ts @@ -18,7 +18,9 @@ export interface SettingsState { export const useSettingsStore = create()( persist( (set) => ({ - serverUrl: 'http://localhost:3000', + // Defaults to the Mac's Tailscale IP so the phone can reach the + // sensing-server from any network. Override in Settings if needed. + serverUrl: 'http://100.123.189.10:8080', rssiScanEnabled: false, theme: 'system', alertSoundEnabled: true, diff --git a/v2/crates/wifi-densepose-desktop/src/commands/discovery.rs b/v2/crates/wifi-densepose-desktop/src/commands/discovery.rs index 804bc8b5..b686a7c9 100644 --- a/v2/crates/wifi-densepose-desktop/src/commands/discovery.rs +++ b/v2/crates/wifi-densepose-desktop/src/commands/discovery.rs @@ -1,4 +1,4 @@ -use std::net::{SocketAddr, UdpSocket}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; use std::time::Duration; use mdns_sd::{ServiceDaemon, ServiceEvent}; @@ -37,13 +37,14 @@ pub async fn discover_nodes( ) -> Result, String> { let timeout_duration = Duration::from_millis(timeout_ms.unwrap_or(3000)); - // Run mDNS and UDP discovery concurrently - let (mdns_nodes, udp_nodes) = tokio::join!( + // Run mDNS, UDP, and HTTP sweep discovery concurrently + let (mdns_nodes, udp_nodes, http_nodes) = tokio::join!( discover_via_mdns(timeout_duration), discover_via_udp(timeout_duration), + discover_via_http_sweep(timeout_duration), ); - // Merge results, deduplicating by MAC address + // Merge results, deduplicating by MAC address (or IP for HTTP-only nodes) let mut registry = NodeRegistry::new(); for node in mdns_nodes.unwrap_or_default() { @@ -58,7 +59,23 @@ pub async fn discover_nodes( } } + let http_vec = http_nodes.unwrap_or_default(); + let _ = std::fs::OpenOptions::new().create(true).append(true) + .open("/tmp/ruview-discovery.log") + .map(|mut f| { use std::io::Write; let _ = writeln!(f, "[discover] http_vec.len()={}", http_vec.len()); }); + for node in http_vec { + // HTTP sweep returns nodes without MAC — key by IP-derived pseudo-MAC + let key = node.mac.clone().unwrap_or_else(|| format!("ip:{}", node.ip)); + let _ = std::fs::OpenOptions::new().create(true).append(true) + .open("/tmp/ruview-discovery.log") + .map(|mut f| { use std::io::Write; let _ = writeln!(f, "[discover] upsert key={} ip={}", key, node.ip); }); + registry.upsert(MacAddress::new(&key), node); + } + let nodes: Vec = registry.all().into_iter().cloned().collect(); + let _ = std::fs::OpenOptions::new().create(true).append(true) + .open("/tmp/ruview-discovery.log") + .map(|mut f| { use std::io::Write; let _ = writeln!(f, "[discover] returning {} nodes", nodes.len()); }); // Update global state { @@ -219,6 +236,148 @@ async fn discover_via_udp(timeout_duration: Duration) -> Result|||||| +/// Discover nodes via HTTP probe of `/ota/status` on port 8032 across local /24 subnet. +/// +/// Strategy: +/// 1. Detect host IPv4 by opening a non-routable UDP socket "connect" to 8.8.8.8. +/// 2. For each host address in the /24 (1..=254, excluding self), send +/// `GET http://X.X.X.X:8032/ota/status` with a short per-request timeout. +/// 3. If the response is JSON containing `version` + `running_partition`, +/// treat the device as a RuView CSI node and build a `DiscoveredNode`. +/// +/// MAC is left as `None` (sensors don't expose it on /ota/status); UI manual +/// add or a future FW field could fill it in. +async fn discover_via_http_sweep(timeout_duration: Duration) -> Result, String> { + // 1. Detect host IPv4 + let host_ip = match detect_host_ipv4() { + Some(ip) => ip, + None => { + tracing::warn!("HTTP sweep: could not determine host IPv4"); + return Ok(Vec::new()); + } + }; + let octets = host_ip.octets(); + let base = (octets[0], octets[1], octets[2]); + tracing::info!("HTTP sweep on {}.{}.{}.0/24 (self={})", base.0, base.1, base.2, host_ip); + + // 2. Build HTTP client with per-request timeout + let per_req_timeout = std::cmp::min(timeout_duration, Duration::from_millis(1500)); + let client = match reqwest::Client::builder() + .timeout(per_req_timeout) + .build() + { + Ok(c) => c, + Err(e) => { + tracing::warn!("HTTP sweep: client build failed: {}", e); + return Ok(Vec::new()); + } + }; + + // 3. Probe all hosts in parallel (capped by spawning futures) + let mut tasks: Vec>> = Vec::new(); + for h in 1u8..=254u8 { + if h == octets[3] { + continue; // skip self + } + let ip = format!("{}.{}.{}.{}", base.0, base.1, base.2, h); + let client = client.clone(); + tasks.push(tokio::spawn(async move { + // Probe FW5.47 /status first, then RuView /ota/status fallback. + let url1 = format!("http://{}:8032/status", ip); + let body: String = match client.get(&url1).send().await { + Ok(r) if r.status().is_success() => match r.text().await { + Ok(t) => t, + Err(_) => return None, + }, + _ => { + let url2 = format!("http://{}:8032/ota/status", ip); + match client.get(&url2).send().await { + Ok(r) if r.status().is_success() => match r.text().await { + Ok(t) => t, + Err(_) => return None, + }, + _ => return None, + } + } + }; + let _ = std::fs::OpenOptions::new().create(true).append(true) + .open("/tmp/ruview-discovery.log") + .map(|mut f| { use std::io::Write; let _ = writeln!(f, "[probe] {} OK len={}", ip, body.len()); }); + let v: serde_json::Value = match serde_json::from_str(&body) { + Ok(v) => v, + Err(e) => { + let _ = std::fs::OpenOptions::new().create(true).append(true) + .open("/tmp/ruview-discovery.log") + .map(|mut f| { use std::io::Write; let _ = writeln!(f, "[probe] {} json err: {}", ip, e); }); + return None; + } + }; + // Both FW5.47 (`version`,`fw`,`node`) and RuView (`version`,`running_partition`). + let version = v.get("version").and_then(|x| x.as_str()).map(String::from) + .or_else(|| v.get("version").and_then(|x| Some(x.to_string()))) + .unwrap_or_else(|| "unknown".to_string()); + let mac = v.get("node").and_then(|x| x.as_str()).map(String::from); + Some(DiscoveredNode { + ip, + mac, + hostname: None, + node_id: 0, + firmware_version: Some(version), + health: HealthStatus::Online, + last_seen: chrono::Utc::now().to_rfc3339(), + chip: Chip::Esp32s3, + mesh_role: MeshRole::Node, + discovery_method: DiscoveryMethod::HttpSweep, + tdm_slot: None, + tdm_total: None, + edge_tier: None, + uptime_secs: None, + capabilities: Some(NodeCapabilities { + wasm: false, + ota: true, + csi: true, + }), + friendly_name: None, + notes: None, + }) + })); + } + + // 4. Wait with overall budget + // Wait for ALL tasks to settle in parallel, bounded by the overall budget. + // Previously used a sequential `for task in tasks { select! }` which awaited + // tasks in IP order — a non-responding 192.168.1.1 blocked discovery of + // 192.168.1.17/19 even though those completed in ~50 ms. + let join_all_fut = futures::future::join_all(tasks); + let results = match tokio::time::timeout(timeout_duration, join_all_fut).await { + Ok(rs) => rs, + Err(_) => { + tracing::info!("HTTP sweep timeout — partial results lost"); + Vec::new() + } + }; + let mut found = Vec::new(); + for r in results { + if let Ok(Some(node)) = r { + tracing::info!("HTTP sweep found {} fw={:?}", node.ip, node.firmware_version); + found.push(node); + } + } + Ok(found) +} + +/// Determine the primary IPv4 of this host by "connecting" a UDP socket +/// to a non-routable target (no packets sent) and reading local_addr. +fn detect_host_ipv4() -> Option { + let sock = UdpSocket::bind("0.0.0.0:0").ok()?; + sock.connect("8.8.8.8:80").ok()?; + let local = sock.local_addr().ok()?; + match local.ip() { + IpAddr::V4(v4) if !v4.is_loopback() => Some(v4), + _ => None, + } +} + fn parse_beacon_response(data: &[u8], addr: SocketAddr) -> Option { let text = std::str::from_utf8(data).ok()?; let parts: Vec<&str> = text.split('|').collect(); diff --git a/v2/crates/wifi-densepose-desktop/src/commands/server.rs b/v2/crates/wifi-densepose-desktop/src/commands/server.rs index 2993b9b0..29153e26 100644 --- a/v2/crates/wifi-densepose-desktop/src/commands/server.rs +++ b/v2/crates/wifi-densepose-desktop/src/commands/server.rs @@ -101,17 +101,47 @@ pub async fn start_server( if let Some(port) = config.udp_port { cmd.args(["--udp-port", &port.to_string()]); } - if let Some(ref bind_addr) = config.bind_address { - cmd.args(["--bind", bind_addr]); - } + // Bind address: default to 0.0.0.0 so LAN-connected ESP32 nodes can reach us. + let bind_addr = config + .bind_address + .as_deref() + .unwrap_or("0.0.0.0"); + cmd.args(["--bind-addr", bind_addr]); + // Pass log level via RUST_LOG env (sensing-server reads tracing_subscriber env). if let Some(ref log_level) = config.log_level { - cmd.args(["--log-level", log_level]); + cmd.env("RUST_LOG", log_level); } - // Set data source (default to "simulate" if not specified for demo mode) - let source = config.source.as_deref().unwrap_or("simulate"); + // Set data source (default to "esp32" for real CSI ingest; UI may override) + let source = config.source.as_deref().unwrap_or("esp32"); cmd.args(["--source", source]); + // Auto-load bundled vital-signs RVF model if present next to the binary. + // Searches: /wifi-densepose-v1.rvf, then /wifi-densepose-v1.rvf. + let mut model_path: Option = None; + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let candidate = dir.join("wifi-densepose-v1.rvf"); + if candidate.exists() { + model_path = Some(candidate); + } + } + } + if model_path.is_none() { + if let Ok(resource_dir) = app.path().resource_dir() { + let candidate = resource_dir.join("wifi-densepose-v1.rvf"); + if candidate.exists() { + model_path = Some(candidate); + } + } + } + if let Some(p) = model_path { + tracing::info!("Auto-loading vital-signs RVF model: {}", p.display()); + cmd.args(["--load-rvf", &p.to_string_lossy()]); + } else { + tracing::warn!("No wifi-densepose-v1.rvf found next to binary or in resources; vital signs disabled"); + } + // Redirect stdout/stderr to pipes for monitoring cmd.stdout(Stdio::piped()); cmd.stderr(Stdio::piped()); diff --git a/v2/crates/wifi-densepose-desktop/ui/package-lock.json b/v2/crates/wifi-densepose-desktop/ui/package-lock.json index 04326e1d..289cda10 100644 --- a/v2/crates/wifi-densepose-desktop/ui/package-lock.json +++ b/v2/crates/wifi-densepose-desktop/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "ruview-desktop-ui", - "version": "0.3.0", + "version": "0.4.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ruview-desktop-ui", - "version": "0.3.0", + "version": "0.4.4", "dependencies": { "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.6.0", @@ -53,7 +53,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1247,7 +1246,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1317,7 +1315,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1587,7 +1584,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1629,7 +1625,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1802,7 +1797,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/v2/crates/wifi-densepose-desktop/ui/src/hooks/useNodes.ts b/v2/crates/wifi-densepose-desktop/ui/src/hooks/useNodes.ts index 03fd4fbb..2fc1948e 100644 --- a/v2/crates/wifi-densepose-desktop/ui/src/hooks/useNodes.ts +++ b/v2/crates/wifi-densepose-desktop/ui/src/hooks/useNodes.ts @@ -37,9 +37,15 @@ export function useNodes(options: UseNodesOptions = {}): UseNodesReturn { try { const discovered = await invoke("discover_nodes", { - timeoutMs: 5000, + timeoutMs: 8000, }); - setNodes(discovered); + // Discovery is flaky on busy LANs — overall timeout races with the + // per-request reqwest timeouts and sometimes returns 0 even when + // sensors are reachable. Keep the last good list rather than + // flashing to "no nodes". + if (discovered.length > 0) { + setNodes(discovered); + } } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/v2/crates/wifi-densepose-desktop/ui/src/hooks/useServer.ts b/v2/crates/wifi-densepose-desktop/ui/src/hooks/useServer.ts index d9ecaaea..27297df2 100644 --- a/v2/crates/wifi-densepose-desktop/ui/src/hooks/useServer.ts +++ b/v2/crates/wifi-densepose-desktop/ui/src/hooks/useServer.ts @@ -5,11 +5,11 @@ import type { ServerConfig, ServerStatus } from "../types"; const DEFAULT_CONFIG: ServerConfig = { http_port: 8080, ws_port: 8765, - udp_port: 5005, + udp_port: 5006, static_dir: null, model_dir: null, log_level: "info", - source: "simulate", + source: "esp32", }; interface UseServerOptions { diff --git a/v2/crates/wifi-densepose-desktop/ui/src/pages/Dashboard.tsx b/v2/crates/wifi-densepose-desktop/ui/src/pages/Dashboard.tsx index e3608616..70c324cd 100644 --- a/v2/crates/wifi-densepose-desktop/ui/src/pages/Dashboard.tsx +++ b/v2/crates/wifi-densepose-desktop/ui/src/pages/Dashboard.tsx @@ -36,9 +36,13 @@ const Dashboard: React.FC = ({ onNavigate }) => { setScanError(null); try { const { invoke } = await import("@tauri-apps/api/core"); - const found = await invoke("discover_nodes", { timeoutMs: 3000 }); - setNodes(found); - if (found.length === 0) { + const found = await invoke("discover_nodes", { timeoutMs: 8000 }); + // Keep last good list when scan returns empty (discovery is flaky + // on busy LANs — see useNodes.ts for context). + if (found.length > 0) { + setNodes(found); + setScanError(null); + } else if (nodes.length === 0) { setScanError("No nodes found. Ensure ESP32 devices are powered on and connected to the network."); } } catch (err) { diff --git a/v2/crates/wifi-densepose-desktop/ui/src/pages/Sensing.tsx b/v2/crates/wifi-densepose-desktop/ui/src/pages/Sensing.tsx index 6d16b46f..83e510fe 100644 --- a/v2/crates/wifi-densepose-desktop/ui/src/pages/Sensing.tsx +++ b/v2/crates/wifi-densepose-desktop/ui/src/pages/Sensing.tsx @@ -303,7 +303,7 @@ export const Sensing: React.FC = () => { const [stopping, setStopping] = useState(false); // Data source selection - const [dataSource, setDataSource] = useState("simulate"); + const [dataSource, setDataSource] = useState("esp32"); // Log viewer state const [logEntries, setLogEntries] = useState([]); @@ -557,7 +557,6 @@ export const Sensing: React.FC = () => { opacity: isRunning ? 0.6 : 1, }} > - diff --git a/v2/crates/wifi-densepose-desktop/ui/src/types.ts b/v2/crates/wifi-densepose-desktop/ui/src/types.ts index d9b2e293..43a5ef20 100644 --- a/v2/crates/wifi-densepose-desktop/ui/src/types.ts +++ b/v2/crates/wifi-densepose-desktop/ui/src/types.ts @@ -170,7 +170,7 @@ export interface WasmModule { // Sensing Server // --------------------------------------------------------------------------- -export type DataSource = "auto" | "wifi" | "esp32" | "simulate"; +export type DataSource = "auto" | "wifi" | "esp32"; export interface ServerConfig { http_port: number; diff --git a/v2/crates/wifi-densepose-sensing-server/src/csi.rs b/v2/crates/wifi-densepose-sensing-server/src/csi.rs index 378ee87d..95458b64 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/csi.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/csi.rs @@ -10,6 +10,62 @@ use crate::vital_signs::VitalSigns; // ── ESP32 UDP frame parsers ───────────────────────────────────────────────── +/// Parse a 60-byte ADR-081 feature_state packet (magic 0xC511_0006). +/// +/// Converts the on-wire rv_feature_state_t into an Esp32VitalsPacket so the +/// existing vitals processing pipeline can consume it directly. Mapping: +/// motion_score → motion_energy (and motion flag if > 0.05) +/// presence_score → presence_score + presence (flag) if > 0.5 +/// respiration_bpm → breathing_rate_bpm +/// heartbeat_bpm → heartrate_bpm +/// quality_flags → presence/fall/motion bits +pub fn parse_rv_feature_state(buf: &[u8]) -> Option { + if buf.len() < 60 { return None; } + let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); + if magic != 0xC511_0006 { return None; } + + let node_id = buf[4]; + let _mode = buf[5]; + let _seq = u16::from_le_bytes([buf[6], buf[7]]); + let ts_us = u64::from_le_bytes([ + buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15], + ]); + let motion_score = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]); + let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]); + let respiration_bpm = f32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]); + let _respiration_conf = f32::from_le_bytes([buf[28], buf[29], buf[30], buf[31]]); + let heartbeat_bpm = f32::from_le_bytes([buf[32], buf[33], buf[34], buf[35]]); + let _heartbeat_conf = f32::from_le_bytes([buf[36], buf[37], buf[38], buf[39]]); + let _anomaly_score = f32::from_le_bytes([buf[40], buf[41], buf[42], buf[43]]); + let _env_shift_score = f32::from_le_bytes([buf[44], buf[45], buf[46], buf[47]]); + let _node_coherence = f32::from_le_bytes([buf[48], buf[49], buf[50], buf[51]]); + let quality_flags = u16::from_le_bytes([buf[52], buf[53]]); + + // Bit 0 of quality_flags = presence valid + let presence_valid = (quality_flags & (1 << 0)) != 0; + let presence = presence_valid && presence_score > 0.5; + // Bit 3 = anomaly triggered → treat as fall (approximation) + let fall_detected = (quality_flags & (1 << 3)) != 0; + let motion = motion_score > 0.05; + + // Single-node feature_state doesn't tell us number of persons; surface 1 when present. + let n_persons = if presence { 1 } else { 0 }; + + Some(Esp32VitalsPacket { + node_id, + presence, + fall_detected, + motion, + breathing_rate_bpm: respiration_bpm as f64, + heartrate_bpm: heartbeat_bpm as f64, + rssi: -50, // not carried; approximation so UI shows a value + n_persons, + motion_energy: motion_score, + presence_score, + timestamp_ms: (ts_us / 1000) as u32, + }) +} + /// Parse a 32-byte edge vitals packet (magic 0xC511_0002). pub fn parse_esp32_vitals(buf: &[u8]) -> Option { if buf.len() < 32 { return None; } diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 5887a752..cff784d0 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -29,6 +29,99 @@ use ruvector_mincut::{DynamicMinCut, MinCutBuilder}; use std::net::SocketAddr; use std::path::PathBuf; use std::sync::Arc; +use std::sync::{Mutex, OnceLock}; + +/// Per-node adaptive baseline for `motion_energy` / `presence_score`. +/// +/// FW reports raw values that are non-zero even in an empty room because of +/// ambient RF noise. We compute an EWMA mean+variance over recent samples and +/// flag presence/motion only when the current value is well above that +/// background (z-score > 2). When the room is quiet long enough the baseline +/// drifts up to the noise floor, so steady-state presence drops to false. +struct BaselineTracker { + motion_mean: f32, + motion_var: f32, + presence_mean: f32, + presence_var: f32, + samples: u32, + /// Rolling smoothed motion (low-pass). + motion_smooth: f32, + /// Hysteresis: count of consecutive frames over threshold for presence on, + /// or under threshold for presence off. + on_count: u32, + off_count: u32, + presence_state: bool, +} + +impl BaselineTracker { + fn new() -> Self { + Self { + motion_mean: 0.0, motion_var: 0.01, + presence_mean: 0.0, presence_var: 0.01, + samples: 0, + motion_smooth: 0.0, + on_count: 0, + off_count: 0, + presence_state: false, + } + } + + /// Returns (is_present, motion_norm 0..1, presence_norm 0..1). + /// + /// FW saturates `motion_score` at 1.0, so we use the derivative of + /// `presence_score`. Empty room: deltas are mostly <0.01 with occasional + /// noise. Human motion: produces frequent spikes of 0.05-1.0. + /// + /// Algorithm: + /// 1. Compute |delta_i| = |presence_i - presence_{i-1}| + /// 2. Slide a 30-frame (~3 sec @ 10pps) window of "is_spike" bits + /// where spike = delta > SPIKE_THRESHOLD + /// 3. If ≥ MIN_SPIKES spikes in window → presence ON + /// 4. If 0 spikes in window → presence OFF + fn update(&mut self, _motion: f32, presence: f32) -> (bool, f32, f32) { + self.samples += 1; + + let raw_delta = (presence - self.presence_mean).abs(); + self.presence_mean = presence; + + const SPIKE_THRESHOLD: f32 = 0.05; + const MIN_SPIKES_ON: u32 = 3; + const WINDOW: u32 = 30; + + if raw_delta > SPIKE_THRESHOLD { + self.on_count = self.on_count.saturating_add(1); + self.off_count = 0; + } else { + self.off_count = self.off_count.saturating_add(1); + } + + // Lightweight rolling: every WINDOW frames, halve on_count so old + // spikes decay (cheap approximation of a sliding window). + if self.samples % WINDOW == 0 { + self.on_count /= 2; + } + + if self.on_count >= MIN_SPIKES_ON { + self.presence_state = true; + } else if self.off_count >= WINDOW { + self.presence_state = false; + } + + // Use smoothed delta as motion_norm for the UI's intensity bar. + let alpha = 0.3; + self.motion_smooth = (1.0 - alpha) * self.motion_smooth + alpha * raw_delta; + let motion_norm = (self.motion_smooth * 5.0).clamp(0.0, 1.0); + let presence_norm = if self.presence_state { motion_norm.max(0.3) } else { 0.0 }; + (self.presence_state, motion_norm, presence_norm) + } +} + +static BASELINE: OnceLock>> = OnceLock::new(); + +fn baseline_init() -> &'static Mutex> { + BASELINE.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + use std::time::Duration; use axum::{ @@ -712,6 +805,45 @@ struct Esp32VitalsPacket { timestamp_ms: u32, } +/// Parse a 60-byte ADR-081 feature_state packet (magic 0xC511_0006). +/// Converts into the local Esp32VitalsPacket so the existing vitals +/// pipeline handles real ESP32 nodes uniformly. +fn parse_rv_feature_state(buf: &[u8]) -> Option { + if buf.len() < 60 { return None; } + let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); + if magic != 0xC511_0006 { return None; } + + let node_id = buf[4]; + let ts_us = u64::from_le_bytes([ + buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15], + ]); + let motion_score = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]); + let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]); + let respiration_bpm = f32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]); + let heartbeat_bpm = f32::from_le_bytes([buf[32], buf[33], buf[34], buf[35]]); + let quality_flags = u16::from_le_bytes([buf[52], buf[53]]); + + let presence_valid = (quality_flags & (1 << 0)) != 0; + let presence = presence_valid && presence_score > 0.5; + let fall_detected = (quality_flags & (1 << 3)) != 0; + let motion = motion_score > 0.05; + let n_persons = if presence { 1 } else { 0 }; + + Some(Esp32VitalsPacket { + node_id, + presence, + fall_detected, + motion, + breathing_rate_bpm: respiration_bpm as f64, + heartrate_bpm: heartbeat_bpm as f64, + rssi: -50, + n_persons, + motion_energy: motion_score, + presence_score, + timestamp_ms: (ts_us / 1000) as u32, + }) +} + /// Parse a 32-byte edge vitals packet (magic 0xC511_0002). fn parse_esp32_vitals(buf: &[u8]) -> Option { if buf.len() < 32 { @@ -799,6 +931,92 @@ fn parse_wasm_output(buf: &[u8]) -> Option { }) } +// ── FW5.47 CSI_LEAN text packet parser ─────────────────────────────────────── +// +// FW5.47 (esp32s3_csi_capture) emits compact CSV-style UDP packets: +// CSI_LEAN,role,src_mac,dst_mac,rssi,noise,channel,ts,seq,n_subc,profile,"[a1 a2 a3 ...]" +// +// The bracketed array contains `n_subc` uint8 amplitude bins (already +// magnitude-summarised on-device). We convert into Esp32Frame with +// amplitudes filled (phases = 0) so the existing DSP pipeline can consume it. +fn parse_csi_lean(buf: &[u8]) -> Option { + // Cheap prefix check before doing UTF-8 decode. + if buf.len() < 10 || &buf[0..9] != b"CSI_LEAN," { + return None; + } + let text = std::str::from_utf8(buf).ok()?; + + // Find amplitude array between the first '[' and ']'. + let lb = text.find('[')?; + let rb = text[lb..].find(']')?; + let arr = &text[lb + 1..lb + rb]; + + // Header part is comma-separated, up to the '"[' chunk. + // Fields (1-indexed): + // 1: role(int), 2: src_mac, 3: dst_mac, 4: rssi(int), 5: noise(int), + // 6: channel(int), 7: ts(int), 8: seq(uint), 9: n_subc(uint), + // 10: profile_name, 11+: array (handled separately). + let head: Vec<&str> = text[..lb].split(',').collect(); + if head.len() < 10 { return None; } + + let _role = head[1].trim().parse::().unwrap_or(1); + let src_mac = head[2].trim(); + let _dst_mac = head[3]; + let rssi: i8 = head[4].trim().parse().unwrap_or(-60); + let noise: i8 = head[5].trim().parse().unwrap_or(-95); + let channel: u16 = head[6].trim().parse().unwrap_or(0); + let sequence: u32 = head[8].trim().parse().unwrap_or(0); + let n_subc: u32 = head[9].trim().parse().unwrap_or(64); + + let mut amplitudes: Vec = arr + .split_whitespace() + .filter_map(|t| t.parse::().ok()) + .map(|v| v as f64) + .collect(); + + if amplitudes.is_empty() { return None; } + // Guard length to what header claims, padding zeros if short. + if amplitudes.len() < n_subc as usize { + amplitudes.resize(n_subc as usize, 0.0); + } else if amplitudes.len() > n_subc as usize { + amplitudes.truncate(n_subc as usize); + } + let phases: Vec = vec![0.0; amplitudes.len()]; + + // Derive node_id from source MAC last octet (unique per board). + // Hard-mapped for the known room sensors so labels match physical units. + let node_id: u8 = match src_mac.to_ascii_lowercase().as_str() { + "1c:db:d4:49:eb:88" => 1, // room01 + "e8:f6:0a:83:89:44" => 2, // room02 + _ => { + // Fallback: parse last MAC octet from "xx:xx:xx:xx:xx:NN" + src_mac.rsplit(':').next() + .and_then(|h| u8::from_str_radix(h, 16).ok()) + .unwrap_or(1) + } + }; + + // Channel → freq_mhz approximation (2.4 GHz band). + let freq_mhz = if channel >= 1 && channel <= 14 { + 2407u16 + 5 * channel + } else { + 2412u16 + }; + + Some(Esp32Frame { + magic: 0xC511_0001, + node_id, + n_antennas: 1, + n_subcarriers: amplitudes.len() as u8, + freq_mhz, + sequence, + rssi: if rssi > 0 { -rssi } else { rssi }, + noise_floor: noise, + amplitudes, + phases, + }) +} + // ── ESP32 UDP frame parser ─────────────────────────────────────────────────── fn parse_esp32_frame(buf: &[u8]) -> Option { @@ -3652,8 +3870,29 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { loop { match socket.recv_from(&mut buf).await { Ok((len, src)) => { - // ADR-039: Try edge vitals packet first (magic 0xC511_0002). - if let Some(vitals) = parse_esp32_vitals(&buf[..len]) { + // ADR-081 feature_state packet (magic 0xC511_0006) — preferred upstream + // payload from the firmware. Convert to Esp32VitalsPacket so the rest of + // the pipeline (rendering, sensing_update broadcast) handles it uniformly. + let maybe_vitals = parse_rv_feature_state(&buf[..len]) + .or_else(|| parse_esp32_vitals(&buf[..len])); + if let Some(mut vitals) = maybe_vitals { + // Adaptive baseline: FW emits raw motion_score / presence_score + // that can be non-zero even in an empty room because of RF + // background noise (router beacons, neighbor APs, etc). + // Run a per-node EWMA baseline and threshold via z-score so + // `vitals.presence` reflects actual change vs ambient noise + // rather than absolute level. + { + let mut g = baseline_init().lock().unwrap(); + let tr = g.entry(vitals.node_id).or_insert_with(BaselineTracker::new); + let (is_present, motion_norm, presence_norm) = + tr.update(vitals.motion_energy, vitals.presence_score); + vitals.presence = is_present; + vitals.motion = motion_norm > 0.3; + vitals.motion_energy = motion_norm; + vitals.presence_score = presence_norm; + if !is_present { vitals.n_persons = 0; } + } debug!("ESP32 vitals from {src}: node={} br={:.1} hr={:.1} pres={}", vitals.node_id, vitals.breathing_rate_bpm, vitals.heartrate_bpm, vitals.presence); @@ -3856,7 +4095,10 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) { continue; } - if let Some(frame) = parse_esp32_frame(&buf[..len]) { + // FW5.47 CSI_LEAN text packet, or FW5.47-style raw 0xC5110001 binary. + let maybe_frame = parse_csi_lean(&buf[..len]) + .or_else(|| parse_esp32_frame(&buf[..len])); + if let Some(frame) = maybe_frame { debug!("ESP32 frame from {src}: node={}, subs={}, seq={}", frame.node_id, frame.n_subcarriers, frame.sequence); @@ -4664,7 +4906,8 @@ async fn main() { info!(" UI path: {}", args.ui_path.display()); info!(" Source: {}", args.source); - // Auto-detect data source + // Auto-detect data source (simulation path removed — production deployments + // must never fall back to synthetic data; ESP32 or WiFi only). let source = match args.source.as_str() { "auto" => { info!("Auto-detecting data source..."); @@ -4675,10 +4918,14 @@ async fn main() { info!(" Windows WiFi detected"); "wifi" } else { - info!(" No hardware detected, using simulation"); - "simulate" + error!("No real data source detected (ESP32 UDP / WiFi). Simulation is disabled in production builds — exiting."); + std::process::exit(2); } } + "simulate" | "simulated" => { + error!("--source simulate is disabled in this build. Use 'esp32' or 'wifi'."); + std::process::exit(2); + } other => other, }; @@ -4852,8 +5099,9 @@ async fn main() { "wifi" => { tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms)); } - _ => { - tokio::spawn(simulated_data_task(state.clone(), args.tick_ms)); + other => { + error!("Unsupported --source '{}'. Allowed: esp32, wifi, auto.", other); + std::process::exit(2); } }