Commit Graph

536 Commits

Author SHA1 Message Date
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
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 c6208621b5 docs: ADR-106 — full complex CSI in WS + managed-ping keepalive
Records the two-part change that gets the maximum raw signal off the
sensors so the future model — and current fine-motion detection —
has everything the parent project describes:

  D1  NodeInfo exposes phases[56], n_antennas, noise_floor_dbm,
      timestamp_us in the WS payload (was amplitude-only).
  D2  NodeState stashes latest phases/noise/timestamp/antenna count
      so build_node_features can populate the new fields uniformly
      without a parallel phase_history buffer.
  D3  csi_keepalive_task spawns managed `ping` children per
      discovered sensor address; replaces the operator's hand-run
      `ping -i 0.05 …` workflow. CLI --csi-keepalive-pps controls
      rate (default 25), 0 disables.
  D4  Why ICMP not UDP: sensor rejects closed-port UDP before its
      CSI callback fires; ICMP is handled in WiFi RX path regardless.

Verified: 55.6 Hz raw CSI per node with no shell ping; both
amplitude[56] and phases[56] populated; noise_floor=-91 dBm.

Two impl commits already on the branch: 4daa2c9b, 8489efe9.
2026-05-17 12:00:43 +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 45c759d207 docs: ADR-105 — no synthetic data in production runtime
Records the cleanup of five fake outputs the rich Docker UI exposed
when pointed at our backend without a trained pose model loaded:

  D1  derive_pose_from_sensing  → Vec::new()
  D2  pose_current              → gated on s.model_loaded
  D3  pose_stats                → drop hard-coded average_confidence 0.87
  D4  pose_zones_summary        → drop fabricated zones, report real presence
  D5  api_info.pose_estimation  → reflects s.model_loaded
  D6  generate_signal_field     → returns zero-filled grid (was procedural)

Two implementation commits already on the branch: 9aa027e9 and 30244d27.

Audit table confirms /api/v1/sensing/latest now carries only real
ESP32-derived state. Out-of-scope items (--source simulate already
disabled; --pretrain/--train synthetic fallbacks are explicit dev
flags; vital_signs already gated on real detection) are documented
so the next reader doesn't re-audit them.
2026-05-17 11:36:30 +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 d28a1834d4 docs(refs): espectre-gap-analysis.md — full Part-2 gap structured by section
Catalogues, section-by-section against Pace's Part-2 article, every
ESPectre technique RuView has and does not have, plus a prioritized
roadmap (9 items, NVS persistence and FP-rate validation top of list).

Replaces the 8-item inline "open items" stub in espectre-techniques.md
with a 1-line forward link. Both files stay ≤ 200 lines per the docs
convention.
2026-05-17 10:51:32 +07:00
arsen 4d3ca49fba docs: ADR-101 / ADR-102 / ADR-103 — full session record
* ADR-101 raw-amplitude presence/motion classifier — per-node and
  cross-node fusion logic, hysteresis, per-node UI surface
  (`PerNodeFeatureInfo.classification` override).
* ADR-102 server-side NBVI subcarrier selection — formula, dead-zone
  gate, ESPectre Step-1 quiet-window finder, why we split FULL vs
  NBVI-subset broadband.
* ADR-103 persistent baseline + universal threshold normalization —
  JSON schema v2 at `v2/data/baseline.json`, FULL-broadband over
  NBVI for cross-restart stability, `norm_cv = cv / baseline_cv`
  with universal 3×/6× gates, recording script workflow.
* Updated espectre-techniques.md to reflect the DONE items (Steps
  1+2+4 of NBVI, baseline persistence, universal threshold) and the
  remaining open items in priority order.

Each ADR ≤ 200 lines per the operator's docs convention; deep detail
lives in `docs/references/espectre-techniques.md` (also ≤ 200) which
the ADRs link to. README.md and CLAUDE.md unchanged (no extra
content added; existing >200-line state pre-dates this session).
2026-05-17 10:46: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 03b123bfc3 feat(esp32 dsp): NBVI sliding-window per-subcarrier variance (deactivated)
Adds the scaffolding for Narrow-Band Vital Information ranking: an
exponentially-weighted moving variance per subcarrier (alpha = 0.02
→ tau ≈ 10 s at 5 pps), refreshed every 25 frames into a stable_bin
mask = bins whose EMA variance is below the across-band median.

The intended payoff is to drive per-node CV in STILL down by averaging
broad_mean_amp_history over quiet bins only (instead of all 128), so
ADR-101's STILL/EMPTY classifier separates them at a smaller body block.

Activated path is REVERTED in this commit on purpose. Quiet bins by
construction barely move, so windowed variance of their mean collapses
to ~0 and motion_energy goes constant. Empirical verification 2026-05-17:
motion_score pinned at 0.013/0.021 with std=0 across 125 frames after
turning quiet-only averaging on; reverted to full-band push_val for
motion_energy with a comment explaining why.

The right shape is a second channel in rv_feature_state_t carrying
"baseline_quiet" alongside motion_score so the server can use one for
classification and the other for motion gating — that's an additive
protocol bump and a separate change. EMA state lands now so we don't
have to wire it back from scratch when we do it.

Also kept from the earlier session: the n_subcarriers > 128 truncate
fix (root cause of motion_energy = 0 — process_frame used to early-
return on 384-byte CSI frames from this silicon) and the broadband-mean
amplitude history that feeds Step 8.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-17 02:20:44 +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
rUv bf30844835
Update README.md 2026-05-14 22:14:36 -04: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 457f713702
Merge pull request #554 from ruvnet/feat/midstream-introspection
feat(introspection): ADR-099 midstream tap + /ws/introspection + /api/v1/introspection/snapshot
2026-05-13 23:43:09 -04:00
ruv ce33042226 docs(changelog): ADR-099 introspection tap — entry under [Unreleased]
Lists the new `/ws/introspection` + `/api/v1/introspection/snapshot`
endpoints, the empirical baseline (0.041 ms p99 update, 5-frame shape
match on 1-D L1 stand-in), and the honest D8 amendment.

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