deploy(esp32s3): fix DSP, OTA, discovery, mobile WS for room01/room02

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 <noreply@anthropic.com>
This commit is contained in:
arsen 2026-05-14 18:56:04 +07:00
parent 58cd860f17
commit fc905c5c77
23 changed files with 988 additions and 112 deletions

View File

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

View File

@ -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.050.25 Hz on the wire, excluding the
0.20.3 Hz human breathing band (1218 BPM).
**Patch**: `edge_processing.c:1063``fs = 10.0f` for both
breathing and heart-rate filters. With D2+D3 active, `breathing_rate_bpm`
populates 2122 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<Mutex<HashMap<u8,_>>>`)
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 /<probe-ip>: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.020.03 | 0.020.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).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (
<ThemedView style={{ flex: 1, backgroundColor: colors.bg, padding: spacing.md }}>

View File

@ -60,7 +60,7 @@ export default function VitalsScreen() {
<ConnectionBanner status={bannerStatus} />
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.headerRow}>{isSimulated ? <ModeBadge mode="SIM" /> : null}</View>
<View style={styles.headerRow}>{/* SIM badge removed: production shows only real signals. */}</View>
<View style={styles.gaugesRow}>
<View style={styles.gaugeCard}>

View File

@ -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<FrameListener>();
private reconnectAttempt = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private simulationTimer: ReturnType<typeof setInterval> | 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 {

View File

@ -26,8 +26,8 @@ export const useMatStore = create<MatState>((set) => ({
survivors: [],
alerts: [],
selectedEventId: null,
dataSource: 'simulated',
simulationAcknowledged: false,
dataSource: 'real',
simulationAcknowledged: true,
upsertEvent: (event) => {
set((state) => {

View File

@ -18,7 +18,9 @@ export interface SettingsState {
export const useSettingsStore = create<SettingsState>()(
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,

View File

@ -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<Vec<DiscoveredNode>, 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<DiscoveredNode> = 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<Vec<DiscoveredNo
/// Parse a UDP beacon response into a DiscoveredNode.
/// Format: RUVIEW_BEACON|<mac>|<node_id>|<version>|<chip>|<role>|<tdm_slot>|<tdm_total>
/// 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<Vec<DiscoveredNode>, 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<tokio::task::JoinHandle<Option<DiscoveredNode>>> = 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<Ipv4Addr> {
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<DiscoveredNode> {
let text = std::str::from_utf8(data).ok()?;
let parts: Vec<&str> = text.split('|').collect();

View File

@ -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: <exe_dir>/wifi-densepose-v1.rvf, then <resource_dir>/wifi-densepose-v1.rvf.
let mut model_path: Option<std::path::PathBuf> = 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());

View File

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

View File

@ -37,9 +37,15 @@ export function useNodes(options: UseNodesOptions = {}): UseNodesReturn {
try {
const discovered = await invoke<Node[]>("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);

View File

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

View File

@ -36,9 +36,13 @@ const Dashboard: React.FC<DashboardProps> = ({ onNavigate }) => {
setScanError(null);
try {
const { invoke } = await import("@tauri-apps/api/core");
const found = await invoke<DiscoveredNode[]>("discover_nodes", { timeoutMs: 3000 });
setNodes(found);
if (found.length === 0) {
const found = await invoke<DiscoveredNode[]>("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) {

View File

@ -303,7 +303,7 @@ export const Sensing: React.FC = () => {
const [stopping, setStopping] = useState(false);
// Data source selection
const [dataSource, setDataSource] = useState<DataSource>("simulate");
const [dataSource, setDataSource] = useState<DataSource>("esp32");
// Log viewer state
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
@ -557,7 +557,6 @@ export const Sensing: React.FC = () => {
opacity: isRunning ? 0.6 : 1,
}}
>
<option value="simulate">Simulate</option>
<option value="esp32">ESP32 (Real)</option>
<option value="wifi">WiFi (RSSI)</option>
<option value="auto">Auto Detect</option>

View File

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

View File

@ -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<Esp32VitalsPacket> {
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<Esp32VitalsPacket> {
if buf.len() < 32 { return None; }

View File

@ -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<Mutex<std::collections::HashMap<u8, BaselineTracker>>> = OnceLock::new();
fn baseline_init() -> &'static Mutex<std::collections::HashMap<u8, BaselineTracker>> {
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<Esp32VitalsPacket> {
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<Esp32VitalsPacket> {
if buf.len() < 32 {
@ -799,6 +931,92 @@ fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {
})
}
// ── 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<Esp32Frame> {
// 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::<u8>().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<f64> = arr
.split_whitespace()
.filter_map(|t| t.parse::<u32>().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<f64> = 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<Esp32Frame> {
@ -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);
}
}