mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-21 22:22:33 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b22c37b998 | ||
|
|
03a897938f | ||
|
|
15d48d4481 | ||
|
|
c764c8916b | ||
|
|
5efc7e2c2c | ||
|
|
39b4ae9ae1 | ||
|
|
4a472926e1 | ||
|
|
c4f9d95e11 | ||
|
|
bf21c8c7a9 | ||
|
|
629e1b9c6c | ||
|
|
78a1474057 | ||
|
|
751b7ae17b | ||
|
|
c4d7ef8086 | ||
|
|
bd7c506076 | ||
|
|
8e12606c6a | ||
|
|
8b857e41cd | ||
|
|
093e3bbeae | ||
|
|
4128661789 | ||
|
|
b6f3abd35e | ||
|
|
45796e4e3e | ||
|
|
3cd360f9e2 | ||
|
|
d31d9daba2 | ||
|
|
b25729acd6 | ||
|
|
8d2be98ed7 | ||
|
|
43f16e5638 | ||
|
|
6112a185df | ||
|
|
888222277e | ||
|
|
1938f0251f | ||
|
|
b90b592d4f | ||
|
|
e1fbbede61 | ||
|
|
aac4155584 | ||
|
|
5ef001b4fe | ||
|
|
6524712900 | ||
|
|
dd02efe2fe | ||
|
|
0c1efda425 | ||
|
|
b1be91478c | ||
|
|
6eb79cad26 | ||
|
|
36e4df95b7 |
No files matched your search
@@ -10,6 +10,11 @@
|
||||
"name": "ruview",
|
||||
"source": "./plugins/ruview",
|
||||
"description": "End-to-end RuView toolkit: getting started, ESP32 hardware setup, configuration, sensing applications (presence / vitals / pose / sleep / MAT), camera-free + camera-supervised model training, advanced multistatic sensing, CLI / API / WASM, mmWave radar, and witness verification"
|
||||
},
|
||||
{
|
||||
"name": "ruview-live",
|
||||
"source": "./harness/ruview/mod",
|
||||
"description": "Claude Code mod: /ruview opens a live pane with your CSI nodes (ESP32, Realtek) and ESPHome radar (presence, distance, device-reported vitals), refreshed on a timer, plus a one-line status. Read-only; ships inside @ruvnet/ruview. Mods are early access (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1)."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -135,6 +135,10 @@ async function runProcess(command, args, {
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
// A child that never reads stdin (e.g. `git check-ignore`) may exit and
|
||||
// close the pipe before this write flushes; the resulting EPIPE is not a
|
||||
// failure of the command, whose real outcome the 'close' handler reports.
|
||||
child.stdin.on('error', () => {});
|
||||
child.stdin.end(input);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: BFI Research Tools
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'tools/bfi/**'
|
||||
- 'tools/bfi_learning/**'
|
||||
- '.github/workflows/bfi-research.yml'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'tools/bfi/**'
|
||||
- 'tools/bfi_learning/**'
|
||||
- '.github/workflows/bfi-research.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: bfi-research-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
offline-tests:
|
||||
name: BFI offline tests (Linux, CPU)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
|
||||
with:
|
||||
python-version: '3.13'
|
||||
- name: Install numeric test dependencies
|
||||
run: python -m pip install numpy==2.4.2 scipy==1.17.1
|
||||
- name: Install CPU PyTorch
|
||||
run: python -m pip install torch==2.11.0 --index-url https://download.pytorch.org/whl/cpu
|
||||
- name: Test capture parsing and bounded runners
|
||||
run: python -B -m unittest discover -s tools/bfi -v
|
||||
- name: Test offline learning and data admission
|
||||
run: python -B -m unittest discover -s tools/bfi_learning -v
|
||||
@@ -204,3 +204,35 @@ jobs:
|
||||
idf.py build
|
||||
test "$(stat -c%s build/esp32-csi-node.bin)" -le $((1152 * 1024))
|
||||
strings build/esp32-csi-node.bin | grep -F "external U.FL"
|
||||
|
||||
publish-release-assets:
|
||||
name: Publish firmware release assets
|
||||
if: github.ref_type == 'tag'
|
||||
needs: [version-guard, build, build-c6-idf6]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download the exact-run firmware artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
pattern: esp32-csi-node-firmware-*
|
||||
path: release-downloads
|
||||
|
||||
- name: Package variants without filename collisions
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
for directory in release-downloads/esp32-csi-node-firmware-*; do
|
||||
variant="${directory##*/}"
|
||||
tar -C "$directory" -czf "release-assets/${variant}.tar.gz" .
|
||||
done
|
||||
(cd release-assets && sha256sum *.tar.gz > SHA256SUMS.txt)
|
||||
|
||||
- name: Publish the downloadable firmware bundles
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65
|
||||
with:
|
||||
name: ESP32 firmware ${{ github.ref_name }}
|
||||
body: |
|
||||
Exact-run ESP32-S3 8 MB, ESP32-S3 4 MB, and ESP32-C6 4 MB firmware bundles.
|
||||
Verify each archive against `SHA256SUMS.txt` before flashing.
|
||||
files: release-assets/*
|
||||
@@ -41,8 +41,12 @@ jobs:
|
||||
build: false
|
||||
publishable: true
|
||||
# ADR-283/325: brain + local hosts + replay assets + guarded Spaces OAuth adapter;
|
||||
# still runtime-dependency-free. 160 KiB is the reviewed hard ceiling.
|
||||
unpacked_budget: 163840
|
||||
# still runtime-dependency-free. ADR-369..374 add the SDK, firmware
|
||||
# flashing, training runner/gate, doctor, device access, and SSH
|
||||
# hosts. ADR-375 adds the MCP Apps console widget (~12 KB), the
|
||||
# Streamable HTTP transport and the terminal UI (384 KiB); ADR-378 the
|
||||
# ruview-live showcase views (~37 KB): 448 KiB reviewed ceiling.
|
||||
unpacked_budget: 458752
|
||||
- dir: harness/homecore
|
||||
build: false
|
||||
publishable: true
|
||||
@@ -131,13 +135,19 @@ jobs:
|
||||
npm i --no-fund --no-audit "$TGZ"
|
||||
case "${{ matrix.package.dir }}" in
|
||||
harness/ruview)
|
||||
./node_modules/.bin/ruview --version
|
||||
./node_modules/.bin/ruview doctor
|
||||
./node_modules/.bin/ruview-harness --version
|
||||
./node_modules/.bin/ruview-harness doctor
|
||||
# the honesty gate must fail closed on empty input (ADR-263 F1)
|
||||
if ./node_modules/.bin/ruview claim-check; then
|
||||
if ./node_modules/.bin/ruview-harness 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);"
|
||||
# ADR-369: the typed SDK resolves and the evidence gate refuses a baseline-free score
|
||||
node --input-type=module -e "const { createRuView } = await import('@ruvnet/ruview/sdk'); const r = createRuView(); if (r.tools().length < 22) process.exit(1); const g = await r.training.gate({ model_score: 0.93 }); if (g.verdict !== 'FAIL') process.exit(1);"
|
||||
./node_modules/.bin/ruview-harness doctor --json --group runtime,harness,firmware > doctor.json
|
||||
if ./node_modules/.bin/ruview-harness train-gate --model-score 0.93 > /dev/null; then
|
||||
echo 'train-gate passed without a baseline'; exit 1
|
||||
fi
|
||||
;;
|
||||
harness/homecore)
|
||||
./node_modules/.bin/homecore --version
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# ADR-373/374 — device-access end-to-end through the real @ruvnet/ruview CLI.
|
||||
# MR60BHA2 (60 GHz) and RPLIDAR are emulated on pseudo-terminals and read via
|
||||
# real pyserial; an ESP32 node is emulated over UDP; the iPhone LiDAR path
|
||||
# uses the repository's real relay. All results are SYNTHETIC: this proves
|
||||
# the transports and parsers, not hardware behaviour.
|
||||
|
||||
name: ruview device e2e
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'harness/ruview/src/**'
|
||||
- 'harness/ruview/bin/**'
|
||||
- 'harness/ruview/test/e2e/**'
|
||||
- 'integrations/iphone-lidar/web/**'
|
||||
- '.github/workflows/ruview-device-e2e.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'harness/ruview/src/**'
|
||||
- 'harness/ruview/bin/**'
|
||||
- 'harness/ruview/test/e2e/**'
|
||||
- 'integrations/iphone-lidar/web/**'
|
||||
- '.github/workflows/ruview-device-e2e.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ruview-device-e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
devices:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install pyserial
|
||||
run: python -m pip install pyserial==3.5
|
||||
|
||||
- name: Install the iPhone LiDAR relay
|
||||
working-directory: integrations/iphone-lidar/web
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Install harness
|
||||
working-directory: harness/ruview
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
# Missing prerequisites fail instead of skipping (RUVIEW_E2E_REQUIRE).
|
||||
# The suite links each emulator pty to /dev/ttyRUVIEWE2E<n> with
|
||||
# `sudo -n`, because the harness refuses /dev/pts/* port names.
|
||||
- name: Device e2e (SYNTHETIC devices)
|
||||
working-directory: harness/ruview
|
||||
env:
|
||||
RUVIEW_E2E_REQUIRE: '1'
|
||||
run: npm run test:e2e:devices
|
||||
@@ -0,0 +1,132 @@
|
||||
# ADR-368 — @ruvnet/ruview-kernel: build the WASM and napi-rs transports from
|
||||
# source on every OS, run the Rust ABI tests, the package unit/security/e2e
|
||||
# tests with the native addon REQUIRED, the wasm↔napi parity check, artifact
|
||||
# verification, and the cross-package e2e through the @ruvnet/ruview
|
||||
# metaharness. No publish step: publication stays CI-only via the release
|
||||
# workflow and requires explicit maintainer approval (ADR-265).
|
||||
|
||||
name: ruview-kernel npm
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'v2/crates/ruview-kernel/**'
|
||||
- 'v2/crates/ruview-kernel-napi/**'
|
||||
- 'v2/crates/wifi-densepose-vitals/**'
|
||||
- 'harness/ruview-kernel/**'
|
||||
- 'harness/ruview/src/kernel.js'
|
||||
- 'harness/ruview/test/kernel.test.mjs'
|
||||
- '.github/workflows/ruview-kernel-npm.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'v2/crates/ruview-kernel/**'
|
||||
- 'v2/crates/ruview-kernel-napi/**'
|
||||
- 'v2/crates/wifi-densepose-vitals/**'
|
||||
- 'harness/ruview-kernel/**'
|
||||
- 'harness/ruview/src/kernel.js'
|
||||
- 'harness/ruview/test/kernel.test.mjs'
|
||||
- '.github/workflows/ruview-kernel-npm.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ruview-kernel-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
kernel:
|
||||
name: kernel (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# The v2 workspace lists submodule crates as members; cargo must be
|
||||
# able to load every member manifest.
|
||||
submodules: recursive
|
||||
|
||||
- name: Install Rust + wasm32 target
|
||||
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
# v2/rust-toolchain pins its own channel; the target must be installed
|
||||
# for that toolchain, not only for the action's default `stable`.
|
||||
- name: Add wasm32 target to the pinned v2 toolchain
|
||||
working-directory: v2
|
||||
run: rustup target add wasm32-unknown-unknown
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
|
||||
with:
|
||||
workspaces: |
|
||||
v2 -> target
|
||||
v2/crates/ruview-kernel-napi -> target
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Rust ABI tests
|
||||
run: cargo test --manifest-path v2/Cargo.toml -p ruview-kernel --locked
|
||||
|
||||
- name: Build WASM + napi-rs artifacts from source
|
||||
working-directory: harness/ruview-kernel
|
||||
run: npm run build
|
||||
|
||||
- name: Verify artifacts (checksums, zero WASM imports, ABI)
|
||||
working-directory: harness/ruview-kernel
|
||||
run: node scripts/verify-artifacts.mjs --require-wasm
|
||||
|
||||
- name: Package tests (napi required)
|
||||
working-directory: harness/ruview-kernel
|
||||
env:
|
||||
RUVIEW_KERNEL_REQUIRE_NAPI: '1'
|
||||
run: npm test
|
||||
|
||||
- name: Doctor, self-test, and wasm↔napi parity (SYNTHETIC)
|
||||
working-directory: harness/ruview-kernel
|
||||
run: |
|
||||
node bin/cli.js doctor --strict-napi
|
||||
node bin/cli.js selftest --backend wasm
|
||||
node bin/cli.js selftest --backend napi
|
||||
node bin/cli.js parity
|
||||
|
||||
- name: Pack
|
||||
working-directory: harness/ruview-kernel
|
||||
run: |
|
||||
npm pack --dry-run
|
||||
# Relative destination: Windows runner temp paths contain
|
||||
# backslashes that bash globbing would mangle.
|
||||
mkdir -p dist-pack
|
||||
npm pack --pack-destination dist-pack
|
||||
|
||||
- name: Cross-package e2e through the @ruvnet/ruview metaharness
|
||||
working-directory: harness/ruview
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
npm install --no-save --ignore-scripts ../ruview-kernel/dist-pack/ruvnet-ruview-kernel-*.tgz
|
||||
node --test test/kernel.test.mjs
|
||||
node bin/cli.js kernel --backend auto
|
||||
|
||||
- name: Upload built artifacts
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ruview-kernel-linux-x64
|
||||
path: |
|
||||
harness/ruview-kernel/wasm/ruview_kernel.wasm
|
||||
harness/ruview-kernel/wasm/SHA256SUMS
|
||||
harness/ruview-kernel/native/*.node
|
||||
harness/ruview-kernel/native/SHA256SUMS
|
||||
retention-days: 14
|
||||
@@ -104,8 +104,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "${{ inputs.package }}" in
|
||||
# ADR-283/325: brain + hosts + replay + guarded Spaces OAuth; no runtime deps.
|
||||
harness/ruview) export UNPACKED_BUDGET=163840 ;;
|
||||
# ADR-283/325/369-374: brain + hosts + replay + Spaces OAuth + SDK,
|
||||
# flashing, training, doctor, device access, SSH hosts; ADR-375 console
|
||||
# widget, HTTP transport, terminal UI; ADR-378 mod showcase; no runtime deps.
|
||||
harness/ruview) export UNPACKED_BUDGET=458752 ;;
|
||||
# ADR-285: CLI + MCP + reviewed brain + WASM-kernel adapter.
|
||||
harness/homecore) export UNPACKED_BUDGET=180000 ;;
|
||||
# ADR-264 O2: map-free tarball (was 188 kB with maps).
|
||||
@@ -140,12 +142,12 @@ jobs:
|
||||
npm i --no-fund --no-audit "$TGZ"
|
||||
case "${{ inputs.package }}" in
|
||||
harness/ruview)
|
||||
./node_modules/.bin/ruview --version
|
||||
./node_modules/.bin/ruview doctor
|
||||
./node_modules/.bin/ruview guidance --topic homecore --query restore --limit 1 \
|
||||
./node_modules/.bin/ruview-harness --version
|
||||
./node_modules/.bin/ruview-harness doctor
|
||||
./node_modules/.bin/ruview-harness guidance --topic homecore --query restore --limit 1 \
|
||||
| grep -q '"homecore-runtime-restore"'
|
||||
# the honesty gate must fail closed on empty input (ADR-263 F1)
|
||||
if ./node_modules/.bin/ruview claim-check; then
|
||||
if ./node_modules/.bin/ruview-harness 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);"
|
||||
|
||||
@@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- **`@ruvnet/ruview` 0.9.1: live sensing views inside Claude Code (ADR-377, ADR-378).** The package now ships `ruview-live`, a Claude Code mod. Type `/ruview` and a pane opens next to the conversation showing what your sensors see right now, refreshed automatically. It has three views, switched with keys `1` `2` `3` or with `/ruview overview|waterfall|radar`:
|
||||
- **Overview**: the CSI nodes streaming to your machine (rate, packet loss, signal strength) and an optional 60 GHz radar kit (presence, distance, heart and breathing rates).
|
||||
- **CSI waterfall**: the Wi-Fi signal itself, the strength of every subcarrier over time, drawn in colour and scrolling as frames arrive. The new `ruview esp32 --spectrum` option supplies the data.
|
||||
- **Radar**: a top-down range fan with an animated ping out to the measured distance, plus heart, breathing and distance charts.
|
||||
|
||||
Every reading is labelled honestly: MEASURED or SYNTHETIC from the packets themselves; radar vitals as "device-reported, not validated"; range-only radar as "distance, not direction". The pane only reads; it never flashes or configures a device.
|
||||
|
||||
Verified on real hardware: an ESP32-C6 streaming CSI and a Seeed MR60BHA2 radar kit (MEASURED; reproducer `npx @ruvnet/ruview esp32 --seconds 4 --spectrum --json`). The package's size ceiling rises from 384 KiB to 448 KiB; it still has no runtime dependencies.
|
||||
- **`wifi-densepose-sar` — coherent wideband RF tomography research crate (ADR-287).** New standalone leaf crate (the `nvsim` pattern; zero coupling to `wifi-densepose-hardware` or any real ingestion path) implementing the synthetic-aperture-radar reconstruction primitive a handheld through-wall RF imaging device would need — motivated by comparison against Applied Electrodynamics' "WaveSight" launch, and explicitly scoped below ADR-278's RISE/DiffRadar/GeRaF reproduction gates. Ships: (1) a stepped-frequency, multi-position complex forward measurement simulator (`y_{m,k} = Σ σ_j/R² · exp(-i·4π·f·R/c) + noise`, deterministic ChaCha20 seeding); (2) delay-and-sum backprojection reconstruction onto a 3D voxel grid, rayon-parallelized over voxels; (3) threshold + local-maximum point-cloud extraction; (4) closed-form range/cross-range resolution and antenna-pose coherence-budget formulas (`ΔR=c/2B`, `δ_CR≈λR/2L`, `Δp≤λ/8`) checked against the reconstruction's *actual* behavior in `tests/physics_validation.rs` rather than merely documented — forward-simulating two targets at controlled separations and proving they resolve or merge exactly where the formulas predict, and that reconstructed focus at a known target degrades as injected antenna-pose error grows. Every number is SYNTHETIC/L0 (ADR-282) — no real wideband RF hardware backs this crate; see the crate README and `docs/tutorials/coherent-rf-tomography-backprojection.md` for the full honesty boundary and a worked walkthrough. `focus_at_point` exploits the evenly-spaced-by-construction frequency sweep (an arithmetic progression in per-term phase) to evaluate each pose's phasor once and advance it by a fixed complex-multiply step per frequency instead of one `sin`/`cos` pair per frequency — **MEASURED ~4.4-4.5x faster** (criterion regression detection, p < 0.001) than the first-shipped direct-computation version, proven equivalent (not just faster) to an independently reimplemented reference across four sweep sizes and on-/off-target points. 25 tests (22 unit + 3 integration), 0 failed, clippy-clean; MEASURED backprojection throughput ~1.7-2.3M voxels/sec (criterion, 21 poses × 32 freq steps).
|
||||
- **HOMECORE platform runtime completion — secure native/Wasmtime plugins, authenticated HAP IP, expanded Home Assistant APIs, durable restoration/migration, and voice protocols.** `homecore-server` now owns deterministic compiled-in native plugin registration plus explicitly configured, path-bounded, Ed25519 publisher-verified Wasm packages executed through Wasmtime with setup/state-change/teardown lifecycle; arbitrary native dynamic libraries remain intentionally unsupported. The optional HAP server implements persisted accessory identity and controller records, SRP-6a Pair-Setup M1–M6, X25519/Ed25519 Pair-Verify M1–M4, HKDF-SHA512/ChaCha20-Poly1305 record framing, authenticated/admin endpoint gates, replay/tamper closure, live entity synchronization, and paired-state `_hap._tcp` mDNS updates (45 focused tests; external Apple certification is not claimed). Startup restores device/entity registries and deterministic latest recorder states before plugins, and migration now atomically preserves forward-compatible device/config-entry fields. The HA-compatible surface adds events, templates, config checks, components, registries, history/logbook with SQL-enforced global response bounds, calendar/camera provider routes, and modern WebSocket negotiation while retaining a machine-readable limitations matrix for integration-specific behavior. Assist adds bounded PCM16, async STT/TTS contracts, an end-to-end speech pipeline, and an authenticated satellite session protocol; real deployments still provide the speech engines.
|
||||
- **`ruview-unified` increment 3 — Gaussian update-loop completion, separable delay-Doppler, and property-tested boundary hardening.** (1) `GaussianMap::merge_overlapping` (ADR-275 step 5: mutual-Mahalanobis + semantic-compatibility dedup catching drift the insert-time gate misses) and lifetime-aware decay (`τ_eff = τ·(1+ln(1+lifetime/τ))` — confirmed structures outlive transients at equal nominal τ). (2) `delay_doppler_map` reimplemented separably (`O(B²S+S²B)`), proven equivalent to the direct reference to <1e-10 and **measured 8.3× faster** (520 µs vs 4.34 ms at 56×8). (3) `tests/security_boundaries.rs` — 8 `proptest` properties over the boundary surfaces (arbitrary values incl. NaN/±inf via `f64::from_bits`) that found and fixed three input-controlled defects: a BLE-CS phase-unwrap infinite loop on non-finite phases and an ~1e299-iteration loop on finite-huge phases (now O(1) modular unwrap + plausibility bound), and a subnormal Gaussian scale overflowing `1/σ²` to NaN density (now physical σ/occupancy bounds). (4) New criterion benches for all increment-2 hot paths (`to_canonical` 38 µs, `ble_cs_range` 481 ns, AoI planner 647 ns/200 regions, coherent fusion 1.5 µs/32 members, factorized pose 521 ns). ruview-unified now 98 tests (87 lib + 3 acceptance + 8 security), 0 failed, clippy-clean.
|
||||
|
||||
@@ -40,29 +40,56 @@ Every WiFi router already fills your space with radio waves. When people move, b
|
||||
<details>
|
||||
<summary><strong>RuView MetaHarness</strong> — guided operation for humans and AI agents</summary>
|
||||
|
||||
The RuView-specific metaharness we created is published as [`@ruvnet/ruview`](harness/ruview/README.md). It provides source-cited guidance, guarded Claude Code/Codex agents, deterministic verification, an honesty check for accuracy claims, and an explicitly granted OAuth-only Cognitum Spaces read.
|
||||
The RuView-specific metaharness we created is published as [`@ruvnet/ruview`](harness/ruview/README.md). It provides:
|
||||
- source-cited guidance;
|
||||
- device access for CSI nodes, radar and LiDAR;
|
||||
- firmware flashing with boot evidence;
|
||||
- one MCP server, usable over stdio or HTTP for ChatGPT, with a live console widget;
|
||||
- a Claude Code mod;
|
||||
- guarded Claude Code/Codex agents;
|
||||
- deterministic verification;
|
||||
- an honesty check for accuracy claims.
|
||||
|
||||
Companion packages: [`homecore`](harness/homecore/README.md) (Homecore developer metaharness) and [`@ruvnet/ruview-kernel`](harness/ruview-kernel/README.md) (the vitals pipeline as WASM).
|
||||
|
||||
```bash
|
||||
# Check the local setup and get source-cited guidance
|
||||
npx @ruvnet/ruview@0.4.0 doctor
|
||||
npx @ruvnet/ruview@0.4.0 guidance --topic sensing --query "model loading"
|
||||
npx @ruvnet/ruview@0.9.1 doctor
|
||||
npx @ruvnet/ruview@0.9.1 guidance --topic sensing --query "model loading"
|
||||
|
||||
# Hardware: what is plugged in, live CSI from your nodes, a 60 GHz radar kit
|
||||
npx @ruvnet/ruview@0.9.1 devices
|
||||
npx @ruvnet/ruview@0.9.1 esp32 --watch # ESP32 + Realtek RAC1 nodes, live view
|
||||
npx @ruvnet/ruview@0.9.1 esp32 --seconds 45 --analyze # live CSI through the vitals kernel
|
||||
npx @ruvnet/ruview@0.9.1 mmwave --source esphome --host <kit-ip>
|
||||
|
||||
# Agents: MCP over stdio, or over HTTP for ChatGPT (token-protected, read tools only)
|
||||
npx @ruvnet/ruview@0.9.1 mcp start
|
||||
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview@0.9.1 mcp start --http
|
||||
|
||||
# Claude Code: a live sensing pane (/ruview), shipped in the package
|
||||
npx @ruvnet/ruview@0.9.1 mod
|
||||
|
||||
# Run a read-only RuView agent through Codex
|
||||
npx @ruvnet/ruview@0.4.0 agent run --host codex --repo . \
|
||||
npx @ruvnet/ruview@0.9.1 agent run --host codex --repo . \
|
||||
--prompt "Find the nearest tests and cite the source files"
|
||||
|
||||
# Search or verify the reviewed contributor brain
|
||||
npx @ruvnet/ruview@0.4.0 brain search --query "calibration"
|
||||
npx @ruvnet/ruview@0.4.0 brain verify --repo .
|
||||
|
||||
# Check claims, replay the deterministic proof, or expose the MCP server
|
||||
npx @ruvnet/ruview@0.4.0 claim-check --file REPORT.md
|
||||
npx @ruvnet/ruview@0.4.0 verify
|
||||
npx @ruvnet/ruview@0.4.0 spaces
|
||||
npx @ruvnet/ruview@0.4.0 mcp start
|
||||
# Check claims, replay the deterministic proof, search the reviewed brain
|
||||
npx @ruvnet/ruview@0.9.1 claim-check --file REPORT.md
|
||||
npx @ruvnet/ruview@0.9.1 verify
|
||||
npx @ruvnet/ruview@0.9.1 brain search --query "calibration"
|
||||
```
|
||||
|
||||
Agent runs are read-only by default. Workspace writes require both `--allow-write` and `--confirm`; retrieved brain content is evidence, not authority.
|
||||
**Safety:**
|
||||
- Agent runs are read-only by default. Workspace writes require both `--allow-write` and `--confirm`.
|
||||
- Hardware reads need the `device-access` grant.
|
||||
- Flashing and calibration never run over the HTTP transport.
|
||||
- Device-reported vitals are labelled as unvalidated.
|
||||
- Retrieved brain content is evidence, not authority.
|
||||
|
||||
A single `npx ruview` package bundling all of this is ready (ADR-376). It is waiting on npm to release the unscoped name.
|
||||
|
||||
Full walkthrough: [user guide → RuView npm toolkit](docs/user-guide.md#ruview-npm-toolkit-ruvnetruview).
|
||||
|
||||
</details>
|
||||
|
||||
@@ -694,7 +721,13 @@ 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).
|
||||
|
||||
For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.4.0`; the quick commands and fuller explanation are in the collapsed MetaHarness section near the top of this README and in [`harness/ruview/`](harness/ruview/README.md).
|
||||
**Live sensing pane (Claude Code mod):** the same marketplace also lists `ruview-live`, a Claude Code mod.
|
||||
- Install it with `/plugin install ruview-live@ruview`, or run `npx @ruvnet/ruview@0.9.1 mod` for its path inside the npm package.
|
||||
- `/ruview` opens a pane beside the transcript with your CSI nodes and radar, refreshed on a timer, plus a one-line status.
|
||||
- It is read-only.
|
||||
- Mods are early access; if `/ruview` is missing, start Claude Code with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`. See [ADR-377](docs/adr/ADR-377-ruview-live-claude-code-mod.md).
|
||||
|
||||
For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.9.1`. The quick commands and a fuller explanation are in the collapsed MetaHarness section near the top of this README, in [`harness/ruview/`](harness/ruview/README.md), and in the [user guide](docs/user-guide.md#ruview-npm-toolkit-ruvnetruview).
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# ADR-364: BFI capture admission and continuity
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-29
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: bfi, capture, evidence, continuity, hardware
|
||||
|
||||
## Context
|
||||
|
||||
The Archer BE550, associated iPhone and MT7927 capture host have produced
|
||||
readable beamforming feedback information (BFI). Monitor-mode support alone
|
||||
does not establish this capability, and readable reports do not establish
|
||||
continuous sensing. Router channel width also does not guarantee an identical
|
||||
feedback grid in every report.
|
||||
|
||||
**MEASURED:** two saved trials contain 66 and 96 unique reports. During their
|
||||
approximately 30-second sender intervals, rates were 2.2 and 3.2 reports/s,
|
||||
with maximum silence of 8.392 and 6.437 seconds. No reports followed in the
|
||||
approximately 15-second post-traffic intervals. All 162 reports received an
|
||||
independent tshark angle/control/SNR comparison. Reproducer commands, capture
|
||||
hashes and limitations are in the [capture results](../../tools/bfi/OPTIMIZATION.md)
|
||||
and [continuity report](../../tools/bfi_learning/CAPTURE-CONTINUITY.md).
|
||||
|
||||
## Decision
|
||||
|
||||
Admit a hardware/driver/profile combination only from its actual packet
|
||||
evidence. Apply these boundaries before using its output for sensing:
|
||||
|
||||
1. Keep originals and receipts outside Git, with raw own-network captures on
|
||||
the capture host. Record hardware, driver, channel, source hashes, capture
|
||||
interval and restoration outcome. Select the authorized AP/client direction
|
||||
explicitly; missing provenance cannot be inferred from an SSID.
|
||||
2. Enforce container, radiotap, MAC, length, fragmentation and supported-format
|
||||
checks in [bfi_decode.py](../../tools/bfi/bfi_decode.py). Validate FCS when
|
||||
present and reject flagged corruption; absent FCS is a recorded limitation.
|
||||
Unsupported tuples remain counted rejections. Compare every admitted angle
|
||||
against an independent dissector before extending a format's support claim.
|
||||
3. Deduplicate retries over the whole capture before slicing time intervals.
|
||||
Preserve integer timestamps and separate feedback grids. Sequence numbers
|
||||
and sounding tokens do not measure lost packets. BFI steering matrices are
|
||||
not full CSI; the identity product of a square unitary matrix with its
|
||||
conjugate transpose cannot serve as a changing channel feature.
|
||||
4. Preserve full-observation quality metrics and add separate before-traffic,
|
||||
sender-interval and after-traffic metrics. Include leading/trailing silence,
|
||||
report rate, maximum gap and duration-weighted occupied one-second bins.
|
||||
Sender timestamps bound attempted UDP sends, not verified over-air sounding.
|
||||
5. Use an initial continuity gate of reports in at least 27 of the first 30
|
||||
complete one-second sender bins and no silence longer than two seconds
|
||||
anywhere in the actual sender interval, repeated in three comparable trials.
|
||||
Report any partial final bin separately; never count it as a complete second.
|
||||
This is a project engineering gate, not a published sensing accuracy result.
|
||||
|
||||
The current traffic receipt lacks a PCAP hash and AP/client binding. The
|
||||
analyzer checks caller-selected file associations and hashes all inputs in its
|
||||
output; this establishes content integrity, not authenticated session binding.
|
||||
Stronger binding is required before automated capture admission can treat
|
||||
receipt association as independently verified.
|
||||
|
||||
## Alternatives
|
||||
|
||||
Monitor-mode declarations, average report rate alone and interpolating missing
|
||||
reports would each hide a different failure. Retain explicit packet validation
|
||||
and interval coverage instead. ESP32/Realtek CSI remains a separate modality;
|
||||
multiple unsynchronized boards do not establish a coherent BFI array.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
Packet validity, capture continuity and sensing accuracy have separate,
|
||||
reproducible acceptance criteria. Changes in denominators cannot appear as RF
|
||||
performance gains.
|
||||
|
||||
### Negative
|
||||
|
||||
Strict format and continuity gates can exclude usable but sparse observations.
|
||||
New PHY formats and hardware require additional captures and independent checks.
|
||||
|
||||
### Neutral
|
||||
|
||||
The current trials fail continuity. Further motion modeling requires a new
|
||||
capture result; this ADR does not authorize hardware changes or establish
|
||||
identity recognition.
|
||||
|
||||
## Validation and links
|
||||
|
||||
Run `python -B -m unittest discover -s tools/bfi -v` and the focused
|
||||
`test_traffic_coverage.py` suite under `tools/bfi_learning`. Synthetic tests
|
||||
check parsing and interval rules; acceptance additionally requires retained
|
||||
real captures and the three successful trials above.
|
||||
|
||||
- Extends proposed [ADR-118](ADR-118-bfld-beamforming-feedback-layer-for-detection.md)
|
||||
and [ADR-123](ADR-123-bfld-capture-path-nexmon-and-esp32.md); neither is superseded.
|
||||
- Preserves [ADR-120](ADR-120-bfld-privacy-class-and-hash-rotation.md)'s privacy boundary.
|
||||
- Public-data admission has a separate contract in
|
||||
[ADR-365](ADR-365-public-bfi-dataset-and-decoder-contract.md).
|
||||
@@ -0,0 +1,121 @@
|
||||
# ADR-365: Public BFI dataset and decoder contract
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-29
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: bfi, datasets, decoding, provenance, benchmarks
|
||||
|
||||
## Context
|
||||
|
||||
Public activity recordings offer a separate route to BFI model development
|
||||
while local capture continuity is insufficient. They introduce different frame
|
||||
formats, collection assumptions and licenses. Public HAR activity labels do
|
||||
not reproduce KIT's person-identification experiment.
|
||||
|
||||
**MEASURED:** the pinned CSI-BFI-HAR train/validation download passed exact
|
||||
size/hash checks for 120 captures plus its license card. A separate body decoder
|
||||
matched 13,104 quantized angles across 14 sample bodies against tshark 4.2.2 in
|
||||
two independent executions. Whole-packet admission and activity training remain
|
||||
incomplete. See [results](../../tools/bfi_learning/RESULTS.md) and the
|
||||
[decoder audit and reproducer](../../tools/bfi_learning/PUBLIC-BFI-DECODE.md).
|
||||
|
||||
## Decision
|
||||
|
||||
### Pin provenance and partitions
|
||||
|
||||
Use the [author dataset](https://huggingface.co/datasets/foysalhaque/CSI-BFI-HAR-Dataset/tree/c3013b3edc5a563a9cf6759f5d464a9d9b6f456a)
|
||||
at revision `c3013b3edc5a563a9cf6759f5d464a9d9b6f456a`. The
|
||||
[manifest](../../tools/bfi_learning/manifests/csi-bfi-har-c3013b3-subset.json)
|
||||
pins URLs, lengths, SHA-256, labels and split membership. Preserve the pinned
|
||||
dataset card's GPL-3.0 declaration and attribution separately from code licenses.
|
||||
|
||||
| Partition | Author directory | Domain |
|
||||
| --- | --- | --- |
|
||||
| Training | HAR-1/BFI/M1 | Day 1, kitchen |
|
||||
| Validation | HAR-3/BFI/M1 | Day 3, classroom |
|
||||
| Held out | HAR-5/BFI/M1 and M2 | Day 5, living room; two views |
|
||||
|
||||
Keep all views of one event in the same partition. Day and room are confounded;
|
||||
M1/M2 labels do not establish heterogeneous-chipset transfer. The selected
|
||||
filenames cover A–T activities and P1–P3 participants.
|
||||
|
||||
Default downloads exclude held-out views. The bounded
|
||||
[downloader](../../tools/bfi_learning/download_public_bfi.py) verifies exact
|
||||
bytes before atomic completion, rehashes resumed files, retains failed partials
|
||||
and enforces transfer deadlines and byte limits. Data stays outside Git;
|
||||
downloaded code is never executed by the downloader.
|
||||
|
||||
### Keep body decoding separate from packet admission
|
||||
|
||||
[public_bfi_decode.py](../../tools/bfi_learning/public_bfi_decode.py) accepts
|
||||
only the audited VHT MU 3x1, 80 MHz, Ng=1, codebook-1 complete action body:
|
||||
1,003 bytes containing one SNR byte, 936 angle bytes and 61 opaque MU-exclusive
|
||||
bytes. It exposes quantized/radian angles and tone ordinals. Frequency indices,
|
||||
exclusive-field interpretation and validated matrix reconstruction remain
|
||||
separate acceptance work.
|
||||
|
||||
Twelve sample frames have nonzero fragment-number bits. Successful tshark
|
||||
decoding and valid CRCs do not resolve their semantics. Retain live MAC guards;
|
||||
quarantine these frames until an explicit, independently reviewed dataset
|
||||
policy can justify admission. Require lossless pcapng conversion evidence for
|
||||
integer timestamps, packet lengths, link types and all original packet bytes.
|
||||
Do not silently strip headers or rewrite fragment fields to gain admission.
|
||||
|
||||
The natural circular representation needs 1,404 features, exceeding the current
|
||||
trainer's 1,024-feature limit. Approve a bounded representation or revised limit
|
||||
with allocation tests before integration; do not truncate tones implicitly.
|
||||
|
||||
### Admit recording quality before scoring models
|
||||
|
||||
Inventory every manifest file, including tiny or unusable traces. Freeze a
|
||||
five-second observed-interval rule: at least 20 valid unique reports, with no
|
||||
inter-report gap above one second and no interpolation. Require at least two
|
||||
independent recordings per activity in each partition/view group that supply
|
||||
an eligible interval. Missing A–T coverage fails benchmark admission.
|
||||
Before implementing the census, also freeze interval anchoring, stride and
|
||||
leading/trailing gap rules: an inter-report gap bound alone does not bound
|
||||
silence at interval edges. These details remain required acceptance work.
|
||||
|
||||
Public files lack trusted recorder start/end manifests. Observed packet spans
|
||||
cannot establish unobserved leading/trailing coverage. Report rejection reasons,
|
||||
eligible recording/time coverage and accuracy together. Freeze event/file groups
|
||||
before generating windows; inspect held-out quality without model selection
|
||||
against held-out outcomes.
|
||||
|
||||
## Alternatives
|
||||
|
||||
Reusing the HE/SU parser without format admission, weakening live fragment
|
||||
guards, or silently dropping sparse classes would change the task while hiding
|
||||
the change. Keep this adapter explicit and fail incomplete benchmark coverage.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
Data provenance, protocol support and score coverage remain independently
|
||||
auditable. New public formats cannot silently weaken live capture validation.
|
||||
|
||||
### Negative
|
||||
|
||||
Sparse files or unresolved framing may prevent a benchmark despite a successful
|
||||
download. Format and representation work must precede model optimization.
|
||||
|
||||
### Neutral
|
||||
|
||||
This is offline research/reference tooling. Production integration must reuse
|
||||
the Rust protocols in [ADR-291](ADR-291-public-benchmark-evaluation-harness.md)
|
||||
and scorecards in [ADR-317](ADR-317-benchmark-multi-domain-scorecard.md); this ADR
|
||||
does not establish a second production trainer or an integrated benchmark score.
|
||||
|
||||
## Validation and links
|
||||
|
||||
Use `test_download_public_bfi.py` and `test_public_bfi_decode.py` under
|
||||
`tools/bfi_learning`, then the full packet oracle and quality census specified
|
||||
in the [decode audit](../../tools/bfi_learning/PUBLIC-BFI-DECODE.md) and
|
||||
[benchmark protocol](../../tools/bfi_learning/RESEARCH-NEXT.md).
|
||||
|
||||
- Depends on [ADR-364](ADR-364-bfi-capture-admission-and-continuity.md)'s distinction
|
||||
between packet validity and coverage.
|
||||
- Preserves [ADR-120](ADR-120-bfld-privacy-class-and-hash-rotation.md).
|
||||
- [ADR-119](ADR-119-bfld-frame-format-and-wire-protocol.md) defines a BFLD envelope;
|
||||
that envelope does not replace raw 802.11 decoding.
|
||||
@@ -0,0 +1,115 @@
|
||||
# ADR-366: CSI controls and frozen evaluation
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-29
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: csi, evaluation, controls, holdout, evidence
|
||||
|
||||
## Context
|
||||
|
||||
The public NTU-Fi HumanID experiment uses 14 classes and a custom directory
|
||||
split of 238 training, 56 validation and 546 held-out recordings. It differs
|
||||
from the author's folder mapping. Physical acquisition-session independence
|
||||
is unknown; absence of exact duplicates cannot establish it.
|
||||
|
||||
**MEASURED:** each of six LSTM/GRU runs classified 56/56 validation recordings
|
||||
correctly. A fixed-alpha ridge model using only mean amplitudes also scored
|
||||
56/56. In a matched three-seed GRU diagnostic, raw/original, raw/shuffled and
|
||||
demeaned/original inputs each scored 56/56 for every seed; demeaned/shuffled
|
||||
scored 52/56, 55/56 and 56/56. Chronological motion is unnecessary for perfect
|
||||
accuracy on this validation split. The physical source of the predictive signal
|
||||
remains unresolved. [Measured results and reproduction receipts](../../tools/bfi_learning/RESULTS.md)
|
||||
|
||||
## Decision
|
||||
|
||||
### Separate diagnostics from claims of generalization
|
||||
|
||||
Use static and temporal controls before interpreting a sequence-model score.
|
||||
Keep input files, optimizer budgets and seeds matched across raw/original,
|
||||
raw/shuffled, demeaned/original and demeaned/shuffled conditions. Permute whole
|
||||
feature rows within one recording, using source/seed information without labels.
|
||||
Never mix partitions. Mark shuffled time coordinates as administrative.
|
||||
|
||||
Whole-recording demeaning is an offline diagnostic that sees the complete
|
||||
recording. It cannot substantiate causal streaming inference. These nondefault
|
||||
controls are restricted to the custom public identity protocol and cannot alter
|
||||
next-observation BFI forecasting. Fit normalization on training inputs only.
|
||||
|
||||
Report recording-level accuracy, macro-F1, included recordings and seed variation
|
||||
alongside validation cross entropy averaged over selected windows. Recording
|
||||
predictions aggregate softmax probabilities across their selected windows;
|
||||
the current loss is not computed from those recording aggregates. Treat window
|
||||
accuracy as secondary because overlapping windows and repeated seeds on the same
|
||||
recordings are not independent participant trials. Retain session-independence
|
||||
limitations beside every custom-split result.
|
||||
|
||||
### Freeze selection before final evaluation
|
||||
|
||||
Keep the [predeclared control protocol](../../tools/bfi_learning/CONTROLS.md):
|
||||
the twelve 50-epoch diagnostic runs are excluded from final-model selection.
|
||||
Only the six original raw/original 200-epoch LSTM/GRU runs are eligible. Select
|
||||
lowest window-averaged validation cross entropy, breaking ties by earlier
|
||||
completion. Do not substitute recording-aggregated loss or change this rule in
|
||||
response to a held-out outcome.
|
||||
|
||||
Freeze source, dataset, metadata, configuration and checkpoint hashes before
|
||||
the scheduled final evaluation. Load frozen weights without retraining. The
|
||||
trainer creates an exclusive one-use claim before holdout scoring and retains
|
||||
it even if scoring fails; a failure requires explicit review, not an automatic
|
||||
retry. This claim is scoped to the selected frozen-run directory; the controller
|
||||
enforces one final evaluation across the task's candidate directories. Stop
|
||||
tuning against that holdout after its result is inspected. At this decision's
|
||||
evidence checkpoint, final holdout metrics remain absent.
|
||||
|
||||
### Preserve executable provenance
|
||||
|
||||
Use bounded numeric NPZ inputs/checkpoints with pickle disabled and validate ZIP
|
||||
central-directory bounds before allocation. Verify hashes and restore recorded
|
||||
transformations during frozen evaluation. The launcher checks returned seed,
|
||||
protocol, configuration and `test: null` during tuning. Save incomplete runs
|
||||
and denominator changes explicitly rather than substituting successful runs.
|
||||
|
||||
Current Python tools are bounded offline reference experiments. Production
|
||||
benchmark integration continues through the Rust protocols and per-domain
|
||||
scorecards of ADR-291/317. A fair state-of-the-art comparison needs matched data,
|
||||
task, representation, partition, adaptation access and evaluation; this custom
|
||||
CSI result establishes neither BFI identity performance nor paper reproduction.
|
||||
|
||||
## Alternatives
|
||||
|
||||
Validation accuracy alone cannot distinguish temporal learning from static
|
||||
cues. Random window splits would leak recordings across partitions. Expanding
|
||||
the eligible model set after diagnostics would change the predeclared selection
|
||||
procedure. Retain the frozen recording split and candidate set.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
Controls expose when model complexity or temporal claims lack experimental
|
||||
support. The final evaluation has a traceable selection rule and one-use record.
|
||||
|
||||
### Negative
|
||||
|
||||
A saturated validation set limits further optimization, and strict one-use
|
||||
evaluation makes interrupted scoring an explicit recovery decision. New-session
|
||||
evidence requires new data rather than additional fitting to this split.
|
||||
|
||||
### Neutral
|
||||
|
||||
The GRU has 24.8% fewer parameters than the tested LSTM, but measured peak GPU
|
||||
allocation was higher and elapsed times do not isolate an architecture speedup.
|
||||
Preserve these separate resource findings when comparing models.
|
||||
|
||||
## Validation and links
|
||||
|
||||
Run the focused `test_prepare.py`, `test_train.py`, `test_static_baseline.py`
|
||||
and `test_run_batch.py` suites under `tools/bfi_learning`. Synthetic isolation
|
||||
and restore tests complement the retained real-data receipts; neither establishes
|
||||
new-session generalization.
|
||||
|
||||
- Extends [ADR-291](ADR-291-public-benchmark-evaluation-harness.md) and
|
||||
[ADR-317](ADR-317-benchmark-multi-domain-scorecard.md) without superseding them.
|
||||
- [Comparison contract](../../tools/bfi_learning/RESEARCH.md),
|
||||
[trainer](../../tools/bfi_learning/train.py) and
|
||||
[overnight frozen-selection procedure](../../tools/bfi_learning/OVERNIGHT.md).
|
||||
@@ -0,0 +1,110 @@
|
||||
# ADR-367: Bounded research swarm and compute ownership
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-29
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: orchestration, ruflo, codex, compute, provenance
|
||||
|
||||
## Context
|
||||
|
||||
Research, implementation and independent review can run concurrently, while
|
||||
GPU training, capture hardware and final evaluation need clear ownership.
|
||||
Registration in a swarm registry does not prove that a worker executed a task.
|
||||
Unbounded retries, duplicate schedulers or overlapping jobs can corrupt evidence
|
||||
and spend resources without improving an experiment.
|
||||
|
||||
**MEASURED at the September 29 checkpoint:** local GPU runs and Codex reviews
|
||||
completed with receipts. Federation enrollment returned `registration paused`
|
||||
and relay membership was rejected; no cross-host worker accepted a task.
|
||||
Shared-memory writes were blocked by the native-WAL guard. These limitations
|
||||
and the actual work locations are recorded in the
|
||||
[overnight procedure](../../tools/bfi_learning/OVERNIGHT.md).
|
||||
|
||||
## Decision
|
||||
|
||||
### Assign one owner to each mutable resource
|
||||
|
||||
The root controller owns each capture action, GPU batch, checkpoint-selection
|
||||
decision and final evaluation. Delegated workers receive bounded source/review
|
||||
tasks and explicit file ownership. Worker findings are untrusted proposals for
|
||||
controller review. Distinguish registration, dispatch, execution and verified
|
||||
completion in receipts; only execution evidence supports a completed-work claim.
|
||||
|
||||
Use the existing app heartbeat as the sole recurring scheduler for this task.
|
||||
Finite serial GPU loops are jobs within a wake, not independent recurring
|
||||
schedulers. Resume from receipts and verified lock ownership; completed runs
|
||||
are not repeated merely because a wake or supervisor restarts.
|
||||
|
||||
### Bound execution and audit outputs
|
||||
|
||||
The [GPU launcher](../../tools/bfi_learning/run_batch.py) uses Linux process
|
||||
groups, an exclusive batch lock, a 900-second trainer budget and a 1,020-second
|
||||
child timeout. The current experiment requires at least 4 GiB free GPU memory,
|
||||
caps the trainer's PyTorch allocator fraction at 20%, and bounds logs/metrics
|
||||
to 8 MiB. This allocator limit is not a whole-device memory reservation.
|
||||
Verify child hashes, seed, protocol, configuration and absence of test metrics
|
||||
before declaring success. Preserve unrelated GPU services; cleanup targets only
|
||||
owned processes. Audit matrix and batch ownership before changing stale locks.
|
||||
|
||||
The task-specific [Codex review wrapper](../../tools/bfi_learning/codex_iteration.py)
|
||||
uses bounded source snapshots on stdin, read-only sandboxing, execution rules,
|
||||
an exclusive lock, timeout and private event logs. This differs from the generic
|
||||
contributor adapter's configuration contract. Check events for unexpected tool
|
||||
execution; never execute suggestions automatically or grant child text authority.
|
||||
|
||||
Preserve federation and memory safety guards when a backend is unavailable.
|
||||
Use private local receipts as the fallback and report degraded coordination.
|
||||
Retry only after a relevant service/writer change or a concrete causal fix.
|
||||
|
||||
### Bind compute to an explicit task budget and end time
|
||||
|
||||
Use local capacity first. A paid GPU needs a measured capacity requirement and
|
||||
an explicitly authorized cumulative budget covering actual spend, outstanding
|
||||
reservations and fees. Before rental, check current prices, active instances
|
||||
and remaining allowance; reserve maximum bounded cost, enforce termination and
|
||||
verify resource cleanup. Transfer only authorized public licensed data and
|
||||
reviewed source. A future run needs its own applicable authorization.
|
||||
|
||||
For the current run, the authorized cap is USD 200, local RTX 5080 capacity has
|
||||
been sufficient and the recorded spend is USD 0. The existing schedule ends
|
||||
September 30, 2026 at 07:00 America/Toronto. Its final wake performs the frozen
|
||||
evaluation, reconciles owned jobs/resources and the ledger, records results,
|
||||
then pauses the heartbeat. These are run-specific values, not durable spending
|
||||
or scheduling authority granted by this ADR.
|
||||
|
||||
## Alternatives
|
||||
|
||||
Multiple controllers per GPU, detached indefinite loops, and treating a
|
||||
registration response as an executed agent task make ownership unverifiable.
|
||||
Retain explicit owners, finite jobs and execution receipts instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
Parallel review remains useful while resource mutation and final evaluation
|
||||
have one accountable owner. Receipts support recovery without silent reruns.
|
||||
|
||||
### Negative
|
||||
|
||||
Serial GPU ownership can leave capacity unused. Federation or memory outages
|
||||
reduce coordination features, and bounded jobs may need reviewed continuation.
|
||||
|
||||
### Neutral
|
||||
|
||||
Source and sanitized documentation belong in the isolated worktree. Raw RF
|
||||
data, subject recordings, weights, logs, credentials and transcripts stay outside
|
||||
Git. A completed experiment does not authorize model promotion, firmware
|
||||
flashing, merging or publication.
|
||||
|
||||
## Validation and links
|
||||
|
||||
Run `test_run_batch.py` and `test_codex_iteration.py` under `tools/bfi_learning`.
|
||||
Audit real execution/cleanup receipts, lock owners, ledger and scheduler state
|
||||
at completion; successful synthetic tests alone cannot establish these outcomes.
|
||||
|
||||
- Preserves [ADR-283](ADR-283-ruview-community-metaharness-flywheel.md)'s
|
||||
proposal-only learning and maintainer promotion boundary.
|
||||
- Follows [ADR-321](ADR-321-decision-policy-action-authorization.md).
|
||||
- Depends on [ADR-366](ADR-366-csi-controls-and-frozen-evaluation.md) for frozen
|
||||
candidate selection and final holdout handling.
|
||||
@@ -0,0 +1,187 @@
|
||||
# ADR-368: RuView compute kernel as a WASM + napi-rs npm package
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: npm, wasm, napi-rs, metaharness, vitals, supply-chain
|
||||
- **Related**: ADR-021 (vitals), ADR-263/265 (npm harness review and
|
||||
distribution), ADR-283 (community metaharness), ADR-285 (WASM-first Homecore
|
||||
metaharness)
|
||||
|
||||
## Context
|
||||
|
||||
`@ruvnet/ruview` (ADR-283) is a dependency-free operator/contributor harness:
|
||||
it guides, verifies, and delegates, but it cannot run RuView signal processing.
|
||||
JavaScript consumers (MCP hosts, dashboards, browser demos, agents) that want
|
||||
real RuView computation must today start the Rust sensing server or re-implement
|
||||
DSP in JS, which drifts from the Rust source of truth.
|
||||
|
||||
We want one Rust implementation reachable from npm with:
|
||||
|
||||
1. a portable default that works on every Node platform and in browsers;
|
||||
2. an optional native path for hosts that want it;
|
||||
3. the metaharness trust posture: least authority by default, the backend that
|
||||
actually ran is always reported, fail-closed on missing or tampered
|
||||
artifacts, and no accuracy claim without an evidence tag.
|
||||
|
||||
## Decision
|
||||
|
||||
### One core, one ABI, two transports
|
||||
|
||||
- **`v2/crates/ruview-kernel`** (workspace member) wraps the ADR-021 pipeline
|
||||
from `wifi-densepose-vitals` (EMA preprocessing, breathing 0.1–0.5 Hz, heart
|
||||
0.8–2.0 Hz, anomaly z-score) in a streaming `Session`, a deterministic
|
||||
SplitMix64 synthetic-CSI generator, and a bounded session registry.
|
||||
- The entire ABI is one function: `call(op, request_json) -> envelope_json`,
|
||||
returning `{"ok":true,"result":…}` or
|
||||
`{"ok":false,"error":{"code","message"}}`. Operations: `info`,
|
||||
`validate_config`, `analyze`, `synthesize`, `session_open|push|summary|close`.
|
||||
Keeping the ABI to a single string function makes backend parity a property
|
||||
of the transport rather than of duplicated bindings.
|
||||
- **WASM transport** (`src/wasm_abi.rs`, `wasm32-unknown-unknown` only):
|
||||
`rvk_alloc`/`rvk_free`/`rvk_call`/`rvk_abi_version`. The module has **zero
|
||||
imports** — no filesystem, network, clock, or randomness. The JS loader
|
||||
refuses any module that imports host functions.
|
||||
- **napi-rs transport** (`v2/crates/ruview-kernel-napi`): exports only
|
||||
`abiVersion()` and `call(op, json)`. It is a standalone Cargo workspace
|
||||
(listed in the v2 `exclude`) so napi never enters
|
||||
`cargo test --workspace`, and it builds with `panic = "unwind"` plus
|
||||
`catch_unwind` so a defect becomes an error envelope, not a Node crash.
|
||||
|
||||
### Input is untrusted at the ABI
|
||||
|
||||
Requests are bounded before processing: 16 MiB per request, 512 subcarriers,
|
||||
100 000 frames per call or synthesis, 64 concurrent sessions. Unknown JSON
|
||||
fields are rejected (`deny_unknown_fields`); frame widths must match the
|
||||
configured subcarrier count; values must be finite with magnitude ≤ 1e9; all
|
||||
frames of a batch are validated before any is processed. The core returns an
|
||||
error envelope instead of panicking on caller data (release builds of the WASM
|
||||
module use `panic = "abort"`, which the loader surfaces as a `trap` and
|
||||
recovers from with a fresh instance).
|
||||
|
||||
### npm package `@ruvnet/ruview-kernel` (`harness/ruview-kernel`)
|
||||
|
||||
- Zero runtime dependencies. `loadKernel({backend})` with
|
||||
`backend ∈ {wasm (default), napi, auto}` or `RUVIEW_KERNEL_BACKEND`.
|
||||
WASM is the default because it carries no host authority. `napi` never
|
||||
silently falls back; `auto` prefers napi and records `fallbackReason`.
|
||||
- Artifacts are built from source by `npm run build` (`scripts/build.mjs`,
|
||||
no shell) into `wasm/` and `native/ruview_kernel.<triple>.node`, with
|
||||
`SHA256SUMS`. The loader verifies checksums; a mismatch is always fatal and
|
||||
never an `auto` fallback trigger; `requireIntegrity` also refuses
|
||||
unchecksummed artifacts. Built artifacts are git-ignored and never committed.
|
||||
- `src/wasm.js` is filesystem-free, so browsers can use
|
||||
`loadWasmKernelFromBytes(bytes)`.
|
||||
- CLI `ruview-kernel`: `doctor`, `info`, `selftest`, `parity`, `synth`,
|
||||
`analyze --file`, `bench`. `synth --out` refuses to overwrite files;
|
||||
`analyze` reads only a bounded regular file.
|
||||
- `selfTest()` runs synthesize → analyze → chunked streaming session → hostile
|
||||
input, and asserts known-tone recovery. `parity()` diffs every output of
|
||||
both backends with a 1e-9 relative tolerance, because `sin`/`exp` come from
|
||||
different libm implementations on wasm32 and native targets; exact JSON
|
||||
equality is reported separately and is not required.
|
||||
|
||||
### Metaharness integration
|
||||
|
||||
`@ruvnet/ruview` gains the read-only tool `ruview_kernel_selftest`
|
||||
(CLI: `ruview kernel [--backend …]`). It dynamically imports the fixed
|
||||
specifier `@ruvnet/ruview-kernel`, so `@ruvnet/ruview` keeps zero runtime
|
||||
dependencies, and it returns `kernel_not_installed` when the package is absent.
|
||||
MCP arguments are limited to `backend` and `seconds`; they cannot choose a
|
||||
module or artifact path. The capability appears in `ruview_guidance` as
|
||||
`portable-compute-kernel`.
|
||||
|
||||
## Evidence
|
||||
|
||||
- **SYNTHETIC** (`cd harness/ruview-kernel && node bin/cli.js selftest`):
|
||||
60 s at 20 Hz, 56 subcarriers, 15 bpm breathing and 72 bpm heart tones →
|
||||
recovered 15.0 bpm respiratory (valid) and 70.6 bpm heart (degraded
|
||||
confidence 0.44). Tolerances asserted: ±3 bpm respiratory, ±8 bpm heart.
|
||||
- **SYNTHETIC** parity (`node bin/cli.js parity`): 900 frames / 45 readings,
|
||||
no difference above 1e-9 relative between wasm32 and linux-x64-gnu napi;
|
||||
outputs are not byte-identical.
|
||||
- **MEASURED** on one Linux x64 development container
|
||||
(`node bin/cli.js bench --backend wasm|napi --seconds 120`): about 78 ms
|
||||
(wasm) and 63 ms (napi) per 2 400-frame `analyze`, including JSON transfer.
|
||||
Single-host timing, not a cross-platform claim.
|
||||
- The WASM release artifact is about 230 KB and imports nothing
|
||||
(`node scripts/verify-artifacts.mjs --require-wasm`).
|
||||
|
||||
None of these numbers says anything about real-world vital-sign accuracy.
|
||||
That still requires the ADR-293 ground-truth protocol with a reference device.
|
||||
|
||||
## Amendment 1 (2026-10-01): binary transport and optimization evidence
|
||||
|
||||
**Profile (MEASURED on one Linux x64 development container, 2 400 frames ×
|
||||
56 subcarriers).** JSON was the dominant cost:
|
||||
|
||||
- JS `JSON.stringify` of the request alone took 52–78 ms against
|
||||
107–124 ms for a whole `analyze`.
|
||||
- Rust then re-parsed about 270 000 numbers.
|
||||
|
||||
**Decision.** Add a binary fast path without changing the ABI version.
|
||||
|
||||
- New operations `analyze_flat` and `session_push_flat`, carried by
|
||||
`call_f64(op, json, &[f64])`:
|
||||
- WASM: `rvk_call_f64`, which reads bytes so alignment does not matter;
|
||||
- napi: `callF64(op, json, Float64Array)`.
|
||||
- `analyze()` and `session.push()` pack uniform frames into one
|
||||
`Float64Array` (all amplitudes, then all phases) and use the binary path
|
||||
automatically. Irregular input still takes the JSON path so its error names
|
||||
the bad frame.
|
||||
- `analyzeFlat(Float64Array, …)` is the zero-copy entry.
|
||||
- `Session::step` is shared by both paths.
|
||||
|
||||
**Evidence.**
|
||||
|
||||
- **Byte-identical outputs.** On identical `f64` inputs the two paths
|
||||
produce byte-identical JSON (`tests/abi.rs`).
|
||||
- **A JSON precision finding.** serde_json's default float parser is not
|
||||
correctly rounded, so the JSON transport perturbs inputs by ULPs (34 of 160
|
||||
amplitude values in a 2 s, 4-subcarrier SYNTHETIC sample). The binary path is bit-exact.
|
||||
- **Speed.** MEASURED, `ruview-kernel bench --seconds 120`, same host:
|
||||
|
||||
| Backend | JSON transport | Binary transport | Speed-up |
|
||||
|---|---|---|---|
|
||||
| wasm | 134.8 ms | 47.8 ms (~50 000 frames/s) | 2.82× |
|
||||
| napi | 109.7 ms | 44.8 ms (~53 600 frames/s) | 2.45× |
|
||||
|
||||
- **Remaining cost is DSP.** Native timing gives heart-rate extraction
|
||||
20.8–22.9 ms of 30.6–38.3 ms (about 60–68%, MEASURED; reproducer:
|
||||
`cargo run --release -p ruview-kernel --example profile_stages`): the per-frame autocorrelation over the 15 s window
|
||||
in `wifi-densepose-vitals`. That crate is shared with the production sensing
|
||||
server, and an incremental ACF would change floating-point summation order
|
||||
and therefore outputs. It is left as a follow-up that needs its own review
|
||||
and regression evidence.
|
||||
|
||||
**Rejected after measurement.** MEASURED, three runs on the same host;
|
||||
reproducer: `node bench/wasm-variants.mjs` in `harness/ruview-kernel`. Each
|
||||
run is the median of 15 `analyzeFlat` calls on 2 400 frames. All variants
|
||||
produced byte-identical outputs.
|
||||
|
||||
| Variant | Size | Median ms (3 runs) | Decision |
|
||||
|---|---|---|---|
|
||||
| baseline (`opt-level = 3`) | 244 KB | 38.3–40.6 | kept |
|
||||
| `+simd128` | 242 KB | 37.4–54.7 | rejected: no consistent gain, and it needs SIMD-capable engines |
|
||||
| `opt-level = "s"` | 227 KB | 40.8–70.1 | rejected: consistently slower for a 17 KB size saving |
|
||||
|
||||
## Consequences
|
||||
|
||||
- JavaScript consumers get real RuView DSP without a server, in Node or the
|
||||
browser, with one Rust source of truth.
|
||||
- A second native build matrix exists. Until per-platform packages
|
||||
(`@ruvnet/ruview-kernel-<triple>`) are published by CI, only WASM is
|
||||
portable; the loader already resolves those package names.
|
||||
- JSON transfer dominates the cost of small calls; a typed-array fast path can
|
||||
be added later behind a new ABI version.
|
||||
|
||||
## Rollout and rollback
|
||||
|
||||
1. CI (`ruview-kernel-npm.yml`) builds both transports from source, runs the
|
||||
Rust tests, the package tests with `RUVIEW_KERNEL_REQUIRE_NAPI=1`, parity,
|
||||
artifact verification, and `npm pack --dry-run`.
|
||||
2. Publication stays CI-only with provenance (ADR-265) and requires explicit
|
||||
maintainer approval; this ADR does not add a publish job.
|
||||
3. Rollback: remove the `ruview_kernel_selftest` tool (the harness has no
|
||||
dependency on the package) and deprecate the npm version; the Rust crates
|
||||
are additive.
|
||||
@@ -0,0 +1,74 @@
|
||||
# ADR-369: npm RuView operator surface — CLI, MCP, and SDK over one registry
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: npm, mcp, sdk, cli, metaharness, least-authority
|
||||
- **Related**: ADR-263/265 (npm harness review and distribution), ADR-283
|
||||
(community metaharness), ADR-368 (compute kernel), ADR-370 (flashing),
|
||||
ADR-371 (training), ADR-372 (doctor)
|
||||
|
||||
## Context
|
||||
|
||||
`@ruvnet/ruview` 0.5.x could onboard, lint claims, verify the proof, monitor a
|
||||
node, and read Cognitum Spaces. Three operator jobs still left the package:
|
||||
|
||||
- **Flashing** was a Windows-only stub that returned `manual_step_required`.
|
||||
- **Training** had only a skill document; nothing ran a trainer or checked
|
||||
results mechanically.
|
||||
- **Diagnostics**: `doctor` printed six PASS/FAIL lines without remedies.
|
||||
|
||||
There was also no programmatic API. Integrators had to shell out to the CLI or
|
||||
speak MCP.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **One registry, three front ends.** Every capability is a `ruview_*` tool
|
||||
in `src/tools.js`, with a JSON schema, a `TOOL_POLICY` class, and a
|
||||
structured, fail-closed result. The CLI verbs, the MCP server, and the new
|
||||
SDK all go through `runTool`. There is one argument validator and one
|
||||
authorization path.
|
||||
2. **SDK** (`@ruvnet/ruview/sdk`, typed by `src/sdk.d.ts`). `createRuView()`
|
||||
returns a frozen object with `doctor`, `firmware.{ports,plan,flash,monitor}`,
|
||||
`training.{plan,run,gate,calibrate}`, `kernel.{selfTest,load}`, `guidance`,
|
||||
`claimCheck`, `verify`, `spaces`, `memorySearch`, `call`, `tools`, and
|
||||
`startMcpServer`.
|
||||
- Calls run with `source: 'sdk'`: trusted in-process code, like the CLI.
|
||||
Mutating tools still refuse to act without `confirm: true`.
|
||||
- `{ strict: true }` turns `ok:false` into a `RuViewError`.
|
||||
3. **Plan/act split for every mutation.**
|
||||
- `ruview_firmware_plan` and `ruview_train_plan` are read-only and their
|
||||
schemas do not accept `confirm`. An MCP client without write grants can
|
||||
review the exact command.
|
||||
- The acting tools (`ruview_node_flash`, `ruview_train`) keep the existing
|
||||
grant-plus-confirm gate: `hardware-write` and `workspace-write`.
|
||||
4. **New tools**: `ruview_doctor`, `ruview_firmware_plan`,
|
||||
`ruview_firmware_ports`, `ruview_train`, `ruview_train_plan`,
|
||||
`ruview_train_gate`. `ruview_node_flash` is re-implemented (ADR-370).
|
||||
`TOOL_POLICY`, `.harness/claims.json`, and `.harness/mcp-policy.json` are
|
||||
kept in lock-step, and a test fails on any drift.
|
||||
5. **Process execution** for operator tools goes through `src/exec.js`:
|
||||
- argv only, never a shell;
|
||||
- a scrubbed environment: the base allowlist plus toolchain locations such
|
||||
as `LIBTORCH`, `CARGO_HOME`, `VIRTUAL_ENV` and `IDF_PATH`, and no
|
||||
credentials;
|
||||
- bounded output, and secrets redacted.
|
||||
6. **Zero runtime dependencies stays.** The optional compute kernel
|
||||
(ADR-368) is loaded dynamically. Version 0.6.0. The unpacked-size ceiling
|
||||
rises from 160 KiB to 224 KiB for the new modules, with the budget recorded
|
||||
in both npm workflows.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Agents, scripts, and humans get the same behaviour and the same refusals.
|
||||
- The package now drives real hardware and training. Keeping the plan/act
|
||||
split and argv-only execution is essential, and tests pin both.
|
||||
- The surface is larger (16 MCP tools). Hosts that cap tool counts can
|
||||
filter by annotations (`readOnlyHint`).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `cd harness/ruview && npm test && npm run test:security` (drift test in
|
||||
`test/sdk.test.mjs`).
|
||||
- `npx @ruvnet/ruview tools` lists 16 tools. `node -e "import('@ruvnet/ruview/sdk')"`
|
||||
resolves from an installed tarball.
|
||||
@@ -0,0 +1,83 @@
|
||||
# ADR-370: Cross-platform, verified ESP32 firmware flashing from npm
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: firmware, esp32, esptool, supply-chain, hardware-evidence
|
||||
- **Related**: ADR-028 (capability audit / witness), ADR-369, firmware
|
||||
`README.md` §0 and §2
|
||||
|
||||
## Context
|
||||
|
||||
The documented flash procedure is four `esptool write_flash` offsets. It is
|
||||
typed by hand, and a wrong chip, wrong flash size, or tampered image can brick
|
||||
a board or silently run the wrong firmware. The previous `ruview_node_flash`
|
||||
was Windows-only and never flashed. Project rules also say that a successful
|
||||
build or write is **not** hardware validation; that requires a captured
|
||||
boot/runtime log from real silicon.
|
||||
|
||||
## Decision
|
||||
|
||||
`src/firmware.js` implements plan → verify → preflight → write → evidence:
|
||||
|
||||
1. **Variants**:
|
||||
- `s3-8mb` → `esp32s3` / 8MB
|
||||
- `s3-4mb` → `esp32s3` / 4MB; prefers `*-4mb.bin` images
|
||||
- `c6` → `esp32c6` / 4MB
|
||||
|
||||
Offsets follow the shipped partition tables: bootloader `0x0`, partition
|
||||
table `0x8000`, optional OTA data `0xf000`, app `0x20000`. NVS is never
|
||||
written, so WiFi and node configuration survive.
|
||||
2. **Bundle verification**. A bundle is an extracted release flash bundle,
|
||||
`release_bins/<variant>`, or an ESP-IDF `build/` directory.
|
||||
- Every image must match `SHA256SUMS` or `SHA256SUMS.txt`. A mismatch is
|
||||
always fatal.
|
||||
- Missing checksums are refused unless `allow_unverified` is set, which is
|
||||
for local builds only.
|
||||
- Images must be regular, non-empty files of at most 16 MiB, inside the
|
||||
bundle; symlinks that escape the bundle are refused.
|
||||
- An 8 MB variant refuses a 4 MB partition table.
|
||||
- A bundle without OTA data produces an explicit warning (a node on `ota_1`
|
||||
keeps booting `ota_1`).
|
||||
3. **Argv, not shell.**
|
||||
- The port must match `COM<n>` or `/dev/(tty|cu)*`.
|
||||
- Baud comes from a fixed set.
|
||||
- The command is `python -m esptool --chip … --port … --baud … write_flash
|
||||
--flash_mode dio --flash_size … <offset> <file>…`. The underscore forms
|
||||
work in both esptool 4.x and 5.x.
|
||||
4. **Chip preflight.** `esptool chip_id` must report the variant's chip. The
|
||||
parser covers the 4.x "Chip is" and 5.x "Chip type:" / "Detecting chip
|
||||
type…" formats. A mismatch or missing response stops the flow before any
|
||||
write.
|
||||
5. **Evidence.** After a write, a read-only pyserial capture (default 15 s,
|
||||
port and duration passed as argv) is summarized: line count, CSI
|
||||
callbacks, MGMT+DATA upgrade, panic markers, firmware version.
|
||||
- `hardwareValidated: true` only when CSI callbacks appear with no panic;
|
||||
the result's `evidence` is then `MEASURED` with a boot-log reason.
|
||||
- Otherwise the result states the board is not validated.
|
||||
6. **Authority.** Without `confirm` the result is a plan. MCP needs the
|
||||
`hardware-write` grant plus `confirm`. `ruview_firmware_plan` and
|
||||
`ruview_firmware_ports` are read-only.
|
||||
7. **Provisioning stays out.** WiFi credentials remain in `provision.py`, so
|
||||
they never enter harness arguments, logs, or MCP transcripts.
|
||||
|
||||
## Evidence
|
||||
|
||||
- `npx @ruvnet/ruview doctor --group firmware` verified all four checked-in
|
||||
`release_bins` bundles (c6-adr110, c6-onboarding, s3-adr110,
|
||||
s3-fair-adr110) against their `SHA256SUMS.txt` in this change's development
|
||||
container.
|
||||
- The write and preflight paths are covered by tests with injected fake
|
||||
hardware: mismatch refusal, probe failure, flash failure, boot-log
|
||||
validation, and a silent log that yields no validation.
|
||||
- **No real board was flashed** during this change, so real-silicon validation
|
||||
is outstanding.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Operators on any OS get one verified command. Agents can review plans
|
||||
without hardware authority.
|
||||
- The workflow depends on Python, esptool, and pyserial. `doctor` reports each
|
||||
one with a fix.
|
||||
- Follow-up: an ADR-028 witness record could store the boot-log summary and
|
||||
image digests.
|
||||
@@ -0,0 +1,91 @@
|
||||
# ADR-371: Harness training runner and mean-pose evidence gate
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: training, evaluation, evidence, pose, honesty
|
||||
- **Related**: ADR-079 (camera-supervised pose), ADR-151 (room calibration),
|
||||
ADR-181 (baseline-relative PCK), ADR-369
|
||||
|
||||
## Context
|
||||
|
||||
RuView has a retracted pose result (a CLAIMED 92.9%/100% that did not survive
|
||||
baseline and leakage review). The project rule is that pose
|
||||
PCK is quoted only with the mean-pose baseline, on a leakage-free held-out
|
||||
split, with an evidence tag. The `train-pose` skill says this in prose, but
|
||||
nothing enforces it. Running the trainers also needs repository knowledge:
|
||||
the `tch-backend` feature, a libtorch version match, and where checkpoints go.
|
||||
|
||||
## Decision
|
||||
|
||||
### Gate (`ruview_train_gate`, pure, read-only)
|
||||
|
||||
The input is a small report: metric, model and baseline score, split,
|
||||
train/test subjects, train end and test start, `n_test`, reproducer, and data
|
||||
source.
|
||||
|
||||
**Hard failures:**
|
||||
- no baseline;
|
||||
- a random-frame split, or any split outside chronological / blocked-gap /
|
||||
grouped-subject / grouped-session;
|
||||
- subject overlap in a grouped-subject split;
|
||||
- missing or overlapping time bounds for chronological or blocked-gap splits;
|
||||
- no improvement over the baseline.
|
||||
|
||||
**Warnings:**
|
||||
- subjects not reported;
|
||||
- subjects overlapping in non-subject splits;
|
||||
- `n_test` < 100;
|
||||
- a near-perfect score: at or above the `implausible_score` threshold in
|
||||
`src/training.js`.
|
||||
|
||||
**Evidence tag:** `SYNTHETIC` when `data: synthetic`, `MEASURED` only with a
|
||||
reproducer, otherwise `CLAIMED`.
|
||||
|
||||
On PASS the gate returns the one acceptable sentence:
|
||||
`Held-out <metric> X% vs Y% mean-pose baseline = +Z pp (<tag>, …, <split> split).`
|
||||
|
||||
### Runner (`ruview_train`, `ruview_train_plan`)
|
||||
|
||||
Modes:
|
||||
- `pose-smoke`: `wifi-densepose-train` `train --dry-run` on the synthetic
|
||||
dataset.
|
||||
- `pose`: `--data-dir`, MM-Fi recordings.
|
||||
- `room`: `wifi-densepose train-room`, or `cargo run -p wifi-densepose-cli`
|
||||
when the binary is absent.
|
||||
|
||||
Rules:
|
||||
- All caller paths (config, data, checkpoints, enrollment, output) must
|
||||
resolve inside the trusted checkout. Realpaths are checked for inputs that
|
||||
already exist.
|
||||
- Pose runs use `cargo run --manifest-path v2/Cargo.toml` with the working
|
||||
directory set to the checkpoint directory, default
|
||||
`v2/target/ruview-train/<mode>`. The trainer's relative `logs/` therefore
|
||||
never lands in the source tree.
|
||||
- Without `confirm` it returns a plan. MCP requires `workspace-write` plus
|
||||
`confirm`.
|
||||
- Failures are classified with remedies:
|
||||
- libtorch absent;
|
||||
- libtorch version mismatch (tch 0.24 expects torch 2.11.0);
|
||||
- link failure.
|
||||
|
||||
## Evidence
|
||||
|
||||
- **SYNTHETIC** (`npx @ruvnet/ruview train --mode pose-smoke --samples 16
|
||||
--confirm`, CPU PyTorch 2.11.0, `LIBTORCH_USE_PYTORCH=1`, Linux x64): the
|
||||
real trainer compiled, ran 20 epochs with early stopping, and wrote
|
||||
safetensors checkpoints and `logs/training_log.csv` under
|
||||
`v2/target/ruview-train/pose-smoke`. The trainer labels its own smoke
|
||||
metrics "DO NOT REPORT". None are reported here.
|
||||
- With PyTorch 2.14.1 the tch 0.24 C++ shim failed to compile. The runner
|
||||
classified this as a version mismatch rather than a missing install.
|
||||
- The gate is covered by `test/training.test.mjs`, including the leakage
|
||||
patterns behind the retraction.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A number can pass through the harness only with its baseline and split
|
||||
attached. The gate does not verify the arithmetic that produced the input;
|
||||
reviewers must still re-run the reproducer.
|
||||
- Training needs the Rust toolchain and a matching libtorch. `doctor` covers
|
||||
Rust; libtorch is detected at run time.
|
||||
@@ -0,0 +1,66 @@
|
||||
# ADR-372: Structured debugging doctor
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: diagnostics, onboarding, support, mcp
|
||||
- **Related**: ADR-369, ADR-370, ADR-371, docs/TROUBLESHOOTING.md
|
||||
|
||||
## Context
|
||||
|
||||
Most failed RuView setups come down to a missing driver, pyserial, esptool, a
|
||||
wrong port, uninitialized submodules, a missing wasm32 target, or a sensing
|
||||
server that is not running. The old `doctor` checked only the harness itself,
|
||||
gave no remedies, and had no machine-readable output for agents.
|
||||
|
||||
## Decision
|
||||
|
||||
`src/doctor.js` returns
|
||||
`{ok, summary{pass,warn,fail,skip}, checks[{group,id,status,detail,remedy}], nextSteps}`.
|
||||
|
||||
Groups:
|
||||
|
||||
| Group | Checks |
|
||||
|---|---|
|
||||
| `runtime` | Node ≥ 20 |
|
||||
| `harness` | packaged-file manifest integrity, claim-guardrail self-test, reviewed brain load |
|
||||
| `hosts` | `claude` / `codex` on PATH |
|
||||
| `repo` | checkout, workspace submodules |
|
||||
| `rust` | cargo, wasm32 target (run inside `v2/` so the pinned toolchain is used), `wifi-densepose` binary |
|
||||
| `python` | interpreter, pyserial, esptool version |
|
||||
| `firmware` | checksum verification of every `release_bins` bundle |
|
||||
| `serial` | pyserial port enumeration; optional named port; optional chip probe |
|
||||
| `sensing` | optional sensing-server `/health` |
|
||||
| `kernel` | optional `@ruvnet/ruview-kernel` WASM/napi status |
|
||||
|
||||
Rules:
|
||||
|
||||
- `fail` is reserved for a requested capability that cannot work. Absent
|
||||
optional tooling is `warn`. Every non-pass check carries a remedy. The CLI
|
||||
exit code is non-zero only on `fail`.
|
||||
- The doctor is read-only. Two exceptions exist, both CLI/SDK options that are
|
||||
absent from the MCP schema:
|
||||
- `--probe` runs `esptool chip_id`, which resets the board but writes
|
||||
nothing.
|
||||
- `--url` makes an HTTP(S) request.
|
||||
- Output is human-readable by default. `--json` gives the structured report.
|
||||
`--group a,b` limits the scope.
|
||||
|
||||
## Evidence
|
||||
|
||||
Development container (Linux x64, Node 22):
|
||||
|
||||
- Before `pip install pyserial esptool`, the doctor reported them as `warn`
|
||||
with the exact pip commands.
|
||||
- After installation it showed `esptool 5.4.0` and enumerated `/dev/ttyS0`.
|
||||
- All four firmware bundles verified.
|
||||
- It caught manifest drift during development, and a missing wasm32 target
|
||||
when run outside `v2/`. That second case led to the `v2/` working-directory
|
||||
rule.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Support threads can ask for `npx @ruvnet/ruview doctor --json`, and agents
|
||||
can act on `nextSteps`.
|
||||
- Checks run real subprocesses, so a full run takes seconds; `--group` keeps
|
||||
targeted runs fast.
|
||||
@@ -0,0 +1,244 @@
|
||||
# ADR-373: Host device access layer — ESP32, mmWave, LiDAR
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: hardware, esp32, mmwave, lidar, serial, udp, mcp, least-authority
|
||||
- **Related**: ADR-018 (CSI frame), ADR-063 (mmWave fusion), ADR-320
|
||||
(sensor HAL), ADR-369 (npm operator surface), ADR-370 (flashing),
|
||||
ADR-374 (remote hosts), `integrations/iphone-lidar`
|
||||
|
||||
## Context
|
||||
|
||||
Operators and agents need to answer basic hardware questions, such as "is the
|
||||
node streaming?", "is the radar talking?" and "does the LiDAR see the room?",
|
||||
before calibration, fusion or training. Until now each modality had its own
|
||||
tool:
|
||||
|
||||
- the ESP32 serial monitor;
|
||||
- `scripts/mmwave_fusion_bridge.py`;
|
||||
- the iPhone LiDAR web relay;
|
||||
- the sensing server itself.
|
||||
|
||||
None of these was reachable through the npm harness, its MCP server or its
|
||||
SDK.
|
||||
|
||||
The package must stay free of runtime dependencies (ADR-263). Node has no
|
||||
serial API. Hardware access is also authority: an MCP client should not get
|
||||
it by default.
|
||||
|
||||
## Decision
|
||||
|
||||
Add `src/devices/` to `@ruvnet/ruview` (0.7.0) with four read-oriented tools.
|
||||
|
||||
| Tool | Transport | What it returns |
|
||||
|---|---|---|
|
||||
| `ruview_devices_scan` | pyserial port list | ports with USB VID:PID, bridge chip, likely roles (esp32 / mmwave / rplidar), and the command that confirms each role |
|
||||
| `ruview_esp32_capture` | `node:dgram` UDP, receive-only | per node: packet counts by kind (magics `0xC5110001`–`0xC5110007`), CSI rate, sequence loss, RSSI, antennas/subcarriers/frequency, latest device vitals |
|
||||
| `ruview_mmwave_read` | serial | MR60BHA2 (60 GHz, 115200) and LD2410 (24 GHz, 256000) frames, checksum errors, presence, distance, device-reported heart and breathing rate |
|
||||
| `ruview_lidar_read` | serial or WebSocket | RPLIDAR SCAN summary (points, revolutions, range, angular coverage), or iPhone relay depth statistics |
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Firmware-identical parsing.**
|
||||
- The MR60BHA2 and LD2410 parsers are ports of
|
||||
`firmware/esp32-csi-node/main/mmwave_sensor.c`: same SOF, `~XOR` header
|
||||
and data checksums, 30-byte payload cap, and F4F3F2F1…F8F7F6F5 framing.
|
||||
- ESP32 packets are parsed as the little-endian structs in `csi_collector`
|
||||
and `edge_processing.h`.
|
||||
- Host and node therefore agree on what a valid frame is.
|
||||
2. **Serial without dependencies.** A fixed Python script (the pyserial
|
||||
"pump") reads the port for a bounded time and number of bytes.
|
||||
- Port, baud, duration and command bytes are passed as argv and validated
|
||||
first: the port regex, a fixed baud set, and command bytes of at most 64
|
||||
hex bytes.
|
||||
- Output is lowercase hex, so the secret-redaction layer cannot corrupt
|
||||
binary data.
|
||||
- DTR control is best-effort and reported when a port does not support it.
|
||||
3. **Receive-only network.**
|
||||
- The ESP32 capture binds a local UDP port (bind address from a fixed set,
|
||||
port ≥ 1024) and never sends to a node.
|
||||
- `EADDRINUSE` is reported as "sensing server already bound".
|
||||
4. **Minimal actuation.** An RPLIDAR scan sends `A5 20` (start) and `A5 25`
|
||||
(stop), and drives DTR low (the A1 motor). It spins the motor but writes no
|
||||
configuration. Nothing else is written to any device.
|
||||
5. **No raw sensor data in results.**
|
||||
- iPhone depth frames are validated with the same rules as
|
||||
`integrations/iphone-lidar/web/codec.mjs` and reduced to statistics.
|
||||
- The relay token comes only from `RUVIEW_LIDAR_TOKEN`. It is rejected in
|
||||
the URL and never echoed.
|
||||
6. **Authority.**
|
||||
- All four tools require the new `device-access` MCP grant.
|
||||
- The CLI and SDK run them directly, as trusted local code.
|
||||
- Serial port names are pattern-checked in the schema and again in the
|
||||
driver. `/dev/pts/*` is deliberately excluded: it could read another
|
||||
terminal's input.
|
||||
7. **Evidence.**
|
||||
- A successful read is tagged `MEASURED` as a *reading* on this host.
|
||||
- mmWave vitals are labelled as the radar's own estimates.
|
||||
- None of these outputs is an accuracy claim.
|
||||
|
||||
## Evidence
|
||||
|
||||
Development container, Linux x64, Node 22. No physical sensors were attached.
|
||||
|
||||
- **ESP32 UDP**: a simulated node sent 60 ADR-018 CSI frames plus 3 vitals
|
||||
packets to `127.0.0.1:5005` through `ruview esp32`.
|
||||
- Result: 1 node, CSI rate 15 Hz, zero sequence loss, vitals decoded
|
||||
(15.2 bpm / 69 bpm, SYNTHETIC).
|
||||
- Unit test on real loopback: 2 dropped sequence numbers out of 7 produce a
|
||||
loss fraction of 0.2857.
|
||||
- **MR60BHA2**, emulated on a pseudo-terminal and read through real pyserial:
|
||||
- auto-detected `mr60bha2`;
|
||||
- 200 frames, 0 checksum errors;
|
||||
- presence 1.0, distance 87 cm, 14.8 / 68.5 bpm (the emulator's values,
|
||||
SYNTHETIC).
|
||||
- **RPLIDAR**, emulated on a pseudo-terminal that answers `A5 20`:
|
||||
- 3564 points, 99 revolutions, full angular coverage;
|
||||
- the missing DTR support was reported.
|
||||
- **iPhone LiDAR**, the real relay (`integrations/iphone-lidar/web/relay.mjs`)
|
||||
with a simulated phone:
|
||||
- 30 frames at 10 fps, median depth 1.824 m (SYNTHETIC);
|
||||
- a wrong token gives `connect_failed` with a remedy.
|
||||
- **Not yet done**: real ESP32-S3/C6, MR60BHA2, LD2410, RPLIDAR and iPhone
|
||||
captures. Each modality needs one real capture before its parser is called
|
||||
hardware-validated.
|
||||
|
||||
## Amendment 1 (2026-10-01): e2e suite and latency
|
||||
|
||||
- `harness/ruview/test/e2e/devices.e2e.mjs` (`npm run test:e2e:devices`, CI
|
||||
workflow `ruview-device-e2e.yml`) automates the emulated-device runs above
|
||||
through the real CLI:
|
||||
- MR60BHA2 and RPLIDAR on pseudo-terminals, read via real pyserial;
|
||||
- an ESP32 UDP node;
|
||||
- the real iPhone relay.
|
||||
- The harness still refuses `/dev/pts/*` port names. The suite links each pty
|
||||
to `/dev/ttyRUVIEWE2E<n>` with root or `sudo -n`.
|
||||
- `RUVIEW_E2E_REQUIRE=1` turns missing prerequisites into failures.
|
||||
- mmWave auto-detect keeps its probe frames (≤ 2 s per model) and reads only
|
||||
the remainder of the window. A 5 s request previously cost about 9 s.
|
||||
- `ruview_lidar_read` (iPhone) gains `max_frames` and returns immediately on
|
||||
a refused connection.
|
||||
- MEASURED wall time for the device e2e suite on the development container:
|
||||
19.6 s before these changes, 5.6 s after (concurrent cases, no redundant
|
||||
re-reads).
|
||||
|
||||
## Amendment 2 (2026-10-01): Realtek nodes, honest capture, live analysis
|
||||
|
||||
Driven by a hardware run of this branch against an RTL8721Dx board (PL2303GC,
|
||||
COM10) and an ESP32-C6 (CP210x, COM16) on Windows 11:
|
||||
|
||||
- **Realtek RAC1/RHB1 (ADR-323).** `ruview_esp32_capture` decodes the 49-byte
|
||||
RAC1 envelope (8- and 16-bit tones, CRC-32 verified, SYNTHETIC flag kept)
|
||||
and counts RHB1 heartbeats per sender. Before this, 3,439 live RAC1 frames
|
||||
in 15 s were all reported as unknown.
|
||||
- **No false success.** The capture now fails with `no_decodable_packets`
|
||||
or `heartbeat_only` when packets arrive but none decode. It previously
|
||||
returned `ok: true` with a MEASURED label for zero decoded packets.
|
||||
`heartbeatOnlySenders` names boards that are alive but deliver no CSI — the
|
||||
stall observed on a Realtek board before a reset.
|
||||
- **More ESP32 packet kinds.** ADR-110 sync (`0xC511A110`) and ADR-081 mesh
|
||||
envelopes (`0xC5118100`, counted by message type, never a phantom node).
|
||||
A node sending several CSI shapes (the C6 sends 1x256, 1x64 and 1x128)
|
||||
reports each in `csiShapes` instead of only the last one.
|
||||
- **Live CSI → kernel.** `--analyze` runs the busiest (or `--node-id`) node's
|
||||
dominant single-antenna shape through `@ruvnet/ruview-kernel` at the
|
||||
measured arrival rate, with a bounded frame buffer (`analyze_max_frames`,
|
||||
default 6,000). Results are signal-processing estimates with no reference.
|
||||
- **Serial monitor no longer reboots nodes.** The monitor opens the port with
|
||||
DTR/RTS deasserted. MEASURED on the C6: uptime kept rising across a monitor
|
||||
run (1,612 s → 1,625 s); before, opening the port reset it (uptime ~15 s).
|
||||
It takes `--baud` (Realtek logs at 1,500,000) and explains the C6/S3
|
||||
USB-Serial/JTAG console when the UART is silent.
|
||||
- **Device scan.** Prolific `067B:23A3` (PL2303GC) is classified `realtek`.
|
||||
- **Windows.** The hosts file gets an owner-only ACL (`icacls`), failing
|
||||
closed; POSIX `0o600` is ignored on Windows. The symlink-escape test skips
|
||||
only where Windows refuses to create symlinks.
|
||||
- **Parser speed.** Int8Array view and `sqrt` instead of per-byte reads and
|
||||
`Math.hypot`: MEASURED 0.42–0.52 M → 1.33–1.67 M packets/s (about 3.2x;
|
||||
three interleaved runs, median of seven each, Windows 11 x64, Node 24).
|
||||
|
||||
Hardware evidence (MEASURED, this host): C6 node 42, 45 s, 1,160 packets,
|
||||
1,157 decoded, 0 sequence loss, 541 1x256 frames at 12.1 Hz analyzed by the
|
||||
napi kernel (integrity verified). The RAC1 decoder is cross-checked against
|
||||
the Rust `realtek-csi-sim` encoder (200/200 frames at 8- and 16-bit tones);
|
||||
a live RAC1 re-run is pending reconnection of the Realtek board.
|
||||
|
||||
Real flash (MEASURED, same C6, COM16): `ruview flash --variant c6 --confirm`
|
||||
with the checksum-verified `release_bins/c6-adr110` bundle wrote 4 images
|
||||
(hash verified by esptool, NVS kept: node 42 rejoined). The boot log showed
|
||||
13 CSI callbacks, the MGMT+DATA upgrade and no panic. After boot, `monitor`
|
||||
saw CSI without a reset (uptime 36 s → 62 s) and the UDP capture decoded
|
||||
183/183 packets with zero loss. The run exposed one more Windows bug, now
|
||||
fixed: the boot-log capture wrote decoded text to a cp1252 stdout (the
|
||||
scrubbed child env) and aborted on the firmware's U+2192 log character, so
|
||||
`bootLog.captured` was false and an earlier abort would have hidden the CSI
|
||||
evidence. It now writes raw bytes; a regression test forces cp1252.
|
||||
|
||||
Live RTL8721Dx re-run (MEASURED, board 4 on COM10, node 3, alongside the C6):
|
||||
`devices` classified COM10 as `realtek`; a 20 s capture decoded 7,404/7,404
|
||||
packets (6,959 RAC1 frames at 348 Hz, RSSI −38.9 dBm, 200 RHB1 heartbeats);
|
||||
`monitor --baud 1500000` saw 62 CSI log lines and did not reset the board
|
||||
(sequence kept rising, 80,856 → 88,257). `--analyze --node-id 3` ran 12,549
|
||||
live frames at a measured 313.7 Hz through the WASM kernel (integrity
|
||||
verified). The board also exposed a loss-accounting bug carried over from the
|
||||
original code: one stray sequence value (+52,835, then straight back) was
|
||||
counted as 52,834 lost frames, giving 97% "loss". The tracker now treats a
|
||||
single out-of-window value as a stray, needs two agreeing frames to accept a
|
||||
counter reset, and lets a late packet fill its own gap. Against ground truth
|
||||
(unique sequence numbers over the span, 15 s): 4,783/4,783 present, true loss
|
||||
0, 9 strays — the tracker reported loss 0 and 9 strays.
|
||||
|
||||
## Amendment 3 (2026-10-01): ESPHome radar kits and CSI buffer starvation
|
||||
|
||||
- **ESPHome source for radar.** The Seeed MR60BHA2 kit (a XIAO ESP32-C6 with
|
||||
the 60 GHz radar) runs ESPHome, which owns the radar UART. Raw frames never
|
||||
reach USB, so `mmwave --model auto` correctly returned `no_valid_frames`.
|
||||
`ruview_mmwave_read` now takes `source: "esphome"` with a `host`.
|
||||
- **Client:** a dependency-free, read-only client for the plaintext ESPHome
|
||||
native API (TCP 6053) runs hello → connect → device info → list entities
|
||||
→ subscribe states for a bounded window. It never sends a command.
|
||||
- **Roles:** presence, heart, breathing, distance and target count are
|
||||
found by entity name. The result uses the same summary fields as the
|
||||
serial path.
|
||||
- **Decoding:** ESPHome's `missing_state` flag and proto3 zero defaults are
|
||||
honoured, so absent readings are excluded rather than averaged as 0.
|
||||
- **Address policy:** hosts must resolve to private, link-local, CGNAT
|
||||
(Tailscale) or loopback addresses.
|
||||
- **Unsupported cases:** encrypted (Noise) and password-protected APIs are
|
||||
reported as unsupported.
|
||||
- **Live kit (MEASURED, 192.168.1.102, ESPHome 2026.9.0, project
|
||||
`seeedstudio.mr60bha2_kit` `spaces-1.0`).**
|
||||
- The kit had been configured for an out-of-range SSID. It was moved to
|
||||
the operator's network through its own captive portal, without
|
||||
reflashing.
|
||||
- A 15 s read returned 154 state updates:
|
||||
- presence: detected, 1 target;
|
||||
- mean distance: 40.0 cm;
|
||||
- device-reported heart rate: mean 75.3 bpm;
|
||||
- device-reported breathing rate: mean 10.7 bpm.
|
||||
- These are firmware-computed values, not validated against a reference.
|
||||
- **Realtek heartbeat-only stall: root cause.** Reading the stalled board's
|
||||
console without a reset showed continuous `[WLAN-W] lack of csi buf!` /
|
||||
`csi buf not enough`.
|
||||
- The radio produces CSI reports, but the firmware does not return report
|
||||
buffers, so CSI stops while heartbeats continue.
|
||||
- `ruview monitor` now counts these lines and fails with
|
||||
`csi_buffer_starvation`. Observed live: 399 lines in 8 s, 0 CSI.
|
||||
- The lasting fix belongs in the RTL8721Dx firmware (outside this
|
||||
repository).
|
||||
- **Views.** Radar results render in the terminal UI and in the console
|
||||
widget (`ruview_mmwave_read` is now a UI tool). Both label vitals
|
||||
"device-reported, not validated".
|
||||
|
||||
## Consequences
|
||||
|
||||
- One command per modality works across CLI, MCP and SDK, with actionable
|
||||
failures (`pyserial_missing`, `port_open_failed`, `no_valid_frames`,
|
||||
`port_in_use`, `connect_failed`).
|
||||
- Python and pyserial become the serial dependency. `doctor` reports both.
|
||||
- The package's unpacked-size ceiling rises to 320 KiB (from 224 KiB) in
|
||||
both npm workflows, with the published package still free of runtime
|
||||
dependencies.
|
||||
- Follow-up: feed these readers into `ruview-hal` observations (ADR-320)
|
||||
for fusion.
|
||||
@@ -0,0 +1,78 @@
|
||||
# ADR-374: Remote host access over SSH (read-only)
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-09-30
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: remote, ssh, hardware, least-authority, mcp
|
||||
- **Related**: ADR-369, ADR-373
|
||||
|
||||
## Context
|
||||
|
||||
RuView hardware usually hangs off a different machine from the one the
|
||||
operator or agent works on: a Raspberry Pi next to the ESP32s, a NUC running
|
||||
the sensing server, a Cognitum Seed. Cloud agent sessions cannot reach local
|
||||
USB at all.
|
||||
|
||||
"Host access" should not mean opening a new network service with its own
|
||||
credentials: that adds attack surface and secret handling. Operators already
|
||||
have SSH.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Where work runs.**
|
||||
- Hardware tools always execute on the machine the hardware is attached
|
||||
to.
|
||||
- Interactive work runs in a local Claude Code session on that machine. It
|
||||
can be driven from the Claude app through Remote Control
|
||||
(`claude remote-control` in the repo).
|
||||
- Other machines are reached over SSH.
|
||||
2. **Hosts registry.**
|
||||
- Stored in `~/.config/ruview/hosts.json` (directory 0700, file 0600).
|
||||
- Written only by the CLI: `ruview hosts add --name --ssh [--port]
|
||||
[--version]`. MCP can list hosts but never add them.
|
||||
- Names must match `^[a-z0-9][a-z0-9-]{0,31}$`. Targets must be
|
||||
`[user@]hostname`: no options, spaces, shell metacharacters or leading
|
||||
dash.
|
||||
3. **Transport.**
|
||||
- Local ssh arguments: `ssh -o BatchMode=yes -o ConnectTimeout=10 -o
|
||||
StrictHostKeyChecking=yes -T [-p N] -- <target> <command>`.
|
||||
- No password prompts, and no trust-on-first-use from the tool. The
|
||||
operator pins the host key once interactively.
|
||||
- The remote command is `npx -y @ruvnet/ruview@<exact version> call <tool>
|
||||
--read-only --args-json <json>`, with every token single-quoted for the
|
||||
remote POSIX shell.
|
||||
4. **Read-only, twice.**
|
||||
- Locally, `ruview_host_run` accepts only tools that are read-only, use no
|
||||
credentials, and are not `ruview_host_*` (no host hopping). It validates
|
||||
arguments against the tool schema before sending.
|
||||
- Remotely, `call --read-only` repeats the check and schema validation.
|
||||
- Flash, train, calibrate and Cognitum Spaces reads are never forwarded.
|
||||
5. **Grants.** Over MCP, `ruview_host_run` needs `remote-host` plus the
|
||||
remote tool's own grant (for example `device-access`).
|
||||
6. **Failures** are classified with remedies: `host_key_unverified`,
|
||||
`ssh_auth_failed`, `host_unreachable`, `remote_node_missing`,
|
||||
`unknown_host`, `remote_tool_not_allowed`.
|
||||
|
||||
## Evidence
|
||||
|
||||
Tests in `harness/ruview/test/remote.test.mjs` cover:
|
||||
|
||||
- host-entry injection cases (`-oProxyCommand=…`, `;`, spaces, `$(…)`,
|
||||
backticks);
|
||||
- quoting, verified by running the quoted command through a real `sh`;
|
||||
- the refusal list;
|
||||
- grant composition;
|
||||
- the file mode;
|
||||
- `call --read-only` refusing `ruview_node_flash` through the real CLI.
|
||||
|
||||
No SSH server was available in the development container, so a live
|
||||
round-trip to a real host is still to be done.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Remote hosts get the same tools with no new daemon, port or credential
|
||||
store.
|
||||
- The remote needs Node 20+ and npm registry access (or a pre-installed
|
||||
version), and `ssh` must be on the local PATH. `doctor --group remote`
|
||||
checks the local side.
|
||||
- Remote mutations stay manual by design.
|
||||
@@ -0,0 +1,202 @@
|
||||
# ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-10-01
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: npm, mcp, mcp-apps, chatgpt, ui, cli, http, least-authority, performance
|
||||
- **Related**: ADR-263 (harness hardening), ADR-265 (distribution and size
|
||||
budget), ADR-369 (operator surface and SDK), ADR-373 (device access),
|
||||
ADR-374 (remote hosts)
|
||||
|
||||
## Context
|
||||
|
||||
The harness exposes one tool registry through the CLI, an MCP stdio server and
|
||||
an SDK (ADR-369). Hardware testing of the device tools (ADR-373 Amendment 2)
|
||||
showed three gaps in how results reach people and models:
|
||||
|
||||
- **MCP results were text only.** Every tool returned pretty-printed JSON in a
|
||||
text block, with no `structuredContent`. The server declared protocol
|
||||
`2024-11-05`, and `resources/list` was always empty, so MCP Apps hosts and
|
||||
ChatGPT had nothing to render.
|
||||
- **ChatGPT could not connect.** ChatGPT connectors reach MCP servers over
|
||||
HTTPS. The harness spoke stdio only.
|
||||
- **The CLI printed raw JSON to humans.** A live capture of two nodes was a
|
||||
1.2 kB JSON object in the terminal, with alerts such as heartbeat-only
|
||||
senders easy to miss.
|
||||
|
||||
Reference implementations for the target hosts (the `web-based-chatgpt-mcp-starter`
|
||||
and `signal-to-swarm` sites) declare a `ui://` HTML resource and point UI tools
|
||||
at it through both the MCP Apps key (`_meta.ui.resourceUri`) and the ChatGPT
|
||||
keys (`openai/outputTemplate`, `openai/widgetAccessible`).
|
||||
|
||||
The package must stay free of runtime dependencies (ADR-263) and inside a
|
||||
reviewed size budget (ADR-265).
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. One console widget, two host families
|
||||
|
||||
`src/ui/console-widget.js` holds one self-contained HTML resource,
|
||||
`ui://ruview/console-v1.html`, with mimeType `text/html;profile=mcp-app`.
|
||||
|
||||
- **Tools.** `ruview_esp32_capture`, `ruview_devices_scan` and `ruview_doctor`
|
||||
carry `_meta.ui.resourceUri`, `ui/resourceUri`, `openai/outputTemplate` and
|
||||
`openai/widgetAccessible`.
|
||||
- **Data intake.** The widget reads results from either host family:
|
||||
- MCP Apps: a `ui/initialize` handshake over `postMessage`, then
|
||||
`ui/notifications/tool-input` and `ui/notifications/tool-result`;
|
||||
- ChatGPT: `window.openai.toolOutput` and the `openai:set_globals` event.
|
||||
- **Refresh.** A Refresh button re-runs the same tool with the same
|
||||
arguments, through `window.openai.callTool` or an MCP Apps `tools/call`
|
||||
request.
|
||||
- **Safety.**
|
||||
- The resource declares an empty CSP allowlist and makes no network
|
||||
requests.
|
||||
- Every value reaches the DOM through `textContent`; there is no
|
||||
`innerHTML`.
|
||||
- Messages are accepted only from the parent frame.
|
||||
- A test pins all of this.
|
||||
- **Design.** The visual language follows the reference sites: dark ground,
|
||||
`#d0ff72` accent, monospace labels, and plain wording about what each value
|
||||
is. Device vitals are labelled "device-reported (not validated)". Kernel
|
||||
outputs carry the "no reference measurement" note, and SYNTHETIC frames
|
||||
raise a warning.
|
||||
|
||||
### 2. Structured results and protocol negotiation
|
||||
|
||||
- **Protocol negotiation.** `initialize` negotiates `2025-06-18`,
|
||||
`2025-03-26` or `2024-11-05`, and advertises `resources`.
|
||||
- **Result shape.** `tools/call` returns `structuredContent` (always an
|
||||
object), a text block with the same JSON in compact form, and
|
||||
`_meta['ruview/tool']` set to the canonical tool name (so an aliased call
|
||||
still refreshes correctly).
|
||||
- **Compact text.** Compact rather than pretty-printed JSON cuts each result
|
||||
by 17–33% (MEASURED on this host: guidance 17.5%, device scan 20.7%, live
|
||||
capture 33.4%). Clients that read only `content` still receive the
|
||||
complete result.
|
||||
- **Titles.** Tools gain `title` and `annotations.title`.
|
||||
|
||||
`handleRpc` is transport-neutral, and `createDispatcher` holds the FIFO tool
|
||||
chain both transports share (ADR-263 O2).
|
||||
|
||||
### 3. Streamable HTTP transport
|
||||
|
||||
`ruview mcp start --http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]`
|
||||
starts `src/mcp-http.js`.
|
||||
|
||||
- **Authentication on every request.** A request must carry either
|
||||
`Authorization: Bearer <token>`, or the secret-path form `/mcp/<token>`.
|
||||
The second exists because ChatGPT connectors cannot send a static header.
|
||||
- Tokens are compared in constant time and must be at least 16 characters.
|
||||
- The token comes from `RUVIEW_MCP_TOKEN`, never argv. If unset, one is
|
||||
generated and printed once to stderr.
|
||||
- **Least authority.**
|
||||
- It binds to loopback by default.
|
||||
- Browser `Origin`s are refused unless allowlisted (DNS-rebinding defence).
|
||||
Server-to-server clients send no Origin.
|
||||
- `workspace-write` and `hardware-write` are never honoured over HTTP, even
|
||||
when granted in `RUVIEW_MCP_GRANTS`. Flashing and calibration stay
|
||||
stdio/CLI-only.
|
||||
- **Protocol surface.**
|
||||
- POST only; single JSON responses with no SSE stream and no sessions.
|
||||
- Batches and unsupported `MCP-Protocol-Version` values are refused.
|
||||
- Bodies are capped at 256 KiB.
|
||||
- Notifications return 202.
|
||||
- `/healthz` reports only the name and version.
|
||||
|
||||
Exposing the port to ChatGPT (a tunnel or reverse proxy with TLS) is the
|
||||
operator's choice. The startup banner says so and warns on a non-loopback
|
||||
bind.
|
||||
|
||||
### 4. Terminal UI
|
||||
|
||||
`src/cli-ui.js` renders results for a TTY:
|
||||
- aligned tables, with unicode rate bars;
|
||||
- colour that respects `NO_COLOR` and `TERM=dumb`;
|
||||
- alerts and fixes ahead of the data;
|
||||
- a stderr countdown for timed captures.
|
||||
|
||||
Pipes and `--json` keep receiving exact JSON. `ruview call` always prints
|
||||
JSON, because remote hosts parse it.
|
||||
|
||||
`ruview esp32 --watch [--seconds 3]` redraws repeated capture windows with a
|
||||
per-node rate sparkline.
|
||||
|
||||
`ruview monitor` now flags Ameba CSI report-buffer starvation (`lack of csi
|
||||
buf` / `csi buf not enough`). This is the root cause of the Realtek
|
||||
heartbeat-only stall, observed live: 399 starvation lines in 8 s, 0 CSI.
|
||||
|
||||
### 5. Startup
|
||||
|
||||
- **Launcher split.** `bin/cli.js` is a thin launcher, and `src/cli.js` loads
|
||||
the tool registry, doctor, brain, host adapters and transports on demand.
|
||||
- **Measured effect.**
|
||||
- `--help`/`--version`/`skills` skip the registry: about 20% faster, median
|
||||
of 9 interleaved runs against the previous build on this host (MEASURED).
|
||||
- The MCP path needs the whole registry to list tools, so its start time is
|
||||
unchanged within noise.
|
||||
- **Compile cache rejected.** Node's on-disk compile cache
|
||||
(`module.enableCompileCache`) was evaluated: module load was 27.7 ms with
|
||||
it and 28.5 ms without (25 interleaved runs, MEASURED). That is within
|
||||
noise, so it was not adopted.
|
||||
|
||||
### 6. Size budget
|
||||
|
||||
The additions cost about 38 kB:
|
||||
- widget, 12.6 kB;
|
||||
- HTTP transport;
|
||||
- terminal UI;
|
||||
- CLI split.
|
||||
|
||||
The `@ruvnet/ruview` unpacked budget rises from 320 KiB to 384 KiB in
|
||||
`npm-packages.yml` and `ruview-npm-release.yml`. The package is about 338 kB.
|
||||
|
||||
## Evidence
|
||||
|
||||
All on Windows 11 x64, Node 24, with an ESP32-C6 (CP210x, COM16) and an
|
||||
RTL8721Dx (PL2303GC, COM10) attached. All results MEASURED unless noted.
|
||||
|
||||
**Browser e2e.** A real MCP Apps host flow in headless Chrome:
|
||||
- The host lists tools and reads the `ui://` resource over HTTP MCP, then
|
||||
loads it in a sandboxed iframe (`allow-scripts` only).
|
||||
- It answers `ui/initialize` and delivers a live `ruview_esp32_capture`
|
||||
result.
|
||||
- The widget's Refresh button triggered a second real capture through the
|
||||
host (108 → 105 packets).
|
||||
- The devices and doctor views rendered, in dark and light themes. The only
|
||||
console error was the test host's own missing favicon.
|
||||
|
||||
**Security probes** against the live HTTP server:
|
||||
- no token: 401;
|
||||
- wrong token: 401;
|
||||
- foreign Origin: 403;
|
||||
- GET: 405;
|
||||
- `ruview_node_flash` with `hardware-write` granted in the environment:
|
||||
`authority_denied`.
|
||||
|
||||
**Tests.**
|
||||
- `npm test`: 160 pass, 1 skip.
|
||||
- `test:security`: 83 pass, 1 skip.
|
||||
- New suites: `mcp-apps.test.mjs` (protocol, metadata, widget safety, HTTP
|
||||
auth/origin/size/write denial) and `cli-ui.test.mjs` (renderers, colour
|
||||
rules, the starvation detector run against a stand-in serial module).
|
||||
|
||||
**Tarball smoke** from a clean install:
|
||||
- CLI and SDK work;
|
||||
- stdio MCP negotiates `2025-06-18` and serves the widget and structured
|
||||
results;
|
||||
- HTTP MCP lists 22 tools, 3 with `ui://` metadata.
|
||||
|
||||
## Consequences
|
||||
|
||||
- MCP Apps hosts and ChatGPT render node streams, device scans and
|
||||
diagnostics instead of raw JSON, and can refresh them in place.
|
||||
- Remote MCP clients can use the read tools over authenticated HTTP. Write
|
||||
tools remain local by construction.
|
||||
- Each tool result costs fewer model tokens.
|
||||
- The widget is a second place where result shapes are interpreted. Changes
|
||||
to the capture, devices or doctor result shapes must update it, and its
|
||||
version suffix (`console-v1`) changes when the contract does.
|
||||
- Host behaviour for MCP Apps and ChatGPT keys is followed as documented by
|
||||
the reference implementations. A live ChatGPT connector session (public
|
||||
HTTPS URL) has not been run yet: CLAIMED, pending.
|
||||
@@ -0,0 +1,142 @@
|
||||
# ADR-376: `ruview` — one npm install for every RuView component
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-10-01
|
||||
- **Deciders**: rUv (requested and approved the release); RuView maintainers
|
||||
- **Tags**: npm, packaging, umbrella, mcp, homecore, kernel, release
|
||||
- **Related**: ADR-263 (harness hardening), ADR-265 (distribution, D2 CI-only
|
||||
publishing, D4 bin ownership), ADR-285 (Homecore metaharness), ADR-368
|
||||
(kernel), ADR-369 (operator surface), ADR-373 (device access), ADR-375 (MCP
|
||||
Apps console, HTTP transport, terminal UI)
|
||||
|
||||
## Context
|
||||
|
||||
RuView is positioned as an ambient intelligence platform rather than a sensor.
|
||||
Its sensing is ahead of comparable packaged options; packaging is the
|
||||
bottleneck. Today the capabilities are split across three packages:
|
||||
|
||||
| Package | Contents | Published before this ADR |
|
||||
|---|---|---|
|
||||
| `@ruvnet/ruview` | operator harness | 0.5.1 |
|
||||
| `homecore` | Homecore developer metaharness | never |
|
||||
| `@ruvnet/ruview-kernel` | WASM vitals kernel | never |
|
||||
|
||||
The unscoped name `ruview` was unclaimed. A user has to know all three
|
||||
packages, install them separately, and run three CLIs and two MCP servers.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **The umbrella.** Publish an unscoped `ruview` package
|
||||
(`harness/ruview-umbrella`). It depends on exact versions of the three
|
||||
component packages and adds no capability of its own:
|
||||
- **CLI.** One binary, `ruview`:
|
||||
- `ruview homecore …` → the Homecore CLI;
|
||||
- `ruview kernel <doctor|info|selftest|parity|synth|analyze|bench>` → the
|
||||
kernel CLI;
|
||||
- bare `ruview kernel [--backend …]` keeps its harness meaning (the
|
||||
SYNTHETIC self-test);
|
||||
- `ruview capabilities` lists every component, version and tool;
|
||||
- everything else goes to the `@ruvnet/ruview` CLI unchanged.
|
||||
- **MCP server.** One server, `ruview mcp start [--http]`. It serves the
|
||||
harness tools (`ruview_*`, including the `ui://` console) plus the
|
||||
Homecore tools (`homecore_*`, already prefixed, so no collision).
|
||||
- Non-Homecore traffic goes to the harness handler, so the harness
|
||||
authority model, grants and HTTP rules (ADR-375) apply unchanged.
|
||||
- Homecore calls go to Homecore's `runTool`, with Homecore's own
|
||||
policy.
|
||||
2. **Bin ownership (amends ADR-265 D4).** The `ruview` command name moves to
|
||||
the umbrella. `@ruvnet/ruview`'s bin is renamed `ruview-harness`.
|
||||
- **Why:** a clean-directory tarball install showed the collision. With
|
||||
both packages declaring `ruview`, `node_modules/.bin/ruview` linked to
|
||||
the harness CLI, so `ruview capabilities` and `ruview homecore` failed.
|
||||
Which package wins that link is not guaranteed.
|
||||
- **Unaffected:** `npx @ruvnet/ruview …` still works, because npx runs a
|
||||
package's only bin whatever its name. The umbrella delegates every
|
||||
non-umbrella verb to the harness, so `ruview <verb>` behaves as before.
|
||||
- **Changed:** a global install of `@ruvnet/ruview` alone now provides
|
||||
`ruview-harness`.
|
||||
3. **Harness changes needed to embed it** (no new runtime dependencies):
|
||||
- **New exports:** `./cli`, `./cli-ui`, `./mcp`, `./mcp-http`, `./ui`,
|
||||
`./esphome` and `./kernel`.
|
||||
- **Injectable MCP handler:** `run(args, { mcpHandler })` and
|
||||
`startMcpServer({ handler })`, so the merged server reuses the stdio and
|
||||
HTTP transports.
|
||||
- **Kernel declared as an optional peer:** `@ruvnet/ruview-kernel`.
|
||||
- **`setKernelImporter`:** lets the umbrella resolve the kernel from its
|
||||
own dependency tree. Node resolves bare specifiers from the importing
|
||||
file's real path, which misses a sibling dependency under linked or
|
||||
pnpm-style installs.
|
||||
- Found while testing: `kernel_not_installed` through a linked umbrella.
|
||||
- Only embedding code can call it; MCP arguments cannot choose the
|
||||
module.
|
||||
- **Homecore** gains a `./cli` export; **the kernel** gains `./cli`.
|
||||
4. **Versions for this release.**
|
||||
|
||||
| Package | Version | Notes |
|
||||
|---|---|---|
|
||||
| `@ruvnet/ruview` | 0.8.0 | 0.7.0 was never published |
|
||||
| `homecore` | 0.1.0 | first publish |
|
||||
| `@ruvnet/ruview-kernel` | 0.1.0 | first publish; WASM only |
|
||||
| `ruview` | 0.8.0 | umbrella |
|
||||
|
||||
The kernel ships WASM only. The napi backend stays optional and absent,
|
||||
because a workstation build would cover only win32-x64. `loadKernel({
|
||||
backend: 'auto' })` reports the honest fallback.
|
||||
5. **Publishing exception (one-off, amends ADR-265 D2 for this release).**
|
||||
- rUv explicitly chose a local `npm publish` from the operator
|
||||
workstation (ruvzen) over the CI-only release workflow.
|
||||
- Packages are published in dependency order: kernel → homecore →
|
||||
@ruvnet/ruview → ruview.
|
||||
- Each is published only after its full gate passes locally (tests,
|
||||
security tests, manifest, version-literal and pack-content/size gates),
|
||||
plus a clean-directory tarball install and smoke test of the umbrella.
|
||||
- These versions carry no npm provenance attestation.
|
||||
- Subsequent releases return to the CI workflow. Adding `ruview` and
|
||||
`@ruvnet/ruview-kernel` to `ruview-npm-release.yml` is follow-up work.
|
||||
|
||||
## Release outcome (2026-10-01)
|
||||
|
||||
**Published from ruvzen** (exact tarballs that passed the clean-directory
|
||||
smoke test; sha256 in the release thread):
|
||||
|
||||
| Package | Version |
|
||||
|---|---|
|
||||
| `@ruvnet/ruview-kernel` | 0.1.0 |
|
||||
| `homecore` | 0.1.0 |
|
||||
| `@ruvnet/ruview` | 0.8.0 |
|
||||
|
||||
All three were re-installed from the registry and smoke-tested:
|
||||
- `npx -y @ruvnet/ruview@0.8.0` works;
|
||||
- the harness self-test resolves the published kernel;
|
||||
- a live ESPHome radar read succeeded.
|
||||
|
||||
**`ruview` was refused by npm:** `403 Forbidden — Package name too similar to
|
||||
existing package iview` (typosquat protection).
|
||||
- The umbrella is ready in `harness/ruview-umbrella`. Until npm support grants
|
||||
the name, it is unpublished.
|
||||
- If the request is declined, the fallback is a scoped name
|
||||
(`@ruvnet/ruview-platform`).
|
||||
|
||||
## Consequences
|
||||
|
||||
- `npx ruview` is the single entry point for operators and agents. One MCP
|
||||
connector gives an agent, or ChatGPT, every RuView and Homecore tool.
|
||||
- The component packages stay independently installable and testable.
|
||||
`@ruvnet/ruview` remains free of runtime dependencies.
|
||||
- The umbrella pins exact component versions, so each component release needs
|
||||
an umbrella release to reach `npx ruview` users.
|
||||
- The local publish trades provenance for speed for this release only. The
|
||||
exception is recorded here and in the #swarm coordination thread.
|
||||
|
||||
## Evidence
|
||||
|
||||
- **Umbrella tests:** 4/4, covering CLI dispatch to all three components,
|
||||
merged MCP tool listing and routing, the harness authority model through
|
||||
the merged server, and stdio and HTTP transports.
|
||||
- **Component gates:**
|
||||
- harness: 166 tests, 89 security tests;
|
||||
- Homecore: 27 tests, 23 security tests;
|
||||
- kernel: 21 tests, 3 skipped native cases; 12 security tests;
|
||||
- all manifests verified, 0 audit findings.
|
||||
- **Clean-directory tarball smoke:** recorded in the release commit and the
|
||||
#development post.
|
||||
@@ -0,0 +1,101 @@
|
||||
# ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-10-01
|
||||
- **Deciders**: rUv (requested integration into the npm package); RuView maintainers
|
||||
- **Tags**: claude-code, mods, function-hooks, ui, npm, least-authority
|
||||
- **Related**: ADR-373 (device access), ADR-375 (MCP Apps console, terminal UI),
|
||||
ADR-376 (umbrella package); Claude Code mods (function hooks): the
|
||||
claude-code issue 91870 and the `mods/` built-ins (`diff` is the closest
|
||||
analogue)
|
||||
|
||||
## Context
|
||||
|
||||
Claude Code mods are plugins whose behaviour is a function-hooks module: one
|
||||
`register(on, options)` that hooks engine events as `($, e, next)`. They can:
|
||||
- register slash commands;
|
||||
- open panes beside the transcript and draw them with the surface's elements;
|
||||
- set a status line;
|
||||
- run processes through `$.process.run`.
|
||||
|
||||
That fits RuView's goal of putting the live state of the sensors where an
|
||||
operator or agent already works.
|
||||
|
||||
RuView already has:
|
||||
- a read-only CLI with `--json` results (ADR-373, ADR-375);
|
||||
- an MCP server and the ChatGPT/MCP Apps console;
|
||||
- a plugin marketplace in this repository.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **Ship a mod, `ruview-live`, inside `@ruvnet/ruview`** (`mod/`, packaged
|
||||
files `mod/.claude-plugin/`, `mod/hooks/`).
|
||||
- It is listed in the repository marketplace as `ruview-live@ruview`, with
|
||||
source `./harness/ruview/mod`.
|
||||
- One copy serves both installs: the npm package and the marketplace.
|
||||
2. **Behaviour.**
|
||||
- **`/ruview`** toggles a pane (`ruview-live`). While it is open, a
|
||||
`$.clock.every` timer (default 15 s, minimum 5 s) runs one read-only
|
||||
capture.
|
||||
- **`/ruview refresh`** runs one capture and returns the summary.
|
||||
- **`/ruview off`** closes the pane.
|
||||
- **The pane** shows nodes (rate, loss, RSSI, shape, SYNTHETIC flag), the
|
||||
optional ESPHome radar (presence, distance, device-reported vitals), and
|
||||
alerts, including heartbeat-only boards with the CSI-buffer-starvation
|
||||
hint. It has Refresh (`r`) and Close (`c`) buttons.
|
||||
- **The status line** is set on every refresh.
|
||||
3. **Data comes only from the harness CLI beside the mod.**
|
||||
- The mod runs `node <plugin root>/../bin/cli.js esp32 … --json` and
|
||||
`mmwave --source esphome … --json`, as fixed argv arrays, through
|
||||
`$.process.run`. So the pane shows what the tested tools return.
|
||||
- Mods may import nothing but their own files and `claude-code` (the
|
||||
validator refuses `node:url`). The CLI is therefore located through
|
||||
`$.plugin.root`.
|
||||
4. **Least authority.**
|
||||
- The mod never runs a write verb (flash, calibrate, train). A unit test
|
||||
pins the argv.
|
||||
- The radar host is validated before use.
|
||||
- Options are bounded: ports 1024–65535, refresh ≥ 5 s, capture 1–10 s.
|
||||
5. **Discovery.** `ruview mod` (and `ruview mod --json`) prints the installed
|
||||
path, the `claude --plugin-dir` command, the marketplace commands, usage,
|
||||
settings, and the early-access note.
|
||||
|
||||
## Evidence
|
||||
|
||||
All on Claude Code 2.1.286, Windows 11 x64.
|
||||
- **Validation:** `claude plugin validate harness/ruview/mod` passes. It lists
|
||||
hooks `session.start`, `command.run{ruview}`, `ui.render{Pane}`,
|
||||
`ui.close{ruview-live}` and `session.end`, and calls `$.clock.every`,
|
||||
`$.clock.now`, `$.command.register`, `$.process.run` and `$.ui.*`.
|
||||
`claude plugin validate .` passes for the marketplace.
|
||||
- **Engine tests:** `claude plugin test harness/ruview/mod`, with
|
||||
`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`, passed 2/2 in three consecutive
|
||||
runs. They check:
|
||||
- `/ruview` opens the pane and runs the exact read-only argv;
|
||||
- the status reads `RuView · 2 nodes · 1 alert`;
|
||||
- the timer refreshes while open and **never after close**;
|
||||
- an honest `no_packets` status.
|
||||
- **Harness unit tests:** `test/mod.test.mjs`, 6/6, under plain Node:
|
||||
settings bounds, host validation, argv, failure parsing, model/status, pane
|
||||
drawing and button wiring, and the no-imports rule.
|
||||
- **Real engine, headless (MEASURED):**
|
||||
`claude --plugin-dir ./mod -p "/ruview refresh"` answered
|
||||
`RuView · 0 nodes · 1 alert` (no CSI node was streaming). With `radarHost`
|
||||
set to the live Seeed MR60BHA2 kit, it answered
|
||||
`RuView · 0 nodes · radar clear · 1 alert`.
|
||||
- **Note:** under Git Bash a leading `/ruview` is rewritten into a Windows path
|
||||
unless `MSYS_NO_PATHCONV=1` is set. This is not an issue inside the Claude
|
||||
Code prompt.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An operator sees node and radar health beside the conversation without
|
||||
asking for it. Agents see the same facts through MCP.
|
||||
- **Mods are early access.** The API may change between Claude Code releases,
|
||||
and hooks modules load only where function hooks are enabled. The mod is
|
||||
therefore optional, and nothing else in the package depends on it.
|
||||
- **Trust.** Like every mod, it runs inside Claude Code with Claude Code's
|
||||
access. It is read-only by construction, and the README says to install it
|
||||
only from a trusted source.
|
||||
- A refresh binds UDP 5005 for the capture window. While the sensing server
|
||||
holds the port, the pane reports `port_in_use` instead of reading.
|
||||
@@ -0,0 +1,144 @@
|
||||
# ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-10-01
|
||||
- **Deciders**: rUv ("make this a showcase … the art of the possible for mods"); RuView maintainers
|
||||
- **Tags**: claude-code, mods, ui, raster, animation, csi, radar, honesty
|
||||
- **Related**: ADR-377 (the `ruview-live` mod), ADR-373 (device access),
|
||||
ADR-375 (terminal UI), ADR-018 / ADR-323 (CSI formats)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-377 shipped `/ruview`: a pane of cards built from text and sparklines. The
|
||||
mod engine can do much more on the terminal surface:
|
||||
|
||||
- `Raster` is a grid of cells, each with a glyph and a 24-bit foreground and
|
||||
background.
|
||||
- `$.ui.blit` repaints a mounted Raster in place, up to 60 frames a second,
|
||||
without redrawing the tree.
|
||||
- Buttons take hotkeys.
|
||||
|
||||
The pane only showed summaries. It never showed the signal itself: per-subcarrier
|
||||
CSI amplitude over time, or the radar's range as geometry.
|
||||
|
||||
## Decision
|
||||
|
||||
### Three views, keys `1` `2` `3`
|
||||
|
||||
| View | What it draws | Data |
|
||||
|---|---|---|
|
||||
| Overview | the ADR-377 cards | unchanged |
|
||||
| CSI waterfall | subcarrier bins across, time down; two frames per text row through `▀` (foreground = upper frame, background = lower); 5th–95th percentile colour limits with a colour bar | `ruview esp32 --spectrum` (new) |
|
||||
| Radar | top-down 120° fan: range rings, a fading trail of past distances, the current distance as an arc; braille line charts of heart, breathing and distance | ESPHome radar kit (ADR-373) |
|
||||
|
||||
`n` cycles nodes in the waterfall when more than one streams. The live views
|
||||
refresh every `liveRefreshSeconds` (default 4, range 2–60); the overview keeps
|
||||
`refreshSeconds`.
|
||||
|
||||
### Harness: `--spectrum`
|
||||
|
||||
`captureEsp32` and the `ruview_esp32_capture` tool take three new arguments:
|
||||
|
||||
- `spectrum`: boolean;
|
||||
- `spectrum_bins`: 8–128, default 48;
|
||||
- `spectrum_frames`: 8–256, default 64.
|
||||
|
||||
A `SpectrumCollector` keeps a ring of the newest frames for each node and CSI
|
||||
shape. It averages each frame's amplitudes into equal bins, and reports the
|
||||
dominant shape per node with its arrival rate and `synthetic` flag. The
|
||||
collector is read-only and bounded (8 nodes), and it adds nothing to a capture
|
||||
that does not ask for it.
|
||||
|
||||
### Animation with `$.ui.blit`
|
||||
|
||||
One 80 ms timer (about 12 fps) repaints only these keyed Rasters:
|
||||
|
||||
- **shimmer**: a highlight sweeping along the rule under the title. Pure
|
||||
decoration.
|
||||
- **pulse**: a ♥ that beats at the device-reported heart rate and a gauge that
|
||||
fills and empties at the device-reported breathing rate. It is labelled *a
|
||||
metronome, not a waveform*.
|
||||
- **fan**: a sonar ring that leaves the sensor and reaches the measured range.
|
||||
- **waterfall replay**: frames arrive a capture at a time, so the view drains
|
||||
them at their measured arrival rate instead of jumping every refresh. It is
|
||||
labelled *replayed at the arrival rate*.
|
||||
|
||||
The first paint and every frame come from the same function (`picturesOf`). A
|
||||
test asserts that each picture keeps its size across frames, because a blit of
|
||||
another size is refused.
|
||||
|
||||
Blits are fire-and-forget: a blit resolves only once a frame is painted, and
|
||||
the surface folds blits between frames anyway.
|
||||
|
||||
### Honesty rules (CLAUDE.md)
|
||||
|
||||
- The waterfall says **MEASURED — live CSI amplitude received on this host** or
|
||||
**SYNTHETIC — simulator frames**, from the packets' own flag.
|
||||
- The radar view states *range only: this kit reports distance, not bearing*.
|
||||
The arc covers every bearing at that range; nothing implies a position.
|
||||
- Vitals stay *device-reported, not validated*. Values outside 40–180 bpm
|
||||
(heart) or 4–40 bpm (breathing) are flagged.
|
||||
- Chart scales fit the data so a rhythm is visible. The plausible band is
|
||||
shaded only where it overlaps the scale.
|
||||
- Surfaces without `Raster` (desktop, VS Code, mobile) get a note in the
|
||||
showcase views instead of a tree the surface would refuse. The overview works
|
||||
everywhere.
|
||||
- Only nodes in the latest capture stay in the waterfall. A node that stops
|
||||
streaming, or a failed capture, clears its frames, so old frames are never
|
||||
shown as live. Frames join across captures only when the CSI shape and bin
|
||||
count both match.
|
||||
- Multi-antenna ESP32 nodes are drawn from their first chain. ADR-018 lays the
|
||||
tones out antenna-major; this applies to the waterfall only, and the vitals
|
||||
kernel still takes single-chain frames.
|
||||
- Animation runs on the real clock, the same one the full render reads. The
|
||||
pulses keep the reported rates even when a frame fires late.
|
||||
|
||||
### Structure
|
||||
|
||||
Mods may import only their own files, so the hooks module is split into:
|
||||
|
||||
- `model.mjs`: settings, argv, results, model and history (pure);
|
||||
- `raster.mjs`: Grid, base64, colour map, waterfall, braille, radar fan, line
|
||||
chart (pure);
|
||||
- `anim.mjs`: frame functions of time (pure);
|
||||
- `views.mjs`: drawing;
|
||||
- `register.mjs`: wiring, timers and blits. It re-exports the rest so tests
|
||||
import one module.
|
||||
|
||||
## Evidence
|
||||
|
||||
- **MEASURED** (real silicon, 2026-10-01):
|
||||
- Hardware: an ESP32-C6 on COM11, flashed with the hardware-verified
|
||||
`release_bins/c6-adr110` image (v0.7.0; SHA-256 checked) and provisioned to
|
||||
ruv.net, node 6.
|
||||
- Boot log: `Got IP: 192.168.1.103` and `UDP sender initialized:
|
||||
192.168.1.45:5005`.
|
||||
- Captures: `ruview esp32 --spectrum` received 29–50 frames per 2 s at 7–18
|
||||
Hz and −37 to −78 dBm, in 1×64 and 1×256 shapes.
|
||||
- Waterfall: a 1×256 capture rendered as 114 bins showing the occupied band
|
||||
against guard and null tones.
|
||||
- Reproducer: `npx @ruvnet/ruview esp32 --seconds 4 --spectrum --json` with
|
||||
a node targeting the host.
|
||||
- **MEASURED** (device-reported): the Seeed MR60BHA2 kit at 192.168.1.102 drives
|
||||
the radar view.
|
||||
- **SYNTHETIC**: the `realtek-csi-sim` simulator exercises the waterfall in
|
||||
development; its frames carry the synthetic flag and the view says so.
|
||||
- **Tests**:
|
||||
- 11 mod tests and 11 showcase tests: Grid/base64 against Node's encoder,
|
||||
colour map monotone, waterfall ordering, braille bits, fan scaling and
|
||||
ping, chart scaling, frame functions, constant picture sizes, MEASURED and
|
||||
SYNTHETIC labels, surfaces without Raster;
|
||||
- a loopback test of `--spectrum`;
|
||||
- 4 engine tests (`claude plugin test mod`), including the waterfall tab
|
||||
sending `--spectrum`, drawing the Raster and animating it with blits.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The package grows by about 37 KB. The reviewed unpacked ceiling rises from
|
||||
384 KiB to 448 KiB in `npm-packages.yml` and `ruview-npm-release.yml`. There
|
||||
are still no runtime dependencies.
|
||||
- The live views poll more often: a 2 s capture every 4 s by default. A
|
||||
capture binds the UDP port briefly, so a sensing server on the same port
|
||||
still reports `port_in_use`, as in ADR-377.
|
||||
- The animation timer runs only while the pane is open, and stops on close and
|
||||
session end.
|
||||
+16
-1
@@ -2,6 +2,21 @@
|
||||
|
||||
Latest proposed decisions:
|
||||
|
||||
- [ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells](ADR-378-ruview-live-showcase-views.md)
|
||||
- [ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`](ADR-377-ruview-live-claude-code-mod.md)
|
||||
- [ADR-376: `ruview` — one npm install for every RuView component](ADR-376-ruview-umbrella-npm-package.md)
|
||||
- [ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`](ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md)
|
||||
- [ADR-374: Remote host access over SSH (read-only)](ADR-374-remote-host-access-over-ssh.md)
|
||||
- [ADR-373: Host device access layer — ESP32, mmWave, LiDAR](ADR-373-host-device-access-layer.md)
|
||||
- [ADR-372: Structured debugging doctor](ADR-372-structured-debugging-doctor.md)
|
||||
- [ADR-371: Harness training runner and mean-pose evidence gate](ADR-371-harness-training-runner-and-evidence-gate.md)
|
||||
- [ADR-370: Cross-platform, verified ESP32 firmware flashing from npm](ADR-370-cross-platform-firmware-flashing.md)
|
||||
- [ADR-369: npm RuView operator surface — CLI, MCP, and SDK](ADR-369-ruview-npm-operator-surface-and-sdk.md)
|
||||
- [ADR-368: RuView compute kernel as a WASM + napi-rs npm package](ADR-368-ruview-kernel-wasm-napi-npm.md)
|
||||
- [ADR-367: Bounded research swarm and compute ownership](ADR-367-bounded-research-swarm-and-compute-ownership.md)
|
||||
- [ADR-366: CSI controls and frozen evaluation](ADR-366-csi-controls-and-frozen-evaluation.md)
|
||||
- [ADR-365: Public BFI dataset and decoder contract](ADR-365-public-bfi-dataset-and-decoder-contract.md)
|
||||
- [ADR-364: BFI capture admission and continuity](ADR-364-bfi-capture-admission-and-continuity.md)
|
||||
- [ADR-187: archive/v1 deprecation + model-weights honest labeling](ADR-187-archive-v1-deprecation-honest-labeling.md) (refs #509, #1125)
|
||||
- [ADR-186: Training progress API — wire the orphaned in-server trainer to /ws/train/progress](ADR-186-training-progress-api.md) (refs #1233)
|
||||
- [ADR-185: Python P6 SOTA bindings — AETHER, MERIDIAN, MAT](ADR-185-python-p6-sota-bindings.md)
|
||||
@@ -9,7 +24,7 @@ Latest proposed decisions:
|
||||
- [ADR-264: Versioned wire protocol for RTL8720F CFR and Range-FFT reports](ADR-264-rtl8720f-radar-wire-protocol.md)
|
||||
- [ADR-263: Adopt RTL8720F 2.4 GHz FMCW radar as an optional RuView sensing platform](ADR-263-rtl8720f-2-4ghz-fmcw-radar-platform.md)
|
||||
|
||||
This folder contains 210 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.)
|
||||
This folder records significant architectural choices 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?
|
||||
|
||||
|
||||
+166
-3
@@ -26,7 +26,12 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
||||
- [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
|
||||
5. [REST API Reference](#rest-api-reference)
|
||||
6. [WebSocket Streaming](#websocket-streaming)
|
||||
7. [Web UI](#web-ui)
|
||||
7. [RuView npm toolkit (`@ruvnet/ruview`)](#ruview-npm-toolkit-ruvnetruview)
|
||||
- [Hardware from the command line](#hardware-from-the-command-line)
|
||||
- [MCP for agents, ChatGPT and MCP Apps](#mcp-for-agents-chatgpt-and-mcp-apps)
|
||||
- [Claude Code mod: live sensing pane](#claude-code-mod-live-sensing-pane)
|
||||
- [Homecore and the vitals kernel](#homecore-and-the-vitals-kernel)
|
||||
8. [Web UI](#web-ui)
|
||||
8. [Vital Sign Detection](#vital-sign-detection)
|
||||
9. [CLI Reference](#cli-reference)
|
||||
10. [Observatory Visualization](#observatory-visualization)
|
||||
@@ -451,8 +456,8 @@ authority. Versioned collections are `sites`, `buildings`, `floors`,
|
||||
The dependency-free contributor harness exposes the same read path:
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview@0.5.0 spaces --resource alerts --limit 25
|
||||
npx @ruvnet/ruview@0.5.0 mcp start
|
||||
npx @ruvnet/ruview@0.9.1 spaces --resource alerts --limit 25
|
||||
npx @ruvnet/ruview@0.9.1 mcp start
|
||||
```
|
||||
|
||||
Its MCP tool is `ruview_spaces_list`. MCP reads are OAuth-only, use the fixed
|
||||
@@ -958,6 +963,164 @@ Source: [`tools/ruview-mcp/`](../tools/ruview-mcp/README.md). Tracking issue: [#
|
||||
|
||||
---
|
||||
|
||||
## RuView npm toolkit (`@ruvnet/ruview`)
|
||||
|
||||
`@ruvnet/ruview` is RuView's operator toolkit on npm. It has no runtime
|
||||
dependencies and runs on Node 20+. It is tested on Windows and Linux. One tool
|
||||
registry serves:
|
||||
- a CLI;
|
||||
- an MCP server (stdio, or HTTP for ChatGPT);
|
||||
- a typed SDK;
|
||||
- a Claude Code mod.
|
||||
|
||||
Every result is honest about what it is:
|
||||
- **MEASURED** when it comes from live packets on your machine;
|
||||
- **SYNTHETIC** for simulators and self-tests;
|
||||
- **device-reported** for values a radar's own firmware computes, which are
|
||||
not validated against a reference.
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview@0.9.1 --help
|
||||
npx @ruvnet/ruview@0.9.1 doctor # what works on this machine, with fixes
|
||||
```
|
||||
|
||||
In a terminal, commands print a formatted view. Pipes and `--json` print JSON,
|
||||
for scripts.
|
||||
|
||||
### Hardware from the command line
|
||||
|
||||
Run these on the machine the hardware is plugged into.
|
||||
|
||||
```bash
|
||||
# What is plugged in (USB VID:PID → ESP32, Realtek RTL8721Dx, radar, LiDAR)
|
||||
npx @ruvnet/ruview@0.9.1 devices
|
||||
|
||||
# CSI nodes streaming to this machine (UDP 5005): ESP32 ADR-018 frames and
|
||||
# Realtek RAC1 frames, per node rate, loss, RSSI and CSI shape
|
||||
npx @ruvnet/ruview@0.9.1 esp32 --seconds 10
|
||||
npx @ruvnet/ruview@0.9.1 esp32 --watch # live view, redrawn every few seconds
|
||||
npx @ruvnet/ruview@0.9.1 esp32 --seconds 45 --analyze # live CSI through the vitals kernel
|
||||
|
||||
# A board's serial console, without resetting it
|
||||
npx @ruvnet/ruview@0.9.1 monitor --port COM9
|
||||
npx @ruvnet/ruview@0.9.1 monitor --port COM10 --baud 1500000 # Realtek Ameba
|
||||
|
||||
# 60 GHz / 24 GHz radar: raw UART, or an ESPHome kit on your network
|
||||
npx @ruvnet/ruview@0.9.1 mmwave --port COM5 --model auto
|
||||
npx @ruvnet/ruview@0.9.1 mmwave --source esphome --host 192.168.1.50
|
||||
|
||||
# Flash ESP32 firmware: plan first, then write with --confirm (records boot evidence)
|
||||
npx @ruvnet/ruview@0.9.1 flash-plan --port COM16 --bundle firmware/esp32-csi-node/release_bins/c6-adr110 --variant c6
|
||||
npx @ruvnet/ruview@0.9.1 flash --port COM16 --bundle firmware/esp32-csi-node/release_bins/c6-adr110 --variant c6 --confirm
|
||||
```
|
||||
|
||||
**What the alerts mean:**
|
||||
|
||||
| Alert | Meaning |
|
||||
|---|---|
|
||||
| `heartbeat-only sender` | A board is alive but sends no CSI. On a Realtek board, run `monitor --baud 1500000`. If it shows `lack of csi buf`, the board ran out of CSI report buffers (`csi_buffer_starvation`). Reset it. |
|
||||
| `port_in_use` | The sensing server already holds UDP 5005. Stop it, or capture on another port your nodes target. |
|
||||
| `no_decodable_packets` | Packets arrived, but none were in a known RuView format. Check the sender. |
|
||||
| `seqReordered` / `seqStrays` | Out-of-order or stray sequence numbers. These are not counted as loss. |
|
||||
|
||||
**ESPHome radar kits** (for example the Seeed MR60BHA2): the kit's firmware
|
||||
owns the radar, so read it over the network with `--source esphome`.
|
||||
- If the kit cannot join your WiFi, it starts a fallback hotspot. Join it and
|
||||
set your network in its captive portal, at `http://192.168.4.1`.
|
||||
- Only private-network addresses are accepted.
|
||||
- Encrypted or password-protected ESPHome APIs are reported as unsupported.
|
||||
|
||||
### MCP for agents, ChatGPT and MCP Apps
|
||||
|
||||
```bash
|
||||
# Claude Code, Codex or any MCP client (stdio)
|
||||
claude mcp add ruview -- npx -y @ruvnet/ruview@0.9.1 mcp start
|
||||
|
||||
# HTTP, for ChatGPT and remote clients
|
||||
export RUVIEW_MCP_TOKEN="$(node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))")"
|
||||
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview@0.9.1 mcp start --http --port 8790
|
||||
```
|
||||
|
||||
**Connecting ChatGPT.** Clients that can send headers use
|
||||
`Authorization: Bearer <token>`. ChatGPT connectors cannot, so:
|
||||
1. Expose the port over HTTPS, with a tunnel or reverse proxy.
|
||||
2. Add `https://<your-host>/mcp/<token>` as the connector URL.
|
||||
|
||||
**Least authority:**
|
||||
- Hardware tools need the `device-access` grant.
|
||||
- The HTTP transport binds to localhost by default.
|
||||
- It refuses unknown browser origins.
|
||||
- It never allows flashing or calibration, whatever grants are set.
|
||||
|
||||
Node captures, radar reads, device scans and doctor results render in the
|
||||
**RuView console**: a widget that ChatGPT and other MCP Apps hosts show inline,
|
||||
with a Refresh button that re-runs the tool.
|
||||
|
||||
### Claude Code mod: live sensing pane
|
||||
|
||||
The package ships `ruview-live`, a Claude Code mod. `/ruview` opens a pane
|
||||
beside the conversation, refreshed on a timer, showing:
|
||||
- your CSI nodes;
|
||||
- an optional ESPHome radar;
|
||||
- alerts.
|
||||
|
||||
It also keeps a status line such as `RuView · 2 nodes · radar present`.
|
||||
|
||||
Keys `1`, `2` and `3` switch between three views (ADR-378):
|
||||
- **Overview**: the cards.
|
||||
- **CSI waterfall**: per-subcarrier amplitude over time, labelled MEASURED or
|
||||
SYNTHETIC; `n` cycles nodes.
|
||||
- **Radar**: a range fan with an animated ping, plus heart, breathing and
|
||||
distance charts. The values are device-reported and not validated, and the
|
||||
kit reports range, not bearing.
|
||||
|
||||
The two live views draw in terminal cells and animate in place.
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview@0.9.1 mod # prints the mod's path in your install and how to load it
|
||||
claude --plugin-dir "<that path>" # try it for one session
|
||||
# or from the RuView marketplace, inside Claude Code:
|
||||
# /plugin marketplace add ruvnet/RuView
|
||||
# /plugin install ruview-live@ruview
|
||||
```
|
||||
|
||||
| In Claude Code | Does |
|
||||
|---|---|
|
||||
| `/ruview` | Open or close the pane |
|
||||
| `/ruview refresh` | Run one capture now; the result goes in the status line |
|
||||
| `/ruview off` | Close the pane |
|
||||
| `/ruview waterfall` / `/ruview radar` | Open on that view |
|
||||
|
||||
**Settings** (`claude plugin configure ruview-live`): `udpPort`, `radarHost`,
|
||||
`refreshSeconds`, `captureSeconds` and `liveRefreshSeconds` (live views,
|
||||
default 4 s).
|
||||
|
||||
The mod only runs the package's read-only CLI commands. Mods are early access:
|
||||
if `/ruview` is missing, start Claude Code with
|
||||
`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`.
|
||||
|
||||
### Homecore and the vitals kernel
|
||||
|
||||
```bash
|
||||
npx homecore@0.1.0 guidance --topic plugins --query Wasmtime # Homecore developer metaharness
|
||||
npx @ruvnet/ruview-kernel@0.1.0 selftest # vitals pipeline as WASM (SYNTHETIC self-test)
|
||||
```
|
||||
|
||||
`esp32 --analyze` uses the kernel when it is installed. Its heart and breathing
|
||||
estimates carry "no reference measurement": they show the signal path works,
|
||||
not that the readings are accurate.
|
||||
|
||||
Design records:
|
||||
- [ADR-373](adr/ADR-373-host-device-access-layer.md): device access;
|
||||
- [ADR-375](adr/ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md):
|
||||
MCP Apps console, HTTP transport and terminal UI;
|
||||
- [ADR-376](adr/ADR-376-ruview-umbrella-npm-package.md): the one-install
|
||||
`ruview` package, pending the npm name;
|
||||
- [ADR-377](adr/ADR-377-ruview-live-claude-code-mod.md): the Claude Code mod.
|
||||
- [ADR-378](adr/ADR-378-ruview-live-showcase-views.md): the mod's CSI waterfall and radar views (keys 1/2/3, `esp32 --spectrum`).
|
||||
|
||||
---
|
||||
|
||||
## Web UI
|
||||
|
||||
The built-in Three.js UI is served at `http://localhost:3000/ui/` (Docker) or the configured HTTP port.
|
||||
|
||||
@@ -362,6 +362,14 @@ The `MSYS_NO_PATHCONV=1` prefix prevents Git Bash from mangling the `/project` p
|
||||
- `build/partition_table/partition-table.bin` -- flash partition layout
|
||||
- `build/esp32-csi-node.bin` -- application firmware
|
||||
|
||||
### Release bundles
|
||||
|
||||
Firmware downloads are published from dedicated `vX.Y.Z-esp32` tags after all
|
||||
S3 and C6 matrix builds pass. Each release contains separate S3 8 MB, S3 4 MB,
|
||||
and C6 4 MB archives plus an archive checksum manifest. The repository's
|
||||
automated `vNNNN` server releases (for example `v2655`) are not firmware tags;
|
||||
do not use their presence or absence of assets as firmware provenance.
|
||||
|
||||
### Custom Configuration
|
||||
|
||||
To change Kconfig settings before building:
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
#include "nvs_config.h"
|
||||
#include "thermal.h"
|
||||
|
||||
/* firmware/esp32-csi-node/partitions_4mb.csv has two 1.81 MB app slots and
|
||||
* only 256 KB free -- no room for the FAT volume this module needs. The
|
||||
* whole implementation below is compiled out on that profile; every entry
|
||||
* point still links via the stubs in the #else branch so main.c's call
|
||||
* sites need no #ifdef of their own. See partitions_display.csv (8MB) and
|
||||
* partitions_16mb.csv for the profiles where this module is active. */
|
||||
#ifndef CONFIG_ESPTOOLPY_FLASHSIZE_4MB
|
||||
|
||||
static const char *TAG = "node_log";
|
||||
|
||||
/* The FAT volume reserved in partitions_16mb.csv (storage, 8000K) and never
|
||||
@@ -380,3 +388,37 @@ esp_err_t node_log_clear(void)
|
||||
xSemaphoreGive(s_lock);
|
||||
return err;
|
||||
}
|
||||
|
||||
#else /* CONFIG_ESPTOOLPY_FLASHSIZE_4MB */
|
||||
|
||||
esp_err_t node_log_init(void) { return ESP_ERR_NOT_SUPPORTED; }
|
||||
bool node_log_is_active(void) { return false; }
|
||||
void node_log_boot(uint32_t reset_reason, uint32_t prev_uptime_s)
|
||||
{
|
||||
(void)reset_reason;
|
||||
(void)prev_uptime_s;
|
||||
}
|
||||
void node_log_periodic(void) { }
|
||||
void node_log_event(uint8_t subtype, int32_t a, int32_t b)
|
||||
{
|
||||
(void)subtype;
|
||||
(void)a;
|
||||
(void)b;
|
||||
}
|
||||
void node_log_note_disconnect(uint8_t reason, int8_t rssi)
|
||||
{
|
||||
(void)reason;
|
||||
(void)rssi;
|
||||
}
|
||||
size_t node_log_read(uint8_t *out, size_t max_records, size_t skip)
|
||||
{
|
||||
(void)out;
|
||||
(void)max_records;
|
||||
(void)skip;
|
||||
return 0;
|
||||
}
|
||||
size_t node_log_count(void) { return 0; }
|
||||
uint16_t node_log_boot_id(void) { return 0; }
|
||||
esp_err_t node_log_clear(void) { return ESP_ERR_NOT_SUPPORTED; }
|
||||
|
||||
#endif /* CONFIG_ESPTOOLPY_FLASHSIZE_4MB */
|
||||
@@ -162,16 +162,9 @@ static void serial_onboarding_task(void *argument)
|
||||
(void)argument;
|
||||
const int original_flags = fcntl(STDIN_FILENO, F_GETFL, 0);
|
||||
if (original_flags >= 0) (void)fcntl(STDIN_FILENO, F_SETFL, original_flags | O_NONBLOCK);
|
||||
/* ESP-IDF secondary consoles mirror output but deliberately do not forward
|
||||
* input to STDIN. Native S3/C6 USB Serial/JTAG therefore needs a bounded
|
||||
* direct VFS reader while UART-console boards continue to use STDIN. */
|
||||
const int usb_fd = open("/dev/secondary", O_RDONLY | O_NONBLOCK);
|
||||
char primary_line[ONBOARDING_LINE_MAX];
|
||||
char usb_line[ONBOARDING_LINE_MAX];
|
||||
size_t primary_length = 0;
|
||||
size_t usb_length = 0;
|
||||
bool primary_overflow = false;
|
||||
bool usb_overflow = false;
|
||||
for (;;) {
|
||||
uint8_t input[64];
|
||||
const ssize_t count = read(STDIN_FILENO, input, sizeof(input));
|
||||
@@ -182,12 +175,6 @@ static void serial_onboarding_task(void *argument)
|
||||
ESP_LOGW(TAG, "serial input unavailable: errno=%d", errno);
|
||||
vTaskDelay(pdMS_TO_TICKS(500));
|
||||
}
|
||||
if (usb_fd >= 0) {
|
||||
const ssize_t usb_count = read(usb_fd, input, sizeof(input));
|
||||
if (usb_count > 0) {
|
||||
consume_bytes(input, usb_count, usb_line, &usb_length, &usb_overflow);
|
||||
}
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(20));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ phy_init, data, phy, 0x11000, 0x1000,
|
||||
ota_0, app, ota_0, 0x20000, 0x200000,
|
||||
ota_1, app, ota_1, 0x220000, 0x200000,
|
||||
spiffs, data, spiffs, 0x420000, 0x1E0000,
|
||||
storage, data, fat, 0x600000, 0x200000,
|
||||
|
@@ -1,4 +1,4 @@
|
||||
126a4b9f305e207aa2e0db93caf09e27f77b3bee4b4f618a0cb963f10a4364bc bootloader.bin
|
||||
183c4557a93f6f0be1680f45988b7c7320130c8e0ed5d9c900add038d5cc84fb esp32-csi-node.bin
|
||||
12e9fe5bd0533c3eb8ebb1cc36f775668cc4dd39f334ea65e26529b87211dc4c bootloader.bin
|
||||
baff3940ede0ce1658f2f5c3ba458cc9007753eb1519231d67b1ffa724382824 esp32-csi-node.bin
|
||||
7d2c7ac4888bfd75cd5f56e8d61f69595121183afc81556c876732fd3782c62f ota_data_initial.bin
|
||||
4c2cc4ffd52641e23b779bd57b3908014083ac3c1aab395756478c89e70d81f0 partition-table.bin
|
||||
Binary file not shown.
Binary file not shown.
@@ -19,6 +19,12 @@ CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_4mb.csv"
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
|
||||
|
||||
# Native USB/JTAG is the interactive onboarding console on C6 boards. ESP-IDF
|
||||
# secondary consoles mirror output only and cannot receive RUVIEW_HELLO_V1.
|
||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
|
||||
# CONFIG_ESP_CONSOLE_UART_DEFAULT is not set
|
||||
# CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG is not set
|
||||
|
||||
# ── CSI (required) ──
|
||||
CONFIG_ESP_WIFI_CSI_ENABLED=y
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@ FUZZ_JOBS ?= 1
|
||||
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 \
|
||||
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \
|
||||
test_thermal run_thermal test_csi_sanitize run_csi_sanitize \
|
||||
test_c6_antenna run_c6_antenna test_serial_onboarding run_serial_onboarding
|
||||
test_c6_antenna run_c6_antenna test_serial_onboarding run_serial_onboarding \
|
||||
run_delivery_contract
|
||||
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
|
||||
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
|
||||
@@ -132,9 +133,12 @@ test_serial_onboarding: test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onb
|
||||
run_serial_onboarding: test_serial_onboarding
|
||||
./test_serial_onboarding
|
||||
|
||||
run_delivery_contract:
|
||||
python3 test_delivery_contract.py
|
||||
|
||||
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \
|
||||
run_c6_antenna run_serial_onboarding
|
||||
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding)"
|
||||
run_c6_antenna run_serial_onboarding run_delivery_contract
|
||||
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding + delivery contract)"
|
||||
|
||||
# --- Serialize fuzzer ---
|
||||
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static delivery contracts that do not require an ESP-IDF toolchain."""
|
||||
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
FIRMWARE_ROOT = Path(__file__).resolve().parents[1]
|
||||
REPOSITORY_ROOT = FIRMWARE_ROOT.parents[1]
|
||||
|
||||
|
||||
class FirmwareDeliveryContractTests(unittest.TestCase):
|
||||
def test_c6_uses_native_usb_serial_jtag_as_primary_console(self) -> None:
|
||||
config = (FIRMWARE_ROOT / "sdkconfig.defaults.esp32c6").read_text()
|
||||
self.assertIn("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y", config)
|
||||
self.assertNotIn("CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y", config)
|
||||
|
||||
onboarding = (FIRMWARE_ROOT / "main/serial_onboarding.c").read_text()
|
||||
self.assertNotIn('open("/dev/secondary"', onboarding)
|
||||
|
||||
def test_tracked_c6_onboarding_app_matches_version_txt(self) -> None:
|
||||
expected = (FIRMWARE_ROOT / "version.txt").read_text().strip().encode()
|
||||
app = (FIRMWARE_ROOT / "release_bins/c6-onboarding/esp32-csi-node.bin").read_bytes()
|
||||
self.assertTrue(
|
||||
expected + b"\0" in app,
|
||||
f"tracked C6 app does not embed version {expected.decode()}",
|
||||
)
|
||||
|
||||
def test_firmware_tags_publish_downloadable_release_assets(self) -> None:
|
||||
workflow = (REPOSITORY_ROOT / ".github/workflows/firmware-ci.yml").read_text()
|
||||
self.assertIn("publish-release-assets:", workflow)
|
||||
self.assertIn("esp32-csi-node-firmware-*", workflow)
|
||||
self.assertIn("softprops/action-gh-release@", workflow)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -32,7 +32,7 @@
|
||||
"README.md": "7b4eda2e05ab35345e50ec8b8058c0bcbe0fc52e11e8b7447f3d8d231b4ca58d",
|
||||
"bin/cli.js": "c463451f4cecebf308cbfce74c553ca44edb58462fe4e7bfacc49a3bf3aa0c49",
|
||||
"brain/corpus/core.jsonl": "a01a42723490e8c4bcf4ceb4233e8115cb7e27fb9a17a802f279d565dc3493fb",
|
||||
"package.json": "45d9e00dc059e84e2445e9b7a8131a04c4c0241b88908c5e849b964735833601",
|
||||
"package.json": "b3614446b634a5b522484adb2d1292421c57ffdc57177b054cda759356d3f87c",
|
||||
"scripts/update-manifest.mjs": "5dbebd536a65c88dd26f6830c5a4b62b3f9058d971304c3c6c410c3e0e78faaa",
|
||||
"scripts/verify-manifest.mjs": "787351d57f452ee58e67675e09158f17e43adba643981f5148ac144cae17fd80",
|
||||
"skills/explore.md": "4fccb5aab3705fd0d266ae7cb98523d71cce4eed4623e9c10e46fb2136b690e3",
|
||||
@@ -54,7 +54,7 @@
|
||||
"src/repo-trust.js": "c11b2255e43f7cba0c74c33e4a972f60193490821445dce8bab0f97dd47db977",
|
||||
"src/tools.js": "1ad280eebca0688a52ae08fe5647c8e42a6a8e8e5d70534cc4361dc319c22fbf"
|
||||
},
|
||||
"filesDigest": "ad2340db9fc044ffe69d6a9b09a560515cee1d454a405db1b9cc3547afc868c1",
|
||||
"filesDigest": "01b6331f69562fc216bec49a23ef318daf2e7fed76f3714c1db26c0ebe1c7f5f",
|
||||
"brainDigest": "a01a42723490e8c4bcf4ceb4233e8115cb7e27fb9a17a802f279d565dc3493fb",
|
||||
"policyDigest": "73893df248c9a8d79da5451940b40d6b930b9b92999b52ef8bd538c527cd8a47",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1 +1 @@
|
||||
1027780eb92030366cf78f50402234e7a039cce328163e6f0f0f70934284164e manifest.json
|
||||
61e790efeca553f1c99d31b4492fe5f535afc99e4007b8209b723b7dda429fd2 manifest.json
|
||||
@@ -8,6 +8,7 @@
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/tools.js",
|
||||
"./cli": "./bin/cli.js",
|
||||
"./brain": "./src/brain.js",
|
||||
"./guidance": "./src/guidance.js",
|
||||
"./hosts": "./src/hosts/index.js",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Built by `npm run build` (ADR-368); never committed. CI builds, checksums,
|
||||
# and packs them from source.
|
||||
wasm/*.wasm
|
||||
wasm/SHA256SUMS
|
||||
native/*.node
|
||||
native/SHA256SUMS
|
||||
node_modules/
|
||||
*.tgz
|
||||
dist-pack/
|
||||
@@ -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.
|
||||
@@ -0,0 +1,94 @@
|
||||
# @ruvnet/ruview-kernel
|
||||
|
||||
RuView's Rust signal pipeline for JavaScript. The ADR-021 vitals pipeline
|
||||
(`wifi-densepose-vitals`) is compiled to **zero-import WebAssembly** (default)
|
||||
and, optionally, a **napi-rs** native addon. Both expose the same one-function
|
||||
JSON ABI, so results match across backends (ADR-368).
|
||||
|
||||
> Outputs are signal-processing estimates, not clinical or camera-grade
|
||||
> measurements. `synthesize()` data and every self-test number are
|
||||
> **SYNTHETIC**.
|
||||
|
||||
## Build from a RuView checkout
|
||||
|
||||
```bash
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cd harness/ruview-kernel
|
||||
npm run build # wasm + napi for this platform, with SHA256SUMS
|
||||
npm test # unit, security, and end-to-end tests
|
||||
node bin/cli.js parity # wasm vs napi on identical SYNTHETIC input
|
||||
```
|
||||
|
||||
`npm run build:wasm` alone is enough for the portable backend.
|
||||
|
||||
## CLI
|
||||
|
||||
```bash
|
||||
ruview-kernel doctor # both backends, ABI, integrity
|
||||
ruview-kernel selftest [--backend wasm|napi|auto]
|
||||
ruview-kernel synth --seconds 60 --out frames.json
|
||||
ruview-kernel analyze --file frames.json --readings
|
||||
ruview-kernel bench --backend napi # MEASURED on this host only
|
||||
```
|
||||
|
||||
`analyze` accepts a JSON array of frames, `{ "config": {...}, "frames": [...] }`,
|
||||
or JSONL (one `{"amplitudes":[...],"phases":[...]}` per line).
|
||||
|
||||
## API
|
||||
|
||||
```js
|
||||
import { loadKernel, selfTest } from '@ruvnet/ruview-kernel';
|
||||
|
||||
const kernel = loadKernel(); // wasm; or { backend: 'napi' | 'auto' }
|
||||
console.log(kernel.backend, kernel.fallbackReason, kernel.integrity);
|
||||
|
||||
const { frames } = kernel.synthesize({ seconds: 60, breathing_bpm: 15 });
|
||||
const report = kernel.analyze(frames, { n_subcarriers: 56, sample_rate_hz: 20 });
|
||||
console.log(report.summary.last.respiratory); // { bpm, confidence, status }
|
||||
|
||||
const session = kernel.openSession({ n_subcarriers: 56 });
|
||||
const readings = session.push(frames.slice(0, 200));
|
||||
session.close();
|
||||
```
|
||||
|
||||
### Binary fast path
|
||||
|
||||
`analyze()` and `session.push()` automatically send uniform frames as one
|
||||
`Float64Array` (all amplitudes, then all phases) instead of JSON numbers.
|
||||
Irregular input still goes through JSON, so errors name the bad frame. To
|
||||
skip packing entirely:
|
||||
|
||||
```js
|
||||
const data = new Float64Array(frames * 56 * 2); // amplitudes…, then phases…
|
||||
kernel.analyzeFlat(data, { n_subcarriers: 56 }, { phases: true });
|
||||
```
|
||||
|
||||
MEASURED on one Linux x64 host (`ruview-kernel bench --seconds 120`): 2.8×
|
||||
(wasm) and 2.5× (napi) faster than the JSON transport for 2 400 frames. The
|
||||
binary path is also bit-exact, while JSON number parsing can move inputs by
|
||||
ULPs.
|
||||
|
||||
In a browser, fetch the module yourself and use the filesystem-free entry:
|
||||
|
||||
```js
|
||||
import { loadWasmKernelFromBytes } from '@ruvnet/ruview-kernel/wasm';
|
||||
const kernel = loadWasmKernelFromBytes(new Uint8Array(await (await fetch(url)).arrayBuffer()));
|
||||
```
|
||||
|
||||
## Backends and trust
|
||||
|
||||
| Backend | Authority | Selection |
|
||||
|---|---|---|
|
||||
| `wasm` | none: module imports nothing, loader rejects any import | default |
|
||||
| `napi` | full process (native code) | `backend: 'napi'`; never falls back silently |
|
||||
| `auto` | napi if present, otherwise wasm with `fallbackReason` | opt-in |
|
||||
|
||||
Artifacts are checked against `SHA256SUMS`; a mismatch is always fatal.
|
||||
Requests are bounded (16 MiB, 512 subcarriers, 100 000 frames per call, 64
|
||||
sessions) and validated before processing.
|
||||
|
||||
## Metaharness
|
||||
|
||||
`npx @ruvnet/ruview kernel` (MCP tool `ruview_kernel_selftest`) runs this
|
||||
package's self-test from the RuView harness when it is installed, and reports
|
||||
`kernel_not_installed` otherwise.
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env node
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Reproducer for the ADR-368 amendment's rejected WASM build options.
|
||||
// Builds the kernel three ways into separate target dirs and reports size,
|
||||
// median analyzeFlat time over 15 runs (2 400 SYNTHETIC frames), and whether
|
||||
// outputs are byte-identical to the baseline build.
|
||||
// node bench/wasm-variants.mjs (from harness/ruview-kernel; needs cargo + wasm32 target)
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { flattenFrames } from '../src/abi.js';
|
||||
import { loadWasmKernelFromBytes } from '../src/wasm.js';
|
||||
|
||||
const V2 = resolve(import.meta.dirname, '..', '..', '..', 'v2');
|
||||
const variants = {
|
||||
baseline: {},
|
||||
simd128: { CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: '-C target-feature=+simd128' },
|
||||
'opt-level-s': { CARGO_PROFILE_RELEASE_OPT_LEVEL: 's' },
|
||||
};
|
||||
const built = {};
|
||||
for (const [name, env] of Object.entries(variants)) {
|
||||
const targetDir = join(tmpdir(), `ruview-kernel-variant-${name}`);
|
||||
const r = spawnSync('cargo', ['build', '-q', '-p', 'ruview-kernel', '--release', '--target', 'wasm32-unknown-unknown', '--target-dir', targetDir], { cwd: V2, env: { ...process.env, ...env }, stdio: 'inherit' });
|
||||
if (r.status !== 0) process.exit(1);
|
||||
built[name] = readFileSync(join(targetDir, 'wasm32-unknown-unknown', 'release', 'ruview_kernel.wasm'));
|
||||
}
|
||||
const base = loadWasmKernelFromBytes(built.baseline);
|
||||
const { frames } = base.synthesize({ seconds: 120 });
|
||||
const { data } = flattenFrames(frames, 56);
|
||||
const reference = JSON.stringify(base.analyzeFlat(data, {}, { phases: true, includeReadings: true }));
|
||||
const rows = [];
|
||||
for (const [name, bytes] of Object.entries(built)) {
|
||||
const k = loadWasmKernelFromBytes(bytes);
|
||||
const identical = JSON.stringify(k.analyzeFlat(data, {}, { phases: true, includeReadings: true })) === reference;
|
||||
for (let i = 0; i < 3; i++) k.analyzeFlat(data, {}, { phases: true });
|
||||
const runs = [];
|
||||
for (let i = 0; i < 15; i++) { const t = performance.now(); k.analyzeFlat(data, {}, { phases: true }); runs.push(performance.now() - t); }
|
||||
runs.sort((a, b) => a - b);
|
||||
rows.push({ variant: name, bytes: bytes.length, medianMs: Number(runs[7].toFixed(1)), identicalToBaseline: identical });
|
||||
}
|
||||
console.log(JSON.stringify({ evidence: 'MEASURED (this host only)', frames: frames.length, rows }, null, 2));
|
||||
Executable
+180
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env node
|
||||
// SPDX-License-Identifier: MIT
|
||||
// `npx @ruvnet/ruview-kernel` — run the RuView compute kernel from the shell
|
||||
// (ADR-368). Zero runtime dependencies; WASM is the default backend.
|
||||
|
||||
import { closeSync, openSync, readFileSync, readSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { argv } from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { MAX_INPUT_BYTES } from '../src/abi.js';
|
||||
import { BACKENDS, kernelStatus, loadKernel, parity, selfTest } from '../src/index.js';
|
||||
|
||||
const pjson = (value) => console.log(JSON.stringify(value, null, 2));
|
||||
|
||||
function parseFlags(rest) {
|
||||
const flags = {};
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const a = rest[i];
|
||||
if (!a.startsWith('--')) continue;
|
||||
const eq = a.indexOf('=');
|
||||
if (eq !== -1) flags[a.slice(2, eq)] = a.slice(eq + 1);
|
||||
else if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) flags[a.slice(2)] = rest[++i];
|
||||
else flags[a.slice(2)] = true;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
function number(flags, key, fallback) {
|
||||
if (flags[key] === undefined) return fallback;
|
||||
const n = Number(flags[key]);
|
||||
if (!Number.isFinite(n)) throw new Error(`--${key} must be a number`);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Bounded read of a caller-named regular file (JSON array/object or JSONL frames). */
|
||||
function readFrames(path) {
|
||||
const stat = statSync(path);
|
||||
if (!stat.isFile()) throw new Error(`${path} is not a regular file`);
|
||||
if (stat.size > MAX_INPUT_BYTES) throw new Error(`${path} exceeds ${MAX_INPUT_BYTES} bytes`);
|
||||
const fd = openSync(path, 'r');
|
||||
let text;
|
||||
try {
|
||||
const buf = Buffer.alloc(stat.size);
|
||||
readSync(fd, buf, 0, stat.size, 0);
|
||||
text = buf.toString('utf8');
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
// Not a single JSON document: treat as JSONL, one frame per line.
|
||||
const frames = trimmed.split('\n').filter((l) => l.trim()).map((l, i) => {
|
||||
try { return JSON.parse(l); } catch { throw new Error(`line ${i + 1} is not valid JSON`); }
|
||||
});
|
||||
return { frames };
|
||||
}
|
||||
if (Array.isArray(parsed)) return { frames: parsed };
|
||||
if (Array.isArray(parsed?.frames)) return { frames: parsed.frames, config: parsed.config };
|
||||
if (parsed && Array.isArray(parsed.amplitudes)) return { frames: [parsed] };
|
||||
throw new Error('input must be a JSON array of frames, {frames, config}, or JSONL');
|
||||
}
|
||||
|
||||
function help() {
|
||||
console.log(`Usage: ruview-kernel <command> [--backend wasm|napi|auto]
|
||||
|
||||
doctor [--strict-napi] report both backends, ABI, and artifact integrity
|
||||
info kernel build info and limits
|
||||
selftest [--seconds 60] SYNTHETIC end-to-end pipeline check
|
||||
parity diff wasm vs napi outputs on the same SYNTHETIC input
|
||||
synth [--seconds 60] [--breathing 15] [--heart 72] [--subcarriers 56] [--seed 7] [--out file.json]
|
||||
analyze --file frames.json|frames.jsonl [--config '{"n_subcarriers":56}'] [--readings]
|
||||
bench [--seconds 120] time analyze on SYNTHETIC input (MEASURED on this host)
|
||||
|
||||
Default backend: RUVIEW_KERNEL_BACKEND or wasm (zero-import, least authority).
|
||||
Outputs are signal-processing estimates, not clinical or camera-grade measurements.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function run(args) {
|
||||
const cmd = args[0] ?? '--help';
|
||||
const flags = parseFlags(args.slice(1));
|
||||
if (flags.backend !== undefined && !BACKENDS.includes(flags.backend)) {
|
||||
console.error(`--backend must be one of ${BACKENDS.join(', ')}`);
|
||||
return 2;
|
||||
}
|
||||
const load = () => loadKernel({ backend: flags.backend });
|
||||
switch (cmd) {
|
||||
case 'doctor': {
|
||||
const status = kernelStatus();
|
||||
const ok = status.wasm.available && (!flags['strict-napi'] || status.napi.available);
|
||||
pjson({ ok, ...status, note: status.napi.available ? 'both backends available' : 'napi optional; wasm backend active by default' });
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
case 'info': {
|
||||
const k = load();
|
||||
pjson({ backend: k.backend, requestedBackend: k.requestedBackend, fallbackReason: k.fallbackReason, integrity: k.integrity, ...k.info() });
|
||||
return 0;
|
||||
}
|
||||
case 'selftest': {
|
||||
const k = load();
|
||||
const result = selfTest(k, { seconds: number(flags, 'seconds', 60) });
|
||||
pjson({ ...result, requestedBackend: k.requestedBackend, fallbackReason: k.fallbackReason });
|
||||
return result.ok ? 0 : 1;
|
||||
}
|
||||
case 'parity': {
|
||||
const result = parity(loadKernel({ backend: 'wasm' }), loadKernel({ backend: 'napi' }));
|
||||
pjson(result);
|
||||
return result.ok ? 0 : 1;
|
||||
}
|
||||
case 'synth': {
|
||||
const out = load().synthesize({
|
||||
seconds: number(flags, 'seconds', 60),
|
||||
breathing_bpm: number(flags, 'breathing', 15),
|
||||
heart_bpm: number(flags, 'heart', 72),
|
||||
n_subcarriers: number(flags, 'subcarriers', 56),
|
||||
sample_rate_hz: number(flags, 'rate', 20),
|
||||
seed: number(flags, 'seed', 7),
|
||||
});
|
||||
const body = JSON.stringify({ evidence: out.evidence, config: { n_subcarriers: out.request.n_subcarriers, sample_rate_hz: out.request.sample_rate_hz }, frames: out.frames });
|
||||
if (typeof flags.out === 'string') {
|
||||
writeFileSync(flags.out, `${body}\n`, { flag: 'wx' }); // never overwrite an existing file
|
||||
pjson({ ok: true, evidence: out.evidence, frames: out.frames.length, out: flags.out });
|
||||
} else console.log(body);
|
||||
return 0;
|
||||
}
|
||||
case 'analyze': {
|
||||
if (typeof flags.file !== 'string') { console.error('analyze: --file <frames.json|frames.jsonl> is required'); return 2; }
|
||||
const input = readFrames(flags.file);
|
||||
const config = typeof flags.config === 'string' ? JSON.parse(flags.config) : (input.config || {});
|
||||
const k = load();
|
||||
pjson({ ok: true, backend: k.backend, ...k.analyze(input.frames, config, { includeReadings: flags.readings === true }) });
|
||||
return 0;
|
||||
}
|
||||
case 'bench': {
|
||||
const k = load();
|
||||
const seconds = number(flags, 'seconds', 120);
|
||||
const { frames } = k.synthesize({ seconds });
|
||||
k.analyze(frames.slice(0, 200)); // warm-up
|
||||
const iterations = 5;
|
||||
const time = (fn) => { const t0 = performance.now(); for (let i = 0; i < iterations; i++) fn(); return (performance.now() - t0) / iterations; };
|
||||
const ms = time(() => k.analyze(frames));
|
||||
const jsonMs = time(() => k.call('analyze', { config: {}, frames, include_readings: false }));
|
||||
pjson({
|
||||
evidence: 'MEASURED',
|
||||
reproducer: `npx @ruvnet/ruview-kernel bench --backend ${k.backend} --seconds ${seconds}`,
|
||||
host: { node: process.version, platform: process.platform, arch: process.arch },
|
||||
backend: k.backend, frames: frames.length, transport: k.binary ? 'binary' : 'json',
|
||||
msPerAnalyze: Number(ms.toFixed(2)), framesPerSecond: Math.round(frames.length / (ms / 1000)),
|
||||
jsonTransportMs: Number(jsonMs.toFixed(2)), speedupVsJson: Number((jsonMs / ms).toFixed(2)),
|
||||
note: 'Includes transfer across the ABI; single-host timing, not a cross-host claim.',
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
case '--version': case '-v': {
|
||||
console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
|
||||
return 0;
|
||||
}
|
||||
case '--help': case '-h': case 'help': return help();
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd}. Try \`ruview-kernel --help\`.`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
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((error) => {
|
||||
pjson({ ok: false, code: error.code || 'error', error: error.message });
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@ruvnet/ruview-kernel",
|
||||
"version": "0.1.0",
|
||||
"description": "RuView portable compute kernel for Node and browsers: the ADR-021 vitals pipeline in Rust, shipped as zero-import WebAssembly with an optional napi-rs native backend and a byte-identical JSON ABI.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"ruview-kernel": "bin/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.js",
|
||||
"./cli": "./bin/cli.js",
|
||||
"./wasm": "./src/wasm.js",
|
||||
"./abi": "./src/abi.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"src/",
|
||||
"wasm/ruview_kernel.wasm",
|
||||
"wasm/SHA256SUMS",
|
||||
"native/*.node",
|
||||
"native/SHA256SUMS",
|
||||
"scripts/",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "node ./scripts/build.mjs --wasm --napi",
|
||||
"build:wasm": "node ./scripts/build.mjs --wasm",
|
||||
"build:napi": "node ./scripts/build.mjs --napi",
|
||||
"test": "node --test",
|
||||
"test:security": "node --test test/abi.test.mjs test/security.test.mjs",
|
||||
"test:e2e": "node --test test/e2e.test.mjs",
|
||||
"doctor": "node ./bin/cli.js doctor",
|
||||
"selftest": "node ./bin/cli.js selftest",
|
||||
"parity": "node ./bin/cli.js parity",
|
||||
"prepack": "node ./scripts/verify-artifacts.mjs --require-wasm"
|
||||
},
|
||||
"keywords": [
|
||||
"ruview",
|
||||
"wifi-sensing",
|
||||
"csi",
|
||||
"channel-state-information",
|
||||
"vital-signs",
|
||||
"webassembly",
|
||||
"wasm",
|
||||
"napi-rs",
|
||||
"metaharness"
|
||||
],
|
||||
"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-kernel"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/ruvnet/RuView/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Build the ruview-kernel artifacts from the Rust source in ../../v2 (ADR-368).
|
||||
// --wasm cargo build -p ruview-kernel --target wasm32-unknown-unknown --release
|
||||
// --napi cargo build --manifest-path v2/crates/ruview-kernel-napi/Cargo.toml --release
|
||||
// Artifacts are copied into wasm/ and native/ with SHA256SUMS. No shell is used.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { platformTriple } from '../src/platform.js';
|
||||
|
||||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const V2 = resolve(ROOT, '..', '..', 'v2');
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const wantWasm = args.has('--wasm');
|
||||
const wantNapi = args.has('--napi');
|
||||
if (!wantWasm && !wantNapi) {
|
||||
console.error('Usage: node scripts/build.mjs [--wasm] [--napi]');
|
||||
process.exit(2);
|
||||
}
|
||||
if (!existsSync(join(V2, 'Cargo.toml'))) {
|
||||
console.error(`RuView Rust workspace not found at ${V2}; build from a RuView checkout.`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function cargo(cargoArgs, cwd) {
|
||||
console.error(`$ cargo ${cargoArgs.join(' ')}`);
|
||||
const r = spawnSync('cargo', cargoArgs, { cwd, stdio: 'inherit' });
|
||||
if (r.status !== 0) {
|
||||
console.error(`cargo failed (${r.error?.message || `exit ${r.status}`})`);
|
||||
const missing = uninitializedSubmodules();
|
||||
if (missing.length) {
|
||||
// The v2 workspace resolves path dependencies inside submodules; a fresh
|
||||
// clone or `git worktree add` leaves them empty and cargo fails on a
|
||||
// missing Cargo.toml.
|
||||
console.error(`Uninitialized git submodules (likely cause): ${missing.join(', ')}`);
|
||||
console.error('Fix: git submodule update --init --recursive (run at the repository root)');
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function uninitializedSubmodules() {
|
||||
const r = spawnSync('git', ['submodule', 'status'], { cwd: join(V2, '..'), encoding: 'utf8' });
|
||||
if (r.status !== 0) return [];
|
||||
return r.stdout.split('\n').filter((l) => l.startsWith('-')).map((l) => l.slice(1).trim().split(/\s+/)[1]).filter(Boolean);
|
||||
}
|
||||
|
||||
const sha256 = (path) => createHash('sha256').update(readFileSync(path)).digest('hex');
|
||||
|
||||
function install(src, dir, name) {
|
||||
mkdirSync(join(ROOT, dir), { recursive: true });
|
||||
const dest = join(ROOT, dir, name);
|
||||
copyFileSync(src, dest);
|
||||
const digest = sha256(dest);
|
||||
// Merge: one line per artifact, so several platform addons can share native/.
|
||||
const sums = join(ROOT, dir, 'SHA256SUMS');
|
||||
const kept = existsSync(sums)
|
||||
? readFileSync(sums, 'utf8').split('\n').filter((l) => l.trim() && !l.trim().endsWith(` ${name}`))
|
||||
: [];
|
||||
writeFileSync(sums, [...kept, `${digest} ${name}`].sort((a, b) => a.slice(66).localeCompare(b.slice(66))).join('\n') + '\n');
|
||||
return { artifact: `${dir}/${name}`, sha256: digest };
|
||||
}
|
||||
|
||||
/** Fail fast when the toolchain v2/ pins lacks the wasm32 target. */
|
||||
function requireWasmTarget() {
|
||||
const r = spawnSync('rustup', ['target', 'list', '--installed'], { cwd: V2, encoding: 'utf8' });
|
||||
if (r.status === 0 && !r.stdout.split(/\r?\n/).includes('wasm32-unknown-unknown')) {
|
||||
console.error('wasm32-unknown-unknown is not installed for the toolchain pinned by v2/rust-toolchain.');
|
||||
console.error('Fix: (cd v2 && rustup target add wasm32-unknown-unknown)');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const built = [];
|
||||
if (wantWasm) {
|
||||
requireWasmTarget();
|
||||
cargo(['build', '-p', 'ruview-kernel', '--release', '--target', 'wasm32-unknown-unknown', '--locked'], V2);
|
||||
const src = join(V2, 'target', 'wasm32-unknown-unknown', 'release', 'ruview_kernel.wasm');
|
||||
built.push(install(src, 'wasm', 'ruview_kernel.wasm'));
|
||||
}
|
||||
if (wantNapi) {
|
||||
const crate = join(V2, 'crates', 'ruview-kernel-napi');
|
||||
cargo(['build', '--release', '--locked', '--manifest-path', join(crate, 'Cargo.toml')], V2);
|
||||
const lib = { win32: 'ruview_kernel_napi.dll', darwin: 'libruview_kernel_napi.dylib' }[process.platform] || 'libruview_kernel_napi.so';
|
||||
const src = join(crate, 'target', 'release', lib);
|
||||
built.push(install(src, 'native', `ruview_kernel.${platformTriple()}.node`));
|
||||
}
|
||||
console.log(JSON.stringify({ ok: true, built }, null, 2));
|
||||
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Verify packaged kernel artifacts before pack/publish (ADR-368):
|
||||
// checksums match, the WASM module imports nothing, and ABI versions agree.
|
||||
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { ABI_VERSION } from '../src/abi.js';
|
||||
import { DEFAULT_WASM_PATH, PACKAGE_ROOT, loadKernel, readVerifiedArtifact } from '../src/index.js';
|
||||
import { compileKernelModule } from '../src/wasm.js';
|
||||
|
||||
const requireWasm = process.argv.includes('--require-wasm');
|
||||
const quiet = process.argv.includes('--quiet');
|
||||
const report = { ok: true, wasm: null, native: [] };
|
||||
const fail = (message) => { report.ok = false; (report.errors ||= []).push(message); };
|
||||
|
||||
if (existsSync(DEFAULT_WASM_PATH)) {
|
||||
try {
|
||||
const artifact = readVerifiedArtifact(DEFAULT_WASM_PATH);
|
||||
if (artifact.integrity !== 'verified') fail('wasm/ruview_kernel.wasm has no SHA256SUMS entry');
|
||||
compileKernelModule(artifact.bytes);
|
||||
const kernel = loadKernel({ backend: 'wasm' });
|
||||
if (kernel.info().abi !== ABI_VERSION) fail('WASM ABI mismatch');
|
||||
report.wasm = { sha256: artifact.sha256, bytes: artifact.bytes.length, imports: 0 };
|
||||
} catch (error) {
|
||||
fail(`wasm: ${error.message}`);
|
||||
}
|
||||
} else if (requireWasm) {
|
||||
fail('wasm/ruview_kernel.wasm is missing; run `npm run build:wasm`');
|
||||
}
|
||||
|
||||
const nativeDir = join(PACKAGE_ROOT, 'native');
|
||||
for (const name of existsSync(nativeDir) ? readdirSync(nativeDir).filter((n) => n.endsWith('.node')).sort() : []) {
|
||||
try {
|
||||
const artifact = readVerifiedArtifact(join(nativeDir, name));
|
||||
if (artifact.integrity !== 'verified') fail(`native/${name} has no SHA256SUMS entry`);
|
||||
report.native.push({ name, sha256: artifact.sha256 });
|
||||
} catch (error) {
|
||||
fail(`native/${name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!quiet || !report.ok) console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(report.ok ? 0 : 1);
|
||||
@@ -0,0 +1,131 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Shared JSON-ABI envelope handling for every ruview-kernel transport (ADR-368).
|
||||
|
||||
export const ABI_VERSION = 1;
|
||||
export const MAX_INPUT_BYTES = 16 * 1024 * 1024;
|
||||
export const OPERATIONS = Object.freeze([
|
||||
'info', 'validate_config', 'analyze', 'synthesize',
|
||||
'session_open', 'session_push', 'session_summary', 'session_close',
|
||||
'analyze_flat', 'session_push_flat',
|
||||
]);
|
||||
const DEFAULT_SUBCARRIERS = 56;
|
||||
const FRAME_KEYS = new Set(['amplitudes', 'phases']);
|
||||
|
||||
/** Error raised for a `{ok:false}` kernel envelope or a transport failure. */
|
||||
export class KernelError extends Error {
|
||||
constructor(code, message, details = {}) {
|
||||
super(message);
|
||||
this.name = 'KernelError';
|
||||
this.code = code;
|
||||
Object.assign(this, details);
|
||||
}
|
||||
}
|
||||
|
||||
/** Serialize a request, enforcing the ABI's operation allow-list and size bound. */
|
||||
export function encodeRequest(op, request) {
|
||||
if (!OPERATIONS.includes(op)) throw new KernelError('unknown_operation', `unknown operation: ${String(op).slice(0, 64)}`);
|
||||
const json = typeof request === 'string' ? request : JSON.stringify(request ?? {});
|
||||
// UTF-8 needs at most 3 bytes per UTF-16 unit; only encode when it could matter.
|
||||
const bytes = json.length * 3 <= MAX_INPUT_BYTES ? json.length : new TextEncoder().encode(json).length;
|
||||
if (bytes > MAX_INPUT_BYTES) {
|
||||
throw new KernelError('limit_exceeded', `request exceeds ${MAX_INPUT_BYTES} bytes`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/** Parse a kernel envelope; returns `result` or throws KernelError. */
|
||||
export function decodeResponse(text) {
|
||||
let envelope;
|
||||
try {
|
||||
envelope = JSON.parse(text);
|
||||
} catch {
|
||||
throw new KernelError('internal', 'kernel returned malformed JSON');
|
||||
}
|
||||
if (envelope?.ok === true) return envelope.result;
|
||||
const error = envelope?.error || {};
|
||||
throw new KernelError(String(error.code || 'internal'), String(error.message || 'kernel error'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack uniform frames into one Float64Array: all amplitudes, then all phases.
|
||||
* Returns null whenever the JSON path must handle the input instead, so that
|
||||
* malformed frames still get the kernel's precise per-frame error messages.
|
||||
*/
|
||||
export function flattenFrames(frames, n) {
|
||||
if (!Array.isArray(frames) || !Number.isInteger(n) || n < 1) return null;
|
||||
const count = frames.length;
|
||||
const withPhases = count > 0 && Array.isArray(frames[0]?.phases) && frames[0].phases.length > 0;
|
||||
const data = new Float64Array(count * n * (withPhases ? 2 : 1));
|
||||
const phaseBase = count * n;
|
||||
for (let k = 0; k < count; k++) {
|
||||
const f = frames[k];
|
||||
if (!f || typeof f !== 'object' || !Array.isArray(f.amplitudes) || f.amplitudes.length !== n) return null;
|
||||
for (const key of Object.keys(f)) if (!FRAME_KEYS.has(key)) return null;
|
||||
const ph = f.phases ?? [];
|
||||
if (!Array.isArray(ph) || ph.length !== (withPhases ? n : 0)) return null;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = f.amplitudes[i];
|
||||
if (typeof a !== 'number') return null;
|
||||
data[k * n + i] = a;
|
||||
if (withPhases) {
|
||||
const p = ph[i];
|
||||
if (typeof p !== 'number') return null;
|
||||
data[phaseBase + k * n + i] = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { data, withPhases };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a raw transport `(op, json) => json` into the high-level kernel API.
|
||||
* Both backends go through here, so their behaviour differs only in transport.
|
||||
* `transportF64(op, json, Float64Array)` is the optional binary fast path.
|
||||
*/
|
||||
export function createKernel(transport, meta, transportF64 = null) {
|
||||
const call = (op, request) => decodeResponse(transport(op, encodeRequest(op, request)));
|
||||
const callF64 = (op, request, data) => {
|
||||
if (!(data instanceof Float64Array)) throw new KernelError('invalid_request', 'binary frame data must be a Float64Array');
|
||||
if (data.byteLength > MAX_INPUT_BYTES) throw new KernelError('limit_exceeded', `binary data exceeds ${MAX_INPUT_BYTES} bytes`);
|
||||
return decodeResponse(transportF64(op, encodeRequest(op, request), data));
|
||||
};
|
||||
const flat = (frames, config) => (transportF64 ? flattenFrames(frames, config?.n_subcarriers ?? DEFAULT_SUBCARRIERS) : null);
|
||||
const kernel = {
|
||||
...meta,
|
||||
binary: Boolean(transportF64),
|
||||
call,
|
||||
info: () => call('info', {}),
|
||||
validateConfig: (config = {}) => call('validate_config', config),
|
||||
analyze(frames, config = {}, { includeReadings = false } = {}) {
|
||||
const packed = flat(frames, config);
|
||||
return packed
|
||||
? callF64('analyze_flat', { config, phases: packed.withPhases, include_readings: includeReadings }, packed.data)
|
||||
: call('analyze', { config, frames, include_readings: includeReadings });
|
||||
},
|
||||
/** Zero-copy entry: amplitudes for every frame, then phases if `phases`. */
|
||||
analyzeFlat(data, config = {}, { phases = false, includeReadings = false } = {}) {
|
||||
if (!transportF64) throw new KernelError('backend_unavailable', 'this kernel build has no binary transport');
|
||||
return callF64('analyze_flat', { config, phases, include_readings: includeReadings }, data);
|
||||
},
|
||||
synthesize: (options = {}) => call('synthesize', options),
|
||||
openSession(config = {}) {
|
||||
const n = config?.n_subcarriers ?? DEFAULT_SUBCARRIERS;
|
||||
const { session } = call('session_open', { config });
|
||||
let open = true;
|
||||
const ensureOpen = () => { if (!open) throw new KernelError('unknown_session', 'session is closed'); };
|
||||
return {
|
||||
id: session,
|
||||
push(frames) {
|
||||
ensureOpen();
|
||||
const packed = transportF64 ? flattenFrames(frames, n) : null;
|
||||
return packed
|
||||
? callF64('session_push_flat', { session, phases: packed.withPhases }, packed.data).readings
|
||||
: call('session_push', { session, frames }).readings;
|
||||
},
|
||||
summary() { ensureOpen(); return call('session_summary', { session }).summary; },
|
||||
close() { if (!open) return false; open = false; return call('session_close', { session }).closed; },
|
||||
};
|
||||
},
|
||||
};
|
||||
return Object.freeze(kernel);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// @ruvnet/ruview-kernel — Node loader with explicit backend selection (ADR-368).
|
||||
//
|
||||
// Backends:
|
||||
// wasm (default) — zero-import WebAssembly: no host authority at all.
|
||||
// napi — optional napi-rs native addon; full process authority,
|
||||
// so it is only used when requested (`napi`) or allowed
|
||||
// (`auto`, which prefers napi and falls back to wasm).
|
||||
// The loader always reports which backend actually runs and why any
|
||||
// fallback happened; it never silently substitutes a backend for `napi`.
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { dirname, join, basename } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { ABI_VERSION, KernelError, createKernel } from './abi.js';
|
||||
import { loadWasmKernelFromBytes } from './wasm.js';
|
||||
import { platformTriple } from './platform.js';
|
||||
|
||||
export { ABI_VERSION, KernelError, OPERATIONS } from './abi.js';
|
||||
export { loadWasmKernelFromBytes } from './wasm.js';
|
||||
|
||||
export const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
export const DEFAULT_WASM_PATH = join(PACKAGE_ROOT, 'wasm', 'ruview_kernel.wasm');
|
||||
export const BACKENDS = Object.freeze(['wasm', 'napi', 'auto']);
|
||||
const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
||||
|
||||
/** Parse a `sha256 name` checksum file next to an artifact. */
|
||||
function expectedDigest(artifactPath) {
|
||||
const sums = join(dirname(artifactPath), 'SHA256SUMS');
|
||||
if (!existsSync(sums)) return null;
|
||||
for (const line of readFileSync(sums, 'utf8').split('\n')) {
|
||||
const match = /^([0-9a-f]{64})\s+\*?(.+)$/.exec(line.trim());
|
||||
if (match && match[2] === basename(artifactPath)) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an artifact and check it against its SHA256SUMS entry. A missing
|
||||
* checksum is reported (integrity `unverified`); a mismatch always fails.
|
||||
*/
|
||||
export function readVerifiedArtifact(path) {
|
||||
const stat = statSync(path);
|
||||
if (!stat.isFile()) throw new KernelError('artifact_invalid', `${path} is not a regular file`);
|
||||
if (stat.size > MAX_ARTIFACT_BYTES) throw new KernelError('artifact_invalid', `${path} exceeds ${MAX_ARTIFACT_BYTES} bytes`);
|
||||
const bytes = readFileSync(path);
|
||||
const digest = sha256(bytes);
|
||||
const expected = expectedDigest(path);
|
||||
if (expected && expected !== digest) {
|
||||
throw new KernelError('integrity_mismatch', `${basename(path)} sha256 ${digest} does not match SHA256SUMS ${expected}`);
|
||||
}
|
||||
return { bytes, sha256: digest, integrity: expected ? 'verified' : 'unverified' };
|
||||
}
|
||||
|
||||
/** Candidate native addon locations, most specific first. */
|
||||
export function nativeCandidates(triple = platformTriple()) {
|
||||
const candidates = [join(PACKAGE_ROOT, 'native', `ruview_kernel.${triple}.node`)];
|
||||
try {
|
||||
candidates.push(require.resolve(`@ruvnet/ruview-kernel-${triple}`));
|
||||
} catch { /* optional platform package not installed */ }
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function loadWasm(options) {
|
||||
const path = options.wasmPath || DEFAULT_WASM_PATH;
|
||||
if (!existsSync(path)) {
|
||||
throw new KernelError('backend_unavailable', `WASM kernel not found at ${path}; run \`npm run build:wasm\` in harness/ruview-kernel`);
|
||||
}
|
||||
const artifact = readVerifiedArtifact(path);
|
||||
return loadWasmKernelFromBytes(artifact.bytes, { artifact: path, sha256: artifact.sha256, integrity: artifact.integrity });
|
||||
}
|
||||
|
||||
function loadNapi(options) {
|
||||
const triple = platformTriple();
|
||||
const candidates = options.nativePath ? [options.nativePath] : nativeCandidates(triple);
|
||||
const path = candidates.find((p) => existsSync(p));
|
||||
if (!path) {
|
||||
throw new KernelError('backend_unavailable', `no napi-rs addon for ${triple}; run \`npm run build:napi\` or use the wasm backend`);
|
||||
}
|
||||
const artifact = readVerifiedArtifact(path);
|
||||
const addon = require(path);
|
||||
if (typeof addon?.call !== 'function' || typeof addon?.abiVersion !== 'function') {
|
||||
throw new KernelError('untrusted_module', `${basename(path)} does not expose the ruview-kernel ABI`);
|
||||
}
|
||||
const abi = addon.abiVersion();
|
||||
if (abi !== ABI_VERSION) throw new KernelError('abi_mismatch', `napi ABI ${abi} != loader ABI ${ABI_VERSION}`);
|
||||
return createKernel((op, json) => addon.call(op, json), {
|
||||
backend: 'napi', abi, triple, artifact: path, sha256: artifact.sha256, integrity: artifact.integrity,
|
||||
}, typeof addon.callF64 === 'function' ? (op, json, data) => addon.callF64(op, json, data) : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the kernel. `backend` defaults to RUVIEW_KERNEL_BACKEND, then `wasm`.
|
||||
* Options: { backend, wasmPath, nativePath, requireIntegrity }.
|
||||
*/
|
||||
export function loadKernel(options = {}) {
|
||||
const requested = options.backend || process.env.RUVIEW_KERNEL_BACKEND || 'wasm';
|
||||
if (!BACKENDS.includes(requested)) {
|
||||
throw new KernelError('invalid_backend', `backend must be one of ${BACKENDS.join(', ')}`);
|
||||
}
|
||||
let kernel;
|
||||
let fallbackReason = null;
|
||||
if (requested === 'wasm') kernel = loadWasm(options);
|
||||
else if (requested === 'napi') kernel = loadNapi(options);
|
||||
else {
|
||||
try {
|
||||
kernel = loadNapi(options);
|
||||
} catch (error) {
|
||||
if (error.code === 'integrity_mismatch') throw error; // tampering is never a fallback case
|
||||
fallbackReason = error.message;
|
||||
kernel = loadWasm(options);
|
||||
}
|
||||
}
|
||||
if (options.requireIntegrity && kernel.integrity !== 'verified') {
|
||||
throw new KernelError('integrity_unverified', `${kernel.backend} artifact has no SHA256SUMS entry`);
|
||||
}
|
||||
return Object.freeze({ ...kernel, requestedBackend: requested, fallbackReason });
|
||||
}
|
||||
|
||||
/** Non-throwing availability report for both backends (used by `doctor`). */
|
||||
export function kernelStatus(options = {}) {
|
||||
const probe = (backend) => {
|
||||
try {
|
||||
const k = loadKernel({ ...options, backend });
|
||||
const info = k.info();
|
||||
return { available: true, version: info.version, abi: k.abi, target: info.target, artifact: k.artifact, sha256: k.sha256, integrity: k.integrity };
|
||||
} catch (error) {
|
||||
return { available: false, code: error.code || 'error', reason: error.message };
|
||||
}
|
||||
};
|
||||
return { node: process.version, triple: platformTriple(), abi: ABI_VERSION, wasm: probe('wasm'), napi: probe('napi') };
|
||||
}
|
||||
|
||||
const within = (value, target, tolerance) => typeof value === 'number' && Math.abs(value - target) <= tolerance;
|
||||
|
||||
/**
|
||||
* Deterministic end-to-end self-test on SYNTHETIC CSI with known tones.
|
||||
* Exercises synthesize → analyze → streaming session → validation errors.
|
||||
*/
|
||||
export function selfTest(kernel, { seconds = 60, breathingBpm = 15, heartBpm = 72 } = {}) {
|
||||
const started = performance.now();
|
||||
const checks = [];
|
||||
const check = (name, pass, detail = undefined) => checks.push({ name, pass: Boolean(pass), ...(detail === undefined ? {} : { detail }) });
|
||||
|
||||
const info = kernel.info();
|
||||
check('abi matches loader', info.abi === ABI_VERSION, { abi: info.abi });
|
||||
|
||||
const synth = kernel.synthesize({ seconds, breathing_bpm: breathingBpm, heart_bpm: heartBpm, seed: 7 });
|
||||
check('synthetic data is labelled SYNTHETIC', synth.evidence === 'SYNTHETIC');
|
||||
const report = kernel.analyze(synth.frames, {}, { includeReadings: true });
|
||||
const last = report.summary.last;
|
||||
check('one reading per second', report.readings.length === Math.round(seconds), { readings: report.readings.length });
|
||||
check('respiratory rate recovered within 3 bpm', within(last?.respiratory.bpm, breathingBpm, 3), { bpm: last?.respiratory.bpm });
|
||||
check('heart rate recovered within 8 bpm', within(last?.heart.bpm, heartBpm, 8), { bpm: last?.heart.bpm });
|
||||
|
||||
const session = kernel.openSession({});
|
||||
for (let i = 0; i < synth.frames.length; i += 97) session.push(synth.frames.slice(i, i + 97));
|
||||
const streamed = session.summary();
|
||||
session.close();
|
||||
check('streaming session equals batch analysis', JSON.stringify(streamed) === JSON.stringify(report.summary));
|
||||
|
||||
let rejected = null;
|
||||
try { kernel.analyze([{ amplitudes: [1] }], { n_subcarriers: 2 }); } catch (error) { rejected = error.code; }
|
||||
check('malformed frame rejected fail-closed', rejected === 'invalid_request', { code: rejected });
|
||||
|
||||
return {
|
||||
ok: checks.every((c) => c.pass),
|
||||
evidence: 'SYNTHETIC',
|
||||
backend: kernel.backend,
|
||||
kernel: { version: info.version, abi: info.abi, target: info.target },
|
||||
summary: report.summary,
|
||||
checks,
|
||||
elapsedMs: Math.round(performance.now() - started),
|
||||
};
|
||||
}
|
||||
|
||||
/** Numeric deep comparison with a relative tolerance (libm may differ by ULPs across targets). */
|
||||
export function deepClose(a, b, tolerance = 1e-9, path = '$') {
|
||||
if (typeof a === 'number' && typeof b === 'number') {
|
||||
const scale = Math.max(1, Math.abs(a), Math.abs(b));
|
||||
return Math.abs(a - b) <= tolerance * scale ? [] : [`${path}: ${a} != ${b}`];
|
||||
}
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return [`${path}: length ${a.length} != ${b.length}`];
|
||||
return a.flatMap((v, i) => deepClose(v, b[i], tolerance, `${path}[${i}]`));
|
||||
}
|
||||
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
||||
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
||||
return keys.flatMap((k) => deepClose(a[k], b[k], tolerance, `${path}.${k}`));
|
||||
}
|
||||
return Object.is(a, b) ? [] : [`${path}: ${JSON.stringify(a)} != ${JSON.stringify(b)}`];
|
||||
}
|
||||
|
||||
/** Run the same SYNTHETIC workload on two kernels and diff every output. */
|
||||
export function parity(left, right, { seconds = 45 } = {}) {
|
||||
const request = { seconds, seed: 11, n_subcarriers: 32 };
|
||||
const a = left.synthesize(request);
|
||||
const b = right.synthesize(request);
|
||||
const synthDiff = deepClose(a.frames, b.frames);
|
||||
const ra = left.analyze(a.frames, { n_subcarriers: 32 }, { includeReadings: true });
|
||||
const rb = right.analyze(a.frames, { n_subcarriers: 32 }, { includeReadings: true });
|
||||
const analyzeDiff = deepClose(ra, rb);
|
||||
const differences = [...synthDiff, ...analyzeDiff];
|
||||
return {
|
||||
ok: differences.length === 0,
|
||||
evidence: 'SYNTHETIC',
|
||||
backends: [left.backend, right.backend],
|
||||
frames: a.frames.length,
|
||||
readings: ra.readings.length,
|
||||
exactJsonMatch: JSON.stringify(ra) === JSON.stringify(rb),
|
||||
differences: differences.slice(0, 20),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// napi-rs platform triple resolution (matches @napi-rs/cli naming).
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
function isMusl() {
|
||||
if (process.platform !== 'linux') return false;
|
||||
try {
|
||||
const report = process.report?.getReport?.();
|
||||
const header = typeof report === 'string' ? JSON.parse(report).header : report?.header;
|
||||
if (header && 'glibcVersionRuntime' in header) return !header.glibcVersionRuntime;
|
||||
} catch { /* fall through */ }
|
||||
try {
|
||||
return readFileSync('/usr/bin/ldd', 'utf8').includes('musl');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** e.g. `linux-x64-gnu`, `darwin-arm64`, `win32-x64-msvc`. */
|
||||
export function platformTriple(platform = process.platform, arch = process.arch) {
|
||||
if (platform === 'linux') return `linux-${arch}-${isMusl() ? 'musl' : 'gnu'}`;
|
||||
if (platform === 'win32') return `win32-${arch}-msvc`;
|
||||
return `${platform}-${arch}`;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Browser- and Node-safe WASM transport (ADR-368). No filesystem access here:
|
||||
// callers supply the module bytes, so this file can be bundled for the web.
|
||||
//
|
||||
// The module imports nothing from the host (no fs, network, clock, or
|
||||
// randomness); instantiation fails closed if that ever changes.
|
||||
|
||||
import { ABI_VERSION, KernelError, createKernel } from './abi.js';
|
||||
|
||||
const REQUIRED_EXPORTS = ['memory', 'rvk_abi_version', 'rvk_alloc', 'rvk_free', 'rvk_call'];
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
/** Compile and validate a module; rejects any host import. */
|
||||
export function compileKernelModule(bytes) {
|
||||
const module = new WebAssembly.Module(bytes);
|
||||
const imports = WebAssembly.Module.imports(module);
|
||||
if (imports.length !== 0) {
|
||||
throw new KernelError('untrusted_module', `kernel module must not import host functions (found ${imports.length})`);
|
||||
}
|
||||
const names = new Set(WebAssembly.Module.exports(module).map((e) => e.name));
|
||||
const missing = REQUIRED_EXPORTS.filter((n) => !names.has(n));
|
||||
if (missing.length) throw new KernelError('untrusted_module', `kernel module is missing exports: ${missing.join(', ')}`);
|
||||
return module;
|
||||
}
|
||||
|
||||
function instantiate(module) {
|
||||
const { exports } = new WebAssembly.Instance(module, {});
|
||||
const abi = exports.rvk_abi_version();
|
||||
if (abi !== ABI_VERSION) throw new KernelError('abi_mismatch', `WASM ABI ${abi} != loader ABI ${ABI_VERSION}`);
|
||||
return exports;
|
||||
}
|
||||
|
||||
function write(exports, text) {
|
||||
const bytes = encoder.encode(text);
|
||||
const ptr = exports.rvk_alloc(bytes.length);
|
||||
if (ptr === 0) throw new KernelError('out_of_memory', 'kernel allocation failed');
|
||||
new Uint8Array(exports.memory.buffer, ptr, bytes.length).set(bytes);
|
||||
return [ptr, bytes.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a raw `(op, json) => json` transport. A trap (Rust panic, OOM)
|
||||
* poisons the instance; the next call gets a fresh one, and open sessions
|
||||
* from the trapped instance are lost (reported as `unknown_session`).
|
||||
*/
|
||||
export function createWasmTransport(module) {
|
||||
let exports = instantiate(module);
|
||||
let generation = 0;
|
||||
const transport = (op, json) => {
|
||||
if (!exports) { exports = instantiate(module); generation += 1; }
|
||||
try {
|
||||
const [opPtr, opLen] = write(exports, op);
|
||||
const [inPtr, inLen] = write(exports, json);
|
||||
const packed = exports.rvk_call(opPtr, opLen, inPtr, inLen);
|
||||
if (packed === 0n) throw new KernelError('out_of_memory', 'kernel response allocation failed');
|
||||
const ptr = Number(packed >> 32n);
|
||||
const len = Number(packed & 0xffffffffn);
|
||||
const text = decoder.decode(new Uint8Array(exports.memory.buffer, ptr, len).slice());
|
||||
exports.rvk_free(ptr, len);
|
||||
return text;
|
||||
} catch (error) {
|
||||
if (error instanceof KernelError) throw error;
|
||||
exports = null;
|
||||
throw new KernelError('trap', `WASM kernel trapped: ${error?.message || error}`);
|
||||
}
|
||||
};
|
||||
transport.generation = () => generation;
|
||||
transport.f64 = (op, json, data) => {
|
||||
if (!exports) { exports = instantiate(module); generation += 1; }
|
||||
if (typeof exports.rvk_call_f64 !== 'function') throw new KernelError('backend_unavailable', 'WASM module predates the binary transport');
|
||||
try {
|
||||
const [opPtr, opLen] = write(exports, op);
|
||||
const [inPtr, inLen] = write(exports, json);
|
||||
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
const dataPtr = exports.rvk_alloc(bytes.length);
|
||||
if (dataPtr === 0) throw new KernelError('out_of_memory', 'kernel allocation failed');
|
||||
new Uint8Array(exports.memory.buffer, dataPtr, bytes.length).set(bytes);
|
||||
const packed = exports.rvk_call_f64(opPtr, opLen, inPtr, inLen, dataPtr, bytes.length);
|
||||
if (packed === 0n) throw new KernelError('out_of_memory', 'kernel response allocation failed');
|
||||
const ptr = Number(packed >> 32n);
|
||||
const len = Number(packed & 0xffffffffn);
|
||||
const text = decoder.decode(new Uint8Array(exports.memory.buffer, ptr, len).slice());
|
||||
exports.rvk_free(ptr, len);
|
||||
return text;
|
||||
} catch (error) {
|
||||
if (error instanceof KernelError) throw error;
|
||||
exports = null;
|
||||
throw new KernelError('trap', `WASM kernel trapped: ${error?.message || error}`);
|
||||
}
|
||||
};
|
||||
return transport;
|
||||
}
|
||||
|
||||
/** Build the high-level kernel API from WASM bytes (browser or Node). */
|
||||
export function loadWasmKernelFromBytes(bytes, meta = {}) {
|
||||
const module = compileKernelModule(bytes);
|
||||
const transport = createWasmTransport(module);
|
||||
const hasF64 = WebAssembly.Module.exports(module).some((e) => e.name === 'rvk_call_f64');
|
||||
return createKernel(transport, { backend: 'wasm', abi: ABI_VERSION, ...meta }, hasF64 ? transport.f64 : null);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ABI_VERSION, KernelError, MAX_INPUT_BYTES, createKernel, decodeResponse, encodeRequest } from '../src/abi.js';
|
||||
import { deepClose } from '../src/index.js';
|
||||
|
||||
test('encodeRequest enforces the operation allow-list and size bound', () => {
|
||||
assert.equal(encodeRequest('info', {}), '{}');
|
||||
assert.throws(() => encodeRequest('rm_rf', {}), (e) => e instanceof KernelError && e.code === 'unknown_operation');
|
||||
assert.throws(() => encodeRequest('analyze', 'x'.repeat(MAX_INPUT_BYTES + 1)), (e) => e.code === 'limit_exceeded');
|
||||
// Multi-byte characters count as UTF-8 bytes, not UTF-16 units.
|
||||
assert.throws(() => encodeRequest('analyze', '€'.repeat(MAX_INPUT_BYTES / 3 + 1)), (e) => e.code === 'limit_exceeded');
|
||||
});
|
||||
|
||||
test('decodeResponse unwraps ok envelopes and raises structured errors', () => {
|
||||
assert.deepEqual(decodeResponse('{"ok":true,"result":{"a":1}}'), { a: 1 });
|
||||
assert.throws(() => decodeResponse('{"ok":false,"error":{"code":"invalid_request","message":"bad"}}'),
|
||||
(e) => e.code === 'invalid_request' && e.message === 'bad');
|
||||
assert.throws(() => decodeResponse('not json'), (e) => e.code === 'internal');
|
||||
assert.throws(() => decodeResponse('{"result":1}'), (e) => e.code === 'internal');
|
||||
});
|
||||
|
||||
test('createKernel routes every helper through the single transport', () => {
|
||||
const calls = [];
|
||||
const transport = (op, json) => {
|
||||
calls.push([op, JSON.parse(json)]);
|
||||
if (op === 'session_open') return '{"ok":true,"result":{"session":9}}';
|
||||
if (op === 'session_close') return '{"ok":true,"result":{"closed":true}}';
|
||||
return `{"ok":true,"result":{"op":"${op}","abi":${ABI_VERSION}}}`;
|
||||
};
|
||||
const k = createKernel(transport, { backend: 'fake' });
|
||||
assert.equal(k.info().op, 'info');
|
||||
k.analyze([{ amplitudes: [1] }], { n_subcarriers: 1 }, { includeReadings: true });
|
||||
assert.deepEqual(calls.at(-1), ['analyze', { config: { n_subcarriers: 1 }, frames: [{ amplitudes: [1] }], include_readings: true }]);
|
||||
const s = k.openSession({});
|
||||
assert.equal(s.close(), true);
|
||||
assert.equal(s.close(), false, 'double close is a no-op');
|
||||
assert.throws(() => s.push([]), (e) => e.code === 'unknown_session');
|
||||
assert.ok(Object.isFrozen(k));
|
||||
});
|
||||
|
||||
test('deepClose tolerates ULP noise but reports real differences', () => {
|
||||
assert.deepEqual(deepClose({ a: [1, 2.0000000000001] }, { a: [1, 2] }), []);
|
||||
assert.equal(deepClose({ a: 1 }, { a: 1.001 }).length, 1);
|
||||
assert.equal(deepClose({ a: 'x' }, { a: 'y' }).length, 1);
|
||||
assert.equal(deepClose([1], [1, 2]).length, 1);
|
||||
assert.equal(deepClose({ a: 1 }, {}).length, 1);
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
// Binary (Float64Array) fast path (ADR-368 amendment). Evidence: SYNTHETIC.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { flattenFrames, MAX_INPUT_BYTES } from '../src/abi.js';
|
||||
import { deepClose, loadKernel } from '../src/index.js';
|
||||
import { napiSkip, requireWasm } from './helpers.mjs';
|
||||
|
||||
test('flattenFrames packs uniform frames and defers irregular ones to JSON', () => {
|
||||
const f = (a, p) => (p ? { amplitudes: a, phases: p } : { amplitudes: a });
|
||||
const packed = flattenFrames([f([1, 2], [5, 6]), f([3, 4], [7, 8])], 2);
|
||||
assert.deepEqual([...packed.data], [1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
assert.equal(packed.withPhases, true);
|
||||
assert.deepEqual([...flattenFrames([f([1, 2]), f([3, 4])], 2).data], [1, 2, 3, 4]);
|
||||
assert.equal(flattenFrames([f([1, 2])], 3), null, 'width mismatch');
|
||||
assert.equal(flattenFrames([f([1, 2], [1, 2]), f([3, 4])], 2), null, 'mixed phases');
|
||||
assert.equal(flattenFrames([{ amplitudes: [1, 2], extra: 1 }], 2), null, 'unknown key');
|
||||
assert.equal(flattenFrames([f([1, '2'])], 2), null, 'non-number');
|
||||
assert.equal(flattenFrames([null], 2), null);
|
||||
assert.equal(flattenFrames([], 2).data.length, 0);
|
||||
});
|
||||
|
||||
function transportsAgree(k) {
|
||||
assert.equal(k.binary, true, `${k.backend} exposes the binary transport`);
|
||||
const { frames } = k.synthesize({ seconds: 30, n_subcarriers: 16 });
|
||||
const viaBinary = k.analyze(frames, { n_subcarriers: 16 }, { includeReadings: true });
|
||||
const viaJson = k.call('analyze', { config: { n_subcarriers: 16 }, frames, include_readings: true });
|
||||
// JSON number parsing perturbs inputs by ULPs; outputs agree to 1e-9.
|
||||
assert.deepEqual(deepClose(viaBinary, viaJson), []);
|
||||
const s = k.openSession({ n_subcarriers: 16 });
|
||||
for (let i = 0; i < frames.length; i += 50) s.push(frames.slice(i, i + 50));
|
||||
assert.deepEqual(s.summary(), viaBinary.summary, 'streamed binary pushes equal batch binary analysis');
|
||||
s.close();
|
||||
}
|
||||
|
||||
test('wasm binary and JSON transports agree', () => {
|
||||
requireWasm();
|
||||
transportsAgree(loadKernel({ backend: 'wasm' }));
|
||||
});
|
||||
|
||||
test('napi binary and JSON transports agree', { skip: napiSkip }, () => {
|
||||
transportsAgree(loadKernel({ backend: 'napi' }));
|
||||
});
|
||||
|
||||
test('binary entry points validate input and keep precise JSON errors for bad frames', () => {
|
||||
requireWasm();
|
||||
const k = loadKernel({ backend: 'wasm' });
|
||||
assert.throws(() => k.analyzeFlat([1, 2, 3]), (e) => e.code === 'invalid_request');
|
||||
assert.throws(() => k.analyzeFlat(new Float64Array(MAX_INPUT_BYTES / 8 + 1)), (e) => e.code === 'limit_exceeded');
|
||||
assert.throws(() => k.analyzeFlat(new Float64Array(5), { n_subcarriers: 2 }), (e) => e.code === 'invalid_request');
|
||||
assert.throws(() => k.analyzeFlat(Float64Array.of(1, NaN), { n_subcarriers: 2 }), (e) => e.code === 'invalid_request');
|
||||
// A malformed frame falls back to JSON, whose error names the frame.
|
||||
assert.throws(() => k.analyze([{ amplitudes: [1, 2] }, { amplitudes: [1] }], { n_subcarriers: 2 }), (e) => /frames\[1\]/.test(e.message));
|
||||
assert.equal(k.info().abi, 1, 'instance healthy after rejected input');
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
// End-to-end: Rust core → WASM/napi transports → JS API → CLI (ADR-368).
|
||||
// Evidence for every numeric assertion here: SYNTHETIC.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { kernelStatus, loadKernel, parity, selfTest } from '../src/index.js';
|
||||
import { napiSkip, requireWasm } from './helpers.mjs';
|
||||
|
||||
const CLI = join(dirname(dirname(fileURLToPath(import.meta.url))), 'bin', 'cli.js');
|
||||
const cli = (...args) => {
|
||||
const r = spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', env: { ...process.env, RUVIEW_KERNEL_BACKEND: '' } });
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr, json: () => JSON.parse(r.stdout) };
|
||||
};
|
||||
|
||||
test('wasm backend passes the SYNTHETIC self-test', () => {
|
||||
requireWasm();
|
||||
const k = loadKernel({ backend: 'wasm' });
|
||||
assert.equal(k.backend, 'wasm');
|
||||
assert.equal(k.integrity, 'verified');
|
||||
const result = selfTest(k);
|
||||
assert.ok(result.ok, JSON.stringify(result.checks.filter((c) => !c.pass)));
|
||||
assert.equal(result.evidence, 'SYNTHETIC');
|
||||
assert.equal(result.kernel.target, 'wasm32');
|
||||
});
|
||||
|
||||
test('napi backend passes the SYNTHETIC self-test', { skip: napiSkip }, () => {
|
||||
const k = loadKernel({ backend: 'napi' });
|
||||
assert.equal(k.backend, 'napi');
|
||||
const result = selfTest(k);
|
||||
assert.ok(result.ok, JSON.stringify(result.checks.filter((c) => !c.pass)));
|
||||
assert.equal(result.kernel.target, 'native');
|
||||
});
|
||||
|
||||
test('wasm and napi produce the same outputs for the same input', { skip: napiSkip }, () => {
|
||||
const result = parity(loadKernel({ backend: 'wasm' }), loadKernel({ backend: 'napi' }));
|
||||
assert.ok(result.ok, result.differences.join('\n'));
|
||||
assert.equal(result.readings, 45);
|
||||
});
|
||||
|
||||
test('auto prefers napi and reports an honest fallback to wasm', () => {
|
||||
requireWasm();
|
||||
const k = loadKernel({ backend: 'auto', nativePath: '/nonexistent/ruview_kernel.node' });
|
||||
assert.equal(k.backend, 'wasm');
|
||||
assert.equal(k.requestedBackend, 'auto');
|
||||
assert.match(k.fallbackReason, /no napi-rs addon|not found|nonexistent/);
|
||||
});
|
||||
|
||||
test('kernelStatus reports both backends without throwing', () => {
|
||||
const status = kernelStatus();
|
||||
assert.equal(status.abi, 1);
|
||||
assert.equal(typeof status.wasm.available, 'boolean');
|
||||
assert.equal(typeof status.napi.available, 'boolean');
|
||||
});
|
||||
|
||||
test('CLI: doctor, selftest, synth → analyze round trip', () => {
|
||||
requireWasm();
|
||||
const doctor = cli('doctor');
|
||||
assert.equal(doctor.status, 0, doctor.stderr);
|
||||
assert.equal(doctor.json().wasm.available, true);
|
||||
|
||||
const st = cli('selftest', '--seconds', '45');
|
||||
assert.equal(st.status, 0, st.stdout);
|
||||
assert.equal(st.json().backend, 'wasm');
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ruview-kernel-e2e-'));
|
||||
try {
|
||||
const file = join(dir, 'frames.json');
|
||||
const synth = cli('synth', '--seconds', '40', '--breathing', '12', '--subcarriers', '24', '--out', file);
|
||||
assert.equal(synth.status, 0, synth.stderr);
|
||||
assert.equal(cli('synth', '--seconds', '1', '--out', file).status, 1, 'synth must refuse to overwrite');
|
||||
|
||||
const analyzed = cli('analyze', '--file', file, '--readings');
|
||||
assert.equal(analyzed.status, 0, analyzed.stderr);
|
||||
const report = analyzed.json();
|
||||
assert.equal(report.summary.frames, 800);
|
||||
assert.equal(report.readings.length, 40);
|
||||
assert.ok(Math.abs(report.summary.last.respiratory.bpm - 12) <= 3, `SYNTHETIC RR ${report.summary.last.respiratory.bpm}`);
|
||||
|
||||
// JSONL input takes the same path.
|
||||
const frames = JSON.parse(cli('synth', '--seconds', '2', '--subcarriers', '4').stdout).frames;
|
||||
const jsonl = join(dir, 'frames.jsonl');
|
||||
writeFileSync(jsonl, frames.map((f) => JSON.stringify(f)).join('\n'));
|
||||
const small = cli('analyze', '--file', jsonl, '--config', '{"n_subcarriers":4}');
|
||||
assert.equal(small.status, 0, small.stdout);
|
||||
assert.equal(small.json().summary.frames, 40);
|
||||
|
||||
const bad = cli('analyze', '--file', jsonl);
|
||||
assert.equal(bad.status, 1, 'width mismatch against default 56 subcarriers must fail');
|
||||
assert.equal(bad.json().code, 'invalid_request');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('CLI rejects unknown backends and commands', () => {
|
||||
assert.equal(cli('info', '--backend', 'gpu').status, 2);
|
||||
assert.equal(cli('frobnicate').status, 2);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import { DEFAULT_WASM_PATH, nativeCandidates } from '../src/index.js';
|
||||
|
||||
export const HAS_WASM = existsSync(DEFAULT_WASM_PATH);
|
||||
export const HAS_NAPI = nativeCandidates().some((p) => existsSync(p));
|
||||
// CI sets RUVIEW_KERNEL_REQUIRE_NAPI=1 after `npm run build:napi` so a missing
|
||||
// addon is a failure there, while contributors without Rust can still run tests.
|
||||
export const REQUIRE_NAPI = process.env.RUVIEW_KERNEL_REQUIRE_NAPI === '1';
|
||||
export const napiSkip = !HAS_NAPI && !REQUIRE_NAPI ? 'napi addon not built (npm run build:napi)' : false;
|
||||
|
||||
export function requireWasm() {
|
||||
if (!HAS_WASM) throw new Error(`WASM kernel missing at ${DEFAULT_WASM_PATH}; run \`npm run build:wasm\` first`);
|
||||
}
|
||||
|
||||
/** Smallest valid module that imports one host function ("env"."f"). */
|
||||
export const MODULE_WITH_IMPORT = new Uint8Array([
|
||||
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
|
||||
0x01, 0x04, 0x01, 0x60, 0x00, 0x00,
|
||||
0x02, 0x09, 0x01, 0x03, 0x65, 0x6e, 0x76, 0x01, 0x66, 0x00, 0x00,
|
||||
]);
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DEFAULT_WASM_PATH, loadKernel } from '../src/index.js';
|
||||
import { compileKernelModule, loadWasmKernelFromBytes } from '../src/wasm.js';
|
||||
import { HAS_WASM, MODULE_WITH_IMPORT, requireWasm } from './helpers.mjs';
|
||||
|
||||
test('a module that imports host functions is refused', () => {
|
||||
assert.throws(() => compileKernelModule(MODULE_WITH_IMPORT), (e) => e.code === 'untrusted_module');
|
||||
assert.throws(() => loadWasmKernelFromBytes(MODULE_WITH_IMPORT), (e) => e.code === 'untrusted_module');
|
||||
});
|
||||
|
||||
test('a module without the kernel exports is refused', () => {
|
||||
const empty = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]);
|
||||
assert.throws(() => compileKernelModule(empty), (e) => e.code === 'untrusted_module' && /missing exports/.test(e.message));
|
||||
});
|
||||
|
||||
test('invalid backend names are rejected', () => {
|
||||
assert.throws(() => loadKernel({ backend: 'python' }), (e) => e.code === 'invalid_backend');
|
||||
});
|
||||
|
||||
test('explicit napi never silently falls back to wasm', () => {
|
||||
assert.throws(() => loadKernel({ backend: 'napi', nativePath: '/nonexistent/ruview.node' }), (e) => e.code === 'backend_unavailable');
|
||||
});
|
||||
|
||||
test('checksum mismatch fails closed and is never an auto fallback', { skip: !HAS_WASM && 'wasm not built' }, () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ruview-kernel-'));
|
||||
try {
|
||||
const wasmPath = join(dir, 'ruview_kernel.wasm');
|
||||
copyFileSync(DEFAULT_WASM_PATH, wasmPath);
|
||||
writeFileSync(join(dir, 'SHA256SUMS'), `${'0'.repeat(64)} ruview_kernel.wasm\n`);
|
||||
assert.throws(() => loadKernel({ backend: 'wasm', wasmPath }), (e) => e.code === 'integrity_mismatch');
|
||||
|
||||
const fakeNative = join(dir, 'fake.node');
|
||||
writeFileSync(fakeNative, 'not an addon');
|
||||
writeFileSync(join(dir, 'SHA256SUMS'), `${'1'.repeat(64)} fake.node\n`);
|
||||
assert.throws(() => loadKernel({ backend: 'auto', nativePath: fakeNative }), (e) => e.code === 'integrity_mismatch');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('requireIntegrity refuses an unchecksummed artifact', { skip: !HAS_WASM && 'wasm not built' }, () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ruview-kernel-'));
|
||||
try {
|
||||
const wasmPath = join(dir, 'ruview_kernel.wasm');
|
||||
copyFileSync(DEFAULT_WASM_PATH, wasmPath);
|
||||
assert.equal(loadKernel({ backend: 'wasm', wasmPath }).integrity, 'unverified');
|
||||
assert.throws(() => loadKernel({ backend: 'wasm', wasmPath, requireIntegrity: true }), (e) => e.code === 'integrity_unverified');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('kernel rejects hostile frames without crashing the instance', () => {
|
||||
requireWasm();
|
||||
const k = loadKernel({ backend: 'wasm' });
|
||||
const cases = [
|
||||
[() => k.analyze([{ amplitudes: [1, 2] }], { n_subcarriers: 3 }), 'invalid_request'],
|
||||
[() => k.analyze([{ amplitudes: [1e300] }], { n_subcarriers: 1 }), 'invalid_request'],
|
||||
[() => k.analyze([{ amplitudes: [1], evil: true }], { n_subcarriers: 1 }), 'invalid_request'],
|
||||
[() => k.analyze([], { n_subcarriers: 100000 }), 'invalid_request'],
|
||||
[() => k.analyze([], { sample_rate_hz: -1 }), 'invalid_request'],
|
||||
[() => k.synthesize({ seconds: 3600, sample_rate_hz: 1000 }), 'limit_exceeded'],
|
||||
[() => k.call('session_push', { session: 424242, frames: [] }), 'unknown_session'],
|
||||
];
|
||||
for (const [fn, code] of cases) assert.throws(fn, (e) => e.code === code, code);
|
||||
assert.equal(k.info().abi, 1, 'instance still healthy after rejected input');
|
||||
});
|
||||
|
||||
test('session registry is bounded per instance', () => {
|
||||
requireWasm();
|
||||
const k = loadKernel({ backend: 'wasm' });
|
||||
const sessions = [];
|
||||
let limited = false;
|
||||
for (let i = 0; i < 70; i++) {
|
||||
try { sessions.push(k.openSession({})); } catch (e) { assert.equal(e.code, 'limit_exceeded'); limited = true; break; }
|
||||
}
|
||||
assert.ok(limited, 'more than 64 concurrent sessions must be refused');
|
||||
for (const s of sessions) s.close();
|
||||
assert.ok(k.openSession({}).close());
|
||||
});
|
||||
Whitespace-only changes.
@@ -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.
|
||||
@@ -0,0 +1,102 @@
|
||||
# ruview
|
||||
|
||||
**RuView is an ambient intelligence platform, not a sensor.** It turns the radio
|
||||
signals already in a room (WiFi channel state from ESP32 and Realtek nodes,
|
||||
60/24 GHz mmWave radar, LiDAR) into presence, motion, breathing and pose. All
|
||||
of it is camera-free and reachable from the tools people and agents already use.
|
||||
|
||||
The sensing stack is ahead of packaged commercial options. The bottleneck has
|
||||
been packaging. This package is RuView in one install: one CLI, one MCP
|
||||
server, and a live console that renders inside ChatGPT and other MCP Apps hosts.
|
||||
|
||||
```bash
|
||||
npx ruview --help
|
||||
npx ruview capabilities # every component, version and tool in this install
|
||||
```
|
||||
|
||||
## What you get
|
||||
|
||||
| Component | Package | What it does |
|
||||
|---|---|---|
|
||||
| Operator harness | [`@ruvnet/ruview`](https://www.npmjs.com/package/@ruvnet/ruview) | Node capture and diagnostics for ESP32 and Realtek RTL8721Dx CSI, 60/24 GHz radar (raw UART or ESPHome kits such as the Seeed MR60BHA2), RPLIDAR and iPhone LiDAR, firmware flashing with boot evidence, training with a baseline/leakage gate, doctor, remote hosts over SSH |
|
||||
| Homecore metaharness | [`homecore`](https://www.npmjs.com/package/homecore) | Source-cited guidance for the Homecore runtime, WASM kernel status, reviewed memory |
|
||||
| Vitals kernel | [`@ruvnet/ruview-kernel`](https://www.npmjs.com/package/@ruvnet/ruview-kernel) | The Rust breathing/heart-rate pipeline as zero-import WebAssembly; `ruview esp32 --analyze` runs live CSI through it |
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# What is plugged in? (USB VID:PID → ESP32, Realtek, radar, LiDAR)
|
||||
npx ruview devices
|
||||
|
||||
# Live CSI from your nodes (UDP :5005), redrawn every few seconds
|
||||
npx ruview esp32 --watch
|
||||
|
||||
# A 60 GHz radar kit running ESPHome, on your LAN
|
||||
npx ruview mmwave --source esphome --host 192.168.1.50
|
||||
|
||||
# Diagnostics with fixes
|
||||
npx ruview doctor
|
||||
|
||||
# Homecore and the kernel through the same CLI
|
||||
npx ruview homecore guidance --topic plugins --query Wasmtime
|
||||
npx ruview kernel selftest
|
||||
```
|
||||
|
||||
A terminal gets a formatted view; pipes and `--json` get JSON.
|
||||
|
||||
In **Claude Code**, the bundled `ruview-live` mod puts a live sensing pane beside the conversation (`/ruview`). Run `npx ruview mod` to see how to load it (mods are early access).
|
||||
|
||||
## Agents: MCP, ChatGPT and MCP Apps
|
||||
|
||||
One server exposes the harness tools (`ruview_*`) and the Homecore tools
|
||||
(`homecore_*`):
|
||||
|
||||
```bash
|
||||
npx ruview mcp start # stdio (Claude Code, Codex, any MCP client)
|
||||
```
|
||||
|
||||
Node captures, radar reads, device scans and doctor results render in the
|
||||
**RuView console** (`ui://ruview/console-v1.html`) in ChatGPT and other MCP Apps
|
||||
hosts. A Refresh button re-runs the tool in place.
|
||||
|
||||
For ChatGPT and other remote clients, serve MCP over HTTP:
|
||||
|
||||
```bash
|
||||
export RUVIEW_MCP_TOKEN="$(node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))")"
|
||||
RUVIEW_MCP_GRANTS=device-access npx ruview mcp start --http --port 8790
|
||||
# then expose it over HTTPS (tunnel / reverse proxy) and add the connector URL
|
||||
# https://<your-host>/mcp/<token>
|
||||
```
|
||||
|
||||
**Least authority by default.**
|
||||
- Hardware reads need the `device-access` grant.
|
||||
- The HTTP transport requires a token.
|
||||
- It binds to loopback.
|
||||
- It refuses unknown browser origins.
|
||||
- It **never** honours write grants: flashing and calibration stay on the
|
||||
local CLI.
|
||||
|
||||
## Honesty rules
|
||||
|
||||
- **Device-reported vitals** (from radar firmware) are labelled as such and
|
||||
are not validated against a reference.
|
||||
- **Kernel estimates** carry "no reference measurement".
|
||||
- **Synthetic data** (simulators, self-tests) is flagged `SYNTHETIC`.
|
||||
- **Accuracy claims** must pass `ruview claim-check`, tagged MEASURED (with a
|
||||
reproducer), CLAIMED or SYNTHETIC.
|
||||
|
||||
WiFi sensing is not camera-grade, and RuView never presents it that way.
|
||||
|
||||
## Privacy
|
||||
|
||||
RuView senses without cameras, and nothing leaves your network unless you send
|
||||
it. The MCP HTTP transport binds to localhost unless you choose otherwise, and
|
||||
the console widget makes no network requests.
|
||||
|
||||
## Links
|
||||
|
||||
- Source and architecture decisions: <https://github.com/ruvnet/RuView>
|
||||
(ADR-373 device access, ADR-375 MCP Apps console/HTTP/terminal UI, ADR-376 this package)
|
||||
- Issues: <https://github.com/ruvnet/RuView/issues>
|
||||
|
||||
MIT licensed.
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
// SPDX-License-Identifier: MIT
|
||||
// `npx ruview` — one CLI for every RuView component (ADR-376).
|
||||
// ruview homecore <cmd> → Homecore developer metaharness
|
||||
// ruview kernel <subcmd> → WASM vitals kernel CLI (doctor|info|selftest|parity|synth|analyze|bench)
|
||||
// ruview mcp start [--http] → one MCP server: harness + Homecore tools, ui:// console
|
||||
// ruview capabilities → what this install can do
|
||||
// ruview <anything else> → @ruvnet/ruview operator harness
|
||||
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { argv } from 'node:process';
|
||||
import { KERNEL_COMMANDS, VERSION, capabilities, componentVersions, wireKernel } from '../src/index.js';
|
||||
|
||||
const HELP_HEADER = `ruview ${VERSION} — RuView in one install
|
||||
|
||||
Components:
|
||||
ruview <command> operator harness (@ruvnet/ruview) — devices, radar, firmware, training, doctor
|
||||
ruview homecore <command> Homecore developer metaharness (ruview homecore --help)
|
||||
ruview kernel <subcommand> vitals kernel: ${KERNEL_COMMANDS.join('|')} (ruview kernel --help)
|
||||
ruview mcp start [--http] one MCP server: harness + Homecore tools, ChatGPT/MCP Apps console
|
||||
ruview capabilities list every component, version and tool
|
||||
`;
|
||||
|
||||
async function mcp(rest) {
|
||||
const { unifiedHandler } = await import('../src/mcp.js');
|
||||
const flags = new Set(rest);
|
||||
if (rest[0] !== undefined && rest[0] !== 'start') { console.error('Usage: ruview mcp start [--http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]]'); return 2; }
|
||||
if (!flags.has('--http')) {
|
||||
const { startMcpServer } = await import('@ruvnet/ruview/mcp');
|
||||
startMcpServer({ handler: unifiedHandler, label: 'harness + homecore tools' });
|
||||
return new Promise(() => {});
|
||||
}
|
||||
// Reuse the harness CLI's HTTP startup (flags, banner, token handling), with the unified handler.
|
||||
const { run } = await import('@ruvnet/ruview/cli');
|
||||
return run(['mcp', ...rest], { mcpHandler: unifiedHandler });
|
||||
}
|
||||
|
||||
export async function run(args) {
|
||||
const [cmd, ...rest] = args;
|
||||
await wireKernel();
|
||||
if (cmd === 'homecore') {
|
||||
const { run: homecore } = await import('homecore/cli');
|
||||
return homecore(rest);
|
||||
}
|
||||
if (cmd === 'kernel' && (KERNEL_COMMANDS.includes(rest[0]) || rest[0] === '--help' || rest[0] === '-h')) {
|
||||
const { run: kernel } = await import('@ruvnet/ruview-kernel/cli');
|
||||
return kernel(rest);
|
||||
}
|
||||
if (cmd === 'mcp') return mcp(rest);
|
||||
if (cmd === 'capabilities') {
|
||||
const res = await capabilities();
|
||||
console.log(JSON.stringify(res, null, 2));
|
||||
return 0;
|
||||
}
|
||||
if (cmd === '--version' || cmd === '-v') {
|
||||
const versions = await componentVersions();
|
||||
console.log(`ruview ${VERSION}`);
|
||||
for (const [name, v] of Object.entries(versions)) console.log(` ${name} ${v ?? 'not installed'}`);
|
||||
return 0;
|
||||
}
|
||||
const { run: harness } = await import('@ruvnet/ruview/cli');
|
||||
if (cmd === '--help' || cmd === '-h') { console.log(HELP_HEADER); return harness(['--help']); }
|
||||
return harness(args);
|
||||
}
|
||||
|
||||
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); });
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"name": "ruview",
|
||||
"version": "0.9.1",
|
||||
"description": "RuView in one install: the WiFi-sensing operator harness (ESP32/Realtek CSI, 60/24 GHz radar incl. ESPHome kits, LiDAR, flashing, training, doctor, MCP + ChatGPT/MCP Apps console), the Homecore developer metaharness, and the WASM vitals kernel. One CLI and one MCP server.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"ruview": "bin/ruview.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.js",
|
||||
"./mcp": "./src/mcp.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"src/",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ruvnet/ruview": "0.9.1",
|
||||
"@ruvnet/ruview-kernel": "0.1.0",
|
||||
"homecore": "0.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"ruview",
|
||||
"wifi-sensing",
|
||||
"csi",
|
||||
"esp32",
|
||||
"realtek",
|
||||
"mmwave",
|
||||
"esphome",
|
||||
"mcp",
|
||||
"mcp-apps",
|
||||
"chatgpt",
|
||||
"homecore",
|
||||
"wasm"
|
||||
],
|
||||
"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-umbrella"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/ruvnet/RuView/issues"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// `ruview` umbrella (ADR-376): one install for the RuView operator harness
|
||||
// (@ruvnet/ruview), the Homecore developer metaharness (homecore) and the WASM
|
||||
// vitals kernel (@ruvnet/ruview-kernel). Components load lazily so `--help`
|
||||
// and the harness commands never pay for Homecore or the kernel.
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
export const VERSION = PKG.version;
|
||||
|
||||
/**
|
||||
* Resolve the kernel from this package's dependency tree for every harness
|
||||
* code path (esp32 --analyze, kernel self-test, doctor), including linked and
|
||||
* pnpm-style installs where the harness cannot see its sibling.
|
||||
*/
|
||||
export async function wireKernel() {
|
||||
const { setKernelImporter } = await import('@ruvnet/ruview/kernel');
|
||||
setKernelImporter((specifier) => import(specifier));
|
||||
}
|
||||
|
||||
/** Kernel CLI subcommands; `ruview kernel` with none of these stays the harness self-test. */
|
||||
export const KERNEL_COMMANDS = Object.freeze(['doctor', 'info', 'selftest', 'parity', 'synth', 'analyze', 'bench']);
|
||||
|
||||
export const COMPONENTS = Object.freeze([
|
||||
{ name: '@ruvnet/ruview', role: 'operator harness: devices, firmware, training, doctor, MCP, ChatGPT/MCP Apps console' },
|
||||
{ name: 'homecore', role: 'Homecore developer metaharness: source-cited guidance, WASM kernel status, verification' },
|
||||
{ name: '@ruvnet/ruview-kernel', role: 'portable vitals kernel (WASM, optional napi) used by `esp32 --analyze`' },
|
||||
]);
|
||||
|
||||
/** Installed component versions, read from each package's own package.json. */
|
||||
export async function componentVersions(importer = (s) => import(s, { with: { type: 'json' } })) {
|
||||
const out = {};
|
||||
for (const c of COMPONENTS) {
|
||||
try {
|
||||
const url = import.meta.resolve(c.name === '@ruvnet/ruview-kernel' ? `${c.name}/package.json` : c.name);
|
||||
const pkgUrl = c.name === '@ruvnet/ruview-kernel' ? url : new URL('../package.json', url).href;
|
||||
out[c.name] = (await importer(pkgUrl)).default.version;
|
||||
} catch {
|
||||
out[c.name] = null;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Everything this install can do, grouped by component (used by `ruview capabilities`). */
|
||||
export async function capabilities() {
|
||||
const [{ listTools: ruviewTools }, homecore, versions] = await Promise.all([
|
||||
import('@ruvnet/ruview'),
|
||||
import('homecore').catch(() => null),
|
||||
componentVersions(),
|
||||
]);
|
||||
return {
|
||||
ok: true,
|
||||
package: 'ruview',
|
||||
version: VERSION,
|
||||
components: COMPONENTS.map((c) => ({ ...c, version: versions[c.name] })),
|
||||
tools: {
|
||||
harness: ruviewTools().map((t) => t.name),
|
||||
homecore: homecore ? homecore.listTools().map((t) => t.name) : [],
|
||||
kernelCli: [...KERNEL_COMMANDS],
|
||||
},
|
||||
entryPoints: {
|
||||
cli: 'ruview <command> (ruview --help)',
|
||||
homecore: 'ruview homecore <command>',
|
||||
kernel: `ruview kernel ${KERNEL_COMMANDS.join('|')}`,
|
||||
mcpStdio: 'ruview mcp start',
|
||||
mcpHttp: 'ruview mcp start --http (ChatGPT / remote MCP; token: RUVIEW_MCP_TOKEN)',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Unified MCP handler (ADR-376): the @ruvnet/ruview tool registry plus the
|
||||
// Homecore tools in one server. Homecore tool names are already prefixed
|
||||
// (`homecore_*`), so there is no collision. Everything that is not a Homecore
|
||||
// tool call — handshake, resources (the ui:// console), ruview tools, policy —
|
||||
// is answered by the harness handler unchanged.
|
||||
|
||||
import { handleRpc, toCallToolResult } from '@ruvnet/ruview/mcp';
|
||||
import { VERSION, wireKernel } from './index.js';
|
||||
|
||||
await wireKernel();
|
||||
|
||||
let homecorePromise = null;
|
||||
const loadHomecore = () => (homecorePromise ??= import('homecore').catch(() => null));
|
||||
|
||||
/** Merge a Homecore tool into the MCP tool shape (no UI metadata). */
|
||||
function homecoreTool(t) {
|
||||
return { name: t.name, ...(t.title ? { title: t.title } : {}), description: t.description, inputSchema: t.inputSchema, annotations: t.annotations };
|
||||
}
|
||||
|
||||
export async function unifiedHandler(msg, context = {}) {
|
||||
const homecore = await loadHomecore();
|
||||
if (msg && msg.jsonrpc === '2.0' && msg.method === 'tools/call' && homecore && typeof msg.params?.name === 'string' && msg.params.name.startsWith('homecore_')) {
|
||||
const out = await homecore.runTool(msg.params.name, msg.params.arguments || {}, { ...context, source: 'mcp' });
|
||||
return { jsonrpc: '2.0', id: msg.id, result: toCallToolResult(msg.params.name, out) };
|
||||
}
|
||||
const reply = await handleRpc(msg, context);
|
||||
if (!reply || !reply.result) return reply;
|
||||
if (msg.method === 'initialize') {
|
||||
reply.result.serverInfo = { ...reply.result.serverInfo, name: 'ruview', version: VERSION };
|
||||
reply.result.instructions += ' Homecore developer tools (homecore_*) are included: source-cited guidance, WASM kernel status and reviewed memory.';
|
||||
}
|
||||
if (msg.method === 'tools/list' && homecore) {
|
||||
reply.result.tools = [...reply.result.tools, ...homecore.listTools({ source: 'mcp' }).map(homecoreTool)];
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// ADR-376: the `ruview` umbrella — CLI dispatch to every component and one
|
||||
// MCP server (harness + Homecore tools), over stdio and HTTP.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { unifiedHandler } from '../src/mcp.js';
|
||||
import { capabilities, componentVersions, KERNEL_COMMANDS, VERSION } from '../src/index.js';
|
||||
|
||||
const BIN = fileURLToPath(new URL('../bin/ruview.js', import.meta.url));
|
||||
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
||||
const cli = (args, env = {}) => spawnSync(process.execPath, [BIN, ...args], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: 120000 });
|
||||
const rpc = (method, params, id = 1) => unifiedHandler({ jsonrpc: '2.0', id, method, params });
|
||||
|
||||
test('version and capabilities cover every component', async () => {
|
||||
assert.equal(VERSION, PKG.version);
|
||||
const versions = await componentVersions();
|
||||
for (const name of Object.keys(PKG.dependencies)) assert.ok(versions[name], `${name} resolves`);
|
||||
const caps = await capabilities();
|
||||
assert.ok(caps.tools.harness.includes('ruview_esp32_capture'));
|
||||
assert.ok(caps.tools.harness.includes('ruview_mmwave_read'));
|
||||
assert.ok(caps.tools.homecore.includes('homecore_guidance'));
|
||||
assert.deepEqual(caps.tools.kernelCli, [...KERNEL_COMMANDS]);
|
||||
const v = cli(['--version']);
|
||||
assert.equal(v.status, 0);
|
||||
assert.match(v.stdout, new RegExp(`^ruview ${VERSION.replace(/\./g, '\\.')}`));
|
||||
assert.match(v.stdout, /homecore \d+\.\d+\.\d+/);
|
||||
});
|
||||
|
||||
test('CLI dispatches to the harness, Homecore and the kernel', () => {
|
||||
const help = cli(['--help']);
|
||||
assert.equal(help.status, 0);
|
||||
assert.match(help.stdout, /RuView in one install/);
|
||||
assert.match(help.stdout, /esp32 \[--udp-port 5005\]/, 'harness help follows');
|
||||
|
||||
const claim = cli(['claim-check', '--text', 'presence accuracy 0.9', '--json']);
|
||||
assert.equal(JSON.parse(claim.stdout).name ?? 'ruview_claim_check', 'ruview_claim_check');
|
||||
|
||||
const hc = cli(['homecore', 'guidance', '--topic', 'plugins', '--query', 'Wasmtime', '--limit', '1']);
|
||||
assert.equal(hc.status, 0, hc.stderr);
|
||||
assert.match(hc.stdout, /wasm-plugins/);
|
||||
|
||||
const kernel = cli(['kernel', 'info']);
|
||||
assert.equal(kernel.status, 0, kernel.stderr);
|
||||
assert.match(kernel.stdout, /"abi"|"version"/);
|
||||
const selftest = cli(['kernel', '--backend', 'wasm', '--json']);
|
||||
assert.ok([0, 1].includes(selftest.status), 'plain `kernel` stays the harness self-test');
|
||||
assert.match(selftest.stdout, /SYNTHETIC/);
|
||||
});
|
||||
|
||||
test('one MCP server lists harness + Homecore tools and routes calls', async () => {
|
||||
const init = (await rpc('initialize', { protocolVersion: '2025-06-18' })).result;
|
||||
assert.deepEqual([init.serverInfo.name, init.serverInfo.version], ['ruview', VERSION]);
|
||||
assert.match(init.instructions, /homecore_/);
|
||||
const tools = (await rpc('tools/list')).result.tools;
|
||||
const names = tools.map((t) => t.name);
|
||||
assert.ok(names.includes('ruview_esp32_capture') && names.includes('homecore_guidance'));
|
||||
assert.equal(new Set(names).size, names.length, 'no duplicate tool names');
|
||||
for (const t of tools) assert.match(t.name, /^[a-zA-Z0-9_-]{1,64}$/);
|
||||
assert.ok(tools.find((t) => t.name === 'ruview_esp32_capture')._meta.ui.resourceUri.startsWith('ui://'));
|
||||
|
||||
const hc = (await rpc('tools/call', { name: 'homecore_guidance', arguments: { topic: 'plugins', query: 'Wasmtime', limit: 1 } }, 2)).result;
|
||||
assert.equal(hc._meta['ruview/tool'], 'homecore_guidance');
|
||||
assert.equal(typeof hc.structuredContent, 'object');
|
||||
const rv = (await rpc('tools/call', { name: 'ruview_claim_check', arguments: { text: 'x MEASURED' } }, 3)).result;
|
||||
assert.equal(rv._meta['ruview/tool'], 'ruview_claim_check');
|
||||
const denied = (await unifiedHandler({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'ruview_devices_scan', arguments: {} } }, { source: 'mcp', grants: [] })).result;
|
||||
assert.equal(denied.structuredContent.reason, 'authority_denied', 'harness authority model is preserved');
|
||||
const res = (await rpc('resources/read', { uri: 'ui://ruview/console-v1.html' }, 5)).result;
|
||||
assert.match(res.contents[0].text, /RuView console/);
|
||||
});
|
||||
|
||||
test('stdio and HTTP transports serve the unified tool set', async (t) => {
|
||||
const child = spawn(process.execPath, [BIN, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
t.after(() => child.kill());
|
||||
const lines = [];
|
||||
child.stdout.on('data', (d) => lines.push(...String(d).trim().split('\n')));
|
||||
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })}\n`);
|
||||
for (let i = 0; i < 100 && !lines.length; i++) await new Promise((r) => setTimeout(r, 50));
|
||||
const stdioTools = JSON.parse(lines[0]).result.tools.map((x) => x.name);
|
||||
assert.ok(stdioTools.includes('homecore_guidance') && stdioTools.includes('ruview_mmwave_read'));
|
||||
|
||||
const token = 'umbrella-test-token-0123456789';
|
||||
const port = 20000 + Math.floor(Math.random() * 20000);
|
||||
const http = spawn(process.execPath, [BIN, 'mcp', 'start', '--http', '--port', String(port)], { env: { ...process.env, RUVIEW_MCP_TOKEN: token }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => http.kill());
|
||||
let ok = false;
|
||||
for (let i = 0; i < 100 && !ok; i++) {
|
||||
try { ok = (await fetch(`http://127.0.0.1:${port}/healthz`)).ok; } catch { await new Promise((r) => setTimeout(r, 100)); }
|
||||
}
|
||||
assert.ok(ok, 'HTTP transport started');
|
||||
const r = await fetch(`http://127.0.0.1:${port}/mcp/${token}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) });
|
||||
const httpTools = (await r.json()).result.tools.map((x) => x.name);
|
||||
assert.deepEqual(httpTools.sort(), [...stdioTools].sort());
|
||||
assert.equal((await fetch(`http://127.0.0.1:${port}/mcp`, { method: 'POST', body: '{}' })).status, 401);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: device-access
|
||||
description: Reach RuView sensing hardware from the machine it is attached to — ESP32 CSI nodes (serial + UDP stream), 60 GHz MR60BHA2 / 24 GHz LD2410 mmWave radars, RPLIDAR and iPhone LiDAR — and from other machines over SSH, read-only.
|
||||
---
|
||||
|
||||
# device-access
|
||||
|
||||
Hardware tools must run **on the host the device is plugged into**. A cloud
|
||||
agent cannot see your USB ports. Run `npx @ruvnet/ruview` locally, start a
|
||||
local Claude Code session there (`claude remote-control` in the repo if you
|
||||
want to drive it from the Claude app), or add the machine as an SSH host.
|
||||
|
||||
## 1. Find devices
|
||||
|
||||
```
|
||||
npx @ruvnet/ruview doctor --group python,serial,devices,remote
|
||||
npx @ruvnet/ruview devices # USB VID:PID → likely ESP32 / mmWave / RPLIDAR + confirm command
|
||||
```
|
||||
|
||||
## 2. Read each modality
|
||||
|
||||
| Device | Command | Confirms |
|
||||
|---|---|---|
|
||||
| ESP32 node (USB) | `ruview doctor --port <p> --probe`, `ruview monitor --port <p>` | chip id; CSI callbacks in the serial log (the monitor never resets the node; C6/S3 USB-Serial/JTAG builds log on the native USB port) |
|
||||
| ESP32 / Realtek node (network) | `ruview esp32 --seconds 10 [--udp-port 5005]` | per-node CSI rate, sequence loss, RSSI, CSI shapes, device vitals; Realtek RAC1 CSI and heartbeat-only senders |
|
||||
| Realtek RTL8721Dx (USB, PL2303) | `ruview monitor --port <p> --baud 1500000` | `RUVIEW_CSI: frame #` lines in the Ameba log |
|
||||
| Live CSI → kernel | `ruview esp32 --seconds 45 --analyze [--node-id N] [--backend napi]` | kernel vitals summary at the measured frame rate (no reference measurement) |
|
||||
| 60 GHz MR60BHA2 | `ruview mmwave --port <p> --model mr60bha2` | valid frames, checksum errors, presence/distance/device HR+BR |
|
||||
| 24 GHz LD2410 | `ruview mmwave --port <p> --model ld2410` | target state and distances |
|
||||
| RPLIDAR | `ruview lidar --source rplidar --port <p> [--baud 115200]` | points, revolutions, range, angular coverage |
|
||||
| iPhone LiDAR | `export RUVIEW_LIDAR_TOKEN=…; ruview lidar --source iphone --url ws://HOST:8787/ws/lidar` | depth frame rate, confident fraction, median depth |
|
||||
|
||||
The UDP capture cannot share port 5005 with a running sensing server; stop
|
||||
the server or have the nodes target another port.
|
||||
|
||||
## 3. Other machines (SSH, read-only)
|
||||
|
||||
```
|
||||
npx @ruvnet/ruview hosts add --name lab-pi --ssh ruv@lab-pi.local
|
||||
ssh ruv@lab-pi.local true # pin the host key once
|
||||
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_devices_scan
|
||||
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_mmwave_read --args-json '{"port":"/dev/ttyUSB0"}'
|
||||
```
|
||||
|
||||
Remote hosts run a pinned `@ruvnet/ruview` and accept only read-only tools.
|
||||
Flash, train, and calibrate on the host itself.
|
||||
|
||||
## Evidence
|
||||
|
||||
Device readouts are `MEASURED` *readings*, not accuracy results. mmWave
|
||||
heart/breathing values are the radar's own estimates. Hardware validation
|
||||
still means a captured log or stream from real silicon, recorded with the
|
||||
command that produced it.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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.
|
||||
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, cross-platform checksum-verified esptool flashing, NVS/WiFi/channel/MAC-filter overrides, boot-log evidence.
|
||||
---
|
||||
|
||||
# provision-node
|
||||
@@ -19,17 +19,23 @@ Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56
|
||||
|
||||
## 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:
|
||||
Use a release flash bundle (or `release_bins/<variant>`, or an ESP-IDF `build/`).
|
||||
The harness flashes on Windows, macOS, and Linux through `python -m esptool`:
|
||||
|
||||
```
|
||||
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
|
||||
npx @ruvnet/ruview doctor --group python,serial,firmware # esptool, pyserial, ports, bundle checksums
|
||||
npx @ruvnet/ruview flash-plan --port <PORT> --bundle <dir> --variant s3-8mb
|
||||
npx @ruvnet/ruview flash --port <PORT> --bundle <dir> --variant s3-8mb --confirm
|
||||
```
|
||||
|
||||
(`ruview_node_flash` returns the exact pinned command rather than running an
|
||||
unattended flash.)
|
||||
`flash` verifies SHA256SUMS, refuses a chip that does not match the variant
|
||||
(`esptool chip_id`), writes 0x0 / 0x8000 / 0xf000 / 0x20000 (NVS preserved),
|
||||
then captures a boot log. `hardwareValidated: true` requires CSI callbacks in
|
||||
that log. Over MCP, `ruview_node_flash` needs the `hardware-write` grant and
|
||||
`confirm: true`.
|
||||
|
||||
Building from source still uses ESP-IDF v5.4 (Docker or the Windows subprocess
|
||||
flow; Git Bash/MSYS is unsupported, so strip `MSYSTEM*` env vars).
|
||||
|
||||
## 3. Provision
|
||||
|
||||
|
||||
@@ -24,6 +24,17 @@ or temporal leakage. Example honest result (ADR-181):
|
||||
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
|
||||
(honest about what's executing).
|
||||
|
||||
## Run it through the harness (ADR-371)
|
||||
|
||||
```
|
||||
npx @ruvnet/ruview train-plan --mode pose-smoke # command, cwd, outputs; runs nothing
|
||||
npx @ruvnet/ruview train --mode pose-smoke --confirm # SYNTHETIC pipeline smoke (libtorch 2.11 for tch 0.24)
|
||||
npx @ruvnet/ruview train --mode pose --data-dir <in-repo MM-Fi dir> --confirm
|
||||
npx @ruvnet/ruview train-gate --file eval-report.json # mean-pose baseline + leakage gate
|
||||
```
|
||||
|
||||
The gate returns the only acceptable claim sentence. Quote nothing it fails.
|
||||
|
||||
## Before you publish a number
|
||||
|
||||
1. Run the mean-pose baseline on the same split.
|
||||
|
||||
@@ -8,27 +8,64 @@
|
||||
"ruview_verify",
|
||||
"ruview_node_monitor",
|
||||
"ruview_guidance",
|
||||
"ruview_memory_search"
|
||||
"ruview_memory_search",
|
||||
"ruview_kernel_selftest",
|
||||
"ruview_doctor",
|
||||
"ruview_firmware_plan",
|
||||
"ruview_firmware_ports",
|
||||
"ruview_train_plan",
|
||||
"ruview_train_gate",
|
||||
"ruview_host_list"
|
||||
],
|
||||
"grants": {
|
||||
"credential-use": {
|
||||
"tools": ["ruview_spaces_list"],
|
||||
"tools": [
|
||||
"ruview_spaces_list"
|
||||
],
|
||||
"requiresConfirmation": false,
|
||||
"notes": "Allows a tenant-scoped external read; OAuth refresh may rotate the local credential file."
|
||||
},
|
||||
"workspace-write": {
|
||||
"tools": ["ruview_calibrate"],
|
||||
"tools": [
|
||||
"ruview_calibrate",
|
||||
"ruview_train"
|
||||
],
|
||||
"requiresConfirmation": true
|
||||
},
|
||||
"hardware-write": {
|
||||
"tools": ["ruview_node_flash"],
|
||||
"tools": [
|
||||
"ruview_node_flash"
|
||||
],
|
||||
"requiresConfirmation": true
|
||||
},
|
||||
"device-access": {
|
||||
"tools": [
|
||||
"ruview_devices_scan",
|
||||
"ruview_esp32_capture",
|
||||
"ruview_mmwave_read",
|
||||
"ruview_lidar_read"
|
||||
],
|
||||
"requiresConfirmation": false,
|
||||
"notes": "Read-only access to serial devices and the local ESP32 UDP stream; RPLIDAR scans spin the motor but write no configuration."
|
||||
},
|
||||
"remote-host": {
|
||||
"tools": [
|
||||
"ruview_host_run"
|
||||
],
|
||||
"requiresConfirmation": false,
|
||||
"notes": "Runs one read-only tool on an operator-configured SSH host with pinned host keys; the remote tool's own grant is also required."
|
||||
}
|
||||
},
|
||||
"agentHosts": {
|
||||
"defaultMode": "read-only",
|
||||
"writeRequires": ["allow-write", "confirm"],
|
||||
"forbiddenFlags": ["dangerously-skip-permissions", "dangerously-bypass-approvals-and-sandbox"]
|
||||
"writeRequires": [
|
||||
"allow-write",
|
||||
"confirm"
|
||||
],
|
||||
"forbiddenFlags": [
|
||||
"dangerously-skip-permissions",
|
||||
"dangerously-bypass-approvals-and-sandbox"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"generator": "RuView metaharness provenance v2",
|
||||
"template": "vertical:ruview",
|
||||
"name": "@ruvnet/ruview",
|
||||
"version": "0.5.1",
|
||||
"version": "0.9.1",
|
||||
"hosts": [
|
||||
"claude-code",
|
||||
"codex"
|
||||
@@ -13,17 +13,18 @@
|
||||
".claude/settings.json": "57d03e8995363bd120fb6d515702967afd0bd557797051301ff8f8156c845824",
|
||||
".claude/skills/calibrate-room/SKILL.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
|
||||
".claude/skills/cognitum-spaces/SKILL.md": "96ae42cc72ad31dbb2f34d59e874c4d15f2e55fc969cd1f610dc1b9a4138840e",
|
||||
".claude/skills/device-access/SKILL.md": "cb6aef6bfe7283df0c2dfd08a24e856ad023cae76b1415118784b31505c63aef",
|
||||
".claude/skills/onboard/SKILL.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
|
||||
".claude/skills/provision-node/SKILL.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
|
||||
".claude/skills/train-pose/SKILL.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
|
||||
".claude/skills/provision-node/SKILL.md": "13dd6c35eac67b79116e370412b77df3509458da2c6f5d7a5938c2d008c5ed73",
|
||||
".claude/skills/train-pose/SKILL.md": "8cb132e468392497377828f7974c744e4c877134ed9a87f960f77fa1221540ba",
|
||||
".claude/skills/verify/SKILL.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
|
||||
".harness/claims.json": "9544cee8012328eb26856a9fff38d80a73f09e48a2da7537f6c3695521b0fd54",
|
||||
".harness/mcp-policy.json": "749e9f24bde85921a45b91bf6fa4ab5605675af769c04c53fe69129019662d3e",
|
||||
".harness/claims.json": "b0fef8d5bb0f747f9c1c3ed952f9bf1bce0ae2a861ba93efb79fbd74500e2d21",
|
||||
".harness/mcp-policy.json": "7ea639f763f31ead88d113368dd11aabe9c48a69de3314e0a337e472ac1f28e0",
|
||||
".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f",
|
||||
"CLAUDE.md": "46d5514f4cbf4d94f683f76aa6d50a3dce2ec5a95fd87b9154ed4752f5ea0e16",
|
||||
"CLAUDE.md": "25f9306b6caae22078cb9375108b6f6bb813d83e617739f6d9372f116b0ede5d",
|
||||
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
|
||||
"README.md": "ce716f07b4b93d5b86285a46cc7be1c6ff48d95ee12ac73518dbf2fb7b61d82e",
|
||||
"bin/cli.js": "0c96bf65a189732a35760c88a3d441a5bd6ce53abbd3bfaa73141665825e1be1",
|
||||
"README.md": "a0ee4d09ecef1b125c9d96a6ec8cdfce869bb727c986e650bb7941b21d23ddce",
|
||||
"bin/cli.js": "8ebf6ca4b6d9529a5e338374c19329c1a2fc2f9e52f13b4341928b7168ee461b",
|
||||
"brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
|
||||
"flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572",
|
||||
"flywheel/fixture.mjs": "de71be88753d0da4695d91011b54380c994a018986fafba36cb13739307a9bce",
|
||||
@@ -31,31 +32,50 @@
|
||||
"flywheel/genome.json": "75db44a3cab70d9459fc8c07863f640ac1214bfaa243483939e1506d63f51214",
|
||||
"flywheel/replay.mjs": "0670ca0b03701f4afe0b4bca8a3d58d481676b61a94a5b98c6a425aefb1159ab",
|
||||
"flywheel/run.mjs": "6d4f97db16900c45367b6538848cbe1915af999e663720dfc51f2bb1698f1cd0",
|
||||
"package.json": "5d29ef238f310c9ee5c57501ab651acc0f856f831b696ada187de71e4b5935a6",
|
||||
"package.json": "01627b1b2c804c6dddfea1b366058c23c0e99111829dcd3e32967b76d0bce997",
|
||||
"scripts/sync-skills.mjs": "43715dab61e204dc91bbd61755810e8fdb2f66e2b0c0bd791b4bf48a2e293565",
|
||||
"scripts/update-manifest.mjs": "8f56764b8f70aed55da0c7e2417ae875b0d58d781d839b6db7f115f08af61e6b",
|
||||
"scripts/verify-manifest.mjs": "6491a221762efcfeb3e749ecab243b204f17fd5bc871f3d4025597f31b8f0f10",
|
||||
"skills/calibrate-room.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
|
||||
"skills/cognitum-spaces.md": "96ae42cc72ad31dbb2f34d59e874c4d15f2e55fc969cd1f610dc1b9a4138840e",
|
||||
"skills/device-access.md": "cb6aef6bfe7283df0c2dfd08a24e856ad023cae76b1415118784b31505c63aef",
|
||||
"skills/onboard.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
|
||||
"skills/provision-node.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
|
||||
"skills/train-pose.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
|
||||
"skills/provision-node.md": "13dd6c35eac67b79116e370412b77df3509458da2c6f5d7a5938c2d008c5ed73",
|
||||
"skills/train-pose.md": "8cb132e468392497377828f7974c744e4c877134ed9a87f960f77fa1221540ba",
|
||||
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
|
||||
"src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6",
|
||||
"src/cli-ui.js": "507fbd4444c6639c69354790ed3aed2addcb826929a6b3ad7ddb752f058c1f2f",
|
||||
"src/cli.js": "5e8467f0d0fb90d81347715143b3777e239165d707dd9d8c5d647294509a7fc3",
|
||||
"src/devices/esp32.js": "4d7ff3d63d751222756e227a98de38c70a707e8be2a431d1b3ac604622f94225",
|
||||
"src/devices/esphome.js": "83b0fbd8c9075e233a401fa9f9dad04fc911e04819e7a0c48eb32d7e35d226af",
|
||||
"src/devices/lidar.js": "d753d88375358ced5174376753c276b5ca621f38ae48eac58dbb307b9004b629",
|
||||
"src/devices/mmwave.js": "09d828e3b3e140926194ab83bf27e7c63cbda1cf438385c82c0e89b9d8e4b526",
|
||||
"src/devices/registry.js": "26725e43adaa908b41462f492c5f6bf63e9193f3ee076e3d64ebaa012b66f6be",
|
||||
"src/devices/serial-pump.js": "bb1351cd129fd00aa71a5d06104efcff5c0a56f3507e39e1df05395fe7fe7844",
|
||||
"src/doctor.js": "f139c2446b469afe6f5b507685836c65ee318c8f16b1ef29819ba828bbf7828f",
|
||||
"src/exec.js": "8889d13b8a8277f322856e743c79ac8f29b707251eaf9757c46d3f745743ae22",
|
||||
"src/firmware.js": "1af024e8e1cd75d43f9e8ef6ff91d0413cf5f4cd84c368d1407e690b051cac4d",
|
||||
"src/guardrails.js": "aacc8fa6088f7f1ccea3a0b02171a5c516b95d3416ee3ba87add3879a1d6aaad",
|
||||
"src/guidance.js": "583904c854eb17e98cb7d959330989c01990a71cff091515777aba8f345de1bf",
|
||||
"src/guidance.js": "1e0ccee808a736319b3f8257a3fc4fc0f8d9f8956970887d597870bb8d615949",
|
||||
"src/hosts/claude-code.js": "2212bc39b49822018800dfe33a471e56bbb4c5233d716bfa7aa4fff77aa23edb",
|
||||
"src/hosts/codex.js": "d41ecd132ce2db7b47aad9cebbc020d70e6810d48c3554858d099ff2e8f6608b",
|
||||
"src/hosts/index.js": "ab276c41ab722bcdf72c2d1649cecbb760ae05c41c1372aae4c2447aa7c11539",
|
||||
"src/mcp-server.js": "8b2ee4b939b25c1b1f507b295a43a2ebad852b8bac9d31af9bf7fb39b181c12e",
|
||||
"src/policy.js": "169cc33793b91ee01a78e6403aeefff1ab5e92f33b73eb85912fe03666464975",
|
||||
"src/kernel.js": "48dfd218a747dd158f82ae108e7e8ed89a82cd68649214d23266112dc9033f5d",
|
||||
"src/mcp-http.js": "6eb36fa546f333e32ec6283e711d2c93914d4de48a6a3b47fce963c5ec711f5d",
|
||||
"src/mcp-server.js": "5eb8c3431768932c3a85476df032d0608fc7a732ede3d6bdcc8467ca47552666",
|
||||
"src/policy.js": "baad5eb8c3eb2e416e0a71b342ad850d7c8413d7d976571ebf596937f69da8e2",
|
||||
"src/process-runner.js": "49533b038044dfb8bc76ed01c030d06a9856ead0836157fb693e2a7d40f786d6",
|
||||
"src/redact.js": "ebf1afff46341078706b0401838c53db043603586e280d51ece5cf1feba35189",
|
||||
"src/remote.js": "ca510804176205eca799b396c0f0bca72df4c08139547c66483885d38fed6815",
|
||||
"src/repo-trust.js": "06e2a94d7113ed936f208a12b7fcc785801c215a3e2c5e7418f6238d991a289c",
|
||||
"src/sdk.d.ts": "82e73721de433551a2b4339821ebf7b2c9d44133c766498ae91d15483ed8a073",
|
||||
"src/sdk.js": "a07b5e5777ee897a50c12f6002065c3ca781e087d625541d54e5f5308ede2116",
|
||||
"src/spaces.js": "45ef786537cb2a446db5e926e5a1c10b73639d2767dec84611f914f78d4325eb",
|
||||
"src/tools.js": "55960c9a677661763e0317fd54ccc787c2edb39c87371c7fbc40cd55f0761c04"
|
||||
"src/tools.js": "7c41347b4be063457d7759ddb8f1f5dd608cbe28baa1c80e4a27d6567af6bc98",
|
||||
"src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956",
|
||||
"src/ui/console-widget.js": "0d2f707d57b5ec54968dee1dc80f31c7db9152dcadfc48700c18379dabb55462"
|
||||
},
|
||||
"filesDigest": "28a3bbd9bbcf966df9fae8ec6ea5be3441f6ab535c1636bb1ce33f67b827d423",
|
||||
"filesDigest": "60935843295b67f3354b4b02cc31df62bac8b5093568b11f896dfc7fc3a91545",
|
||||
"brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
|
||||
"gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd",
|
||||
"developmentPins": {
|
||||
|
||||
@@ -1 +1 @@
|
||||
478ccaff9aa249bc7ea6e20551ccc9ac88697a3cc91a7b55400337c5e939a19e manifest.json
|
||||
0f73608c5acd0547ef850c0944ef76633a40e957c226ce555309832f87491a1c manifest.json
|
||||
@@ -12,13 +12,43 @@
|
||||
"ruview_verify",
|
||||
"ruview_node_monitor",
|
||||
"ruview_guidance",
|
||||
"ruview_memory_search"
|
||||
"ruview_memory_search",
|
||||
"ruview_kernel_selftest",
|
||||
"ruview_doctor",
|
||||
"ruview_firmware_plan",
|
||||
"ruview_firmware_ports",
|
||||
"ruview_train_plan",
|
||||
"ruview_train_gate",
|
||||
"ruview_host_list"
|
||||
],
|
||||
"guardedReadTools": {
|
||||
"ruview_spaces_list": {
|
||||
"grant": "credential-use",
|
||||
"network": true,
|
||||
"mayRefreshStoredCredential": true
|
||||
},
|
||||
"ruview_devices_scan": {
|
||||
"grant": "device-access",
|
||||
"hardware": true
|
||||
},
|
||||
"ruview_esp32_capture": {
|
||||
"grant": "device-access",
|
||||
"hardware": true
|
||||
},
|
||||
"ruview_mmwave_read": {
|
||||
"grant": "device-access",
|
||||
"hardware": true,
|
||||
"network": true
|
||||
},
|
||||
"ruview_lidar_read": {
|
||||
"grant": "device-access",
|
||||
"hardware": true,
|
||||
"network": true
|
||||
},
|
||||
"ruview_host_run": {
|
||||
"grant": "remote-host",
|
||||
"network": true,
|
||||
"readOnlyRemoteTools": true
|
||||
}
|
||||
},
|
||||
"dangerousTools": {
|
||||
@@ -29,6 +59,10 @@
|
||||
"ruview_node_flash": {
|
||||
"grant": "hardware-write",
|
||||
"confirm": true
|
||||
},
|
||||
"ruview_train": {
|
||||
"grant": "workspace-write",
|
||||
"confirm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,13 @@ accuracy number:
|
||||
|
||||
`ruview_onboard`, `ruview_claim_check`, `ruview_verify`, `ruview_node_monitor`,
|
||||
`ruview_calibrate`, `ruview_node_flash`, `ruview_guidance`,
|
||||
`ruview_spaces_list`, `ruview_memory_search`. Start unfamiliar work with
|
||||
`ruview_spaces_list`, `ruview_memory_search`, `ruview_kernel_selftest`,
|
||||
`ruview_doctor`, `ruview_firmware_plan`, `ruview_firmware_ports`,
|
||||
`ruview_train`, `ruview_train_plan`, `ruview_train_gate`,
|
||||
`ruview_devices_scan`, `ruview_esp32_capture`, `ruview_mmwave_read`,
|
||||
`ruview_lidar_read`, `ruview_host_list`, `ruview_host_run`. Device tools need
|
||||
the `device-access` MCP grant; remote hosts need `remote-host`. Start
|
||||
unfamiliar work with
|
||||
`ruview_guidance`; its
|
||||
capability status, source paths, validation commands, and limitations are
|
||||
navigation evidence, not authority. All tools fail closed. Mutating/hardware
|
||||
|
||||
+207
-2
@@ -15,7 +15,13 @@ against a baseline — that rule is enforced in code (`ruview_claim_check`).
|
||||
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, adapters, local CLIs)
|
||||
npx @ruvnet/ruview doctor # structured diagnostics with fixes (ADR-372)
|
||||
npx @ruvnet/ruview ports # find the ESP32 serial port
|
||||
npx @ruvnet/ruview flash --port COM7 --bundle ./esp32-csi-node-s3-8mb # plan only
|
||||
npx @ruvnet/ruview train-plan --mode pose-smoke
|
||||
npx @ruvnet/ruview train-gate --file eval-report.json
|
||||
npx @ruvnet/ruview devices # ESP32 / mmWave / LiDAR on this machine
|
||||
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_devices_scan
|
||||
npx @ruvnet/ruview guidance --topic homecore --query "Wasmtime plugins"
|
||||
npx @ruvnet/ruview spaces --resource spaces
|
||||
npx @ruvnet/ruview spaces --resource events --limit 25
|
||||
@@ -38,14 +44,141 @@ Exposed both as CLI verbs and as an MCP server (`npx @ruvnet/ruview mcp start`):
|
||||
| `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) |
|
||||
| `ruview_node_flash` | Cross-platform esptool flash with checksum + chip preflight and boot-log evidence (mutating, guarded) |
|
||||
| `ruview_firmware_plan` | Verify a firmware bundle and return the exact esptool command (read-only) |
|
||||
| `ruview_firmware_ports` | Enumerate serial ports via pyserial (read-only) |
|
||||
| `ruview_doctor` | Structured diagnostics: runtime, integrity, hosts, repo, Rust, Python/esptool, bundles, serial, kernel |
|
||||
| `ruview_train` | Run pose-smoke / pose / room training from the checkout (writes checkpoints, guarded) |
|
||||
| `ruview_train_plan` | Resolve a training command without running it (read-only) |
|
||||
| `ruview_train_gate` | Mean-pose-baseline + leakage gate that returns the only publishable claim wording |
|
||||
| `ruview_devices_scan` / `ruview_esp32_capture` / `ruview_mmwave_read` / `ruview_lidar_read` | Host device access: USB discovery, ESP32 UDP stream, 60/24 GHz mmWave, RPLIDAR/iPhone LiDAR (`device-access` grant) |
|
||||
| `ruview_host_list` / `ruview_host_run` | SSH remote hosts, read-only tools only (`remote-host` grant) |
|
||||
| `ruview_guidance` | Source-cited code map, capability maturity, validation commands, and limitations |
|
||||
| `ruview_spaces_list` | OAuth-only paging for sites/buildings/floors/spaces/zones/entities/events/alerts (guarded over MCP) |
|
||||
| `ruview_memory_search` | Search the reviewed, source-cited contributor brain |
|
||||
| `ruview_kernel_selftest` | SYNTHETIC self-test of the optional `@ruvnet/ruview-kernel` WASM/napi-rs compute package (ADR-368) |
|
||||
|
||||
Every tool is **fail-closed**: missing repo / python / binary / port → an honest
|
||||
negative, never a fabricated success.
|
||||
|
||||
### MCP authority model
|
||||
|
||||
Read-only tools need no grant. Over MCP, `ruview_calibrate` and `ruview_train`
|
||||
need the `workspace-write` grant plus `confirm: true`; `ruview_node_flash`
|
||||
needs `hardware-write` plus `confirm: true`; `ruview_spaces_list` needs
|
||||
`credential-use`. The plan tools (`ruview_firmware_plan`, `ruview_train_plan`)
|
||||
cannot take `confirm`, so a read-only client can review exactly what would run.
|
||||
The MCP doctor cannot probe a board or contact a network host; those are
|
||||
CLI/SDK options (`--probe`, `--url`).
|
||||
|
||||
## Device and host access (ADR-373, ADR-374)
|
||||
|
||||
Run these on the machine the hardware is plugged into. A cloud agent cannot
|
||||
reach your USB ports; use a local session or an SSH host.
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview devices # classify USB serial devices by VID:PID
|
||||
npx @ruvnet/ruview esp32 --seconds 10 # ESP32 / Realtek RAC1 node UDP stream (default :5005)
|
||||
npx @ruvnet/ruview esp32 --seconds 45 --analyze # ...and run live CSI through @ruvnet/ruview-kernel
|
||||
npx @ruvnet/ruview monitor --port COM10 --baud 1500000 # serial console without resetting the node
|
||||
npx @ruvnet/ruview mmwave --port /dev/ttyUSB0 # MR60BHA2 60 GHz / LD2410 24 GHz, auto-detected
|
||||
npx @ruvnet/ruview lidar --source rplidar --port /dev/ttyUSB1 # RPLIDAR scan summary
|
||||
RUVIEW_LIDAR_TOKEN=… npx @ruvnet/ruview lidar --source iphone --url ws://HOST:8787/ws/lidar
|
||||
```
|
||||
|
||||
| Tool | Access | MCP grant |
|
||||
|---|---|---|
|
||||
| `ruview_devices_scan` | pyserial port list + VID:PID roles (opens no port) | `device-access` |
|
||||
| `ruview_esp32_capture` | receive-only UDP; CSI/vitals/feature packets per node, loss, RSSI | `device-access` |
|
||||
| `ruview_mmwave_read` | serial; firmware-identical MR60BHA2/LD2410 parsers | `device-access` |
|
||||
| `ruview_lidar_read` | RPLIDAR SCAN over serial, or iPhone relay WebSocket (stats only) | `device-access` |
|
||||
| `ruview_host_list` | configured SSH hosts | none |
|
||||
| `ruview_host_run` | one read-only tool on an SSH host | `remote-host` + the tool's grant |
|
||||
|
||||
Remote hosts live in `~/.config/ruview/hosts.json` (mode 0600) and are added
|
||||
only from the CLI:
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview hosts add --name lab-pi --ssh ruv@lab-pi.local
|
||||
ssh ruv@lab-pi.local true # pin the host key once; BatchMode + StrictHostKeyChecking are enforced
|
||||
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_esp32_capture --args-json '{"seconds":10}'
|
||||
```
|
||||
|
||||
The remote side runs `npx -y @ruvnet/ruview@<this version> call <tool>
|
||||
--read-only`. Every argument is validated against the tool schema on both
|
||||
ends and single-quoted for the remote shell. Flash, train, calibrate,
|
||||
credentialed reads, and host hopping are never forwarded.
|
||||
|
||||
## Firmware flashing (ADR-370)
|
||||
|
||||
Works on Windows, macOS, and Linux through `python -m esptool` (`pip install
|
||||
esptool pyserial`). The bundle directory is an extracted release flash bundle,
|
||||
`firmware/esp32-csi-node/release_bins/<variant>`, or an ESP-IDF `build/`.
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview ports
|
||||
npx @ruvnet/ruview flash-plan --port /dev/ttyUSB0 --bundle ./bundle --variant s3-8mb
|
||||
npx @ruvnet/ruview flash --port /dev/ttyUSB0 --bundle ./bundle --variant s3-8mb --confirm
|
||||
```
|
||||
|
||||
Before writing, every image is checked against `SHA256SUMS(.txt)` (a mismatch
|
||||
is always refused), and `esptool chip_id` must report the chip the variant
|
||||
targets. After writing, a 15 s serial capture is summarized. The result sets
|
||||
`hardwareValidated: true` only when that log shows CSI callbacks without a
|
||||
panic. A successful write alone is not hardware validation. WiFi provisioning
|
||||
stays with `provision.py` (see the `provision-node` skill) so credentials never
|
||||
pass through the harness.
|
||||
|
||||
## Training (ADR-371)
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview train-plan --mode pose-smoke # review the command
|
||||
npx @ruvnet/ruview train --mode pose-smoke --confirm # SYNTHETIC smoke run (needs libtorch)
|
||||
npx @ruvnet/ruview train --mode pose --data-dir data/mmfi --confirm
|
||||
npx @ruvnet/ruview train --mode room --enrollment enrollment.json --confirm
|
||||
npx @ruvnet/ruview train-gate --file eval-report.json # PASS/FAIL + publishable wording
|
||||
```
|
||||
|
||||
Pose training builds `wifi-densepose-train` with `tch-backend`. It needs
|
||||
libtorch: tch 0.24 expects torch 2.11.0, via `LIBTORCH` or
|
||||
`LIBTORCH_USE_PYTORCH=1`. Checkpoints and trainer logs go under
|
||||
`v2/target/ruview-train/<mode>` unless `--checkpoint-dir` names another path
|
||||
inside the checkout. The gate refuses any score that has no mean-pose baseline,
|
||||
uses a random-frame split, shares subjects across a grouped-subject split,
|
||||
overlaps in time, or fails to beat the baseline.
|
||||
|
||||
## Debugging doctor (ADR-372)
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview doctor # all groups, human output
|
||||
npx @ruvnet/ruview doctor --json --group python,serial,firmware
|
||||
npx @ruvnet/ruview doctor --port COM7 --probe # esptool chip_id (resets the board, writes nothing)
|
||||
npx @ruvnet/ruview doctor --url http://localhost:3000 # sensing-server /health
|
||||
```
|
||||
|
||||
Each check is `pass`, `warn`, `fail`, or `skip`, and every non-passing check
|
||||
carries a `fix:`. The exit code is non-zero only for failures.
|
||||
|
||||
## SDK (ADR-369)
|
||||
|
||||
```js
|
||||
import { createRuView } from '@ruvnet/ruview/sdk'; // typed: src/sdk.d.ts
|
||||
|
||||
const ruview = createRuView(); // { strict: true } throws RuViewError on ok:false
|
||||
const report = await ruview.doctor({ groups: ['python', 'serial'] });
|
||||
const plan = await ruview.firmware.plan({ port: 'COM7', bundle: './bundle', variant: 'c6' });
|
||||
const flashed = await ruview.firmware.flash({ port: 'COM7', bundle: './bundle', variant: 'c6', confirm: true });
|
||||
const gate = await ruview.training.gate({ model_score: 0.595, baseline_score: 0.501, split: 'chronological',
|
||||
train_end: '2026-03-01', test_start: '2026-03-02', n_test: 800, reproducer: 'python eval.py' });
|
||||
const kernel = await ruview.kernel.load(); // optional @ruvnet/ruview-kernel
|
||||
const radar = await ruview.devices.mmwave({ port: '/dev/ttyUSB0', model: 'auto' });
|
||||
const nodes = await ruview.devices.esp32({ seconds: 10 });
|
||||
const remote = await ruview.hosts.run('lab-pi', 'ruview_lidar_read', { source: 'rplidar', port: '/dev/ttyUSB1' });
|
||||
```
|
||||
|
||||
The SDK calls the same policy-checked registry as the CLI and MCP server.
|
||||
Mutating calls still need `confirm: true`.
|
||||
|
||||
### Cognitum Spaces OAuth
|
||||
|
||||
Activate the additional read scope through the Rust CLI, then use the same
|
||||
@@ -109,6 +242,78 @@ 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`.
|
||||
|
||||
## Claude Code mod: live sensing pane (ADR-377)
|
||||
|
||||
This package ships **`ruview-live`**, a Claude Code mod: a plugin whose behaviour is a function-hooks module. `/ruview` opens a pane beside the transcript, refreshed on a timer, with:
|
||||
- your CSI nodes (ESP32, Realtek): rate, loss, RSSI and CSI shape;
|
||||
- an optional ESPHome radar kit: presence, distance, and device-reported heart/breathing;
|
||||
- alerts, such as a board that sends heartbeats but no CSI.
|
||||
|
||||
It also keeps a one-line status: `RuView · 2 nodes · radar present · 1 alert`.
|
||||
|
||||
**Showcase views (ADR-378).** The pane has three views, switched with keys `1`, `2` and `3`. The two live views are drawn in terminal cells (24-bit colour) and animated in place:
|
||||
|
||||
| Key | View | What you see |
|
||||
|---|---|---|
|
||||
| `1` | Overview | the cards above |
|
||||
| `2` | CSI waterfall | per-subcarrier amplitude over time (`esp32 --spectrum`), replayed at the frames' arrival rate and labelled **MEASURED** or **SYNTHETIC** from the packets' own flag; `n` cycles nodes |
|
||||
| `3` | Radar | a 120° range fan with a sonar ping out to the measured distance; heart and breathing charts, and a ♥ and breathing gauge that pulse at the device-reported rates (a metronome, not a waveform) |
|
||||
|
||||
The radar kit reports range, not bearing, so the arc covers every bearing at that range. Desktop and IDE surfaces show the overview, and a note in the live views.
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview mod # where the mod is in this install, and how to load it
|
||||
claude --plugin-dir "<path printed above>" # try it for one session
|
||||
# or from the RuView marketplace:
|
||||
# /plugin marketplace add ruvnet/RuView
|
||||
# /plugin install ruview-live@ruview
|
||||
```
|
||||
|
||||
In Claude Code:
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `/ruview` | Open or close the pane |
|
||||
| `/ruview refresh` | Run one capture now; the result goes in the status line |
|
||||
| `/ruview off` | Close the pane |
|
||||
| `/ruview waterfall`, `/ruview radar` | Open the pane on that view |
|
||||
|
||||
**Settings** (`claude plugin configure ruview-live`):
|
||||
- `udpPort` (default 5005);
|
||||
- `radarHost`, an ESPHome kit on your private network;
|
||||
- `refreshSeconds` (default 15, minimum 5);
|
||||
- `liveRefreshSeconds` for the waterfall and radar views (default 4, range 2–60);
|
||||
- `captureSeconds` (default 3).
|
||||
|
||||
**How it reads:**
|
||||
- The mod only runs this package's CLI, for read-only `esp32` and `mmwave --source esphome` captures, with `--json`, so the pane shows exactly what those tools return.
|
||||
- It never flashes, provisions or writes to a device.
|
||||
- Like every mod, it runs inside Claude Code with Claude Code's access, so install it only from a source you trust.
|
||||
|
||||
Mods are early access in Claude Code. If `/ruview` is missing, start Claude Code with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`.
|
||||
|
||||
## ChatGPT and MCP Apps (ADR-375)
|
||||
|
||||
`ruview_esp32_capture`, `ruview_devices_scan` and `ruview_doctor` render in a
|
||||
self-contained console widget (`ui://ruview/console-v1.html`) in ChatGPT and
|
||||
other MCP Apps hosts, with a Refresh button that re-runs the tool. Results also
|
||||
carry `structuredContent`.
|
||||
|
||||
```bash
|
||||
# MCP over HTTP: loopback, token-authenticated, read tools only
|
||||
export RUVIEW_MCP_TOKEN="$(node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))")"
|
||||
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview mcp start --http --port 8790
|
||||
```
|
||||
|
||||
Clients that can send headers use `Authorization: Bearer <token>` on
|
||||
`http://127.0.0.1:8790/mcp`. ChatGPT connectors cannot, so expose the port over
|
||||
HTTPS (a tunnel or reverse proxy) and add `https://<host>/mcp/<token>` as the
|
||||
connector URL. Write grants (`hardware-write`, `workspace-write`) are never
|
||||
honoured over HTTP: flashing and calibration stay on the CLI and stdio.
|
||||
|
||||
In a terminal, commands print a formatted view; pipes and `--json` print JSON.
|
||||
`ruview esp32 --watch` redraws live capture windows with per-node trends.
|
||||
|
||||
## Hosts
|
||||
|
||||
Claude Code and Codex are implemented directly and tested with the local,
|
||||
|
||||
+5
-216
@@ -1,225 +1,14 @@
|
||||
#!/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/…) and local host adapters are
|
||||
// pure Node and run with zero runtime dependencies.
|
||||
// `npx ruview` launcher. The logic lives in src/cli.js, which loads modules on
|
||||
// demand (ADR-375); `run` is re-exported for tests and embedders.
|
||||
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { realpathSync, existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { argv } from 'node:process';
|
||||
import { TOOLS, runTool, listTools, findRepoRoot, which } from '../src/tools.js';
|
||||
import { claimCheck, summarize } from '../src/guardrails.js';
|
||||
import { getHost } from '../src/hosts/index.js';
|
||||
import { makeProposal, searchBrain, verifyBrain } from '../src/brain.js';
|
||||
import { run } from '../src/cli.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',
|
||||
guidance: 'ruview_guidance',
|
||||
spaces: 'ruview_spaces_list',
|
||||
};
|
||||
|
||||
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]);
|
||||
checks.push(['Claude Code adapter resolves', getHost('claude-code').name === 'claude-code']);
|
||||
checks.push(['Codex adapter resolves', getHost('codex').name === 'codex']);
|
||||
const localHosts = [
|
||||
which('claude') ? 'claude -p' : null,
|
||||
which('codex') ? 'codex exec' : null,
|
||||
].filter(Boolean);
|
||||
const spacesBackend = which('wifi-densepose') ? 'wifi-densepose binary' : 'unavailable (install wifi-densepose)';
|
||||
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'} — local hosts: ${localHosts.join(', ') || 'none on PATH (optional)'}; Spaces backend: ${spacesBackend}`);
|
||||
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)
|
||||
guidance [--topic homecore] [--query "Wasmtime"] source-cited code/capability map
|
||||
spaces [--resource sites|...|alerts] [--limit 50] page OAuth-bound Cognitum spatial resources
|
||||
|
||||
Harness:
|
||||
doctor verify tools, adapters, and local CLI discovery
|
||||
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
|
||||
agent run --host claude-code|codex --prompt "..." [--repo <dir>]
|
||||
brain search --query "..." | verify | propose
|
||||
--version | --help
|
||||
|
||||
Hosts implemented and tested locally: claude-code (-p), codex (exec)`);
|
||||
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');
|
||||
delete toolArgs.file;
|
||||
}
|
||||
// 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 === 'guidance' && flags.limit) toolArgs.limit = Number(flags.limit);
|
||||
if (cmd === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
|
||||
if (cmd === 'spaces') {
|
||||
if (flags['credentials-path'] !== undefined) toolArgs.credentials_path = flags['credentials-path'];
|
||||
delete toolArgs['credentials-path'];
|
||||
if (flags.limit !== undefined) toolArgs.limit = Number(flags.limit);
|
||||
}
|
||||
const res = await runTool(VERB_TO_TOOL[cmd], toolArgs, { source: 'cli' });
|
||||
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 'agent': {
|
||||
if (rest[0] !== 'run') { console.error('Usage: ruview agent run --host claude-code|codex --prompt "..." [--repo <dir>]'); return 2; }
|
||||
const hostName = String(flags.host || 'codex');
|
||||
const prompt = String(flags.prompt || '');
|
||||
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
|
||||
if (!repo) { console.error('agent run: trusted RuView repo not found; pass --repo <root>.'); return 2; }
|
||||
if (!prompt.trim()) { console.error('agent run: --prompt is required.'); return 2; }
|
||||
const allowWrite = flags['allow-write'] === true;
|
||||
if (allowWrite && flags.confirm !== true) { console.error('agent run: --allow-write also requires --confirm.'); return 2; }
|
||||
try {
|
||||
const result = await getHost(hostName).run({
|
||||
prompt, repoRoot: repo, trustedRoot: repo, allowWrite, confirm: flags.confirm === true,
|
||||
});
|
||||
pjson({ ok: true, host: hostName, mode: allowWrite ? 'workspace-write' : 'read-only', stdout: result.stdout, stderr: result.stderr });
|
||||
return 0;
|
||||
} catch (error) {
|
||||
pjson({ ok: false, host: hostName, error: error.message });
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
case 'brain': {
|
||||
const action = rest[0] || 'search';
|
||||
if (action === 'search') {
|
||||
const query = String(flags.query || '');
|
||||
if (!query.trim()) { console.error('brain search: --query is required.'); return 2; }
|
||||
pjson({ ok: true, results: searchBrain(query, { limit: flags.limit }) }); return 0;
|
||||
}
|
||||
if (action === 'verify') {
|
||||
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
|
||||
if (!repo) { console.error('brain verify: RuView repo not found.'); return 2; }
|
||||
const result = verifyBrain({ repo }); pjson(result); return result.ok ? 0 : 1;
|
||||
}
|
||||
if (action === 'propose') {
|
||||
const result = makeProposal(flags); pjson(result); return result.ok ? 0 : 1;
|
||||
}
|
||||
console.error('Usage: ruview brain search|verify|propose'); return 2;
|
||||
}
|
||||
case 'install': {
|
||||
const host = flags.host || 'claude-code';
|
||||
if (!['claude-code', 'codex'].includes(host)) {
|
||||
console.error(`Host "${host}" is not implemented. Supported: claude-code, codex.`);
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
const adapter = getHost(host);
|
||||
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 "${host}" is unavailable.`);
|
||||
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;
|
||||
}
|
||||
}
|
||||
export { run };
|
||||
|
||||
// CLI guard: run only when invoked directly (realpath both sides — npm/npx shims
|
||||
// pass a non-normalized, possibly case-skewed argv[1] on Windows).
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "ruview-live",
|
||||
"version": "0.1.0",
|
||||
"description": "RuView live sensing in Claude Code: /ruview opens a pane beside the transcript with three views: an overview of your CSI nodes (ESP32, Realtek) and 60 GHz radar (rates, loss, RSSI, presence, device-reported vitals, alerts), a live CSI amplitude waterfall, and a radar range fan with vitals charts, all drawn in terminal cells. Reads only; runs the @ruvnet/ruview CLI it ships beside.",
|
||||
"author": {
|
||||
"name": "ruvnet",
|
||||
"url": "https://github.com/ruvnet/RuView"
|
||||
},
|
||||
"homepage": "https://github.com/ruvnet/RuView",
|
||||
"license": "MIT",
|
||||
"userConfig": {
|
||||
"udpPort": {
|
||||
"type": "number",
|
||||
"title": "UDP port",
|
||||
"description": "Port your CSI nodes stream to (the sensing server's default is 5005). The pane listens briefly on each refresh; it reports port_in_use when the sensing server holds it.",
|
||||
"default": 5005
|
||||
},
|
||||
"radarHost": {
|
||||
"type": "string",
|
||||
"title": "Radar kit host",
|
||||
"description": "IP or hostname of an ESPHome radar kit (e.g. a Seeed MR60BHA2) on your private network. Empty skips the radar.",
|
||||
"default": ""
|
||||
},
|
||||
"refreshSeconds": {
|
||||
"type": "number",
|
||||
"title": "Refresh interval (s)",
|
||||
"description": "Seconds between refreshes while the pane is open (minimum 5).",
|
||||
"default": 15
|
||||
},
|
||||
"captureSeconds": {
|
||||
"type": "number",
|
||||
"title": "Capture window (s)",
|
||||
"description": "Seconds each refresh listens for node packets and radar states (1-10).",
|
||||
"default": 3
|
||||
},
|
||||
"liveRefreshSeconds": {
|
||||
"type": "number",
|
||||
"title": "Live view refresh (s)",
|
||||
"description": "Seconds between refreshes in the waterfall and radar views (2-60).",
|
||||
"default": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// ruview-live animation frames (ADR-378): pure functions of time that the
|
||||
// hooks module repaints in place with `$.ui.blit` (no redraw). Decoration is
|
||||
// labelled as decoration; anything driven by a reading says which reading.
|
||||
|
||||
import { Grid, rgb } from './raster.mjs';
|
||||
|
||||
const mix = (a, b, f) => rgb(
|
||||
((a >> 16) & 255) + ((((b >> 16) & 255) - ((a >> 16) & 255)) * f),
|
||||
((a >> 8) & 255) + ((((b >> 8) & 255) - ((a >> 8) & 255)) * f),
|
||||
(a & 255) + (((b & 255) - (a & 255)) * f),
|
||||
);
|
||||
const CYAN = rgb(0, 190, 220);
|
||||
const EMERALD = rgb(40, 210, 140);
|
||||
const DARK = rgb(16, 22, 30);
|
||||
|
||||
/** A one-row cyan→emerald rule with a highlight sweeping across it (pure decoration). */
|
||||
export function shimmer(columns, tMs) {
|
||||
const g = new Grid(columns, 1);
|
||||
const head = ((tMs / 2400) % 1) * (g.columns + 24) - 12;
|
||||
for (let x = 0; x < g.columns; x++) {
|
||||
const base = mix(CYAN, EMERALD, x / Math.max(1, g.columns - 1));
|
||||
const glow = Math.max(0, 1 - Math.abs(x - head) / 10);
|
||||
g.set(x, 0, 0x2501, mix(mix(DARK, base, 0.55), rgb(235, 255, 250), glow * glow));
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Phase in [0, 1) of a rhythm at `perMinute` (0 when unknown). */
|
||||
export function phaseOf(tMs, perMinute) {
|
||||
if (!Number.isFinite(perMinute) || perMinute <= 0) return 0;
|
||||
return ((tMs / 60000) * perMinute) % 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* A heart icon that beats, and a breathing gauge that fills and empties, at
|
||||
* the device-reported rates: a metronome of the reading, not a waveform.
|
||||
* Unknown rates draw a still, dim strip.
|
||||
*/
|
||||
export function pulse(columns, tMs, { heartBpm = null, breathingBpm = null } = {}) {
|
||||
const g = new Grid(columns, 1);
|
||||
const beat = Number.isFinite(heartBpm) ? Math.exp(-phaseOf(tMs, heartBpm) * 7) : 0;
|
||||
g.set(0, 0, 0x2665, Number.isFinite(heartBpm) ? mix(rgb(90, 20, 35), rgb(255, 90, 120), beat) : rgb(70, 76, 84));
|
||||
const width = g.columns - 3;
|
||||
const breath = Number.isFinite(breathingBpm) ? 0.5 - 0.5 * Math.cos(2 * Math.PI * phaseOf(tMs, breathingBpm)) : 0;
|
||||
const fill = breath * width;
|
||||
for (let x = 0; x < width; x++) {
|
||||
const f = Math.max(0, Math.min(1, fill - x));
|
||||
const on = mix(rgb(40, 120, 200), rgb(140, 220, 255), x / Math.max(1, width));
|
||||
g.set(x + 3, 0, f > 0 ? 0x2588 : 0x2500, f > 0 ? mix(DARK, on, f) : rgb(40, 46, 54));
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waterfall replay: frames arrive a capture at a time, so the view shows them
|
||||
* at their arrival rate instead of jumping. `lag` is how many received frames
|
||||
* are not shown yet; it drains at `rateHz` and never exceeds what is held.
|
||||
*/
|
||||
export function drain(lag, rateHz, dtMs) {
|
||||
if (!Number.isFinite(lag) || lag <= 0) return 0;
|
||||
const rate = Number.isFinite(rateHz) && rateHz > 0 ? rateHz : 10;
|
||||
return Math.max(0, lag - (rate * dtMs) / 1000);
|
||||
}
|
||||
|
||||
/** Frames to show now: all but the `lag` newest. */
|
||||
export function shownFrames(frames, lag) {
|
||||
const hold = Math.min(frames.length, Math.max(0, Math.floor(lag || 0)));
|
||||
return hold ? frames.slice(0, frames.length - hold) : frames;
|
||||
}
|
||||
|
||||
/** Sonar ping phase for the radar fan: a ring leaving the sensor every 1.6 s. */
|
||||
export const pingOf = (tMs) => (tMs % 1600) / 1600;
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"description": "RuView live pane: /ruview and its Pane drawing, a timed refresh from the @ruvnet/ruview CLI (node capture and ESPHome radar, read-only), and a one-line status",
|
||||
"modules": ["./register.mjs"]
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// ruview-live data: settings, CLI argv, result parsing, the pane model, trend
|
||||
// history and the status line. Pure functions, tested under plain Node.
|
||||
|
||||
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/;
|
||||
export const HISTORY = 48;
|
||||
export const MODES = Object.freeze(['overview', 'waterfall', 'radar']);
|
||||
|
||||
/**
|
||||
* The harness CLI beside this mod: the plugin root is <pkg>/mod, so the CLI is
|
||||
* <pkg>/bin/cli.js. Mods import nothing but their own files, so the engine's
|
||||
* `$.plugin.root` locates it.
|
||||
*/
|
||||
export function cliPathOf(pluginRoot) {
|
||||
return `${String(pluginRoot).replace(/[\\/]+$/, '')}/../bin/cli.js`;
|
||||
}
|
||||
|
||||
/** Normalise plugin options (userConfig) into bounded settings. */
|
||||
export function settingsOf(options = {}) {
|
||||
const num = (v, d, lo, hi) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), lo), hi) : d;
|
||||
};
|
||||
const host = typeof options.radarHost === 'string' ? options.radarHost.trim() : '';
|
||||
return {
|
||||
udpPort: num(options.udpPort, 5005, 1024, 65535),
|
||||
radarHost: HOST_RE.test(host) ? host : '',
|
||||
refreshMs: num(options.refreshSeconds, 15, 5, 3600) * 1000,
|
||||
captureSeconds: num(options.captureSeconds, 3, 1, 10),
|
||||
liveRefreshMs: num(options.liveRefreshSeconds, 4, 2, 60) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* argv for one capture and (optionally) one radar read. In a live view the
|
||||
* capture is shorter, and the waterfall asks for binned amplitude frames.
|
||||
*/
|
||||
export function commandsOf(settings, { live = false, spectrum = null } = {}) {
|
||||
const s = String(live ? Math.min(settings.captureSeconds, 2) : settings.captureSeconds);
|
||||
const capture = ['esp32', '--seconds', s, '--udp-port', String(settings.udpPort), '--json'];
|
||||
if (spectrum) capture.push('--spectrum', '--spectrum-bins', String(spectrum.bins), '--spectrum-frames', String(spectrum.frames));
|
||||
return {
|
||||
capture,
|
||||
radar: settings.radarHost ? ['mmwave', '--source', 'esphome', '--host', settings.radarHost, '--seconds', s, '--json'] : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a CLI run into its JSON result, or an honest failure. */
|
||||
export function resultOf(run) {
|
||||
if (!run) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(run.stdout);
|
||||
if (parsed && typeof parsed === 'object') return parsed;
|
||||
} catch { /* fall through */ }
|
||||
return { ok: false, reason: 'cli_error', detail: String(run.stderr || run.stdout || `exit ${run.exitCode}`).trim().slice(0, 300) };
|
||||
}
|
||||
|
||||
const fixed = (v, d = 1) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
|
||||
const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(1)}%` : '—');
|
||||
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
||||
|
||||
/** Resting ranges outside which a device-reported vital is flagged, not trusted. */
|
||||
export const RANGES = Object.freeze({ heart: [40, 180], breathing: [4, 40] });
|
||||
|
||||
/** Why a device-reported vital looks implausible, or null. */
|
||||
export function plausibilityOf(kind, value) {
|
||||
if (value == null || !RANGES[kind]) return null;
|
||||
const [lo, hi] = RANGES[kind];
|
||||
return value < lo || value > hi ? `outside ${lo}–${hi} bpm` : null;
|
||||
}
|
||||
|
||||
const SPARK = '▁▂▃▄▅▆▇█';
|
||||
/** Unicode sparkline of the last `width` finite values ('' below two points). */
|
||||
export function sparkline(values, width = 16) {
|
||||
const v = (values || []).filter((x) => typeof x === 'number' && Number.isFinite(x)).slice(-width);
|
||||
if (v.length < 2) return '';
|
||||
const lo = Math.min(...v);
|
||||
const hi = Math.max(...v);
|
||||
return v.map((x) => SPARK[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
|
||||
}
|
||||
|
||||
/** "12s ago" / "3m ago" from a millisecond age, or '' when unknown. */
|
||||
export function ageOf(ms) {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '';
|
||||
const s = Math.round(ms / 1000);
|
||||
return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
|
||||
}
|
||||
|
||||
/** The pane's model: plain data, drawn by the views and summarised by statusOf. */
|
||||
export function modelOf(capture, radar, at) {
|
||||
const alerts = [];
|
||||
// "No packets" is shown inside the nodes card; only real faults become alerts.
|
||||
if (capture && capture.ok === false && capture.reason !== 'no_packets') {
|
||||
alerts.push({ level: 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` });
|
||||
}
|
||||
for (const hb of capture?.heartbeatOnlySenders || []) alerts.push({ level: 'warn', text: `${hb.address} sends heartbeats but no CSI (run \`ruview monitor --baud 1500000\` on it: "lack of csi buf" = buffer starvation)` });
|
||||
if (radar && radar.ok === false) alerts.push({ level: 'bad', text: `radar: ${String(radar.reason).replace(/_/g, ' ')}${radar.detail ? ` — ${radar.detail}` : ''}` });
|
||||
const nodes = (capture?.nodes || []).map((n) => ({
|
||||
key: `${n.source}:${n.nodeId}`,
|
||||
label: `${n.source === 'realtek' ? 'realtek' : 'esp32'} ${n.nodeId}`,
|
||||
rateHz: num(n.csiRateHz),
|
||||
rate: `${fixed(n.csiRateHz)} Hz`,
|
||||
loss: pct(n.csiLossFraction),
|
||||
rssi: `${fixed(n.rssiMean)} dBm`,
|
||||
shape: n.csi?.shape || '—',
|
||||
lossy: typeof n.csiLossFraction === 'number' && n.csiLossFraction > 0.05,
|
||||
synthetic: Boolean(n.csi?.synthetic),
|
||||
}));
|
||||
const spectrum = (capture?.spectrum || []).map((s) => ({
|
||||
key: `${s.source}:${s.nodeId}`,
|
||||
label: `${s.source === 'realtek' ? 'realtek' : 'esp32'} ${s.nodeId}`,
|
||||
shape: s.shape ?? null, subcarriers: s.subcarriers, bins: s.bins, frames: Array.isArray(s.frames) ? s.frames : [],
|
||||
rateHz: num(s.rateHz), synthetic: Boolean(s.synthetic),
|
||||
}));
|
||||
const radarRow = radar && radar.ok !== false ? {
|
||||
name: radar.device?.name || radar.host || 'radar',
|
||||
present: radar.presentNow ?? (radar.presentFraction == null ? null : radar.presentFraction >= 0.5),
|
||||
targets: num(radar.targetsMax),
|
||||
distanceCm: num(radar.distanceCmMean),
|
||||
heartBpm: num(radar.heartBpmMean),
|
||||
breathingBpm: num(radar.breathingBpmMean),
|
||||
distance: radar.distanceCmMean == null ? '—' : `${fixed(radar.distanceCmMean)} cm`,
|
||||
heart: radar.heartBpmMean == null ? '—' : `${fixed(radar.heartBpmMean)} bpm`,
|
||||
breathing: radar.breathingBpmMean == null ? '—' : `${fixed(radar.breathingBpmMean)} bpm`,
|
||||
} : null;
|
||||
return {
|
||||
at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0,
|
||||
noPackets: capture?.reason === 'no_packets', nodes, spectrum, radar: radarRow, alerts,
|
||||
};
|
||||
}
|
||||
|
||||
/** Append one model to a bounded history of trend values. */
|
||||
export function historyWith(history, model, max = HISTORY) {
|
||||
const h = history || { heart: [], breathing: [], distance: [], rates: {} };
|
||||
const cap = (arr, v) => [...arr, v].slice(-max);
|
||||
const rates = { ...h.rates };
|
||||
for (const n of model?.nodes || []) rates[n.key] = cap(rates[n.key] || [], n.rateHz);
|
||||
return {
|
||||
heart: cap(h.heart, model?.radar?.heartBpm ?? null),
|
||||
breathing: cap(h.breathing, model?.radar?.breathingBpm ?? null),
|
||||
distance: cap(h.distance, model?.radar?.distanceCm ?? null),
|
||||
rates,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry each node's waterfall frames across captures, so the picture scrolls
|
||||
* instead of restarting every refresh. Only nodes in this capture remain: a
|
||||
* node that stopped streaming (or a failed capture) leaves the waterfall, so
|
||||
* old frames are never shown as live. A node whose CSI shape or bin count
|
||||
* changed starts over rather than joining two subcarrier layouts.
|
||||
*/
|
||||
export function spectrumWith(previous, spectrum, max = 128) {
|
||||
const byKey = new Map((previous || []).map((s) => [s.key, s]));
|
||||
return (spectrum || []).map((s) => {
|
||||
const old = byKey.get(s.key);
|
||||
const same = old && old.bins === s.bins && old.shape === s.shape;
|
||||
return { ...s, frames: (same ? [...old.frames, ...s.frames] : [...s.frames]).slice(-max) };
|
||||
});
|
||||
}
|
||||
|
||||
/** One line for the status bar. */
|
||||
export function statusOf(model) {
|
||||
if (!model) return 'RuView · starting';
|
||||
const parts = [`RuView · ${model.nodes.length} node${model.nodes.length === 1 ? '' : 's'}`];
|
||||
if (model.radar) parts.push(`radar ${model.radar.present == null ? '?' : model.radar.present ? 'present' : 'clear'}`);
|
||||
if (model.alerts.length) parts.push(`${model.alerts.length} alert${model.alerts.length === 1 ? '' : 's'}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Cell graphics for the ruview-live showcase (ADR-378): every picture is a
|
||||
// terminal `Raster`, a grid of width-1 characters with 24-bit colours, so it
|
||||
// draws in any terminal (Windows Terminal included), not only pixel-capable
|
||||
// ones. Pure functions: no engine calls, so Node tests can check them.
|
||||
|
||||
/** The terminal's own colour (bit 24 alone), per the Raster contract. */
|
||||
export const DEFAULT = 0x01000000;
|
||||
|
||||
/** 0xRRGGBB from components. */
|
||||
export const rgb = (r, g, b) => (((r & 255) << 16) | ((g & 255) << 8) | (b & 255)) >>> 0;
|
||||
|
||||
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
||||
|
||||
/** Standard padded base64 of a byte array (the mod environment has no Node Buffer). */
|
||||
export function toBase64(bytes) {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
for (; i + 2 < bytes.length; i += 3) {
|
||||
const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
|
||||
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63];
|
||||
}
|
||||
const rest = bytes.length - i;
|
||||
if (rest === 1) {
|
||||
const n = bytes[i] << 16;
|
||||
out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}==`;
|
||||
} else if (rest === 2) {
|
||||
const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
|
||||
out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}${B64[(n >> 6) & 63]}=`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A grid of cells: code point, foreground, background. */
|
||||
export class Grid {
|
||||
constructor(columns, rows) {
|
||||
this.columns = Math.max(1, Math.min(512, Math.floor(columns)));
|
||||
this.rows = Math.max(1, Math.min(256, Math.floor(rows)));
|
||||
this.cells = new Uint32Array(this.columns * this.rows * 3);
|
||||
for (let i = 0; i < this.columns * this.rows; i++) {
|
||||
this.cells[i * 3] = 0x20;
|
||||
this.cells[i * 3 + 1] = DEFAULT;
|
||||
this.cells[i * 3 + 2] = DEFAULT;
|
||||
}
|
||||
}
|
||||
set(x, y, ch, fg = DEFAULT, bg = DEFAULT) {
|
||||
if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) return;
|
||||
const i = (Math.floor(y) * this.columns + Math.floor(x)) * 3;
|
||||
this.cells[i] = typeof ch === 'number' ? ch : ch.codePointAt(0);
|
||||
this.cells[i + 1] = fg >>> 0;
|
||||
this.cells[i + 2] = bg >>> 0;
|
||||
}
|
||||
get(x, y) {
|
||||
const i = (y * this.columns + x) * 3;
|
||||
return [this.cells[i], this.cells[i + 1], this.cells[i + 2]];
|
||||
}
|
||||
/** Write a string left to right (labels over a picture). */
|
||||
text(x, y, str, fg = DEFAULT, bg = DEFAULT) {
|
||||
[...str].forEach((ch, k) => this.set(x + k, y, ch, fg, bg));
|
||||
}
|
||||
/** Props for the surface's `Raster` element. */
|
||||
toRaster(key) {
|
||||
const bytes = new Uint8Array(this.cells.buffer, this.cells.byteOffset, this.cells.byteLength);
|
||||
return { key, columns: this.columns, rows: this.rows, cells: toBase64(bytes) };
|
||||
}
|
||||
}
|
||||
|
||||
// Perceptual-ish ramp on near-black: indigo → cyan → emerald → amber → white.
|
||||
const STOPS = [
|
||||
[0.0, [10, 12, 22]], [0.22, [36, 38, 120]], [0.45, [0, 150, 200]],
|
||||
[0.65, [30, 205, 140]], [0.85, [235, 205, 70]], [1.0, [255, 250, 235]],
|
||||
];
|
||||
|
||||
/** Colour for t in [0, 1]. */
|
||||
export function colormap(t) {
|
||||
const x = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0));
|
||||
for (let k = 1; k < STOPS.length; k++) {
|
||||
const [t1, c1] = STOPS[k];
|
||||
if (x <= t1) {
|
||||
const [t0, c0] = STOPS[k - 1];
|
||||
const f = (x - t0) / (t1 - t0 || 1);
|
||||
return rgb(c0[0] + (c1[0] - c0[0]) * f, c0[1] + (c1[1] - c0[1]) * f, c0[2] + (c1[2] - c0[2]) * f);
|
||||
}
|
||||
}
|
||||
return rgb(255, 250, 235);
|
||||
}
|
||||
|
||||
/** The values at fractions p of a sample (robust colour limits). */
|
||||
export function percentiles(values, ps) {
|
||||
const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
|
||||
if (!v.length) return ps.map(() => 0);
|
||||
return ps.map((p) => v[Math.min(v.length - 1, Math.max(0, Math.round(p * (v.length - 1))))]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waterfall of amplitude frames (newest last): subcarrier bins across, time
|
||||
* down, two frames per text row through the upper-half block (fg = upper
|
||||
* frame, bg = lower frame). Colour limits are the 5th–95th percentile of the
|
||||
* shown frames. Returns { grid, lo, hi, shown }.
|
||||
*/
|
||||
export function waterfall(frames, columns, rows) {
|
||||
const g = new Grid(columns, rows);
|
||||
const shown = (frames || []).slice(-(g.rows * 2));
|
||||
const [lo, hi] = percentiles(shown.flat(), [0.05, 0.95]);
|
||||
const span = hi - lo || 1;
|
||||
const offset = g.rows * 2 - shown.length; // empty frame slots at the top
|
||||
const colourAt = (slot, x) => {
|
||||
const f = shown[slot - offset];
|
||||
if (!f || !f.length) return rgb(10, 12, 22);
|
||||
const bin = Math.min(f.length - 1, Math.floor((x * f.length) / g.columns));
|
||||
return colormap((f[bin] - lo) / span);
|
||||
};
|
||||
for (let y = 0; y < g.rows; y++) {
|
||||
for (let x = 0; x < g.columns; x++) g.set(x, y, 0x2580, colourAt(y * 2, x), colourAt(y * 2 + 1, x));
|
||||
}
|
||||
return { grid: g, lo, hi, shown: shown.length };
|
||||
}
|
||||
|
||||
/** A one-row colour bar for a legend. */
|
||||
export function colourbar(columns) {
|
||||
const g = new Grid(columns, 1);
|
||||
for (let x = 0; x < g.columns; x++) g.set(x, 0, 0x2588, colormap(x / Math.max(1, g.columns - 1)));
|
||||
return g;
|
||||
}
|
||||
|
||||
const BRAILLE_BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
|
||||
|
||||
/** A braille canvas: 2×4 dots per cell, one colour per cell (highest priority wins). */
|
||||
export class Braille {
|
||||
constructor(columns, rows) {
|
||||
this.columns = Math.max(1, Math.min(512, Math.floor(columns)));
|
||||
this.rows = Math.max(1, Math.min(256, Math.floor(rows)));
|
||||
this.width = this.columns * 2;
|
||||
this.height = this.rows * 4;
|
||||
this.bits = new Uint8Array(this.columns * this.rows);
|
||||
this.colour = new Uint32Array(this.columns * this.rows).fill(DEFAULT);
|
||||
this.prio = new Int8Array(this.columns * this.rows).fill(-1);
|
||||
this.bg = new Uint32Array(this.columns * this.rows).fill(DEFAULT);
|
||||
}
|
||||
plot(px, py, colour = DEFAULT, prio = 0) {
|
||||
const x = Math.round(px);
|
||||
const y = Math.round(py);
|
||||
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
|
||||
const c = (y >> 2) * this.columns + (x >> 1);
|
||||
this.bits[c] |= BRAILLE_BITS[y & 3][x & 1];
|
||||
if (prio >= this.prio[c]) { this.prio[c] = prio; this.colour[c] = colour; }
|
||||
}
|
||||
/** A straight segment between two dot positions. */
|
||||
line(x0, y0, x1, y1, colour, prio = 0) {
|
||||
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))));
|
||||
for (let s = 0; s <= steps; s++) this.plot(x0 + ((x1 - x0) * s) / steps, y0 + ((y1 - y0) * s) / steps, colour, prio);
|
||||
}
|
||||
/** Shade a cell's background (bands, glows). */
|
||||
shade(cx, cy, colour) {
|
||||
if (cx < 0 || cy < 0 || cx >= this.columns || cy >= this.rows) return;
|
||||
this.bg[cy * this.columns + cx] = colour;
|
||||
}
|
||||
toGrid() {
|
||||
const g = new Grid(this.columns, this.rows);
|
||||
for (let y = 0; y < this.rows; y++) {
|
||||
for (let x = 0; x < this.columns; x++) {
|
||||
const c = y * this.columns + x;
|
||||
g.set(x, y, this.bits[c] ? 0x2800 + this.bits[c] : 0x20, this.colour[c], this.bg[c]);
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
}
|
||||
|
||||
const GREY = rgb(70, 76, 84);
|
||||
const DIM = rgb(110, 118, 128);
|
||||
|
||||
/**
|
||||
* Top-down radar fan: the sensor at the bottom centre, range rings, the field
|
||||
* of view as a wedge, and the measured distance as an arc across it (this kit
|
||||
* reports range, not bearing), with older distances as fading arcs.
|
||||
* history: distances in cm, oldest first; present: boolean|null.
|
||||
*/
|
||||
export function radarFan(columns, rows, { distances = [], present = null, fovDeg = 120, ping = null } = {}) {
|
||||
const c = new Braille(columns, rows);
|
||||
const ox = c.width / 2;
|
||||
const oy = c.height - 1;
|
||||
const known = distances.filter((d) => Number.isFinite(d) && d > 0);
|
||||
const latest = known.length ? known[known.length - 1] : null;
|
||||
const maxCm = Math.max(150, Math.min(600, Math.ceil(((latest ?? 100) * 1.4) / 50) * 50));
|
||||
const half = (fovDeg * Math.PI) / 360;
|
||||
// Dots per cm (dots are about square): the wedge's half-width is r·sin(half).
|
||||
const scale = Math.min(oy, ox / Math.sin(Math.min(half, Math.PI / 2))) / maxCm;
|
||||
const arc = (rCm, colour, prio, density = 1) => {
|
||||
const r = rCm * scale;
|
||||
const steps = Math.max(8, Math.ceil(r * 2 * half * density));
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const a = -half + (2 * half * s) / steps;
|
||||
c.plot(ox + r * Math.sin(a), oy - r * Math.cos(a), colour, prio);
|
||||
}
|
||||
};
|
||||
// Wedge edges and rings every 50 cm.
|
||||
for (const a of [-half, half]) c.line(ox, oy, ox + maxCm * scale * Math.sin(a), oy - maxCm * scale * Math.cos(a), GREY, 0);
|
||||
for (let r = 50; r <= maxCm; r += 50) arc(r, GREY, 0, 0.35);
|
||||
// Trail: older distances fade from teal to near-background.
|
||||
const trail = known.slice(-8, -1);
|
||||
trail.forEach((d, k) => {
|
||||
const f = (k + 1) / (trail.length + 1);
|
||||
arc(d, rgb(20 + 20 * f, 70 + 90 * f, 80 + 80 * f), 1);
|
||||
});
|
||||
// Ping (animation): a ring leaving the sensor and reaching the measured range.
|
||||
if (ping != null && latest != null) {
|
||||
const f = Math.max(0, Math.min(1, ping));
|
||||
const fade = 0.35 + 0.65 * f;
|
||||
arc(latest * f, rgb(40 * fade, 160 * fade, 200 * fade), 2, 1);
|
||||
}
|
||||
const hit = ping != null && ping > 0.92;
|
||||
if (latest != null) arc(latest, hit ? rgb(220, 255, 235) : present ? rgb(80, 255, 150) : rgb(230, 235, 240), 3, 1.6);
|
||||
// Sensor marker.
|
||||
c.plot(ox, oy, rgb(255, 220, 90), 4);
|
||||
c.plot(ox - 1, oy, rgb(255, 220, 90), 4);
|
||||
const g = c.toGrid();
|
||||
// Ring labels along the right edge of the wedge.
|
||||
for (let r = 100; r <= maxCm; r += 100) {
|
||||
const px = ox + r * scale * Math.sin(half);
|
||||
const py = oy - r * scale * Math.cos(half);
|
||||
g.text(Math.min(g.columns - 3, Math.round(px / 2) + 1), Math.max(0, Math.round(py / 4)), `${r / 100}m`, DIM);
|
||||
}
|
||||
return { grid: g, maxCm };
|
||||
}
|
||||
|
||||
/**
|
||||
* Braille line chart of a series (oldest first, nulls skipped). The scale fits
|
||||
* the data (at least `minSpan` wide), so a rhythm stays visible; a plausible
|
||||
* band is shaded where it overlaps the scale. Returns { grid, lo, hi }.
|
||||
*/
|
||||
export function lineChart(series, columns, rows, { band = null, colour = rgb(0, 200, 220), minSpan = 2 } = {}) {
|
||||
const c = new Braille(columns, rows);
|
||||
const pts = (series || []).map((v, i) => [i, v]).filter(([, v]) => Number.isFinite(v)).slice(-c.width);
|
||||
const vals = pts.map(([, v]) => v);
|
||||
let lo = vals.length ? Math.min(...vals) : (band ? band[0] : 0);
|
||||
let hi = vals.length ? Math.max(...vals) : (band ? band[1] : 1);
|
||||
const pad = Math.max(0, minSpan - (hi - lo)) / 2 + (hi - lo) * 0.1;
|
||||
lo -= pad;
|
||||
hi += pad;
|
||||
if (hi - lo < 1e-9) { lo -= 1; hi += 1; }
|
||||
const yOf = (v) => (c.height - 1) - ((v - lo) / (hi - lo)) * (c.height - 1);
|
||||
if (band && band[1] >= lo && band[0] <= hi) {
|
||||
const top = Math.max(0, Math.floor(yOf(Math.min(band[1], hi)) / 4));
|
||||
const bottom = Math.min(c.rows - 1, Math.floor(yOf(Math.max(band[0], lo)) / 4));
|
||||
for (let y = top; y <= bottom; y++) for (let x = 0; x < c.columns; x++) c.shade(x, y, rgb(14, 34, 30));
|
||||
}
|
||||
const n = pts.length;
|
||||
const xOf = (k) => (n <= 1 ? c.width - 1 : (k * (c.width - 1)) / (n - 1));
|
||||
for (let k = 0; k < n; k++) {
|
||||
const y = yOf(pts[k][1]);
|
||||
if (k > 0) c.line(xOf(k - 1), yOf(pts[k - 1][1]), xOf(k), y, colour, 1);
|
||||
else c.plot(xOf(k), y, colour, 1);
|
||||
}
|
||||
if (n) c.plot(xOf(n - 1), yOf(pts[n - 1][1]), rgb(255, 255, 255), 2);
|
||||
return { grid: c.toGrid(), lo, hi };
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// ruview-live — a Claude Code mod (function hooks) shipped inside @ruvnet/ruview.
|
||||
//
|
||||
// `/ruview` opens a pane beside the transcript with three views: an overview of
|
||||
// the CSI nodes streaming to this machine and an optional ESPHome radar kit; a
|
||||
// live CSI amplitude waterfall; and a radar fan with vitals charts (ADR-377,
|
||||
// ADR-378). Every reading comes from the @ruvnet/ruview CLI this mod ships
|
||||
// beside (`--json`), so the pane shows exactly what the tested tools return.
|
||||
// Read-only: it never flashes, provisions or writes to a device.
|
||||
|
||||
import { commandsOf, cliPathOf, historyWith, MODES, modelOf, resultOf, settingsOf, spectrumWith, statusOf } from './model.mjs';
|
||||
import { drain } from './anim.mjs';
|
||||
import { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
|
||||
|
||||
export * from './anim.mjs';
|
||||
export * from './model.mjs';
|
||||
export * from './raster.mjs';
|
||||
export { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
|
||||
|
||||
export const PANE_ID = 'ruview-live';
|
||||
export const COMMAND = 'ruview';
|
||||
const TICK_MS = 5000;
|
||||
/** Body rows each view asks for when the pane sits inline above the prompt. */
|
||||
export const VIEW_ROWS = Object.freeze({ overview: 16, waterfall: 30, radar: 26 });
|
||||
|
||||
/**
|
||||
* The open request: a dialog (focus + closeOnEscape + holdToasts) takes the
|
||||
* keyboard, so the view keys work at once; `rows` sizes the inline pane to
|
||||
* the view instead of a third of the screen (the dock ignores it).
|
||||
*/
|
||||
export const openArgsOf = (mode) => ({
|
||||
id: PANE_ID, title: 'RuView', focus: true, closeOnEscape: true, holdToasts: true, rows: VIEW_ROWS[mode] ?? VIEW_ROWS.overview,
|
||||
});
|
||||
/** Animation frame period: ~12 fps, well inside blit's 60 shown a second. */
|
||||
export const FRAME_MS = 80;
|
||||
|
||||
/** Spectrum request for the waterfall: one bin per picture column (at most 128). */
|
||||
export function spectrumOf(columns, rows) {
|
||||
const { width, height } = sizesOf(columns, rows);
|
||||
return { bins: Math.max(8, Math.min(128, width)), frames: Math.max(8, Math.min(256, height * 2)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The mod entry: `/ruview [off|refresh|overview|waterfall|radar]`, the pane,
|
||||
* the timers, the status.
|
||||
* @param on the engine's registrar
|
||||
* @param options this plugin's userConfig values
|
||||
*/
|
||||
export function register(on, options = {}) {
|
||||
const settings = settingsOf(options);
|
||||
let host = null;
|
||||
let isOpen = false;
|
||||
let busy = false;
|
||||
let model = null;
|
||||
let history = null;
|
||||
let spectrum = [];
|
||||
let mode = 'overview';
|
||||
let nodeIndex = 0;
|
||||
let size = { columns: 100, rows: 30 };
|
||||
let stopTimer = null;
|
||||
let stopTick = null;
|
||||
let stopAnim = null;
|
||||
let animating = false;
|
||||
let lastFrame = null;
|
||||
let lag = {};
|
||||
let cliPath = null;
|
||||
|
||||
const intervalOf = () => (mode === 'overview' ? settings.refreshMs : settings.liveRefreshMs);
|
||||
const stop = () => {
|
||||
if (stopTimer) { stopTimer(); stopTimer = null; }
|
||||
if (stopTick) { stopTick(); stopTick = null; }
|
||||
if (stopAnim) { stopAnim(); stopAnim = null; }
|
||||
};
|
||||
const cancelOf = (t) => (typeof t === 'function' ? t : () => t?.cancel?.());
|
||||
|
||||
async function runCli(args) {
|
||||
if (!args) return null;
|
||||
try {
|
||||
return await host.run(['node', cliPath, ...args], { timeoutMs: (settings.captureSeconds + 30) * 1000 });
|
||||
} catch (error) {
|
||||
return { exitCode: -1, stdout: '', stderr: String(error?.message || error) };
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!host || busy) return;
|
||||
busy = true;
|
||||
host.invalidate();
|
||||
try {
|
||||
const commands = commandsOf(settings, {
|
||||
live: mode !== 'overview',
|
||||
spectrum: mode === 'waterfall' ? spectrumOf(size.columns, size.rows) : null,
|
||||
});
|
||||
const [capture, radar] = await Promise.all([runCli(commands.capture), runCli(commands.radar)]);
|
||||
// $.clock.now() resolves a promise of epoch milliseconds.
|
||||
model = modelOf(resultOf(capture), resultOf(radar), await host.now());
|
||||
history = historyWith(history, model);
|
||||
spectrum = spectrumWith(spectrum, model.spectrum);
|
||||
lag = Object.fromEntries(Object.entries(lag).filter(([key]) => spectrum.some((s) => s.key === key)));
|
||||
// New frames join the replay queue: shown at their arrival rate, not at once.
|
||||
for (const s of model.spectrum) {
|
||||
const held = spectrum.find((x) => x.key === s.key)?.frames.length ?? 0;
|
||||
lag[s.key] = Math.min(held, (lag[s.key] ?? 0) + s.frames.length);
|
||||
}
|
||||
host.status(statusOf(model));
|
||||
} finally {
|
||||
busy = false;
|
||||
host.invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
/** Options shared by the full drawing and each animation frame. */
|
||||
const drawOpts = (t) => ({ mode, nodeIndex, lag, history, t, columns: size.columns, rows: size.rows });
|
||||
const shownModel = () => (model ? { ...model, spectrum } : null);
|
||||
|
||||
/** One animation frame: repaint each mounted animated Raster in place. */
|
||||
async function animate() {
|
||||
if (!host || animating) return;
|
||||
animating = true;
|
||||
try {
|
||||
// Real time, the clock the full render reads too: pulses keep the
|
||||
// reported rates and the replay keeps up even when a frame fires late.
|
||||
const t = await host.now();
|
||||
if (!Number.isFinite(t)) return;
|
||||
const dt = Number.isFinite(lastFrame) ? Math.max(0, Math.min(1000, t - lastFrame)) : 0;
|
||||
lastFrame = t;
|
||||
for (const s of spectrum) lag[s.key] = drain(lag[s.key], s.rateHz, dt);
|
||||
const pics = picturesOf(shownModel(), drawOpts(t));
|
||||
// Fire and forget: a blit resolves only once a frame is painted, and the
|
||||
// surface folds blits between frames anyway, so nothing waits on one.
|
||||
for (const key of ANIMATED) {
|
||||
if (!pics[key]) continue;
|
||||
const { columns, rows, cells } = pics[key].grid.toRaster(key);
|
||||
Promise.resolve(host.blit({ requestId: PANE_ID, key, cells, columns, rows })).catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
animating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the refresh timer, the age tick and the animation, and fetch now, unless already polling. */
|
||||
function startPolling() {
|
||||
isOpen = true;
|
||||
if (stopTimer) return;
|
||||
stopTimer = cancelOf(host.every(intervalOf(), () => { void refresh(); }));
|
||||
stopTick = cancelOf(host.every(TICK_MS, () => host.invalidate()));
|
||||
stopAnim = cancelOf(host.every(FRAME_MS, () => { void animate(); }));
|
||||
void refresh();
|
||||
}
|
||||
|
||||
/** Switch view; the live views poll faster, so the timer restarts. */
|
||||
function setMode(next) {
|
||||
if (!MODES.includes(next) || next === mode) return;
|
||||
mode = next;
|
||||
if (isOpen && host) {
|
||||
// Each open sets the size anew: re-request rows for this view, keeping the keys.
|
||||
void host.open(openArgsOf(mode)).catch(() => undefined);
|
||||
stop();
|
||||
startPolling();
|
||||
} else host?.invalidate();
|
||||
}
|
||||
|
||||
async function open(initialMode) {
|
||||
if (MODES.includes(initialMode)) mode = initialMode;
|
||||
await host.open(openArgsOf(mode));
|
||||
stop();
|
||||
startPolling();
|
||||
}
|
||||
|
||||
async function close() {
|
||||
stop();
|
||||
isOpen = false;
|
||||
await host.close({ id: PANE_ID }).catch(() => undefined);
|
||||
}
|
||||
|
||||
on('session.start', async ($, e, next) => {
|
||||
cliPath = cliPathOf($.plugin.root);
|
||||
host = {
|
||||
run: (argv, init) => $.process.run(argv, init),
|
||||
every: (ms, fn) => $.clock.every(ms, fn),
|
||||
now: () => $.clock.now(),
|
||||
open: (pane) => $.ui.open(pane),
|
||||
close: (pane) => $.ui.close(pane),
|
||||
status: (text) => $.ui.status(text),
|
||||
invalidate: () => $.ui.invalidate('ui.render'),
|
||||
blit: (args) => $.ui.blit(args),
|
||||
};
|
||||
await $.command.register({ name: COMMAND, description: 'RuView live sensing pane: CSI nodes, CSI waterfall and radar', argumentHint: '[waterfall|radar|refresh|off]' }).catch(() => undefined);
|
||||
return next(e);
|
||||
});
|
||||
|
||||
on('command.run', { command: COMMAND }, async ($, e, next) => {
|
||||
if (!host) return next(e);
|
||||
const arg = String(e.args || '').trim().toLowerCase();
|
||||
if (arg === 'off') { await close(); host.status(undefined); return { text: 'RuView pane closed.' }; }
|
||||
if (arg === 'refresh') { await refresh(); return { text: statusOf(model) }; }
|
||||
if (MODES.includes(arg)) {
|
||||
if (isOpen) {
|
||||
if (arg === mode) await host.open(openArgsOf(mode)).catch(() => undefined);
|
||||
else setMode(arg);
|
||||
} else await open(arg);
|
||||
return { text: `RuView pane: ${arg} view.` };
|
||||
}
|
||||
if (isOpen) { await close(); return { text: 'RuView pane closed.' }; }
|
||||
await open();
|
||||
return { text: `RuView pane open: nodes on UDP ${settings.udpPort}${settings.radarHost ? `, radar ${settings.radarHost}` : ''}; keys 1 overview · 2 CSI waterfall · 3 radar.` };
|
||||
});
|
||||
|
||||
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
|
||||
if (e.requestId !== PANE_ID) return next(e);
|
||||
// A reload (hot reload, or a resumed session) re-runs register with fresh
|
||||
// variables while the engine keeps the pane open: drawing it means it is
|
||||
// open, so resume polling instead of waiting for the first capture forever.
|
||||
if (host && !stopTimer) startPolling();
|
||||
size = {
|
||||
columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 100,
|
||||
rows: e.props?.scroll?.bodyRows ?? e.viewport?.rows ?? 30,
|
||||
};
|
||||
const ui = await $.ui.resolve(e);
|
||||
const now = await $.clock.now();
|
||||
return viewOf(ui, shownModel(), {
|
||||
...drawOpts(now), refreshMs: settings.refreshMs, liveRefreshMs: settings.liveRefreshMs, busy, now,
|
||||
focused: e.props?.isFocused !== false,
|
||||
udpPort: settings.udpPort, radarConfigured: Boolean(settings.radarHost),
|
||||
onRefresh: () => { void refresh(); },
|
||||
onClose: () => { void close(); },
|
||||
onMode: (m) => setMode(m),
|
||||
onNextNode: () => { nodeIndex += 1; host?.invalidate(); },
|
||||
});
|
||||
});
|
||||
|
||||
on('ui.close', { id: PANE_ID }, async ($, e, next) => {
|
||||
stop();
|
||||
isOpen = false;
|
||||
return next(e);
|
||||
});
|
||||
|
||||
on('session.end', async ($, e, next) => {
|
||||
stop();
|
||||
return next(e);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// ruview-live drawing (ADR-377, ADR-378): the overview cards and the two
|
||||
// showcase views (CSI waterfall, radar fan + vitals charts), built from the
|
||||
// surface's own elements. Pictures are `Raster`s (terminal); other surfaces
|
||||
// get an honest note instead. `picturesOf` builds every animated picture, so
|
||||
// the first paint and each `$.ui.blit` frame share one size and one source.
|
||||
|
||||
import { pingOf, pulse, shimmer, shownFrames } from './anim.mjs';
|
||||
import { ageOf, plausibilityOf, RANGES, sparkline } from './model.mjs';
|
||||
import { colourbar, lineChart, radarFan, rgb, waterfall } from './raster.mjs';
|
||||
|
||||
const TABS = [['overview', '1', 'Overview'], ['waterfall', '2', 'CSI waterfall'], ['radar', '3', 'Radar']];
|
||||
/** Keys of the pictures the hooks module repaints with `$.ui.blit`. */
|
||||
export const ANIMATED = Object.freeze(['shimmer', 'waterfall', 'fan', 'pulse']);
|
||||
|
||||
/** Picture sizes that fit the pane body. */
|
||||
export function sizesOf(columns = 80, rows = 30) {
|
||||
const width = Math.max(24, Math.min(128, columns - 6));
|
||||
const height = Math.max(6, Math.min(26, rows - 15)); // the header, tabs, card chrome and footer take ~15 rows
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
const wrapIndex = (i, n) => ((i % n) + n) % n;
|
||||
const radarLayout = (columns, rows) => {
|
||||
const { width, height } = sizesOf(columns, rows);
|
||||
const wide = (columns ?? 80) >= 110;
|
||||
const fanW = wide ? Math.floor(width * 0.55) : width;
|
||||
// A 120° wedge is about width / (4·sin 60°) rows tall.
|
||||
const fanH = Math.max(6, Math.min(height, Math.round(fanW / 3.4) + 1));
|
||||
return { width, wide, fanW, fanH, chartW: wide ? Math.max(20, width - fanW - 6) : width };
|
||||
};
|
||||
|
||||
/**
|
||||
* Every animated picture for this mode at time `t` (ms): `{ shimmer, waterfall,
|
||||
* fan, pulse }`, each `{ grid, ... }` or null. opts: { mode, columns, rows, t,
|
||||
* nodeIndex, lag (per node key), history }.
|
||||
*/
|
||||
export function picturesOf(model, opts) {
|
||||
const mode = opts.mode || 'overview';
|
||||
const t = Number.isFinite(opts.t) ? opts.t : 0;
|
||||
const columns = opts.columns ?? 80;
|
||||
const out = { shimmer: { grid: shimmer(Math.max(8, Math.min(160, columns - 4)), t) }, waterfall: null, fan: null, pulse: null };
|
||||
if (!model) return out;
|
||||
if (mode === 'waterfall' && model.spectrum?.length) {
|
||||
const nodes = model.spectrum;
|
||||
const node = nodes[wrapIndex(opts.nodeIndex ?? 0, nodes.length)];
|
||||
const { width, height } = sizesOf(columns, opts.rows);
|
||||
out.waterfall = { ...waterfall(shownFrames(node.frames, opts.lag?.[node.key]), width, height), node, nodes };
|
||||
}
|
||||
if (mode === 'radar' && model.radar) {
|
||||
const history = opts.history || { distance: [] };
|
||||
const { fanW, fanH, chartW } = radarLayout(columns, opts.rows);
|
||||
out.fan = radarFan(fanW, fanH, { distances: history.distance, present: model.radar.present, ping: pingOf(t) });
|
||||
out.pulse = { grid: pulse(Math.min(chartW, 40), t, { heartBpm: model.radar.heartBpm, breathingBpm: model.radar.breathingBpm }) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the pane. opts: { mode, nodeIndex, refreshMs, liveRefreshMs, busy, now, t, lag, columns, rows,
|
||||
* history, udpPort, radarConfigured, onRefresh, onClose, onMode(mode), onNextNode }.
|
||||
*/
|
||||
export function viewOf(ui, model, opts) {
|
||||
const { Box, Text, Button } = ui;
|
||||
const mode = opts.mode || 'overview';
|
||||
const live = mode !== 'overview';
|
||||
const interval = live ? (opts.liveRefreshMs ?? opts.refreshMs) : opts.refreshMs;
|
||||
const t = (children, props = {}) => Text({ wrap: 'truncate-end', ...props, children });
|
||||
const row = (children, props = {}) => Box({ flexDirection: 'row', gap: 1, ...props, children: children.filter(Boolean) });
|
||||
const pics = ui.Raster ? picturesOf(model, opts) : {};
|
||||
|
||||
// Header: freshness badge and age, then the signal rule.
|
||||
const finiteAt = model && Number.isFinite(model.at);
|
||||
const age = finiteAt && Number.isFinite(opts.now) ? opts.now - model.at : NaN;
|
||||
const fresh = Number.isFinite(age) && age <= interval * 2 + 5000;
|
||||
const when = finiteAt ? new Date(model.at).toLocaleTimeString() : '—';
|
||||
const badge = !model ? t('○ STARTING', { dimColor: true, bold: true })
|
||||
: fresh ? t('● LIVE', { color: 'green', bold: true }) : t('○ STALE', { color: 'yellow', bold: true });
|
||||
const header = row([
|
||||
t('RuView', { color: 'cyan', bold: true }),
|
||||
badge,
|
||||
t(model ? `updated ${when}${ageOf(age) ? ` (${ageOf(age)})` : ''} · every ${Math.round(interval / 1000)}s` : `waiting for the first capture… · every ${Math.round(interval / 1000)}s`, { dimColor: true }),
|
||||
opts.busy ? t('⟳ refreshing', { color: 'cyan' }) : null,
|
||||
]);
|
||||
const rule = pics.shimmer ? ui.Raster(pics.shimmer.grid.toRaster('shimmer')) : null;
|
||||
|
||||
// Hotkeys reach a Pane only while it holds the keyboard; say how to give it.
|
||||
const tabs = opts.onMode ? row([
|
||||
...TABS.map(([m, key, label]) => Button({
|
||||
key: `tab-${m}`, hotkey: key, label: `${m === mode ? '▸ ' : ''}${label} (${key})`, onPress: () => opts.onMode(m),
|
||||
})),
|
||||
opts.focused === false ? t('click the pane or press ctrl+x tab to use the keys', { dimColor: true, italic: true }) : null,
|
||||
], { gap: 2 }) : null;
|
||||
|
||||
const alerts = (model?.alerts || []).map((a) => Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
|
||||
|
||||
let body;
|
||||
if (mode === 'waterfall') body = waterfallView(ui, model, opts, pics, t, row);
|
||||
else if (mode === 'radar') body = radarView(ui, model, opts, pics, t, row);
|
||||
else body = overview(ui, model, opts, t, row);
|
||||
|
||||
const footer = row([
|
||||
Button({ key: 'refresh', label: opts.busy ? 'Refreshing…' : 'Refresh (r)', hotkey: 'r', onPress: opts.onRefresh }),
|
||||
mode === 'waterfall' && opts.onNextNode && (model?.spectrum?.length ?? 0) > 1 ? Button({ key: 'next-node', label: 'Next node (n)', hotkey: 'n', onPress: opts.onNextNode }) : null,
|
||||
Button({ key: 'close', label: 'Close (c)', hotkey: 'c', onPress: opts.onClose }),
|
||||
], { gap: 2 });
|
||||
|
||||
return Box({ flexDirection: 'column', gap: 1, paddingX: 1, children: [Box({ flexDirection: 'column', children: [header, rule].filter(Boolean) }), tabs, ...alerts, body, footer].filter(Boolean) });
|
||||
}
|
||||
|
||||
function card(ui, title, subtitle, borderColor, children, t, row) {
|
||||
return ui.Box({
|
||||
flexDirection: 'column', borderStyle: 'round', borderColor, paddingX: 1, flexGrow: 1,
|
||||
children: [row([t(title, { bold: true }), subtitle ? t(subtitle, { dimColor: true }) : null]), ...children.filter(Boolean)],
|
||||
});
|
||||
}
|
||||
|
||||
function needsTerminal(ui, t) {
|
||||
return ui.Raster ? null : t('This view draws with terminal cells; open it in the terminal. The overview works everywhere.', { dimColor: true });
|
||||
}
|
||||
|
||||
function overview(ui, model, opts, t, row) {
|
||||
if (!model) return null;
|
||||
const history = opts.history || { heart: [], breathing: [], distance: [], rates: {} };
|
||||
const vital = (label, value, raw, kind, series) => {
|
||||
const warn = kind ? plausibilityOf(kind, raw) : null;
|
||||
return row([
|
||||
t(label.padEnd(10), { dimColor: true }),
|
||||
t(value.padStart(9), { bold: raw != null && !warn, color: warn ? 'yellow' : undefined }),
|
||||
t(sparkline(series), { color: 'cyan' }),
|
||||
warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null,
|
||||
]);
|
||||
};
|
||||
let radarCard = null;
|
||||
if (model.radar) {
|
||||
const r = model.radar;
|
||||
const presence = r.present == null ? t('? presence unknown', { dimColor: true })
|
||||
: r.present ? t(`● PRESENCE DETECTED${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
|
||||
: t('○ no presence', { dimColor: true });
|
||||
radarCard = card(ui, '60 GHz RADAR', r.name, r.present ? 'green' : 'gray', [
|
||||
presence,
|
||||
vital('distance', r.distance, r.distanceCm, null, history.distance),
|
||||
vital('heart', r.heart, r.heartBpm, 'heart', history.heart),
|
||||
vital('breathing', r.breathing, r.breathingBpm, 'breathing', history.breathing),
|
||||
t('device-reported values, not validated against a reference', { dimColor: true, italic: true }),
|
||||
], t, row);
|
||||
} else if (!opts.radarConfigured) {
|
||||
radarCard = card(ui, '60 GHz RADAR', 'not configured', 'gray', [
|
||||
t('Set radarHost to an ESPHome radar kit:', { dimColor: true }),
|
||||
t('claude plugin configure ruview-live', { color: 'cyan' }),
|
||||
], t, row);
|
||||
}
|
||||
const lines = model.nodes.length
|
||||
? model.nodes.map((n) => row([
|
||||
t(n.label.padEnd(10), { bold: true }),
|
||||
t(n.rate.padStart(9)),
|
||||
t(sparkline(history.rates[n.key]), { color: 'cyan' }),
|
||||
t(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : { dimColor: true }),
|
||||
t(n.rssi, { dimColor: true }),
|
||||
t(n.shape, { dimColor: true }),
|
||||
n.synthetic ? t('SYNTHETIC', { color: 'yellow', bold: true }) : null,
|
||||
]))
|
||||
: [
|
||||
t('none streaming to this machine', { dimColor: true }),
|
||||
model.noPackets ? t(`check node target IP/port · firewall UDP ${opts.udpPort ?? 5005}`, { dimColor: true }) : null,
|
||||
];
|
||||
const nodesCard = card(ui, 'CSI NODES', `UDP ${opts.udpPort ?? 5005} · ${model.decoded}/${model.packets} decoded`, model.nodes.length ? 'cyan' : 'gray', [
|
||||
...lines,
|
||||
model.nodes.length ? t('press 2 for the live CSI waterfall', { dimColor: true, italic: true }) : null,
|
||||
], t, row);
|
||||
const cards = [radarCard, nodesCard].filter(Boolean);
|
||||
return ui.Box({ flexDirection: (opts.columns ?? 80) >= 100 ? 'row' : 'column', gap: 1, children: cards });
|
||||
}
|
||||
|
||||
function waterfallView(ui, model, opts, pics, t, row) {
|
||||
const note = needsTerminal(ui, t);
|
||||
if (note) return note;
|
||||
if (!model) return t('waiting for the first capture…', { dimColor: true });
|
||||
if (!pics.waterfall) {
|
||||
return card(ui, 'CSI WATERFALL', 'no CSI frames', 'gray', [
|
||||
t('No CSI node is streaming to this machine, so there is nothing to draw.', { dimColor: true }),
|
||||
t(`Point a node at this host's UDP ${opts.udpPort ?? 5005} (provision.py --target-ip), then this view fills by itself.`, { dimColor: true }),
|
||||
], t, row);
|
||||
}
|
||||
const { grid, lo, hi, node: n, nodes } = pics.waterfall;
|
||||
const legend = colourbar(Math.min(32, grid.columns - 20));
|
||||
return card(ui, 'CSI WATERFALL', `${n.label} · ${n.subcarriers} subcarriers → ${n.bins} bins · ${n.rateHz ?? '—'} Hz${nodes.length > 1 ? ` · node ${nodes.indexOf(n) + 1}/${nodes.length}` : ''}`, n.synthetic ? 'yellow' : 'cyan', [
|
||||
n.synthetic ? t('SYNTHETIC — simulator frames, not a live measurement', { color: 'yellow', bold: true }) : t('MEASURED — live CSI amplitude received on this host', { color: 'green' }),
|
||||
ui.Raster(grid.toRaster('waterfall')),
|
||||
row([t('subcarrier →', { dimColor: true }), t('newest at the bottom · replayed at the arrival rate', { dimColor: true })], { justifyContent: 'space-between' }),
|
||||
row([t(lo.toFixed(0), { dimColor: true }), ui.Raster(legend.toRaster('legend')), t(`${hi.toFixed(0)} amplitude (5th–95th pct)`, { dimColor: true })]),
|
||||
], t, row);
|
||||
}
|
||||
|
||||
function radarView(ui, model, opts, pics, t, row) {
|
||||
const note = needsTerminal(ui, t);
|
||||
if (note) return note;
|
||||
if (!model) return t('waiting for the first capture…', { dimColor: true });
|
||||
if (!opts.radarConfigured) {
|
||||
return card(ui, 'RADAR', 'not configured', 'gray', [t('Set radarHost: claude plugin configure ruview-live', { color: 'cyan' })], t, row);
|
||||
}
|
||||
const r = model.radar;
|
||||
if (!r || !pics.fan) return card(ui, '60 GHz RADAR', 'unreachable', 'red', [t('No reading this refresh; see the alert above.', { dimColor: true })], t, row);
|
||||
const history = opts.history || { heart: [], breathing: [], distance: [] };
|
||||
const { wide, chartW } = radarLayout(opts.columns, opts.rows);
|
||||
const presence = r.present ? t(`● PRESENCE DETECTED · ${r.distance}${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
|
||||
: t('○ no presence', { dimColor: true });
|
||||
const fan = card(ui, '60 GHz RADAR', `${r.name} · range ${pics.fan.maxCm / 100} m`, r.present ? 'green' : 'gray', [
|
||||
presence,
|
||||
ui.Raster(pics.fan.grid.toRaster('fan')),
|
||||
t('range only: this kit reports distance, not bearing (arc = every bearing at that range)', { dimColor: true, italic: true }),
|
||||
], t, row);
|
||||
const chart = (label, series, kind, unit, colour, minSpan) => {
|
||||
const { grid: g, lo, hi } = lineChart(series, chartW, 3, { band: kind ? RANGES[kind] : null, colour, minSpan });
|
||||
const last = [...series].reverse().find((v) => Number.isFinite(v));
|
||||
const warn = kind ? plausibilityOf(kind, last ?? null) : null;
|
||||
return [
|
||||
row([t(label, { bold: true }), t(last == null ? '—' : `${last.toFixed(1)} ${unit}`, { color: warn ? 'yellow' : undefined }), warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null, t(`${lo.toFixed(0)}–${hi.toFixed(0)}`, { dimColor: true })]),
|
||||
ui.Raster(g.toRaster(`chart-${label}`)),
|
||||
];
|
||||
};
|
||||
const charts = card(ui, 'VITALS', 'device-reported, not validated', 'gray', [
|
||||
pics.pulse ? ui.Raster(pics.pulse.grid.toRaster('pulse')) : null,
|
||||
pics.pulse ? t('♥ beats and the gauge breathes at the reported rates (a metronome, not a waveform)', { dimColor: true, italic: true }) : null,
|
||||
...chart('heart', history.heart, 'heart', 'bpm', rgb(255, 110, 130), 10),
|
||||
...chart('breathing', history.breathing, 'breathing', 'bpm', rgb(110, 200, 255), 4),
|
||||
...chart('distance', history.distance, null, 'cm', rgb(120, 255, 170), 20),
|
||||
t('shaded = inside the plausible resting range', { dimColor: true, italic: true }),
|
||||
], t, row);
|
||||
return ui.Box({ flexDirection: wide ? 'row' : 'column', gap: 1, children: [fan, charts] });
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, mock, test, tier } from 'claude-code/testing'
|
||||
|
||||
tier('user')
|
||||
|
||||
const CAPTURE = {
|
||||
ok: true,
|
||||
packets: 120,
|
||||
decodedPackets: 120,
|
||||
heartbeatOnlySenders: [{ address: '192.168.1.67', heartbeats: 30 }],
|
||||
nodes: [
|
||||
{ source: 'esp32', nodeId: 42, csiRateHz: 4.7, csiLossFraction: 0, rssiMean: -63, csi: { shape: '1x64' } },
|
||||
{ source: 'realtek', nodeId: 3, csiRateHz: 348, csiLossFraction: 0.08, rssiMean: -39, csi: { shape: '1x52' } },
|
||||
],
|
||||
}
|
||||
|
||||
describe('register', () => {
|
||||
test('/ruview opens the pane, refreshes from the harness CLI, and stops on close', async ($, on) => {
|
||||
const runs: (readonly string[])[] = []
|
||||
const opened: string[] = []
|
||||
const closed: string[] = []
|
||||
const statuses: (string | undefined)[] = []
|
||||
const clock = mock.clock(on)
|
||||
on('session.start', ($, e) => ({ cwd: e.cwd }))
|
||||
on('command.register', ($, e) => ({ value: { command: e.name } }))
|
||||
on('process.run', ($, e) => {
|
||||
runs.push(e.argv)
|
||||
return { value: { exitCode: 0, stdout: JSON.stringify(CAPTURE), stderr: '' } }
|
||||
})
|
||||
const focusAsked: (true | undefined)[] = []
|
||||
on('ui.open', ($, e) => {
|
||||
opened.push(e.id)
|
||||
focusAsked.push(e.focus)
|
||||
return { value: undefined }
|
||||
})
|
||||
on('ui.close', ($, e) => {
|
||||
closed.push(e.id)
|
||||
return { value: undefined }
|
||||
})
|
||||
on('ui.status', ($, e) => {
|
||||
statuses.push(e.text)
|
||||
return { value: undefined }
|
||||
})
|
||||
on('ui.invalidate', () => ({ value: undefined }))
|
||||
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 5; i++) await clock.settle()
|
||||
}
|
||||
|
||||
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
|
||||
const { text } = await $.command.run({ command: 'ruview', args: '', origin: { kind: 'composer' } })
|
||||
await settle()
|
||||
|
||||
expect(text).toContain('RuView pane open')
|
||||
expect(opened).toEqual(['ruview-live'])
|
||||
expect(focusAsked).toEqual([true]) // hotkeys reach a Pane only while it holds the keyboard
|
||||
expect(runs.length).toBeGreaterThanOrEqual(1)
|
||||
const argv = runs[0] ?? []
|
||||
expect(argv[0]).toBe('node')
|
||||
expect(String(argv[1])).toContain('bin/cli.js')
|
||||
expect(argv.slice(2)).toEqual(['esp32', '--seconds', '3', '--udp-port', '5005', '--json'])
|
||||
expect(statuses.at(-1)).toBe('RuView · 2 nodes · 1 alert')
|
||||
|
||||
const beforeTicks = runs.length
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await clock.advance(15_000)
|
||||
await settle()
|
||||
}
|
||||
expect(runs.length).toBeGreaterThan(beforeTicks)
|
||||
|
||||
await $.command.run({ command: 'ruview', args: '', origin: { kind: 'composer' } })
|
||||
await settle()
|
||||
expect(closed).toEqual(['ruview-live'])
|
||||
const afterClose = runs.length
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await clock.advance(15_000)
|
||||
await settle()
|
||||
}
|
||||
expect(runs.length).toBe(afterClose)
|
||||
})
|
||||
|
||||
test('a pane drawn after a reload resumes polling instead of waiting forever', async ($, on) => {
|
||||
// Seen live: a hot reload re-runs register with fresh variables while the
|
||||
// engine keeps the pane open, so the drawing sat at "waiting for the first
|
||||
// capture…" with no timer running.
|
||||
const runs: (readonly string[])[] = []
|
||||
const clock = mock.clock(on)
|
||||
on('session.start', ($, e) => ({ cwd: e.cwd }))
|
||||
on('command.register', ($, e) => ({ value: { command: e.name } }))
|
||||
on('process.run', ($, e) => {
|
||||
runs.push(e.argv)
|
||||
return { value: { exitCode: 0, stdout: JSON.stringify(CAPTURE), stderr: '' } }
|
||||
})
|
||||
on('ui.status', () => ({ value: undefined }))
|
||||
on('ui.invalidate', () => ({ value: undefined }))
|
||||
on('ui.blit', () => ({ value: {} }))
|
||||
const PANE = {
|
||||
title: 'RuView',
|
||||
isFocused: false,
|
||||
bodyColumns: 100,
|
||||
placement: 'dock' as const,
|
||||
scroll: { offset: 0, bodyRows: 30 },
|
||||
view: {},
|
||||
}
|
||||
|
||||
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
|
||||
// No /ruview in this module instance: the engine draws the pane it kept open.
|
||||
const first = await $.ui.mount({ plugin: 'ruview-live', surface: 'terminal', component: 'Pane', requestId: 'ruview-live', props: PANE })
|
||||
for (let i = 0; i < 5; i++) await clock.settle()
|
||||
expect(runs.length).toBeGreaterThanOrEqual(1)
|
||||
await first.unmount()
|
||||
|
||||
const again = await $.ui.mount({ plugin: 'ruview-live', surface: 'terminal', component: 'Pane', requestId: 'ruview-live', props: PANE })
|
||||
expect(await again.find({ type: 'Text', text: /NODES/ })).toBeDefined()
|
||||
expect(await again.find({ type: 'Text', text: /waiting for the first capture/ })).toBeUndefined()
|
||||
expect(await again.find({ type: 'Text', text: /ctrl\+x tab/ })).toBeDefined() // unfocused: says how to focus
|
||||
const polled = runs.length
|
||||
// Step time as a live session does (the animation timer runs every 80 ms).
|
||||
for (let s = 0; s < 16; s++) {
|
||||
await clock.advance(1_000)
|
||||
for (let i = 0; i < 5; i++) await clock.settle()
|
||||
}
|
||||
expect(runs.length).toBeGreaterThan(polled)
|
||||
await again.unmount()
|
||||
})
|
||||
|
||||
test('the waterfall tab asks for spectrum frames, draws a Raster, and animates it with blits', async ($, on) => {
|
||||
const runs: (readonly string[])[] = []
|
||||
const blits: string[] = []
|
||||
const clock = mock.clock(on)
|
||||
const frames = Array.from({ length: 40 }, (_, k) => [k, 2 * k, 3 * k, 4 * k])
|
||||
on('session.start', ($, e) => ({ cwd: e.cwd }))
|
||||
on('command.register', ($, e) => ({ value: { command: e.name } }))
|
||||
on('process.run', ($, e) => {
|
||||
runs.push(e.argv)
|
||||
const spectrum = e.argv.includes('--spectrum')
|
||||
? [{ source: 'esp32', nodeId: 42, subcarriers: 64, bins: 4, rateHz: 20, synthetic: false, frames }]
|
||||
: undefined
|
||||
return { value: { exitCode: 0, stdout: JSON.stringify({ ...CAPTURE, spectrum }), stderr: '' } }
|
||||
})
|
||||
on('ui.status', () => ({ value: undefined }))
|
||||
// No ui.invalidate stub here: the mounted drawing follows the mod's invalidations.
|
||||
on('ui.blit', ($, e) => {
|
||||
blits.push(e.key)
|
||||
return { value: {} }
|
||||
})
|
||||
const PANE = { title: 'RuView', isFocused: true, bodyColumns: 120, placement: 'dock' as const, scroll: { offset: 0, bodyRows: 34 }, view: {} }
|
||||
const settle = async () => {
|
||||
for (let i = 0; i < 5; i++) await clock.settle()
|
||||
}
|
||||
|
||||
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
|
||||
const pane = await $.ui.mount({ plugin: 'ruview-live', surface: 'terminal', component: 'Pane', requestId: 'ruview-live', props: PANE })
|
||||
await settle()
|
||||
expect(await pane.find({ key: 'tab-waterfall' })).toBeDefined()
|
||||
await pane.press({ key: 'tab-waterfall' })
|
||||
await settle()
|
||||
expect(runs.at(-1)).toContain('--spectrum')
|
||||
expect(runs.at(-1)).toContain('--spectrum-bins')
|
||||
|
||||
expect(await pane.find({ type: 'Text', text: /MEASURED/ })).toBeDefined()
|
||||
expect(await pane.find({ type: 'Raster', key: 'waterfall' })).toBeDefined()
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await clock.advance(100)
|
||||
await settle()
|
||||
}
|
||||
expect(blits).toContain('waterfall')
|
||||
expect(blits).toContain('shimmer')
|
||||
await pane.unmount()
|
||||
})
|
||||
|
||||
test('/ruview refresh with no nodes reports the honest failure in the status', async ($, on) => {
|
||||
const statuses: (string | undefined)[] = []
|
||||
mock.clock(on)
|
||||
on('session.start', ($, e) => ({ cwd: e.cwd }))
|
||||
on('command.register', ($, e) => ({ value: { command: e.name } }))
|
||||
on('process.run', () => ({ value: { exitCode: 1, stdout: JSON.stringify({ ok: false, reason: 'no_packets', packets: 0 }), stderr: '' } }))
|
||||
on('ui.status', ($, e) => {
|
||||
statuses.push(e.text)
|
||||
return { value: undefined }
|
||||
})
|
||||
on('ui.invalidate', () => ({ value: undefined }))
|
||||
|
||||
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
|
||||
const { text } = await $.command.run({ command: 'ruview', args: 'refresh', origin: { kind: 'composer' } })
|
||||
|
||||
expect(text).toBe('RuView · 0 nodes')
|
||||
expect(statuses.at(-1)).toBe('RuView · 0 nodes')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.claude-plugin/types/tsconfig.json"
|
||||
}
|
||||
Generated
+11
-3
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "@ruvnet/ruview",
|
||||
"version": "0.5.1",
|
||||
"version": "0.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@ruvnet/ruview",
|
||||
"version": "0.5.1",
|
||||
"version": "0.9.1",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ruview": "bin/cli.js"
|
||||
"ruview-harness": "bin/cli.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@metaharness/darwin": "0.8.0",
|
||||
@@ -18,6 +18,14 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@ruvnet/ruview-kernel": ">=0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@ruvnet/ruview-kernel": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@metaharness/darwin": {
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
{
|
||||
"name": "@ruvnet/ruview",
|
||||
"version": "0.5.1",
|
||||
"description": "RuView WiFi-sensing operator harness — onboard, calibrate, verify, enforce evidence guardrails, and read Cognitum Spaces through explicitly granted OAuth.",
|
||||
"version": "0.9.1",
|
||||
"description": "RuView WiFi-sensing operator harness \u2014 CLI, MCP server, and SDK for host device access (ESP32 CSI nodes, 60/24 GHz mmWave, LiDAR), SSH remote hosts, diagnostics, verified ESP32 flashing, evidence-gated training, calibration, and Cognitum Spaces.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"ruview": "bin/cli.js"
|
||||
"ruview-harness": "bin/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/tools.js",
|
||||
"./sdk": {
|
||||
"types": "./src/sdk.d.ts",
|
||||
"default": "./src/sdk.js"
|
||||
},
|
||||
"./guardrails": "./src/guardrails.js",
|
||||
"./brain": "./src/brain.js",
|
||||
"./guidance": "./src/guidance.js",
|
||||
"./hosts": "./src/hosts/index.js"
|
||||
"./hosts": "./src/hosts/index.js",
|
||||
"./doctor": "./src/doctor.js",
|
||||
"./firmware": "./src/firmware.js",
|
||||
"./training": "./src/training.js",
|
||||
"./devices": "./src/devices/registry.js",
|
||||
"./remote": "./src/remote.js",
|
||||
"./cli": "./src/cli.js",
|
||||
"./cli-ui": "./src/cli-ui.js",
|
||||
"./mcp": "./src/mcp-server.js",
|
||||
"./mcp-http": "./src/mcp-http.js",
|
||||
"./ui": "./src/ui/console-widget.js",
|
||||
"./esphome": "./src/devices/esphome.js",
|
||||
"./kernel": "./src/kernel.js"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
@@ -23,13 +39,15 @@
|
||||
"brain/",
|
||||
"flywheel/",
|
||||
"scripts/",
|
||||
"mod/.claude-plugin/",
|
||||
"mod/hooks/",
|
||||
"CLAUDE.md",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"test:security": "node --test test/hosts.test.mjs test/brain.test.mjs test/policy.test.mjs test/spaces.test.mjs",
|
||||
"test:security": "node --test test/hosts.test.mjs test/brain.test.mjs test/policy.test.mjs test/spaces.test.mjs test/kernel.test.mjs test/firmware.test.mjs test/training.test.mjs test/devices.test.mjs test/csi-nodes.test.mjs test/remote.test.mjs test/mcp-apps.test.mjs test/cli-ui.test.mjs test/esphome.test.mjs",
|
||||
"doctor": "node ./bin/cli.js doctor",
|
||||
"mcp": "node ./bin/cli.js mcp start",
|
||||
"brain:verify": "node ./bin/cli.js brain verify",
|
||||
@@ -39,7 +57,8 @@
|
||||
"manifest:update": "node ./scripts/update-manifest.mjs",
|
||||
"manifest:verify": "node ./scripts/verify-manifest.mjs",
|
||||
"prepack": "node ./scripts/sync-skills.mjs && node ./scripts/update-manifest.mjs --quiet && node ./scripts/verify-manifest.mjs --quiet",
|
||||
"prepublishOnly": "npm test && node ./scripts/verify-manifest.mjs"
|
||||
"prepublishOnly": "npm test && node ./scripts/verify-manifest.mjs",
|
||||
"test:e2e:devices": "node --test test/e2e/devices.e2e.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"wifi-sensing",
|
||||
@@ -57,8 +76,26 @@
|
||||
"claude-code",
|
||||
"ambient-intelligence",
|
||||
"cognitum-spaces",
|
||||
"oauth"
|
||||
"oauth",
|
||||
"firmware-flashing",
|
||||
"esptool",
|
||||
"sdk",
|
||||
"model-training",
|
||||
"mmwave",
|
||||
"lidar",
|
||||
"rplidar",
|
||||
"mr60bha2",
|
||||
"ld2410",
|
||||
"ssh"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@ruvnet/ruview-kernel": ">=0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@ruvnet/ruview-kernel": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -80,5 +117,6 @@
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
},
|
||||
"types": "./src/sdk.d.ts"
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: device-access
|
||||
description: Reach RuView sensing hardware from the machine it is attached to — ESP32 CSI nodes (serial + UDP stream), 60 GHz MR60BHA2 / 24 GHz LD2410 mmWave radars, RPLIDAR and iPhone LiDAR — and from other machines over SSH, read-only.
|
||||
---
|
||||
|
||||
# device-access
|
||||
|
||||
Hardware tools must run **on the host the device is plugged into**. A cloud
|
||||
agent cannot see your USB ports. Run `npx @ruvnet/ruview` locally, start a
|
||||
local Claude Code session there (`claude remote-control` in the repo if you
|
||||
want to drive it from the Claude app), or add the machine as an SSH host.
|
||||
|
||||
## 1. Find devices
|
||||
|
||||
```
|
||||
npx @ruvnet/ruview doctor --group python,serial,devices,remote
|
||||
npx @ruvnet/ruview devices # USB VID:PID → likely ESP32 / mmWave / RPLIDAR + confirm command
|
||||
```
|
||||
|
||||
## 2. Read each modality
|
||||
|
||||
| Device | Command | Confirms |
|
||||
|---|---|---|
|
||||
| ESP32 node (USB) | `ruview doctor --port <p> --probe`, `ruview monitor --port <p>` | chip id; CSI callbacks in the serial log (the monitor never resets the node; C6/S3 USB-Serial/JTAG builds log on the native USB port) |
|
||||
| ESP32 / Realtek node (network) | `ruview esp32 --seconds 10 [--udp-port 5005]` | per-node CSI rate, sequence loss, RSSI, CSI shapes, device vitals; Realtek RAC1 CSI and heartbeat-only senders |
|
||||
| Realtek RTL8721Dx (USB, PL2303) | `ruview monitor --port <p> --baud 1500000` | `RUVIEW_CSI: frame #` lines in the Ameba log |
|
||||
| Live CSI → kernel | `ruview esp32 --seconds 45 --analyze [--node-id N] [--backend napi]` | kernel vitals summary at the measured frame rate (no reference measurement) |
|
||||
| 60 GHz MR60BHA2 | `ruview mmwave --port <p> --model mr60bha2` | valid frames, checksum errors, presence/distance/device HR+BR |
|
||||
| 24 GHz LD2410 | `ruview mmwave --port <p> --model ld2410` | target state and distances |
|
||||
| RPLIDAR | `ruview lidar --source rplidar --port <p> [--baud 115200]` | points, revolutions, range, angular coverage |
|
||||
| iPhone LiDAR | `export RUVIEW_LIDAR_TOKEN=…; ruview lidar --source iphone --url ws://HOST:8787/ws/lidar` | depth frame rate, confident fraction, median depth |
|
||||
|
||||
The UDP capture cannot share port 5005 with a running sensing server; stop
|
||||
the server or have the nodes target another port.
|
||||
|
||||
## 3. Other machines (SSH, read-only)
|
||||
|
||||
```
|
||||
npx @ruvnet/ruview hosts add --name lab-pi --ssh ruv@lab-pi.local
|
||||
ssh ruv@lab-pi.local true # pin the host key once
|
||||
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_devices_scan
|
||||
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_mmwave_read --args-json '{"port":"/dev/ttyUSB0"}'
|
||||
```
|
||||
|
||||
Remote hosts run a pinned `@ruvnet/ruview` and accept only read-only tools.
|
||||
Flash, train, and calibrate on the host itself.
|
||||
|
||||
## Evidence
|
||||
|
||||
Device readouts are `MEASURED` *readings*, not accuracy results. mmWave
|
||||
heart/breathing values are the radar's own estimates. Hardware validation
|
||||
still means a captured log or stream from real silicon, recorded with the
|
||||
command that produced it.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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.
|
||||
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, cross-platform checksum-verified esptool flashing, NVS/WiFi/channel/MAC-filter overrides, boot-log evidence.
|
||||
---
|
||||
|
||||
# provision-node
|
||||
@@ -19,17 +19,23 @@ Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56
|
||||
|
||||
## 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:
|
||||
Use a release flash bundle (or `release_bins/<variant>`, or an ESP-IDF `build/`).
|
||||
The harness flashes on Windows, macOS, and Linux through `python -m esptool`:
|
||||
|
||||
```
|
||||
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
|
||||
npx @ruvnet/ruview doctor --group python,serial,firmware # esptool, pyserial, ports, bundle checksums
|
||||
npx @ruvnet/ruview flash-plan --port <PORT> --bundle <dir> --variant s3-8mb
|
||||
npx @ruvnet/ruview flash --port <PORT> --bundle <dir> --variant s3-8mb --confirm
|
||||
```
|
||||
|
||||
(`ruview_node_flash` returns the exact pinned command rather than running an
|
||||
unattended flash.)
|
||||
`flash` verifies SHA256SUMS, refuses a chip that does not match the variant
|
||||
(`esptool chip_id`), writes 0x0 / 0x8000 / 0xf000 / 0x20000 (NVS preserved),
|
||||
then captures a boot log. `hardwareValidated: true` requires CSI callbacks in
|
||||
that log. Over MCP, `ruview_node_flash` needs the `hardware-write` grant and
|
||||
`confirm: true`.
|
||||
|
||||
Building from source still uses ESP-IDF v5.4 (Docker or the Windows subprocess
|
||||
flow; Git Bash/MSYS is unsupported, so strip `MSYSTEM*` env vars).
|
||||
|
||||
## 3. Provision
|
||||
|
||||
|
||||
@@ -24,6 +24,17 @@ or temporal leakage. Example honest result (ADR-181):
|
||||
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
|
||||
(honest about what's executing).
|
||||
|
||||
## Run it through the harness (ADR-371)
|
||||
|
||||
```
|
||||
npx @ruvnet/ruview train-plan --mode pose-smoke # command, cwd, outputs; runs nothing
|
||||
npx @ruvnet/ruview train --mode pose-smoke --confirm # SYNTHETIC pipeline smoke (libtorch 2.11 for tch 0.24)
|
||||
npx @ruvnet/ruview train --mode pose --data-dir <in-repo MM-Fi dir> --confirm
|
||||
npx @ruvnet/ruview train-gate --file eval-report.json # mean-pose baseline + leakage gate
|
||||
```
|
||||
|
||||
The gate returns the only acceptable claim sentence. Quote nothing it fails.
|
||||
|
||||
## Before you publish a number
|
||||
|
||||
1. Run the mean-pose baseline on the same split.
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Terminal rendering for the CLI (ADR-375). Used only when stdout is a TTY and
|
||||
// `--json` is absent; pipes and scripts keep receiving exact JSON. Respects
|
||||
// NO_COLOR and TERM=dumb. Pure functions of (tool, result) so tests can pin
|
||||
// the output byte-for-byte.
|
||||
|
||||
const ESC = '\x1b[';
|
||||
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
||||
|
||||
export function colorEnabled(stream = process.stdout, env = process.env) {
|
||||
return Boolean(stream && stream.isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb';
|
||||
}
|
||||
|
||||
export function createStyle(on) {
|
||||
const wrap = (code) => (s) => (on ? `${ESC}${code}m${s}${ESC}0m` : String(s));
|
||||
return { acid: wrap('38;5;155'), muted: wrap('38;5;245'), warn: wrap('38;5;221'), bad: wrap('38;5;203'), bold: wrap('1') };
|
||||
}
|
||||
|
||||
const visible = (s) => String(s).replace(ANSI_RE, '').length;
|
||||
const pad = (s, n) => String(s) + ' '.repeat(Math.max(0, n - visible(s)));
|
||||
const num = (v, d = 0) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
|
||||
const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(v > 0 && v < 0.01 ? 2 : 1)}%` : '—');
|
||||
const BARS = '▁▂▃▄▅▆▇█';
|
||||
|
||||
/** Unicode bar of `width` cells for a 0..1 fraction. */
|
||||
export function bar(frac, width = 10) {
|
||||
const f = Math.max(0, Math.min(1, Number(frac) || 0));
|
||||
const full = Math.floor(f * width);
|
||||
return '█'.repeat(full) + (full < width ? (f * width - full >= 0.5 ? '▌' : ' ') : '') + ' '.repeat(Math.max(0, width - full - 1));
|
||||
}
|
||||
|
||||
/** Sparkline of a numeric series (min..max scaled). */
|
||||
export function sparkline(values) {
|
||||
const v = values.filter((x) => typeof x === 'number' && Number.isFinite(x));
|
||||
if (!v.length) return '';
|
||||
const lo = Math.min(...v); const hi = Math.max(...v);
|
||||
return v.map((x) => BARS[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
|
||||
}
|
||||
|
||||
/** Align rows (arrays of cells) into columns. */
|
||||
export function table(rows, style, gap = 2) {
|
||||
const widths = [];
|
||||
for (const row of rows) row.forEach((c, i) => { widths[i] = Math.max(widths[i] || 0, visible(c)); });
|
||||
return rows.map((row, r) => {
|
||||
const line = row.map((c, i) => (i === row.length - 1 ? String(c) : pad(c, widths[i] + gap))).join('').trimEnd();
|
||||
return r === 0 ? style.muted(line) : line;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function head(view, r, s) {
|
||||
const measured = r.evidence && /^MEASURED/.test(r.evidence);
|
||||
const status = r.ok === false
|
||||
? s.bad(`✖ ${String(r.reason || 'failed').replace(/_/g, ' ')}`)
|
||||
: measured ? s.acid('● MEASURED') : s.acid('✔ ok');
|
||||
return `${s.muted(`RUVIEW / ${view}`)} ${status}`;
|
||||
}
|
||||
|
||||
function failureLines(r, s) {
|
||||
if (r.ok !== false) return [];
|
||||
const out = [];
|
||||
for (const k of ['detail', 'remedy', 'hint']) if (r[k]) out.push(`${s.muted(k === 'remedy' ? 'fix' : k)} ${r[k]}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderCapture(r, s, history) {
|
||||
const lines = [head('NODE STREAM', r, s), ...failureLines(r, s)];
|
||||
lines.push(s.muted(`packets ${r.packets ?? '—'} · decoded ${r.decodedPackets ?? '—'} · csi nodes ${r.csiNodes ?? '—'} · ${r.seconds ?? '—'} s · ${r.listen ?? ''}`));
|
||||
for (const hb of r.heartbeatOnlySenders || []) lines.push(s.warn(`! heartbeat-only: ${hb.address} (${hb.heartbeats} heartbeats, no CSI)`));
|
||||
const nodes = r.nodes || [];
|
||||
if (nodes.length) {
|
||||
const maxHz = Math.max(...nodes.map((n) => n.csiRateHz || 0), 1);
|
||||
const rows = [['SOURCE', 'NODE', 'CSI RATE', '', 'LOSS', 'RSSI', 'SHAPE', 'SEQ', history ? 'TREND' : '']];
|
||||
for (const n of nodes) {
|
||||
const key = `${n.source}:${n.nodeId}`;
|
||||
const csi = n.csi || {};
|
||||
const seq = [n.seqReordered && `${n.seqReordered}r`, n.seqStrays && `${n.seqStrays}s`, n.seqResyncs && `${n.seqResyncs}R`].filter(Boolean).join(' ') || '—';
|
||||
const loss = n.csiLossFraction;
|
||||
rows.push([
|
||||
n.source === 'realtek' ? 'realtek' : 'esp32', String(n.nodeId),
|
||||
`${num(n.csiRateHz, 1)} Hz`, s.acid(bar((n.csiRateHz || 0) / maxHz, 8)),
|
||||
loss == null ? '—' : loss > 0.05 ? s.warn(pct(loss)) : pct(loss),
|
||||
`${num(n.rssiMean, 1)} dBm`,
|
||||
`${csi.shape || '—'}${n.csiShapes ? ` +${n.csiShapes.length - 1}` : ''}${csi.synthetic ? s.warn(' SYNTHETIC') : ''}`,
|
||||
seq, history ? s.acid(sparkline(history.get(key) || [])) : '',
|
||||
]);
|
||||
}
|
||||
lines.push('', table(rows, s));
|
||||
}
|
||||
const a = r.analysis;
|
||||
if (a) {
|
||||
lines.push('');
|
||||
if (a.ok === false) lines.push(s.warn(`kernel analysis: ${a.reason}${a.detail ? ` — ${a.detail}` : ''}`));
|
||||
else {
|
||||
const last = a.summary?.last || {}; const hr = last.heart || {}; const rr = last.respiratory || {};
|
||||
lines.push(`${s.bold('kernel analysis')} ${s.muted(`${a.backend} · ${a.integrity} · ${a.input?.frames} frames @ ${num(a.input?.sampleRateHz, 1)} Hz`)}`);
|
||||
lines.push(` heart ${num(hr.bpm, 1)} bpm (${hr.status || '—'}) breathing ${rr.status === 'unavailable' ? 'unavailable' : `${num(rr.bpm, 1)} bpm (${rr.status || '—'})`}`);
|
||||
lines.push(s.muted(` ${a.note || 'estimates without a reference measurement'}`));
|
||||
}
|
||||
}
|
||||
if (r.unknownPackets) lines.push(s.muted(`unknown packets ${r.unknownPackets}: ${Object.keys(r.unknownMagics || {}).join(', ')}`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderDevices(r, s) {
|
||||
const lines = [head('DEVICES', r, s), ...failureLines(r, s)];
|
||||
const rows = [['PORT', 'BRIDGE', 'LIKELY', 'CONFIRM WITH']];
|
||||
for (const d of (r.devices || []).filter((x) => !x.builtin)) {
|
||||
rows.push([d.port, d.bridge || d.usb || 'unknown', (d.likelyRoles || []).join('/') || '—', (d.confirmWith || [])[0] || '—']);
|
||||
}
|
||||
if (rows.length > 1) lines.push('', table(rows, s));
|
||||
if (r.note) lines.push('', s.muted(r.note));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderMonitor(r, s) {
|
||||
const lines = [head('SERIAL MONITOR', r, s), ...failureLines(r, s)];
|
||||
if (r.ok !== false || r.lines !== undefined) lines.push(`csi log lines ${r.csi_callbacks ?? 0} · console lines ${r.lines ?? '—'} · ${r.baud ?? '—'} baud · reset on open: ${r.reset_on_open === false ? 'no' : 'unknown'}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderFlash(r, s) {
|
||||
const lines = [head(r.dryRun ? 'FLASH PLAN' : 'FLASH', r, s), ...failureLines(r, s)];
|
||||
const p = r.plan;
|
||||
if (p) {
|
||||
lines.push(s.muted(`${p.chip} · ${p.flashSize} · ${p.port} · checksums ${p.checksums || 'none'}`));
|
||||
lines.push('', table([['OFFSET', 'ROLE', 'BYTES', 'INTEGRITY'], ...(p.images || []).map((i) => [i.offset, i.role, String(i.bytes), i.integrity === 'verified' ? s.acid(i.integrity) : s.warn(i.integrity)])], s));
|
||||
}
|
||||
if (r.bootLog) lines.push('', `boot log: ${r.bootLog.lines ?? 0} lines · CSI callbacks ${r.bootLog.csiCallbacks ?? 0} · panic ${r.bootLog.panic ? s.bad('yes') : 'no'}`);
|
||||
if (r.evidence) lines.push(s.acid(r.evidence));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderMmwave(r, s) {
|
||||
const lines = [head('RADAR', r, s), ...failureLines(r, s)];
|
||||
if (r.ok === false) {
|
||||
for (const a of r.attempts || []) lines.push(s.muted(`tried ${a.model}: ${a.bytes} bytes, ${a.frames} frames`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
const d = r.device || {};
|
||||
lines.push(s.muted(r.source === 'esphome'
|
||||
? `ESPHome ${d.esphomeVersion || '?'} · ${d.name || r.host} · ${d.project || ''}${d.projectVersion ? ` ${d.projectVersion}` : ''} · ${r.stateUpdates} updates in ${r.seconds} s`
|
||||
: `${r.model} (${r.band}) · ${r.frames} frames · ${r.checksumErrors} checksum errors · ${r.frameRateHz} Hz`));
|
||||
const present = r.presentNow ?? (r.presentFraction == null ? null : r.presentFraction >= 0.5);
|
||||
lines.push('', `${s.bold('presence')} ${present == null ? '—' : present ? s.acid('● detected') : s.muted('○ none')}${r.presentFraction == null ? '' : s.muted(` (${(r.presentFraction * 100).toFixed(0)}% of reports)`)}${r.targetsMax != null ? s.muted(` targets ${r.targetsMax}`) : ''}`);
|
||||
lines.push(`${s.bold('distance')} ${r.distanceCmMean == null ? '—' : `${num(r.distanceCmMean, 1)} cm`}`);
|
||||
lines.push(`${s.bold('heart')} ${r.heartBpmMean == null ? '—' : `${num(r.heartBpmMean, 1)} bpm`} ${s.bold('breathing')} ${r.breathingBpmMean == null ? '—' : `${num(r.breathingBpmMean, 1)} bpm`}`);
|
||||
if (r.entities) {
|
||||
lines.push('', table([['ENTITY', 'LAST', 'UPDATES'], ...r.entities.map((e) => [e.name, e.last == null ? '—' : `${typeof e.last === 'number' ? num(e.last, 2) : e.last}${e.unit ? ` ${e.unit}` : ''}`, String(e.updates)])], s));
|
||||
}
|
||||
lines.push('', s.muted('device-reported values (computed by the radar firmware), not validated against a reference'));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderGeneric(tool, r, s) {
|
||||
const view = String(tool || 'result').replace(/^ruview_/, '').replace(/_/g, ' ').toUpperCase();
|
||||
const lines = [head(view, r, s), ...failureLines(r, s)];
|
||||
const scalars = Object.entries(r).filter(([k, v]) => !['ok', 'reason', 'detail', 'remedy', 'hint'].includes(k) && (v === null || ['string', 'number', 'boolean'].includes(typeof v)));
|
||||
for (const [k, v] of scalars.slice(0, 16)) lines.push(`${s.muted(pad(k, 18))}${String(v).length > 140 ? `${String(v).slice(0, 137)}…` : v}`);
|
||||
const nested = Object.keys(r).filter((k) => r[k] && typeof r[k] === 'object');
|
||||
if (nested.length) lines.push(s.muted(`… ${nested.join(', ')} (use --json for the full result)`));
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a tool result for a terminal. `history` (Map of node key → rates)
|
||||
* adds trend sparklines in watch mode.
|
||||
*/
|
||||
export function renderResult(tool, r, { color = false, history } = {}) {
|
||||
const s = createStyle(color);
|
||||
if (!r || typeof r !== 'object') return String(r);
|
||||
if (tool === 'ruview_esp32_capture' || r.nodes) return renderCapture(r, s, history);
|
||||
if (tool === 'ruview_devices_scan') return renderDevices(r, s);
|
||||
if (tool === 'ruview_node_monitor') return renderMonitor(r, s);
|
||||
if (tool === 'ruview_mmwave_read') return renderMmwave(r, s);
|
||||
if (tool === 'ruview_node_flash' || tool === 'ruview_firmware_plan') return renderFlash(r, s);
|
||||
return renderGeneric(tool, r, s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a countdown on stderr while `fn` runs (TTY only). Returns fn's result.
|
||||
*/
|
||||
export async function withProgress(label, seconds, fn, stream = process.stderr) {
|
||||
if (!stream.isTTY || !seconds) return fn();
|
||||
const frames = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏';
|
||||
const start = Date.now();
|
||||
let i = 0;
|
||||
const draw = () => {
|
||||
const left = Math.max(0, seconds - (Date.now() - start) / 1000);
|
||||
stream.write(`\r${frames[i++ % frames.length]} ${label} · ${left.toFixed(0)}s left `);
|
||||
};
|
||||
draw();
|
||||
const timer = setInterval(draw, 120);
|
||||
try { return await fn(); } finally { clearInterval(timer); stream.write('\r\x1b[2K'); }
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
// 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/…) and local host adapters are
|
||||
// pure Node and run with zero runtime dependencies. Modules load on demand so
|
||||
// `--help`/`--version`/`skills` never pay for the tool registry (ADR-375).
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { colorEnabled, renderResult, withProgress } from './cli-ui.js';
|
||||
|
||||
const NAME = 'ruview';
|
||||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const SKILLS_DIR = join(ROOT, 'skills');
|
||||
const tools = () => import('./tools.js');
|
||||
|
||||
// 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',
|
||||
guidance: 'ruview_guidance',
|
||||
spaces: 'ruview_spaces_list',
|
||||
kernel: 'ruview_kernel_selftest',
|
||||
'flash-plan': 'ruview_firmware_plan',
|
||||
ports: 'ruview_firmware_ports',
|
||||
train: 'ruview_train',
|
||||
'train-plan': 'ruview_train_plan',
|
||||
'train-gate': 'ruview_train_gate',
|
||||
devices: 'ruview_devices_scan',
|
||||
esp32: 'ruview_esp32_capture',
|
||||
mmwave: 'ruview_mmwave_read',
|
||||
lidar: 'ruview_lidar_read',
|
||||
};
|
||||
|
||||
// Verbs whose kebab-case flags map 1:1 onto snake_case schema fields (ADR-369).
|
||||
const SNAKE_VERBS = new Set(['flash', 'flash-plan', 'train', 'train-plan', 'train-gate', 'devices', 'esp32', 'mmwave', 'lidar']);
|
||||
const NUMERIC_FLAGS = new Set(['baud', 'boot_log_seconds', 'samples', 'model_score', 'baseline_score', 'n_test', 'seconds', 'udp_port', 'max_packets', 'max_frames', 'node_id', 'analyze_max_frames', 'api_port', 'spectrum_bins', 'spectrum_frames']);
|
||||
const BOOLEAN_FLAGS = new Set(['confirm', 'cuda', 'allow_unverified', 'analyze', 'spectrum']);
|
||||
// Presentation-only flags never reach a tool schema.
|
||||
const UI_FLAGS = new Set(['json', 'watch', 'interval']);
|
||||
|
||||
function toSchemaArgs(flags) {
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(flags)) {
|
||||
const k = key.replaceAll('-', '_');
|
||||
if (UI_FLAGS.has(k)) continue;
|
||||
if (NUMERIC_FLAGS.has(k)) out[k] = Number(value);
|
||||
else if (BOOLEAN_FLAGS.has(k)) out[k] = value === true || value === 'true';
|
||||
else if (k === 'train_subjects' || k === 'test_subjects') out[k] = String(value).split(',').filter(Boolean);
|
||||
else out[k] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pjson(o) { console.log(JSON.stringify(o, null, 2)); }
|
||||
|
||||
/** JSON for pipes and --json; the terminal UI for a human at a TTY. */
|
||||
function emit(tool, res, flags) {
|
||||
if (flags.json === true || !process.stdout.isTTY) pjson(res);
|
||||
else console.log(renderResult(tool, res, { color: colorEnabled() }));
|
||||
}
|
||||
|
||||
/** Tools that hold the terminal for a known window get a countdown (TTY only). */
|
||||
function progressLabel(tool, args) {
|
||||
if (tool === 'ruview_esp32_capture') return [`Listening on UDP ${args.bind || '0.0.0.0'}:${args.udp_port || 5005}`, args.seconds ?? 10];
|
||||
if (tool === 'ruview_node_monitor') return [`Reading ${args.port} at ${args.baud || 115200} baud`, args.seconds ?? 12];
|
||||
if (tool === 'ruview_mmwave_read' || tool === 'ruview_lidar_read') return [`Reading ${args.host ? `ESPHome ${args.host}` : args.port || args.url || 'device'}`, args.seconds ?? 10];
|
||||
return [null, 0];
|
||||
}
|
||||
|
||||
async function callTool(tool, args) {
|
||||
const { runTool } = await tools();
|
||||
const [label, seconds] = progressLabel(tool, args);
|
||||
return label ? withProgress(label, seconds, () => runTool(tool, args, { source: 'cli' })) : runTool(tool, args, { source: 'cli' });
|
||||
}
|
||||
|
||||
/** `esp32 --watch`: repeated short captures, redrawn in place, with per-node trend lines. */
|
||||
async function watchCapture(args, flags) {
|
||||
const { runTool } = await tools();
|
||||
const window = { ...args, seconds: args.seconds ?? 3 };
|
||||
const history = new Map();
|
||||
let stop = false;
|
||||
process.once('SIGINT', () => { stop = true; });
|
||||
const tty = process.stdout.isTTY && flags.json !== true;
|
||||
while (!stop) {
|
||||
const res = await runTool('ruview_esp32_capture', window, { source: 'cli' });
|
||||
for (const n of res.nodes || []) {
|
||||
const key = `${n.source}:${n.nodeId}`;
|
||||
history.set(key, [...(history.get(key) || []), n.csiRateHz].slice(-30));
|
||||
}
|
||||
if (!tty) { console.log(JSON.stringify(res)); continue; }
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
console.log(renderResult('ruview_esp32_capture', res, { color: colorEnabled(), history }));
|
||||
console.log(`\n${new Date().toLocaleTimeString()} · ${window.seconds}s windows · Ctrl+C to stop`);
|
||||
if (res.reason === 'port_in_use' || res.reason === 'invalid_port' || res.reason === 'invalid_bind') return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function listSkills() {
|
||||
if (!existsSync(SKILLS_DIR)) return [];
|
||||
return readdirSync(SKILLS_DIR).filter((f) => f.endsWith('.md')).map((f) => f.replace(/\.md$/, ''));
|
||||
}
|
||||
|
||||
async function doctor(flags) {
|
||||
const [{ formatDoctor, runDoctor, DOCTOR_GROUPS }, { OPERATOR_DEPS }] = await Promise.all([import('./doctor.js'), tools()]);
|
||||
const groups = typeof flags.group === 'string' ? flags.group.split(',').map((g) => g.trim()).filter(Boolean) : undefined;
|
||||
const unknown = (groups || []).filter((g) => !DOCTOR_GROUPS.includes(g));
|
||||
if (unknown.length) { console.error(`doctor: unknown group(s) ${unknown.join(', ')}; valid: ${DOCTOR_GROUPS.join(', ')}`); return 2; }
|
||||
const report = await runDoctor({
|
||||
groups,
|
||||
port: typeof flags.port === 'string' ? flags.port : undefined,
|
||||
probe_port: flags.probe === true,
|
||||
sensing_url: typeof flags.url === 'string' ? flags.url : undefined,
|
||||
}, OPERATOR_DEPS);
|
||||
if (flags.json === true) pjson(report);
|
||||
else console.log(formatDoctor(report));
|
||||
return report.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
async function mcp(rest, flags, handler) {
|
||||
if (rest[0] !== undefined && rest[0] !== 'start') { console.error('Usage: ruview mcp start [--http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]]'); return 2; }
|
||||
if (flags.http !== true) {
|
||||
const { startMcpServer } = await import('./mcp-server.js');
|
||||
startMcpServer(handler ? { handler } : {});
|
||||
return new Promise(() => {}); // run until stdin closes
|
||||
}
|
||||
const { startMcpHttp, DEFAULT_HTTP_PORT } = await import('./mcp-http.js');
|
||||
const host = typeof flags.host === 'string' ? flags.host : '127.0.0.1';
|
||||
const port = flags.port === undefined ? DEFAULT_HTTP_PORT : Number(flags.port);
|
||||
if (!Number.isInteger(port) || port < 0 || port > 65535) { console.error('mcp start: --port must be 0..65535'); return 2; }
|
||||
const allowOrigins = typeof flags['allow-origin'] === 'string' ? flags['allow-origin'].split(',').map((o) => o.trim()).filter(Boolean) : [];
|
||||
// The token is read from the environment, never from argv (process lists are visible to other users).
|
||||
const srv = await startMcpHttp({ host, port, token: process.env.RUVIEW_MCP_TOKEN, allowOrigins, ...(handler ? { handler } : {}) });
|
||||
const generated = !process.env.RUVIEW_MCP_TOKEN;
|
||||
process.stderr.write([
|
||||
`ruview MCP over HTTP on ${srv.url}${srv.loopback ? '' : ' (non-loopback bind: anyone who can reach this port and holds the token can call read tools)'}`,
|
||||
` grants: ${srv.grants.join(', ') || 'none (set RUVIEW_MCP_GRANTS=device-access for hardware reads)'}`,
|
||||
` token: ${generated ? `${srv.token} (generated; set RUVIEW_MCP_TOKEN to keep it stable)` : 'from RUVIEW_MCP_TOKEN'}`,
|
||||
' clients with headers: Authorization: Bearer <token>',
|
||||
' ChatGPT (no custom headers): expose the port over HTTPS (e.g. a tunnel) and add the connector URL',
|
||||
` https://<public-host>/mcp/${generated ? srv.token : '<token>'}`,
|
||||
'',
|
||||
].join('\n'));
|
||||
const shutdown = () => srv.close().then(() => process.exit(0));
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
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] [--baud 1500000] assert CSI is flowing on a node (no reset on open)
|
||||
flash --port COM8 --variant s3-8mb [--confirm] build+flash firmware (Windows/ESP-IDF)
|
||||
guidance [--topic homecore] [--query "Wasmtime"] source-cited code/capability map
|
||||
spaces [--resource sites|...|alerts] [--limit 50] page OAuth-bound Cognitum spatial resources
|
||||
kernel [--backend wasm|napi|auto] [--seconds 60] SYNTHETIC self-test of @ruvnet/ruview-kernel (optional)
|
||||
|
||||
Firmware (ADR-370, cross-platform esptool):
|
||||
ports list serial ports (pyserial)
|
||||
flash-plan --port <p> --bundle <dir> [--variant s3-8mb|s3-4mb|c6] verify + print the plan
|
||||
flash --port <p> --bundle <dir> [--variant ...] [--baud 460800] --confirm
|
||||
[--boot-log-seconds 15] [--allow-unverified] write flash, then capture boot evidence
|
||||
|
||||
Devices (ADR-373) — run on the machine the hardware is attached to:
|
||||
devices classify USB serial devices (ESP32, Realtek, mmWave, RPLIDAR)
|
||||
esp32 [--udp-port 5005] [--seconds 10] [--bind 0.0.0.0] receive + summarize ESP32 / Realtek RAC1 node UDP streams
|
||||
[--analyze [--node-id N] [--backend wasm|napi|auto]] run live CSI through @ruvnet/ruview-kernel
|
||||
[--watch [--seconds 3]] live view: repeated windows with per-node trends
|
||||
mmwave --port <p> [--model auto|mr60bha2|ld2410] [--seconds 10] 60/24 GHz radar readout
|
||||
mmwave --source esphome --host <ip> [--api-port 6053] radar kit running ESPHome (e.g. Seeed MR60BHA2 kit)
|
||||
lidar --source rplidar --port <p> [--baud 115200] RPLIDAR scan summary
|
||||
lidar --source iphone --url ws://HOST:8787/ws/lidar iPhone LiDAR relay (token: RUVIEW_LIDAR_TOKEN)
|
||||
|
||||
Remote hosts (ADR-374) — SSH, read-only tools only:
|
||||
hosts add --name pi --ssh user@pi.local [--port 22] [--version X.Y.Z]
|
||||
hosts list
|
||||
hosts run --host pi --tool ruview_devices_scan [--args-json '{"seconds":10}']
|
||||
call <tool> [--read-only] [--args-json '{...}'] generic schema-validated tool call
|
||||
|
||||
Training (ADR-371):
|
||||
train-plan [--mode pose-smoke|pose|room] [...] resolve the command, run nothing
|
||||
train --mode pose-smoke|pose|room [--config f] [--data-dir d] [--checkpoint-dir d]
|
||||
[--enrollment f] [--output f] [--samples 64] [--cuda] --confirm
|
||||
train-gate --file report.json | --model-score .59 --baseline-score .50 --split chronological ...
|
||||
|
||||
Harness:
|
||||
doctor [--json] [--group a,b] [--port P [--probe]] [--url http://host:port]
|
||||
structured diagnostics with fixes (ADR-372)
|
||||
skills list bundled skills
|
||||
skill <name> print a skill playbook
|
||||
mod [--json] where the ruview-live Claude Code mod is, and how to load it (ADR-377)
|
||||
mcp start run the ruview.* MCP server (stdio)
|
||||
mcp start --http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]
|
||||
MCP over HTTP for ChatGPT and remote clients (ADR-375);
|
||||
token: RUVIEW_MCP_TOKEN (generated if unset), grants: RUVIEW_MCP_GRANTS
|
||||
install --host <h> project the harness config into the current repo
|
||||
agent run --host claude-code|codex --prompt "..." [--repo <dir>]
|
||||
brain search --query "..." | verify | propose
|
||||
--version | --help
|
||||
|
||||
Output: a terminal gets a formatted view; pipes and --json get JSON.
|
||||
Hosts implemented and tested locally: claude-code (-p), codex (exec)`);
|
||||
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;
|
||||
}
|
||||
|
||||
/** Run the CLI. `opts.mcpHandler` lets an embedding package serve a merged MCP tool set. */
|
||||
export async function run(args, opts = {}) {
|
||||
const cmd = args[0] ?? 'onboard';
|
||||
const rest = args.slice(1);
|
||||
const flags = parseFlags(rest);
|
||||
|
||||
// Direct tool verbs.
|
||||
if (VERB_TO_TOOL[cmd]) {
|
||||
const tool = VERB_TO_TOOL[cmd];
|
||||
const toolArgs = { ...flags };
|
||||
for (const k of UI_FLAGS) delete toolArgs[k];
|
||||
if (cmd === 'claim-check') {
|
||||
if (flags.file) {
|
||||
toolArgs.text = readFileSync(flags.file, 'utf8');
|
||||
delete toolArgs.file;
|
||||
}
|
||||
// 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 callTool(tool, toolArgs);
|
||||
emit(tool, res, flags);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
if (cmd === 'monitor' && flags.seconds) toolArgs.seconds = Number(flags.seconds);
|
||||
if (cmd === 'monitor' && flags.baud) toolArgs.baud = Number(flags.baud);
|
||||
if (cmd === 'guidance' && flags.limit) toolArgs.limit = Number(flags.limit);
|
||||
if (cmd === 'calibrate' && typeof flags.args === 'string') toolArgs.args = flags.args.split(',');
|
||||
if (cmd === 'kernel' && flags.seconds !== undefined) toolArgs.seconds = Number(flags.seconds);
|
||||
if (SNAKE_VERBS.has(cmd)) {
|
||||
let snake = toSchemaArgs(flags);
|
||||
if (cmd === 'train-gate' && typeof flags.file === 'string') {
|
||||
snake = JSON.parse(readFileSync(flags.file, 'utf8'));
|
||||
}
|
||||
if (cmd === 'esp32' && flags.watch === true) return watchCapture(snake, flags);
|
||||
const res = await callTool(tool, snake);
|
||||
emit(tool, res, flags);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
if (cmd === 'spaces') {
|
||||
if (flags['credentials-path'] !== undefined) toolArgs.credentials_path = flags['credentials-path'];
|
||||
delete toolArgs['credentials-path'];
|
||||
if (flags.limit !== undefined) toolArgs.limit = Number(flags.limit);
|
||||
}
|
||||
const res = await callTool(tool, toolArgs);
|
||||
emit(tool, res, flags);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 'doctor': return doctor(flags);
|
||||
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': return mcp(rest, flags, opts.mcpHandler);
|
||||
case 'mod': {
|
||||
// The ruview-live Claude Code mod ships inside this package (ADR-377).
|
||||
const dir = join(ROOT, 'mod');
|
||||
const info = {
|
||||
ok: existsSync(join(dir, '.claude-plugin', 'plugin.json')),
|
||||
plugin: 'ruview-live',
|
||||
path: dir,
|
||||
run: `claude --plugin-dir "${dir}"`,
|
||||
marketplace: ['/plugin marketplace add ruvnet/RuView', '/plugin install ruview-live@ruview'],
|
||||
usage: ['/ruview open or close the live pane', '/ruview refresh one capture now, result in the status line', '/ruview off close the pane'],
|
||||
configure: 'claude plugin configure ruview-live (udpPort, radarHost, refreshSeconds, captureSeconds)',
|
||||
note: 'Mods are early access in Claude Code. If /ruview is missing, start Claude Code with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.',
|
||||
};
|
||||
if (flags.json === true || !process.stdout.isTTY) pjson(info);
|
||||
else console.log([`ruview-live mod: ${info.path}`, ` try it: ${info.run}`, ` install: ${info.marketplace.join(' then ')}`, ...info.usage.map((u) => ` ${u}`), ` ${info.configure}`, ` ${info.note}`].join('\n'));
|
||||
return info.ok ? 0 : 1;
|
||||
}
|
||||
case 'agent': {
|
||||
if (rest[0] !== 'run') { console.error('Usage: ruview agent run --host claude-code|codex --prompt "..." [--repo <dir>]'); return 2; }
|
||||
const [{ findRepoRoot }, { getHost }] = await Promise.all([tools(), import('./hosts/index.js')]);
|
||||
const hostName = String(flags.host || 'codex');
|
||||
const prompt = String(flags.prompt || '');
|
||||
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
|
||||
if (!repo) { console.error('agent run: trusted RuView repo not found; pass --repo <root>.'); return 2; }
|
||||
if (!prompt.trim()) { console.error('agent run: --prompt is required.'); return 2; }
|
||||
const allowWrite = flags['allow-write'] === true;
|
||||
if (allowWrite && flags.confirm !== true) { console.error('agent run: --allow-write also requires --confirm.'); return 2; }
|
||||
try {
|
||||
const result = await getHost(hostName).run({
|
||||
prompt, repoRoot: repo, trustedRoot: repo, allowWrite, confirm: flags.confirm === true,
|
||||
});
|
||||
pjson({ ok: true, host: hostName, mode: allowWrite ? 'workspace-write' : 'read-only', stdout: result.stdout, stderr: result.stderr });
|
||||
return 0;
|
||||
} catch (error) {
|
||||
pjson({ ok: false, host: hostName, error: error.message });
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
case 'brain': {
|
||||
const { makeProposal, searchBrain, verifyBrain } = await import('./brain.js');
|
||||
const action = rest[0] || 'search';
|
||||
if (action === 'search') {
|
||||
const query = String(flags.query || '');
|
||||
if (!query.trim()) { console.error('brain search: --query is required.'); return 2; }
|
||||
pjson({ ok: true, results: searchBrain(query, { limit: flags.limit }) }); return 0;
|
||||
}
|
||||
if (action === 'verify') {
|
||||
const { findRepoRoot } = await tools();
|
||||
const repo = flags.repo ? resolve(flags.repo) : findRepoRoot();
|
||||
if (!repo) { console.error('brain verify: RuView repo not found.'); return 2; }
|
||||
const result = verifyBrain({ repo }); pjson(result); return result.ok ? 0 : 1;
|
||||
}
|
||||
if (action === 'propose') {
|
||||
const result = makeProposal(flags); pjson(result); return result.ok ? 0 : 1;
|
||||
}
|
||||
console.error('Usage: ruview brain search|verify|propose'); return 2;
|
||||
}
|
||||
case 'install': {
|
||||
const host = flags.host || 'claude-code';
|
||||
if (!['claude-code', 'codex'].includes(host)) {
|
||||
console.error(`Host "${host}" is not implemented. Supported: claude-code, codex.`);
|
||||
return 2;
|
||||
}
|
||||
try {
|
||||
const { getHost } = await import('./hosts/index.js');
|
||||
const adapter = getHost(host);
|
||||
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 "${host}" is unavailable.`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
case 'tools': { const { listTools } = await tools(); pjson(listTools()); return 0; }
|
||||
case 'call': {
|
||||
// Generic, schema-validated tool call (ADR-374). `--read-only` is what
|
||||
// remote hosts receive: it refuses anything but read-only tools.
|
||||
const { isRemoteReadOnlyTool, resolveToolName } = await tools();
|
||||
const name = resolveToolName(rest[0] || '');
|
||||
if (!name) { console.error('Usage: ruview call <tool> [--read-only] [--args-json \'{...}\']'); return 2; }
|
||||
if (flags['read-only'] === true && !isRemoteReadOnlyTool(name)) {
|
||||
pjson({ ok: false, reason: 'remote_tool_not_allowed', name }); return 1;
|
||||
}
|
||||
let toolArgs = {};
|
||||
if (typeof flags['args-json'] === 'string') {
|
||||
try { toolArgs = JSON.parse(flags['args-json']); } catch { pjson({ ok: false, reason: 'invalid_arguments', errors: ['--args-json is not valid JSON'] }); return 2; }
|
||||
}
|
||||
const res = await callTool(name, toolArgs);
|
||||
// Remote hosts parse this stream: `call` always prints JSON.
|
||||
pjson(res);
|
||||
return res.ok ? 0 : 1;
|
||||
}
|
||||
case 'hosts': {
|
||||
const action = rest[0] || 'list';
|
||||
if (action === 'list') { const res = await callTool('ruview_host_list', {}); pjson(res); return res.ok ? 0 : 1; }
|
||||
if (action === 'add') {
|
||||
const { saveHost } = await import('./remote.js');
|
||||
try {
|
||||
const out = saveHost({ name: flags.name, ssh: flags.ssh, port: flags.port ? Number(flags.port) : undefined, version: flags.version, description: flags.description });
|
||||
pjson({ ok: true, ...out, next: `Verify the host key once: ssh ${out.host.ssh} true` }); return 0;
|
||||
} catch (error) { pjson({ ok: false, reason: error.reason || 'invalid_host', detail: error.message }); return 2; }
|
||||
}
|
||||
if (action === 'run') {
|
||||
const { resolveToolName } = await tools();
|
||||
let toolArgs = {};
|
||||
if (typeof flags['args-json'] === 'string') {
|
||||
try { toolArgs = JSON.parse(flags['args-json']); } catch { console.error('--args-json is not valid JSON'); return 2; }
|
||||
}
|
||||
const tool = resolveToolName(String(flags.tool || '')) || String(flags.tool || '');
|
||||
const res = await callTool('ruview_host_run', { host: String(flags.host || ''), tool, args: toolArgs });
|
||||
pjson(res); return res.ok ? 0 : 1;
|
||||
}
|
||||
console.error('Usage: ruview hosts list | add --name pi --ssh user@pi.local [--port 22] [--version X.Y.Z] | run --host pi --tool ruview_devices_scan [--args-json {...}]');
|
||||
return 2;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// CSI node network access (ADR-373): receive the node UDP stream and summarize
|
||||
// it. Decodes ESP32 ADR-018 frames and edge packets, ADR-110 sync packets, and
|
||||
// Realtek RTL8721Dx RAC1 CSI envelopes plus their RHB1 heartbeats (ADR-323).
|
||||
// Receive-only: the socket never sends a byte to a node.
|
||||
|
||||
import dgram from 'node:dgram';
|
||||
|
||||
/** ESP32 packet magics (little-endian u32) from firmware/esp32-csi-node/main. */
|
||||
export const PACKET_KINDS = Object.freeze({
|
||||
0xC5110001: 'csi',
|
||||
0xC5110002: 'vitals',
|
||||
0xC5110003: 'features',
|
||||
0xC5110004: 'fused-vitals',
|
||||
0xC5110005: 'compressed',
|
||||
0xC5110006: 'feature-state',
|
||||
0xC5110007: 'wasm-output',
|
||||
0xC511A110: 'sync',
|
||||
});
|
||||
|
||||
/** ADR-081 mesh envelope (rv_mesh.h): 16-byte header, no one-byte node id. */
|
||||
export const MESH_MAGIC = 0xC5118100;
|
||||
const MESH_TYPES = Object.freeze({
|
||||
1: 'time-sync', 2: 'role-assign', 3: 'channel-plan', 4: 'calibration-start',
|
||||
5: 'feature-delta', 6: 'health', 7: 'anomaly-alert',
|
||||
});
|
||||
|
||||
/** Realtek RTL8721Dx magics (ADR-323): ASCII "RAC1" and "RHB1" read as LE u32. */
|
||||
export const RAC1_MAGIC = 0x31434152;
|
||||
export const RHB1_MAGIC = 0x31424852;
|
||||
const RAC1_HEADER_LEN = 49;
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
|
||||
/** CRC-32/IEEE, the RAC1 trailer checksum. */
|
||||
export function crc32(buf, end = buf.length) {
|
||||
let c = 0xFFFFFFFF;
|
||||
for (let i = 0; i < end; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
|
||||
return (c ^ 0xFFFFFFFF) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mean |IQ| over int8 pairs, and optionally the per-tone amplitude/phase arrays.
|
||||
* `imagFirst` matches the ESP-IDF buffer order (imag, real); Realtek is (I, Q).
|
||||
*/
|
||||
function iq8(buf, offset, tones, imagFirst, wantIq) {
|
||||
const v = new Int8Array(buf.buffer, buf.byteOffset + offset, tones * 2);
|
||||
let sum = 0;
|
||||
const amplitudes = wantIq ? new Array(tones) : null;
|
||||
const phases = wantIq ? new Array(tones) : null;
|
||||
for (let k = 0, j = 0; k < tones; k++, j += 2) {
|
||||
const re = imagFirst ? v[j + 1] : v[j];
|
||||
const im = imagFirst ? v[j] : v[j + 1];
|
||||
const a = Math.sqrt(re * re + im * im);
|
||||
sum += a;
|
||||
if (wantIq) { amplitudes[k] = a; phases[k] = Math.atan2(im, re); }
|
||||
}
|
||||
return { mean: sum / tones, amplitudes, phases };
|
||||
}
|
||||
|
||||
function iq16(buf, offset, tones, wantIq) {
|
||||
let sum = 0;
|
||||
const amplitudes = wantIq ? new Array(tones) : null;
|
||||
const phases = wantIq ? new Array(tones) : null;
|
||||
for (let k = 0; k < tones; k++) {
|
||||
const re = buf.readInt16LE(offset + 4 * k);
|
||||
const im = buf.readInt16LE(offset + 4 * k + 2);
|
||||
const a = Math.sqrt(re * re + im * im);
|
||||
sum += a;
|
||||
if (wantIq) { amplitudes[k] = a; phases[k] = Math.atan2(im, re); }
|
||||
}
|
||||
return { mean: sum / tones, amplitudes, phases };
|
||||
}
|
||||
|
||||
function parseRac1(buf, wantIq) {
|
||||
if (buf.length < RAC1_HEADER_LEN + 4) return { kind: 'malformed', reason: 'rac1 header' };
|
||||
const version = buf.readUInt8(4);
|
||||
const headerLen = buf.readUInt16LE(5);
|
||||
const frameLen = buf.readUInt32LE(7);
|
||||
if (version !== 1 || headerLen !== RAC1_HEADER_LEN) return { kind: 'malformed', reason: 'rac1 version' };
|
||||
if (frameLen !== buf.length) return { kind: 'malformed', reason: 'rac1 length' };
|
||||
const tones = buf.readUInt16LE(37);
|
||||
const bitsPerTone = buf.readUInt8(39);
|
||||
const payloadLen = buf.readUInt32LE(45);
|
||||
if ((bitsPerTone !== 16 && bitsPerTone !== 32) || tones === 0 || payloadLen !== tones * (bitsPerTone / 8)
|
||||
|| RAC1_HEADER_LEN + payloadLen + 4 !== frameLen) return { kind: 'malformed', reason: 'rac1 payload' };
|
||||
if (crc32(buf, frameLen - 4) !== buf.readUInt32LE(frameLen - 4)) return { kind: 'malformed', reason: 'rac1 crc' };
|
||||
const iq = bitsPerTone === 16 ? iq8(buf, RAC1_HEADER_LEN, tones, false, wantIq) : iq16(buf, RAC1_HEADER_LEN, tones, wantIq);
|
||||
const flags = buf.readUInt8(44);
|
||||
return {
|
||||
kind: 'csi', source: 'realtek', nodeId: buf.readUInt8(11), antennas: 1, subcarriers: tones,
|
||||
channel: buf.readUInt8(33), seq: buf.readUInt32LE(13), rssi: buf.readInt8(41),
|
||||
bw40: buf.readUInt8(34) === 1, protocol: buf.readUInt8(36), bitsPerTone,
|
||||
csiValid: buf.readUInt8(43) === 1, synthetic: Boolean(flags & 1),
|
||||
meanAmplitude: iq.mean, amplitudes: iq.amplitudes, phases: iq.phases,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one datagram. Unknown or malformed input returns {kind:'unknown'|'malformed'}.
|
||||
* opts.iq adds per-tone `amplitudes`/`phases` to CSI packets (used for kernel analysis).
|
||||
*/
|
||||
export function parsePacket(buf, opts = {}) {
|
||||
if (!Buffer.isBuffer(buf) || buf.length < 4) return { kind: 'malformed', reason: 'short' };
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic === RAC1_MAGIC) return parseRac1(buf, Boolean(opts.iq));
|
||||
if (magic === RHB1_MAGIC) return { kind: 'heartbeat', source: 'realtek', nodeId: null };
|
||||
if (magic === MESH_MAGIC) {
|
||||
if (buf.length < 16) return { kind: 'malformed', reason: 'mesh header' };
|
||||
const type = buf.readUInt8(5);
|
||||
return { kind: 'mesh', source: 'esp32', nodeId: null, version: buf.readUInt8(4), msgType: MESH_TYPES[type] || `type-${type}` };
|
||||
}
|
||||
const kind = PACKET_KINDS[magic];
|
||||
if (!kind) return { kind: 'unknown', magic: `0x${magic.toString(16)}` };
|
||||
if (kind === 'csi') {
|
||||
if (buf.length < 20) return { kind: 'malformed', reason: 'csi header' };
|
||||
const antennas = buf.readUInt8(5);
|
||||
const subcarriers = buf.readUInt16LE(6);
|
||||
const tones = antennas * subcarriers;
|
||||
if (antennas === 0 || subcarriers === 0 || buf.length < 20 + tones * 2) return { kind: 'malformed', reason: 'csi payload' };
|
||||
const flags = buf.readUInt8(19);
|
||||
// Per-tone arrays only for single-antenna frames: the kernel takes one chain.
|
||||
const iq = iq8(buf, 20, tones, true, Boolean(opts.iq) && antennas === 1);
|
||||
// The waterfall (ADR-378) draws multi-antenna nodes from their first chain:
|
||||
// ADR-018 lays tones out antenna-major, so chain 0 is the first `subcarriers`.
|
||||
const chain0 = opts.firstChain && antennas > 1 ? iq8(buf, 20, subcarriers, true, true).amplitudes : undefined;
|
||||
return {
|
||||
kind, source: 'esp32', nodeId: buf.readUInt8(4), antennas, subcarriers,
|
||||
freqMhz: buf.readUInt32LE(8), seq: buf.readUInt32LE(12),
|
||||
rssi: buf.readInt8(16), noiseFloor: buf.readInt8(17), ppdu: buf.readUInt8(18),
|
||||
bw40: Boolean(flags & 1), firstWordZeroed: Boolean(flags & 0x20),
|
||||
meanAmplitude: iq.mean, amplitudes: iq.amplitudes, phases: iq.phases,
|
||||
...(chain0 ? { chain0Amplitudes: chain0 } : {}),
|
||||
};
|
||||
}
|
||||
if (kind === 'vitals') {
|
||||
if (buf.length < 32) return { kind: 'malformed', reason: 'vitals length' };
|
||||
const flags = buf.readUInt8(5);
|
||||
return {
|
||||
kind, source: 'esp32', nodeId: buf.readUInt8(4),
|
||||
presence: Boolean(flags & 1), fall: Boolean(flags & 2), motion: Boolean(flags & 4),
|
||||
breathingBpm: buf.readUInt16LE(6) / 100, heartBpm: buf.readUInt32LE(8) / 10000,
|
||||
rssi: buf.readInt8(12), persons: buf.readUInt8(13),
|
||||
motionEnergy: buf.readFloatLE(16), presenceScore: buf.readFloatLE(20), uptimeMs: buf.readUInt32LE(24),
|
||||
};
|
||||
}
|
||||
if (kind === 'sync') {
|
||||
if (buf.length < 32) return { kind: 'malformed', reason: 'sync length' };
|
||||
return {
|
||||
kind, source: 'esp32', nodeId: buf.readUInt8(4), protoVer: buf.readUInt8(5),
|
||||
leader: Boolean(buf.readUInt8(6) & 1), highWaterSeq: buf.readUInt32LE(24),
|
||||
};
|
||||
}
|
||||
return { kind, source: 'esp32', nodeId: buf.length > 4 ? buf.readUInt8(4) : null, bytes: buf.length };
|
||||
}
|
||||
|
||||
const shapeKey = (pkt) => `${pkt.antennas}x${pkt.subcarriers}`;
|
||||
|
||||
/** Jumps larger than this are a counter reset or a stray frame, not loss. */
|
||||
export const SEQ_WINDOW = 1024;
|
||||
|
||||
/**
|
||||
* Loss accounting that survives UDP reordering and stray sequence values.
|
||||
* A late packet fills the hole it left instead of being re-counted. A single
|
||||
* out-of-window value is a stray (observed live on an RTL8721Dx: sporadic
|
||||
* seq 0 and +52,835 frames between in-order ones) and leaves the baseline
|
||||
* alone; two consecutive frames agreeing on a new range are a counter reset.
|
||||
*/
|
||||
function trackSequence(n, seq) {
|
||||
if (n.lastSeq === null) { n.lastSeq = seq; return; }
|
||||
const d = seq - n.lastSeq;
|
||||
if (d >= 1 && d <= SEQ_WINDOW) {
|
||||
n.seqGaps += d - 1;
|
||||
n.lastSeq = seq;
|
||||
n.pendingSeq = null;
|
||||
} else if (d <= 0 && d > -SEQ_WINDOW) {
|
||||
n.reordered += 1; // late or duplicate; a late packet fills one counted gap
|
||||
if (d < 0 && n.seqGaps > 0) n.seqGaps -= 1;
|
||||
} else if (n.pendingSeq != null && seq - n.pendingSeq >= 1 && seq - n.pendingSeq <= SEQ_WINDOW) {
|
||||
n.strays -= 1; // the previous out-of-window frame started a new range
|
||||
n.resyncs += 1;
|
||||
n.lastSeq = seq;
|
||||
n.pendingSeq = null;
|
||||
} else {
|
||||
n.strays += 1;
|
||||
n.pendingSeq = seq;
|
||||
}
|
||||
}
|
||||
|
||||
/** Aggregate parsed packets into a per-node summary. */
|
||||
export class NodeStats {
|
||||
constructor() {
|
||||
this.nodes = new Map();
|
||||
this.unknown = 0;
|
||||
this.unknownMagics = new Map();
|
||||
this.malformed = 0;
|
||||
this.malformedReasons = new Map();
|
||||
this.senders = new Map(); // address → { packets, csi, heartbeats }
|
||||
this.mesh = new Map(); // msgType → count
|
||||
}
|
||||
|
||||
add(pkt, sender) {
|
||||
const s = sender ? (this.senders.get(sender) || { packets: 0, csi: 0, heartbeats: 0 }) : null;
|
||||
if (s) { s.packets += 1; this.senders.set(sender, s); }
|
||||
if (pkt.kind === 'unknown') {
|
||||
this.unknown += 1;
|
||||
if (this.unknownMagics.size < 32 || this.unknownMagics.has(pkt.magic)) this.unknownMagics.set(pkt.magic, (this.unknownMagics.get(pkt.magic) || 0) + 1);
|
||||
return;
|
||||
}
|
||||
if (pkt.kind === 'malformed') {
|
||||
this.malformed += 1;
|
||||
this.malformedReasons.set(pkt.reason, (this.malformedReasons.get(pkt.reason) || 0) + 1);
|
||||
return;
|
||||
}
|
||||
if (pkt.kind === 'heartbeat') { if (s) s.heartbeats += 1; return; }
|
||||
if (pkt.kind === 'mesh') { this.mesh.set(pkt.msgType, (this.mesh.get(pkt.msgType) || 0) + 1); return; }
|
||||
if (s && pkt.kind === 'csi') s.csi += 1;
|
||||
const key = `${pkt.source}:${pkt.nodeId ?? -1}`;
|
||||
let n = this.nodes.get(key);
|
||||
if (!n) {
|
||||
n = { source: pkt.source, nodeId: pkt.nodeId ?? -1, packets: {}, rssiSum: 0, rssiCount: 0, lastSeq: null, pendingSeq: null, seqGaps: 0, reordered: 0, strays: 0, resyncs: 0, shapes: new Map(), vitals: null };
|
||||
this.nodes.set(key, n);
|
||||
}
|
||||
n.packets[pkt.kind] = (n.packets[pkt.kind] || 0) + 1;
|
||||
if (pkt.kind === 'csi') {
|
||||
n.rssiSum += pkt.rssi; n.rssiCount += 1;
|
||||
trackSequence(n, pkt.seq);
|
||||
const shape = shapeKey(pkt);
|
||||
const sh = n.shapes.get(shape) || { count: 0, ampSum: 0, last: null };
|
||||
sh.count += 1; sh.ampSum += pkt.meanAmplitude; sh.last = pkt;
|
||||
n.shapes.set(shape, sh);
|
||||
} else if (pkt.kind === 'vitals') {
|
||||
n.rssiSum += pkt.rssi; n.rssiCount += 1;
|
||||
const { kind, nodeId, source, ...rest } = pkt;
|
||||
n.vitals = rest;
|
||||
}
|
||||
}
|
||||
|
||||
summary(seconds) {
|
||||
const nodes = [...this.nodes.values()]
|
||||
.sort((a, b) => (a.source === b.source ? a.nodeId - b.nodeId : a.source.localeCompare(b.source)))
|
||||
.map((n) => {
|
||||
const csi = n.packets.csi || 0;
|
||||
const received = csi + n.seqGaps;
|
||||
const shapes = [...n.shapes.entries()].sort((a, b) => b[1].count - a[1].count);
|
||||
const describe = ([shape, sh]) => {
|
||||
const p = sh.last;
|
||||
return {
|
||||
shape, frames: sh.count, antennas: p.antennas, subcarriers: p.subcarriers,
|
||||
...(p.source === 'esp32' ? { freqMhz: p.freqMhz, ppdu: p.ppdu } : { channel: p.channel, protocol: p.protocol, bitsPerTone: p.bitsPerTone, synthetic: p.synthetic }),
|
||||
bw40: p.bw40, meanAmplitude: Number((sh.ampSum / sh.count).toFixed(2)),
|
||||
};
|
||||
};
|
||||
return {
|
||||
source: n.source,
|
||||
nodeId: n.nodeId,
|
||||
packets: n.packets,
|
||||
csiRateHz: Number((csi / seconds).toFixed(2)),
|
||||
csiLossFraction: received ? Number((n.seqGaps / received).toFixed(4)) : null,
|
||||
...(n.reordered ? { seqReordered: n.reordered } : {}),
|
||||
...(n.strays ? { seqStrays: n.strays } : {}),
|
||||
...(n.resyncs ? { seqResyncs: n.resyncs } : {}),
|
||||
rssiMean: n.rssiCount ? Number((n.rssiSum / n.rssiCount).toFixed(1)) : null,
|
||||
csi: shapes.length ? describe(shapes[0]) : null,
|
||||
...(shapes.length > 1 ? { csiShapes: shapes.map(describe) } : {}),
|
||||
lastVitals: n.vitals,
|
||||
};
|
||||
});
|
||||
const senders = [...this.senders.keys()].slice(0, 32);
|
||||
const heartbeatOnly = [...this.senders.entries()].filter(([, s]) => s.heartbeats > 0 && s.csi === 0).map(([a, s]) => ({ address: a, heartbeats: s.heartbeats }));
|
||||
return {
|
||||
nodes,
|
||||
unknownPackets: this.unknown,
|
||||
...(this.unknown ? { unknownMagics: Object.fromEntries(this.unknownMagics) } : {}),
|
||||
malformedPackets: this.malformed,
|
||||
...(this.malformed ? { malformedReasons: Object.fromEntries(this.malformedReasons) } : {}),
|
||||
heartbeats: [...this.senders.values()].reduce((a, s) => a + s.heartbeats, 0),
|
||||
...(this.mesh.size ? { meshMessages: Object.fromEntries(this.mesh) } : {}),
|
||||
...(heartbeatOnly.length ? { heartbeatOnlySenders: heartbeatOnly } : {}),
|
||||
senders,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Bounded per-node frame buffer for kernel analysis (one CSI shape per node). */
|
||||
class FrameCollector {
|
||||
constructor(maxFrames) { this.max = maxFrames; this.byNode = new Map(); }
|
||||
add(pkt, t) {
|
||||
if (pkt.kind !== 'csi' || !pkt.amplitudes) return;
|
||||
const key = `${pkt.source}:${pkt.nodeId}|${shapeKey(pkt)}`;
|
||||
let c = this.byNode.get(key);
|
||||
if (!c) { c = { source: pkt.source, nodeId: pkt.nodeId, subcarriers: pkt.subcarriers, frames: [], first: t, last: t, dropped: 0 }; this.byNode.set(key, c); }
|
||||
if (c.frames.length >= this.max) { c.dropped += 1; return; }
|
||||
c.frames.push({ amplitudes: pkt.amplitudes, phases: pkt.phases });
|
||||
c.last = t;
|
||||
}
|
||||
/** The requested node's (or the busiest node's) dominant shape. */
|
||||
pick(nodeId) {
|
||||
const all = [...this.byNode.values()].filter((c) => nodeId === undefined || c.nodeId === nodeId);
|
||||
return all.sort((a, b) => b.frames.length - a.frames.length)[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Average a frame's per-subcarrier amplitudes into `bins` equal groups. */
|
||||
export function binAmplitudes(amplitudes, bins) {
|
||||
const n = amplitudes.length;
|
||||
const b = Math.max(1, Math.min(bins, n));
|
||||
const out = new Array(b);
|
||||
for (let i = 0; i < b; i++) {
|
||||
const start = Math.floor((i * n) / b);
|
||||
const end = Math.max(start + 1, Math.floor(((i + 1) * n) / b));
|
||||
let sum = 0;
|
||||
for (let k = start; k < end; k++) sum += amplitudes[k];
|
||||
out[i] = Math.round((sum / (end - start)) * 10) / 10;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent amplitude frames per node, binned, for a waterfall view
|
||||
* (ADR-378). A ring buffer: unlike the analyzer's collector it keeps the
|
||||
* newest frames. Each node reports its dominant CSI shape only.
|
||||
*/
|
||||
export class SpectrumCollector {
|
||||
constructor(frames, bins, maxNodes = 8) { this.frames = frames; this.bins = bins; this.maxNodes = maxNodes; this.byKey = new Map(); }
|
||||
add(pkt, t) {
|
||||
const amplitudes = pkt.amplitudes ?? pkt.chain0Amplitudes;
|
||||
if (pkt.kind !== 'csi' || !amplitudes) return;
|
||||
const key = `${pkt.source}:${pkt.nodeId}|${shapeKey(pkt)}`;
|
||||
let c = this.byKey.get(key);
|
||||
if (!c) {
|
||||
if (this.byKey.size >= this.maxNodes * 4) return;
|
||||
c = { source: pkt.source, nodeId: pkt.nodeId, shape: shapeKey(pkt), subcarriers: pkt.subcarriers, rows: [], count: 0, first: t, last: t, synthetic: false };
|
||||
this.byKey.set(key, c);
|
||||
}
|
||||
c.rows.push(binAmplitudes(amplitudes, this.bins));
|
||||
if (c.rows.length > this.frames) c.rows.shift();
|
||||
c.count += 1;
|
||||
c.last = t;
|
||||
c.synthetic ||= Boolean(pkt.synthetic);
|
||||
}
|
||||
result() {
|
||||
const best = new Map();
|
||||
for (const c of this.byKey.values()) {
|
||||
const node = `${c.source}:${c.nodeId}`;
|
||||
if (!best.has(node) || best.get(node).count < c.count) best.set(node, c);
|
||||
}
|
||||
return [...best.values()]
|
||||
.sort((a, b) => (a.source === b.source ? a.nodeId - b.nodeId : a.source.localeCompare(b.source)))
|
||||
.slice(0, this.maxNodes)
|
||||
.map((c) => ({
|
||||
source: c.source, nodeId: c.nodeId, shape: c.shape, subcarriers: c.subcarriers, bins: c.rows[0]?.length ?? 0,
|
||||
frames: c.rows, framesSeen: c.count,
|
||||
rateHz: c.last > c.first ? Number((((c.count - 1) * 1000) / (c.last - c.first)).toFixed(2)) : null,
|
||||
synthetic: c.synthetic,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const BIND_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::', '::1']);
|
||||
const NODE_PACKETS = (summary) => summary.nodes.reduce((a, n) => a + Object.values(n.packets).reduce((x, y) => x + y, 0), 0);
|
||||
|
||||
/**
|
||||
* Listen for node packets. args: { udp_port=5005, bind='0.0.0.0', seconds=10, max_packets=200000,
|
||||
* analyze=false, node_id, analyze_max_frames=6000, spectrum=false, spectrum_bins=48, spectrum_frames=64 }.
|
||||
* deps.createSocket is injectable for tests;
|
||||
* deps.analyze(frames, config) runs the compute kernel when args.analyze is set.
|
||||
*/
|
||||
export function captureEsp32(args = {}, deps = {}) {
|
||||
const port = args.udp_port ?? 5005;
|
||||
const bind = args.bind ?? '0.0.0.0';
|
||||
const seconds = Math.min(Math.max(args.seconds ?? 10, 1), 300);
|
||||
const maxPackets = Math.min(args.max_packets ?? 200_000, 1_000_000);
|
||||
const analyze = Boolean(args.analyze);
|
||||
const maxFrames = Math.min(Math.max(args.analyze_max_frames ?? 6000, 64), 20_000);
|
||||
const spectrum = Boolean(args.spectrum);
|
||||
const spectrumBins = Math.min(Math.max(Math.round(args.spectrum_bins ?? 48), 8), 128);
|
||||
const spectrumFrames = Math.min(Math.max(Math.round(args.spectrum_frames ?? 64), 8), 256);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) return Promise.resolve({ ok: false, reason: 'invalid_port', detail: 'udp_port must be an integer in 1024..65535' });
|
||||
if (!BIND_HOSTS.has(bind)) return Promise.resolve({ ok: false, reason: 'invalid_bind', detail: `bind must be one of ${[...BIND_HOSTS].join(', ')}` });
|
||||
if (args.node_id !== undefined && (!Number.isInteger(args.node_id) || args.node_id < 0 || args.node_id > 255)) return Promise.resolve({ ok: false, reason: 'invalid_node_id', detail: 'node_id must be an integer in 0..255' });
|
||||
if (analyze && typeof deps.analyze !== 'function') return Promise.resolve({ ok: false, reason: 'analyze_unavailable', detail: 'no compute kernel bridge was provided' });
|
||||
const createSocket = deps.createSocket || ((type) => dgram.createSocket({ type, reuseAddr: false }));
|
||||
const stats = new NodeStats();
|
||||
const collector = analyze ? new FrameCollector(maxFrames) : null;
|
||||
const spectra = spectrum ? new SpectrumCollector(spectrumFrames, spectrumBins) : null;
|
||||
const parseOpts = { iq: analyze || spectrum, firstChain: spectrum };
|
||||
let packets = 0;
|
||||
return new Promise((resolve) => {
|
||||
const socket = createSocket(bind.includes(':') ? 'udp6' : 'udp4');
|
||||
let timer;
|
||||
let done = false;
|
||||
const finish = async (extra = {}) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
try { socket.close(); } catch { /* already closed */ }
|
||||
const summary = stats.summary(seconds);
|
||||
const csiNodes = summary.nodes.filter((n) => (n.packets.csi || 0) > 0).length;
|
||||
const decoded = packets - summary.unknownPackets - summary.malformedPackets;
|
||||
let failure = null;
|
||||
if (packets === 0) {
|
||||
failure = { reason: 'no_packets', remedy: `No node packets reached ${bind}:${port}. Check the node's target IP/port (provision.py --target-ip/--target-port), that this host is on the same network, and the firewall (UDP ${port}).` };
|
||||
} else if (NODE_PACKETS(summary) === 0 && !summary.meshMessages && summary.heartbeats > 0) {
|
||||
failure = { reason: 'heartbeat_only', remedy: 'Realtek nodes are alive (RHB1 heartbeats) but no RAC1 CSI arrived. Run `ruview monitor --port <p> --baud 1500000`: "lack of csi buf" lines mean CSI report-buffer starvation in the firmware (reset the board); otherwise check csi_sequence and channel contention (ADR-323).' };
|
||||
} else if (decoded === 0) {
|
||||
failure = { reason: 'no_decodable_packets', remedy: 'Packets arrived but none matched a known RuView format (see unknownMagics). Check the sender and firmware version.' };
|
||||
}
|
||||
const result = {
|
||||
ok: !failure,
|
||||
...(failure || {}),
|
||||
listen: `${bind}:${port}`, seconds, packets, decodedPackets: decoded, csiNodes, ...summary,
|
||||
evidence: failure ? null : 'MEASURED: live UDP packets received and decoded on this host',
|
||||
...extra,
|
||||
};
|
||||
if (spectra && !failure) result.spectrum = spectra.result();
|
||||
if (collector && !failure) result.analysis = await runAnalysis(collector, args.node_id, deps.analyze);
|
||||
resolve(result);
|
||||
};
|
||||
socket.on('error', (error) => {
|
||||
if (error.code === 'EADDRINUSE') {
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
try { socket.close(); } catch { /* ignore */ }
|
||||
resolve({ ok: false, reason: 'port_in_use', detail: `UDP ${port} is already bound (is the sensing server running?)`, remedy: 'Stop the sensing server or capture on another port the nodes target.' });
|
||||
} else finish({ ok: false, reason: 'socket_error', detail: error.message });
|
||||
});
|
||||
socket.on('message', (msg, rinfo) => {
|
||||
if (done) return;
|
||||
packets += 1;
|
||||
const pkt = parsePacket(msg, parseOpts);
|
||||
stats.add(pkt, rinfo?.address);
|
||||
if (collector || spectra) {
|
||||
const t = performance.now();
|
||||
if (collector) collector.add(pkt, t);
|
||||
if (spectra) spectra.add(pkt, t);
|
||||
}
|
||||
if (packets >= maxPackets) finish({ truncated: true });
|
||||
});
|
||||
socket.bind(port, bind, () => { timer = setTimeout(() => finish(), seconds * 1000); });
|
||||
});
|
||||
}
|
||||
|
||||
async function runAnalysis(collector, nodeId, analyzeFn) {
|
||||
const c = collector.pick(nodeId);
|
||||
if (!c || c.frames.length < 2) {
|
||||
return { ok: false, reason: 'no_frames', detail: nodeId === undefined ? 'no single-antenna CSI frames were captured' : `no single-antenna CSI frames from node ${nodeId}` };
|
||||
}
|
||||
const spanSec = (c.last - c.first) / 1000;
|
||||
const sampleRateHz = spanSec > 0 ? (c.frames.length - 1) / spanSec : 0;
|
||||
const input = { source: c.source, nodeId: c.nodeId, frames: c.frames.length, droppedOverCap: c.dropped, subcarriers: c.subcarriers, sampleRateHz: Number(sampleRateHz.toFixed(3)) };
|
||||
if (!(sampleRateHz > 0)) return { ok: false, input, reason: 'no_frames', detail: 'frames arrived without a measurable time span' };
|
||||
const config = { n_subcarriers: c.subcarriers, sample_rate_hz: input.sampleRateHz };
|
||||
try {
|
||||
const out = await analyzeFn(c.frames, config);
|
||||
return {
|
||||
input, ...out,
|
||||
note: 'Signal-processing estimates from live CSI with no reference measurement; not clinical or camera-grade. The sample rate is the measured arrival rate.',
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false, input, reason: error?.code || 'kernel_error', detail: String(error?.message || error) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// ESPHome native API reader (ADR-373 Amendment 3). Radar kits such as the
|
||||
// Seeed MR60BHA2 (XIAO ESP32-C6 + 60 GHz radar) ship ESPHome firmware that owns
|
||||
// the radar UART, so no raw frames reach USB. Their values are published
|
||||
// through ESPHome's native API on TCP 6053.
|
||||
//
|
||||
// This is a dependency-free, read-only client for the plaintext protocol:
|
||||
// hello → connect → device info → list entities → subscribe states, for a
|
||||
// bounded window. It never sends a command to the device. Only private,
|
||||
// link-local and loopback addresses are accepted. Encrypted APIs (Noise) are
|
||||
// reported honestly as unsupported.
|
||||
|
||||
import net from 'node:net';
|
||||
import { lookup } from 'node:dns/promises';
|
||||
|
||||
export const ESPHOME_DEFAULT_PORT = 6053;
|
||||
const HOST_RE = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,62})(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,62}))*\.?$/;
|
||||
const MAX_BUFFER = 1024 * 1024;
|
||||
|
||||
// Message type ids (api.proto).
|
||||
const T = Object.freeze({
|
||||
HELLO_REQ: 1, HELLO_RES: 2, CONNECT_REQ: 3, CONNECT_RES: 4, DISCONNECT_REQ: 5, DISCONNECT_RES: 6,
|
||||
PING_REQ: 7, PING_RES: 8, DEVICE_INFO_REQ: 9, DEVICE_INFO_RES: 10, LIST_REQ: 11,
|
||||
LIST_BINARY: 12, LIST_SENSOR: 16, LIST_TEXT: 18, LIST_DONE: 19, SUBSCRIBE_STATES: 20,
|
||||
STATE_BINARY: 21, STATE_SENSOR: 25, STATE_TEXT: 27,
|
||||
});
|
||||
|
||||
// --- protobuf (proto3) primitives -------------------------------------------
|
||||
export function encodeVarint(n) {
|
||||
const out = [];
|
||||
let v = n >>> 0;
|
||||
do { let b = v & 0x7f; v >>>= 7; if (v) b |= 0x80; out.push(b); } while (v);
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
export function readVarint(buf, offset) {
|
||||
let result = 0;
|
||||
let shift = 0;
|
||||
let i = offset;
|
||||
for (;;) {
|
||||
if (i >= buf.length) return null;
|
||||
const b = buf[i++];
|
||||
result += (b & 0x7f) * 2 ** shift;
|
||||
if (!(b & 0x80)) return [result, i];
|
||||
shift += 7;
|
||||
if (shift > 63) throw new Error('varint too long');
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode a protobuf message into { field: [{wt, v}] } (wire types 0, 1, 2, 5). */
|
||||
export function decodeFields(buf) {
|
||||
const fields = {};
|
||||
let o = 0;
|
||||
while (o < buf.length) {
|
||||
const tag = readVarint(buf, o);
|
||||
if (!tag) throw new Error('truncated tag');
|
||||
o = tag[1];
|
||||
const field = Math.floor(tag[0] / 8);
|
||||
const wt = tag[0] & 7;
|
||||
let v;
|
||||
if (wt === 0) { const r = readVarint(buf, o); if (!r) throw new Error('truncated varint'); [v, o] = r; }
|
||||
else if (wt === 2) {
|
||||
const r = readVarint(buf, o); if (!r) throw new Error('truncated length');
|
||||
if (r[1] + r[0] > buf.length) throw new Error('truncated bytes');
|
||||
v = buf.subarray(r[1], r[1] + r[0]); o = r[1] + r[0];
|
||||
} else if (wt === 5) { if (o + 4 > buf.length) throw new Error('truncated fixed32'); v = buf.subarray(o, o + 4); o += 4; }
|
||||
else if (wt === 1) { if (o + 8 > buf.length) throw new Error('truncated fixed64'); v = buf.subarray(o, o + 8); o += 8; }
|
||||
else throw new Error(`unsupported wire type ${wt}`);
|
||||
(fields[field] ??= []).push({ wt, v });
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
const fStr = (f, n) => (f[n]?.[0]?.wt === 2 ? f[n][0].v.toString('utf8') : '');
|
||||
const fKey = (f, n) => (f[n]?.[0]?.wt === 5 ? f[n][0].v.readUInt32LE(0) : null);
|
||||
const fBool = (f, n) => (f[n]?.[0]?.wt === 0 ? f[n][0].v !== 0 : false);
|
||||
const fFloat = (f, n) => (f[n]?.[0]?.wt === 5 ? f[n][0].v.readFloatLE(0) : 0); // proto3: absent float is 0
|
||||
const fUint = (f, n) => (f[n]?.[0]?.wt === 0 ? f[n][0].v : 0);
|
||||
|
||||
const pbString = (field, s) => { const b = Buffer.from(s, 'utf8'); return Buffer.concat([encodeVarint((field << 3) | 2), encodeVarint(b.length), b]); };
|
||||
const pbUint = (field, n) => Buffer.concat([encodeVarint(field << 3), encodeVarint(n)]);
|
||||
|
||||
/** Plaintext frame: 0x00, varint(length), varint(type), payload. */
|
||||
export function encodeFrame(type, payload = Buffer.alloc(0)) {
|
||||
return Buffer.concat([Buffer.from([0]), encodeVarint(payload.length), encodeVarint(type), payload]);
|
||||
}
|
||||
|
||||
/** Split complete frames off a buffer. Returns { frames, rest } or throws on an encrypted stream. */
|
||||
export function splitFrames(buf) {
|
||||
const frames = [];
|
||||
let o = 0;
|
||||
while (o < buf.length) {
|
||||
if (buf[o] === 0x01) { const e = new Error('encrypted'); e.reason = 'esphome_encrypted'; throw e; }
|
||||
if (buf[o] !== 0x00) { const e = new Error(`bad frame indicator 0x${buf[o].toString(16)}`); e.reason = 'esphome_protocol_error'; throw e; }
|
||||
const len = readVarint(buf, o + 1);
|
||||
if (!len) break;
|
||||
const type = readVarint(buf, len[1]);
|
||||
if (!type) break;
|
||||
if (type[1] + len[0] > buf.length) break;
|
||||
frames.push({ type: type[0], payload: buf.subarray(type[1], type[1] + len[0]) });
|
||||
o = type[1] + len[0];
|
||||
}
|
||||
return { frames, rest: buf.subarray(o) };
|
||||
}
|
||||
|
||||
// --- address policy ------------------------------------------------------------
|
||||
export function isPrivateAddress(ip) {
|
||||
if (net.isIPv4(ip)) {
|
||||
const [a, b] = ip.split('.').map(Number);
|
||||
return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127);
|
||||
}
|
||||
if (net.isIPv6(ip)) {
|
||||
const v = ip.toLowerCase();
|
||||
if (v.startsWith('::ffff:')) return isPrivateAddress(v.slice(7));
|
||||
return v === '::1' || v.startsWith('fc') || v.startsWith('fd') || v.startsWith('fe8') || v.startsWith('fe9') || v.startsWith('fea') || v.startsWith('feb');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveHost(host, deps) {
|
||||
if (typeof host !== 'string' || !(net.isIP(host) || HOST_RE.test(host))) {
|
||||
return { ok: false, reason: 'invalid_host', detail: 'host must be an IP address or a hostname' };
|
||||
}
|
||||
let address = host;
|
||||
if (!net.isIP(host)) {
|
||||
try { address = (await (deps.lookup || lookup)(host)).address; } catch (error) {
|
||||
return { ok: false, reason: 'host_unresolved', detail: `${host}: ${error.code || error.message}`, remedy: 'Use the device IP (the router DHCP table, or `arp -a`), or its mDNS name <name>.local.' };
|
||||
}
|
||||
}
|
||||
if (!isPrivateAddress(address)) {
|
||||
return { ok: false, reason: 'host_not_private', detail: `${host} resolves to ${address}`, remedy: 'ESPHome reads are limited to private, link-local, CGNAT/Tailscale and loopback addresses.' };
|
||||
}
|
||||
return { ok: true, address };
|
||||
}
|
||||
|
||||
// --- radar roles -----------------------------------------------------------------
|
||||
const ROLES = [
|
||||
['heartBpm', (e) => /heart/i.test(e.name + e.objectId)],
|
||||
['breathingBpm', (e) => /respir|breath/i.test(e.name + e.objectId)],
|
||||
['distanceCm', (e) => /distance/i.test(e.name + e.objectId)],
|
||||
['targets', (e) => /target/i.test(e.name + e.objectId) && e.kind === 'sensor'],
|
||||
['present', (e) => e.kind === 'binary' && /person|presence|occup|motion|human/i.test(e.name + e.objectId)],
|
||||
];
|
||||
const roleOf = (e) => (ROLES.find(([, test]) => test(e)) || [null])[0];
|
||||
const mean = (xs) => (xs.length ? Number((xs.reduce((a, b) => a + b, 0) / xs.length).toFixed(2)) : null);
|
||||
|
||||
/** Summarize a session into the mmWave result shape (shared with the serial path). */
|
||||
export function summarizeEsphome(session, seconds) {
|
||||
const entities = [...session.entities.values()];
|
||||
const byRole = {};
|
||||
for (const e of entities) { const r = roleOf(e); if (r && !byRole[r]) byRole[r] = e; }
|
||||
const values = (e) => (e ? e.samples.filter((s) => !s.missing).map((s) => s.value) : []);
|
||||
const presence = values(byRole.present);
|
||||
const toCm = (e) => (e && /^m$/i.test(e.unit || '') ? values(e).map((v) => v * 100) : values(e));
|
||||
return {
|
||||
source: 'esphome',
|
||||
device: session.device,
|
||||
seconds,
|
||||
stateUpdates: session.updates,
|
||||
updateRateHz: Number((session.updates / seconds).toFixed(2)),
|
||||
presentFraction: presence.length ? Number((presence.filter(Boolean).length / presence.length).toFixed(3)) : null,
|
||||
presentNow: byRole.present && byRole.present.last && !byRole.present.last.missing ? Boolean(byRole.present.last.value) : null,
|
||||
heartBpmMean: mean(values(byRole.heartBpm).filter((v) => v > 0 && v <= 250)),
|
||||
breathingBpmMean: mean(values(byRole.breathingBpm).filter((v) => v > 0 && v <= 60)),
|
||||
distanceCmMean: mean(toCm(byRole.distanceCm).filter((v) => v > 0)),
|
||||
targetsMax: values(byRole.targets).length ? Math.max(...values(byRole.targets)) : null,
|
||||
entities: entities.map((e) => ({
|
||||
kind: e.kind, name: e.name, unit: e.unit || undefined, role: roleOf(e) || undefined,
|
||||
updates: e.samples.length, last: e.last ? (e.last.missing ? null : e.last.value) : null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an ESPHome device. args: { host, api_port=6053, seconds=10 }.
|
||||
* deps.connect(port, host) → net.Socket and deps.lookup are injectable for tests.
|
||||
*/
|
||||
export async function readEsphome(args = {}, deps = {}) {
|
||||
const seconds = Math.min(Math.max(args.seconds ?? 10, 1), 120);
|
||||
const port = args.api_port ?? ESPHOME_DEFAULT_PORT;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return { ok: false, reason: 'invalid_port', detail: 'api_port must be 1..65535' };
|
||||
const target = await resolveHost(args.host, deps);
|
||||
if (!target.ok) return target;
|
||||
|
||||
const session = { device: {}, entities: new Map(), updates: 0, hello: false };
|
||||
return new Promise((resolve) => {
|
||||
const socket = (deps.connect || ((p, h) => net.connect(p, h)))(port, target.address);
|
||||
let buf = Buffer.alloc(0);
|
||||
let done = false;
|
||||
const finish = (out) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
clearTimeout(connectTimer);
|
||||
try { socket.write(encodeFrame(T.DISCONNECT_REQ)); } catch { /* closing anyway */ }
|
||||
socket.destroy();
|
||||
resolve(out);
|
||||
};
|
||||
const fail = (reason, detail, remedy) => finish({ ok: false, reason, detail, ...(remedy ? { remedy } : {}), host: args.host, port });
|
||||
const connectTimer = setTimeout(() => fail('esphome_unreachable', `no TCP connection to ${target.address}:${port} within 5 s`, 'Check that the device is on the network and that `api:` is enabled in its ESPHome config.'), 5000);
|
||||
let timer = null;
|
||||
|
||||
socket.on('connect', () => {
|
||||
clearTimeout(connectTimer);
|
||||
socket.write(encodeFrame(T.HELLO_REQ, Buffer.concat([pbString(1, 'ruview'), pbUint(2, 1), pbUint(3, 10)])));
|
||||
socket.write(encodeFrame(T.CONNECT_REQ));
|
||||
socket.write(encodeFrame(T.DEVICE_INFO_REQ));
|
||||
socket.write(encodeFrame(T.LIST_REQ));
|
||||
timer = setTimeout(() => {
|
||||
if (!session.hello) return fail('esphome_protocol_error', 'no HelloResponse from the device');
|
||||
const summary = summarizeEsphome(session, seconds);
|
||||
finish({
|
||||
ok: true, host: args.host, port, ...summary,
|
||||
evidence: 'MEASURED: device-reported radar values read on this host (not an accuracy claim)',
|
||||
note: 'Values are computed by the device firmware and are not validated against a reference.',
|
||||
});
|
||||
}, seconds * 1000);
|
||||
});
|
||||
socket.on('error', (error) => fail('esphome_unreachable', `${target.address}:${port}: ${error.code || error.message}`, 'Check that the device is on the network and that `api:` is enabled in its ESPHome config.'));
|
||||
socket.on('close', () => { if (!done && session.hello) fail('esphome_closed', 'the device closed the API connection early'); else if (!done) fail('esphome_unreachable', 'connection closed before the API handshake'); });
|
||||
socket.on('data', (chunk) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
if (buf.length > MAX_BUFFER) return fail('esphome_protocol_error', 'response exceeded 1 MiB');
|
||||
let frames;
|
||||
try { ({ frames, rest: buf } = splitFrames(buf)); } catch (error) {
|
||||
if (error.reason === 'esphome_encrypted') return fail('esphome_encrypted', 'the device requires the encrypted (Noise) API', 'API encryption is not supported yet: read the value over USB logs, or use a build without `api: encryption:` on a trusted network.');
|
||||
return fail(error.reason || 'esphome_protocol_error', error.message);
|
||||
}
|
||||
for (const { type, payload } of frames) {
|
||||
let f;
|
||||
try { f = decodeFields(payload); } catch (error) { return fail('esphome_protocol_error', error.message); }
|
||||
switch (type) {
|
||||
case T.HELLO_RES:
|
||||
session.hello = true;
|
||||
session.device.apiVersion = `${fUint(f, 1)}.${fUint(f, 2)}`;
|
||||
session.device.serverInfo = fStr(f, 3);
|
||||
session.device.name = fStr(f, 4) || undefined;
|
||||
break;
|
||||
case T.CONNECT_RES:
|
||||
if (fBool(f, 1)) return fail('esphome_password_required', 'the device API requires a password', 'Password-protected ESPHome APIs are not supported; remove `api: password:` or use the USB log.');
|
||||
break;
|
||||
case T.DEVICE_INFO_RES:
|
||||
Object.assign(session.device, {
|
||||
name: fStr(f, 2) || session.device.name, esphomeVersion: fStr(f, 4) || undefined, compiled: fStr(f, 5) || undefined,
|
||||
model: fStr(f, 6) || undefined, project: fStr(f, 8) || undefined, projectVersion: fStr(f, 9) || undefined,
|
||||
});
|
||||
break;
|
||||
case T.PING_REQ:
|
||||
socket.write(encodeFrame(T.PING_RES));
|
||||
break;
|
||||
case T.LIST_BINARY: case T.LIST_SENSOR: case T.LIST_TEXT: {
|
||||
const key = fKey(f, 2);
|
||||
if (key !== null) session.entities.set(key, { kind: type === T.LIST_BINARY ? 'binary' : type === T.LIST_SENSOR ? 'sensor' : 'text', objectId: fStr(f, 1), name: fStr(f, 3), unit: type === T.LIST_SENSOR ? fStr(f, 6) : '', samples: [], last: null });
|
||||
break;
|
||||
}
|
||||
case T.LIST_DONE:
|
||||
socket.write(encodeFrame(T.SUBSCRIBE_STATES));
|
||||
break;
|
||||
case T.STATE_BINARY: case T.STATE_SENSOR: case T.STATE_TEXT: {
|
||||
const e = session.entities.get(fKey(f, 1));
|
||||
if (!e) break;
|
||||
const value = type === T.STATE_SENSOR ? fFloat(f, 2) : type === T.STATE_BINARY ? fBool(f, 2) : fStr(f, 2);
|
||||
const sample = { value, missing: fBool(f, 3) };
|
||||
session.updates += 1;
|
||||
if (e.samples.length < 10_000) e.samples.push(sample);
|
||||
e.last = sample;
|
||||
break;
|
||||
}
|
||||
default: break; // lights, switches, etc. are ignored (read-only client)
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// LiDAR access (ADR-373):
|
||||
// * rplidar — Slamtec RPLIDAR A1/A2/A3/C1/S-series legacy SCAN protocol over
|
||||
// USB serial. Starting a scan spins the motor/laser; stopping
|
||||
// is sent on exit. No persistent configuration is written.
|
||||
// * iphone — the RuView iPhone LiDAR relay (integrations/iphone-lidar),
|
||||
// read as a WebSocket client. Only depth statistics are
|
||||
// returned; raw depth never leaves this process.
|
||||
|
||||
import { readSerial } from './serial-pump.js';
|
||||
|
||||
const SCAN = 'a520';
|
||||
const STOP = 'a525';
|
||||
const DESCRIPTOR = [0xa5, 0x5a, 0x05, 0x00, 0x00, 0x40, 0x81];
|
||||
|
||||
/** Parse an RPLIDAR SCAN byte stream into measurement nodes. */
|
||||
export function parseRplidar(bytes) {
|
||||
let i = 0;
|
||||
// Skip to the scan response descriptor if present; otherwise assume raw nodes.
|
||||
for (let j = 0; j + DESCRIPTOR.length <= bytes.length; j++) {
|
||||
if (DESCRIPTOR.every((v, k) => bytes[j + k] === v)) { i = j + DESCRIPTOR.length; break; }
|
||||
}
|
||||
const points = [];
|
||||
let invalid = 0;
|
||||
let revolutions = 0;
|
||||
while (i + 5 <= bytes.length) {
|
||||
const b0 = bytes[i];
|
||||
const b1 = bytes[i + 1];
|
||||
const start = b0 & 1;
|
||||
const inv = (b0 >> 1) & 1;
|
||||
if (start === inv || (b1 & 1) !== 1) { invalid += 1; i += 1; continue; } // resync
|
||||
const angle = ((b1 >> 1) | (bytes[i + 2] << 7)) / 64;
|
||||
const distanceMm = (bytes[i + 3] | (bytes[i + 4] << 8)) / 4;
|
||||
if (start) revolutions += 1;
|
||||
points.push({ angle, distanceMm, quality: b0 >> 2, start: Boolean(start) });
|
||||
i += 5;
|
||||
}
|
||||
return { points, invalid, revolutions };
|
||||
}
|
||||
|
||||
const median = (xs) => {
|
||||
if (!xs.length) return null;
|
||||
const s = [...xs].sort((a, b) => a - b);
|
||||
return s[Math.floor(s.length / 2)];
|
||||
};
|
||||
|
||||
export function summarizeScan({ points, invalid, revolutions }, seconds) {
|
||||
const valid = points.filter((p) => p.distanceMm > 0);
|
||||
const d = valid.map((p) => p.distanceMm);
|
||||
const sectors = new Set(valid.map((p) => Math.floor(p.angle / 10) % 36));
|
||||
return {
|
||||
points: points.length, validPoints: valid.length, resyncBytes: invalid, revolutions,
|
||||
scanRateHz: Number((revolutions / seconds).toFixed(2)),
|
||||
rangeMm: d.length ? { min: Math.min(...d), median: median(d), max: Math.max(...d) } : null,
|
||||
angularCoverage: Number((sectors.size / 36).toFixed(2)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readRplidar(args, deps) {
|
||||
const seconds = args.seconds ?? 5;
|
||||
let cap;
|
||||
try {
|
||||
cap = await readSerial({ port: args.port, baud: args.baud ?? 115200, seconds, startHex: SCAN, stopHex: STOP, dtr: 'low', maxBytes: 4 * 1024 * 1024 }, deps);
|
||||
} catch (error) {
|
||||
return { ok: false, reason: error.reason || 'invalid_arguments', detail: error.message };
|
||||
}
|
||||
if (!cap.ok) return { ok: false, reason: cap.reason, detail: cap.detail, remedy: cap.remedy };
|
||||
const summary = summarizeScan(parseRplidar(cap.bytes), cap.seconds);
|
||||
if (!summary.validPoints) {
|
||||
return { ok: false, reason: 'no_scan_points', bytes: cap.bytes.length, ...summary, remedy: 'A2/A3/S-series need 115200/256000/1000000 baud per model; check the USB adapter powers the motor (A1 motor follows DTR).' };
|
||||
}
|
||||
return { ok: true, source: 'rplidar', bytes: cap.bytes.length, ...summary, ...(cap.warnings ? { warnings: cap.warnings } : {}), evidence: 'MEASURED: live scan read on this host' };
|
||||
}
|
||||
|
||||
/** Validate and reduce one ruview.lidar.depth.v1 packet to statistics. */
|
||||
export function depthStats(packet) {
|
||||
if (!packet || packet.type !== 'ruview.lidar.depth.v1') throw new Error('unsupported packet type');
|
||||
const depth = packet.depth || {};
|
||||
if (depth.encoding !== 'u16le-mm+u8-confidence') throw new Error('unsupported depth encoding');
|
||||
const { width, height } = depth;
|
||||
if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1 || width * height > 1_000_000) throw new Error('invalid depth dimensions');
|
||||
const mm = Buffer.from(String(depth.millimetersBase64 || ''), 'base64');
|
||||
const conf = Buffer.from(String(depth.confidenceBase64 || ''), 'base64');
|
||||
if (mm.length !== width * height * 2 || conf.length !== width * height) throw new Error('depth payload length mismatch');
|
||||
const values = [];
|
||||
let confident = 0;
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
const v = mm.readUInt16LE(i * 2);
|
||||
if (v > 0 && conf[i] >= 1) { values.push(v); confident += 1; }
|
||||
}
|
||||
return { width, height, confidentFraction: Number((confident / (width * height)).toFixed(3)), medianDepthM: values.length ? median(values) / 1000 : null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the iPhone LiDAR relay as a WebSocket client.
|
||||
* args: { url: ws(s)://host:port/ws/lidar, seconds, max_frames }. Returns early
|
||||
* once max_frames arrive or the connection is refused. The access token comes
|
||||
* only from RUVIEW_LIDAR_TOKEN (never from tool arguments or logs).
|
||||
*/
|
||||
export function readIphoneLidar(args, deps = {}) {
|
||||
const seconds = Math.min(Math.max(args.seconds ?? 5, 1), 60);
|
||||
const maxFrames = Math.min(Math.max(args.max_frames ?? 10_000, 1), 10_000);
|
||||
let url;
|
||||
try {
|
||||
url = new URL(args.url);
|
||||
if (!['ws:', 'wss:'].includes(url.protocol)) throw new Error('url must be ws:// or wss://');
|
||||
if (url.searchParams.has('token')) throw new Error('put the relay token in RUVIEW_LIDAR_TOKEN, not in the URL');
|
||||
} catch (error) {
|
||||
return Promise.resolve({ ok: false, reason: 'invalid_url', detail: error.message });
|
||||
}
|
||||
const token = (deps.env || process.env).RUVIEW_LIDAR_TOKEN;
|
||||
if (token) url.searchParams.set('token', token);
|
||||
const WS = deps.WebSocket || globalThis.WebSocket;
|
||||
if (typeof WS !== 'function') return Promise.resolve({ ok: false, reason: 'websocket_unavailable', remedy: 'Use Node 22+ (built-in WebSocket client).' });
|
||||
const shown = `${url.protocol}//${url.host}${url.pathname}`;
|
||||
return new Promise((resolve) => {
|
||||
const frames = [];
|
||||
let rejected = 0;
|
||||
let lastError = null;
|
||||
let ws;
|
||||
let done = false;
|
||||
let opened = false;
|
||||
const finish = (extra = {}) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
try { ws?.close(); } catch { /* ignore */ }
|
||||
const n = frames.length;
|
||||
resolve({
|
||||
ok: n > 0 && !extra.reason,
|
||||
source: 'iphone', relay: shown, seconds, frames: n, rejectedFrames: rejected,
|
||||
fps: Number((n / Math.max((Date.now() - started) / 1000, 0.001)).toFixed(2)),
|
||||
...(n ? { last: frames.at(-1), confidentFractionMean: Number((frames.reduce((a, f) => a + f.confidentFraction, 0) / n).toFixed(3)) } : {}),
|
||||
...(n === 0 && !extra.reason ? (opened
|
||||
? { reason: 'no_frames', detail: lastError, remedy: 'Connected, but the iPhone is not streaming: start capture in the RuView LiDAR app pointed at this relay.' }
|
||||
: { reason: 'connect_failed', detail: lastError, remedy: 'Check the relay is running at this URL and RUVIEW_LIDAR_TOKEN matches the token it printed.' }) : {}),
|
||||
evidence: n ? 'MEASURED: live depth frames received on this host' : null,
|
||||
...extra,
|
||||
});
|
||||
};
|
||||
const started = Date.now();
|
||||
const timer = setTimeout(() => finish(), seconds * 1000);
|
||||
try {
|
||||
ws = new WS(url.href);
|
||||
} catch (error) {
|
||||
finish({ ok: false, reason: 'connect_failed', detail: error.message });
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => { opened = true; };
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8');
|
||||
if (text.length > 4_000_000) throw new Error('frame too large');
|
||||
frames.push(depthStats(JSON.parse(text)));
|
||||
if (frames.length >= maxFrames) finish();
|
||||
} catch (error) { rejected += 1; lastError = error.message; }
|
||||
};
|
||||
ws.onerror = () => {
|
||||
lastError = 'websocket error';
|
||||
if (!opened) finish(); // refused before opening: nothing will arrive
|
||||
};
|
||||
ws.onclose = (event) => {
|
||||
if (!opened) finish();
|
||||
else if (!frames.length && event?.code && event.code !== 1000) finish({ ok: false, reason: 'relay_closed', detail: `close code ${event.code}${event.code === 1008 ? ' (token rejected?)' : ''}` });
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// mmWave radar access (ADR-373): Seeed MR60BHA2 (60 GHz, 115200 baud) and
|
||||
// HLK-LD2410 (24 GHz, 256000 baud) over a USB-UART adapter. The parsers are
|
||||
// byte-for-byte ports of firmware/esp32-csi-node/main/mmwave_sensor.c so the
|
||||
// host and the node agree on what a valid frame is.
|
||||
|
||||
import { readSerial } from './serial-pump.js';
|
||||
|
||||
export const MMWAVE_MODELS = Object.freeze({
|
||||
mr60bha2: Object.freeze({ baud: 115200, band: '60 GHz', vendor: 'Seeed' }),
|
||||
ld2410: Object.freeze({ baud: 256000, band: '24 GHz', vendor: 'HLK' }),
|
||||
});
|
||||
|
||||
const MR60 = Object.freeze({ SOF: 0x01, MAX_PAYLOAD: 30, BREATHING: 0x0a14, HEART: 0x0a15, DISTANCE: 0x0a16, PRESENCE: 0x0f09, PHASE: 0x0a13, POINTCLOUD: 0x0a04 });
|
||||
|
||||
/** ~XOR checksum used by the Seeed protocol. */
|
||||
export function mr60Checksum(bytes) {
|
||||
let x = 0;
|
||||
for (const b of bytes) x ^= b;
|
||||
return (~x) & 0xff;
|
||||
}
|
||||
|
||||
/** Streaming MR60BHA2 parser. feed() returns decoded frames. */
|
||||
export class Mr60Parser {
|
||||
constructor() { this.reset(); this.errors = 0; }
|
||||
reset() { this.state = 'sof'; this.header = []; this.data = []; }
|
||||
feed(bytes) {
|
||||
const frames = [];
|
||||
for (const b of bytes) {
|
||||
if (this.state === 'sof') {
|
||||
if (b === MR60.SOF) { this.header = [b]; this.state = 'header'; }
|
||||
} else if (this.state === 'header') {
|
||||
this.header.push(b);
|
||||
if (this.header.length === 8) {
|
||||
if (mr60Checksum(this.header.slice(0, 7)) !== this.header[7]) { this.errors += 1; this.reset(); continue; }
|
||||
this.len = (this.header[3] << 8) | this.header[4];
|
||||
this.type = (this.header[5] << 8) | this.header[6];
|
||||
this.data = [];
|
||||
if (this.len > MR60.MAX_PAYLOAD) { this.errors += 1; this.reset(); } else this.state = this.len === 0 ? 'cksum' : 'data';
|
||||
}
|
||||
} else if (this.state === 'data') {
|
||||
this.data.push(b);
|
||||
if (this.data.length >= this.len) this.state = 'cksum';
|
||||
} else {
|
||||
if (this.len === 0 || mr60Checksum(this.data) === b) frames.push(decodeMr60(this.type, Buffer.from(this.data)));
|
||||
else this.errors += 1;
|
||||
this.reset();
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeMr60(type, d) {
|
||||
switch (type) {
|
||||
case MR60.BREATHING: {
|
||||
const v = d.length >= 4 ? d.readFloatLE(0) : NaN;
|
||||
return { type: 'breathing', breathingBpm: v >= 0 && v <= 60 ? v : null };
|
||||
}
|
||||
case MR60.HEART: {
|
||||
const v = d.length >= 4 ? d.readFloatLE(0) : NaN;
|
||||
return { type: 'heart', heartBpm: v >= 0 && v <= 250 ? v : null };
|
||||
}
|
||||
case MR60.DISTANCE:
|
||||
return d.length >= 8 && d.readUInt32LE(0) !== 0 ? { type: 'distance', distanceCm: d.readFloatLE(4) } : { type: 'distance', distanceCm: null };
|
||||
case MR60.PRESENCE:
|
||||
return { type: 'presence', present: d.length >= 1 ? d[0] !== 0 : null };
|
||||
case MR60.PHASE: return { type: 'phase', bytes: d.length };
|
||||
case MR60.POINTCLOUD: return { type: 'pointcloud', bytes: d.length };
|
||||
default: return { type: `0x${type.toString(16).padStart(4, '0')}`, bytes: d.length };
|
||||
}
|
||||
}
|
||||
|
||||
const LD_HEAD = [0xf4, 0xf3, 0xf2, 0xf1];
|
||||
const LD_TAIL = [0xf8, 0xf7, 0xf6, 0xf5];
|
||||
|
||||
/** Streaming HLK-LD2410 report parser. */
|
||||
export class Ld2410Parser {
|
||||
constructor() { this.state = 0; this.errors = 0; this.data = []; }
|
||||
feed(bytes) {
|
||||
const frames = [];
|
||||
for (const b of bytes) {
|
||||
if (this.state < 4) {
|
||||
this.state = b === LD_HEAD[this.state] ? this.state + 1 : (b === LD_HEAD[0] ? 1 : 0);
|
||||
} else if (this.state === 4) { this.len = b; this.state = 5; }
|
||||
else if (this.state === 5) {
|
||||
this.len |= b << 8;
|
||||
this.data = [];
|
||||
this.state = this.len === 0 || this.len > 256 ? 0 : 6;
|
||||
} else if (this.state === 6) {
|
||||
this.data.push(b);
|
||||
if (this.data.length >= this.len) this.state = 7;
|
||||
} else {
|
||||
const i = this.state - 7;
|
||||
if (b !== LD_TAIL[i]) { this.errors += 1; this.state = 0; continue; }
|
||||
if (i === 3) { const f = decodeLd2410(this.data); if (f) frames.push(f); this.state = 0; } else this.state += 1;
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeLd2410(d) {
|
||||
if (d.length < 12 || d[1] !== 0xaa) return null;
|
||||
const target = d[2];
|
||||
const moving = d[3] | (d[4] << 8);
|
||||
const stat = d[6] | (d[7] << 8);
|
||||
return {
|
||||
type: 'target',
|
||||
mode: d[0] === 0x01 ? 'engineering' : 'basic',
|
||||
targetState: ['none', 'moving', 'static', 'moving+static'][target] ?? `0x${target.toString(16)}`,
|
||||
present: target !== 0,
|
||||
movingCm: moving, movingEnergy: d[5], staticCm: stat, staticEnergy: d[8], detectCm: d[9] | (d[10] << 8),
|
||||
distanceCm: target === 1 || target === 3 ? moving : target === 2 ? stat : 0,
|
||||
};
|
||||
}
|
||||
|
||||
const mean = (xs) => (xs.length ? Number((xs.reduce((a, b) => a + b, 0) / xs.length).toFixed(2)) : null);
|
||||
|
||||
/** Summarize decoded frames from either model. */
|
||||
export function summarizeMmwave(model, frames, errors, bytes, seconds) {
|
||||
const pick = (key) => frames.map((f) => f[key]).filter((v) => typeof v === 'number' && Number.isFinite(v));
|
||||
const presence = frames.filter((f) => typeof f.present === 'boolean');
|
||||
const types = {};
|
||||
for (const f of frames) types[f.type] = (types[f.type] || 0) + 1;
|
||||
return {
|
||||
model, band: MMWAVE_MODELS[model].band, bytes, frames: frames.length, checksumErrors: errors,
|
||||
frameRateHz: Number((frames.length / seconds).toFixed(2)), frameTypes: types,
|
||||
presentFraction: presence.length ? Number((presence.filter((f) => f.present).length / presence.length).toFixed(3)) : null,
|
||||
breathingBpmMean: mean(pick('breathingBpm')),
|
||||
heartBpmMean: mean(pick('heartBpm')),
|
||||
distanceCmMean: mean(pick('distanceCm')),
|
||||
last: frames.slice(-5),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMmwave(model, bytes) {
|
||||
const parser = model === 'ld2410' ? new Ld2410Parser() : new Mr60Parser();
|
||||
const frames = parser.feed(bytes);
|
||||
return { frames, errors: parser.errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an mmWave radar. args: { port, model: auto|mr60bha2|ld2410, seconds }.
|
||||
* Auto-detect probes each model's baud for up to 2 s; the probe's frames are
|
||||
* kept and only the remainder of the window is read afterwards, so a request
|
||||
* for N seconds costs about N seconds rather than probe + N.
|
||||
*/
|
||||
export async function readMmwave(args, deps) {
|
||||
const seconds = args.seconds ?? 10;
|
||||
const auto = !args.model || args.model === 'auto';
|
||||
const models = auto ? ['mr60bha2', 'ld2410'] : [args.model];
|
||||
const probeSeconds = auto ? Math.min(seconds, 2) : seconds;
|
||||
const attempts = [];
|
||||
const capture = async (model, secs) => {
|
||||
try {
|
||||
return await readSerial({ port: args.port, baud: MMWAVE_MODELS[model].baud, seconds: secs }, deps);
|
||||
} catch (error) {
|
||||
return { ok: false, reason: error.reason || 'invalid_arguments', detail: error.message };
|
||||
}
|
||||
};
|
||||
for (const model of models) {
|
||||
const cap = await capture(model, probeSeconds);
|
||||
if (!cap.ok) return { ok: false, reason: cap.reason, detail: cap.detail, remedy: cap.remedy };
|
||||
let { frames, errors } = parseMmwave(model, cap.bytes);
|
||||
attempts.push({ model, bytes: cap.bytes.length, frames: frames.length, errors });
|
||||
if (frames.length < 2) continue;
|
||||
let bytes = cap.bytes.length;
|
||||
let elapsed = cap.seconds;
|
||||
const remaining = seconds - probeSeconds;
|
||||
if (auto && remaining >= 0.5) {
|
||||
const more = await capture(model, remaining);
|
||||
if (more.ok) {
|
||||
// A fresh parser: the probe may have ended mid-frame.
|
||||
const extra = parseMmwave(model, more.bytes);
|
||||
frames = frames.concat(extra.frames);
|
||||
errors += extra.errors;
|
||||
bytes += more.bytes.length;
|
||||
elapsed += more.seconds;
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
...summarizeMmwave(model, frames, errors, bytes, elapsed),
|
||||
...(auto ? { detected: model } : {}),
|
||||
evidence: 'MEASURED: device-reported radar values read on this host (not an accuracy claim)',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false, reason: 'no_valid_frames', attempts,
|
||||
remedy: 'Check TX/RX wiring (crossed), 5 V power, and model/baud (MR60BHA2 115200, LD2410 256000). A C6+MR60 board streams through ESPHome/firmware instead — use its serial log or the node UDP stream.',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Device discovery (ADR-373): enumerate serial ports and classify them by USB
|
||||
// VID:PID into likely RuView roles. Classification is a hint, not identity —
|
||||
// many boards share generic USB-UART bridges, so each candidate lists the
|
||||
// read command that confirms it. `realtek` marks RTL8721Dx (Ameba) CSI boards
|
||||
// (ADR-323), which stream RAC1 over UDP and log at 1500000 baud.
|
||||
|
||||
import { listSerialPorts } from '../firmware.js';
|
||||
|
||||
/** Known USB bridges/native USB in the RuView hardware set. */
|
||||
export const USB_IDS = Object.freeze({
|
||||
'303A:1001': { chip: 'Espressif native USB (ESP32-S3/C3/C6 USB-Serial/JTAG)', roles: ['esp32'] },
|
||||
'303A:4001': { chip: 'Espressif native USB (ESP32-S2/S3 CDC)', roles: ['esp32'] },
|
||||
'10C4:EA60': { chip: 'Silicon Labs CP210x', roles: ['esp32', 'rplidar', 'mmwave'] },
|
||||
'1A86:7523': { chip: 'WCH CH340', roles: ['esp32', 'mmwave'] },
|
||||
'1A86:55D4': { chip: 'WCH CH9102', roles: ['esp32', 'mmwave'] },
|
||||
'1A86:55D3': { chip: 'WCH CH343', roles: ['esp32', 'mmwave'] },
|
||||
'0403:6001': { chip: 'FTDI FT232R', roles: ['mmwave', 'rplidar', 'esp32'] },
|
||||
'0403:6015': { chip: 'FTDI FT231X', roles: ['mmwave', 'esp32'] },
|
||||
'067B:23A3': { chip: 'Prolific PL2303GC (Realtek Ameba RTL8721Dx boards)', roles: ['realtek'] },
|
||||
'067B:2303': { chip: 'Prolific PL2303', roles: ['realtek', 'esp32'] },
|
||||
'2341:0043': { chip: 'Arduino Uno', roles: [] },
|
||||
});
|
||||
|
||||
const NEXT = Object.freeze({
|
||||
esp32: (p) => `ruview doctor --port ${p} --probe | ruview monitor --port ${p}`,
|
||||
mmwave: (p) => `ruview mmwave --port ${p} --model auto`,
|
||||
rplidar: (p) => `ruview lidar --source rplidar --port ${p}`,
|
||||
realtek: (p) => `ruview monitor --port ${p} --baud 1500000 | ruview esp32 --seconds 10 (RAC1 over UDP)`,
|
||||
});
|
||||
|
||||
export function classifyPort(port) {
|
||||
const key = port.vid && port.pid ? `${port.vid}:${port.pid}` : null;
|
||||
const known = key ? USB_IDS[key] : null;
|
||||
const text = `${port.description || ''} ${port.hwid || ''}`;
|
||||
let roles = known ? [...known.roles] : [];
|
||||
if (/slamtec|rplidar/i.test(text)) roles = ['rplidar'];
|
||||
else if (/espressif|esp32|usb jtag/i.test(text) && !roles.includes('esp32')) roles.unshift('esp32');
|
||||
const builtin = /ttyS\d+|Bluetooth|debug-console|wlan-debug/i.test(port.port + text);
|
||||
return {
|
||||
...port,
|
||||
usb: key,
|
||||
bridge: known?.chip || null,
|
||||
likelyRoles: builtin ? [] : roles,
|
||||
builtin,
|
||||
confirmWith: builtin ? [] : roles.map((r) => NEXT[r](port.port)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function scanDevices(deps) {
|
||||
const listed = await listSerialPorts(deps);
|
||||
if (!listed.ok) return { ok: false, reason: listed.reason, remedy: listed.reason === 'pyserial_missing' ? 'pip install pyserial' : listed.remedy, devices: [] };
|
||||
const devices = listed.ports.map(classifyPort);
|
||||
const candidates = devices.filter((d) => d.likelyRoles.length);
|
||||
return {
|
||||
ok: true,
|
||||
devices,
|
||||
candidates: candidates.length,
|
||||
network: {
|
||||
esp32Udp: 'ESP32 nodes stream UDP to their provisioned target; run `ruview esp32 --seconds 10` on that host (default UDP 5005).',
|
||||
iphoneLidar: 'Start integrations/iphone-lidar/web relay, export RUVIEW_LIDAR_TOKEN, then `ruview lidar --source iphone --url ws://HOST:8787/ws/lidar`.',
|
||||
},
|
||||
note: candidates.length ? 'Roles are VID:PID hints; confirm with the listed read command.' : 'No USB serial devices matched known bridges. Check the cable (data-capable) and drivers (CP210x/CH34x/PL2303).',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Bounded raw serial capture through pyserial (ADR-373). Node has no built-in
|
||||
// serial API and the package stays dependency-free, so a fixed Python script
|
||||
// streams lowercase-hex chunks; all parameters are argv, never spliced into
|
||||
// source. Hex (not base64) because process output passes through secret
|
||||
// redaction, whose patterns cannot match the [0-9a-f] alphabet.
|
||||
|
||||
import { validatePort } from '../firmware.js';
|
||||
|
||||
export const PUMP_SCRIPT = [
|
||||
'import sys,time,binascii',
|
||||
'try:',
|
||||
' import serial',
|
||||
'except Exception:',
|
||||
" print('NO_PYSERIAL'); sys.exit(3)",
|
||||
'port,baud,dur,maxb,start,stop,dtr=sys.argv[1],int(sys.argv[2]),float(sys.argv[3]),int(sys.argv[4]),sys.argv[5],sys.argv[6],sys.argv[7]',
|
||||
'try:',
|
||||
' ser=serial.Serial(port,baud,timeout=0.2)',
|
||||
'except Exception as e:',
|
||||
" print('OPEN_ERROR '+str(e)[:200]); sys.exit(4)",
|
||||
'try:',
|
||||
" if dtr=='low': ser.dtr=False",
|
||||
" if dtr=='high': ser.dtr=True",
|
||||
'except Exception:',
|
||||
" print('DTR_UNSUPPORTED')",
|
||||
'if start: ser.write(binascii.unhexlify(start)); ser.flush()',
|
||||
'n=0; end=time.time()+dur',
|
||||
'while time.time()<end and n<maxb:',
|
||||
' d=ser.read(4096)',
|
||||
' if d:',
|
||||
' d=d[:maxb-n]; n+=len(d)',
|
||||
" print('HEX '+binascii.hexlify(d).decode()); sys.stdout.flush()",
|
||||
'if stop: ser.write(binascii.unhexlify(stop)); ser.flush()',
|
||||
'ser.close()',
|
||||
"print('END '+str(n))",
|
||||
].join('\n');
|
||||
|
||||
const HEX_RE = /^(?:[0-9a-fA-F]{2}){0,64}$/;
|
||||
export const SERIAL_BAUDS = Object.freeze([9600, 19200, 38400, 57600, 115200, 230400, 256000, 460800, 921600]);
|
||||
export const MAX_CAPTURE_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Capture raw bytes from a serial port.
|
||||
* opts: { port, baud, seconds, maxBytes, startHex, stopHex, dtr: 'keep'|'low'|'high' }
|
||||
* Returns { ok, bytes: Buffer, reason?, detail? }.
|
||||
*/
|
||||
export async function readSerial(opts, deps) {
|
||||
validatePort(opts.port);
|
||||
if (!SERIAL_BAUDS.includes(opts.baud)) throw Object.assign(new Error(`baud must be one of ${SERIAL_BAUDS.join(', ')}`), { reason: 'invalid_baud' });
|
||||
const seconds = Math.min(Math.max(Number(opts.seconds) || 5, 0.5), 120);
|
||||
const maxBytes = Math.min(Math.max(Number(opts.maxBytes) || 1_048_576, 1), MAX_CAPTURE_BYTES);
|
||||
const startHex = opts.startHex || '';
|
||||
const stopHex = opts.stopHex || '';
|
||||
if (!HEX_RE.test(startHex) || !HEX_RE.test(stopHex)) throw Object.assign(new Error('command bytes must be short hex strings'), { reason: 'invalid_command' });
|
||||
const dtr = opts.dtr || 'keep';
|
||||
if (!['keep', 'low', 'high'].includes(dtr)) throw Object.assign(new Error('dtr must be keep, low, or high'), { reason: 'invalid_dtr' });
|
||||
|
||||
const python = deps.python();
|
||||
if (!python) return { ok: false, reason: 'python_missing', remedy: 'Install Python 3 and `pip install pyserial`.', bytes: Buffer.alloc(0) };
|
||||
const r = await deps.exec(python, ['-c', PUMP_SCRIPT, opts.port, String(opts.baud), String(seconds), String(maxBytes), startHex, stopHex, dtr], {
|
||||
timeoutMs: (seconds + 15) * 1000,
|
||||
maxOutputBytes: maxBytes * 2 + Math.ceil(maxBytes / 2048) * 8 + 65_536,
|
||||
fullOutput: true,
|
||||
});
|
||||
const out = r.stdout;
|
||||
if (out.includes('NO_PYSERIAL')) return { ok: false, reason: 'pyserial_missing', remedy: 'pip install pyserial', bytes: Buffer.alloc(0) };
|
||||
const openError = /OPEN_ERROR (.*)/.exec(out);
|
||||
if (openError) {
|
||||
return { ok: false, reason: 'port_open_failed', detail: openError[1], remedy: 'Close other serial monitors (ESP-IDF monitor, Arduino, the sensing server) and check permissions (Linux: dialout group).', bytes: Buffer.alloc(0) };
|
||||
}
|
||||
const chunks = [];
|
||||
for (const line of out.split(/\r?\n/)) if (/^HEX [0-9a-f]*$/.test(line)) chunks.push(Buffer.from(line.slice(4), 'hex'));
|
||||
const bytes = Buffer.concat(chunks);
|
||||
if (!r.ok && !bytes.length) return { ok: false, reason: 'capture_failed', detail: (r.stderr || r.error || '').slice(-400), bytes };
|
||||
return { ok: true, bytes, seconds, ...(out.includes('DTR_UNSUPPORTED') ? { warnings: ['port does not support DTR control; an RPLIDAR A1 motor may not spin'] } : {}) };
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Structured debugging doctor (ADR-372).
|
||||
//
|
||||
// Every check returns {id, group, status: pass|warn|fail|skip, detail, remedy}.
|
||||
// `fail` means a requested capability cannot work; optional tooling that is
|
||||
// absent is `warn`. The doctor is read-only: it never flashes, writes, or
|
||||
// opens a serial port unless `probe_port` is explicitly requested (esptool
|
||||
// chip_id resets the board but writes nothing).
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { claimCheck } from './guardrails.js';
|
||||
import { loadBrain } from './brain.js';
|
||||
import { FIRMWARE_VARIANTS, listSerialPorts, parseChip, resolveBundle, validatePort } from './firmware.js';
|
||||
import { classifyPort } from './devices/registry.js';
|
||||
import { loadHosts } from './remote.js';
|
||||
|
||||
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
export const DOCTOR_GROUPS = Object.freeze(['runtime', 'harness', 'hosts', 'repo', 'rust', 'python', 'firmware', 'serial', 'devices', 'remote', 'sensing', 'kernel']);
|
||||
const sha = (value) => createHash('sha256').update(value).digest('hex');
|
||||
|
||||
/** Verify the packaged provenance manifest (same rules as scripts/verify-manifest.mjs). */
|
||||
export function verifyPackageManifest(root = ROOT) {
|
||||
const path = join(root, '.harness', 'manifest.json');
|
||||
if (!existsSync(path)) return { ok: false, findings: ['manifest.json:missing'] };
|
||||
const raw = readFileSync(path);
|
||||
const manifest = JSON.parse(raw);
|
||||
const findings = [];
|
||||
for (const [name, expected] of Object.entries(manifest.files || {})) {
|
||||
const target = join(root, name);
|
||||
if (!existsSync(target)) findings.push(`${name}:missing`);
|
||||
else if (sha(readFileSync(target, 'utf8').replace(/\r\n/g, '\n')) !== expected) findings.push(`${name}:hash-mismatch`);
|
||||
}
|
||||
const outer = readFileSync(join(root, '.harness', 'manifest.sha256'), 'utf8').trim().split(/\s+/)[0];
|
||||
if (sha(raw) !== outer) findings.push('manifest.sha256:mismatch');
|
||||
return { ok: findings.length === 0, files: Object.keys(manifest.files || {}).length, findings };
|
||||
}
|
||||
|
||||
const firstLine = (text) => String(text || '').trim().split(/\r?\n/)[0]?.slice(0, 200) || '';
|
||||
|
||||
/**
|
||||
* Run diagnostics. args: { groups?, port?, probe_port?, sensing_url? }.
|
||||
* deps: { exec, which, findRepoRoot, python, importer, fetch }.
|
||||
*/
|
||||
export async function runDoctor(args = {}, deps) {
|
||||
const groups = new Set(args.groups?.length ? args.groups : DOCTOR_GROUPS);
|
||||
const checks = [];
|
||||
const add = (group, id, status, detail, remedy) => checks.push({ group, id, status, detail, ...(remedy ? { remedy } : {}) });
|
||||
const want = (g) => groups.has(g);
|
||||
|
||||
if (want('runtime')) {
|
||||
const major = Number(process.versions.node.split('.')[0]);
|
||||
add('runtime', 'node-version', major >= 20 ? 'pass' : 'fail', `Node ${process.version} on ${process.platform}-${process.arch}`, major >= 20 ? null : 'Install Node.js 20 or newer.');
|
||||
}
|
||||
|
||||
if (want('harness')) {
|
||||
const manifest = verifyPackageManifest();
|
||||
add('harness', 'package-integrity', manifest.ok ? 'pass' : 'warn',
|
||||
manifest.ok ? `${manifest.files} packaged files match .harness/manifest.json` : `manifest drift: ${manifest.findings.slice(0, 5).join(', ')}`,
|
||||
manifest.ok ? null : 'Reinstall the package; in a checkout run `npm run manifest:update` after intentional edits.');
|
||||
const guard = !claimCheck('We hit 100% accuracy on poses.').ok && claimCheck('Held-out PCK@20 59.5% (MEASURED vs mean-pose baseline, verify.py).').ok;
|
||||
add('harness', 'claim-guardrail', guard ? 'pass' : 'fail', guard ? 'claim_check flags overclaims and passes tagged claims' : 'claim_check self-test failed', guard ? null : 'Reinstall @ruvnet/ruview.');
|
||||
try {
|
||||
const brain = loadBrain();
|
||||
add('harness', 'shared-brain', 'pass', `${brain.records.length} reviewed records, digest ${brain.digest.slice(0, 12)}`);
|
||||
} catch (error) {
|
||||
add('harness', 'shared-brain', 'fail', error.message, 'Reinstall the package; the reviewed corpus is corrupt.');
|
||||
}
|
||||
}
|
||||
|
||||
if (want('hosts')) {
|
||||
for (const [id, bin, remedy] of [['claude-code', 'claude', 'npm i -g @anthropic-ai/claude-code'], ['codex', 'codex', 'npm i -g @openai/codex']]) {
|
||||
const path = deps.which(bin);
|
||||
add('hosts', id, path ? 'pass' : 'warn', path ? `found ${path}` : `${bin} not on PATH (optional: agent run)`, path ? null : remedy);
|
||||
}
|
||||
}
|
||||
|
||||
const repo = deps.findRepoRoot();
|
||||
if (want('repo')) {
|
||||
if (!repo) add('repo', 'checkout', 'warn', 'not inside a RuView checkout (verify/train/build need one)', 'git clone https://github.com/ruvnet/RuView && cd RuView');
|
||||
else {
|
||||
add('repo', 'checkout', 'pass', repo);
|
||||
const missing = ['vendor/rufield/Cargo.toml', 'v2/crates/ruview-swarm/Cargo.toml'].filter((p) => !existsSync(join(repo, p)));
|
||||
add('repo', 'submodules', missing.length ? 'warn' : 'pass', missing.length ? `uninitialized: ${missing.join(', ')}` : 'workspace submodules present',
|
||||
missing.length ? 'git submodule update --init --recursive' : null);
|
||||
}
|
||||
}
|
||||
|
||||
if (want('rust')) {
|
||||
// Run inside v2/ so a pinned rust-toolchain file selects the same toolchain CI uses.
|
||||
const rustCwd = repo && existsSync(join(repo, 'v2')) ? join(repo, 'v2') : undefined;
|
||||
const cargo = deps.which('cargo');
|
||||
if (!cargo) add('rust', 'cargo', 'warn', 'cargo not on PATH (needed for training, calibration from source, kernel builds)', 'Install Rust: https://rustup.rs');
|
||||
else {
|
||||
const v = await deps.exec(cargo, ['--version'], { cwd: rustCwd, timeoutMs: 20_000 });
|
||||
add('rust', 'cargo', v.ok ? 'pass' : 'warn', firstLine(v.stdout) || v.error);
|
||||
const rustup = deps.which('rustup');
|
||||
if (rustup) {
|
||||
const t = await deps.exec(rustup, ['target', 'list', '--installed'], { cwd: rustCwd, timeoutMs: 20_000 });
|
||||
const wasm = t.stdout.includes('wasm32-unknown-unknown');
|
||||
add('rust', 'wasm32-target', wasm ? 'pass' : 'warn', wasm ? 'wasm32-unknown-unknown installed' : 'wasm32-unknown-unknown missing (kernel WASM builds)', wasm ? null : 'rustup target add wasm32-unknown-unknown');
|
||||
}
|
||||
}
|
||||
const bin = deps.which('wifi-densepose');
|
||||
add('rust', 'wifi-densepose-cli', bin ? 'pass' : 'warn', bin ? `found ${bin}` : 'wifi-densepose binary not on PATH; calibration/room training will use cargo run',
|
||||
bin ? null : 'cargo install --path v2/crates/wifi-densepose-cli');
|
||||
}
|
||||
|
||||
const python = deps.python();
|
||||
if (want('python')) {
|
||||
if (!python) add('python', 'python', 'warn', 'python not on PATH (needed for flashing, serial monitor, proof)', 'Install Python 3.9+');
|
||||
else {
|
||||
const v = await deps.exec(python, ['--version'], { timeoutMs: 20_000 });
|
||||
add('python', 'python', 'pass', `${python}: ${firstLine(v.stdout || v.stderr)}`);
|
||||
const serial = await deps.exec(python, ['-c', 'import serial;print(serial.__version__)'], { timeoutMs: 20_000 });
|
||||
add('python', 'pyserial', serial.ok ? 'pass' : 'warn', serial.ok ? `pyserial ${firstLine(serial.stdout)}` : 'pyserial not importable', serial.ok ? null : 'pip install pyserial');
|
||||
const esp = await deps.exec(python, ['-m', 'esptool', 'version'], { timeoutMs: 30_000 });
|
||||
const espVersion = /v?(\d+\.\d+(?:\.\d+)?)/.exec(esp.stdout)?.[1];
|
||||
add('python', 'esptool', esp.ok && espVersion ? 'pass' : 'warn', esp.ok && espVersion ? `esptool ${espVersion}` : 'esptool not importable', esp.ok ? null : 'pip install esptool');
|
||||
}
|
||||
}
|
||||
|
||||
if (want('firmware')) {
|
||||
const binsRoot = repo && join(repo, 'firmware', 'esp32-csi-node', 'release_bins');
|
||||
if (!binsRoot || !existsSync(binsRoot)) add('firmware', 'bundles', 'skip', 'no firmware/esp32-csi-node/release_bins in this checkout; pass --bundle <extracted release bundle> to flash');
|
||||
else {
|
||||
for (const name of readdirSync(binsRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort()) {
|
||||
const variant = name.startsWith('c6') ? 'c6' : 's3-8mb';
|
||||
try {
|
||||
const b = resolveBundle(join(binsRoot, name), variant);
|
||||
add('firmware', `bundle:${name}`, 'pass', `${b.chipName} ${b.images.length} images verified against ${b.checksums}`);
|
||||
} catch (error) {
|
||||
add('firmware', `bundle:${name}`, 'fail', error.message, 'Re-download the release bundle and verify SHA256SUMS before flashing.');
|
||||
}
|
||||
}
|
||||
}
|
||||
add('firmware', 'variants', 'pass', `supported: ${Object.keys(FIRMWARE_VARIANTS).join(', ')}`);
|
||||
}
|
||||
|
||||
if (want('serial') && python) {
|
||||
const ports = await listSerialPorts(deps);
|
||||
if (!ports.ok) add('serial', 'ports', 'warn', `cannot enumerate ports (${ports.reason})`, ports.reason === 'pyserial_missing' ? 'pip install pyserial' : null);
|
||||
else add('serial', 'ports', ports.ports.length ? 'pass' : 'warn', ports.ports.length ? ports.ports.map((p) => `${p.port} ${p.description}`).join('; ').slice(0, 600) : 'no serial ports found',
|
||||
ports.ports.length ? null : 'Connect the board with a data-capable USB cable; install the CP210x/CH340 driver if needed.');
|
||||
if (args.port) {
|
||||
try {
|
||||
validatePort(args.port);
|
||||
const listed = ports.ports.some((p) => p.port === args.port);
|
||||
add('serial', 'port', listed ? 'pass' : 'warn', listed ? `${args.port} present` : `${args.port} not in enumerated ports`, listed ? null : 'Check the port name; on Linux add your user to the dialout group.');
|
||||
if (args.probe_port === true) {
|
||||
const probe = await deps.exec(python, ['-m', 'esptool', '--port', args.port, 'chip_id'], { timeoutMs: 60_000 });
|
||||
const chip = parseChip(probe.stdout + probe.stderr);
|
||||
add('serial', 'chip', chip ? 'pass' : 'fail', chip ? `detected ${chip}` : `no chip response: ${firstLine(probe.stderr || probe.error)}`,
|
||||
chip ? null : 'Close other serial monitors; hold BOOT and tap RESET; try --baud 115200.');
|
||||
}
|
||||
} catch (error) {
|
||||
add('serial', 'port', 'fail', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (want('devices') && python) {
|
||||
const listed = await listSerialPorts(deps);
|
||||
if (listed.ok) {
|
||||
const devices = listed.ports.map(classifyPort).filter((d) => d.likelyRoles.length);
|
||||
if (!devices.length) add('devices', 'usb-serial', 'warn', 'no ESP32 / Realtek / mmWave / RPLIDAR candidates on USB', 'Use a data-capable cable; install CP210x, CH34x or PL2303 drivers; on Linux add your user to dialout.');
|
||||
for (const d of devices) add('devices', `usb:${d.port}`, 'pass', `${d.bridge || d.usb}: likely ${d.likelyRoles.join('/')} — confirm: ${d.confirmWith[0]}`);
|
||||
} else add('devices', 'usb-serial', 'warn', `cannot enumerate (${listed.reason})`, listed.reason === 'pyserial_missing' ? 'pip install pyserial' : null);
|
||||
add('devices', 'network-streams', 'skip', 'ESP32 UDP and iPhone LiDAR are network streams: `ruview esp32 --seconds 10`, `ruview lidar --source iphone --url ws://HOST:8787/ws/lidar`');
|
||||
}
|
||||
|
||||
if (want('remote')) {
|
||||
try {
|
||||
const { path, hosts } = loadHosts();
|
||||
add('remote', 'hosts-file', 'pass', hosts.length ? `${hosts.length} host(s) in ${path}: ${hosts.map((h) => h.name).join(', ')}` : `no remote hosts configured (${path})`);
|
||||
} catch (error) {
|
||||
add('remote', 'hosts-file', 'fail', `invalid hosts file: ${error.message}`, 'Fix or remove ~/.config/ruview/hosts.json; re-add hosts with `ruview hosts add`.');
|
||||
}
|
||||
const ssh = deps.which('ssh');
|
||||
add('remote', 'ssh', ssh ? 'pass' : 'warn', ssh ? `found ${ssh}` : 'ssh client not on PATH (remote hosts)', ssh ? null : 'Install the OpenSSH client.');
|
||||
}
|
||||
|
||||
if (want('sensing')) {
|
||||
if (!args.sensing_url) add('sensing', 'server', 'skip', 'pass --url http://<host>:<port> to check a running sensing server');
|
||||
else {
|
||||
try {
|
||||
const url = new URL(args.sensing_url);
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('sensing_url must be http(s)');
|
||||
const target = new URL('/health', url);
|
||||
const res = await deps.fetch(target, { signal: AbortSignal.timeout(5000) });
|
||||
add('sensing', 'server', res.ok ? 'pass' : 'fail', `${target.href} → HTTP ${res.status}`, res.ok ? null : 'Start the sensing server: cargo run -p wifi-densepose-sensing-server');
|
||||
} catch (error) {
|
||||
add('sensing', 'server', 'fail', `unreachable: ${error.message}`, 'Check host/port and firewall; ESP32 nodes stream UDP to the aggregator, the HTTP API is separate.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (want('kernel')) {
|
||||
try {
|
||||
const mod = await deps.importer('@ruvnet/ruview-kernel');
|
||||
const status = mod.kernelStatus();
|
||||
add('kernel', 'wasm', status.wasm.available ? 'pass' : 'fail', status.wasm.available ? `ruview-kernel ${status.wasm.version} (${status.wasm.integrity})` : status.wasm.reason);
|
||||
add('kernel', 'napi', status.napi.available ? 'pass' : 'warn', status.napi.available ? `native ${status.triple}` : `optional: ${status.napi.reason}`);
|
||||
} catch {
|
||||
add('kernel', 'package', 'warn', '@ruvnet/ruview-kernel not installed (optional compute/SDK kernel)', 'npm install @ruvnet/ruview-kernel');
|
||||
}
|
||||
}
|
||||
|
||||
const summary = { pass: 0, warn: 0, fail: 0, skip: 0 };
|
||||
for (const c of checks) summary[c.status] += 1;
|
||||
return {
|
||||
ok: summary.fail === 0,
|
||||
summary,
|
||||
checks,
|
||||
nextSteps: checks.filter((c) => c.remedy && c.status !== 'pass').map((c) => `${c.group}/${c.id}: ${c.remedy}`),
|
||||
};
|
||||
}
|
||||
|
||||
/** Human-readable rendering for the CLI. */
|
||||
export function formatDoctor(report) {
|
||||
const icon = { pass: 'PASS', warn: 'WARN', fail: 'FAIL', skip: 'SKIP' };
|
||||
const lines = [];
|
||||
let group = null;
|
||||
for (const c of report.checks) {
|
||||
if (c.group !== group) { group = c.group; lines.push(`\n[${group}]`); }
|
||||
lines.push(` ${icon[c.status]} ${c.id} — ${c.detail}`);
|
||||
if (c.remedy && c.status !== 'pass') lines.push(` fix: ${c.remedy}`);
|
||||
}
|
||||
const s = report.summary;
|
||||
lines.push(`\nruview doctor: ${report.ok ? 'no failures' : 'failures found'} — ${s.pass} pass, ${s.warn} warn, ${s.fail} fail, ${s.skip} skip`);
|
||||
return lines.join('\n').trimStart();
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// Non-throwing wrapper over the scrubbed-environment process runner, for
|
||||
// operator tools (esptool, cargo, python). Never uses a shell.
|
||||
|
||||
import { DEFAULT_ENV_ALLOWLIST, runProcess } from './process-runner.js';
|
||||
|
||||
// Toolchain locations operators legitimately configure; no credentials.
|
||||
export const TOOLCHAIN_ENV = Object.freeze([
|
||||
...DEFAULT_ENV_ALLOWLIST,
|
||||
'CARGO_HOME', 'RUSTUP_HOME', 'RUSTUP_TOOLCHAIN', 'VIRTUAL_ENV', 'CONDA_PREFIX',
|
||||
'LIBTORCH', 'LIBTORCH_USE_PYTORCH', 'LIBTORCH_BYPASS_VERSION_CHECK', 'LD_LIBRARY_PATH', 'DYLD_LIBRARY_PATH', 'CUDA_HOME', 'IDF_PATH',
|
||||
]);
|
||||
|
||||
const TAIL = 16_384;
|
||||
|
||||
/** Run a command; resolves {ok, code, stdout, stderr, error} and never rejects. */
|
||||
export async function execTool(command, args, { cwd, timeoutMs = 120_000, maxOutputBytes = 4_194_304, fullOutput = false } = {}) {
|
||||
// fullOutput: return all of stdout (bounded by maxOutputBytes) for binary
|
||||
// captures; otherwise keep only a diagnostic tail.
|
||||
const tail = (text) => (fullOutput ? text : text.slice(-TAIL));
|
||||
try {
|
||||
const r = await runProcess(command, args, { cwd, timeoutMs, maxOutputBytes, envAllowlist: TOOLCHAIN_ENV });
|
||||
return { ok: true, code: r.code, stdout: tail(r.stdout), stderr: r.stderr.slice(-TAIL), error: null };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
code: error.code ?? null,
|
||||
stdout: tail(String(error.stdout || '')),
|
||||
stderr: String(error.stderr || '').slice(-TAIL),
|
||||
error: String(error.message || error).slice(0, 2000),
|
||||
timedOut: Boolean(error.timedOut),
|
||||
};
|
||||
}
|
||||
}
|
||||
Loaded 100 of 202 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user