Commit Graph

79 Commits

Author SHA1 Message Date
arsen f6adcb2014 ui(raw): surface WiFi-CSI breathing + heart rate pills
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>
2026-05-18 12:17:36 +07:00
arsen e53a2e1f5c feat(adr-121): mmWave radar pill in raw.html top bar
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>
2026-05-18 11:54:54 +07:00
arsen cb6e24ed57 feat(adr-121): HLK-LD2402 mmWave radar live readout in UI
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>
2026-05-18 11:27:28 +07:00
arsen e2c68191a2 docs: CHECKLIST sweep + .gitignore session artifacts + UI CSS catchup
- 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>
2026-05-18 10:32:39 +07:00
arsen 12e1cf9d5e feat(adr-120): /api/v1/adaptive/debug + softer smoothing (15/2)
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>
2026-05-18 01:45:41 +07:00
arsen 2956414bf8 fix(adr-120): centralised motion-label smoothing โ€” 0 flips in 30s
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>
2026-05-18 01:39:41 +07:00
arsen 77d404d613 fix(adr-120): two-layer label smoothing โ€” Layer1 30-tick mode + Layer2 5-tick confirm
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>
2026-05-18 01:32:40 +07:00
arsen c3f00f3abf tune(adr-120): adaptive smoothing window 7 โ†’ 15 ticks (~1.5s) 2026-05-18 01:23:09 +07:00
arsen 3e12686ae9 fix(adr-120): 7-tick majority smoothing โ€” stops UI label flicker
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>
2026-05-18 01:21:01 +07:00
arsen 442c03da3b fix(adr-120): hybrid priority โ€” adaptive owns waving/transition
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>
2026-05-18 01:16:27 +07:00
arsen da4c123df9 feat(adr-120): windowed temporal classifier (W-MLP) โ€” 53.53% โ†’ 90.40%
Adds WindowedMlpModel: 440 โ†’ 64 ReLU โ†’ n_classes, stacks last 20
frames ร— 22 features as input. Captures temporal patterns that
frame-level classifiers physically cannot see (walking cadence,
sit-stand cycles, gesture rhythm).

AppStateInner gets feature_window: VecDeque<[f64; 22]> (cap 20)
auto-pushed at the 3 tick sites before adaptive_override. The
classify_window API flattens the buffer (oldest first) + current
frame's features โ†’ 440-d input โ†’ softmax over classes. Cold-start
(<20 frames) falls back to frame-level MLP.

AdaptiveModel now carries all three classifiers side-by-side:
LogReg (ADR-118), MLP (ADR-119), W-MLP (this). classify_window
picks W-MLP first; legacy classify() picks MLP > LogReg.

Result on the same 6-node, 7-class, 151,329-frame dataset:
  LogReg:   49.58%
  MLP:      53.53%
  W-MLP:    90.40%  (+36.87 pts over MLP, +50.0 pts over original
                     2-node 15-feature LogReg baseline)

Per-class W-MLP accuracy:
  absent          100% (was 41%)
  present_still   100% (was 99%, saturated)
  transition       86% (was 36%)  โ€” sit/stand cadence captured
  waving           90% (was 38%)  โ€” gesture cadence captured
  present_moving   82% (was 33%)  โ€” walking step cadence captured
  active           74% (was 30%)  โ€” jumping bursts captured

Loss broke through frame-level plateau (1.15 โ†’ 0.25). Caveat:
90.4% is training-set accuracy; ~28k weights on ~30k windowed
samples means some overfitting likely. Held-out test set
recommended as follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 01:02:38 +07:00
arsen 9433070864 feat(adr-119): MLP classifier (22โ†’32โ†’6) replaces LogReg fallback
Single-hidden-layer perceptron (~3k params, ReLU + softmax) trained via
manual backprop (no external ML crate). SGD + momentum 0.9 + weight
decay 1e-4 + cosine LR decay, 30 epochs over 151,329 frames.

AdaptiveModel carries both LogReg and MLP weights side-by-side;
classify() prefers MLP via is_trained() check, falls back to LogReg
when loading legacy 15-feature models.

Result on same 6-node 7-class dataset:
  LogReg (ADR-118):   49.58%
  MLP    (this):      53.53%   (+3.95 pts)

Per-class gains concentrated on motion classes โ€” exactly where
non-linear feature combinations matter:
  absent          +1   (40% โ†’ 41%)
  present_still   tied (99% โ†’ 99%, class-imbalance ceiling)
  transition      +7   (29% โ†’ 36%)
  active          +8   (22% โ†’ 30%)
  waving          +4   (34% โ†’ 38%)
  present_moving  +9   (24% โ†’ 33%)

Cumulative session improvement vs 2-node 15-feature baseline:
  40.4% โ†’ 53.53% (+13.1 pts).

Loss flatlines at 1.15 around epoch 10 โ€” frame-level information
ceiling for the 22-feature representation. Next big lever is
temporal context (windowed LSTM/TCN), documented in Out-of-scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 00:48:19 +07:00
arsen e86f650681 feat(adr-118): feature decorrelation + multi-node extractor
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>
2026-05-18 00:35:08 +07:00
arsen 6ce25cec79 feat(adr-117): process hygiene + pose path honesty + audit sweep
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>
2026-05-17 19:24:04 +07:00
arsen 7cdd8f69e6 feat(adr-116): WiFlow-v1 supervised pose loader (Rust)
Pure-Rust port of scripts/train-wiflow-supervised.js inference path.
Loads ruv/ruview/wiflow-v1.json (lite scale, 186946 params) โ€” base64
weights, 2 TCN blocks (k=3, d=[1,2]), 35โ†’32โ†’32 channels, FC 640โ†’256โ†’34.
BatchNorm uses per-window mean/var matching the JS impl. No new crates;
inline base64 decoder, hand-written math.

CLI: --wiflow-model PATH flips /api/v1/info {pose_estimation:true},
populates SensingUpdate.pose_keypoints per tick, pose_current returns
17 COCO keypoints. Verified on TP-Link/.100/.101 deployment.

Output values are sigmoid-saturated (transfer w/o fine-tune) โ€” model
needs per-deployment LoRA adapter or re-train, follow-up Pack E.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 18:47:17 +07:00
arsen 96225e27cf feat(adr-114): 2000-packet replay regression suite
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>
2026-05-17 17:00:10 +07:00
arsen a1e0952501 feat(adr-113): day/night baseline profiles with hot-reload
--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>
2026-05-17 16:49:06 +07:00
arsen 47dafab42d feat(adr-104): phase-domain drift channel (script + server)
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>
2026-05-17 16:44:21 +07:00
arsen 432753e188 feat(adr-107): progress bar in raw.html calibrate button
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>
2026-05-17 16:34:14 +07:00
arsen c8ac60f6ab feat(adr-112): multi-AP signal_field via MultistaticFuser
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>
2026-05-17 16:33:56 +07:00
arsen 4b58e5442a Merge remote-tracking branch 'origin/main' into feat/ota-rssi-mobile 2026-05-17 15:27:54 +07:00
arsen eec3ca6ce2 feat(adr-104): per-sub drift in WS + raw.html sparkline + staleness watch
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>
2026-05-17 14:14:13 +07:00
arsen 598a4b2f6b feat(adr-105): n_aps_used in enhanced_motion/enhanced_breathing
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>
2026-05-17 14:13:57 +07:00
arsen 6212b17ed1 feat(adr-102/104): NBVI FP-rate validation + per-subcarrier drift presence
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.
2026-05-17 13:25:31 +07:00
arsen b787f40a86 feat(adr-106): real sensor ยตs timestamp (rx_ctrl.timestamp) โ€” flashed via OTA
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.
2026-05-17 12:55:07 +07:00
arsen 45c1464cc0 feat(adr-107): raw.html calibrate button + ADR
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
2026-05-17 12:15:09 +07:00
arsen 0f373467e5 feat(adr-107): REST /api/v1/baseline/* + auto-recalibrate in background
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.
2026-05-17 12:12:24 +07:00
arsen 68068d73d8 feat(adr-106): server-side ยตs timestamp on raw-CSI ingest
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).
2026-05-17 12:04:11 +07:00
arsen 8489efe9ae feat(adr-106): built-in CSI keepalive via managed ping processes
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.
2026-05-17 11:57:23 +07:00
arsen 4daa2c9bc2 feat(adr-106): expose full complex CSI in WS NodeInfo (amp+phase+meta)
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).
2026-05-17 11:47:33 +07:00
arsen 30244d274b feat(adr-105): kill synthetic signal_field โ€” only real ESP32 data left
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.
2026-05-17 11:34:31 +07:00
arsen 9aa027e95e feat(adr-105): kill synthetic pose + hard-coded confidence โ€” only real data
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.
2026-05-17 11:28:36 +07:00
arsen 2f4b2d5304 feat(adr-103 v2): universal threshold via baseline-CV normalization
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.
2026-05-17 10:14:33 +07:00
arsen f411992435 feat(adr-103 v2): stable persistent baseline + NBVI quiet-window finder
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.
2026-05-17 10:11:24 +07:00
arsen 764388c0bf docs+fix: ESPectre technique reference + revert stale-amp UI fill
* 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.
2026-05-17 09:08:09 +07:00
arsen 7185ead826 chore(sensing-server/static): keep only raw.html, drop duplicates
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.
2026-05-17 02:57:37 +07:00
arsen 7535dff3e4 fix(sensing-server): feature_state path keeps last raw amplitudes
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.
2026-05-17 02:54:37 +07:00
arsen 2f12a2236b feat(sensing-server): NBVI subcarrier selection (ADR-102)
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.
2026-05-17 02:44:16 +07:00
arsen c22dfcd256 fix(raw.html): guard zero RSSI + correct per-node fps counter
* 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.
2026-05-17 02:37:05 +07:00
arsen 72047a4185 feat(ops): one-command OTA deploy + mobile-first operator UI
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>
2026-05-17 02:21:06 +07:00
arsen 3393c1e839 fix(rssi): correct parse_esp32_frame offsets + carry RSSI through feature_state
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>
2026-05-17 02:20:25 +07:00
arsen c3126c39a3 feat(raw.html): per-node classification badges (ADR-101)
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 %.
2026-05-17 01:01:10 +07:00
arsen 6604adae18 feat(sensing-server): raw-amplitude presence/motion classifier (ADR-101)
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
2026-05-17 00:54:10 +07:00
arsen 8aef82069b deploy(esp32s3): PHY gain-lock for baseline-stable CSI + raw signals UI
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.
2026-05-17 00:31:07 +07:00
arsen b292c7d869 deploy: tp-link wisp ap + rssi-ฮ” presence detector + live calibration ui
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>
2026-05-15 11:26:07 +07:00
arsen fc905c5c77 deploy(esp32s3): fix DSP, OTA, discovery, mobile WS for room01/room02
End-to-end deployment fixes that took the two ESP32-S3 sensor boards
(room01, room02) from "boots but DSP frozen, OTA always rolls back" to
"motion/presence/breathing all live, two consecutive OTA round-trips
succeed". Full forensic write-up in docs/adr/ADR-098.

Firmware (firmware/esp32-csi-node/main/):
* csi_collector.c โ€” remove esp_wifi_set_promiscuous(true): this call
  silenced the CSI RX callback entirely on this silicon revision
  (yield=0pps). Without it, callbacks resume at ~5-10 pps.
* edge_processing.c โ€” root cause: incoming CSI frames carry 192
  subcarriers but EDGE_MAX_SUBCARRIERS=128, so the size check
  early-returned every frame and Step 8 (motion) never ran. Truncate
  to 128 + warn once instead of returning.
* edge_processing.c โ€” replace per-bin unwrapped-phase variance with
  temporal variance of per-frame broadband mean amplitude. Empirical
  separation on deployed hardware: empty 0.07-0.10, walking 3.5-14
  (~44x). Scaled by /3.0 and clamped to [0,1].
* edge_processing.c โ€” biquad fs 20.0 -> 10.0, matching the actual
  callback rate (was halving the breathing passband).
* ota_update.c โ€” OTA_WITH_SEQUENTIAL_WRITES -> OTA_SIZE_UNKNOWN to
  erase the full target partition (stale tail of the previous larger
  image was crashing the new image on boot, looking like rollback).
* ota_update.c โ€” httpd_config_t.stack_size = 8192 (default 4 KB
  overflowed in OTA verify path).
* main.c โ€” log esp_reset_reason() and running_partition->label once
  at app_main start, so OTA outcomes are visible without guesswork.
* sdkconfig.defaults โ€” local deployment defaults: tier=2, display
  disabled (no expander on these boards), 8192 timer stack.

Sensing server (v2/crates/wifi-densepose-sensing-server/):
* src/main.rs โ€” parse_rv_feature_state() for the 0xC5110006
  feature_state packet that RuView FW emits by default; this format
  was previously unhandled. Wire ahead of parse_esp32_vitals.
* src/main.rs โ€” BaselineTracker with hysteretic motion gating on top
  of FW-reported scores, so UI sees clean boolean presence transitions.
* src/main.rs โ€” refuse --source simulate; remove auto-fallback to
  synthetic data. Production builds never run on fake signals.
* src/main.rs/csi.rs โ€” parse_csi_lean() for legacy FW 5.47 CSV
  packets; defence-in-depth for mistakenly flashed legacy sensors.

Desktop UI (v2/crates/wifi-densepose-desktop/):
* src/commands/discovery.rs โ€” third discovery path: HTTP /status sweep
  across the local /24 in parallel with mDNS/UDP. mDNS+UDP-beacon are
  not advertised by current RuView FW. Replace sequential
  for-task-in-tasks select-with-deadline (which blocked on slow
  unrelated IPs) with futures::join_all + overall timeout.
* src/commands/server.rs โ€” pass --bind-addr (was --bind); pass
  RUST_LOG env instead of unsupported --log-level; auto-load bundled
  wifi-densepose-v1.rvf next to the binary; reasonable defaults
  (esp32 source, 0.0.0.0 bind).
* ui/* โ€” keep last good node list when a poll returns 0 (discovery
  is jittery on busy LANs); 8 s timeout (was 3 s); remove "simulate"
  from DataSource enum and Sensing dropdown; default Sensing source
  esp32.

Mobile UI (ui/mobile/):
* constants/websocket.ts โ€” WS_PATH '/ws/sensing' + WS_PORT 8765 to
  match the RuView sensing-server's WS endpoint (was the legacy
  FastAPI /api/v1/stream/pose).
* services/ws.service.ts โ€” derive WS host from serverUrl but use
  WS_PORT; remove simulation fallback paths entirely (no
  generateSimulatedData, no startSimulation on reconnect failure).
* stores/settingsStore.ts โ€” serverUrl defaults to
  http://100.123.189.10:8080 (deployed Mac's Tailscale IP), so the
  phone connects from any network without LAN dependency.
* stores/matStore.ts โ€” default dataSource='real',
  simulationAcknowledged=true; no synthetic triage data.
* screens/MATScreen, VitalsScreen โ€” hide simulation overlay/badge.

Docker:
* docker/docker-compose.yml โ€” sensing-server host port 5005 -> 5006
  to match the RuView FW's compiled CSI_TARGET_PORT default.

Documentation:
* docs/adr/ADR-098-esp32s3-csi-deployment-fixes.md โ€” full forensic
  ADR covering each decision, the empirical numbers that drove it,
  the false hypotheses we ruled out along the way, and open items.

Verified on hardware (both nodes):
* motion empty < 0.05 (room01 0.018, room02 0.070)
* motion walking > 0.3 within 1-3 s, saturates at 1.0
* motion decay < 0.1 within 5 s after leaving
* breathing 21-22 BPM detected after ~30 s stationary
* two consecutive OTA round-trips succeed without USB intervention
* discovery finds both sensors via HTTP sweep in <2 s

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 18:56:04 +07:00
ruv ca97527646 feat(introspection): I6 โ€” regime-changed signal + per-frame analyze + honest ADR-099 D8 amendment
Three threads in this commit:

1) Per-frame attractor analysis (default analyze_every_n: 8 โ†’ 1).
   The I5 benchmark put per-frame update at 0.012 ms p99 โ€” 83ร— under D4's
   1 ms budget. The cost case for the every-8th-frame default doesn't hold;
   per-frame analysis is what makes regime_changed a viable early-detection
   trigger.

2) New `regime_changed: bool` field in IntrospectionSnapshot โ€” flips on any
   frame whose attractor regime classification differs from the previous
   frame's. Pairs with top_k_similarity (full-shape match) to give
   downstream consumers two latencies with different robustness profiles.

3) Honest amendment of ADR-099 D8 to reflect empirical reality:
   - L1 stand-in achieves 3.20ร— ratio (5-frame shape match vs 16-frame
     event-path floor); the 10ร— aspirational bar is architecturally
     unreachable at 1-D scalar feature resolution.
   - regime_changed didn't fire in the 10-frame motion window โ€” the
     200-frame noise trajectory dominates the Lyapunov classification, and
     short perturbations don't shift the regime fast enough on a scalar
     feature.
   - Path to 10ร—: ADR-208 Phase 2 (Hailo NPU vec128 embeddings) โ€” multi-dim
     partial matches discriminate from noise in 1-2 frames, not 5.
   - Side finding: midstream temporal-compare::DTW uses *discrete equality*
     cost (designed for LLM tokens), not numeric distance โ€” swapping it in
     for f64 amplitude scoring would be strictly worse than the L1 stand-in.
     A numeric DTW is a separate concern (hand-roll or new crate).
   - Revised D8: ship behind --introspection (off by default) until multi-
     dim features land. Per-frame update budget IS met (0.041 ms p99 in this
     bench, ~24ร— under the 1 ms bar) โ€” the feature is cheap enough to
     carry dark today.

cargo test -p wifi-densepose-sensing-server --no-default-features:
  introspection (lib): 8 passed, 0 failed
  introspection_latency (test): 5 passed, 0 failed (incl. new
                                 regime_change_path_latency)
clippy: clean on the introspection surface (pre-existing approx_constant
        lints in pose.rs / main.rs unchanged).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-13 23:29:37 -04:00
ruv 59d2d0e54f test(sensing-server): ADR-099 latency benchmark โ€” record empirical baseline
I5. Measures the architectural latency floor of the introspection path
vs. the window-aggregated event path, plus the per-frame update cost.

  Result on this run:
    ADR-099 D8 floor ratio    : 3.20ร— (16 frames / 5 frames)
                                D8 target โ‰ฅ10ร— โ€” NOT YET MET on the host-side
                                L1 stand-in scoring; I6 closes the gap.
    ADR-099 D4 update p50/p99 : 0.001 ms / 0.012 ms (~83ร— under the 1 ms
                                budget on a desktop runner; even with thermal
                                throttling on a Pi 5 we have orders of
                                magnitude of headroom).
    Regime after 200 frames   : Idle, lyapunov=-2.32, confidence=1.0
                                (attractor analyzer is firing as designed).

The D8 gap is structural to the current scoring: signature_score() uses a
length-normalised L1 over the trailing window, which requires roughly the
full signature length of in-shape frames before crossing
promotion_threshold. Closing it is the I6 work โ€” swap in the real
midstreamer-temporal-compare DTW (partial-match scoring) and/or surface
the attractor's regime-change as an *earlier* trigger than full signature
match.

The latency-ratio test asserts a regression bar (โ‰ฅ3.0ร—) on the L1 baseline,
prints the D8 ratio + whether it's met, and explicitly defers the โ‰ฅ10ร—
target to I6 in the docstring. Better empirical reporting than a flag that
silently fails until tuned.

ESP32 sanity (independent of the benchmark): COM7 device alive at csi_collector
cb #84500 (~30 min uptime), len=128/256 HT20/HT40, ch5, RSSI swings -44 to
-79 (= real motion in the room). UDP target still unreachable from this
host per the earlier diagnosis; that's a deployment fix, not a measurement
gate.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-13 23:18:10 -04:00
ruv 4a1f3a1e10 feat(sensing-server): wire ADR-099 introspection tap + /ws/introspection + /api/v1/introspection/snapshot
I3 (per ADR-099). Three changes in main.rs:

1) AppStateInner: + intro: IntrospectionState + intro_tx: broadcast::Sender<String>
   (256-slot ring, same shape as the existing tx).

2) ESP32 frame path: after the global frame_history push, before the
   per-node mutable borrow of s.node_states, compute the per-frame derived
   feature (mean amplitude across subcarriers), call s.intro.update(ts_ns,
   feature), and broadcast the snapshot JSON to s.intro_tx. Placement is
   deliberate โ€” between the global state's mutable touch and the per-node
   &mut so borrow-checking stays linear; ns is borrowed *after* the tap
   completes its s.intro / s.intro_tx access.

3) Routes:
     ws_introspection_handler   โ†’ /ws/introspection
     api_introspection_snapshot โ†’ /api/v1/introspection/snapshot
   Same Axum + tokio::sync::broadcast pattern as ws_sensing_handler,
   subscribed against s.intro_tx. Wrapped by the bearer-auth middleware
   already on /api/v1/* โ€” orchestrator probes and unauthenticated /ws/sensing
   reachers continue to land on the existing topic.

Verified:
  cargo build -p wifi-densepose-sensing-server --no-default-features โœ“
  cargo test  -p wifi-densepose-sensing-server --no-default-features
    lib:           207 passed, 0 failed (199 pre-tap + 8 introspection)
    integration suites: 70, 8, 16, 18 passed, 0 failed
  cargo clippy: clean on the introspection surface (pre-existing warnings
                on -core / -ruvector / -signal unchanged).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-13 23:00:31 -04:00
ruv 94ef125240 feat(sensing-server): introspection module skeleton (ADR-099 D1+D7+D8)
Adds the per-frame introspection state that ADR-099 specifies, plus the two
midstream dependencies. Pure addition โ€” no other code touched.

  v2/crates/wifi-densepose-sensing-server/Cargo.toml
    + midstreamer-temporal-compare = "0.2"
    + midstreamer-attractor        = "0.2"

  v2/crates/wifi-densepose-sensing-server/src/introspection.rs (new, 530 lines)
    pub struct IntrospectionState
      โ”œโ”€ midstreamer-attractor's AttractorAnalyzer (regime + Lyapunov)
      โ”œโ”€ SignatureLibrary (JSON-loaded labelled segments)
      โ”œโ”€ VecDeque<f64> sliding amplitude buffer (default 128 points)
      โ””โ”€ update(timestamp_ns, derived_feature) โ€” never window-blocked
         + snapshot() -> IntrospectionSnapshot
            { timestamp_ns, frame_count, regime, lyapunov_exponent,
              attractor_dim, attractor_confidence, top_k_similarity }
    pub enum Regime { Idle, Periodic, Transient, Chaotic, Unknown }
    pub struct Signature { id, label, vectors, dtw, promotion_threshold }
    pub struct SimilarityMatch { signature_id, score, above_threshold }

DTW path is currently a host-side stand-in (length-normalised L1 with the
real DTW call deferred to I3/I5 once vec128 embeddings exist โ€” ADR-099 P1).
The attractor path is wired to midstream directly. The analyze() step only
runs every N frames (default 8) to stay under the per-frame ms budget.

8 unit tests (snapshot defaults, frame-count + timestamp advance, empty
library, scoring + ordering invariants, threshold gating, empty-signature
fault-tolerance, regime classification after 200 frames). 199 โ†’ 207 lib tests,
0 failures. cargo build clean (only pre-existing warnings).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-13 22:50:58 -04:00