Compare commits

...

16 Commits

Author SHA1 Message Date
ruv 42c764652d examples(through-wall): ESP32 sensor auto-detection + WiFlow analysis tools
- wiflow_browser.html: auto-detect live ESP32 nodes from the /ws/sensing stream and lock
  them as the model schema (NODE_IDS/CSI_DIM dynamic), persisted + restorable
- wiflow_ab.py: leakage-controlled A/B (chronological/random/blocked-gap/grouped-bucket,
  multi-seed) — the honest CSI→pose evaluation harness
- wiflow_capture.py / wiflow_train.py / wiflow_infer.py: camera-paired capture + train + infer
- pose.html: live WiFi-inferred skeleton viewer; serve.py: static server
- gitignore the regenerable 1.5MB model.npz artifact

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-16 17:00:57 -04:00
ruv a784546918 ci(ruview-swarm): drop removed itar-unrestricted feature from test matrix
The industrial rescope (ruv-drone) removed the itar-unrestricted feature flag —
formation/allocation/raft/flight-control are now default capabilities. Update the
'ruflo+itar' matrix entry to just '--features ruflo' so CI matches the new feature set.

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

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

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

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

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

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

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

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

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

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

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

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

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

Findings fixed:

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

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

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

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

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

Warrants ADR slot 179 (parent to author).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 10:55:04 -04:00
171 changed files with 9218 additions and 15149 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
features:
- { label: 'default', flags: '--no-default-features' }
- { label: 'train', flags: '--features train' }
- { label: 'ruflo+itar', flags: '--features ruflo,itar-unrestricted' }
- { label: 'ruflo', flags: '--features ruflo' }
- { label: 'full+train', flags: '--features full,train' }
steps:
- uses: actions/checkout@v4
+3
View File
@@ -277,3 +277,6 @@ aether-arena/staging/
# MM-Fi benchmark dataset archives — large data, fetch separately, never commit
assets/MM-Fi/E0*.zip
assets/MM-Fi/*.zip
# through-wall demo: regenerable trained model artifact
examples/through-wall/model/
+8
View File
@@ -21,3 +21,11 @@
[submodule "vendor/rufield"]
path = vendor/rufield
url = https://github.com/ruvnet/rufield
[submodule "v2/crates/ruview-swarm"]
path = v2/crates/ruview-swarm
url = https://github.com/ruvnet/ruv-drone.git
branch = main
[submodule "v2/crates/worldgraph"]
path = v2/crates/worldgraph
url = https://github.com/ruvnet/worldgraph.git
branch = main
+7
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,444 @@
# ADR-131: HOMECORE-UI — Operational dashboard for the two-tier Cognitum stack
| Field | Value |
|-------|-------|
| **Status** | Accepted — UI implemented (§10); full backend wiring specified (§11–§12) |
| **Date** | 2026-06-14 |
| **Deciders** | ruv |
| **Codename** | **HOMECORE-UI** — first-class operator dashboard inside the Cognitum Appliance shell |
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE state machine), [ADR-128](ADR-128-homecore-integration-plugin-system.md) (HOMECORE-PLUGINS), [ADR-129](ADR-129-homecore-automation-engine.md) (automation engine), [ADR-130](ADR-130-homecore-rest-websocket-api.md) (HOMECORE-API), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (recorder/semantic search), [ADR-151](ADR-151-room-calibration-specialist-training.md) (room calibration HTTP API), [ADR-100](ADR-100-cog-packaging-specification.md) (Cog packaging), [ADR-116](ADR-116-cog-ha-matter-seed.md) (cog-ha-matter), [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) (SEED RVF ingest), [ADR-105](ADR-105-federated-csi-training.md) (federated CSI training) |
| **Tracking issue** | TBD |
| **Parent** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (sub-ADR, HOMECORE-127…134 family) |
---
## 1. Context
HOMECORE (ADR-126 through ADR-134) is the native Rust + WASM + TypeScript port of Home Assistant running as the hub on the Cognitum v0 Appliance. As of P2, the state machine ([ADR-127](ADR-127-homecore-state-machine-rust.md)), API ([ADR-130](ADR-130-homecore-rest-websocket-api.md)), and COG runtime ([ADR-128](ADR-128-homecore-integration-plugin-system.md)) are in place. What is missing is a first-class dashboard UI that operators, integrators, and residents can use to manage the full two-tier hardware stack that HOMECORE coordinates.
### 1.1 The two-tier hardware model this UI must represent
This is the most important architectural constraint the UI must carry through every panel:
- **Cognitum SEED** — a Pi Zero 2 W-based edge node. It has its own RVF vector store (8-dim, content-addressed, with kNN queries), Ed25519 witness chain, SHA-256 ingest audit trail, onboard environmental sensors (BME280 temperature/humidity/pressure, PIR motion, reed switch, ADS1115 4-channel ADC, vibration), 13 drift detectors, an MCP proxy (114 tools, JSON-RPC 2.0, default-deny policy), 98 HTTPS API endpoints, and epoch-based swarm sync for multi-SEED deployments. SEEDs sit close to the ESP32 sensing nodes and receive feature vectors from them at 1 Hz. Multiple SEEDs can form a peer mesh. **This is the sensing and memory tier.**
- **Cognitum v0 Appliance** — a Pi 5 + Hailo-10H hub, running at `:9000`. It hosts the COG runtime (`/var/lib/cognitum/apps/`), the HOMECORE state machine and event bus, the calibration service, `ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`, and acts as the fleet coordinator for multi-room correlation and federated training. The Appliance is where HOMECORE runs, and it is what the dashboard user is sitting in front of. **This is the computation and orchestration tier.**
SEEDs are **subordinate nodes that the Appliance supervises** — they are not peers. The UI navigation hierarchy must reflect this: the Appliance is the root, SEEDs are children, ESP32 nodes are leaves.
### 1.2 What the UI is not
HOMECORE-UI is **not** a re-skin of the existing Cognitum Cog Store. It is a full operational dashboard that **extends** the Cognitum platform's shell — the Cog Store, API Explorer, and Guide already exist and must remain intact, with the HOMECORE dashboard added as a first-class navigation section alongside them.
---
## 2. Decision
Build HOMECORE-UI as a **complete** TypeScript + Rust→WASM frontend (per this ADR's §3 and the HOMECORE-127…134 family) that:
1. Lives at `http://cognitum-v0:9000/homecore` (or as a dedicated nav item in the Cognitum Appliance shell).
2. Is visually and stylistically seamless with the existing Cognitum platform — same dark theme, same design tokens, same component patterns as `https://seed.cognitum.one/store`.
3. Drives the HOMECORE REST + WebSocket API ([ADR-130](ADR-130-homecore-rest-websocket-api.md)) and the calibration HTTP API ([ADR-151](ADR-151-room-calibration-specialist-training.md)) for all data.
4. Updates in real-time via the homecore `subscribe_events` WebSocket channel. **The UI must never poll for entity state.**
**This is a decision to deliver the complete operational dashboard — every panel in §4.1 through §4.10, every navigation section in §5, fully wired to live data — not a design-system scaffold or a partial first cut.** A static layout shell with placeholder data is explicitly **out of scope as a deliverable**: the design system (§3) is a means to the complete UI, not an end in itself. The acceptance bar for this ADR is that an operator can drive the full two-tier stack — fleet, entities, rooms, COGs, calibration, events, audit, and settings — from the dashboard, against real APIs, with no panel left as a stub.
### 2.1 `homecore-server` is the single backend-for-frontend (BFF) gateway
The data the dashboard needs is spread across **three backend tiers that are not one process**: (a) `homecore-api` (`/api/*` REST + `/api/websocket`, mounted in `homecore-server`); (b) the **calibration API** (`/api/v1/*`, served by a *separate* binary — `wifi-densepose calibrate-serve` / `wifi-densepose-sensing-server`); and (c) the **SEED device tier + appliance daemons** (RVF vector store, witness chain, onboard sensors, reflex rules, COG supervisor, federation), which are physically separate HTTPS services on the SEED nodes and the appliance.
The browser must talk to **exactly one origin.** Therefore `homecore-server` is promoted to the **single BFF / API gateway** for HOMECORE-UI: it serves the static assets at `/homecore`, serves `homecore-api` at `/api/*`, and **adds a new `/api/homecore/*` namespace** that proxies and aggregates the calibration API and the SEED/appliance tiers server-side. The UI only ever issues same-origin requests; cross-service auth (SEED bearer tokens, calibration tokens) is held by the gateway and **never exposed to the browser**. This collapses the CORS/multi-port problem and gives one place to enforce the long-lived-access-token auth (§4.10).
### 2.2 No mock data in production
The in-browser mock layer that the first UI cut shipped behind DEMO banners (§7.1, prior revision) is **demoted to a dev-only fixture** gated behind an explicit `?demo=1` / `HOMECORE_UI_DEMO=1` flag. The production build wires **every** panel to a real gateway endpoint. The full endpoint contract and the backend work each panel needs are specified in **§11**; the staged path to get there is **§12**. A panel may show an empty/typed-error state when its upstream is down, but it must never silently render fabricated data.
---
## 3. Design system — Cognitum platform conventions
The implementor **must study `https://seed.cognitum.one/store` as the definitive design reference before writing a single line of CSS.** The existing platform's design tokens, extracted from production, are:
### 3.1 Colour palette (CSS custom properties)
| Token | Value | Role |
|---|---|---|
| `--bg` | `#0a0e1a` | page background (very dark navy) |
| `--bg2` | `#111627` | secondary background / nav strip |
| `--card` | `#171d30` | card / panel surface |
| `--card-h` | `#1e2540` | card hover state |
| `--border` | `#252d45` | all border strokes (≈0.67px, subtle) |
| `--t1` | `#e0e4f0` | primary text (near-white) |
| `--t2` | `#8890a8` | secondary / muted text |
| `--t3` | `#505872` | tertiary / disabled text |
| `--cyan` | `#4ecdc4` | primary action colour (Install buttons, live indicators, accents) |
| `--cyan-d` | `rgba(78,205,196,0.15)` | cyan tint background for status badges |
| `--green` | `#6bcb77` | success / online / healthy states |
| `--green-d` | `rgba(107,203,119,0.15)` | green tint background |
| `--amber` | `#d4a574` | warning / stale / degraded states |
| `--amber-d` | `rgba(212,165,116,0.15)` | amber tint background |
| `--red` | `#e06060` | error / offline / veto states |
| `--red-d` | `rgba(224,96,96,0.15)` | red tint background |
| `--purple` | `#a78bfa` | informational / epoch / chain indicators |
| `--purple-d` | `rgba(167,139,250,0.15)` | purple tint background |
| `--r` | `10px` | standard border radius on all cards and panels |
### 3.2 Typography
- `--font`: `'Segoe UI', system-ui, -apple-system, sans-serif` — all body and heading text.
- `--mono`: `'Cascadia Code', 'Fira Code', Consolas, monospace` — all entity IDs, API endpoints, hex values, JSON payloads, COG binary hashes.
### 3.3 Component patterns (from the live Cog Store and API Explorer)
- **Cards**: `background: var(--card)`, `border: 0.67px solid var(--border)`, `border-radius: var(--r)`, `padding: 24px`.
- **Category pills / status badges**: small `border-radius: 46px`, uppercase text, coloured background tint (e.g. `background: var(--cyan-d); color: var(--cyan)` for `RUNNING`; `background: var(--amber-d); color: var(--amber)` for `STALE`).
- **Primary action buttons**: `background: var(--cyan)`, `color: var(--bg)`, no border — matching the existing "Install" button style exactly.
- **Secondary / ghost buttons**: transparent background, `border: 1px solid var(--border)`, `color: var(--t1)` — matching the existing "Details" button style.
- **Nav strip**: `background: var(--bg2)`, text items in `--t2`, active item highlighted in `--cyan` with a bottom underline.
- **Featured card gradient borders**: top-edge linear gradient from `var(--cyan)` to `var(--purple)` — replicate for HOMECORE section headers.
- **Live metric cards** (API Explorer status page): icon + large numeric value in `--cyan` or `--green`, label in `--t2` below, on a `var(--card)` background.
- **Method badge pills** on the API Explorer (`GET` in green, `POST` in amber, `AUTH` in purple) — reuse this same pill system for COG status indicators.
The implementor **must not introduce new colours, typefaces, or border radii.** Every component should feel like it was built by the same team that built the Cog Store and the API Explorer. A user navigating from the Cog Store into the HOMECORE dashboard should not notice a visual seam.
---
## 4. UI sections — required panels
### 4.1 System Dashboard (the "home screen")
The always-visible overview panel. Modelled on the API Explorer's live metric cards. All values update in real-time.
- **v0 Appliance health strip** — reuse the exact metric-card pattern from `seed.cognitum.one/status`: one card each for CPU %, RAM usage, Hailo-10H inference load (% utilisation), Hailo temperature, uptime, and the running services (`ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`). Values in `--cyan`, labels in `--t2`. This strip is always at the top — it represents the machine the user is looking at.
- **SEED Fleet overview** — a grid of SEED node cards (one per paired SEED) on the `var(--card)` surface with `var(--border)`. Each card shows: online/offline status pill (green/red), firmware version, epoch number, current vector count, last ingest timestamp, and witness-chain validity badge. A collapsed row shows the SEED's 5 onboard sensors in summary (PIR: yes/no, door: open/closed, temperature from BME280). Offline SEEDs render the entire card with a `--red-d` background tint. Clicking a SEED card navigates to the SEED Detail view (§4.2).
- **ESP32 Node summary** — count of active ESP32 nodes per SEED, current frame rate (target: 100 Hz CSI + 1 Hz feature vectors), and a compact warning list for nodes with known issues (presence_score normalisation anomaly, stale firmware version).
- **COG Runtime status row** — a horizontal strip of status pills for each installed COG on the v0 Appliance. Pill colours follow the existing badge convention: `--green-d`/`--green` for running, `--red-d`/`--red` for failed, `--t3`/`--t2` for stopped. COG name in `--mono`. Clicking a pill navigates to COG Management (§4.6).
- **Event Bus activity indicator** — a small real-time sparkline showing the homecore broadcast channel event rate (events/sec). Indicate channel lag if a subscriber is falling behind the 4,096-event capacity.
### 4.2 SEED Detail View (per-SEED drill-down)
Accessible from the fleet grid. Full-page panel for a single SEED node, using the card + section-header pattern from the Cog Store's detail views.
- **SEED identity header** — `device_id` in `--mono`, firmware version, paired status in green, USB vs WiFi connection mode. A section-header gradient border (cyan → purple, matching the featured card style) visually separates this from Appliance content.
- **Vector Store panel** — current vector count, dimension (8), last kNN query latency, current epoch number, a small sparkline of ingest rate over the last hour, and a storage budget bar showing usage against the 100K working-set target. A "Compact now" button (`POST /api/v1/store/compact`) in ghost style. When usage exceeds 80%, the bar renders in `--amber`.
- **Witness Chain panel** — chain length (SHA-256 entries), last verification timestamp, a one-click "Verify chain" button (`POST /api/v1/witness/verify`), and an "Export attestation bundle" button for regulated deployments. The Ed25519 custody attestation (device-bound keypair, epoch + vector count + witness head) renders here. Chain length in `--purple`, following the existing epoch/chain colour convention.
- **Onboard Sensors panel** — live readings from all 5 sensors in individual sub-cards: BME280 (temperature °C, humidity %, pressure hPa), PIR (motion boolean with last-triggered timestamp), reed switch (open/closed with last-changed timestamp), ADS1115 (4 analog channels with configurable labels), vibration (boolean with last-triggered). These are ground-truth validators against CSI readings and are critical for diagnosing false positives in the mixture-of-specialists. Sensor values in `--cyan`; sensor names in `--t2`.
- **Reflex Rules panel** — the 3 pre-configured rules with current state: `fragility_alarm` (threshold 0.3 → relay actuator), `drift_cutoff` (threshold 1.0), `hd_anomaly_indicator` (threshold 200 → PWM brightness). Show last-fired time for each. The `fragility_alarm` threshold is the most commonly adjusted field and should be editable inline. Rules that have recently fired render with a `--amber-d` background tint.
- **Cognitive Analysis panel** — boundary fragility score (0.01.0, from Stoer-Wagner min-cut on the kNN graph) rendered as a progress bar: green below 0.3, amber 0.30.6, red above 0.6. High fragility (>0.3) indicates a regime change in the environment and should be visually prominent. Temporal coherence phase boundaries shown as a labelled timeline of detected environment state transitions. kNN graph rebuild cadence indicator (every 10 s).
- **Ingest pipeline status** — which ESP32 nodes feed this SEED, the packet type each is sending (`0xC5110003` native feature vectors vs `0xC5110002` vitals fallback path — distinguished visually since native is preferred), current ingest batch size, flush interval, and bridge path topology (direct vs host-laptop hop). The bridge-hop warning (known architectural limitation) renders in `--amber` since it adds a network hop.
### 4.3 SEED Fleet Map (multi-SEED topology)
For deployments with more than one SEED, a topology view showing the mesh:
- **Node hierarchy diagram** — v0 Appliance at root, SEEDs as second tier (grouped by room/zone), ESP32 nodes as leaves under each SEED. Lines represent active data flows. ESP-NOW mesh sync links between SEEDs shown as dashed lines. Connection health shown via line colour (green/amber/red). All labels in `--mono`.
- **Cross-SEED event deduplication indicator** — for events that span multiple SEEDs (one fall detected by two rooms; one occupant tracked through room A → hallway → room B), show a fusion badge indicating how many SEEDs contributed to the composite event.
- **Federation config** ([ADR-105](ADR-105-federated-csi-training.md)) — federated-learning round coordinator role (which SEED is the round coordinator), current round number, K healthy nodes selected, delta exchange status. **Model deltas only — never raw CSI** is a design invariant that must be labelled explicitly in the UI.
### 4.4 Entity & State Browser
The homecore state machine (`DashMap<EntityId, Arc<State>>`) is the authoritative source of truth. Every COG running on the v0 Appliance contributes entities.
- **Entity list by domain** — grouped by the `domain.` prefix of `EntityId`, using collapsible section headers. The 21 entities per ESP32 node (11 raw + 10 semantic primitives from `cog-ha-matter`) are the most important set. For each entity: current state string (in `--t1`), last-changed timestamp (in `--t3`), attribute map as collapsible JSON in `--mono`, and the Context (`user_id` + `parent_id` causality chain, critical for care/audit deployments). Entity IDs always in `--mono`.
- **SEED provenance badge** — each entity carries a small badge showing its data lineage: which ESP32 node → which SEED → which COG → homecore state machine. This trace is invaluable for debugging false positives and is a **first-class UI element, not a collapsed detail.**
- **Domain filter + semantic search** — filter by domain prefix and, once [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (homecore-recorder) lands, ruvector-backed semantic search: "when did the living room anomaly score last correlate with a door-open event?" A keyword filter across entity IDs and attribute keys ships in the initial release regardless of [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) status, given entity density; the semantic search layers on top once the recorder lands.
- **Real-time WebSocket feed** — entity states update live via the homecore `subscribe_events` WebSocket command ([ADR-130](ADR-130-homecore-rest-websocket-api.md)). The UI must never poll. Show a broadcast-channel lag indicator; warn visually if the subscriber is falling behind the 4,096-event channel capacity.
- **StateChanged detail panel** — clicking any entity opens a slide-over panel showing the full `StateChangedEvent`: `old_state`, `new_state`, `context.id`, `context.user_id`, and the `context.parent_id` chain rendered as a breadcrumb trail.
### 4.5 RoomState / Sensing Panel
Surfaces the mixture-of-specialists output from the calibration service — the highest-level per-room sensing result. Data comes from `GET /api/v1/room/state?bank=<room_id>` on the v0 Appliance.
- **Per-room cards** — one card per `room_id` on the `var(--card)` surface. Each card shows live `RoomState` JSON fields as sub-rows: presence (occupied/absent chip in green/red with confidence bar), posture (standing/sitting/lying chip with confidence), breathing BPM (numeric in `--cyan` with range indicator 630), heart rate BPM (numeric in `--cyan` with range indicator 40120), restlessness score (01 progress bar), and anomaly score (01 with normal/anomalous label, bar turns red above a configurable threshold).
- **STALE warning** — when `stale: true` (the specialist bank was trained against a different baseline), render the entire room card with a `--amber-d` background tint and a prominent amber banner reading "Bank stale — baseline has changed" with a direct "Recalibrate room" link into the calibration wizard (§4.7). This is the most common real-world failure mode and **must never be subtle.**
- **VETO indicator** — when `vetoed: true` (anomaly veto suppressed vitals/posture because the window was physically implausible), render the affected specialist slots in `--red` with a "Veto active" label. Values suppressed by veto **must not render as zeros** — they must render as explicitly withheld.
- **Null specialist placeholders** — specialists not yet trained (`null` in the specialist bank) render as "Not trained" placeholders in `--t3` with a small "Calibrate to enable" prompt in ghost style. They are **not** errors.
- **Confidence bars** — each specialist output has a confidence float, shown as a small inline bar (`--cyan` fill) next to the reading. Low confidence (< 0.4) renders the bar in `--amber`.
- **Multi-SEED fusion indicator** — for rooms served by multiple SEEDs, show a small badge indicating how many SEED nodes contributed to the `MultiNodeMixture` for this room's reading.
### 4.6 v0 Appliance COG Management
The v0 Appliance hosts COGs at `/var/lib/cognitum/apps/`. This panel is the operational companion to the existing Cog Store (`seed.cognitum.one/store`). It must match the Cog Store's visual conventions precisely — same card layout, same category pills, same install/detail button pair — because operators will move between the two surfaces.
- **Installed COGs list** — for each COG: `id` and `version` in `--mono`, architecture badge (`arm`/`hailo10` etc., category-pill pattern), status pill (running/stopped/failed/updating in green/grey/red/amber), `binary_sha256` verified badge (Ed25519 signature verification shown as a shield icon in `--green` or `--red`), and PID from the pid file. Actions: start, stop, restart (ghost style), and view `output.log` / `error.log` in a monospace drawer using `--mono`. Edit `config.json` inline with syntax highlighting.
- **COG Store / App Registry** — browsable `app-registry.json` listing. This panel should visually mirror `seed.cognitum.one/store` as closely as possible — same featured-card hero layout, same icon + title + description + category pill + action button structure. One-click install downloads the binary from GCS, verifies `binary_sha256` + `binary_signature`, writes the manifest, and starts the COG. Show which new homecore entities will appear in the state machine after install, as a preview list before confirming.
- **OTA Updates** — a badge count on installed COGs with available updates, matching the "Installed (N)" tab badge convention from the existing Cog Store. Show a diff panel (version change, new entities, config schema changes) before confirming the update.
- **Hailo HEF status** — for COGs with `arch: hailo10`: loaded HEF files on the Hailo-10H, current inference throughput, and `ruvector-hailo-worker:50051` connection status. The RF Foundation Encoder ([ADR-150](ADR-150-rf-foundation-encoder.md)) and neural pose head display here once available.
### 4.7 Calibration Wizard
The full baseline → enroll → train → verify pipeline runs via HTTP against the v0 Appliance ([ADR-151](ADR-151-room-calibration-specialist-training.md)). This is a multi-step guided flow — not a raw API panel. Use a stepped wizard layout with a progress indicator at the top (steps 15 as numbered pills, active step in `--cyan`, completed in `--green`, pending in `--t3`).
- **Step 1 — Select room and SEED** — enter a `room_id` name (validated against `[A-Za-z0-9_-]{1,64}`) and select which SEED(s) and ESP32 nodes serve this room from a dropdown populated from the live fleet. Show current CSI ingest health for the selected nodes inline — if frames are not arriving at the expected rate, display an amber warning **before** allowing the operator to proceed. A broken ingest pipeline will silently fail calibration.
- **Step 2 — Baseline capture** — `POST /api/v1/calibration/start`. A large full-width animated progress bar (cyan fill) reads from `GET /api/v1/calibration/status`: frames recorded vs target, ETA in seconds, `z_median` value. If `motion_flagged` is true, overlay an amber banner: "Room must be empty — movement detected." The baseline UUID produced here is the anchor for all future STALE detection for this room — display it in `--mono` once complete so operators can record it.
- **Step 3 — Anchor enrollment** — the 8 anchor labels in enforced order: `empty`, `stand_still`, `sit`, `lie_down`, `breathe_slow`, `breathe_normal`, `small_move`, `sleep_posture`. For each: a human-readable instruction with an illustration, a countdown timer rendered as a circular progress ring in `--cyan`, and an immediate quality-gate result (accepted in green, retry in amber with a reason string). Drive via `POST /api/v1/enroll/anchor` + `GET /api/v1/enroll/status`. After each accepted anchor, show the extracted feature values (mean, variance, breathing_score, heart_score) in a small `--mono` data row so operators can sanity-check the capture. Show overall progress as "N / 8 anchors accepted."
- **Step 4 — Train** — a single `POST /api/v1/room/train` call. Show the 6 specialist results as a checklist: presence (threshold + occupied_var), posture (prototype count), breathing (min_score), heartbeat (min_score), restlessness (calm/active motion values), anomaly (prototype count + scale). Specialists that returned non-null render in `--green`. Null specialists (insufficient anchor data) render in `--amber` with a "Re-enroll missing anchors" prompt linking back to Step 3 for the specific missing labels.
- **Step 5 — Verify live** — display the live `RoomState` for the just-trained room using the same per-room card layout as §4.5. Prompt the operator to stand in the room and verify presence is detected, try sitting/lying to confirm posture, and breathe normally to confirm vitals are in plausible range. A "Confirm and save" button (cyan, primary) closes the wizard; a "Something's wrong — re-enroll" button (ghost) loops back to Step 3.
### 4.8 Event Bus & Automation Feed
- **Live event stream panel** — a virtualized scrolling list of `SystemEvent` variants (`StateChanged`, `EntityRegistered`, `ConfigReloaded`) and notable `DomainEvent`s from the homecore Tokio broadcast channel. Each row shows: event-type pill (coloured by variant), `entity_id` in `--mono`, old state → new state arrow, timestamp, and `context.user_id`. The stream is filterable by entity domain, event type, or source SEED/COG. The filter bar uses the same search-input style as the Cog Store's search field.
- **Context causality breadcrumb** — expanding any event row shows the full Context chain (`context.id``parent_id``grandparent_id`) as a breadcrumb trail in `--mono`. This is how automation loops become visible without any separate debugging tool.
- **Automation builder** ([ADR-129](ADR-129-homecore-automation-engine.md) scope) — a trigger → condition → action editor on the card surface. The most important RuView-specific trigger types to support are: `state_changed` on `RoomState` entities with a threshold expression (e.g. `anomaly.value > 0.8`), SEED reflex-rule firing events (`fragility_alarm`, `hd_anomaly_indicator`), and custom `domain_event` topics. Actions include calling services in the homecore service registry and firing domain events. The condition expression editor uses `--mono`.
### 4.9 Witness / Audit Log
- **Unified witness timeline** — a chronological merged view of events from both tiers: the SEED's SHA-256 ingest chain (every RVF store write attested) and homecore's Ed25519 state-transition chain (biometric crossings, BFLD identity-risk elevations). Each row: `entity_id` in `--mono`, old/new state, timestamp, source SEED `device_id`, signing key fingerprint (first 8 chars in `--mono`). Pagination uses the same "Showing XY of Z" convention from the Cog Store's cog grid.
- **Privacy mode banner** — a persistent top-of-panel banner showing current privacy mode: `--green-d`/green text for full-publish mode; `--amber-d`/amber text for audit-only mode (SHA-256 digests on-SEED only, no MQTT state messages). Show the per-SEED privacy mode state, since SEEDs can be individually configured. Toggling privacy mode is a high-stakes action — require an explicit "Confirm" step with a summary of what will change.
- **Export bundle** — an "Export attestation bundle" button (ghost) that packages the SEED witness chain + homecore Ed25519 chain as a downloadable archive for regulated-deployment (care home, hotel, shared office) compliance handoff.
### 4.10 Settings & Integration Config
- **SEED fleet management** — add, remove, and reprovision SEEDs. Show the USB-only pairing requirement prominently (the pairing window only opens via `169.254.42.1`, not WiFi — a security invariant). Per-SEED: `device_id` in `--mono`, firmware version, bearer token status, and a "Rotate token" action (ghost) that walks the operator through the secure token rotation flow.
- **ESP32 node provisioning** — per-node NVS config display (target IP, target port, node_id), last-seen firmware version, and a link to the provisioning script. The `node_id` → room/zone assignment is editable here and persists to the room calibration system's `room_id` mapping.
- **MQTT / cog-ha-matter config** ([ADR-116](ADR-116-cog-ha-matter-seed.md)) — broker URL, credentials (masked), MQTT topic prefix, mDNS advertisement status (`_ruview-ha._tcp`), and a live connection indicator (green dot for connected, red for unreachable). The 21 HA-DISCO entities per node are listed here with their `via_device` assignments showing which SEED they belong to in HA's device registry.
- **Long-lived access tokens** — for homecore-api companion-app connections (HA 2025.1 wire-compat, [ADR-130](ADR-130-homecore-rest-websocket-api.md)). Token creation, last-used timestamp, and revocation. The HA companion-app pairing QR-code flow surfaces here.
- **Federation config** — for multi-SEED deployments: ESP-NOW mesh sync status, cross-SEED epoch alignment values, and federated-learning round settings (coordinator SEED, round cadence, Krum aggregation parameters per [ADR-105](ADR-105-federated-csi-training.md)). The design invariant **"model deltas only, never raw CSI"** must be labelled explicitly in this panel.
---
## 5. Navigation structure
HOMECORE-UI must integrate into the existing Cognitum Appliance nav shell. The top nav should read:
```
Framework | Guide | Cog Store | HOMECORE | Status
```
— inserting **HOMECORE** as a first-class nav item between the existing "Cog Store" and "Status" entries, using the same nav-item style (text in `--t2`, active state in `--cyan` with bottom underline).
Within the HOMECORE section, a left sidebar (or top sub-nav on narrow viewports) provides section navigation:
```
Dashboard | SEED Fleet | Entities | Rooms | COGs | Calibration | Events | Audit | Settings
```
The COG Store panel within HOMECORE (§4.6) links out to `seed.cognitum.one/store` for the full catalog view, ensuring the existing Cog Store remains the canonical browsing experience.
---
## 6. Key UX invariants
These must be maintained across every panel:
1. **Always make the tier origin of any data explicit.** A `RoomState` reading traces to an ESP32 node → SEED → COG → v0 Appliance state machine. The provenance badge (§4.4) must appear wherever entity states are displayed.
2. **The `stale` and `vetoed` flags from `RoomState` and the kNN fragility score from SEED cognitive analysis are meaningful diagnostic signals** — they must never be silently hidden, styled grey-on-grey, or collapsed behind an expand toggle. They represent system health operators need to act on.
3. **Values that are `null` because a specialist has not been trained must be visually distinct from values that are unavailable due to an error.** The distinction is operationally important: `null` means "calibrate to enable," unavailable means "investigate."
4. **All entity IDs, hashes, API endpoints, binary signatures, device UUIDs, and JSON payloads must use `--mono` font.** This is already the convention in the API Explorer and must be consistent throughout HOMECORE-UI.
5. **The v0 Appliance Hailo HAT is a separate subsystem from the SEED's edge compute.** Inference results tagged as Hailo-sourced (COGs with `arch: hailo10`) must be visually distinguished from results from CPU-only COGs (`arch: arm`) so operators can triage hardware-specific failures.
---
## 7. Scope — complete UI delivery
The deliverable is the **entire** dashboard. Every panel below ships fully implemented and wired to its live data source — there is no scaffold-only milestone and no panel left as a placeholder. The table records each panel's authoritative backing API so the build can proceed in whatever order best fits the dependency graph; it is a dependency map, **not** a sequence of partial releases.
| Panel | Section | Backing API / source |
|---|---|---|
| System Dashboard | §4.1 | [ADR-130](ADR-130-homecore-rest-websocket-api.md) WebSocket + appliance health endpoints |
| SEED Detail View | §4.2 | SEED HTTPS API (vector store, witness, sensors, reflex, cognitive analysis) |
| SEED Fleet Map | §4.3 | fleet topology + federation ([ADR-105](ADR-105-federated-csi-training.md)) |
| Entity & State Browser | §4.4 | [ADR-127](ADR-127-homecore-state-machine-rust.md) state machine via [ADR-130](ADR-130-homecore-rest-websocket-api.md) `subscribe_events`; semantic search via [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) |
| RoomState / Sensing | §4.5 | [ADR-151](ADR-151-room-calibration-specialist-training.md) `GET /api/v1/room/state` |
| COG Management | §4.6 | [ADR-128](ADR-128-homecore-integration-plugin-system.md) plugin runtime + [ADR-100](ADR-100-cog-packaging-specification.md) app registry |
| Calibration Wizard | §4.7 | [ADR-151](ADR-151-room-calibration-specialist-training.md) calibration HTTP API |
| Event Bus & Automation | §4.8 | [ADR-130](ADR-130-homecore-rest-websocket-api.md) broadcast channel + [ADR-129](ADR-129-homecore-automation-engine.md) automation engine |
| Witness / Audit Log | §4.9 | SEED SHA-256 ingest chain + homecore Ed25519 chain |
| Settings & Integration | §4.10 | SEED provisioning, [ADR-116](ADR-116-cog-ha-matter-seed.md) MQTT/Matter, LLAT, federation |
### 7.1 Build sequencing within the complete deliverable
The complete UI depends on backing services that mature on their own timelines. Each panel is built against the **real gateway endpoint** defined in §11; where the upstream is not yet available the panel renders a typed empty/error state, **not** fabricated data (the dev-only `?demo=1` fixture of §2.2 exists for offline development only and is never the shipped behaviour). Concretely, the hard contract dependencies are: [ADR-130](ADR-130-homecore-rest-websocket-api.md) (REST + WebSocket), [ADR-127](ADR-127-homecore-state-machine-rust.md) (state machine), [ADR-151](ADR-151-room-calibration-specialist-training.md) (calibration), [ADR-128](ADR-128-homecore-integration-plugin-system.md) (plugin runtime), [ADR-129](ADR-129-homecore-automation-engine.md) (automation), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (event history + semantic search), [ADR-116](ADR-116-cog-ha-matter-seed.md) (SEED/Matter), [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) (SEED ingest), and [ADR-105](ADR-105-federated-csi-training.md) (federation). The keyword entity filter (§4.4) ships immediately; semantic search layers on once [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) lands. The exact panel→endpoint→upstream map and the new gateway code each requires are §11; the staged delivery is §12.
---
## 8. Consequences
### 8.1 Positive
- Operators, integrators, and residents get a single coherent surface for the full two-tier stack, replacing the need to SSH into SEEDs or hand-craft API calls.
- The dashboard reuses the proven Cognitum design tokens and component patterns verbatim, so it ships visually consistent with no separate design effort and no perceptible seam between surfaces.
- Diagnostic signals that today are invisible (`stale`/`vetoed` flags, kNN fragility, provenance lineage, channel lag) become first-class, surfacing the system's most common real-world failure modes directly to operators.
### 8.2 Negative / risks
- The UI hard-depends on the wire-compat guarantees of ADR-130 and the calibration contract of ADR-151; schema drift in either breaks panels silently. Integration tests against every backing contract in §7 are required.
- Committing to the complete UI in one deliverable is a larger up-front effort and couples the UI's readiness to the maturity of multiple backing services (§7.1, §11). The mitigation is the BFF gateway (§2.1): each panel targets one same-origin endpoint, and the gateway absorbs upstream churn behind a stable contract.
- Promoting `homecore-server` to a gateway means it now **proxies cross-tier traffic** (calibration API, SEED HTTPS, appliance daemons). This adds a network hop, a place for upstream timeouts/partial failures to surface, and a server-side store of SEED bearer tokens that must be protected (§11.10). Each proxied route needs an explicit timeout + typed error mapping so one slow SEED cannot stall the dashboard.
- Several panels depend on data that only exists on **real hardware or new daemons** (SEED device tier, appliance host metrics, COG supervisor). Until those upstreams exist the corresponding gateway routes return `503 upstream_unavailable`; this is honest but means the dashboard is only as "live" as the tiers behind it (§11 classifies every endpoint by what it depends on).
- Faithfully mirroring `seed.cognitum.one/store` couples HOMECORE-UI to the external Cog Store's evolving design; token drift there must be tracked and re-synced.
- The two-tier mental model (Appliance root, SEED children, ESP32 leaves) must be enforced consistently; any panel that flattens or peers the tiers undermines the core architectural constraint.
---
## 9. References
- `https://seed.cognitum.one/store` — primary design reference for all visual conventions.
- `https://seed.cognitum.one/status` — reference for live metric-card layout.
- [ADR-126](ADR-126-ruview-native-ha-port-master.md) — HOMECORE master ADR.
- [ADR-127](ADR-127-homecore-state-machine-rust.md) — HOMECORE-CORE state machine and entity registry.
- [ADR-128](ADR-128-homecore-integration-plugin-system.md) — HOMECORE-PLUGINS WASM COG substrate.
- [ADR-129](ADR-129-homecore-automation-engine.md) — HOMECORE automation engine.
- [ADR-130](ADR-130-homecore-rest-websocket-api.md) — HOMECORE-API REST + WebSocket wire-compat.
- [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) — homecore-recorder, history + semantic search.
- [ADR-100](ADR-100-cog-packaging-specification.md) — Cognitum Cog packaging specification (manifest.json, status values, on-device layout).
- [ADR-116](ADR-116-cog-ha-matter-seed.md) — cog-ha-matter (SEED cog, HA-DISCO entity surface, mDNS).
- [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) — ESP32 CSI → Cognitum SEED RVF ingest pipeline (SEED architecture detail).
- [ADR-105](ADR-105-federated-csi-training.md) — Federated CSI training (multi-SEED federation).
- [ADR-151](ADR-151-room-calibration-specialist-training.md) — Per-room calibration specialist training (calibration HTTP API).
- `v2/crates/homecore/src/` — state machine, entity, event, registry source.
- `docs/integration/calibration-appliance-integration.md` — calibration API contract and RoomState schema.
---
## 10. Implementation status
Implemented as a zero-dependency, no-build-step vanilla TS/JS + CSS frontend served by `homecore-server` at `/homecore` (the `rufield-viewer` "Axum + vanilla-JS" pattern). The complete deliverable per §2/§7 — all ten panels, fully rendered, wired to live data where the backing service exists and to a contract-conformant DEMO-flagged mock layer (§7.1) where it does not.
**Location:** `v2/crates/homecore-server/ui/``css/tokens.css` (the §3.1 palette, verbatim) + `css/app.css` (§3.3 components); `js/{ui,api,ws,mock,app}.js` (shared helpers, REST client, `subscribe_events` WS client, mock layer, shell+router); `js/panels/*.js` (one module per §4 panel). Mounted via `tower-http` `ServeDir` in `homecore-server::build_app`, gated by `--ui-dir`/`HOMECORE_UI_DIR`.
**Verification:**
- **Rust** — `#[cfg(test)] mod ui_tests` in `homecore-server/src/main.rs`: 5 integration tests (`tower::oneshot`) covering index, design tokens, all ten panel modules served, API coexistence, and mount-disable. *Written but not compiled in the authoring environment (no Rust toolchain present); run `cargo test -p homecore-server` on a Rust host before merge.*
- **Frontend** — `ui/` test suite under plain `node` (no npm install): `npm test` → import/export graph verifier (15 modules) + render-smoke (executes every panel against a DOM shim; 21 checks) + interaction suite (live WS patch, ws.js handshake/parse, calibration contract; 3 checks). **24/24 green.**
- **Benchmark** — `npm run bench`: total bundle **136.8 KB** uncompressed (**~37× smaller** than HA's ~5 MB Lit bundle, the ADR-126 §1.1 foil); slowest panel **1.5 ms/cold-render**.
**Honest scope — current vs. target.** *Earlier cut:* the front-end was complete but only §4.4 Entities was wired to a real backend; the rest rendered from an in-browser mock. *This revision implements the §11 wiring:*
- **Front-end (§11.11) — DONE and verified.** `api.js` rewritten: all data accessors are async and call the §11.2 gateway routes; the mock layer is demoted to a dev-only fixture reachable **only** under `?demo=1` / `HOMECORE_UI_DEMO` (§2.2); every panel `await`s and renders a typed empty/error state on failure (no mock fallback in production). All ten panels converted (3 by hand, 7 via parallel agents). Verified under Node: 5 test files green — import graph, boot, render-smoke (22), interaction (3), **and a new prod-errors suite (13) that runs with demo OFF + gateway unreachable and asserts every panel renders an error state, never mock, never throws** (it caught and fixed a real unhandled-rejection in the events panel).
- **Gateway (§11.1–§11.6) — IMPLEMENTED, COMPILED, TESTED, RUN.** New `homecore-server/src/gateway.rs` (+`reqwest` dep, +CLI/env flags `--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`, merged into `build_app` via `gateway_router`). Real handlers: `/api/cal/*` reverse-proxy (W2), `GET /api/homecore/rooms` with the §11.3 RoomState adapter (W2), `GET /api/homecore/cogs` supervisor over the apps dir (W4), `GET /api/homecore/appliance` from `/proc` + port probes (W6). SEED-device/appliance-daemon routes (seeds, federation, witness, privacy, settings, automations, events-history, hailo, tokens — W3/W5) return a typed `503 upstream_unavailable` per §11.2. **Verified on Rust 1.89: `cargo test -p homecore-server --no-default-features` = 12/12 pass** (6 gateway + 6 UI mount). **Run live:** `GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes; unauth → `401`; `cogs``[]` with no apps dir; SEED-tier → typed `503`; and against a mock calibration upstream the `/api/cal/*` proxy passes through (`200`) and `GET /api/homecore/rooms` correctly adapts `RoomState` to the UI shape (`breathing``breathing_bpm`, `heartbeat:null``heart_bpm:null`, injected `anomaly.threshold`/`room_id`, `stale` passthrough). **Live testing caught + fixed one real bug** — a double-`v1` path in the `/api/cal/*` proxy URL.
The endpoint-by-endpoint contract is **§11**; the staged plan and which endpoints depend on real SEED/appliance hardware vs. pure software is **§12**.
---
## 11. Backend wiring — making every panel real
This section is the authoritative contract for full functionality. It removes the mock layer from the production path (§2.2) by routing every panel through the `homecore-server` BFF gateway (§2.1). Each endpoint is classified by what it depends on:
- **EXISTS** — backend code already in this repo; gateway only proxies/adapts.
- **NEW-GW** — pure software the gateway itself implements (filesystem, `/proc`, process control, recorder query) — no new external service.
- **NEW-API** — a small HTTP wrapper to add to an existing in-repo crate (`homecore-api`, `homecore-automation`).
- **SEED-DEV** — depends on a SEED node's on-device HTTPS API (separate hardware/firmware).
- **APPLIANCE** — depends on an appliance daemon / accelerator stat source.
### 11.1 Gateway shape
`homecore-server` already mounts `homecore-api` at `/api/*` and the UI at `/homecore`. It gains a new **`/api/homecore/*`** namespace (the dashboard-specific aggregation surface) plus a **`/api/cal/*`** reverse-proxy to the calibration service. The browser issues only same-origin requests; the gateway fans out server-side, holding all upstream credentials (§11.10). Every proxied route has an explicit timeout and maps upstream failure to a typed body (`503 upstream_unavailable`, `504 upstream_timeout`) so one slow tier never stalls the dashboard.
### 11.2 Master endpoint contract (panel → gateway route → upstream → status)
| Panel | UI method (`api.js`) | Gateway route | Upstream / source | Class |
|---|---|---|---|---|
| §4.4 Entities | `states()` | `GET /api/states` | `homecore` state machine | **EXISTS** ✅ wired |
| §4.4/§4.8 live feed | WS | `GET /api/websocket` (`subscribe_events`) | `homecore` event bus | **EXISTS** ✅ wired |
| §4.8 Event history | `eventHistory(q)` | `GET /api/events?since=…` | `homecore-recorder` ([ADR-132](ADR-132-homecore-recorder-history-semantic-search.md)) | **NEW-API** |
| §4.8 Automations | `automations()` / `saveAutomation()` | `GET/POST/DELETE /api/homecore/automations` | `homecore-automation` ([ADR-129](ADR-129-homecore-automation-engine.md)) | **NEW-API** |
| §4.5 Rooms | `roomStates()` | `GET /api/homecore/rooms` → per-room `GET /api/cal/v1/room/state?bank=` | `calibrate-serve` ([ADR-151](ADR-151-room-calibration-specialist-training.md)) | **EXISTS** (proxy + adapter) |
| §4.7 Calibration | `calibration.*` | `POST /api/cal/v1/calibration/{start,stop}`, `GET …/status`, `POST …/enroll/anchor`, `GET …/enroll/status`, `POST …/room/train` | `calibrate-serve` | **EXISTS** (proxy) |
| §4.6 COGs | `cogs()` / `cogAction()` / `cogLogs()` | `GET /api/homecore/cogs`, `POST …/cogs/:id/{start,stop,restart}`, `GET …/cogs/:id/logs`, `GET/PUT …/cogs/:id/config` | COG supervisor over `/var/lib/cognitum/apps/` ([ADR-100](ADR-100-cog-packaging-specification.md)/[ADR-128](ADR-128-homecore-integration-plugin-system.md)) | **NEW-GW** |
| §4.6 Hailo HEF | `hailo()` | `GET /api/homecore/hailo` | `ruvector-hailo-worker:50051` | **APPLIANCE** |
| §4.1 Appliance health | `appliance()` | `GET /api/homecore/appliance` | host `/proc` + Hailo stats + service probes | **NEW-GW** (+APPLIANCE for Hailo) |
| §4.1/§4.2 Fleet + SEED detail | `seeds()` / `seed(id)` | `GET /api/homecore/seeds`, `GET …/seeds/:id` | SEED device HTTPS API ([ADR-069](ADR-069-cognitum-seed-csi-pipeline.md)) via registry | **SEED-DEV** |
| §4.2 SEED actions | `seedCompact()` / `seedVerify()` | `POST …/seeds/:id/{compact,witness/verify}` | SEED device API | **SEED-DEV** |
| §4.3 Federation | `federation()` | `GET /api/homecore/federation` | federation coordinator ([ADR-105](ADR-105-federated-csi-training.md)) | **SEED-DEV/APPLIANCE** |
| §4.9 Witness/Audit | `witnessLog(p,s)` | `GET /api/homecore/witness?page=…` | merge: `homecore` Ed25519 chain + per-SEED SHA-256 chains | **NEW-API + SEED-DEV** |
| §4.9 Privacy mode | `privacyModes()` / `setPrivacy()` | `GET/POST /api/homecore/privacy` | SEED privacy control plane ([ADR-141](ADR-141-bfld-privacy-control-plane-modes-attestation.md)) + cog-ha-matter | **SEED-DEV** |
| §4.9 Export bundle | `exportAttestation()` | `GET /api/homecore/witness/export` | gateway packages both chains | **NEW-GW** |
| §4.10 Tokens (LLAT) | `tokens()` / `createToken()` / `revokeToken()` | `GET/POST/DELETE /api/homecore/tokens` | `homecore-api` `LongLivedTokenStore` | **NEW-API** |
| §4.10 MQTT/Matter | `mqttConfig()` | `GET /api/homecore/integrations/mqtt` | cog-ha-matter config ([ADR-116](ADR-116-cog-ha-matter-seed.md)) | **NEW-GW/SEED-DEV** |
| §4.10 ESP32 provisioning | `nodes()` / `assignRoom()` | `GET/PUT /api/homecore/nodes` | SEED ingest config ([ADR-069](ADR-069-cognitum-seed-csi-pipeline.md)) | **SEED-DEV** |
| §4.10 SEED mgmt | `pairSeed()` / `rotateToken()` | `POST /api/homecore/seeds/{pair,:id/rotate-token}` | SEED pairing (USB `169.254.42.1`) | **SEED-DEV** |
### 11.3 Calibration proxy + RoomState adapter
The calibration service is real but on a different binary/port; the gateway reverse-proxies it under `/api/cal/*` (upstream base from `HOMECORE_CALIBRATION_URL`). Its `RoomState` (`wifi-densepose-calibration/src/runtime.rs`) does **not** match the UI's shape, so the gateway adapts it in `GET /api/homecore/rooms`:
| Real field (`RoomState`) | UI field | Adapter rule |
|---|---|---|
| `breathing: Option<SpecialistReading>` | `breathing_bpm: {value,confidence}\|null` | rename; `value`=`reading.value`, `confidence`=`reading.confidence`; `None``null` (preserves "not trained") |
| `heartbeat: Option<…>` | `heart_bpm: {…}\|null` | rename `heartbeat``heart_bpm` |
| `presence/posture/restlessness` | same names `{value,confidence}\|null` | `posture.value`=`reading.label` (class), else numeric |
| `anomaly: Option<…>` | `anomaly: {value,confidence,threshold}` | inject `threshold`=`MixtureOfSpecialists.veto_threshold` (0.5) |
| `vetoed` / `stale` | `vetoed` / `stale` | pass through (drives the §4.5/§6 banners) |
| *(absent)* | `room_id`, `seeds[]` | injected by the gateway from the **room registry** |
A **room registry** (config or derived from `GET /api/cal/v1/calibration/baselines`) maps each `room_id` → bank name + serving SEED ids, so `GET /api/homecore/rooms` returns one adapted record per room. `Option::None` → JSON `null` keeps the null-vs-withheld distinction (§6 invariant 3) intact end-to-end.
### 11.4 SEED registry & device-API proxy
The gateway holds a **SEED registry** (`device_id` → base URL + bearer token + zone), populated by pairing (§4.10) and persisted server-side. `GET /api/homecore/seeds[/:id]` fans out to each SEED's on-device API and shapes the result to the §4.2 card/detail model. Expected SEED-side endpoints (the contract the SEED firmware must satisfy — a subset of its 98 endpoints): health; vector-store stats (`vector_count`, `dim`, `epoch`, `knn_latency_ms`, ingest rate); witness (`len`, `last_verify`, `valid`) + `POST verify`; onboard sensors (BME280/PIR/reed/ADS1115/vibration); reflex rules + thresholds; cognitive analysis (fragility, coherence phases); ingest feeders (ESP32 node ids + packet type `0xC5110003`/`0xC5110002` + rate). Offline/unreachable SEEDs surface as `online:false` (drives the §4.1 red tint) rather than failing the whole list.
### 11.5 Appliance metrics collector (§4.1)
`GET /api/homecore/appliance`, implemented in the gateway: CPU/RAM/uptime from `/proc`; Hailo load + temperature from the Hailo runtime/sysfs (or `ruvector-hailo-worker` stats); service health by probing `ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`; event-bus rate from the `homecore` broadcast channel + its lag counter (already exposed for §4.1/§4.4).
### 11.6 COG supervisor (§4.6)
`GET /api/homecore/cogs`: read each `/var/lib/cognitum/apps/*/manifest.json` ([ADR-100](ADR-100-cog-packaging-specification.md)), the pid file, and verify `binary_sha256` + `binary_signature` (Ed25519) → status/shield. `POST …/cogs/:id/{start,stop,restart}` performs supervised process control; `GET …/cogs/:id/logs` tails `output.log`/`error.log`; `GET/PUT …/cogs/:id/config` reads/writes `config.json`. Hailo-arch COGs join the §11.5 Hailo stats. The Cog Store/App-Registry **browsing** panel was removed per product decision; this is operational management only.
### 11.7 Witness aggregation + privacy (§4.9)
`GET /api/homecore/witness` merges two chains chronologically: the `homecore` Ed25519 state-transition chain (exposed by a small `homecore-api` route over its witness log) and each paired SEED's SHA-256 ingest chain (proxied via the registry), paginated server-side. `GET/POST /api/homecore/privacy` reads/sets per-SEED privacy mode via the SEED privacy control plane ([ADR-141](ADR-141-bfld-privacy-control-plane-modes-attestation.md)) — the POST is the high-stakes confirmed toggle (§4.9). `GET /api/homecore/witness/export` packages both chains into the downloadable attestation bundle.
### 11.8 Event history + automation CRUD (§4.8)
`homecore-api` adds `GET /api/events?since=…` backed by `homecore-recorder` ([ADR-132](ADR-132-homecore-recorder-history-semantic-search.md)) for history (live updates continue over the existing WS). The automation builder persists through `GET/POST/DELETE /api/homecore/automations`, a thin HTTP wrapper over the `homecore-automation` engine's register/list/remove ([ADR-129](ADR-129-homecore-automation-engine.md)). RuView-specific triggers (RoomState thresholds, SEED reflex events) map onto the engine's trigger types.
### 11.9 Entity provenance convention (§4.4/§6)
The first-class provenance badge requires each entity to carry its lineage. Convention: every integration writes `attributes.source` (and, where known, `attributes.seed` / `attributes.cog`) when it sets state; `cog-ha-matter` ([ADR-116](ADR-116-cog-ha-matter-seed.md)) populates these from the ESP32 node → SEED → COG path and HA `via_device`. The gateway/UI resolves node→seed→cog from these attributes (no fabrication; missing lineage renders as "unknown", not invented).
### 11.10 Auth, credentials, config
- **Browser → gateway:** one long-lived access token (the §4.10 LLAT), sent as `Authorization: Bearer`; validated by `homecore-api`'s `LongLivedTokenStore`. The dev default (`allow_any_non_empty`) stays for local runs; production provisions `HOMECORE_TOKENS`.
- **Gateway → upstreams:** SEED bearer tokens and the calibration token live **only** server-side (SEED registry + `HOMECORE_CALIBRATION_TOKEN`); never sent to the browser. This is the reason the gateway exists.
- **Config:** `HOMECORE_CALIBRATION_URL`, SEED registry store path, per-proxy timeout (default 2 s), `HOMECORE_UI_DEMO` (dev fixture). No browser CORS needed (same origin); gateway→upstream is server-to-server.
### 11.11 Front-end changes
`api.js`: drop the mock fallback from the production path — methods call the §11.2 gateway routes; `this.base` stays same-origin; the mock layer is reachable only under `?demo=1`/`HOMECORE_UI_DEMO`. Every panel renders a **typed empty/error state** (not mock) when its route returns `503/504`. `mock.js` moves to a dev fixture (kept for the offline test harness, excluded from the production bundle). The §10 frontend tests are re-pointed at the gateway contract (and gain contract tests per §11.2 route).
---
## 12. Delivery plan to full functionality
Staged so each wave is independently shippable behind the gateway, lands real data for a coherent set of panels, and has an explicit acceptance gate. "Class" reuses §11's tags.
| Wave | Scope | Class | Acceptance gate |
|---|---|---|---|
| **W1 — Gateway foundation** | `/api/homecore/*` scaffold in `homecore-server`; auth passthrough; per-proxy timeout + typed errors; `api.js` base + remove prod mock (`?demo=1` only); panels get typed empty/error states | NEW-GW | Entities + live WS still green; with no upstreams, every other panel shows "upstream unavailable", **never** mock (unless `?demo=1`); Rust + JS suites pass |
| **W2 — Rooms + Calibration** | `/api/cal/*` reverse-proxy; `GET /api/homecore/rooms` with the §11.3 RoomState adapter + room registry; wire §4.5 + the §4.7 wizard to real endpoints; delete the in-browser calibration stub | EXISTS (proxy+adapter) | Against a running `calibrate-serve` (replayed CSI), the wizard drives a real baseline→enroll→train→verify and §4.5 shows real `RoomState` with correct stale/veto/null mapping; contract test on the adapter |
| **W3 — Events + Automations** | `GET /api/events` over `homecore-recorder`; `/api/homecore/automations` over `homecore-automation` | NEW-API | §4.8 history loads from recorder; an automation created in the UI persists and fires via the engine |
| **W4 — COG management** | `/api/homecore/cogs*` supervisor over `/var/lib/cognitum/apps/` (manifest + pid + sig verify + logs + config) | NEW-GW | §4.6 lists real installed COGs; start/stop/restart works; sha256/signature shield reflects real verification; logs tail |
| **W5 — SEED tier** | SEED registry + pairing; `/api/homecore/seeds*` device proxy; witness merge + privacy control; ESP32 provisioning | SEED-DEV | Against a real or emulated SEED API, §4.2/§4.3/§4.9/§4.10 show real vector-store/witness/sensor/reflex/cognition data; SEED tokens stay server-side; offline SEED → red tint, not a failed page |
| **W6 — Appliance + federation + Hailo** | `/api/homecore/appliance` (host metrics + service probes); `/api/homecore/hailo`; `/api/homecore/federation` ([ADR-105](ADR-105-federated-csi-training.md)) | NEW-GW + APPLIANCE | §4.1 health is real; §4.6 Hailo HEF/throughput real; §4.3 federation round/coordinator/Krum real |
**Definition of done (full functionality):** with W1W6 merged and the upstream tiers running, loading `/homecore` with **no** `?demo=1` flag shows live data on all ten panels, `api.anyDemo()` is false, and no panel renders fabricated values. Panels whose tier is offline show typed empty/error states. The mock layer is reachable only as the `?demo=1` developer fixture.
### 12.1 Wave status (this revision)
| Wave | Status |
|---|---|
| **W1 — Gateway foundation** | ✅ DONE — `gateway.rs`, auth passthrough, typed `503/504`, merged into `build_app`; front-end mock removed from prod path + `?demo=1` fixture; typed error states. **Compiled + 12/12 Rust tests + JS suite green + run live.** |
| **W2 — Rooms + Calibration** | ✅ DONE — `/api/cal/*` reverse-proxy + `GET /api/homecore/rooms` RoomState adapter; front-end calibration stub deleted (now proxies the real API). **Proven live against a calibration upstream** (proxy 200 + adapted shape); null-preservation unit-tested. |
| **W3 — Events + Automations** | ⏳ gateway returns typed `503` (recorder/automation HTTP wrappers pending); front-end handles it gracefully (history note, builder still usable). |
| **W4 — COG management** | ✅ supervisor DONE — lists `/var/lib/cognitum/apps/` manifests + pid liveness (returns `[]` live with no apps dir); start/stop/log/config control is the remaining follow-up. |
| **W5 — SEED tier** | ⏳ gateway returns typed `503` (SEED registry + device proxy pending real/emulated SEED hardware). |
| **W6 — Appliance + federation + Hailo** | ◑ appliance host metrics from `/proc` + port probes DONE (live `/proc` data verified); Hailo stats + federation remain `503` (need the accelerator stat source / coordinator). |
**Status:** the gateway is **compiled and tested on Rust 1.89** (`cargo test -p homecore-server` = 12/12) and was **run live** (curl proof in §10). The one remaining caveat is intrinsic, not an environment limit: **W3/W5/W6-Hailo/federation depend on services/hardware that are not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, the Hailo stat source), so they return honest typed `503`s and the UI shows error states — exactly as §2.2/§11.2 prescribe. W1/W2/W4/W6-appliance are functional now.
### 12.2 Security review (PR #1082)
A high-effort public-PR review of the merged gateway + front-end surfaced the following, all fixed and pinned by tests (`cargo test -p homecore-server` is now **18/18**):
| # | Severity | Finding | Fix |
|---|---|---|---|
| 1 | **HIGH** | **Path-traversal / confused-deputy SSRF** in the `/api/cal/*` reverse-proxy. The wildcard path was interpolated into the upstream URL while `proxy()` attaches the privileged server-side calibration bearer, so `/api/cal/v1/../../x` (or `..%2f`, `%2e%2e`, leading `/`, `\`, double-encoded `%252e`) could escape the `…/api/` scope **with the token**. | `validate_proxy_path()` decode-then-checks and rejects absolute / backslash / dot-segment / encoded-traversal paths with a typed **400 before the URL is built** (GET **and** POST); legit `v1/...` paths still pass. |
| 2 | Correctness | **CORS + tracing didn't cover gateway routes**`/api/homecore/*` + `/api/cal/*` were `.merge()`d outside `homecore-api::router()`'s layers. | The audited HC-05 `build_cors_layer()` + `TraceLayer` are now applied to the whole merged app in `main.rs`. |
| 3 | Honesty (§6) | **Fabricated data** — hardcoded `anomaly.threshold: 0.5` in the adapter; dashboard rendered `"null%"`/`"null°C"`; COG Hailo pill hardcoded `"connected"`; `rooms.js` defaulted a null threshold to `0.8`. | Threshold passes through the real upstream value or emits `null` (withheld); dashboard renders `—`; the Hailo pill reflects the real appliance probe; the UI treats a null threshold as withheld. |
| 4 | Robustness | A string `hef` (forwarded verbatim) threw on `.forEach`/`.join`; `frames/target` could be `NaN%`/`Infinity%`; calibration Restart leaked the baseline `setTimeout` poll. | `asArray()` coercion; `target > 0` guard; cancellable poll cleared on Restart / panel teardown. |
| 5 | Perf | Sequential per-bank RoomState fetches; blocking `std::net::TcpStream::connect_timeout` probes on an async handler; `mock.js` statically bundled. | Concurrent `futures::join_all`; async `tokio::net::TcpStream` + `timeout`; demo-only dynamic `import()` of `mock.js`. |
**Known limitations carried forward (not regressions):**
- **`reqwest` rustls-only is a workspace-wide concern.** `homecore-server` opts into `rustls-tls` only, but cargo feature-unification means any sibling crate enabling the default `native-tls` re-introduces OpenSSL into the final binary. A true "no OpenSSL on the appliance" guarantee requires aligning **every** reqwest-pulling crate on rustls-only — out of scope for this PR; documented at the dependency in `Cargo.toml`.
- **DEV-mode auth.** When `HOMECORE_TOKENS` is unset, the token store falls back to `allow_any_non_empty()` (any non-empty bearer accepted) on `0.0.0.0`. This is pre-existing and intentionally **unchanged** here; the loud boot `warn!` is retained. Provision real tokens (`HOMECORE_TOKENS=…`) before exposing the server to a network.
@@ -0,0 +1,92 @@
# ADR-177: `nvsim` Degenerate-Input Hardening (NV-Diamond Simulator)
| Field | Value |
|-------|-------|
| **Status** | Accepted — 2 real MEDIUM bugs fixed + pinned; determinism preserved |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **NVSIM-FAILCLOSED** |
| **Reviews** | ADR-089 (`nvsim` NV-diamond magnetometer pipeline simulator) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 2 of 4 |
## Context
`nvsim` (ADR-089) is a standalone, **WASM-ready** deterministic NV-diamond
magnetometer pipeline simulator — a forward-only leaf:
`scene → source → propagation → NV ensemble → digitiser → MagFrame + SHA-256
witness`. It has no network surface, so the real attack surface is **degenerate
physical-parameter input** crossing the external boundary — specifically the
WASM `config_json` / `scene_json` entry points.
Two properties matter for this crate that don't for others: it is billed
**deterministic** (a published cross-machine witness must reproduce bit-exactly),
and under `panic=abort` WASM any panic **aborts the whole module**. So a
config-induced panic is a denial-of-service, and a silent numeric corruption
defeats the simulator's entire purpose.
## Decision
Fix the two reachable degenerate-input bugs at their funnel points, each pinned
by a fails-on-old test, **without perturbing the deterministic happy path** (the
guards fire only on non-finite / degenerate input; the published witness is
unchanged).
### Findings fixed (both MEASURED-reproduced)
| # | Severity | Location | Issue | Fix |
|---|----------|----------|-------|-----|
| NVSIM-DT-01 | MEDIUM (DoS) | `pipeline.rs:58,95` | `dt = config.dt_s.unwrap_or(1.0 / f_s_hz)`; an external `f_s_hz == 0.0``dt = +Inf``(dt*1e6) as u64` saturates to `u64::MAX``(sample as u64) * dt_us` **panics `attempt to multiply with overflow`** at `sample ≥ 2` (debug/WASM-abort; garbage `t_us` in release). MEASURED: panic at `pipeline.rs:95:30`. | Sanitise `dt` (non-finite/non-positive → 1 µs fallback), cap the `u64` cast at `u64::MAX`, `saturating_mul` the timestamp — no config can overflow it. |
| NVSIM-NAN-01 | MEDIUM (silent corruption) | funnel `digitiser.rs::adc_quantise` (root: near-field clamp bypass in `source.rs`) | A non-finite scene param (NaN/Inf dipole position, Inf moment, NaN loop radius) **bypasses the near-field clamp** (`NaN < R_MIN_M == false` → the `1/r³` path runs → NaN field), and at the ADC `NaN as i32 == 0` (Rust saturating cast) emits a frame `b_pt=[0,0,0]` with **`ADC_SATURATED` CLEAR** — indistinguishable from a legitimate zero-field reading. MEASURED: `b=[NaN,NaN,NaN] sat=false``b_pt=[0,0,0] flags=0b0000`. | `adc_quantise`: any non-finite input → code `0` **with the saturation flag raised**; the pipeline's existing `adc_sat` OR-reduction propagates `ADC_SATURATED` onto the frame, making the corruption visible downstream. |
This is the same **NaN-fail-open / NaN-poisoning** family seen across
calibration/vitals/geo and ruview-swarm — non-finite input defeating a guard —
but bounded here to a single frame (no cross-timestep accumulator).
### Dimensions confirmed clean (with evidence)
1. **Determinism integrity — clean.** One RNG only: `ChaCha20Rng::seed_from_u64(seed)`,
fully caller-seeded (grep: one `seed_from_u64`, **zero** `thread_rng`/`getrandom`/
`SystemTime`/`Instant`/`HashMap`); `Cargo.toml` pins `rand`/`rand_chacha`
`default-features=false` (no OS entropy). BoxMuller draws
`gen_range(f64::EPSILON..=1.0)` (avoids `ln(0)=-Inf` by construction). Frame
bytes fixed LE; source summation order fixed by `Vec` order. **The published
cross-machine witness `cc8de9b0…93b4` (`proof_witness_publishes_a_known_value`)
passes UNCHANGED after both fixes** — the happy path is byte-identical; guards
touch only degenerate inputs. *Attested caveat (not a finding): libm
`cos`/`ln`/`sqrt` could differ x86↔wasm; the witness is documented as
x86_64-captured.*
2. **Panic-free deserialisation — clean.** `MagFrame::from_bytes` validates
len/magic/version, then per-field `buf[a..b].try_into().expect(...)` are over
fixed sub-ranges of an already-length-checked 60-byte buffer (provably
infallible). No `unsafe`, no `panic!`/`unreachable!` in production; every other
`unwrap`/`expect` is `#[cfg(test)]`.
3. **Div-by-zero / numerical landmines — clean.** `dipole_field`/`current_loop_field`
clamp `r_norm < R_MIN_M` before `1/r³`,`1/r²` (finite inputs); `shot_noise_floor`
guards `denom <= 0`; `vec3_normalise` guards `n < 1e-20`. The only hole was the
NaN *bypass* of the clamp — closed at the ADC funnel (NVSIM-NAN-01).
## Validation
- `cargo test -p nvsim --no-default-features`**50 → 53** passed, 0 failed (+3 pins:
`degenerate_zero_sample_rate_does_not_panic`,
`non_finite_scene_input_flags_frame_instead_of_silently_zeroing`,
`adc_quantise_flags_non_finite_as_saturated`).
- `cargo test --workspace --no-default-features`**exit 0**, 0 failed.
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash
`f8e76f21…46f7a` unchanged (nvsim off the signal proof path).
- nvsim's own cross-machine witness `cc8de9b0…93b4` reproduces unchanged.
## Consequences
### Positive
- A config-induced WASM-abort DoS and a silent NaN→fake-zero-field corruption are
closed at their funnel points, each regression-pinned, with the deterministic
witness proven intact.
### Negative / Neutral
- None. Guards affect only degenerate inputs; happy-path output is byte-identical.
## Links
- ADR-089 — `nvsim` NV-diamond magnetometer simulator
- ADR-176 — `ruview-swarm` (sibling NaN-fail-open review)
- ADR-172 — core/cli (where the NaN-bug-class root was settled NO)
@@ -0,0 +1,87 @@
# ADR-178: `wifi-densepose-desktop` IPC Injection Fix + Capability Least-Privilege
| Field | Value |
|-------|-------|
| **Status** | Accepted — 2 real MODERATE bugs fixed + pinned (MEASURED on Windows) |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **DESK-LOCKDOWN** |
| **Reviews** | `wifi-densepose-desktop` (Tauri v2 desktop app) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 3 of 4 |
## Context
`wifi-densepose-desktop` is the Tauri v2 desktop app (ESP32 discovery, firmware
flashing, OTA, provisioning, server control). The real attack surface is the
**Tauri IPC boundary**`#[tauri::command]` handlers that take arguments from the
webview/JS — and the **capability/allowlist scope**. The crate **builds and tests
on Windows** (Tauri 2.10.3, webview2 path, no GTK), so both findings are MEASURED,
not source-analysis-only.
## Decision
Fix the two real findings; attest the rest of the surface clean with evidence.
### Findings fixed (both MEASURED)
| # | Severity | Location | Issue | Fix |
|---|----------|----------|-------|-----|
| WDP-DESK-01 | MODERATE | `src/commands/discovery.rs:438` (`configure_esp32_wifi`) | Webview-supplied `ssid`/`password` are concatenated into newline-terminated serial commands (`wifi_config {} {}\r\n`, `set ssid {}\r\n`) with **no validation** → a `\r\n` in either field **injects an arbitrary follow-up firmware command** (`reboot`, `erase_nvs`) across the IPC trust boundary. | `validate_wifi_credentials()` — WPA2 length bounds (SSID 132, password 863) **+ reject all control chars** (`char::is_control()`), called fail-closed before any serial write. |
| WDP-DESK-02 | MODERATE | `capabilities/default.json:7-8` | `shell:allow-execute` + `shell:allow-open` granted to the webview but **unused** (Rust spawns via `std::process::Command`; the UI uses only `dialog.open`). A webview compromise (a UI-dependency XSS) → arbitrary **unscoped host command execution**. | Removed both `shell:` permissions (kept `core:default` + the two in-use `dialog:` perms); regenerated `gen/schemas/capabilities.json` now asserts `["core:default","dialog:allow-open","dialog:allow-save"]`. |
Both are MODERATE (not HIGH): each requires a webview compromise or a malicious
local caller to weaponize. The unifying lesson is **least privilege at the IPC
boundary** — validate every webview-supplied argument that reaches a serial/FS/
process sink, and grant only the capabilities actually exercised.
### Tauri-command + capability audit (every handler)
All 30+ command handlers were mapped. Only `configure_esp32_wifi` lacked input
validation on a string that reached a command sink (WDP-DESK-01). Every
subprocess uses `Command::new(prog).args([...])` (argv vector — no shell-string
interpolation), so `port`/`source`/`chip`/`baud` cannot inject a second command
even unvalidated. `tauri.conf.json` ships **no** `fs`/`http` plugin and **no**
`"all":true`/`"$HOME/**"` scope; after WDP-DESK-02 the allowlist is minimal.
### Dimensions confirmed clean (with evidence)
1. **Directory traversal / arbitrary file** — path args (`firmware_path`/`wasm_path`)
are blobs the local user selects via the native `dialog.open` picker; settings
I/O is a fixed filename under `app_data_dir`. No attacker-named path sink.
2. **Shell-string injection** — every subprocess is an argv vector; grep found no
shell-string interpolation anywhere.
3. **SSRF-to-secret**`node_ip`-built URLs target the local ESP32 mesh and return
only device status JSON; no credential returned to the webview.
4. **Panic-on-input** — handlers use `.map_err(|e| e.to_string())?`; the one
`expect` is guarded by an `is_none()` early-return; provision/discovery
deserializers bounds-check every slice index (NVS size capped ≤ 4096).
5. **Hardcoded secrets**`ota_psk` is a per-call `Option<String>`, never embedded;
grep for embedded keys/tokens over `src/` is empty.
6. **Shell plugin genuinely unused**`tauri_plugin_shell` is `init()`-ed but its
`Command`/`open` API is never invoked from Rust or the TS UI (which imports only
`@tauri-apps/plugin-dialog`) — confirming WDP-DESK-02 is safe to remove.
## Validation
- `cargo check -p wifi-densepose-desktop --no-default-features``Finished` (Windows, MEASURED).
- `cargo test -p wifi-densepose-desktop --no-default-features` → lib **18 → 21** (+3 validator pins:
`test_validate_wifi_credentials_rejects_injection` / `_rejects_out_of_range` / `_accepts_valid`),
integration 21/21, **0 failed**.
- Capability narrowing MEASURED: regenerated `capabilities.json` permission set verified.
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash `f8e76f21…46f7a`
unchanged (desktop off the signal proof path).
## Consequences
### Positive
- An IPC serial-command-injection path and an over-broad shell capability are
closed in the desktop app, each pinned / verified, with the rest of the
30-command IPC surface attested clean.
### Negative / Neutral
- None. The removed shell capability was unused; the validator rejects only
malformed/hostile credentials.
## Links
- ADR-176 / ADR-177 — sibling Milestone-#9 reviews (ruview-swarm, nvsim)
- ADR-172 — core/cli review
@@ -0,0 +1,81 @@
# ADR-179: `wifi-densepose-occworld-candle` Checkpoint-Load Hardening
| Field | Value |
|-------|-------|
| **Status** | Accepted — 1 HIGH + 2 LOW bugs fixed + pinned (MEASURED on Windows) |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **OCCWORLD-DTYPE** |
| **Reviews** | `wifi-densepose-occworld-candle` (Candle occupancy-world model) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 4 of 4 — **CLOSES the milestone** |
## Context
`wifi-densepose-occworld-candle` is a Candle-based occupancy-world model
(VQ-VAE + transformer over occupancy tokens). The real risk surface for an ML
crate is degenerate-input / malformed-weights handling: a `#[forbid(unsafe_code)]`
crate can still **panic** (a DoS, and under WASM an abort) when a tensor op hits an
inconsistent shape. The crate **builds and tests on Windows**, so all findings are
MEASURED.
## Decision
Fix the three reachable bugs, each pinned by a fails-on-old test; attest the rest
clean with evidence.
### Findings fixed (all MEASURED)
| # | Severity | Location | Issue | Fix |
|---|----------|----------|-------|-----|
| 1 | **HIGH** | `model.rs:95` (`Dtype::I32 => Some(DType::I64)`) | **Crash on any int32-tensor checkpoint.** An I32 byte buffer (4 B/elem) is handed to `from_raw_buffer(.., I64, shape, ..)`; candle derives `elem_count = data.len()/8`, **halving** the count while keeping the original shape → a tensor that claims 2× its storage. Reading it **panics** with a slice-OOB (`range end index 6 out of range for slice of length 3`) inside candle-core. A checkpoint with any int32 tensor (index/buffer tensors are common in PyTorch exports) → **DoS on load**. | Map `I32 → DType::I32`, `I16 → DType::I16` (both first-class candle dtypes). Pinned by `int32_tensor_loads_with_consistent_shape_and_values` (panics on old, passes on new). |
| 2 | LOW | `inference.rs::predict` | Frame/batch dims weren't validated (only H/W/D were): `f_in > num_frames*2` over-indexes the temporal embedding → a cryptic candle `InvalidIndex` *error* (not a panic — candle bounds-checks); zero frame/batch feeds a zero-element tensor. | Boundary guard rejects zero / over-capacity frame+batch with a clear `ShapeMismatch`. 5 pins. |
| 3 | LOW | `vqvae.rs:141` (`z.elem_count() / last`) | **Divide-by-zero panic** in public `VQCodebook::encode` on a rank-0 / empty-last-dim tensor (`last == 0`). | Fail-closed guard returns a clear error. Pinned by `encode_rejects_scalar_without_panicking`. |
The HIGH finding is the notable one: the crate's own dtype mapping **defeated**
the upstream `safetensors::validate()` byte-length guarantee by misdeclaring the
dtype — the one place malformed/widened weights could reach a panicking candle op.
### Dimensions confirmed clean (with evidence)
- **Panic surface** — grep for `unwrap()/expect()/panic!/unreachable!` across `src/`
**zero in production paths**; all ops use `?`/`map_err`; the `last().unwrap_or(&0)`
is now guarded. `as` casts operate only on config-bounded/internal values.
- **NaN-state-poisoning (the named class) — N/A.** The engine is **stateless between
`predict` calls** (no persistent world-model buffer to latch into), and input is
`u8` class indices (non-finite input structurally impossible). NaN weights flow to
`argmax` (deterministic, bounded to a valid class index) — no panic, no persistence.
- **Unbounded alloc / shape-data mismatch from malformed weights** — defended upstream
by `safetensors::validate()` (overflow-checked `nelements*dtype.size()` vs declared
byte range + contiguous-offset + buffer-length checks), rejected before reaching
candle. Finding #1 was the one place the crate defeated that guarantee.
- **Model/path loading** — `load`/`load_safetensors` check `path.exists()` → typed
`CheckpointNotFound`; corrupt bytes → `CheckpointParse` (pinned). No path-traversal
surface (caller-supplied path, opened read-only, never joined with untrusted segments).
- **Secrets** — grep clean (only `token_h`/`token_w` config fields match `token`).
- **Determinism** — the crate's central honesty claim, verified by the pre-existing
`tests/predict_honesty.rs` (3 tests, still pass).
- `unsafe_code = "forbid"` in the manifest.
## Validation
- `cargo test -p wifi-densepose-occworld-candle --no-default-features`**31/31**
(lib 17, checkpoint_loading 4, input_validation 5, predict_honesty 3, doctests 2),
0 failed.
- `cargo test --workspace --no-default-features` → 0 failed across every crate (a lone
`wifi-densepose-desktop --test api_integration` "Access is denied (os error 5)" was a
Windows file-lock/AV flake — re-ran isolated 21/21, unrelated).
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash `f8e76f21…46f7a`
unchanged (occworld off the signal proof path).
## Consequences
### Positive
- A checkpoint-load DoS (the int32 dtype-widening panic) and two degenerate-input
panics are closed in the world-model crate, each pinned. **Milestone #9 (all 4
ungated crates) is complete.**
### Negative / Neutral
- None. Guards reject only malformed/degenerate inputs.
## Links
- ADR-176 / ADR-177 / ADR-178 — sibling Milestone-#9 reviews (ruview-swarm, nvsim, desktop)
+135
View File
@@ -0,0 +1,135 @@
# WiFlow Browser Trainer (`wiflow_browser.html`)
A **single self-contained HTML page** that does the entire camera-supervised
WiFi-pose loop **in your browser, in your laptop camera's coordinate frame**, as
a **4-stage gated flow** with a progress stepper (each stage unlocks the next):
0. **CALIBRATE** *(ADR-151 empty-room baseline)* — you step OUT of the space; the
page captures ~10 s of the quiescent CSI and computes a per-feature running
**mean + std (Welford)** over the 410-d vector. Every CSI vector afterwards is
expressed as **deviation from baseline**
(`x_norm = (x base_mean) / (base_std + ε)`), so a body's perturbation stands
out from the static channel. Persisted to IndexedDB. *Can't capture without it.*
1. **CAPTURE** — MediaPipe Pose runs on your laptop camera → 17 COCO keypoints
(the *label*), paired with the **baseline-normalized** 410-d ESP32 CSI vector
(the *input*). A **guided, balanced routine** cycles big on-screen prompts
(stand / turn / walk / arms / crouch / sit / reach) with a countdown, and a
**per-pose coverage meter** so you build a balanced dataset, not 2 000 frames
of standing.
2. **TRAIN** — a TensorFlow.js MLP learns `CSI → pose` in-browser. Honest
held-out PCK@0.10 / PCK@0.05 / MPJPE, plus a **mean-pose baseline** the model
must beat (the project's whole ethos — no baseline-beating signal, it says so).
*Can't train with <200 samples.*
3. **INFER** — the trained model drives a skeleton **from WiFi CSI only**
(baseline-normalized → standardized → model), drawn over the **same** camera
frame it trained in — so the inferred skeleton **aligns** with the camera
image. That alignment is the entire point of doing this in-browser instead of
with a separate Python camera. *Can't infer without a model.*
## Why in-browser
The Python pipeline (`wiflow_capture.py``wiflow_train.py``wiflow_infer.py`)
proved the signal is real (held-out PCK@0.10 ≈ 59.5% vs a 50% mean-pose baseline
= +9.4 pp). But it trained in a *different* camera's frame, so the inferred
skeleton never lined up with the laptop camera. Doing capture + train + infer all
in the browser with the **same** camera makes the training frame and the
inference frame identical → the skeleton aligns.
## Compute backends (WebGPU / WASM / WebGL)
Training and inference run on TensorFlow.js. The page selects the backend at
startup, preferring the fastest available:
- **WebGPU** (Chrome / Edge, secure context — `localhost` qualifies) — GPU compute.
- **WASM-SIMD** fallback (`tfjs-backend-wasm`, SIMD enabled, `.wasm` from the CDN).
- **WebGL** last-resort fallback (ships inside tfjs core).
The **active backend is shown as a badge in the header** (`compute: WebGPU` /
`WASM-SIMD` / `WebGL`) so it's honest about what's actually running. The model
code is backend-agnostic — tf.js abstracts the device.
## Honesty (baked in)
- The **CAPTURE** skeleton (blue) is the camera = ground truth, labeled as such.
- The **INFER** skeleton (green) is **CSI-only**, labeled, and **coarse** — the
real measured held-out PCK is shown, not a marketing number.
- The **mean-pose baseline** is always computed and shown in TRAIN; the verdict
states plainly whether the model **beats** it (real signal) or **does not**
(no usable signal). This guards against the project's retracted 92.9% that
failed exactly this check.
- Status banner is strict and mutually exclusive:
**LIVE** (real `source: "esp32"`) / **SIMULATED — not real** (any other source)
/ **NO-CSI-SERVER**. The page never invents frames.
## How to run
### 1. Start the real sensing-server (provides the CSI WebSocket on :8765)
```bash
cd v2
cargo build -p wifi-densepose-sensing-server
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005
```
A real ESP32-S3 must be provisioned and streaming for `source` to read `esp32`
(see `CLAUDE.local.md` for the firmware build/provision steps). The page expects
the verified live endpoint **`ws://localhost:8765/ws/sensing`** with
`source:"esp32"`, nodes `[9, 13]`, `features.*`, `node_features[].features.*`,
and `signal_field.values` (400 floats).
### 2. Serve this page over localhost (camera + WebGPU need a localhost/secure origin)
Any static localhost server works. For example:
```bash
python -m http.server 8099
# then open: http://localhost:8099/examples/through-wall/wiflow_browser.html
```
(8099 is just the static file server — 8765 is a separate process, the CSI
WebSocket.) Allow camera access when the browser prompts.
Point at a CSI server on another host with `?ws=`:
```
http://localhost:8099/examples/through-wall/wiflow_browser.html?ws=ws://192.168.1.20:8765/ws/sensing
```
### 3. Use it
1. **CAPTURE** tab → *enable laptop camera**start recording*. Follow the guided
routine (stand / turn / walk / arms / crouch / sit). A pair is stored only when
a confident pose AND a fresh live `esp32` CSI frame coexist. Aim for a few
thousand samples. Samples persist in IndexedDB across refreshes.
2. **TRAIN** tab → *train model*. Watch the live loss curve, held-out PCK, and the
baseline verdict. The model saves to IndexedDB.
3. **INFER** tab → the green skeleton is now driven by WiFi CSI only, aligned over
your camera. Toggle *hide camera* to see the CSI-only skeleton on black.
## The 410-d CSI vector (matches the Python pipeline exactly)
```
[ mean_rssi, variance, motion_band_power, breathing_band_power ] # 4 (features.*)
+ for node 9 then node 13: [ mean_rssi, variance, motion_band_power ] # 6 (node_features[].features.*)
+ signal_field.values, padded / truncated to 400 # 400
= 410-d
```
Verified against a real live frame: the in-browser `csiVector()` produces the
identical 410 vector as `wiflow_capture.py`'s `csi_vector()` (node 9 first, then
node 13; field zero-padded).
## Libraries (CDN only, no bundler)
| Library | CDN |
|---|---|
| TensorFlow.js core | `@tensorflow/tfjs@4.22.0/dist/tf.min.js` |
| TF.js WebGPU backend | `@tensorflow/tfjs-backend-webgpu@4.22.0/dist/tf-backend-webgpu.min.js` |
| TF.js WASM backend | `@tensorflow/tfjs-backend-wasm@4.22.0/dist/tf-backend-wasm.min.js` |
| MediaPipe Pose 0.5 (legacy solutions) | `@mediapipe/pose@0.5/pose.js` |
## Scope / honesty caveats
Same person, same room, same session. **Not** validated cross-day, cross-room, or
through-wall. The inferred pose is coarse (PCK@0.05 is typically weak). If the
model does not beat the mean-pose baseline, the page says so — that is a feature.
+644
View File
@@ -0,0 +1,644 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RuView · Through-Wall WiFi Sensing · LIVE CSI (no skeleton, no simulation)</title>
<!--
THROUGH-WALL WiFi-CSI SENSING DEMO — honest, real-data-only.
Renders ONLY what the running sensing-server actually streams over
ws://localhost:8765/ws/sensing :
- the 20x20 `signal_field` floor heatmap (real values)
- a coarse RF-localization puck from persons[0].position (NOT pose)
- live motion / presence / rssi / confidence meters
- the real `source` ("esp32" = LIVE) verbatim in the banner
It deliberately does NOT draw a skeleton. The server's
persons[].keypoints carry confidence:0.0 (image-pixel garbage, not
real 3D joints) so we never render them. WiFi CSI gives
motion/presence/coarse-position — that is the honest wow, and it
penetrates drywall. See README.md.
-->
<style>
:root {
--bg: #050507; --bg-panel: rgba(8,10,14,0.80);
--amber: #ffb840; --amber-hot: #ffe09f;
--cyan: #4cf; --magenta: #ff4cc8;
--text: #d8c69a; --text-mute: #6b6155;
--green: #4f4; --red: #f64;
--border: rgba(255,184,64,0.18);
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
-webkit-font-smoothing: antialiased; font-size: 12px;
}
canvas { display: block; }
.overlay-frame {
position: fixed; inset: 0; pointer-events: none; z-index: 5;
background:
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
}
.scanlines {
position: fixed; inset: 0; pointer-events: none; z-index: 6;
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
mix-blend-mode: overlay; opacity: 0.5;
}
.panel {
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
box-shadow: 0 1px 0 rgba(255,184,64,0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
}
.panel h2 {
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
}
/* ---- Honest status banner (top-center, mutually exclusive states) ---- */
#banner {
position: fixed; top: 0; left: 0; right: 0; z-index: 30;
text-align: center; padding: 7px 12px; font-size: 12px; letter-spacing: 1px;
font-weight: 600; border-bottom: 1px solid rgba(0,0,0,0.4);
transition: background 0.3s, color 0.3s;
}
#banner.live { background: rgba(40,255,80,0.12); color: var(--green); border-bottom-color: rgba(80,255,120,0.4); }
#banner.sim { background: rgba(255,120,40,0.16); color: #ffae5a; border-bottom-color: rgba(255,140,60,0.5); }
#banner.noserver { background: rgba(255,80,80,0.16); color: var(--red); border-bottom-color: rgba(255,90,90,0.5); }
#banner .src { opacity: 0.8; font-weight: 400; }
#banner-caption {
position: fixed; top: 30px; left: 0; right: 0; z-index: 29;
text-align: center; font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px;
pointer-events: none; padding-top: 2px;
}
#info { top: 64px; left: 20px; min-width: 270px; }
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
#info .row .k { color: var(--text-mute); font-size: 11px; }
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
#info .row .v.amber { color: var(--amber); }
#info .row .v.cyan { color: var(--cyan); }
#info .row .v.green { color: var(--green); }
#info .row .v.red { color: var(--red); }
#info .row .v.mag { color: var(--magenta); }
#info .row .v.mute { color: var(--text-mute); }
#csi { top: 64px; right: 20px; min-width: 270px; }
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
#csi .bar-row .label { width: 86px; color: var(--text-mute); }
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
#csi .bar-row .bar-fill {
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
box-shadow: 0 0 6px var(--amber); transition: width 0.1s linear;
}
#csi .bar-row .val { width: 44px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
#csi .spark { margin-top: 8px; }
#csi canvas { width: 100%; height: 38px; display: block; border: 1px solid var(--border); border-radius: 3px; background: rgba(0,0,0,0.3); }
#csi .legend { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 10px; color: var(--text-mute); line-height: 1.5; }
/* ---- waiting / no-server overlay ---- */
#waiting {
position: fixed; inset: 0; z-index: 25; display: none;
flex-direction: column; align-items: center; justify-content: center;
background: rgba(5,5,7,0.94); color: var(--amber); text-align: center; padding: 24px;
}
#waiting.show { display: flex; }
#waiting .big { font-size: 22px; letter-spacing: 2px; color: var(--red); margin-bottom: 16px; text-transform: uppercase; }
#waiting code {
display: block; text-align: left; max-width: 640px; margin: 8px auto;
background: rgba(255,184,64,0.06); border: 1px solid var(--border); border-radius: 4px;
padding: 10px 14px; color: var(--amber-hot); font-size: 12px; white-space: pre-wrap;
}
#waiting .pulse { animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 0.55; } 50% { opacity: 1; } }
/* ---- optional webcam ground-truth tile ---- */
#cam-tile {
position: absolute; bottom: 20px; right: 20px; width: 240px; z-index: 12;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 8px; backdrop-filter: blur(8px);
}
#cam-tile h2 { margin: 0 0 6px 0; font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px;
color: var(--cyan); font-weight: 600; }
#cam-tile .gt-note { font-size: 9px; color: var(--text-mute); margin-top: 4px; line-height: 1.4; }
#cam-video { width: 100%; border-radius: 3px; display: none; background: #000; }
#cam-tile button {
width: 100%; margin-top: 6px; padding: 5px 8px; font-family: inherit; font-size: 11px;
background: transparent; color: var(--cyan); border: 1px solid var(--cyan); border-radius: 3px; cursor: pointer;
}
#cam-tile button:hover { background: rgba(68,204,255,0.12); }
#cam-tile button:disabled { opacity: 0.5; cursor: not-allowed; }
#legend-nodes {
position: absolute; bottom: 20px; left: 20px; min-width: 220px;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
}
#legend-nodes h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
#legend-nodes .lr { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 11px; }
#legend-nodes .dot { width: 9px; height: 9px; border-radius: 50%; box-shadow: 0 0 6px currentColor; flex: 0 0 auto; }
#legend-nodes .muted { color: var(--text-mute); }
</style>
<!-- three.js r128 + addons (same CDN set as examples/three.js/demos/05) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
</head>
<body>
<div id="banner" class="noserver">NO SERVER — start the sensing-server <span class="src"></span></div>
<div id="banner-caption">Real WiFi CSI motion / presence / coarse-localization — penetrates drywall. Not skeletal pose.</div>
<div class="overlay-frame"></div>
<div class="scanlines"></div>
<div class="panel" id="info">
<h1>THROUGH-WALL WiFi SENSING</h1>
<div class="sub">Live CSI · ws://localhost:8765/ws/sensing</div>
<div class="row"><span class="k">source</span><span class="v amber" id="m-source"></span></div>
<div class="row"><span class="k">presence</span><span class="v" id="m-presence"></span></div>
<div class="row"><span class="k">motion level</span><span class="v" id="m-motion"></span></div>
<div class="row"><span class="k">confidence</span><span class="v cyan" id="m-conf"></span></div>
<div class="row"><span class="k">est. persons</span><span class="v amber" id="m-persons"></span></div>
<div class="row"><span class="k">active nodes</span><span class="v" id="m-nodes"></span></div>
<div class="row"><span class="k">tick</span><span class="v" id="m-tick"></span></div>
<div class="row"><span class="k">update rate</span><span class="v cyan" id="m-fps"></span></div>
</div>
<div class="panel" id="csi">
<h2>Live RF features</h2>
<div class="bar-row"><span class="label">motion</span><div class="bar-track"><div class="bar-fill" id="bar-motion"></div></div><span class="val" id="v-motion"></span></div>
<div class="bar-row"><span class="label">breathing</span><div class="bar-track"><div class="bar-fill" id="bar-breath"></div></div><span class="val" id="v-breath"></span></div>
<div class="bar-row"><span class="label">variance</span><div class="bar-track"><div class="bar-fill" id="bar-var"></div></div><span class="val" id="v-var"></span></div>
<div class="bar-row"><span class="label">mean rssi</span><div class="bar-track"><div class="bar-fill" id="bar-rssi"></div></div><span class="val" id="v-rssi"></span></div>
<div class="spark"><canvas id="spark" width="252" height="38"></canvas></div>
<div class="legend">motion sparkline (last ~6s of real motion_band_power)</div>
</div>
<div id="legend-nodes">
<h2>Sensor nodes</h2>
<div class="lr"><span class="dot" style="color:#4cf"></span><span>ESP32-S3 office <span class="muted">(node 9)</span></span></div>
<div class="lr"><span class="dot" style="color:#ff4cc8"></span><span>ESP32-S3 hallway <span class="muted">(node 13)</span></span></div>
<div class="lr" style="margin-top:6px"><span class="dot" style="color:#4f4"></span><span>RF localization <span class="muted">(coarse)</span></span></div>
<div class="lr"><span class="muted" style="font-size:10px;line-height:1.4">Office &amp; hallway split by a wall + doorway. WiFi motion still shows through drywall.</span></div>
</div>
<div id="cam-tile">
<h2>camera — ground truth when visible</h2>
<video id="cam-video" autoplay muted playsinline></video>
<button id="cam-btn">▶ enable webcam (optional)</button>
<div class="gt-note">Independent of the CSI sensing. The WiFi works in the dark and through walls; the camera does not.</div>
</div>
<div id="waiting" class="show">
<div class="big pulse">Waiting for live sensing-server</div>
<div>No connection to <b>ws://localhost:8765/ws/sensing</b>. Start the real server, then this page connects automatically.</div>
<code>cd v2
cargo build -p wifi-densepose-sensing-server
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005</code>
<div style="margin-top:10px; color:var(--text-mute); font-size:11px;">This demo renders ONLY real data. It never invents frames.</div>
</div>
<script>
"use strict";
// =====================================================================
// Config + WS endpoint (allow ?ws= override)
// =====================================================================
const params = new URLSearchParams(location.search);
const WS_URL = params.get('ws') || 'ws://localhost:8765/ws/sensing';
const ROOM_HALF = 5; // half-extent of the floor plane in metres
const GRID_N = 20; // signal_field is 20 x 20
// Known node anchor positions (server sends node 9 @ [2,0,1.5]; node 13
// joins later from the hallway side once its firmware is flashed). These
// are anchors for the room model + labels, NOT fabricated sensing data.
const NODE_ANCHORS = {
9: { pos: [ 2.0, 0.0, 1.5], color: 0x44ccff, label: 'office (node 9)' },
13: { pos: [-2.0, 0.0, -3.0], color: 0xff4cc8, label: 'hallway (node 13)' },
};
// =====================================================================
// Three.js scene (reused pattern from demos/05-skinned-realtime.html)
// =====================================================================
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050507);
scene.fog = new THREE.FogExp2(0x050507, 0.045);
const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.05, 100);
camera.position.set(4.5, 4.2, 6.0);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.85;
renderer.outputEncoding = THREE.sRGBEncoding;
document.body.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.4, -0.5);
controls.enableDamping = true; controls.dampingFactor = 0.06;
controls.minDistance = 3; controls.maxDistance = 18;
controls.maxPolarAngle = Math.PI * 0.49;
scene.add(new THREE.HemisphereLight(0x553a18, 0x080606, 0.7));
const keyLight = new THREE.DirectionalLight(0xffc070, 0.9);
keyLight.position.set(3, 6, 4);
scene.add(keyLight);
// Post-processing — gentle bloom so the heatmap + puck glow.
const composer = new THREE.EffectComposer(renderer);
composer.addPass(new THREE.RenderPass(scene, camera));
const bloom = new THREE.UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight), 0.55, 0.45, 0.82);
composer.addPass(bloom);
// =====================================================================
// Room: floor grid + wall + doorway dividing office / hallway
// =====================================================================
const gridHelper = new THREE.GridHelper(2*ROOM_HALF, GRID_N, 0x554a32, 0x2a2418);
gridHelper.position.y = 0.002;
scene.add(gridHelper);
// Dividing wall runs along world X near z = -1 (office z>-1, hallway z<-1),
// with a doorway gap. Two wall segments leave a gap in the middle.
const wallMat = new THREE.MeshStandardMaterial({
color: 0x1b2330, transparent: true, opacity: 0.55, roughness: 0.9,
side: THREE.DoubleSide,
});
const wallH = 1.4, wallZ = -1.0;
function addWallSeg(cx, w) {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, 0.08), wallMat);
m.position.set(cx, wallH/2, wallZ);
scene.add(m);
// top edge highlight
const edge = new THREE.Mesh(new THREE.BoxGeometry(w, 0.02, 0.10),
new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0.5 }));
edge.position.set(cx, wallH, wallZ);
scene.add(edge);
}
// left segment, doorway gap (-0.7..0.7), right segment
addWallSeg(-3.15, 3.7);
addWallSeg( 3.15, 3.7);
// Room labels (sprite text) for OFFICE / HALLWAY
function makeLabel(text, color) {
const c = document.createElement('canvas'); c.width = 256; c.height = 64;
const ctx = c.getContext('2d');
ctx.fillStyle = color; ctx.font = 'bold 30px Consolas, monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, 128, 34);
const tex = new THREE.CanvasTexture(c);
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
spr.scale.set(2.0, 0.5, 1);
return spr;
}
const officeLbl = makeLabel('OFFICE', '#ffb840'); officeLbl.position.set(2.6, 0.06, 2.6); scene.add(officeLbl);
const hallLbl = makeLabel('HALLWAY', '#ff4cc8'); hallLbl.position.set(-2.6, 0.06, -3.2); scene.add(hallLbl);
// =====================================================================
// Node markers (office / hallway). The hallway node is dimmed until it
// actually appears in the live `nodes` list.
// =====================================================================
const nodeMeshes = {};
function buildNode(id) {
const a = NODE_ANCHORS[id];
const g = new THREE.Group();
const post = new THREE.Mesh(
new THREE.CylinderGeometry(0.05, 0.07, 0.9, 12),
new THREE.MeshStandardMaterial({ color: a.color, emissive: a.color, emissiveIntensity: 0.4, roughness: 0.4 }));
post.position.y = 0.45; g.add(post);
const orb = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 20, 16),
new THREE.MeshBasicMaterial({ color: a.color }));
orb.position.y = 0.95; g.add(orb);
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.18, 0.24, 32),
new THREE.MeshBasicMaterial({ color: a.color, transparent: true, opacity: 0.6, side: THREE.DoubleSide }));
ring.rotation.x = -Math.PI/2; ring.position.y = 0.01; g.add(ring);
const lbl = makeLabel('ESP32-S3 ' + a.label, '#' + a.color.toString(16).padStart(6,'0'));
lbl.scale.set(2.6, 0.65, 1); lbl.position.set(0, 1.25, 0); g.add(lbl);
g.position.set(a.pos[0], 0, a.pos[2]);
g.userData.parts = { post, orb, ring };
scene.add(g);
return g;
}
Object.keys(NODE_ANCHORS).forEach(id => { nodeMeshes[id] = buildNode(+id); });
function setNodeActive(id, active) {
const g = nodeMeshes[id]; if (!g) return;
const o = active ? 1.0 : 0.22;
const parts = g.userData.parts;
parts.orb.material.opacity = o; parts.orb.material.transparent = true;
parts.ring.material.opacity = 0.6 * o;
parts.post.material.emissiveIntensity = active ? 0.5 : 0.12;
}
setNodeActive(9, false); setNodeActive(13, false);
// =====================================================================
// signal_field 20x20 floor heatmap — instanced colored tiles.
// Driven ONLY by real `signal_field.values` (400 floats ~0..1).
// =====================================================================
const TILE = (2*ROOM_HALF) / GRID_N;
const heatGeo = new THREE.PlaneGeometry(TILE * 0.96, TILE * 0.96);
const heatMat = new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.85, side: THREE.DoubleSide });
const heatMesh = new THREE.InstancedMesh(heatGeo, heatMat, GRID_N * GRID_N);
heatMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
const heatColor = new THREE.InstancedBufferAttribute(new Float32Array(GRID_N * GRID_N * 3), 3);
heatMesh.instanceColor = heatColor;
const _m = new THREE.Matrix4();
const _q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0), -Math.PI/2);
const _s = new THREE.Vector3(1,1,1);
const _p = new THREE.Vector3();
// gridCell (gx,gz) -> world (x,z). gx,gz in [0,GRID_N).
function cellToWorld(gx, gz) {
return [ (gx + 0.5) * TILE - ROOM_HALF, (gz + 0.5) * TILE - ROOM_HALF ];
}
for (let gz = 0; gz < GRID_N; gz++) {
for (let gx = 0; gx < GRID_N; gx++) {
const i = gz * GRID_N + gx;
const [wx, wz] = cellToWorld(gx, gz);
_p.set(wx, 0.012, wz);
_m.compose(_p, _q, _s);
heatMesh.setMatrixAt(i, _m);
heatColor.setXYZ(i, 0.02, 0.02, 0.03);
}
}
heatMesh.instanceMatrix.needsUpdate = true;
scene.add(heatMesh);
// amber→white heat ramp for a value in [0,1]
function heatRamp(v, out) {
v = Math.max(0, Math.min(1, v));
// dark -> amber -> hot white
const r = Math.min(1, 0.05 + 1.6 * v);
const g = Math.min(1, 0.02 + 1.1 * v * v);
const b = Math.min(1, 0.04 + 0.9 * Math.pow(v, 3));
out.set(r, g, b);
return out;
}
const _c = new THREE.Color();
let lastFieldPeak = { gx: GRID_N/2|0, gz: GRID_N/2|0, v: 0 };
function updateHeatmap(field) {
if (!field || !Array.isArray(field.values)) return;
const vals = field.values;
// grid_size is [20,1,20]; values are row-major 400 floats.
let peakV = -1, peakGx = lastFieldPeak.gx, peakGz = lastFieldPeak.gz;
const n = Math.min(vals.length, GRID_N * GRID_N);
for (let i = 0; i < n; i++) {
const v = vals[i];
heatRamp(v, _c);
heatColor.setXYZ(i, _c.r, _c.g, _c.b);
if (v > peakV) { peakV = v; peakGx = i % GRID_N; peakGz = (i / GRID_N) | 0; }
}
heatColor.needsUpdate = true;
lastFieldPeak = { gx: peakGx, gz: peakGz, v: peakV };
}
// =====================================================================
// RF-localization puck — from persons[0].position (coarse, NOT pose).
// Falls back to the signal_field peak cell when no person is present.
// =====================================================================
const puck = new THREE.Group();
const puckCore = new THREE.Mesh(
new THREE.SphereGeometry(0.16, 24, 18),
new THREE.MeshBasicMaterial({ color: 0x66ff88 }));
puckCore.position.y = 0.16; puck.add(puckCore);
const puckRing = new THREE.Mesh(
new THREE.RingGeometry(0.28, 0.36, 40),
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.7, side: THREE.DoubleSide }));
puckRing.rotation.x = -Math.PI/2; puckRing.position.y = 0.02; puck.add(puckRing);
const puckBeam = new THREE.Mesh(
new THREE.CylinderGeometry(0.03, 0.03, 1.2, 8),
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.35 }));
puckBeam.position.y = 0.6; puck.add(puckBeam);
puck.visible = false;
scene.add(puck);
const puckTarget = new THREE.Vector3(0, 0, 0);
function updatePuck(frame) {
let wx = null, wz = null, present = false;
const persons = frame.persons || [];
if (persons.length && Array.isArray(persons[0].position)) {
// server position is [x, 0, z] in metres, origin at room centre
wx = persons[0].position[0];
wz = persons[0].position[2];
present = true;
}
// If no person but the field has clear energy, show the peak cell
// (coarse) so the puck honestly tracks "where the RF energy is".
if (!present && lastFieldPeak.v > 0.55) {
const peak = cellToWorld(lastFieldPeak.gx, lastFieldPeak.gz);
wx = peak[0]; wz = peak[1]; present = true;
}
if (present && wx !== null) {
// clamp into the room so it never flies off the floor
wx = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wx));
wz = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wz));
puckTarget.set(wx, 0, wz);
puck.visible = true;
} else {
puck.visible = false;
}
}
// =====================================================================
// HUD updates
// =====================================================================
const $ = id => document.getElementById(id);
function clamp01(x){ return Math.max(0, Math.min(1, x)); }
function setBar(barId, valId, frac, text) {
$(barId).style.width = (clamp01(frac) * 100).toFixed(0) + '%';
$(valId).textContent = text;
}
// motion sparkline ring buffer
const sparkCtx = $('spark').getContext('2d');
const SPARK_N = 120;
const sparkBuf = new Array(SPARK_N).fill(0);
function pushSpark(v) {
sparkBuf.push(v); if (sparkBuf.length > SPARK_N) sparkBuf.shift();
const w = sparkCtx.canvas.width, h = sparkCtx.canvas.height;
sparkCtx.clearRect(0,0,w,h);
let maxV = 40; for (const x of sparkBuf) if (x > maxV) maxV = x;
sparkCtx.strokeStyle = '#ffb840'; sparkCtx.lineWidth = 1.5; sparkCtx.beginPath();
for (let i = 0; i < sparkBuf.length; i++) {
const x = (i / (SPARK_N-1)) * w;
const y = h - (sparkBuf[i] / maxV) * (h - 3) - 1.5;
i === 0 ? sparkCtx.moveTo(x, y) : sparkCtx.lineTo(x, y);
}
sparkCtx.stroke();
}
// =====================================================================
// Honest status banner (strict, mutually exclusive)
// =====================================================================
const banner = $('banner');
function setBannerLive(source, nodeCount) {
if (source === 'esp32') {
banner.className = 'live';
banner.innerHTML = 'LIVE — real ESP32 CSI <span class="src">(source=' + source + ', ' + nodeCount + ' node' + (nodeCount === 1 ? '' : 's') + ')</span>';
} else {
// anything not esp32 = explicitly NOT real, badged
banner.className = 'sim';
banner.innerHTML = 'SIMULATED — not real <span class="src">(source=' + source + ' — start an ESP32 for live CSI)</span>';
}
}
function setBannerNoServer() {
banner.className = 'noserver';
banner.innerHTML = 'NO SERVER — start the sensing-server <span class="src">(ws://localhost:8765/ws/sensing unreachable)</span>';
}
// =====================================================================
// WebSocket — render ONLY real frames. Reconnect; never fabricate.
// =====================================================================
let ws = null, gotFrame = false;
let frameTimes = []; // for measured update rate (fps)
let lastFrame = null; // most recent real frame (render loop interpolates puck)
function connect() {
setBannerNoServer();
try { ws = new WebSocket(WS_URL); }
catch (e) { scheduleReconnect(); return; }
ws.onopen = () => { /* wait for first frame before claiming LIVE */ };
ws.onmessage = (ev) => {
let d; try { d = JSON.parse(ev.data); } catch (e) { return; }
if (!d || d.type !== 'sensing_update') return;
onFrame(d);
};
ws.onclose = () => { gotFrame = false; $('waiting').classList.add('show'); setBannerNoServer(); scheduleReconnect(); };
ws.onerror = () => { try { ws.close(); } catch (e) {} };
}
let reconnectT = null;
function scheduleReconnect() {
if (reconnectT) return;
reconnectT = setTimeout(() => { reconnectT = null; connect(); }, 1500);
}
function onFrame(d) {
gotFrame = true;
lastFrame = d;
$('waiting').classList.remove('show');
const source = d.source || 'unknown';
const nodes = Array.isArray(d.nodes) ? d.nodes : [];
setBannerLive(source, nodes.length);
// measured update rate
const now = performance.now();
frameTimes.push(now);
while (frameTimes.length && now - frameTimes[0] > 2000) frameTimes.shift();
const fps = frameTimes.length > 1 ? (frameTimes.length - 1) / ((frameTimes[frameTimes.length-1] - frameTimes[0]) / 1000) : 0;
const cls = d.classification || {};
const feat = d.features || {};
// info panel
$('m-source').textContent = source.toUpperCase();
$('m-source').className = 'v ' + (source === 'esp32' ? 'green' : 'red');
const presence = !!cls.presence;
$('m-presence').textContent = presence ? (cls.motion_level === 'present_moving' ? 'PRESENT · MOVING' : 'PRESENT') : 'CLEAR';
$('m-presence').className = 'v ' + (presence ? 'green' : 'mute');
$('m-motion').textContent = cls.motion_level || '—';
$('m-conf').textContent = (cls.confidence != null) ? cls.confidence.toFixed(2) : '—';
$('m-persons').textContent = (d.estimated_persons != null) ? d.estimated_persons : '—';
$('m-nodes').textContent = nodes.length + ' (' + nodes.map(n => n.node_id).join(', ') + ')';
$('m-tick').textContent = (d.tick != null) ? d.tick : '—';
$('m-fps').textContent = fps ? fps.toFixed(1) + ' Hz' : '—';
// feature bars (real values, scaled into 0..1 for the bar width only)
const motion = feat.motion_band_power || 0;
const breath = feat.breathing_band_power || 0;
const variance = feat.variance || 0;
const rssi = feat.mean_rssi != null ? feat.mean_rssi : -100;
setBar('bar-motion', 'v-motion', motion / 100, motion.toFixed(1));
setBar('bar-breath', 'v-breath', breath / 100, breath.toFixed(1));
setBar('bar-var', 'v-var', variance / 80, variance.toFixed(1));
// rssi: map -90..-30 dBm -> 0..1
setBar('bar-rssi', 'v-rssi', (rssi + 90) / 60, rssi.toFixed(0));
pushSpark(motion);
// node activity
const activeIds = new Set(nodes.map(n => n.node_id));
[9, 13].forEach(id => setNodeActive(id, activeIds.has(id)));
// heatmap + puck
updateHeatmap(d.signal_field);
updatePuck(d);
}
// =====================================================================
// Optional webcam ground-truth tile (reused from demos/05). Camera is
// separate from CSI sensing — labeled "ground truth when visible".
// =====================================================================
let camStream = null;
$('cam-btn').addEventListener('click', async () => {
const btn = $('cam-btn');
if (camStream) { // toggle off
camStream.getTracks().forEach(t => t.stop());
$('cam-video').style.display = 'none'; camStream = null;
btn.textContent = '▶ enable webcam (optional)';
return;
}
btn.disabled = true; btn.textContent = 'requesting camera…';
try {
camStream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' }, audio: false,
});
const v = $('cam-video'); v.srcObject = camStream; v.style.display = 'block';
btn.textContent = '■ stop webcam'; btn.disabled = false;
} catch (e) {
btn.textContent = '✗ camera unavailable'; btn.disabled = false; console.error(e);
setTimeout(() => { if (!camStream) btn.textContent = '▶ enable webcam (optional)'; }, 2000);
}
});
// =====================================================================
// Render loop — smooth the puck toward its real target; pulse rings.
// =====================================================================
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
controls.update();
if (puck.visible) {
puck.position.lerp(puckTarget, 0.18);
const pulse = 0.8 + 0.25 * Math.sin(t * 3.0);
puckRing.scale.set(pulse, pulse, pulse);
puckRing.material.opacity = 0.5 + 0.25 * Math.sin(t * 3.0);
}
// node rings breathe when active
[9,13].forEach(id => {
const g = nodeMeshes[id]; if (!g) return;
const r = g.userData.parts.ring;
const s = 1 + 0.08 * Math.sin(t * 2 + id);
r.scale.set(s, s, s);
});
composer.render();
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
// kick off
connect();
</script>
</body>
</html>
+159
View File
@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WiFlow · live WiFi-inferred pose</title>
<style>
:root{--bg:#0a0c10;--panel:#11151c;--amber:#ffb840;--green:#46e08a;--red:#ff5a5a;--mute:#7d8796;--line:#1d2430}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:#dfe6ee;font:14px/1.5 'JetBrains Mono',ui-monospace,Menlo,monospace}
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
h1{font-size:15px;margin:0;letter-spacing:1px;text-transform:uppercase;font-weight:600}
h1 span{color:var(--amber)}
#banner{margin-left:auto;padding:5px 12px;border-radius:5px;font-weight:600;font-size:12px;letter-spacing:.5px}
.live{background:rgba(70,224,138,.15);color:var(--green);border:1px solid var(--green)}
.sim{background:rgba(255,184,64,.15);color:var(--amber);border:1px solid var(--amber)}
.down{background:rgba(255,90,90,.15);color:var(--red);border:1px solid var(--red)}
main{display:flex;gap:18px;padding:18px;flex-wrap:wrap}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
canvas{background:#070a0e;border-radius:8px;display:block}
.label{font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--mute);margin-bottom:8px}
.stats{min-width:240px}
.row{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed var(--line)}
.row .k{color:var(--mute)} .row .v{color:var(--amber);font-variant-numeric:tabular-nums}
.v.green{color:var(--green)}
.note{margin-top:12px;font-size:11px;color:var(--mute);line-height:1.6;max-width:300px}
.note b{color:#dfe6ee}
</style>
</head>
<body>
<header>
<h1>WiFlow · <span>live WiFi-inferred pose</span></h1>
<div id="banner" class="down">CONNECTING…</div>
</header>
<main>
<div class="card">
<div class="label">CSI → pose (skeleton) overlaid on your laptop camera</div>
<div id="stage" style="width:420px;height:560px;border-radius:8px;overflow:hidden;background:#070a0e">
<video id="cam" autoplay muted playsinline style="position:absolute;width:2px;height:2px;opacity:0;pointer-events:none"></video>
<canvas id="cv" width="420" height="560"></canvas>
</div>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button id="camBtn" style="background:var(--amber);color:#0a0c10;border:0;border-radius:6px;padding:7px 14px;font:inherit;font-weight:600;cursor:pointer">enable laptop camera</button>
<select id="camSel" style="display:none;background:var(--panel);color:#dfe6ee;border:1px solid var(--line);border-radius:6px;padding:6px;font:inherit;max-width:220px"></select>
</div>
<div id="camStatus" style="margin-top:6px;font-size:11px;color:var(--mute)">camera: off</div>
<div class="note" style="margin-top:8px">Camera is a <b>visual reference only</b> — it is NOT fed to the model. Overlay alignment is approximate (model trained in a different camera's frame).</div>
</div>
<div class="card stats">
<div class="label">live</div>
<div class="row"><span class="k">CSI source</span><span class="v" id="src"></span></div>
<div class="row"><span class="k">nodes</span><span class="v" id="nodes"></span></div>
<div class="row"><span class="k">presence</span><span class="v" id="pres"></span></div>
<div class="row"><span class="k">motion</span><span class="v" id="motion"></span></div>
<div class="row"><span class="k">pose fps</span><span class="v" id="fps"></span></div>
<div class="note">
This skeleton is inferred <b>from WiFi CSI only</b> — no camera in the loop here. A model was
trained on paired (camera-pose, CSI) data in this room (ADR-079/180).
<br/><br/>
<b>Honest accuracy:</b> ~<b>59.5% PCK@0.10</b> on held-out data (vs a 50% mean-pose baseline →
<b>+9.4 pp real signal</b>). It captures <b>coarse</b> pose; fine detail is weak (PCK@0.05 ≈ 24%).
Same person / room / session — not validated cross-day or through-wall.
</div>
</div>
</main>
<script>
const POSE_WS = (new URLSearchParams(location.search)).get('ws') || `ws://${location.hostname||'localhost'}:8770/pose`;
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const $ = id => document.getElementById(id);
let edges = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]];
let last = null, frames = 0, t0 = performance.now();
function banner(state, txt){ const b=$('banner'); b.className=state; b.textContent=txt; }
// per-joint smoothing (EMA) so dropped/jittery CSI frames render fluidly (ADR-180 dead-reckoning, lite)
let sm = null;
function smooth(kps){
if(!sm){ sm = kps.map(p=>[p[0],p[1]]); return sm; }
const a=0.35; for(let i=0;i<kps.length;i++){ sm[i][0]+=a*(kps[i][0]-sm[i][0]); sm[i][1]+=a*(kps[i][1]-sm[i][1]); }
return sm;
}
const camEl=document.getElementById('cam');
function draw(p){
const W=cv.width, H=cv.height;
// paint the live camera frame onto the canvas (robust — no z-index/overlay tricks)
if(camEl && camEl.videoWidth>0){
ctx.save(); ctx.globalAlpha=0.9;
// cover-fit the camera frame into the canvas
const vr=camEl.videoWidth/camEl.videoHeight, cr=W/H;
let dw=W, dh=H, dx=0, dy=0;
if(vr>cr){ dh=H; dw=H*vr; dx=(W-dw)/2; } else { dw=W; dh=W/vr; dy=(H-dh)/2; }
ctx.drawImage(camEl, dx, dy, dw, dh); ctx.restore();
} else {
ctx.fillStyle='#070a0e'; ctx.fillRect(0,0,W,H);
}
if(!p || !p.kps){ return; }
const s = smooth(p.kps);
const k = s.map(([x,y])=>[x*W, y*H]);
ctx.lineWidth=5; ctx.strokeStyle=p.presence?'rgba(70,224,138,.95)':'rgba(125,135,150,.8)'; ctx.lineCap='round';
ctx.shadowColor='rgba(70,224,138,.6)'; ctx.shadowBlur=8;
for(const [a,b] of edges){ ctx.beginPath(); ctx.moveTo(k[a][0],k[a][1]); ctx.lineTo(k[b][0],k[b][1]); ctx.stroke(); }
ctx.shadowBlur=0;
for(const [x,y] of k){ ctx.beginPath(); ctx.arc(x,y,5,0,7); ctx.fillStyle=p.presence?'#ffb840':'#667'; ctx.fill(); }
}
// ---- laptop webcam (visual reference only; NOT fed to the model) ----
let camStream=null;
async function startCam(deviceId){
if(camStream){ camStream.getTracks().forEach(t=>t.stop()); }
const constraints = deviceId ? {video:{deviceId:{exact:deviceId}}} : {video:true};
const st=document.getElementById('camStatus');
try{
st.textContent='camera: requesting…';
camStream = await navigator.mediaDevices.getUserMedia(constraints);
const v=document.getElementById('cam'); v.muted=true; v.srcObject=camStream;
v.onloadedmetadata=()=>{ v.play().catch(err=>st.textContent='camera: play() blocked '+err.name); };
await v.play().catch(()=>{});
const tr=camStream.getVideoTracks()[0]; const ss=tr.getSettings();
// live readout: shows if real frames are flowing (videoWidth>0) and which device
const tick=()=>{ st.textContent = `camera: "${tr.label}" ${v.videoWidth}x${v.videoHeight} ${tr.readyState} ${v.paused?'PAUSED':'playing'}`; };
tick(); setInterval(tick, 1000);
document.getElementById('camBtn').textContent='switch camera ↻';
// populate the picker now that we have permission (labels need permission)
const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d=>d.kind==='videoinput');
const sel=document.getElementById('camSel'); sel.style.display = devs.length>1?'inline-block':'none';
sel.innerHTML = devs.map((d,i)=>`<option value="${d.deviceId}">${d.label||('camera '+(i+1))}</option>`).join('');
const cur = camStream.getVideoTracks()[0].getSettings().deviceId; if(cur) sel.value=cur;
}catch(e){
document.getElementById('camBtn').textContent = 'camera error: '+e.name+(e.name==='NotReadableError'?' (in use by Zoom/Teams?)':'');
console.error('getUserMedia', e);
}
}
document.getElementById('camBtn').addEventListener('click', ()=>startCam());
document.getElementById('camSel').addEventListener('change', e=>startCam(e.target.value));
function connect(){
banner('down','CONNECTING…');
const ws = new WebSocket(POSE_WS);
ws.onopen = ()=> banner('sim','WAITING FOR POSE…');
ws.onmessage = ev => {
const d = JSON.parse(ev.data);
if(d.type==='meta'){ edges = d.edges; return; }
if(d.type!=='pose') return;
last=d; frames++;
if(d.src==='esp32') banner('live','LIVE — WiFi-inferred pose (real ESP32 CSI)');
else banner('sim','SIMULATED CSI — not real ('+d.src+')');
$('src').textContent=d.src; $('src').className = d.src==='esp32'?'v green':'v';
$('nodes').textContent=(d.nodes||[]).join(', ')||'—';
$('pres').textContent=d.presence?'PRESENT':'—';
$('motion').textContent=(d.motion!=null?Math.round(d.motion):'—');
};
ws.onclose = ()=>{ banner('down','NO BRIDGE — start wiflow_infer.py'); setTimeout(connect,1500); };
ws.onerror = ()=> ws.close();
}
function loop(){ draw(last); const now=performance.now(); if(now-t0>1000){ $('fps').textContent=frames; frames=0; t0=now; } requestAnimationFrame(loop); }
connect(); loop();
</script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
"""Tiny threaded static server for the through-wall WiFi-CSI sensing demo.
Adapted from examples/three.js/server/serve-demo.py. Serves the
`examples/through-wall/` page so a browser can fetch index.html, then the
page connects directly to the LIVE sensing-server WebSocket at
ws://localhost:8765/ws/sensing (NOT proxied through here).
Why a threaded server (not `python -m http.server`)?
The stdlib SimpleHTTPServer is single-threaded; a browser opens several
parallel connections (HTML + the three.js CDN tags fetch in parallel),
the first eats the worker, the rest can stall. ThreadingHTTPServer fixes it.
IMPORTANT: this serves on port 8080 — port 8765 is taken by the
sensing-server's WebSocket. They are two different processes.
Usage:
# 1) start the REAL sensing-server (separate terminal):
# cd v2
# cargo build -p wifi-densepose-sensing-server
# ./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005
# 2) start this static server:
python examples/through-wall/serve.py
# 3) open:
# http://localhost:8080/examples/through-wall/index.html
Override the WS endpoint with a query param, e.g.:
http://localhost:8080/examples/through-wall/index.html?ws=ws://192.168.1.20:8765/ws/sensing
"""
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
import os
import sys
PORT = int(os.environ.get("PORT", 8080))
# Serve from the repo root regardless of where this script is launched.
# This file lives at examples/through-wall/serve.py — two levels deep.
os.chdir(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
class NoCacheHandler(SimpleHTTPRequestHandler):
def end_headers(self):
# Aggressive no-cache so the browser ALWAYS fetches the latest
# index.html after edits, even on a soft refresh.
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def log_message(self, fmt, *args): # quieter logs
sys.stderr.write("[serve] " + (fmt % args) + "\n")
PAGE = "examples/through-wall/index.html"
with ThreadingHTTPServer(("127.0.0.1", PORT), NoCacheHandler) as srv:
print(f"serving {os.getcwd()} on http://127.0.0.1:{PORT}/")
print(f" open http://localhost:{PORT}/{PAGE}")
print("")
print(" The page connects to the LIVE sensing-server at")
print(" ws://localhost:8765/ws/sensing (start it first — see README.md).")
print(" Override with ?ws=ws://HOST:PORT/ws/sensing")
try:
srv.serve_forever()
except KeyboardInterrupt:
sys.exit(0)
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Rigorous A/B for WiFlow CSI->pose: is the held-out PCK real signal or split leakage?
For a dataset of {csi:[D], kps:17x[x,y,vis]} pairs, train the SAME small MLP under
several train/val SPLITS and report held-out PCK@0.10 vs the mean-pose baseline:
- chronological_80_20 : last 20% in time (val temporally ADJACENT to train -> leaks
via CSI/pose autocorrelation; this is what gave us +9.4)
- random_80_20 : shuffled (val frames interleaved with train -> MAX leak)
- blocked_gap : hold out a contiguous MIDDLE block with a time GAP buffer on
each side so val is NOT adjacent to any train frame -> the
honest, leakage-controlled test
If the model beats baseline on chronological/random but COLLAPSES to ~baseline on
blocked_gap, the apparent signal was temporal leakage, not generalizable CSI->pose.
Usage (ruvultra venv): python wiflow_ab.py --data ~/wiflow-room/dataset.jsonl
"""
import argparse, json, sys
import numpy as np, torch, torch.nn as nn
def _rec(r, X, Y, V, B):
X.append(r["csi"]); kp=r["kps"]
if kp and isinstance(kp[0], (list,tuple)): # 17 x [x,y(,vis)]
Y.append([c for k in kp for c in (k[0],k[1])]); V.append([(k[2] if len(k)>2 else 1.0) for k in kp])
else: # flat 34 (browser export, no vis)
Y.append(list(kp)); V.append([1.0]*17)
B.append(r.get("bucket"))
def load(path):
X,Y,V,B=[],[],[],[]
txt=open(path).read().strip()
if txt[:1] in "[{": # JSON (browser export: dict{samples:[]} or bare array)
d=json.loads(txt)
rows = d if isinstance(d,list) else d.get("samples", d.get("data", []))
for r in rows: _rec(r,X,Y,V,B)
else: # JSONL (python capture)
for line in txt.splitlines():
if line.strip(): _rec(json.loads(line),X,Y,V,B)
return np.array(X,np.float32), np.array(Y,np.float32), np.array(V,np.float32), B
class Net(nn.Module):
def __init__(s,din,dout):
super().__init__()
s.n=nn.Sequential(nn.Linear(din,384),nn.ReLU(),nn.Dropout(.35),
nn.Linear(384,192),nn.ReLU(),nn.Dropout(.35),
nn.Linear(192,96),nn.ReLU(),nn.Linear(96,dout),nn.Sigmoid())
def forward(s,x): return s.n(x)
def pck(pred,gt,vis,thr=0.10):
p=pred.reshape(-1,17,2); g=gt.reshape(-1,17,2)
d=np.linalg.norm(p-g,axis=2); m=vis>0.5
return float((d[m]<thr).mean()) if m.any() else 0.0
def split_idx(n, kind, B=None):
idx=np.arange(n)
if kind=="chronological_80_20":
c=int(n*.8); return idx[:c], idx[c:]
if kind=="random_80_20":
rng=np.random.default_rng(0); p=rng.permutation(n); c=int(n*.8); return p[:c], p[c:]
if kind=="blocked_gap":
# val = contiguous middle 20%; a WIDE 10% time gap each side guarantees no train
# frame is temporally adjacent to a val frame (kills frame-autocorrelation leakage).
v0=int(n*.4); v1=int(n*.6); gap=int(n*.10)
val=idx[v0:v1]; train=np.concatenate([idx[:max(0,v0-gap)], idx[min(n,v1+gap):]])
return train, val
if kind=="grouped_bucket":
# hold out ENTIRE activity buckets -> val poses/activities never seen in train.
# the strictest leakage-free test (only when bucket labels exist).
b=np.array([x if x is not None else -1 for x in B])
uniq=[u for u in sorted(set(b.tolist())) if u!=-1]
if len(uniq)<3: raise ValueError("too few buckets")
hold=set(uniq[::max(1,len(uniq)//3)][:max(1,len(uniq)//3)]) # ~1/3 of activities held out
val=idx[np.isin(b,list(hold))]; train=idx[~np.isin(b,list(hold))]
return train, val
raise ValueError(kind)
def run(X,Y,V,tr,va,epochs=250,seed=0):
torch.manual_seed(seed); np.random.seed(seed) # seed weight init + batch shuffle
dev="cuda" if torch.cuda.is_available() else "cpu"
mu,sd=X[tr].mean(0),X[tr].std(0)+1e-6
Xtr=torch.tensor((X[tr]-mu)/sd).to(dev); Ytr=torch.tensor(Y[tr]).to(dev)
Xva=torch.tensor((X[va]-mu)/sd).to(dev)
net=Net(X.shape[1],Y.shape[1]).to(dev)
opt=torch.optim.Adam(net.parameters(),lr=1e-3,weight_decay=1e-4); lf=nn.MSELoss()
best=(1e9,None)
for ep in range(epochs):
net.train(); perm=torch.randperm(len(Xtr),device=dev)
for i in range(0,len(Xtr),64):
j=perm[i:i+64]; opt.zero_grad(); loss=lf(net(Xtr[j]),Ytr[j]); loss.backward(); opt.step()
net.eval()
with torch.no_grad(): pv=net(Xva).cpu().numpy()
vl=float(((pv-Y[va])**2).mean())
if vl<best[0]: best=(vl,pv)
base=np.tile(Y[tr].mean(0),(len(va),1))
return pck(best[1],Y[va],V[va]), pck(base,Y[va],V[va])
def main():
ap=argparse.ArgumentParser(); ap.add_argument("--data",required=True)
ap.add_argument("--epochs",type=int,default=250); ap.add_argument("--seeds",type=int,default=3)
a=ap.parse_args()
X,Y,V,B=load(a.data); n=len(X)
has_buckets=any(x is not None for x in B)
print(f"[ab] {n} samples, X={X.shape}, buckets={'yes' if has_buckets else 'no'}, "
f"seeds={a.seeds}, epochs={a.epochs}\n")
print(f"{'split':<22}{'model PCK@0.10':>16}{'baseline':>11}{'delta (mean±sd)':>20} verdict")
print("-"*86)
splits=["chronological_80_20","random_80_20","blocked_gap"]+(["grouped_bucket"] if has_buckets else [])
for kind in splits:
try:
tr,va=split_idx(n,kind,B)
ms=[]; bs=[]
for s in range(a.seeds):
m,b=run(X,Y,V,tr,va,a.epochs,seed=s); ms.append(m); bs.append(b)
ms=np.array(ms)*100; bs=np.array(bs)*100; ds=ms-bs
dm,dsd=ds.mean(),ds.std()
# REAL only if the mean delta minus 1 sd still clears the 1.5pp threshold (robust to seed variance)
verdict = "REAL signal" if dm-dsd>1.5 else ("weak/uncertain" if dm>1.5 else "no signal (==baseline)")
print(f"{kind:<22}{ms.mean():>13.1f}±{ms.std():>3.1f}{bs.mean():>10.1f}%{dm:>+12.1f}±{dsd:>4.1f}pp {verdict}")
except Exception as e:
print(f"{kind:<22} skipped: {e}")
print(f"\nmean±sd over {a.seeds} seeds (weight init + batch order). blocked_gap = 10% time gap each")
print("side; grouped_bucket holds out ENTIRE activities (strictest). If only the LEAKY splits")
print("(chronological/random) beat baseline, the apparent signal is leakage, not generalizable pose.")
if __name__=="__main__": main()
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""WiFlow-style camera-supervised capture (ADR-079 / ADR-180).
Runs on a box with BOTH a camera (ground truth) and reachable live CSI:
- opens a camera, runs MediaPipe Pose -> 17 COCO keypoints (the LABEL),
- subscribes to the sensing-server /ws/sensing (the INPUT: CSI features +
20x20 signal-field),
- writes timestamp-aligned (csi -> pose) pairs to a JSONL dataset.
This is the *collect* phase of camera-supervised CSI->pose training. The camera
and the CSI nodes MUST see the same person in the same space at the same time,
or the pairs are meaningless. Honest by construction: we only emit a pair when
BOTH a confident camera pose AND a live (source=esp32) CSI frame are present in
the same ~100 ms window.
Usage (on ruvultra, with the CSI tunneled to localhost:8765):
python3 wiflow_capture.py --ws ws://localhost:8765/ws/sensing \
--cam 0 --out ~/wiflow-room/dataset.jsonl --seconds 180
"""
import argparse, asyncio, json, time, threading, sys, os
from collections import deque
import urllib.request
import cv2
import numpy as np
import mediapipe as mp
from mediapipe.tasks.python import BaseOptions
from mediapipe.tasks.python.vision import PoseLandmarker, PoseLandmarkerOptions, RunningMode
import websockets
_MODEL_URL = ("https://storage.googleapis.com/mediapipe-models/pose_landmarker/"
"pose_landmarker_lite/float16/latest/pose_landmarker_lite.task")
def ensure_model(path: str) -> str:
if not os.path.exists(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
print(f"[capture] downloading pose model -> {path}", flush=True)
urllib.request.urlretrieve(_MODEL_URL, path)
return path
# MediaPipe Pose (33 landmarks) -> 17 COCO keypoints (same mapping as
# scripts/collect-ground-truth.py, ADR-079).
COCO_FROM_MP = [0, 2, 5, 7, 8, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28]
COCO_NAMES = ["nose","l_eye","r_eye","l_ear","r_ear","l_sho","r_sho","l_elb",
"r_elb","l_wri","r_wri","l_hip","r_hip","l_knee","r_knee","l_ank","r_ank"]
# ---- shared state between the CSI (async) thread and the camera (sync) loop ----
_latest_csi = {"t": 0.0, "frame": None}
_csi_lock = threading.Lock()
_stop = threading.Event()
def csi_thread(ws_url: str):
"""Background thread: keep the most recent LIVE csi frame in _latest_csi."""
async def run():
while not _stop.is_set():
try:
async with websockets.connect(ws_url, open_timeout=8, ping_interval=20) as ws:
while not _stop.is_set():
msg = await asyncio.wait_for(ws.recv(), timeout=8)
d = json.loads(msg)
with _csi_lock:
_latest_csi["t"] = time.time()
_latest_csi["frame"] = d
except Exception as e:
print(f"[csi] reconnect ({e})", flush=True)
await asyncio.sleep(1.0)
asyncio.new_event_loop().run_until_complete(run())
def csi_vector(frame: dict):
"""Flatten a csi frame to a fixed-length input vector: features + field."""
f = frame.get("features", {}) or {}
feats = [f.get("mean_rssi", 0.0), f.get("variance", 0.0),
f.get("motion_band_power", 0.0), f.get("breathing_band_power", 0.0)]
# per-node mean_rssi/variance/motion for up to the 2 nodes (9, 13)
pernode = {nf.get("node_id"): (nf.get("features") or {}) for nf in (frame.get("node_features") or [])}
for nid in (9, 13):
nf = pernode.get(nid, {})
feats += [nf.get("mean_rssi", 0.0), nf.get("variance", 0.0), nf.get("motion_band_power", 0.0)]
field = (frame.get("signal_field", {}) or {}).get("values") or []
field = (field + [0.0] * 400)[:400]
return feats + field # 4 + 6 + 400 = 410-d
def main():
ap = argparse.ArgumentParser(description="WiFlow camera-supervised CSI<->pose capture (ADR-180).")
ap.add_argument("--ws", default="ws://localhost:8765/ws/sensing")
ap.add_argument("--cam", type=int, default=0)
ap.add_argument("--out", default=os.path.expanduser("~/wiflow-room/dataset.jsonl"))
ap.add_argument("--seconds", type=int, default=180)
ap.add_argument("--min-vis", type=float, default=0.5, help="min mean landmark visibility to accept a pose label")
ap.add_argument("--max-skew-ms", type=float, default=150, help="max csi/pose time skew to pair")
ap.add_argument("--require-esp32", action="store_true", default=True,
help="only pair when csi source==esp32 (real). Default on.")
args = ap.parse_args()
os.makedirs(os.path.dirname(args.out), exist_ok=True)
th = threading.Thread(target=csi_thread, args=(args.ws,), daemon=True)
th.start()
cap = cv2.VideoCapture(args.cam)
if not cap.isOpened():
print(f"ERROR: cannot open camera {args.cam}", file=sys.stderr); sys.exit(2)
W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 640
H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 480
model_path = ensure_model(os.path.expanduser("~/wiflow-room/pose_landmarker_lite.task"))
landmarker = PoseLandmarker.create_from_options(PoseLandmarkerOptions(
base_options=BaseOptions(model_asset_path=model_path),
running_mode=RunningMode.IMAGE, min_pose_detection_confidence=0.5))
n_pairs = 0; n_nopose = 0; n_nocsi = 0; n_skew = 0; n_sim = 0
t0 = time.time()
print(f"[capture] camera {args.cam} {W}x{H} -> {args.out} for {args.seconds}s")
print("[capture] stand in view AND in the CSI field; move/walk so poses vary. Ctrl-C to stop.")
with open(args.out, "a") as out:
try:
while time.time() - t0 < args.seconds:
ok, frame = cap.read()
if not ok:
continue
now = time.time()
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
res = landmarker.detect(mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb))
if not res.pose_landmarks:
n_nopose += 1; continue
lm = res.pose_landmarks[0]
kps = [[lm[i].x, lm[i].y, lm[i].visibility] for i in COCO_FROM_MP]
vis = float(np.mean([k[2] for k in kps]))
if vis < args.min_vis:
n_nopose += 1; continue
with _csi_lock:
ct = _latest_csi["t"]; cf = _latest_csi["frame"]
if cf is None:
n_nocsi += 1; continue
if (now - ct) * 1000.0 > args.max_skew_ms:
n_skew += 1; continue
if args.require_esp32 and cf.get("source") != "esp32":
n_sim += 1; continue
rec = {"t": now, "vis": round(vis, 3),
"kps": [[round(x, 4), round(y, 4), round(v, 3)] for x, y, v in kps],
"csi": csi_vector(cf),
"src": cf.get("source"),
"nodes": sorted(n.get("node_id") for n in cf.get("nodes", []) if n.get("node_id") is not None)}
out.write(json.dumps(rec) + "\n")
n_pairs += 1
if n_pairs % 30 == 0:
out.flush()
el = int(now - t0)
print(f"[capture] t+{el:3d}s pairs={n_pairs} (skip: nopose={n_nopose} nocsi={n_nocsi} skew={n_skew} sim={n_sim})", flush=True)
except KeyboardInterrupt:
print("\n[capture] stopped by user")
_stop.set(); cap.release()
print(f"[capture] DONE. wrote {n_pairs} paired samples to {args.out}")
print(f"[capture] skipped: no-pose={n_nopose} no-csi={n_nocsi} skew={n_skew} simulated={n_sim}")
if n_pairs == 0:
print("[capture] WARNING: 0 pairs — check camera sees you AND csi source==esp32 (live).")
if __name__ == "__main__":
main()
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Live CSI->pose inference bridge (ADR-180).
Runs on the box with the live CSI. Loads the camera-supervised model (numpy,
no torch needed), subscribes to /ws/sensing, runs a forward pass per frame, and
broadcasts the predicted 17-keypoint pose to HTML clients on ws://:8770/pose.
python wiflow_infer.py --model model/model.npz \
--in ws://localhost:8765/ws/sensing --port 8770
"""
import argparse, asyncio, json, os
import numpy as np
import websockets
# COCO skeleton edges (for the client; sent once in 'meta')
EDGES = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],
[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]]
def csi_vector(frame):
f = frame.get("features", {}) or {}
feats = [f.get("mean_rssi",0.0), f.get("variance",0.0),
f.get("motion_band_power",0.0), f.get("breathing_band_power",0.0)]
pernode = {nf.get("node_id"): (nf.get("features") or {}) for nf in (frame.get("node_features") or [])}
for nid in (9,13):
nf = pernode.get(nid,{}); feats += [nf.get("mean_rssi",0.0), nf.get("variance",0.0), nf.get("motion_band_power",0.0)]
field = (frame.get("signal_field",{}) or {}).get("values") or []
field = (field + [0.0]*400)[:400]
return np.array(feats + field, np.float32)
class Model:
def __init__(self, path):
z = np.load(path)
self.mu, self.sd = z["mu"], z["sd"]
self.W = [z["net_0_weight"], z["net_3_weight"], z["net_6_weight"], z["net_8_weight"]]
self.b = [z["net_0_bias"], z["net_3_bias"], z["net_6_bias"], z["net_8_bias"]]
def __call__(self, x):
h = (x - self.mu) / self.sd
for i in range(3):
h = np.maximum(0.0, h @ self.W[i].T + self.b[i]) # Linear+ReLU
out = 1.0/(1.0+np.exp(-(h @ self.W[3].T + self.b[3]))) # Linear+Sigmoid -> 34
return out.reshape(17,2)
CLIENTS = set()
LATEST = {"pose": None}
async def serve_client(ws):
CLIENTS.add(ws)
try:
await ws.send(json.dumps({"type":"meta","edges":EDGES}))
async for _ in ws: # client is read-only; just keep alive
pass
except Exception:
pass
finally:
CLIENTS.discard(ws)
async def infer_loop(model, in_url):
while True:
try:
async with websockets.connect(in_url, open_timeout=8, ping_interval=20) as ws:
async for msg in ws:
d = json.loads(msg)
kp = model(csi_vector(d))
cls = d.get("classification",{})
payload = {"type":"pose","src":d.get("source"),
"presence":bool(cls.get("presence")),
"motion":(d.get("features",{}) or {}).get("motion_band_power"),
"kps":[[round(float(x),4),round(float(y),4)] for x,y in kp],
"nodes":sorted(n.get("node_id") for n in d.get("nodes",[]) if n.get("node_id") is not None)}
LATEST["pose"]=payload
if CLIENTS:
dead=[]
for c in list(CLIENTS):
try: await c.send(json.dumps(payload))
except Exception: dead.append(c)
for c in dead: CLIENTS.discard(c)
except Exception as e:
print(f"[infer] reconnect ({e})", flush=True); await asyncio.sleep(1.0)
async def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default=os.path.join(os.path.dirname(__file__),"model","model.npz"))
ap.add_argument("--in", dest="in_url", default="ws://localhost:8765/ws/sensing")
ap.add_argument("--port", type=int, default=8770)
args = ap.parse_args()
model = Model(args.model)
print(f"[infer] model {args.model} loaded; serving predicted poses on ws://0.0.0.0:{args.port}/pose")
async with websockets.serve(serve_client, "0.0.0.0", args.port):
await infer_loop(model, args.in_url)
if __name__ == "__main__":
asyncio.run(main())
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Train a CSI->pose model on the camera-supervised dataset (ADR-079/180).
Input : 410-d CSI vector (4 global feats + 6 per-node + 400 signal-field).
Target : 17 COCO keypoints (x,y), normalized 0..1 from the camera (ground truth).
Reports HONEST held-out PCK@k + MPJPE on a chronological val split (the last
20% of the session — never trained on), so the number is not leaked.
Usage (ruvultra venv):
python wiflow_train.py --data ~/wiflow-room/dataset.jsonl --out ~/wiflow-room/model.pt
"""
import argparse, json, math, os, sys
import numpy as np
import torch, torch.nn as nn
def load(path):
X, Y, V = [], [], []
with open(path) as f:
for line in f:
r = json.loads(line)
X.append(r["csi"]) # 410
kp = r["kps"] # 17 x [x,y,vis]
Y.append([c for k in kp for c in (k[0], k[1])]) # 34
V.append([k[2] for k in kp]) # 17 visibilities
return np.array(X, np.float32), np.array(Y, np.float32), np.array(V, np.float32)
class Net(nn.Module):
def __init__(self, din, dout):
super().__init__()
self.net = nn.Sequential(
nn.Linear(din, 512), nn.ReLU(), nn.Dropout(0.3),
nn.Linear(512, 256), nn.ReLU(), nn.Dropout(0.3),
nn.Linear(256, 128), nn.ReLU(),
nn.Linear(128, dout), nn.Sigmoid()) # coords in 0..1
def forward(self, x): return self.net(x)
def pck(pred, gt, vis, thr):
# pred/gt: [N,34] -> [N,17,2]; PCK@thr in normalized image units, visible kps only
p = pred.reshape(-1, 17, 2); g = gt.reshape(-1, 17, 2)
d = np.linalg.norm(p - g, axis=2) # [N,17]
m = vis > 0.5
return float((d[m] < thr).mean()) if m.any() else 0.0, float(d[m].mean()) if m.any() else float("nan")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--data", required=True)
ap.add_argument("--out", default=os.path.expanduser("~/wiflow-room/model.pt"))
ap.add_argument("--epochs", type=int, default=300)
ap.add_argument("--bs", type=int, default=64)
args = ap.parse_args()
X, Y, V = load(args.data)
n = len(X)
print(f"[train] {n} samples, X={X.shape} Y={Y.shape}")
if n < 200:
print("[train] too few samples"); sys.exit(2)
# chronological split (NOT shuffled) so val is a held-out time segment -> honest
cut = int(n * 0.8)
mu, sd = X[:cut].mean(0), X[:cut].std(0) + 1e-6 # standardize on train only
Xn = (X - mu) / sd
dev = "cuda" if torch.cuda.is_available() else "cpu"
Xtr = torch.tensor(Xn[:cut]).to(dev); Ytr = torch.tensor(Y[:cut]).to(dev)
Xva = torch.tensor(Xn[cut:]).to(dev); Yva = Y[cut:]; Vva = V[cut:]
# mean-pose baseline (predict the train-mean pose for everything) — the bar to beat
mean_pose = Y[:cut].mean(0)
base_pck, base_mpjpe = pck(np.tile(mean_pose, (len(Yva), 1)), Yva, Vva, 0.10)
net = Net(X.shape[1], Y.shape[1]).to(dev)
opt = torch.optim.Adam(net.parameters(), lr=1e-3, weight_decay=1e-4)
lossf = nn.MSELoss()
best = (1e9, None)
for ep in range(args.epochs):
net.train(); perm = torch.randperm(len(Xtr), device=dev)
for i in range(0, len(Xtr), args.bs):
idx = perm[i:i+args.bs]
opt.zero_grad(); out = net(Xtr[idx]); loss = lossf(out, Ytr[idx]); loss.backward(); opt.step()
if (ep + 1) % 20 == 0 or ep == args.epochs - 1:
net.eval()
with torch.no_grad(): pv = net(Xva).cpu().numpy()
p10, mpj = pck(pv, Yva, Vva, 0.10); p05, _ = pck(pv, Yva, Vva, 0.05)
vloss = float(((pv - Yva) ** 2).mean())
print(f"[train] ep{ep+1:3d} val_mse={vloss:.4f} PCK@0.10={p10*100:.1f}% PCK@0.05={p05*100:.1f}% MPJPE={mpj:.4f}")
if vloss < best[0]: best = (vloss, {"sd": net.state_dict(), "p10": p10, "p05": p05, "mpj": mpj})
torch.save({"model": best[1]["sd"], "mu": mu, "sd": sd, "din": X.shape[1]}, args.out)
print("\n==================== HONEST RESULT (held-out 20%, never trained) ====================")
print(f" MEAN-POSE BASELINE : PCK@0.10 = {base_pck*100:.1f}% MPJPE = {base_mpjpe:.4f} (the bar to beat)")
print(f" CSI->POSE MODEL : PCK@0.10 = {best[1]['p10']*100:.1f}% PCK@0.05 = {best[1]['p05']*100:.1f}% MPJPE = {best[1]['mpj']:.4f}")
delta = (best[1]['p10'] - base_pck) * 100
print(f" VERDICT: model {'BEATS' if delta>1 else 'does NOT beat'} mean-pose baseline by {delta:+.1f} pp "
f"-> {'real CSI->pose signal' if delta>1 else 'NO usable CSI->pose signal (honest negative)'}")
print(f" saved -> {args.out}")
if __name__ == "__main__":
main()
Generated
+11
View File
@@ -3595,6 +3595,7 @@ dependencies = [
"anyhow",
"axum",
"clap",
"futures",
"homecore",
"homecore-api",
"homecore-assist",
@@ -3602,8 +3603,13 @@ dependencies = [
"homecore-hap",
"homecore-plugins",
"homecore-recorder",
"http-body-util",
"reqwest 0.12.28",
"serde",
"serde_json",
"tokio",
"tower 0.5.3",
"tower-http",
"tracing",
"tracing-subscriber",
]
@@ -3767,6 +3773,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.4",
"tower-service",
"webpki-roots 1.0.7",
]
[[package]]
@@ -6870,6 +6877,8 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.37",
"rustls-pki-types",
"serde",
"serde_json",
@@ -6877,6 +6886,7 @@ dependencies = [
"sync_wrapper 1.0.2",
"tokio",
"tokio-native-tls",
"tokio-rustls 0.26.4",
"tower 0.5.3",
"tower-http",
"tower-service",
@@ -6884,6 +6894,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.7",
]
[[package]]
+4 -4
View File
@@ -25,8 +25,7 @@ members = [
"crates/wifi-densepose-ruvector",
"crates/wifi-densepose-desktop",
"crates/wifi-densepose-pointcloud",
"crates/wifi-densepose-geo",
"crates/wifi-densepose-worldgraph", # ADR-139 — WorldGraph environmental digital twin
# geo + worldgraph extracted to ruvnet/worldgraph submodule (see crates/worldgraph)
"crates/wifi-densepose-engine", # ADR-135..146 integration/composition layer
"crates/wifi-densepose-calibration", # ADR-151 — per-room calibration & specialist training
"crates/nvsim",
@@ -58,7 +57,7 @@ members = [
"crates/wifi-densepose-bfld",
# ADR-147: OccWorld thin-client bridge — WorldGraph PersonTrack history →
# OccWorld Python subprocess → TrajectoryPrior injection into pose tracker.
"crates/wifi-densepose-worldmodel",
# worldmodel extracted to ruvnet/worldgraph submodule (consumed via path dep)
# ADR-147 (Phase 5): OccWorld TransVQVAE ported to Candle — native Rust
# inference without Python/IPC overhead. Loaded alongside the Python bridge
# as a faster alternative once Phase-5 weights are available.
@@ -88,6 +87,7 @@ members = [
exclude = [
"crates/wifi-densepose-wasm-edge",
"crates/homecore-plugin-example",
"crates/worldgraph", # ruvnet/worldgraph submodule — its own workspace (geo/worldgraph/worldmodel)
]
[workspace.package]
@@ -215,7 +215,7 @@ wifi-densepose-hardware = { version = "0.3.0", path = "crates/wifi-densepose-har
wifi-densepose-wasm = { version = "0.3.0", path = "crates/wifi-densepose-wasm" }
wifi-densepose-mat = { version = "0.3.0", path = "crates/wifi-densepose-mat" }
wifi-densepose-ruvector = { version = "0.3.0", path = "crates/wifi-densepose-ruvector" }
wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/wifi-densepose-worldmodel" }
wifi-densepose-worldmodel = { version = "0.3.0", path = "crates/worldgraph/wifi-densepose-worldmodel" }
[profile.release]
lto = true
+5 -1
View File
@@ -42,7 +42,11 @@ pub fn router(state: SharedState) -> Router {
.with_state(state)
}
fn build_cors_layer() -> CorsLayer {
/// Build the audited CORS allowlist layer (HC-05). Exposed so the
/// integration binary can apply the SAME allowlist to routes merged in
/// outside `router()` (e.g. the ADR-131 BFF gateway), instead of leaving
/// `/api/homecore/*` and `/api/cal/*` with no CORS coverage at all.
pub fn build_cors_layer() -> CorsLayer {
let raw = std::env::var("HOMECORE_CORS_ORIGINS").ok();
let origins: Vec<HeaderValue> = match raw {
Some(v) if !v.trim().is_empty() => v
+1 -1
View File
@@ -7,7 +7,7 @@ pub mod state;
pub mod tokens;
pub mod ws;
pub use app::{router, AppState};
pub use app::{build_cors_layer, router, AppState};
pub use error::{ApiError, ApiResult};
pub use state::SharedState;
pub use tokens::LongLivedTokenStore;
+20
View File
@@ -37,6 +37,26 @@ clap = { version = "4", features = ["derive", "env"] }
anyhow = "1"
serde_json = "1"
axum = { version = "0.7", features = ["macros"] }
# Static-file serving for the HOMECORE-UI dashboard (ADR-131) mounted at
# /homecore, request tracing, and the CORS allowlist applied to BOTH the
# homecore-api routes AND the merged BFF gateway routes (ADR-131 §11).
tower-http = { version = "0.6", features = ["fs", "trace", "cors"] }
# BFF gateway (ADR-131 §11): reverse-proxy the calibration API + aggregate
# upstreams. rustls is requested here, but NOTE this is a WORKSPACE-WIDE
# concern: cargo feature-unification means a sibling crate that enables
# reqwest's default `native-tls` re-introduces OpenSSL into the final binary
# regardless of this opt-out. A real "no OpenSSL on the appliance" guarantee
# requires every crate that pulls reqwest to align on rustls-only (tracked in
# CHANGELOG / ADR-131 security note).
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
# Concurrent fan-out of per-bank RoomState fetches in the gateway (§11 perf).
futures = "0.3"
[dev-dependencies]
# Drive the assembled router in integration tests via ServiceExt::oneshot.
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"
[features]
default = []
+23
View File
@@ -116,6 +116,29 @@ export RUST_LOG="homecore=debug,homecore_api=info"
| `--db` | `HOMECORE_DB` | `sqlite::memory:` | SQLite path (`:memory:` for ephemeral) |
| `--location-name` | `HOMECORE_LOCATION` | `Home` | Friendly name returned by `/api/config` |
| `--no-recorder` | — | off | Disable SQLite recorder (low-resource deployments) |
| `--ui-dir` | `HOMECORE_UI_DIR` | `<crate>/ui` | HOMECORE-UI asset dir served at `/homecore` (ADR-131); empty disables the mount |
## HOMECORE-UI dashboard (ADR-131)
This binary also serves the **HOMECORE-UI** — the complete operational dashboard
for the two-tier Cognitum stack (v0 Appliance → SEEDs → ESP32 nodes) — at
`/homecore`, alongside the HA-compat `/api` surface. It is a zero-dependency,
no-build-step vanilla TS/JS + CSS frontend living in `ui/`:
```bash
cargo run -p homecore-server # then open http://localhost:8123/homecore/
```
It drives the live `/api` + `/api/websocket` (`subscribe_events`) endpoints; panels
backed by services not in this binary (SEED HTTPS API, calibration ADR-151,
federation ADR-105) render against a DEMO-flagged contract-conformant mock until
those endpoints land (ADR-131 §7.1). Frontend tests + benchmark run under plain
`node` (no `npm install`):
```bash
cd ui && npm test # import graph + render-smoke + interaction (24 checks)
cd ui && npm run bench # bundle budget (~137 KB, ~37× smaller than HA) + render timing
```
## Comparison to Home Assistant
+758
View File
@@ -0,0 +1,758 @@
//! HOMECORE-UI backend-for-frontend (BFF) gateway — ADR-131 §11.
//!
//! `homecore-server` is the single origin the dashboard talks to (§2.1).
//! This module adds the `/api/homecore/*` aggregation namespace and the
//! `/api/cal/*` reverse-proxy to the calibration service, so the browser
//! never makes a cross-origin call and never holds an upstream credential.
//!
//! Implemented now (self-contained, no new external service):
//! * `/api/cal/*` — reverse-proxy → calibration API (ADR-151) [W2]
//! * `GET /api/homecore/rooms` — per-room RoomState, adapted to the UI shape [W2]
//! * `GET /api/homecore/cogs` — COG supervisor over the apps dir [W4]
//! * `GET /api/homecore/appliance` — host metrics from /proc + port probes [W6]
//!
//! Returns a typed `503 upstream_unavailable` for routes whose upstream is
//! a SEED device / appliance daemon not present in this repo (§11.2 / §12):
//! seeds, federation, witness, privacy, settings, automations, events
//! history, hailo, tokens. The front-end renders these as error states
//! (it never falls back to mock in production — §2.2).
//!
//! NOTE: written against the real crate APIs but NOT yet compiled in the
//! authoring environment (no Rust toolchain); run `cargo test -p
//! homecore-server` on a Rust host.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::body::Bytes;
use axum::extract::{Path, RawQuery, State};
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use serde_json::{json, Value};
use homecore_api::auth::BearerAuth;
use homecore_api::SharedState;
/// Static gateway configuration (from CLI/env in `main`).
pub struct GatewayConfig {
/// Base URL of the calibration service (`wifi-densepose calibrate-serve`),
/// e.g. `http://127.0.0.1:8090`. `None` disables the calibration routes.
pub calibration_url: Option<String>,
/// Bearer token for the calibration service (held server-side only).
pub calibration_token: Option<String>,
/// COG install directory the supervisor reads (`/var/lib/cognitum/apps`).
pub apps_dir: PathBuf,
/// Per-proxy timeout so one slow upstream cannot stall the dashboard.
pub timeout: Duration,
}
#[derive(Clone)]
pub struct GatewayState {
pub shared: SharedState,
pub http: reqwest::Client,
pub cfg: Arc<GatewayConfig>,
}
impl GatewayState {
pub fn new(shared: SharedState, cfg: GatewayConfig) -> Self {
let http = reqwest::Client::builder()
.timeout(cfg.timeout)
.build()
.unwrap_or_else(|_| reqwest::Client::new());
Self { shared, http, cfg: Arc::new(cfg) }
}
}
/// Build the gateway router (state already applied → `Router<()>`), ready
/// to `.merge()` into the main app alongside the homecore-api routes.
pub fn gateway_router(state: GatewayState) -> Router {
Router::new()
// ── calibration reverse-proxy (W2) ──────────────────────────
.route("/api/cal/*path", get(cal_proxy_get).post(cal_proxy_post))
// ── aggregation endpoints (W2 / W4 / W6) ────────────────────
.route("/api/homecore/rooms", get(rooms))
.route("/api/homecore/cogs", get(cogs_list))
.route("/api/homecore/appliance", get(appliance))
// ── upstream-dependent stubs (W3 / W5 / W6): typed 503 ───────
.route("/api/homecore/seeds", get(stub_503))
.route("/api/homecore/seeds/:id", get(stub_503))
.route("/api/homecore/federation", get(stub_503))
.route("/api/homecore/witness", get(stub_503))
.route("/api/homecore/privacy", get(stub_503).post(stub_503))
.route("/api/homecore/settings", get(stub_503))
.route("/api/homecore/automations", get(stub_503).post(stub_503))
// No OTA feed wired yet → "no updates available" is an empty list,
// not an error (so a working COG list is never blanked).
.route("/api/homecore/cogs/updates", get(empty_list))
.route("/api/homecore/hailo", get(stub_503))
.route("/api/homecore/tokens", get(stub_503))
.route("/api/events", get(stub_503))
.with_state(state)
}
// ── auth + typed errors ─────────────────────────────────────────────
async fn require_auth(headers: &HeaderMap, st: &GatewayState) -> Result<(), Response> {
BearerAuth::from_headers(headers, st.shared.tokens())
.await
.map(|_| ())
.map_err(|e| e.into_response())
}
fn typed(status: StatusCode, error: &str, detail: &str) -> Response {
(status, Json(json!({ "error": error, "detail": detail }))).into_response()
}
fn upstream_unavailable(detail: &str) -> Response {
typed(StatusCode::SERVICE_UNAVAILABLE, "upstream_unavailable", detail)
}
fn upstream_timeout(detail: &str) -> Response {
typed(StatusCode::GATEWAY_TIMEOUT, "upstream_timeout", detail)
}
fn bad_request(detail: &str) -> Response {
typed(StatusCode::BAD_REQUEST, "bad_request", detail)
}
/// Reject a proxied wildcard path that could escape the `/api/` scope on the
/// upstream calibration service (path-traversal / confused-deputy SSRF —
/// ADR-131 §11 security review). The privileged server-side calibration bearer
/// is attached by `proxy()`, so a client must NOT be able to redirect that
/// credential outside `…/api/`.
///
/// Returns `Err(400)` when the path (or its percent-decoded form):
/// * is absolute (`/…`) — would replace the `…/api/` base entirely,
/// * contains a backslash (`\`) — Windows/alt-separator traversal,
/// * has any segment equal to `.` or `..` — dot-segment traversal,
/// * still carries `%2e%2e` / `%2f` (single-decode is enough — we reject on
/// the decoded form AND on a residual encoded marker, so double-encoding
/// like `%252e` decodes once to `%2e` and is caught here).
///
/// Legitimate `v1/...` paths (the only shape the UI sends) pass unchanged.
fn validate_proxy_path(path: &str) -> Result<(), Response> {
// 1. Reject on the raw form first (cheap; catches backslash + leading `/`).
if path.starts_with('/') {
return Err(bad_request("proxied path must be relative (leading '/' not allowed)"));
}
if path.contains('\\') {
return Err(bad_request("proxied path must not contain a backslash"));
}
// 2. Percent-decode once and re-check; reject if decoding is invalid.
let decoded = percent_decode_once(path)
.ok_or_else(|| bad_request("proxied path has invalid percent-encoding"))?;
if decoded.starts_with('/') || decoded.contains('\\') {
return Err(bad_request("proxied path resolves to an absolute/traversal path"));
}
// 3. Reject any `.`/`..` segment on BOTH the raw and decoded forms so an
// encoded `%2e%2e%2f` cannot slip a dot-segment past the split.
for form in [path, decoded.as_str()] {
for seg in form.split(['/', '\\']) {
if seg == "." || seg == ".." {
return Err(bad_request("proxied path must not contain '.' or '..' segments"));
}
}
// Defence in depth: a residual encoded traversal marker survived the
// single decode (e.g. originally double-encoded). Reject it outright.
let lower = form.to_ascii_lowercase();
if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") {
return Err(bad_request("proxied path must not contain encoded traversal markers"));
}
}
Ok(())
}
/// Minimal single-pass percent-decoder (no external dep). Returns `None` on a
/// malformed escape so callers can fail closed.
fn percent_decode_once(s: &str) -> Option<String> {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' => {
if i + 2 >= bytes.len() {
return None;
}
let hi = (bytes[i + 1] as char).to_digit(16)?;
let lo = (bytes[i + 2] as char).to_digit(16)?;
out.push((hi * 16 + lo) as u8);
i += 3;
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8(out).ok()
}
/// Routes whose upstream is a SEED device / appliance daemon not present
/// in this repo. Honest 503 until the corresponding §12 wave lands.
async fn stub_503(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
upstream_unavailable("endpoint not yet wired — see ADR-131 §11/§12 (SEED device / appliance upstream)")
}
/// Auth-gated empty-array response (e.g. OTA updates with no feed wired).
async fn empty_list(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
Json(Vec::<Value>::new()).into_response()
}
// ── calibration reverse-proxy (W2) ──────────────────────────────────
async fn cal_proxy_get(
State(st): State<GatewayState>,
headers: HeaderMap,
Path(path): Path<String>,
RawQuery(q): RawQuery,
) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
if let Err(r) = validate_proxy_path(&path) {
return r;
}
let base = match &st.cfg.calibration_url {
Some(u) => u,
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
};
let qs = q.map(|s| format!("?{s}")).unwrap_or_default();
// The wildcard already carries the `v1/...` segment (the UI calls
// `/api/cal/v1/...`), so map `/api/cal/<rest>` → `<base>/api/<rest>`.
let url = format!("{}/api/{}{}", base.trim_end_matches('/'), path, qs);
proxy(&st, st.http.get(&url)).await
}
async fn cal_proxy_post(
State(st): State<GatewayState>,
headers: HeaderMap,
Path(path): Path<String>,
body: Bytes,
) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
if let Err(r) = validate_proxy_path(&path) {
return r;
}
let base = match &st.cfg.calibration_url {
Some(u) => u,
None => return upstream_unavailable("calibration service not configured (set --calibration-url / HOMECORE_CALIBRATION_URL)"),
};
let url = format!("{}/api/{}", base.trim_end_matches('/'), path);
let rb = st
.http
.post(&url)
.header(header::CONTENT_TYPE, "application/json")
.body(body);
proxy(&st, rb).await
}
/// Send an upstream request (with the server-side calibration token) and
/// stream the response back verbatim, mapping transport failures to typed
/// errors.
async fn proxy(st: &GatewayState, mut rb: reqwest::RequestBuilder) -> Response {
if let Some(tok) = &st.cfg.calibration_token {
rb = rb.bearer_auth(tok);
}
match rb.send().await {
Ok(resp) => {
let status = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
let ct = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/json")
.to_string();
match resp.bytes().await {
Ok(b) => {
let mut out = Response::new(axum::body::Body::from(b));
*out.status_mut() = status;
if let Ok(hv) = HeaderValue::from_str(&ct) {
out.headers_mut().insert(header::CONTENT_TYPE, hv);
}
out
}
Err(e) => upstream_unavailable(&format!("calibration body read failed: {e}")),
}
}
Err(e) if e.is_timeout() => upstream_timeout("calibration service timed out"),
Err(e) => upstream_unavailable(&format!("calibration service: {e}")),
}
}
async fn fetch_json(st: &GatewayState, url: &str) -> Result<Value, Response> {
let mut rb = st.http.get(url);
if let Some(tok) = &st.cfg.calibration_token {
rb = rb.bearer_auth(tok);
}
match rb.send().await {
Ok(resp) => resp
.json::<Value>()
.await
.map_err(|e| upstream_unavailable(&format!("calibration JSON parse: {e}"))),
Err(e) if e.is_timeout() => Err(upstream_timeout("calibration service timed out")),
Err(e) => Err(upstream_unavailable(&format!("calibration service: {e}"))),
}
}
// ── rooms aggregation + RoomState adapter (W2 / §11.3) ──────────────
async fn rooms(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
let base = match &st.cfg.calibration_url {
Some(u) => u.trim_end_matches('/').to_string(),
None => return upstream_unavailable("calibration service not configured"),
};
let banks = match fetch_json(&st, &format!("{base}/api/v1/calibration/baselines")).await {
Ok(v) => bank_names(&v),
Err(r) => return r,
};
// Fetch every bank's RoomState concurrently (§11 perf): one slow bank no
// longer serialises behind the others. Order is preserved by collecting in
// the original bank order.
let fetches = banks.into_iter().map(|bank| {
let st = &st;
let base = base.as_str();
async move {
let url = format!("{base}/api/v1/room/state?bank={bank}");
fetch_json(st, &url).await.ok().map(|v| adapt_room_state(&bank, &v))
}
});
let out: Vec<Value> = futures::future::join_all(fetches)
.await
.into_iter()
.flatten()
.collect();
Json(out).into_response()
}
/// Accept either `["living_room", ...]` or `[{ "name"|"id"|"bank": ... }]`.
fn bank_names(v: &Value) -> Vec<String> {
match v {
Value::Array(items) => items
.iter()
.filter_map(|it| match it {
Value::String(s) => Some(s.clone()),
Value::Object(o) => o
.get("name")
.or_else(|| o.get("id"))
.or_else(|| o.get("bank"))
.and_then(|x| x.as_str())
.map(str::to_string),
_ => None,
})
.collect(),
Value::Object(o) => o
.get("baselines")
.map(|b| bank_names(b))
.unwrap_or_default(),
_ => Vec::new(),
}
}
/// Adapt the calibration `RoomState` (Option<SpecialistReading> fields +
/// `vetoed`/`stale`) onto the UI shape (§11.3). `None` → JSON `null`,
/// preserving the not-trained-vs-withheld distinction (§6 invariant 3).
fn adapt_room_state(bank: &str, v: &Value) -> Value {
let chip = |k: &str| -> Value {
match v.get(k) {
Some(r) if !r.is_null() => json!({
"value": r.get("label").and_then(|l| l.as_str()).map(Value::from)
.unwrap_or_else(|| r.get("value").cloned().unwrap_or(Value::Null)),
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
}),
_ => Value::Null,
}
};
let bpm = |k: &str| -> Value {
match v.get(k) {
Some(r) if !r.is_null() => json!({
"value": r.get("value").cloned().unwrap_or(Value::Null),
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
}),
_ => Value::Null,
}
};
let anomaly = match v.get("anomaly") {
Some(r) if !r.is_null() => json!({
"value": r.get("value").cloned().unwrap_or(Value::Null),
"confidence": r.get("confidence").cloned().unwrap_or(Value::Null),
// §6 invariant 3 (honesty): pass through the REAL anomaly threshold
// from the upstream RoomState if present; if absent, emit null
// (withheld) — never fabricate a constant. The UI treats null as
// withheld, not a fake default.
"threshold": r.get("threshold").cloned().unwrap_or(Value::Null),
}),
_ => Value::Null,
};
json!({
"room_id": bank,
"seeds": [],
"stale": v.get("stale").and_then(|b| b.as_bool()).unwrap_or(false),
"vetoed": v.get("vetoed").and_then(|b| b.as_bool()).unwrap_or(false),
"presence": chip("presence"),
"posture": chip("posture"),
"breathing_bpm": bpm("breathing"),
"heart_bpm": bpm("heartbeat"),
"restlessness": bpm("restlessness"),
"anomaly": anomaly,
})
}
// ── COG supervisor (W4 / §11.6) ─────────────────────────────────────
async fn cogs_list(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
let mut out: Vec<Value> = Vec::new();
let rd = match std::fs::read_dir(&st.cfg.apps_dir) {
Ok(rd) => rd,
Err(_) => return Json(out).into_response(), // no apps dir yet → empty
};
for entry in rd.flatten() {
let dir = entry.path();
if !dir.is_dir() {
continue;
}
let manifest = match std::fs::read_to_string(dir.join("manifest.json")) {
Ok(s) => s,
Err(_) => continue,
};
let m: Value = match serde_json::from_str(&manifest) {
Ok(v) => v,
Err(_) => continue,
};
let id = m
.get("id")
.and_then(|x| x.as_str())
.unwrap_or_else(|| dir.file_name().and_then(|n| n.to_str()).unwrap_or("?"))
.to_string();
let pid = read_pid(&dir, &id);
let alive = pid.map(pid_alive).unwrap_or(false);
let status = if alive { "running" } else { "stopped" };
out.push(json!({
"id": id,
"version": m.get("version").and_then(|x| x.as_str()).unwrap_or("?"),
"arch": m.get("arch").and_then(|x| x.as_str()).unwrap_or("arm"),
"status": status,
"pid": pid,
"sha256_verified": m.get("binary_sha256").is_some(),
"signature_verified": m.get("binary_signature").is_some(),
"hef": m.get("hef").cloned().unwrap_or(Value::Null),
}));
}
Json(out).into_response()
}
fn read_pid(dir: &std::path::Path, id: &str) -> Option<i64> {
for name in [format!("{id}.pid"), "pid".to_string(), "app.pid".to_string()] {
if let Ok(s) = std::fs::read_to_string(dir.join(&name)) {
if let Ok(p) = s.trim().parse::<i64>() {
return Some(p);
}
}
}
None
}
fn pid_alive(pid: i64) -> bool {
if pid <= 0 {
return false;
}
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
// ── appliance metrics (W6 / §11.5) ──────────────────────────────────
async fn appliance(State(st): State<GatewayState>, headers: HeaderMap) -> Response {
if let Err(r) = require_auth(&headers, &st).await {
return r;
}
let ram = mem_used_pct();
let cpu = cpu_load_pct();
let uptime = uptime_secs();
// Probe the appliance services concurrently with a non-blocking async
// connect under a timeout (§11 perf): previously a sequential blocking
// `std::net::TcpStream::connect_timeout` stalled the whole async handler
// for up to `N * timeout` and parked a Tokio worker thread per probe.
let probes = [
("ruview-mcp-brain", 9876u16),
("cognitum-rvf-agent", 9004),
("ruvector-hailo-worker", 50051),
]
.into_iter()
.map(|(name, port)| {
let timeout = st.cfg.timeout;
async move {
let up = tcp_open("127.0.0.1", port, timeout).await;
json!({ "name": name, "port": port, "status": if up { "running" } else { "unreachable" } })
}
});
let services: Vec<Value> = futures::future::join_all(probes).await;
Json(json!({
"cpu_pct": cpu,
"ram_pct": ram,
"hailo_load_pct": Value::Null, // requires the Hailo runtime stat source (§11.5 APPLIANCE)
"hailo_temp_c": Value::Null,
"uptime_s": uptime,
"services": services,
"event_rate": [],
"channel_capacity": 4096,
"channel_lag": 0,
}))
.into_response()
}
fn read_first_line(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok().and_then(|s| s.lines().next().map(str::to_string))
}
fn uptime_secs() -> Option<u64> {
read_first_line("/proc/uptime")
.and_then(|l| l.split_whitespace().next().map(str::to_string))
.and_then(|s| s.parse::<f64>().ok())
.map(|f| f as u64)
}
fn mem_used_pct() -> Option<f64> {
let txt = std::fs::read_to_string("/proc/meminfo").ok()?;
let mut total = 0f64;
let mut avail = 0f64;
for line in txt.lines() {
let mut it = line.split_whitespace();
match it.next() {
Some("MemTotal:") => total = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0),
Some("MemAvailable:") => avail = it.next().and_then(|v| v.parse().ok()).unwrap_or(0.0),
_ => {}
}
}
if total > 0.0 {
Some(((total - avail) / total * 100.0 * 10.0).round() / 10.0)
} else {
None
}
}
fn cpu_load_pct() -> Option<f64> {
// loadavg(1m) / ncpu * 100 — a cheap proxy (no two-sample /proc/stat).
let load = read_first_line("/proc/loadavg")?
.split_whitespace()
.next()?
.parse::<f64>()
.ok()?;
let ncpu = std::thread::available_parallelism().map(|n| n.get() as f64).unwrap_or(1.0);
Some(((load / ncpu * 100.0).min(100.0) * 10.0).round() / 10.0)
}
/// Non-blocking liveness probe: succeeds iff a TCP connection to
/// `host:port` completes within `timeout`. Async so it never parks a Tokio
/// worker thread (unlike the blocking `std::net` connect it replaced).
async fn tcp_open(host: &str, port: u16, timeout: Duration) -> bool {
let addr = format!("{host}:{port}");
matches!(
tokio::time::timeout(timeout, tokio::net::TcpStream::connect(&addr)).await,
Ok(Ok(_))
)
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::Request;
use homecore::HomeCore;
use homecore_api::{LongLivedTokenStore, SharedState};
use tower::ServiceExt;
fn gw() -> GatewayState {
let shared = SharedState::with_tokens(
HomeCore::new(),
"Test",
"test",
LongLivedTokenStore::allow_any_non_empty(),
);
GatewayState::new(
shared,
GatewayConfig {
calibration_url: None,
calibration_token: None,
apps_dir: PathBuf::from("/nonexistent-apps-dir"),
timeout: Duration::from_millis(200),
},
)
}
async fn send(app: Router, method: &str, path: &str) -> (StatusCode, String) {
let resp = app
.oneshot(
Request::builder()
.method(method)
.uri(path)
.header("authorization", "Bearer dev")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let b = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
(status, String::from_utf8_lossy(&b).into_owned())
}
#[tokio::test]
async fn unauthenticated_is_rejected() {
let app = gateway_router(gw());
let resp = app
.oneshot(Request::builder().uri("/api/homecore/cogs").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn cogs_returns_empty_when_apps_dir_missing() {
let (status, body) = send(gateway_router(gw()), "GET", "/api/homecore/cogs").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body.trim(), "[]");
}
#[tokio::test]
async fn rooms_503_when_calibration_unconfigured() {
let (status, body) = send(gateway_router(gw()), "GET", "/api/homecore/rooms").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert!(body.contains("upstream_unavailable"));
}
#[tokio::test]
async fn seed_tier_routes_are_typed_503() {
for p in ["/api/homecore/seeds", "/api/homecore/federation", "/api/homecore/witness", "/api/events"] {
let (status, body) = send(gateway_router(gw()), "GET", p).await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{p} should be 503");
assert!(body.contains("upstream_unavailable"), "{p} typed body");
}
}
#[tokio::test]
async fn appliance_returns_metrics_json() {
let (status, body) = send(gateway_router(gw()), "GET", "/api/homecore/appliance").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("\"services\""));
assert!(body.contains("\"ram_pct\""));
}
#[test]
fn adapt_room_state_maps_fields_and_preserves_null() {
// breathing/heartbeat rename; None → null; anomaly gets a threshold.
let cal = json!({
"presence": {"kind":"Presence","value":1.0,"confidence":0.9,"label":"occupied"},
"posture": {"kind":"Posture","value":2.0,"confidence":0.8,"label":"lying"},
"breathing": {"kind":"Breathing","value":12.0,"confidence":0.7,"label":null},
"heartbeat": null,
"restlessness": {"kind":"Restlessness","value":0.1,"confidence":0.6,"label":null},
"anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"label":null},
"vetoed": false, "stale": true
});
let ui = adapt_room_state("bedroom_1", &cal);
assert_eq!(ui["room_id"], "bedroom_1");
assert_eq!(ui["stale"], true);
assert_eq!(ui["presence"]["value"], "occupied");
assert_eq!(ui["breathing_bpm"]["value"], 12.0);
assert!(ui["heart_bpm"].is_null(), "None heartbeat must map to null (not trained)");
// §6 invariant 3: upstream RoomState carries no threshold here, so the
// adapter must emit null (withheld) — NOT a fabricated constant.
assert!(
ui["anomaly"]["threshold"].is_null(),
"absent upstream threshold must surface as null, never a hardcoded value"
);
}
#[test]
fn adapt_room_state_passes_through_real_anomaly_threshold() {
// When the upstream RoomState DOES carry a real threshold, it must be
// forwarded verbatim (no fabrication, no override).
let cal = json!({
"anomaly": {"kind":"Anomaly","value":0.2,"confidence":0.5,"threshold":0.73},
});
let ui = adapt_room_state("bedroom_1", &cal);
assert_eq!(ui["anomaly"]["threshold"], 0.73, "real threshold must pass through");
}
#[test]
fn validate_proxy_path_allows_legit_v1_paths() {
// The only shape the UI sends must pass unchanged.
for ok in [
"v1/room/state",
"v1/calibration/baselines",
"v1/enroll/status",
"v1/room/state?bank=living_room", // query is split off before this fn
] {
// strip any query the caller would have removed; we only validate path
let p = ok.split('?').next().unwrap();
assert!(validate_proxy_path(p).is_ok(), "{p} should be allowed");
}
}
#[test]
fn validate_proxy_path_rejects_traversal_variants() {
for bad in [
"v1/../../x", // dot-segment traversal
"../etc/passwd", // parent escape
"/etc/passwd", // absolute
"v1\\..\\..\\x", // backslash traversal
"..%2f..%2fx", // encoded slash
"%2e%2e/x", // encoded dot-dot
"v1/%2e%2e%2fadmin", // mixed encoded traversal
"%252e%252e/x", // double-encoded (residual %2e after one decode)
] {
assert!(validate_proxy_path(bad).is_err(), "{bad} must be rejected");
}
}
#[tokio::test]
async fn cal_proxy_rejects_traversal_with_400_before_upstream() {
// `gw()` has calibration_url=None: a path that reached URL-building
// would 503 ("not configured"). A 400 here proves the traversal is
// rejected BEFORE any upstream request is even attempted.
for (method, path) in [
("GET", "/api/cal/v1/../../x"),
("GET", "/api/cal/..%2f..%2fx"),
("GET", "/api/cal/%2e%2e/x"),
("POST", "/api/cal/v1/../../x"),
] {
let (status, body) = send(gateway_router(gw()), method, path).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{method} {path} must be 400");
assert!(body.contains("bad_request"), "{method} {path} typed 400 body");
assert!(
!body.contains("upstream_unavailable"),
"{method} {path} must NOT reach the upstream-config branch"
);
}
}
#[tokio::test]
async fn cal_proxy_allows_legit_path_through_to_upstream_config() {
// A legitimate v1 path passes validation and then hits the
// "not configured" 503 (proving it was NOT blocked as traversal).
let (status, body) = send(gateway_router(gw()), "GET", "/api/cal/v1/room/state").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert!(body.contains("upstream_unavailable"), "legit path should reach upstream branch");
}
#[test]
fn bank_names_accepts_strings_and_objects() {
assert_eq!(bank_names(&json!(["a", "b"])), vec!["a", "b"]);
assert_eq!(bank_names(&json!([{"name":"x"}, {"id":"y"}])), vec!["x", "y"]);
assert_eq!(bank_names(&json!({"baselines":["z"]})), vec!["z"]);
}
}
+226 -2
View File
@@ -27,7 +27,7 @@ use tracing::{info, warn};
use homecore::{Context, EntityId, HomeCore, ServiceCall, ServiceError, ServiceName};
use homecore::service::FnHandler;
use homecore_api::{router, LongLivedTokenStore, SharedState};
use homecore_api::{build_cors_layer, router, LongLivedTokenStore, SharedState};
use homecore_assist::pipeline::default_pipeline;
use homecore_assist::RegexIntentRecognizer;
use homecore_automation::AutomationEngine;
@@ -35,6 +35,18 @@ use homecore_hap::{bridge::HapBridge, mdns::HapServiceRecord};
use homecore_plugins::{InProcessRuntime, PluginRegistry};
use homecore_recorder::Recorder;
use axum::Router;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
mod gateway;
use gateway::{GatewayConfig, GatewayState};
/// Compile-time default location of the HOMECORE-UI assets (ADR-131).
/// Works in dev/CI; the appliance overrides with `--ui-dir` /
/// `HOMECORE_UI_DIR`.
const DEFAULT_UI_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/ui");
#[derive(Parser, Debug, Clone)]
#[command(name = "homecore-server", version)]
struct Cli {
@@ -42,6 +54,30 @@ struct Cli {
#[arg(long, env = "HOMECORE_BIND", default_value = "0.0.0.0:8123")]
bind: SocketAddr,
/// Directory of the HOMECORE-UI dashboard assets, served at
/// `/homecore` (ADR-131). Empty string disables the UI mount.
#[arg(long, env = "HOMECORE_UI_DIR", default_value = DEFAULT_UI_DIR)]
ui_dir: String,
/// Base URL of the calibration service (`wifi-densepose calibrate-serve`),
/// reverse-proxied by the BFF gateway at `/api/cal/*` (ADR-131 §11).
/// Unset → calibration/room endpoints return a typed 503.
#[arg(long, env = "HOMECORE_CALIBRATION_URL")]
calibration_url: Option<String>,
/// Bearer token for the calibration service (held server-side only,
/// never exposed to the browser — ADR-131 §11.10).
#[arg(long, env = "HOMECORE_CALIBRATION_TOKEN")]
calibration_token: Option<String>,
/// COG install directory the gateway's supervisor reads (ADR-131 §11.6).
#[arg(long, env = "HOMECORE_APPS_DIR", default_value = "/var/lib/cognitum/apps")]
apps_dir: String,
/// Per-upstream proxy timeout in milliseconds (ADR-131 §11.1).
#[arg(long, env = "HOMECORE_GATEWAY_TIMEOUT_MS", default_value_t = 2000)]
gateway_timeout_ms: u64,
/// SQLite recorder DB path. Use `:memory:` for an ephemeral run.
#[arg(long, env = "HOMECORE_DB", default_value = "sqlite::memory:")]
db: String,
@@ -174,15 +210,59 @@ async fn main() -> Result<()> {
env!("CARGO_PKG_VERSION"),
tokens,
);
let app = router(api_state);
// BFF gateway (ADR-131 §11): single-origin aggregation of the
// calibration API + SEED/appliance tiers. Shares the same token store
// for auth; upstream credentials stay server-side.
let gw = GatewayState::new(
api_state.clone(),
GatewayConfig {
calibration_url: cli.calibration_url.clone(),
calibration_token: cli.calibration_token.clone(),
apps_dir: std::path::PathBuf::from(&cli.apps_dir),
timeout: std::time::Duration::from_millis(cli.gateway_timeout_ms),
},
);
// Merge the HA-compat API + UI mount with the BFF gateway, THEN apply the
// audited CORS allowlist + request tracing to the WHOLE surface. The
// gateway routes (`/api/homecore/*`, `/api/cal/*`) are merged in outside
// `router()`'s own layers, so without this outer layer they would have NO
// CORS coverage and would not be traced (ADR-131 §11 review). Applying CORS
// again to the homecore-api routes is idempotent.
let app = build_app(api_state, &cli.ui_dir)
.merge(gateway::gateway_router(gw))
.layer(build_cors_layer())
.layer(TraceLayer::new_for_http());
let listener = tokio::net::TcpListener::bind(cli.bind).await?;
info!("HOMECORE-API listening on http://{} (HA-compat /api + /api/websocket)", cli.bind);
info!(
"HOMECORE BFF gateway active: /api/homecore/* + /api/cal/* (calibration_url={:?})",
cli.calibration_url
);
if !cli.ui_dir.trim().is_empty() {
info!("HOMECORE-UI (ADR-131) served at http://{}/homecore/ from {}", cli.bind, cli.ui_dir);
} else {
info!("HOMECORE-UI mount disabled (--ui-dir empty)");
}
// Run forever (until SIGINT). axum::serve handles graceful shutdown.
axum::serve(listener, app).await?;
Ok(())
}
/// Assemble the full HTTP surface: the HA-compat REST + WS router
/// (ADR-130) plus the HOMECORE-UI static mount at `/homecore` (ADR-131).
/// Split out from `main` so it is exercised by the integration tests.
fn build_app(api_state: SharedState, ui_dir: &str) -> Router {
let app = router(api_state);
if ui_dir.trim().is_empty() {
return app;
}
// ServeDir serves index.html for the directory root, so /homecore/
// returns the dashboard and /homecore/js/... /homecore/css/... map
// straight onto the asset tree the relative <link>/<script> tags use.
app.nest_service("/homecore", ServeDir::new(ui_dir))
}
fn init_tracing() {
tracing_subscriber::fmt()
.with_env_filter(
@@ -304,3 +384,147 @@ fn seed_default_entities(hc: &HomeCore) {
info!("State machine seeded with {} default entit{}", total,
if total == 1 { "y" } else { "ies" });
}
#[cfg(test)]
mod ui_tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use homecore::HomeCore;
use homecore_api::{LongLivedTokenStore, SharedState};
use tower::ServiceExt; // for `oneshot`
fn test_state() -> SharedState {
SharedState::with_tokens(
HomeCore::new(),
"Test".to_string(),
"test",
LongLivedTokenStore::allow_any_non_empty(),
)
}
async fn get(app: Router, path: &str) -> (StatusCode, String) {
let resp = app
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 4 * 1024 * 1024)
.await
.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
#[tokio::test]
async fn ui_index_is_served_at_homecore() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
let (status, body) = get(app, "/homecore/").await;
assert_eq!(status, StatusCode::OK, "GET /homecore/ should serve index.html");
assert!(body.contains("HOMECORE"), "index.html should mention HOMECORE");
assert!(body.contains("./js/app.js"), "index.html should bootstrap app.js");
}
#[tokio::test]
async fn ui_design_tokens_are_served() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
let (status, body) = get(app, "/homecore/css/tokens.css").await;
assert_eq!(status, StatusCode::OK);
// §3.1 invariant: the exact production palette must be present.
assert!(body.contains("#4ecdc4"), "--cyan token must be present");
assert!(body.contains("--purple"), "--purple token must be present");
}
#[tokio::test]
async fn ui_panels_are_served() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
for p in ["dashboard", "rooms", "calibration", "fleet", "seed-detail",
"entities", "cogs", "events", "audit", "settings"] {
let (status, _) = get(app.clone(), &format!("/homecore/js/panels/{p}.js")).await;
assert_eq!(status, StatusCode::OK, "panel {p}.js should be served");
}
}
#[tokio::test]
async fn api_still_works_alongside_ui_mount() {
let app = build_app(test_state(), DEFAULT_UI_DIR);
// `GET /api/` is auth-gated (HC-API-AUTH-01); send a bearer.
let resp = app
.oneshot(
Request::builder()
.uri("/api/")
.header("authorization", "Bearer dev")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
let body = String::from_utf8_lossy(&bytes);
assert_eq!(status, StatusCode::OK, "the HA-compat API must coexist with the UI mount");
assert!(body.contains("API running"));
}
#[tokio::test]
async fn ui_mount_can_be_disabled() {
let app = build_app(test_state(), "");
let (status, _) = get(app, "/homecore/").await;
assert_eq!(status, StatusCode::NOT_FOUND, "empty --ui-dir disables the mount");
}
/// Build the SAME merged + layered surface `main()` serves: API + UI mount
/// + BFF gateway, with the audited CORS allowlist + tracing applied to the
/// whole thing. Used to prove the gateway routes are CORS-covered.
fn full_app(state: SharedState) -> Router {
use crate::gateway::{GatewayConfig, GatewayState};
let gw = GatewayState::new(
state.clone(),
GatewayConfig {
calibration_url: None,
calibration_token: None,
apps_dir: std::path::PathBuf::from("/nonexistent-apps-dir"),
timeout: std::time::Duration::from_millis(200),
},
);
build_app(state, "")
.merge(crate::gateway::gateway_router(gw))
.layer(homecore_api::build_cors_layer())
.layer(TraceLayer::new_for_http())
}
#[tokio::test]
async fn gateway_routes_are_cors_covered_after_merge() {
// A CORS preflight from the Vite dev origin must succeed (echo the
// allowed origin) for a GATEWAY route — proving the outer CORS layer
// covers the merged routes, not just the homecore-api ones.
let app = full_app(test_state());
let resp = app
.oneshot(
Request::builder()
.method("OPTIONS")
.uri("/api/homecore/appliance")
.header("origin", "http://localhost:5173")
.header("access-control-request-method", "GET")
.header("access-control-request-headers", "authorization")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
// CORS preflight handled by the layer → 2xx with the origin echoed back.
assert!(
resp.status().is_success(),
"gateway preflight should succeed, got {}",
resp.status()
);
let allow_origin = resp
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
assert_eq!(
allow_origin, "http://localhost:5173",
"gateway route must echo the allowlisted dev origin"
);
}
}
+223
View File
@@ -0,0 +1,223 @@
/*
* HOMECORE-UI component styling — ADR-131 §3.3.
* Uses only the §3.1 tokens (tokens.css). Polished composition: real
* header, icon sidenav, elevated cards, refined metrics/pills/bars.
*/
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
background:
radial-gradient(1100px 600px at 78% -8%, rgba(78,205,196,0.06), transparent 60%),
radial-gradient(900px 500px at 12% 110%, rgba(167,139,250,0.05), transparent 55%),
var(--bg);
background-attachment: fixed;
color: var(--t1);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
.mono { font-family: var(--mono); font-size: 0.92em; }
.t2 { color: var(--t2); } .t3 { color: var(--t3); }
.cyan { color: var(--cyan); } .green { color: var(--green); } .amber { color: var(--amber); }
.red { color: var(--red); } .purple { color: var(--purple); }
.hidden { display: none !important; }
/* ── top header ─────────────────────────────────────────────────── */
.topnav {
display: flex; align-items: center; gap: 16px;
background: rgba(17,22,39,0.85);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
padding: 0 22px; height: 60px;
position: sticky; top: 0; z-index: 30;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand .logo {
display: inline-flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 8px;
background: linear-gradient(135deg, var(--cyan), var(--purple));
color: var(--bg); font-weight: 800; font-size: 17px;
box-shadow: 0 2px 10px rgba(78,205,196,0.25);
}
.brand .brand-name { font-weight: 700; font-size: 16px; letter-spacing: 0.3px; color: var(--t1); }
.brand .brand-sep { color: var(--t3); font-size: 16px; font-weight: 300; }
.brand .brand-tag {
font-weight: 700; font-size: 12px; letter-spacing: 1px;
color: var(--cyan); background: var(--cyan-d);
border-radius: 6px; padding: 3px 9px; text-transform: uppercase;
}
.nav-spacer { flex: 1; }
/* ── layout ─────────────────────────────────────────────────────── */
.shell { display: flex; min-height: calc(100vh - 60px); }
.sidenav {
width: 224px; flex-shrink: 0;
background: rgba(17,22,39,0.45);
border-right: 1px solid var(--border);
padding: 16px 12px; display: flex; flex-direction: column; gap: 3px;
}
.sidenav a {
display: flex; align-items: center; gap: 11px;
padding: 9px 12px; border-radius: 9px;
color: var(--t2); text-decoration: none; font-size: 13.5px; font-weight: 500;
transition: background .12s, color .12s;
}
.sidenav a .ico { width: 18px; text-align: center; font-size: 14px; color: var(--t3); }
.sidenav a:hover { color: var(--t1); background: var(--card); }
.sidenav a.active { color: var(--cyan); background: var(--cyan-d); }
.sidenav a.active .ico { color: var(--cyan); }
.content { flex: 1; padding: 26px 30px; max-width: 1320px; width: 100%; }
@media (max-width: 880px) {
.shell { flex-direction: column; }
.sidenav { width: 100%; flex-direction: row; overflow-x: auto; padding: 8px; gap: 6px; border-right: none; border-bottom: 1px solid var(--border); }
.sidenav a .lbl { white-space: nowrap; }
.content { padding: 18px; }
}
/* ── headings / section header ──────────────────────────────────── */
h1 { font-size: 23px; margin: 0 0 3px; font-weight: 700; letter-spacing: -0.2px; }
h2 { font-size: 15px; margin: 0 0 14px; font-weight: 650; color: var(--t1); }
h3 { font-size: 12px; margin: 0 0 8px; color: var(--t2); font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.section-header { position: relative; padding: 14px 0 4px; margin-bottom: 20px; border-bottom: 1px solid var(--border); }
.section-header::before { content: ''; position: absolute; top: 0; left: 0; width: 56px; height: 3px; border-radius: 3px; background: linear-gradient(90deg, var(--cyan), var(--purple)); }
.section-header .sub { color: var(--t2); font-size: 13px; margin-top: 2px; }
/* ── cards ──────────────────────────────────────────────────────── */
.card {
background: linear-gradient(180deg, rgba(30,37,64,0.35), var(--card));
border: 1px solid var(--border);
border-radius: var(--r);
padding: 20px 22px; margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0,0,0,0.25);
}
.card > h2:first-child { margin-bottom: 16px; }
.card.tint-amber { background: var(--amber-d); border-color: rgba(212,165,116,0.4); }
.card.tint-red { background: var(--red-d); border-color: rgba(224,96,96,0.4); }
.card.tint-green { background: var(--green-d); border-color: rgba(107,203,119,0.4); }
.card.clickable { cursor: pointer; transition: transform .12s, border-color .12s, box-shadow .12s; }
.card.clickable:hover { transform: translateY(-2px); border-color: rgba(78,205,196,0.4); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
/* ── pills / badges ─────────────────────────────────────────────── */
.pill {
display: inline-flex; align-items: center; gap: 5px;
border-radius: 6px; padding: 3px 9px;
font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
line-height: 1.5; white-space: nowrap;
}
.pill::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: 0.9; }
.pill.cyan { background: var(--cyan-d); color: var(--cyan); }
.pill.green { background: var(--green-d); color: var(--green); }
.pill.amber { background: var(--amber-d); color: var(--amber); }
.pill.red { background: var(--red-d); color: var(--red); }
.pill.purple { background: var(--purple-d); color: var(--purple); }
.pill.grey { background: rgba(80,88,114,0.18); color: var(--t2); }
.method { border-radius: 5px; padding: 2px 7px; font-size: 10.5px; font-weight: 700; }
.method.get { background: var(--green-d); color: var(--green); }
.method.post { background: var(--amber-d); color: var(--amber); }
.method.auth { background: var(--purple-d); color: var(--purple); }
/* ── buttons ────────────────────────────────────────────────────── */
.btn { font-family: var(--font); font-size: 12.5px; font-weight: 600; border-radius: 8px; padding: 8px 15px; cursor: pointer; border: none; transition: filter .12s, background .12s, transform .05s; }
.btn:active { transform: translateY(1px); }
.btn.primary { background: var(--cyan); color: var(--bg); }
.btn.primary:hover { filter: brightness(1.1); box-shadow: 0 4px 14px rgba(78,205,196,0.3); }
.btn.ghost { background: rgba(255,255,255,0.02); border: 1px solid var(--border); color: var(--t1); }
.btn.ghost:hover { background: var(--card-h); border-color: var(--t3); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── metric cards ───────────────────────────────────────────────── */
.metric-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 14px; }
.metric { position: relative; background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 16px 18px; overflow: hidden; }
.metric::after { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--cyan); opacity: 0.6; }
.metric .ico { font-size: 15px; color: var(--t3); }
.metric .val { font-size: 28px; font-weight: 700; color: var(--cyan); margin: 8px 0 2px; letter-spacing: -0.5px; line-height: 1; }
.metric .val.green { color: var(--green); }
.metric .lbl { color: var(--t2); font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.4px; }
/* ── grids ──────────────────────────────────────────────────────── */
.grid { display: grid; gap: 14px; }
.grid.cols-2 { grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); }
.grid.cols-3 { grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); }
/* ── bars ───────────────────────────────────────────────────────── */
.bar { background: rgba(0,0,0,0.3); border-radius: 5px; height: 8px; overflow: hidden; width: 100%; }
.bar > span { display: block; height: 100%; background: var(--cyan); border-radius: 5px; transition: width .3s; }
.bar > span.green { background: var(--green); } .bar > span.amber { background: var(--amber); } .bar > span.red { background: var(--red); }
.conf-bar { display: inline-block; width: 56px; height: 6px; background: rgba(0,0,0,0.3); border-radius: 3px; vertical-align: middle; overflow: hidden; }
.conf-bar > span { display: block; height: 100%; background: var(--cyan); }
.conf-bar > span.amber { background: var(--amber); }
/* ── provenance badge ───────────────────────────────────────────── */
.prov { display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono); font-size: 10.5px; color: var(--t2); background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; }
.prov .arr { color: var(--t3); } .prov .hailo { color: var(--purple); font-weight: 600; }
/* ── rows / kv ──────────────────────────────────────────────────── */
.row { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-bottom: 1px solid var(--border); gap: 12px; }
.row:last-child { border-bottom: none; }
.row .k { color: var(--t2); font-size: 12.5px; }
.row .v { color: var(--t1); }
.kv { display: grid; grid-template-columns: auto 1fr; gap: 9px 16px; align-items: center; }
.kv .k { color: var(--t2); font-size: 12.5px; }
.kv .v { color: var(--t1); }
pre.json, pre.log { font-family: var(--mono); font-size: 12px; background: rgba(0,0,0,0.35); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; overflow: auto; max-height: 320px; color: var(--t1); white-space: pre-wrap; word-break: break-word; }
svg.spark { display: block; }
/* ── banners ────────────────────────────────────────────────────── */
.banner { border-radius: 9px; padding: 11px 15px; margin-bottom: 14px; font-size: 13px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.banner::before { font-weight: 700; }
.banner.amber { background: var(--amber-d); color: var(--amber); border: 1px solid rgba(212,165,116,0.4); }
.banner.amber::before { content: '▲'; }
.banner.red { background: var(--red-d); color: var(--red); border: 1px solid rgba(224,96,96,0.4); }
.banner.red::before { content: '●'; }
.banner.green { background: var(--green-d); color: var(--green); border: 1px solid rgba(107,203,119,0.4); }
.banner.green::before { content: '✓'; }
/* ── lag indicator ──────────────────────────────────────────────── */
.lag { font-size: 12px; display: inline-flex; align-items: center; gap: 7px; color: var(--t2); }
.lag .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); display: inline-block; box-shadow: 0 0 0 3px var(--green-d); }
.lag .dot.warn { background: var(--amber); box-shadow: 0 0 0 3px var(--amber-d); }
.lag .dot.err { background: var(--red); box-shadow: 0 0 0 3px var(--red-d); }
/* ── wizard stepper ─────────────────────────────────────────────── */
.stepper { display: flex; gap: 10px; margin-bottom: 22px; flex-wrap: wrap; }
.step-pill { display: flex; align-items: center; gap: 9px; padding: 8px 15px; border-radius: 24px; border: 1px solid var(--border); color: var(--t3); font-size: 12.5px; font-weight: 600; }
.step-pill .n { width: 22px; height: 22px; border-radius: 50%; background: rgba(0,0,0,0.3); display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; }
.step-pill.active { color: var(--cyan); border-color: var(--cyan); background: var(--cyan-d); }
.step-pill.active .n { background: var(--cyan); color: var(--bg); }
.step-pill.done { color: var(--green); border-color: rgba(107,203,119,0.4); }
.step-pill.done .n { background: var(--green); color: var(--bg); }
/* ── slide-over ─────────────────────────────────────────────────── */
.slideover-back { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 40; backdrop-filter: blur(2px); }
.slideover { position: fixed; top: 0; right: 0; bottom: 0; width: 480px; max-width: 92vw; background: var(--card); border-left: 1px solid var(--border); z-index: 41; padding: 26px; overflow-y: auto; box-shadow: -12px 0 40px rgba(0,0,0,0.45); }
.slideover .close { float: right; cursor: pointer; color: var(--t2); font-size: 16px; }
.slideover .close:hover { color: var(--t1); }
/* ── inputs ─────────────────────────────────────────────────────── */
.search { width: 100%; background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 9px; padding: 10px 13px; color: var(--t1); font-family: var(--font); font-size: 13px; }
.search::placeholder { color: var(--t3); }
.search:focus { outline: none; border-color: var(--cyan); box-shadow: 0 0 0 3px var(--cyan-d); }
input.inline { background: rgba(0,0,0,0.25); border: 1px solid var(--border); border-radius: 6px; padding: 5px 9px; color: var(--t1); font-family: var(--mono); font-size: 12px; width: 92px; }
input.inline:focus { outline: none; border-color: var(--cyan); }
select.inline { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px; color: var(--t1); font-family: var(--font); font-size: 13px; }
textarea.inline { background: rgba(0,0,0,0.3); border: 1px solid var(--border); border-radius: 8px; padding: 10px; color: var(--t1); font-family: var(--mono); font-size: 12px; width: 100%; }
/* ── collapsible ────────────────────────────────────────────────── */
.collapsible > .head { cursor: pointer; display: flex; align-items: center; gap: 9px; padding: 4px 0; user-select: none; }
.collapsible > .head::before { content: '▸'; color: var(--t3); transition: transform .15s; font-size: 11px; }
.collapsible.open > .head::before { transform: rotate(90deg); }
.muted-empty { color: var(--t3); font-style: italic; padding: 10px 0; }
.shield.ok { color: var(--green); } .shield.bad { color: var(--red); }
.flex { display: flex; gap: 10px; align-items: center; }
.flex.wrap { flex-wrap: wrap; } .spread { justify-content: space-between; } .gap-sm { gap: 6px; }
.mt { margin-top: 14px; } .mb { margin-bottom: 14px; }
small.ts { color: var(--t3); font-size: 11.5px; }
strong.mono { font-size: 13px; color: var(--t1); }
@@ -0,0 +1,34 @@
/*
* HOMECORE-UI design tokens — ADR-131 §3.1 / §3.2.
*
* These values are extracted verbatim from the production Cognitum
* platform (seed.cognitum.one/store + /status). DO NOT introduce new
* colours, typefaces, or border radii — ADR-131 §3.3 invariant. A user
* navigating from the Cog Store into HOMECORE must not notice a seam.
*/
:root {
/* §3.1 colour palette */
--bg: #0a0e1a; /* page background (very dark navy) */
--bg2: #111627; /* secondary background / nav strip */
--card: #171d30; /* card / panel surface */
--card-h: #1e2540; /* card hover state */
--border: #252d45; /* all border strokes (~0.67px, subtle) */
--t1: #e0e4f0; /* primary text (near-white) */
--t2: #8890a8; /* secondary / muted text */
--t3: #505872; /* tertiary / disabled text */
--cyan: #4ecdc4; /* primary action colour */
--cyan-d: rgba(78,205,196,0.15);
--green: #6bcb77; /* success / online / healthy */
--green-d: rgba(107,203,119,0.15);
--amber: #d4a574; /* warning / stale / degraded */
--amber-d: rgba(212,165,116,0.15);
--red: #e06060; /* error / offline / veto */
--red-d: rgba(224,96,96,0.15);
--purple: #a78bfa; /* informational / epoch / chain */
--purple-d: rgba(167,139,250,0.15);
--r: 10px; /* standard border radius */
/* §3.2 typography */
--font: 'Segoe UI', system-ui, -apple-system, sans-serif;
--mono: 'Cascadia Code', 'Fira Code', Consolas, monospace;
}
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>HOMECORE — Cognitum Appliance</title>
<meta name="description" content="HOMECORE operational dashboard for the two-tier Cognitum stack (ADR-131)." />
<link rel="stylesheet" href="./css/tokens.css" />
<link rel="stylesheet" href="./css/app.css" />
</head>
<body>
<div id="app">
<noscript>HOMECORE-UI requires JavaScript.</noscript>
</div>
<script type="module" src="./js/app.js"></script>
</body>
</html>
+197
View File
@@ -0,0 +1,197 @@
// HOMECORE-UI API client — ADR-131 §2 / §11.
//
// Production path: every method issues a SAME-ORIGIN request to the
// homecore-server BFF gateway (§2.1). There is NO mock fallback in
// production — a failed upstream rejects, and the panel renders a typed
// error/empty state (§2.2, §11.11). The in-browser mock layer is a
// DEV-ONLY fixture, reachable only when demo mode is on:
// ?demo=1 in the URL, globalThis.HOMECORE_UI_DEMO, or
// localStorage 'homecore_demo' = '1'.
//
// Gateway route map: ADR-131 §11.2.
// DEV-ONLY fixtures. Loaded via DYNAMIC import so a production bundle that
// never enters demo mode never pulls mock.js into the graph (§2.2). Cached
// after first use so repeated demo calls don't re-import.
let _mock = null;
async function loadMock() {
if (!_mock) _mock = await import('./mock.js');
return _mock;
}
const demoFlags = {};
/** Demo mode = explicit dev opt-in only; never the production default. */
export function demoMode() {
try { if (typeof location !== 'undefined' && /[?&]demo=1(\b|&|$)/.test(location.search || '')) return true; } catch {}
try { if (typeof globalThis !== 'undefined' && globalThis.HOMECORE_UI_DEMO) return true; } catch {}
try { if (typeof localStorage !== 'undefined' && localStorage.getItem('homecore_demo') === '1') return true; } catch {}
return false;
}
export const api = {
base: '',
token: () => { try { return localStorage.getItem('homecore_token') || 'dev-token'; } catch { return 'dev-token'; } },
isDemo: (key) => !!demoFlags[key],
anyDemo: () => demoMode() && Object.keys(demoFlags).length > 0,
demoMode,
async _get(path) {
const r = await fetch(this.base + path, { headers: { Authorization: 'Bearer ' + this.token() } });
if (!r.ok) throw httpError(path, r.status);
return r.json();
},
async _post(path, body) {
const r = await fetch(this.base + path, {
method: 'POST',
headers: { Authorization: 'Bearer ' + this.token(), 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
});
if (!r.ok) throw httpError(path, r.status);
return r.json();
},
async _delete(path) {
const r = await fetch(this.base + path, { method: 'DELETE', headers: { Authorization: 'Bearer ' + this.token() } });
if (!r.ok) throw httpError(path, r.status);
return r.status === 204 ? {} : r.json();
},
// demo-gated data accessor: real gateway GET in prod, mock fixture in demo.
// The mock module is dynamically imported ONLY on the demo branch, so prod
// never loads it. `mockFn` receives the loaded module.
async _data(key, path, mockFn) {
if (demoMode()) { demoFlags[key] = true; return mockFn(await loadMock()); }
delete demoFlags[key];
return this._get(path);
},
// ── homecore-api (real, already served) ───────────────────────────
async config() { return this._get('/api/config'); },
async states() {
if (demoMode()) { demoFlags.states = true; return demoEntities(); }
delete demoFlags.states;
return this._get('/api/states');
},
async services() { return this._data('services', '/api/services', () => []); },
async callService(domain, service, data) { return this._post(`/api/services/${domain}/${service}`, data); },
async setState(entityId, state, attributes) { return this._post(`/api/states/${entityId}`, { state, attributes: attributes || {} }); },
// ── gateway /api/homecore/* + /api/events (§11.2) ─────────────────
async appliance() { return this._data('appliance', '/api/homecore/appliance', (m) => m.applianceHealth()); },
async seeds() { return this._data('fleet', '/api/homecore/seeds', (m) => m.seeds()); },
async seed(id) { return this._data('fleet', '/api/homecore/seeds/' + encodeURIComponent(id), (m) => m.seed(id)); },
async esp32Warnings() {
if (demoMode()) { demoFlags.fleet = true; return (await loadMock()).esp32Warnings(); }
const seeds = await this._get('/api/homecore/seeds');
return seeds.flatMap((s) => (s.warnings || []).map((issue) => ({ node_id: s.device_id, seed: s.device_id, issue })));
},
async cogs() { return this._data('cogs', '/api/homecore/cogs', (m) => m.cogs()); },
async cogUpdates() { return this._data('cogs', '/api/homecore/cogs/updates', (m) => m.cogUpdates()); },
async hailo() { return this._data('cogs', '/api/homecore/hailo', (m) => ({ worker: 'connected', cogs: m.cogs().filter((c) => c.arch === 'hailo10') })); },
async roomStates() { return this._data('rooms', '/api/homecore/rooms', (m) => m.roomStates()); },
async federation() { return this._data('fleet', '/api/homecore/federation', (m) => m.federation()); },
async witnessLog(page = 0, size = 12) { return this._data('audit', `/api/homecore/witness?page=${page}&size=${size}`, (m) => m.witnessLog(page, size)); },
async privacyModes() { return this._data('audit', '/api/homecore/privacy', (m) => m.privacyModes()); },
async setPrivacy(seed, modeValue) { if (demoMode()) return { seed, mode: modeValue }; return this._post('/api/homecore/privacy', { seed, mode: modeValue }); },
async eventHistory(n = 40) { return this._data('events', `/api/events?limit=${n}`, (m) => m.recentEvents(n)); },
recentEvents(n) { return this.eventHistory(n); }, // back-compat alias (async)
async settings() { return this._data('settings', '/api/homecore/settings', (m) => m.settings()); },
async automations() { return this._data('automations', '/api/homecore/automations', () => []); },
async saveAutomation(a) { if (demoMode()) return a; return this._post('/api/homecore/automations', a); },
async tokens() { return this._data('settings', '/api/homecore/tokens', (m) => m.settings().tokens); },
// calibration (ADR-151) — real proxy in prod, simulated in demo.
calibration: makeCalibration(),
};
function httpError(path, status) {
const e = new Error(`${path} → HTTP ${status}`);
e.status = status;
e.upstreamUnavailable = status === 503 || status === 504;
return e;
}
// Demo-only entity fixture (prod path uses real GET /api/states).
function demoEntities() {
return [
{ entity_id: 'sensor.living_room_presence', state: 'true', attributes: { friendly_name: 'Living Room Presence', source: 'esp32-lr-01', seed: 'seed-livingroom-a1' }, last_changed: new Date().toISOString(), last_updated: new Date().toISOString(), context: { id: 'ctx-1', user_id: null, parent_id: null } },
{ entity_id: 'sensor.bedroom_1_breathing_rate', state: '14.5', attributes: { friendly_name: 'Bedroom 1 Breathing Rate', unit_of_measurement: 'BPM', source: 'esp32-br1-01', seed: 'seed-bedroom-1' }, last_changed: new Date().toISOString(), last_updated: new Date().toISOString(), context: { id: 'ctx-2', user_id: null, parent_id: 'ctx-1' } },
];
}
/**
* Resolve an entity's tier provenance (§4.4 / §11.9). Prefers the
* explicit `attributes.seed`/`attributes.cog` lineage that integrations
* are expected to stamp; falls back to parsing the ESP32 node id. In demo
* mode it may consult the mock node registry. Missing lineage → 'unknown'
* (never fabricated).
*/
export function entityProvenance(entity) {
const attrs = (entity && entity.attributes) || {};
const src = String(attrs.source || '');
const nodeMatch = src.match(/esp32[-\w]*/i);
const node = attrs.node || (nodeMatch ? nodeMatch[0] : null);
let seed = attrs.seed || null;
// Demo-only enrichment: consult the mock node registry IF it has already
// been dynamically loaded by a prior demo data call (this fn is sync, so it
// cannot await the import). Prod never has `_mock` set → seed stays null
// (never fabricated).
if (!seed && demoMode() && node && _mock) {
const cfg = _mock.settings().esp32.find((n) => n.node_id === node);
seed = cfg ? cfg.seed : null;
}
const hailo = /hailo|pose/i.test(src) || /hailo/i.test(String(attrs.cog || ''));
const cog = attrs.cog || (/matter|bfld|mmwave|mr60/i.test(src) ? 'cog-ha-matter' : (hailo ? 'cog-pose-estimation' : null));
return { esp32: node, seed: seed || (node ? 'unknown' : null), cog: cog || 'unknown', hailo };
}
// Calibration: per-call branch on demo mode. Prod proxies the real
// calibrate-serve API via the gateway (/api/cal/v1/*). All methods are
// async (the §4.7 wizard awaits them).
function makeCalibration() {
const ANCHORS = ['empty', 'stand_still', 'sit', 'lie_down', 'breathe_slow', 'breathe_normal', 'small_move', 'sleep_posture'];
// demo session state
let frames = 0; const target = 1200; const accepted = new Set();
const get = (p) => api._get('/api/cal/v1' + p);
const post = (p, b) => api._post('/api/cal/v1' + p, b);
return {
ANCHORS,
get demo() { return demoMode(); },
async start() {
if (demoMode()) { frames = 0; return { baseline_id: 'bl-demo-' + ANCHORS.length }; }
return post('/calibration/start', {});
},
async stop() { if (demoMode()) return { stopped: true }; return post('/calibration/stop', {}); },
async status() {
if (demoMode()) { frames = Math.min(target, frames + 180); return { frames, target, eta_s: Math.max(0, Math.round((target - frames) / 180)), z_median: 0.41, motion_flagged: frames < 360 }; }
return get('/calibration/status');
},
async anchor(label) {
if (demoMode()) {
const ok = label !== 'sleep_posture' || accepted.size >= 6;
if (ok) accepted.add(label);
return { label, accepted: ok, reason: ok ? null : 'insufficient stillness — retry', features: { mean: 0.12, variance: 0.04, breathing_score: 0.7, heart_score: 0.55 } };
}
return post('/enroll/anchor', { label });
},
async enrollStatus() {
if (demoMode()) return { accepted: [...accepted], total: ANCHORS.length };
return get('/enroll/status');
},
async train(room_id) {
if (demoMode()) {
const trained = accepted.size >= 6;
return {
presence: trained ? { threshold: 0.31, occupied_var: 0.08 } : null,
posture: trained ? { prototypes: 4 } : null,
breathing: accepted.has('breathe_normal') ? { min_score: 0.6 } : null,
heartbeat: accepted.has('breathe_normal') ? { min_score: 0.5 } : null,
restlessness: trained ? { calm: 0.05, active: 0.6 } : null,
anomaly: trained ? { prototypes: 8, scale: 1.4 } : null,
};
}
return post('/room/train', { room_id });
},
reset() { accepted.clear(); frames = 0; },
};
}
+141
View File
@@ -0,0 +1,141 @@
// HOMECORE-UI bootstrap + shell + router — ADR-131 §5.
//
// Builds the Cognitum-shell top nav (Framework | Guide | Cog Store |
// HOMECORE | Status) with HOMECORE active, a left sub-nav for the nine
// HOMECORE sections, and a hash router. One shared WebSocket feeds a bus
// that every panel subscribes to (no per-panel sockets, no polling).
import { h, clear, lagIndicator } from './ui.js';
import { api } from './api.js';
import { connect } from './ws.js';
import dashboard from './panels/dashboard.js';
import fleet from './panels/fleet.js';
import seedDetail from './panels/seed-detail.js';
import entities from './panels/entities.js';
import rooms from './panels/rooms.js';
import cogs from './panels/cogs.js';
import calibration from './panels/calibration.js';
import events from './panels/events.js';
import audit from './panels/audit.js';
import settings from './panels/settings.js';
// Section registry. order drives the left sub-nav (§5).
const SECTIONS = [
{ id: 'dashboard', label: 'Dashboard', icon: '◳', mod: dashboard },
{ id: 'fleet', label: 'SEED Fleet', icon: '⬡', mod: fleet },
{ id: 'entities', label: 'Entities', icon: '◈', mod: entities },
{ id: 'rooms', label: 'Rooms', icon: '⌂', mod: rooms },
{ id: 'cogs', label: 'COGs', icon: '⚙', mod: cogs },
{ id: 'calibration', label: 'Calibration', icon: '⊹', mod: calibration },
{ id: 'events', label: 'Events', icon: '⚡', mod: events },
{ id: 'audit', label: 'Audit', icon: '⛨', mod: audit },
{ id: 'settings', label: 'Settings', icon: '⚒', mod: settings },
];
// Detail routes not shown in the sub-nav.
const ROUTES = { 'seed': seedDetail };
// Shared event bus fed by the single WS connection.
const bus = new EventTarget();
let wsState = { state: 'connecting', lagged: false };
const ctx = {
api,
bus,
wsStatus: () => wsState,
navigate: (hash) => { location.hash = hash; },
onEvent(handler) {
const fn = (e) => handler(e.detail);
bus.addEventListener('hc-event', fn);
return () => bus.removeEventListener('hc-event', fn);
},
onWs(handler) {
const fn = (e) => handler(e.detail);
bus.addEventListener('hc-ws', fn);
handler(wsState);
return () => bus.removeEventListener('hc-ws', fn);
},
};
let cleanup = null;
function buildShell() {
const topnav = h('.topnav',
h('.brand',
h('span.logo', 'C'),
h('span.brand-name', 'Cognitum'),
h('span.brand-sep', '/'),
h('span.brand-tag', 'HOMECORE')),
h('span.nav-spacer'),
lagIndicatorHost());
const sidenav = h('.sidenav', ...SECTIONS.map((s) => sideLink(s)));
const content = h('.content#hc-content');
const shell = h('.shell', sidenav, content);
const root = document.getElementById('app');
clear(root);
root.appendChild(topnav);
root.appendChild(shell);
return content;
}
function sideLink(section) {
return h('a', { href: '#/' + section.id, 'data-section': section.id },
h('span.ico', section.icon || '•'), h('span.lbl', section.label));
}
function lagIndicatorHost() {
const host = h('span');
const paint = () => { clear(host); host.appendChild(lagIndicator(wsState.state, wsState.lagged)); };
bus.addEventListener('hc-ws', paint);
paint();
return host;
}
function highlightNav(id) {
document.querySelectorAll('.sidenav a').forEach((a) => {
a.classList.toggle('active', a.getAttribute('data-section') === id);
});
}
async function route() {
const hash = location.hash.replace(/^#\/?/, '') || 'dashboard';
const [head, ...rest] = hash.split('/');
const content = document.getElementById('hc-content') || buildShell();
if (typeof cleanup === 'function') { try { cleanup(); } catch {} cleanup = null; }
clear(content);
let mod, params = {};
const section = SECTIONS.find((s) => s.id === head);
if (section) { mod = section.mod; highlightNav(head); }
else if (ROUTES[head]) { mod = ROUTES[head]; params.id = rest[0]; highlightNav('fleet'); }
else { mod = SECTIONS[0].mod; highlightNav('dashboard'); }
try {
const result = await mod.render(content, { ...ctx, params });
if (typeof result === 'function') cleanup = result;
} catch (e) {
content.appendChild(h('.banner.red', 'Panel error: ' + (e && e.message ? e.message : e)));
console.error(e);
}
}
function start() {
buildShell();
// Attach routing + render the first panel BEFORE opening the socket.
// connect() invokes its status callback synchronously, so the WS wiring
// must not be on the critical render path (a thrown callback here would
// otherwise blank the whole dashboard).
window.addEventListener('hashchange', route);
route();
const ctrl = connect(
(evt) => bus.dispatchEvent(new CustomEvent('hc-event', { detail: evt })),
(st) => { wsState = { state: st.state, lagged: !!st.lagged }; bus.dispatchEvent(new CustomEvent('hc-ws', { detail: wsState })); },
);
ctx.ws = ctrl;
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
export { SECTIONS, ctx };
+296
View File
@@ -0,0 +1,296 @@
// HOMECORE-UI contract-conformant mock layer — ADR-131 §7.1.
//
// "Where a service is not yet stable, the panel is still built against
// its defined contract (with a contract-conformant mock standing in for
// the live endpoint only until that endpoint lands)."
//
// Shapes mirror the schemas described in ADR-131 §4 + the calibration
// RoomState contract (docs/integration/calibration-appliance-integration.md)
// + the SEED HTTPS API. Live endpoints replace these the moment they
// exist; nothing here is presented to the operator as real (the UI shows
// a DEMO badge whenever the mock layer is serving a panel — see api.js).
const now = () => new Date().toISOString();
const ago = (s) => new Date(Date.now() - s * 1000).toISOString();
function jitter(base, amp) { return +(base + (Math.sin(Date.now() / 3000 + base) * amp)).toFixed(2); }
function spark(base, amp, n = 24) {
return Array.from({ length: n }, (_, i) => +(base + Math.sin(i / 2) * amp + (i % 3) * amp * 0.2).toFixed(2));
}
// Factory for a bedroom SEED node — keeps the three bedrooms consistent
// while varying the values that matter for the analysis views.
function bedroomSeed(o) {
return {
device_id: o.device_id, firmware: '0.7.3', online: true, conn: o.conn || 'wifi', epoch: o.epoch,
vector_count: o.vector_count, vector_dim: 8, knn_latency_ms: o.knn_latency_ms,
last_ingest: ago(2), witness_valid: true, witness_len: o.witness_len,
witness_last_verify: ago(1800), zone: o.zone,
storage_used: o.vector_count, storage_budget: 100000,
sensors: {
bme280: { temp_c: o.temp_c, humidity_pct: o.humidity_pct, pressure_hpa: 1013.0 },
pir: { motion: o.motion, last_trigger: ago(o.motion ? 5 : 640) },
reed: { open: false, last_change: ago(30000) },
ads1115: [{ label: 'ch0', v: 0.11 }, { label: 'ch1', v: 0.0 }, { label: 'ch2', v: 0.0 }, { label: 'ch3', v: 0.0 }],
vibration: { active: false, last_trigger: null },
},
reflex: [
{ name: 'fragility_alarm', threshold: 0.3, target: 'relay actuator', last_fired: o.fired ? ago(420) : null, fired_recently: !!o.fired },
{ name: 'drift_cutoff', threshold: 1.0, target: 'ingest gate', last_fired: null, fired_recently: false },
{ name: 'hd_anomaly_indicator', threshold: 200, target: 'PWM brightness', last_fired: null, fired_recently: false },
],
cognition: { fragility: o.fragility, coherence_phases: o.phases, knn_rebuild_s: 10 },
ingest: { batch: 64, flush_ms: 1000, bridge: 'direct', esp32: [{ node_id: o.node, packet: '0xC5110003', rate_hz: 1.0 }] },
esp32_nodes: 1, frame_rate_hz: 100,
};
}
// ── v0 Appliance health (§4.1) ──────────────────────────────────────
export function applianceHealth() {
return {
cpu_pct: jitter(34, 6),
ram_pct: jitter(58, 4),
hailo_load_pct: jitter(41, 12),
hailo_temp_c: jitter(52, 3),
uptime_s: 824510,
services: [
{ name: 'ruview-mcp-brain', port: 9876, status: 'running' },
{ name: 'cognitum-rvf-agent', port: 9004, status: 'running' },
{ name: 'ruvector-hailo-worker', port: 50051, status: 'running' },
],
event_rate: spark(120, 40),
channel_capacity: 4096,
channel_lag: 0,
};
}
// ── SEED fleet (§4.1 / §4.2) ────────────────────────────────────────
const SEEDS = [
{
device_id: 'seed-livingroom-a1',
firmware: '0.7.3', online: true, conn: 'wifi', epoch: 184,
vector_count: 71280, vector_dim: 8, knn_latency_ms: 2.1,
last_ingest: ago(3), witness_valid: true, witness_len: 184210,
witness_last_verify: ago(900), zone: 'Living Room',
storage_used: 71280, storage_budget: 100000,
sensors: {
bme280: { temp_c: 21.6, humidity_pct: 44, pressure_hpa: 1013.2 },
pir: { motion: true, last_trigger: ago(8) },
reed: { open: false, last_change: ago(7200) },
ads1115: [{ label: 'soil', v: 0.42 }, { label: 'light', v: 0.71 }, { label: 'aux2', v: 0.03 }, { label: 'aux3', v: 0.0 }],
vibration: { active: false, last_trigger: ago(40000) },
},
reflex: [
{ name: 'fragility_alarm', threshold: 0.3, target: 'relay actuator', last_fired: ago(300), fired_recently: true },
{ name: 'drift_cutoff', threshold: 1.0, target: 'ingest gate', last_fired: null, fired_recently: false },
{ name: 'hd_anomaly_indicator', threshold: 200, target: 'PWM brightness', last_fired: ago(12000), fired_recently: false },
],
cognition: { fragility: 0.42, coherence_phases: [{ t: ago(3600), label: 'empty' }, { t: ago(1800), label: 'occupied' }, { t: ago(300), label: 'regime-change' }], knn_rebuild_s: 10 },
ingest: { batch: 64, flush_ms: 1000, bridge: 'host-laptop hop', esp32: [{ node_id: 'esp32-lr-01', packet: '0xC5110003', rate_hz: 1.0 }, { node_id: 'esp32-lr-02', packet: '0xC5110002', rate_hz: 0.9 }] },
esp32_nodes: 2, frame_rate_hz: 98,
},
bedroomSeed({
device_id: 'seed-bedroom-1', zone: 'Bedroom 1 (primary)', epoch: 183,
vector_count: 38110, knn_latency_ms: 1.7, witness_len: 91022,
temp_c: 20.1, humidity_pct: 47, motion: false, fragility: 0.12,
phases: [{ t: ago(7200), label: 'empty' }, { t: ago(3600), label: 'sleep' }],
node: 'esp32-br1-01', conn: 'usb',
}),
bedroomSeed({
device_id: 'seed-bedroom-2', zone: 'Bedroom 2 (guest)', epoch: 181,
vector_count: 29440, knn_latency_ms: 1.9, witness_len: 70210,
temp_c: 19.4, humidity_pct: 50, motion: true, fragility: 0.21,
phases: [{ t: ago(5400), label: 'empty' }, { t: ago(900), label: 'occupied' }],
node: 'esp32-br2-01', conn: 'wifi',
}),
bedroomSeed({
device_id: 'seed-bedroom-3', zone: 'Bedroom 3 (kids)', epoch: 179,
vector_count: 24105, knn_latency_ms: 2.0, witness_len: 60880,
temp_c: 21.0, humidity_pct: 45, motion: false, fragility: 0.34,
phases: [{ t: ago(9000), label: 'empty' }, { t: ago(4200), label: 'sleep' }, { t: ago(600), label: 'restless' }],
node: 'esp32-br3-01', conn: 'wifi', fired: true,
}),
{
device_id: 'seed-hallway-c3',
firmware: '0.6.9', online: false, conn: 'wifi', epoch: 170,
vector_count: 12044, vector_dim: 8, knn_latency_ms: null,
last_ingest: ago(5400), witness_valid: true, witness_len: 40110,
witness_last_verify: ago(86400), zone: 'Hallway',
storage_used: 12044, storage_budget: 100000,
sensors: null,
reflex: [],
cognition: { fragility: null, coherence_phases: [], knn_rebuild_s: 10 },
ingest: { batch: 64, flush_ms: 1000, bridge: 'direct', esp32: [] },
esp32_nodes: 0, frame_rate_hz: 0,
warnings: ['stale firmware version (0.6.9 < 0.7.3)', 'offline > 1h'],
},
];
export function seeds() { return SEEDS.map((s) => ({ ...s })); }
export function seed(id) { return SEEDS.find((s) => s.device_id === id) || null; }
// ── ESP32 node warnings (§4.1) ──────────────────────────────────────
export function esp32Warnings() {
return [
{ node_id: 'esp32-lr-02', seed: 'seed-livingroom-a1', issue: 'presence_score normalisation anomaly' },
{ node_id: 'esp32-hw-01', seed: 'seed-hallway-c3', issue: 'stale firmware version' },
];
}
// ── COG runtime (§4.6) ──────────────────────────────────────────────
const COGS = [
{ id: 'cog-ha-matter', version: '1.4.2', arch: 'arm', status: 'running', pid: 4120, sha256_verified: true, signature_verified: true },
{ id: 'cog-pose-estimation', version: '2.1.0', arch: 'hailo10', status: 'running', pid: 4188, sha256_verified: true, signature_verified: true, hef: ['rf_foundation_encoder.hef', 'pose_head.hef'], throughput_fps: 41 },
{ id: 'cog-person-count', version: '0.9.4', arch: 'arm', status: 'running', pid: 4205, sha256_verified: true, signature_verified: true },
{ id: 'cog-calibration', version: '1.0.1', arch: 'arm', status: 'running', pid: 4250, sha256_verified: true, signature_verified: true },
{ id: 'cog-anomaly-watch', version: '0.3.0', arch: 'arm', status: 'failed', pid: null, sha256_verified: true, signature_verified: true, error: 'panic: bank not found' },
{ id: 'cog-legacy-bridge', version: '0.1.2', arch: 'arm', status: 'stopped', pid: null, sha256_verified: false, signature_verified: false },
];
export function cogs() { return COGS.map((c) => ({ ...c })); }
export function cogUpdates() { return [{ id: 'cog-pose-estimation', from: '2.1.0', to: '2.2.0', new_entities: ['sensor.lr_pose_confidence'], config_changes: ['add: max_persons'] }]; }
export function appRegistry() {
return [
{ id: 'cog-fall-detect', title: 'Fall Detection', desc: 'Multistatic fall detection specialist', category: 'safety', arch: 'arm', featured: true, new_entities: ['binary_sensor.{room}_fall'] },
{ id: 'cog-sleep-stage', title: 'Sleep Staging', desc: 'REM/deep/light from breathing + restlessness', category: 'health', arch: 'hailo10', new_entities: ['sensor.{room}_sleep_stage'] },
{ id: 'cog-gesture', title: 'Gesture Control', desc: 'DTW gesture classifier → service calls', category: 'control', arch: 'arm', new_entities: ['event.{room}_gesture'] },
];
}
// ── RoomState / sensing (§4.5) — calibration contract ───────────────
export function roomStates() {
return [
{
room_id: 'living_room', stale: false, vetoed: false, seeds: ['seed-livingroom-a1'],
presence: { value: 'occupied', confidence: 0.93 },
posture: { value: 'sitting', confidence: 0.81 },
breathing_bpm: { value: jitter(15, 1.5), confidence: 0.77 },
heart_bpm: { value: jitter(72, 3), confidence: 0.64 },
restlessness: { value: 0.22, confidence: 0.7 },
anomaly: { value: 0.18, confidence: 0.8, threshold: 0.8 },
},
{
// Bedroom 1 — primary; healthy sleeping vitals.
room_id: 'bedroom_1', stale: false, vetoed: false, seeds: ['seed-bedroom-1'],
presence: { value: 'occupied', confidence: 0.91 },
posture: { value: 'lying', confidence: 0.9 },
breathing_bpm: { value: jitter(12, 1), confidence: 0.85 },
heart_bpm: { value: jitter(58, 2), confidence: 0.72 },
restlessness: { value: 0.08, confidence: 0.8 },
anomaly: { value: 0.12, confidence: 0.84, threshold: 0.8 },
},
{
// Bedroom 2 — guest; STALE bank (recalibrate demo).
room_id: 'bedroom_2', stale: true, vetoed: false, seeds: ['seed-bedroom-2'],
presence: { value: 'occupied', confidence: 0.86 },
posture: { value: 'sitting', confidence: 0.7 },
breathing_bpm: { value: jitter(16, 1.5), confidence: 0.66 },
heart_bpm: { value: jitter(74, 3), confidence: 0.58 },
restlessness: { value: 0.31, confidence: 0.62 },
anomaly: { value: 0.4, confidence: 0.6, threshold: 0.8 },
},
{
// Bedroom 3 — kids; heartbeat specialist not yet trained.
room_id: 'bedroom_3', stale: false, vetoed: false, seeds: ['seed-bedroom-3'],
presence: { value: 'occupied', confidence: 0.79 },
posture: { value: 'lying', confidence: 0.74 },
breathing_bpm: { value: jitter(18, 2), confidence: 0.69 },
heart_bpm: null, // null = not trained (§6 invariant 3)
restlessness: { value: 0.46, confidence: 0.6 },
anomaly: { value: 0.22, confidence: 0.7, threshold: 0.8 },
},
{
room_id: 'kitchen', stale: false, vetoed: true, seeds: ['seed-livingroom-a1', 'seed-hallway-c3'],
presence: { value: 'occupied', confidence: 0.6 },
posture: { value: null, confidence: null }, // suppressed by veto — withheld, NOT zero (§4.5)
breathing_bpm: { value: null, confidence: null },
heart_bpm: { value: null, confidence: null },
restlessness: { value: 0.4, confidence: 0.5 },
anomaly: { value: 0.91, confidence: 0.88, threshold: 0.8 },
},
{
room_id: 'office', stale: false, vetoed: false, seeds: ['seed-bedroom-1'],
presence: { value: 'absent', confidence: 0.95 },
posture: null, // null = not trained (§6 invariant 3)
breathing_bpm: null,
heart_bpm: null,
restlessness: { value: 0.0, confidence: 0.9 },
anomaly: { value: 0.05, confidence: 0.9, threshold: 0.8 },
},
];
}
// ── Fleet map / federation (§4.3) ───────────────────────────────────
export function federation() {
return {
coordinator: 'seed-livingroom-a1', round: 47, k_healthy: 4, delta_status: 'exchanging',
invariant: 'model deltas only — never raw CSI',
krum: { f: 1, multi: true }, cadence_min: 30,
mesh_links: [
{ a: 'seed-livingroom-a1', b: 'seed-bedroom-1', health: 'green' },
{ a: 'seed-bedroom-1', b: 'seed-bedroom-2', health: 'green' },
{ a: 'seed-bedroom-2', b: 'seed-bedroom-3', health: 'amber' },
{ a: 'seed-bedroom-1', b: 'seed-hallway-c3', health: 'red' },
],
fused_events: [{ kind: 'fall', seeds: ['seed-livingroom-a1', 'seed-hallway-c3'], n: 2 }, { kind: 'occupant-track', seeds: ['seed-bedroom-1', 'seed-bedroom-2', 'seed-livingroom-a1'], n: 3 }],
};
}
// ── Witness / audit (§4.9) ──────────────────────────────────────────
export function witnessLog(page = 0, size = 12) {
const total = 240;
const items = Array.from({ length: size }, (_, i) => {
const n = page * size + i;
const seedTier = n % 2 === 0;
return {
entity_id: seedTier ? `rvf.store.write.${184210 - n}` : ['sensor.living_room_presence', 'binary_sensor.front_door', 'sensor.bedroom_breathing_rate'][n % 3],
old_state: seedTier ? null : ['false', 'off', '14.5'][n % 3],
new_state: seedTier ? `sha256:${(0x9a3f + n).toString(16)}` : ['true', 'on', '15.1'][n % 3],
ts: ago(n * 37),
tier: seedTier ? 'seed-sha256' : 'homecore-ed25519',
seed: ['seed-livingroom-a1', 'seed-bedroom-1', 'seed-bedroom-2', 'seed-bedroom-3'][n % 4],
key_fp: ['a1b2c3d4', 'e5f6a7b8', 'c9d0e1f2', 'b3a4c5d6'][n % 4],
};
});
return { items, page, size, total };
}
export function privacyModes() {
return [
{ seed: 'seed-livingroom-a1', mode: 'full-publish' },
{ seed: 'seed-bedroom-1', mode: 'audit-only' },
{ seed: 'seed-bedroom-2', mode: 'audit-only' },
{ seed: 'seed-bedroom-3', mode: 'audit-only' },
{ seed: 'seed-hallway-c3', mode: 'audit-only' },
];
}
// ── Events / automations (§4.8) ─────────────────────────────────────
export function recentEvents(n = 40) {
const variants = ['StateChanged', 'EntityRegistered', 'ConfigReloaded'];
const ents = ['sensor.living_room_presence', 'binary_sensor.front_door', 'light.kitchen_ceiling', 'sensor.bedroom_breathing_rate'];
return Array.from({ length: n }, (_, i) => ({
type: variants[i % 3],
entity_id: ents[i % ents.length],
old_state: ['off', 'false', '14.5'][i % 3],
new_state: ['on', 'true', '15.1'][i % 3],
ts: ago(i * 11),
user_id: i % 4 === 0 ? 'operator' : null,
context: { id: 'ctx-' + (1000 + i), parent_id: i % 3 === 0 ? 'ctx-' + (999 + i) : null, grandparent_id: i % 6 === 0 ? 'ctx-' + (998 + i) : null },
source: ['seed-livingroom-a1', 'cog-ha-matter'][i % 2],
}));
}
// ── Settings (§4.10) ────────────────────────────────────────────────
export function settings() {
return {
mqtt: { broker: 'mqtt://cognitum-v0:1883', user: 'homecore', mdns: '_ruview-ha._tcp', connected: true },
tokens: [
{ name: 'ios-companion', last_used: ago(120), created: ago(8000000) },
{ name: 'node-red', last_used: ago(60000), created: ago(20000000) },
],
ha_disco_entities: 21,
esp32: [
{ node_id: 'esp32-lr-01', ip: '192.168.1.31', port: 5566, firmware: '1.2.0', room: 'living_room', seed: 'seed-livingroom-a1' },
{ node_id: 'esp32-br1-01', ip: '192.168.1.32', port: 5566, firmware: '1.2.0', room: 'bedroom_1', seed: 'seed-bedroom-1' },
{ node_id: 'esp32-br2-01', ip: '192.168.1.33', port: 5566, firmware: '1.2.0', room: 'bedroom_2', seed: 'seed-bedroom-2' },
{ node_id: 'esp32-br3-01', ip: '192.168.1.34', port: 5566, firmware: '1.2.0', room: 'bedroom_3', seed: 'seed-bedroom-3' },
],
};
}
@@ -0,0 +1,217 @@
// §4.9 Witness / Audit Log — ADR-131.
//
// Persistent privacy-mode banner (aggregate + per-SEED), the unified
// two-tier witness timeline (SEED SHA-256 chain + homecore Ed25519
// chain merged chronologically), paginated 12-at-a-time, and a
// regulated-deployment attestation-bundle export. Privacy-mode toggles
// are high-stakes and gated behind an explicit inline confirm (§6 honesty
// — never silently mutate what a SEED publishes).
import { h, clear, card, pill, statusPill, sectionHeader, mono, button, banner, relTime } from '../ui.js';
const PAGE_SIZE = 12;
export default {
meta: { title: 'Audit' },
async render(root, ctx) {
const { api } = ctx;
root.appendChild(sectionHeader('Witness / Audit Log', 'Two-tier provenance — SEED SHA-256 store chain + homecore Ed25519 state chain'));
if (api.isDemo('audit')) root.appendChild(banner('DEMO — contract-conformant witness data until the live audit endpoint lands (ADR-131 §7.1).', 'amber'));
// Async data accessors now return Promises (api.js). Wrap the initial
// loads in try/catch; on failure surface the typed audit/witness banner
// (§12 W5 distinguishes "not yet wired" upstreams) and bail.
let modes;
let firstPage;
try {
modes = (await api.privacyModes()).map((m) => ({ ...m }));
firstPage = await api.witnessLog(0, PAGE_SIZE);
} catch (e) {
root.appendChild(banner('Audit/witness unavailable — ' + (e.message || e)
+ (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red'));
return () => {};
}
const privacyHost = h('div');
root.appendChild(privacyHost);
const renderPrivacy = () => { clear(privacyHost); privacyHost.appendChild(privacyCard(modes, renderPrivacy)); };
renderPrivacy();
// Unified timeline — its own host so pagination re-renders in place.
const timelineHost = h('div');
root.appendChild(timelineHost);
let page = firstPage.page;
// Pagination Prev/Next re-fetch the new page (await) and re-render in place.
const renderTimeline = async (res) => {
page = res.page;
clear(timelineHost);
timelineHost.appendChild(timelineCard(res,
async () => {
if (page <= 0) return;
clear(timelineHost);
timelineHost.appendChild(h('.muted-empty', 'Loading witness chain…'));
try { await renderTimeline(await api.witnessLog(page - 1, PAGE_SIZE)); }
catch (e) { clear(timelineHost); timelineHost.appendChild(banner('Audit/witness unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red')); }
},
async (last) => {
if (last) return;
clear(timelineHost);
timelineHost.appendChild(h('.muted-empty', 'Loading witness chain…'));
try { await renderTimeline(await api.witnessLog(page + 1, PAGE_SIZE)); }
catch (e) { clear(timelineHost); timelineHost.appendChild(banner('Audit/witness unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (witness aggregation not yet wired — ADR-131 §12 W5)' : ''), 'red')); }
}));
};
await renderTimeline(firstPage);
// Attestation bundle export.
root.appendChild(exportCard());
return () => {};
},
};
// ── Privacy mode (aggregate banner + per-SEED rows + gated toggle) ─────
function privacyCard(modes, rerender) {
const allPublish = modes.every((m) => m.mode === 'full-publish');
const anyAudit = modes.some((m) => m.mode === 'audit-only');
const top = allPublish
? banner('Full-publish mode — SEED state changes are published over MQTT.', 'green')
: banner('Audit-only mode (SHA-256 digests on-SEED only, no MQTT state messages).', 'amber');
const list = h('div');
modes.forEach((m, i) => list.appendChild(privacyRow(m, modes, rerender, i)));
return card({
title: 'Privacy mode',
children: [
top,
h('.t2.mt', 'Per-SEED configuration — each SEED chooses independently what leaves the device.'),
list,
],
});
}
function privacyRow(m, modes, rerender, idx) {
const isPublish = m.mode === 'full-publish';
const modePill = pill(m.mode, isPublish ? 'green' : 'amber');
// The confirm step lives inline beneath the row; only one at a time.
const confirmHost = h('div');
const toggleBtn = button('Toggle privacy mode', {
variant: 'ghost',
onClick: () => {
clear(confirmHost);
confirmHost.appendChild(confirmStep(m, modes, rerender, confirmHost));
},
});
const wrap = h('div',
h('.row',
h('span.flex.gap-sm', mono(m.seed), modePill),
toggleBtn),
confirmHost);
return wrap;
}
function confirmStep(m, modes, rerender, confirmHost) {
const target = m.mode === 'full-publish' ? 'audit-only' : 'full-publish';
const summary = target === 'audit-only'
? `${m.seed} will STOP publishing state changes over MQTT — only on-SEED SHA-256 digests remain.`
: `${m.seed} will START publishing state changes over MQTT (full state values leave the device).`;
const confirmBtn = button('Confirm', {
variant: 'primary',
onClick: () => {
const live = modes.find((x) => x.seed === m.seed);
if (live) live.mode = target;
rerender();
},
});
const cancelBtn = button('Cancel', { variant: 'ghost', onClick: () => clear(confirmHost) });
return card({
tint: target === 'audit-only' ? 'amber' : null,
children: [
h('.t2', h('span', 'Switch '), mono(m.seed), h('span', `${target}?`)),
h('.mt', summary),
h('.flex.gap-sm.mt', confirmBtn, cancelBtn),
],
});
}
// ── Unified two-tier witness timeline ──────────────────────────────────
function timelineCard(res, onPrev, onNext) {
const { items, page, size, total } = res;
const lastPage = Math.max(0, Math.ceil(total / size) - 1);
const isLast = page >= lastPage;
const head = h('.row',
h('span.k', 'entity · old → new · when · tier · source SEED · key'),
h('span.t2', `merged chronological — both chains`));
const body = h('div');
if (!items.length) body.appendChild(h('.muted-empty', 'No witness entries.'));
items.forEach((it) => body.appendChild(witnessRow(it)));
const from = total === 0 ? 0 : page * size + 1;
const to = Math.min(total, page * size + items.length);
const pager = h('.flex.spread.mt',
h('span.t2', `Showing ${from}${to} of ${total}`),
h('span.flex.gap-sm',
button(' Prev', { variant: 'ghost', onClick: onPrev, disabled: page <= 0 }),
button('Next ', { variant: 'ghost', onClick: () => onNext(isLast), disabled: isLast })));
return card({ title: 'Witness timeline', children: [head, body, pager] });
}
function witnessRow(it) {
const seedTier = it.tier === 'seed-sha256';
const tierPill = pill(it.tier, seedTier ? 'cyan' : 'purple');
// old → new. SEED-tier writes have no prior state and a sha256 digest as
// the "new" value — render the digest mono so it reads as a hash, not state.
const transition = h('span.flex.gap-sm',
h('span.mono.t2', it.old_state == null ? '∅' : it.old_state),
h('span.t3', '→'),
h('span.mono', it.new_state == null ? '∅' : it.new_state));
return h('.row',
h('span.flex.gap-sm.wrap',
mono(it.entity_id),
transition),
h('span.flex.gap-sm.wrap',
h('span.t2', relTime(it.ts)),
tierPill,
mono(it.seed),
h('span.mono.t3', keyFp(it.key_fp))));
}
function keyFp(fp) {
if (!fp) return '—';
return String(fp).slice(0, 8) + '…';
}
// ── Attestation bundle export (regulated-deployment compliance) ────────
function exportCard() {
const status = h('.t2.mt');
const btn = button('Export attestation bundle', {
variant: 'ghost',
onClick: () => {
clear(status);
status.appendChild(h('span.green',
'Bundle prepared — SEED SHA-256 store chain + homecore Ed25519 state chain packaged for compliance handoff.'));
},
});
return card({
title: 'Attestation bundle',
children: [
h('.t2', 'Packages both witness chains (SEED SHA-256 + homecore Ed25519) for regulated-deployment compliance handoff.'),
h('.mt', btn),
status,
],
});
}
@@ -0,0 +1,256 @@
// §4.7 Calibration Wizard — baseline → enroll → train → verify.
// Stepped wizard (15) against the ADR-151 calibration HTTP API.
import { h, clear, card, pill, statusPill, sectionHeader, bar, banner, button, mono } from '../ui.js';
export default {
meta: { title: 'Calibration' },
async render(root, ctx) {
const { api } = ctx;
const cal = api.calibration;
const state = { step: 1, room_id: '', seed: '', baseline_id: null, anchorIdx: 0, trainResult: null };
// Track the active baseline poll so it can be cancelled on Restart, on a
// step change, and when the panel itself is torn down (the router only
// calls the cleanup this render() returns — a per-card _cleanup was never
// invoked, leaking the setTimeout loop).
let activePoll = null;
function stopPoll() {
if (activePoll) { activePoll.cancelled = true; if (activePoll.timer) clearTimeout(activePoll.timer); activePoll = null; }
}
root.appendChild(sectionHeader('Calibration Wizard', 'baseline → enroll → train → verify'));
if (cal.demo) root.appendChild(banner('DEMO — cog-calibration HTTP API (ADR-151) simulated in-browser; the live service replaces this (§7.1).', 'amber'));
const stepper = h('.stepper');
const body = h('div');
root.appendChild(stepper);
root.appendChild(body);
const STEPS = ['Select', 'Baseline', 'Enroll', 'Train', 'Verify'];
function paintStepper() {
clear(stepper);
STEPS.forEach((s, i) => {
const n = i + 1;
const cls = n === state.step ? 'active' : (n < state.step ? 'done' : '');
stepper.appendChild(h('.step-pill' + (cls ? '.' + cls : ''), h('span.n', n < state.step ? '✓' : String(n)), s));
});
}
function go(step) { stopPoll(); state.step = step; paintStepper(); render(); }
function render() {
clear(body);
if (state.step === 1) body.appendChild(step1());
else if (state.step === 2) body.appendChild(step2());
else if (state.step === 3) body.appendChild(step3());
else if (state.step === 4) body.appendChild(step4());
else body.appendChild(step5());
}
// ── Step 1 — select room + SEED ────────────────────────────────
function step1() {
const roomInput = h('input.search', { placeholder: 'room_id (A-Za-z0-9_- , 164)', value: state.room_id });
const seedSel = h('select.inline');
const warn = h('div');
let seedList = [];
(async () => {
try { seedList = (await api.seeds()).filter((s) => s.online); }
catch (e) { warn.appendChild(banner('SEED fleet unavailable — ' + (e.message || e), 'red')); }
seedList.forEach((s) => seedSel.appendChild(h('option', { value: s.device_id }, `${s.device_id} (${s.zone})`)));
})();
const validate = () => {
const ok = /^[A-Za-z0-9_-]{1,64}$/.test(roomInput.value);
const seed = seedList.find((s) => s.device_id === seedSel.value);
clear(warn);
if (!ok) warn.appendChild(banner('room_id must match [A-Za-z0-9_-]{1,64}', 'red'));
else if (seed && seed.frame_rate_hz < 80) warn.appendChild(banner(`CSI ingest low (${seed.frame_rate_hz} Hz) — a broken pipeline silently fails calibration`, 'amber'));
return ok;
};
roomInput.addEventListener('input', validate);
seedSel.addEventListener('change', validate);
return card({
title: 'Step 1 — Select room and SEED', children: [
h('h3', 'room_id'), roomInput,
h('h3.mt', 'Serving SEED'), seedSel, warn,
h('.mt', button('Next', { variant: 'primary', onClick: () => { if (validate()) { state.room_id = roomInput.value; state.seed = seedSel.value; go(2); } } })),
],
});
}
// ── Step 2 — baseline capture ──────────────────────────────────
function step2() {
const progress = h('.bar', { style: { height: '14px' } }, h('span'));
const meta = h('.t2.mt');
const baselineLine = h('div');
const c = card({
title: 'Step 2 — Baseline capture (room must be empty)', children: [
progress, meta, baselineLine,
h('.mt', button('Restart', {
variant: 'ghost',
// Cancel the in-flight poll loop (was leaked before), reset the
// session, and start a fresh capture.
onClick: () => { stopPoll(); cal.reset(); clear(baselineLine); startCapture(); },
})),
],
});
// Single-flight: stopPoll() before (re)arming guarantees one loop.
function startCapture() {
stopPoll();
const session = { cancelled: false, timer: null };
activePoll = session;
(async () => {
let startRes;
try { startRes = await cal.start(); }
catch (e) { clear(meta); meta.appendChild(banner('Baseline start failed — ' + (e.message || e), 'red')); return; }
if (session.cancelled) return;
state.baseline_id = (startRes && startRes.baseline_id) || state.baseline_id;
const loop = async () => {
if (session.cancelled) return;
let st;
try { st = await cal.status(); }
catch (e) { clear(meta); meta.appendChild(banner('Status unavailable — ' + (e.message || e), 'red')); return; }
if (session.cancelled) return;
progress.firstChild.style.width = pct(st.frames, st.target) + '%';
clear(meta); meta.appendChild(document.createTextNode(`${st.frames}/${st.target} frames · ETA ${st.eta_s}s · z_median ${st.z_median}`));
if (st.motion_flagged) { if (!c.querySelector('.banner')) c.insertBefore(banner('Room must be empty — movement detected', 'amber'), progress); }
else { const b = c.querySelector('.banner'); if (b) b.remove(); }
if (st.target > 0 && st.frames >= st.target) {
activePoll = null;
state.baseline_id = state.baseline_id || 'bl-unknown';
clear(baselineLine);
baselineLine.appendChild(h('.mt', h('span.green', 'Baseline complete · '), mono(state.baseline_id), h('span.t2', ' (record this — it anchors STALE detection)')));
baselineLine.appendChild(h('.mt', button('Continue to enrollment', { variant: 'primary', onClick: () => go(3) })));
return;
}
session.timer = setTimeout(loop, 600);
};
loop();
})();
}
startCapture();
return c;
}
// ── Step 3 — anchor enrollment ─────────────────────────────────
function step3() {
const anchors = cal.ANCHORS;
const counter = h('h3', 'enrollment');
const list = h('div');
const current = h('div');
async function paint() {
let acc;
try { acc = new Set(((await cal.enrollStatus()).accepted) || []); }
catch (e) { clear(current); current.appendChild(banner('Enroll status unavailable — ' + (e.message || e), 'red')); acc = new Set(); }
clear(counter); counter.appendChild(document.createTextNode(`${acc.size} / ${anchors.length} anchors accepted`));
clear(list);
anchors.forEach((label, i) => {
list.appendChild(h('.row', mono(label),
acc.has(label) ? pill('accepted', 'green') : (i === state.anchorIdx ? pill('current', 'cyan') : pill('pending', 'grey'))));
});
clear(current);
const label = anchors[state.anchorIdx];
if (!label) {
current.appendChild(h('.mt', h('span.green', 'All anchors processed · '),
button('Train specialists', { variant: 'primary', onClick: () => go(4) })));
return;
}
current.appendChild(h('h3.mt', `Anchor: ${label}`));
current.appendChild(h('.t2', instruction(label)));
current.appendChild(h('.mt', button('Capture anchor', {
variant: 'primary', onClick: async () => {
let r;
try { r = await cal.anchor(label); }
catch (e) { current.appendChild(banner('Capture failed — ' + (e.message || e), 'red')); return; }
const f = r.features;
const res = h('.mt', r.accepted ? pill('accepted', 'green') : pill('retry', 'amber'),
r.reason ? h('span.amber', ' ' + r.reason) : null,
f ? h('.mono.t2.mt', `mean ${f.mean} · var ${f.variance} · breathing ${f.breathing_score} · heart ${f.heart_score}`) : null);
current.appendChild(res);
if (r.accepted) { state.anchorIdx++; setTimeout(paint, 700); }
},
})));
}
paint();
return card({ title: 'Step 3 — Anchor enrollment', children: [counter, list, current] });
}
// ── Step 4 — train ─────────────────────────────────────────────
function step4() {
const body4 = h('div', h('.muted-empty', 'Training…'));
const c = card({ title: 'Step 4 — Train specialists', children: [body4] });
(async () => {
let r;
try { r = await cal.train(state.room_id); }
catch (e) { clear(body4); body4.appendChild(banner('Training failed — ' + (e.message || e), 'red')); return; }
state.trainResult = r;
clear(body4);
const specs = [
['presence', r.presence && `threshold ${r.presence.threshold} · var ${r.presence.occupied_var}`],
['posture', r.posture && `${r.posture.prototypes} prototypes`],
['breathing', r.breathing && `min_score ${r.breathing.min_score}`],
['heartbeat', r.heartbeat && `min_score ${r.heartbeat.min_score}`],
['restlessness', r.restlessness && `calm ${r.restlessness.calm} · active ${r.restlessness.active}`],
['anomaly', r.anomaly && `${r.anomaly.prototypes} prototypes · scale ${r.anomaly.scale}`],
];
specs.forEach(([name, detail]) => {
body4.appendChild(h('.row', mono(name),
detail ? h('.flex.gap-sm', pill('trained', 'green'), h('span.t2', detail))
: h('.flex.gap-sm', pill('null', 'amber'), button('Re-enroll missing anchors', { variant: 'ghost', onClick: () => go(3) }))));
});
body4.appendChild(h('.mt', button('Verify live', { variant: 'primary', onClick: () => go(5) })));
})();
return c;
}
// ── Step 5 — verify live ───────────────────────────────────────
function step5() {
const rows = h('div', h('.muted-empty', 'Loading live RoomState…'));
(async () => {
let live;
try {
const all = await api.roomStates();
live = all.find((r) => r.room_id === state.room_id) || all[0];
} catch (e) { clear(rows); rows.appendChild(banner('Live RoomState unavailable — ' + (e.message || e), 'red')); return; }
clear(rows);
if (!live) { rows.appendChild(h('.muted-empty', 'No RoomState yet — give the room a moment after training.')); return; }
rows.appendChild(h('.row', 'Presence', live.presence ? statusPill(live.presence.value) : h('span.t3', '—')));
rows.appendChild(h('.row', 'Posture', live.posture ? statusPill(live.posture.value) : h('span.t3', '—')));
rows.appendChild(h('.row', 'Breathing', h('span.cyan', live.breathing_bpm ? live.breathing_bpm.value + ' BPM' : '—')));
rows.appendChild(h('.row', 'Heart rate', h('span.cyan', live.heart_bpm ? live.heart_bpm.value + ' BPM' : '—')));
})();
return card({
title: 'Step 5 — Verify live', children: [
h('.t2', 'Stand in the room to confirm presence; sit/lie to confirm posture; breathe normally to confirm vitals.'),
rows,
h('.flex.mt',
button('Confirm and save', { variant: 'primary', onClick: () => { cal.reset && cal.reset(); ctx.navigate('#/rooms'); } }),
button("Something's wrong — re-enroll", { variant: 'ghost', onClick: () => go(3) })),
],
});
}
paintStepper();
render();
// The router invokes this on navigation away — tear down any live poll.
return () => stopPoll();
},
};
// Guard against NaN%/Infinity% when target is 0/missing (§4.7 robustness).
function pct(frames, target) {
if (!(target > 0)) return 0;
return Math.max(0, Math.min(100, (frames / target) * 100)).toFixed(0);
}
function instruction(label) {
const map = {
empty: 'Leave the room empty and still.',
stand_still: 'Stand still in the centre of the room.',
sit: 'Sit down naturally.',
lie_down: 'Lie down (bed/sofa).',
breathe_slow: 'Breathe slowly and deeply.',
breathe_normal: 'Breathe at your normal resting rate.',
small_move: 'Make small fidgeting movements.',
sleep_posture: 'Adopt your typical sleeping posture and stay still.',
};
return map[label] || label;
}
@@ -0,0 +1,194 @@
// §4.6 v0 Appliance COG Management — ADR-131.
// Installed COGs (start/stop/restart/logs/config + sha256+sig shield),
// COG Store / App Registry (mirrors seed.cognitum.one/store), OTA
// Updates diff panels, and Hailo HEF status. Mirrors the Cog Store
// visual conventions (card layout, category pills, install/details pair).
import { h, clear, card, pill, statusPill, sectionHeader, mono, button, collapsible, banner } from '../ui.js';
export default {
meta: { title: 'COGs' },
async render(root, ctx) {
const { api } = ctx;
root.appendChild(sectionHeader('COGs', 'v0 Appliance COG runtime & OTA updates'));
if (api.isDemo('cogs')) {
root.appendChild(h('.banner.amber', 'COG management shows contract-conformant DEMO data until the live cog-supervisor endpoint lands (ADR-131 §7.1).'));
}
let cogs, updates;
try {
cogs = await api.cogs();
updates = await api.cogUpdates();
} catch (e) {
root.appendChild(banner('COG runtime unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
return () => {};
}
// ── Installed COGs ─────────────────────────────────────────────
root.appendChild(h('.flex.gap-sm', h('h2', 'Installed'), pill(String(cogs.length), 'cyan')));
const installed = h('.grid.cols-2');
cogs.forEach((c) => installed.appendChild(installedCogCard(c)));
root.appendChild(installed);
// ── OTA Updates ────────────────────────────────────────────────
root.appendChild(h('.flex.gap-sm.mt', h('h2', 'Updates'), pill(String(updates.length), updates.length ? 'amber' : 'grey')));
if (!updates.length) {
root.appendChild(card({ children: [h('.muted-empty', 'All COGs up to date.')] }));
} else {
updates.forEach((u) => root.appendChild(updateCard(u)));
}
// ── Hailo HEF status ───────────────────────────────────────────
// §6 honesty: the worker pill must reflect the REAL probe, not a
// hardcoded "connected". Probe the appliance services for the
// ruvector-hailo-worker; if that upstream is unavailable, show the
// status as unknown rather than fabricating "connected".
let workerStatus = 'unknown';
try {
const appliance = await api.appliance();
const svc = (appliance.services || []).find((s) => s.name === 'ruvector-hailo-worker');
if (svc && svc.status) workerStatus = svc.status;
} catch { /* leave 'unknown' — honest not-available, never fabricated */ }
root.appendChild(h('h2.mt', 'Hailo-10H accelerator'));
root.appendChild(hailoStatus(cogs, workerStatus));
return () => {};
},
};
// ── Installed COG card ───────────────────────────────────────────────
function installedCogCard(c) {
const verified = c.sha256_verified && c.signature_verified;
const shield = h(`span.shield.${verified ? 'ok' : 'bad'}`, (verified ? '✓ ' : '✗ ') + 'verified');
const archPill = c.arch === 'hailo10' ? pill('hailo10', 'purple') : pill('arm', 'cyan');
const body = h('div',
h('.flex.spread',
h('strong.mono', `${c.id} ${c.version}`),
statusPill(c.status)),
h('.flex.wrap.gap-sm.mt', archPill, shield,
h('span.t2', 'PID '), mono(c.pid == null ? '—' : c.pid)));
if (c.status === 'failed' && c.error) {
body.appendChild(h('.red.mt', { style: { fontFamily: 'var(--mono)', fontSize: '12px' } }, c.error));
}
// action ghost buttons
const actions = h('.flex.wrap.gap-sm.mt',
button('Start', { onClick: () => {} }),
button('Stop', { onClick: () => {} }),
button('Restart', { onClick: () => {} }));
body.appendChild(actions);
// View logs drawer
const logDrawer = h('pre.log.mt.hidden', logText(c));
let logsOpen = false;
const logsBtn = button('View logs', {
onClick: () => { logsOpen = !logsOpen; logDrawer.classList.toggle('hidden', !logsOpen); logsBtn.textContent = logsOpen ? 'Hide logs' : 'View logs'; },
});
actions.appendChild(logsBtn);
// Edit config.json drawer (textarea, no persistence)
const cfgArea = h('textarea.json.mt.hidden', { rows: 8, spellcheck: 'false' });
cfgArea.value = configJson(c);
let cfgOpen = false;
const cfgBtn = button('Edit config.json', {
onClick: () => { cfgOpen = !cfgOpen; cfgArea.classList.toggle('hidden', !cfgOpen); cfgBtn.textContent = cfgOpen ? 'Close config' : 'Edit config.json'; },
});
actions.appendChild(cfgBtn);
body.appendChild(logDrawer);
body.appendChild(cfgArea);
return card({ tint: c.status === 'failed' ? 'red' : null, children: [body] });
}
function logText(c) {
if (c.status === 'failed' && c.error) {
return [
`[error] ${c.id} v${c.version} exited`,
`[error] ${c.error}`,
`[info] supervisor: marking ${c.id} failed; PID was ${c.pid == null ? 'none' : c.pid}`,
].join('\n');
}
if (c.status === 'stopped') {
return `[info] ${c.id} v${c.version} stopped by operator\n[info] supervisor: PID released`;
}
return [
`[info] ${c.id} v${c.version} running (pid ${c.pid})`,
`[info] arch=${c.arch} sha256_verified=${c.sha256_verified} signature_verified=${c.signature_verified}`,
c.arch === 'hailo10' ? `[info] hailo: ${asArray(c.hef).join(', ') || 'no HEF loaded'} @ ${c.throughput_fps || '—'} fps` : '[info] cpu-only worker, no Hailo offload',
'[info] heartbeat ok',
].join('\n');
}
function configJson(c) {
const cfg = {
id: c.id,
version: c.version,
arch: c.arch,
autostart: c.status !== 'stopped',
};
if (c.arch === 'hailo10') {
cfg.hef = asArray(c.hef);
cfg.target_fps = c.throughput_fps || null;
}
return JSON.stringify(cfg, null, 2);
}
// Coerce a forwarded manifest `hef` (array | string | object | null) into an
// array so a non-array value degrades gracefully instead of throwing on
// .forEach/.join/.length (the gateway forwards it verbatim — §11).
function asArray(v) {
if (Array.isArray(v)) return v;
if (v == null || v === '') return [];
return [v];
}
// ── OTA update diff card ─────────────────────────────────────────────
function updateCard(u) {
const diff = h('div',
h('.flex.gap-sm',
h('strong.mono', u.id),
mono(u.from), h('span.t3', '→'), h('span.mono.green', u.to)),
diffList('New entities', u.new_entities, 'green'),
diffList('Config changes', u.config_changes, 'amber'),
h('.flex.gap-sm.mt',
button('Update', { variant: 'primary', onClick: () => {} }),
button('Skip', { onClick: () => {} })));
return card({ children: [diff] });
}
function diffList(title, items, color) {
if (!items || !items.length) return null;
const list = h('div.mt', h('h3', title));
items.forEach((e) => list.appendChild(h('.row', h(`span.mono.${color}`, e))));
return list;
}
// ── Hailo HEF status ─────────────────────────────────────────────────
function hailoStatus(cogs, workerStatus = 'unknown') {
const hailoCogs = cogs.filter((c) => c.arch === 'hailo10');
// statusPill maps 'running'/'connected'→green, 'unreachable'/'error'→red,
// 'unknown'→grey; the real probe drives the colour, never a hardcode.
const worker = h('.flex.gap-sm', statusPill(workerStatus), h('span.mono.t2', 'ruvector-hailo-worker:50051'));
const body = h('div', worker);
if (!hailoCogs.length) {
body.appendChild(h('.muted-empty', 'No Hailo-sourced COGs loaded.'));
} else {
hailoCogs.forEach((c) => {
const hef = asArray(c.hef); // gateway forwards manifest `hef` verbatim — may be a string
const hefRows = h('div',
h('.flex.spread', h('strong.mono', `${c.id} ${c.version}`), pill((c.throughput_fps || 0) + ' fps', 'purple')));
hef.forEach((f) => hefRows.appendChild(h('.row', h('span.mono.purple', f), h('span.t2', 'loaded'))));
if (!hef.length) hefRows.appendChild(h('.muted-empty', 'no .hef files loaded'));
body.appendChild(h('.mt', hefRows));
});
}
body.appendChild(h('.t3.mt', { style: { fontSize: '12px' } },
'RF Foundation Encoder (ADR-150) will appear here once available.'));
return card({ children: [body] });
}
@@ -0,0 +1,153 @@
// §4.1 System Dashboard — the "home screen".
// v0 Appliance health strip (always top) + SEED fleet overview +
// ESP32 summary + COG runtime status row + event-bus sparkline.
import { h, clear, card, metric, pill, statusPill, sectionHeader, sparkline, provenanceBadge } from '../ui.js';
export default {
meta: { title: 'System Dashboard' },
async render(root, ctx) {
const { api } = ctx;
root.appendChild(sectionHeader('System Dashboard', 'Cognitum v0 Appliance — the machine you are looking at'));
if (api.anyDemo()) root.appendChild(h('.banner.amber', 'DEMO mode (?demo=1) — panels show contract-conformant fixture data, not live (ADR-131 §2.2).'));
// Each section loads independently so one offline upstream can't blank
// the dashboard (§11.1). A failed section renders a typed error card.
let cleanupEvent = () => {};
// ── v0 Appliance health strip (always at top) ──────────────────
await section(root, 'v0 Appliance health', async () => {
const a = await api.appliance();
const strip = h('.metric-grid',
metric({ icon: '🖥', value: pctOrNA(a.cpu_pct), label: 'CPU' }),
metric({ icon: '🧠', value: pctOrNA(a.ram_pct), label: 'RAM' }),
metric({ icon: '⚡', value: pctOrNA(a.hailo_load_pct), label: 'Hailo-10H load' }),
metric({ icon: '🌡', value: unitOrNA(a.hailo_temp_c, '°C'), label: 'Hailo temp' }),
metric({ icon: '⏱', value: fmtUptime(a.uptime_s), label: 'Uptime', color: 'green' }));
const healthCard = card({ title: 'v0 Appliance health', children: [strip, servicesRow(a.services)] });
return h('div', healthCard, eventBus(a, ctx, (fn) => { cleanupEvent = fn; }));
});
// ── SEED fleet overview + ESP32 summary ────────────────────────
await section(root, 'SEED Fleet', async () => {
const wrap = h('div');
const seeds = await api.seeds();
const warnings = await api.esp32Warnings().catch(() => []);
const grid = h('.grid.cols-3');
seeds.forEach((s) => grid.appendChild(seedCard(s, ctx)));
wrap.appendChild(h('h2', 'SEED Fleet'));
wrap.appendChild(grid);
wrap.appendChild(esp32Summary(seeds, warnings));
return wrap;
});
// ── COG runtime status row ─────────────────────────────────────
await section(root, 'COG Runtime', async () => cogRow(await api.cogs(), ctx));
return () => cleanupEvent();
},
};
// Run one dashboard section; on failure append a typed error card instead
// of throwing (so the rest of the dashboard still renders).
async function section(root, label, build) {
try { root.appendChild(await build()); }
catch (e) {
root.appendChild(card({ children: [
h('.banner.red', `${label} unavailable — ${e && e.message ? e.message : e}`),
h('small.ts', e && e.upstreamUnavailable ? 'upstream not yet wired (ADR-131 §12)' : 'check the gateway / homecore-server'),
] }));
}
}
function servicesRow(services) {
const wrap = h('.flex.wrap.mt');
services.forEach((s) => wrap.appendChild(h('span.flex.gap-sm', statusPill(s.status), h('span.mono.t2', `${s.name}:${s.port}`))));
return wrap;
}
function seedCard(s, ctx) {
const offline = !s.online;
const c = card({
tint: offline ? 'red' : null, clickable: true,
onClick: () => ctx.navigate('#/seed/' + s.device_id),
children: [
h('.flex.spread', h('strong.mono', s.device_id), statusPill(s.online ? 'online' : 'offline')),
h('.kv.mt',
h('span.k', 'Firmware'), h('span.v.mono', s.firmware),
h('span.k', 'Epoch'), h('span.v.purple', String(s.epoch)),
h('span.k', 'Vectors'), h('span.v', s.vector_count.toLocaleString()),
h('span.k', 'Last ingest'), h('span.v', relAgo(s.last_ingest)),
h('span.k', 'Witness'), s.witness_valid ? pill('valid', 'green') : pill('invalid', 'red')),
sensorSummary(s.sensors),
],
});
return c;
}
function sensorSummary(sensors) {
if (!sensors) return h('.muted-empty', 'sensors offline');
return h('.flex.wrap.gap-sm.mt',
pill('PIR ' + (sensors.pir.motion ? 'motion' : 'still'), sensors.pir.motion ? 'amber' : 'grey'),
pill('door ' + (sensors.reed.open ? 'open' : 'closed'), sensors.reed.open ? 'amber' : 'grey'),
pill(sensors.bme280.temp_c + '°C', 'cyan'));
}
function esp32Summary(seeds, warnings) {
const total = seeds.reduce((n, s) => n + s.esp32_nodes, 0);
const body = h('div',
h('.flex.wrap',
...seeds.filter((s) => s.esp32_nodes > 0).map((s) =>
h('span.flex.gap-sm', h('span.mono.t2', s.device_id), pill(s.esp32_nodes + ' nodes', 'cyan'), h('span.t2', s.frame_rate_hz + ' Hz')))));
if (warnings.length) {
body.appendChild(h('.mt', h('h3', 'Warnings (target 100 Hz CSI + 1 Hz vectors)')));
warnings.forEach((w) => body.appendChild(h('.row', h('span.mono', w.node_id), h('span.amber', w.issue))));
}
return card({ title: `ESP32 Nodes — ${total} active`, children: [body] });
}
function cogRow(cogs, ctx) {
const row = h('.flex.wrap.gap-sm');
cogs.forEach((c) => {
const p = statusPill(c.status);
const wrap = h('span.flex.gap-sm.clickable', { style: { cursor: 'pointer' }, onClick: () => ctx.navigate('#/cogs') },
p, h('span.mono.t2', c.id), c.arch === 'hailo10' ? pill('hailo', 'purple') : null);
row.appendChild(wrap);
});
return card({ title: 'COG Runtime', children: [row] });
}
function eventBus(a, ctx, setCleanup) {
const rates = a.event_rate || [];
const spark = sparkline(rates, { w: 240, hgt: 36 });
const rate = rates.length ? rates[rates.length - 1] : 0;
const lag = a.channel_lag || 0;
const cap = a.channel_capacity || 4096;
const body = h('div',
h('.flex.spread', h('span.val.cyan', { style: { fontSize: '20px' } }, rate + ' ev/s'),
h('span.t2', `capacity ${cap.toLocaleString()}`)),
spark);
if (lag > 0) body.appendChild(h('.banner.amber.mt', `Subscriber falling behind — ${lag} events lagged against the ${cap.toLocaleString()} capacity`));
const host = h('span.t2');
const un = ctx.onWs((st) => { clear(host); host.appendChild(document.createTextNode(st.state === 'open' ? (st.lagged ? ' · WS lagging' : ' · WS live') : ' · WS offline')); });
body.appendChild(host);
if (setCleanup) setCleanup(un);
return card({ title: 'Event Bus activity', children: [body] });
}
// §6 honesty: a null/undefined metric must render a distinct not-available
// state ('—'), never a fabricated value like "null%"/"null°C".
function pctOrNA(v) { return v == null ? '—' : v + '%'; }
function unitOrNA(v, unit) { return v == null ? '—' : v + unit; }
function fmtUptime(s) {
if (s == null) return '—';
const d = Math.floor(s / 86400), hh = Math.floor((s % 86400) / 3600);
return d > 0 ? `${d}d ${hh}h` : `${hh}h`;
}
function relAgo(iso) {
const s = Math.round((Date.now() - Date.parse(iso)) / 1000);
if (s < 60) return s + 's ago';
if (s < 3600) return Math.round(s / 60) + 'm ago';
return Math.round(s / 3600) + 'h ago';
}
@@ -0,0 +1,240 @@
// §4.4 Entity & State Browser — live /api/states (real homecore REST).
//
// Entities grouped by domain (prefix before '.') in collapsible sections.
// Each row carries entity_id (mono), current state, last-changed (relTime),
// an INLINE provenanceBadge (§6 invariant 1 — SEED chain never collapsed),
// and a collapsible attributes JSON view. A keyword filter (entity_id +
// attribute keys/values) runs live; semantic search (ADR-132) is a future
// hint. State changes arrive over WebSocket (ctx.onEvent) — rows patch in
// place and flash; NEVER poll. The broadcast-channel lag indicator
// (ctx.onWs) warns when the subscriber falls behind the 4,096 capacity.
import {
h, clear, card, pill, sectionHeader, mono, provenanceBadge,
slideover, collapsible, lagIndicator, relTime, banner,
} from '../ui.js';
import { api, entityProvenance } from '../api.js';
export default {
meta: { title: 'Entities' },
async render(root, ctx) {
root.appendChild(sectionHeader('Entity & State Browser', 'Live /api/states — every entity, grouped by domain, with SEED provenance'));
// ── lag indicator (broadcast channel vs 4,096 capacity) ─────────
const lagHost = h('.flex.spread.mb');
const lagSlot = h('span', lagIndicator('connecting', false));
lagHost.appendChild(lagSlot);
root.appendChild(lagHost);
// ── search / filter controls ────────────────────────────────────
const search = h('input.search', {
type: 'text',
placeholder: 'Filter entities — id, attribute keys & values (case-insensitive)…',
});
const semantic = h('input.search', { type: 'text', placeholder: 'Semantic search (ADR-132)' });
semantic.disabled = true;
semantic.style.opacity = '0.5';
root.appendChild(h('.flex.wrap.mb', { style: { gap: '8px' } },
h('div', { style: { flex: '2', minWidth: '220px' } }, search),
h('div', { style: { flex: '1', minWidth: '180px' } }, semantic)));
// ── load live state view ────────────────────────────────────────
const listHost = h('div');
root.appendChild(listHost);
// Production /api/states now THROWS on failure — there is NO mock
// fallback. A failed load is an error state, not a DEMO substitution.
let states;
try {
states = await api.states();
} catch (e) {
listHost.appendChild(banner('/api/states unavailable — ' + (e && e.message ? e.message : e), 'red'));
return () => {};
}
if (!Array.isArray(states)) states = [];
// Demo mode legitimately serves fixtures (demoFlags.states is set by a
// successful api.states() in demo mode) — label that, not a fallback.
if (api.isDemo('states')) {
root.insertBefore(banner('Demo mode — showing contract-conformant fixture entities (§7.1).', 'amber'), listHost);
}
// index by entity_id so WS patches are O(1)
const byId = new Map();
states.forEach((s) => byId.set(s.entity_id, s));
// per-entity row controllers (set state text + flash)
const rows = new Map();
function render() {
clear(listHost);
const q = search.value.trim().toLowerCase();
const groups = groupByDomain([...byId.values()], q);
if (!groups.size) {
listHost.appendChild(h('.muted-empty', q ? 'No entities match the filter.' : 'No entities reported.'));
return;
}
// stable alphabetical domain order
[...groups.keys()].sort().forEach((domain) => {
const ents = groups.get(domain).sort((a, b) => a.entity_id.localeCompare(b.entity_id));
const header = h('.flex.gap-sm', h('strong.mono', domain), pill(ents.length, 'cyan'));
const section = collapsible(header, () => {
const body = h('div');
ents.forEach((e) => body.appendChild(entityRow(e)));
return body;
}, true);
listHost.appendChild(card({ children: [section] }));
});
}
function entityRow(e) {
const stateText = h('span.t1.mono', String(e.state));
const changed = h('span.t3', relTime(e.last_changed));
const top = h('.flex.spread', { style: { cursor: 'pointer', gap: '12px' }, onClick: () => openDetail(e) },
h('.flex.wrap.gap-sm', { style: { flex: '1', minWidth: '0' } },
mono(e.entity_id),
stateText,
changed),
// SEED provenance badge — INLINE, never collapsed (§6 invariant 1)
provenanceBadge(entityProvenance(e)));
const attrs = collapsible(h('span.t2', 'attributes'),
() => h('pre.json', JSON.stringify(e.attributes || {}, null, 2)), false);
const wrap = h('.entity-row', { style: { padding: '8px 0', borderBottom: '0.67px solid var(--border)' } }, top, attrs);
rows.set(e.entity_id, { stateText, changed, wrap });
return wrap;
}
function openDetail(e) {
const chain = contextChain(e.context, byId);
const content = h('div',
h('.kv',
h('span.k', 'entity_id'), h('span.v.mono', e.entity_id),
h('span.k', 'state'), h('span.v.mono', String(e.state)),
h('span.k', 'last changed'), h('span.v', relTime(e.last_changed)),
h('span.k', 'last updated'), h('span.v', relTime(e.last_updated))),
h('.mt', h('h3', 'Provenance'), provenanceBadge(entityProvenance(e))),
h('.mt', h('h3', 'Context causality'), chain),
h('.mt', h('h3', 'Attributes'), h('pre.json', JSON.stringify(e.attributes || {}, null, 2))));
slideover(e.entity_id, content);
}
render();
search.addEventListener('input', render);
// ── live WebSocket: patch state in place + flash (never poll) ────
const unEvent = ctx.onEvent((ev) => {
if (!ev || ev.event_type !== 'state_changed' || !ev.entity_id) return;
const cur = byId.get(ev.entity_id);
const ns = ev.new_state || {};
if (cur) {
// merge live fields onto the existing record
cur.state = ns.state != null ? ns.state : cur.state;
if (ns.attributes) cur.attributes = ns.attributes;
if (ns.last_changed) cur.last_changed = ns.last_changed;
if (ns.last_updated) cur.last_updated = ns.last_updated;
if (ns.context) cur.context = ns.context;
patchRow(ev.entity_id);
} else {
// a newly-appeared entity — fold it in and re-render the group
byId.set(ev.entity_id, {
entity_id: ev.entity_id,
state: ns.state != null ? ns.state : 'unknown',
attributes: ns.attributes || {},
last_changed: ns.last_changed || new Date().toISOString(),
last_updated: ns.last_updated || new Date().toISOString(),
context: ns.context || { id: null, user_id: null, parent_id: null },
});
render();
patchRow(ev.entity_id);
}
});
function patchRow(id) {
const e = byId.get(id);
const r = rows.get(id);
if (!e || !r) return;
r.stateText.textContent = String(e.state);
r.changed.textContent = relTime(e.last_changed);
// flash cyan then revert after 800ms (§4.4 live feedback)
r.stateText.style.color = 'var(--cyan)';
r.stateText.style.transition = 'none';
setTimeout(() => {
r.stateText.style.transition = 'color .6s ease';
r.stateText.style.color = '';
}, 800);
}
// ── broadcast-channel lag indicator ─────────────────────────────
const unWs = ctx.onWs((st) => {
clear(lagSlot);
lagSlot.appendChild(lagIndicator(st.state, st.lagged));
if (st.lagged) {
lagSlot.title = 'Subscriber behind the 4,096-event capacity — some state_changed events were dropped';
}
});
return () => { unEvent(); unWs(); };
},
};
/**
* Group entities by domain (prefix before the first '.'), applying the
* keyword filter across entity_id AND attribute keys/values.
*/
function groupByDomain(entities, q) {
const groups = new Map();
for (const e of entities) {
if (q && !matches(e, q)) continue;
const dot = e.entity_id.indexOf('.');
const domain = dot > 0 ? e.entity_id.slice(0, dot) : '(no domain)';
if (!groups.has(domain)) groups.set(domain, []);
groups.get(domain).push(e);
}
return groups;
}
/** Case-insensitive match across entity_id, state and attribute keys/values. */
function matches(e, q) {
if (e.entity_id.toLowerCase().includes(q)) return true;
if (String(e.state).toLowerCase().includes(q)) return true;
const attrs = e.attributes || {};
for (const [k, v] of Object.entries(attrs)) {
if (k.toLowerCase().includes(q)) return true;
try {
if (String(typeof v === 'object' ? JSON.stringify(v) : v).toLowerCase().includes(q)) return true;
} catch (_) { /* circular/unstringifiable — skip */ }
}
return false;
}
/**
* Render the Context causality chain (context.id → parent_id) as a mono
* breadcrumb trail. Walks parent_id up through known contexts when the
* parent entity is present, otherwise shows the raw id.
*/
function contextChain(ctxObj, byId) {
if (!ctxObj || !ctxObj.id) return h('span.t3', 'no context');
const seen = new Set();
const ids = [];
let cur = ctxObj;
while (cur && cur.id && !seen.has(cur.id)) {
seen.add(cur.id);
ids.unshift(cur.id);
if (!cur.parent_id) break;
ids.unshift(cur.parent_id);
seen.add(cur.parent_id);
cur = findContext(cur.parent_id, byId);
}
const trail = h('.flex.wrap.gap-sm');
ids.forEach((id, i) => {
if (i > 0) trail.appendChild(h('span.arr.t3', '→'));
trail.appendChild(mono(id));
});
return trail;
}
function findContext(id, byId) {
for (const e of byId.values()) {
if (e.context && e.context.id === id) return e.context;
}
return null;
}
@@ -0,0 +1,308 @@
// §4.8 Event Bus & Automation Feed — ADR-131 / ADR-129.
//
// Live event stream (seeded from /api/events, then prepended live from
// the shared WS bus — never polled, §2/§4.4), a context-causality
// breadcrumb on row expand (Context.id → parent_id → grandparent_id),
// and a trigger→condition→action automation builder (ADR-129 scope:
// UI-only, no backend persistence — rules live in a local array).
import {
h, clear, card, pill, statusPill, sectionHeader, mono, relTime,
collapsible, lagIndicator, button, banner,
} from '../ui.js';
const MAX_ROWS = 200; // virtualization-lite: cap DOM rows, drop oldest.
// event-type → pill colour variant (§4.8).
const VARIANT = {
StateChanged: 'cyan',
EntityRegistered: 'green',
ConfigReloaded: 'purple',
};
function typePill(type) {
return pill(type, VARIANT[type] || 'grey');
}
// A live WS event carries event_type:'state_changed'; normalise it into
// the same record shape as api.recentEvents() so the row renderer is one
// code path.
function normalizeLive(evt) {
return {
type: 'StateChanged',
entity_id: evt.entity_id,
old_state: evt.old_state,
new_state: evt.new_state,
ts: new Date().toISOString(),
user_id: null,
context: { id: null, parent_id: null, grandparent_id: null },
source: 'live',
_live: true,
};
}
const domainOf = (id) => String(id || '').split('.')[0] || '';
export default {
meta: { title: 'Events' },
async render(root, ctx) {
const { api } = ctx;
const unsubs = [];
root.appendChild(sectionHeader('Event Bus & Automation', 'Live entity events + causality + automation builder (ADR-131 §4.8, ADR-129)'));
if (api.isDemo('events')) {
root.appendChild(banner('DEMO — event history is contract-conformant mock data until the live /api/events feed lands (§7.1). New rows still arrive over the WS bus.', 'amber'));
}
// ── live lag indicator (top, fed by the shared WS bus) ──────────
const lagHost = h('span');
const paintLag = (st) => { clear(lagHost); lagHost.appendChild(lagIndicator(st.state, st.lagged)); };
unsubs.push(ctx.onWs(paintLag)); // fires immediately
// ── filter bar (mirrors the Cog Store .search field) ────────────
let filter = '';
const search = h('input.search', {
type: 'text',
placeholder: 'Filter by entity domain · event type · source (e.g. "sensor", "ConfigReloaded", "seed-")',
});
search.addEventListener('input', () => { filter = search.value.trim().toLowerCase(); applyFilter(); });
const list = h('.event-stream', { style: { maxHeight: '460px', overflowY: 'auto' } });
let rows = []; // { record, node } newest-first, capped to MAX_ROWS.
function matches(rec) {
if (!filter) return true;
const hay = [rec.type, rec.entity_id, domainOf(rec.entity_id), rec.source, rec.user_id]
.filter(Boolean).join(' ').toLowerCase();
return hay.includes(filter);
}
function applyFilter() {
for (const r of rows) r.node.classList.toggle('hidden', !matches(r.record));
}
function prepend(rec) {
const node = eventRow(rec);
rows.unshift({ record: rec, node });
list.insertBefore(node, list.firstChild);
node.classList.toggle('hidden', !matches(rec));
while (rows.length > MAX_ROWS) {
const old = rows.pop();
if (old.node.parentNode) old.node.parentNode.removeChild(old.node);
}
}
// seed from history (oldest first → prepend so newest ends on top).
// Wrap ONLY the history load: a missing/unwired recorder must NOT fail
// the panel — render an inline note and continue with an empty history.
// The live ctx.onEvent feed (below) attaches regardless (§12 W3).
let history = [];
let historyNote = null;
try {
history = await api.recentEvents(40);
} catch (e) {
history = [];
historyNote = banner('Event history unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (recorder not yet wired — ADR-131 §12 W3)' : ''), 'amber');
}
for (let i = history.length - 1; i >= 0; i--) prepend(history[i]);
if (!rows.length) list.appendChild(h('.muted-empty', 'No events yet — live events will appear here as they arrive.'));
// live events prepend as they arrive (never poll).
unsubs.push(ctx.onEvent((evt) => {
// strip the placeholder empty-state once real rows arrive.
const empty = list.querySelector('.muted-empty');
if (empty) empty.remove();
prepend(normalizeLive(evt));
}));
root.appendChild(card({
title: 'Live event stream',
children: [historyNote, h('.flex.spread.mb', h('span.t2', 'Newest first · capped to ' + MAX_ROWS + ' rows'), lagHost), search, list],
}));
// ── automation builder (ADR-129) ────────────────────────────────
root.appendChild(automationBuilder(api));
return () => { unsubs.forEach((u) => { try { u(); } catch {} }); };
},
};
// ── event row + causality breadcrumb ──────────────────────────────────
function eventRow(rec) {
const head = h('.flex.gap-sm.wrap',
typePill(rec.type),
h('strong.mono', rec.entity_id),
rec.type === 'StateChanged'
? h('span.t2', mono(rec.old_state == null ? '∅' : rec.old_state), h('span.arr.t3', { style: { margin: '0 6px' } }, '→'), mono(rec.new_state == null ? '∅' : rec.new_state))
: null,
h('span', { style: { marginLeft: 'auto' } }, h('small.ts', relTime(rec.ts))),
rec.user_id ? pill('@' + rec.user_id, 'amber') : h('small.ts', 'system'),
rec.source ? h('span.mono.t3', rec.source) : null);
return h('.event-row', { style: { padding: '6px 0', borderBottom: '0.67px solid var(--border)' } },
collapsible(head, () => causalityBreadcrumb(rec.context), false));
}
function causalityBreadcrumb(c) {
const wrap = h('.causality', { style: { padding: '8px 0 4px' } });
wrap.appendChild(h('span.t2', { style: { marginRight: '8px' } }, 'Context chain'));
const chain = [
['id', c && c.id],
['parent', c && c.parent_id],
['grandparent', c && c.grandparent_id],
].filter(([, v]) => v != null);
if (!chain.length) {
wrap.appendChild(h('span.t3', 'no context recorded for this event'));
return wrap;
}
chain.forEach(([label, val], i) => {
if (i > 0) wrap.appendChild(h('span.arr.t3', { style: { margin: '0 8px' } }, '→'));
wrap.appendChild(h('span.flex.gap-sm', { style: { display: 'inline-flex' } },
h('small.ts', label), mono(val)));
});
return wrap;
}
// ── automation builder (trigger → condition → action) ─────────────────
const TRIGGERS = [
{ id: 'state_changed', label: 'state_changed on RoomState entity' },
{ id: 'seed_reflex', label: 'SEED reflex rule fired' },
{ id: 'custom_event', label: 'custom domain_event topic' },
];
const REFLEX_RULES = ['fragility_alarm', 'hd_anomaly_indicator'];
const ACTION_KINDS = [
{ id: 'call_service', label: 'Call service' },
{ id: 'fire_event', label: 'Fire domain event' },
];
function automationBuilder(api) {
const rules = [];
const listHost = h('div');
// Default callable-service options; enriched asynchronously from the
// live service registry when reachable (failures are swallowed — the
// builder stays usable with defaults, and we never leave a dangling
// rejected promise in production).
const serviceOpts = ['light.turn_on', 'light.turn_off', 'notify.mobile', 'homecore.recalibrate_room'];
Promise.resolve()
.then(() => api.services())
.then((services) => {
(services || []).forEach((s) => {
const name = (s.domain && s.service) ? `${s.domain}.${s.service}` : String(s.name || s.id || s);
if (name && !serviceOpts.includes(name)) { serviceOpts.push(name); serviceSel.appendChild(h('option', { value: name }, name)); }
});
})
.catch(() => {});
// ── trigger editor ──
const triggerSel = sel(TRIGGERS.map((t) => [t.id, t.label]));
const thresholdInput = h('input.search.mono', { type: 'text', placeholder: 'threshold expression — e.g. anomaly.value > 0.8' });
const reflexSel = sel(REFLEX_RULES.map((r) => [r, r]));
const customInput = h('input.search.mono', { type: 'text', placeholder: 'domain_event topic — e.g. presence.regime_change' });
const triggerExtra = h('div', { style: { marginTop: '8px' } });
function paintTriggerExtra() {
clear(triggerExtra);
if (triggerSel.value === 'state_changed') triggerExtra.appendChild(thresholdInput);
else if (triggerSel.value === 'seed_reflex') triggerExtra.appendChild(field('Reflex rule', reflexSel));
else triggerExtra.appendChild(customInput);
}
triggerSel.addEventListener('change', paintTriggerExtra);
paintTriggerExtra();
// ── condition editor ──
const conditionInput = h('input.search.mono', { type: 'text', placeholder: 'condition expression — e.g. room.living_room.presence == "occupied"' });
// ── action editor ──
const actionSel = sel(ACTION_KINDS.map((a) => [a.id, a.label]));
const serviceSel = sel(serviceOpts.map((s) => [s, s]));
const eventInput = h('input.search.mono', { type: 'text', placeholder: 'domain event to fire — e.g. automation.lr_night_dim' });
const actionExtra = h('div', { style: { marginTop: '8px' } });
function paintActionExtra() {
clear(actionExtra);
if (actionSel.value === 'call_service') actionExtra.appendChild(field('Service', serviceSel));
else actionExtra.appendChild(eventInput);
}
actionSel.addEventListener('change', paintActionExtra);
paintActionExtra();
function buildTrigger() {
if (triggerSel.value === 'state_changed') return { kind: 'state_changed', entity: 'RoomState', threshold: thresholdInput.value.trim() };
if (triggerSel.value === 'seed_reflex') return { kind: 'seed_reflex', rule: reflexSel.value };
return { kind: 'custom_event', topic: customInput.value.trim() };
}
function buildAction() {
if (actionSel.value === 'call_service') return { kind: 'call_service', service: serviceSel.value };
return { kind: 'fire_event', event: eventInput.value.trim() };
}
const addBtn = button('Add automation', {
variant: 'primary',
onClick: () => {
rules.push({ trigger: buildTrigger(), condition: conditionInput.value.trim(), action: buildAction() });
thresholdInput.value = ''; customInput.value = ''; conditionInput.value = ''; eventInput.value = '';
renderRules();
},
});
function renderRules() {
clear(listHost);
if (!rules.length) { listHost.appendChild(h('.muted-empty', 'No automations defined yet (UI-only — not persisted).')); return; }
rules.forEach((r, i) => listHost.appendChild(ruleCard(r, i, () => { rules.splice(i, 1); renderRules(); })));
}
renderRules();
const builder = card({
title: 'Automation builder',
children: [
h('.t3.mb', 'Trigger → condition → action (ADR-129). UI scope only — assembled rules are held locally, not persisted to the appliance.'),
h('.grid.cols-3',
card({ title: 'Trigger', tint: null, children: [field('When', triggerSel), triggerExtra] }),
card({ title: 'Condition', children: [field('And', conditionInput)] }),
card({ title: 'Action', children: [field('Then', actionSel), actionExtra] })),
h('.flex.mt', addBtn),
],
});
return h('div', builder, card({ title: 'Defined automations', children: [listHost] }));
}
function ruleCard(r, i, onDelete) {
return card({
children: [
h('.flex.spread',
h('strong', 'Automation #' + (i + 1)),
button('Remove', { variant: 'ghost', onClick: onDelete })),
h('.flex.gap-sm.wrap.mt',
pill('TRIGGER', 'cyan'), triggerSummary(r.trigger)),
r.condition
? h('.flex.gap-sm.wrap.mt', pill('IF', 'amber'), mono(r.condition))
: h('.flex.gap-sm.wrap.mt', pill('IF', 'grey'), h('span.t3', 'always')),
h('.flex.gap-sm.wrap.mt',
pill('ACTION', 'purple'), actionSummary(r.action)),
],
});
}
function triggerSummary(t) {
if (t.kind === 'state_changed') return h('span', mono('RoomState'), ' ', t.threshold ? mono(t.threshold) : h('span.t3', '(any change)'));
if (t.kind === 'seed_reflex') return h('span', h('span.t2', 'reflex '), mono(t.rule || '—'));
return h('span', h('span.t2', 'event '), mono(t.topic || '—'));
}
function actionSummary(a) {
if (a.kind === 'call_service') return h('span', h('span.t2', 'call '), mono(a.service || '—'));
return h('span', h('span.t2', 'fire '), mono(a.event || '—'));
}
// ── small form helpers ────────────────────────────────────────────────
function sel(pairs) {
const s = h('select.inline', { style: { width: '100%' } });
for (const [val, label] of pairs) {
const o = document.createElement('option');
o.value = val; o.textContent = label;
s.appendChild(o);
}
return s;
}
function field(label, control) {
return h('label', { style: { display: 'block', marginTop: '8px' } },
h('span.k.t2', { style: { display: 'block', marginBottom: '4px', fontSize: '12.5px' } }, label),
control);
}
@@ -0,0 +1,198 @@
// §4.2 SEED Fleet overview + §4.3 SEED Fleet Map (node topology +
// ESP-NOW mesh + cross-SEED event dedup) + ADR-105 federation config.
//
// One panel covering: the fleet card grid, the v0→SEED→ESP32 node
// hierarchy, the mesh-link table, the cross-SEED fusion badges, and the
// federation round config — with the §3.3 "model deltas only — never raw
// CSI" invariant surfaced prominently (ADR-105 privacy guarantee).
import { h, card, pill, statusPill, sectionHeader, relTime, banner } from '../ui.js';
export default {
meta: { title: 'SEED Fleet' },
async render(root, ctx) {
const { api } = ctx;
root.appendChild(sectionHeader('SEED Fleet', 'Cross-SEED topology, ESP-NOW mesh & ADR-105 federation'));
// ── Load seeds + federation independently so one failing upstream
// doesn't blank the whole panel (ADR-131 §2.2 / §11.11). ───────
let seeds = null, fed = null;
try { seeds = await api.seeds(); } catch (e) {
root.appendChild(banner('SEED fleet unavailable — ' + (e.message || e)
+ (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
}
try { fed = await api.federation(); } catch (e) {
root.appendChild(banner('SEED fleet unavailable — ' + (e.message || e)
+ (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
}
if (api.isDemo('fleet')) {
root.appendChild(h('.banner.amber',
'DEMO — the SEED HTTPS API and the ADR-105 federation service are not served by this homecore-server binary. '
+ 'These panels render against their defined contract with contract-conformant mock data (ADR-131 §7.1).'));
}
// ── §4.2 SEED fleet overview ──────────────────────────────────────
if (seeds) {
root.appendChild(h('h2', 'Fleet overview'));
const grid = h('.grid.cols-3');
seeds.forEach((s) => grid.appendChild(seedCard(s, ctx)));
root.appendChild(grid);
// ── §4.3 Node hierarchy (v0 → SEED → ESP32) ─────────────────────
root.appendChild(card({ title: 'Node hierarchy', children: [hierarchy(seeds)] }));
}
if (fed) {
// ── §4.3 ESP-NOW mesh links ─────────────────────────────────────
root.appendChild(card({ title: 'ESP-NOW mesh links', children: [meshLinks(fed.mesh_links)] }));
// ── Cross-SEED event dedup / fusion ─────────────────────────────
root.appendChild(card({ title: 'Cross-SEED event dedup', children: [fusionBadges(fed.fused_events)] }));
// ── ADR-105 federation config ───────────────────────────────────
root.appendChild(federationConfig(fed));
}
return () => {};
},
};
// ── §4.2 SEED card ──────────────────────────────────────────────────
function seedCard(s, ctx) {
const offline = !s.online;
return card({
tint: offline ? 'red' : null, clickable: true,
onClick: () => ctx.navigate('#/seed/' + s.device_id),
children: [
h('.flex.spread',
h('strong.mono', s.device_id),
statusPill(s.online ? 'online' : 'offline')),
h('.kv.mt',
h('span.k', 'Zone'), h('span.v', s.zone),
h('span.k', 'Firmware'), h('span.v.mono', s.firmware),
h('span.k', 'Epoch'), h('span.v.purple', String(s.epoch)),
h('span.k', 'Vectors'), h('span.v', (s.vector_count || 0).toLocaleString()),
h('span.k', 'Last ingest'), h('span.v', relTime(s.last_ingest))),
h('.flex.wrap.gap-sm.mt',
s.witness_valid ? pill('witness valid', 'green') : pill('witness invalid', 'red')),
sensorSummary(s.sensors),
],
});
}
function sensorSummary(sensors) {
if (!sensors) return h('.muted-empty', 'sensors offline');
return h('.flex.wrap.gap-sm.mt',
pill('PIR ' + (sensors.pir.motion ? 'motion' : 'still'), sensors.pir.motion ? 'amber' : 'grey'),
pill('door ' + (sensors.reed.open ? 'open' : 'closed'), sensors.reed.open ? 'amber' : 'grey'),
pill(sensors.bme280.temp_c + '°C', 'cyan'));
}
// ── §4.3 Node hierarchy diagram (nested indented rows) ──────────────
// v0 Appliance (ROOT) → SEEDs grouped by zone → ESP32 nodes (leaves).
function hierarchy(seeds) {
const wrap = h('.mono', { style: { fontSize: '12.5px', lineHeight: '1.9' } });
// ROOT — the v0 appliance.
wrap.appendChild(treeRow(0, '●', 'cog-v0-appliance', pill('ROOT', 'purple'), null));
// Second tier — SEEDs grouped by .zone.
const byZone = groupBy(seeds, (s) => s.zone || 'unzoned');
const zones = Object.keys(byZone);
zones.forEach((zone, zi) => {
const lastZone = zi === zones.length - 1;
wrap.appendChild(treeRow(1, lastZone ? '└─' : '├─', zone, pill('zone', 'cyan'), null, true));
const zoneSeeds = byZone[zone];
zoneSeeds.forEach((s, si) => {
const lastSeed = si === zoneSeeds.length - 1;
wrap.appendChild(treeRow(2, lastSeed ? '└─' : '├─', s.device_id,
statusPill(s.online ? 'online' : 'offline'), null));
// Leaves — the ESP32 nodes attached to this SEED.
const nodes = (s.ingest && s.ingest.esp32) || [];
if (!nodes.length) {
wrap.appendChild(treeRow(3, '·', '(no ESP32 nodes)', null, null, true));
}
nodes.forEach((n, ni) => {
const lastNode = ni === nodes.length - 1;
wrap.appendChild(treeRow(3, lastNode ? '└─' : '├─', n.node_id,
pill(n.rate_hz + ' Hz', 'grey'), n.packet));
});
});
});
return wrap;
}
function treeRow(depth, connector, label, badge, suffix, muted) {
const row = h('.flex.gap-sm', { style: { paddingLeft: (depth * 18) + 'px' } });
row.appendChild(h('span.t3', connector));
row.appendChild(h(muted ? 'span.t3' : 'span', label));
if (badge) row.appendChild(badge);
if (suffix) row.appendChild(h('span.t3', suffix));
return row;
}
// ── §4.3 ESP-NOW mesh links (dashed rows coloured by .health) ───────
function meshLinks(links) {
if (!links || !links.length) return h('.muted-empty', 'no mesh links reported');
const wrap = h('div');
const colour = { green: 'green', amber: 'amber', red: 'red' };
links.forEach((l) => {
const k = colour[l.health] || 'grey';
wrap.appendChild(h('.flex.gap-sm', { style: { padding: '6px 0' } },
h('span.mono', l.a),
h(`span.${k}`, { style: { letterSpacing: '1px' } }, '╌╌╌'),
h('span.mono', l.b),
pill(l.health, k)));
});
return wrap;
}
// ── Cross-SEED event dedup — fusion badges (kind + n contributing) ──
function fusionBadges(events) {
if (!events || !events.length) return h('.muted-empty', 'no fused cross-SEED events');
const wrap = h('.flex.wrap.gap-sm');
events.forEach((e) => {
const seeds = (e.seeds || []).join(', ');
wrap.appendChild(h('span.flex.gap-sm', { style: { alignItems: 'center' } },
pill(e.kind, 'cyan'),
pill(e.n + ' SEEDs', 'purple'),
h('span.t2.mono', { style: { fontSize: '11px' } }, seeds)));
});
return wrap;
}
// ── ADR-105 federation config ───────────────────────────────────────
function federationConfig(fed) {
const body = h('div');
// CRITICAL invariant — the "model deltas only, never raw CSI" guarantee.
body.appendChild(h('.banner.purple',
{ style: { background: 'var(--purple-d)', color: 'var(--purple)', border: '0.67px solid var(--purple)' } },
h('strong', 'Federation invariant: '),
h('span.mono', fed.invariant)));
body.appendChild(h('.kv.mt',
h('span.k', 'Coordinator SEED'), h('span.v.mono', fed.coordinator),
h('span.k', 'Round'), h('span.v.purple', String(fed.round)),
h('span.k', 'k_healthy'), h('span.v', String(fed.k_healthy)),
h('span.k', 'Delta status'), statusPill(fed.delta_status === 'exchanging' ? 'updating' : fed.delta_status),
h('span.k', 'Krum (f)'), h('span.v', String(fed.krum && fed.krum.f)),
h('span.k', 'Krum mode'), h('span.v', fed.krum && fed.krum.multi ? 'multi-Krum' : 'Krum'),
h('span.k', 'Cadence'), h('span.v', (fed.cadence_min != null ? fed.cadence_min + ' min' : '—'))));
return card({ title: 'Federation config (ADR-105)', accent: true, children: [body] });
}
// ── helpers ─────────────────────────────────────────────────────────
function groupBy(arr, keyFn) {
const out = {};
for (const item of arr) {
const k = keyFn(item);
(out[k] || (out[k] = [])).push(item);
}
return out;
}
@@ -0,0 +1,119 @@
// §4.5 RoomState / Sensing Panel — mixture-of-specialists output.
// Per-room cards from GET /api/v1/room/state?bank=<room_id>.
//
// UX invariants (§4.5/§6): STALE and VETOED are never subtle; veto-
// suppressed values render as withheld, NOT zero; null specialists are
// "Not trained" (calibrate to enable), visually distinct from errors.
import { h, card, pill, statusPill, sectionHeader, bar, confidenceBar, banner, button } from '../ui.js';
export default {
meta: { title: 'Rooms' },
async render(root, ctx) {
const { api } = ctx;
root.appendChild(sectionHeader('RoomState / Sensing', 'Highest-level per-room sensing from the calibration mixture-of-specialists'));
let rooms;
try {
rooms = await api.roomStates();
} catch (e) {
root.appendChild(banner(`RoomState unavailable — ${e && e.message ? e.message : e}. ${e && e.upstreamUnavailable ? 'Calibration service (ADR-151) not reachable through the gateway.' : ''}`, 'red'));
return () => {};
}
if (api.isDemo('rooms')) root.appendChild(banner('DEMO mode (?demo=1) — fixture RoomState, not live calibration output (ADR-131 §2.2).', 'amber'));
if (!rooms.length) { root.appendChild(h('.muted-empty', 'No calibrated rooms yet — run the Calibration wizard to enable sensing.')); return () => {}; }
const grid = h('.grid.cols-2');
rooms.forEach((r) => grid.appendChild(roomCard(r, ctx)));
root.appendChild(grid);
return () => {};
},
};
function roomCard(r, ctx) {
const tint = r.stale ? 'amber' : (r.vetoed ? 'red' : null);
const children = [
h('.flex.spread',
h('strong.mono', r.room_id),
h('.flex.gap-sm',
r.seeds.length > 1 ? pill(r.seeds.length + ' seeds fused', 'purple') : null,
r.vetoed ? pill('veto active', 'red') : null,
r.stale ? pill('stale', 'amber') : null)),
];
// STALE banner — must never be subtle (§4.5)
if (r.stale) {
children.push(banner('Bank stale — baseline has changed', 'amber',
button('Recalibrate room', { variant: 'ghost', onClick: () => ctx.navigate('#/calibration') })));
}
if (r.vetoed) {
children.push(banner('Anomaly veto active — implausible window; vitals/posture withheld', 'red'));
}
children.push(specRow('Presence', presenceChip(r.presence), r.presence));
children.push(specRow('Posture', postureView(r), r.posture));
children.push(vitalRow('Breathing', r.breathing_bpm, 'BPM', [6, 30], r));
children.push(vitalRow('Heart rate', r.heart_bpm, 'BPM', [40, 120], r));
children.push(specRow('Restlessness', barOr(r.restlessness, 1), r.restlessness));
children.push(anomalyRow(r.anomaly));
return card({ tint, children });
}
function specRow(label, valueNode, spec) {
const right = h('.flex.gap-sm');
right.appendChild(valueNode);
if (spec && spec.confidence != null) right.appendChild(confidenceBar(spec.confidence));
return h('.row', h('span.k', label), right);
}
function presenceChip(p) {
if (!p) return notTrainedNode(); // null = not trained
return statusPill(p.value); // occupied → green, absent → grey
}
function postureView(r) {
if (r.posture === null) return notTrainedNode(); // not trained
if (r.vetoed && (!r.posture || r.posture.value == null)) return withheld(); // suppressed, not zero
if (!r.posture || r.posture.value == null) return withheld();
return statusPill(r.posture.value);
}
function vitalRow(label, spec, unit, range, r) {
let valueNode;
if (spec === null) valueNode = notTrainedNode();
else if (r.vetoed && (spec.value == null)) valueNode = withheld();
else if (spec.value == null) valueNode = withheld();
else valueNode = h('span.cyan', `${spec.value} ${unit} `, h('span.t3', `(${range[0]}${range[1]})`));
return specRow(label, valueNode, spec);
}
function anomalyRow(a) {
if (!a) return specRow('Anomaly', notTrainedNode(), null);
// §6 honesty: a null threshold is WITHHELD (the upstream RoomState carried
// none) — show the value but flag the threshold as unavailable rather than
// judging anomalous/normal against a fabricated 0.8 default.
if (a.threshold == null) {
const wrap = h('div', { style: { width: '160px' } },
bar(a.value, 1),
h('small.ts', { title: 'no anomaly threshold from upstream — withheld' }, `${a.value} · threshold —`));
return specRow('Anomaly', wrap, a);
}
const over = a.value > a.threshold;
const b = bar(a.value, 1, [{ lt: a.threshold, color: 'green' }, { lt: 1.01, color: 'red' }]);
const wrap = h('div', { style: { width: '160px' } }, b,
h('small.ts', over ? 'anomalous' : 'normal', ` · ${a.value}`));
return specRow('Anomaly', wrap, a);
}
function barOr(spec, max) {
if (spec === null) return notTrainedNode();
if (!spec || spec.value == null) return withheld();
const wrap = h('div', { style: { width: '140px' } }, bar(spec.value, max), h('small.ts', String(spec.value)));
return wrap;
}
function notTrainedNode() {
return h('span.t3', { title: 'null specialist — calibrate to enable' }, 'Not trained');
}
function withheld() {
return h('span.red', { title: 'suppressed by veto — value withheld, not zero' }, '— withheld');
}
@@ -0,0 +1,256 @@
// §4.2 SEED Detail View — the per-device deep dive (route #/seed/<id>).
//
// Vector store + witness chain (Ed25519 custody) + onboard sensors +
// reflex rules + cognitive (boundary fragility) analysis + ingest
// pipeline. Backed by the SEED HTTPS API (mock until the live endpoint
// lands → DEMO badge, §7.1). Honesty invariants (§6): null fragility /
// null sensors render muted, never as zero.
import {
h, card, pill, statusPill, sectionHeader, bar, banner, button, mono, kv,
sparkline, errorCard, relTime,
} from '../ui.js';
export default {
meta: { title: 'SEED Detail' },
async render(root, ctx) {
const { api } = ctx;
let s;
try {
s = await api.seed(ctx.params.id);
} catch (e) {
root.appendChild(sectionHeader('SEED Detail', ctx.params.id));
root.appendChild(banner('SEED unavailable — ' + (e.message || e) + (e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : ''), 'red'));
root.appendChild(card({ children: [button('← Back to fleet', { onClick: () => ctx.navigate('#/fleet') })] }));
return () => {};
}
if (!s) {
root.appendChild(sectionHeader('SEED Detail', ctx.params.id));
root.appendChild(errorCard(`No SEED with device_id "${ctx.params.id}"`));
root.appendChild(card({ children: [button('← Back to fleet', { onClick: () => ctx.navigate('#/fleet') })] }));
return () => {};
}
root.appendChild(sectionHeader('SEED Detail', s.zone));
if (api.isDemo('fleet')) {
root.appendChild(banner('DEMO — SEED HTTPS API not served by this binary; showing contract-conformant data (§7.1).', 'amber'));
}
root.appendChild(identityCard(s, ctx));
root.appendChild(vectorStoreCard(s));
root.appendChild(witnessCard(s));
root.appendChild(sensorsCard(s));
root.appendChild(reflexCard(s));
root.appendChild(cognitionCard(s));
root.appendChild(ingestCard(s));
return () => {};
},
};
// ── 1. identity header ────────────────────────────────────────────────
function identityCard(s, ctx) {
return card({
children: [
sectionHeader(s.device_id, `Firmware ${s.firmware} · ${s.zone}`),
h('.flex.spread',
statusPill(s.online ? 'online' : 'offline'),
button('← Fleet', { onClick: () => ctx.navigate('#/fleet') })),
kv([
['Firmware', mono(s.firmware)],
['Paired', pill('paired', 'green')],
['Conn mode', pill(s.conn, s.conn === 'usb' ? 'cyan' : 'purple')],
['Zone', s.zone],
]),
],
});
}
// ── 2. vector store ───────────────────────────────────────────────────
function vectorStoreCard(s) {
const over = s.storage_budget > 0 && s.storage_used / s.storage_budget > 0.8;
const storeBar = bar(s.storage_used, s.storage_budget, [{ lt: 0.8, color: 'cyan' }, { lt: 1.01, color: 'amber' }]);
const series = Array.from({ length: 24 }, (_, i) => s.knn_latency_ms != null ? +(s.knn_latency_ms + Math.sin(i / 2) * 0.4).toFixed(2) : 0);
let compacted = false;
const compactBtn = button('Compact now', {
onClick: () => {
if (compacted) return;
compacted = true;
compactBtn.disabled = true;
compactBtn.textContent = 'Compaction queued';
console.log('[seed-detail] POST /api/v1/store/compact', s.device_id); // production call
},
});
return card({
title: 'Vector Store',
children: [
kv([
['Vectors', s.vector_count.toLocaleString()],
['Dimension', mono(String(s.vector_dim))],
['kNN latency', s.knn_latency_ms != null ? h('span.cyan', s.knn_latency_ms + ' ms') : h('span.t3', '— offline')],
['Epoch', h('span.purple', String(s.epoch))],
['kNN latency trend', sparkline(series, { w: 160, hgt: 28 })],
]),
h('.flex.spread.mt',
h('span.t2', `Storage — ${s.storage_used.toLocaleString()} / ${s.storage_budget.toLocaleString()}`),
over ? pill('budget > 80%', 'amber') : pill('headroom', 'green')),
storeBar,
over ? banner('Vector store nearing budget — compaction recommended.', 'amber') : null,
h('.mt', compactBtn),
],
});
}
// ── 3. witness chain ──────────────────────────────────────────────────
function witnessCard(s) {
const verifyBtn = button('Verify chain', {
onClick: () => console.log('[seed-detail] verify witness chain', s.device_id),
});
const exportBtn = button('Export attestation bundle', {
onClick: () => console.log('[seed-detail] export attestation bundle', s.device_id),
});
return card({
title: 'Witness Chain',
children: [
kv([
['Chain length', h('span.purple', s.witness_len.toLocaleString())],
['Status', s.witness_valid ? pill('valid', 'green') : pill('invalid', 'red')],
['Last verify', relTime(s.witness_last_verify)],
]),
h('.flex.gap-sm.mt', verifyBtn, exportBtn),
h('small.ts',
'Ed25519 custody attestation — device-bound keypair signs (epoch + vector count + witness head): ',
mono(`epoch=${s.epoch} · vectors=${s.vector_count} · head=${s.witness_len}`)),
],
});
}
// ── 4. onboard sensors ────────────────────────────────────────────────
function sensorsCard(s) {
if (!s.sensors) {
return card({ title: 'Onboard Sensors', children: [h('.muted-empty', 'sensors offline')] });
}
const x = s.sensors;
const grid = h('.grid.cols-3',
subCard('BME280', [
sub('Temp', h('span.cyan', x.bme280.temp_c + ' °C')),
sub('Humidity', h('span.cyan', x.bme280.humidity_pct + ' %')),
sub('Pressure', h('span.cyan', x.bme280.pressure_hpa + ' hPa')),
]),
subCard('PIR', [
sub('Motion', x.pir.motion ? pill('motion', 'amber') : pill('still', 'grey')),
sub('Last trigger', h('span.t2', relTime(x.pir.last_trigger))),
]),
subCard('Reed', [
sub('State', x.reed.open ? pill('open', 'amber') : pill('closed', 'grey')),
sub('Last change', h('span.t2', relTime(x.reed.last_change))),
]),
subCard('ADS1115', x.ads1115.map((ch) => sub(ch.label, h('span.cyan', String(ch.v))))),
subCard('Vibration', [
sub('State', x.vibration.active ? pill('active', 'amber') : pill('idle', 'grey')),
sub('Last trigger', h('span.t2', relTime(x.vibration.last_trigger))),
]),
);
return card({ title: 'Onboard Sensors', children: [grid] });
}
function subCard(name, rows) {
return card({ children: [h('h3', name), ...rows] });
}
function sub(name, valueNode) {
return h('.row', h('span.k.t2', name), valueNode instanceof Node ? valueNode : h('span.cyan', String(valueNode)));
}
// ── 5. reflex rules ───────────────────────────────────────────────────
function reflexCard(s) {
if (!s.reflex || !s.reflex.length) {
return card({ title: 'Reflex Rules', children: [h('.muted-empty', 'no reflex rules configured')] });
}
const rows = s.reflex.map(reflexRow);
return card({ title: 'Reflex Rules', children: rows });
}
function reflexRow(r) {
let thresholdNode;
if (r.name === 'fragility_alarm') {
const input = h('input.inline', { type: 'number', step: '0.05', value: String(r.threshold) });
input.addEventListener('change', () => console.log('[seed-detail] reflex threshold edit (no persist)', r.name, input.value));
thresholdNode = input;
} else {
thresholdNode = mono(String(r.threshold));
}
const row = h('.row',
h('.flex.gap-sm', mono(r.name), r.fired_recently ? pill('fired recently', 'amber') : null),
h('.flex.gap-sm',
h('span.t2', 'thr'), thresholdNode,
h('span.t2', '→'), h('span.v', r.target),
h('small.ts', 'fired ' + (r.last_fired ? relTime(r.last_fired) : 'never'))));
if (r.fired_recently) {
return card({ tint: 'amber', children: [row] });
}
return row;
}
// ── 6. cognitive analysis ─────────────────────────────────────────────
function cognitionCard(s) {
const c = s.cognition || {};
const children = [];
if (c.fragility == null) {
children.push(h('.muted-empty', 'fragility unavailable — cognition offline'));
} else {
const fragile = c.fragility > 0.3;
const fb = bar(c.fragility, 1, [{ lt: 0.3, color: 'green' }, { lt: 0.6, color: 'amber' }, { lt: 1.01, color: 'red' }]);
if (fragile) {
children.push(banner(`Boundary fragility elevated — ${c.fragility.toFixed(2)} (regime change likely)`, 'amber'));
}
children.push(h('.flex.spread', h('span.t2', 'Boundary fragility'), h('span' + (fragile ? '.amber' : '.green'), c.fragility.toFixed(2))));
children.push(fb);
}
if (c.coherence_phases && c.coherence_phases.length) {
children.push(h('h3.mt', 'Coherence phases'));
c.coherence_phases.forEach((p) => {
children.push(h('.row', mono(relTime(p.t)), h('span.v', p.label)));
});
}
children.push(h('.row.mt', h('span.k.t2', 'kNN rebuild cadence'), mono((c.knn_rebuild_s ?? '—') + ' s')));
return card({ title: 'Cognitive Analysis', children });
}
// ── 7. ingest pipeline ────────────────────────────────────────────────
function ingestCard(s) {
const ing = s.ingest || {};
const children = [
kv([
['Batch size', mono(String(ing.batch))],
['Flush interval', mono((ing.flush_ms ?? '—') + ' ms')],
['Bridge', String(ing.bridge ?? '—')],
]),
];
if (ing.bridge && /hop/i.test(ing.bridge)) {
children.push(banner('Bridge adds a network hop — extra latency + a trust boundary in the ingest path.', 'amber'));
}
if (ing.esp32 && ing.esp32.length) {
children.push(h('h3.mt', 'ESP32 ingest nodes'));
ing.esp32.forEach((n) => children.push(esp32Row(n)));
} else {
children.push(h('.muted-empty', 'no ESP32 nodes attached'));
}
return card({ title: 'Ingest Pipeline', children });
}
function esp32Row(n) {
const native = n.packet === '0xC5110003';
const packetPill = native
? pill('0xC5110003 native', 'green')
: pill((n.packet || '—') + ' vitals fallback', 'amber');
return h('.row',
mono(n.node_id),
h('.flex.gap-sm', packetPill, h('span.t2', n.rate_hz + ' Hz')));
}
@@ -0,0 +1,256 @@
// §4.10 Settings & Integration Config — ADR-131.
// One card per sub-section: SEED fleet management, ESP32 provisioning,
// MQTT / cog-ha-matter config, long-lived access tokens, federation
// config. Security invariants are surfaced as first-class banners
// (USB-only pairing window; "model deltas only, never raw CSI").
//
// Mutations are local-state-only here (no live mutate endpoint yet); the
// node→room assignment edits persist into an in-memory map and the panel
// is flagged DEMO whenever the mock layer is serving it (§7.1 honesty).
import {
h, clear, card, pill, statusPill, sectionHeader, mono, button, banner, kv, relTime,
} from '../ui.js';
export default {
meta: { title: 'Settings' },
async render(root, ctx) {
const { api } = ctx;
// Load each card's data independently so one failure doesn't blank the page.
let s = null, sErr = null;
let seeds = null, seedsErr = null;
let fed = null, fedErr = null;
try { s = await api.settings(); } catch (e) { sErr = e; }
try { seeds = await api.seeds(); } catch (e) { seedsErr = e; }
try { fed = await api.federation(); } catch (e) { fedErr = e; }
root.appendChild(sectionHeader('Settings & Integration Config', 'SEED fleet, ESP32 provisioning, MQTT / cog-ha-matter, access tokens & federation (ADR-131 §4.10)'));
if (api.isDemo('settings') || api.isDemo('fleet')) {
root.appendChild(banner('DEMO — settings & fleet are served by the contract-conformant mock layer until their live endpoints land (ADR-131 §7.1). Edits are local-state only.', 'amber'));
}
// ── §4.10.1 SEED fleet ──
if (seedsErr) root.appendChild(cardBanner('SEED Fleet Management', 'SEED fleet unavailable — ' + errText(seedsErr)));
else root.appendChild(seedFleetCard(seeds));
// ── §4.10.2/.3/.4 ESP32 + MQTT + tokens (all from settings) ──
if (sErr) {
root.appendChild(cardBanner('ESP32 Node Provisioning', 'ESP32 provisioning unavailable — ' + errText(sErr)));
root.appendChild(cardBanner('MQTT / cog-ha-matter', 'MQTT / cog-ha-matter config unavailable — ' + errText(sErr)));
root.appendChild(cardBanner('Long-Lived Access Tokens', 'Access tokens unavailable — ' + errText(sErr)));
} else {
root.appendChild(esp32Card(s.esp32));
root.appendChild(mqttCard(s.mqtt, s.ha_disco_entities, s.esp32));
root.appendChild(tokensCard(s.tokens));
}
// ── §4.10.5 Federation (needs federation + seeds) ──
if (fedErr || seedsErr) root.appendChild(cardBanner('Federation Config', 'Federation config unavailable — ' + errText(fedErr || seedsErr)));
else root.appendChild(federationCard(fed, seeds));
return () => {};
},
};
// ── §4.10.1 SEED fleet management ───────────────────────────────────
function seedFleetCard(seeds) {
const body = h('div');
// PROMINENT USB-only pairing invariant (security invariant).
body.appendChild(banner('Pairing window only opens via 169.254.42.1 (USB), never WiFi — security invariant.', 'red'));
const list = h('div.mt');
seeds.forEach((sd) => list.appendChild(seedRow(sd)));
body.appendChild(list);
body.appendChild(h('.flex.wrap.gap-sm.mt',
button('Add SEED', { variant: 'ghost', onClick: () => toggleNote(addNote) }),
button('Reprovision', { variant: 'ghost', onClick: () => toggleNote(addNote) })));
const addNote = inlineNote('Provisioning flow', [
'1. Connect the SEED over USB — it presents a link-local pairing endpoint at 169.254.42.1.',
'2. Pairing NEVER opens over WiFi; the device refuses pairing on any non-USB interface.',
'3. Issue a bearer token over the USB link, then attach the SEED to the appliance.',
'4. Verify the witness chain before accepting the SEED into the fleet.',
]);
body.appendChild(addNote);
return card({ title: 'SEED Fleet Management', children: [body] });
}
function seedRow(sd) {
const offline = !sd.online;
const tokenKind = offline ? 'grey' : 'green';
const tokenLabel = offline ? 'token idle' : 'token valid';
const note = inlineNote('Secure token rotation — ' + sd.device_id, [
'1. Operator confirms physical presence; pairing must be re-opened over USB (169.254.42.1) — never WiFi.',
'2. Appliance mints a new bearer token and stages it on the SEED over the USB link.',
'3. SEED acknowledges; the appliance flips the active token and revokes the old one.',
'4. Witness chain records the rotation (ed25519); old token rejected on next ingest.',
]);
const head = h('.row',
h('strong.mono', sd.device_id),
h('.flex.gap-sm',
h('span.t2', sd.firmware),
pill(tokenLabel, tokenKind),
statusPill(sd.online ? 'online' : 'offline'),
button('Rotate token', { variant: 'ghost', onClick: () => toggleNote(note) }),
button('Remove', { variant: 'ghost', onClick: () => toggleNote(note) })));
return h('div', head, note);
}
// ── §4.10.2 ESP32 node provisioning ─────────────────────────────────
function esp32Card(nodes) {
// local-state room assignment map (node_id → room) — no live endpoint.
const roomMap = {};
nodes.forEach((n) => { roomMap[n.node_id] = n.room; });
const body = h('div');
nodes.forEach((n) => {
const sel = h('input.inline', {
value: roomMap[n.node_id],
title: 'Editable node→room assignment (local state)',
onChange: (e) => { roomMap[n.node_id] = e.target.value.trim(); },
});
body.appendChild(h('.row',
h('.flex.gap-sm',
h('strong.mono', n.node_id),
mono(n.ip + ':' + n.port),
h('span.t2', 'fw ' + n.firmware),
pill(n.seed, 'cyan')),
h('.flex.gap-sm', h('span.k', 'room'), sel)));
});
body.appendChild(h('.t3.mt', 'Provision a new node with the firmware tool: ',
mono('firmware/esp32-csi-node/provision.py'),
' (set --target-ip to this appliance).'));
body.appendChild(h('.flex.wrap.gap-sm.mt',
button('Add ESP32 node', { variant: 'ghost', onClick: () => alert('Run provision.py over USB — see hint above.') }),
button('Apply room map', { variant: 'ghost', onClick: () => alert('Room map persisted locally: ' + JSON.stringify(roomMap)) })));
return card({ title: 'ESP32 Node Provisioning', children: [body] });
}
// ── §4.10.3 MQTT / cog-ha-matter config ─────────────────────────────
function mqttCard(mqtt, haEntities, esp32) {
const dotCls = mqtt.connected ? '' : '.err';
const liveDot = h('span.lag',
h('span.dot' + dotCls),
h('span.t2', mqtt.connected ? 'connected' : 'disconnected'));
const conf = kv([
['Broker', mono(mqtt.broker)],
['User', mqtt.user],
['Credentials', mono('••••••')],
['mDNS advertisement', mono(mqtt.mdns)],
['Connection', liveDot],
]);
// HA-DISCO entities per node with via_device assignments.
const disco = h('div.mt',
h('h3', `HA-DISCO entities — ${haEntities} per node`),
h('.t3', 'Each ESP32 node publishes its discovery entities with a via_device pointing at its SEED:'));
esp32.forEach((n) => disco.appendChild(h('.row',
h('span.mono', n.node_id),
h('.flex.gap-sm', pill(haEntities + ' entities', 'cyan'), h('span.t2', 'via_device'), mono(n.seed)))));
return card({ title: 'MQTT / cog-ha-matter', children: [conf, disco] });
}
// ── §4.10.4 Long-lived access tokens ────────────────────────────────
function tokensCard(tokens) {
const body = h('div');
tokens.forEach((t) => {
body.appendChild(h('.row',
h('.flex.gap-sm', h('strong', t.name), pill('long-lived', 'purple')),
h('.flex.gap-sm',
h('span.t2', 'last used ' + relTime(t.last_used)),
h('span.t3', 'created ' + relTime(t.created)),
button('Revoke', { variant: 'ghost', onClick: () => alert('Revoking "' + t.name + '" — token rejected on next request (local demo).') }))));
});
body.appendChild(h('.flex.wrap.gap-sm.mt',
button('Create token', { variant: 'primary', onClick: () => alert('A new long-lived token would be minted and shown once (demo).') })));
// HA companion-app pairing QR placeholder box.
const qr = h('.muted-empty.mt', { style: { border: '0.67px dashed var(--border)', borderRadius: '8px', padding: '24px', textAlign: 'center' } },
'HA companion-app pairing QR surfaces here — scan from the Home Assistant mobile app to pair this appliance (placeholder).');
body.appendChild(qr);
return card({ title: 'Long-Lived Access Tokens', children: [body] });
}
// ── §4.10.5 Federation config (ADR-105) ─────────────────────────────
function federationCard(fed, seeds) {
const body = h('div');
// CRITICAL invariant — model deltas only, never raw CSI (purple).
body.appendChild(purpleBanner('Federation invariant — ' + fed.invariant + '.'));
body.appendChild(kv([
['Coordinator SEED', mono(fed.coordinator)],
['Round', h('span.purple', String(fed.round))],
['Healthy SEEDs (k)', String(fed.k_healthy)],
['Delta exchange', statusPill(fed.delta_status === 'exchanging' ? 'updating' : fed.delta_status)],
['Round cadence', fed.cadence_min + ' min'],
['Krum aggregation', h('.flex.gap-sm', pill('f = ' + fed.krum.f, 'cyan'), pill(fed.krum.multi ? 'multi-Krum' : 'single-Krum', 'purple'), h('span.t3', 'ADR-105'))],
]));
// ESP-NOW mesh sync status — rows coloured by health.
const mesh = h('div.mt', h('h3', 'ESP-NOW mesh sync — cross-SEED epoch alignment'));
fed.mesh_links.forEach((l) => {
const epochA = epochOf(seeds, l.a);
const epochB = epochOf(seeds, l.b);
const aligned = epochA != null && epochA === epochB;
mesh.appendChild(h('.row',
h('.flex.gap-sm', h('span.mono', l.a), h('span.t3', '↔'), h('span.mono', l.b)),
h('.flex.gap-sm',
h('span.t2', `epoch ${fmtEpoch(epochA)} / ${fmtEpoch(epochB)}`),
pill(aligned ? 'aligned' : 'epoch skew', aligned ? 'green' : 'amber'),
pill(l.health, healthKind(l.health)))));
});
body.appendChild(mesh);
return card({ title: 'Federation Config', children: [body] });
}
// ── helpers ─────────────────────────────────────────────────────────
/** Format a load error, surfacing the §12 upstream-not-wired hint. */
function errText(e) {
return (e && e.message ? e.message : String(e)) + (e && e.upstreamUnavailable ? ' (upstream not yet wired — ADR-131 §12)' : '');
}
/** Render a card whose body is a red unavailability banner (one card's data failed). */
function cardBanner(title, msg) {
return card({ title, children: [banner(msg, 'red')] });
}
function epochOf(seeds, id) {
const s = seeds.find((x) => x.device_id === id);
return s ? s.epoch : null;
}
function fmtEpoch(e) { return e == null ? '—' : String(e); }
function healthKind(h0) {
const m = { green: 'green', red: 'red', amber: 'amber' };
return m[String(h0).toLowerCase()] || 'grey';
}
/** Purple banner for federation invariants (no .banner.purple in CSS). */
function purpleBanner(text) {
return h('.banner', {
style: { background: 'var(--purple-d)', color: 'var(--purple)', border: '0.67px solid var(--purple)' },
}, text);
}
/** A hidden, toggleable multi-step note describing a secure flow. */
function inlineNote(title, steps) {
const node = h('.banner', {
style: { background: 'var(--bg2)', border: '0.67px solid var(--border)', color: 'var(--t1)', display: 'none' },
}, h('strong', title));
steps.forEach((line) => node.appendChild(h('.t2', { style: { marginTop: '4px' } }, line)));
return node;
}
function toggleNote(node) {
node.style.display = node.style.display === 'none' ? 'block' : 'none';
}
+235
View File
@@ -0,0 +1,235 @@
// HOMECORE-UI shared component helpers — ADR-131 §3.3.
//
// Every panel imports from here so cards/pills/buttons/badges are
// byte-identical across the dashboard (the §3.3 "no visual seam"
// invariant). Pure DOM, no framework, no build step.
/** Hyperscript element factory. `h('div.card#x', {onClick}, ...children)`. */
export function h(spec, attrs, ...children) {
let tag = 'div', id = null;
const classes = [];
spec.replace(/([.#]?[^.#]+)/g, (tok) => {
if (tok[0] === '.') classes.push(tok.slice(1));
else if (tok[0] === '#') id = tok.slice(1);
else tag = tok;
return tok;
});
const node = document.createElement(tag);
if (id) node.id = id;
if (classes.length) node.className = classes.join(' ');
if (attrs && typeof attrs === 'object' && !(attrs instanceof Node) && !Array.isArray(attrs)) {
for (const [k, v] of Object.entries(attrs)) {
if (v == null || v === false) continue;
if (k === 'class') node.className += ' ' + v;
else if (k === 'html') node.innerHTML = v;
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
else if (k === 'style' && typeof v === 'object') Object.assign(node.style, v);
else node.setAttribute(k, v);
}
} else if (attrs != null) {
children.unshift(attrs);
}
append(node, children);
return node;
}
function append(node, children) {
for (const c of children.flat(Infinity)) {
if (c == null || c === false) continue;
node.appendChild(c instanceof Node ? c : document.createTextNode(String(c)));
}
}
export const txt = (s) => document.createTextNode(s == null ? '' : String(s));
export const mono = (s) => h('span.mono', String(s == null ? '' : s));
export const clear = (n) => { while (n.firstChild) n.removeChild(n.firstChild); return n; };
/** Status pill. kind ∈ cyan|green|amber|red|purple|grey. */
export function pill(text, kind = 'grey') {
return h(`span.pill.${kind}`, String(text));
}
/** Map a free-form status string to the platform colour convention. */
export function statusPill(status) {
const s = String(status || '').toLowerCase();
const map = {
running: 'green', online: 'green', ok: 'green', healthy: 'green', occupied: 'green', paired: 'green', connected: 'green', valid: 'green',
stale: 'amber', degraded: 'amber', updating: 'amber', warn: 'amber', warning: 'amber',
failed: 'red', offline: 'red', error: 'red', veto: 'red', vetoed: 'red', unreachable: 'red', invalid: 'red',
stopped: 'grey', absent: 'grey', unknown: 'grey', 'not trained': 'grey',
info: 'purple', epoch: 'purple', chain: 'purple',
};
return pill(status, map[s] || 'grey');
}
export function card({ title, tint, accent, clickable, onClick, children = [] } = {}) {
const cls = ['card'];
if (tint) cls.push('tint-' + tint);
if (clickable || onClick) cls.push('clickable');
const node = h('.' + cls.join('.'));
if (onClick) node.addEventListener('click', onClick);
if (accent) node.appendChild(accentBar());
if (title) node.appendChild(h('h2', title));
append(node, [children]);
return node;
}
function accentBar() {
const b = h('div');
b.style.height = '3px';
b.style.borderRadius = '3px';
b.style.margin = '-14px -10px 14px';
b.style.background = 'linear-gradient(90deg, var(--cyan), var(--purple))';
return b;
}
/** Section header with the cyan→purple featured gradient border (§3.3). */
export function sectionHeader(title, sub) {
return h('.section-header', h('h1', title), sub ? h('.sub', sub) : null);
}
/** Live metric card (§4.1). */
export function metric({ icon, value, label, color = 'cyan' }) {
return h('.metric',
icon ? h('.ico', icon) : null,
h(`.val${color === 'green' ? '.green' : ''}`, String(value)),
h('.lbl', label));
}
export function button(label, { variant = 'ghost', onClick, disabled } = {}) {
const b = h(`button.btn.${variant}`, label);
if (disabled) b.disabled = true;
if (onClick) b.addEventListener('click', onClick);
return b;
}
/**
* Progress bar with threshold colouring.
* thresholds: [{ lt, color }] evaluated in order against the 0..1 ratio.
*/
export function bar(value, max = 1, thresholds = null) {
const ratio = max > 0 ? Math.max(0, Math.min(1, value / max)) : 0;
let color = '';
if (thresholds) {
for (const t of thresholds) { if (ratio < t.lt) { color = t.color; break; } }
if (!color) color = thresholds[thresholds.length - 1].color;
}
const fill = h('span' + (color ? '.' + color : ''));
fill.style.width = (ratio * 100).toFixed(1) + '%';
return h('.bar', fill);
}
/** Small inline confidence bar — amber below 0.4 (§4.5). */
export function confidenceBar(conf) {
const c = Math.max(0, Math.min(1, conf || 0));
const fill = h('span' + (c < 0.4 ? '.amber' : ''));
fill.style.width = (c * 100).toFixed(0) + '%';
return h('.conf-bar', fill);
}
/**
* Provenance badge (§4.4 / §6) — ESP32 → SEED → COG → state machine.
* A first-class element, never collapsed. hailo:true marks Hailo-sourced
* inference visually distinct from CPU-only COGs (§6 invariant 5).
*/
export function provenanceBadge({ esp32, seed, cog, hailo } = {}) {
return h('span.prov',
esp32 ? txt(esp32) : null, esp32 ? h('span.arr', '→') : null,
seed ? txt(seed) : null, h('span.arr', '→'),
h(hailo ? 'span.hailo' : 'span', cog || 'cog'),
h('span.arr', '→'), txt('homecore'));
}
/** Tiny inline SVG sparkline. */
export function sparkline(values, { w = 120, hgt = 28, color = 'var(--cyan)' } = {}) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('width', w); svg.setAttribute('height', hgt); svg.setAttribute('class', 'spark');
if (!values || values.length < 2) return svg;
const min = Math.min(...values), max = Math.max(...values), span = max - min || 1;
const step = w / (values.length - 1);
const pts = values.map((v, i) => `${(i * step).toFixed(1)},${(hgt - ((v - min) / span) * (hgt - 4) - 2).toFixed(1)}`).join(' ');
const pl = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
pl.setAttribute('points', pts); pl.setAttribute('fill', 'none');
pl.setAttribute('stroke', color); pl.setAttribute('stroke-width', '1.5');
svg.appendChild(pl);
return svg;
}
export function banner(text, kind = 'amber', extra) {
return h(`.banner.${kind}`, text, extra ? txt(' ') : null, extra || null);
}
export function row(k, v) {
return h('.row', h('span.k', k), v instanceof Node ? v : h('span.v', String(v == null ? '—' : v)));
}
export function kv(pairs) {
const node = h('.kv');
for (const [k, v] of pairs) {
node.appendChild(h('span.k', k));
node.appendChild(v instanceof Node ? v : h('span.v', String(v == null ? '—' : v)));
}
return node;
}
/** Collapsible section. */
export function collapsible(title, contentFn, open = false) {
const wrap = h('.collapsible' + (open ? '.open' : ''));
const head = h('.head', title);
const body = h('div');
wrap.appendChild(head); wrap.appendChild(body);
let built = false;
const toggle = () => {
wrap.classList.toggle('open');
if (wrap.classList.contains('open')) {
if (!built) { body.appendChild(contentFn()); built = true; }
body.classList.remove('hidden');
} else body.classList.add('hidden');
};
head.addEventListener('click', toggle);
if (open) { body.appendChild(contentFn()); built = true; } else body.classList.add('hidden');
return wrap;
}
/** Slide-over panel (§4.4 StateChanged detail). */
export function slideover(title, content) {
const back = h('.slideover-back');
const panel = h('.slideover', h('span.close', { onClick: close }, '✕'), h('h2', title), content);
function close() { back.remove(); panel.remove(); }
back.addEventListener('click', close);
document.body.appendChild(back);
document.body.appendChild(panel);
return { close };
}
/** Lag indicator (§4.1/§4.4 — broadcast channel vs 4096 capacity). */
export function lagIndicator(state, lagged) {
const cls = state === 'open' ? (lagged ? 'warn' : '') : 'err';
const label = state === 'open' ? (lagged ? 'WS lagging — events dropped' : 'WS live') : 'WS offline';
return h('span.lag', h(`span.dot${cls ? '.' + cls : ''}`), h('span.t2', label));
}
export function relTime(iso) {
if (!iso) return '—';
const t = Date.parse(iso);
if (Number.isNaN(t)) return String(iso);
const s = Math.round((Date.now() - t) / 1000);
if (s < 0) return 'in ' + fmtDur(-s);
if (s < 5) return 'just now';
return fmtDur(s) + ' ago';
}
function fmtDur(s) {
if (s < 60) return s + 's';
if (s < 3600) return Math.round(s / 60) + 'm';
if (s < 86400) return Math.round(s / 3600) + 'h';
return Math.round(s / 86400) + 'd';
}
/** Loading + error wrappers panels can await. */
export function loading(label = 'Loading…') { return h('.muted-empty', label); }
export function errorCard(e) { return banner('Unavailable — ' + (e && e.message ? e.message : e), 'red'); }
/** Distinguish "not trained" (null) from "unavailable" (error) — §6 invariant 3. */
export function notTrained(prompt = 'Calibrate to enable') {
return h('span.t3', 'Not trained ', button(prompt, { variant: 'ghost' }));
}
+69
View File
@@ -0,0 +1,69 @@
// HOMECORE-UI WebSocket client — ADR-130 subscribe_events.
//
// "The UI must never poll for entity state" (ADR-131 §2/§4.4). This
// client performs the HA-compat auth handshake then subscribes to
// state_changed events and surfaces broadcast-channel lag against the
// 4,096-event capacity (§4.1/§4.4) — the server emits a lag signal when
// a subscriber falls behind; we also detect gaps in our own delivery.
import { api } from './api.js';
/**
* Connect and stream events.
* @param {(evt) => void} onEvent called with {entity_id, old_state, new_state, event_type}
* @param {(status) => void} onStatus called with {state:'connecting'|'open'|'closed', lagged:bool}
* @returns controller with .close()
*/
export function connect(onEvent, onStatus) {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/websocket`;
let ws, msgId = 1, closedByUs = false, lagged = false;
let retry = 0;
const status = (state) => onStatus && onStatus({ state, lagged });
function open() {
status('connecting');
try { ws = new WebSocket(url); } catch (e) { schedule(); return; }
ws.onmessage = (m) => {
let msg; try { msg = JSON.parse(m.data); } catch { return; }
if (msg.type === 'auth_required') {
ws.send(JSON.stringify({ type: 'auth', access_token: api.token() }));
} else if (msg.type === 'auth_ok') {
retry = 0; status('open');
ws.send(JSON.stringify({ id: msgId++, type: 'subscribe_events', event_type: 'state_changed' }));
} else if (msg.type === 'auth_invalid') {
status('closed');
} else if (msg.type === 'event' && msg.event) {
const e = msg.event;
if (e.event_type === 'state_changed' && e.data) {
onEvent && onEvent({
event_type: 'state_changed',
entity_id: e.data.entity_id,
old_state: e.data.old_state,
new_state: e.data.new_state,
});
} else {
onEvent && onEvent({ event_type: e.event_type, ...e.data });
}
} else if (msg.type === 'lagged' || (msg.type === 'event' && msg.lagged)) {
lagged = true; status('open');
}
};
ws.onclose = () => { if (!closedByUs) schedule(); else status('closed'); };
ws.onerror = () => { try { ws.close(); } catch {} };
}
function schedule() {
status('closed');
retry = Math.min(retry + 1, 6);
const delay = Math.min(500 * 2 ** retry, 15000);
setTimeout(() => { if (!closedByUs) open(); }, delay);
}
open();
return {
close() { closedByUs = true; try { ws && ws.close(); } catch {} },
isLagged: () => lagged,
clearLag() { lagged = false; },
};
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "homecore-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "HOMECORE-UI — operational dashboard for the two-tier Cognitum stack (ADR-131). Zero-dependency vanilla TS/JS + CSS; served by homecore-server at /homecore.",
"scripts": {
"check": "node tests/verify-imports.mjs",
"test": "node tests/verify-imports.mjs && node tests/boot.mjs && node tests/render-smoke.mjs && node tests/interaction.mjs && node tests/prod-errors.mjs && node tests/unit-fixes.mjs",
"bench": "node tests/benchmark.mjs"
}
}
@@ -0,0 +1,54 @@
// Benchmark — ADR-131 §8 / ADR-126 §1.1.
// HOMECORE exists partly because HA's frontend is a ~5 MB Lit bundle
// (ADR-126 §1.1). This benchmark enforces a hard bundle budget and
// measures cold render throughput for all 10 panels.
// Run: node tests/benchmark.mjs
import { install } from './dom-shim.mjs';
install();
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { resolve } from 'node:path';
const ROOT = resolve(import.meta.dirname, '..');
const BUDGET_BYTES = 250 * 1024; // 250 KB total — vs HA's ~5 MB (20× smaller)
function walk(dir) {
let total = 0; const rows = [];
for (const name of readdirSync(dir)) {
if (name === 'tests' || name === 'node_modules') continue;
const p = resolve(dir, name); const s = statSync(p);
if (s.isDirectory()) { const sub = walk(p); total += sub.total; rows.push(...sub.rows); }
else if (/\.(js|css|html|json)$/.test(name)) { total += s.size; rows.push([p.replace(ROOT + '/', ''), s.size]); }
}
return { total, rows };
}
const { total, rows } = walk(ROOT);
rows.sort((a, b) => b[1] - a[1]);
console.log('── Bundle size (uncompressed) ──');
for (const [f, sz] of rows.slice(0, 8)) console.log(` ${(sz / 1024).toFixed(1).padStart(7)} KB ${f}`);
console.log(` ${'-'.repeat(40)}`);
console.log(` ${(total / 1024).toFixed(1).padStart(7)} KB TOTAL across ${rows.length} files`);
console.log(` budget ${(BUDGET_BYTES / 1024).toFixed(0)} KB · HA baseline ~5120 KB · ratio ${(5120 * 1024 / total).toFixed(1)}× smaller`);
// ── render throughput ───────────────────────────────────────────────
const { api } = await import('../js/api.js');
const ctx = { api, navigate() {}, params: { id: 'seed-livingroom-a1' }, onEvent() { return () => {}; }, onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; } };
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
const mods = {};
for (const p of PANELS) mods[p] = (await import(`../js/panels/${p}.js`)).default;
console.log('\n── Cold render throughput (avg of 50 renders each) ──');
let worst = 0;
for (const p of PANELS) {
const N = 50; const t0 = performance.now();
for (let i = 0; i < N; i++) { const root = document.createElement('div'); const c = await mods[p].render(root, ctx); if (typeof c === 'function') c(); }
const ms = (performance.now() - t0) / N;
worst = Math.max(worst, ms);
console.log(` ${ms.toFixed(3).padStart(7)} ms/render ${p}`);
}
console.log('');
let exit = 0;
if (total > BUDGET_BYTES) { console.error(`FAIL — bundle ${(total / 1024).toFixed(1)} KB exceeds ${(BUDGET_BYTES / 1024).toFixed(0)} KB budget`); exit = 1; }
else console.log(`OK — bundle within budget; slowest panel ${worst.toFixed(2)} ms/render`);
process.exit(exit);
@@ -0,0 +1,37 @@
// Boot regression test — exercises the REAL app.js boot + router (not
// just individual panels). Catches the class of bug where start() throws
// before route() runs and the dashboard renders blank.
// Run: node tests/boot.mjs (from the ui/ dir)
import { install } from './dom-shim.mjs';
const { document, window } = install();
globalThis.HOMECORE_UI_DEMO = true; // boot with fixtures (no gateway in tests)
const errs = [];
const origErr = console.error;
console.error = (...a) => { errs.push(a.map(String).join(' ')); };
await import('../js/app.js');
await new Promise((r) => setTimeout(r, 30));
console.error = origErr;
const fails = [];
const content = document.getElementById('hc-content');
const app = document.getElementById('app');
if (!app || app.children.length < 2) fails.push('shell not built (#app should have topnav + shell)');
if (!content) fails.push('#hc-content missing — buildShell did not run');
else if (content.children.length === 0) fails.push('BLANK: dashboard rendered nothing into #hc-content on boot');
if (errs.length) fails.push('console.error during boot: ' + errs.slice(0, 3).join(' | '));
// navigation must re-render the panel
window.location.hash = '#/fleet';
await new Promise((r) => setTimeout(r, 30));
if (!content || content.children.length === 0) fails.push('BLANK after navigating to #/fleet');
// a clean topnav with no dead Cognitum tabs / Cog Store link
const links = app ? app.querySelectorAll('a') : [];
const hrefs = links.map((a) => a.getAttribute('href') || '');
if (hrefs.some((h) => /cognitum\.one\/store/.test(h))) fails.push('Cog Store external link should be removed');
if (fails.length) { console.error('\nFAILED:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — app.js boots, dashboard renders, navigation re-renders, no dead Cog Store link');
@@ -0,0 +1,103 @@
// Minimal DOM shim — enough to *run* the HOMECORE-UI panels under Node
// without jsdom. Installs globals (document, location, localStorage,
// fetch, WebSocket) so render-smoke.mjs can execute every panel and
// assert it builds a real DOM subtree without throwing.
class ClassList {
constructor(el) { this.el = el; this.set = new Set(); }
add(...c) { c.forEach((x) => x && this.set.add(x)); this.sync(); }
remove(...c) { c.forEach((x) => this.set.delete(x)); this.sync(); }
toggle(c, force) { const has = this.set.has(c); const on = force === undefined ? !has : force; if (on) this.set.add(c); else this.set.delete(c); this.sync(); return on; }
contains(c) { return this.set.has(c); }
sync() { this.el._class = [...this.set].join(' '); }
}
class El {
constructor(tag) {
this.tagName = String(tag).toUpperCase();
this.children = [];
this.attrs = {};
this.style = {};
this.listeners = {};
this._class = '';
this.classList = new ClassList(this);
this.parentNode = null;
this.id = '';
this._text = '';
this.disabled = false;
this.value = '';
}
set className(v) { this._class = v || ''; this.classList.set = new Set(String(v || '').split(/\s+/).filter(Boolean)); }
get className() { return this._class; }
set innerHTML(v) { this._html = v; }
get innerHTML() { return this._html || ''; }
set textContent(v) { this._text = v; this.children = []; }
get textContent() { return this._text || this.children.map((c) => c.textContent || c._text || '').join(''); }
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
insertBefore(c, ref) { const i = this.children.indexOf(ref); c.parentNode = this; if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); return c; }
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); c.parentNode = null; return c; }
remove() { if (this.parentNode) this.parentNode.removeChild(this); }
get firstChild() { return this.children[0] || null; }
setAttribute(k, v) { this.attrs[k] = String(v); }
getAttribute(k) { return this.attrs[k] ?? null; }
addEventListener(t, fn) { (this.listeners[t] ||= []).push(fn); }
removeEventListener(t, fn) { this.listeners[t] = (this.listeners[t] || []).filter((f) => f !== fn); }
dispatch(t, detail) { (this.listeners[t] || []).forEach((fn) => fn({ detail, target: this, preventDefault() {}, stopPropagation() {} })); }
_all() { return this.children.flatMap((c) => [c, ...(c._all ? c._all() : [])]); }
matchesSel(sel) {
return sel.split(/\s+/).pop().split('.').every((p, i, arr) => {
if (i === 0 && p && !p.startsWith('.') && !p.startsWith('#')) { if (p.startsWith('.')) {} }
return true;
});
}
querySelector(sel) {
const want = sel.replace(/^.*\s/, '');
const cls = want.startsWith('.') ? want.slice(1) : null;
return this._all().find((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase())) || null;
}
querySelectorAll(sel) {
const want = sel.replace(/^.*\s/, '');
const cls = want.startsWith('.') ? want.slice(1) : null;
return this._all().filter((e) => (cls ? (e.classList && e.classList.contains(cls)) : e.tagName === want.toUpperCase()));
}
}
class TextNode { constructor(t) { this.textContent = String(t); this._text = String(t); this.nodeType = 3; this.parentNode = null; } remove() { if (this.parentNode) this.parentNode.removeChild(this); } }
// Node instanceof checks in ui.js use `instanceof Node`; expose a Node base.
globalThis.Node = El;
// TextNode must also pass `instanceof Node` (ui.js append() treats text via createTextNode).
Object.setPrototypeOf(TextNode.prototype, El.prototype);
const body = new El('body');
const documentObj = {
createElement: (t) => new El(t),
createElementNS: (_ns, t) => new El(t),
createTextNode: (t) => new TextNode(t),
getElementById: (id) => byId[id] || (byId[id] = mkRoot(id)),
body,
readyState: 'complete',
addEventListener() {},
querySelectorAll: () => [],
};
const byId = {};
function mkRoot(id) { const e = new El('div'); e.id = id; return e; }
export function install() {
globalThis.document = documentObj;
globalThis.EventTarget = class { constructor() { this._l = {}; } addEventListener(t, fn) { (this._l[t] ||= []).push(fn); } removeEventListener(t, fn) { this._l[t] = (this._l[t] || []).filter((f) => f !== fn); } dispatchEvent(e) { (this._l[e.type] || []).forEach((fn) => fn(e)); return true; } };
// window with a navigable location.hash that fires `hashchange`.
const win = new globalThis.EventTarget();
let _hash = '';
const loc = { host: 'localhost:8123', protocol: 'http:', get hash() { return _hash; }, set hash(v) { _hash = String(v).startsWith('#') ? String(v) : '#' + v; win.dispatchEvent({ type: 'hashchange' }); } };
win.location = loc;
globalThis.window = win;
globalThis.location = loc;
globalThis.localStorage = { _m: {}, getItem(k) { return this._m[k] ?? null; }, setItem(k, v) { this._m[k] = String(v); } };
globalThis.fetch = () => Promise.reject(new Error('offline (test) — panels fall back to mock per §7.1'));
globalThis.WebSocket = class { constructor() { this.readyState = 0; } send() {} close() {} };
globalThis.CustomEvent = class { constructor(t, o) { this.type = t; this.detail = o && o.detail; } };
return { El, TextNode, body, document: documentObj, window: win, location: loc };
}
export { El, TextNode };
@@ -0,0 +1,86 @@
// Interaction tests — the dynamic behaviours that syntax/render checks
// cannot reach: the live WebSocket entity patch (§4.4 "never poll"), the
// ws.js handshake + event parse (ADR-130), and the calibration backend
// driving the §4.7 wizard. Run: node tests/interaction.mjs
import { install } from './dom-shim.mjs';
install();
globalThis.HOMECORE_UI_DEMO = true; // exercise the demo/calibration fixture path
const fails = [], passes = [];
async function t(name, fn) {
try { await fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
// ── 1. entities panel patches state live over the bus (no polling) ──
await t('entities: live state_changed patches the row in place', async () => {
const entities = (await import('../js/panels/entities.js')).default;
const { api } = await import('../js/api.js');
let handler = null;
const ctx = {
api, navigate() {}, params: {},
onEvent(fn) { handler = fn; return () => {}; },
onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; },
};
const root = document.createElement('div');
await entities.render(root, ctx);
assert(typeof handler === 'function', 'panel must register an onEvent handler (it must not poll)');
const before = root.querySelectorAll('.t1').map((n) => n.textContent);
assert(before.some((x) => x === 'true'), 'living_room_presence should start "true" from the mock fallback');
// Fire a live event; ws.js delivers new_state as a StateView object.
handler({ event_type: 'state_changed', entity_id: 'sensor.living_room_presence', old_state: { state: 'true' }, new_state: { state: 'false' } });
const after = root.querySelectorAll('.t1').map((n) => n.textContent);
assert(after.some((x) => x === 'false'), 'row should now show patched state "false"');
});
// ── 2. ws.js performs the HA-compat handshake and parses events ─────
await t('ws.js: handshake → subscribe_events → parsed event', async () => {
const sent = [];
let inst = null;
globalThis.WebSocket = class { constructor(url) { this.url = url; inst = this; } send(m) { sent.push(JSON.parse(m)); } close() { this.onclose && this.onclose(); } };
const { connect } = await import('../js/ws.js?ws-test');
const got = [], status = [];
const ctrl = connect((e) => got.push(e), (s) => status.push(s));
assert(inst, 'WebSocket should be constructed');
inst.onmessage({ data: JSON.stringify({ type: 'auth_required', ha_version: 'x' }) });
assert(sent[0] && sent[0].type === 'auth' && 'access_token' in sent[0], 'must reply to auth_required with an auth token');
inst.onmessage({ data: JSON.stringify({ type: 'auth_ok', ha_version: 'x' }) });
assert(sent.some((m) => m.type === 'subscribe_events' && m.event_type === 'state_changed'), 'must subscribe_events after auth_ok');
inst.onmessage({ data: JSON.stringify({ type: 'event', event: { event_type: 'state_changed', data: { entity_id: 'light.x', old_state: { state: 'off' }, new_state: { state: 'on' } } } }) });
assert(got.length === 1, 'one event expected');
assert(got[0].entity_id === 'light.x' && got[0].new_state.state === 'on', 'event fields must parse through');
inst.onmessage({ data: JSON.stringify({ type: 'lagged' }) });
assert(ctrl.isLagged(), 'lag signal should set isLagged');
ctrl.close();
});
// ── 3. calibration backend drives the 5-step wizard contract ───────
await t('calibration: start→status→anchor→train contract', async () => {
const { api } = await import('../js/api.js');
const cal = api.calibration;
cal.reset();
const bl = await cal.start();
assert(bl.baseline_id, 'start() returns a baseline_id (the STALE anchor)');
let st;
for (let i = 0; i < 10; i++) { st = await cal.status(); if (st.frames >= st.target) break; }
assert(st.frames >= st.target, 'status() converges to target frames');
for (const label of cal.ANCHORS) await cal.anchor(label);
assert((await cal.enrollStatus()).accepted.length >= 6, 'most anchors accepted after enrollment');
const trained = await cal.train();
assert(trained.presence && trained.anomaly, 'train() returns non-null specialists when enrolled');
cal.reset();
});
console.log(`\n${passes.length} passed, ${fails.length} failed`);
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — live WS patch, ws.js handshake/parse, and calibration contract verified');
@@ -0,0 +1,45 @@
// Production-mode test (ADR-131 §2.2 / §11.11): with demo mode OFF and
// the gateway unreachable, every panel must render a typed empty/error
// state WITHOUT throwing and WITHOUT showing fabricated data.
// Run: node tests/prod-errors.mjs
import { install } from './dom-shim.mjs';
install();
globalThis.HOMECORE_UI_DEMO = false; // PRODUCTION path — no fixtures
// fetch already rejects in the shim → simulates an unreachable gateway.
const fails = [], passes = [];
async function t(name, fn) {
try { await fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
const { api, demoMode } = await import('../js/api.js');
await t('demoMode() is false in production', () => assert(demoMode() === false));
await t('api.anyDemo() is false in production', () => assert(api.anyDemo() === false));
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
const ctx = {
api, navigate() {}, params: { id: 'seed-livingroom-a1' },
onEvent() { return () => {}; },
onWs(fn) { fn({ state: 'closed', lagged: false }); return () => {}; },
};
for (const name of PANELS) {
await t(`prod render (gateway down): ${name} shows a state, never throws`, async () => {
const mod = await import(`../js/panels/${name}.js`);
const root = document.createElement('div');
const cleanup = await mod.default.render(root, ctx);
// must render SOMETHING (header + error/empty state), not crash, not blank
assert(root.children.length > 0, 'panel rendered nothing in prod error mode');
if (typeof cleanup === 'function') cleanup();
});
}
// No data accessor may have flipped a demo flag in production.
await t('no demo flags set after production renders', () => assert(api.anyDemo() === false, 'a panel served mock data in production'));
console.log(`\n${passes.length} passed, ${fails.length} failed`);
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — every panel renders a typed empty/error state in production with no mock fallback');
@@ -0,0 +1,109 @@
// Render-smoke test — actually executes every HOMECORE-UI panel against
// the DOM shim and asserts each builds a non-empty DOM subtree without
// throwing. Also exercises the ui.js helpers and the mock contract.
// Run: node tests/render-smoke.mjs (from the ui/ dir)
import { install } from './dom-shim.mjs';
install();
globalThis.HOMECORE_UI_DEMO = true; // render panels against fixtures
const fails = [];
const passes = [];
function check(name, fn) {
try { fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
async function checkAsync(name, fn) {
try { await fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
const ui = await import('../js/ui.js');
const { api, entityProvenance } = await import('../js/api.js');
const mock = await import('../js/mock.js');
// ── ui.js helper unit checks ────────────────────────────────────────
check('ui.h builds element with class/id', () => {
const n = ui.h('div.card#x', { 'data-k': 'v' }, 'hi');
if (n.tagName !== 'DIV') throw new Error('tag');
if (!n.classList.contains('card')) throw new Error('class');
if (n.id !== 'x') throw new Error('id');
});
check('ui.statusPill maps running→green', () => {
const p = ui.statusPill('running');
if (!p.classList.contains('green')) throw new Error('expected green pill');
});
check('ui.statusPill maps offline→red', () => {
if (!ui.statusPill('offline').classList.contains('red')) throw new Error('expected red');
});
check('ui.bar applies threshold colour', () => {
const b = ui.bar(0.9, 1, [{ lt: 0.3, color: 'green' }, { lt: 0.6, color: 'amber' }, { lt: 1.01, color: 'red' }]);
if (!b.firstChild.classList.contains('red')) throw new Error('expected red fill at 0.9');
});
check('ui.confidenceBar amber under 0.4', () => {
if (!ui.confidenceBar(0.2).firstChild.classList.contains('amber')) throw new Error('low conf should be amber');
});
check('ui.provenanceBadge marks hailo', () => {
const p = ui.provenanceBadge({ esp32: 'e', seed: 's', cog: 'c', hailo: true });
if (!p.querySelector('.hailo')) throw new Error('hailo class missing');
});
check('ui.sparkline yields svg polyline', () => {
const s = ui.sparkline([1, 2, 3, 4]);
if (!s.querySelector('polyline')) throw new Error('no polyline');
});
// ── mock contract checks ────────────────────────────────────────────
check('mock RoomState distinguishes null vs withheld', () => {
const rs = mock.roomStates();
const office = rs.find((r) => r.room_id === 'office');
if (office.posture !== null) throw new Error('office posture should be null (not trained)');
const kitchen = rs.find((r) => r.room_id === 'kitchen');
if (!kitchen.vetoed) throw new Error('kitchen should be vetoed');
if (kitchen.posture.value !== null) throw new Error('vetoed posture value should be null/withheld, not zero');
});
check('analysis covers at least 3 bedrooms', () => {
const beds = mock.roomStates().filter((r) => /^bedroom/.test(r.room_id));
if (beds.length < 3) throw new Error(`expected ≥3 bedrooms in RoomState analysis, got ${beds.length}`);
const bedSeeds = mock.seeds().filter((s) => /bedroom/i.test(s.zone));
if (bedSeeds.length < 3) throw new Error(`expected ≥3 bedroom SEED nodes, got ${bedSeeds.length}`);
});
check('mock fleet has an offline seed with red tint semantics', () => {
if (!mock.seeds().some((s) => !s.online)) throw new Error('need an offline seed for §4.1 tint');
});
check('mock federation states the raw-CSI invariant', () => {
if (!/never raw CSI/i.test(mock.federation().invariant)) throw new Error('invariant text missing');
});
check('entityProvenance derives node→seed chain', () => {
const prov = entityProvenance({ attributes: { source: 'esp32-lr-01 BFLD' } });
if (prov.esp32 !== 'esp32-lr-01') throw new Error('node parse failed');
if (!prov.seed) throw new Error('seed mapping failed');
});
// ── render every panel ──────────────────────────────────────────────
const PANELS = ['dashboard', 'fleet', 'seed-detail', 'entities', 'rooms', 'cogs', 'calibration', 'events', 'audit', 'settings'];
const ctx = {
api,
navigate() {},
params: { id: 'seed-livingroom-a1' },
onEvent() { return () => {}; },
onWs(fn) { fn({ state: 'open', lagged: false }); return () => {}; },
wsStatus: () => ({ state: 'open', lagged: false }),
bus: new globalThis.EventTarget(),
};
for (const name of PANELS) {
await checkAsync(`render panel: ${name}`, async () => {
const mod = await import(`../js/panels/${name}.js`);
const panel = mod.default;
if (!panel || typeof panel.render !== 'function') throw new Error('no default.render export');
if (!panel.meta || !panel.meta.title) throw new Error('missing meta.title');
const root = document.createElement('div');
const cleanup = await panel.render(root, ctx);
if (root.children.length === 0) throw new Error('rendered nothing into root');
if (cleanup && typeof cleanup === 'function') cleanup(); // must not throw
});
}
// ── report ──────────────────────────────────────────────────────────
console.log(`\n${passes.length} passed, ${fails.length} failed`);
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — all ui helpers, mock contracts, and 10 panels render without throwing');
@@ -0,0 +1,101 @@
// Regression tests pinning the ADR-131 PR-1082 review fixes:
// * dashboard renders a not-available state ('—') for null appliance
// metrics — never "null%"/"null°C" (§6 honesty / fabricated-data fix).
// * cogs panel does NOT throw when the gateway forwards a `hef` that is a
// string (or other non-array) instead of an array (crash/robustness fix).
// * cogs Hailo worker pill reflects the real probe, not a hardcoded
// "connected" (§6 honesty fix).
// Run: node tests/unit-fixes.mjs
import { install } from './dom-shim.mjs';
install();
globalThis.HOMECORE_UI_DEMO = false; // production path — no fixtures
const fails = [], passes = [];
async function t(name, fn) {
try { await fn(); passes.push(name); }
catch (e) { fails.push(`${name}: ${e && e.stack ? e.stack.split('\n').slice(0, 3).join(' | ') : e}`); }
}
const assert = (c, m) => { if (!c) throw new Error(m || 'assertion failed'); };
const { api } = await import('../js/api.js');
// Shared ctx; per-test we override the api accessors we need.
function ctxWith(overrides) {
return {
api: Object.assign(Object.create(api), overrides),
navigate() {},
params: {},
onEvent() { return () => {}; },
onWs(fn) { fn({ state: 'closed', lagged: false }); return () => {}; },
};
}
// ── dashboard: null metrics → '—', never "null%"/"null°C" ─────────────
await t('dashboard renders not-available for null hailo metrics (no "null%")', async () => {
const mod = await import('../js/panels/dashboard.js');
const root = document.createElement('div');
const ctx = ctxWith({
appliance: async () => ({
cpu_pct: 12.5, ram_pct: 40.1,
hailo_load_pct: null, hailo_temp_c: null, // the fabricated-data trap
uptime_s: null,
services: [{ name: 'ruview-mcp-brain', port: 9876, status: 'unreachable' }],
event_rate: [], channel_capacity: 4096, channel_lag: 0,
}),
seeds: async () => [],
esp32Warnings: async () => [],
cogs: async () => [],
anyDemo: () => false,
});
const cleanup = await mod.default.render(root, ctx);
const text = root.textContent;
assert(!/null\s*%/.test(text), `dashboard showed "null%": ${text.slice(0, 200)}`);
assert(!/null\s*°C/.test(text), `dashboard showed "null°C": ${text.slice(0, 200)}`);
assert(text.includes('—'), 'dashboard should render the "—" not-available marker for null metrics');
// real values must still concatenate their unit
assert(text.includes('12.5%'), 'real CPU value must still render with its unit');
if (typeof cleanup === 'function') cleanup();
});
// ── cogs: string `hef` must not throw ─────────────────────────────────
await t('cogs does not throw when hef is a string (non-array)', async () => {
const mod = await import('../js/panels/cogs.js');
const root = document.createElement('div');
const ctx = ctxWith({
cogs: async () => [
{ id: 'cog-pose', version: '1.0', arch: 'hailo10', status: 'running', pid: 42,
sha256_verified: true, signature_verified: true, throughput_fps: 30,
hef: 'pose_estimation.hef' }, // STRING, not array — the crash trap
],
cogUpdates: async () => [],
appliance: async () => ({ services: [{ name: 'ruvector-hailo-worker', port: 50051, status: 'running' }] }),
isDemo: () => false,
});
// If asArray() weren't applied, .forEach/.join/.length on a string would throw.
const cleanup = await mod.default.render(root, ctx);
assert(root.children.length > 0, 'cogs rendered nothing');
// The string hef should surface as a single loaded HEF row.
assert(root.textContent.includes('pose_estimation.hef'), 'string hef should render as one HEF entry');
if (typeof cleanup === 'function') cleanup();
});
// ── cogs: Hailo worker pill reflects the real probe, not hardcoded ────
await t('cogs Hailo worker pill is unknown when appliance probe is unavailable', async () => {
const mod = await import('../js/panels/cogs.js');
const root = document.createElement('div');
const ctx = ctxWith({
cogs: async () => [],
cogUpdates: async () => [],
appliance: async () => { throw new Error('appliance upstream down'); }, // probe fails
isDemo: () => false,
});
const cleanup = await mod.default.render(root, ctx);
// statusPill('unknown') → grey pill containing the literal label "unknown".
assert(root.textContent.includes('unknown'), 'worker status should be honestly "unknown" when probe fails');
assert(!/connected/.test(root.textContent), 'worker pill must not fabricate "connected"');
if (typeof cleanup === 'function') cleanup();
});
console.log(`\n${passes.length} passed, ${fails.length} failed`);
if (fails.length) { console.error('\nFAILURES:'); fails.forEach((f) => console.error(' ✗ ' + f)); process.exit(1); }
console.log('OK — dashboard not-available, cogs string-hef + honest worker pill pinned');
@@ -0,0 +1,67 @@
// Static import/export graph verifier for HOMECORE-UI.
// No deps — parses `import { a, b } from './x.js'` against the named
// exports of x.js. Fails if a panel imports a symbol that doesn't exist.
// Run: node tests/verify-imports.mjs (from the ui/ dir)
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
const ROOT = resolve(import.meta.dirname, '..');
const files = [
'js/ui.js', 'js/api.js', 'js/ws.js', 'js/mock.js', 'js/app.js',
...readdirSync(resolve(ROOT, 'js/panels')).filter((f) => f.endsWith('.js')).map((f) => 'js/panels/' + f),
];
function namedExports(src) {
const out = new Set();
// export function/const/class NAME
for (const m of src.matchAll(/export\s+(?:async\s+)?(?:function|const|let|class)\s+([A-Za-z0-9_$]+)/g)) out.add(m[1]);
// export { a, b as c }
for (const m of src.matchAll(/export\s*\{([^}]*)\}/g)) {
for (const part of m[1].split(',')) {
const name = part.trim().split(/\s+as\s+/).pop().trim();
if (name) out.add(name);
}
}
if (/export\s+default/.test(src)) out.add('default');
return out;
}
function imports(src) {
const res = [];
for (const m of src.matchAll(/import\s+([^;]+?)\s+from\s+['"]([^'"]+)['"]/g)) {
const clause = m[1].trim(), spec = m[2];
const names = [];
const named = clause.match(/\{([^}]*)\}/);
if (named) for (const p of named[1].split(',')) { const n = p.trim().split(/\s+as\s+/)[0].trim(); if (n) names.push(n); }
const def = clause.replace(/\{[^}]*\}/, '').replace(/\*\s+as\s+\w+/, '').replace(/,/g, '').trim();
if (def) names.push('default');
if (/\*\s+as\s+/.test(clause)) names.push('*');
res.push({ spec, names });
}
return res;
}
const exportCache = {};
function exportsOf(absPath) {
if (!exportCache[absPath]) exportCache[absPath] = namedExports(readFileSync(absPath, 'utf8'));
return exportCache[absPath];
}
let errors = 0;
for (const rel of files) {
const abs = resolve(ROOT, rel);
const src = readFileSync(abs, 'utf8');
for (const imp of imports(src)) {
if (!imp.spec.startsWith('.')) continue; // skip bare specifiers
const target = resolve(dirname(abs), imp.spec);
let exps;
try { exps = exportsOf(target); } catch { console.error(`${rel}: cannot resolve ${imp.spec}`); errors++; continue; }
for (const n of imp.names) {
if (n === '*') continue;
if (!exps.has(n)) { console.error(`${rel}: imports '${n}' from ${imp.spec} which does not export it`); errors++; }
}
}
}
if (errors) { console.error(`\nFAILED — ${errors} unresolved import(s)`); process.exit(1); }
console.log(`OK — import/export graph consistent across ${files.length} modules`);
+30
View File
@@ -39,7 +39,20 @@ pub const DEFAULT_SAMPLE_RATE_HZ: f64 = 10_000.0;
pub const DEFAULT_F_MOD_HZ: f64 = 1_000.0;
/// Quantise one input sample (T) to a signed ADC code. Returns `(code, saturated)`.
///
/// A **non-finite** input (`NaN` / `±Inf`) is treated as an out-of-range
/// condition: it clamps to code `0` and raises the saturation flag. This is
/// the funnel point that stops the NaN-state-poisoning class — a non-finite
/// physical field (e.g. produced by a degenerate scene with a NaN dipole
/// position) would otherwise coerce silently to code `0` *with the saturation
/// flag clear*, yielding a frame indistinguishable from a legitimate
/// zero-field reading. Flagging it preserves the "every frame is honest about
/// its own validity" contract the proof bundle relies on.
pub fn adc_quantise(b_in_t: f64) -> (i32, bool) {
if !b_in_t.is_finite() {
// Non-finite => not representable on the ±FS scale; mark saturated.
return (0, true);
}
let code_f = (b_in_t / ADC_LSB_T).round();
let max_code = (1_i32 << (ADC_BITS - 1)) - 1; // 32_767 for 16-bit signed
let min_code = -max_code; // symmetric
@@ -153,6 +166,23 @@ mod tests {
}
}
#[test]
fn adc_quantise_flags_non_finite_as_saturated() {
// Security pinning (NaN-state-poisoning guard): a non-finite field
// value must clamp to code 0 AND raise the saturation flag, so the
// pipeline can flag the frame rather than emitting it as a silent,
// indistinguishable zero-field reading. Pre-fix this returned
// (0, false) for NaN — a silent corruption.
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
let (code, sat) = adc_quantise(bad);
assert_eq!(code, 0, "non-finite input {bad} must clamp to code 0");
assert!(sat, "non-finite input {bad} must raise the saturation flag");
}
// A finite in-range value is unaffected (no false positives).
let (_, sat) = adc_quantise(1.0e-7);
assert!(!sat, "a finite in-range value must NOT be flagged saturated");
}
#[test]
fn adc_saturates_above_full_scale() {
let (code_pos, sat_pos) = adc_quantise(20.0e-6);
+76 -3
View File
@@ -51,11 +51,28 @@ impl Pipeline {
/// (sensor × sample) — i.e. `n_samples · scene.sensors.len()` frames
/// in scene-major / sample-minor order.
pub fn run(&self, n_samples: usize) -> Vec<MagFrame> {
let dt = self
// `dt` is derived from caller-supplied config — an external boundary
// (e.g. the WASM `config_json`). A degenerate `f_s_hz == 0` makes
// `1.0 / f_s_hz == +Inf`; a non-finite or non-positive `dt_s` is
// equally hostile. Sanitise before any arithmetic that could panic.
let raw_dt = self
.config
.dt_s
.unwrap_or(1.0 / self.config.digitiser.f_s_hz);
let dt_us = (dt * 1.0e6) as u64;
// Fall back to a 1 µs step (the smallest physically meaningful
// sample interval here) when `dt` is non-finite or non-positive, so
// the run produces well-defined frames instead of garbage / a panic.
let dt = if raw_dt.is_finite() && raw_dt > 0.0 {
raw_dt
} else {
1.0e-6
};
// `dt` is now finite & positive, so `dt * 1e6` is finite. Cap the
// `u64` cast defensively (a huge but finite `dt` could still exceed
// `u64::MAX`) and use `saturating_mul` for the per-sample timestamp so
// a pathological config can never trigger a multiply-with-overflow
// panic (debug / WASM panic=abort) or wrap to a garbage timestamp.
let dt_us = (dt * 1.0e6).min(u64::MAX as f64) as u64;
let nv = NvSensor::new(self.config.sensor);
let mut out: Vec<MagFrame> =
@@ -92,7 +109,7 @@ impl Pipeline {
];
let mut frame = MagFrame::empty(sensor_idx as u16);
frame.t_us = (sample as u64) * dt_us;
frame.t_us = (sample as u64).saturating_mul(dt_us);
frame.b_pt = b_pt;
frame.sigma_pt = sigma_pt;
frame.noise_floor_pt_sqrt_hz = (reading.noise_floor_t_sqrt_hz * 1.0e12) as f32;
@@ -205,6 +222,62 @@ mod tests {
}
}
#[test]
fn degenerate_zero_sample_rate_does_not_panic() {
// Security pinning (panic / DoS guard): an externally-supplied
// `f_s_hz == 0` makes `1/f_s_hz == +Inf`; pre-fix that produced
// `dt_us == u64::MAX`, and `sample * dt_us` panicked with
// "attempt to multiply with overflow" (debug / WASM panic=abort) at
// sample >= 2, or wrapped to a garbage timestamp in release. The
// sanitised `dt` + `saturating_mul` must keep the run finite.
let scene = fixture_scene();
let cfg = PipelineConfig {
digitiser: crate::digitiser::DigitiserConfig {
f_s_hz: 0.0,
f_mod_hz: 1000.0,
},
..PipelineConfig::default()
};
let frames = Pipeline::new(scene, cfg, 42).run(8);
assert_eq!(frames.len(), 8);
for f in &frames {
// Timestamps are monotone-well-defined, not garbage.
assert!(f.t_us < u64::MAX);
}
}
#[test]
fn non_finite_scene_input_flags_frame_instead_of_silently_zeroing() {
// Security pinning (NaN-state-poisoning guard): a NaN dipole position
// makes `r_norm` NaN, which bypasses the near-field clamp
// (`NaN < R_MIN_M` is false) and yields a NaN field. Pre-fix the
// digitiser silently coerced that NaN to code 0 with the saturation
// flag CLEAR — a frame indistinguishable from a real zero-field
// reading. Post-fix the frame must carry ADC_SATURATED so the
// corruption is visible downstream.
let mut scene = Scene::new();
scene.add_dipole(DipoleSource::new([f64::NAN, 0.0, 0.5], [0.0, 0.0, 1.0e-3]));
scene.add_sensor([0.0, 0.0, 0.0]);
let cfg = PipelineConfig {
sensor: NvSensorConfig {
shot_noise_disabled: true,
..NvSensorConfig::default()
},
..PipelineConfig::default()
};
let frames = Pipeline::new(scene, cfg, 0).run(4);
for f in &frames {
assert!(
f.has_flag(flag::ADC_SATURATED),
"non-finite field must raise ADC_SATURATED, not emit a silent zero frame"
);
// And the emitted value is a defined number, not NaN.
for b in f.b_pt {
assert!(b.is_finite());
}
}
}
#[test]
fn adc_saturation_flag_fires_above_full_scale() {
// Place a dipole close enough to drive the field above ±10 µT FS.
-84
View File
@@ -1,84 +0,0 @@
[package]
name = "ruview-swarm"
version = "0.1.0"
edition = "2021"
description = "RuView drone swarm control system — hierarchical-mesh topology, Raft consensus, MARL, CSI sensing integration (ADR-148)"
license = "Apache-2.0"
# Publishing disabled until: (1) PR #862 merges, (2) internal path-deps are
# published in dependency order, (3) export-control sign-off on the ITAR-gated
# coordination features (USML Category VIII(h)(12)). Flip to true deliberately.
publish = false
[features]
default = []
# ITAR/USML Category VIII(h)(12): swarming coordination features.
# Must not be enabled in international distributions without export counsel review.
itar-unrestricted = []
mavlink = ["dep:mavlink"]
ros2-dds = []
onnx = ["dep:ort"]
simulation = []
demo = ["simulation"]
full = ["mavlink", "onnx", "demo", "itar-unrestricted"]
ruflo = ["dep:reqwest", "dep:serde_json"]
# Heavy GPU-capable MARL training (real Candle autodiff PPO). Off by default so
# the default build stays light and the existing test suite keeps passing.
train = ["dep:candle-core", "dep:candle-nn"]
cuda = ["candle-core/cuda", "candle-nn/cuda"]
[dependencies]
wifi-densepose-core = { path = "../wifi-densepose-core" }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", optional = true }
toml = "0.8"
# Async runtime
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
# MAVLink v2 (optional)
mavlink = { version = "0.13", optional = true }
# ONNX Runtime (optional — for MARL actor inference)
ort = { version = "2.0.0-rc.11", optional = true }
# Candle 0.9 — real autodiff PPO training (optional, behind `train` feature).
candle-core = { version = "0.9", default-features = false, optional = true }
candle-nn = { version = "0.9", default-features = false, optional = true }
# HTTP client (optional — for Ruflo HTTP backend)
reqwest = { version = "0.12", features = ["json"], optional = true }
# Crypto — MAVLink v2 HMAC-SHA256 signing
hmac = "0.12"
sha2 = "0.10"
# Error handling
thiserror = "2.0"
# Logging
tracing = "0.1"
# Numerics
nalgebra = "0.33"
rand = "0.8"
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
tokio-test = "0.4"
[[bench]]
name = "swarm_bench"
harness = false
# MARL training binary — requires the `train` feature (Candle autodiff).
# Excluded from the default build so `cargo test`/CI stay light.
[[bin]]
name = "train_marl"
required-features = ["train"]
# ADR-171 Stage-1 evaluation CLI — pure Rust, no special feature needed.
[[bin]]
name = "eval_swarm"
-108
View File
@@ -1,108 +0,0 @@
# wifi-densepose-swarm
Drone swarm control system for the RuView wifi-densepose workspace. Implements ADR-148.
## Overview
`wifi-densepose-swarm` provides a hierarchical-mesh drone swarm coordination system
with Raft consensus, MAPPO-based multi-agent reinforcement learning, and tight
integration with the existing WiFi CSI sensing pipeline (`wifi-densepose-signal`,
`wifi-densepose-ruvector`).
## Features
- **Hierarchical-Mesh Topology** — cluster heads over Raft consensus; inter-cluster Gossip for map dissemination
- **Formation Control** — F1 VirtualStructure, F2 LeaderFollower, F3 Reynolds flocking
- **3-Phase Coverage** — boustrophedon sweep → Bayesian probability grid → multi-drone triangulation
- **RRT-APF Path Planner** — RRT* with Artificial Potential Field reactive collision avoidance
- **MARL Actor (MAPPO)** — 64-dim local observation, 3-layer MLP actor, CTDE training interface
- **CSI Sensing Integration** — drone payload pipeline (ESP32-S3 → Jetson), multi-drone CSI fusion
- **OccWorld Bridge** — integrates ADR-147 OccWorld occupancy prior as path planner environment
- **Security Hardening** — MAVLink v2 HMAC-SHA256 signing, UWB GPS anti-spoofing, onboard geofencing, Remote ID
- **Fail-Safe State Machine** — 10-state onboard safety system, GCS-independent
- **Demo & Training Modes** — synthetic CSI generation, Gazebo/PX4 SITL interface, TOML mission configs
## ITAR Notice
> ⚠️ **Export-controlled capability.** Swarming coordination features (formation control,
> Raft consensus, task allocation) are gated behind the `itar-unrestricted` feature flag
> per **USML Category VIII(h)(12)**. Default builds compile only safe stubs.
> Do not enable `itar-unrestricted` for international distribution without export counsel review.
## Crate Features
| Feature | Description |
|---------|-------------|
| `default` | Core types, sensing, failsafe, config, MARL — no ITAR-gated code |
| `itar-unrestricted` | Enables formation control, Raft consensus, task allocation |
| `mavlink` | MAVLink v2 protocol support |
| `onnx` | ONNX Runtime backend for MARL actor inference (INT8) |
| `simulation` | Simulation-mode stubs |
| `demo` | Synthetic CSI generation, scenario runners |
| `full` | All of the above |
## Quick Start
```rust
use wifi_densepose_swarm::{config::SwarmConfig, demo::scenario::DemoScenario};
// Load a mission profile
let config = SwarmConfig::sar_default();
// Run a demo scenario
let scenario = DemoScenario::sar_rubble_field(4); // 4-drone SAR
let estimated_secs = scenario.estimate_coverage_time_secs();
// → < 240 s for 4 drones over 400×400 m (beyond Wi2SAR SOTA single-drone baseline)
```
## Mission Profiles
| Profile | Drones | Area | Application |
|---------|--------|------|-------------|
| `sar` | 612 | 400×400 m | Structural collapse victim search |
| `inspection` | 36 | Linear corridor | Infrastructure (power lines, bridges) |
| `agriculture` | 412 | Field-configurable | NDVI mapping, variable-rate spraying |
| `mine` | 24 | Tunnel | GPS-denied underground exploration |
| `relay` | 620 | Perimeter | Emergency telecom relay chain |
| `demo` | Any | Configurable | Synthetic CSI, configurable victims |
## Module Structure
```
src/
├── types.rs — NodeId, DroneState, SwarmTask, SwarmError, FailSafeState
├── topology/ — Raft consensus¹, Gossip dissemination, MeshTopology
├── formation/ — VirtualStructure¹, LeaderFollower¹, Reynolds flocking¹
├── planning/ — RRT-APF planner, 3-phase coverage, Bayesian grid, pheromone
├── allocation/ — Auction-based task allocation¹, FNN bid scorer¹
├── sensing/ — CSI payload pipeline, multi-drone fusion, OccWorld bridge
├── marl/ — MAPPO actor, LocalObservation, reward shaping, TrainingConfig
├── security/ — MAVLink signing, UWB anti-spoofing, geofencing, Remote ID
├── failsafe/ — 10-state onboard fail-safe machine
├── config/ — TOML SwarmConfig with mission presets
├── demo/ — Synthetic CSI, DemoScenario runners
├── integration/ — FlightController trait (PX4/ArduPilot/Sim)
└── bench_support.rs — Criterion fixture generators
¹ Requires `itar-unrestricted` feature.
```
## Related ADRs
| ADR | Title | Relation |
|-----|-------|----------|
| ADR-148 | Drone Swarm Control System | This crate |
| ADR-147 | OccWorld Occupancy World Model | Environment prior via `sensing::occworld_bridge` |
| ADR-134 | CSI→CIR ISTA Sparse Recovery | Drone payload sensing |
| ADR-146 | RF Encoder Multitask Heads | Drone payload inference |
| ADR-016 | RuVector Training Integration | CrossViewpointAttention |
## Performance Targets (vs. Wi2SAR SOTA)
| Metric | Wi2SAR baseline (1 drone) | 4-drone target |
|--------|--------------------------|----------------|
| Coverage | 160,000 m² | 160,000 m² |
| Time | 13.5 min | ≤ 4 min |
| Localization | 5 m | ≤ 2 m (3-view fusion) |
| MARL inference | N/A | ≤ 5 ms (INT8, release) |
| Raft election | N/A | ≤ 300 ms |
@@ -1,70 +0,0 @@
use criterion::{criterion_group, criterion_main, Criterion};
use ruview_swarm::marl::{MappoActor, ActorConfig};
use ruview_swarm::marl::LocalObservation;
use ruview_swarm::sensing::MultiViewFusion;
use ruview_swarm::planning::RrtApfPlanner;
use ruview_swarm::demo::{DemoScenario};
use ruview_swarm::types::{CsiDetection, NodeId, Position3D};
fn bench_marl_inference(c: &mut Criterion) {
let actor = MappoActor::random_init(ActorConfig::default());
let obs = LocalObservation::zeros();
c.bench_function("marl_actor_inference", |b| b.iter(|| actor.forward(&obs)));
}
fn bench_rrt_apf_plan(c: &mut Criterion) {
let planner = RrtApfPlanner::new(3.0);
let start = Position3D { x: 0.0, y: 0.0, z: -30.0 };
let goal = Position3D { x: 50.0, y: 50.0, z: -30.0 };
c.bench_function("rrt_apf_100iter", |b| b.iter(|| {
let mut rng = rand::thread_rng();
planner.plan(start, goal, 100, &mut rng)
}));
}
fn bench_multiview_fusion(c: &mut Criterion) {
let fusion = MultiViewFusion::default();
let detections = vec![
CsiDetection { drone_id: NodeId(0), confidence: 0.85, victim_position: Some(Position3D { x: 51.0, y: 49.0, z: 0.0 }), timestamp_ms: 0 },
CsiDetection { drone_id: NodeId(1), confidence: 0.78, victim_position: Some(Position3D { x: 49.0, y: 51.0, z: 0.0 }), timestamp_ms: 0 },
CsiDetection { drone_id: NodeId(2), confidence: 0.92, victim_position: Some(Position3D { x: 50.0, y: 50.0, z: 0.0 }), timestamp_ms: 0 },
];
let positions = vec![
(NodeId(0), Position3D { x: 0.0, y: 0.0, z: -30.0 }),
(NodeId(1), Position3D { x: 100.0, y: 0.0, z: -30.0 }),
(NodeId(2), Position3D { x: 50.0, y: 86.6, z: -30.0 }),
];
c.bench_function("multiview_fusion_3drones", |b| b.iter(|| fusion.fuse(&detections, &positions)));
}
fn bench_demo_coverage_estimate(c: &mut Criterion) {
let scenario = DemoScenario::sar_rubble_field(4);
c.bench_function("demo_coverage_estimate", |b| b.iter(|| scenario.estimate_coverage_time_secs()));
}
fn bench_ppo_update(c: &mut Criterion) {
use ruview_swarm::marl::{MappoActor, ActorConfig, LocalObservation};
use ruview_swarm::marl::training_loop::{ReplayBuffer, Transition, PpoConfig, ppo_update};
use ruview_swarm::marl::actor::ActorAction;
let mut buf = ReplayBuffer::new(64);
for i in 0..64 {
buf.push(Transition {
obs: LocalObservation::zeros(),
action: ActorAction { delta_heading_rad: 0.1, delta_altitude_m: 0.0, speed_ms: 5.0, trigger_csi_scan: true },
reward: if i % 2 == 0 { 10.0 } else { -2.0 },
next_obs: LocalObservation::zeros(),
done: i == 63,
});
}
let cfg = PpoConfig::default();
c.bench_function("ppo_update_64transitions", |b| {
b.iter(|| {
let mut actor = MappoActor::random_init(ActorConfig::default());
ppo_update(&mut actor, &buf, &cfg)
})
});
}
criterion_group!(benches, bench_marl_inference, bench_rrt_apf_plan, bench_multiview_fusion, bench_demo_coverage_estimate, bench_ppo_update);
criterion_main!(benches);
-2
View File
@@ -1,2 +0,0 @@
# ADR-171 evaluation outputs
RESULTS.md is generated by the `eval_swarm` binary.
-26
View File
@@ -1,26 +0,0 @@
# ruview-swarm Evaluation Results (ADR-171 Stage 1, kinematic)
Statistically-rigorous evaluation harness: seeded multi-run rollouts with IQM + 95% stratified-bootstrap confidence intervals (Agarwal et al., NeurIPS 2021).
## Run configuration
- **Stage**: 1 (kinematic, self-contained, deterministic per seed)
- **Episodes per pattern**: 100 (seed × episode matrix)
- **CI method**: 95% stratified bootstrap of the IQM, stratified by seed
- **GDOP**: 2-D geometric dilution of precision at first detection
> **Stage 2 pending**: high-fidelity Gazebo/PX4 SITL evaluation (false-alarm rate, real collision rate on the median seeds) is a follow-on — see ADR-171 §6.1. The collision figures below are a kinematic min-separation proxy, not SITL physics.
## Flight-pattern leaderboard
| Flight pattern | Coverage IQM [95% CI] | Localization (m) IQM [95% CI] | Detection rate | Mean GDOP |
|----------------|-----------------------|-------------------------------|----------------|-----------|
| partitioned_lawnmower | 1.000 [1.000, 1.000] | 7.022 [5.669, 8.379] | 100.0% | 0.000 |
| pheromone | 0.662 [0.652, 0.671] | 4.110 [3.346, 5.141] | 95.0% | 1.598 |
| levy_flight | 0.490 [0.489, 0.491] | 3.523 [2.897, 4.160] | 100.0% | 0.000 |
| boustrophedon | 0.370 [0.370, 0.370] | 2.740 [2.357, 3.207] | 100.0% | 0.000 |
| spiral | 0.336 [0.336, 0.336] | 3.082 [2.678, 3.568] | 100.0% | 0.000 |
| potential_field | 0.254 [0.252, 0.256] | 4.343 [3.489, 5.265] | 100.0% | 0.000 |
| _Wi2SAR (paper baseline)_ | _n/a_ | _5.0 (paper)_ | _n/a_ | _n/a_ |
_Wi2SAR row is the published single-drone localization figure (arxiv 2604.09115), shown paper-to-paper for reference only — it was not re-run through this kinematic harness._
@@ -1,118 +0,0 @@
//! Contract-net (auction) task allocation.
use crate::types::{DroneState, NodeId, SwarmTask, TaskId};
use std::collections::HashMap;
/// A bid submitted by a node for a task.
#[derive(Debug, Clone)]
pub struct Bid {
pub node_id: NodeId,
pub task_id: TaskId,
/// Lower score = more capable/willing. Computed by the bidding node.
pub score: f32,
}
/// Auction-based task allocator.
pub struct AuctionAllocator {
pub pending_tasks: HashMap<TaskId, SwarmTask>,
pub bids: HashMap<TaskId, Vec<Bid>>,
pub timeout_ms: u64,
}
impl AuctionAllocator {
pub fn new(timeout_ms: u64) -> Self {
Self {
pending_tasks: HashMap::new(),
bids: HashMap::new(),
timeout_ms,
}
}
/// Announce a new task (add to pending pool).
pub fn announce_task(&mut self, task: SwarmTask) {
let id = task.id;
self.pending_tasks.insert(id, task);
self.bids.entry(id).or_default();
}
/// Accept a bid for a pending task.
pub fn submit_bid(&mut self, bid: Bid) {
if self.pending_tasks.contains_key(&bid.task_id) {
self.bids.entry(bid.task_id).or_default().push(bid);
}
}
/// Resolve all pending tasks: assign each to the best bidder.
/// Returns a list of (TaskId, winning NodeId) pairs.
pub fn resolve(&mut self) -> Vec<(TaskId, NodeId)> {
let mut results = Vec::new();
let task_ids: Vec<TaskId> = self.pending_tasks.keys().copied().collect();
for task_id in task_ids {
let winner = self
.bids
.get(&task_id)
.and_then(|bids| {
bids.iter()
.min_by(|a, b| {
a.score.partial_cmp(&b.score).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|b| b.node_id)
});
if let Some(winner_id) = winner {
if let Some(task) = self.pending_tasks.get_mut(&task_id) {
task.assigned_to = Some(winner_id);
}
results.push((task_id, winner_id));
self.bids.remove(&task_id);
}
}
// Clean up resolved tasks
for (tid, _) in &results {
self.pending_tasks.remove(tid);
}
results
}
/// Compute a bid score heuristic for a node given a task.
/// Returns a score ∈ [0, ∞): lower is better.
pub fn compute_bid_score(node: &DroneState, task: &SwarmTask) -> f32 {
let dist = node.position.distance_to(&task.target) as f32;
let battery_penalty = (100.0 - node.battery_pct) / 100.0;
let link_penalty = 1.0 - node.link_quality;
let priority_bonus = 1.0 - task.priority.clamp(0.0, 1.0);
dist / 100.0 + battery_penalty * 0.3 + link_penalty * 0.2 + priority_bonus * 0.1
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Position3D, SwarmTask, TaskId, TaskKind};
fn make_task(id: u64) -> SwarmTask {
SwarmTask {
id: TaskId(id),
kind: TaskKind::ReturnToHome,
priority: 0.5,
target: Position3D::zero(),
deadline_ms: None,
assigned_to: None,
}
}
#[test]
fn test_auction_assigns_best_bidder() {
let mut alloc = AuctionAllocator::new(1000);
let task = make_task(1);
alloc.announce_task(task);
alloc.submit_bid(Bid { node_id: NodeId(1), task_id: TaskId(1), score: 0.8 });
alloc.submit_bid(Bid { node_id: NodeId(2), task_id: TaskId(1), score: 0.3 });
let results = alloc.resolve();
assert_eq!(results.len(), 1);
assert_eq!(results[0].1, NodeId(2)); // lower score wins
}
}
@@ -1,97 +0,0 @@
//! Lightweight 3-layer FNN bid scorer — pure Rust, no ONNX required.
/// 3-layer FNN: 5 inputs → 16 hidden (ReLU) → 8 hidden (ReLU) → 1 output (sigmoid).
pub struct FnnScorer {
pub w1: [[f32; 5]; 16],
pub b1: [f32; 16],
pub w2: [[f32; 16]; 8],
pub b2: [f32; 8],
pub w3: [f32; 8],
pub b3: f32,
}
fn relu(x: f32) -> f32 {
x.max(0.0)
}
fn sigmoid(x: f32) -> f32 {
1.0 / (1.0 + (-x).exp())
}
impl FnnScorer {
/// Score a feature vector. Returns sigmoid(output) ∈ [0, 1].
/// Features: [dist_norm, battery_norm, link_quality, csi_confidence, workload_norm]
pub fn score(&self, features: [f32; 5]) -> f32 {
// Layer 1: 5 → 16 (ReLU)
let mut h1 = [0.0f32; 16];
for (i, row) in self.w1.iter().enumerate() {
let z: f32 = row.iter().zip(features.iter()).map(|(w, x)| w * x).sum();
h1[i] = relu(z + self.b1[i]);
}
// Layer 2: 16 → 8 (ReLU)
let mut h2 = [0.0f32; 8];
for (i, row) in self.w2.iter().enumerate() {
let z: f32 = row.iter().zip(h1.iter()).map(|(w, x)| w * x).sum();
h2[i] = relu(z + self.b2[i]);
}
// Layer 3: 8 → 1 (sigmoid)
let z3: f32 = self.w3.iter().zip(h2.iter()).map(|(w, x)| w * x).sum::<f32>() + self.b3;
sigmoid(z3)
}
/// Default weights initialised to a simple identity-like setup.
pub fn default_weights() -> Self {
// Simple: w1 diagonalish, others small constant
// Index needed: diagonal/strided init uses i for both row and column.
let mut w1 = [[0.0f32; 5]; 16];
#[allow(clippy::needless_range_loop)]
for i in 0..5 {
w1[i][i] = 1.0;
}
for row in w1.iter_mut().take(16).skip(5) {
row[0] = 0.1;
}
let mut w2 = [[0.0f32; 16]; 8];
#[allow(clippy::needless_range_loop)]
for i in 0..8 {
w2[i][i * 2] = 1.0;
}
let w3 = [0.125f32; 8];
Self {
w1,
b1: [0.0; 16],
w2,
b2: [0.0; 8],
w3,
b3: 0.0,
}
}
}
impl Default for FnnScorer {
fn default() -> Self {
Self::default_weights()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_score_in_unit_interval() {
let scorer = FnnScorer::default_weights();
let features = [0.3f32, 0.8, 0.9, 0.75, 0.2];
let s = scorer.score(features);
assert!(s >= 0.0 && s <= 1.0, "score {s} out of [0,1]");
}
#[test]
fn test_score_deterministic() {
let scorer = FnnScorer::default_weights();
let f = [0.5f32; 5];
assert_eq!(scorer.score(f), scorer.score(f));
}
}
@@ -1,22 +0,0 @@
//! Task allocation: auction-based and FNN-scored bid evaluation.
//!
// NOTE: Task allocation is ITAR-controlled (USML Category VIII(h)(12)).
// Only available when the `itar-unrestricted` feature is enabled.
#[cfg(feature = "itar-unrestricted")]
pub mod auction;
#[cfg(feature = "itar-unrestricted")]
pub mod fnn;
#[cfg(feature = "itar-unrestricted")]
pub use auction::{AuctionAllocator, Bid};
#[cfg(feature = "itar-unrestricted")]
pub use fnn::FnnScorer;
/// Stub: task allocation is export-controlled. Enable `itar-unrestricted` feature.
#[cfg(not(feature = "itar-unrestricted"))]
pub fn allocate_stub() -> crate::SwarmResult<()> {
Err(crate::SwarmError::Security(
"Task allocation requires itar-unrestricted feature (USML VIII(h)(12))".into(),
))
}
@@ -1,45 +0,0 @@
//! Benchmark support utilities: scenario builders and timing helpers for criterion benchmarks.
use crate::types::{DroneState, NodeId, Position3D, Velocity3D};
/// Generate N drone states arranged in a grid.
pub fn grid_drone_states(n: usize, spacing_m: f64) -> Vec<DroneState> {
let side = (n as f64).sqrt().ceil() as usize;
(0..n)
.map(|i| {
let row = i / side;
let col = i % side;
DroneState {
id: NodeId(i as u32),
position: Position3D {
x: col as f64 * spacing_m,
y: row as f64 * spacing_m,
z: -30.0,
},
velocity: Velocity3D::default(),
heading_rad: 0.0,
altitude_agl_m: 30.0,
battery_pct: 80.0,
link_quality: 0.9,
timestamp_ms: 0,
}
})
.collect()
}
/// Generate N evenly-spaced positions in a circle.
pub fn circle_positions(n: usize, radius_m: f64) -> Vec<(NodeId, Position3D)> {
(0..n)
.map(|i| {
let angle = 2.0 * std::f64::consts::PI * i as f64 / n as f64;
(
NodeId(i as u32),
Position3D {
x: radius_m * angle.cos(),
y: radius_m * angle.sin(),
z: -30.0,
},
)
})
.collect()
}
@@ -1,104 +0,0 @@
//! ADR-171 Stage-1 evaluation CLI.
//!
//! Runs the kinematic eval matrix over every flight pattern (default) and
//! writes a ranked `RESULTS.md` leaderboard. Pure Rust — no special feature
//! flag required, so it builds and runs in default CI.
//!
//! Defaults are intentionally small (10 seeds × 10 episodes) so the run is fast.
//! The full ADR-171 reporting configuration is 10 seeds × 50 episodes — pass
//! `--seeds 10 --episodes 50` for the publication run.
//!
//! ```text
//! cargo run -p ruview-swarm --bin eval_swarm -- \
//! --seeds 10 --episodes 10 --out crates/ruview-swarm/evals/RESULTS.md
//! ```
use std::path::PathBuf;
use ruview_swarm::evals::metrics::AggregateMetrics;
use ruview_swarm::evals::report::render_results_md;
use ruview_swarm::evals::runner::{run_matrix, EvalConfig};
use ruview_swarm::planning::patterns::FlightPattern;
fn main() {
let args: Vec<String> = std::env::args().collect();
let mut seeds = 10usize;
let mut episodes = 10usize;
let mut out = PathBuf::from("crates/ruview-swarm/evals/RESULTS.md");
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--seeds" => {
i += 1;
seeds = args.get(i).and_then(|s| s.parse().ok()).unwrap_or(seeds);
}
"--episodes" => {
i += 1;
episodes = args.get(i).and_then(|s| s.parse().ok()).unwrap_or(episodes);
}
"--out" => {
i += 1;
if let Some(p) = args.get(i) {
out = PathBuf::from(p);
}
}
"--help" | "-h" => {
eprintln!(
"eval_swarm — ADR-171 Stage-1 kinematic evaluator\n\
Usage: eval_swarm [--seeds N] [--episodes M] [--out PATH]\n\
Defaults: --seeds 10 --episodes 10 --out crates/ruview-swarm/evals/RESULTS.md"
);
return;
}
other => {
eprintln!("warning: ignoring unknown argument '{other}'");
}
}
i += 1;
}
eprintln!(
"Running ADR-171 Stage-1 eval: {seeds} seeds × {episodes} episodes \
over {} flight patterns...",
FlightPattern::all().len()
);
let mut rows: Vec<(String, AggregateMetrics)> = Vec::new();
for (idx, pattern) in FlightPattern::all().into_iter().enumerate() {
let mut cfg = EvalConfig::sar_small(pattern);
cfg.seeds = seeds;
cfg.episodes_per_seed = episodes;
let matrix = run_matrix(&cfg);
let agg = AggregateMetrics::from_strata(&matrix, 0x0149 ^ idx as u64);
eprintln!(
" {}: coverage IQM {:.3}, detection {:.0}%",
pattern.name(),
agg.coverage_iqm.point,
agg.detection_rate * 100.0
);
rows.push((pattern.name().to_string(), agg));
}
// Rank by descending coverage point estimate.
rows.sort_by(|a, b| {
b.1.coverage_iqm
.point
.partial_cmp(&a.1.coverage_iqm.point)
.unwrap_or(std::cmp::Ordering::Equal)
});
let md = render_results_md(&rows);
if let Some(parent) = out.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
eprintln!("error: could not create {}: {e}", parent.display());
std::process::exit(1);
}
}
if let Err(e) = std::fs::write(&out, &md) {
eprintln!("error: could not write {}: {e}", out.display());
std::process::exit(1);
}
eprintln!("Wrote {} ({} bytes).", out.display(), md.len());
}
@@ -1,474 +0,0 @@
//! MARL training entry point for ruview-swarm (ADR-148 M4).
//!
//! Real Candle autodiff PPO training loop. Runs on CPU, or CUDA when built
//! with `--features train,cuda` (local RTX 5080 or a GCP L4 instance).
//!
//! Movement is driven by a selectable `FlightPattern` (boustrophedon,
//! partitioned, spiral, pheromone, potential, levy) and reward is shaped by a
//! selectable `LearningPattern` (mappo, ippo, curiosity, meta). This makes each
//! pattern produce visibly distinct trajectories + telemetry instead of every
//! drone clustering on the orchestrator's internal coverage strategy.
//!
//! Usage:
//! cargo run --release -p ruview-swarm --features train,cuda --bin train_marl -- \
//! --episodes 5000 --drones 4 --profile sar \
//! --flight-pattern partitioned --learn-pattern mappo_curiosity \
//! --checkpoint-dir ./marl-checkpoints
//!
//! Right-sizing note: the policy is a 64→128→64 MLP. The bottleneck is
//! environment-rollout throughput, not GPU matmul — an L4 + 16 vCPU beats an
//! 8× A100 box for this workload at ~1/20th the cost. See scripts/gcp/.
use std::collections::HashSet;
use ruview_swarm::config::SwarmConfig;
use ruview_swarm::integration::telemetry::{DroneFrame, TelemetryRecorder};
use ruview_swarm::marl::candle_ppo::{CandlePpoConfig, CandleTrainer};
use ruview_swarm::marl::learning::{shaped_reward, CuriosityModule, LearningPattern};
use ruview_swarm::marl::observation::LocalObservation;
use ruview_swarm::marl::reward::{RewardCalculator, RewardContext};
use ruview_swarm::planning::patterns::{FlightPattern, PatternContext};
use ruview_swarm::types::{DroneState, NodeId, Position3D, Velocity3D};
struct Args {
episodes: usize,
drones: usize,
profile: String,
steps_per_episode: usize,
checkpoint_dir: String,
checkpoint_every: usize,
telemetry: Option<String>,
telemetry_episode: usize,
flight_pattern: String,
learn_pattern: String,
}
impl Default for Args {
fn default() -> Self {
Self {
episodes: 1000,
drones: 4,
profile: "sar".to_string(),
steps_per_episode: 200,
checkpoint_dir: "./marl-checkpoints".to_string(),
checkpoint_every: 100,
telemetry: None,
telemetry_episode: 0,
flight_pattern: "partitioned".to_string(),
learn_pattern: "mappo".to_string(),
}
}
}
fn parse_args() -> Args {
let mut args = Args::default();
let argv: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < argv.len() {
let next = || argv.get(i + 1).cloned().unwrap_or_default();
match argv[i].as_str() {
"--episodes" => {
args.episodes = next().parse().unwrap_or(args.episodes);
i += 1;
}
"--drones" => {
args.drones = next().parse().unwrap_or(args.drones);
i += 1;
}
"--profile" => {
args.profile = next();
i += 1;
}
"--steps" => {
args.steps_per_episode = next().parse().unwrap_or(args.steps_per_episode);
i += 1;
}
"--checkpoint-dir" => {
args.checkpoint_dir = next();
i += 1;
}
"--checkpoint-every" => {
args.checkpoint_every = next().parse().unwrap_or(args.checkpoint_every);
i += 1;
}
"--telemetry" => {
args.telemetry = Some(next());
i += 1;
}
"--telemetry-episode" => {
args.telemetry_episode = next().parse().unwrap_or(args.telemetry_episode);
i += 1;
}
"--flight-pattern" => {
args.flight_pattern = next();
i += 1;
}
"--learn-pattern" => {
args.learn_pattern = next();
i += 1;
}
"-h" | "--help" => {
println!(
"train_marl — ruview-swarm MARL training (ADR-148 M4)\n\
\nOptions:\n \
--episodes N training episodes (default 1000)\n \
--drones N swarm size (default 4)\n \
--profile NAME sar|inspection|mine|agriculture (default sar)\n \
--steps N steps per episode (default 200)\n \
--flight-pattern P boustrophedon|partitioned|spiral|pheromone|potential|levy (default partitioned)\n \
--learn-pattern P mappo|ippo|curiosity|meta (default mappo)\n \
--checkpoint-dir D checkpoint output dir (default ./marl-checkpoints)\n \
--checkpoint-every N save every N episodes (default 100)\n \
--telemetry FILE write JSONL telemetry for viz/swarm_viz.html\n \
--telemetry-episode N which episode's steps to record spatially (default 0)"
);
std::process::exit(0);
}
other => eprintln!("warning: ignoring unknown arg {other}"),
}
i += 1;
}
args
}
fn config_for(profile: &str) -> SwarmConfig {
match profile {
"inspection" => SwarmConfig::inspection_default(),
"mine" => SwarmConfig::mine_default(),
"agriculture" => SwarmConfig::agriculture_default(),
_ => SwarmConfig::wi2sar_reference(),
}
}
/// Map a world coordinate to a grid cell index at `grid_res` metre resolution.
fn cell_of(x: f64, y: f64, grid_res: f64) -> (u32, u32) {
let gx = (x / grid_res).floor().max(0.0) as u32;
let gy = (y / grid_res).floor().max(0.0) as u32;
(gx, gy)
}
/// Mark every grid cell within the drone's circular scan footprint as scanned,
/// returning how many *newly* scanned cells this step contributed.
fn mark_scanned(
scanned: &mut HashSet<(u32, u32)>,
pos: &Position3D,
scan_width_m: f64,
grid_res: f64,
area_w: f64,
area_h: f64,
) -> u32 {
let r = scan_width_m * 0.5;
let cols = (area_w / grid_res).ceil() as i64;
let rows = (area_h / grid_res).ceil() as i64;
let (cx, cy) = cell_of(pos.x, pos.y, grid_res);
let span = (r / grid_res).ceil() as i64;
let mut new_cells = 0u32;
for dgx in -span..=span {
for dgy in -span..=span {
let gx = cx as i64 + dgx;
let gy = cy as i64 + dgy;
if gx < 0 || gy < 0 || gx >= cols || gy >= rows {
continue;
}
// Cell centre in metres.
let mx = (gx as f64 + 0.5) * grid_res;
let my = (gy as f64 + 0.5) * grid_res;
if (mx - pos.x).hypot(my - pos.y) <= r && scanned.insert((gx as u32, gy as u32)) {
new_cells += 1;
}
}
}
new_cells
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = parse_args();
let cfg = config_for(&args.profile);
let flight_pattern = FlightPattern::from_str(&args.flight_pattern);
let learn_pattern = LearningPattern::from_str(&args.learn_pattern);
println!(
"MARL training: profile={} drones={} episodes={} steps/ep={} flight={} learn={} ({})",
args.profile,
args.drones,
args.episodes,
args.steps_per_episode,
flight_pattern.name(),
learn_pattern.name(),
if learn_pattern.centralized_critic() {
"CTDE / centralized critic"
} else {
"independent learners"
}
);
let ppo_cfg = CandlePpoConfig::default();
let mut trainer = CandleTrainer::new(ppo_cfg)?;
println!("device: {:?}", trainer.net.device());
let reward_calc = RewardCalculator::default();
std::fs::create_dir_all(&args.checkpoint_dir).ok();
let area_w = cfg.mission.area_width_m;
let area_h = cfg.mission.area_height_m;
let grid_res = cfg.mission.grid_resolution_m.max(1.0);
let scan_w = cfg.planning.csi_scan_width_m;
let max_speed = cfg.planning.max_speed_ms.max(0.1);
let altitude_z = -cfg.planning.flight_altitude_m;
let total_cells = ((area_w / grid_res).ceil() * (area_h / grid_res).ceil()).max(1.0);
// Synthetic victims placed within the mission area for reward signal.
let victims = vec![
Position3D { x: area_w * 0.2, y: area_h * 0.3, z: 0.0 },
Position3D { x: area_w * 0.6, y: area_h * 0.45, z: 0.0 },
];
// Composite profile label so the viewer header surfaces the active patterns.
let profile_label = format!(
"{} · flight={} · learn={}",
args.profile,
flight_pattern.name(),
learn_pattern.name()
);
// Optional telemetry recorder for the visualizer.
let mut telem = match &args.telemetry {
Some(path) => {
let mut rec = TelemetryRecorder::create(path)?;
rec.meta(&profile_label, args.drones, area_w, area_h, &victims)?;
println!("telemetry → {path} (spatial steps from episode {})", args.telemetry_episode);
Some(rec)
}
None => None,
};
let mut best_return = f32::MIN;
for episode in 0..args.episodes {
// Per-episode curiosity module (count-based novelty over the area).
let mut curiosity = CuriosityModule::new(area_w, area_h, 32, 0.5);
// Build drone states directly so the FlightPattern fully drives motion.
let cols = (args.drones as f64).sqrt().ceil().max(1.0) as usize;
let mut states: Vec<DroneState> = (0..args.drones)
.map(|d| {
let (row, col) = (d / cols, d % cols);
let mut s = DroneState::default_at_origin(NodeId(d as u32));
s.position = Position3D {
x: 10.0 + col as f64 * (area_w / cols as f64),
y: 10.0 + row as f64 * (area_h / cols.max(1) as f64),
z: altitude_z,
};
s.altitude_agl_m = cfg.planning.flight_altitude_m;
s
})
.collect();
// Coverage tracker (shared across drones — total area scanned).
let mut scanned: HashSet<(u32, u32)> = HashSet::new();
// Rolling recent-positions trail for pheromone/potential patterns.
let mut visited: Vec<Position3D> = Vec::with_capacity(256);
// Rollout buffers (flattened across drones).
let mut obs_buf: Vec<LocalObservation> = Vec::new();
let mut action_buf: Vec<[f32; 4]> = Vec::new();
let mut reward_buf: Vec<f32> = Vec::new();
let mut value_buf: Vec<f32> = Vec::new();
let mut done_buf: Vec<bool> = Vec::new();
for step in 0..args.steps_per_episode {
let is_last = step == args.steps_per_episode - 1;
// Snapshot peer positions for this tick (observations + repulsion).
let positions: Vec<(NodeId, Position3D)> =
states.iter().map(|s| (s.id, s.position)).collect();
// Index needed: mutates states[idx] while reading peer positions; borrow constraints.
#[allow(clippy::needless_range_loop)]
for idx in 0..states.len() {
let prev_pos = states[idx].position;
let node_id = states[idx].id;
// Neighbour positions (everyone except this drone).
let neighbors: Vec<(NodeId, Position3D)> = positions
.iter()
.filter(|(id, _)| *id != node_id)
.cloned()
.collect();
let peers: Vec<Position3D> = neighbors.iter().map(|(_, p)| *p).collect();
// Observation from the current (pre-move) state.
let obs =
LocalObservation::from_state_no_grid(&states[idx], &neighbors, None, None);
// --- FlightPattern drives the next waypoint --------------------
let ctx = PatternContext {
drone_id: node_id,
swarm_size: args.drones,
current: prev_pos,
area_w,
area_h,
altitude_z,
scan_width_m: scan_w,
step: step as u64,
visited: &visited,
peers: &peers,
};
let target = flight_pattern.next_target(&ctx);
// Move one tick toward the target at max_speed (no teleport).
let dx = target.x - prev_pos.x;
let dy = target.y - prev_pos.y;
let dist = dx.hypot(dy);
let new_pos = if dist > 1e-9 {
let stepd = dist.min(max_speed);
Position3D {
x: prev_pos.x + dx / dist * stepd,
y: prev_pos.y + dy / dist * stepd,
z: altitude_z,
}
} else {
prev_pos
};
let heading = if dist > 1e-9 { dy.atan2(dx) } else { states[idx].heading_rad };
let moved = prev_pos.distance_to(&new_pos);
// Commit the move to the drone state.
{
let s = &mut states[idx];
s.velocity = Velocity3D {
vx: (new_pos.x - prev_pos.x),
vy: (new_pos.y - prev_pos.y),
vz: 0.0,
};
s.position = new_pos;
s.heading_rad = heading;
s.timestamp_ms = s.timestamp_ms.saturating_add(1000);
}
// Coverage: mark scanned footprint, count new cells.
let new_cells =
mark_scanned(&mut scanned, &new_pos, scan_w, grid_res, area_w, area_h);
// Detection: any victim within the scan footprint.
let detected = victims.iter().any(|v| new_pos.distance_to(v) < scan_w);
// Nearest-neighbour distance (for collision shaping).
let nearest = peers
.iter()
.map(|p| new_pos.distance_to(p))
.fold(f64::MAX, f64::min);
// Base extrinsic reward.
let ctx_r = RewardContext {
state: &states[idx],
new_cells_covered: new_cells,
victim_confirmed: detected,
contributed_to_triangulation: false,
nearest_neighbor_dist: nearest,
geofence_breached: false,
battery_depleted_without_rth: false,
};
let base = reward_calc.compute(&ctx_r);
// Curiosity shaping (only when the learning pattern uses it).
let reward = if learn_pattern.uses_curiosity() {
let bonus = curiosity.visit_bonus(new_pos.x, new_pos.y);
shaped_reward(learn_pattern, base, bonus)
} else {
base
};
let action = [
heading as f32,
states[idx].altitude_agl_m as f32,
(moved / 1.0) as f32,
0.0,
];
obs_buf.push(obs);
action_buf.push(action);
reward_buf.push(reward);
value_buf.push(0.0); // bootstrap value (critic learns this)
done_buf.push(is_last);
// Record the move in the shared visited trail (cap length).
visited.push(new_pos);
}
// Trim the visited trail to the most recent ~200 positions.
if visited.len() > 200 {
let drop = visited.len() - 200;
visited.drain(0..drop);
}
// Record spatial telemetry for the selected episode only.
if let Some(rec) = telem.as_mut() {
if episode == args.telemetry_episode {
let frames: Vec<DroneFrame> = states
.iter()
.map(|s| {
let detected =
victims.iter().any(|v| s.position.distance_to(v) < scan_w);
DroneFrame::from_state(s, detected)
})
.collect();
let coverage = scanned.len() as f64 / total_cells;
let _ = rec.step(episode, step, step as f64, &frames, coverage);
}
}
}
// PPO update on the episode's rollout.
let (advantages, returns) = trainer.compute_gae(&reward_buf, &value_buf, &done_buf);
let old_log_probs = vec![0.0f32; obs_buf.len()];
let (policy_loss, value_loss, _entropy) =
trainer.update(&obs_buf, &action_buf, &advantages, &returns, &old_log_probs)?;
let mean_return = if returns.is_empty() {
0.0
} else {
returns.iter().sum::<f32>() / returns.len() as f32
};
if mean_return > best_return {
best_return = mean_return;
}
// Per-episode training-metric telemetry (every episode).
if let Some(rec) = telem.as_mut() {
let _ = rec.episode(episode, mean_return, policy_loss, value_loss, 0);
}
if episode % 10 == 0 || episode == args.episodes - 1 {
let coverage_pct = scanned.len() as f64 / total_cells * 100.0;
println!(
"ep {:>5}/{} mean_return={:>8.3} best={:>8.3} policy_loss={:>8.4} value_loss={:>8.4} coverage={:>5.1}%",
episode, args.episodes, mean_return, best_return, policy_loss, value_loss, coverage_pct
);
}
// Checkpoint the trained variables periodically.
if args.checkpoint_every > 0 && (episode + 1) % args.checkpoint_every == 0
|| episode == args.episodes - 1
{
let path = format!("{}/marl-ep{}.safetensors", args.checkpoint_dir, episode + 1);
if let Err(e) = trainer.net.varmap().save(&path) {
eprintln!("checkpoint save failed at {path}: {e}");
} else {
println!("checkpoint saved: {path}");
}
}
}
if let Some(rec) = telem.as_mut() {
rec.flush()?;
if let Some(path) = &args.telemetry {
println!("telemetry written: {path} — open viz/swarm_viz.html and load it");
}
}
println!("training complete. best mean_return={best_return:.3}");
Ok(())
}
-207
View File
@@ -1,207 +0,0 @@
//! TOML-based swarm configuration with mission profiles.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwarmConfig {
pub swarm: SwarmParams,
pub formation: FormationConfig,
pub planning: PlanningConfig,
pub security: SecurityConfig,
pub mission: MissionConfig,
pub demo: Option<DemoConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwarmParams {
pub max_agents: usize,
pub cluster_size: usize,
pub raft_election_timeout_ms: u64,
pub raft_heartbeat_ms: u64,
pub gossip_fanout: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormationConfig {
/// "virtual_structure" | "leader_follower" | "reynolds"
pub mode: String,
pub min_separation_m: f64,
pub grid_spacing_m: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanningConfig {
pub flight_altitude_m: f64,
pub max_speed_ms: f64,
/// Wi2SAR validated scan footprint width.
pub csi_scan_width_m: f64,
pub lateral_overlap_pct: f64,
/// P(victim) threshold to trigger Phase 3 convergence.
pub convergence_threshold: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityConfig {
pub mavlink_signing: bool,
pub uwb_antispoofing: bool,
pub uwb_tolerance_m: f64,
pub geofence_hard_margin_m: f64,
pub geofence_soft_margin_m: f64,
/// Remote ID broadcast rate in Hz (FAA/EU requirement: ≥ 1 Hz).
pub remote_id_broadcast_hz: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MissionConfig {
/// "sar" | "inspection" | "agriculture" | "mine" | "relay"
pub profile: String,
pub area_width_m: f64,
pub area_height_m: f64,
pub grid_resolution_m: f64,
pub max_flight_time_mins: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DemoConfig {
pub synthetic_csi: bool,
/// Victim positions in NED [x, y, z].
pub victim_positions: Vec<[f64; 3]>,
pub wind_noise_ms: f64,
pub csi_noise_std: f64,
pub packet_loss_pct: f64,
pub replay_speed: f64,
}
impl SwarmConfig {
pub fn from_toml_str(s: &str) -> Result<Self, toml::de::Error> {
toml::from_str(s)
}
pub fn sar_default() -> Self {
Self {
swarm: SwarmParams {
max_agents: 12,
cluster_size: 4,
raft_election_timeout_ms: 300,
raft_heartbeat_ms: 100,
gossip_fanout: 3,
},
formation: FormationConfig {
mode: "virtual_structure".into(),
min_separation_m: 5.0,
grid_spacing_m: 20.0,
},
planning: PlanningConfig {
flight_altitude_m: 30.0,
max_speed_ms: 8.0,
csi_scan_width_m: 28.0,
lateral_overlap_pct: 20.0,
convergence_threshold: 0.75,
},
security: SecurityConfig {
mavlink_signing: true,
uwb_antispoofing: true,
uwb_tolerance_m: 2.0,
geofence_hard_margin_m: 20.0,
geofence_soft_margin_m: 50.0,
remote_id_broadcast_hz: 1.0,
},
mission: MissionConfig {
profile: "sar".into(),
area_width_m: 500.0,
area_height_m: 500.0,
grid_resolution_m: 5.0,
max_flight_time_mins: 25.0,
},
demo: None,
}
}
pub fn inspection_default() -> Self {
let mut cfg = Self::sar_default();
cfg.mission.profile = "inspection".into();
cfg.planning.flight_altitude_m = 15.0;
cfg.planning.max_speed_ms = 4.0;
cfg.formation.mode = "leader_follower".into();
cfg
}
pub fn agriculture_default() -> Self {
let mut cfg = Self::sar_default();
cfg.mission.profile = "agriculture".into();
cfg.planning.flight_altitude_m = 10.0;
cfg.planning.max_speed_ms = 6.0;
cfg.planning.csi_scan_width_m = 15.0;
cfg.formation.mode = "virtual_structure".into();
cfg.formation.grid_spacing_m = 12.0;
cfg
}
pub fn mine_default() -> Self {
let mut cfg = Self::sar_default();
cfg.mission.profile = "mine".into();
cfg.planning.flight_altitude_m = 5.0;
cfg.planning.max_speed_ms = 2.0;
cfg.security.uwb_antispoofing = true; // GPS-denied: UWB only
cfg
}
/// Wi2SAR reference configuration (400×400 m, 8 m/s, 4 drones) for ADR-148 SOTA benchmark.
/// Produces 223 s coverage estimate — below the 240 s (4-min) SOTA target.
/// Source: Wi2SAR (arxiv 2604.09115): single drone, 160,000 m², 13.5 min.
pub fn wi2sar_reference() -> Self {
let mut cfg = Self::sar_default();
cfg.mission.area_width_m = 400.0;
cfg.mission.area_height_m = 400.0;
cfg.planning.max_speed_ms = 8.0;
cfg.planning.csi_scan_width_m = 28.0;
cfg.planning.lateral_overlap_pct = 20.0;
cfg
}
pub fn demo_default() -> Self {
let mut cfg = Self::sar_default();
cfg.demo = Some(DemoConfig {
synthetic_csi: true,
victim_positions: vec![[50.0, 80.0, 0.0], [150.0, 200.0, 0.0], [300.0, 100.0, 0.0]],
wind_noise_ms: 2.0,
csi_noise_std: 0.05,
packet_loss_pct: 5.0,
replay_speed: 1.0,
});
cfg
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sar_default_serialization() {
let cfg = SwarmConfig::sar_default();
let toml_str = toml::to_string(&cfg).expect("serialize ok");
let parsed = SwarmConfig::from_toml_str(&toml_str).expect("parse ok");
assert_eq!(parsed.mission.profile, "sar");
}
#[test]
fn test_demo_default_has_victims() {
let cfg = SwarmConfig::demo_default();
assert!(cfg.demo.is_some());
assert_eq!(cfg.demo.unwrap().victim_positions.len(), 3);
}
#[test]
fn test_wi2sar_reference_coverage_within_4min() {
use crate::demo::scenario::DemoScenario;
let scenario = DemoScenario {
name: "Wi2SAR Reference".into(),
config: SwarmConfig::wi2sar_reference(),
num_drones: 4,
victims: vec![],
};
let t = scenario.estimate_coverage_time_secs();
assert!(t < 240.0, "4-drone Wi2SAR reference scenario: {}s should be < 240s (4 min SOTA)", t);
}
}
-10
View File
@@ -1,10 +0,0 @@
//! Demo scenario runner — synthetic CSI with configurable victim positions.
//!
//! Wires together a [`SyntheticCsiGenerator`] and pre-built [`DemoScenario`]
//! definitions for rapid scenario validation without real hardware.
pub mod synthetic_csi;
pub mod scenario;
pub use synthetic_csi::SyntheticCsiGenerator;
pub use scenario::{DemoScenario, ScenarioResult};
-150
View File
@@ -1,150 +0,0 @@
//! Pre-built demo scenarios for rapid validation without hardware.
//!
//! Each scenario bundles a [`SwarmConfig`], victim positions, and a
//! [`SyntheticCsiGenerator`] so integration tests can drive a complete
//! swarm sim-loop with one call.
use crate::{
config::SwarmConfig,
types::Position3D,
};
use super::synthetic_csi::SyntheticCsiGenerator;
/// A self-contained demo scenario.
pub struct DemoScenario {
pub name: String,
pub config: SwarmConfig,
pub num_drones: usize,
pub victims: Vec<Position3D>,
}
/// Aggregate results produced after running a scenario.
#[derive(Debug, Clone)]
pub struct ScenarioResult {
pub victims_found: usize,
pub victims_total: usize,
pub coverage_time_secs: f64,
pub localization_error_m: f64,
pub collision_count: u32,
}
impl DemoScenario {
/// Standard SAR rubble-field: 3 victims in a 400 × 400 m area.
pub fn sar_rubble_field(num_drones: usize) -> Self {
Self {
name: "SAR Rubble Field".into(),
config: SwarmConfig::demo_default(),
num_drones,
victims: vec![
Position3D { x: 50.0, y: 80.0, z: 0.0 },
Position3D { x: 150.0, y: 200.0, z: 0.0 },
Position3D { x: 300.0, y: 100.0, z: 0.0 },
],
}
}
/// Open-field search: single victim, easy detection conditions.
pub fn open_field_search(num_drones: usize) -> Self {
Self {
name: "Open Field Search".into(),
config: SwarmConfig::demo_default(),
num_drones,
victims: vec![
Position3D { x: 200.0, y: 150.0, z: 0.0 },
],
}
}
/// Mine/GPS-denied: victims in a narrow corridor, low speed.
pub fn mine_corridor(num_drones: usize) -> Self {
let mut cfg = SwarmConfig::mine_default();
cfg.demo = Some(crate::config::DemoConfig {
synthetic_csi: true,
victim_positions: vec![[30.0, 10.0, -2.0], [80.0, 15.0, -2.0]],
wind_noise_ms: 0.1,
csi_noise_std: 0.08,
packet_loss_pct: 10.0,
replay_speed: 0.5,
});
Self {
name: "Mine Corridor GPS-Denied".into(),
config: cfg,
num_drones,
victims: vec![
Position3D { x: 30.0, y: 10.0, z: -2.0 },
Position3D { x: 80.0, y: 15.0, z: -2.0 },
],
}
}
/// Build a [`SyntheticCsiGenerator`] from this scenario's config and victims.
pub fn make_csi_generator(&self) -> SyntheticCsiGenerator {
let (noise_std, detection_range_m) = self.config.demo.as_ref().map(|d| {
(d.csi_noise_std, self.config.planning.csi_scan_width_m / 2.0)
}).unwrap_or((0.05, 14.0));
SyntheticCsiGenerator::new(self.victims.clone(), noise_std, detection_range_m)
}
/// Analytic estimate of coverage time (seconds) for this scenario.
///
/// Formula: `area / (scan_strip × drones) / speed`
///
/// where `scan_strip = csi_scan_width_m × (1 lateral_overlap / 100)`.
pub fn estimate_coverage_time_secs(&self) -> f64 {
let p = &self.config.planning;
let m = &self.config.mission;
let area = m.area_width_m * m.area_height_m;
let scan_strip = p.csi_scan_width_m * (1.0 - p.lateral_overlap_pct / 100.0);
if scan_strip <= 0.0 || p.max_speed_ms <= 0.0 || self.num_drones == 0 {
return f64::INFINITY;
}
let total_track_m = area / scan_strip;
let per_drone_track = total_track_m / self.num_drones as f64;
per_drone_track / p.max_speed_ms
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sar_scenario_coverage_estimate_within_10min() {
// 4-drone SAR swarm over 500 × 500 m at 8 m/s, 20% overlap, 28 m scan width.
// Analytic upper bound: area / (scan_strip × drones × speed)
// = 250_000 / (22.4 × 4 × 8) ≈ 349 s (< 600 s = 10 min battery limit).
let scenario = DemoScenario::sar_rubble_field(4);
let t = scenario.estimate_coverage_time_secs();
assert!(
t < 600.0,
"4-drone SAR coverage estimate {t:.1} s exceeds 600 s (10 min) battery limit"
);
// Also verify the estimate is positive and finite.
assert!(t > 0.0 && t.is_finite(), "coverage estimate {t} must be positive and finite");
}
#[test]
fn test_open_field_single_victim() {
let scenario = DemoScenario::open_field_search(2);
assert_eq!(scenario.victims.len(), 1);
assert_eq!(scenario.num_drones, 2);
}
#[test]
fn test_mine_scenario_low_speed() {
let scenario = DemoScenario::mine_corridor(2);
assert!(
scenario.config.planning.max_speed_ms <= 3.0,
"mine scenario max speed should be ≤ 3 m/s, got {}",
scenario.config.planning.max_speed_ms
);
}
#[test]
fn test_make_csi_generator_victims_match() {
let scenario = DemoScenario::sar_rubble_field(4);
let gen = scenario.make_csi_generator();
assert_eq!(gen.victims.len(), scenario.victims.len());
}
}
@@ -1,140 +0,0 @@
//! Synthetic CSI generator — simulates WiFi CSI victim detections without hardware.
//!
//! Uses exponential distance decay and configurable Gaussian noise to produce
//! realistic CsiDetection events for scenario testing and demo mode.
use rand::Rng;
use crate::types::{CsiDetection, NodeId, Position3D};
/// Generates synthetic CSI detection events for a set of victim positions.
pub struct SyntheticCsiGenerator {
/// Ground-truth victim positions in NED metres.
pub victims: Vec<Position3D>,
/// Std-dev of additive Gaussian noise on confidence and position estimate.
pub noise_std: f64,
/// Maximum range (metres) at which a drone can detect a victim.
pub detection_range_m: f64,
}
impl SyntheticCsiGenerator {
pub fn new(victims: Vec<Position3D>, noise_std: f64, detection_range_m: f64) -> Self {
Self { victims, noise_std, detection_range_m }
}
/// Attempt to detect a victim from the given drone position.
///
/// Returns the strongest detection within range, or `None` if no victim
/// is within `detection_range_m`. Confidence is modelled as
/// `exp(-dist / range)` plus zero-mean Gaussian noise.
pub fn detect(
&self,
drone_id: NodeId,
drone_pos: &Position3D,
timestamp_ms: u64,
) -> Option<CsiDetection> {
let mut rng = rand::thread_rng();
let mut best: Option<CsiDetection> = None;
for victim in &self.victims {
let dist = drone_pos.distance_to(victim);
if dist >= self.detection_range_m {
continue;
}
// Exponential decay: full confidence at 0 m, ~37% at 1× range
let base_conf = (-dist / self.detection_range_m).exp();
let noise: f64 = rng.gen_range(-self.noise_std..self.noise_std);
let confidence = (base_conf + noise).clamp(0.0, 1.0) as f32;
if confidence <= 0.4 {
continue;
}
// Add positional noise proportional to noise_std
let pos_jitter = self.noise_std * 10.0;
let est_pos = Position3D {
x: victim.x + rng.gen_range(-pos_jitter..pos_jitter),
y: victim.y + rng.gen_range(-pos_jitter..pos_jitter),
z: victim.z,
};
let det = CsiDetection {
drone_id,
confidence,
victim_position: Some(est_pos),
timestamp_ms,
};
// Keep the highest-confidence detection
match &best {
None => best = Some(det),
Some(b) if det.confidence > b.confidence => best = Some(det),
_ => {}
}
}
best
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_close_victim() {
// A victim right on the drone should nearly always return a detection.
// Run 20 trials; at least 15 should detect (0.4 threshold at distance 0).
let gen = SyntheticCsiGenerator::new(
vec![Position3D { x: 0.0, y: 0.0, z: 0.0 }],
0.01,
28.0,
);
let mut hits = 0u32;
for i in 0..20 {
if gen.detect(NodeId(0), &Position3D::zero(), i as u64).is_some() {
hits += 1;
}
}
assert!(hits >= 15, "expected ≥15/20 detections at zero range, got {hits}");
}
#[test]
fn test_detect_beyond_range_returns_none() {
let gen = SyntheticCsiGenerator::new(
vec![Position3D { x: 0.0, y: 0.0, z: 0.0 }],
0.01,
28.0,
);
let far_pos = Position3D { x: 1000.0, y: 1000.0, z: 0.0 };
// All 10 attempts should return None since drone is 1414 m away.
for i in 0..10 {
assert!(
gen.detect(NodeId(0), &far_pos, i).is_none(),
"expected no detection at 1414 m"
);
}
}
#[test]
fn test_best_of_two_victims_returned() {
// Two victims: one very close (high conf), one just at boundary (low conf).
let gen = SyntheticCsiGenerator::new(
vec![
Position3D { x: 1.0, y: 0.0, z: 0.0 }, // close
Position3D { x: 27.0, y: 0.0, z: 0.0 }, // near boundary
],
0.01,
28.0,
);
// Run 10 trials; whenever both return a detection the close one should win.
for i in 0..10 {
if let Some(det) = gen.detect(NodeId(0), &Position3D::zero(), i) {
assert!(
det.confidence >= 0.4,
"returned confidence {:.3} is below threshold",
det.confidence
);
}
}
}
}
-118
View File
@@ -1,118 +0,0 @@
//! Geometric Dilution of Precision (GDOP) for a constellation of observers.
//!
//! GDOP quantifies how observer geometry amplifies measurement error into
//! position-estimate error. Build the geometry matrix `H` of unit
//! line-of-sight (LOS) vectors from each observer to the target, form the
//! normal matrix `HᵀH`, invert it, and take `GDOP = sqrt(trace((HᵀH)⁻¹))`.
//!
//! For the 2-D (x, y) localization case `H` is `N×2` and `HᵀH` is `2×2`, so a
//! closed-form 2×2 inverse suffices (no linear-algebra dependency needed).
//!
//! Lower GDOP = better geometry: observers spread ~120° apart around the target
//! give low GDOP; (near-)collinear observers give a singular/ill-conditioned
//! `HᵀH` → GDOP → ∞.
use crate::types::Position3D;
/// Geometric Dilution of Precision (2-D) for `observers` viewing a `target`.
///
/// Lower = better geometry. A ~120° constellation → low GDOP; collinear → very
/// large (→∞). Returns `None` if fewer than two observers, if any observer is
/// coincident with the target (undefined LOS), or if the geometry is singular
/// / degenerate (collinear) so `HᵀH` is not invertible.
pub fn gdop(observers: &[Position3D], target: &Position3D) -> Option<f64> {
if observers.len() < 2 {
return None;
}
// Accumulate HᵀH directly (2×2 symmetric) from unit LOS vectors.
// Row i of H is the unit vector from target → observer i in (x, y).
let mut a = 0.0; // sum ux*ux
let mut b = 0.0; // sum ux*uy
let mut d = 0.0; // sum uy*uy
for obs in observers {
let dx = obs.x - target.x;
let dy = obs.y - target.y;
let range = (dx * dx + dy * dy).sqrt();
if range < 1e-9 {
// Observer on top of the target → LOS undefined.
return None;
}
let ux = dx / range;
let uy = dy / range;
a += ux * ux;
b += ux * uy;
d += uy * uy;
}
// Determinant of HᵀH = [[a, b], [b, d]].
let det = a * d - b * b;
if det.abs() < 1e-12 {
// Singular: observers are (near-)collinear with the target.
return None;
}
// (HᵀH)⁻¹ = 1/det * [[d, -b], [-b, a]]; trace = (d + a) / det.
let trace_inv = (a + d) / det;
if trace_inv <= 0.0 || !trace_inv.is_finite() {
return None;
}
Some(trace_inv.sqrt())
}
#[cfg(test)]
mod tests {
use super::*;
fn p(x: f64, y: f64) -> Position3D {
Position3D { x, y, z: 0.0 }
}
#[test]
fn test_triangle_lower_than_collinear() {
let target = p(0.0, 0.0);
// Three observers at 120° around the target, radius 10.
let r = 10.0;
let triangle = [
p(r * 0.0_f64.cos(), r * 0.0_f64.sin()),
p(
r * (2.0 * std::f64::consts::PI / 3.0).cos(),
r * (2.0 * std::f64::consts::PI / 3.0).sin(),
),
p(
r * (4.0 * std::f64::consts::PI / 3.0).cos(),
r * (4.0 * std::f64::consts::PI / 3.0).sin(),
),
];
// Three nearly-collinear observers (tiny y perturbation to stay invertible).
let near_collinear = [p(5.0, 0.01), p(10.0, 0.0), p(15.0, 0.01)];
let tri = gdop(&triangle, &target).expect("triangle finite GDOP");
let col = gdop(&near_collinear, &target).expect("near-collinear finite GDOP");
assert!(tri.is_finite(), "triangle GDOP must be finite: {tri}");
assert!(
tri < col,
"120° constellation should have lower GDOP than near-collinear: tri={tri}, col={col}"
);
}
#[test]
fn test_collinear_degenerate() {
let target = p(0.0, 0.0);
// Perfectly collinear observers along +x → singular HᵀH.
let collinear = [p(5.0, 0.0), p(10.0, 0.0), p(20.0, 0.0)];
let g = gdop(&collinear, &target);
assert!(
g.is_none() || g.unwrap() > 1e6,
"perfectly collinear geometry must be None or huge, got {g:?}"
);
}
#[test]
fn test_single_observer_none() {
let target = p(0.0, 0.0);
assert!(gdop(&[p(5.0, 5.0)], &target).is_none());
assert!(gdop(&[], &target).is_none());
}
}
-150
View File
@@ -1,150 +0,0 @@
//! Per-episode and aggregate SAR + MARL metrics (ADR-171 Stage 1).
use crate::evals::stats::{stratified_bootstrap_ci, ConfidenceInterval};
/// Per-episode SAR metrics (Stage 1 kinematic).
#[derive(Debug, Clone)]
pub struct EpisodeMetrics {
/// Fraction of the mission area scanned at least once, in [0, 1].
pub coverage_pct: f64,
/// Localization error (m) of the fused victim estimate; `None` if no detection.
pub localization_error_m: Option<f64>,
/// GDOP of the contributing-drone constellation at detection; `None` if none.
pub gdop_at_detection: Option<f64>,
/// Mission-elapsed seconds to first detection; `None` if no detection.
pub time_to_first_detection_s: Option<f64>,
/// Whether at least one victim was detected this episode.
pub detected: bool,
/// Count of inter-drone proximity violations (kinematic proxy for collisions).
pub collisions: u32,
/// Fraction of scanned area covered by more than one drone, in [0, 1].
pub overlap_ratio: f64,
/// Scalar episodic return (reward-like coverage/detection objective).
pub episodic_return: f64,
}
/// Aggregate over a seed × episode matrix with IQM + 95% bootstrap CIs.
#[derive(Debug, Clone)]
pub struct AggregateMetrics {
pub coverage_iqm: ConfidenceInterval,
/// IQM over detected episodes only (undetected episodes carry no error).
pub localization_iqm: ConfidenceInterval,
pub detection_rate: f64,
pub mean_gdop: f64,
pub return_iqm: ConfidenceInterval,
pub n_episodes: usize,
}
impl AggregateMetrics {
/// Aggregate a seed-stratified matrix of episodes. Each inner `Vec` is one
/// seed's episodes; bootstrap resampling is stratified by seed so the CI
/// reflects between-seed variance (the dominant source per ADR-171).
pub fn from_strata(per_seed: &[Vec<EpisodeMetrics>], boot_seed: u64) -> Self {
const N_BOOT: usize = 1000;
let coverage_strata: Vec<Vec<f64>> = per_seed
.iter()
.map(|s| s.iter().map(|e| e.coverage_pct).collect())
.collect();
let return_strata: Vec<Vec<f64>> = per_seed
.iter()
.map(|s| s.iter().map(|e| e.episodic_return).collect())
.collect();
// Localization: only detected episodes contribute. Keep stratification
// by seed but drop empty strata so the bootstrap doesn't degenerate.
let loc_strata: Vec<Vec<f64>> = per_seed
.iter()
.map(|s| {
s.iter()
.filter_map(|e| e.localization_error_m)
.collect::<Vec<f64>>()
})
.filter(|v: &Vec<f64>| !v.is_empty())
.collect();
let mut detected = 0usize;
let mut total = 0usize;
let mut gdop_sum = 0.0;
let mut gdop_n = 0usize;
for seed in per_seed {
for e in seed {
total += 1;
if e.detected {
detected += 1;
}
if let Some(g) = e.gdop_at_detection {
if g.is_finite() {
gdop_sum += g;
gdop_n += 1;
}
}
}
}
let detection_rate = if total == 0 {
0.0
} else {
detected as f64 / total as f64
};
let mean_gdop = if gdop_n == 0 {
0.0
} else {
gdop_sum / gdop_n as f64
};
AggregateMetrics {
coverage_iqm: stratified_bootstrap_ci(&coverage_strata, N_BOOT, boot_seed),
localization_iqm: stratified_bootstrap_ci(
&loc_strata,
N_BOOT,
boot_seed.wrapping_add(1),
),
detection_rate,
mean_gdop,
return_iqm: stratified_bootstrap_ci(
&return_strata,
N_BOOT,
boot_seed.wrapping_add(2),
),
n_episodes: total,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ep(cov: f64, loc: Option<f64>, ret: f64, detected: bool) -> EpisodeMetrics {
EpisodeMetrics {
coverage_pct: cov,
localization_error_m: loc,
gdop_at_detection: if detected { Some(2.0) } else { None },
time_to_first_detection_s: if detected { Some(10.0) } else { None },
detected,
collisions: 0,
overlap_ratio: 0.1,
episodic_return: ret,
}
}
#[test]
fn test_aggregate_detection_rate_and_shape() {
let per_seed = vec![
vec![
ep(0.8, Some(1.5), 80.0, true),
ep(0.7, None, 70.0, false),
],
vec![
ep(0.9, Some(2.0), 90.0, true),
ep(0.85, Some(1.0), 85.0, true),
],
];
let agg = AggregateMetrics::from_strata(&per_seed, 7);
assert_eq!(agg.n_episodes, 4);
assert!((agg.detection_rate - 0.75).abs() < 1e-9);
assert!(agg.coverage_iqm.lo <= agg.coverage_iqm.point);
assert!(agg.coverage_iqm.point <= agg.coverage_iqm.hi);
assert!(agg.mean_gdop > 0.0);
}
}
-19
View File
@@ -1,19 +0,0 @@
//! ADR-171 statistically-rigorous evaluation harness (Stage 1, kinematic).
//!
//! Produces SAR + MARL metrics over a seeded N-seed × M-episode matrix with
//! IQM + 95% stratified-bootstrap CIs, a (sigma, kappa) CSI-noise sweep, and
//! GDOP-stratified localization error. Generates evals/RESULTS.md.
//!
//! Stage 2 (Gazebo/PX4 SITL high-fidelity, false-alarm + collision rate on the
//! median seeds) is a follow-on — see ADR-171 §6.1.
pub mod gdop;
pub mod stats;
pub mod metrics;
pub mod runner;
pub mod report;
pub use gdop::gdop;
pub use stats::{iqm, stratified_bootstrap_ci, ConfidenceInterval};
pub use metrics::{EpisodeMetrics, AggregateMetrics};
pub use runner::{EvalConfig, NoiseLevel, run_matrix};
pub use report::render_results_md;
-120
View File
@@ -1,120 +0,0 @@
//! RESULTS.md leaderboard generator (ADR-171 Stage 1).
use crate::evals::metrics::AggregateMetrics;
use crate::evals::stats::ConfidenceInterval;
/// Wi2SAR published localization baseline (paper-to-paper), metres.
const WI2SAR_LOCALIZATION_M: f64 = 5.0;
/// Format a CI as `point [lo, hi]` with two decimals.
fn fmt_ci(ci: &ConfidenceInterval) -> String {
format!("{:.3} [{:.3}, {:.3}]", ci.point, ci.lo, ci.hi)
}
/// Render a markdown leaderboard: one row per flight pattern with coverage
/// IQM±CI, localization IQM±CI, detection rate, and mean GDOP — plus the
/// Wi2SAR paper baseline row clearly labelled paper-to-paper.
///
/// `rows` is `(pattern_name, aggregate)`; rows are emitted in the order given,
/// so callers should pre-sort (e.g. by descending coverage point estimate).
pub fn render_results_md(rows: &[(String, AggregateMetrics)]) -> String {
let mut s = String::new();
s.push_str("# ruview-swarm Evaluation Results (ADR-171 Stage 1, kinematic)\n\n");
s.push_str(
"Statistically-rigorous evaluation harness: seeded multi-run rollouts with \
IQM + 95% stratified-bootstrap confidence intervals (Agarwal et al., \
NeurIPS 2021).\n\n",
);
// Run configuration header.
let (n_episodes, n_seeds) = rows
.first()
.map(|(_, a)| {
let n = a.n_episodes;
// Episodes-per-seed isn't stored; report total + leave seed split to caller note.
(n, 0usize)
})
.unwrap_or((0, 0));
s.push_str("## Run configuration\n\n");
s.push_str(&format!(
"- **Stage**: 1 (kinematic, self-contained, deterministic per seed)\n\
- **Episodes per pattern**: {n_episodes} (seed × episode matrix)\n\
- **CI method**: 95% stratified bootstrap of the IQM, stratified by seed\n\
- **GDOP**: 2-D geometric dilution of precision at first detection\n"
));
let _ = n_seeds;
s.push_str(
"\n> **Stage 2 pending**: high-fidelity Gazebo/PX4 SITL evaluation \
(false-alarm rate, real collision rate on the median seeds) is a \
follow-on — see ADR-171 §6.1. The collision figures below are a \
kinematic min-separation proxy, not SITL physics.\n\n",
);
// Leaderboard table.
s.push_str("## Flight-pattern leaderboard\n\n");
s.push_str(
"| Flight pattern | Coverage IQM [95% CI] | Localization (m) IQM [95% CI] | \
Detection rate | Mean GDOP |\n",
);
s.push_str(
"|----------------|-----------------------|-------------------------------|\
----------------|-----------|\n",
);
for (name, agg) in rows {
s.push_str(&format!(
"| {} | {} | {} | {:.1}% | {:.3} |\n",
name,
fmt_ci(&agg.coverage_iqm),
fmt_ci(&agg.localization_iqm),
agg.detection_rate * 100.0,
agg.mean_gdop,
));
}
// Wi2SAR paper baseline row (paper-to-paper, no kinematic re-run).
s.push_str(&format!(
"| _Wi2SAR (paper baseline)_ | _n/a_ | _{:.1} (paper)_ | _n/a_ | _n/a_ |\n",
WI2SAR_LOCALIZATION_M,
));
s.push_str(
"\n_Wi2SAR row is the published single-drone localization figure \
(arxiv 2604.09115), shown paper-to-paper for reference only — it was \
not re-run through this kinematic harness._\n",
);
s
}
#[cfg(test)]
mod tests {
use super::*;
use crate::evals::stats::ConfidenceInterval;
fn agg(cov: f64, det: f64) -> AggregateMetrics {
let ci = |p: f64| ConfidenceInterval { point: p, lo: p - 0.05, hi: p + 0.05 };
AggregateMetrics {
coverage_iqm: ci(cov),
localization_iqm: ci(1.5),
detection_rate: det,
mean_gdop: 2.1,
return_iqm: ci(80.0),
n_episodes: 100,
}
}
#[test]
fn test_render_contains_rows_and_baseline() {
let rows = vec![
("partitioned_lawnmower".to_string(), agg(0.92, 0.95)),
("levy_flight".to_string(), agg(0.40, 0.50)),
];
let md = render_results_md(&rows);
assert!(md.contains("partitioned_lawnmower"));
assert!(md.contains("levy_flight"));
assert!(md.contains("Wi2SAR"));
assert!(md.contains("Stage 2 pending"));
assert!(md.contains("95% stratified bootstrap"));
// Coverage point estimate appears.
assert!(md.contains("0.920"));
}
}
-364
View File
@@ -1,364 +0,0 @@
//! Stage-1 kinematic rollout + seed × episode matrix (ADR-171).
//!
//! A single `run_episode` deterministically drives `drones` drones across a
//! mission area under a chosen [`FlightPattern`], marks coverage on a grid,
//! simulates CSI victim detection perturbed by `(sigma, kappa)` amplitude /
//! von-Mises-phase noise, and computes the GDOP of the contributing-drone
//! constellation at first detection. It is self-contained and seeded — no
//! Candle / training backend required — so it runs in CI by default.
use crate::config::SwarmConfig;
use crate::evals::gdop::gdop;
use crate::evals::metrics::EpisodeMetrics;
use crate::planning::patterns::{FlightPattern, PatternContext};
use crate::types::{NodeId, Position3D};
/// CSI-noise level: amplitude std `sigma` and von-Mises phase concentration `kappa`.
/// Higher `sigma` = noisier amplitude; *lower* `kappa` = noisier phase (more diffuse).
#[derive(Debug, Clone, Copy)]
pub struct NoiseLevel {
pub sigma: f64,
pub kappa: f64,
}
/// One evaluation configuration: a flight pattern + swarm/mission parameters.
#[derive(Debug, Clone)]
pub struct EvalConfig {
pub flight: FlightPattern,
pub config: SwarmConfig,
pub drones: usize,
pub steps: usize,
pub seeds: usize, // ≥10 per ADR-171
pub episodes_per_seed: usize, // e.g. 50
pub victims: Vec<Position3D>,
pub noise: NoiseLevel,
}
impl EvalConfig {
/// A small SAR default suitable for fast CI runs.
pub fn sar_small(flight: FlightPattern) -> Self {
EvalConfig {
flight,
config: SwarmConfig::sar_default(),
drones: 4,
steps: 120,
seeds: 10,
episodes_per_seed: 10,
victims: vec![
Position3D { x: 120.0, y: 90.0, z: 0.0 },
Position3D { x: 320.0, y: 280.0, z: 0.0 },
],
noise: NoiseLevel { sigma: 0.05, kappa: 8.0 },
}
}
}
/// Minimal reproducible LCG → f64 in [0, 1). Self-contained for determinism.
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
Lcg(seed ^ 0xD1B5_4A32_D192_ED03)
}
#[inline]
fn next_u64(&mut self) -> u64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.0
}
#[inline]
fn unit(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
/// Standard-normal sample via BoxMuller (deterministic).
#[inline]
fn normal(&mut self) -> f64 {
let u1 = self.unit().max(1e-12);
let u2 = self.unit();
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
}
}
/// Run one kinematic episode deterministically from `seed`.
///
/// Drives drones step-by-step by the flight pattern, marks a coarse coverage
/// grid, and on the first step a drone comes within scan range of any victim
/// records a fused localization estimate (weighted centroid of contributing
/// drones' per-drone victim estimates, each perturbed by `(sigma, kappa)`
/// noise) and the GDOP of those contributing drones.
pub fn run_episode(cfg: &EvalConfig, seed: u64) -> EpisodeMetrics {
let mut rng = Lcg::new(seed);
let area_w = cfg.config.mission.area_width_m;
let area_h = cfg.config.mission.area_height_m;
let altitude_z = -cfg.config.planning.flight_altitude_m;
let scan_width = cfg.config.planning.csi_scan_width_m.max(1.0);
let min_sep = cfg.config.formation.min_separation_m.max(0.1);
let n = cfg.drones.max(1);
// Coverage grid sized so each cell ~= scan_width.
let gx = ((area_w / scan_width).ceil() as usize).max(1);
let gy = ((area_h / scan_width).ceil() as usize).max(1);
let cell_w = area_w / gx as f64;
let cell_h = area_h / gy as f64;
let mut cover_count = vec![0u32; gx * gy];
// Spread drones along the bottom edge with a small seeded jitter.
let mut positions: Vec<Position3D> = (0..n)
.map(|i| {
let frac = (i as f64 + 0.5) / n as f64;
Position3D {
x: (frac * area_w + (rng.unit() - 0.5) * scan_width).clamp(0.0, area_w),
y: (rng.unit() * scan_width).clamp(0.0, area_h),
z: altitude_z,
}
})
.collect();
// Recent-visit ring buffer for pheromone / potential-field patterns.
let mut visited: Vec<Position3D> = Vec::new();
let max_visited = 32usize;
let scan_range = scan_width; // detect a victim within one scan footprint
let mut collisions = 0u32;
let mut detected = false;
let mut loc_error: Option<f64> = None;
let mut gdop_val: Option<f64> = None;
let mut t_detect: Option<f64> = None;
let dt = step_seconds(cfg);
for step in 0..cfg.steps {
// Advance each drone one waypoint under the pattern.
let snapshot = positions.clone();
for (i, pos) in positions.iter_mut().enumerate() {
let peers: Vec<Position3D> = snapshot
.iter()
.enumerate()
.filter(|(j, _)| *j != i)
.map(|(_, p)| *p)
.collect();
let ctx = PatternContext {
drone_id: NodeId(i as u32),
swarm_size: n,
current: *pos,
area_w,
area_h,
altitude_z,
scan_width_m: scan_width,
step: step as u64,
visited: &visited,
peers: &peers,
};
*pos = cfg.flight.next_target(&ctx);
}
// Mark coverage + record visits.
for pos in &positions {
let cx = ((pos.x / cell_w).floor() as i64).clamp(0, gx as i64 - 1) as usize;
let cy = ((pos.y / cell_h).floor() as i64).clamp(0, gy as i64 - 1) as usize;
cover_count[cy * gx + cx] = cover_count[cy * gx + cx].saturating_add(1);
visited.push(*pos);
}
if visited.len() > max_visited {
let drop = visited.len() - max_visited;
visited.drain(0..drop);
}
// Proximity / collision check (kinematic proxy).
for a in 0..positions.len() {
for b in (a + 1)..positions.len() {
let d = positions[a].distance_to(&positions[b]);
if d < min_sep {
collisions = collisions.saturating_add(1);
}
}
}
// Detection: first step any victim falls within scan range of ≥1 drone,
// fuse a localization estimate from the contributing drones. A single
// contributor still yields a (noisier) estimate; GDOP is only defined
// for the multistatic ≥2-drone case and is `None` otherwise.
if !detected {
for victim in &cfg.victims {
let contributors: Vec<Position3D> = positions
.iter()
.filter(|p| horiz_dist(p, victim) <= scan_range)
.copied()
.collect();
if !contributors.is_empty() {
let (est, g) = fuse_estimate(&contributors, victim, cfg.noise, &mut rng);
loc_error = Some(horiz_dist(&est, victim));
gdop_val = g; // None for a single contributor
t_detect = Some((step as f64 + 1.0) * dt);
detected = true;
break;
}
}
}
}
// Coverage + overlap.
let total_cells = (gx * gy) as f64;
let scanned = cover_count.iter().filter(|&&c| c > 0).count() as f64;
let overlapped = cover_count.iter().filter(|&&c| c > 1).count() as f64;
let coverage_pct = if total_cells > 0.0 { scanned / total_cells } else { 0.0 };
let overlap_ratio = if scanned > 0.0 { overlapped / scanned } else { 0.0 };
// Episodic return: reward coverage + detection, penalize overlap + collisions.
let detect_bonus = if detected { 1.0 } else { 0.0 };
let loc_term = match loc_error {
Some(e) => (1.0 / (1.0 + e)).max(0.0),
None => 0.0,
};
let episodic_return = 100.0 * coverage_pct + 30.0 * detect_bonus + 20.0 * loc_term
- 10.0 * overlap_ratio
- 5.0 * collisions as f64;
EpisodeMetrics {
coverage_pct,
localization_error_m: loc_error,
gdop_at_detection: gdop_val,
time_to_first_detection_s: t_detect,
detected,
collisions,
overlap_ratio,
episodic_return,
}
}
/// Per-step wall-clock seconds, derived from scan width and drone speed.
fn step_seconds(cfg: &EvalConfig) -> f64 {
let speed = cfg.config.planning.max_speed_ms.max(0.1);
(cfg.config.planning.csi_scan_width_m.max(1.0) / speed).max(0.1)
}
/// Horizontal (x, y) distance, ignoring altitude.
fn horiz_dist(a: &Position3D, b: &Position3D) -> f64 {
(a.x - b.x).hypot(a.y - b.y)
}
/// Fuse contributing drones' per-drone victim estimates into a weighted
/// centroid, perturbed by `(sigma, kappa)` CSI noise, and compute the GDOP of
/// the contributing constellation.
fn fuse_estimate(
contributors: &[Position3D],
victim: &Position3D,
noise: NoiseLevel,
rng: &mut Lcg,
) -> (Position3D, Option<f64>) {
// Phase noise std from von Mises concentration: sigma_phase ≈ 1/sqrt(kappa).
let phase_std = 1.0 / noise.kappa.max(1e-3).sqrt();
let mut sx = 0.0;
let mut sy = 0.0;
let mut wsum = 0.0;
for c in contributors {
let range = horiz_dist(c, victim).max(1e-6);
// Each drone's estimate = true victim + range-scaled amplitude noise +
// bearing error from phase noise (perpendicular to LOS).
let amp = noise.sigma * range;
let nx = rng.normal() * amp;
let ny = rng.normal() * amp;
// Bearing wobble: rotate LOS unit vector by a small phase-noise angle.
let bearing = (victim.y - c.y).atan2(victim.x - c.x);
let dtheta = rng.normal() * phase_std;
let bx = range * (bearing + dtheta).cos();
let by = range * (bearing + dtheta).sin();
let est_x = c.x + bx + nx;
let est_y = c.y + by + ny;
// Inverse-range weighting: closer drones trusted more.
let w = 1.0 / range;
sx += est_x * w;
sy += est_y * w;
wsum += w;
}
let w = wsum.max(1e-9);
let est = Position3D { x: sx / w, y: sy / w, z: 0.0 };
let g = gdop(contributors, victim);
(est, g)
}
/// Run the full seed × episode matrix → per-seed strata of [`EpisodeMetrics`].
pub fn run_matrix(cfg: &EvalConfig) -> Vec<Vec<EpisodeMetrics>> {
(0..cfg.seeds)
.map(|s| {
(0..cfg.episodes_per_seed)
.map(|e| {
// Distinct deterministic seed per (seed, episode) cell.
let cell_seed = (s as u64)
.wrapping_mul(0x100_0000)
.wrapping_add(e as u64)
.wrapping_add(0xABCD);
run_episode(cfg, cell_seed)
})
.collect()
})
.collect()
}
/// Standard ADR-171 noise sweep grid: cartesian product of σ × κ levels.
pub fn default_noise_sweep() -> Vec<NoiseLevel> {
let sigmas = [0.02, 0.05, 0.10];
let kappas = [16.0, 8.0, 4.0];
let mut out = Vec::with_capacity(sigmas.len() * kappas.len());
for &sigma in &sigmas {
for &kappa in &kappas {
out.push(NoiseLevel { sigma, kappa });
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_run_episode_deterministic() {
let cfg = EvalConfig::sar_small(FlightPattern::PartitionedLawnmower);
let a = run_episode(&cfg, 12345);
let b = run_episode(&cfg, 12345);
assert_eq!(a.coverage_pct, b.coverage_pct);
assert_eq!(a.detected, b.detected);
assert_eq!(a.localization_error_m, b.localization_error_m);
assert_eq!(a.collisions, b.collisions);
assert_eq!(a.episodic_return, b.episodic_return);
}
#[test]
fn test_partitioned_beats_levy_coverage() {
let mut part = EvalConfig::sar_small(FlightPattern::PartitionedLawnmower);
part.seeds = 3;
part.episodes_per_seed = 5;
let mut levy = part.clone();
levy.flight = FlightPattern::LevyFlight;
let part_m = run_matrix(&part);
let levy_m = run_matrix(&levy);
let part_agg = crate::evals::metrics::AggregateMetrics::from_strata(&part_m, 1);
let levy_agg = crate::evals::metrics::AggregateMetrics::from_strata(&levy_m, 1);
assert!(
part_agg.coverage_iqm.point > levy_agg.coverage_iqm.point,
"partitioned coverage {} should beat levy {}",
part_agg.coverage_iqm.point,
levy_agg.coverage_iqm.point
);
}
#[test]
fn test_matrix_shape() {
let mut cfg = EvalConfig::sar_small(FlightPattern::Spiral);
cfg.seeds = 4;
cfg.episodes_per_seed = 6;
let m = run_matrix(&cfg);
assert_eq!(m.len(), 4);
assert!(m.iter().all(|s| s.len() == 6));
}
#[test]
fn test_noise_sweep_grid() {
let sweep = default_noise_sweep();
assert_eq!(sweep.len(), 9);
}
}
-203
View File
@@ -1,203 +0,0 @@
//! Hand-rolled robust statistics for the evaluation harness (Agarwal 2021).
//!
//! Implements the interquartile mean (IQM), a 95% stratified-bootstrap
//! confidence interval of the IQM, and the probability-of-improvement metric —
//! the three statistics recommended by "Deep RL at the Edge of the
//! Statistical Precipice" (Agarwal et al., NeurIPS 2021) for reporting
//! few-seed RL results.
//!
//! All randomness comes from a local linear-congruential generator (LCG) seeded
//! explicitly, so every CI is fully reproducible — no `thread_rng`, no clock.
/// Interquartile mean: mean of the middle 50% of samples (drop the bottom 25%
/// and the top 25%). Robust to outliers in either tail.
///
/// Small-N behaviour: with fewer than 4 samples the trim would empty the set,
/// so it falls back to the plain arithmetic mean. An empty slice returns 0.0.
pub fn iqm(samples: &[f64]) -> f64 {
if samples.is_empty() {
return 0.0;
}
if samples.len() < 4 {
return samples.iter().sum::<f64>() / samples.len() as f64;
}
let mut sorted = samples.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let n = sorted.len();
let lo = n / 4; // trim bottom 25%
let hi = n - lo; // trim top 25% (symmetric)
let mid = &sorted[lo..hi];
if mid.is_empty() {
return sorted.iter().sum::<f64>() / n as f64;
}
mid.iter().sum::<f64>() / mid.len() as f64
}
/// A point estimate with its lower / upper 95% confidence bounds.
#[derive(Debug, Clone, Copy)]
pub struct ConfidenceInterval {
pub point: f64,
pub lo: f64,
pub hi: f64,
}
/// Minimal reproducible LCG (Numerical Recipes constants) yielding f64 in [0,1).
struct Lcg(u64);
impl Lcg {
fn new(seed: u64) -> Self {
// Avoid a zero state collapsing the generator.
Lcg(seed ^ 0x9E37_79B9_7F4A_7C15)
}
#[inline]
fn next_u64(&mut self) -> u64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
self.0
}
/// Uniform index in [0, n).
#[inline]
fn index(&mut self, n: usize) -> usize {
if n == 0 {
return 0;
}
(self.next_u64() >> 11) as usize % n
}
}
/// 95% stratified-bootstrap CI of the IQM.
///
/// `strata` groups samples (one inner `Vec` per stratum, e.g. per task or per
/// seed). Each bootstrap replicate resamples WITH replacement *within* each
/// stratum (preserving the stratum sizes), pools all resampled values, and
/// recomputes the IQM. Repeat `n_boot` times and take the 2.5 / 97.5
/// percentiles for the CI bounds. The `point` estimate is the IQM of the pooled
/// original samples. Deterministic for a fixed `seed`.
pub fn stratified_bootstrap_ci(
strata: &[Vec<f64>],
n_boot: usize,
seed: u64,
) -> ConfidenceInterval {
let pooled: Vec<f64> = strata.iter().flatten().copied().collect();
let point = iqm(&pooled);
if pooled.is_empty() || n_boot == 0 {
return ConfidenceInterval { point, lo: point, hi: point };
}
let mut rng = Lcg::new(seed);
let mut replicates = Vec::with_capacity(n_boot);
let mut buf: Vec<f64> = Vec::with_capacity(pooled.len());
for _ in 0..n_boot {
buf.clear();
for stratum in strata {
let m = stratum.len();
for _ in 0..m {
buf.push(stratum[rng.index(m)]);
}
}
replicates.push(iqm(&buf));
}
replicates.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let lo = percentile(&replicates, 2.5);
let hi = percentile(&replicates, 97.5);
ConfidenceInterval { point, lo, hi }
}
/// Linear-interpolated percentile of a pre-sorted slice. `p` in [0, 100].
fn percentile(sorted: &[f64], p: f64) -> f64 {
if sorted.is_empty() {
return 0.0;
}
if sorted.len() == 1 {
return sorted[0];
}
let rank = (p / 100.0) * (sorted.len() as f64 - 1.0);
let lo = rank.floor() as usize;
let hi = rank.ceil() as usize;
if lo == hi {
return sorted[lo];
}
let frac = rank - lo as f64;
sorted[lo] * (1.0 - frac) + sorted[hi] * frac
}
/// Probability of improvement: P(a-sample > b-sample) over all pairs (Agarwal).
///
/// Counts each (a_i, b_j) pair where `a_i > b_j` as 1, a tie as 0.5, and
/// normalizes by the pair count. 1.0 means `a` strictly dominates; ~0.5 means
/// the two are statistically indistinguishable. Returns 0.5 if either is empty.
pub fn probability_of_improvement(a: &[f64], b: &[f64]) -> f64 {
if a.is_empty() || b.is_empty() {
return 0.5;
}
let mut wins = 0.0;
for &ai in a {
for &bj in b {
if ai > bj {
wins += 1.0;
} else if (ai - bj).abs() < f64::EPSILON {
wins += 0.5;
}
}
}
wins / (a.len() as f64 * b.len() as f64)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_iqm_trims_outliers() {
// 0..=100 plus one extreme outlier; IQM should sit near the middle (~50),
// not be dragged toward 1e9.
let mut samples: Vec<f64> = (0..=100).map(|i| i as f64).collect();
samples.push(1e9);
let v = iqm(&samples);
assert!(
(40.0..=60.0).contains(&v),
"IQM should be near the middle-50% mean (~50), got {v}"
);
}
#[test]
fn test_iqm_small() {
// Fewer than 4 samples → plain mean.
assert_eq!(iqm(&[2.0, 4.0]), 3.0);
assert_eq!(iqm(&[10.0]), 10.0);
assert_eq!(iqm(&[1.0, 2.0, 3.0]), 2.0);
assert_eq!(iqm(&[]), 0.0);
}
#[test]
fn test_bootstrap_ci_brackets_point() {
let strata = vec![
vec![1.0, 2.0, 3.0, 4.0, 5.0],
vec![2.0, 3.0, 4.0, 5.0, 6.0],
];
let ci = stratified_bootstrap_ci(&strata, 500, 42);
assert!(ci.lo <= ci.point, "lo ≤ point: {} ≤ {}", ci.lo, ci.point);
assert!(ci.point <= ci.hi, "point ≤ hi: {} ≤ {}", ci.point, ci.hi);
// Deterministic: same seed → identical interval.
let ci2 = stratified_bootstrap_ci(&strata, 500, 42);
assert_eq!(ci.point, ci2.point);
assert_eq!(ci.lo, ci2.lo);
assert_eq!(ci.hi, ci2.hi);
}
#[test]
fn test_prob_improvement_obvious() {
assert_eq!(
probability_of_improvement(&[10.0, 10.0, 10.0], &[0.0, 0.0, 0.0]),
1.0
);
// Identical samples → all ties → 0.5.
let poi = probability_of_improvement(&[5.0, 5.0], &[5.0, 5.0]);
assert!((poi - 0.5).abs() < 1e-9, "symmetric ties → ~0.5, got {poi}");
}
}
-191
View File
@@ -1,191 +0,0 @@
//! Fail-safe state machine: link loss, low battery, collision avoidance.
use crate::types::DroneState;
use serde::{Deserialize, Serialize};
use std::time::Instant;
/// Fail-safe operating state.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FailSafeState {
Nominal,
AutonomousHold,
LowBatteryWarn,
ReturnToHome,
EmergencyLand,
EmergencyDiverge,
ControlledDescent,
}
/// State machine driving fail-safe transitions.
pub struct FailSafeMachine {
state: FailSafeState,
link_loss_start: Option<Instant>,
pub link_loss_hold_secs: f64,
pub link_loss_rth_secs: f64,
pub battery_warn_pct: f32,
pub battery_rth_pct: f32,
pub collision_dist_m: f64,
}
impl FailSafeMachine {
pub fn new() -> Self {
Self {
state: FailSafeState::Nominal,
link_loss_start: None,
link_loss_hold_secs: 3.0,
link_loss_rth_secs: 30.0,
battery_warn_pct: 20.0,
battery_rth_pct: 15.0,
collision_dist_m: 1.5,
}
}
/// Drive one tick. Returns the current state after evaluation.
pub fn tick(
&mut self,
state: &DroneState,
link_alive: bool,
nearest_neighbor_dist: f64,
) -> FailSafeState {
// Collision avoidance has highest priority.
//
// Fail CLOSED on a non-finite neighbour distance. `nearest_neighbor_dist`
// is derived from peer positions (see
// `SwarmOrchestrator::nearest_peer_distance`), which arrive over the
// untrusted swarm comm layer as `DroneState` values whose f64 position
// fields can deserialize to NaN/Inf. A naive `NaN < collision_dist_m`
// evaluates to `false`, silently DISABLING collision avoidance — the
// worst possible failure for a physical drone. Treat a non-finite
// distance as "too close" so the swarm diverges rather than trusting a
// poisoned reading.
if !nearest_neighbor_dist.is_finite() || nearest_neighbor_dist < self.collision_dist_m {
self.state = FailSafeState::EmergencyDiverge;
return self.state.clone();
}
// Link loss handling
if !link_alive {
let start = self.link_loss_start.get_or_insert_with(Instant::now);
let elapsed = start.elapsed().as_secs_f64();
if elapsed > self.link_loss_rth_secs {
self.state = FailSafeState::ReturnToHome;
} else if elapsed > self.link_loss_hold_secs {
self.state = FailSafeState::AutonomousHold;
}
return self.state.clone();
} else {
// Link restored
self.link_loss_start = None;
if self.state == FailSafeState::AutonomousHold {
self.state = FailSafeState::Nominal;
}
}
// Battery checks. A non-finite battery reading (NaN/Inf from a corrupt or
// forged telemetry/peer message) must fail CLOSED: `NaN <= threshold` is
// `false`, which would otherwise let a drone with an unknown battery
// level keep flying nominally. Treat a non-finite reading as critical.
if !state.battery_pct.is_finite() || state.battery_pct <= self.battery_rth_pct {
self.state = FailSafeState::ReturnToHome;
} else if state.battery_pct <= self.battery_warn_pct {
self.state = FailSafeState::LowBatteryWarn;
} else if self.state == FailSafeState::LowBatteryWarn {
// Recovered from low battery (charged on the fly / wrong reading)
self.state = FailSafeState::Nominal;
}
self.state.clone()
}
pub fn current(&self) -> &FailSafeState {
&self.state
}
pub fn force_land(&mut self) {
self.state = FailSafeState::EmergencyLand;
}
}
impl Default for FailSafeMachine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::NodeId;
fn good_state() -> DroneState {
let mut s = DroneState::default_at_origin(NodeId(1));
s.battery_pct = 80.0;
s.link_quality = 1.0;
s
}
#[test]
fn test_nominal_when_healthy() {
let mut fsm = FailSafeMachine::new();
let s = good_state();
let result = fsm.tick(&s, true, 10.0);
assert_eq!(result, FailSafeState::Nominal);
}
#[test]
fn test_low_battery_warn() {
let mut fsm = FailSafeMachine::new();
let mut s = good_state();
s.battery_pct = 18.0;
let result = fsm.tick(&s, true, 10.0);
assert_eq!(result, FailSafeState::LowBatteryWarn);
}
#[test]
fn test_battery_rth() {
let mut fsm = FailSafeMachine::new();
let mut s = good_state();
s.battery_pct = 10.0;
let result = fsm.tick(&s, true, 10.0);
assert_eq!(result, FailSafeState::ReturnToHome);
}
#[test]
fn test_collision_avoidance() {
let mut fsm = FailSafeMachine::new();
let s = good_state();
let result = fsm.tick(&s, true, 0.5); // too close
assert_eq!(result, FailSafeState::EmergencyDiverge);
}
/// Security: a NaN neighbour distance (poisoned peer position over the swarm
/// comm layer) must NOT silently disable collision avoidance. Fails on old
/// code where `NaN < collision_dist_m` is `false` and the state stays Nominal.
#[test]
fn test_nan_neighbor_distance_fails_closed_to_diverge() {
let mut fsm = FailSafeMachine::new();
let s = good_state();
let result = fsm.tick(&s, true, f64::NAN);
assert_eq!(
result,
FailSafeState::EmergencyDiverge,
"non-finite neighbour distance must fail closed to EmergencyDiverge"
);
}
/// Security: a NaN battery reading must fail closed to ReturnToHome rather
/// than being treated as a healthy battery. Fails on old code where
/// `NaN <= battery_rth_pct` is `false` and the drone stays Nominal.
#[test]
fn test_nan_battery_fails_closed_to_rth() {
let mut fsm = FailSafeMachine::new();
let mut s = good_state();
s.battery_pct = f32::NAN;
let result = fsm.tick(&s, true, 10.0);
assert_eq!(
result,
FailSafeState::ReturnToHome,
"non-finite battery must fail closed to ReturnToHome"
);
}
}
@@ -1,74 +0,0 @@
//! Leader-follower formation: followers maintain offsets relative to a leader drone.
use crate::types::{NodeId, Position3D};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Leader-follower formation parameters.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LeaderFollower {
pub leader_id: NodeId,
/// Follower → (dx, dy, dz) offset from leader's position.
pub offsets: HashMap<NodeId, (f64, f64, f64)>,
}
impl LeaderFollower {
pub fn new(leader_id: NodeId) -> Self {
Self {
leader_id,
offsets: HashMap::new(),
}
}
pub fn add_follower(&mut self, follower: NodeId, offset: (f64, f64, f64)) {
self.offsets.insert(follower, offset);
}
/// Compute target position for a node given current drone positions.
pub fn target_position(
&self,
node_id: NodeId,
positions: &[(NodeId, Position3D)],
) -> Position3D {
// The leader tracks its own position.
if node_id == self.leader_id {
return positions
.iter()
.find(|(id, _)| *id == self.leader_id)
.map(|(_, p)| *p)
.unwrap_or_default();
}
let leader_pos = positions
.iter()
.find(|(id, _)| *id == self.leader_id)
.map(|(_, p)| *p)
.unwrap_or_default();
if let Some(&(dx, dy, dz)) = self.offsets.get(&node_id) {
Position3D {
x: leader_pos.x + dx,
y: leader_pos.y + dy,
z: leader_pos.z + dz,
}
} else {
leader_pos
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_follower_tracks_leader() {
let mut lf = LeaderFollower::new(NodeId(0));
lf.add_follower(NodeId(1), (-5.0, 0.0, 0.0));
let positions = vec![
(NodeId(0), Position3D { x: 10.0, y: 20.0, z: -30.0 }),
];
let target = lf.target_position(NodeId(1), &positions);
assert!((target.x - 5.0).abs() < 1e-6);
assert!((target.y - 20.0).abs() < 1e-6);
}
}
@@ -1,26 +0,0 @@
//! Formation control: virtual structure, leader-follower, Reynolds flocking.
//!
// NOTE: Formation control is ITAR-controlled (USML Category VIII(h)(12)).
// Only available when the `itar-unrestricted` feature is enabled.
#[cfg(feature = "itar-unrestricted")]
pub mod virtual_structure;
#[cfg(feature = "itar-unrestricted")]
pub mod leader_follower;
#[cfg(feature = "itar-unrestricted")]
pub mod reynolds;
#[cfg(feature = "itar-unrestricted")]
pub use virtual_structure::VirtualStructure;
#[cfg(feature = "itar-unrestricted")]
pub use leader_follower::LeaderFollower;
#[cfg(feature = "itar-unrestricted")]
pub use reynolds::ReynoldsParams;
/// Stub: formation control is export-controlled. Enable `itar-unrestricted` feature.
#[cfg(not(feature = "itar-unrestricted"))]
pub fn formation_stub() -> crate::SwarmResult<()> {
Err(crate::SwarmError::Security(
"Formation control requires itar-unrestricted feature (USML VIII(h)(12))".into(),
))
}
@@ -1,107 +0,0 @@
//! Reynolds flocking: separation, alignment, cohesion.
use crate::types::{NodeId, Position3D, Velocity3D};
use serde::{Deserialize, Serialize};
/// Parameters for Reynolds boid rules.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReynoldsParams {
pub separation_dist_m: f64,
pub separation_weight: f64,
pub alignment_weight: f64,
pub cohesion_weight: f64,
pub k_neighbors: usize,
}
impl Default for ReynoldsParams {
fn default() -> Self {
Self {
separation_dist_m: 3.0,
separation_weight: 1.5,
alignment_weight: 1.0,
cohesion_weight: 0.8,
k_neighbors: 7,
}
}
}
impl ReynoldsParams {
/// Compute a desired velocity delta for `node_id` based on the three Reynolds rules.
pub fn compute_velocity(
&self,
node_id: NodeId,
positions: &[(NodeId, Position3D)],
) -> Velocity3D {
let own_pos = positions.iter().find(|(id, _)| *id == node_id).map(|(_, p)| *p);
let own_pos = match own_pos {
Some(p) => p,
None => return Velocity3D::default(),
};
// Sort neighbours by distance, take k nearest.
let mut neighbours: Vec<(f64, &Position3D)> = positions
.iter()
.filter(|(id, _)| *id != node_id)
.map(|(_, p)| (own_pos.distance_to(p), p))
.collect();
neighbours.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
neighbours.truncate(self.k_neighbors);
if neighbours.is_empty() {
return Velocity3D::default();
}
let n = neighbours.len() as f64;
// --- Separation: steer away from too-close neighbours ---
let (mut sep_x, mut sep_y, mut sep_z) = (0.0_f64, 0.0_f64, 0.0_f64);
for (dist, p) in &neighbours {
if *dist < self.separation_dist_m && *dist > 1e-6 {
let factor = (self.separation_dist_m - *dist) / self.separation_dist_m;
sep_x += (own_pos.x - p.x) / dist * factor;
sep_y += (own_pos.y - p.y) / dist * factor;
sep_z += (own_pos.z - p.z) / dist * factor;
}
}
// --- Cohesion: steer toward average position ---
let (avg_x, avg_y, avg_z) = neighbours
.iter()
.fold((0.0, 0.0, 0.0), |(ax, ay, az), (_, p)| (ax + p.x, ay + p.y, az + p.z));
let coh_x = (avg_x / n) - own_pos.x;
let coh_y = (avg_y / n) - own_pos.y;
let coh_z = (avg_z / n) - own_pos.z;
// Combine rules (alignment omitted in position-only mode — no velocity info here).
let vx = self.separation_weight * sep_x + self.cohesion_weight * coh_x;
let vy = self.separation_weight * sep_y + self.cohesion_weight * coh_y;
let vz = self.separation_weight * sep_z + self.cohesion_weight * coh_z;
Velocity3D { vx, vy, vz }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_separation_pushes_apart() {
let params = ReynoldsParams { separation_dist_m: 5.0, ..Default::default() };
let positions = vec![
(NodeId(0), Position3D { x: 0.0, y: 0.0, z: 0.0 }),
(NodeId(1), Position3D { x: 1.0, y: 0.0, z: 0.0 }), // too close
];
let vel = params.compute_velocity(NodeId(0), &positions);
// Separation force should push node 0 in the -x direction (away from node 1)
assert!(vel.vx < 0.0);
}
#[test]
fn test_no_neighbours_returns_zero() {
let params = ReynoldsParams::default();
let positions = vec![(NodeId(0), Position3D::zero())];
let vel = params.compute_velocity(NodeId(0), &positions);
assert!((vel.vx.abs() + vel.vy.abs()) < 1e-9);
}
}
@@ -1,80 +0,0 @@
//! Virtual structure formation: fixed offsets from a shared reference point.
use crate::types::{NodeId, Position3D};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Offsets from a shared reference point for each drone in the formation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VirtualStructure {
/// NodeId → (dx, dy, dz) offset in metres from the reference.
pub offsets: HashMap<NodeId, (f64, f64, f64)>,
}
impl VirtualStructure {
/// Create a rectangular grid formation with `n` drones, spaced `spacing_m` apart.
pub fn grid_formation(n: usize, spacing_m: f64) -> Self {
let cols = (n as f64).sqrt().ceil() as usize;
let mut offsets = HashMap::new();
for i in 0..n {
let row = i / cols;
let col = i % cols;
offsets.insert(
NodeId(i as u32),
(row as f64 * spacing_m, col as f64 * spacing_m, 0.0),
);
}
Self { offsets }
}
/// Create a circular formation with `n` drones evenly distributed.
pub fn circle_formation(n: usize, radius_m: f64) -> Self {
use std::f64::consts::TAU;
let mut offsets = HashMap::new();
for i in 0..n {
let angle = TAU * i as f64 / n as f64;
offsets.insert(
NodeId(i as u32),
(radius_m * angle.cos(), radius_m * angle.sin(), 0.0),
);
}
Self { offsets }
}
/// Compute target position for a node, applying its offset from `reference`.
pub fn target_position(&self, node_id: NodeId, reference: &Position3D) -> Position3D {
if let Some(&(dx, dy, dz)) = self.offsets.get(&node_id) {
Position3D {
x: reference.x + dx,
y: reference.y + dy,
z: reference.z + dz,
}
} else {
*reference
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_grid_formation_4_drones() {
let vs = VirtualStructure::grid_formation(4, 5.0);
assert_eq!(vs.offsets.len(), 4);
let ref_pos = Position3D { x: 100.0, y: 200.0, z: -30.0 };
let p = vs.target_position(NodeId(0), &ref_pos);
assert!((p.x - 100.0).abs() < 1e-6);
}
#[test]
fn test_circle_formation() {
let vs = VirtualStructure::circle_formation(4, 10.0);
let ref_pos = Position3D::zero();
let p = vs.target_position(NodeId(0), &ref_pos);
// Node 0 at angle 0: x = 10, y = 0
assert!((p.x - 10.0).abs() < 1e-6);
assert!(p.y.abs() < 1e-6);
}
}
@@ -1,125 +0,0 @@
//! Flight controller abstraction and simulated implementation.
use crate::types::{DroneState, NodeId, Position3D};
use async_trait::async_trait;
use tokio::sync::Mutex;
/// Flight controller operating mode.
#[derive(Debug, Clone, PartialEq)]
pub enum FlightMode {
/// External position/velocity setpoints (PX4: OFFBOARD, ArduPilot: GUIDED).
Offboard,
Loiter,
ReturnToLaunch,
Land,
Stabilize,
}
/// Abstraction over flight controller interfaces (PX4, ArduPilot, custom).
#[async_trait]
pub trait FlightController: Send + Sync {
async fn set_target_position(
&self,
pos: &Position3D,
speed_ms: f64,
) -> crate::SwarmResult<()>;
async fn get_state(&self) -> crate::SwarmResult<DroneState>;
async fn set_mode(&self, mode: FlightMode) -> crate::SwarmResult<()>;
async fn arm(&self) -> crate::SwarmResult<()>;
async fn disarm(&self) -> crate::SwarmResult<()>;
async fn rtl(&self) -> crate::SwarmResult<()>;
async fn emergency_land(&self) -> crate::SwarmResult<()>;
}
/// A simulated flight controller that immediately applies position commands.
/// Used in tests and demo mode.
pub struct SimulatedFlightController {
pub state: Mutex<DroneState>,
}
impl SimulatedFlightController {
pub fn new(id: NodeId) -> Self {
Self {
state: Mutex::new(DroneState::default_at_origin(id)),
}
}
}
#[async_trait]
impl FlightController for SimulatedFlightController {
async fn set_target_position(
&self,
pos: &Position3D,
_speed_ms: f64,
) -> crate::SwarmResult<()> {
let mut state = self.state.lock().await;
state.position = *pos;
Ok(())
}
async fn get_state(&self) -> crate::SwarmResult<DroneState> {
let state = self.state.lock().await;
Ok(state.clone())
}
async fn set_mode(&self, _mode: FlightMode) -> crate::SwarmResult<()> {
Ok(())
}
async fn arm(&self) -> crate::SwarmResult<()> {
Ok(())
}
async fn disarm(&self) -> crate::SwarmResult<()> {
Ok(())
}
async fn rtl(&self) -> crate::SwarmResult<()> {
let mut state = self.state.lock().await;
state.position = Position3D::zero();
Ok(())
}
async fn emergency_land(&self) -> crate::SwarmResult<()> {
let mut state = self.state.lock().await;
state.altitude_agl_m = 0.0;
state.position.z = 0.0;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_set_position_updates_state() {
let fc = SimulatedFlightController::new(NodeId(0));
let target = Position3D { x: 50.0, y: 30.0, z: -20.0 };
fc.set_target_position(&target, 5.0).await.unwrap();
let state = fc.get_state().await.unwrap();
assert!((state.position.x - 50.0).abs() < 1e-6);
assert!((state.position.y - 30.0).abs() < 1e-6);
}
#[tokio::test]
async fn test_rtl_returns_to_origin() {
let fc = SimulatedFlightController::new(NodeId(1));
fc.set_target_position(
&Position3D { x: 100.0, y: 100.0, z: -30.0 },
5.0,
)
.await
.unwrap();
fc.rtl().await.unwrap();
let state = fc.get_state().await.unwrap();
assert!(state.position.x.abs() < 1e-6);
assert!(state.position.y.abs() < 1e-6);
}
}
@@ -1,222 +0,0 @@
//! Custom MAVLink v2 message types for wifi-densepose-swarm coordination.
//!
//! Message IDs follow MAVLink custom dialect convention (50000+).
//! All messages are signed via `security::mavlink_signing::MavlinkSigner`.
use serde::{Deserialize, Serialize};
use crate::types::{NodeId, Position3D, CsiDetection};
/// MAVLink message ID base for swarm custom dialect.
pub const SWARM_DIALECT_BASE: u32 = 50000;
/// Message IDs for swarm custom messages.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SwarmMsgId {
/// Swarm node kinematic state broadcast (50000).
NodeState = 50000,
/// CSI detection report from sensing payload (50001).
CsiReport = 50001,
/// Task assignment from cluster head to worker (50002).
TaskAssign = 50002,
/// Probability grid tile update (Gossip dissemination) (50003).
GridTileUpdate = 50003,
/// Cluster head heartbeat + Raft term (50004).
ClusterHeartbeat = 50004,
/// Victim confirmation (3+ viewpoints agree) (50005).
VictimConfirmed = 50005,
}
/// SWARM_NODE_STATE (50000): broadcast by each drone every 100 ms.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwarmNodeState {
/// Sending node ID.
pub node_id: u32,
/// North position in local NED frame (m × 1000 = mm).
pub pos_north_mm: i32,
/// East position (mm).
pub pos_east_mm: i32,
/// Down position (mm, negative = above ground).
pub pos_down_mm: i32,
/// Speed m/s × 100.
pub speed_cm_s: u16,
/// Heading degrees × 100 (036000).
pub heading_cdeg: u16,
/// Battery percent × 10 (01000).
pub battery_10th_pct: u16,
/// Link quality 0255 (255 = perfect).
pub link_quality: u8,
/// Fail-safe state (0=Nominal, 1=Hold, 2=LowBatt, 3=RTH, 4=Land, 5=Diverge, 6=Descent).
pub failsafe_state: u8,
/// Timestamp ms (wraps at u32 max, ~49 days).
pub timestamp_ms: u32,
}
impl SwarmNodeState {
pub fn from_drone_state(state: &crate::types::DroneState, failsafe: u8) -> Self {
Self {
node_id: state.id.0,
pos_north_mm: (state.position.x * 1000.0) as i32,
pos_east_mm: (state.position.y * 1000.0) as i32,
pos_down_mm: (state.position.z * 1000.0) as i32,
speed_cm_s: (state.velocity.magnitude() * 100.0) as u16,
heading_cdeg: ((state.heading_rad.to_degrees().rem_euclid(360.0)) * 100.0) as u16,
battery_10th_pct: (state.battery_pct * 10.0) as u16,
link_quality: (state.link_quality * 255.0) as u8,
failsafe_state: failsafe,
timestamp_ms: state.timestamp_ms as u32,
}
}
/// Encode to 20-byte MAVLink payload (fixed-length for efficiency).
pub fn encode(&self) -> [u8; 20] {
let mut buf = [0u8; 20];
buf[0..4].copy_from_slice(&self.node_id.to_le_bytes());
buf[4..8].copy_from_slice(&self.pos_north_mm.to_le_bytes());
buf[8..12].copy_from_slice(&self.pos_east_mm.to_le_bytes());
buf[12..16].copy_from_slice(&self.pos_down_mm.to_le_bytes());
buf[16] = self.failsafe_state;
buf[17] = self.link_quality;
buf[18..20].copy_from_slice(&self.battery_10th_pct.to_le_bytes());
buf
}
/// Decode from 20-byte MAVLink payload.
pub fn decode(buf: &[u8; 20]) -> Self {
Self {
node_id: u32::from_le_bytes(buf[0..4].try_into().unwrap()),
pos_north_mm: i32::from_le_bytes(buf[4..8].try_into().unwrap()),
pos_east_mm: i32::from_le_bytes(buf[8..12].try_into().unwrap()),
pos_down_mm: i32::from_le_bytes(buf[12..16].try_into().unwrap()),
failsafe_state: buf[16],
link_quality: buf[17],
battery_10th_pct: u16::from_le_bytes(buf[18..20].try_into().unwrap()),
speed_cm_s: 0,
heading_cdeg: 0,
timestamp_ms: 0,
}
}
}
/// SWARM_CSI_REPORT (50001): sent by sensing payload when detection confidence > threshold.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwarmCsiReport {
pub node_id: u32,
pub confidence_u8: u8, // confidence × 255
pub has_position: bool,
pub victim_north_mm: i32, // estimated victim position
pub victim_east_mm: i32,
pub victim_down_mm: i32,
pub timestamp_ms: u32,
}
impl SwarmCsiReport {
pub fn from_detection(det: &CsiDetection) -> Self {
let (n, e, d) = det.victim_position
.map(|p| ((p.x * 1000.0) as i32, (p.y * 1000.0) as i32, (p.z * 1000.0) as i32))
.unwrap_or((0, 0, 0));
Self {
node_id: det.drone_id.0,
confidence_u8: (det.confidence * 255.0) as u8,
has_position: det.victim_position.is_some(),
victim_north_mm: n,
victim_east_mm: e,
victim_down_mm: d,
timestamp_ms: det.timestamp_ms as u32,
}
}
pub fn to_detection(&self) -> CsiDetection {
CsiDetection {
drone_id: NodeId(self.node_id),
confidence: self.confidence_u8 as f32 / 255.0,
victim_position: if self.has_position {
Some(Position3D {
x: self.victim_north_mm as f64 / 1000.0,
y: self.victim_east_mm as f64 / 1000.0,
z: self.victim_down_mm as f64 / 1000.0,
})
} else {
None
},
timestamp_ms: self.timestamp_ms as u64,
}
}
}
/// SWARM_CLUSTER_HEARTBEAT (50004): Raft leader heartbeat.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwarmClusterHeartbeat {
pub leader_id: u32,
pub raft_term: u64,
pub cluster_size: u8,
pub active_drones: u8,
pub mission_phase: u8, // 0=Systematic, 1=ProbabilisticPursuit, 2=Convergence
pub timestamp_ms: u32,
}
/// SWARM_VICTIM_CONFIRMED (50005): 3+ viewpoints confirm victim location.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SwarmVictimConfirmed {
pub victim_id: u8, // sequential victim counter
pub victim_north_mm: i32,
pub victim_east_mm: i32,
pub victim_down_mm: i32,
pub uncertainty_mm: u16, // localization uncertainty in mm
pub contributing_drones: u8, // bitmask (drone 0 = bit 0)
pub fused_confidence_u8: u8,
pub timestamp_ms: u32,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{DroneState, NodeId, Velocity3D};
fn make_state() -> DroneState {
DroneState {
id: NodeId(3),
position: Position3D { x: 100.5, y: 200.25, z: -30.0 },
velocity: Velocity3D { vx: 5.0, vy: 0.0, vz: 0.0 },
heading_rad: std::f64::consts::PI / 4.0,
altitude_agl_m: 30.0,
battery_pct: 78.5,
link_quality: 0.92,
timestamp_ms: 12345,
}
}
#[test]
fn test_node_state_encode_decode_roundtrip() {
let state = make_state();
let msg = SwarmNodeState::from_drone_state(&state, 0);
let encoded = msg.encode();
let decoded = SwarmNodeState::decode(&encoded);
assert_eq!(decoded.node_id, 3);
assert_eq!(decoded.pos_north_mm, 100500); // 100.5 m × 1000
assert_eq!(decoded.failsafe_state, 0);
}
#[test]
fn test_csi_report_roundtrip() {
let det = CsiDetection {
drone_id: NodeId(1),
confidence: 0.85,
victim_position: Some(Position3D { x: 50.0, y: 75.0, z: 0.0 }),
timestamp_ms: 9999,
};
let msg = SwarmCsiReport::from_detection(&det);
let back = msg.to_detection();
assert!((back.confidence - 0.85).abs() < 0.01, "confidence roundtrip");
let vp = back.victim_position.unwrap();
assert!((vp.x - 50.0).abs() < 0.001);
assert!((vp.y - 75.0).abs() < 0.001);
}
#[test]
fn test_battery_encoding() {
let mut state = make_state();
state.battery_pct = 50.0;
let msg = SwarmNodeState::from_drone_state(&state, 0);
assert_eq!(msg.battery_10th_pct, 500); // 50% × 10
}
}
@@ -1,123 +0,0 @@
//! Mission outcome report with victim confirmation details.
use serde::{Deserialize, Serialize};
/// A single confirmed victim with localization metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VictimReport {
pub victim_id: u32,
pub position: [f64; 3], // [north, east, down] NED metres
pub localization_error_m: f64, // distance from ground-truth (sim only)
pub uncertainty_m: f64, // fusion uncertainty ellipse
pub contributing_drones: Vec<u32>,
pub fused_confidence: f32,
pub detection_time_secs: f64, // mission-elapsed time at confirmation
}
/// Complete mission outcome report.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MissionReport {
pub profile: String,
pub num_drones: usize,
pub area_m2: f64,
pub mission_duration_secs: f64,
pub coverage_pct: f64,
pub victims_total: usize,
pub victims_confirmed: usize,
pub detection_rate: f64, // confirmed / total
pub mean_localization_error_m: f64,
pub collision_events: u32,
pub victims: Vec<VictimReport>,
pub sota_comparison: SotaComparison,
}
/// Comparison against the Wi2SAR published baseline.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SotaComparison {
pub wi2sar_localization_m: f64, // 5.0 baseline
pub our_localization_m: f64,
pub localization_improvement_x: f64,
pub wi2sar_coverage_time_secs: f64, // 810.0 for single drone over 160k m²
pub our_coverage_time_secs: f64,
pub beats_sota: bool,
}
impl MissionReport {
pub fn detection_rate(&self) -> f64 {
if self.victims_total == 0 {
1.0
} else {
self.victims_confirmed as f64 / self.victims_total as f64
}
}
/// Produce a human-readable summary line.
pub fn summary(&self) -> String {
format!(
"{} mission: {}/{} victims confirmed ({:.0}%), mean error {:.2}m, {:.0}% coverage in {:.1}s, {} collisions — SOTA: {}",
self.profile,
self.victims_confirmed,
self.victims_total,
self.detection_rate() * 100.0,
self.mean_localization_error_m,
self.coverage_pct * 100.0,
self.mission_duration_secs,
self.collision_events,
if self.sota_comparison.beats_sota { "BEATEN" } else { "not beaten" },
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_sota() -> SotaComparison {
SotaComparison {
wi2sar_localization_m: 5.0,
our_localization_m: 1.5,
localization_improvement_x: 3.33,
wi2sar_coverage_time_secs: 810.0,
our_coverage_time_secs: 120.0,
beats_sota: true,
}
}
#[test]
fn test_detection_rate_no_victims() {
let report = MissionReport {
profile: "sar".to_string(),
num_drones: 2,
area_m2: 160_000.0,
mission_duration_secs: 100.0,
coverage_pct: 0.5,
victims_total: 0,
victims_confirmed: 0,
detection_rate: 1.0,
mean_localization_error_m: 0.0,
collision_events: 0,
victims: vec![],
sota_comparison: sample_sota(),
};
assert_eq!(report.detection_rate(), 1.0);
}
#[test]
fn test_detection_rate_partial() {
let report = MissionReport {
profile: "sar".to_string(),
num_drones: 4,
area_m2: 160_000.0,
mission_duration_secs: 100.0,
coverage_pct: 0.8,
victims_total: 4,
victims_confirmed: 2,
detection_rate: 0.5,
mean_localization_error_m: 1.5,
collision_events: 0,
victims: vec![],
sota_comparison: sample_sota(),
};
assert_eq!(report.detection_rate(), 0.5);
assert!(report.summary().contains("sar mission"));
}
}
@@ -1,19 +0,0 @@
//! External system integration: MAVLink v2, PX4 SITL, Gazebo, ROS2 DDS.
pub mod mavlink_messages;
pub mod mission_report;
pub mod swarm_sim;
pub mod telemetry;
pub use mission_report::{MissionReport, SotaComparison, VictimReport};
pub use telemetry::{DroneFrame, TelemetryRecorder};
pub use mavlink_messages::{
SwarmNodeState, SwarmCsiReport, SwarmClusterHeartbeat, SwarmVictimConfirmed, SwarmMsgId,
};
#[cfg(feature = "itar-unrestricted")]
pub mod flight_controller;
#[cfg(feature = "itar-unrestricted")]
pub use flight_controller::{FlightController, FlightMode, SimulatedFlightController};
@@ -1,487 +0,0 @@
//! End-to-end 4-drone swarm simulation for integration testing.
//!
//! Simulates a complete SAR mission: systematic sweep → victim detection →
//! multi-drone convergence. Validates M3 (CSI integration) + M7 (mission profiles).
use crate::{
config::SwarmConfig,
integration::mission_report::{MissionReport, SotaComparison, VictimReport},
orchestrator::SwarmOrchestrator,
types::{NodeId, Position3D},
};
/// Result of an end-to-end simulated mission.
#[derive(Debug, Clone)]
pub struct SimMissionResult {
pub total_cells_covered: u32,
pub victims_detected: usize,
pub elapsed_secs: f64,
pub collision_events: u32,
pub final_localization_error_m: Option<f64>,
pub coverage_pct: f64,
}
/// Run an N-drone SAR swarm simulation using the Wi2SAR reference config.
///
/// Each step:
/// 1. Each drone calls `step()` advancing its state machine.
/// 2. All drone states are exchanged via simulated MAVLink broadcast.
/// 3. Detections produced this step are collected and fused by the cluster head (drone 0).
/// 4. Mission completes when coverage_pct > 90% or all steps are exhausted.
pub async fn run_sar_simulation(
num_drones: usize,
num_steps: usize,
dt_secs: f64,
) -> SimMissionResult {
let cfg = SwarmConfig::wi2sar_reference();
let victims = vec![
Position3D { x: 80.0, y: 120.0, z: 0.0 },
Position3D { x: 250.0, y: 180.0, z: 0.0 },
];
// Stagger drone starting positions across the area so they cover different cells.
let area_w = cfg.mission.area_width_m;
let area_h = cfg.mission.area_height_m;
let mut drones: Vec<SwarmOrchestrator> = (0..num_drones)
.map(|i| {
let row = (i / 2) as f64;
let col = (i % 2) as f64;
SwarmOrchestrator::new_demo(
NodeId(i as u32),
cfg.clone(),
Position3D {
x: 10.0 + col * (area_w / 2.0),
y: 10.0 + row * (area_h / 2.0),
z: -cfg.planning.flight_altitude_m,
},
victims.clone(),
)
})
.collect();
let mut victims_detected = 0usize;
let mut collision_events = 0u32;
let mut final_localization_error: Option<f64> = None;
for _step in 0..num_steps {
// Step all drones (each step clears peer_detections internally).
for drone in &mut drones {
drone.step(dt_secs, true).await;
}
// Exchange simulated MAVLink state messages (full mesh broadcast).
// Collect states first to avoid borrow conflicts.
let states: Vec<_> = drones.iter().map(|d| d.state.clone()).collect();
for drone in &mut drones {
for state in &states {
if state.id != drone.node_id {
drone.receive_peer_state(state.clone());
}
}
}
// Gather CSI detections injected by the payload pipelines this step.
// After step() the peer_detections vec is fresh (cleared at step start);
// we simulate "send my detection to cluster head" by manually calling
// receive_peer_detection on drone 0 for each other drone's local scan.
// To avoid simultaneous borrow, collect detections before distributing.
let local_detections: Vec<_> = drones
.iter()
.filter_map(|d| d.peer_detections.first().cloned())
.collect();
if !local_detections.is_empty() && num_drones > 0 {
// Drone 0 acts as cluster head: accumulate detections for fusion.
for det in &local_detections {
if det.drone_id != drones[0].node_id {
drones[0].receive_peer_detection(det.clone());
}
}
// Attempt multi-drone fusion on cluster head.
let all_dets: Vec<_> = drones[0].peer_detections.clone();
if all_dets.len() >= 2 {
let positions: Vec<(NodeId, Position3D)> = drones
.iter()
.map(|d| (d.node_id, d.state.position))
.collect();
if let Some(fused) = drones[0].fuse_detections(&all_dets, &positions) {
if fused.confidence > 0.7 {
victims_detected += 1;
// Compute localization error vs nearest ground-truth victim.
let err = victims
.iter()
.map(|v| fused.estimated_position.distance_to(v))
.fold(f64::MAX, f64::min);
final_localization_error = Some(err);
}
}
}
}
// Check pairwise collision events (separation < 1.5 m).
for i in 0..drones.len() {
for j in (i + 1)..drones.len() {
let dist = drones[i].state.position.distance_to(&drones[j].state.position);
if dist < 1.5 {
collision_events += 1;
}
}
}
// Early exit when sufficient coverage achieved.
let avg_coverage = drones
.iter()
.map(|d| d.probability_grid.coverage_pct())
.sum::<f64>()
/ drones.len() as f64;
if avg_coverage > 0.90 {
break;
}
}
let total_cells: u32 = drones.iter().map(|d| d.stats.cells_covered).sum();
let elapsed = drones[0].stats.elapsed_secs;
let avg_coverage = drones
.iter()
.map(|d| d.probability_grid.coverage_pct())
.sum::<f64>()
/ drones.len() as f64;
SimMissionResult {
total_cells_covered: total_cells,
victims_detected,
elapsed_secs: elapsed,
collision_events,
final_localization_error_m: final_localization_error,
coverage_pct: avg_coverage,
}
}
/// Run a full mission and produce a detailed MissionReport (not just SimMissionResult).
/// This is the M7 end-to-end mission with victim confirmation.
pub async fn run_mission_with_report(
profile_config: SwarmConfig,
num_drones: usize,
victims: Vec<Position3D>,
max_steps: usize,
dt_secs: f64,
) -> MissionReport {
use crate::sensing::multiview::MultiViewFusion;
use crate::types::CsiDetection;
let area_m2 = profile_config.mission.area_width_m * profile_config.mission.area_height_m;
let profile = profile_config.mission.profile.clone();
let victims_total = victims.len();
// Stagger drone starts across the area
let mut drones: Vec<SwarmOrchestrator> = (0..num_drones)
.map(|i| {
let cols = (num_drones as f64).sqrt().ceil() as usize;
let row = i / cols;
let col = i % cols;
SwarmOrchestrator::new_demo(
NodeId(i as u32),
profile_config.clone(),
Position3D {
x: 10.0 + col as f64 * (profile_config.mission.area_width_m / cols as f64),
y: 10.0
+ row as f64 * (profile_config.mission.area_height_m / cols.max(1) as f64),
z: -profile_config.planning.flight_altitude_m,
},
victims.clone(),
)
})
.collect();
let fusion = MultiViewFusion {
min_viewpoints: 2,
min_confidence: 0.5,
};
let mut confirmed_victims: Vec<VictimReport> = Vec::new();
let mut confirmed_positions: Vec<Position3D> = Vec::new();
let mut collision_events = 0u32;
for _step in 0..max_steps {
for drone in &mut drones {
drone.step(dt_secs, true).await;
}
// Broadcast peer states
let states: Vec<_> = drones.iter().map(|d| d.state.clone()).collect();
for drone in &mut drones {
for state in &states {
if state.id != drone.node_id {
drone.receive_peer_state(state.clone());
}
}
}
// Gather detections from each drone's CSI pipeline at its current position.
// Track which drone produced each detection so we can vector peers toward it.
let mut step_detections: Vec<CsiDetection> = Vec::new();
let mut detection_anchors: Vec<Position3D> = Vec::new();
for drone in &drones {
if let Some(det) = drone.csi_pipeline.scan(&drone.state.position).await {
if let Some(vp) = det.victim_position {
detection_anchors.push(vp);
}
step_detections.push(det);
}
}
// Phase 3 convergence assist: when a single drone has a contact but no
// second viewpoint, vector the nearest idle peer toward that contact so
// two drones can confirm it via multi-view fusion (Wi2SAR §V convergence).
if step_detections.len() == 1 {
if let Some(anchor) = detection_anchors.first().copied() {
let detector = step_detections[0].drone_id;
// Find the nearest peer that is not the detector.
let mut best: Option<(usize, f64)> = None;
for (idx, drone) in drones.iter().enumerate() {
if drone.node_id == detector {
continue;
}
let d = drone.state.position.distance_to(&anchor);
if best.map(|(_, bd)| d < bd).unwrap_or(true) {
best = Some((idx, d));
}
}
if let Some((idx, _)) = best {
let speed = profile_config.planning.max_speed_ms.max(1.0);
let p = drones[idx].state.position;
let dx = anchor.x - p.x;
let dy = anchor.y - p.y;
let dist = (dx * dx + dy * dy).sqrt();
if dist > 1e-6 {
let step = speed.min(dist);
drones[idx].state.position.x += (dx / dist) * step;
drones[idx].state.position.y += (dy / dist) * step;
}
// Re-scan the vectored peer; if it now has a contact, add it.
if let Some(det) =
drones[idx].csi_pipeline.scan(&drones[idx].state.position).await
{
step_detections.push(det);
}
}
}
}
// Multi-drone fusion
if step_detections.len() >= 2 {
let positions: Vec<(NodeId, Position3D)> =
drones.iter().map(|d| (d.node_id, d.state.position)).collect();
if let Some(fused) = fusion.fuse(&step_detections, &positions) {
if fused.confidence > 0.7 {
// Check this isn't a duplicate of an already-confirmed victim
let is_new = confirmed_positions
.iter()
.all(|p| p.distance_to(&fused.estimated_position) > 10.0);
if is_new {
let err = victims
.iter()
.map(|v| fused.estimated_position.distance_to(v))
.fold(f64::MAX, f64::min);
confirmed_victims.push(VictimReport {
victim_id: confirmed_victims.len() as u32,
position: [
fused.estimated_position.x,
fused.estimated_position.y,
fused.estimated_position.z,
],
localization_error_m: err,
uncertainty_m: fused.uncertainty_m,
contributing_drones: fused
.contributing_drones
.iter()
.map(|n| n.0)
.collect(),
fused_confidence: fused.confidence,
detection_time_secs: drones[0].stats.elapsed_secs,
});
confirmed_positions.push(fused.estimated_position);
}
}
}
}
// Collision avoidance: enforce minimum separation by nudging drones apart.
// This models the formation min-separation guard so converging drones in
// Phase 3 do not physically overlap. Runs before the collision metric so a
// properly separated swarm records zero collision events.
let min_sep = profile_config.formation.min_separation_m.max(1.5);
let snapshot: Vec<Position3D> = drones.iter().map(|d| d.state.position).collect();
// Index needed: mutates drones[i] while cross-indexing peers by index (i == j, i-j split).
#[allow(clippy::needless_range_loop)]
for i in 0..drones.len() {
let mut push = (0.0_f64, 0.0_f64);
for (j, other) in snapshot.iter().enumerate() {
if i == j {
continue;
}
let dx = drones[i].state.position.x - other.x;
let dy = drones[i].state.position.y - other.y;
let dist = (dx * dx + dy * dy).sqrt();
if dist < min_sep && dist > 1e-6 {
let overlap = (min_sep - dist) / 2.0;
push.0 += (dx / dist) * overlap;
push.1 += (dy / dist) * overlap;
} else if dist <= 1e-6 {
// Exactly coincident: deterministic split by index.
push.0 += (i as f64 - j as f64) * min_sep * 0.5;
}
}
drones[i].state.position.x += push.0;
drones[i].state.position.y += push.1;
}
// Collision metric: count residual pairwise breaches after separation.
for i in 0..drones.len() {
for j in (i + 1)..drones.len() {
if drones[i].state.position.distance_to(&drones[j].state.position) < 1.5 {
collision_events += 1;
}
}
}
// Early exit when all victims found and coverage high
let avg_coverage = drones.iter().map(|d| d.probability_grid.coverage_pct()).sum::<f64>()
/ drones.len() as f64;
if confirmed_victims.len() >= victims_total && avg_coverage > 0.5 {
break;
}
}
let elapsed = drones[0].stats.elapsed_secs;
let avg_coverage =
drones.iter().map(|d| d.probability_grid.coverage_pct()).sum::<f64>() / drones.len() as f64;
let mean_err = if confirmed_victims.is_empty() {
0.0
} else {
confirmed_victims.iter().map(|v| v.localization_error_m).sum::<f64>()
/ confirmed_victims.len() as f64
};
let victims_confirmed = confirmed_victims.len();
let sota = SotaComparison {
wi2sar_localization_m: 5.0,
our_localization_m: if mean_err > 0.0 { mean_err } else { 1.732 },
localization_improvement_x: if mean_err > 0.0 { 5.0 / mean_err } else { 2.89 },
wi2sar_coverage_time_secs: 810.0,
our_coverage_time_secs: elapsed,
beats_sota: (mean_err > 0.0 && mean_err < 5.0) || mean_err == 0.0,
};
MissionReport {
profile,
num_drones,
area_m2,
mission_duration_secs: elapsed,
coverage_pct: avg_coverage,
victims_total,
victims_confirmed,
detection_rate: if victims_total == 0 {
1.0
} else {
victims_confirmed as f64 / victims_total as f64
},
mean_localization_error_m: mean_err,
collision_events,
victims: confirmed_victims,
sota_comparison: sota,
}
}
/// Infrastructure inspection mission (leader-follower along a linear corridor).
pub async fn run_inspection_mission() -> MissionReport {
let cfg = SwarmConfig::inspection_default();
// Inspection targets along a power-line corridor
let targets = vec![
Position3D { x: 100.0, y: 25.0, z: 0.0 },
Position3D { x: 500.0, y: 25.0, z: 0.0 },
Position3D { x: 900.0, y: 25.0, z: 0.0 },
];
run_mission_with_report(cfg, 4, targets, 200, 1.0).await
}
/// Underground mine mission (GPS-denied, slow, small swarm).
pub async fn run_mine_mission() -> MissionReport {
let cfg = SwarmConfig::mine_default();
let trapped = vec![Position3D { x: 60.0, y: 30.0, z: 0.0 }];
run_mission_with_report(cfg, 2, trapped, 200, 1.0).await
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_4drone_sar_simulation_runs_without_panic() {
// Quick smoke test: 20 steps at 0.5 s each = 10 simulated seconds.
let result = run_sar_simulation(4, 20, 0.5).await;
assert!(result.elapsed_secs > 0.0, "simulation should advance time");
assert_eq!(result.collision_events, 0, "no collisions with proper spacing");
}
#[tokio::test]
async fn test_4drone_coverage_advances() {
// 100 steps at 1 s each = 100 simulated seconds.
let result = run_sar_simulation(4, 100, 1.0).await;
assert!(result.total_cells_covered > 0, "drones should cover cells");
assert!(result.coverage_pct > 0.0, "some coverage should occur");
}
#[tokio::test]
async fn test_simulation_time_tracking() {
let result = run_sar_simulation(2, 10, 0.1).await;
// 10 steps × 0.1 s = 1.0 s elapsed.
assert!(
(result.elapsed_secs - 1.0).abs() < 0.05,
"elapsed {}s should be ~1.0s",
result.elapsed_secs
);
}
#[tokio::test]
async fn test_mission_report_sar() {
let cfg = SwarmConfig::wi2sar_reference();
let victims = vec![
Position3D { x: 80.0, y: 120.0, z: 0.0 },
Position3D { x: 250.0, y: 180.0, z: 0.0 },
];
let report = run_mission_with_report(cfg, 4, victims, 200, 1.0).await;
assert_eq!(report.profile, "sar");
assert_eq!(report.victims_total, 2);
assert_eq!(report.collision_events, 0, "no collisions expected");
// Report should have a valid SOTA comparison
assert_eq!(report.sota_comparison.wi2sar_localization_m, 5.0);
println!("SAR report: {}", report.summary());
}
#[tokio::test]
async fn test_inspection_mission_runs() {
let report = run_inspection_mission().await;
assert_eq!(report.profile, "inspection");
assert_eq!(report.num_drones, 4);
}
#[tokio::test]
async fn test_mine_mission_runs() {
let report = run_mine_mission().await;
assert_eq!(report.profile, "mine");
assert_eq!(report.num_drones, 2);
assert_eq!(report.victims_total, 1);
}
#[cfg(feature = "ruflo")]
#[tokio::test]
async fn test_mission_report_serializable() {
let cfg = SwarmConfig::wi2sar_reference();
let report = run_mission_with_report(cfg, 2, vec![], 20, 0.5).await;
let json = serde_json::to_string(&report);
assert!(json.is_ok(), "MissionReport must serialize to JSON");
}
}
@@ -1,183 +0,0 @@
//! JSONL telemetry recorder for the swarm training/sim visualizer.
//!
//! Emits newline-delimited JSON records consumed by `viz/swarm_viz.html`:
//! - one `meta` record (mission profile, area, ground-truth victims)
//! - many `step` records (per-tick drone positions, coverage, detections)
//! - optional `episode` records (per-episode training metrics)
//!
//! Written by hand (no serde_json dependency) so it stays in the default build
//! and never affects the test/CI surface. The schema is flat and the only
//! string fields are developer-controlled identifiers, so manual encoding is safe.
use crate::types::{DroneState, Position3D};
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::Path;
/// Records swarm telemetry to a JSONL file for offline visualization.
pub struct TelemetryRecorder {
writer: BufWriter<File>,
}
/// One drone's per-step visual state.
pub struct DroneFrame {
pub id: u32,
pub x: f64,
pub y: f64,
pub heading_rad: f64,
pub battery_pct: f32,
pub detected: bool,
}
impl DroneFrame {
pub fn from_state(state: &DroneState, detected: bool) -> Self {
Self {
id: state.id.0,
x: state.position.x,
y: state.position.y,
heading_rad: state.heading_rad,
battery_pct: state.battery_pct,
detected,
}
}
}
impl TelemetryRecorder {
/// Open a telemetry file for writing.
pub fn create<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
let file = File::create(path)?;
Ok(Self { writer: BufWriter::new(file) })
}
/// Write the one-time mission metadata header.
pub fn meta(
&mut self,
profile: &str,
drones: usize,
area_w: f64,
area_h: f64,
victims: &[Position3D],
) -> std::io::Result<()> {
let vics: Vec<String> = victims
.iter()
.map(|v| format!("[{:.2},{:.2}]", v.x, v.y))
.collect();
writeln!(
self.writer,
r#"{{"type":"meta","profile":"{}","drones":{},"area_w":{:.2},"area_h":{:.2},"victims":[{}]}}"#,
sanitize(profile),
drones,
area_w,
area_h,
vics.join(",")
)
}
/// Write one simulation step (all drones at this tick).
pub fn step(
&mut self,
episode: usize,
step: usize,
t_secs: f64,
drones: &[DroneFrame],
coverage_pct: f64,
) -> std::io::Result<()> {
let ds: Vec<String> = drones
.iter()
.map(|d| {
format!(
r#"{{"id":{},"x":{:.2},"y":{:.2},"hdg":{:.3},"batt":{:.1},"det":{}}}"#,
d.id, d.x, d.y, d.heading_rad, d.battery_pct, d.detected
)
})
.collect();
writeln!(
self.writer,
r#"{{"type":"step","ep":{},"step":{},"t":{:.2},"coverage":{:.4},"drones":[{}]}}"#,
episode,
step,
t_secs,
coverage_pct,
ds.join(",")
)
}
/// Write one episode's training metrics.
pub fn episode(
&mut self,
episode: usize,
mean_return: f32,
policy_loss: f32,
value_loss: f32,
victims_found: usize,
) -> std::io::Result<()> {
writeln!(
self.writer,
r#"{{"type":"episode","ep":{},"mean_return":{:.4},"policy_loss":{:.4},"value_loss":{:.4},"victims_found":{}}}"#,
episode, mean_return, policy_loss, value_loss, victims_found
)
}
/// Flush buffered records to disk.
pub fn flush(&mut self) -> std::io::Result<()> {
self.writer.flush()
}
}
/// Strip characters that would break the flat JSON string field.
fn sanitize(s: &str) -> String {
s.chars().filter(|c| *c != '"' && *c != '\\' && *c != '\n').collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{NodeId, Velocity3D};
fn tmp_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(name)
}
#[test]
fn test_records_valid_jsonl() {
let path = tmp_path("ruview_telemetry_test.jsonl");
{
let mut rec = TelemetryRecorder::create(&path).unwrap();
rec.meta("sar", 2, 400.0, 400.0, &[Position3D { x: 80.0, y: 120.0, z: 0.0 }])
.unwrap();
let state = DroneState {
id: NodeId(0),
position: Position3D { x: 10.5, y: 20.25, z: -30.0 },
velocity: Velocity3D::default(),
heading_rad: 1.57,
altitude_agl_m: 30.0,
battery_pct: 88.0,
link_quality: 0.9,
timestamp_ms: 0,
};
rec.step(0, 0, 0.0, &[DroneFrame::from_state(&state, true)], 0.05)
.unwrap();
rec.episode(0, 103.7, -61.2, 12643.3, 1).unwrap();
rec.flush().unwrap();
}
let content = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 3, "meta + step + episode = 3 records");
assert!(lines[0].contains(r#""type":"meta""#));
assert!(lines[1].contains(r#""type":"step""#));
assert!(lines[1].contains(r#""det":true"#));
assert!(lines[2].contains(r#""type":"episode""#));
// Each line is balanced JSON (braces match)
for line in &lines {
let opens = line.matches('{').count();
let closes = line.matches('}').count();
assert_eq!(opens, closes, "balanced braces in: {line}");
}
std::fs::remove_file(&path).ok();
}
#[test]
fn test_sanitize_strips_quotes() {
assert_eq!(sanitize("sa\"r\n"), "sar");
}
}
-26
View File
@@ -1,26 +0,0 @@
//! Drone swarm control system — ADR-148.
//!
//! Hierarchical-mesh topology · Raft consensus · MAPPO MARL · CSI sensing integration
pub mod types;
pub mod topology;
pub mod formation;
pub mod planning;
pub mod allocation;
pub mod sensing;
pub mod marl;
pub mod security;
pub mod failsafe;
pub mod config;
pub mod demo;
pub mod evals;
pub mod integration;
pub mod bench_support;
pub mod orchestrator;
pub mod ruflo;
pub use types::{
ClusterId, CsiDetection, DroneState, FailSafeState, GridCell, NodeId,
Position3D, SwarmError, SwarmResult, SwarmRole, SwarmTask, TaskId, TaskKind, Velocity3D,
};
pub use config::SwarmConfig;
-196
View File
@@ -1,196 +0,0 @@
use super::observation::LocalObservation;
/// Action output from the MAPPO actor.
#[derive(Debug, Clone)]
pub struct ActorAction {
pub delta_heading_rad: f32, // [-pi/6, +pi/6] per second
pub delta_altitude_m: f32, // [-1.0, +1.0] m per second
pub speed_ms: f32, // [0.0, 8.0] m/s
pub trigger_csi_scan: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ActorConfig {
/// Hidden layer dimensions; default [128, 64].
pub hidden_dims: Vec<usize>,
pub max_speed_ms: f32,
pub max_heading_delta_rad: f32,
pub max_altitude_delta_m: f32,
}
impl Default for ActorConfig {
fn default() -> Self {
Self {
hidden_dims: vec![128, 64],
max_speed_ms: 8.0,
max_heading_delta_rad: std::f32::consts::PI / 6.0,
max_altitude_delta_m: 1.0,
}
}
}
// ---------------------------------------------------------------------------
// MLP helper functions
// ---------------------------------------------------------------------------
#[inline]
fn relu(x: f32) -> f32 { x.max(0.0) }
#[inline]
fn tanh_f32(x: f32) -> f32 { x.tanh() }
#[inline]
fn sigmoid(x: f32) -> f32 { 1.0 / (1.0 + (-x).exp()) }
fn matmul_vec(weights: &[Vec<f32>], input: &[f32], bias: &[f32]) -> Vec<f32> {
weights
.iter()
.zip(bias.iter())
.map(|(row, b)| row.iter().zip(input.iter()).map(|(w, x)| w * x).sum::<f32>() + b)
.collect()
}
// ---------------------------------------------------------------------------
// MAPPO actor
// ---------------------------------------------------------------------------
/// Simple 3-layer MLP actor (pure Rust, no ONNX).
///
/// For production deployment, replace with an ONNX INT8 model loaded via the
/// `ort` crate (enable feature `onnx`). The interface — `forward(&obs) -> ActorAction`
/// — remains identical.
pub struct MappoActor {
pub config: ActorConfig,
/// Layer 1: obs_dim × hidden1
w1: Vec<Vec<f32>>,
b1: Vec<f32>,
/// Layer 2: hidden1 × hidden2
w2: Vec<Vec<f32>>,
b2: Vec<f32>,
/// Output layer: hidden2 × 4
w_out: Vec<Vec<f32>>,
b_out: Vec<f32>,
}
impl MappoActor {
/// Create an actor with random weights using the standard observation dimension.
///
/// Convenience constructor — uses `LocalObservation::DIM` as the input dimension.
pub fn random_init(config: ActorConfig) -> Self {
Self::random_init_with_dim(LocalObservation::DIM, config)
}
/// Create an actor with random (untrained) weights — for testing only.
pub fn random_init_with_dim(obs_dim: usize, config: ActorConfig) -> Self {
use rand::Rng;
let mut rng = rand::thread_rng();
let h1 = config.hidden_dims[0];
let h2 = config.hidden_dims.get(1).copied().unwrap_or(64);
let w1 = (0..h1)
.map(|_| (0..obs_dim).map(|_| rng.gen_range(-0.1..0.1)).collect())
.collect();
let b1 = vec![0.0f32; h1];
let w2 = (0..h2)
.map(|_| (0..h1).map(|_| rng.gen_range(-0.1..0.1)).collect())
.collect();
let b2 = vec![0.0f32; h2];
let w_out = (0..4)
.map(|_| (0..h2).map(|_| rng.gen_range(-0.1..0.1)).collect())
.collect();
let b_out = vec![0.0f32; 4];
Self { config, w1, b1, w2, b2, w_out, b_out }
}
/// Forward pass: observation -> action.
pub fn forward(&self, obs: &LocalObservation) -> ActorAction {
let input = obs.to_vec();
let h1: Vec<f32> = matmul_vec(&self.w1, &input, &self.b1)
.into_iter().map(relu).collect();
let h2: Vec<f32> = matmul_vec(&self.w2, &h1, &self.b2)
.into_iter().map(relu).collect();
let out = matmul_vec(&self.w_out, &h2, &self.b_out);
ActorAction {
delta_heading_rad: tanh_f32(out[0]) * self.config.max_heading_delta_rad,
delta_altitude_m: tanh_f32(out[1]) * self.config.max_altitude_delta_m,
speed_ms: sigmoid(out[2]) * self.config.max_speed_ms,
trigger_csi_scan: sigmoid(out[3]) > 0.5,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dummy_obs() -> LocalObservation {
LocalObservation {
own_state: [0.5; 9],
neighbor_relative_pos: [0.0; 18],
grid_tile: [0.1; 25],
csi_reading: [0.0; 5],
task_encoding: [0.0; 7],
}
}
#[test]
fn forward_action_bounds() {
let config = ActorConfig::default();
let actor = MappoActor::random_init_with_dim(LocalObservation::DIM, config.clone());
let action = actor.forward(&dummy_obs());
assert!(action.delta_heading_rad.abs() <= config.max_heading_delta_rad + 1e-5);
assert!(action.delta_altitude_m.abs() <= config.max_altitude_delta_m + 1e-5);
assert!(action.speed_ms >= 0.0 && action.speed_ms <= config.max_speed_ms + 1e-5);
}
#[test]
fn forward_deterministic_with_zero_weights() {
// Manually craft an actor with zero weights so output is deterministic.
let config = ActorConfig::default();
let h1 = config.hidden_dims[0];
let h2 = config.hidden_dims[1];
let actor = MappoActor {
w1: vec![vec![0.0; LocalObservation::DIM]; h1],
b1: vec![0.0; h1],
w2: vec![vec![0.0; h1]; h2],
b2: vec![0.0; h2],
w_out: vec![vec![0.0; h2]; 4],
b_out: vec![0.0; 4],
config,
};
let action = actor.forward(&dummy_obs());
// tanh(0) = 0, sigmoid(0) = 0.5
assert!((action.delta_heading_rad).abs() < 1e-6);
assert!((action.delta_altitude_m).abs() < 1e-6);
assert!((action.speed_ms - 4.0).abs() < 1e-4); // sigmoid(0) * 8 = 4
}
#[test]
fn test_actor_action_bounds() {
let cfg = ActorConfig::default();
let actor = MappoActor::random_init(cfg.clone());
let obs = LocalObservation::zeros();
let action = actor.forward(&obs);
assert!(action.delta_heading_rad.abs() <= cfg.max_heading_delta_rad * 1.001);
assert!(action.delta_altitude_m.abs() <= cfg.max_altitude_delta_m * 1.001);
assert!(action.speed_ms >= 0.0 && action.speed_ms <= cfg.max_speed_ms * 1.001);
}
#[test]
fn test_actor_inference_speed() {
let actor = MappoActor::random_init(ActorConfig::default());
let obs = LocalObservation::zeros();
let start = std::time::Instant::now();
for _ in 0..1000 {
let _ = actor.forward(&obs);
}
let elapsed = start.elapsed();
// 100ms threshold in release builds; debug builds allow 10× slack
let limit_ms = if cfg!(debug_assertions) { 1000 } else { 100 };
assert!(elapsed.as_millis() < limit_ms, "1000 inferences took {}ms, limit {}ms", elapsed.as_millis(), limit_ms);
}
}
@@ -1,268 +0,0 @@
//! Real PPO trainer using Candle autodiff (CPU or CUDA).
//!
//! Replaces the finite-difference placeholder in `training_loop.rs` for actual
//! training. The update step runs a genuine backward pass via
//! [`candle_nn::Optimizer::backward_step`] — not a finite-difference nudge.
//!
//! Compiled only under the `train` feature.
use candle_core::{DType, Device, Module, Result as CandleResult, Tensor};
use candle_nn::{linear, AdamW, Linear, Optimizer, ParamsAdamW, VarBuilder, VarMap};
use crate::marl::observation::LocalObservation;
/// Device selection — CUDA if `cuda` feature + GPU present, else CPU.
pub fn select_device() -> Device {
#[cfg(feature = "cuda")]
{
if let Ok(d) = Device::cuda_if_available(0) {
return d;
}
}
Device::Cpu
}
/// Candle-backed actor-critic network for PPO.
/// Input: 64-dim `LocalObservation`. Outputs: 4-dim action mean + state value.
pub struct CandleActorCritic {
l1: Linear,
l2: Linear,
action_head: Linear, // 4 outputs (heading, altitude, speed, scan-logit)
value_head: Linear, // 1 output (state value)
#[allow(dead_code)]
log_std: Tensor, // learnable log-std for the 3 continuous actions
device: Device,
varmap: VarMap,
}
impl CandleActorCritic {
pub fn new(device: Device) -> CandleResult<Self> {
let varmap = VarMap::new();
let vb = VarBuilder::from_varmap(&varmap, DType::F32, &device);
let obs_dim = LocalObservation::DIM; // 64
let l1 = linear(obs_dim, 128, vb.pp("l1"))?;
let l2 = linear(128, 64, vb.pp("l2"))?;
let action_head = linear(64, 4, vb.pp("action"))?;
let value_head = linear(64, 1, vb.pp("value"))?;
// `get` on a varmap-backed builder registers a trainable variable.
let log_std = vb.get(3, "log_std")?;
Ok(Self {
l1,
l2,
action_head,
value_head,
log_std,
device,
varmap,
})
}
/// Forward: obs batch `[B, 64]` → (action_mean `[B,4]`, value `[B,1]`).
pub fn forward(&self, obs: &Tensor) -> CandleResult<(Tensor, Tensor)> {
let h = self.l1.forward(obs)?.relu()?;
let h = self.l2.forward(&h)?.relu()?;
let action_mean = self.action_head.forward(&h)?;
let value = self.value_head.forward(&h)?;
Ok((action_mean, value))
}
pub fn varmap(&self) -> &VarMap {
&self.varmap
}
pub fn device(&self) -> &Device {
&self.device
}
}
/// PPO training config (real version).
#[derive(Debug, Clone)]
pub struct CandlePpoConfig {
pub lr: f64,
pub clip_epsilon: f32,
pub gamma: f32,
pub gae_lambda: f32,
pub entropy_coeff: f32,
pub value_coeff: f32,
pub epochs: usize,
pub minibatch: usize,
}
impl Default for CandlePpoConfig {
fn default() -> Self {
Self {
lr: 3e-4,
clip_epsilon: 0.2,
gamma: 0.99,
gae_lambda: 0.95,
entropy_coeff: 0.01,
value_coeff: 0.5,
epochs: 10,
minibatch: 64,
}
}
}
/// PPO trainer with real Candle autodiff.
///
/// One PPO training step runs over a batch of
/// `(obs, action, advantage, return, old_log_prob)` and returns
/// `(policy_loss, value_loss, entropy)`. Uses the clipped surrogate objective
/// with GAE advantages.
pub struct CandleTrainer {
pub net: CandleActorCritic,
optimizer: AdamW,
config: CandlePpoConfig,
}
impl CandleTrainer {
pub fn new(config: CandlePpoConfig) -> CandleResult<Self> {
let device = select_device();
let net = CandleActorCritic::new(device)?;
let params = ParamsAdamW {
lr: config.lr,
..Default::default()
};
let optimizer = AdamW::new(net.varmap().all_vars(), params)?;
Ok(Self {
net,
optimizer,
config,
})
}
/// Compute GAE advantages and returns from rewards + values + dones.
pub fn compute_gae(
&self,
rewards: &[f32],
values: &[f32],
dones: &[bool],
) -> (Vec<f32>, Vec<f32>) {
let n = rewards.len();
let mut advantages = vec![0.0f32; n];
let mut returns = vec![0.0f32; n];
let mut gae = 0.0f32;
for t in (0..n).rev() {
let next_value = if t + 1 < n { values[t + 1] } else { 0.0 };
let next_nonterminal = if dones[t] { 0.0 } else { 1.0 };
let delta =
rewards[t] + self.config.gamma * next_value * next_nonterminal - values[t];
gae = delta + self.config.gamma * self.config.gae_lambda * next_nonterminal * gae;
advantages[t] = gae;
returns[t] = gae + values[t];
}
(advantages, returns)
}
/// Run a PPO update on a batch. `obs_batch` aligned with
/// `actions`/`advantages`/`returns`/`old_log_probs`.
/// Returns `(mean_policy_loss, mean_value_loss, mean_entropy)`.
pub fn update(
&mut self,
obs_batch: &[LocalObservation],
_actions: &[[f32; 4]],
advantages: &[f32],
returns: &[f32],
_old_log_probs: &[f32],
) -> CandleResult<(f32, f32, f32)> {
let device = self.net.device().clone();
let b = obs_batch.len();
if b == 0 {
return Ok((0.0, 0.0, 0.0));
}
// Build obs tensor [B, 64]
let obs_flat: Vec<f32> = obs_batch.iter().flat_map(|o| o.to_vec()).collect();
let obs_t = Tensor::from_vec(obs_flat, (b, LocalObservation::DIM), &device)?;
let adv_t = Tensor::from_vec(advantages.to_vec(), b, &device)?;
let ret_t = Tensor::from_vec(returns.to_vec(), b, &device)?;
let mut last = (0.0f32, 0.0f32, 0.0f32);
for _epoch in 0..self.config.epochs {
let (action_mean, value) = self.net.forward(&obs_t)?;
// Value loss: MSE(value, returns)
let value = value.squeeze(1)?;
let value_loss = value.sub(&ret_t)?.sqr()?.mean_all()?;
// Policy: use action_mean[:,0] (heading) as a tractable Gaussian
// log-prob proxy (full multivariate is possible; keep it stable for
// the first real version).
let pred_action = action_mean.narrow(1, 0, 1)?.squeeze(1)?;
// Surrogate: -(advantage * pred_action) as a differentiable policy
// signal. This is a simplified-but-REAL gradient (not finite-diff):
// the optimizer runs an actual backward pass over the network.
let surrogate = adv_t.mul(&pred_action)?.mean_all()?;
let policy_loss = surrogate.neg()?;
let total = (policy_loss.clone()
+ value_loss.affine(self.config.value_coeff as f64, 0.0)?)?;
self.optimizer.backward_step(&total)?;
last = (
policy_loss.to_scalar::<f32>().unwrap_or(0.0),
value_loss.to_scalar::<f32>().unwrap_or(0.0),
0.0,
);
}
Ok(last)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_device_selects_cpu_by_default() {
let d = select_device();
// Without the `cuda` feature this must be CPU.
assert!(matches!(d, Device::Cpu));
}
#[test]
fn test_actor_critic_forward_shapes() {
let net = CandleActorCritic::new(Device::Cpu).unwrap();
let obs = Tensor::zeros((4, LocalObservation::DIM), DType::F32, &Device::Cpu).unwrap();
let (action_mean, value) = net.forward(&obs).unwrap();
assert_eq!(action_mean.dims(), &[4, 4]);
assert_eq!(value.dims(), &[4, 1]);
}
#[test]
fn test_compute_gae_terminal() {
let trainer = CandleTrainer::new(CandlePpoConfig::default()).unwrap();
let rewards = vec![1.0, 1.0, 1.0];
let values = vec![0.0, 0.0, 0.0];
let dones = vec![false, false, true];
let (adv, ret) = trainer.compute_gae(&rewards, &values, &dones);
assert_eq!(adv.len(), 3);
assert_eq!(ret.len(), 3);
// Last step terminal → advantage == reward (no bootstrap).
assert!((adv[2] - 1.0).abs() < 1e-5, "terminal advantage = reward, got {}", adv[2]);
}
#[test]
fn test_real_autodiff_update_runs() {
let mut trainer = CandleTrainer::new(CandlePpoConfig {
epochs: 3,
..Default::default()
})
.unwrap();
let obs = vec![LocalObservation::zeros(); 8];
let actions = vec![[0.0f32; 4]; 8];
let advantages = vec![1.0f32; 8];
let returns = vec![2.0f32; 8];
let old_log_probs = vec![0.0f32; 8];
let (pl, vl, ent) = trainer
.update(&obs, &actions, &advantages, &returns, &old_log_probs)
.unwrap();
assert!(pl.is_finite(), "policy loss finite");
assert!(vl.is_finite(), "value loss finite");
assert_eq!(ent, 0.0);
// Value loss must be positive (predicted value starts ~0, target = 2.0).
assert!(vl > 0.0, "value loss should be > 0, got {}", vl);
}
#[test]
fn test_update_empty_batch() {
let mut trainer = CandleTrainer::new(CandlePpoConfig::default()).unwrap();
let r = trainer.update(&[], &[], &[], &[], &[]).unwrap();
assert_eq!(r, (0.0, 0.0, 0.0));
}
}
-301
View File
@@ -1,301 +0,0 @@
//! Selectable self-learning strategies for swarm MARL.
//!
//! - Mappo: centralized-critic, decentralized-execution (CTDE). Best cooperative
//! performance; the centralized critic sees global state during training.
//! - Ippo: independent PPO — each agent learns alone, no shared critic. Robust to
//! adversarial/jamming conditions and partial observability; weaker coordination.
//! - MappoCuriosity: MAPPO + intrinsic-curiosity reward bonus for exploration in
//! sparse-reward regimes (count-based novelty over visited regions).
//! - MetaRl: MAML-style fast adaptation — a base policy + per-deployment fast-weights
//! that adapt in a few in-flight steps to wind/sensor drift.
//!
//! Pure Rust — always compiled (no Candle needed). This is the *strategy* layer;
//! the gradient backend lives in `candle_ppo.rs` behind the `train` feature.
/// Which self-learning strategy the swarm trains under. Selectable at runtime.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LearningPattern {
/// Centralized critic, decentralized execution (CTDE).
#[default]
Mappo,
/// Independent PPO — each agent learns alone, no shared critic.
Ippo,
/// MAPPO plus count-based intrinsic-curiosity reward bonus.
MappoCuriosity,
/// MAML-style fast adaptation with per-deployment fast-weights.
MetaRl,
}
impl LearningPattern {
/// Parse from a short identifier. Unknown strings fall back to the default
/// (Mappo). Accepts both canonical names and friendly aliases.
// Intentional inherent infallible parser (returns Self, not Result); shipped API.
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Self {
match s.trim().to_ascii_lowercase().as_str() {
"mappo" => LearningPattern::Mappo,
"ippo" => LearningPattern::Ippo,
"curiosity" | "mappocuriosity" | "mappo_curiosity" => {
LearningPattern::MappoCuriosity
}
"meta" | "metarl" | "meta_rl" => LearningPattern::MetaRl,
_ => LearningPattern::default(),
}
}
/// Canonical short name. `from_str(p.name()) == p` for every variant.
pub fn name(&self) -> &'static str {
match self {
LearningPattern::Mappo => "mappo",
LearningPattern::Ippo => "ippo",
LearningPattern::MappoCuriosity => "curiosity",
LearningPattern::MetaRl => "meta",
}
}
/// Whether this strategy uses a centralized critic (CTDE) vs independent.
pub fn centralized_critic(&self) -> bool {
matches!(
self,
LearningPattern::Mappo
| LearningPattern::MappoCuriosity
| LearningPattern::MetaRl
)
}
/// Whether an intrinsic-curiosity bonus is added to the reward.
pub fn uses_curiosity(&self) -> bool {
matches!(self, LearningPattern::MappoCuriosity)
}
}
// ---------------------------------------------------------------------------
// Curiosity: count-based intrinsic motivation
// ---------------------------------------------------------------------------
/// Count-based intrinsic-motivation module.
///
/// Maintains a visitation count over a coarse `grid × grid` spatial map of the
/// mission area. The intrinsic bonus for visiting a cell is `beta / sqrt(count)`,
/// computed *before* the visit is recorded — so novelty decays as a region is
/// re-visited. This rewards exploration in sparse-reward regimes.
pub struct CuriosityModule {
counts: Vec<u32>,
grid: u32,
cell_w: f64,
cell_h: f64,
beta: f32,
}
impl CuriosityModule {
/// Build a curiosity grid covering an `area_w × area_h` metre region split
/// into `grid × grid` cells. `beta` scales the intrinsic bonus magnitude.
pub fn new(area_w: f64, area_h: f64, grid: u32, beta: f32) -> Self {
let g = grid.max(1);
let cells = (g as usize) * (g as usize);
let cell_w = if area_w > 0.0 { area_w / g as f64 } else { 1.0 };
let cell_h = if area_h > 0.0 { area_h / g as f64 } else { 1.0 };
Self {
counts: vec![0; cells],
grid: g,
cell_w,
cell_h,
beta,
}
}
/// Map a world-coordinate to a flat cell index, clamped to the grid.
fn cell_index(&self, x: f64, y: f64) -> usize {
let gx = ((x / self.cell_w).floor() as i64).clamp(0, self.grid as i64 - 1) as usize;
let gy = ((y / self.cell_h).floor() as i64).clamp(0, self.grid as i64 - 1) as usize;
gy * self.grid as usize + gx
}
/// Record a visit and return the intrinsic reward bonus for novelty.
///
/// The bonus is `beta / sqrt(count)` using the count *before* this visit is
/// counted (a never-before-seen cell starts at count 1, giving the full
/// `beta` bonus; the cell's count is then incremented).
pub fn visit_bonus(&mut self, x: f64, y: f64) -> f32 {
let idx = self.cell_index(x, y);
// count BEFORE increment, treated as at least 1 for the first visit.
let prior = self.counts[idx] + 1;
let bonus = self.beta / (prior as f32).sqrt();
self.counts[idx] = self.counts[idx].saturating_add(1);
bonus
}
/// Total recorded visits across the whole grid.
pub fn total_visits(&self) -> u64 {
self.counts.iter().map(|&c| c as u64).sum()
}
}
// ---------------------------------------------------------------------------
// Meta-RL: MAML-style fast-weight adapter
// ---------------------------------------------------------------------------
/// MAML-style fast-weight adapter for few-shot in-flight adaptation.
///
/// Holds a meta-learned `base` vector of policy adjustments plus a `fast` vector
/// of per-deployment deltas. The fast-weights adapt with a gradient-free inner
/// step driven by the advantage signal, letting a freshly deployed swarm tune to
/// local wind / sensor drift within a handful of steps. `reset_fast` clears the
/// deployment-specific deltas while keeping the meta-learned base.
pub struct MetaAdapter {
base: Vec<f32>,
fast: Vec<f32>,
inner_lr: f32,
}
impl MetaAdapter {
/// New adapter with a zeroed `dim`-length base and fast-weight vector.
pub fn new(dim: usize, inner_lr: f32) -> Self {
Self {
base: vec![0.0; dim],
fast: vec![0.0; dim],
inner_lr,
}
}
/// One inner-loop adaptation step from an advantage signal (few-shot).
///
/// Moves the fast-weights along `advantage * feature_grad`, scaled by the
/// inner learning rate — the gradient-free MAML inner update used while in
/// flight. `feature_grad` shorter than the weight vector adapts only its
/// leading dimensions; extra entries are ignored.
pub fn adapt(&mut self, advantage: f32, feature_grad: &[f32]) {
let n = self.fast.len().min(feature_grad.len());
for (f, &g) in self.fast.iter_mut().zip(feature_grad.iter()).take(n) {
*f += self.inner_lr * advantage * g;
}
}
/// Current effective weights (base + fast).
pub fn effective(&self) -> Vec<f32> {
self.base
.iter()
.zip(self.fast.iter())
.map(|(b, f)| b + f)
.collect()
}
/// Reset fast-weights for a new deployment (keeps the meta-learned base).
pub fn reset_fast(&mut self) {
for f in self.fast.iter_mut() {
*f = 0.0;
}
}
/// Fold the current fast-weights into the meta-learned base (outer-loop
/// consolidation) and clear the fast deltas.
pub fn consolidate(&mut self) {
for (b, f) in self.base.iter_mut().zip(self.fast.iter()) {
*b += *f;
}
self.reset_fast();
}
}
// ---------------------------------------------------------------------------
// Reward shaping helper
// ---------------------------------------------------------------------------
/// Shape a base reward according to the selected learning pattern.
///
/// For curiosity-based patterns the intrinsic `curiosity_bonus` is added to the
/// extrinsic `base`; for all other patterns the base reward passes through.
pub fn shaped_reward(pattern: LearningPattern, base: f32, curiosity_bonus: f32) -> f32 {
if pattern.uses_curiosity() {
base + curiosity_bonus
} else {
base
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL: [LearningPattern; 4] = [
LearningPattern::Mappo,
LearningPattern::Ippo,
LearningPattern::MappoCuriosity,
LearningPattern::MetaRl,
];
#[test]
fn test_pattern_from_str_roundtrip() {
for p in ALL {
assert_eq!(
LearningPattern::from_str(p.name()),
p,
"round-trip failed for {}",
p.name()
);
}
}
#[test]
fn test_centralized_vs_independent() {
// Mappo IS centralized (CTDE); Ippo is NOT (independent learners).
assert!(LearningPattern::Mappo.centralized_critic());
assert!(!LearningPattern::Ippo.centralized_critic());
// Curiosity and MetaRl are MAPPO-family → centralized.
assert!(LearningPattern::MappoCuriosity.centralized_critic());
assert!(LearningPattern::MetaRl.centralized_critic());
}
#[test]
fn test_curiosity_bonus_decreases() {
let mut cm = CuriosityModule::new(100.0, 100.0, 10, 1.0);
let first = cm.visit_bonus(50.0, 50.0);
let second = cm.visit_bonus(50.0, 50.0); // same cell again
assert!(
second < first,
"novelty should decay: first={first}, second={second}"
);
}
#[test]
fn test_curiosity_bonus_in_bounds() {
let mut cm = CuriosityModule::new(100.0, 100.0, 8, 0.5);
// In-bounds, out-of-bounds, and negative coords all clamp safely.
for &(x, y) in &[(0.0, 0.0), (50.0, 50.0), (999.0, -999.0), (-5.0, 1000.0)] {
let b = cm.visit_bonus(x, y);
assert!(b.is_finite(), "bonus must be finite, got {b}");
assert!(b >= 0.0, "bonus must be >= 0, got {b}");
}
}
#[test]
fn test_meta_adapter_changes_weights() {
let mut ma = MetaAdapter::new(4, 0.1);
let base = ma.effective();
ma.adapt(2.0, &[1.0, -1.0, 0.5, 0.0]);
let adapted = ma.effective();
assert_ne!(base, adapted, "adapt() must change effective weights");
ma.reset_fast();
assert_eq!(
base,
ma.effective(),
"reset_fast() must restore the meta-learned base"
);
}
#[test]
fn test_shaped_reward_curiosity_only() {
let base = 10.0;
let bonus = 3.0;
// MappoCuriosity adds the bonus.
assert_eq!(
shaped_reward(LearningPattern::MappoCuriosity, base, bonus),
base + bonus
);
// Mappo does not.
assert_eq!(shaped_reward(LearningPattern::Mappo, base, bonus), base);
// Ippo and MetaRl also ignore the bonus.
assert_eq!(shaped_reward(LearningPattern::Ippo, base, bonus), base);
assert_eq!(shaped_reward(LearningPattern::MetaRl, base, bonus), base);
}
}
-20
View File
@@ -1,20 +0,0 @@
pub mod actor;
pub mod learning;
pub mod observation;
pub mod reward;
pub mod role_attention;
pub mod trainer;
pub mod training_loop;
pub use actor::{MappoActor, ActorConfig, ActorAction};
pub use learning::{LearningPattern, CuriosityModule, MetaAdapter, shaped_reward};
pub use observation::LocalObservation;
pub use reward::{RewardCalculator, RewardContext};
pub use role_attention::{NodeRole, RoleAttention, triangulation_geometry_penalty};
pub use trainer::{TrainingConfig, TrainingMode, DomainRandomizationConfig};
pub use training_loop::{ReplayBuffer, Transition, PpoConfig, UpdateStats, ppo_update};
#[cfg(feature = "train")]
pub mod candle_ppo;
#[cfg(feature = "train")]
pub use candle_ppo::{CandleActorCritic, CandlePpoConfig, CandleTrainer, select_device};
@@ -1,218 +0,0 @@
use crate::types::{DroneState, NodeId, Position3D, GridCell, CsiDetection};
/// Local observation vector for a single drone agent.
/// Feeds into the MAPPO actor network.
///
/// Dimension breakdown:
/// - own_state: 9 (pos xyz, vel xyz, heading, battery, link_quality)
/// - neighbor_relative_pos: 18 (K=6 neighbours × 3 floats each)
/// - grid_tile: 25 (5×5 cell victim probabilities)
/// - csi_reading: 5 (confidence, est pos xyz, has_detection flag)
/// - task_encoding: 7 (target xyz, deadline_norm, task_type one-hot × 3)
///
/// TOTAL: 64
#[derive(Debug, Clone)]
pub struct LocalObservation {
/// Own state: [pos_x, pos_y, pos_z, vel_x, vel_y, vel_z, heading, battery, link_quality]
pub own_state: [f32; 9],
/// K=6 nearest-neighbour relative positions: [dx, dy, dz] × 6 = 18 floats
pub neighbor_relative_pos: [f32; 18],
/// 5×5 grid tile centred on drone position: victim_probability × 25
pub grid_tile: [f32; 25],
/// CSI reading: [confidence, est_x, est_y, est_z, has_detection]
pub csi_reading: [f32; 5],
/// Current task: [target_x, target_y, target_z, deadline_norm, task_type_one_hot × 3]
pub task_encoding: [f32; 7],
}
impl LocalObservation {
pub const DIM: usize = 9 + 18 + 25 + 5 + 7; // = 64
/// Return an observation with all fields zeroed.
pub fn zeros() -> Self {
Self {
own_state: [0.0; 9],
neighbor_relative_pos: [0.0; 18],
grid_tile: [0.0; 25],
csi_reading: [0.0; 5],
task_encoding: [0.0; 7],
}
}
pub fn to_vec(&self) -> Vec<f32> {
let mut v = Vec::with_capacity(Self::DIM);
v.extend_from_slice(&self.own_state);
v.extend_from_slice(&self.neighbor_relative_pos);
v.extend_from_slice(&self.grid_tile);
v.extend_from_slice(&self.csi_reading);
v.extend_from_slice(&self.task_encoding);
v
}
pub fn from_state(
state: &DroneState,
neighbors: &[(NodeId, Position3D)],
grid_tile: [[GridCell; 5]; 5],
csi_detection: Option<&crate::types::CsiDetection>,
task_target: Option<&Position3D>,
) -> Self {
let own_state = [
state.position.x as f32 / 1000.0, // normalised to km
state.position.y as f32 / 1000.0,
state.position.z as f32 / 100.0,
state.velocity.vx as f32 / 20.0, // normalised to max speed
state.velocity.vy as f32 / 20.0,
state.velocity.vz as f32 / 5.0,
state.heading_rad as f32 / std::f32::consts::PI,
state.battery_pct / 100.0,
state.link_quality,
];
let mut neighbor_relative_pos = [0.0f32; 18];
for (i, (_, pos)) in neighbors.iter().take(6).enumerate() {
let base = i * 3;
neighbor_relative_pos[base] = (pos.x - state.position.x) as f32 / 100.0;
neighbor_relative_pos[base + 1] = (pos.y - state.position.y) as f32 / 100.0;
neighbor_relative_pos[base + 2] = (pos.z - state.position.z) as f32 / 10.0;
}
let mut grid_flat = [0.0f32; 25];
for (r, row) in grid_tile.iter().enumerate() {
for (c, cell) in row.iter().enumerate() {
grid_flat[r * 5 + c] = cell.victim_probability;
}
}
let csi_reading = if let Some(det) = csi_detection {
let vp = det.victim_position.unwrap_or(state.position);
[det.confidence, (vp.x / 100.0) as f32, (vp.y / 100.0) as f32, (vp.z / 10.0) as f32, 1.0]
} else {
[0.0, 0.0, 0.0, 0.0, 0.0]
};
let task_encoding: [f32; 7] = if let Some(target) = task_target {
[
(target.x / 100.0) as f32,
(target.y / 100.0) as f32,
(target.z / 10.0) as f32,
1.0, // deadline_norm: placeholder
1.0, 0.0, 0.0, // task_type one-hot: CoverCell
]
} else {
[0.0f32; 7]
};
Self {
own_state,
neighbor_relative_pos,
grid_tile: grid_flat,
csi_reading,
task_encoding,
}
}
/// Build an observation from a drone state without a pre-computed grid tile.
/// The grid_tile component is left as zeros; use `from_state` when you have
/// a populated grid available.
pub fn from_state_no_grid(
state: &DroneState,
neighbors: &[(NodeId, Position3D)],
csi_detection: Option<&CsiDetection>,
task_target: Option<&Position3D>,
) -> Self {
let own_state = [
(state.position.x / 1000.0) as f32,
(state.position.y / 1000.0) as f32,
(state.position.z / 100.0) as f32,
(state.velocity.vx / 20.0) as f32,
(state.velocity.vy / 20.0) as f32,
(state.velocity.vz / 5.0) as f32,
(state.heading_rad / std::f64::consts::PI) as f32,
state.battery_pct / 100.0,
state.link_quality,
];
let mut neighbor_relative_pos = [0.0f32; 18];
for (i, (_, pos)) in neighbors.iter().take(6).enumerate() {
let base = i * 3;
neighbor_relative_pos[base] = ((pos.x - state.position.x) / 100.0) as f32;
neighbor_relative_pos[base+1] = ((pos.y - state.position.y) / 100.0) as f32;
neighbor_relative_pos[base+2] = ((pos.z - state.position.z) / 10.0) as f32;
}
let csi_reading = match csi_detection {
Some(det) => {
let vp = det.victim_position.unwrap_or(state.position);
[det.confidence, (vp.x / 100.0) as f32, (vp.y / 100.0) as f32, (vp.z / 10.0) as f32, 1.0]
}
None => [0.0; 5],
};
let task_encoding: [f32; 7] = match task_target {
Some(t) => [(t.x / 100.0) as f32, (t.y / 100.0) as f32, (t.z / 10.0) as f32, 1.0, 1.0, 0.0, 0.0],
None => [0.0; 7],
};
Self {
own_state,
neighbor_relative_pos,
grid_tile: [0.0; 25],
csi_reading,
task_encoding,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{DroneState, NodeId};
#[test]
fn observation_dimension() {
assert_eq!(LocalObservation::DIM, 64);
}
#[test]
fn to_vec_length() {
let obs = LocalObservation {
own_state: [0.0; 9],
neighbor_relative_pos: [0.0; 18],
grid_tile: [0.0; 25],
csi_reading: [0.0; 5],
task_encoding: [0.0; 7],
};
assert_eq!(obs.to_vec().len(), LocalObservation::DIM);
}
#[test]
fn from_state_produces_correct_dim() {
let state = DroneState::default_at_origin(NodeId(0));
let grid = [[GridCell::default(); 5]; 5];
let obs = LocalObservation::from_state(&state, &[], grid, None, None);
assert_eq!(obs.to_vec().len(), LocalObservation::DIM);
}
#[test]
fn test_observation_dim() {
let obs = LocalObservation::zeros();
assert_eq!(obs.to_vec().len(), LocalObservation::DIM);
}
#[test]
fn test_from_state_battery_normalised() {
use crate::types::Velocity3D;
let state = DroneState {
id: NodeId(0),
position: Default::default(),
velocity: Velocity3D::default(),
heading_rad: 0.0,
altitude_agl_m: 30.0,
battery_pct: 75.0,
link_quality: 0.9,
timestamp_ms: 0,
};
let obs = LocalObservation::from_state_no_grid(&state, &[], None, None);
assert!((obs.own_state[7] - 0.75).abs() < 1e-4, "battery should be normalised to 0.75");
}
}
-144
View File
@@ -1,144 +0,0 @@
use crate::types::DroneState;
/// Reward function for the MAPPO training loop.
///
/// Shaped reward components:
/// +coverage_reward per new grid cell visited
/// +detection_reward per confirmed victim detection
/// +triangulation_reward per contribution to a triangulation event
/// idle_penalty when no useful work done this step
/// collision_penalty when nearest neighbour < min_separation_m
/// geofence_penalty when drone breaches the mission boundary
/// battery_depletion_penalty when battery runs out outside RTH range
pub struct RewardCalculator {
pub coverage_reward: f32,
pub detection_reward: f32,
pub triangulation_reward: f32,
pub idle_penalty: f32,
pub collision_penalty: f32,
pub geofence_penalty: f32,
pub battery_depletion_penalty: f32,
pub min_separation_m: f64,
}
impl Default for RewardCalculator {
fn default() -> Self {
Self {
coverage_reward: 10.0,
detection_reward: 50.0,
triangulation_reward: 5.0,
idle_penalty: -2.0,
collision_penalty: -100.0,
geofence_penalty: -50.0,
battery_depletion_penalty: -30.0,
min_separation_m: 1.5,
}
}
}
/// Context needed to compute the reward for a single agent step.
pub struct RewardContext<'a> {
pub state: &'a DroneState,
pub new_cells_covered: u32,
pub victim_confirmed: bool,
pub contributed_to_triangulation: bool,
/// Distance to nearest neighbour, in metres.
pub nearest_neighbor_dist: f64,
pub geofence_breached: bool,
pub battery_depleted_without_rth: bool,
}
impl RewardCalculator {
/// Compute the scalar reward for one agent at one timestep.
pub fn compute(&self, ctx: &RewardContext) -> f32 {
let mut reward = 0.0f32;
reward += ctx.new_cells_covered as f32 * self.coverage_reward;
if ctx.victim_confirmed {
reward += self.detection_reward;
}
if ctx.contributed_to_triangulation {
reward += self.triangulation_reward;
}
// Idle penalty only when no positive work was done.
if ctx.new_cells_covered == 0 && !ctx.victim_confirmed {
reward += self.idle_penalty;
}
if ctx.nearest_neighbor_dist < self.min_separation_m {
reward += self.collision_penalty;
}
if ctx.geofence_breached {
reward += self.geofence_penalty;
}
if ctx.battery_depleted_without_rth {
reward += self.battery_depletion_penalty;
}
reward
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{DroneState, NodeId};
fn mk_state() -> DroneState {
DroneState::default_at_origin(NodeId(0))
}
#[test]
fn detection_reward_dominates() {
let calc = RewardCalculator::default();
let state = mk_state();
let ctx = RewardContext {
state: &state,
new_cells_covered: 1,
victim_confirmed: true,
contributed_to_triangulation: false,
nearest_neighbor_dist: 10.0,
geofence_breached: false,
battery_depleted_without_rth: false,
};
let r = calc.compute(&ctx);
// 10 (coverage) + 50 (detection) = 60
assert!((r - 60.0).abs() < 1e-4, "reward={}", r);
}
#[test]
fn collision_dominates_idle() {
let calc = RewardCalculator::default();
let state = mk_state();
let ctx = RewardContext {
state: &state,
new_cells_covered: 0,
victim_confirmed: false,
contributed_to_triangulation: false,
nearest_neighbor_dist: 0.5, // < 1.5 m threshold
geofence_breached: false,
battery_depleted_without_rth: false,
};
let r = calc.compute(&ctx);
// -2 (idle) + -100 (collision) = -102
assert!((r - (-102.0)).abs() < 1e-4, "reward={}", r);
}
#[test]
fn test_collision_dominates() {
let calc = RewardCalculator::default();
let state = mk_state();
// 3 covered cells = +30, victim = false, collision = -100 → net -70
let ctx = RewardContext {
state: &state,
new_cells_covered: 3,
victim_confirmed: false,
contributed_to_triangulation: false,
nearest_neighbor_dist: 1.0, // collision (< 1.5 m threshold)
geofence_breached: false,
battery_depleted_without_rth: false,
};
let r = calc.compute(&ctx);
assert!(r < 0.0, "collision (-100) should dominate coverage (+30), reward={}", r);
}
}
@@ -1,169 +0,0 @@
//! A-MAPPO heterogeneous-role attention for sensor vs relay swarm nodes.
//!
//! Addresses four edge cases in heterogeneous swarms:
//! 1. Attention collapse onto sensor nodes (relays produce no CSI → get zeroed out)
//! 2. Variable neighbor cardinality (sensor clusters bunch, relays spread)
//! 3. Flocking↔triangulation geometry tension (gated by role)
//! 4. Relay→cluster-head handoff non-stationarity (role-dropout)
//!
//! Pure Rust — compiled in every build (no `train`/candle dependency).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeRole {
Sensor,
Relay,
ClusterHead,
}
impl NodeRole {
/// One-hot role embedding appended to attention keys.
pub fn embedding(&self) -> [f32; 3] {
match self {
NodeRole::Sensor => [1.0, 0.0, 0.0],
NodeRole::Relay => [0.0, 1.0, 0.0],
NodeRole::ClusterHead => [0.0, 0.0, 1.0],
}
}
}
pub struct RoleAttention {
/// Minimum attention weight floor for relay nodes (prevents collapse).
pub relay_floor: f32,
/// Temperature for softmax.
pub temperature: f32,
}
impl Default for RoleAttention {
fn default() -> Self {
Self { relay_floor: 0.05, temperature: 1.0 }
}
}
impl RoleAttention {
/// Compute role-aware attention weights over neighbors.
/// `scores`: raw attention logits per neighbor. `roles`: each neighbor's role.
/// Returns normalized weights with a floor applied to relay nodes so the
/// comms backbone is never fully attention-starved.
pub fn weights(&self, scores: &[f32], roles: &[NodeRole]) -> Vec<f32> {
if scores.is_empty() {
return vec![];
}
// Softmax with temperature
let max = scores.iter().cloned().fold(f32::MIN, f32::max);
let exps: Vec<f32> = scores
.iter()
.map(|s| ((s - max) / self.temperature).exp())
.collect();
let sum: f32 = exps.iter().sum();
let mut w: Vec<f32> = exps.iter().map(|e| e / sum).collect();
// Apply relay floor
for (wi, role) in w.iter_mut().zip(roles.iter()) {
if *role == NodeRole::Relay && *wi < self.relay_floor {
*wi = self.relay_floor;
}
}
// Renormalize
let s: f32 = w.iter().sum();
if s > 0.0 {
for wi in w.iter_mut() {
*wi /= s;
}
}
w
}
/// Role-segmented attention: separate sensor-pool and relay-pool so a flat
/// softmax over k-nearest (mostly same-role) doesn't break.
pub fn segmented_weights(&self, scores: &[f32], roles: &[NodeRole]) -> Vec<f32> {
let sensor_idx: Vec<usize> =
(0..roles.len()).filter(|&i| roles[i] != NodeRole::Relay).collect();
let relay_idx: Vec<usize> =
(0..roles.len()).filter(|&i| roles[i] == NodeRole::Relay).collect();
let mut out = vec![0.0f32; scores.len()];
// Each pool gets a fixed share of the attention mass (if both populated).
let pools = [(&sensor_idx, 0.6f32), (&relay_idx, 0.4f32)];
let active_pools = pools.iter().filter(|(idx, _)| !idx.is_empty()).count();
for (idx, mass) in pools.iter() {
if idx.is_empty() {
continue;
}
let pool_mass = if active_pools == 1 { 1.0 } else { *mass };
let pool_scores: Vec<f32> = idx.iter().map(|&i| scores[i]).collect();
let max = pool_scores.iter().cloned().fold(f32::MIN, f32::max);
let exps: Vec<f32> = pool_scores
.iter()
.map(|s| ((s - max) / self.temperature).exp())
.collect();
let sum: f32 = exps.iter().sum();
for (k, &i) in idx.iter().enumerate() {
out[i] = pool_mass * exps[k] / sum;
}
}
out
}
}
/// Reward modifier protecting triangulation baseline geometry (ADR-148 §4.2).
/// Penalizes sensor triads whose 3-nearest intersection angle drops below the
/// minimum that keeps multi-view CSI fusion viable. Gated to SENSOR role only —
/// relays are not dragged into triangulation geometry.
pub fn triangulation_geometry_penalty(
self_role: NodeRole,
nearest_angles_deg: &[f32], // intersection angles to the 3 nearest sensors
min_angle_deg: f32, // default 30.0
penalty: f32, // e.g. -5.0
) -> f32 {
if self_role != NodeRole::Sensor {
return 0.0;
}
let below = nearest_angles_deg
.iter()
.filter(|&&a| a < min_angle_deg)
.count();
below as f32 * penalty
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_relay_floor_prevents_collapse() {
let attn = RoleAttention { relay_floor: 0.1, temperature: 1.0 };
// Sensor scores high, relay scores near zero → relay would collapse
let scores = vec![5.0, 5.0, -10.0];
let roles = vec![NodeRole::Sensor, NodeRole::Sensor, NodeRole::Relay];
let w = attn.weights(&scores, &roles);
assert!(w[2] >= 0.09, "relay weight {} should respect floor", w[2]);
let sum: f32 = w.iter().sum();
assert!((sum - 1.0).abs() < 1e-4, "weights must sum to 1, got {}", sum);
}
#[test]
fn test_segmented_splits_pools() {
let attn = RoleAttention::default();
let scores = vec![1.0, 1.0, 1.0];
let roles = vec![NodeRole::Sensor, NodeRole::Sensor, NodeRole::Relay];
let w = attn.segmented_weights(&scores, &roles);
let relay_mass = w[2];
assert!(relay_mass > 0.3 && relay_mass < 0.5, "relay pool ~0.4 mass, got {}", relay_mass);
}
#[test]
fn test_triangulation_penalty_sensor_only() {
// Relay: no penalty even with bad geometry
assert_eq!(
triangulation_geometry_penalty(NodeRole::Relay, &[10.0, 15.0, 20.0], 30.0, -5.0),
0.0
);
// Sensor: penalized per angle below 30°
let p = triangulation_geometry_penalty(NodeRole::Sensor, &[10.0, 15.0, 40.0], 30.0, -5.0);
assert_eq!(p, -10.0, "two angles below 30° → 2 × -5.0");
}
#[test]
fn test_role_embedding_onehot() {
assert_eq!(NodeRole::Sensor.embedding(), [1.0, 0.0, 0.0]);
assert_eq!(NodeRole::Relay.embedding(), [0.0, 1.0, 0.0]);
}
}
-133
View File
@@ -1,133 +0,0 @@
use serde::{Deserialize, Serialize};
/// Which environment the MARL training loop runs against.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub enum TrainingMode {
/// Pure Rust simulation — no real hardware or external simulator.
Simulation,
/// Gazebo + PX4 SITL (requires Gazebo running on localhost).
GazeboPx4Sitl { host: String, port: u16 },
/// Hardware-in-the-loop: real drones, simulated mission world.
HardwareInTheLoop,
/// Demo mode: synthetic CSI with configurable victim positions.
#[default]
Demo,
}
/// Full MAPPO training configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingConfig {
pub mode: TrainingMode,
pub num_drones: usize,
pub num_episodes: usize,
pub max_steps_per_episode: usize,
/// PPO clip epsilon.
pub clip_epsilon: f32,
/// Generalised Advantage Estimation lambda.
pub gae_lambda: f32,
/// Adam learning rate.
pub lr: f32,
/// Entropy coefficient (encourages exploration).
pub entropy_coeff: f32,
/// Number of transitions per PPO update batch.
pub batch_size: usize,
/// PPO epochs per update step.
pub ppo_epochs: usize,
/// Domain randomisation settings applied per episode.
pub domain_rand: DomainRandomizationConfig,
}
impl Default for TrainingConfig {
fn default() -> Self {
Self {
mode: TrainingMode::Demo,
num_drones: 4,
num_episodes: 1000,
max_steps_per_episode: 2000,
clip_epsilon: 0.2,
gae_lambda: 0.95,
lr: 3e-4,
entropy_coeff: 0.01,
batch_size: 2048,
ppo_epochs: 10,
domain_rand: DomainRandomizationConfig::default(),
}
}
}
/// Per-episode domain randomisation parameters.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DomainRandomizationConfig {
/// Maximum wind speed (Dryden turbulence model), m/s.
pub wind_max_ms: f64,
/// Gaussian noise standard deviation added to CSI amplitude.
pub csi_noise_std: f64,
/// Fractional thrust coefficient variation: ±motor_thrust_variation.
pub motor_thrust_variation: f64,
/// Mean packet loss percentage [0100].
pub packet_loss_pct: f64,
/// Maximum additional MAVLink latency injected, ms.
pub extra_latency_max_ms: u64,
}
impl Default for DomainRandomizationConfig {
fn default() -> Self {
Self {
wind_max_ms: 6.0,
csi_noise_std: 0.05,
motor_thrust_variation: 0.10,
packet_loss_pct: 15.0,
extra_latency_max_ms: 100,
}
}
}
impl TrainingConfig {
/// Quick 10-episode demo run — suitable for CI smoke tests.
pub fn quick_demo() -> Self {
Self {
mode: TrainingMode::Demo,
num_drones: 4,
num_episodes: 10,
max_steps_per_episode: 200,
..Default::default()
}
}
/// Full training preset with aggressive domain randomisation.
pub fn full_training() -> Self {
Self {
num_episodes: 5000,
max_steps_per_episode: 5000,
domain_rand: DomainRandomizationConfig {
wind_max_ms: 12.0,
csi_noise_std: 0.1,
motor_thrust_variation: 0.15,
packet_loss_pct: 30.0,
extra_latency_max_ms: 200,
},
..Default::default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quick_demo_has_fewer_episodes() {
let quick = TrainingConfig::quick_demo();
let full = TrainingConfig::full_training();
assert!(quick.num_episodes < full.num_episodes);
assert_eq!(quick.mode, TrainingMode::Demo);
}
#[test]
fn full_training_has_larger_domain_rand() {
let full = TrainingConfig::full_training();
let def = DomainRandomizationConfig::default();
assert!(full.domain_rand.wind_max_ms > def.wind_max_ms);
assert!(full.domain_rand.packet_loss_pct > def.packet_loss_pct);
}
}
@@ -1,277 +0,0 @@
//! Minimal MAPPO training loop — PPO policy gradient update on CPU.
//!
//! Production training uses Gazebo/PX4 SITL or the Demo environment.
//! This module provides the update step itself, independent of the environment.
use super::{
actor::{ActorAction, MappoActor},
observation::LocalObservation,
};
/// A single (observation, action, reward, next_observation, done) transition.
#[derive(Debug, Clone)]
pub struct Transition {
pub obs: LocalObservation,
pub action: ActorAction,
pub reward: f32,
pub next_obs: LocalObservation,
pub done: bool,
}
/// Replay buffer for PPO — stores a fixed number of transitions per update.
pub struct ReplayBuffer {
pub transitions: Vec<Transition>,
pub capacity: usize,
}
impl ReplayBuffer {
pub fn new(capacity: usize) -> Self {
Self { transitions: Vec::with_capacity(capacity), capacity }
}
pub fn push(&mut self, t: Transition) {
if self.transitions.len() >= self.capacity {
self.transitions.remove(0);
}
self.transitions.push(t);
}
pub fn is_full(&self) -> bool {
self.transitions.len() >= self.capacity
}
pub fn len(&self) -> usize { self.transitions.len() }
pub fn is_empty(&self) -> bool { self.transitions.is_empty() }
/// Compute discounted returns for all transitions (GAE-λ simplified to MC return).
pub fn compute_returns(&self, gamma: f32) -> Vec<f32> {
let n = self.transitions.len();
let mut returns = vec![0.0f32; n];
let mut running = 0.0f32;
for i in (0..n).rev() {
running = self.transitions[i].reward
+ gamma * running * (!self.transitions[i].done as i32 as f32);
returns[i] = running;
}
returns
}
}
/// PPO hyperparameters.
#[derive(Debug, Clone)]
pub struct PpoConfig {
pub lr: f32,
pub clip_epsilon: f32,
pub gamma: f32,
pub gae_lambda: f32,
pub entropy_coeff: f32,
pub epochs: usize,
}
impl Default for PpoConfig {
fn default() -> Self {
Self {
lr: 3e-4,
clip_epsilon: 0.2,
gamma: 0.99,
gae_lambda: 0.95,
entropy_coeff: 0.01,
epochs: 10,
}
}
}
/// Statistics from one PPO update step.
#[derive(Debug, Clone, Default)]
pub struct UpdateStats {
pub mean_return: f32,
pub policy_loss: f32,
pub entropy: f32,
pub updates: usize,
}
/// Compute mean return from a buffer.
pub fn compute_mean_return(buffer: &ReplayBuffer, gamma: f32) -> f32 {
let returns = buffer.compute_returns(gamma);
if returns.is_empty() { return 0.0; }
returns.iter().sum::<f32>() / returns.len() as f32
}
/// Simplified PPO policy gradient update.
///
/// In production this would use autodiff; here we use a finite-difference
/// approximation for the pure-Rust MLP actor (no autograd required for demo).
/// The production path should use Candle or burn for full gradient computation.
///
/// Returns update statistics.
pub fn ppo_update(
actor: &mut MappoActor,
buffer: &ReplayBuffer,
config: &PpoConfig,
) -> UpdateStats {
if buffer.is_empty() {
return UpdateStats::default();
}
let returns = buffer.compute_returns(config.gamma);
let mean_return = returns.iter().sum::<f32>() / returns.len() as f32;
// Normalise returns
let std_return = {
let var = returns.iter()
.map(|r| (r - mean_return).powi(2))
.sum::<f32>() / returns.len() as f32;
var.sqrt().max(1e-8)
};
let advantages: Vec<f32> = returns.iter()
.map(|r| (r - mean_return) / std_return)
.collect();
// Finite-difference pseudo-gradient update on output layer bias
// (production code would use autograd; this is a demo approximation)
let fd_eps = config.lr * 0.01;
let mut total_loss = 0.0f32;
for (transition, advantage) in buffer.transitions.iter().zip(advantages.iter()) {
let predicted = actor.forward(&transition.obs);
// Log-prob proxy: use tanh(delta_heading) as action probability proxy
let log_prob = (predicted.delta_heading_rad + 1e-8).abs().ln();
let loss = -log_prob * advantage;
total_loss += loss;
// Nudge: update a single scalar in the direction of advantage
// (This is a placeholder — real PPO needs full backprop)
let _ = fd_eps * advantage; // consume value; real update would modify weights
}
let policy_loss = total_loss / buffer.len() as f32;
// Entropy: uniform action distribution maximises entropy; proxy here
let entropy = config.entropy_coeff * 0.5;
UpdateStats {
mean_return,
policy_loss,
entropy,
updates: config.epochs,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::marl::{actor::ActorConfig, observation::LocalObservation};
fn make_transition(reward: f32) -> Transition {
Transition {
obs: LocalObservation::zeros(),
action: ActorAction {
delta_heading_rad: 0.1,
delta_altitude_m: 0.0,
speed_ms: 4.0,
trigger_csi_scan: false,
},
reward,
next_obs: LocalObservation::zeros(),
done: false,
}
}
#[test]
fn test_buffer_capacity() {
let mut buf = ReplayBuffer::new(5);
for i in 0..8 {
buf.push(make_transition(i as f32));
}
assert_eq!(buf.len(), 5, "buffer should cap at capacity");
}
#[test]
fn test_returns_monotone_positive() {
let mut buf = ReplayBuffer::new(4);
for _ in 0..4 { buf.push(make_transition(1.0)); }
let returns = buf.compute_returns(0.99);
// Each return should be >= 1.0 (positive reward accumulates)
for r in &returns {
assert!(*r >= 1.0, "all returns should be >= 1.0 with positive rewards");
}
// Returns should be non-decreasing from right to left
for i in 0..returns.len() - 1 {
assert!(returns[i] >= returns[i + 1],
"earlier returns should be higher (more future reward)");
}
}
#[test]
fn test_ppo_update_produces_stats() {
let mut actor = MappoActor::random_init(ActorConfig::default());
let mut buf = ReplayBuffer::new(20);
for i in 0..20 {
buf.push(make_transition(if i % 2 == 0 { 10.0 } else { -2.0 }));
}
let stats = ppo_update(&mut actor, &buf, &PpoConfig::default());
assert_ne!(stats.mean_return, 0.0, "mean return should be computed");
assert_eq!(stats.updates, PpoConfig::default().epochs);
}
#[test]
fn test_empty_buffer_no_crash() {
let mut actor = MappoActor::random_init(ActorConfig::default());
let buf = ReplayBuffer::new(20);
let stats = ppo_update(&mut actor, &buf, &PpoConfig::default());
assert_eq!(stats.mean_return, 0.0);
assert_eq!(stats.updates, 0);
}
#[test]
fn test_marl_convergence_improves_mean_return() {
use rand::Rng;
let mut actor = MappoActor::random_init(ActorConfig::default());
let ppo_cfg = PpoConfig { lr: 1e-3, ..PpoConfig::default() };
let mut rng = rand::thread_rng();
// Collect transitions with varying rewards (simulate improvement trajectory)
let mut buf = ReplayBuffer::new(64);
for step in 0..64 {
// Simulate improving rewards: early steps low reward, later steps higher
let reward = if step < 32 {
rng.gen_range(-5.0f32..-1.0)
} else {
rng.gen_range(1.0..15.0)
};
buf.push(Transition {
obs: LocalObservation::zeros(),
action: ActorAction {
delta_heading_rad: 0.1,
delta_altitude_m: 0.0,
speed_ms: 5.0,
trigger_csi_scan: true,
},
reward,
next_obs: LocalObservation::zeros(),
done: step == 63,
});
}
// Run PPO update
let stats = ppo_update(&mut actor, &buf, &ppo_cfg);
// The mean return should reflect the mixed-reward trajectory
assert!(stats.updates > 0, "PPO should have run updates");
assert!(
stats.mean_return.is_finite(),
"mean return should be finite: {}",
stats.mean_return
);
// With 32 negative + 32 positive rewards, mean should be non-zero
assert!(
stats.mean_return != 0.0,
"mean return should be non-zero with varied rewards"
);
// Run multiple update cycles and verify stats are stable
let stats2 = ppo_update(&mut actor, &buf, &ppo_cfg);
assert!(stats2.mean_return.is_finite());
}
}
@@ -1,415 +0,0 @@
//! SwarmOrchestrator — wires together all swarm subsystems for a complete swarm node.
//!
//! Each physical drone runs one SwarmOrchestrator instance. In demo/sim mode it
//! runs N orchestrators in one process to simulate a full swarm.
use crate::{
config::SwarmConfig,
failsafe::{FailSafeMachine, FailSafeState},
sensing::{
multiview::MultiViewFusion,
payload::{CsiPayloadPipeline, PayloadConfig},
},
planning::{
coverage::CoverageStrategy,
probability_grid::ProbabilityGrid,
},
types::{CsiDetection, DroneState, NodeId, Position3D, Velocity3D},
};
use std::collections::HashMap;
/// The complete per-drone swarm coordinator.
///
/// In production: backed by live CSI payload and PX4 flight controller.
/// In demo/sim: backed by synthetic CSI and simulated state.
pub struct SwarmOrchestrator {
pub node_id: NodeId,
pub config: SwarmConfig,
pub state: DroneState,
pub failsafe: FailSafeMachine,
pub coverage: CoverageStrategy,
pub probability_grid: ProbabilityGrid,
pub csi_pipeline: CsiPayloadPipeline,
pub fusion: MultiViewFusion,
/// Latest known positions of swarm peers.
pub peer_states: HashMap<NodeId, DroneState>,
/// Detections received from peers (last cycle).
pub peer_detections: Vec<CsiDetection>,
/// Accumulated mission statistics.
pub stats: MissionStats,
/// Optional Ruflo backend for AgentDB, AIDefence, and SONA intelligence.
/// When None (default), all Ruflo calls are no-ops — existing behaviour preserved.
#[cfg(feature = "ruflo")]
pub ruflo: Option<Box<dyn crate::ruflo::RufloBackend>>,
/// Active trajectory ID issued by the Ruflo intelligence hooks.
#[cfg(feature = "ruflo")]
pub trajectory_id: Option<String>,
}
/// Accumulated metrics for one mission run.
#[derive(Debug, Clone, Default)]
pub struct MissionStats {
pub cells_covered: u32,
pub victims_confirmed: u32,
pub collision_events: u32,
pub steps: u64,
pub elapsed_secs: f64,
}
impl SwarmOrchestrator {
/// Create a new orchestrator in demo mode (synthetic CSI).
pub fn new_demo(
node_id: NodeId,
config: SwarmConfig,
start_position: Position3D,
victims: Vec<Position3D>,
) -> Self {
let grid_w = (config.mission.area_width_m / config.mission.grid_resolution_m).ceil() as u32;
let grid_h = (config.mission.area_height_m / config.mission.grid_resolution_m).ceil() as u32;
let probability_grid =
ProbabilityGrid::new(grid_w, grid_h, config.mission.grid_resolution_m);
let noise_std = config.demo.as_ref().map(|d| d.csi_noise_std).unwrap_or(0.05);
let detection_range = config.planning.csi_scan_width_m;
let convergence_threshold = config.planning.convergence_threshold;
let csi_pipeline = CsiPayloadPipeline::new_synthetic(
node_id,
PayloadConfig {
scan_freq_hz: 10.0,
detection_range_m: detection_range,
confidence_threshold: 0.5,
esp32_baud_rate: 921_600,
},
victims,
noise_std,
node_id.0 as u64,
);
let state = DroneState {
id: node_id,
position: start_position,
velocity: Velocity3D::default(),
heading_rad: 0.0,
altitude_agl_m: config.planning.flight_altitude_m,
battery_pct: 100.0,
link_quality: 1.0,
timestamp_ms: 0,
};
Self {
node_id,
config: config.clone(),
state,
failsafe: FailSafeMachine::new(),
coverage: CoverageStrategy::new(convergence_threshold),
probability_grid,
csi_pipeline,
fusion: MultiViewFusion::default(),
peer_states: HashMap::new(),
peer_detections: Vec::new(),
stats: MissionStats::default(),
#[cfg(feature = "ruflo")]
ruflo: None,
#[cfg(feature = "ruflo")]
trajectory_id: None,
}
}
/// Process one simulation step (dt_secs: time elapsed since last step).
/// Returns the current fail-safe state after evaluation.
pub async fn step(&mut self, dt_secs: f64, link_alive: bool) -> FailSafeState {
self.stats.steps += 1;
self.stats.elapsed_secs += dt_secs;
// 1. Drain stale peer detections from previous cycle.
self.peer_detections.clear();
// 2. Evaluate fail-safe state machine.
let nearest_dist = self.nearest_peer_distance();
let fs_state = self.failsafe.tick(&self.state, link_alive, nearest_dist);
if fs_state != FailSafeState::Nominal && fs_state != FailSafeState::LowBatteryWarn {
return fs_state; // safety takes over; skip mission logic
}
// 3. CSI scan at current position.
let current_pos = self.state.position;
if let Some(detection) = self.csi_pipeline.scan(&current_pos).await {
if detection.confidence >= self.csi_pipeline.config.confidence_threshold {
if let Some(victim_pos) = detection.victim_position {
let cell = self.pos_to_cell(&victim_pos);
self.probability_grid.update_bayesian(cell, detection.confidence, true);
}
}
}
// 4. Mark current cell as scanned.
let cur_cell = self.pos_to_cell(&current_pos);
let was_new = self.probability_grid.mark_scanned(cur_cell);
if was_new {
self.stats.cells_covered += 1;
}
// 5. Update coverage phase based on grid state.
self.coverage.phase_transition(&self.probability_grid);
// 6. Move toward next waypoint (proportional navigation for simulation).
if let Some(target) = self.coverage.next_target(&self.state, &self.probability_grid) {
self.move_toward(target, dt_secs);
}
// 7. Simple battery drain: 1% per 30 s at full speed.
self.state.battery_pct -= (dt_secs / 30.0) as f32;
self.state.battery_pct = self.state.battery_pct.max(0.0);
self.state.timestamp_ms += (dt_secs * 1_000.0) as u64;
fs_state
}
/// Multi-drone CSI fusion at the cluster-head level.
/// Returns a fused detection if enough viewpoints agree.
pub fn fuse_detections(
&self,
all_detections: &[CsiDetection],
all_positions: &[(NodeId, Position3D)],
) -> Option<crate::sensing::multiview::FusedDetection> {
self.fusion.fuse(all_detections, all_positions)
}
/// Accept an incoming peer state update (called by the swarm comm layer).
pub fn receive_peer_state(&mut self, peer: DroneState) {
self.peer_states.insert(peer.id, peer);
}
/// Accept an incoming CSI detection from a peer.
pub fn receive_peer_detection(&mut self, det: CsiDetection) {
self.peer_detections.push(det);
}
/// Attach a Ruflo backend for AgentDB pattern learning, AIDefence, and SONA.
///
/// Call after `new_demo()`:
/// ```ignore
/// let orch = SwarmOrchestrator::new_demo(...)
/// .with_ruflo(Box::new(MockRufloBackend::new()));
/// ```
#[cfg(feature = "ruflo")]
pub fn with_ruflo(mut self, backend: Box<dyn crate::ruflo::RufloBackend>) -> Self {
self.ruflo = Some(backend);
self
}
/// Start a Ruflo intelligence trajectory for this mission node.
///
/// Call before the mission loop begins. If no backend is attached this is a no-op.
#[cfg(feature = "ruflo")]
pub async fn start_trajectory(&mut self, mission_desc: &str) {
if let Some(ruflo) = &self.ruflo {
match ruflo.trajectory_start(mission_desc, "swarm-specialist").await {
Ok(tid) => self.trajectory_id = Some(tid),
Err(e) => tracing::warn!("trajectory_start failed: {}", e),
}
}
}
/// End the Ruflo trajectory and persist the mission summary in AgentDB.
///
/// Stores both a searchable memory entry and a pattern-learned description.
/// If no backend is attached this is a no-op.
#[cfg(feature = "ruflo")]
pub async fn finish_trajectory(&mut self, success: bool, mission_key: &str) {
if let Some(ruflo) = &self.ruflo {
let tid = self.trajectory_id.take();
if let Some(tid) = &tid {
let _ = ruflo.trajectory_end(tid, success, None).await;
}
// Build and serialise mission summary.
let summary = crate::ruflo::MissionSummary::from_stats(
&self.stats,
&self.config.mission.profile,
1, // single drone; caller sets correct count via separate API if needed
self.config.mission.area_width_m,
self.config.mission.area_height_m,
0, // caller sets victims_total; 0 = unknown
self.probability_grid.coverage_pct(),
);
if let Ok(json) = serde_json::to_string(&summary) {
let _ = ruflo.store_mission(mission_key, &json, "swarm-missions").await;
}
let _ = ruflo.store_pattern(
&summary.to_pattern_description(),
summary.pattern_type(),
summary.pattern_confidence(),
).await;
}
}
/// AIDefence-checked variant of `receive_peer_detection`.
///
/// Returns `true` and enqueues the detection if it passes the safety check.
/// Returns `false` (and drops the detection) if AIDefence flags it as unsafe.
/// Falls back to `true` (accept) if the Ruflo backend is not attached or the
/// check itself errors (fail-open to avoid blocking legitimate traffic).
#[cfg(feature = "ruflo")]
pub async fn receive_peer_detection_checked(&mut self, det: CsiDetection) -> bool {
if let Some(ruflo) = &self.ruflo {
// Serialise the detection to a string for AIDefence inspection.
let repr = format!(
"drone_id={:?} confidence={:.3} victim={:?}",
det.drone_id, det.confidence, det.victim_position
);
match ruflo.mavlink_is_safe(&repr).await {
Ok(false) => {
tracing::warn!(
"aidefence rejected peer detection from {:?}",
det.drone_id
);
return false;
}
Err(e) => tracing::debug!("aidefence check failed (proceeding): {}", e),
_ => {}
}
}
self.receive_peer_detection(det);
true
}
/// Returns true when the mission is considered complete.
pub fn is_mission_complete(&self) -> bool {
self.probability_grid.coverage_pct() > 0.95
}
// ──────────────────────── private helpers ────────────────────────
/// Distance to the nearest peer drone (f64::MAX if no peers).
fn nearest_peer_distance(&self) -> f64 {
self.peer_states
.values()
.map(|p| self.state.position.distance_to(&p.position))
.fold(f64::MAX, f64::min)
}
/// Convert a world position to grid cell indices, clamped to grid bounds.
fn pos_to_cell(&self, pos: &Position3D) -> (u32, u32) {
let r = self.config.mission.grid_resolution_m;
let w = (self.config.mission.area_width_m / r) as u32;
let h = (self.config.mission.area_height_m / r) as u32;
let xi = (pos.x / r).max(0.0) as u32;
let yi = (pos.y / r).max(0.0) as u32;
(xi.min(w.saturating_sub(1)), yi.min(h.saturating_sub(1)))
}
/// Simple proportional navigation: steer toward target at max planning speed.
fn move_toward(&mut self, target: Position3D, dt_secs: f64) {
let dx = target.x - self.state.position.x;
let dy = target.y - self.state.position.y;
let dist = (dx * dx + dy * dy).sqrt();
if dist < 0.5 {
self.state.velocity = Velocity3D::default();
return;
}
let speed = self.config.planning.max_speed_ms.min(dist / dt_secs);
let vx = (dx / dist) * speed;
let vy = (dy / dist) * speed;
self.state.position.x += vx * dt_secs;
self.state.position.y += vy * dt_secs;
self.state.velocity = Velocity3D { vx, vy, vz: 0.0 };
self.state.heading_rad = vy.atan2(vx);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn demo_orchestrator(node_id: u32, victims: Vec<Position3D>) -> SwarmOrchestrator {
let cfg = SwarmConfig::demo_default();
SwarmOrchestrator::new_demo(
NodeId(node_id),
cfg,
Position3D { x: 10.0 * node_id as f64, y: 0.0, z: -30.0 },
victims,
)
}
#[tokio::test]
async fn test_single_orchestrator_step() {
let mut orch =
demo_orchestrator(0, vec![Position3D { x: 50.0, y: 50.0, z: 0.0 }]);
let state = orch.step(0.1, true).await;
assert_eq!(state, FailSafeState::Nominal);
assert_eq!(orch.stats.steps, 1);
}
#[tokio::test]
async fn test_failsafe_triggers_on_link_loss() {
let mut orch = demo_orchestrator(0, vec![]);
// Lower the hold threshold so it trips well within a sub-second test run.
orch.failsafe.link_loss_hold_secs = 0.001;
orch.failsafe.link_loss_rth_secs = 0.1;
// One tick to start the link-loss timer, then sleep briefly so the
// real-time elapsed exceeds the tiny hold threshold.
orch.step(0.1, false).await;
std::thread::sleep(std::time::Duration::from_millis(5));
let state = orch.step(0.1, false).await;
assert_ne!(state, FailSafeState::Nominal, "link loss should trigger failsafe");
}
#[tokio::test]
async fn test_multi_drone_coverage() {
let victims = vec![Position3D { x: 50.0, y: 50.0, z: 0.0 }];
let mut drones: Vec<SwarmOrchestrator> =
(0..4).map(|i| demo_orchestrator(i, victims.clone())).collect();
// 50 steps × 0.1 s dt = 5 simulated seconds
for _ in 0..50 {
for drone in &mut drones {
drone.step(0.1, true).await;
}
}
let total_cells: u32 = drones.iter().map(|d| d.stats.cells_covered).sum();
assert!(total_cells > 0, "drones should have covered some cells");
let elapsed = drones[0].stats.elapsed_secs;
assert!((elapsed - 5.0).abs() < 0.01, "elapsed should be ~5 s, got {elapsed}");
}
#[tokio::test]
async fn test_peer_state_exchange() {
let mut orch0 = demo_orchestrator(0, vec![]);
let mut orch1 = demo_orchestrator(1, vec![]);
orch0.step(0.1, true).await;
orch1.step(0.1, true).await;
// Exchange states
orch0.receive_peer_state(orch1.state.clone());
orch1.receive_peer_state(orch0.state.clone());
assert!(
orch0.peer_states.contains_key(&NodeId(1)),
"orch0 should know about orch1"
);
}
#[tokio::test]
async fn test_mission_complete_after_full_coverage() {
let mut orch = demo_orchestrator(0, vec![]);
// Manually mark every cell scanned.
let w = orch.probability_grid.width;
let h = orch.probability_grid.height;
for y in 0..h {
for x in 0..w {
orch.probability_grid.mark_scanned((x, y));
}
}
assert!(orch.is_mission_complete(), "should be complete at 100% coverage");
}
}
@@ -1,119 +0,0 @@
//! Coverage strategy: systematic sweep → probabilistic pursuit → convergence.
use crate::types::{DroneState, NodeId, Position3D};
use super::probability_grid::ProbabilityGrid;
use std::collections::HashMap;
/// Phase of the coverage mission.
#[derive(Debug, Clone)]
pub enum Phase {
/// Systematic boustrophedon sweep of the mission area.
Systematic,
/// Probabilistic pursuit: drones head toward high-P cells.
ProbabilisticPursuit,
/// Convergence on confirmed detections by the listed drones.
Convergence(Vec<NodeId>),
}
/// Coverage strategy tracking phase and cell assignments.
pub struct CoverageStrategy {
pub phase: Phase,
/// Assigned cell per drone.
pub assignments: HashMap<NodeId, (u32, u32)>,
pub convergence_threshold: f32,
}
impl CoverageStrategy {
pub fn new(convergence_threshold: f32) -> Self {
Self {
phase: Phase::Systematic,
assignments: HashMap::new(),
convergence_threshold,
}
}
/// Compute the next waypoint for a drone given the current grid.
pub fn next_waypoint(
&self,
node_id: NodeId,
state: &DroneState,
grid: &ProbabilityGrid,
flight_altitude_m: f64,
) -> Position3D {
if let Phase::Convergence(_) = &self.phase {
if let Some(&(cx, cy)) = self.assignments.get(&node_id) {
return Position3D {
x: cx as f64 * grid.cell_size_m,
y: cy as f64 * grid.cell_size_m,
z: -flight_altitude_m,
};
}
}
// Default: head toward the highest-priority unscanned cell.
if let Some((cx, cy)) = grid.highest_priority_unscanned() {
Position3D {
x: cx as f64 * grid.cell_size_m,
y: cy as f64 * grid.cell_size_m,
z: -flight_altitude_m,
}
} else {
state.position
}
}
/// Return the next navigation target position for an orchestrator step.
///
/// - Systematic phase: next unscanned boustrophedon cell.
/// - ProbabilisticPursuit: highest-priority unscanned cell.
/// - Convergence: highest-priority unscanned cell (refine around detections).
pub fn next_target(&self, state: &DroneState, grid: &ProbabilityGrid) -> Option<Position3D> {
let r = grid.cell_size_m;
match &self.phase {
Phase::Systematic => {
grid.next_systematic_cell(state).map(|(cx, cy)| Position3D {
x: cx as f64 * r + r / 2.0,
y: cy as f64 * r + r / 2.0,
z: state.position.z,
})
}
Phase::ProbabilisticPursuit | Phase::Convergence(_) => {
grid.highest_priority_unscanned().map(|(cx, cy)| Position3D {
x: cx as f64 * r + r / 2.0,
y: cy as f64 * r + r / 2.0,
z: state.position.z,
})
}
}
}
/// Transition to next phase based on grid state, guarded by a threshold.
pub fn phase_transition_with_threshold(
&mut self,
grid: &ProbabilityGrid,
_threshold: f32,
) {
self.phase_transition(grid);
}
/// Transition to next phase based on grid state.
pub fn phase_transition(&mut self, grid: &ProbabilityGrid) {
let max_p = grid
.cells
.iter()
.flat_map(|row| row.iter())
.map(|c| c.victim_probability)
.fold(0.0_f32, f32::max);
self.phase = match &self.phase {
Phase::Systematic if max_p >= self.convergence_threshold => {
Phase::ProbabilisticPursuit
}
Phase::ProbabilisticPursuit if max_p >= 0.9 => {
Phase::Convergence(vec![])
}
other => other.clone(),
};
}
}
@@ -1,12 +0,0 @@
//! Mission planning: coverage, probability grid, RRT-APF path planning.
pub mod rrt_apf;
pub mod coverage;
pub mod probability_grid;
pub mod pheromone;
pub mod patterns;
pub use rrt_apf::{RrtApfPlanner, Waypoint};
pub use coverage::{CoverageStrategy, Phase};
pub use probability_grid::ProbabilityGrid;
pub use patterns::{FlightPattern, PatternContext};

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