--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
Eliminates the manual `scripts/record-baseline.py` ritual:
REST endpoints
GET /api/v1/baseline — current per-node baseline +
last_written_sec_ago + calibration_status
POST /api/v1/baseline/calibrate — start a background capture, optional
JSON body { duration_sec, trim_sec,
clean_window_sec, out }. Returns
immediately; status transitions
idle → running → complete | error: ...
Auto-recalibrate background task
Watches the live classifier. When motion_level=="absent" and CV<0.08 for
--auto-recalibrate-quiet-sec (default 1800 = 30 min) AND the last write
is older than --auto-recalibrate-min-age-sec (default 3600 = 1h),
silently re-runs the capture and live-reloads the override map. No
operator action needed.
Implementation
capture_baseline_to_disk() — in-process port of record-baseline.py:
trim head/tail, scan windows for lowest-
CV chunk, compute full-broadband stats,
write baseline.json, hot-reload override.
BASELINE_BUS — broadcast bus carrying every sensing_update
JSON so the capture can read live frames
without re-binding any sockets.
BASELINE_LAST_WRITTEN — SystemTime tracker for the cool-down.
BASELINE_CALIBRATION_STATUS — status string for the REST endpoint.
Verified live: POST /api/v1/baseline/calibrate (5 s test window) ->
capture wrote `/tmp/test_baseline.json` with n_samples=86 per node,
override hot-reloaded (visible via GET /api/v1/baseline). Real baseline
restored on next server restart from data/baseline.json.
Closes the first ADR-106 open item without an FW change. On every
raw-CSI frame we now stamp `ns.latest_timestamp_us` with
SystemTime::now() in µs since UNIX epoch. NodeInfo.timestamp_us
surfaces it on WS via the already-wired skip_serializing_if guard.
Accuracy is wall-clock + Mac monotonic + LAN jitter ≈ ~1 ms. Verified
cross-node skew ts(node1) - ts(node2) = 1556 µs in a single test, well
within the 5-10 ms tolerance needed for FFT-based vital-signs
correlation across sensors.
Sensor-side ESP-IDF rx_ctrl.timestamp (true RX-time µs) is still
better and remains on the open list for a future FW header bump
(reserved bytes [18..19] are only 2 of the 4 we'd need — header
extension required, opt-in via new magic).
Records the two-part change that gets the maximum raw signal off the
sensors so the future model — and current fine-motion detection —
has everything the parent project describes:
D1 NodeInfo exposes phases[56], n_antennas, noise_floor_dbm,
timestamp_us in the WS payload (was amplitude-only).
D2 NodeState stashes latest phases/noise/timestamp/antenna count
so build_node_features can populate the new fields uniformly
without a parallel phase_history buffer.
D3 csi_keepalive_task spawns managed `ping` children per
discovered sensor address; replaces the operator's hand-run
`ping -i 0.05 …` workflow. CLI --csi-keepalive-pps controls
rate (default 25), 0 disables.
D4 Why ICMP not UDP: sensor rejects closed-port UDP before its
CSI callback fires; ICMP is handled in WiFi RX path regardless.
Verified: 55.6 Hz raw CSI per node with no shell ping; both
amplitude[56] and phases[56] populated; noise_floor=-91 dBm.
Two impl commits already on the branch: 4daa2c9b, 8489efe9.
Continuation of ADR-106 (max raw signal off sensors).
Operator was running `ping -i 0.05 192.168.0.101 &` by hand to keep CSI
callbacks firing on the sensors. Server now does this itself:
* Track per-node source addresses in NODE_ADDRS, populated on every
recv_from via a cheap magic-byte peek (works for 0xC5110001 raw,
0xC5110002 vitals, 0xC5110006 feature_state).
* csi_keepalive_task spawns one `ping -i <interval> <ip>` child per
discovered sensor, re-spawns if the child dies or the sensor IP
changes. Default 25 pkt/s via --csi-keepalive-pps; 0 disables.
Why ICMP, not UDP: tried a UDP-based keepalive (send tiny UDP packet
to sensor's known src port). Sensor's closed-port UDP rejected before
the CSI callback fired on its side. ICMP echo gets handled in the
WiFi stack regardless of any user-space listener so CSI fires reliably.
Verified live, no external `ping` running:
keepalive: ping -i 0.040 192.168.0.101 for node 1
node 1: 55.6 Hz raw CSI (amp+phase populated)
node 2: 55.6 Hz raw CSI (amp+phase populated)
Combined with ADR-106 NodeInfo fields (phases, noise_floor_dbm,
n_antennas, timestamp_us) this gives downstream consumers — UI,
classifier, future ML model — the full complex CSI signal at high
rate without any operator-side ritual.
Operator asked for maximum raw signal off the sensors so a future
trained pose / fine-motion model has everything it needs, instead of
only the amplitude scalar we surfaced before. Adds four fields to
NodeInfo:
phases: Vec<f64> per-subcarrier atan2(Q,I), radians
n_antennas: u8 RX antenna count from WiFi driver
noise_floor_dbm: i8 noise floor reported by ESP-IDF
timestamp_us: u64 per-frame µs timestamp from the sensor
Each is `skip_serializing_if = zero-or-empty` so feature_state ticks
(which carry no raw CSI) stay slim in the WS payload — only real raw
CSI frames populate them.
NodeState gains: latest_phases / latest_noise_floor /
latest_n_antennas / latest_timestamp_us (per-node stash, replaces
having to keep a parallel phase_history). The raw-CSI ingest path
populates these on every frame.
Verified live: WS now emits 185 messages over 4 s (~46 fps) with
both amplitude[56] and phases[56] populated; noise_floor reports -91
dBm; n_antennas reports 1 (ESP32-S3 single antenna).
Records the cleanup of five fake outputs the rich Docker UI exposed
when pointed at our backend without a trained pose model loaded:
D1 derive_pose_from_sensing → Vec::new()
D2 pose_current → gated on s.model_loaded
D3 pose_stats → drop hard-coded average_confidence 0.87
D4 pose_zones_summary → drop fabricated zones, report real presence
D5 api_info.pose_estimation → reflects s.model_loaded
D6 generate_signal_field → returns zero-filled grid (was procedural)
Two implementation commits already on the branch: 9aa027e9 and 30244d27.
Audit table confirms /api/v1/sensing/latest now carries only real
ESP32-derived state. Out-of-scope items (--source simulate already
disabled; --pretrain/--train synthetic fallbacks are explicit dev
flags; vital_signs already gated on real detection) are documented
so the next reader doesn't re-audit them.
Continuation of ADR-105 (no synthetic outputs in production runtime).
The 20×20 SignalField heatmap was generated by mapping subcarrier
index k to angle 2π·k/N and dropping a Gaussian hotspot — a totally
fabricated spatial layout. A single sensor has no directional info
so the resulting heatmap had no correspondence to where anything
actually was in the room; UI showed believable-looking but
physically meaningless hotspots. Operator asked for boots-on-the-
ground honesty.
`generate_signal_field` now returns a zero-filled 20×1×20 grid. UI
renders blank, which is the truthful state until a real multistatic
localizer is wired (multi-AP attention from ADR-008 or the
`MultistaticFuser` already in code).
Audit of remaining fields confirmed they are either:
- already gated on real data (vital_signs returns None when br < 1 BPM,
persons/pose_keypoints/posture/signal_quality_score all None without
model loaded),
- or processed from real CSI (classification, features.mean_rssi,
features.variance, enhanced_motion when multi-AP pipeline active).
`--source simulate` was already disabled by an earlier change
(exit code 2). `--pretrain` and `--train` synthetic fallbacks remain
in code as developer tools but never touch the runtime sensing path.
Operator inspected the rich Docker UI tied to our backend and noticed
the dashboard showed a 17-keypoint skeleton even with no DensePose
model loaded. Tracing it: `derive_pose_from_sensing` synthesized
geometric placeholders, `pose_stats.average_confidence` was hard-coded
0.87, `pose_zones_summary` invented zones 2/3/4 as "clear", and
`/api/v1/info.features.pose_estimation` claimed `true` regardless.
All cosmetic noise that hid the real capability gap.
Changes:
* `derive_pose_from_sensing` is now an inert `Vec::new()` stub.
Heuristic logic kept in `derive_single_person_pose` (dead-code-warned
out by the rustc unused-fn lint) for the day someone wires a real
trained pose model in.
* `pose_current` returns persons only when `model_loaded == true`; the
endpoint always includes `model_loaded` so the UI can decide what
to render.
* `pose_stats` drops the fake `average_confidence: 0.87`.
* `pose_zones_summary` reports `zones_configured: 0` and an empty
`zones {}` instead of fabricating four zones.
* `api_info.features.pose_estimation` now mirrors `s.model_loaded`.
Sensing endpoints (`/api/v1/sensing/latest`, `/ws/sensing`) are
unchanged — they always carried real ESP32-derived data per ADR-101.
Catalogues, section-by-section against Pace's Part-2 article, every
ESPectre technique RuView has and does not have, plus a prioritized
roadmap (9 items, NVS persistence and FP-rate validation top of list).
Replaces the 8-item inline "open items" stub in espectre-techniques.md
with a 1-line forward link. Both files stay ≤ 200 lines per the docs
convention.
* ADR-101 raw-amplitude presence/motion classifier — per-node and
cross-node fusion logic, hysteresis, per-node UI surface
(`PerNodeFeatureInfo.classification` override).
* ADR-102 server-side NBVI subcarrier selection — formula, dead-zone
gate, ESPectre Step-1 quiet-window finder, why we split FULL vs
NBVI-subset broadband.
* ADR-103 persistent baseline + universal threshold normalization —
JSON schema v2 at `v2/data/baseline.json`, FULL-broadband over
NBVI for cross-restart stability, `norm_cv = cv / baseline_cv`
with universal 3×/6× gates, recording script workflow.
* Updated espectre-techniques.md to reflect the DONE items (Steps
1+2+4 of NBVI, baseline persistence, universal threshold) and the
remaining open items in priority order.
Each ADR ≤ 200 lines per the operator's docs convention; deep detail
lives in `docs/references/espectre-techniques.md` (also ≤ 200) which
the ADRs link to. README.md and CLAUDE.md unchanged (no extra
content added; existing >200-line state pre-dates this session).
Pace's Problem #3 ("threshold=1.0 means different things on different
devices") solved by normalizing the runtime CV against the empty-room
baseline CV measured during calibration.
norm_cv = current_cv / baseline_cv
gates: norm_cv ≥ 3.0 → present_moving
norm_cv ≥ 6.0 → active
Baseline CV loaded per-node from data/baseline.json (full_broadband_cv_pct).
When no calibration loaded, falls back to absolute gates (0.10 / 0.22)
that were deployment-tuned earlier — keeps backwards compatibility.
Both per-node `amp_node_level` and global `amp_classify_from_latest` use
the same normalization. On the operator's deployment with baseline CV
~4 %, the universal 3×/6× gates map to ~12 %/24 % absolute — same numbers
the hard-coded thresholds had, but now any-room-portable.
Problem from ADR-103 v1: persisted NBVI-subset mean (19.86 in operator's
recording) drifted out of comparability after server restart because
NBVI re-selected a different top-12 subset, yielding a different mean
from the same channel. classifier saw current/baseline ratio > 1 even
in clearly empty room.
Fix:
1. Separate FULL-broadband mean (all non-zero subcarriers) from
NBVI-subset mean in amp_presence_override. NBVI subset still drives
CV / motion sensitivity. FULL is what gets compared to the
persistent baseline — stable across NBVI re-selection.
2. baseline.json schema v2: full_broadband_{mean,p50,p95,std,cv_pct}
replaces NBVI-only p95_amp/mean_amp. Loader prefers full_*; falls
back to legacy fields for backward compat.
3. NBVI Step 1 quiet-window finder (ESPectre): nbvi_select_top_k now
slides a window across the calibration history, picks the lowest-CV
sub-window, and ranks subcarriers using only that. Robust to brief
motion during the calibration buffer.
4. scripts/record-baseline.py v2: emits v2 schema, computes
full-broadband stats per node, trims head/tail transients, picks
cleanest 30-s sub-window, also saves per_subcarrier_mean for future
subcarrier-level comparison.
Operator workflow now: step out → run script → restart server →
forget about the empty-room ritual forever.
* 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.
After 3393c1e8 made FW emit ~80 % feature_state packets and ~20 % raw
CSI, the server's feature_state path was overwriting NodeInfo.amplitude
with vec![] on every feature_state tick. raw.html's per-node bar chart
ended up freezing for hundreds of milliseconds between rare raw-CSI
packets, and /api/v1/sensing/latest mostly snapshotted an empty amps
vector even though raw CSI was flowing.
Fix: in the feature_state SensingUpdate builder, hand out
ns.frame_history.back() (the last raw amps vector that the raw-CSI
path pushed) instead of an empty Vec. Bars now refresh on every WS
update (verified: 100/100 updates carry amps in a 4-s sample, was
~20/100 before the patch).
Classifier behaviour unchanged — amp_presence_override still runs only
when actual raw CSI arrives; this only affects what the UI displays.
Ports Pace's NBVI = α·(σ/μ²) + (1-α)·(σ/μ) (α=0.5) into the
amp_presence_override classifier. Per node, accumulates a 30-second
ring of full amplitude vectors, every ~5 s ranks the subcarriers,
picks top-12 by lowest NBVI, then computes broadband mean and CV ONLY
on that subset instead of all 56 subcarriers.
Live impact on the operator's deployment (idle room, 2 pps ping):
node 1 CV: 5% -> 3.1% (-38 %)
node 2 CV: 7% -> 3.9% (-44 %)
Thresholds tightened proportionally to match the new baseline:
active: 30 % -> 22 %
present_moving: 15 % -> 10 %
This lets the detector catch subtler motion (e.g. waving while seated)
without raising the false-positive rate above what we had before.
Implemented entirely server-side — no firmware change, no second
flash cycle. Algorithm parameters in const block for easy retuning.
* 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>
Adds the scaffolding for Narrow-Band Vital Information ranking: an
exponentially-weighted moving variance per subcarrier (alpha = 0.02
→ tau ≈ 10 s at 5 pps), refreshed every 25 frames into a stable_bin
mask = bins whose EMA variance is below the across-band median.
The intended payoff is to drive per-node CV in STILL down by averaging
broad_mean_amp_history over quiet bins only (instead of all 128), so
ADR-101's STILL/EMPTY classifier separates them at a smaller body block.
Activated path is REVERTED in this commit on purpose. Quiet bins by
construction barely move, so windowed variance of their mean collapses
to ~0 and motion_energy goes constant. Empirical verification 2026-05-17:
motion_score pinned at 0.013/0.021 with std=0 across 125 frames after
turning quiet-only averaging on; reverted to full-band push_val for
motion_energy with a comment explaining why.
The right shape is a second channel in rv_feature_state_t carrying
"baseline_quiet" alongside motion_score so the server can use one for
classification and the other for motion gating — that's an additive
protocol bump and a separate change. EMA state lands now so we don't
have to wire it back from scratch when we do it.
Also kept from the earlier session: the n_subcarriers > 128 truncate
fix (root cause of motion_energy = 0 — process_frame used to early-
return on 384-byte CSI frames from this silicon) and the broadband-mean
amplitude history that feeds Step 8.
Co-Authored-By: claude-flow <ruv@ruv.net>
Two server-side parsers (csi.rs::parse_esp32_frame and the duplicate in
main.rs) read every field after `n_antennas` from offsets shifted by 2
bytes — n_subcarriers as u8 instead of u16, sequence at 10..14 instead of
12..16, rssi at 14 instead of 16. The saturating_neg() workaround hid the
bug by always forcing a negative dBm value, so the trace looked plausible
but was actually a slice of mid-sequence number. ADR-100 D3 documented
this as an open item; this commit closes it.
Adds two regression tests in csi.rs (header-offset round-trip with
distinctive values per field, plus 20-byte boundary case) so the layout
contract can't drift again without CI catching it.
Even with both parsers correct, RSSI never reached the UI because the
firmware now ships only rv_feature_state_t (0xC5110006) — raw CSI
(0xC5110001) is no longer hot. rv_feature_state had no RSSI field;
both parsers fell back to rssi: -50 hardcode.
To fix without a protocol bump: repurpose the first byte of the trailing
`reserved` field (offset 54) as `int8_t rssi_dbm`. Firmware fills it from
radio_ops::get_health()::rssi_median_dbm in emit_feature_state. Server
reads buf[54] as i8; 0 means "not measured yet" → keeps the historical
-50 fallback for backward compat with pre-update nodes.
Verified live on TP-Link WISP (192.168.0.100/101):
node 1: -54 dBm node 2: -63 dBm (was plateau -50.0 fallback)
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 %.
After ADR-100 gain-lock reveals a clean baseline, the broadband CV of
mean amplitude separates EMPTY/STILL/WALK by 3-6× on the operator's
deployment where RSSI MAD-Δ overlapped within noise. Adds:
amp_presence_override(node_id, amps) — per-frame: rolling 4.5 s
short window for CV, 60 s long window for 95th-percentile baseline,
cross-node fusion (MAX CV gate, ANY baseline-drop → still),
3 s motion hysteresis to bridge step pauses.
amp_classify_from_latest() — readonly fusion for feature_state
(0xC5110006) and adaptive-model paths that don't carry raw amps.
Wired into the three SensingUpdate-producing paths (raw CSI,
feature_state, adaptive model). Marks rssi_presence_override as
dead_code, kept for reference.
Live test (10 samples @ 3 s):
walk: present_moving, CV 41-53 %, sustained through pauses
stop: absent (CV 4-8 %) after 3 s hold expires
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>
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>
Lists the new `/ws/introspection` + `/api/v1/introspection/snapshot`
endpoints, the empirical baseline (0.041 ms p99 update, 5-frame shape
match on 1-D L1 stand-in), and the honest D8 amendment.
Co-Authored-By: claude-flow <ruv@ruv.net>