Commit Graph

565 Commits

Author SHA1 Message Date
arsen ee6d9dfa80 fix(mmwave): presence-gate vitals so empty beam doesn't show a number
The FFT will always find *some* peak in 0.8-2.0 Hz, even on pure
clutter, and the peak-to-mean ratio frequently lands at 0.5-0.7
"confidence" from noise alone. Net result: the HR pill showed 75-97
BPM with 60%+ confidence while the operator was across the room
with their back to the radar.

Add a presence gate based on the target gate's micromotion energy:

  empty room       peak_micro_mid  1k-3k
  person nearby    peak_micro_mid  10k-20k
  person in beam   peak_micro_mid  40k-80k

Threshold at 20k. Below it we null both BR and HR (the breathing
detector's internal buffer is still fed so it stays warm for instant
re-acquisition).

New diagnostic endpoint GET /api/v1/mmwave/gates dumps current
motion/micro arrays + the target gate so we can re-calibrate the
threshold on new firmware.

UI: pill now shows "· нет цели" (no target) when presence=false, so
the operator can tell "buffer warming up" from "nobody in beam"
from "module fell back to Normal Mode".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 18:39:03 +07:00
arsen 81e848ef2a feat(mmwave): ADR-122 — HLK-LD2402 Engineering Mode + heart rate
ADR-121 (Normal Mode) gave us distance and a passable breathing
estimate but couldn't see the heartbeat — cardiac chest displacement
(~0.5 mm) is well below the cm quantisation of `distance:NNN`.

Engineering Mode streams per-range-gate energy at the same 6 Hz
cadence (15 motion + 15 micromotion gates, u32 LE each). The
micromotion bin at the target's distance carries enough cardiac
modulation for FFT peak-detection in the 0.8-2.0 Hz band.

Live result, seated operator ~1.5 m from the radar:

  🫁 📡 13.0 BPM · 37%   норма 12-20
  💓 📡 76 BPM · 63%     норма 60-100

Implementation:
- Send enable-config → set-mode(0x04) → disable-config on startup;
  fall back to Normal-Mode ASCII parsing if the sequence fails.
- Binary frame parser: F4 F3 F2 F1 | len(2) | 0x01 | dist(2) | 8z |
  motion[15]×u32 LE | micro[15]×u32 LE | F8 F7 F6 F5. Gate the ASCII
  line-drain on the engineering_mode flag — first cut ran both
  unconditionally and destroyed 80% of partial frames mid-buffer.
- Target-gate selection: distance-bracketed gate first, mid-range
  micro-peak fallback, gate 1 default. Per-gate ring buffer of
  log-energies feeds a Hann + radix-2 FFT.
- /api/v1/mmwave/vitals now returns real `heart_rate_bpm`.
- raw.html: 💓 📡 pill now shows real values (no more "n/a"
  placeholder).
- New probe script v2/scripts/probe_ld2402_engineering.py used to
  reverse-engineer the wire format; kept in tree for next time.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:33:14 +07:00
arsen b9d1f6361e feat(mmwave): dual-source vital signs (WiFi-CSI 📶 vs mmWave 📡)
Previously only WiFi-CSI produced breathing/HR estimates. With the
HLK-LD2402 radar wired up we can compute a second, physically
independent breathing estimate from chest-induced cm flicker in the
distance time-series — a useful cross-check that catches the case
when one modality is blind (e.g. WiFi-CSI when nodes are offline,
or mmWave when nothing's in the radar's field of view).

mmwave.rs:
- Plumb a per-reading VitalSignDetector tuned for the module's 6 Hz
  Normal-Mode cadence (Nyquist 3 Hz comfortably covers the 0.1-0.5
  Hz breathing band).
- Distance (cm) feeds the detector as the "amplitude" channel;
  phase is empty so heartbeat falls back to amplitude residual.
- Gate `current_vitals()` on data freshness so a disconnected radar
  doesn't return stale cached BPMs.

main.rs:
- New GET /api/v1/mmwave/vitals returning the same shape as
  /api/v1/vital-signs plus buffer status for UI warm-up feedback.

ui/raw.html:
- Each vital pill now shows both 📶 (WiFi-CSI) and 📡 (mmWave)
  values side-by-side, separated by `|`. mmWave HR is labelled
  "n/a" — cm precision at 6 Hz puts heartbeat below the noise
  floor. Buffer fill (e.g. "120/180") shown while detector is
  warming up so the operator knows BPM is on the way.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:02:30 +07:00
arsen 26d47a9533 ui(raw): show adult-at-rest norms next to vital pills
The breathing/HR pills carried raw BPM with no context. An operator
glancing at "94 BPM" can't tell if that's normal or tachycardia
without external reference.

Add inline "норма 12–20" / "норма 60–100" hints (dimmed so they
don't compete with the live value), and tint the number amber when
it falls outside the adult-at-rest range. Tooltip carries the
medical terminology (bradypnea/tachypnea, bradycardia/tachycardia).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:52:17 +07:00
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 b74ffd958a chore(ui): serve raw.html from ui/ so the calibration console is reachable
Previously raw.html lived only at v2/crates/wifi-densepose-sensing-server/static/raw.html.
When the server is started with --ui-path /Users/arsen/Desktop/RuView/ui
(the SPA path) the calibration console returns 404 on /ui/raw.html.

Copy the file into ui/ alongside index.html so a single --ui-path
covers both the SPA and the engineer-facing raw view. The static/
copy in the crate stays as the canonical source (referenced by ADRs
104/107); ui/raw.html is a deploy mirror.

Live at http://localhost:8080/ui/raw.html.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:50:47 +07:00
arsen a36af57d19 fix(docs): repair internal links broken by README/CLAUDE doc-slim
After ADR-117/118 docs sweep (commit 4075b608) extracted Use Cases,
How It Works, Edge Modules, Self-Learning sections from README into
docs/use-cases.md + docs/architecture.md, but two classes of links
were left dangling:

1. README anchor links pointing at section IDs that no longer exist
   in README:
     #edge-intelligence-adr-041  → moved to docs/use-cases.md
     #esp32-s3-hardware-pipeline → architecture detail in docs/
     #vital-sign-detection       → moved out
     #sensing-server             → moved out
     #-quick-start               → renamed during slim

   Replaced with deep links into docs/use-cases.md or docs/dev-handbook.md
   / docs/architecture.md where appropriate.

2. Extracted docs (docs/use-cases.md etc.) had path links written from
   the perspective of repo root (docs/edge-modules/, v2/crates/...) —
   broken once the file moved into docs/. Bulk-rewrote via Python
   regex pass:
     docs/edge-modules/X → edge-modules/X
     docs/adr/X          → adr/X
     v2/...              → ../v2/...
     archive/...         → ../archive/...
     scripts/...         → ../scripts/...
     plugins/...         → ../plugins/...
     firmware/...        → ../firmware/...

3. docs/use-cases.md self-reference #ai-backbone-ruvector → that
   section was never moved; replaced with prose + link to
   architecture.md.

Final scan: ZERO dangling anchors in the doc tree. One valid
anchor `#edge-module-list` in use-cases.md points to a local
`<details id="edge-module-list">` block.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:34:02 +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 831602b584 docs(sensors): correct hardware mapping — nodes 1/2 are camera boards
Operator clarified: nodes 1 and 2 (.101 / .100) are ESP32-S3 + OV-camera
boards (sensor_06, sensor_07 in the photo set), NOT YD-ESP32-23. Nodes
3-6 (.102 / .104 / .105 / .106) are the YD-ESP32-23 boards with u.FL
external-antenna connectors (sensor_08, sensor_09).

Impact: Pack E.2 (WiFlow camera-supervised retrain) is closer than
previously assumed — the camera hardware is already deployed at nodes
1 and 2. Path becomes:
  1. Extend FW with parallel camera_capture.c → stream MJPEG over UDP/HTTP
  2. Run MediaPipe Pose on server (deps already installed in
     ~/.venv/ruview-train from earlier session)
  3. Time-align with existing scripts/align-ground-truth.js
  4. Retrain via scripts/train-wiflow-supervised.js --scale lite

The 4 PCB-strip antennas in sensor_02 map 1:1 to nodes 3-6 — drop-in
upgrade once each board is power-cycled to swap the antenna feed.

README now lists the per-node board type, IP, camera/u.FL status, and
which photos show each. No code changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:13:51 +07:00
arsen 2538fa2fab docs: import hardware photos + sensor inventory
9 photos of the additional sensor/antenna hardware staged for ADR-120+
experimentation (captured 2026-05-18):

  sensor_01  5× u.FL pigtail antennas (bare)
  sensor_02  4× flat PCB-strip 2.4 GHz antennas w/ 3M backing + u.FL
  sensor_03  HLK-LD2402 24 GHz mmWave radar (close-up, chip S1KM0008)
  sensor_04  CP2102 USB-to-UART bridge (AMS1117-3.3 LDO)
  sensor_05  HLK-LD2402 + USB-UART wired together (working setup)
  sensor_06  ESP32-S3 dev board with microSD slot (back)
  sensor_07  ESP32-S3-WROOM with OV-camera + ribbon FFC mounted
  sensor_08  YD-ESP32-23 2022-V1.3 (back) — spare matching nodes 1-6
  sensor_09  YD-ESP32-23 (front) — ESP32-S3-N16R8 + FTDI

assets/sensors/README.md catalogues each photo + suggests where each
piece fits in the roadmap:
  * u.FL antennas → attach to n1/n5 (near-AP, sep_ratio ~0.05 per ADR-118)
  * HLK-LD2402 → vitals ground-truth reference for WiFi pipeline
  * Camera-ESP32-S3 → on-device camera capture for WiFlow Pack E.2 retrain
  * YD-ESP32-23 spare → flashable as node 7 when needed

Photos referenced only from this README, not used by any code path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:09:45 +07:00
arsen 4075b6082d docs: enforce ≤200-line cap on README/CLAUDE/CHECKLIST and 3 ADRs
User-stated rule: README.md and CLAUDE.md must not exceed 200 lines;
all detail goes into docs/ with a link. ADRs also targeted at ≤200.

Before:
  README.md   542 lines
  CLAUDE.md   407 lines
  CHECKLIST   235 lines
  ADR-116     224
  ADR-117     245
  ADR-120     209

After:
  README.md   198 ✓
  CLAUDE.md   149 ✓
  CHECKLIST   199 ✓
  ADR-116     191 ✓
  ADR-117     199 ✓
  ADR-120     200 ✓
  ADR-115/118/119  already under (161 / 193 / 161)

New supporting docs (extracted content):
  docs/use-cases.md     — full deployment-tier catalogue + 60 ADR-041 edge modules
                          + ADR-024 self-learning section, all moved from README
  docs/architecture.md  — pipeline diagram + module breakdown from README
  docs/dev-handbook.md  — crate map, RuvSense modules, build/firmware/release
                          /publish, witness verification — all moved from CLAUDE.md
  docs/claude-swarm.md  — V3 CLI commands, agent types, memory commands —
                          moved from CLAUDE.md

Trims (compress prose without losing facts):
  ADR-116 — D7 honesty section + Verified Acceptance + Open Items
  ADR-117 — Context narrative folded to bullets + Out of Scope condensed
  ADR-120 — Out of Scope condensed
  CHECKLIST — adaptive classifier entries compacted + Deferred grouped

CLAUDE.md now adds the ≤200-line rule explicitly to Behavioral Rules
+ Project Architecture + Pre-Merge Checklist so future sessions can't
forget it. README.md was a 67% reduction; CLAUDE.md 63%.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 11:04:15 +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 0ec1e4b06f fix(adr-116): surface WiFlow-v1 in Model Control dropdown
LiveDemoTab.fetchModels() now probes /api/v1/info after the RVF
model list; when features.pose_estimation is true (i.e.
--wiflow-model was loaded), inserts a virtual 'WiFlow-v1 (lite,
186K params, --wiflow-model)' option, marks it active, and
populates name + PCK 0.929 in the panel.

Cosmetic only — does not change inference path or pose_keypoints
flow. Closes the UX inconsistency where the badge said MODEL
INFERENCE but the dropdown said 'No model loaded'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 18:56:53 +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 7d3e0c2d7e feat(adr-115): POST /ota/set-target — set CSI target IP/port via WiFi
New REST endpoint on FW HTTP server (port 8032) writes
csi_cfg/target_ip + target_port to NVS and reboots. Body is
plain text "IPv4:PORT" (e.g. 192.168.0.103:5005). Verified on
both 192.168.0.100 and 192.168.0.101 — sensors silent after
Mac IP move came back online in ~3 min instead of needing USB.

Same PSK auth as /ota/recalibrate (ADR-050). Strict body parser
rejects malformed input before touching NVS. Binary size +1 KB.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 18:27:06 +07:00
arsen 54adc48b2e docs: CHECKLIST sweep — 43 Done / 0 Open in-scope
All Pack A/B/C items closed this session (ADRs 109, 112, 113, 114
+ ADR-104 phase closure + ADR-105 / ADR-107 last items).
Tailscale-target moved to Deferred per session brief.
Hygiene H1: schema verified end-to-end; baseline.json file is
untracked, no repo commit needed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 17:15:04 +07:00
arsen c827cde69e docs(adr-114): ADR for replay regression suite
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 17:00:16 +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 d9b73a24fa docs(adr-113): ADR for day/night baseline profiles
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 16:49:13 +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 5a79127780 docs(adr-112): ADR-112 + close ADR-105 + CHECKLIST sweep
- 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>
2026-05-17 16:35:18 +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 2dcb30a6de feat(adr-105): hide pose canvas in Docker SPA when no model is loaded
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>
2026-05-17 16:34:04 +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 169a589def docs(adr-109): new ADR + close ADR-108 open items
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>
2026-05-17 16:14:51 +07:00
arsen f92807cdaf feat(adr-109): /ota/recalibrate + NVS AP-MAC binding for gain-lock
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>
2026-05-17 16:14:46 +07:00
arsen 4b58e5442a Merge remote-tracking branch 'origin/main' into feat/ota-rssi-mobile 2026-05-17 15:27:54 +07:00
arsen f0a5f80143 docs: rename our ADR-099 (tplink-wisp) → ADR-110 to free ADR-099 for upstream
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.
2026-05-17 15:27:47 +07:00
arsen 197457a78d docs: close ADR-104/105 open items shipped in 598a4b2f/eec3ca6c
CHECKLIST.md, ADR-104, ADR-105 reflect:
- n_aps_used field shipped (ADR-105)
- per-sub drift exposed in WS + raw.html sparkline (ADR-104)
- baseline staleness watch task (ADR-104)

Open ADR-104 items reduced to phase-domain drift only.
Open ADR-105 items reduced to UI-no-model + multi-AP signal_field.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 14:15:36 +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 8431674a6a docs: CHECKLIST.md at repo root — discoverable single-source-of-truth
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.
2026-05-17 13:55:36 +07:00
arsen e4204595b0 docs: actualization sweep — close items shipped this session
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.
2026-05-17 13:52:50 +07:00
arsen d7189d9b0f docs: ADR-104 (per-subcarrier drift) + ADR-108 (FW NVS persistence)
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.
2026-05-17 13:34:22 +07:00
arsen 3779bb7655 feat(adr-108): NVS persistence of gain-lock — reboot ready in 0.5s
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.
2026-05-17 13:30:08 +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 274984d3a9 docs(refs): ota-pipeline.md — verbatim OTA reproduction recipe
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.
2026-05-17 12:24:02 +07:00