Compare commits

...

34 Commits

Author SHA1 Message Date
rUv 4bf88e1283 feat(firmware): gate LED gamma viz behind CONFIG_LED_GAMMA_VIZ (ADR-183 follow-up) (#1129)
The 40 Hz gamma flicker is now Kconfig-gated (default y, unchanged
behaviour). Set CONFIG_LED_GAMMA_VIZ=n for a dark, lower-power boot (the
LED is simply cleared) — important for photosensitive deployments, no
source edit needed. The colormap saturation point is now operator-tunable
via CONFIG_LED_MOTION_FULLSCALE_MILLI (default 250 = 0.25).

Build + flash confirmed on ESP32-S3 N16R8 (COM8): default y still arms the
gamma timer, CSI flows. ADR-183 updated (gate moved from follow-up to done).
2026-06-17 22:22:20 -04:00
rUv a4c2935a2f feat(firmware): onboard LED 40 Hz gamma stimulus + CSI-motion colour (ADR-183) (#1127)
* chore(deps): bump ruv-neural submodule — ColorMap no_std for ESP32

Points to ruvnet/ruv-neural#3 (c9638fa): ruv-neural-viz::ColorMap now
builds no_std, so it can run on the ESP32. Unblocks driving the onboard
WS2812 from the viridis/cool-warm colormap.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(firmware): onboard LED as 40 Hz gamma stimulus, colour from live CSI motion (ADR-183)

The S3 onboard WS2812 (GPIO 48, #962) now runs a GENUS-style 40 Hz gamma
square wave (12.5 ms on/off, 50% duty). The ON-phase colour is live CSI
motion (edge motion_energy) mapped through a 60-step viridis LUT generated
from ruv-neural-viz::ColorMap::viridis() — still=purple, moving=yellow.

Uses the now-no_std ColorMap (ruvnet/ruv-neural#3 / #1126). Hardware-
confirmed on ESP32-S3 N16R8 (COM8): boot log shows the timer armed, CSI
keeps flowing (27-38 pps). Honesty + photosensitivity notes + a Kconfig-gate
follow-up are in ADR-183.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-17 21:04:02 -04:00
rUv 315d7df09e chore(deps): bump ruv-neural submodule — ColorMap no_std for ESP32 (#1126)
Points to ruvnet/ruv-neural#3 (c9638fa): ruv-neural-viz::ColorMap now
builds no_std, so it can run on the ESP32. Unblocks driving the onboard
WS2812 from the viridis/cool-warm colormap.
2026-06-17 20:18:35 -04:00
rUv bdd1eaf927 chore: untrack ruvector.db runtime artifacts + gitignore at any depth (#1124)
These are hook/runtime-generated databases (ruvector/intelligence store)
that kept showing as dirty and don't belong in version control. Removed
from the index (files kept on disk) and ignored globally.
2026-06-17 17:49:47 -04:00
rUv 4001e9e178 feat(harness): npx @ruvnet/ruview operator harness + ADR-182 (#1123)
A host-portable RuView agent harness minted via MetaHarness and hardened
per ADR-182. Published as @ruvnet/ruview@0.1.0 (bare `ruview` blocked by
npm's typosquat filter → scoped fallback).

What it does:
- 6 fail-closed `ruview.*` tools (onboard, claim_check, verify,
  node_monitor, calibrate, node_flash) exposed as CLI verbs + a
  dependency-free MCP stdio server.
- The "prove everything" rule made executable: `ruview.claim_check`
  flags untagged accuracy claims and the retracted "100%" framing.
- 5 host-neutral skills (onboard/provision-node/calibrate-room/
  train-pose/verify) + bundled .claude/ config + provenance manifest.

Validated: 17/17 unit tests, live MCP handshake, `ruview.verify` ran the
real verify.py to VERDICT: PASS, clean `npx @ruvnet/ruview` from registry.
Packs to 16.7 kB / 21 files; kernel+host are optionalDependencies so the
operator tools install lightweight.

README: documented as the portable, multi-host companion to the in-repo
plugins/ruview/ Claude Code plugin (not a replacement).
2026-06-17 17:46:31 -04:00
rUv 65e29ef47a fix(display): no false display-detect on bare DevKit → CSI starves at MGMT-only (#1000) (#1121)
The SH8601 QSPI panel is write-only, so display_hal_init_panel() 'succeeds' even on a
bare display-less board — display_is_active() then returned true and main.c skipped the
#893/#906 MGMT->MGMT+DATA CSI filter upgrade (yield=0pps). Gate on the FT3168 touch I2C
readback (always present on the Touch-AMOLED board, absent on a bare DevKit): if touch is
absent, the panel 'success' was a false-positive — bail to headless before the display
task starts, so display_is_active() stays false and CSI captures.

Co-authored-by: ruv <ruvnet@gmail.com>
2026-06-17 11:24:53 -04:00
rUv cb30988cf9 fix(mmwave): require validated MR60 header on probe — no false detect on empty UART (#1107) (#1119)
probe_at_baud counted bare 0x01 (SOF) bytes and declared MR60BHA2 on a single one.
A floating UART1 with no sensor reads noise full of 0x01s → false 'Detected MR60BHA2
(caps=0x000f)'. Now a candidate must be a full 8-byte header with a valid header
checksum (bytes 0..6) AND a known frame type (0x0A__ / 0x0F09), and clear the ≥3
threshold; removed the weak single-hit fallback. Real sensors stream valid frames
continuously, so detection of present hardware is unaffected.

Co-authored-by: ruv <ruvnet@gmail.com>
2026-06-17 11:24:23 -04:00
rUv 128b129474 Merge pull request #1120 from ruvnet/fix/1007-paired-data-pipeline
fix(paired-data): 4 bugs in CSI recorder + ground-truth aligner (#1007)
2026-06-17 10:26:14 -04:00
ruv 15a983b555 fix(paired-data): 4 bugs corrupting/blocking camera-supervised training data (#1007)
1. record-csi-udp.py stamped LOCAL time with a 'Z' (UTC) suffix → camera/CSI disagreed
   by the UTC offset → 0 aligned pairs. Now writes true UTC via datetime.now(timezone.utc).
2. align-ground-truth.js kept empty-keypoint (non-detection) records at confidence 0,
   collapsing window avgConf below threshold → all windows rejected. Now skipped at load.
3. extractCsiMatrix silently zero-padded/truncated mixed-subcarrier frames. Now frames
   are filtered to the session's modal subcarrier count before windowing — never padded.
4. CSI/feature matrices are filled frame-major but were labeled shape [nSc, nFrames] —
   transposed. Labels corrected to [nFrames, nSc] / [nFrames, dim].

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-17 10:17:12 -04:00
rUv c6e7667676 Merge pull request #1104 from ruvnet/fix/issue-1049-configurable-guard
fix(sensing-server): make multistatic guard interval configurable (closes #1049)
2026-06-17 09:53:23 -04:00
rUv d639c747df Merge pull request #1114 from ruvnet/examples/through-wall-tools
examples(through-wall): ESP32 sensor auto-detection + WiFlow analysis tools
2026-06-16 17:02:38 -04:00
ruv 42c764652d examples(through-wall): ESP32 sensor auto-detection + WiFlow analysis tools
- wiflow_browser.html: auto-detect live ESP32 nodes from the /ws/sensing stream and lock
  them as the model schema (NODE_IDS/CSI_DIM dynamic), persisted + restorable
- wiflow_ab.py: leakage-controlled A/B (chronological/random/blocked-gap/grouped-bucket,
  multi-seed) — the honest CSI→pose evaluation harness
- wiflow_capture.py / wiflow_train.py / wiflow_infer.py: camera-paired capture + train + infer
- pose.html: live WiFi-inferred skeleton viewer; serve.py: static server
- gitignore the regenerable 1.5MB model.npz artifact

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 17:00:57 -04:00
rUv db02956c22 Merge pull request #1113 from ruvnet/chore/bump-ruv-neural-submodule
chore: bump ruv-neural submodule to current main
2026-06-16 17:00:56 -04:00
ruv c84ea39e62 chore: bump ruv-neural submodule → current main (web console, closed-loop neuromod, ruvn mention)
Advances the vendored ruv-neural submodule from the stale 'Initial' pin (1ece3af) to
current main (81be9e1): the static web console UI, the closed-loop neuromodulation
platform, repositioned README, and the @ruvnet/ruvn companion-tool mention.
ruv-neural is not a v2 workspace member, so this does not affect the workspace build
(cargo metadata resolves clean).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 17:00:13 -04:00
rUv 760d05026c Merge pull request #1112 from ruvnet/chore/extract-swarm-worldgraph-submodules
Extract ruview-swarm → ruvnet/ruv-drone and world crates → ruvnet/worldgraph (submodules)
2026-06-16 16:49:58 -04:00
ruv a784546918 ci(ruview-swarm): drop removed itar-unrestricted feature from test matrix
The industrial rescope (ruv-drone) removed the itar-unrestricted feature flag —
formation/allocation/raft/flight-control are now default capabilities. Update the
'ruflo+itar' matrix entry to just '--features ruflo' so CI matches the new feature set.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 16:34:06 -04:00
ruv 9c751d0d92 chore(worldgraph): bump submodule — README + metadata polish
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:52:34 -04:00
ruv a13e9b66cb chore: bump ruv-drone + worldgraph submodules (LICENSE + CI polish)
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:43:10 -04:00
ruv 6db183bf3e chore(swarm): bump ruv-drone submodule — README cleanup
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:35:06 -04:00
ruv f65d0f79e7 chore(swarm): bump ruv-drone submodule (rescope + stray-file cleanup)
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:28:30 -04:00
ruv 7fb3b88061 chore(swarm): bump ruv-drone submodule — industrial rescope (drop ITAR/USML gating)
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:27:24 -04:00
ruv aeac5f5543 chore(worldgraph): extract geo+worldgraph+worldmodel to ruvnet/worldgraph submodule
- published as github.com/ruvnet/worldgraph (3-crate workspace, history via git-filter-repo)
- replace the 3 in-tree crates with one submodule at v2/crates/worldgraph
- parent workspace: drop the 3 members, exclude the submodule (it is its own workspace),
  repoint workspace.dependencies(worldmodel) + engine/sensing-server path-deps into it
- cargo metadata resolves clean (geo/worldgraph/worldmodel consumed from the submodule)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:14:34 -04:00
ruv c257e67c3d chore(swarm): extract ruview-swarm to ruvnet/ruv-drone submodule
- ruview-swarm published as github.com/ruvnet/ruv-drone (history preserved via subtree split)
- replace the in-tree crate with a submodule at v2/crates/ruview-swarm (branch main)
- standalone repo dropped the unused wifi-densepose-core path-dep; export-control NOTICE added there
- workspace member path unchanged; cargo metadata resolves ruview-swarm from the submodule

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 14:03:56 -04:00
ruv a4d5ea88f3 feat(examples): in-browser WiFlow trainer + camera-supervised pipeline + ADR-180/181/181A
Tonight's real WiFlow work, all honest:
- examples/through-wall/: live 2-node CSI demo (index.html), the WiFlow
  camera-supervised pipeline (wiflow_capture/train/infer.py — proven +9.4pp
  over mean-pose baseline on ruvultra), the live pose viewer (pose.html),
  and the COMPLETE in-browser trainer (wiflow_browser.html): 4-stage
  calibrate->capture->train->infer, TF.js WebGPU/WASM/WebGL, MediaPipe
  camera supervision, IndexedDB persistence, mean-pose-baseline honesty.
- ADR-180 (through-wall hand-off demo), ADR-181 (full browser WiFlow,
  WASM+WebGPU, calibration phase, mobile/secure-context matrix),
  ADR-181A (binary CSI framing protocol).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 17:31:19 -04:00
ruv ebe217569b feat(examples): real live WiFi-CSI through-wall sensing demo
Self-contained Three.js r128 demo at examples/through-wall/ that renders
ONLY genuine data streamed from the running sensing-server over
ws://localhost:8765/ws/sensing. No simulation, no fabricated frames, no
fake skeleton.

Renders, driven by real /ws/sensing frames:
- 20x20 signal_field floor heatmap (real values)
- coarse RF-localization puck from persons[0].position (labeled coarse,
  NOT pose; peak signal_field cell as fallback)
- live motion/breathing/variance/rssi bars + motion sparkline
- presence/confidence/estimated_persons/active-node/tick/Hz meters
- 3D room with wall + doorway dividing office (node 9) / hallway (node 13)
- honest mutually-exclusive banner: LIVE (source=esp32) / SIMULATED /
  NO SERVER, showing the real source verbatim
- optional webcam tile (ground-truth-when-visible, separate from CSI)

Reuses scene/lights/bloom/CSS + webcam path from
examples/three.js/demos/05-skinned-realtime.html, the floor-heatmap idea
from ui/observatory/js/, and the threaded no-cache server from
examples/three.js/server/serve-demo.py (serve.py on :8080).

Verified against the live server: real frame source=esp32, nodes [9,13],
400 signal_field values, persons[0].position present. Python proof PASS.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 16:20:49 -04:00
ruv c27d6cc98e fix(sensing-server): make multistatic guard interval operator-configurable (#1049)
Two ESP32-S3 nodes on WiFi/ESP-NOW sync drift 10-150 ms (~70 ms typ.), exceeding
the 60 ms default guard → permanent trust demotion to Restricted, all pose output
suppressed, 200k+ errors, no escape but a container restart.

Add a direct WDP_GUARD_INTERVAL_US override (+ optional WDP_SOFT_GUARD_US) to
multistatic_guard_config_from_env. Precedence (most-specific wins): direct
override > WDP_TDM_SLOTS+WDP_TDM_SLOT_US schedule-derived > 60ms/20ms default.
Soft band always clamped strictly below hard; malformed/zero ignored (falls back,
never breaks fusion). Effective guard logged at startup.

Pinned by 6 tests (multistatic_guard_config_tests). sensing-server bin tests
449 -> 455, 0 failed. Python proof PASS, hash unchanged (off signal path).

Closes #1049.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 13:41:43 -04:00
rUv cafbeb1e81 fix(wasm-edge): sanitize non-finite host floats at the WASM↔host frame boundary (#1102)
Closing beyond-SOTA security review of wifi-densepose-wasm-edge (ADR-040,
~70 edge modules). The two WASM↔host boundaries (lib.rs::on_frame/on_timer
and bin/ghost_hunter.rs::on_frame) read raw IEEE-754 f32 from the csi_get_*
imports with no finiteness check — the crate had zero is_finite/is_nan
guards and its clamp helpers propagate NaN. A single non-finite host value
latches NaN into long-lived per-module accumulators (EMA / Welford / phasor
sums / anomaly baselines), after which detectors fail degraded (stuck gate
state, silently-disabled checks) — silent corruption, not a crash.

Add sanitize_host_f32() (non-finite -> 0.0, core-only for no_std) applied at
every host_get_* float read: one chokepoint covering all downstream modules,
mirroring the existing M-01 negative-n_subcarriers boundary clamp. LOW /
defense-in-depth (the Tier-2 DSP firmware supplies the imports, a semi-trusted
boundary).

Pinned by boundary_tests::{sanitize_passes_finite_values_through,
sanitize_maps_non_finite_to_zero,
coherence_monitor_nan_latches_without_sanitize_but_not_with} — the last
asserts on the current CoherenceMonitor that a raw NaN frame latches the
smoothed score while the sanitized path stays finite.

Other review dimensions attested clean with evidence (see CHANGELOG): no
hot-path panics (all unwrap/expect are test-only or std-gated RVF builder),
all bounds min()-clamped, all index-by-cast const-bounded or guarded, no
leaking closures (no move||/forget/leak), no secrets.

Verified: host `cargo test --features std,medical-experimental` 672 passed /
0 failed (+3 new tests); all three wasm32-unknown-unknown release artifacts
build clean (lib default no_std/panic=abort, ghost_hunter standalone-bin,
medical-experimental); Python proof VERDICT PASS, hash unchanged.
2026-06-15 13:06:46 -04:00
rUv c859f6f743 security(occworld-candle): int32-checkpoint crash + degenerate-input guards + ADR-179 (closes Milestone #9) (#1101)
* fix(occworld-candle): security review fixes — int32 checkpoint crash + predict input validation

Beyond-SOTA security + correctness review of wifi-densepose-occworld-candle
(Milestone #9, crate 4/4 — the last ungated crate).

Findings fixed:

1. HIGH (MEASURED) — checkpoint-load crash on any int32 tensor.
   model.rs mapped safetensors I32 -> candle DType::I64 and passed the raw
   int32 byte buffer (4 bytes/elem) to Tensor::from_raw_buffer(.., I64, ..).
   Candle derives elem_count = data.len() / dtype.size(), so the I64 path
   halved the count while keeping the original shape -> a tensor whose shape
   claims 2x its storage. Reading it PANICS (slice OOB: "range end index 6
   out of range for slice of length 3") on any checkpoint containing an int32
   tensor. Fixed: I32 -> DType::I32, I16 -> DType::I16 (both first-class
   candle dtypes). Reproduced on old code; pinned in tests/checkpoint_loading.rs.

2. LOW (MEASURED) — predict() lacked frame/batch validation at the input
   boundary. f_in > num_frames*2 over-indexed the temporal embedding (cryptic
   candle "gather" error); zero frame/batch fed a zero-element tensor in. Now
   rejected with a clear ShapeMismatch. Pinned in tests/input_validation.rs.

3. LOW (MEASURED) — divide-by-zero panic in the public VQCodebook::encode on a
   rank-0 / empty-last-dim tensor (last == 0). Now fails closed with a clear
   error. Pinned in vqvae.rs unit tests.

Dimensions confirmed clean with evidence: panic surface (no unwrap/expect/
panic in prod paths), NaN-state-poisoning (N/A — stateless engine, u8 input),
unbounded-alloc/shape-data mismatch (defended upstream by safetensors::
validate), secrets (none). unsafe_code = forbid.

Validation (MEASURED, Windows): crate 31/31 pass; workspace 0 failed (lone
desktop api_integration "Access is denied" file-lock flake passes 21/21 in
isolation); Python proof VERDICT PASS, hash f8e76f21…446f7a unchanged.

Warrants ADR slot 179 (parent to author).

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr): ADR-179 — occworld-candle checkpoint-load hardening (closes Milestone #9)

Records the HIGH int32-checkpoint crash fix (I32→I64 dtype-widening → slice-OOB
panic on load = DoS) + 2 LOW degenerate-input fixes from 5e77f47e5. Stateless
engine (NaN-poisoning N/A), unsafe forbidden, safetensors validate() defends
malloc upstream. occworld 31/31. Final ungated crate — Milestone #9 complete.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 12:35:29 -04:00
rUv 10c813fde3 security(desktop): IPC serial-command-injection + over-broad shell capability + ADR-178 (#1100)
* fix(security): desktop IPC serial-command-injection + over-broad shell capability (ADR-178)

Beyond-SOTA security review of wifi-densepose-desktop (Tauri v2). Two real
findings, each MEASURED on Windows (crate builds + tests under
--no-default-features):

WDP-DESK-01 (MODERATE) — serial command injection via configure_esp32_wifi.
The #[tauri::command] handler concatenated webview-supplied ssid/password into
newline-terminated serial commands with no validation; a \r\n let a compromised
webview inject an arbitrary follow-up firmware command (reboot/erase). Added
validate_wifi_credentials() enforcing WPA2 length bounds and rejecting all
control characters, called fail-closed before any serial write. Pinned by 3
new tests (rejects \r\n / \n / NUL injection, rejects out-of-range, accepts
valid boundaries).

WDP-DESK-02 (MODERATE) — removed unused shell:allow-execute / shell:allow-open
from capabilities/default.json. The Rust backend spawns processes via
std::process::Command (bypassing the allowlist) and the UI only uses
dialog.open; the shell perms were unused privilege granting the webview
arbitrary host command execution on compromise. Regenerated capabilities.json
confirms only core:default + dialog perms remain.

lib tests 18 -> 21 (+3 pins), integration 21 -> 21, 0 failed. Python
deterministic proof unchanged (f8e76f21...46f7a; desktop off the signal path).

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr): ADR-178 — desktop IPC injection fix + capability least-privilege

Records the 2 MEASURED MODERATE fixes in feddcde9d: WDP-DESK-01 (webview
ssid/password \r\n-injected arbitrary firmware serial commands → validated
fail-closed) and WDP-DESK-02 (unused shell:allow-execute/open capability
granted to the webview → removed). 30-command IPC surface + capability scope
audited; 6 dimensions clean-with-evidence. desktop 18→21.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 12:01:17 -04:00
rUv 20ad75f30c feat(ADR-131): HOMECORE-UI dashboard + BFF gateway — review-fixed (supersedes #1082) (#1099)
* feat(ADR-131): HOMECORE-UI operational dashboard + BFF gateway

Complete two-tier Cognitum operator dashboard (ADR-131), served by
homecore-server at /homecore, plus the single-origin BFF gateway that
wires it to real backends.

Front-end (zero-dep vanilla TS/JS + CSS, exact Cognitum design tokens):
- All 10 panels (§4.1-4.10): dashboard, SEED fleet + detail, fleet map,
  entities (live WS subscribe_events, never polls), rooms, COGs,
  calibration wizard, events + automation builder, witness/audit, settings.
- §6 UX invariants in code: first-class provenance, prominent stale/veto/
  fragility, null(not-trained) vs withheld vs error, --mono everywhere,
  Hailo vs CPU COG distinction.
- api.js calls the gateway routes in production; mock demoted to a
  dev-only ?demo=1 fixture (no mock in prod); typed error states.
- Tests under plain node: import-graph, boot, render-smoke (22),
  interaction (3), prod-errors (13) — 5 files green; bundle ~137 KB
  (~37x smaller than HA), <2 ms/cold-render.

BFF gateway (homecore-server/src/gateway.rs, compiled + tested on Rust 1.89):
- /api/cal/* reverse-proxy to the calibration API (ADR-151).
- GET /api/homecore/rooms with the RoomState adapter (breathing->breathing_bpm,
  heartbeat:null->heart_bpm:null, injected anomaly.threshold/room_id).
- GET /api/homecore/cogs supervisor over /var/lib/cognitum/apps/.
- GET /api/homecore/appliance from /proc + TCP service probes.
- SEED-device/appliance routes return typed 503 upstream_unavailable.
- cargo test -p homecore-server = 12/12; run live (curl-verified);
  fixed a real double-v1 proxy-URL bug found during live testing.

Honest scope: W1/W2/W4/W6-appliance functional; W3/W5/W6-Hailo/federation
return typed 503 (depend on services/hardware not in this repo).

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(homecore-ui): resolve code-review findings — SSRF guard, CORS/trace coverage, §6 honesty, crash guards

Addresses the high-effort review of PR #1082:
- SECURITY: cal_proxy rejects path-traversal/confused-deputy SSRF (`.`/`..`
  segments, backslash, %2e%2e/%2f, absolute) on raw+decoded forms → 400,
  before attaching the server-side calibration bearer.
- CORRECTNESS: /api/homecore/* + /api/cal/* now covered by the shared CORS
  allowlist (build_cors_layer, exported from homecore-api) + TraceLayer —
  previously merged outside router()'s layers (no CORS, no tracing).
- §6 HONESTY (no fabricated data): dashboard renders '—' for null metrics
  (not "null%"/"null°C"); cogs Hailo pill reflects the REAL appliance probe
  (not hardcoded "connected"); room anomaly threshold passed through / null,
  not a fabricated 0.5.
- ROBUSTNESS: cogs asArray(hef) guards a non-array manifest field; calibration
  progress guards target<=0 (no NaN%/Infinity%); restart clears the poll timer.
- CLEANUP: mock.js is now a cached DYNAMIC import (demo-only) — never bundled
  in production (§2.2).
- New ui/tests/unit-fixes.mjs pins the above; ADR-131 + CHANGELOG updated.

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: Nick Ruest <127058086+nicholas-ruest@users.noreply.github.com>
2026-06-15 11:11:19 -04:00
rUv 1df6d1e1ee security(nvsim): guard degenerate input — config panic + NaN silent-corruption + ADR-177 (#1098)
* fix(nvsim): guard degenerate input — config-induced panic + NaN-state poisoning

Beyond-SOTA security review of the ADR-089 NV-diamond simulator (milestone #9,
crate 2 of 4). Two real degenerate-input findings, each pinned fails-on-old:

NVSIM-DT-01 (config panic/DoS, pipeline.rs): an external f_s_hz == 0 made
dt == +Inf, dt_us saturated to u64::MAX, and `sample * dt_us` panicked with
"attempt to multiply with overflow" at sample >= 2 (debug/WASM panic=abort;
garbage t_us in release). Fix: sanitise dt (non-finite/non-positive -> 1 µs
fallback), cap the u64 cast, and saturating_mul the timestamp.

NVSIM-NAN-01 (NaN-state poisoning, digitiser.rs): a non-finite scene parameter
(NaN dipole position / Inf moment / NaN loop radius) bypasses the near-field
clamp (NaN < R_MIN_M is false) and yields a NaN field; at the ADC `NaN as i32`
== 0 silently emitted b_pt=[0,0,0] with ADC_SATURATED CLEAR — indistinguishable
from a legit zero-field reading. Fix at the funnel: adc_quantise treats any
non-finite input as out-of-range -> clamps to code 0 AND raises the saturation
flag, so the corruption is visible downstream.

Determinism integrity, panic-free MagFrame deserialisation, and RNG seeding
confirmed clean with evidence. The published cross-machine witness
(cc8de9b0…93b4) is unchanged — guards only affect degenerate inputs.

cargo test -p nvsim --no-default-features: 50 -> 53 passed, 0 failed.
Workspace green; Python deterministic proof unchanged (f8e76f21…46f7a,
nvsim off the signal proof path). Needs ADR slot 177.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr): ADR-177 — nvsim degenerate-input hardening

Records the 2 MEASURED MEDIUM fixes in 37764be55 (NVSIM-DT-01 config-induced
overflow panic / WASM-abort DoS; NVSIM-NAN-01 non-finite scene param →
silent fake zero-field reading with saturation flag clear) + 3 pins, and the
clean-with-evidence determinism/deser/div-by-zero verdict. Cross-machine
witness cc8de9b0…93b4 reproduces unchanged.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 10:55:04 -04:00
rUv 4a083999e5 security(ruview-swarm): fail-closed on NaN/Inf at the swarm-comm trust boundary + ADR-176 (#1096)
* fix(ruview-swarm): fail-closed on NaN/Inf at swarm-comm trust boundary (ADR-148)

Beyond-SOTA security review of the ADR-148 drone swarm control plane found
four IEEE-754 NaN/Inf fail-open / DoS bugs on data crossing the untrusted
swarm-comm boundary (receive_peer_state / receive_peer_detection accept full
DroneState/CsiDetection whose f64/f32 fields deserialize with no finite-check).

- HIGH: failsafe::tick collision-avoidance + battery checks fail-open on NaN
  (NaN < threshold == false silently disabled collision avoidance / kept a
  NaN-battery drone Nominal). Now fails closed to EmergencyDiverge / RTH.
- MED: geofence::check NaN-altitude bypass returned Safe through the
  point-in-polygon path. Now leading non-finite-coordinate guard -> HardBreach.
- MED/DoS: antijamming FhssRadio panicked with "% 0" on an empty deserialized
  channels_mhz. Now len==0 early-returns (benign 0.0 sentinel).
- LOW: multiview::fuse propagated a NaN victim_position into the fused
  "confirmed victim" location. Now requires finite confidence + position.

Each fix pinned by a fails-on-old / passes-on-new test (MEASURED: old code
returned Nominal/Safe or panicked). cargo test -p ruview-swarm
--no-default-features: 117 -> 123 passed, 0 failed. Workspace green; Python
deterministic proof unchanged (f8e76f21...46f7a, off the signal path).

Documented-not-fixed (ADR slot 176): Raft AppendEntries lacks Log-Matching
consistency check (topology/raft.rs); MavlinkSigner::verify uses non-constant
-time tag compare + no replay-window rejection (already doc-flagged).

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr): ADR-176 — ruview-swarm NaN-fail-open safety review

Records the 4 MEASURED fail-open safety bugs fixed in f671000d7 (collision
avoidance, battery RTH, geofence, anti-jamming %0 panic — all NaN/Inf
defeating a safety comparison at the swarm-comm trust boundary) + 6 pins,
5 clean-with-evidence dimensions, and the 2 genuine issues deferred to a
focused follow-up (Raft AppendEntries log-matching; MAVLink signer
constant-time + replay window).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 09:55:40 -04:00
rUv 0f64d23516 feat(bench): int8 quantization of WiFlow-STD half pose model — MEASURED trade-off (ADR-175, honest negative) (#1095)
Sub-deliverable 8.2 of the benchmark/optimization milestone. Quantizes the
843,834-param "half" WiFlow-STD pose model (half_best.pth) to int8 two ways and
MEASURES the accuracy/size trade-off vs fp32 under ONE locked normalization
(ADR-173 torso-diameter PCK, upstream calculate_pck use_torso_norm=True), on the
same seed-42 file-level 70/15/15 test split that produced the fp32 sweep numbers.

MEASURED on ruvultra (RTX 5080, torch 2.11.0+cu128, fbgemm; clean test, torso-PCK):
  fp32             96.62% pck@20  99.47% pck@50  0.008981 mpjpe  3.351 MB
  int8 PTQ static  40.98% pck@20  94.98% pck@50  0.038262 mpjpe  1.046 MB  (-55.64pp)
  int8 QAT (3 ep)  67.48% pck@20  98.69% pck@50  0.026548 mpjpe  1.043 MB  (-29.15pp)

Verdict (honest no): int8 is NOT a win at the strict PCK@20 edge target. Static
PTQ collapses; QAT recovers a large share but still loses 29 pp @20 for a 3.2x
size win — keep fp32/fp16 on the edge. Disclosed: QAT fake-quant val pck@20 was
83.45% but converted int8 scores 67.48% (~16pp convert_fx gap, reported honestly).

Deliverables:
- v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py (reproducible:
  header carries the exact ssh command + run date; QAT primary, static PTQ fallback)
- docs/adr/ADR-175-int8-quantization-half-pose-model-measured.md (MEASURED table,
  locked normalization, QAT-vs-PTQ labeling, verdict, reproduction, limitations)
- CHANGELOG [Unreleased] ### Added entry

No production Rust or signal-pipeline change. Python deterministic proof unchanged
(f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a, bit-exact).
2026-06-15 09:16:22 -04:00
rUv b209b8b778 ci(bench): compile-verify regression gate for v2 criterion benches + ADR-174 (#1094)
* ci(bench): wire v2 criterion benches into CI as a compile-verify regression gate

Sub-deliverable 8.3 of the benchmark/optimization milestone (needs ADR slot 174).

The v2/ workspace ships 26 criterion benches across 18 crates, but benches are
not part of `cargo test`, so nothing in CI compiled them and they silently rot
when a public API they call changes.

Add `.github/workflows/bench-regression.yml`:
  - bench-compile (HARD GATE): `cargo bench --workspace --no-default-features
    --no-run` compiles + links every default-feature bench (no measurement) plus
    the cir-gated cir_bench — a real, deterministic regression guard against
    bench bit-rot.
  - bench-fast-run (INFORMATIONAL, continue-on-error, never gates): runs a
    curated pure-CPU subset (nvsim, ruvector sketch/fusion) in criterion
    quick-mode and uploads logs as an artifact.

No timing-regression gate, by design: wall-clock on shared GitHub runners varies
2-3x run-to-run, so a hard threshold or cross-runner `criterion --baseline`
compare would manufacture false failures. The honest scope is compile-verify +
informational-run; the workflow header documents the self-hosted-runner
condition under which true timing-gating becomes honest. The crv-gated crv_bench
is excluded because its crates.io dep ruvector-crv 0.1.1 fails to build upstream.

Running the gate immediately caught one already-bit-rotted bench:
wifi-densepose-mat/detection_bench failed to compile (E0063: missing field
last_rssi in SensorPosition). Fixed (last_rssi: None) and re-verified.

Validation (MEASURED): mat detection_bench + cir_bench + nvsim + ruvector +
vitals + swarm benches compile under --no-default-features; fast subset runs;
`cargo test -p wifi-densepose-mat --no-default-features` 174 passed / 0 failed;
Python proof PASS, hash f8e76f21...46f7a unchanged.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr): ADR-174 — CI bench-regression compile-verify gate

Records sub-deliverable 8.3 (bench-regression.yml, committed c4c59e085):
a hard compile-verify gate over all 26 v2 criterion benches (caught + fixed
one real bit-rotted bench, mat/detection_bench E0063) + an informational
fast-run. Documents the honest scope — no timing-regression gate, since
shared-runner wall-clock varies 2-3x; states the self-hosted-runner condition
under which timing gating becomes honest.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 08:26:38 -04:00
215 changed files with 12074 additions and 15016 deletions
+199
View File
@@ -0,0 +1,199 @@
name: Bench Regression Guard
# Sub-deliverable 8.3 of the benchmark/optimization milestone.
#
# HONEST SCOPE (read this before assuming this gates on timing):
# * The `bench-compile` job is a REAL, HARD-FAILING regression gate. It runs
# `cargo bench --no-default-features --no-run`, which type-checks and links
# EVERY criterion bench in the v2/ workspace without running a single
# measurement. Benches are not part of `cargo test`, so they silently
# bit-rot when a public API they call changes — this job catches that the
# moment it happens. This is the part of this workflow that can fail a PR.
#
# * The `bench-fast-run` job runs a small, curated subset of pure-CPU benches
# in criterion "quick mode" (short warm-up / measurement / 10 samples) and
# is INFORMATIONAL ONLY (`continue-on-error: true`). It does NOT gate on
# timing. Wall-clock timings on shared GitHub-hosted runners vary by
# 2-3x run-to-run (noisy neighbours, CPU throttling, no pinned frequency),
# so a hard ">X ms" threshold here would flake constantly and teach
# everyone to ignore it. We deliberately do not pretend to do timing
# regression-gating we cannot deliver reliably. The numbers are surfaced in
# the job log + uploaded as an artifact for humans to eyeball trends.
#
# WHY NO criterion --baseline COMPARE GATE:
# criterion's `--save-baseline` / `--baseline` compare is the textbook
# regression mechanism, but it only produces a trustworthy verdict when the
# baseline and the candidate were measured on the SAME hardware under the SAME
# conditions. GitHub-hosted runners give neither (the baseline commit and the
# PR commit land on different physical machines). Committing a baseline JSON
# measured on one runner and comparing a different runner against it would
# manufacture false regressions. If/when these benches run on a dedicated,
# frequency-pinned self-hosted runner, a `--baseline` compare with a generous
# (>2x) noise floor becomes honest and can be added then. Until then,
# compile-verify + informational-run is the honest gate.
on:
push:
branches: [ main, develop, 'feat/*' ]
paths:
- 'v2/crates/**/benches/**'
- 'v2/crates/**/Cargo.toml'
- 'v2/crates/**/src/**'
- 'v2/Cargo.toml'
- 'v2/Cargo.lock'
- '.github/workflows/bench-regression.yml'
pull_request:
paths:
- 'v2/crates/**/benches/**'
- 'v2/crates/**/Cargo.toml'
- 'v2/crates/**/src/**'
- 'v2/Cargo.toml'
- 'v2/Cargo.lock'
- '.github/workflows/bench-regression.yml'
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
# Debuginfo is useless in CI and the 38-crate workspace target dir otherwise
# exhausts the runner disk (mirrors ci.yml's rust-tests job). The bench
# profile inherits release + debug = true (v2/Cargo.toml [profile.bench]);
# force it off so the link step does not run out of space.
CARGO_PROFILE_BENCH_DEBUG: "0"
CARGO_PROFILE_RELEASE_DEBUG: "0"
jobs:
# ── HARD GATE: every bench must still compile + link ─────────────────────
bench-compile:
name: bench compile-verify (--no-run)
runs-on: ubuntu-latest
steps:
- name: Checkout (recursive — wifi-densepose-rufield path-deps vendor/rufield)
uses: actions/checkout@v4
with:
# The workspace includes `wifi-densepose-rufield`, which path-deps the
# `vendor/rufield` submodule crates. Without a recursive checkout the
# whole workspace fails to resolve before any bench is built.
submodules: recursive
# The workspace pulls in `wifi-densepose-desktop` (Tauri v2) whose -sys
# crates need the GTK/WebKit/serial dev libraries via pkg-config, exactly
# as ci.yml's rust-tests job documents. A `--workspace` bench build links
# the whole graph, so these are required here too.
- name: Install Tauri / GTK / serial system dev libraries
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libglib2.0-dev \
libgtk-3-dev \
libsoup-3.0-dev \
libjavascriptcoregtk-4.1-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libxdo-dev \
libudev-dev \
libdbus-1-dev \
libssl-dev \
pkg-config
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
with:
workspaces: v2
# Distinct cache scope from ci.yml's rust-tests so the bench profile
# artifacts (release+opt) do not evict the test profile cache.
key: bench-regression
# The core regression guard. `--no-run` compiles + links every bench
# target in the workspace's DEFAULT feature set but runs no measurement,
# so it is deterministic and fast-ish (build only). A bench that no longer
# compiles — because a type/signature it calls changed and nobody updated
# the bench — fails the build here. `--no-default-features` is the
# workspace's standard gate flag (openblas/tch/ort/onnx stay opt-out).
- name: Compile all workspace benches (default features)
working-directory: v2
run: cargo bench --workspace --no-default-features --no-run
# Feature-gated benches are skipped by the default build above because
# their `[[bench]]` entries carry `required-features`. Compile the ones we
# can guard so they are also covered against bit-rot.
# * cir → wifi-densepose-signal/benches/cir_bench.rs (ADR-134). The
# `cir` feature is pure-Rust (`cir = []`), so it builds on the stock
# runner and is a real, hard-failing guard like the step above.
#
# NOT guarded here (honest scope):
# * crv → wifi-densepose-ruvector/benches/crv_bench.rs. The `crv` feature
# pulls the crates.io dependency `ruvector-crv 0.1.1`, which currently
# FAILS to compile on stable (E0308 type mismatch in its own
# `stage_iii.rs` — an UPSTREAM bug, unrelated to bench bit-rot).
# Adding a hard `--features crv` compile step would make this workflow
# red for a reason this gate is not meant to police. Re-add this step
# once `ruvector-crv` ships a fixed release. (mqtt/onnx benches are
# likewise left to their own crate workflows.)
- name: Compile feature-gated benches (cir)
working-directory: v2
run: cargo bench -p wifi-densepose-signal --no-default-features --features cir --bench cir_bench --no-run
# ── INFORMATIONAL: run a curated fast subset (never gates) ───────────────
bench-fast-run:
name: bench fast-run (informational, non-gating)
runs-on: ubuntu-latest
# NEVER fail the workflow on this job — timings are noise-prone on shared
# runners (see header). It exists to surface trends for humans, not to gate.
continue-on-error: true
needs: [bench-compile]
steps:
- name: Checkout (recursive)
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo (Swatinem/rust-cache)
uses: Swatinem/rust-cache@v2
with:
workspaces: v2
key: bench-regression
# Curated subset = pure-CPU, fast, dependency-light criterion benches that
# finish in seconds under quick-mode flags. Each is targeted by `--bench`
# (NOT a bare `cargo bench -p`) because the crates' lib targets use the
# libtest harness, which rejects criterion's CLI flags (--warm-up-time
# etc.) and aborts the run. Quick-mode: 1s warm-up, 2s measure, 10 samples.
- name: nvsim pipeline_throughput (quick)
working-directory: v2
run: |
mkdir -p ../bench-out
cargo bench -p nvsim --no-default-features --bench pipeline_throughput -- \
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
| tee ../bench-out/nvsim_pipeline_throughput.txt
- name: ruvector sketch_bench (quick)
working-directory: v2
run: |
cargo bench -p wifi-densepose-ruvector --no-default-features --bench sketch_bench -- \
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
| tee ../bench-out/ruvector_sketch_bench.txt
- name: ruvector fusion_bench (quick)
working-directory: v2
run: |
cargo bench -p wifi-densepose-ruvector --no-default-features --bench fusion_bench -- \
--warm-up-time 1 --measurement-time 2 --sample-size 10 \
| tee ../bench-out/ruvector_fusion_bench.txt
- name: Upload informational bench logs
if: always()
uses: actions/upload-artifact@v4
with:
name: bench-fast-run-logs
path: bench-out/
if-no-files-found: warn
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
features:
- { label: 'default', flags: '--no-default-features' }
- { label: 'train', flags: '--features train' }
- { label: 'ruflo+itar', flags: '--features ruflo,itar-unrestricted' }
- { label: 'ruflo', flags: '--features ruflo' }
- { label: 'full+train', flags: '--features full,train' }
steps:
- uses: actions/checkout@v4
+14
View File
@@ -277,3 +277,17 @@ aether-arena/staging/
# MM-Fi benchmark dataset archives — large data, fetch separately, never commit
assets/MM-Fi/E0*.zip
assets/MM-Fi/*.zip
# through-wall demo: regenerable trained model artifact
examples/through-wall/model/
# RuView harness (npx ruview) build artifacts — ADR-182
harness/**/node_modules/
harness/**/*.tgz
harness/**/package-lock.json
harness/**/.claude-flow/
harness/**/ruvector.db
# ruvector runtime/hook DB — never tracked (any depth)
ruvector.db
**/ruvector.db
+8
View File
@@ -21,3 +21,11 @@
[submodule "vendor/rufield"]
path = vendor/rufield
url = https://github.com/ruvnet/rufield
[submodule "v2/crates/ruview-swarm"]
path = v2/crates/ruview-swarm
url = https://github.com/ruvnet/ruv-drone.git
branch = main
[submodule "v2/crates/worldgraph"]
path = v2/crates/worldgraph
url = https://github.com/ruvnet/worldgraph.git
branch = main
+13
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -601,6 +601,8 @@ claude --plugin-dir ./plugins/ruview
Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full details: [`plugins/ruview/README.md`](plugins/ruview/README.md).
**Portable harness — `npx @ruvnet/ruview`:** a lighter, host-portable companion to the in-repo plugin, minted via [MetaHarness](https://www.npmjs.com/package/metaharness) and hardened per [ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md). It runs **without cloning this repo** and on more hosts (Claude Code, Codex, Copilot, opencode, …), exposing the RuView operator tools (`onboard`, `verify`, `node_monitor`, `calibrate`, `node_flash`) over an MCP server — plus the project's **MEASURED-vs-CLAIMED honesty guardrail enforced in code** (`ruview.claim_check` flags untagged or retracted-"100%" accuracy claims). v0.1: the onboarding/verify/claim-check paths are tested (17/17, `verify.py` → PASS); the hardware tools are fail-closed wrappers. Try `npx @ruvnet/ruview` to onboard, or `npx @ruvnet/ruview claim-check --text "…"`. Source: [`harness/ruview/`](harness/ruview/README.md).
---
## 📖 Documentation
@@ -614,6 +616,7 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
| [**SENSE-BRIDGE — rvagent MCP server**](tools/ruview-mcp/README.md) | Dual-transport MCP server (`@ruvnet/rvagent`) bridging the RuView sensing stack to AI agents (Claude Code, Cursor, ruflo swarms). 6 tools wired: `ruview.presence.now`, `ruview.vitals.get_{breathing,heart_rate,all}`, `ruview.bfld.last_scan`, `ruview.bfld.subscribe`. stdio + Streamable HTTP (`POST /mcp`, Origin-validated, bearer-token auth, `127.0.0.1` bind). Full 20-tool Zod schema barrel + 5 RUVIEW-POLICY governance tools. 93 tests. [ADR-124](docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md). Try: `npx @ruvnet/rvagent stdio`. |
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
| [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
Binary file not shown.
@@ -0,0 +1,444 @@
# ADR-131: HOMECORE-UI — Operational dashboard for the two-tier Cognitum stack
| Field | Value |
|-------|-------|
| **Status** | Accepted — UI implemented (§10); full backend wiring specified (§11–§12) |
| **Date** | 2026-06-14 |
| **Deciders** | ruv |
| **Codename** | **HOMECORE-UI** — first-class operator dashboard inside the Cognitum Appliance shell |
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE state machine), [ADR-128](ADR-128-homecore-integration-plugin-system.md) (HOMECORE-PLUGINS), [ADR-129](ADR-129-homecore-automation-engine.md) (automation engine), [ADR-130](ADR-130-homecore-rest-websocket-api.md) (HOMECORE-API), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (recorder/semantic search), [ADR-151](ADR-151-room-calibration-specialist-training.md) (room calibration HTTP API), [ADR-100](ADR-100-cog-packaging-specification.md) (Cog packaging), [ADR-116](ADR-116-cog-ha-matter-seed.md) (cog-ha-matter), [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) (SEED RVF ingest), [ADR-105](ADR-105-federated-csi-training.md) (federated CSI training) |
| **Tracking issue** | TBD |
| **Parent** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (sub-ADR, HOMECORE-127…134 family) |
---
## 1. Context
HOMECORE (ADR-126 through ADR-134) is the native Rust + WASM + TypeScript port of Home Assistant running as the hub on the Cognitum v0 Appliance. As of P2, the state machine ([ADR-127](ADR-127-homecore-state-machine-rust.md)), API ([ADR-130](ADR-130-homecore-rest-websocket-api.md)), and COG runtime ([ADR-128](ADR-128-homecore-integration-plugin-system.md)) are in place. What is missing is a first-class dashboard UI that operators, integrators, and residents can use to manage the full two-tier hardware stack that HOMECORE coordinates.
### 1.1 The two-tier hardware model this UI must represent
This is the most important architectural constraint the UI must carry through every panel:
- **Cognitum SEED** — a Pi Zero 2 W-based edge node. It has its own RVF vector store (8-dim, content-addressed, with kNN queries), Ed25519 witness chain, SHA-256 ingest audit trail, onboard environmental sensors (BME280 temperature/humidity/pressure, PIR motion, reed switch, ADS1115 4-channel ADC, vibration), 13 drift detectors, an MCP proxy (114 tools, JSON-RPC 2.0, default-deny policy), 98 HTTPS API endpoints, and epoch-based swarm sync for multi-SEED deployments. SEEDs sit close to the ESP32 sensing nodes and receive feature vectors from them at 1 Hz. Multiple SEEDs can form a peer mesh. **This is the sensing and memory tier.**
- **Cognitum v0 Appliance** — a Pi 5 + Hailo-10H hub, running at `:9000`. It hosts the COG runtime (`/var/lib/cognitum/apps/`), the HOMECORE state machine and event bus, the calibration service, `ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`, and acts as the fleet coordinator for multi-room correlation and federated training. The Appliance is where HOMECORE runs, and it is what the dashboard user is sitting in front of. **This is the computation and orchestration tier.**
SEEDs are **subordinate nodes that the Appliance supervises** — they are not peers. The UI navigation hierarchy must reflect this: the Appliance is the root, SEEDs are children, ESP32 nodes are leaves.
### 1.2 What the UI is not
HOMECORE-UI is **not** a re-skin of the existing Cognitum Cog Store. It is a full operational dashboard that **extends** the Cognitum platform's shell — the Cog Store, API Explorer, and Guide already exist and must remain intact, with the HOMECORE dashboard added as a first-class navigation section alongside them.
---
## 2. Decision
Build HOMECORE-UI as a **complete** TypeScript + Rust→WASM frontend (per this ADR's §3 and the HOMECORE-127…134 family) that:
1. Lives at `http://cognitum-v0:9000/homecore` (or as a dedicated nav item in the Cognitum Appliance shell).
2. Is visually and stylistically seamless with the existing Cognitum platform — same dark theme, same design tokens, same component patterns as `https://seed.cognitum.one/store`.
3. Drives the HOMECORE REST + WebSocket API ([ADR-130](ADR-130-homecore-rest-websocket-api.md)) and the calibration HTTP API ([ADR-151](ADR-151-room-calibration-specialist-training.md)) for all data.
4. Updates in real-time via the homecore `subscribe_events` WebSocket channel. **The UI must never poll for entity state.**
**This is a decision to deliver the complete operational dashboard — every panel in §4.1 through §4.10, every navigation section in §5, fully wired to live data — not a design-system scaffold or a partial first cut.** A static layout shell with placeholder data is explicitly **out of scope as a deliverable**: the design system (§3) is a means to the complete UI, not an end in itself. The acceptance bar for this ADR is that an operator can drive the full two-tier stack — fleet, entities, rooms, COGs, calibration, events, audit, and settings — from the dashboard, against real APIs, with no panel left as a stub.
### 2.1 `homecore-server` is the single backend-for-frontend (BFF) gateway
The data the dashboard needs is spread across **three backend tiers that are not one process**: (a) `homecore-api` (`/api/*` REST + `/api/websocket`, mounted in `homecore-server`); (b) the **calibration API** (`/api/v1/*`, served by a *separate* binary — `wifi-densepose calibrate-serve` / `wifi-densepose-sensing-server`); and (c) the **SEED device tier + appliance daemons** (RVF vector store, witness chain, onboard sensors, reflex rules, COG supervisor, federation), which are physically separate HTTPS services on the SEED nodes and the appliance.
The browser must talk to **exactly one origin.** Therefore `homecore-server` is promoted to the **single BFF / API gateway** for HOMECORE-UI: it serves the static assets at `/homecore`, serves `homecore-api` at `/api/*`, and **adds a new `/api/homecore/*` namespace** that proxies and aggregates the calibration API and the SEED/appliance tiers server-side. The UI only ever issues same-origin requests; cross-service auth (SEED bearer tokens, calibration tokens) is held by the gateway and **never exposed to the browser**. This collapses the CORS/multi-port problem and gives one place to enforce the long-lived-access-token auth (§4.10).
### 2.2 No mock data in production
The in-browser mock layer that the first UI cut shipped behind DEMO banners (§7.1, prior revision) is **demoted to a dev-only fixture** gated behind an explicit `?demo=1` / `HOMECORE_UI_DEMO=1` flag. The production build wires **every** panel to a real gateway endpoint. The full endpoint contract and the backend work each panel needs are specified in **§11**; the staged path to get there is **§12**. A panel may show an empty/typed-error state when its upstream is down, but it must never silently render fabricated data.
---
## 3. Design system — Cognitum platform conventions
The implementor **must study `https://seed.cognitum.one/store` as the definitive design reference before writing a single line of CSS.** The existing platform's design tokens, extracted from production, are:
### 3.1 Colour palette (CSS custom properties)
| Token | Value | Role |
|---|---|---|
| `--bg` | `#0a0e1a` | page background (very dark navy) |
| `--bg2` | `#111627` | secondary background / nav strip |
| `--card` | `#171d30` | card / panel surface |
| `--card-h` | `#1e2540` | card hover state |
| `--border` | `#252d45` | all border strokes (≈0.67px, subtle) |
| `--t1` | `#e0e4f0` | primary text (near-white) |
| `--t2` | `#8890a8` | secondary / muted text |
| `--t3` | `#505872` | tertiary / disabled text |
| `--cyan` | `#4ecdc4` | primary action colour (Install buttons, live indicators, accents) |
| `--cyan-d` | `rgba(78,205,196,0.15)` | cyan tint background for status badges |
| `--green` | `#6bcb77` | success / online / healthy states |
| `--green-d` | `rgba(107,203,119,0.15)` | green tint background |
| `--amber` | `#d4a574` | warning / stale / degraded states |
| `--amber-d` | `rgba(212,165,116,0.15)` | amber tint background |
| `--red` | `#e06060` | error / offline / veto states |
| `--red-d` | `rgba(224,96,96,0.15)` | red tint background |
| `--purple` | `#a78bfa` | informational / epoch / chain indicators |
| `--purple-d` | `rgba(167,139,250,0.15)` | purple tint background |
| `--r` | `10px` | standard border radius on all cards and panels |
### 3.2 Typography
- `--font`: `'Segoe UI', system-ui, -apple-system, sans-serif` — all body and heading text.
- `--mono`: `'Cascadia Code', 'Fira Code', Consolas, monospace` — all entity IDs, API endpoints, hex values, JSON payloads, COG binary hashes.
### 3.3 Component patterns (from the live Cog Store and API Explorer)
- **Cards**: `background: var(--card)`, `border: 0.67px solid var(--border)`, `border-radius: var(--r)`, `padding: 24px`.
- **Category pills / status badges**: small `border-radius: 46px`, uppercase text, coloured background tint (e.g. `background: var(--cyan-d); color: var(--cyan)` for `RUNNING`; `background: var(--amber-d); color: var(--amber)` for `STALE`).
- **Primary action buttons**: `background: var(--cyan)`, `color: var(--bg)`, no border — matching the existing "Install" button style exactly.
- **Secondary / ghost buttons**: transparent background, `border: 1px solid var(--border)`, `color: var(--t1)` — matching the existing "Details" button style.
- **Nav strip**: `background: var(--bg2)`, text items in `--t2`, active item highlighted in `--cyan` with a bottom underline.
- **Featured card gradient borders**: top-edge linear gradient from `var(--cyan)` to `var(--purple)` — replicate for HOMECORE section headers.
- **Live metric cards** (API Explorer status page): icon + large numeric value in `--cyan` or `--green`, label in `--t2` below, on a `var(--card)` background.
- **Method badge pills** on the API Explorer (`GET` in green, `POST` in amber, `AUTH` in purple) — reuse this same pill system for COG status indicators.
The implementor **must not introduce new colours, typefaces, or border radii.** Every component should feel like it was built by the same team that built the Cog Store and the API Explorer. A user navigating from the Cog Store into the HOMECORE dashboard should not notice a visual seam.
---
## 4. UI sections — required panels
### 4.1 System Dashboard (the "home screen")
The always-visible overview panel. Modelled on the API Explorer's live metric cards. All values update in real-time.
- **v0 Appliance health strip** — reuse the exact metric-card pattern from `seed.cognitum.one/status`: one card each for CPU %, RAM usage, Hailo-10H inference load (% utilisation), Hailo temperature, uptime, and the running services (`ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`). Values in `--cyan`, labels in `--t2`. This strip is always at the top — it represents the machine the user is looking at.
- **SEED Fleet overview** — a grid of SEED node cards (one per paired SEED) on the `var(--card)` surface with `var(--border)`. Each card shows: online/offline status pill (green/red), firmware version, epoch number, current vector count, last ingest timestamp, and witness-chain validity badge. A collapsed row shows the SEED's 5 onboard sensors in summary (PIR: yes/no, door: open/closed, temperature from BME280). Offline SEEDs render the entire card with a `--red-d` background tint. Clicking a SEED card navigates to the SEED Detail view (§4.2).
- **ESP32 Node summary** — count of active ESP32 nodes per SEED, current frame rate (target: 100 Hz CSI + 1 Hz feature vectors), and a compact warning list for nodes with known issues (presence_score normalisation anomaly, stale firmware version).
- **COG Runtime status row** — a horizontal strip of status pills for each installed COG on the v0 Appliance. Pill colours follow the existing badge convention: `--green-d`/`--green` for running, `--red-d`/`--red` for failed, `--t3`/`--t2` for stopped. COG name in `--mono`. Clicking a pill navigates to COG Management (§4.6).
- **Event Bus activity indicator** — a small real-time sparkline showing the homecore broadcast channel event rate (events/sec). Indicate channel lag if a subscriber is falling behind the 4,096-event capacity.
### 4.2 SEED Detail View (per-SEED drill-down)
Accessible from the fleet grid. Full-page panel for a single SEED node, using the card + section-header pattern from the Cog Store's detail views.
- **SEED identity header** — `device_id` in `--mono`, firmware version, paired status in green, USB vs WiFi connection mode. A section-header gradient border (cyan → purple, matching the featured card style) visually separates this from Appliance content.
- **Vector Store panel** — current vector count, dimension (8), last kNN query latency, current epoch number, a small sparkline of ingest rate over the last hour, and a storage budget bar showing usage against the 100K working-set target. A "Compact now" button (`POST /api/v1/store/compact`) in ghost style. When usage exceeds 80%, the bar renders in `--amber`.
- **Witness Chain panel** — chain length (SHA-256 entries), last verification timestamp, a one-click "Verify chain" button (`POST /api/v1/witness/verify`), and an "Export attestation bundle" button for regulated deployments. The Ed25519 custody attestation (device-bound keypair, epoch + vector count + witness head) renders here. Chain length in `--purple`, following the existing epoch/chain colour convention.
- **Onboard Sensors panel** — live readings from all 5 sensors in individual sub-cards: BME280 (temperature °C, humidity %, pressure hPa), PIR (motion boolean with last-triggered timestamp), reed switch (open/closed with last-changed timestamp), ADS1115 (4 analog channels with configurable labels), vibration (boolean with last-triggered). These are ground-truth validators against CSI readings and are critical for diagnosing false positives in the mixture-of-specialists. Sensor values in `--cyan`; sensor names in `--t2`.
- **Reflex Rules panel** — the 3 pre-configured rules with current state: `fragility_alarm` (threshold 0.3 → relay actuator), `drift_cutoff` (threshold 1.0), `hd_anomaly_indicator` (threshold 200 → PWM brightness). Show last-fired time for each. The `fragility_alarm` threshold is the most commonly adjusted field and should be editable inline. Rules that have recently fired render with a `--amber-d` background tint.
- **Cognitive Analysis panel** — boundary fragility score (0.01.0, from Stoer-Wagner min-cut on the kNN graph) rendered as a progress bar: green below 0.3, amber 0.30.6, red above 0.6. High fragility (>0.3) indicates a regime change in the environment and should be visually prominent. Temporal coherence phase boundaries shown as a labelled timeline of detected environment state transitions. kNN graph rebuild cadence indicator (every 10 s).
- **Ingest pipeline status** — which ESP32 nodes feed this SEED, the packet type each is sending (`0xC5110003` native feature vectors vs `0xC5110002` vitals fallback path — distinguished visually since native is preferred), current ingest batch size, flush interval, and bridge path topology (direct vs host-laptop hop). The bridge-hop warning (known architectural limitation) renders in `--amber` since it adds a network hop.
### 4.3 SEED Fleet Map (multi-SEED topology)
For deployments with more than one SEED, a topology view showing the mesh:
- **Node hierarchy diagram** — v0 Appliance at root, SEEDs as second tier (grouped by room/zone), ESP32 nodes as leaves under each SEED. Lines represent active data flows. ESP-NOW mesh sync links between SEEDs shown as dashed lines. Connection health shown via line colour (green/amber/red). All labels in `--mono`.
- **Cross-SEED event deduplication indicator** — for events that span multiple SEEDs (one fall detected by two rooms; one occupant tracked through room A → hallway → room B), show a fusion badge indicating how many SEEDs contributed to the composite event.
- **Federation config** ([ADR-105](ADR-105-federated-csi-training.md)) — federated-learning round coordinator role (which SEED is the round coordinator), current round number, K healthy nodes selected, delta exchange status. **Model deltas only — never raw CSI** is a design invariant that must be labelled explicitly in the UI.
### 4.4 Entity & State Browser
The homecore state machine (`DashMap<EntityId, Arc<State>>`) is the authoritative source of truth. Every COG running on the v0 Appliance contributes entities.
- **Entity list by domain** — grouped by the `domain.` prefix of `EntityId`, using collapsible section headers. The 21 entities per ESP32 node (11 raw + 10 semantic primitives from `cog-ha-matter`) are the most important set. For each entity: current state string (in `--t1`), last-changed timestamp (in `--t3`), attribute map as collapsible JSON in `--mono`, and the Context (`user_id` + `parent_id` causality chain, critical for care/audit deployments). Entity IDs always in `--mono`.
- **SEED provenance badge** — each entity carries a small badge showing its data lineage: which ESP32 node → which SEED → which COG → homecore state machine. This trace is invaluable for debugging false positives and is a **first-class UI element, not a collapsed detail.**
- **Domain filter + semantic search** — filter by domain prefix and, once [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (homecore-recorder) lands, ruvector-backed semantic search: "when did the living room anomaly score last correlate with a door-open event?" A keyword filter across entity IDs and attribute keys ships in the initial release regardless of [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) status, given entity density; the semantic search layers on top once the recorder lands.
- **Real-time WebSocket feed** — entity states update live via the homecore `subscribe_events` WebSocket command ([ADR-130](ADR-130-homecore-rest-websocket-api.md)). The UI must never poll. Show a broadcast-channel lag indicator; warn visually if the subscriber is falling behind the 4,096-event channel capacity.
- **StateChanged detail panel** — clicking any entity opens a slide-over panel showing the full `StateChangedEvent`: `old_state`, `new_state`, `context.id`, `context.user_id`, and the `context.parent_id` chain rendered as a breadcrumb trail.
### 4.5 RoomState / Sensing Panel
Surfaces the mixture-of-specialists output from the calibration service — the highest-level per-room sensing result. Data comes from `GET /api/v1/room/state?bank=<room_id>` on the v0 Appliance.
- **Per-room cards** — one card per `room_id` on the `var(--card)` surface. Each card shows live `RoomState` JSON fields as sub-rows: presence (occupied/absent chip in green/red with confidence bar), posture (standing/sitting/lying chip with confidence), breathing BPM (numeric in `--cyan` with range indicator 630), heart rate BPM (numeric in `--cyan` with range indicator 40120), restlessness score (01 progress bar), and anomaly score (01 with normal/anomalous label, bar turns red above a configurable threshold).
- **STALE warning** — when `stale: true` (the specialist bank was trained against a different baseline), render the entire room card with a `--amber-d` background tint and a prominent amber banner reading "Bank stale — baseline has changed" with a direct "Recalibrate room" link into the calibration wizard (§4.7). This is the most common real-world failure mode and **must never be subtle.**
- **VETO indicator** — when `vetoed: true` (anomaly veto suppressed vitals/posture because the window was physically implausible), render the affected specialist slots in `--red` with a "Veto active" label. Values suppressed by veto **must not render as zeros** — they must render as explicitly withheld.
- **Null specialist placeholders** — specialists not yet trained (`null` in the specialist bank) render as "Not trained" placeholders in `--t3` with a small "Calibrate to enable" prompt in ghost style. They are **not** errors.
- **Confidence bars** — each specialist output has a confidence float, shown as a small inline bar (`--cyan` fill) next to the reading. Low confidence (< 0.4) renders the bar in `--amber`.
- **Multi-SEED fusion indicator** — for rooms served by multiple SEEDs, show a small badge indicating how many SEED nodes contributed to the `MultiNodeMixture` for this room's reading.
### 4.6 v0 Appliance COG Management
The v0 Appliance hosts COGs at `/var/lib/cognitum/apps/`. This panel is the operational companion to the existing Cog Store (`seed.cognitum.one/store`). It must match the Cog Store's visual conventions precisely — same card layout, same category pills, same install/detail button pair — because operators will move between the two surfaces.
- **Installed COGs list** — for each COG: `id` and `version` in `--mono`, architecture badge (`arm`/`hailo10` etc., category-pill pattern), status pill (running/stopped/failed/updating in green/grey/red/amber), `binary_sha256` verified badge (Ed25519 signature verification shown as a shield icon in `--green` or `--red`), and PID from the pid file. Actions: start, stop, restart (ghost style), and view `output.log` / `error.log` in a monospace drawer using `--mono`. Edit `config.json` inline with syntax highlighting.
- **COG Store / App Registry** — browsable `app-registry.json` listing. This panel should visually mirror `seed.cognitum.one/store` as closely as possible — same featured-card hero layout, same icon + title + description + category pill + action button structure. One-click install downloads the binary from GCS, verifies `binary_sha256` + `binary_signature`, writes the manifest, and starts the COG. Show which new homecore entities will appear in the state machine after install, as a preview list before confirming.
- **OTA Updates** — a badge count on installed COGs with available updates, matching the "Installed (N)" tab badge convention from the existing Cog Store. Show a diff panel (version change, new entities, config schema changes) before confirming the update.
- **Hailo HEF status** — for COGs with `arch: hailo10`: loaded HEF files on the Hailo-10H, current inference throughput, and `ruvector-hailo-worker:50051` connection status. The RF Foundation Encoder ([ADR-150](ADR-150-rf-foundation-encoder.md)) and neural pose head display here once available.
### 4.7 Calibration Wizard
The full baseline → enroll → train → verify pipeline runs via HTTP against the v0 Appliance ([ADR-151](ADR-151-room-calibration-specialist-training.md)). This is a multi-step guided flow — not a raw API panel. Use a stepped wizard layout with a progress indicator at the top (steps 15 as numbered pills, active step in `--cyan`, completed in `--green`, pending in `--t3`).
- **Step 1 — Select room and SEED** — enter a `room_id` name (validated against `[A-Za-z0-9_-]{1,64}`) and select which SEED(s) and ESP32 nodes serve this room from a dropdown populated from the live fleet. Show current CSI ingest health for the selected nodes inline — if frames are not arriving at the expected rate, display an amber warning **before** allowing the operator to proceed. A broken ingest pipeline will silently fail calibration.
- **Step 2 — Baseline capture** — `POST /api/v1/calibration/start`. A large full-width animated progress bar (cyan fill) reads from `GET /api/v1/calibration/status`: frames recorded vs target, ETA in seconds, `z_median` value. If `motion_flagged` is true, overlay an amber banner: "Room must be empty — movement detected." The baseline UUID produced here is the anchor for all future STALE detection for this room — display it in `--mono` once complete so operators can record it.
- **Step 3 — Anchor enrollment** — the 8 anchor labels in enforced order: `empty`, `stand_still`, `sit`, `lie_down`, `breathe_slow`, `breathe_normal`, `small_move`, `sleep_posture`. For each: a human-readable instruction with an illustration, a countdown timer rendered as a circular progress ring in `--cyan`, and an immediate quality-gate result (accepted in green, retry in amber with a reason string). Drive via `POST /api/v1/enroll/anchor` + `GET /api/v1/enroll/status`. After each accepted anchor, show the extracted feature values (mean, variance, breathing_score, heart_score) in a small `--mono` data row so operators can sanity-check the capture. Show overall progress as "N / 8 anchors accepted."
- **Step 4 — Train** — a single `POST /api/v1/room/train` call. Show the 6 specialist results as a checklist: presence (threshold + occupied_var), posture (prototype count), breathing (min_score), heartbeat (min_score), restlessness (calm/active motion values), anomaly (prototype count + scale). Specialists that returned non-null render in `--green`. Null specialists (insufficient anchor data) render in `--amber` with a "Re-enroll missing anchors" prompt linking back to Step 3 for the specific missing labels.
- **Step 5 — Verify live** — display the live `RoomState` for the just-trained room using the same per-room card layout as §4.5. Prompt the operator to stand in the room and verify presence is detected, try sitting/lying to confirm posture, and breathe normally to confirm vitals are in plausible range. A "Confirm and save" button (cyan, primary) closes the wizard; a "Something's wrong — re-enroll" button (ghost) loops back to Step 3.
### 4.8 Event Bus & Automation Feed
- **Live event stream panel** — a virtualized scrolling list of `SystemEvent` variants (`StateChanged`, `EntityRegistered`, `ConfigReloaded`) and notable `DomainEvent`s from the homecore Tokio broadcast channel. Each row shows: event-type pill (coloured by variant), `entity_id` in `--mono`, old state → new state arrow, timestamp, and `context.user_id`. The stream is filterable by entity domain, event type, or source SEED/COG. The filter bar uses the same search-input style as the Cog Store's search field.
- **Context causality breadcrumb** — expanding any event row shows the full Context chain (`context.id``parent_id``grandparent_id`) as a breadcrumb trail in `--mono`. This is how automation loops become visible without any separate debugging tool.
- **Automation builder** ([ADR-129](ADR-129-homecore-automation-engine.md) scope) — a trigger → condition → action editor on the card surface. The most important RuView-specific trigger types to support are: `state_changed` on `RoomState` entities with a threshold expression (e.g. `anomaly.value > 0.8`), SEED reflex-rule firing events (`fragility_alarm`, `hd_anomaly_indicator`), and custom `domain_event` topics. Actions include calling services in the homecore service registry and firing domain events. The condition expression editor uses `--mono`.
### 4.9 Witness / Audit Log
- **Unified witness timeline** — a chronological merged view of events from both tiers: the SEED's SHA-256 ingest chain (every RVF store write attested) and homecore's Ed25519 state-transition chain (biometric crossings, BFLD identity-risk elevations). Each row: `entity_id` in `--mono`, old/new state, timestamp, source SEED `device_id`, signing key fingerprint (first 8 chars in `--mono`). Pagination uses the same "Showing XY of Z" convention from the Cog Store's cog grid.
- **Privacy mode banner** — a persistent top-of-panel banner showing current privacy mode: `--green-d`/green text for full-publish mode; `--amber-d`/amber text for audit-only mode (SHA-256 digests on-SEED only, no MQTT state messages). Show the per-SEED privacy mode state, since SEEDs can be individually configured. Toggling privacy mode is a high-stakes action — require an explicit "Confirm" step with a summary of what will change.
- **Export bundle** — an "Export attestation bundle" button (ghost) that packages the SEED witness chain + homecore Ed25519 chain as a downloadable archive for regulated-deployment (care home, hotel, shared office) compliance handoff.
### 4.10 Settings & Integration Config
- **SEED fleet management** — add, remove, and reprovision SEEDs. Show the USB-only pairing requirement prominently (the pairing window only opens via `169.254.42.1`, not WiFi — a security invariant). Per-SEED: `device_id` in `--mono`, firmware version, bearer token status, and a "Rotate token" action (ghost) that walks the operator through the secure token rotation flow.
- **ESP32 node provisioning** — per-node NVS config display (target IP, target port, node_id), last-seen firmware version, and a link to the provisioning script. The `node_id` → room/zone assignment is editable here and persists to the room calibration system's `room_id` mapping.
- **MQTT / cog-ha-matter config** ([ADR-116](ADR-116-cog-ha-matter-seed.md)) — broker URL, credentials (masked), MQTT topic prefix, mDNS advertisement status (`_ruview-ha._tcp`), and a live connection indicator (green dot for connected, red for unreachable). The 21 HA-DISCO entities per node are listed here with their `via_device` assignments showing which SEED they belong to in HA's device registry.
- **Long-lived access tokens** — for homecore-api companion-app connections (HA 2025.1 wire-compat, [ADR-130](ADR-130-homecore-rest-websocket-api.md)). Token creation, last-used timestamp, and revocation. The HA companion-app pairing QR-code flow surfaces here.
- **Federation config** — for multi-SEED deployments: ESP-NOW mesh sync status, cross-SEED epoch alignment values, and federated-learning round settings (coordinator SEED, round cadence, Krum aggregation parameters per [ADR-105](ADR-105-federated-csi-training.md)). The design invariant **"model deltas only, never raw CSI"** must be labelled explicitly in this panel.
---
## 5. Navigation structure
HOMECORE-UI must integrate into the existing Cognitum Appliance nav shell. The top nav should read:
```
Framework | Guide | Cog Store | HOMECORE | Status
```
— inserting **HOMECORE** as a first-class nav item between the existing "Cog Store" and "Status" entries, using the same nav-item style (text in `--t2`, active state in `--cyan` with bottom underline).
Within the HOMECORE section, a left sidebar (or top sub-nav on narrow viewports) provides section navigation:
```
Dashboard | SEED Fleet | Entities | Rooms | COGs | Calibration | Events | Audit | Settings
```
The COG Store panel within HOMECORE (§4.6) links out to `seed.cognitum.one/store` for the full catalog view, ensuring the existing Cog Store remains the canonical browsing experience.
---
## 6. Key UX invariants
These must be maintained across every panel:
1. **Always make the tier origin of any data explicit.** A `RoomState` reading traces to an ESP32 node → SEED → COG → v0 Appliance state machine. The provenance badge (§4.4) must appear wherever entity states are displayed.
2. **The `stale` and `vetoed` flags from `RoomState` and the kNN fragility score from SEED cognitive analysis are meaningful diagnostic signals** — they must never be silently hidden, styled grey-on-grey, or collapsed behind an expand toggle. They represent system health operators need to act on.
3. **Values that are `null` because a specialist has not been trained must be visually distinct from values that are unavailable due to an error.** The distinction is operationally important: `null` means "calibrate to enable," unavailable means "investigate."
4. **All entity IDs, hashes, API endpoints, binary signatures, device UUIDs, and JSON payloads must use `--mono` font.** This is already the convention in the API Explorer and must be consistent throughout HOMECORE-UI.
5. **The v0 Appliance Hailo HAT is a separate subsystem from the SEED's edge compute.** Inference results tagged as Hailo-sourced (COGs with `arch: hailo10`) must be visually distinguished from results from CPU-only COGs (`arch: arm`) so operators can triage hardware-specific failures.
---
## 7. Scope — complete UI delivery
The deliverable is the **entire** dashboard. Every panel below ships fully implemented and wired to its live data source — there is no scaffold-only milestone and no panel left as a placeholder. The table records each panel's authoritative backing API so the build can proceed in whatever order best fits the dependency graph; it is a dependency map, **not** a sequence of partial releases.
| Panel | Section | Backing API / source |
|---|---|---|
| System Dashboard | §4.1 | [ADR-130](ADR-130-homecore-rest-websocket-api.md) WebSocket + appliance health endpoints |
| SEED Detail View | §4.2 | SEED HTTPS API (vector store, witness, sensors, reflex, cognitive analysis) |
| SEED Fleet Map | §4.3 | fleet topology + federation ([ADR-105](ADR-105-federated-csi-training.md)) |
| Entity & State Browser | §4.4 | [ADR-127](ADR-127-homecore-state-machine-rust.md) state machine via [ADR-130](ADR-130-homecore-rest-websocket-api.md) `subscribe_events`; semantic search via [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) |
| RoomState / Sensing | §4.5 | [ADR-151](ADR-151-room-calibration-specialist-training.md) `GET /api/v1/room/state` |
| COG Management | §4.6 | [ADR-128](ADR-128-homecore-integration-plugin-system.md) plugin runtime + [ADR-100](ADR-100-cog-packaging-specification.md) app registry |
| Calibration Wizard | §4.7 | [ADR-151](ADR-151-room-calibration-specialist-training.md) calibration HTTP API |
| Event Bus & Automation | §4.8 | [ADR-130](ADR-130-homecore-rest-websocket-api.md) broadcast channel + [ADR-129](ADR-129-homecore-automation-engine.md) automation engine |
| Witness / Audit Log | §4.9 | SEED SHA-256 ingest chain + homecore Ed25519 chain |
| Settings & Integration | §4.10 | SEED provisioning, [ADR-116](ADR-116-cog-ha-matter-seed.md) MQTT/Matter, LLAT, federation |
### 7.1 Build sequencing within the complete deliverable
The complete UI depends on backing services that mature on their own timelines. Each panel is built against the **real gateway endpoint** defined in §11; where the upstream is not yet available the panel renders a typed empty/error state, **not** fabricated data (the dev-only `?demo=1` fixture of §2.2 exists for offline development only and is never the shipped behaviour). Concretely, the hard contract dependencies are: [ADR-130](ADR-130-homecore-rest-websocket-api.md) (REST + WebSocket), [ADR-127](ADR-127-homecore-state-machine-rust.md) (state machine), [ADR-151](ADR-151-room-calibration-specialist-training.md) (calibration), [ADR-128](ADR-128-homecore-integration-plugin-system.md) (plugin runtime), [ADR-129](ADR-129-homecore-automation-engine.md) (automation), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (event history + semantic search), [ADR-116](ADR-116-cog-ha-matter-seed.md) (SEED/Matter), [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) (SEED ingest), and [ADR-105](ADR-105-federated-csi-training.md) (federation). The keyword entity filter (§4.4) ships immediately; semantic search layers on once [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) lands. The exact panel→endpoint→upstream map and the new gateway code each requires are §11; the staged delivery is §12.
---
## 8. Consequences
### 8.1 Positive
- Operators, integrators, and residents get a single coherent surface for the full two-tier stack, replacing the need to SSH into SEEDs or hand-craft API calls.
- The dashboard reuses the proven Cognitum design tokens and component patterns verbatim, so it ships visually consistent with no separate design effort and no perceptible seam between surfaces.
- Diagnostic signals that today are invisible (`stale`/`vetoed` flags, kNN fragility, provenance lineage, channel lag) become first-class, surfacing the system's most common real-world failure modes directly to operators.
### 8.2 Negative / risks
- The UI hard-depends on the wire-compat guarantees of ADR-130 and the calibration contract of ADR-151; schema drift in either breaks panels silently. Integration tests against every backing contract in §7 are required.
- Committing to the complete UI in one deliverable is a larger up-front effort and couples the UI's readiness to the maturity of multiple backing services (§7.1, §11). The mitigation is the BFF gateway (§2.1): each panel targets one same-origin endpoint, and the gateway absorbs upstream churn behind a stable contract.
- Promoting `homecore-server` to a gateway means it now **proxies cross-tier traffic** (calibration API, SEED HTTPS, appliance daemons). This adds a network hop, a place for upstream timeouts/partial failures to surface, and a server-side store of SEED bearer tokens that must be protected (§11.10). Each proxied route needs an explicit timeout + typed error mapping so one slow SEED cannot stall the dashboard.
- Several panels depend on data that only exists on **real hardware or new daemons** (SEED device tier, appliance host metrics, COG supervisor). Until those upstreams exist the corresponding gateway routes return `503 upstream_unavailable`; this is honest but means the dashboard is only as "live" as the tiers behind it (§11 classifies every endpoint by what it depends on).
- Faithfully mirroring `seed.cognitum.one/store` couples HOMECORE-UI to the external Cog Store's evolving design; token drift there must be tracked and re-synced.
- The two-tier mental model (Appliance root, SEED children, ESP32 leaves) must be enforced consistently; any panel that flattens or peers the tiers undermines the core architectural constraint.
---
## 9. References
- `https://seed.cognitum.one/store` — primary design reference for all visual conventions.
- `https://seed.cognitum.one/status` — reference for live metric-card layout.
- [ADR-126](ADR-126-ruview-native-ha-port-master.md) — HOMECORE master ADR.
- [ADR-127](ADR-127-homecore-state-machine-rust.md) — HOMECORE-CORE state machine and entity registry.
- [ADR-128](ADR-128-homecore-integration-plugin-system.md) — HOMECORE-PLUGINS WASM COG substrate.
- [ADR-129](ADR-129-homecore-automation-engine.md) — HOMECORE automation engine.
- [ADR-130](ADR-130-homecore-rest-websocket-api.md) — HOMECORE-API REST + WebSocket wire-compat.
- [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) — homecore-recorder, history + semantic search.
- [ADR-100](ADR-100-cog-packaging-specification.md) — Cognitum Cog packaging specification (manifest.json, status values, on-device layout).
- [ADR-116](ADR-116-cog-ha-matter-seed.md) — cog-ha-matter (SEED cog, HA-DISCO entity surface, mDNS).
- [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) — ESP32 CSI → Cognitum SEED RVF ingest pipeline (SEED architecture detail).
- [ADR-105](ADR-105-federated-csi-training.md) — Federated CSI training (multi-SEED federation).
- [ADR-151](ADR-151-room-calibration-specialist-training.md) — Per-room calibration specialist training (calibration HTTP API).
- `v2/crates/homecore/src/` — state machine, entity, event, registry source.
- `docs/integration/calibration-appliance-integration.md` — calibration API contract and RoomState schema.
---
## 10. Implementation status
Implemented as a zero-dependency, no-build-step vanilla TS/JS + CSS frontend served by `homecore-server` at `/homecore` (the `rufield-viewer` "Axum + vanilla-JS" pattern). The complete deliverable per §2/§7 — all ten panels, fully rendered, wired to live data where the backing service exists and to a contract-conformant DEMO-flagged mock layer (§7.1) where it does not.
**Location:** `v2/crates/homecore-server/ui/``css/tokens.css` (the §3.1 palette, verbatim) + `css/app.css` (§3.3 components); `js/{ui,api,ws,mock,app}.js` (shared helpers, REST client, `subscribe_events` WS client, mock layer, shell+router); `js/panels/*.js` (one module per §4 panel). Mounted via `tower-http` `ServeDir` in `homecore-server::build_app`, gated by `--ui-dir`/`HOMECORE_UI_DIR`.
**Verification:**
- **Rust** — `#[cfg(test)] mod ui_tests` in `homecore-server/src/main.rs`: 5 integration tests (`tower::oneshot`) covering index, design tokens, all ten panel modules served, API coexistence, and mount-disable. *Written but not compiled in the authoring environment (no Rust toolchain present); run `cargo test -p homecore-server` on a Rust host before merge.*
- **Frontend** — `ui/` test suite under plain `node` (no npm install): `npm test` → import/export graph verifier (15 modules) + render-smoke (executes every panel against a DOM shim; 21 checks) + interaction suite (live WS patch, ws.js handshake/parse, calibration contract; 3 checks). **24/24 green.**
- **Benchmark** — `npm run bench`: total bundle **136.8 KB** uncompressed (**~37× smaller** than HA's ~5 MB Lit bundle, the ADR-126 §1.1 foil); slowest panel **1.5 ms/cold-render**.
**Honest scope — current vs. target.** *Earlier cut:* the front-end was complete but only §4.4 Entities was wired to a real backend; the rest rendered from an in-browser mock. *This revision implements the §11 wiring:*
- **Front-end (§11.11) — DONE and verified.** `api.js` rewritten: all data accessors are async and call the §11.2 gateway routes; the mock layer is demoted to a dev-only fixture reachable **only** under `?demo=1` / `HOMECORE_UI_DEMO` (§2.2); every panel `await`s and renders a typed empty/error state on failure (no mock fallback in production). All ten panels converted (3 by hand, 7 via parallel agents). Verified under Node: 5 test files green — import graph, boot, render-smoke (22), interaction (3), **and a new prod-errors suite (13) that runs with demo OFF + gateway unreachable and asserts every panel renders an error state, never mock, never throws** (it caught and fixed a real unhandled-rejection in the events panel).
- **Gateway (§11.1–§11.6) — IMPLEMENTED, COMPILED, TESTED, RUN.** New `homecore-server/src/gateway.rs` (+`reqwest` dep, +CLI/env flags `--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`, merged into `build_app` via `gateway_router`). Real handlers: `/api/cal/*` reverse-proxy (W2), `GET /api/homecore/rooms` with the §11.3 RoomState adapter (W2), `GET /api/homecore/cogs` supervisor over the apps dir (W4), `GET /api/homecore/appliance` from `/proc` + port probes (W6). SEED-device/appliance-daemon routes (seeds, federation, witness, privacy, settings, automations, events-history, hailo, tokens — W3/W5) return a typed `503 upstream_unavailable` per §11.2. **Verified on Rust 1.89: `cargo test -p homecore-server --no-default-features` = 12/12 pass** (6 gateway + 6 UI mount). **Run live:** `GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes; unauth → `401`; `cogs``[]` with no apps dir; SEED-tier → typed `503`; and against a mock calibration upstream the `/api/cal/*` proxy passes through (`200`) and `GET /api/homecore/rooms` correctly adapts `RoomState` to the UI shape (`breathing``breathing_bpm`, `heartbeat:null``heart_bpm:null`, injected `anomaly.threshold`/`room_id`, `stale` passthrough). **Live testing caught + fixed one real bug** — a double-`v1` path in the `/api/cal/*` proxy URL.
The endpoint-by-endpoint contract is **§11**; the staged plan and which endpoints depend on real SEED/appliance hardware vs. pure software is **§12**.
---
## 11. Backend wiring — making every panel real
This section is the authoritative contract for full functionality. It removes the mock layer from the production path (§2.2) by routing every panel through the `homecore-server` BFF gateway (§2.1). Each endpoint is classified by what it depends on:
- **EXISTS** — backend code already in this repo; gateway only proxies/adapts.
- **NEW-GW** — pure software the gateway itself implements (filesystem, `/proc`, process control, recorder query) — no new external service.
- **NEW-API** — a small HTTP wrapper to add to an existing in-repo crate (`homecore-api`, `homecore-automation`).
- **SEED-DEV** — depends on a SEED node's on-device HTTPS API (separate hardware/firmware).
- **APPLIANCE** — depends on an appliance daemon / accelerator stat source.
### 11.1 Gateway shape
`homecore-server` already mounts `homecore-api` at `/api/*` and the UI at `/homecore`. It gains a new **`/api/homecore/*`** namespace (the dashboard-specific aggregation surface) plus a **`/api/cal/*`** reverse-proxy to the calibration service. The browser issues only same-origin requests; the gateway fans out server-side, holding all upstream credentials (§11.10). Every proxied route has an explicit timeout and maps upstream failure to a typed body (`503 upstream_unavailable`, `504 upstream_timeout`) so one slow tier never stalls the dashboard.
### 11.2 Master endpoint contract (panel → gateway route → upstream → status)
| Panel | UI method (`api.js`) | Gateway route | Upstream / source | Class |
|---|---|---|---|---|
| §4.4 Entities | `states()` | `GET /api/states` | `homecore` state machine | **EXISTS** ✅ wired |
| §4.4/§4.8 live feed | WS | `GET /api/websocket` (`subscribe_events`) | `homecore` event bus | **EXISTS** ✅ wired |
| §4.8 Event history | `eventHistory(q)` | `GET /api/events?since=…` | `homecore-recorder` ([ADR-132](ADR-132-homecore-recorder-history-semantic-search.md)) | **NEW-API** |
| §4.8 Automations | `automations()` / `saveAutomation()` | `GET/POST/DELETE /api/homecore/automations` | `homecore-automation` ([ADR-129](ADR-129-homecore-automation-engine.md)) | **NEW-API** |
| §4.5 Rooms | `roomStates()` | `GET /api/homecore/rooms` → per-room `GET /api/cal/v1/room/state?bank=` | `calibrate-serve` ([ADR-151](ADR-151-room-calibration-specialist-training.md)) | **EXISTS** (proxy + adapter) |
| §4.7 Calibration | `calibration.*` | `POST /api/cal/v1/calibration/{start,stop}`, `GET …/status`, `POST …/enroll/anchor`, `GET …/enroll/status`, `POST …/room/train` | `calibrate-serve` | **EXISTS** (proxy) |
| §4.6 COGs | `cogs()` / `cogAction()` / `cogLogs()` | `GET /api/homecore/cogs`, `POST …/cogs/:id/{start,stop,restart}`, `GET …/cogs/:id/logs`, `GET/PUT …/cogs/:id/config` | COG supervisor over `/var/lib/cognitum/apps/` ([ADR-100](ADR-100-cog-packaging-specification.md)/[ADR-128](ADR-128-homecore-integration-plugin-system.md)) | **NEW-GW** |
| §4.6 Hailo HEF | `hailo()` | `GET /api/homecore/hailo` | `ruvector-hailo-worker:50051` | **APPLIANCE** |
| §4.1 Appliance health | `appliance()` | `GET /api/homecore/appliance` | host `/proc` + Hailo stats + service probes | **NEW-GW** (+APPLIANCE for Hailo) |
| §4.1/§4.2 Fleet + SEED detail | `seeds()` / `seed(id)` | `GET /api/homecore/seeds`, `GET …/seeds/:id` | SEED device HTTPS API ([ADR-069](ADR-069-cognitum-seed-csi-pipeline.md)) via registry | **SEED-DEV** |
| §4.2 SEED actions | `seedCompact()` / `seedVerify()` | `POST …/seeds/:id/{compact,witness/verify}` | SEED device API | **SEED-DEV** |
| §4.3 Federation | `federation()` | `GET /api/homecore/federation` | federation coordinator ([ADR-105](ADR-105-federated-csi-training.md)) | **SEED-DEV/APPLIANCE** |
| §4.9 Witness/Audit | `witnessLog(p,s)` | `GET /api/homecore/witness?page=…` | merge: `homecore` Ed25519 chain + per-SEED SHA-256 chains | **NEW-API + SEED-DEV** |
| §4.9 Privacy mode | `privacyModes()` / `setPrivacy()` | `GET/POST /api/homecore/privacy` | SEED privacy control plane ([ADR-141](ADR-141-bfld-privacy-control-plane-modes-attestation.md)) + cog-ha-matter | **SEED-DEV** |
| §4.9 Export bundle | `exportAttestation()` | `GET /api/homecore/witness/export` | gateway packages both chains | **NEW-GW** |
| §4.10 Tokens (LLAT) | `tokens()` / `createToken()` / `revokeToken()` | `GET/POST/DELETE /api/homecore/tokens` | `homecore-api` `LongLivedTokenStore` | **NEW-API** |
| §4.10 MQTT/Matter | `mqttConfig()` | `GET /api/homecore/integrations/mqtt` | cog-ha-matter config ([ADR-116](ADR-116-cog-ha-matter-seed.md)) | **NEW-GW/SEED-DEV** |
| §4.10 ESP32 provisioning | `nodes()` / `assignRoom()` | `GET/PUT /api/homecore/nodes` | SEED ingest config ([ADR-069](ADR-069-cognitum-seed-csi-pipeline.md)) | **SEED-DEV** |
| §4.10 SEED mgmt | `pairSeed()` / `rotateToken()` | `POST /api/homecore/seeds/{pair,:id/rotate-token}` | SEED pairing (USB `169.254.42.1`) | **SEED-DEV** |
### 11.3 Calibration proxy + RoomState adapter
The calibration service is real but on a different binary/port; the gateway reverse-proxies it under `/api/cal/*` (upstream base from `HOMECORE_CALIBRATION_URL`). Its `RoomState` (`wifi-densepose-calibration/src/runtime.rs`) does **not** match the UI's shape, so the gateway adapts it in `GET /api/homecore/rooms`:
| Real field (`RoomState`) | UI field | Adapter rule |
|---|---|---|
| `breathing: Option<SpecialistReading>` | `breathing_bpm: {value,confidence}\|null` | rename; `value`=`reading.value`, `confidence`=`reading.confidence`; `None``null` (preserves "not trained") |
| `heartbeat: Option<…>` | `heart_bpm: {…}\|null` | rename `heartbeat``heart_bpm` |
| `presence/posture/restlessness` | same names `{value,confidence}\|null` | `posture.value`=`reading.label` (class), else numeric |
| `anomaly: Option<…>` | `anomaly: {value,confidence,threshold}` | inject `threshold`=`MixtureOfSpecialists.veto_threshold` (0.5) |
| `vetoed` / `stale` | `vetoed` / `stale` | pass through (drives the §4.5/§6 banners) |
| *(absent)* | `room_id`, `seeds[]` | injected by the gateway from the **room registry** |
A **room registry** (config or derived from `GET /api/cal/v1/calibration/baselines`) maps each `room_id` → bank name + serving SEED ids, so `GET /api/homecore/rooms` returns one adapted record per room. `Option::None` → JSON `null` keeps the null-vs-withheld distinction (§6 invariant 3) intact end-to-end.
### 11.4 SEED registry & device-API proxy
The gateway holds a **SEED registry** (`device_id` → base URL + bearer token + zone), populated by pairing (§4.10) and persisted server-side. `GET /api/homecore/seeds[/:id]` fans out to each SEED's on-device API and shapes the result to the §4.2 card/detail model. Expected SEED-side endpoints (the contract the SEED firmware must satisfy — a subset of its 98 endpoints): health; vector-store stats (`vector_count`, `dim`, `epoch`, `knn_latency_ms`, ingest rate); witness (`len`, `last_verify`, `valid`) + `POST verify`; onboard sensors (BME280/PIR/reed/ADS1115/vibration); reflex rules + thresholds; cognitive analysis (fragility, coherence phases); ingest feeders (ESP32 node ids + packet type `0xC5110003`/`0xC5110002` + rate). Offline/unreachable SEEDs surface as `online:false` (drives the §4.1 red tint) rather than failing the whole list.
### 11.5 Appliance metrics collector (§4.1)
`GET /api/homecore/appliance`, implemented in the gateway: CPU/RAM/uptime from `/proc`; Hailo load + temperature from the Hailo runtime/sysfs (or `ruvector-hailo-worker` stats); service health by probing `ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`; event-bus rate from the `homecore` broadcast channel + its lag counter (already exposed for §4.1/§4.4).
### 11.6 COG supervisor (§4.6)
`GET /api/homecore/cogs`: read each `/var/lib/cognitum/apps/*/manifest.json` ([ADR-100](ADR-100-cog-packaging-specification.md)), the pid file, and verify `binary_sha256` + `binary_signature` (Ed25519) → status/shield. `POST …/cogs/:id/{start,stop,restart}` performs supervised process control; `GET …/cogs/:id/logs` tails `output.log`/`error.log`; `GET/PUT …/cogs/:id/config` reads/writes `config.json`. Hailo-arch COGs join the §11.5 Hailo stats. The Cog Store/App-Registry **browsing** panel was removed per product decision; this is operational management only.
### 11.7 Witness aggregation + privacy (§4.9)
`GET /api/homecore/witness` merges two chains chronologically: the `homecore` Ed25519 state-transition chain (exposed by a small `homecore-api` route over its witness log) and each paired SEED's SHA-256 ingest chain (proxied via the registry), paginated server-side. `GET/POST /api/homecore/privacy` reads/sets per-SEED privacy mode via the SEED privacy control plane ([ADR-141](ADR-141-bfld-privacy-control-plane-modes-attestation.md)) — the POST is the high-stakes confirmed toggle (§4.9). `GET /api/homecore/witness/export` packages both chains into the downloadable attestation bundle.
### 11.8 Event history + automation CRUD (§4.8)
`homecore-api` adds `GET /api/events?since=…` backed by `homecore-recorder` ([ADR-132](ADR-132-homecore-recorder-history-semantic-search.md)) for history (live updates continue over the existing WS). The automation builder persists through `GET/POST/DELETE /api/homecore/automations`, a thin HTTP wrapper over the `homecore-automation` engine's register/list/remove ([ADR-129](ADR-129-homecore-automation-engine.md)). RuView-specific triggers (RoomState thresholds, SEED reflex events) map onto the engine's trigger types.
### 11.9 Entity provenance convention (§4.4/§6)
The first-class provenance badge requires each entity to carry its lineage. Convention: every integration writes `attributes.source` (and, where known, `attributes.seed` / `attributes.cog`) when it sets state; `cog-ha-matter` ([ADR-116](ADR-116-cog-ha-matter-seed.md)) populates these from the ESP32 node → SEED → COG path and HA `via_device`. The gateway/UI resolves node→seed→cog from these attributes (no fabrication; missing lineage renders as "unknown", not invented).
### 11.10 Auth, credentials, config
- **Browser → gateway:** one long-lived access token (the §4.10 LLAT), sent as `Authorization: Bearer`; validated by `homecore-api`'s `LongLivedTokenStore`. The dev default (`allow_any_non_empty`) stays for local runs; production provisions `HOMECORE_TOKENS`.
- **Gateway → upstreams:** SEED bearer tokens and the calibration token live **only** server-side (SEED registry + `HOMECORE_CALIBRATION_TOKEN`); never sent to the browser. This is the reason the gateway exists.
- **Config:** `HOMECORE_CALIBRATION_URL`, SEED registry store path, per-proxy timeout (default 2 s), `HOMECORE_UI_DEMO` (dev fixture). No browser CORS needed (same origin); gateway→upstream is server-to-server.
### 11.11 Front-end changes
`api.js`: drop the mock fallback from the production path — methods call the §11.2 gateway routes; `this.base` stays same-origin; the mock layer is reachable only under `?demo=1`/`HOMECORE_UI_DEMO`. Every panel renders a **typed empty/error state** (not mock) when its route returns `503/504`. `mock.js` moves to a dev fixture (kept for the offline test harness, excluded from the production bundle). The §10 frontend tests are re-pointed at the gateway contract (and gain contract tests per §11.2 route).
---
## 12. Delivery plan to full functionality
Staged so each wave is independently shippable behind the gateway, lands real data for a coherent set of panels, and has an explicit acceptance gate. "Class" reuses §11's tags.
| Wave | Scope | Class | Acceptance gate |
|---|---|---|---|
| **W1 — Gateway foundation** | `/api/homecore/*` scaffold in `homecore-server`; auth passthrough; per-proxy timeout + typed errors; `api.js` base + remove prod mock (`?demo=1` only); panels get typed empty/error states | NEW-GW | Entities + live WS still green; with no upstreams, every other panel shows "upstream unavailable", **never** mock (unless `?demo=1`); Rust + JS suites pass |
| **W2 — Rooms + Calibration** | `/api/cal/*` reverse-proxy; `GET /api/homecore/rooms` with the §11.3 RoomState adapter + room registry; wire §4.5 + the §4.7 wizard to real endpoints; delete the in-browser calibration stub | EXISTS (proxy+adapter) | Against a running `calibrate-serve` (replayed CSI), the wizard drives a real baseline→enroll→train→verify and §4.5 shows real `RoomState` with correct stale/veto/null mapping; contract test on the adapter |
| **W3 — Events + Automations** | `GET /api/events` over `homecore-recorder`; `/api/homecore/automations` over `homecore-automation` | NEW-API | §4.8 history loads from recorder; an automation created in the UI persists and fires via the engine |
| **W4 — COG management** | `/api/homecore/cogs*` supervisor over `/var/lib/cognitum/apps/` (manifest + pid + sig verify + logs + config) | NEW-GW | §4.6 lists real installed COGs; start/stop/restart works; sha256/signature shield reflects real verification; logs tail |
| **W5 — SEED tier** | SEED registry + pairing; `/api/homecore/seeds*` device proxy; witness merge + privacy control; ESP32 provisioning | SEED-DEV | Against a real or emulated SEED API, §4.2/§4.3/§4.9/§4.10 show real vector-store/witness/sensor/reflex/cognition data; SEED tokens stay server-side; offline SEED → red tint, not a failed page |
| **W6 — Appliance + federation + Hailo** | `/api/homecore/appliance` (host metrics + service probes); `/api/homecore/hailo`; `/api/homecore/federation` ([ADR-105](ADR-105-federated-csi-training.md)) | NEW-GW + APPLIANCE | §4.1 health is real; §4.6 Hailo HEF/throughput real; §4.3 federation round/coordinator/Krum real |
**Definition of done (full functionality):** with W1W6 merged and the upstream tiers running, loading `/homecore` with **no** `?demo=1` flag shows live data on all ten panels, `api.anyDemo()` is false, and no panel renders fabricated values. Panels whose tier is offline show typed empty/error states. The mock layer is reachable only as the `?demo=1` developer fixture.
### 12.1 Wave status (this revision)
| Wave | Status |
|---|---|
| **W1 — Gateway foundation** | ✅ DONE — `gateway.rs`, auth passthrough, typed `503/504`, merged into `build_app`; front-end mock removed from prod path + `?demo=1` fixture; typed error states. **Compiled + 12/12 Rust tests + JS suite green + run live.** |
| **W2 — Rooms + Calibration** | ✅ DONE — `/api/cal/*` reverse-proxy + `GET /api/homecore/rooms` RoomState adapter; front-end calibration stub deleted (now proxies the real API). **Proven live against a calibration upstream** (proxy 200 + adapted shape); null-preservation unit-tested. |
| **W3 — Events + Automations** | ⏳ gateway returns typed `503` (recorder/automation HTTP wrappers pending); front-end handles it gracefully (history note, builder still usable). |
| **W4 — COG management** | ✅ supervisor DONE — lists `/var/lib/cognitum/apps/` manifests + pid liveness (returns `[]` live with no apps dir); start/stop/log/config control is the remaining follow-up. |
| **W5 — SEED tier** | ⏳ gateway returns typed `503` (SEED registry + device proxy pending real/emulated SEED hardware). |
| **W6 — Appliance + federation + Hailo** | ◑ appliance host metrics from `/proc` + port probes DONE (live `/proc` data verified); Hailo stats + federation remain `503` (need the accelerator stat source / coordinator). |
**Status:** the gateway is **compiled and tested on Rust 1.89** (`cargo test -p homecore-server` = 12/12) and was **run live** (curl proof in §10). The one remaining caveat is intrinsic, not an environment limit: **W3/W5/W6-Hailo/federation depend on services/hardware that are not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, the Hailo stat source), so they return honest typed `503`s and the UI shows error states — exactly as §2.2/§11.2 prescribe. W1/W2/W4/W6-appliance are functional now.
### 12.2 Security review (PR #1082)
A high-effort public-PR review of the merged gateway + front-end surfaced the following, all fixed and pinned by tests (`cargo test -p homecore-server` is now **18/18**):
| # | Severity | Finding | Fix |
|---|---|---|---|
| 1 | **HIGH** | **Path-traversal / confused-deputy SSRF** in the `/api/cal/*` reverse-proxy. The wildcard path was interpolated into the upstream URL while `proxy()` attaches the privileged server-side calibration bearer, so `/api/cal/v1/../../x` (or `..%2f`, `%2e%2e`, leading `/`, `\`, double-encoded `%252e`) could escape the `…/api/` scope **with the token**. | `validate_proxy_path()` decode-then-checks and rejects absolute / backslash / dot-segment / encoded-traversal paths with a typed **400 before the URL is built** (GET **and** POST); legit `v1/...` paths still pass. |
| 2 | Correctness | **CORS + tracing didn't cover gateway routes**`/api/homecore/*` + `/api/cal/*` were `.merge()`d outside `homecore-api::router()`'s layers. | The audited HC-05 `build_cors_layer()` + `TraceLayer` are now applied to the whole merged app in `main.rs`. |
| 3 | Honesty (§6) | **Fabricated data** — hardcoded `anomaly.threshold: 0.5` in the adapter; dashboard rendered `"null%"`/`"null°C"`; COG Hailo pill hardcoded `"connected"`; `rooms.js` defaulted a null threshold to `0.8`. | Threshold passes through the real upstream value or emits `null` (withheld); dashboard renders `—`; the Hailo pill reflects the real appliance probe; the UI treats a null threshold as withheld. |
| 4 | Robustness | A string `hef` (forwarded verbatim) threw on `.forEach`/`.join`; `frames/target` could be `NaN%`/`Infinity%`; calibration Restart leaked the baseline `setTimeout` poll. | `asArray()` coercion; `target > 0` guard; cancellable poll cleared on Restart / panel teardown. |
| 5 | Perf | Sequential per-bank RoomState fetches; blocking `std::net::TcpStream::connect_timeout` probes on an async handler; `mock.js` statically bundled. | Concurrent `futures::join_all`; async `tokio::net::TcpStream` + `timeout`; demo-only dynamic `import()` of `mock.js`. |
**Known limitations carried forward (not regressions):**
- **`reqwest` rustls-only is a workspace-wide concern.** `homecore-server` opts into `rustls-tls` only, but cargo feature-unification means any sibling crate enabling the default `native-tls` re-introduces OpenSSL into the final binary. A true "no OpenSSL on the appliance" guarantee requires aligning **every** reqwest-pulling crate on rustls-only — out of scope for this PR; documented at the dependency in `Cargo.toml`.
- **DEV-mode auth.** When `HOMECORE_TOKENS` is unset, the token store falls back to `allow_any_non_empty()` (any non-empty bearer accepted) on `0.0.0.0`. This is pre-existing and intentionally **unchanged** here; the loud boot `warn!` is retained. Provision real tokens (`HOMECORE_TOKENS=…`) before exposing the server to a network.
@@ -0,0 +1,110 @@
# ADR-174: CI Bench-Regression Gate (Compile-Verify)
| Field | Value |
|-------|-------|
| **Status** | Accepted — implemented, caught one real bit-rotted bench |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **BENCH-GATE** |
| **Milestone** | benchmark/optimization re-balance — sub-deliverable 8.3 |
| **Motivated by** | `docs/research/sota-nn-train-benchmark-brief.md` (target 3: criterion benches as CI regression baselines) |
## Context
The v2/ workspace ships **26 criterion benches across 18 crates** (e.g.
`nvsim/pipeline_throughput`, `wifi-densepose-ruvector/{ann,sketch,fusion}_bench`,
`wifi-densepose-signal/{signal,dsp_perf,features,calibration,cir,…}_bench`,
`wifi-densepose-mat/detection_bench`, `wifi-densepose-nn/{inference,native_conv}_bench`,
`wifi-densepose-engine/engine_cycle`, …). Because **benches are not part of
`cargo test`**, nothing in CI compiled them — so they bit-rot silently the moment
a public API they call changes, and the rot is invisible until someone manually
runs `cargo bench` months later.
The SOTA brief named "wire existing criterion benches into CI as regression
baselines" as a concrete benchmark-hygiene target. The honest difficulty: true
*timing*-regression gating on shared GitHub runners is unreliable — wall-clock
varies 23× run-to-run (a captured 10-sample run showed `float_l2/512` ranging
307444 ns), so a hard threshold or a cross-runner `criterion --baseline` compare
(baseline and PR land on different physical machines) would manufacture false
regressions. A gate that cries wolf gets disabled.
## Decision
Add `.github/workflows/bench-regression.yml` with **two jobs of explicitly
different authority** — and do NOT pretend to gate on timing.
### `bench-compile` — HARD GATE (real regression detection)
`cargo bench --workspace --no-default-features --no-run` compiles + links every
default-feature bench (no measurement → fully deterministic), plus a
`--features cir` compile of the gated `cir_bench`. Benches aren't in `cargo test`,
so this is the genuine guard: **the build fails the moment a bench stops
compiling.**
### `bench-fast-run` — INFORMATIONAL (`continue-on-error: true`, never gates)
Runs a curated pure-CPU subset (`nvsim/pipeline_throughput`,
`ruvector/{sketch,fusion}_bench`) in criterion quick-mode (1 s warm-up / 2 s
measure / 10 samples), targeted per-`--bench`, and uploads logs as an artifact.
Every number it produces is **informational only** — explicitly stated in the
workflow header.
### What is NOT done, and why (honest scope)
No timing-regression gate, no committed baseline JSON. The workflow header
documents the exact condition under which true timing-gating becomes honest: a
frequency-pinned **self-hosted** runner with a generous (>2×) floor. A
cross-runner baseline would be dishonest, so none is committed.
### Proof it matters (MEASURED)
Running the new gate on the current tree immediately caught
`wifi-densepose-mat/detection_bench` failing to compile:
`error[E0063]: missing field last_rssi in initializer of SensorPosition` — the
struct gained a field; the bench was never updated. **Fixed** in the same change
(`last_rssi: None`, the simulated-zone convention) and re-verified
(`cargo bench -p wifi-densepose-mat --no-default-features --bench detection_bench --no-run`
`Finished`). The gate paid for itself on its first run.
### Exclusions (documented in-workflow)
- `ruvector/crv_bench` — its crates.io dep `ruvector-crv 0.1.1` fails to build on
stable (upstream `E0308` in `stage_iii.rs`); excluded with a re-add condition.
- `onnx_bench` / `mqtt_throughput` — feature-gated (ort / mqtt), left to their
crates' own workflows. `wasm-edge/process_frame_bench` — workspace-excluded.
Conventions mirror existing workflows: `submodules: recursive` (the workspace
path-deps `vendor/rufield`), Swatinem/rust-cache `workspaces: v2`, Tauri/GTK apt
deps (a `--workspace` bench link pulls the whole graph), path-filtered triggers.
## Validation
- **Bit-rot caught + fixed** (above), re-verified `--no-run`.
- **MEASURED locally** (`--no-default-features`, Windows): nvsim, ruvector
(sketch/fusion/ann), signal/cir_bench, mat/detection_bench (post-fix),
vitals, ruview-swarm/swarm_bench all compile; fast subset runs (`nvsim
pipeline_run/d1/256` ≈ 55 µs; `ruvector sketch_hamming` ≈ 37 ns vs `float_l2`
≈ 63371 ns).
- `cargo test -p wifi-densepose-mat --no-default-features` → 166/6/2 passed, 0 failed.
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash
`f8e76f21…46f7a` unchanged.
- **Honest limitation:** the full `--workspace --no-run` could not be
end-to-end validated on this Windows box (`desktop` needs GTK, `candle-core`
fails on MSVC, `swarm_bench` LTO-links OOM under parallel pressure — all
Windows-env artifacts; each affected bench compiles standalone here). **The
first green Linux CI run on the PR is the authoritative proof of the
`--workspace` step.**
## Consequences
### Positive
- Bench bit-rot is now a hard CI failure, not a silent surprise — the 26 benches
stay compilable as the APIs they exercise evolve.
- The benchmark-infrastructure half of the DoD (step 5) is satisfied honestly,
setting up the next sub-deliverable (QAT-int8 measurement) to be
regression-protected.
### Negative / Neutral
- No automated timing-regression detection (deliberate — see scope). Revisit only
with a frequency-pinned self-hosted runner.
- One bench (`crv_bench`) excluded pending an upstream dep fix.
## Links
- ADR-173 — metric-locked accuracy harness (sub-deliverable 8.1)
- `docs/research/sota-nn-train-benchmark-brief.md` — motivating target
- ADR-134 (CIR), ADR-135 (calibration), ADR-154 (signal DSP benches) — benched paths
@@ -0,0 +1,172 @@
# ADR-175: int8 Quantization of the WiFlow-STD "half" Pose Model — MEASURED accuracy/size trade-off
| Field | Value |
|-------|-------|
| **Status** | Accepted — MEASURED, reproducible (honest negative) |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **EDGE-INT8** |
| **Sub-deliverable** | 8.2 of the benchmark/optimization milestone |
| **Metric lock** | ADR-173 (one declared PCK normalization for every reported number) |
| **Motivated by** | `docs/research/sota-nn-train-benchmark-brief.md` (§edge int8) |
## Context
The SOTA brief characterized the int8 edge story for the WiFlow-STD pose net as
"fully characterized" for PTQ on the **published 2.23M** model (static QDQ
conv-only = the sweet spot; dynamic int8 ≈ no-op on this all-conv net), and named
**QAT-int8 on the strictly-dominating 843,834-param "half" model** as "the one
untested edge lever." This ADR is the reading of that lever — a MEASURED
fp32-vs-int8 trade-off for the half model, not a claim.
The half model (`half_best.pth`, 843,834 params) is the efficiency-sweep winner
from ADR-152 (`run_sweep.py` VARIANTS[0]: `tcn=[270,220,170,120]`,
`conv=[4,8,16,32]`, `attn_groups=4`). Its fp32 accuracy was recorded in the sweep;
this ADR re-measures it under the locked normalization and quantizes it.
**The whole point of this deliverable is reproducibility.** Every number below was
produced by running `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py`
on host `ruvultra` (RTX 5080, torch 2.11.0+cu128) against the real checkpoint and
the real seed-42 test split. The script + the exact command + the recorded stdout
**is** the proof artifact. Nothing here is estimated.
## Decision
Quantize the half model to int8 with **both** levers and report both honestly:
1. **QAT (primary target)** — FX graph-mode quantization-aware training, fbgemm
backend, 3 epochs of fake-quant fine-tuning from `half_best.pth` (AdamW lr 2e-5,
the existing `PoseLoss`), then `convert_fx` to a true int8 graph.
2. **PTQ static QDQ (the brief's "sweet spot", measured as the honest fallback)**
FX graph-mode static PTQ, fbgemm, calibrated on 64 train batches.
### Locked normalization (ADR-173)
**Torso-diameter PCK** — neck (keypoint idx 2) → pelvis (idx 12) distance — the
standard MM-Fi/GraphPose-Fi convention. This is exactly the default
`use_torso_norm=True` path of the upstream harness's `utils/metrics.calculate_pck`.
The **same** `calculate_pck`/`calculate_mpjpe` that produced the sweep's fp32
numbers scores **both** fp32 and int8 here, so the comparison is metric-locked: no
normalization is mixed, and the fp32 baseline reproduces the sweep's recorded
`half` test numbers bit-for-bit (PCK@20 clean = 96.62%), confirming the harness is
the same one.
### Device note (why int8 is CPU)
PyTorch int8 quantized kernels execute on CPU (fbgemm/x86), not CUDA. So int8 eval
is CPU. To keep the accuracy delta device-matched (not confounding int8-vs-fp32
with CPU-vs-GPU), the script measures an **fp32-CPU** baseline too. fp32-CPU and
fp32-GPU agree to 4 decimals (PCK@20 clean 0.96623 vs 0.96623), so CPU/GPU
introduces no drift — the int8 deltas below are pure quantization effect.
## MEASURED results (clean test subset = 52,560 NaN-free windows; torso-PCK)
Source: stdout of the run below + `~/wiflow-std-bench/sweep/int8/int8_results.json`.
| model | quant | size (MB) | PCK@20 | PCK@50 | MPJPE | Δ PCK@20 | Δ PCK@50 | size win |
|-------|-------|-----------|--------|--------|-------|----------|----------|----------|
| **fp32** (cpu) | — | **3.351** | **96.62%** | **99.47%** | **0.008981** | — | — | 1.00× |
| int8 PTQ static | PTQ | 1.046 | 40.98% | 94.98% | 0.038262 | **55.64 pp** | 4.49 pp | 3.20× smaller |
| int8 QAT (3 ep) | **QAT** | 1.043 | 67.48% | 98.69% | 0.026548 | **29.15 pp** | 0.78 pp | 3.21× smaller |
Full-test-set (54,000 windows incl. NaN-zero-filled files 487499) tracks the
clean subset: fp32 96.10% / int8-PTQ 41.11% / int8-QAT 67.48% PCK@20 — same shape,
recorded in the JSON.
### Verdict
**int8 is NOT a win for this model at the tight PCK@20 edge target — honest no.**
- **PTQ static collapses** (55.64 pp PCK@20). Naive static QDQ destroys the half
model. The "sweet spot" characterization from the brief does not transfer from
the 2.23M model to this 843k model at the strict torso-PCK@20 threshold.
- **QAT recovers a large share of the relative gap** (PTQ 40.98% → QAT 67.48%) but
still **loses 29.15 pp** at PCK@20 for a 3.21× size reduction. At the loose
PCK@50 threshold QAT is nearly lossless (0.78 pp), i.e. coarse-localization
survives int8 but fine-localization does not.
- The size win is real and consistent (3.2× smaller, 3.351 MB → ~1.04 MB), but
**3.2× compression at 29 pp PCK@20 is a bad trade** when the half model already
fits comfortably in edge flash at fp32. Recommendation: **keep fp32 (or fp16)
for the half model on the edge**; do not ship this int8 variant as-is.
### Observed fake-quant → int8 conversion gap (disclosed, not hidden)
During QAT the **fake-quant** model's val PCK@20 reached 83.45% (epoch 3), but the
**converted int8** model scores 67.48% on test. A ~16 pp drop on `convert_fx` is a
real effect — the fbgemm int8 kernels are not bit-identical to the fake-quant
simulation (per-tensor activation quant + the axial-attention `einsum`/softmax path
quantize worse than the straight-through estimate predicts). This gap is the honest
reason QAT did not close the loss, and it is exactly the kind of number that would
be invisible if one only reported the fake-quant proxy. We report the **converted
int8** number as the deliverable, not the fake-quant proxy.
## Reproduction
```bash
ssh ruvultra 'cd ~/wiflow-std-bench && source venv/bin/activate && \
python ~/quantize_half_int8.py --mode both --qat-epochs 3 2>&1'
```
- Script (committed): `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py`
(scp'd to `~/quantize_half_int8.py` on ruvultra for the run).
- Inputs (on ruvultra, unmodified): `~/wiflow-std-bench/sweep/half_best.pth`,
`~/wiflow-std-bench/preprocessed_csi_data/` (seed-42 file-level 70/15/15 split),
upstream `models`/`dataset`/`utils/metrics`/`losses` (DY2434/WiFlow @ 06899d29,
Apache-2.0), and `sweep/model_compact.py` (the half-model definition).
- Outputs (written, non-destructive): `~/wiflow-std-bench/sweep/int8/`
`half_int8_qat.pth`, `half_int8_ptq_static.pth`, `int8_results.json`,
`int8_run.log`. **No existing file under `~/wiflow-std-bench` was modified.**
- Run metadata: host `ruvultra`, GPU RTX 5080, torch `2.11.0+cu128`, fbgemm engine,
`date_utc 2026-06-15T12:35:06Z`, QAT ≈ 97 s/epoch.
## What is MEASURED vs CLAIMED
- **MEASURED:** every PCK/MPJPE/size number in the table; the fp32 baseline (which
reproduces the recorded sweep `half` numbers); the PTQ collapse; the QAT partial
recovery; the fake-quant→int8 conversion gap; the 3.2× size reduction.
- **CLAIMED / not done here:** ONNX/TFLite export; on-real-edge (ESP32/Pi/Hailo)
latency or energy (int8 here is measured on x86 fbgemm, the dev box, **not** an
edge SoC — the size number transfers, a latency number does **not**); a
per-layer mixed-precision search that might keep the attention block in fp32; QAT
beyond 3 epochs or with learned-quant-range schedules. Those are the obvious next
levers if int8 is revisited; none is asserted as a result.
## Honest scope / limitations
- **Single eval split** — one seed-42 file-level test partition; no cross-room /
cross-environment generalization split (the GraphPose-Fi frontier from ADR-173 is
a separate, harder split and is not what is measured here).
- **In-domain only** — these are in-distribution test numbers; they say nothing
about the cross-environment robustness gap.
- **x86 int8, not edge-SoC int8** — accuracy and size transfer to an edge int8
runtime; the runtime/latency does not (different kernels, different SoC). No
latency claim is made.
- **QAT lightly tuned** — 3 epochs, single LR, default fbgemm qconfig. A longer /
better-tuned QAT might narrow the 29 pp, but on the evidence here int8 does not
reach fp32 at PCK@20, and that is the reportable result today.
## Consequences
### Positive
- The "one untested edge lever" (QAT-int8 on the half model) is now MEASURED. The
edge int8 question for the half model is answered with reproducible numbers: at
the strict PCK@20 target it loses, and we can say so with a committed script.
- Establishes a reusable, metric-locked quantization+eval harness
(`quantize_half_int8.py`) for any future int8 attempt on these compact variants.
### Negative
- None to the codebase (additive script + ADR + CHANGELOG only; no production Rust
or signal-pipeline change; Python deterministic proof hash
`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a` unchanged).
### Neutral
- The negative verdict means the half model stays fp32/fp16 on the edge for now.
int8 for these compact pose nets is parked pending the next-lever work above.
## Links
- ADR-173 — metric-locked PCK/MPJPE harness (the locked normalization used here)
- ADR-152 — WiFi-Pose SOTA 2026 intake / WiFlow-STD benchmark / efficiency sweep
(produced `half_best.pth`)
- `docs/research/sota-nn-train-benchmark-brief.md` — §edge int8 (the "one untested
lever" this ADR measures)
- Script: `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py`
@@ -0,0 +1,103 @@
# ADR-176: `ruview-swarm` NaN-Fail-Open Safety Review
| Field | Value |
|-------|-------|
| **Status** | Accepted — 4 real safety bugs fixed + pinned; 2 issues documented for follow-up |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **SWARM-FAILCLOSED** |
| **Reviews** | ADR-148 (`ruview-swarm` drone swarm control plane) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 1 of 4 |
## Context
`ruview-swarm` (ADR-148) is the drone swarm control plane — hierarchical-mesh
topology, Raft consensus, MARL, CSI sensing payload, MAVLink/PX4 command
dispatch. It is the highest-stakes of the four never-reviewed v2 crates: a defect
here can produce an **unsafe physical drone command**. It had no prior security
ADR.
### Trust-boundary map
Untrusted input enters via `SwarmOrchestrator::receive_peer_state` /
`receive_peer_detection`, which accept full `DroneState` / `CsiDetection` serde
structs with **f64/f32 fields and no finite-check**, and via
`SwarmConfig`/`FhssConfig`/`Geofence` deserialization. The MAVLink wire formats in
`mavlink_messages.rs` are **integer-encoded** (i32 mm / u8) and provably cannot
carry NaN — so the NaN class is reachable through the **serde struct path, not the
MAVLink decode path**. Commands flow out to a `FlightController` (PX4/ArduPilot).
The unifying bug class found: **IEEE-754 NaN/Inf silently defeating a safety
comparison** (`NaN < threshold` evaluates to `false`), causing safety logic to
**fail OPEN**. This is distinct from — but rhymes with — the NaN-state-poisoning
class found earlier in calibration/vitals/geo (there, NaN latched into persistent
state; here, NaN slips through a one-shot guard). Both are "non-finite input
defeats logic," and the fix discipline is the same: **reject non-finite at the
trust boundary, fail CLOSED.**
## Decision
Fix the four reachable fail-open bugs by making each safety predicate
non-finite-aware and fail-closed, each pinned by a fails-on-old test. Document
two further genuine issues that need larger, riskier changes rather than churning
them in a security pass.
### Findings fixed (all MEASURED fails-on-old)
| # | Severity | File:line | Issue | Fix | Pin (old behavior) |
|---|----------|-----------|-------|-----|--------------------|
| F1a | **HIGH** | `failsafe/mod.rs:51` | `nearest_neighbor_dist < collision_dist_m` fails open on a NaN peer position → **collision avoidance silently disabled** | `!is_finite() ||``EmergencyDiverge` | `test_nan_neighbor_distance_fails_closed_to_diverge` (old → `Nominal`) |
| F1b | **HIGH** | `failsafe/mod.rs:75` | NaN `battery_pct` bypasses every battery check → drone stays Nominal on unknown battery | `!is_finite() ||``ReturnToHome` | `test_nan_battery_fails_closed_to_rth` (old → `Nominal`) |
| F2 | **MEDIUM** | `security/geofence.rs:33` | NaN `z` altitude skips the altitude-breach check and point-in-polygon returns `Safe` → silent geofence bypass | leading non-finite coord → `HardBreach` | `test_nan_altitude_fails_closed` (old → `Safe`) |
| F3 | **MEDIUM/DoS** | `security/antijamming.rs:65,71,102` | empty deserialized `channels_mhz``% 0` **panic** in `next_hop`/`current_channel_mhz`/`evasive_hop`/`tick`, crashing the radio task | `len == 0` early-return (`0.0` sentinel) | `test_empty_channels_does_not_panic` (old → panic `divisor of zero`) |
| F4 | **LOW** | `sensing/multiview.rs:70` | NaN `victim_position` passes the `is_some()` filter and propagates into the fused "confirmed victim" location dispatched to the swarm | require finite confidence + position (drop) | `test_nan_victim_position_dropped_from_fusion` (old → non-finite fused position) |
### Dimensions confirmed clean (with evidence)
- **MAVLink decode panic-safety**`SwarmNodeState::decode(&[u8;20])` `try_into().unwrap()`s are over fixed const ranges of a fixed-size array → provably infallible; no arbitrary-length `&[u8]` decode path exists.
- **UWB/GPS anti-spoofing NaN-safe**`(gps_dist - uwb_dist).abs() <= tol` already fails CLOSED on a NaN range (counts as inconsistent → spoof rejected); covered by `test_spoofed_gps_invalid`.
- **Bounded grid / no allocate-from-length-field**`ProbabilityGrid` bounds-checks `cx/cy`; `pos_to_cell` uses saturating `as u32` (no UB).
- **Mesh `nearest_k` NaN-safe sort**`partial_cmp(..).unwrap_or(Equal)` cannot panic on NaN.
- **No hardcoded secrets**`MavlinkSigner` key is constructor-injected `[u8;32]`; grep-confirmed nothing embedded.
### Documented, not fixed (genuine — deferred to avoid churn/regression risk)
1. **Raft `AppendEntries` lacks the Log-Matching consistency check**
(`topology/raft.rs:187`). A follower appends a leader's entries when
`term >= current_term` **without validating `prev_log_index`/`prev_log_term`**,
so a malformed/byzantine leader can corrupt a follower's log — a genuine
consensus-safety gap. A correct fix reworks the log-append plus the
caller-side vote-tally contract (the existing `handle_message` delegates
tallying to the caller) — a larger change with test-rewrite risk, so it is
recorded here rather than rushed in a security pass.
2. **`MavlinkSigner::verify` uses a non-constant-time tag `==` and has no
replay/timestamp-window rejection** (`security/mavlink_signing.rs:64`). The
module doc already flags the replay limitation as a demo/test simplification.
Hardening (constant-time compare + monotonic timestamp window) is a focused
follow-up.
These two are the recommended scope of the next `ruview-swarm` hardening pass.
## Validation
- `cargo test -p ruview-swarm --no-default-features`**117 → 123** passed, 0 failed (+6 pins).
- All 6 new tests MEASURED fails-on-old (2× `Nominal`, `Safe`, panic `divisor of zero`, non-finite fused position); pass on the fix.
- `cargo test --workspace --no-default-features`**exit 0**, 0 failed.
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash
`f8e76f21…46f7a` unchanged (ruview-swarm off the signal proof path).
## Consequences
### Positive
- Four reachable fail-open paths in a *physical-safety* control plane (collision
avoidance, battery RTH, geofence, anti-jamming radio task) now fail CLOSED on
hostile/degenerate input, each regression-pinned.
- Extends the "non-finite input defeats logic" defense from the state-poisoning
variant (calibration/vitals/geo) to the fail-open-comparison variant.
### Negative / Neutral
- Two genuine issues (Raft log-matching, MAVLink signer) remain open by choice —
see Documented-not-fixed; they define the next hardening pass.
## Links
- ADR-148 — `ruview-swarm` drone swarm control system
- ADR-172 — core/cli review (where the NaN bug-class root question was settled NO)
- ADR-127 — homecore review (sibling NaN/concurrency hardening)
@@ -0,0 +1,92 @@
# ADR-177: `nvsim` Degenerate-Input Hardening (NV-Diamond Simulator)
| Field | Value |
|-------|-------|
| **Status** | Accepted — 2 real MEDIUM bugs fixed + pinned; determinism preserved |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **NVSIM-FAILCLOSED** |
| **Reviews** | ADR-089 (`nvsim` NV-diamond magnetometer pipeline simulator) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 2 of 4 |
## Context
`nvsim` (ADR-089) is a standalone, **WASM-ready** deterministic NV-diamond
magnetometer pipeline simulator — a forward-only leaf:
`scene → source → propagation → NV ensemble → digitiser → MagFrame + SHA-256
witness`. It has no network surface, so the real attack surface is **degenerate
physical-parameter input** crossing the external boundary — specifically the
WASM `config_json` / `scene_json` entry points.
Two properties matter for this crate that don't for others: it is billed
**deterministic** (a published cross-machine witness must reproduce bit-exactly),
and under `panic=abort` WASM any panic **aborts the whole module**. So a
config-induced panic is a denial-of-service, and a silent numeric corruption
defeats the simulator's entire purpose.
## Decision
Fix the two reachable degenerate-input bugs at their funnel points, each pinned
by a fails-on-old test, **without perturbing the deterministic happy path** (the
guards fire only on non-finite / degenerate input; the published witness is
unchanged).
### Findings fixed (both MEASURED-reproduced)
| # | Severity | Location | Issue | Fix |
|---|----------|----------|-------|-----|
| NVSIM-DT-01 | MEDIUM (DoS) | `pipeline.rs:58,95` | `dt = config.dt_s.unwrap_or(1.0 / f_s_hz)`; an external `f_s_hz == 0.0``dt = +Inf``(dt*1e6) as u64` saturates to `u64::MAX``(sample as u64) * dt_us` **panics `attempt to multiply with overflow`** at `sample ≥ 2` (debug/WASM-abort; garbage `t_us` in release). MEASURED: panic at `pipeline.rs:95:30`. | Sanitise `dt` (non-finite/non-positive → 1 µs fallback), cap the `u64` cast at `u64::MAX`, `saturating_mul` the timestamp — no config can overflow it. |
| NVSIM-NAN-01 | MEDIUM (silent corruption) | funnel `digitiser.rs::adc_quantise` (root: near-field clamp bypass in `source.rs`) | A non-finite scene param (NaN/Inf dipole position, Inf moment, NaN loop radius) **bypasses the near-field clamp** (`NaN < R_MIN_M == false` → the `1/r³` path runs → NaN field), and at the ADC `NaN as i32 == 0` (Rust saturating cast) emits a frame `b_pt=[0,0,0]` with **`ADC_SATURATED` CLEAR** — indistinguishable from a legitimate zero-field reading. MEASURED: `b=[NaN,NaN,NaN] sat=false``b_pt=[0,0,0] flags=0b0000`. | `adc_quantise`: any non-finite input → code `0` **with the saturation flag raised**; the pipeline's existing `adc_sat` OR-reduction propagates `ADC_SATURATED` onto the frame, making the corruption visible downstream. |
This is the same **NaN-fail-open / NaN-poisoning** family seen across
calibration/vitals/geo and ruview-swarm — non-finite input defeating a guard —
but bounded here to a single frame (no cross-timestep accumulator).
### Dimensions confirmed clean (with evidence)
1. **Determinism integrity — clean.** One RNG only: `ChaCha20Rng::seed_from_u64(seed)`,
fully caller-seeded (grep: one `seed_from_u64`, **zero** `thread_rng`/`getrandom`/
`SystemTime`/`Instant`/`HashMap`); `Cargo.toml` pins `rand`/`rand_chacha`
`default-features=false` (no OS entropy). BoxMuller draws
`gen_range(f64::EPSILON..=1.0)` (avoids `ln(0)=-Inf` by construction). Frame
bytes fixed LE; source summation order fixed by `Vec` order. **The published
cross-machine witness `cc8de9b0…93b4` (`proof_witness_publishes_a_known_value`)
passes UNCHANGED after both fixes** — the happy path is byte-identical; guards
touch only degenerate inputs. *Attested caveat (not a finding): libm
`cos`/`ln`/`sqrt` could differ x86↔wasm; the witness is documented as
x86_64-captured.*
2. **Panic-free deserialisation — clean.** `MagFrame::from_bytes` validates
len/magic/version, then per-field `buf[a..b].try_into().expect(...)` are over
fixed sub-ranges of an already-length-checked 60-byte buffer (provably
infallible). No `unsafe`, no `panic!`/`unreachable!` in production; every other
`unwrap`/`expect` is `#[cfg(test)]`.
3. **Div-by-zero / numerical landmines — clean.** `dipole_field`/`current_loop_field`
clamp `r_norm < R_MIN_M` before `1/r³`,`1/r²` (finite inputs); `shot_noise_floor`
guards `denom <= 0`; `vec3_normalise` guards `n < 1e-20`. The only hole was the
NaN *bypass* of the clamp — closed at the ADC funnel (NVSIM-NAN-01).
## Validation
- `cargo test -p nvsim --no-default-features`**50 → 53** passed, 0 failed (+3 pins:
`degenerate_zero_sample_rate_does_not_panic`,
`non_finite_scene_input_flags_frame_instead_of_silently_zeroing`,
`adc_quantise_flags_non_finite_as_saturated`).
- `cargo test --workspace --no-default-features`**exit 0**, 0 failed.
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash
`f8e76f21…46f7a` unchanged (nvsim off the signal proof path).
- nvsim's own cross-machine witness `cc8de9b0…93b4` reproduces unchanged.
## Consequences
### Positive
- A config-induced WASM-abort DoS and a silent NaN→fake-zero-field corruption are
closed at their funnel points, each regression-pinned, with the deterministic
witness proven intact.
### Negative / Neutral
- None. Guards affect only degenerate inputs; happy-path output is byte-identical.
## Links
- ADR-089 — `nvsim` NV-diamond magnetometer simulator
- ADR-176 — `ruview-swarm` (sibling NaN-fail-open review)
- ADR-172 — core/cli (where the NaN-bug-class root was settled NO)
@@ -0,0 +1,87 @@
# ADR-178: `wifi-densepose-desktop` IPC Injection Fix + Capability Least-Privilege
| Field | Value |
|-------|-------|
| **Status** | Accepted — 2 real MODERATE bugs fixed + pinned (MEASURED on Windows) |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **DESK-LOCKDOWN** |
| **Reviews** | `wifi-densepose-desktop` (Tauri v2 desktop app) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 3 of 4 |
## Context
`wifi-densepose-desktop` is the Tauri v2 desktop app (ESP32 discovery, firmware
flashing, OTA, provisioning, server control). The real attack surface is the
**Tauri IPC boundary** — `#[tauri::command]` handlers that take arguments from the
webview/JS — and the **capability/allowlist scope**. The crate **builds and tests
on Windows** (Tauri 2.10.3, webview2 path, no GTK), so both findings are MEASURED,
not source-analysis-only.
## Decision
Fix the two real findings; attest the rest of the surface clean with evidence.
### Findings fixed (both MEASURED)
| # | Severity | Location | Issue | Fix |
|---|----------|----------|-------|-----|
| WDP-DESK-01 | MODERATE | `src/commands/discovery.rs:438` (`configure_esp32_wifi`) | Webview-supplied `ssid`/`password` are concatenated into newline-terminated serial commands (`wifi_config {} {}\r\n`, `set ssid {}\r\n`) with **no validation** → a `\r\n` in either field **injects an arbitrary follow-up firmware command** (`reboot`, `erase_nvs`) across the IPC trust boundary. | `validate_wifi_credentials()` — WPA2 length bounds (SSID 132, password 863) **+ reject all control chars** (`char::is_control()`), called fail-closed before any serial write. |
| WDP-DESK-02 | MODERATE | `capabilities/default.json:7-8` | `shell:allow-execute` + `shell:allow-open` granted to the webview but **unused** (Rust spawns via `std::process::Command`; the UI uses only `dialog.open`). A webview compromise (a UI-dependency XSS) → arbitrary **unscoped host command execution**. | Removed both `shell:` permissions (kept `core:default` + the two in-use `dialog:` perms); regenerated `gen/schemas/capabilities.json` now asserts `["core:default","dialog:allow-open","dialog:allow-save"]`. |
Both are MODERATE (not HIGH): each requires a webview compromise or a malicious
local caller to weaponize. The unifying lesson is **least privilege at the IPC
boundary** — validate every webview-supplied argument that reaches a serial/FS/
process sink, and grant only the capabilities actually exercised.
### Tauri-command + capability audit (every handler)
All 30+ command handlers were mapped. Only `configure_esp32_wifi` lacked input
validation on a string that reached a command sink (WDP-DESK-01). Every
subprocess uses `Command::new(prog).args([...])` (argv vector — no shell-string
interpolation), so `port`/`source`/`chip`/`baud` cannot inject a second command
even unvalidated. `tauri.conf.json` ships **no** `fs`/`http` plugin and **no**
`"all":true`/`"$HOME/**"` scope; after WDP-DESK-02 the allowlist is minimal.
### Dimensions confirmed clean (with evidence)
1. **Directory traversal / arbitrary file** — path args (`firmware_path`/`wasm_path`)
are blobs the local user selects via the native `dialog.open` picker; settings
I/O is a fixed filename under `app_data_dir`. No attacker-named path sink.
2. **Shell-string injection** — every subprocess is an argv vector; grep found no
shell-string interpolation anywhere.
3. **SSRF-to-secret**`node_ip`-built URLs target the local ESP32 mesh and return
only device status JSON; no credential returned to the webview.
4. **Panic-on-input** — handlers use `.map_err(|e| e.to_string())?`; the one
`expect` is guarded by an `is_none()` early-return; provision/discovery
deserializers bounds-check every slice index (NVS size capped ≤ 4096).
5. **Hardcoded secrets**`ota_psk` is a per-call `Option<String>`, never embedded;
grep for embedded keys/tokens over `src/` is empty.
6. **Shell plugin genuinely unused**`tauri_plugin_shell` is `init()`-ed but its
`Command`/`open` API is never invoked from Rust or the TS UI (which imports only
`@tauri-apps/plugin-dialog`) — confirming WDP-DESK-02 is safe to remove.
## Validation
- `cargo check -p wifi-densepose-desktop --no-default-features``Finished` (Windows, MEASURED).
- `cargo test -p wifi-densepose-desktop --no-default-features` → lib **18 → 21** (+3 validator pins:
`test_validate_wifi_credentials_rejects_injection` / `_rejects_out_of_range` / `_accepts_valid`),
integration 21/21, **0 failed**.
- Capability narrowing MEASURED: regenerated `capabilities.json` permission set verified.
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash `f8e76f21…46f7a`
unchanged (desktop off the signal proof path).
## Consequences
### Positive
- An IPC serial-command-injection path and an over-broad shell capability are
closed in the desktop app, each pinned / verified, with the rest of the
30-command IPC surface attested clean.
### Negative / Neutral
- None. The removed shell capability was unused; the validator rejects only
malformed/hostile credentials.
## Links
- ADR-176 / ADR-177 — sibling Milestone-#9 reviews (ruview-swarm, nvsim)
- ADR-172 — core/cli review
@@ -0,0 +1,81 @@
# ADR-179: `wifi-densepose-occworld-candle` Checkpoint-Load Hardening
| Field | Value |
|-------|-------|
| **Status** | Accepted — 1 HIGH + 2 LOW bugs fixed + pinned (MEASURED on Windows) |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **OCCWORLD-DTYPE** |
| **Reviews** | `wifi-densepose-occworld-candle` (Candle occupancy-world model) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 4 of 4 — **CLOSES the milestone** |
## Context
`wifi-densepose-occworld-candle` is a Candle-based occupancy-world model
(VQ-VAE + transformer over occupancy tokens). The real risk surface for an ML
crate is degenerate-input / malformed-weights handling: a `#[forbid(unsafe_code)]`
crate can still **panic** (a DoS, and under WASM an abort) when a tensor op hits an
inconsistent shape. The crate **builds and tests on Windows**, so all findings are
MEASURED.
## Decision
Fix the three reachable bugs, each pinned by a fails-on-old test; attest the rest
clean with evidence.
### Findings fixed (all MEASURED)
| # | Severity | Location | Issue | Fix |
|---|----------|----------|-------|-----|
| 1 | **HIGH** | `model.rs:95` (`Dtype::I32 => Some(DType::I64)`) | **Crash on any int32-tensor checkpoint.** An I32 byte buffer (4 B/elem) is handed to `from_raw_buffer(.., I64, shape, ..)`; candle derives `elem_count = data.len()/8`, **halving** the count while keeping the original shape → a tensor that claims 2× its storage. Reading it **panics** with a slice-OOB (`range end index 6 out of range for slice of length 3`) inside candle-core. A checkpoint with any int32 tensor (index/buffer tensors are common in PyTorch exports) → **DoS on load**. | Map `I32 → DType::I32`, `I16 → DType::I16` (both first-class candle dtypes). Pinned by `int32_tensor_loads_with_consistent_shape_and_values` (panics on old, passes on new). |
| 2 | LOW | `inference.rs::predict` | Frame/batch dims weren't validated (only H/W/D were): `f_in > num_frames*2` over-indexes the temporal embedding → a cryptic candle `InvalidIndex` *error* (not a panic — candle bounds-checks); zero frame/batch feeds a zero-element tensor. | Boundary guard rejects zero / over-capacity frame+batch with a clear `ShapeMismatch`. 5 pins. |
| 3 | LOW | `vqvae.rs:141` (`z.elem_count() / last`) | **Divide-by-zero panic** in public `VQCodebook::encode` on a rank-0 / empty-last-dim tensor (`last == 0`). | Fail-closed guard returns a clear error. Pinned by `encode_rejects_scalar_without_panicking`. |
The HIGH finding is the notable one: the crate's own dtype mapping **defeated**
the upstream `safetensors::validate()` byte-length guarantee by misdeclaring the
dtype — the one place malformed/widened weights could reach a panicking candle op.
### Dimensions confirmed clean (with evidence)
- **Panic surface** — grep for `unwrap()/expect()/panic!/unreachable!` across `src/`
**zero in production paths**; all ops use `?`/`map_err`; the `last().unwrap_or(&0)`
is now guarded. `as` casts operate only on config-bounded/internal values.
- **NaN-state-poisoning (the named class) — N/A.** The engine is **stateless between
`predict` calls** (no persistent world-model buffer to latch into), and input is
`u8` class indices (non-finite input structurally impossible). NaN weights flow to
`argmax` (deterministic, bounded to a valid class index) — no panic, no persistence.
- **Unbounded alloc / shape-data mismatch from malformed weights** — defended upstream
by `safetensors::validate()` (overflow-checked `nelements*dtype.size()` vs declared
byte range + contiguous-offset + buffer-length checks), rejected before reaching
candle. Finding #1 was the one place the crate defeated that guarantee.
- **Model/path loading**`load`/`load_safetensors` check `path.exists()` → typed
`CheckpointNotFound`; corrupt bytes → `CheckpointParse` (pinned). No path-traversal
surface (caller-supplied path, opened read-only, never joined with untrusted segments).
- **Secrets** — grep clean (only `token_h`/`token_w` config fields match `token`).
- **Determinism** — the crate's central honesty claim, verified by the pre-existing
`tests/predict_honesty.rs` (3 tests, still pass).
- `unsafe_code = "forbid"` in the manifest.
## Validation
- `cargo test -p wifi-densepose-occworld-candle --no-default-features` → **31/31**
(lib 17, checkpoint_loading 4, input_validation 5, predict_honesty 3, doctests 2),
0 failed.
- `cargo test --workspace --no-default-features` → 0 failed across every crate (a lone
`wifi-densepose-desktop --test api_integration` "Access is denied (os error 5)" was a
Windows file-lock/AV flake — re-ran isolated 21/21, unrelated).
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash `f8e76f21…46f7a`
unchanged (occworld off the signal proof path).
## Consequences
### Positive
- A checkpoint-load DoS (the int32 dtype-widening panic) and two degenerate-input
panics are closed in the world-model crate, each pinned. **Milestone #9 (all 4
ungated crates) is complete.**
### Negative / Neutral
- None. Guards reject only malformed/degenerate inputs.
## Links
- ADR-176 / ADR-177 / ADR-178 — sibling Milestone-#9 reviews (ruview-swarm, nvsim, desktop)
@@ -0,0 +1,279 @@
# ADR-182: `npx ruview` — A RuView Agent Harness Minted via MetaHarness
| Field | Value |
|-------|-------|
| **Status** | Accepted — **P1+P2 implemented & validated** (`harness/ruview/`, 17/17 tests, MCP handshake + `ruview.verify` PASS against the real repo, packs to 16.7 kB / 21 files) · P3 publish-ready (name decision pending) · P4 (router + provenance) designed |
| **Date** | 2026-06-17 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-HARNESS** |
| **Builds on** | MetaHarness (`metaharness@0.1.15`, `@metaharness/kernel`, `@metaharness/host-*`, `@metaharness/router`), the `ruview-*` Claude Code subagents (`ruview-onboarding-guide`, `ruview-config-engineer`, `ruview-training-engineer`), the `wifi-densepose` CLI (`calibrate`/`enroll`/`train-room`/`room-watch`), the sensing-server, ADR-028 (witness verification), ADR-095/096 (rvCSI runtime), ADR-260/262 (RuField bridge) |
| **Supersedes** | none |
## Context
RuView (WiFi-DensePose) is a deep stack — 15 Rust crates, an ESP32 firmware line,
a sensing-server, a CLI, ~180 ADRs, a calibration pipeline, training recipes, and a
hard cultural rule that **every claim must be independently reproducible** (the
"prove everything" ethos, after the project was accused of AI-slop). The barrier to
entry is correspondingly steep: a newcomer who wants to "set up WiFi sensing" must
discover the right firmware variant, provision an ESP32 over a Windows-only Python
subprocess, point it at the sensing-server, run `calibrate``enroll`
`train-room`, and know which numbers are MEASURED vs CLAIMED. We already encode this
knowledge as **Claude Code subagents** (`ruview-onboarding-guide`,
`ruview-config-engineer`, `ruview-training-engineer`) — but those only exist inside
*this* repo's `.claude/agents/`, only on Claude Code, and only for someone who has
already cloned the monorepo.
Separately, this session shipped **MetaHarness** (`metaharness@0.1.15`): a tool that
*"mints a custom AI agent harness from any repo"*, runnable on **9 hosts**
(claude-code, codex, pi-dev, hermes, openclaw, rvm, copilot, opencode,
github-actions) over a wasm-primary / NAPI-RS-fallback **kernel**, with a
**cost-optimal model router** (`@metaharness/router`, the productized DRACO Phase-2
k-NN finding) and ed25519/SLSA/SBOM provenance baked in. Crucially, MetaHarness
**already ships a `vertical:ruview` template** in its template list. That template
is generic scaffolding; it is not wired to RuView's actual tools, agents, or the
"prove everything" guardrails.
The gap: **there is no single, host-portable, provenance-signed entry point that
gives any user an AI agent that actually knows how to operate RuView.** A user
should be able to run one command —
```bash
npx ruview
```
— in an empty directory (or alongside an ESP32) and get an agent harness that can
onboard them, configure firmware, drive a live capture, train a room model, and
**refuse to overstate accuracy** — on whichever coding host they already use.
## Decision
**Mint a first-class RuView agent harness from this repo using MetaHarness, harden
its `vertical:ruview` template into a RuView-specific harness with a real MCP tool
surface and the project's honesty guardrails, and publish it as `npx ruview`.**
`npx ruview` is *not* a new runtime. It is a **thin, versioned distribution** of a
MetaHarness harness: the kernel + host adapters + a RuView "genome" (skills, agents,
MCP tools, guardrails) generated from and pinned against this monorepo. The harness
is the product; `npx ruview` is the front door.
### Why mint-from-repo instead of hand-writing a harness
MetaHarness's value here is exactly the work we would otherwise hand-roll across 9
hosts: host-specific config (`.claude/settings.json` MCP + hooks for claude-code,
the codex/copilot/opencode equivalents), the kernel that abstracts wasm-vs-native,
the cost router, and the provenance chain. We write the **RuView knowledge once** as
host-neutral genome assets; MetaHarness projects them onto each host adapter. This
also keeps the harness regenerable: when the CLI or an ADR changes, re-mint and
re-pin rather than maintaining 9 divergent copies.
### What the harness contains (the RuView genome)
1. **Skills / playbooks** (host-neutral markdown, projected to each host's skill
format):
- `onboard` — zero-to-sensing path picker (Docker demo / repo build / live
ESP32), the physics caveats, the hardware table. Port of
`ruview-onboarding-guide`.
- `provision-node` — ESP-IDF v5.4 Windows-subprocess build/flash/provision flow
(the exact MSYSTEM-stripped invocation from `CLAUDE.local.md`), firmware
variant selection (8MB display / 4MB no-display / C6), NVS + WiFi + channel /
MAC-filter overrides (ADR-060).
- `calibrate-room``baseline → enroll → extract → train` via the
`wifi-densepose` CLI (`calibrate`/`calibrate-serve`/`enroll`/`train-room`/
`room-watch`, ADR-151).
- `train-pose` — camera-supervised + camera-free training, the MEASURED-vs-CLAIMED
discipline, the mean-pose baseline check (ADR-079, ADR-152, ADR-181).
- `verify` — run the witness bundle + Python proof (`verify.py` → VERDICT: PASS),
ADR-028.
- Ports of `ruview-config-engineer` and `ruview-training-engineer`.
2. **MCP tool surface** (`@metaharness/kernel`-hosted MCP server, one schema per
capability — see "MCP tools" below). This is what makes the harness *operate*
RuView, not just talk about it.
3. **Guardrails** (the differentiator): the harness's system prompt and a
pre-output hook enforce the "prove everything" rule — accuracy numbers must be
tagged MEASURED (with a reproducer) or CLAIMED; the agent must run the mean-pose
baseline before quoting PCK; firmware fixes are never presented as
hardware-validated without a real boot log (the exact discipline this session
followed for `v0.8.1-esp32`).
4. **Host adapters** — claude-code first (P1), then codex / opencode / copilot /
pi-dev / hermes / rvm / github-actions (P3+), each via the published
`@metaharness/host-*` package.
5. **Router**`@metaharness/router` routes each step to the cheapest adequate
model (e.g. a var-rename or a log-grep → Haiku; calibration-math reasoning or a
security review → Sonnet/Opus), mirroring the repo's 3-tier routing (ADR-026).
### MCP tools (the operational surface)
| Tool | Wraps | Purpose |
|------|-------|---------|
| `ruview.onboard` | docs + agent | Pick a setup path, print the next concrete command |
| `ruview.node.flash` | ESP-IDF subprocess (ADR `CLAUDE.local.md`) | Build + flash a firmware variant to a COM port |
| `ruview.node.provision` | `provision.py` | Set SSID/password/target-ip/channel/MAC-filter over serial |
| `ruview.node.monitor` | pyserial | Stream boot log; assert CSI is flowing (MGMT+DATA) |
| `ruview.server.up` | sensing-server | Start the Axum sensing-server (`:3000`/`:5005`/`:8765`) |
| `ruview.calibrate` | `wifi-densepose calibrate`/`enroll`/`train-room` | Run the ADR-151 room pipeline |
| `ruview.room.watch` | `wifi-densepose room-watch` | Live presence/vitals from a trained room |
| `ruview.verify` | `scripts/generate-witness-bundle.sh` + `verify.py` | Produce/verify the witness bundle (must be N/N PASS) |
| `ruview.claim.check` | static lint | Scan output for untagged accuracy claims; flag MEASURED-vs-CLAIMED |
Each tool returns structured JSON and is fail-closed: a tool that cannot prove its
result (e.g. `ruview.node.monitor` sees no CSI callbacks) returns an honest negative,
never a fabricated success — consistent with the RuField `map_privacy` fail-closed
posture (ADR-262 §3.3).
### The mint + pin flow (how the harness is produced)
```bash
# P1 — mint from this repo, claude-code host, RuView vertical
npx metaharness ruview --template vertical:ruview --host claude-code \
--from-existing . --description "RuView WiFi-sensing operator agent" \
--target ./harness/ruview
# readiness + fit/cost/safety scorecards (ADR-041) — gate before publish
npx metaharness genome . # 7-section repo readiness
npx metaharness score . --json # fit / cost / safety
npx metaharness analyze . # recommended harness plan (no-exec)
```
The minted harness is committed under `harness/ruview/` and **pinned** (kernel +
host-adapter + router versions locked) so `npx ruview` is reproducible. Re-minting on
a CLI/ADR change is a reviewed PR, not an implicit regeneration.
### Distribution: `npx ruview`
A small published package whose `bin` boots the pinned harness via the kernel:
- **Preferred name:** `ruview` (currently **free** on npm — verified 2026-06-17).
- **Risk:** npm's typosquat filter may reject `ruview` as too close to `review` /
`preview` (this session hit exactly that on `ruvn``levn`/`raven` and
`worldgraph``world-graph`). **Fallback:** publish scoped `@ruvnet/ruview` (also
free) and/or `npx ruvnet/ruview` straight from GitHub. Decide at publish time;
do not unpublish to rename (the 24-h name-lock lesson from `worldgraphs`).
- `bin: { "ruview": "bin/cli.js" }` — note **`bin/cli.js`, not `./bin/cli.js`** (npm
strips the `./` form; this broke `ruvn@0.1.0` this session).
- `npx ruview` with no args → `onboard` skill (interactive path picker).
`npx ruview <skill> [...]` → run a specific skill. `npx ruview --host codex`
install the harness into an existing repo for that host.
## Architecture
```
npx ruview (thin bin — boots the pinned harness)
@metaharness/kernel (wasm primary · NAPI-RS native fallback)
├── host adapter ── claude-code | codex | opencode | copilot | pi-dev | hermes | rvm | github-actions
├── @metaharness/router (k-NN cost-optimal model routing — DRACO P2 / ADR-026)
└── RuView genome (pinned)
├── skills onboard · provision-node · calibrate-room · train-pose · verify
├── mcp tools ruview.node.* · ruview.calibrate · ruview.room.watch · ruview.verify · ruview.claim.check
└── guardrails MEASURED-vs-CLAIMED · mean-pose baseline · no-unvalidated-firmware-claims
RuView assets (the real system the agent drives)
├── wifi-densepose CLI calibrate / enroll / train-room / room-watch
├── sensing-server :3000 / :5005 / :8765
├── ESP-IDF subprocess build / flash / provision / monitor (COM8/COM9/COM12)
└── witness bundle + verify.py
```
Provenance: the harness ships an **ed25519 witness + SBOM (SPDX) + SLSA** chain
(MetaHarness already does this for minted harnesses), so a recipient can verify the
RuView harness was built from a specific monorepo commit — the agentic analogue of
the firmware witness bundle (ADR-028).
## Phases
- **P1 — Mint & pin (claude-code).** `npx metaharness ruview --template
vertical:ruview --from-existing . --host claude-code`. Port the three `ruview-*`
subagents into host-neutral genome skills. Commit under `harness/ruview/`, pin
versions. Acceptance: `npx metaharness score .` ≥ threshold; the harness can run
`onboard` and `verify` end-to-end locally.
- **P2 — MCP tool surface.** Implement the `ruview.*` MCP tools over the kernel
(start with `onboard`, `verify`, `claim.check`, `node.monitor` — the read-only /
proving tools), then the mutating ones (`node.flash`, `provision`, `calibrate`).
Acceptance: `ruview.verify` returns the witness bundle PASS as structured JSON;
`ruview.claim.check` flags a seeded untagged "100% accuracy" string.
- **P3 — Publish `npx ruview` + multi-host.** Publish the bin package (name decision
per Distribution). Add codex / opencode / copilot / pi-dev / hermes / rvm /
github-actions adapters. Acceptance: `npx ruview` cold-starts on ≥3 hosts and runs
`onboard`; provenance verifies.
- **P4 — Router + guardrail hardening.** Wire `@metaharness/router`; calibrate the
3-tier routing on a RuView task set. Make the MEASURED-vs-CLAIMED guardrail a hard
pre-output gate. Acceptance: a benchmark of RuView tasks shows cost reduction vs
all-Opus with no quality regression; the guardrail blocks an untagged accuracy
claim in a red-team prompt.
## Consequences
**Positive**
- One reproducible, signed entry point (`npx ruview`) that operates RuView on the
host the user already has — onboarding goes from "clone a 15-crate monorepo" to a
single `npx`.
- The "prove everything" ethos becomes **executable**, not just documentation: the
harness *enforces* MEASURED-vs-CLAIMED and the mean-pose baseline.
- Knowledge written once (host-neutral genome) instead of 9× per host; regenerable
from the repo as the system evolves.
- Dogfoods MetaHarness on a hard real vertical, surfacing bugs back to
`agent-harness-generator` (this session already filed #9#13 there).
**Negative / risks**
- **Drift:** a pinned harness goes stale as the CLI/ADRs move; mitigated by a
re-mint-on-change PR ritual and a CI check that the genome's referenced
CLI flags still exist.
- **Surface area:** mutating MCP tools (`node.flash`, `provision`) touch hardware and
the network — must be permission-gated and fail-closed; the firmware-flash tool
must never claim hardware validation without a captured boot log.
- **Name/typosquat:** `ruview` may be rejected at publish; scoped fallback decided in
P3. Do not unpublish-to-rename.
- **Host parity:** not all 9 hosts support MCP + hooks equally; the guardrail gate
may degrade to advisory on weaker hosts — must be disclosed in the badge, not
hidden (same honesty principle as ADR-181's backend badge).
- **Windows-coupled tooling:** the ESP-IDF flow is Windows-subprocess-specific
today; the `node.*` tools are gated to that environment until a cross-platform
path exists.
## Alternatives considered
1. **Keep the `ruview-*` subagents repo-local (status quo).** Zero new surface, but
stays Claude-Code-only and clone-gated; no portable front door. Rejected — it's
the gap this ADR exists to close.
2. **Hand-write a bespoke `npx ruview` harness (no MetaHarness).** Full control, but
re-implements the kernel, 9 host adapters, the router, and the provenance chain
we already ship — months of duplicated work and 9 divergent configs to maintain.
Rejected.
3. **Use the generic `vertical:ruview` template as-is.** It's scaffolding with no
real tools or guardrails — it would *talk about* RuView without being able to
*operate* it or enforce honesty. Rejected as insufficient; P2 is precisely the
hardening that makes it real.
4. **Ship only an MCP server (no harness/host adapters).** Covers tools but not the
skills, routing, guardrails, or multi-host projection — a strictly smaller subset
of this design. Folded in as the P2 layer rather than the whole.
## Open questions
- Final published name: bare `ruview` vs scoped `@ruvnet/ruview` vs GitHub-only
`npx ruvnet/ruview` — resolve against the typosquat filter at P3.
- Does the harness bundle the `wifi-densepose` binary, shell out to a user-installed
one, or offer both? (Leaning: shell out; print install guidance if absent.)
- Where do the `node.*` hardware tools live for non-Windows users — defer, or wrap
the rvCSI runtime (ADR-095/096) which is cross-platform Rust?
- Should `ruview.verify` gate `npx ruview` self-tests in CI (harness can't publish if
the witness bundle regresses)?
- Relationship to the RuField MFS harness surface (ADR-260/262) — one harness with a
RuField skill, or a sibling `npx rufield`?
## References
- MetaHarness: `metaharness@0.1.15` (`npx metaharness`, templates incl.
`vertical:ruview`; hosts: claude-code/codex/pi-dev/hermes/openclaw/rvm/copilot/
opencode/github-actions), `@metaharness/kernel`, `@metaharness/router`,
`@metaharness/host-*`, repo `github.com/ruvnet/agent-harness-generator`.
- RuView subagents: `ruview-onboarding-guide`, `ruview-config-engineer`,
`ruview-training-engineer` (`.claude/agents/`).
- ADR-026 (3-tier model routing), ADR-028 (witness verification), ADR-041
(MetaHarness scorecards), ADR-060 (channel / MAC-filter overrides), ADR-079
(camera ground-truth training), ADR-095/096 (rvCSI runtime), ADR-151 (per-room
calibration), ADR-152/181 (WiFlow / browser pose), ADR-260/262 (RuField bridge).
@@ -0,0 +1,98 @@
# ADR-183: Onboard LED as a 40 Hz Gamma Stimulus, Colour-Mapped from Live CSI via `ruv-neural-viz`
| Field | Value |
|-------|-------|
| **Status** | Accepted — implemented & hardware-confirmed on ESP32-S3 N16R8 (COM8) |
| **Date** | 2026-06-17 |
| **Deciders** | ruv |
| **Codename** | **GAMMA-VIZ** |
| **Builds on** | `ruv-neural-viz::ColorMap` (now `no_std` — ruvnet/ruv-neural#3 / RuView#1126), the ESP32 edge `motion_energy` metric (`edge_processing.c`), PR #962 (WS2812 on GPIO 48) |
## Context
Two threads converged. (1) `ruv-neural-viz::ColorMap` — the viridis/cool-warm
palette the rUv-Neural stack uses to render brain-topology graphs — was `std`-only,
so it couldn't run on the ESP32. (2) The onboard WS2812 on the S3 CSI node was dead
weight: the firmware only cleared it on boot (and on the wrong pin for N16R8 — GPIO
38 vs the actual 48, see #962).
The ask: make the LED do something real and honest, using the project's own visual
capability — not a decorative blink. The natural fit is a **40 Hz gamma stimulus**
(the GENUS gamma-entrainment frequency from Alzheimer's light-therapy research)
whose **colour is driven by live sensed motion**, so the node's front panel is both
a known bio-stimulus waveform and a truthful readout of what the CSI is detecting.
## Decision
### Part A — make `ColorMap` `no_std`
`colormap.rs` is self-contained (no cross-crate deps), so expose it on `no_std`
targets. The only blockers were two `std`-only `f64` ops:
- `f64::round` / `f64::abs` → replaced with `core`+`alloc`-safe helpers `fround`
(round via `f64 as i64` truncation — a `core` cast, no `libm`) and `fabs`.
- `Vec`/`String`/`format!` → from `alloc`.
The graph-bound modules (`animation`/`ascii`/`export`/`layout`) and their heavy deps
move behind a default `std` feature; `--no-default-features` builds the crate `no_std`
and exposes only `colormap`. Output is **byte-identical** (8/8 colormap tests pass with
the same RGB values), so this is a pure portability change.
### Part B — the LED stimulus (firmware)
`firmware/esp32-csi-node/main/main.c`, on boot:
- WS2812 on **GPIO 48** (N16R8 / DevKitC-1 v1.1; GPIO 8 on C6).
- An `esp_timer` periodic at **12 500 µs toggles a square wave → 40 Hz, 50 % duty**
(full-on / full-off — a *perceptible* gamma flicker, not a colour drift).
- **ON-phase colour = live CSI motion.** Each ON phase reads `edge_get_vitals().motion_energy`,
normalises it (`/ LED_MOTION_FULLSCALE`, clamped `[0,1]`), and indexes a **60-step
viridis LUT generated from `ColorMap::viridis().map()`** — still = dark purple,
strong motion = yellow.
The LUT is baked from the real crate (Part A makes the same `ColorMap` embeddable
for a future direct FFI path once the ESP Rust toolchain is in CI). The colours are
therefore provably `ruv-neural-viz`'s, and the motion is provably real.
## Honesty (what it is and is not)
- **40 Hz is a real square-wave stimulus** (12.5 ms on / 12.5 ms off), not a label on
a colour sweep. It is *not* tied to any measured 40 Hz brain rhythm — it is an
*output* stimulus at the gamma frequency, not a readout of neural gamma.
- **Colour is a real CSI readout**`motion_energy` is the on-device phase-variance
motion metric the node already computes; no fabrication. At rest the LED sits at the
purple (low) end and flickers there.
- No therapeutic claim is made. 40 Hz GENUS entrainment is cited as the *origin of the
frequency choice*, not as a validated medical effect of this device.
## Consequences
**Positive**
- The LED is now an honest front-panel: gamma-frequency flicker + a live motion readout.
- `ColorMap` is embeddable (`no_std`), unblocking on-device use of the rUv-Neural
palette beyond this LED.
- Confirms #962's GPIO-48 fix visually (the LED lights on N16R8).
**Negative / risks**
- Changes the *default* firmware behaviour: the onboard LED animates instead of staying
off. Now **gated by `CONFIG_LED_GAMMA_VIZ`** (default `y`); set it `n` for a dark,
lower-power boot (the LED is just cleared) — no source change needed.
- A 40 Hz flicker can be an issue for photosensitive users; document on the enclosure
and disable `CONFIG_LED_GAMMA_VIZ` in those deployments.
- The saturation point is now `CONFIG_LED_MOTION_FULLSCALE_MILLI` (default 250 = 0.25),
operator-tunable; still not auto-calibrated per-environment.
- The colour uses a baked LUT, not the live Rust `ColorMap` (FFI path deferred — needs
the ESP Rust/xtensa toolchain, not yet in CI).
## Validation
- `ruv-neural-viz`: `cargo build` (std) ✓, `cargo test colormap` 8/8 ✓ (identical RGB),
`cargo build --no-default-features` compiles `no_std` ✓.
- Firmware: built (1.13 MB), flashed to ESP32-S3 N16R8 (COM8). Boot log:
`Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO 48`;
CSI continues (2738 pps), `motion=0.00` at rest → purple flicker as designed.
- Full on-device (xtensa) Rust build of `ColorMap` not run — ESP Rust toolchain absent.
## References
- ruvnet/ruv-neural#3 (ColorMap no_std), RuView#1126 (submodule bump), #962 (GPIO 48).
- Singer/Tsai GENUS 40 Hz gamma entrainment (origin of the frequency, not a device claim).
+135
View File
@@ -0,0 +1,135 @@
# WiFlow Browser Trainer (`wiflow_browser.html`)
A **single self-contained HTML page** that does the entire camera-supervised
WiFi-pose loop **in your browser, in your laptop camera's coordinate frame**, as
a **4-stage gated flow** with a progress stepper (each stage unlocks the next):
0. **CALIBRATE** *(ADR-151 empty-room baseline)* — you step OUT of the space; the
page captures ~10 s of the quiescent CSI and computes a per-feature running
**mean + std (Welford)** over the 410-d vector. Every CSI vector afterwards is
expressed as **deviation from baseline**
(`x_norm = (x base_mean) / (base_std + ε)`), so a body's perturbation stands
out from the static channel. Persisted to IndexedDB. *Can't capture without it.*
1. **CAPTURE** — MediaPipe Pose runs on your laptop camera → 17 COCO keypoints
(the *label*), paired with the **baseline-normalized** 410-d ESP32 CSI vector
(the *input*). A **guided, balanced routine** cycles big on-screen prompts
(stand / turn / walk / arms / crouch / sit / reach) with a countdown, and a
**per-pose coverage meter** so you build a balanced dataset, not 2 000 frames
of standing.
2. **TRAIN** — a TensorFlow.js MLP learns `CSI → pose` in-browser. Honest
held-out PCK@0.10 / PCK@0.05 / MPJPE, plus a **mean-pose baseline** the model
must beat (the project's whole ethos — no baseline-beating signal, it says so).
*Can't train with <200 samples.*
3. **INFER** — the trained model drives a skeleton **from WiFi CSI only**
(baseline-normalized → standardized → model), drawn over the **same** camera
frame it trained in — so the inferred skeleton **aligns** with the camera
image. That alignment is the entire point of doing this in-browser instead of
with a separate Python camera. *Can't infer without a model.*
## Why in-browser
The Python pipeline (`wiflow_capture.py``wiflow_train.py``wiflow_infer.py`)
proved the signal is real (held-out PCK@0.10 ≈ 59.5% vs a 50% mean-pose baseline
= +9.4 pp). But it trained in a *different* camera's frame, so the inferred
skeleton never lined up with the laptop camera. Doing capture + train + infer all
in the browser with the **same** camera makes the training frame and the
inference frame identical → the skeleton aligns.
## Compute backends (WebGPU / WASM / WebGL)
Training and inference run on TensorFlow.js. The page selects the backend at
startup, preferring the fastest available:
- **WebGPU** (Chrome / Edge, secure context — `localhost` qualifies) — GPU compute.
- **WASM-SIMD** fallback (`tfjs-backend-wasm`, SIMD enabled, `.wasm` from the CDN).
- **WebGL** last-resort fallback (ships inside tfjs core).
The **active backend is shown as a badge in the header** (`compute: WebGPU` /
`WASM-SIMD` / `WebGL`) so it's honest about what's actually running. The model
code is backend-agnostic — tf.js abstracts the device.
## Honesty (baked in)
- The **CAPTURE** skeleton (blue) is the camera = ground truth, labeled as such.
- The **INFER** skeleton (green) is **CSI-only**, labeled, and **coarse** — the
real measured held-out PCK is shown, not a marketing number.
- The **mean-pose baseline** is always computed and shown in TRAIN; the verdict
states plainly whether the model **beats** it (real signal) or **does not**
(no usable signal). This guards against the project's retracted 92.9% that
failed exactly this check.
- Status banner is strict and mutually exclusive:
**LIVE** (real `source: "esp32"`) / **SIMULATED — not real** (any other source)
/ **NO-CSI-SERVER**. The page never invents frames.
## How to run
### 1. Start the real sensing-server (provides the CSI WebSocket on :8765)
```bash
cd v2
cargo build -p wifi-densepose-sensing-server
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005
```
A real ESP32-S3 must be provisioned and streaming for `source` to read `esp32`
(see `CLAUDE.local.md` for the firmware build/provision steps). The page expects
the verified live endpoint **`ws://localhost:8765/ws/sensing`** with
`source:"esp32"`, nodes `[9, 13]`, `features.*`, `node_features[].features.*`,
and `signal_field.values` (400 floats).
### 2. Serve this page over localhost (camera + WebGPU need a localhost/secure origin)
Any static localhost server works. For example:
```bash
python -m http.server 8099
# then open: http://localhost:8099/examples/through-wall/wiflow_browser.html
```
(8099 is just the static file server — 8765 is a separate process, the CSI
WebSocket.) Allow camera access when the browser prompts.
Point at a CSI server on another host with `?ws=`:
```
http://localhost:8099/examples/through-wall/wiflow_browser.html?ws=ws://192.168.1.20:8765/ws/sensing
```
### 3. Use it
1. **CAPTURE** tab → *enable laptop camera**start recording*. Follow the guided
routine (stand / turn / walk / arms / crouch / sit). A pair is stored only when
a confident pose AND a fresh live `esp32` CSI frame coexist. Aim for a few
thousand samples. Samples persist in IndexedDB across refreshes.
2. **TRAIN** tab → *train model*. Watch the live loss curve, held-out PCK, and the
baseline verdict. The model saves to IndexedDB.
3. **INFER** tab → the green skeleton is now driven by WiFi CSI only, aligned over
your camera. Toggle *hide camera* to see the CSI-only skeleton on black.
## The 410-d CSI vector (matches the Python pipeline exactly)
```
[ mean_rssi, variance, motion_band_power, breathing_band_power ] # 4 (features.*)
+ for node 9 then node 13: [ mean_rssi, variance, motion_band_power ] # 6 (node_features[].features.*)
+ signal_field.values, padded / truncated to 400 # 400
= 410-d
```
Verified against a real live frame: the in-browser `csiVector()` produces the
identical 410 vector as `wiflow_capture.py`'s `csi_vector()` (node 9 first, then
node 13; field zero-padded).
## Libraries (CDN only, no bundler)
| Library | CDN |
|---|---|
| TensorFlow.js core | `@tensorflow/tfjs@4.22.0/dist/tf.min.js` |
| TF.js WebGPU backend | `@tensorflow/tfjs-backend-webgpu@4.22.0/dist/tf-backend-webgpu.min.js` |
| TF.js WASM backend | `@tensorflow/tfjs-backend-wasm@4.22.0/dist/tf-backend-wasm.min.js` |
| MediaPipe Pose 0.5 (legacy solutions) | `@mediapipe/pose@0.5/pose.js` |
## Scope / honesty caveats
Same person, same room, same session. **Not** validated cross-day, cross-room, or
through-wall. The inferred pose is coarse (PCK@0.05 is typically weak). If the
model does not beat the mean-pose baseline, the page says so — that is a feature.
+644
View File
@@ -0,0 +1,644 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RuView · Through-Wall WiFi Sensing · LIVE CSI (no skeleton, no simulation)</title>
<!--
THROUGH-WALL WiFi-CSI SENSING DEMO — honest, real-data-only.
Renders ONLY what the running sensing-server actually streams over
ws://localhost:8765/ws/sensing :
- the 20x20 `signal_field` floor heatmap (real values)
- a coarse RF-localization puck from persons[0].position (NOT pose)
- live motion / presence / rssi / confidence meters
- the real `source` ("esp32" = LIVE) verbatim in the banner
It deliberately does NOT draw a skeleton. The server's
persons[].keypoints carry confidence:0.0 (image-pixel garbage, not
real 3D joints) so we never render them. WiFi CSI gives
motion/presence/coarse-position — that is the honest wow, and it
penetrates drywall. See README.md.
-->
<style>
:root {
--bg: #050507; --bg-panel: rgba(8,10,14,0.80);
--amber: #ffb840; --amber-hot: #ffe09f;
--cyan: #4cf; --magenta: #ff4cc8;
--text: #d8c69a; --text-mute: #6b6155;
--green: #4f4; --red: #f64;
--border: rgba(255,184,64,0.18);
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
-webkit-font-smoothing: antialiased; font-size: 12px;
}
canvas { display: block; }
.overlay-frame {
position: fixed; inset: 0; pointer-events: none; z-index: 5;
background:
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
}
.scanlines {
position: fixed; inset: 0; pointer-events: none; z-index: 6;
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
mix-blend-mode: overlay; opacity: 0.5;
}
.panel {
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
box-shadow: 0 1px 0 rgba(255,184,64,0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
}
.panel h2 {
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
}
/* ---- Honest status banner (top-center, mutually exclusive states) ---- */
#banner {
position: fixed; top: 0; left: 0; right: 0; z-index: 30;
text-align: center; padding: 7px 12px; font-size: 12px; letter-spacing: 1px;
font-weight: 600; border-bottom: 1px solid rgba(0,0,0,0.4);
transition: background 0.3s, color 0.3s;
}
#banner.live { background: rgba(40,255,80,0.12); color: var(--green); border-bottom-color: rgba(80,255,120,0.4); }
#banner.sim { background: rgba(255,120,40,0.16); color: #ffae5a; border-bottom-color: rgba(255,140,60,0.5); }
#banner.noserver { background: rgba(255,80,80,0.16); color: var(--red); border-bottom-color: rgba(255,90,90,0.5); }
#banner .src { opacity: 0.8; font-weight: 400; }
#banner-caption {
position: fixed; top: 30px; left: 0; right: 0; z-index: 29;
text-align: center; font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px;
pointer-events: none; padding-top: 2px;
}
#info { top: 64px; left: 20px; min-width: 270px; }
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
#info .row .k { color: var(--text-mute); font-size: 11px; }
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
#info .row .v.amber { color: var(--amber); }
#info .row .v.cyan { color: var(--cyan); }
#info .row .v.green { color: var(--green); }
#info .row .v.red { color: var(--red); }
#info .row .v.mag { color: var(--magenta); }
#info .row .v.mute { color: var(--text-mute); }
#csi { top: 64px; right: 20px; min-width: 270px; }
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
#csi .bar-row .label { width: 86px; color: var(--text-mute); }
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
#csi .bar-row .bar-fill {
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
box-shadow: 0 0 6px var(--amber); transition: width 0.1s linear;
}
#csi .bar-row .val { width: 44px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
#csi .spark { margin-top: 8px; }
#csi canvas { width: 100%; height: 38px; display: block; border: 1px solid var(--border); border-radius: 3px; background: rgba(0,0,0,0.3); }
#csi .legend { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 10px; color: var(--text-mute); line-height: 1.5; }
/* ---- waiting / no-server overlay ---- */
#waiting {
position: fixed; inset: 0; z-index: 25; display: none;
flex-direction: column; align-items: center; justify-content: center;
background: rgba(5,5,7,0.94); color: var(--amber); text-align: center; padding: 24px;
}
#waiting.show { display: flex; }
#waiting .big { font-size: 22px; letter-spacing: 2px; color: var(--red); margin-bottom: 16px; text-transform: uppercase; }
#waiting code {
display: block; text-align: left; max-width: 640px; margin: 8px auto;
background: rgba(255,184,64,0.06); border: 1px solid var(--border); border-radius: 4px;
padding: 10px 14px; color: var(--amber-hot); font-size: 12px; white-space: pre-wrap;
}
#waiting .pulse { animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 0.55; } 50% { opacity: 1; } }
/* ---- optional webcam ground-truth tile ---- */
#cam-tile {
position: absolute; bottom: 20px; right: 20px; width: 240px; z-index: 12;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 8px; backdrop-filter: blur(8px);
}
#cam-tile h2 { margin: 0 0 6px 0; font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px;
color: var(--cyan); font-weight: 600; }
#cam-tile .gt-note { font-size: 9px; color: var(--text-mute); margin-top: 4px; line-height: 1.4; }
#cam-video { width: 100%; border-radius: 3px; display: none; background: #000; }
#cam-tile button {
width: 100%; margin-top: 6px; padding: 5px 8px; font-family: inherit; font-size: 11px;
background: transparent; color: var(--cyan); border: 1px solid var(--cyan); border-radius: 3px; cursor: pointer;
}
#cam-tile button:hover { background: rgba(68,204,255,0.12); }
#cam-tile button:disabled { opacity: 0.5; cursor: not-allowed; }
#legend-nodes {
position: absolute; bottom: 20px; left: 20px; min-width: 220px;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
}
#legend-nodes h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
#legend-nodes .lr { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 11px; }
#legend-nodes .dot { width: 9px; height: 9px; border-radius: 50%; box-shadow: 0 0 6px currentColor; flex: 0 0 auto; }
#legend-nodes .muted { color: var(--text-mute); }
</style>
<!-- three.js r128 + addons (same CDN set as examples/three.js/demos/05) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
</head>
<body>
<div id="banner" class="noserver">NO SERVER — start the sensing-server <span class="src"></span></div>
<div id="banner-caption">Real WiFi CSI motion / presence / coarse-localization — penetrates drywall. Not skeletal pose.</div>
<div class="overlay-frame"></div>
<div class="scanlines"></div>
<div class="panel" id="info">
<h1>THROUGH-WALL WiFi SENSING</h1>
<div class="sub">Live CSI · ws://localhost:8765/ws/sensing</div>
<div class="row"><span class="k">source</span><span class="v amber" id="m-source"></span></div>
<div class="row"><span class="k">presence</span><span class="v" id="m-presence"></span></div>
<div class="row"><span class="k">motion level</span><span class="v" id="m-motion"></span></div>
<div class="row"><span class="k">confidence</span><span class="v cyan" id="m-conf"></span></div>
<div class="row"><span class="k">est. persons</span><span class="v amber" id="m-persons"></span></div>
<div class="row"><span class="k">active nodes</span><span class="v" id="m-nodes"></span></div>
<div class="row"><span class="k">tick</span><span class="v" id="m-tick"></span></div>
<div class="row"><span class="k">update rate</span><span class="v cyan" id="m-fps"></span></div>
</div>
<div class="panel" id="csi">
<h2>Live RF features</h2>
<div class="bar-row"><span class="label">motion</span><div class="bar-track"><div class="bar-fill" id="bar-motion"></div></div><span class="val" id="v-motion"></span></div>
<div class="bar-row"><span class="label">breathing</span><div class="bar-track"><div class="bar-fill" id="bar-breath"></div></div><span class="val" id="v-breath"></span></div>
<div class="bar-row"><span class="label">variance</span><div class="bar-track"><div class="bar-fill" id="bar-var"></div></div><span class="val" id="v-var"></span></div>
<div class="bar-row"><span class="label">mean rssi</span><div class="bar-track"><div class="bar-fill" id="bar-rssi"></div></div><span class="val" id="v-rssi"></span></div>
<div class="spark"><canvas id="spark" width="252" height="38"></canvas></div>
<div class="legend">motion sparkline (last ~6s of real motion_band_power)</div>
</div>
<div id="legend-nodes">
<h2>Sensor nodes</h2>
<div class="lr"><span class="dot" style="color:#4cf"></span><span>ESP32-S3 office <span class="muted">(node 9)</span></span></div>
<div class="lr"><span class="dot" style="color:#ff4cc8"></span><span>ESP32-S3 hallway <span class="muted">(node 13)</span></span></div>
<div class="lr" style="margin-top:6px"><span class="dot" style="color:#4f4"></span><span>RF localization <span class="muted">(coarse)</span></span></div>
<div class="lr"><span class="muted" style="font-size:10px;line-height:1.4">Office &amp; hallway split by a wall + doorway. WiFi motion still shows through drywall.</span></div>
</div>
<div id="cam-tile">
<h2>camera — ground truth when visible</h2>
<video id="cam-video" autoplay muted playsinline></video>
<button id="cam-btn">▶ enable webcam (optional)</button>
<div class="gt-note">Independent of the CSI sensing. The WiFi works in the dark and through walls; the camera does not.</div>
</div>
<div id="waiting" class="show">
<div class="big pulse">Waiting for live sensing-server</div>
<div>No connection to <b>ws://localhost:8765/ws/sensing</b>. Start the real server, then this page connects automatically.</div>
<code>cd v2
cargo build -p wifi-densepose-sensing-server
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005</code>
<div style="margin-top:10px; color:var(--text-mute); font-size:11px;">This demo renders ONLY real data. It never invents frames.</div>
</div>
<script>
"use strict";
// =====================================================================
// Config + WS endpoint (allow ?ws= override)
// =====================================================================
const params = new URLSearchParams(location.search);
const WS_URL = params.get('ws') || 'ws://localhost:8765/ws/sensing';
const ROOM_HALF = 5; // half-extent of the floor plane in metres
const GRID_N = 20; // signal_field is 20 x 20
// Known node anchor positions (server sends node 9 @ [2,0,1.5]; node 13
// joins later from the hallway side once its firmware is flashed). These
// are anchors for the room model + labels, NOT fabricated sensing data.
const NODE_ANCHORS = {
9: { pos: [ 2.0, 0.0, 1.5], color: 0x44ccff, label: 'office (node 9)' },
13: { pos: [-2.0, 0.0, -3.0], color: 0xff4cc8, label: 'hallway (node 13)' },
};
// =====================================================================
// Three.js scene (reused pattern from demos/05-skinned-realtime.html)
// =====================================================================
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050507);
scene.fog = new THREE.FogExp2(0x050507, 0.045);
const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.05, 100);
camera.position.set(4.5, 4.2, 6.0);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.85;
renderer.outputEncoding = THREE.sRGBEncoding;
document.body.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.4, -0.5);
controls.enableDamping = true; controls.dampingFactor = 0.06;
controls.minDistance = 3; controls.maxDistance = 18;
controls.maxPolarAngle = Math.PI * 0.49;
scene.add(new THREE.HemisphereLight(0x553a18, 0x080606, 0.7));
const keyLight = new THREE.DirectionalLight(0xffc070, 0.9);
keyLight.position.set(3, 6, 4);
scene.add(keyLight);
// Post-processing — gentle bloom so the heatmap + puck glow.
const composer = new THREE.EffectComposer(renderer);
composer.addPass(new THREE.RenderPass(scene, camera));
const bloom = new THREE.UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight), 0.55, 0.45, 0.82);
composer.addPass(bloom);
// =====================================================================
// Room: floor grid + wall + doorway dividing office / hallway
// =====================================================================
const gridHelper = new THREE.GridHelper(2*ROOM_HALF, GRID_N, 0x554a32, 0x2a2418);
gridHelper.position.y = 0.002;
scene.add(gridHelper);
// Dividing wall runs along world X near z = -1 (office z>-1, hallway z<-1),
// with a doorway gap. Two wall segments leave a gap in the middle.
const wallMat = new THREE.MeshStandardMaterial({
color: 0x1b2330, transparent: true, opacity: 0.55, roughness: 0.9,
side: THREE.DoubleSide,
});
const wallH = 1.4, wallZ = -1.0;
function addWallSeg(cx, w) {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, 0.08), wallMat);
m.position.set(cx, wallH/2, wallZ);
scene.add(m);
// top edge highlight
const edge = new THREE.Mesh(new THREE.BoxGeometry(w, 0.02, 0.10),
new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0.5 }));
edge.position.set(cx, wallH, wallZ);
scene.add(edge);
}
// left segment, doorway gap (-0.7..0.7), right segment
addWallSeg(-3.15, 3.7);
addWallSeg( 3.15, 3.7);
// Room labels (sprite text) for OFFICE / HALLWAY
function makeLabel(text, color) {
const c = document.createElement('canvas'); c.width = 256; c.height = 64;
const ctx = c.getContext('2d');
ctx.fillStyle = color; ctx.font = 'bold 30px Consolas, monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, 128, 34);
const tex = new THREE.CanvasTexture(c);
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
spr.scale.set(2.0, 0.5, 1);
return spr;
}
const officeLbl = makeLabel('OFFICE', '#ffb840'); officeLbl.position.set(2.6, 0.06, 2.6); scene.add(officeLbl);
const hallLbl = makeLabel('HALLWAY', '#ff4cc8'); hallLbl.position.set(-2.6, 0.06, -3.2); scene.add(hallLbl);
// =====================================================================
// Node markers (office / hallway). The hallway node is dimmed until it
// actually appears in the live `nodes` list.
// =====================================================================
const nodeMeshes = {};
function buildNode(id) {
const a = NODE_ANCHORS[id];
const g = new THREE.Group();
const post = new THREE.Mesh(
new THREE.CylinderGeometry(0.05, 0.07, 0.9, 12),
new THREE.MeshStandardMaterial({ color: a.color, emissive: a.color, emissiveIntensity: 0.4, roughness: 0.4 }));
post.position.y = 0.45; g.add(post);
const orb = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 20, 16),
new THREE.MeshBasicMaterial({ color: a.color }));
orb.position.y = 0.95; g.add(orb);
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.18, 0.24, 32),
new THREE.MeshBasicMaterial({ color: a.color, transparent: true, opacity: 0.6, side: THREE.DoubleSide }));
ring.rotation.x = -Math.PI/2; ring.position.y = 0.01; g.add(ring);
const lbl = makeLabel('ESP32-S3 ' + a.label, '#' + a.color.toString(16).padStart(6,'0'));
lbl.scale.set(2.6, 0.65, 1); lbl.position.set(0, 1.25, 0); g.add(lbl);
g.position.set(a.pos[0], 0, a.pos[2]);
g.userData.parts = { post, orb, ring };
scene.add(g);
return g;
}
Object.keys(NODE_ANCHORS).forEach(id => { nodeMeshes[id] = buildNode(+id); });
function setNodeActive(id, active) {
const g = nodeMeshes[id]; if (!g) return;
const o = active ? 1.0 : 0.22;
const parts = g.userData.parts;
parts.orb.material.opacity = o; parts.orb.material.transparent = true;
parts.ring.material.opacity = 0.6 * o;
parts.post.material.emissiveIntensity = active ? 0.5 : 0.12;
}
setNodeActive(9, false); setNodeActive(13, false);
// =====================================================================
// signal_field 20x20 floor heatmap — instanced colored tiles.
// Driven ONLY by real `signal_field.values` (400 floats ~0..1).
// =====================================================================
const TILE = (2*ROOM_HALF) / GRID_N;
const heatGeo = new THREE.PlaneGeometry(TILE * 0.96, TILE * 0.96);
const heatMat = new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.85, side: THREE.DoubleSide });
const heatMesh = new THREE.InstancedMesh(heatGeo, heatMat, GRID_N * GRID_N);
heatMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
const heatColor = new THREE.InstancedBufferAttribute(new Float32Array(GRID_N * GRID_N * 3), 3);
heatMesh.instanceColor = heatColor;
const _m = new THREE.Matrix4();
const _q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0), -Math.PI/2);
const _s = new THREE.Vector3(1,1,1);
const _p = new THREE.Vector3();
// gridCell (gx,gz) -> world (x,z). gx,gz in [0,GRID_N).
function cellToWorld(gx, gz) {
return [ (gx + 0.5) * TILE - ROOM_HALF, (gz + 0.5) * TILE - ROOM_HALF ];
}
for (let gz = 0; gz < GRID_N; gz++) {
for (let gx = 0; gx < GRID_N; gx++) {
const i = gz * GRID_N + gx;
const [wx, wz] = cellToWorld(gx, gz);
_p.set(wx, 0.012, wz);
_m.compose(_p, _q, _s);
heatMesh.setMatrixAt(i, _m);
heatColor.setXYZ(i, 0.02, 0.02, 0.03);
}
}
heatMesh.instanceMatrix.needsUpdate = true;
scene.add(heatMesh);
// amber→white heat ramp for a value in [0,1]
function heatRamp(v, out) {
v = Math.max(0, Math.min(1, v));
// dark -> amber -> hot white
const r = Math.min(1, 0.05 + 1.6 * v);
const g = Math.min(1, 0.02 + 1.1 * v * v);
const b = Math.min(1, 0.04 + 0.9 * Math.pow(v, 3));
out.set(r, g, b);
return out;
}
const _c = new THREE.Color();
let lastFieldPeak = { gx: GRID_N/2|0, gz: GRID_N/2|0, v: 0 };
function updateHeatmap(field) {
if (!field || !Array.isArray(field.values)) return;
const vals = field.values;
// grid_size is [20,1,20]; values are row-major 400 floats.
let peakV = -1, peakGx = lastFieldPeak.gx, peakGz = lastFieldPeak.gz;
const n = Math.min(vals.length, GRID_N * GRID_N);
for (let i = 0; i < n; i++) {
const v = vals[i];
heatRamp(v, _c);
heatColor.setXYZ(i, _c.r, _c.g, _c.b);
if (v > peakV) { peakV = v; peakGx = i % GRID_N; peakGz = (i / GRID_N) | 0; }
}
heatColor.needsUpdate = true;
lastFieldPeak = { gx: peakGx, gz: peakGz, v: peakV };
}
// =====================================================================
// RF-localization puck — from persons[0].position (coarse, NOT pose).
// Falls back to the signal_field peak cell when no person is present.
// =====================================================================
const puck = new THREE.Group();
const puckCore = new THREE.Mesh(
new THREE.SphereGeometry(0.16, 24, 18),
new THREE.MeshBasicMaterial({ color: 0x66ff88 }));
puckCore.position.y = 0.16; puck.add(puckCore);
const puckRing = new THREE.Mesh(
new THREE.RingGeometry(0.28, 0.36, 40),
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.7, side: THREE.DoubleSide }));
puckRing.rotation.x = -Math.PI/2; puckRing.position.y = 0.02; puck.add(puckRing);
const puckBeam = new THREE.Mesh(
new THREE.CylinderGeometry(0.03, 0.03, 1.2, 8),
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.35 }));
puckBeam.position.y = 0.6; puck.add(puckBeam);
puck.visible = false;
scene.add(puck);
const puckTarget = new THREE.Vector3(0, 0, 0);
function updatePuck(frame) {
let wx = null, wz = null, present = false;
const persons = frame.persons || [];
if (persons.length && Array.isArray(persons[0].position)) {
// server position is [x, 0, z] in metres, origin at room centre
wx = persons[0].position[0];
wz = persons[0].position[2];
present = true;
}
// If no person but the field has clear energy, show the peak cell
// (coarse) so the puck honestly tracks "where the RF energy is".
if (!present && lastFieldPeak.v > 0.55) {
const peak = cellToWorld(lastFieldPeak.gx, lastFieldPeak.gz);
wx = peak[0]; wz = peak[1]; present = true;
}
if (present && wx !== null) {
// clamp into the room so it never flies off the floor
wx = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wx));
wz = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wz));
puckTarget.set(wx, 0, wz);
puck.visible = true;
} else {
puck.visible = false;
}
}
// =====================================================================
// HUD updates
// =====================================================================
const $ = id => document.getElementById(id);
function clamp01(x){ return Math.max(0, Math.min(1, x)); }
function setBar(barId, valId, frac, text) {
$(barId).style.width = (clamp01(frac) * 100).toFixed(0) + '%';
$(valId).textContent = text;
}
// motion sparkline ring buffer
const sparkCtx = $('spark').getContext('2d');
const SPARK_N = 120;
const sparkBuf = new Array(SPARK_N).fill(0);
function pushSpark(v) {
sparkBuf.push(v); if (sparkBuf.length > SPARK_N) sparkBuf.shift();
const w = sparkCtx.canvas.width, h = sparkCtx.canvas.height;
sparkCtx.clearRect(0,0,w,h);
let maxV = 40; for (const x of sparkBuf) if (x > maxV) maxV = x;
sparkCtx.strokeStyle = '#ffb840'; sparkCtx.lineWidth = 1.5; sparkCtx.beginPath();
for (let i = 0; i < sparkBuf.length; i++) {
const x = (i / (SPARK_N-1)) * w;
const y = h - (sparkBuf[i] / maxV) * (h - 3) - 1.5;
i === 0 ? sparkCtx.moveTo(x, y) : sparkCtx.lineTo(x, y);
}
sparkCtx.stroke();
}
// =====================================================================
// Honest status banner (strict, mutually exclusive)
// =====================================================================
const banner = $('banner');
function setBannerLive(source, nodeCount) {
if (source === 'esp32') {
banner.className = 'live';
banner.innerHTML = 'LIVE — real ESP32 CSI <span class="src">(source=' + source + ', ' + nodeCount + ' node' + (nodeCount === 1 ? '' : 's') + ')</span>';
} else {
// anything not esp32 = explicitly NOT real, badged
banner.className = 'sim';
banner.innerHTML = 'SIMULATED — not real <span class="src">(source=' + source + ' — start an ESP32 for live CSI)</span>';
}
}
function setBannerNoServer() {
banner.className = 'noserver';
banner.innerHTML = 'NO SERVER — start the sensing-server <span class="src">(ws://localhost:8765/ws/sensing unreachable)</span>';
}
// =====================================================================
// WebSocket — render ONLY real frames. Reconnect; never fabricate.
// =====================================================================
let ws = null, gotFrame = false;
let frameTimes = []; // for measured update rate (fps)
let lastFrame = null; // most recent real frame (render loop interpolates puck)
function connect() {
setBannerNoServer();
try { ws = new WebSocket(WS_URL); }
catch (e) { scheduleReconnect(); return; }
ws.onopen = () => { /* wait for first frame before claiming LIVE */ };
ws.onmessage = (ev) => {
let d; try { d = JSON.parse(ev.data); } catch (e) { return; }
if (!d || d.type !== 'sensing_update') return;
onFrame(d);
};
ws.onclose = () => { gotFrame = false; $('waiting').classList.add('show'); setBannerNoServer(); scheduleReconnect(); };
ws.onerror = () => { try { ws.close(); } catch (e) {} };
}
let reconnectT = null;
function scheduleReconnect() {
if (reconnectT) return;
reconnectT = setTimeout(() => { reconnectT = null; connect(); }, 1500);
}
function onFrame(d) {
gotFrame = true;
lastFrame = d;
$('waiting').classList.remove('show');
const source = d.source || 'unknown';
const nodes = Array.isArray(d.nodes) ? d.nodes : [];
setBannerLive(source, nodes.length);
// measured update rate
const now = performance.now();
frameTimes.push(now);
while (frameTimes.length && now - frameTimes[0] > 2000) frameTimes.shift();
const fps = frameTimes.length > 1 ? (frameTimes.length - 1) / ((frameTimes[frameTimes.length-1] - frameTimes[0]) / 1000) : 0;
const cls = d.classification || {};
const feat = d.features || {};
// info panel
$('m-source').textContent = source.toUpperCase();
$('m-source').className = 'v ' + (source === 'esp32' ? 'green' : 'red');
const presence = !!cls.presence;
$('m-presence').textContent = presence ? (cls.motion_level === 'present_moving' ? 'PRESENT · MOVING' : 'PRESENT') : 'CLEAR';
$('m-presence').className = 'v ' + (presence ? 'green' : 'mute');
$('m-motion').textContent = cls.motion_level || '—';
$('m-conf').textContent = (cls.confidence != null) ? cls.confidence.toFixed(2) : '—';
$('m-persons').textContent = (d.estimated_persons != null) ? d.estimated_persons : '—';
$('m-nodes').textContent = nodes.length + ' (' + nodes.map(n => n.node_id).join(', ') + ')';
$('m-tick').textContent = (d.tick != null) ? d.tick : '—';
$('m-fps').textContent = fps ? fps.toFixed(1) + ' Hz' : '—';
// feature bars (real values, scaled into 0..1 for the bar width only)
const motion = feat.motion_band_power || 0;
const breath = feat.breathing_band_power || 0;
const variance = feat.variance || 0;
const rssi = feat.mean_rssi != null ? feat.mean_rssi : -100;
setBar('bar-motion', 'v-motion', motion / 100, motion.toFixed(1));
setBar('bar-breath', 'v-breath', breath / 100, breath.toFixed(1));
setBar('bar-var', 'v-var', variance / 80, variance.toFixed(1));
// rssi: map -90..-30 dBm -> 0..1
setBar('bar-rssi', 'v-rssi', (rssi + 90) / 60, rssi.toFixed(0));
pushSpark(motion);
// node activity
const activeIds = new Set(nodes.map(n => n.node_id));
[9, 13].forEach(id => setNodeActive(id, activeIds.has(id)));
// heatmap + puck
updateHeatmap(d.signal_field);
updatePuck(d);
}
// =====================================================================
// Optional webcam ground-truth tile (reused from demos/05). Camera is
// separate from CSI sensing — labeled "ground truth when visible".
// =====================================================================
let camStream = null;
$('cam-btn').addEventListener('click', async () => {
const btn = $('cam-btn');
if (camStream) { // toggle off
camStream.getTracks().forEach(t => t.stop());
$('cam-video').style.display = 'none'; camStream = null;
btn.textContent = '▶ enable webcam (optional)';
return;
}
btn.disabled = true; btn.textContent = 'requesting camera…';
try {
camStream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' }, audio: false,
});
const v = $('cam-video'); v.srcObject = camStream; v.style.display = 'block';
btn.textContent = '■ stop webcam'; btn.disabled = false;
} catch (e) {
btn.textContent = '✗ camera unavailable'; btn.disabled = false; console.error(e);
setTimeout(() => { if (!camStream) btn.textContent = '▶ enable webcam (optional)'; }, 2000);
}
});
// =====================================================================
// Render loop — smooth the puck toward its real target; pulse rings.
// =====================================================================
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
controls.update();
if (puck.visible) {
puck.position.lerp(puckTarget, 0.18);
const pulse = 0.8 + 0.25 * Math.sin(t * 3.0);
puckRing.scale.set(pulse, pulse, pulse);
puckRing.material.opacity = 0.5 + 0.25 * Math.sin(t * 3.0);
}
// node rings breathe when active
[9,13].forEach(id => {
const g = nodeMeshes[id]; if (!g) return;
const r = g.userData.parts.ring;
const s = 1 + 0.08 * Math.sin(t * 2 + id);
r.scale.set(s, s, s);
});
composer.render();
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
// kick off
connect();
</script>
</body>
</html>
+159
View File
@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WiFlow · live WiFi-inferred pose</title>
<style>
:root{--bg:#0a0c10;--panel:#11151c;--amber:#ffb840;--green:#46e08a;--red:#ff5a5a;--mute:#7d8796;--line:#1d2430}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:#dfe6ee;font:14px/1.5 'JetBrains Mono',ui-monospace,Menlo,monospace}
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
h1{font-size:15px;margin:0;letter-spacing:1px;text-transform:uppercase;font-weight:600}
h1 span{color:var(--amber)}
#banner{margin-left:auto;padding:5px 12px;border-radius:5px;font-weight:600;font-size:12px;letter-spacing:.5px}
.live{background:rgba(70,224,138,.15);color:var(--green);border:1px solid var(--green)}
.sim{background:rgba(255,184,64,.15);color:var(--amber);border:1px solid var(--amber)}
.down{background:rgba(255,90,90,.15);color:var(--red);border:1px solid var(--red)}
main{display:flex;gap:18px;padding:18px;flex-wrap:wrap}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
canvas{background:#070a0e;border-radius:8px;display:block}
.label{font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--mute);margin-bottom:8px}
.stats{min-width:240px}
.row{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed var(--line)}
.row .k{color:var(--mute)} .row .v{color:var(--amber);font-variant-numeric:tabular-nums}
.v.green{color:var(--green)}
.note{margin-top:12px;font-size:11px;color:var(--mute);line-height:1.6;max-width:300px}
.note b{color:#dfe6ee}
</style>
</head>
<body>
<header>
<h1>WiFlow · <span>live WiFi-inferred pose</span></h1>
<div id="banner" class="down">CONNECTING…</div>
</header>
<main>
<div class="card">
<div class="label">CSI → pose (skeleton) overlaid on your laptop camera</div>
<div id="stage" style="width:420px;height:560px;border-radius:8px;overflow:hidden;background:#070a0e">
<video id="cam" autoplay muted playsinline style="position:absolute;width:2px;height:2px;opacity:0;pointer-events:none"></video>
<canvas id="cv" width="420" height="560"></canvas>
</div>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button id="camBtn" style="background:var(--amber);color:#0a0c10;border:0;border-radius:6px;padding:7px 14px;font:inherit;font-weight:600;cursor:pointer">enable laptop camera</button>
<select id="camSel" style="display:none;background:var(--panel);color:#dfe6ee;border:1px solid var(--line);border-radius:6px;padding:6px;font:inherit;max-width:220px"></select>
</div>
<div id="camStatus" style="margin-top:6px;font-size:11px;color:var(--mute)">camera: off</div>
<div class="note" style="margin-top:8px">Camera is a <b>visual reference only</b> — it is NOT fed to the model. Overlay alignment is approximate (model trained in a different camera's frame).</div>
</div>
<div class="card stats">
<div class="label">live</div>
<div class="row"><span class="k">CSI source</span><span class="v" id="src"></span></div>
<div class="row"><span class="k">nodes</span><span class="v" id="nodes"></span></div>
<div class="row"><span class="k">presence</span><span class="v" id="pres"></span></div>
<div class="row"><span class="k">motion</span><span class="v" id="motion"></span></div>
<div class="row"><span class="k">pose fps</span><span class="v" id="fps"></span></div>
<div class="note">
This skeleton is inferred <b>from WiFi CSI only</b> — no camera in the loop here. A model was
trained on paired (camera-pose, CSI) data in this room (ADR-079/180).
<br/><br/>
<b>Honest accuracy:</b> ~<b>59.5% PCK@0.10</b> on held-out data (vs a 50% mean-pose baseline →
<b>+9.4 pp real signal</b>). It captures <b>coarse</b> pose; fine detail is weak (PCK@0.05 ≈ 24%).
Same person / room / session — not validated cross-day or through-wall.
</div>
</div>
</main>
<script>
const POSE_WS = (new URLSearchParams(location.search)).get('ws') || `ws://${location.hostname||'localhost'}:8770/pose`;
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const $ = id => document.getElementById(id);
let edges = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]];
let last = null, frames = 0, t0 = performance.now();
function banner(state, txt){ const b=$('banner'); b.className=state; b.textContent=txt; }
// per-joint smoothing (EMA) so dropped/jittery CSI frames render fluidly (ADR-180 dead-reckoning, lite)
let sm = null;
function smooth(kps){
if(!sm){ sm = kps.map(p=>[p[0],p[1]]); return sm; }
const a=0.35; for(let i=0;i<kps.length;i++){ sm[i][0]+=a*(kps[i][0]-sm[i][0]); sm[i][1]+=a*(kps[i][1]-sm[i][1]); }
return sm;
}
const camEl=document.getElementById('cam');
function draw(p){
const W=cv.width, H=cv.height;
// paint the live camera frame onto the canvas (robust — no z-index/overlay tricks)
if(camEl && camEl.videoWidth>0){
ctx.save(); ctx.globalAlpha=0.9;
// cover-fit the camera frame into the canvas
const vr=camEl.videoWidth/camEl.videoHeight, cr=W/H;
let dw=W, dh=H, dx=0, dy=0;
if(vr>cr){ dh=H; dw=H*vr; dx=(W-dw)/2; } else { dw=W; dh=W/vr; dy=(H-dh)/2; }
ctx.drawImage(camEl, dx, dy, dw, dh); ctx.restore();
} else {
ctx.fillStyle='#070a0e'; ctx.fillRect(0,0,W,H);
}
if(!p || !p.kps){ return; }
const s = smooth(p.kps);
const k = s.map(([x,y])=>[x*W, y*H]);
ctx.lineWidth=5; ctx.strokeStyle=p.presence?'rgba(70,224,138,.95)':'rgba(125,135,150,.8)'; ctx.lineCap='round';
ctx.shadowColor='rgba(70,224,138,.6)'; ctx.shadowBlur=8;
for(const [a,b] of edges){ ctx.beginPath(); ctx.moveTo(k[a][0],k[a][1]); ctx.lineTo(k[b][0],k[b][1]); ctx.stroke(); }
ctx.shadowBlur=0;
for(const [x,y] of k){ ctx.beginPath(); ctx.arc(x,y,5,0,7); ctx.fillStyle=p.presence?'#ffb840':'#667'; ctx.fill(); }
}
// ---- laptop webcam (visual reference only; NOT fed to the model) ----
let camStream=null;
async function startCam(deviceId){
if(camStream){ camStream.getTracks().forEach(t=>t.stop()); }
const constraints = deviceId ? {video:{deviceId:{exact:deviceId}}} : {video:true};
const st=document.getElementById('camStatus');
try{
st.textContent='camera: requesting…';
camStream = await navigator.mediaDevices.getUserMedia(constraints);
const v=document.getElementById('cam'); v.muted=true; v.srcObject=camStream;
v.onloadedmetadata=()=>{ v.play().catch(err=>st.textContent='camera: play() blocked '+err.name); };
await v.play().catch(()=>{});
const tr=camStream.getVideoTracks()[0]; const ss=tr.getSettings();
// live readout: shows if real frames are flowing (videoWidth>0) and which device
const tick=()=>{ st.textContent = `camera: "${tr.label}" ${v.videoWidth}x${v.videoHeight} ${tr.readyState} ${v.paused?'PAUSED':'playing'}`; };
tick(); setInterval(tick, 1000);
document.getElementById('camBtn').textContent='switch camera ↻';
// populate the picker now that we have permission (labels need permission)
const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d=>d.kind==='videoinput');
const sel=document.getElementById('camSel'); sel.style.display = devs.length>1?'inline-block':'none';
sel.innerHTML = devs.map((d,i)=>`<option value="${d.deviceId}">${d.label||('camera '+(i+1))}</option>`).join('');
const cur = camStream.getVideoTracks()[0].getSettings().deviceId; if(cur) sel.value=cur;
}catch(e){
document.getElementById('camBtn').textContent = 'camera error: '+e.name+(e.name==='NotReadableError'?' (in use by Zoom/Teams?)':'');
console.error('getUserMedia', e);
}
}
document.getElementById('camBtn').addEventListener('click', ()=>startCam());
document.getElementById('camSel').addEventListener('change', e=>startCam(e.target.value));
function connect(){
banner('down','CONNECTING…');
const ws = new WebSocket(POSE_WS);
ws.onopen = ()=> banner('sim','WAITING FOR POSE…');
ws.onmessage = ev => {
const d = JSON.parse(ev.data);
if(d.type==='meta'){ edges = d.edges; return; }
if(d.type!=='pose') return;
last=d; frames++;
if(d.src==='esp32') banner('live','LIVE — WiFi-inferred pose (real ESP32 CSI)');
else banner('sim','SIMULATED CSI — not real ('+d.src+')');
$('src').textContent=d.src; $('src').className = d.src==='esp32'?'v green':'v';
$('nodes').textContent=(d.nodes||[]).join(', ')||'—';
$('pres').textContent=d.presence?'PRESENT':'—';
$('motion').textContent=(d.motion!=null?Math.round(d.motion):'—');
};
ws.onclose = ()=>{ banner('down','NO BRIDGE — start wiflow_infer.py'); setTimeout(connect,1500); };
ws.onerror = ()=> ws.close();
}
function loop(){ draw(last); const now=performance.now(); if(now-t0>1000){ $('fps').textContent=frames; frames=0; t0=now; } requestAnimationFrame(loop); }
connect(); loop();
</script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
"""Tiny threaded static server for the through-wall WiFi-CSI sensing demo.
Adapted from examples/three.js/server/serve-demo.py. Serves the
`examples/through-wall/` page so a browser can fetch index.html, then the
page connects directly to the LIVE sensing-server WebSocket at
ws://localhost:8765/ws/sensing (NOT proxied through here).
Why a threaded server (not `python -m http.server`)?
The stdlib SimpleHTTPServer is single-threaded; a browser opens several
parallel connections (HTML + the three.js CDN tags fetch in parallel),
the first eats the worker, the rest can stall. ThreadingHTTPServer fixes it.
IMPORTANT: this serves on port 8080 port 8765 is taken by the
sensing-server's WebSocket. They are two different processes.
Usage:
# 1) start the REAL sensing-server (separate terminal):
# cd v2
# cargo build -p wifi-densepose-sensing-server
# ./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005
# 2) start this static server:
python examples/through-wall/serve.py
# 3) open:
# http://localhost:8080/examples/through-wall/index.html
Override the WS endpoint with a query param, e.g.:
http://localhost:8080/examples/through-wall/index.html?ws=ws://192.168.1.20:8765/ws/sensing
"""
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
import os
import sys
PORT = int(os.environ.get("PORT", 8080))
# Serve from the repo root regardless of where this script is launched.
# This file lives at examples/through-wall/serve.py — two levels deep.
os.chdir(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
class NoCacheHandler(SimpleHTTPRequestHandler):
def end_headers(self):
# Aggressive no-cache so the browser ALWAYS fetches the latest
# index.html after edits, even on a soft refresh.
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def log_message(self, fmt, *args): # quieter logs
sys.stderr.write("[serve] " + (fmt % args) + "\n")
PAGE = "examples/through-wall/index.html"
with ThreadingHTTPServer(("127.0.0.1", PORT), NoCacheHandler) as srv:
print(f"serving {os.getcwd()} on http://127.0.0.1:{PORT}/")
print(f" open http://localhost:{PORT}/{PAGE}")
print("")
print(" The page connects to the LIVE sensing-server at")
print(" ws://localhost:8765/ws/sensing (start it first — see README.md).")
print(" Override with ?ws=ws://HOST:PORT/ws/sensing")
try:
srv.serve_forever()
except KeyboardInterrupt:
sys.exit(0)
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Rigorous A/B for WiFlow CSI->pose: is the held-out PCK real signal or split leakage?
For a dataset of {csi:[D], kps:17x[x,y,vis]} pairs, train the SAME small MLP under
several train/val SPLITS and report held-out PCK@0.10 vs the mean-pose baseline:
- chronological_80_20 : last 20% in time (val temporally ADJACENT to train -> leaks
via CSI/pose autocorrelation; this is what gave us +9.4)
- random_80_20 : shuffled (val frames interleaved with train -> MAX leak)
- blocked_gap : hold out a contiguous MIDDLE block with a time GAP buffer on
each side so val is NOT adjacent to any train frame -> the
honest, leakage-controlled test
If the model beats baseline on chronological/random but COLLAPSES to ~baseline on
blocked_gap, the apparent signal was temporal leakage, not generalizable CSI->pose.
Usage (ruvultra venv): python wiflow_ab.py --data ~/wiflow-room/dataset.jsonl
"""
import argparse, json, sys
import numpy as np, torch, torch.nn as nn
def _rec(r, X, Y, V, B):
X.append(r["csi"]); kp=r["kps"]
if kp and isinstance(kp[0], (list,tuple)): # 17 x [x,y(,vis)]
Y.append([c for k in kp for c in (k[0],k[1])]); V.append([(k[2] if len(k)>2 else 1.0) for k in kp])
else: # flat 34 (browser export, no vis)
Y.append(list(kp)); V.append([1.0]*17)
B.append(r.get("bucket"))
def load(path):
X,Y,V,B=[],[],[],[]
txt=open(path).read().strip()
if txt[:1] in "[{": # JSON (browser export: dict{samples:[]} or bare array)
d=json.loads(txt)
rows = d if isinstance(d,list) else d.get("samples", d.get("data", []))
for r in rows: _rec(r,X,Y,V,B)
else: # JSONL (python capture)
for line in txt.splitlines():
if line.strip(): _rec(json.loads(line),X,Y,V,B)
return np.array(X,np.float32), np.array(Y,np.float32), np.array(V,np.float32), B
class Net(nn.Module):
def __init__(s,din,dout):
super().__init__()
s.n=nn.Sequential(nn.Linear(din,384),nn.ReLU(),nn.Dropout(.35),
nn.Linear(384,192),nn.ReLU(),nn.Dropout(.35),
nn.Linear(192,96),nn.ReLU(),nn.Linear(96,dout),nn.Sigmoid())
def forward(s,x): return s.n(x)
def pck(pred,gt,vis,thr=0.10):
p=pred.reshape(-1,17,2); g=gt.reshape(-1,17,2)
d=np.linalg.norm(p-g,axis=2); m=vis>0.5
return float((d[m]<thr).mean()) if m.any() else 0.0
def split_idx(n, kind, B=None):
idx=np.arange(n)
if kind=="chronological_80_20":
c=int(n*.8); return idx[:c], idx[c:]
if kind=="random_80_20":
rng=np.random.default_rng(0); p=rng.permutation(n); c=int(n*.8); return p[:c], p[c:]
if kind=="blocked_gap":
# val = contiguous middle 20%; a WIDE 10% time gap each side guarantees no train
# frame is temporally adjacent to a val frame (kills frame-autocorrelation leakage).
v0=int(n*.4); v1=int(n*.6); gap=int(n*.10)
val=idx[v0:v1]; train=np.concatenate([idx[:max(0,v0-gap)], idx[min(n,v1+gap):]])
return train, val
if kind=="grouped_bucket":
# hold out ENTIRE activity buckets -> val poses/activities never seen in train.
# the strictest leakage-free test (only when bucket labels exist).
b=np.array([x if x is not None else -1 for x in B])
uniq=[u for u in sorted(set(b.tolist())) if u!=-1]
if len(uniq)<3: raise ValueError("too few buckets")
hold=set(uniq[::max(1,len(uniq)//3)][:max(1,len(uniq)//3)]) # ~1/3 of activities held out
val=idx[np.isin(b,list(hold))]; train=idx[~np.isin(b,list(hold))]
return train, val
raise ValueError(kind)
def run(X,Y,V,tr,va,epochs=250,seed=0):
torch.manual_seed(seed); np.random.seed(seed) # seed weight init + batch shuffle
dev="cuda" if torch.cuda.is_available() else "cpu"
mu,sd=X[tr].mean(0),X[tr].std(0)+1e-6
Xtr=torch.tensor((X[tr]-mu)/sd).to(dev); Ytr=torch.tensor(Y[tr]).to(dev)
Xva=torch.tensor((X[va]-mu)/sd).to(dev)
net=Net(X.shape[1],Y.shape[1]).to(dev)
opt=torch.optim.Adam(net.parameters(),lr=1e-3,weight_decay=1e-4); lf=nn.MSELoss()
best=(1e9,None)
for ep in range(epochs):
net.train(); perm=torch.randperm(len(Xtr),device=dev)
for i in range(0,len(Xtr),64):
j=perm[i:i+64]; opt.zero_grad(); loss=lf(net(Xtr[j]),Ytr[j]); loss.backward(); opt.step()
net.eval()
with torch.no_grad(): pv=net(Xva).cpu().numpy()
vl=float(((pv-Y[va])**2).mean())
if vl<best[0]: best=(vl,pv)
base=np.tile(Y[tr].mean(0),(len(va),1))
return pck(best[1],Y[va],V[va]), pck(base,Y[va],V[va])
def main():
ap=argparse.ArgumentParser(); ap.add_argument("--data",required=True)
ap.add_argument("--epochs",type=int,default=250); ap.add_argument("--seeds",type=int,default=3)
a=ap.parse_args()
X,Y,V,B=load(a.data); n=len(X)
has_buckets=any(x is not None for x in B)
print(f"[ab] {n} samples, X={X.shape}, buckets={'yes' if has_buckets else 'no'}, "
f"seeds={a.seeds}, epochs={a.epochs}\n")
print(f"{'split':<22}{'model PCK@0.10':>16}{'baseline':>11}{'delta (mean±sd)':>20} verdict")
print("-"*86)
splits=["chronological_80_20","random_80_20","blocked_gap"]+(["grouped_bucket"] if has_buckets else [])
for kind in splits:
try:
tr,va=split_idx(n,kind,B)
ms=[]; bs=[]
for s in range(a.seeds):
m,b=run(X,Y,V,tr,va,a.epochs,seed=s); ms.append(m); bs.append(b)
ms=np.array(ms)*100; bs=np.array(bs)*100; ds=ms-bs
dm,dsd=ds.mean(),ds.std()
# REAL only if the mean delta minus 1 sd still clears the 1.5pp threshold (robust to seed variance)
verdict = "REAL signal" if dm-dsd>1.5 else ("weak/uncertain" if dm>1.5 else "no signal (==baseline)")
print(f"{kind:<22}{ms.mean():>13.1f}±{ms.std():>3.1f}{bs.mean():>10.1f}%{dm:>+12.1f}±{dsd:>4.1f}pp {verdict}")
except Exception as e:
print(f"{kind:<22} skipped: {e}")
print(f"\nmean±sd over {a.seeds} seeds (weight init + batch order). blocked_gap = 10% time gap each")
print("side; grouped_bucket holds out ENTIRE activities (strictest). If only the LEAKY splits")
print("(chronological/random) beat baseline, the apparent signal is leakage, not generalizable pose.")
if __name__=="__main__": main()
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""WiFlow-style camera-supervised capture (ADR-079 / ADR-180).
Runs on a box with BOTH a camera (ground truth) and reachable live CSI:
- opens a camera, runs MediaPipe Pose -> 17 COCO keypoints (the LABEL),
- subscribes to the sensing-server /ws/sensing (the INPUT: CSI features +
20x20 signal-field),
- writes timestamp-aligned (csi -> pose) pairs to a JSONL dataset.
This is the *collect* phase of camera-supervised CSI->pose training. The camera
and the CSI nodes MUST see the same person in the same space at the same time,
or the pairs are meaningless. Honest by construction: we only emit a pair when
BOTH a confident camera pose AND a live (source=esp32) CSI frame are present in
the same ~100 ms window.
Usage (on ruvultra, with the CSI tunneled to localhost:8765):
python3 wiflow_capture.py --ws ws://localhost:8765/ws/sensing \
--cam 0 --out ~/wiflow-room/dataset.jsonl --seconds 180
"""
import argparse, asyncio, json, time, threading, sys, os
from collections import deque
import urllib.request
import cv2
import numpy as np
import mediapipe as mp
from mediapipe.tasks.python import BaseOptions
from mediapipe.tasks.python.vision import PoseLandmarker, PoseLandmarkerOptions, RunningMode
import websockets
_MODEL_URL = ("https://storage.googleapis.com/mediapipe-models/pose_landmarker/"
"pose_landmarker_lite/float16/latest/pose_landmarker_lite.task")
def ensure_model(path: str) -> str:
if not os.path.exists(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
print(f"[capture] downloading pose model -> {path}", flush=True)
urllib.request.urlretrieve(_MODEL_URL, path)
return path
# MediaPipe Pose (33 landmarks) -> 17 COCO keypoints (same mapping as
# scripts/collect-ground-truth.py, ADR-079).
COCO_FROM_MP = [0, 2, 5, 7, 8, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28]
COCO_NAMES = ["nose","l_eye","r_eye","l_ear","r_ear","l_sho","r_sho","l_elb",
"r_elb","l_wri","r_wri","l_hip","r_hip","l_knee","r_knee","l_ank","r_ank"]
# ---- shared state between the CSI (async) thread and the camera (sync) loop ----
_latest_csi = {"t": 0.0, "frame": None}
_csi_lock = threading.Lock()
_stop = threading.Event()
def csi_thread(ws_url: str):
"""Background thread: keep the most recent LIVE csi frame in _latest_csi."""
async def run():
while not _stop.is_set():
try:
async with websockets.connect(ws_url, open_timeout=8, ping_interval=20) as ws:
while not _stop.is_set():
msg = await asyncio.wait_for(ws.recv(), timeout=8)
d = json.loads(msg)
with _csi_lock:
_latest_csi["t"] = time.time()
_latest_csi["frame"] = d
except Exception as e:
print(f"[csi] reconnect ({e})", flush=True)
await asyncio.sleep(1.0)
asyncio.new_event_loop().run_until_complete(run())
def csi_vector(frame: dict):
"""Flatten a csi frame to a fixed-length input vector: features + field."""
f = frame.get("features", {}) or {}
feats = [f.get("mean_rssi", 0.0), f.get("variance", 0.0),
f.get("motion_band_power", 0.0), f.get("breathing_band_power", 0.0)]
# per-node mean_rssi/variance/motion for up to the 2 nodes (9, 13)
pernode = {nf.get("node_id"): (nf.get("features") or {}) for nf in (frame.get("node_features") or [])}
for nid in (9, 13):
nf = pernode.get(nid, {})
feats += [nf.get("mean_rssi", 0.0), nf.get("variance", 0.0), nf.get("motion_band_power", 0.0)]
field = (frame.get("signal_field", {}) or {}).get("values") or []
field = (field + [0.0] * 400)[:400]
return feats + field # 4 + 6 + 400 = 410-d
def main():
ap = argparse.ArgumentParser(description="WiFlow camera-supervised CSI<->pose capture (ADR-180).")
ap.add_argument("--ws", default="ws://localhost:8765/ws/sensing")
ap.add_argument("--cam", type=int, default=0)
ap.add_argument("--out", default=os.path.expanduser("~/wiflow-room/dataset.jsonl"))
ap.add_argument("--seconds", type=int, default=180)
ap.add_argument("--min-vis", type=float, default=0.5, help="min mean landmark visibility to accept a pose label")
ap.add_argument("--max-skew-ms", type=float, default=150, help="max csi/pose time skew to pair")
ap.add_argument("--require-esp32", action="store_true", default=True,
help="only pair when csi source==esp32 (real). Default on.")
args = ap.parse_args()
os.makedirs(os.path.dirname(args.out), exist_ok=True)
th = threading.Thread(target=csi_thread, args=(args.ws,), daemon=True)
th.start()
cap = cv2.VideoCapture(args.cam)
if not cap.isOpened():
print(f"ERROR: cannot open camera {args.cam}", file=sys.stderr); sys.exit(2)
W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 640
H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 480
model_path = ensure_model(os.path.expanduser("~/wiflow-room/pose_landmarker_lite.task"))
landmarker = PoseLandmarker.create_from_options(PoseLandmarkerOptions(
base_options=BaseOptions(model_asset_path=model_path),
running_mode=RunningMode.IMAGE, min_pose_detection_confidence=0.5))
n_pairs = 0; n_nopose = 0; n_nocsi = 0; n_skew = 0; n_sim = 0
t0 = time.time()
print(f"[capture] camera {args.cam} {W}x{H} -> {args.out} for {args.seconds}s")
print("[capture] stand in view AND in the CSI field; move/walk so poses vary. Ctrl-C to stop.")
with open(args.out, "a") as out:
try:
while time.time() - t0 < args.seconds:
ok, frame = cap.read()
if not ok:
continue
now = time.time()
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
res = landmarker.detect(mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb))
if not res.pose_landmarks:
n_nopose += 1; continue
lm = res.pose_landmarks[0]
kps = [[lm[i].x, lm[i].y, lm[i].visibility] for i in COCO_FROM_MP]
vis = float(np.mean([k[2] for k in kps]))
if vis < args.min_vis:
n_nopose += 1; continue
with _csi_lock:
ct = _latest_csi["t"]; cf = _latest_csi["frame"]
if cf is None:
n_nocsi += 1; continue
if (now - ct) * 1000.0 > args.max_skew_ms:
n_skew += 1; continue
if args.require_esp32 and cf.get("source") != "esp32":
n_sim += 1; continue
rec = {"t": now, "vis": round(vis, 3),
"kps": [[round(x, 4), round(y, 4), round(v, 3)] for x, y, v in kps],
"csi": csi_vector(cf),
"src": cf.get("source"),
"nodes": sorted(n.get("node_id") for n in cf.get("nodes", []) if n.get("node_id") is not None)}
out.write(json.dumps(rec) + "\n")
n_pairs += 1
if n_pairs % 30 == 0:
out.flush()
el = int(now - t0)
print(f"[capture] t+{el:3d}s pairs={n_pairs} (skip: nopose={n_nopose} nocsi={n_nocsi} skew={n_skew} sim={n_sim})", flush=True)
except KeyboardInterrupt:
print("\n[capture] stopped by user")
_stop.set(); cap.release()
print(f"[capture] DONE. wrote {n_pairs} paired samples to {args.out}")
print(f"[capture] skipped: no-pose={n_nopose} no-csi={n_nocsi} skew={n_skew} simulated={n_sim}")
if n_pairs == 0:
print("[capture] WARNING: 0 pairs — check camera sees you AND csi source==esp32 (live).")
if __name__ == "__main__":
main()
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Live CSI->pose inference bridge (ADR-180).
Runs on the box with the live CSI. Loads the camera-supervised model (numpy,
no torch needed), subscribes to /ws/sensing, runs a forward pass per frame, and
broadcasts the predicted 17-keypoint pose to HTML clients on ws://:8770/pose.
python wiflow_infer.py --model model/model.npz \
--in ws://localhost:8765/ws/sensing --port 8770
"""
import argparse, asyncio, json, os
import numpy as np
import websockets
# COCO skeleton edges (for the client; sent once in 'meta')
EDGES = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],
[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]]
def csi_vector(frame):
f = frame.get("features", {}) or {}
feats = [f.get("mean_rssi",0.0), f.get("variance",0.0),
f.get("motion_band_power",0.0), f.get("breathing_band_power",0.0)]
pernode = {nf.get("node_id"): (nf.get("features") or {}) for nf in (frame.get("node_features") or [])}
for nid in (9,13):
nf = pernode.get(nid,{}); feats += [nf.get("mean_rssi",0.0), nf.get("variance",0.0), nf.get("motion_band_power",0.0)]
field = (frame.get("signal_field",{}) or {}).get("values") or []
field = (field + [0.0]*400)[:400]
return np.array(feats + field, np.float32)
class Model:
def __init__(self, path):
z = np.load(path)
self.mu, self.sd = z["mu"], z["sd"]
self.W = [z["net_0_weight"], z["net_3_weight"], z["net_6_weight"], z["net_8_weight"]]
self.b = [z["net_0_bias"], z["net_3_bias"], z["net_6_bias"], z["net_8_bias"]]
def __call__(self, x):
h = (x - self.mu) / self.sd
for i in range(3):
h = np.maximum(0.0, h @ self.W[i].T + self.b[i]) # Linear+ReLU
out = 1.0/(1.0+np.exp(-(h @ self.W[3].T + self.b[3]))) # Linear+Sigmoid -> 34
return out.reshape(17,2)
CLIENTS = set()
LATEST = {"pose": None}
async def serve_client(ws):
CLIENTS.add(ws)
try:
await ws.send(json.dumps({"type":"meta","edges":EDGES}))
async for _ in ws: # client is read-only; just keep alive
pass
except Exception:
pass
finally:
CLIENTS.discard(ws)
async def infer_loop(model, in_url):
while True:
try:
async with websockets.connect(in_url, open_timeout=8, ping_interval=20) as ws:
async for msg in ws:
d = json.loads(msg)
kp = model(csi_vector(d))
cls = d.get("classification",{})
payload = {"type":"pose","src":d.get("source"),
"presence":bool(cls.get("presence")),
"motion":(d.get("features",{}) or {}).get("motion_band_power"),
"kps":[[round(float(x),4),round(float(y),4)] for x,y in kp],
"nodes":sorted(n.get("node_id") for n in d.get("nodes",[]) if n.get("node_id") is not None)}
LATEST["pose"]=payload
if CLIENTS:
dead=[]
for c in list(CLIENTS):
try: await c.send(json.dumps(payload))
except Exception: dead.append(c)
for c in dead: CLIENTS.discard(c)
except Exception as e:
print(f"[infer] reconnect ({e})", flush=True); await asyncio.sleep(1.0)
async def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default=os.path.join(os.path.dirname(__file__),"model","model.npz"))
ap.add_argument("--in", dest="in_url", default="ws://localhost:8765/ws/sensing")
ap.add_argument("--port", type=int, default=8770)
args = ap.parse_args()
model = Model(args.model)
print(f"[infer] model {args.model} loaded; serving predicted poses on ws://0.0.0.0:{args.port}/pose")
async with websockets.serve(serve_client, "0.0.0.0", args.port):
await infer_loop(model, args.in_url)
if __name__ == "__main__":
asyncio.run(main())
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Train a CSI->pose model on the camera-supervised dataset (ADR-079/180).
Input : 410-d CSI vector (4 global feats + 6 per-node + 400 signal-field).
Target : 17 COCO keypoints (x,y), normalized 0..1 from the camera (ground truth).
Reports HONEST held-out PCK@k + MPJPE on a chronological val split (the last
20% of the session never trained on), so the number is not leaked.
Usage (ruvultra venv):
python wiflow_train.py --data ~/wiflow-room/dataset.jsonl --out ~/wiflow-room/model.pt
"""
import argparse, json, math, os, sys
import numpy as np
import torch, torch.nn as nn
def load(path):
X, Y, V = [], [], []
with open(path) as f:
for line in f:
r = json.loads(line)
X.append(r["csi"]) # 410
kp = r["kps"] # 17 x [x,y,vis]
Y.append([c for k in kp for c in (k[0], k[1])]) # 34
V.append([k[2] for k in kp]) # 17 visibilities
return np.array(X, np.float32), np.array(Y, np.float32), np.array(V, np.float32)
class Net(nn.Module):
def __init__(self, din, dout):
super().__init__()
self.net = nn.Sequential(
nn.Linear(din, 512), nn.ReLU(), nn.Dropout(0.3),
nn.Linear(512, 256), nn.ReLU(), nn.Dropout(0.3),
nn.Linear(256, 128), nn.ReLU(),
nn.Linear(128, dout), nn.Sigmoid()) # coords in 0..1
def forward(self, x): return self.net(x)
def pck(pred, gt, vis, thr):
# pred/gt: [N,34] -> [N,17,2]; PCK@thr in normalized image units, visible kps only
p = pred.reshape(-1, 17, 2); g = gt.reshape(-1, 17, 2)
d = np.linalg.norm(p - g, axis=2) # [N,17]
m = vis > 0.5
return float((d[m] < thr).mean()) if m.any() else 0.0, float(d[m].mean()) if m.any() else float("nan")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--data", required=True)
ap.add_argument("--out", default=os.path.expanduser("~/wiflow-room/model.pt"))
ap.add_argument("--epochs", type=int, default=300)
ap.add_argument("--bs", type=int, default=64)
args = ap.parse_args()
X, Y, V = load(args.data)
n = len(X)
print(f"[train] {n} samples, X={X.shape} Y={Y.shape}")
if n < 200:
print("[train] too few samples"); sys.exit(2)
# chronological split (NOT shuffled) so val is a held-out time segment -> honest
cut = int(n * 0.8)
mu, sd = X[:cut].mean(0), X[:cut].std(0) + 1e-6 # standardize on train only
Xn = (X - mu) / sd
dev = "cuda" if torch.cuda.is_available() else "cpu"
Xtr = torch.tensor(Xn[:cut]).to(dev); Ytr = torch.tensor(Y[:cut]).to(dev)
Xva = torch.tensor(Xn[cut:]).to(dev); Yva = Y[cut:]; Vva = V[cut:]
# mean-pose baseline (predict the train-mean pose for everything) — the bar to beat
mean_pose = Y[:cut].mean(0)
base_pck, base_mpjpe = pck(np.tile(mean_pose, (len(Yva), 1)), Yva, Vva, 0.10)
net = Net(X.shape[1], Y.shape[1]).to(dev)
opt = torch.optim.Adam(net.parameters(), lr=1e-3, weight_decay=1e-4)
lossf = nn.MSELoss()
best = (1e9, None)
for ep in range(args.epochs):
net.train(); perm = torch.randperm(len(Xtr), device=dev)
for i in range(0, len(Xtr), args.bs):
idx = perm[i:i+args.bs]
opt.zero_grad(); out = net(Xtr[idx]); loss = lossf(out, Ytr[idx]); loss.backward(); opt.step()
if (ep + 1) % 20 == 0 or ep == args.epochs - 1:
net.eval()
with torch.no_grad(): pv = net(Xva).cpu().numpy()
p10, mpj = pck(pv, Yva, Vva, 0.10); p05, _ = pck(pv, Yva, Vva, 0.05)
vloss = float(((pv - Yva) ** 2).mean())
print(f"[train] ep{ep+1:3d} val_mse={vloss:.4f} PCK@0.10={p10*100:.1f}% PCK@0.05={p05*100:.1f}% MPJPE={mpj:.4f}")
if vloss < best[0]: best = (vloss, {"sd": net.state_dict(), "p10": p10, "p05": p05, "mpj": mpj})
torch.save({"model": best[1]["sd"], "mu": mu, "sd": sd, "din": X.shape[1]}, args.out)
print("\n==================== HONEST RESULT (held-out 20%, never trained) ====================")
print(f" MEAN-POSE BASELINE : PCK@0.10 = {base_pck*100:.1f}% MPJPE = {base_mpjpe:.4f} (the bar to beat)")
print(f" CSI->POSE MODEL : PCK@0.10 = {best[1]['p10']*100:.1f}% PCK@0.05 = {best[1]['p05']*100:.1f}% MPJPE = {best[1]['mpj']:.4f}")
delta = (best[1]['p10'] - base_pck) * 100
print(f" VERDICT: model {'BEATS' if delta>1 else 'does NOT beat'} mean-pose baseline by {delta:+.1f} pp "
f"-> {'real CSI->pose signal' if delta>1 else 'NO usable CSI->pose signal (honest negative)'}")
print(f" saved -> {args.out}")
if __name__ == "__main__":
main()
@@ -468,3 +468,29 @@ menu "Mock CSI (QEMU Testing)"
depends on CSI_MOCK_ENABLED
default n
endmenu
menu "Onboard LED (ADR-183)"
config LED_GAMMA_VIZ
bool "Onboard WS2812: 40 Hz gamma flicker + CSI-motion colour"
default y
help
Drive the onboard WS2812 as a GENUS-style 40 Hz gamma square wave
(12.5 ms on / 12.5 ms off, 50% duty). The ON-phase colour is live
CSI motion (edge motion_energy) mapped through the ruv-neural-viz
viridis colormap (still=purple, moving=yellow).
Disable to leave the LED off at boot — lower power, no flicker.
NOTE: a 40 Hz flicker can affect photosensitive users; disable or
shield the LED in those environments. Not a medical device.
config LED_MOTION_FULLSCALE_MILLI
int "Motion value (x1000) that saturates the colormap to yellow"
depends on LED_GAMMA_VIZ
default 250
range 1 100000
help
edge motion_energy that maps to the top (yellow) of the viridis
colormap, in milli-units (250 = 0.25). Lower = more sensitive
(reaches yellow with less motion).
endmenu
@@ -114,6 +114,19 @@ esp_err_t display_task_start(void)
/* Init touch (optional) */
esp_err_t touch_ret = display_hal_init_touch();
/* The SH8601 QSPI panel is write-only — display_hal_init_panel() above "succeeds"
* even on a bare board with no panel attached, so it cannot detect absence. The
* FT3168 touch controller is an I2C device with readback and is always present on
* the Touch-AMOLED board. If touch is absent, the panel "success" was a false-
* positive on a display-less DevKit: bail to headless so display_is_active() stays
* false and CSI upgrades to MGMT+DATA capture instead of starving at MGMT-only
* (RuView#1000). */
if (touch_ret != ESP_OK) {
ESP_LOGW(TAG, "No FT3168 touch readback — SH8601 probe was a false-positive on a "
"display-less board; running headless so CSI captures (#1000)");
return ESP_OK;
}
/* Initialize LVGL */
lv_init();
+71 -5
View File
@@ -144,6 +144,54 @@ static void wifi_init_sta(void)
}
}
#if CONFIG_LED_GAMMA_VIZ
/* Viridis colormap (60 steps), generated from ruv-neural-viz::ColorMap::viridis()
* the rUv-Neural brain-topology colormap, now no_std (ruvnet/ruv-neural#3 /
* RuView#1126). Used as the ON-phase colour of the 40 Hz gamma flicker below:
* dark-purple (still) -> teal -> green -> yellow (strong motion). */
static const uint8_t VIRIDIS_LUT[60][3] = {
{ 68, 1, 84},{ 67, 6, 88},{ 67, 12, 91},{ 66, 17, 95},{ 66, 23, 99},
{ 65, 28,103},{ 64, 34,106},{ 64, 39,110},{ 63, 45,114},{ 63, 50,118},
{ 62, 56,121},{ 61, 61,125},{ 61, 67,129},{ 60, 72,132},{ 59, 78,136},
{ 59, 83,139},{ 57, 87,139},{ 55, 92,139},{ 53, 96,139},{ 52,100,139},
{ 50,104,139},{ 48,109,139},{ 46,113,139},{ 44,117,140},{ 43,122,140},
{ 41,126,140},{ 39,130,140},{ 37,134,140},{ 36,139,140},{ 34,143,140},
{ 35,147,139},{ 39,151,136},{ 43,154,133},{ 47,158,130},{ 52,162,127},
{ 56,166,124},{ 60,170,121},{ 64,173,119},{ 68,177,116},{ 72,181,113},
{ 76,185,110},{ 81,189,107},{ 85,192,104},{ 89,196,102},{ 93,200, 99},
{102,203, 95},{113,205, 91},{124,207, 87},{134,209, 82},{145,211, 78},
{156,213, 74},{167,215, 70},{178,217, 66},{188,219, 62},{199,221, 58},
{210,223, 54},{221,225, 49},{231,227, 45},{242,229, 41},{253,231, 37},
};
static led_strip_handle_t s_viz_led;
/* motion_energy that saturates the colormap to yellow (CONFIG, milli-units). */
#define LED_MOTION_FULLSCALE ((float)CONFIG_LED_MOTION_FULLSCALE_MILLI / 1000.0f)
/* GENUS-style 40 Hz gamma flicker: full on/off square wave, 50% duty (toggled
* every 12.5 ms 40 Hz). The ON colour is live CSI motion (edge motion_energy)
* mapped through the ruv-neural-viz viridis LUT still=purple, moving=yellow.
* So the LED is a real 40 Hz gamma stimulus whose hue tracks sensed motion. */
static void led_gamma_40hz_cb(void *arg)
{
static bool on = false;
on = !on;
if (on) {
edge_vitals_pkt_t v;
float m = edge_get_vitals(&v) ? v.motion_energy : 0.0f;
float norm = m / LED_MOTION_FULLSCALE;
if (norm < 0.0f) norm = 0.0f;
if (norm > 1.0f) norm = 1.0f;
int idx = (int)(norm * 59.0f + 0.5f);
const uint8_t *c = VIRIDIS_LUT[idx];
led_strip_set_pixel(s_viz_led, 0, c[0], c[1], c[2]); /* R,G,B (driver maps to GRB) */
} else {
led_strip_set_pixel(s_viz_led, 0, 0, 0, 0); /* off phase */
}
led_strip_refresh(s_viz_led);
}
#endif /* CONFIG_LED_GAMMA_VIZ */
void app_main(void)
{
/* Initialize NVS */
@@ -173,15 +221,16 @@ void app_main(void)
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
target_name, app_desc->version, g_nvs_config.node_id);
/* Turn off onboard WS2812 LED.
* S3 dev boards put the LED on GPIO 38; C6 dev boards on GPIO 8.
* On C6, GPIO 38 doesn't exist (only 0-30) gate the init by target. */
/* Onboard WS2812. C6 wires the LED to GPIO 8; S3 to GPIO 38 (DevKitC-1 v1.0)
* or GPIO 48 (DevKitC-1 v1.1 / N16R8 see #962). On S3 we drive 48 (the
* common module). On C6, GPIO 38/48 don't exist (only 0-30) gate by target.
* Behaviour is set by CONFIG_LED_GAMMA_VIZ (ADR-183): on = 40 Hz gamma flicker
* coloured by CSI motion; off = clear the LED at boot. */
#if defined(CONFIG_IDF_TARGET_ESP32C6)
const int led_gpio = 8;
#else
const int led_gpio = 38;
const int led_gpio = 48;
#endif
led_strip_handle_t led_strip;
led_strip_config_t strip_config = {
.strip_gpio_num = led_gpio,
.max_leds = 1,
@@ -193,9 +242,26 @@ void app_main(void)
.resolution_hz = 10 * 1000 * 1000, // 10MHz
.flags.with_dma = false,
};
#if CONFIG_LED_GAMMA_VIZ
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &s_viz_led) == ESP_OK) {
const esp_timer_create_args_t viz_args = {
.callback = &led_gamma_40hz_cb,
.name = "led_gamma_40hz",
};
esp_timer_handle_t viz_timer;
if (esp_timer_create(&viz_args, &viz_timer) == ESP_OK) {
esp_timer_start_periodic(viz_timer, 12500); // 12.5 ms toggle → 40 Hz square wave
ESP_LOGI(TAG, "Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO %d", led_gpio);
}
}
#else
/* Viz disabled — clear the onboard LED at boot and release the RMT channel. */
led_strip_handle_t led_strip;
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip) == ESP_OK) {
led_strip_clear(led_strip);
led_strip_del(led_strip);
}
#endif /* CONFIG_LED_GAMMA_VIZ */
/* ADR-110 P4: 802.15.4 mesh time-sync (C6 only).
* Initialized BEFORE WiFi so it's available even when WiFi STA can't
+16 -7
View File
@@ -387,11 +387,21 @@ static mmwave_type_t probe_at_baud(uint32_t baud)
if (len <= 0) continue;
for (int i = 0; i < len; i++) {
/* MR60BHA2: SOF = 0x01, followed by valid-looking frame_id bytes */
if (buf[i] == MR60_SOF && baud == MMWAVE_MR60_BAUD) {
mr60_sof_seen++;
/* MR60BHA2: require a *validated* 8-byte header — SOF (0x01) + a valid
* header checksum (over bytes 0..6) + a known frame type (0x0A__ or
* 0x0F09) NOT a bare 0x01 byte. A floating UART1 with no sensor reads
* noise full of 0x01s, which the old `buf[i] == MR60_SOF` check mistook
* for a real sensor (false "Detected MR60BHA2", #1107). */
if (buf[i] == MR60_SOF && baud == MMWAVE_MR60_BAUD && i + 7 < len) {
const uint8_t *h = &buf[i];
if (mr60_calc_checksum(h, 7) == h[7]) {
uint16_t type = ((uint16_t)h[5] << 8) | h[6];
if ((type >> 8) == 0x0A || type == 0x0F09) {
mr60_sof_seen++;
}
}
}
/* LD2410: 4-byte header 0xF4F3F2F1 */
/* LD2410: 4-byte header 0xF4F3F2F1 (already specific enough). */
if (i + 3 < len && buf[i] == 0xF4 && buf[i+1] == 0xF3
&& buf[i+2] == 0xF2 && buf[i+3] == 0xF1
&& baud == MMWAVE_LD2410_BAUD) {
@@ -403,9 +413,8 @@ static mmwave_type_t probe_at_baud(uint32_t baud)
if (ld2410_header_seen >= 2) return MMWAVE_TYPE_LD2410;
}
if (mr60_sof_seen > 0) return MMWAVE_TYPE_MR60BHA2;
if (ld2410_header_seen > 0) return MMWAVE_TYPE_LD2410;
/* No weak single-hit fallback: line noise can produce a stray match, so a real
* sensor must clear the 3 (MR60) / 2 (LD2410) validated-frame thresholds. */
return MMWAVE_TYPE_NONE;
}
+18
View File
@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(npx ruview*)",
"mcp__ruview__*"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)"
]
},
"mcpServers": {
"ruview": {
"command": "npx",
"args": ["-y", "@ruvnet/ruview", "mcp", "start"]
}
}
}
@@ -0,0 +1,29 @@
---
name: calibrate-room
description: Run the ADR-151 per-room calibration pipeline — baseline → enroll → extract → train → a bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly).
---
# calibrate-room
Turn a provisioned node + sensing-server into a working room model. Pure-Rust,
edge-deployable (ADR-151). Use the `ruview.calibrate` tool (installed
`wifi-densepose` binary, else `cargo run -p wifi-densepose-cli`).
## Sequence
1. **baseline** — capture the empty room (Welford amplitude + von Mises phase). Leave
the room empty.
`ruview.calibrate {step: "baseline"}`
2. **enroll** — record the occupant(s) doing the target activities.
`ruview.calibrate {step: "enroll"}`
3. **train-room** — train the bank of small specialists from baseline + enrollment.
`ruview.calibrate {step: "train-room"}`
4. **room-watch** — live presence/posture/breathing from the trained room.
`ruview.calibrate {step: "room-watch"}` (or the `room-watch` skill)
## Honesty
The specialists are calibrated to *this* room; cross-room transfer is a separate
problem (LoRA recalibration, ADR-079 P9). Report which room a number came from, and
tag presence/vitals accuracy MEASURED only with a held-out check — run
`ruview.claim_check` on the writeup.
@@ -0,0 +1,30 @@
---
name: onboard
description: Zero-to-sensing path picker for RuView (WiFi-DensePose) — pick docker-demo, repo-build, or live-esp32 and run the next concrete step.
---
# onboard
Get a newcomer from nothing to a working RuView setup. **First fact to set:** WiFi
sensing infers *coarse* pose/presence/breathing from Channel State Information — it
is **not a camera**, and any accuracy number must be MEASURED against a baseline
(use the `verify` skill / `ruview.claim_check` tool). Never present WiFi output as
camera-grade.
## Pick a path
Run `ruview.onboard {path}` or decide from:
1. **docker-demo** — fastest, no hardware. Replays sample CSI into the dashboard.
`docker run -p 8000:8000 ruvnet/wifi-densepose` → open `http://localhost:8000`.
Use to see what it looks like.
2. **repo-build** — for developers. `cd v2 && cargo test --workspace --no-default-features`
(1,031+ tests pass), then `cargo run -p wifi-densepose-cli -- --help`.
3. **live-esp32** — a real install. Flash a node (`provision-node` skill), point it at
the sensing-server, then `calibrate-room`. This is the only path that senses a real room.
## Then
- Live sensing → go to **provision-node**, then **calibrate-room**.
- Evaluating a model/claim → go to **verify** and run `ruview.claim_check` on any
report before you quote a number.
@@ -0,0 +1,49 @@
---
name: provision-node
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, ESP-IDF Windows-subprocess flow, NVS/WiFi/channel/MAC-filter overrides.
---
# provision-node
Bring an ESP32 sensing node online.
## 1. Pick a firmware variant
- **s3-8mb** (display build) — ESP32-S3 N16R8 / 16MB; AMOLED optional. The display-detect
fix (#1000) means a *bare* board still captures CSI (MGMT+DATA).
- **s3-4mb** (no-display) — ESP32-S3 4MB; dual-OTA, display disabled.
- **c6** — ESP32-C6 + Seeed MR60BHA2 (60 GHz mmWave + WiFi CSI). The mmwave probe
requires a validated MR60 header (#1107) so an empty UART never false-detects.
Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56 rev v0.2).
## 2. Flash
ESP-IDF v5.4 on Windows is **subprocess-only** (Git Bash/MSYS is unsupported — strip
`MSYSTEM*` env vars). Offsets for the S3 image:
```
esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
0x0 bootloader.bin 0x8000 partition-table.bin \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
```
(`ruview.node_flash` returns the exact pinned command rather than running an
unattended flash.)
## 3. Provision
```
python firmware/esp32-csi-node/provision.py --port <PORT> \
--ssid "<SSID>" --password "<secret>" --target-ip <server-ip> --target-port 5005
# optional ADR-060 overrides:
python firmware/esp32-csi-node/provision.py --port <PORT> --channel 6 --filter-mac AA:BB:CC:DD:EE:FF
```
Never echo or commit the WiFi password.
## 4. Confirm CSI is flowing
`ruview.node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
(on a bare board) `CSI filter upgraded to MGMT+DATA`. No callbacks → the node isn't
capturing; do not proceed to calibration.
@@ -0,0 +1,33 @@
---
name: train-pose
description: Train/evaluate WiFi pose models honestly — camera-supervised (MediaPipe + CSI) and camera-free (WiFlow), always checked against the mean-pose baseline before any PCK is quoted.
---
# train-pose
Build a CSI→pose model without overstating it. The project has a **retracted 92.9%/100%**
history — the discipline below exists so it never recurs.
## The non-negotiable: mean-pose baseline first
A pose model that always predicts the dataset's *mean pose* already scores ~50% PCK.
**Quote PCK only as a delta over that baseline**, on a held-out split with no subject
or temporal leakage. Example honest result (ADR-181):
> Held-out PCK@20 **59.5%** vs a 50% mean-pose baseline = **+9.4 pp real signal** — MEASURED.
## Paths
- **camera-supervised** (ADR-079) — MediaPipe Pose labels the camera frame; paired CSI
trains the net. Train/infer in one camera frame so the skeleton aligns.
- **camera-free** (WiFlow, ADR-152) — no camera at inference; geometry-conditioned.
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
(honest about what's executing).
## Before you publish a number
1. Run the mean-pose baseline on the same split.
2. Report `(model baseline)` in pp, with the split definition (chronological /
blocked-gap / grouped-bucket; no leakage).
3. `ruview.claim_check` the writeup — it flags any untagged or 100%/perfect claim.
4. If it's a benchmark vs SOTA, tag MEASURED-EQUIVALENT only with the reproducer.
@@ -0,0 +1,42 @@
---
name: verify
description: Prove a RuView result is real — run the deterministic SHA-256 proof and the witness bundle (ADR-028), and lint any claim for MEASURED-vs-CLAIMED honesty.
---
# verify
The "prove everything" skill. Nothing ships as validated without this.
## Deterministic proof (Trust Kill Switch)
`ruview.verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
through the production pipeline and hashes the output against
`expected_features.sha256`. Must print **VERDICT: PASS**. If numpy/scipy changed the
hash, regenerate with `verify.py --generate-hash` then re-verify.
## Witness bundle (ADR-028)
For a release-grade attestation:
```
bash scripts/generate-witness-bundle.sh
cd dist/witness-bundle-ADR028-*/ && bash VERIFY.sh # must be 7/7 PASS
```
Contains the Rust test log, the proof + expected hash, firmware SHA-256 manifest, and
crate versions — a recipient can re-verify with one command.
## Claim honesty
Run `ruview.claim_check {text}` on any report, README section, PR body, or model card
before quoting accuracy. It flags:
- untagged accuracy numbers (must be MEASURED / CLAIMED / SYNTHETIC),
- MEASURED claims with no reproducer cited,
- the retracted "100%/perfect accuracy" framing.
## Firmware-specific
A firmware fix is **not** "hardware-validated" without a captured boot log on real
silicon (e.g. the `v0.8.1-esp32` rev-v0.2 validation: `running headless so CSI
captures (#1000)` + `CSI filter upgraded to MGMT+DATA` + a no-false-detect mmwave
probe). Do not merge or release on a build-passes signal alone.
+39
View File
@@ -0,0 +1,39 @@
{
"schema": 1,
"generator": "metaharness 0.1.15 + ADR-182 hardening",
"template": "vertical:ruview",
"name": "@ruvnet/ruview",
"vars": {
"name": "@ruvnet/ruview",
"description": "RuView WiFi-sensing operator agent harness",
"host": "claude-code"
},
"hosts": [
"claude-code"
],
"files": {
".claude/settings.json": "b0ea971383716f18b89db73010b8f0ea0f1b16bdec4cd1068245772ba1c27bdd",
".claude/skills/calibrate-room/SKILL.md": "6a6c8211a7109feb76620c618963c10ad9a9f633ffce7676e631a80a1181986d",
".claude/skills/onboard/SKILL.md": "22323732fe746b38b77a7c8c052e952dff2fe87ae939ba125379125827385f21",
".claude/skills/provision-node/SKILL.md": "5ffe5a75873e873b80758d9c81005774d4191317227f2e9aa4345cbce3f29751",
".claude/skills/train-pose/SKILL.md": "b3ee95bfb0b678eb3d101138b9ea0e7cab3db3a9906d19c4059f9cca0598e87b",
".claude/skills/verify/SKILL.md": "c0314d5ead465d9089b6a4917fd125051a5be20dc07ba92d5b601fcaada32e19",
"CLAUDE.md": "7ecdb2b9d9abcf4aa22dd3ce553b60216a135e147893a59fa944fc1a8c81f5ef",
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
"README.md": "b77d30428de8efb6758f2ca3eb22e84849013b2c0e6c601d488d2ea5a6f0da44",
"bin/cli.js": "b0d74690cff4329dfe342271fc475eaa140b767bdb66b37cf4992ad209012fe8",
"package.json": "2af49561ef0d59cafc4b99885816e580635b2d2ad329dfe17c69b9df6f8afceb",
"skills/calibrate-room.md": "6a6c8211a7109feb76620c618963c10ad9a9f633ffce7676e631a80a1181986d",
"skills/onboard.md": "22323732fe746b38b77a7c8c052e952dff2fe87ae939ba125379125827385f21",
"skills/provision-node.md": "5ffe5a75873e873b80758d9c81005774d4191317227f2e9aa4345cbce3f29751",
"skills/train-pose.md": "b3ee95bfb0b678eb3d101138b9ea0e7cab3db3a9906d19c4059f9cca0598e87b",
"skills/verify.md": "c0314d5ead465d9089b6a4917fd125051a5be20dc07ba92d5b601fcaada32e19",
"src/guardrails.js": "1631cea02c4354fe6126c576300faf5f8b68ae2f5e2e3a658c99eb25a7403e55",
"src/mcp-server.js": "e51379f5ebb0b7b4670c7412714e559931ef1be8df20551f8f7309b53f0fb7af",
"src/tools.js": "b558f61bb202abf5a967ce3a6ccaea351f2d186238cf49c7fc151d1de028eee8"
},
"meta": {
"surface": "cli+mcp",
"adr": "ADR-182"
}
}
+1
View File
@@ -0,0 +1 @@
6c6c1431c37472494c9b309c8b5d761dd4fc41e30313baead6320831fb982e57 manifest.json
+34
View File
@@ -0,0 +1,34 @@
# RuView harness — agent operating notes
You are operating **RuView** (WiFi-DensePose), a camera-free WiFi-CSI sensing system.
## The one rule: prove everything
This project was accused of AI-slop; the fix is hard discipline. Before you quote ANY
accuracy number:
1. It must be tagged **MEASURED** (with a reproducer named), **CLAIMED**, or **SYNTHETIC**.
2. Pose PCK is quoted only as a **delta over the mean-pose baseline** on a leakage-free
held-out split. (A mean-pose predictor already scores ~50% PCK.)
3. Run `ruview.claim_check` on any report/PR/model-card. It flags untagged numbers and
the retracted "100%/perfect accuracy" framing.
4. Firmware is "hardware-validated" only with a captured **boot log on real silicon**
never on a build-passes signal.
## Tools
`ruview.onboard`, `ruview.claim_check`, `ruview.verify`, `ruview.node_monitor`,
`ruview.calibrate`, `ruview.node_flash`. All fail-closed. Mutating/hardware tools
(`node_flash`) require explicit confirmation and are Windows/ESP-IDF gated.
## Skills
`onboard` · `provision-node` · `calibrate-room` · `train-pose` · `verify`
(`npx @ruvnet/ruview skill <name>`).
## Don'ts
- Don't present WiFi sensing as camera-grade.
- Don't echo or commit WiFi passwords / secrets.
- Don't merge or release firmware without a real boot log.
- Don't report a PCK without its mean-pose baseline.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ruvnet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+60
View File
@@ -0,0 +1,60 @@
# `npx @ruvnet/ruview` — RuView WiFi-sensing operator harness
An AI agent harness that knows how to operate **RuView** (WiFi-DensePose): onboard a
newcomer, provision an ESP32 CSI node, calibrate a room, train pose models, and —
crucially — **refuse to overstate accuracy**. Minted from the RuView monorepo via
[`metaharness`](https://www.npmjs.com/package/metaharness) and hardened per **ADR-182**.
WiFi sensing infers *coarse* pose/presence/breathing from Channel State Information.
It is **not a camera**. Every accuracy number this harness emits must be MEASURED
against a baseline — that rule is enforced in code (`ruview.claim_check`).
## Quick start
```bash
npx @ruvnet/ruview # onboard — pick a setup path
npx @ruvnet/ruview claim-check --text "we hit 100% accuracy" # the honesty guardrail
npx @ruvnet/ruview verify # run the deterministic proof (VERDICT: PASS)
npx @ruvnet/ruview doctor # self-check (tools + optional kernel/host)
npx @ruvnet/ruview --help
```
The operator tools are pure Node and run with **zero install weight**. The
`@metaharness/kernel` + host adapter are `optionalDependencies` — only `doctor` /
`install` use them, only if present.
## Tools (`ruview.*`)
Exposed both as CLI verbs and as an MCP server (`npx @ruvnet/ruview mcp start`):
| Tool | What it does |
|------|--------------|
| `ruview.onboard` | Pick docker-demo / repo-build / live-esp32; print the next command |
| `ruview.claim_check` | Lint text for untagged / overstated accuracy claims (guardrail) |
| `ruview.verify` | Run `verify.py` deterministic proof → VERDICT |
| `ruview.node_monitor` | Assert CSI is flowing on an ESP32 (read-only) |
| `ruview.calibrate` | ADR-151 room pipeline (baseline→enroll→train-room→room-watch) |
| `ruview.node_flash` | Build+flash firmware (Windows/ESP-IDF; mutating, guarded) |
Every tool is **fail-closed**: missing repo / python / binary / port → an honest
negative, never a fabricated success.
## Skills
Host-neutral playbooks in `skills/` (`onboard`, `provision-node`, `calibrate-room`,
`train-pose`, `verify`). `npx @ruvnet/ruview skill <name>` prints one.
## Use as a Claude Code MCP server
The bundled `.claude/settings.json` registers the `ruview` MCP server
(`npx -y @ruvnet/ruview mcp start`). Drop this package's `.claude/` into a repo, or run
`npx @ruvnet/ruview install --host claude-code`.
## Hosts
claude-code (bundled), and via metaharness host adapters: codex, opencode, copilot,
pi-dev, hermes, rvm, github-actions.
## License
MIT © ruvnet
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// `npx ruview` — the RuView WiFi-sensing operator harness (minted via metaharness,
// hardened per ADR-182). Plain ESM, no build step: ships and runs as-is.
//
// The `ruview.*` tools (onboard/verify/claim-check/…) are PURE Node and run with
// zero deps. The kernel + host adapter are only touched by `doctor`/`install`
// (the harness-into-a-repo story), so the operator tools never block on a wasm load.
import { fileURLToPath } from 'node:url';
import { realpathSync, existsSync, readdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { argv } from 'node:process';
import { TOOLS, runTool, listTools } from '../src/tools.js';
import { claimCheck, summarize } from '../src/guardrails.js';
const NAME = 'ruview';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const SKILLS_DIR = join(ROOT, 'skills');
// Map friendly CLI verbs → registry tool names.
const VERB_TO_TOOL = {
onboard: 'ruview.onboard',
verify: 'ruview.verify',
'claim-check': 'ruview.claim_check',
calibrate: 'ruview.calibrate',
monitor: 'ruview.node_monitor',
flash: 'ruview.node_flash',
};
function pjson(o) { console.log(JSON.stringify(o, null, 2)); }
function listSkills() {
if (!existsSync(SKILLS_DIR)) return [];
return readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''));
}
async function doctor() {
const checks = [];
// Tools layer (always available, no deps).
checks.push(['tool registry loads', Object.keys(TOOLS).length > 0]);
checks.push(['claim_check flags a 100% claim',
!claimCheck('We hit 100% accuracy on poses.').ok]);
checks.push(['claim_check passes a tagged MEASURED claim',
claimCheck('Held-out PCK@20 59.5% (MEASURED vs mean-pose baseline, verify.py).').ok]);
checks.push(['skills present', listSkills().length > 0]);
// Kernel + host adapter (optional — only needed to install into a repo).
let kernelLine = 'kernel/host: not installed (ok — operator tools run without them)';
try {
const { loadKernel } = await import('@metaharness/kernel');
const adapter = (await import('@metaharness/host-claude-code')).default;
const k = await loadKernel();
const info = k.kernelInfo();
checks.push(['kernel loads + reports version', typeof info.version === 'string' && info.version.length > 0]);
checks.push(['kernel backend is native|wasm|js', ['native', 'wasm', 'js'].includes(k.backend)]);
checks.push(['host adapter resolves', typeof adapter?.name === 'string']);
kernelLine = `kernel ${info.version} (${k.backend}) · host ${adapter.name}`;
} catch {
/* kernel not installed — fine for the tools-only path */
}
let ok = true;
for (const [label, pass] of checks) { console.log(`${pass ? 'PASS' : 'FAIL'} ${label}`); if (!pass) ok = false; }
console.log(`\n${NAME}: ${ok ? 'all checks passed' : 'doctor found problems'}${kernelLine}`);
return ok ? 0 : 1;
}
function help() {
console.log(`Usage: ${NAME} <command> [options]
Operator tools:
onboard [--path docker-demo|repo-build|live-esp32] pick a setup path
verify [--repo <dir>] run the deterministic proof (VERDICT: PASS)
claim-check --text "..." | --file <path> lint accuracy claims (the honesty guardrail)
calibrate --step baseline|enroll|train-room|room-watch
monitor --port COM8 [--seconds 12] assert CSI is flowing on a node
flash --port COM8 --variant s3-8mb [--confirm] build+flash firmware (Windows/ESP-IDF)
Harness:
doctor verify the install (tools + optional kernel/host)
skills list bundled skills
skill <name> print a skill playbook
mcp start run the ruview.* MCP server (stdio)
install --host <h> project the harness config into the current repo
--version | --help
Hosts: claude-code, codex, opencode, copilot, pi-dev, hermes, rvm, github-actions`);
return 0;
}
/** tiny flag parser: --k v / --k=v / --flag (boolean) */
function parseFlags(rest) {
const f = {};
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a.startsWith('--')) {
const eq = a.indexOf('=');
if (eq !== -1) { f[a.slice(2, eq)] = a.slice(eq + 1); }
else if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) { f[a.slice(2)] = rest[++i]; }
else { f[a.slice(2)] = true; }
}
}
return f;
}
export async function run(args) {
const cmd = args[0] ?? 'onboard';
const rest = args.slice(1);
const flags = parseFlags(rest);
// Direct tool verbs.
if (VERB_TO_TOOL[cmd]) {
const toolArgs = { ...flags };
if (cmd === 'claim-check') {
if (flags.file) toolArgs.text = readFileSync(flags.file, 'utf8');
const res = runTool('ruview.claim_check', toolArgs);
pjson(res);
return res.ok ? 0 : 1;
}
if (cmd === 'monitor' && flags.seconds) toolArgs.seconds = Number(flags.seconds);
if (cmd === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
const res = runTool(VERB_TO_TOOL[cmd], toolArgs);
pjson(res);
return res.ok ? 0 : 1;
}
switch (cmd) {
case 'doctor': return doctor();
case 'skills': console.log(listSkills().join('\n') || '(none)'); return 0;
case 'skill': {
const n = rest[0];
const p = n && join(SKILLS_DIR, `${n}.md`);
if (!p || !existsSync(p)) { console.error(`No skill "${n}". Try: ${listSkills().join(', ')}`); return 2; }
console.log(readFileSync(p, 'utf8'));
return 0;
}
case 'mcp': {
if (rest[0] === 'start' || rest[0] === undefined) {
const { startMcpServer } = await import('../src/mcp-server.js');
startMcpServer();
return new Promise(() => {}); // run until stdin closes
}
console.error('Usage: ruview mcp start'); return 2;
}
case 'install': {
const host = flags.host || 'claude-code';
try {
const adapter = (await import('@metaharness/host-claude-code')).default;
console.log(`Projecting RuView harness for host "${host}" via ${adapter.name}.`);
console.log('Add to your host config — MCP server command: npx -y ruview mcp start');
console.log('Skills:', listSkills().join(', '));
return 0;
} catch {
console.error('Host adapter not installed. `npm i @metaharness/host-claude-code` or use the bundled .claude/ config.');
return 1;
}
}
case 'tools': pjson(listTools()); return 0;
case '--version': case '-v': {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
console.log(pkg.version); return 0;
}
case '--help': case '-h': return help();
default:
console.error(`Unknown command: ${cmd}. Try \`${NAME} --help\`.`);
return 2;
}
}
// CLI guard: run only when invoked directly (realpath both sides — npm/npx shims
// pass a non-normalized, possibly case-skewed argv[1] on Windows).
const invokedDirectly = (() => {
if (!argv[1]) return false;
try {
const a = realpathSync(argv[1]);
const b = realpathSync(fileURLToPath(import.meta.url));
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
} catch { return false; }
})();
if (invokedDirectly) {
run(argv.slice(2)).then((code) => process.exit(code)).catch((err) => { console.error(err); process.exit(1); });
}
+65
View File
@@ -0,0 +1,65 @@
{
"name": "@ruvnet/ruview",
"version": "0.1.0",
"description": "RuView WiFi-sensing operator agent harness — onboard, calibrate, train, and verify camera-free WiFi-CSI sensing, with the project's MEASURED-vs-CLAIMED honesty guardrail enforced. Minted via metaharness (ADR-182).",
"type": "module",
"bin": {
"ruview": "bin/cli.js"
},
"exports": {
".": "./src/tools.js",
"./guardrails": "./src/guardrails.js"
},
"files": [
"bin/",
"src/",
"skills/",
".claude/",
".harness/",
"CLAUDE.md",
"README.md",
"LICENSE"
],
"scripts": {
"test": "node --test test/*.test.mjs",
"doctor": "node ./bin/cli.js doctor",
"mcp": "node ./bin/cli.js mcp start"
},
"optionalDependencies": {
"@metaharness/kernel": "^0.1.0",
"@metaharness/host-claude-code": "^0.1.0"
},
"keywords": [
"wifi-sensing",
"wifi-densepose",
"ruview",
"csi",
"channel-state-information",
"pose-estimation",
"presence-detection",
"esp32",
"agent-harness",
"metaharness",
"mcp",
"mcp-server",
"claude-code",
"ambient-intelligence"
],
"engines": {
"node": ">=20.0.0"
},
"license": "MIT",
"author": "ruvnet",
"homepage": "https://github.com/ruvnet/RuView#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/ruvnet/RuView.git",
"directory": "harness/ruview"
},
"bugs": {
"url": "https://github.com/ruvnet/RuView/issues"
},
"publishConfig": {
"access": "public"
}
}
+29
View File
@@ -0,0 +1,29 @@
---
name: calibrate-room
description: Run the ADR-151 per-room calibration pipeline — baseline → enroll → extract → train → a bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly).
---
# calibrate-room
Turn a provisioned node + sensing-server into a working room model. Pure-Rust,
edge-deployable (ADR-151). Use the `ruview.calibrate` tool (installed
`wifi-densepose` binary, else `cargo run -p wifi-densepose-cli`).
## Sequence
1. **baseline** — capture the empty room (Welford amplitude + von Mises phase). Leave
the room empty.
`ruview.calibrate {step: "baseline"}`
2. **enroll** — record the occupant(s) doing the target activities.
`ruview.calibrate {step: "enroll"}`
3. **train-room** — train the bank of small specialists from baseline + enrollment.
`ruview.calibrate {step: "train-room"}`
4. **room-watch** — live presence/posture/breathing from the trained room.
`ruview.calibrate {step: "room-watch"}` (or the `room-watch` skill)
## Honesty
The specialists are calibrated to *this* room; cross-room transfer is a separate
problem (LoRA recalibration, ADR-079 P9). Report which room a number came from, and
tag presence/vitals accuracy MEASURED only with a held-out check — run
`ruview.claim_check` on the writeup.
+30
View File
@@ -0,0 +1,30 @@
---
name: onboard
description: Zero-to-sensing path picker for RuView (WiFi-DensePose) — pick docker-demo, repo-build, or live-esp32 and run the next concrete step.
---
# onboard
Get a newcomer from nothing to a working RuView setup. **First fact to set:** WiFi
sensing infers *coarse* pose/presence/breathing from Channel State Information — it
is **not a camera**, and any accuracy number must be MEASURED against a baseline
(use the `verify` skill / `ruview.claim_check` tool). Never present WiFi output as
camera-grade.
## Pick a path
Run `ruview.onboard {path}` or decide from:
1. **docker-demo** — fastest, no hardware. Replays sample CSI into the dashboard.
`docker run -p 8000:8000 ruvnet/wifi-densepose` → open `http://localhost:8000`.
Use to see what it looks like.
2. **repo-build** — for developers. `cd v2 && cargo test --workspace --no-default-features`
(1,031+ tests pass), then `cargo run -p wifi-densepose-cli -- --help`.
3. **live-esp32** — a real install. Flash a node (`provision-node` skill), point it at
the sensing-server, then `calibrate-room`. This is the only path that senses a real room.
## Then
- Live sensing → go to **provision-node**, then **calibrate-room**.
- Evaluating a model/claim → go to **verify** and run `ruview.claim_check` on any
report before you quote a number.
+49
View File
@@ -0,0 +1,49 @@
---
name: provision-node
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, ESP-IDF Windows-subprocess flow, NVS/WiFi/channel/MAC-filter overrides.
---
# provision-node
Bring an ESP32 sensing node online.
## 1. Pick a firmware variant
- **s3-8mb** (display build) — ESP32-S3 N16R8 / 16MB; AMOLED optional. The display-detect
fix (#1000) means a *bare* board still captures CSI (MGMT+DATA).
- **s3-4mb** (no-display) — ESP32-S3 4MB; dual-OTA, display disabled.
- **c6** — ESP32-C6 + Seeed MR60BHA2 (60 GHz mmWave + WiFi CSI). The mmwave probe
requires a validated MR60 header (#1107) so an empty UART never false-detects.
Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56 rev v0.2).
## 2. Flash
ESP-IDF v5.4 on Windows is **subprocess-only** (Git Bash/MSYS is unsupported — strip
`MSYSTEM*` env vars). Offsets for the S3 image:
```
esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
0x0 bootloader.bin 0x8000 partition-table.bin \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
```
(`ruview.node_flash` returns the exact pinned command rather than running an
unattended flash.)
## 3. Provision
```
python firmware/esp32-csi-node/provision.py --port <PORT> \
--ssid "<SSID>" --password "<secret>" --target-ip <server-ip> --target-port 5005
# optional ADR-060 overrides:
python firmware/esp32-csi-node/provision.py --port <PORT> --channel 6 --filter-mac AA:BB:CC:DD:EE:FF
```
Never echo or commit the WiFi password.
## 4. Confirm CSI is flowing
`ruview.node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
(on a bare board) `CSI filter upgraded to MGMT+DATA`. No callbacks → the node isn't
capturing; do not proceed to calibration.
+33
View File
@@ -0,0 +1,33 @@
---
name: train-pose
description: Train/evaluate WiFi pose models honestly — camera-supervised (MediaPipe + CSI) and camera-free (WiFlow), always checked against the mean-pose baseline before any PCK is quoted.
---
# train-pose
Build a CSI→pose model without overstating it. The project has a **retracted 92.9%/100%**
history — the discipline below exists so it never recurs.
## The non-negotiable: mean-pose baseline first
A pose model that always predicts the dataset's *mean pose* already scores ~50% PCK.
**Quote PCK only as a delta over that baseline**, on a held-out split with no subject
or temporal leakage. Example honest result (ADR-181):
> Held-out PCK@20 **59.5%** vs a 50% mean-pose baseline = **+9.4 pp real signal** — MEASURED.
## Paths
- **camera-supervised** (ADR-079) — MediaPipe Pose labels the camera frame; paired CSI
trains the net. Train/infer in one camera frame so the skeleton aligns.
- **camera-free** (WiFlow, ADR-152) — no camera at inference; geometry-conditioned.
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
(honest about what's executing).
## Before you publish a number
1. Run the mean-pose baseline on the same split.
2. Report `(model baseline)` in pp, with the split definition (chronological /
blocked-gap / grouped-bucket; no leakage).
3. `ruview.claim_check` the writeup — it flags any untagged or 100%/perfect claim.
4. If it's a benchmark vs SOTA, tag MEASURED-EQUIVALENT only with the reproducer.
+42
View File
@@ -0,0 +1,42 @@
---
name: verify
description: Prove a RuView result is real — run the deterministic SHA-256 proof and the witness bundle (ADR-028), and lint any claim for MEASURED-vs-CLAIMED honesty.
---
# verify
The "prove everything" skill. Nothing ships as validated without this.
## Deterministic proof (Trust Kill Switch)
`ruview.verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
through the production pipeline and hashes the output against
`expected_features.sha256`. Must print **VERDICT: PASS**. If numpy/scipy changed the
hash, regenerate with `verify.py --generate-hash` then re-verify.
## Witness bundle (ADR-028)
For a release-grade attestation:
```
bash scripts/generate-witness-bundle.sh
cd dist/witness-bundle-ADR028-*/ && bash VERIFY.sh # must be 7/7 PASS
```
Contains the Rust test log, the proof + expected hash, firmware SHA-256 manifest, and
crate versions — a recipient can re-verify with one command.
## Claim honesty
Run `ruview.claim_check {text}` on any report, README section, PR body, or model card
before quoting accuracy. It flags:
- untagged accuracy numbers (must be MEASURED / CLAIMED / SYNTHETIC),
- MEASURED claims with no reproducer cited,
- the retracted "100%/perfect accuracy" framing.
## Firmware-specific
A firmware fix is **not** "hardware-validated" without a captured boot log on real
silicon (e.g. the `v0.8.1-esp32` rev-v0.2 validation: `running headless so CSI
captures (#1000)` + `CSI filter upgraded to MGMT+DATA` + a no-false-detect mmwave
probe). Do not merge or release on a build-passes signal alone.
+106
View File
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: MIT
// RuView harness guardrails — the "prove everything" rule made executable.
//
// The project was accused of AI-slop; the cultural fix is that every accuracy
// number must be tagged MEASURED (with a reproducer) or CLAIMED/SYNTHETIC, and
// the retracted "100% accuracy" framing must never reappear untagged. This module
// is the static enforcement of that, shared by the `ruview.claim_check` MCP tool,
// the `npx ruview claim-check` CLI, and the claude-code pre-output hook.
/** Phrases that signal a quantitative accuracy claim. */
const METRIC_TERMS = [
'accuracy', 'pck', 'pck@', 'f1', 'precision', 'recall', 'map', 'auc',
'iou', 'mpjpe', 'error rate', 'detection rate', 'true positive',
];
/** Tags that make a claim honest (case-insensitive). */
const HONEST_TAGS = ['measured', 'claimed', 'synthetic', 'unvalidated', 'baseline'];
/** Reproducer references that count as evidence backing a MEASURED claim. */
const REPRODUCER_HINTS = [
'verify.py', 'witness', 'mean-pose', 'mean pose', 'held-out', 'held out',
'baseline', 'reproduce', 'sha256', 'boot log', 'pck@20 vs', 'expected_features',
];
const PERCENT_RE = /\b(\d{1,3}(?:\.\d+)?)\s?%/g;
// "perfect" / "100%" framing is the specific retracted claim — always high severity.
// NOTE: no trailing \b after "%": "%"→" " is non-word→non-word, so a trailing \b
// never matches and would silently miss "100%". Bare 100% is only damning next to a
// metric term (see claimCheck); the word phrases are inherently accuracy claims.
const PERFECT_PCT_RE = /\b100(?:\.0+)?\s?%/;
const PERFECT_WORD_RE = /perfect accuracy|flawless|never (?:wrong|fails)/i;
/**
* Lint a block of text for untagged or overstated accuracy claims.
* @param {string} text
* @returns {{ok: boolean, findings: Array<{severity:'high'|'medium', line:number, excerpt:string, reason:string, suggestion:string}>}}
*/
export function claimCheck(text) {
const findings = [];
if (typeof text !== 'string' || text.length === 0) {
return { ok: true, findings };
}
const lines = text.split(/\r?\n/);
lines.forEach((raw, i) => {
const line = raw.trim();
if (!line) return;
const lower = line.toLowerCase();
const hasPercent = PERCENT_RE.test(line);
PERCENT_RE.lastIndex = 0; // reset stateful global regex
const mentionsMetric = METRIC_TERMS.some((t) => lower.includes(t));
if (!hasPercent && !mentionsMetric) return;
const tagged = HONEST_TAGS.some((t) => lower.includes(t));
const hasReproducer = REPRODUCER_HINTS.some((h) => lower.includes(h));
const perfect = PERFECT_WORD_RE.test(line) || (mentionsMetric && PERFECT_PCT_RE.test(line));
if (perfect && !lower.includes('retract')) {
findings.push({
severity: 'high',
line: i + 1,
excerpt: clip(line),
reason: 'States perfect/100% accuracy — this is the exact framing the project retracted.',
suggestion: 'Replace with a held-out number vs the mean-pose baseline, tagged MEASURED, or mark the old claim "retracted".',
});
return;
}
// A metric/percent with no honesty tag at all.
if (!tagged) {
findings.push({
severity: 'medium',
line: i + 1,
excerpt: clip(line),
reason: 'Accuracy claim is not tagged MEASURED / CLAIMED / SYNTHETIC.',
suggestion: 'Tag it. If MEASURED, name the reproducer (verify.py, witness bundle, held-out vs mean-pose).',
});
return;
}
// Tagged MEASURED but cites no reproducer — still a gap.
if (lower.includes('measured') && !hasReproducer) {
findings.push({
severity: 'medium',
line: i + 1,
excerpt: clip(line),
reason: 'Tagged MEASURED but cites no reproducer/evidence.',
suggestion: 'Add the evidence path: verify.py VERDICT, witness bundle, or held-out PCK vs the mean-pose baseline.',
});
}
});
return { ok: findings.length === 0, findings };
}
function clip(s, n = 120) {
return s.length > n ? `${s.slice(0, n - 1)}` : s;
}
/** Convenience: a one-line human summary for CLI output. */
export function summarize(result) {
if (result.ok) return 'claim-check: PASS — no untagged or overstated accuracy claims.';
const high = result.findings.filter((f) => f.severity === 'high').length;
return `claim-check: ${result.findings.length} finding(s) (${high} high) — accuracy claims need MEASURED/CLAIMED tags + a reproducer.`;
}
+68
View File
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: MIT
// RuView harness — minimal MCP stdio server (JSON-RPC 2.0 over stdin/stdout).
//
// Dependency-free on purpose: a published `npx ruview` must `mcp start` without
// pulling the full MCP SDK. Implements the subset hosts use: `initialize`,
// `tools/list`, `tools/call`, and the `notifications/initialized` ack. Logs go to
// stderr ONLY — stdout is the JSON-RPC channel and must stay clean.
import { createInterface } from 'node:readline';
import { listTools, runTool } from './tools.js';
const PROTOCOL_VERSION = '2024-11-05';
const SERVER_INFO = { name: 'ruview', version: '0.1.0' };
function send(msg) {
process.stdout.write(JSON.stringify(msg) + '\n');
}
function result(id, res) { send({ jsonrpc: '2.0', id, result: res }); }
function error(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
function log(...a) { process.stderr.write('[ruview-mcp] ' + a.join(' ') + '\n'); }
function handle(msg) {
const { id, method, params } = msg;
switch (method) {
case 'initialize':
return result(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: SERVER_INFO,
instructions: 'RuView WiFi-sensing operator tools. All results are fail-closed; accuracy claims must pass ruview.claim_check.',
});
case 'notifications/initialized':
case 'initialized':
return; // notification — no response
case 'ping':
return result(id, {});
case 'tools/list':
return result(id, { tools: listTools() });
case 'tools/call': {
const name = params?.name;
const args = params?.arguments || {};
const out = runTool(name, args);
// MCP content envelope: text block with the JSON, isError reflects ok=false.
return result(id, {
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
isError: out && out.ok === false,
});
}
default:
if (id !== undefined) error(id, -32601, `Method not found: ${method}`);
}
}
export function startMcpServer() {
log(`starting (protocol ${PROTOCOL_VERSION}, ${listTools().length} tools)`);
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
rl.on('line', (line) => {
const s = line.trim();
if (!s) return;
let msg;
try { msg = JSON.parse(s); } catch { return log('bad JSON line dropped'); }
try { handle(msg); } catch (err) {
if (msg && msg.id !== undefined) error(msg.id, -32603, String(err && err.message || err));
log('handler error:', String(err));
}
});
rl.on('close', () => { log('stdin closed — exiting'); process.exit(0); });
}
+220
View File
@@ -0,0 +1,220 @@
// SPDX-License-Identifier: MIT
// RuView harness — the `ruview.*` tool registry.
//
// One registry consumed by BOTH the CLI (`npx ruview <tool>`) and the MCP server
// (`npx ruview mcp start`). Every handler returns structured JSON and is
// FAIL-CLOSED: when a prerequisite (the RuView repo, python+pyserial, the
// `wifi-densepose` binary, an ESP32 on a port) is absent, it returns an honest
// negative — never a fabricated success. This mirrors the project's "prove
// everything" rule and the RuField fail-closed posture (ADR-262 §3.3).
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { claimCheck, summarize } from './guardrails.js';
/** Walk up from `start` to find the RuView monorepo root (or null). */
export function findRepoRoot(start = process.cwd()) {
let dir = resolve(start);
for (let i = 0; i < 8; i++) {
const hasProof = existsSync(join(dir, 'archive', 'v1', 'data', 'proof', 'verify.py'));
const hasV2 = existsSync(join(dir, 'v2', 'Cargo.toml'));
if (hasProof || hasV2) return dir;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
function which(cmd) {
const probe = process.platform === 'win32'
? spawnSync('where', [cmd], { encoding: 'utf8' })
: spawnSync('command', ['-v', cmd], { encoding: 'utf8', shell: true });
return probe.status === 0 ? (probe.stdout || '').trim().split(/\r?\n/)[0] : null;
}
function run(cmd, args, opts = {}) {
const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: opts.timeout ?? 120000, ...opts });
return {
status: r.status,
ok: r.status === 0,
stdout: (r.stdout || '').slice(-8000),
stderr: (r.stderr || '').slice(-4000),
error: r.error ? r.error.message : null,
};
}
const ONBOARD_PATHS = {
'docker-demo': 'Fastest. `docker run -p 8000:8000 ruvnet/wifi-densepose` → open the dashboard. No hardware; replays sample CSI. Good for "what does it look like".',
'repo-build': 'Build from source. `cd v2 && cargo test --workspace --no-default-features` (1,031+ tests). Then `cargo run -p wifi-densepose-cli -- --help`. Good for developers.',
'live-esp32': 'Real sensing. Flash an ESP32-S3 (see `provision-node` skill), point it at the sensing-server, then `calibrate → enroll → train-room → room-watch` (see `calibrate-room`). Good for an actual install.',
};
/**
* The tool registry. Each entry: { title, description, inputSchema, handler }.
* inputSchema is JSON-Schema (object). handler(args) JSON-serializable result.
*/
export const TOOLS = {
'ruview.onboard': {
title: 'Onboard',
description: 'Pick a RuView setup path (docker-demo | repo-build | live-esp32) and print the next concrete command.',
inputSchema: {
type: 'object',
properties: { path: { type: 'string', enum: Object.keys(ONBOARD_PATHS), description: 'Which setup path. Omit to list all.' } },
},
handler(args = {}) {
const repo = findRepoRoot();
if (args.path && ONBOARD_PATHS[args.path]) {
return { ok: true, path: args.path, next: ONBOARD_PATHS[args.path], in_ruview_repo: !!repo };
}
return {
ok: true,
in_ruview_repo: !!repo,
repo_root: repo,
paths: ONBOARD_PATHS,
recommend: repo ? 'repo-build' : 'docker-demo',
note: 'WiFi sensing infers coarse pose/presence from CSI — it is not a camera. Accuracy claims must be MEASURED vs a baseline (run `ruview.claim_check`).',
};
},
},
'ruview.claim_check': {
title: 'Claim check',
description: 'Static lint: scan text for untagged or overstated accuracy claims (the "prove everything" guardrail). Returns findings.',
inputSchema: {
type: 'object',
required: ['text'],
properties: { text: { type: 'string', description: 'The text to lint (a report, README section, PR body, model card).' } },
},
handler(args = {}) {
const result = claimCheck(String(args.text ?? ''));
return { ...result, summary: summarize(result) };
},
},
'ruview.verify': {
title: 'Verify (witness)',
description: 'Run the deterministic proof (archive/v1/data/proof/verify.py) and report VERDICT. Fail-closed if not in a RuView repo or python is missing.',
inputSchema: {
type: 'object',
properties: { repo: { type: 'string', description: 'RuView repo root. Default: auto-detect from cwd.' } },
},
handler(args = {}) {
const repo = args.repo ? resolve(args.repo) : findRepoRoot();
if (!repo) return { ok: false, reason: 'not_in_ruview_repo', hint: 'Run inside the RuView monorepo or pass {repo}.' };
const proof = join(repo, 'archive', 'v1', 'data', 'proof', 'verify.py');
if (!existsSync(proof)) return { ok: false, reason: 'proof_missing', path: proof };
const py = which('python') || which('python3');
if (!py) return { ok: false, reason: 'python_missing', hint: 'Install python to run the deterministic proof.' };
const r = run(py, [proof], { cwd: repo, timeout: 180000 });
const verdict = /VERDICT:\s*PASS/i.test(r.stdout) ? 'PASS' : (/VERDICT:\s*FAIL/i.test(r.stdout) ? 'FAIL' : 'UNKNOWN');
return { ok: r.ok && verdict === 'PASS', verdict, exit: r.status, tail: r.stdout.slice(-1200), stderr: r.stderr.slice(-400) };
},
},
'ruview.node_monitor': {
title: 'Node monitor',
description: 'Open an ESP32 serial port and assert CSI is flowing (MGMT+DATA). Fail-closed if python+pyserial or the port is absent. Read-only.',
inputSchema: {
type: 'object',
properties: {
port: { type: 'string', description: 'Serial port, e.g. COM8 or /dev/ttyUSB0.' },
seconds: { type: 'number', description: 'Capture window (default 12).' },
},
},
handler(args = {}) {
const port = args.port;
if (!port) return { ok: false, reason: 'no_port', hint: 'Pass {port} (e.g. COM8).' };
const py = which('python') || which('python3');
if (!py) return { ok: false, reason: 'python_missing' };
const dur = Number(args.seconds) > 0 ? Number(args.seconds) : 12;
const script = [
'import sys,time',
'try:',
' import serial',
'except Exception as e:',
" print('NO_PYSERIAL'); sys.exit(3)",
`ser=serial.Serial(${JSON.stringify(port)},115200,timeout=1)`,
'csi=0; n=0; t=time.time()',
`while time.time()-t<${dur}:`,
' ln=ser.readline()',
' if not ln: continue',
" s=ln.decode('utf-8','replace')",
' n+=1',
" if 'CSI cb' in s or 'csi_collector' in s: csi+=1",
" if 'MGMT+DATA' in s: print('UPGRADE_MGMT_DATA')",
'ser.close()',
"print(f'LINES={n} CSI={csi}')",
].join('\n');
const r = run(py, ['-c', script], { timeout: (dur + 10) * 1000 });
if (r.stdout.includes('NO_PYSERIAL')) return { ok: false, reason: 'pyserial_missing', hint: 'pip install pyserial' };
if (!r.ok) return { ok: false, reason: 'port_error', stderr: r.stderr, error: r.error };
const csi = Number((r.stdout.match(/CSI=(\d+)/) || [])[1] || 0);
const upgraded = r.stdout.includes('UPGRADE_MGMT_DATA');
return { ok: csi > 0, csi_callbacks: csi, mgmt_data_upgrade: upgraded, raw: r.stdout.trim() };
},
},
'ruview.calibrate': {
title: 'Calibrate room',
description: 'Run the ADR-151 room pipeline via the wifi-densepose CLI (baseline→enroll→train-room). Fail-closed if the binary is absent.',
inputSchema: {
type: 'object',
properties: {
step: { type: 'string', enum: ['baseline', 'enroll', 'train-room', 'room-watch'], description: 'Which calibration step.' },
args: { type: 'array', items: { type: 'string' }, description: 'Extra CLI args passed through.' },
},
},
handler(args = {}) {
const step = args.step || 'baseline';
const bin = which('wifi-densepose');
const repo = findRepoRoot();
if (!bin && !repo) return { ok: false, reason: 'cli_missing', hint: 'Install the wifi-densepose CLI or run in the repo (cargo run -p wifi-densepose-cli).' };
const passthru = Array.isArray(args.args) ? args.args.map(String) : [];
// Prefer the installed binary; otherwise cargo-run from the repo.
const r = bin
? run(bin, [step, ...passthru], { timeout: 300000 })
: run('cargo', ['run', '-q', '-p', 'wifi-densepose-cli', '--', step, ...passthru], { cwd: repo, timeout: 600000 });
return { ok: r.ok, step, via: bin ? 'binary' : 'cargo', exit: r.status, tail: r.stdout.slice(-1500), stderr: r.stderr.slice(-500) };
},
},
'ruview.node_flash': {
title: 'Node flash',
description: 'Build+flash an ESP32 firmware variant. MUTATING + hardware. Fail-closed off-Windows or without ESP-IDF. Never claims hardware validation without a boot log.',
inputSchema: {
type: 'object',
properties: {
port: { type: 'string', description: 'Target port, e.g. COM8.' },
variant: { type: 'string', enum: ['s3-8mb', 's3-4mb', 'c6'], description: 'Firmware variant.' },
confirm: { type: 'boolean', description: 'Must be true to actually flash (guard).' },
},
},
handler(args = {}) {
if (process.platform !== 'win32') {
return { ok: false, reason: 'unsupported_platform', detail: 'The ESP-IDF flash flow is Windows-subprocess-specific today (see CLAUDE.local.md).' };
}
if (!args.confirm) {
return { ok: false, reason: 'not_confirmed', detail: 'Mutating hardware op — re-call with {confirm:true}.', would_flash: { port: args.port, variant: args.variant || 's3-8mb' } };
}
return { ok: false, reason: 'manual_step_required', detail: 'Flashing uses the pinned ESP-IDF subprocess in CLAUDE.local.md. This tool returns the exact command rather than running an unattended flash.', see: 'skills/provision-node.md' };
},
},
};
/** Run one tool by name; returns the structured result (or an error envelope). */
export function runTool(name, args) {
const tool = TOOLS[name];
if (!tool) return { ok: false, reason: 'unknown_tool', name, available: Object.keys(TOOLS) };
try {
return tool.handler(args || {});
} catch (err) {
return { ok: false, reason: 'tool_threw', name, error: String(err && err.message || err) };
}
}
/** MCP-shaped tool list: [{name, description, inputSchema}]. */
export function listTools() {
return Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema }));
}
+111
View File
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: MIT
// RuView harness tests — Node's built-in test runner (no devDeps to install).
// Run: `node --test test/` (or `npm test`).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { claimCheck, summarize } from '../src/guardrails.js';
import { TOOLS, runTool, listTools, findRepoRoot } from '../src/tools.js';
import { run } from '../bin/cli.js';
test('guardrail flags the retracted 100% framing as high severity', () => {
const r = claimCheck('Our model reaches 100% accuracy on every pose.');
assert.equal(r.ok, false);
assert.ok(r.findings.some((f) => f.severity === 'high'));
});
test('guardrail flags an untagged percentage accuracy claim', () => {
// "hit", not "measured" — "measured" would (correctly) route to the no-reproducer branch.
const r = claimCheck('We hit 92.9% PCK on the test set.');
assert.equal(r.ok, false);
assert.ok(r.findings.some((f) => /not tagged/i.test(f.reason)));
});
test('guardrail passes a MEASURED claim that cites a reproducer', () => {
const r = claimCheck('Held-out PCK@20 59.5% vs 50% mean-pose baseline = +9.4pp (MEASURED, verify.py).');
assert.equal(r.ok, true, JSON.stringify(r.findings));
});
test('guardrail flags MEASURED with no reproducer', () => {
const r = claimCheck('Presence detection 97% (MEASURED).');
assert.equal(r.ok, false);
assert.ok(r.findings.some((f) => /no reproducer/i.test(f.reason)));
});
test('guardrail ignores non-metric prose', () => {
assert.equal(claimCheck('The ESP32 streams CSI over UDP to the sensing-server.').ok, true);
assert.equal(claimCheck('').ok, true);
});
test('summarize gives PASS/finding text', () => {
assert.match(summarize(claimCheck('nothing here')), /PASS/);
assert.match(summarize(claimCheck('100% accuracy')), /finding/);
});
test('registry exposes the documented tools with schemas', () => {
const names = Object.keys(TOOLS);
for (const n of ['ruview.onboard', 'ruview.claim_check', 'ruview.verify', 'ruview.node_monitor', 'ruview.calibrate', 'ruview.node_flash']) {
assert.ok(names.includes(n), `missing ${n}`);
assert.equal(TOOLS[n].inputSchema.type, 'object');
}
assert.equal(listTools().length, names.length);
});
test('ruview.onboard returns paths and a recommendation', () => {
const r = runTool('ruview.onboard', {});
assert.equal(r.ok, true);
assert.ok(r.paths['live-esp32']);
assert.ok(['repo-build', 'docker-demo'].includes(r.recommend));
});
test('ruview.claim_check tool wraps the guardrail', () => {
const r = runTool('ruview.claim_check', { text: '100% accuracy' });
assert.equal(r.ok, false);
assert.match(r.summary, /honesty|tag|MEASURED|finding/i);
});
test('unknown tool fails closed', () => {
const r = runTool('ruview.does_not_exist', {});
assert.equal(r.ok, false);
assert.equal(r.reason, 'unknown_tool');
});
test('node_monitor fails closed without a port', () => {
const r = runTool('ruview.node_monitor', {});
assert.equal(r.ok, false);
assert.equal(r.reason, 'no_port');
});
test('node_flash refuses without confirm (mutating guard)', () => {
const r = runTool('ruview.node_flash', { port: 'COM8', variant: 's3-8mb' });
assert.equal(r.ok, false);
// either not-confirmed (win32) or unsupported_platform (posix) — both fail-closed
assert.ok(['not_confirmed', 'unsupported_platform'].includes(r.reason));
});
test('verify fails closed when not in a RuView repo', () => {
// point at a tmp dir with no repo markers
const r = runTool('ruview.verify', { repo: process.platform === 'win32' ? 'C:/Windows/Temp' : '/tmp' });
assert.equal(r.ok, false);
assert.ok(['proof_missing', 'python_missing'].includes(r.reason), r.reason);
});
test('CLI run(): claim-check exits non-zero on a bad claim', async () => {
const code = await run(['claim-check', '--text', '100% accuracy']);
assert.notEqual(code, 0);
});
test('CLI run(): doctor exits 0 (tools-only path)', async () => {
const code = await run(['doctor']);
assert.equal(code, 0);
});
test('CLI run(): unknown command exits non-zero', async () => {
assert.notEqual(await run(['definitely-not-a-command']), 0);
});
test('findRepoRoot locates this monorepo from cwd', () => {
// when run from within wifi-densepose, it should find a root; elsewhere null is fine
const root = findRepoRoot();
assert.ok(root === null || typeof root === 'string');
});
Binary file not shown.
BIN
View File
Binary file not shown.
+32 -6
View File
@@ -184,7 +184,9 @@ function loadGroundTruth(filePath) {
const raw = loadJsonl(filePath);
const frames = [];
for (const r of raw) {
if (r.ts_ns == null || !r.keypoints) continue;
// Skip non-detection frames (empty keypoints []) — they must not dilute window
// confidence; confidence stats are over actual detections only (#1007 Bug 2).
if (r.ts_ns == null || !r.keypoints || r.keypoints.length === 0) continue;
frames.push({
tsMs: cameraTsToMs(r.ts_ns),
keypoints: r.keypoints,
@@ -266,7 +268,29 @@ function loadCsi(filePath) {
// Sort by timestamp
rawCsi.sort((a, b) => a.tsMs - b.tsMs);
features.sort((a, b) => a.tsMs - b.tsMs);
return { rawCsi, features };
// Bug 3 (#1007): keep only frames at the session's MODAL subcarrier count so windows
// are homogeneous; never silently zero-pad/truncate the off-format frames the ESP32
// emits (HT20/HT40/fragments). extractCsiMatrix then sees uniform-width frames.
return { rawCsi: filterToModalSubcarriers(rawCsi), features };
}
/**
* Keep only frames whose subcarrier count equals the session's modal (most common)
* count. Off-format frames are dropped (logged), not padded prevents the silent
* zero-padding that corrupted windows in #1007.
*/
function filterToModalSubcarriers(frames) {
if (frames.length === 0) return frames;
const counts = new Map();
for (const f of frames) counts.set(f.subcarriers, (counts.get(f.subcarriers) || 0) + 1);
let modal = frames[0].subcarriers, best = 0;
for (const [sc, n] of counts) if (n > best) { best = n; modal = sc; }
const kept = frames.filter((f) => f.subcarriers === modal);
if (kept.length !== frames.length) {
console.error(`[align] #1007: kept ${kept.length}/${frames.length} CSI frames at modal subcarrier count ${modal} (dropped ${frames.length - kept.length} off-format; no silent padding)`);
}
return kept;
}
// ---------------------------------------------------------------------------
@@ -343,7 +367,8 @@ function averageKeypoints(cameraFrames) {
/**
* Extract CSI amplitude matrix from raw_csi window.
* Returns { data: flat Float32Array, shape: [subcarriers, windowFrames] }.
* Fill is frame-major (matrix[f*nSc + s]), so shape is [windowFrames, subcarriers]
* (#1007 Bug 4 was mislabeled [subcarriers, windowFrames], transposing consumers).
*/
function extractCsiMatrix(window) {
const nFrames = window.length;
@@ -363,12 +388,13 @@ function extractCsiMatrix(window) {
}
}
return { data: Array.from(matrix), shape: [nSc, nFrames] };
return { data: Array.from(matrix), shape: [nFrames, nSc] };
}
/**
* Extract feature matrix from feature-type window.
* Returns { data: flat array, shape: [featureDim, windowFrames] }.
* Fill is frame-major (matrix[f*dim + d]), so shape is [windowFrames, featureDim]
* (#1007 Bug 4 was mislabeled [featureDim, windowFrames]).
*/
function extractFeatureMatrix(window) {
const nFrames = window.length;
@@ -382,7 +408,7 @@ function extractFeatureMatrix(window) {
}
}
return { data: Array.from(matrix), shape: [dim, nFrames] };
return { data: Array.from(matrix), shape: [nFrames, dim] };
}
// ---------------------------------------------------------------------------
+3 -1
View File
@@ -15,6 +15,7 @@ import os
import socket
import struct
import time
from datetime import datetime, timezone
def parse_csi_packet(data):
@@ -41,7 +42,8 @@ def parse_csi_packet(data):
return {
"type": "raw_csi",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(time.time() * 1000) % 1000:03d}Z",
# true UTC, not local-time-labeled-Z (#1007 Bug 1) — e.g. "2026-06-17T01:23:45.678Z"
"timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
"ts_ns": time.time_ns(),
"node_id": node_id,
"rssi": rssi,
Binary file not shown.
Generated
+11
View File
@@ -3595,6 +3595,7 @@ dependencies = [
"anyhow",
"axum",
"clap",
"futures",
"homecore",
"homecore-api",
"homecore-assist",
@@ -3602,8 +3603,13 @@ dependencies = [
"homecore-hap",
"homecore-plugins",
"homecore-recorder",
"http-body-util",
"reqwest 0.12.28",
"serde",
"serde_json",
"tokio",
"tower 0.5.3",
"tower-http",
"tracing",
"tracing-subscriber",
]
@@ -3767,6 +3773,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.4",
"tower-service",
"webpki-roots 1.0.7",
]
[[package]]
@@ -6870,6 +6877,8 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.37",
"rustls-pki-types",
"serde",
"serde_json",
@@ -6877,6 +6886,7 @@ dependencies = [
"sync_wrapper 1.0.2",
"tokio",
"tokio-native-tls",
"tokio-rustls 0.26.4",
"tower 0.5.3",
"tower-http",
"tower-service",
@@ -6884,6 +6894,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.7",
]
[[package]]
+4 -4
View File
@@ -25,8 +25,7 @@ members = [
"crates/wifi-densepose-ruvector",
"crates/wifi-densepose-desktop",
"crates/wifi-densepose-pointcloud",
"crates/wifi-densepose-geo",
"crates/wifi-densepose-worldgraph", # ADR-139 — WorldGraph environmental digital twin
# geo + worldgraph extracted to ruvnet/worldgraph submodule (see crates/worldgraph)
"crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer
"crates/wifi-densepose-calibration", # ADR-151 — per-room calibration & specialist training
"crates/nvsim",
@@ -58,7 +57,7 @@ members = [
"crates/wifi-densepose-bfld",
# ADR-147: OccWorld thin-client bridge — WorldGraph PersonTrack history →
# OccWorld Python subprocess → TrajectoryPrior injection into pose tracker.
"crates/wifi-densepose-worldmodel",
# worldmodel extracted to ruvnet/worldgraph submodule (consumed via path dep)
# ADR-147 (Phase 5): OccWorld TransVQVAE ported to Candle — native Rust
# inference without Python/IPC overhead. Loaded alongside the Python bridge
# as a faster alternative once Phase-5 weights are available.
@@ -88,6 +87,7 @@ members = [
exclude = [
"crates/wifi-densepose-wasm-edge",
"crates/homecore-plugin-example",
"crates/worldgraph", # ruvnet/worldgraph submodule — its own workspace (geo/worldgraph/worldmodel)
]
[workspace.package]
@@ -215,7 +215,7 @@ wifi-densepose-hardware = { version = "0.3.0", path = "crates/wifi-densepose-har
wifi-densepose-wasm = { version = "0.3.0", path = "crates/wifi-densepose-wasm" }
wifi-densepose-mat = { version = "0.3.0", path = "crates/wifi-densepose-mat" }
wifi-densepose-ruvector = { version = "0.3.0", path = "crates/wifi-densepose-ruvector" }
wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/wifi-densepose-worldmodel" }
wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/worldgraph/wifi-densepose-worldmodel" }
[profile.release]
lto = true
+5 -1
View File
@@ -42,7 +42,11 @@ pub fn router(state: SharedState) -> Router {
.with_state(state)
}
fn build_cors_layer() -> CorsLayer {
/// Build the audited CORS allowlist layer (HC-05). Exposed so the
/// integration binary can apply the SAME allowlist to routes merged in
/// outside `router()` (e.g. the ADR-131 BFF gateway), instead of leaving
/// `/api/homecore/*` and `/api/cal/*` with no CORS coverage at all.
pub fn build_cors_layer() -> CorsLayer {
let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
let origins: Vec<HeaderValue> = match raw {
Some(v) if !v.trim().is_empty() => v
+1 -1
View File
@@ -7,7 +7,7 @@ pub mod state;
pub mod tokens;
pub mod ws;
pub use app::{router, AppState};
pub use app::{build_cors_layer, router, AppState};
pub use error::{ApiError, ApiResult};
pub use state::SharedState;
pub use tokens::LongLivedTokenStore;
+20
View File
@@ -37,6 +37,26 @@ clap = { version = "4", features = ["derive", "env"] }
anyhow = "1"
serde_json = "1"
axum = { version = "0.7", features = ["macros"] }
# Static-file serving for the HOMECORE-UI dashboard (ADR-131) mounted at
# /homecore, request tracing, and the CORS allowlist applied to BOTH the
# homecore-api routes AND the merged BFF gateway routes (ADR-131 §11).
tower-http = { version = "0.6", features = ["fs", "trace", "cors"] }
# BFF gateway (ADR-131 §11): reverse-proxy the calibration API + aggregate
# upstreams. rustls is requested here, but NOTE this is a WORKSPACE-WIDE
# concern: cargo feature-unification means a sibling crate that enables
# reqwest's default `native-tls` re-introduces OpenSSL into the final binary
# regardless of this opt-out. A real "no OpenSSL on the appliance" guarantee
# requires every crate that pulls reqwest to align on rustls-only (tracked in
# CHANGELOG / ADR-131 security note).
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
# Concurrent fan-out of per-bank RoomState fetches in the gateway (§11 perf).
futures = "0.3"
[dev-dependencies]
# Drive the assembled router in integration tests via ServiceExt::oneshot.
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
[features]
default = []
+23
View File
@@ -116,6 +116,29 @@ export RUST_LOG="homecore=debug,homecore_api=info"
| `--db` | `HOMECORE_DB` | `sqlite::memory:` | SQLite path (`:memory:` for ephemeral) |
| `--location-name` | `HOMECORE_LOCATION` | `Home` | Friendly name returned by `/api/config` |
| `--no-recorder` | — | off | Disable SQLite recorder (low-resource deployments) |
| `--ui-dir` | `HOMECORE_UI_DIR` | `<crate>/ui` | HOMECORE-UI asset dir served at `/homecore` (ADR-131); empty disables the mount |
## HOMECORE-UI dashboard (ADR-131)
This binary also serves the **HOMECORE-UI** — the complete operational dashboard
for the two-tier Cognitum stack (v0 Appliance → SEEDs → ESP32 nodes) — at
`/homecore`, alongside the HA-compat `/api` surface. It is a zero-dependency,
no-build-step vanilla TS/JS + CSS frontend living in `ui/`:
```bash
cargo run -p homecore-server # then open http://localhost:8123/homecore/
```
It drives the live `/api` + `/api/websocket` (`subscribe_events`) endpoints; panels
backed by services not in this binary (SEED HTTPS API, calibration ADR-151,
federation ADR-105) render against a DEMO-flagged contract-conformant mock until
those endpoints land (ADR-131 §7.1). Frontend tests + benchmark run under plain
`node` (no `npm install`):
```bash
cd ui && npm test # import graph + render-smoke + interaction (24 checks)
cd ui && npm run bench # bundle budget (~137 KB, ~37× smaller than HA) + render timing
```
## Comparison to Home Assistant
+758
View File
@@ -0,0 +1,758 @@
//! HOMECORE-UI backend-for-frontend (BFF) gateway — ADR-131 §11.
//!
//! `homecore-server` is the single origin the dashboard talks to (§2.1).
//! This module adds the `/api/homecore/*` aggregation namespace and the
//! `/api/cal/*` reverse-proxy to the calibration service, so the browser
//! never makes a cross-origin call and never holds an upstream credential.
//!
//! Implemented now (self-contained, no new external service):
//! * `/api/cal/*` — reverse-proxy → calibration API (ADR-151) [W2]
//! * `GET /api/homecore/rooms` — per-room RoomState, adapted to the UI shape [W2]
//! * `GET /api/homecore/cogs` — COG supervisor over the apps dir [W4]
//! * `GET /api/homecore/appliance` — host metrics from /proc + port probes [W6]
//!
//! Returns a typed `503 upstream_unavailable` for routes whose upstream is
//! a SEED device / appliance daemon not present in this repo (§11.2 / §12):
//! seeds, federation, witness, privacy, settings, automations, events
//! history, hailo, tokens. The front-end renders these as error states
//! (it never falls back to mock in production — §2.2).
//!
//! NOTE: written against the real crate APIs but NOT yet compiled in the
//! authoring environment (no Rust toolchain); run `cargo test -p
//! homecore-server` on a Rust host.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::body::Bytes;
use axum::extract::{Path, RawQuery, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use serde_json::{json, Value};
use homecore_api::auth::BearerAuth;
use homecore_api::SharedState;
/// Static gateway configuration (from CLI/env in `main`).
pub struct GatewayConfig {
/// Base URL of the calibration service (`wifi-densepose calibrate-serve`),
/// e.g. `http://127.0.0.1:8090`. `None` disables the calibration routes.
pub calibration_url: Option<String>,
/// Bearer token for the calibration service (held server-side only).
pub calibration_token: Option<String>,
/// COG install directory the supervisor reads (`/var/lib/cognitum/apps`).
pub apps_dir: PathBuf,
/// Per-proxy timeout so one slow upstream cannot stall the dashboard.
pub timeout: Duration,
}
#[derive(Clone)]
pub struct GatewayState {
pub shared: SharedState,
pub http: reqwest::Client,
pub cfg: Arc<GatewayConfig>,
}
impl GatewayState {
pub fn new(shared: SharedState, cfg: GatewayConfig) -> Self {
let http = reqwest::Client::builder()
.timeout(cfg.timeout)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self { shared, http, cfg: Arc::new(cfg) }
}
}
/// Build the gateway router (state already applied → `Router<()>`), ready
/// to `.merge()` into the main app alongside the homecore-api routes.
pub fn gateway_router(state: GatewayState) -> Router {
Router::new()
// ── calibration reverse-proxy (W2) ──────────────────────────
.route("/api/cal/*path", get(cal_proxy_get).post(cal_proxy_post))
// ── aggregation endpoints (W2 / W4 / W6) ────────────────────
.route("/api/homecore/rooms", get(rooms))
.route("/api/homecore/cogs", get(cogs_list))
.route("/api/homecore/appliance", get(appliance))
// ── upstream-dependent stubs (W3 / W5 / W6): typed 503 ───────
.route("/api/homecore/seeds", get(stub_503))
.route("/api/homecore/seeds/:id", get(stub_503))
.route("/api/homecore/federation", get(stub_503))
.route("/api/homecore/witness", get(stub_503))
.route("/api/homecore/privacy", get(stub_503).post(stub_503))
.route("/api/homecore/settings", get(stub_503))
.route("/api/homecore/automations", get(stub_503).post(stub_503))
// No OTA feed wired yet → "no updates available" is an empty list,
// not an error (so a working COG list is never blanked).
.route("/api/homecore/cogs/updates", get(empty_list))
.route("/api/homecore/hailo", get(stub_503))
.route("/api/homecore/tokens", get(stub_503))
.route("/api/events", get(stub_503))
.with_state(state)
}
// ── auth + typed errors ─────────────────────────────────────────────
async fn require_auth(headers: &HeaderMap, st: &GatewayState) -> Result<(), Response> {
BearerAuth::from_headers(headers, st.shared.tokens())
.await
.map(|_| ())
.map_err(|e| e.into_response())
}
fn typed(status: StatusCode, error: &str, detail: &str) -> Response {
(status, Json(json!({ "error": error, "detail": detail }))).into_response()
}
fn upstream_unavailable(detail: &str) -> Response {
typed(StatusCode::SERVICE_UNAVAILABLE, "upstream_unavailable", detail)
}
fn upstream_timeout(detail: &str) -> Response {
typed(StatusCode::GATEWAY_TIMEOUT, "upstream_timeout", detail)
}
fn bad_request(detail: &str) -> Response {
typed(StatusCode::BAD_REQUEST, "bad_request", detail)
}
/// Reject a proxied wildcard path that could escape the `/api/` scope on the
/// upstream calibration service (path-traversal / confused-deputy SSRF —
/// ADR-131 §11 security review). The privileged server-side calibration bearer
/// is attached by `proxy()`, so a client must NOT be able to redirect that
/// credential outside `…/api/`.
///
/// Returns `Err(400)` when the path (or its percent-decoded form):
/// * is absolute (`/…`) — would replace the `…/api/` base entirely,
/// * contains a backslash (`\`) — Windows/alt-separator traversal,
/// * has any segment equal to `.` or `..` — dot-segment traversal,
/// * still carries `%2e%2e` / `%2f` (single-decode is enough — we reject on
/// the decoded form AND on a residual encoded marker, so double-encoding
/// like `%252e` decodes once to `%2e` and is caught here).
///
/// Legitimate `v1/...` paths (the only shape the UI sends) pass unchanged.
fn validate_proxy_path(path: &str) -> Result<(), Response> {
// 1. Reject on the raw form first (cheap; catches backslash + leading `/`).
if path.starts_with('/') {
return Err(bad_request("proxied path must be relative (leading '/' not allowed)"));
}
if path.contains('\\') {
return Err(bad_request("proxied path must not contain a backslash"));
}
// 2. Percent-decode once and re-check; reject if decoding is invalid.
let decoded = percent_decode_once(path)
.ok_or_else(|| bad_request("proxied path has invalid percent-encoding"))?;
if decoded.starts_with('/') || decoded.contains('\\') {
return Err(bad_request("proxied path resolves to an absolute/traversal path"));
}
// 3. Reject any `.`/`..` segment on BOTH the raw and decoded forms so an
// encoded `%2e%2e%2f` cannot slip a dot-segment past the split.
for form in [path, decoded.as_str()] {
for seg in form.split(['/', '\\']) {
if seg == "." || seg == ".." {
return Err(bad_request("proxied path must not contain '.' or '..' segments"));
}
}
// Defence in depth: a residual encoded traversal marker survived the
// single decode (e.g. originally double-encoded). Reject it outright.
let lower = form.to_ascii_lowercase();
if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") {
return Err(bad_request("proxied path must not contain encoded traversal markers"));
}
}
Ok(())
}
/// Minimal single-pass percent-decoder (no external dep). Returns `None` on a
/// malformed escape so callers can fail closed.
fn percent_decode_once(s: &str) -> Option<String> {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' => {
if i + 2 >= bytes.len() {
return None;
}
let hi = (bytes[i + 1] as char).to_digit(16)?;
let lo = (bytes[i + 2] as char).to_digit(16)?;
out.push((hi * 16 + lo) as u8);
i += 3;
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8(out).ok()
}
/// Routes whose upstream is a SEED device / appliance daemon not present
/// in this repo. Honest 503 until the corresponding §12 wave lands.
async fn stub_503(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
upstream_unavailable("endpoint not yet wired — see ADR-131 §11/§12 (SEED device / appliance upstream)")
}
/// Auth-gated empty-array response (e.g. OTA updates with no feed wired).
async fn empty_list(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
Json(Vec::<Value>::new()).into_response()
}
// ── calibration reverse-proxy (W2) ──────────────────────────────────
async fn cal_proxy_get(
State(st): State<GatewayState>,
headers: HeaderMap,
Path(path): Path<String>,
RawQuery(q): RawQuery,
) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
if let Err(r) = validate_proxy_path(&path) {
return r;
}
let base = match &st.cfg.calibration_url {
Some(u) => u,
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
};
let qs = q.map(|s| format!("?{s}")).unwrap_or_default();
// The wildcard already carries the `v1/...` segment (the UI calls
// `/api/cal/v1/...`), so map `/api/cal/<rest>` → `<base>/api/<rest>`.
let url = format!("{}/api/{}{}", base.trim_end_matches('/'), path, qs);
proxy(&st, st.http.get(&url)).await
}
async fn cal_proxy_post(
State(st): State<GatewayState>,
headers: HeaderMap,
Path(path): Path<String>,
body: Bytes,
) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
if let Err(r) = validate_proxy_path(&path) {
return r;
}
let base = match &st.cfg.calibration_url {
Some(u) => u,
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
};
let url = format!("{}/api/{}", base.trim_end_matches('/'), path);
let rb = st
.http
.post(&url)
.header(header::CONTENT_TYPE, "application/json")
.body(body);
proxy(&st, rb).await
}
/// Send an upstream request (with the server-side calibration token) and
/// stream the response back verbatim, mapping transport failures to typed
/// errors.
async fn proxy(st: &GatewayState, mut rb: reqwest::RequestBuilder) -> Response {
if let Some(tok) = &st.cfg.calibration_token {
rb = rb.bearer_auth(tok);
}
match rb.send().await {
Ok(resp) => {
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let ct = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/json")
.to_string();
match resp.bytes().await {
Ok(b) => {
let mut out = Response::new(axum::body::Body::from(b));
*out.status_mut() = status;
if let Ok(hv) = HeaderValue::from_str(&ct) {
out.headers_mut().insert(header::CONTENT_TYPE, hv);
}
out
}
Err(e) => upstream_unavailable(&format!("calibration body read failed: {e}")),
}
}
Err(e) if e.is_timeout() => upstream_timeout("calibration service timed out"),
Err(e) => upstream_unavailable(&format!("calibration service: {e}")),
}
}
async fn fetch_json(st: &GatewayState, url: &str) -> Result<Value, Response> {
let mut rb = st.http.get(url);
if let Some(tok) = &st.cfg.calibration_token {
rb = rb.bearer_auth(tok);
}
match rb.send().await {
Ok(resp) => resp
.json::<Value>()
.await
.map_err(|e| upstream_unavailable(&format!("calibration JSON parse: {e}"))),
Err(e) if e.is_timeout() => Err(upstream_timeout("calibration service timed out")),
Err(e) => Err(upstream_unavailable(&format!("calibration service: {e}"))),
}
}
// ── rooms aggregation + RoomState adapter (W2 / §11.3) ──────────────
async fn rooms(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
let base = match &st.cfg.calibration_url {
Some(u) => u.trim_end_matches('/').to_string(),
None => return upstream_unavailable("calibration service not configured"),
};
let banks = match fetch_json(&st, &format!("{base}/api/v1/calibration/baselines")).await {
Ok(v) => bank_names(&v),
Err(r) => return r,
};
// Fetch every bank's RoomState concurrently (§11 perf): one slow bank no
// longer serialises behind the others. Order is preserved by collecting in
// the original bank order.
let fetches = banks.into_iter().map(|bank| {
let st = &st;
let base = base.as_str();
async move {
let url = format!("{base}/api/v1/room/state?bank={bank}");
fetch_json(st, &url).await.ok().map(|v| adapt_room_state(&bank, &v))
}
});
let out: Vec<Value> = futures::future::join_all(fetches)
.await
.into_iter()
.flatten()
.collect();
Json(out).into_response()
}
/// Accept either `["living_room", ...]` or `[{ "name"|"id"|"bank": ... }]`.
fn bank_names(v: &Value) -> Vec<String> {
match v {
Value::Array(items) => items
.iter()
.filter_map(|it| match it {
Value::String(s) => Some(s.clone()),
Value::Object(o) => o
.get("name")
.or_else(|| o.get("id"))
.or_else(|| o.get("bank"))
.and_then(|x| x.as_str())
.map(str::to_string),
_ => None,
})
.collect(),
Value::Object(o) => o
.get("baselines")
.map(|b| bank_names(b))
.unwrap_or_default(),
_ => Vec::new(),
}
}
/// Adapt the calibration `RoomState` (Option<SpecialistReading> fields +
/// `vetoed`/`stale`) onto the UI shape (§11.3). `None` → JSON `null`,
/// preserving the not-trained-vs-withheld distinction (§6 invariant 3).
fn adapt_room_state(bank: &str, v: &Value) -> Value {
let chip = |k: &str| -> Value {
match v.get(k) {
Some(r) if !r.is_null() => json!({
"value": r.get("label").and_then(|l| l.as_str()).map(Value::from)
.unwrap_or_else(|| r.get("value").cloned().unwrap_or(Value::Null)),
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
}),
_ => Value::Null,
}
};
let bpm = |k: &str| -> Value {
match v.get(k) {
Some(r) if !r.is_null() => json!({
"value": r.get("value").cloned().unwrap_or(Value::Null),
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
}),
_ => Value::Null,
}
};
let anomaly = match v.get("anomaly") {
Some(r) if !r.is_null() => json!({
"value": r.get("value").cloned().unwrap_or(Value::Null),
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
// §6 invariant 3 (honesty): pass through the REAL anomaly threshold
// from the upstream RoomState if present; if absent, emit null
// (withheld) — never fabricate a constant. The UI treats null as
// withheld, not a fake default.
"threshold": r.get("threshold").cloned().unwrap_or(Value::Null),
}),
_ => Value::Null,
};
json!({
"room_id": bank,
"seeds": [],
"stale": v.get("stale").and_then(|b| b.as_bool()).unwrap_or(false),
"vetoed": v.get("vetoed").and_then(|b| b.as_bool()).unwrap_or(false),
"presence": chip("presence"),
"posture": chip("posture"),
"breathing_bpm": bpm("breathing"),
"heart_bpm": bpm("heartbeat"),
"restlessness": bpm("restlessness"),
"anomaly": anomaly,
})
}
// ── COG supervisor (W4 / §11.6) ─────────────────────────────────────
async fn cogs_list(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
let mut out: Vec<Value> = Vec::new();
let rd = match std::fs::read_dir(&st.cfg.apps_dir) {
Ok(rd) => rd,
Err(_) => return Json(out).into_response(), // no apps dir yet → empty
};
for entry in rd.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let manifest = match std::fs::read_to_string(dir.join("manifest.json")) {
Ok(s) => s,
Err(_) => continue,
};
let m: Value = match serde_json::from_str(&manifest) {
Ok(v) => v,
Err(_) => continue,
};
let id = m
.get("id")
.and_then(|x| x.as_str())
.unwrap_or_else(|| dir.file_name().and_then(|n| n.to_str()).unwrap_or("?"))
.to_string();
let pid = read_pid(&dir, &id);
let alive = pid.map(pid_alive).unwrap_or(false);
let status = if alive { "running" } else { "stopped" };
out.push(json!({
"id": id,
"version": m.get("version").and_then(|x| x.as_str()).unwrap_or("?"),
"arch": m.get("arch").and_then(|x| x.as_str()).unwrap_or("arm"),
"status": status,
"pid": pid,
"sha256_verified": m.get("binary_sha256").is_some(),
"signature_verified": m.get("binary_signature").is_some(),
"hef": m.get("hef").cloned().unwrap_or(Value::Null),
}));
}
Json(out).into_response()
}
fn read_pid(dir: &std::path::Path, id: &str) -> Option<i64> {
for name in [format!("{id}.pid"), "pid".to_string(), "app.pid".to_string()] {
if let Ok(s) = std::fs::read_to_string(dir.join(&name)) {
if let Ok(p) = s.trim().parse::<i64>() {
return Some(p);
}
}
}
None
}
fn pid_alive(pid: i64) -> bool {
if pid <= 0 {
return false;
}
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
// ── appliance metrics (W6 / §11.5) ──────────────────────────────────
async fn appliance(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
let ram = mem_used_pct();
let cpu = cpu_load_pct();
let uptime = uptime_secs();
// Probe the appliance services concurrently with a non-blocking async
// connect under a timeout (§11 perf): previously a sequential blocking
// `std::net::TcpStream::connect_timeout` stalled the whole async handler
// for up to `N * timeout` and parked a Tokio worker thread per probe.
let probes = [
("ruview-mcp-brain", 9876u16),
("cognitum-rvf-agent", 9004),
("ruvector-hailo-worker", 50051),
]
.into_iter()
.map(|(name, port)| {
let timeout = st.cfg.timeout;
async move {
let up = tcp_open("127.0.0.1", port, timeout).await;
json!({ "name": name, "port": port, "status": if up { "running" } else { "unreachable" } })
}
});
let services: Vec<Value> = futures::future::join_all(probes).await;
Json(json!({
"cpu_pct": cpu,
"ram_pct": ram,
"hailo_load_pct": Value::Null, // requires the Hailo runtime stat source (§11.5 APPLIANCE)
"hailo_temp_c": Value::Null,
"uptime_s": uptime,
"services": services,
"event_rate": [],
"channel_capacity": 4096,
"channel_lag": 0,
}))
.into_response()
}
fn read_first_line(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok().and_then(|s| s.lines().next().map(str::to_string))
}
fn uptime_secs() -> Option<u64> {
read_first_line("/proc/uptime")
.and_then(|l| l.split_whitespace().next().map(str::to_string))
.and_then(|s| s.parse::<f64>().ok())
.map(|f| f as u64)
}
fn mem_used_pct() -> Option<f64> {
let txt = std::fs::read_to_string("/proc/meminfo").ok()?;
let mut total = 0f64;
let mut avail = 0f64;
for line in txt.lines() {
let mut it = line.split_whitespace();
match it.next() {
Some("MemTotal:") => total = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0),
Some("MemAvailable:") => avail = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0),
_ => {}
}
}
if total > 0.0 {
Some(((total - avail) / total * 100.0 * 10.0).round() / 10.0)
} else {
None
}
}
fn cpu_load_pct() -> Option<f64> {
// loadavg(1m) / ncpu * 100 — a cheap proxy (no two-sample /proc/stat).
let load = read_first_line("/proc/loadavg")?
.split_whitespace()
.next()?
.parse::<f64>()
.ok()?;
let ncpu = std::thread::available_parallelism().map(|n| n.get() as f64).unwrap_or(1.0);
Some(((load / ncpu * 100.0).min(100.0) * 10.0).round() / 10.0)
}
/// Non-blocking liveness probe: succeeds iff a TCP connection to
/// `host:port` completes within `timeout`. Async so it never parks a Tokio
/// worker thread (unlike the blocking `std::net` connect it replaced).
async fn tcp_open(host: &str, port: u16, timeout: Duration) -> bool {
let addr = format!("{host}:{port}");
matches!(
tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await,
Ok(Ok(_))
)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use homecore::HomeCore;
use homecore_api::{LongLivedTokenStore, SharedState};
use tower::ServiceExt;
fn gw() -> GatewayState {
let shared = SharedState::with_tokens(
HomeCore::new(),
"Test",
"test",
LongLivedTokenStore::allow_any_non_empty(),
);
GatewayState::new(
shared,
GatewayConfig {
calibration_url: None,
calibration_token: None,
apps_dir: PathBuf::from("/nonexistent-apps-dir"),
timeout: Duration::from_millis(200),
},
)
}
async fn send(app: Router, method: &str, path: &str) -> (StatusCode, String) {
let resp = app
.oneshot(
Request::builder()
.method(method)
.uri(path)
.header("authorization", "Bearer dev")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let b = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(status, String::from_utf8_lossy(&b).into_owned())
}
#[tokio::test]
async fn unauthenticated_is_rejected() {
let app = gateway_router(gw());
let resp = app
.oneshot(Request::builder().uri("/api/homecore/cogs").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn cogs_returns_empty_when_apps_dir_missing() {
let (status, body) = send(gateway_router(gw()), "GET", "/api/homecore/cogs").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.trim(), "[]");
}
#[tokio::test]
async fn rooms_503_when_calibration_unconfigured() {
let (status, body) = send(gateway_router(gw()), "GET", "/api/homecore/rooms").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert!(body.contains("upstream_unavailable"));
}
#[tokio::test]
async fn seed_tier_routes_are_typed_503() {
for p in ["/api/homecore/seeds", "/api/homecore/federation", "/api/homecore/witness", "/api/events"] {
let (status, body) = send(gateway_router(gw()), "GET", p).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{p} should be 503");
assert!(body.contains("upstream_unavailable"), "{p} typed body");
}
}
#[tokio::test]
async fn appliance_returns_metrics_json() {
let (status, body) = send(gateway_router(gw()), "GET", "/api/homecore/appliance").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("\"services\""));
assert!(body.contains("\"ram_pct\""));
}
#[test]
fn adapt_room_state_maps_fields_and_preserves_null() {
// breathing/heartbeat rename; None → null; anomaly gets a threshold.
let cal = json!({
"presence": {"kind":"Presence","value":1.0,"confidence":0.9,"label":"occupied"},
"posture": {"kind":"Posture","value":2.0,"confidence":0.8,"label":"lying"},
"breathing": {"kind":"Breathing","value":12.0,"confidence":0.7,"label":null},
"heartbeat": null,
"restlessness": {"kind":"Restlessness","value":0.1,"confidence":0.6,"label":null},
"anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"label":null},
"vetoed": false, "stale": true
});
let ui = adapt_room_state("bedroom_1", &cal);
assert_eq!(ui["room_id"], "bedroom_1");
assert_eq!(ui["stale"], true);
assert_eq!(ui["presence"]["value"], "occupied");
assert_eq!(ui["breathing_bpm"]["value"], 12.0);
assert!(ui["heart_bpm"].is_null(), "None heartbeat must map to null (not trained)");
// §6 invariant 3: upstream RoomState carries no threshold here, so the
// adapter must emit null (withheld) — NOT a fabricated constant.
assert!(
ui["anomaly"]["threshold"].is_null(),
"absent upstream threshold must surface as null, never a hardcoded value"
);
}
#[test]
fn adapt_room_state_passes_through_real_anomaly_threshold() {
// When the upstream RoomState DOES carry a real threshold, it must be
// forwarded verbatim (no fabrication, no override).
let cal = json!({
"anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"threshold":0.73},
});
let ui = adapt_room_state("bedroom_1", &cal);
assert_eq!(ui["anomaly"]["threshold"], 0.73, "real threshold must pass through");
}
#[test]
fn validate_proxy_path_allows_legit_v1_paths() {
// The only shape the UI sends must pass unchanged.
for ok in [
"v1/room/state",
"v1/calibration/baselines",
"v1/enroll/status",
"v1/room/state?bank=living_room", // query is split off before this fn
] {
// strip any query the caller would have removed; we only validate path
let p = ok.split('?').next().unwrap();
assert!(validate_proxy_path(p).is_ok(), "{p} should be allowed");
}
}
#[test]
fn validate_proxy_path_rejects_traversal_variants() {
for bad in [
"v1/../../x", // dot-segment traversal
"../etc/passwd", // parent escape
"/etc/passwd", // absolute
"v1\\..\\..\\x", // backslash traversal
"..%2f..%2fx", // encoded slash
"%2e%2e/x", // encoded dot-dot
"v1/%2e%2e%2fadmin", // mixed encoded traversal
"%252e%252e/x", // double-encoded (residual %2e after one decode)
] {
assert!(validate_proxy_path(bad).is_err(), "{bad} must be rejected");
}
}
#[tokio::test]
async fn cal_proxy_rejects_traversal_with_400_before_upstream() {
// `gw()` has calibration_url=None: a path that reached URL-building
// would 503 ("not configured"). A 400 here proves the traversal is
// rejected BEFORE any upstream request is even attempted.
for (method, path) in [
("GET", "/api/cal/v1/../../x"),
("GET", "/api/cal/..%2f..%2fx"),
("GET", "/api/cal/%2e%2e/x"),
("POST", "/api/cal/v1/../../x"),
] {
let (status, body) = send(gateway_router(gw()), method, path).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {path} must be 400");
assert!(body.contains("bad_request"), "{method} {path} typed 400 body");
assert!(
!body.contains("upstream_unavailable"),
"{method} {path} must NOT reach the upstream-config branch"
);
}
}
#[tokio::test]
async fn cal_proxy_allows_legit_path_through_to_upstream_config() {
// A legitimate v1 path passes validation and then hits the
// "not configured" 503 (proving it was NOT blocked as traversal).
let (status, body) = send(gateway_router(gw()), "GET", "/api/cal/v1/room/state").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert!(body.contains("upstream_unavailable"), "legit path should reach upstream branch");
}
#[test]
fn bank_names_accepts_strings_and_objects() {
assert_eq!(bank_names(&json!(["a", "b"])), vec!["a", "b"]);
assert_eq!(bank_names(&json!([{"name":"x"}, {"id":"y"}])), vec!["x", "y"]);
assert_eq!(bank_names(&json!({"baselines":["z"]})), vec!["z"]);
}
}
+226 -2
View File
@@ -27,7 +27,7 @@ use tracing::{info, warn};
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
use homecore::service::FnHandler;
use homecore_api::{router, LongLivedTokenStore, SharedState};
use homecore_api::{build_cors_layer, router, LongLivedTokenStore, SharedState};
use homecore_assist::pipeline::default_pipeline;
use homecore_assist::RegexIntentRecognizer;
use homecore_automation::AutomationEngine;
@@ -35,6 +35,18 @@ use homecore_hap::{bridge::HapBridge, mdns::HapServiceRecord};
use homecore_plugins::{InProcessRuntime, PluginRegistry};
use homecore_recorder::Recorder;
use axum::Router;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
mod gateway;
use gateway::{GatewayConfig, GatewayState};
/// Compile-time default location of the HOMECORE-UI assets (ADR-131).
/// Works in dev/CI; the appliance overrides with `--ui-dir` /
/// `HOMECORE_UI_DIR`.
const DEFAULT_UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ui");
#[derive(Parser, Debug, Clone)]
#[command(name = "homecore-server", version)]
struct Cli {
@@ -42,6 +54,30 @@ struct Cli {
#[arg(long, env = "HOMECORE_BIND", default_value = "0.0.0.0:8123")]
bind: SocketAddr,
/// Directory of the HOMECORE-UI dashboard assets, served at
/// `/homecore` (ADR-131). Empty string disables the UI mount.
#[arg(long, env = "HOMECORE_UI_DIR", default_value = DEFAULT_UI_DIR)]
ui_dir: String,
/// Base URL of the calibration service (`wifi-densepose calibrate-serve`),
/// reverse-proxied by the BFF gateway at `/api/cal/*` (ADR-131 §11).
/// Unset → calibration/room endpoints return a typed 503.
#[arg(long, env = "HOMECORE_CALIBRATION_URL")]
calibration_url: Option<String>,
/// Bearer token for the calibration service (held server-side only,
/// never exposed to the browser — ADR-131 §11.10).
#[arg(long, env = "HOMECORE_CALIBRATION_TOKEN")]
calibration_token: Option<String>,
/// COG install directory the gateway's supervisor reads (ADR-131 §11.6).
#[arg(long, env = "HOMECORE_APPS_DIR", default_value = "/var/lib/cognitum/apps")]
apps_dir: String,
/// Per-upstream proxy timeout in milliseconds (ADR-131 §11.1).
#[arg(long, env = "HOMECORE_GATEWAY_TIMEOUT_MS", default_value_t = 2000)]
gateway_timeout_ms: u64,
/// SQLite recorder DB path. Use `:memory:` for an ephemeral run.
#[arg(long, env = "HOMECORE_DB", default_value = "sqlite::memory:")]
db: String,
@@ -174,15 +210,59 @@ async fn main() -> Result<()> {
env!("CARGO_PKG_VERSION"),
tokens,
);
let app = router(api_state);
// BFF gateway (ADR-131 §11): single-origin aggregation of the
// calibration API + SEED/appliance tiers. Shares the same token store
// for auth; upstream credentials stay server-side.
let gw = GatewayState::new(
api_state.clone(),
GatewayConfig {
calibration_url: cli.calibration_url.clone(),
calibration_token: cli.calibration_token.clone(),
apps_dir: std::path::PathBuf::from(&cli.apps_dir),
timeout: std::time::Duration::from_millis(cli.gateway_timeout_ms),
},
);
// Merge the HA-compat API + UI mount with the BFF gateway, THEN apply the
// audited CORS allowlist + request tracing to the WHOLE surface. The
// gateway routes (`/api/homecore/*`, `/api/cal/*`) are merged in outside
// `router()`'s own layers, so without this outer layer they would have NO
// CORS coverage and would not be traced (ADR-131 §11 review). Applying CORS
// again to the homecore-api routes is idempotent.
let app = build_app(api_state, &cli.ui_dir)
.merge(gateway::gateway_router(gw))
.layer(build_cors_layer())
.layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
info!("HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind);
info!(
"HOMECORE BFF gateway active: /api/homecore/* + /api/cal/* (calibration_url={:?})",
cli.calibration_url
);
if !cli.ui_dir.trim().is_empty() {
info!("HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}", cli.bind, cli.ui_dir);
} else {
info!("HOMECORE-UI mount disabled (--ui-dir empty)");
}
// Run forever (until SIGINT). axum::serve handles graceful shutdown.
axum::serve(listener, app).await?;
Ok(())
}
/// Assemble the full HTTP surface: the HA-compat REST + WS router
/// (ADR-130) plus the HOMECORE-UI static mount at `/homecore` (ADR-131).
/// Split out from `main` so it is exercised by the integration tests.
fn build_app(api_state: SharedState, ui_dir: &str) -> Router {
let app = router(api_state);
if ui_dir.trim().is_empty() {
return app;
}
// ServeDir serves index.html for the directory root, so /homecore/
// returns the dashboard and /homecore/js/... /homecore/css/... map
// straight onto the asset tree the relative <link>/<script> tags use.
app.nest_service("/homecore", ServeDir::new(ui_dir))
}
fn init_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
@@ -304,3 +384,147 @@ fn seed_default_entities(hc: &HomeCore) {
info!("State machine seeded with {} default entit{}", total,
if total == 1 { "y" } else { "ies" });
}
#[cfg(test)]
mod ui_tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use homecore::HomeCore;
use homecore_api::{LongLivedTokenStore, SharedState};
use tower::ServiceExt; // for `oneshot`
fn test_state() -> SharedState {
SharedState::with_tokens(
HomeCore::new(),
"Test".to_string(),
"test",
LongLivedTokenStore::allow_any_non_empty(),
)
}
async fn get(app: Router, path: &str) -> (StatusCode, String) {
let resp = app
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 4 * 1024 * 1024)
.await
.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
#[tokio::test]
async fn ui_index_is_served_at_homecore() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
let (status, body) = get(app, "/homecore/").await;
assert_eq!(status, StatusCode::OK, "GET /homecore/ should serve index.html");
assert!(body.contains("HOMECORE"), "index.html should mention HOMECORE");
assert!(body.contains("./js/app.js"), "index.html should bootstrap app.js");
}
#[tokio::test]
async fn ui_design_tokens_are_served() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
let (status, body) = get(app, "/homecore/css/tokens.css").await;
assert_eq!(status, StatusCode::OK);
// §3.1 invariant: the exact production palette must be present.
assert!(body.contains("#4ecdc4"), "--cyan token must be present");
assert!(body.contains("--purple"), "--purple token must be present");
}
#[tokio::test]
async fn ui_panels_are_served() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
for p in ["dashboard", "rooms", "calibration", "fleet", "seed-detail",
"entities", "cogs", "events", "audit", "settings"] {
let (status, _) = get(app.clone(), &format!("/homecore/js/panels/{p}.js")).await;
assert_eq!(status, StatusCode::OK, "panel {p}.js should be served");
}
}
#[tokio::test]
async fn api_still_works_alongside_ui_mount() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
// `GET /api/` is auth-gated (HC-API-AUTH-01); send a bearer.
let resp = app
.oneshot(
Request::builder()
.uri("/api/")
.header("authorization", "Bearer dev")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert_eq!(status, StatusCode::OK, "the HA-compat API must coexist with the UI mount");
assert!(body.contains("API running"));
}
#[tokio::test]
async fn ui_mount_can_be_disabled() {
let app = build_app(test_state(), "");
let (status, _) = get(app, "/homecore/").await;
assert_eq!(status, StatusCode::NOT_FOUND, "empty --ui-dir disables the mount");
}
/// Build the SAME merged + layered surface `main()` serves: API + UI mount
/// + BFF gateway, with the audited CORS allowlist + tracing applied to the
/// whole thing. Used to prove the gateway routes are CORS-covered.
fn full_app(state: SharedState) -> Router {
use crate::gateway::{GatewayConfig, GatewayState};
let gw = GatewayState::new(
state.clone(),
GatewayConfig {
calibration_url: None,
calibration_token: None,
apps_dir: std::path::PathBuf::from("/nonexistent-apps-dir"),
timeout: std::time::Duration::from_millis(200),
},
);
build_app(state, "")
.merge(crate::gateway::gateway_router(gw))
.layer(homecore_api::build_cors_layer())
.layer(TraceLayer::new_for_http())
}
#[tokio::test]
async fn gateway_routes_are_cors_covered_after_merge() {
// A CORS preflight from the Vite dev origin must succeed (echo the
// allowed origin) for a GATEWAY route — proving the outer CORS layer
// covers the merged routes, not just the homecore-api ones.
let app = full_app(test_state());
let resp = app
.oneshot(
Request::builder()
.method("OPTIONS")
.uri("/api/homecore/appliance")
.header("origin", "http://localhost:5173")
.header("access-control-request-method", "GET")
.header("access-control-request-headers", "authorization")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
// CORS preflight handled by the layer → 2xx with the origin echoed back.
assert!(
resp.status().is_success(),
"gateway preflight should succeed, got {}",
resp.status()
);
let allow_origin = resp
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(
allow_origin, "http://localhost:5173",
"gateway route must echo the allowlisted dev origin"
);
}
}
+223
View File
@@ -0,0 +1,223 @@
/*
* HOMECORE-UI component styling ADR-131 §3.3.
* Uses only the §3.1 tokens (tokens.css). Polished composition: real
* header, icon sidenav, elevated cards, refined metrics/pills/bars.
*/
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background:
radial-gradient(1100px 600px at 78% -8%, rgba(78,205,196,0.06), transparent 60%),
radial-gradient(900px 500px at 12% 110%, rgba(167,139,250,0.05), transparent 55%),
var(--bg);
background-attachment: fixed;
color: var(--t1);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.mono { font-family: var(--mono); font-size: 0.92em; }
.t2 { color: var(--t2); } .t3 { color: var(--t3); }
.cyan { color: var(--cyan); } .green { color: var(--green); } .amber { color: var(--amber); }
.red { color: var(--red); } .purple { color: var(--purple); }
.hidden { display: none !important; }
/* ── top header ─────────────────────────────────────────────────── */
.topnav {
display: flex; align-items: center; gap: 16px;
background: rgba(17,22,39,0.85);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
padding: 0 22px; height: 60px;
position: sticky; top: 0; z-index: 30;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand .logo {
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 8px;
background: linear-gradient(135deg, var(--cyan), var(--purple));
color: var(--bg); font-weight: 800; font-size: 17px;
box-shadow: 0 2px 10px rgba(78,205,196,0.25);
}
.brand .brand-name { font-weight: 700; font-size: 16px; letter-spacing: 0.3px; color: var(--t1); }
.brand .brand-sep { color: var(--t3); font-size: 16px; font-weight: 300; }
.brand .brand-tag {
font-weight: 700; font-size: 12px; letter-spacing: 1px;
color: var(--cyan); background: var(--cyan-d);
border-radius: 6px; padding: 3px 9px; text-transform: uppercase;
}
.nav-spacer { flex: 1; }
/* ── layout ─────────────────────────────────────────────────────── */
.shell { display: flex; min-height: calc(100vh - 60px); }
.sidenav {
width: 224px; flex-shrink: 0;
background: rgba(17,22,39,0.45);
border-right: 1px solid var(--border);
padding: 16px 12px; display: flex; flex-direction: column; gap: 3px;
}
.sidenav a {
display: flex; align-items: center; gap: 11px;
padding: 9px 12px; border-radius: 9px;
color: var(--t2); text-decoration: none; font-size: 13.5px; font-weight: 500;
transition: background .12s, color .12s;
}
.sidenav a .ico { width: 18px; text-align: center; font-size: 14px; color: var(--t3); }
.sidenav a:hover { color: var(--t1); background: var(--card); }
.sidenav a.active { color: var(--cyan); background: var(--cyan-d); }
.sidenav a.active .ico { color: var(--cyan); }
.content { flex: 1; padding: 26px 30px; max-width: 1320px; width: 100%; }
@media (max-width: 880px) {
.shell { flex-direction: column; }
.sidenav { width: 100%; flex-direction: row; overflow-x: auto; padding: 8px; gap: 6px; border-right: none; border-bottom: 1px solid var(--border); }
.sidenav a .lbl { white-space: nowrap; }
.content { padding: 18px; }
}
/* ── headings / section header ──────────────────────────────────── */
h1 { font-size: 23px; margin: 0 0 3px; font-weight: 700; letter-spacing: -0.2px; }
h2 { font-size: 15px; margin: 0 0 14px; font-weight: 650; color: var(--t1); }
h3 { font-size: 12px; margin: 0 0 8px; color: var(--t2); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.section-header { position: relative; padding: 14px 0 4px; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
.section-header::before { content: ''; position: absolute; top: 0; left: 0; width: 56px; height: 3px; border-radius: 3px; background: linear-gradient(90deg, var(--cyan), var(--purple)); }
.section-header .sub { color: var(--t2); font-size: 13px; margin-top: 2px; }
/* ── cards ──────────────────────────────────────────────────────── */
.card {
background: linear-gradient(180deg, rgba(30,37,64,0.35), var(--card));
border: 1px solid var(--border);
border-radius: var(--r);
padding: 20px 22px; margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0,0,0,0.25);
}
.card > h2:first-child { margin-bottom: 16px; }
.card.tint-amber { background: var(--amber-d); border-color: rgba(212,165,116,0.4); }
.card.tint-red { background: var(--red-d); border-color: rgba(224,96,96,0.4); }
.card.tint-green { background: var(--green-d); border-color: rgba(107,203,119,0.4); }
.card.clickable { cursor: pointer; transition: transform .12s, border-color .12s, box-shadow .12s; }
.card.clickable:hover { transform: translateY(-2px); border-color: rgba(78,205,196,0.4); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
/* ── pills / badges ─────────────────────────────────────────────── */
.pill {
display: inline-flex; align-items: center; gap: 5px;
border-radius: 6px; padding: 3px 9px;
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
line-height: 1.5; white-space: nowrap;
}
.pill::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.9; }
.pill.cyan { background: var(--cyan-d); color: var(--cyan); }
.pill.green { background: var(--green-d); color: var(--green); }
.pill.amber { background: var(--amber-d); color: var(--amber); }
.pill.red { background: var(--red-d); color: var(--red); }
.pill.purple { background: var(--purple-d); color: var(--purple); }
.pill.grey { background: rgba(80,88,114,0.18); color: var(--t2); }
.method { border-radius: 5px; padding: 2px 7px; font-size: 10.5px; font-weight: 700; }
.method.get { background: var(--green-d); color: var(--green); }
.method.post { background: var(--amber-d); color: var(--amber); }
.method.auth { background: var(--purple-d); color: var(--purple); }
/* ── buttons ────────────────────────────────────────────────────── */
.btn { font-family: var(--font); font-size: 12.5px; font-weight: 600; border-radius: 8px; padding: 8px 15px; cursor: pointer; border: none; transition: filter .12s, background .12s, transform .05s; }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--cyan); color: var(--bg); }
.btn.primary:hover { filter: brightness(1.1); box-shadow: 0 4px 14px rgba(78,205,196,0.3); }
.btn.ghost { background: rgba(255,255,255,0.02); border: 1px solid var(--border); color: var(--t1); }
.btn.ghost:hover { background: var(--card-h); border-color: var(--t3); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── metric cards ───────────────────────────────────────────────── */
.metric-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px; }
.metric { position: relative; background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 16px 18px; overflow: hidden; }
.metric::after { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--cyan); opacity: 0.6; }
.metric .ico { font-size: 15px; color: var(--t3); }
.metric .val { font-size: 28px; font-weight: 700; color: var(--cyan); margin: 8px 0 2px; letter-spacing: -0.5px; line-height: 1; }
.metric .val.green { color: var(--green); }
.metric .lbl { color: var(--t2); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.4px; }
/* ── grids ──────────────────────────────────────────────────────── */
.grid { display: grid; gap: 14px; }
.grid.cols-2 { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
.grid.cols-3 { grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); }
/* ── bars ───────────────────────────────────────────────────────── */
.bar { background: rgba(0,0,0,0.3); border-radius: 5px; height: 8px; overflow: hidden; width: 100%; }
.bar > span { display: block; height: 100%; background: var(--cyan); border-radius: 5px; transition: width .3s; }
.bar > span.green { background: var(--green); } .bar > span.amber { background: var(--amber); } .bar > span.red { background: var(--red); }
.conf-bar { display: inline-block; width: 56px; height: 6px; background: rgba(0,0,0,0.3); border-radius: 3px; vertical-align: middle; overflow: hidden; }
.conf-bar > span { display: block; height: 100%; background: var(--cyan); }
.conf-bar > span.amber { background: var(--amber); }
/* ── provenance badge ───────────────────────────────────────────── */
.prov { display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono); font-size: 10.5px; color: var(--t2); background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; }
.prov .arr { color: var(--t3); } .prov .hailo { color: var(--purple); font-weight: 600; }
/* ── rows / kv ──────────────────────────────────────────────────── */
.row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--border); gap: 12px; }
.row:last-child { border-bottom: none; }
.row .k { color: var(--t2); font-size: 12.5px; }
.row .v { color: var(--t1); }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 9px 16px; align-items: center; }
.kv .k { color: var(--t2); font-size: 12.5px; }
.kv .v { color: var(--t1); }
pre.json, pre.log { font-family: var(--mono); font-size: 12px; background: rgba(0,0,0,0.35); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; overflow: auto; max-height: 320px; color: var(--t1); white-space: pre-wrap; word-break: break-word; }
svg.spark { display: block; }
/* ── banners ────────────────────────────────────────────────────── */
.banner { border-radius: 9px; padding: 11px 15px; margin-bottom: 14px; font-size: 13px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.banner::before { font-weight: 700; }
.banner.amber { background: var(--amber-d); color: var(--amber); border: 1px solid rgba(212,165,116,0.4); }
.banner.amber::before { content: '▲'; }
.banner.red { background: var(--red-d); color: var(--red); border: 1px solid rgba(224,96,96,0.4); }
.banner.red::before { content: '●'; }
.banner.green { background: var(--green-d); color: var(--green); border: 1px solid rgba(107,203,119,0.4); }
.banner.green::before { content: '✓'; }
/* ── lag indicator ──────────────────────────────────────────────── */
.lag { font-size: 12px; display: inline-flex; align-items: center; gap: 7px; color: var(--t2); }
.lag .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); display: inline-block; box-shadow: 0 0 0 3px var(--green-d); }
.lag .dot.warn { background: var(--amber); box-shadow: 0 0 0 3px var(--amber-d); }
.lag .dot.err { background: var(--red); box-shadow: 0 0 0 3px var(--red-d); }
/* ── wizard stepper ─────────────────────────────────────────────── */
.stepper { display: flex; gap: 10px; margin-bottom: 22px; flex-wrap: wrap; }
.step-pill { display: flex; align-items: center; gap: 9px; padding: 8px 15px; border-radius: 24px; border: 1px solid var(--border); color: var(--t3); font-size: 12.5px; font-weight: 600; }
.step-pill .n { width: 22px; height: 22px; border-radius: 50%; background: rgba(0,0,0,0.3); display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; }
.step-pill.active { color: var(--cyan); border-color: var(--cyan); background: var(--cyan-d); }
.step-pill.active .n { background: var(--cyan); color: var(--bg); }
.step-pill.done { color: var(--green); border-color: rgba(107,203,119,0.4); }
.step-pill.done .n { background: var(--green); color: var(--bg); }
/* ── slide-over ─────────────────────────────────────────────────── */
.slideover-back { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 40; backdrop-filter: blur(2px); }
.slideover { position: fixed; top: 0; right: 0; bottom: 0; width: 480px; max-width: 92vw; background: var(--card); border-left: 1px solid var(--border); z-index: 41; padding: 26px; overflow-y: auto; box-shadow: -12px 0 40px rgba(0,0,0,0.45); }
.slideover .close { float: right; cursor: pointer; color: var(--t2); font-size: 16px; }
.slideover .close:hover { color: var(--t1); }
/* ── inputs ─────────────────────────────────────────────────────── */
.search { width: 100%; background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 9px; padding: 10px 13px; color: var(--t1); font-family: var(--font); font-size: 13px; }
.search::placeholder { color: var(--t3); }
.search:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 0 3px var(--cyan-d); }
input.inline { background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 6px; padding: 5px 9px; color: var(--t1); font-family: var(--mono); font-size: 12px; width: 92px; }
input.inline:focus { outline: none; border-color: var(--cyan); }
select.inline { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px; color: var(--t1); font-family: var(--font); font-size: 13px; }
textarea.inline { background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: 8px; padding: 10px; color: var(--t1); font-family: var(--mono); font-size: 12px; width: 100%; }
/* ── collapsible ────────────────────────────────────────────────── */
.collapsible > .head { cursor: pointer; display: flex; align-items: center; gap: 9px; padding: 4px 0; user-select: none; }
.collapsible > .head::before { content: '▸'; color: var(--t3); transition: transform .15s; font-size: 11px; }
.collapsible.open > .head::before { transform: rotate(90deg); }
.muted-empty { color: var(--t3); font-style: italic; padding: 10px 0; }
.shield.ok { color: var(--green); } .shield.bad { color: var(--red); }
.flex { display: flex; gap: 10px; align-items: center; }
.flex.wrap { flex-wrap: wrap; } .spread { justify-content: space-between; } .gap-sm { gap: 6px; }
.mt { margin-top: 14px; } .mb { margin-bottom: 14px; }
small.ts { color: var(--t3); font-size: 11.5px; }
strong.mono { font-size: 13px; color: var(--t1); }
@@ -0,0 +1,34 @@
/*
* HOMECORE-UI design tokens ADR-131 §3.1 / §3.2.
*
* These values are extracted verbatim from the production Cognitum
* platform (seed.cognitum.one/store + /status). DO NOT introduce new
* colours, typefaces, or border radii ADR-131 §3.3 invariant. A user
* navigating from the Cog Store into HOMECORE must not notice a seam.
*/
:root {
/* §3.1 colour palette */
--bg: #0a0e1a; /* page background (very dark navy) */
--bg2: #111627; /* secondary background / nav strip */
--card: #171d30; /* card / panel surface */
--card-h: #1e2540; /* card hover state */
--border: #252d45; /* all border strokes (~0.67px, subtle) */
--t1: #e0e4f0; /* primary text (near-white) */
--t2: #8890a8; /* secondary / muted text */
--t3: #505872; /* tertiary / disabled text */
--cyan: #4ecdc4; /* primary action colour */
--cyan-d: rgba(78,205,196,0.15);
--green: #6bcb77; /* success / online / healthy */
--green-d: rgba(107,203,119,0.15);
--amber: #d4a574; /* warning / stale / degraded */
--amber-d: rgba(212,165,116,0.15);
--red: #e06060; /* error / offline / veto */
--red-d: rgba(224,96,96,0.15);
--purple: #a78bfa; /* informational / epoch / chain */
--purple-d: rgba(167,139,250,0.15);
--r: 10px; /* standard border radius */
/* §3.2 typography */
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
--mono: 'Cascadia Code', 'Fira Code', Consolas, monospace;
}
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HOMECORE — Cognitum Appliance</title>
<meta name="description" content="HOMECORE operational dashboard for the two-tier Cognitum stack (ADR-131)." />
<link rel="stylesheet" href="./css/tokens.css" />
<link rel="stylesheet" href="./css/app.css" />
</head>
<body>
<div id="app">
<noscript>HOMECORE-UI requires JavaScript.</noscript>
</div>
<script type="module" src="./js/app.js"></script>
</body>
</html>
+197
View File
@@ -0,0 +1,197 @@
// HOMECORE-UI API client — ADR-131 §2 / §11.
//
// Production path: every method issues a SAME-ORIGIN request to the
// homecore-server BFF gateway (§2.1). There is NO mock fallback in
// production — a failed upstream rejects, and the panel renders a typed
// error/empty state (§2.2, §11.11). The in-browser mock layer is a
// DEV-ONLY fixture, reachable only when demo mode is on:
// ?demo=1 in the URL, globalThis.HOMECORE_UI_DEMO, or
// localStorage 'homecore_demo' = '1'.
//
// Gateway route map: ADR-131 §11.2.
// DEV-ONLY fixtures. Loaded via DYNAMIC import so a production bundle that
// never enters demo mode never pulls mock.js into the graph (§2.2). Cached
// after first use so repeated demo calls don't re-import.
let _mock = null;
async function loadMock() {
if (!_mock) _mock = await import('./mock.js');
return _mock;
}
const demoFlags = {};
/** Demo mode = explicit dev opt-in only; never the production default. */
export function demoMode() {
try { if (typeof location !== 'undefined' && /[?&]demo=1(\b|&|$)/.test(location.search || '')) return true; } catch {}
try { if (typeof globalThis !== 'undefined' && globalThis.HOMECORE_UI_DEMO) return true; } catch {}
try { if (typeof localStorage !== 'undefined' && localStorage.getItem('homecore_demo') === '1') return true; } catch {}
return false;
}
export const api = {
base: '',
token: () => { try { return localStorage.getItem('homecore_token') || 'dev-token'; } catch { return 'dev-token'; } },
isDemo: (key) => !!demoFlags[key],
anyDemo: () => demoMode() && Object.keys(demoFlags).length > 0,
demoMode,
async _get(path) {
const r = await fetch(this.base + path, { headers: { Authorization: 'Bearer ' + this.token() } });
if (!r.ok) throw httpError(path, r.status);
return r.json();
},
async _post(path, body) {
const r = await fetch(this.base + path, {
method: 'POST',
headers: { Authorization: 'Bearer ' + this.token(), 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
if (!r.ok) throw httpError(path, r.status);
return r.json();
},
async _delete(path) {
const r = await fetch(this.base + path, { method: 'DELETE', headers: { Authorization: 'Bearer ' + this.token() } });
if (!r.ok) throw httpError(path, r.status);
return r.status === 204 ? {} : r.json();
},
// demo-gated data accessor: real gateway GET in prod, mock fixture in demo.
// The mock module is dynamically imported ONLY on the demo branch, so prod
// never loads it. `mockFn` receives the loaded module.
async _data(key, path, mockFn) {
if (demoMode()) { demoFlags[key] = true; return mockFn(await loadMock()); }
delete demoFlags[key];
return this._get(path);
},
// ── homecore-api (real, already served) ───────────────────────────
async config() { return this._get('/api/config'); },
async states() {
if (demoMode()) { demoFlags.states = true; return demoEntities(); }
delete demoFlags.states;
return this._get('/api/states');
},
async services() { return this._data('services', '/api/services', () => []); },
async callService(domain, service, data) { return this._post(`/api/services/${domain}/${service}`, data); },
async setState(entityId, state, attributes) { return this._post(`/api/states/${entityId}`, { state, attributes: attributes || {} }); },
// ── gateway /api/homecore/* + /api/events (§11.2) ─────────────────
async appliance() { return this._data('appliance', '/api/homecore/appliance', (m) => m.applianceHealth()); },
async seeds() { return this._data('fleet', '/api/homecore/seeds', (m) => m.seeds()); },
async seed(id) { return this._data('fleet', '/api/homecore/seeds/' + encodeURIComponent(id), (m) => m.seed(id)); },
async esp32Warnings() {
if (demoMode()) { demoFlags.fleet = true; return (await loadMock()).esp32Warnings(); }
const seeds = await this._get('/api/homecore/seeds');
return seeds.flatMap((s) => (s.warnings || []).map((issue) => ({ node_id: s.device_id, seed: s.device_id, issue })));
},
async cogs() { return this._data('cogs', '/api/homecore/cogs', (m) => m.cogs()); },
async cogUpdates() { return this._data('cogs', '/api/homecore/cogs/updates', (m) => m.cogUpdates()); },
async hailo() { return this._data('cogs', '/api/homecore/hailo', (m) => ({ worker: 'connected', cogs: m.cogs().filter((c) => c.arch === 'hailo10') })); },
async roomStates() { return this._data('rooms', '/api/homecore/rooms', (m) => m.roomStates()); },
async federation() { return this._data('fleet', '/api/homecore/federation', (m) => m.federation()); },
async witnessLog(page = 0, size = 12) { return this._data('audit', `/api/homecore/witness?page=${page}&size=${size}`, (m) => m.witnessLog(page, size)); },
async privacyModes() { return this._data('audit', '/api/homecore/privacy', (m) => m.privacyModes()); },
async setPrivacy(seed, modeValue) { if (demoMode()) return { seed, mode: modeValue }; return this._post('/api/homecore/privacy', { seed, mode: modeValue }); },
async eventHistory(n = 40) { return this._data('events', `/api/events?limit=${n}`, (m) => m.recentEvents(n)); },
recentEvents(n) { return this.eventHistory(n); }, // back-compat alias (async)
async settings() { return this._data('settings', '/api/homecore/settings', (m) => m.settings()); },
async automations() { return this._data('automations', '/api/homecore/automations', () => []); },
async saveAutomation(a) { if (demoMode()) return a; return this._post('/api/homecore/automations', a); },
async tokens() { return this._data('settings', '/api/homecore/tokens', (m) => m.settings().tokens); },
// calibration (ADR-151) — real proxy in prod, simulated in demo.
calibration: makeCalibration(),
};
function httpError(path, status) {
const e = new Error(`${path} → HTTP ${status}`);
e.status = status;
e.upstreamUnavailable = status === 503 || status === 504;
return e;
}
// Demo-only entity fixture (prod path uses real GET /api/states).
function demoEntities() {
return [
{ entity_id: 'sensor.living_room_presence', state: 'true', attributes: { friendly_name: 'Living Room Presence', source: 'esp32-lr-01', seed: 'seed-livingroom-a1' }, last_changed: new Date().toISOString(), last_updated: new Date().toISOString(), context: { id: 'ctx-1', user_id: null, parent_id: null } },
{ entity_id: 'sensor.bedroom_1_breathing_rate', state: '14.5', attributes: { friendly_name: 'Bedroom 1 Breathing Rate', unit_of_measurement: 'BPM', source: 'esp32-br1-01', seed: 'seed-bedroom-1' }, last_changed: new Date().toISOString(), last_updated: new Date().toISOString(), context: { id: 'ctx-2', user_id: null, parent_id: 'ctx-1' } },
];
}
/**
* Resolve an entity's tier provenance (§4.4 / §11.9). Prefers the
* explicit `attributes.seed`/`attributes.cog` lineage that integrations
* are expected to stamp; falls back to parsing the ESP32 node id. In demo
* mode it may consult the mock node registry. Missing lineage 'unknown'
* (never fabricated).
*/
export function entityProvenance(entity) {
const attrs = (entity && entity.attributes) || {};
const src = String(attrs.source || '');
const nodeMatch = src.match(/esp32[-\w]*/i);
const node = attrs.node || (nodeMatch ? nodeMatch[0] : null);
let seed = attrs.seed || null;
// Demo-only enrichment: consult the mock node registry IF it has already
// been dynamically loaded by a prior demo data call (this fn is sync, so it
// cannot await the import). Prod never has `_mock` set → seed stays null
// (never fabricated).
if (!seed && demoMode() && node && _mock) {
const cfg = _mock.settings().esp32.find((n) => n.node_id === node);
seed = cfg ? cfg.seed : null;
}
const hailo = /hailo|pose/i.test(src) || /hailo/i.test(String(attrs.cog || ''));
const cog = attrs.cog || (/matter|bfld|mmwave|mr60/i.test(src) ? 'cog-ha-matter' : (hailo ? 'cog-pose-estimation' : null));
return { esp32: node, seed: seed || (node ? 'unknown' : null), cog: cog || 'unknown', hailo };
}
// Calibration: per-call branch on demo mode. Prod proxies the real
// calibrate-serve API via the gateway (/api/cal/v1/*). All methods are
// async (the §4.7 wizard awaits them).
function makeCalibration() {
const ANCHORS = ['empty', 'stand_still', 'sit', 'lie_down', 'breathe_slow', 'breathe_normal', 'small_move', 'sleep_posture'];
// demo session state
let frames = 0; const target = 1200; const accepted = new Set();
const get = (p) => api._get('/api/cal/v1' + p);
const post = (p, b) => api._post('/api/cal/v1' + p, b);
return {
ANCHORS,
get demo() { return demoMode(); },
async start() {
if (demoMode()) { frames = 0; return { baseline_id: 'bl-demo-' + ANCHORS.length }; }
return post('/calibration/start', {});
},
async stop() { if (demoMode()) return { stopped: true }; return post('/calibration/stop', {}); },
async status() {
if (demoMode()) { frames = Math.min(target, frames + 180); return { frames, target, eta_s: Math.max(0, Math.round((target - frames) / 180)), z_median: 0.41, motion_flagged: frames < 360 }; }
return get('/calibration/status');
},
async anchor(label) {
if (demoMode()) {
const ok = label !== 'sleep_posture' || accepted.size >= 6;
if (ok) accepted.add(label);
return { label, accepted: ok, reason: ok ? null : 'insufficient stillness — retry', features: { mean: 0.12, variance: 0.04, breathing_score: 0.7, heart_score: 0.55 } };
}
return post('/enroll/anchor', { label });
},
async enrollStatus() {
if (demoMode()) return { accepted: [...accepted], total: ANCHORS.length };
return get('/enroll/status');
},
async train(room_id) {
if (demoMode()) {
const trained = accepted.size >= 6;
return {
presence: trained ? { threshold: 0.31, occupied_var: 0.08 } : null,
posture: trained ? { prototypes: 4 } : null,
breathing: accepted.has('breathe_normal') ? { min_score: 0.6 } : null,
heartbeat: accepted.has('breathe_normal') ? { min_score: 0.5 } : null,
restlessness: trained ? { calm: 0.05, active: 0.6 } : null,
anomaly: trained ? { prototypes: 8, scale: 1.4 } : null,
};
}
return post('/room/train', { room_id });
},
reset() { accepted.clear(); frames = 0; },
};
}
+141
View File
@@ -0,0 +1,141 @@
// HOMECORE-UI bootstrap + shell + router — ADR-131 §5.
//
// Builds the Cognitum-shell top nav (Framework | Guide | Cog Store |
// HOMECORE | Status) with HOMECORE active, a left sub-nav for the nine
// HOMECORE sections, and a hash router. One shared WebSocket feeds a bus
// that every panel subscribes to (no per-panel sockets, no polling).
import { h, clear, lagIndicator } from './ui.js';
import { api } from './api.js';
import { connect } from './ws.js';
import dashboard from './panels/dashboard.js';
import fleet from './panels/fleet.js';
import seedDetail from './panels/seed-detail.js';
import entities from './panels/entities.js';
import rooms from './panels/rooms.js';
import cogs from './panels/cogs.js';
import calibration from './panels/calibration.js';
import events from './panels/events.js';
import audit from './panels/audit.js';
import settings from './panels/settings.js';
// Section registry. order drives the left sub-nav (§5).
const SECTIONS = [
{ id: 'dashboard', label: 'Dashboard', icon: '◳', mod: dashboard },
{ id: 'fleet', label: 'SEED Fleet', icon: '⬡', mod: fleet },
{ id: 'entities', label: 'Entities', icon: '◈', mod: entities },
{ id: 'rooms', label: 'Rooms', icon: '⌂', mod: rooms },
{ id: 'cogs', label: 'COGs', icon: '⚙', mod: cogs },
{ id: 'calibration', label: 'Calibration', icon: '⊹', mod: calibration },
{ id: 'events', label: 'Events', icon: '⚡', mod: events },
{ id: 'audit', label: 'Audit', icon: '⛨', mod: audit },
{ id: 'settings', label: 'Settings', icon: '⚒', mod: settings },
];
// Detail routes not shown in the sub-nav.
const ROUTES = { 'seed': seedDetail };
// Shared event bus fed by the single WS connection.
const bus = new EventTarget();
let wsState = { state: 'connecting', lagged: false };
const ctx = {
api,
bus,
wsStatus: () => wsState,
navigate: (hash) => { location.hash = hash; },
onEvent(handler) {
const fn = (e) => handler(e.detail);
bus.addEventListener('hc-event', fn);
return () => bus.removeEventListener('hc-event', fn);
},
onWs(handler) {
const fn = (e) => handler(e.detail);
bus.addEventListener('hc-ws', fn);
handler(wsState);
return () => bus.removeEventListener('hc-ws', fn);
},
};
let cleanup = null;
function buildShell() {
const topnav = h('.topnav',
h('.brand',
h('span.logo', 'C'),
h('span.brand-name', 'Cognitum'),
h('span.brand-sep', '/'),
h('span.brand-tag', 'HOMECORE')),
h('span.nav-spacer'),
lagIndicatorHost());
const sidenav = h('.sidenav', ...SECTIONS.map((s) => sideLink(s)));
const content = h('.content#hc-content');
const shell = h('.shell', sidenav, content);
const root = document.getElementById('app');
clear(root);
root.appendChild(topnav);
root.appendChild(shell);
return content;
}
function sideLink(section) {
return h('a', { href: '#/' + section.id, 'data-section': section.id },
h('span.ico', section.icon || '•'), h('span.lbl', section.label));
}
function lagIndicatorHost() {
const host = h('span');
const paint = () => { clear(host); host.appendChild(lagIndicator(wsState.state, wsState.lagged)); };
bus.addEventListener('hc-ws', paint);
paint();
return host;
}
function highlightNav(id) {
document.querySelectorAll('.sidenav a').forEach((a) => {
a.classList.toggle('active', a.getAttribute('data-section') === id);
});
}
async function route() {
const hash = location.hash.replace(/^#\/?/, '') || 'dashboard';
const [head, ...rest] = hash.split('/');
const content = document.getElementById('hc-content') || buildShell();
if (typeof cleanup === 'function') { try { cleanup(); } catch {} cleanup = null; }
clear(content);
let mod, params = {};
const section = SECTIONS.find((s) => s.id === head);
if (section) { mod = section.mod; highlightNav(head); }
else if (ROUTES[head]) { mod = ROUTES[head]; params.id = rest[0]; highlightNav('fleet'); }
else { mod = SECTIONS[0].mod; highlightNav('dashboard'); }
try {
const result = await mod.render(content, { ...ctx, params });
if (typeof result === 'function') cleanup = result;
} catch (e) {
content.appendChild(h('.banner.red', 'Panel error: ' + (e && e.message ? e.message : e)));
console.error(e);
}
}
function start() {
buildShell();
// Attach routing + render the first panel BEFORE opening the socket.
// connect() invokes its status callback synchronously, so the WS wiring
// must not be on the critical render path (a thrown callback here would
// otherwise blank the whole dashboard).
window.addEventListener('hashchange', route);
route();
const ctrl = connect(
(evt) => bus.dispatchEvent(new CustomEvent('hc-event', { detail: evt })),
(st) => { wsState = { state: st.state, lagged: !!st.lagged }; bus.dispatchEvent(new CustomEvent('hc-ws', { detail: wsState })); },
);
ctx.ws = ctrl;
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
export { SECTIONS, ctx };
+296
View File
@@ -0,0 +1,296 @@
// HOMECORE-UI contract-conformant mock layer — ADR-131 §7.1.
//
// "Where a service is not yet stable, the panel is still built against
// its defined contract (with a contract-conformant mock standing in for
// the live endpoint only until that endpoint lands)."
//
// Shapes mirror the schemas described in ADR-131 §4 + the calibration
// RoomState contract (docs/integration/calibration-appliance-integration.md)
// + the SEED HTTPS API. Live endpoints replace these the moment they
// exist; nothing here is presented to the operator as real (the UI shows
// a DEMO badge whenever the mock layer is serving a panel — see api.js).
const now = () => new Date().toISOString();
const ago = (s) => new Date(Date.now() - s * 1000).toISOString();
function jitter(base, amp) { return +(base + (Math.sin(Date.now() / 3000 + base) * amp)).toFixed(2); }
function spark(base, amp, n = 24) {
return Array.from({ length: n }, (_, i) => +(base + Math.sin(i / 2) * amp + (i % 3) * amp * 0.2).toFixed(2));
}
// Factory for a bedroom SEED node — keeps the three bedrooms consistent
// while varying the values that matter for the analysis views.
function bedroomSeed(o) {
return {
device_id: o.device_id, firmware: '0.7.3', online: true, conn: o.conn || 'wifi', epoch: o.epoch,
vector_count: o.vector_count, vector_dim: 8, knn_latency_ms: o.knn_latency_ms,
last_ingest: ago(2), witness_valid: true, witness_len: o.witness_len,
witness_last_verify: ago(1800), zone: o.zone,
storage_used: o.vector_count, storage_budget: 100000,
sensors: {
bme280: { temp_c: o.temp_c, humidity_pct: o.humidity_pct, pressure_hpa: 1013.0 },
pir: { motion: o.motion, last_trigger: ago(o.motion ? 5 : 640) },
reed: { open: false, last_change: ago(30000) },
ads1115: [{ label: 'ch0', v: 0.11 }, { label: 'ch1', v: 0.0 }, { label: 'ch2', v: 0.0 }, { label: 'ch3', v: 0.0 }],
vibration: { active: false, last_trigger: null },
},
reflex: [
{ name: 'fragility_alarm', threshold: 0.3, target: 'relay actuator', last_fired: o.fired ? ago(420) : null, fired_recently: !!o.fired },
{ name: 'drift_cutoff', threshold: 1.0, target: 'ingest gate', last_fired: null, fired_recently: false },
{ name: 'hd_anomaly_indicator', threshold: 200, target: 'PWM brightness', last_fired: null, fired_recently: false },
],
cognition: { fragility: o.fragility, coherence_phases: o.phases, knn_rebuild_s: 10 },
ingest: { batch: 64, flush_ms: 1000, bridge: 'direct', esp32: [{ node_id: o.node, packet: '0xC5110003', rate_hz: 1.0 }] },
esp32_nodes: 1, frame_rate_hz: 100,
};
}
// ── v0 Appliance health (§4.1) ──────────────────────────────────────
export function applianceHealth() {
return {
cpu_pct: jitter(34, 6),
ram_pct: jitter(58, 4),
hailo_load_pct: jitter(41, 12),
hailo_temp_c: jitter(52, 3),
uptime_s: 824510,
services: [
{ name: 'ruview-mcp-brain', port: 9876, status: 'running' },
{ name: 'cognitum-rvf-agent', port: 9004, status: 'running' },
{ name: 'ruvector-hailo-worker', port: 50051, status: 'running' },
],
event_rate: spark(120, 40),
channel_capacity: 4096,
channel_lag: 0,
};
}
// ── SEED fleet (§4.1 / §4.2) ────────────────────────────────────────
const SEEDS = [
{
device_id: 'seed-livingroom-a1',
firmware: '0.7.3', online: true, conn: 'wifi', epoch: 184,
vector_count: 71280, vector_dim: 8, knn_latency_ms: 2.1,
last_ingest: ago(3), witness_valid: true, witness_len: 184210,
witness_last_verify: ago(900), zone: 'Living Room',
storage_used: 71280, storage_budget: 100000,
sensors: {
bme280: { temp_c: 21.6, humidity_pct: 44, pressure_hpa: 1013.2 },
pir: { motion: true, last_trigger: ago(8) },
reed: { open: false, last_change: ago(7200) },
ads1115: [{ label: 'soil', v: 0.42 }, { label: 'light', v: 0.71 }, { label: 'aux2', v: 0.03 }, { label: 'aux3', v: 0.0 }],
vibration: { active: false, last_trigger: ago(40000) },
},
reflex: [
{ name: 'fragility_alarm', threshold: 0.3, target: 'relay actuator', last_fired: ago(300), fired_recently: true },
{ name: 'drift_cutoff', threshold: 1.0, target: 'ingest gate', last_fired: null, fired_recently: false },
{ name: 'hd_anomaly_indicator', threshold: 200, target: 'PWM brightness', last_fired: ago(12000), fired_recently: false },
],
cognition: { fragility: 0.42, coherence_phases: [{ t: ago(3600), label: 'empty' }, { t: ago(1800), label: 'occupied' }, { t: ago(300), label: 'regime-change' }], knn_rebuild_s: 10 },
ingest: { batch: 64, flush_ms: 1000, bridge: 'host-laptop hop', esp32: [{ node_id: 'esp32-lr-01', packet: '0xC5110003', rate_hz: 1.0 }, { node_id: 'esp32-lr-02', packet: '0xC5110002', rate_hz: 0.9 }] },
esp32_nodes: 2, frame_rate_hz: 98,
},
bedroomSeed({
device_id: 'seed-bedroom-1', zone: 'Bedroom 1 (primary)', epoch: 183,
vector_count: 38110, knn_latency_ms: 1.7, witness_len: 91022,
temp_c: 20.1, humidity_pct: 47, motion: false, fragility: 0.12,
phases: [{ t: ago(7200), label: 'empty' }, { t: ago(3600), label: 'sleep' }],
node: 'esp32-br1-01', conn: 'usb',
}),
bedroomSeed({
device_id: 'seed-bedroom-2', zone: 'Bedroom 2 (guest)', epoch: 181,
vector_count: 29440, knn_latency_ms: 1.9, witness_len: 70210,
temp_c: 19.4, humidity_pct: 50, motion: true, fragility: 0.21,
phases: [{ t: ago(5400), label: 'empty' }, { t: ago(900), label: 'occupied' }],
node: 'esp32-br2-01', conn: 'wifi',
}),
bedroomSeed({
device_id: 'seed-bedroom-3', zone: 'Bedroom 3 (kids)', epoch: 179,
vector_count: 24105, knn_latency_ms: 2.0, witness_len: 60880,
temp_c: 21.0, humidity_pct: 45, motion: false, fragility: 0.34,
phases: [{ t: ago(9000), label: 'empty' }, { t: ago(4200), label: 'sleep' }, { t: ago(600), label: 'restless' }],
node: 'esp32-br3-01', conn: 'wifi', fired: true,
}),
{
device_id: 'seed-hallway-c3',
firmware: '0.6.9', online: false, conn: 'wifi', epoch: 170,
vector_count: 12044, vector_dim: 8, knn_latency_ms: null,
last_ingest: ago(5400), witness_valid: true, witness_len: 40110,
witness_last_verify: ago(86400), zone: 'Hallway',
storage_used: 12044, storage_budget: 100000,
sensors: null,
reflex: [],
cognition: { fragility: null, coherence_phases: [], knn_rebuild_s: 10 },
ingest: { batch: 64, flush_ms: 1000, bridge: 'direct', esp32: [] },
esp32_nodes: 0, frame_rate_hz: 0,
warnings: ['stale firmware version (0.6.9 < 0.7.3)', 'offline > 1h'],
},
];
export function seeds() { return SEEDS.map((s) => ({ ...s })); }
export function seed(id) { return SEEDS.find((s) => s.device_id === id) || null; }
// ── ESP32 node warnings (§4.1) ──────────────────────────────────────
export function esp32Warnings() {
return [
{ node_id: 'esp32-lr-02', seed: 'seed-livingroom-a1', issue: 'presence_score normalisation anomaly' },
{ node_id: 'esp32-hw-01', seed: 'seed-hallway-c3', issue: 'stale firmware version' },
];
}
// ── COG runtime (§4.6) ──────────────────────────────────────────────
const COGS = [
{ id: 'cog-ha-matter', version: '1.4.2', arch: 'arm', status: 'running', pid: 4120, sha256_verified: true, signature_verified: true },
{ id: 'cog-pose-estimation', version: '2.1.0', arch: 'hailo10', status: 'running', pid: 4188, sha256_verified: true, signature_verified: true, hef: ['rf_foundation_encoder.hef', 'pose_head.hef'], throughput_fps: 41 },
{ id: 'cog-person-count', version: '0.9.4', arch: 'arm', status: 'running', pid: 4205, sha256_verified: true, signature_verified: true },
{ id: 'cog-calibration', version: '1.0.1', arch: 'arm', status: 'running', pid: 4250, sha256_verified: true, signature_verified: true },
{ id: 'cog-anomaly-watch', version: '0.3.0', arch: 'arm', status: 'failed', pid: null, sha256_verified: true, signature_verified: true, error: 'panic: bank not found' },
{ id: 'cog-legacy-bridge', version: '0.1.2', arch: 'arm', status: 'stopped', pid: null, sha256_verified: false, signature_verified: false },
];
export function cogs() { return COGS.map((c) => ({ ...c })); }
export function cogUpdates() { return [{ id: 'cog-pose-estimation', from: '2.1.0', to: '2.2.0', new_entities: ['sensor.lr_pose_confidence'], config_changes: ['add: max_persons'] }]; }
export function appRegistry() {
return [
{ id: 'cog-fall-detect', title: 'Fall Detection', desc: 'Multistatic fall detection specialist', category: 'safety', arch: 'arm', featured: true, new_entities: ['binary_sensor.{room}_fall'] },
{ id: 'cog-sleep-stage', title: 'Sleep Staging', desc: 'REM/deep/light from breathing + restlessness', category: 'health', arch: 'hailo10', new_entities: ['sensor.{room}_sleep_stage'] },
{ id: 'cog-gesture', title: 'Gesture Control', desc: 'DTW gesture classifier → service calls', category: 'control', arch: 'arm', new_entities: ['event.{room}_gesture'] },
];
}
// ── RoomState / sensing (§4.5) — calibration contract ───────────────
export function roomStates() {
return [
{
room_id: 'living_room', stale: false, vetoed: false, seeds: ['seed-livingroom-a1'],
presence: { value: 'occupied', confidence: 0.93 },
posture: { value: 'sitting', confidence: 0.81 },
breathing_bpm: { value: jitter(15, 1.5), confidence: 0.77 },
heart_bpm: { value: jitter(72, 3), confidence: 0.64 },
restlessness: { value: 0.22, confidence: 0.7 },
anomaly: { value: 0.18, confidence: 0.8, threshold: 0.8 },
},
{
// Bedroom 1 — primary; healthy sleeping vitals.
room_id: 'bedroom_1', stale: false, vetoed: false, seeds: ['seed-bedroom-1'],
presence: { value: 'occupied', confidence: 0.91 },
posture: { value: 'lying', confidence: 0.9 },
breathing_bpm: { value: jitter(12, 1), confidence: 0.85 },
heart_bpm: { value: jitter(58, 2), confidence: 0.72 },
restlessness: { value: 0.08, confidence: 0.8 },
anomaly: { value: 0.12, confidence: 0.84, threshold: 0.8 },
},
{
// Bedroom 2 — guest; STALE bank (recalibrate demo).
room_id: 'bedroom_2', stale: true, vetoed: false, seeds: ['seed-bedroom-2'],
presence: { value: 'occupied', confidence: 0.86 },
posture: { value: 'sitting', confidence: 0.7 },
breathing_bpm: { value: jitter(16, 1.5), confidence: 0.66 },
heart_bpm: { value: jitter(74, 3), confidence: 0.58 },
restlessness: { value: 0.31, confidence: 0.62 },
anomaly: { value: 0.4, confidence: 0.6, threshold: 0.8 },
},
{
// Bedroom 3 — kids; heartbeat specialist not yet trained.
room_id: 'bedroom_3', stale: false, vetoed: false, seeds: ['seed-bedroom-3'],
presence: { value: 'occupied', confidence: 0.79 },
posture: { value: 'lying', confidence: 0.74 },
breathing_bpm: { value: jitter(18, 2), confidence: 0.69 },
heart_bpm: null, // null = not trained (§6 invariant 3)
restlessness: { value: 0.46, confidence: 0.6 },
anomaly: { value: 0.22, confidence: 0.7, threshold: 0.8 },
},
{
room_id: 'kitchen', stale: false, vetoed: true, seeds: ['seed-livingroom-a1', 'seed-hallway-c3'],
presence: { value: 'occupied', confidence: 0.6 },
posture: { value: null, confidence: null }, // suppressed by veto — withheld, NOT zero (§4.5)
breathing_bpm: { value: null, confidence: null },
heart_bpm: { value: null, confidence: null },
restlessness: { value: 0.4, confidence: 0.5 },
anomaly: { value: 0.91, confidence: 0.88, threshold: 0.8 },
},
{
room_id: 'office', stale: false, vetoed: false, seeds: ['seed-bedroom-1'],
presence: { value: 'absent', confidence: 0.95 },
posture: null, // null = not trained (§6 invariant 3)
breathing_bpm: null,
heart_bpm: null,
restlessness: { value: 0.0, confidence: 0.9 },
anomaly: { value: 0.05, confidence: 0.9, threshold: 0.8 },
},
];
}
// ── Fleet map / federation (§4.3) ───────────────────────────────────
export function federation() {
return {
coordinator: 'seed-livingroom-a1', round: 47, k_healthy: 4, delta_status: 'exchanging',
invariant: 'model deltas only — never raw CSI',
krum: { f: 1, multi: true }, cadence_min: 30,
mesh_links: [
{ a: 'seed-livingroom-a1', b: 'seed-bedroom-1', health: 'green' },
{ a: 'seed-bedroom-1', b: 'seed-bedroom-2', health: 'green' },
{ a: 'seed-bedroom-2', b: 'seed-bedroom-3', health: 'amber' },
{ a: 'seed-bedroom-1', b: 'seed-hallway-c3', health: 'red' },
],
fused_events: [{ kind: 'fall', seeds: ['seed-livingroom-a1', 'seed-hallway-c3'], n: 2 }, { kind: 'occupant-track', seeds: ['seed-bedroom-1', 'seed-bedroom-2', 'seed-livingroom-a1'], n: 3 }],
};
}
// ── Witness / audit (§4.9) ──────────────────────────────────────────
export function witnessLog(page = 0, size = 12) {
const total = 240;
const items = Array.from({ length: size }, (_, i) => {
const n = page * size + i;
const seedTier = n % 2 === 0;
return {
entity_id: seedTier ? `rvf.store.write.${184210 - n}` : ['sensor.living_room_presence', 'binary_sensor.front_door', 'sensor.bedroom_breathing_rate'][n % 3],
old_state: seedTier ? null : ['false', 'off', '14.5'][n % 3],
new_state: seedTier ? `sha256:${(0x9a3f + n).toString(16)}` : ['true', 'on', '15.1'][n % 3],
ts: ago(n * 37),
tier: seedTier ? 'seed-sha256' : 'homecore-ed25519',
seed: ['seed-livingroom-a1', 'seed-bedroom-1', 'seed-bedroom-2', 'seed-bedroom-3'][n % 4],
key_fp: ['a1b2c3d4', 'e5f6a7b8', 'c9d0e1f2', 'b3a4c5d6'][n % 4],
};
});
return { items, page, size, total };
}
export function privacyModes() {
return [
{ seed: 'seed-livingroom-a1', mode: 'full-publish' },
{ seed: 'seed-bedroom-1', mode: 'audit-only' },
{ seed: 'seed-bedroom-2', mode: 'audit-only' },
{ seed: 'seed-bedroom-3', mode: 'audit-only' },
{ seed: 'seed-hallway-c3', mode: 'audit-only' },
];
}
// ── Events / automations (§4.8) ─────────────────────────────────────
export function recentEvents(n = 40) {
const variants = ['StateChanged', 'EntityRegistered', 'ConfigReloaded'];
const ents = ['sensor.living_room_presence', 'binary_sensor.front_door', 'light.kitchen_ceiling', 'sensor.bedroom_breathing_rate'];
return Array.from({ length: n }, (_, i) => ({
type: variants[i % 3],
entity_id: ents[i % ents.length],
old_state: ['off', 'false', '14.5'][i % 3],
new_state: ['on', 'true', '15.1'][i % 3],
ts: ago(i * 11),
user_id: i % 4 === 0 ? 'operator' : null,
context: { id: 'ctx-' + (1000 + i), parent_id: i % 3 === 0 ? 'ctx-' + (999 + i) : null, grandparent_id: i % 6 === 0 ? 'ctx-' + (998 + i) : null },
source: ['seed-livingroom-a1', 'cog-ha-matter'][i % 2],
}));
}
// ── Settings (§4.10) ────────────────────────────────────────────────
export function settings() {
return {
mqtt: { broker: 'mqtt://cognitum-v0:1883', user: 'homecore', mdns: '_ruview-ha._tcp', connected: true },
tokens: [
{ name: 'ios-companion', last_used: ago(120), created: ago(8000000) },
{ name: 'node-red', last_used: ago(60000), created: ago(20000000) },
],
ha_disco_entities: 21,
esp32: [
{ node_id: 'esp32-lr-01', ip: '192.168.1.31', port: 5566, firmware: '1.2.0', room: 'living_room', seed: 'seed-livingroom-a1' },
{ node_id: 'esp32-br1-01', ip: '192.168.1.32', port: 5566, firmware: '1.2.0', room: 'bedroom_1', seed: 'seed-bedroom-1' },
{ node_id: 'esp32-br2-01', ip: '192.168.1.33', port: 5566, firmware: '1.2.0', room: 'bedroom_2', seed: 'seed-bedroom-2' },
{ node_id: 'esp32-br3-01', ip: '192.168.1.34', port: 5566, firmware: '1.2.0', room: 'bedroom_3', seed: 'seed-bedroom-3' },
],
};
}
@@ -0,0 +1,217 @@
// §4.9 Witness / Audit Log — ADR-131.
//
// Persistent privacy-mode banner (aggregate + per-SEED), the unified
// two-tier witness timeline (SEED SHA-256 chain + homecore Ed25519
// chain merged chronologically), paginated 12-at-a-time, and a
// regulated-deployment attestation-bundle export. Privacy-mode toggles
// are high-stakes and gated behind an explicit inline confirm (§6 honesty
// — never silently mutate what a SEED publishes).
import { h, clear, card, pill, statusPill, sectionHeader, mono, button, banner, relTime } from '../ui.js';
const PAGE_SIZE = 12;
export default {
meta: { title: 'Audit' },
async render(root, ctx) {
const { api } = ctx;
root.appendChild(sectionHeader('Witness / Audit Log', 'Two-tier provenance — SEED SHA-256 store chain + homecore Ed25519 state chain'));
if (api.isDemo('audit')) root.appendChild(banner('DEMO — contract-conformant witness data until the live audit endpoint lands (ADR-131 §7.1).', 'amber'));
// Async data accessors now return Promises (api.js). Wrap the initial
// loads in try/catch; on failure surface the typed audit/witness banner
// (§12 W5 distinguishes "not yet wired" upstreams) and bail.
let modes;
let firstPage;
try {
modes = (await api.privacyModes()).map((m) => ({ ...m }));
firstPage = await api.witnessLog(0, PAGE_SIZE);
} catch (e) {
root.appendChild(banner('Audit/witness unavailable — ' + (e.message || e)
+ (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red'));
return () => {};
}
const privacyHost = h('div');
root.appendChild(privacyHost);
const renderPrivacy = () => { clear(privacyHost); privacyHost.appendChild(privacyCard(modes, renderPrivacy)); };
renderPrivacy();
// Unified timeline — its own host so pagination re-renders in place.
const timelineHost = h('div');
root.appendChild(timelineHost);
let page = firstPage.page;
// Pagination Prev/Next re-fetch the new page (await) and re-render in place.
const renderTimeline = async (res) => {
page = res.page;
clear(timelineHost);
timelineHost.appendChild(timelineCard(res,
async () => {
if (page <= 0) return;
clear(timelineHost);
timelineHost.appendChild(h('.muted-empty', 'Loading witness chain…'));
try { await renderTimeline(await api.witnessLog(page - 1, PAGE_SIZE)); }
catch (e) { clear(timelineHost); timelineHost.appendChild(banner('Audit/witness unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red')); }
},
async (last) => {
if (last) return;
clear(timelineHost);
timelineHost.appendChild(h('.muted-empty', 'Loading witness chain…'));
try { await renderTimeline(await api.witnessLog(page + 1, PAGE_SIZE)); }
catch (e) { clear(timelineHost); timelineHost.appendChild(banner('Audit/witness unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red')); }
}));
};
await renderTimeline(firstPage);
// Attestation bundle export.
root.appendChild(exportCard());
return () => {};
},
};
// ── Privacy mode (aggregate banner + per-SEED rows + gated toggle) ─────
function privacyCard(modes, rerender) {
const allPublish = modes.every((m) => m.mode === 'full-publish');
const anyAudit = modes.some((m) => m.mode === 'audit-only');
const top = allPublish
? banner('Full-publish mode — SEED state changes are published over MQTT.', 'green')
: banner('Audit-only mode (SHA-256 digests on-SEED only, no MQTT state messages).', 'amber');
const list = h('div');
modes.forEach((m, i) => list.appendChild(privacyRow(m, modes, rerender, i)));
return card({
title: 'Privacy mode',
children: [
top,
h('.t2.mt', 'Per-SEED configuration — each SEED chooses independently what leaves the device.'),
list,
],
});
}
function privacyRow(m, modes, rerender, idx) {
const isPublish = m.mode === 'full-publish';
const modePill = pill(m.mode, isPublish ? 'green' : 'amber');
// The confirm step lives inline beneath the row; only one at a time.
const confirmHost = h('div');
const toggleBtn = button('Toggle privacy mode', {
variant: 'ghost',
onClick: () => {
clear(confirmHost);
confirmHost.appendChild(confirmStep(m, modes, rerender, confirmHost));
},
});
const wrap = h('div',
h('.row',
h('span.flex.gap-sm', mono(m.seed), modePill),
toggleBtn),
confirmHost);
return wrap;
}
function confirmStep(m, modes, rerender, confirmHost) {
const target = m.mode === 'full-publish' ? 'audit-only' : 'full-publish';
const summary = target === 'audit-only'
? `${m.seed} will STOP publishing state changes over MQTT — only on-SEED SHA-256 digests remain.`
: `${m.seed} will START publishing state changes over MQTT (full state values leave the device).`;
const confirmBtn = button('Confirm', {
variant: 'primary',
onClick: () => {
const live = modes.find((x) => x.seed === m.seed);
if (live) live.mode = target;
rerender();
},
});
const cancelBtn = button('Cancel', { variant: 'ghost', onClick: () => clear(confirmHost) });
return card({
tint: target === 'audit-only' ? 'amber' : null,
children: [
h('.t2', h('span', 'Switch '), mono(m.seed), h('span', `${target}?`)),
h('.mt', summary),
h('.flex.gap-sm.mt', confirmBtn, cancelBtn),
],
});
}
// ── Unified two-tier witness timeline ──────────────────────────────────
function timelineCard(res, onPrev, onNext) {
const { items, page, size, total } = res;
const lastPage = Math.max(0, Math.ceil(total / size) - 1);
const isLast = page >= lastPage;
const head = h('.row',
h('span.k', 'entity · old → new · when · tier · source SEED · key'),
h('span.t2', `merged chronological — both chains`));
const body = h('div');
if (!items.length) body.appendChild(h('.muted-empty', 'No witness entries.'));
items.forEach((it) => body.appendChild(witnessRow(it)));
const from = total === 0 ? 0 : page * size + 1;
const to = Math.min(total, page * size + items.length);
const pager = h('.flex.spread.mt',
h('span.t2', `Showing ${from}${to} of ${total}`),
h('span.flex.gap-sm',
button(' Prev', { variant: 'ghost', onClick: onPrev, disabled: page <= 0 }),
button('Next ', { variant: 'ghost', onClick: () => onNext(isLast), disabled: isLast })));
return card({ title: 'Witness timeline', children: [head, body, pager] });
}
function witnessRow(it) {
const seedTier = it.tier === 'seed-sha256';
const tierPill = pill(it.tier, seedTier ? 'cyan' : 'purple');
// old → new. SEED-tier writes have no prior state and a sha256 digest as
// the "new" value — render the digest mono so it reads as a hash, not state.
const transition = h('span.flex.gap-sm',
h('span.mono.t2', it.old_state == null ? '∅' : it.old_state),
h('span.t3', '→'),
h('span.mono', it.new_state == null ? '∅' : it.new_state));
return h('.row',
h('span.flex.gap-sm.wrap',
mono(it.entity_id),
transition),
h('span.flex.gap-sm.wrap',
h('span.t2', relTime(it.ts)),
tierPill,
mono(it.seed),
h('span.mono.t3', keyFp(it.key_fp))));
}
function keyFp(fp) {
if (!fp) return '—';
return String(fp).slice(0, 8) + '…';
}
// ── Attestation bundle export (regulated-deployment compliance) ────────
function exportCard() {
const status = h('.t2.mt');
const btn = button('Export attestation bundle', {
variant: 'ghost',
onClick: () => {
clear(status);
status.appendChild(h('span.green',
'Bundle prepared — SEED SHA-256 store chain + homecore Ed25519 state chain packaged for compliance handoff.'));
},
});
return card({
title: 'Attestation bundle',
children: [
h('.t2', 'Packages both witness chains (SEED SHA-256 + homecore Ed25519) for regulated-deployment compliance handoff.'),
h('.mt', btn),
status,
],
});
}
@@ -0,0 +1,256 @@
// §4.7 Calibration Wizard — baseline → enroll → train → verify.
// Stepped wizard (15) against the ADR-151 calibration HTTP API.
import { h, clear, card, pill, statusPill, sectionHeader, bar, banner, button, mono } from '../ui.js';
export default {
meta: { title: 'Calibration' },
async render(root, ctx) {
const { api } = ctx;
const cal = api.calibration;
const state = { step: 1, room_id: '', seed: '', baseline_id: null, anchorIdx: 0, trainResult: null };
// Track the active baseline poll so it can be cancelled on Restart, on a
// step change, and when the panel itself is torn down (the router only
// calls the cleanup this render() returns — a per-card _cleanup was never
// invoked, leaking the setTimeout loop).
let activePoll = null;
function stopPoll() {
if (activePoll) { activePoll.cancelled = true; if (activePoll.timer) clearTimeout(activePoll.timer); activePoll = null; }
}
root.appendChild(sectionHeader('Calibration Wizard', 'baseline → enroll → train → verify'));
if (cal.demo) root.appendChild(banner('DEMO — cog-calibration HTTP API (ADR-151) simulated in-browser; the live service replaces this (§7.1).', 'amber'));
const stepper = h('.stepper');
const body = h('div');
root.appendChild(stepper);
root.appendChild(body);
const STEPS = ['Select', 'Baseline', 'Enroll', 'Train', 'Verify'];
function paintStepper() {
clear(stepper);
STEPS.forEach((s, i) => {
const n = i + 1;
const cls = n === state.step ? 'active' : (n < state.step ? 'done' : '');
stepper.appendChild(h('.step-pill' + (cls ? '.' + cls : ''), h('span.n', n < state.step ? '✓' : String(n)), s));
});
}
function go(step) { stopPoll(); state.step = step; paintStepper(); render(); }
function render() {
clear(body);
if (state.step === 1) body.appendChild(step1());
else if (state.step === 2) body.appendChild(step2());
else if (state.step === 3) body.appendChild(step3());
else if (state.step === 4) body.appendChild(step4());
else body.appendChild(step5());
}
// ── Step 1 — select room + SEED ────────────────────────────────
function step1() {
const roomInput = h('input.search', { placeholder: 'room_id (A-Za-z0-9_- , 164)', value: state.room_id });
const seedSel = h('select.inline');
const warn = h('div');
let seedList = [];
(async () => {
try { seedList = (await api.seeds()).filter((s) => s.online); }
catch (e) { warn.appendChild(banner('SEED fleet unavailable — ' + (e.message || e), 'red')); }
seedList.forEach((s) => seedSel.appendChild(h('option', { value: s.device_id }, `${s.device_id} (${s.zone})`)));
})();
const validate = () => {
const ok = /^[A-Za-z0-9_-]{1,64}$/.test(roomInput.value);
const seed = seedList.find((s) => s.device_id === seedSel.value);
clear(warn);
if (!ok) warn.appendChild(banner('room_id must match [A-Za-z0-9_-]{1,64}', 'red'));
else if (seed && seed.frame_rate_hz < 80) warn.appendChild(banner(`CSI ingest low (${seed.frame_rate_hz} Hz) — a broken pipeline silently fails calibration`, 'amber'));
return ok;
};
roomInput.addEventListener('input', validate);
seedSel.addEventListener('change', validate);
return card({
title: 'Step 1 — Select room and SEED', children: [
h('h3', 'room_id'), roomInput,
h('h3.mt', 'Serving SEED'), seedSel, warn,
h('.mt', button('Next', { variant: 'primary', onClick: () => { if (validate()) { state.room_id = roomInput.value; state.seed = seedSel.value; go(2); } } })),
],
});
}
// ── Step 2 — baseline capture ──────────────────────────────────
function step2() {
const progress = h('.bar', { style: { height: '14px' } }, h('span'));
const meta = h('.t2.mt');
const baselineLine = h('div');
const c = card({
title: 'Step 2 — Baseline capture (room must be empty)', children: [
progress, meta, baselineLine,
h('.mt', button('Restart', {
variant: 'ghost',
// Cancel the in-flight poll loop (was leaked before), reset the
// session, and start a fresh capture.
onClick: () => { stopPoll(); cal.reset(); clear(baselineLine); startCapture(); },
})),
],
});
// Single-flight: stopPoll() before (re)arming guarantees one loop.
function startCapture() {
stopPoll();
const session = { cancelled: false, timer: null };
activePoll = session;
(async () => {
let startRes;
try { startRes = await cal.start(); }
catch (e) { clear(meta); meta.appendChild(banner('Baseline start failed — ' + (e.message || e), 'red')); return; }
if (session.cancelled) return;
state.baseline_id = (startRes && startRes.baseline_id) || state.baseline_id;
const loop = async () => {
if (session.cancelled) return;
let st;
try { st = await cal.status(); }
catch (e) { clear(meta); meta.appendChild(banner('Status unavailable — ' + (e.message || e), 'red')); return; }
if (session.cancelled) return;
progress.firstChild.style.width = pct(st.frames, st.target) + '%';
clear(meta); meta.appendChild(document.createTextNode(`${st.frames}/${st.target} frames · ETA ${st.eta_s}s · z_median ${st.z_median}`));
if (st.motion_flagged) { if (!c.querySelector('.banner')) c.insertBefore(banner('Room must be empty — movement detected', 'amber'), progress); }
else { const b = c.querySelector('.banner'); if (b) b.remove(); }
if (st.target > 0 && st.frames >= st.target) {
activePoll = null;
state.baseline_id = state.baseline_id || 'bl-unknown';
clear(baselineLine);
baselineLine.appendChild(h('.mt', h('span.green', 'Baseline complete · '), mono(state.baseline_id), h('span.t2', ' (record this — it anchors STALE detection)')));
baselineLine.appendChild(h('.mt', button('Continue to enrollment', { variant: 'primary', onClick: () => go(3) })));
return;
}
session.timer = setTimeout(loop, 600);
};
loop();
})();
}
startCapture();
return c;
}
// ── Step 3 — anchor enrollment ─────────────────────────────────
function step3() {
const anchors = cal.ANCHORS;
const counter = h('h3', 'enrollment');
const list = h('div');
const current = h('div');
async function paint() {
let acc;
try { acc = new Set(((await cal.enrollStatus()).accepted) || []); }
catch (e) { clear(current); current.appendChild(banner('Enroll status unavailable — ' + (e.message || e), 'red')); acc = new Set(); }
clear(counter); counter.appendChild(document.createTextNode(`${acc.size} / ${anchors.length} anchors accepted`));
clear(list);
anchors.forEach((label, i) => {
list.appendChild(h('.row', mono(label),
acc.has(label) ? pill('accepted', 'green') : (i === state.anchorIdx ? pill('current', 'cyan') : pill('pending', 'grey'))));
});
clear(current);
const label = anchors[state.anchorIdx];
if (!label) {
current.appendChild(h('.mt', h('span.green', 'All anchors processed · '),
button('Train specialists', { variant: 'primary', onClick: () => go(4) })));
return;
}
current.appendChild(h('h3.mt', `Anchor: ${label}`));
current.appendChild(h('.t2', instruction(label)));
current.appendChild(h('.mt', button('Capture anchor', {
variant: 'primary', onClick: async () => {
let r;
try { r = await cal.anchor(label); }
catch (e) { current.appendChild(banner('Capture failed — ' + (e.message || e), 'red')); return; }
const f = r.features;
const res = h('.mt', r.accepted ? pill('accepted', 'green') : pill('retry', 'amber'),
r.reason ? h('span.amber', ' ' + r.reason) : null,
f ? h('.mono.t2.mt', `mean ${f.mean} · var ${f.variance} · breathing ${f.breathing_score} · heart ${f.heart_score}`) : null);
current.appendChild(res);
if (r.accepted) { state.anchorIdx++; setTimeout(paint, 700); }
},
})));
}
paint();
return card({ title: 'Step 3 — Anchor enrollment', children: [counter, list, current] });
}
// ── Step 4 — train ─────────────────────────────────────────────
function step4() {
const body4 = h('div', h('.muted-empty', 'Training…'));
const c = card({ title: 'Step 4 — Train specialists', children: [body4] });
(async () => {
let r;
try { r = await cal.train(state.room_id); }
catch (e) { clear(body4); body4.appendChild(banner('Training failed — ' + (e.message || e), 'red')); return; }
state.trainResult = r;
clear(body4);
const specs = [
['presence', r.presence && `threshold ${r.presence.threshold} · var ${r.presence.occupied_var}`],
['posture', r.posture && `${r.posture.prototypes} prototypes`],
['breathing', r.breathing && `min_score ${r.breathing.min_score}`],
['heartbeat', r.heartbeat && `min_score ${r.heartbeat.min_score}`],
['restlessness', r.restlessness && `calm ${r.restlessness.calm} · active ${r.restlessness.active}`],
['anomaly', r.anomaly && `${r.anomaly.prototypes} prototypes · scale ${r.anomaly.scale}`],
];
specs.forEach(([name, detail]) => {
body4.appendChild(h('.row', mono(name),
detail ? h('.flex.gap-sm', pill('trained', 'green'), h('span.t2', detail))
: h('.flex.gap-sm', pill('null', 'amber'), button('Re-enroll missing anchors', { variant: 'ghost', onClick: () => go(3) }))));
});
body4.appendChild(h('.mt', button('Verify live', { variant: 'primary', onClick: () => go(5) })));
})();
return c;
}
// ── Step 5 — verify live ───────────────────────────────────────
function step5() {
const rows = h('div', h('.muted-empty', 'Loading live RoomState…'));
(async () => {
let live;
try {
const all = await api.roomStates();
live = all.find((r) => r.room_id === state.room_id) || all[0];
} catch (e) { clear(rows); rows.appendChild(banner('Live RoomState unavailable — ' + (e.message || e), 'red')); return; }
clear(rows);
if (!live) { rows.appendChild(h('.muted-empty', 'No RoomState yet — give the room a moment after training.')); return; }
rows.appendChild(h('.row', 'Presence', live.presence ? statusPill(live.presence.value) : h('span.t3', '—')));
rows.appendChild(h('.row', 'Posture', live.posture ? statusPill(live.posture.value) : h('span.t3', '—')));
rows.appendChild(h('.row', 'Breathing', h('span.cyan', live.breathing_bpm ? live.breathing_bpm.value + ' BPM' : '—')));
rows.appendChild(h('.row', 'Heart rate', h('span.cyan', live.heart_bpm ? live.heart_bpm.value + ' BPM' : '—')));
})();
return card({
title: 'Step 5 — Verify live', children: [
h('.t2', 'Stand in the room to confirm presence; sit/lie to confirm posture; breathe normally to confirm vitals.'),
rows,
h('.flex.mt',
button('Confirm and save', { variant: 'primary', onClick: () => { cal.reset && cal.reset(); ctx.navigate('#/rooms'); } }),
button("Something's wrong — re-enroll", { variant: 'ghost', onClick: () => go(3) })),
],
});
}
paintStepper();
render();
// The router invokes this on navigation away — tear down any live poll.
return () => stopPoll();
},
};
// Guard against NaN%/Infinity% when target is 0/missing (§4.7 robustness).
function pct(frames, target) {
if (!(target > 0)) return 0;
return Math.max(0, Math.min(100, (frames / target) * 100)).toFixed(0);
}
function instruction(label) {
const map = {
empty: 'Leave the room empty and still.',
stand_still: 'Stand still in the centre of the room.',
sit: 'Sit down naturally.',
lie_down: 'Lie down (bed/sofa).',
breathe_slow: 'Breathe slowly and deeply.',
breathe_normal: 'Breathe at your normal resting rate.',
small_move: 'Make small fidgeting movements.',
sleep_posture: 'Adopt your typical sleeping posture and stay still.',
};
return map[label] || label;
}
@@ -0,0 +1,194 @@
// §4.6 v0 Appliance COG Management — ADR-131.
// Installed COGs (start/stop/restart/logs/config + sha256+sig shield),
// COG Store / App Registry (mirrors seed.cognitum.one/store), OTA
// Updates diff panels, and Hailo HEF status. Mirrors the Cog Store
// visual conventions (card layout, category pills, install/details pair).
import { h, clear, card, pill, statusPill, sectionHeader, mono, button, collapsible, banner } from '../ui.js';
export default {
meta: { title: 'COGs' },
async render(root, ctx) {
const { api } = ctx;
root.appendChild(sectionHeader('COGs', 'v0 Appliance COG runtime & OTA updates'));
if (api.isDemo('cogs')) {
root.appendChild(h('.banner.amber', 'COG management shows contract-conformant DEMO data until the live cog-supervisor endpoint lands (ADR-131 §7.1).'));
}
let cogs, updates;
try {
cogs = await api.cogs();
updates = await api.cogUpdates();
} catch (e) {
root.appendChild(banner('COG runtime unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
return () => {};
}
// ── Installed COGs ─────────────────────────────────────────────
root.appendChild(h('.flex.gap-sm', h('h2', 'Installed'), pill(String(cogs.length), 'cyan')));
const installed = h('.grid.cols-2');
cogs.forEach((c) => installed.appendChild(installedCogCard(c)));
root.appendChild(installed);
// ── OTA Updates ────────────────────────────────────────────────
root.appendChild(h('.flex.gap-sm.mt', h('h2', 'Updates'), pill(String(updates.length), updates.length ? 'amber' : 'grey')));
if (!updates.length) {
root.appendChild(card({ children: [h('.muted-empty', 'All COGs up to date.')] }));
} else {
updates.forEach((u) => root.appendChild(updateCard(u)));
}
// ── Hailo HEF status ───────────────────────────────────────────
// §6 honesty: the worker pill must reflect the REAL probe, not a
// hardcoded "connected". Probe the appliance services for the
// ruvector-hailo-worker; if that upstream is unavailable, show the
// status as unknown rather than fabricating "connected".
let workerStatus = 'unknown';
try {
const appliance = await api.appliance();
const svc = (appliance.services || []).find((s) => s.name === 'ruvector-hailo-worker');
if (svc && svc.status) workerStatus = svc.status;
} catch { /* leave 'unknown' — honest not-available, never fabricated */ }
root.appendChild(h('h2.mt', 'Hailo-10H accelerator'));
root.appendChild(hailoStatus(cogs, workerStatus));
return () => {};
},
};
// ── Installed COG card ───────────────────────────────────────────────
function installedCogCard(c) {
const verified = c.sha256_verified && c.signature_verified;
const shield = h(`span.shield.${verified ? 'ok' : 'bad'}`, (verified ? '✓ ' : '✗ ') + 'verified');
const archPill = c.arch === 'hailo10' ? pill('hailo10', 'purple') : pill('arm', 'cyan');
const body = h('div',
h('.flex.spread',
h('strong.mono', `${c.id} ${c.version}`),
statusPill(c.status)),
h('.flex.wrap.gap-sm.mt', archPill, shield,
h('span.t2', 'PID '), mono(c.pid == null ? '—' : c.pid)));
if (c.status === 'failed' && c.error) {
body.appendChild(h('.red.mt', { style: { fontFamily: 'var(--mono)', fontSize: '12px' } }, c.error));
}
// action ghost buttons
const actions = h('.flex.wrap.gap-sm.mt',
button('Start', { onClick: () => {} }),
button('Stop', { onClick: () => {} }),
button('Restart', { onClick: () => {} }));
body.appendChild(actions);
// View logs drawer
const logDrawer = h('pre.log.mt.hidden', logText(c));
let logsOpen = false;
const logsBtn = button('View logs', {
onClick: () => { logsOpen = !logsOpen; logDrawer.classList.toggle('hidden', !logsOpen); logsBtn.textContent = logsOpen ? 'Hide logs' : 'View logs'; },
});
actions.appendChild(logsBtn);
// Edit config.json drawer (textarea, no persistence)
const cfgArea = h('textarea.json.mt.hidden', { rows: 8, spellcheck: 'false' });
cfgArea.value = configJson(c);
let cfgOpen = false;
const cfgBtn = button('Edit config.json', {
onClick: () => { cfgOpen = !cfgOpen; cfgArea.classList.toggle('hidden', !cfgOpen); cfgBtn.textContent = cfgOpen ? 'Close config' : 'Edit config.json'; },
});
actions.appendChild(cfgBtn);
body.appendChild(logDrawer);
body.appendChild(cfgArea);
return card({ tint: c.status === 'failed' ? 'red' : null, children: [body] });
}
function logText(c) {
if (c.status === 'failed' && c.error) {
return [
`[error] ${c.id} v${c.version} exited`,
`[error] ${c.error}`,
`[info] supervisor: marking ${c.id} failed; PID was ${c.pid == null ? 'none' : c.pid}`,
].join('\n');
}
if (c.status === 'stopped') {
return `[info] ${c.id} v${c.version} stopped by operator\n[info] supervisor: PID released`;
}
return [
`[info] ${c.id} v${c.version} running (pid ${c.pid})`,
`[info] arch=${c.arch} sha256_verified=${c.sha256_verified} signature_verified=${c.signature_verified}`,
c.arch === 'hailo10' ? `[info] hailo: ${asArray(c.hef).join(', ') || 'no HEF loaded'} @ ${c.throughput_fps || '—'} fps` : '[info] cpu-only worker, no Hailo offload',
'[info] heartbeat ok',
].join('\n');
}
function configJson(c) {
const cfg = {
id: c.id,
version: c.version,
arch: c.arch,
autostart: c.status !== 'stopped',
};
if (c.arch === 'hailo10') {
cfg.hef = asArray(c.hef);
cfg.target_fps = c.throughput_fps || null;
}
return JSON.stringify(cfg, null, 2);
}
// Coerce a forwarded manifest `hef` (array | string | object | null) into an
// array so a non-array value degrades gracefully instead of throwing on
// .forEach/.join/.length (the gateway forwards it verbatim — §11).
function asArray(v) {
if (Array.isArray(v)) return v;
if (v == null || v === '') return [];
return [v];
}
// ── OTA update diff card ─────────────────────────────────────────────
function updateCard(u) {
const diff = h('div',
h('.flex.gap-sm',
h('strong.mono', u.id),
mono(u.from), h('span.t3', '→'), h('span.mono.green', u.to)),
diffList('New entities', u.new_entities, 'green'),
diffList('Config changes', u.config_changes, 'amber'),
h('.flex.gap-sm.mt',
button('Update', { variant: 'primary', onClick: () => {} }),
button('Skip', { onClick: () => {} })));
return card({ children: [diff] });
}
function diffList(title, items, color) {
if (!items || !items.length) return null;
const list = h('div.mt', h('h3', title));
items.forEach((e) => list.appendChild(h('.row', h(`span.mono.${color}`, e))));
return list;
}
// ── Hailo HEF status ─────────────────────────────────────────────────
function hailoStatus(cogs, workerStatus = 'unknown') {
const hailoCogs = cogs.filter((c) => c.arch === 'hailo10');
// statusPill maps 'running'/'connected'→green, 'unreachable'/'error'→red,
// 'unknown'→grey; the real probe drives the colour, never a hardcode.
const worker = h('.flex.gap-sm', statusPill(workerStatus), h('span.mono.t2', 'ruvector-hailo-worker:50051'));
const body = h('div', worker);
if (!hailoCogs.length) {
body.appendChild(h('.muted-empty', 'No Hailo-sourced COGs loaded.'));
} else {
hailoCogs.forEach((c) => {
const hef = asArray(c.hef); // gateway forwards manifest `hef` verbatim — may be a string
const hefRows = h('div',
h('.flex.spread', h('strong.mono', `${c.id} ${c.version}`), pill((c.throughput_fps || 0) + ' fps', 'purple')));
hef.forEach((f) => hefRows.appendChild(h('.row', h('span.mono.purple', f), h('span.t2', 'loaded'))));
if (!hef.length) hefRows.appendChild(h('.muted-empty', 'no .hef files loaded'));
body.appendChild(h('.mt', hefRows));
});
}
body.appendChild(h('.t3.mt', { style: { fontSize: '12px' } },
'RF Foundation Encoder (ADR-150) will appear here once available.'));
return card({ children: [body] });
}
@@ -0,0 +1,153 @@
// §4.1 System Dashboard — the "home screen".
// v0 Appliance health strip (always top) + SEED fleet overview +
// ESP32 summary + COG runtime status row + event-bus sparkline.
import { h, clear, card, metric, pill, statusPill, sectionHeader, sparkline, provenanceBadge } from '../ui.js';
export default {
meta: { title: 'System Dashboard' },
async render(root, ctx) {
const { api } = ctx;
root.appendChild(sectionHeader('System Dashboard', 'Cognitum v0 Appliance — the machine you are looking at'));
if (api.anyDemo()) root.appendChild(h('.banner.amber', 'DEMO mode (?demo=1) — panels show contract-conformant fixture data, not live (ADR-131 §2.2).'));
// Each section loads independently so one offline upstream can't blank
// the dashboard (§11.1). A failed section renders a typed error card.
let cleanupEvent = () => {};
// ── v0 Appliance health strip (always at top) ──────────────────
await section(root, 'v0 Appliance health', async () => {
const a = await api.appliance();
const strip = h('.metric-grid',
metric({ icon: '🖥', value: pctOrNA(a.cpu_pct), label: 'CPU' }),
metric({ icon: '🧠', value: pctOrNA(a.ram_pct), label: 'RAM' }),
metric({ icon: '⚡', value: pctOrNA(a.hailo_load_pct), label: 'Hailo-10H load' }),
metric({ icon: '🌡', value: unitOrNA(a.hailo_temp_c, '°C'), label: 'Hailo temp' }),
metric({ icon: '⏱', value: fmtUptime(a.uptime_s), label: 'Uptime', color: 'green' }));
const healthCard = card({ title: 'v0 Appliance health', children: [strip, servicesRow(a.services)] });
return h('div', healthCard, eventBus(a, ctx, (fn) => { cleanupEvent = fn; }));
});
// ── SEED fleet overview + ESP32 summary ────────────────────────
await section(root, 'SEED Fleet', async () => {
const wrap = h('div');
const seeds = await api.seeds();
const warnings = await api.esp32Warnings().catch(() => []);
const grid = h('.grid.cols-3');
seeds.forEach((s) => grid.appendChild(seedCard(s, ctx)));
wrap.appendChild(h('h2', 'SEED Fleet'));
wrap.appendChild(grid);
wrap.appendChild(esp32Summary(seeds, warnings));
return wrap;
});
// ── COG runtime status row ─────────────────────────────────────
await section(root, 'COG Runtime', async () => cogRow(await api.cogs(), ctx));
return () => cleanupEvent();
},
};
// Run one dashboard section; on failure append a typed error card instead
// of throwing (so the rest of the dashboard still renders).
async function section(root, label, build) {
try { root.appendChild(await build()); }
catch (e) {
root.appendChild(card({ children: [
h('.banner.red', `${label} unavailable — ${e && e.message ? e.message : e}`),
h('small.ts', e && e.upstreamUnavailable ? 'upstream not yet wired (ADR-131 §12)' : 'check the gateway / homecore-server'),
] }));
}
}
function servicesRow(services) {
const wrap = h('.flex.wrap.mt');
services.forEach((s) => wrap.appendChild(h('span.flex.gap-sm', statusPill(s.status), h('span.mono.t2', `${s.name}:${s.port}`))));
return wrap;
}
function seedCard(s, ctx) {
const offline = !s.online;
const c = card({
tint: offline ? 'red' : null, clickable: true,
onClick: () => ctx.navigate('#/seed/' + s.device_id),
children: [
h('.flex.spread', h('strong.mono', s.device_id), statusPill(s.online ? 'online' : 'offline')),
h('.kv.mt',
h('span.k', 'Firmware'), h('span.v.mono', s.firmware),
h('span.k', 'Epoch'), h('span.v.purple', String(s.epoch)),
h('span.k', 'Vectors'), h('span.v', s.vector_count.toLocaleString()),
h('span.k', 'Last ingest'), h('span.v', relAgo(s.last_ingest)),
h('span.k', 'Witness'), s.witness_valid ? pill('valid', 'green') : pill('invalid', 'red')),
sensorSummary(s.sensors),
],
});
return c;
}
function sensorSummary(sensors) {
if (!sensors) return h('.muted-empty', 'sensors offline');
return h('.flex.wrap.gap-sm.mt',
pill('PIR ' + (sensors.pir.motion ? 'motion' : 'still'), sensors.pir.motion ? 'amber' : 'grey'),
pill('door ' + (sensors.reed.open ? 'open' : 'closed'), sensors.reed.open ? 'amber' : 'grey'),
pill(sensors.bme280.temp_c + '°C', 'cyan'));
}
function esp32Summary(seeds, warnings) {
const total = seeds.reduce((n, s) => n + s.esp32_nodes, 0);
const body = h('div',
h('.flex.wrap',
...seeds.filter((s) => s.esp32_nodes > 0).map((s) =>
h('span.flex.gap-sm', h('span.mono.t2', s.device_id), pill(s.esp32_nodes + ' nodes', 'cyan'), h('span.t2', s.frame_rate_hz + ' Hz')))));
if (warnings.length) {
body.appendChild(h('.mt', h('h3', 'Warnings (target 100 Hz CSI + 1 Hz vectors)')));
warnings.forEach((w) => body.appendChild(h('.row', h('span.mono', w.node_id), h('span.amber', w.issue))));
}
return card({ title: `ESP32 Nodes — ${total} active`, children: [body] });
}
function cogRow(cogs, ctx) {
const row = h('.flex.wrap.gap-sm');
cogs.forEach((c) => {
const p = statusPill(c.status);
const wrap = h('span.flex.gap-sm.clickable', { style: { cursor: 'pointer' }, onClick: () => ctx.navigate('#/cogs') },
p, h('span.mono.t2', c.id), c.arch === 'hailo10' ? pill('hailo', 'purple') : null);
row.appendChild(wrap);
});
return card({ title: 'COG Runtime', children: [row] });
}
function eventBus(a, ctx, setCleanup) {
const rates = a.event_rate || [];
const spark = sparkline(rates, { w: 240, hgt: 36 });
const rate = rates.length ? rates[rates.length - 1] : 0;
const lag = a.channel_lag || 0;
const cap = a.channel_capacity || 4096;
const body = h('div',
h('.flex.spread', h('span.val.cyan', { style: { fontSize: '20px' } }, rate + ' ev/s'),
h('span.t2', `capacity ${cap.toLocaleString()}`)),
spark);
if (lag > 0) body.appendChild(h('.banner.amber.mt', `Subscriber falling behind — ${lag} events lagged against the ${cap.toLocaleString()} capacity`));
const host = h('span.t2');
const un = ctx.onWs((st) => { clear(host); host.appendChild(document.createTextNode(st.state === 'open' ? (st.lagged ? ' · WS lagging' : ' · WS live') : ' · WS offline')); });
body.appendChild(host);
if (setCleanup) setCleanup(un);
return card({ title: 'Event Bus activity', children: [body] });
}
// §6 honesty: a null/undefined metric must render a distinct not-available
// state ('—'), never a fabricated value like "null%"/"null°C".
function pctOrNA(v) { return v == null ? '—' : v + '%'; }
function unitOrNA(v, unit) { return v == null ? '—' : v + unit; }
function fmtUptime(s) {
if (s == null) return '—';
const d = Math.floor(s / 86400), hh = Math.floor((s % 86400) / 3600);
return d > 0 ? `${d}d ${hh}h` : `${hh}h`;
}
function relAgo(iso) {
const s = Math.round((Date.now() - Date.parse(iso)) / 1000);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.round(s / 60) + 'm ago';
return Math.round(s / 3600) + 'h ago';
}
@@ -0,0 +1,240 @@
// §4.4 Entity & State Browser — live /api/states (real homecore REST).
//
// Entities grouped by domain (prefix before '.') in collapsible sections.
// Each row carries entity_id (mono), current state, last-changed (relTime),
// an INLINE provenanceBadge (§6 invariant 1 — SEED chain never collapsed),
// and a collapsible attributes JSON view. A keyword filter (entity_id +
// attribute keys/values) runs live; semantic search (ADR-132) is a future
// hint. State changes arrive over WebSocket (ctx.onEvent) — rows patch in
// place and flash; NEVER poll. The broadcast-channel lag indicator
// (ctx.onWs) warns when the subscriber falls behind the 4,096 capacity.
import {
h, clear, card, pill, sectionHeader, mono, provenanceBadge,
slideover, collapsible, lagIndicator, relTime, banner,
} from '../ui.js';
import { api, entityProvenance } from '../api.js';
export default {
meta: { title: 'Entities' },
async render(root, ctx) {
root.appendChild(sectionHeader('Entity & State Browser', 'Live /api/states — every entity, grouped by domain, with SEED provenance'));
// ── lag indicator (broadcast channel vs 4,096 capacity) ─────────
const lagHost = h('.flex.spread.mb');
const lagSlot = h('span', lagIndicator('connecting', false));
lagHost.appendChild(lagSlot);
root.appendChild(lagHost);
// ── search / filter controls ────────────────────────────────────
const search = h('input.search', {
type: 'text',
placeholder: 'Filter entities — id, attribute keys & values (case-insensitive)…',
});
const semantic = h('input.search', { type: 'text', placeholder: 'Semantic search (ADR-132)' });
semantic.disabled = true;
semantic.style.opacity = '0.5';
root.appendChild(h('.flex.wrap.mb', { style: { gap: '8px' } },
h('div', { style: { flex: '2', minWidth: '220px' } }, search),
h('div', { style: { flex: '1', minWidth: '180px' } }, semantic)));
// ── load live state view ────────────────────────────────────────
const listHost = h('div');
root.appendChild(listHost);
// Production /api/states now THROWS on failure — there is NO mock
// fallback. A failed load is an error state, not a DEMO substitution.
let states;
try {
states = await api.states();
} catch (e) {
listHost.appendChild(banner('/api/states unavailable — ' + (e && e.message ? e.message : e), 'red'));
return () => {};
}
if (!Array.isArray(states)) states = [];
// Demo mode legitimately serves fixtures (demoFlags.states is set by a
// successful api.states() in demo mode) — label that, not a fallback.
if (api.isDemo('states')) {
root.insertBefore(banner('Demo mode — showing contract-conformant fixture entities (§7.1).', 'amber'), listHost);
}
// index by entity_id so WS patches are O(1)
const byId = new Map();
states.forEach((s) => byId.set(s.entity_id, s));
// per-entity row controllers (set state text + flash)
const rows = new Map();
function render() {
clear(listHost);
const q = search.value.trim().toLowerCase();
const groups = groupByDomain([...byId.values()], q);
if (!groups.size) {
listHost.appendChild(h('.muted-empty', q ? 'No entities match the filter.' : 'No entities reported.'));
return;
}
// stable alphabetical domain order
[...groups.keys()].sort().forEach((domain) => {
const ents = groups.get(domain).sort((a, b) => a.entity_id.localeCompare(b.entity_id));
const header = h('.flex.gap-sm', h('strong.mono', domain), pill(ents.length, 'cyan'));
const section = collapsible(header, () => {
const body = h('div');
ents.forEach((e) => body.appendChild(entityRow(e)));
return body;
}, true);
listHost.appendChild(card({ children: [section] }));
});
}
function entityRow(e) {
const stateText = h('span.t1.mono', String(e.state));
const changed = h('span.t3', relTime(e.last_changed));
const top = h('.flex.spread', { style: { cursor: 'pointer', gap: '12px' }, onClick: () => openDetail(e) },
h('.flex.wrap.gap-sm', { style: { flex: '1', minWidth: '0' } },
mono(e.entity_id),
stateText,
changed),
// SEED provenance badge — INLINE, never collapsed (§6 invariant 1)
provenanceBadge(entityProvenance(e)));
const attrs = collapsible(h('span.t2', 'attributes'),
() => h('pre.json', JSON.stringify(e.attributes || {}, null, 2)), false);
const wrap = h('.entity-row', { style: { padding: '8px 0', borderBottom: '0.67px solid var(--border)' } }, top, attrs);
rows.set(e.entity_id, { stateText, changed, wrap });
return wrap;
}
function openDetail(e) {
const chain = contextChain(e.context, byId);
const content = h('div',
h('.kv',
h('span.k', 'entity_id'), h('span.v.mono', e.entity_id),
h('span.k', 'state'), h('span.v.mono', String(e.state)),
h('span.k', 'last changed'), h('span.v', relTime(e.last_changed)),
h('span.k', 'last updated'), h('span.v', relTime(e.last_updated))),
h('.mt', h('h3', 'Provenance'), provenanceBadge(entityProvenance(e))),
h('.mt', h('h3', 'Context causality'), chain),
h('.mt', h('h3', 'Attributes'), h('pre.json', JSON.stringify(e.attributes || {}, null, 2))));
slideover(e.entity_id, content);
}
render();
search.addEventListener('input', render);
// ── live WebSocket: patch state in place + flash (never poll) ────
const unEvent = ctx.onEvent((ev) => {
if (!ev || ev.event_type !== 'state_changed' || !ev.entity_id) return;
const cur = byId.get(ev.entity_id);
const ns = ev.new_state || {};
if (cur) {
// merge live fields onto the existing record
cur.state = ns.state != null ? ns.state : cur.state;
if (ns.attributes) cur.attributes = ns.attributes;
if (ns.last_changed) cur.last_changed = ns.last_changed;
if (ns.last_updated) cur.last_updated = ns.last_updated;
if (ns.context) cur.context = ns.context;
patchRow(ev.entity_id);
} else {
// a newly-appeared entity — fold it in and re-render the group
byId.set(ev.entity_id, {
entity_id: ev.entity_id,
state: ns.state != null ? ns.state : 'unknown',
attributes: ns.attributes || {},
last_changed: ns.last_changed || new Date().toISOString(),
last_updated: ns.last_updated || new Date().toISOString(),
context: ns.context || { id: null, user_id: null, parent_id: null },
});
render();
patchRow(ev.entity_id);
}
});
function patchRow(id) {
const e = byId.get(id);
const r = rows.get(id);
if (!e || !r) return;
r.stateText.textContent = String(e.state);
r.changed.textContent = relTime(e.last_changed);
// flash cyan then revert after 800ms (§4.4 live feedback)
r.stateText.style.color = 'var(--cyan)';
r.stateText.style.transition = 'none';
setTimeout(() => {
r.stateText.style.transition = 'color .6s ease';
r.stateText.style.color = '';
}, 800);
}
// ── broadcast-channel lag indicator ─────────────────────────────
const unWs = ctx.onWs((st) => {
clear(lagSlot);
lagSlot.appendChild(lagIndicator(st.state, st.lagged));
if (st.lagged) {
lagSlot.title = 'Subscriber behind the 4,096-event capacity — some state_changed events were dropped';
}
});
return () => { unEvent(); unWs(); };
},
};
/**
* Group entities by domain (prefix before the first '.'), applying the
* keyword filter across entity_id AND attribute keys/values.
*/
function groupByDomain(entities, q) {
const groups = new Map();
for (const e of entities) {
if (q && !matches(e, q)) continue;
const dot = e.entity_id.indexOf('.');
const domain = dot > 0 ? e.entity_id.slice(0, dot) : '(no domain)';
if (!groups.has(domain)) groups.set(domain, []);
groups.get(domain).push(e);
}
return groups;
}
/** Case-insensitive match across entity_id, state and attribute keys/values. */
function matches(e, q) {
if (e.entity_id.toLowerCase().includes(q)) return true;
if (String(e.state).toLowerCase().includes(q)) return true;
const attrs = e.attributes || {};
for (const [k, v] of Object.entries(attrs)) {
if (k.toLowerCase().includes(q)) return true;
try {
if (String(typeof v === 'object' ? JSON.stringify(v) : v).toLowerCase().includes(q)) return true;
} catch (_) { /* circular/unstringifiable — skip */ }
}
return false;
}
/**
* Render the Context causality chain (context.id parent_id) as a mono
* breadcrumb trail. Walks parent_id up through known contexts when the
* parent entity is present, otherwise shows the raw id.
*/
function contextChain(ctxObj, byId) {
if (!ctxObj || !ctxObj.id) return h('span.t3', 'no context');
const seen = new Set();
const ids = [];
let cur = ctxObj;
while (cur && cur.id && !seen.has(cur.id)) {
seen.add(cur.id);
ids.unshift(cur.id);
if (!cur.parent_id) break;
ids.unshift(cur.parent_id);
seen.add(cur.parent_id);
cur = findContext(cur.parent_id, byId);
}
const trail = h('.flex.wrap.gap-sm');
ids.forEach((id, i) => {
if (i > 0) trail.appendChild(h('span.arr.t3', '→'));
trail.appendChild(mono(id));
});
return trail;
}
function findContext(id, byId) {
for (const e of byId.values()) {
if (e.context && e.context.id === id) return e.context;
}
return null;
}
@@ -0,0 +1,308 @@
// §4.8 Event Bus & Automation Feed — ADR-131 / ADR-129.
//
// Live event stream (seeded from /api/events, then prepended live from
// the shared WS bus — never polled, §2/§4.4), a context-causality
// breadcrumb on row expand (Context.id → parent_id → grandparent_id),
// and a trigger→condition→action automation builder (ADR-129 scope:
// UI-only, no backend persistence — rules live in a local array).
import {
h, clear, card, pill, statusPill, sectionHeader, mono, relTime,
collapsible, lagIndicator, button, banner,
} from '../ui.js';
const MAX_ROWS = 200; // virtualization-lite: cap DOM rows, drop oldest.
// event-type → pill colour variant (§4.8).
const VARIANT = {
StateChanged: 'cyan',
EntityRegistered: 'green',
ConfigReloaded: 'purple',
};
function typePill(type) {
return pill(type, VARIANT[type] || 'grey');
}
// A live WS event carries event_type:'state_changed'; normalise it into
// the same record shape as api.recentEvents() so the row renderer is one
// code path.
function normalizeLive(evt) {
return {
type: 'StateChanged',
entity_id: evt.entity_id,
old_state: evt.old_state,
new_state: evt.new_state,
ts: new Date().toISOString(),
user_id: null,
context: { id: null, parent_id: null, grandparent_id: null },
source: 'live',
_live: true,
};
}
const domainOf = (id) => String(id || '').split('.')[0] || '';
export default {
meta: { title: 'Events' },
async render(root, ctx) {
const { api } = ctx;
const unsubs = [];
root.appendChild(sectionHeader('Event Bus & Automation', 'Live entity events + causality + automation builder (ADR-131 §4.8, ADR-129)'));
if (api.isDemo('events')) {
root.appendChild(banner('DEMO — event history is contract-conformant mock data until the live /api/events feed lands (§7.1). New rows still arrive over the WS bus.', 'amber'));
}
// ── live lag indicator (top, fed by the shared WS bus) ──────────
const lagHost = h('span');
const paintLag = (st) => { clear(lagHost); lagHost.appendChild(lagIndicator(st.state, st.lagged)); };
unsubs.push(ctx.onWs(paintLag)); // fires immediately
// ── filter bar (mirrors the Cog Store .search field) ────────────
let filter = '';
const search = h('input.search', {
type: 'text',
placeholder: 'Filter by entity domain · event type · source (e.g. "sensor", "ConfigReloaded", "seed-")',
});
search.addEventListener('input', () => { filter = search.value.trim().toLowerCase(); applyFilter(); });
const list = h('.event-stream', { style: { maxHeight: '460px', overflowY: 'auto' } });
let rows = []; // { record, node } newest-first, capped to MAX_ROWS.
function matches(rec) {
if (!filter) return true;
const hay = [rec.type, rec.entity_id, domainOf(rec.entity_id), rec.source, rec.user_id]
.filter(Boolean).join(' ').toLowerCase();
return hay.includes(filter);
}
function applyFilter() {
for (const r of rows) r.node.classList.toggle('hidden', !matches(r.record));
}
function prepend(rec) {
const node = eventRow(rec);
rows.unshift({ record: rec, node });
list.insertBefore(node, list.firstChild);
node.classList.toggle('hidden', !matches(rec));
while (rows.length > MAX_ROWS) {
const old = rows.pop();
if (old.node.parentNode) old.node.parentNode.removeChild(old.node);
}
}
// seed from history (oldest first → prepend so newest ends on top).
// Wrap ONLY the history load: a missing/unwired recorder must NOT fail
// the panel — render an inline note and continue with an empty history.
// The live ctx.onEvent feed (below) attaches regardless (§12 W3).
let history = [];
let historyNote = null;
try {
history = await api.recentEvents(40);
} catch (e) {
history = [];
historyNote = banner('Event history unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (recorder not yet wired — ADR-131 §12 W3)' : ''), 'amber');
}
for (let i = history.length - 1; i >= 0; i--) prepend(history[i]);
if (!rows.length) list.appendChild(h('.muted-empty', 'No events yet — live events will appear here as they arrive.'));
// live events prepend as they arrive (never poll).
unsubs.push(ctx.onEvent((evt) => {
// strip the placeholder empty-state once real rows arrive.
const empty = list.querySelector('.muted-empty');
if (empty) empty.remove();
prepend(normalizeLive(evt));
}));
root.appendChild(card({
title: 'Live event stream',
children: [historyNote, h('.flex.spread.mb', h('span.t2', 'Newest first · capped to ' + MAX_ROWS + ' rows'), lagHost), search, list],
}));
// ── automation builder (ADR-129) ────────────────────────────────
root.appendChild(automationBuilder(api));
return () => { unsubs.forEach((u) => { try { u(); } catch {} }); };
},
};
// ── event row + causality breadcrumb ──────────────────────────────────
function eventRow(rec) {
const head = h('.flex.gap-sm.wrap',
typePill(rec.type),
h('strong.mono', rec.entity_id),
rec.type === 'StateChanged'
? h('span.t2', mono(rec.old_state == null ? '∅' : rec.old_state), h('span.arr.t3', { style: { margin: '0 6px' } }, '→'), mono(rec.new_state == null ? '∅' : rec.new_state))
: null,
h('span', { style: { marginLeft: 'auto' } }, h('small.ts', relTime(rec.ts))),
rec.user_id ? pill('@' + rec.user_id, 'amber') : h('small.ts', 'system'),
rec.source ? h('span.mono.t3', rec.source) : null);
return h('.event-row', { style: { padding: '6px 0', borderBottom: '0.67px solid var(--border)' } },
collapsible(head, () => causalityBreadcrumb(rec.context), false));
}
function causalityBreadcrumb(c) {
const wrap = h('.causality', { style: { padding: '8px 0 4px' } });
wrap.appendChild(h('span.t2', { style: { marginRight: '8px' } }, 'Context chain'));
const chain = [
['id', c && c.id],
['parent', c && c.parent_id],
['grandparent', c && c.grandparent_id],
].filter(([, v]) => v != null);
if (!chain.length) {
wrap.appendChild(h('span.t3', 'no context recorded for this event'));
return wrap;
}
chain.forEach(([label, val], i) => {
if (i > 0) wrap.appendChild(h('span.arr.t3', { style: { margin: '0 8px' } }, '→'));
wrap.appendChild(h('span.flex.gap-sm', { style: { display: 'inline-flex' } },
h('small.ts', label), mono(val)));
});
return wrap;
}
// ── automation builder (trigger → condition → action) ─────────────────
const TRIGGERS = [
{ id: 'state_changed', label: 'state_changed on RoomState entity' },
{ id: 'seed_reflex', label: 'SEED reflex rule fired' },
{ id: 'custom_event', label: 'custom domain_event topic' },
];
const REFLEX_RULES = ['fragility_alarm', 'hd_anomaly_indicator'];
const ACTION_KINDS = [
{ id: 'call_service', label: 'Call service' },
{ id: 'fire_event', label: 'Fire domain event' },
];
function automationBuilder(api) {
const rules = [];
const listHost = h('div');
// Default callable-service options; enriched asynchronously from the
// live service registry when reachable (failures are swallowed — the
// builder stays usable with defaults, and we never leave a dangling
// rejected promise in production).
const serviceOpts = ['light.turn_on', 'light.turn_off', 'notify.mobile', 'homecore.recalibrate_room'];
Promise.resolve()
.then(() => api.services())
.then((services) => {
(services || []).forEach((s) => {
const name = (s.domain && s.service) ? `${s.domain}.${s.service}` : String(s.name || s.id || s);
if (name && !serviceOpts.includes(name)) { serviceOpts.push(name); serviceSel.appendChild(h('option', { value: name }, name)); }
});
})
.catch(() => {});
// ── trigger editor ──
const triggerSel = sel(TRIGGERS.map((t) => [t.id, t.label]));
const thresholdInput = h('input.search.mono', { type: 'text', placeholder: 'threshold expression — e.g. anomaly.value > 0.8' });
const reflexSel = sel(REFLEX_RULES.map((r) => [r, r]));
const customInput = h('input.search.mono', { type: 'text', placeholder: 'domain_event topic — e.g. presence.regime_change' });
const triggerExtra = h('div', { style: { marginTop: '8px' } });
function paintTriggerExtra() {
clear(triggerExtra);
if (triggerSel.value === 'state_changed') triggerExtra.appendChild(thresholdInput);
else if (triggerSel.value === 'seed_reflex') triggerExtra.appendChild(field('Reflex rule', reflexSel));
else triggerExtra.appendChild(customInput);
}
triggerSel.addEventListener('change', paintTriggerExtra);
paintTriggerExtra();
// ── condition editor ──
const conditionInput = h('input.search.mono', { type: 'text', placeholder: 'condition expression — e.g. room.living_room.presence == "occupied"' });
// ── action editor ──
const actionSel = sel(ACTION_KINDS.map((a) => [a.id, a.label]));
const serviceSel = sel(serviceOpts.map((s) => [s, s]));
const eventInput = h('input.search.mono', { type: 'text', placeholder: 'domain event to fire — e.g. automation.lr_night_dim' });
const actionExtra = h('div', { style: { marginTop: '8px' } });
function paintActionExtra() {
clear(actionExtra);
if (actionSel.value === 'call_service') actionExtra.appendChild(field('Service', serviceSel));
else actionExtra.appendChild(eventInput);
}
actionSel.addEventListener('change', paintActionExtra);
paintActionExtra();
function buildTrigger() {
if (triggerSel.value === 'state_changed') return { kind: 'state_changed', entity: 'RoomState', threshold: thresholdInput.value.trim() };
if (triggerSel.value === 'seed_reflex') return { kind: 'seed_reflex', rule: reflexSel.value };
return { kind: 'custom_event', topic: customInput.value.trim() };
}
function buildAction() {
if (actionSel.value === 'call_service') return { kind: 'call_service', service: serviceSel.value };
return { kind: 'fire_event', event: eventInput.value.trim() };
}
const addBtn = button('Add automation', {
variant: 'primary',
onClick: () => {
rules.push({ trigger: buildTrigger(), condition: conditionInput.value.trim(), action: buildAction() });
thresholdInput.value = ''; customInput.value = ''; conditionInput.value = ''; eventInput.value = '';
renderRules();
},
});
function renderRules() {
clear(listHost);
if (!rules.length) { listHost.appendChild(h('.muted-empty', 'No automations defined yet (UI-only — not persisted).')); return; }
rules.forEach((r, i) => listHost.appendChild(ruleCard(r, i, () => { rules.splice(i, 1); renderRules(); })));
}
renderRules();
const builder = card({
title: 'Automation builder',
children: [
h('.t3.mb', 'Trigger → condition → action (ADR-129). UI scope only — assembled rules are held locally, not persisted to the appliance.'),
h('.grid.cols-3',
card({ title: 'Trigger', tint: null, children: [field('When', triggerSel), triggerExtra] }),
card({ title: 'Condition', children: [field('And', conditionInput)] }),
card({ title: 'Action', children: [field('Then', actionSel), actionExtra] })),
h('.flex.mt', addBtn),
],
});
return h('div', builder, card({ title: 'Defined automations', children: [listHost] }));
}
function ruleCard(r, i, onDelete) {
return card({
children: [
h('.flex.spread',
h('strong', 'Automation #' + (i + 1)),
button('Remove', { variant: 'ghost', onClick: onDelete })),
h('.flex.gap-sm.wrap.mt',
pill('TRIGGER', 'cyan'), triggerSummary(r.trigger)),
r.condition
? h('.flex.gap-sm.wrap.mt', pill('IF', 'amber'), mono(r.condition))
: h('.flex.gap-sm.wrap.mt', pill('IF', 'grey'), h('span.t3', 'always')),
h('.flex.gap-sm.wrap.mt',
pill('ACTION', 'purple'), actionSummary(r.action)),
],
});
}
function triggerSummary(t) {
if (t.kind === 'state_changed') return h('span', mono('RoomState'), ' ', t.threshold ? mono(t.threshold) : h('span.t3', '(any change)'));
if (t.kind === 'seed_reflex') return h('span', h('span.t2', 'reflex '), mono(t.rule || '—'));
return h('span', h('span.t2', 'event '), mono(t.topic || '—'));
}
function actionSummary(a) {
if (a.kind === 'call_service') return h('span', h('span.t2', 'call '), mono(a.service || '—'));
return h('span', h('span.t2', 'fire '), mono(a.event || '—'));
}
// ── small form helpers ────────────────────────────────────────────────
function sel(pairs) {
const s = h('select.inline', { style: { width: '100%' } });
for (const [val, label] of pairs) {
const o = document.createElement('option');
o.value = val; o.textContent = label;
s.appendChild(o);
}
return s;
}
function field(label, control) {
return h('label', { style: { display: 'block', marginTop: '8px' } },
h('span.k.t2', { style: { display: 'block', marginBottom: '4px', fontSize: '12.5px' } }, label),
control);
}
@@ -0,0 +1,198 @@
// §4.2 SEED Fleet overview + §4.3 SEED Fleet Map (node topology +
// ESP-NOW mesh + cross-SEED event dedup) + ADR-105 federation config.
//
// One panel covering: the fleet card grid, the v0→SEED→ESP32 node
// hierarchy, the mesh-link table, the cross-SEED fusion badges, and the
// federation round config — with the §3.3 "model deltas only — never raw
// CSI" invariant surfaced prominently (ADR-105 privacy guarantee).
import { h, card, pill, statusPill, sectionHeader, relTime, banner } from '../ui.js';
export default {
meta: { title: 'SEED Fleet' },
async render(root, ctx) {
const { api } = ctx;
root.appendChild(sectionHeader('SEED Fleet', 'Cross-SEED topology, ESP-NOW mesh & ADR-105 federation'));
// ── Load seeds + federation independently so one failing upstream
// doesn't blank the whole panel (ADR-131 §2.2 / §11.11). ───────
let seeds = null, fed = null;
try { seeds = await api.seeds(); } catch (e) {
root.appendChild(banner('SEED fleet unavailable — ' + (e.message || e)
+ (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
}
try { fed = await api.federation(); } catch (e) {
root.appendChild(banner('SEED fleet unavailable — ' + (e.message || e)
+ (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
}
if (api.isDemo('fleet')) {
root.appendChild(h('.banner.amber',
'DEMO — the SEED HTTPS API and the ADR-105 federation service are not served by this homecore-server binary. '
+ 'These panels render against their defined contract with contract-conformant mock data (ADR-131 §7.1).'));
}
// ── §4.2 SEED fleet overview ──────────────────────────────────────
if (seeds) {
root.appendChild(h('h2', 'Fleet overview'));
const grid = h('.grid.cols-3');
seeds.forEach((s) => grid.appendChild(seedCard(s, ctx)));
root.appendChild(grid);
// ── §4.3 Node hierarchy (v0 → SEED → ESP32) ─────────────────────
root.appendChild(card({ title: 'Node hierarchy', children: [hierarchy(seeds)] }));
}
if (fed) {
// ── §4.3 ESP-NOW mesh links ─────────────────────────────────────
root.appendChild(card({ title: 'ESP-NOW mesh links', children: [meshLinks(fed.mesh_links)] }));
// ── Cross-SEED event dedup / fusion ─────────────────────────────
root.appendChild(card({ title: 'Cross-SEED event dedup', children: [fusionBadges(fed.fused_events)] }));
// ── ADR-105 federation config ───────────────────────────────────
root.appendChild(federationConfig(fed));
}
return () => {};
},
};
// ── §4.2 SEED card ──────────────────────────────────────────────────
function seedCard(s, ctx) {
const offline = !s.online;
return card({
tint: offline ? 'red' : null, clickable: true,
onClick: () => ctx.navigate('#/seed/' + s.device_id),
children: [
h('.flex.spread',
h('strong.mono', s.device_id),
statusPill(s.online ? 'online' : 'offline')),
h('.kv.mt',
h('span.k', 'Zone'), h('span.v', s.zone),
h('span.k', 'Firmware'), h('span.v.mono', s.firmware),
h('span.k', 'Epoch'), h('span.v.purple', String(s.epoch)),
h('span.k', 'Vectors'), h('span.v', (s.vector_count || 0).toLocaleString()),
h('span.k', 'Last ingest'), h('span.v', relTime(s.last_ingest))),
h('.flex.wrap.gap-sm.mt',
s.witness_valid ? pill('witness valid', 'green') : pill('witness invalid', 'red')),
sensorSummary(s.sensors),
],
});
}
function sensorSummary(sensors) {
if (!sensors) return h('.muted-empty', 'sensors offline');
return h('.flex.wrap.gap-sm.mt',
pill('PIR ' + (sensors.pir.motion ? 'motion' : 'still'), sensors.pir.motion ? 'amber' : 'grey'),
pill('door ' + (sensors.reed.open ? 'open' : 'closed'), sensors.reed.open ? 'amber' : 'grey'),
pill(sensors.bme280.temp_c + '°C', 'cyan'));
}
// ── §4.3 Node hierarchy diagram (nested indented rows) ──────────────
// v0 Appliance (ROOT) → SEEDs grouped by zone → ESP32 nodes (leaves).
function hierarchy(seeds) {
const wrap = h('.mono', { style: { fontSize: '12.5px', lineHeight: '1.9' } });
// ROOT — the v0 appliance.
wrap.appendChild(treeRow(0, '●', 'cog-v0-appliance', pill('ROOT', 'purple'), null));
// Second tier — SEEDs grouped by .zone.
const byZone = groupBy(seeds, (s) => s.zone || 'unzoned');
const zones = Object.keys(byZone);
zones.forEach((zone, zi) => {
const lastZone = zi === zones.length - 1;
wrap.appendChild(treeRow(1, lastZone ? '└─' : '├─', zone, pill('zone', 'cyan'), null, true));
const zoneSeeds = byZone[zone];
zoneSeeds.forEach((s, si) => {
const lastSeed = si === zoneSeeds.length - 1;
wrap.appendChild(treeRow(2, lastSeed ? '└─' : '├─', s.device_id,
statusPill(s.online ? 'online' : 'offline'), null));
// Leaves — the ESP32 nodes attached to this SEED.
const nodes = (s.ingest && s.ingest.esp32) || [];
if (!nodes.length) {
wrap.appendChild(treeRow(3, '·', '(no ESP32 nodes)', null, null, true));
}
nodes.forEach((n, ni) => {
const lastNode = ni === nodes.length - 1;
wrap.appendChild(treeRow(3, lastNode ? '└─' : '├─', n.node_id,
pill(n.rate_hz + ' Hz', 'grey'), n.packet));
});
});
});
return wrap;
}
function treeRow(depth, connector, label, badge, suffix, muted) {
const row = h('.flex.gap-sm', { style: { paddingLeft: (depth * 18) + 'px' } });
row.appendChild(h('span.t3', connector));
row.appendChild(h(muted ? 'span.t3' : 'span', label));
if (badge) row.appendChild(badge);
if (suffix) row.appendChild(h('span.t3', suffix));
return row;
}
// ── §4.3 ESP-NOW mesh links (dashed rows coloured by .health) ───────
function meshLinks(links) {
if (!links || !links.length) return h('.muted-empty', 'no mesh links reported');
const wrap = h('div');
const colour = { green: 'green', amber: 'amber', red: 'red' };
links.forEach((l) => {
const k = colour[l.health] || 'grey';
wrap.appendChild(h('.flex.gap-sm', { style: { padding: '6px 0' } },
h('span.mono', l.a),
h(`span.${k}`, { style: { letterSpacing: '1px' } }, '╌╌╌'),
h('span.mono', l.b),
pill(l.health, k)));
});
return wrap;
}
// ── Cross-SEED event dedup — fusion badges (kind + n contributing) ──
function fusionBadges(events) {
if (!events || !events.length) return h('.muted-empty', 'no fused cross-SEED events');
const wrap = h('.flex.wrap.gap-sm');
events.forEach((e) => {
const seeds = (e.seeds || []).join(', ');
wrap.appendChild(h('span.flex.gap-sm', { style: { alignItems: 'center' } },
pill(e.kind, 'cyan'),
pill(e.n + ' SEEDs', 'purple'),
h('span.t2.mono', { style: { fontSize: '11px' } }, seeds)));
});
return wrap;
}
// ── ADR-105 federation config ───────────────────────────────────────
function federationConfig(fed) {
const body = h('div');
// CRITICAL invariant — the "model deltas only, never raw CSI" guarantee.
body.appendChild(h('.banner.purple',
{ style: { background: 'var(--purple-d)', color: 'var(--purple)', border: '0.67px solid var(--purple)' } },
h('strong', 'Federation invariant: '),
h('span.mono', fed.invariant)));
body.appendChild(h('.kv.mt',
h('span.k', 'Coordinator SEED'), h('span.v.mono', fed.coordinator),
h('span.k', 'Round'), h('span.v.purple', String(fed.round)),
h('span.k', 'k_healthy'), h('span.v', String(fed.k_healthy)),
h('span.k', 'Delta status'), statusPill(fed.delta_status === 'exchanging' ? 'updating' : fed.delta_status),
h('span.k', 'Krum (f)'), h('span.v', String(fed.krum && fed.krum.f)),
h('span.k', 'Krum mode'), h('span.v', fed.krum && fed.krum.multi ? 'multi-Krum' : 'Krum'),
h('span.k', 'Cadence'), h('span.v', (fed.cadence_min != null ? fed.cadence_min + ' min' : '—'))));
return card({ title: 'Federation config (ADR-105)', accent: true, children: [body] });
}
// ── helpers ─────────────────────────────────────────────────────────
function groupBy(arr, keyFn) {
const out = {};
for (const item of arr) {
const k = keyFn(item);
(out[k] || (out[k] = [])).push(item);
}
return out;
}
@@ -0,0 +1,119 @@
// §4.5 RoomState / Sensing Panel — mixture-of-specialists output.
// Per-room cards from GET /api/v1/room/state?bank=<room_id>.
//
// UX invariants (§4.5/§6): STALE and VETOED are never subtle; veto-
// suppressed values render as withheld, NOT zero; null specialists are
// "Not trained" (calibrate to enable), visually distinct from errors.
import { h, card, pill, statusPill, sectionHeader, bar, confidenceBar, banner, button } from '../ui.js';
export default {
meta: { title: 'Rooms' },
async render(root, ctx) {
const { api } = ctx;
root.appendChild(sectionHeader('RoomState / Sensing', 'Highest-level per-room sensing from the calibration mixture-of-specialists'));
let rooms;
try {
rooms = await api.roomStates();
} catch (e) {
root.appendChild(banner(`RoomState unavailable — ${e && e.message ? e.message : e}. ${e && e.upstreamUnavailable ? 'Calibration service (ADR-151) not reachable through the gateway.' : ''}`, 'red'));
return () => {};
}
if (api.isDemo('rooms')) root.appendChild(banner('DEMO mode (?demo=1) — fixture RoomState, not live calibration output (ADR-131 §2.2).', 'amber'));
if (!rooms.length) { root.appendChild(h('.muted-empty', 'No calibrated rooms yet — run the Calibration wizard to enable sensing.')); return () => {}; }
const grid = h('.grid.cols-2');
rooms.forEach((r) => grid.appendChild(roomCard(r, ctx)));
root.appendChild(grid);
return () => {};
},
};
function roomCard(r, ctx) {
const tint = r.stale ? 'amber' : (r.vetoed ? 'red' : null);
const children = [
h('.flex.spread',
h('strong.mono', r.room_id),
h('.flex.gap-sm',
r.seeds.length > 1 ? pill(r.seeds.length + ' seeds fused', 'purple') : null,
r.vetoed ? pill('veto active', 'red') : null,
r.stale ? pill('stale', 'amber') : null)),
];
// STALE banner — must never be subtle (§4.5)
if (r.stale) {
children.push(banner('Bank stale — baseline has changed', 'amber',
button('Recalibrate room', { variant: 'ghost', onClick: () => ctx.navigate('#/calibration') })));
}
if (r.vetoed) {
children.push(banner('Anomaly veto active — implausible window; vitals/posture withheld', 'red'));
}
children.push(specRow('Presence', presenceChip(r.presence), r.presence));
children.push(specRow('Posture', postureView(r), r.posture));
children.push(vitalRow('Breathing', r.breathing_bpm, 'BPM', [6, 30], r));
children.push(vitalRow('Heart rate', r.heart_bpm, 'BPM', [40, 120], r));
children.push(specRow('Restlessness', barOr(r.restlessness, 1), r.restlessness));
children.push(anomalyRow(r.anomaly));
return card({ tint, children });
}
function specRow(label, valueNode, spec) {
const right = h('.flex.gap-sm');
right.appendChild(valueNode);
if (spec && spec.confidence != null) right.appendChild(confidenceBar(spec.confidence));
return h('.row', h('span.k', label), right);
}
function presenceChip(p) {
if (!p) return notTrainedNode(); // null = not trained
return statusPill(p.value); // occupied → green, absent → grey
}
function postureView(r) {
if (r.posture === null) return notTrainedNode(); // not trained
if (r.vetoed && (!r.posture || r.posture.value == null)) return withheld(); // suppressed, not zero
if (!r.posture || r.posture.value == null) return withheld();
return statusPill(r.posture.value);
}
function vitalRow(label, spec, unit, range, r) {
let valueNode;
if (spec === null) valueNode = notTrainedNode();
else if (r.vetoed && (spec.value == null)) valueNode = withheld();
else if (spec.value == null) valueNode = withheld();
else valueNode = h('span.cyan', `${spec.value} ${unit} `, h('span.t3', `(${range[0]}${range[1]})`));
return specRow(label, valueNode, spec);
}
function anomalyRow(a) {
if (!a) return specRow('Anomaly', notTrainedNode(), null);
// §6 honesty: a null threshold is WITHHELD (the upstream RoomState carried
// none) — show the value but flag the threshold as unavailable rather than
// judging anomalous/normal against a fabricated 0.8 default.
if (a.threshold == null) {
const wrap = h('div', { style: { width: '160px' } },
bar(a.value, 1),
h('small.ts', { title: 'no anomaly threshold from upstream — withheld' }, `${a.value} · threshold —`));
return specRow('Anomaly', wrap, a);
}
const over = a.value > a.threshold;
const b = bar(a.value, 1, [{ lt: a.threshold, color: 'green' }, { lt: 1.01, color: 'red' }]);
const wrap = h('div', { style: { width: '160px' } }, b,
h('small.ts', over ? 'anomalous' : 'normal', ` · ${a.value}`));
return specRow('Anomaly', wrap, a);
}
function barOr(spec, max) {
if (spec === null) return notTrainedNode();
if (!spec || spec.value == null) return withheld();
const wrap = h('div', { style: { width: '140px' } }, bar(spec.value, max), h('small.ts', String(spec.value)));
return wrap;
}
function notTrainedNode() {
return h('span.t3', { title: 'null specialist — calibrate to enable' }, 'Not trained');
}
function withheld() {
return h('span.red', { title: 'suppressed by veto — value withheld, not zero' }, '— withheld');
}
@@ -0,0 +1,256 @@
// §4.2 SEED Detail View — the per-device deep dive (route #/seed/<id>).
//
// Vector store + witness chain (Ed25519 custody) + onboard sensors +
// reflex rules + cognitive (boundary fragility) analysis + ingest
// pipeline. Backed by the SEED HTTPS API (mock until the live endpoint
// lands → DEMO badge, §7.1). Honesty invariants (§6): null fragility /
// null sensors render muted, never as zero.
import {
h, card, pill, statusPill, sectionHeader, bar, banner, button, mono, kv,
sparkline, errorCard, relTime,
} from '../ui.js';
export default {
meta: { title: 'SEED Detail' },
async render(root, ctx) {
const { api } = ctx;
let s;
try {
s = await api.seed(ctx.params.id);
} catch (e) {
root.appendChild(sectionHeader('SEED Detail', ctx.params.id));
root.appendChild(banner('SEED unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
root.appendChild(card({ children: [button('← Back to fleet', { onClick: () => ctx.navigate('#/fleet') })] }));
return () => {};
}
if (!s) {
root.appendChild(sectionHeader('SEED Detail', ctx.params.id));
root.appendChild(errorCard(`No SEED with device_id "${ctx.params.id}"`));
root.appendChild(card({ children: [button('← Back to fleet', { onClick: () => ctx.navigate('#/fleet') })] }));
return () => {};
}
root.appendChild(sectionHeader('SEED Detail', s.zone));
if (api.isDemo('fleet')) {
root.appendChild(banner('DEMO — SEED HTTPS API not served by this binary; showing contract-conformant data (§7.1).', 'amber'));
}
root.appendChild(identityCard(s, ctx));
root.appendChild(vectorStoreCard(s));
root.appendChild(witnessCard(s));
root.appendChild(sensorsCard(s));
root.appendChild(reflexCard(s));
root.appendChild(cognitionCard(s));
root.appendChild(ingestCard(s));
return () => {};
},
};
// ── 1. identity header ────────────────────────────────────────────────
function identityCard(s, ctx) {
return card({
children: [
sectionHeader(s.device_id, `Firmware ${s.firmware} · ${s.zone}`),
h('.flex.spread',
statusPill(s.online ? 'online' : 'offline'),
button('← Fleet', { onClick: () => ctx.navigate('#/fleet') })),
kv([
['Firmware', mono(s.firmware)],
['Paired', pill('paired', 'green')],
['Conn mode', pill(s.conn, s.conn === 'usb' ? 'cyan' : 'purple')],
['Zone', s.zone],
]),
],
});
}
// ── 2. vector store ───────────────────────────────────────────────────
function vectorStoreCard(s) {
const over = s.storage_budget > 0 && s.storage_used / s.storage_budget > 0.8;
const storeBar = bar(s.storage_used, s.storage_budget, [{ lt: 0.8, color: 'cyan' }, { lt: 1.01, color: 'amber' }]);
const series = Array.from({ length: 24 }, (_, i) => s.knn_latency_ms != null ? +(s.knn_latency_ms + Math.sin(i / 2) * 0.4).toFixed(2) : 0);
let compacted = false;
const compactBtn = button('Compact now', {
onClick: () => {
if (compacted) return;
compacted = true;
compactBtn.disabled = true;
compactBtn.textContent = 'Compaction queued';
console.log('[seed-detail] POST /api/v1/store/compact', s.device_id); // production call
},
});
return card({
title: 'Vector Store',
children: [
kv([
['Vectors', s.vector_count.toLocaleString()],
['Dimension', mono(String(s.vector_dim))],
['kNN latency', s.knn_latency_ms != null ? h('span.cyan', s.knn_latency_ms + ' ms') : h('span.t3', '— offline')],
['Epoch', h('span.purple', String(s.epoch))],
['kNN latency trend', sparkline(series, { w: 160, hgt: 28 })],
]),
h('.flex.spread.mt',
h('span.t2', `Storage — ${s.storage_used.toLocaleString()} / ${s.storage_budget.toLocaleString()}`),
over ? pill('budget > 80%', 'amber') : pill('headroom', 'green')),
storeBar,
over ? banner('Vector store nearing budget — compaction recommended.', 'amber') : null,
h('.mt', compactBtn),
],
});
}
// ── 3. witness chain ──────────────────────────────────────────────────
function witnessCard(s) {
const verifyBtn = button('Verify chain', {
onClick: () => console.log('[seed-detail] verify witness chain', s.device_id),
});
const exportBtn = button('Export attestation bundle', {
onClick: () => console.log('[seed-detail] export attestation bundle', s.device_id),
});
return card({
title: 'Witness Chain',
children: [
kv([
['Chain length', h('span.purple', s.witness_len.toLocaleString())],
['Status', s.witness_valid ? pill('valid', 'green') : pill('invalid', 'red')],
['Last verify', relTime(s.witness_last_verify)],
]),
h('.flex.gap-sm.mt', verifyBtn, exportBtn),
h('small.ts',
'Ed25519 custody attestation — device-bound keypair signs (epoch + vector count + witness head): ',
mono(`epoch=${s.epoch} · vectors=${s.vector_count} · head=${s.witness_len}`)),
],
});
}
// ── 4. onboard sensors ────────────────────────────────────────────────
function sensorsCard(s) {
if (!s.sensors) {
return card({ title: 'Onboard Sensors', children: [h('.muted-empty', 'sensors offline')] });
}
const x = s.sensors;
const grid = h('.grid.cols-3',
subCard('BME280', [
sub('Temp', h('span.cyan', x.bme280.temp_c + ' °C')),
sub('Humidity', h('span.cyan', x.bme280.humidity_pct + ' %')),
sub('Pressure', h('span.cyan', x.bme280.pressure_hpa + ' hPa')),
]),
subCard('PIR', [
sub('Motion', x.pir.motion ? pill('motion', 'amber') : pill('still', 'grey')),
sub('Last trigger', h('span.t2', relTime(x.pir.last_trigger))),
]),
subCard('Reed', [
sub('State', x.reed.open ? pill('open', 'amber') : pill('closed', 'grey')),
sub('Last change', h('span.t2', relTime(x.reed.last_change))),
]),
subCard('ADS1115', x.ads1115.map((ch) => sub(ch.label, h('span.cyan', String(ch.v))))),
subCard('Vibration', [
sub('State', x.vibration.active ? pill('active', 'amber') : pill('idle', 'grey')),
sub('Last trigger', h('span.t2', relTime(x.vibration.last_trigger))),
]),
);
return card({ title: 'Onboard Sensors', children: [grid] });
}
function subCard(name, rows) {
return card({ children: [h('h3', name), ...rows] });
}
function sub(name, valueNode) {
return h('.row', h('span.k.t2', name), valueNode instanceof Node ? valueNode : h('span.cyan', String(valueNode)));
}
// ── 5. reflex rules ───────────────────────────────────────────────────
function reflexCard(s) {
if (!s.reflex || !s.reflex.length) {
return card({ title: 'Reflex Rules', children: [h('.muted-empty', 'no reflex rules configured')] });
}
const rows = s.reflex.map(reflexRow);
return card({ title: 'Reflex Rules', children: rows });
}
function reflexRow(r) {
let thresholdNode;
if (r.name === 'fragility_alarm') {
const input = h('input.inline', { type: 'number', step: '0.05', value: String(r.threshold) });
input.addEventListener('change', () => console.log('[seed-detail] reflex threshold edit (no persist)', r.name, input.value));
thresholdNode = input;
} else {
thresholdNode = mono(String(r.threshold));
}
const row = h('.row',
h('.flex.gap-sm', mono(r.name), r.fired_recently ? pill('fired recently', 'amber') : null),
h('.flex.gap-sm',
h('span.t2', 'thr'), thresholdNode,
h('span.t2', '→'), h('span.v', r.target),
h('small.ts', 'fired ' + (r.last_fired ? relTime(r.last_fired) : 'never'))));
if (r.fired_recently) {
return card({ tint: 'amber', children: [row] });
}
return row;
}
// ── 6. cognitive analysis ─────────────────────────────────────────────
function cognitionCard(s) {
const c = s.cognition || {};
const children = [];
if (c.fragility == null) {
children.push(h('.muted-empty', 'fragility unavailable — cognition offline'));
} else {
const fragile = c.fragility > 0.3;
const fb = bar(c.fragility, 1, [{ lt: 0.3, color: 'green' }, { lt: 0.6, color: 'amber' }, { lt: 1.01, color: 'red' }]);
if (fragile) {
children.push(banner(`Boundary fragility elevated — ${c.fragility.toFixed(2)} (regime change likely)`, 'amber'));
}
children.push(h('.flex.spread', h('span.t2', 'Boundary fragility'), h('span' + (fragile ? '.amber' : '.green'), c.fragility.toFixed(2))));
children.push(fb);
}
if (c.coherence_phases && c.coherence_phases.length) {
children.push(h('h3.mt', 'Coherence phases'));
c.coherence_phases.forEach((p) => {
children.push(h('.row', mono(relTime(p.t)), h('span.v', p.label)));
});
}
children.push(h('.row.mt', h('span.k.t2', 'kNN rebuild cadence'), mono((c.knn_rebuild_s ?? '—') + ' s')));
return card({ title: 'Cognitive Analysis', children });
}
// ── 7. ingest pipeline ────────────────────────────────────────────────
function ingestCard(s) {
const ing = s.ingest || {};
const children = [
kv([
['Batch size', mono(String(ing.batch))],
['Flush interval', mono((ing.flush_ms ?? '—') + ' ms')],
['Bridge', String(ing.bridge ?? '—')],
]),
];
if (ing.bridge && /hop/i.test(ing.bridge)) {
children.push(banner('Bridge adds a network hop — extra latency + a trust boundary in the ingest path.', 'amber'));
}
if (ing.esp32 && ing.esp32.length) {
children.push(h('h3.mt', 'ESP32 ingest nodes'));
ing.esp32.forEach((n) => children.push(esp32Row(n)));
} else {
children.push(h('.muted-empty', 'no ESP32 nodes attached'));
}
return card({ title: 'Ingest Pipeline', children });
}
function esp32Row(n) {
const native = n.packet === '0xC5110003';
const packetPill = native
? pill('0xC5110003 native', 'green')
: pill((n.packet || '—') + ' vitals fallback', 'amber');
return h('.row',
mono(n.node_id),
h('.flex.gap-sm', packetPill, h('span.t2', n.rate_hz + ' Hz')));
}
@@ -0,0 +1,256 @@
// §4.10 Settings & Integration Config — ADR-131.
// One card per sub-section: SEED fleet management, ESP32 provisioning,
// MQTT / cog-ha-matter config, long-lived access tokens, federation
// config. Security invariants are surfaced as first-class banners
// (USB-only pairing window; "model deltas only, never raw CSI").
//
// Mutations are local-state-only here (no live mutate endpoint yet); the
// node→room assignment edits persist into an in-memory map and the panel
// is flagged DEMO whenever the mock layer is serving it (§7.1 honesty).
import {
h, clear, card, pill, statusPill, sectionHeader, mono, button, banner, kv, relTime,
} from '../ui.js';
export default {
meta: { title: 'Settings' },
async render(root, ctx) {
const { api } = ctx;
// Load each card's data independently so one failure doesn't blank the page.
let s = null, sErr = null;
let seeds = null, seedsErr = null;
let fed = null, fedErr = null;
try { s = await api.settings(); } catch (e) { sErr = e; }
try { seeds = await api.seeds(); } catch (e) { seedsErr = e; }
try { fed = await api.federation(); } catch (e) { fedErr = e; }
root.appendChild(sectionHeader('Settings & Integration Config', 'SEED fleet, ESP32 provisioning, MQTT / cog-ha-matter, access tokens & federation (ADR-131 §4.10)'));
if (api.isDemo('settings') || api.isDemo('fleet')) {
root.appendChild(banner('DEMO — settings & fleet are served by the contract-conformant mock layer until their live endpoints land (ADR-131 §7.1). Edits are local-state only.', 'amber'));
}
// ── §4.10.1 SEED fleet ──
if (seedsErr) root.appendChild(cardBanner('SEED Fleet Management', 'SEED fleet unavailable — ' + errText(seedsErr)));
else root.appendChild(seedFleetCard(seeds));
// ── §4.10.2/.3/.4 ESP32 + MQTT + tokens (all from settings) ──
if (sErr) {
root.appendChild(cardBanner('ESP32 Node Provisioning', 'ESP32 provisioning unavailable — ' + errText(sErr)));
root.appendChild(cardBanner('MQTT / cog-ha-matter', 'MQTT / cog-ha-matter config unavailable — ' + errText(sErr)));
root.appendChild(cardBanner('Long-Lived Access Tokens', 'Access tokens unavailable — ' + errText(sErr)));
} else {
root.appendChild(esp32Card(s.esp32));
root.appendChild(mqttCard(s.mqtt, s.ha_disco_entities, s.esp32));
root.appendChild(tokensCard(s.tokens));
}
// ── §4.10.5 Federation (needs federation + seeds) ──
if (fedErr || seedsErr) root.appendChild(cardBanner('Federation Config', 'Federation config unavailable — ' + errText(fedErr || seedsErr)));
else root.appendChild(federationCard(fed, seeds));
return () => {};
},
};
// ── §4.10.1 SEED fleet management ───────────────────────────────────
function seedFleetCard(seeds) {
const body = h('div');
// PROMINENT USB-only pairing invariant (security invariant).
body.appendChild(banner('Pairing window only opens via 169.254.42.1 (USB), never WiFi — security invariant.', 'red'));
const list = h('div.mt');
seeds.forEach((sd) => list.appendChild(seedRow(sd)));
body.appendChild(list);
body.appendChild(h('.flex.wrap.gap-sm.mt',
button('Add SEED', { variant: 'ghost', onClick: () => toggleNote(addNote) }),
button('Reprovision', { variant: 'ghost', onClick: () => toggleNote(addNote) })));
const addNote = inlineNote('Provisioning flow', [
'1. Connect the SEED over USB — it presents a link-local pairing endpoint at 169.254.42.1.',
'2. Pairing NEVER opens over WiFi; the device refuses pairing on any non-USB interface.',
'3. Issue a bearer token over the USB link, then attach the SEED to the appliance.',
'4. Verify the witness chain before accepting the SEED into the fleet.',
]);
body.appendChild(addNote);
return card({ title: 'SEED Fleet Management', children: [body] });
}
function seedRow(sd) {
const offline = !sd.online;
const tokenKind = offline ? 'grey' : 'green';
const tokenLabel = offline ? 'token idle' : 'token valid';
const note = inlineNote('Secure token rotation — ' + sd.device_id, [
'1. Operator confirms physical presence; pairing must be re-opened over USB (169.254.42.1) — never WiFi.',
'2. Appliance mints a new bearer token and stages it on the SEED over the USB link.',
'3. SEED acknowledges; the appliance flips the active token and revokes the old one.',
'4. Witness chain records the rotation (ed25519); old token rejected on next ingest.',
]);
const head = h('.row',
h('strong.mono', sd.device_id),
h('.flex.gap-sm',
h('span.t2', sd.firmware),
pill(tokenLabel, tokenKind),
statusPill(sd.online ? 'online' : 'offline'),
button('Rotate token', { variant: 'ghost', onClick: () => toggleNote(note) }),
button('Remove', { variant: 'ghost', onClick: () => toggleNote(note) })));
return h('div', head, note);
}
// ── §4.10.2 ESP32 node provisioning ─────────────────────────────────
function esp32Card(nodes) {
// local-state room assignment map (node_id → room) — no live endpoint.
const roomMap = {};
nodes.forEach((n) => { roomMap[n.node_id] = n.room; });
const body = h('div');
nodes.forEach((n) => {
const sel = h('input.inline', {
value: roomMap[n.node_id],
title: 'Editable node→room assignment (local state)',
onChange: (e) => { roomMap[n.node_id] = e.target.value.trim(); },
});
body.appendChild(h('.row',
h('.flex.gap-sm',
h('strong.mono', n.node_id),
mono(n.ip + ':' + n.port),
h('span.t2', 'fw ' + n.firmware),
pill(n.seed, 'cyan')),
h('.flex.gap-sm', h('span.k', 'room'), sel)));
});
body.appendChild(h('.t3.mt', 'Provision a new node with the firmware tool: ',
mono('firmware/esp32-csi-node/provision.py'),
' (set --target-ip to this appliance).'));
body.appendChild(h('.flex.wrap.gap-sm.mt',
button('Add ESP32 node', { variant: 'ghost', onClick: () => alert('Run provision.py over USB — see hint above.') }),
button('Apply room map', { variant: 'ghost', onClick: () => alert('Room map persisted locally: ' + JSON.stringify(roomMap)) })));
return card({ title: 'ESP32 Node Provisioning', children: [body] });
}
// ── §4.10.3 MQTT / cog-ha-matter config ─────────────────────────────
function mqttCard(mqtt, haEntities, esp32) {
const dotCls = mqtt.connected ? '' : '.err';
const liveDot = h('span.lag',
h('span.dot' + dotCls),
h('span.t2', mqtt.connected ? 'connected' : 'disconnected'));
const conf = kv([
['Broker', mono(mqtt.broker)],
['User', mqtt.user],
['Credentials', mono('••••••')],
['mDNS advertisement', mono(mqtt.mdns)],
['Connection', liveDot],
]);
// HA-DISCO entities per node with via_device assignments.
const disco = h('div.mt',
h('h3', `HA-DISCO entities — ${haEntities} per node`),
h('.t3', 'Each ESP32 node publishes its discovery entities with a via_device pointing at its SEED:'));
esp32.forEach((n) => disco.appendChild(h('.row',
h('span.mono', n.node_id),
h('.flex.gap-sm', pill(haEntities + ' entities', 'cyan'), h('span.t2', 'via_device'), mono(n.seed)))));
return card({ title: 'MQTT / cog-ha-matter', children: [conf, disco] });
}
// ── §4.10.4 Long-lived access tokens ────────────────────────────────
function tokensCard(tokens) {
const body = h('div');
tokens.forEach((t) => {
body.appendChild(h('.row',
h('.flex.gap-sm', h('strong', t.name), pill('long-lived', 'purple')),
h('.flex.gap-sm',
h('span.t2', 'last used ' + relTime(t.last_used)),
h('span.t3', 'created ' + relTime(t.created)),
button('Revoke', { variant: 'ghost', onClick: () => alert('Revoking "' + t.name + '" — token rejected on next request (local demo).') }))));
});
body.appendChild(h('.flex.wrap.gap-sm.mt',
button('Create token', { variant: 'primary', onClick: () => alert('A new long-lived token would be minted and shown once (demo).') })));
// HA companion-app pairing QR placeholder box.
const qr = h('.muted-empty.mt', { style: { border: '0.67px dashed var(--border)', borderRadius: '8px', padding: '24px', textAlign: 'center' } },
'HA companion-app pairing QR surfaces here — scan from the Home Assistant mobile app to pair this appliance (placeholder).');
body.appendChild(qr);
return card({ title: 'Long-Lived Access Tokens', children: [body] });
}
// ── §4.10.5 Federation config (ADR-105) ─────────────────────────────
function federationCard(fed, seeds) {
const body = h('div');
// CRITICAL invariant — model deltas only, never raw CSI (purple).
body.appendChild(purpleBanner('Federation invariant — ' + fed.invariant + '.'));
body.appendChild(kv([
['Coordinator SEED', mono(fed.coordinator)],
['Round', h('span.purple', String(fed.round))],
['Healthy SEEDs (k)', String(fed.k_healthy)],
['Delta exchange', statusPill(fed.delta_status === 'exchanging' ? 'updating' : fed.delta_status)],
['Round cadence', fed.cadence_min + ' min'],
['Krum aggregation', h('.flex.gap-sm', pill('f = ' + fed.krum.f, 'cyan'), pill(fed.krum.multi ? 'multi-Krum' : 'single-Krum', 'purple'), h('span.t3', 'ADR-105'))],
]));
// ESP-NOW mesh sync status — rows coloured by health.
const mesh = h('div.mt', h('h3', 'ESP-NOW mesh sync — cross-SEED epoch alignment'));
fed.mesh_links.forEach((l) => {
const epochA = epochOf(seeds, l.a);
const epochB = epochOf(seeds, l.b);
const aligned = epochA != null && epochA === epochB;
mesh.appendChild(h('.row',
h('.flex.gap-sm', h('span.mono', l.a), h('span.t3', '↔'), h('span.mono', l.b)),
h('.flex.gap-sm',
h('span.t2', `epoch ${fmtEpoch(epochA)} / ${fmtEpoch(epochB)}`),
pill(aligned ? 'aligned' : 'epoch skew', aligned ? 'green' : 'amber'),
pill(l.health, healthKind(l.health)))));
});
body.appendChild(mesh);
return card({ title: 'Federation Config', children: [body] });
}
// ── helpers ─────────────────────────────────────────────────────────
/** Format a load error, surfacing the §12 upstream-not-wired hint. */
function errText(e) {
return (e && e.message ? e.message : String(e)) + (e && e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : '');
}
/** Render a card whose body is a red unavailability banner (one card's data failed). */
function cardBanner(title, msg) {
return card({ title, children: [banner(msg, 'red')] });
}
function epochOf(seeds, id) {
const s = seeds.find((x) => x.device_id === id);
return s ? s.epoch : null;
}
function fmtEpoch(e) { return e == null ? '—' : String(e); }
function healthKind(h0) {
const m = { green: 'green', red: 'red', amber: 'amber' };
return m[String(h0).toLowerCase()] || 'grey';
}
/** Purple banner for federation invariants (no .banner.purple in CSS). */
function purpleBanner(text) {
return h('.banner', {
style: { background: 'var(--purple-d)', color: 'var(--purple)', border: '0.67px solid var(--purple)' },
}, text);
}
/** A hidden, toggleable multi-step note describing a secure flow. */
function inlineNote(title, steps) {
const node = h('.banner', {
style: { background: 'var(--bg2)', border: '0.67px solid var(--border)', color: 'var(--t1)', display: 'none' },
}, h('strong', title));
steps.forEach((line) => node.appendChild(h('.t2', { style: { marginTop: '4px' } }, line)));
return node;
}
function toggleNote(node) {
node.style.display = node.style.display === 'none' ? 'block' : 'none';
}
+235
View File
@@ -0,0 +1,235 @@
// HOMECORE-UI shared component helpers — ADR-131 §3.3.
//
// Every panel imports from here so cards/pills/buttons/badges are
// byte-identical across the dashboard (the §3.3 "no visual seam"
// invariant). Pure DOM, no framework, no build step.
/** Hyperscript element factory. `h('div.card#x', {onClick}, ...children)`. */
export function h(spec, attrs, ...children) {
let tag = 'div', id = null;
const classes = [];
spec.replace(/([.#]?[^.#]+)/g, (tok) => {
if (tok[0] === '.') classes.push(tok.slice(1));
else if (tok[0] === '#') id = tok.slice(1);
else tag = tok;
return tok;
});
const node = document.createElement(tag);
if (id) node.id = id;
if (classes.length) node.className = classes.join(' ');
if (attrs && typeof attrs === 'object' && !(attrs instanceof Node) && !Array.isArray(attrs)) {
for (const [k, v] of Object.entries(attrs)) {
if (v == null || v === false) continue;
if (k === 'class') node.className += ' ' + v;
else if (k === 'html') node.innerHTML = v;
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
else node.setAttribute(k, v);
}
} else if (attrs != null) {
children.unshift(attrs);
}
append(node, children);
return node;
}
function append(node, children) {
for (const c of children.flat(Infinity)) {
if (c == null || c === false) continue;
node.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
}
}
export const txt = (s) => document.createTextNode(s == null ? '' : String(s));
export const mono = (s) => h('span.mono', String(s == null ? '' : s));
export const clear = (n) => { while (n.firstChild) n.removeChild(n.firstChild); return n; };
/** Status pill. kind ∈ cyan|green|amber|red|purple|grey. */
export function pill(text, kind = 'grey') {
return h(`span.pill.${kind}`, String(text));
}
/** Map a free-form status string to the platform colour convention. */
export function statusPill(status) {
const s = String(status || '').toLowerCase();
const map = {
running: 'green', online: 'green', ok: 'green', healthy: 'green', occupied: 'green', paired: 'green', connected: 'green', valid: 'green',
stale: 'amber', degraded: 'amber', updating: 'amber', warn: 'amber', warning: 'amber',
failed: 'red', offline: 'red', error: 'red', veto: 'red', vetoed: 'red', unreachable: 'red', invalid: 'red',
stopped: 'grey', absent: 'grey', unknown: 'grey', 'not trained': 'grey',
info: 'purple', epoch: 'purple', chain: 'purple',
};
return pill(status, map[s] || 'grey');
}
export function card({ title, tint, accent, clickable, onClick, children = [] } = {}) {
const cls = ['card'];
if (tint) cls.push('tint-' + tint);
if (clickable || onClick) cls.push('clickable');
const node = h('.' + cls.join('.'));
if (onClick) node.addEventListener('click', onClick);
if (accent) node.appendChild(accentBar());
if (title) node.appendChild(h('h2', title));
append(node, [children]);
return node;
}
function accentBar() {
const b = h('div');
b.style.height = '3px';
b.style.borderRadius = '3px';
b.style.margin = '-14px -10px 14px';
b.style.background = 'linear-gradient(90deg, var(--cyan), var(--purple))';
return b;
}
/** Section header with the cyan→purple featured gradient border (§3.3). */
export function sectionHeader(title, sub) {
return h('.section-header', h('h1', title), sub ? h('.sub', sub) : null);
}
/** Live metric card (§4.1). */
export function metric({ icon, value, label, color = 'cyan' }) {
return h('.metric',
icon ? h('.ico', icon) : null,
h(`.val${color === 'green' ? '.green' : ''}`, String(value)),
h('.lbl', label));
}
export function button(label, { variant = 'ghost', onClick, disabled } = {}) {
const b = h(`button.btn.${variant}`, label);
if (disabled) b.disabled = true;
if (onClick) b.addEventListener('click', onClick);
return b;
}
/**
* Progress bar with threshold colouring.
* thresholds: [{ lt, color }] evaluated in order against the 0..1 ratio.
*/
export function bar(value, max = 1, thresholds = null) {
const ratio = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
let color = '';
if (thresholds) {
for (const t of thresholds) { if (ratio < t.lt) { color = t.color; break; } }
if (!color) color = thresholds[thresholds.length - 1].color;
}
const fill = h('span' + (color ? '.' + color : ''));
fill.style.width = (ratio * 100).toFixed(1) + '%';
return h('.bar', fill);
}
/** Small inline confidence bar — amber below 0.4 (§4.5). */
export function confidenceBar(conf) {
const c = Math.max(0, Math.min(1, conf || 0));
const fill = h('span' + (c < 0.4 ? '.amber' : ''));
fill.style.width = (c * 100).toFixed(0) + '%';
return h('.conf-bar', fill);
}
/**
* Provenance badge (§4.4 / §6) ESP32 SEED COG state machine.
* A first-class element, never collapsed. hailo:true marks Hailo-sourced
* inference visually distinct from CPU-only COGs (§6 invariant 5).
*/
export function provenanceBadge({ esp32, seed, cog, hailo } = {}) {
return h('span.prov',
esp32 ? txt(esp32) : null, esp32 ? h('span.arr', '→') : null,
seed ? txt(seed) : null, h('span.arr', '→'),
h(hailo ? 'span.hailo' : 'span', cog || 'cog'),
h('span.arr', '→'), txt('homecore'));
}
/** Tiny inline SVG sparkline. */
export function sparkline(values, { w = 120, hgt = 28, color = 'var(--cyan)' } = {}) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', w); svg.setAttribute('height', hgt); svg.setAttribute('class', 'spark');
if (!values || values.length < 2) return svg;
const min = Math.min(...values), max = Math.max(...values), span = max - min || 1;
const step = w / (values.length - 1);
const pts = values.map((v, i) => `${(i * step).toFixed(1)},${(hgt - ((v - min) / span) * (hgt - 4) - 2).toFixed(1)}`).join(' ');
const pl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
pl.setAttribute('points', pts); pl.setAttribute('fill', 'none');
pl.setAttribute('stroke', color); pl.setAttribute('stroke-width', '1.5');
svg.appendChild(pl);
return svg;
}
export function banner(text, kind = 'amber', extra) {
return h(`.banner.${kind}`, text, extra ? txt(' ') : null, extra || null);
}
export function row(k, v) {
return h('.row', h('span.k', k), v instanceof Node ? v : h('span.v', String(v == null ? '—' : v)));
}
export function kv(pairs) {
const node = h('.kv');
for (const [k, v] of pairs) {
node.appendChild(h('span.k', k));
node.appendChild(v instanceof Node ? v : h('span.v', String(v == null ? '—' : v)));
}
return node;
}
/** Collapsible section. */
export function collapsible(title, contentFn, open = false) {
const wrap = h('.collapsible' + (open ? '.open' : ''));
const head = h('.head', title);
const body = h('div');
wrap.appendChild(head); wrap.appendChild(body);
let built = false;
const toggle = () => {
wrap.classList.toggle('open');
if (wrap.classList.contains('open')) {
if (!built) { body.appendChild(contentFn()); built = true; }
body.classList.remove('hidden');
} else body.classList.add('hidden');
};
head.addEventListener('click', toggle);
if (open) { body.appendChild(contentFn()); built = true; } else body.classList.add('hidden');
return wrap;
}
/** Slide-over panel (§4.4 StateChanged detail). */
export function slideover(title, content) {
const back = h('.slideover-back');
const panel = h('.slideover', h('span.close', { onClick: close }, '✕'), h('h2', title), content);
function close() { back.remove(); panel.remove(); }
back.addEventListener('click', close);
document.body.appendChild(back);
document.body.appendChild(panel);
return { close };
}
/** Lag indicator (§4.1/§4.4 — broadcast channel vs 4096 capacity). */
export function lagIndicator(state, lagged) {
const cls = state === 'open' ? (lagged ? 'warn' : '') : 'err';
const label = state === 'open' ? (lagged ? 'WS lagging — events dropped' : 'WS live') : 'WS offline';
return h('span.lag', h(`span.dot${cls ? '.' + cls : ''}`), h('span.t2', label));
}
export function relTime(iso) {
if (!iso) return '—';
const t = Date.parse(iso);
if (Number.isNaN(t)) return String(iso);
const s = Math.round((Date.now() - t) / 1000);
if (s < 0) return 'in ' + fmtDur(-s);
if (s < 5) return 'just now';
return fmtDur(s) + ' ago';
}
function fmtDur(s) {
if (s < 60) return s + 's';
if (s < 3600) return Math.round(s / 60) + 'm';
if (s < 86400) return Math.round(s / 3600) + 'h';
return Math.round(s / 86400) + 'd';
}
/** Loading + error wrappers panels can await. */
export function loading(label = 'Loading…') { return h('.muted-empty', label); }
export function errorCard(e) { return banner('Unavailable — ' + (e && e.message ? e.message : e), 'red'); }
/** Distinguish "not trained" (null) from "unavailable" (error) — §6 invariant 3. */
export function notTrained(prompt = 'Calibrate to enable') {
return h('span.t3', 'Not trained ', button(prompt, { variant: 'ghost' }));
}
+69
View File
@@ -0,0 +1,69 @@
// HOMECORE-UI WebSocket client — ADR-130 subscribe_events.
//
// "The UI must never poll for entity state" (ADR-131 §2/§4.4). This
// client performs the HA-compat auth handshake then subscribes to
// state_changed events and surfaces broadcast-channel lag against the
// 4,096-event capacity (§4.1/§4.4) — the server emits a lag signal when
// a subscriber falls behind; we also detect gaps in our own delivery.
import { api } from './api.js';
/**
* Connect and stream events.
* @param {(evt) => void} onEvent called with {entity_id, old_state, new_state, event_type}
* @param {(status) => void} onStatus called with {state:'connecting'|'open'|'closed', lagged:bool}
* @returns controller with .close()
*/
export function connect(onEvent, onStatus) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/websocket`;
let ws, msgId = 1, closedByUs = false, lagged = false;
let retry = 0;
const status = (state) => onStatus && onStatus({ state, lagged });
function open() {
status('connecting');
try { ws = new WebSocket(url); } catch (e) { schedule(); return; }
ws.onmessage = (m) => {
let msg; try { msg = JSON.parse(m.data); } catch { return; }
if (msg.type === 'auth_required') {
ws.send(JSON.stringify({ type: 'auth', access_token: api.token() }));
} else if (msg.type === 'auth_ok') {
retry = 0; status('open');
ws.send(JSON.stringify({ id: msgId++, type: 'subscribe_events', event_type: 'state_changed' }));
} else if (msg.type === 'auth_invalid') {
status('closed');
} else if (msg.type === 'event' && msg.event) {
const e = msg.event;
if (e.event_type === 'state_changed' && e.data) {
onEvent && onEvent({
event_type: 'state_changed',
entity_id: e.data.entity_id,
old_state: e.data.old_state,
new_state: e.data.new_state,
});
} else {
onEvent && onEvent({ event_type: e.event_type, ...e.data });
}
} else if (msg.type === 'lagged' || (msg.type === 'event' && msg.lagged)) {
lagged = true; status('open');
}
};
ws.onclose = () => { if (!closedByUs) schedule(); else status('closed'); };
ws.onerror = () => { try { ws.close(); } catch {} };
}
function schedule() {
status('closed');
retry = Math.min(retry + 1, 6);
const delay = Math.min(500 * 2 ** retry, 15000);
setTimeout(() => { if (!closedByUs) open(); }, delay);
}
open();
return {
close() { closedByUs = true; try { ws && ws.close(); } catch {} },
isLagged: () => lagged,
clearLag() { lagged = false; },
};
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "homecore-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "HOMECORE-UI — operational dashboard for the two-tier Cognitum stack (ADR-131). Zero-dependency vanilla TS/JS + CSS; served by homecore-server at /homecore.",
"scripts": {
"check": "node tests/verify-imports.mjs",
"test": "node tests/verify-imports.mjs && node tests/boot.mjs && node tests/render-smoke.mjs && node tests/interaction.mjs && node tests/prod-errors.mjs && node tests/unit-fixes.mjs",
"bench": "node tests/benchmark.mjs"
}
}
@@ -0,0 +1,54 @@
// Benchmark — ADR-131 §8 / ADR-126 §1.1.
// HOMECORE exists partly because HA's frontend is a ~5 MB Lit bundle
// (ADR-126 §1.1). This benchmark enforces a hard bundle budget and
// measures cold render throughput for all 10 panels.
// Run: node tests/benchmark.mjs
import { install } from './dom-shim.mjs';
install();
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
const ROOT = resolve(import.meta.dirname, '..');
const BUDGET_BYTES = 250 * 1024; // 250 KB total — vs HA's ~5 MB (20× smaller)
function walk(dir) {
let total = 0; const rows = [];
for (const name of readdirSync(dir)) {
if (name === 'tests' || name === 'node_modules') continue;
const p = resolve(dir, name); const s = statSync(p);
if (s.isDirectory()) { const sub = walk(p); total += sub.total; rows.push(...sub.rows); }
else if (/\.(js|css|html|json)$/.test(name)) { total += s.size; rows.push([p.replace(ROOT + '/', ''), s.size]); }
}
return { total, rows };
}
const { total, rows } = walk(ROOT);
rows.sort((a, b) => b[1] - a[1]);
console.log('── Bundle size (uncompressed) ──');
for (const [f, sz] of rows.slice(0, 8)) console.log(` ${(sz / 1024).toFixed(1).padStart(7)} KB ${f}`);
console.log(` ${'-'.repeat(40)}`);
console.log(` ${(total / 1024).toFixed(1).padStart(7)} KB TOTAL across ${rows.length} files`);
console.log(` budget ${(BUDGET_BYTES / 1024).toFixed(0)} KB · HA baseline ~5120 KB · ratio ${(5120 * 1024 / total).toFixed(1)}× smaller`);
// ── render throughput ───────────────────────────────────────────────
const { api } = await import('../js/api.js');
const ctx = { api, navigate() {}, params: { id: 'seed-livingroom-a1' }, onEvent() { return () => {}; }, onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; } };
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
const mods = {};
for (const p of PANELS) mods[p] = (await import(`../js/panels/${p}.js`)).default;
console.log('\n── Cold render throughput (avg of 50 renders each) ──');
let worst = 0;
for (const p of PANELS) {
const N = 50; const t0 = performance.now();
for (let i = 0; i < N; i++) { const root = document.createElement('div'); const c = await mods[p].render(root, ctx); if (typeof c === 'function') c(); }
const ms = (performance.now() - t0) / N;
worst = Math.max(worst, ms);
console.log(` ${ms.toFixed(3).padStart(7)} ms/render ${p}`);
}
console.log('');
let exit = 0;
if (total > BUDGET_BYTES) { console.error(`FAIL — bundle ${(total / 1024).toFixed(1)} KB exceeds ${(BUDGET_BYTES / 1024).toFixed(0)} KB budget`); exit = 1; }
else console.log(`OK — bundle within budget; slowest panel ${worst.toFixed(2)} ms/render`);
process.exit(exit);
@@ -0,0 +1,37 @@
// Boot regression test — exercises the REAL app.js boot + router (not
// just individual panels). Catches the class of bug where start() throws
// before route() runs and the dashboard renders blank.
// Run: node tests/boot.mjs (from the ui/ dir)
import { install } from './dom-shim.mjs';
const { document, window } = install();
globalThis.HOMECORE_UI_DEMO = true; // boot with fixtures (no gateway in tests)
const errs = [];
const origErr = console.error;
console.error = (...a) => { errs.push(a.map(String).join(' ')); };
await import('../js/app.js');
await new Promise((r) => setTimeout(r, 30));
console.error = origErr;
const fails = [];
const content = document.getElementById('hc-content');
const app = document.getElementById('app');
if (!app || app.children.length < 2) fails.push('shell not built (#app should have topnav + shell)');
if (!content) fails.push('#hc-content missing — buildShell did not run');
else if (content.children.length === 0) fails.push('BLANK: dashboard rendered nothing into #hc-content on boot');
if (errs.length) fails.push('console.error during boot: ' + errs.slice(0, 3).join(' | '));
// navigation must re-render the panel
window.location.hash = '#/fleet';
await new Promise((r) => setTimeout(r, 30));
if (!content || content.children.length === 0) fails.push('BLANK after navigating to #/fleet');
// a clean topnav with no dead Cognitum tabs / Cog Store link
const links = app ? app.querySelectorAll('a') : [];
const hrefs = links.map((a) => a.getAttribute('href') || '');
if (hrefs.some((h) => /cognitum\.one\/store/.test(h))) fails.push('Cog Store external link should be removed');
if (fails.length) { console.error('\nFAILED:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — app.js boots, dashboard renders, navigation re-renders, no dead Cog Store link');
@@ -0,0 +1,103 @@
// Minimal DOM shim — enough to *run* the HOMECORE-UI panels under Node
// without jsdom. Installs globals (document, location, localStorage,
// fetch, WebSocket) so render-smoke.mjs can execute every panel and
// assert it builds a real DOM subtree without throwing.
class ClassList {
constructor(el) { this.el = el; this.set = new Set(); }
add(...c) { c.forEach((x) => x && this.set.add(x)); this.sync(); }
remove(...c) { c.forEach((x) => this.set.delete(x)); this.sync(); }
toggle(c, force) { const has = this.set.has(c); const on = force === undefined ? !has : force; if (on) this.set.add(c); else this.set.delete(c); this.sync(); return on; }
contains(c) { return this.set.has(c); }
sync() { this.el._class = [...this.set].join(' '); }
}
class El {
constructor(tag) {
this.tagName = String(tag).toUpperCase();
this.children = [];
this.attrs = {};
this.style = {};
this.listeners = {};
this._class = '';
this.classList = new ClassList(this);
this.parentNode = null;
this.id = '';
this._text = '';
this.disabled = false;
this.value = '';
}
set className(v) { this._class = v || ''; this.classList.set = new Set(String(v || '').split(/\s+/).filter(Boolean)); }
get className() { return this._class; }
set innerHTML(v) { this._html = v; }
get innerHTML() { return this._html || ''; }
set textContent(v) { this._text = v; this.children = []; }
get textContent() { return this._text || this.children.map((c) => c.textContent || c._text || '').join(''); }
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
insertBefore(c, ref) { const i = this.children.indexOf(ref); c.parentNode = this; if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); return c; }
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); c.parentNode = null; return c; }
remove() { if (this.parentNode) this.parentNode.removeChild(this); }
get firstChild() { return this.children[0] || null; }
setAttribute(k, v) { this.attrs[k] = String(v); }
getAttribute(k) { return this.attrs[k] ?? null; }
addEventListener(t, fn) { (this.listeners[t] ||= []).push(fn); }
removeEventListener(t, fn) { this.listeners[t] = (this.listeners[t] || []).filter((f) => f !== fn); }
dispatch(t, detail) { (this.listeners[t] || []).forEach((fn) => fn({ detail, target: this, preventDefault() {}, stopPropagation() {} })); }
_all() { return this.children.flatMap((c) => [c, ...(c._all ? c._all() : [])]); }
matchesSel(sel) {
return sel.split(/\s+/).pop().split('.').every((p, i, arr) => {
if (i === 0 && p && !p.startsWith('.') && !p.startsWith('#')) { if (p.startsWith('.')) {} }
return true;
});
}
querySelector(sel) {
const want = sel.replace(/^.*\s/, '');
const cls = want.startsWith('.') ? want.slice(1) : null;
return this._all().find((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase())) || null;
}
querySelectorAll(sel) {
const want = sel.replace(/^.*\s/, '');
const cls = want.startsWith('.') ? want.slice(1) : null;
return this._all().filter((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase()));
}
}
class TextNode { constructor(t) { this.textContent = String(t); this._text = String(t); this.nodeType = 3; this.parentNode = null; } remove() { if (this.parentNode) this.parentNode.removeChild(this); } }
// Node instanceof checks in ui.js use `instanceof Node`; expose a Node base.
globalThis.Node = El;
// TextNode must also pass `instanceof Node` (ui.js append() treats text via createTextNode).
Object.setPrototypeOf(TextNode.prototype, El.prototype);
const body = new El('body');
const documentObj = {
createElement: (t) => new El(t),
createElementNS: (_ns, t) => new El(t),
createTextNode: (t) => new TextNode(t),
getElementById: (id) => byId[id] || (byId[id] = mkRoot(id)),
body,
readyState: 'complete',
addEventListener() {},
querySelectorAll: () => [],
};
const byId = {};
function mkRoot(id) { const e = new El('div'); e.id = id; return e; }
export function install() {
globalThis.document = documentObj;
globalThis.EventTarget = class { constructor() { this._l = {}; } addEventListener(t, fn) { (this._l[t] ||= []).push(fn); } removeEventListener(t, fn) { this._l[t] = (this._l[t] || []).filter((f) => f !== fn); } dispatchEvent(e) { (this._l[e.type] || []).forEach((fn) => fn(e)); return true; } };
// window with a navigable location.hash that fires `hashchange`.
const win = new globalThis.EventTarget();
let _hash = '';
const loc = { host: 'localhost:8123', protocol: 'http:', get hash() { return _hash; }, set hash(v) { _hash = String(v).startsWith('#') ? String(v) : '#' + v; win.dispatchEvent({ type: 'hashchange' }); } };
win.location = loc;
globalThis.window = win;
globalThis.location = loc;
globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); } };
globalThis.fetch = () => Promise.reject(new Error('offline (test) — panels fall back to mock per §7.1'));
globalThis.WebSocket = class { constructor() { this.readyState = 0; } send() {} close() {} };
globalThis.CustomEvent = class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } };
return { El, TextNode, body, document: documentObj, window: win, location: loc };
}
export { El, TextNode };
@@ -0,0 +1,86 @@
// Interaction tests — the dynamic behaviours that syntax/render checks
// cannot reach: the live WebSocket entity patch (§4.4 "never poll"), the
// ws.js handshake + event parse (ADR-130), and the calibration backend
// driving the §4.7 wizard. Run: node tests/interaction.mjs
import { install } from './dom-shim.mjs';
install();
globalThis.HOMECORE_UI_DEMO = true; // exercise the demo/calibration fixture path
const fails = [], passes = [];
async function t(name, fn) {
try { await fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
// ── 1. entities panel patches state live over the bus (no polling) ──
await t('entities: live state_changed patches the row in place', async () => {
const entities = (await import('../js/panels/entities.js')).default;
const { api } = await import('../js/api.js');
let handler = null;
const ctx = {
api, navigate() {}, params: {},
onEvent(fn) { handler = fn; return () => {}; },
onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; },
};
const root = document.createElement('div');
await entities.render(root, ctx);
assert(typeof handler === 'function', 'panel must register an onEvent handler (it must not poll)');
const before = root.querySelectorAll('.t1').map((n) => n.textContent);
assert(before.some((x) => x === 'true'), 'living_room_presence should start "true" from the mock fallback');
// Fire a live event; ws.js delivers new_state as a StateView object.
handler({ event_type: 'state_changed', entity_id: 'sensor.living_room_presence', old_state: { state: 'true' }, new_state: { state: 'false' } });
const after = root.querySelectorAll('.t1').map((n) => n.textContent);
assert(after.some((x) => x === 'false'), 'row should now show patched state "false"');
});
// ── 2. ws.js performs the HA-compat handshake and parses events ─────
await t('ws.js: handshake → subscribe_events → parsed event', async () => {
const sent = [];
let inst = null;
globalThis.WebSocket = class { constructor(url) { this.url = url; inst = this; } send(m) { sent.push(JSON.parse(m)); } close() { this.onclose && this.onclose(); } };
const { connect } = await import('../js/ws.js?ws-test');
const got = [], status = [];
const ctrl = connect((e) => got.push(e), (s) => status.push(s));
assert(inst, 'WebSocket should be constructed');
inst.onmessage({ data: JSON.stringify({ type: 'auth_required', ha_version: 'x' }) });
assert(sent[0] && sent[0].type === 'auth' && 'access_token' in sent[0], 'must reply to auth_required with an auth token');
inst.onmessage({ data: JSON.stringify({ type: 'auth_ok', ha_version: 'x' }) });
assert(sent.some((m) => m.type === 'subscribe_events' && m.event_type === 'state_changed'), 'must subscribe_events after auth_ok');
inst.onmessage({ data: JSON.stringify({ type: 'event', event: { event_type: 'state_changed', data: { entity_id: 'light.x', old_state: { state: 'off' }, new_state: { state: 'on' } } } }) });
assert(got.length === 1, 'one event expected');
assert(got[0].entity_id === 'light.x' && got[0].new_state.state === 'on', 'event fields must parse through');
inst.onmessage({ data: JSON.stringify({ type: 'lagged' }) });
assert(ctrl.isLagged(), 'lag signal should set isLagged');
ctrl.close();
});
// ── 3. calibration backend drives the 5-step wizard contract ───────
await t('calibration: start→status→anchor→train contract', async () => {
const { api } = await import('../js/api.js');
const cal = api.calibration;
cal.reset();
const bl = await cal.start();
assert(bl.baseline_id, 'start() returns a baseline_id (the STALE anchor)');
let st;
for (let i = 0; i < 10; i++) { st = await cal.status(); if (st.frames >= st.target) break; }
assert(st.frames >= st.target, 'status() converges to target frames');
for (const label of cal.ANCHORS) await cal.anchor(label);
assert((await cal.enrollStatus()).accepted.length >= 6, 'most anchors accepted after enrollment');
const trained = await cal.train();
assert(trained.presence && trained.anomaly, 'train() returns non-null specialists when enrolled');
cal.reset();
});
console.log(`\n${passes.length} passed, ${fails.length} failed`);
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — live WS patch, ws.js handshake/parse, and calibration contract verified');
@@ -0,0 +1,45 @@
// Production-mode test (ADR-131 §2.2 / §11.11): with demo mode OFF and
// the gateway unreachable, every panel must render a typed empty/error
// state WITHOUT throwing and WITHOUT showing fabricated data.
// Run: node tests/prod-errors.mjs
import { install } from './dom-shim.mjs';
install();
globalThis.HOMECORE_UI_DEMO = false; // PRODUCTION path — no fixtures
// fetch already rejects in the shim → simulates an unreachable gateway.
const fails = [], passes = [];
async function t(name, fn) {
try { await fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
const { api, demoMode } = await import('../js/api.js');
await t('demoMode() is false in production', () => assert(demoMode() === false));
await t('api.anyDemo() is false in production', () => assert(api.anyDemo() === false));
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
const ctx = {
api, navigate() {}, params: { id: 'seed-livingroom-a1' },
onEvent() { return () => {}; },
onWs(fn) { fn({ state: 'closed', lagged: false }); return () => {}; },
};
for (const name of PANELS) {
await t(`prod render (gateway down): ${name} shows a state, never throws`, async () => {
const mod = await import(`../js/panels/${name}.js`);
const root = document.createElement('div');
const cleanup = await mod.default.render(root, ctx);
// must render SOMETHING (header + error/empty state), not crash, not blank
assert(root.children.length > 0, 'panel rendered nothing in prod error mode');
if (typeof cleanup === 'function') cleanup();
});
}
// No data accessor may have flipped a demo flag in production.
await t('no demo flags set after production renders', () => assert(api.anyDemo() === false, 'a panel served mock data in production'));
console.log(`\n${passes.length} passed, ${fails.length} failed`);
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — every panel renders a typed empty/error state in production with no mock fallback');
@@ -0,0 +1,109 @@
// Render-smoke test — actually executes every HOMECORE-UI panel against
// the DOM shim and asserts each builds a non-empty DOM subtree without
// throwing. Also exercises the ui.js helpers and the mock contract.
// Run: node tests/render-smoke.mjs (from the ui/ dir)
import { install } from './dom-shim.mjs';
install();
globalThis.HOMECORE_UI_DEMO = true; // render panels against fixtures
const fails = [];
const passes = [];
function check(name, fn) {
try { fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
async function checkAsync(name, fn) {
try { await fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
const ui = await import('../js/ui.js');
const { api, entityProvenance } = await import('../js/api.js');
const mock = await import('../js/mock.js');
// ── ui.js helper unit checks ────────────────────────────────────────
check('ui.h builds element with class/id', () => {
const n = ui.h('div.card#x', { 'data-k': 'v' }, 'hi');
if (n.tagName !== 'DIV') throw new Error('tag');
if (!n.classList.contains('card')) throw new Error('class');
if (n.id !== 'x') throw new Error('id');
});
check('ui.statusPill maps running→green', () => {
const p = ui.statusPill('running');
if (!p.classList.contains('green')) throw new Error('expected green pill');
});
check('ui.statusPill maps offline→red', () => {
if (!ui.statusPill('offline').classList.contains('red')) throw new Error('expected red');
});
check('ui.bar applies threshold colour', () => {
const b = ui.bar(0.9, 1, [{ lt: 0.3, color: 'green' }, { lt: 0.6, color: 'amber' }, { lt: 1.01, color: 'red' }]);
if (!b.firstChild.classList.contains('red')) throw new Error('expected red fill at 0.9');
});
check('ui.confidenceBar amber under 0.4', () => {
if (!ui.confidenceBar(0.2).firstChild.classList.contains('amber')) throw new Error('low conf should be amber');
});
check('ui.provenanceBadge marks hailo', () => {
const p = ui.provenanceBadge({ esp32: 'e', seed: 's', cog: 'c', hailo: true });
if (!p.querySelector('.hailo')) throw new Error('hailo class missing');
});
check('ui.sparkline yields svg polyline', () => {
const s = ui.sparkline([1, 2, 3, 4]);
if (!s.querySelector('polyline')) throw new Error('no polyline');
});
// ── mock contract checks ────────────────────────────────────────────
check('mock RoomState distinguishes null vs withheld', () => {
const rs = mock.roomStates();
const office = rs.find((r) => r.room_id === 'office');
if (office.posture !== null) throw new Error('office posture should be null (not trained)');
const kitchen = rs.find((r) => r.room_id === 'kitchen');
if (!kitchen.vetoed) throw new Error('kitchen should be vetoed');
if (kitchen.posture.value !== null) throw new Error('vetoed posture value should be null/withheld, not zero');
});
check('analysis covers at least 3 bedrooms', () => {
const beds = mock.roomStates().filter((r) => /^bedroom/.test(r.room_id));
if (beds.length < 3) throw new Error(`expected ≥3 bedrooms in RoomState analysis, got ${beds.length}`);
const bedSeeds = mock.seeds().filter((s) => /bedroom/i.test(s.zone));
if (bedSeeds.length < 3) throw new Error(`expected ≥3 bedroom SEED nodes, got ${bedSeeds.length}`);
});
check('mock fleet has an offline seed with red tint semantics', () => {
if (!mock.seeds().some((s) => !s.online)) throw new Error('need an offline seed for §4.1 tint');
});
check('mock federation states the raw-CSI invariant', () => {
if (!/never raw CSI/i.test(mock.federation().invariant)) throw new Error('invariant text missing');
});
check('entityProvenance derives node→seed chain', () => {
const prov = entityProvenance({ attributes: { source: 'esp32-lr-01 BFLD' } });
if (prov.esp32 !== 'esp32-lr-01') throw new Error('node parse failed');
if (!prov.seed) throw new Error('seed mapping failed');
});
// ── render every panel ──────────────────────────────────────────────
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
const ctx = {
api,
navigate() {},
params: { id: 'seed-livingroom-a1' },
onEvent() { return () => {}; },
onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; },
wsStatus: () => ({ state: 'open', lagged: false }),
bus: new globalThis.EventTarget(),
};
for (const name of PANELS) {
await checkAsync(`render panel: ${name}`, async () => {
const mod = await import(`../js/panels/${name}.js`);
const panel = mod.default;
if (!panel || typeof panel.render !== 'function') throw new Error('no default.render export');
if (!panel.meta || !panel.meta.title) throw new Error('missing meta.title');
const root = document.createElement('div');
const cleanup = await panel.render(root, ctx);
if (root.children.length === 0) throw new Error('rendered nothing into root');
if (cleanup && typeof cleanup === 'function') cleanup(); // must not throw
});
}
// ── report ──────────────────────────────────────────────────────────
console.log(`\n${passes.length} passed, ${fails.length} failed`);
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — all ui helpers, mock contracts, and 10 panels render without throwing');
@@ -0,0 +1,101 @@
// Regression tests pinning the ADR-131 PR-1082 review fixes:
// * dashboard renders a not-available state ('—') for null appliance
// metrics — never "null%"/"null°C" (§6 honesty / fabricated-data fix).
// * cogs panel does NOT throw when the gateway forwards a `hef` that is a
// string (or other non-array) instead of an array (crash/robustness fix).
// * cogs Hailo worker pill reflects the real probe, not a hardcoded
// "connected" (§6 honesty fix).
// Run: node tests/unit-fixes.mjs
import { install } from './dom-shim.mjs';
install();
globalThis.HOMECORE_UI_DEMO = false; // production path — no fixtures
const fails = [], passes = [];
async function t(name, fn) {
try { await fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
const { api } = await import('../js/api.js');
// Shared ctx; per-test we override the api accessors we need.
function ctxWith(overrides) {
return {
api: Object.assign(Object.create(api), overrides),
navigate() {},
params: {},
onEvent() { return () => {}; },
onWs(fn) { fn({ state: 'closed', lagged: false }); return () => {}; },
};
}
// ── dashboard: null metrics → '—', never "null%"/"null°C" ─────────────
await t('dashboard renders not-available for null hailo metrics (no "null%")', async () => {
const mod = await import('../js/panels/dashboard.js');
const root = document.createElement('div');
const ctx = ctxWith({
appliance: async () => ({
cpu_pct: 12.5, ram_pct: 40.1,
hailo_load_pct: null, hailo_temp_c: null, // the fabricated-data trap
uptime_s: null,
services: [{ name: 'ruview-mcp-brain', port: 9876, status: 'unreachable' }],
event_rate: [], channel_capacity: 4096, channel_lag: 0,
}),
seeds: async () => [],
esp32Warnings: async () => [],
cogs: async () => [],
anyDemo: () => false,
});
const cleanup = await mod.default.render(root, ctx);
const text = root.textContent;
assert(!/null\s*%/.test(text), `dashboard showed "null%": ${text.slice(0, 200)}`);
assert(!/null\s*°C/.test(text), `dashboard showed "null°C": ${text.slice(0, 200)}`);
assert(text.includes('—'), 'dashboard should render the "—" not-available marker for null metrics');
// real values must still concatenate their unit
assert(text.includes('12.5%'), 'real CPU value must still render with its unit');
if (typeof cleanup === 'function') cleanup();
});
// ── cogs: string `hef` must not throw ─────────────────────────────────
await t('cogs does not throw when hef is a string (non-array)', async () => {
const mod = await import('../js/panels/cogs.js');
const root = document.createElement('div');
const ctx = ctxWith({
cogs: async () => [
{ id: 'cog-pose', version: '1.0', arch: 'hailo10', status: 'running', pid: 42,
sha256_verified: true, signature_verified: true, throughput_fps: 30,
hef: 'pose_estimation.hef' }, // STRING, not array — the crash trap
],
cogUpdates: async () => [],
appliance: async () => ({ services: [{ name: 'ruvector-hailo-worker', port: 50051, status: 'running' }] }),
isDemo: () => false,
});
// If asArray() weren't applied, .forEach/.join/.length on a string would throw.
const cleanup = await mod.default.render(root, ctx);
assert(root.children.length > 0, 'cogs rendered nothing');
// The string hef should surface as a single loaded HEF row.
assert(root.textContent.includes('pose_estimation.hef'), 'string hef should render as one HEF entry');
if (typeof cleanup === 'function') cleanup();
});
// ── cogs: Hailo worker pill reflects the real probe, not hardcoded ────
await t('cogs Hailo worker pill is unknown when appliance probe is unavailable', async () => {
const mod = await import('../js/panels/cogs.js');
const root = document.createElement('div');
const ctx = ctxWith({
cogs: async () => [],
cogUpdates: async () => [],
appliance: async () => { throw new Error('appliance upstream down'); }, // probe fails
isDemo: () => false,
});
const cleanup = await mod.default.render(root, ctx);
// statusPill('unknown') → grey pill containing the literal label "unknown".
assert(root.textContent.includes('unknown'), 'worker status should be honestly "unknown" when probe fails');
assert(!/connected/.test(root.textContent), 'worker pill must not fabricate "connected"');
if (typeof cleanup === 'function') cleanup();
});
console.log(`\n${passes.length} passed, ${fails.length} failed`);
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — dashboard not-available, cogs string-hef + honest worker pill pinned');
@@ -0,0 +1,67 @@
// Static import/export graph verifier for HOMECORE-UI.
// No deps — parses `import { a, b } from './x.js'` against the named
// exports of x.js. Fails if a panel imports a symbol that doesn't exist.
// Run: node tests/verify-imports.mjs (from the ui/ dir)
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const ROOT = resolve(import.meta.dirname, '..');
const files = [
'js/ui.js', 'js/api.js', 'js/ws.js', 'js/mock.js', 'js/app.js',
...readdirSync(resolve(ROOT, 'js/panels')).filter((f) => f.endsWith('.js')).map((f) => 'js/panels/' + f),
];
function namedExports(src) {
const out = new Set();
// export function/const/class NAME
for (const m of src.matchAll(/export\s+(?:async\s+)?(?:function|const|let|class)\s+([A-Za-z0-9_$]+)/g)) out.add(m[1]);
// export { a, b as c }
for (const m of src.matchAll(/export\s*\{([^}]*)\}/g)) {
for (const part of m[1].split(',')) {
const name = part.trim().split(/\s+as\s+/).pop().trim();
if (name) out.add(name);
}
}
if (/export\s+default/.test(src)) out.add('default');
return out;
}
function imports(src) {
const res = [];
for (const m of src.matchAll(/import\s+([^;]+?)\s+from\s+['"]([^'"]+)['"]/g)) {
const clause = m[1].trim(), spec = m[2];
const names = [];
const named = clause.match(/\{([^}]*)\}/);
if (named) for (const p of named[1].split(',')) { const n = p.trim().split(/\s+as\s+/)[0].trim(); if (n) names.push(n); }
const def = clause.replace(/\{[^}]*\}/, '').replace(/\*\s+as\s+\w+/, '').replace(/,/g, '').trim();
if (def) names.push('default');
if (/\*\s+as\s+/.test(clause)) names.push('*');
res.push({ spec, names });
}
return res;
}
const exportCache = {};
function exportsOf(absPath) {
if (!exportCache[absPath]) exportCache[absPath] = namedExports(readFileSync(absPath, 'utf8'));
return exportCache[absPath];
}
let errors = 0;
for (const rel of files) {
const abs = resolve(ROOT, rel);
const src = readFileSync(abs, 'utf8');
for (const imp of imports(src)) {
if (!imp.spec.startsWith('.')) continue; // skip bare specifiers
const target = resolve(dirname(abs), imp.spec);
let exps;
try { exps = exportsOf(target); } catch { console.error(`${rel}: cannot resolve ${imp.spec}`); errors++; continue; }
for (const n of imp.names) {
if (n === '*') continue;
if (!exps.has(n)) { console.error(`${rel}: imports '${n}' from ${imp.spec} which does not export it`); errors++; }
}
}
}
if (errors) { console.error(`\nFAILED — ${errors} unresolved import(s)`); process.exit(1); }
console.log(`OK — import/export graph consistent across ${files.length} modules`);
+30
View File
@@ -39,7 +39,20 @@ pub const DEFAULT_SAMPLE_RATE_HZ: f64 = 10_000.0;
pub const DEFAULT_F_MOD_HZ: f64 = 1_000.0;
/// Quantise one input sample (T) to a signed ADC code. Returns `(code, saturated)`.
///
/// A **non-finite** input (`NaN` / `±Inf`) is treated as an out-of-range
/// condition: it clamps to code `0` and raises the saturation flag. This is
/// the funnel point that stops the NaN-state-poisoning class — a non-finite
/// physical field (e.g. produced by a degenerate scene with a NaN dipole
/// position) would otherwise coerce silently to code `0` *with the saturation
/// flag clear*, yielding a frame indistinguishable from a legitimate
/// zero-field reading. Flagging it preserves the "every frame is honest about
/// its own validity" contract the proof bundle relies on.
pub fn adc_quantise(b_in_t: f64) -> (i32, bool) {
if !b_in_t.is_finite() {
// Non-finite => not representable on the ±FS scale; mark saturated.
return (0, true);
}
let code_f = (b_in_t / ADC_LSB_T).round();
let max_code = (1_i32 << (ADC_BITS - 1)) - 1; // 32_767 for 16-bit signed
let min_code = -max_code; // symmetric
@@ -153,6 +166,23 @@ mod tests {
}
}
#[test]
fn adc_quantise_flags_non_finite_as_saturated() {
// Security pinning (NaN-state-poisoning guard): a non-finite field
// value must clamp to code 0 AND raise the saturation flag, so the
// pipeline can flag the frame rather than emitting it as a silent,
// indistinguishable zero-field reading. Pre-fix this returned
// (0, false) for NaN — a silent corruption.
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let (code, sat) = adc_quantise(bad);
assert_eq!(code, 0, "non-finite input {bad} must clamp to code 0");
assert!(sat, "non-finite input {bad} must raise the saturation flag");
}
// A finite in-range value is unaffected (no false positives).
let (_, sat) = adc_quantise(1.0e-7);
assert!(!sat, "a finite in-range value must NOT be flagged saturated");
}
#[test]
fn adc_saturates_above_full_scale() {
let (code_pos, sat_pos) = adc_quantise(20.0e-6);
+76 -3
View File
@@ -51,11 +51,28 @@ impl Pipeline {
/// (sensor × sample) — i.e. `n_samples · scene.sensors.len()` frames
/// in scene-major / sample-minor order.
pub fn run(&self, n_samples: usize) -> Vec<MagFrame> {
let dt = self
// `dt` is derived from caller-supplied config — an external boundary
// (e.g. the WASM `config_json`). A degenerate `f_s_hz == 0` makes
// `1.0 / f_s_hz == +Inf`; a non-finite or non-positive `dt_s` is
// equally hostile. Sanitise before any arithmetic that could panic.
let raw_dt = self
.config
.dt_s
.unwrap_or(1.0 / self.config.digitiser.f_s_hz);
let dt_us = (dt * 1.0e6) as u64;
// Fall back to a 1 µs step (the smallest physically meaningful
// sample interval here) when `dt` is non-finite or non-positive, so
// the run produces well-defined frames instead of garbage / a panic.
let dt = if raw_dt.is_finite() && raw_dt > 0.0 {
raw_dt
} else {
1.0e-6
};
// `dt` is now finite & positive, so `dt * 1e6` is finite. Cap the
// `u64` cast defensively (a huge but finite `dt` could still exceed
// `u64::MAX`) and use `saturating_mul` for the per-sample timestamp so
// a pathological config can never trigger a multiply-with-overflow
// panic (debug / WASM panic=abort) or wrap to a garbage timestamp.
let dt_us = (dt * 1.0e6).min(u64::MAX as f64) as u64;
let nv = NvSensor::new(self.config.sensor);
let mut out: Vec<MagFrame> =
@@ -92,7 +109,7 @@ impl Pipeline {
];
let mut frame = MagFrame::empty(sensor_idx as u16);
frame.t_us = (sample as u64) * dt_us;
frame.t_us = (sample as u64).saturating_mul(dt_us);
frame.b_pt = b_pt;
frame.sigma_pt = sigma_pt;
frame.noise_floor_pt_sqrt_hz = (reading.noise_floor_t_sqrt_hz * 1.0e12) as f32;
@@ -205,6 +222,62 @@ mod tests {
}
}
#[test]
fn degenerate_zero_sample_rate_does_not_panic() {
// Security pinning (panic / DoS guard): an externally-supplied
// `f_s_hz == 0` makes `1/f_s_hz == +Inf`; pre-fix that produced
// `dt_us == u64::MAX`, and `sample * dt_us` panicked with
// "attempt to multiply with overflow" (debug / WASM panic=abort) at
// sample >= 2, or wrapped to a garbage timestamp in release. The
// sanitised `dt` + `saturating_mul` must keep the run finite.
let scene = fixture_scene();
let cfg = PipelineConfig {
digitiser: crate::digitiser::DigitiserConfig {
f_s_hz: 0.0,
f_mod_hz: 1000.0,
},
..PipelineConfig::default()
};
let frames = Pipeline::new(scene, cfg, 42).run(8);
assert_eq!(frames.len(), 8);
for f in &frames {
// Timestamps are monotone-well-defined, not garbage.
assert!(f.t_us < u64::MAX);
}
}
#[test]
fn non_finite_scene_input_flags_frame_instead_of_silently_zeroing() {
// Security pinning (NaN-state-poisoning guard): a NaN dipole position
// makes `r_norm` NaN, which bypasses the near-field clamp
// (`NaN < R_MIN_M` is false) and yields a NaN field. Pre-fix the
// digitiser silently coerced that NaN to code 0 with the saturation
// flag CLEAR — a frame indistinguishable from a real zero-field
// reading. Post-fix the frame must carry ADC_SATURATED so the
// corruption is visible downstream.
let mut scene = Scene::new();
scene.add_dipole(DipoleSource::new([f64::NAN, 0.0, 0.5], [0.0, 0.0, 1.0e-3]));
scene.add_sensor([0.0, 0.0, 0.0]);
let cfg = PipelineConfig {
sensor: NvSensorConfig {
shot_noise_disabled: true,
..NvSensorConfig::default()
},
..PipelineConfig::default()
};
let frames = Pipeline::new(scene, cfg, 0).run(4);
for f in &frames {
assert!(
f.has_flag(flag::ADC_SATURATED),
"non-finite field must raise ADC_SATURATED, not emit a silent zero frame"
);
// And the emitted value is a defined number, not NaN.
for b in f.b_pt {
assert!(b.is_finite());
}
}
}
#[test]
fn adc_saturation_flag_fires_above_full_scale() {
// Place a dipole close enough to drive the field above ±10 µT FS.
-84
View File
@@ -1,84 +0,0 @@
[package]
name = "ruview-swarm"
version = "0.1.0"
edition = "2021"
description = "RuView drone swarm control system — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing integration (ADR-148)"
license = "Apache-2.0"
# Publishing disabled until: (1) PR #862 merges, (2) internal path-deps are
# published in dependency order, (3) export-control sign-off on the ITAR-gated
# coordination features (USML Category VIII(h)(12)). Flip to true deliberately.
publish = false
[features]
default = []
# ITAR/USML Category VIII(h)(12): swarming coordination features.
# Must not be enabled in international distributions without export counsel review.
itar-unrestricted = []
mavlink = ["dep:mavlink"]
ros2-dds = []
onnx = ["dep:ort"]
simulation = []
demo = ["simulation"]
full = ["mavlink", "onnx", "demo", "itar-unrestricted"]
ruflo = ["dep:reqwest", "dep:serde_json"]
# Heavy GPU-capable MARL training (real Candle autodiff PPO). Off by default so
# the default build stays light and the existing test suite keeps passing.
train = ["dep:candle-core", "dep:candle-nn"]
cuda = ["candle-core/cuda", "candle-nn/cuda"]
[dependencies]
wifi-densepose-core = { path = "../wifi-densepose-core" }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
toml = "0.8"
# Async runtime
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
# MAVLink v2 (optional)
mavlink = { version = "0.13", optional = true }
# ONNX Runtime (optional — for MARL actor inference)
ort = { version = "2.0.0-rc.11", optional = true }
# Candle 0.9 — real autodiff PPO training (optional, behind `train` feature).
candle-core = { version = "0.9", default-features = false, optional = true }
candle-nn = { version = "0.9", default-features = false, optional = true }
# HTTP client (optional — for Ruflo HTTP backend)
reqwest = { version = "0.12", features = ["json"], optional = true }
# Crypto — MAVLink v2 HMAC-SHA256 signing
hmac = "0.12"
sha2 = "0.10"
# Error handling
thiserror = "2.0"
# Logging
tracing = "0.1"
# Numerics
nalgebra = "0.33"
rand = "0.8"
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
tokio-test = "0.4"
[[bench]]
name = "swarm_bench"
harness = false
# MARL training binary — requires the `train` feature (Candle autodiff).
# Excluded from the default build so `cargo test`/CI stay light.
[[bin]]
name = "train_marl"
required-features = ["train"]
# ADR-171 Stage-1 evaluation CLI — pure Rust, no special feature needed.
[[bin]]
name = "eval_swarm"
-108
View File
@@ -1,108 +0,0 @@
# wifi-densepose-swarm
Drone swarm control system for the RuView wifi-densepose workspace. Implements ADR-148.
## Overview
`wifi-densepose-swarm` provides a hierarchical-mesh drone swarm coordination system
with Raft consensus, MAPPO-based multi-agent reinforcement learning, and tight
integration with the existing WiFi CSI sensing pipeline (`wifi-densepose-signal`,
`wifi-densepose-ruvector`).
## Features
- **Hierarchical-Mesh Topology** — cluster heads over Raft consensus; inter-cluster Gossip for map dissemination
- **Formation Control** — F1 VirtualStructure, F2 LeaderFollower, F3 Reynolds flocking
- **3-Phase Coverage** — boustrophedon sweep → Bayesian probability grid → multi-drone triangulation
- **RRT-APF Path Planner** — RRT* with Artificial Potential Field reactive collision avoidance
- **MARL Actor (MAPPO)** — 64-dim local observation, 3-layer MLP actor, CTDE training interface
- **CSI Sensing Integration** — drone payload pipeline (ESP32-S3 → Jetson), multi-drone CSI fusion
- **OccWorld Bridge** — integrates ADR-147 OccWorld occupancy prior as path planner environment
- **Security Hardening** — MAVLink v2 HMAC-SHA256 signing, UWB GPS anti-spoofing, onboard geofencing, Remote ID
- **Fail-Safe State Machine** — 10-state onboard safety system, GCS-independent
- **Demo & Training Modes** — synthetic CSI generation, Gazebo/PX4 SITL interface, TOML mission configs
## ITAR Notice
> ⚠️ **Export-controlled capability.** Swarming coordination features (formation control,
> Raft consensus, task allocation) are gated behind the `itar-unrestricted` feature flag
> per **USML Category VIII(h)(12)**. Default builds compile only safe stubs.
> Do not enable `itar-unrestricted` for international distribution without export counsel review.
## Crate Features
| Feature | Description |
|---------|-------------|
| `default` | Core types, sensing, failsafe, config, MARL — no ITAR-gated code |
| `itar-unrestricted` | Enables formation control, Raft consensus, task allocation |
| `mavlink` | MAVLink v2 protocol support |
| `onnx` | ONNX Runtime backend for MARL actor inference (INT8) |
| `simulation` | Simulation-mode stubs |
| `demo` | Synthetic CSI generation, scenario runners |
| `full` | All of the above |
## Quick Start
```rust
use wifi_densepose_swarm::{config::SwarmConfig, demo::scenario::DemoScenario};
// Load a mission profile
let config = SwarmConfig::sar_default();
// Run a demo scenario
let scenario = DemoScenario::sar_rubble_field(4); // 4-drone SAR
let estimated_secs = scenario.estimate_coverage_time_secs();
// → < 240 s for 4 drones over 400×400 m (beyond Wi2SAR SOTA single-drone baseline)
```
## Mission Profiles
| Profile | Drones | Area | Application |
|---------|--------|------|-------------|
| `sar` | 612 | 400×400 m | Structural collapse victim search |
| `inspection` | 36 | Linear corridor | Infrastructure (power lines, bridges) |
| `agriculture` | 412 | Field-configurable | NDVI mapping, variable-rate spraying |
| `mine` | 24 | Tunnel | GPS-denied underground exploration |
| `relay` | 620 | Perimeter | Emergency telecom relay chain |
| `demo` | Any | Configurable | Synthetic CSI, configurable victims |
## Module Structure
```
src/
├── types.rs — NodeId, DroneState, SwarmTask, SwarmError, FailSafeState
├── topology/ — Raft consensus¹, Gossip dissemination, MeshTopology
├── formation/ — VirtualStructure¹, LeaderFollower¹, Reynolds flocking¹
├── planning/ — RRT-APF planner, 3-phase coverage, Bayesian grid, pheromone
├── allocation/ — Auction-based task allocation¹, FNN bid scorer¹
├── sensing/ — CSI payload pipeline, multi-drone fusion, OccWorld bridge
├── marl/ — MAPPO actor, LocalObservation, reward shaping, TrainingConfig
├── security/ — MAVLink signing, UWB anti-spoofing, geofencing, Remote ID
├── failsafe/ — 10-state onboard fail-safe machine
├── config/ — TOML SwarmConfig with mission presets
├── demo/ — Synthetic CSI, DemoScenario runners
├── integration/ — FlightController trait (PX4/ArduPilot/Sim)
└── bench_support.rs — Criterion fixture generators
¹ Requires `itar-unrestricted` feature.
```
## Related ADRs
| ADR | Title | Relation |
|-----|-------|----------|
| ADR-148 | Drone Swarm Control System | This crate |
| ADR-147 | OccWorld Occupancy World Model | Environment prior via `sensing::occworld_bridge` |
| ADR-134 | CSI→CIR ISTA Sparse Recovery | Drone payload sensing |
| ADR-146 | RF Encoder Multitask Heads | Drone payload inference |
| ADR-016 | RuVector Training Integration | CrossViewpointAttention |
## Performance Targets (vs. Wi2SAR SOTA)
| Metric | Wi2SAR baseline (1 drone) | 4-drone target |
|--------|--------------------------|----------------|
| Coverage | 160,000 m² | 160,000 m² |
| Time | 13.5 min | ≤ 4 min |
| Localization | 5 m | ≤ 2 m (3-view fusion) |
| MARL inference | N/A | ≤ 5 ms (INT8, release) |
| Raft election | N/A | ≤ 300 ms |
@@ -1,70 +0,0 @@
use criterion::{criterion_group, criterion_main, Criterion};
use ruview_swarm::marl::{MappoActor, ActorConfig};
use ruview_swarm::marl::LocalObservation;
use ruview_swarm::sensing::MultiViewFusion;
use ruview_swarm::planning::RrtApfPlanner;
use ruview_swarm::demo::{DemoScenario};
use ruview_swarm::types::{CsiDetection, NodeId, Position3D};
fn bench_marl_inference(c: &mut Criterion) {
let actor = MappoActor::random_init(ActorConfig::default());
let obs = LocalObservation::zeros();
c.bench_function("marl_actor_inference", |b| b.iter(|| actor.forward(&obs)));
}
fn bench_rrt_apf_plan(c: &mut Criterion) {
let planner = RrtApfPlanner::new(3.0);
let start = Position3D { x: 0.0, y: 0.0, z: -30.0 };
let goal = Position3D { x: 50.0, y: 50.0, z: -30.0 };
c.bench_function("rrt_apf_100iter", |b| b.iter(|| {
let mut rng = rand::thread_rng();
planner.plan(start, goal, 100, &mut rng)
}));
}
fn bench_multiview_fusion(c: &mut Criterion) {
let fusion = MultiViewFusion::default();
let detections = vec![
CsiDetection { drone_id: NodeId(0), confidence: 0.85, victim_position: Some(Position3D { x: 51.0, y: 49.0, z: 0.0 }), timestamp_ms: 0 },
CsiDetection { drone_id: NodeId(1), confidence: 0.78, victim_position: Some(Position3D { x: 49.0, y: 51.0, z: 0.0 }), timestamp_ms: 0 },
CsiDetection { drone_id: NodeId(2), confidence: 0.92, victim_position: Some(Position3D { x: 50.0, y: 50.0, z: 0.0 }), timestamp_ms: 0 },
];
let positions = vec![
(NodeId(0), Position3D { x: 0.0, y: 0.0, z: -30.0 }),
(NodeId(1), Position3D { x: 100.0, y: 0.0, z: -30.0 }),
(NodeId(2), Position3D { x: 50.0, y: 86.6, z: -30.0 }),
];
c.bench_function("multiview_fusion_3drones", |b| b.iter(|| fusion.fuse(&detections, &positions)));
}
fn bench_demo_coverage_estimate(c: &mut Criterion) {
let scenario = DemoScenario::sar_rubble_field(4);
c.bench_function("demo_coverage_estimate", |b| b.iter(|| scenario.estimate_coverage_time_secs()));
}
fn bench_ppo_update(c: &mut Criterion) {
use ruview_swarm::marl::{MappoActor, ActorConfig, LocalObservation};
use ruview_swarm::marl::training_loop::{ReplayBuffer, Transition, PpoConfig, ppo_update};
use ruview_swarm::marl::actor::ActorAction;
let mut buf = ReplayBuffer::new(64);
for i in 0..64 {
buf.push(Transition {
obs: LocalObservation::zeros(),
action: ActorAction { delta_heading_rad: 0.1, delta_altitude_m: 0.0, speed_ms: 5.0, trigger_csi_scan: true },
reward: if i % 2 == 0 { 10.0 } else { -2.0 },
next_obs: LocalObservation::zeros(),
done: i == 63,
});
}
let cfg = PpoConfig::default();
c.bench_function("ppo_update_64transitions", |b| {
b.iter(|| {
let mut actor = MappoActor::random_init(ActorConfig::default());
ppo_update(&mut actor, &buf, &cfg)
})
});
}
criterion_group!(benches, bench_marl_inference, bench_rrt_apf_plan, bench_multiview_fusion, bench_demo_coverage_estimate, bench_ppo_update);
criterion_main!(benches);
-2
View File
@@ -1,2 +0,0 @@
# ADR-171 evaluation outputs
RESULTS.md is generated by the `eval_swarm` binary.
-26
View File
@@ -1,26 +0,0 @@
# ruview-swarm Evaluation Results (ADR-171 Stage 1, kinematic)
Statistically-rigorous evaluation harness: seeded multi-run rollouts with IQM + 95% stratified-bootstrap confidence intervals (Agarwal et al., NeurIPS 2021).
## Run configuration
- **Stage**: 1 (kinematic, self-contained, deterministic per seed)
- **Episodes per pattern**: 100 (seed × episode matrix)
- **CI method**: 95% stratified bootstrap of the IQM, stratified by seed
- **GDOP**: 2-D geometric dilution of precision at first detection
> **Stage 2 pending**: high-fidelity Gazebo/PX4 SITL evaluation (false-alarm rate, real collision rate on the median seeds) is a follow-on — see ADR-171 §6.1. The collision figures below are a kinematic min-separation proxy, not SITL physics.
## Flight-pattern leaderboard
| Flight pattern | Coverage IQM [95% CI] | Localization (m) IQM [95% CI] | Detection rate | Mean GDOP |
|----------------|-----------------------|-------------------------------|----------------|-----------|
| partitioned_lawnmower | 1.000 [1.000, 1.000] | 7.022 [5.669, 8.379] | 100.0% | 0.000 |
| pheromone | 0.662 [0.652, 0.671] | 4.110 [3.346, 5.141] | 95.0% | 1.598 |
| levy_flight | 0.490 [0.489, 0.491] | 3.523 [2.897, 4.160] | 100.0% | 0.000 |
| boustrophedon | 0.370 [0.370, 0.370] | 2.740 [2.357, 3.207] | 100.0% | 0.000 |
| spiral | 0.336 [0.336, 0.336] | 3.082 [2.678, 3.568] | 100.0% | 0.000 |
| potential_field | 0.254 [0.252, 0.256] | 4.343 [3.489, 5.265] | 100.0% | 0.000 |
| _Wi2SAR (paper baseline)_ | _n/a_ | _5.0 (paper)_ | _n/a_ | _n/a_ |
_Wi2SAR row is the published single-drone localization figure (arxiv 2604.09115), shown paper-to-paper for reference only — it was not re-run through this kinematic harness._

Some files were not shown because too many files have changed in this diff Show More