diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5bc9a9..1b9cf82a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Security +- **ADR-131 HOMECORE-UI BFF gateway — public-PR review fixes (PR #1082).** (1) **HIGH — path-traversal / confused-deputy SSRF closed in the `/api/cal/*` reverse-proxy** (`homecore-server/src/gateway.rs`). The wildcard proxy path was interpolated straight into the upstream URL while `proxy()` attaches the server-side calibration bearer, so `/api/cal/v1/../../x` (and percent-encoded `..%2f`, `%2e%2e`, leading `/`, backslash, double-encoded `%252e`) could escape the `…/api/` scope **with the privileged token**. Now `validate_proxy_path()` decode-then-checks and rejects absolute/backslash/dot-segment/encoded-traversal paths with a typed **400 BEFORE the URL is built** (applies to GET **and** POST); legit `v1/...` paths still pass. Pinned by `cal_proxy_rejects_traversal_with_400_before_upstream` (fails on old code) + `validate_proxy_path_rejects_traversal_variants`. (2) **CORS + request-tracing now cover the gateway routes.** `/api/homecore/*` and `/api/cal/*` were `.merge()`d **outside** the layers `homecore-api::router()` applies, leaving them with no CORS allowlist and untraced; the audited `build_cors_layer()` (HC-05) + `TraceLayer` are now applied to the whole merged surface in `main.rs`. Pinned by `gateway_routes_are_cors_covered_after_merge` (Vite-dev-origin preflight succeeds on a gateway route). (3) **Fabricated-data honesty (§6 invariant 3):** the gateway no longer injects a hardcoded `anomaly.threshold: 0.5` — it passes through the REAL upstream threshold or emits `null` (withheld); the dashboard renders a not-available `—` instead of `"null%"`/`"null°C"` for null appliance metrics; the COG panel's Hailo-worker pill reflects the real appliance probe instead of a hardcoded `"connected"`; `rooms.js` treats a null anomaly threshold as withheld, not a fake `0.8` default. (4) **Robustness:** a forwarded `hef` that is a string (not an array) no longer throws in the COG panel; the calibration wizard guards `frames/target` against `NaN%`/`Infinity%` and clears its baseline poll timer on Restart / panel teardown (leaked `setTimeout` loop fixed). (5) **Perf:** per-bank RoomState fetches and the appliance service probes now run concurrently (`futures::join_all`; async `tokio::net::TcpStream` + `timeout` replaces the blocking `connect_timeout` that parked a worker per probe); the mock fixture module is now a dynamic `import()` gated on demo mode so production never bundles it. **Note (workspace-wide, not fixed here):** `homecore-server` requests `reqwest`'s `rustls-tls` only, but cargo feature-unification means a sibling crate enabling the default `native-tls` re-introduces OpenSSL into the final binary regardless — a true "no OpenSSL on the appliance" guarantee requires aligning every reqwest-pulling crate on rustls-only. **Note (pre-existing, out of scope):** DEV-mode `allow_any_non_empty()` bearer auth when `HOMECORE_TOKENS` is unset on `0.0.0.0` is unchanged; the loud `warn!` at boot is retained — provision real tokens before network exposure. **Verified:** `cargo test -p homecore-server --no-default-features` = **18/18 pass**, `cargo build -p homecore-server` clean, UI suite (`node tests`) all green, Python proof VERDICT PASS (hash unchanged). - **`ruview-swarm` beyond-SOTA security + correctness review (ADR-148 drone swarm control plane; needs ADR slot 176) — 4 real fail-open / DoS bugs fixed in the NaN-state-poisoning class, each pinned fails-on-old / passes-on-new; 5 dimensions confirmed clean with evidence.** The shared theme is **IEEE-754 NaN/Inf silently defeating a safety comparison** on data that crosses the untrusted swarm-comm trust boundary (`SwarmOrchestrator::receive_peer_state` / `receive_peer_detection` accept full `DroneState`/`CsiDetection` whose f64/f32 fields deserialize with no finite-check; the integer-encoded MAVLink wire formats in `mavlink_messages.rs` cannot carry NaN, but the serde struct path can). **(1) HIGH — `failsafe::FailSafeMachine::tick` collision-avoidance + battery fail-open** (`failsafe/mod.rs:51,75`). `nearest_neighbor_dist < collision_dist_m` and `battery_pct <= rth_pct` both evaluate `false` for a NaN operand, so a poisoned peer position (→ NaN `nearest_peer_distance` via `Position3D::distance_to`) **silently disabled collision avoidance** and a NaN battery reading kept a drone Nominal — the worst failure for a physical airframe. Fixed to fail CLOSED (`!is_finite() ||` → `EmergencyDiverge` / `ReturnToHome`). MEASURED fails-on-old: `test_nan_neighbor_distance_fails_closed_to_diverge` / `test_nan_battery_fails_closed_to_rth` both returned `Nominal` pre-fix. **(2) MEDIUM — `security::geofence::Geofence::check` NaN-altitude bypass** (`security/geofence.rs:33`). A NaN `z` (altitude) with valid x/y skipped the altitude breach (`NaN < min || NaN > max` = `false`) and returned **`Safe`** through the point-in-polygon path — a silent geofence bypass. Fixed with a leading non-finite-coordinate → `HardBreach` guard. MEASURED fails-on-old: `test_nan_altitude_fails_closed` returned `Safe` pre-fix. **(3) MEDIUM/DoS — `security::antijamming::FhssRadio` `% 0` panic on empty `channels_mhz`** (`security/antijamming.rs:65,71,102`). `FhssConfig` is `Deserialize`; an empty channel list (malformed/hostile config) made `next_hop`/`current_channel_mhz`/`evasive_hop`/`tick` panic with `remainder with a divisor of zero`, crashing the radio task. Fixed with `len == 0` early-returns (benign `0.0` sentinel). MEASURED fails-on-old: `test_empty_channels_does_not_panic` **panicked** (`divisor of zero`) pre-fix. **(4) LOW — `sensing::multiview::MultiViewFusion::fuse` NaN victim-position propagation** (`sensing/multiview.rs:70`). A NaN `victim_position` passed the `is_some()` filter and propagated through the confidence-weighted average into the fused "confirmed victim" location dispatched to the swarm. Fixed by requiring finite `confidence` + finite position components (fail-closed drop). MEASURED fails-on-old: `test_nan_victim_position_dropped_from_fusion` produced a non-finite fused position pre-fix. **Dimensions confirmed clean (with evidence):** (a) **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]` path exists). (b) **UWB GPS anti-spoofing is NaN-safe** — `(gps_dist - uwb_dist).abs() <= tol` already fails CLOSED on a NaN range/position (counts as inconsistent → spoof rejected), verified by reasoning + existing `test_spoofed_gps_invalid`. (c) **Bounded grid / no allocation-from-length-field** — `ProbabilityGrid::update_bayesian`/`mark_scanned` bounds-check `cx >= width || cy >= height`; `pos_to_cell` uses saturating `as u32` (Rust `as` saturates, no UB). (d) **Mesh `nearest_k` NaN-safe sort** — `partial_cmp(..).unwrap_or(Equal)` cannot panic on NaN distances. (e) **No hardcoded secrets** — `MavlinkSigner` key is constructor-injected (`[u8;32]`), nothing embedded. **Documented-not-fixed (for ADR-176, not churned to avoid test-rewrite risk):** (i) **Raft `AppendEntries` lacks the Log-Matching consistency check** (`topology/raft.rs:187`) — a follower appends leader entries on `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; vote tallying is also delegated to the caller per the existing `handle_message` comment). (ii) **`MavlinkSigner::verify` uses a non-constant-time tag `==` and has no replay/timestamp-window rejection** (`security/mavlink_signing.rs:64`) — the doc comment already flags the replay limitation as a known demo/test simplification. `cargo test -p ruview-swarm --no-default-features`: **117 → 123 passed, 0 failed** (+6 pins). Workspace green; Python deterministic proof unchanged (`f8e76f21a0f9852b70b6d9dd5318239f6b20cbcb4cdd995863263cecdc446f7a`, bit-exact — `ruview-swarm` is off the signal proof path). - **`nvsim` (ADR-089 NV-diamond magnetometer simulator) beyond-SOTA security review — two real degenerate-input findings fixed (config-induced panic/DoS + NaN-state-poisoning silent-corruption), each pinned by a fails-on-old test; determinism integrity, panic-free deserialisation, and RNG-seeding confirmed clean with evidence. Needs ADR slot 177.** Beyond-SOTA review of the standalone WASM-ready forward-only pipeline (`scene → source → propagation → NV ensemble → digitiser → MagFrame + SHA-256 witness`). The real risk surface is degenerate physical-parameter input (the scene + config are the external boundary, especially via the WASM `config_json`/`scene_json` entry points). **Finding 1 — NVSIM-DT-01 (config-induced panic / DoS, MEDIUM; `pipeline.rs:58,95`).** `dt` was derived as `config.dt_s.unwrap_or(1.0 / f_s_hz)`; an externally-supplied `f_s_hz == 0.0` makes `dt == +Inf`, `(dt*1e6) as u64` saturates to `u64::MAX`, and `(sample as u64) * dt_us` then **panics `attempt to multiply with overflow`** for `sample >= 2` (MEASURED: probe panicked at `pipeline.rs:95:30` under the debug/test profile; in `panic=abort` WASM this aborts the module, in release it silently wraps `t_us` to garbage). **Fixed** by sanitising `dt` (non-finite/non-positive → 1 µs fallback), capping the `u64` cast at `u64::MAX`, and using `saturating_mul` for the timestamp so no config can ever overflow it. **Finding 2 — NVSIM-NAN-01 (NaN-state-poisoning silent corruption, MEDIUM; funnel at `digitiser.rs::adc_quantise`).** A non-finite scene parameter (e.g. a `NaN`/`Inf` dipole **position**, `Inf` **moment**, or `NaN` loop **radius**) flows through `scene_field_at` and **bypasses the near-field clamp** — `NaN < R_MIN_M` is `false`, so the `1/r³` path is taken and produces a `NaN`/`Inf` field (MEASURED: `b=[NaN,NaN,NaN], sat=false`). At the ADC that non-finite value hit the `else` branch and **`NaN as i32 == 0`** (Rust saturating cast), emitting a frame with `b_pt=[0,0,0]` and **the `ADC_SATURATED` flag CLEAR** — a frame *indistinguishable from a legitimate zero-field reading* (MEASURED: `b_pt=[0.0,0.0,0.0] flags=0b0000`). This is the same NaN-poisoning class flagged across the calibration/vitals crates; the `propagation` module already guards its NaN paths, but the source→ADC path did not. **Fixed** at the single funnel point: `adc_quantise` now treats any non-finite input as out-of-range → clamps to code `0` **and raises the saturation flag**, so the corruption is visible downstream (the pipeline's existing `adc_sat` OR-reduction propagates `ADC_SATURATED` onto the frame). **Dimensions confirmed clean (with evidence):** (1) **Determinism integrity — clean.** The only RNG is `ChaCha20Rng::seed_from_u64(seed)` fully seeded from the caller's `u64` (grep: one `seed_from_u64`, zero `thread_rng`/`getrandom`/`SystemTime`/`Instant`/`HashMap`); Cargo.toml pins `rand`/`rand_chacha` with `default-features=false` (no OS-entropy path). Box–Muller draws from `gen_range(f64::EPSILON..=1.0)` (avoids `ln(0) = -Inf` by construction). Frame serialisation is fixed little-endian; source summation order is fixed by `Vec` order. The published cross-machine witness `cc8de9b0…93b4` (`proof::tests::proof_witness_publishes_a_known_value`) **still passes unchanged** after both fixes — the happy-path output is byte-identical, confirming the guards only affect degenerate inputs. (Attested caveat, not a finding: libm `cos`/`ln`/`sqrt` *could* differ x86↔wasm; witness is documented as x86_64-captured.) (2) **Panic-free deserialisation — clean.** `MagFrame::from_bytes` validates `len`/magic/version, then the per-field `buf[a..b].try_into().expect(...)` calls are over **fixed sub-ranges of an already-length-checked 60-byte buffer** → provably infallible, not reachable panic vectors. No `unsafe`, no `panic!`/`unreachable!` in production code; every other `unwrap`/`expect` is `#[cfg(test)]`. (3) **Div-by-zero — clean.** `dipole_field`/`current_loop_field` clamp `r_norm < R_MIN_M` (1 mm) before the `1/r³`/`1/r²` divide (finite inputs); `shot_noise_floor` guards `denom <= 0.0 → f64::INFINITY`; `vec3_normalise` guards `n < 1e-20`. (The only gap was the NaN case that *bypasses* the `r_norm` clamp — fixed at the ADC funnel above.) **Pinning tests (fails-on-old / passes-on-new, MEASURED):** `pipeline::degenerate_zero_sample_rate_does_not_panic` (panicked on old code; now finite frames), `pipeline::non_finite_scene_input_flags_frame_instead_of_silently_zeroing` (old: `flags=0b0000`; now `ADC_SATURATED` set, `b_pt` finite), `digitiser::adc_quantise_flags_non_finite_as_saturated` (old: `(0,false)` for NaN; now `(0,true)`). `cargo test -p nvsim --no-default-features`: **50 → 53 passed, 0 failed**. Workspace green; Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — nvsim is off the signal proof path). Needs ADR slot 177. - **`wifi-densepose-core` + `wifi-densepose-cli` beyond-SOTA security review (ADR-127 note; CLI needs ADR slot) — NaN-state-poisoning bug class does NOT originate in core (verdict: no + evidence); both crates confirmed clean on all reviewed dimensions; 4 regression pins added locking in two real DoS guards.** **Load-bearing question — verdict NO (with evidence, MEASURED).** The NaN-state-poisoning class that hit `wifi-densepose-calibration`/`-vitals`/`-geo` (a non-finite input latching into a persistent IIR/Welford/von-Mises/voxel accumulator → silent permanent feature failure) does **not** live in a shared `wifi-densepose-core` primitive: core exposes **no stateful accumulator at all** — no Welford/running-mean, no von-Mises/circular-mean, no IIR/biquad filter state, no voxel grid. Grep over `core/src` for `welford|von_mises|biquad|y1|y2|running_mean|accumulat|voxel|self.*+=` matched only the `InvalidState` *error* enum, "reset state" doc comments, and a test-only LCG — zero stateful logic (MEASURED). Each downstream crate rolls its own accumulator, so each fix is correctly local; corroborated by `wifi-densepose-calibration::Features::from_series`, which **already** filters non-finite samples and returns `Features::ZERO` (the downstream re-implementation of the fix). The only float math in core's hot path is construction-time projection (`CsiFrame::new` → `amplitude`/`phase` via `mapv`) and pure stateless `utils` functions — none persists state across frames. **Dimensions confirmed clean (with evidence):** (1) **panic-on-adversarial-input = 0** — `CsiFrame::from_canonical_bytes` (the replay/forward deserialisation boundary) returns a typed `CanonicalDecodeError` for every malformed input (truncation, bad discriminant, non-UTF-8 device id, nonzero reserved bytes, shape/payload mismatch, trailing bytes); the CLI UDP parser `parse_csi_packet` (the widest CLI attack surface — bound to `0.0.0.0` by default) returns `None` on any malformed datagram. Both proven panic-free over deterministic-LCG fuzz sweeps (new pins). (2) **`Confidence::new` rejects NaN** (`!(0.0..=1.0).contains(&NaN)` ⇒ `true` ⇒ `Err`); `compute_bounding_box`/`to_flat_array` are NaN-tolerant (f32 `min`/`max` ignore NaN). (3) **`amplitude_variance`/`mean_amplitude` panic-free on empty frames** — ndarray 0.17 `var(0.0)`/`mean()` return finite/`None` (handled), not a panic (MEASURED via throwaway probe). (4) **Unbounded-memory DoS bounded** in both deserialisers: `from_canonical_bytes` guards the `Vec::with_capacity(rows*cols)` with `rows.saturating_mul(cols).saturating_mul(16) <= bytes.len()`; `parse_csi_packet` guards `Array2::zeros` with `buf.len() < 20 + n_pairs*2` — a header lying about an enormous `rows×cols` / `n_antennas×n_subcarriers` is rejected before allocation. (5) **CLI path-traversal** in `calibrate-serve` already defended by `sanitize_room_id` (keeps `[A-Za-z0-9_-]`, caps 64 chars, with tests) on every client-supplied `room_id`/`bank`/`baseline` name that reaches a file path; bearer-auth gate + non-loopback-bind warning present. (6) **No hardcoded secrets** (`--token` read from `CALIBRATE_TOKEN` env, never embedded). **Regression pins added (fails-on-old / passes-on-new):** core `canonical_decode_oversized_shape_is_bounded_not_allocated` (MEASURED: with the saturating guard removed it panics `capacity overflow` at `types.rs:801`; passes with the guard) and `canonical_decode_never_panics_on_arbitrary_bytes`; CLI `test_parse_csi_packet_oversized_claim_is_rejected_not_allocated` (a 255×65535-pair claim ≈ 33 MB in a 2 KB datagram → `None`, never OOMs) and `test_parse_csi_packet_never_panics_on_arbitrary_bytes`. `cargo test -p wifi-densepose-core`: **35 → 37** lib tests, 0 failed; `cargo test -p wifi-densepose-cli --no-default-features`: **24 → 26**, 0 failed. Workspace green; Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — core/cli are off the signal proof path). No production code changed — review is clean-with-evidence plus pins. @@ -16,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`homecore-recorder` security review (ADR-132 surfaces) — two real bounding fixes; SQL-injection & NaN-index dimensions confirmed clean with evidence.** Beyond-SOTA review of the HA-compat state recorder (DB persistence + history + ruvector semantic search), the crux being its DB-backed SQL-injection surface. **Findings + fixes:** (1) **Memory-DoS — unbounded `get_state_history`.** The history query carried no `LIMIT`, so a wide `[since, until]` window over a high-frequency entity (a per-second sensor ≈ 86k rows/day) would load an unbounded row set into a single in-memory `Vec`. Added a hard `LIMIT MAX_HISTORY_ROWS` (1,000,000 — generous enough never to truncate a realistic history graph, bounded enough to cap the worst case); the sibling search paths were already `k`-bounded. (2) **Disk-DoS / documented-but-missing `purge`.** The README + HA-compat table advertised `Recorder::purge(older_than)` as a capability, but **no such method existed** — i.e. no retention path at all → unbounded disk growth. Implemented a **transactional** `purge` that deletes `states` + `events` strictly **older than** the cutoff (**exclusive** boundary — idempotent, no off-by-one; a row at the cutoff instant is kept) and **garbage-collects** orphaned `state_attributes` blobs (a dedup-shared blob is dropped only once its last referencing state is gone); all three deletes run in one transaction so a mid-purge failure rolls back cleanly (no states-deleted-but-events-kept corruption). **Confirmed clean with evidence:** SQL injection — **every** query in `db.rs` uses bound `?` parameters (no `format!`/string-concat of user data into SQL); the lone `format!` builds the LIKE *pattern*, which is itself bound as a parameter with `ESCAPE '\\'` and metacharacter escaping. Pinned: a state value `'; DROP TABLE states; --` is stored/queried **literally** (table survives), and a `%`/`_` in a search query matches **literally**, not as a wildcard. NaN-index poisoning (the calibration/vitals/geo class) — **structurally impossible** here: embeddings are SHA-256 → `i32` → `f32` (an `i32` cast to `f32` is always finite, never NaN/Inf), with an all-zero-digest norm guard; probed empty-index search, empty-string query, and `k=0` — all return `Ok(0)`, **no panic**. Fail-closed write path — a removal event yields `Ok(None)`, semantic-index failure is logged not propagated (best-effort, never blocks the durable SQLite write), and `EntityId` parsing failures fall back rather than panic. **6 new pinning tests** (SQL-injection literal-storage, LIKE-metacharacter literalness, history `LIMIT`, purge exclusive-boundary, purge attribute-GC-keeps-shared, purge old-events): `homecore-recorder` **19 → 25** (`--no-default-features`) / **25 → 31** (`--features ruvector`), 0 failed; the purge-boundary test is a true pin (fails deleting 2 rows under an inclusive cutoff, passes deleting 1 under the exclusive cutoff). Behaviour otherwise unchanged; Python deterministic proof unchanged (recorder is off the signal proof path). ### Added +- **ADR-131 §11–§12: HOMECORE-UI wired to a real backend — single-origin BFF gateway + production front-end (no mock in prod).** Implements the §11 wiring decision so the dashboard stops rendering fabricated data. **Front-end (DONE + verified under Node):** `api.js` rewritten so every data accessor is async and calls the §11.2 gateway routes; the in-browser mock is demoted to a **dev-only fixture** reachable only via `?demo=1`/`HOMECORE_UI_DEMO` (§2.2); all ten panels now `await` and render a **typed empty/error state** on upstream failure (no mock fallback in production) — 3 panels converted by hand, 7 via a parallel agent swarm. **New `homecore-server` BFF gateway (`src/gateway.rs`, compile-pending — no Rust toolchain in the authoring env):** promotes `homecore-server` to the single origin (§2.1); adds `/api/homecore/*` + `/api/cal/*` merged into `build_app`, with `reqwest` + CLI/env flags (`--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`). Real handlers: calibration **reverse-proxy** (W2), `GET /api/homecore/rooms` with the §11.3 **RoomState adapter** (`breathing`→`breathing_bpm`, `heartbeat`→`heart_bpm`, `None`→`null` preserving not-trained-vs-withheld, injected `anomaly.threshold`/`room_id`), **COG supervisor** over `/var/lib/cognitum/apps/` (W4), and **appliance metrics** from `/proc` + TCP service probes (W6); SEED-device/appliance routes (seeds/federation/witness/privacy/settings/automations/events-history/hailo/tokens — W3/W5) return a typed `503 upstream_unavailable` and the UI shows error states. **Tests:** front-end **5 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 proves every panel renders an error state, never mock, never throws (it caught + fixed a real unhandled-rejection in the events automation builder). **Gateway compiled, tested, and run on Rust 1.89:** `cargo test -p homecore-server --no-default-features` = **12/12 pass** (6 gateway + 6 UI mount); the binary was **run live** — `GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes, unauth → `401`, `cogs` → `[]` (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` adapts `RoomState` to the UI shape (`breathing`→`breathing_bpm`, `heartbeat:null`→`heart_bpm:null`, injected `anomaly.threshold`/`room_id`). **Live testing caught + fixed a real bug** — a double-`v1` segment in the `/api/cal/*` proxy URL. **Remaining (intrinsic, not an env limit):** W3/W5/W6-Hailo/federation depend on services/hardware **not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, Hailo stat source), so they return honest `503`s rather than fabricate data; W1/W2/W4/W6-appliance are functional now. ADR-131 §10/§12.1 updated with per-wave status. +- **ADR-131: HOMECORE-UI — the complete operational dashboard for the two-tier Cognitum stack, served by `homecore-server` at `/homecore`.** A zero-dependency, no-build-step vanilla TS/JS + CSS frontend (the `rufield-viewer` "Axum + vanilla-JS" pattern) that extends the Cognitum Appliance shell as a first-class nav section (Framework | Guide | Cog Store | **HOMECORE** | Status). **Complete, not a scaffold** (per the ADR's revised §2/§7): all **10 panels** ship fully built and rendered — §4.1 System Dashboard (v0 Appliance health strip + SEED fleet grid + ESP32 summary + COG status row + event-bus sparkline), §4.2 SEED Detail (vector store / witness chain / 5 onboard sensors / reflex rules / cognitive-fragility / ingest packet-type), §4.3 SEED Fleet Map (Appliance→SEED→ESP32 hierarchy, ESP-NOW mesh, cross-SEED fusion badges, ADR-105 federation), §4.4 Entity & State Browser (domain-grouped, **live WebSocket `subscribe_events` patching — never polls**, first-class provenance badges, keyword filter, context-causality slide-over), §4.5 RoomState/Sensing (mixture-of-specialists), §4.6 COG Management + App Registry, §4.7 Calibration Wizard (5-step baseline→enroll→train→verify), §4.8 Event Bus + Automation builder, §4.9 Witness/Audit log (two-tier SHA-256 + Ed25519 timeline, privacy-mode banner, pagination, export), §4.10 Settings. **Design system is the exact production Cognitum palette** (`tokens.css` carries `--cyan #4ecdc4` … `--r 10px` verbatim, §3.1) so there is no visual seam with the Cog Store (§3.3 invariant). **§6 UX invariants enforced in code and pinned by tests:** tier-origin provenance is always-visible (never collapsed); `stale`/`vetoed` flags and the kNN fragility score are prominent (amber/red tint + banners, never grey-on-grey); a `null` specialist renders "Not trained / calibrate to enable" **visually distinct from** veto-`withheld` (rendered as explicitly withheld, never zero) **distinct from** an error; all IDs/hashes/endpoints/payloads use `--mono`; Hailo-sourced COGs (`arch: hailo10`) are visually distinguished from CPU-only (`arch: arm`). **Wiring:** `homecore-server` gains a `--ui-dir`/`HOMECORE_UI_DIR` flag and mounts the assets via `tower-http` `ServeDir` at `/homecore` alongside the unchanged HA-compat `/api` surface (new testable `build_app()`), with **5 Rust integration tests** (`#[cfg(test)] mod ui_tests`, `tower::oneshot`) asserting index / design tokens / all-10-panels are served, the API coexists, and an empty `--ui-dir` disables the mount. **JS test + benchmark suite (`ui/`, runs under plain `node`, no npm install): 24 checks / 0 failed** — an import/export graph verifier (15 modules consistent), a DOM-shim render-smoke that *executes every panel* (21 checks: ui helpers + mock contracts + all 10 panels render without throwing), and an interaction suite (3 checks: live WS state-patch, ws.js handshake/parse, calibration backend contract). **Benchmark:** total bundle **136.8 KB uncompressed across 18 files — ~37× smaller than HA's ~5 MB Lit bundle** (the ADR-126 §1.1 foil), slowest panel **1.5 ms/cold-render**. **Honest scope (§7.1):** the live HOMECORE REST API (`/api/config|states|services`) and the WebSocket `subscribe_events` feed are driven for real; panels whose backing service is **not** in this binary (SEED HTTPS API, calibration ADR-151, ADR-105 federation) render against a **contract-conformant mock layer flagged with a DEMO banner** and swap to live the moment those endpoints land — no mock data is ever presented as real. **Not verified in this environment:** the Rust crate was edited and the integration tests written but **not compiled/run here** (no Rust toolchain present); `cargo test -p homecore-server` + `cargo build` must be run on a Rust host before merge. - **ADR-175: int8 quantization of the WiFlow-STD "half" pose model — MEASURED fp32-vs-int8 accuracy/size trade-off (honest negative).** Sub-deliverable 8.2 of the benchmark/optimization milestone, and the reading of the SOTA brief's "one untested edge lever" (QAT-int8 on the 843,834-param half model that strictly dominates the published 2.23M model). A new committed script `v2/crates/wifi-densepose-train/scripts/quantize_half_int8.py` quantizes `half_best.pth` to int8 two ways and scores both with the **same** upstream `calculate_pck`/`calculate_mpjpe` that produced the fp32 sweep numbers, under **one locked normalization** (ADR-173 torso-diameter PCK — neck idx2→pelvis idx12, `use_torso_norm=True`, the standard MM-Fi/GraphPose-Fi convention), on the **same** seed-42 file-level 70/15/15 test split (52,560 NaN-free / 54,000 full windows). **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 (fp32-CPU reproduces fp32-GPU to 4 dp, so the int8 deltas are pure quantization, not CPU/GPU drift); **int8 static PTQ = 40.98% PCK@20 (−55.64 pp), 1.046 MB** — naive static QDQ **collapses** on this model (the brief's 2.23M "sweet spot" does NOT transfer to the 843k half model at the tight @20 threshold); **int8 QAT (3-epoch FX fake-quant fine-tune from half_best) = 67.48% PCK@20 (−29.15 pp) / 98.69% PCK@50 (−0.78 pp), 1.043 MB.** **Verdict (honest no):** int8 is **not a win** at the strict PCK@20 edge target — QAT recovers a large share of the PTQ collapse and is near-lossless at the loose PCK@50 (coarse localization survives int8, fine does not), but a **3.2× size win at −29 pp PCK@20** is a bad trade when the half model already fits edge flash at fp32 → **keep fp32/fp16 on the edge for now.** **Disclosed gap:** the QAT *fake-quant* val PCK@20 reached 83.45% but the *converted* int8 model scores 67.48% — a real ~16 pp `convert_fx` gap (fbgemm int8 kernels ≠ straight-through estimate, esp. the axial-attention einsum/softmax); we report the converted-int8 number, not the fake-quant proxy. **MEASURED:** every table number + the PTQ collapse + the QAT partial recovery + the conversion gap. **CLAIMED/not done:** ONNX/TFLite export, on-edge-SoC latency/energy (int8 measured on x86 fbgemm — size transfers, latency does NOT), mixed-precision keeping attention fp32, longer/better-tuned QAT. **Honest limitations:** single in-domain eval split (no cross-environment split), x86-int8 not edge-SoC-int8, lightly-tuned QAT. Additive only — no production Rust or signal-pipeline change; Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — off the signal proof path). - **Metric-locked PCK/MPJPE accuracy harness — resolves the PCK-definition ambiguity (`wifi-densepose-train`, needs ADR slot 173).** The SOTA brief (`docs/research/sota-nn-train-benchmark-brief.md` §1, §3.1, §4) found the single biggest threat to any "beyond-SOTA" claim is **metric ambiguity**: three PCK@20 figures (96.09% WiFlow-STD image-normalized, 81.63% AetherArena torso-PCK, 61.1% GraphPose-Fi standard PCK) cannot be lined up because each silently uses a different normalization — the project was retracted twice over this (a withdrawn "92.9%" used *absolute* pixels, not torso). New `src/accuracy.rs` makes the normalizer **explicit, selectable, and carried with every reported number**: a `PckNormalization` enum (`TorsoDiameter` = standard MM-Fi/GraphPose-Fi hip↔hip; `BoundingBoxDiagonal` = looser WiFlow-STD image-normalized; `AbsolutePixels(threshold)` = the retracted convention, included so historical numbers are reproducible and clearly labeled non-comparable); one canonical `pck_at(pred, gt, vis, k, normalization)` reusing the `metrics_core` geometric primitives (hip distance, bbox diagonal — no duplicate kernel); `mpjpe(pred, gt, vis)` (2D/3D, mm); and a self-describing `PoseAccuracy { pck_at: BTreeMap, mpjpe, normalization, n_keypoints, n_frames }` returned by `accuracy_report(frames, ks, normalization)` so an **unlabeled PCK number is structurally impossible**. **17 hand-computed deterministic tests** (no GPU, no datasets) prove the harness arithmetic: perfect→PCK=1.0/MPJPE=0; all-just-outside→0.0; half-in-half-out→0.5; the **key proof** that identical predictions score 0.50 (torso) / 1.00 (bbox) / 0.75 (abs) under the three normalizations (the ambiguity is real and the definitions are distinct); MPJPE 2D/3D fixtures; and graceful degenerate handling (zero torso, empty frames, NaN coords — no panic, never a false-perfect). **This is measurement infrastructure, not an accuracy claim** — the tests prove the harness is correct, not that any model is good. `wifi-densepose-train` lib 191→206, `test_metrics` 12→14, 0 failed. Python deterministic proof unchanged (off the signal proof path). - **CI bench-regression guard (`.github/workflows/bench-regression.yml`) — wires the v2/ criterion benches into CI as a real, hard-failing COMPILE-VERIFY gate + an informational fast-run; caught + fixed one already-bit-rotted bench (benchmark/optimization milestone sub-deliverable 8.3; needs ADR slot 174).** 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,aether_prefilter,cir}_bench`, `wifi-densepose-mat/detection_bench`, `wifi-densepose-nn/{inference,native_conv,onnx}_bench`, `wifi-densepose-engine/engine_cycle`, …) but, because benches are **not** part of `cargo test`, nothing in CI compiled them — so they silently rot when a public API they call changes. **Proof this matters (MEASURED):** running the new gate on the current tree immediately caught `wifi-densepose-mat/detection_bench` failing to compile (`E0063: missing field last_rssi in initializer of SensorPosition` — the struct gained a field, the bench was never updated); fixed in this 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`, Executable produced). **HONEST SCOPE — what gates vs what is informational:** (1) `bench-compile` (HARD GATE) runs `cargo bench --workspace --no-default-features --no-run` (compile + link every default-feature bench, no measurement) plus a `--features cir` compile of the gated `cir_bench` — a deterministic, real regression guard against bench bit-rot; (2) `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 (1s warm-up / 2s measure / 10 samples), targeted per-`--bench` (the crates' libtest lib targets reject criterion flags), and uploads the logs as an artifact. **No timing-regression gate, by design and stated in the workflow header:** wall-clock on shared GitHub runners varies 2-3x run-to-run, so a hard threshold or a cross-runner `criterion --baseline` compare would manufacture false failures; that becomes honest only on a frequency-pinned self-hosted runner (documented as the re-add condition). The `crv`-gated `ruvector/crv_bench` is deliberately NOT compiled by the gate because its crates.io dep `ruvector-crv 0.1.1` currently fails to build on stable (upstream E0308 in its own `stage_iii.rs`) — noted in-workflow with the re-add condition. Checkout is `submodules: recursive` (the workspace path-deps `vendor/rufield`) and installs the Tauri/GTK dev libs like `ci.yml`'s rust-tests job (a `--workspace` bench link pulls the whole graph). **MEASURED locally (Windows, `--no-default-features`):** `nvsim`, `wifi-densepose-ruvector` (sketch/fusion/ann), `wifi-densepose-signal/cir_bench`, `wifi-densepose-mat/detection_bench` (post-fix), `wifi-densepose-vitals/vitals_bench`, and `ruview-swarm/swarm_bench` all compile + the fast subset runs (sample baseline: `nvsim pipeline_run/d1/256` ≈ 55 µs, `d16/1024` ≈ 315 µs; `ruvector sketch_hamming` ≈ 3-7 ns vs `float_l2` ≈ 63-371 ns). The full `--workspace` `--no-run` could **not** be fully validated on Windows (Tauri-`desktop` needs GTK, `candle-core` fails on MSVC, `swarm_bench` LTO-links OOM under parallel pressure) — those are Windows-env artifacts that build in the Linux CI runner (each affected bench was confirmed to compile standalone here). No baseline JSON is committed (a cross-runner baseline would be dishonest). Python deterministic proof unchanged (`f8e76f21…46f7a`, bit-exact — off the signal proof path). diff --git a/docs/adr/ADR-131-homecore-ui-operational-dashboard.md b/docs/adr/ADR-131-homecore-ui-operational-dashboard.md new file mode 100644 index 00000000..3e73efb9 --- /dev/null +++ b/docs/adr/ADR-131-homecore-ui-operational-dashboard.md @@ -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: 4–6px`, 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.0–1.0, from Stoer-Wagner min-cut on the kNN graph) rendered as a progress bar: green below 0.3, amber 0.3–0.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>`) 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=` 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 6–30), heart rate BPM (numeric in `--cyan` with range indicator 40–120), restlessness score (0–1 progress bar), and anomaly score (0–1 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 1–5 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 X–Y 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` | `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 W1–W6 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. diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 75d85a5f..8c094cf6 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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]] diff --git a/v2/crates/homecore-api/src/app.rs b/v2/crates/homecore-api/src/app.rs index 210cab06..9863d2f2 100644 --- a/v2/crates/homecore-api/src/app.rs +++ b/v2/crates/homecore-api/src/app.rs @@ -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 = match raw { Some(v) if !v.trim().is_empty() => v diff --git a/v2/crates/homecore-api/src/lib.rs b/v2/crates/homecore-api/src/lib.rs index d83d84a8..71a2098d 100644 --- a/v2/crates/homecore-api/src/lib.rs +++ b/v2/crates/homecore-api/src/lib.rs @@ -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; diff --git a/v2/crates/homecore-server/Cargo.toml b/v2/crates/homecore-server/Cargo.toml index 428fde4f..9ea8c809 100644 --- a/v2/crates/homecore-server/Cargo.toml +++ b/v2/crates/homecore-server/Cargo.toml @@ -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 = [] diff --git a/v2/crates/homecore-server/README.md b/v2/crates/homecore-server/README.md index 8034a80a..79ddba33 100644 --- a/v2/crates/homecore-server/README.md +++ b/v2/crates/homecore-server/README.md @@ -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` | `/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 diff --git a/v2/crates/homecore-server/src/gateway.rs b/v2/crates/homecore-server/src/gateway.rs new file mode 100644 index 00000000..5d67fad6 --- /dev/null +++ b/v2/crates/homecore-server/src/gateway.rs @@ -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, + /// Bearer token for the calibration service (held server-side only). + pub calibration_token: Option, + /// 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, +} + +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 { + let bytes = s.as_bytes(); + let mut out: Vec = 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, 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, headers: HeaderMap) -> Response { + if let Err(r) = require_auth(&headers, &st).await { + return r; + } + Json(Vec::::new()).into_response() +} + +// ── calibration reverse-proxy (W2) ────────────────────────────────── + +async fn cal_proxy_get( + State(st): State, + headers: HeaderMap, + Path(path): Path, + 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/` → `/api/`. + 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, + headers: HeaderMap, + Path(path): Path, + 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 { + 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::() + .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, 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 = 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 { + 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 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, headers: HeaderMap) -> Response { + if let Err(r) = require_auth(&headers, &st).await { + return r; + } + let mut out: Vec = 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 { + 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::() { + 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, 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 = 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 { + std::fs::read_to_string(path).ok().and_then(|s| s.lines().next().map(str::to_string)) +} + +fn uptime_secs() -> Option { + read_first_line("/proc/uptime") + .and_then(|l| l.split_whitespace().next().map(str::to_string)) + .and_then(|s| s.parse::().ok()) + .map(|f| f as u64) +} + +fn mem_used_pct() -> Option { + 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 { + // loadavg(1m) / ncpu * 100 — a cheap proxy (no two-sample /proc/stat). + let load = read_first_line("/proc/loadavg")? + .split_whitespace() + .next()? + .parse::() + .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"]); + } +} diff --git a/v2/crates/homecore-server/src/main.rs b/v2/crates/homecore-server/src/main.rs index 383e8ddf..9f5e3255 100644 --- a/v2/crates/homecore-server/src/main.rs +++ b/v2/crates/homecore-server/src/main.rs @@ -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, + + /// 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, + + /// 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 / + + diff --git a/v2/crates/homecore-server/ui/js/api.js b/v2/crates/homecore-server/ui/js/api.js new file mode 100644 index 00000000..e53131d0 --- /dev/null +++ b/v2/crates/homecore-server/ui/js/api.js @@ -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; }, + }; +} diff --git a/v2/crates/homecore-server/ui/js/app.js b/v2/crates/homecore-server/ui/js/app.js new file mode 100644 index 00000000..17f2c8f2 --- /dev/null +++ b/v2/crates/homecore-server/ui/js/app.js @@ -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 }; diff --git a/v2/crates/homecore-server/ui/js/mock.js b/v2/crates/homecore-server/ui/js/mock.js new file mode 100644 index 00000000..cb1add5b --- /dev/null +++ b/v2/crates/homecore-server/ui/js/mock.js @@ -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' }, + ], + }; +} diff --git a/v2/crates/homecore-server/ui/js/panels/audit.js b/v2/crates/homecore-server/ui/js/panels/audit.js new file mode 100644 index 00000000..88ff5bb9 --- /dev/null +++ b/v2/crates/homecore-server/ui/js/panels/audit.js @@ -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, + ], + }); +} diff --git a/v2/crates/homecore-server/ui/js/panels/calibration.js b/v2/crates/homecore-server/ui/js/panels/calibration.js new file mode 100644 index 00000000..6212f74e --- /dev/null +++ b/v2/crates/homecore-server/ui/js/panels/calibration.js @@ -0,0 +1,256 @@ +// §4.7 Calibration Wizard — baseline → enroll → train → verify. +// Stepped wizard (1–5) 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_- , 1–64)', 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; +} diff --git a/v2/crates/homecore-server/ui/js/panels/cogs.js b/v2/crates/homecore-server/ui/js/panels/cogs.js new file mode 100644 index 00000000..3b889d2a --- /dev/null +++ b/v2/crates/homecore-server/ui/js/panels/cogs.js @@ -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] }); +} diff --git a/v2/crates/homecore-server/ui/js/panels/dashboard.js b/v2/crates/homecore-server/ui/js/panels/dashboard.js new file mode 100644 index 00000000..d0782177 --- /dev/null +++ b/v2/crates/homecore-server/ui/js/panels/dashboard.js @@ -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'; +} diff --git a/v2/crates/homecore-server/ui/js/panels/entities.js b/v2/crates/homecore-server/ui/js/panels/entities.js new file mode 100644 index 00000000..4c895e2a --- /dev/null +++ b/v2/crates/homecore-server/ui/js/panels/entities.js @@ -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; +} diff --git a/v2/crates/homecore-server/ui/js/panels/events.js b/v2/crates/homecore-server/ui/js/panels/events.js new file mode 100644 index 00000000..be4ffcbe --- /dev/null +++ b/v2/crates/homecore-server/ui/js/panels/events.js @@ -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); +} diff --git a/v2/crates/homecore-server/ui/js/panels/fleet.js b/v2/crates/homecore-server/ui/js/panels/fleet.js new file mode 100644 index 00000000..1476622c --- /dev/null +++ b/v2/crates/homecore-server/ui/js/panels/fleet.js @@ -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; +} diff --git a/v2/crates/homecore-server/ui/js/panels/rooms.js b/v2/crates/homecore-server/ui/js/panels/rooms.js new file mode 100644 index 00000000..7dbff403 --- /dev/null +++ b/v2/crates/homecore-server/ui/js/panels/rooms.js @@ -0,0 +1,119 @@ +// §4.5 RoomState / Sensing Panel — mixture-of-specialists output. +// Per-room cards from GET /api/v1/room/state?bank=. +// +// 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'); +} diff --git a/v2/crates/homecore-server/ui/js/panels/seed-detail.js b/v2/crates/homecore-server/ui/js/panels/seed-detail.js new file mode 100644 index 00000000..1ce38166 --- /dev/null +++ b/v2/crates/homecore-server/ui/js/panels/seed-detail.js @@ -0,0 +1,256 @@ +// §4.2 SEED Detail View — the per-device deep dive (route #/seed/). +// +// 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'))); +} diff --git a/v2/crates/homecore-server/ui/js/panels/settings.js b/v2/crates/homecore-server/ui/js/panels/settings.js new file mode 100644 index 00000000..facb4185 --- /dev/null +++ b/v2/crates/homecore-server/ui/js/panels/settings.js @@ -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'; +} diff --git a/v2/crates/homecore-server/ui/js/ui.js b/v2/crates/homecore-server/ui/js/ui.js new file mode 100644 index 00000000..592b0eaa --- /dev/null +++ b/v2/crates/homecore-server/ui/js/ui.js @@ -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' })); +} diff --git a/v2/crates/homecore-server/ui/js/ws.js b/v2/crates/homecore-server/ui/js/ws.js new file mode 100644 index 00000000..804caa78 --- /dev/null +++ b/v2/crates/homecore-server/ui/js/ws.js @@ -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; }, + }; +} diff --git a/v2/crates/homecore-server/ui/package.json b/v2/crates/homecore-server/ui/package.json new file mode 100644 index 00000000..9ced11be --- /dev/null +++ b/v2/crates/homecore-server/ui/package.json @@ -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" + } +} diff --git a/v2/crates/homecore-server/ui/tests/benchmark.mjs b/v2/crates/homecore-server/ui/tests/benchmark.mjs new file mode 100644 index 00000000..9ee9012d --- /dev/null +++ b/v2/crates/homecore-server/ui/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); diff --git a/v2/crates/homecore-server/ui/tests/boot.mjs b/v2/crates/homecore-server/ui/tests/boot.mjs new file mode 100644 index 00000000..41c077d6 --- /dev/null +++ b/v2/crates/homecore-server/ui/tests/boot.mjs @@ -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'); diff --git a/v2/crates/homecore-server/ui/tests/dom-shim.mjs b/v2/crates/homecore-server/ui/tests/dom-shim.mjs new file mode 100644 index 00000000..cc31d961 --- /dev/null +++ b/v2/crates/homecore-server/ui/tests/dom-shim.mjs @@ -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 }; diff --git a/v2/crates/homecore-server/ui/tests/interaction.mjs b/v2/crates/homecore-server/ui/tests/interaction.mjs new file mode 100644 index 00000000..a152c83c --- /dev/null +++ b/v2/crates/homecore-server/ui/tests/interaction.mjs @@ -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'); diff --git a/v2/crates/homecore-server/ui/tests/prod-errors.mjs b/v2/crates/homecore-server/ui/tests/prod-errors.mjs new file mode 100644 index 00000000..913287f7 --- /dev/null +++ b/v2/crates/homecore-server/ui/tests/prod-errors.mjs @@ -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'); diff --git a/v2/crates/homecore-server/ui/tests/render-smoke.mjs b/v2/crates/homecore-server/ui/tests/render-smoke.mjs new file mode 100644 index 00000000..cbe66916 --- /dev/null +++ b/v2/crates/homecore-server/ui/tests/render-smoke.mjs @@ -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'); diff --git a/v2/crates/homecore-server/ui/tests/unit-fixes.mjs b/v2/crates/homecore-server/ui/tests/unit-fixes.mjs new file mode 100644 index 00000000..28938b77 --- /dev/null +++ b/v2/crates/homecore-server/ui/tests/unit-fixes.mjs @@ -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'); diff --git a/v2/crates/homecore-server/ui/tests/verify-imports.mjs b/v2/crates/homecore-server/ui/tests/verify-imports.mjs new file mode 100644 index 00000000..3e43673d --- /dev/null +++ b/v2/crates/homecore-server/ui/tests/verify-imports.mjs @@ -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`);