Compare commits

...

37 Commits

Author SHA1 Message Date
github-actions[bot] 04b1460560 chore: update vendor submodules to latest upstream 2026-07-03 07:12:38 +00:00
rUv e6f26e9ac9 docs(adr): deep review of the RuView npm surface — ADR-263/264/265 optimization strategies (#1229)
* docs(adr): deep review of the RuView npm surface — ADR-263/264/265 optimization strategies

ADR-263 — @ruvnet/ruview@0.1.0 harness review (O1–O9):
- HIGH: claim-check CLI fails open on empty input (no --text/--file -> PASS exit 0)
- HIGH: MCP stdio server head-of-line blocking (spawnSync verify/calibrate up to 600s)
- MEASURED: optionalDependencies triple the cold npx install (4 pkgs/620kB/71 files
  vs 1 pkg/172kB/22 files with --omit=optional) for a path that never imports them
- maxBuffer truncation, python -c port interpolation, version drift, duplicate skills,
  guardrail METRIC_TERMS substring false positives ('map'/'F1' — found by dogfooding
  claim-check on these very ADRs), zero CI

ADR-264 — @ruvnet/rvagent@0.1.0 + @ruv/ruview-cli review (O1–O9), verified against
the published registry tarball:
- HIGH: exports.require -> dist/index.cjs which is never built nor published
- MEASURED: 44 dead source-map files = 62,698B of the 188kB unpacked payload
- stdio-only server described as dual-transport; mixed dot/underscore tool names;
  double Zod validation + hand-duplicated advertised schemas; 2-fd leak per training
  job; unbounded body in the unwired HTTP scaffold; dead detectCogBinary candidates;
  ruview bin-name collision

ADR-265 — cross-cutting npm distribution strategy: npm-packages.yml CI matrix
(test + pack-content/size gate + tarball-install smoke test), publish-from-CI-only
with npm provenance, version single-sourcing from package.json, bin/namespace
ownership (ruview bin belongs to @ruvnet/ruview), claim-check on package READMEs.

Docs only — no runtime code changed. Index/CHANGELOG/CLAUDE.md/README counts updated.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz

* fix(npm): implement ADR-263/264/265 — harness fail-closed + async MCP, rvagent packaging/transport/naming, npm CI+provenance gate

ADR-263 (@ruvnet/ruview 0.2.0), O1-O9:
- claim-check fails closed on empty input (CLI exit 2, empty_text tool error)
- MCP stdio server dispatches tools/call asynchronously (promise-based spawn);
  ping answers while a 3s fake verify runs — pinned by new e2e test
- optionalDependencies dropped: cold npx installs exactly 1 package
  (MEASURED: was 4 pkgs/620kB/71 files via npm i in a clean prefix)
- bounded rolling output tails replace spawnSync 1MiB maxBuffer
- node_monitor port passed via sys.argv, never spliced into python -c source
- serverInfo.version read from package.json; resources/prompts stubs
- skills single-sourced: prepack sync script generates .claude/skills/ copies
- which() = memoized dep-free PATH scan
- tools underscore-canonical (ruview_claim_check, ...) + dotted aliases
- guardrail precision: word-boundary map/f1/auc/iou, code-span + F1/O2 label
  scrubbing, quantitative-claims-only; packaging reproducer hints
- 30/30 tests (was 17), incl. concurrency e2e + fail-open regression pins

ADR-264 (@ruvnet/rvagent 0.2.0), O1-O9:
- exports fixed: types-first, phantom dist/index.cjs require target removed
- tarball map-free: 127,704B unpacked / 46 files / 0 maps (MEASURED,
  npm pack --dry-run; was 188kB incl. 44 maps referencing unshipped src)
- Streamable HTTP actually wired behind RVAGENT_HTTP_PORT: one transport +
  one MCP server per session (mcp-session-id routing), 1MiB body cap (413),
  port-aware localhost origin gate; dual-transport description now true
- tools renamed underscore-canonical with dotted router-only aliases
- single Zod validation gate; advertised inputSchema generated from the same
  Zod source (zod-to-json-schema)
- train_count: parent log fds closed (was leaking 2/job); job records
  persisted to <jobsDir>/<id>.json (job_status survives restarts); bounded
  log-tail reads
- detectCogBinary probes its candidates instead of dead-coding them
- version from package.json; @types/express dropped; @types/jest -> 29
- README rewritten to match reality (no phantom subcommands/policy layer)
- 99/99 jest tests (incl. new session/body-cap suite + previously-broken
  manifest suite); stdio handshake + HTTP session flow smoke-tested live

ADR-265 D1-D4:
- .github/workflows/npm-packages.yml: 3-package x Node 20/22 gate — tests,
  version-literal grep (D3), pack-content/size gate, tarball-install smoke
  test (catches the ADR-264 F1 class), README claim-check (D4)
- .github/workflows/ruview-npm-release.yml: publish from CI only with
  npm publish --provenance
- @ruv/ruview-cli bin renamed ruview-cli (ruview bin belongs to
  @ruvnet/ruview); version single-sourced
- ci.yml NODE_VERSION 18 -> 20

ADR statuses updated to Accepted/implemented; harness manifest re-pinned;
ADR-263/264/265 + both package READMEs pass claim-check.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz

* perf(rvagent): lazy-load HTTP transport + memoize generated tool schemas

stdio time-to-first-response ~242ms -> ~189ms (-22%; MEASURED, median of
repeated initialize round-trips against dist/index.js in this container).

- ./http-transport.js now imported lazily inside the RVAGENT_HTTP_PORT
  branch: it chain-loads the MCP SDK streamableHttp module (~48ms MEASURED
  via per-module import() timing) which the default stdio path never uses
- toolInputJsonSchema memoized per tool: schemas are static for the process
  lifetime; under the session-per-server HTTP model every session calls
  tools/list, so stop re-walking the Zod tree each time

No behavior change: 99/99 jest tests; HTTP session flow re-smoke-tested
through the lazy import path (initialize -> 200 + mcp-session-id).

Profiled @ruvnet/ruview too and left it alone: 50ms CLI startup vs ~29ms
bare 'node -e ""' floor on the same box (MEASURED) — already near the
interpreter floor with zero dependencies.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01WrGfTGKv1oWZ6iwXZACULz

* ci(ruview-cli): pass jest --passWithNoTests so the private no-test package doesn't fail the npm-packages matrix

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

* fix(npm): address 10 verified review findings in harness + rvagent before 0.2.0 publish

harness/ruview (@ruvnet/ruview):
- guardrails: digit gate now sees numbers inside code spans; F1-style
  metric tokens followed by ':' or a nearby number are no longer scrubbed
  (fail-open regressions in the honesty gate)
- mcp-server: tools/call requests serialize through a FIFO promise chain
  (hardware/mutating tools never overlap) while ping/tools/list stay
  immediate; stdin close drains in-flight responses before exit
- tools: which() no longer memoizes negative lookups

tools/ruview-mcp (@ruvnet/rvagent):
- index: realpath invoked-directly guard — library import no longer
  connects a stdio transport to the consumer's process
- http-transport: explicit allowedOrigins is exact-match only (localhost
  any-port convenience applies only with no configured allowlist);
  session map gains maxSessions=64 + 5min idle TTL sweep
- train-count: job records persist the child pid and reconcile stale
  'running' status after a server restart (exit-code marker or dead pid)
- config: cog binary candidates ordered by process.arch

.github/workflows/ruview-npm-release.yml: port the full ADR-265 D1 gate
(version-literal check, unpacked-size budget, tarball-install smoke test)
from npm-packages.yml so the publish path enforces what the header claims.

Tests: harness 30→36, rvagent 99→112, all passing.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 13:11:15 -04:00
rUv 1a0492992f fix(build): repoint stale Makefile targets to v2/ and archive/v1/ (#1201)
The root Makefile still referenced pre-reorg paths that no longer exist, so
the documented build/test/run entrypoints were all broken:
- rust-port/wifi-densepose-rs/ -> v2/  (build-rust, build-wasm[-mat],
  test-rust, bench, clean cargo step)
- v1.src.api.main -> archive.v1.src.api.main  (run-api, run-api-dev)

test-rust now uses the documented `--no-default-features` invocation (the
proven-passing, GPU-free path). Verified: `cd v2 && cargo test --workspace
--no-default-features --no-run` compiles the full workspace clean.

Surfaced during a metaharness review (the broken root build entrypoint is
why genome reported build:none / publish_readiness 0.40 at the repo root).


Claude-Session: https://claude.ai/code/session_01AgpTcBLRJ32hUsKWxDXf36
2026-06-27 18:43:09 -04:00
rUv c0d3d7c792 chore(firmware): add release guard against stale-sdkconfig partition mismatch (#1194)
While cutting v0.8.3-esp32, an incremental 8MB build reused a leftover
generated `sdkconfig` and silently linked the 4MB dual-OTA partition layout
(no spiffs, ota_1 @ 0x1F0000) — the would-be released `partition-table.bin`
did not match the 8MB `partitions_display.csv` it claimed.

scripts/firmware-release-guard.sh regenerates the expected partition table
from the CSV the named flash-size variant must use and byte-compares it to the
built `partition-table.bin`, and cross-checks flash size in flasher_args.json.
Fails closed so a release pipeline can't ship a mismatched table.

Usage: scripts/firmware-release-guard.sh <8mb|4mb> <build-dir>


Claude-Session: https://claude.ai/code/session_01AgpTcBLRJ32hUsKWxDXf36
2026-06-27 13:21:05 -04:00
rUv fca5e6f0a0 fix: multistatic canonicalization, csi_fps burst inflation, control-packet starvation (#1170, #1180, #1183) (#1193)
#1170 — live multistatic bridge fed raw, un-canonicalized per-node CSI
(64/128/192 bins) to MultistaticFuser, tripping DimensionMismatch every
cycle and silently disabling fusion on mixed HT20/HT40 meshes. Add
HardwareNormalizer::resample_to_canonical (resample-only, no z-score) and
canonicalize every node frame onto the 56-tone grid before fusion.

#1180 — update_csi_fps_ema only rejected dt<=0 or >=1s, so sub-ms UDP-burst
arrivals (36us -> ~27kHz) inflated csi_fps_ema 40-840x. Add a 5ms plausibility
floor and stop re-anchoring observe_csi_frame_arrival on burst deltas.

#1183 — global ENOMEM backoff (CSI flood) starved <=48B/<=1Hz control packets.
Add stream_sender_send_priority() bypassing the backoff gate without touching
the streak; route feature_state/HEALTH/sync through it. Fix the misleading
"HEALTH sent" log that printed even on rv_mesh_send failure.

Verified: signal 501, sensing-server 677 tests (0 failed); firmware builds clean.


Claude-Session: https://claude.ai/code/session_01AgpTcBLRJ32hUsKWxDXf36
2026-06-27 13:04:44 -04:00
rUv 7831f29436 fix(firmware): phantom LD2410 detection + ENOMEM backoff (#1135) (#1159)
Bug #2 (root cause): LD2410 probe-detection matched only the 4-byte head
0xF4F3F2F1, so a floating UART at 256000 baud could phantom-detect a sensor
and spawn a UART task. Now requires a full validated report frame (head +
sane length + tail 0xF8F7F6F5), extracted to mmwave_detect.h and shared with
a host unit test (test_mmwave_detect.c, 8 vectors) so firmware and test can't
diverge. Matches the validate-before-trust approach used for MR60 in #1107.

Bug #1: sendto ENOMEM used a fixed 100 ms backoff too short to drain sustained
lwIP/WiFi buffer pressure, so a node could stay stuck. Now exponential
(100->200->...->2000 ms per consecutive ENOMEM, reset on first successful
send). Removing the phantom LD2410 task (bug #2) also removes the extra load
that tipped the reporter's tier-2 node into the stuck state.

Validated on ESP32-S3 QFN56 rev v0.2 (the reporter's silicon): tier-2 streams
~100 frames/s with no stuck ENOMEM and correctly reports no mmWave (no
phantom). LD2410 predicate truth table proven (head-without-tail REJECTED).
Could not reproduce the reporter's environment-specific floating-pin noise, so
the deterministic proof is the host unit test.
2026-06-22 12:31:21 -04:00
rUv 4bf88e1283 feat(firmware): gate LED gamma viz behind CONFIG_LED_GAMMA_VIZ (ADR-183 follow-up) (#1129)
The 40 Hz gamma flicker is now Kconfig-gated (default y, unchanged
behaviour). Set CONFIG_LED_GAMMA_VIZ=n for a dark, lower-power boot (the
LED is simply cleared) — important for photosensitive deployments, no
source edit needed. The colormap saturation point is now operator-tunable
via CONFIG_LED_MOTION_FULLSCALE_MILLI (default 250 = 0.25).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #1049.

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

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

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

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

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

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

Findings fixed:

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

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

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

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

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

Warrants ADR slot 179 (parent to author).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-06-15 10:55:04 -04:00
252 changed files with 14004 additions and 16038 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ on:
env:
PYTHON_VERSION: '3.11'
NODE_VERSION: '18'
NODE_VERSION: '20' # ADR-265: all Node packages in this repo declare engines >= 20
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
+148
View File
@@ -0,0 +1,148 @@
# ADR-265 D1 — the npm-package gate.
#
# Every Node package in this repo (published or private) gets: install, build,
# tests, a version-literal gate (D3 — package.json is the only place a version
# lives), a pack-content gate (no source maps, unpacked-size budget), a
# tarball-install smoke test (would have caught ADR-264 F1's broken `require`
# export), and the claim-check honesty lint on the README (D4).
name: npm packages
on:
push:
branches: [main]
paths:
- 'harness/ruview/**'
- 'tools/ruview-mcp/**'
- 'tools/ruview-cli/**'
- '.github/workflows/npm-packages.yml'
pull_request:
paths:
- 'harness/ruview/**'
- 'tools/ruview-mcp/**'
- 'tools/ruview-cli/**'
- '.github/workflows/npm-packages.yml'
permissions:
contents: read
jobs:
gate:
name: ${{ matrix.package.dir }} (node ${{ matrix.node }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['20', '22']
package:
- dir: harness/ruview
build: false
publishable: true
# ADR-263: dependency-free harness; budget guards against dep creep.
unpacked_budget: 65536
- dir: tools/ruview-mcp
build: true
publishable: true
# ADR-264 O2: map-free tarball (was 188 kB with maps).
unpacked_budget: 140000
- dir: tools/ruview-cli
build: true
publishable: false
unpacked_budget: 0
defaults:
run:
working-directory: ${{ matrix.package.dir }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
# Repo policy gitignores lockfiles under harness/ (the harness is
# dependency-free anyway); the TS packages commit theirs.
- name: Install
run: |
if [ -f package-lock.json ]; then npm ci; else npm install --no-fund --no-audit; fi
- name: Build
if: ${{ matrix.package.build }}
run: npm run build
- name: Test
run: npm test --if-present
# ADR-265 D3 — package.json is the only place a version string lives.
- name: Version-literal gate
run: |
set -euo pipefail
hits=""
for d in src bin; do
if [ -d "$d" ]; then
hits+=$(grep -rEn '\b[0-9]+\.[0-9]+\.[0-9]+\b' "$d" | grep -vE '127\.0\.0\.1|0\.0\.0\.0' || true)
fi
done
if [ -n "$hits" ]; then
echo "Hardcoded version-like literals found (read package.json instead — ADR-265 D3):"
echo "$hits"
exit 1
fi
# ADR-265 D1.3 — pack-content gate: no maps, size budget enforced.
- name: Pack gate
if: ${{ matrix.package.publishable }}
run: |
npm pack --dry-run --json 2>/dev/null | node -e "
const [info] = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const budget = Number(process.env.UNPACKED_BUDGET);
const maps = info.files.filter((f) => f.path.endsWith('.map'));
if (maps.length > 0) {
console.error('Tarball contains source maps (ADR-264 F2):', maps.map((m) => m.path));
process.exit(1);
}
if (info.unpackedSize > budget) {
console.error(\`Unpacked size \${info.unpackedSize} B exceeds budget \${budget} B\`);
process.exit(1);
}
console.log(\`pack gate OK: \${info.files.length} files, \${info.unpackedSize} B unpacked (budget \${budget} B), 0 maps\`);
"
env:
UNPACKED_BUDGET: ${{ matrix.package.unpacked_budget }}
# ADR-265 D1.4 — install the real tarball and drive each bin/export.
- name: Tarball smoke test
if: ${{ matrix.package.publishable }}
run: |
set -euo pipefail
TGZ="$PWD/$(npm pack --silent 2>/dev/null | tail -1)"
SMOKE="$(mktemp -d)"
cd "$SMOKE"
npm init -y > /dev/null
npm i --no-fund --no-audit "$TGZ"
case "${{ matrix.package.dir }}" in
harness/ruview)
./node_modules/.bin/ruview --version
./node_modules/.bin/ruview doctor
# the honesty gate must fail closed on empty input (ADR-263 F1)
if ./node_modules/.bin/ruview claim-check; then
echo 'claim-check passed with no input — fail-open regression'; exit 1
fi
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
;;
tools/ruview-mcp)
# initialize over stdio; server must answer and exit 0 on EOF
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}\n' \
| timeout 30 ./node_modules/.bin/rvagent | grep -q '"serverInfo"'
# the ESM export must resolve from the installed tarball (ADR-264 F1)
timeout 30 node --input-type=module -e "await import('@ruvnet/rvagent');" < /dev/null
;;
esac
# ADR-265 D4 — package READMEs must pass the project's own honesty lint.
- name: Claim-check README
run: |
if [ -f README.md ]; then
node "$GITHUB_WORKSPACE/harness/ruview/bin/cli.js" claim-check --file README.md
else
echo "no README.md — skipping"
fi
+137
View File
@@ -0,0 +1,137 @@
# ADR-265 D2 — publish only from CI, with provenance.
#
# Manual `npm publish` from laptops stops: this workflow re-runs the ADR-265 D1
# gate for the selected package and then publishes with npm provenance
# attestations (OIDC), tying every published version to a public commit +
# workflow run — the npm-side analogue of the ADR-028 witness bundle.
#
# Requires: NPM_TOKEN repo secret (an npm automation token), or npm Trusted
# Publishing configured for the package (in which case the token is unused).
name: ruview npm release
on:
workflow_dispatch:
inputs:
package:
description: 'Package directory to publish'
required: true
type: choice
options:
- harness/ruview
- tools/ruview-mcp
dist_tag:
description: 'npm dist-tag'
required: false
default: 'latest'
type: string
permissions:
contents: read
id-token: write # npm --provenance
jobs:
publish:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ inputs.package }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
- name: Install
run: |
if [ -f package-lock.json ]; then npm ci; else npm install --no-fund --no-audit; fi
- name: Build (if present)
run: npm run build --if-present
- name: Test
run: npm test --if-present
# ADR-265 D3 — package.json is the only place a version string lives.
- name: Version-literal gate
run: |
set -euo pipefail
hits=""
for d in src bin; do
if [ -d "$d" ]; then
hits+=$(grep -rEn '\b[0-9]+\.[0-9]+\.[0-9]+\b' "$d" | grep -vE '127\.0\.0\.1|0\.0\.0\.0' || true)
fi
done
if [ -n "$hits" ]; then
echo "Hardcoded version-like literals found (read package.json instead — ADR-265 D3):"
echo "$hits"
exit 1
fi
# ADR-265 D1.3 — pack-content gate: no maps AND the per-package
# unpacked-size budget (the budgets that npm-packages.yml enforces).
- name: Pack gate (no maps + size budget)
run: |
set -euo pipefail
case "${{ inputs.package }}" in
# ADR-263: dependency-free harness; budget guards against dep creep.
harness/ruview) export UNPACKED_BUDGET=65536 ;;
# ADR-264 O2: map-free tarball (was 188 kB with maps).
tools/ruview-mcp) export UNPACKED_BUDGET=140000 ;;
*) echo "Unknown package '${{ inputs.package }}' — no budget defined"; exit 1 ;;
esac
npm pack --dry-run --json 2>/dev/null | node -e "
const [info] = JSON.parse(require('fs').readFileSync(0, 'utf8'));
const budget = Number(process.env.UNPACKED_BUDGET);
const maps = info.files.filter((f) => f.path.endsWith('.map'));
if (maps.length > 0) {
console.error('Tarball contains source maps (ADR-264 F2):', maps.map((m) => m.path));
process.exit(1);
}
if (info.unpackedSize > budget) {
console.error(\`Unpacked size \${info.unpackedSize} B exceeds budget \${budget} B\`);
process.exit(1);
}
console.log(\`pack gate OK: \${info.files.length} files, \${info.unpackedSize} B unpacked (budget \${budget} B), 0 maps\`);
"
# ADR-265 D1.4 — install the real tarball and drive each bin/export.
- name: Tarball smoke test
run: |
set -euo pipefail
TGZ="$PWD/$(npm pack --silent 2>/dev/null | tail -1)"
SMOKE="$(mktemp -d)"
cd "$SMOKE"
npm init -y > /dev/null
npm i --no-fund --no-audit "$TGZ"
case "${{ inputs.package }}" in
harness/ruview)
./node_modules/.bin/ruview --version
./node_modules/.bin/ruview doctor
# the honesty gate must fail closed on empty input (ADR-263 F1)
if ./node_modules/.bin/ruview claim-check; then
echo 'claim-check passed with no input — fail-open regression'; exit 1
fi
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
;;
tools/ruview-mcp)
# initialize over stdio; server must answer and exit 0 on EOF
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"ci","version":"0"}}}\n' \
| timeout 30 ./node_modules/.bin/rvagent | grep -q '"serverInfo"'
# the ESM export must resolve from the installed tarball (ADR-264 F1)
timeout 30 node --input-type=module -e "await import('@ruvnet/rvagent');" < /dev/null
;;
esac
- name: Claim-check README
run: |
if [ -f README.md ]; then
node "$GITHUB_WORKSPACE/harness/ruview/bin/cli.js" claim-check --file README.md
fi
- name: Publish (with provenance)
run: npm publish --provenance --access public --tag "${{ inputs.dist_tag }}"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
features:
- { label: 'default', flags: '--no-default-features' }
- { label: 'train', flags: '--features train' }
- { label: 'ruflo+itar', flags: '--features ruflo,itar-unrestricted' }
- { label: 'ruflo', flags: '--features ruflo' }
- { label: 'full+train', flags: '--features full,train' }
steps:
- uses: actions/checkout@v4
+14
View File
@@ -277,3 +277,17 @@ aether-arena/staging/
# MM-Fi benchmark dataset archives — large data, fetch separately, never commit
assets/MM-Fi/E0*.zip
assets/MM-Fi/*.zip
# through-wall demo: regenerable trained model artifact
examples/through-wall/model/
# RuView harness (npx ruview) build artifacts — ADR-182
harness/**/node_modules/
harness/**/*.tgz
harness/**/package-lock.json
harness/**/.claude-flow/
harness/**/ruvector.db
# ruvector runtime/hook DB — never tracked (any depth)
ruvector.db
**/ruvector.db
+8
View File
@@ -21,3 +21,11 @@
[submodule "vendor/rufield"]
path = vendor/rufield
url = https://github.com/ruvnet/rufield
[submodule "v2/crates/ruview-swarm"]
path = v2/crates/ruview-swarm
url = https://github.com/ruvnet/ruv-drone.git
branch = main
[submodule "v2/crates/worldgraph"]
path = v2/crates/worldgraph
url = https://github.com/ruvnet/worldgraph.git
branch = main
+18
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -62,7 +62,7 @@ All 5 ruvector crates integrated in workspace:
- `ruvector-attention``model.rs` (apply_spatial_attention) + `bvp.rs`
### Architecture Decisions
43 ADRs in `docs/adr/` (ADR-001 through ADR-043). Key ones:
182 ADRs in `docs/adr/` (numbered ADR-001 through ADR-265, with gaps). Key ones:
- ADR-014: SOTA signal processing (Accepted)
- ADR-015: MM-Fi + Wi-Pose training datasets (Accepted)
- ADR-016: RuVector training pipeline integration (Accepted — complete)
@@ -77,6 +77,10 @@ All 5 ruvector crates integrated in workspace:
- ADR-148: Drone swarm control system / `ruview-swarm` (In Progress)
- ADR-152: WiFi-Pose SOTA 2026 intake — geometry conditioning, WiFlow-STD benchmark (measurement (a) complete: claims MEASURED-EQUIVALENT at ~96% PCK@20), MAE recipe (Proposed; §2.12.3, 2.6 implemented)
- ADR-153: IEEE 802.11bf-2025 forward-compatibility protocol model (Accepted — amends ADR-152 §2.4)
- ADR-182: `npx ruview` harness minted via MetaHarness (Accepted — P1+P2 shipped as `@ruvnet/ruview`)
- ADR-263: `@ruvnet/ruview` npm harness deep review + optimization strategy (Proposed)
- ADR-264: `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` deep review + optimization strategy (Proposed)
- ADR-265: RuView npm distribution strategy — CI gate, provenance, version single-sourcing (Proposed)
### Supported Hardware
+8 -8
View File
@@ -51,26 +51,26 @@ verify-audit:
# ─── Rust Builds ─────────────────────────────────────────────
build-rust:
cd rust-port/wifi-densepose-rs && cargo build --release
cd v2 && cargo build --release
build-wasm:
cd rust-port/wifi-densepose-rs && wasm-pack build crates/wifi-densepose-wasm --target web --release
cd v2 && wasm-pack build crates/wifi-densepose-wasm --target web --release
build-wasm-mat:
cd rust-port/wifi-densepose-rs && wasm-pack build crates/wifi-densepose-wasm --target web --release -- --features mat
cd v2 && wasm-pack build crates/wifi-densepose-wasm --target web --release -- --features mat
test-rust:
cd rust-port/wifi-densepose-rs && cargo test --workspace
cd v2 && cargo test --workspace --no-default-features
bench:
cd rust-port/wifi-densepose-rs && cargo bench --package wifi-densepose-signal
cd v2 && cargo bench --package wifi-densepose-signal
# ─── Run ─────────────────────────────────────────────────────
run-api:
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000
uvicorn archive.v1.src.api.main:app --host 0.0.0.0 --port 8000
run-api-dev:
uvicorn v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
uvicorn archive.v1.src.api.main:app --host 0.0.0.0 --port 8000 --reload
run-viz:
python3 -m http.server 3000 --directory ui
@@ -81,7 +81,7 @@ run-docker:
# ─── Clean ───────────────────────────────────────────────────
clean:
rm -f .install.log
cd rust-port/wifi-densepose-rs && cargo clean 2>/dev/null || true
cd v2 && cargo clean 2>/dev/null || true
# ─── Help ────────────────────────────────────────────────────
help:
+4 -1
View File
@@ -601,6 +601,8 @@ claude --plugin-dir ./plugins/ruview
Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full details: [`plugins/ruview/README.md`](plugins/ruview/README.md).
**Portable harness — `npx @ruvnet/ruview`:** a lighter, host-portable companion to the in-repo plugin, minted via [MetaHarness](https://www.npmjs.com/package/metaharness) and hardened per [ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md). It runs **without cloning this repo** and on more hosts (Claude Code, Codex, Copilot, opencode, …), exposing the RuView operator tools (`onboard`, `verify`, `node_monitor`, `calibrate`, `node_flash`) over an MCP server — plus the project's **MEASURED-vs-CLAIMED honesty guardrail enforced in code** (`ruview.claim_check` flags untagged or retracted-"100%" accuracy claims). v0.1: the onboarding/verify/claim-check paths are tested (17/17, `verify.py` → PASS); the hardware tools are fail-closed wrappers. Try `npx @ruvnet/ruview` to onboard, or `npx @ruvnet/ruview claim-check --text "…"`. Source: [`harness/ruview/`](harness/ruview/README.md).
---
## 📖 Documentation
@@ -614,7 +616,8 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
| [**SENSE-BRIDGE — rvagent MCP server**](tools/ruview-mcp/README.md) | Dual-transport MCP server (`@ruvnet/rvagent`) bridging the RuView sensing stack to AI agents (Claude Code, Cursor, ruflo swarms). 6 tools wired: `ruview.presence.now`, `ruview.vitals.get_{breathing,heart_rate,all}`, `ruview.bfld.last_scan`, `ruview.bfld.subscribe`. stdio + Streamable HTTP (`POST /mcp`, Origin-validated, bearer-token auth, `127.0.0.1` bind). Full 20-tool Zod schema barrel + 5 RUVIEW-POLICY governance tools. 93 tests. [ADR-124](docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md). Try: `npx @ruvnet/rvagent stdio`. |
| [Semantic Primitives — Precision/Recall](docs/integrations/semantic-primitives-metrics.md) | Per-primitive F1 on the held-out paired-capture set: someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting, bathroom, fall-risk, bed-exit, no-movement, multi-room. |
| [Claude Code / Codex Plugin](plugins/ruview/README.md) | The `ruview` plugin + marketplace — skills, `/ruview-*` commands, agents, and the Codex prompt mirror |
| [Architecture Decisions](docs/adr/README.md) | 96 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Portable harness — `npx @ruvnet/ruview`](harness/ruview/README.md) | MetaHarness-minted, host-portable RuView operator harness — `ruview.*` MCP tools + the MEASURED-vs-CLAIMED honesty guardrail enforced in code ([ADR-182](docs/adr/ADR-182-npx-ruview-harness-via-metaharness.md)). A lighter, multi-host companion to the in-repo plugin. |
| [Architecture Decisions](docs/adr/README.md) | 182 ADRs — why each technical choice was made, organized by domain (hardware, signal processing, ML, platform, infrastructure) |
| [Domain Models](docs/ddd/README.md) | 8 DDD models (RuvSense, Signal Processing, Training Pipeline, Hardware Platform, Sensing Server, WiFi-Mat, CHCI, rvCSI) — bounded contexts, aggregates, domain events, and ubiquitous language |
| [rvCSI — edge RF sensing runtime](https://github.com/ruvnet/rvcsi) | Rust-first / TypeScript-accessible / hardware-abstracted CSI runtime: multi-source ingestion (incl. real nexmon_csi `.pcap` from a **Raspberry Pi 5** / Pi 4 / Pi 3B+ — CYW43455 / BCM43455c0) → validation → DSP → typed events → RuVector RF memory ([ADR-095](docs/adr/ADR-095-rvcsi-edge-rf-sensing-platform.md), [ADR-096](docs/adr/ADR-096-rvcsi-ffi-crate-layout.md), [domain model](docs/ddd/rvcsi-domain-model.md)). Now its own repo — [`ruvnet/rvcsi`](https://github.com/ruvnet/rvcsi) — vendored here under `vendor/rvcsi`; 9 `rvcsi-*` crates on crates.io, `@ruv/rvcsi` on npm, plus a Claude Code plugin. |
| [Desktop App](v2/crates/wifi-densepose-desktop/README.md) | **WIP** — Tauri v2 desktop app for node management, OTA updates, WASM deployment, and mesh visualization |
Binary file not shown.
@@ -0,0 +1,444 @@
# ADR-131: HOMECORE-UI — Operational dashboard for the two-tier Cognitum stack
| Field | Value |
|-------|-------|
| **Status** | Accepted — UI implemented (§10); full backend wiring specified (§11–§12) |
| **Date** | 2026-06-14 |
| **Deciders** | ruv |
| **Codename** | **HOMECORE-UI** — first-class operator dashboard inside the Cognitum Appliance shell |
| **Relates to** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (HOMECORE master), [ADR-127](ADR-127-homecore-state-machine-rust.md) (HOMECORE-CORE state machine), [ADR-128](ADR-128-homecore-integration-plugin-system.md) (HOMECORE-PLUGINS), [ADR-129](ADR-129-homecore-automation-engine.md) (automation engine), [ADR-130](ADR-130-homecore-rest-websocket-api.md) (HOMECORE-API), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (recorder/semantic search), [ADR-151](ADR-151-room-calibration-specialist-training.md) (room calibration HTTP API), [ADR-100](ADR-100-cog-packaging-specification.md) (Cog packaging), [ADR-116](ADR-116-cog-ha-matter-seed.md) (cog-ha-matter), [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) (SEED RVF ingest), [ADR-105](ADR-105-federated-csi-training.md) (federated CSI training) |
| **Tracking issue** | TBD |
| **Parent** | [ADR-126](ADR-126-ruview-native-ha-port-master.md) (sub-ADR, HOMECORE-127…134 family) |
---
## 1. Context
HOMECORE (ADR-126 through ADR-134) is the native Rust + WASM + TypeScript port of Home Assistant running as the hub on the Cognitum v0 Appliance. As of P2, the state machine ([ADR-127](ADR-127-homecore-state-machine-rust.md)), API ([ADR-130](ADR-130-homecore-rest-websocket-api.md)), and COG runtime ([ADR-128](ADR-128-homecore-integration-plugin-system.md)) are in place. What is missing is a first-class dashboard UI that operators, integrators, and residents can use to manage the full two-tier hardware stack that HOMECORE coordinates.
### 1.1 The two-tier hardware model this UI must represent
This is the most important architectural constraint the UI must carry through every panel:
- **Cognitum SEED** — a Pi Zero 2 W-based edge node. It has its own RVF vector store (8-dim, content-addressed, with kNN queries), Ed25519 witness chain, SHA-256 ingest audit trail, onboard environmental sensors (BME280 temperature/humidity/pressure, PIR motion, reed switch, ADS1115 4-channel ADC, vibration), 13 drift detectors, an MCP proxy (114 tools, JSON-RPC 2.0, default-deny policy), 98 HTTPS API endpoints, and epoch-based swarm sync for multi-SEED deployments. SEEDs sit close to the ESP32 sensing nodes and receive feature vectors from them at 1 Hz. Multiple SEEDs can form a peer mesh. **This is the sensing and memory tier.**
- **Cognitum v0 Appliance** — a Pi 5 + Hailo-10H hub, running at `:9000`. It hosts the COG runtime (`/var/lib/cognitum/apps/`), the HOMECORE state machine and event bus, the calibration service, `ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`, and acts as the fleet coordinator for multi-room correlation and federated training. The Appliance is where HOMECORE runs, and it is what the dashboard user is sitting in front of. **This is the computation and orchestration tier.**
SEEDs are **subordinate nodes that the Appliance supervises** — they are not peers. The UI navigation hierarchy must reflect this: the Appliance is the root, SEEDs are children, ESP32 nodes are leaves.
### 1.2 What the UI is not
HOMECORE-UI is **not** a re-skin of the existing Cognitum Cog Store. It is a full operational dashboard that **extends** the Cognitum platform's shell — the Cog Store, API Explorer, and Guide already exist and must remain intact, with the HOMECORE dashboard added as a first-class navigation section alongside them.
---
## 2. Decision
Build HOMECORE-UI as a **complete** TypeScript + Rust→WASM frontend (per this ADR's §3 and the HOMECORE-127…134 family) that:
1. Lives at `http://cognitum-v0:9000/homecore` (or as a dedicated nav item in the Cognitum Appliance shell).
2. Is visually and stylistically seamless with the existing Cognitum platform — same dark theme, same design tokens, same component patterns as `https://seed.cognitum.one/store`.
3. Drives the HOMECORE REST + WebSocket API ([ADR-130](ADR-130-homecore-rest-websocket-api.md)) and the calibration HTTP API ([ADR-151](ADR-151-room-calibration-specialist-training.md)) for all data.
4. Updates in real-time via the homecore `subscribe_events` WebSocket channel. **The UI must never poll for entity state.**
**This is a decision to deliver the complete operational dashboard — every panel in §4.1 through §4.10, every navigation section in §5, fully wired to live data — not a design-system scaffold or a partial first cut.** A static layout shell with placeholder data is explicitly **out of scope as a deliverable**: the design system (§3) is a means to the complete UI, not an end in itself. The acceptance bar for this ADR is that an operator can drive the full two-tier stack — fleet, entities, rooms, COGs, calibration, events, audit, and settings — from the dashboard, against real APIs, with no panel left as a stub.
### 2.1 `homecore-server` is the single backend-for-frontend (BFF) gateway
The data the dashboard needs is spread across **three backend tiers that are not one process**: (a) `homecore-api` (`/api/*` REST + `/api/websocket`, mounted in `homecore-server`); (b) the **calibration API** (`/api/v1/*`, served by a *separate* binary — `wifi-densepose calibrate-serve` / `wifi-densepose-sensing-server`); and (c) the **SEED device tier + appliance daemons** (RVF vector store, witness chain, onboard sensors, reflex rules, COG supervisor, federation), which are physically separate HTTPS services on the SEED nodes and the appliance.
The browser must talk to **exactly one origin.** Therefore `homecore-server` is promoted to the **single BFF / API gateway** for HOMECORE-UI: it serves the static assets at `/homecore`, serves `homecore-api` at `/api/*`, and **adds a new `/api/homecore/*` namespace** that proxies and aggregates the calibration API and the SEED/appliance tiers server-side. The UI only ever issues same-origin requests; cross-service auth (SEED bearer tokens, calibration tokens) is held by the gateway and **never exposed to the browser**. This collapses the CORS/multi-port problem and gives one place to enforce the long-lived-access-token auth (§4.10).
### 2.2 No mock data in production
The in-browser mock layer that the first UI cut shipped behind DEMO banners (§7.1, prior revision) is **demoted to a dev-only fixture** gated behind an explicit `?demo=1` / `HOMECORE_UI_DEMO=1` flag. The production build wires **every** panel to a real gateway endpoint. The full endpoint contract and the backend work each panel needs are specified in **§11**; the staged path to get there is **§12**. A panel may show an empty/typed-error state when its upstream is down, but it must never silently render fabricated data.
---
## 3. Design system — Cognitum platform conventions
The implementor **must study `https://seed.cognitum.one/store` as the definitive design reference before writing a single line of CSS.** The existing platform's design tokens, extracted from production, are:
### 3.1 Colour palette (CSS custom properties)
| Token | Value | Role |
|---|---|---|
| `--bg` | `#0a0e1a` | page background (very dark navy) |
| `--bg2` | `#111627` | secondary background / nav strip |
| `--card` | `#171d30` | card / panel surface |
| `--card-h` | `#1e2540` | card hover state |
| `--border` | `#252d45` | all border strokes (≈0.67px, subtle) |
| `--t1` | `#e0e4f0` | primary text (near-white) |
| `--t2` | `#8890a8` | secondary / muted text |
| `--t3` | `#505872` | tertiary / disabled text |
| `--cyan` | `#4ecdc4` | primary action colour (Install buttons, live indicators, accents) |
| `--cyan-d` | `rgba(78,205,196,0.15)` | cyan tint background for status badges |
| `--green` | `#6bcb77` | success / online / healthy states |
| `--green-d` | `rgba(107,203,119,0.15)` | green tint background |
| `--amber` | `#d4a574` | warning / stale / degraded states |
| `--amber-d` | `rgba(212,165,116,0.15)` | amber tint background |
| `--red` | `#e06060` | error / offline / veto states |
| `--red-d` | `rgba(224,96,96,0.15)` | red tint background |
| `--purple` | `#a78bfa` | informational / epoch / chain indicators |
| `--purple-d` | `rgba(167,139,250,0.15)` | purple tint background |
| `--r` | `10px` | standard border radius on all cards and panels |
### 3.2 Typography
- `--font`: `'Segoe UI', system-ui, -apple-system, sans-serif` — all body and heading text.
- `--mono`: `'Cascadia Code', 'Fira Code', Consolas, monospace` — all entity IDs, API endpoints, hex values, JSON payloads, COG binary hashes.
### 3.3 Component patterns (from the live Cog Store and API Explorer)
- **Cards**: `background: var(--card)`, `border: 0.67px solid var(--border)`, `border-radius: var(--r)`, `padding: 24px`.
- **Category pills / status badges**: small `border-radius: 46px`, uppercase text, coloured background tint (e.g. `background: var(--cyan-d); color: var(--cyan)` for `RUNNING`; `background: var(--amber-d); color: var(--amber)` for `STALE`).
- **Primary action buttons**: `background: var(--cyan)`, `color: var(--bg)`, no border — matching the existing "Install" button style exactly.
- **Secondary / ghost buttons**: transparent background, `border: 1px solid var(--border)`, `color: var(--t1)` — matching the existing "Details" button style.
- **Nav strip**: `background: var(--bg2)`, text items in `--t2`, active item highlighted in `--cyan` with a bottom underline.
- **Featured card gradient borders**: top-edge linear gradient from `var(--cyan)` to `var(--purple)` — replicate for HOMECORE section headers.
- **Live metric cards** (API Explorer status page): icon + large numeric value in `--cyan` or `--green`, label in `--t2` below, on a `var(--card)` background.
- **Method badge pills** on the API Explorer (`GET` in green, `POST` in amber, `AUTH` in purple) — reuse this same pill system for COG status indicators.
The implementor **must not introduce new colours, typefaces, or border radii.** Every component should feel like it was built by the same team that built the Cog Store and the API Explorer. A user navigating from the Cog Store into the HOMECORE dashboard should not notice a visual seam.
---
## 4. UI sections — required panels
### 4.1 System Dashboard (the "home screen")
The always-visible overview panel. Modelled on the API Explorer's live metric cards. All values update in real-time.
- **v0 Appliance health strip** — reuse the exact metric-card pattern from `seed.cognitum.one/status`: one card each for CPU %, RAM usage, Hailo-10H inference load (% utilisation), Hailo temperature, uptime, and the running services (`ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`). Values in `--cyan`, labels in `--t2`. This strip is always at the top — it represents the machine the user is looking at.
- **SEED Fleet overview** — a grid of SEED node cards (one per paired SEED) on the `var(--card)` surface with `var(--border)`. Each card shows: online/offline status pill (green/red), firmware version, epoch number, current vector count, last ingest timestamp, and witness-chain validity badge. A collapsed row shows the SEED's 5 onboard sensors in summary (PIR: yes/no, door: open/closed, temperature from BME280). Offline SEEDs render the entire card with a `--red-d` background tint. Clicking a SEED card navigates to the SEED Detail view (§4.2).
- **ESP32 Node summary** — count of active ESP32 nodes per SEED, current frame rate (target: 100 Hz CSI + 1 Hz feature vectors), and a compact warning list for nodes with known issues (presence_score normalisation anomaly, stale firmware version).
- **COG Runtime status row** — a horizontal strip of status pills for each installed COG on the v0 Appliance. Pill colours follow the existing badge convention: `--green-d`/`--green` for running, `--red-d`/`--red` for failed, `--t3`/`--t2` for stopped. COG name in `--mono`. Clicking a pill navigates to COG Management (§4.6).
- **Event Bus activity indicator** — a small real-time sparkline showing the homecore broadcast channel event rate (events/sec). Indicate channel lag if a subscriber is falling behind the 4,096-event capacity.
### 4.2 SEED Detail View (per-SEED drill-down)
Accessible from the fleet grid. Full-page panel for a single SEED node, using the card + section-header pattern from the Cog Store's detail views.
- **SEED identity header** — `device_id` in `--mono`, firmware version, paired status in green, USB vs WiFi connection mode. A section-header gradient border (cyan → purple, matching the featured card style) visually separates this from Appliance content.
- **Vector Store panel** — current vector count, dimension (8), last kNN query latency, current epoch number, a small sparkline of ingest rate over the last hour, and a storage budget bar showing usage against the 100K working-set target. A "Compact now" button (`POST /api/v1/store/compact`) in ghost style. When usage exceeds 80%, the bar renders in `--amber`.
- **Witness Chain panel** — chain length (SHA-256 entries), last verification timestamp, a one-click "Verify chain" button (`POST /api/v1/witness/verify`), and an "Export attestation bundle" button for regulated deployments. The Ed25519 custody attestation (device-bound keypair, epoch + vector count + witness head) renders here. Chain length in `--purple`, following the existing epoch/chain colour convention.
- **Onboard Sensors panel** — live readings from all 5 sensors in individual sub-cards: BME280 (temperature °C, humidity %, pressure hPa), PIR (motion boolean with last-triggered timestamp), reed switch (open/closed with last-changed timestamp), ADS1115 (4 analog channels with configurable labels), vibration (boolean with last-triggered). These are ground-truth validators against CSI readings and are critical for diagnosing false positives in the mixture-of-specialists. Sensor values in `--cyan`; sensor names in `--t2`.
- **Reflex Rules panel** — the 3 pre-configured rules with current state: `fragility_alarm` (threshold 0.3 → relay actuator), `drift_cutoff` (threshold 1.0), `hd_anomaly_indicator` (threshold 200 → PWM brightness). Show last-fired time for each. The `fragility_alarm` threshold is the most commonly adjusted field and should be editable inline. Rules that have recently fired render with a `--amber-d` background tint.
- **Cognitive Analysis panel** — boundary fragility score (0.01.0, from Stoer-Wagner min-cut on the kNN graph) rendered as a progress bar: green below 0.3, amber 0.30.6, red above 0.6. High fragility (>0.3) indicates a regime change in the environment and should be visually prominent. Temporal coherence phase boundaries shown as a labelled timeline of detected environment state transitions. kNN graph rebuild cadence indicator (every 10 s).
- **Ingest pipeline status** — which ESP32 nodes feed this SEED, the packet type each is sending (`0xC5110003` native feature vectors vs `0xC5110002` vitals fallback path — distinguished visually since native is preferred), current ingest batch size, flush interval, and bridge path topology (direct vs host-laptop hop). The bridge-hop warning (known architectural limitation) renders in `--amber` since it adds a network hop.
### 4.3 SEED Fleet Map (multi-SEED topology)
For deployments with more than one SEED, a topology view showing the mesh:
- **Node hierarchy diagram** — v0 Appliance at root, SEEDs as second tier (grouped by room/zone), ESP32 nodes as leaves under each SEED. Lines represent active data flows. ESP-NOW mesh sync links between SEEDs shown as dashed lines. Connection health shown via line colour (green/amber/red). All labels in `--mono`.
- **Cross-SEED event deduplication indicator** — for events that span multiple SEEDs (one fall detected by two rooms; one occupant tracked through room A → hallway → room B), show a fusion badge indicating how many SEEDs contributed to the composite event.
- **Federation config** ([ADR-105](ADR-105-federated-csi-training.md)) — federated-learning round coordinator role (which SEED is the round coordinator), current round number, K healthy nodes selected, delta exchange status. **Model deltas only — never raw CSI** is a design invariant that must be labelled explicitly in the UI.
### 4.4 Entity & State Browser
The homecore state machine (`DashMap<EntityId, Arc<State>>`) is the authoritative source of truth. Every COG running on the v0 Appliance contributes entities.
- **Entity list by domain** — grouped by the `domain.` prefix of `EntityId`, using collapsible section headers. The 21 entities per ESP32 node (11 raw + 10 semantic primitives from `cog-ha-matter`) are the most important set. For each entity: current state string (in `--t1`), last-changed timestamp (in `--t3`), attribute map as collapsible JSON in `--mono`, and the Context (`user_id` + `parent_id` causality chain, critical for care/audit deployments). Entity IDs always in `--mono`.
- **SEED provenance badge** — each entity carries a small badge showing its data lineage: which ESP32 node → which SEED → which COG → homecore state machine. This trace is invaluable for debugging false positives and is a **first-class UI element, not a collapsed detail.**
- **Domain filter + semantic search** — filter by domain prefix and, once [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (homecore-recorder) lands, ruvector-backed semantic search: "when did the living room anomaly score last correlate with a door-open event?" A keyword filter across entity IDs and attribute keys ships in the initial release regardless of [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) status, given entity density; the semantic search layers on top once the recorder lands.
- **Real-time WebSocket feed** — entity states update live via the homecore `subscribe_events` WebSocket command ([ADR-130](ADR-130-homecore-rest-websocket-api.md)). The UI must never poll. Show a broadcast-channel lag indicator; warn visually if the subscriber is falling behind the 4,096-event channel capacity.
- **StateChanged detail panel** — clicking any entity opens a slide-over panel showing the full `StateChangedEvent`: `old_state`, `new_state`, `context.id`, `context.user_id`, and the `context.parent_id` chain rendered as a breadcrumb trail.
### 4.5 RoomState / Sensing Panel
Surfaces the mixture-of-specialists output from the calibration service — the highest-level per-room sensing result. Data comes from `GET /api/v1/room/state?bank=<room_id>` on the v0 Appliance.
- **Per-room cards** — one card per `room_id` on the `var(--card)` surface. Each card shows live `RoomState` JSON fields as sub-rows: presence (occupied/absent chip in green/red with confidence bar), posture (standing/sitting/lying chip with confidence), breathing BPM (numeric in `--cyan` with range indicator 630), heart rate BPM (numeric in `--cyan` with range indicator 40120), restlessness score (01 progress bar), and anomaly score (01 with normal/anomalous label, bar turns red above a configurable threshold).
- **STALE warning** — when `stale: true` (the specialist bank was trained against a different baseline), render the entire room card with a `--amber-d` background tint and a prominent amber banner reading "Bank stale — baseline has changed" with a direct "Recalibrate room" link into the calibration wizard (§4.7). This is the most common real-world failure mode and **must never be subtle.**
- **VETO indicator** — when `vetoed: true` (anomaly veto suppressed vitals/posture because the window was physically implausible), render the affected specialist slots in `--red` with a "Veto active" label. Values suppressed by veto **must not render as zeros** — they must render as explicitly withheld.
- **Null specialist placeholders** — specialists not yet trained (`null` in the specialist bank) render as "Not trained" placeholders in `--t3` with a small "Calibrate to enable" prompt in ghost style. They are **not** errors.
- **Confidence bars** — each specialist output has a confidence float, shown as a small inline bar (`--cyan` fill) next to the reading. Low confidence (< 0.4) renders the bar in `--amber`.
- **Multi-SEED fusion indicator** — for rooms served by multiple SEEDs, show a small badge indicating how many SEED nodes contributed to the `MultiNodeMixture` for this room's reading.
### 4.6 v0 Appliance COG Management
The v0 Appliance hosts COGs at `/var/lib/cognitum/apps/`. This panel is the operational companion to the existing Cog Store (`seed.cognitum.one/store`). It must match the Cog Store's visual conventions precisely — same card layout, same category pills, same install/detail button pair — because operators will move between the two surfaces.
- **Installed COGs list** — for each COG: `id` and `version` in `--mono`, architecture badge (`arm`/`hailo10` etc., category-pill pattern), status pill (running/stopped/failed/updating in green/grey/red/amber), `binary_sha256` verified badge (Ed25519 signature verification shown as a shield icon in `--green` or `--red`), and PID from the pid file. Actions: start, stop, restart (ghost style), and view `output.log` / `error.log` in a monospace drawer using `--mono`. Edit `config.json` inline with syntax highlighting.
- **COG Store / App Registry** — browsable `app-registry.json` listing. This panel should visually mirror `seed.cognitum.one/store` as closely as possible — same featured-card hero layout, same icon + title + description + category pill + action button structure. One-click install downloads the binary from GCS, verifies `binary_sha256` + `binary_signature`, writes the manifest, and starts the COG. Show which new homecore entities will appear in the state machine after install, as a preview list before confirming.
- **OTA Updates** — a badge count on installed COGs with available updates, matching the "Installed (N)" tab badge convention from the existing Cog Store. Show a diff panel (version change, new entities, config schema changes) before confirming the update.
- **Hailo HEF status** — for COGs with `arch: hailo10`: loaded HEF files on the Hailo-10H, current inference throughput, and `ruvector-hailo-worker:50051` connection status. The RF Foundation Encoder ([ADR-150](ADR-150-rf-foundation-encoder.md)) and neural pose head display here once available.
### 4.7 Calibration Wizard
The full baseline → enroll → train → verify pipeline runs via HTTP against the v0 Appliance ([ADR-151](ADR-151-room-calibration-specialist-training.md)). This is a multi-step guided flow — not a raw API panel. Use a stepped wizard layout with a progress indicator at the top (steps 15 as numbered pills, active step in `--cyan`, completed in `--green`, pending in `--t3`).
- **Step 1 — Select room and SEED** — enter a `room_id` name (validated against `[A-Za-z0-9_-]{1,64}`) and select which SEED(s) and ESP32 nodes serve this room from a dropdown populated from the live fleet. Show current CSI ingest health for the selected nodes inline — if frames are not arriving at the expected rate, display an amber warning **before** allowing the operator to proceed. A broken ingest pipeline will silently fail calibration.
- **Step 2 — Baseline capture** — `POST /api/v1/calibration/start`. A large full-width animated progress bar (cyan fill) reads from `GET /api/v1/calibration/status`: frames recorded vs target, ETA in seconds, `z_median` value. If `motion_flagged` is true, overlay an amber banner: "Room must be empty — movement detected." The baseline UUID produced here is the anchor for all future STALE detection for this room — display it in `--mono` once complete so operators can record it.
- **Step 3 — Anchor enrollment** — the 8 anchor labels in enforced order: `empty`, `stand_still`, `sit`, `lie_down`, `breathe_slow`, `breathe_normal`, `small_move`, `sleep_posture`. For each: a human-readable instruction with an illustration, a countdown timer rendered as a circular progress ring in `--cyan`, and an immediate quality-gate result (accepted in green, retry in amber with a reason string). Drive via `POST /api/v1/enroll/anchor` + `GET /api/v1/enroll/status`. After each accepted anchor, show the extracted feature values (mean, variance, breathing_score, heart_score) in a small `--mono` data row so operators can sanity-check the capture. Show overall progress as "N / 8 anchors accepted."
- **Step 4 — Train** — a single `POST /api/v1/room/train` call. Show the 6 specialist results as a checklist: presence (threshold + occupied_var), posture (prototype count), breathing (min_score), heartbeat (min_score), restlessness (calm/active motion values), anomaly (prototype count + scale). Specialists that returned non-null render in `--green`. Null specialists (insufficient anchor data) render in `--amber` with a "Re-enroll missing anchors" prompt linking back to Step 3 for the specific missing labels.
- **Step 5 — Verify live** — display the live `RoomState` for the just-trained room using the same per-room card layout as §4.5. Prompt the operator to stand in the room and verify presence is detected, try sitting/lying to confirm posture, and breathe normally to confirm vitals are in plausible range. A "Confirm and save" button (cyan, primary) closes the wizard; a "Something's wrong — re-enroll" button (ghost) loops back to Step 3.
### 4.8 Event Bus & Automation Feed
- **Live event stream panel** — a virtualized scrolling list of `SystemEvent` variants (`StateChanged`, `EntityRegistered`, `ConfigReloaded`) and notable `DomainEvent`s from the homecore Tokio broadcast channel. Each row shows: event-type pill (coloured by variant), `entity_id` in `--mono`, old state → new state arrow, timestamp, and `context.user_id`. The stream is filterable by entity domain, event type, or source SEED/COG. The filter bar uses the same search-input style as the Cog Store's search field.
- **Context causality breadcrumb** — expanding any event row shows the full Context chain (`context.id``parent_id``grandparent_id`) as a breadcrumb trail in `--mono`. This is how automation loops become visible without any separate debugging tool.
- **Automation builder** ([ADR-129](ADR-129-homecore-automation-engine.md) scope) — a trigger → condition → action editor on the card surface. The most important RuView-specific trigger types to support are: `state_changed` on `RoomState` entities with a threshold expression (e.g. `anomaly.value > 0.8`), SEED reflex-rule firing events (`fragility_alarm`, `hd_anomaly_indicator`), and custom `domain_event` topics. Actions include calling services in the homecore service registry and firing domain events. The condition expression editor uses `--mono`.
### 4.9 Witness / Audit Log
- **Unified witness timeline** — a chronological merged view of events from both tiers: the SEED's SHA-256 ingest chain (every RVF store write attested) and homecore's Ed25519 state-transition chain (biometric crossings, BFLD identity-risk elevations). Each row: `entity_id` in `--mono`, old/new state, timestamp, source SEED `device_id`, signing key fingerprint (first 8 chars in `--mono`). Pagination uses the same "Showing XY of Z" convention from the Cog Store's cog grid.
- **Privacy mode banner** — a persistent top-of-panel banner showing current privacy mode: `--green-d`/green text for full-publish mode; `--amber-d`/amber text for audit-only mode (SHA-256 digests on-SEED only, no MQTT state messages). Show the per-SEED privacy mode state, since SEEDs can be individually configured. Toggling privacy mode is a high-stakes action — require an explicit "Confirm" step with a summary of what will change.
- **Export bundle** — an "Export attestation bundle" button (ghost) that packages the SEED witness chain + homecore Ed25519 chain as a downloadable archive for regulated-deployment (care home, hotel, shared office) compliance handoff.
### 4.10 Settings & Integration Config
- **SEED fleet management** — add, remove, and reprovision SEEDs. Show the USB-only pairing requirement prominently (the pairing window only opens via `169.254.42.1`, not WiFi — a security invariant). Per-SEED: `device_id` in `--mono`, firmware version, bearer token status, and a "Rotate token" action (ghost) that walks the operator through the secure token rotation flow.
- **ESP32 node provisioning** — per-node NVS config display (target IP, target port, node_id), last-seen firmware version, and a link to the provisioning script. The `node_id` → room/zone assignment is editable here and persists to the room calibration system's `room_id` mapping.
- **MQTT / cog-ha-matter config** ([ADR-116](ADR-116-cog-ha-matter-seed.md)) — broker URL, credentials (masked), MQTT topic prefix, mDNS advertisement status (`_ruview-ha._tcp`), and a live connection indicator (green dot for connected, red for unreachable). The 21 HA-DISCO entities per node are listed here with their `via_device` assignments showing which SEED they belong to in HA's device registry.
- **Long-lived access tokens** — for homecore-api companion-app connections (HA 2025.1 wire-compat, [ADR-130](ADR-130-homecore-rest-websocket-api.md)). Token creation, last-used timestamp, and revocation. The HA companion-app pairing QR-code flow surfaces here.
- **Federation config** — for multi-SEED deployments: ESP-NOW mesh sync status, cross-SEED epoch alignment values, and federated-learning round settings (coordinator SEED, round cadence, Krum aggregation parameters per [ADR-105](ADR-105-federated-csi-training.md)). The design invariant **"model deltas only, never raw CSI"** must be labelled explicitly in this panel.
---
## 5. Navigation structure
HOMECORE-UI must integrate into the existing Cognitum Appliance nav shell. The top nav should read:
```
Framework | Guide | Cog Store | HOMECORE | Status
```
— inserting **HOMECORE** as a first-class nav item between the existing "Cog Store" and "Status" entries, using the same nav-item style (text in `--t2`, active state in `--cyan` with bottom underline).
Within the HOMECORE section, a left sidebar (or top sub-nav on narrow viewports) provides section navigation:
```
Dashboard | SEED Fleet | Entities | Rooms | COGs | Calibration | Events | Audit | Settings
```
The COG Store panel within HOMECORE (§4.6) links out to `seed.cognitum.one/store` for the full catalog view, ensuring the existing Cog Store remains the canonical browsing experience.
---
## 6. Key UX invariants
These must be maintained across every panel:
1. **Always make the tier origin of any data explicit.** A `RoomState` reading traces to an ESP32 node → SEED → COG → v0 Appliance state machine. The provenance badge (§4.4) must appear wherever entity states are displayed.
2. **The `stale` and `vetoed` flags from `RoomState` and the kNN fragility score from SEED cognitive analysis are meaningful diagnostic signals** — they must never be silently hidden, styled grey-on-grey, or collapsed behind an expand toggle. They represent system health operators need to act on.
3. **Values that are `null` because a specialist has not been trained must be visually distinct from values that are unavailable due to an error.** The distinction is operationally important: `null` means "calibrate to enable," unavailable means "investigate."
4. **All entity IDs, hashes, API endpoints, binary signatures, device UUIDs, and JSON payloads must use `--mono` font.** This is already the convention in the API Explorer and must be consistent throughout HOMECORE-UI.
5. **The v0 Appliance Hailo HAT is a separate subsystem from the SEED's edge compute.** Inference results tagged as Hailo-sourced (COGs with `arch: hailo10`) must be visually distinguished from results from CPU-only COGs (`arch: arm`) so operators can triage hardware-specific failures.
---
## 7. Scope — complete UI delivery
The deliverable is the **entire** dashboard. Every panel below ships fully implemented and wired to its live data source — there is no scaffold-only milestone and no panel left as a placeholder. The table records each panel's authoritative backing API so the build can proceed in whatever order best fits the dependency graph; it is a dependency map, **not** a sequence of partial releases.
| Panel | Section | Backing API / source |
|---|---|---|
| System Dashboard | §4.1 | [ADR-130](ADR-130-homecore-rest-websocket-api.md) WebSocket + appliance health endpoints |
| SEED Detail View | §4.2 | SEED HTTPS API (vector store, witness, sensors, reflex, cognitive analysis) |
| SEED Fleet Map | §4.3 | fleet topology + federation ([ADR-105](ADR-105-federated-csi-training.md)) |
| Entity & State Browser | §4.4 | [ADR-127](ADR-127-homecore-state-machine-rust.md) state machine via [ADR-130](ADR-130-homecore-rest-websocket-api.md) `subscribe_events`; semantic search via [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) |
| RoomState / Sensing | §4.5 | [ADR-151](ADR-151-room-calibration-specialist-training.md) `GET /api/v1/room/state` |
| COG Management | §4.6 | [ADR-128](ADR-128-homecore-integration-plugin-system.md) plugin runtime + [ADR-100](ADR-100-cog-packaging-specification.md) app registry |
| Calibration Wizard | §4.7 | [ADR-151](ADR-151-room-calibration-specialist-training.md) calibration HTTP API |
| Event Bus & Automation | §4.8 | [ADR-130](ADR-130-homecore-rest-websocket-api.md) broadcast channel + [ADR-129](ADR-129-homecore-automation-engine.md) automation engine |
| Witness / Audit Log | §4.9 | SEED SHA-256 ingest chain + homecore Ed25519 chain |
| Settings & Integration | §4.10 | SEED provisioning, [ADR-116](ADR-116-cog-ha-matter-seed.md) MQTT/Matter, LLAT, federation |
### 7.1 Build sequencing within the complete deliverable
The complete UI depends on backing services that mature on their own timelines. Each panel is built against the **real gateway endpoint** defined in §11; where the upstream is not yet available the panel renders a typed empty/error state, **not** fabricated data (the dev-only `?demo=1` fixture of §2.2 exists for offline development only and is never the shipped behaviour). Concretely, the hard contract dependencies are: [ADR-130](ADR-130-homecore-rest-websocket-api.md) (REST + WebSocket), [ADR-127](ADR-127-homecore-state-machine-rust.md) (state machine), [ADR-151](ADR-151-room-calibration-specialist-training.md) (calibration), [ADR-128](ADR-128-homecore-integration-plugin-system.md) (plugin runtime), [ADR-129](ADR-129-homecore-automation-engine.md) (automation), [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) (event history + semantic search), [ADR-116](ADR-116-cog-ha-matter-seed.md) (SEED/Matter), [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) (SEED ingest), and [ADR-105](ADR-105-federated-csi-training.md) (federation). The keyword entity filter (§4.4) ships immediately; semantic search layers on once [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) lands. The exact panel→endpoint→upstream map and the new gateway code each requires are §11; the staged delivery is §12.
---
## 8. Consequences
### 8.1 Positive
- Operators, integrators, and residents get a single coherent surface for the full two-tier stack, replacing the need to SSH into SEEDs or hand-craft API calls.
- The dashboard reuses the proven Cognitum design tokens and component patterns verbatim, so it ships visually consistent with no separate design effort and no perceptible seam between surfaces.
- Diagnostic signals that today are invisible (`stale`/`vetoed` flags, kNN fragility, provenance lineage, channel lag) become first-class, surfacing the system's most common real-world failure modes directly to operators.
### 8.2 Negative / risks
- The UI hard-depends on the wire-compat guarantees of ADR-130 and the calibration contract of ADR-151; schema drift in either breaks panels silently. Integration tests against every backing contract in §7 are required.
- Committing to the complete UI in one deliverable is a larger up-front effort and couples the UI's readiness to the maturity of multiple backing services (§7.1, §11). The mitigation is the BFF gateway (§2.1): each panel targets one same-origin endpoint, and the gateway absorbs upstream churn behind a stable contract.
- Promoting `homecore-server` to a gateway means it now **proxies cross-tier traffic** (calibration API, SEED HTTPS, appliance daemons). This adds a network hop, a place for upstream timeouts/partial failures to surface, and a server-side store of SEED bearer tokens that must be protected (§11.10). Each proxied route needs an explicit timeout + typed error mapping so one slow SEED cannot stall the dashboard.
- Several panels depend on data that only exists on **real hardware or new daemons** (SEED device tier, appliance host metrics, COG supervisor). Until those upstreams exist the corresponding gateway routes return `503 upstream_unavailable`; this is honest but means the dashboard is only as "live" as the tiers behind it (§11 classifies every endpoint by what it depends on).
- Faithfully mirroring `seed.cognitum.one/store` couples HOMECORE-UI to the external Cog Store's evolving design; token drift there must be tracked and re-synced.
- The two-tier mental model (Appliance root, SEED children, ESP32 leaves) must be enforced consistently; any panel that flattens or peers the tiers undermines the core architectural constraint.
---
## 9. References
- `https://seed.cognitum.one/store` — primary design reference for all visual conventions.
- `https://seed.cognitum.one/status` — reference for live metric-card layout.
- [ADR-126](ADR-126-ruview-native-ha-port-master.md) — HOMECORE master ADR.
- [ADR-127](ADR-127-homecore-state-machine-rust.md) — HOMECORE-CORE state machine and entity registry.
- [ADR-128](ADR-128-homecore-integration-plugin-system.md) — HOMECORE-PLUGINS WASM COG substrate.
- [ADR-129](ADR-129-homecore-automation-engine.md) — HOMECORE automation engine.
- [ADR-130](ADR-130-homecore-rest-websocket-api.md) — HOMECORE-API REST + WebSocket wire-compat.
- [ADR-132](ADR-132-homecore-recorder-history-semantic-search.md) — homecore-recorder, history + semantic search.
- [ADR-100](ADR-100-cog-packaging-specification.md) — Cognitum Cog packaging specification (manifest.json, status values, on-device layout).
- [ADR-116](ADR-116-cog-ha-matter-seed.md) — cog-ha-matter (SEED cog, HA-DISCO entity surface, mDNS).
- [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) — ESP32 CSI → Cognitum SEED RVF ingest pipeline (SEED architecture detail).
- [ADR-105](ADR-105-federated-csi-training.md) — Federated CSI training (multi-SEED federation).
- [ADR-151](ADR-151-room-calibration-specialist-training.md) — Per-room calibration specialist training (calibration HTTP API).
- `v2/crates/homecore/src/` — state machine, entity, event, registry source.
- `docs/integration/calibration-appliance-integration.md` — calibration API contract and RoomState schema.
---
## 10. Implementation status
Implemented as a zero-dependency, no-build-step vanilla TS/JS + CSS frontend served by `homecore-server` at `/homecore` (the `rufield-viewer` "Axum + vanilla-JS" pattern). The complete deliverable per §2/§7 — all ten panels, fully rendered, wired to live data where the backing service exists and to a contract-conformant DEMO-flagged mock layer (§7.1) where it does not.
**Location:** `v2/crates/homecore-server/ui/``css/tokens.css` (the §3.1 palette, verbatim) + `css/app.css` (§3.3 components); `js/{ui,api,ws,mock,app}.js` (shared helpers, REST client, `subscribe_events` WS client, mock layer, shell+router); `js/panels/*.js` (one module per §4 panel). Mounted via `tower-http` `ServeDir` in `homecore-server::build_app`, gated by `--ui-dir`/`HOMECORE_UI_DIR`.
**Verification:**
- **Rust** — `#[cfg(test)] mod ui_tests` in `homecore-server/src/main.rs`: 5 integration tests (`tower::oneshot`) covering index, design tokens, all ten panel modules served, API coexistence, and mount-disable. *Written but not compiled in the authoring environment (no Rust toolchain present); run `cargo test -p homecore-server` on a Rust host before merge.*
- **Frontend** — `ui/` test suite under plain `node` (no npm install): `npm test` → import/export graph verifier (15 modules) + render-smoke (executes every panel against a DOM shim; 21 checks) + interaction suite (live WS patch, ws.js handshake/parse, calibration contract; 3 checks). **24/24 green.**
- **Benchmark** — `npm run bench`: total bundle **136.8 KB** uncompressed (**~37× smaller** than HA's ~5 MB Lit bundle, the ADR-126 §1.1 foil); slowest panel **1.5 ms/cold-render**.
**Honest scope — current vs. target.** *Earlier cut:* the front-end was complete but only §4.4 Entities was wired to a real backend; the rest rendered from an in-browser mock. *This revision implements the §11 wiring:*
- **Front-end (§11.11) — DONE and verified.** `api.js` rewritten: all data accessors are async and call the §11.2 gateway routes; the mock layer is demoted to a dev-only fixture reachable **only** under `?demo=1` / `HOMECORE_UI_DEMO` (§2.2); every panel `await`s and renders a typed empty/error state on failure (no mock fallback in production). All ten panels converted (3 by hand, 7 via parallel agents). Verified under Node: 5 test files green — import graph, boot, render-smoke (22), interaction (3), **and a new prod-errors suite (13) that runs with demo OFF + gateway unreachable and asserts every panel renders an error state, never mock, never throws** (it caught and fixed a real unhandled-rejection in the events panel).
- **Gateway (§11.1–§11.6) — IMPLEMENTED, COMPILED, TESTED, RUN.** New `homecore-server/src/gateway.rs` (+`reqwest` dep, +CLI/env flags `--calibration-url`/`--calibration-token`/`--apps-dir`/`--gateway-timeout-ms`, merged into `build_app` via `gateway_router`). Real handlers: `/api/cal/*` reverse-proxy (W2), `GET /api/homecore/rooms` with the §11.3 RoomState adapter (W2), `GET /api/homecore/cogs` supervisor over the apps dir (W4), `GET /api/homecore/appliance` from `/proc` + port probes (W6). SEED-device/appliance-daemon routes (seeds, federation, witness, privacy, settings, automations, events-history, hailo, tokens — W3/W5) return a typed `503 upstream_unavailable` per §11.2. **Verified on Rust 1.89: `cargo test -p homecore-server --no-default-features` = 12/12 pass** (6 gateway + 6 UI mount). **Run live:** `GET /api/homecore/appliance` returns real `/proc` metrics + TCP service probes; unauth → `401`; `cogs``[]` with no apps dir; SEED-tier → typed `503`; and against a mock calibration upstream the `/api/cal/*` proxy passes through (`200`) and `GET /api/homecore/rooms` correctly adapts `RoomState` to the UI shape (`breathing``breathing_bpm`, `heartbeat:null``heart_bpm:null`, injected `anomaly.threshold`/`room_id`, `stale` passthrough). **Live testing caught + fixed one real bug** — a double-`v1` path in the `/api/cal/*` proxy URL.
The endpoint-by-endpoint contract is **§11**; the staged plan and which endpoints depend on real SEED/appliance hardware vs. pure software is **§12**.
---
## 11. Backend wiring — making every panel real
This section is the authoritative contract for full functionality. It removes the mock layer from the production path (§2.2) by routing every panel through the `homecore-server` BFF gateway (§2.1). Each endpoint is classified by what it depends on:
- **EXISTS** — backend code already in this repo; gateway only proxies/adapts.
- **NEW-GW** — pure software the gateway itself implements (filesystem, `/proc`, process control, recorder query) — no new external service.
- **NEW-API** — a small HTTP wrapper to add to an existing in-repo crate (`homecore-api`, `homecore-automation`).
- **SEED-DEV** — depends on a SEED node's on-device HTTPS API (separate hardware/firmware).
- **APPLIANCE** — depends on an appliance daemon / accelerator stat source.
### 11.1 Gateway shape
`homecore-server` already mounts `homecore-api` at `/api/*` and the UI at `/homecore`. It gains a new **`/api/homecore/*`** namespace (the dashboard-specific aggregation surface) plus a **`/api/cal/*`** reverse-proxy to the calibration service. The browser issues only same-origin requests; the gateway fans out server-side, holding all upstream credentials (§11.10). Every proxied route has an explicit timeout and maps upstream failure to a typed body (`503 upstream_unavailable`, `504 upstream_timeout`) so one slow tier never stalls the dashboard.
### 11.2 Master endpoint contract (panel → gateway route → upstream → status)
| Panel | UI method (`api.js`) | Gateway route | Upstream / source | Class |
|---|---|---|---|---|
| §4.4 Entities | `states()` | `GET /api/states` | `homecore` state machine | **EXISTS** ✅ wired |
| §4.4/§4.8 live feed | WS | `GET /api/websocket` (`subscribe_events`) | `homecore` event bus | **EXISTS** ✅ wired |
| §4.8 Event history | `eventHistory(q)` | `GET /api/events?since=…` | `homecore-recorder` ([ADR-132](ADR-132-homecore-recorder-history-semantic-search.md)) | **NEW-API** |
| §4.8 Automations | `automations()` / `saveAutomation()` | `GET/POST/DELETE /api/homecore/automations` | `homecore-automation` ([ADR-129](ADR-129-homecore-automation-engine.md)) | **NEW-API** |
| §4.5 Rooms | `roomStates()` | `GET /api/homecore/rooms` → per-room `GET /api/cal/v1/room/state?bank=` | `calibrate-serve` ([ADR-151](ADR-151-room-calibration-specialist-training.md)) | **EXISTS** (proxy + adapter) |
| §4.7 Calibration | `calibration.*` | `POST /api/cal/v1/calibration/{start,stop}`, `GET …/status`, `POST …/enroll/anchor`, `GET …/enroll/status`, `POST …/room/train` | `calibrate-serve` | **EXISTS** (proxy) |
| §4.6 COGs | `cogs()` / `cogAction()` / `cogLogs()` | `GET /api/homecore/cogs`, `POST …/cogs/:id/{start,stop,restart}`, `GET …/cogs/:id/logs`, `GET/PUT …/cogs/:id/config` | COG supervisor over `/var/lib/cognitum/apps/` ([ADR-100](ADR-100-cog-packaging-specification.md)/[ADR-128](ADR-128-homecore-integration-plugin-system.md)) | **NEW-GW** |
| §4.6 Hailo HEF | `hailo()` | `GET /api/homecore/hailo` | `ruvector-hailo-worker:50051` | **APPLIANCE** |
| §4.1 Appliance health | `appliance()` | `GET /api/homecore/appliance` | host `/proc` + Hailo stats + service probes | **NEW-GW** (+APPLIANCE for Hailo) |
| §4.1/§4.2 Fleet + SEED detail | `seeds()` / `seed(id)` | `GET /api/homecore/seeds`, `GET …/seeds/:id` | SEED device HTTPS API ([ADR-069](ADR-069-cognitum-seed-csi-pipeline.md)) via registry | **SEED-DEV** |
| §4.2 SEED actions | `seedCompact()` / `seedVerify()` | `POST …/seeds/:id/{compact,witness/verify}` | SEED device API | **SEED-DEV** |
| §4.3 Federation | `federation()` | `GET /api/homecore/federation` | federation coordinator ([ADR-105](ADR-105-federated-csi-training.md)) | **SEED-DEV/APPLIANCE** |
| §4.9 Witness/Audit | `witnessLog(p,s)` | `GET /api/homecore/witness?page=…` | merge: `homecore` Ed25519 chain + per-SEED SHA-256 chains | **NEW-API + SEED-DEV** |
| §4.9 Privacy mode | `privacyModes()` / `setPrivacy()` | `GET/POST /api/homecore/privacy` | SEED privacy control plane ([ADR-141](ADR-141-bfld-privacy-control-plane-modes-attestation.md)) + cog-ha-matter | **SEED-DEV** |
| §4.9 Export bundle | `exportAttestation()` | `GET /api/homecore/witness/export` | gateway packages both chains | **NEW-GW** |
| §4.10 Tokens (LLAT) | `tokens()` / `createToken()` / `revokeToken()` | `GET/POST/DELETE /api/homecore/tokens` | `homecore-api` `LongLivedTokenStore` | **NEW-API** |
| §4.10 MQTT/Matter | `mqttConfig()` | `GET /api/homecore/integrations/mqtt` | cog-ha-matter config ([ADR-116](ADR-116-cog-ha-matter-seed.md)) | **NEW-GW/SEED-DEV** |
| §4.10 ESP32 provisioning | `nodes()` / `assignRoom()` | `GET/PUT /api/homecore/nodes` | SEED ingest config ([ADR-069](ADR-069-cognitum-seed-csi-pipeline.md)) | **SEED-DEV** |
| §4.10 SEED mgmt | `pairSeed()` / `rotateToken()` | `POST /api/homecore/seeds/{pair,:id/rotate-token}` | SEED pairing (USB `169.254.42.1`) | **SEED-DEV** |
### 11.3 Calibration proxy + RoomState adapter
The calibration service is real but on a different binary/port; the gateway reverse-proxies it under `/api/cal/*` (upstream base from `HOMECORE_CALIBRATION_URL`). Its `RoomState` (`wifi-densepose-calibration/src/runtime.rs`) does **not** match the UI's shape, so the gateway adapts it in `GET /api/homecore/rooms`:
| Real field (`RoomState`) | UI field | Adapter rule |
|---|---|---|
| `breathing: Option<SpecialistReading>` | `breathing_bpm: {value,confidence}\|null` | rename; `value`=`reading.value`, `confidence`=`reading.confidence`; `None``null` (preserves "not trained") |
| `heartbeat: Option<…>` | `heart_bpm: {…}\|null` | rename `heartbeat``heart_bpm` |
| `presence/posture/restlessness` | same names `{value,confidence}\|null` | `posture.value`=`reading.label` (class), else numeric |
| `anomaly: Option<…>` | `anomaly: {value,confidence,threshold}` | inject `threshold`=`MixtureOfSpecialists.veto_threshold` (0.5) |
| `vetoed` / `stale` | `vetoed` / `stale` | pass through (drives the §4.5/§6 banners) |
| *(absent)* | `room_id`, `seeds[]` | injected by the gateway from the **room registry** |
A **room registry** (config or derived from `GET /api/cal/v1/calibration/baselines`) maps each `room_id` → bank name + serving SEED ids, so `GET /api/homecore/rooms` returns one adapted record per room. `Option::None` → JSON `null` keeps the null-vs-withheld distinction (§6 invariant 3) intact end-to-end.
### 11.4 SEED registry & device-API proxy
The gateway holds a **SEED registry** (`device_id` → base URL + bearer token + zone), populated by pairing (§4.10) and persisted server-side. `GET /api/homecore/seeds[/:id]` fans out to each SEED's on-device API and shapes the result to the §4.2 card/detail model. Expected SEED-side endpoints (the contract the SEED firmware must satisfy — a subset of its 98 endpoints): health; vector-store stats (`vector_count`, `dim`, `epoch`, `knn_latency_ms`, ingest rate); witness (`len`, `last_verify`, `valid`) + `POST verify`; onboard sensors (BME280/PIR/reed/ADS1115/vibration); reflex rules + thresholds; cognitive analysis (fragility, coherence phases); ingest feeders (ESP32 node ids + packet type `0xC5110003`/`0xC5110002` + rate). Offline/unreachable SEEDs surface as `online:false` (drives the §4.1 red tint) rather than failing the whole list.
### 11.5 Appliance metrics collector (§4.1)
`GET /api/homecore/appliance`, implemented in the gateway: CPU/RAM/uptime from `/proc`; Hailo load + temperature from the Hailo runtime/sysfs (or `ruvector-hailo-worker` stats); service health by probing `ruview-mcp-brain:9876`, `cognitum-rvf-agent:9004`, `ruvector-hailo-worker:50051`; event-bus rate from the `homecore` broadcast channel + its lag counter (already exposed for §4.1/§4.4).
### 11.6 COG supervisor (§4.6)
`GET /api/homecore/cogs`: read each `/var/lib/cognitum/apps/*/manifest.json` ([ADR-100](ADR-100-cog-packaging-specification.md)), the pid file, and verify `binary_sha256` + `binary_signature` (Ed25519) → status/shield. `POST …/cogs/:id/{start,stop,restart}` performs supervised process control; `GET …/cogs/:id/logs` tails `output.log`/`error.log`; `GET/PUT …/cogs/:id/config` reads/writes `config.json`. Hailo-arch COGs join the §11.5 Hailo stats. The Cog Store/App-Registry **browsing** panel was removed per product decision; this is operational management only.
### 11.7 Witness aggregation + privacy (§4.9)
`GET /api/homecore/witness` merges two chains chronologically: the `homecore` Ed25519 state-transition chain (exposed by a small `homecore-api` route over its witness log) and each paired SEED's SHA-256 ingest chain (proxied via the registry), paginated server-side. `GET/POST /api/homecore/privacy` reads/sets per-SEED privacy mode via the SEED privacy control plane ([ADR-141](ADR-141-bfld-privacy-control-plane-modes-attestation.md)) — the POST is the high-stakes confirmed toggle (§4.9). `GET /api/homecore/witness/export` packages both chains into the downloadable attestation bundle.
### 11.8 Event history + automation CRUD (§4.8)
`homecore-api` adds `GET /api/events?since=…` backed by `homecore-recorder` ([ADR-132](ADR-132-homecore-recorder-history-semantic-search.md)) for history (live updates continue over the existing WS). The automation builder persists through `GET/POST/DELETE /api/homecore/automations`, a thin HTTP wrapper over the `homecore-automation` engine's register/list/remove ([ADR-129](ADR-129-homecore-automation-engine.md)). RuView-specific triggers (RoomState thresholds, SEED reflex events) map onto the engine's trigger types.
### 11.9 Entity provenance convention (§4.4/§6)
The first-class provenance badge requires each entity to carry its lineage. Convention: every integration writes `attributes.source` (and, where known, `attributes.seed` / `attributes.cog`) when it sets state; `cog-ha-matter` ([ADR-116](ADR-116-cog-ha-matter-seed.md)) populates these from the ESP32 node → SEED → COG path and HA `via_device`. The gateway/UI resolves node→seed→cog from these attributes (no fabrication; missing lineage renders as "unknown", not invented).
### 11.10 Auth, credentials, config
- **Browser → gateway:** one long-lived access token (the §4.10 LLAT), sent as `Authorization: Bearer`; validated by `homecore-api`'s `LongLivedTokenStore`. The dev default (`allow_any_non_empty`) stays for local runs; production provisions `HOMECORE_TOKENS`.
- **Gateway → upstreams:** SEED bearer tokens and the calibration token live **only** server-side (SEED registry + `HOMECORE_CALIBRATION_TOKEN`); never sent to the browser. This is the reason the gateway exists.
- **Config:** `HOMECORE_CALIBRATION_URL`, SEED registry store path, per-proxy timeout (default 2 s), `HOMECORE_UI_DEMO` (dev fixture). No browser CORS needed (same origin); gateway→upstream is server-to-server.
### 11.11 Front-end changes
`api.js`: drop the mock fallback from the production path — methods call the §11.2 gateway routes; `this.base` stays same-origin; the mock layer is reachable only under `?demo=1`/`HOMECORE_UI_DEMO`. Every panel renders a **typed empty/error state** (not mock) when its route returns `503/504`. `mock.js` moves to a dev fixture (kept for the offline test harness, excluded from the production bundle). The §10 frontend tests are re-pointed at the gateway contract (and gain contract tests per §11.2 route).
---
## 12. Delivery plan to full functionality
Staged so each wave is independently shippable behind the gateway, lands real data for a coherent set of panels, and has an explicit acceptance gate. "Class" reuses §11's tags.
| Wave | Scope | Class | Acceptance gate |
|---|---|---|---|
| **W1 — Gateway foundation** | `/api/homecore/*` scaffold in `homecore-server`; auth passthrough; per-proxy timeout + typed errors; `api.js` base + remove prod mock (`?demo=1` only); panels get typed empty/error states | NEW-GW | Entities + live WS still green; with no upstreams, every other panel shows "upstream unavailable", **never** mock (unless `?demo=1`); Rust + JS suites pass |
| **W2 — Rooms + Calibration** | `/api/cal/*` reverse-proxy; `GET /api/homecore/rooms` with the §11.3 RoomState adapter + room registry; wire §4.5 + the §4.7 wizard to real endpoints; delete the in-browser calibration stub | EXISTS (proxy+adapter) | Against a running `calibrate-serve` (replayed CSI), the wizard drives a real baseline→enroll→train→verify and §4.5 shows real `RoomState` with correct stale/veto/null mapping; contract test on the adapter |
| **W3 — Events + Automations** | `GET /api/events` over `homecore-recorder`; `/api/homecore/automations` over `homecore-automation` | NEW-API | §4.8 history loads from recorder; an automation created in the UI persists and fires via the engine |
| **W4 — COG management** | `/api/homecore/cogs*` supervisor over `/var/lib/cognitum/apps/` (manifest + pid + sig verify + logs + config) | NEW-GW | §4.6 lists real installed COGs; start/stop/restart works; sha256/signature shield reflects real verification; logs tail |
| **W5 — SEED tier** | SEED registry + pairing; `/api/homecore/seeds*` device proxy; witness merge + privacy control; ESP32 provisioning | SEED-DEV | Against a real or emulated SEED API, §4.2/§4.3/§4.9/§4.10 show real vector-store/witness/sensor/reflex/cognition data; SEED tokens stay server-side; offline SEED → red tint, not a failed page |
| **W6 — Appliance + federation + Hailo** | `/api/homecore/appliance` (host metrics + service probes); `/api/homecore/hailo`; `/api/homecore/federation` ([ADR-105](ADR-105-federated-csi-training.md)) | NEW-GW + APPLIANCE | §4.1 health is real; §4.6 Hailo HEF/throughput real; §4.3 federation round/coordinator/Krum real |
**Definition of done (full functionality):** with W1W6 merged and the upstream tiers running, loading `/homecore` with **no** `?demo=1` flag shows live data on all ten panels, `api.anyDemo()` is false, and no panel renders fabricated values. Panels whose tier is offline show typed empty/error states. The mock layer is reachable only as the `?demo=1` developer fixture.
### 12.1 Wave status (this revision)
| Wave | Status |
|---|---|
| **W1 — Gateway foundation** | ✅ DONE — `gateway.rs`, auth passthrough, typed `503/504`, merged into `build_app`; front-end mock removed from prod path + `?demo=1` fixture; typed error states. **Compiled + 12/12 Rust tests + JS suite green + run live.** |
| **W2 — Rooms + Calibration** | ✅ DONE — `/api/cal/*` reverse-proxy + `GET /api/homecore/rooms` RoomState adapter; front-end calibration stub deleted (now proxies the real API). **Proven live against a calibration upstream** (proxy 200 + adapted shape); null-preservation unit-tested. |
| **W3 — Events + Automations** | ⏳ gateway returns typed `503` (recorder/automation HTTP wrappers pending); front-end handles it gracefully (history note, builder still usable). |
| **W4 — COG management** | ✅ supervisor DONE — lists `/var/lib/cognitum/apps/` manifests + pid liveness (returns `[]` live with no apps dir); start/stop/log/config control is the remaining follow-up. |
| **W5 — SEED tier** | ⏳ gateway returns typed `503` (SEED registry + device proxy pending real/emulated SEED hardware). |
| **W6 — Appliance + federation + Hailo** | ◑ appliance host metrics from `/proc` + port probes DONE (live `/proc` data verified); Hailo stats + federation remain `503` (need the accelerator stat source / coordinator). |
**Status:** the gateway is **compiled and tested on Rust 1.89** (`cargo test -p homecore-server` = 12/12) and was **run live** (curl proof in §10). The one remaining caveat is intrinsic, not an environment limit: **W3/W5/W6-Hailo/federation depend on services/hardware that are not in this repo** (recorder/automation HTTP wrappers, real SEED nodes, the Hailo stat source), so they return honest typed `503`s and the UI shows error states — exactly as §2.2/§11.2 prescribe. W1/W2/W4/W6-appliance are functional now.
### 12.2 Security review (PR #1082)
A high-effort public-PR review of the merged gateway + front-end surfaced the following, all fixed and pinned by tests (`cargo test -p homecore-server` is now **18/18**):
| # | Severity | Finding | Fix |
|---|---|---|---|
| 1 | **HIGH** | **Path-traversal / confused-deputy SSRF** in the `/api/cal/*` reverse-proxy. The wildcard path was interpolated into the upstream URL while `proxy()` attaches the privileged server-side calibration bearer, so `/api/cal/v1/../../x` (or `..%2f`, `%2e%2e`, leading `/`, `\`, double-encoded `%252e`) could escape the `…/api/` scope **with the token**. | `validate_proxy_path()` decode-then-checks and rejects absolute / backslash / dot-segment / encoded-traversal paths with a typed **400 before the URL is built** (GET **and** POST); legit `v1/...` paths still pass. |
| 2 | Correctness | **CORS + tracing didn't cover gateway routes**`/api/homecore/*` + `/api/cal/*` were `.merge()`d outside `homecore-api::router()`'s layers. | The audited HC-05 `build_cors_layer()` + `TraceLayer` are now applied to the whole merged app in `main.rs`. |
| 3 | Honesty (§6) | **Fabricated data** — hardcoded `anomaly.threshold: 0.5` in the adapter; dashboard rendered `"null%"`/`"null°C"`; COG Hailo pill hardcoded `"connected"`; `rooms.js` defaulted a null threshold to `0.8`. | Threshold passes through the real upstream value or emits `null` (withheld); dashboard renders `—`; the Hailo pill reflects the real appliance probe; the UI treats a null threshold as withheld. |
| 4 | Robustness | A string `hef` (forwarded verbatim) threw on `.forEach`/`.join`; `frames/target` could be `NaN%`/`Infinity%`; calibration Restart leaked the baseline `setTimeout` poll. | `asArray()` coercion; `target > 0` guard; cancellable poll cleared on Restart / panel teardown. |
| 5 | Perf | Sequential per-bank RoomState fetches; blocking `std::net::TcpStream::connect_timeout` probes on an async handler; `mock.js` statically bundled. | Concurrent `futures::join_all`; async `tokio::net::TcpStream` + `timeout`; demo-only dynamic `import()` of `mock.js`. |
**Known limitations carried forward (not regressions):**
- **`reqwest` rustls-only is a workspace-wide concern.** `homecore-server` opts into `rustls-tls` only, but cargo feature-unification means any sibling crate enabling the default `native-tls` re-introduces OpenSSL into the final binary. A true "no OpenSSL on the appliance" guarantee requires aligning **every** reqwest-pulling crate on rustls-only — out of scope for this PR; documented at the dependency in `Cargo.toml`.
- **DEV-mode auth.** When `HOMECORE_TOKENS` is unset, the token store falls back to `allow_any_non_empty()` (any non-empty bearer accepted) on `0.0.0.0`. This is pre-existing and intentionally **unchanged** here; the loud boot `warn!` is retained. Provision real tokens (`HOMECORE_TOKENS=…`) before exposing the server to a network.
@@ -0,0 +1,92 @@
# ADR-177: `nvsim` Degenerate-Input Hardening (NV-Diamond Simulator)
| Field | Value |
|-------|-------|
| **Status** | Accepted — 2 real MEDIUM bugs fixed + pinned; determinism preserved |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **NVSIM-FAILCLOSED** |
| **Reviews** | ADR-089 (`nvsim` NV-diamond magnetometer pipeline simulator) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 2 of 4 |
## Context
`nvsim` (ADR-089) is a standalone, **WASM-ready** deterministic NV-diamond
magnetometer pipeline simulator — a forward-only leaf:
`scene → source → propagation → NV ensemble → digitiser → MagFrame + SHA-256
witness`. It has no network surface, so the real attack surface is **degenerate
physical-parameter input** crossing the external boundary — specifically the
WASM `config_json` / `scene_json` entry points.
Two properties matter for this crate that don't for others: it is billed
**deterministic** (a published cross-machine witness must reproduce bit-exactly),
and under `panic=abort` WASM any panic **aborts the whole module**. So a
config-induced panic is a denial-of-service, and a silent numeric corruption
defeats the simulator's entire purpose.
## Decision
Fix the two reachable degenerate-input bugs at their funnel points, each pinned
by a fails-on-old test, **without perturbing the deterministic happy path** (the
guards fire only on non-finite / degenerate input; the published witness is
unchanged).
### Findings fixed (both MEASURED-reproduced)
| # | Severity | Location | Issue | Fix |
|---|----------|----------|-------|-----|
| NVSIM-DT-01 | MEDIUM (DoS) | `pipeline.rs:58,95` | `dt = config.dt_s.unwrap_or(1.0 / f_s_hz)`; an external `f_s_hz == 0.0``dt = +Inf``(dt*1e6) as u64` saturates to `u64::MAX``(sample as u64) * dt_us` **panics `attempt to multiply with overflow`** at `sample ≥ 2` (debug/WASM-abort; garbage `t_us` in release). MEASURED: panic at `pipeline.rs:95:30`. | Sanitise `dt` (non-finite/non-positive → 1 µs fallback), cap the `u64` cast at `u64::MAX`, `saturating_mul` the timestamp — no config can overflow it. |
| NVSIM-NAN-01 | MEDIUM (silent corruption) | funnel `digitiser.rs::adc_quantise` (root: near-field clamp bypass in `source.rs`) | A non-finite scene param (NaN/Inf dipole position, Inf moment, NaN loop radius) **bypasses the near-field clamp** (`NaN < R_MIN_M == false` → the `1/r³` path runs → NaN field), and at the ADC `NaN as i32 == 0` (Rust saturating cast) emits a frame `b_pt=[0,0,0]` with **`ADC_SATURATED` CLEAR** — indistinguishable from a legitimate zero-field reading. MEASURED: `b=[NaN,NaN,NaN] sat=false``b_pt=[0,0,0] flags=0b0000`. | `adc_quantise`: any non-finite input → code `0` **with the saturation flag raised**; the pipeline's existing `adc_sat` OR-reduction propagates `ADC_SATURATED` onto the frame, making the corruption visible downstream. |
This is the same **NaN-fail-open / NaN-poisoning** family seen across
calibration/vitals/geo and ruview-swarm — non-finite input defeating a guard —
but bounded here to a single frame (no cross-timestep accumulator).
### Dimensions confirmed clean (with evidence)
1. **Determinism integrity — clean.** One RNG only: `ChaCha20Rng::seed_from_u64(seed)`,
fully caller-seeded (grep: one `seed_from_u64`, **zero** `thread_rng`/`getrandom`/
`SystemTime`/`Instant`/`HashMap`); `Cargo.toml` pins `rand`/`rand_chacha`
`default-features=false` (no OS entropy). BoxMuller draws
`gen_range(f64::EPSILON..=1.0)` (avoids `ln(0)=-Inf` by construction). Frame
bytes fixed LE; source summation order fixed by `Vec` order. **The published
cross-machine witness `cc8de9b0…93b4` (`proof_witness_publishes_a_known_value`)
passes UNCHANGED after both fixes** — the happy path is byte-identical; guards
touch only degenerate inputs. *Attested caveat (not a finding): libm
`cos`/`ln`/`sqrt` could differ x86↔wasm; the witness is documented as
x86_64-captured.*
2. **Panic-free deserialisation — clean.** `MagFrame::from_bytes` validates
len/magic/version, then per-field `buf[a..b].try_into().expect(...)` are over
fixed sub-ranges of an already-length-checked 60-byte buffer (provably
infallible). No `unsafe`, no `panic!`/`unreachable!` in production; every other
`unwrap`/`expect` is `#[cfg(test)]`.
3. **Div-by-zero / numerical landmines — clean.** `dipole_field`/`current_loop_field`
clamp `r_norm < R_MIN_M` before `1/r³`,`1/r²` (finite inputs); `shot_noise_floor`
guards `denom <= 0`; `vec3_normalise` guards `n < 1e-20`. The only hole was the
NaN *bypass* of the clamp — closed at the ADC funnel (NVSIM-NAN-01).
## Validation
- `cargo test -p nvsim --no-default-features`**50 → 53** passed, 0 failed (+3 pins:
`degenerate_zero_sample_rate_does_not_panic`,
`non_finite_scene_input_flags_frame_instead_of_silently_zeroing`,
`adc_quantise_flags_non_finite_as_saturated`).
- `cargo test --workspace --no-default-features`**exit 0**, 0 failed.
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash
`f8e76f21…46f7a` unchanged (nvsim off the signal proof path).
- nvsim's own cross-machine witness `cc8de9b0…93b4` reproduces unchanged.
## Consequences
### Positive
- A config-induced WASM-abort DoS and a silent NaN→fake-zero-field corruption are
closed at their funnel points, each regression-pinned, with the deterministic
witness proven intact.
### Negative / Neutral
- None. Guards affect only degenerate inputs; happy-path output is byte-identical.
## Links
- ADR-089 — `nvsim` NV-diamond magnetometer simulator
- ADR-176 — `ruview-swarm` (sibling NaN-fail-open review)
- ADR-172 — core/cli (where the NaN-bug-class root was settled NO)
@@ -0,0 +1,87 @@
# ADR-178: `wifi-densepose-desktop` IPC Injection Fix + Capability Least-Privilege
| Field | Value |
|-------|-------|
| **Status** | Accepted — 2 real MODERATE bugs fixed + pinned (MEASURED on Windows) |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **DESK-LOCKDOWN** |
| **Reviews** | `wifi-densepose-desktop` (Tauri v2 desktop app) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 3 of 4 |
## Context
`wifi-densepose-desktop` is the Tauri v2 desktop app (ESP32 discovery, firmware
flashing, OTA, provisioning, server control). The real attack surface is the
**Tauri IPC boundary**`#[tauri::command]` handlers that take arguments from the
webview/JS — and the **capability/allowlist scope**. The crate **builds and tests
on Windows** (Tauri 2.10.3, webview2 path, no GTK), so both findings are MEASURED,
not source-analysis-only.
## Decision
Fix the two real findings; attest the rest of the surface clean with evidence.
### Findings fixed (both MEASURED)
| # | Severity | Location | Issue | Fix |
|---|----------|----------|-------|-----|
| WDP-DESK-01 | MODERATE | `src/commands/discovery.rs:438` (`configure_esp32_wifi`) | Webview-supplied `ssid`/`password` are concatenated into newline-terminated serial commands (`wifi_config {} {}\r\n`, `set ssid {}\r\n`) with **no validation** → a `\r\n` in either field **injects an arbitrary follow-up firmware command** (`reboot`, `erase_nvs`) across the IPC trust boundary. | `validate_wifi_credentials()` — WPA2 length bounds (SSID 132, password 863) **+ reject all control chars** (`char::is_control()`), called fail-closed before any serial write. |
| WDP-DESK-02 | MODERATE | `capabilities/default.json:7-8` | `shell:allow-execute` + `shell:allow-open` granted to the webview but **unused** (Rust spawns via `std::process::Command`; the UI uses only `dialog.open`). A webview compromise (a UI-dependency XSS) → arbitrary **unscoped host command execution**. | Removed both `shell:` permissions (kept `core:default` + the two in-use `dialog:` perms); regenerated `gen/schemas/capabilities.json` now asserts `["core:default","dialog:allow-open","dialog:allow-save"]`. |
Both are MODERATE (not HIGH): each requires a webview compromise or a malicious
local caller to weaponize. The unifying lesson is **least privilege at the IPC
boundary** — validate every webview-supplied argument that reaches a serial/FS/
process sink, and grant only the capabilities actually exercised.
### Tauri-command + capability audit (every handler)
All 30+ command handlers were mapped. Only `configure_esp32_wifi` lacked input
validation on a string that reached a command sink (WDP-DESK-01). Every
subprocess uses `Command::new(prog).args([...])` (argv vector — no shell-string
interpolation), so `port`/`source`/`chip`/`baud` cannot inject a second command
even unvalidated. `tauri.conf.json` ships **no** `fs`/`http` plugin and **no**
`"all":true`/`"$HOME/**"` scope; after WDP-DESK-02 the allowlist is minimal.
### Dimensions confirmed clean (with evidence)
1. **Directory traversal / arbitrary file** — path args (`firmware_path`/`wasm_path`)
are blobs the local user selects via the native `dialog.open` picker; settings
I/O is a fixed filename under `app_data_dir`. No attacker-named path sink.
2. **Shell-string injection** — every subprocess is an argv vector; grep found no
shell-string interpolation anywhere.
3. **SSRF-to-secret**`node_ip`-built URLs target the local ESP32 mesh and return
only device status JSON; no credential returned to the webview.
4. **Panic-on-input** — handlers use `.map_err(|e| e.to_string())?`; the one
`expect` is guarded by an `is_none()` early-return; provision/discovery
deserializers bounds-check every slice index (NVS size capped ≤ 4096).
5. **Hardcoded secrets**`ota_psk` is a per-call `Option<String>`, never embedded;
grep for embedded keys/tokens over `src/` is empty.
6. **Shell plugin genuinely unused**`tauri_plugin_shell` is `init()`-ed but its
`Command`/`open` API is never invoked from Rust or the TS UI (which imports only
`@tauri-apps/plugin-dialog`) — confirming WDP-DESK-02 is safe to remove.
## Validation
- `cargo check -p wifi-densepose-desktop --no-default-features``Finished` (Windows, MEASURED).
- `cargo test -p wifi-densepose-desktop --no-default-features` → lib **18 → 21** (+3 validator pins:
`test_validate_wifi_credentials_rejects_injection` / `_rejects_out_of_range` / `_accepts_valid`),
integration 21/21, **0 failed**.
- Capability narrowing MEASURED: regenerated `capabilities.json` permission set verified.
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash `f8e76f21…46f7a`
unchanged (desktop off the signal proof path).
## Consequences
### Positive
- An IPC serial-command-injection path and an over-broad shell capability are
closed in the desktop app, each pinned / verified, with the rest of the
30-command IPC surface attested clean.
### Negative / Neutral
- None. The removed shell capability was unused; the validator rejects only
malformed/hostile credentials.
## Links
- ADR-176 / ADR-177 — sibling Milestone-#9 reviews (ruview-swarm, nvsim)
- ADR-172 — core/cli review
@@ -0,0 +1,81 @@
# ADR-179: `wifi-densepose-occworld-candle` Checkpoint-Load Hardening
| Field | Value |
|-------|-------|
| **Status** | Accepted — 1 HIGH + 2 LOW bugs fixed + pinned (MEASURED on Windows) |
| **Date** | 2026-06-15 |
| **Deciders** | ruv |
| **Codename** | **OCCWORLD-DTYPE** |
| **Reviews** | `wifi-densepose-occworld-candle` (Candle occupancy-world model) |
| **Milestone** | #9 (ungated-crate security sweep) — crate 4 of 4 — **CLOSES the milestone** |
## Context
`wifi-densepose-occworld-candle` is a Candle-based occupancy-world model
(VQ-VAE + transformer over occupancy tokens). The real risk surface for an ML
crate is degenerate-input / malformed-weights handling: a `#[forbid(unsafe_code)]`
crate can still **panic** (a DoS, and under WASM an abort) when a tensor op hits an
inconsistent shape. The crate **builds and tests on Windows**, so all findings are
MEASURED.
## Decision
Fix the three reachable bugs, each pinned by a fails-on-old test; attest the rest
clean with evidence.
### Findings fixed (all MEASURED)
| # | Severity | Location | Issue | Fix |
|---|----------|----------|-------|-----|
| 1 | **HIGH** | `model.rs:95` (`Dtype::I32 => Some(DType::I64)`) | **Crash on any int32-tensor checkpoint.** An I32 byte buffer (4 B/elem) is handed to `from_raw_buffer(.., I64, shape, ..)`; candle derives `elem_count = data.len()/8`, **halving** the count while keeping the original shape → a tensor that claims 2× its storage. Reading it **panics** with a slice-OOB (`range end index 6 out of range for slice of length 3`) inside candle-core. A checkpoint with any int32 tensor (index/buffer tensors are common in PyTorch exports) → **DoS on load**. | Map `I32 → DType::I32`, `I16 → DType::I16` (both first-class candle dtypes). Pinned by `int32_tensor_loads_with_consistent_shape_and_values` (panics on old, passes on new). |
| 2 | LOW | `inference.rs::predict` | Frame/batch dims weren't validated (only H/W/D were): `f_in > num_frames*2` over-indexes the temporal embedding → a cryptic candle `InvalidIndex` *error* (not a panic — candle bounds-checks); zero frame/batch feeds a zero-element tensor. | Boundary guard rejects zero / over-capacity frame+batch with a clear `ShapeMismatch`. 5 pins. |
| 3 | LOW | `vqvae.rs:141` (`z.elem_count() / last`) | **Divide-by-zero panic** in public `VQCodebook::encode` on a rank-0 / empty-last-dim tensor (`last == 0`). | Fail-closed guard returns a clear error. Pinned by `encode_rejects_scalar_without_panicking`. |
The HIGH finding is the notable one: the crate's own dtype mapping **defeated**
the upstream `safetensors::validate()` byte-length guarantee by misdeclaring the
dtype — the one place malformed/widened weights could reach a panicking candle op.
### Dimensions confirmed clean (with evidence)
- **Panic surface** — grep for `unwrap()/expect()/panic!/unreachable!` across `src/`
**zero in production paths**; all ops use `?`/`map_err`; the `last().unwrap_or(&0)`
is now guarded. `as` casts operate only on config-bounded/internal values.
- **NaN-state-poisoning (the named class) — N/A.** The engine is **stateless between
`predict` calls** (no persistent world-model buffer to latch into), and input is
`u8` class indices (non-finite input structurally impossible). NaN weights flow to
`argmax` (deterministic, bounded to a valid class index) — no panic, no persistence.
- **Unbounded alloc / shape-data mismatch from malformed weights** — defended upstream
by `safetensors::validate()` (overflow-checked `nelements*dtype.size()` vs declared
byte range + contiguous-offset + buffer-length checks), rejected before reaching
candle. Finding #1 was the one place the crate defeated that guarantee.
- **Model/path loading** — `load`/`load_safetensors` check `path.exists()` → typed
`CheckpointNotFound`; corrupt bytes → `CheckpointParse` (pinned). No path-traversal
surface (caller-supplied path, opened read-only, never joined with untrusted segments).
- **Secrets** — grep clean (only `token_h`/`token_w` config fields match `token`).
- **Determinism** — the crate's central honesty claim, verified by the pre-existing
`tests/predict_honesty.rs` (3 tests, still pass).
- `unsafe_code = "forbid"` in the manifest.
## Validation
- `cargo test -p wifi-densepose-occworld-candle --no-default-features`**31/31**
(lib 17, checkpoint_loading 4, input_validation 5, predict_honesty 3, doctests 2),
0 failed.
- `cargo test --workspace --no-default-features` → 0 failed across every crate (a lone
`wifi-densepose-desktop --test api_integration` "Access is denied (os error 5)" was a
Windows file-lock/AV flake — re-ran isolated 21/21, unrelated).
- `python archive/v1/data/proof/verify.py`**VERDICT: PASS**, hash `f8e76f21…46f7a`
unchanged (occworld off the signal proof path).
## Consequences
### Positive
- A checkpoint-load DoS (the int32 dtype-widening panic) and two degenerate-input
panics are closed in the world-model crate, each pinned. **Milestone #9 (all 4
ungated crates) is complete.**
### Negative / Neutral
- None. Guards reject only malformed/degenerate inputs.
## Links
- ADR-176 / ADR-177 / ADR-178 — sibling Milestone-#9 reviews (ruview-swarm, nvsim, desktop)
@@ -0,0 +1,279 @@
# ADR-182: `npx ruview` — A RuView Agent Harness Minted via MetaHarness
| Field | Value |
|-------|-------|
| **Status** | Accepted — **P1+P2 implemented & validated** (`harness/ruview/`, 17/17 tests, MCP handshake + `ruview.verify` PASS against the real repo, packs to 16.7 kB / 21 files) · P3 publish-ready (name decision pending) · P4 (router + provenance) designed |
| **Date** | 2026-06-17 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-HARNESS** |
| **Builds on** | MetaHarness (`metaharness@0.1.15`, `@metaharness/kernel`, `@metaharness/host-*`, `@metaharness/router`), the `ruview-*` Claude Code subagents (`ruview-onboarding-guide`, `ruview-config-engineer`, `ruview-training-engineer`), the `wifi-densepose` CLI (`calibrate`/`enroll`/`train-room`/`room-watch`), the sensing-server, ADR-028 (witness verification), ADR-095/096 (rvCSI runtime), ADR-260/262 (RuField bridge) |
| **Supersedes** | none |
## Context
RuView (WiFi-DensePose) is a deep stack — 15 Rust crates, an ESP32 firmware line,
a sensing-server, a CLI, ~180 ADRs, a calibration pipeline, training recipes, and a
hard cultural rule that **every claim must be independently reproducible** (the
"prove everything" ethos, after the project was accused of AI-slop). The barrier to
entry is correspondingly steep: a newcomer who wants to "set up WiFi sensing" must
discover the right firmware variant, provision an ESP32 over a Windows-only Python
subprocess, point it at the sensing-server, run `calibrate``enroll`
`train-room`, and know which numbers are MEASURED vs CLAIMED. We already encode this
knowledge as **Claude Code subagents** (`ruview-onboarding-guide`,
`ruview-config-engineer`, `ruview-training-engineer`) — but those only exist inside
*this* repo's `.claude/agents/`, only on Claude Code, and only for someone who has
already cloned the monorepo.
Separately, this session shipped **MetaHarness** (`metaharness@0.1.15`): a tool that
*"mints a custom AI agent harness from any repo"*, runnable on **9 hosts**
(claude-code, codex, pi-dev, hermes, openclaw, rvm, copilot, opencode,
github-actions) over a wasm-primary / NAPI-RS-fallback **kernel**, with a
**cost-optimal model router** (`@metaharness/router`, the productized DRACO Phase-2
k-NN finding) and ed25519/SLSA/SBOM provenance baked in. Crucially, MetaHarness
**already ships a `vertical:ruview` template** in its template list. That template
is generic scaffolding; it is not wired to RuView's actual tools, agents, or the
"prove everything" guardrails.
The gap: **there is no single, host-portable, provenance-signed entry point that
gives any user an AI agent that actually knows how to operate RuView.** A user
should be able to run one command —
```bash
npx ruview
```
— in an empty directory (or alongside an ESP32) and get an agent harness that can
onboard them, configure firmware, drive a live capture, train a room model, and
**refuse to overstate accuracy** — on whichever coding host they already use.
## Decision
**Mint a first-class RuView agent harness from this repo using MetaHarness, harden
its `vertical:ruview` template into a RuView-specific harness with a real MCP tool
surface and the project's honesty guardrails, and publish it as `npx ruview`.**
`npx ruview` is *not* a new runtime. It is a **thin, versioned distribution** of a
MetaHarness harness: the kernel + host adapters + a RuView "genome" (skills, agents,
MCP tools, guardrails) generated from and pinned against this monorepo. The harness
is the product; `npx ruview` is the front door.
### Why mint-from-repo instead of hand-writing a harness
MetaHarness's value here is exactly the work we would otherwise hand-roll across 9
hosts: host-specific config (`.claude/settings.json` MCP + hooks for claude-code,
the codex/copilot/opencode equivalents), the kernel that abstracts wasm-vs-native,
the cost router, and the provenance chain. We write the **RuView knowledge once** as
host-neutral genome assets; MetaHarness projects them onto each host adapter. This
also keeps the harness regenerable: when the CLI or an ADR changes, re-mint and
re-pin rather than maintaining 9 divergent copies.
### What the harness contains (the RuView genome)
1. **Skills / playbooks** (host-neutral markdown, projected to each host's skill
format):
- `onboard` — zero-to-sensing path picker (Docker demo / repo build / live
ESP32), the physics caveats, the hardware table. Port of
`ruview-onboarding-guide`.
- `provision-node` — ESP-IDF v5.4 Windows-subprocess build/flash/provision flow
(the exact MSYSTEM-stripped invocation from `CLAUDE.local.md`), firmware
variant selection (8MB display / 4MB no-display / C6), NVS + WiFi + channel /
MAC-filter overrides (ADR-060).
- `calibrate-room``baseline → enroll → extract → train` via the
`wifi-densepose` CLI (`calibrate`/`calibrate-serve`/`enroll`/`train-room`/
`room-watch`, ADR-151).
- `train-pose` — camera-supervised + camera-free training, the MEASURED-vs-CLAIMED
discipline, the mean-pose baseline check (ADR-079, ADR-152, ADR-181).
- `verify` — run the witness bundle + Python proof (`verify.py` → VERDICT: PASS),
ADR-028.
- Ports of `ruview-config-engineer` and `ruview-training-engineer`.
2. **MCP tool surface** (`@metaharness/kernel`-hosted MCP server, one schema per
capability — see "MCP tools" below). This is what makes the harness *operate*
RuView, not just talk about it.
3. **Guardrails** (the differentiator): the harness's system prompt and a
pre-output hook enforce the "prove everything" rule — accuracy numbers must be
tagged MEASURED (with a reproducer) or CLAIMED; the agent must run the mean-pose
baseline before quoting PCK; firmware fixes are never presented as
hardware-validated without a real boot log (the exact discipline this session
followed for `v0.8.1-esp32`).
4. **Host adapters** — claude-code first (P1), then codex / opencode / copilot /
pi-dev / hermes / rvm / github-actions (P3+), each via the published
`@metaharness/host-*` package.
5. **Router**`@metaharness/router` routes each step to the cheapest adequate
model (e.g. a var-rename or a log-grep → Haiku; calibration-math reasoning or a
security review → Sonnet/Opus), mirroring the repo's 3-tier routing (ADR-026).
### MCP tools (the operational surface)
| Tool | Wraps | Purpose |
|------|-------|---------|
| `ruview.onboard` | docs + agent | Pick a setup path, print the next concrete command |
| `ruview.node.flash` | ESP-IDF subprocess (ADR `CLAUDE.local.md`) | Build + flash a firmware variant to a COM port |
| `ruview.node.provision` | `provision.py` | Set SSID/password/target-ip/channel/MAC-filter over serial |
| `ruview.node.monitor` | pyserial | Stream boot log; assert CSI is flowing (MGMT+DATA) |
| `ruview.server.up` | sensing-server | Start the Axum sensing-server (`:3000`/`:5005`/`:8765`) |
| `ruview.calibrate` | `wifi-densepose calibrate`/`enroll`/`train-room` | Run the ADR-151 room pipeline |
| `ruview.room.watch` | `wifi-densepose room-watch` | Live presence/vitals from a trained room |
| `ruview.verify` | `scripts/generate-witness-bundle.sh` + `verify.py` | Produce/verify the witness bundle (must be N/N PASS) |
| `ruview.claim.check` | static lint | Scan output for untagged accuracy claims; flag MEASURED-vs-CLAIMED |
Each tool returns structured JSON and is fail-closed: a tool that cannot prove its
result (e.g. `ruview.node.monitor` sees no CSI callbacks) returns an honest negative,
never a fabricated success — consistent with the RuField `map_privacy` fail-closed
posture (ADR-262 §3.3).
### The mint + pin flow (how the harness is produced)
```bash
# P1 — mint from this repo, claude-code host, RuView vertical
npx metaharness ruview --template vertical:ruview --host claude-code \
--from-existing . --description "RuView WiFi-sensing operator agent" \
--target ./harness/ruview
# readiness + fit/cost/safety scorecards (ADR-041) — gate before publish
npx metaharness genome . # 7-section repo readiness
npx metaharness score . --json # fit / cost / safety
npx metaharness analyze . # recommended harness plan (no-exec)
```
The minted harness is committed under `harness/ruview/` and **pinned** (kernel +
host-adapter + router versions locked) so `npx ruview` is reproducible. Re-minting on
a CLI/ADR change is a reviewed PR, not an implicit regeneration.
### Distribution: `npx ruview`
A small published package whose `bin` boots the pinned harness via the kernel:
- **Preferred name:** `ruview` (currently **free** on npm — verified 2026-06-17).
- **Risk:** npm's typosquat filter may reject `ruview` as too close to `review` /
`preview` (this session hit exactly that on `ruvn``levn`/`raven` and
`worldgraph``world-graph`). **Fallback:** publish scoped `@ruvnet/ruview` (also
free) and/or `npx ruvnet/ruview` straight from GitHub. Decide at publish time;
do not unpublish to rename (the 24-h name-lock lesson from `worldgraphs`).
- `bin: { "ruview": "bin/cli.js" }` — note **`bin/cli.js`, not `./bin/cli.js`** (npm
strips the `./` form; this broke `ruvn@0.1.0` this session).
- `npx ruview` with no args → `onboard` skill (interactive path picker).
`npx ruview <skill> [...]` → run a specific skill. `npx ruview --host codex`
install the harness into an existing repo for that host.
## Architecture
```
npx ruview (thin bin — boots the pinned harness)
@metaharness/kernel (wasm primary · NAPI-RS native fallback)
├── host adapter ── claude-code | codex | opencode | copilot | pi-dev | hermes | rvm | github-actions
├── @metaharness/router (k-NN cost-optimal model routing — DRACO P2 / ADR-026)
└── RuView genome (pinned)
├── skills onboard · provision-node · calibrate-room · train-pose · verify
├── mcp tools ruview.node.* · ruview.calibrate · ruview.room.watch · ruview.verify · ruview.claim.check
└── guardrails MEASURED-vs-CLAIMED · mean-pose baseline · no-unvalidated-firmware-claims
RuView assets (the real system the agent drives)
├── wifi-densepose CLI calibrate / enroll / train-room / room-watch
├── sensing-server :3000 / :5005 / :8765
├── ESP-IDF subprocess build / flash / provision / monitor (COM8/COM9/COM12)
└── witness bundle + verify.py
```
Provenance: the harness ships an **ed25519 witness + SBOM (SPDX) + SLSA** chain
(MetaHarness already does this for minted harnesses), so a recipient can verify the
RuView harness was built from a specific monorepo commit — the agentic analogue of
the firmware witness bundle (ADR-028).
## Phases
- **P1 — Mint & pin (claude-code).** `npx metaharness ruview --template
vertical:ruview --from-existing . --host claude-code`. Port the three `ruview-*`
subagents into host-neutral genome skills. Commit under `harness/ruview/`, pin
versions. Acceptance: `npx metaharness score .` ≥ threshold; the harness can run
`onboard` and `verify` end-to-end locally.
- **P2 — MCP tool surface.** Implement the `ruview.*` MCP tools over the kernel
(start with `onboard`, `verify`, `claim.check`, `node.monitor` — the read-only /
proving tools), then the mutating ones (`node.flash`, `provision`, `calibrate`).
Acceptance: `ruview.verify` returns the witness bundle PASS as structured JSON;
`ruview.claim.check` flags a seeded untagged "100% accuracy" string.
- **P3 — Publish `npx ruview` + multi-host.** Publish the bin package (name decision
per Distribution). Add codex / opencode / copilot / pi-dev / hermes / rvm /
github-actions adapters. Acceptance: `npx ruview` cold-starts on ≥3 hosts and runs
`onboard`; provenance verifies.
- **P4 — Router + guardrail hardening.** Wire `@metaharness/router`; calibrate the
3-tier routing on a RuView task set. Make the MEASURED-vs-CLAIMED guardrail a hard
pre-output gate. Acceptance: a benchmark of RuView tasks shows cost reduction vs
all-Opus with no quality regression; the guardrail blocks an untagged accuracy
claim in a red-team prompt.
## Consequences
**Positive**
- One reproducible, signed entry point (`npx ruview`) that operates RuView on the
host the user already has — onboarding goes from "clone a 15-crate monorepo" to a
single `npx`.
- The "prove everything" ethos becomes **executable**, not just documentation: the
harness *enforces* MEASURED-vs-CLAIMED and the mean-pose baseline.
- Knowledge written once (host-neutral genome) instead of 9× per host; regenerable
from the repo as the system evolves.
- Dogfoods MetaHarness on a hard real vertical, surfacing bugs back to
`agent-harness-generator` (this session already filed #9#13 there).
**Negative / risks**
- **Drift:** a pinned harness goes stale as the CLI/ADRs move; mitigated by a
re-mint-on-change PR ritual and a CI check that the genome's referenced
CLI flags still exist.
- **Surface area:** mutating MCP tools (`node.flash`, `provision`) touch hardware and
the network — must be permission-gated and fail-closed; the firmware-flash tool
must never claim hardware validation without a captured boot log.
- **Name/typosquat:** `ruview` may be rejected at publish; scoped fallback decided in
P3. Do not unpublish-to-rename.
- **Host parity:** not all 9 hosts support MCP + hooks equally; the guardrail gate
may degrade to advisory on weaker hosts — must be disclosed in the badge, not
hidden (same honesty principle as ADR-181's backend badge).
- **Windows-coupled tooling:** the ESP-IDF flow is Windows-subprocess-specific
today; the `node.*` tools are gated to that environment until a cross-platform
path exists.
## Alternatives considered
1. **Keep the `ruview-*` subagents repo-local (status quo).** Zero new surface, but
stays Claude-Code-only and clone-gated; no portable front door. Rejected — it's
the gap this ADR exists to close.
2. **Hand-write a bespoke `npx ruview` harness (no MetaHarness).** Full control, but
re-implements the kernel, 9 host adapters, the router, and the provenance chain
we already ship — months of duplicated work and 9 divergent configs to maintain.
Rejected.
3. **Use the generic `vertical:ruview` template as-is.** It's scaffolding with no
real tools or guardrails — it would *talk about* RuView without being able to
*operate* it or enforce honesty. Rejected as insufficient; P2 is precisely the
hardening that makes it real.
4. **Ship only an MCP server (no harness/host adapters).** Covers tools but not the
skills, routing, guardrails, or multi-host projection — a strictly smaller subset
of this design. Folded in as the P2 layer rather than the whole.
## Open questions
- Final published name: bare `ruview` vs scoped `@ruvnet/ruview` vs GitHub-only
`npx ruvnet/ruview` — resolve against the typosquat filter at P3.
- Does the harness bundle the `wifi-densepose` binary, shell out to a user-installed
one, or offer both? (Leaning: shell out; print install guidance if absent.)
- Where do the `node.*` hardware tools live for non-Windows users — defer, or wrap
the rvCSI runtime (ADR-095/096) which is cross-platform Rust?
- Should `ruview.verify` gate `npx ruview` self-tests in CI (harness can't publish if
the witness bundle regresses)?
- Relationship to the RuField MFS harness surface (ADR-260/262) — one harness with a
RuField skill, or a sibling `npx rufield`?
## References
- MetaHarness: `metaharness@0.1.15` (`npx metaharness`, templates incl.
`vertical:ruview`; hosts: claude-code/codex/pi-dev/hermes/openclaw/rvm/copilot/
opencode/github-actions), `@metaharness/kernel`, `@metaharness/router`,
`@metaharness/host-*`, repo `github.com/ruvnet/agent-harness-generator`.
- RuView subagents: `ruview-onboarding-guide`, `ruview-config-engineer`,
`ruview-training-engineer` (`.claude/agents/`).
- ADR-026 (3-tier model routing), ADR-028 (witness verification), ADR-041
(MetaHarness scorecards), ADR-060 (channel / MAC-filter overrides), ADR-079
(camera ground-truth training), ADR-095/096 (rvCSI runtime), ADR-151 (per-room
calibration), ADR-152/181 (WiFlow / browser pose), ADR-260/262 (RuField bridge).
@@ -0,0 +1,98 @@
# ADR-183: Onboard LED as a 40 Hz Gamma Stimulus, Colour-Mapped from Live CSI via `ruv-neural-viz`
| Field | Value |
|-------|-------|
| **Status** | Accepted — implemented & hardware-confirmed on ESP32-S3 N16R8 (COM8) |
| **Date** | 2026-06-17 |
| **Deciders** | ruv |
| **Codename** | **GAMMA-VIZ** |
| **Builds on** | `ruv-neural-viz::ColorMap` (now `no_std` — ruvnet/ruv-neural#3 / RuView#1126), the ESP32 edge `motion_energy` metric (`edge_processing.c`), PR #962 (WS2812 on GPIO 48) |
## Context
Two threads converged. (1) `ruv-neural-viz::ColorMap` — the viridis/cool-warm
palette the rUv-Neural stack uses to render brain-topology graphs — was `std`-only,
so it couldn't run on the ESP32. (2) The onboard WS2812 on the S3 CSI node was dead
weight: the firmware only cleared it on boot (and on the wrong pin for N16R8 — GPIO
38 vs the actual 48, see #962).
The ask: make the LED do something real and honest, using the project's own visual
capability — not a decorative blink. The natural fit is a **40 Hz gamma stimulus**
(the GENUS gamma-entrainment frequency from Alzheimer's light-therapy research)
whose **colour is driven by live sensed motion**, so the node's front panel is both
a known bio-stimulus waveform and a truthful readout of what the CSI is detecting.
## Decision
### Part A — make `ColorMap` `no_std`
`colormap.rs` is self-contained (no cross-crate deps), so expose it on `no_std`
targets. The only blockers were two `std`-only `f64` ops:
- `f64::round` / `f64::abs` → replaced with `core`+`alloc`-safe helpers `fround`
(round via `f64 as i64` truncation — a `core` cast, no `libm`) and `fabs`.
- `Vec`/`String`/`format!` → from `alloc`.
The graph-bound modules (`animation`/`ascii`/`export`/`layout`) and their heavy deps
move behind a default `std` feature; `--no-default-features` builds the crate `no_std`
and exposes only `colormap`. Output is **byte-identical** (8/8 colormap tests pass with
the same RGB values), so this is a pure portability change.
### Part B — the LED stimulus (firmware)
`firmware/esp32-csi-node/main/main.c`, on boot:
- WS2812 on **GPIO 48** (N16R8 / DevKitC-1 v1.1; GPIO 8 on C6).
- An `esp_timer` periodic at **12 500 µs toggles a square wave → 40 Hz, 50 % duty**
(full-on / full-off — a *perceptible* gamma flicker, not a colour drift).
- **ON-phase colour = live CSI motion.** Each ON phase reads `edge_get_vitals().motion_energy`,
normalises it (`/ LED_MOTION_FULLSCALE`, clamped `[0,1]`), and indexes a **60-step
viridis LUT generated from `ColorMap::viridis().map()`** — still = dark purple,
strong motion = yellow.
The LUT is baked from the real crate (Part A makes the same `ColorMap` embeddable
for a future direct FFI path once the ESP Rust toolchain is in CI). The colours are
therefore provably `ruv-neural-viz`'s, and the motion is provably real.
## Honesty (what it is and is not)
- **40 Hz is a real square-wave stimulus** (12.5 ms on / 12.5 ms off), not a label on
a colour sweep. It is *not* tied to any measured 40 Hz brain rhythm — it is an
*output* stimulus at the gamma frequency, not a readout of neural gamma.
- **Colour is a real CSI readout** — `motion_energy` is the on-device phase-variance
motion metric the node already computes; no fabrication. At rest the LED sits at the
purple (low) end and flickers there.
- No therapeutic claim is made. 40 Hz GENUS entrainment is cited as the *origin of the
frequency choice*, not as a validated medical effect of this device.
## Consequences
**Positive**
- The LED is now an honest front-panel: gamma-frequency flicker + a live motion readout.
- `ColorMap` is embeddable (`no_std`), unblocking on-device use of the rUv-Neural
palette beyond this LED.
- Confirms #962's GPIO-48 fix visually (the LED lights on N16R8).
**Negative / risks**
- Changes the *default* firmware behaviour: the onboard LED animates instead of staying
off. Now **gated by `CONFIG_LED_GAMMA_VIZ`** (default `y`); set it `n` for a dark,
lower-power boot (the LED is just cleared) — no source change needed.
- A 40 Hz flicker can be an issue for photosensitive users; document on the enclosure
and disable `CONFIG_LED_GAMMA_VIZ` in those deployments.
- The saturation point is now `CONFIG_LED_MOTION_FULLSCALE_MILLI` (default 250 = 0.25),
operator-tunable; still not auto-calibrated per-environment.
- The colour uses a baked LUT, not the live Rust `ColorMap` (FFI path deferred — needs
the ESP Rust/xtensa toolchain, not yet in CI).
## Validation
- `ruv-neural-viz`: `cargo build` (std) ✓, `cargo test colormap` 8/8 ✓ (identical RGB),
`cargo build --no-default-features` compiles `no_std` ✓.
- Firmware: built (1.13 MB), flashed to ESP32-S3 N16R8 (COM8). Boot log:
`Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO 48`;
CSI continues (2738 pps), `motion=0.00` at rest → purple flicker as designed.
- Full on-device (xtensa) Rust build of `ColorMap` not run — ESP Rust toolchain absent.
## References
- ruvnet/ruv-neural#3 (ColorMap no_std), RuView#1126 (submodule bump), #962 (GPIO 48).
- Singer/Tsai GENUS 40 Hz gamma entrainment (origin of the frequency, not a device claim).
@@ -0,0 +1,191 @@
# ADR-263: `@ruvnet/ruview` npm Harness — Deep Review + Optimization Strategy
| Field | Value |
|-------|-------|
| **Status** | Accepted — **implemented** (O1O9, `@ruvnet/ruview@0.2.0`): fail-closed `claim-check`, async MCP dispatch (ping answered mid-`verify`, pinned by e2e test), zero-dependency install, bounded output tails, argv-passed monitor port, package.json-sourced version, prepack skill sync, memoized `which()`, underscore-canonical tools with dotted aliases, word-boundary guardrail matching. 30/30 tests (MEASURED, `node --test test/*.test.mjs`); CI gate in ADR-265's `npm-packages.yml` |
| **Date** | 2026-07-02 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-NPM-REVIEW-1** |
| **Supersedes / amends** | none (records review of the ADR-182 P1+P2 artifact; feeds ADR-265 distribution strategy) |
## Context
ADR-182 minted and published **`@ruvnet/ruview@0.1.0`** (`harness/ruview/`) — the
`npx ruview` operator harness: a dependency-free ESM CLI + minimal MCP stdio server
exposing six `ruview.*` tools (onboard / claim_check / verify / node_monitor /
calibrate / node_flash), five skill playbooks, and the executable
MEASURED-vs-CLAIMED guardrail (`src/guardrails.js`). The package is live on npm
(0.1.0, 49.5 kB unpacked / 21 files — MEASURED, `npm view @ruvnet/ruview` +
`npm pack --dry-run`) and is the recommended MCP registration path
(`npx -y @ruvnet/ruview mcp start` in the bundled `.claude/settings.json`).
This ADR is the first dedicated deep review of that npm artifact: correctness,
fail-open/fail-closed posture, performance (cold start + request handling),
packaging hygiene, and security of the subprocess surface. All 17 bundled tests
pass on Node 22 (MEASURED, `node --test test/*.test.mjs`, 17/17, ~108 ms).
## Findings
Severity reflects impact on the package's stated contract: *fail-closed operator
tools + an honesty guardrail that must never fail open*.
### F1 (HIGH, fail-open): `claim-check` passes silently on empty input
`bin/cli.js` `claim-check` with **neither `--text` nor `--file`** sends
`text: undefined``claimCheck(String(args.text ?? ''))``''``ok: true`,
**exit 0**. A CI hook wired as `npx ruview claim-check --text "$BODY"` where
`$BODY` expands empty therefore reports PASS. This is the single tool whose whole
purpose is to fail closed; empty input must be an error, not a pass.
Reproducer: `node bin/cli.js claim-check``{"ok": true}`, exit 0.
### F2 (HIGH, head-of-line blocking): MCP server is fully synchronous
`src/mcp-server.js` dispatches `tools/call` inside the readline `line` handler,
and every heavyweight handler in `src/tools.js` uses **`spawnSync`**
(`ruview.verify` up to 180 s, `ruview.calibrate` up to 300600 s,
`ruview.node_monitor` up to `seconds+10`). While one call runs, the event loop is
blocked: `ping`, `tools/list`, and concurrent `tools/call` requests are not even
read from stdin. Hosts that health-check with `ping` during a long `calibrate`
will conclude the server is dead and kill it mid-run.
### F3 (MEDIUM, cold start): optionalDependencies triple the `npx` install for a path that never uses them
`package.json` declares `optionalDependencies` on `@metaharness/kernel` and
`@metaharness/host-claude-code`. npm installs optional deps **by default**, so
every cold `npx -y @ruvnet/ruview mcp start` fetches 3 extra packages (kernel +
host + transitive `@ruvector/emergent-time`). MEASURED (npm 10.9.7, this
container): default install = **4 packages, 620 kB, 71 files**; with
`--omit=optional` = **1 package, 172 kB, 22 files**. The operator-tool and MCP
paths never import these — only `doctor`/`install` do, and both already
dynamic-import inside `try/catch` and degrade gracefully when absent
(`kernel/host: not installed (ok…)`). The optional deps buy nothing on the hot
path and cost 3 registry round-trips + ~450 kB on every cold start.
### F4 (MEDIUM, silent truncation): `spawnSync` default `maxBuffer` (1 MiB)
`run()` in `src/tools.js` never sets `maxBuffer`. `cargo run -p
wifi-densepose-cli` (the `calibrate` fallback path) and a chatty `verify.py` can
exceed 1 MiB of stdout, at which point the child is killed with `ENOBUFS` and the
tool reports a spawn error that looks like a proof/calibration failure. The
handlers only ever consume the last 8 kB/1.5 kB; buffering should be bounded but
generous (e.g. `maxBuffer: 16 MiB`) or streamed with a tail ring.
### F5 (MEDIUM, injection surface): `node_monitor` interpolates the port into Python source
The handler builds a `python -c` script by string interpolation:
`` `ser=serial.Serial(${JSON.stringify(port)},115200,…)` `` and
`` `while time.time()-t<${dur}:` ``. `JSON.stringify` produces a *JavaScript*
string literal; Python string-literal semantics differ at the edges (`\uXXXX` is
shared, but e.g. JS emits raw U+2028/U+2029 unescaped pre-ES2019 rules aside, and
any future non-JSON-safe field added the same way would be executable). `port`
arrives from the MCP caller (an agent), so this is an agent-controlled string
concatenated into an interpreter invocation. `dur` is `Number()`-guarded; `port`
should be passed out-of-band (`sys.argv`/env), never spliced into source.
### F6 (LOW, drift): server version hardcoded
`SERVER_INFO = { name: 'ruview', version: '0.1.0' }` in `src/mcp-server.js`
duplicates `package.json.version` (the CLI's `--version` already reads
package.json at runtime). First release bump will drift the MCP handshake
version.
### F7 (LOW, duplication): every skill ships twice
`skills/*.md` and `.claude/skills/*/SKILL.md` are byte-identical (same sha256 in
`.harness/manifest.json`). ~8 kB of the 49.5 kB unpacked payload is duplicate
content, and — worse than size — two copies must be kept in sync by hand.
### F8 (LOW, perf + portability): `which()` is uncached and shells out
`which()` runs up to twice per tool call (`python` then `python3`), each a
blocking `spawnSync`; the POSIX branch spawns a shell (`shell: true`). Results
are stable for the process lifetime and should be memoized; the lookup can be
done dep-free with a PATH scan instead of a shell.
### F9 (LOW, interop): dot-named tools + minimal protocol surface
Tool names (`ruview.onboard`, `ruview.claim_check`, …) contain dots. MCP itself
does not restrict names, but downstream host APIs commonly enforce
`^[a-zA-Z0-9_-]{1,64}$` for tool names; hosts must then sanitize or reject.
The server also answers `resources/list` / `prompts/list` with `-32601` (it does
not advertise those capabilities, so this is spec-legal, but empty-list stubs are
cheaper than every host's error path). Protocol version is pinned to
`2024-11-05` with no negotiation fallback. None of this breaks Claude Code today;
it narrows portability, which is the harness's whole pitch (9 hosts, ADR-182).
### F10 (LOW, CI gap): the published package has zero CI
No workflow under `.github/workflows/` runs `harness/ruview` tests (checked:
no workflow references `harness/ruview`, `ruview-mcp`, or `ruview-cli`), and
`ci.yml` pins `NODE_VERSION: '18'` while the package declares
`engines.node >= 20`. Note also `node --test test/` (directory form) fails on
Node 22 while the documented glob form passes — CI should pin the working
invocation. Consolidated CI/publish strategy is ADR-265.
### F11 (MEDIUM, guardrail precision): `METRIC_TERMS` substring matching false-positives on ordinary prose
Found by dogfooding this review: `claimCheck` matches metric terms with
`lower.includes(t)`, so the two-character terms `'map'` and `'f1'` fire inside
ordinary words and labels — "source **map**s", "the **map**s can never
resolve", finding IDs like "**F1** (HIGH…)". MEASURED reproducer: running
`npx ruview claim-check --file` over this ADR and ADR-264 yields 4 and 16
medium findings respectively, the majority of which are `map`/`F1`
false positives on lines carrying no accuracy claim. A guardrail that cries
wolf trains people to ignore it — precision is part of its fail-closed
contract. Short/ambiguous terms need word-boundary matching (`\bmap\b`,
`\bf1\b`, likewise `auc`, `iou`), and section-heading label patterns
(`F\d+`, `O\d+`) should not count as metric mentions.
## Decision
Adopt the following optimization strategy, in priority order. Each item is
independently shippable; F-numbers map to findings.
- **O1 (F1):** `claim-check` with no `--text`/`--file` (or empty text after read)
exits 2 with a usage error. Add a regression test pinning exit ≠ 0.
- **O2 (F2):** make the MCP dispatch async: convert `run()`/`which()` to
promise-based `spawn`, make `tools/call` handlers `async`, and keep reading
stdin while calls run (respond to `ping`/`tools/list` concurrently; serialize
only same-tool hardware operations). Acceptance: `ping` round-trips < 50 ms
while a synthetic 30 s `calibrate` is in flight.
- **O3 (F3):** drop the two `optionalDependencies`; `doctor`/`install` already
degrade and should print the exact `npm i @metaharness/kernel
@metaharness/host-claude-code` hint on the miss path. Acceptance: cold
`npm i @ruvnet/ruview` installs exactly 1 package (MEASURED baseline above).
- **O4 (F4):** set `maxBuffer: 16 * 1024 * 1024` in `run()` (or stream + tail).
- **O5 (F5):** pass `port` to the monitor script via `sys.argv`
(`python -c script -- <port>`), never by source interpolation.
- **O6 (F6):** read the MCP `serverInfo.version` from `package.json` once at
startup (same pattern the CLI already uses).
- **O7 (F7):** make `skills/*.md` the single source and generate
`.claude/skills/*/SKILL.md` in a `prepack` script (or vice versa); manifest
hashes then pin one canonical set.
- **O8 (F8, F9):** memoize `which()`; add underscore aliases for the dot-named
tools (accept both in `tools/call`, advertise the underscore form) and add
empty `resources/list` / `prompts/list` stubs.
- **O9 (F11):** switch `METRIC_TERMS` matching to word-boundary regexes for
short terms (`map`, `f1`, `auc`, `iou`) and skip label tokens matching
`\b[FO]\d+\b`. Acceptance: `claim-check --file` over ADR-263/264/265 reports
only the genuinely tagged-or-taggable percentage lines, and the existing 17
guardrail tests still pass plus new false-positive pins ("source maps",
"F1 (HIGH)" → no finding).
Non-goals: no new runtime dependencies (the zero-dep MCP server is a feature,
not an accident — keep it), no build step, no change to the fail-closed tool
contracts.
## Consequences
- The honesty guardrail becomes fail-closed end-to-end (its current empty-input
pass is the exact failure mode the guardrail exists to prevent).
- `npx` cold start drops ~450 kB / 3 packages (MEASURED baseline in F3) with no
feature loss; `doctor` output already communicates the optional-dep story.
- Long-running `verify`/`calibrate` no longer starve the MCP channel — the
harness survives host health checks during real calibration runs.
- Two-copy skill drift becomes impossible at pack time.
- Costs: async conversion touches every handler signature in `src/tools.js`
(mechanical, ~6 handlers); alias tools add a small compatibility table.
- Verification for the implementing PR: bundled tests extended for O1/O2/O5
(target ≥ 20 tests), `npm pack --dry-run` file-count asserted, and the F3
install measurement re-run and quoted MEASURED in the PR body — which must
itself pass `npx ruview claim-check`.
@@ -0,0 +1,169 @@
# ADR-264: `@ruvnet/rvagent` MCP Server + `@ruv/ruview-cli` — Deep Review + Optimization Strategy
| Field | Value |
|-------|-------|
| **Status** | Accepted — **implemented** (O1O9, `@ruvnet/rvagent@0.2.0`): `exports` fixed (types-first, no phantom `.cjs`), map-free tarball (127,704 B unpacked / 46 files / 0 maps — MEASURED, `npm pack --dry-run`, from 188 kB), Streamable HTTP **wired** behind `RVAGENT_HTTP_PORT` with per-session transports + 1 MiB body cap + port-aware origin gate, underscore tool names with dotted router aliases, single Zod validation gate with generated JSON Schemas, fd-leak fixed + persisted job records + bounded log tails, probing `detectCogBinary`, package.json-sourced version, `ruview-cli` bin renamed. 99/99 jest tests (MEASURED); both transports smoke-tested live |
| **Date** | 2026-07-02 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-NPM-REVIEW-2** |
| **Supersedes / amends** | none (reviews the ADR-104/ADR-124 artifacts; feeds ADR-265 distribution strategy) |
## Context
Two TypeScript npm packages expose RuView sensing to agents and shells:
- **`@ruvnet/rvagent@0.1.0`** (`tools/ruview-mcp/`) — SENSE-BRIDGE, the MCP
server over the sensing-server HTTP API + cog binaries: 12 tools
(csi/pose/count/registry/train/job + ADR-124 BFLD/presence/vitals). Published
(188 kB unpacked — MEASURED, `npm view @ruvnet/rvagent`). Deps:
`@modelcontextprotocol/sdk` + `zod`.
- **`@ruv/ruview-cli@0.0.1`** (`tools/ruview-cli/`) — `private: true` yargs CLI
mirroring the same capabilities; intentionally duplicates `http.ts`/`cog.ts`/
`config.ts` (~150 lines) to stay standalone.
This ADR records a deep review of both: packaging correctness (verified against
the **published** tarball, not just the source tree), protocol/interop, resource
lifecycle, and the honesty of the package's own self-description — the same
MEASURED-vs-CLAIMED bar the project applies to accuracy numbers.
## Findings
### F1 (HIGH, broken export): `require` condition points at a file that does not exist
`package.json` `exports["."].require = "./dist/index.cjs"`, but the build is
plain `tsc` (ESM only) and **the published 0.1.0 tarball contains no
`index.cjs`** (verified by listing the registry tarball). Any CJS consumer doing
`require('@ruvnet/rvagent')` resolves to a nonexistent file →
`ERR_MODULE_NOT_FOUND`. Additionally the `types` condition is listed **after**
`import`/`require`; TypeScript requires `types` first or it may be ignored under
`moduleResolution: bundler/node16`.
### F2 (MEDIUM, tarball bloat): a third of the published package is dead source maps
The 0.1.0 tarball ships **44 `.map` files = 62,698 B** against 78,209 B of
actual `.js` (MEASURED, extracted registry tarball). `src/` is not published, so
every `sourceMappingURL` points at `../src/*.ts` that consumers do not have —
the maps can never resolve. Also `files` lists `CHANGELOG.md`, which does not
exist in `tools/ruview-mcp/` (npm silently skips it), so the advertised file set
is partly fictional.
### F3 (MEDIUM, honesty): the package description claims a transport it does not start
The description reads "**dual-transport MCP server (stdio + Streamable HTTP)**",
but `main()` in `src/index.ts` wires **stdio only**. `http-transport.ts` is a
complete, tested scaffold that nothing imports at runtime — there is no flag,
env var, or subcommand that starts it. By this project's own rule this is a
CLAIMED capability presented as shipped. Either wire it (`--http` /
`RVAGENT_HTTP_PORT` gate) or de-claim the description until it is.
### F4 (MEDIUM, interop + inconsistency): two tool-naming conventions, one of them dot-based
Six tools use `ruview_snake_case`; six (ADR-124 additions) use
`ruview.dotted.names`. Same interop caveat as ADR-263 F9 (host tool-name
regexes commonly `^[a-zA-Z0-9_-]{1,64}$`), plus the split convention makes the
tool surface look like two products. Standardize on underscores and accept the
dotted forms as aliases for one deprecation cycle.
### F5 (MEDIUM, double work + drift): every tool input is validated twice from two hand-maintained schemas
`CallToolRequestSchema` handler runs `TOOL_INPUT_SCHEMAS[name].safeParse(args)`,
then each tool handler runs its own `schema.parse(args)` again — two full Zod
passes per call. Separately, the `inputSchema` JSON advertised via `tools/list`
is **hand-written** and duplicates the Zod schema field-by-field (defaults,
min/max, descriptions) — schema drift between what is advertised and what is
enforced is a matter of time. Parse once at the gate, pass the typed result to
handlers, and generate the advertised JSON Schema from the Zod source
(`zod-to-json-schema` at build time, or Zod 4's native `z.toJSONSchema` when the
SDK's peer range allows).
### F6 (MEDIUM, resource lifecycle): `train_count` leaks 2 fds per job; job registry is process-local
`trainCount` opens `logFdOut`/`logFdErr` with `openSync` and never closes them
in the parent — the spawned cargo child inherits duplicates, but the parent's
descriptors stay open for the MCP server's lifetime: 2 leaked fds per training
job. `jobRegistry` is an in-memory `Map`, so `ruview_job_status` after a server
restart reports "not found" for a training run that is still burning GPU (the
source comments acknowledge this; the fix — persist `~/.ruview/jobs/<id>.json`,
already the documented layout — is small). Also `jobStatus` re-`import`s
`node:fs` on every poll and reads the entire log to return 20 lines.
### F7 (MEDIUM, security/robustness of the HTTP scaffold): unbounded body + one shared session transport
`http-transport.ts` buffers the request body with no size cap (memory DoS the
moment it is wired to a socket), reuses a **single**
`StreamableHTTPServerTransport` with `sessionIdGenerator` for all clients (the
SDK's stateful mode expects one transport per session — a second client's
`initialize` collides), and the Origin allowlist is exact-match
(`http://localhost` will not match a real browser origin `http://localhost:5173`).
Must be fixed **before** F3 wires it in; bearer-token + 127.0.0.1 defaults are
already right.
### F8 (LOW, dead/misleading code): `detectCogBinary` always returns the bare name
It builds a 4-candidate appliance-path array and then returns
`candidates[candidates.length - 1]` — i.e. always `name` — without checking
existence. The candidates are dead weight that reads as if path detection
happens. Either probe with `existsSync` or delete the array.
### F9 (LOW, drift + hygiene): hardcoded versions, unused/mismatched devDeps, bin-name collision
`PACKAGE_VERSION = "0.1.0"` (index.ts) duplicates package.json;
`@types/express` is unused (`http-transport` uses `node:http`); `@types/jest@30`
against `jest@29`; `ruview-cli` hardcodes `.version("0.0.1")`. And
`@ruv/ruview-cli` claims the **`ruview`** bin name, which collides with
`@ruvnet/ruview`'s bin (ADR-182) if both are ever installed globally —
ADR-263/265 give the `ruview` name to the harness; the CLI must rename or fold.
## Decision
- **O1 (F1):** fix `exports`: drop the `require` condition (ESM-only is fine for
a bin-first package) or add a real CJS build; put `types` first. Add a CI
smoke test that does `npm pack` + `node -e "import('<tarball install>')"`.
- **O2 (F2):** publish without maps: `declarationMap: false`, `sourceMap: false`
in a `tsconfig.build.json` used by `prepack` (or add `!dist/**/*.map` to
`files`). Remove the phantom `CHANGELOG.md` entry or create the file.
Acceptance: unpacked size ≤ ~125 kB (from 188 kB — MEASURED, `npm pack --dry-run`).
- **O3 (F3, F7):** wire the HTTP transport behind an explicit opt-in
(`RVAGENT_HTTP_PORT` or `--http`), after F7 fixes: per-session transport map
keyed by `mcp-session-id`, 1 MiB body cap, origin matching that honors ports
(compare `URL.origin` prefixes or document exact origins). Until then, change
the description to "stdio MCP server (Streamable HTTP scaffold, unwired)".
- **O4 (F4):** rename dotted tools to underscore (`ruview_bfld_last_scan`, …),
keep dotted aliases in the call router for one release, note it in the README.
- **O5 (F5):** single validation gate: the registry maps name → Zod schema →
typed handler; advertised `inputSchema` generated from Zod at build time.
- **O6 (F6):** close parent fds after spawn (`closeSync` post-`spawn` — the
child holds its own copies), persist job records to
`<jobsDir>/<id>.json`, and read log tails with a bounded read.
- **O7 (F8):** make `detectCogBinary` actually probe (`existsSync` over the
candidates) — it is the entire reason the function exists.
- **O8 (F9):** single-source versions from package.json; drop `@types/express`;
align `@types/jest` with jest 29 (or move to `node:test` like the harness and
drop the jest toolchain entirely — it is the heaviest devDep in both
packages).
- **O9 (F9, scope):** fold `@ruv/ruview-cli` into `rvagent` as a second bin
(`rvagent-cli`) sharing `http/cog/config`, or keep it private-forever and say
so in its README. Its `ruview` bin name is surrendered to `@ruvnet/ruview`
either way.
## Consequences
- CJS consumers stop hitting a guaranteed-broken export path (F1 is the only
finding that fails for every consumer of that entry point deterministically).
- The published artifact shrinks ~33% (MEASURED, F2 tarball listing: 62,698 B
of maps in a 188 kB unpacked payload) and stops advertising files/transports
it does not contain — the package description itself passes the project's
claim-check bar.
- One schema source ends advertised-vs-enforced drift and halves per-call
validation cost; naming unification makes the 12-tool surface read as one
product and survive strict host tool-name validation.
- Long-lived MCP servers stop accumulating fds during training campaigns, and
job polling survives restarts.
- Costs: the alias cycle (O4) briefly doubles the advertised tool count unless
aliases are router-only (recommended: router-only, advertise underscore names
exclusively); folding the CLI (O9) retires a package name already in use in
scripts, so it needs a deprecation note.
- Verification for the implementing PR: `npm pack --dry-run` asserted file list
(no `.map`, no phantom entries), pack-size budget in CI (ADR-265), jest/`node
--test` suite green, and a tarball-install smoke test for both `import` and
the `rvagent` bin.
@@ -0,0 +1,124 @@
# ADR-265: RuView npm Distribution Strategy — CI Gate, Provenance, Version Single-Sourcing, Namespace
| Field | Value |
|-------|-------|
| **Status** | Accepted — **D1D4 implemented**: `.github/workflows/npm-packages.yml` (matrix gate: tests, version-literal grep, pack-content/size gate, tarball-install smoke test, README claim-check), `.github/workflows/ruview-npm-release.yml` (publish-from-CI with `npm publish --provenance`), version single-sourcing (all three packages read package.json), `ruview` bin owned by `@ruvnet/ruview` (`@ruv/ruview-cli` bin renamed `ruview-cli`), `ci.yml` NODE_VERSION 18→20. D5 (no workspace) stands as recorded |
| **Date** | 2026-07-02 |
| **Deciders** | ruv |
| **Codename** | **RUVIEW-NPM-DIST** |
| **Supersedes / amends** | none (cross-cutting layer above ADR-263 and ADR-264; complements ADR-182 P3/P4) |
## Context
The monorepo now ships (or stages) **three Node packages** with no shared
distribution engineering:
| Package | Dir | Published | Bin(s) | Tests in CI |
|---------|-----|-----------|--------|-------------|
| `@ruvnet/ruview` | `harness/ruview/` | 0.1.0 (live) | `ruview` | **none** |
| `@ruvnet/rvagent` | `tools/ruview-mcp/` | 0.1.0 (live) | `rvagent`, `ruview-mcp` | **none** |
| `@ruv/ruview-cli` | `tools/ruview-cli/` | private | `ruview` (collides) | **none** |
Cross-cutting facts established during the ADR-263/264 reviews:
- **Zero CI coverage.** No workflow under `.github/workflows/` references any of
the three directories. Two of the packages are *live on the registry* and were
published from a laptop state CI never saw. Meanwhile the Rust side has a
1,031+-test gate and a witness-bundle culture (ADR-028) — the npm surface is
the only shipped artifact class with no verification gate at all.
- **`ci.yml` pins `NODE_VERSION: '18'`** while all three packages declare
`engines.node >= 20`.
- **Version triplication.** Each package hardcodes its version in source at
least once beyond package.json (harness `SERVER_INFO`, rvagent
`PACKAGE_VERSION`, cli `.version("0.0.1")`).
- **Bin-name collision.** Two packages claim the `ruview` bin.
- **No provenance.** Neither published package carries npm provenance
attestations, in a project whose differentiator is signed, reproducible
evidence (ADR-028 witness bundles, ADR-182 P4 ed25519/SLSA design).
- **No pack-content gate.** ADR-264 F1/F2 (broken `require` target, 33% dead map weight — MEASURED, tarball listing — and a phantom
`CHANGELOG.md` in `files`) are exactly the defect class an
`npm pack --dry-run` assertion catches in seconds.
## Decision
Adopt one distribution layer for all Node packages. Per-package code fixes live
in ADR-263/264; this ADR fixes the machinery around them.
### D1 — One `npm-packages.yml` CI workflow (the gate)
Matrix over `[harness/ruview, tools/ruview-mcp, tools/ruview-cli]` ×
Node `[20, 22]`:
1. `npm ci` where a lockfile is committed (the TS packages); the harness
installs with `npm install` — repo policy gitignores lockfiles under
`harness/`, and the package is dependency-free after ADR-263 O3 so there is
nothing to pin.
2. `npm test` (harness: `node --test test/*.test.mjs` — pin the glob form,
the directory form fails on Node 22; TS packages: build + jest or `node:test`
per ADR-264 O8).
3. **Pack gate:** `npm pack --dry-run --json` asserted against a checked-in
expected file list + a max unpacked-size budget per package (harness ≤ 60 kB;
rvagent ≤ 130 kB post ADR-264 O2). Any new/missing/renamed shipped file is a
reviewed diff, not a surprise.
4. **Tarball smoke test:** install the packed tarball into a temp dir; run
`ruview --version`, `ruview doctor`, `rvagent` `--help`-equivalent, and a
Node `import()` of each declared export condition — this is the test that
would have caught ADR-264 F1 (`require` → nonexistent `dist/index.cjs`).
5. Bump `ci.yml` `NODE_VERSION` to `'20'` (independent of the matrix above).
### D2 — Publish only from CI, with provenance
Manual `npm publish` from laptops stops. A tag-triggered workflow
(`ruview-npm-release.yml`, mirroring the firmware release discipline) runs the
D1 gate, then `npm publish --provenance --access public` under the GitHub OIDC
token. Consequence: every published version is attested to a public commit +
workflow run — the npm-side analogue of the ADR-028 witness bundle. The
`prepublishOnly` script in each package runs the pack gate locally as a
belt-and-braces (publishing outside CI fails loudly, not silently).
### D3 — Version single-sourcing
Rule: **package.json is the only place a version string lives.** Runtime code
reads it (`createRequire(import.meta.url)('./package.json').version` or a
build-time define for the TS packages). CI greps for `\d+\.\d+\.\d+` literals in
`src/` of each package and fails on match (allowlist: test fixtures). This
retires ADR-263 F6 and ADR-264 F9 permanently instead of per-incident.
### D4 — Namespace and bin ownership
- `@ruvnet/ruview` **owns the `ruview` bin** (it is the published front door,
ADR-182). `@ruv/ruview-cli` renames its bin or folds into `rvagent`
(ADR-264 O9) — decided here so neither package ADR relitigates it.
- New Node packages in this repo use the `@ruvnet/` scope (the `@ruv/` scope
holds `rvcsi` legacies; do not grow it).
- Every package README + description must pass
`npx ruview claim-check` — enforced in the D1 gate. The guardrail package
linting its sibling packages' claims is the cheapest dogfooding we have
(ADR-264 F3 is the standing example of why).
### D5 — Shared-code policy (bounded)
Do **not** introduce an npm workspace or a shared runtime package yet: three
packages, two of which may merge (ADR-264 O9), do not justify workspace
machinery, and the harness's zero-dep property is load-bearing. Revisit if a
fourth package appears or if the `http/cog/config` duplication survives the
ADR-264 O9 fold. Record the duplication as intentional in each file header (the
CLI already does this).
## Consequences
- The npm artifacts get the same class of gate the Rust workspace has had since
ADR-028: no publish without tests, no shipped file set without an asserted
manifest, no version without provenance. The two defects that reached the
registry (broken `require` condition, dead maps) become CI-impossible.
- Cold-path costs stay near zero: the D1 matrix is 6 fast jobs (the harness
suite runs in ~108 ms MEASURED; TS builds dominate at a few tens of seconds).
- Publishing gains one constraint (must go through CI) and loses one failure
mode (laptop-state publishes) — the right trade for a project whose brand is
reproducible evidence.
- D3's grep gate is blunt but cheap; if it over-fires, scope it to
`version`-adjacent identifiers before weakening it.
- Follow-ups tracked elsewhere: per-package code fixes (ADR-263 O1O8, ADR-264
O1O9); ADR-182 P4 (metaharness router + ed25519 provenance chain) remains
the deeper provenance story that D2's npm attestations complement, not
replace.
+4 -1
View File
@@ -1,6 +1,6 @@
# Architecture Decision Records
This folder contains 45 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project.
This folder contains 182 Architecture Decision Records (ADRs) that document every significant technical choice in the RuView / WiFi-DensePose project. (The index tables below list a curated subset per domain; see the directory listing for the full set.)
## Why ADRs?
@@ -120,6 +120,9 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-097](ADR-097-adopt-rvcsi-as-ruview-csi-runtime.md) | Adopt rvCSI as RuView's primary CSI runtime (phased adoption) | Proposed |
| [ADR-098](ADR-098-evaluate-midstream-fit.md) | Evaluate `ruvnet/midstream` for RuView's CSI / WebSocket / mesh pipeline | Rejected |
| [ADR-099](ADR-099-midstream-introspection-tap.md) | Adopt midstream as RuView's real-time introspection + low-latency tap | Proposed |
| [ADR-263](ADR-263-ruview-npm-harness-deep-review.md) | `@ruvnet/ruview` npm harness — deep review + optimization strategy | Proposed |
| [ADR-264](ADR-264-rvagent-mcp-and-cli-npm-deep-review.md) | `@ruvnet/rvagent` MCP server + `@ruv/ruview-cli` — deep review + optimization strategy | Proposed |
| [ADR-265](ADR-265-ruview-npm-distribution-strategy.md) | RuView npm distribution strategy — CI gate, provenance, version single-sourcing, namespace | Proposed |
---
+135
View File
@@ -0,0 +1,135 @@
# WiFlow Browser Trainer (`wiflow_browser.html`)
A **single self-contained HTML page** that does the entire camera-supervised
WiFi-pose loop **in your browser, in your laptop camera's coordinate frame**, as
a **4-stage gated flow** with a progress stepper (each stage unlocks the next):
0. **CALIBRATE** *(ADR-151 empty-room baseline)* — you step OUT of the space; the
page captures ~10 s of the quiescent CSI and computes a per-feature running
**mean + std (Welford)** over the 410-d vector. Every CSI vector afterwards is
expressed as **deviation from baseline**
(`x_norm = (x base_mean) / (base_std + ε)`), so a body's perturbation stands
out from the static channel. Persisted to IndexedDB. *Can't capture without it.*
1. **CAPTURE** — MediaPipe Pose runs on your laptop camera → 17 COCO keypoints
(the *label*), paired with the **baseline-normalized** 410-d ESP32 CSI vector
(the *input*). A **guided, balanced routine** cycles big on-screen prompts
(stand / turn / walk / arms / crouch / sit / reach) with a countdown, and a
**per-pose coverage meter** so you build a balanced dataset, not 2 000 frames
of standing.
2. **TRAIN** — a TensorFlow.js MLP learns `CSI → pose` in-browser. Honest
held-out PCK@0.10 / PCK@0.05 / MPJPE, plus a **mean-pose baseline** the model
must beat (the project's whole ethos — no baseline-beating signal, it says so).
*Can't train with <200 samples.*
3. **INFER** — the trained model drives a skeleton **from WiFi CSI only**
(baseline-normalized → standardized → model), drawn over the **same** camera
frame it trained in — so the inferred skeleton **aligns** with the camera
image. That alignment is the entire point of doing this in-browser instead of
with a separate Python camera. *Can't infer without a model.*
## Why in-browser
The Python pipeline (`wiflow_capture.py``wiflow_train.py``wiflow_infer.py`)
proved the signal is real (held-out PCK@0.10 ≈ 59.5% vs a 50% mean-pose baseline
= +9.4 pp). But it trained in a *different* camera's frame, so the inferred
skeleton never lined up with the laptop camera. Doing capture + train + infer all
in the browser with the **same** camera makes the training frame and the
inference frame identical → the skeleton aligns.
## Compute backends (WebGPU / WASM / WebGL)
Training and inference run on TensorFlow.js. The page selects the backend at
startup, preferring the fastest available:
- **WebGPU** (Chrome / Edge, secure context — `localhost` qualifies) — GPU compute.
- **WASM-SIMD** fallback (`tfjs-backend-wasm`, SIMD enabled, `.wasm` from the CDN).
- **WebGL** last-resort fallback (ships inside tfjs core).
The **active backend is shown as a badge in the header** (`compute: WebGPU` /
`WASM-SIMD` / `WebGL`) so it's honest about what's actually running. The model
code is backend-agnostic — tf.js abstracts the device.
## Honesty (baked in)
- The **CAPTURE** skeleton (blue) is the camera = ground truth, labeled as such.
- The **INFER** skeleton (green) is **CSI-only**, labeled, and **coarse** — the
real measured held-out PCK is shown, not a marketing number.
- The **mean-pose baseline** is always computed and shown in TRAIN; the verdict
states plainly whether the model **beats** it (real signal) or **does not**
(no usable signal). This guards against the project's retracted 92.9% that
failed exactly this check.
- Status banner is strict and mutually exclusive:
**LIVE** (real `source: "esp32"`) / **SIMULATED — not real** (any other source)
/ **NO-CSI-SERVER**. The page never invents frames.
## How to run
### 1. Start the real sensing-server (provides the CSI WebSocket on :8765)
```bash
cd v2
cargo build -p wifi-densepose-sensing-server
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005
```
A real ESP32-S3 must be provisioned and streaming for `source` to read `esp32`
(see `CLAUDE.local.md` for the firmware build/provision steps). The page expects
the verified live endpoint **`ws://localhost:8765/ws/sensing`** with
`source:"esp32"`, nodes `[9, 13]`, `features.*`, `node_features[].features.*`,
and `signal_field.values` (400 floats).
### 2. Serve this page over localhost (camera + WebGPU need a localhost/secure origin)
Any static localhost server works. For example:
```bash
python -m http.server 8099
# then open: http://localhost:8099/examples/through-wall/wiflow_browser.html
```
(8099 is just the static file server — 8765 is a separate process, the CSI
WebSocket.) Allow camera access when the browser prompts.
Point at a CSI server on another host with `?ws=`:
```
http://localhost:8099/examples/through-wall/wiflow_browser.html?ws=ws://192.168.1.20:8765/ws/sensing
```
### 3. Use it
1. **CAPTURE** tab → *enable laptop camera**start recording*. Follow the guided
routine (stand / turn / walk / arms / crouch / sit). A pair is stored only when
a confident pose AND a fresh live `esp32` CSI frame coexist. Aim for a few
thousand samples. Samples persist in IndexedDB across refreshes.
2. **TRAIN** tab → *train model*. Watch the live loss curve, held-out PCK, and the
baseline verdict. The model saves to IndexedDB.
3. **INFER** tab → the green skeleton is now driven by WiFi CSI only, aligned over
your camera. Toggle *hide camera* to see the CSI-only skeleton on black.
## The 410-d CSI vector (matches the Python pipeline exactly)
```
[ mean_rssi, variance, motion_band_power, breathing_band_power ] # 4 (features.*)
+ for node 9 then node 13: [ mean_rssi, variance, motion_band_power ] # 6 (node_features[].features.*)
+ signal_field.values, padded / truncated to 400 # 400
= 410-d
```
Verified against a real live frame: the in-browser `csiVector()` produces the
identical 410 vector as `wiflow_capture.py`'s `csi_vector()` (node 9 first, then
node 13; field zero-padded).
## Libraries (CDN only, no bundler)
| Library | CDN |
|---|---|
| TensorFlow.js core | `@tensorflow/tfjs@4.22.0/dist/tf.min.js` |
| TF.js WebGPU backend | `@tensorflow/tfjs-backend-webgpu@4.22.0/dist/tf-backend-webgpu.min.js` |
| TF.js WASM backend | `@tensorflow/tfjs-backend-wasm@4.22.0/dist/tf-backend-wasm.min.js` |
| MediaPipe Pose 0.5 (legacy solutions) | `@mediapipe/pose@0.5/pose.js` |
## Scope / honesty caveats
Same person, same room, same session. **Not** validated cross-day, cross-room, or
through-wall. The inferred pose is coarse (PCK@0.05 is typically weak). If the
model does not beat the mean-pose baseline, the page says so — that is a feature.
+644
View File
@@ -0,0 +1,644 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RuView · Through-Wall WiFi Sensing · LIVE CSI (no skeleton, no simulation)</title>
<!--
THROUGH-WALL WiFi-CSI SENSING DEMO — honest, real-data-only.
Renders ONLY what the running sensing-server actually streams over
ws://localhost:8765/ws/sensing :
- the 20x20 `signal_field` floor heatmap (real values)
- a coarse RF-localization puck from persons[0].position (NOT pose)
- live motion / presence / rssi / confidence meters
- the real `source` ("esp32" = LIVE) verbatim in the banner
It deliberately does NOT draw a skeleton. The server's
persons[].keypoints carry confidence:0.0 (image-pixel garbage, not
real 3D joints) so we never render them. WiFi CSI gives
motion/presence/coarse-position — that is the honest wow, and it
penetrates drywall. See README.md.
-->
<style>
:root {
--bg: #050507; --bg-panel: rgba(8,10,14,0.80);
--amber: #ffb840; --amber-hot: #ffe09f;
--cyan: #4cf; --magenta: #ff4cc8;
--text: #d8c69a; --text-mute: #6b6155;
--green: #4f4; --red: #f64;
--border: rgba(255,184,64,0.18);
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
-webkit-font-smoothing: antialiased; font-size: 12px;
}
canvas { display: block; }
.overlay-frame {
position: fixed; inset: 0; pointer-events: none; z-index: 5;
background:
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
}
.scanlines {
position: fixed; inset: 0; pointer-events: none; z-index: 6;
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
mix-blend-mode: overlay; opacity: 0.5;
}
.panel {
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
box-shadow: 0 1px 0 rgba(255,184,64,0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
}
.panel h2 {
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
}
/* ---- Honest status banner (top-center, mutually exclusive states) ---- */
#banner {
position: fixed; top: 0; left: 0; right: 0; z-index: 30;
text-align: center; padding: 7px 12px; font-size: 12px; letter-spacing: 1px;
font-weight: 600; border-bottom: 1px solid rgba(0,0,0,0.4);
transition: background 0.3s, color 0.3s;
}
#banner.live { background: rgba(40,255,80,0.12); color: var(--green); border-bottom-color: rgba(80,255,120,0.4); }
#banner.sim { background: rgba(255,120,40,0.16); color: #ffae5a; border-bottom-color: rgba(255,140,60,0.5); }
#banner.noserver { background: rgba(255,80,80,0.16); color: var(--red); border-bottom-color: rgba(255,90,90,0.5); }
#banner .src { opacity: 0.8; font-weight: 400; }
#banner-caption {
position: fixed; top: 30px; left: 0; right: 0; z-index: 29;
text-align: center; font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px;
pointer-events: none; padding-top: 2px;
}
#info { top: 64px; left: 20px; min-width: 270px; }
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
#info .row .k { color: var(--text-mute); font-size: 11px; }
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
#info .row .v.amber { color: var(--amber); }
#info .row .v.cyan { color: var(--cyan); }
#info .row .v.green { color: var(--green); }
#info .row .v.red { color: var(--red); }
#info .row .v.mag { color: var(--magenta); }
#info .row .v.mute { color: var(--text-mute); }
#csi { top: 64px; right: 20px; min-width: 270px; }
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
#csi .bar-row .label { width: 86px; color: var(--text-mute); }
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
#csi .bar-row .bar-fill {
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
box-shadow: 0 0 6px var(--amber); transition: width 0.1s linear;
}
#csi .bar-row .val { width: 44px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
#csi .spark { margin-top: 8px; }
#csi canvas { width: 100%; height: 38px; display: block; border: 1px solid var(--border); border-radius: 3px; background: rgba(0,0,0,0.3); }
#csi .legend { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 10px; color: var(--text-mute); line-height: 1.5; }
/* ---- waiting / no-server overlay ---- */
#waiting {
position: fixed; inset: 0; z-index: 25; display: none;
flex-direction: column; align-items: center; justify-content: center;
background: rgba(5,5,7,0.94); color: var(--amber); text-align: center; padding: 24px;
}
#waiting.show { display: flex; }
#waiting .big { font-size: 22px; letter-spacing: 2px; color: var(--red); margin-bottom: 16px; text-transform: uppercase; }
#waiting code {
display: block; text-align: left; max-width: 640px; margin: 8px auto;
background: rgba(255,184,64,0.06); border: 1px solid var(--border); border-radius: 4px;
padding: 10px 14px; color: var(--amber-hot); font-size: 12px; white-space: pre-wrap;
}
#waiting .pulse { animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: 0.55; } 50% { opacity: 1; } }
/* ---- optional webcam ground-truth tile ---- */
#cam-tile {
position: absolute; bottom: 20px; right: 20px; width: 240px; z-index: 12;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 8px; backdrop-filter: blur(8px);
}
#cam-tile h2 { margin: 0 0 6px 0; font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px;
color: var(--cyan); font-weight: 600; }
#cam-tile .gt-note { font-size: 9px; color: var(--text-mute); margin-top: 4px; line-height: 1.4; }
#cam-video { width: 100%; border-radius: 3px; display: none; background: #000; }
#cam-tile button {
width: 100%; margin-top: 6px; padding: 5px 8px; font-family: inherit; font-size: 11px;
background: transparent; color: var(--cyan); border: 1px solid var(--cyan); border-radius: 3px; cursor: pointer;
}
#cam-tile button:hover { background: rgba(68,204,255,0.12); }
#cam-tile button:disabled { opacity: 0.5; cursor: not-allowed; }
#legend-nodes {
position: absolute; bottom: 20px; left: 20px; min-width: 220px;
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
}
#legend-nodes h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
#legend-nodes .lr { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 11px; }
#legend-nodes .dot { width: 9px; height: 9px; border-radius: 50%; box-shadow: 0 0 6px currentColor; flex: 0 0 auto; }
#legend-nodes .muted { color: var(--text-mute); }
</style>
<!-- three.js r128 + addons (same CDN set as examples/three.js/demos/05) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
</head>
<body>
<div id="banner" class="noserver">NO SERVER — start the sensing-server <span class="src"></span></div>
<div id="banner-caption">Real WiFi CSI motion / presence / coarse-localization — penetrates drywall. Not skeletal pose.</div>
<div class="overlay-frame"></div>
<div class="scanlines"></div>
<div class="panel" id="info">
<h1>THROUGH-WALL WiFi SENSING</h1>
<div class="sub">Live CSI · ws://localhost:8765/ws/sensing</div>
<div class="row"><span class="k">source</span><span class="v amber" id="m-source"></span></div>
<div class="row"><span class="k">presence</span><span class="v" id="m-presence"></span></div>
<div class="row"><span class="k">motion level</span><span class="v" id="m-motion"></span></div>
<div class="row"><span class="k">confidence</span><span class="v cyan" id="m-conf"></span></div>
<div class="row"><span class="k">est. persons</span><span class="v amber" id="m-persons"></span></div>
<div class="row"><span class="k">active nodes</span><span class="v" id="m-nodes"></span></div>
<div class="row"><span class="k">tick</span><span class="v" id="m-tick"></span></div>
<div class="row"><span class="k">update rate</span><span class="v cyan" id="m-fps"></span></div>
</div>
<div class="panel" id="csi">
<h2>Live RF features</h2>
<div class="bar-row"><span class="label">motion</span><div class="bar-track"><div class="bar-fill" id="bar-motion"></div></div><span class="val" id="v-motion"></span></div>
<div class="bar-row"><span class="label">breathing</span><div class="bar-track"><div class="bar-fill" id="bar-breath"></div></div><span class="val" id="v-breath"></span></div>
<div class="bar-row"><span class="label">variance</span><div class="bar-track"><div class="bar-fill" id="bar-var"></div></div><span class="val" id="v-var"></span></div>
<div class="bar-row"><span class="label">mean rssi</span><div class="bar-track"><div class="bar-fill" id="bar-rssi"></div></div><span class="val" id="v-rssi"></span></div>
<div class="spark"><canvas id="spark" width="252" height="38"></canvas></div>
<div class="legend">motion sparkline (last ~6s of real motion_band_power)</div>
</div>
<div id="legend-nodes">
<h2>Sensor nodes</h2>
<div class="lr"><span class="dot" style="color:#4cf"></span><span>ESP32-S3 office <span class="muted">(node 9)</span></span></div>
<div class="lr"><span class="dot" style="color:#ff4cc8"></span><span>ESP32-S3 hallway <span class="muted">(node 13)</span></span></div>
<div class="lr" style="margin-top:6px"><span class="dot" style="color:#4f4"></span><span>RF localization <span class="muted">(coarse)</span></span></div>
<div class="lr"><span class="muted" style="font-size:10px;line-height:1.4">Office &amp; hallway split by a wall + doorway. WiFi motion still shows through drywall.</span></div>
</div>
<div id="cam-tile">
<h2>camera — ground truth when visible</h2>
<video id="cam-video" autoplay muted playsinline></video>
<button id="cam-btn">▶ enable webcam (optional)</button>
<div class="gt-note">Independent of the CSI sensing. The WiFi works in the dark and through walls; the camera does not.</div>
</div>
<div id="waiting" class="show">
<div class="big pulse">Waiting for live sensing-server</div>
<div>No connection to <b>ws://localhost:8765/ws/sensing</b>. Start the real server, then this page connects automatically.</div>
<code>cd v2
cargo build -p wifi-densepose-sensing-server
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005</code>
<div style="margin-top:10px; color:var(--text-mute); font-size:11px;">This demo renders ONLY real data. It never invents frames.</div>
</div>
<script>
"use strict";
// =====================================================================
// Config + WS endpoint (allow ?ws= override)
// =====================================================================
const params = new URLSearchParams(location.search);
const WS_URL = params.get('ws') || 'ws://localhost:8765/ws/sensing';
const ROOM_HALF = 5; // half-extent of the floor plane in metres
const GRID_N = 20; // signal_field is 20 x 20
// Known node anchor positions (server sends node 9 @ [2,0,1.5]; node 13
// joins later from the hallway side once its firmware is flashed). These
// are anchors for the room model + labels, NOT fabricated sensing data.
const NODE_ANCHORS = {
9: { pos: [ 2.0, 0.0, 1.5], color: 0x44ccff, label: 'office (node 9)' },
13: { pos: [-2.0, 0.0, -3.0], color: 0xff4cc8, label: 'hallway (node 13)' },
};
// =====================================================================
// Three.js scene (reused pattern from demos/05-skinned-realtime.html)
// =====================================================================
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x050507);
scene.fog = new THREE.FogExp2(0x050507, 0.045);
const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.05, 100);
camera.position.set(4.5, 4.2, 6.0);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.85;
renderer.outputEncoding = THREE.sRGBEncoding;
document.body.appendChild(renderer.domElement);
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.4, -0.5);
controls.enableDamping = true; controls.dampingFactor = 0.06;
controls.minDistance = 3; controls.maxDistance = 18;
controls.maxPolarAngle = Math.PI * 0.49;
scene.add(new THREE.HemisphereLight(0x553a18, 0x080606, 0.7));
const keyLight = new THREE.DirectionalLight(0xffc070, 0.9);
keyLight.position.set(3, 6, 4);
scene.add(keyLight);
// Post-processing — gentle bloom so the heatmap + puck glow.
const composer = new THREE.EffectComposer(renderer);
composer.addPass(new THREE.RenderPass(scene, camera));
const bloom = new THREE.UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight), 0.55, 0.45, 0.82);
composer.addPass(bloom);
// =====================================================================
// Room: floor grid + wall + doorway dividing office / hallway
// =====================================================================
const gridHelper = new THREE.GridHelper(2*ROOM_HALF, GRID_N, 0x554a32, 0x2a2418);
gridHelper.position.y = 0.002;
scene.add(gridHelper);
// Dividing wall runs along world X near z = -1 (office z>-1, hallway z<-1),
// with a doorway gap. Two wall segments leave a gap in the middle.
const wallMat = new THREE.MeshStandardMaterial({
color: 0x1b2330, transparent: true, opacity: 0.55, roughness: 0.9,
side: THREE.DoubleSide,
});
const wallH = 1.4, wallZ = -1.0;
function addWallSeg(cx, w) {
const m = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, 0.08), wallMat);
m.position.set(cx, wallH/2, wallZ);
scene.add(m);
// top edge highlight
const edge = new THREE.Mesh(new THREE.BoxGeometry(w, 0.02, 0.10),
new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0.5 }));
edge.position.set(cx, wallH, wallZ);
scene.add(edge);
}
// left segment, doorway gap (-0.7..0.7), right segment
addWallSeg(-3.15, 3.7);
addWallSeg( 3.15, 3.7);
// Room labels (sprite text) for OFFICE / HALLWAY
function makeLabel(text, color) {
const c = document.createElement('canvas'); c.width = 256; c.height = 64;
const ctx = c.getContext('2d');
ctx.fillStyle = color; ctx.font = 'bold 30px Consolas, monospace';
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(text, 128, 34);
const tex = new THREE.CanvasTexture(c);
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
spr.scale.set(2.0, 0.5, 1);
return spr;
}
const officeLbl = makeLabel('OFFICE', '#ffb840'); officeLbl.position.set(2.6, 0.06, 2.6); scene.add(officeLbl);
const hallLbl = makeLabel('HALLWAY', '#ff4cc8'); hallLbl.position.set(-2.6, 0.06, -3.2); scene.add(hallLbl);
// =====================================================================
// Node markers (office / hallway). The hallway node is dimmed until it
// actually appears in the live `nodes` list.
// =====================================================================
const nodeMeshes = {};
function buildNode(id) {
const a = NODE_ANCHORS[id];
const g = new THREE.Group();
const post = new THREE.Mesh(
new THREE.CylinderGeometry(0.05, 0.07, 0.9, 12),
new THREE.MeshStandardMaterial({ color: a.color, emissive: a.color, emissiveIntensity: 0.4, roughness: 0.4 }));
post.position.y = 0.45; g.add(post);
const orb = new THREE.Mesh(
new THREE.SphereGeometry(0.12, 20, 16),
new THREE.MeshBasicMaterial({ color: a.color }));
orb.position.y = 0.95; g.add(orb);
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.18, 0.24, 32),
new THREE.MeshBasicMaterial({ color: a.color, transparent: true, opacity: 0.6, side: THREE.DoubleSide }));
ring.rotation.x = -Math.PI/2; ring.position.y = 0.01; g.add(ring);
const lbl = makeLabel('ESP32-S3 ' + a.label, '#' + a.color.toString(16).padStart(6,'0'));
lbl.scale.set(2.6, 0.65, 1); lbl.position.set(0, 1.25, 0); g.add(lbl);
g.position.set(a.pos[0], 0, a.pos[2]);
g.userData.parts = { post, orb, ring };
scene.add(g);
return g;
}
Object.keys(NODE_ANCHORS).forEach(id => { nodeMeshes[id] = buildNode(+id); });
function setNodeActive(id, active) {
const g = nodeMeshes[id]; if (!g) return;
const o = active ? 1.0 : 0.22;
const parts = g.userData.parts;
parts.orb.material.opacity = o; parts.orb.material.transparent = true;
parts.ring.material.opacity = 0.6 * o;
parts.post.material.emissiveIntensity = active ? 0.5 : 0.12;
}
setNodeActive(9, false); setNodeActive(13, false);
// =====================================================================
// signal_field 20x20 floor heatmap — instanced colored tiles.
// Driven ONLY by real `signal_field.values` (400 floats ~0..1).
// =====================================================================
const TILE = (2*ROOM_HALF) / GRID_N;
const heatGeo = new THREE.PlaneGeometry(TILE * 0.96, TILE * 0.96);
const heatMat = new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.85, side: THREE.DoubleSide });
const heatMesh = new THREE.InstancedMesh(heatGeo, heatMat, GRID_N * GRID_N);
heatMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
const heatColor = new THREE.InstancedBufferAttribute(new Float32Array(GRID_N * GRID_N * 3), 3);
heatMesh.instanceColor = heatColor;
const _m = new THREE.Matrix4();
const _q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0), -Math.PI/2);
const _s = new THREE.Vector3(1,1,1);
const _p = new THREE.Vector3();
// gridCell (gx,gz) -> world (x,z). gx,gz in [0,GRID_N).
function cellToWorld(gx, gz) {
return [ (gx + 0.5) * TILE - ROOM_HALF, (gz + 0.5) * TILE - ROOM_HALF ];
}
for (let gz = 0; gz < GRID_N; gz++) {
for (let gx = 0; gx < GRID_N; gx++) {
const i = gz * GRID_N + gx;
const [wx, wz] = cellToWorld(gx, gz);
_p.set(wx, 0.012, wz);
_m.compose(_p, _q, _s);
heatMesh.setMatrixAt(i, _m);
heatColor.setXYZ(i, 0.02, 0.02, 0.03);
}
}
heatMesh.instanceMatrix.needsUpdate = true;
scene.add(heatMesh);
// amber→white heat ramp for a value in [0,1]
function heatRamp(v, out) {
v = Math.max(0, Math.min(1, v));
// dark -> amber -> hot white
const r = Math.min(1, 0.05 + 1.6 * v);
const g = Math.min(1, 0.02 + 1.1 * v * v);
const b = Math.min(1, 0.04 + 0.9 * Math.pow(v, 3));
out.set(r, g, b);
return out;
}
const _c = new THREE.Color();
let lastFieldPeak = { gx: GRID_N/2|0, gz: GRID_N/2|0, v: 0 };
function updateHeatmap(field) {
if (!field || !Array.isArray(field.values)) return;
const vals = field.values;
// grid_size is [20,1,20]; values are row-major 400 floats.
let peakV = -1, peakGx = lastFieldPeak.gx, peakGz = lastFieldPeak.gz;
const n = Math.min(vals.length, GRID_N * GRID_N);
for (let i = 0; i < n; i++) {
const v = vals[i];
heatRamp(v, _c);
heatColor.setXYZ(i, _c.r, _c.g, _c.b);
if (v > peakV) { peakV = v; peakGx = i % GRID_N; peakGz = (i / GRID_N) | 0; }
}
heatColor.needsUpdate = true;
lastFieldPeak = { gx: peakGx, gz: peakGz, v: peakV };
}
// =====================================================================
// RF-localization puck — from persons[0].position (coarse, NOT pose).
// Falls back to the signal_field peak cell when no person is present.
// =====================================================================
const puck = new THREE.Group();
const puckCore = new THREE.Mesh(
new THREE.SphereGeometry(0.16, 24, 18),
new THREE.MeshBasicMaterial({ color: 0x66ff88 }));
puckCore.position.y = 0.16; puck.add(puckCore);
const puckRing = new THREE.Mesh(
new THREE.RingGeometry(0.28, 0.36, 40),
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.7, side: THREE.DoubleSide }));
puckRing.rotation.x = -Math.PI/2; puckRing.position.y = 0.02; puck.add(puckRing);
const puckBeam = new THREE.Mesh(
new THREE.CylinderGeometry(0.03, 0.03, 1.2, 8),
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.35 }));
puckBeam.position.y = 0.6; puck.add(puckBeam);
puck.visible = false;
scene.add(puck);
const puckTarget = new THREE.Vector3(0, 0, 0);
function updatePuck(frame) {
let wx = null, wz = null, present = false;
const persons = frame.persons || [];
if (persons.length && Array.isArray(persons[0].position)) {
// server position is [x, 0, z] in metres, origin at room centre
wx = persons[0].position[0];
wz = persons[0].position[2];
present = true;
}
// If no person but the field has clear energy, show the peak cell
// (coarse) so the puck honestly tracks "where the RF energy is".
if (!present && lastFieldPeak.v > 0.55) {
const peak = cellToWorld(lastFieldPeak.gx, lastFieldPeak.gz);
wx = peak[0]; wz = peak[1]; present = true;
}
if (present && wx !== null) {
// clamp into the room so it never flies off the floor
wx = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wx));
wz = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wz));
puckTarget.set(wx, 0, wz);
puck.visible = true;
} else {
puck.visible = false;
}
}
// =====================================================================
// HUD updates
// =====================================================================
const $ = id => document.getElementById(id);
function clamp01(x){ return Math.max(0, Math.min(1, x)); }
function setBar(barId, valId, frac, text) {
$(barId).style.width = (clamp01(frac) * 100).toFixed(0) + '%';
$(valId).textContent = text;
}
// motion sparkline ring buffer
const sparkCtx = $('spark').getContext('2d');
const SPARK_N = 120;
const sparkBuf = new Array(SPARK_N).fill(0);
function pushSpark(v) {
sparkBuf.push(v); if (sparkBuf.length > SPARK_N) sparkBuf.shift();
const w = sparkCtx.canvas.width, h = sparkCtx.canvas.height;
sparkCtx.clearRect(0,0,w,h);
let maxV = 40; for (const x of sparkBuf) if (x > maxV) maxV = x;
sparkCtx.strokeStyle = '#ffb840'; sparkCtx.lineWidth = 1.5; sparkCtx.beginPath();
for (let i = 0; i < sparkBuf.length; i++) {
const x = (i / (SPARK_N-1)) * w;
const y = h - (sparkBuf[i] / maxV) * (h - 3) - 1.5;
i === 0 ? sparkCtx.moveTo(x, y) : sparkCtx.lineTo(x, y);
}
sparkCtx.stroke();
}
// =====================================================================
// Honest status banner (strict, mutually exclusive)
// =====================================================================
const banner = $('banner');
function setBannerLive(source, nodeCount) {
if (source === 'esp32') {
banner.className = 'live';
banner.innerHTML = 'LIVE — real ESP32 CSI <span class="src">(source=' + source + ', ' + nodeCount + ' node' + (nodeCount === 1 ? '' : 's') + ')</span>';
} else {
// anything not esp32 = explicitly NOT real, badged
banner.className = 'sim';
banner.innerHTML = 'SIMULATED — not real <span class="src">(source=' + source + ' — start an ESP32 for live CSI)</span>';
}
}
function setBannerNoServer() {
banner.className = 'noserver';
banner.innerHTML = 'NO SERVER — start the sensing-server <span class="src">(ws://localhost:8765/ws/sensing unreachable)</span>';
}
// =====================================================================
// WebSocket — render ONLY real frames. Reconnect; never fabricate.
// =====================================================================
let ws = null, gotFrame = false;
let frameTimes = []; // for measured update rate (fps)
let lastFrame = null; // most recent real frame (render loop interpolates puck)
function connect() {
setBannerNoServer();
try { ws = new WebSocket(WS_URL); }
catch (e) { scheduleReconnect(); return; }
ws.onopen = () => { /* wait for first frame before claiming LIVE */ };
ws.onmessage = (ev) => {
let d; try { d = JSON.parse(ev.data); } catch (e) { return; }
if (!d || d.type !== 'sensing_update') return;
onFrame(d);
};
ws.onclose = () => { gotFrame = false; $('waiting').classList.add('show'); setBannerNoServer(); scheduleReconnect(); };
ws.onerror = () => { try { ws.close(); } catch (e) {} };
}
let reconnectT = null;
function scheduleReconnect() {
if (reconnectT) return;
reconnectT = setTimeout(() => { reconnectT = null; connect(); }, 1500);
}
function onFrame(d) {
gotFrame = true;
lastFrame = d;
$('waiting').classList.remove('show');
const source = d.source || 'unknown';
const nodes = Array.isArray(d.nodes) ? d.nodes : [];
setBannerLive(source, nodes.length);
// measured update rate
const now = performance.now();
frameTimes.push(now);
while (frameTimes.length && now - frameTimes[0] > 2000) frameTimes.shift();
const fps = frameTimes.length > 1 ? (frameTimes.length - 1) / ((frameTimes[frameTimes.length-1] - frameTimes[0]) / 1000) : 0;
const cls = d.classification || {};
const feat = d.features || {};
// info panel
$('m-source').textContent = source.toUpperCase();
$('m-source').className = 'v ' + (source === 'esp32' ? 'green' : 'red');
const presence = !!cls.presence;
$('m-presence').textContent = presence ? (cls.motion_level === 'present_moving' ? 'PRESENT · MOVING' : 'PRESENT') : 'CLEAR';
$('m-presence').className = 'v ' + (presence ? 'green' : 'mute');
$('m-motion').textContent = cls.motion_level || '—';
$('m-conf').textContent = (cls.confidence != null) ? cls.confidence.toFixed(2) : '—';
$('m-persons').textContent = (d.estimated_persons != null) ? d.estimated_persons : '—';
$('m-nodes').textContent = nodes.length + ' (' + nodes.map(n => n.node_id).join(', ') + ')';
$('m-tick').textContent = (d.tick != null) ? d.tick : '—';
$('m-fps').textContent = fps ? fps.toFixed(1) + ' Hz' : '—';
// feature bars (real values, scaled into 0..1 for the bar width only)
const motion = feat.motion_band_power || 0;
const breath = feat.breathing_band_power || 0;
const variance = feat.variance || 0;
const rssi = feat.mean_rssi != null ? feat.mean_rssi : -100;
setBar('bar-motion', 'v-motion', motion / 100, motion.toFixed(1));
setBar('bar-breath', 'v-breath', breath / 100, breath.toFixed(1));
setBar('bar-var', 'v-var', variance / 80, variance.toFixed(1));
// rssi: map -90..-30 dBm -> 0..1
setBar('bar-rssi', 'v-rssi', (rssi + 90) / 60, rssi.toFixed(0));
pushSpark(motion);
// node activity
const activeIds = new Set(nodes.map(n => n.node_id));
[9, 13].forEach(id => setNodeActive(id, activeIds.has(id)));
// heatmap + puck
updateHeatmap(d.signal_field);
updatePuck(d);
}
// =====================================================================
// Optional webcam ground-truth tile (reused from demos/05). Camera is
// separate from CSI sensing — labeled "ground truth when visible".
// =====================================================================
let camStream = null;
$('cam-btn').addEventListener('click', async () => {
const btn = $('cam-btn');
if (camStream) { // toggle off
camStream.getTracks().forEach(t => t.stop());
$('cam-video').style.display = 'none'; camStream = null;
btn.textContent = '▶ enable webcam (optional)';
return;
}
btn.disabled = true; btn.textContent = 'requesting camera…';
try {
camStream = await navigator.mediaDevices.getUserMedia({
video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' }, audio: false,
});
const v = $('cam-video'); v.srcObject = camStream; v.style.display = 'block';
btn.textContent = '■ stop webcam'; btn.disabled = false;
} catch (e) {
btn.textContent = '✗ camera unavailable'; btn.disabled = false; console.error(e);
setTimeout(() => { if (!camStream) btn.textContent = '▶ enable webcam (optional)'; }, 2000);
}
});
// =====================================================================
// Render loop — smooth the puck toward its real target; pulse rings.
// =====================================================================
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
controls.update();
if (puck.visible) {
puck.position.lerp(puckTarget, 0.18);
const pulse = 0.8 + 0.25 * Math.sin(t * 3.0);
puckRing.scale.set(pulse, pulse, pulse);
puckRing.material.opacity = 0.5 + 0.25 * Math.sin(t * 3.0);
}
// node rings breathe when active
[9,13].forEach(id => {
const g = nodeMeshes[id]; if (!g) return;
const r = g.userData.parts.ring;
const s = 1 + 0.08 * Math.sin(t * 2 + id);
r.scale.set(s, s, s);
});
composer.render();
}
animate();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
// kick off
connect();
</script>
</body>
</html>
+159
View File
@@ -0,0 +1,159 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WiFlow · live WiFi-inferred pose</title>
<style>
:root{--bg:#0a0c10;--panel:#11151c;--amber:#ffb840;--green:#46e08a;--red:#ff5a5a;--mute:#7d8796;--line:#1d2430}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:#dfe6ee;font:14px/1.5 'JetBrains Mono',ui-monospace,Menlo,monospace}
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
h1{font-size:15px;margin:0;letter-spacing:1px;text-transform:uppercase;font-weight:600}
h1 span{color:var(--amber)}
#banner{margin-left:auto;padding:5px 12px;border-radius:5px;font-weight:600;font-size:12px;letter-spacing:.5px}
.live{background:rgba(70,224,138,.15);color:var(--green);border:1px solid var(--green)}
.sim{background:rgba(255,184,64,.15);color:var(--amber);border:1px solid var(--amber)}
.down{background:rgba(255,90,90,.15);color:var(--red);border:1px solid var(--red)}
main{display:flex;gap:18px;padding:18px;flex-wrap:wrap}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
canvas{background:#070a0e;border-radius:8px;display:block}
.label{font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--mute);margin-bottom:8px}
.stats{min-width:240px}
.row{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed var(--line)}
.row .k{color:var(--mute)} .row .v{color:var(--amber);font-variant-numeric:tabular-nums}
.v.green{color:var(--green)}
.note{margin-top:12px;font-size:11px;color:var(--mute);line-height:1.6;max-width:300px}
.note b{color:#dfe6ee}
</style>
</head>
<body>
<header>
<h1>WiFlow · <span>live WiFi-inferred pose</span></h1>
<div id="banner" class="down">CONNECTING…</div>
</header>
<main>
<div class="card">
<div class="label">CSI → pose (skeleton) overlaid on your laptop camera</div>
<div id="stage" style="width:420px;height:560px;border-radius:8px;overflow:hidden;background:#070a0e">
<video id="cam" autoplay muted playsinline style="position:absolute;width:2px;height:2px;opacity:0;pointer-events:none"></video>
<canvas id="cv" width="420" height="560"></canvas>
</div>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button id="camBtn" style="background:var(--amber);color:#0a0c10;border:0;border-radius:6px;padding:7px 14px;font:inherit;font-weight:600;cursor:pointer">enable laptop camera</button>
<select id="camSel" style="display:none;background:var(--panel);color:#dfe6ee;border:1px solid var(--line);border-radius:6px;padding:6px;font:inherit;max-width:220px"></select>
</div>
<div id="camStatus" style="margin-top:6px;font-size:11px;color:var(--mute)">camera: off</div>
<div class="note" style="margin-top:8px">Camera is a <b>visual reference only</b> — it is NOT fed to the model. Overlay alignment is approximate (model trained in a different camera's frame).</div>
</div>
<div class="card stats">
<div class="label">live</div>
<div class="row"><span class="k">CSI source</span><span class="v" id="src"></span></div>
<div class="row"><span class="k">nodes</span><span class="v" id="nodes"></span></div>
<div class="row"><span class="k">presence</span><span class="v" id="pres"></span></div>
<div class="row"><span class="k">motion</span><span class="v" id="motion"></span></div>
<div class="row"><span class="k">pose fps</span><span class="v" id="fps"></span></div>
<div class="note">
This skeleton is inferred <b>from WiFi CSI only</b> — no camera in the loop here. A model was
trained on paired (camera-pose, CSI) data in this room (ADR-079/180).
<br/><br/>
<b>Honest accuracy:</b> ~<b>59.5% PCK@0.10</b> on held-out data (vs a 50% mean-pose baseline →
<b>+9.4 pp real signal</b>). It captures <b>coarse</b> pose; fine detail is weak (PCK@0.05 ≈ 24%).
Same person / room / session — not validated cross-day or through-wall.
</div>
</div>
</main>
<script>
const POSE_WS = (new URLSearchParams(location.search)).get('ws') || `ws://${location.hostname||'localhost'}:8770/pose`;
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
const $ = id => document.getElementById(id);
let edges = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]];
let last = null, frames = 0, t0 = performance.now();
function banner(state, txt){ const b=$('banner'); b.className=state; b.textContent=txt; }
// per-joint smoothing (EMA) so dropped/jittery CSI frames render fluidly (ADR-180 dead-reckoning, lite)
let sm = null;
function smooth(kps){
if(!sm){ sm = kps.map(p=>[p[0],p[1]]); return sm; }
const a=0.35; for(let i=0;i<kps.length;i++){ sm[i][0]+=a*(kps[i][0]-sm[i][0]); sm[i][1]+=a*(kps[i][1]-sm[i][1]); }
return sm;
}
const camEl=document.getElementById('cam');
function draw(p){
const W=cv.width, H=cv.height;
// paint the live camera frame onto the canvas (robust — no z-index/overlay tricks)
if(camEl && camEl.videoWidth>0){
ctx.save(); ctx.globalAlpha=0.9;
// cover-fit the camera frame into the canvas
const vr=camEl.videoWidth/camEl.videoHeight, cr=W/H;
let dw=W, dh=H, dx=0, dy=0;
if(vr>cr){ dh=H; dw=H*vr; dx=(W-dw)/2; } else { dw=W; dh=W/vr; dy=(H-dh)/2; }
ctx.drawImage(camEl, dx, dy, dw, dh); ctx.restore();
} else {
ctx.fillStyle='#070a0e'; ctx.fillRect(0,0,W,H);
}
if(!p || !p.kps){ return; }
const s = smooth(p.kps);
const k = s.map(([x,y])=>[x*W, y*H]);
ctx.lineWidth=5; ctx.strokeStyle=p.presence?'rgba(70,224,138,.95)':'rgba(125,135,150,.8)'; ctx.lineCap='round';
ctx.shadowColor='rgba(70,224,138,.6)'; ctx.shadowBlur=8;
for(const [a,b] of edges){ ctx.beginPath(); ctx.moveTo(k[a][0],k[a][1]); ctx.lineTo(k[b][0],k[b][1]); ctx.stroke(); }
ctx.shadowBlur=0;
for(const [x,y] of k){ ctx.beginPath(); ctx.arc(x,y,5,0,7); ctx.fillStyle=p.presence?'#ffb840':'#667'; ctx.fill(); }
}
// ---- laptop webcam (visual reference only; NOT fed to the model) ----
let camStream=null;
async function startCam(deviceId){
if(camStream){ camStream.getTracks().forEach(t=>t.stop()); }
const constraints = deviceId ? {video:{deviceId:{exact:deviceId}}} : {video:true};
const st=document.getElementById('camStatus');
try{
st.textContent='camera: requesting…';
camStream = await navigator.mediaDevices.getUserMedia(constraints);
const v=document.getElementById('cam'); v.muted=true; v.srcObject=camStream;
v.onloadedmetadata=()=>{ v.play().catch(err=>st.textContent='camera: play() blocked '+err.name); };
await v.play().catch(()=>{});
const tr=camStream.getVideoTracks()[0]; const ss=tr.getSettings();
// live readout: shows if real frames are flowing (videoWidth>0) and which device
const tick=()=>{ st.textContent = `camera: "${tr.label}" ${v.videoWidth}x${v.videoHeight} ${tr.readyState} ${v.paused?'PAUSED':'playing'}`; };
tick(); setInterval(tick, 1000);
document.getElementById('camBtn').textContent='switch camera ↻';
// populate the picker now that we have permission (labels need permission)
const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d=>d.kind==='videoinput');
const sel=document.getElementById('camSel'); sel.style.display = devs.length>1?'inline-block':'none';
sel.innerHTML = devs.map((d,i)=>`<option value="${d.deviceId}">${d.label||('camera '+(i+1))}</option>`).join('');
const cur = camStream.getVideoTracks()[0].getSettings().deviceId; if(cur) sel.value=cur;
}catch(e){
document.getElementById('camBtn').textContent = 'camera error: '+e.name+(e.name==='NotReadableError'?' (in use by Zoom/Teams?)':'');
console.error('getUserMedia', e);
}
}
document.getElementById('camBtn').addEventListener('click', ()=>startCam());
document.getElementById('camSel').addEventListener('change', e=>startCam(e.target.value));
function connect(){
banner('down','CONNECTING…');
const ws = new WebSocket(POSE_WS);
ws.onopen = ()=> banner('sim','WAITING FOR POSE…');
ws.onmessage = ev => {
const d = JSON.parse(ev.data);
if(d.type==='meta'){ edges = d.edges; return; }
if(d.type!=='pose') return;
last=d; frames++;
if(d.src==='esp32') banner('live','LIVE — WiFi-inferred pose (real ESP32 CSI)');
else banner('sim','SIMULATED CSI — not real ('+d.src+')');
$('src').textContent=d.src; $('src').className = d.src==='esp32'?'v green':'v';
$('nodes').textContent=(d.nodes||[]).join(', ')||'—';
$('pres').textContent=d.presence?'PRESENT':'—';
$('motion').textContent=(d.motion!=null?Math.round(d.motion):'—');
};
ws.onclose = ()=>{ banner('down','NO BRIDGE — start wiflow_infer.py'); setTimeout(connect,1500); };
ws.onerror = ()=> ws.close();
}
function loop(){ draw(last); const now=performance.now(); if(now-t0>1000){ $('fps').textContent=frames; frames=0; t0=now; } requestAnimationFrame(loop); }
connect(); loop();
</script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
"""Tiny threaded static server for the through-wall WiFi-CSI sensing demo.
Adapted from examples/three.js/server/serve-demo.py. Serves the
`examples/through-wall/` page so a browser can fetch index.html, then the
page connects directly to the LIVE sensing-server WebSocket at
ws://localhost:8765/ws/sensing (NOT proxied through here).
Why a threaded server (not `python -m http.server`)?
The stdlib SimpleHTTPServer is single-threaded; a browser opens several
parallel connections (HTML + the three.js CDN tags fetch in parallel),
the first eats the worker, the rest can stall. ThreadingHTTPServer fixes it.
IMPORTANT: this serves on port 8080 — port 8765 is taken by the
sensing-server's WebSocket. They are two different processes.
Usage:
# 1) start the REAL sensing-server (separate terminal):
# cd v2
# cargo build -p wifi-densepose-sensing-server
# ./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005
# 2) start this static server:
python examples/through-wall/serve.py
# 3) open:
# http://localhost:8080/examples/through-wall/index.html
Override the WS endpoint with a query param, e.g.:
http://localhost:8080/examples/through-wall/index.html?ws=ws://192.168.1.20:8765/ws/sensing
"""
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
import os
import sys
PORT = int(os.environ.get("PORT", 8080))
# Serve from the repo root regardless of where this script is launched.
# This file lives at examples/through-wall/serve.py — two levels deep.
os.chdir(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
class NoCacheHandler(SimpleHTTPRequestHandler):
def end_headers(self):
# Aggressive no-cache so the browser ALWAYS fetches the latest
# index.html after edits, even on a soft refresh.
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def log_message(self, fmt, *args): # quieter logs
sys.stderr.write("[serve] " + (fmt % args) + "\n")
PAGE = "examples/through-wall/index.html"
with ThreadingHTTPServer(("127.0.0.1", PORT), NoCacheHandler) as srv:
print(f"serving {os.getcwd()} on http://127.0.0.1:{PORT}/")
print(f" open http://localhost:{PORT}/{PAGE}")
print("")
print(" The page connects to the LIVE sensing-server at")
print(" ws://localhost:8765/ws/sensing (start it first — see README.md).")
print(" Override with ?ws=ws://HOST:PORT/ws/sensing")
try:
srv.serve_forever()
except KeyboardInterrupt:
sys.exit(0)
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Rigorous A/B for WiFlow CSI->pose: is the held-out PCK real signal or split leakage?
For a dataset of {csi:[D], kps:17x[x,y,vis]} pairs, train the SAME small MLP under
several train/val SPLITS and report held-out PCK@0.10 vs the mean-pose baseline:
- chronological_80_20 : last 20% in time (val temporally ADJACENT to train -> leaks
via CSI/pose autocorrelation; this is what gave us +9.4)
- random_80_20 : shuffled (val frames interleaved with train -> MAX leak)
- blocked_gap : hold out a contiguous MIDDLE block with a time GAP buffer on
each side so val is NOT adjacent to any train frame -> the
honest, leakage-controlled test
If the model beats baseline on chronological/random but COLLAPSES to ~baseline on
blocked_gap, the apparent signal was temporal leakage, not generalizable CSI->pose.
Usage (ruvultra venv): python wiflow_ab.py --data ~/wiflow-room/dataset.jsonl
"""
import argparse, json, sys
import numpy as np, torch, torch.nn as nn
def _rec(r, X, Y, V, B):
X.append(r["csi"]); kp=r["kps"]
if kp and isinstance(kp[0], (list,tuple)): # 17 x [x,y(,vis)]
Y.append([c for k in kp for c in (k[0],k[1])]); V.append([(k[2] if len(k)>2 else 1.0) for k in kp])
else: # flat 34 (browser export, no vis)
Y.append(list(kp)); V.append([1.0]*17)
B.append(r.get("bucket"))
def load(path):
X,Y,V,B=[],[],[],[]
txt=open(path).read().strip()
if txt[:1] in "[{": # JSON (browser export: dict{samples:[]} or bare array)
d=json.loads(txt)
rows = d if isinstance(d,list) else d.get("samples", d.get("data", []))
for r in rows: _rec(r,X,Y,V,B)
else: # JSONL (python capture)
for line in txt.splitlines():
if line.strip(): _rec(json.loads(line),X,Y,V,B)
return np.array(X,np.float32), np.array(Y,np.float32), np.array(V,np.float32), B
class Net(nn.Module):
def __init__(s,din,dout):
super().__init__()
s.n=nn.Sequential(nn.Linear(din,384),nn.ReLU(),nn.Dropout(.35),
nn.Linear(384,192),nn.ReLU(),nn.Dropout(.35),
nn.Linear(192,96),nn.ReLU(),nn.Linear(96,dout),nn.Sigmoid())
def forward(s,x): return s.n(x)
def pck(pred,gt,vis,thr=0.10):
p=pred.reshape(-1,17,2); g=gt.reshape(-1,17,2)
d=np.linalg.norm(p-g,axis=2); m=vis>0.5
return float((d[m]<thr).mean()) if m.any() else 0.0
def split_idx(n, kind, B=None):
idx=np.arange(n)
if kind=="chronological_80_20":
c=int(n*.8); return idx[:c], idx[c:]
if kind=="random_80_20":
rng=np.random.default_rng(0); p=rng.permutation(n); c=int(n*.8); return p[:c], p[c:]
if kind=="blocked_gap":
# val = contiguous middle 20%; a WIDE 10% time gap each side guarantees no train
# frame is temporally adjacent to a val frame (kills frame-autocorrelation leakage).
v0=int(n*.4); v1=int(n*.6); gap=int(n*.10)
val=idx[v0:v1]; train=np.concatenate([idx[:max(0,v0-gap)], idx[min(n,v1+gap):]])
return train, val
if kind=="grouped_bucket":
# hold out ENTIRE activity buckets -> val poses/activities never seen in train.
# the strictest leakage-free test (only when bucket labels exist).
b=np.array([x if x is not None else -1 for x in B])
uniq=[u for u in sorted(set(b.tolist())) if u!=-1]
if len(uniq)<3: raise ValueError("too few buckets")
hold=set(uniq[::max(1,len(uniq)//3)][:max(1,len(uniq)//3)]) # ~1/3 of activities held out
val=idx[np.isin(b,list(hold))]; train=idx[~np.isin(b,list(hold))]
return train, val
raise ValueError(kind)
def run(X,Y,V,tr,va,epochs=250,seed=0):
torch.manual_seed(seed); np.random.seed(seed) # seed weight init + batch shuffle
dev="cuda" if torch.cuda.is_available() else "cpu"
mu,sd=X[tr].mean(0),X[tr].std(0)+1e-6
Xtr=torch.tensor((X[tr]-mu)/sd).to(dev); Ytr=torch.tensor(Y[tr]).to(dev)
Xva=torch.tensor((X[va]-mu)/sd).to(dev)
net=Net(X.shape[1],Y.shape[1]).to(dev)
opt=torch.optim.Adam(net.parameters(),lr=1e-3,weight_decay=1e-4); lf=nn.MSELoss()
best=(1e9,None)
for ep in range(epochs):
net.train(); perm=torch.randperm(len(Xtr),device=dev)
for i in range(0,len(Xtr),64):
j=perm[i:i+64]; opt.zero_grad(); loss=lf(net(Xtr[j]),Ytr[j]); loss.backward(); opt.step()
net.eval()
with torch.no_grad(): pv=net(Xva).cpu().numpy()
vl=float(((pv-Y[va])**2).mean())
if vl<best[0]: best=(vl,pv)
base=np.tile(Y[tr].mean(0),(len(va),1))
return pck(best[1],Y[va],V[va]), pck(base,Y[va],V[va])
def main():
ap=argparse.ArgumentParser(); ap.add_argument("--data",required=True)
ap.add_argument("--epochs",type=int,default=250); ap.add_argument("--seeds",type=int,default=3)
a=ap.parse_args()
X,Y,V,B=load(a.data); n=len(X)
has_buckets=any(x is not None for x in B)
print(f"[ab] {n} samples, X={X.shape}, buckets={'yes' if has_buckets else 'no'}, "
f"seeds={a.seeds}, epochs={a.epochs}\n")
print(f"{'split':<22}{'model PCK@0.10':>16}{'baseline':>11}{'delta (mean±sd)':>20} verdict")
print("-"*86)
splits=["chronological_80_20","random_80_20","blocked_gap"]+(["grouped_bucket"] if has_buckets else [])
for kind in splits:
try:
tr,va=split_idx(n,kind,B)
ms=[]; bs=[]
for s in range(a.seeds):
m,b=run(X,Y,V,tr,va,a.epochs,seed=s); ms.append(m); bs.append(b)
ms=np.array(ms)*100; bs=np.array(bs)*100; ds=ms-bs
dm,dsd=ds.mean(),ds.std()
# REAL only if the mean delta minus 1 sd still clears the 1.5pp threshold (robust to seed variance)
verdict = "REAL signal" if dm-dsd>1.5 else ("weak/uncertain" if dm>1.5 else "no signal (==baseline)")
print(f"{kind:<22}{ms.mean():>13.1f}±{ms.std():>3.1f}{bs.mean():>10.1f}%{dm:>+12.1f}±{dsd:>4.1f}pp {verdict}")
except Exception as e:
print(f"{kind:<22} skipped: {e}")
print(f"\nmean±sd over {a.seeds} seeds (weight init + batch order). blocked_gap = 10% time gap each")
print("side; grouped_bucket holds out ENTIRE activities (strictest). If only the LEAKY splits")
print("(chronological/random) beat baseline, the apparent signal is leakage, not generalizable pose.")
if __name__=="__main__": main()
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""WiFlow-style camera-supervised capture (ADR-079 / ADR-180).
Runs on a box with BOTH a camera (ground truth) and reachable live CSI:
- opens a camera, runs MediaPipe Pose -> 17 COCO keypoints (the LABEL),
- subscribes to the sensing-server /ws/sensing (the INPUT: CSI features +
20x20 signal-field),
- writes timestamp-aligned (csi -> pose) pairs to a JSONL dataset.
This is the *collect* phase of camera-supervised CSI->pose training. The camera
and the CSI nodes MUST see the same person in the same space at the same time,
or the pairs are meaningless. Honest by construction: we only emit a pair when
BOTH a confident camera pose AND a live (source=esp32) CSI frame are present in
the same ~100 ms window.
Usage (on ruvultra, with the CSI tunneled to localhost:8765):
python3 wiflow_capture.py --ws ws://localhost:8765/ws/sensing \
--cam 0 --out ~/wiflow-room/dataset.jsonl --seconds 180
"""
import argparse, asyncio, json, time, threading, sys, os
from collections import deque
import urllib.request
import cv2
import numpy as np
import mediapipe as mp
from mediapipe.tasks.python import BaseOptions
from mediapipe.tasks.python.vision import PoseLandmarker, PoseLandmarkerOptions, RunningMode
import websockets
_MODEL_URL = ("https://storage.googleapis.com/mediapipe-models/pose_landmarker/"
"pose_landmarker_lite/float16/latest/pose_landmarker_lite.task")
def ensure_model(path: str) -> str:
if not os.path.exists(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
print(f"[capture] downloading pose model -> {path}", flush=True)
urllib.request.urlretrieve(_MODEL_URL, path)
return path
# MediaPipe Pose (33 landmarks) -> 17 COCO keypoints (same mapping as
# scripts/collect-ground-truth.py, ADR-079).
COCO_FROM_MP = [0, 2, 5, 7, 8, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28]
COCO_NAMES = ["nose","l_eye","r_eye","l_ear","r_ear","l_sho","r_sho","l_elb",
"r_elb","l_wri","r_wri","l_hip","r_hip","l_knee","r_knee","l_ank","r_ank"]
# ---- shared state between the CSI (async) thread and the camera (sync) loop ----
_latest_csi = {"t": 0.0, "frame": None}
_csi_lock = threading.Lock()
_stop = threading.Event()
def csi_thread(ws_url: str):
"""Background thread: keep the most recent LIVE csi frame in _latest_csi."""
async def run():
while not _stop.is_set():
try:
async with websockets.connect(ws_url, open_timeout=8, ping_interval=20) as ws:
while not _stop.is_set():
msg = await asyncio.wait_for(ws.recv(), timeout=8)
d = json.loads(msg)
with _csi_lock:
_latest_csi["t"] = time.time()
_latest_csi["frame"] = d
except Exception as e:
print(f"[csi] reconnect ({e})", flush=True)
await asyncio.sleep(1.0)
asyncio.new_event_loop().run_until_complete(run())
def csi_vector(frame: dict):
"""Flatten a csi frame to a fixed-length input vector: features + field."""
f = frame.get("features", {}) or {}
feats = [f.get("mean_rssi", 0.0), f.get("variance", 0.0),
f.get("motion_band_power", 0.0), f.get("breathing_band_power", 0.0)]
# per-node mean_rssi/variance/motion for up to the 2 nodes (9, 13)
pernode = {nf.get("node_id"): (nf.get("features") or {}) for nf in (frame.get("node_features") or [])}
for nid in (9, 13):
nf = pernode.get(nid, {})
feats += [nf.get("mean_rssi", 0.0), nf.get("variance", 0.0), nf.get("motion_band_power", 0.0)]
field = (frame.get("signal_field", {}) or {}).get("values") or []
field = (field + [0.0] * 400)[:400]
return feats + field # 4 + 6 + 400 = 410-d
def main():
ap = argparse.ArgumentParser(description="WiFlow camera-supervised CSI<->pose capture (ADR-180).")
ap.add_argument("--ws", default="ws://localhost:8765/ws/sensing")
ap.add_argument("--cam", type=int, default=0)
ap.add_argument("--out", default=os.path.expanduser("~/wiflow-room/dataset.jsonl"))
ap.add_argument("--seconds", type=int, default=180)
ap.add_argument("--min-vis", type=float, default=0.5, help="min mean landmark visibility to accept a pose label")
ap.add_argument("--max-skew-ms", type=float, default=150, help="max csi/pose time skew to pair")
ap.add_argument("--require-esp32", action="store_true", default=True,
help="only pair when csi source==esp32 (real). Default on.")
args = ap.parse_args()
os.makedirs(os.path.dirname(args.out), exist_ok=True)
th = threading.Thread(target=csi_thread, args=(args.ws,), daemon=True)
th.start()
cap = cv2.VideoCapture(args.cam)
if not cap.isOpened():
print(f"ERROR: cannot open camera {args.cam}", file=sys.stderr); sys.exit(2)
W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) or 640
H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) or 480
model_path = ensure_model(os.path.expanduser("~/wiflow-room/pose_landmarker_lite.task"))
landmarker = PoseLandmarker.create_from_options(PoseLandmarkerOptions(
base_options=BaseOptions(model_asset_path=model_path),
running_mode=RunningMode.IMAGE, min_pose_detection_confidence=0.5))
n_pairs = 0; n_nopose = 0; n_nocsi = 0; n_skew = 0; n_sim = 0
t0 = time.time()
print(f"[capture] camera {args.cam} {W}x{H} -> {args.out} for {args.seconds}s")
print("[capture] stand in view AND in the CSI field; move/walk so poses vary. Ctrl-C to stop.")
with open(args.out, "a") as out:
try:
while time.time() - t0 < args.seconds:
ok, frame = cap.read()
if not ok:
continue
now = time.time()
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
res = landmarker.detect(mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb))
if not res.pose_landmarks:
n_nopose += 1; continue
lm = res.pose_landmarks[0]
kps = [[lm[i].x, lm[i].y, lm[i].visibility] for i in COCO_FROM_MP]
vis = float(np.mean([k[2] for k in kps]))
if vis < args.min_vis:
n_nopose += 1; continue
with _csi_lock:
ct = _latest_csi["t"]; cf = _latest_csi["frame"]
if cf is None:
n_nocsi += 1; continue
if (now - ct) * 1000.0 > args.max_skew_ms:
n_skew += 1; continue
if args.require_esp32 and cf.get("source") != "esp32":
n_sim += 1; continue
rec = {"t": now, "vis": round(vis, 3),
"kps": [[round(x, 4), round(y, 4), round(v, 3)] for x, y, v in kps],
"csi": csi_vector(cf),
"src": cf.get("source"),
"nodes": sorted(n.get("node_id") for n in cf.get("nodes", []) if n.get("node_id") is not None)}
out.write(json.dumps(rec) + "\n")
n_pairs += 1
if n_pairs % 30 == 0:
out.flush()
el = int(now - t0)
print(f"[capture] t+{el:3d}s pairs={n_pairs} (skip: nopose={n_nopose} nocsi={n_nocsi} skew={n_skew} sim={n_sim})", flush=True)
except KeyboardInterrupt:
print("\n[capture] stopped by user")
_stop.set(); cap.release()
print(f"[capture] DONE. wrote {n_pairs} paired samples to {args.out}")
print(f"[capture] skipped: no-pose={n_nopose} no-csi={n_nocsi} skew={n_skew} simulated={n_sim}")
if n_pairs == 0:
print("[capture] WARNING: 0 pairs — check camera sees you AND csi source==esp32 (live).")
if __name__ == "__main__":
main()
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Live CSI->pose inference bridge (ADR-180).
Runs on the box with the live CSI. Loads the camera-supervised model (numpy,
no torch needed), subscribes to /ws/sensing, runs a forward pass per frame, and
broadcasts the predicted 17-keypoint pose to HTML clients on ws://:8770/pose.
python wiflow_infer.py --model model/model.npz \
--in ws://localhost:8765/ws/sensing --port 8770
"""
import argparse, asyncio, json, os
import numpy as np
import websockets
# COCO skeleton edges (for the client; sent once in 'meta')
EDGES = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],
[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]]
def csi_vector(frame):
f = frame.get("features", {}) or {}
feats = [f.get("mean_rssi",0.0), f.get("variance",0.0),
f.get("motion_band_power",0.0), f.get("breathing_band_power",0.0)]
pernode = {nf.get("node_id"): (nf.get("features") or {}) for nf in (frame.get("node_features") or [])}
for nid in (9,13):
nf = pernode.get(nid,{}); feats += [nf.get("mean_rssi",0.0), nf.get("variance",0.0), nf.get("motion_band_power",0.0)]
field = (frame.get("signal_field",{}) or {}).get("values") or []
field = (field + [0.0]*400)[:400]
return np.array(feats + field, np.float32)
class Model:
def __init__(self, path):
z = np.load(path)
self.mu, self.sd = z["mu"], z["sd"]
self.W = [z["net_0_weight"], z["net_3_weight"], z["net_6_weight"], z["net_8_weight"]]
self.b = [z["net_0_bias"], z["net_3_bias"], z["net_6_bias"], z["net_8_bias"]]
def __call__(self, x):
h = (x - self.mu) / self.sd
for i in range(3):
h = np.maximum(0.0, h @ self.W[i].T + self.b[i]) # Linear+ReLU
out = 1.0/(1.0+np.exp(-(h @ self.W[3].T + self.b[3]))) # Linear+Sigmoid -> 34
return out.reshape(17,2)
CLIENTS = set()
LATEST = {"pose": None}
async def serve_client(ws):
CLIENTS.add(ws)
try:
await ws.send(json.dumps({"type":"meta","edges":EDGES}))
async for _ in ws: # client is read-only; just keep alive
pass
except Exception:
pass
finally:
CLIENTS.discard(ws)
async def infer_loop(model, in_url):
while True:
try:
async with websockets.connect(in_url, open_timeout=8, ping_interval=20) as ws:
async for msg in ws:
d = json.loads(msg)
kp = model(csi_vector(d))
cls = d.get("classification",{})
payload = {"type":"pose","src":d.get("source"),
"presence":bool(cls.get("presence")),
"motion":(d.get("features",{}) or {}).get("motion_band_power"),
"kps":[[round(float(x),4),round(float(y),4)] for x,y in kp],
"nodes":sorted(n.get("node_id") for n in d.get("nodes",[]) if n.get("node_id") is not None)}
LATEST["pose"]=payload
if CLIENTS:
dead=[]
for c in list(CLIENTS):
try: await c.send(json.dumps(payload))
except Exception: dead.append(c)
for c in dead: CLIENTS.discard(c)
except Exception as e:
print(f"[infer] reconnect ({e})", flush=True); await asyncio.sleep(1.0)
async def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", default=os.path.join(os.path.dirname(__file__),"model","model.npz"))
ap.add_argument("--in", dest="in_url", default="ws://localhost:8765/ws/sensing")
ap.add_argument("--port", type=int, default=8770)
args = ap.parse_args()
model = Model(args.model)
print(f"[infer] model {args.model} loaded; serving predicted poses on ws://0.0.0.0:{args.port}/pose")
async with websockets.serve(serve_client, "0.0.0.0", args.port):
await infer_loop(model, args.in_url)
if __name__ == "__main__":
asyncio.run(main())
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Train a CSI->pose model on the camera-supervised dataset (ADR-079/180).
Input : 410-d CSI vector (4 global feats + 6 per-node + 400 signal-field).
Target : 17 COCO keypoints (x,y), normalized 0..1 from the camera (ground truth).
Reports HONEST held-out PCK@k + MPJPE on a chronological val split (the last
20% of the session — never trained on), so the number is not leaked.
Usage (ruvultra venv):
python wiflow_train.py --data ~/wiflow-room/dataset.jsonl --out ~/wiflow-room/model.pt
"""
import argparse, json, math, os, sys
import numpy as np
import torch, torch.nn as nn
def load(path):
X, Y, V = [], [], []
with open(path) as f:
for line in f:
r = json.loads(line)
X.append(r["csi"]) # 410
kp = r["kps"] # 17 x [x,y,vis]
Y.append([c for k in kp for c in (k[0], k[1])]) # 34
V.append([k[2] for k in kp]) # 17 visibilities
return np.array(X, np.float32), np.array(Y, np.float32), np.array(V, np.float32)
class Net(nn.Module):
def __init__(self, din, dout):
super().__init__()
self.net = nn.Sequential(
nn.Linear(din, 512), nn.ReLU(), nn.Dropout(0.3),
nn.Linear(512, 256), nn.ReLU(), nn.Dropout(0.3),
nn.Linear(256, 128), nn.ReLU(),
nn.Linear(128, dout), nn.Sigmoid()) # coords in 0..1
def forward(self, x): return self.net(x)
def pck(pred, gt, vis, thr):
# pred/gt: [N,34] -> [N,17,2]; PCK@thr in normalized image units, visible kps only
p = pred.reshape(-1, 17, 2); g = gt.reshape(-1, 17, 2)
d = np.linalg.norm(p - g, axis=2) # [N,17]
m = vis > 0.5
return float((d[m] < thr).mean()) if m.any() else 0.0, float(d[m].mean()) if m.any() else float("nan")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--data", required=True)
ap.add_argument("--out", default=os.path.expanduser("~/wiflow-room/model.pt"))
ap.add_argument("--epochs", type=int, default=300)
ap.add_argument("--bs", type=int, default=64)
args = ap.parse_args()
X, Y, V = load(args.data)
n = len(X)
print(f"[train] {n} samples, X={X.shape} Y={Y.shape}")
if n < 200:
print("[train] too few samples"); sys.exit(2)
# chronological split (NOT shuffled) so val is a held-out time segment -> honest
cut = int(n * 0.8)
mu, sd = X[:cut].mean(0), X[:cut].std(0) + 1e-6 # standardize on train only
Xn = (X - mu) / sd
dev = "cuda" if torch.cuda.is_available() else "cpu"
Xtr = torch.tensor(Xn[:cut]).to(dev); Ytr = torch.tensor(Y[:cut]).to(dev)
Xva = torch.tensor(Xn[cut:]).to(dev); Yva = Y[cut:]; Vva = V[cut:]
# mean-pose baseline (predict the train-mean pose for everything) — the bar to beat
mean_pose = Y[:cut].mean(0)
base_pck, base_mpjpe = pck(np.tile(mean_pose, (len(Yva), 1)), Yva, Vva, 0.10)
net = Net(X.shape[1], Y.shape[1]).to(dev)
opt = torch.optim.Adam(net.parameters(), lr=1e-3, weight_decay=1e-4)
lossf = nn.MSELoss()
best = (1e9, None)
for ep in range(args.epochs):
net.train(); perm = torch.randperm(len(Xtr), device=dev)
for i in range(0, len(Xtr), args.bs):
idx = perm[i:i+args.bs]
opt.zero_grad(); out = net(Xtr[idx]); loss = lossf(out, Ytr[idx]); loss.backward(); opt.step()
if (ep + 1) % 20 == 0 or ep == args.epochs - 1:
net.eval()
with torch.no_grad(): pv = net(Xva).cpu().numpy()
p10, mpj = pck(pv, Yva, Vva, 0.10); p05, _ = pck(pv, Yva, Vva, 0.05)
vloss = float(((pv - Yva) ** 2).mean())
print(f"[train] ep{ep+1:3d} val_mse={vloss:.4f} PCK@0.10={p10*100:.1f}% PCK@0.05={p05*100:.1f}% MPJPE={mpj:.4f}")
if vloss < best[0]: best = (vloss, {"sd": net.state_dict(), "p10": p10, "p05": p05, "mpj": mpj})
torch.save({"model": best[1]["sd"], "mu": mu, "sd": sd, "din": X.shape[1]}, args.out)
print("\n==================== HONEST RESULT (held-out 20%, never trained) ====================")
print(f" MEAN-POSE BASELINE : PCK@0.10 = {base_pck*100:.1f}% MPJPE = {base_mpjpe:.4f} (the bar to beat)")
print(f" CSI->POSE MODEL : PCK@0.10 = {best[1]['p10']*100:.1f}% PCK@0.05 = {best[1]['p05']*100:.1f}% MPJPE = {best[1]['mpj']:.4f}")
delta = (best[1]['p10'] - base_pck) * 100
print(f" VERDICT: model {'BEATS' if delta>1 else 'does NOT beat'} mean-pose baseline by {delta:+.1f} pp "
f"-> {'real CSI->pose signal' if delta>1 else 'NO usable CSI->pose signal (honest negative)'}")
print(f" saved -> {args.out}")
if __name__ == "__main__":
main()
@@ -468,3 +468,29 @@ menu "Mock CSI (QEMU Testing)"
depends on CSI_MOCK_ENABLED
default n
endmenu
menu "Onboard LED (ADR-183)"
config LED_GAMMA_VIZ
bool "Onboard WS2812: 40 Hz gamma flicker + CSI-motion colour"
default y
help
Drive the onboard WS2812 as a GENUS-style 40 Hz gamma square wave
(12.5 ms on / 12.5 ms off, 50% duty). The ON-phase colour is live
CSI motion (edge motion_energy) mapped through the ruv-neural-viz
viridis colormap (still=purple, moving=yellow).
Disable to leave the LED off at boot — lower power, no flicker.
NOTE: a 40 Hz flicker can affect photosensitive users; disable or
shield the LED in those environments. Not a medical device.
config LED_MOTION_FULLSCALE_MILLI
int "Motion value (x1000) that saturates the colormap to yellow"
depends on LED_GAMMA_VIZ
default 250
range 1 100000
help
edge motion_energy that maps to the top (yellow) of the viridis
colormap, in milli-units (250 = 0.25). Lower = more sensitive
(reaches yellow with less motion).
endmenu
@@ -319,7 +319,9 @@ static void emit_feature_state(void)
(uint64_t)esp_timer_get_time(),
profile);
int sent = stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
/* feature_state is ~1 Hz and small — priority path so the CSI ENOMEM
* backoff can't starve it (#1183). */
int sent = stream_sender_send_priority((const uint8_t *)&pkt, sizeof(pkt));
if (sent < 0) {
ESP_LOGW(TAG, "feature_state emit failed");
}
@@ -333,11 +335,14 @@ static void slow_loop_cb(TimerHandle_t t)
* detect sync-error drift. */
uint8_t nid[8];
node_id_bytes(nid);
rv_mesh_send_health(s_role, s_mesh_epoch, nid);
/* #1183: report the actual send result — the old log printed "HEALTH sent"
* unconditionally even when rv_mesh_send returned ESP_FAIL. */
esp_err_t health_rc = rv_mesh_send_health(s_role, s_mesh_epoch, nid);
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u, role=%u, epoch=%u) HEALTH sent",
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u, role=%u, epoch=%u) HEALTH %s",
(unsigned)s_state, (unsigned)s_feature_state_seq,
(unsigned)s_role, (unsigned)s_mesh_epoch);
(unsigned)s_role, (unsigned)s_mesh_epoch,
health_rc == ESP_OK ? "sent" : "FAILED");
}
/* ---- Public API ---- */
+3 -1
View File
@@ -341,7 +341,9 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
memcpy(&sync[24], &s_sequence, 4); /* high-water seq for pairing */
uint32_t zero32 = 0;
memcpy(&sync[28], &zero32, 4); /* reserved (room for leader_id low32) */
int sr = stream_sender_send(sync, sizeof(sync));
/* Sync packets are 32 B at ~0.5 Hz — priority path so the CSI
* ENOMEM backoff can't starve cross-node time alignment (#1183). */
int sr = stream_sender_send_priority(sync, sizeof(sync));
static uint32_t s_sync_count = 0;
s_sync_count++;
if (s_sync_count <= 3 || (s_sync_count % 60) == 0) {
@@ -114,6 +114,19 @@ esp_err_t display_task_start(void)
/* Init touch (optional) */
esp_err_t touch_ret = display_hal_init_touch();
/* The SH8601 QSPI panel is write-only — display_hal_init_panel() above "succeeds"
* even on a bare board with no panel attached, so it cannot detect absence. The
* FT3168 touch controller is an I2C device with readback and is always present on
* the Touch-AMOLED board. If touch is absent, the panel "success" was a false-
* positive on a display-less DevKit: bail to headless so display_is_active() stays
* false and CSI upgrades to MGMT+DATA capture instead of starving at MGMT-only
* (RuView#1000). */
if (touch_ret != ESP_OK) {
ESP_LOGW(TAG, "No FT3168 touch readback — SH8601 probe was a false-positive on a "
"display-less board; running headless so CSI captures (#1000)");
return ESP_OK;
}
/* Initialize LVGL */
lv_init();
+71 -5
View File
@@ -144,6 +144,54 @@ static void wifi_init_sta(void)
}
}
#if CONFIG_LED_GAMMA_VIZ
/* Viridis colormap (60 steps), generated from ruv-neural-viz::ColorMap::viridis()
* the rUv-Neural brain-topology colormap, now no_std (ruvnet/ruv-neural#3 /
* RuView#1126). Used as the ON-phase colour of the 40 Hz gamma flicker below:
* dark-purple (still) -> teal -> green -> yellow (strong motion). */
static const uint8_t VIRIDIS_LUT[60][3] = {
{ 68, 1, 84},{ 67, 6, 88},{ 67, 12, 91},{ 66, 17, 95},{ 66, 23, 99},
{ 65, 28,103},{ 64, 34,106},{ 64, 39,110},{ 63, 45,114},{ 63, 50,118},
{ 62, 56,121},{ 61, 61,125},{ 61, 67,129},{ 60, 72,132},{ 59, 78,136},
{ 59, 83,139},{ 57, 87,139},{ 55, 92,139},{ 53, 96,139},{ 52,100,139},
{ 50,104,139},{ 48,109,139},{ 46,113,139},{ 44,117,140},{ 43,122,140},
{ 41,126,140},{ 39,130,140},{ 37,134,140},{ 36,139,140},{ 34,143,140},
{ 35,147,139},{ 39,151,136},{ 43,154,133},{ 47,158,130},{ 52,162,127},
{ 56,166,124},{ 60,170,121},{ 64,173,119},{ 68,177,116},{ 72,181,113},
{ 76,185,110},{ 81,189,107},{ 85,192,104},{ 89,196,102},{ 93,200, 99},
{102,203, 95},{113,205, 91},{124,207, 87},{134,209, 82},{145,211, 78},
{156,213, 74},{167,215, 70},{178,217, 66},{188,219, 62},{199,221, 58},
{210,223, 54},{221,225, 49},{231,227, 45},{242,229, 41},{253,231, 37},
};
static led_strip_handle_t s_viz_led;
/* motion_energy that saturates the colormap to yellow (CONFIG, milli-units). */
#define LED_MOTION_FULLSCALE ((float)CONFIG_LED_MOTION_FULLSCALE_MILLI / 1000.0f)
/* GENUS-style 40 Hz gamma flicker: full on/off square wave, 50% duty (toggled
* every 12.5 ms 40 Hz). The ON colour is live CSI motion (edge motion_energy)
* mapped through the ruv-neural-viz viridis LUT still=purple, moving=yellow.
* So the LED is a real 40 Hz gamma stimulus whose hue tracks sensed motion. */
static void led_gamma_40hz_cb(void *arg)
{
static bool on = false;
on = !on;
if (on) {
edge_vitals_pkt_t v;
float m = edge_get_vitals(&v) ? v.motion_energy : 0.0f;
float norm = m / LED_MOTION_FULLSCALE;
if (norm < 0.0f) norm = 0.0f;
if (norm > 1.0f) norm = 1.0f;
int idx = (int)(norm * 59.0f + 0.5f);
const uint8_t *c = VIRIDIS_LUT[idx];
led_strip_set_pixel(s_viz_led, 0, c[0], c[1], c[2]); /* R,G,B (driver maps to GRB) */
} else {
led_strip_set_pixel(s_viz_led, 0, 0, 0, 0); /* off phase */
}
led_strip_refresh(s_viz_led);
}
#endif /* CONFIG_LED_GAMMA_VIZ */
void app_main(void)
{
/* Initialize NVS */
@@ -173,15 +221,16 @@ void app_main(void)
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
target_name, app_desc->version, g_nvs_config.node_id);
/* Turn off onboard WS2812 LED.
* S3 dev boards put the LED on GPIO 38; C6 dev boards on GPIO 8.
* On C6, GPIO 38 doesn't exist (only 0-30) gate the init by target. */
/* Onboard WS2812. C6 wires the LED to GPIO 8; S3 to GPIO 38 (DevKitC-1 v1.0)
* or GPIO 48 (DevKitC-1 v1.1 / N16R8 see #962). On S3 we drive 48 (the
* common module). On C6, GPIO 38/48 don't exist (only 0-30) gate by target.
* Behaviour is set by CONFIG_LED_GAMMA_VIZ (ADR-183): on = 40 Hz gamma flicker
* coloured by CSI motion; off = clear the LED at boot. */
#if defined(CONFIG_IDF_TARGET_ESP32C6)
const int led_gpio = 8;
#else
const int led_gpio = 38;
const int led_gpio = 48;
#endif
led_strip_handle_t led_strip;
led_strip_config_t strip_config = {
.strip_gpio_num = led_gpio,
.max_leds = 1,
@@ -193,9 +242,26 @@ void app_main(void)
.resolution_hz = 10 * 1000 * 1000, // 10MHz
.flags.with_dma = false,
};
#if CONFIG_LED_GAMMA_VIZ
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &s_viz_led) == ESP_OK) {
const esp_timer_create_args_t viz_args = {
.callback = &led_gamma_40hz_cb,
.name = "led_gamma_40hz",
};
esp_timer_handle_t viz_timer;
if (esp_timer_create(&viz_args, &viz_timer) == ESP_OK) {
esp_timer_start_periodic(viz_timer, 12500); // 12.5 ms toggle → 40 Hz square wave
ESP_LOGI(TAG, "Onboard WS2812: 40 Hz gamma flicker (GENUS), colour=CSI motion via ruv-neural-viz, GPIO %d", led_gpio);
}
}
#else
/* Viz disabled — clear the onboard LED at boot and release the RMT channel. */
led_strip_handle_t led_strip;
if (led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip) == ESP_OK) {
led_strip_clear(led_strip);
led_strip_del(led_strip);
}
#endif /* CONFIG_LED_GAMMA_VIZ */
/* ADR-110 P4: 802.15.4 mesh time-sync (C6 only).
* Initialized BEFORE WiFi so it's available even when WiFi STA can't
@@ -0,0 +1,37 @@
/**
* @file mmwave_detect.h
* @brief Pure (host-testable) mmWave frame-validation predicates for probe-time
* sensor detection. No ESP-IDF deps safe to #include in a host unit test.
*
* Detection must validate a *full* frame, never a bare header byte/pattern: a
* floating UART with no sensor reads line noise that can contain header-looking
* bytes, which the old loose checks mistook for a real sensor (#1107 MR60,
* #1135 LD2410). These predicates are the validate-before-trust gate.
*/
#ifndef MMWAVE_DETECT_H
#define MMWAVE_DETECT_H
#include <stdint.h>
#include <stdbool.h>
/**
* True iff buf[i..] begins a *validated* LD2410 report frame within [0,len):
* F4 F3 F2 F1 | len(LE,2) | data[len] | F8 F7 F6 F5
* Requires the head magic, a sane intra-frame length, AND the matching tail at
* head+6+len. Pure noise that merely contains 0xF4F3F2F1 fails the tail check.
*/
static inline bool mmwave_ld2410_valid_at(const uint8_t *buf, int i, int len)
{
if (i < 0 || i + 5 >= len) return false;
if (!(buf[i] == 0xF4 && buf[i+1] == 0xF3 && buf[i+2] == 0xF2 && buf[i+3] == 0xF1))
return false;
uint16_t flen = (uint16_t)buf[i+4] | ((uint16_t)buf[i+5] << 8);
/* Real LD2410 report frames are small (basic=13, engineering=35). */
if (flen < 1 || flen > 64) return false;
int tail = i + 6 + (int)flen;
if (tail + 3 >= len) return false;
return buf[tail] == 0xF8 && buf[tail+1] == 0xF7
&& buf[tail+2] == 0xF6 && buf[tail+3] == 0xF5;
}
#endif /* MMWAVE_DETECT_H */
+22 -10
View File
@@ -26,6 +26,7 @@
*/
#include "mmwave_sensor.h"
#include "mmwave_detect.h"
#include <string.h>
#include <math.h>
@@ -387,14 +388,26 @@ static mmwave_type_t probe_at_baud(uint32_t baud)
if (len <= 0) continue;
for (int i = 0; i < len; i++) {
/* MR60BHA2: SOF = 0x01, followed by valid-looking frame_id bytes */
if (buf[i] == MR60_SOF && baud == MMWAVE_MR60_BAUD) {
mr60_sof_seen++;
/* MR60BHA2: require a *validated* 8-byte header — SOF (0x01) + a valid
* header checksum (over bytes 0..6) + a known frame type (0x0A__ or
* 0x0F09) NOT a bare 0x01 byte. A floating UART1 with no sensor reads
* noise full of 0x01s, which the old `buf[i] == MR60_SOF` check mistook
* for a real sensor (false "Detected MR60BHA2", #1107). */
if (buf[i] == MR60_SOF && baud == MMWAVE_MR60_BAUD && i + 7 < len) {
const uint8_t *h = &buf[i];
if (mr60_calc_checksum(h, 7) == h[7]) {
uint16_t type = ((uint16_t)h[5] << 8) | h[6];
if ((type >> 8) == 0x0A || type == 0x0F09) {
mr60_sof_seen++;
}
}
}
/* LD2410: 4-byte header 0xF4F3F2F1 */
if (i + 3 < len && buf[i] == 0xF4 && buf[i+1] == 0xF3
&& buf[i+2] == 0xF2 && buf[i+3] == 0xF1
&& baud == MMWAVE_LD2410_BAUD) {
/* LD2410: require a *full validated* report frame, not just the
* 4-byte head. A floating UART1 at 256000 baud can emit the head
* pattern 0xF4F3F2F1 from line noise (#1135 bug #2). The shared
* predicate (host-unit-tested in mmwave_detect.h) demands a sane
* intra-frame length AND the matching tail 0xF8F7F6F5. */
if (baud == MMWAVE_LD2410_BAUD && mmwave_ld2410_valid_at(buf, i, len)) {
ld2410_header_seen++;
}
}
@@ -403,9 +416,8 @@ static mmwave_type_t probe_at_baud(uint32_t baud)
if (ld2410_header_seen >= 2) return MMWAVE_TYPE_LD2410;
}
if (mr60_sof_seen > 0) return MMWAVE_TYPE_MR60BHA2;
if (ld2410_header_seen > 0) return MMWAVE_TYPE_LD2410;
/* No weak single-hit fallback: line noise can produce a stray match, so a real
* sensor must clear the 3 (MR60) / 2 (LD2410) validated-frame thresholds. */
return MMWAVE_TYPE_NONE;
}
+3 -1
View File
@@ -188,7 +188,9 @@ size_t rv_mesh_encode_calibration_start(uint8_t sender_role,
esp_err_t rv_mesh_send(const uint8_t *frame, size_t len)
{
if (frame == NULL || len == 0) return ESP_ERR_INVALID_ARG;
int sent = stream_sender_send(frame, len);
/* Mesh control packets (HEALTH, anomaly) are low-rate and tiny — send them
* on the priority path so the CSI ENOMEM backoff can't starve them (#1183). */
int sent = stream_sender_send_priority(frame, len);
if (sent < 0) {
ESP_LOGW(TAG, "rv_mesh_send: stream_sender failed (len=%u)",
(unsigned)len);
+48 -5
View File
@@ -26,9 +26,16 @@ static struct sockaddr_in s_dest_addr;
* rapid-fire CSI callbacks can exhaust the pbuf pool and crash the device.
*/
static int64_t s_backoff_until_us = 0; /* esp_timer timestamp to resume */
#define ENOMEM_COOLDOWN_MS 100 /* suppress sends for 100 ms */
#define ENOMEM_COOLDOWN_MS 100 /* base backoff; doubles per streak */
#define ENOMEM_COOLDOWN_MAX_MS 2000 /* cap on the exponential backoff */
#define ENOMEM_LOG_INTERVAL 50 /* log every Nth suppressed send */
static uint32_t s_enomem_suppressed = 0;
/* Consecutive ENOMEM episodes without an intervening successful send. A fixed
* 100 ms backoff is too short to drain sustained lwIP/WiFi buffer pressure
* (#1135 bug #1: tier-2 + concurrent TX keeps the node stuck), so the backoff
* grows 1002004002000 ms per streak and resets on the first send that
* succeeds. */
static uint32_t s_enomem_streak = 0;
static int sender_init_internal(const char *ip, uint16_t port)
{
@@ -93,16 +100,52 @@ int stream_sender_send(const uint8_t *data, size_t len)
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
if (sent < 0) {
if (errno == ENOMEM) {
/* Start backoff to let lwIP reclaim buffers */
s_backoff_until_us = esp_timer_get_time() +
(int64_t)ENOMEM_COOLDOWN_MS * 1000;
ESP_LOGW(TAG, "sendto ENOMEM — backing off for %d ms", ENOMEM_COOLDOWN_MS);
/* Exponential backoff: double the cooldown each consecutive ENOMEM
* (capped) so sustained buffer pressure actually drains instead of
* the node re-failing every 100 ms forever (#1135 bug #1). */
uint32_t shift = s_enomem_streak < 5 ? s_enomem_streak : 5;
uint32_t cooldown = ENOMEM_COOLDOWN_MS << shift;
if (cooldown > ENOMEM_COOLDOWN_MAX_MS) cooldown = ENOMEM_COOLDOWN_MAX_MS;
s_enomem_streak++;
s_backoff_until_us = esp_timer_get_time() + (int64_t)cooldown * 1000;
ESP_LOGW(TAG, "sendto ENOMEM — backing off for %lu ms (streak %lu)",
(unsigned long)cooldown, (unsigned long)s_enomem_streak);
} else {
ESP_LOGW(TAG, "sendto failed: errno %d", errno);
}
return -1;
}
/* A send got through — buffer pressure cleared; reset the backoff streak. */
s_enomem_streak = 0;
return sent;
}
int stream_sender_send_priority(const uint8_t *data, size_t len)
{
if (s_sock < 0) {
return -1;
}
/* Priority path (#1183): low-rate control packets (feature_state, HEALTH,
* mesh sync) bypass the global ENOMEM backoff gate so the high-rate CSI
* stream cannot starve them. These are 48 B at 1 Hz negligible pbuf
* pressure, so they won't re-trigger the crash cascade that the backoff
* (driven by the 50 Hz CSI flood) exists to prevent.
*
* Crucially, an ENOMEM here is reported quietly and does NOT extend the
* global streak/backoff: a tiny control packet failing is a symptom of
* the bulk-stream pressure, not a cause, so it must not feed the cooldown
* that suppresses the next CSI frame. Likewise a success does not reset
* the streak the bulk path owns that signal. */
int sent = sendto(s_sock, data, len, 0,
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
if (sent < 0) {
if (errno != ENOMEM) {
ESP_LOGW(TAG, "priority sendto failed: errno %d", errno);
}
return -1;
}
return sent;
}
@@ -36,6 +36,20 @@ int stream_sender_init_with(const char *ip, uint16_t port);
*/
int stream_sender_send(const uint8_t *data, size_t len);
/**
* Send a low-rate control packet, bypassing the ENOMEM backoff gate (#1183).
*
* Intended for 48 B, 1 Hz control traffic (feature_state, HEALTH, mesh
* sync) that must not be starved by the global backoff the high-rate CSI
* stream triggers. An ENOMEM on this path is reported quietly and does NOT
* extend or reset the global backoff streak.
*
* @param data Frame data buffer.
* @param len Length of data to send.
* @return Number of bytes sent, or -1 on error.
*/
int stream_sender_send_priority(const uint8_t *data, size_t len);
/**
* Close the UDP sender socket.
*/
+15 -4
View File
@@ -44,9 +44,9 @@ FUZZ_DURATION ?= 30
FUZZ_JOBS ?= 1
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
test_vitals run_vitals host_tests
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect
# --- ADR-110 encoding unit tests ---
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
@@ -69,8 +69,19 @@ test_vitals: test_vitals_count_presence.c $(MAIN_DIR)/edge_processing.h
run_vitals: test_vitals
./test_vitals
host_tests: run_adr110 run_vitals
@echo "Host tests passed (ADR-110 + vitals #998/#996)"
# --- mmWave LD2410 detection predicate (#1135 bug #2) ---
# Host-side, no libFuzzer. Proves a floating-UART head pattern (0xF4F3F2F1)
# without a valid frame length+tail is REJECTED, so a phantom LD2410 is never
# detected on a node with no sensor wired. Tests the real predicate the
# firmware uses (../main/mmwave_detect.h) — test and firmware can't disagree.
test_mmwave_detect: test_mmwave_detect.c $(MAIN_DIR)/mmwave_detect.h
cc -std=c99 -Wall -Wextra -I$(MAIN_DIR) -o $@ $<
run_mmwave_detect: test_mmwave_detect
./test_mmwave_detect
host_tests: run_adr110 run_vitals run_mmwave_detect
@echo "Host tests passed (ADR-110 + vitals #998/#996 + mmwave detect #1135)"
# --- Serialize fuzzer ---
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
@@ -0,0 +1,80 @@
/**
* @file test_mmwave_detect.c
* @brief Host-side unit tests for the LD2410 frame-validation predicate (#1135).
*
* Proves the phantom-detection fix: a floating UART can emit the 4-byte head
* 0xF4F3F2F1, but the predicate rejects it unless a sane length + matching tail
* 0xF8F7F6F5 are also present. Tests the REAL predicate from mmwave_detect.h
* (the same code the firmware's probe_at_baud calls).
*
* cc -std=c99 -Wall -I../main -o test_mmwave_detect test_mmwave_detect.c && ./test_mmwave_detect
*
* Exits 0 on all-pass; prints the failing case otherwise.
*/
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include "mmwave_detect.h"
static int failures = 0;
#define CHECK(cond, msg) do { \
if (!(cond)) { printf("FAIL: %s\n", msg); failures++; } \
else { printf("ok: %s\n", msg); } \
} while (0)
/* Build a valid LD2410 report frame: F4F3F2F1 | len(LE) | data[len] | F8F7F6F5 */
static int make_frame(uint8_t *out, uint16_t dlen)
{
int n = 0;
out[n++] = 0xF4; out[n++] = 0xF3; out[n++] = 0xF2; out[n++] = 0xF1;
out[n++] = (uint8_t)(dlen & 0xFF); out[n++] = (uint8_t)(dlen >> 8);
for (uint16_t k = 0; k < dlen; k++) out[n++] = (uint8_t)(0xAA ^ k);
out[n++] = 0xF8; out[n++] = 0xF7; out[n++] = 0xF6; out[n++] = 0xF5;
return n;
}
int main(void)
{
uint8_t buf[256];
/* 1. A real basic-report frame (data len 13) validates. */
int n = make_frame(buf, 13);
CHECK(mmwave_ld2410_valid_at(buf, 0, n), "valid basic frame (len=13) accepted");
/* 2. A real engineering-report frame (data len 35) validates. */
n = make_frame(buf, 35);
CHECK(mmwave_ld2410_valid_at(buf, 0, n), "valid engineering frame (len=35) accepted");
/* 3. Head magic present but NO valid tail — the #1135 phantom case. */
memset(buf, 0x00, sizeof(buf));
buf[0]=0xF4; buf[1]=0xF3; buf[2]=0xF2; buf[3]=0xF1; buf[4]=13; buf[5]=0;
/* data present but tail is zeros, not F8F7F6F5 */
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "head magic without valid tail REJECTED (#1135)");
/* 4. Head magic with insane length is rejected. */
memset(buf, 0xFF, sizeof(buf));
buf[0]=0xF4; buf[1]=0xF3; buf[2]=0xF2; buf[3]=0xF1; buf[4]=0xFF; buf[5]=0xFF; /* len=65535 */
CHECK(!mmwave_ld2410_valid_at(buf, 0, 200), "head magic with oversized length REJECTED");
/* 5. Pure noise (no head) is rejected. */
for (int k = 0; k < 64; k++) buf[k] = (uint8_t)(0x5A + k);
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "non-header noise REJECTED");
/* 6. Truncated frame (tail would run past the buffer) is rejected. */
n = make_frame(buf, 13);
CHECK(!mmwave_ld2410_valid_at(buf, 0, n - 2), "truncated frame (tail past buffer) REJECTED");
/* 7. Valid frame at a non-zero offset still validates. */
memset(buf, 0x00, sizeof(buf));
n = make_frame(buf + 7, 13);
CHECK(mmwave_ld2410_valid_at(buf, 7, 7 + n), "valid frame at offset 7 accepted");
/* 8. Repeated head bytes without a frame (worst-case noise) rejected. */
for (int k = 0; k + 3 < 64; k += 4) {
buf[k]=0xF4; buf[k+1]=0xF3; buf[k+2]=0xF2; buf[k+3]=0xF1;
}
CHECK(!mmwave_ld2410_valid_at(buf, 0, 64), "repeated bare head bytes REJECTED");
printf("\n%s (%d failures)\n", failures ? "FAILED" : "ALL PASS", failures);
return failures ? 1 : 0;
}
+18
View File
@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(npx ruview*)",
"mcp__ruview__*"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)"
]
},
"mcpServers": {
"ruview": {
"command": "npx",
"args": ["-y", "@ruvnet/ruview", "mcp", "start"]
}
}
}
@@ -0,0 +1,29 @@
---
name: calibrate-room
description: Run the ADR-151 per-room calibration pipeline — baseline → enroll → extract → train → a bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly).
---
# calibrate-room
Turn a provisioned node + sensing-server into a working room model. Pure-Rust,
edge-deployable (ADR-151). Use the `ruview_calibrate` tool (installed
`wifi-densepose` binary, else `cargo run -p wifi-densepose-cli`).
## Sequence
1. **baseline** — capture the empty room (Welford amplitude + von Mises phase). Leave
the room empty.
`ruview_calibrate {step: "baseline"}`
2. **enroll** — record the occupant(s) doing the target activities.
`ruview_calibrate {step: "enroll"}`
3. **train-room** — train the bank of small specialists from baseline + enrollment.
`ruview_calibrate {step: "train-room"}`
4. **room-watch** — live presence/posture/breathing from the trained room.
`ruview_calibrate {step: "room-watch"}` (or the `room-watch` skill)
## Honesty
The specialists are calibrated to *this* room; cross-room transfer is a separate
problem (LoRA recalibration, ADR-079 P9). Report which room a number came from, and
tag presence/vitals accuracy MEASURED only with a held-out check — run
`ruview_claim_check` on the writeup.
@@ -0,0 +1,30 @@
---
name: onboard
description: Zero-to-sensing path picker for RuView (WiFi-DensePose) — pick docker-demo, repo-build, or live-esp32 and run the next concrete step.
---
# onboard
Get a newcomer from nothing to a working RuView setup. **First fact to set:** WiFi
sensing infers *coarse* pose/presence/breathing from Channel State Information — it
is **not a camera**, and any accuracy number must be MEASURED against a baseline
(use the `verify` skill / `ruview_claim_check` tool). Never present WiFi output as
camera-grade.
## Pick a path
Run `ruview_onboard {path}` or decide from:
1. **docker-demo** — fastest, no hardware. Replays sample CSI into the dashboard.
`docker run -p 8000:8000 ruvnet/wifi-densepose` → open `http://localhost:8000`.
Use to see what it looks like.
2. **repo-build** — for developers. `cd v2 && cargo test --workspace --no-default-features`
(1,031+ tests pass), then `cargo run -p wifi-densepose-cli -- --help`.
3. **live-esp32** — a real install. Flash a node (`provision-node` skill), point it at
the sensing-server, then `calibrate-room`. This is the only path that senses a real room.
## Then
- Live sensing → go to **provision-node**, then **calibrate-room**.
- Evaluating a model/claim → go to **verify** and run `ruview_claim_check` on any
report before you quote a number.
@@ -0,0 +1,49 @@
---
name: provision-node
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, ESP-IDF Windows-subprocess flow, NVS/WiFi/channel/MAC-filter overrides.
---
# provision-node
Bring an ESP32 sensing node online.
## 1. Pick a firmware variant
- **s3-8mb** (display build) — ESP32-S3 N16R8 / 16MB; AMOLED optional. The display-detect
fix (#1000) means a *bare* board still captures CSI (MGMT+DATA).
- **s3-4mb** (no-display) — ESP32-S3 4MB; dual-OTA, display disabled.
- **c6** — ESP32-C6 + Seeed MR60BHA2 (60 GHz mmWave + WiFi CSI). The mmwave probe
requires a validated MR60 header (#1107) so an empty UART never false-detects.
Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56 rev v0.2).
## 2. Flash
ESP-IDF v5.4 on Windows is **subprocess-only** (Git Bash/MSYS is unsupported — strip
`MSYSTEM*` env vars). Offsets for the S3 image:
```
esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
0x0 bootloader.bin 0x8000 partition-table.bin \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
```
(`ruview_node_flash` returns the exact pinned command rather than running an
unattended flash.)
## 3. Provision
```
python firmware/esp32-csi-node/provision.py --port <PORT> \
--ssid "<SSID>" --password "<secret>" --target-ip <server-ip> --target-port 5005
# optional ADR-060 overrides:
python firmware/esp32-csi-node/provision.py --port <PORT> --channel 6 --filter-mac AA:BB:CC:DD:EE:FF
```
Never echo or commit the WiFi password.
## 4. Confirm CSI is flowing
`ruview_node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
(on a bare board) `CSI filter upgraded to MGMT+DATA`. No callbacks → the node isn't
capturing; do not proceed to calibration.
@@ -0,0 +1,33 @@
---
name: train-pose
description: Train/evaluate WiFi pose models honestly — camera-supervised (MediaPipe + CSI) and camera-free (WiFlow), always checked against the mean-pose baseline before any PCK is quoted.
---
# train-pose
Build a CSI→pose model without overstating it. The project has a **retracted 92.9%/100%**
history — the discipline below exists so it never recurs.
## The non-negotiable: mean-pose baseline first
A pose model that always predicts the dataset's *mean pose* already scores ~50% PCK.
**Quote PCK only as a delta over that baseline**, on a held-out split with no subject
or temporal leakage. Example honest result (ADR-181):
> Held-out PCK@20 **59.5%** vs a 50% mean-pose baseline = **+9.4 pp real signal** — MEASURED.
## Paths
- **camera-supervised** (ADR-079) — MediaPipe Pose labels the camera frame; paired CSI
trains the net. Train/infer in one camera frame so the skeleton aligns.
- **camera-free** (WiFlow, ADR-152) — no camera at inference; geometry-conditioned.
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
(honest about what's executing).
## Before you publish a number
1. Run the mean-pose baseline on the same split.
2. Report `(model baseline)` in pp, with the split definition (chronological /
blocked-gap / grouped-bucket; no leakage).
3. `ruview_claim_check` the writeup — it flags any untagged or 100%/perfect claim.
4. If it's a benchmark vs SOTA, tag MEASURED-EQUIVALENT only with the reproducer.
@@ -0,0 +1,42 @@
---
name: verify
description: Prove a RuView result is real — run the deterministic SHA-256 proof and the witness bundle (ADR-028), and lint any claim for MEASURED-vs-CLAIMED honesty.
---
# verify
The "prove everything" skill. Nothing ships as validated without this.
## Deterministic proof (Trust Kill Switch)
`ruview_verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
through the production pipeline and hashes the output against
`expected_features.sha256`. Must print **VERDICT: PASS**. If numpy/scipy changed the
hash, regenerate with `verify.py --generate-hash` then re-verify.
## Witness bundle (ADR-028)
For a release-grade attestation:
```
bash scripts/generate-witness-bundle.sh
cd dist/witness-bundle-ADR028-*/ && bash VERIFY.sh # must be 7/7 PASS
```
Contains the Rust test log, the proof + expected hash, firmware SHA-256 manifest, and
crate versions — a recipient can re-verify with one command.
## Claim honesty
Run `ruview_claim_check {text}` on any report, README section, PR body, or model card
before quoting accuracy. It flags:
- untagged accuracy numbers (must be MEASURED / CLAIMED / SYNTHETIC),
- MEASURED claims with no reproducer cited,
- the retracted "100%/perfect accuracy" framing.
## Firmware-specific
A firmware fix is **not** "hardware-validated" without a captured boot log on real
silicon (e.g. the `v0.8.1-esp32` rev-v0.2 validation: `running headless so CSI
captures (#1000)` + `CSI filter upgraded to MGMT+DATA` + a no-false-detect mmwave
probe). Do not merge or release on a build-passes signal alone.
+39
View File
@@ -0,0 +1,39 @@
{
"schema": 1,
"generator": "metaharness 0.1.15 + ADR-182 hardening",
"template": "vertical:ruview",
"name": "@ruvnet/ruview",
"vars": {
"name": "@ruvnet/ruview",
"description": "RuView WiFi-sensing operator agent harness",
"host": "claude-code"
},
"hosts": [
"claude-code"
],
"files": {
".claude/settings.json": "b0ea971383716f18b89db73010b8f0ea0f1b16bdec4cd1068245772ba1c27bdd",
".claude/skills/calibrate-room/SKILL.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
".claude/skills/onboard/SKILL.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
".claude/skills/provision-node/SKILL.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
".claude/skills/train-pose/SKILL.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
".claude/skills/verify/SKILL.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
"CLAUDE.md": "1d7af0c310dd8093b4ae6c9c94a1c0cc9ff02ac9c8d5b45caba5363c3af99475",
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
"README.md": "ac35157d66243a5f9eba262bdf2d593e978d935b3dde6e455b7acf650768eac6",
"bin/cli.js": "85d8394375edb1e967418451452e68bdbe26e69fc6877ed4936894f6101e1a12",
"package.json": "4509b68bb4211217f1e9f3f95f3134b326ee23a2322aef8d19b99a4b1d415b08",
"skills/calibrate-room.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
"skills/onboard.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
"skills/provision-node.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
"skills/train-pose.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
"src/guardrails.js": "66407b00d31c4f7939b75ee3e29598855c36a4154ccf1436655a4e52b0d7c034",
"src/mcp-server.js": "ad0f21be65a37237b9c2aad69e6e75166e5f101d902cb986377043545a7a80fb",
"src/tools.js": "1d72377ae53ad2b0c6dc03eb66f584422d8a60e442cb0d4f08355590f3edf031"
},
"meta": {
"surface": "cli+mcp",
"adr": "ADR-182"
}
}
+1
View File
@@ -0,0 +1 @@
380d4bf928fd7c5fa753d11a30c1e24e2ea471caca57b439f765a9d864cef472 manifest.json
+34
View File
@@ -0,0 +1,34 @@
# RuView harness — agent operating notes
You are operating **RuView** (WiFi-DensePose), a camera-free WiFi-CSI sensing system.
## The one rule: prove everything
This project was accused of AI-slop; the fix is hard discipline. Before you quote ANY
accuracy number:
1. It must be tagged **MEASURED** (with a reproducer named), **CLAIMED**, or **SYNTHETIC**.
2. Pose PCK is quoted only as a **delta over the mean-pose baseline** on a leakage-free
held-out split. (A mean-pose predictor already scores ~50% PCK.)
3. Run `ruview_claim_check` on any report/PR/model-card. It flags untagged numbers and
the retracted "100%/perfect accuracy" framing.
4. Firmware is "hardware-validated" only with a captured **boot log on real silicon**
never on a build-passes signal.
## Tools
`ruview_onboard`, `ruview_claim_check`, `ruview_verify`, `ruview_node_monitor`,
`ruview_calibrate`, `ruview_node_flash`. All fail-closed. Mutating/hardware tools
(`node_flash`) require explicit confirmation and are Windows/ESP-IDF gated.
## Skills
`onboard` · `provision-node` · `calibrate-room` · `train-pose` · `verify`
(`npx @ruvnet/ruview skill <name>`).
## Don'ts
- Don't present WiFi sensing as camera-grade.
- Don't echo or commit WiFi passwords / secrets.
- Don't merge or release firmware without a real boot log.
- Don't report a PCK without its mean-pose baseline.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ruvnet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+62
View File
@@ -0,0 +1,62 @@
# `npx @ruvnet/ruview` — RuView WiFi-sensing operator harness
An AI agent harness that knows how to operate **RuView** (WiFi-DensePose): onboard a
newcomer, provision an ESP32 CSI node, calibrate a room, train pose models, and —
crucially — **refuse to overstate accuracy**. Minted from the RuView monorepo via
[`metaharness`](https://www.npmjs.com/package/metaharness) and hardened per **ADR-182**.
WiFi sensing infers *coarse* pose/presence/breathing from Channel State Information.
It is **not a camera**. Every accuracy number this harness emits must be MEASURED
against a baseline — that rule is enforced in code (`ruview_claim_check`).
## Quick start
```bash
npx @ruvnet/ruview # onboard — pick a setup path
npx @ruvnet/ruview claim-check --file REPORT.md # the honesty guardrail (non-zero exit on untagged claims)
npx @ruvnet/ruview verify # run the deterministic proof (VERDICT: PASS)
npx @ruvnet/ruview doctor # self-check (tools + optional kernel/host)
npx @ruvnet/ruview --help
```
The operator tools are pure Node and run with **zero install weight** — the
package has no dependencies at all (ADR-263 O3). `doctor` / `install` can
additionally use `@metaharness/kernel` + a host adapter if you install them
(`npm i @metaharness/kernel @metaharness/host-claude-code`); everything else
runs without them.
## Tools (`ruview_*`)
Exposed both as CLI verbs and as an MCP server (`npx @ruvnet/ruview mcp start`):
| Tool | What it does |
|------|--------------|
| `ruview_onboard` | Pick docker-demo / repo-build / live-esp32; print the next command |
| `ruview_claim_check` | Lint text for untagged / overstated accuracy claims (guardrail) |
| `ruview_verify` | Run `verify.py` deterministic proof → VERDICT |
| `ruview_node_monitor` | Assert CSI is flowing on an ESP32 (read-only) |
| `ruview_calibrate` | ADR-151 room pipeline (baseline→enroll→train-room→room-watch) |
| `ruview_node_flash` | Build+flash firmware (Windows/ESP-IDF; mutating, guarded) |
Every tool is **fail-closed**: missing repo / python / binary / port → an honest
negative, never a fabricated success.
## Skills
Host-neutral playbooks in `skills/` (`onboard`, `provision-node`, `calibrate-room`,
`train-pose`, `verify`). `npx @ruvnet/ruview skill <name>` prints one.
## Use as a Claude Code MCP server
The bundled `.claude/settings.json` registers the `ruview` MCP server
(`npx -y @ruvnet/ruview mcp start`). Drop this package's `.claude/` into a repo, or run
`npx @ruvnet/ruview install --host claude-code`.
## Hosts
claude-code (bundled), and via metaharness host adapters: codex, opencode, copilot,
pi-dev, hermes, rvm, github-actions.
## License
MIT © ruvnet
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// `npx ruview` — the RuView WiFi-sensing operator harness (minted via metaharness,
// hardened per ADR-182). Plain ESM, no build step: ships and runs as-is.
//
// The `ruview.*` tools (onboard/verify/claim-check/…) are PURE Node and run with
// zero deps. The kernel + host adapter are only touched by `doctor`/`install`
// (the harness-into-a-repo story), so the operator tools never block on a wasm load.
import { fileURLToPath } from 'node:url';
import { realpathSync, existsSync, readdirSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { argv } from 'node:process';
import { TOOLS, runTool, listTools } from '../src/tools.js';
import { claimCheck, summarize } from '../src/guardrails.js';
const NAME = 'ruview';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const SKILLS_DIR = join(ROOT, 'skills');
// Map friendly CLI verbs → registry tool names (underscore-canonical, ADR-263).
const VERB_TO_TOOL = {
onboard: 'ruview_onboard',
verify: 'ruview_verify',
'claim-check': 'ruview_claim_check',
calibrate: 'ruview_calibrate',
monitor: 'ruview_node_monitor',
flash: 'ruview_node_flash',
};
function pjson(o) { console.log(JSON.stringify(o, null, 2)); }
function listSkills() {
if (!existsSync(SKILLS_DIR)) return [];
return readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''));
}
async function doctor() {
const checks = [];
// Tools layer (always available, no deps).
checks.push(['tool registry loads', Object.keys(TOOLS).length > 0]);
checks.push(['claim_check flags a 100% claim',
!claimCheck('We hit 100% accuracy on poses.').ok]);
checks.push(['claim_check passes a tagged MEASURED claim',
claimCheck('Held-out PCK@20 59.5% (MEASURED vs mean-pose baseline, verify.py).').ok]);
checks.push(['skills present', listSkills().length > 0]);
// Kernel + host adapter (optional — only needed to install into a repo).
let kernelLine = 'kernel/host: not installed (ok — operator tools run without them)';
try {
const { loadKernel } = await import('@metaharness/kernel');
const adapter = (await import('@metaharness/host-claude-code')).default;
const k = await loadKernel();
const info = k.kernelInfo();
checks.push(['kernel loads + reports version', typeof info.version === 'string' && info.version.length > 0]);
checks.push(['kernel backend is native|wasm|js', ['native', 'wasm', 'js'].includes(k.backend)]);
checks.push(['host adapter resolves', typeof adapter?.name === 'string']);
kernelLine = `kernel ${info.version} (${k.backend}) · host ${adapter.name}`;
} catch {
/* kernel not installed — fine for the tools-only path */
}
let ok = true;
for (const [label, pass] of checks) { console.log(`${pass ? 'PASS' : 'FAIL'} ${label}`); if (!pass) ok = false; }
console.log(`\n${NAME}: ${ok ? 'all checks passed' : 'doctor found problems'}${kernelLine}`);
return ok ? 0 : 1;
}
function help() {
console.log(`Usage: ${NAME} <command> [options]
Operator tools:
onboard [--path docker-demo|repo-build|live-esp32] pick a setup path
verify [--repo <dir>] run the deterministic proof (VERDICT: PASS)
claim-check --text "..." | --file <path> lint accuracy claims (the honesty guardrail)
calibrate --step baseline|enroll|train-room|room-watch
monitor --port COM8 [--seconds 12] assert CSI is flowing on a node
flash --port COM8 --variant s3-8mb [--confirm] build+flash firmware (Windows/ESP-IDF)
Harness:
doctor verify the install (tools + optional kernel/host)
skills list bundled skills
skill <name> print a skill playbook
mcp start run the ruview.* MCP server (stdio)
install --host <h> project the harness config into the current repo
--version | --help
Hosts: claude-code, codex, opencode, copilot, pi-dev, hermes, rvm, github-actions`);
return 0;
}
/** tiny flag parser: --k v / --k=v / --flag (boolean) */
function parseFlags(rest) {
const f = {};
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (a.startsWith('--')) {
const eq = a.indexOf('=');
if (eq !== -1) { f[a.slice(2, eq)] = a.slice(eq + 1); }
else if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) { f[a.slice(2)] = rest[++i]; }
else { f[a.slice(2)] = true; }
}
}
return f;
}
export async function run(args) {
const cmd = args[0] ?? 'onboard';
const rest = args.slice(1);
const flags = parseFlags(rest);
// Direct tool verbs.
if (VERB_TO_TOOL[cmd]) {
const toolArgs = { ...flags };
if (cmd === 'claim-check') {
if (flags.file) toolArgs.text = readFileSync(flags.file, 'utf8');
// Fail closed (ADR-263 O1): an honesty gate must never PASS on no input.
if (typeof toolArgs.text !== 'string' || toolArgs.text.trim().length === 0) {
console.error('claim-check: no input — pass --text "..." or --file <path> (empty input is an error, not a PASS).');
return 2;
}
const res = await runTool('ruview_claim_check', toolArgs);
pjson(res);
return res.ok ? 0 : 1;
}
if (cmd === 'monitor' && flags.seconds) toolArgs.seconds = Number(flags.seconds);
if (cmd === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
const res = await runTool(VERB_TO_TOOL[cmd], toolArgs);
pjson(res);
return res.ok ? 0 : 1;
}
switch (cmd) {
case 'doctor': return doctor();
case 'skills': console.log(listSkills().join('\n') || '(none)'); return 0;
case 'skill': {
const n = rest[0];
const p = n && join(SKILLS_DIR, `${n}.md`);
if (!p || !existsSync(p)) { console.error(`No skill "${n}". Try: ${listSkills().join(', ')}`); return 2; }
console.log(readFileSync(p, 'utf8'));
return 0;
}
case 'mcp': {
if (rest[0] === 'start' || rest[0] === undefined) {
const { startMcpServer } = await import('../src/mcp-server.js');
startMcpServer();
return new Promise(() => {}); // run until stdin closes
}
console.error('Usage: ruview mcp start'); return 2;
}
case 'install': {
const host = flags.host || 'claude-code';
try {
const adapter = (await import('@metaharness/host-claude-code')).default;
console.log(`Projecting RuView harness for host "${host}" via ${adapter.name}.`);
console.log('Add to your host config — MCP server command: npx -y ruview mcp start');
console.log('Skills:', listSkills().join(', '));
return 0;
} catch {
console.error('Host adapter not installed. `npm i @metaharness/host-claude-code` or use the bundled .claude/ config.');
return 1;
}
}
case 'tools': pjson(listTools()); return 0;
case '--version': case '-v': {
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
console.log(pkg.version); return 0;
}
case '--help': case '-h': return help();
default:
console.error(`Unknown command: ${cmd}. Try \`${NAME} --help\`.`);
return 2;
}
}
// CLI guard: run only when invoked directly (realpath both sides — npm/npx shims
// pass a non-normalized, possibly case-skewed argv[1] on Windows).
const invokedDirectly = (() => {
if (!argv[1]) return false;
try {
const a = realpathSync(argv[1]);
const b = realpathSync(fileURLToPath(import.meta.url));
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
} catch { return false; }
})();
if (invokedDirectly) {
run(argv.slice(2)).then((code) => process.exit(code)).catch((err) => { console.error(err); process.exit(1); });
}
+64
View File
@@ -0,0 +1,64 @@
{
"name": "@ruvnet/ruview",
"version": "0.2.0",
"description": "RuView WiFi-sensing operator agent harness — onboard, calibrate, train, and verify camera-free WiFi-CSI sensing, with the project's MEASURED-vs-CLAIMED honesty guardrail enforced. Minted via metaharness (ADR-182).",
"type": "module",
"bin": {
"ruview": "bin/cli.js"
},
"exports": {
".": "./src/tools.js",
"./guardrails": "./src/guardrails.js"
},
"files": [
"bin/",
"src/",
"skills/",
".claude/",
".harness/",
"CLAUDE.md",
"README.md",
"LICENSE"
],
"scripts": {
"test": "node --test test/*.test.mjs",
"doctor": "node ./bin/cli.js doctor",
"mcp": "node ./bin/cli.js mcp start",
"sync-skills": "node ./scripts/sync-skills.mjs",
"prepack": "node ./scripts/sync-skills.mjs",
"prepublishOnly": "npm test"
},
"keywords": [
"wifi-sensing",
"wifi-densepose",
"ruview",
"csi",
"channel-state-information",
"pose-estimation",
"presence-detection",
"esp32",
"agent-harness",
"metaharness",
"mcp",
"mcp-server",
"claude-code",
"ambient-intelligence"
],
"engines": {
"node": ">=20.0.0"
},
"license": "MIT",
"author": "ruvnet",
"homepage": "https://github.com/ruvnet/RuView#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/ruvnet/RuView.git",
"directory": "harness/ruview"
},
"bugs": {
"url": "https://github.com/ruvnet/RuView/issues"
},
"publishConfig": {
"access": "public"
}
}
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// ADR-263 O7: skills/*.md is the single source of truth; the host-projected
// copies (.claude/skills/<name>/SKILL.md) are GENERATED here at pack time.
// Run with --check to verify without writing (used by tests/CI).
import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const SRC = join(ROOT, 'skills');
const DST = join(ROOT, '.claude', 'skills');
const checkOnly = process.argv.includes('--check');
let drift = 0;
for (const f of readdirSync(SRC).filter((f) => f.endsWith('.md'))) {
const name = f.replace(/\.md$/, '');
const src = readFileSync(join(SRC, f), 'utf8');
const dstDir = join(DST, name);
const dstFile = join(dstDir, 'SKILL.md');
const current = existsSync(dstFile) ? readFileSync(dstFile, 'utf8') : null;
if (current === src) continue;
drift++;
if (checkOnly) {
console.error(`DRIFT: .claude/skills/${name}/SKILL.md != skills/${f}`);
} else {
mkdirSync(dstDir, { recursive: true });
writeFileSync(dstFile, src);
console.error(`synced .claude/skills/${name}/SKILL.md`);
}
}
if (checkOnly && drift > 0) {
console.error(`sync-skills --check: ${drift} file(s) out of sync — run \`npm run sync-skills\`.`);
process.exit(1);
}
console.error(`sync-skills: ${drift === 0 ? 'all in sync' : `${drift} file(s) ${checkOnly ? 'OUT OF SYNC' : 'synced'}`}`);
+29
View File
@@ -0,0 +1,29 @@
---
name: calibrate-room
description: Run the ADR-151 per-room calibration pipeline — baseline → enroll → extract → train → a bank of small specialists (presence/posture/breathing/heartbeat/restlessness/anomaly).
---
# calibrate-room
Turn a provisioned node + sensing-server into a working room model. Pure-Rust,
edge-deployable (ADR-151). Use the `ruview_calibrate` tool (installed
`wifi-densepose` binary, else `cargo run -p wifi-densepose-cli`).
## Sequence
1. **baseline** — capture the empty room (Welford amplitude + von Mises phase). Leave
the room empty.
`ruview_calibrate {step: "baseline"}`
2. **enroll** — record the occupant(s) doing the target activities.
`ruview_calibrate {step: "enroll"}`
3. **train-room** — train the bank of small specialists from baseline + enrollment.
`ruview_calibrate {step: "train-room"}`
4. **room-watch** — live presence/posture/breathing from the trained room.
`ruview_calibrate {step: "room-watch"}` (or the `room-watch` skill)
## Honesty
The specialists are calibrated to *this* room; cross-room transfer is a separate
problem (LoRA recalibration, ADR-079 P9). Report which room a number came from, and
tag presence/vitals accuracy MEASURED only with a held-out check — run
`ruview_claim_check` on the writeup.
+30
View File
@@ -0,0 +1,30 @@
---
name: onboard
description: Zero-to-sensing path picker for RuView (WiFi-DensePose) — pick docker-demo, repo-build, or live-esp32 and run the next concrete step.
---
# onboard
Get a newcomer from nothing to a working RuView setup. **First fact to set:** WiFi
sensing infers *coarse* pose/presence/breathing from Channel State Information — it
is **not a camera**, and any accuracy number must be MEASURED against a baseline
(use the `verify` skill / `ruview_claim_check` tool). Never present WiFi output as
camera-grade.
## Pick a path
Run `ruview_onboard {path}` or decide from:
1. **docker-demo** — fastest, no hardware. Replays sample CSI into the dashboard.
`docker run -p 8000:8000 ruvnet/wifi-densepose` → open `http://localhost:8000`.
Use to see what it looks like.
2. **repo-build** — for developers. `cd v2 && cargo test --workspace --no-default-features`
(1,031+ tests pass), then `cargo run -p wifi-densepose-cli -- --help`.
3. **live-esp32** — a real install. Flash a node (`provision-node` skill), point it at
the sensing-server, then `calibrate-room`. This is the only path that senses a real room.
## Then
- Live sensing → go to **provision-node**, then **calibrate-room**.
- Evaluating a model/claim → go to **verify** and run `ruview_claim_check` on any
report before you quote a number.
+49
View File
@@ -0,0 +1,49 @@
---
name: provision-node
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, ESP-IDF Windows-subprocess flow, NVS/WiFi/channel/MAC-filter overrides.
---
# provision-node
Bring an ESP32 sensing node online.
## 1. Pick a firmware variant
- **s3-8mb** (display build) — ESP32-S3 N16R8 / 16MB; AMOLED optional. The display-detect
fix (#1000) means a *bare* board still captures CSI (MGMT+DATA).
- **s3-4mb** (no-display) — ESP32-S3 4MB; dual-OTA, display disabled.
- **c6** — ESP32-C6 + Seeed MR60BHA2 (60 GHz mmWave + WiFi CSI). The mmwave probe
requires a validated MR60 header (#1107) so an empty UART never false-detects.
Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56 rev v0.2).
## 2. Flash
ESP-IDF v5.4 on Windows is **subprocess-only** (Git Bash/MSYS is unsupported — strip
`MSYSTEM*` env vars). Offsets for the S3 image:
```
esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
0x0 bootloader.bin 0x8000 partition-table.bin \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
```
(`ruview_node_flash` returns the exact pinned command rather than running an
unattended flash.)
## 3. Provision
```
python firmware/esp32-csi-node/provision.py --port <PORT> \
--ssid "<SSID>" --password "<secret>" --target-ip <server-ip> --target-port 5005
# optional ADR-060 overrides:
python firmware/esp32-csi-node/provision.py --port <PORT> --channel 6 --filter-mac AA:BB:CC:DD:EE:FF
```
Never echo or commit the WiFi password.
## 4. Confirm CSI is flowing
`ruview_node_monitor {port}` — PASS criteria: serial shows `CSI cb #...` callbacks and
(on a bare board) `CSI filter upgraded to MGMT+DATA`. No callbacks → the node isn't
capturing; do not proceed to calibration.
+33
View File
@@ -0,0 +1,33 @@
---
name: train-pose
description: Train/evaluate WiFi pose models honestly — camera-supervised (MediaPipe + CSI) and camera-free (WiFlow), always checked against the mean-pose baseline before any PCK is quoted.
---
# train-pose
Build a CSI→pose model without overstating it. The project has a **retracted 92.9%/100%**
history — the discipline below exists so it never recurs.
## The non-negotiable: mean-pose baseline first
A pose model that always predicts the dataset's *mean pose* already scores ~50% PCK.
**Quote PCK only as a delta over that baseline**, on a held-out split with no subject
or temporal leakage. Example honest result (ADR-181):
> Held-out PCK@20 **59.5%** vs a 50% mean-pose baseline = **+9.4 pp real signal** — MEASURED.
## Paths
- **camera-supervised** (ADR-079) — MediaPipe Pose labels the camera frame; paired CSI
trains the net. Train/infer in one camera frame so the skeleton aligns.
- **camera-free** (WiFlow, ADR-152) — no camera at inference; geometry-conditioned.
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
(honest about what's executing).
## Before you publish a number
1. Run the mean-pose baseline on the same split.
2. Report `(model baseline)` in pp, with the split definition (chronological /
blocked-gap / grouped-bucket; no leakage).
3. `ruview_claim_check` the writeup — it flags any untagged or 100%/perfect claim.
4. If it's a benchmark vs SOTA, tag MEASURED-EQUIVALENT only with the reproducer.
+42
View File
@@ -0,0 +1,42 @@
---
name: verify
description: Prove a RuView result is real — run the deterministic SHA-256 proof and the witness bundle (ADR-028), and lint any claim for MEASURED-vs-CLAIMED honesty.
---
# verify
The "prove everything" skill. Nothing ships as validated without this.
## Deterministic proof (Trust Kill Switch)
`ruview_verify` runs `archive/v1/data/proof/verify.py`: it feeds a reference signal
through the production pipeline and hashes the output against
`expected_features.sha256`. Must print **VERDICT: PASS**. If numpy/scipy changed the
hash, regenerate with `verify.py --generate-hash` then re-verify.
## Witness bundle (ADR-028)
For a release-grade attestation:
```
bash scripts/generate-witness-bundle.sh
cd dist/witness-bundle-ADR028-*/ && bash VERIFY.sh # must be 7/7 PASS
```
Contains the Rust test log, the proof + expected hash, firmware SHA-256 manifest, and
crate versions — a recipient can re-verify with one command.
## Claim honesty
Run `ruview_claim_check {text}` on any report, README section, PR body, or model card
before quoting accuracy. It flags:
- untagged accuracy numbers (must be MEASURED / CLAIMED / SYNTHETIC),
- MEASURED claims with no reproducer cited,
- the retracted "100%/perfect accuracy" framing.
## Firmware-specific
A firmware fix is **not** "hardware-validated" without a captured boot log on real
silicon (e.g. the `v0.8.1-esp32` rev-v0.2 validation: `running headless so CSI
captures (#1000)` + `CSI filter upgraded to MGMT+DATA` + a no-false-detect mmwave
probe). Do not merge or release on a build-passes signal alone.
+148
View File
@@ -0,0 +1,148 @@
// SPDX-License-Identifier: MIT
// RuView harness guardrails — the "prove everything" rule made executable.
//
// The project was accused of AI-slop; the cultural fix is that every accuracy
// number must be tagged MEASURED (with a reproducer) or CLAIMED/SYNTHETIC, and
// the retracted "100% accuracy" framing must never reappear untagged. This module
// is the static enforcement of that, shared by the `ruview_claim_check` MCP tool,
// the `npx ruview claim-check` CLI, and the claude-code pre-output hook.
/** Phrases that signal a quantitative accuracy claim (safe as substrings). */
const METRIC_TERMS = [
'accuracy', 'pck', 'precision', 'recall',
'mpjpe', 'error rate', 'detection rate', 'true positive',
];
// Short/ambiguous metric tokens (ADR-263 F11): 'map' is usually the English
// word or a file extension, 'f1'/'o1' collide with finding/option labels.
// They only count as metric mentions when word-bounded, not a `.map` file
// reference, and the line (after scrubbing) carries a number — "mAP 62.3" is
// a claim, "F-numbers map to findings" is not.
// 'map' additionally must not be a `.map` file suffix or a hyphenated
// compound ("map-free", "map-reduce") — mAP the metric never appears as either.
const METRIC_TERMS_SHORT = [/(?<![.\w])map\b(?!-)/, /\bf1\b/, /\bauc\b/, /\biou\b/];
// Finding/option labels (F1, O2, …) count as labels unless the token sits in a
// metric context: an immediately following score/=/%/digit or colon ("F1: 0.91"),
// or a number later in the same clause ("F1 reaches 0.91" — an F1-score claim).
// Bare option refs ("F7 fixes", "O1O9", "ADR-263 O2") carry no clause number of
// their own and stay labels. (A surviving 'f1' still only fires as a metric when
// its scrubbed line actually carries a number — see mentionsMetricTerm.)
const LABEL_TOKEN_RE = /\b[fo]\d+\b(?!\s*(?:score|=|\d|%|:))(?![^\n.;]*\d)/g;
const CODE_SPAN_RE = /`[^`]*`/g; // backticked identifiers are code, not claims
const HAS_NUMBER_RE = /\d/;
/** Line with code spans and finding/option labels removed. */
function scrubLine(lower) {
return lower.replace(CODE_SPAN_RE, ' ').replace(LABEL_TOKEN_RE, ' ');
}
function mentionsMetricTerm(lower, scrubbed) {
if (METRIC_TERMS.some((t) => lower.includes(t))) return true;
if (!HAS_NUMBER_RE.test(scrubbed)) return false;
return METRIC_TERMS_SHORT.some((re) => re.test(scrubbed));
}
/** Tags that make a claim honest (case-insensitive). */
const HONEST_TAGS = ['measured', 'claimed', 'synthetic', 'unvalidated', 'baseline'];
/** Reproducer references that count as evidence backing a MEASURED claim. */
const REPRODUCER_HINTS = [
'verify.py', 'witness', 'mean-pose', 'mean pose', 'held-out', 'held out',
'baseline', 'reproduce', 'sha256', 'boot log', 'pck@20 vs', 'expected_features',
// Packaging-claim reproducers (ADR-263/264 npm reviews): the tarball itself.
'npm pack', 'npm view', 'npm i ', 'npm install', 'tarball', 'cargo test',
];
const PERCENT_RE = /\b(\d{1,3}(?:\.\d+)?)\s?%/g;
// "perfect" / "100%" framing is the specific retracted claim — always high severity.
// NOTE: no trailing \b after "%": "%"→" " is non-word→non-word, so a trailing \b
// never matches and would silently miss "100%". Bare 100% is only damning next to a
// metric term (see claimCheck); the word phrases are inherently accuracy claims.
const PERFECT_PCT_RE = /\b100(?:\.0+)?\s?%/;
const PERFECT_WORD_RE = /perfect accuracy|flawless|never (?:wrong|fails)/i;
/**
* Lint a block of text for untagged or overstated accuracy claims.
* @param {string} text
* @returns {{ok: boolean, findings: Array<{severity:'high'|'medium', line:number, excerpt:string, reason:string, suggestion:string}>}}
*/
export function claimCheck(text) {
const findings = [];
if (typeof text !== 'string' || text.length === 0) {
return { ok: true, findings };
}
const lines = text.split(/\r?\n/);
lines.forEach((raw, i) => {
const line = raw.trim();
if (!line) return;
const lower = line.toLowerCase();
const hasPercent = PERCENT_RE.test(line);
PERCENT_RE.lastIndex = 0; // reset stateful global regex
const scrubbed = scrubLine(lower);
const mentionsMetric = mentionsMetricTerm(lower, scrubbed);
if (!hasPercent && !mentionsMetric) return;
const tagged = HONEST_TAGS.some((t) => lower.includes(t));
const hasReproducer = REPRODUCER_HINTS.some((h) => lower.includes(h));
const perfect = PERFECT_WORD_RE.test(line) || (mentionsMetric && PERFECT_PCT_RE.test(line));
if (perfect && !lower.includes('retract')) {
findings.push({
severity: 'high',
line: i + 1,
excerpt: clip(line),
reason: 'States perfect/100% accuracy — this is the exact framing the project retracted.',
suggestion: 'Replace with a held-out number vs the mean-pose baseline, tagged MEASURED, or mark the old claim "retracted".',
});
return;
}
// A quantitative claim needs a number. Digits hidden in a code span still
// count — "accuracy reached `0.95`" is a claim — so test the line with only
// finding/option labels stripped, NOT the code-span-scrubbed copy: scrubbing
// dropped `0.95` and wrongly short-circuited both the untagged and the
// MEASURED-without-reproducer checks below. A bare metric word in prose
// ("precision matters here", "every accuracy number must be MEASURED") has no
// number and is not a taggable claim (ADR-263 F11).
if (!hasPercent && !HAS_NUMBER_RE.test(lower.replace(LABEL_TOKEN_RE, ' '))) return;
// A metric/percent with no honesty tag at all.
if (!tagged) {
findings.push({
severity: 'medium',
line: i + 1,
excerpt: clip(line),
reason: 'Accuracy claim is not tagged MEASURED / CLAIMED / SYNTHETIC.',
suggestion: 'Tag it. If MEASURED, name the reproducer (verify.py, witness bundle, held-out vs mean-pose).',
});
return;
}
// Tagged MEASURED but cites no reproducer — still a gap (reached now even
// when the only number is inside a code span, e.g. "accuracy `0.97` (MEASURED)").
if (lower.includes('measured') && !hasReproducer) {
findings.push({
severity: 'medium',
line: i + 1,
excerpt: clip(line),
reason: 'Tagged MEASURED but cites no reproducer/evidence.',
suggestion: 'Add the evidence path: verify.py VERDICT, witness bundle, or held-out PCK vs the mean-pose baseline.',
});
}
});
return { ok: findings.length === 0, findings };
}
function clip(s, n = 120) {
return s.length > n ? `${s.slice(0, n - 1)}` : s;
}
/** Convenience: a one-line human summary for CLI output. */
export function summarize(result) {
if (result.ok) return 'claim-check: PASS — no untagged or overstated accuracy claims.';
const high = result.findings.filter((f) => f.severity === 'high').length;
return `claim-check: ${result.findings.length} finding(s) (${high} high) — accuracy claims need MEASURED/CLAIMED tags + a reproducer.`;
}
+107
View File
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: MIT
// RuView harness — minimal MCP stdio server (JSON-RPC 2.0 over stdin/stdout).
//
// Dependency-free on purpose: a published `npx ruview` must `mcp start` without
// pulling the full MCP SDK. Implements the subset hosts use: `initialize`,
// `tools/list`, `tools/call`, `ping`, empty `resources/list`/`prompts/list`
// stubs, and the `notifications/initialized` ack. Logs go to stderr ONLY —
// stdout is the JSON-RPC channel and must stay clean.
//
// ADR-263 O2: `tools/call` is dispatched asynchronously — a long-running
// verify/calibrate no longer blocks ping/tools/list, so hosts that health-check
// mid-run see a live server. Responses may therefore arrive out of request
// order, which JSON-RPC permits (ids correlate them).
import { createInterface } from 'node:readline';
import { readFileSync } from 'node:fs';
import { listTools, runTool } from './tools.js';
const PROTOCOL_VERSION = '2024-11-05';
// Single-source the version from package.json (ADR-263 O6).
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
const SERVER_INFO = { name: 'ruview', version: PKG.version };
function send(msg) {
process.stdout.write(JSON.stringify(msg) + '\n');
}
function result(id, res) { send({ jsonrpc: '2.0', id, result: res }); }
function error(id, code, message) { send({ jsonrpc: '2.0', id, error: { code, message } }); }
function log(...a) { process.stderr.write('[ruview-mcp] ' + a.join(' ') + '\n'); }
async function handle(msg) {
const { id, method, params } = msg;
switch (method) {
case 'initialize':
return result(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: SERVER_INFO,
instructions: 'RuView WiFi-sensing operator tools. All results are fail-closed; accuracy claims must pass ruview_claim_check.',
});
case 'notifications/initialized':
case 'initialized':
case 'notifications/cancelled':
return; // notifications — no response
case 'ping':
return result(id, {});
case 'tools/list':
return result(id, { tools: listTools() });
case 'resources/list':
return result(id, { resources: [] });
case 'prompts/list':
return result(id, { prompts: [] });
case 'tools/call': {
const name = params?.name;
const args = params?.arguments || {};
const out = await runTool(name, args);
// MCP content envelope: text block with the JSON, isError reflects ok=false.
return result(id, {
content: [{ type: 'text', text: JSON.stringify(out, null, 2) }],
isError: out && out.ok === false,
});
}
default:
if (id !== undefined) error(id, -32601, `Method not found: ${method}`);
}
}
export function startMcpServer() {
log(`starting v${SERVER_INFO.version} (protocol ${PROTOCOL_VERSION}, ${listTools().length} tools)`);
const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
// tools/call runs are serialized through a FIFO promise chain: hardware/mutating
// tools (calibrate, serial monitor, flash) must never overlap. ping/tools/list/
// initialize/resources/prompts stay immediate (ADR-263 O2 — a health check must
// answer during a long tool run). `toolChain` also lets stdin-close drain the
// in-flight call so its response is flushed instead of dropped by process.exit.
let toolChain = Promise.resolve();
const dispatch = (msg) => handle(msg).catch((err) => {
if (msg && msg.id !== undefined) error(msg.id, -32603, String(err && err.message || err));
log('handler error:', String(err));
});
rl.on('line', (line) => {
const s = line.trim();
if (!s) return;
let msg;
try { msg = JSON.parse(s); } catch { return log('bad JSON line dropped'); }
if (msg && msg.method === 'tools/call') {
toolChain = toolChain.then(() => dispatch(msg)); // one tool at a time
} else {
dispatch(msg); // health/list/handshake answer immediately, even mid tool run
}
});
rl.on('close', () => {
// Wait for any queued/in-flight tool call to settle (its response written)
// before exiting — fire-and-forget used to race this and drop the response.
toolChain.then(() => {
log('stdin closed — exiting');
const done = () => process.exit(0);
// Pipe writes are async; flush buffered stdout before exit.
if (process.stdout.writableLength) process.stdout.once('drain', done);
else done();
});
});
}
+301
View File
@@ -0,0 +1,301 @@
// SPDX-License-Identifier: MIT
// RuView harness — the `ruview.*` tool registry.
//
// One registry consumed by BOTH the CLI (`npx ruview <tool>`) and the MCP server
// (`npx ruview mcp start`). Every handler returns structured JSON and is
// FAIL-CLOSED: when a prerequisite (the RuView repo, python+pyserial, the
// `wifi-densepose` binary, an ESP32 on a port) is absent, it returns an honest
// negative — never a fabricated success. This mirrors the project's "prove
// everything" rule and the RuField fail-closed posture (ADR-262 §3.3).
//
// ADR-263: handlers are async (promise-based spawn, never spawnSync) so the MCP
// server keeps answering ping/tools/list while a long verify/calibrate runs.
// Canonical tool names use underscores (host tool-name regexes commonly enforce
// ^[a-zA-Z0-9_-]{1,64}$); the historical dotted names are accepted as aliases.
import { spawn } from 'node:child_process';
import { existsSync, accessSync, constants } from 'node:fs';
import { join, dirname, resolve, delimiter } from 'node:path';
import { claimCheck, summarize } from './guardrails.js';
/** Walk up from `start` to find the RuView monorepo root (or null). */
export function findRepoRoot(start = process.cwd()) {
let dir = resolve(start);
for (let i = 0; i < 8; i++) {
const hasProof = existsSync(join(dir, 'archive', 'v1', 'data', 'proof', 'verify.py'));
const hasV2 = existsSync(join(dir, 'v2', 'Cargo.toml'));
if (hasProof || hasV2) return dir;
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
// Dep-free PATH scan (ADR-263 O8) — no shell subprocess per lookup. Only hits
// are memoized: a miss can resolve later in a long-lived MCP session (the
// operator installs python/the CLI mid-run), so misses are re-probed each call.
const whichCache = new Map();
export function which(cmd) {
if (whichCache.has(cmd)) return whichCache.get(cmd);
const isWin = process.platform === 'win32';
const exts = isWin
? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
: [''];
let found = null;
outer:
for (const dir of (process.env.PATH || '').split(delimiter)) {
if (!dir) continue;
for (const ext of isWin ? ['', ...exts] : exts) {
const p = join(dir, cmd + ext);
try {
accessSync(p, isWin ? constants.F_OK : constants.X_OK);
found = p;
break outer;
} catch { /* keep scanning */ }
}
}
if (found !== null) whichCache.set(cmd, found);
return found;
}
// Bounded output tails (ADR-263 O4): spawnSync's default 1 MiB maxBuffer killed
// chatty children with ENOBUFS; handlers only ever surface the last few kB, so
// keep rolling tails instead of the full stream.
const STDOUT_TAIL = 65536;
const STDERR_TAIL = 16384;
/** Promise-based spawn with timeout + rolling output tails. */
export function run(cmd, args, opts = {}) {
const timeout = opts.timeout ?? 120000;
return new Promise((resolvePromise) => {
let stdout = '';
let stderr = '';
let child;
try {
child = spawn(cmd, args, { cwd: opts.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
} catch (e) {
resolvePromise({ status: null, ok: false, stdout: '', stderr: '', error: e.message });
return;
}
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, timeout);
child.stdout.on('data', (d) => {
stdout = (stdout + d).slice(-STDOUT_TAIL);
});
child.stderr.on('data', (d) => {
stderr = (stderr + d).slice(-STDERR_TAIL);
});
child.on('error', (e) => {
clearTimeout(timer);
resolvePromise({ status: null, ok: false, stdout, stderr, error: e.message });
});
child.on('close', (status) => {
clearTimeout(timer);
resolvePromise({
status,
ok: status === 0,
stdout,
stderr,
error: timedOut ? `timed out after ${timeout} ms` : null,
});
});
});
}
const ONBOARD_PATHS = {
'docker-demo': 'Fastest. `docker run -p 8000:8000 ruvnet/wifi-densepose` → open the dashboard. No hardware; replays sample CSI. Good for "what does it look like".',
'repo-build': 'Build from source. `cd v2 && cargo test --workspace --no-default-features` (1,031+ tests). Then `cargo run -p wifi-densepose-cli -- --help`. Good for developers.',
'live-esp32': 'Real sensing. Flash an ESP32-S3 (see `provision-node` skill), point it at the sensing-server, then `calibrate → enroll → train-room → room-watch` (see `calibrate-room`). Good for an actual install.',
};
// Read-only serial monitor script; the port arrives via sys.argv (ADR-263 O5 —
// never spliced into interpreter source).
const MONITOR_SCRIPT = [
'import sys,time',
'try:',
' import serial',
'except Exception as e:',
" print('NO_PYSERIAL'); sys.exit(3)",
'port=sys.argv[1]',
'dur=float(sys.argv[2])',
'ser=serial.Serial(port,115200,timeout=1)',
'csi=0; n=0; t=time.time()',
'while time.time()-t<dur:',
' ln=ser.readline()',
' if not ln: continue',
" s=ln.decode('utf-8','replace')",
' n+=1',
" if 'CSI cb' in s or 'csi_collector' in s: csi+=1",
" if 'MGMT+DATA' in s: print('UPGRADE_MGMT_DATA')",
'ser.close()',
"print(f'LINES={n} CSI={csi}')",
].join('\n');
/**
* The tool registry. Each entry: { title, description, inputSchema, handler }.
* inputSchema is JSON-Schema (object). handler(args) JSON-serializable result
* (sync or promise). Canonical names are underscore-form.
*/
export const TOOLS = {
ruview_onboard: {
title: 'Onboard',
description: 'Pick a RuView setup path (docker-demo | repo-build | live-esp32) and print the next concrete command.',
inputSchema: {
type: 'object',
properties: { path: { type: 'string', enum: Object.keys(ONBOARD_PATHS), description: 'Which setup path. Omit to list all.' } },
},
handler(args = {}) {
const repo = findRepoRoot();
if (args.path && ONBOARD_PATHS[args.path]) {
return { ok: true, path: args.path, next: ONBOARD_PATHS[args.path], in_ruview_repo: !!repo };
}
return {
ok: true,
in_ruview_repo: !!repo,
repo_root: repo,
paths: ONBOARD_PATHS,
recommend: repo ? 'repo-build' : 'docker-demo',
note: 'WiFi sensing infers coarse pose/presence from CSI — it is not a camera. Accuracy claims must be MEASURED vs a baseline (run `ruview_claim_check`).',
};
},
},
ruview_claim_check: {
title: 'Claim check',
description: 'Static lint: scan text for untagged or overstated accuracy claims (the "prove everything" guardrail). Returns findings. Fail-closed: empty input is an error, not a pass.',
inputSchema: {
type: 'object',
required: ['text'],
properties: { text: { type: 'string', description: 'The text to lint (a report, README section, PR body, model card).' } },
},
handler(args = {}) {
const text = typeof args.text === 'string' ? args.text : '';
if (text.trim().length === 0) {
return { ok: false, reason: 'empty_text', hint: 'Pass the text to lint — an empty input must not pass an honesty gate.' };
}
const result = claimCheck(text);
return { ...result, summary: summarize(result) };
},
},
ruview_verify: {
title: 'Verify (witness)',
description: 'Run the deterministic proof (archive/v1/data/proof/verify.py) and report VERDICT. Fail-closed if not in a RuView repo or python is missing.',
inputSchema: {
type: 'object',
properties: { repo: { type: 'string', description: 'RuView repo root. Default: auto-detect from cwd.' } },
},
async handler(args = {}) {
const repo = args.repo ? resolve(args.repo) : findRepoRoot();
if (!repo) return { ok: false, reason: 'not_in_ruview_repo', hint: 'Run inside the RuView monorepo or pass {repo}.' };
const proof = join(repo, 'archive', 'v1', 'data', 'proof', 'verify.py');
if (!existsSync(proof)) return { ok: false, reason: 'proof_missing', path: proof };
const py = which('python') || which('python3');
if (!py) return { ok: false, reason: 'python_missing', hint: 'Install python to run the deterministic proof.' };
const r = await run(py, [proof], { cwd: repo, timeout: 180000 });
const verdict = /VERDICT:\s*PASS/i.test(r.stdout) ? 'PASS' : (/VERDICT:\s*FAIL/i.test(r.stdout) ? 'FAIL' : 'UNKNOWN');
return { ok: r.ok && verdict === 'PASS', verdict, exit: r.status, tail: r.stdout.slice(-1200), stderr: r.stderr.slice(-400) };
},
},
ruview_node_monitor: {
title: 'Node monitor',
description: 'Open an ESP32 serial port and assert CSI is flowing (MGMT+DATA). Fail-closed if python+pyserial or the port is absent. Read-only.',
inputSchema: {
type: 'object',
properties: {
port: { type: 'string', description: 'Serial port, e.g. COM8 or /dev/ttyUSB0.' },
seconds: { type: 'number', description: 'Capture window (default 12).' },
},
},
async handler(args = {}) {
const port = args.port;
if (!port || typeof port !== 'string') return { ok: false, reason: 'no_port', hint: 'Pass {port} (e.g. COM8).' };
const py = which('python') || which('python3');
if (!py) return { ok: false, reason: 'python_missing' };
const dur = Number(args.seconds) > 0 ? Number(args.seconds) : 12;
const r = await run(py, ['-c', MONITOR_SCRIPT, port, String(dur)], { timeout: (dur + 10) * 1000 });
if (r.stdout.includes('NO_PYSERIAL')) return { ok: false, reason: 'pyserial_missing', hint: 'pip install pyserial' };
if (!r.ok) return { ok: false, reason: 'port_error', stderr: r.stderr, error: r.error };
const csi = Number((r.stdout.match(/CSI=(\d+)/) || [])[1] || 0);
const upgraded = r.stdout.includes('UPGRADE_MGMT_DATA');
return { ok: csi > 0, csi_callbacks: csi, mgmt_data_upgrade: upgraded, raw: r.stdout.trim() };
},
},
ruview_calibrate: {
title: 'Calibrate room',
description: 'Run the ADR-151 room pipeline via the wifi-densepose CLI (baseline→enroll→train-room). Fail-closed if the binary is absent.',
inputSchema: {
type: 'object',
properties: {
step: { type: 'string', enum: ['baseline', 'enroll', 'train-room', 'room-watch'], description: 'Which calibration step.' },
args: { type: 'array', items: { type: 'string' }, description: 'Extra CLI args passed through.' },
},
},
async handler(args = {}) {
const step = args.step || 'baseline';
const bin = which('wifi-densepose');
const repo = findRepoRoot();
if (!bin && !repo) return { ok: false, reason: 'cli_missing', hint: 'Install the wifi-densepose CLI or run in the repo (cargo run -p wifi-densepose-cli).' };
const passthru = Array.isArray(args.args) ? args.args.map(String) : [];
// Prefer the installed binary; otherwise cargo-run from the repo.
const r = bin
? await run(bin, [step, ...passthru], { timeout: 300000 })
: await run('cargo', ['run', '-q', '-p', 'wifi-densepose-cli', '--', step, ...passthru], { cwd: repo, timeout: 600000 });
return { ok: r.ok, step, via: bin ? 'binary' : 'cargo', exit: r.status, tail: r.stdout.slice(-1500), stderr: r.stderr.slice(-500) };
},
},
ruview_node_flash: {
title: 'Node flash',
description: 'Build+flash an ESP32 firmware variant. MUTATING + hardware. Fail-closed off-Windows or without ESP-IDF. Never claims hardware validation without a boot log.',
inputSchema: {
type: 'object',
properties: {
port: { type: 'string', description: 'Target port, e.g. COM8.' },
variant: { type: 'string', enum: ['s3-8mb', 's3-4mb', 'c6'], description: 'Firmware variant.' },
confirm: { type: 'boolean', description: 'Must be true to actually flash (guard).' },
},
},
handler(args = {}) {
if (process.platform !== 'win32') {
return { ok: false, reason: 'unsupported_platform', detail: 'The ESP-IDF flash flow is Windows-subprocess-specific today (see CLAUDE.local.md).' };
}
if (!args.confirm) {
return { ok: false, reason: 'not_confirmed', detail: 'Mutating hardware op — re-call with {confirm:true}.', would_flash: { port: args.port, variant: args.variant || 's3-8mb' } };
}
return { ok: false, reason: 'manual_step_required', detail: 'Flashing uses the pinned ESP-IDF subprocess in CLAUDE.local.md. This tool returns the exact command rather than running an unattended flash.', see: 'skills/provision-node.md' };
},
},
};
// Historical dotted names (pre-ADR-263) accepted as call-time aliases; the
// underscore form is what tools/list advertises.
export const TOOL_ALIASES = Object.fromEntries(
Object.keys(TOOLS).map((name) => [name.replace(/_/, '.'), name])
);
/** Resolve a canonical or aliased tool name (or null). */
export function resolveToolName(name) {
if (TOOLS[name]) return name;
if (TOOL_ALIASES[name]) return TOOL_ALIASES[name];
return null;
}
/** Run one tool by name (canonical or dotted alias); always resolves to the structured result. */
export async function runTool(name, args) {
const canonical = resolveToolName(name);
if (!canonical) return { ok: false, reason: 'unknown_tool', name, available: Object.keys(TOOLS) };
try {
return await TOOLS[canonical].handler(args || {});
} catch (err) {
return { ok: false, reason: 'tool_threw', name: canonical, error: String(err && err.message || err) };
}
}
/** MCP-shaped tool list: [{name, description, inputSchema}]. */
export function listTools() {
return Object.entries(TOOLS).map(([name, t]) => ({ name, description: t.description, inputSchema: t.inputSchema }));
}
+148
View File
@@ -0,0 +1,148 @@
// SPDX-License-Identifier: MIT
// MCP stdio server e2e — spawns `bin/cli.js mcp start` and speaks JSON-RPC.
// Pins ADR-263 O2 (ping answered while a long tools/call runs), O6 (version
// from package.json), and O8 (underscore names advertised, dotted accepted,
// resources/prompts stubs).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { which } from '../src/tools.js';
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const CLI = join(PKG_ROOT, 'bin', 'cli.js');
/** Start the MCP server; returns {send, next, close} where next(id) resolves the response with that id. */
function startServer() {
const child = spawn(process.execPath, [CLI, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
const waiters = new Map();
let buf = '';
child.stdout.on('data', (d) => {
buf += d;
let nl;
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
const msg = JSON.parse(line);
const w = waiters.get(msg.id);
if (w) { waiters.delete(msg.id); w(msg); }
}
});
return {
send(msg) { child.stdin.write(JSON.stringify(msg) + '\n'); },
next(id) { return new Promise((res) => waiters.set(id, res)); },
close() { child.stdin.end(); child.kill(); },
};
}
test('MCP handshake: initialize reports the package.json version; list endpoints respond', async () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
const s = startServer();
try {
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
const init = await s.next(1);
assert.equal(init.result.serverInfo.version, pkg.version, 'ADR-263 O6: version must match package.json');
s.send({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
const tools = (await s.next(2)).result.tools;
assert.equal(tools.length, 6);
for (const t of tools) assert.match(t.name, /^[a-zA-Z0-9_-]{1,64}$/, `advertised name not host-safe: ${t.name}`);
s.send({ jsonrpc: '2.0', id: 3, method: 'resources/list' });
assert.deepEqual((await s.next(3)).result, { resources: [] });
s.send({ jsonrpc: '2.0', id: 4, method: 'prompts/list' });
assert.deepEqual((await s.next(4)).result, { prompts: [] });
// Dotted legacy name still callable (alias).
s.send({ jsonrpc: '2.0', id: 5, method: 'tools/call', params: { name: 'ruview.onboard', arguments: {} } });
const call = await s.next(5);
assert.equal(call.result.isError, false);
} finally {
s.close();
}
});
test('MCP server answers ping while a long tools/call is in flight (ADR-263 O2)', { skip: !which('python') && !which('python3') ? 'python not on PATH' : false }, async () => {
// Fake RuView repo whose verify.py sleeps 3 s then passes.
const repo = mkdtempSync(join(tmpdir(), 'ruview-mcp-e2e-'));
const proofDir = join(repo, 'archive', 'v1', 'data', 'proof');
mkdirSync(proofDir, { recursive: true });
writeFileSync(join(proofDir, 'verify.py'), 'import time\ntime.sleep(3)\nprint("VERDICT: PASS")\n');
const s = startServer();
try {
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
await s.next(1);
const verifyDone = s.next(10);
s.send({ jsonrpc: '2.0', id: 10, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
// Give the server a beat to start the child, then ping.
await new Promise((r) => setTimeout(r, 300));
const t0 = Date.now();
const pinged = s.next(11);
s.send({ jsonrpc: '2.0', id: 11, method: 'ping' });
await pinged;
const pingMs = Date.now() - t0;
assert.ok(pingMs < 1000, `ping took ${pingMs} ms while verify was in flight — server is blocking`);
const verify = await verifyDone;
const payload = JSON.parse(verify.result.content[0].text);
assert.equal(payload.verdict, 'PASS');
} finally {
s.close();
rmSync(repo, { recursive: true, force: true });
}
});
test('tools/call executions are serialized — two slow calls run sequentially', { skip: !which('python') && !which('python3') ? 'python not on PATH' : false }, async () => {
// Two verify.py that each sleep 0.8 s. Serialized ⇒ ~1.6 s+; concurrent ⇒ ~0.8 s.
const repo = mkdtempSync(join(tmpdir(), 'ruview-mcp-serial-'));
const proofDir = join(repo, 'archive', 'v1', 'data', 'proof');
mkdirSync(proofDir, { recursive: true });
writeFileSync(join(proofDir, 'verify.py'), 'import time\ntime.sleep(0.8)\nprint("VERDICT: PASS")\n');
const s = startServer();
try {
s.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
await s.next(1);
const t0 = Date.now();
const a = s.next(20);
const b = s.next(21);
s.send({ jsonrpc: '2.0', id: 20, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
s.send({ jsonrpc: '2.0', id: 21, method: 'tools/call', params: { name: 'ruview_verify', arguments: { repo } } });
const [ra, rb] = await Promise.all([a, b]);
const elapsed = Date.now() - t0;
assert.equal(JSON.parse(ra.result.content[0].text).verdict, 'PASS');
assert.equal(JSON.parse(rb.result.content[0].text).verdict, 'PASS');
assert.ok(elapsed > 1400, `two 0.8 s tool calls finished in ${elapsed} ms — they overlapped instead of serializing`);
} finally {
s.close();
rmSync(repo, { recursive: true, force: true });
}
});
test('stdin close flushes an in-flight tools/call response before exit', async () => {
const child = spawn(process.execPath, [CLI, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
let out = '';
child.stdout.on('data', (d) => { out += d; });
const exited = new Promise((res) => child.on('exit', res));
// Write a tools/call then immediately close stdin. The old fire-and-forget
// dispatch raced rl 'close' → process.exit and could drop this response.
child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'tools/call', params: { name: 'ruview_onboard', arguments: {} } }) + '\n');
child.stdin.end();
await exited;
const msgs = out.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
const resp = msgs.find((m) => m.id === 42);
assert.ok(resp, 'the in-flight tools/call response must be flushed to stdout before exit');
assert.equal(resp.result.isError, false);
});
+256
View File
@@ -0,0 +1,256 @@
// SPDX-License-Identifier: MIT
// RuView harness tests — Node's built-in test runner (no devDeps to install).
// Run: `node --test test/*.test.mjs` (or `npm test`).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync, readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { join, dirname, delimiter } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { claimCheck, summarize } from '../src/guardrails.js';
import { TOOLS, TOOL_ALIASES, runTool, listTools, findRepoRoot, run, which } from '../src/tools.js';
import { run as cliRun } from '../bin/cli.js';
const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
test('guardrail flags the retracted 100% framing as high severity', () => {
const r = claimCheck('Our model reaches 100% accuracy on every pose.');
assert.equal(r.ok, false);
assert.ok(r.findings.some((f) => f.severity === 'high'));
});
test('guardrail flags an untagged percentage accuracy claim', () => {
// "hit", not "measured" — "measured" would (correctly) route to the no-reproducer branch.
const r = claimCheck('We hit 92.9% PCK on the test set.');
assert.equal(r.ok, false);
assert.ok(r.findings.some((f) => /not tagged/i.test(f.reason)));
});
test('guardrail passes a MEASURED claim that cites a reproducer', () => {
const r = claimCheck('Held-out PCK@20 59.5% vs 50% mean-pose baseline = +9.4pp (MEASURED, verify.py).');
assert.equal(r.ok, true, JSON.stringify(r.findings));
});
test('guardrail flags MEASURED with no reproducer', () => {
const r = claimCheck('Presence detection 97% (MEASURED).');
assert.equal(r.ok, false);
assert.ok(r.findings.some((f) => /no reproducer/i.test(f.reason)));
});
test('guardrail ignores non-metric prose', () => {
assert.equal(claimCheck('The ESP32 streams CSI over UDP to the sensing-server.').ok, true);
assert.equal(claimCheck('').ok, true);
});
// ADR-263 F11/O9: precision pins — short metric tokens must not fire on prose.
test('guardrail does not false-positive on "map"/"F1" prose (ADR-263 F11)', () => {
assert.equal(claimCheck('F-numbers map to findings.').ok, true);
assert.equal(claimCheck('### F1 (HIGH, broken export): `require` points at a missing file').ok, true);
assert.equal(claimCheck('The 0.1.0 tarball ships 44 `.map` files = 62,698 B of dead weight.').ok, true);
assert.equal(claimCheck('the source maps can never resolve').ok, true);
assert.equal(claimCheck('- **O1 (F1):** fix `exports` (see F2 for the 33% map weight — MEASURED, tarball listing)').ok, true);
assert.equal(claimCheck('ADR-264: exports fix, map-free tarball, session-per-transport').ok, true);
});
test('guardrail still catches real short-token metric claims', () => {
assert.equal(claimCheck('We reach mAP 62.3 on COCO.').ok, false);
assert.equal(claimCheck('F1 score of 0.91 on the held set.').ok, false, 'f1 with a real score must still fire');
assert.equal(claimCheck('IoU 0.75 across rooms.').ok, false);
});
// Digits hidden in a code span still make a claim — scrubbing must not blind the
// number gate to `0.95` (regression: code-span number bypassed the gate).
test('guardrail flags an accuracy number stated inside a code span', () => {
const r = claimCheck('Count accuracy reached `0.95` in our tests.');
assert.equal(r.ok, false, JSON.stringify(r.findings));
assert.ok(r.findings.some((f) => /not tagged/i.test(f.reason)));
});
// A MEASURED claim whose only number hides in a code span must still reach the
// missing-reproducer check (regression: the scrubbed gate short-circuited it).
// Bare metric prose with no number at all (e.g. the README rule text) stays a pass.
test('guardrail flags a MEASURED code-span number with no reproducer', () => {
const r = claimCheck('Detection accuracy `0.97` on the set (MEASURED).');
assert.equal(r.ok, false, JSON.stringify(r.findings));
assert.ok(r.findings.some((f) => /no reproducer/i.test(f.reason)));
assert.equal(claimCheck('Every accuracy number must be MEASURED against a baseline.').ok, true);
});
// F1-score phrasings ("F1: 0.91", "F1 reaches 0.91") were scrubbed as option
// labels and slipped through; option refs alone must still not false-positive.
test('guardrail catches F1-score claims but not bare option refs (ADR-263 F11)', () => {
assert.equal(claimCheck('F1: 0.91 on the held-out set.').ok, false, 'F1: value is a metric claim');
assert.equal(claimCheck('F1 reaches 0.91 on the held-out set.').ok, false, 'F1 with a nearby number is a claim');
assert.equal(claimCheck('Options O1O9 are tracked in ADR-263 O2.').ok, true, 'option labels are not metrics');
assert.equal(claimCheck('ADR-263 O2 lands the exports fix.').ok, true);
});
test('summarize gives PASS/finding text', () => {
assert.match(summarize(claimCheck('nothing here')), /PASS/);
assert.match(summarize(claimCheck('100% accuracy')), /finding/);
});
test('registry exposes the documented tools with schemas (underscore-canonical)', () => {
const names = Object.keys(TOOLS);
for (const n of ['ruview_onboard', 'ruview_claim_check', 'ruview_verify', 'ruview_node_monitor', 'ruview_calibrate', 'ruview_node_flash']) {
assert.ok(names.includes(n), `missing ${n}`);
assert.equal(TOOLS[n].inputSchema.type, 'object');
assert.match(n, /^[a-zA-Z0-9_-]{1,64}$/, 'canonical names must satisfy host tool-name regexes');
}
assert.equal(listTools().length, names.length);
});
test('dotted legacy names resolve via aliases (ADR-263 O8)', async () => {
assert.equal(TOOL_ALIASES['ruview.claim_check'], 'ruview_claim_check');
assert.equal(TOOL_ALIASES['ruview.node_monitor'], 'ruview_node_monitor');
const r = await runTool('ruview.onboard', {});
assert.equal(r.ok, true);
});
test('ruview_onboard returns paths and a recommendation', async () => {
const r = await runTool('ruview_onboard', {});
assert.equal(r.ok, true);
assert.ok(r.paths['live-esp32']);
assert.ok(['repo-build', 'docker-demo'].includes(r.recommend));
});
test('ruview_claim_check tool wraps the guardrail', async () => {
const r = await runTool('ruview_claim_check', { text: '100% accuracy' });
assert.equal(r.ok, false);
assert.match(r.summary, /honesty|tag|MEASURED|finding/i);
});
// ADR-263 F1/O1: the honesty gate must fail closed on empty input.
test('ruview_claim_check fails closed on empty/missing text', async () => {
const empty = await runTool('ruview_claim_check', { text: '' });
assert.equal(empty.ok, false);
assert.equal(empty.reason, 'empty_text');
const missing = await runTool('ruview_claim_check', {});
assert.equal(missing.ok, false);
assert.equal(missing.reason, 'empty_text');
});
test('unknown tool fails closed', async () => {
const r = await runTool('ruview_does_not_exist', {});
assert.equal(r.ok, false);
assert.equal(r.reason, 'unknown_tool');
});
test('node_monitor fails closed without a port', async () => {
const r = await runTool('ruview_node_monitor', {});
assert.equal(r.ok, false);
assert.equal(r.reason, 'no_port');
});
test('node_flash refuses without confirm (mutating guard)', async () => {
const r = await runTool('ruview_node_flash', { port: 'COM8', variant: 's3-8mb' });
assert.equal(r.ok, false);
// either not-confirmed (win32) or unsupported_platform (posix) — both fail-closed
assert.ok(['not_confirmed', 'unsupported_platform'].includes(r.reason));
});
test('verify fails closed when not in a RuView repo', async () => {
// point at a tmp dir with no repo markers
const r = await runTool('ruview_verify', { repo: process.platform === 'win32' ? 'C:/Windows/Temp' : '/tmp' });
assert.equal(r.ok, false);
assert.ok(['proof_missing', 'python_missing'].includes(r.reason), r.reason);
});
// ADR-263 F2/O2: registry-level concurrency — a slow child must not block
// other tool calls (run() is promise-based, never spawnSync).
test('run() is non-blocking: a fast tool completes while a slow child runs', async () => {
const slow = run('node', ['-e', 'setTimeout(() => {}, 2000)'], { timeout: 5000 });
const t0 = Date.now();
const fast = await runTool('ruview_onboard', {});
const elapsed = Date.now() - t0;
assert.equal(fast.ok, true);
assert.ok(elapsed < 1000, `onboard took ${elapsed} ms while a 2 s child was running`);
const r = await slow;
assert.equal(r.ok, true);
});
test('run() reports a timeout as a failure, not a hang', async () => {
const r = await run('node', ['-e', 'setTimeout(() => {}, 10000)'], { timeout: 300 });
assert.equal(r.ok, false);
assert.match(String(r.error), /timed out/);
});
test('run() bounds captured output instead of dying on big streams (ADR-263 O4)', async () => {
// 4 MiB of stdout would have hit spawnSync's 1 MiB default maxBuffer (ENOBUFS).
const r = await run('node', ['-e', "process.stdout.write('x'.repeat(4 * 1024 * 1024)); console.log('TAIL_MARKER')"], { timeout: 30000 });
assert.equal(r.ok, true);
assert.ok(r.stdout.length <= 65536, `tail not bounded: ${r.stdout.length}`);
assert.ok(r.stdout.includes('TAIL_MARKER'), 'tail must keep the end of the stream');
});
test('which() finds node and re-probes misses (hits are cached)', () => {
assert.ok(which('node'), 'node must be on PATH in the test env');
assert.equal(which('definitely-not-a-binary-xyz'), null);
assert.equal(which('definitely-not-a-binary-xyz'), null); // re-probed, still absent
});
// ADR-263 O8: a miss must not be cached — an operator who installs a tool
// mid-session (e.g. python after a python_missing failure) must be found next call.
test('which() re-probes after a miss so a newly-installed tool is found', () => {
const dir = mkdtempSync(join(tmpdir(), 'ruview-which-'));
const name = 'ruview-probe-xyz';
const isWin = process.platform === 'win32';
const bin = join(dir, isWin ? `${name}.cmd` : name);
const prevPath = process.env.PATH;
try {
assert.equal(which(name), null, 'not on PATH yet → miss');
writeFileSync(bin, isWin ? '@echo off\n' : '#!/bin/sh\n', { mode: 0o755 });
process.env.PATH = dir + delimiter + prevPath;
assert.ok(which(name), 'installed mid-session → the miss must not have been cached');
} finally {
process.env.PATH = prevPath;
rmSync(dir, { recursive: true, force: true });
}
});
test('CLI run(): claim-check exits non-zero on a bad claim', async () => {
const code = await cliRun(['claim-check', '--text', '100% accuracy']);
assert.notEqual(code, 0);
});
// ADR-263 F1/O1: the CLI must not PASS silently with no input.
test('CLI run(): claim-check with no input exits 2 (fail-closed)', async () => {
assert.equal(await cliRun(['claim-check']), 2);
assert.equal(await cliRun(['claim-check', '--text', ' ']), 2);
});
test('CLI run(): doctor exits 0 (tools-only path)', async () => {
const code = await cliRun(['doctor']);
assert.equal(code, 0);
});
test('CLI run(): unknown command exits non-zero', async () => {
assert.notEqual(await cliRun(['definitely-not-a-command']), 0);
});
test('findRepoRoot locates this monorepo from cwd', () => {
// when run from within wifi-densepose, it should find a root; elsewhere null is fine
const root = findRepoRoot();
assert.ok(root === null || typeof root === 'string');
});
// ADR-263 F7/O7: skills ship from one source; the projected copies must match.
test('.claude/skills/*/SKILL.md are byte-identical to skills/*.md', () => {
const srcDir = join(PKG_ROOT, 'skills');
for (const f of readdirSync(srcDir).filter((f) => f.endsWith('.md'))) {
const name = f.replace(/\.md$/, '');
const src = readFileSync(join(srcDir, f), 'utf8');
const projected = readFileSync(join(PKG_ROOT, '.claude', 'skills', name, 'SKILL.md'), 'utf8');
assert.equal(projected, src, `skill drift: ${name} — run \`npm run sync-skills\``);
}
});
// ADR-263 F6/O6 + F3/O3: package hygiene pins.
test('package.json has no optionalDependencies and no hardcoded server version drift', () => {
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
assert.equal(pkg.optionalDependencies, undefined, 'ADR-263 O3: optional deps tripled the cold npx install');
assert.equal(pkg.dependencies, undefined, 'the harness is dependency-free by design');
const mcpSrc = readFileSync(join(PKG_ROOT, 'src', 'mcp-server.js'), 'utf8');
assert.ok(!/version:\s*'\d+\.\d+\.\d+'/.test(mcpSrc), 'ADR-263 O6: server version must come from package.json');
});
Binary file not shown.
BIN
View File
Binary file not shown.
+32 -6
View File
@@ -184,7 +184,9 @@ function loadGroundTruth(filePath) {
const raw = loadJsonl(filePath);
const frames = [];
for (const r of raw) {
if (r.ts_ns == null || !r.keypoints) continue;
// Skip non-detection frames (empty keypoints []) — they must not dilute window
// confidence; confidence stats are over actual detections only (#1007 Bug 2).
if (r.ts_ns == null || !r.keypoints || r.keypoints.length === 0) continue;
frames.push({
tsMs: cameraTsToMs(r.ts_ns),
keypoints: r.keypoints,
@@ -266,7 +268,29 @@ function loadCsi(filePath) {
// Sort by timestamp
rawCsi.sort((a, b) => a.tsMs - b.tsMs);
features.sort((a, b) => a.tsMs - b.tsMs);
return { rawCsi, features };
// Bug 3 (#1007): keep only frames at the session's MODAL subcarrier count so windows
// are homogeneous; never silently zero-pad/truncate the off-format frames the ESP32
// emits (HT20/HT40/fragments). extractCsiMatrix then sees uniform-width frames.
return { rawCsi: filterToModalSubcarriers(rawCsi), features };
}
/**
* Keep only frames whose subcarrier count equals the session's modal (most common)
* count. Off-format frames are dropped (logged), not padded prevents the silent
* zero-padding that corrupted windows in #1007.
*/
function filterToModalSubcarriers(frames) {
if (frames.length === 0) return frames;
const counts = new Map();
for (const f of frames) counts.set(f.subcarriers, (counts.get(f.subcarriers) || 0) + 1);
let modal = frames[0].subcarriers, best = 0;
for (const [sc, n] of counts) if (n > best) { best = n; modal = sc; }
const kept = frames.filter((f) => f.subcarriers === modal);
if (kept.length !== frames.length) {
console.error(`[align] #1007: kept ${kept.length}/${frames.length} CSI frames at modal subcarrier count ${modal} (dropped ${frames.length - kept.length} off-format; no silent padding)`);
}
return kept;
}
// ---------------------------------------------------------------------------
@@ -343,7 +367,8 @@ function averageKeypoints(cameraFrames) {
/**
* Extract CSI amplitude matrix from raw_csi window.
* Returns { data: flat Float32Array, shape: [subcarriers, windowFrames] }.
* Fill is frame-major (matrix[f*nSc + s]), so shape is [windowFrames, subcarriers]
* (#1007 Bug 4 was mislabeled [subcarriers, windowFrames], transposing consumers).
*/
function extractCsiMatrix(window) {
const nFrames = window.length;
@@ -363,12 +388,13 @@ function extractCsiMatrix(window) {
}
}
return { data: Array.from(matrix), shape: [nSc, nFrames] };
return { data: Array.from(matrix), shape: [nFrames, nSc] };
}
/**
* Extract feature matrix from feature-type window.
* Returns { data: flat array, shape: [featureDim, windowFrames] }.
* Fill is frame-major (matrix[f*dim + d]), so shape is [windowFrames, featureDim]
* (#1007 Bug 4 was mislabeled [featureDim, windowFrames]).
*/
function extractFeatureMatrix(window) {
const nFrames = window.length;
@@ -382,7 +408,7 @@ function extractFeatureMatrix(window) {
}
}
return { data: Array.from(matrix), shape: [dim, nFrames] };
return { data: Array.from(matrix), shape: [nFrames, dim] };
}
// ---------------------------------------------------------------------------
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
#
# firmware-release-guard.sh — guard against shipping firmware built from a
# stale generated `sdkconfig` (the v0.8.3-esp32 release bug).
#
# Symptom it catches: an incremental build reuses a leftover `sdkconfig`
# instead of `sdkconfig.defaults`, so an "8MB" build silently links the 4MB
# dual-OTA partition layout (no spiffs, ota_1 @ 0x1F0000) and the released
# `partition-table.bin` does not match the flash-size variant it claims to be.
#
# What it does: for the named flash-size variant, regenerate the EXPECTED
# partition table from the partition CSV that variant must use, and byte-compare
# it against the freshly built `partition-table.bin`. Also cross-checks the
# flash size recorded in the build's `flasher_args.json`. Exits non-zero on any
# mismatch so a release pipeline fails closed.
#
# Usage:
# scripts/firmware-release-guard.sh <8mb|4mb> <build-dir>
#
# Example:
# scripts/firmware-release-guard.sh 8mb firmware/esp32-csi-node/build
#
set -euo pipefail
VARIANT="${1:-}"
BUILD_DIR="${2:-}"
if [[ -z "$VARIANT" || -z "$BUILD_DIR" ]]; then
echo "usage: $0 <8mb|4mb> <build-dir>" >&2
exit 2
fi
# Firmware project root (this script lives in <repo>/scripts).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FW_DIR="$SCRIPT_DIR/../firmware/esp32-csi-node"
case "$VARIANT" in
8mb) EXPECT_CSV="partitions_display.csv"; EXPECT_FLASH="8MB" ;;
4mb) EXPECT_CSV="partitions_4mb.csv"; EXPECT_FLASH="4MB" ;;
*) echo "ERROR: unknown variant '$VARIANT' (want 8mb|4mb)" >&2; exit 2 ;;
esac
BUILT_PT="$BUILD_DIR/partition_table/partition-table.bin"
CSV_PATH="$FW_DIR/$EXPECT_CSV"
[[ -f "$BUILT_PT" ]] || { echo "ERROR: built partition table not found: $BUILT_PT" >&2; exit 1; }
[[ -f "$CSV_PATH" ]] || { echo "ERROR: expected CSV not found: $CSV_PATH" >&2; exit 1; }
# Locate the ESP-IDF partition table generator.
GEN="${IDF_PATH:-}/components/partition_table/gen_esp32part.py"
if [[ ! -f "$GEN" ]]; then
GEN="C:/Users/ruv/esp/v5.4/esp-idf/components/partition_table/gen_esp32part.py"
fi
[[ -f "$GEN" ]] || { echo "ERROR: gen_esp32part.py not found (set IDF_PATH)" >&2; exit 1; }
PY="${PYTHON:-python}"
command -v "$PY" >/dev/null 2>&1 || PY="C:/Espressif/tools/python/v5.4/venv/Scripts/python.exe"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
EXPECT_PT="$TMP/expected-partition-table.bin"
# Regenerate the expected table from the CSV this variant must use.
"$PY" "$GEN" --quiet "$CSV_PATH" "$EXPECT_PT"
fail=0
if ! cmp -s "$EXPECT_PT" "$BUILT_PT"; then
echo "FAIL: built partition table does not match $EXPECT_CSV for the $VARIANT variant." >&2
echo " The build likely reused a stale sdkconfig. Decoded built table:" >&2
"$PY" "$GEN" "$BUILT_PT" 2>/dev/null | grep -vE '^#|^Parsing|^Verifying' | sed 's/^/ /' >&2
fail=1
fi
# Cross-check the flash size the build actually targeted.
FA="$BUILD_DIR/flasher_args.json"
if [[ -f "$FA" ]]; then
GOT_FLASH="$("$PY" - "$FA" <<'PYEOF'
import json,sys
with open(sys.argv[1]) as f: d=json.load(f)
print(d.get("flash_settings",{}).get("flash_size",""))
PYEOF
)"
if [[ "$GOT_FLASH" != "$EXPECT_FLASH" ]]; then
echo "FAIL: flasher_args.json flash_size='$GOT_FLASH', expected '$EXPECT_FLASH'." >&2
fail=1
fi
fi
if [[ "$fail" -ne 0 ]]; then
exit 1
fi
echo "OK: $VARIANT firmware build matches $EXPECT_CSV (flash_size=$EXPECT_FLASH)."
+3 -1
View File
@@ -15,6 +15,7 @@ import os
import socket
import struct
import time
from datetime import datetime, timezone
def parse_csi_packet(data):
@@ -41,7 +42,8 @@ def parse_csi_packet(data):
return {
"type": "raw_csi",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(time.time() * 1000) % 1000:03d}Z",
# true UTC, not local-time-labeled-Z (#1007 Bug 1) — e.g. "2026-06-17T01:23:45.678Z"
"timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
"ts_ns": time.time_ns(),
"node_id": node_id,
"rssi": rssi,
+1 -1
View File
@@ -12,7 +12,7 @@
"yargs": "^17.7.2"
},
"bin": {
"ruview": "dist/index.js"
"ruview-cli": "dist/index.js"
},
"devDependencies": {
"@types/node": "^20.14.0",
+3 -3
View File
@@ -1,13 +1,13 @@
{
"name": "@ruv/ruview-cli",
"version": "0.0.1",
"description": "RuView CLI — shell access to WiFi-DensePose sensing, inference, and training capabilities",
"description": "RuView CLI — shell access to WiFi-DensePose sensing, inference, and training capabilities. Private/unpublished; the `ruview` bin name belongs to @ruvnet/ruview (ADR-265 D4).",
"private": true,
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"ruview": "dist/index.js"
"ruview-cli": "dist/index.js"
},
"files": [
"dist"
@@ -15,7 +15,7 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "node --experimental-vm-modules node_modules/.bin/jest",
"test": "node --experimental-vm-modules node_modules/.bin/jest --passWithNoTests",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit"
},
+9 -2
View File
@@ -25,6 +25,7 @@
* See ADR-104 for the full design rationale and security model.
*/
import { createRequire } from "node:module";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { csiCommand } from "./commands/csi.js";
@@ -34,9 +35,15 @@ import { cogsCommand } from "./commands/cogs.js";
import { trainCommand } from "./commands/train.js";
import { jobCommand } from "./commands/job.js";
// Single-source the version from package.json (ADR-265 D3).
const require = createRequire(import.meta.url);
const VERSION: string = (require("../package.json") as { version: string }).version;
// Bin name is `ruview-cli`: the bare `ruview` bin belongs to @ruvnet/ruview
// (ADR-264 O9 / ADR-265 D4).
const cli = yargs(hideBin(process.argv))
.scriptName("ruview")
.version("0.0.1")
.scriptName("ruview-cli")
.version(VERSION)
.usage("$0 <command> [options]")
.strict()
.help()
+34 -23
View File
@@ -2,52 +2,63 @@
**SENSE-BRIDGE** is a dual-transport [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that bridges the RuView WiFi-DensePose sensing stack to AI agents (Claude Code, Cursor, ruflo swarms, and any MCP-compatible client).
Install once; AI agents can then call `ruview.presence.now`, `ruview.vitals.get_heart_rate`, `ruview.bfld.last_scan`, and more — without writing HTTP or WebSocket client code.
Install once; AI agents can then call `ruview_presence_now`, `ruview_vitals_get_heart_rate`, `ruview_bfld_last_scan`, and more — without writing HTTP or WebSocket client code.
## Quickstart
```bash
# 1. Add to Claude Code
claude mcp add rvagent -- npx @ruvnet/rvagent stdio
# 1. Add to Claude Code (stdio transport — the default)
claude mcp add rvagent -- npx -y @ruvnet/rvagent
# 2. Or run directly
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 npx @ruvnet/rvagent stdio
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 npx @ruvnet/rvagent
# 3. Streamable HTTP (remote agents, ruflo swarms)
# 3. Streamable HTTP (remote agents, ruflo swarms) — explicit opt-in
RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 \
RVAGENT_HTTP_TOKEN=your-secret \
npx @ruvnet/rvagent http --port 3001
# POST JSON-RPC to http://127.0.0.1:3001/mcp
RVAGENT_HTTP_PORT=3001 npx @ruvnet/rvagent
# POST JSON-RPC to http://127.0.0.1:3001/mcp (initialize first; then send the
# returned mcp-session-id header on every request)
```
Requirements: **Node.js >= 20**. The `wifi-densepose-sensing-server` Rust binary must be reachable at `RUVIEW_SENSING_SERVER_URL` (default `http://localhost:3000`).
## Feature matrix
## Tools
Canonical tool names are underscore-form (ADR-264 — host tool-name validators
commonly enforce `^[a-zA-Z0-9_-]{1,64}$`). The pre-0.1.1 dotted names
(`ruview.presence.now`, …) are still accepted at call time as deprecated
aliases; `tools/list` advertises the underscore form only.
| Tool | Description | ADR |
|------|-------------|-----|
| `ruview.presence.now` | Current occupancy: `present`, `n_persons`, `confidence` | ADR-124 §4.1 |
| `ruview.vitals.get_breathing` | Breathing rate bpm (null if unavailable) | ADR-124 §4.1 |
| `ruview.vitals.get_heart_rate` | Heart rate bpm (null if unavailable) | ADR-124 §4.1 |
| `ruview.vitals.get_all` | Full `EdgeVitalsMessage` surface | ADR-124 §4.1 |
| `ruview.bfld.last_scan` | Latest BFLD scan: `identity_risk_score`, `privacy_class`, `n_frames` | ADR-118/124 |
| `ruview.bfld.subscribe` | Subscribe to `ruview/<node_id>/bfld/*` events for `duration_s` seconds | ADR-122/124 |
| *(next iters)* | `pose.latest`, `primitives.*`, `node.*`, `vector.*`, `policy.*` | ADR-124 §4.1/4.1a |
| `ruview_csi_latest` | Latest 56×20 CSI window from the sensing-server | ADR-101/102 |
| `ruview_pose_infer` | Single-shot 17-keypoint pose inference via cog binary | ADR-101 |
| `ruview_count_infer` | Single-shot person-count inference via cog binary | ADR-103 |
| `ruview_registry_list` | Cognitum edge module registry (category/search filters) | ADR-102 |
| `ruview_train_count` | Kick off a count-cog training run (background job) | ADR-103 |
| `ruview_job_status` | Poll a training job (persists across server restarts) | ADR-103 |
| `ruview_presence_now` | Current occupancy: `present`, `n_persons`, `confidence` | ADR-124 §4.1 |
| `ruview_vitals_get_breathing` | Breathing rate bpm (null if unavailable) | ADR-124 §4.1 |
| `ruview_vitals_get_heart_rate` | Heart rate bpm (null if unavailable) | ADR-124 §4.1 |
| `ruview_vitals_get_all` | Full `EdgeVitalsMessage` surface | ADR-124 §4.1 |
| `ruview_bfld_last_scan` | Latest BFLD scan: `identity_risk_score`, `privacy_class`, `n_frames` | ADR-118/124 |
| `ruview_bfld_subscribe` | Subscribe to `ruview/<node_id>/bfld/*` events for `duration_s` seconds | ADR-122/124 |
| *(roadmap, ADR-124 §4.1/4.1a)* | `pose.latest`, `primitives.*`, `node.*`, `vector.*`, and the `policy.*` governance layer are catalogued in `src/schemas/` but **not yet implemented** | ADR-124 |
**Transport security (ADR-124 §6)**:
- **stdio**: process-level isolation — no auth needed for local Claude Code / Cursor.
- **Streamable HTTP** (`POST /mcp`): Origin header validation (cross-origin → 403), optional bearer token (`RVAGENT_HTTP_TOKEN` → 401 on mismatch), binds `127.0.0.1` by default per MCP spec.
**Transport security (ADR-124 §6, hardened per ADR-264)**:
- **stdio** (default): process-level isolation — no auth needed for local Claude Code / Cursor.
- **Streamable HTTP** (`/mcp`, opt-in via `RVAGENT_HTTP_PORT`): one transport + one MCP server per session (routed by `mcp-session-id`), Origin validation (localhost on any port allowed; anything else → 403), optional bearer token (`RVAGENT_HTTP_TOKEN` → 401 on mismatch), 1 MiB request-body cap (413), binds `127.0.0.1` by default per MCP spec.
**Schema validation**: every tool call runs `zod.safeParse` before dispatch; invalid arguments return `McpError(InvalidParams)` rather than a wrapped string.
**Policy layer** (ADR-124 §4.1a): `ruview.policy.*` tools gate every sensing call — `vitals.*` is default-deny until a policy grant is registered via `npx @ruvnet/rvagent policy grant`. Presence and node-list are allow by default.
**Schema validation**: each tool declares one Zod schema; the CallTool gate parses exactly once and the advertised JSON Schema is generated from the same Zod source. Invalid arguments return `McpError(InvalidParams)` rather than a wrapped string.
## ADR cross-reference
| ADR | Decision |
|-----|----------|
| [ADR-124](../../docs/adr/ADR-124-rvagent-mcp-ruvector-npm-integration.md) | SENSE-BRIDGE: dual-transport MCP server + ruvector npm + ruflo integration |
| [ADR-118](../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) | BFLD pipeline — source of `bfld.last_scan` wire format |
| [ADR-264](../../docs/adr/ADR-264-rvagent-mcp-and-cli-npm-deep-review.md) | npm deep review — exports fix, map-free tarball, naming, session-per-transport |
| [ADR-118](../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md) | BFLD pipeline — source of `bfld_last_scan` wire format |
| [ADR-122](../../docs/adr/ADR-122-bfld-ruview-ha-matter-exposure.md) | MQTT topic routing `ruview/<node_id>/bfld/*` |
| [ADR-115](../../docs/adr/ADR-115-home-assistant-integration.md) | `EdgeVitalsMessage` WebSocket surface (`ws.py:74-88` parity) |
| [ADR-055](../../docs/adr/ADR-055-integrated-sensing-server.md) | Sensing-server REST API (`/api/v1/*`) |
@@ -58,7 +69,7 @@ Requirements: **Node.js >= 20**. The `wifi-densepose-sensing-server` Rust binary
cd tools/ruview-mcp
npm install
npm run build # tsc
npm test # jest — 93 tests across 7 suites
npm test # jest — 99 tests across 7 suites
```
Source: `tools/ruview-mcp/src/`. Tests: `tools/ruview-mcp/tests/`.
+10 -373
View File
@@ -1,24 +1,24 @@
{
"name": "@ruvnet/rvagent",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@ruvnet/rvagent",
"version": "0.1.0",
"version": "0.2.0",
"license": "Apache-2.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.8"
"zod": "^3.23.8",
"zod-to-json-schema": "^3.25.2"
},
"bin": {
"ruview-mcp": "dist/index.js",
"rvagent": "dist/index.js"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^20.14.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.0",
@@ -629,16 +629,6 @@
}
}
},
"node_modules/@jest/diff-sequences": {
"version": "30.4.0",
"resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz",
"integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/environment": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz",
@@ -700,16 +690,6 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/@jest/get-type": {
"version": "30.1.0",
"resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz",
"integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/globals": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz",
@@ -726,30 +706,6 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/@jest/pattern": {
"version": "30.4.0",
"resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz",
"integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"jest-regex-util": "30.4.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/pattern/node_modules/jest-regex-util": {
"version": "30.4.0",
"resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz",
"integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@jest/reporters": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz",
@@ -1061,52 +1017,6 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
"@types/node": "*"
}
},
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0",
"@types/serve-static": "^2"
}
},
"node_modules/@types/express-serve-static-core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"@types/qs": "*",
"@types/range-parser": "*",
"@types/send": "*"
}
},
"node_modules/@types/graceful-fs": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz",
@@ -1117,13 +1027,6 @@
"@types/node": "*"
}
},
"node_modules/@types/http-errors": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -1152,229 +1055,14 @@
}
},
"node_modules/@types/jest": {
"version": "30.0.0",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz",
"integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==",
"version": "29.5.14",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz",
"integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"expect": "^30.0.0",
"pretty-format": "^30.0.0"
}
},
"node_modules/@types/jest/node_modules/@jest/expect-utils": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz",
"integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/get-type": "30.1.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/@jest/schemas": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz",
"integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sinclair/typebox": "^0.34.0"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/@jest/types": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz",
"integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/pattern": "30.4.0",
"@jest/schemas": "30.4.1",
"@types/istanbul-lib-coverage": "^2.0.6",
"@types/istanbul-reports": "^3.0.4",
"@types/node": "*",
"@types/yargs": "^17.0.33",
"chalk": "^4.1.2"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/@sinclair/typebox": {
"version": "0.34.49",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz",
"integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/jest/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@types/jest/node_modules/ci-info": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz",
"integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/sibiraj-s"
}
],
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@types/jest/node_modules/expect": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz",
"integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/expect-utils": "30.4.1",
"@jest/get-type": "30.1.0",
"jest-matcher-utils": "30.4.1",
"jest-message-util": "30.4.1",
"jest-mock": "30.4.1",
"jest-util": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-diff": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz",
"integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/diff-sequences": "30.4.0",
"@jest/get-type": "30.1.0",
"chalk": "^4.1.2",
"pretty-format": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-matcher-utils": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz",
"integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/get-type": "30.1.0",
"chalk": "^4.1.2",
"jest-diff": "30.4.1",
"pretty-format": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-message-util": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz",
"integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@jest/types": "30.4.1",
"@types/stack-utils": "^2.0.3",
"chalk": "^4.1.2",
"graceful-fs": "^4.2.11",
"jest-util": "30.4.1",
"picomatch": "^4.0.3",
"pretty-format": "30.4.1",
"slash": "^3.0.0",
"stack-utils": "^2.0.6"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-mock": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz",
"integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/types": "30.4.1",
"@types/node": "*",
"jest-util": "30.4.1"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/jest-util": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz",
"integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/types": "30.4.1",
"@types/node": "*",
"chalk": "^4.1.2",
"ci-info": "^4.2.0",
"graceful-fs": "^4.2.11",
"picomatch": "^4.0.3"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
}
},
"node_modules/@types/jest/node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/@types/jest/node_modules/pretty-format": {
"version": "30.4.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz",
"integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jest/schemas": "30.4.1",
"ansi-styles": "^5.2.0",
"react-is-18": "npm:react-is@^18.3.1",
"react-is-19": "npm:react-is@^19.2.5"
},
"engines": {
"node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0"
"expect": "^29.0.0",
"pretty-format": "^29.0.0"
}
},
"node_modules/@types/node": {
@@ -1387,41 +1075,6 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/serve-static": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
"@types/node": "*"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@@ -4355,22 +4008,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/react-is-18": {
"name": "react-is",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"dev": true,
"license": "MIT"
},
"node_modules/react-is-19": {
"name": "react-is",
"version": "19.2.6",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
"integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+10 -11
View File
@@ -1,15 +1,14 @@
{
"name": "@ruvnet/rvagent",
"version": "0.1.0",
"description": "SENSE-BRIDGE: dual-transport MCP server (stdio + Streamable HTTP) exposing RuView WiFi-DensePose sensing primitives to AI agents",
"version": "0.2.0",
"description": "SENSE-BRIDGE: dual-transport MCP server (stdio default; Streamable HTTP opt-in via RVAGENT_HTTP_PORT) exposing RuView WiFi-DensePose sensing primitives to AI agents",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"bin": {
@@ -18,8 +17,7 @@
},
"files": [
"dist",
"README.md",
"CHANGELOG.md"
"README.md"
],
"scripts": {
"build": "tsc",
@@ -27,7 +25,8 @@
"start": "node dist/index.js",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --forceExit",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"prepublishOnly": "npm run build && npm test"
},
"keywords": [
"mcp",
@@ -53,11 +52,11 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.8"
"zod": "^3.23.8",
"zod-to-json-schema": "^3.25.2"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/jest": "^30.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^20.14.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.0",
Binary file not shown.
+30 -11
View File
@@ -8,6 +8,7 @@
import os from "node:os";
import path from "node:path";
import { existsSync } from "node:fs";
import type { RuviewConfig } from "./types.js";
function env(key: string): string | undefined {
@@ -51,17 +52,35 @@ export function loadConfig(): RuviewConfig {
}
/**
* Attempt to locate a cog binary on PATH or in common install locations.
* Returns the bare binary name if not found (will fail gracefully at invocation).
* Ordered cog-binary candidate paths for a host of the given CPU architecture.
* The native-arch build is probed FIRST: an appliance that ships both
* `cog-<id>-arm` and `cog-<id>-x86_64` must never hand back the wrong-arch
* binary (ADR-264 F8/O7 the pre-review order tried `-arm` unconditionally).
* The `/usr/local/bin` and bare-name (PATH) fallbacks follow, arch-agnostic.
*
* Pure and arch-injectable so the ordering is unit-testable.
*/
export function cogBinaryCandidates(
name: string,
arch: string = process.arch
): string[] {
const id = name.replace("cog-", "");
const dir = `/var/lib/cognitum/apps/${id}`;
const arm = `${dir}/cog-${id}-arm`;
const x86 = `${dir}/cog-${id}-x86_64`;
// arm64 → prefer -arm; everything else (notably x64) → prefer -x86_64.
const archOrdered = arch === "arm64" ? [arm, x86] : [x86, arm];
return [...archOrdered, `/usr/local/bin/${name}`];
}
/**
* Locate a cog binary in the common appliance install locations, probing each
* candidate in native-arch-first order. Falls back to the bare name (PATH
* resolution at spawn time) when no candidate exists.
*/
function detectCogBinary(name: string): string {
// Common install paths for Cognitum cog binaries on Linux/macOS appliances.
const candidates = [
`/var/lib/cognitum/apps/${name.replace("cog-", "")}/cog-${name.replace("cog-", "")}-arm`,
`/var/lib/cognitum/apps/${name.replace("cog-", "")}/cog-${name.replace("cog-", "")}-x86_64`,
`/usr/local/bin/${name}`,
name, // bare name — rely on PATH
];
// Return the first candidate that might exist; actual existence is checked at call time.
return candidates[candidates.length - 1] ?? name;
for (const candidate of cogBinaryCandidates(name)) {
if (existsSync(candidate)) return candidate;
}
return name; // bare name — rely on PATH; spawn fails gracefully if absent
}
+242 -69
View File
@@ -1,30 +1,44 @@
/**
* Streamable HTTP transport scaffold for @ruvnet/rvagent (ADR-124 §3).
* Streamable HTTP transport for @ruvnet/rvagent (ADR-124 §3, hardened per
* ADR-264 F7/O3).
*
* Binds to 127.0.0.1 by default and mounts a POST /mcp endpoint backed by
* Binds to 127.0.0.1 by default and mounts an /mcp endpoint backed by
* StreamableHTTPServerTransport from @modelcontextprotocol/sdk.
*
* Security model (ADR-124 §6):
* - Origin validation: requests from origins other than the configured
* allowlist are rejected with 403 Forbidden before reaching the MCP layer.
* - Default allowlist: ['http://localhost', 'http://127.0.0.1'] covers
* Claude Code and Cursor on the same machine.
* Session model (ADR-264 F7): the SDK's stateful mode requires ONE transport
* (and one MCP Server) per session. An `initialize` POST creates a fresh
* transport + server pair via the caller-supplied factory; follow-up
* POST/GET/DELETE requests are routed to their session by the
* `mcp-session-id` header. Transports are dropped when their session closes.
*
* Security model (ADR-124 §6 + ADR-264 F7):
* - Origin validation: browser-style requests whose Origin is not local
* are rejected with 403 before reaching the MCP layer. With NO explicit
* allowlist, localhost origins match on hostname, ANY port
* (http://localhost:5173 is local). When an explicit allowedOrigins list is
* configured, matching is exact the any-port-localhost convenience is off,
* so a localhost peer on an unlisted port must be added to be accepted.
* - Bearer token: when RVAGENT_HTTP_TOKEN is set, requests must carry
* Authorization: Bearer <token>; missing/wrong tokens 401.
* - Body cap: request bodies over 1 MiB are rejected with 413 (the
* unbounded-buffering DoS from the pre-ADR-264 scaffold).
* - Bind address: defaults to 127.0.0.1 per MCP spec security requirement.
* Set RVAGENT_HTTP_HOST=0.0.0.0 only for intentional fleet deployment.
*
* Usage:
* import { createHttpTransport } from './http-transport.js';
* const { server: httpServer, transport } = await createHttpTransport(mcpServer);
* const { httpServer } = await createHttpTransport(() => buildServer(config));
* // httpServer is a node:http.Server — call httpServer.close() to shut down.
*/
import { createServer, type Server as HttpServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import type { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
export type McpServerFactory = () => McpServer;
export interface HttpTransportOptions {
/** TCP host to bind (default: 127.0.0.1). */
host?: string;
@@ -32,8 +46,8 @@ export interface HttpTransportOptions {
port?: number;
/**
* Allowed Origin header values. Requests with an Origin not in this list
* are rejected with 403. Use '*' to disable Origin validation entirely
* (not recommended outside of local-dev flags).
* (and not a localhost origin) are rejected with 403. Use '*' to disable
* Origin validation entirely (not recommended outside of local-dev flags).
*/
allowedOrigins?: string[];
/**
@@ -42,32 +56,51 @@ export interface HttpTransportOptions {
* Defaults to process.env.RVAGENT_HTTP_TOKEN (undefined = auth disabled).
*/
bearerToken?: string;
/** Maximum accepted request body size in bytes (default: 1 MiB). */
maxBodyBytes?: number;
/**
* Maximum number of concurrent live sessions (default: 64). When a new
* `initialize` arrives at the cap, the oldest-idle session is evicted (its
* transport closed) to make room bounds memory against a flaky client that
* loops `initialize` or a malicious localhost peer (ADR-264 F7).
*/
maxSessions?: number;
/**
* Idle time-to-live for a session in ms (default: 5 min). Sessions with no
* request activity for longer than this are swept and closed.
*/
sessionIdleMs?: number;
/** How often the idle-session sweeper runs, in ms (default: 60 s). */
sweepIntervalMs?: number;
}
export interface HttpTransportResult {
/** The raw Node.js HTTP server — call .close() to shut down. */
httpServer: HttpServer;
/** The MCP Streamable HTTP transport instance wired to the MCP server. */
transport: StreamableHTTPServerTransport;
/** Live sessions keyed by session id (exposed for tests/observability). */
sessions: Map<string, StreamableHTTPServerTransport>;
/** The bound address string (e.g. "http://127.0.0.1:3001"). */
boundAddress: string;
}
const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = 3001;
const LOCALHOST_ORIGINS = new Set([
"http://localhost",
"http://127.0.0.1",
"https://localhost",
"https://127.0.0.1",
]);
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
const DEFAULT_MAX_SESSIONS = 64;
const DEFAULT_SESSION_IDLE_MS = 5 * 60 * 1000;
const DEFAULT_SWEEP_INTERVAL_MS = 60 * 1000;
const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
/**
* Validate Origin header against the allowlist.
* Returns true if the request should be allowed, false if it should be rejected.
*
* An absent Origin header is allowed (same-origin non-browser requests, curl, etc.).
* A present Origin that is not in the allowlist is rejected.
* An absent Origin header is allowed (same-origin non-browser requests, curl,
* etc.). When NO explicit allowlist was configured (empty list), a localhost
* origin is allowed on any port as a convenience real browser origins carry
* ports (ADR-264 F7). When an explicit allowlist IS configured, matching is
* exact: the any-port-localhost shortcut is disabled so an operator who pins an
* allowlist actually gets it (a looped-back peer on an unlisted port is denied).
*/
export function isOriginAllowed(
origin: string | undefined,
@@ -75,76 +108,222 @@ export function isOriginAllowed(
): boolean {
if (origin === undefined) return true; // no Origin = not a cross-origin browser request
if (allowedOrigins.includes("*")) return true;
return allowedOrigins.some((o) => o === origin);
if (allowedOrigins.includes(origin)) return true;
// Explicit allowlist ⇒ exact matching only; skip the localhost convenience.
if (allowedOrigins.length > 0) return false;
try {
const u = new URL(origin);
return (
(u.protocol === "http:" || u.protocol === "https:") &&
LOCAL_HOSTNAMES.has(u.hostname === "::1" ? "[::1]" : u.hostname)
);
} catch {
return false;
}
}
/** Read a request body with a hard size cap; null = payload too large. */
function readBody(
req: IncomingMessage,
maxBytes: number
): Promise<string | null> {
return new Promise((resolve, reject) => {
let size = 0;
let tooLarge = false;
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
if (tooLarge) return; // keep draining so the 413 response can flush
size += chunk.length;
if (size > maxBytes) {
tooLarge = true;
chunks.length = 0;
resolve(null);
return;
}
chunks.push(chunk);
});
req.on("end", () => {
if (!tooLarge) resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", reject);
});
}
function json(res: ServerResponse, status: number, body: object): void {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
}
/**
* Build and wire a Streamable HTTP transport to the provided MCP server.
* Returns the Node.js HTTP server (not yet listening) plus the transport.
* Build the HTTP server around a per-session MCP transport map.
* Returns the Node.js HTTP server (not yet listening) plus the session map.
* Call httpServer.listen(port, host) or rely on createHttpTransport which
* does that for you.
*/
export function buildHttpApp(
mcpServer: McpServer,
serverFactory: McpServerFactory,
opts: HttpTransportOptions = {}
): { httpServer: HttpServer; transport: StreamableHTTPServerTransport } {
const allowedOrigins: string[] = opts.allowedOrigins ?? [
...LOCALHOST_ORIGINS,
];
): { httpServer: HttpServer; sessions: Map<string, StreamableHTTPServerTransport> } {
const allowedOrigins: string[] = opts.allowedOrigins ?? [];
const bearerToken = opts.bearerToken ?? process.env["RVAGENT_HTTP_TOKEN"];
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
const maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
const sessionIdleMs = opts.sessionIdleMs ?? DEFAULT_SESSION_IDLE_MS;
const sweepIntervalMs = opts.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
const sessions = new Map<string, StreamableHTTPServerTransport>();
// lastSeen tracks per-session request activity so the sweeper and the
// oldest-idle eviction can bound the session map (ADR-264 F7).
const lastSeen = new Map<string, number>();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
});
/** Mark a session as freshly used. */
function touch(sessionId: string): void {
lastSeen.set(sessionId, Date.now());
}
const httpServer = createServer(
(req: IncomingMessage, res: ServerResponse) => {
// ── Origin validation ────────────────────────────────────────────────
/** Close a session's transport and drop it from the bookkeeping maps. */
function closeSession(id: string): void {
const transport = sessions.get(id);
sessions.delete(id);
lastSeen.delete(id);
if (transport) {
try {
void transport.close(); // onclose is idempotent against the maps above
} catch {
/* best-effort: a half-open transport must not block eviction */
}
}
}
/** Evict the session that has been idle longest — called when at capacity. */
function evictOldestIdle(): void {
let oldestId: string | undefined;
let oldestSeen = Infinity;
for (const [id, seen] of lastSeen) {
if (seen < oldestSeen) {
oldestSeen = seen;
oldestId = id;
}
}
if (oldestId !== undefined) closeSession(oldestId);
}
/** Periodic sweep: close sessions idle beyond sessionIdleMs. */
function sweepIdleSessions(): void {
const now = Date.now();
for (const [id, seen] of lastSeen) {
if (now - seen > sessionIdleMs) closeSession(id);
}
}
const sweepTimer = setInterval(sweepIdleSessions, sweepIntervalMs);
sweepTimer.unref(); // never keep the process alive just to sweep
const httpServer = createServer((req: IncomingMessage, res: ServerResponse) => {
void (async () => {
// ── Origin validation ──────────────────────────────────────────────
const origin = req.headers["origin"] as string | undefined;
if (!isOriginAllowed(origin, allowedOrigins)) {
res.writeHead(403, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Forbidden: cross-origin request rejected" }));
json(res, 403, { error: "Forbidden: cross-origin request rejected" });
return;
}
// ── Bearer token auth ────────────────────────────────────────────────
// ── Bearer token auth ──────────────────────────────────────────────
if (bearerToken !== undefined && bearerToken !== "") {
const authHeader = req.headers["authorization"] as string | undefined;
const supplied = authHeader?.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: undefined;
if (supplied !== bearerToken) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Unauthorized: missing or invalid bearer token" }));
json(res, 401, { error: "Unauthorized: missing or invalid bearer token" });
return;
}
}
// ── Route: POST /mcp ─────────────────────────────────────────────────
if (req.method === "POST" && req.url === "/mcp") {
let body = "";
req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
req.on("end", () => {
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Bad Request: invalid JSON body" }));
return;
}
void transport.handleRequest(req, res, parsed);
});
// ── Route: /mcp ────────────────────────────────────────────────────
if (req.url !== "/mcp") {
json(res, 404, { error: "Not found. MCP endpoint: /mcp" });
return;
}
// ── Fallback ─────────────────────────────────────────────────────────
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Not found. MCP endpoint: POST /mcp" }));
}
);
const sessionId = req.headers["mcp-session-id"] as string | undefined;
return { httpServer, transport };
if (req.method === "POST") {
const body = await readBody(req, maxBodyBytes);
if (body === null) {
json(res, 413, { error: `Payload too large (max ${maxBodyBytes} bytes)` });
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
json(res, 400, { error: "Bad Request: invalid JSON body" });
return;
}
// Existing session → route to its transport.
if (sessionId !== undefined) {
const transport = sessions.get(sessionId);
if (!transport) {
json(res, 404, { error: `Unknown session "${sessionId}"` });
return;
}
touch(sessionId);
await transport.handleRequest(req, res, parsed);
return;
}
// New session: must be an initialize request (ADR-264 F7 — one
// transport + one MCP Server per session).
if (!isInitializeRequest(parsed)) {
json(res, 400, {
error: "Bad Request: no mcp-session-id and not an initialize request",
});
return;
}
// Bound the session map: at capacity, reclaim the oldest-idle slot
// before minting a new session (ADR-264 F7).
if (sessions.size >= maxSessions) evictOldestIdle();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (id: string) => {
sessions.set(id, transport);
touch(id);
},
});
transport.onclose = () => {
if (transport.sessionId !== undefined) {
sessions.delete(transport.sessionId);
lastSeen.delete(transport.sessionId);
}
};
const mcpServer = serverFactory();
await mcpServer.connect(transport as Parameters<typeof mcpServer.connect>[0]);
await transport.handleRequest(req, res, parsed);
return;
}
// GET (SSE stream) / DELETE (session termination) — session-scoped.
if (req.method === "GET" || req.method === "DELETE") {
const transport = sessionId !== undefined ? sessions.get(sessionId) : undefined;
if (!transport) {
json(res, 400, { error: "Bad Request: missing or unknown mcp-session-id" });
return;
}
if (sessionId !== undefined) touch(sessionId);
await transport.handleRequest(req, res);
return;
}
json(res, 405, { error: "Method not allowed. Use POST/GET/DELETE on /mcp" });
})().catch(() => {
if (!res.headersSent) json(res, 500, { error: "Internal server error" });
else res.end();
});
});
httpServer.on("close", () => clearInterval(sweepTimer));
return { httpServer, sessions };
}
/**
@@ -152,19 +331,13 @@ export function buildHttpApp(
* is bound and listening.
*/
export async function createHttpTransport(
mcpServer: McpServer,
serverFactory: McpServerFactory,
opts: HttpTransportOptions = {}
): Promise<HttpTransportResult> {
const host = opts.host ?? process.env["RVAGENT_HTTP_HOST"] ?? DEFAULT_HOST;
const port = opts.port ?? Number(process.env["RVAGENT_HTTP_PORT"] ?? DEFAULT_PORT);
const { httpServer, transport } = buildHttpApp(mcpServer, opts);
// Wire MCP server to the transport only after the HTTP server is built.
// Cast needed: StreamableHTTPServerTransport implements Transport but
// exactOptionalPropertyTypes causes a false incompatibility on optional
// callback properties; the cast is safe — the SDK types are consistent.
await mcpServer.connect(transport as Parameters<typeof mcpServer.connect>[0]);
const { httpServer, sessions } = buildHttpApp(serverFactory, opts);
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
@@ -173,7 +346,7 @@ export async function createHttpTransport(
return {
httpServer,
transport,
sessions,
boundAddress: `http://${host}:${port}`,
};
}
+197 -269
View File
@@ -1,29 +1,39 @@
#!/usr/bin/env node
/**
* @ruv/ruview-mcp RuView MCP Server
* @ruvnet/rvagent RuView MCP Server
*
* Exposes RuView's WiFi-DensePose sensing capabilities as Model Context Protocol
* (MCP) tools that Claude Code, Cursor, Codex, and other MCP-compatible agents
* can call directly.
*
* Tools exposed:
* ruview_csi_latest pull the latest CSI window from the sensing-server
* ruview_pose_infer single-shot 17-keypoint pose estimation
* ruview_count_infer single-shot person count with confidence interval
* ruview_registry_list list cogs from the Cognitum edge registry (ADR-102)
* ruview_train_count kick off a count-cog training run (returns job ID)
* ruview_job_status poll a background training job
* Transports (ADR-264 O3):
* stdio (default) node dist/index.js
* Streamable HTTP RVAGENT_HTTP_PORT=3001 node dist/index.js
* (127.0.0.1-bound, Origin-gated, optional bearer token
* see http-transport.ts for the security model)
*
* Usage:
* node dist/index.js # stdio transport (default)
* RUVIEW_SENSING_SERVER_URL=http://cognitum-v0:3000 node dist/index.js
* Tool naming (ADR-264 O4): canonical names are underscore-form
* (host tool-name regexes commonly enforce ^[a-zA-Z0-9_-]{1,64}$). The
* pre-ADR-264 dotted names (ruview.bfld.last_scan, ) remain callable as
* router-only aliases for one deprecation cycle; tools/list advertises the
* underscore form only.
*
* Validation (ADR-264 O5): each tool declares ONE Zod schema. The CallTool
* gate parses exactly once and hands the typed result to the handler; the
* advertised JSON Schema is generated from the same Zod source, so what is
* advertised is what is enforced.
*
* To register with Claude Code:
* claude mcp add ruview -- node /path/to/tools/ruview-mcp/dist/index.js
* claude mcp add ruview -- npx -y @ruvnet/rvagent
*
* See ADR-104 for the full design rationale and security model.
* See ADR-104 for the original design rationale and ADR-264 for the npm
* deep-review this layout implements.
*/
import { createRequire } from "node:module";
import { realpathSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { argv } from "node:process";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
@@ -32,6 +42,8 @@ import {
McpError,
ErrorCode,
} from "@modelcontextprotocol/sdk/types.js";
import type { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { loadConfig } from "./config.js";
import { csiLatestSchema, csiLatest } from "./tools/csi-latest.js";
@@ -44,40 +56,51 @@ import {
jobStatusSchema,
jobStatus,
} from "./tools/train-count.js";
import { TOOL_INPUT_SCHEMAS } from "./schemas/index.js";
import { bfldLastScan } from "./tools/bfld-last-scan.js";
import { bfldSubscribe } from "./tools/bfld-subscribe.js";
import { presenceNow } from "./tools/presence-now.js";
import { vitalsGetBreathing } from "./tools/vitals-get-breathing.js";
import { vitalsGetHeartRate } from "./tools/vitals-get-heart-rate.js";
import { vitalsGetAll } from "./tools/vitals-get-all.js";
import { bfldLastScanSchema, bfldLastScan } from "./tools/bfld-last-scan.js";
import { bfldSubscribeSchema, bfldSubscribe } from "./tools/bfld-subscribe.js";
import { presenceNowSchema, presenceNow } from "./tools/presence-now.js";
import {
vitalsGetBreathingSchema,
vitalsGetBreathing,
} from "./tools/vitals-get-breathing.js";
import {
vitalsGetHeartRateSchema,
vitalsGetHeartRate,
} from "./tools/vitals-get-heart-rate.js";
import { vitalsGetAllSchema, vitalsGetAll } from "./tools/vitals-get-all.js";
// NOTE: ./http-transport.js is imported lazily in main() — it chain-loads the
// SDK's streamableHttp module (~48 ms MEASURED), which the default stdio path
// never uses.
const PACKAGE_VERSION = "0.1.0";
// Single-source the version from package.json (ADR-264 O8/ADR-265 D3).
const require = createRequire(import.meta.url);
const PACKAGE_VERSION: string = (
require("../package.json") as { version: string }
).version;
const SERVER_NAME = "rvagent";
// ── Tool registry ──────────────────────────────────────────────────────────
const TOOLS = [
type RuviewConfig = ReturnType<typeof loadConfig>;
interface ToolDef {
name: string;
description: string;
/** The single validation source; the advertised JSON Schema derives from it. */
schema: z.ZodTypeAny;
handler: (parsedArgs: unknown, config: RuviewConfig) => Promise<object>;
}
export const TOOLS: ToolDef[] = [
{
name: "ruview_csi_latest",
description:
"Pull the latest CSI window from a running wifi-densepose-sensing-server. " +
"Returns 56-subcarrier × 20-frame amplitude/phase arrays suitable for " +
"downstream inference or research analysis.",
inputSchema: {
type: "object" as const,
properties: {
sensing_server_url: {
type: "string",
description:
"Base URL of the sensing-server (default: RUVIEW_SENSING_SERVER_URL or http://localhost:3000).",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = csiLatestSchema.parse(args);
return csiLatest(input, config);
},
schema: csiLatestSchema,
handler: (args, config) =>
csiLatest(args as Parameters<typeof csiLatest>[0], config),
},
{
name: "ruview_pose_infer",
@@ -86,23 +109,9 @@ const TOOLS = [
"cog-pose-estimation Cog binary (ADR-101). Accepts a CSI window JSON file " +
"or uses the live sensing-server if no window is provided. " +
"Returns [{keypoints: [[x,y]×17], confidence}] per detected person.",
inputSchema: {
type: "object" as const,
properties: {
window_path: {
type: "string",
description: "Path to a CSI window JSON file. Omit to use the live sensing-server.",
},
cog_binary: {
type: "string",
description: "Path to cog-pose-estimation binary.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = poseInferSchema.parse(args);
return poseInfer(input, config);
},
schema: poseInferSchema,
handler: (args, config) =>
poseInfer(args as Parameters<typeof poseInfer>[0], config),
},
{
name: "ruview_count_infer",
@@ -110,29 +119,9 @@ const TOOLS = [
"Run a single-shot person-count inference using the cog-person-count Cog " +
"binary (ADR-103). Returns {count, confidence, count_p95_low, count_p95_high} " +
"with a Stoer-Wagner multi-node fusion upper bound when multiple nodes are active.",
inputSchema: {
type: "object" as const,
properties: {
window_path: {
type: "string",
description: "Path to a CSI window JSON file. Omit to use the live sensing-server.",
},
cog_binary: {
type: "string",
description: "Path to cog-person-count binary.",
},
max_persons: {
type: "integer",
minimum: 1,
maximum: 7,
description: "Upper bound on person count (17). Default: 7.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = countInferSchema.parse(args);
return countInfer(input, config);
},
schema: countInferSchema,
handler: (args, config) =>
countInfer(args as Parameters<typeof countInfer>[0], config),
},
{
name: "ruview_registry_list",
@@ -140,33 +129,9 @@ const TOOLS = [
"List cogs from the Cognitum edge module registry (ADR-102). " +
"Fetches /api/v1/edge/registry from the sensing-server, which proxies the " +
"canonical GCS catalog (105 cogs, 11 categories). Supports category filter and search.",
inputSchema: {
type: "object" as const,
properties: {
category: {
type: "string",
description:
"Filter by category: health, security, building, retail, industrial, " +
"research, ai, swarm, signal, network, developer.",
},
search: {
type: "string",
description: "Search substring matched against cog id and name (case-insensitive).",
},
refresh: {
type: "boolean",
description: "Bypass the 1-hour registry cache.",
},
sensing_server_url: {
type: "string",
description: "Override the sensing-server URL.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = registryListSchema.parse(args);
return registryList(input, config);
},
schema: registryListSchema,
handler: (args, config) =>
registryList(args as Parameters<typeof registryList>[0], config),
},
{
name: "ruview_train_count",
@@ -174,211 +139,139 @@ const TOOLS = [
"Kick off a cog-person-count training run using the Candle GPU trainer " +
"(ADR-103). The paired JSONL file provides CSI windows + camera-derived " +
"person-count labels. Returns a job_id to poll with ruview_job_status.",
inputSchema: {
type: "object" as const,
required: ["paired_jsonl"],
properties: {
paired_jsonl: {
type: "string",
description:
"Path to the paired JSONL training file (produced by scripts/align-ground-truth.js).",
},
epochs: {
type: "integer",
minimum: 1,
maximum: 10000,
description: "Training epochs (default: 400).",
},
learning_rate: {
type: "number",
description: "Initial learning rate (default: 0.001).",
},
output_dir: {
type: "string",
description:
"Directory for model artifacts (default: v2/crates/cog-person-count/cog/artifacts/).",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = trainCountSchema.parse(args);
return trainCount(input, config);
},
schema: trainCountSchema,
handler: (args, config) =>
trainCount(args as Parameters<typeof trainCount>[0], config),
},
{
name: "ruview_job_status",
description:
"Poll the status of a background training job started by ruview_train_count. " +
"Returns {status, epochs_done, epochs_total, recent_log} for the given job_id.",
inputSchema: {
type: "object" as const,
required: ["job_id"],
properties: {
job_id: {
type: "string",
description: "UUID returned by ruview_train_count.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
const input = jobStatusSchema.parse(args);
return jobStatus(input, config);
},
schema: jobStatusSchema,
handler: (args, config) =>
jobStatus(args as Parameters<typeof jobStatus>[0], config),
},
// ── ADR-124 BFLD tools (Phase 4 Refinement) ─────────────────────────────
// ── ADR-124 BFLD tools (Phase 4 Refinement; underscore names per ADR-264)
{
name: "ruview.bfld.last_scan",
name: "ruview_bfld_last_scan",
description:
"Return the most recent BFLD scan result for a node (ADR-118/ADR-121). " +
"Fields: node_id, identity_risk_score [0,1], privacy_class, n_frames, timestamp_ms. " +
"Proxied from sensing-server GET /api/v1/bfld/<node_id>/last_scan which aggregates " +
"the MQTT state topics ruview/<node_id>/bfld/* (ADR-122 §2.2).",
inputSchema: {
type: "object" as const,
properties: {
node_id: {
type: "string",
description: "Target node id. Omit to use the single active node.",
},
sensing_server_url: {
type: "string",
description: "Override sensing-server URL for this call only.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
return bfldLastScan(args as Parameters<typeof bfldLastScan>[0], config);
},
schema: bfldLastScanSchema,
handler: (args, config) =>
bfldLastScan(args as Parameters<typeof bfldLastScan>[0], config),
},
{
name: "ruview.bfld.subscribe",
name: "ruview_bfld_subscribe",
description:
"Subscribe to BFLD events on ruview/<node_id>/bfld/* for duration_s seconds (ADR-122). " +
"Returns {ok, subscription_id, expires_at, topic}. When the sensing-server is unreachable, " +
"returns a synthetic envelope with ok:false,warn:true so the caller can distinguish " +
"a network error from an invalid request.",
inputSchema: {
type: "object" as const,
required: ["duration_s"],
properties: {
node_id: {
type: "string",
description: "Target node id. Omit to use the single active node.",
},
duration_s: {
type: "number",
minimum: 0,
maximum: 3600,
description: "Subscription duration in seconds (max 3600).",
},
sensing_server_url: {
type: "string",
description: "Override sensing-server URL for this call only.",
},
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) => {
return bfldSubscribe(args as Parameters<typeof bfldSubscribe>[0], config);
},
schema: bfldSubscribeSchema,
handler: (args, config) =>
bfldSubscribe(args as Parameters<typeof bfldSubscribe>[0], config),
},
// ── ADR-124 Presence + Vitals tools (Phase 4 Refinement iter 5) ──────────
// ── ADR-124 Presence + Vitals tools ───────────────────────────────────────
{
name: "ruview.presence.now",
name: "ruview_presence_now",
description:
"Return current occupancy for a node: present, n_persons, confidence, timestamp_ms. " +
"Wraps EdgeVitalsMessage.presence + n_persons (ADR-124 §4.1, ws.py:74-88).",
inputSchema: {
type: "object" as const,
properties: {
node_id: { type: "string", description: "Target node id." },
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
schema: presenceNowSchema,
handler: (args, config) =>
presenceNow(args as Parameters<typeof presenceNow>[0], config),
},
{
name: "ruview.vitals.get_breathing",
name: "ruview_vitals_get_breathing",
description:
"Return breathing rate for a node: breathing_rate_bpm (null if unavailable), " +
"confidence, timestamp_ms. Wraps EdgeVitalsMessage.breathing_rate_bpm (ws.py:82).",
inputSchema: {
type: "object" as const,
properties: {
node_id: { type: "string", description: "Target node id." },
window_s: { type: "number", description: "Averaging window in seconds (max 300)." },
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
schema: vitalsGetBreathingSchema,
handler: (args, config) =>
vitalsGetBreathing(args as Parameters<typeof vitalsGetBreathing>[0], config),
},
{
name: "ruview.vitals.get_heart_rate",
name: "ruview_vitals_get_heart_rate",
description:
"Return heart rate for a node: heartrate_bpm (null if unavailable), " +
"confidence, timestamp_ms. Wraps EdgeVitalsMessage.heartrate_bpm (ws.py:83).",
inputSchema: {
type: "object" as const,
properties: {
node_id: { type: "string", description: "Target node id." },
window_s: { type: "number", description: "Averaging window in seconds (max 300)." },
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
schema: vitalsGetHeartRateSchema,
handler: (args, config) =>
vitalsGetHeartRate(args as Parameters<typeof vitalsGetHeartRate>[0], config),
},
{
name: "ruview.vitals.get_all",
name: "ruview_vitals_get_all",
description:
"Return the full EdgeVitalsMessage for a node (all fields except raw): " +
"presence, n_persons, confidence, breathing_rate_bpm, heartrate_bpm, motion, zone_id. " +
"Full surface of ws.py:74-88.",
inputSchema: {
type: "object" as const,
properties: {
node_id: { type: "string", description: "Target node id." },
sensing_server_url: { type: "string", description: "Override sensing-server URL." },
},
},
handler: async (args: unknown, config: ReturnType<typeof loadConfig>) =>
schema: vitalsGetAllSchema,
handler: (args, config) =>
vitalsGetAll(args as Parameters<typeof vitalsGetAll>[0], config),
},
] as const;
];
// ── Server bootstrap ────────────────────────────────────────────────────────
/**
* Pre-ADR-264 dotted tool names, accepted at call time for one deprecation
* cycle. Router-only: tools/list never advertises these.
*/
export const TOOL_ALIASES: Record<string, string> = {
"ruview.bfld.last_scan": "ruview_bfld_last_scan",
"ruview.bfld.subscribe": "ruview_bfld_subscribe",
"ruview.presence.now": "ruview_presence_now",
"ruview.vitals.get_breathing": "ruview_vitals_get_breathing",
"ruview.vitals.get_heart_rate": "ruview_vitals_get_heart_rate",
"ruview.vitals.get_all": "ruview_vitals_get_all",
};
async function main(): Promise<void> {
const config = loadConfig();
/**
* Advertised JSON Schema, generated from the Zod source (ADR-264 O5).
* Memoized: schemas are static for the process lifetime, and tools/list is
* called once per session (per HTTP session under the session-per-server
* model) no point re-walking the Zod tree each time.
*/
const jsonSchemaCache = new Map<string, object>();
export function toolInputJsonSchema(def: ToolDef): object {
const cached = jsonSchemaCache.get(def.name);
if (cached !== undefined) return cached;
const raw = zodToJsonSchema(def.schema, { $refStrategy: "none" }) as Record<
string,
unknown
>;
delete raw["$schema"];
jsonSchemaCache.set(def.name, raw);
return raw;
}
// ── Server factory ──────────────────────────────────────────────────────────
/**
* Build a fully-wired MCP Server. A factory (not a singleton) because each
* Streamable-HTTP session needs its own Server instance (ADR-264 F7/O3).
*/
export function buildServer(config: RuviewConfig = loadConfig()): Server {
const server = new Server(
{
name: SERVER_NAME,
version: PACKAGE_VERSION,
},
{
capabilities: {
tools: {},
},
}
{ name: SERVER_NAME, version: PACKAGE_VERSION },
{ capabilities: { tools: {} } }
);
// List tools handler.
server.setRequestHandler(ListToolsRequestSchema, () => ({
tools: TOOLS.map((t) => ({
name: t.name,
description: t.description,
inputSchema: t.inputSchema,
inputSchema: toolInputJsonSchema(t),
})),
}));
// Call tool handler — uniform Zod validation gate (ADR-124 §3 Architecture).
// If TOOL_INPUT_SCHEMAS has a schema for the tool name, run safeParse first.
// Parse failures throw McpError(InvalidParams) so the client sees a typed
// JSON-RPC error rather than a wrapped string error.
// Call tool handler — the SINGLE Zod validation gate (ADR-264 O5): parse
// once, hand the typed result (with defaults applied) to the handler.
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
const { name: rawName, arguments: args } = request.params;
const name = TOOL_ALIASES[rawName] ?? rawName;
const tool = TOOLS.find((t) => t.name === name);
if (!tool) {
@@ -388,7 +281,7 @@ async function main(): Promise<void> {
type: "text" as const,
text: JSON.stringify({
ok: false,
error: `Unknown tool "${name}". Available tools: ${TOOLS.map((t) => t.name).join(", ")}`,
error: `Unknown tool "${rawName}". Available tools: ${TOOLS.map((t) => t.name).join(", ")}`,
}),
},
],
@@ -396,22 +289,16 @@ async function main(): Promise<void> {
};
}
// Schema validation gate — applies to all tools registered in TOOL_INPUT_SCHEMAS.
const schemaEntry = Object.prototype.hasOwnProperty.call(TOOL_INPUT_SCHEMAS, name)
? TOOL_INPUT_SCHEMAS[name as keyof typeof TOOL_INPUT_SCHEMAS]
: undefined;
if (schemaEntry !== undefined) {
const parsed = schemaEntry.safeParse(args ?? {});
if (!parsed.success) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for tool "${name}": ${parsed.error.message}`
);
}
const parsed = tool.schema.safeParse(args ?? {});
if (!parsed.success) {
throw new McpError(
ErrorCode.InvalidParams,
`Invalid arguments for tool "${rawName}": ${parsed.error.message}`
);
}
try {
const result = await tool.handler(args ?? {}, config);
const result = await tool.handler(parsed.data, config);
return {
content: [
{
@@ -438,18 +325,59 @@ async function main(): Promise<void> {
}
});
// Wire up stdio transport.
return server;
}
// ── Server bootstrap ────────────────────────────────────────────────────────
async function main(): Promise<void> {
const config = loadConfig();
// stdio transport (default, always on).
const stdioServer = buildServer(config);
const transport = new StdioServerTransport();
await server.connect(transport);
await stdioServer.connect(transport);
// Streamable HTTP transport — explicit opt-in only (ADR-264 O3). Lazily
// imported so the stdio path never pays the streamableHttp load cost.
const httpPort = process.env["RVAGENT_HTTP_PORT"];
let httpNote = "";
if (httpPort !== undefined && httpPort !== "") {
const { createHttpTransport } = await import("./http-transport.js");
const { boundAddress } = await createHttpTransport(
() => buildServer(config),
{ port: Number(httpPort) }
);
httpNote = ` HTTP: ${boundAddress}/mcp.`;
}
// Log to stderr so it doesn't interfere with the MCP stdio protocol.
process.stderr.write(
`[@ruvnet/rvagent] Server v${PACKAGE_VERSION} started. ` +
`Sensing server: ${config.sensingServerUrl}\n`
`Sensing server: ${config.sensingServerUrl}.${httpNote}\n`
);
}
main().catch((e) => {
process.stderr.write(`[ruview-mcp] Fatal: ${String(e)}\n`);
process.exit(1);
});
// CLI guard: boot the server only when this module is the entrypoint — invoked
// as the `rvagent` / `ruview-mcp` bin or `node dist/index.js`. Importing it as a
// library (`import { buildServer } from "@ruvnet/rvagent"`) must NOT side-effect
// connect a StdioServerTransport to the consumer's stdin/stdout. Realpath both
// sides because npm's bin shim is a symlink and passes a non-normalized,
// possibly case-skewed argv[1] on Windows (mirrors harness/ruview/bin/cli.js).
const invokedDirectly = (() => {
if (!argv[1]) return false;
try {
const a = realpathSync(argv[1]);
const b = realpathSync(fileURLToPath(import.meta.url));
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
} catch {
return false;
}
})();
if (invokedDirectly) {
main().catch((e) => {
process.stderr.write(`[ruview-mcp] Fatal: ${String(e)}\n`);
process.exit(1);
});
}
+160 -31
View File
@@ -17,7 +17,16 @@
import { z } from "zod";
import { randomUUID } from "node:crypto";
import { mkdirSync, appendFileSync, openSync } from "node:fs";
import {
mkdirSync,
appendFileSync,
openSync,
closeSync,
readFileSync,
writeFileSync,
statSync,
readSync,
} from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import type { RuviewConfig, TrainJobResult, JobStatusResult } from "../types.js";
@@ -66,17 +75,101 @@ export const jobStatusSchema = z.object({
export type JobStatusInput = z.infer<typeof jobStatusSchema>;
// In-process job registry (survives for the lifetime of the MCP server process).
// For a production implementation, persist to ~/.ruview/jobs/<id>.json.
const jobRegistry = new Map<
string,
{
status: "queued" | "running" | "done" | "failed";
log_path: string;
queued_at: number;
epochs_total: number;
interface JobRecord {
status: "queued" | "running" | "done" | "failed" | "unknown";
log_path: string;
queued_at: number;
epochs_total: number;
/**
* OS pid of the training child. Persisted so a later process (e.g. after an
* MCP server restart) can tell whether a job still marked 'running' actually
* outlived the process that spawned it (ADR-264 O6).
*/
pid?: number | undefined;
/** Human-readable explanation attached during reconciliation (unknown state). */
reason?: string | undefined;
}
// In-process job registry, mirrored to <jobsDir>/<id>.json on every state
// change so ruview_job_status survives an MCP server restart (ADR-264 O6).
const jobRegistry = new Map<string, JobRecord>();
function jobRecordPath(jobsDir: string, jobId: string): string {
return path.join(jobsDir, `${jobId}.json`);
}
function persistJob(jobsDir: string, jobId: string, record: JobRecord): void {
try {
writeFileSync(
jobRecordPath(jobsDir, jobId),
JSON.stringify({ job_id: jobId, ...record }, null, 2)
);
} catch {
// Persistence is best-effort; the in-memory record still serves this process.
}
>();
}
function loadPersistedJob(jobsDir: string, jobId: string): JobRecord | undefined {
try {
const raw = JSON.parse(readFileSync(jobRecordPath(jobsDir, jobId), "utf8")) as
Partial<JobRecord>;
if (typeof raw.log_path !== "string" || typeof raw.status !== "string") {
return undefined;
}
return {
status: raw.status,
log_path: raw.log_path,
queued_at: typeof raw.queued_at === "number" ? raw.queued_at : 0,
epochs_total: typeof raw.epochs_total === "number" ? raw.epochs_total : 0,
pid: typeof raw.pid === "number" ? raw.pid : undefined,
reason: typeof raw.reason === "string" ? raw.reason : undefined,
};
} catch {
return undefined;
}
}
/**
* Is `pid` still a live process? `process.kill(pid, 0)` sends no signal but
* probes existence: ESRCH gone; EPERM alive but owned by another user
* (treated as alive so we never falsely reconcile a still-running job).
*/
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (e) {
return (e as NodeJS.ErrnoException).code === "EPERM";
}
}
/**
* Scan log lines (tail) for the "# exit code: N" marker the child.on('close')
* handler appends. `found:false` means the process died without the marker
* i.e. this server never saw the close (it restarted mid-run).
*/
function findExitMarker(lines: string[]): { found: boolean; code: number | null } {
for (let i = lines.length - 1; i >= 0; i--) {
const m = /^# exit code: (-?\d+|null)$/.exec((lines[i] ?? "").trim());
if (m) return { found: true, code: m[1] === "null" ? null : Number(m[1]) };
}
return { found: false, code: null };
}
/** Read the last `maxLines` lines of a file without loading the whole log. */
function tailLines(filePath: string, maxLines: number, maxBytes = 64 * 1024): string[] {
const size = statSync(filePath).size;
const start = Math.max(0, size - maxBytes);
const buf = Buffer.alloc(size - start);
const fd = openSync(filePath, "r");
try {
readSync(fd, buf, 0, buf.length, start);
} finally {
closeSync(fd);
}
const lines = buf.toString("utf8").split("\n");
return lines.slice(Math.max(0, lines.length - maxLines));
}
export async function trainCount(
input: TrainCountInput,
@@ -92,13 +185,16 @@ export async function trainCount(
const outputDir =
input.output_dir ?? "v2/crates/cog-person-count/cog/artifacts";
// Record the job immediately so ruview_job_status can find it.
jobRegistry.set(jobId, {
// Record the job immediately so ruview_job_status can find it — in memory
// and on disk (survives server restarts, ADR-264 O6).
const record: JobRecord = {
status: "queued",
log_path: logPath,
queued_at: queuedAt,
epochs_total: input.epochs,
});
};
jobRegistry.set(jobId, record);
persistJob(logDir, jobId, record);
// Write the header synchronously so the log file exists before spawn.
const header = [
@@ -142,21 +238,29 @@ export async function trainCount(
child.unref(); // Allow the MCP server process to exit without waiting for training.
const entry = jobRegistry.get(jobId);
if (entry) {
entry.status = "running";
}
// The child holds its own duplicates of the log fds; close the parent's
// copies immediately or every job leaks 2 fds for the server's lifetime
// (ADR-264 F6/O6).
closeSync(logFdOut);
closeSync(logFdErr);
// Record the child pid so a later process can reconcile a stale 'running'
// record after a server restart (child.pid is undefined only if spawn failed
// synchronously, in which case the 'error' handler flips status to 'failed').
record.pid = child.pid;
record.status = "running";
persistJob(logDir, jobId, record);
child.on("error", (e) => {
appendFileSync(logPath, `\n# ERROR: ${e.message}\n`);
const rec = jobRegistry.get(jobId);
if (rec) rec.status = "failed";
record.status = "failed";
persistJob(logDir, jobId, record);
});
child.on("close", (code) => {
appendFileSync(logPath, `\n# exit code: ${code}\n`);
const rec = jobRegistry.get(jobId);
if (rec) rec.status = code === 0 ? "done" : "failed";
record.status = code === 0 ? "done" : "failed";
persistJob(logDir, jobId, record);
});
const result: TrainJobResult = {
@@ -178,24 +282,48 @@ export async function trainCount(
export async function jobStatus(
input: JobStatusInput,
_config: RuviewConfig
config: RuviewConfig
): Promise<object> {
const job = jobRegistry.get(input.job_id);
// Memory first, then the persisted record (survives server restarts).
let job = jobRegistry.get(input.job_id) ?? loadPersistedJob(config.jobsDir, input.job_id);
if (!job) {
return {
ok: false,
error: `Job ${input.job_id} not found. ` +
"The MCP server may have restarted — check the log directory directly.",
error: `Job ${input.job_id} not found in this server or in ${config.jobsDir}.`,
};
}
// Read the last 20 lines of the log file.
// Reconcile a 'running' record whose owning process is gone. The status flip
// to done/failed lives only in the spawning process's child.on('close'/'error')
// handlers; if this server restarted mid-run, the record froze at 'running'
// (ADR-264 O6). When the pid is dead, recover the true outcome from the log's
// "# exit code: N" marker, else surface an honest 'unknown'.
if (job.status === "running" && typeof job.pid === "number" && !isProcessAlive(job.pid)) {
let tail: string[] = [];
try {
tail = tailLines(job.log_path, 40);
} catch {
/* log unreadable — treated as no marker below */
}
const marker = findExitMarker(tail);
const reconciled: JobRecord = { ...job };
if (marker.found) {
reconciled.status = marker.code === 0 ? "done" : "failed";
reconciled.reason = undefined;
} else {
reconciled.status = "unknown";
reconciled.reason =
"process gone, no exit marker — server likely restarted mid-run";
}
jobRegistry.set(input.job_id, reconciled);
persistJob(config.jobsDir, input.job_id, reconciled);
job = reconciled;
}
// Bounded tail read — never load a multi-GB training log wholesale.
let recentLog: string[] = [];
try {
const { readFileSync } = await import("node:fs");
const content = readFileSync(job.log_path, "utf8");
const lines = content.split("\n");
recentLog = lines.slice(Math.max(0, lines.length - 20));
recentLog = tailLines(job.log_path, 20);
} catch {
recentLog = ["(log not readable yet)"];
}
@@ -206,6 +334,7 @@ export async function jobStatus(
log_path: job.log_path,
recent_log: recentLog,
epochs_total: job.epochs_total,
...(job.reason !== undefined ? { reason: job.reason } : {}),
};
return { ok: true, result };
+8 -1
View File
@@ -115,7 +115,12 @@ export interface TrainJobResult {
/** Output of ruview_job_status. */
export interface JobStatusResult {
job_id: string;
status: "queued" | "running" | "done" | "failed";
/**
* 'unknown' is only ever produced by post-restart reconciliation: a record
* frozen at 'running' whose owning process is gone and whose log carries no
* exit-code marker (see reason).
*/
status: "queued" | "running" | "done" | "failed" | "unknown";
progress_pct?: number | undefined;
/** Most recent log lines (last 20). */
recent_log: string[];
@@ -124,6 +129,8 @@ export interface JobStatusResult {
epochs_done?: number | undefined;
/** Total epochs scheduled. */
epochs_total?: number | undefined;
/** Explanation attached when status was reconciled to 'unknown'. */
reason?: string | undefined;
}
// ── Vitals (ADR-124 §6 Python surface parity: ws.py:74-88) ───────────────
+49
View File
@@ -0,0 +1,49 @@
/**
* ADR-264 F8/O7 cog-binary detection must be architecture-aware.
*
* detectCogBinary() itself probes hardcoded /var/lib paths, so it is not
* cheaply testable without fs mocking. The bug it fixes, however, lives purely
* in the candidate ORDER, which cogBinaryCandidates() exposes as a pure,
* arch-injectable function that is what we pin here.
*/
import { cogBinaryCandidates } from "../src/config.js";
describe("cogBinaryCandidates()", () => {
it("probes -arm before -x86_64 on arm64 hosts", () => {
const c = cogBinaryCandidates("cog-person-count", "arm64");
const arm = c.findIndex((p) => p.endsWith("cog-person-count-arm"));
const x86 = c.findIndex((p) => p.endsWith("cog-person-count-x86_64"));
expect(arm).toBeGreaterThanOrEqual(0);
expect(x86).toBeGreaterThanOrEqual(0);
expect(arm).toBeLessThan(x86);
});
it("probes -x86_64 before -arm on x64 hosts", () => {
const c = cogBinaryCandidates("cog-person-count", "x64");
const arm = c.findIndex((p) => p.endsWith("cog-person-count-arm"));
const x86 = c.findIndex((p) => p.endsWith("cog-person-count-x86_64"));
expect(x86).toBeLessThan(arm);
});
it("defaults an unknown arch to the x86_64-first order", () => {
const c = cogBinaryCandidates("cog-pose-estimation", "riscv64");
const arm = c.findIndex((p) => p.endsWith("cog-pose-estimation-arm"));
const x86 = c.findIndex((p) => p.endsWith("cog-pose-estimation-x86_64"));
expect(x86).toBeLessThan(arm);
});
it("keeps the /usr/local/bin and bare-name PATH fallbacks last", () => {
const c = cogBinaryCandidates("cog-person-count", "arm64");
// The two arch builds come first; the /usr/local/bin fallback follows them.
expect(c[c.length - 1]).toBe("/usr/local/bin/cog-person-count");
expect(c).toHaveLength(3);
});
it("derives the id by stripping the cog- prefix once", () => {
const c = cogBinaryCandidates("cog-person-count", "x64");
expect(c[0]).toBe(
"/var/lib/cognitum/apps/person-count/cog-person-count-x86_64"
);
});
});
+145 -3
View File
@@ -59,7 +59,9 @@ async function startServer(
basePort: number
): Promise<{ port: number; close: () => Promise<void> }> {
const port = basePort + Math.floor(Math.random() * 100);
const { httpServer } = buildHttpApp(makeMockMcpServer(), opts);
// Factory, not instance: each Streamable-HTTP session gets its own MCP
// Server (ADR-264 F7/O3).
const { httpServer } = buildHttpApp(() => makeMockMcpServer(), opts);
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(port, "127.0.0.1", () => resolve());
@@ -95,8 +97,34 @@ describe("isOriginAllowed()", () => {
expect(isOriginAllowed("https://evil.example.com", ["*"])).toBe(true);
});
it("is case-sensitive per RFC 6454", () => {
expect(isOriginAllowed("HTTP://localhost", allow)).toBe(false);
// ADR-264 F7: real browser origins carry ports — localhost must match on
// hostname, any port, even with an empty allowlist.
it("allows localhost origins on any port", () => {
expect(isOriginAllowed("http://localhost:5173", [])).toBe(true);
expect(isOriginAllowed("http://127.0.0.1:8080", [])).toBe(true);
expect(isOriginAllowed("https://localhost:3001", [])).toBe(true);
});
it("rejects non-local origins even with a localhost-looking prefix", () => {
expect(isOriginAllowed("http://localhost.evil.example.com", [])).toBe(false);
expect(isOriginAllowed("https://evil.example.com:443", [])).toBe(false);
});
// ADR-264 F7 hardening: an EXPLICIT allowlist means exact matching only. The
// any-port-localhost convenience applies solely to the empty-allowlist case,
// so an operator who pins an allowlist actually gets it.
it("with an explicit allowlist, rejects a localhost origin on an unlisted port", () => {
expect(isOriginAllowed("http://localhost:5173", allow)).toBe(false);
expect(isOriginAllowed("http://127.0.0.1:8080", allow)).toBe(false);
});
it("with an explicit allowlist, still accepts an exactly-listed localhost origin", () => {
expect(isOriginAllowed("http://localhost", allow)).toBe(true);
expect(isOriginAllowed("http://127.0.0.1", allow)).toBe(true);
});
it("is case-sensitive for non-local allowlist entries per RFC 6454", () => {
expect(isOriginAllowed("HTTPS://Partner.Example.com", ["https://partner.example.com"])).toBe(false);
});
});
@@ -165,3 +193,117 @@ describe("HTTP transport bearer-token auth gate", () => {
expect(r.status).not.toBe(401);
});
});
// ── 7. ADR-264 F7/O3 hardening: body cap + per-session routing ─────────────
describe("HTTP transport session + body-cap hardening (ADR-264 F7)", () => {
let port: number;
let close: () => Promise<void>;
beforeAll(async () => {
const srv = await startServer({ allowedOrigins: ["*"], maxBodyBytes: 64 * 1024 }, 49600);
port = srv.port;
close = srv.close;
});
afterAll(async () => { await close(); });
it("rejects oversized request bodies with 413", async () => {
const huge = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "x", params: { pad: "y".repeat(128 * 1024) } });
const r = await post(port, "/mcp", {}, huge);
expect(r.status).toBe(413);
});
it("rejects a non-initialize POST without a session id with 400 (never a shared transport)", async () => {
const r = await post(port, "/mcp", {}, MCP_BODY); // tools/list, no mcp-session-id
expect(r.status).toBe(400);
const body = JSON.parse(r.body) as Record<string, unknown>;
expect(body["error"]).toMatch(/initialize/i);
});
it("rejects a POST with an unknown session id with 404", async () => {
const r = await post(port, "/mcp", { "mcp-session-id": "no-such-session" }, MCP_BODY);
expect(r.status).toBe(404);
});
it("creates a fresh session (and MCP server) per initialize request", async () => {
const init = JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test-client", version: "0.0.0" },
},
});
const r = await post(port, "/mcp", { Accept: "application/json, text/event-stream" }, init);
expect([200, 406]).not.toContain(0); // sanity
expect(r.status).toBe(200);
});
});
// ── 8. ADR-264 F7: session-map bounds (cap + idle TTL sweep) ───────────────
describe("HTTP transport session bounds (ADR-264 F7)", () => {
const initBody = (id: number): string =>
JSON.stringify({
jsonrpc: "2.0",
id,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test-client", version: "0.0.0" },
},
});
// Build directly (not via startServer) so we can inspect the sessions map.
async function startWithApp(
opts: Parameters<typeof buildHttpApp>[1],
basePort: number
): Promise<{
port: number;
sessions: ReturnType<typeof buildHttpApp>["sessions"];
close: () => Promise<void>;
}> {
const { httpServer, sessions } = buildHttpApp(() => makeMockMcpServer(), opts);
const port = basePort + Math.floor(Math.random() * 100);
await new Promise<void>((resolve, reject) => {
httpServer.once("error", reject);
httpServer.listen(port, "127.0.0.1", () => resolve());
});
const close = () =>
new Promise<void>((res, rej) => httpServer.close((e) => (e ? rej(e) : res())));
return { port, sessions, close };
}
const ACCEPT = { Accept: "application/json, text/event-stream" };
it("never exceeds maxSessions — evicts the oldest-idle session at capacity", async () => {
const srv = await startWithApp({ allowedOrigins: ["*"], maxSessions: 2 }, 49800);
try {
for (let i = 0; i < 5; i++) {
await post(srv.port, "/mcp", ACCEPT, initBody(i));
}
expect(srv.sessions.size).toBeLessThanOrEqual(2);
} finally {
await srv.close();
}
});
it("sweeps sessions idle beyond sessionIdleMs", async () => {
const srv = await startWithApp(
{ allowedOrigins: ["*"], sessionIdleMs: 20, sweepIntervalMs: 10 },
49900
);
try {
await post(srv.port, "/mcp", ACCEPT, initBody(1));
expect(srv.sessions.size).toBe(1);
await new Promise((r) => setTimeout(r, 150));
expect(srv.sessions.size).toBe(0);
} finally {
await srv.close();
}
});
});
+4 -4
View File
@@ -15,11 +15,11 @@
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const pkgPath = resolve(__dirname, "../package.json");
// jest runs from the package root; avoid import.meta (ts-jest transforms this
// suite to a module target that rejects it — pre-existing suite failure).
const pkgPath = resolve(process.cwd(), "package.json");
// Parse once; keep raw for snapshot assertions.
const raw = readFileSync(pkgPath, "utf-8");
@@ -0,0 +1,96 @@
/**
* ADR-264 O6 post-restart job reconciliation.
*
* When the MCP server restarts mid-run, the persisted job record stays frozen
* at 'running' (the child.on('close') that flips it lived in the dead process).
* ruview_job_status must reconcile such a record against the recorded pid and
* the log's "# exit code: N" marker.
*
* We fabricate a persisted record pointing at a KNOWN-DEAD pid (a synchronous
* child that has already exited) and assert the reconciled status.
*/
import { mkdtempSync, writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { jobStatus } from "../src/tools/train-count.js";
import type { RuviewConfig } from "../src/types.js";
/** A pid that has certainly exited: spawnSync waits for the child to finish. */
function deadPid(): number {
const r = spawnSync(process.execPath, ["-e", ""]);
if (typeof r.pid !== "number") throw new Error("could not spawn probe child");
return r.pid;
}
function makeConfig(jobsDir: string): RuviewConfig {
return {
sensingServerUrl: "http://127.0.0.1:19999",
apiToken: undefined,
poseCogBinary: "nonexistent",
countCogBinary: "nonexistent",
jobsDir,
};
}
/** Write a fake persisted 'running' record + its log, return {jobId, config}. */
function seedRunningJob(logBody: string): { jobId: string; config: RuviewConfig } {
const jobsDir = mkdtempSync(path.join(os.tmpdir(), "rvagent-jobs-"));
const jobId = randomUUID();
const logPath = path.join(jobsDir, `${jobId}.log`);
writeFileSync(logPath, logBody);
const record = {
job_id: jobId,
status: "running",
log_path: logPath,
queued_at: Date.now() / 1000,
epochs_total: 5,
pid: deadPid(),
};
writeFileSync(
path.join(jobsDir, `${jobId}.json`),
JSON.stringify(record, null, 2)
);
return { jobId, config: makeConfig(jobsDir) };
}
describe("ruview_job_status reconciliation (ADR-264 O6)", () => {
it("reconciles a dead 'running' job with exit 0 to 'done'", async () => {
const { jobId, config } = seedRunningJob(
"# training...\nepoch 5/5\n# exit code: 0\n"
);
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
expect(out["ok"]).toBe(true);
const res = out["result"] as Record<string, unknown>;
expect(res["status"]).toBe("done");
});
it("reconciles a dead 'running' job with non-zero exit to 'failed'", async () => {
const { jobId, config } = seedRunningJob(
"# training...\npanic: cuda oom\n# exit code: 101\n"
);
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
const res = out["result"] as Record<string, unknown>;
expect(res["status"]).toBe("failed");
});
it("marks a dead 'running' job with no exit marker as 'unknown' with a reason", async () => {
const { jobId, config } = seedRunningJob("# training...\nepoch 2/5\n");
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
const res = out["result"] as Record<string, unknown>;
expect(res["status"]).toBe("unknown");
expect(typeof res["reason"]).toBe("string");
expect(res["reason"]).toMatch(/restarted/i);
});
it("treats a signal-killed marker (null) as 'failed'", async () => {
const { jobId, config } = seedRunningJob(
"# training...\n# exit code: null\n"
);
const out = (await jobStatus({ job_id: jobId }, config)) as Record<string, unknown>;
const res = out["result"] as Record<string, unknown>;
expect(res["status"]).toBe("failed");
});
});
+2 -2
View File
@@ -7,8 +7,8 @@
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"declarationMap": false,
"sourceMap": false,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
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; },
};
}

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