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:
parent
58cd860f17
commit
fc905c5c77
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<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.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).
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue