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.
Saves the comprehensive OTA pipeline reference written by another
agent so future sessions don't lose the diagnostic flowchart or the
"three FW prerequisites" causal chain.
Tested live against current FW (v0.6.4): port 8032 reachable on both
sensors, scripts/ota-deploy.sh round-trip works, both nodes
successfully switched partitions (ota_0 ↔ ota_1) without USB+BOOT
dance. OTA is the supported path for future FW changes from this
session — sensor µs timestamp (ADR-106 open item), NVS persistence
of gain-lock (gap-analysis #5), and any larger FW work.
Kept whole (329 lines, over the usual 200 line cap for docs) because
the flowchart and pitfall table lose meaning if split. The cap is a
guideline for new project ADRs; a verbatim recipe is justified by
diagnostic value.
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