The FFT will always find *some* peak in 0.8-2.0 Hz, even on pure
clutter, and the peak-to-mean ratio frequently lands at 0.5-0.7
"confidence" from noise alone. Net result: the HR pill showed 75-97
BPM with 60%+ confidence while the operator was across the room
with their back to the radar.
Add a presence gate based on the target gate's micromotion energy:
empty room peak_micro_mid 1k-3k
person nearby peak_micro_mid 10k-20k
person in beam peak_micro_mid 40k-80k
Threshold at 20k. Below it we null both BR and HR (the breathing
detector's internal buffer is still fed so it stays warm for instant
re-acquisition).
New diagnostic endpoint GET /api/v1/mmwave/gates dumps current
motion/micro arrays + the target gate so we can re-calibrate the
threshold on new firmware.
UI: pill now shows "· нет цели" (no target) when presence=false, so
the operator can tell "buffer warming up" from "nobody in beam"
from "module fell back to Normal Mode".
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ADR-121 (Normal Mode) gave us distance and a passable breathing
estimate but couldn't see the heartbeat — cardiac chest displacement
(~0.5 mm) is well below the cm quantisation of `distance:NNN`.
Engineering Mode streams per-range-gate energy at the same 6 Hz
cadence (15 motion + 15 micromotion gates, u32 LE each). The
micromotion bin at the target's distance carries enough cardiac
modulation for FFT peak-detection in the 0.8-2.0 Hz band.
Live result, seated operator ~1.5 m from the radar:
🫁📡 13.0 BPM · 37% норма 12-20
💓📡 76 BPM · 63% норма 60-100
Implementation:
- Send enable-config → set-mode(0x04) → disable-config on startup;
fall back to Normal-Mode ASCII parsing if the sequence fails.
- Binary frame parser: F4 F3 F2 F1 | len(2) | 0x01 | dist(2) | 8z |
motion[15]×u32 LE | micro[15]×u32 LE | F8 F7 F6 F5. Gate the ASCII
line-drain on the engineering_mode flag — first cut ran both
unconditionally and destroyed 80% of partial frames mid-buffer.
- Target-gate selection: distance-bracketed gate first, mid-range
micro-peak fallback, gate 1 default. Per-gate ring buffer of
log-energies feeds a Hann + radix-2 FFT.
- /api/v1/mmwave/vitals now returns real `heart_rate_bpm`.
- raw.html: 💓📡 pill now shows real values (no more "n/a"
placeholder).
- New probe script v2/scripts/probe_ld2402_engineering.py used to
reverse-engineer the wire format; kept in tree for next time.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously only WiFi-CSI produced breathing/HR estimates. With the
HLK-LD2402 radar wired up we can compute a second, physically
independent breathing estimate from chest-induced cm flicker in the
distance time-series — a useful cross-check that catches the case
when one modality is blind (e.g. WiFi-CSI when nodes are offline,
or mmWave when nothing's in the radar's field of view).
mmwave.rs:
- Plumb a per-reading VitalSignDetector tuned for the module's 6 Hz
Normal-Mode cadence (Nyquist 3 Hz comfortably covers the 0.1-0.5
Hz breathing band).
- Distance (cm) feeds the detector as the "amplitude" channel;
phase is empty so heartbeat falls back to amplitude residual.
- Gate `current_vitals()` on data freshness so a disconnected radar
doesn't return stale cached BPMs.
main.rs:
- New GET /api/v1/mmwave/vitals returning the same shape as
/api/v1/vital-signs plus buffer status for UI warm-up feedback.
ui/raw.html:
- Each vital pill now shows both 📶 (WiFi-CSI) and 📡 (mmWave)
values side-by-side, separated by `|`. mmWave HR is labelled
"n/a" — cm precision at 6 Hz puts heartbeat below the noise
floor. Buffer fill (e.g. "120/180") shown while detector is
warming up so the operator knows BPM is on the way.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The breathing/HR pills carried raw BPM with no context. An operator
glancing at "94 BPM" can't tell if that's normal or tachycardia
without external reference.
Add inline "норма 12–20" / "норма 60–100" hints (dimmed so they
don't compete with the live value), and tint the number amber when
it falls outside the adult-at-rest range. Tooltip carries the
medical terminology (bradypnea/tachypnea, bradycardia/tachycardia).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ADR-021 already publishes `vital_signs` inside SensingUpdate but the
raw calibration console had no readout — the operator had to curl
/api/v1/vital-signs to see breathing/HR. Add two pills (🫁 + 💓)
next to the mmWave one and update them on every WS tick.
Confidence < 20 % dims the pill so noise-floor estimates don't read
as real values. Missing/zero rates fall back to "— BPM".
Mirrored ui/raw.html → static/raw.html so both deployment paths
serve the same console.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a hidden-by-default 📡 mmWave pill next to the global badge + CV
stat. Polls /api/v1/mmwave/latest at 5 Hz (~200 ms) — well above the
HLK-LD2402's 6 Hz native cadence so no information is lost. Pill shows:
📡 mmWave 152 cm · 60 ms
Distance + age (ms since last reading). Fades to 50% opacity when age
>1.5 s, hides entirely when the server reports `available: false`
(port absent or stale >2 s).
Synced both copies — ui/raw.html (deploy mirror) + static/raw.html
(canonical source referenced by ADR-104 / ADR-107).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaces the text-pill status with a 140×14 px progress bar that
fills from 0 → 99% over CALIB_DURATION_SEC (90s default). On
complete it flashes to 100% with "done" label, then hides itself
after 3s; on error it surfaces a text pill so failure modes stay
visible.
Closes the last Open Item in ADR-107.
Co-Authored-By: claude-flow <ruv@ruv.net>
Three related ADR-104 follow-ups:
1. Expose per-node drift_score on PerNodeFeatureInfo (skip-if-none
so legacy v1 baseline.json — no per_subcarrier_mean — emits
nothing instead of misleading 0.0).
2. raw.html drift sparkline below the RSSI/broadband trace, fixed
Y range [0, 0.30] with dashed presence (0.10) + warning (0.15)
thresholds so operators can read off-axis presence across nodes
without re-scaling. Stat pill "drift" shows the live numeric.
3. baseline_staleness_watch background task: when the on-disk
baseline is older than --baseline-stale-age-sec (default 4 h)
AND drift > 1.5× presence threshold for ≥3 consecutive 5-min
ticks while the classifier reports `absent`, logs a warning
suggesting recalibration. Rate-limited via
--baseline-stale-warn-cooldown-sec (default 1 h). Independent
from auto-recalibrate: that one needs a quiet room; this one
fires when the operator is *in* the room while the channel
itself has physically shifted (AP moved, furniture, etc.).
Co-Authored-By: claude-flow <ruv@ruv.net>
UI side of ADR-107: green "calibrate empty" button in raw.html next
to the existing reset/log-y controls. Click → confirm dialog tells
the operator to step out → POST /api/v1/baseline/calibrate with
90 s capture window → polls GET /api/v1/baseline every 2 s, surfaces
"recording… N/90 s" then "baseline updated ✓".
ADR-107 documents:
D1 in-process capture_baseline_to_disk (port of record-baseline.py)
D2 BASELINE_BUS broadcast forwarder so capture stays decoupled from
WS clients
D3 POST /api/v1/baseline/calibrate (immediate ack, background work)
D4 GET /api/v1/baseline (current state + cooldown + status)
D5 auto_recalibrate_task — 30-min absent+low-CV trigger, 1-h cooldown
D6 raw.html button + polling
* docs/references/espectre-techniques.md — catalogues every Pace
technique from Part-2 against what RuView has implemented, doesn't
have, or has differently. Includes ranked open-items list.
* sensing-server: revert feature_state path to vec![] amplitudes.
The previous fix made bars LOOK live by reissuing the last raw-CSI
vector on every feature_state tick — operator reported this made
the bars misleading (visually busy but unresponsive to movement).
raw.html already skips empty-amp updates so bars now refresh only
on actual fresh CSI, which is honest.
* raw.html: comment on the skip-empty branch for future-me.
Operator request: only one UI page open. raw.html (ADR-099 console,
extended in ADR-101 with per-node classification badges) covers all
live-debug use cases. mobile.html / spectrum.html / calibrate.html
were either superseded or never adopted in the field — removing them
reduces the surface that has to track ADR-101/102 contract changes.
raw.html stays at /static/raw.html on the existing :8080 listener.
* nodes[].rssi_dbm of 0 used to display literally as "0.0 dBm",
misleading the operator when rssi_history was empty on the first
few ticks. Now coerce to "--" and skip pushing zeros to the trace.
* per-node fps was 1/dt instantaneous, blown up to 235 by multiple
SensingUpdate emit paths firing back-to-back. Replaced with a
1-second windowed counter — now matches the real ~38 fps per node.
scripts/ota-deploy.sh
Python 3 helper (the earlier bash version tripped over macOS bash 3.2's
missing associative arrays). One invocation with no arguments:
1. discovers nodes in the local /24 via ARP + /ota/status:8032 probe;
2. POSTs the firmware blob to every node in parallel;
3. waits for reboot, polls /ota/status until running_partition flips,
and fails-loud if any node stays on the old partition (typical
symptom of a panic on first boot from the new slot).
Supports `--build` (idf.py build first), `--no-verify`, explicit IP
list, and OTA_PSK=<token> for the ADR-050 Bearer auth path.
Measured cycle: ~25 s end-to-end for both room01 + room02.
static/mobile.html
Mobile-first sibling of static/raw.html. The desktop page is unreadable
on a 360-420 px screen — bars chart fights the narrow viewport, 11-12 px
font, controls overlap the badge. The mobile page:
- sticky global badge (30 px) + connection pill + reset (44 px tap);
- per-node card with 22 px node badge, 18 px stat tiles, 90 px trace;
- drops the bars chart (useless under 600 px wide);
- viewport-fit=cover, theme-color, apple-mobile-web-app meta tags;
- high-contrast palette tuned for outdoor light;
- reuses the /ws/sensing contract verbatim — anything that lights up
raw.html lights this up too.
main.rs ServeDir route
Adds `.nest_service("/static", ServeDir::new(.../static))` so
raw.html / mobile.html / calibrate.html / spectrum.html are served on
the main 8080 port. Previously they needed a separate
`python -m http.server :8091`, which the operator had to remember to
start by hand on every deploy. Now there's exactly one URL per device.
Reachable from a phone on the LAN:
http://<mac>:8080/static/mobile.html
http://<mac>:8080/static/raw.html
Co-Authored-By: claude-flow <ruv@ruv.net>
Surfaces the raw-amplitude classifier's per-node decision in
node_features[].classification so the UI can show which sensor is
actually seeing motion at any moment. Lets the operator visually find
the best sensor placement without physically moving things — just walk
around and watch which badge lights up.
Server side: adds amp_node_level() pure helper + amp_node_snapshot()
that reads AMP_LATEST, then plugs it into build_node_features so the
existing PerNodeFeatureInfo.classification carries the new labels.
UI: adds a global badge in the top bar and a per-node badge inline in
each h2, color-coded (grey/absent, blue/present_still, green/moving,
red/active) plus the live per-node CV %.
Ports Francesco Pace's ESPectre gain-lock (GPLv3) to RuView FW: medians
AGC and FFT scale over the first 300 packets after boot, then freezes
them via phy_force_rx_gain / phy_fft_scale_force. With both sensors
locked and proper AP→body→sensor geometry, a 30-s × 3-state capture
(empty / still / walk) now separates by ×3.4–×5.9 instead of ±0.02
within ±0.10 noise as in ADR-099.
Adds static/raw.html — per-node 56-subcarrier amplitude bars + RSSI/
broadband traces, no DSP, for live calibration.
ADR-100 documents the technique, boot calibration values for the
operator's deployment (AGC=42/44, both APPLIED), and the verified
three-state separation table.
Operator's household environment showed CSI-variance presence detection
failing — empty room produced HIGHER variance than an occupied room because
ambient WiFi noise (neighbour APs, retransmits, BT-coex) dominated the
broadband-variance signal at multi-meter range.
Deployed a TP-Link TL-WR841N in WISP mode as a dedicated isolated AP for
the sensors:
* Sensors associate only with TP-Link_8340 (clean channel)
* TP-Link bridges to the household AP, NAT-forwards sensor UDP to the Mac
* Mac keeps its primary household-AP association — no LAN reconfig needed
* Empty-room variance dropped 50.7 → 35.8 (-30%)
Replaced presence classification with RSSI MAD-Δ override:
* Per-node rolling 120-sample (~10 s @ 12 Hz) window of frame RSSI
* Metric: mean(|Δrssi|) between consecutive frames — robust to int8
quantisation jitter
* Thresholds tuned for the operator's geometry:
d < 0.20 → absent
< 0.55 → present_still
< 1.10 → present_moving
>= 1.10 → active
* Confidence field temporarily carries raw d for in-field threshold tuning
* CSI-based features (variance, motion_band_power, spectral_power) remain
in features.* for vital-sign signal-quality and multi-node fusion paths
UI / tooling:
* New static/spectrum.html — live signal console: combined classification,
all host-computed features (variance, motion_band, spectral, breathing
band, RSSI, dominant_freq, change_points), per-node FW signals, and a
60-second variance trace. Served via `python -m http.server 8091`.
* static/calibrate.html — simpler per-node motion/presence/RSSI bars
with peak-hold.
Desktop UI / discovery hardening (rolled in here because they came up
during this debug session):
* commands/discovery.rs: HTTP sweep limited to 2..=60 hosts (was 1..=254),
mDNS + UDP-broadcast paths disabled (current RuView FW doesn't advertise
them and they were burning CPU every poll cycle). Per-request timeout
set to 1500 ms with overall budget enforced via tokio::time::timeout +
futures::join_all (replaces the previous sequential select loop that
blocked on slow IPs).
* ui/hooks/useNodes.ts: poll interval 10 s → 30 s.
* ui/pages/Dashboard.tsx + NetworkDiscovery.tsx: merge new scan results
into existing list instead of replacing — discovery races sometimes miss
a node that was found a moment ago.
Firmware tuning:
* edge_processing.c: broadband-variance divisor /3.0 → /30.0 → /5.0
iterated; final /5.0 chosen for multi-meter geometry (sensor 1-3 m
from activity zone). DEBUG_MOTION_DSP scaffolding removed.
* csi_collector.c: CSI_MIN_SEND_INTERVAL_US 20 ms → 4 ms so the host can
see every available frame (real ceiling is the WiFi CSI callback rate).
Documentation:
* docs/adr/ADR-099 — full forensic write-up: measurement tables for sit/
walk/empty, the RSSI-Δ rationale, the WISP setup procedure, calibration
protocol for new deployments, and open items.
Verified end-to-end on hardware (sensors at 192.168.1.17/.19 → TP-Link at
192.168.1.14 → Mac at 192.168.1.21):
* UDP/5006 packets arrive ~12 Hz combined from both nodes
* Empty-room baseline d ≈ 0.49 measured (next: capture sit + walk to
finalize thresholds)
* Vital signs continue to populate (breathing 9–11 BPM stable)
* Two consecutive OTA round-trips remain functional after the change
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>