HLK-LD2402's antenna near-zone (0–70 cm) is a dead spot for its
internal distance algorithm: gate-0 micromotion energy collapses to
zero, and the firmware falls back to a sidelobe pick that lands at
1.5–2 m. Operator sitting 40 cm away saw "180 cm" jumping ±10 cm.
Detect the near-field state from the gate snapshot:
motion[0] > 5k AND motion[0] >= peak_motion_mid AND micro[0] < 3k
Debounce across the last 6 frames (≈1 s) so a single jittery frame
doesn't toggle the UI — gate energies swing 5–30k frame-to-frame
when the target is breathing right against the module.
When the flag is set, the distance pill renders "<70 cm" with a
tooltip explaining that vitals are unreliable at this range; the
recommended sweet-spot is 0.7–2 m.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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>
Previously raw.html lived only at v2/crates/wifi-densepose-sensing-server/static/raw.html.
When the server is started with --ui-path /Users/arsen/Desktop/RuView/ui
(the SPA path) the calibration console returns 404 on /ui/raw.html.
Copy the file into ui/ alongside index.html so a single --ui-path
covers both the SPA and the engineer-facing raw view. The static/
copy in the crate stays as the canonical source (referenced by ADRs
104/107); ui/raw.html is a deploy mirror.
Live at http://localhost:8080/ui/raw.html.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After ADR-117/118 docs sweep (commit 4075b608) extracted Use Cases,
How It Works, Edge Modules, Self-Learning sections from README into
docs/use-cases.md + docs/architecture.md, but two classes of links
were left dangling:
1. README anchor links pointing at section IDs that no longer exist
in README:
#edge-intelligence-adr-041 → moved to docs/use-cases.md
#esp32-s3-hardware-pipeline → architecture detail in docs/
#vital-sign-detection → moved out
#sensing-server → moved out
#-quick-start → renamed during slim
Replaced with deep links into docs/use-cases.md or docs/dev-handbook.md
/ docs/architecture.md where appropriate.
2. Extracted docs (docs/use-cases.md etc.) had path links written from
the perspective of repo root (docs/edge-modules/, v2/crates/...) —
broken once the file moved into docs/. Bulk-rewrote via Python
regex pass:
docs/edge-modules/X → edge-modules/X
docs/adr/X → adr/X
v2/... → ../v2/...
archive/... → ../archive/...
scripts/... → ../scripts/...
plugins/... → ../plugins/...
firmware/... → ../firmware/...
3. docs/use-cases.md self-reference #ai-backbone-ruvector → that
section was never moved; replaced with prose + link to
architecture.md.
Final scan: ZERO dangling anchors in the doc tree. One valid
anchor `#edge-module-list` in use-cases.md points to a local
`<details id="edge-module-list">` block.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a dedicated blocking serial-reader thread that opens the
HLK-LD2402 over a CP2102 USB-UART bridge (default 115200 8N1),
parses ASCII `distance:<cm>\r\n` lines @ ~6 Hz, stores the latest
reading in a static OnceLock<Mutex<…>>, and exposes it via:
GET /api/v1/mmwave/latest →
{ "available": true, "distance_cm": 152, "age_ms": 90 }
{ "available": false } (port absent, stale > 2 s)
UI (Sensing tab) polls the endpoint every visible WS tick and
shows a new blue card "mmWave Radar (24 GHz)" with distance +
age bar. Card hides when unavailable.
CLI:
--mmwave-port /dev/cu.usbserial-1140
--mmwave-baud 115200 (default)
Both optional — server runs as before if the module is absent.
Open failure: single WARN log, reader thread exits, server keeps
serving WiFi sensing.
Verified live: distance 149-153 cm at ~6 Hz, REST returns fresh
readings with age_ms 55-127.
Out of scope (logged in ADR-121): Engineering Mode binary frames,
vitals cross-check vs ADR-021, W-MLP feature fusion, auto-reconnect.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Operator clarified: nodes 1 and 2 (.101 / .100) are ESP32-S3 + OV-camera
boards (sensor_06, sensor_07 in the photo set), NOT YD-ESP32-23. Nodes
3-6 (.102 / .104 / .105 / .106) are the YD-ESP32-23 boards with u.FL
external-antenna connectors (sensor_08, sensor_09).
Impact: Pack E.2 (WiFlow camera-supervised retrain) is closer than
previously assumed — the camera hardware is already deployed at nodes
1 and 2. Path becomes:
1. Extend FW with parallel camera_capture.c → stream MJPEG over UDP/HTTP
2. Run MediaPipe Pose on server (deps already installed in
~/.venv/ruview-train from earlier session)
3. Time-align with existing scripts/align-ground-truth.js
4. Retrain via scripts/train-wiflow-supervised.js --scale lite
The 4 PCB-strip antennas in sensor_02 map 1:1 to nodes 3-6 — drop-in
upgrade once each board is power-cycled to swap the antenna feed.
README now lists the per-node board type, IP, camera/u.FL status, and
which photos show each. No code changes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- CHECKLIST.md: refresh head sha (12e1cf9d), date (2026-05-18),
count (47 → 50 Done), explicit Done entries for ADR-118/119/120
with the full session accuracy trajectory (40.4% → 90.40%).
- .gitignore: stop tracking deployment-specific training artifacts:
v2/data/recordings/ (175 MB each), v2/data/adaptive_model.json
(regenerated on each retrain), v2/data/baseline.json (regenerated
on /api/v1/baseline/calibrate).
- ui/style.css: ship the .sensing-class-label color rules for
present_moving (yellow), waving (purple), transition (orange) —
written during ADR-117 conversation but missed by that commit.
- git rm --cached v2/data/adaptive_model.json (stays on disk; untracked).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds diagnostic endpoint returning the last 30 RAW model labels,
their distribution, the smoother's internal buffer, committed +
candidate labels, and consecutive count. Lets the operator
distinguish "smoothing is sticky" from "model genuinely keeps
outputting the same class" — without that signal, tuning smoothing
parameters is shooting in the dark.
Also relaxes smoothing back to 15/2 (Layer-1 1.5s majority +
Layer-2 200ms confirm). The earlier 30/5 setting was over-damped
because the actual problem was model overfitting, not flicker.
Diagnostic finding on current live data:
transition raw count: 25/30 (83%)
present_still: 2
absent: 2
present_moving: 1
Model believes user is performing sit/stand transitions even when
they're typing at the keyboard. Likely cause: `train_transition`
recording captured ~3s pauses between sit-stand cycles, so the
class signature is broad enough to grab typing/mouse motion. Fix
is data-side (re-record cleaner transition class or add a desk_work
class), not algorithm-side. ADR-120 follow-up notes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous smoothing covered only the adaptive_override path. The 5 other
classification.motion_level writes (amp_presence_override and
amp_classify_from_latest in 3 different tick handlers) wrote raw
values that bypassed the smoother entirely — explaining the lingering
"переключается со скоростью света" complaint after the two-layer fix.
New finalize_motion_label(&mut classification) runs at end-of-tick AFTER
all overrides have settled, applies the same two-layer (30-tick mode +
5-tick confirm) smoothing uniformly to whatever label survived the
priority cascade. Called from 3 sites:
- multi-BSSID tick handler
- feature_state tick handler
- per-node loop in broadcast tick task
adaptive_override now emits raw model label (no double-smoothing).
Verified: 30-second sample, user actively performing transitions,
ZERO flips. Label persisted as `transition` all 30 samples.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous 15-tick majority window still flickered visibly in the live
UI ("переключается со скоростью света"). Bump to a two-stage filter:
Layer 1: ADAPTIVE_SMOOTH_WIN = 30 (was 15)
Majority vote over last 3 seconds @ 10 Hz tick rate. Doubles the
window — sustained signal dominates, brief glitches lose.
Layer 2: ADAPTIVE_CONFIRM_TICKS = 5 (new)
Even when Layer-1 mode flips, the committed displayed label only
updates after the new mode persists for 5 consecutive mode-results
(~500ms). Stops rapid bouncing between near-tied classes.
Effective dwell time: ≥3 seconds before any visible label change.
Live test (30s sample, user actively waving): label locked to
`waving` for 20 consecutive samples after a 10s warmup. No flicker.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After hybrid priority fix (442c03da) the W-MLP labels reach the live UI
but at ~10 Hz tick rate they flip between adjacent classes (transition /
present_still / present_moving) too fast to read. Adds majority-vote
smoothing over last 7 ticks (~700ms window) — snappy enough for real-
time feedback, stable enough that the displayed label persists long
enough to be readable.
Implementation: static ADAPTIVE_LABEL_HISTORY VecDeque + helper
adaptive_label_smooth() called at end of adaptive_override after the
model emits its raw decision. Mode of last 7 raw labels wins; ties
break sticky to the previous committed label.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
W-MLP claimed 90.4% training accuracy in ADR-120 but live UI kept
showing only the 4 baseline classes (absent/still/moving/active).
Root cause: 3 amp_presence_override / amp_classify_from_latest call
sites ALWAYS overwrite classification.motion_level after
adaptive_override runs, regardless of what the model decided. The
rule-based path only knows 4 classes; the 2 new ones (waving,
transition) emitted by the adaptive W-MLP were silently clobbered
every tick.
Hybrid priority:
rule-based wins → absent / present_still / present_moving / active
(ESPectre-style F1>96%, battle-tested)
adaptive wins → waving / transition (exclusive to ADR-120 W-MLP)
Implementation: new helper adaptive_owns_class() + ADAPTIVE_EXCLUSIVE_CLASSES
constant. Each of the 3 rule-based override blocks (multi-BSSID tick,
feature_state path, per-node loop) now guards on `if !adaptive_owns_class(
classification.motion_level)`. Skips the overwrite when the adaptive
model has just emitted a new class.
Live verification (30s sample):
transition: 14/30 (47%) — visible in live UI for the first time
present_still: 10/30 (33%)
present_moving: 1/30
absent: 1/30
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Audit on 6-node training data (151,329 frames) found 21 multicollinear
pairs (|r|>0.85), one dead feature (amp_min constant 0), and only node[0]
used in 8 of 15 features. Top per-feature F-stat = 15,497 but accuracy
stuck at 44.4% — classifier couldn't extract the signal that physical
sensors were already capturing.
Refactor:
- Drop 8 dead/redundant features (amp_min, amp_range, breath_bp,
spec_pow, motion_bp, amp_mean, amp_max, amp_iqr, amp_kurt).
- Keep 4 globals: variance, mean_rssi, dom_hz, change_pts.
- Add per-node features × all 6 nodes: amp_std, amp_skew, amp_entropy.
- New N_FEATURES = 22 (was 15). Z-score normalisation kept.
API change: features_from_runtime now takes &[(u8, &[f64])] — caller
must supply per-node amplitudes. New helper current_per_node_amps()
reads AMP_HIST.nbvi_history.back() for all live nodes.
Old data/adaptive_model.json removed (incompatible 15-feature schema).
Retrain result on same 151k frames:
44.4% → 49.58% accuracy (+5.2 pts)
Total improvement vs 2-node baseline (40.4%): +9.2 pts.
Live confidence distribution now meaningful (0.30-0.85) vs pre-fix
near-uniform 0.04-0.10. Sensor placement matters: n6 (near door, far
from AP) sep_ratio=0.60 best; n1/n5 (near AP) ~0.01-0.06 nearly dead.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Audit fix bundle (10 areas; details in ADR-117 + commit body below).
Server (main.rs / wiflow_v1.rs):
- UDP receiver filters loopback/multicast/unspecified before NODE_ADDRS
registration. Defends against `cargo test` cross-talk that spawned
250+ ping zombies on the production server's :5005 port.
- csi_keepalive_task pre-reaps `/sbin/ping -i 0.040` orphans at task
entry. macOS doesn't propagate parent death, so killed servers used
to leave init-parented pings running indefinitely.
- run_wiflow_inference stamps real classifier confidence onto every
keypoint (was hardcoded 1.0) — reads 0.037 on live data, honest.
- run_wiflow_inference clones only the tail-20 frames inside the lock,
not the full 600-deep VecDeque (~270 KB → ~9 KB per tick).
- wiflow_v1::build_input_from_history: zero-pad dead channel slots
instead of duplicating subcarrier 0 across all of them. Comment said
"zero the rest", prior code did the opposite.
- GET / now 308-redirects to /ui/index.html; API index moved to /api.
UI (ui/index.html, ui/components/LiveDemoTab.js):
- <section id="sensing"> gets a <div id="sensing-container"> child so
app.js::SensingTab.mount has its mount point. Sensing tab was
permanently blank.
- LiveDemoTab.fetchModels: only inject WiFlow into the dropdown if no
RVF model is already active. Prevents silent flip back to WiFlow
after every poll.
Tests (multi_node_test.rs):
- test_multi_node_udp_send probes 127.0.0.1:5005 first; if bind fails
(e.g. a dev server is running), skip the send. Two-layer defense
with the server-side filter above.
Docs (CHECKLIST.md, ADR-115, espectre-gap-analysis.md, ota-pipeline.md):
- CHECKLIST head sha + count refreshed (43→47 Done, head 0ec1e4b0,
ADR range to 001-117 with ADR-111 noted as intentionally absent).
- ADR-115 typo fixes: "ADR-100" → "ADR-110" (TP-Link WISP),
"ADR-111" → "ADR-109" (AP-MAC tracking actually lives there).
- gap-analysis "Still open" table: 8 shipped items annotated with
commit hashes; remainder reclassified Deferred with reason.
- ota-pipeline.md: new "Operator REST endpoints" section listing
/ota/recalibrate (ADR-109) and /ota/set-target (ADR-115) with
unauthed + bearer-token curl examples.
Verified post-restart:
- exactly 2 ping children, both parented to current PID, one per real
sensor IP, no 127.0.0.1.
- GET / → 308 → /ui/index.html.
- /api/v1/info: pose_estimation=true, version 0.3.0.
- /api/v1/pose/current: 17 COCO keypoints, confidence 0.037 (real).
- cargo test --workspace: 13 passed / 0 failed / 5 ignored.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
LiveDemoTab.fetchModels() now probes /api/v1/info after the RVF
model list; when features.pose_estimation is true (i.e.
--wiflow-model was loaded), inserts a virtual 'WiFlow-v1 (lite,
186K params, --wiflow-model)' option, marks it active, and
populates name + PCK 0.929 in the panel.
Cosmetic only — does not change inference path or pose_keypoints
flow. Closes the UX inconsistency where the badge said MODEL
INFERENCE but the dropdown said 'No model loaded'.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New REST endpoint on FW HTTP server (port 8032) writes
csi_cfg/target_ip + target_port to NVS and reboots. Body is
plain text "IPv4:PORT" (e.g. 192.168.0.103:5005). Verified on
both 192.168.0.100 and 192.168.0.101 — sensors silent after
Mac IP move came back online in ~3 min instead of needing USB.
Same PSK auth as /ota/recalibrate (ADR-050). Strict body parser
rejects malformed input before touching NVS. Binary size +1 KB.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1000 idle + 1000 motion synthetic-but-parameter-matched CSI
frames live under tests/fixtures/replay_*.jsonl; the cargo test
`replay_2000_packets_f1_above_threshold` replays each through
amp_presence_override and asserts F1 ≥ 0.85.
Fixtures generated by scripts/generate-replay-fixtures.py (seeded
42/43). Parameters mirror data/baseline.json: per-node baseline
mean from live recording, idle σ=1.8 % per-frame noise, motion
±40 % envelope at 0.15 Hz (long enough to swing the classifier's
4.5 s rolling CV) plus 5 % per-frame noise.
Current run: F1 = 1.000 (tp=822, fp=0, tn=822, fn=0; 178 warmup
frames per fixture excluded). 0.85 threshold leaves headroom for
classifier evolution.
Test resets per-node history + per-sub baseline between fixtures
so each run is hermetic; keeps the per-node baseline-CV so the
ADR-103 universal-threshold path stays exercised.
Co-Authored-By: claude-flow <ruv@ruv.net>
--baseline-profile {single,auto,day,night} (default single).
* single — legacy data/baseline.json path, unchanged.
* auto — picks data/baseline.{day,night}.json by local hour
(day=07:00-20:59), hot-swaps every 5 min on transitions.
* day/night — force one of the profile files, no switching.
Missing profile files fall back to data/baseline.json with a
warning, so migration is incremental — operator can record one
profile at a time without breaking the deployment.
Watch task is a no-op outside `auto` (no log noise, no tokio slot).
Smoke: --baseline-profile auto with no day.json → "falling back
to data/baseline.json" warning then normal startup; watch task
enabled.
Co-Authored-By: claude-flow <ruv@ruv.net>
scripts/record-baseline.py and capture_baseline_to_disk now
compute per-subcarrier circular mean + variance of phases when the
WS stream carries them (ADR-106). Saved as per_subcarrier_phase_mean
+ per_subcarrier_phase_var in baseline.json.
Server loads them into PHASE_BASELINE_PER_SUB; phase_drift_update
computes a per-tick score (mean circular distance / π over
subcarriers with baseline variance < 0.30) and stores it in
PHASE_DRIFT. Surfaces as PerNodeFeatureInfo.phase_drift_score
(skip-if-none). Honesty contract: emits None below
PHASE_DRIFT_MIN_USABLE = 16 usable subcarriers.
Legacy baselines without phase fields fall back to amplitude-only
behaviour with no change.
Co-Authored-By: claude-flow <ruv@ruv.net>
- ADR-112 (Multi-AP signal_field via MultistaticFuser) added.
- ADR-105 closes the Real-signal_field Open Item.
- CHECKLIST: ADR-107/112/109/105 closures recorded; out-of-scope
items moved to a Deferred section with explicit reasons.
Co-Authored-By: claude-flow <ruv@ruv.net>
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>
PoseDetectionCanvas polls /api/v1/pose/stats every 30 s. When
model_loaded === false (the default — no trained pose model present),
the canvas is hidden and a "No trained pose model loaded" overlay
explains why, pointing the operator at the Sensing / Hardware tabs
for the channels that are still active.
renderPoseData() also short-circuits on modelLoaded !== true so any
WS frames that slip through during the poll interval can't paint a
misleading skeleton.
Closes the last Open Item in ADR-105.
Co-Authored-By: claude-flow <ruv@ruv.net>
signal_field_from_multistatic renders a 20×20 floor-plan heatmap by
overlaying isotropic Gaussians at each ESP32 node's configured 3D
position, scaled by cv²(fused_amplitude) × cross_node_coherence.
Replaces ADR-105 D6's zero grid only when ≥2 nodes are active AND
positions are configured (--node-positions); else preserves the zero
grid (ADR-105 honesty contract).
Honestly framed as a coverage × activity map, not a target-position
estimate — commodity ESP32s have no phase-coherent ranging.
Verified end-to-end: 320/400 cells non-zero with two live sensors
at (1.5,2,1) and (-1.5,2,-1), all-zero on single sensor / no-position
deployments. cargo test --workspace passes (313 tests).
Co-Authored-By: claude-flow <ruv@ruv.net>
ADR-109 documents POST /ota/recalibrate + gl_ap_mac NVS binding
and supersedes the two Open Items in ADR-108.
Co-Authored-By: claude-flow <ruv@ruv.net>
Two FW changes closing both Open Items in ADR-108:
1. POST /ota/recalibrate on port 8032 erases csi_cfg/gl_agc, gl_fft,
gl_ap_mac then esp_restart() — operator can force a full re-cal
without USB. Reuses ota_check_auth Bearer-token guard.
2. New csi_cfg/gl_ap_mac (6-byte blob) saved alongside AGC/FFT.
Boot-time short-circuit compares saved BSSID with current
esp_wifi_sta_get_ap_info().bssid; mismatch → discard cache, run
full calibration. All-zero (legacy NVS without MAC) treated as
wildcard so existing deployments don't re-cal on first upgrade.
Verified by OTA-flashing both sensors (192.168.0.100, .101) and
calling /ota/recalibrate via curl — both returned the expected JSON
and came back online ~15 s later running fresh calibration.
Co-Authored-By: claude-flow <ruv@ruv.net>
Upstream merged ADR-099-midstream-introspection-tap during this
session (PR #554, commits 900b877c..ce330422 on origin/main). Our
existing ADR-099-tplink-wisp-deployment-and-rssi-presence has a
different topic but the same number. Rather than fight the
numbering, slot ours up to ADR-110 (next free) and let upstream
own ADR-099.
git mv ADR-099-tplink-wisp-...md → ADR-110-tplink-wisp-...md
bulk sed `ADR-099` → `ADR-110` across all our docs from this
session (ADR-100..108, refs/,
CHECKLIST.md, self-reference)
No code changes; no semantic change beyond the number. Resolves the
collision before rebase against origin/main.
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>
Uniform u8 field on both enhanced_* JSON objects so downstream
consumers can decide whether to trust a multi-AP enhancement
that, on a single sensor, may have run with only 1 AP. Mirrors
the existing contributing_bssids / bssid_count counts under a
single name across motion and breathing.
Co-Authored-By: claude-flow <ruv@ruv.net>
Compact, easy-to-find checklist of every shipped feature + every
open item from the 2026-05-15..17 session sweep. Each line carries
its ADR reference and (where relevant) the implementing commit.
Three sections:
✅ Done — server, FW, ops, docs
⏳ Open — priority-sorted (high-value-low-effort first,
bigger items last, hygiene at bottom)
Reference — pointers to detail docs
Lives at the repo root so `ls` / GitHub README sidebar / any agent
opening the repo finds it first. Pairs with espectre-gap-analysis.md
which carries the deeper technique-by-technique reasoning.
≤ 200 lines per the project's docs convention.
Cross-referenced every ADR Open Items section + both reference docs
against the actual implementation state on the branch. Closed items
the session shipped, kept stale "will be done in ADR-X" forward-refs
honest:
ADR-100 ✅ NBVI port (ADR-102), RSSI parse fix (3393c1e8), idle-
channel keepalive (ADR-106). ⏳ Tailscale-target still open.
ADR-101 ✅ per-sub baseline-drop / off-axis sit (both via ADR-104).
⏳ CV saturation above ~30 % still open.
ADR-102 ✅ Step 3 FP-rate validation (ADR-104 D4).
ADR-103 ✅ all three open items closed (REST endpoint via ADR-107,
per-sub comparison via ADR-104, auto-recalibrate via ADR-107).
ADR-106 ✅ FW-side µs timestamp via OTA (b787f40a).
espectre-techniques.md:
- NBVI: now "DONE (all 4 NBVI steps)" instead of "missing Step 3".
- Persisted calibration: split into "server (ADR-103) + FW NVS (ADR-108)"
with intentional design note for NBVI staying server-side.
espectre-gap-analysis.md:
- NBVI Step 3, gain-lock NVS, baseline persistence, threshold
persistence all flipped to ✅ in the per-section comparison tables.
- Priority list restructured into "✅ Done in this session" (10 items)
+ "⏳ Still open by impact" (14 items) with reality-checked
estimates. Top 3 open: HA via MQTT, 2 000-packet test suite,
per-sub delta sparkline in raw.html.
Verbatim Pace Part-2 article still informs the gap structure; nothing
was removed from his pipeline, only RuView's column updated.
ADR-104: documents the off-axis presence channel that fires
present_still when per-subcarrier amplitudes drift ≥10% from the
saved per_subcarrier_mean baseline, plus the NBVI Step 3 FP-rate
validation (K candidate sweep, smallest-FP wins). Implementation
shipped in 6212b17e.
ADR-108: documents the FW NVS persistence of gain-lock values
(csi_cfg/gl_agc + gl_fft), the one-shot load at first packet after
boot, the save after every successful calibration, and the safety
MIN_SAFE_AGC guard on restored values. Implementation shipped in
3779bb76; flashed to both sensors via OTA.
Both ADRs ≤ 200 lines per the project's docs convention. Open items
recorded so future agents can pick up: per-sub drift age check,
phase-domain drift, REST recalibrate endpoint, AP-MAC tied cache.
ADR-108: after the first successful gain-lock on FW, save the AGC and
FFT median values to NVS (namespace "csi_cfg", keys "gl_agc" / "gl_fft").
On every subsequent boot the FW loads them and immediately calls
phy_force_rx_gain / phy_fft_scale_force without waiting 300 packets
(~3-12 s) for fresh calibration.
Mechanics:
rv_gain_load_from_nvs / rv_gain_save_to_nvs — small NVS helpers in
the gain-lock module.
rv_gain_lock_process — `s_nvs_checked` static gate triggers a one-
shot load on the first packet after boot. If
a saved AGC ≥ MIN_SAFE_AGC is found, lock
immediately + mark locked. Otherwise fall
through to the existing 300-packet sampler.
Existing lock branch — after the median + force_*, save to NVS so
the next boot has the values.
Verified live: second OTA → 44 Hz raw CSI at WS in the first 3-s
sample after boot (was ~5-12 s gap before). Both nodes flashed via
WiFi (no USB), no MIN_SAFE_AGC skip in operator's deployment (AGC=44).
Tradeoff: NVS values are tied to sensor location + AP MAC + channel +
antenna. If the operator moves the sensor or swaps the AP, stale
values may be slightly off-optimal until they re-trigger calibration.
Today: erase NVS keys via console; future: dedicated FW endpoint.
ADR-102 Step 3 (FP-rate validation) — `nbvi_select_top_k` no longer
takes the literal top-K. Evaluates candidate K ∈ {6,8,10,12,16,20}
over the quiet window: for each, computes per-subset broadband CV
on a sliding sub-window and counts how many sub-windows cross the
moving threshold (0.10). Picks smallest K with fewest "false
positives" (ties broken by smallest total-NBVI). Defends against
the rare case where the literal top-12 happens to include a
subcarrier overlapping a noise source — the FP count surfaces it
and a tighter K wins.
ADR-104 (off-axis presence via per-subcarrier drift) — when
baseline.json carries `per_subcarrier_mean` for a node, server
loads the vector into AMP_BASELINE_PER_SUB. Each classifier tick
computes `drift = mean |Δ amp / baseline|` over the recent
AMP_SHORT_WIN frames vs that baseline. Drift ≥ 10 % → trigger
`present_still` even if broadband mean barely shifted. Catches the
case where the operator is in the room but off the AP→sensor line,
so individual subcarriers are perturbed without a global drop.
amp_node_level / amp_node_snapshot — per-node drift trigger
amp_classify_from_latest — cross-node MAX drift trigger
Drift channel is opportunistic: if baseline.json predates ADR-104
(no per_subcarrier_mean field), drift = 0 and classifier behaves
exactly as before. Re-record baseline via the calibrate-empty button
to populate the field and activate the channel.
Closes ADR-106 open item #1: server now receives the real WiFi RX
timestamp from the sensor's hardware controller instead of stamping
on receipt with SystemTime.
FW (csi_collector.c csi_serialize_frame):
Append uint32_t = info->rx_ctrl.timestamp (µs since FW boot,
monotonic per ESP-IDF docs) as 4 trailing bytes after I/Q data.
Header layout unchanged → old server parsers still work (they
ignore tail bytes per existing `if buf.len() >= expected` check).
Server (parse_esp32_frame):
Opportunistically read trailing 4 bytes as u32 LE into
Esp32Frame.sensor_timestamp_us. Old FW → None, new FW → Some(µs).
udp_receiver_task uses sensor timestamp when present, falls back
to server SystemTime if not. Result published as NodeInfo.timestamp_us.
Flashed both sensors via OTA (no USB dance):
192.168.0.101: ota_0 → ota_1 ✓
192.168.0.100: ota_1 → ota_0 ✓
Live verify: WS timestamps now sub-1e12 (sensor monotonic, ~39s
after FW boot), Δ between successive frames = 43.3 ms ≈ 23 fps
sampling jitter, sub-ms precision. Cross-node skew = sensor boot
time delta (here ~292 ms). For sync the host can subtract per-node
boot offset learned from the first packet pair.