feat(ADR-131): HOMECORE-UI operational dashboard + BFF gateway

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

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

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

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

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
Nick Ruest
2026-06-14 23:35:27 +00:00
parent db3d94a313
commit 075344b023
33 changed files with 5050 additions and 1 deletions
Generated
+10
View File
@@ -3602,8 +3602,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 +3772,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.4",
"tower-service",
"webpki-roots 1.0.7",
]
[[package]]
@@ -6870,6 +6876,8 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.37",
"rustls-pki-types",
"serde",
"serde_json",
@@ -6877,6 +6885,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 +6893,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.7",
]
[[package]]
+11
View File
@@ -37,6 +37,17 @@ 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.
tower-http = { version = "0.6", features = ["fs", "trace"] }
# BFF gateway (ADR-131 §11): reverse-proxy the calibration API + aggregate
# upstreams. rustls to avoid an OpenSSL system dep on the appliance.
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
# Drive the assembled router in integration tests via ServiceExt::oneshot.
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
[features]
default = []
+23
View File
@@ -116,6 +116,29 @@ export RUST_LOG="homecore=debug,homecore_api=info"
| `--db` | `HOMECORE_DB` | `sqlite::memory:` | SQLite path (`:memory:` for ephemeral) |
| `--location-name` | `HOMECORE_LOCATION` | `Home` | Friendly name returned by `/api/config` |
| `--no-recorder` | — | off | Disable SQLite recorder (low-resource deployments) |
| `--ui-dir` | `HOMECORE_UI_DIR` | `<crate>/ui` | HOMECORE-UI asset dir served at `/homecore` (ADR-131); empty disables the mount |
## HOMECORE-UI dashboard (ADR-131)
This binary also serves the **HOMECORE-UI** — the complete operational dashboard
for the two-tier Cognitum stack (v0 Appliance → SEEDs → ESP32 nodes) — at
`/homecore`, alongside the HA-compat `/api` surface. It is a zero-dependency,
no-build-step vanilla TS/JS + CSS frontend living in `ui/`:
```bash
cargo run -p homecore-server # then open http://localhost:8123/homecore/
```
It drives the live `/api` + `/api/websocket` (`subscribe_events`) endpoints; panels
backed by services not in this binary (SEED HTTPS API, calibration ADR-151,
federation ADR-105) render against a DEMO-flagged contract-conformant mock until
those endpoints land (ADR-131 §7.1). Frontend tests + benchmark run under plain
`node` (no `npm install`):
```bash
cd ui && npm test # import graph + render-smoke + interaction (24 checks)
cd ui && npm run bench # bundle budget (~137 KB, ~37× smaller than HA) + render timing
```
## Comparison to Home Assistant
+577
View File
@@ -0,0 +1,577 @@
//! HOMECORE-UI backend-for-frontend (BFF) gateway — ADR-131 §11.
//!
//! `homecore-server` is the single origin the dashboard talks to (§2.1).
//! This module adds the `/api/homecore/*` aggregation namespace and the
//! `/api/cal/*` reverse-proxy to the calibration service, so the browser
//! never makes a cross-origin call and never holds an upstream credential.
//!
//! Implemented now (self-contained, no new external service):
//! * `/api/cal/*` — reverse-proxy → calibration API (ADR-151) [W2]
//! * `GET /api/homecore/rooms` — per-room RoomState, adapted to the UI shape [W2]
//! * `GET /api/homecore/cogs` — COG supervisor over the apps dir [W4]
//! * `GET /api/homecore/appliance` — host metrics from /proc + port probes [W6]
//!
//! Returns a typed `503 upstream_unavailable` for routes whose upstream is
//! a SEED device / appliance daemon not present in this repo (§11.2 / §12):
//! seeds, federation, witness, privacy, settings, automations, events
//! history, hailo, tokens. The front-end renders these as error states
//! (it never falls back to mock in production — §2.2).
//!
//! NOTE: written against the real crate APIs but NOT yet compiled in the
//! authoring environment (no Rust toolchain); run `cargo test -p
//! homecore-server` on a Rust host.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::body::Bytes;
use axum::extract::{Path, RawQuery, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use serde_json::{json, Value};
use homecore_api::auth::BearerAuth;
use homecore_api::SharedState;
/// Static gateway configuration (from CLI/env in `main`).
pub struct GatewayConfig {
/// Base URL of the calibration service (`wifi-densepose calibrate-serve`),
/// e.g. `http://127.0.0.1:8090`. `None` disables the calibration routes.
pub calibration_url: Option<String>,
/// Bearer token for the calibration service (held server-side only).
pub calibration_token: Option<String>,
/// COG install directory the supervisor reads (`/var/lib/cognitum/apps`).
pub apps_dir: PathBuf,
/// Per-proxy timeout so one slow upstream cannot stall the dashboard.
pub timeout: Duration,
}
#[derive(Clone)]
pub struct GatewayState {
pub shared: SharedState,
pub http: reqwest::Client,
pub cfg: Arc<GatewayConfig>,
}
impl GatewayState {
pub fn new(shared: SharedState, cfg: GatewayConfig) -> Self {
let http = reqwest::Client::builder()
.timeout(cfg.timeout)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self { shared, http, cfg: Arc::new(cfg) }
}
}
/// Build the gateway router (state already applied → `Router<()>`), ready
/// to `.merge()` into the main app alongside the homecore-api routes.
pub fn gateway_router(state: GatewayState) -> Router {
Router::new()
// ── calibration reverse-proxy (W2) ──────────────────────────
.route("/api/cal/*path", get(cal_proxy_get).post(cal_proxy_post))
// ── aggregation endpoints (W2 / W4 / W6) ────────────────────
.route("/api/homecore/rooms", get(rooms))
.route("/api/homecore/cogs", get(cogs_list))
.route("/api/homecore/appliance", get(appliance))
// ── upstream-dependent stubs (W3 / W5 / W6): typed 503 ───────
.route("/api/homecore/seeds", get(stub_503))
.route("/api/homecore/seeds/:id", get(stub_503))
.route("/api/homecore/federation", get(stub_503))
.route("/api/homecore/witness", get(stub_503))
.route("/api/homecore/privacy", get(stub_503).post(stub_503))
.route("/api/homecore/settings", get(stub_503))
.route("/api/homecore/automations", get(stub_503).post(stub_503))
// No OTA feed wired yet → "no updates available" is an empty list,
// not an error (so a working COG list is never blanked).
.route("/api/homecore/cogs/updates", get(empty_list))
.route("/api/homecore/hailo", get(stub_503))
.route("/api/homecore/tokens", get(stub_503))
.route("/api/events", get(stub_503))
.with_state(state)
}
// ── auth + typed errors ─────────────────────────────────────────────
async fn require_auth(headers: &HeaderMap, st: &GatewayState) -> Result<(), Response> {
BearerAuth::from_headers(headers, st.shared.tokens())
.await
.map(|_| ())
.map_err(|e| e.into_response())
}
fn typed(status: StatusCode, error: &str, detail: &str) -> Response {
(status, Json(json!({ "error": error, "detail": detail }))).into_response()
}
fn upstream_unavailable(detail: &str) -> Response {
typed(StatusCode::SERVICE_UNAVAILABLE, "upstream_unavailable", detail)
}
fn upstream_timeout(detail: &str) -> Response {
typed(StatusCode::GATEWAY_TIMEOUT, "upstream_timeout", detail)
}
/// Routes whose upstream is a SEED device / appliance daemon not present
/// in this repo. Honest 503 until the corresponding §12 wave lands.
async fn stub_503(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
upstream_unavailable("endpoint not yet wired — see ADR-131 §11/§12 (SEED device / appliance upstream)")
}
/// Auth-gated empty-array response (e.g. OTA updates with no feed wired).
async fn empty_list(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
Json(Vec::<Value>::new()).into_response()
}
// ── calibration reverse-proxy (W2) ──────────────────────────────────
async fn cal_proxy_get(
State(st): State<GatewayState>,
headers: HeaderMap,
Path(path): Path<String>,
RawQuery(q): RawQuery,
) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
let base = match &st.cfg.calibration_url {
Some(u) => u,
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
};
let qs = q.map(|s| format!("?{s}")).unwrap_or_default();
// The wildcard already carries the `v1/...` segment (the UI calls
// `/api/cal/v1/...`), so map `/api/cal/<rest>` → `<base>/api/<rest>`.
let url = format!("{}/api/{}{}", base.trim_end_matches('/'), path, qs);
proxy(&st, st.http.get(&url)).await
}
async fn cal_proxy_post(
State(st): State<GatewayState>,
headers: HeaderMap,
Path(path): Path<String>,
body: Bytes,
) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
let base = match &st.cfg.calibration_url {
Some(u) => u,
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
};
let url = format!("{}/api/{}", base.trim_end_matches('/'), path);
let rb = st
.http
.post(&url)
.header(header::CONTENT_TYPE, "application/json")
.body(body);
proxy(&st, rb).await
}
/// Send an upstream request (with the server-side calibration token) and
/// stream the response back verbatim, mapping transport failures to typed
/// errors.
async fn proxy(st: &GatewayState, mut rb: reqwest::RequestBuilder) -> Response {
if let Some(tok) = &st.cfg.calibration_token {
rb = rb.bearer_auth(tok);
}
match rb.send().await {
Ok(resp) => {
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let ct = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/json")
.to_string();
match resp.bytes().await {
Ok(b) => {
let mut out = Response::new(axum::body::Body::from(b));
*out.status_mut() = status;
if let Ok(hv) = HeaderValue::from_str(&ct) {
out.headers_mut().insert(header::CONTENT_TYPE, hv);
}
out
}
Err(e) => upstream_unavailable(&format!("calibration body read failed: {e}")),
}
}
Err(e) if e.is_timeout() => upstream_timeout("calibration service timed out"),
Err(e) => upstream_unavailable(&format!("calibration service: {e}")),
}
}
async fn fetch_json(st: &GatewayState, url: &str) -> Result<Value, Response> {
let mut rb = st.http.get(url);
if let Some(tok) = &st.cfg.calibration_token {
rb = rb.bearer_auth(tok);
}
match rb.send().await {
Ok(resp) => resp
.json::<Value>()
.await
.map_err(|e| upstream_unavailable(&format!("calibration JSON parse: {e}"))),
Err(e) if e.is_timeout() => Err(upstream_timeout("calibration service timed out")),
Err(e) => Err(upstream_unavailable(&format!("calibration service: {e}"))),
}
}
// ── rooms aggregation + RoomState adapter (W2 / §11.3) ──────────────
async fn rooms(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
let base = match &st.cfg.calibration_url {
Some(u) => u.trim_end_matches('/').to_string(),
None => return upstream_unavailable("calibration service not configured"),
};
let banks = match fetch_json(&st, &format!("{base}/api/v1/calibration/baselines")).await {
Ok(v) => bank_names(&v),
Err(r) => return r,
};
let mut out: Vec<Value> = Vec::new();
for bank in banks {
let url = format!("{base}/api/v1/room/state?bank={bank}");
if let Ok(v) = fetch_json(&st, &url).await {
out.push(adapt_room_state(&bank, &v));
}
}
Json(out).into_response()
}
/// Accept either `["living_room", ...]` or `[{ "name"|"id"|"bank": ... }]`.
fn bank_names(v: &Value) -> Vec<String> {
match v {
Value::Array(items) => items
.iter()
.filter_map(|it| match it {
Value::String(s) => Some(s.clone()),
Value::Object(o) => o
.get("name")
.or_else(|| o.get("id"))
.or_else(|| o.get("bank"))
.and_then(|x| x.as_str())
.map(str::to_string),
_ => None,
})
.collect(),
Value::Object(o) => o
.get("baselines")
.map(|b| bank_names(b))
.unwrap_or_default(),
_ => Vec::new(),
}
}
/// Adapt the calibration `RoomState` (Option<SpecialistReading> fields +
/// `vetoed`/`stale`) onto the UI shape (§11.3). `None` → JSON `null`,
/// preserving the not-trained-vs-withheld distinction (§6 invariant 3).
fn adapt_room_state(bank: &str, v: &Value) -> Value {
let chip = |k: &str| -> Value {
match v.get(k) {
Some(r) if !r.is_null() => json!({
"value": r.get("label").and_then(|l| l.as_str()).map(Value::from)
.unwrap_or_else(|| r.get("value").cloned().unwrap_or(Value::Null)),
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
}),
_ => Value::Null,
}
};
let bpm = |k: &str| -> Value {
match v.get(k) {
Some(r) if !r.is_null() => json!({
"value": r.get("value").cloned().unwrap_or(Value::Null),
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
}),
_ => Value::Null,
}
};
let anomaly = match v.get("anomaly") {
Some(r) if !r.is_null() => json!({
"value": r.get("value").cloned().unwrap_or(Value::Null),
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
"threshold": 0.5,
}),
_ => Value::Null,
};
json!({
"room_id": bank,
"seeds": [],
"stale": v.get("stale").and_then(|b| b.as_bool()).unwrap_or(false),
"vetoed": v.get("vetoed").and_then(|b| b.as_bool()).unwrap_or(false),
"presence": chip("presence"),
"posture": chip("posture"),
"breathing_bpm": bpm("breathing"),
"heart_bpm": bpm("heartbeat"),
"restlessness": bpm("restlessness"),
"anomaly": anomaly,
})
}
// ── COG supervisor (W4 / §11.6) ─────────────────────────────────────
async fn cogs_list(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
let mut out: Vec<Value> = Vec::new();
let rd = match std::fs::read_dir(&st.cfg.apps_dir) {
Ok(rd) => rd,
Err(_) => return Json(out).into_response(), // no apps dir yet → empty
};
for entry in rd.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let manifest = match std::fs::read_to_string(dir.join("manifest.json")) {
Ok(s) => s,
Err(_) => continue,
};
let m: Value = match serde_json::from_str(&manifest) {
Ok(v) => v,
Err(_) => continue,
};
let id = m
.get("id")
.and_then(|x| x.as_str())
.unwrap_or_else(|| dir.file_name().and_then(|n| n.to_str()).unwrap_or("?"))
.to_string();
let pid = read_pid(&dir, &id);
let alive = pid.map(pid_alive).unwrap_or(false);
let status = if alive { "running" } else { "stopped" };
out.push(json!({
"id": id,
"version": m.get("version").and_then(|x| x.as_str()).unwrap_or("?"),
"arch": m.get("arch").and_then(|x| x.as_str()).unwrap_or("arm"),
"status": status,
"pid": pid,
"sha256_verified": m.get("binary_sha256").is_some(),
"signature_verified": m.get("binary_signature").is_some(),
"hef": m.get("hef").cloned().unwrap_or(Value::Null),
}));
}
Json(out).into_response()
}
fn read_pid(dir: &std::path::Path, id: &str) -> Option<i64> {
for name in [format!("{id}.pid"), "pid".to_string(), "app.pid".to_string()] {
if let Ok(s) = std::fs::read_to_string(dir.join(&name)) {
if let Ok(p) = s.trim().parse::<i64>() {
return Some(p);
}
}
}
None
}
fn pid_alive(pid: i64) -> bool {
if pid <= 0 {
return false;
}
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
// ── appliance metrics (W6 / §11.5) ──────────────────────────────────
async fn appliance(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
let ram = mem_used_pct();
let cpu = cpu_load_pct();
let uptime = uptime_secs();
let services: Vec<Value> = [
("ruview-mcp-brain", 9876u16),
("cognitum-rvf-agent", 9004),
("ruvector-hailo-worker", 50051),
]
.iter()
.map(|(name, port)| {
let up = tcp_open("127.0.0.1", *port, st.cfg.timeout);
json!({ "name": name, "port": port, "status": if up { "running" } else { "unreachable" } })
})
.collect();
Json(json!({
"cpu_pct": cpu,
"ram_pct": ram,
"hailo_load_pct": Value::Null, // requires the Hailo runtime stat source (§11.5 APPLIANCE)
"hailo_temp_c": Value::Null,
"uptime_s": uptime,
"services": services,
"event_rate": [],
"channel_capacity": 4096,
"channel_lag": 0,
}))
.into_response()
}
fn read_first_line(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok().and_then(|s| s.lines().next().map(str::to_string))
}
fn uptime_secs() -> Option<u64> {
read_first_line("/proc/uptime")
.and_then(|l| l.split_whitespace().next().map(str::to_string))
.and_then(|s| s.parse::<f64>().ok())
.map(|f| f as u64)
}
fn mem_used_pct() -> Option<f64> {
let txt = std::fs::read_to_string("/proc/meminfo").ok()?;
let mut total = 0f64;
let mut avail = 0f64;
for line in txt.lines() {
let mut it = line.split_whitespace();
match it.next() {
Some("MemTotal:") => total = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0),
Some("MemAvailable:") => avail = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0),
_ => {}
}
}
if total > 0.0 {
Some(((total - avail) / total * 100.0 * 10.0).round() / 10.0)
} else {
None
}
}
fn cpu_load_pct() -> Option<f64> {
// loadavg(1m) / ncpu * 100 — a cheap proxy (no two-sample /proc/stat).
let load = read_first_line("/proc/loadavg")?
.split_whitespace()
.next()?
.parse::<f64>()
.ok()?;
let ncpu = std::thread::available_parallelism().map(|n| n.get() as f64).unwrap_or(1.0);
Some(((load / ncpu * 100.0).min(100.0) * 10.0).round() / 10.0)
}
fn tcp_open(host: &str, port: u16, timeout: Duration) -> bool {
use std::net::ToSocketAddrs;
let addr = match (host, port).to_socket_addrs().ok().and_then(|mut a| a.next()) {
Some(a) => a,
None => return false,
};
std::net::TcpStream::connect_timeout(&addr, timeout).is_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)");
assert_eq!(ui["anomaly"]["threshold"], 0.5);
}
#[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"]);
}
}
+159 -1
View File
@@ -35,6 +35,17 @@ use homecore_hap::{bridge::HapBridge, mdns::HapServiceRecord};
use homecore_plugins::{InProcessRuntime, PluginRegistry};
use homecore_recorder::Recorder;
use axum::Router;
use tower_http::services::ServeDir;
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 +53,30 @@ struct Cli {
#[arg(long, env = "HOMECORE_BIND", default_value = "0.0.0.0:8123")]
bind: SocketAddr,
/// Directory of the HOMECORE-UI dashboard assets, served at
/// `/homecore` (ADR-131). Empty string disables the UI mount.
#[arg(long, env = "HOMECORE_UI_DIR", default_value = DEFAULT_UI_DIR)]
ui_dir: String,
/// Base URL of the calibration service (`wifi-densepose calibrate-serve`),
/// reverse-proxied by the BFF gateway at `/api/cal/*` (ADR-131 §11).
/// Unset → calibration/room endpoints return a typed 503.
#[arg(long, env = "HOMECORE_CALIBRATION_URL")]
calibration_url: Option<String>,
/// Bearer token for the calibration service (held server-side only,
/// never exposed to the browser — ADR-131 §11.10).
#[arg(long, env = "HOMECORE_CALIBRATION_TOKEN")]
calibration_token: Option<String>,
/// COG install directory the gateway's supervisor reads (ADR-131 §11.6).
#[arg(long, env = "HOMECORE_APPS_DIR", default_value = "/var/lib/cognitum/apps")]
apps_dir: String,
/// Per-upstream proxy timeout in milliseconds (ADR-131 §11.1).
#[arg(long, env = "HOMECORE_GATEWAY_TIMEOUT_MS", default_value_t = 2000)]
gateway_timeout_ms: u64,
/// SQLite recorder DB path. Use `:memory:` for an ephemeral run.
#[arg(long, env = "HOMECORE_DB", default_value = "sqlite::memory:")]
db: String,
@@ -174,15 +209,50 @@ 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),
},
);
let app = build_app(api_state, &cli.ui_dir).merge(gateway::gateway_router(gw));
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
info!("HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind);
info!(
"HOMECORE BFF gateway active: /api/homecore/* + /api/cal/* (calibration_url={:?})",
cli.calibration_url
);
if !cli.ui_dir.trim().is_empty() {
info!("HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}", cli.bind, cli.ui_dir);
} else {
info!("HOMECORE-UI mount disabled (--ui-dir empty)");
}
// Run forever (until SIGINT). axum::serve handles graceful shutdown.
axum::serve(listener, app).await?;
Ok(())
}
/// Assemble the full HTTP surface: the HA-compat REST + WS router
/// (ADR-130) plus the HOMECORE-UI static mount at `/homecore` (ADR-131).
/// Split out from `main` so it is exercised by the integration tests.
fn build_app(api_state: SharedState, ui_dir: &str) -> Router {
let app = router(api_state);
if ui_dir.trim().is_empty() {
return app;
}
// ServeDir serves index.html for the directory root, so /homecore/
// returns the dashboard and /homecore/js/... /homecore/css/... map
// straight onto the asset tree the relative <link>/<script> tags use.
app.nest_service("/homecore", ServeDir::new(ui_dir))
}
fn init_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
@@ -304,3 +374,91 @@ fn seed_default_entities(hc: &HomeCore) {
info!("State machine seeded with {} default entit{}", total,
if total == 1 { "y" } else { "ies" });
}
#[cfg(test)]
mod ui_tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use homecore::HomeCore;
use homecore_api::{LongLivedTokenStore, SharedState};
use tower::ServiceExt; // for `oneshot`
fn test_state() -> SharedState {
SharedState::with_tokens(
HomeCore::new(),
"Test".to_string(),
"test",
LongLivedTokenStore::allow_any_non_empty(),
)
}
async fn get(app: Router, path: &str) -> (StatusCode, String) {
let resp = app
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 4 * 1024 * 1024)
.await
.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
#[tokio::test]
async fn ui_index_is_served_at_homecore() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
let (status, body) = get(app, "/homecore/").await;
assert_eq!(status, StatusCode::OK, "GET /homecore/ should serve index.html");
assert!(body.contains("HOMECORE"), "index.html should mention HOMECORE");
assert!(body.contains("./js/app.js"), "index.html should bootstrap app.js");
}
#[tokio::test]
async fn ui_design_tokens_are_served() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
let (status, body) = get(app, "/homecore/css/tokens.css").await;
assert_eq!(status, StatusCode::OK);
// §3.1 invariant: the exact production palette must be present.
assert!(body.contains("#4ecdc4"), "--cyan token must be present");
assert!(body.contains("--purple"), "--purple token must be present");
}
#[tokio::test]
async fn ui_panels_are_served() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
for p in ["dashboard", "rooms", "calibration", "fleet", "seed-detail",
"entities", "cogs", "events", "audit", "settings"] {
let (status, _) = get(app.clone(), &format!("/homecore/js/panels/{p}.js")).await;
assert_eq!(status, StatusCode::OK, "panel {p}.js should be served");
}
}
#[tokio::test]
async fn api_still_works_alongside_ui_mount() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
// `GET /api/` is auth-gated (HC-API-AUTH-01); send a bearer.
let resp = app
.oneshot(
Request::builder()
.uri("/api/")
.header("authorization", "Bearer dev")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert_eq!(status, StatusCode::OK, "the HA-compat API must coexist with the UI mount");
assert!(body.contains("API running"));
}
#[tokio::test]
async fn ui_mount_can_be_disabled() {
let app = build_app(test_state(), "");
let (status, _) = get(app, "/homecore/").await;
assert_eq!(status, StatusCode::NOT_FOUND, "empty --ui-dir disables the mount");
}
}
+223
View File
@@ -0,0 +1,223 @@
/*
* HOMECORE-UI component styling — ADR-131 §3.3.
* Uses only the §3.1 tokens (tokens.css). Polished composition: real
* header, icon sidenav, elevated cards, refined metrics/pills/bars.
*/
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background:
radial-gradient(1100px 600px at 78% -8%, rgba(78,205,196,0.06), transparent 60%),
radial-gradient(900px 500px at 12% 110%, rgba(167,139,250,0.05), transparent 55%),
var(--bg);
background-attachment: fixed;
color: var(--t1);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.mono { font-family: var(--mono); font-size: 0.92em; }
.t2 { color: var(--t2); } .t3 { color: var(--t3); }
.cyan { color: var(--cyan); } .green { color: var(--green); } .amber { color: var(--amber); }
.red { color: var(--red); } .purple { color: var(--purple); }
.hidden { display: none !important; }
/* ── top header ─────────────────────────────────────────────────── */
.topnav {
display: flex; align-items: center; gap: 16px;
background: rgba(17,22,39,0.85);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
padding: 0 22px; height: 60px;
position: sticky; top: 0; z-index: 30;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand .logo {
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 8px;
background: linear-gradient(135deg, var(--cyan), var(--purple));
color: var(--bg); font-weight: 800; font-size: 17px;
box-shadow: 0 2px 10px rgba(78,205,196,0.25);
}
.brand .brand-name { font-weight: 700; font-size: 16px; letter-spacing: 0.3px; color: var(--t1); }
.brand .brand-sep { color: var(--t3); font-size: 16px; font-weight: 300; }
.brand .brand-tag {
font-weight: 700; font-size: 12px; letter-spacing: 1px;
color: var(--cyan); background: var(--cyan-d);
border-radius: 6px; padding: 3px 9px; text-transform: uppercase;
}
.nav-spacer { flex: 1; }
/* ── layout ─────────────────────────────────────────────────────── */
.shell { display: flex; min-height: calc(100vh - 60px); }
.sidenav {
width: 224px; flex-shrink: 0;
background: rgba(17,22,39,0.45);
border-right: 1px solid var(--border);
padding: 16px 12px; display: flex; flex-direction: column; gap: 3px;
}
.sidenav a {
display: flex; align-items: center; gap: 11px;
padding: 9px 12px; border-radius: 9px;
color: var(--t2); text-decoration: none; font-size: 13.5px; font-weight: 500;
transition: background .12s, color .12s;
}
.sidenav a .ico { width: 18px; text-align: center; font-size: 14px; color: var(--t3); }
.sidenav a:hover { color: var(--t1); background: var(--card); }
.sidenav a.active { color: var(--cyan); background: var(--cyan-d); }
.sidenav a.active .ico { color: var(--cyan); }
.content { flex: 1; padding: 26px 30px; max-width: 1320px; width: 100%; }
@media (max-width: 880px) {
.shell { flex-direction: column; }
.sidenav { width: 100%; flex-direction: row; overflow-x: auto; padding: 8px; gap: 6px; border-right: none; border-bottom: 1px solid var(--border); }
.sidenav a .lbl { white-space: nowrap; }
.content { padding: 18px; }
}
/* ── headings / section header ──────────────────────────────────── */
h1 { font-size: 23px; margin: 0 0 3px; font-weight: 700; letter-spacing: -0.2px; }
h2 { font-size: 15px; margin: 0 0 14px; font-weight: 650; color: var(--t1); }
h3 { font-size: 12px; margin: 0 0 8px; color: var(--t2); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.section-header { position: relative; padding: 14px 0 4px; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
.section-header::before { content: ''; position: absolute; top: 0; left: 0; width: 56px; height: 3px; border-radius: 3px; background: linear-gradient(90deg, var(--cyan), var(--purple)); }
.section-header .sub { color: var(--t2); font-size: 13px; margin-top: 2px; }
/* ── cards ──────────────────────────────────────────────────────── */
.card {
background: linear-gradient(180deg, rgba(30,37,64,0.35), var(--card));
border: 1px solid var(--border);
border-radius: var(--r);
padding: 20px 22px; margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0,0,0,0.25);
}
.card > h2:first-child { margin-bottom: 16px; }
.card.tint-amber { background: var(--amber-d); border-color: rgba(212,165,116,0.4); }
.card.tint-red { background: var(--red-d); border-color: rgba(224,96,96,0.4); }
.card.tint-green { background: var(--green-d); border-color: rgba(107,203,119,0.4); }
.card.clickable { cursor: pointer; transition: transform .12s, border-color .12s, box-shadow .12s; }
.card.clickable:hover { transform: translateY(-2px); border-color: rgba(78,205,196,0.4); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
/* ── pills / badges ─────────────────────────────────────────────── */
.pill {
display: inline-flex; align-items: center; gap: 5px;
border-radius: 6px; padding: 3px 9px;
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
line-height: 1.5; white-space: nowrap;
}
.pill::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.9; }
.pill.cyan { background: var(--cyan-d); color: var(--cyan); }
.pill.green { background: var(--green-d); color: var(--green); }
.pill.amber { background: var(--amber-d); color: var(--amber); }
.pill.red { background: var(--red-d); color: var(--red); }
.pill.purple { background: var(--purple-d); color: var(--purple); }
.pill.grey { background: rgba(80,88,114,0.18); color: var(--t2); }
.method { border-radius: 5px; padding: 2px 7px; font-size: 10.5px; font-weight: 700; }
.method.get { background: var(--green-d); color: var(--green); }
.method.post { background: var(--amber-d); color: var(--amber); }
.method.auth { background: var(--purple-d); color: var(--purple); }
/* ── buttons ────────────────────────────────────────────────────── */
.btn { font-family: var(--font); font-size: 12.5px; font-weight: 600; border-radius: 8px; padding: 8px 15px; cursor: pointer; border: none; transition: filter .12s, background .12s, transform .05s; }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--cyan); color: var(--bg); }
.btn.primary:hover { filter: brightness(1.1); box-shadow: 0 4px 14px rgba(78,205,196,0.3); }
.btn.ghost { background: rgba(255,255,255,0.02); border: 1px solid var(--border); color: var(--t1); }
.btn.ghost:hover { background: var(--card-h); border-color: var(--t3); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── metric cards ───────────────────────────────────────────────── */
.metric-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px; }
.metric { position: relative; background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 16px 18px; overflow: hidden; }
.metric::after { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--cyan); opacity: 0.6; }
.metric .ico { font-size: 15px; color: var(--t3); }
.metric .val { font-size: 28px; font-weight: 700; color: var(--cyan); margin: 8px 0 2px; letter-spacing: -0.5px; line-height: 1; }
.metric .val.green { color: var(--green); }
.metric .lbl { color: var(--t2); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.4px; }
/* ── grids ──────────────────────────────────────────────────────── */
.grid { display: grid; gap: 14px; }
.grid.cols-2 { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
.grid.cols-3 { grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); }
/* ── bars ───────────────────────────────────────────────────────── */
.bar { background: rgba(0,0,0,0.3); border-radius: 5px; height: 8px; overflow: hidden; width: 100%; }
.bar > span { display: block; height: 100%; background: var(--cyan); border-radius: 5px; transition: width .3s; }
.bar > span.green { background: var(--green); } .bar > span.amber { background: var(--amber); } .bar > span.red { background: var(--red); }
.conf-bar { display: inline-block; width: 56px; height: 6px; background: rgba(0,0,0,0.3); border-radius: 3px; vertical-align: middle; overflow: hidden; }
.conf-bar > span { display: block; height: 100%; background: var(--cyan); }
.conf-bar > span.amber { background: var(--amber); }
/* ── provenance badge ───────────────────────────────────────────── */
.prov { display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono); font-size: 10.5px; color: var(--t2); background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; }
.prov .arr { color: var(--t3); } .prov .hailo { color: var(--purple); font-weight: 600; }
/* ── rows / kv ──────────────────────────────────────────────────── */
.row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--border); gap: 12px; }
.row:last-child { border-bottom: none; }
.row .k { color: var(--t2); font-size: 12.5px; }
.row .v { color: var(--t1); }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 9px 16px; align-items: center; }
.kv .k { color: var(--t2); font-size: 12.5px; }
.kv .v { color: var(--t1); }
pre.json, pre.log { font-family: var(--mono); font-size: 12px; background: rgba(0,0,0,0.35); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; overflow: auto; max-height: 320px; color: var(--t1); white-space: pre-wrap; word-break: break-word; }
svg.spark { display: block; }
/* ── banners ────────────────────────────────────────────────────── */
.banner { border-radius: 9px; padding: 11px 15px; margin-bottom: 14px; font-size: 13px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.banner::before { font-weight: 700; }
.banner.amber { background: var(--amber-d); color: var(--amber); border: 1px solid rgba(212,165,116,0.4); }
.banner.amber::before { content: '▲'; }
.banner.red { background: var(--red-d); color: var(--red); border: 1px solid rgba(224,96,96,0.4); }
.banner.red::before { content: '●'; }
.banner.green { background: var(--green-d); color: var(--green); border: 1px solid rgba(107,203,119,0.4); }
.banner.green::before { content: '✓'; }
/* ── lag indicator ──────────────────────────────────────────────── */
.lag { font-size: 12px; display: inline-flex; align-items: center; gap: 7px; color: var(--t2); }
.lag .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); display: inline-block; box-shadow: 0 0 0 3px var(--green-d); }
.lag .dot.warn { background: var(--amber); box-shadow: 0 0 0 3px var(--amber-d); }
.lag .dot.err { background: var(--red); box-shadow: 0 0 0 3px var(--red-d); }
/* ── wizard stepper ─────────────────────────────────────────────── */
.stepper { display: flex; gap: 10px; margin-bottom: 22px; flex-wrap: wrap; }
.step-pill { display: flex; align-items: center; gap: 9px; padding: 8px 15px; border-radius: 24px; border: 1px solid var(--border); color: var(--t3); font-size: 12.5px; font-weight: 600; }
.step-pill .n { width: 22px; height: 22px; border-radius: 50%; background: rgba(0,0,0,0.3); display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; }
.step-pill.active { color: var(--cyan); border-color: var(--cyan); background: var(--cyan-d); }
.step-pill.active .n { background: var(--cyan); color: var(--bg); }
.step-pill.done { color: var(--green); border-color: rgba(107,203,119,0.4); }
.step-pill.done .n { background: var(--green); color: var(--bg); }
/* ── slide-over ─────────────────────────────────────────────────── */
.slideover-back { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 40; backdrop-filter: blur(2px); }
.slideover { position: fixed; top: 0; right: 0; bottom: 0; width: 480px; max-width: 92vw; background: var(--card); border-left: 1px solid var(--border); z-index: 41; padding: 26px; overflow-y: auto; box-shadow: -12px 0 40px rgba(0,0,0,0.45); }
.slideover .close { float: right; cursor: pointer; color: var(--t2); font-size: 16px; }
.slideover .close:hover { color: var(--t1); }
/* ── inputs ─────────────────────────────────────────────────────── */
.search { width: 100%; background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 9px; padding: 10px 13px; color: var(--t1); font-family: var(--font); font-size: 13px; }
.search::placeholder { color: var(--t3); }
.search:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 0 3px var(--cyan-d); }
input.inline { background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 6px; padding: 5px 9px; color: var(--t1); font-family: var(--mono); font-size: 12px; width: 92px; }
input.inline:focus { outline: none; border-color: var(--cyan); }
select.inline { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px; color: var(--t1); font-family: var(--font); font-size: 13px; }
textarea.inline { background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: 8px; padding: 10px; color: var(--t1); font-family: var(--mono); font-size: 12px; width: 100%; }
/* ── collapsible ────────────────────────────────────────────────── */
.collapsible > .head { cursor: pointer; display: flex; align-items: center; gap: 9px; padding: 4px 0; user-select: none; }
.collapsible > .head::before { content: '▸'; color: var(--t3); transition: transform .15s; font-size: 11px; }
.collapsible.open > .head::before { transform: rotate(90deg); }
.muted-empty { color: var(--t3); font-style: italic; padding: 10px 0; }
.shield.ok { color: var(--green); } .shield.bad { color: var(--red); }
.flex { display: flex; gap: 10px; align-items: center; }
.flex.wrap { flex-wrap: wrap; } .spread { justify-content: space-between; } .gap-sm { gap: 6px; }
.mt { margin-top: 14px; } .mb { margin-bottom: 14px; }
small.ts { color: var(--t3); font-size: 11.5px; }
strong.mono { font-size: 13px; color: var(--t1); }
@@ -0,0 +1,34 @@
/*
* HOMECORE-UI design tokens — ADR-131 §3.1 / §3.2.
*
* These values are extracted verbatim from the production Cognitum
* platform (seed.cognitum.one/store + /status). DO NOT introduce new
* colours, typefaces, or border radii — ADR-131 §3.3 invariant. A user
* navigating from the Cog Store into HOMECORE must not notice a seam.
*/
:root {
/* §3.1 colour palette */
--bg: #0a0e1a; /* page background (very dark navy) */
--bg2: #111627; /* secondary background / nav strip */
--card: #171d30; /* card / panel surface */
--card-h: #1e2540; /* card hover state */
--border: #252d45; /* all border strokes (~0.67px, subtle) */
--t1: #e0e4f0; /* primary text (near-white) */
--t2: #8890a8; /* secondary / muted text */
--t3: #505872; /* tertiary / disabled text */
--cyan: #4ecdc4; /* primary action colour */
--cyan-d: rgba(78,205,196,0.15);
--green: #6bcb77; /* success / online / healthy */
--green-d: rgba(107,203,119,0.15);
--amber: #d4a574; /* warning / stale / degraded */
--amber-d: rgba(212,165,116,0.15);
--red: #e06060; /* error / offline / veto */
--red-d: rgba(224,96,96,0.15);
--purple: #a78bfa; /* informational / epoch / chain */
--purple-d: rgba(167,139,250,0.15);
--r: 10px; /* standard border radius */
/* §3.2 typography */
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
--mono: 'Cascadia Code', 'Fira Code', Consolas, monospace;
}
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HOMECORE — Cognitum Appliance</title>
<meta name="description" content="HOMECORE operational dashboard for the two-tier Cognitum stack (ADR-131)." />
<link rel="stylesheet" href="./css/tokens.css" />
<link rel="stylesheet" href="./css/app.css" />
</head>
<body>
<div id="app">
<noscript>HOMECORE-UI requires JavaScript.</noscript>
</div>
<script type="module" src="./js/app.js"></script>
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
// 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.
import * as mock from './mock.js';
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.
async _data(key, path, mockFn) {
if (demoMode()) { demoFlags[key] = true; return mockFn(); }
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', () => mock.applianceHealth()); },
async seeds() { return this._data('fleet', '/api/homecore/seeds', () => mock.seeds()); },
async seed(id) { return this._data('fleet', '/api/homecore/seeds/' + encodeURIComponent(id), () => mock.seed(id)); },
async esp32Warnings() {
if (demoMode()) { demoFlags.fleet = true; return mock.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', () => mock.cogs()); },
async cogUpdates() { return this._data('cogs', '/api/homecore/cogs/updates', () => mock.cogUpdates()); },
async hailo() { return this._data('cogs', '/api/homecore/hailo', () => ({ worker: 'connected', cogs: mock.cogs().filter((c) => c.arch === 'hailo10') })); },
async roomStates() { return this._data('rooms', '/api/homecore/rooms', () => mock.roomStates()); },
async federation() { return this._data('fleet', '/api/homecore/federation', () => mock.federation()); },
async witnessLog(page = 0, size = 12) { return this._data('audit', `/api/homecore/witness?page=${page}&size=${size}`, () => mock.witnessLog(page, size)); },
async privacyModes() { return this._data('audit', '/api/homecore/privacy', () => mock.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}`, () => mock.recentEvents(n)); },
recentEvents(n) { return this.eventHistory(n); }, // back-compat alias (async)
async settings() { return this._data('settings', '/api/homecore/settings', () => mock.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', () => mock.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;
if (!seed && demoMode() && node) {
const cfg = mock.settings().esp32.find((n) => n.node_id === node);
seed = cfg ? cfg.seed : null;
}
const hailo = /hailo|pose/i.test(src) || /hailo/i.test(String(attrs.cog || ''));
const cog = attrs.cog || (/matter|bfld|mmwave|mr60/i.test(src) ? 'cog-ha-matter' : (hailo ? 'cog-pose-estimation' : null));
return { esp32: node, seed: seed || (node ? 'unknown' : null), cog: cog || 'unknown', hailo };
}
// Calibration: per-call branch on demo mode. Prod proxies the real
// calibrate-serve API via the gateway (/api/cal/v1/*). All methods are
// async (the §4.7 wizard awaits them).
function makeCalibration() {
const ANCHORS = ['empty', 'stand_still', 'sit', 'lie_down', 'breathe_slow', 'breathe_normal', 'small_move', 'sleep_posture'];
// demo session state
let frames = 0; const target = 1200; const accepted = new Set();
const get = (p) => api._get('/api/cal/v1' + p);
const post = (p, b) => api._post('/api/cal/v1' + p, b);
return {
ANCHORS,
get demo() { return demoMode(); },
async start() {
if (demoMode()) { frames = 0; return { baseline_id: 'bl-demo-' + ANCHORS.length }; }
return post('/calibration/start', {});
},
async stop() { if (demoMode()) return { stopped: true }; return post('/calibration/stop', {}); },
async status() {
if (demoMode()) { frames = Math.min(target, frames + 180); return { frames, target, eta_s: Math.max(0, Math.round((target - frames) / 180)), z_median: 0.41, motion_flagged: frames < 360 }; }
return get('/calibration/status');
},
async anchor(label) {
if (demoMode()) {
const ok = label !== 'sleep_posture' || accepted.size >= 6;
if (ok) accepted.add(label);
return { label, accepted: ok, reason: ok ? null : 'insufficient stillness — retry', features: { mean: 0.12, variance: 0.04, breathing_score: 0.7, heart_score: 0.55 } };
}
return post('/enroll/anchor', { label });
},
async enrollStatus() {
if (demoMode()) return { accepted: [...accepted], total: ANCHORS.length };
return get('/enroll/status');
},
async train(room_id) {
if (demoMode()) {
const trained = accepted.size >= 6;
return {
presence: trained ? { threshold: 0.31, occupied_var: 0.08 } : null,
posture: trained ? { prototypes: 4 } : null,
breathing: accepted.has('breathe_normal') ? { min_score: 0.6 } : null,
heartbeat: accepted.has('breathe_normal') ? { min_score: 0.5 } : null,
restlessness: trained ? { calm: 0.05, active: 0.6 } : null,
anomaly: trained ? { prototypes: 8, scale: 1.4 } : null,
};
}
return post('/room/train', { room_id });
},
reset() { accepted.clear(); frames = 0; },
};
}
+141
View File
@@ -0,0 +1,141 @@
// HOMECORE-UI bootstrap + shell + router — ADR-131 §5.
//
// Builds the Cognitum-shell top nav (Framework | Guide | Cog Store |
// HOMECORE | Status) with HOMECORE active, a left sub-nav for the nine
// HOMECORE sections, and a hash router. One shared WebSocket feeds a bus
// that every panel subscribes to (no per-panel sockets, no polling).
import { h, clear, lagIndicator } from './ui.js';
import { api } from './api.js';
import { connect } from './ws.js';
import dashboard from './panels/dashboard.js';
import fleet from './panels/fleet.js';
import seedDetail from './panels/seed-detail.js';
import entities from './panels/entities.js';
import rooms from './panels/rooms.js';
import cogs from './panels/cogs.js';
import calibration from './panels/calibration.js';
import events from './panels/events.js';
import audit from './panels/audit.js';
import settings from './panels/settings.js';
// Section registry. order drives the left sub-nav (§5).
const SECTIONS = [
{ id: 'dashboard', label: 'Dashboard', icon: '◳', mod: dashboard },
{ id: 'fleet', label: 'SEED Fleet', icon: '⬡', mod: fleet },
{ id: 'entities', label: 'Entities', icon: '◈', mod: entities },
{ id: 'rooms', label: 'Rooms', icon: '⌂', mod: rooms },
{ id: 'cogs', label: 'COGs', icon: '⚙', mod: cogs },
{ id: 'calibration', label: 'Calibration', icon: '⊹', mod: calibration },
{ id: 'events', label: 'Events', icon: '⚡', mod: events },
{ id: 'audit', label: 'Audit', icon: '⛨', mod: audit },
{ id: 'settings', label: 'Settings', icon: '⚒', mod: settings },
];
// Detail routes not shown in the sub-nav.
const ROUTES = { 'seed': seedDetail };
// Shared event bus fed by the single WS connection.
const bus = new EventTarget();
let wsState = { state: 'connecting', lagged: false };
const ctx = {
api,
bus,
wsStatus: () => wsState,
navigate: (hash) => { location.hash = hash; },
onEvent(handler) {
const fn = (e) => handler(e.detail);
bus.addEventListener('hc-event', fn);
return () => bus.removeEventListener('hc-event', fn);
},
onWs(handler) {
const fn = (e) => handler(e.detail);
bus.addEventListener('hc-ws', fn);
handler(wsState);
return () => bus.removeEventListener('hc-ws', fn);
},
};
let cleanup = null;
function buildShell() {
const topnav = h('.topnav',
h('.brand',
h('span.logo', 'C'),
h('span.brand-name', 'Cognitum'),
h('span.brand-sep', '/'),
h('span.brand-tag', 'HOMECORE')),
h('span.nav-spacer'),
lagIndicatorHost());
const sidenav = h('.sidenav', ...SECTIONS.map((s) => sideLink(s)));
const content = h('.content#hc-content');
const shell = h('.shell', sidenav, content);
const root = document.getElementById('app');
clear(root);
root.appendChild(topnav);
root.appendChild(shell);
return content;
}
function sideLink(section) {
return h('a', { href: '#/' + section.id, 'data-section': section.id },
h('span.ico', section.icon || '•'), h('span.lbl', section.label));
}
function lagIndicatorHost() {
const host = h('span');
const paint = () => { clear(host); host.appendChild(lagIndicator(wsState.state, wsState.lagged)); };
bus.addEventListener('hc-ws', paint);
paint();
return host;
}
function highlightNav(id) {
document.querySelectorAll('.sidenav a').forEach((a) => {
a.classList.toggle('active', a.getAttribute('data-section') === id);
});
}
async function route() {
const hash = location.hash.replace(/^#\/?/, '') || 'dashboard';
const [head, ...rest] = hash.split('/');
const content = document.getElementById('hc-content') || buildShell();
if (typeof cleanup === 'function') { try { cleanup(); } catch {} cleanup = null; }
clear(content);
let mod, params = {};
const section = SECTIONS.find((s) => s.id === head);
if (section) { mod = section.mod; highlightNav(head); }
else if (ROUTES[head]) { mod = ROUTES[head]; params.id = rest[0]; highlightNav('fleet'); }
else { mod = SECTIONS[0].mod; highlightNav('dashboard'); }
try {
const result = await mod.render(content, { ...ctx, params });
if (typeof result === 'function') cleanup = result;
} catch (e) {
content.appendChild(h('.banner.red', 'Panel error: ' + (e && e.message ? e.message : e)));
console.error(e);
}
}
function start() {
buildShell();
// Attach routing + render the first panel BEFORE opening the socket.
// connect() invokes its status callback synchronously, so the WS wiring
// must not be on the critical render path (a thrown callback here would
// otherwise blank the whole dashboard).
window.addEventListener('hashchange', route);
route();
const ctrl = connect(
(evt) => bus.dispatchEvent(new CustomEvent('hc-event', { detail: evt })),
(st) => { wsState = { state: st.state, lagged: !!st.lagged }; bus.dispatchEvent(new CustomEvent('hc-ws', { detail: wsState })); },
);
ctx.ws = ctrl;
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
export { SECTIONS, ctx };
+296
View File
@@ -0,0 +1,296 @@
// HOMECORE-UI contract-conformant mock layer — ADR-131 §7.1.
//
// "Where a service is not yet stable, the panel is still built against
// its defined contract (with a contract-conformant mock standing in for
// the live endpoint only until that endpoint lands)."
//
// Shapes mirror the schemas described in ADR-131 §4 + the calibration
// RoomState contract (docs/integration/calibration-appliance-integration.md)
// + the SEED HTTPS API. Live endpoints replace these the moment they
// exist; nothing here is presented to the operator as real (the UI shows
// a DEMO badge whenever the mock layer is serving a panel — see api.js).
const now = () => new Date().toISOString();
const ago = (s) => new Date(Date.now() - s * 1000).toISOString();
function jitter(base, amp) { return +(base + (Math.sin(Date.now() / 3000 + base) * amp)).toFixed(2); }
function spark(base, amp, n = 24) {
return Array.from({ length: n }, (_, i) => +(base + Math.sin(i / 2) * amp + (i % 3) * amp * 0.2).toFixed(2));
}
// Factory for a bedroom SEED node — keeps the three bedrooms consistent
// while varying the values that matter for the analysis views.
function bedroomSeed(o) {
return {
device_id: o.device_id, firmware: '0.7.3', online: true, conn: o.conn || 'wifi', epoch: o.epoch,
vector_count: o.vector_count, vector_dim: 8, knn_latency_ms: o.knn_latency_ms,
last_ingest: ago(2), witness_valid: true, witness_len: o.witness_len,
witness_last_verify: ago(1800), zone: o.zone,
storage_used: o.vector_count, storage_budget: 100000,
sensors: {
bme280: { temp_c: o.temp_c, humidity_pct: o.humidity_pct, pressure_hpa: 1013.0 },
pir: { motion: o.motion, last_trigger: ago(o.motion ? 5 : 640) },
reed: { open: false, last_change: ago(30000) },
ads1115: [{ label: 'ch0', v: 0.11 }, { label: 'ch1', v: 0.0 }, { label: 'ch2', v: 0.0 }, { label: 'ch3', v: 0.0 }],
vibration: { active: false, last_trigger: null },
},
reflex: [
{ name: 'fragility_alarm', threshold: 0.3, target: 'relay actuator', last_fired: o.fired ? ago(420) : null, fired_recently: !!o.fired },
{ name: 'drift_cutoff', threshold: 1.0, target: 'ingest gate', last_fired: null, fired_recently: false },
{ name: 'hd_anomaly_indicator', threshold: 200, target: 'PWM brightness', last_fired: null, fired_recently: false },
],
cognition: { fragility: o.fragility, coherence_phases: o.phases, knn_rebuild_s: 10 },
ingest: { batch: 64, flush_ms: 1000, bridge: 'direct', esp32: [{ node_id: o.node, packet: '0xC5110003', rate_hz: 1.0 }] },
esp32_nodes: 1, frame_rate_hz: 100,
};
}
// ── v0 Appliance health (§4.1) ──────────────────────────────────────
export function applianceHealth() {
return {
cpu_pct: jitter(34, 6),
ram_pct: jitter(58, 4),
hailo_load_pct: jitter(41, 12),
hailo_temp_c: jitter(52, 3),
uptime_s: 824510,
services: [
{ name: 'ruview-mcp-brain', port: 9876, status: 'running' },
{ name: 'cognitum-rvf-agent', port: 9004, status: 'running' },
{ name: 'ruvector-hailo-worker', port: 50051, status: 'running' },
],
event_rate: spark(120, 40),
channel_capacity: 4096,
channel_lag: 0,
};
}
// ── SEED fleet (§4.1 / §4.2) ────────────────────────────────────────
const SEEDS = [
{
device_id: 'seed-livingroom-a1',
firmware: '0.7.3', online: true, conn: 'wifi', epoch: 184,
vector_count: 71280, vector_dim: 8, knn_latency_ms: 2.1,
last_ingest: ago(3), witness_valid: true, witness_len: 184210,
witness_last_verify: ago(900), zone: 'Living Room',
storage_used: 71280, storage_budget: 100000,
sensors: {
bme280: { temp_c: 21.6, humidity_pct: 44, pressure_hpa: 1013.2 },
pir: { motion: true, last_trigger: ago(8) },
reed: { open: false, last_change: ago(7200) },
ads1115: [{ label: 'soil', v: 0.42 }, { label: 'light', v: 0.71 }, { label: 'aux2', v: 0.03 }, { label: 'aux3', v: 0.0 }],
vibration: { active: false, last_trigger: ago(40000) },
},
reflex: [
{ name: 'fragility_alarm', threshold: 0.3, target: 'relay actuator', last_fired: ago(300), fired_recently: true },
{ name: 'drift_cutoff', threshold: 1.0, target: 'ingest gate', last_fired: null, fired_recently: false },
{ name: 'hd_anomaly_indicator', threshold: 200, target: 'PWM brightness', last_fired: ago(12000), fired_recently: false },
],
cognition: { fragility: 0.42, coherence_phases: [{ t: ago(3600), label: 'empty' }, { t: ago(1800), label: 'occupied' }, { t: ago(300), label: 'regime-change' }], knn_rebuild_s: 10 },
ingest: { batch: 64, flush_ms: 1000, bridge: 'host-laptop hop', esp32: [{ node_id: 'esp32-lr-01', packet: '0xC5110003', rate_hz: 1.0 }, { node_id: 'esp32-lr-02', packet: '0xC5110002', rate_hz: 0.9 }] },
esp32_nodes: 2, frame_rate_hz: 98,
},
bedroomSeed({
device_id: 'seed-bedroom-1', zone: 'Bedroom 1 (primary)', epoch: 183,
vector_count: 38110, knn_latency_ms: 1.7, witness_len: 91022,
temp_c: 20.1, humidity_pct: 47, motion: false, fragility: 0.12,
phases: [{ t: ago(7200), label: 'empty' }, { t: ago(3600), label: 'sleep' }],
node: 'esp32-br1-01', conn: 'usb',
}),
bedroomSeed({
device_id: 'seed-bedroom-2', zone: 'Bedroom 2 (guest)', epoch: 181,
vector_count: 29440, knn_latency_ms: 1.9, witness_len: 70210,
temp_c: 19.4, humidity_pct: 50, motion: true, fragility: 0.21,
phases: [{ t: ago(5400), label: 'empty' }, { t: ago(900), label: 'occupied' }],
node: 'esp32-br2-01', conn: 'wifi',
}),
bedroomSeed({
device_id: 'seed-bedroom-3', zone: 'Bedroom 3 (kids)', epoch: 179,
vector_count: 24105, knn_latency_ms: 2.0, witness_len: 60880,
temp_c: 21.0, humidity_pct: 45, motion: false, fragility: 0.34,
phases: [{ t: ago(9000), label: 'empty' }, { t: ago(4200), label: 'sleep' }, { t: ago(600), label: 'restless' }],
node: 'esp32-br3-01', conn: 'wifi', fired: true,
}),
{
device_id: 'seed-hallway-c3',
firmware: '0.6.9', online: false, conn: 'wifi', epoch: 170,
vector_count: 12044, vector_dim: 8, knn_latency_ms: null,
last_ingest: ago(5400), witness_valid: true, witness_len: 40110,
witness_last_verify: ago(86400), zone: 'Hallway',
storage_used: 12044, storage_budget: 100000,
sensors: null,
reflex: [],
cognition: { fragility: null, coherence_phases: [], knn_rebuild_s: 10 },
ingest: { batch: 64, flush_ms: 1000, bridge: 'direct', esp32: [] },
esp32_nodes: 0, frame_rate_hz: 0,
warnings: ['stale firmware version (0.6.9 < 0.7.3)', 'offline > 1h'],
},
];
export function seeds() { return SEEDS.map((s) => ({ ...s })); }
export function seed(id) { return SEEDS.find((s) => s.device_id === id) || null; }
// ── ESP32 node warnings (§4.1) ──────────────────────────────────────
export function esp32Warnings() {
return [
{ node_id: 'esp32-lr-02', seed: 'seed-livingroom-a1', issue: 'presence_score normalisation anomaly' },
{ node_id: 'esp32-hw-01', seed: 'seed-hallway-c3', issue: 'stale firmware version' },
];
}
// ── COG runtime (§4.6) ──────────────────────────────────────────────
const COGS = [
{ id: 'cog-ha-matter', version: '1.4.2', arch: 'arm', status: 'running', pid: 4120, sha256_verified: true, signature_verified: true },
{ id: 'cog-pose-estimation', version: '2.1.0', arch: 'hailo10', status: 'running', pid: 4188, sha256_verified: true, signature_verified: true, hef: ['rf_foundation_encoder.hef', 'pose_head.hef'], throughput_fps: 41 },
{ id: 'cog-person-count', version: '0.9.4', arch: 'arm', status: 'running', pid: 4205, sha256_verified: true, signature_verified: true },
{ id: 'cog-calibration', version: '1.0.1', arch: 'arm', status: 'running', pid: 4250, sha256_verified: true, signature_verified: true },
{ id: 'cog-anomaly-watch', version: '0.3.0', arch: 'arm', status: 'failed', pid: null, sha256_verified: true, signature_verified: true, error: 'panic: bank not found' },
{ id: 'cog-legacy-bridge', version: '0.1.2', arch: 'arm', status: 'stopped', pid: null, sha256_verified: false, signature_verified: false },
];
export function cogs() { return COGS.map((c) => ({ ...c })); }
export function cogUpdates() { return [{ id: 'cog-pose-estimation', from: '2.1.0', to: '2.2.0', new_entities: ['sensor.lr_pose_confidence'], config_changes: ['add: max_persons'] }]; }
export function appRegistry() {
return [
{ id: 'cog-fall-detect', title: 'Fall Detection', desc: 'Multistatic fall detection specialist', category: 'safety', arch: 'arm', featured: true, new_entities: ['binary_sensor.{room}_fall'] },
{ id: 'cog-sleep-stage', title: 'Sleep Staging', desc: 'REM/deep/light from breathing + restlessness', category: 'health', arch: 'hailo10', new_entities: ['sensor.{room}_sleep_stage'] },
{ id: 'cog-gesture', title: 'Gesture Control', desc: 'DTW gesture classifier → service calls', category: 'control', arch: 'arm', new_entities: ['event.{room}_gesture'] },
];
}
// ── RoomState / sensing (§4.5) — calibration contract ───────────────
export function roomStates() {
return [
{
room_id: 'living_room', stale: false, vetoed: false, seeds: ['seed-livingroom-a1'],
presence: { value: 'occupied', confidence: 0.93 },
posture: { value: 'sitting', confidence: 0.81 },
breathing_bpm: { value: jitter(15, 1.5), confidence: 0.77 },
heart_bpm: { value: jitter(72, 3), confidence: 0.64 },
restlessness: { value: 0.22, confidence: 0.7 },
anomaly: { value: 0.18, confidence: 0.8, threshold: 0.8 },
},
{
// Bedroom 1 — primary; healthy sleeping vitals.
room_id: 'bedroom_1', stale: false, vetoed: false, seeds: ['seed-bedroom-1'],
presence: { value: 'occupied', confidence: 0.91 },
posture: { value: 'lying', confidence: 0.9 },
breathing_bpm: { value: jitter(12, 1), confidence: 0.85 },
heart_bpm: { value: jitter(58, 2), confidence: 0.72 },
restlessness: { value: 0.08, confidence: 0.8 },
anomaly: { value: 0.12, confidence: 0.84, threshold: 0.8 },
},
{
// Bedroom 2 — guest; STALE bank (recalibrate demo).
room_id: 'bedroom_2', stale: true, vetoed: false, seeds: ['seed-bedroom-2'],
presence: { value: 'occupied', confidence: 0.86 },
posture: { value: 'sitting', confidence: 0.7 },
breathing_bpm: { value: jitter(16, 1.5), confidence: 0.66 },
heart_bpm: { value: jitter(74, 3), confidence: 0.58 },
restlessness: { value: 0.31, confidence: 0.62 },
anomaly: { value: 0.4, confidence: 0.6, threshold: 0.8 },
},
{
// Bedroom 3 — kids; heartbeat specialist not yet trained.
room_id: 'bedroom_3', stale: false, vetoed: false, seeds: ['seed-bedroom-3'],
presence: { value: 'occupied', confidence: 0.79 },
posture: { value: 'lying', confidence: 0.74 },
breathing_bpm: { value: jitter(18, 2), confidence: 0.69 },
heart_bpm: null, // null = not trained (§6 invariant 3)
restlessness: { value: 0.46, confidence: 0.6 },
anomaly: { value: 0.22, confidence: 0.7, threshold: 0.8 },
},
{
room_id: 'kitchen', stale: false, vetoed: true, seeds: ['seed-livingroom-a1', 'seed-hallway-c3'],
presence: { value: 'occupied', confidence: 0.6 },
posture: { value: null, confidence: null }, // suppressed by veto — withheld, NOT zero (§4.5)
breathing_bpm: { value: null, confidence: null },
heart_bpm: { value: null, confidence: null },
restlessness: { value: 0.4, confidence: 0.5 },
anomaly: { value: 0.91, confidence: 0.88, threshold: 0.8 },
},
{
room_id: 'office', stale: false, vetoed: false, seeds: ['seed-bedroom-1'],
presence: { value: 'absent', confidence: 0.95 },
posture: null, // null = not trained (§6 invariant 3)
breathing_bpm: null,
heart_bpm: null,
restlessness: { value: 0.0, confidence: 0.9 },
anomaly: { value: 0.05, confidence: 0.9, threshold: 0.8 },
},
];
}
// ── Fleet map / federation (§4.3) ───────────────────────────────────
export function federation() {
return {
coordinator: 'seed-livingroom-a1', round: 47, k_healthy: 4, delta_status: 'exchanging',
invariant: 'model deltas only — never raw CSI',
krum: { f: 1, multi: true }, cadence_min: 30,
mesh_links: [
{ a: 'seed-livingroom-a1', b: 'seed-bedroom-1', health: 'green' },
{ a: 'seed-bedroom-1', b: 'seed-bedroom-2', health: 'green' },
{ a: 'seed-bedroom-2', b: 'seed-bedroom-3', health: 'amber' },
{ a: 'seed-bedroom-1', b: 'seed-hallway-c3', health: 'red' },
],
fused_events: [{ kind: 'fall', seeds: ['seed-livingroom-a1', 'seed-hallway-c3'], n: 2 }, { kind: 'occupant-track', seeds: ['seed-bedroom-1', 'seed-bedroom-2', 'seed-livingroom-a1'], n: 3 }],
};
}
// ── Witness / audit (§4.9) ──────────────────────────────────────────
export function witnessLog(page = 0, size = 12) {
const total = 240;
const items = Array.from({ length: size }, (_, i) => {
const n = page * size + i;
const seedTier = n % 2 === 0;
return {
entity_id: seedTier ? `rvf.store.write.${184210 - n}` : ['sensor.living_room_presence', 'binary_sensor.front_door', 'sensor.bedroom_breathing_rate'][n % 3],
old_state: seedTier ? null : ['false', 'off', '14.5'][n % 3],
new_state: seedTier ? `sha256:${(0x9a3f + n).toString(16)}` : ['true', 'on', '15.1'][n % 3],
ts: ago(n * 37),
tier: seedTier ? 'seed-sha256' : 'homecore-ed25519',
seed: ['seed-livingroom-a1', 'seed-bedroom-1', 'seed-bedroom-2', 'seed-bedroom-3'][n % 4],
key_fp: ['a1b2c3d4', 'e5f6a7b8', 'c9d0e1f2', 'b3a4c5d6'][n % 4],
};
});
return { items, page, size, total };
}
export function privacyModes() {
return [
{ seed: 'seed-livingroom-a1', mode: 'full-publish' },
{ seed: 'seed-bedroom-1', mode: 'audit-only' },
{ seed: 'seed-bedroom-2', mode: 'audit-only' },
{ seed: 'seed-bedroom-3', mode: 'audit-only' },
{ seed: 'seed-hallway-c3', mode: 'audit-only' },
];
}
// ── Events / automations (§4.8) ─────────────────────────────────────
export function recentEvents(n = 40) {
const variants = ['StateChanged', 'EntityRegistered', 'ConfigReloaded'];
const ents = ['sensor.living_room_presence', 'binary_sensor.front_door', 'light.kitchen_ceiling', 'sensor.bedroom_breathing_rate'];
return Array.from({ length: n }, (_, i) => ({
type: variants[i % 3],
entity_id: ents[i % ents.length],
old_state: ['off', 'false', '14.5'][i % 3],
new_state: ['on', 'true', '15.1'][i % 3],
ts: ago(i * 11),
user_id: i % 4 === 0 ? 'operator' : null,
context: { id: 'ctx-' + (1000 + i), parent_id: i % 3 === 0 ? 'ctx-' + (999 + i) : null, grandparent_id: i % 6 === 0 ? 'ctx-' + (998 + i) : null },
source: ['seed-livingroom-a1', 'cog-ha-matter'][i % 2],
}));
}
// ── Settings (§4.10) ────────────────────────────────────────────────
export function settings() {
return {
mqtt: { broker: 'mqtt://cognitum-v0:1883', user: 'homecore', mdns: '_ruview-ha._tcp', connected: true },
tokens: [
{ name: 'ios-companion', last_used: ago(120), created: ago(8000000) },
{ name: 'node-red', last_used: ago(60000), created: ago(20000000) },
],
ha_disco_entities: 21,
esp32: [
{ node_id: 'esp32-lr-01', ip: '192.168.1.31', port: 5566, firmware: '1.2.0', room: 'living_room', seed: 'seed-livingroom-a1' },
{ node_id: 'esp32-br1-01', ip: '192.168.1.32', port: 5566, firmware: '1.2.0', room: 'bedroom_1', seed: 'seed-bedroom-1' },
{ node_id: 'esp32-br2-01', ip: '192.168.1.33', port: 5566, firmware: '1.2.0', room: 'bedroom_2', seed: 'seed-bedroom-2' },
{ node_id: 'esp32-br3-01', ip: '192.168.1.34', port: 5566, firmware: '1.2.0', room: 'bedroom_3', seed: 'seed-bedroom-3' },
],
};
}
@@ -0,0 +1,217 @@
// §4.9 Witness / Audit Log — ADR-131.
//
// Persistent privacy-mode banner (aggregate + per-SEED), the unified
// two-tier witness timeline (SEED SHA-256 chain + homecore Ed25519
// chain merged chronologically), paginated 12-at-a-time, and a
// regulated-deployment attestation-bundle export. Privacy-mode toggles
// are high-stakes and gated behind an explicit inline confirm (§6 honesty
// — never silently mutate what a SEED publishes).
import { h, clear, card, pill, statusPill, sectionHeader, mono, button, banner, relTime } from '../ui.js';
const PAGE_SIZE = 12;
export default {
meta: { title: 'Audit' },
async render(root, ctx) {
const { api } = ctx;
root.appendChild(sectionHeader('Witness / Audit Log', 'Two-tier provenance — SEED SHA-256 store chain + homecore Ed25519 state chain'));
if (api.isDemo('audit')) root.appendChild(banner('DEMO — contract-conformant witness data until the live audit endpoint lands (ADR-131 §7.1).', 'amber'));
// Async data accessors now return Promises (api.js). Wrap the initial
// loads in try/catch; on failure surface the typed audit/witness banner
// (§12 W5 distinguishes "not yet wired" upstreams) and bail.
let modes;
let firstPage;
try {
modes = (await api.privacyModes()).map((m) => ({ ...m }));
firstPage = await api.witnessLog(0, PAGE_SIZE);
} catch (e) {
root.appendChild(banner('Audit/witness unavailable — ' + (e.message || e)
+ (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red'));
return () => {};
}
const privacyHost = h('div');
root.appendChild(privacyHost);
const renderPrivacy = () => { clear(privacyHost); privacyHost.appendChild(privacyCard(modes, renderPrivacy)); };
renderPrivacy();
// Unified timeline — its own host so pagination re-renders in place.
const timelineHost = h('div');
root.appendChild(timelineHost);
let page = firstPage.page;
// Pagination Prev/Next re-fetch the new page (await) and re-render in place.
const renderTimeline = async (res) => {
page = res.page;
clear(timelineHost);
timelineHost.appendChild(timelineCard(res,
async () => {
if (page <= 0) return;
clear(timelineHost);
timelineHost.appendChild(h('.muted-empty', 'Loading witness chain…'));
try { await renderTimeline(await api.witnessLog(page - 1, PAGE_SIZE)); }
catch (e) { clear(timelineHost); timelineHost.appendChild(banner('Audit/witness unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red')); }
},
async (last) => {
if (last) return;
clear(timelineHost);
timelineHost.appendChild(h('.muted-empty', 'Loading witness chain…'));
try { await renderTimeline(await api.witnessLog(page + 1, PAGE_SIZE)); }
catch (e) { clear(timelineHost); timelineHost.appendChild(banner('Audit/witness unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red')); }
}));
};
await renderTimeline(firstPage);
// Attestation bundle export.
root.appendChild(exportCard());
return () => {};
},
};
// ── Privacy mode (aggregate banner + per-SEED rows + gated toggle) ─────
function privacyCard(modes, rerender) {
const allPublish = modes.every((m) => m.mode === 'full-publish');
const anyAudit = modes.some((m) => m.mode === 'audit-only');
const top = allPublish
? banner('Full-publish mode — SEED state changes are published over MQTT.', 'green')
: banner('Audit-only mode (SHA-256 digests on-SEED only, no MQTT state messages).', 'amber');
const list = h('div');
modes.forEach((m, i) => list.appendChild(privacyRow(m, modes, rerender, i)));
return card({
title: 'Privacy mode',
children: [
top,
h('.t2.mt', 'Per-SEED configuration — each SEED chooses independently what leaves the device.'),
list,
],
});
}
function privacyRow(m, modes, rerender, idx) {
const isPublish = m.mode === 'full-publish';
const modePill = pill(m.mode, isPublish ? 'green' : 'amber');
// The confirm step lives inline beneath the row; only one at a time.
const confirmHost = h('div');
const toggleBtn = button('Toggle privacy mode', {
variant: 'ghost',
onClick: () => {
clear(confirmHost);
confirmHost.appendChild(confirmStep(m, modes, rerender, confirmHost));
},
});
const wrap = h('div',
h('.row',
h('span.flex.gap-sm', mono(m.seed), modePill),
toggleBtn),
confirmHost);
return wrap;
}
function confirmStep(m, modes, rerender, confirmHost) {
const target = m.mode === 'full-publish' ? 'audit-only' : 'full-publish';
const summary = target === 'audit-only'
? `${m.seed} will STOP publishing state changes over MQTT — only on-SEED SHA-256 digests remain.`
: `${m.seed} will START publishing state changes over MQTT (full state values leave the device).`;
const confirmBtn = button('Confirm', {
variant: 'primary',
onClick: () => {
const live = modes.find((x) => x.seed === m.seed);
if (live) live.mode = target;
rerender();
},
});
const cancelBtn = button('Cancel', { variant: 'ghost', onClick: () => clear(confirmHost) });
return card({
tint: target === 'audit-only' ? 'amber' : null,
children: [
h('.t2', h('span', 'Switch '), mono(m.seed), h('span', `${target}?`)),
h('.mt', summary),
h('.flex.gap-sm.mt', confirmBtn, cancelBtn),
],
});
}
// ── Unified two-tier witness timeline ──────────────────────────────────
function timelineCard(res, onPrev, onNext) {
const { items, page, size, total } = res;
const lastPage = Math.max(0, Math.ceil(total / size) - 1);
const isLast = page >= lastPage;
const head = h('.row',
h('span.k', 'entity · old → new · when · tier · source SEED · key'),
h('span.t2', `merged chronological — both chains`));
const body = h('div');
if (!items.length) body.appendChild(h('.muted-empty', 'No witness entries.'));
items.forEach((it) => body.appendChild(witnessRow(it)));
const from = total === 0 ? 0 : page * size + 1;
const to = Math.min(total, page * size + items.length);
const pager = h('.flex.spread.mt',
h('span.t2', `Showing ${from}${to} of ${total}`),
h('span.flex.gap-sm',
button(' Prev', { variant: 'ghost', onClick: onPrev, disabled: page <= 0 }),
button('Next ', { variant: 'ghost', onClick: () => onNext(isLast), disabled: isLast })));
return card({ title: 'Witness timeline', children: [head, body, pager] });
}
function witnessRow(it) {
const seedTier = it.tier === 'seed-sha256';
const tierPill = pill(it.tier, seedTier ? 'cyan' : 'purple');
// old → new. SEED-tier writes have no prior state and a sha256 digest as
// the "new" value — render the digest mono so it reads as a hash, not state.
const transition = h('span.flex.gap-sm',
h('span.mono.t2', it.old_state == null ? '∅' : it.old_state),
h('span.t3', '→'),
h('span.mono', it.new_state == null ? '∅' : it.new_state));
return h('.row',
h('span.flex.gap-sm.wrap',
mono(it.entity_id),
transition),
h('span.flex.gap-sm.wrap',
h('span.t2', relTime(it.ts)),
tierPill,
mono(it.seed),
h('span.mono.t3', keyFp(it.key_fp))));
}
function keyFp(fp) {
if (!fp) return '—';
return String(fp).slice(0, 8) + '…';
}
// ── Attestation bundle export (regulated-deployment compliance) ────────
function exportCard() {
const status = h('.t2.mt');
const btn = button('Export attestation bundle', {
variant: 'ghost',
onClick: () => {
clear(status);
status.appendChild(h('span.green',
'Bundle prepared — SEED SHA-256 store chain + homecore Ed25519 state chain packaged for compliance handoff.'));
},
});
return card({
title: 'Attestation bundle',
children: [
h('.t2', 'Packages both witness chains (SEED SHA-256 + homecore Ed25519) for regulated-deployment compliance handoff.'),
h('.mt', btn),
status,
],
});
}
@@ -0,0 +1,225 @@
// §4.7 Calibration Wizard — baseline → enroll → train → verify.
// Stepped wizard (15) against the ADR-151 calibration HTTP API.
import { h, clear, card, pill, statusPill, sectionHeader, bar, banner, button, mono } from '../ui.js';
export default {
meta: { title: 'Calibration' },
async render(root, ctx) {
const { api } = ctx;
const cal = api.calibration;
const state = { step: 1, room_id: '', seed: '', baseline_id: null, anchorIdx: 0, trainResult: null };
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) { state.step = step; paintStepper(); render(); }
function render() {
clear(body);
if (state.step === 1) body.appendChild(step1());
else if (state.step === 2) body.appendChild(step2());
else if (state.step === 3) body.appendChild(step3());
else if (state.step === 4) body.appendChild(step4());
else body.appendChild(step5());
}
// ── Step 1 — select room + SEED ────────────────────────────────
function step1() {
const roomInput = h('input.search', { placeholder: 'room_id (A-Za-z0-9_- , 164)', value: state.room_id });
const seedSel = h('select.inline');
const warn = h('div');
let seedList = [];
(async () => {
try { seedList = (await api.seeds()).filter((s) => s.online); }
catch (e) { warn.appendChild(banner('SEED fleet unavailable — ' + (e.message || e), 'red')); }
seedList.forEach((s) => seedSel.appendChild(h('option', { value: s.device_id }, `${s.device_id} (${s.zone})`)));
})();
const validate = () => {
const ok = /^[A-Za-z0-9_-]{1,64}$/.test(roomInput.value);
const seed = seedList.find((s) => s.device_id === seedSel.value);
clear(warn);
if (!ok) warn.appendChild(banner('room_id must match [A-Za-z0-9_-]{1,64}', 'red'));
else if (seed && seed.frame_rate_hz < 80) warn.appendChild(banner(`CSI ingest low (${seed.frame_rate_hz} Hz) — a broken pipeline silently fails calibration`, 'amber'));
return ok;
};
roomInput.addEventListener('input', validate);
seedSel.addEventListener('change', validate);
return card({
title: 'Step 1 — Select room and SEED', children: [
h('h3', 'room_id'), roomInput,
h('h3.mt', 'Serving SEED'), seedSel, warn,
h('.mt', button('Next', { variant: 'primary', onClick: () => { if (validate()) { state.room_id = roomInput.value; state.seed = seedSel.value; go(2); } } })),
],
});
}
// ── Step 2 — baseline capture ──────────────────────────────────
function step2() {
const progress = h('.bar', { style: { height: '14px' } }, h('span'));
const meta = h('.t2.mt');
const baselineLine = h('div');
const c = card({
title: 'Step 2 — Baseline capture (room must be empty)', children: [
progress, meta, baselineLine,
h('.mt', button('Restart', { variant: 'ghost', onClick: () => { cal.reset(); poll.started = false; } })),
],
});
const poll = { started: false, timer: null };
(async () => {
let startRes;
try { startRes = await cal.start(); }
catch (e) { clear(meta); meta.appendChild(banner('Baseline start failed — ' + (e.message || e), 'red')); return; }
state.baseline_id = (startRes && startRes.baseline_id) || state.baseline_id;
const loop = async () => {
let st;
try { st = await cal.status(); }
catch (e) { clear(meta); meta.appendChild(banner('Status unavailable — ' + (e.message || e), 'red')); return; }
progress.firstChild.style.width = (st.frames / st.target * 100).toFixed(0) + '%';
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.frames >= st.target) {
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;
}
poll.timer = setTimeout(loop, 600);
};
loop();
})();
c._cleanup = () => poll.timer && clearTimeout(poll.timer);
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();
return () => {};
},
};
function instruction(label) {
const map = {
empty: 'Leave the room empty and still.',
stand_still: 'Stand still in the centre of the room.',
sit: 'Sit down naturally.',
lie_down: 'Lie down (bed/sofa).',
breathe_slow: 'Breathe slowly and deeply.',
breathe_normal: 'Breathe at your normal resting rate.',
small_move: 'Make small fidgeting movements.',
sleep_posture: 'Adopt your typical sleeping posture and stay still.',
};
return map[label] || label;
}
@@ -0,0 +1,171 @@
// §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 ───────────────────────────────────────────
root.appendChild(h('h2.mt', 'Hailo-10H accelerator'));
root.appendChild(hailoStatus(cogs));
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: ${(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 = c.hef || [];
cfg.target_fps = c.throughput_fps || null;
}
return JSON.stringify(cfg, null, 2);
}
// ── 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) {
const hailoCogs = cogs.filter((c) => c.arch === 'hailo10');
const worker = h('.flex.gap-sm', statusPill('connected'), 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 hefRows = h('div',
h('.flex.spread', h('strong.mono', `${c.id} ${c.version}`), pill((c.throughput_fps || 0) + ' fps', 'purple')));
(c.hef || []).forEach((f) => hefRows.appendChild(h('.row', h('span.mono.purple', f), h('span.t2', 'loaded'))));
if (!(c.hef || []).length) hefRows.appendChild(h('.muted-empty', 'no .hef files loaded'));
body.appendChild(h('.mt', hefRows));
});
}
body.appendChild(h('.t3.mt', { style: { fontSize: '12px' } },
'RF Foundation Encoder (ADR-150) will appear here once available.'));
return card({ children: [body] });
}
@@ -0,0 +1,147 @@
// §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: a.cpu_pct + '%', label: 'CPU' }),
metric({ icon: '🧠', value: a.ram_pct + '%', label: 'RAM' }),
metric({ icon: '⚡', value: a.hailo_load_pct + '%', label: 'Hailo-10H load' }),
metric({ icon: '🌡', value: 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] });
}
function fmtUptime(s) {
const d = Math.floor(s / 86400), hh = Math.floor((s % 86400) / 3600);
return d > 0 ? `${d}d ${hh}h` : `${hh}h`;
}
function relAgo(iso) {
const s = Math.round((Date.now() - Date.parse(iso)) / 1000);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.round(s / 60) + 'm ago';
return Math.round(s / 3600) + 'h ago';
}
@@ -0,0 +1,240 @@
// §4.4 Entity & State Browser — live /api/states (real homecore REST).
//
// Entities grouped by domain (prefix before '.') in collapsible sections.
// Each row carries entity_id (mono), current state, last-changed (relTime),
// an INLINE provenanceBadge (§6 invariant 1 — SEED chain never collapsed),
// and a collapsible attributes JSON view. A keyword filter (entity_id +
// attribute keys/values) runs live; semantic search (ADR-132) is a future
// hint. State changes arrive over WebSocket (ctx.onEvent) — rows patch in
// place and flash; NEVER poll. The broadcast-channel lag indicator
// (ctx.onWs) warns when the subscriber falls behind the 4,096 capacity.
import {
h, clear, card, pill, sectionHeader, mono, provenanceBadge,
slideover, collapsible, lagIndicator, relTime, banner,
} from '../ui.js';
import { api, entityProvenance } from '../api.js';
export default {
meta: { title: 'Entities' },
async render(root, ctx) {
root.appendChild(sectionHeader('Entity & State Browser', 'Live /api/states — every entity, grouped by domain, with SEED provenance'));
// ── lag indicator (broadcast channel vs 4,096 capacity) ─────────
const lagHost = h('.flex.spread.mb');
const lagSlot = h('span', lagIndicator('connecting', false));
lagHost.appendChild(lagSlot);
root.appendChild(lagHost);
// ── search / filter controls ────────────────────────────────────
const search = h('input.search', {
type: 'text',
placeholder: 'Filter entities — id, attribute keys & values (case-insensitive)…',
});
const semantic = h('input.search', { type: 'text', placeholder: 'Semantic search (ADR-132)' });
semantic.disabled = true;
semantic.style.opacity = '0.5';
root.appendChild(h('.flex.wrap.mb', { style: { gap: '8px' } },
h('div', { style: { flex: '2', minWidth: '220px' } }, search),
h('div', { style: { flex: '1', minWidth: '180px' } }, semantic)));
// ── load live state view ────────────────────────────────────────
const listHost = h('div');
root.appendChild(listHost);
// Production /api/states now THROWS on failure — there is NO mock
// fallback. A failed load is an error state, not a DEMO substitution.
let states;
try {
states = await api.states();
} catch (e) {
listHost.appendChild(banner('/api/states unavailable — ' + (e && e.message ? e.message : e), 'red'));
return () => {};
}
if (!Array.isArray(states)) states = [];
// Demo mode legitimately serves fixtures (demoFlags.states is set by a
// successful api.states() in demo mode) — label that, not a fallback.
if (api.isDemo('states')) {
root.insertBefore(banner('Demo mode — showing contract-conformant fixture entities (§7.1).', 'amber'), listHost);
}
// index by entity_id so WS patches are O(1)
const byId = new Map();
states.forEach((s) => byId.set(s.entity_id, s));
// per-entity row controllers (set state text + flash)
const rows = new Map();
function render() {
clear(listHost);
const q = search.value.trim().toLowerCase();
const groups = groupByDomain([...byId.values()], q);
if (!groups.size) {
listHost.appendChild(h('.muted-empty', q ? 'No entities match the filter.' : 'No entities reported.'));
return;
}
// stable alphabetical domain order
[...groups.keys()].sort().forEach((domain) => {
const ents = groups.get(domain).sort((a, b) => a.entity_id.localeCompare(b.entity_id));
const header = h('.flex.gap-sm', h('strong.mono', domain), pill(ents.length, 'cyan'));
const section = collapsible(header, () => {
const body = h('div');
ents.forEach((e) => body.appendChild(entityRow(e)));
return body;
}, true);
listHost.appendChild(card({ children: [section] }));
});
}
function entityRow(e) {
const stateText = h('span.t1.mono', String(e.state));
const changed = h('span.t3', relTime(e.last_changed));
const top = h('.flex.spread', { style: { cursor: 'pointer', gap: '12px' }, onClick: () => openDetail(e) },
h('.flex.wrap.gap-sm', { style: { flex: '1', minWidth: '0' } },
mono(e.entity_id),
stateText,
changed),
// SEED provenance badge — INLINE, never collapsed (§6 invariant 1)
provenanceBadge(entityProvenance(e)));
const attrs = collapsible(h('span.t2', 'attributes'),
() => h('pre.json', JSON.stringify(e.attributes || {}, null, 2)), false);
const wrap = h('.entity-row', { style: { padding: '8px 0', borderBottom: '0.67px solid var(--border)' } }, top, attrs);
rows.set(e.entity_id, { stateText, changed, wrap });
return wrap;
}
function openDetail(e) {
const chain = contextChain(e.context, byId);
const content = h('div',
h('.kv',
h('span.k', 'entity_id'), h('span.v.mono', e.entity_id),
h('span.k', 'state'), h('span.v.mono', String(e.state)),
h('span.k', 'last changed'), h('span.v', relTime(e.last_changed)),
h('span.k', 'last updated'), h('span.v', relTime(e.last_updated))),
h('.mt', h('h3', 'Provenance'), provenanceBadge(entityProvenance(e))),
h('.mt', h('h3', 'Context causality'), chain),
h('.mt', h('h3', 'Attributes'), h('pre.json', JSON.stringify(e.attributes || {}, null, 2))));
slideover(e.entity_id, content);
}
render();
search.addEventListener('input', render);
// ── live WebSocket: patch state in place + flash (never poll) ────
const unEvent = ctx.onEvent((ev) => {
if (!ev || ev.event_type !== 'state_changed' || !ev.entity_id) return;
const cur = byId.get(ev.entity_id);
const ns = ev.new_state || {};
if (cur) {
// merge live fields onto the existing record
cur.state = ns.state != null ? ns.state : cur.state;
if (ns.attributes) cur.attributes = ns.attributes;
if (ns.last_changed) cur.last_changed = ns.last_changed;
if (ns.last_updated) cur.last_updated = ns.last_updated;
if (ns.context) cur.context = ns.context;
patchRow(ev.entity_id);
} else {
// a newly-appeared entity — fold it in and re-render the group
byId.set(ev.entity_id, {
entity_id: ev.entity_id,
state: ns.state != null ? ns.state : 'unknown',
attributes: ns.attributes || {},
last_changed: ns.last_changed || new Date().toISOString(),
last_updated: ns.last_updated || new Date().toISOString(),
context: ns.context || { id: null, user_id: null, parent_id: null },
});
render();
patchRow(ev.entity_id);
}
});
function patchRow(id) {
const e = byId.get(id);
const r = rows.get(id);
if (!e || !r) return;
r.stateText.textContent = String(e.state);
r.changed.textContent = relTime(e.last_changed);
// flash cyan then revert after 800ms (§4.4 live feedback)
r.stateText.style.color = 'var(--cyan)';
r.stateText.style.transition = 'none';
setTimeout(() => {
r.stateText.style.transition = 'color .6s ease';
r.stateText.style.color = '';
}, 800);
}
// ── broadcast-channel lag indicator ─────────────────────────────
const unWs = ctx.onWs((st) => {
clear(lagSlot);
lagSlot.appendChild(lagIndicator(st.state, st.lagged));
if (st.lagged) {
lagSlot.title = 'Subscriber behind the 4,096-event capacity — some state_changed events were dropped';
}
});
return () => { unEvent(); unWs(); };
},
};
/**
* Group entities by domain (prefix before the first '.'), applying the
* keyword filter across entity_id AND attribute keys/values.
*/
function groupByDomain(entities, q) {
const groups = new Map();
for (const e of entities) {
if (q && !matches(e, q)) continue;
const dot = e.entity_id.indexOf('.');
const domain = dot > 0 ? e.entity_id.slice(0, dot) : '(no domain)';
if (!groups.has(domain)) groups.set(domain, []);
groups.get(domain).push(e);
}
return groups;
}
/** Case-insensitive match across entity_id, state and attribute keys/values. */
function matches(e, q) {
if (e.entity_id.toLowerCase().includes(q)) return true;
if (String(e.state).toLowerCase().includes(q)) return true;
const attrs = e.attributes || {};
for (const [k, v] of Object.entries(attrs)) {
if (k.toLowerCase().includes(q)) return true;
try {
if (String(typeof v === 'object' ? JSON.stringify(v) : v).toLowerCase().includes(q)) return true;
} catch (_) { /* circular/unstringifiable — skip */ }
}
return false;
}
/**
* Render the Context causality chain (context.id → parent_id) as a mono
* breadcrumb trail. Walks parent_id up through known contexts when the
* parent entity is present, otherwise shows the raw id.
*/
function contextChain(ctxObj, byId) {
if (!ctxObj || !ctxObj.id) return h('span.t3', 'no context');
const seen = new Set();
const ids = [];
let cur = ctxObj;
while (cur && cur.id && !seen.has(cur.id)) {
seen.add(cur.id);
ids.unshift(cur.id);
if (!cur.parent_id) break;
ids.unshift(cur.parent_id);
seen.add(cur.parent_id);
cur = findContext(cur.parent_id, byId);
}
const trail = h('.flex.wrap.gap-sm');
ids.forEach((id, i) => {
if (i > 0) trail.appendChild(h('span.arr.t3', '→'));
trail.appendChild(mono(id));
});
return trail;
}
function findContext(id, byId) {
for (const e of byId.values()) {
if (e.context && e.context.id === id) return e.context;
}
return null;
}
@@ -0,0 +1,308 @@
// §4.8 Event Bus & Automation Feed — ADR-131 / ADR-129.
//
// Live event stream (seeded from /api/events, then prepended live from
// the shared WS bus — never polled, §2/§4.4), a context-causality
// breadcrumb on row expand (Context.id → parent_id → grandparent_id),
// and a trigger→condition→action automation builder (ADR-129 scope:
// UI-only, no backend persistence — rules live in a local array).
import {
h, clear, card, pill, statusPill, sectionHeader, mono, relTime,
collapsible, lagIndicator, button, banner,
} from '../ui.js';
const MAX_ROWS = 200; // virtualization-lite: cap DOM rows, drop oldest.
// event-type → pill colour variant (§4.8).
const VARIANT = {
StateChanged: 'cyan',
EntityRegistered: 'green',
ConfigReloaded: 'purple',
};
function typePill(type) {
return pill(type, VARIANT[type] || 'grey');
}
// A live WS event carries event_type:'state_changed'; normalise it into
// the same record shape as api.recentEvents() so the row renderer is one
// code path.
function normalizeLive(evt) {
return {
type: 'StateChanged',
entity_id: evt.entity_id,
old_state: evt.old_state,
new_state: evt.new_state,
ts: new Date().toISOString(),
user_id: null,
context: { id: null, parent_id: null, grandparent_id: null },
source: 'live',
_live: true,
};
}
const domainOf = (id) => String(id || '').split('.')[0] || '';
export default {
meta: { title: 'Events' },
async render(root, ctx) {
const { api } = ctx;
const unsubs = [];
root.appendChild(sectionHeader('Event Bus & Automation', 'Live entity events + causality + automation builder (ADR-131 §4.8, ADR-129)'));
if (api.isDemo('events')) {
root.appendChild(banner('DEMO — event history is contract-conformant mock data until the live /api/events feed lands (§7.1). New rows still arrive over the WS bus.', 'amber'));
}
// ── live lag indicator (top, fed by the shared WS bus) ──────────
const lagHost = h('span');
const paintLag = (st) => { clear(lagHost); lagHost.appendChild(lagIndicator(st.state, st.lagged)); };
unsubs.push(ctx.onWs(paintLag)); // fires immediately
// ── filter bar (mirrors the Cog Store .search field) ────────────
let filter = '';
const search = h('input.search', {
type: 'text',
placeholder: 'Filter by entity domain · event type · source (e.g. "sensor", "ConfigReloaded", "seed-")',
});
search.addEventListener('input', () => { filter = search.value.trim().toLowerCase(); applyFilter(); });
const list = h('.event-stream', { style: { maxHeight: '460px', overflowY: 'auto' } });
let rows = []; // { record, node } newest-first, capped to MAX_ROWS.
function matches(rec) {
if (!filter) return true;
const hay = [rec.type, rec.entity_id, domainOf(rec.entity_id), rec.source, rec.user_id]
.filter(Boolean).join(' ').toLowerCase();
return hay.includes(filter);
}
function applyFilter() {
for (const r of rows) r.node.classList.toggle('hidden', !matches(r.record));
}
function prepend(rec) {
const node = eventRow(rec);
rows.unshift({ record: rec, node });
list.insertBefore(node, list.firstChild);
node.classList.toggle('hidden', !matches(rec));
while (rows.length > MAX_ROWS) {
const old = rows.pop();
if (old.node.parentNode) old.node.parentNode.removeChild(old.node);
}
}
// seed from history (oldest first → prepend so newest ends on top).
// Wrap ONLY the history load: a missing/unwired recorder must NOT fail
// the panel — render an inline note and continue with an empty history.
// The live ctx.onEvent feed (below) attaches regardless (§12 W3).
let history = [];
let historyNote = null;
try {
history = await api.recentEvents(40);
} catch (e) {
history = [];
historyNote = banner('Event history unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (recorder not yet wired — ADR-131 §12 W3)' : ''), 'amber');
}
for (let i = history.length - 1; i >= 0; i--) prepend(history[i]);
if (!rows.length) list.appendChild(h('.muted-empty', 'No events yet — live events will appear here as they arrive.'));
// live events prepend as they arrive (never poll).
unsubs.push(ctx.onEvent((evt) => {
// strip the placeholder empty-state once real rows arrive.
const empty = list.querySelector('.muted-empty');
if (empty) empty.remove();
prepend(normalizeLive(evt));
}));
root.appendChild(card({
title: 'Live event stream',
children: [historyNote, h('.flex.spread.mb', h('span.t2', 'Newest first · capped to ' + MAX_ROWS + ' rows'), lagHost), search, list],
}));
// ── automation builder (ADR-129) ────────────────────────────────
root.appendChild(automationBuilder(api));
return () => { unsubs.forEach((u) => { try { u(); } catch {} }); };
},
};
// ── event row + causality breadcrumb ──────────────────────────────────
function eventRow(rec) {
const head = h('.flex.gap-sm.wrap',
typePill(rec.type),
h('strong.mono', rec.entity_id),
rec.type === 'StateChanged'
? h('span.t2', mono(rec.old_state == null ? '∅' : rec.old_state), h('span.arr.t3', { style: { margin: '0 6px' } }, '→'), mono(rec.new_state == null ? '∅' : rec.new_state))
: null,
h('span', { style: { marginLeft: 'auto' } }, h('small.ts', relTime(rec.ts))),
rec.user_id ? pill('@' + rec.user_id, 'amber') : h('small.ts', 'system'),
rec.source ? h('span.mono.t3', rec.source) : null);
return h('.event-row', { style: { padding: '6px 0', borderBottom: '0.67px solid var(--border)' } },
collapsible(head, () => causalityBreadcrumb(rec.context), false));
}
function causalityBreadcrumb(c) {
const wrap = h('.causality', { style: { padding: '8px 0 4px' } });
wrap.appendChild(h('span.t2', { style: { marginRight: '8px' } }, 'Context chain'));
const chain = [
['id', c && c.id],
['parent', c && c.parent_id],
['grandparent', c && c.grandparent_id],
].filter(([, v]) => v != null);
if (!chain.length) {
wrap.appendChild(h('span.t3', 'no context recorded for this event'));
return wrap;
}
chain.forEach(([label, val], i) => {
if (i > 0) wrap.appendChild(h('span.arr.t3', { style: { margin: '0 8px' } }, '→'));
wrap.appendChild(h('span.flex.gap-sm', { style: { display: 'inline-flex' } },
h('small.ts', label), mono(val)));
});
return wrap;
}
// ── automation builder (trigger → condition → action) ─────────────────
const TRIGGERS = [
{ id: 'state_changed', label: 'state_changed on RoomState entity' },
{ id: 'seed_reflex', label: 'SEED reflex rule fired' },
{ id: 'custom_event', label: 'custom domain_event topic' },
];
const REFLEX_RULES = ['fragility_alarm', 'hd_anomaly_indicator'];
const ACTION_KINDS = [
{ id: 'call_service', label: 'Call service' },
{ id: 'fire_event', label: 'Fire domain event' },
];
function automationBuilder(api) {
const rules = [];
const listHost = h('div');
// Default callable-service options; enriched asynchronously from the
// live service registry when reachable (failures are swallowed — the
// builder stays usable with defaults, and we never leave a dangling
// rejected promise in production).
const serviceOpts = ['light.turn_on', 'light.turn_off', 'notify.mobile', 'homecore.recalibrate_room'];
Promise.resolve()
.then(() => api.services())
.then((services) => {
(services || []).forEach((s) => {
const name = (s.domain && s.service) ? `${s.domain}.${s.service}` : String(s.name || s.id || s);
if (name && !serviceOpts.includes(name)) { serviceOpts.push(name); serviceSel.appendChild(h('option', { value: name }, name)); }
});
})
.catch(() => {});
// ── trigger editor ──
const triggerSel = sel(TRIGGERS.map((t) => [t.id, t.label]));
const thresholdInput = h('input.search.mono', { type: 'text', placeholder: 'threshold expression — e.g. anomaly.value > 0.8' });
const reflexSel = sel(REFLEX_RULES.map((r) => [r, r]));
const customInput = h('input.search.mono', { type: 'text', placeholder: 'domain_event topic — e.g. presence.regime_change' });
const triggerExtra = h('div', { style: { marginTop: '8px' } });
function paintTriggerExtra() {
clear(triggerExtra);
if (triggerSel.value === 'state_changed') triggerExtra.appendChild(thresholdInput);
else if (triggerSel.value === 'seed_reflex') triggerExtra.appendChild(field('Reflex rule', reflexSel));
else triggerExtra.appendChild(customInput);
}
triggerSel.addEventListener('change', paintTriggerExtra);
paintTriggerExtra();
// ── condition editor ──
const conditionInput = h('input.search.mono', { type: 'text', placeholder: 'condition expression — e.g. room.living_room.presence == "occupied"' });
// ── action editor ──
const actionSel = sel(ACTION_KINDS.map((a) => [a.id, a.label]));
const serviceSel = sel(serviceOpts.map((s) => [s, s]));
const eventInput = h('input.search.mono', { type: 'text', placeholder: 'domain event to fire — e.g. automation.lr_night_dim' });
const actionExtra = h('div', { style: { marginTop: '8px' } });
function paintActionExtra() {
clear(actionExtra);
if (actionSel.value === 'call_service') actionExtra.appendChild(field('Service', serviceSel));
else actionExtra.appendChild(eventInput);
}
actionSel.addEventListener('change', paintActionExtra);
paintActionExtra();
function buildTrigger() {
if (triggerSel.value === 'state_changed') return { kind: 'state_changed', entity: 'RoomState', threshold: thresholdInput.value.trim() };
if (triggerSel.value === 'seed_reflex') return { kind: 'seed_reflex', rule: reflexSel.value };
return { kind: 'custom_event', topic: customInput.value.trim() };
}
function buildAction() {
if (actionSel.value === 'call_service') return { kind: 'call_service', service: serviceSel.value };
return { kind: 'fire_event', event: eventInput.value.trim() };
}
const addBtn = button('Add automation', {
variant: 'primary',
onClick: () => {
rules.push({ trigger: buildTrigger(), condition: conditionInput.value.trim(), action: buildAction() });
thresholdInput.value = ''; customInput.value = ''; conditionInput.value = ''; eventInput.value = '';
renderRules();
},
});
function renderRules() {
clear(listHost);
if (!rules.length) { listHost.appendChild(h('.muted-empty', 'No automations defined yet (UI-only — not persisted).')); return; }
rules.forEach((r, i) => listHost.appendChild(ruleCard(r, i, () => { rules.splice(i, 1); renderRules(); })));
}
renderRules();
const builder = card({
title: 'Automation builder',
children: [
h('.t3.mb', 'Trigger → condition → action (ADR-129). UI scope only — assembled rules are held locally, not persisted to the appliance.'),
h('.grid.cols-3',
card({ title: 'Trigger', tint: null, children: [field('When', triggerSel), triggerExtra] }),
card({ title: 'Condition', children: [field('And', conditionInput)] }),
card({ title: 'Action', children: [field('Then', actionSel), actionExtra] })),
h('.flex.mt', addBtn),
],
});
return h('div', builder, card({ title: 'Defined automations', children: [listHost] }));
}
function ruleCard(r, i, onDelete) {
return card({
children: [
h('.flex.spread',
h('strong', 'Automation #' + (i + 1)),
button('Remove', { variant: 'ghost', onClick: onDelete })),
h('.flex.gap-sm.wrap.mt',
pill('TRIGGER', 'cyan'), triggerSummary(r.trigger)),
r.condition
? h('.flex.gap-sm.wrap.mt', pill('IF', 'amber'), mono(r.condition))
: h('.flex.gap-sm.wrap.mt', pill('IF', 'grey'), h('span.t3', 'always')),
h('.flex.gap-sm.wrap.mt',
pill('ACTION', 'purple'), actionSummary(r.action)),
],
});
}
function triggerSummary(t) {
if (t.kind === 'state_changed') return h('span', mono('RoomState'), ' ', t.threshold ? mono(t.threshold) : h('span.t3', '(any change)'));
if (t.kind === 'seed_reflex') return h('span', h('span.t2', 'reflex '), mono(t.rule || '—'));
return h('span', h('span.t2', 'event '), mono(t.topic || '—'));
}
function actionSummary(a) {
if (a.kind === 'call_service') return h('span', h('span.t2', 'call '), mono(a.service || '—'));
return h('span', h('span.t2', 'fire '), mono(a.event || '—'));
}
// ── small form helpers ────────────────────────────────────────────────
function sel(pairs) {
const s = h('select.inline', { style: { width: '100%' } });
for (const [val, label] of pairs) {
const o = document.createElement('option');
o.value = val; o.textContent = label;
s.appendChild(o);
}
return s;
}
function field(label, control) {
return h('label', { style: { display: 'block', marginTop: '8px' } },
h('span.k.t2', { style: { display: 'block', marginBottom: '4px', fontSize: '12.5px' } }, label),
control);
}
@@ -0,0 +1,198 @@
// §4.2 SEED Fleet overview + §4.3 SEED Fleet Map (node topology +
// ESP-NOW mesh + cross-SEED event dedup) + ADR-105 federation config.
//
// One panel covering: the fleet card grid, the v0→SEED→ESP32 node
// hierarchy, the mesh-link table, the cross-SEED fusion badges, and the
// federation round config — with the §3.3 "model deltas only — never raw
// CSI" invariant surfaced prominently (ADR-105 privacy guarantee).
import { h, card, pill, statusPill, sectionHeader, relTime, banner } from '../ui.js';
export default {
meta: { title: 'SEED Fleet' },
async render(root, ctx) {
const { api } = ctx;
root.appendChild(sectionHeader('SEED Fleet', 'Cross-SEED topology, ESP-NOW mesh & ADR-105 federation'));
// ── Load seeds + federation independently so one failing upstream
// doesn't blank the whole panel (ADR-131 §2.2 / §11.11). ───────
let seeds = null, fed = null;
try { seeds = await api.seeds(); } catch (e) {
root.appendChild(banner('SEED fleet unavailable — ' + (e.message || e)
+ (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
}
try { fed = await api.federation(); } catch (e) {
root.appendChild(banner('SEED fleet unavailable — ' + (e.message || e)
+ (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
}
if (api.isDemo('fleet')) {
root.appendChild(h('.banner.amber',
'DEMO — the SEED HTTPS API and the ADR-105 federation service are not served by this homecore-server binary. '
+ 'These panels render against their defined contract with contract-conformant mock data (ADR-131 §7.1).'));
}
// ── §4.2 SEED fleet overview ──────────────────────────────────────
if (seeds) {
root.appendChild(h('h2', 'Fleet overview'));
const grid = h('.grid.cols-3');
seeds.forEach((s) => grid.appendChild(seedCard(s, ctx)));
root.appendChild(grid);
// ── §4.3 Node hierarchy (v0 → SEED → ESP32) ─────────────────────
root.appendChild(card({ title: 'Node hierarchy', children: [hierarchy(seeds)] }));
}
if (fed) {
// ── §4.3 ESP-NOW mesh links ─────────────────────────────────────
root.appendChild(card({ title: 'ESP-NOW mesh links', children: [meshLinks(fed.mesh_links)] }));
// ── Cross-SEED event dedup / fusion ─────────────────────────────
root.appendChild(card({ title: 'Cross-SEED event dedup', children: [fusionBadges(fed.fused_events)] }));
// ── ADR-105 federation config ───────────────────────────────────
root.appendChild(federationConfig(fed));
}
return () => {};
},
};
// ── §4.2 SEED card ──────────────────────────────────────────────────
function seedCard(s, ctx) {
const offline = !s.online;
return card({
tint: offline ? 'red' : null, clickable: true,
onClick: () => ctx.navigate('#/seed/' + s.device_id),
children: [
h('.flex.spread',
h('strong.mono', s.device_id),
statusPill(s.online ? 'online' : 'offline')),
h('.kv.mt',
h('span.k', 'Zone'), h('span.v', s.zone),
h('span.k', 'Firmware'), h('span.v.mono', s.firmware),
h('span.k', 'Epoch'), h('span.v.purple', String(s.epoch)),
h('span.k', 'Vectors'), h('span.v', (s.vector_count || 0).toLocaleString()),
h('span.k', 'Last ingest'), h('span.v', relTime(s.last_ingest))),
h('.flex.wrap.gap-sm.mt',
s.witness_valid ? pill('witness valid', 'green') : pill('witness invalid', 'red')),
sensorSummary(s.sensors),
],
});
}
function sensorSummary(sensors) {
if (!sensors) return h('.muted-empty', 'sensors offline');
return h('.flex.wrap.gap-sm.mt',
pill('PIR ' + (sensors.pir.motion ? 'motion' : 'still'), sensors.pir.motion ? 'amber' : 'grey'),
pill('door ' + (sensors.reed.open ? 'open' : 'closed'), sensors.reed.open ? 'amber' : 'grey'),
pill(sensors.bme280.temp_c + '°C', 'cyan'));
}
// ── §4.3 Node hierarchy diagram (nested indented rows) ──────────────
// v0 Appliance (ROOT) → SEEDs grouped by zone → ESP32 nodes (leaves).
function hierarchy(seeds) {
const wrap = h('.mono', { style: { fontSize: '12.5px', lineHeight: '1.9' } });
// ROOT — the v0 appliance.
wrap.appendChild(treeRow(0, '●', 'cog-v0-appliance', pill('ROOT', 'purple'), null));
// Second tier — SEEDs grouped by .zone.
const byZone = groupBy(seeds, (s) => s.zone || 'unzoned');
const zones = Object.keys(byZone);
zones.forEach((zone, zi) => {
const lastZone = zi === zones.length - 1;
wrap.appendChild(treeRow(1, lastZone ? '└─' : '├─', zone, pill('zone', 'cyan'), null, true));
const zoneSeeds = byZone[zone];
zoneSeeds.forEach((s, si) => {
const lastSeed = si === zoneSeeds.length - 1;
wrap.appendChild(treeRow(2, lastSeed ? '└─' : '├─', s.device_id,
statusPill(s.online ? 'online' : 'offline'), null));
// Leaves — the ESP32 nodes attached to this SEED.
const nodes = (s.ingest && s.ingest.esp32) || [];
if (!nodes.length) {
wrap.appendChild(treeRow(3, '·', '(no ESP32 nodes)', null, null, true));
}
nodes.forEach((n, ni) => {
const lastNode = ni === nodes.length - 1;
wrap.appendChild(treeRow(3, lastNode ? '└─' : '├─', n.node_id,
pill(n.rate_hz + ' Hz', 'grey'), n.packet));
});
});
});
return wrap;
}
function treeRow(depth, connector, label, badge, suffix, muted) {
const row = h('.flex.gap-sm', { style: { paddingLeft: (depth * 18) + 'px' } });
row.appendChild(h('span.t3', connector));
row.appendChild(h(muted ? 'span.t3' : 'span', label));
if (badge) row.appendChild(badge);
if (suffix) row.appendChild(h('span.t3', suffix));
return row;
}
// ── §4.3 ESP-NOW mesh links (dashed rows coloured by .health) ───────
function meshLinks(links) {
if (!links || !links.length) return h('.muted-empty', 'no mesh links reported');
const wrap = h('div');
const colour = { green: 'green', amber: 'amber', red: 'red' };
links.forEach((l) => {
const k = colour[l.health] || 'grey';
wrap.appendChild(h('.flex.gap-sm', { style: { padding: '6px 0' } },
h('span.mono', l.a),
h(`span.${k}`, { style: { letterSpacing: '1px' } }, '╌╌╌'),
h('span.mono', l.b),
pill(l.health, k)));
});
return wrap;
}
// ── Cross-SEED event dedup — fusion badges (kind + n contributing) ──
function fusionBadges(events) {
if (!events || !events.length) return h('.muted-empty', 'no fused cross-SEED events');
const wrap = h('.flex.wrap.gap-sm');
events.forEach((e) => {
const seeds = (e.seeds || []).join(', ');
wrap.appendChild(h('span.flex.gap-sm', { style: { alignItems: 'center' } },
pill(e.kind, 'cyan'),
pill(e.n + ' SEEDs', 'purple'),
h('span.t2.mono', { style: { fontSize: '11px' } }, seeds)));
});
return wrap;
}
// ── ADR-105 federation config ───────────────────────────────────────
function federationConfig(fed) {
const body = h('div');
// CRITICAL invariant — the "model deltas only, never raw CSI" guarantee.
body.appendChild(h('.banner.purple',
{ style: { background: 'var(--purple-d)', color: 'var(--purple)', border: '0.67px solid var(--purple)' } },
h('strong', 'Federation invariant: '),
h('span.mono', fed.invariant)));
body.appendChild(h('.kv.mt',
h('span.k', 'Coordinator SEED'), h('span.v.mono', fed.coordinator),
h('span.k', 'Round'), h('span.v.purple', String(fed.round)),
h('span.k', 'k_healthy'), h('span.v', String(fed.k_healthy)),
h('span.k', 'Delta status'), statusPill(fed.delta_status === 'exchanging' ? 'updating' : fed.delta_status),
h('span.k', 'Krum (f)'), h('span.v', String(fed.krum && fed.krum.f)),
h('span.k', 'Krum mode'), h('span.v', fed.krum && fed.krum.multi ? 'multi-Krum' : 'Krum'),
h('span.k', 'Cadence'), h('span.v', (fed.cadence_min != null ? fed.cadence_min + ' min' : '—'))));
return card({ title: 'Federation config (ADR-105)', accent: true, children: [body] });
}
// ── helpers ─────────────────────────────────────────────────────────
function groupBy(arr, keyFn) {
const out = {};
for (const item of arr) {
const k = keyFn(item);
(out[k] || (out[k] = [])).push(item);
}
return out;
}
@@ -0,0 +1,110 @@
// §4.5 RoomState / Sensing Panel — mixture-of-specialists output.
// Per-room cards from GET /api/v1/room/state?bank=<room_id>.
//
// UX invariants (§4.5/§6): STALE and VETOED are never subtle; veto-
// suppressed values render as withheld, NOT zero; null specialists are
// "Not trained" (calibrate to enable), visually distinct from errors.
import { h, card, pill, statusPill, sectionHeader, bar, confidenceBar, banner, button } from '../ui.js';
export default {
meta: { title: 'Rooms' },
async render(root, ctx) {
const { api } = ctx;
root.appendChild(sectionHeader('RoomState / Sensing', 'Highest-level per-room sensing from the calibration mixture-of-specialists'));
let rooms;
try {
rooms = await api.roomStates();
} catch (e) {
root.appendChild(banner(`RoomState unavailable — ${e && e.message ? e.message : e}. ${e && e.upstreamUnavailable ? 'Calibration service (ADR-151) not reachable through the gateway.' : ''}`, 'red'));
return () => {};
}
if (api.isDemo('rooms')) root.appendChild(banner('DEMO mode (?demo=1) — fixture RoomState, not live calibration output (ADR-131 §2.2).', 'amber'));
if (!rooms.length) { root.appendChild(h('.muted-empty', 'No calibrated rooms yet — run the Calibration wizard to enable sensing.')); return () => {}; }
const grid = h('.grid.cols-2');
rooms.forEach((r) => grid.appendChild(roomCard(r, ctx)));
root.appendChild(grid);
return () => {};
},
};
function roomCard(r, ctx) {
const tint = r.stale ? 'amber' : (r.vetoed ? 'red' : null);
const children = [
h('.flex.spread',
h('strong.mono', r.room_id),
h('.flex.gap-sm',
r.seeds.length > 1 ? pill(r.seeds.length + ' seeds fused', 'purple') : null,
r.vetoed ? pill('veto active', 'red') : null,
r.stale ? pill('stale', 'amber') : null)),
];
// STALE banner — must never be subtle (§4.5)
if (r.stale) {
children.push(banner('Bank stale — baseline has changed', 'amber',
button('Recalibrate room', { variant: 'ghost', onClick: () => ctx.navigate('#/calibration') })));
}
if (r.vetoed) {
children.push(banner('Anomaly veto active — implausible window; vitals/posture withheld', 'red'));
}
children.push(specRow('Presence', presenceChip(r.presence), r.presence));
children.push(specRow('Posture', postureView(r), r.posture));
children.push(vitalRow('Breathing', r.breathing_bpm, 'BPM', [6, 30], r));
children.push(vitalRow('Heart rate', r.heart_bpm, 'BPM', [40, 120], r));
children.push(specRow('Restlessness', barOr(r.restlessness, 1), r.restlessness));
children.push(anomalyRow(r.anomaly));
return card({ tint, children });
}
function specRow(label, valueNode, spec) {
const right = h('.flex.gap-sm');
right.appendChild(valueNode);
if (spec && spec.confidence != null) right.appendChild(confidenceBar(spec.confidence));
return h('.row', h('span.k', label), right);
}
function presenceChip(p) {
if (!p) return notTrainedNode(); // null = not trained
return statusPill(p.value); // occupied → green, absent → grey
}
function postureView(r) {
if (r.posture === null) return notTrainedNode(); // not trained
if (r.vetoed && (!r.posture || r.posture.value == null)) return withheld(); // suppressed, not zero
if (!r.posture || r.posture.value == null) return withheld();
return statusPill(r.posture.value);
}
function vitalRow(label, spec, unit, range, r) {
let valueNode;
if (spec === null) valueNode = notTrainedNode();
else if (r.vetoed && (spec.value == null)) valueNode = withheld();
else if (spec.value == null) valueNode = withheld();
else valueNode = h('span.cyan', `${spec.value} ${unit} `, h('span.t3', `(${range[0]}${range[1]})`));
return specRow(label, valueNode, spec);
}
function anomalyRow(a) {
if (!a) return specRow('Anomaly', notTrainedNode(), null);
const over = a.value > (a.threshold ?? 0.8);
const b = bar(a.value, 1, [{ lt: a.threshold ?? 0.8, color: 'green' }, { lt: 1.01, color: 'red' }]);
const wrap = h('div', { style: { width: '160px' } }, b,
h('small.ts', over ? 'anomalous' : 'normal', ` · ${a.value}`));
return specRow('Anomaly', wrap, a);
}
function barOr(spec, max) {
if (spec === null) return notTrainedNode();
if (!spec || spec.value == null) return withheld();
const wrap = h('div', { style: { width: '140px' } }, bar(spec.value, max), h('small.ts', String(spec.value)));
return wrap;
}
function notTrainedNode() {
return h('span.t3', { title: 'null specialist — calibrate to enable' }, 'Not trained');
}
function withheld() {
return h('span.red', { title: 'suppressed by veto — value withheld, not zero' }, '— withheld');
}
@@ -0,0 +1,256 @@
// §4.2 SEED Detail View — the per-device deep dive (route #/seed/<id>).
//
// Vector store + witness chain (Ed25519 custody) + onboard sensors +
// reflex rules + cognitive (boundary fragility) analysis + ingest
// pipeline. Backed by the SEED HTTPS API (mock until the live endpoint
// lands → DEMO badge, §7.1). Honesty invariants (§6): null fragility /
// null sensors render muted, never as zero.
import {
h, card, pill, statusPill, sectionHeader, bar, banner, button, mono, kv,
sparkline, errorCard, relTime,
} from '../ui.js';
export default {
meta: { title: 'SEED Detail' },
async render(root, ctx) {
const { api } = ctx;
let s;
try {
s = await api.seed(ctx.params.id);
} catch (e) {
root.appendChild(sectionHeader('SEED Detail', ctx.params.id));
root.appendChild(banner('SEED unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
root.appendChild(card({ children: [button('← Back to fleet', { onClick: () => ctx.navigate('#/fleet') })] }));
return () => {};
}
if (!s) {
root.appendChild(sectionHeader('SEED Detail', ctx.params.id));
root.appendChild(errorCard(`No SEED with device_id "${ctx.params.id}"`));
root.appendChild(card({ children: [button('← Back to fleet', { onClick: () => ctx.navigate('#/fleet') })] }));
return () => {};
}
root.appendChild(sectionHeader('SEED Detail', s.zone));
if (api.isDemo('fleet')) {
root.appendChild(banner('DEMO — SEED HTTPS API not served by this binary; showing contract-conformant data (§7.1).', 'amber'));
}
root.appendChild(identityCard(s, ctx));
root.appendChild(vectorStoreCard(s));
root.appendChild(witnessCard(s));
root.appendChild(sensorsCard(s));
root.appendChild(reflexCard(s));
root.appendChild(cognitionCard(s));
root.appendChild(ingestCard(s));
return () => {};
},
};
// ── 1. identity header ────────────────────────────────────────────────
function identityCard(s, ctx) {
return card({
children: [
sectionHeader(s.device_id, `Firmware ${s.firmware} · ${s.zone}`),
h('.flex.spread',
statusPill(s.online ? 'online' : 'offline'),
button('← Fleet', { onClick: () => ctx.navigate('#/fleet') })),
kv([
['Firmware', mono(s.firmware)],
['Paired', pill('paired', 'green')],
['Conn mode', pill(s.conn, s.conn === 'usb' ? 'cyan' : 'purple')],
['Zone', s.zone],
]),
],
});
}
// ── 2. vector store ───────────────────────────────────────────────────
function vectorStoreCard(s) {
const over = s.storage_budget > 0 && s.storage_used / s.storage_budget > 0.8;
const storeBar = bar(s.storage_used, s.storage_budget, [{ lt: 0.8, color: 'cyan' }, { lt: 1.01, color: 'amber' }]);
const series = Array.from({ length: 24 }, (_, i) => s.knn_latency_ms != null ? +(s.knn_latency_ms + Math.sin(i / 2) * 0.4).toFixed(2) : 0);
let compacted = false;
const compactBtn = button('Compact now', {
onClick: () => {
if (compacted) return;
compacted = true;
compactBtn.disabled = true;
compactBtn.textContent = 'Compaction queued';
console.log('[seed-detail] POST /api/v1/store/compact', s.device_id); // production call
},
});
return card({
title: 'Vector Store',
children: [
kv([
['Vectors', s.vector_count.toLocaleString()],
['Dimension', mono(String(s.vector_dim))],
['kNN latency', s.knn_latency_ms != null ? h('span.cyan', s.knn_latency_ms + ' ms') : h('span.t3', '— offline')],
['Epoch', h('span.purple', String(s.epoch))],
['kNN latency trend', sparkline(series, { w: 160, hgt: 28 })],
]),
h('.flex.spread.mt',
h('span.t2', `Storage — ${s.storage_used.toLocaleString()} / ${s.storage_budget.toLocaleString()}`),
over ? pill('budget > 80%', 'amber') : pill('headroom', 'green')),
storeBar,
over ? banner('Vector store nearing budget — compaction recommended.', 'amber') : null,
h('.mt', compactBtn),
],
});
}
// ── 3. witness chain ──────────────────────────────────────────────────
function witnessCard(s) {
const verifyBtn = button('Verify chain', {
onClick: () => console.log('[seed-detail] verify witness chain', s.device_id),
});
const exportBtn = button('Export attestation bundle', {
onClick: () => console.log('[seed-detail] export attestation bundle', s.device_id),
});
return card({
title: 'Witness Chain',
children: [
kv([
['Chain length', h('span.purple', s.witness_len.toLocaleString())],
['Status', s.witness_valid ? pill('valid', 'green') : pill('invalid', 'red')],
['Last verify', relTime(s.witness_last_verify)],
]),
h('.flex.gap-sm.mt', verifyBtn, exportBtn),
h('small.ts',
'Ed25519 custody attestation — device-bound keypair signs (epoch + vector count + witness head): ',
mono(`epoch=${s.epoch} · vectors=${s.vector_count} · head=${s.witness_len}`)),
],
});
}
// ── 4. onboard sensors ────────────────────────────────────────────────
function sensorsCard(s) {
if (!s.sensors) {
return card({ title: 'Onboard Sensors', children: [h('.muted-empty', 'sensors offline')] });
}
const x = s.sensors;
const grid = h('.grid.cols-3',
subCard('BME280', [
sub('Temp', h('span.cyan', x.bme280.temp_c + ' °C')),
sub('Humidity', h('span.cyan', x.bme280.humidity_pct + ' %')),
sub('Pressure', h('span.cyan', x.bme280.pressure_hpa + ' hPa')),
]),
subCard('PIR', [
sub('Motion', x.pir.motion ? pill('motion', 'amber') : pill('still', 'grey')),
sub('Last trigger', h('span.t2', relTime(x.pir.last_trigger))),
]),
subCard('Reed', [
sub('State', x.reed.open ? pill('open', 'amber') : pill('closed', 'grey')),
sub('Last change', h('span.t2', relTime(x.reed.last_change))),
]),
subCard('ADS1115', x.ads1115.map((ch) => sub(ch.label, h('span.cyan', String(ch.v))))),
subCard('Vibration', [
sub('State', x.vibration.active ? pill('active', 'amber') : pill('idle', 'grey')),
sub('Last trigger', h('span.t2', relTime(x.vibration.last_trigger))),
]),
);
return card({ title: 'Onboard Sensors', children: [grid] });
}
function subCard(name, rows) {
return card({ children: [h('h3', name), ...rows] });
}
function sub(name, valueNode) {
return h('.row', h('span.k.t2', name), valueNode instanceof Node ? valueNode : h('span.cyan', String(valueNode)));
}
// ── 5. reflex rules ───────────────────────────────────────────────────
function reflexCard(s) {
if (!s.reflex || !s.reflex.length) {
return card({ title: 'Reflex Rules', children: [h('.muted-empty', 'no reflex rules configured')] });
}
const rows = s.reflex.map(reflexRow);
return card({ title: 'Reflex Rules', children: rows });
}
function reflexRow(r) {
let thresholdNode;
if (r.name === 'fragility_alarm') {
const input = h('input.inline', { type: 'number', step: '0.05', value: String(r.threshold) });
input.addEventListener('change', () => console.log('[seed-detail] reflex threshold edit (no persist)', r.name, input.value));
thresholdNode = input;
} else {
thresholdNode = mono(String(r.threshold));
}
const row = h('.row',
h('.flex.gap-sm', mono(r.name), r.fired_recently ? pill('fired recently', 'amber') : null),
h('.flex.gap-sm',
h('span.t2', 'thr'), thresholdNode,
h('span.t2', '→'), h('span.v', r.target),
h('small.ts', 'fired ' + (r.last_fired ? relTime(r.last_fired) : 'never'))));
if (r.fired_recently) {
return card({ tint: 'amber', children: [row] });
}
return row;
}
// ── 6. cognitive analysis ─────────────────────────────────────────────
function cognitionCard(s) {
const c = s.cognition || {};
const children = [];
if (c.fragility == null) {
children.push(h('.muted-empty', 'fragility unavailable — cognition offline'));
} else {
const fragile = c.fragility > 0.3;
const fb = bar(c.fragility, 1, [{ lt: 0.3, color: 'green' }, { lt: 0.6, color: 'amber' }, { lt: 1.01, color: 'red' }]);
if (fragile) {
children.push(banner(`Boundary fragility elevated — ${c.fragility.toFixed(2)} (regime change likely)`, 'amber'));
}
children.push(h('.flex.spread', h('span.t2', 'Boundary fragility'), h('span' + (fragile ? '.amber' : '.green'), c.fragility.toFixed(2))));
children.push(fb);
}
if (c.coherence_phases && c.coherence_phases.length) {
children.push(h('h3.mt', 'Coherence phases'));
c.coherence_phases.forEach((p) => {
children.push(h('.row', mono(relTime(p.t)), h('span.v', p.label)));
});
}
children.push(h('.row.mt', h('span.k.t2', 'kNN rebuild cadence'), mono((c.knn_rebuild_s ?? '—') + ' s')));
return card({ title: 'Cognitive Analysis', children });
}
// ── 7. ingest pipeline ────────────────────────────────────────────────
function ingestCard(s) {
const ing = s.ingest || {};
const children = [
kv([
['Batch size', mono(String(ing.batch))],
['Flush interval', mono((ing.flush_ms ?? '—') + ' ms')],
['Bridge', String(ing.bridge ?? '—')],
]),
];
if (ing.bridge && /hop/i.test(ing.bridge)) {
children.push(banner('Bridge adds a network hop — extra latency + a trust boundary in the ingest path.', 'amber'));
}
if (ing.esp32 && ing.esp32.length) {
children.push(h('h3.mt', 'ESP32 ingest nodes'));
ing.esp32.forEach((n) => children.push(esp32Row(n)));
} else {
children.push(h('.muted-empty', 'no ESP32 nodes attached'));
}
return card({ title: 'Ingest Pipeline', children });
}
function esp32Row(n) {
const native = n.packet === '0xC5110003';
const packetPill = native
? pill('0xC5110003 native', 'green')
: pill((n.packet || '—') + ' vitals fallback', 'amber');
return h('.row',
mono(n.node_id),
h('.flex.gap-sm', packetPill, h('span.t2', n.rate_hz + ' Hz')));
}
@@ -0,0 +1,256 @@
// §4.10 Settings & Integration Config — ADR-131.
// One card per sub-section: SEED fleet management, ESP32 provisioning,
// MQTT / cog-ha-matter config, long-lived access tokens, federation
// config. Security invariants are surfaced as first-class banners
// (USB-only pairing window; "model deltas only, never raw CSI").
//
// Mutations are local-state-only here (no live mutate endpoint yet); the
// node→room assignment edits persist into an in-memory map and the panel
// is flagged DEMO whenever the mock layer is serving it (§7.1 honesty).
import {
h, clear, card, pill, statusPill, sectionHeader, mono, button, banner, kv, relTime,
} from '../ui.js';
export default {
meta: { title: 'Settings' },
async render(root, ctx) {
const { api } = ctx;
// Load each card's data independently so one failure doesn't blank the page.
let s = null, sErr = null;
let seeds = null, seedsErr = null;
let fed = null, fedErr = null;
try { s = await api.settings(); } catch (e) { sErr = e; }
try { seeds = await api.seeds(); } catch (e) { seedsErr = e; }
try { fed = await api.federation(); } catch (e) { fedErr = e; }
root.appendChild(sectionHeader('Settings & Integration Config', 'SEED fleet, ESP32 provisioning, MQTT / cog-ha-matter, access tokens & federation (ADR-131 §4.10)'));
if (api.isDemo('settings') || api.isDemo('fleet')) {
root.appendChild(banner('DEMO — settings & fleet are served by the contract-conformant mock layer until their live endpoints land (ADR-131 §7.1). Edits are local-state only.', 'amber'));
}
// ── §4.10.1 SEED fleet ──
if (seedsErr) root.appendChild(cardBanner('SEED Fleet Management', 'SEED fleet unavailable — ' + errText(seedsErr)));
else root.appendChild(seedFleetCard(seeds));
// ── §4.10.2/.3/.4 ESP32 + MQTT + tokens (all from settings) ──
if (sErr) {
root.appendChild(cardBanner('ESP32 Node Provisioning', 'ESP32 provisioning unavailable — ' + errText(sErr)));
root.appendChild(cardBanner('MQTT / cog-ha-matter', 'MQTT / cog-ha-matter config unavailable — ' + errText(sErr)));
root.appendChild(cardBanner('Long-Lived Access Tokens', 'Access tokens unavailable — ' + errText(sErr)));
} else {
root.appendChild(esp32Card(s.esp32));
root.appendChild(mqttCard(s.mqtt, s.ha_disco_entities, s.esp32));
root.appendChild(tokensCard(s.tokens));
}
// ── §4.10.5 Federation (needs federation + seeds) ──
if (fedErr || seedsErr) root.appendChild(cardBanner('Federation Config', 'Federation config unavailable — ' + errText(fedErr || seedsErr)));
else root.appendChild(federationCard(fed, seeds));
return () => {};
},
};
// ── §4.10.1 SEED fleet management ───────────────────────────────────
function seedFleetCard(seeds) {
const body = h('div');
// PROMINENT USB-only pairing invariant (security invariant).
body.appendChild(banner('Pairing window only opens via 169.254.42.1 (USB), never WiFi — security invariant.', 'red'));
const list = h('div.mt');
seeds.forEach((sd) => list.appendChild(seedRow(sd)));
body.appendChild(list);
body.appendChild(h('.flex.wrap.gap-sm.mt',
button('Add SEED', { variant: 'ghost', onClick: () => toggleNote(addNote) }),
button('Reprovision', { variant: 'ghost', onClick: () => toggleNote(addNote) })));
const addNote = inlineNote('Provisioning flow', [
'1. Connect the SEED over USB — it presents a link-local pairing endpoint at 169.254.42.1.',
'2. Pairing NEVER opens over WiFi; the device refuses pairing on any non-USB interface.',
'3. Issue a bearer token over the USB link, then attach the SEED to the appliance.',
'4. Verify the witness chain before accepting the SEED into the fleet.',
]);
body.appendChild(addNote);
return card({ title: 'SEED Fleet Management', children: [body] });
}
function seedRow(sd) {
const offline = !sd.online;
const tokenKind = offline ? 'grey' : 'green';
const tokenLabel = offline ? 'token idle' : 'token valid';
const note = inlineNote('Secure token rotation — ' + sd.device_id, [
'1. Operator confirms physical presence; pairing must be re-opened over USB (169.254.42.1) — never WiFi.',
'2. Appliance mints a new bearer token and stages it on the SEED over the USB link.',
'3. SEED acknowledges; the appliance flips the active token and revokes the old one.',
'4. Witness chain records the rotation (ed25519); old token rejected on next ingest.',
]);
const head = h('.row',
h('strong.mono', sd.device_id),
h('.flex.gap-sm',
h('span.t2', sd.firmware),
pill(tokenLabel, tokenKind),
statusPill(sd.online ? 'online' : 'offline'),
button('Rotate token', { variant: 'ghost', onClick: () => toggleNote(note) }),
button('Remove', { variant: 'ghost', onClick: () => toggleNote(note) })));
return h('div', head, note);
}
// ── §4.10.2 ESP32 node provisioning ─────────────────────────────────
function esp32Card(nodes) {
// local-state room assignment map (node_id → room) — no live endpoint.
const roomMap = {};
nodes.forEach((n) => { roomMap[n.node_id] = n.room; });
const body = h('div');
nodes.forEach((n) => {
const sel = h('input.inline', {
value: roomMap[n.node_id],
title: 'Editable node→room assignment (local state)',
onChange: (e) => { roomMap[n.node_id] = e.target.value.trim(); },
});
body.appendChild(h('.row',
h('.flex.gap-sm',
h('strong.mono', n.node_id),
mono(n.ip + ':' + n.port),
h('span.t2', 'fw ' + n.firmware),
pill(n.seed, 'cyan')),
h('.flex.gap-sm', h('span.k', 'room'), sel)));
});
body.appendChild(h('.t3.mt', 'Provision a new node with the firmware tool: ',
mono('firmware/esp32-csi-node/provision.py'),
' (set --target-ip to this appliance).'));
body.appendChild(h('.flex.wrap.gap-sm.mt',
button('Add ESP32 node', { variant: 'ghost', onClick: () => alert('Run provision.py over USB — see hint above.') }),
button('Apply room map', { variant: 'ghost', onClick: () => alert('Room map persisted locally: ' + JSON.stringify(roomMap)) })));
return card({ title: 'ESP32 Node Provisioning', children: [body] });
}
// ── §4.10.3 MQTT / cog-ha-matter config ─────────────────────────────
function mqttCard(mqtt, haEntities, esp32) {
const dotCls = mqtt.connected ? '' : '.err';
const liveDot = h('span.lag',
h('span.dot' + dotCls),
h('span.t2', mqtt.connected ? 'connected' : 'disconnected'));
const conf = kv([
['Broker', mono(mqtt.broker)],
['User', mqtt.user],
['Credentials', mono('••••••')],
['mDNS advertisement', mono(mqtt.mdns)],
['Connection', liveDot],
]);
// HA-DISCO entities per node with via_device assignments.
const disco = h('div.mt',
h('h3', `HA-DISCO entities — ${haEntities} per node`),
h('.t3', 'Each ESP32 node publishes its discovery entities with a via_device pointing at its SEED:'));
esp32.forEach((n) => disco.appendChild(h('.row',
h('span.mono', n.node_id),
h('.flex.gap-sm', pill(haEntities + ' entities', 'cyan'), h('span.t2', 'via_device'), mono(n.seed)))));
return card({ title: 'MQTT / cog-ha-matter', children: [conf, disco] });
}
// ── §4.10.4 Long-lived access tokens ────────────────────────────────
function tokensCard(tokens) {
const body = h('div');
tokens.forEach((t) => {
body.appendChild(h('.row',
h('.flex.gap-sm', h('strong', t.name), pill('long-lived', 'purple')),
h('.flex.gap-sm',
h('span.t2', 'last used ' + relTime(t.last_used)),
h('span.t3', 'created ' + relTime(t.created)),
button('Revoke', { variant: 'ghost', onClick: () => alert('Revoking "' + t.name + '" — token rejected on next request (local demo).') }))));
});
body.appendChild(h('.flex.wrap.gap-sm.mt',
button('Create token', { variant: 'primary', onClick: () => alert('A new long-lived token would be minted and shown once (demo).') })));
// HA companion-app pairing QR placeholder box.
const qr = h('.muted-empty.mt', { style: { border: '0.67px dashed var(--border)', borderRadius: '8px', padding: '24px', textAlign: 'center' } },
'HA companion-app pairing QR surfaces here — scan from the Home Assistant mobile app to pair this appliance (placeholder).');
body.appendChild(qr);
return card({ title: 'Long-Lived Access Tokens', children: [body] });
}
// ── §4.10.5 Federation config (ADR-105) ─────────────────────────────
function federationCard(fed, seeds) {
const body = h('div');
// CRITICAL invariant — model deltas only, never raw CSI (purple).
body.appendChild(purpleBanner('Federation invariant — ' + fed.invariant + '.'));
body.appendChild(kv([
['Coordinator SEED', mono(fed.coordinator)],
['Round', h('span.purple', String(fed.round))],
['Healthy SEEDs (k)', String(fed.k_healthy)],
['Delta exchange', statusPill(fed.delta_status === 'exchanging' ? 'updating' : fed.delta_status)],
['Round cadence', fed.cadence_min + ' min'],
['Krum aggregation', h('.flex.gap-sm', pill('f = ' + fed.krum.f, 'cyan'), pill(fed.krum.multi ? 'multi-Krum' : 'single-Krum', 'purple'), h('span.t3', 'ADR-105'))],
]));
// ESP-NOW mesh sync status — rows coloured by health.
const mesh = h('div.mt', h('h3', 'ESP-NOW mesh sync — cross-SEED epoch alignment'));
fed.mesh_links.forEach((l) => {
const epochA = epochOf(seeds, l.a);
const epochB = epochOf(seeds, l.b);
const aligned = epochA != null && epochA === epochB;
mesh.appendChild(h('.row',
h('.flex.gap-sm', h('span.mono', l.a), h('span.t3', '↔'), h('span.mono', l.b)),
h('.flex.gap-sm',
h('span.t2', `epoch ${fmtEpoch(epochA)} / ${fmtEpoch(epochB)}`),
pill(aligned ? 'aligned' : 'epoch skew', aligned ? 'green' : 'amber'),
pill(l.health, healthKind(l.health)))));
});
body.appendChild(mesh);
return card({ title: 'Federation Config', children: [body] });
}
// ── helpers ─────────────────────────────────────────────────────────
/** Format a load error, surfacing the §12 upstream-not-wired hint. */
function errText(e) {
return (e && e.message ? e.message : String(e)) + (e && e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : '');
}
/** Render a card whose body is a red unavailability banner (one card's data failed). */
function cardBanner(title, msg) {
return card({ title, children: [banner(msg, 'red')] });
}
function epochOf(seeds, id) {
const s = seeds.find((x) => x.device_id === id);
return s ? s.epoch : null;
}
function fmtEpoch(e) { return e == null ? '—' : String(e); }
function healthKind(h0) {
const m = { green: 'green', red: 'red', amber: 'amber' };
return m[String(h0).toLowerCase()] || 'grey';
}
/** Purple banner for federation invariants (no .banner.purple in CSS). */
function purpleBanner(text) {
return h('.banner', {
style: { background: 'var(--purple-d)', color: 'var(--purple)', border: '0.67px solid var(--purple)' },
}, text);
}
/** A hidden, toggleable multi-step note describing a secure flow. */
function inlineNote(title, steps) {
const node = h('.banner', {
style: { background: 'var(--bg2)', border: '0.67px solid var(--border)', color: 'var(--t1)', display: 'none' },
}, h('strong', title));
steps.forEach((line) => node.appendChild(h('.t2', { style: { marginTop: '4px' } }, line)));
return node;
}
function toggleNote(node) {
node.style.display = node.style.display === 'none' ? 'block' : 'none';
}
+235
View File
@@ -0,0 +1,235 @@
// HOMECORE-UI shared component helpers — ADR-131 §3.3.
//
// Every panel imports from here so cards/pills/buttons/badges are
// byte-identical across the dashboard (the §3.3 "no visual seam"
// invariant). Pure DOM, no framework, no build step.
/** Hyperscript element factory. `h('div.card#x', {onClick}, ...children)`. */
export function h(spec, attrs, ...children) {
let tag = 'div', id = null;
const classes = [];
spec.replace(/([.#]?[^.#]+)/g, (tok) => {
if (tok[0] === '.') classes.push(tok.slice(1));
else if (tok[0] === '#') id = tok.slice(1);
else tag = tok;
return tok;
});
const node = document.createElement(tag);
if (id) node.id = id;
if (classes.length) node.className = classes.join(' ');
if (attrs && typeof attrs === 'object' && !(attrs instanceof Node) && !Array.isArray(attrs)) {
for (const [k, v] of Object.entries(attrs)) {
if (v == null || v === false) continue;
if (k === 'class') node.className += ' ' + v;
else if (k === 'html') node.innerHTML = v;
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
else node.setAttribute(k, v);
}
} else if (attrs != null) {
children.unshift(attrs);
}
append(node, children);
return node;
}
function append(node, children) {
for (const c of children.flat(Infinity)) {
if (c == null || c === false) continue;
node.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
}
}
export const txt = (s) => document.createTextNode(s == null ? '' : String(s));
export const mono = (s) => h('span.mono', String(s == null ? '' : s));
export const clear = (n) => { while (n.firstChild) n.removeChild(n.firstChild); return n; };
/** Status pill. kind ∈ cyan|green|amber|red|purple|grey. */
export function pill(text, kind = 'grey') {
return h(`span.pill.${kind}`, String(text));
}
/** Map a free-form status string to the platform colour convention. */
export function statusPill(status) {
const s = String(status || '').toLowerCase();
const map = {
running: 'green', online: 'green', ok: 'green', healthy: 'green', occupied: 'green', paired: 'green', connected: 'green', valid: 'green',
stale: 'amber', degraded: 'amber', updating: 'amber', warn: 'amber', warning: 'amber',
failed: 'red', offline: 'red', error: 'red', veto: 'red', vetoed: 'red', unreachable: 'red', invalid: 'red',
stopped: 'grey', absent: 'grey', unknown: 'grey', 'not trained': 'grey',
info: 'purple', epoch: 'purple', chain: 'purple',
};
return pill(status, map[s] || 'grey');
}
export function card({ title, tint, accent, clickable, onClick, children = [] } = {}) {
const cls = ['card'];
if (tint) cls.push('tint-' + tint);
if (clickable || onClick) cls.push('clickable');
const node = h('.' + cls.join('.'));
if (onClick) node.addEventListener('click', onClick);
if (accent) node.appendChild(accentBar());
if (title) node.appendChild(h('h2', title));
append(node, [children]);
return node;
}
function accentBar() {
const b = h('div');
b.style.height = '3px';
b.style.borderRadius = '3px';
b.style.margin = '-14px -10px 14px';
b.style.background = 'linear-gradient(90deg, var(--cyan), var(--purple))';
return b;
}
/** Section header with the cyan→purple featured gradient border (§3.3). */
export function sectionHeader(title, sub) {
return h('.section-header', h('h1', title), sub ? h('.sub', sub) : null);
}
/** Live metric card (§4.1). */
export function metric({ icon, value, label, color = 'cyan' }) {
return h('.metric',
icon ? h('.ico', icon) : null,
h(`.val${color === 'green' ? '.green' : ''}`, String(value)),
h('.lbl', label));
}
export function button(label, { variant = 'ghost', onClick, disabled } = {}) {
const b = h(`button.btn.${variant}`, label);
if (disabled) b.disabled = true;
if (onClick) b.addEventListener('click', onClick);
return b;
}
/**
* Progress bar with threshold colouring.
* thresholds: [{ lt, color }] evaluated in order against the 0..1 ratio.
*/
export function bar(value, max = 1, thresholds = null) {
const ratio = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
let color = '';
if (thresholds) {
for (const t of thresholds) { if (ratio < t.lt) { color = t.color; break; } }
if (!color) color = thresholds[thresholds.length - 1].color;
}
const fill = h('span' + (color ? '.' + color : ''));
fill.style.width = (ratio * 100).toFixed(1) + '%';
return h('.bar', fill);
}
/** Small inline confidence bar — amber below 0.4 (§4.5). */
export function confidenceBar(conf) {
const c = Math.max(0, Math.min(1, conf || 0));
const fill = h('span' + (c < 0.4 ? '.amber' : ''));
fill.style.width = (c * 100).toFixed(0) + '%';
return h('.conf-bar', fill);
}
/**
* Provenance badge (§4.4 / §6) — ESP32 → SEED → COG → state machine.
* A first-class element, never collapsed. hailo:true marks Hailo-sourced
* inference visually distinct from CPU-only COGs (§6 invariant 5).
*/
export function provenanceBadge({ esp32, seed, cog, hailo } = {}) {
return h('span.prov',
esp32 ? txt(esp32) : null, esp32 ? h('span.arr', '→') : null,
seed ? txt(seed) : null, h('span.arr', '→'),
h(hailo ? 'span.hailo' : 'span', cog || 'cog'),
h('span.arr', '→'), txt('homecore'));
}
/** Tiny inline SVG sparkline. */
export function sparkline(values, { w = 120, hgt = 28, color = 'var(--cyan)' } = {}) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', w); svg.setAttribute('height', hgt); svg.setAttribute('class', 'spark');
if (!values || values.length < 2) return svg;
const min = Math.min(...values), max = Math.max(...values), span = max - min || 1;
const step = w / (values.length - 1);
const pts = values.map((v, i) => `${(i * step).toFixed(1)},${(hgt - ((v - min) / span) * (hgt - 4) - 2).toFixed(1)}`).join(' ');
const pl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
pl.setAttribute('points', pts); pl.setAttribute('fill', 'none');
pl.setAttribute('stroke', color); pl.setAttribute('stroke-width', '1.5');
svg.appendChild(pl);
return svg;
}
export function banner(text, kind = 'amber', extra) {
return h(`.banner.${kind}`, text, extra ? txt(' ') : null, extra || null);
}
export function row(k, v) {
return h('.row', h('span.k', k), v instanceof Node ? v : h('span.v', String(v == null ? '—' : v)));
}
export function kv(pairs) {
const node = h('.kv');
for (const [k, v] of pairs) {
node.appendChild(h('span.k', k));
node.appendChild(v instanceof Node ? v : h('span.v', String(v == null ? '—' : v)));
}
return node;
}
/** Collapsible section. */
export function collapsible(title, contentFn, open = false) {
const wrap = h('.collapsible' + (open ? '.open' : ''));
const head = h('.head', title);
const body = h('div');
wrap.appendChild(head); wrap.appendChild(body);
let built = false;
const toggle = () => {
wrap.classList.toggle('open');
if (wrap.classList.contains('open')) {
if (!built) { body.appendChild(contentFn()); built = true; }
body.classList.remove('hidden');
} else body.classList.add('hidden');
};
head.addEventListener('click', toggle);
if (open) { body.appendChild(contentFn()); built = true; } else body.classList.add('hidden');
return wrap;
}
/** Slide-over panel (§4.4 StateChanged detail). */
export function slideover(title, content) {
const back = h('.slideover-back');
const panel = h('.slideover', h('span.close', { onClick: close }, '✕'), h('h2', title), content);
function close() { back.remove(); panel.remove(); }
back.addEventListener('click', close);
document.body.appendChild(back);
document.body.appendChild(panel);
return { close };
}
/** Lag indicator (§4.1/§4.4 — broadcast channel vs 4096 capacity). */
export function lagIndicator(state, lagged) {
const cls = state === 'open' ? (lagged ? 'warn' : '') : 'err';
const label = state === 'open' ? (lagged ? 'WS lagging — events dropped' : 'WS live') : 'WS offline';
return h('span.lag', h(`span.dot${cls ? '.' + cls : ''}`), h('span.t2', label));
}
export function relTime(iso) {
if (!iso) return '—';
const t = Date.parse(iso);
if (Number.isNaN(t)) return String(iso);
const s = Math.round((Date.now() - t) / 1000);
if (s < 0) return 'in ' + fmtDur(-s);
if (s < 5) return 'just now';
return fmtDur(s) + ' ago';
}
function fmtDur(s) {
if (s < 60) return s + 's';
if (s < 3600) return Math.round(s / 60) + 'm';
if (s < 86400) return Math.round(s / 3600) + 'h';
return Math.round(s / 86400) + 'd';
}
/** Loading + error wrappers panels can await. */
export function loading(label = 'Loading…') { return h('.muted-empty', label); }
export function errorCard(e) { return banner('Unavailable — ' + (e && e.message ? e.message : e), 'red'); }
/** Distinguish "not trained" (null) from "unavailable" (error) — §6 invariant 3. */
export function notTrained(prompt = 'Calibrate to enable') {
return h('span.t3', 'Not trained ', button(prompt, { variant: 'ghost' }));
}
+69
View File
@@ -0,0 +1,69 @@
// HOMECORE-UI WebSocket client — ADR-130 subscribe_events.
//
// "The UI must never poll for entity state" (ADR-131 §2/§4.4). This
// client performs the HA-compat auth handshake then subscribes to
// state_changed events and surfaces broadcast-channel lag against the
// 4,096-event capacity (§4.1/§4.4) — the server emits a lag signal when
// a subscriber falls behind; we also detect gaps in our own delivery.
import { api } from './api.js';
/**
* Connect and stream events.
* @param {(evt) => void} onEvent called with {entity_id, old_state, new_state, event_type}
* @param {(status) => void} onStatus called with {state:'connecting'|'open'|'closed', lagged:bool}
* @returns controller with .close()
*/
export function connect(onEvent, onStatus) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/websocket`;
let ws, msgId = 1, closedByUs = false, lagged = false;
let retry = 0;
const status = (state) => onStatus && onStatus({ state, lagged });
function open() {
status('connecting');
try { ws = new WebSocket(url); } catch (e) { schedule(); return; }
ws.onmessage = (m) => {
let msg; try { msg = JSON.parse(m.data); } catch { return; }
if (msg.type === 'auth_required') {
ws.send(JSON.stringify({ type: 'auth', access_token: api.token() }));
} else if (msg.type === 'auth_ok') {
retry = 0; status('open');
ws.send(JSON.stringify({ id: msgId++, type: 'subscribe_events', event_type: 'state_changed' }));
} else if (msg.type === 'auth_invalid') {
status('closed');
} else if (msg.type === 'event' && msg.event) {
const e = msg.event;
if (e.event_type === 'state_changed' && e.data) {
onEvent && onEvent({
event_type: 'state_changed',
entity_id: e.data.entity_id,
old_state: e.data.old_state,
new_state: e.data.new_state,
});
} else {
onEvent && onEvent({ event_type: e.event_type, ...e.data });
}
} else if (msg.type === 'lagged' || (msg.type === 'event' && msg.lagged)) {
lagged = true; status('open');
}
};
ws.onclose = () => { if (!closedByUs) schedule(); else status('closed'); };
ws.onerror = () => { try { ws.close(); } catch {} };
}
function schedule() {
status('closed');
retry = Math.min(retry + 1, 6);
const delay = Math.min(500 * 2 ** retry, 15000);
setTimeout(() => { if (!closedByUs) open(); }, delay);
}
open();
return {
close() { closedByUs = true; try { ws && ws.close(); } catch {} },
isLagged: () => lagged,
clearLag() { lagged = false; },
};
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "homecore-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "HOMECORE-UI — operational dashboard for the two-tier Cognitum stack (ADR-131). Zero-dependency vanilla TS/JS + CSS; served by homecore-server at /homecore.",
"scripts": {
"check": "node tests/verify-imports.mjs",
"test": "node tests/verify-imports.mjs && node tests/boot.mjs && node tests/render-smoke.mjs && node tests/interaction.mjs && node tests/prod-errors.mjs",
"bench": "node tests/benchmark.mjs"
}
}
@@ -0,0 +1,54 @@
// Benchmark — ADR-131 §8 / ADR-126 §1.1.
// HOMECORE exists partly because HA's frontend is a ~5 MB Lit bundle
// (ADR-126 §1.1). This benchmark enforces a hard bundle budget and
// measures cold render throughput for all 10 panels.
// Run: node tests/benchmark.mjs
import { install } from './dom-shim.mjs';
install();
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
const ROOT = resolve(import.meta.dirname, '..');
const BUDGET_BYTES = 250 * 1024; // 250 KB total — vs HA's ~5 MB (20× smaller)
function walk(dir) {
let total = 0; const rows = [];
for (const name of readdirSync(dir)) {
if (name === 'tests' || name === 'node_modules') continue;
const p = resolve(dir, name); const s = statSync(p);
if (s.isDirectory()) { const sub = walk(p); total += sub.total; rows.push(...sub.rows); }
else if (/\.(js|css|html|json)$/.test(name)) { total += s.size; rows.push([p.replace(ROOT + '/', ''), s.size]); }
}
return { total, rows };
}
const { total, rows } = walk(ROOT);
rows.sort((a, b) => b[1] - a[1]);
console.log('── Bundle size (uncompressed) ──');
for (const [f, sz] of rows.slice(0, 8)) console.log(` ${(sz / 1024).toFixed(1).padStart(7)} KB ${f}`);
console.log(` ${'-'.repeat(40)}`);
console.log(` ${(total / 1024).toFixed(1).padStart(7)} KB TOTAL across ${rows.length} files`);
console.log(` budget ${(BUDGET_BYTES / 1024).toFixed(0)} KB · HA baseline ~5120 KB · ratio ${(5120 * 1024 / total).toFixed(1)}× smaller`);
// ── render throughput ───────────────────────────────────────────────
const { api } = await import('../js/api.js');
const ctx = { api, navigate() {}, params: { id: 'seed-livingroom-a1' }, onEvent() { return () => {}; }, onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; } };
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
const mods = {};
for (const p of PANELS) mods[p] = (await import(`../js/panels/${p}.js`)).default;
console.log('\n── Cold render throughput (avg of 50 renders each) ──');
let worst = 0;
for (const p of PANELS) {
const N = 50; const t0 = performance.now();
for (let i = 0; i < N; i++) { const root = document.createElement('div'); const c = await mods[p].render(root, ctx); if (typeof c === 'function') c(); }
const ms = (performance.now() - t0) / N;
worst = Math.max(worst, ms);
console.log(` ${ms.toFixed(3).padStart(7)} ms/render ${p}`);
}
console.log('');
let exit = 0;
if (total > BUDGET_BYTES) { console.error(`FAIL — bundle ${(total / 1024).toFixed(1)} KB exceeds ${(BUDGET_BYTES / 1024).toFixed(0)} KB budget`); exit = 1; }
else console.log(`OK — bundle within budget; slowest panel ${worst.toFixed(2)} ms/render`);
process.exit(exit);
@@ -0,0 +1,37 @@
// Boot regression test — exercises the REAL app.js boot + router (not
// just individual panels). Catches the class of bug where start() throws
// before route() runs and the dashboard renders blank.
// Run: node tests/boot.mjs (from the ui/ dir)
import { install } from './dom-shim.mjs';
const { document, window } = install();
globalThis.HOMECORE_UI_DEMO = true; // boot with fixtures (no gateway in tests)
const errs = [];
const origErr = console.error;
console.error = (...a) => { errs.push(a.map(String).join(' ')); };
await import('../js/app.js');
await new Promise((r) => setTimeout(r, 30));
console.error = origErr;
const fails = [];
const content = document.getElementById('hc-content');
const app = document.getElementById('app');
if (!app || app.children.length < 2) fails.push('shell not built (#app should have topnav + shell)');
if (!content) fails.push('#hc-content missing — buildShell did not run');
else if (content.children.length === 0) fails.push('BLANK: dashboard rendered nothing into #hc-content on boot');
if (errs.length) fails.push('console.error during boot: ' + errs.slice(0, 3).join(' | '));
// navigation must re-render the panel
window.location.hash = '#/fleet';
await new Promise((r) => setTimeout(r, 30));
if (!content || content.children.length === 0) fails.push('BLANK after navigating to #/fleet');
// a clean topnav with no dead Cognitum tabs / Cog Store link
const links = app ? app.querySelectorAll('a') : [];
const hrefs = links.map((a) => a.getAttribute('href') || '');
if (hrefs.some((h) => /cognitum\.one\/store/.test(h))) fails.push('Cog Store external link should be removed');
if (fails.length) { console.error('\nFAILED:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — app.js boots, dashboard renders, navigation re-renders, no dead Cog Store link');
@@ -0,0 +1,103 @@
// Minimal DOM shim — enough to *run* the HOMECORE-UI panels under Node
// without jsdom. Installs globals (document, location, localStorage,
// fetch, WebSocket) so render-smoke.mjs can execute every panel and
// assert it builds a real DOM subtree without throwing.
class ClassList {
constructor(el) { this.el = el; this.set = new Set(); }
add(...c) { c.forEach((x) => x && this.set.add(x)); this.sync(); }
remove(...c) { c.forEach((x) => this.set.delete(x)); this.sync(); }
toggle(c, force) { const has = this.set.has(c); const on = force === undefined ? !has : force; if (on) this.set.add(c); else this.set.delete(c); this.sync(); return on; }
contains(c) { return this.set.has(c); }
sync() { this.el._class = [...this.set].join(' '); }
}
class El {
constructor(tag) {
this.tagName = String(tag).toUpperCase();
this.children = [];
this.attrs = {};
this.style = {};
this.listeners = {};
this._class = '';
this.classList = new ClassList(this);
this.parentNode = null;
this.id = '';
this._text = '';
this.disabled = false;
this.value = '';
}
set className(v) { this._class = v || ''; this.classList.set = new Set(String(v || '').split(/\s+/).filter(Boolean)); }
get className() { return this._class; }
set innerHTML(v) { this._html = v; }
get innerHTML() { return this._html || ''; }
set textContent(v) { this._text = v; this.children = []; }
get textContent() { return this._text || this.children.map((c) => c.textContent || c._text || '').join(''); }
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
insertBefore(c, ref) { const i = this.children.indexOf(ref); c.parentNode = this; if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); return c; }
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); c.parentNode = null; return c; }
remove() { if (this.parentNode) this.parentNode.removeChild(this); }
get firstChild() { return this.children[0] || null; }
setAttribute(k, v) { this.attrs[k] = String(v); }
getAttribute(k) { return this.attrs[k] ?? null; }
addEventListener(t, fn) { (this.listeners[t] ||= []).push(fn); }
removeEventListener(t, fn) { this.listeners[t] = (this.listeners[t] || []).filter((f) => f !== fn); }
dispatch(t, detail) { (this.listeners[t] || []).forEach((fn) => fn({ detail, target: this, preventDefault() {}, stopPropagation() {} })); }
_all() { return this.children.flatMap((c) => [c, ...(c._all ? c._all() : [])]); }
matchesSel(sel) {
return sel.split(/\s+/).pop().split('.').every((p, i, arr) => {
if (i === 0 && p && !p.startsWith('.') && !p.startsWith('#')) { if (p.startsWith('.')) {} }
return true;
});
}
querySelector(sel) {
const want = sel.replace(/^.*\s/, '');
const cls = want.startsWith('.') ? want.slice(1) : null;
return this._all().find((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase())) || null;
}
querySelectorAll(sel) {
const want = sel.replace(/^.*\s/, '');
const cls = want.startsWith('.') ? want.slice(1) : null;
return this._all().filter((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase()));
}
}
class TextNode { constructor(t) { this.textContent = String(t); this._text = String(t); this.nodeType = 3; this.parentNode = null; } remove() { if (this.parentNode) this.parentNode.removeChild(this); } }
// Node instanceof checks in ui.js use `instanceof Node`; expose a Node base.
globalThis.Node = El;
// TextNode must also pass `instanceof Node` (ui.js append() treats text via createTextNode).
Object.setPrototypeOf(TextNode.prototype, El.prototype);
const body = new El('body');
const documentObj = {
createElement: (t) => new El(t),
createElementNS: (_ns, t) => new El(t),
createTextNode: (t) => new TextNode(t),
getElementById: (id) => byId[id] || (byId[id] = mkRoot(id)),
body,
readyState: 'complete',
addEventListener() {},
querySelectorAll: () => [],
};
const byId = {};
function mkRoot(id) { const e = new El('div'); e.id = id; return e; }
export function install() {
globalThis.document = documentObj;
globalThis.EventTarget = class { constructor() { this._l = {}; } addEventListener(t, fn) { (this._l[t] ||= []).push(fn); } removeEventListener(t, fn) { this._l[t] = (this._l[t] || []).filter((f) => f !== fn); } dispatchEvent(e) { (this._l[e.type] || []).forEach((fn) => fn(e)); return true; } };
// window with a navigable location.hash that fires `hashchange`.
const win = new globalThis.EventTarget();
let _hash = '';
const loc = { host: 'localhost:8123', protocol: 'http:', get hash() { return _hash; }, set hash(v) { _hash = String(v).startsWith('#') ? String(v) : '#' + v; win.dispatchEvent({ type: 'hashchange' }); } };
win.location = loc;
globalThis.window = win;
globalThis.location = loc;
globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); } };
globalThis.fetch = () => Promise.reject(new Error('offline (test) — panels fall back to mock per §7.1'));
globalThis.WebSocket = class { constructor() { this.readyState = 0; } send() {} close() {} };
globalThis.CustomEvent = class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } };
return { El, TextNode, body, document: documentObj, window: win, location: loc };
}
export { El, TextNode };
@@ -0,0 +1,86 @@
// Interaction tests — the dynamic behaviours that syntax/render checks
// cannot reach: the live WebSocket entity patch (§4.4 "never poll"), the
// ws.js handshake + event parse (ADR-130), and the calibration backend
// driving the §4.7 wizard. Run: node tests/interaction.mjs
import { install } from './dom-shim.mjs';
install();
globalThis.HOMECORE_UI_DEMO = true; // exercise the demo/calibration fixture path
const fails = [], passes = [];
async function t(name, fn) {
try { await fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
// ── 1. entities panel patches state live over the bus (no polling) ──
await t('entities: live state_changed patches the row in place', async () => {
const entities = (await import('../js/panels/entities.js')).default;
const { api } = await import('../js/api.js');
let handler = null;
const ctx = {
api, navigate() {}, params: {},
onEvent(fn) { handler = fn; return () => {}; },
onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; },
};
const root = document.createElement('div');
await entities.render(root, ctx);
assert(typeof handler === 'function', 'panel must register an onEvent handler (it must not poll)');
const before = root.querySelectorAll('.t1').map((n) => n.textContent);
assert(before.some((x) => x === 'true'), 'living_room_presence should start "true" from the mock fallback');
// Fire a live event; ws.js delivers new_state as a StateView object.
handler({ event_type: 'state_changed', entity_id: 'sensor.living_room_presence', old_state: { state: 'true' }, new_state: { state: 'false' } });
const after = root.querySelectorAll('.t1').map((n) => n.textContent);
assert(after.some((x) => x === 'false'), 'row should now show patched state "false"');
});
// ── 2. ws.js performs the HA-compat handshake and parses events ─────
await t('ws.js: handshake → subscribe_events → parsed event', async () => {
const sent = [];
let inst = null;
globalThis.WebSocket = class { constructor(url) { this.url = url; inst = this; } send(m) { sent.push(JSON.parse(m)); } close() { this.onclose && this.onclose(); } };
const { connect } = await import('../js/ws.js?ws-test');
const got = [], status = [];
const ctrl = connect((e) => got.push(e), (s) => status.push(s));
assert(inst, 'WebSocket should be constructed');
inst.onmessage({ data: JSON.stringify({ type: 'auth_required', ha_version: 'x' }) });
assert(sent[0] && sent[0].type === 'auth' && 'access_token' in sent[0], 'must reply to auth_required with an auth token');
inst.onmessage({ data: JSON.stringify({ type: 'auth_ok', ha_version: 'x' }) });
assert(sent.some((m) => m.type === 'subscribe_events' && m.event_type === 'state_changed'), 'must subscribe_events after auth_ok');
inst.onmessage({ data: JSON.stringify({ type: 'event', event: { event_type: 'state_changed', data: { entity_id: 'light.x', old_state: { state: 'off' }, new_state: { state: 'on' } } } }) });
assert(got.length === 1, 'one event expected');
assert(got[0].entity_id === 'light.x' && got[0].new_state.state === 'on', 'event fields must parse through');
inst.onmessage({ data: JSON.stringify({ type: 'lagged' }) });
assert(ctrl.isLagged(), 'lag signal should set isLagged');
ctrl.close();
});
// ── 3. calibration backend drives the 5-step wizard contract ───────
await t('calibration: start→status→anchor→train contract', async () => {
const { api } = await import('../js/api.js');
const cal = api.calibration;
cal.reset();
const bl = await cal.start();
assert(bl.baseline_id, 'start() returns a baseline_id (the STALE anchor)');
let st;
for (let i = 0; i < 10; i++) { st = await cal.status(); if (st.frames >= st.target) break; }
assert(st.frames >= st.target, 'status() converges to target frames');
for (const label of cal.ANCHORS) await cal.anchor(label);
assert((await cal.enrollStatus()).accepted.length >= 6, 'most anchors accepted after enrollment');
const trained = await cal.train();
assert(trained.presence && trained.anomaly, 'train() returns non-null specialists when enrolled');
cal.reset();
});
console.log(`\n${passes.length} passed, ${fails.length} failed`);
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — live WS patch, ws.js handshake/parse, and calibration contract verified');
@@ -0,0 +1,45 @@
// Production-mode test (ADR-131 §2.2 / §11.11): with demo mode OFF and
// the gateway unreachable, every panel must render a typed empty/error
// state WITHOUT throwing and WITHOUT showing fabricated data.
// Run: node tests/prod-errors.mjs
import { install } from './dom-shim.mjs';
install();
globalThis.HOMECORE_UI_DEMO = false; // PRODUCTION path — no fixtures
// fetch already rejects in the shim → simulates an unreachable gateway.
const fails = [], passes = [];
async function t(name, fn) {
try { await fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
const { api, demoMode } = await import('../js/api.js');
await t('demoMode() is false in production', () => assert(demoMode() === false));
await t('api.anyDemo() is false in production', () => assert(api.anyDemo() === false));
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
const ctx = {
api, navigate() {}, params: { id: 'seed-livingroom-a1' },
onEvent() { return () => {}; },
onWs(fn) { fn({ state: 'closed', lagged: false }); return () => {}; },
};
for (const name of PANELS) {
await t(`prod render (gateway down): ${name} shows a state, never throws`, async () => {
const mod = await import(`../js/panels/${name}.js`);
const root = document.createElement('div');
const cleanup = await mod.default.render(root, ctx);
// must render SOMETHING (header + error/empty state), not crash, not blank
assert(root.children.length > 0, 'panel rendered nothing in prod error mode');
if (typeof cleanup === 'function') cleanup();
});
}
// No data accessor may have flipped a demo flag in production.
await t('no demo flags set after production renders', () => assert(api.anyDemo() === false, 'a panel served mock data in production'));
console.log(`\n${passes.length} passed, ${fails.length} failed`);
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — every panel renders a typed empty/error state in production with no mock fallback');
@@ -0,0 +1,109 @@
// Render-smoke test — actually executes every HOMECORE-UI panel against
// the DOM shim and asserts each builds a non-empty DOM subtree without
// throwing. Also exercises the ui.js helpers and the mock contract.
// Run: node tests/render-smoke.mjs (from the ui/ dir)
import { install } from './dom-shim.mjs';
install();
globalThis.HOMECORE_UI_DEMO = true; // render panels against fixtures
const fails = [];
const passes = [];
function check(name, fn) {
try { fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
async function checkAsync(name, fn) {
try { await fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
const ui = await import('../js/ui.js');
const { api, entityProvenance } = await import('../js/api.js');
const mock = await import('../js/mock.js');
// ── ui.js helper unit checks ────────────────────────────────────────
check('ui.h builds element with class/id', () => {
const n = ui.h('div.card#x', { 'data-k': 'v' }, 'hi');
if (n.tagName !== 'DIV') throw new Error('tag');
if (!n.classList.contains('card')) throw new Error('class');
if (n.id !== 'x') throw new Error('id');
});
check('ui.statusPill maps running→green', () => {
const p = ui.statusPill('running');
if (!p.classList.contains('green')) throw new Error('expected green pill');
});
check('ui.statusPill maps offline→red', () => {
if (!ui.statusPill('offline').classList.contains('red')) throw new Error('expected red');
});
check('ui.bar applies threshold colour', () => {
const b = ui.bar(0.9, 1, [{ lt: 0.3, color: 'green' }, { lt: 0.6, color: 'amber' }, { lt: 1.01, color: 'red' }]);
if (!b.firstChild.classList.contains('red')) throw new Error('expected red fill at 0.9');
});
check('ui.confidenceBar amber under 0.4', () => {
if (!ui.confidenceBar(0.2).firstChild.classList.contains('amber')) throw new Error('low conf should be amber');
});
check('ui.provenanceBadge marks hailo', () => {
const p = ui.provenanceBadge({ esp32: 'e', seed: 's', cog: 'c', hailo: true });
if (!p.querySelector('.hailo')) throw new Error('hailo class missing');
});
check('ui.sparkline yields svg polyline', () => {
const s = ui.sparkline([1, 2, 3, 4]);
if (!s.querySelector('polyline')) throw new Error('no polyline');
});
// ── mock contract checks ────────────────────────────────────────────
check('mock RoomState distinguishes null vs withheld', () => {
const rs = mock.roomStates();
const office = rs.find((r) => r.room_id === 'office');
if (office.posture !== null) throw new Error('office posture should be null (not trained)');
const kitchen = rs.find((r) => r.room_id === 'kitchen');
if (!kitchen.vetoed) throw new Error('kitchen should be vetoed');
if (kitchen.posture.value !== null) throw new Error('vetoed posture value should be null/withheld, not zero');
});
check('analysis covers at least 3 bedrooms', () => {
const beds = mock.roomStates().filter((r) => /^bedroom/.test(r.room_id));
if (beds.length < 3) throw new Error(`expected ≥3 bedrooms in RoomState analysis, got ${beds.length}`);
const bedSeeds = mock.seeds().filter((s) => /bedroom/i.test(s.zone));
if (bedSeeds.length < 3) throw new Error(`expected ≥3 bedroom SEED nodes, got ${bedSeeds.length}`);
});
check('mock fleet has an offline seed with red tint semantics', () => {
if (!mock.seeds().some((s) => !s.online)) throw new Error('need an offline seed for §4.1 tint');
});
check('mock federation states the raw-CSI invariant', () => {
if (!/never raw CSI/i.test(mock.federation().invariant)) throw new Error('invariant text missing');
});
check('entityProvenance derives node→seed chain', () => {
const prov = entityProvenance({ attributes: { source: 'esp32-lr-01 BFLD' } });
if (prov.esp32 !== 'esp32-lr-01') throw new Error('node parse failed');
if (!prov.seed) throw new Error('seed mapping failed');
});
// ── render every panel ──────────────────────────────────────────────
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
const ctx = {
api,
navigate() {},
params: { id: 'seed-livingroom-a1' },
onEvent() { return () => {}; },
onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; },
wsStatus: () => ({ state: 'open', lagged: false }),
bus: new globalThis.EventTarget(),
};
for (const name of PANELS) {
await checkAsync(`render panel: ${name}`, async () => {
const mod = await import(`../js/panels/${name}.js`);
const panel = mod.default;
if (!panel || typeof panel.render !== 'function') throw new Error('no default.render export');
if (!panel.meta || !panel.meta.title) throw new Error('missing meta.title');
const root = document.createElement('div');
const cleanup = await panel.render(root, ctx);
if (root.children.length === 0) throw new Error('rendered nothing into root');
if (cleanup && typeof cleanup === 'function') cleanup(); // must not throw
});
}
// ── report ──────────────────────────────────────────────────────────
console.log(`\n${passes.length} passed, ${fails.length} failed`);
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — all ui helpers, mock contracts, and 10 panels render without throwing');
@@ -0,0 +1,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`);