From 2c7a0194f46a039f3e3f3bb5f9a878b1aa44dc4e Mon Sep 17 00:00:00 2001 From: rUv Date: Fri, 9 Oct 2026 05:40:30 -0700 Subject: [PATCH 01/23] Add virtual-view prototype: integrations/virtual-view/adapter.mjs --- integrations/virtual-view/adapter.mjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 integrations/virtual-view/adapter.mjs diff --git a/integrations/virtual-view/adapter.mjs b/integrations/virtual-view/adapter.mjs new file mode 100644 index 000000000..157105858 --- /dev/null +++ b/integrations/virtual-view/adapter.mjs @@ -0,0 +1,15 @@ +// Opt-in cross-repository prototype. Imports no hardware, network or model client. +export function createVirtualViewAdapter({ broker, privateKey, enabled = false }) { + if (typeof broker !== 'function' || typeof enabled !== 'boolean') throw new TypeError('adapter configuration'); + return Object.freeze({ + observe(snapshot) { + if (!enabled) return { decision: 'BLOCK', reason: 'disabled', authority: 'none', svg: null }; + // Explicit metric scene contract only. RF Gaussians are not manipulation geometry. + if (snapshot?.schema !== 'virtual-view.v1' || snapshot?.frame !== 'room_enu') { + throw new TypeError('calibrated metric scene snapshot required; raw RF/LiDAR packets are not supported'); + } + return broker(snapshot, { privateKey }); + }, + hardwareActuation: false, + }); +} From f8750a4a50cbf01c820c29e835e5b5822b6b1159 Mon Sep 17 00:00:00 2001 From: rUv Date: Fri, 9 Oct 2026 05:40:32 -0700 Subject: [PATCH 02/23] Add virtual-view prototype: integrations/virtual-view/adapter.test.mjs --- integrations/virtual-view/adapter.test.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 integrations/virtual-view/adapter.test.mjs diff --git a/integrations/virtual-view/adapter.test.mjs b/integrations/virtual-view/adapter.test.mjs new file mode 100644 index 000000000..f98eb816e --- /dev/null +++ b/integrations/virtual-view/adapter.test.mjs @@ -0,0 +1,14 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createVirtualViewAdapter } from './adapter.mjs'; +test('disabled by default and never actuates', () => { + const adapter = createVirtualViewAdapter({ broker: () => { throw Error('not called'); } }); + assert.equal(adapter.observe({}).reason, 'disabled'); assert.equal(adapter.hardwareActuation, false); +}); +test('raw RF is rejected; explicit scene is forwarded to validating broker', () => { + let received; + const adapter = createVirtualViewAdapter({ enabled: true, broker: s => { received = s; return { authority: 'none' }; } }); + assert.throws(() => adapter.observe({ schema: 'spatial.evidence.v1' })); + const s = { schema: 'virtual-view.v1', frame: 'room_enu' }; + assert.equal(adapter.observe(s).authority, 'none'); assert.equal(received, s); +}); From 88daeb8cf2140039e89ec1899654d51088f6c089 Mon Sep 17 00:00:00 2001 From: rUv Date: Fri, 9 Oct 2026 05:40:33 -0700 Subject: [PATCH 03/23] Add virtual-view prototype: integrations/virtual-view/README.md --- integrations/virtual-view/README.md | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 integrations/virtual-view/README.md diff --git a/integrations/virtual-view/README.md b/integrations/virtual-view/README.md new file mode 100644 index 000000000..74e59f00b --- /dev/null +++ b/integrations/virtual-view/README.md @@ -0,0 +1,35 @@ +# Virtual-view broker adapter (research prototype) + +Opt-in integration with Dream Machine's `scripts/virtual-view-broker.mjs`. +Disabled by default. No cameras, model calls, network, hardware commands or +automatic promotion. It is NOT part of the Rust production perception pipeline. + +```js +import { generateKeyPairSync } from 'node:crypto'; +import { broker } from '../../../dream-machine/scripts/virtual-view-broker.mjs'; +import { createVirtualViewAdapter } from './adapter.mjs'; +// Example in-memory research identity only; use a trusted host signer in deployment. +const { privateKey } = generateKeyPairSync('ed25519'); +const adapter = createVirtualViewAdapter({ broker, privateKey, enabled: true }); +// adapter.observe(calibratedSnapshot) returns RENDER, SKIP or BLOCK plus authority:none. +``` + +The example assumes sibling RuView and Dream Machine clones; adjust the import +from the executing module's location. Pass calibrated `virtual-view.v1` scene +snapshots in room-local ENU metres. See Dream Machine ADR-0108 for every field, +threshold, trust boundary, receipt and acceptance rule. The adapter does not +transform raw `spatial.evidence.v1` RF Gaussians or `ruview.lidar.depth.v1` packets: +neither supplies the independently tracked manipulation scene this method needs. + +```sh +node --test integrations/virtual-view/adapter.test.mjs +# From the sibling Dream Machine checkout: +node scripts/bench-virtual-view.mjs ../ruview/integrations/virtual-view/adapter.mjs +``` + +The cross-repository benchmark exercises this adapter with the real broker, +renders 256-object synthetic scenes, signs and replays receipts. It measures local +CPU overhead only. Required robot evidence remains missing: +20 points on two of +three held-out tasks, no regression beyond five points, added p95 below 150 ms, +at least 95% pre-action drift detection, and replayable receipts for all trials. +No receipt grants actuator authority. Do not wire this to live robot control. From 4696804b23180d556483cb8652c0dd564d9bb099 Mon Sep 17 00:00:00 2001 From: rUv Date: Fri, 9 Oct 2026 05:45:16 -0700 Subject: [PATCH 04/23] docs: add Cognitum-style animated RuView visual system --- README.md | 50 +++++++++ assets/readme/cognitum.svg | 1 + assets/readme/feature-agents.svg | 1 + assets/readme/feature-automation.svg | 1 + assets/readme/feature-edge.svg | 1 + assets/readme/feature-pose.svg | 1 + assets/readme/feature-presence.svg | 1 + assets/readme/feature-vitals.svg | 1 + assets/readme/header-docs.svg | 1 + assets/readme/header-edge.svg | 1 + assets/readme/header-evidence.svg | 1 + assets/readme/header-features.svg | 1 + assets/readme/header-learning.svg | 1 + assets/readme/hero.svg | 1 + assets/readme/manifest.json | 147 +++++++++++++++++++++++++++ assets/readme/nav-agents.svg | 1 + assets/readme/nav-hardware.svg | 1 + assets/readme/nav-home.svg | 1 + assets/readme/nav-models.svg | 1 + assets/readme/nav-seed.svg | 1 + assets/readme/nav-start.svg | 1 + assets/readme/overview.svg | 1 + docs/readme-visual-system.md | 24 +++++ scripts/render-readme-visuals.py | 95 +++++++++++++++++ scripts/verify-readme-visuals.py | 29 ++++++ 25 files changed, 365 insertions(+) create mode 100644 assets/readme/cognitum.svg create mode 100644 assets/readme/feature-agents.svg create mode 100644 assets/readme/feature-automation.svg create mode 100644 assets/readme/feature-edge.svg create mode 100644 assets/readme/feature-pose.svg create mode 100644 assets/readme/feature-presence.svg create mode 100644 assets/readme/feature-vitals.svg create mode 100644 assets/readme/header-docs.svg create mode 100644 assets/readme/header-edge.svg create mode 100644 assets/readme/header-evidence.svg create mode 100644 assets/readme/header-features.svg create mode 100644 assets/readme/header-learning.svg create mode 100644 assets/readme/hero.svg create mode 100644 assets/readme/manifest.json create mode 100644 assets/readme/nav-agents.svg create mode 100644 assets/readme/nav-hardware.svg create mode 100644 assets/readme/nav-home.svg create mode 100644 assets/readme/nav-models.svg create mode 100644 assets/readme/nav-seed.svg create mode 100644 assets/readme/nav-start.svg create mode 100644 assets/readme/overview.svg create mode 100644 docs/readme-visual-system.md create mode 100644 scripts/render-readme-visuals.py create mode 100644 scripts/verify-readme-visuals.py diff --git a/README.md b/README.md index 08faa63a2..49cd48214 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,31 @@ # π RuView +[![RuView: spaces become signals, with an animated conceptual RF field](assets/readme/hero.svg)](docs/user-guide.md) + + + + + + + + + + + + +
Start here
Start here
Hardware
Hardware
Models
Models
AI toolkit
AI toolkit
Smart home
Smart home
Cognitum Seed
Cognitum Seed
+ +
+Watch the original RuView sensing demo +

RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi

+
+

RuView — WiFi becomes spatial awareness, with Ruflo coordination and a ruOS sensing workspace @@ -27,6 +47,8 @@ Works natively with the four major smart-home ecosystems: **[Home Assistant](doc Every WiFi router already fills your space with radio waves. When people move, breathe, or even sit still, they disturb those waves in measurable ways. RuView captures these disturbances using Channel State Information (CSI) from low-cost ESP32 sensors and turns them into actionable data: who's there, what they're doing, and whether they're okay. +[![Explore RuView sensing, models and automation](assets/readme/header-features.svg)](docs/user-guide.md) + **What it senses:** - **Presence and occupancy** — detect people through walls, count them, track entries and exits - **Vital signs** — breathing rate and heart rate, contactless, while sleeping or sitting @@ -43,6 +65,22 @@ Every WiFi router already fills your space with radio waves. When people move, b - **Governed evidence** — attach privacy policy, uncertainty, provenance, and witness records to sensing events - **RuView MetaHarness** — use an AI operator to onboard, calibrate, train, verify, and check sensing claims + + + + + + + + + + + + + + +
Presence and motion: open documentation and current limitations
Presence and motion
Breathing and vital signals: open documentation and current limitations
Breathing and vital signals
Pose research and limitations: open documentation and current limitations
Pose research and limitations
Edge sensor mesh: open documentation and current limitations
Edge sensor mesh
Local automation: open documentation and current limitations
Local automation
Guided AI operator: open documentation and current limitations
Guided AI operator
+

RuView MetaHarness — guided operation for humans and AI agents @@ -263,6 +301,8 @@ The separate **17-keypoint pose-estimation model** is now published at [`ruvnet/ ### Results & proof +[![RuView visual guide: header-evidence](assets/readme/header-evidence.svg)](docs/benchmarks/pose-estimation-cog.md) + See the measured benchmarks, witness records, and one-command reproducibility check.
@@ -317,6 +357,8 @@ number. The path to a first *reproducible* on-device baseline (PCK@20 ≥ 35%) i ## 🧩 Edge Module Catalog +[![RuView visual guide: header-edge](assets/readme/header-edge.svg)](firmware/esp32-csi-node/README.md) + Add signed modules for health, security, buildings, industry, research, AI, and more.
@@ -491,6 +533,8 @@ Browse and install modules at [seed.cognitum.one/store](https://seed.cognitum.on ## 🔬 How It Works +[![RuView visual guide: overview](assets/readme/overview.svg)](docs/user-guide.md) + WiFi routers flood every room with radio waves. When a person moves — or even breathes — those waves scatter differently. WiFi DensePose reads that scattering pattern and reconstructs what happened: ``` @@ -615,6 +659,8 @@ These scenarios exploit WiFi's ability to penetrate solid materials — concrete ## 🧠 Self-Learning WiFi AI +[![RuView visual guide: header-learning](assets/readme/header-learning.svg)](docs/user-guide.md) + Learn compact room fingerprints from raw CSI and adapt the model to each environment.
@@ -744,6 +790,8 @@ For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.9.1`. The quick c ## 📖 Documentation +[![RuView visual guide: header-docs](assets/readme/header-docs.svg)](docs/user-guide.md) + Start with the user, build, and calibration guides; expand for the full reference map.
@@ -785,6 +833,8 @@ Start with the user, build, and calibration guides; expand for the full referenc > > Contributions and bug reports welcome at [Issues](https://github.com/ruvnet/RuView/issues). +[![Cognitum One: Ambient Intelligence at the edge of the physical world](assets/readme/cognitum.svg)](https://cognitum.one) + ## 📄 License MIT License — see [LICENSE](LICENSE) for details. diff --git a/assets/readme/cognitum.svg b/assets/readme/cognitum.svg new file mode 100644 index 000000000..c5a7615ae --- /dev/null +++ b/assets/readme/cognitum.svg @@ -0,0 +1 @@ +Explore Cognitum OneAmbient Intelligence at the edge of the physical world. Open cognitum.one.COGNITUM ONEAmbient Intelligence.At the edge of the physical world.Explore the platform behind the ecosystem.Explore Cognitum diff --git a/assets/readme/feature-agents.svg b/assets/readme/feature-agents.svg new file mode 100644 index 000000000..7cd6197bc --- /dev/null +++ b/assets/readme/feature-agents.svg @@ -0,0 +1 @@ +A guided AI operatorSource-cited setup and verification. CLI, MCP and agent workflows. Conceptual animation; follow the linked source for capability limits.RUVIEW / 06A guided AI operatorSource-cited setup and verification.CLI, MCP and agent workflows.TOOLKIT GUIDE diff --git a/assets/readme/feature-automation.svg b/assets/readme/feature-automation.svg new file mode 100644 index 000000000..8104d37ab --- /dev/null +++ b/assets/readme/feature-automation.svg @@ -0,0 +1 @@ +Local automationConnect rooms to your smart home. Home Assistant, MQTT and Matter. Conceptual animation; follow the linked source for capability limits.RUVIEW / 05Local automationConnect rooms to your smart home.Home Assistant, MQTT and Matter.INTEGRATION GUIDE diff --git a/assets/readme/feature-edge.svg b/assets/readme/feature-edge.svg new file mode 100644 index 000000000..d3c589cc1 --- /dev/null +++ b/assets/readme/feature-edge.svg @@ -0,0 +1 @@ +Edge sensor meshProvision, capture and calibrate. Explore ESP32 firmware and runbooks. Conceptual animation; follow the linked source for capability limits.RUVIEW / 04Edge sensor meshProvision, capture and calibrate.Explore ESP32 firmware and runbooks.HARDWARE GUIDE diff --git a/assets/readme/feature-pose.svg b/assets/readme/feature-pose.svg new file mode 100644 index 000000000..eeaa9cb20 --- /dev/null +++ b/assets/readme/feature-pose.svg @@ -0,0 +1 @@ +Pose researchTrain and compare body keypoints. Live accuracy differs from benchmarks. Conceptual animation; follow the linked source for capability limits.RUVIEW / 03Pose researchTrain and compare body keypoints.Live accuracy differs from benchmarks.READ THE EVIDENCE diff --git a/assets/readme/feature-presence.svg b/assets/readme/feature-presence.svg new file mode 100644 index 000000000..910d9d0d8 --- /dev/null +++ b/assets/readme/feature-presence.svg @@ -0,0 +1 @@ +Presence & motionExplore changes in room occupancy. Inspect motion features from CSI. Conceptual animation; follow the linked source for capability limits.RUVIEW / 01Presence & motionExplore changes in room occupancy.Inspect motion features from CSI.SENSING GUIDE diff --git a/assets/readme/feature-vitals.svg b/assets/readme/feature-vitals.svg new file mode 100644 index 000000000..c2cb41c03 --- /dev/null +++ b/assets/readme/feature-vitals.svg @@ -0,0 +1 @@ +Breathing & vital signalsInspect phase-derived signal trends. Validate estimates on your hardware. Conceptual animation; follow the linked source for capability limits.RUVIEW / 02Breathing & vital signalsInspect phase-derived signal trends.Validate estimates on your hardware.SIGNAL PIPELINE diff --git a/assets/readme/header-docs.svg b/assets/readme/header-docs.svg new file mode 100644 index 000000000..50e9885e7 --- /dev/null +++ b/assets/readme/header-docs.svg @@ -0,0 +1 @@ +Choose your next stepAnimated section heading. GUIDES / HARDWARE / INTEGRATIONSChoose your next stepGUIDES / HARDWARE / INTEGRATIONS diff --git a/assets/readme/header-edge.svg b/assets/readme/header-edge.svg new file mode 100644 index 000000000..1c989c9f9 --- /dev/null +++ b/assets/readme/header-edge.svg @@ -0,0 +1 @@ +Intelligence at the edgeAnimated section heading. SENSORS / FIRMWARE / MODULESIntelligence at the edgeSENSORS / FIRMWARE / MODULES diff --git a/assets/readme/header-evidence.svg b/assets/readme/header-evidence.svg new file mode 100644 index 000000000..2b8a102b0 --- /dev/null +++ b/assets/readme/header-evidence.svg @@ -0,0 +1 @@ +Evidence before accuracy claimsAnimated section heading. BENCHMARKS / REPRODUCERS / LIMITSEvidence before accuracy claimsBENCHMARKS / REPRODUCERS / LIMITS diff --git a/assets/readme/header-features.svg b/assets/readme/header-features.svg new file mode 100644 index 000000000..8eb839430 --- /dev/null +++ b/assets/readme/header-features.svg @@ -0,0 +1 @@ +Explore the capabilitiesAnimated section heading. SENSING / MODELS / AUTOMATIONExplore the capabilitiesSENSING / MODELS / AUTOMATION diff --git a/assets/readme/header-learning.svg b/assets/readme/header-learning.svg new file mode 100644 index 000000000..ee40ae04e --- /dev/null +++ b/assets/readme/header-learning.svg @@ -0,0 +1 @@ +From recordings to modelsAnimated section heading. CAPTURE / TRAIN / EVALUATEFrom recordings to modelsCAPTURE / TRAIN / EVALUATE diff --git a/assets/readme/hero.svg b/assets/readme/hero.svg new file mode 100644 index 000000000..51e0d1d44 --- /dev/null +++ b/assets/readme/hero.svg @@ -0,0 +1 @@ +RuView: spaces become signalsAnimated conceptual RF field and room geometry. Explore the RuView user guide. This illustration is not a sensor measurement.RUVIEW / COGNITUM ECOSYSTEMRuViewSpaces become signals.Signals become understanding.Explore RF sensing, edge inference and local automation.Explore the guideRF SENSING / EDGE MODELS / LOCAL ACTIONCONCEPTUAL RF FIELD diff --git a/assets/readme/manifest.json b/assets/readme/manifest.json new file mode 100644 index 000000000..c7a3e4c2f --- /dev/null +++ b/assets/readme/manifest.json @@ -0,0 +1,147 @@ +{ + "schema_version": 1, + "style": "Cognitum cyan on midnight", + "motion": "Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies", + "assets": [ + { + "file": "assets/readme/hero.svg", + "title": "RuView: spaces become signals", + "target": "docs/user-guide.md", + "width": 1200, + "height": 450 + }, + { + "file": "assets/readme/nav-start.svg", + "title": "Start here", + "target": "docs/user-guide.md", + "width": 180, + "height": 116 + }, + { + "file": "assets/readme/nav-hardware.svg", + "title": "Hardware", + "target": "firmware/esp32-csi-node/README.md", + "width": 180, + "height": 116 + }, + { + "file": "assets/readme/nav-models.svg", + "title": "Models", + "target": "https://huggingface.co/ruvnet/wifi-densepose-pretrained", + "width": 180, + "height": 116 + }, + { + "file": "assets/readme/nav-agents.svg", + "title": "AI toolkit", + "target": "harness/ruview/README.md", + "width": 180, + "height": 116 + }, + { + "file": "assets/readme/nav-home.svg", + "title": "Smart home", + "target": "docs/integrations/home-assistant.md", + "width": 180, + "height": 116 + }, + { + "file": "assets/readme/nav-seed.svg", + "title": "Cognitum", + "target": "https://cognitum.one/seed", + "width": 180, + "height": 116 + }, + { + "file": "assets/readme/feature-presence.svg", + "title": "Presence & motion", + "target": "docs/user-guide.md", + "width": 580, + "height": 276 + }, + { + "file": "assets/readme/feature-vitals.svg", + "title": "Breathing & vital signals", + "target": "v2/crates/wifi-densepose-signal/README.md", + "width": 580, + "height": 276 + }, + { + "file": "assets/readme/feature-pose.svg", + "title": "Pose research", + "target": "docs/benchmarks/pose-estimation-cog.md", + "width": 580, + "height": 276 + }, + { + "file": "assets/readme/feature-edge.svg", + "title": "Edge sensor mesh", + "target": "firmware/esp32-csi-node/README.md", + "width": 580, + "height": 276 + }, + { + "file": "assets/readme/feature-automation.svg", + "title": "Local automation", + "target": "docs/integrations/home-assistant.md", + "width": 580, + "height": 276 + }, + { + "file": "assets/readme/feature-agents.svg", + "title": "A guided AI operator", + "target": "harness/ruview/README.md", + "width": 580, + "height": 276 + }, + { + "file": "assets/readme/overview.svg", + "title": "RuView sensing overview", + "target": "docs/user-guide.md", + "width": 1200, + "height": 396 + }, + { + "file": "assets/readme/header-features.svg", + "title": "Explore the capabilities", + "target": "docs/user-guide.md", + "width": 1200, + "height": 148 + }, + { + "file": "assets/readme/header-edge.svg", + "title": "Intelligence at the edge", + "target": "firmware/esp32-csi-node/README.md", + "width": 1200, + "height": 148 + }, + { + "file": "assets/readme/header-learning.svg", + "title": "From recordings to models", + "target": "docs/user-guide.md", + "width": 1200, + "height": 148 + }, + { + "file": "assets/readme/header-evidence.svg", + "title": "Evidence before accuracy claims", + "target": "docs/benchmarks/pose-estimation-cog.md", + "width": 1200, + "height": 148 + }, + { + "file": "assets/readme/header-docs.svg", + "title": "Choose your next step", + "target": "docs/user-guide.md", + "width": 1200, + "height": 148 + }, + { + "file": "assets/readme/cognitum.svg", + "title": "Explore Cognitum One", + "target": "https://cognitum.one", + "width": 1200, + "height": 304 + } + ] +} diff --git a/assets/readme/nav-agents.svg b/assets/readme/nav-agents.svg new file mode 100644 index 000000000..5b427af96 --- /dev/null +++ b/assets/readme/nav-agents.svg @@ -0,0 +1 @@ +AI toolkitOpen AI toolkit. Animated navigation icon.AI toolkit diff --git a/assets/readme/nav-hardware.svg b/assets/readme/nav-hardware.svg new file mode 100644 index 000000000..ab83aa8ad --- /dev/null +++ b/assets/readme/nav-hardware.svg @@ -0,0 +1 @@ +HardwareOpen Hardware. Animated navigation icon.Hardware diff --git a/assets/readme/nav-home.svg b/assets/readme/nav-home.svg new file mode 100644 index 000000000..1ef980672 --- /dev/null +++ b/assets/readme/nav-home.svg @@ -0,0 +1 @@ +Smart homeOpen Smart home. Animated navigation icon.Smart home diff --git a/assets/readme/nav-models.svg b/assets/readme/nav-models.svg new file mode 100644 index 000000000..41f538a74 --- /dev/null +++ b/assets/readme/nav-models.svg @@ -0,0 +1 @@ +ModelsOpen Models. Animated navigation icon.Models diff --git a/assets/readme/nav-seed.svg b/assets/readme/nav-seed.svg new file mode 100644 index 000000000..0788f4381 --- /dev/null +++ b/assets/readme/nav-seed.svg @@ -0,0 +1 @@ +CognitumOpen Cognitum. Animated navigation icon.Cognitum diff --git a/assets/readme/nav-start.svg b/assets/readme/nav-start.svg new file mode 100644 index 000000000..2e911f115 --- /dev/null +++ b/assets/readme/nav-start.svg @@ -0,0 +1 @@ +Start hereOpen Start here. Animated navigation icon.Start here diff --git a/assets/readme/overview.svg b/assets/readme/overview.svg new file mode 100644 index 000000000..27d98d2b8 --- /dev/null +++ b/assets/readme/overview.svg @@ -0,0 +1 @@ +RuView sensing overviewConceptual sequence: capture RF, extract context, act locally. Links to architecture and user documentation.SIGNAL TO CONTEXTA sensing loop you can inspect.01Capture RFCSI from supported hardwareProvision and calibrate02Extract contextSignal processing and modelsCompare against real data03Act locallyEvents, APIs and integrationsApply rules and review outputsCONCEPTUAL PIPELINE / HARDWARE AND ENVIRONMENT VALIDATION MATTER diff --git a/docs/readme-visual-system.md b/docs/readme-visual-system.md new file mode 100644 index 000000000..fc74c099a --- /dev/null +++ b/docs/readme-visual-system.md @@ -0,0 +1,24 @@ +# RuView README visual system + +The README uses a Cognitum-inspired palette: midnight backgrounds, cyan gradients, wireframe geometry and restrained signal motion. It includes a hero, six feature cards, six navigation icons, an overview, five chapter headings and a Cognitum destination banner. + +## Rebuild and check + +```bash +python3 scripts/render-readme-visuals.py +python3 scripts/verify-readme-visuals.py +``` + +The generator uses only Python's standard library and makes no network calls. The committed SVGs are the deployment artifacts. The manifest in `assets/readme/manifest.json` lists every destination. + +## Links and rendering + +GitHub renders SVGs as images, so image-internal links are not used. Each SVG has an outer Markdown or HTML link in the README. Each card and navigation icon also has a plain text link for narrow screens and assistive technology. Source headings remain intact so existing README anchors still work. + +Animation uses CSS only. All copy and essential geometry are visible without animation, JavaScript, remote fonts or remote images. `prefers-reduced-motion` disables decorative movement. The original GIF remains available in an expandable demo section; the existing ruOS promotion remains linked. + +## Content boundaries + +The overview and RF room are conceptual illustrations, not live measurements or benchmark evidence. Feature cards link to current source documentation. Pose research is identified as such, and the signal card directs readers to hardware validation. No new speed, accuracy, medical or device-support claims are introduced. + +Sources: `docs/user-guide.md`, `docs/benchmarks/pose-estimation-cog.md`, `v2/crates/wifi-densepose-signal/README.md`, `firmware/esp32-csi-node/README.md`, `docs/integrations/home-assistant.md`, and `harness/ruview/README.md`. diff --git a/scripts/render-readme-visuals.py b/scripts/render-readme-visuals.py new file mode 100644 index 000000000..fdc5f67d8 --- /dev/null +++ b/scripts/render-readme-visuals.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Generate the RuView README visual system. Deterministic, stdlib, no network.""" +from pathlib import Path +from html import escape +import math,json,xml.etree.ElementTree as ET +ROOT=Path(__file__).resolve().parents[1];OUT=ROOT/'assets/readme';OUT.mkdir(parents=True,exist_ok=True) +C='#72eee0';BLUE='#70b6ff';INK='#041117';WHITE='#ecfafb';MUTED='#9abac5' +def text(x,y,value,size=16,color=MUTED,anchor='start',extra=''): + return f'{escape(value)}' +def path(d,color=C,width=1.5,extra=''): + return f'' +def circle(x,y,r,color=C,extra=''): + fill = '' if 'fill=' in extra else 'fill="none"' + return f'' + +def icon(kind,x,y,scale=1): + s='' + if kind in ['signal','start']: + for r in [13,25,38]:s+=path(f'M{-r*.8} {-r*.4}Q0 {-r*1.4} {r*.8} {-r*.4}',extra=f'class="breathe" style="animation-delay:-{r/20}s"') + s+=circle(0,20,4,extra='fill="#72eee0"')+path('M0 12V-1') + elif kind in ['presence','pose']: + s+=circle(0,-28,8)+path('M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38',width=2) + if kind=='pose': + for px,py in [(0,-14),(0,12),(-24,-6),(24,-6),(-18,38),(18,38)]:s+=circle(px,py,3,BLUE,extra='fill="#041117" class="breathe"') + else:s+=f'' + elif kind in ['vitals','models']: + s+=path('M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43',width=2,extra='class="trace"')+path('M-42 31H43','#214451',1) + elif kind in ['edge','hardware']: + s+='' + s+='' + for v in [-16,0,16]:s+=path(f'M{v} -36V-25M{v} 25V36M-36 {v}H-25M25 {v}H36') + s+=circle(0,0,4,extra='fill="#72eee0" class="breathe"') + elif kind in ['home','automation']: + s+=path('M-37 -3L0 -34L37 -3M-28 -10V32H28V-10M-9 32V8H9V32',width=2) + s+=circle(0,-9,5,BLUE,extra='class="breathe"') + elif kind in ['agent','agents']: + for px,py in [(0,-33),(-35,23),(35,23)]:s+=path(f'M0 0L{px} {py}',BLUE,1,extra='class="flow"')+circle(px,py,7,extra='class="breathe"') + s+=circle(0,0,15)+path('M-6 0L-1 5L8 -6') + elif kind in ['seed','evidence']: + s+=path('M0 -38L31 -20V18L0 38L-31 18V-20ZM-31 -20L0 -1L31 -20M0 -1V38',width=2) + s+=path('M-15 1L-5 11L17 -12',BLUE,2,extra='class="trace"') + else: + for j in range(3):s+=path(f'M-38 {j*14-20}L0 {j*14-38}L38 {j*14-20}L0 {j*14-2}Z',extra='class="breathe"') + return f'{s}' +def frame(w,h,title,desc,body): + return f'''{escape(title)}{escape(desc)}{body}''' +manifest=[] +def save(name,w,h,title,desc,body,target): + s=frame(w,h,title,desc,body);ET.fromstring(s);(OUT/(name+'.svg')).write_text(s+'\n');manifest.append({'file':'assets/readme/'+name+'.svg','title':title,'target':target,'width':w,'height':h}) +# Main title panel: a radio field passes through an illustrated room. +s=text(48,44,'RUVIEW / COGNITUM ECOSYSTEM',13,C,extra='class="mono"')+text(48,121,'RuView',76,WHITE)+text(48,184,'Spaces become signals.',38,WHITE)+text(48,234,'Signals become understanding.',32,C)+text(50,279,'Explore RF sensing, edge inference and local automation.',18) +s+=''+text(72,359,'Explore the guide',20,INK)+path('M272 352H292M285 345L292 352L285 359',INK,2) +s+=text(49,419,'RF SENSING / EDGE MODELS / LOCAL ACTION',11,MUTED,extra='class="mono"') +s+='' +for i in range(9):s+=path(f'M{750+i*40} 282L{610+i*80} 443','#12343d',1) +for y in [306,336,372,418]:s+=path(f'M680 {y}H1168','#12343d',1) +s+=path('M803 157L1008 106L1137 177L930 236ZM803 157V314L930 391L1137 324V177M930 236V391M1008 106V268L803 314M1008 268L1137 324','#4b9ba4',1.5) +s+=icon('presence',976,265,1.35)+icon('edge',780,349,.7) +for i in range(4):s+=path(f'M{794+i*12} {333-i*9}Q{830+i*25} {245-i*24} {896+i*29} {207-i*3}',C,1.4,extra=f'class="flow" style="animation-delay:-{i}s" opacity="{.8-i*.12}"') +for x,y in [(803,157),(1008,106),(1137,177),(930,236),(930,391),(803,314),(1137,324)]:s+=circle(x,y,3,extra='fill="#72eee0" class="breathe"') +s+=text(1148,419,'CONCEPTUAL RF FIELD',10,MUTED,'end',extra='class="mono"') +save('hero',1200,450,'RuView: spaces become signals','Animated conceptual RF field and room geometry. Explore the RuView user guide. This illustration is not a sensor measurement.',s,'docs/user-guide.md') +# Visual navigation icons, each receives a real outer link in Markdown. +nav=[('start','Start here','docs/user-guide.md'),('hardware','Hardware','firmware/esp32-csi-node/README.md'),('models','Models','https://huggingface.co/ruvnet/wifi-densepose-pretrained'),('agents','AI toolkit','harness/ruview/README.md'),('home','Smart home','docs/integrations/home-assistant.md'),('seed','Cognitum','https://cognitum.one/seed')] +for k,label,target in nav:save('nav-'+k,180,116,label,'Open '+label+'. Animated navigation icon.',icon(k,90,45,.65)+text(90,98,label,16,WHITE,'middle'),target) +# Capability cards. No unverified performance claims. +cards=[('presence','01','Presence & motion','Explore changes in room occupancy.','Inspect motion features from CSI.','SENSING GUIDE','docs/user-guide.md'),('vitals','02','Breathing & vital signals','Inspect phase-derived signal trends.','Validate estimates on your hardware.','SIGNAL PIPELINE','v2/crates/wifi-densepose-signal/README.md'),('pose','03','Pose research','Train and compare body keypoints.','Live accuracy differs from benchmarks.','READ THE EVIDENCE','docs/benchmarks/pose-estimation-cog.md'),('edge','04','Edge sensor mesh','Provision, capture and calibrate.','Explore ESP32 firmware and runbooks.','HARDWARE GUIDE','firmware/esp32-csi-node/README.md'),('automation','05','Local automation','Connect rooms to your smart home.','Home Assistant, MQTT and Matter.','INTEGRATION GUIDE','docs/integrations/home-assistant.md'),('agents','06','A guided AI operator','Source-cited setup and verification.','CLI, MCP and agent workflows.','TOOLKIT GUIDE','harness/ruview/README.md')] +for k,num,title,l1,l2,cta,target in cards: + s=text(30,39,'RUVIEW / '+num,11,C,extra='class="mono"')+text(30,94,title,29,WHITE)+text(30,136,l1,16)+text(30,164,l2,16) + s+=''+icon(k,474,168,.83)+path('M30 204H550','#245360',1)+path('M30 204H550',C,1,extra='class="flow" opacity=".5"') + s+=text(30,245,cta,12,C,extra='class="mono"')+path('M510 239H540M532 231L540 239L532 247',C,1.5) + save('feature-'+k,580,276,title,l1+' '+l2+' Conceptual animation; follow the linked source for capability limits.',s,target) +# Overview uses three clearly labelled stages rather than fabricated telemetry. +s=text(36,44,'SIGNAL TO CONTEXT',12,C,extra='class="mono"')+text(36,89,'A sensing loop you can inspect.',34,WHITE) +for i,(label,sub,kind,tail) in enumerate([('Capture RF','CSI from supported hardware','signal','Provision and calibrate'),('Extract context','Signal processing and models','models','Compare against real data'),('Act locally','Events, APIs and integrations','automation','Apply rules and review outputs')]): + x=36+i*396;s+=f'' + s+=text(x+24,153,f'0{i+1}',12,C)+text(x+24,188,label,26,WHITE)+text(x+24,216,sub,15)+icon(kind,x+269,281,.62)+text(x+24,311,tail,13) + if i<2:s+=path(f'M{x+344} 229H{x+384}M{x+376} 221L{x+384} 229L{x+376} 237',C,2,extra='class="flow"') +s+=text(36,373,'CONCEPTUAL PIPELINE / HARDWARE AND ENVIRONMENT VALIDATION MATTER',11,MUTED,extra='class="mono"') +save('overview',1200,396,'RuView sensing overview','Conceptual sequence: capture RF, extract context, act locally. Links to architecture and user documentation.',s,'docs/user-guide.md') +# Chapter headings act as visual landmarks, all remain links. +headers=[('features','Explore the capabilities','SENSING / MODELS / AUTOMATION','presence','docs/user-guide.md'),('edge','Intelligence at the edge','SENSORS / FIRMWARE / MODULES','edge','firmware/esp32-csi-node/README.md'),('learning','From recordings to models','CAPTURE / TRAIN / EVALUATE','models','docs/user-guide.md'),('evidence','Evidence before accuracy claims','BENCHMARKS / REPRODUCERS / LIMITS','evidence','docs/benchmarks/pose-estimation-cog.md'),('docs','Choose your next step','GUIDES / HARDWARE / INTEGRATIONS','agents','docs/user-guide.md')] +for k,title,sub,kind,target in headers: + s=icon(kind,76,75,.75)+text(140,63,title,34,WHITE)+text(142,99,sub,12,C,extra='class="mono"') + for i in range(4):s+=path(f'M865 {44+i*17}Q960 {10+i*28} 1124 {52+i*17}',C,1,extra=f'class="flow" opacity="{.65-i*.1}"') + s+=path('M1140 70H1160M1152 62L1160 70L1152 78',C,2) + save('header-'+k,1200,148,title,'Animated section heading. '+sub,s,target) +# Brand destination; original ruOS promotion remains in the README. +s=text(44,42,'COGNITUM ONE',12,C,extra='class="mono"')+text(44,99,'Ambient Intelligence.',42,WHITE)+text(44,147,'At the edge of the physical world.',25,C)+text(44,190,'Explore the platform behind the ecosystem.',16) +s+=''+text(65,254,'Explore Cognitum',18,INK)+path('M263 247H284M277 240L284 247L277 254',INK,2) +s+=''+icon('seed',945,150,2.4) +for j in range(3):s+=f'' +save('cognitum',1200,304,'Explore Cognitum One','Ambient Intelligence at the edge of the physical world. Open cognitum.one.',s,'https://cognitum.one') +(OUT/'manifest.json').write_text(json.dumps({'schema_version':1,'style':'Cognitum cyan on midnight','motion':'Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies','assets':manifest},indent=2)+'\n') +print(f'Generated {len(manifest)} SVGs, {sum((ROOT/x["file"]).stat().st_size for x in manifest):,} bytes') diff --git a/scripts/verify-readme-visuals.py b/scripts/verify-readme-visuals.py new file mode 100644 index 000000000..7847fc5e4 --- /dev/null +++ b/scripts/verify-readme-visuals.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Verify the generated SVGs and GitHub-compatible image link wrappers.""" +from pathlib import Path +import json,re,xml.etree.ElementTree as ET +from urllib.parse import urlparse +ROOT=Path(__file__).resolve().parents[1];NS='{http://www.w3.org/2000/svg}' +manifest=json.loads((ROOT/'assets/readme/manifest.json').read_text());readme=(ROOT/'README.md').read_text();total=0 +for asset in manifest['assets']: + file=ROOT/asset['file'];raw=file.read_text();total+=file.stat().st_size;root=ET.fromstring(raw) + assert root.tag==NS+'svg' and root.get('viewBox'),asset['file'] + assert root.find(NS+'title') is not None and root.find(NS+'desc') is not None + ids=[e.get('id') for e in root.iter() if e.get('id')];assert len(ids)==len(set(ids)) + assert 'prefers-reduced-motion:reduce' in raw and '@keyframes' in raw + for e in root.iter(): + assert e.tag not in [NS+'script',NS+'foreignObject',NS+'image'],asset['file'] + assert all(not k.lower().startswith('on') for k in e.attrib) + assert not any(k.endswith('href') for k in e.attrib),'Links belong to README wrappers' + assert not re.search(r'url\((?!#)',raw),'Remote dependency' + target=asset['target'];parsed=urlparse(target) + if parsed.scheme: + assert parsed.scheme=='https' and parsed.hostname in ['cognitum.one','huggingface.co'] + else:assert (ROOT/target.split('#')[0]).is_file(),target + escaped=re.escape(asset['file']);destination=re.escape(target) + linked=bool(re.search(r'\[!\[[^\]]*\]\('+escaped+r'\)\]\('+destination+r'\)',readme)) + linked=linked or bool(re.search(r'\s* Date: Sat, 10 Oct 2026 13:22:26 -0400 Subject: [PATCH 05/23] fix(sensing-server): explain bootstrap holdout failures --- .../wifi-densepose-sensing-server/README.md | 24 +- .../src/bootstrap_baseline.rs | 286 ++++++++++++++++++ .../wifi-densepose-sensing-server/src/main.rs | 15 +- 3 files changed, 322 insertions(+), 3 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/README.md b/v2/crates/wifi-densepose-sensing-server/README.md index 386dabca2..d7e3457d6 100644 --- a/v2/crates/wifi-densepose-sensing-server/README.md +++ b/v2/crates/wifi-densepose-sensing-server/README.md @@ -88,7 +88,29 @@ cargo run -p wifi-densepose-sensing-server -- \ Complete the normal empty-room calibration, then call `POST /api/v1/calibration/bootstrap/promote`. The server measures a separate 12-sample holdout -before storing an aggregate field-model snapshot. On a later start with the same installation ID, +before storing an aggregate field-model snapshot. Promotion requires all of the following: + +- Exactly 12 fresh samples, spaced at least one second apart, with no stale samples. +- At least 10 of the 12 samples classified as empty. +- Zero samples containing numeric vital-sign estimates. + +The 10-of-12 tolerance applies only to the empty classification. A holdout with 12 empty samples +and five samples containing vital-sign estimates still fails; a vital-sign estimate does not +prove occupancy, but it prevents storing the startup baseline under this conservative check. + +Completed holdout evaluations return the existing `validation` counters and additive +`validation_diagnostics.requirements` and `validation_diagnostics.failures` fields. A failed +check retains `success: false`, `error_code`, and `error`; the error describes the observed +counts and required limits. Each failure identifies its `check`, `observed` and `required` +counts, and `comparison`, so clients can explain every failed condition. For example, five +vital-sign samples produce a `vital_sign_sample_count` failure with `observed: 5`, +`required: 0`, and `comparison: "at_most"`. + +The bounded refinement endpoint, `POST /api/v1/calibration/bootstrap/refine`, also requires +12 fresh samples with zero stale or vital-sign samples. Its diagnostics omit the empty-sample +minimum because refinement does not apply that promotion-only check. + +On a later start with the same installation ID, the snapshot is restored with `bootstrap_only` authority. It can reduce startup background false positives, but cannot authorize calibrated evidence or numeric heart and breathing rates. Use `POST /api/v1/calibration/reset` with administrator scope to remove it. diff --git a/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs b/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs index b6ce978c8..326e2f5fa 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs @@ -90,6 +90,131 @@ pub struct BootstrapValidationResult { pub passed: bool, } +/// Independent ADR-355 requirements. Refinement does not count empty votes. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct BootstrapValidationRequirements { + pub sample_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_empty_sample_count: Option, + pub max_stale_sample_count: usize, + pub max_vital_sign_sample_count: usize, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct BootstrapValidationFailure { + pub check: &'static str, + pub observed: usize, + pub required: usize, + pub comparison: &'static str, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct BootstrapValidationDiagnostics { + pub requirements: BootstrapValidationRequirements, + pub failures: Vec, +} + +impl BootstrapValidationResult { + pub fn diagnostics(&self) -> BootstrapValidationDiagnostics { + validation_diagnostics( + self.sample_count, + Some(self.empty_sample_count), + self.stale_sample_count, + self.vital_sign_sample_count, + ) + } +} + +impl BootstrapValidationDiagnostics { + /// Describe every failed check, without interpreting estimates as people. + pub fn failure_summary(&self) -> String { + self.failures + .iter() + .map(|failure| match failure.check { + "sample_count" => format!( + "{} samples collected (exactly {} required); check the CSI stream", + failure.observed, failure.required, + ), + "empty_sample_count" => format!( + "{} samples reported empty (at least {} required); vacate the room and check for moving objects", + failure.observed, failure.required, + ), + "stale_sample_count" => format!( + "{} stale samples ({} allowed); check the CSI stream", + failure.observed, failure.required, + ), + _ => format!( + "numeric vital-sign estimates in {}/{} samples ({} allowed); vacate the room and check for moving objects", + failure.observed, self.requirements.sample_count, failure.required, + ), + }) + .collect::>() + .join("; ") + } +} + +/// Explain existing gate counters without changing how samples are counted. +/// Promotion supplies all attempted samples; refinement supplies only fresh +/// residuals. Stale samples are a separate check, never added to sample_count. +pub fn validation_diagnostics( + sample_count: usize, + empty_sample_count: Option, + stale_sample_count: usize, + vital_sign_sample_count: usize, +) -> BootstrapValidationDiagnostics { + let requirements = BootstrapValidationRequirements { + sample_count: BOOTSTRAP_VALIDATION_SAMPLES, + min_empty_sample_count: empty_sample_count.map(|_| BOOTSTRAP_VALIDATION_MIN_EMPTY), + max_stale_sample_count: 0, + max_vital_sign_sample_count: 0, + }; + let mut failures = Vec::new(); + let checks = [ + ( + "sample_count", + sample_count, + requirements.sample_count, + "equal", + sample_count != requirements.sample_count, + ), + ( + "empty_sample_count", + empty_sample_count.unwrap_or(0), + BOOTSTRAP_VALIDATION_MIN_EMPTY, + "at_least", + empty_sample_count.is_some_and(|count| count < BOOTSTRAP_VALIDATION_MIN_EMPTY), + ), + ( + "stale_sample_count", + stale_sample_count, + requirements.max_stale_sample_count, + "at_most", + stale_sample_count != 0, + ), + ( + "vital_sign_sample_count", + vital_sign_sample_count, + requirements.max_vital_sign_sample_count, + "at_most", + vital_sign_sample_count != 0, + ), + ]; + for (check, observed, required, comparison, failed) in checks { + if failed { + failures.push(BootstrapValidationFailure { + check, + observed, + required, + comparison, + }); + } + } + BootstrapValidationDiagnostics { + requirements, + failures, + } +} + #[derive(Debug, Error)] pub enum BootstrapBaselineError { #[error("a stable installation_id is required for a local bootstrap baseline")] @@ -505,6 +630,167 @@ mod tests { assert!(!evaluate_validation(&vital).passed); } + fn empty_validation_samples(count: usize) -> Vec { + vec![ + BootstrapValidationSample { + fresh_tick: true, + calibrated_empty: true, + vital_signs_absent: true, + }; + count + ] + } + + #[test] + fn validation_diagnostics_keep_empty_tolerance_separate_from_vitals() { + let mut samples = empty_validation_samples(12); + samples[0].calibrated_empty = false; + samples[1].calibrated_empty = false; + let passing = evaluate_validation(&samples); + assert!(passing.passed); + assert_eq!(passing.empty_sample_count, 10); + assert!(passing.diagnostics().failures.is_empty()); + assert!(passing.diagnostics().failure_summary().is_empty()); + + samples[2].calibrated_empty = false; + let rejected = evaluate_validation(&samples); + assert!(!rejected.passed); + assert_eq!( + rejected.diagnostics().failures, + vec![BootstrapValidationFailure { + check: "empty_sample_count", + observed: 9, + required: 10, + comparison: "at_least", + }] + ); + } + + #[test] + fn validation_diagnostics_explain_numeric_vital_veto_even_when_all_empty() { + for count in [1, 5] { + let mut samples = empty_validation_samples(12); + for sample in samples.iter_mut().take(count) { + sample.vital_signs_absent = false; + } + let result = evaluate_validation(&samples); + assert!(!result.passed); + assert_eq!(result.empty_sample_count, 12); + let diagnostics = result.diagnostics(); + let json = serde_json::to_value(&diagnostics).unwrap(); + assert_eq!( + json["requirements"], + serde_json::json!({ + "sample_count": 12, + "min_empty_sample_count": 10, + "max_stale_sample_count": 0, + "max_vital_sign_sample_count": 0, + }) + ); + assert_eq!( + json["failures"], + serde_json::json!([{ + "check": "vital_sign_sample_count", + "observed": count, + "required": 0, + "comparison": "at_most", + }]) + ); + assert!(diagnostics.failure_summary().contains(&format!( + "numeric vital-sign estimates in {count}/12 samples (0 allowed)" + ))); + } + } + + #[test] + fn validation_diagnostics_reject_stale_incomplete_and_oversized_windows() { + for count in [0, 11, 13] { + let result = evaluate_validation(&empty_validation_samples(count)); + assert!(!result.passed); + let diagnostics = result.diagnostics(); + assert_eq!(diagnostics.failures[0].check, "sample_count"); + assert_eq!(diagnostics.failures[0].observed, count); + assert_eq!(diagnostics.failures[0].required, 12); + assert!(diagnostics + .failure_summary() + .contains("check the CSI stream")); + } + let mut samples = empty_validation_samples(12); + samples[0].fresh_tick = false; + let result = evaluate_validation(&samples); + assert!(!result.passed); + assert_eq!(result.sample_count, 12); + assert_eq!(result.empty_sample_count, 11); + assert_eq!( + result.diagnostics().failures, + vec![BootstrapValidationFailure { + check: "stale_sample_count", + observed: 1, + required: 0, + comparison: "at_most", + }] + ); + } + + #[test] + fn validation_diagnostics_report_all_simultaneous_failures() { + let mut samples = empty_validation_samples(11); + samples[0].fresh_tick = false; + samples[1].calibrated_empty = false; + samples[2].vital_signs_absent = false; + let result = evaluate_validation(&samples); + assert!(!result.passed); + let diagnostics = result.diagnostics(); + assert_eq!( + diagnostics + .failures + .iter() + .map(|failure| failure.check) + .collect::>(), + [ + "sample_count", + "empty_sample_count", + "stale_sample_count", + "vital_sign_sample_count", + ] + ); + let summary = diagnostics.failure_summary(); + for expected in [ + "11 samples collected (exactly 12 required)", + "9 samples reported empty (at least 10 required)", + "1 stale samples (0 allowed)", + "numeric vital-sign estimates in 1/12 samples (0 allowed)", + ] { + assert!(summary.contains(expected), "missing {expected}: {summary}"); + } + } + + #[test] + fn refinement_diagnostics_keep_fresh_count_and_omit_empty_vote_requirement() { + let diagnostics = validation_diagnostics(11, None, 1, 5); + let json = serde_json::to_value(&diagnostics).unwrap(); + assert!(json["requirements"].get("min_empty_sample_count").is_none()); + assert_eq!( + diagnostics + .failures + .iter() + .map(|failure| failure.check) + .collect::>(), + [ + "sample_count", + "stale_sample_count", + "vital_sign_sample_count", + ] + ); + assert_eq!(diagnostics.failures[0].observed, 11); + assert!(diagnostics + .failure_summary() + .contains("5/12 samples (0 allowed)")); + let passing = validation_diagnostics(12, None, 0, 0); + assert!(passing.failures.is_empty()); + assert!(passing.failure_summary().is_empty()); + } + #[test] fn restart_verifies_the_exact_stored_payload_bytes() { let dir = tempfile::tempdir().unwrap(); diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 63ea44184..62b0eecf0 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -10036,12 +10036,14 @@ async fn calibration_promote_bootstrap( } let validation = bootstrap_baseline::evaluate_validation(&samples); + let validation_diagnostics = validation.diagnostics(); if !validation.passed { return Json(serde_json::json!({ "success": false, "error_code": "bootstrap_validation_failed", - "error": "The held out empty room check did not pass. The model was not stored.", + "error": format!("The held out empty room check did not pass: {}. The model was not stored.", validation_diagnostics.failure_summary()), "validation": validation, + "validation_diagnostics": validation_diagnostics, })); } @@ -10107,6 +10109,7 @@ async fn calibration_promote_bootstrap( "success": true, "message": "Validated startup baseline stored locally.", "validation": validation, + "validation_diagnostics": validation_diagnostics, "holdout_window_size": holdout_window_size, "bootstrap_baseline": stored, "calibrated_evidence_authorized": false, @@ -10236,6 +10239,12 @@ async fn calibration_refine_bootstrap( + Duration::from_millis(BOOTSTRAP_VALIDATION_MIN_SPACING_MS); } + let validation_diagnostics = bootstrap_baseline::validation_diagnostics( + residuals.len(), + None, + stale_sample_count, + vital_sign_sample_count, + ); if residuals.len() != BOOTSTRAP_VALIDATION_SAMPLES || stale_sample_count != 0 || vital_sign_sample_count != 0 @@ -10243,12 +10252,13 @@ async fn calibration_refine_bootstrap( return Json(serde_json::json!({ "success": false, "error_code": "bootstrap_refinement_validation_failed", - "error": "The fresh empty-room refinement gate did not pass. The stored baseline was not changed.", + "error": format!("The fresh empty-room refinement gate did not pass: {}. The stored baseline was not changed.", validation_diagnostics.failure_summary()), "validation": { "sample_count": residuals.len(), "stale_sample_count": stale_sample_count, "vital_sign_sample_count": vital_sign_sample_count, }, + "validation_diagnostics": validation_diagnostics, })); } @@ -10335,6 +10345,7 @@ async fn calibration_refine_bootstrap( Json(serde_json::json!({ "success": true, "message": "The local empty-room startup baseline was refined.", + "validation_diagnostics": validation_diagnostics, "validation": { "sample_count": residuals.len(), "stale_sample_count": stale_sample_count, From d779961a55b9a80a5ebc057f23998f4a90260708 Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 10 Oct 2026 13:29:51 -0400 Subject: [PATCH 06/23] fix(virtual-view): validate bounded plain data before broker calls --- .github/workflows/ci.yml | 3 + integrations/virtual-view/README.md | 15 ++ integrations/virtual-view/adapter.mjs | 47 +++++- integrations/virtual-view/adapter.test.mjs | 170 ++++++++++++++++++++- 4 files changed, 226 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e92106c0..205ba5c27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,6 +206,9 @@ jobs: - name: Run UI unit tests run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs ui/services/sensing.service.test.mjs ui/components/DashboardTab.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs + - name: Run virtual-view adapter boundary tests + run: node --test integrations/virtual-view/adapter.test.mjs + # Unit and Integration Tests # Python pytest matrix — runs against the archived v1 Python tree. # `continue-on-error: true` for the same reason as code-quality above: diff --git a/integrations/virtual-view/README.md b/integrations/virtual-view/README.md index 74e59f00b..2d5680593 100644 --- a/integrations/virtual-view/README.md +++ b/integrations/virtual-view/README.md @@ -4,6 +4,11 @@ Opt-in integration with Dream Machine's `scripts/virtual-view-broker.mjs`. Disabled by default. No cameras, model calls, network, hardware commands or automatic promotion. It is NOT part of the Rust production perception pipeline. +The reviewed companion is Dream Machine commit +[`5707c825b2c065de44abed02594e1abf5eab95ea`](https://github.com/ruvnet/dream-machine/tree/5707c825b2c065de44abed02594e1abf5eab95ea), +merged in [Dream Machine PR #162](https://github.com/ruvnet/dream-machine/pull/162). +Use a checkout at that revision when reproducing the integration. + ```js import { generateKeyPairSync } from 'node:crypto'; import { broker } from '../../../dream-machine/scripts/virtual-view-broker.mjs'; @@ -21,6 +26,16 @@ threshold, trust boundary, receipt and acceptance rule. The adapter does not transform raw `spatial.evidence.v1` RF Gaussians or `ruview.lidar.depth.v1` packets: neither supplies the independently tracked manipulation scene this method needs. +The injected broker is trusted executable code, not sandboxed by this adapter. +The no-network/no-actuation behavior applies to this adapter and the reviewed +companion broker. Before reading scene markers or calling the broker, the adapter +checks the entire snapshot as bounded plain JSON using property descriptors. +It rejects proxies, getters, inherited/custom prototypes, nonfinite values, +sparse arrays and non-JSON data, including nested values. Structural limits match +the reviewed companion: depth 12, 20,000 values, 1,024 array elements, 64 object +properties, 128-character keys and 4,096-character strings. Full geometry, +provenance-label and signing-key validation remain the companion broker's responsibility. + ```sh node --test integrations/virtual-view/adapter.test.mjs # From the sibling Dream Machine checkout: diff --git a/integrations/virtual-view/adapter.mjs b/integrations/virtual-view/adapter.mjs index 157105858..ced2c1f8b 100644 --- a/integrations/virtual-view/adapter.mjs +++ b/integrations/virtual-view/adapter.mjs @@ -1,12 +1,55 @@ // Opt-in cross-repository prototype. Imports no hardware, network or model client. +import { types } from 'node:util'; + +const snapshotError = () => new TypeError( + 'calibrated metric scene snapshot required; raw RF/LiDAR packets and non-JSON data are not supported', +); + +// Structural preflight only, matching the reviewed companion's JSON budgets. +// Never invoke input methods, getters or proxy traps while inspecting data. +function validateSnapshotData(value, depth = 0, budget = { nodes: 0 }) { + if (depth > 12 || ++budget.nodes > 20000) throw snapshotError(); + if (value === null || typeof value === 'boolean') return; + if (typeof value === 'string' && value.length <= 4096) return; + if (typeof value === 'number' && Number.isFinite(value)) return; + if (typeof value !== 'object' || types.isProxy(value)) throw snapshotError(); + + const array = Array.isArray(value); + if (Object.getPrototypeOf(value) !== (array ? Array.prototype : Object.prototype)) { + throw snapshotError(); + } + if (array) { + const length = Object.getOwnPropertyDescriptor(value, 'length').value; + if (length > 1024) throw snapshotError(); + if (Reflect.ownKeys(value).length !== length + 1) throw snapshotError(); + for (let index = 0; index < length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) throw snapshotError(); + validateSnapshotData(descriptor.value, depth + 1, budget); + } + return; + } + const keys = Reflect.ownKeys(value); + if (keys.length > 64) throw snapshotError(); + for (const key of keys) { + if (typeof key !== 'string' || key.length > 128) throw snapshotError(); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) throw snapshotError(); + validateSnapshotData(descriptor.value, depth + 1, budget); + } +} + export function createVirtualViewAdapter({ broker, privateKey, enabled = false }) { if (typeof broker !== 'function' || typeof enabled !== 'boolean') throw new TypeError('adapter configuration'); return Object.freeze({ observe(snapshot) { if (!enabled) return { decision: 'BLOCK', reason: 'disabled', authority: 'none', svg: null }; + validateSnapshotData(snapshot); // Explicit metric scene contract only. RF Gaussians are not manipulation geometry. - if (snapshot?.schema !== 'virtual-view.v1' || snapshot?.frame !== 'room_enu') { - throw new TypeError('calibrated metric scene snapshot required; raw RF/LiDAR packets are not supported'); + const schema = snapshot && Object.getOwnPropertyDescriptor(snapshot, 'schema'); + const frame = snapshot && Object.getOwnPropertyDescriptor(snapshot, 'frame'); + if (schema?.value !== 'virtual-view.v1' || frame?.value !== 'room_enu') { + throw snapshotError(); } return broker(snapshot, { privateKey }); }, diff --git a/integrations/virtual-view/adapter.test.mjs b/integrations/virtual-view/adapter.test.mjs index f98eb816e..b49d9e4c2 100644 --- a/integrations/virtual-view/adapter.test.mjs +++ b/integrations/virtual-view/adapter.test.mjs @@ -3,12 +3,168 @@ import assert from 'node:assert/strict'; import { createVirtualViewAdapter } from './adapter.mjs'; test('disabled by default and never actuates', () => { const adapter = createVirtualViewAdapter({ broker: () => { throw Error('not called'); } }); - assert.equal(adapter.observe({}).reason, 'disabled'); assert.equal(adapter.hardwareActuation, false); + let reads = 0; + const snapshot = { get schema() { reads++; throw Error('not read'); } }; + assert.deepEqual(adapter.observe(snapshot), { + decision: 'BLOCK', reason: 'disabled', authority: 'none', svg: null, + }); + assert.equal(reads, 0); + assert.equal(adapter.hardwareActuation, false); + assert.equal(Object.isFrozen(adapter), true); }); -test('raw RF is rejected; explicit scene is forwarded to validating broker', () => { - let received; - const adapter = createVirtualViewAdapter({ enabled: true, broker: s => { received = s; return { authority: 'none' }; } }); - assert.throws(() => adapter.observe({ schema: 'spatial.evidence.v1' })); - const s = { schema: 'virtual-view.v1', frame: 'room_enu' }; - assert.equal(adapter.observe(s).authority, 'none'); assert.equal(received, s); + +test('raw RF, LiDAR and malformed scene markers never reach the broker', () => { + let calls = 0; + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } }); + for (const snapshot of [ + null, undefined, 'virtual-view.v1', [], {}, + { schema: 'spatial.evidence.v1', frame: 'room_enu' }, + { schema: 'ruview.lidar.depth.v1', frame: 'room_enu' }, + { schema: 'virtual-view.v1' }, + { schema: 'virtual-view.v1', frame: 'camera' }, + Object.create({ schema: 'virtual-view.v1', frame: 'room_enu' }), + Object.defineProperty({ frame: 'room_enu' }, 'schema', { value: 'virtual-view.v1' }), + ]) { + assert.throws(() => adapter.observe(snapshot), /calibrated metric scene snapshot required/); + } + assert.equal(calls, 0); +}); + +test('scene marker getters are rejected without executing input code', () => { + let reads = 0, calls = 0; + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } }); + for (const key of ['schema', 'frame']) { + const snapshot = { schema: 'virtual-view.v1', frame: 'room_enu' }; + Object.defineProperty(snapshot, key, { + enumerable: true, + get() { reads++; return key === 'schema' ? 'virtual-view.v1' : 'room_enu'; }, + }); + assert.throws(() => adapter.observe(snapshot), /calibrated metric scene snapshot required/); + } + assert.equal(reads, 0); + assert.equal(calls, 0); +}); + +test('proxy snapshots are rejected without running inspection traps', () => { + let traps = 0, calls = 0; + const snapshot = new Proxy({ schema: 'virtual-view.v1', frame: 'room_enu' }, { + get() { traps++; throw Error('get trap'); }, + getPrototypeOf() { traps++; throw Error('prototype trap'); }, + getOwnPropertyDescriptor() { traps++; throw Error('descriptor trap'); }, + }); + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } }); + assert.throws(() => adapter.observe(snapshot), /calibrated metric scene snapshot required/); + assert.equal(traps, 0); + assert.equal(calls, 0); +}); + +test('nested object and array getters are rejected without reads or broker calls', () => { + let reads = 0, calls = 0; + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } }); + const getter = { enumerable: true, get() { reads++; return 0; } }; + const object = Object.defineProperty({}, 'uncertaintyM', getter); + const array = Object.defineProperty([0], '0', getter); + for (const extra of [object, [object], { nested: array }]) { + assert.throws(() => adapter.observe({ schema: 'virtual-view.v1', frame: 'room_enu', extra }), + /calibrated metric scene snapshot required/); + } + assert.equal(reads, 0); + assert.equal(calls, 0); +}); + +test('nested proxies and custom array prototypes never execute input code', () => { + let traps = 0, calls = 0; + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } }); + const handler = { + get() { traps++; throw Error('get trap'); }, + getPrototypeOf() { traps++; throw Error('prototype trap'); }, + ownKeys() { traps++; throw Error('keys trap'); }, + getOwnPropertyDescriptor() { traps++; throw Error('descriptor trap'); }, + }; + const custom = Object.setPrototypeOf([], { + get [Symbol.iterator]() { traps++; throw Error('iterator getter'); }, + }); + class SceneArray extends Array {} + for (const extra of [ + { nested: new Proxy({}, handler) }, + [new Proxy([], handler)], + { nested: custom }, + new SceneArray(), + ]) { + assert.throws(() => adapter.observe({ schema: 'virtual-view.v1', frame: 'room_enu', extra }), + /calibrated metric scene snapshot required/); + } + assert.equal(traps, 0); + assert.equal(calls, 0); +}); + +test('cyclic, non-JSON and oversized structures are rejected before the broker', () => { + let calls = 0; + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } }); + const cyclic = {}; cyclic.self = cyclic; + let deep = 0; + for (let index = 0; index < 13; index++) deep = { next: deep }; + const sparse = Array(2); sparse[1] = 0; + const extraProperty = [0]; extraProperty.extra = 0; + for (const extra of [ + cyclic, deep, sparse, extraProperty, NaN, Infinity, undefined, () => {}, new Date(), + 'x'.repeat(4097), Array(1025).fill(0), + { ['x'.repeat(129)]: 0 }, + Object.fromEntries(Array.from({ length: 65 }, (_, index) => [`k${index}`, 0])), + Array.from({ length: 20 }, () => Array(1024).fill(0)), + ]) { + assert.throws(() => adapter.observe({ schema: 'virtual-view.v1', frame: 'room_enu', extra }), + /calibrated metric scene snapshot required/); + } + assert.equal(calls, 0); +}); + +test('a complete 256-object synthetic scene stays within the structural budget', () => { + const scene = { + schema: 'virtual-view.v1', sceneId: 'fixture', requestId: 'request:1', policyId: 'fixed', + revision: 1, frame: 'room_enu', proof: 'SYNTHETIC', nowMs: 1000, observedMs: 990, + contact: 'static', target: 'o0', reference: 'o1', physicalAxis: 'x', + objects: Array.from({ length: 256 }, (_, index) => ({ + id: `o${index}`, observed: [index / 100, index % 3, 0.1], + predicted: [index / 100, index % 3, 0.1], size: [0.01, 0.01, 0.01], + uncertaintyM: 0.015, sourceReceipt: `fixture:${index}`, + })), + }; + let calls = 0; + const result = { authority: 'none' }; + const adapter = createVirtualViewAdapter({ enabled: true, broker: received => { + calls++; + assert.equal(received, scene); + return result; + } }); + assert.equal(adapter.observe(scene), result); + assert.equal(calls, 1); +}); + +test('explicit immutable scene and signer are forwarded unchanged to trusted broker', () => { + const snapshot = Object.freeze({ schema: 'virtual-view.v1', frame: 'room_enu' }); + const privateKey = Object.freeze({ researchSigner: true }); + const result = Object.freeze({ decision: 'SKIP', authority: 'none', svg: null }); + let calls = 0; + const adapter = createVirtualViewAdapter({ enabled: true, privateKey, broker: (received, options) => { + calls++; + assert.equal(received, snapshot); + assert.equal(options.privateKey, privateKey); + return result; + } }); + assert.equal(adapter.observe(snapshot), result); + assert.equal(calls, 1); +}); + +test('full scene validation remains delegated and broker rejection propagates', () => { + const rejection = new TypeError('missing tracked scene geometry'); + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { throw rejection; } }); + assert.throws(() => adapter.observe({ schema: 'virtual-view.v1', frame: 'room_enu' }), + error => error === rejection); +}); + +test('enablement must be explicit and configuration types are checked', () => { + for (const configuration of [ + { broker: null }, { broker: () => {}, enabled: 'true' }, { broker: () => {}, enabled: 1 }, + ]) assert.throws(() => createVirtualViewAdapter(configuration), /adapter configuration/); }); From 8a140c190685e30499122f1f3a2e63bbe663477d Mon Sep 17 00:00:00 2001 From: ruv Date: Sat, 10 Oct 2026 13:41:26 -0400 Subject: [PATCH 07/23] fix(docs): harden README visual XML and path validation --- .github/workflows/readme-visuals.yml | 44 +++++++ docs/readme-visual-system.md | 14 ++- scripts/readme_visuals_xml.py | 50 ++++++++ scripts/render-readme-visuals.py | 9 +- scripts/requirements-readme-visuals.txt | 2 + scripts/tests/test_readme_visuals_xml.py | 152 +++++++++++++++++++++++ scripts/verify-readme-visuals.py | 10 +- 7 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/readme-visuals.yml create mode 100644 scripts/readme_visuals_xml.py create mode 100644 scripts/requirements-readme-visuals.txt create mode 100644 scripts/tests/test_readme_visuals_xml.py diff --git a/.github/workflows/readme-visuals.yml b/.github/workflows/readme-visuals.yml new file mode 100644 index 000000000..f3abb8325 --- /dev/null +++ b/.github/workflows/readme-visuals.yml @@ -0,0 +1,44 @@ +name: README visual asset validation + +on: + pull_request: + paths: + - 'README.md' + - 'assets/readme/**' + - 'docs/readme-visual-system.md' + - 'scripts/*readme*' + - 'scripts/tests/test_readme_visuals_xml.py' + - '.github/workflows/readme-visuals.yml' + push: + branches: [main] + paths: + - 'README.md' + - 'assets/readme/**' + - 'docs/readme-visual-system.md' + - 'scripts/*readme*' + - 'scripts/tests/test_readme_visuals_xml.py' + - '.github/workflows/readme-visuals.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify: + name: Safe and reproducible README visuals + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.11' + - name: Install visual tooling dependency + run: python -m pip install -r scripts/requirements-readme-visuals.txt + - name: Test XML input boundaries + run: python -m unittest discover -s scripts/tests -p test_readme_visuals_xml.py + - name: Verify reproducible and accessible assets + run: | + python scripts/render-readme-visuals.py + python scripts/verify-readme-visuals.py + git diff --exit-code -- assets/readme + test -z "$(git ls-files --others --exclude-standard -- assets/readme)" diff --git a/docs/readme-visual-system.md b/docs/readme-visual-system.md index fc74c099a..ad6fcd740 100644 --- a/docs/readme-visual-system.md +++ b/docs/readme-visual-system.md @@ -5,11 +5,23 @@ The README uses a Cognitum-inspired palette: midnight backgrounds, cyan gradient ## Rebuild and check ```bash +python3 -m pip install -r scripts/requirements-readme-visuals.txt python3 scripts/render-readme-visuals.py python3 scripts/verify-readme-visuals.py +python3 -m unittest discover -s scripts/tests -p test_readme_visuals_xml.py ``` -The generator uses only Python's standard library and makes no network calls. The committed SVGs are the deployment artifacts. The manifest in `assets/readme/manifest.json` lists every destination. +Install the tooling dependency in a virtual environment. The generator and verifier +use `defusedxml` to reject DTDs, entities and external XML references. SVG inputs +must be smaller than 20,000 bytes; the verifier reads at most that limit and +rejects oversized inputs before parsing. Both tools use UTF-8, and generated +files use LF line endings for reproducibility across platforms. They make no +network calls. This dependency is only for visual maintenance, not the runtime. +Manifest paths must be relative: SVG files stay inside `assets/readme`, and +local link targets stay inside the repository, including after symlink resolution. + +The committed SVGs are the deployment artifacts. The manifest in +`assets/readme/manifest.json` lists every destination. ## Links and rendering diff --git a/scripts/readme_visuals_xml.py b/scripts/readme_visuals_xml.py new file mode 100644 index 000000000..334d02d32 --- /dev/null +++ b/scripts/readme_visuals_xml.py @@ -0,0 +1,50 @@ +"""Bounded, hardened XML parsing for the README visual maintenance tools.""" + +from pathlib import Path, PurePosixPath, PureWindowsPath + +from defusedxml import ElementTree + + +# Keep the visual verifier's existing exclusive per-asset size limit. +MAX_SVG_BYTES = 20_000 + + +def resolve_repo_file(root: Path, relative: str, *, asset: bool = False) -> Path: + """Resolve a portable manifest path inside its trusted repository boundary.""" + if ( + not isinstance(relative, str) + or not relative + or "\\" in relative + or ":" in relative + or "\x00" in relative + or PurePosixPath(relative).is_absolute() + or PureWindowsPath(relative).drive + or ".." in relative.split("/") + ): + raise ValueError("Expected a repository-relative path without traversal") + repository = root.resolve() + boundary = repository / "assets" / "readme" if asset else repository + resolved = (repository / relative).resolve() + if not resolved.is_relative_to(boundary): + raise ValueError("Manifest path escapes its allowed repository directory") + if not resolved.is_file(): + raise ValueError("Manifest path must name an existing file") + return resolved + + +def parse_svg(raw: bytes): + """Reject oversized input and DTD/entity declarations before building a tree.""" + if not isinstance(raw, bytes): + raise TypeError("SVG input must be bytes") + if len(raw) >= MAX_SVG_BYTES: + raise ValueError(f"SVG must be smaller than {MAX_SVG_BYTES} bytes") + return ElementTree.fromstring( + raw, forbid_dtd=True, forbid_entities=True, forbid_external=True + ) + + +def read_svg(path: Path): + """Read at most the rejection boundary, including when a file grows mid-read.""" + with path.open("rb") as stream: + raw = stream.read(MAX_SVG_BYTES) + return raw, parse_svg(raw) diff --git a/scripts/render-readme-visuals.py b/scripts/render-readme-visuals.py index fdc5f67d8..e37a9c58b 100644 --- a/scripts/render-readme-visuals.py +++ b/scripts/render-readme-visuals.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -"""Generate the RuView README visual system. Deterministic, stdlib, no network.""" +"""Generate the RuView README visual system. Deterministic, no network.""" from pathlib import Path from html import escape -import math,json,xml.etree.ElementTree as ET +import math,json +from readme_visuals_xml import parse_svg ROOT=Path(__file__).resolve().parents[1];OUT=ROOT/'assets/readme';OUT.mkdir(parents=True,exist_ok=True) C='#72eee0';BLUE='#70b6ff';INK='#041117';WHITE='#ecfafb';MUTED='#9abac5' def text(x,y,value,size=16,color=MUTED,anchor='start',extra=''): @@ -46,7 +47,7 @@ def frame(w,h,title,desc,body): return f'''{escape(title)}{escape(desc)}{body}''' manifest=[] def save(name,w,h,title,desc,body,target): - s=frame(w,h,title,desc,body);ET.fromstring(s);(OUT/(name+'.svg')).write_text(s+'\n');manifest.append({'file':'assets/readme/'+name+'.svg','title':title,'target':target,'width':w,'height':h}) + s=frame(w,h,title,desc,body);raw=(s+'\n').encode('utf-8');parse_svg(raw);(OUT/(name+'.svg')).write_bytes(raw);manifest.append({'file':'assets/readme/'+name+'.svg','title':title,'target':target,'width':w,'height':h}) # Main title panel: a radio field passes through an illustrated room. s=text(48,44,'RUVIEW / COGNITUM ECOSYSTEM',13,C,extra='class="mono"')+text(48,121,'RuView',76,WHITE)+text(48,184,'Spaces become signals.',38,WHITE)+text(48,234,'Signals become understanding.',32,C)+text(50,279,'Explore RF sensing, edge inference and local automation.',18) s+=''+text(72,359,'Explore the guide',20,INK)+path('M272 352H292M285 345L292 352L285 359',INK,2) @@ -91,5 +92,5 @@ s+=''+ s+=''+icon('seed',945,150,2.4) for j in range(3):s+=f'' save('cognitum',1200,304,'Explore Cognitum One','Ambient Intelligence at the edge of the physical world. Open cognitum.one.',s,'https://cognitum.one') -(OUT/'manifest.json').write_text(json.dumps({'schema_version':1,'style':'Cognitum cyan on midnight','motion':'Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies','assets':manifest},indent=2)+'\n') +(OUT/'manifest.json').write_bytes((json.dumps({'schema_version':1,'style':'Cognitum cyan on midnight','motion':'Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies','assets':manifest},indent=2)+'\n').encode('utf-8')) print(f'Generated {len(manifest)} SVGs, {sum((ROOT/x["file"]).stat().st_size for x in manifest):,} bytes') diff --git a/scripts/requirements-readme-visuals.txt b/scripts/requirements-readme-visuals.txt new file mode 100644 index 000000000..d17db13ae --- /dev/null +++ b/scripts/requirements-readme-visuals.txt @@ -0,0 +1,2 @@ +# Development-only dependency for README SVG generation and verification. +defusedxml==0.7.1 diff --git a/scripts/tests/test_readme_visuals_xml.py b/scripts/tests/test_readme_visuals_xml.py new file mode 100644 index 000000000..d9786fcda --- /dev/null +++ b/scripts/tests/test_readme_visuals_xml.py @@ -0,0 +1,152 @@ +"""Security regressions for the README visual tools' shared XML boundary.""" + +import io +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch + +from defusedxml.common import DTDForbidden + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import readme_visuals_xml as svg_xml + + +class ReadmeVisualXmlTests(unittest.TestCase): + def test_manifest_paths_reject_absolute_and_traversal_before_resolution(self): + for relative in [ + '', '/tmp/outside.svg', '//server/share/outside.svg', + 'C:/outside.svg', 'C:outside.svg', r'\\server\share\outside.svg', + r'\outside.svg', r'\\?\C:\outside.svg', 'file.svg:stream', + '../outside.svg', 'assets/readme/../../../outside.svg', + r'assets\readme\..\outside.svg', 'assets/readme/\x00.svg', + ]: + with self.subTest(relative=relative): + with patch.object(Path, 'resolve') as resolve: + with self.assertRaises(ValueError): + svg_xml.resolve_repo_file(Path('repository'), relative) + resolve.assert_not_called() + + def test_manifest_paths_keep_assets_and_targets_in_separate_boundaries(self): + with tempfile.TemporaryDirectory() as directory: + repository = Path(directory).resolve() + asset = repository / 'assets' / 'readme' / 'ordinary.svg' + asset.parent.mkdir(parents=True) + asset.write_bytes(b'') + target = repository / 'README.md' + target.write_bytes(b'# Readme\n') + self.assertEqual(svg_xml.resolve_repo_file(repository, 'assets/readme/ordinary.svg', asset=True), asset) + self.assertEqual(svg_xml.resolve_repo_file(repository, 'README.md'), target) + with patch.object(Path, 'is_file') as is_file: + with self.assertRaises(ValueError): + svg_xml.resolve_repo_file(repository, 'README.md', asset=True) + is_file.assert_not_called() + + def test_manifest_paths_reject_symlink_escape_before_file_inspection(self): + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + repository = base / 'repository' + assets = repository / 'assets' / 'readme' + assets.mkdir(parents=True) + outside = base / 'outside.svg' + outside.write_bytes(b'') + try: + (assets / 'escape.svg').symlink_to(outside) + (repository / 'escaped-directory').symlink_to(base, target_is_directory=True) + except (OSError, NotImplementedError) as error: + if sys.platform == 'win32' and (isinstance(error, NotImplementedError) or getattr(error, 'winerror', None) == 1314): + self.skipTest('Windows symlink privilege unavailable') + raise + for relative, asset in [('assets/readme/escape.svg', True), ('escaped-directory/outside.svg', False)]: + with self.subTest(relative=relative): + with patch.object(Path, 'is_file') as is_file: + with self.assertRaises(ValueError): + svg_xml.resolve_repo_file(repository, relative, asset=asset) + is_file.assert_not_called() + + def test_asset_directory_symlink_cannot_redefine_trusted_boundary(self): + with tempfile.TemporaryDirectory() as directory: + base = Path(directory) + repository = base / 'repository' + (repository / 'assets').mkdir(parents=True) + outside = base / 'outside' + outside.mkdir() + (outside / 'escape.svg').write_bytes(b'') + try: + (repository / 'assets' / 'readme').symlink_to(outside, target_is_directory=True) + except (OSError, NotImplementedError) as error: + if sys.platform == 'win32' and (isinstance(error, NotImplementedError) or getattr(error, 'winerror', None) == 1314): + self.skipTest('Windows symlink privilege unavailable') + raise + with patch.object(Path, 'is_file') as is_file: + with self.assertRaises(ValueError): + svg_xml.resolve_repo_file(repository, 'assets/readme/escape.svg', asset=True) + is_file.assert_not_called() + + def test_plain_utf8_svg_parses(self): + root = svg_xml.parse_svg('Présence'.encode('utf-8')) + self.assertEqual(root.tag, 'svg') + self.assertEqual(root.find('title').text, 'Présence') + + def test_dtd_and_entity_payloads_are_rejected(self): + for raw in [ + b'', + b']>&payload;', + b']>&payload;', + b'', + ]: + with self.subTest(raw=raw): + with self.assertRaises(DTDForbidden): + svg_xml.parse_svg(raw) + + def test_size_limit_is_enforced_before_parser_runs(self): + for size in [svg_xml.MAX_SVG_BYTES, svg_xml.MAX_SVG_BYTES + 1]: + with self.subTest(size=size): + with patch.object(svg_xml.ElementTree, 'fromstring') as parser: + with self.assertRaisesRegex(ValueError, 'smaller than 20000 bytes'): + svg_xml.parse_svg(b' ' * size) + parser.assert_not_called() + + def test_limit_counts_utf8_bytes_and_allows_last_valid_size(self): + valid = b'' + b' ' * (svg_xml.MAX_SVG_BYTES - 7) + self.assertEqual(len(valid), svg_xml.MAX_SVG_BYTES - 1) + self.assertEqual(svg_xml.parse_svg(valid).tag, 'svg') + text = '' + 'é' * 10000 + '' + self.assertLess(len(text), svg_xml.MAX_SVG_BYTES) + with self.assertRaises(ValueError): + svg_xml.parse_svg(text.encode('utf-8')) + + def test_reader_never_requests_unbounded_input(self): + class BoundedReader(io.BytesIO): + requested = None + + def read(self, size=-1): + self.requested = size + if size < 0 or size > svg_xml.MAX_SVG_BYTES: + raise AssertionError('unbounded read') + return super().read(size) + + stream = BoundedReader(b' ' * (svg_xml.MAX_SVG_BYTES * 2)) + with patch.object(Path, 'open', return_value=stream): + with patch.object(svg_xml.ElementTree, 'fromstring') as parser: + with self.assertRaises(ValueError): + svg_xml.read_svg(Path('oversized.svg')) + parser.assert_not_called() + self.assertEqual(stream.requested, svg_xml.MAX_SVG_BYTES) + + def test_file_reader_uses_same_hardened_parser(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'fixture.svg' + raw = b'ordinary' + path.write_bytes(raw) + read, root = svg_xml.read_svg(path) + self.assertEqual(read, raw) + self.assertEqual(root.find('title').text, 'ordinary') + path.write_bytes(b'') + with self.assertRaises(DTDForbidden): + svg_xml.read_svg(path) + + +if __name__ == '__main__': + unittest.main() diff --git a/scripts/verify-readme-visuals.py b/scripts/verify-readme-visuals.py index 7847fc5e4..7431903f8 100644 --- a/scripts/verify-readme-visuals.py +++ b/scripts/verify-readme-visuals.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 """Verify the generated SVGs and GitHub-compatible image link wrappers.""" from pathlib import Path -import json,re,xml.etree.ElementTree as ET +import json,re from urllib.parse import urlparse +from readme_visuals_xml import read_svg,resolve_repo_file ROOT=Path(__file__).resolve().parents[1];NS='{http://www.w3.org/2000/svg}' -manifest=json.loads((ROOT/'assets/readme/manifest.json').read_text());readme=(ROOT/'README.md').read_text();total=0 +manifest=json.loads((ROOT/'assets/readme/manifest.json').read_text(encoding='utf-8'));readme=(ROOT/'README.md').read_text(encoding='utf-8');total=0 for asset in manifest['assets']: - file=ROOT/asset['file'];raw=file.read_text();total+=file.stat().st_size;root=ET.fromstring(raw) + file=resolve_repo_file(ROOT,asset['file'],asset=True);data,root=read_svg(file);raw=data.decode('utf-8');total+=len(data) assert root.tag==NS+'svg' and root.get('viewBox'),asset['file'] assert root.find(NS+'title') is not None and root.find(NS+'desc') is not None ids=[e.get('id') for e in root.iter() if e.get('id')];assert len(ids)==len(set(ids)) @@ -19,11 +20,10 @@ for asset in manifest['assets']: target=asset['target'];parsed=urlparse(target) if parsed.scheme: assert parsed.scheme=='https' and parsed.hostname in ['cognitum.one','huggingface.co'] - else:assert (ROOT/target.split('#')[0]).is_file(),target + else:resolve_repo_file(ROOT,target.split('#')[0]) escaped=re.escape(asset['file']);destination=re.escape(target) linked=bool(re.search(r'\[!\[[^\]]*\]\('+escaped+r'\)\]\('+destination+r'\)',readme)) linked=linked or bool(re.search(r'\s* Date: Sun, 11 Oct 2026 14:05:06 +0200 Subject: [PATCH 08/23] feat(sensing): integrate decision and holdout diagnostics for Mac qualification --- .../wifi-densepose-sensing-server/README.md | 24 +- .../src/bootstrap_baseline.rs | 286 ++++++++++++++++++ .../src/field_bridge.rs | 61 +++- .../wifi-densepose-sensing-server/src/main.rs | 64 +++- 4 files changed, 427 insertions(+), 8 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/README.md b/v2/crates/wifi-densepose-sensing-server/README.md index 386dabca2..d7e3457d6 100644 --- a/v2/crates/wifi-densepose-sensing-server/README.md +++ b/v2/crates/wifi-densepose-sensing-server/README.md @@ -88,7 +88,29 @@ cargo run -p wifi-densepose-sensing-server -- \ Complete the normal empty-room calibration, then call `POST /api/v1/calibration/bootstrap/promote`. The server measures a separate 12-sample holdout -before storing an aggregate field-model snapshot. On a later start with the same installation ID, +before storing an aggregate field-model snapshot. Promotion requires all of the following: + +- Exactly 12 fresh samples, spaced at least one second apart, with no stale samples. +- At least 10 of the 12 samples classified as empty. +- Zero samples containing numeric vital-sign estimates. + +The 10-of-12 tolerance applies only to the empty classification. A holdout with 12 empty samples +and five samples containing vital-sign estimates still fails; a vital-sign estimate does not +prove occupancy, but it prevents storing the startup baseline under this conservative check. + +Completed holdout evaluations return the existing `validation` counters and additive +`validation_diagnostics.requirements` and `validation_diagnostics.failures` fields. A failed +check retains `success: false`, `error_code`, and `error`; the error describes the observed +counts and required limits. Each failure identifies its `check`, `observed` and `required` +counts, and `comparison`, so clients can explain every failed condition. For example, five +vital-sign samples produce a `vital_sign_sample_count` failure with `observed: 5`, +`required: 0`, and `comparison: "at_most"`. + +The bounded refinement endpoint, `POST /api/v1/calibration/bootstrap/refine`, also requires +12 fresh samples with zero stale or vital-sign samples. Its diagnostics omit the empty-sample +minimum because refinement does not apply that promotion-only check. + +On a later start with the same installation ID, the snapshot is restored with `bootstrap_only` authority. It can reduce startup background false positives, but cannot authorize calibrated evidence or numeric heart and breathing rates. Use `POST /api/v1/calibration/reset` with administrator scope to remove it. diff --git a/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs b/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs index b6ce978c8..326e2f5fa 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bootstrap_baseline.rs @@ -90,6 +90,131 @@ pub struct BootstrapValidationResult { pub passed: bool, } +/// Independent ADR-355 requirements. Refinement does not count empty votes. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct BootstrapValidationRequirements { + pub sample_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_empty_sample_count: Option, + pub max_stale_sample_count: usize, + pub max_vital_sign_sample_count: usize, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct BootstrapValidationFailure { + pub check: &'static str, + pub observed: usize, + pub required: usize, + pub comparison: &'static str, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct BootstrapValidationDiagnostics { + pub requirements: BootstrapValidationRequirements, + pub failures: Vec, +} + +impl BootstrapValidationResult { + pub fn diagnostics(&self) -> BootstrapValidationDiagnostics { + validation_diagnostics( + self.sample_count, + Some(self.empty_sample_count), + self.stale_sample_count, + self.vital_sign_sample_count, + ) + } +} + +impl BootstrapValidationDiagnostics { + /// Describe every failed check, without interpreting estimates as people. + pub fn failure_summary(&self) -> String { + self.failures + .iter() + .map(|failure| match failure.check { + "sample_count" => format!( + "{} samples collected (exactly {} required); check the CSI stream", + failure.observed, failure.required, + ), + "empty_sample_count" => format!( + "{} samples reported empty (at least {} required); vacate the room and check for moving objects", + failure.observed, failure.required, + ), + "stale_sample_count" => format!( + "{} stale samples ({} allowed); check the CSI stream", + failure.observed, failure.required, + ), + _ => format!( + "numeric vital-sign estimates in {}/{} samples ({} allowed); vacate the room and check for moving objects", + failure.observed, self.requirements.sample_count, failure.required, + ), + }) + .collect::>() + .join("; ") + } +} + +/// Explain existing gate counters without changing how samples are counted. +/// Promotion supplies all attempted samples; refinement supplies only fresh +/// residuals. Stale samples are a separate check, never added to sample_count. +pub fn validation_diagnostics( + sample_count: usize, + empty_sample_count: Option, + stale_sample_count: usize, + vital_sign_sample_count: usize, +) -> BootstrapValidationDiagnostics { + let requirements = BootstrapValidationRequirements { + sample_count: BOOTSTRAP_VALIDATION_SAMPLES, + min_empty_sample_count: empty_sample_count.map(|_| BOOTSTRAP_VALIDATION_MIN_EMPTY), + max_stale_sample_count: 0, + max_vital_sign_sample_count: 0, + }; + let mut failures = Vec::new(); + let checks = [ + ( + "sample_count", + sample_count, + requirements.sample_count, + "equal", + sample_count != requirements.sample_count, + ), + ( + "empty_sample_count", + empty_sample_count.unwrap_or(0), + BOOTSTRAP_VALIDATION_MIN_EMPTY, + "at_least", + empty_sample_count.is_some_and(|count| count < BOOTSTRAP_VALIDATION_MIN_EMPTY), + ), + ( + "stale_sample_count", + stale_sample_count, + requirements.max_stale_sample_count, + "at_most", + stale_sample_count != 0, + ), + ( + "vital_sign_sample_count", + vital_sign_sample_count, + requirements.max_vital_sign_sample_count, + "at_most", + vital_sign_sample_count != 0, + ), + ]; + for (check, observed, required, comparison, failed) in checks { + if failed { + failures.push(BootstrapValidationFailure { + check, + observed, + required, + comparison, + }); + } + } + BootstrapValidationDiagnostics { + requirements, + failures, + } +} + #[derive(Debug, Error)] pub enum BootstrapBaselineError { #[error("a stable installation_id is required for a local bootstrap baseline")] @@ -505,6 +630,167 @@ mod tests { assert!(!evaluate_validation(&vital).passed); } + fn empty_validation_samples(count: usize) -> Vec { + vec![ + BootstrapValidationSample { + fresh_tick: true, + calibrated_empty: true, + vital_signs_absent: true, + }; + count + ] + } + + #[test] + fn validation_diagnostics_keep_empty_tolerance_separate_from_vitals() { + let mut samples = empty_validation_samples(12); + samples[0].calibrated_empty = false; + samples[1].calibrated_empty = false; + let passing = evaluate_validation(&samples); + assert!(passing.passed); + assert_eq!(passing.empty_sample_count, 10); + assert!(passing.diagnostics().failures.is_empty()); + assert!(passing.diagnostics().failure_summary().is_empty()); + + samples[2].calibrated_empty = false; + let rejected = evaluate_validation(&samples); + assert!(!rejected.passed); + assert_eq!( + rejected.diagnostics().failures, + vec![BootstrapValidationFailure { + check: "empty_sample_count", + observed: 9, + required: 10, + comparison: "at_least", + }] + ); + } + + #[test] + fn validation_diagnostics_explain_numeric_vital_veto_even_when_all_empty() { + for count in [1, 5] { + let mut samples = empty_validation_samples(12); + for sample in samples.iter_mut().take(count) { + sample.vital_signs_absent = false; + } + let result = evaluate_validation(&samples); + assert!(!result.passed); + assert_eq!(result.empty_sample_count, 12); + let diagnostics = result.diagnostics(); + let json = serde_json::to_value(&diagnostics).unwrap(); + assert_eq!( + json["requirements"], + serde_json::json!({ + "sample_count": 12, + "min_empty_sample_count": 10, + "max_stale_sample_count": 0, + "max_vital_sign_sample_count": 0, + }) + ); + assert_eq!( + json["failures"], + serde_json::json!([{ + "check": "vital_sign_sample_count", + "observed": count, + "required": 0, + "comparison": "at_most", + }]) + ); + assert!(diagnostics.failure_summary().contains(&format!( + "numeric vital-sign estimates in {count}/12 samples (0 allowed)" + ))); + } + } + + #[test] + fn validation_diagnostics_reject_stale_incomplete_and_oversized_windows() { + for count in [0, 11, 13] { + let result = evaluate_validation(&empty_validation_samples(count)); + assert!(!result.passed); + let diagnostics = result.diagnostics(); + assert_eq!(diagnostics.failures[0].check, "sample_count"); + assert_eq!(diagnostics.failures[0].observed, count); + assert_eq!(diagnostics.failures[0].required, 12); + assert!(diagnostics + .failure_summary() + .contains("check the CSI stream")); + } + let mut samples = empty_validation_samples(12); + samples[0].fresh_tick = false; + let result = evaluate_validation(&samples); + assert!(!result.passed); + assert_eq!(result.sample_count, 12); + assert_eq!(result.empty_sample_count, 11); + assert_eq!( + result.diagnostics().failures, + vec![BootstrapValidationFailure { + check: "stale_sample_count", + observed: 1, + required: 0, + comparison: "at_most", + }] + ); + } + + #[test] + fn validation_diagnostics_report_all_simultaneous_failures() { + let mut samples = empty_validation_samples(11); + samples[0].fresh_tick = false; + samples[1].calibrated_empty = false; + samples[2].vital_signs_absent = false; + let result = evaluate_validation(&samples); + assert!(!result.passed); + let diagnostics = result.diagnostics(); + assert_eq!( + diagnostics + .failures + .iter() + .map(|failure| failure.check) + .collect::>(), + [ + "sample_count", + "empty_sample_count", + "stale_sample_count", + "vital_sign_sample_count", + ] + ); + let summary = diagnostics.failure_summary(); + for expected in [ + "11 samples collected (exactly 12 required)", + "9 samples reported empty (at least 10 required)", + "1 stale samples (0 allowed)", + "numeric vital-sign estimates in 1/12 samples (0 allowed)", + ] { + assert!(summary.contains(expected), "missing {expected}: {summary}"); + } + } + + #[test] + fn refinement_diagnostics_keep_fresh_count_and_omit_empty_vote_requirement() { + let diagnostics = validation_diagnostics(11, None, 1, 5); + let json = serde_json::to_value(&diagnostics).unwrap(); + assert!(json["requirements"].get("min_empty_sample_count").is_none()); + assert_eq!( + diagnostics + .failures + .iter() + .map(|failure| failure.check) + .collect::>(), + [ + "sample_count", + "stale_sample_count", + "vital_sign_sample_count", + ] + ); + assert_eq!(diagnostics.failures[0].observed, 11); + assert!(diagnostics + .failure_summary() + .contains("5/12 samples (0 allowed)")); + let passing = validation_diagnostics(12, None, 0, 0); + assert!(passing.failures.is_empty()); + assert!(passing.failure_summary().is_empty()); + } + #[test] fn restart_verifies_the_exact_stored_payload_bytes() { let dir = tempfile::tempdir().unwrap(); diff --git a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs index 2e3f5d5bb..6793cc26f 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/field_bridge.rs @@ -24,6 +24,9 @@ pub const CALIBRATION_MODEL_RECEIPT_SCHEMA: &str = pub const CALIBRATED_PRESENCE_EVIDENCE_SCHEMA: &str = "ruview.calibration.calibrated-presence-evidence.v2"; +/// Schema for bounded explanations, independent from calibration authority. +pub const DETECTOR_DIAGNOSTICS_SCHEMA: &str = "ruview.calibration.detector-diagnostics.v1"; + /// Length-only canonicalizer for calibration frames (issue #1170 pattern, /// shared with `multistatic_bridge`). Raw ESP32 amplitudes arrive at the /// hardware's native width (HT20 ≈ 64, HT40 ≈ 128/192); the FieldModel is @@ -90,7 +93,7 @@ fn mean_frame(frames: &[Vec]) -> Option> { Some(mean) } -fn perturbation_occupancy(field: &FieldModel, frames: &[Vec]) -> Option { +fn perturbation_occupancy_with_diagnostics(field: &FieldModel, frames: &[Vec]) -> Option<(usize, OccupancyDiagnostics)> { let adaptive_threshold = field.empty_room_residual_energy_threshold(); let frame = match adaptive_threshold { Some(_) => mean_frame(frames)?, @@ -122,7 +125,19 @@ fn perturbation_occupancy(field: &FieldModel, frames: &[Vec]) -> Option]) -> Option { + perturbation_occupancy_with_diagnostics(field, frames).map(|(count, _)| count) } /// Provenance for a count produced by the calibrated field model. Callers @@ -143,10 +158,21 @@ impl CalibratedOccupancyMethod { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Explanation of the very same scoring operation; it grants no authority. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)] +pub struct OccupancyDiagnostics { + pub residual_energy: Option, + pub decision_threshold: Option, + pub signed_margin: Option, + pub window_frames: usize, + pub fallback_reason: Option<&'static str>, +} + +#[derive(Debug, Clone, Copy, PartialEq)] pub struct CalibratedOccupancy { pub person_count: usize, pub method: CalibratedOccupancyMethod, + pub diagnostics: OccupancyDiagnostics, } /// Create a FieldModelConfig for single-link mode (one ESP32 node = one link). @@ -284,11 +310,15 @@ pub fn calibrated_occupancy( return Some(CalibratedOccupancy { person_count: person_count.min(MAX_SINGLE_LINK_OCCUPANCY), method: CalibratedOccupancyMethod::Eigenvalue, + diagnostics: OccupancyDiagnostics { + residual_energy: None, decision_threshold: None, signed_margin: None, + window_frames: frames.len(), fallback_reason: None, + }, }); } + let (person_count, diagnostics) = perturbation_occupancy_with_diagnostics(field, &frames)?; Some(CalibratedOccupancy { - person_count: perturbation_occupancy(field, &frames)?, - method: CalibratedOccupancyMethod::PerturbationEnergy, + person_count, method: CalibratedOccupancyMethod::PerturbationEnergy, diagnostics, }) } @@ -799,6 +829,27 @@ mod tests { ); } + #[test] + fn diagnostic_margin_explains_the_same_perturbation_decision() { + let field = fresh_test_model(); + let baseline = field.modes().unwrap().baseline[0].clone(); + for offset in [0.0, 0.3, 5.0, 20.0] { + let history: VecDeque> = (0..50) + .map(|_| baseline.iter().map(|value| value + offset).collect()).collect(); + let result = calibrated_occupancy(&field, &history, 1_500_000).unwrap(); + assert_eq!(result.person_count, occupancy_or_fallback(&field, &history, 1_500_000, 0.0, 0)); + if result.method == CalibratedOccupancyMethod::PerturbationEnergy { + let energy = result.diagnostics.residual_energy.unwrap(); + let threshold = result.diagnostics.decision_threshold.unwrap(); + assert_eq!(result.diagnostics.signed_margin, Some(energy - threshold)); + assert_eq!(result.person_count > 0, energy > threshold); + assert_eq!(result.diagnostics.window_frames, 50); + } else { + assert_eq!(result.diagnostics.signed_margin, None); + } + } + } + #[test] fn occupancy_falls_back_after_expiry_without_a_process_restart() { let field = fresh_test_model(); diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 63ea44184..d35f8830f 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -474,6 +474,19 @@ struct CalibratedPresenceEvidence { inference_method: String, presence: bool, person_count: usize, + #[serde(skip)] + diagnostics: Option, +} + +/// Bounded decision explanation, separate from the strict authority envelope. +#[derive(Debug, Clone, Serialize)] +struct DetectorDiagnostics { + schema: &'static str, + scored_node_id: u8, + source_age_ms: u64, + source_sequence: u32, + required_window_frames: usize, + runtime: field_bridge::OccupancyDiagnostics, } /// Sensing update broadcast to WebSocket clients @@ -495,6 +508,8 @@ struct SensingUpdate { /// active model receipt. Omitted when no calibrated result is available. #[serde(default, skip_serializing_if = "Option::is_none")] calibrated_presence_evidence: Option, + #[serde(skip_deserializing, skip_serializing_if = "Option::is_none")] + detector_diagnostics: Option, // ── ADR-022 Phase 3: Enhanced multi-BSSID pipeline fields ── /// Enhanced motion estimate from multi-BSSID pipeline. #[serde(skip_serializing_if = "Option::is_none")] @@ -2793,6 +2808,17 @@ impl AppStateInner { inference_method: occupancy.method.wire_name().to_string(), presence: occupancy.person_count > 0, person_count: occupancy.person_count, + diagnostics: self.calibration_grid_binding.and_then(|binding| { + let node = self.node_states.get(&binding.source_node_id)?; + Some(DetectorDiagnostics { + schema: field_bridge::DETECTOR_DIAGNOSTICS_SCHEMA, + scored_node_id: binding.source_node_id, + source_age_ms: node.field_model_latest_seen?.elapsed().as_millis().min(u64::MAX as u128) as u64, + source_sequence: node.field_model_latest_sequence?, + required_window_frames: self.field_model.as_ref()?.modes()?.baseline_runtime_window_size.unwrap_or(50), + runtime: occupancy.diagnostics, + }) + }), }) } @@ -3235,6 +3261,21 @@ mod calibration_expiry_tests { state } + #[test] + fn diagnostics_stay_outside_the_strict_calibration_authority_envelope() { + let state = state_with_runtime_window(); + let evidence = state.calibrated_presence_evidence(5, 77, 1_500).unwrap(); + let diagnostic = evidence.diagnostics.as_ref().expect("bound source diagnostics"); + assert_eq!(diagnostic.scored_node_id, 5); + assert_eq!(diagnostic.runtime.window_frames, 50); + let encoded = serde_json::to_value(&evidence).unwrap(); + assert!(encoded.get("diagnostics").is_none()); + assert_eq!(encoded.as_object().unwrap().len(), 13); + assert_eq!(encoded["person_count"], 0); + assert_eq!(serde_json::to_value(diagnostic).unwrap()["schema"], + field_bridge::DETECTOR_DIAGNOSTICS_SCHEMA); + } + #[test] fn calibrated_empty_blocks_vitals_even_when_room_heuristic_reports_one() { let state = state_with_runtime_window(); @@ -5058,6 +5099,7 @@ async fn wifi_task(state: SharedState, tick_ms: u64) { ), vital_signs: None, calibrated_presence_evidence: None, + detector_diagnostics: None, enhanced_motion, enhanced_breathing, posture: posture_str, @@ -5227,6 +5269,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) { ), vital_signs: None, calibrated_presence_evidence: None, + detector_diagnostics: None, enhanced_motion: None, enhanced_breathing: None, posture: None, @@ -6117,6 +6160,7 @@ mod latest_update_staleness_tests { classifier: None, activity: None, calibrated_presence_evidence: None, + detector_diagnostics: None, enhanced_motion: None, enhanced_breathing: None, posture: None, @@ -10036,12 +10080,14 @@ async fn calibration_promote_bootstrap( } let validation = bootstrap_baseline::evaluate_validation(&samples); + let validation_diagnostics = validation.diagnostics(); if !validation.passed { return Json(serde_json::json!({ "success": false, "error_code": "bootstrap_validation_failed", - "error": "The held out empty room check did not pass. The model was not stored.", + "error": format!("The held out empty room check did not pass: {}. The model was not stored.", validation_diagnostics.failure_summary()), "validation": validation, + "validation_diagnostics": validation_diagnostics, })); } @@ -10107,6 +10153,7 @@ async fn calibration_promote_bootstrap( "success": true, "message": "Validated startup baseline stored locally.", "validation": validation, + "validation_diagnostics": validation_diagnostics, "holdout_window_size": holdout_window_size, "bootstrap_baseline": stored, "calibrated_evidence_authorized": false, @@ -10236,6 +10283,12 @@ async fn calibration_refine_bootstrap( + Duration::from_millis(BOOTSTRAP_VALIDATION_MIN_SPACING_MS); } + let validation_diagnostics = bootstrap_baseline::validation_diagnostics( + residuals.len(), + None, + stale_sample_count, + vital_sign_sample_count, + ); if residuals.len() != BOOTSTRAP_VALIDATION_SAMPLES || stale_sample_count != 0 || vital_sign_sample_count != 0 @@ -10243,12 +10296,13 @@ async fn calibration_refine_bootstrap( return Json(serde_json::json!({ "success": false, "error_code": "bootstrap_refinement_validation_failed", - "error": "The fresh empty-room refinement gate did not pass. The stored baseline was not changed.", + "error": format!("The fresh empty-room refinement gate did not pass: {}. The stored baseline was not changed.", validation_diagnostics.failure_summary()), "validation": { "sample_count": residuals.len(), "stale_sample_count": stale_sample_count, "vital_sign_sample_count": vital_sign_sample_count, }, + "validation_diagnostics": validation_diagnostics, })); } @@ -10335,6 +10389,7 @@ async fn calibration_refine_bootstrap( Json(serde_json::json!({ "success": true, "message": "The local empty-room startup baseline was refined.", + "validation_diagnostics": validation_diagnostics, "validation": { "sample_count": residuals.len(), "stale_sample_count": stale_sample_count, @@ -11709,6 +11764,7 @@ fn mediatek_room_update( }, vital_signs: None, calibrated_presence_evidence: None, + detector_diagnostics: None, enhanced_motion: None, enhanced_breathing: None, posture: None, @@ -12629,6 +12685,7 @@ async fn udp_receiver_task( classification, signal_field, vital_signs: published_vitals, + detector_diagnostics: calibrated_presence_evidence.as_ref().and_then(|e| e.diagnostics.clone()), calibrated_presence_evidence, enhanced_motion: None, enhanced_breathing: None, @@ -13124,6 +13181,7 @@ async fn udp_receiver_task( &sub_variances, ), vital_signs: published_vitals, + detector_diagnostics: calibrated_presence_evidence.as_ref().and_then(|e| e.diagnostics.clone()), calibrated_presence_evidence, enhanced_motion: None, enhanced_breathing: None, @@ -13364,6 +13422,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) { ), vital_signs: None, calibrated_presence_evidence: None, + detector_diagnostics: None, enhanced_motion: None, enhanced_breathing: None, posture: None, @@ -16068,6 +16127,7 @@ mod observatory_persons_field_position_tests { signal_field, vital_signs: None, calibrated_presence_evidence: None, + detector_diagnostics: None, enhanced_motion: None, enhanced_breathing: None, posture: None, From 18d98d9452875b12f8b22c7b696b1a5cdf40c86c Mon Sep 17 00:00:00 2001 From: mallesh-bot Date: Wed, 2 Sep 2026 13:37:25 +0530 Subject: [PATCH 09/23] fix(cli): abort incomplete calibration sessions --- .../wifi-densepose-cli/src/calibrate_api.rs | 47 ++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs index 8be6fb034..49ad1dcc4 100644 --- a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs +++ b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs @@ -536,7 +536,7 @@ async fn ingest_loop( if let Some(sess) = active.as_ref() { if Instant::now() >= sess.deadline { let frames = sess.recorder.frames_recorded() as usize; - if frames >= 10 { + if frames >= sess.target_frames { if let Some(done) = active.take() { let _ = finalize(done, &output_dir, &status).await; } @@ -544,8 +544,9 @@ async fn ingest_loop( // not enough frames — abort honestly rather than emit a bad baseline done.motion_flagged = false; let note = format!( - "aborted: only {frames} frames in the time window (need >=10) — \ + "aborted: only {frames} frames in the time window (need >= {}) — \ is the ESP32 streaming to udp:{}? ", + done.target_frames, status.read().await.udp_port ); let snap = session_snapshot(&done, "aborted", Some(note.clone())); @@ -615,10 +616,17 @@ async fn finalize( status.write().await.session = Some(snap); } - let baseline: BaselineCalibration = sess - .recorder - .finalize() - .map_err(|e| format!("finalize failed: {e}"))?; + let baseline: BaselineCalibration = match sess.recorder.finalize() { + Ok(baseline) => baseline, + Err(e) => { + let message = format!("finalize failed: {e}"); + let note = format!("aborted: {message}"); + let snap = session_snapshot(&sess, "aborted", Some(note.clone())); + status.write().await.session = Some(snap); + eprintln!("[calibrate-serve] {note}"); + return Err(message); + } + }; let (amp_mean_avg, amp_var_avg, disp_avg) = baseline_averages(&baseline); let uuid = baseline.calibration_uuid().to_string(); @@ -1055,6 +1063,33 @@ mod tests { assert_eq!(a.udp_port, 5005); } + #[tokio::test] + async fn finalize_insufficient_frames_marks_session_aborted() { + let dir = tempfile::tempdir().unwrap(); + let status = Arc::new(RwLock::new(SharedStatus::default())); + let sess = ActiveSession { + recorder: CalibrationRecorder::new(tier_config("ht20")), + room_id: "test".into(), + tier: "ht20".into(), + started: Instant::now(), + deadline: Instant::now(), + target_frames: 1, + z_median: 0.0, + z_max: 0.0, + motion_flagged: false, + }; + + let err = finalize(sess, dir.path().to_str().unwrap(), &status) + .await + .expect_err("an empty recorder cannot finalize"); + assert!(err.contains("finalize failed")); + + let guard = status.read().await; + let session = guard.session.as_ref().expect("terminal session status"); + assert_eq!(session.state, "aborted"); + assert!(session.note.as_deref().unwrap_or_default().contains("finalize failed")); + } + #[test] fn sanitize_blocks_path_traversal() { assert_eq!(sanitize_room_id("../../etc/passwd"), "etcpasswd"); From 99cea559f9a032dea6780779248a3f548a961146 Mon Sep 17 00:00:00 2001 From: mallesh-bot Date: Wed, 2 Sep 2026 14:59:30 +0530 Subject: [PATCH 10/23] fix(cli): retain abort status after finalize failure --- v2/crates/wifi-densepose-cli/src/calibrate_api.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs index 49ad1dcc4..dc4378e6d 100644 --- a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs +++ b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs @@ -616,13 +616,16 @@ async fn finalize( status.write().await.session = Some(snap); } + // `CalibrationRecorder::finalize` consumes the recorder, so retain a + // terminal snapshot before attempting the fallible operation. + let mut aborted_snapshot = session_snapshot(&sess, "aborted", None); let baseline: BaselineCalibration = match sess.recorder.finalize() { Ok(baseline) => baseline, Err(e) => { let message = format!("finalize failed: {e}"); let note = format!("aborted: {message}"); - let snap = session_snapshot(&sess, "aborted", Some(note.clone())); - status.write().await.session = Some(snap); + aborted_snapshot.note = Some(note.clone()); + status.write().await.session = Some(aborted_snapshot); eprintln!("[calibrate-serve] {note}"); return Err(message); } From 9d863c3d22c22ce5caa603d040c9e60482b424e1 Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 12 Oct 2026 11:43:04 -0400 Subject: [PATCH 11/23] fix(cli): report calibration persistence failures as aborted --- .../wifi-densepose-cli/src/calibrate_api.rs | 75 ++++++++++++++++++- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs index dc4378e6d..27a936a05 100644 --- a/v2/crates/wifi-densepose-cli/src/calibrate_api.rs +++ b/v2/crates/wifi-densepose-cli/src/calibrate_api.rs @@ -636,9 +636,14 @@ async fn finalize( let path = format!("{output_dir}/{room_id}-{uuid}.bin"); let bytes = baseline.to_bytes(); // Async write — never block the ingest task's UDP/command path. - tokio::fs::write(&path, &bytes) - .await - .map_err(|e| format!("cannot write {path}: {e}"))?; + if let Err(error) = tokio::fs::write(&path, &bytes).await { + let message = format!("cannot write {path}: {error}"); + let note = format!("aborted: {message}"); + aborted_snapshot.note = Some(note.clone()); + status.write().await.session = Some(aborted_snapshot); + eprintln!("[calibrate-serve] {note}"); + return Err(message); + } let summary = ResultSummary { calibration_id: uuid, @@ -1093,6 +1098,70 @@ mod tests { assert!(session.note.as_deref().unwrap_or_default().contains("finalize failed")); } + fn complete_test_session() -> ActiveSession { + let mut config = tier_config("ht20"); + config.min_frames = 2; + let mut recorder = CalibrationRecorder::new(config); + let mut packet = vec![0u8; 20 + 64 * 2]; + packet[..4].copy_from_slice(&0xC511_0001u32.to_le_bytes()); + packet[4] = 1; + packet[5] = 1; + packet[6..8].copy_from_slice(&64u16.to_le_bytes()); + packet[8..12].copy_from_slice(&2437u32.to_le_bytes()); + packet[20..].fill(3); + let frame = parse_csi_packet(&packet, "ht20").unwrap(); + recorder.record(&frame).unwrap(); + recorder.record(&frame).unwrap(); + ActiveSession { + recorder, + room_id: "test".into(), + tier: "ht20".into(), + started: Instant::now(), + deadline: Instant::now(), + target_frames: 2, + z_median: 0.0, + z_max: 0.0, + motion_flagged: false, + } + } + + #[tokio::test] + async fn finalize_write_failure_marks_session_aborted_without_result() { + let dir = tempfile::tempdir().unwrap(); + let blocked = dir.path().join("not-a-directory"); + std::fs::write(&blocked, b"owned test fixture").unwrap(); + let status = Arc::new(RwLock::new(SharedStatus::default())); + let error = finalize(complete_test_session(), blocked.to_str().unwrap(), &status) + .await + .expect_err("a regular file cannot hold the baseline"); + assert!(error.starts_with("cannot write ")); + let guard = status.read().await; + let session = guard.session.as_ref().unwrap(); + assert_eq!(session.state, "aborted"); + assert!(session.note.as_deref().unwrap().contains("cannot write ")); + assert!(guard.last_result.is_none()); + assert_eq!(std::fs::read(blocked).unwrap(), b"owned test fixture"); + } + + #[tokio::test] + async fn finalize_success_publishes_a_readable_baseline_and_complete_status() { + let dir = tempfile::tempdir().unwrap(); + let status = Arc::new(RwLock::new(SharedStatus::default())); + let summary = finalize(complete_test_session(), dir.path().to_str().unwrap(), &status) + .await + .unwrap(); + let bytes = std::fs::read(&summary.output_path).unwrap(); + let baseline = BaselineCalibration::from_bytes(&bytes).unwrap(); + assert_eq!(baseline.frame_count, 2); + assert_eq!(summary.saved_bytes, bytes.len()); + let guard = status.read().await; + let session = guard.session.as_ref().unwrap(); + assert_eq!(session.state, "complete"); + assert_eq!(session.progress, 1.0); + assert!(session.note.is_none()); + assert_eq!(guard.last_result.as_ref().unwrap().calibration_id, summary.calibration_id); + } + #[test] fn sanitize_blocks_path_traversal() { assert_eq!(sanitize_room_id("../../etc/passwd"), "etcpasswd"); From a937a5fd20a6b1010738d4dd21e2e2c26405153d Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 12 Oct 2026 11:48:38 -0400 Subject: [PATCH 12/23] fix(firmware): correct device identity and provision OTA keys for 0.8.14 --- .github/workflows/firmware-ci.yml | 24 ++- docs/user-guide.md | 35 +++- firmware/esp32-csi-node/README.md | 96 ++++++++-- .../esp32-csi-node/main/onboarding_identity.h | 39 ++++ firmware/esp32-csi-node/main/ota_update.c | 6 +- .../esp32-csi-node/main/serial_onboarding.c | 29 +-- firmware/esp32-csi-node/provision.py | 94 ++++++++- .../esp32-csi-node/release_bins/README.md | 16 ++ .../release_bins/c6-onboarding/version.txt | 1 + firmware/esp32-csi-node/test/Makefile | 12 +- .../test/identity_stubs/esp_mac.h | 8 + .../test/identity_stubs/psa/crypto.h | 13 ++ .../test/test_delivery_contract.py | 57 ++++-- .../test/test_onboarding_identity.c | 74 +++++++ .../tests/test_provision_ota_psk.py | 181 ++++++++++++++++++ firmware/esp32-csi-node/version.txt | 2 +- 16 files changed, 618 insertions(+), 69 deletions(-) create mode 100644 firmware/esp32-csi-node/main/onboarding_identity.h create mode 100644 firmware/esp32-csi-node/release_bins/README.md create mode 100644 firmware/esp32-csi-node/release_bins/c6-onboarding/version.txt create mode 100644 firmware/esp32-csi-node/test/identity_stubs/esp_mac.h create mode 100644 firmware/esp32-csi-node/test/identity_stubs/psa/crypto.h create mode 100644 firmware/esp32-csi-node/test/test_onboarding_identity.c create mode 100644 firmware/esp32-csi-node/tests/test_provision_ota_psk.py diff --git a/.github/workflows/firmware-ci.yml b/.github/workflows/firmware-ci.yml index dae86129d..91e5ccf14 100644 --- a/.github/workflows/firmware-ci.yml +++ b/.github/workflows/firmware-ci.yml @@ -16,6 +16,17 @@ on: - '.github/workflows/firmware-ci.yml' jobs: + provisioning-tests: + name: Provisioning tooling tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.11' + - name: Verify provisioning and secret handling without hardware + run: python -m unittest discover -s firmware/esp32-csi-node/tests -p 'test_provision*.py' -v + version-guard: name: Verify version.txt matches release tag runs-on: ubuntu-latest @@ -119,6 +130,10 @@ jobs: fi echo "Binary size OK: $SIZE <= $MAX" + - name: Verify built application version before packaging + working-directory: firmware/esp32-csi-node + run: python test/test_delivery_contract.py --built-app build/esp32-csi-node.bin + - name: Verify flash image integrity working-directory: firmware/esp32-csi-node run: | @@ -208,7 +223,7 @@ jobs: publish-release-assets: name: Publish firmware release assets if: github.ref_type == 'tag' - needs: [version-guard, build, build-c6-idf6] + needs: [version-guard, provisioning-tests, build, build-c6-idf6] runs-on: ubuntu-latest permissions: contents: write @@ -232,7 +247,12 @@ jobs: uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 with: name: ESP32 firmware ${{ github.ref_name }} + prerelease: true + make_latest: false body: | - Exact-run ESP32-S3 8 MB, ESP32-S3 4 MB, and ESP32-C6 4 MB firmware bundles. + Exact-run ESP32-S3 display 8 MB, ESP32-S3 headless 4 MB, and ESP32-C6 4 MB firmware bundles. Verify each archive against `SHA256SUMS.txt` before flashing. + CI builds and host tests verify software behavior, not target hardware. + This prerelease has no new physical-board qualification or sensing-accuracy claim. + Select the matching board configuration using firmware/esp32-csi-node/README.md. files: release-assets/* diff --git a/docs/user-guide.md b/docs/user-guide.md index 69324034e..f17429698 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -1911,31 +1911,51 @@ A 3-6 node ESP32-S3 mesh provides full CSI at 20 Hz. Total cost: ~$54 for a 3-no **Flashing firmware:** -Pre-built binaries are available at [Releases](https://github.com/ruvnet/RuView/releases): +Use versioned bundles from [Releases](https://github.com/ruvnet/RuView/releases), +not the historical binaries in `firmware/esp32-csi-node/release_bins/`. +The 0.8.14 prerelease fixes USB device identity and adds OTA key provisioning. +It has CI build/host-test coverage; new physical-board qualification remains +pending. The previous stable firmware is 0.8.8. | Release | What It Includes | Tag | |---------|-----------------|-----| -| [v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32) | **Latest — ADR-110 firmware-side substrate closed.** Adds ESP-NOW mesh substrate with quantified ≤100 µs alignment (104.1 µs smoothed stdev, 3.95× suppression, 99.56 % cross-board match measured live), 32-byte sync-packet UDP emission with operator-tunable cadence, ADR-018 byte 19 bit 4 wire-fix sourced from working ESP-NOW path, Python SyncPacketParser stub for host wiring ([WITNESS-LOG-110 §A0.7-§A0.13](WITNESS-LOG-110.md)) | `v0.7.0-esp32` | +| [v0.8.14](https://github.com/ruvnet/RuView/releases/tag/v0.8.14-esp32) | **Prerelease:** corrected C6 base-MAC identity, OTA key provisioning without a host key cache; physical qualification pending | `v0.8.14-esp32` | +| [v0.8.8](https://github.com/ruvnet/RuView/releases/tag/v0.8.8-esp32) | Previous stable firmware: consistent occupancy and rate-aware C6 processing; see [release evidence](releases/v0.8.8-esp32.md) | `v0.8.8-esp32` | +| [v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32) | Historical ADR-110 firmware-side substrate: ESP-NOW synchronization and sync-packet UDP emission ([WITNESS-LOG-110 §A0.7-§A0.13](WITNESS-LOG-110.md)) | `v0.7.0-esp32` | | [v0.6.9](https://github.com/ruvnet/RuView/releases/tag/v0.6.9-esp32) | Sync-packet UDP emission, `CONFIG_C6_SYNC_EVERY_N_FRAMES` tunable cadence | `v0.6.9-esp32` | | [v0.6.8](https://github.com/ruvnet/RuView/releases/tag/v0.6.8-esp32) | ESP-NOW EMA-smoothed cross-board offset (3.95× suppression, 104 µs stdev) | `v0.6.8-esp32` | | [v0.6.7](https://github.com/ruvnet/RuView/releases/tag/v0.6.7-esp32) | Real LP-core motion-gate RISC-V program (B4 code path complete) + Wi-Fi 6 soft-AP with TWT Responder for two-board iTWT benches (B1/B2 unblock) | `v0.6.7-esp32` | -| [v0.5.0](https://github.com/ruvnet/RuView/releases/tag/v0.5.0-esp32) | **Stable (S3 mesh, recommended)** — mmWave sensor fusion (MR60BHA2/LD2410 auto-detect), 48-byte fused vitals, all v0.4.3.1 fixes | `v0.5.0-esp32` | +| [v0.5.0](https://github.com/ruvnet/RuView/releases/tag/v0.5.0-esp32) | Historical S3 mesh release — mmWave sensor fusion (MR60BHA2/LD2410 auto-detect), 48-byte fused vitals, all v0.4.3.1 fixes | `v0.5.0-esp32` | | [v0.4.3.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.3.1-esp32) | Fall detection fix ([#263](https://github.com/ruvnet/RuView/issues/263)), 4MB flash ([#265](https://github.com/ruvnet/RuView/issues/265)), watchdog fix ([#266](https://github.com/ruvnet/RuView/issues/266)) | `v0.4.3.1-esp32` | | [v0.4.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.1-esp32) | CSI build fix, compile guard, AMOLED display, edge intelligence ([ADR-057](../docs/adr/ADR-057-firmware-csi-build-guard.md)) | `v0.4.1-esp32` | | [v0.3.0-alpha](https://github.com/ruvnet/RuView/releases/tag/v0.3.0-alpha-esp32) | Alpha — adds on-device edge intelligence (ADR-039) | `v0.3.0-alpha-esp32` | | [v0.2.0](https://github.com/ruvnet/RuView/releases/tag/v0.2.0-esp32) | Raw CSI streaming, TDM, channel hopping, QUIC mesh | `v0.2.0-esp32` | -> **Important:** Always use **v0.4.3.1 or later**. Earlier versions have false fall detection alerts (v0.4.2 and below) and CSI disabled in the build config (pre-v0.4.1). +For 0.8.14, select the correct archive, verify its release `SHA256SUMS.txt`, +extract into a separate directory, then verify the enclosed checksums: + +| Archive | Board configuration | +|---------|---------------------| +| `esp32-csi-node-firmware-8mb.tar.gz` | ESP32-S3 display build, 8 MB flash | +| `esp32-csi-node-firmware-4mb.tar.gz` | ESP32-S3 headless build, 4 MB layout | +| `esp32-csi-node-firmware-c6-4mb.tar.gz` | ESP32-C6 research target, 4 MB layout | + +Bare 8 MB DevKitC boards need the source `sdkconfig.defaults.devkitc` overlay; +do not use the display build. See the [firmware guide](../firmware/esp32-csi-node/README.md#0-download-a-versioned-firmware-bundle) +for board selection, C6 filenames, backup and OTA guidance. ```bash -# Flash an ESP32-S3 with 8MB flash (most boards) +# From the extracted ESP32-S3 display / 8 MB bundle: python -m esptool --chip esp32s3 --port COM7 --baud 460800 \ write-flash --flash-mode dio --flash-size 8MB --flash-freq 80m \ 0x0 bootloader.bin 0x8000 partition-table.bin \ 0xf000 ota_data_initial.bin 0x20000 esp32-csi-node.bin ``` -**4MB flash boards** (e.g. ESP32-S3 SuperMini 4MB): download `esp32-csi-node-s3-4mb.bin` + `partition-table-s3-4mb.bin` from the [v0.6.7 release](https://github.com/ruvnet/RuView/releases/tag/v0.6.7-esp32) (882 KB binary, 52 % partition slack) and use `--flash-size 4MB`: +**4 MB headless S3 boards:** use the matching application and partition table +from `esp32-csi-node-firmware-4mb.tar.gz` on the +[0.8.14 prerelease](https://github.com/ruvnet/RuView/releases/tag/v0.8.14-esp32) +and select `--flash-size 4MB`: ```bash python -m esptool --chip esp32s3 --port COM7 --baud 460800 \ @@ -1952,6 +1972,9 @@ python firmware/esp32-csi-node/provision.py --port COM7 \ ``` Replace `192.168.1.20` with the IP of the machine running the sensing server. +To enable authenticated OTA updates, add `--ota-psk-file` with a private +64-hex key file. The key must be resupplied on later NVS writes; it is never +cached in host state. See [OTA key provisioning](../firmware/esp32-csi-node/README.md#ota-key-provisioning). **Mesh key provisioning (secure mode):** diff --git a/firmware/esp32-csi-node/README.md b/firmware/esp32-csi-node/README.md index 52a19d9a1..f9e800fa0 100644 --- a/firmware/esp32-csi-node/README.md +++ b/firmware/esp32-csi-node/README.md @@ -19,7 +19,23 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (pr > | **Fall detection** | Phase acceleration threshold | Configurable sensitivity | > | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash | -## Firmware 0.8.12: guided Mac onboarding +## Firmware 0.8.14 prerelease: device identity and OTA provisioning + +Version 0.8.14 fixes the six-byte base-MAC input used for USB onboarding on +ESP32-C6, where the previous API wrote an eight-byte EUI-64 into that buffer. +The PSA SHA-256 algorithm and domain remain unchanged. Affected boards now +report a corrected `device_digest`; rediscover and reselect the physical board +after updating rather than accepting its old saved digest. + +The provisioner can also write the OTA authentication key to `security/ota_psk` +using `--ota-psk-file`. The key is never cached in host state. See +[OTA key provisioning](#ota-key-provisioning) before reprovisioning a node. + +This prerelease is built from the main source line. CI builds and host tests +do not establish physical-board qualification or sensing accuracy. The older +0.8.13 prerelease's unmerged watchdog experiments are not included. + +### Guided Mac onboarding (introduced in 0.8.12) Firmware 0.8.12 adds the protocol used by the RuView macOS Add Sensor workflow. A directly attached S3 or C6 can identify itself through a @@ -67,22 +83,34 @@ measured evidence and limitations. For users who want to get running fast. Detailed explanations follow in later sections. -### 0. Download the 0.8.8 release +### 0. Download a versioned firmware bundle Use the versioned source tag and binaries on the -[v0.8.8 ESP32 release page](https://github.com/ruvnet/RuView/releases/tag/v0.8.8-esp32). +[v0.8.14 ESP32 prerelease page](https://github.com/ruvnet/RuView/releases/tag/v0.8.14-esp32). +For the previous stable firmware and its hardware evidence, see +[v0.8.8](https://github.com/ruvnet/RuView/releases/tag/v0.8.8-esp32). Choose the package that names both your chip and flash size: | Package | Use it for | |---------|------------| -| `esp32-csi-node-v0.8.8-s3-8mb-flash-bundle.zip` | Fresh ESP32-S3 installation with 8 MB flash | -| `esp32-csi-node-v0.8.8-s3-4mb-flash-bundle.zip` | Fresh ESP32-S3 installation with 4 MB flash | -| `esp32-csi-node-v0.8.8-c6-4mb-flash-bundle.zip` | Fresh ESP32-C6 installation using the supported 4 MB layout | +| `esp32-csi-node-firmware-8mb.tar.gz` | ESP32-S3 display configuration, 8 MB flash | +| `esp32-csi-node-firmware-4mb.tar.gz` | ESP32-S3 headless configuration, 4 MB layout | +| `esp32-csi-node-firmware-c6-4mb.tar.gz` | ESP32-C6 research target, 4 MB layout | Each bundle contains the matching bootloader, partition table, OTA metadata, -application, checksums, and a short flashing guide. Never flash an S3 bundle +application, version and checksums. Verify the archive using the release's +`SHA256SUMS.txt`, extract it into its own directory, then verify the enclosed +`SHA256SUMS.txt`. Never flash an S3 bundle onto a C6, or a C6 bundle onto an S3. +The 4 MB application/partition filenames are `esp32-csi-node-4mb.bin` and +`partition-table-4mb.bin`; C6 uses `esp32-csi-node-c6.bin` and +`partition-table-c6.bin`. Do not mix files from different archives. +Bare 8 MB DevKitC boards need a source build with `sdkconfig.defaults.devkitc` +as documented below; the 8 MB release bundle enables display support. +The tracked `release_bins/` files are historical snapshots and must not be +used as current firmware downloads (see issues #1899 and #2092). + Example for an 8 MB ESP32-S3 after extracting its bundle: ```bash @@ -181,9 +209,14 @@ password when run from a terminal. `--password` still works, but it shows up in ### 4. Start the sensing server ```bash -cargo run -p wifi-densepose-sensing-server -- --http-port 3000 --source auto +cd v2 +cargo run -p wifi-densepose-sensing-server -- --http-port 3000 --source auto \ + --udp-bind 0.0.0.0 --udp-allow 192.168.1.0/24 ``` +Use your nodes' subnet for `--udp-allow`. The default UDP listener is loopback +only, so LAN nodes need both the bind address and an explicit source allowlist. + ### 5. Open the UI Navigate to [http://localhost:3000](http://localhost:3000) in your browser. @@ -274,8 +307,8 @@ Adds real-time health and safety monitoring. drives the on-screen skeleton from signal-based heuristics (amplitude variance, motion-band power), not from learned keypoint inference. The repository does not ship pre-trained weights — see issues - [#509](../../issues/509) and [#506](../../issues/506) for context, and - [ADR-079](../../docs/adr/ADR-079-camera-supervised-pose-finetune.md) for + [#509](https://github.com/ruvnet/RuView/issues/509) and [#506](https://github.com/ruvnet/RuView/issues/506) for context, and + [ADR-079](../../docs/adr/ADR-079-camera-ground-truth-training.md) for the planned training path (phases P7-P9 are `Pending`). - The presence indicator is a calibrated variance threshold and **will false-positive** under strong RF interference from non-human sources @@ -462,6 +495,43 @@ dropped. Without a password flag the script prompts on a terminal; in scripts and CI it doesn't prompt and fails if no password was given now or saved earlier for that port. +### OTA key provisioning + +Create a random 256-bit key in a private file outside the repository. For +example, on POSIX: + +```bash +(umask 077; set -C; openssl rand -hex 32 > ~/.ruview-ota-psk) +python firmware/esp32-csi-node/provision.py --port COM7 \ + --ssid "MyWiFi" --password-file ~/.wifi-pass --target-ip 192.168.1.20 \ + --ota-psk-file ~/.ruview-ota-psk +``` + +The file must contain exactly 64 hexadecimal characters and optionally one +trailing newline. The tool rejects symlinks and non-regular files, bounds the +read, and checks owner-only POSIX permissions. On Windows, protect the file +with your user ACL; POSIX mode bits cannot enforce Windows ACLs. +`--ota-psk HEX` is also supported, but exposes the key in process arguments and +shell history. Key values are never printed by this tool, including `--state +--show-secrets`, and never saved in the per-board host JSON. + +Provisioning rewrites the **whole NVS partition**, not just the key supplied. +Pass the key again on every later provisioning run. Once this tool records +that OTA was configured, omission is refused unless `--clear-ota-psk` explicitly +disables OTA. An older board with no host record cannot be detected this way; +without a supplied key the tool warns that the rewrite disables OTA. Legacy +cached OTA keys are hidden from inspection and never reused. A successful +provisioning run removes them from saved state; read-only inspection leaves +the existing file unchanged. +Keep the key backed up securely. A dry-run binary or fallback CSV still +contains credentials and must remain private and be deleted after use. + +This writes `security/ota_psk` through the USB NVS workflow; it does not flash +application firmware. After reboot, verify the `OTA PSK loaded` log. The HTTP +server starting alone does not mean OTA authentication is armed. Uploads +remain fail-closed without a key; see the first-boot health check above after +an authenticated update. + ### NVS Key Reference #### Network Settings @@ -949,12 +1019,12 @@ This firmware implements or references the following ADRs: | ADR | Title | Status | |-----|-------|--------| -| [ADR-018](../../docs/adr/ADR-018-csi-binary-frame-format.md) | CSI binary frame format | Accepted | +| [ADR-018](../../docs/adr/ADR-018-esp32-dev-implementation.md) | CSI binary frame format | Accepted | | [ADR-029](../../docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md) | Channel hopping and TDM protocol | Accepted | | [ADR-039](../../docs/adr/ADR-039-esp32-edge-intelligence.md) | Edge intelligence tiers 0-2 | Accepted | | [ADR-040](../../docs/adr/) | WASM programmable sensing (Tier 3) with RVF container format | Alpha | -| [ADR-057](../../docs/adr/ADR-057-build-time-csi-guard.md) | Build-time CSI guard (`CONFIG_ESP_WIFI_CSI_ENABLED`) | Accepted | -| [ADR-060](../../docs/adr/ADR-060-channel-mac-filter.md) | Channel override and MAC address filter | Accepted | +| [ADR-057](../../docs/adr/ADR-057-firmware-csi-build-guard.md) | Build-time CSI guard (`CONFIG_ESP_WIFI_CSI_ENABLED`) | Accepted | +| [ADR-060](../../docs/adr/ADR-060-provision-channel-mac-filter.md) | Channel override and MAC address filter | Accepted | | [ADR-061](../../docs/adr/ADR-061-qemu-esp32s3-firmware-testing.md) | QEMU ESP32-S3 emulation for firmware testing | Proposed | --- diff --git a/firmware/esp32-csi-node/main/onboarding_identity.h b/firmware/esp32-csi-node/main/onboarding_identity.h new file mode 100644 index 000000000..675e80536 --- /dev/null +++ b/firmware/esp32-csi-node/main/onboarding_identity.h @@ -0,0 +1,39 @@ +#ifndef RUVIEW_ONBOARDING_IDENTITY_H +#define RUVIEW_ONBOARDING_IDENTITY_H + +#include +#include +#include + +#include "esp_mac.h" +#include "psa/crypto.h" + +/* Shared with the host regression test; hashing remains the IDF PSA API. + * esp_efuse_mac_get_default writes an eight-byte EUI-64 on C6/H2. The base + * MAC API always returns the six-byte identity required by ADR-363. */ +static inline void ruview_onboarding_device_digest(char output[17]) +{ + uint8_t base_mac[6] = {0}; + uint8_t digest[32] = {0}; + static const uint8_t domain[] = "ruview-device-v1"; + uint8_t digest_input[sizeof(domain) - 1 + sizeof(base_mac)]; + size_t digest_length = 0; + memcpy(digest_input, domain, sizeof(domain) - 1); + const esp_err_t mac_result = esp_base_mac_addr_get(base_mac); + if (mac_result == ESP_OK) { + memcpy(digest_input + sizeof(domain) - 1, base_mac, sizeof(base_mac)); + } + if (mac_result == ESP_OK && + psa_crypto_init() == PSA_SUCCESS && + psa_hash_compute(PSA_ALG_SHA_256, digest_input, sizeof(digest_input), + digest, sizeof(digest), &digest_length) == PSA_SUCCESS && + digest_length == sizeof(digest)) { + for (size_t index = 0; index < 8; index++) { + snprintf(output + index * 2, 3, "%02x", digest[index]); + } + } else { + memcpy(output, "0000000000000000", 17); + } +} + +#endif diff --git a/firmware/esp32-csi-node/main/ota_update.c b/firmware/esp32-csi-node/main/ota_update.c index 3ccfdff88..fe634057e 100644 --- a/firmware/esp32-csi-node/main/ota_update.c +++ b/firmware/esp32-csi-node/main/ota_update.c @@ -40,7 +40,7 @@ static char s_ota_psk[OTA_PSK_MAX_LEN] = {0}; * Returns true only when a PSK is provisioned AND the Bearer token * matches it. An unprovisioned node refuses all OTA requests * (fail-closed, see RuView#596 audit). The OTA server still starts so - * the operator can `provision.py --ota-psk ` over USB-CDC without + * the operator can `provision.py --ota-psk-file ` over USB without * a reflash, but the upload endpoint will reject every request until * the PSK is set. */ @@ -53,7 +53,7 @@ static bool ota_check_auth(httpd_req_t *req) * Plain HTTP transport + no Secure Boot V2 + no signed-image * verification meant a single LAN call could brick or back- * door a node. Reject until provisioned. */ - ESP_LOGW(TAG, "OTA rejected: no PSK in NVS (run provision.py --ota-psk )"); + ESP_LOGW(TAG, "OTA rejected: no PSK in NVS (run provision.py --ota-psk-file )"); return false; } @@ -319,7 +319,7 @@ static void ota_load_psk_from_nvs(void) ESP_LOGI(TAG, "OTA PSK loaded from NVS (%d chars) — authentication enabled", (int)len - 1); } else { ESP_LOGW(TAG, "No OTA PSK in NVS — OTA upload endpoint will REJECT all requests until " - "provisioned (provision.py --ota-psk ). Fail-closed per RuView#596."); + "provisioned (provision.py --ota-psk-file ). Fail-closed per RuView#596."); } nvs_close(nvs); } else { diff --git a/firmware/esp32-csi-node/main/serial_onboarding.c b/firmware/esp32-csi-node/main/serial_onboarding.c index be706cbaa..8529f3f76 100644 --- a/firmware/esp32-csi-node/main/serial_onboarding.c +++ b/firmware/esp32-csi-node/main/serial_onboarding.c @@ -1,6 +1,7 @@ #include "serial_onboarding.h" #include "serial_onboarding_protocol.h" +#include "onboarding_identity.h" #include #include @@ -17,7 +18,6 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "nvs.h" -#include "psa/crypto.h" static const char *TAG = "serial_onboard"; static nvs_config_t s_current_config; @@ -38,36 +38,11 @@ static const char *chip_name(void) #endif } -static void device_digest(char output[17]) -{ - uint8_t base_mac[6] = {0}; - uint8_t digest[32] = {0}; - static const uint8_t domain[] = "ruview-device-v1"; - uint8_t digest_input[sizeof(domain) - 1 + sizeof(base_mac)]; - size_t digest_length = 0; - memcpy(digest_input, domain, sizeof(domain) - 1); - const esp_err_t mac_result = esp_efuse_mac_get_default(base_mac); - if (mac_result == ESP_OK) { - memcpy(digest_input + sizeof(domain) - 1, base_mac, sizeof(base_mac)); - } - if (mac_result == ESP_OK && - psa_crypto_init() == PSA_SUCCESS && - psa_hash_compute(PSA_ALG_SHA_256, digest_input, sizeof(digest_input), - digest, sizeof(digest), &digest_length) == PSA_SUCCESS && - digest_length == sizeof(digest)) { - for (size_t index = 0; index < 8; index++) { - snprintf(output + index * 2, 3, "%02x", digest[index]); - } - } else { - memcpy(output, "0000000000000000", 17); - } -} - static void emit_hello(const char *nonce) { const esp_app_desc_t *description = esp_app_get_description(); char digest[17] = {0}; - device_digest(digest); + ruview_onboarding_device_digest(digest); const bool configured = s_current_config.wifi_ssid[0] != '\0' && s_current_config.target_ip[0] != '\0'; printf("RUVIEW_HELLO_OK_V1 nonce=%s chip=%s version=%s node_id=%u " diff --git a/firmware/esp32-csi-node/provision.py b/firmware/esp32-csi-node/provision.py index 1c3730ba5..46a7aab31 100644 --- a/firmware/esp32-csi-node/provision.py +++ b/firmware/esp32-csi-node/provision.py @@ -68,6 +68,8 @@ NVS_PARTITION_SIZE = 0x6000 # 24 KiB CONFIG_VALUE_CHECKS = [ ("ssid", bool), ("password", lambda value: value is not None), + ("ota_psk", lambda value: value is not None), + ("clear_ota_psk", bool), ("target_ip", bool), ("target_port", lambda value: value is not None), ("node_id", lambda value: value is not None), @@ -118,6 +120,9 @@ MERGEABLE_ATTRS = [ "seed_url", "seed_token", "zone", "swarm_hb", "swarm_ingest", ] +# Cache only whether this tool armed OTA, never the firmware-upload credential. +OTA_PSK_STATE_KEY = "_ota_psk_configured" + def _default_state_dir() -> str: """Per-user config dir for provision-state JSON files.""" @@ -260,6 +265,43 @@ def read_password_file(path: str, allow_insecure: bool = False) -> str: return text +def validate_ota_psk(value: str) -> str: + """A 256-bit hex token matching the firmware's 65-byte NVS buffer.""" + if not isinstance(value, str) or re.fullmatch(r"[0-9a-fA-F]{64}", value) is None: + raise ValueError("OTA PSK must contain exactly 64 hexadecimal characters") + return value + + +def read_ota_psk_file(path: str) -> str: + """Read a bounded regular file; enforce POSIX privacy and refuse symlinks.""" + try: + before = os.lstat(path) + if not stat.S_ISREG(before.st_mode): + raise ValueError("--ota-psk-file must be a regular file, not a symlink") + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0) + flags |= getattr(os, "O_BINARY", 0) + with os.fdopen(os.open(path, flags), "rb") as stream: + opened = os.fstat(stream.fileno()) + if (not stat.S_ISREG(opened.st_mode) + or (before.st_dev, before.st_ino) != (opened.st_dev, opened.st_ino)): + raise ValueError("--ota-psk-file changed while opening") + if sys.platform != "win32" and stat.S_IMODE(opened.st_mode) & 0o077: + raise ValueError("--ota-psk-file must be owner-only; run chmod 600 on it") + # 64 hex bytes plus an optional LF/CRLF; one extra byte detects overflow. + data = stream.read(67) + except OSError: + raise ValueError("could not open --ota-psk-file as a regular file") from None + try: + value = data.decode("ascii") + except UnicodeDecodeError: + raise ValueError("OTA PSK file must contain ASCII hexadecimal text") from None + if value.endswith("\r\n"): + value = value[:-2] + elif value.endswith("\n"): + value = value[:-1] + return validate_ota_psk(value) + + def load_state(port: str, state_dir: str) -> dict: """Return the merged-state dict for `port`, or `{}` if absent / unreadable.""" path = _state_path_for(port, state_dir) @@ -269,6 +311,11 @@ def load_state(port: str, state_dir: str) -> dict: with open(path, "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): + if "ota_psk" in data: + # Some earlier external provisioners cached this credential. + # Drop it from inspection/merge without mutating a read-only + # request. The next successful state write scrubs the file. + data[OTA_PSK_STATE_KEY] = bool(data.pop("ota_psk")) return data except (OSError, json.JSONDecodeError) as exc: print(f"WARNING: could not read state file {path}: {exc}", file=sys.stderr) @@ -280,6 +327,9 @@ def save_state(port: str, state_dir: str, state: dict) -> str: The file holds secrets, so it is written 0600 inside a 0700 dir (#1754). """ + state = dict(state) + if "ota_psk" in state: + state[OTA_PSK_STATE_KEY] = bool(state.pop("ota_psk")) os.makedirs(state_dir, mode=STATE_DIR_MODE, exist_ok=True) harden_state_dir(state_dir) path = _state_path_for(port, state_dir) @@ -421,6 +471,8 @@ def merge_state_into_args(args, prior: dict) -> dict: in place so downstream `build_nvs_csv` sees the merged values. """ merged = dict(prior) + if "ota_psk" in merged: + merged[OTA_PSK_STATE_KEY] = bool(merged.pop("ota_psk")) for name in MERGEABLE_ATTRS: cli_val = getattr(args, name, None) if cli_val is not None: @@ -431,7 +483,7 @@ def merge_state_into_args(args, prior: dict) -> dict: def build_nvs_csv(args): - """Build an NVS CSV string for the csi_cfg namespace.""" + """Build csi_cfg and the optional security/ota_psk namespace.""" buf = io.StringIO() writer = csv.writer(buf) writer.writerow(["key", "type", "encoding", "value"]) @@ -490,6 +542,9 @@ def build_nvs_csv(args): writer.writerow(["swarm_hb", "data", "u16", str(args.swarm_hb)]) if args.swarm_ingest is not None: writer.writerow(["swarm_ingest", "data", "u16", str(args.swarm_ingest)]) + if getattr(args, "ota_psk", None) is not None: + writer.writerow(["security", "namespace", "", ""]) + writer.writerow(["ota_psk", "data", "string", validate_ota_psk(args.ota_psk)]) return buf.getvalue() @@ -526,7 +581,7 @@ def generate_nvs_binary(csv_content, size): subprocess.check_call([ # nosemgrep: dangerous-subprocess-use-tainted-env-args sys.executable, gen_script, "generate", csv_path, bin_path, hex(size) - ]) + ], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) with open(bin_path, "rb") as f: return f.read() @@ -595,6 +650,16 @@ def main(): parser.add_argument("--allow-insecure-password-file", action="store_true", help="Accept a --password-file that group or others can read, with a " "warning (e.g. a read-only 0444 secrets mount).") + ota_source = parser.add_mutually_exclusive_group() + ota_source.add_argument("--ota-psk", metavar="HEX", + help="OTA key: exactly 64 hex characters. Visible in process " + "arguments/history; prefer --ota-psk-file. Never cached in state.") + ota_source.add_argument("--ota-psk-file", metavar="PATH", + help="Read the 64-hex OTA key from a regular file. Requires " + "owner-only POSIX permissions; protect Windows files with ACLs.") + ota_source.add_argument("--clear-ota-psk", action="store_true", + help="Explicitly remove the OTA key during this NVS rewrite; " + "firmware updates will be refused until a key is provisioned.") parser.add_argument("--target-ip", help="Aggregator host IP (e.g. 192.168.1.20)") parser.add_argument("--target-port", type=int, help="Aggregator UDP port (default: 5005)") parser.add_argument("--node-id", type=int, help="Node ID 0-255 (default: 1)") @@ -653,6 +718,16 @@ def main(): # or early exit (#1754). harden_state_dir(args.state_dir) + try: + if args.ota_psk_file is not None: + args.ota_psk = read_ota_psk_file(args.ota_psk_file) + elif args.ota_psk is not None: + args.ota_psk = validate_ota_psk(args.ota_psk) + print("WARNING: --ota-psk is visible in process arguments and shell history; " + "use --ota-psk-file instead.", file=sys.stderr) + except ValueError as exc: + parser.error(str(exc)) + if args.password_file is not None: try: args.password = read_password_file( @@ -708,6 +783,10 @@ def main(): f"board last provisioned on {args.port}, rerun with --reset.", file=sys.stderr) merged = merge_state_into_args(args, prior) + if args.ota_psk is not None: + merged[OTA_PSK_STATE_KEY] = True + elif args.clear_ota_psk: + merged[OTA_PSK_STATE_KEY] = False # A new --ssid with no password given: ask for it on a terminal instead of # requiring it on the command line. The saved password is reused only when @@ -726,6 +805,14 @@ def main(): print("Secrets hidden; pass --show-secrets to print them.", file=sys.stderr) return + if args.ota_psk is None: + if merged.get(OTA_PSK_STATE_KEY) and not args.clear_ota_psk: + parser.error("OTA was previously configured. Resupply --ota-psk-file (keys " + "are not cached), or pass --clear-ota-psk to disable OTA.") + print("WARNING: this rewrites the whole NVS partition without an OTA key. " + "OTA uploads will be disabled; supply --ota-psk-file to keep them enabled.", + file=sys.stderr) + if not has_config_value(args): parser.error( "At least one config value must be specified (after merging prior state). " @@ -825,7 +912,8 @@ def main(): try: nvs_bin = generate_nvs_binary(csv_content, NVS_PARTITION_SIZE) except Exception as e: - print(f"\nError generating NVS binary: {e}", file=sys.stderr) + detail = str(e).replace(args.ota_psk, "(redacted)") if args.ota_psk else str(e) + print(f"\nError generating NVS binary: {detail}", file=sys.stderr) print("\nFallback: save CSV and flash manually with ESP-IDF tools.", file=sys.stderr) fallback_path = "nvs_config.csv" _write_private(fallback_path, csv_content.encode("utf-8")) diff --git a/firmware/esp32-csi-node/release_bins/README.md b/firmware/esp32-csi-node/release_bins/README.md new file mode 100644 index 000000000..5aede3904 --- /dev/null +++ b/firmware/esp32-csi-node/release_bins/README.md @@ -0,0 +1,16 @@ +# Historical firmware snapshots + +These tracked files are historical artifacts, not the current release channel. +Do not select them for a new installation: their board configurations and +versions differ, and older builds can produce insufficient CSI traffic. + +Use the versioned, checksummed bundles on the +[ESP32 0.8.14 prerelease page](https://github.com/ruvnet/RuView/releases/tag/v0.8.14-esp32) +or the [previous stable 0.8.8 release](https://github.com/ruvnet/RuView/releases/tag/v0.8.8-esp32). +See the [firmware guide](../README.md#0-download-a-versioned-firmware-bundle) +for chip, flash-size and display selection. The 0.8.14 prerelease requires +physical-board qualification; CI build success is not hardware validation. + +`c6-onboarding/` remains the historical 0.8.12 snapshot. Its local `version.txt` +and `SHA256SUMS.txt` describe that snapshot, independently of current source. +No tracked binary is updated as part of a source-only firmware release. diff --git a/firmware/esp32-csi-node/release_bins/c6-onboarding/version.txt b/firmware/esp32-csi-node/release_bins/c6-onboarding/version.txt new file mode 100644 index 000000000..7eff8ab95 --- /dev/null +++ b/firmware/esp32-csi-node/release_bins/c6-onboarding/version.txt @@ -0,0 +1 @@ +0.8.12 diff --git a/firmware/esp32-csi-node/test/Makefile b/firmware/esp32-csi-node/test/Makefile index 12a7098c5..382081235 100644 --- a/firmware/esp32-csi-node/test/Makefile +++ b/firmware/esp32-csi-node/test/Makefile @@ -56,7 +56,7 @@ FUZZ_JOBS ?= 1 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 \ - run_delivery_contract test_ota_health run_ota_health + run_delivery_contract test_ota_health run_ota_health run_onboarding_identity 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 \ @@ -134,6 +134,12 @@ test_serial_onboarding: test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onb run_serial_onboarding: test_serial_onboarding ./test_serial_onboarding +test_onboarding_identity: test_onboarding_identity.c $(MAIN_DIR)/onboarding_identity.h identity_stubs/esp_mac.h identity_stubs/psa/crypto.h + cc -std=c11 -Wall -Wextra -Werror -pedantic -Iidentity_stubs -I$(MAIN_DIR) -o $@ $< + +run_onboarding_identity: test_onboarding_identity + ./test_onboarding_identity + # --- OTA first-boot health check (ADR-379) --- # Host-side. Exercises ota_health_step() from ../main/ota_health.h directly -- # the decision the device runs to confirm or roll back an OTA'd image. @@ -147,7 +153,7 @@ 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 run_delivery_contract run_ota_health + run_c6_antenna run_serial_onboarding run_onboarding_identity run_delivery_contract run_ota_health @echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding + delivery contract + OTA health)" # --- Serialize fuzzer --- @@ -186,5 +192,5 @@ run_all: run_serialize run_edge run_nvs clean: rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \ test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding \ - test_ota_health + test_ota_health test_onboarding_identity rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/ diff --git a/firmware/esp32-csi-node/test/identity_stubs/esp_mac.h b/firmware/esp32-csi-node/test/identity_stubs/esp_mac.h new file mode 100644 index 000000000..9c1898be1 --- /dev/null +++ b/firmware/esp32-csi-node/test/identity_stubs/esp_mac.h @@ -0,0 +1,8 @@ +#ifndef RUVIEW_TEST_ESP_MAC_H +#define RUVIEW_TEST_ESP_MAC_H +#include +typedef int esp_err_t; +#define ESP_OK 0 +esp_err_t esp_base_mac_addr_get(uint8_t *mac); +esp_err_t esp_efuse_mac_get_default(uint8_t *mac); +#endif diff --git a/firmware/esp32-csi-node/test/identity_stubs/psa/crypto.h b/firmware/esp32-csi-node/test/identity_stubs/psa/crypto.h new file mode 100644 index 000000000..bffeaa3d8 --- /dev/null +++ b/firmware/esp32-csi-node/test/identity_stubs/psa/crypto.h @@ -0,0 +1,13 @@ +#ifndef RUVIEW_TEST_PSA_CRYPTO_H +#define RUVIEW_TEST_PSA_CRYPTO_H +#include +#include +typedef int psa_status_t; +typedef uint32_t psa_algorithm_t; +#define PSA_SUCCESS 0 +#define PSA_ALG_SHA_256 ((psa_algorithm_t)0x02000009) +psa_status_t psa_crypto_init(void); +psa_status_t psa_hash_compute(psa_algorithm_t algorithm, + const uint8_t *input, size_t input_length, + uint8_t *hash, size_t hash_size, size_t *hash_length); +#endif diff --git a/firmware/esp32-csi-node/test/test_delivery_contract.py b/firmware/esp32-csi-node/test/test_delivery_contract.py index adcf6daa5..69c89161f 100644 --- a/firmware/esp32-csi-node/test/test_delivery_contract.py +++ b/firmware/esp32-csi-node/test/test_delivery_contract.py @@ -2,6 +2,10 @@ """Static delivery contracts that do not require an ESP-IDF toolchain.""" from pathlib import Path +import argparse +import hashlib +import sys +import tempfile import unittest @@ -9,29 +13,60 @@ FIRMWARE_ROOT = Path(__file__).resolve().parents[1] REPOSITORY_ROOT = FIRMWARE_ROOT.parents[1] +def app_version(path: Path) -> str: + """Read the first segment's ESP-IDF app descriptor, not arbitrary strings. + + IDF image header + segment header occupy 32 bytes; esp_app_desc_t has + magic 0xABCD5432 and a 32-byte version at descriptor offset 16. + """ + with path.open("rb") as stream: + header = stream.read(80) + if (len(header) != 80 or header[0] != 0xE9 + or header[32:36] != bytes.fromhex("3254cdab")): + raise ValueError("missing ESP-IDF application descriptor") + return header[48:80].split(b"\0", 1)[0].decode("ascii") + + 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() + config = (FIRMWARE_ROOT / "sdkconfig.defaults.esp32c6").read_text(encoding="utf-8") 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() + onboarding = (FIRMWARE_ROOT / "main/serial_onboarding.c").read_text(encoding="utf-8") 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_historical_c6_snapshot_matches_its_own_version_and_checksums(self) -> None: + snapshot = FIRMWARE_ROOT / "release_bins/c6-onboarding" + expected = (snapshot / "version.txt").read_text(encoding="utf-8").strip() + self.assertEqual(app_version(snapshot / "esp32-csi-node.bin"), expected) + for line in (snapshot / "SHA256SUMS.txt").read_text(encoding="ascii").splitlines(): + digest, filename = line.split() + self.assertEqual(hashlib.sha256((snapshot / filename).read_bytes()).hexdigest(), digest) + + def test_app_version_rejects_truncated_or_wrong_descriptor(self) -> None: + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "app.bin" + for data in [b"", b"0.8.14\0" * 20, b"\xe9" + b"\0" * 79]: + path.write_bytes(data) + with self.assertRaises(ValueError): + app_version(path) def test_firmware_tags_publish_downloadable_release_assets(self) -> None: - workflow = (REPOSITORY_ROOT / ".github/workflows/firmware-ci.yml").read_text() + workflow = (REPOSITORY_ROOT / ".github/workflows/firmware-ci.yml").read_text(encoding="utf-8") 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() + parser = argparse.ArgumentParser() + parser.add_argument("--built-app", type=Path) + args, remaining = parser.parse_known_args() + if args.built_app: + expected = (FIRMWARE_ROOT / "version.txt").read_text(encoding="utf-8").strip() + actual = app_version(args.built_app) + if actual != expected: + raise SystemExit(f"built application version {actual!r} does not match {expected!r}") + print(f"PASS: built application descriptor version {actual}") + unittest.main(argv=[sys.argv[0], *remaining]) diff --git a/firmware/esp32-csi-node/test/test_onboarding_identity.c b/firmware/esp32-csi-node/test/test_onboarding_identity.c new file mode 100644 index 000000000..5ed9757eb --- /dev/null +++ b/firmware/esp32-csi-node/test/test_onboarding_identity.c @@ -0,0 +1,74 @@ +/* Exercises the production identity boundary with synthetic IDF/PSA seams. + * This verifies the complete six-byte MAC reaches PSA, not PSA's SHA-256 + * implementation or physical hardware. No replacement hash ships on device. */ +#include +#include "onboarding_identity.h" + +static uint8_t board_mac[6] = {0x02, 0x10, 0x20, 0x30, 0x40, 0x50}; +static int mac_result, init_result, hash_result; +static size_t hash_length = 32; +static unsigned mac_calls, hash_calls; + +esp_err_t esp_base_mac_addr_get(uint8_t *mac) +{ + mac_calls++; + memcpy(mac, board_mac, sizeof(board_mac)); + return mac_result; +} + +esp_err_t esp_efuse_mac_get_default(uint8_t *mac) +{ + (void)mac; + assert(!"EUI-64 API must never receive the six-byte identity buffer"); + return -1; +} + +psa_status_t psa_crypto_init(void) { return init_result; } + +psa_status_t psa_hash_compute(psa_algorithm_t algorithm, + const uint8_t *input, size_t input_length, + uint8_t *hash, size_t hash_size, size_t *length) +{ + static const uint8_t domain[] = "ruview-device-v1"; + hash_calls++; + assert(algorithm == PSA_ALG_SHA_256); + assert(input_length == sizeof(domain) - 1 + sizeof(board_mac)); + assert(memcmp(input, domain, sizeof(domain) - 1) == 0); + assert(memcmp(input + sizeof(domain) - 1, board_mac, sizeof(board_mac)) == 0); + assert(hash_size == 32); + /* Synthetic digest exposes lost tail bytes and output hex formatting. */ + memset(hash, 0xab, hash_size); + memcpy(hash, board_mac, sizeof(board_mac)); + *length = hash_length; + return hash_result; +} + +int main(void) +{ + char first[17], second[17]; + ruview_onboarding_device_digest(first); + assert(strcmp(first, "021020304050abab") == 0); + board_mac[5]++; + ruview_onboarding_device_digest(second); + assert(strcmp(second, "021020304051abab") == 0); + assert(strcmp(first, second) != 0); + assert(mac_calls == 2 && hash_calls == 2); + + mac_result = -1; + ruview_onboarding_device_digest(second); + assert(strcmp(second, "0000000000000000") == 0 && hash_calls == 2); + mac_result = 0; + init_result = -1; + ruview_onboarding_device_digest(second); + assert(strcmp(second, "0000000000000000") == 0 && hash_calls == 2); + init_result = 0; + hash_result = -1; + ruview_onboarding_device_digest(second); + assert(strcmp(second, "0000000000000000") == 0); + hash_result = 0; + hash_length = 31; + ruview_onboarding_device_digest(second); + assert(strcmp(second, "0000000000000000") == 0); + puts("PASS: six-byte onboarding identity input and fail-closed output"); + return 0; +} diff --git a/firmware/esp32-csi-node/tests/test_provision_ota_psk.py b/firmware/esp32-csi-node/tests/test_provision_ota_psk.py new file mode 100644 index 000000000..9574ed2a7 --- /dev/null +++ b/firmware/esp32-csi-node/tests/test_provision_ota_psk.py @@ -0,0 +1,181 @@ +"""OTA provisioning regressions; all hardware and NVS generation are mocked.""" + +import contextlib +import importlib.util +import io +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + +SPEC = importlib.util.spec_from_file_location( + "provision_ota", Path(__file__).resolve().parents[1] / "provision.py") +provision = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(provision) +FAKE_PSK = "ab" * 32 + + +class OtaProvisionTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.state = str(self.root / "state") + self.base = ["--port", "COM7", "--state-dir", self.state, + "--ssid", "test-network", "--password", "synthetic-password", + "--target-ip", "192.0.2.1"] + self.csv = [] + + def tearDown(self): + self.temp.cleanup() + + def key_file(self, value=FAKE_PSK): + path = self.root / "key.txt" + path.write_bytes(value.encode("ascii")) + path.chmod(0o600) + return str(path) + + def run_main(self, *options, generator_error=None): + def generate(csv, _size): + self.csv.append(csv) + if generator_error: + raise generator_error + return b"synthetic-nvs" + + out, err = io.StringIO(), io.StringIO() + old_cwd = os.getcwd() + try: + os.chdir(self.root) + with mock.patch.object(sys, "argv", ["provision.py", *self.base, *options]), \ + mock.patch.object(provision, "read_chip_mac", return_value=None) as read_mac, \ + mock.patch.object(provision, "flash_nvs") as flash, \ + mock.patch.object(provision, "generate_nvs_binary", side_effect=generate), \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = 0 + try: + provision.main() + except SystemExit as exc: + code = exc.code + return code, out.getvalue(), err.getvalue(), read_mac, flash + finally: + os.chdir(old_cwd) + + def test_key_reaches_security_namespace_but_never_state_or_output(self): + code, out, err, _, flash = self.run_main("--ota-psk-file", self.key_file()) + self.assertEqual(code, 0) + flash.assert_called_once() + rows = list(provision.csv.reader(io.StringIO(self.csv[-1]))) + namespace = rows.index(["security", "namespace", "", ""]) + self.assertEqual(rows[namespace + 1], ["ota_psk", "data", "string", FAKE_PSK]) + saved = Path(provision._state_path_for("COM7", self.state)).read_text() + self.assertNotIn(FAKE_PSK, saved + out + err) + self.assertNotIn('"ota_psk"', saved) + self.assertTrue(json.loads(saved)[provision.OTA_PSK_STATE_KEY]) + code, out, err, read_mac, flash = self.run_main("--state", "--show-secrets") + self.assertEqual(code, 0) + self.assertNotIn(FAKE_PSK, out + err) + read_mac.assert_not_called() + flash.assert_not_called() + + def test_known_key_must_be_resupplied_or_explicitly_removed(self): + self.run_main("--ota-psk-file", self.key_file()) + self.csv.clear() + code, _, err, _, flash = self.run_main("--node-id", "2") + self.assertEqual(code, 2) + self.assertIn("keys are not cached", err) + self.assertEqual(self.csv, []) + flash.assert_not_called() + code, _, _, _, flash = self.run_main("--clear-ota-psk") + self.assertEqual(code, 0) + flash.assert_called_once() + self.assertNotIn("security,namespace", self.csv[-1]) + self.assertFalse(provision.load_state("COM7", self.state)[provision.OTA_PSK_STATE_KEY]) + + def test_legacy_key_is_hidden_on_read_and_scrubbed_only_on_successful_write(self): + os.makedirs(self.state) + path = Path(provision._state_path_for("COM7", self.state)) + path.write_text(json.dumps({"ota_psk": FAKE_PSK, "node_id": 7})) + before = path.read_bytes() + with mock.patch.object(provision, "save_state", side_effect=PermissionError("read-only")) as save: + code, out, err, _, _ = self.run_main("--state", "--show-secrets") + save.assert_not_called() + self.assertEqual(code, 0) + self.assertNotIn(FAKE_PSK, out + err) + self.assertEqual(path.read_bytes(), before) + self.assertTrue(json.loads(out)[provision.OTA_PSK_STATE_KEY]) + self.assertEqual(json.loads(out)["node_id"], 7) + code, _, _, _, flash = self.run_main() + self.assertEqual(code, 2) + flash.assert_not_called() + self.assertEqual(path.read_bytes(), before) + code, _, _, _, _ = self.run_main("--ota-psk-file", self.key_file()) + self.assertEqual(code, 0) + self.assertNotIn(FAKE_PSK, path.read_text()) + + def test_direct_key_validation_rejects_before_hardware_without_echo(self): + for value in ["", "a", "a" * 63, "a" * 65, "z" * 64, " " + FAKE_PSK, + FAKE_PSK + "\n", "é" * 64]: + with self.subTest(length=len(value)): + code, out, err, read_mac, flash = self.run_main("--ota-psk", value) + self.assertEqual(code, 2) + self.assertIn("exactly 64 hexadecimal", err) + if len(value) > 20: + self.assertNotIn(value, out + err) + read_mac.assert_not_called() + flash.assert_not_called() + + def test_direct_key_and_standalone_key_count_as_configuration(self): + code, out, err, _, _ = self.run_main("--ota-psk", FAKE_PSK) + self.assertEqual(code, 0) + self.assertIn("process arguments", err) + self.assertNotIn(FAKE_PSK, out + err) + args = type("Args", (), {"ota_psk": FAKE_PSK})() + self.assertTrue(provision.has_config_value(args)) + + def test_file_validation_allows_one_newline_and_rejects_oversize(self): + for suffix in ["", "\n", "\r\n"]: + self.assertEqual(provision.read_ota_psk_file(self.key_file(FAKE_PSK + suffix)), FAKE_PSK) + for value in [FAKE_PSK + "\n\n", "a" * 10000, "short"]: + with self.assertRaises(ValueError): + provision.read_ota_psk_file(self.key_file(value)) + with self.assertRaises(ValueError): + provision.read_ota_psk_file(str(self.root)) + with self.assertRaises(ValueError): + provision.read_ota_psk_file(str(self.root / "missing")) + + @unittest.skipIf(sys.platform == "win32", "POSIX permission/symlink model") + def test_file_rejects_insecure_permissions_and_symlinks(self): + key = Path(self.key_file()) + key.chmod(0o644) + with self.assertRaisesRegex(ValueError, "owner-only"): + provision.read_ota_psk_file(str(key)) + key.chmod(0o600) + link = self.root / "key-link" + link.symlink_to(key) + with self.assertRaisesRegex(ValueError, "not a symlink"): + provision.read_ota_psk_file(str(link)) + + def test_dry_run_has_no_hardware_or_state_side_effects(self): + code, out, err, read_mac, flash = self.run_main("--ota-psk-file", self.key_file(), "--dry-run") + self.assertEqual(code, 0) + self.assertNotIn(FAKE_PSK, out + err) + read_mac.assert_not_called() + flash.assert_not_called() + self.assertFalse(Path(provision._state_path_for("COM7", self.state)).exists()) + + def test_generator_failure_redacts_key_from_diagnostic(self): + code, out, err, _, flash = self.run_main( + "--ota-psk-file", self.key_file(), generator_error=RuntimeError(FAKE_PSK)) + self.assertEqual(code, 1) + self.assertNotIn(FAKE_PSK, out + err) + flash.assert_not_called() + + def test_state_writer_strips_key_as_backstop(self): + path = provision.save_state("COM7", self.state, {"ota_psk": FAKE_PSK}) + self.assertNotIn(FAKE_PSK, Path(path).read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/firmware/esp32-csi-node/version.txt b/firmware/esp32-csi-node/version.txt index 7eff8ab95..832bad274 100644 --- a/firmware/esp32-csi-node/version.txt +++ b/firmware/esp32-csi-node/version.txt @@ -1 +1 @@ -0.8.12 +0.8.14 From 3620abff12c53bce4dfc763ae8b469272be5e53b Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 12 Oct 2026 11:49:52 -0400 Subject: [PATCH 13/23] fix(toolkit): integrate capture and device corrections for 0.9.2 Integrate reviewed source PRs #2201-#2223, #2225-#2231, #2235-#2240, #2242 and #2243 (firmware README hunks remain in #2247). Regenerate one package manifest after integration, validate native Windows test execution, bound MTK listener startup, support official C6 release bundles, and retain stronger release metadata gates. --- .github/workflows/ci.yml | 16 +- .github/workflows/ruview-harness-flywheel.yml | 18 ++- README.md | 4 +- docs/TROUBLESHOOTING.md | 14 +- .../ADR-383-http-request-target-validation.md | 23 +++ .../adr/ADR-384-sdk-explicit-search-limits.md | 19 +++ .../ADR-385-watch-terminal-input-errors.md | 19 +++ docs/adr/ADR-386-csi-gap-recovery-identity.md | 23 +++ .../ADR-389-csi-observed-capture-duration.md | 32 ++++ .../adr/ADR-390-lidar-bounded-scan-extrema.md | 27 ++++ ...R-391-csi-analysis-synthetic-provenance.md | 29 ++++ ...ADR-392-mmwave-known-model-single-frame.md | 27 ++++ docs/adr/ADR-393-lidar-even-sample-median.md | 28 ++++ docs/adr/ADR-394-sdk-strict-doctor-errors.md | 26 ++++ .../adr/ADR-395-cli-missing-numeric-values.md | 27 ++++ ...R-396-esphome-millimeter-distance-units.md | 27 ++++ .../ADR-397-esphome-entity-update-counters.md | 27 ++++ ...ADR-398-esphome-nonfinite-sensor-states.md | 28 ++++ .../adr/ADR-399-csi-dominant-capped-stream.md | 29 ++++ .../ADR-400-fused-vitals-node-diagnostics.md | 31 ++++ ...-auxiliary-node-wire-payload-validation.md | 35 +++++ ...2-csi-shape-window-synthetic-provenance.md | 29 ++++ ...training-report-timestamp-normalization.md | 28 ++++ ...DR-404-cli-watch-missing-window-history.md | 31 ++++ ...5-iphone-lidar-closed-stream-completion.md | 31 ++++ ...R-406-esphome-numeric-role-entity-kinds.md | 31 ++++ .../ADR-407-serial-partial-capture-status.md | 32 ++++ .../ADR-408-ld2410-absent-target-distance.md | 31 ++++ ...409-sdk-supported-device-argument-types.md | 35 +++++ .../ADR-410-monitor-csi-flow-log-markers.md | 32 ++++ ...411-doctor-corrupt-manifest-diagnostics.md | 32 ++++ ...-csi-transcode-incomplete-iq-quarantine.md | 30 ++++ ...ADR-413-csi-relay-datagram-preservation.md | 29 ++++ ...R-414-overnight-capture-adr018-decoding.md | 31 ++++ ...415-training-capture-session-collisions.md | 31 ++++ ...ADR-416-alignment-cli-window-validation.md | 34 ++++ ...R-417-csi-transcode-source-preservation.md | 29 ++++ ...phome-rejection-fixture-reset-lifecycle.md | 30 ++++ docs/integrations/home-assistant.md | 9 +- docs/readme-details.md | 130 ++++++++-------- docs/user-guide.md | 65 ++++---- harness/ruview/.harness/manifest.json | 36 ++--- harness/ruview/.harness/manifest.sha256 | 2 +- harness/ruview/README.md | 3 +- harness/ruview/package-lock.json | 4 +- harness/ruview/package.json | 4 +- harness/ruview/src/cli-ui.js | 7 +- harness/ruview/src/cli.js | 14 +- harness/ruview/src/devices/esp32.js | 63 ++++++-- harness/ruview/src/devices/esphome.js | 19 ++- harness/ruview/src/devices/lidar.js | 11 +- harness/ruview/src/devices/mmwave.js | 15 +- harness/ruview/src/devices/serial-pump.js | 5 +- harness/ruview/src/doctor.js | 30 +++- harness/ruview/src/firmware.js | 7 +- harness/ruview/src/mcp-http.js | 5 +- harness/ruview/src/sdk.d.ts | 6 +- harness/ruview/src/sdk.js | 8 +- harness/ruview/src/tools.js | 2 +- harness/ruview/src/training.js | 5 +- .../alignment-cli-window-validation.test.mjs | 28 ++++ .../ruview/test/cli-numeric-input.test.mjs | 24 +++ harness/ruview/test/cli-ui.test.mjs | 17 ++ harness/ruview/test/csi-nodes.test.mjs | 146 +++++++++++++++++- .../ruview/test/csi-relay-datagrams.test.mjs | 45 ++++++ harness/ruview/test/csi-transcode-iq.test.mjs | 34 ++++ ...csi-transcode-source-preservation.test.mjs | 34 ++++ harness/ruview/test/devices.test.mjs | 4 +- .../test/doctor-corrupt-manifest.test.mjs | 59 +++++++ .../ruview/test/esphome-role-kinds.test.mjs | 58 +++++++ harness/ruview/test/esphome.test.mjs | 85 +++++++++- harness/ruview/test/firmware.test.mjs | 40 +++++ harness/ruview/test/fixtures/device-types.mts | 18 +++ harness/ruview/test/helpers/python.mjs | 11 ++ harness/ruview/test/iphone-close.test.mjs | 57 +++++++ harness/ruview/test/ld2410-distance.test.mjs | 51 ++++++ harness/ruview/test/lidar-large-scan.test.mjs | 28 ++++ harness/ruview/test/lidar-median.test.mjs | 23 +++ harness/ruview/test/mcp-apps.test.mjs | 54 +++++++ .../ruview/test/mmwave-single-frame.test.mjs | 21 +++ harness/ruview/test/monitor-flow.test.mjs | 55 +++++++ .../test/overnight-capture-layout.test.mjs | 46 ++++++ harness/ruview/test/remote.test.mjs | 16 +- .../ruview/test/sdk-device-options.test.mjs | 78 ++++++++++ .../ruview/test/sdk-doctor-strict.test.mjs | 17 ++ harness/ruview/test/sdk.test.mjs | 15 ++ harness/ruview/test/serial-partial.test.mjs | 75 +++++++++ .../test/training-capture-collisions.test.mjs | 40 +++++ harness/ruview/test/training-time.test.mjs | 22 +++ harness/ruview/test/watch-gaps.test.mjs | 62 ++++++++ python/README.md | 7 +- python/src/bindings/bfld.rs | 10 +- python/tests/test_client_ha.py | 9 ++ python/tests/test_client_mqtt.py | 30 +++- python/tests/test_client_primitives.py | 32 +++- python/wifi_densepose/client/ha.py | 11 +- python/wifi_densepose/client/primitives.py | 14 +- scripts/align-ground-truth.js | 21 ++- scripts/collect-training-data.py | 21 ++- scripts/csi-udp-relay.py | 4 +- scripts/deep-scan.js | 2 +- scripts/esp32_jsonl_to_rvcsi.py | 15 +- scripts/overnight-empty-capture.py | 34 ++-- scripts/rf-scan-multifreq.js | 2 +- scripts/rf-scan.js | 2 +- scripts/room-fingerprint.js | 2 +- .../wifi-densepose-mtk-bridge/src/main.rs | 4 +- .../tests/listen_gate.rs | 131 +++++++++------- .../wifi-densepose-sensing-server/README.md | 22 +-- 109 files changed, 2784 insertions(+), 337 deletions(-) create mode 100644 docs/adr/ADR-383-http-request-target-validation.md create mode 100644 docs/adr/ADR-384-sdk-explicit-search-limits.md create mode 100644 docs/adr/ADR-385-watch-terminal-input-errors.md create mode 100644 docs/adr/ADR-386-csi-gap-recovery-identity.md create mode 100644 docs/adr/ADR-389-csi-observed-capture-duration.md create mode 100644 docs/adr/ADR-390-lidar-bounded-scan-extrema.md create mode 100644 docs/adr/ADR-391-csi-analysis-synthetic-provenance.md create mode 100644 docs/adr/ADR-392-mmwave-known-model-single-frame.md create mode 100644 docs/adr/ADR-393-lidar-even-sample-median.md create mode 100644 docs/adr/ADR-394-sdk-strict-doctor-errors.md create mode 100644 docs/adr/ADR-395-cli-missing-numeric-values.md create mode 100644 docs/adr/ADR-396-esphome-millimeter-distance-units.md create mode 100644 docs/adr/ADR-397-esphome-entity-update-counters.md create mode 100644 docs/adr/ADR-398-esphome-nonfinite-sensor-states.md create mode 100644 docs/adr/ADR-399-csi-dominant-capped-stream.md create mode 100644 docs/adr/ADR-400-fused-vitals-node-diagnostics.md create mode 100644 docs/adr/ADR-401-auxiliary-node-wire-payload-validation.md create mode 100644 docs/adr/ADR-402-csi-shape-window-synthetic-provenance.md create mode 100644 docs/adr/ADR-403-training-report-timestamp-normalization.md create mode 100644 docs/adr/ADR-404-cli-watch-missing-window-history.md create mode 100644 docs/adr/ADR-405-iphone-lidar-closed-stream-completion.md create mode 100644 docs/adr/ADR-406-esphome-numeric-role-entity-kinds.md create mode 100644 docs/adr/ADR-407-serial-partial-capture-status.md create mode 100644 docs/adr/ADR-408-ld2410-absent-target-distance.md create mode 100644 docs/adr/ADR-409-sdk-supported-device-argument-types.md create mode 100644 docs/adr/ADR-410-monitor-csi-flow-log-markers.md create mode 100644 docs/adr/ADR-411-doctor-corrupt-manifest-diagnostics.md create mode 100644 docs/adr/ADR-412-csi-transcode-incomplete-iq-quarantine.md create mode 100644 docs/adr/ADR-413-csi-relay-datagram-preservation.md create mode 100644 docs/adr/ADR-414-overnight-capture-adr018-decoding.md create mode 100644 docs/adr/ADR-415-training-capture-session-collisions.md create mode 100644 docs/adr/ADR-416-alignment-cli-window-validation.md create mode 100644 docs/adr/ADR-417-csi-transcode-source-preservation.md create mode 100644 docs/adr/ADR-418-esphome-rejection-fixture-reset-lifecycle.md create mode 100644 harness/ruview/test/alignment-cli-window-validation.test.mjs create mode 100644 harness/ruview/test/cli-numeric-input.test.mjs create mode 100644 harness/ruview/test/csi-relay-datagrams.test.mjs create mode 100644 harness/ruview/test/csi-transcode-iq.test.mjs create mode 100644 harness/ruview/test/csi-transcode-source-preservation.test.mjs create mode 100644 harness/ruview/test/doctor-corrupt-manifest.test.mjs create mode 100644 harness/ruview/test/esphome-role-kinds.test.mjs create mode 100644 harness/ruview/test/fixtures/device-types.mts create mode 100644 harness/ruview/test/helpers/python.mjs create mode 100644 harness/ruview/test/iphone-close.test.mjs create mode 100644 harness/ruview/test/ld2410-distance.test.mjs create mode 100644 harness/ruview/test/lidar-large-scan.test.mjs create mode 100644 harness/ruview/test/lidar-median.test.mjs create mode 100644 harness/ruview/test/mmwave-single-frame.test.mjs create mode 100644 harness/ruview/test/monitor-flow.test.mjs create mode 100644 harness/ruview/test/overnight-capture-layout.test.mjs create mode 100644 harness/ruview/test/sdk-device-options.test.mjs create mode 100644 harness/ruview/test/sdk-doctor-strict.test.mjs create mode 100644 harness/ruview/test/serial-partial.test.mjs create mode 100644 harness/ruview/test/training-capture-collisions.test.mjs create mode 100644 harness/ruview/test/training-time.test.mjs create mode 100644 harness/ruview/test/watch-gaps.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e92106c0..baa4ddb0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -501,7 +501,7 @@ jobs: notify: name: Notify runs-on: ubuntu-latest - needs: [code-quality, test, rust-tests, performance-test, docker-build, docs] + needs: [code-quality, test, rust-tests, ui-tests, performance-test, docker-build, docs] if: always() permissions: contents: write # required by softprops/action-gh-release @@ -528,18 +528,22 @@ jobs: text: '❌ CI pipeline failed for ${{ github.ref }}' - name: Create GitHub Release - if: github.ref == 'refs/heads/main' && needs.docker-build.result == 'success' + if: github.ref == 'refs/heads/main' && needs.rust-tests.result == 'success' && needs.ui-tests.result == 'success' && needs.docker-build.result == 'success' uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 with: tag_name: v${{ github.run_number }} - name: Release v${{ github.run_number }} + name: CI snapshot v${{ github.run_number }} + # CI snapshots must not replace a curated product release as Latest. + make_latest: false body: | - Automated release from CI pipeline + Source snapshot validated by the CI pipeline. This entry does not contain firmware bundles. **Changes:** ${{ github.event.head_commit.message }} - **Docker Image:** - `${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}` + **Source commit:** `${{ github.sha }}` + + Container images publish separately through the [sensing-server release workflow](https://github.com/ruvnet/RuView/actions/workflows/sensing-server-docker.yml). + Firmware downloads are attached to the dedicated `vX.Y.Z-esp32` releases. draft: false prerelease: false diff --git a/.github/workflows/ruview-harness-flywheel.yml b/.github/workflows/ruview-harness-flywheel.yml index a47105be2..e072871f0 100644 --- a/.github/workflows/ruview-harness-flywheel.yml +++ b/.github/workflows/ruview-harness-flywheel.yml @@ -4,6 +4,12 @@ on: pull_request: paths: - 'harness/ruview/**' + - 'scripts/csi-udp-relay.py' + - 'scripts/esp32_jsonl_to_rvcsi.py' + - 'scripts/overnight-empty-capture.py' + - 'scripts/record-csi-udp.py' + - 'scripts/collect-training-data.py' + - 'scripts/align-ground-truth.js' - '.github/scripts/nightly-sota/**' - '.github/workflows/nightly-sota-agent.yml' - '.github/workflows/ruview-harness-flywheel.yml' @@ -25,8 +31,12 @@ concurrency: jobs: verify: - name: Verify contributor harness - runs-on: ubuntu-latest + name: Verify contributor harness (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] defaults: run: working-directory: harness/ruview @@ -39,9 +49,13 @@ jobs: node-version: 22 cache: npm cache-dependency-path: harness/ruview/package-lock.json + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 + with: + python-version: '3.11' - run: npm ci --ignore-scripts - run: npm audit --omit=optional - run: npm test + - run: npm run test:security - run: npm run brain:verify - run: npm run flywheel:plan - run: npm run flywheel:verify diff --git a/README.md b/README.md index 08faa63a2..c48c36120 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,8 @@ RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the > | 🧱 **Through-wall sensing** | Fresnel-zone geometry + multipath modeling | Up to ~5 m, signal-dependent | > | 🧠 **Edge intelligence** | **105-cog catalog** ([ADR-102](docs/adr/ADR-102-edge-module-registry.md)) live from `app-registry.json` — health, security, building, retail, industrial, research, AI, swarm, signal, network, and developer modules. Optional Cognitum Seed adds persistent vector store + kNN + witness chain | $140 total BOM | > | 🎯 **Camera-free pre-training** | Self-supervised contrastive encoder, 12.2M training steps on 60K frames, shipped on Hugging Face | 84 s/epoch retrain on M4 Pro | -> | 📷 **Camera-supervised fine-tune** | MediaPipe + ESP32 CSI paired training, end-to-end Candle pipeline on RTX 5080 ([ADR-079](docs/adr/ADR-079-camera-supervised-pose-finetune.md)) | 2.1 s for 400 epochs (~5 ms/epoch) | -> | 📡 **Multi-frequency mesh** | Channel hopping across 6 bands, TDM slot scheduling ([ADR-029](docs/adr/ADR-029-multifrequency-mesh.md)) | 3× sensing bandwidth | +> | 📷 **Camera-supervised fine-tune** | MediaPipe + ESP32 CSI paired training, end-to-end Candle pipeline on RTX 5080 ([ADR-079](docs/adr/ADR-079-camera-ground-truth-training.md)) | 2.1 s for 400 epochs (~5 ms/epoch) | +> | 📡 **Multi-frequency mesh** | Channel hopping across 6 bands, TDM slot scheduling ([ADR-029](docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md)) | 3× sensing bandwidth | > | 🌐 **3D point cloud fusion** | Camera depth (MiDaS) + WiFi CSI + mmWave radar → unified spatial model | 22 ms pipeline · 19K+ points/frame | > > Browse the full 105-module catalog (with practical descriptions, sizes, and difficulty) below in [🧩 Edge Module Catalog](#-edge-module-catalog), or visit [seed.cognitum.one/store](https://seed.cognitum.one/store). diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 90ba94b20..1befbc18a 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -6,7 +6,9 @@ Known issues and fixes from the rebase-to-upstream branch (upstream #301). ## 1. Node not appearing in /api/v1/nodes -**Symptom:** ESP32-S3 node associates with WiFi, LED blinks, but no CSI frames arrive at the server. Node missing from `/api/v1/spatial/nodes`. +**Symptom:** ESP32-S3 node associates with WiFi, LED blinks, but no CSI frames arrive at the server. Node missing from `/api/v1/nodes`. + +**Check the server first:** the UDP receiver listens on `127.0.0.1` by default (ADR-296), so a natively run `sensing-server` never hears nodes on your network unless it was started with `--udp-bind 0.0.0.0 --udp-allow ` (or `--udp-insecure-lan`). The startup log line for the UDP receiver says `loopback-only` when this is the problem. For Docker, see "Receiving ESP32 frames in Docker" in the [user guide](user-guide.md). **Root cause:** After USB flash, the node enters a limping state where WiFi associates but the UDP CSI sender silently fails. The SoftAP + mDNS stack initializes but the CSI callback never fires. @@ -145,18 +147,18 @@ ESP32 nodes still target the host on `--target-ip :5005` — no firmware r **Symptom:** `sensing-server` starts cleanly, logs `HTTP server listening on http://localhost:3000`, but loading `http://localhost:3000/` (or `/ui/index.html`) returns `404 Not Found`. Reported in #188. -**Root cause:** The default `--ui-path ../../ui` is resolved relative to the binary's *current working directory*, not the binary location. When the binary is launched from anywhere other than `crates/wifi-densepose-sensing-server/`, the relative path doesn't reach the UI assets and Axum's static file handler returns 404. +**Root cause:** The default `--ui-path ../ui` is resolved relative to the *current working directory*, not the binary location. If it doesn't exist the server also tries `./ui` and `../../ui`. Launching from `v2/` works; from anywhere else (including `v2/crates/wifi-densepose-sensing-server/`) those paths miss the repo's UI, or hit some other `ui` directory, and Axum's static file handler returns 404. -**Fix:** Pass an absolute UI path, run the binary from the crate directory, or use the Docker image (which bundles the UI under `/app/ui`). +**Fix:** Pass an absolute UI path, run the binary from `v2/`, or use the Docker image (which bundles the UI under `/app/ui`). ```bash # Option A — absolute path (recommended for production) sensing-server --source esp32 --udp-port 5005 --http-port 3000 \ --ws-port 3001 --ui-path /absolute/path/to/ui -# Option B — run from the crate dir (works for local dev / cargo run) -cd v2/crates/wifi-densepose-sensing-server -cargo run -- --source esp32 +# Option B — run from v2/ (works for local dev / cargo run) +cd v2 +cargo run -p wifi-densepose-sensing-server -- --source esp32 # Option C — Docker (no path config needed) docker compose -f docker/docker-compose.yml up sensing-server diff --git a/docs/adr/ADR-383-http-request-target-validation.md b/docs/adr/ADR-383-http-request-target-validation.md new file mode 100644 index 000000000..cfbb21f81 --- /dev/null +++ b/docs/adr/ADR-383-http-request-target-validation.md @@ -0,0 +1,23 @@ +# ADR-383: Reject malformed HTTP request targets without stopping the transport + +Status: Proposed; maintainer acceptance pending + +## Context + +ADR-375 introduces the authenticated Streamable HTTP transport. Node accepts an HTTP request target that the WHATWG URL parser rejects. The current request listener lets that parser exception escape, terminating the process before route selection. A bounded loopback fixture using the public `startMcpHttp` API reproduces this with `http://[`; the original code resets the connection and exits. + +## Decision + +Catch URL construction failures at the request boundary and return HTTP 400 with the generic `invalid_request_target` error. Do not include the request target or parser exception in the response, since the target can carry a secret path. Keep route selection, token checks, origin policy, grants, protocol negotiation and JSON handling unchanged. + +## Alternatives + +Relying on Node's HTTP parser does not suffice: a syntactically accepted request target can still fail URL construction. A process-level exception handler would obscure unrelated faults and would not provide a request-specific response. + +## Verification + +MEASURED software fixture: the actual child-process HTTP transport fails the new regression on the original source (nine existing controls pass); the patched transport passes all ten focused tests. The new test also verifies subsequent health, rejected unauthenticated calls and successful authenticated initialization. Broader package checks are recorded separately against the final implementation revision. No external service, device, hardware, throughput or production deployment claim is made. + +## Consequences + +Malformed URL targets receive a stable client error and do not interrupt later requests. This change does not add SSE or sessions, change authentication, or authorize additional operations. diff --git a/docs/adr/ADR-384-sdk-explicit-search-limits.md b/docs/adr/ADR-384-sdk-explicit-search-limits.md new file mode 100644 index 000000000..fdd21273f --- /dev/null +++ b/docs/adr/ADR-384-sdk-explicit-search-limits.md @@ -0,0 +1,19 @@ +# ADR-384: Preserve explicitly supplied SDK search limits + +Status: Proposed; maintainer acceptance pending + +## Context + +The public `memorySearch(query, limit)` helper uses a truthiness check before forwarding its optional limit. Explicit zero and NaN therefore disappear, even though the shared registry rejects them. The helper silently returns success where the equivalent raw SDK call returns `invalid_arguments`; strict SDK users also miss the expected `RuViewError`. + +## Decision + +Omit the optional limit only when it is undefined. Forward every explicitly supplied value to the existing shared validator. Do not duplicate validation or change the registry range, search algorithm, result count, strict error policy or defaults. + +## Alternatives + +Validating separately in the helper would duplicate schema rules and risk drift. Retaining truthiness makes invalid caller inputs indistinguishable from an omitted option. + +## Verification + +MEASURED local software regression exercises the public SDK and actual registry. Original code fails for zero; the corrected helper rejects zero, NaN, negative and excessive limits, preserves strict errors, and matches raw registry results for omitted and valid limits. Package gate receipts are recorded separately against the final revision. No external retrieval, training, hardware or production service claim is made. diff --git a/docs/adr/ADR-385-watch-terminal-input-errors.md b/docs/adr/ADR-385-watch-terminal-input-errors.md new file mode 100644 index 000000000..958d8cfe0 --- /dev/null +++ b/docs/adr/ADR-385-watch-terminal-input-errors.md @@ -0,0 +1,19 @@ +# ADR-385: Stop watch mode on permanent input errors in every output mode + +Status: Proposed; maintainer acceptance pending + +## Context + +The CLI's `esp32 --watch` repeats short capture windows. Permanent bind/port errors stop the terminal view, but the piped/JSON branch continues before reaching that check. Schema-level invalid arguments also never stop either branch. Actual CLI children with a fractional UDP port or rejected bind value repeatedly emit errors despite having no usable capture configuration. These fixtures fail before opening a socket or accessing a device. + +## Decision + +Classify existing permanent port/bind errors and schema-invalid arguments before selecting output mode. Emit the error once and return exit status 1 for both terminal and JSON output. Keep repeated capture behavior for nonterminal results, including windows with no CSI packets; retain the normal watch output contract. + +## Alternatives + +Adding a delay would still repeatedly retry an unchanged invalid configuration. Stopping every unsuccessful capture would incorrectly terminate legitimate empty capture windows. + +## Verification + +MEASURED bounded actual CLI regression checks piped `--watch --json` with invalid inputs, one structured failure and nonzero exit. No network socket, hardware, external provider or live capture is exercised. Whole-package validation receipts accompany the final revision. diff --git a/docs/adr/ADR-386-csi-gap-recovery-identity.md b/docs/adr/ADR-386-csi-gap-recovery-identity.md new file mode 100644 index 000000000..fd8171ce1 --- /dev/null +++ b/docs/adr/ADR-386-csi-gap-recovery-identity.md @@ -0,0 +1,23 @@ +# ADR-386: Recover CSI packet loss by missing sequence identity + +Status: Proposed; maintainer acceptance pending + +## Context + +The RF capture diagnostics report CSI packet loss and account for UDP reordering. The existing tracker decrements its gap count for any late sequence while gaps exist. A duplicated recovery or previously received packet therefore erases a different missing packet. For example, sequences 100, 103, 101, 101 leave 102 missing but currently report zero loss. Accurate loss diagnostics matter when distinguishing transport problems from healthy sensing input. + +## Decision + +Retain the identities of counted missing sequences within the existing 1024-sequence reorder window. Recover a gap only when deleting its identity succeeds, so a duplicate cannot recover it twice. Prune identities outside the window without erasing their historical gap counts. A confirmed counter reset clears recovery identities but preserves accumulated loss. Keep the existing frame count/rate, loss denominator, stray/reset classification and packet format unchanged. + +## Alternatives + +A duplicate-only set cannot distinguish packets observed before the capture baseline from genuinely missing ones. Keeping all missing identities indefinitely is unnecessary and could grow without bound. + +## Verification + +MEASURED software fixtures use byte-accurate RAC1 envelopes, the public capture API and real loopback UDP. Regressions cover duplicated recoveries, already-received packets, pre-capture packets and distinct valid recoveries. A direct public parser/NodeStats control checks bounded bookkeeping and reset semantics. Existing sequence/stray/reset tests remain applicable. This is a software diagnostic correction, not physical RF, device throughput or physiological accuracy validation. + +## Consequences + +Loss no longer disappears when unrelated late packets arrive. Recovery remains limited to the established reorder window; older gaps stay counted. Stored identities are bounded per node. diff --git a/docs/adr/ADR-389-csi-observed-capture-duration.md b/docs/adr/ADR-389-csi-observed-capture-duration.md new file mode 100644 index 000000000..78f4374ea --- /dev/null +++ b/docs/adr/ADR-389-csi-observed-capture-duration.md @@ -0,0 +1,32 @@ +# ADR-389: Measure CSI rates over the observed capture interval + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-373 (receive-only device diagnostics) + +## Context + +The public `captureEsp32` producer may stop at `max_packets` before its configured +`seconds` deadline. Its node CSI rates still divided received packets by that +requested deadline. Two packets received in approximately 150 milliseconds with a +30-second deadline therefore appeared as 0.07 Hz, misleading operators evaluating +whether a node streams normally. + +## Decision + +Start a monotonic clock when the UDP socket successfully binds. At completion, +calculate `elapsedSeconds` and use it as the denominator for node CSI rates. +Retain `seconds` as the configured deadline for compatibility and expose the +observed interval separately. Close and analysis processing time are excluded. +A socket error before binding has a zero observed interval and no received data. + +## Verification and limits + +A deterministic-format ESP32 fixture is transmitted through an owned loopback UDP +socket into the public capture function. The baseline fails because the observed +interval is absent and the rate is 0.07 Hz. The fixed capture checks early +termination, preserves the deadline, and checks the rate against observed elapsed +time. Existing CSI decoding and capture tests remain controls. This is software +transport verification; it makes no radio, hardware, sensing-accuracy, or firmware +throughput claim. Normal deadline captures may report a slightly longer interval +because timer callbacks are scheduled by the host event loop. diff --git a/docs/adr/ADR-390-lidar-bounded-scan-extrema.md b/docs/adr/ADR-390-lidar-bounded-scan-extrema.md new file mode 100644 index 000000000..1a0e09ef9 --- /dev/null +++ b/docs/adr/ADR-390-lidar-bounded-scan-extrema.md @@ -0,0 +1,27 @@ +# ADR-390: Reduce LiDAR scan extrema without variadic argument limits + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-373 (device diagnostics) + +## Context + +`readRplidar` accepts up to 4 MiB of serial scan bytes. Its summary passed every +valid distance as an argument to `Math.min` and `Math.max`. A valid 1 MiB scan +containing 200,000 measurement nodes exceeds the JavaScript argument-stack limit +and throws `RangeError` instead of returning diagnostics. + +## Decision + +Reduce extrema with a loop and constant auxiliary state. Retain current median, +zero-distance filtering, coverage, units, and empty-scan behavior. This avoids +runtime-specific argument limits across the full supported capture size. + +## Verification and limits + +An offline serial-pump dependency supplies protocol-valid bytes to the public +`readRplidar` producer. The 3-point control passes on the baseline; the 200,000 +point fixture fails with `RangeError`. Both pass after this change with exact +minimum, median, maximum, and coverage checks. Existing device tests provide +empty and mixed-input controls. The fixture uses no serial device or motor and +proves software decoding/aggregation only, not real hardware performance. diff --git a/docs/adr/ADR-391-csi-analysis-synthetic-provenance.md b/docs/adr/ADR-391-csi-analysis-synthetic-provenance.md new file mode 100644 index 000000000..ffb81d337 --- /dev/null +++ b/docs/adr/ADR-391-csi-analysis-synthetic-provenance.md @@ -0,0 +1,29 @@ +# ADR-391: Retain synthetic CSI provenance in analysis input + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-323 (RAC1 synthetic flag), ADR-373 (CSI analysis) + +## Context + +The RAC1 decoder exposes the firmware's synthetic flag and the waterfall retains +it, but the kernel-analysis frame collector discarded it. Analysis of synthetic +input consequently returned the same live-CSI explanatory note as unmarked input, +preventing callers from distinguishing synthetic-inclusive estimates. + +## Decision + +Track whether any retained analysis frame is firmware-marked synthetic, expose +that boolean in `analysis.input.synthetic`, and explicitly describe synthetic +input in the estimate note. Frames rejected by the analysis buffer cap do not +change the provenance of the retained computation. Keep arrival-rate measurements +and raw packet diagnostics unchanged. An unmarked packet is not proof of physical +radio origin; this records an explicit upstream flag rather than certifying truth. + +## Verification + +The public capture producer receives protocol-valid RAC1 datagrams through owned +loopback UDP and invokes an injected deterministic analyzer. A mixed marked and +unmarked window fails the baseline provenance assertion. The fixed path retains +true for the mixed window and false for an unmarked control. This validates +software provenance propagation only, not sensing accuracy or hardware behavior. diff --git a/docs/adr/ADR-392-mmwave-known-model-single-frame.md b/docs/adr/ADR-392-mmwave-known-model-single-frame.md new file mode 100644 index 000000000..bdcefba3e --- /dev/null +++ b/docs/adr/ADR-392-mmwave-known-model-single-frame.md @@ -0,0 +1,27 @@ +# ADR-392: Return single valid frames for explicitly selected radar models + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-373 (host device diagnostics) + +## Context + +The serial radar reader requires two valid frames before auto-detecting a model. +That confidence threshold was also applied to an explicitly selected model, +rejecting a valid short capture with one decoded presence or vital report as +`no_valid_frames`. The caller already supplied the model and baud selection. + +## Decision + +Require two frames for auto detection and one for an explicitly selected model. +Retain the empty-capture failure, checksums, parsing, capture permissions, and +all reported values. A single decoded frame is a successful software read and +is not evidence of clinical accuracy or sustained radar performance. + +## Verification + +An offline serial-pump fixture supplies one checksum-valid MR60BHA2 presence +report through the public reader. Explicit model selection fails on the baseline +and returns one frame with presentFraction=1 after the change. The same input in +auto mode remains refused. Existing multi-frame and invalid-input device tests +remain controls. No serial port or hardware was opened by this verification. diff --git a/docs/adr/ADR-393-lidar-even-sample-median.md b/docs/adr/ADR-393-lidar-even-sample-median.md new file mode 100644 index 000000000..4cb46dbc5 --- /dev/null +++ b/docs/adr/ADR-393-lidar-even-sample-median.md @@ -0,0 +1,28 @@ +# ADR-393: Use the arithmetic median for even LiDAR samples + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-373 (range and depth diagnostics) + +## Context + +The shared LiDAR median helper selected the upper middle sample for even-sized +sets. The public depth reader reported 4 metres for confident depths of 1 and +4 metres, and the serial scan reader likewise returned 4000 mm. Fields named +median should report the arithmetic median, 2.5 metres or 2500 mm respectively. + +## Decision + +Average the two central sorted samples for even counts and preserve the central +sample for odd counts. Empty sets remain null. Existing distance/confidence +filtering and metre/millimetre conversion remain in the public producers. +Update existing even-sample expectations to reflect the corrected numerical +contract; this intentionally changes these statistics for even counts. + +## Verification + +Offline protocol fixtures enter both public readers through their existing +transport injection boundaries. The baseline fails two even-sample cases and +passes an unsorted odd-sample control. The correction verifies both units and +zero-depth exclusion. No real LiDAR, serial device, or radar was used, and this +software calculation does not prove measurement accuracy. diff --git a/docs/adr/ADR-394-sdk-strict-doctor-errors.md b/docs/adr/ADR-394-sdk-strict-doctor-errors.md new file mode 100644 index 000000000..6436119bb --- /dev/null +++ b/docs/adr/ADR-394-sdk-strict-doctor-errors.md @@ -0,0 +1,26 @@ +# ADR-394: Apply strict SDK errors to diagnostic reports + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-369 (SDK operator interface) + +## Context + +A strict SDK instance throws `RuViewError` when an operation returns `ok:false`. +The doctor helper directly invokes the diagnostic producer to retain injectable +operator dependencies, bypassing that shared result check. Failed diagnostics +therefore silently resolved even when the caller selected strict mode. + +## Decision + +Extract the existing result check and apply it to both registry calls and doctor +reports. Preserve injected dependencies and retain the original structured report +on the thrown error. Non-strict calls and successful diagnostics continue to +resolve reports; the existing dry-run exception remains unchanged. + +## Verification + +The public SDK doctor runs a deterministic offline invalid-protocol diagnostic. +The baseline fails the promised strict rejection. The correction checks the exact +error class, tool identifier, and failed report; controls verify non-strict failure +reports and successful strict reports. No external service or device is accessed. diff --git a/docs/adr/ADR-395-cli-missing-numeric-values.md b/docs/adr/ADR-395-cli-missing-numeric-values.md new file mode 100644 index 000000000..ae1457506 --- /dev/null +++ b/docs/adr/ADR-395-cli-missing-numeric-values.md @@ -0,0 +1,27 @@ +# ADR-395: Preserve missing numeric CLI values for schema rejection + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-369 (operator CLI) + +## Context + +The common device/training CLI flag adapter coerces values with `Number`. A bare +`--node-id` is parsed as boolean true and becomes node 1; `--node-id=` becomes +node 0. Both silently select a real node rather than reporting the missing +numeric argument. The same adapter handles capture deadlines and sample limits. + +## Decision + +Retain boolean and empty-string values in the common schema argument adapter so +existing numeric schemas reject them. Convert explicit non-empty numeric values +as before, including zero. Keep presentation flags and tool schemas unchanged. +This change is limited to verbs using the common snake-case argument adapter. + +## Verification + +Execute the actual CLI process with both missing-value forms and explicit zero. +An intentionally invalid UDP port ensures argument validation completes before +opening a socket. Baseline errors omit node_id for both missing forms; the fixed +CLI reports those numeric type errors while preserving the explicit zero control. +This is offline CLI/schema verification, not a radio capture. diff --git a/docs/adr/ADR-396-esphome-millimeter-distance-units.md b/docs/adr/ADR-396-esphome-millimeter-distance-units.md new file mode 100644 index 000000000..178996a4f --- /dev/null +++ b/docs/adr/ADR-396-esphome-millimeter-distance-units.md @@ -0,0 +1,27 @@ +# ADR-396: Convert ESPHome millimetre distances to reported centimetres + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-373 Amendment 3 (ESPHome radar reader) + +## Context + +ESPHome sensor discovery advertises each entity's unit. The reader maps entities +named distance into `distanceCmMean`, converting metres to centimetres but leaving +millimetres unchanged. An advertised 500 mm measurement therefore became 500 cm +instead of 50 cm, a factor-of-ten numerical error. + +## Decision + +Apply unit scales of 100 for metres and 0.1 for millimetres. Preserve centimetre +and existing unspecified/other-unit behavior, positive-distance filtering, and +entity metadata. Unit matching remains case insensitive. This is a correction +to reported units, not a change to sensor discovery or access permissions. + +## Verification + +The public reader connects to an owned loopback TCP fixture speaking the native +ESPHome protocol. Discovery and state updates carry 500 mm, 0.5 m, and 50 cm. +The millimetre baseline fails with 500 cm; metre and centimetre controls pass. +All three report 50 cm after the correction. This proves software protocol/unit +handling only; no real radar or external network service was accessed. diff --git a/docs/adr/ADR-397-esphome-entity-update-counters.md b/docs/adr/ADR-397-esphome-entity-update-counters.md new file mode 100644 index 000000000..3bb5ab819 --- /dev/null +++ b/docs/adr/ADR-397-esphome-entity-update-counters.md @@ -0,0 +1,27 @@ +# ADR-397: Distinguish ESPHome received updates from retained samples + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-373 Amendment 3 (bounded ESPHome sessions) + +## Context + +ESPHome sessions count every incoming state update, but each entity retains only +its first 10,000 samples. The entity report used the retained length as `updates`, +understating actual arrivals once this cap was reached. Mean values also came from +that bounded subset without exposing how many updates were omitted. + +## Decision + +Maintain a separate arrival counter per entity. Report that count as `updates`, +retain `sampledUpdates` for the aggregation subset, and disclose `droppedSamples`. +Preserve the existing buffer bound and latest-value behavior. Existing hand-built +summary sessions without the new counter fall back to their retained sample count. + +## Verification + +An owned loopback native-protocol fixture emits 10,001 distance state updates +through the public reader. The baseline reports session=10,001 but entity=10,000. +The correction reports received=10,001, retained=10,000, omitted=1, with unchanged +50 cm mean. An existing small multi-entity fixture verifies zero omitted samples +and correct ordinary counts. This is software aggregation verification only. diff --git a/docs/adr/ADR-398-esphome-nonfinite-sensor-states.md b/docs/adr/ADR-398-esphome-nonfinite-sensor-states.md new file mode 100644 index 000000000..74ed04524 --- /dev/null +++ b/docs/adr/ADR-398-esphome-nonfinite-sensor-states.md @@ -0,0 +1,28 @@ +# ADR-398: Treat nonfinite ESPHome sensor states as unavailable + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-373 Amendment 3 (native sensor-state semantics) + +## Context + +Native protobuf float states can encode NaN and infinities without setting the +missing-state flag. Positive infinity passed the distance filter and poisoned an +otherwise finite distance mean; last entity states also exposed nonfinite values +that become null only after JSON serialization, producing inconsistent reports. + +## Decision + +Treat a nonfinite sensor float as missing when ingesting a state. Preserve state +arrival counts, normal finite values, proto3 absent-float zero, explicit missing +flags, and binary/text state behavior. Existing summary rules then exclude the +invalid sample from means and report unavailable last state as null. + +## Verification + +Owned loopback native-protocol fixtures emit a 50 cm state followed by explicitly +encoded NaN, positive infinity, or negative infinity. Corrected baseline fixtures +fail on the nonfinite mean or last-state result; all fixed cases retain the 50 cm +mean, null latest state, and two arrival counts. The existing fixture controls +finite vitals, explicit missing states, and omitted proto3 zero values. No real +sensor or external service is accessed; this proves software numeric handling. diff --git a/docs/adr/ADR-399-csi-dominant-capped-stream.md b/docs/adr/ADR-399-csi-dominant-capped-stream.md new file mode 100644 index 000000000..2b603283e --- /dev/null +++ b/docs/adr/ADR-399-csi-dominant-capped-stream.md @@ -0,0 +1,29 @@ +# ADR-399: Select the busiest CSI stream using total arrivals + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-373 (bounded live CSI analysis) + +## Context + +The analysis collector advertises selection of the busiest node's dominant shape. +It ranks buffers by their retained frame length. Once multiple streams reach the +analysis buffer cap, their retained lengths tie and insertion order wins even +when a later stream received substantially more frames. A 70-frame first node +therefore beats a 100-frame second node under a 64-frame cap. + +## Decision + +Rank candidate streams by retained frames plus their existing dropped-over-cap +counter. Preserve the frame cap, first-window sample rate, explicit node filter, +and deterministic insertion order for actual arrival-count ties. The analyzer +still consumes only the retained bounded samples; ranking does not invent data. + +## Verification + +The public capture receives two protocol-valid node streams through owned +loopback UDP. The baseline selects the 70-frame node despite the second node's +100 arrivals. The correction selects node 2 and reports 64 retained/36 omitted +frames. An explicit node-1 control still selects node 1 and reports 64/6. +Existing uncapped analysis and decoding tests remain controls. This is software +stream-selection verification, not hardware throughput or sensing accuracy. diff --git a/docs/adr/ADR-400-fused-vitals-node-diagnostics.md b/docs/adr/ADR-400-fused-vitals-node-diagnostics.md new file mode 100644 index 000000000..89ea0eaf2 --- /dev/null +++ b/docs/adr/ADR-400-fused-vitals-node-diagnostics.md @@ -0,0 +1,31 @@ +# ADR-400: Decode fused vitals into node diagnostics + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-063 (fusion), ADR-373 (node diagnostics) + +## Context + +The capture parser recognizes firmware magic `0xC5110004` as fused vitals but +returns only a byte count. Node aggregation then omits its RSSI and physiological +values. A valid fused-only stream therefore has null `lastVitals` even though the +firmware supplies the current fused heart and breathing estimates. + +## Decision + +Decode the exact 48-byte packed `edge_fused_vitals_pkt_t` from +`firmware/esp32-csi-node/main/edge_processing.h`. Share the common fields and fixed +point unit conversions with ordinary vitals, then expose the mmWave presence, +type, confidence, raw heart/breathing values, distance in centimetres, and target +count from their actual offsets. Aggregate fused reports into RSSI and latest +vitals while retaining their distinct packet kind counter. Reject truncated +fused reports before they can count as decoded node data. + +## Verification and limits + +A firmware-layout fixture enters the public capture over owned loopback UDP. +The baseline counts the packet but returns null RSSI and latest vitals. Fixed +assertions check 72 bpm fused heart, 15 bpm breathing, 120 cm distance, confidence +and target values, and timestamp units. Truncated-prefix tests and existing +ordinary-vitals tests remain controls. These are decoded device-reported values; +no clinical accuracy, real radio, or sensor-fusion quality is established. diff --git a/docs/adr/ADR-401-auxiliary-node-wire-payload-validation.md b/docs/adr/ADR-401-auxiliary-node-wire-payload-validation.md new file mode 100644 index 000000000..3dd0b9112 --- /dev/null +++ b/docs/adr/ADR-401-auxiliary-node-wire-payload-validation.md @@ -0,0 +1,35 @@ +# ADR-401: Validate auxiliary node datagrams before counting decoded reports + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-069 (features), ADR-039 (compression), feature-state and WASM firmware formats + +## Context + +Known auxiliary magics were sufficient to count a datagram as decoded. A +four-byte feature, compressed, feature-state, or WASM prefix created a phantom +node and successful diagnostic counts. Corrupt feature-state CRCs and truncated +variable payloads were also accepted, hiding actual data corruption. + +## Decision + +Validate firmware-defined payload envelopes before retaining their existing +metadata-only report shape: features require their 48-byte record, compressed +reports require a 10-byte header and declared non-empty payload, feature-state +requires the fixed 60-byte record and its IEEE CRC, and WASM output requires +an 8-byte header plus five bytes for each of at most 16 declared events. Keep +raw auxiliary payloads out of the response and retain existing kind counters. + +Source contracts are `edge_processing.h`/`send_compressed_frame` in +`edge_processing.c`, `rv_feature_state.h`, and the variable-length producer in +`wasm_runtime.c`. This validates envelope integrity without claiming to decompress +CSI or interpret feature/event semantics. + +## Verification + +Owned loopback UDP drives the public capture with four complete envelope controls, +four magic-only prefixes, one corrupt CRC, and three truncated payloads. The +baseline counts all twelve as decoded and hides corruption. The correction counts +four decoded and eight malformed, with one valid node and exact kind counters. +Existing CSI, vitals, and other packet tests remain controls. No firmware or real +sensor was exercised; the claim is software format and diagnostic correctness. diff --git a/docs/adr/ADR-402-csi-shape-window-synthetic-provenance.md b/docs/adr/ADR-402-csi-shape-window-synthetic-provenance.md new file mode 100644 index 000000000..d1b5759d0 --- /dev/null +++ b/docs/adr/ADR-402-csi-shape-window-synthetic-provenance.md @@ -0,0 +1,29 @@ +# ADR-402: Preserve synthetic provenance for the whole CSI shape window + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-323 (RAC1 flags), ADR-373 (per-shape node diagnostics) + +## Context + +A normal capture's shape summary averages all frames but reports the synthetic +flag from only its latest packet. A marked frame followed by an unmarked frame +therefore contributes to the mean while removing the synthetic label shown by +normal CLI diagnostics. The waterfall already preserves a window-wide flag; +kernel-analysis input has a separate collector and provenance boundary. + +## Decision + +Track whether any frame in each statistical shape window is firmware-marked +synthetic and report that flag alongside the window mean/count. Preserve the +latest packet's other descriptor fields and keep provenance independent for each +shape. An unmarked window still reports false; that is not certification of its +physical origin. No capture or hardware authority changes. + +## Verification + +The public ordinary capture receives a marked RAC1 frame then an unmarked frame +through owned loopback UDP. The baseline reports two aggregated frames and false +synthetic provenance. The correction reports true; an entirely unmarked control +remains false. Existing shape and source statistics remain controls. This verifies +software window provenance, not sensing accuracy or authentic radio origin. diff --git a/docs/adr/ADR-403-training-report-timestamp-normalization.md b/docs/adr/ADR-403-training-report-timestamp-normalization.md new file mode 100644 index 000000000..c491fe37f --- /dev/null +++ b/docs/adr/ADR-403-training-report-timestamp-normalization.md @@ -0,0 +1,28 @@ +# ADR-403: Normalize chronological report timestamps without truthiness + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-371 (offline training evidence gate) + +## Context + +The gate used `Date.parse(value) || numericFallback`. Valid epoch-zero ISO times +parse to zero and were then treated as missing. Documented numeric millisecond +boundaries were first interpreted as calendar strings by Date.parse, so 5 ms and +30 ms could be misordered and falsely refused as temporal leakage. + +## Decision + +Retain finite numeric boundaries as milliseconds and parse string boundaries +once. Test finiteness explicitly rather than using truthiness. Preserve refusal +of invalid/missing dates and non-increasing train/test chronology. The tool schema +continues accepting its existing ISO string input; the public training export +retains its documented numeric input support. + +## Verification + +The actual SDK evaluates an epoch-zero ISO boundary with a later test timestamp. +The public training export evaluates numeric 5 ms and 30 ms boundaries. Both +baseline cases are falsely refused and pass after normalization. Equal-time and +invalid-date controls remain refused. Fixtures are explicitly synthetic; no +training, model calls, dataset claims, or publication is performed by these tests. diff --git a/docs/adr/ADR-404-cli-watch-missing-window-history.md b/docs/adr/ADR-404-cli-watch-missing-window-history.md new file mode 100644 index 000000000..8806fd024 --- /dev/null +++ b/docs/adr/ADR-404-cli-watch-missing-window-history.md @@ -0,0 +1,31 @@ +# ADR-404: Preserve missing capture windows in CLI watch trends + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-375 (per-node watch rate sparklines) + +## Context + +Watch draws repeated timed capture windows, but appends rate history only for +nodes present in a window. A present–absent–present node is therefore shown as +two adjacent observations, hiding the intervening outage. The sparkline helper +also discards missing positions, so merely adding null history entries would +still compress time. An actual terminal-mode CLI and owned loopback UDP replay +reproduces the false continuous two-bar trend. + +## Decision + +Append a missing entry for each previously observed node absent from a capture +window. Keep the existing 30-window bound. Render each missing position as `·`, +while scaling finite rates using only finite observations. Missing means no +observation in that window; it is not a measured zero rate. Initial history +still starts when a node is first observed. JSON capture output is unchanged. + +## Verification + +The public CLI receives one valid packet, an empty one-second capture, and one +returning packet. Its terminal row must show `▄·▄` instead of `▄▄`. A real child +process executes the CLI; only stdout's TTY capability is supplied by the test. +The test owns and cleans up its loopback sockets and child. Finite, zero, +all-missing and empty sparkline controls preserve scale and positions. This is +software replay, not a claim about radio hardware availability. diff --git a/docs/adr/ADR-405-iphone-lidar-closed-stream-completion.md b/docs/adr/ADR-405-iphone-lidar-closed-stream-completion.md new file mode 100644 index 000000000..fcca7ec07 --- /dev/null +++ b/docs/adr/ADR-405-iphone-lidar-closed-stream-completion.md @@ -0,0 +1,31 @@ +# ADR-405: Complete iPhone LiDAR captures when the relay stream closes + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-373 (bounded device readers) + +## Context + +The iPhone reader completes at its packet limit, timeout, or pre-open failure. +After an opened WebSocket closes normally, however, it keeps the capture timer +running despite having no remaining source of frames. A one-frame stream that +closes immediately waits the entire requested window; an empty normal stream +also delays its no_frames result. This wastes the user's capture time and uses +the idle post-close interval in its reported receive rate. + +## Decision + +Finish on every close event. Retain accepted depth statistics. Preserve the +existing relay_closed refusal for abnormal empty streams, pre-open connection +failure, and max_frames termination. A normal empty stream still returns +no_frames; a stream with valid depth frames remains a successful capture. The +requested seconds field retains its existing meaning. + +## Verification + +An owned loopback HTTP server performs a real WebSocket upgrade and sends a +valid one-pixel depth frame followed by a normal close handshake. The public +reader must return promptly instead of waiting its two-second capture timeout. +A normal empty stream and abnormal empty code1008 stream check refusal behavior; +a max_frames=1 control checks existing early termination. These are transport +fixtures, not iPhone hardware or depth-accuracy evidence. diff --git a/docs/adr/ADR-406-esphome-numeric-role-entity-kinds.md b/docs/adr/ADR-406-esphome-numeric-role-entity-kinds.md new file mode 100644 index 000000000..7e0e03d52 --- /dev/null +++ b/docs/adr/ADR-406-esphome-numeric-role-entity-kinds.md @@ -0,0 +1,31 @@ +# ADR-406: Assign numeric radar roles only to ESPHome sensor entities + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-373 Amendment 3 (ESPHome native API reader) + +## Context + +The reader infers heart, breathing and distance roles from entity names without +checking their protocol kind. A binary Heart alarm or text Distance status can +therefore be selected before the actual numeric sensor. Boolean true becomes +1 bpm through arithmetic coercion; a numeric-looking text status becomes a +fabricated distance. The actual numeric sensor is then ignored. A loopback +native API producer reproduces this with valid entity metadata and states. + +## Decision + +Require sensor kind for heartBpm, breathingBpm and distanceCm roles, matching +the existing targets rule. Keep binary presence inference. Preserve binary and +text entities and their raw states in the entities result, without assigning +them numeric radar roles. Numeric scalar filtering and unit handling remain +unchanged. No medical accuracy or sensor-name taxonomy is introduced. + +## Verification + +A real TCP producer lists binary alarm and text status entities before valid +numeric heart, distance and breathing sensors. The public reader must select +72 bpm,120 cm and15 bpm rather than1,50 and1. A producer with only nonnumeric +entities must return null numeric summaries while retaining its3 raw updates. +Existing ESPHome presence, missing-state and numeric sensor controls run too. +These are deterministic native API fixtures, not radio or clinical evidence. diff --git a/docs/adr/ADR-407-serial-partial-capture-status.md b/docs/adr/ADR-407-serial-partial-capture-status.md new file mode 100644 index 000000000..91b7e6eda --- /dev/null +++ b/docs/adr/ADR-407-serial-partial-capture-status.md @@ -0,0 +1,32 @@ +# ADR-407: Preserve interrupted serial-capture state in device summaries + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-373 (bounded serial pump and device readers) + +## Context + +The pump can emit valid HEX chunks before its process fails. readSerial retains +those useful bytes, but discards the failure whenever bytes exist. Radar and +LiDAR callers consequently present an ordinary successful capture without any +indication that the requested window was interrupted. Auto-detected radar also +silently retains only its probe if the remainder capture fails. + +## Decision + +Keep valid partial bytes and measurements. Mark the capture as incomplete and +include a warning when the pump process fails after producing data. Propagate +that state through both serial device summaries, including an auto-detection +remainder failure. Do not expose subprocess stderr in the warning. Existing +failures without bytes and normal complete capture behavior remain unchanged. +The flag describes transport completeness, not measurement accuracy. + +## Verification + +The actual Python PUMP_SCRIPT runs through the production execTool process +runner, with a temporary fixture serial module that emits valid radar or scan +bytes then raises. The public serial, radar and LiDAR readers must retain data +and report captureIncomplete. A real normally completing pump is the control. +Auto-detection also receives a normal probe followed by a failing empty pump. +All serial port operations stay inside the fixture module; no hardware opens, +physical motor commands, radio transmissions or model calls occur. diff --git a/docs/adr/ADR-408-ld2410-absent-target-distance.md b/docs/adr/ADR-408-ld2410-absent-target-distance.md new file mode 100644 index 000000000..2aa3f4105 --- /dev/null +++ b/docs/adr/ADR-408-ld2410-absent-target-distance.md @@ -0,0 +1,31 @@ +# ADR-408: Exclude absent LD2410 targets from distance means + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-373 (shared mmWave summary) + +## Context + +The LD2410 decoder explicitly emits present=false and distanceCm=0 for target +state none. The aggregate treats that no-target sentinel as a measured range. +A 90 cm detection followed by an absent target consequently reports 45 cm; an +all-absent stream reports 0 cm instead of having no distance observation. The +presence fraction already identifies these absent frames correctly. + +## Decision + +Exclude explicitly absent target frames only from the distance mean. Keep their +raw distance sentinel, frame counts, rates, and presence contribution. Continue +averaging moving and stationary target measurements normally. MR60BHA2 distance +frames are unchanged; they use their own range-validity field in the decoder. +This distinguishes absence from a distance measurement, without new physical +range limits or an accuracy claim. + +## Verification + +The actual Python serial pump and production process runner read complete +LD2410 frames from an isolated serial module. Public readMmwave must report 90 cm +for present 90/absent, null for all-absent, and 150 cm for moving 90/static 210. +Presence fractions and raw last-frame zero controls remain intact. The baseline +fails both absence regressions and passes the positive-distance control. No +hardware port opens or radar transmissions occur. diff --git a/docs/adr/ADR-409-sdk-supported-device-argument-types.md b/docs/adr/ADR-409-sdk-supported-device-argument-types.md new file mode 100644 index 000000000..8b7c26a7c --- /dev/null +++ b/docs/adr/ADR-409-sdk-supported-device-argument-types.md @@ -0,0 +1,35 @@ +# ADR-409: Align SDK device argument types with supported runtime options + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-369 (typed SDK), ADR-373 (device readers), ADR-378 (spectrum) + +## Context + +The SDK forwards device arguments to the current tool registry, but its shipped +TypeScript declarations still describe earlier options. A real TypeScript +consumer cannot select ESPHome by source/host/API port, request ESP32 spectrum +rows, or set an iPhone depth packet cap. Each workflow already works through the +same public SDK at runtime. Explicit serial source is missing too. + +## Decision + +Describe mmWave arguments as serial (the existing default, requiring port) or +ESPHome (requiring host). Add the existing optional spectrum parameters and +iPhone max_frames. Keep numeric and enum constraints, and all existing serial +call shapes. This changes declarations only; no reader or tool behavior changes. +Group these omissions as one declaration-drift fix rather than separate fields. + +## Verification + +A TypeScript consumer compiled with strict NodeNext checks fails on the valid +runtime options before the fix and succeeds after it. Its expected-error controls +retain host/port requirements, numeric parameter types, and known backends. The +same public SDK is exercised with real owned TCP ESPHome, UDP CSI, and WebSocket +depth producers: numeric state, spectrum bins, and packet-cap termination all +work before and after the declaration change. No hardware or external service +is involved. The fixture can be replayed with an installed TypeScript compiler: + +``` +tsc --strict --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext test/fixtures/device-types.mts +``` diff --git a/docs/adr/ADR-410-monitor-csi-flow-log-markers.md b/docs/adr/ADR-410-monitor-csi-flow-log-markers.md new file mode 100644 index 000000000..c92966e0f --- /dev/null +++ b/docs/adr/ADR-410-monitor-csi-flow-log-markers.md @@ -0,0 +1,32 @@ +# ADR-410: Require callback/frame markers for CSI monitor flow evidence + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-373 (serial monitor), ADR-375 (starvation diagnostics) + +## Context + +The monitor counted any line containing the ESP32 csi_collector log tag as a +CSI callback. Current firmware uses that tag for early configuration, startup, +self-ping failures and other metadata as well as actual callbacks. A startup +log with no callback consequently returned ok=true and falsely certified flow. +The source's actual callback line uses `CSI cb #`; Realtek uses +`RUVIEW_CSI: frame #`. + +## Decision + +Count only those explicit callback/frame markers as flow evidence. Keep the +existing startup-upgrade metadata and independent buffer-starvation detection. +The existing count remains a count of observed rate-limited log markers, not a +radio callback-rate measurement. No UART, reset or hardware control behavior +changes. Older log formats with only configuration tags cannot certify flow. + +## Verification + +The public SDK monitor runs the actual Python monitor script in a real child +process. An isolated serial module supplies current firmware startup/self-ping +messages; the baseline falsely succeeds, and the fix returns no-flow diagnostics. +Mixed startup plus ESP32/Realtek frame markers counts only two observations. +A real frame plus a starvation message still fails as csi_buffer_starvation. +Existing monitor rendering and Python fixture controls run too. No physical +serial port opens or node resets occur. diff --git a/docs/adr/ADR-411-doctor-corrupt-manifest-diagnostics.md b/docs/adr/ADR-411-doctor-corrupt-manifest-diagnostics.md new file mode 100644 index 000000000..eeb935a3a --- /dev/null +++ b/docs/adr/ADR-411-doctor-corrupt-manifest-diagnostics.md @@ -0,0 +1,32 @@ +# ADR-411: Preserve structured doctor diagnostics for package corruption + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-370 (doctor), ADR-394 (SDK strict diagnostic contract) +- Promotion: extend existing doctor PR #2208; no separate publication unit + +## Context + +The package-integrity diagnostic reports hash drift as a structured warning, but +malformed manifest JSON or a missing outer checksum throws before the doctor +report is assembled. The public SDK therefore cannot return the promised +checks/remedies for those ordinary package-corruption cases. A copied-package +fixture reproduces both exceptions without touching the installed manifest. + +## Decision + +Convert manifest read/parse and packaged-file read failures into integrity +findings. Report a missing or unreadable checksum explicitly. Preserve existing +hash comparison, file counts, diagnostic report shape and warning policy. SDK +strict mode continues rejecting failed reports through the existing checked +path; integrity warnings retain their current warning behavior. No additional +manifest schema or trust policy is introduced. + +## Verification + +The actual public SDK is imported from an owned temporary copy containing the +package source and reviewed brain. Strict doctor with groups=harness must return +an integrity warning and reinstall remedy for malformed JSON, a missing or +unreadable checksum, and unreadable packaged data. The valid-copy control retains successful integrity status. Existing +doctor and strict failed-report controls run alongside these tests. No canonical +manifest changes, credentials, network requests or hardware operations occur. diff --git a/docs/adr/ADR-412-csi-transcode-incomplete-iq-quarantine.md b/docs/adr/ADR-412-csi-transcode-incomplete-iq-quarantine.md new file mode 100644 index 000000000..869077a95 --- /dev/null +++ b/docs/adr/ADR-412-csi-transcode-incomplete-iq-quarantine.md @@ -0,0 +1,30 @@ +# ADR-412: Quarantine incomplete recorded I/Q pairs during transcode + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-096 and ADR-097 (rvCSI capture bridge) + +## Context + +The public ESP32 JSONL converter is the documented capture fixture bridge. Its +contract drops rejected frames. It currently floors odd byte lengths, silently +accepting a truncated I/Q pair as a shorter valid frame; malformed hex instead +aborts the entire capture and loses later valid frames. The existing rej_len +counter is never used. + +## Decision + +Reject invalid hex and odd byte lengths before constructing numeric samples. +Increment the existing rej_len counter once for each rejected record and +continue processing later frames. Preserve complete int8 pair conversion, +quality scoring, timestamps, and output format. Do not infer missing samples or +change adapter calibration or hardware policy. + +## Verification + +Actual public Python CLI processes owned temporary JSONL captures with valid +frames before and after each damaged record. The baseline accepts the odd pair +and aborts on invalid hex. Fixed runs retain the two complete frames with +contiguous frame IDs and truthful counters. A signed-IQ control verifies +amplitude and real/imaginary orientation. All eight harness gates run alongside +these subprocess tests. This is offline recording evidence only. diff --git a/docs/adr/ADR-413-csi-relay-datagram-preservation.md b/docs/adr/ADR-413-csi-relay-datagram-preservation.md new file mode 100644 index 000000000..165de15d6 --- /dev/null +++ b/docs/adr/ADR-413-csi-relay-datagram-preservation.md @@ -0,0 +1,29 @@ +# ADR-413: Preserve complete UDP datagrams in the CSI relay + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-018 (ESP32 CSI packet layout) + +## Context + +The public Python relay promises byte-for-byte forwarding without parsing or +mutation. Its 2048-byte receive buffer truncates larger datagrams; a valid +ADR-018 shape with four antennas and 256 subcarriers occupies 2068 bytes. Its +truthiness check also discards empty UDP datagrams instead of forwarding them. +Both behaviors violate the transparent relay contract. + +## Decision + +Receive up to 65535 bytes, sufficient for a complete IPv4 UDP payload, and +forward every received datagram, including an empty payload. Retain None as the +socket-timeout sentinel. Preserve bind defaults, destination selection, +reporting and cleanup. Introduce no packet interpretation or hardware policy. + +## Verification + +Actual public Python CLI relays traffic between owned loopback sockets. The +baseline truncates a complete 2068-byte ADR-018 packet and fails to forward an +empty datagram; a small binary payload is unchanged. Fixed runs compare every +byte and retain both controls. All eight harness gates run with these actual +subprocess tests. This proves local datagram preservation, not physical CSI +capture quality or network performance. diff --git a/docs/adr/ADR-414-overnight-capture-adr018-decoding.md b/docs/adr/ADR-414-overnight-capture-adr018-decoding.md new file mode 100644 index 000000000..991498946 --- /dev/null +++ b/docs/adr/ADR-414-overnight-capture-adr018-decoding.md @@ -0,0 +1,31 @@ +# ADR-414: Use ADR-018 decoding for overnight capture segments + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-018 (wire layout), ADR-135 (empty-room corpus) + +## Context + +The overnight empty-room recorder claims the same ADR-018 layout as the ordinary +UDP recorder, but still reads an obsolete eight-byte header. It records the +subcarrier-count bytes as RSSI/channel, consumes the remaining header as I/Q, +and accepts unrelated or truncated datagrams. This corrupts baseline corpus +samples before downstream analysis. + +## Decision + +Reuse record-csi-udp.py's existing twenty-byte ADR-018 parser through a local +module import. Preserve segment naming, duration and receive workflow. The +shared parser retains receive timestamps, decodes metadata and declared +antenna-major payload, ignores trailing bytes, and rejects unsupported magic or +truncated payloads. Avoid a second copy of the packet layout and do not alter +quality, calibration or physical capture policy. + +## Verification + +Actual public overnight CLI writes owned temporary segment files from loopback +UDP traffic. Baseline reports the wrong RSSI, channel and payload and retains +invalid packets. Fixed tests verify declared multi-antenna amplitudes and raw IQ, +metadata, trailing-byte handling, and valid-frame continuity after invalid or +truncated datagrams. All eight harness gates run alongside these tests. +This is deterministic software packet evidence only; no hardware accuracy claim. diff --git a/docs/adr/ADR-415-training-capture-session-collisions.md b/docs/adr/ADR-415-training-capture-session-collisions.md new file mode 100644 index 000000000..802ea54a5 --- /dev/null +++ b/docs/adr/ADR-415-training-capture-session-collisions.md @@ -0,0 +1,31 @@ +# ADR-415: Allocate distinct training capture files on session-name collisions + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-018 (collector wire input) + +## Context + +The training collector names sessions to whole seconds and opens recordings in +append mode. Two captures with the same label during that second append samples +to one file and overwrite its metadata with only the later frame count. Dataset +manifests then undercount the actual recording and the original session loses +its identity. Fractional CLI durations and concurrent collection make the +collision reachable. + +## Decision + +Allocate each recording with exclusive file creation. Retain existing names +when available; on a data or metadata collision, try a numeric suffix until +allocation succeeds. Update session ID, data path and metadata path together. +Preserve prior recordings and metadata instead of implicitly resuming them. +No CLI resume contract exists; ordinary collection creates a new session. + +## Verification + +Two actual public collector CLI processes receive distinct owned loopback +packets under an isolated fixed datetime fixture that reproduces a same-second +collision deterministically. Baseline leaves one combined data file and one +miscounted metadata entry. Fixed captures retain distinct IDs, one sample per +file, matching metadata counts and a two-session/two-frame manifest. All eight +harness gates run. No hardware capture or physical accuracy is claimed. diff --git a/docs/adr/ADR-416-alignment-cli-window-validation.md b/docs/adr/ADR-416-alignment-cli-window-validation.md new file mode 100644 index 000000000..28bfe0a17 --- /dev/null +++ b/docs/adr/ADR-416-alignment-cli-window-validation.md @@ -0,0 +1,34 @@ +# ADR-416: Validate alignment CLI window parameters before processing captures + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-079 (ground-truth pairing) + +## Context + +The public ground-truth alignment CLI parses window and numeric options without +validation. A zero window frame count makes the windowing loop advance by zero +and grow indefinitely. Negative counts also cannot advance correctly, and +fractional or partially numeric values are silently truncated. Invalid camera +counts, confidence thresholds and time values can bypass matching checks or +produce misleading paired output. + +## Decision + +Parse complete numeric values and validate before reading or writing captures. +Require positive safe integer frame counts, finite nonnegative window duration, +confidence in [0,1], and finite signed clock offsets. Retain zero expansion and +zero confidence as supported controls. Report the offending option and exit2 +for invalid inputs. Preserve valid windowing and pairing calculations. + +## Verification + +Actual CLI subprocesses process owned tiny feature/camera recordings. The +baseline zero/negative count processes are bounded by a one-second timeout and +32MiB heap cap; fractional/malformed/range cases lack the expected early error. +The fixed CLI rejects each invalid option before output creation and retains +complete valid windows, zero expansion/confidence and a signed finite offset. +An initial fixture passed negative values as separate tokens, which Node's +argument parser already rejects; that attempt is retained but invalidated. +Final regressions use documented --option=value syntax. All eight harness gates +run. No model training, camera, physical capture or hardware operation occurs. diff --git a/docs/adr/ADR-417-csi-transcode-source-preservation.md b/docs/adr/ADR-417-csi-transcode-source-preservation.md new file mode 100644 index 000000000..65156ba54 --- /dev/null +++ b/docs/adr/ADR-417-csi-transcode-source-preservation.md @@ -0,0 +1,29 @@ +# ADR-417: Preserve input recordings when converter output aliases the source + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-412 (capture converter input quarantine) +- Promotion: extend existing converter PR #2227; no new publication unit + +## Context + +The public JSONL converter opens output in write mode before reading input. If +both arguments refer to one file, including hard-link or symbolic-link aliases, +it truncates the recording and reports successful conversion with zero frames. +Input validation must not destroy the original evidence it is meant to read. + +## Decision + +Check existing file identity before opening the output. Reject identical files +with a clear CLI error and exit2, preserving source bytes and links. Keep normal +distinct-path conversion and the existing incomplete-IQ quarantine unchanged. +This does not add in-place conversion or a broader transactional output policy. + +## Verification + +Actual public Python CLI receives owned input captures with identical, hard-link +and symbolic-link output paths. Baseline overwrites each source; fixed runs +leave the original bytes intact and reject the arguments. Existing signed IQ, +quarantine and later-frame controls pass alongside these fixtures. All eight +harness gates run. Original capture files outside temporary fixtures are never +mutated. This extends the existing converter PR as one reviewed unit. diff --git a/docs/adr/ADR-418-esphome-rejection-fixture-reset-lifecycle.md b/docs/adr/ADR-418-esphome-rejection-fixture-reset-lifecycle.md new file mode 100644 index 000000000..8940b08b6 --- /dev/null +++ b/docs/adr/ADR-418-esphome-rejection-fixture-reset-lifecycle.md @@ -0,0 +1,30 @@ +# ADR-418: Handle expected resets in owned ESPHome rejection fixtures + +- Status: proposed +- Date: 2026-10-11 +- Related: ADR-406 (ESPHome fixture-driven role validation) +- Promotion: extend existing ESPHome PR #2221; no new publication unit + +## Context + +The encrypted/password test fixtures write while the reader deliberately rejects +and closes the connection. The server socket can then emit ECONNRESET after the +test ends. With no socket error listener, that expected fixture event produces +an uncaught exception and intermittent unrelated full/security gate failures. +Converter and alignment failures retained locally show this exact test event. + +## Decision + +Handle ECONNRESET only in the owned encrypted/password fixture modes. Continue +throwing any unexpected error code and any reset in ordinary fixture mode. Do +not change the production reader, its failure reports or its network policy. + +## Verification + +Actual public reader connects to owned TCP fixtures and returns the expected +rejection report. A controlled socket reset event reproduces the missing error +lifecycle deterministically: baseline throws, fixed modes accept the reset. +Negative controls retain failures for EIO in rejection mode and ECONNRESET in +ordinary mode. Existing roles and public reader tests, plus all eight gates, +run on this revision. This is an explicitly injected software fixture event, +not physical device or hardware evidence. diff --git a/docs/integrations/home-assistant.md b/docs/integrations/home-assistant.md index 2617faeac..3b2c53f0b 100644 --- a/docs/integrations/home-assistant.md +++ b/docs/integrations/home-assistant.md @@ -153,14 +153,11 @@ Per ADR-115 §3.11.1, the Matter Bridge exposes a subset on standard clusters so | `--mqtt-rate-pose ` | 1.0 | Unused while no pose entity is announced | | `--privacy-mode` | off | Strip HR/BR/pose from MQTT, Matter, REST, WebSocket and recordings | | `--data-dir ` | `data` | Holds `mqtt_client_id` (and other server state) | -| `--matter` | off | Enable the HA-FABRIC Matter Bridge | -| `--matter-setup-file ` | — | Where to write the QR + manual code | -| `--matter-reset` | off | Wipe fabric credentials and re-commission | -| `--matter-vendor-id ` | `0xFFF1` (dev) | CSA-assigned vendor ID | -| `--matter-product-id ` | `0x8001` | Product ID | The `--semantic`, `--semantic-thresholds-file`, `--semantic-zones-file` and `--no-semantic` flags from ADR-115 §3.12.5 are **not accepted by `sensing-server` yet**. The primitives always run with the defaults in `PrimitiveConfig`, and no zone map is loaded. +The same goes for the Matter Bridge flags (`--matter`, `--matter-setup-file`, `--matter-reset`, `--matter-vendor-id`, `--matter-product-id`). They're defined in the library's `cli::Args` but the binary doesn't parse them, so the Matter Bridge can't be started yet. + ### Zone tags and threshold overrides (not implemented) ADR-115 §3.12.5 specifies a zone-tag file and a threshold-override file. Neither is read by `sensing-server` today, which is why the zone-based primitives are not announced. @@ -396,6 +393,8 @@ Per [ADR-115 §3.9](../adr/ADR-115-home-assistant-integration.md#39-tls--auth), ### Matter pairing fails +The Matter Bridge can't be started from `sensing-server` yet (see the note under the CLI flags), so this is for when it is. + 1. Check the setup code in your `--matter-setup-file` log (defaults to printing on startup). 2. Make sure the host running `sensing-server` is on the same WiFi subnet as the controller. 3. If Apple Home complains about an unknown vendor, that's expected — RuView uses dev VID `0xFFF1` until P10 (see [ADR §9.9](../adr/ADR-115-home-assistant-integration.md#9b-matter-path-p7p10)). Tap "Add anyway". diff --git a/docs/readme-details.md b/docs/readme-details.md index 5b09ade0e..6ce5c0d8f 100644 --- a/docs/readme-details.md +++ b/docs/readme-details.md @@ -56,7 +56,7 @@ See [PR #405](https://github.com/ruvnet/RuView/pull/405) for full details. | Capability | What it does | ADR | |-----------|-------------|-----| -| **Camera ground-truth collection** | MediaPipe PoseLandmarker captures 17 COCO keypoints at 30fps, synced with ESP32 CSI | [ADR-079](docs/adr/ADR-079-camera-ground-truth-training.md) | +| **Camera ground-truth collection** | MediaPipe PoseLandmarker captures 17 COCO keypoints at 30fps, synced with ESP32 CSI | [ADR-079](../docs/adr/ADR-079-camera-ground-truth-training.md) | | **ruvector subcarrier selection** | Variance-based top-K reduces input by 50% (70→35 subcarriers) | ADR-079 O6 | | **Stoer-Wagner min-cut** | Person-specific subcarrier cluster separation for multi-person training | ADR-079 O8 | | **Scalable WiFlow model** | 4 presets: lite (189K) → small (474K) → medium (800K) → full (7.7M params) | ADR-079 | @@ -183,11 +183,11 @@ All scripts support `--replay data/recordings/*.csi.jsonl` for offline analysis | Capability | What it does | ADR | |-----------|-------------|-----| -| **Spiking Neural Network** | Adapts to your room in <30s with STDP online learning — no labels, no batches, 16-160x less compute | [ADR-074](docs/adr/ADR-074-spiking-neural-csi-sensing.md) | -| **MinCut Person Counting** | Stoer-Wagner min-cut on subcarrier correlation graph — **fixes #348** (was always 4, now correct) | [ADR-075](docs/adr/ADR-075-mincut-person-separation.md) | -| **CNN Spectrogram Embeddings** | Treat CSI as a 64×20 image → 128-dim embedding for environment fingerprinting (0.95+ similarity) | [ADR-076](docs/adr/ADR-076-csi-spectrogram-embeddings.md) | -| **WiFlow SOTA Architecture** | TCN + axial attention + pose decoder → 17 COCO keypoints, 1.8M params (881 KB at 4-bit) | [ADR-072](docs/adr/ADR-072-wiflow-architecture.md) | -| **Multi-Frequency Mesh** | Channel hopping across 6 bands, neighbor WiFi as passive radar illuminators | [ADR-073](docs/adr/ADR-073-multifrequency-mesh-scan.md) | +| **Spiking Neural Network** | Adapts to your room in <30s with STDP online learning — no labels, no batches, 16-160x less compute | [ADR-074](../docs/adr/ADR-074-spiking-neural-csi-sensing.md) | +| **MinCut Person Counting** | Stoer-Wagner min-cut on subcarrier correlation graph — **fixes #348** (was always 4, now correct) | [ADR-075](../docs/adr/ADR-075-mincut-person-separation.md) | +| **CNN Spectrogram Embeddings** | Treat CSI as a 64×20 image → 128-dim embedding for environment fingerprinting (0.95+ similarity) | [ADR-076](../docs/adr/ADR-076-csi-spectrogram-embeddings.md) | +| **WiFlow SOTA Architecture** | TCN + axial attention + pose decoder → 17 COCO keypoints, 1.8M params (881 KB at 4-bit) | [ADR-072](../docs/adr/ADR-072-wiflow-architecture.md) | +| **Multi-Frequency Mesh** | Channel hopping across 6 bands, neighbor WiFi as passive radar illuminators | [ADR-073](../docs/adr/ADR-073-multifrequency-mesh-scan.md) | ```bash # Live RF room scan (spectrum visualization) @@ -282,7 +282,7 @@ node scripts/benchmark-ruvllm.js --model models/csi-ruvllm | **Test suite** | **1,463 tests passed** | Rock-solid foundation | | **Total hardware cost** | **$140** | ESP32 ($9) + [Cognitum Seed](https://cognitum.one) ($131) | -See [ADR-069](docs/adr/ADR-069-cognitum-seed-csi-pipeline.md), [ADR-071](docs/adr/ADR-071-ruvllm-training-pipeline.md), and the [Cognitum Seed tutorial](docs/tutorials/cognitum-seed-pretraining.md) for full details. +See [ADR-069](../docs/adr/ADR-069-cognitum-seed-csi-pipeline.md), [ADR-071](../docs/adr/ADR-071-ruvllm-training-pipeline.md), and the [Cognitum Seed tutorial](../docs/tutorials/cognitum-seed-pretraining.md) for full details.
@@ -301,8 +301,8 @@ See people, breathing, and heartbeats through walls — using only WiFi signals | 👥 | **Multi-Person** | Tracks multiple people simultaneously, each with independent pose and vitals — no hard software limit (physics: ~3-5 per AP with 56 subcarriers, more with multi-AP) | | 🧱 | **Through-Wall** | WiFi passes through walls, furniture, and debris — works where cameras cannot | | 🚑 | **Disaster Response** | Detects trapped survivors through rubble and classifies injury severity (START triage) | -| 📡 | **Multistatic Mesh** | 4-6 low-cost sensor nodes work together, combining 12+ overlapping signal paths for full 360-degree room coverage with sub-inch accuracy and no person mix-ups ([ADR-029](docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md)) | -| 🌐 | **Persistent Field Model** | The system learns the RF signature of each room — then subtracts the room to isolate human motion, detect drift over days, predict intent before movement starts, and flag spoofing attempts ([ADR-030](docs/adr/ADR-030-ruvsense-persistent-field-model.md)) | +| 📡 | **Multistatic Mesh** | 4-6 low-cost sensor nodes work together, combining 12+ overlapping signal paths for full 360-degree room coverage with sub-inch accuracy and no person mix-ups ([ADR-029](../docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md)) | +| 🌐 | **Persistent Field Model** | The system learns the RF signature of each room — then subtracts the room to isolate human motion, detect drift over days, predict intent before movement starts, and flag spoofing attempts ([ADR-030](../docs/adr/ADR-030-ruvsense-persistent-field-model.md)) | ### Intelligence @@ -310,13 +310,13 @@ The system learns on its own and gets smarter over time — no hand-tuning, no l | | Feature | What It Means | |---|---------|---------------| -| 🧠 | **Self-Learning** | Teaches itself from raw WiFi data — no labeled training sets, no cameras needed to bootstrap ([ADR-024](docs/adr/ADR-024-contrastive-csi-embedding-model.md)) | +| 🧠 | **Self-Learning** | Teaches itself from raw WiFi data — no labeled training sets, no cameras needed to bootstrap ([ADR-024](../docs/adr/ADR-024-contrastive-csi-embedding-model.md)) | | 🎯 | **AI Signal Processing** | Attention networks, graph algorithms, and smart compression replace hand-tuned thresholds — adapts to each room automatically ([RuVector](https://github.com/ruvnet/ruvector)) | -| 🌍 | **Works Everywhere** | Train once, deploy in any room — adversarial domain generalization strips environment bias so models transfer across rooms, buildings, and hardware ([ADR-027](docs/adr/ADR-027-cross-environment-domain-generalization.md)) | -| 👁️ | **Cross-Viewpoint Fusion** | AI combines what each sensor sees from its own angle — fills in blind spots and depth ambiguity that no single viewpoint can resolve on its own ([ADR-031](docs/adr/ADR-031-ruview-sensing-first-rf-mode.md)) | -| 🔮 | **Signal-Line Protocol** | A 6-stage processing pipeline transforms raw WiFi signals into structured body representations — from signal cleanup through graph-based spatial reasoning to final pose output ([ADR-033](docs/adr/ADR-033-crv-signal-line-sensing-integration.md)) | -| 🔒 | **QUIC Mesh Security** | All sensor-to-sensor communication is encrypted end-to-end with tamper detection, replay protection, and seamless reconnection if a node moves or drops offline ([ADR-032](docs/adr/ADR-032-multistatic-mesh-security-hardening.md)) | -| 🎯 | **Adaptive Classifier** | Records labeled CSI sessions, trains a 15-feature logistic regression model in pure Rust, and learns your room's unique signal characteristics — replaces hand-tuned thresholds with data-driven classification ([ADR-048](docs/adr/ADR-048-adaptive-csi-classifier.md)) | +| 🌍 | **Works Everywhere** | Train once, deploy in any room — adversarial domain generalization strips environment bias so models transfer across rooms, buildings, and hardware ([ADR-027](../docs/adr/ADR-027-cross-environment-domain-generalization.md)) | +| 👁️ | **Cross-Viewpoint Fusion** | AI combines what each sensor sees from its own angle — fills in blind spots and depth ambiguity that no single viewpoint can resolve on its own ([ADR-031](../docs/adr/ADR-031-ruview-sensing-first-rf-mode.md)) | +| 🔮 | **Signal-Line Protocol** | A 6-stage processing pipeline transforms raw WiFi signals into structured body representations — from signal cleanup through graph-based spatial reasoning to final pose output ([ADR-033](../docs/adr/ADR-033-crv-signal-line-sensing-integration.md)) | +| 🔒 | **QUIC Mesh Security** | All sensor-to-sensor communication is encrypted end-to-end with tamper detection, replay protection, and seamless reconnection if a node moves or drops offline ([ADR-032](../docs/adr/ADR-032-multistatic-mesh-security-hardening.md)) | +| 🎯 | **Adaptive Classifier** | Records labeled CSI sessions, trains a 15-feature logistic regression model in pure Rust, and learns your room's unique signal characteristics — replaces hand-tuned thresholds with data-driven classification ([ADR-048](../docs/adr/ADR-048-adaptive-csi-classifier.md)) | ### Performance & Deployment @@ -329,8 +329,8 @@ Fast enough for real-time use, small enough for edge devices, simple enough for | 🐳 | **One-Command Setup** | `docker pull ruvnet/wifi-densepose:latest` — live sensing in 30 seconds, no toolchain needed (amd64 + arm64 / Apple Silicon) | | 📡 | **Fully Local** | Runs completely on a $9 ESP32 — no internet connection, no cloud account, no recurring fees. Detects presence, vital signs, and falls on-device with instant response | | 📦 | **Portable Models** | Trained models package into a single `.rvf` file — runs on edge, cloud, or browser (WASM) | -| 🔭 | **Observatory Visualization** | Cinematic Three.js dashboard with 5 holographic panels — subcarrier manifold, vital signs oracle, presence heatmap, phase constellation, convergence engine — all driven by live or demo CSI data ([ADR-047](docs/adr/ADR-047-psychohistory-observatory-visualization.md)) | -| 📟 | **AMOLED Display** | ESP32-S3 boards with built-in AMOLED screens show real-time presence, vital signs, and room status directly on the sensor — no phone or PC needed ([ADR-045](docs/adr/ADR-045-amoled-display-support.md)) | +| 🔭 | **Observatory Visualization** | Cinematic Three.js dashboard with 5 holographic panels — subcarrier manifold, vital signs oracle, presence heatmap, phase constellation, convergence engine — all driven by live or demo CSI data ([ADR-047](../docs/adr/ADR-047-psychohistory-observatory-visualization.md)) | +| 📟 | **AMOLED Display** | ESP32-S3 boards with built-in AMOLED screens show real-time presence, vital signs, and room status directly on the sensor — no phone or PC needed ([ADR-045](../docs/adr/ADR-045-amoled-display-support.md)) | --- @@ -469,7 +469,7 @@ cargo add wifi-densepose-ruvector # RuVector v2.0.4 integration layer (ADR-017 All crates integrate with [RuVector v2.0.4](https://github.com/ruvnet/ruvector) — see [AI Backbone](#ai-backbone-ruvector) below. -**[rUv Neural](v2/crates/ruv-neural/)** — A separate 12-crate workspace for brain network topology analysis, neural decoding, and medical sensing. See [rUv Neural](#ruv-neural) in Models & Training. +**[rUv Neural](https://github.com/ruvnet/ruv-neural)** — A separate 12-crate workspace for brain network topology analysis, neural decoding, and medical sensing. See [rUv Neural](#ruv-neural) in Models & Training.
@@ -551,11 +551,11 @@ The signal processing stack transforms raw WiFi Channel State Information into a |---------|-------------|------| | [Key Features](#key-features) | Sensing, Intelligence, and Performance & Deployment capabilities | — | | [How It Works](#how-it-works) | End-to-end pipeline: radio waves → CSI capture → signal processing → AI → pose + vitals | — | -| [ESP32-S3 Hardware Pipeline](#esp32-s3-hardware-pipeline) | 20 Hz CSI streaming, binary frame parsing, flash & provision | [ADR-018](docs/adr/ADR-018-esp32-dev-implementation.md) · [Tutorial #34](https://github.com/ruvnet/RuView/issues/34) | -| [Vital Sign Detection](#vital-sign-detection) | Breathing 6-30 BPM, heartbeat 40-120 BPM, FFT peak detection | [ADR-021](docs/adr/ADR-021-vital-sign-detection-rvdna-pipeline.md) | -| [WiFi Scan Domain Layer](#wifi-scan-domain-layer) | 8-stage RSSI pipeline, multi-BSSID fingerprinting, Windows WiFi | [ADR-022](docs/adr/ADR-022-windows-wifi-enhanced-fidelity-ruvector.md) · [Tutorial #36](https://github.com/ruvnet/RuView/issues/36) | -| [WiFi-Mat Disaster Response](#wifi-mat-disaster-response) | Search & rescue, START triage, 3D localization through debris | [ADR-001](docs/adr/ADR-001-wifi-mat-disaster-detection.md) · [User Guide](docs/wifi-mat-user-guide.md) | -| [SOTA Signal Processing](#sota-signal-processing) | SpotFi, Hampel, Fresnel, STFT spectrogram, subcarrier selection, BVP | [ADR-014](docs/adr/ADR-014-sota-signal-processing.md) | +| [ESP32-S3 Hardware Pipeline](#esp32-s3-hardware-pipeline) | 20 Hz CSI streaming, binary frame parsing, flash & provision | [ADR-018](../docs/adr/ADR-018-esp32-dev-implementation.md) · [Tutorial #34](https://github.com/ruvnet/RuView/issues/34) | +| [Vital Sign Detection](#vital-sign-detection) | Breathing 6-30 BPM, heartbeat 40-120 BPM, FFT peak detection | [ADR-021](../docs/adr/ADR-021-vital-sign-detection-rvdna-pipeline.md) | +| [WiFi Scan Domain Layer](#wifi-scan-domain-layer) | 8-stage RSSI pipeline, multi-BSSID fingerprinting, Windows WiFi | [ADR-022](../docs/adr/ADR-022-windows-wifi-enhanced-fidelity-ruvector.md) · [Tutorial #36](https://github.com/ruvnet/RuView/issues/36) | +| [WiFi-Mat Disaster Response](#wifi-mat-disaster-response) | Search & rescue, START triage, 3D localization through debris | [ADR-001](../docs/adr/ADR-001-wifi-mat-disaster-detection.md) · [User Guide](../docs/wifi-mat-user-guide.md) | +| [SOTA Signal Processing](#sota-signal-processing) | SpotFi, Hampel, Fresnel, STFT spectrogram, subcarrier selection, BVP | [ADR-014](../docs/adr/ADR-014-sota-signal-processing.md) |
@@ -566,13 +566,13 @@ The neural pipeline uses a graph transformer with cross-attention to map CSI fea | Section | Description | Docs | |---------|-------------|------| -| [RVF Model Container](#rvf-model-container) | Binary packaging with Ed25519 signing, progressive 3-layer loading, SIMD quantization | [ADR-023](docs/adr/ADR-023-trained-densepose-model-ruvector-pipeline.md) | -| [Training & Fine-Tuning](#training--fine-tuning) | 8-phase pure Rust pipeline (7,832 lines), MM-Fi/Wi-Pose pre-training, 6-term composite loss, SONA LoRA | [ADR-023](docs/adr/ADR-023-trained-densepose-model-ruvector-pipeline.md) | -| [RuVector Crates](#ruvector-crates) | 11 vendored Rust crates from [ruvector](https://github.com/ruvnet/ruvector): attention, min-cut, solver, GNN, HNSW, temporal compression, sparse inference | [GitHub](https://github.com/ruvnet/ruvector) · [Source](vendor/ruvector/) | -| [rUv Neural](#ruv-neural) | 12-crate brain topology analysis ecosystem: neural decoding, quantum sensor integration, cognitive state classification, BCI output | [README](v2/crates/ruv-neural/README.md) | +| [RVF Model Container](#rvf-model-container) | Binary packaging with Ed25519 signing, progressive 3-layer loading, SIMD quantization | [ADR-023](../docs/adr/ADR-023-trained-densepose-model-ruvector-pipeline.md) | +| [Training & Fine-Tuning](#training--fine-tuning) | 8-phase pure Rust pipeline (7,832 lines), MM-Fi/Wi-Pose pre-training, 6-term composite loss, SONA LoRA | [ADR-023](../docs/adr/ADR-023-trained-densepose-model-ruvector-pipeline.md) | +| [RuVector Crates](#ruvector-crates) | 11 vendored Rust crates from [ruvector](https://github.com/ruvnet/ruvector): attention, min-cut, solver, GNN, HNSW, temporal compression, sparse inference | [GitHub](https://github.com/ruvnet/ruvector) · [Source](../vendor/) | +| [rUv Neural](#ruv-neural) | 12-crate brain topology analysis ecosystem: neural decoding, quantum sensor integration, cognitive state classification, BCI output | [README](https://github.com/ruvnet/ruv-neural#readme) | | [AI Backbone (RuVector)](#ai-backbone-ruvector) | 5 AI capabilities replacing hand-tuned thresholds: attention, graph min-cut, sparse solvers, tiered compression | [crates.io](https://crates.io/crates/wifi-densepose-ruvector) | -| [Self-Learning WiFi AI (ADR-024)](#self-learning-wifi-ai-adr-024) | Contrastive self-supervised learning, room fingerprinting, anomaly detection, 55 KB model | [ADR-024](docs/adr/ADR-024-contrastive-csi-embedding-model.md) | -| [Cross-Environment Generalization (ADR-027)](docs/adr/ADR-027-cross-environment-domain-generalization.md) | Domain-adversarial training, geometry-conditioned inference, hardware normalization, zero-shot deployment | [ADR-027](docs/adr/ADR-027-cross-environment-domain-generalization.md) | +| [Self-Learning WiFi AI (ADR-024)](#self-learning-wifi-ai-adr-024) | Contrastive self-supervised learning, room fingerprinting, anomaly detection, 55 KB model | [ADR-024](../docs/adr/ADR-024-contrastive-csi-embedding-model.md) | +| [Cross-Environment Generalization (ADR-027)](../docs/adr/ADR-027-cross-environment-domain-generalization.md) | Domain-adversarial training, geometry-conditioned inference, hardware normalization, zero-shot deployment | [ADR-027](../docs/adr/ADR-027-cross-environment-domain-generalization.md) |
@@ -585,7 +585,7 @@ The Rust sensing server is the primary interface, offering a comprehensive CLI w |---------|-------------|------| | [CLI Usage](#cli-usage) | `--source`, `--train`, `--benchmark`, `--export-rvf`, `--model`, `--progressive` | — | | [REST API & WebSocket](#rest-api--websocket) | 6 REST endpoints (sensing, vitals, BSSID, SONA), WebSocket real-time stream | — | -| [Hardware Support](#hardware-support-1) | ESP32-S3 ($8), Intel 5300 ($15), Atheros AR9580 ($20), Windows RSSI ($0) | [ADR-012](docs/adr/ADR-012-esp32-csi-sensor-mesh.md) · [ADR-013](docs/adr/ADR-013-feature-level-sensing-commodity-gear.md) | +| [Hardware Support](#hardware-support-1) | ESP32-S3 ($8), Intel 5300 ($15), Atheros AR9580 ($20), Windows RSSI ($0) | [ADR-012](../docs/adr/ADR-012-esp32-csi-sensor-mesh.md) · [ADR-013](../docs/adr/ADR-013-feature-level-sensing-commodity-gear.md) |
@@ -621,8 +621,8 @@ WiFi DensePose is MIT-licensed open source, developed by [ruvnet](https://github | Section | Description | Link | |---------|-------------|------| -| [Changelog](#changelog) | v3.0.0 (AETHER AI + Docker), v2.0.0 (Rust port + SOTA + WiFi-Mat) | [CHANGELOG.md](CHANGELOG.md) | -| [License](#license) | MIT License | [LICENSE](LICENSE) | +| [Changelog](#changelog) | v3.0.0 (AETHER AI + Docker), v2.0.0 (Rust port + SOTA + WiFi-Mat) | [CHANGELOG.md](../CHANGELOG.md) | +| [License](#license) | MIT License | [LICENSE](../LICENSE) | | [Support](#support) | Bug reports, feature requests, community discussion | [Issues](https://github.com/ruvnet/RuView/issues) · [Discussions](https://github.com/ruvnet/RuView/discussions) | @@ -667,14 +667,14 @@ CSI Encoder (existing) ──→ latent features - Atomic instance counter ensures unique weight initialization across threads - Division-by-zero guards on all augmentation parameters -See [`docs/adr/ADR-027-cross-environment-domain-generalization.md`](docs/adr/ADR-027-cross-environment-domain-generalization.md) for full architectural details. +See [`docs/adr/ADR-027-cross-environment-domain-generalization.md`](../docs/adr/ADR-027-cross-environment-domain-generalization.md) for full architectural details.
🔍 Independent Capability Audit (ADR-028) — 1,031 tests, SHA-256 proof, self-verifying witness bundle -A [3-agent parallel audit](docs/adr/ADR-028-esp32-capability-audit.md) independently verified every claim in this repository — ESP32 hardware, signal processing, neural networks, training pipeline, deployment, and security. Results: +A [3-agent parallel audit](../docs/adr/ADR-028-esp32-capability-audit.md) independently verified every claim in this repository — ESP32 hardware, signal processing, neural networks, training pipeline, deployment, and security. Results: ``` Rust tests: 1,031 passed, 0 failed @@ -699,9 +699,9 @@ cd dist/witness-bundle-ADR028-*/ && bash VERIFY.sh | Document | What it contains | |----------|-----------------| -| [ADR-028](docs/adr/ADR-028-esp32-capability-audit.md) | Full audit: ESP32 specs, signal algorithms, NN architectures, training phases, deployment infra | -| [Witness Log](docs/WITNESS-LOG-028.md) | 11 reproducible verification steps + 33-row attestation matrix with evidence per row | -| [`generate-witness-bundle.sh`](scripts/generate-witness-bundle.sh) | Creates self-contained tar.gz with test logs, proof output, firmware hashes, crate versions, VERIFY.sh | +| [ADR-028](../docs/adr/ADR-028-esp32-capability-audit.md) | Full audit: ESP32 specs, signal algorithms, NN architectures, training phases, deployment infra | +| [Witness Log](../docs/WITNESS-LOG-028.md) | 11 reproducible verification steps + 33-row attestation matrix with evidence per row | +| [`generate-witness-bundle.sh`](../scripts/generate-witness-bundle.sh) | Creates self-contained tar.gz with test logs, proof output, firmware hashes, crate versions, VERIFY.sh |
@@ -721,9 +721,9 @@ A single WiFi receiver can track people, but has blind spots — limbs behind th | ADR | Codename | What it adds | |-----|----------|-------------| -| [ADR-029](docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md) | **RuvSense** | Channel hopping, TDM protocol, multi-node fusion, coherence gating, 17-keypoint Kalman tracker | -| [ADR-030](docs/adr/ADR-030-ruvsense-persistent-field-model.md) | **RuvSense Field** | Room electromagnetic eigenstructure (SVD), RF tomography, longitudinal drift detection, intention prediction, gesture recognition, adversarial detection | -| [ADR-031](docs/adr/ADR-031-ruview-sensing-first-rf-mode.md) | **RuView** | Cross-viewpoint attention with geometric bias, viewpoint diversity optimization, embedding-level fusion | +| [ADR-029](../docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md) | **RuvSense** | Channel hopping, TDM protocol, multi-node fusion, coherence gating, 17-keypoint Kalman tracker | +| [ADR-030](../docs/adr/ADR-030-ruvsense-persistent-field-model.md) | **RuvSense Field** | Room electromagnetic eigenstructure (SVD), RF tomography, longitudinal drift detection, intention prediction, gesture recognition, adversarial detection | +| [ADR-031](../docs/adr/ADR-031-ruview-sensing-first-rf-mode.md) | **RuView** | Cross-viewpoint attention with geometric bias, viewpoint diversity optimization, embedding-level fusion | **Architecture** @@ -786,7 +786,7 @@ Pose Tracker + DensePose 17-keypoint Kalman, re-ID via AETHER embeddings | `csi_collector.c` | Channel hop table, timer-driven hop, NDP injection stub | | `nvs_config.c` | 5 new NVS keys: hop_count, channel_list, dwell_ms, tdm_slot, tdm_node_count | -**DDD Domain Model** — 6 bounded contexts: Multistatic Sensing, Coherence, Pose Tracking, Field Model, Cross-Room Identity, Adversarial Detection. Full specification: [`docs/ddd/ruvsense-domain-model.md`](docs/ddd/ruvsense-domain-model.md). +**DDD Domain Model** — 6 bounded contexts: Multistatic Sensing, Coherence, Pose Tracking, Field Model, Cross-Room Identity, Adversarial Detection. Full specification: [`docs/ddd/ruvsense-domain-model.md`](../docs/ddd/ruvsense-domain-model.md). See the ADR documents for full architectural details, GOAP integration plans, and research references. @@ -876,11 +876,11 @@ Download a pre-built binary — no build toolchain needed: | [v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0) | **Latest** — Camera-supervised WiFlow model (accuracy figure retracted 2026-06-10, see above), ground-truth training pipeline, ruvector optimizations | `v0.7.0` | | [v0.6.0](https://github.com/ruvnet/RuView/releases/tag/v0.6.0-esp32) | [Pre-trained models on HuggingFace](https://huggingface.co/ruv/ruview), 17 sensing apps, 51.6% contrastive improvement, 0.008ms inference | `v0.6.0-esp32` | | [v0.5.5](https://github.com/ruvnet/RuView/releases/tag/v0.5.5-esp32) | SNN + MinCut (#348 fix) + CNN spectrogram + WiFlow + multi-freq mesh + graph transformer | `v0.5.5-esp32` | -| [v0.5.4](https://github.com/ruvnet/RuView/releases/tag/v0.5.4-esp32) | Cognitum Seed integration ([ADR-069](docs/adr/ADR-069-cognitum-seed-csi-pipeline.md)), 8-dim feature vectors, RVF store, witness chain, security hardening | `v0.5.4-esp32` | -| [v0.5.0](https://github.com/ruvnet/RuView/releases/tag/v0.5.0-esp32) | mmWave sensor fusion ([ADR-063](docs/adr/ADR-063-mmwave-sensor-fusion.md)), auto-detect MR60BHA2/LD2410, 48-byte fused vitals, all v0.4.3.1 fixes | `v0.5.0-esp32` | +| [v0.5.4](https://github.com/ruvnet/RuView/releases/tag/v0.5.4-esp32) | Cognitum Seed integration ([ADR-069](../docs/adr/ADR-069-cognitum-seed-csi-pipeline.md)), 8-dim feature vectors, RVF store, witness chain, security hardening | `v0.5.4-esp32` | +| [v0.5.0](https://github.com/ruvnet/RuView/releases/tag/v0.5.0-esp32) | mmWave sensor fusion ([ADR-063](../docs/adr/ADR-063-mmwave-sensor-fusion.md)), auto-detect MR60BHA2/LD2410, 48-byte fused vitals, all v0.4.3.1 fixes | `v0.5.0-esp32` | | [v0.4.3.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.3.1-esp32) | Fall detection fix ([#263](https://github.com/ruvnet/RuView/issues/263)), 4MB flash ([#265](https://github.com/ruvnet/RuView/issues/265)), watchdog fix ([#266](https://github.com/ruvnet/RuView/issues/266)) | `v0.4.3.1-esp32` | -| [v0.4.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.1-esp32) | CSI build fix, compile guard, AMOLED display, edge intelligence ([ADR-057](docs/adr/ADR-057-firmware-csi-build-guard.md)) | `v0.4.1-esp32` | -| [v0.3.0-alpha](https://github.com/ruvnet/RuView/releases/tag/v0.3.0-alpha-esp32) | Alpha — adds on-device edge intelligence and WASM modules ([ADR-039](docs/adr/ADR-039-esp32-edge-intelligence.md), [ADR-040](docs/adr/ADR-040-wasm-programmable-sensing.md)) | `v0.3.0-alpha-esp32` | +| [v0.4.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.1-esp32) | CSI build fix, compile guard, AMOLED display, edge intelligence ([ADR-057](../docs/adr/ADR-057-firmware-csi-build-guard.md)) | `v0.4.1-esp32` | +| [v0.3.0-alpha](https://github.com/ruvnet/RuView/releases/tag/v0.3.0-alpha-esp32) | Alpha — adds on-device edge intelligence and WASM modules ([ADR-039](../docs/adr/ADR-039-esp32-edge-intelligence.md), [ADR-040](../docs/adr/ADR-040-wasm-programmable-sensing.md)) | `v0.3.0-alpha-esp32` | | [v0.2.0](https://github.com/ruvnet/RuView/releases/tag/v0.2.0-esp32) | Raw CSI streaming, multi-node TDM, channel hopping | `v0.2.0-esp32` | ```bash @@ -921,7 +921,7 @@ python firmware/esp32-csi-node/provision.py --port COM8 \ --node-id 1 --tdm-slot 1 --tdm-total 3 ``` -Nodes can also hop across WiFi channels (1, 6, 11) to increase sensing bandwidth — configured via [ADR-029](docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md) channel hopping. +Nodes can also hop across WiFi channels (1, 6, 11) to increase sensing bandwidth — configured via [ADR-029](../docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md) channel hopping. ### Cognitum Seed integration (ADR-069) @@ -949,7 +949,7 @@ python scripts/seed_csi_bridge.py \ python scripts/seed_csi_bridge.py --token "$SEED_TOKEN" --stats ``` -The 8-dim feature vector captures: presence, motion, breathing rate, heart rate, phase variance, person count, fall detection, and RSSI — all normalized to [0.0, 1.0]. See [ADR-069](docs/adr/ADR-069-cognitum-seed-csi-pipeline.md) for the full architecture. +The 8-dim feature vector captures: presence, motion, breathing rate, heart rate, phase variance, person count, fall detection, and RSSI — all normalized to [0.0, 1.0]. See [ADR-069](../docs/adr/ADR-069-cognitum-seed-csi-pipeline.md) for the full architecture. ### On-device intelligence (v0.3.0-alpha) @@ -960,7 +960,7 @@ The alpha firmware can analyze signals locally and send compact results instead | **0** | Off — streams raw CSI only (same as v0.2.0) | 0 KB | | **1** | Cleans up signals, picks the best subcarriers, compresses data (saves 30-50% bandwidth) | ~30 KB | | **2** | Everything in Tier 1 + detects presence, measures breathing and heart rate, detects falls | ~33 KB | -| **3** | Everything in Tier 2 + runs custom WASM modules (gesture recognition, intrusion detection, and [63 more](docs/edge-modules/README.md)) | ~160 KB/module | +| **3** | Everything in Tier 2 + runs custom WASM modules (gesture recognition, intrusion detection, and [63 more](../docs/edge-modules/README.md)) | ~160 KB/module | Enable without reflashing — just reprovision: @@ -977,7 +977,7 @@ python firmware/esp32-csi-node/provision.py --port COM7 \ When Tier 2 is active, the node sends a 32-byte vitals packet once per second containing: presence, motion level, breathing BPM, heart rate BPM, confidence scores, fall alert flag, and occupancy count. -See [firmware/esp32-csi-node/README.md](firmware/esp32-csi-node/README.md), [ADR-039](docs/adr/ADR-039-esp32-edge-intelligence.md), [ADR-044](docs/adr/ADR-044-provisioning-tool-enhancements.md), and [Tutorial #34](https://github.com/ruvnet/RuView/issues/34). +See [firmware/esp32-csi-node/README.md](../firmware/esp32-csi-node/README.md), [ADR-039](../docs/adr/ADR-039-esp32-edge-intelligence.md), [ADR-050](../docs/adr/ADR-050-provisioning-tool-enhancements.md), and [Tutorial #34](https://github.com/ruvnet/RuView/issues/34). @@ -1026,7 +1026,7 @@ cargo bench --package wifi-densepose-signal curl http://localhost:3000/api/v1/vital-signs ``` -See [ADR-021](docs/adr/ADR-021-vital-sign-detection-rvdna-pipeline.md). +See [ADR-021](../docs/adr/ADR-021-vital-sign-detection-rvdna-pipeline.md). @@ -1048,7 +1048,7 @@ See [ADR-021](docs/adr/ADR-021-vital-sign-detection-rvdna-pipeline.md). cargo test -p wifi-densepose-wifiscan ``` -See [ADR-022](docs/adr/ADR-022-windows-wifi-enhanced-fidelity-ruvector.md) and [Tutorial #36](https://github.com/ruvnet/RuView/issues/36). +See [ADR-022](../docs/adr/ADR-022-windows-wifi-enhanced-fidelity-ruvector.md) and [Tutorial #36](https://github.com/ruvnet/RuView/issues/36). @@ -1093,7 +1093,7 @@ response.start_scanning().await?; **Safety guarantees:** fail-safe defaults (assume life present on ambiguous signals), redundant multi-algorithm voting, complete audit trail, offline-capable (no network required). -- [WiFi-Mat User Guide](docs/wifi-mat-user-guide.md) | [ADR-001](docs/adr/ADR-001-wifi-mat-disaster-detection.md) | [Domain Model](docs/ddd/wifi-mat-domain-model.md) +- [WiFi-Mat User Guide](../docs/wifi-mat-user-guide.md) | [ADR-001](../docs/adr/ADR-001-wifi-mat-disaster-detection.md) | [Domain Model](../docs/ddd/wifi-mat-domain-model.md) @@ -1113,7 +1113,7 @@ The signal processing layer bridges the gap between raw commodity WiFi hardware **Processing pipeline order:** Raw CSI → Conjugate multiplication (phase cleaning) → Hampel filter (outlier removal) → Subcarrier selection (top-K) → CSI spectrogram (time-frequency) → Fresnel model (breathing) + BVP (activity) -See [ADR-014](docs/adr/ADR-014-sota-signal-processing.md) for full mathematical derivations. +See [ADR-014](../docs/adr/ADR-014-sota-signal-processing.md) for full mathematical derivations. @@ -1141,7 +1141,7 @@ Clean, structured signal --> DensePose Neural Network --> 17-keypoint body pose --> ruvector-solver ------------> physics-based localization ``` -The [`wifi-densepose-ruvector`](https://crates.io/crates/wifi-densepose-ruvector) crate ([ADR-017](docs/adr/ADR-017-ruvector-signal-mat-integration.md)) connects all 7 integration points: +The [`wifi-densepose-ruvector`](https://crates.io/crates/wifi-densepose-ruvector) crate ([ADR-017](../docs/adr/ADR-017-ruvector-signal-mat-integration.md)) connects all 7 integration points: | AI Capability | What It Replaces | RuVector Crate | Result | |--------------|-----------------|----------------|--------| @@ -1214,7 +1214,7 @@ The [RuVector Format (RVF)](https://github.com/ruvnet/ruvector/tree/main/crates/ docker run --rm -v $(pwd):/out ruvnet/wifi-densepose:latest --export-rvf /out/model.rvf ``` -Built on the [rvf](https://github.com/ruvnet/ruvector/tree/main/crates/rvf) crate family (rvf-types, rvf-wire, rvf-manifest, rvf-index, rvf-quant, rvf-crypto, rvf-runtime). See [ADR-023](docs/adr/ADR-023-trained-densepose-model-ruvector-pipeline.md). +Built on the [rvf](https://github.com/ruvnet/ruvector/tree/main/crates/rvf) crate family (rvf-types, rvf-wire, rvf-manifest, rvf-index, rvf-quant, rvf-crypto, rvf-runtime). See [ADR-023](../docs/adr/ADR-023-trained-densepose-model-ruvector-pipeline.md). @@ -1265,7 +1265,7 @@ docker run --rm -v $(pwd)/data:/data ruvnet/wifi-densepose:latest \ --train --dataset /data --epochs 100 --export-rvf /data/model.rvf ``` -See [ADR-023](docs/adr/ADR-023-trained-densepose-model-ruvector-pipeline.md) · [SONA crate](https://github.com/ruvnet/ruvector/tree/main/crates/sona) · [arXiv:2301.00250](https://arxiv.org/abs/2301.00250) +See [ADR-023](../docs/adr/ADR-023-trained-densepose-model-ruvector-pipeline.md) · [SONA crate](https://github.com/ruvnet/ruvector/tree/main/crates/sona) · [arXiv:2301.00250](https://arxiv.org/abs/2301.00250) @@ -1293,14 +1293,14 @@ See [ADR-023](docs/adr/ADR-023-trained-densepose-model-ruvector-pipeline.md) · | [`ruvector-nervous-system`](https://github.com/ruvnet/ruvector/tree/main/crates/ruvector-nervous-system) | PredictiveLayer, OscillatoryRouter, Hopfield associative memory | [crate](https://crates.io/crates/ruvector-nervous-system) | | [`ruvector-coherence`](https://github.com/ruvnet/ruvector/tree/main/crates/ruvector-coherence) | Spectral coherence monitoring, HNSW graph health, Fiedler connectivity | [crate](https://crates.io/crates/ruvector-coherence) | -The full RuVector ecosystem includes 90+ crates. See [github.com/ruvnet/ruvector](https://github.com/ruvnet/ruvector) for the complete library, and [`vendor/ruvector/`](vendor/ruvector/) for the vendored source in this project. +The full RuVector ecosystem includes 90+ crates. See [github.com/ruvnet/ruvector](https://github.com/ruvnet/ruvector) for the complete library, and [`vendor/ruvector/`](../vendor/) for the vendored source in this project.
🧠 rUv Neural — Brain topology analysis ecosystem for neural decoding and medical sensing -[**rUv Neural**](v2/crates/ruv-neural/README.md) is a 12-crate Rust ecosystem that extends RuView's signal processing into brain network topology analysis. It transforms neural magnetic field measurements from quantum sensors (NV diamond magnetometers, optically pumped magnetometers) into dynamic connectivity graphs, using minimum cut algorithms to detect cognitive state transitions in real time. The ecosystem includes crates for signal processing (`ruv-neural-signal`), graph construction (`ruv-neural-graph`), HNSW-indexed pattern memory (`ruv-neural-memory`), graph embeddings (`ruv-neural-embed`), cognitive state decoding (`ruv-neural-decoder`), and ESP32/WASM edge targets. Medical and research applications include early neurological disease detection via topology signatures, brain-computer interfaces, clinical neurofeedback, and non-invasive biomedical sensing -- bridging RuView's RF sensing architecture with the emerging field of quantum biomedical diagnostics. +[**rUv Neural**](https://github.com/ruvnet/ruv-neural#readme) is a 12-crate Rust ecosystem that extends RuView's signal processing into brain network topology analysis. It transforms neural magnetic field measurements from quantum sensors (NV diamond magnetometers, optically pumped magnetometers) into dynamic connectivity graphs, using minimum cut algorithms to detect cognitive state transitions in real time. The ecosystem includes crates for signal processing (`ruv-neural-signal`), graph construction (`ruv-neural-graph`), HNSW-indexed pattern memory (`ruv-neural-memory`), graph embeddings (`ruv-neural-embed`), cognitive state decoding (`ruv-neural-decoder`), and ESP32/WASM edge targets. Medical and research applications include early neurological disease detection via topology signatures, brain-computer interfaces, clinical neurofeedback, and non-invasive biomedical sensing -- bridging RuView's RF sensing architecture with the emerging field of quantum biomedical diagnostics.
@@ -1557,7 +1557,7 @@ WebSocket: `ws://localhost:3001/ws/sensing` (real-time sensing + vital signs) | Intel 5300 | Firmware mod | ~$15 | Linux `iwl-csi` | | Atheros AR9580 | ath9k patch | ~$20 | Linux only | | Any Windows WiFi | RSSI only | $0 | [Tutorial #36](https://github.com/ruvnet/RuView/issues/36) | -| Any macOS WiFi | RSSI only (CoreWLAN) | $0 | [ADR-025](docs/adr/ADR-025-macos-corewlan-wifi-sensing.md) | +| Any macOS WiFi | RSSI only (CoreWLAN) | $0 | [ADR-025](../docs/adr/ADR-025-macos-corewlan-wifi-sensing.md) | | Any Linux WiFi | RSSI only (`iw`) | $0 | Requires `iw` + `CAP_NET_ADMIN` | @@ -1603,7 +1603,7 @@ bash scripts/qemu-chaos-test.sh --faults all --duration 120 **CI**: GitHub Actions workflow runs 7 NVS matrix configs, 3 fuzz targets, and NVS binary validation on every push to `firmware/`. -See [ADR-061](docs/adr/ADR-061-qemu-esp32s3-firmware-testing.md) for the full architecture. +See [ADR-061](../docs/adr/ADR-061-qemu-esp32s3-firmware-testing.md) for the full architecture. @@ -1634,7 +1634,7 @@ python3 scripts/qemu_swarm.py --preset standard --dry-run **9 swarm assertions**: boot check, crash detection, TDM collision, frame production, coordinator reception, fall detection, frame rate, boot time, heap health. -See [ADR-062](docs/adr/ADR-062-qemu-swarm-configurator.md) and the [User Guide](docs/user-guide.md#testing-firmware-without-hardware-qemu) for step-by-step instructions. +See [ADR-062](../docs/adr/ADR-062-qemu-swarm-configurator.md) and the [User Guide](../docs/user-guide.md#testing-firmware-without-hardware-qemu) for step-by-step instructions. @@ -1657,10 +1657,10 @@ wifi-densepose tasks list # List background tasks
Documentation Links -- [User Guide](docs/user-guide.md) — installation, first run, API, hardware setup, QEMU testing -- [WiFi-Mat User Guide](docs/wifi-mat-user-guide.md) | [Domain Model](docs/ddd/wifi-mat-domain-model.md) -- [ADR-061](docs/adr/ADR-061-qemu-esp32s3-firmware-testing.md) QEMU platform | [ADR-062](docs/adr/ADR-062-qemu-swarm-configurator.md) Swarm configurator -- [ADR-021](docs/adr/ADR-021-vital-sign-detection-rvdna-pipeline.md) | [ADR-022](docs/adr/ADR-022-windows-wifi-enhanced-fidelity-ruvector.md) | [ADR-023](docs/adr/ADR-023-trained-densepose-model-ruvector-pipeline.md) +- [User Guide](../docs/user-guide.md) — installation, first run, API, hardware setup, QEMU testing +- [WiFi-Mat User Guide](../docs/wifi-mat-user-guide.md) | [Domain Model](../docs/ddd/wifi-mat-domain-model.md) +- [ADR-061](../docs/adr/ADR-061-qemu-esp32s3-firmware-testing.md) QEMU platform | [ADR-062](../docs/adr/ADR-062-qemu-swarm-configurator.md) Swarm configurator +- [ADR-021](../docs/adr/ADR-021-vital-sign-detection-rvdna-pipeline.md) | [ADR-022](../docs/adr/ADR-022-windows-wifi-enhanced-fidelity-ruvector.md) | [ADR-023](../docs/adr/ADR-023-trained-densepose-model-ruvector-pipeline.md)
diff --git a/docs/user-guide.md b/docs/user-guide.md index 69324034e..56b838b82 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -366,7 +366,8 @@ Real Channel State Information at 20 Hz with 56-192 subcarriers. Required for po ```bash # From source -./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001 +./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001 \ + --udp-bind 0.0.0.0 --udp-allow # Docker (use CSI_SOURCE environment variable) docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \ @@ -376,6 +377,8 @@ docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \ The ESP32 nodes stream binary CSI frames over UDP to port 5005. See [Hardware Setup](#esp32-s3-mesh) for flashing instructions. +The server only listens for those frames on `127.0.0.1` unless you tell it otherwise (ADR-296), so a native run needs `--udp-bind 0.0.0.0` to hear nodes on your network, plus `--udp-allow` with their IP or subnet (for example `192.168.1.0/24`). Without them the server starts fine but no node ever shows up. If you'd rather accept frames from any source on a trusted network, use `--udp-insecure-lan` instead of `--udp-allow`. + #### Receiving ESP32 frames in Docker The sensing server binds its UDP receiver to `127.0.0.1` by default (ADR-296). Inside a container that address cannot receive anything from a published `5005/udp` port, so no node ever appears. The image's entrypoint binds UDP on `0.0.0.0` only when you set a source guard, and prints a note at startup when you have not: @@ -408,7 +411,8 @@ For higher accuracy with through-wall tracking, deploy 3-6 ESP32-S3 nodes in a * ```bash # Start the aggregator with multistatic mode -./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001 +./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001 \ + --udp-bind 0.0.0.0 --udp-allow ``` The mesh uses a **Time-Division Multiplexing (TDM)** protocol so nodes take turns transmitting, avoiding self-interference. Key features: @@ -443,9 +447,11 @@ cd v2 cargo run -p wifi-densepose-sensing-server -- \ --source esp32 \ --udp-port 5005 \ + --udp-bind 0.0.0.0 \ + --udp-allow \ --http-port 3000 \ --ws-port 3001 \ - --ui-path ../../ui + --ui-path ../ui # Docker docker run --rm \ @@ -912,11 +918,7 @@ Presence, motion, person count, zones and the coarse posture label are still ser ### Matter Bridge (Apple Home / Google Home / Alexa / SmartThings) -```bash -sensing-server --matter --matter-setup-file /var/run/ruview-matter.txt -``` - -Open `/var/run/ruview-matter.txt` for the Matter pairing QR / 11-digit setup code. Scan it from Apple Home / Google Home / your HA Matter integration. RuView appears as a Bridged Device with one occupancy endpoint per node + per zone, plus a momentary switch for fall events. +Not available yet. The `--matter` and `--matter-setup-file` flags from ADR-115 are defined in the crate's library CLI, but the `sensing-server` binary doesn't accept them (`error: unexpected argument '--matter' found`), so there's no way to start the Matter bridge today. Use the MQTT integration above for Home Assistant in the meantime. Detailed entity reference, blueprint catalog, troubleshooting recipe matrix: see [`docs/integrations/home-assistant.md`](integrations/home-assistant.md). @@ -1346,8 +1348,11 @@ The Rust sensing server binary accepts the following flags: | `--http-port` | `8080` | HTTP port for REST API and UI | | `--ws-port` | `8765` | WebSocket port | | `--udp-port` | `5005` | UDP port for ESP32 CSI frames | -| `--ui-path` | (none) | Path to UI static files directory | -| `--tick-ms` | `50` | Simulated frame interval (milliseconds) | +| `--udp-bind` | `127.0.0.1` | Address the UDP receiver listens on. Use `0.0.0.0` to receive frames from nodes on your network; that also needs `--udp-allow` or `--udp-insecure-lan` | +| `--udp-allow` | (none) | Comma-separated IPs/CIDRs allowed to send CSI frames, e.g. `192.168.1.0/24` | +| `--udp-insecure-lan` | off | Accept frames from any source on a routable bind (the UDP data plane is not authenticated) | +| `--ui-path` | `../ui` | Path to UI static files directory (falls back to `./ui` or `../../ui` if it doesn't exist) | +| `--tick-ms` | `100` | Simulated frame interval (milliseconds) | | `--benchmark` | off | Run vital sign benchmark (1000 frames) and exit | | `--train` | off | Train a model from dataset | | `--dataset` | (none) | Path to dataset directory (MM-Fi or Wi-Pose) | @@ -1363,10 +1368,11 @@ The Rust sensing server binary accepts the following flags: ```bash # Simulated mode with UI (development) -./target/release/sensing-server --source simulate --http-port 3000 --ws-port 3001 --ui-path ../../ui +./target/release/sensing-server --source simulate --http-port 3000 --ws-port 3001 --ui-path ../ui # ESP32 hardware mode -./target/release/sensing-server --source esp32 --udp-port 5005 +./target/release/sensing-server --source esp32 --udp-port 5005 \ + --udp-bind 0.0.0.0 --udp-allow # Windows WiFi RSSI ./target/release/sensing-server --source wifi --tick-ms 500 @@ -1711,7 +1717,8 @@ The pipeline runs 10 phases: ### Step 3: Use the Trained Model ```bash -./target/release/sensing-server --model model.rvf --progressive --source esp32 +./target/release/sensing-server --model model.rvf --progressive --source esp32 \ + --udp-bind 0.0.0.0 --udp-allow ``` Progressive loading enables instant startup (Layer A loads in <5ms with basic inference), with full model loading in the background. @@ -1953,17 +1960,7 @@ python firmware/esp32-csi-node/provision.py --port COM7 \ Replace `192.168.1.20` with the IP of the machine running the sensing server. -**Mesh key provisioning (secure mode):** - -For multistatic mesh deployments with authenticated beacons (ADR-032), provision a shared mesh key: - -```bash -python firmware/esp32-csi-node/provision.py --port COM7 \ - --ssid "YourWiFi" --password "YourPassword" --target-ip 192.168.1.20 \ - --mesh-key "$(openssl rand -hex 32)" -``` - -All nodes in a mesh must share the same 256-bit mesh key for HMAC-SHA256 beacon authentication. The key is stored in ESP32 NVS flash and zeroed on firmware erase. +**Mesh key provisioning:** ADR-032 describes giving every node in a multistatic mesh a shared mesh key, but that isn't wired up yet. `provision.py` has no `--mesh-key` option and the firmware doesn't read a mesh key from NVS, so there's nothing to provision for now. ### ESP32-C6 (Wi-Fi 6 + 802.15.4 research target — ADR-110) @@ -2109,8 +2106,9 @@ Binary size: 990 KB (8MB flash, 52% free) or 773 KB (4MB flash). v0.5.0 adds mmW **Start the aggregator:** ```bash -# From source -./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001 +# From source (--udp-bind/--udp-allow let it hear nodes on your network) +./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001 \ + --udp-bind 0.0.0.0 --udp-allow # Docker (see "Receiving ESP32 frames in Docker" for the UDP source guard) docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \ @@ -2378,19 +2376,19 @@ WiFi sensing can monitor health metrics without any wearable or camera: ```bash # Sleep quality monitoring (run overnight) -node scripts/sleep-monitor.js --port 5006 --bind 192.168.1.20 +node scripts/sleep-monitor.js --port 5006 # Breathing disorder pre-screening -node scripts/apnea-detector.js --port 5006 --bind 192.168.1.20 +node scripts/apnea-detector.js --port 5006 # Stress detection via heart rate variability -node scripts/stress-monitor.js --port 5006 --bind 192.168.1.20 +node scripts/stress-monitor.js --port 5006 # Walking analysis + tremor detection -node scripts/gait-analyzer.js --port 5006 --bind 192.168.1.20 +node scripts/gait-analyzer.js --port 5006 -# Replay on recorded data (no live hardware needed) -node scripts/sleep-monitor.js --replay data/recordings/*.csi.jsonl +# Replay one recording (no live hardware needed) +node scripts/sleep-monitor.js --replay data/recordings/.csi.jsonl ``` > **Note:** These are pre-screening tools, not medical devices. Consult a healthcare professional for diagnosis. @@ -2837,7 +2835,8 @@ The server applies a 3-stage smoothing pipeline (ADR-048). If readings are still - Verify the sensing server is running: `curl http://localhost:3000/health` - Access Observatory via the server URL: `http://localhost:3000/ui/observatory.html` (not a file:// URL) -- If a standalone `aggregator` command is already listening on UDP `:5005`, stop it and run `sensing-server --source esp32 --udp-port 5005` instead; the Observatory reads the server WebSocket, not the standalone aggregator output +- If a standalone `aggregator` command is already listening on UDP `:5005`, stop it and run `sensing-server --source esp32 --udp-port 5005 --udp-bind 0.0.0.0 --udp-allow ` instead; the Observatory reads the server WebSocket, not the standalone aggregator output +- On a native run, make sure the server was started with `--udp-bind 0.0.0.0` and `--udp-allow` (or `--udp-insecure-lan`). It listens on `127.0.0.1` by default, so frames from your nodes never reach it otherwise - Verify the ESP32 nodes are provisioned to the IP address of the machine running `sensing-server` - Hard refresh with Ctrl+Shift+R to clear cached settings - The auto-detect probes `/health` on the same origin — cross-origin won't work diff --git a/harness/ruview/.harness/manifest.json b/harness/ruview/.harness/manifest.json index 6b30fcadf..84d7c157a 100644 --- a/harness/ruview/.harness/manifest.json +++ b/harness/ruview/.harness/manifest.json @@ -3,7 +3,7 @@ "generator": "RuView metaharness provenance v2", "template": "vertical:ruview", "name": "@ruvnet/ruview", - "version": "0.9.1", + "version": "0.9.2", "hosts": [ "claude-code", "codex" @@ -23,7 +23,7 @@ ".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f", "CLAUDE.md": "25f9306b6caae22078cb9375108b6f6bb813d83e617739f6d9372f116b0ede5d", "LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06", - "README.md": "a0ee4d09ecef1b125c9d96a6ec8cdfce869bb727c986e650bb7941b21d23ddce", + "README.md": "ae06aa31b2e1f93902a8a05070b5248327bc3aa17219ea4611d29a95ea7efc11", "bin/cli.js": "8ebf6ca4b6d9529a5e338374c19329c1a2fc2f9e52f13b4341928b7168ee461b", "brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572", @@ -32,7 +32,7 @@ "flywheel/genome.json": "75db44a3cab70d9459fc8c07863f640ac1214bfaa243483939e1506d63f51214", "flywheel/replay.mjs": "0670ca0b03701f4afe0b4bca8a3d58d481676b61a94a5b98c6a425aefb1159ab", "flywheel/run.mjs": "6d4f97db16900c45367b6538848cbe1915af999e663720dfc51f2bb1698f1cd0", - "package.json": "01627b1b2c804c6dddfea1b366058c23c0e99111829dcd3e32967b76d0bce997", + "package.json": "d1d313a9e57244c817d688918c150c46aaf7aeb50ca65c0d982690e302ef7fdd", "scripts/sync-skills.mjs": "43715dab61e204dc91bbd61755810e8fdb2f66e2b0c0bd791b4bf48a2e293565", "scripts/update-manifest.mjs": "8f56764b8f70aed55da0c7e2417ae875b0d58d781d839b6db7f115f08af61e6b", "scripts/verify-manifest.mjs": "6491a221762efcfeb3e749ecab243b204f17fd5bc871f3d4025597f31b8f0f10", @@ -44,38 +44,38 @@ "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/cli-ui.js": "5ac04c5790f03df708c24a6dd366b51ff33a643691642b689284bcada25c9d9a", + "src/cli.js": "bb7989f4660566cd016a0cb3c44b6e816d19618ed94337398488732056e8202b", + "src/devices/esp32.js": "21b8c74d9b0a47f52538b5d902faa32ac6f4c58dab94d6612d7fbc89237d8f80", + "src/devices/esphome.js": "d4429fd09c8d93fdc2e5926305d8193c98e074affc48deabda4b16bb9b620056", + "src/devices/lidar.js": "d9a91e2b6eec21463bbb950fc333e9d0ebd2699a7c533d534323bb4d6a55bd97", + "src/devices/mmwave.js": "2ac0be6c4be3c71d147d4bc57b8fd516d051234a8897295bc9e7f9c36c8ae710", "src/devices/registry.js": "26725e43adaa908b41462f492c5f6bf63e9193f3ee076e3d64ebaa012b66f6be", - "src/devices/serial-pump.js": "bb1351cd129fd00aa71a5d06104efcff5c0a56f3507e39e1df05395fe7fe7844", - "src/doctor.js": "f139c2446b469afe6f5b507685836c65ee318c8f16b1ef29819ba828bbf7828f", + "src/devices/serial-pump.js": "86fd00708c7f74ca004eee1b729821aa10a70bfd394cc556dc0ec4b64b43b149", + "src/doctor.js": "e5feccab91da3fdad16316ed7fd9090218a55b6de48772219ced96a930b87e75", "src/exec.js": "8889d13b8a8277f322856e743c79ac8f29b707251eaf9757c46d3f745743ae22", - "src/firmware.js": "1af024e8e1cd75d43f9e8ef6ff91d0413cf5f4cd84c368d1407e690b051cac4d", + "src/firmware.js": "a38e9ea3da8b60ffff662c73525418b4e6ffe58f7c1a0797b82defdda4908457", "src/guardrails.js": "aacc8fa6088f7f1ccea3a0b02171a5c516b95d3416ee3ba87add3879a1d6aaad", "src/guidance.js": "1e0ccee808a736319b3f8257a3fc4fc0f8d9f8956970887d597870bb8d615949", "src/hosts/claude-code.js": "2212bc39b49822018800dfe33a471e56bbb4c5233d716bfa7aa4fff77aa23edb", "src/hosts/codex.js": "d41ecd132ce2db7b47aad9cebbc020d70e6810d48c3554858d099ff2e8f6608b", "src/hosts/index.js": "ab276c41ab722bcdf72c2d1649cecbb760ae05c41c1372aae4c2447aa7c11539", "src/kernel.js": "48dfd218a747dd158f82ae108e7e8ed89a82cd68649214d23266112dc9033f5d", - "src/mcp-http.js": "6eb36fa546f333e32ec6283e711d2c93914d4de48a6a3b47fce963c5ec711f5d", + "src/mcp-http.js": "b72e021ae00d01831ef7184db824f5e85a2b8747e1d8e994b6a1cb1cb4d08945", "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/sdk.d.ts": "d4c80689248c197b2ddb1ebf4cd7d8f2e55716722074d9e1cdb8029b2dfa19f7", + "src/sdk.js": "1ac29fb389ed86e0f62ecfddbbd31614c58e832a8181495caa83fe5cf8b30bc4", "src/spaces.js": "45ef786537cb2a446db5e926e5a1c10b73639d2767dec84611f914f78d4325eb", - "src/tools.js": "7c41347b4be063457d7759ddb8f1f5dd608cbe28baa1c80e4a27d6567af6bc98", - "src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956", + "src/tools.js": "1b81750fe6271fa632bf7dfc35f7a072ef3f26baeed9c58cfbc82ad27b0fed30", + "src/training.js": "bd8a6c12eba4f9d2f8990fa9692cd04c743ea247f9b13e10f8717ceefcbd09ac", "src/ui/console-widget.js": "0d2f707d57b5ec54968dee1dc80f31c7db9152dcadfc48700c18379dabb55462" }, - "filesDigest": "60935843295b67f3354b4b02cc31df62bac8b5093568b11f896dfc7fc3a91545", + "filesDigest": "52d75ef1509cb993b96291fd28836baeb284a05a99ae1e7fac6a1356f9d80b7a", "brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd", "developmentPins": { diff --git a/harness/ruview/.harness/manifest.sha256 b/harness/ruview/.harness/manifest.sha256 index b71ca67bf..5cc32a1c3 100644 --- a/harness/ruview/.harness/manifest.sha256 +++ b/harness/ruview/.harness/manifest.sha256 @@ -1 +1 @@ -0f73608c5acd0547ef850c0944ef76633a40e957c226ce555309832f87491a1c manifest.json +c76cd7d8c4e204d8b8ba2fa9fdc787fcaa6adcea7fb6f0c62328ce87d01ab390 manifest.json diff --git a/harness/ruview/README.md b/harness/ruview/README.md index 0004cb992..e0abaa348 100644 --- a/harness/ruview/README.md +++ b/harness/ruview/README.md @@ -309,7 +309,8 @@ Clients that can send headers use `Authorization: Bearer ` 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:///mcp/` as the connector URL. Write grants (`hardware-write`, `workspace-write`) are never -honoured over HTTP: flashing and calibration stay on the CLI and stdio. +honoured over HTTP: flashing and calibration stay on the CLI and stdio. Malformed request targets return HTTP 400 +with `invalid_request_target`; the transport continues serving subsequent requests. In a terminal, commands print a formatted view; pipes and `--json` print JSON. `ruview esp32 --watch` redraws live capture windows with per-node trends. diff --git a/harness/ruview/package-lock.json b/harness/ruview/package-lock.json index 1ee96b5b9..26101e8a1 100644 --- a/harness/ruview/package-lock.json +++ b/harness/ruview/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ruvnet/ruview", - "version": "0.9.1", + "version": "0.9.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ruvnet/ruview", - "version": "0.9.1", + "version": "0.9.2", "license": "MIT", "bin": { "ruview-harness": "bin/cli.js" diff --git a/harness/ruview/package.json b/harness/ruview/package.json index 6672651d5..06d44e5ed 100644 --- a/harness/ruview/package.json +++ b/harness/ruview/package.json @@ -1,7 +1,7 @@ { "name": "@ruvnet/ruview", - "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.", + "version": "0.9.2", + "description": "RuView WiFi-sensing operator harness — 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-harness": "bin/cli.js" diff --git a/harness/ruview/src/cli-ui.js b/harness/ruview/src/cli-ui.js index 5ebec3c52..363277c9d 100644 --- a/harness/ruview/src/cli-ui.js +++ b/harness/ruview/src/cli-ui.js @@ -29,12 +29,13 @@ export function bar(frac, width = 10) { 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). */ +/** Sparkline of capture windows; missing samples retain their time position. */ export function sparkline(values) { const v = values.filter((x) => typeof x === 'number' && Number.isFinite(x)); - if (!v.length) return ''; + if (!v.length) return '·'.repeat(values.length); 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(''); + return values.map((x) => typeof x === 'number' && Number.isFinite(x) + ? BARS[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)] : '·').join(''); } /** Align rows (arrays of cells) into columns. */ diff --git a/harness/ruview/src/cli.js b/harness/ruview/src/cli.js index 6a0944fb1..7b99d9e76 100644 --- a/harness/ruview/src/cli.js +++ b/harness/ruview/src/cli.js @@ -45,12 +45,15 @@ const BOOLEAN_FLAGS = new Set(['confirm', 'cuda', 'allow_unverified', 'analyze', // Presentation-only flags never reach a tool schema. const UI_FLAGS = new Set(['json', 'watch', 'interval']); +// Leave absent flag values uncoerced so the tool schema rejects them. +const numericFlag = (value) => typeof value === 'boolean' || (typeof value === 'string' && value.trim() === '') ? value : Number(value); + 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); + if (NUMERIC_FLAGS.has(k)) out[k] = numericFlag(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; @@ -90,15 +93,20 @@ async function watchCapture(args, flags) { const tty = process.stdout.isTTY && flags.json !== true; while (!stop) { const res = await runTool('ruview_esp32_capture', window, { source: 'cli' }); + const present = new Set((res.nodes || []).map(n => `${n.source}:${n.nodeId}`)); + for (const [key, values] of history) { + if (!present.has(key)) history.set(key, [...values, null].slice(-30)); + } 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; } + const terminalError = ['port_in_use', 'invalid_port', 'invalid_bind', 'invalid_arguments'].includes(res.reason); + if (!tty) { console.log(JSON.stringify(res)); if (terminalError) return 1; 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; + if (terminalError) return 1; } return 0; } diff --git a/harness/ruview/src/devices/esp32.js b/harness/ruview/src/devices/esp32.js index ada746d0f..a49fd3c40 100644 --- a/harness/ruview/src/devices/esp32.js +++ b/harness/ruview/src/devices/esp32.js @@ -141,8 +141,8 @@ export function parsePacket(buf, opts = {}) { ...(chain0 ? { chain0Amplitudes: chain0 } : {}), }; } - if (kind === 'vitals') { - if (buf.length < 32) return { kind: 'malformed', reason: 'vitals length' }; + if (kind === 'vitals' || kind === 'fused-vitals') { + if (buf.length < (kind === 'fused-vitals' ? 48 : 32)) return { kind: 'malformed', reason: `${kind} length` }; const flags = buf.readUInt8(5); return { kind, source: 'esp32', nodeId: buf.readUInt8(4), @@ -150,6 +150,11 @@ export function parsePacket(buf, opts = {}) { 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), + ...(kind === 'fused-vitals' ? { + mmwavePresent: Boolean(flags & 8), mmwaveType: buf.readUInt8(14), fusionConfidence: buf.readUInt8(15), + mmwaveHeartBpm: buf.readFloatLE(28), mmwaveBreathingBpm: buf.readFloatLE(32), mmwaveDistanceCm: buf.readFloatLE(36), + mmwaveTargets: buf.readUInt8(40), mmwaveConfidence: buf.readUInt8(41), + } : {}), }; } if (kind === 'sync') { @@ -159,6 +164,22 @@ export function parsePacket(buf, opts = {}) { leader: Boolean(buf.readUInt8(6) & 1), highWaterSeq: buf.readUInt32LE(24), }; } + if (kind === 'features' && buf.length < 48) return { kind: 'malformed', reason: 'features length' }; + if (kind === 'compressed') { + if (buf.length < 10) return { kind: 'malformed', reason: 'compressed header' }; + const originalLength = buf.readUInt16LE(6); + const compressedLength = buf.readUInt16LE(8); + if (!originalLength || !compressedLength || buf.length !== 10 + compressedLength) return { kind: 'malformed', reason: 'compressed payload' }; + } + if (kind === 'feature-state') { + if (buf.length !== 60) return { kind: 'malformed', reason: 'feature-state length' }; + if (crc32(buf, 56) !== buf.readUInt32LE(56)) return { kind: 'malformed', reason: 'feature-state crc' }; + } + if (kind === 'wasm-output') { + if (buf.length < 8) return { kind: 'malformed', reason: 'wasm-output header' }; + const events = buf.readUInt16LE(6); + if (events > 16 || buf.length !== 8 + 5 * events) return { kind: 'malformed', reason: 'wasm-output payload' }; + } return { kind, source: 'esp32', nodeId: buf.length > 4 ? buf.readUInt8(4) : null, bytes: buf.length }; } @@ -178,15 +199,22 @@ function trackSequence(n, seq) { if (n.lastSeq === null) { n.lastSeq = seq; return; } const d = seq - n.lastSeq; if (d >= 1 && d <= SEQ_WINDOW) { + // Only distinct packets corresponding to counted holes can recover loss. + // Retain at most the existing reorder window; older gaps remain counted. + for (const missing of n.missingSeq) { + if (seq - missing >= SEQ_WINDOW) n.missingSeq.delete(missing); + } + for (let missing = n.lastSeq + 1; missing < seq; missing++) n.missingSeq.add(missing); 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; + if (d < 0 && n.missingSeq.delete(seq)) 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.missingSeq.clear(); n.lastSeq = seq; n.pendingSeq = null; } else { @@ -226,7 +254,7 @@ export class NodeStats { 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 }; + n = { source: pkt.source, nodeId: pkt.nodeId ?? -1, packets: {}, rssiSum: 0, rssiCount: 0, lastSeq: null, pendingSeq: null, seqGaps: 0, missingSeq: new Set(), 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; @@ -234,10 +262,10 @@ export class NodeStats { 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; + const sh = n.shapes.get(shape) || { count: 0, ampSum: 0, last: null, synthetic: false }; + sh.count += 1; sh.ampSum += pkt.meanAmplitude; sh.last = pkt; sh.synthetic ||= Boolean(pkt.synthetic); n.shapes.set(shape, sh); - } else if (pkt.kind === 'vitals') { + } else if (pkt.kind === 'vitals' || pkt.kind === 'fused-vitals') { n.rssiSum += pkt.rssi; n.rssiCount += 1; const { kind, nodeId, source, ...rest } = pkt; n.vitals = rest; @@ -255,7 +283,7 @@ export class NodeStats { 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 }), + ...(p.source === 'esp32' ? { freqMhz: p.freqMhz, ppdu: p.ppdu } : { channel: p.channel, protocol: p.protocol, bitsPerTone: p.bitsPerTone, synthetic: sh.synthetic }), bw40: p.bw40, meanAmplitude: Number((sh.ampSum / sh.count).toFixed(2)), }; }; @@ -297,15 +325,16 @@ class FrameCollector { 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) { c = { source: pkt.source, nodeId: pkt.nodeId, subcarriers: pkt.subcarriers, frames: [], first: t, last: t, dropped: 0, synthetic: false }; this.byNode.set(key, c); } if (c.frames.length >= this.max) { c.dropped += 1; return; } + c.synthetic ||= Boolean(pkt.synthetic); 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; + return all.sort((a, b) => (b.frames.length + b.dropped) - (a.frames.length + a.dropped))[0] || null; } } @@ -397,13 +426,15 @@ export function captureEsp32(args = {}, deps = {}) { return new Promise((resolve) => { const socket = createSocket(bind.includes(':') ? 'udp6' : 'udp4'); let timer; + let startedAt = null; 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 elapsedSeconds = startedAt === null ? 0 : Math.max((performance.now() - startedAt) / 1000, Number.EPSILON); + const summary = stats.summary(elapsedSeconds || seconds); const csiNodes = summary.nodes.filter((n) => (n.packets.csi || 0) > 0).length; const decoded = packets - summary.unknownPackets - summary.malformedPackets; let failure = null; @@ -417,7 +448,7 @@ export function captureEsp32(args = {}, deps = {}) { const result = { ok: !failure, ...(failure || {}), - listen: `${bind}:${port}`, seconds, packets, decodedPackets: decoded, csiNodes, ...summary, + listen: `${bind}:${port}`, seconds, elapsedSeconds, packets, decodedPackets: decoded, csiNodes, ...summary, evidence: failure ? null : 'MEASURED: live UDP packets received and decoded on this host', ...extra, }; @@ -445,7 +476,7 @@ export function captureEsp32(args = {}, deps = {}) { } if (packets >= maxPackets) finish({ truncated: true }); }); - socket.bind(port, bind, () => { timer = setTimeout(() => finish(), seconds * 1000); }); + socket.bind(port, bind, () => { startedAt = performance.now(); timer = setTimeout(() => finish(), seconds * 1000); }); }); } @@ -456,14 +487,16 @@ async function runAnalysis(collector, nodeId, analyzeFn) { } 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)) }; + const input = { source: c.source, nodeId: c.nodeId, frames: c.frames.length, droppedOverCap: c.dropped, synthetic: c.synthetic, 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.', + note: c.synthetic + ? 'Signal-processing estimates include firmware-marked synthetic CSI with no reference measurement; not clinical or camera-grade. The sample rate is the measured arrival rate.' + : '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) }; diff --git a/harness/ruview/src/devices/esphome.js b/harness/ruview/src/devices/esphome.js index 342e3bb21..942599f92 100644 --- a/harness/ruview/src/devices/esphome.js +++ b/harness/ruview/src/devices/esphome.js @@ -135,9 +135,9 @@ async function resolveHost(host, deps) { // --- 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)], + ['heartBpm', (e) => e.kind === 'sensor' && /heart/i.test(e.name + e.objectId)], + ['breathingBpm', (e) => e.kind === 'sensor' && /respir|breath/i.test(e.name + e.objectId)], + ['distanceCm', (e) => e.kind === 'sensor' && /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)], ]; @@ -151,7 +151,11 @@ export function summarizeEsphome(session, seconds) { 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)); + const toCm = (e) => { + const unit = (e?.unit || '').toLowerCase(); + const scale = unit === 'm' ? 100 : unit === 'mm' ? 0.1 : 1; + return values(e).map((value) => value * scale); + }; return { source: 'esphome', device: session.device, @@ -166,7 +170,7 @@ export function summarizeEsphome(session, seconds) { 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, + updates: e.updates ?? e.samples.length, sampledUpdates: e.samples.length, droppedSamples: Math.max(0, (e.updates ?? e.samples.length) - e.samples.length), last: e.last ? (e.last.missing ? null : e.last.value) : null, })), }; } @@ -250,7 +254,7 @@ export async function readEsphome(args = {}, deps = {}) { 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 }); + 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, updates: 0 }); break; } case T.LIST_DONE: @@ -260,8 +264,9 @@ export async function readEsphome(args = {}, deps = {}) { 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) }; + const sample = { value, missing: fBool(f, 3) || (type === T.STATE_SENSOR && !Number.isFinite(value)) }; session.updates += 1; + e.updates += 1; if (e.samples.length < 10_000) e.samples.push(sample); e.last = sample; break; diff --git a/harness/ruview/src/devices/lidar.js b/harness/ruview/src/devices/lidar.js index 0ddba2f8a..5034cb0e9 100644 --- a/harness/ruview/src/devices/lidar.js +++ b/harness/ruview/src/devices/lidar.js @@ -41,17 +41,21 @@ export function parseRplidar(bytes) { const median = (xs) => { if (!xs.length) return null; const s = [...xs].sort((a, b) => a - b); - return s[Math.floor(s.length / 2)]; + const middle = Math.floor(s.length / 2); + return s.length % 2 ? s[middle] : (s[middle - 1] + s[middle]) / 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)); + let min = Infinity; + let max = -Infinity; + for (const value of d) { min = Math.min(min, value); max = Math.max(max, value); } 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, + rangeMm: d.length ? { min, median: median(d), max } : null, angularCoverage: Number((sectors.size / 36).toFixed(2)), }; } @@ -69,7 +73,7 @@ export async function readRplidar(args, deps) { 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' }; + return { ok: true, source: 'rplidar', bytes: cap.bytes.length, ...summary, ...(cap.captureIncomplete ? { captureIncomplete: true } : {}), ...(cap.warnings ? { warnings: cap.warnings } : {}), evidence: 'MEASURED: live scan read on this host' }; } /** Validate and reduce one ruview.lidar.depth.v1 packet to statistics. */ @@ -162,6 +166,7 @@ export function readIphoneLidar(args, deps = {}) { 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?)' : ''}` }); + else finish(); // The closed stream cannot contribute another frame. }; }); } diff --git a/harness/ruview/src/devices/mmwave.js b/harness/ruview/src/devices/mmwave.js index 035aa32c7..013f57085 100644 --- a/harness/ruview/src/devices/mmwave.js +++ b/harness/ruview/src/devices/mmwave.js @@ -119,7 +119,7 @@ const mean = (xs) => (xs.length ? Number((xs.reduce((a, b) => a + b, 0) / xs.len /** 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 pick = (key, include = () => true) => frames.filter(include).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; @@ -129,7 +129,7 @@ export function summarizeMmwave(model, frames, errors, bytes, seconds) { 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')), + distanceCmMean: mean(pick('distanceCm', (f) => f.type !== 'target' || f.present !== false)), last: frames.slice(-5), }; } @@ -164,9 +164,11 @@ export async function readMmwave(args, deps) { 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; + if (frames.length < (auto ? 2 : 1)) continue; let bytes = cap.bytes.length; let elapsed = cap.seconds; + let captureIncomplete = Boolean(cap.captureIncomplete); + const warnings = [...(cap.warnings || [])]; const remaining = seconds - probeSeconds; if (auto && remaining >= 0.5) { const more = await capture(model, remaining); @@ -177,12 +179,19 @@ export async function readMmwave(args, deps) { errors += extra.errors; bytes += more.bytes.length; elapsed += more.seconds; + captureIncomplete ||= Boolean(more.captureIncomplete); + warnings.push(...(more.warnings || [])); + } else { + captureIncomplete = true; + warnings.push('remaining serial capture failed; only probe frames retained'); } } return { ok: true, ...summarizeMmwave(model, frames, errors, bytes, elapsed), ...(auto ? { detected: model } : {}), + ...(captureIncomplete ? { captureIncomplete: true } : {}), + ...(warnings.length ? { warnings } : {}), evidence: 'MEASURED: device-reported radar values read on this host (not an accuracy claim)', }; } diff --git a/harness/ruview/src/devices/serial-pump.js b/harness/ruview/src/devices/serial-pump.js index 617d86253..affbf8201 100644 --- a/harness/ruview/src/devices/serial-pump.js +++ b/harness/ruview/src/devices/serial-pump.js @@ -72,5 +72,8 @@ export async function readSerial(opts, deps) { 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'] } : {}) }; + const warnings = []; + if (out.includes('DTR_UNSUPPORTED')) warnings.push('port does not support DTR control; an RPLIDAR A1 motor may not spin'); + if (!r.ok) warnings.push('serial capture process failed; partial bytes retained'); + return { ok: true, bytes, seconds, ...(!r.ok ? { captureIncomplete: true } : {}), ...(warnings.length ? { warnings } : {}) }; } diff --git a/harness/ruview/src/doctor.js b/harness/ruview/src/doctor.js index 558454f95..c49f78260 100644 --- a/harness/ruview/src/doctor.js +++ b/harness/ruview/src/doctor.js @@ -25,17 +25,33 @@ const sha = (value) => createHash('sha256').update(value).digest('hex'); 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); + let raw; let manifest; let entries; + try { + raw = readFileSync(path); + manifest = JSON.parse(raw); + entries = Object.entries(manifest.files || {}); + } catch { + return { ok: false, files: 0, findings: ['manifest.json:unreadable-or-invalid'] }; + } const findings = []; - for (const [name, expected] of Object.entries(manifest.files || {})) { + for (const [name, expected] of entries) { 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`); + else { + try { + if (sha(readFileSync(target, 'utf8').replace(/\r\n/g, '\n')) !== expected) findings.push(`${name}:hash-mismatch`); + } catch { findings.push(`${name}:unreadable`); } + } } - 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 checksum = join(root, '.harness', 'manifest.sha256'); + if (!existsSync(checksum)) findings.push('manifest.sha256:missing'); + else { + try { + const outer = readFileSync(checksum, 'utf8').trim().split(/\s+/)[0]; + if (sha(raw) !== outer) findings.push('manifest.sha256:mismatch'); + } catch { findings.push('manifest.sha256:unreadable'); } + } + return { ok: findings.length === 0, files: entries.length, findings }; } const firstLine = (text) => String(text || '').trim().split(/\r?\n/)[0]?.slice(0, 200) || ''; diff --git a/harness/ruview/src/firmware.js b/harness/ruview/src/firmware.js index 76e2b04d3..71da15566 100644 --- a/harness/ruview/src/firmware.js +++ b/harness/ruview/src/firmware.js @@ -21,9 +21,9 @@ export const FIRMWARE_VARIANTS = Object.freeze({ /** Image layout shared by the S3 and C6 partition tables (README §2). */ const IMAGES = Object.freeze([ { role: 'bootloader', offset: '0x0', names: ['bootloader.bin', 'bootloader/bootloader.bin'], required: true }, - { role: 'partition-table', offset: '0x8000', names: ['partition-table.bin', 'partition_table/partition-table.bin'], names4mb: ['partition-table-4mb.bin'], required: true }, + { role: 'partition-table', offset: '0x8000', names: ['partition-table.bin', 'partition_table/partition-table.bin'], names4mb: ['partition-table-4mb.bin'], namesC6: ['partition-table-c6.bin'], required: true }, { role: 'otadata', offset: '0xf000', names: ['ota_data_initial.bin'], required: false }, - { role: 'app', offset: '0x20000', names: ['esp32-csi-node.bin'], names4mb: ['esp32-csi-node-4mb.bin'], required: true }, + { role: 'app', offset: '0x20000', names: ['esp32-csi-node.bin'], names4mb: ['esp32-csi-node-4mb.bin'], namesC6: ['esp32-csi-node-c6.bin'], required: true }, ]); const MAX_IMAGE_BYTES = 16 * 1024 * 1024; @@ -88,7 +88,8 @@ export function resolveBundle(bundleDir, variant, { requireChecksums = true } = } const images = []; for (const image of IMAGES) { - const candidates = [...(variant === 's3-4mb' && image.names4mb ? image.names4mb : []), ...image.names]; + const variantNames = variant === 'c6' ? image.namesC6 : variant === 's3-4mb' ? image.names4mb : []; + const candidates = [...(variantNames ?? []), ...image.names]; const found = candidates.map((n) => join(root, n)).find((p) => existsSync(p)); if (!found) { if (image.required) throw new FirmwareError('image_missing', `bundle is missing ${image.role} (${candidates.join(' | ')})`); diff --git a/harness/ruview/src/mcp-http.js b/harness/ruview/src/mcp-http.js index fa5847799..bfd4761fd 100644 --- a/harness/ruview/src/mcp-http.js +++ b/harness/ruview/src/mcp-http.js @@ -64,7 +64,10 @@ export function startMcpHttp(opts = {}) { const { dispatch } = createDispatcher({ source: 'mcp', transport: 'http', grants }, opts.handler || handleRpc); const server = http.createServer((req, res) => { - const url = new URL(req.url || '/', 'http://localhost'); + let url; + try { url = new URL(req.url || '/', 'http://localhost'); } catch { + return send(res, 400, { error: 'invalid_request_target' }); + } if (req.method === 'GET' && url.pathname === '/healthz') return send(res, 200, { ok: true, name: SERVER_INFO.name, version: SERVER_INFO.version }); const segments = url.pathname.split('/').filter(Boolean); diff --git a/harness/ruview/src/sdk.d.ts b/harness/ruview/src/sdk.d.ts index 37df2f5b4..d79c3df66 100644 --- a/harness/ruview/src/sdk.d.ts +++ b/harness/ruview/src/sdk.d.ts @@ -105,9 +105,11 @@ export interface RuView { udp_port?: number; bind?: '0.0.0.0' | '127.0.0.1' | '::' | '::1'; seconds?: number; max_packets?: number; /** Run live single-antenna CSI through @ruvnet/ruview-kernel (optional package). */ analyze?: boolean; node_id?: number; backend?: 'wasm' | 'napi' | 'auto'; analyze_max_frames?: number; + /** Return recent binned amplitude frames for the waterfall view. */ + spectrum?: boolean; spectrum_bins?: number; spectrum_frames?: number; }): Promise; - mmwave(args: { port: string; model?: 'auto' | 'mr60bha2' | 'ld2410'; seconds?: number }): Promise; - lidar(args: { source: 'rplidar'; port: string; baud?: 115200 | 256000 | 460800 | 921600; seconds?: number } | { source: 'iphone'; url: string; seconds?: number }): Promise; + mmwave(args: { source?: 'serial'; port: string; model?: 'auto' | 'mr60bha2' | 'ld2410'; seconds?: number } | { source: 'esphome'; host: string; api_port?: number; seconds?: number }): Promise; + lidar(args: { source: 'rplidar'; port: string; baud?: 115200 | 256000 | 460800 | 921600; seconds?: number } | { source: 'iphone'; url: string; seconds?: number; max_frames?: number }): Promise; }; hosts: { list(): Promise; diff --git a/harness/ruview/src/sdk.js b/harness/ruview/src/sdk.js index 6d2e3106d..56dce5195 100644 --- a/harness/ruview/src/sdk.js +++ b/harness/ruview/src/sdk.js @@ -31,22 +31,22 @@ export class RuViewError extends Error { } export function createRuView({ strict = false, deps = OPERATOR_DEPS } = {}) { - const call = async (tool, args = {}) => { - const result = await runTool(tool, args, { source: 'sdk' }); + const checked = (tool, result) => { if (strict && result?.ok === false && !result.dryRun) throw new RuViewError(tool, result); return result; }; + const call = async (tool, args = {}) => checked(tool, await runTool(tool, args, { source: 'sdk' })); return Object.freeze({ /** Raw access to any registry tool by canonical name. */ call, tools: () => listTools(), /** Structured diagnostics; options: { groups, port, probe_port, sensing_url }. */ - doctor: (options = {}) => runDoctor(options, deps), + doctor: async (options = {}) => checked('ruview_doctor', await runDoctor(options, deps)), onboard: (path) => call('ruview_onboard', path ? { path } : {}), guidance: (args = {}) => call('ruview_guidance', args), - memorySearch: (query, limit) => call('ruview_memory_search', limit ? { query, limit } : { query }), + memorySearch: (query, limit) => call('ruview_memory_search', limit !== undefined ? { query, limit } : { query }), claimCheck: (text) => call('ruview_claim_check', { text }), verify: (repo) => call('ruview_verify', repo ? { repo } : {}), spaces: (args = {}) => call('ruview_spaces_list', args), diff --git a/harness/ruview/src/tools.js b/harness/ruview/src/tools.js index b3081b13a..05d0b055a 100644 --- a/harness/ruview/src/tools.js +++ b/harness/ruview/src/tools.js @@ -170,7 +170,7 @@ export const MONITOR_SCRIPT = [ ' if not ln: continue', " s=ln.decode('utf-8','replace')", ' n+=1', - " if 'CSI cb' in s or 'csi_collector' in s or 'RUVIEW_CSI: frame #' in s: csi+=1", + " if 'CSI cb #' in s or 'RUVIEW_CSI: frame #' in s: csi+=1", " if 'MGMT+DATA' in s: print('UPGRADE_MGMT_DATA')", " if 'lack of csi buf' in s or 'csi buf not enough' in s: starved+=1", 'ser.close()', diff --git a/harness/ruview/src/training.js b/harness/ruview/src/training.js index fcf551e39..e5e7476cd 100644 --- a/harness/ruview/src/training.js +++ b/harness/ruview/src/training.js @@ -18,6 +18,7 @@ export const TRAIN_MODES = Object.freeze(['pose-smoke', 'pose', 'room']); const isNum = (v) => typeof v === 'number' && Number.isFinite(v); const toPct = (v) => (v <= 1 ? v * 100 : v); +const timeMillis = (value) => isNum(value) ? value : typeof value === 'string' ? Date.parse(value) : NaN; /** * Evaluate a training report. Fields: @@ -44,8 +45,8 @@ export function trainingGate(report = {}) { } else warn('subjects_unreported', 'train_subjects/test_subjects not reported; cross-person generalization is unknown.'); if (report.split === 'chronological' || report.split === 'blocked-gap') { - const end = Date.parse(report.train_end ?? '') || (isNum(report.train_end) ? report.train_end : NaN); - const start = Date.parse(report.test_start ?? '') || (isNum(report.test_start) ? report.test_start : NaN); + const end = timeMillis(report.train_end); + const start = timeMillis(report.test_start); if (!Number.isFinite(end) || !Number.isFinite(start)) fail('time_bounds_missing', 'chronological splits need train_end and test_start.'); else if (start <= end) fail('temporal_leakage', 'test_start must be after train_end.'); } diff --git a/harness/ruview/test/alignment-cli-window-validation.test.mjs b/harness/ruview/test/alignment-cli-window-validation.test.mjs new file mode 100644 index 000000000..fe85e02a6 --- /dev/null +++ b/harness/ruview/test/alignment-cli-window-validation.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +const script=fileURLToPath(new URL('../../../scripts/align-ground-truth.js',import.meta.url)); +function alignment(extra, inspect) { + const dir=mkdtempSync(join(tmpdir(),'ruview-align-args-')); + try { + const gt=join(dir,'gt.jsonl'),csi=join(dir,'csi.jsonl'),output=join(dir,'paired.jsonl'); + const t=1750000000; + writeFileSync(gt,[0,.01,.02,.03].map(d=>JSON.stringify({ts_ns:Math.round((t+d)*1e9),keypoints:Array.from({length:17},()=>[.5,.5]),confidence:1,n_visible:17})).join('\n')+'\n'); + writeFileSync(csi,[0,.01,.02,.03].map(d=>JSON.stringify({type:'feature',timestamp:t+d,node_id:1,features:[1,2,3,4,5,6,7,8]})).join('\n')+'\n'); + const flags=extra.filter((_,i)=>i%2===0).map((flag,i)=>`${flag}=${extra[i*2+1]}`); + const r=spawnSync(process.execPath,['--max-old-space-size=32',script,'--gt',gt,'--csi',csi,'--output',output,'--window-frames','2','--min-camera-frames','1',...flags],{encoding:'utf8',timeout:1000,maxBuffer:128*1024}); + return inspect(r,output); + } finally {rmSync(dir,{recursive:true,force:true});} +} +for(const [flag,value] of [['window-frames','0'],['window-frames','-1'],['window-frames','1.5'],['window-frames','2junk'],['window-ms','NaN'],['window-ms','-1'],['min-camera-frames','0'],['min-camera-frames','1.5'],['min-confidence','NaN'],['min-confidence','1.1'],['clock-offset-ms','Infinity']]){ + test(`public alignment CLI rejects --${flag}=${value} before writing output`,()=>alignment(['--'+flag,value],(r,output)=>{ + assert.equal(r.status,2,`${r.error?.message||''} ${r.stderr.slice(0,200)}`);assert.match(r.stderr,new RegExp('--'+flag));assert.equal(existsSync(output),false); + })); +} +test('public alignment CLI retains complete windows and permits zero expansion/confidence with signed finite offset',()=>alignment(['--window-ms','0','--min-confidence','0','--clock-offset-ms','-0.5'],(r,output)=>{ + assert.equal(r.status,0,r.stderr);const frames=readFileSync(output,'utf8').trim().split('\n').map(JSON.parse);assert.equal(frames.length,2);assert.deepEqual(frames[0].csi_shape,[2,8]);assert.equal(frames[0].conf,1);assert.ok(frames.every(f=>f.n_camera_frames>0)); +})); diff --git a/harness/ruview/test/cli-numeric-input.test.mjs b/harness/ruview/test/cli-numeric-input.test.mjs new file mode 100644 index 000000000..5c6b19ba0 --- /dev/null +++ b/harness/ruview/test/cli-numeric-input.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +const cli = fileURLToPath(new URL('../bin/cli.js', import.meta.url)); + +function capture(args) { + // Invalid UDP port guarantees schema refusal before any socket is opened. + const child = spawnSync(process.execPath, [cli, 'esp32', '--udp-port', '1', ...args], { encoding: 'utf8' }); + assert.equal(child.status, 1, child.stderr); + const result = JSON.parse(child.stdout); + assert.equal(result.reason, 'invalid_arguments'); + return result.errors; +} + +for (const args of [['--node-id'], ['--node-id=']]) { + test(`public CLI rejects absent numeric value ${args}`, () => { + assert.ok(capture(args).some((error) => error.includes('node_id')), 'missing node selector must not become 0 or 1'); + }); +} + +test('public CLI still forwards explicit numeric node zero', () => { + assert.ok(!capture(['--node-id', '0']).some((error) => error.includes('node_id'))); +}); diff --git a/harness/ruview/test/cli-ui.test.mjs b/harness/ruview/test/cli-ui.test.mjs index 114b8eae6..2149032fc 100644 --- a/harness/ruview/test/cli-ui.test.mjs +++ b/harness/ruview/test/cli-ui.test.mjs @@ -7,6 +7,7 @@ import { spawnSync } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { bar, colorEnabled, renderResult, sparkline, table, createStyle } from '../src/cli-ui.js'; import { MONITOR_SCRIPT } from '../src/tools.js'; @@ -94,3 +95,19 @@ test('monitor script flags Ameba CSI buffer starvation', (t) => { rmSync(fake, { recursive: true, force: true }); } }); + + +test('piped watch exits after one permanent input failure', () => { + const cli = fileURLToPath(new URL('../bin/cli.js', import.meta.url)); + for (const options of [['--udp-port', '1024.5'], ['--bind', 'invalid-bind']]) { + const child = spawnSync(process.execPath, [cli, 'esp32', '--watch', '--json', ...options], + { encoding: 'utf8', timeout: 2000, maxBuffer: 256 * 1024 }); + assert.equal(child.status, 1, 'permanent errors must exit instead of retrying forever'); + assert.equal(child.error, undefined); + const lines = child.stdout.trim().split('\n'); + assert.equal(lines.length, 1, 'one structured failure is emitted'); + const result = JSON.parse(lines[0]); + assert.equal(result.ok, false); + assert.ok(['invalid_port', 'invalid_arguments'].includes(result.reason)); + } +}); diff --git a/harness/ruview/test/csi-nodes.test.mjs b/harness/ruview/test/csi-nodes.test.mjs index 2640ff672..a98791ebf 100644 --- a/harness/ruview/test/csi-nodes.test.mjs +++ b/harness/ruview/test/csi-nodes.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import dgram from 'node:dgram'; -import { binAmplitudes, captureEsp32, crc32, parsePacket, RAC1_MAGIC, RHB1_MAGIC, SpectrumCollector } from '../src/devices/esp32.js'; +import { binAmplitudes, captureEsp32, crc32, parsePacket, RAC1_MAGIC, RHB1_MAGIC, SpectrumCollector, NodeStats, SEQ_WINDOW } from '../src/devices/esp32.js'; import { classifyPort } from '../src/devices/registry.js'; import { kernelAnalyzer } from '../src/kernel.js'; import { runTool, TOOLS } from '../src/tools.js'; @@ -243,3 +243,147 @@ test('spectrum draws multi-antenna ESP32 nodes from their first chain (ADR-018 a assert.deepEqual([s.nodeId, s.shape, s.bins], [9, '2x8', 8]); assert.ok(s.frames.every((f) => f.every((v) => v === 3))); }); + + +test('only missing CSI sequence numbers recover counted packet loss', async () => { + for (const [seqs, gaps] of [ + [[100, 103, 101, 101], 1], // duplicated recovery must not fill missing 102 + [[100, 102, 100], 1], // an already-received packet cannot fill missing 101 + [[100, 102, 99], 1], // a pre-capture packet cannot fill missing 101 + [[100, 103, 102, 101], 0], // distinct actual gap recoveries still work + ]) { + const result = await capture({ max_packets: seqs.length }, seqs.map((seq) => rac1({ seq }))); + assert.equal(result.nodes[0].csiLossFraction, Number((gaps / (seqs.length + gaps)).toFixed(4)), + `loss for ${seqs.join(',')}`); + } +}); + + +test('CSI recovery bookkeeping stays within the reorder window', () => { + const stats = new NodeStats(); + for (let seq = 0; seq <= SEQ_WINDOW * 10; seq += 64) { + stats.add(parsePacket(rac1({ seq })), '127.0.0.1'); + const node = stats.nodes.get('realtek:3'); + assert.ok(node.missingSeq.size < SEQ_WINDOW); + } + const node = stats.nodes.get('realtek:3'); + const historicalGaps = node.seqGaps; + stats.add(parsePacket(rac1({ seq: 1 })), '127.0.0.1'); + stats.add(parsePacket(rac1({ seq: 2 })), '127.0.0.1'); + assert.equal(node.missingSeq.size, 0, 'a confirmed counter reset clears old recovery identities'); + assert.equal(node.seqGaps, historicalGaps, 'historical loss remains counted'); +}); +test('packet-limited captures report the observed interval and rate', async () => { + const r = await capture({ seconds: 30, max_packets: 2 }, [esp32Csi({ seq: 1 }), esp32Csi({ seq: 2 })]); + assert.equal(r.truncated, true); + assert.equal(r.seconds, 30, 'configured duration remains available'); + assert.ok(r.elapsedSeconds > 0 && r.elapsedSeconds < 5, JSON.stringify(r)); + assert.equal(r.nodes[0].csiRateHz, Number((2 / r.elapsedSeconds).toFixed(2))); + }); +test('synthetic Realtek frames retain provenance in kernel analysis', async () => { + const r = await capture({ analyze: true, max_packets: 3 }, [rac1({ seq: 1, synthetic: 1 }), rac1({ seq: 2 }), rac1({ seq: 3 })], { + analyze: async () => ({ ok: true, estimate: 1 }), + }); + assert.equal(r.analysis.ok, true); + assert.equal(r.analysis.input.synthetic, true); + assert.match(r.analysis.note, /synthetic/i); +}); + +test('unmarked Realtek frames keep ordinary analysis provenance', async () => { + const r = await capture({ analyze: true, max_packets: 2 }, [rac1({ seq: 1 }), rac1({ seq: 2 })], { analyze: async () => ({ ok: true }) }); + assert.equal(r.analysis.input.synthetic, false); + assert.doesNotMatch(r.analysis.note, /synthetic/i); +}); + +test('ordinary CSI shape summaries retain synthetic provenance across the window', async () => { + const result = await capture({ max_packets: 2 }, [rac1({ seq: 1, synthetic: 1 }), rac1({ seq: 2 })]); + assert.equal(result.nodes[0].csi.frames, 2); + assert.equal(result.nodes[0].csi.synthetic, true); +}); + +test('ordinary unmarked CSI shape summaries remain unmarked', async () => { + const result = await capture({ max_packets: 2 }, [rac1({ seq: 1 }), rac1({ seq: 2 })]); + assert.equal(result.nodes[0].csi.synthetic, false); +}); + +test('kernel analysis selects the busiest stream after both buffers reach their cap', async () => { + const datagrams = []; + for (let seq = 0; seq < 70; seq++) datagrams.push(esp32Csi({ node: 1, seq })); + for (let seq = 0; seq < 100; seq++) datagrams.push(esp32Csi({ node: 2, seq })); + const result = await capture({ analyze: true, analyze_max_frames: 64, max_packets: datagrams.length }, datagrams, { analyze: async () => ({ ok: true }) }); + assert.equal(result.analysis.input.nodeId, 2); + assert.equal(result.analysis.input.frames, 64); + assert.equal(result.analysis.input.droppedOverCap, 36); +}); + +test('requested node selection remains authoritative after analysis buffers fill', async () => { + const datagrams = []; + for (let seq = 0; seq < 70; seq++) datagrams.push(esp32Csi({ node: 1, seq })); + for (let seq = 0; seq < 100; seq++) datagrams.push(esp32Csi({ node: 2, seq })); + const result = await capture({ analyze: true, node_id: 1, analyze_max_frames: 64, max_packets: datagrams.length }, datagrams, { analyze: async () => ({ ok: true }) }); + assert.equal(result.analysis.input.nodeId, 1); + assert.equal(result.analysis.input.frames, 64); + assert.equal(result.analysis.input.droppedOverCap, 6); +}); + +function fusedVitals() { + // Exact packed edge_fused_vitals_pkt_t offsets from edge_processing.h. + const packet = Buffer.alloc(48); + packet.writeUInt32LE(0xC5110004, 0); packet[4] = 7; packet[5] = 9; + packet.writeUInt16LE(1500, 6); packet.writeUInt32LE(720000, 8); + packet.writeInt8(-45, 12); packet[13] = 1; packet[14] = 2; packet[15] = 85; + packet.writeFloatLE(0.25, 16); packet.writeFloatLE(0.75, 20); packet.writeUInt32LE(12345, 24); + packet.writeFloatLE(73, 28); packet.writeFloatLE(16, 32); packet.writeFloatLE(120, 36); + packet[40] = 2; packet[41] = 90; + return packet; +} + +test('firmware fused vitals update public node diagnostics with wire units', async () => { + const result = await capture({ max_packets: 1 }, [fusedVitals()]); + assert.equal(result.ok, true); + const node = result.nodes[0]; + assert.equal(node.packets['fused-vitals'], 1); + assert.equal(node.rssiMean, -45); + assert.equal(node.lastVitals.heartBpm, 72); + assert.equal(node.lastVitals.breathingBpm, 15); + assert.equal(node.lastVitals.uptimeMs, 12345); + assert.equal(node.lastVitals.mmwavePresent, true); + assert.equal(node.lastVitals.mmwaveHeartBpm, 73); + assert.equal(node.lastVitals.mmwaveBreathingBpm, 16); + assert.equal(node.lastVitals.mmwaveDistanceCm, 120); + assert.equal(node.lastVitals.mmwaveTargets, 2); + assert.equal(node.lastVitals.mmwaveConfidence, 90); + assert.equal(node.lastVitals.fusionConfidence, 85); +}); + +test('truncated fused-vitals packets are not accepted as decoded node reports', () => { + for (const length of [4, 28, 32, 47]) { + assert.equal(parsePacket(fusedVitals().subarray(0, length)).kind, 'malformed'); + } +}); + +function auxiliaryPackets() { + const features = Buffer.alloc(48); features.writeUInt32LE(0xC5110003, 0); features[4] = 7; + const compressed = Buffer.alloc(12); compressed.writeUInt32LE(0xC5110005, 0); compressed[4] = 7; + compressed.writeUInt16LE(4, 6); compressed.writeUInt16LE(2, 8); compressed[11] = 4; // identical prior frame: RLE [0, 4] + const state = Buffer.alloc(60); state.writeUInt32LE(0xC5110006, 0); state[4] = 7; state.writeUInt32LE(crc32(state, 56), 56); + const wasm = Buffer.alloc(13); wasm.writeUInt32LE(0xC5110007, 0); wasm[4] = 7; wasm.writeUInt16LE(1, 6); wasm[8] = 1; wasm.writeFloatLE(50, 9); + return [features, compressed, state, wasm]; +} + +test('auxiliary node reports require complete wire payloads and feature-state CRC', async () => { + const valid = auxiliaryPackets(); + const prefixes = valid.map((packet) => packet.subarray(0, 4)); + const corruptState = Buffer.from(valid[2]); corruptState[20] ^= 1; + const shortCompressed = valid[1].subarray(0, 11); + const shortWasm = valid[3].subarray(0, 12); + const shortFeatures = valid[0].subarray(0, 47); + const invalid = [...prefixes, corruptState, shortCompressed, shortWasm, shortFeatures]; + const result = await capture({ max_packets: invalid.length + valid.length }, [...invalid, ...valid]); + assert.equal(result.ok, true); + assert.equal(result.packets, 12); + assert.equal(result.malformedPackets, 8); + assert.equal(result.decodedPackets, 4); + assert.equal(result.nodes.length, 1, 'invalid prefixes must not create phantom node -1'); + assert.deepEqual(result.nodes[0].packets, { features: 1, compressed: 1, 'feature-state': 1, 'wasm-output': 1 }); +}); diff --git a/harness/ruview/test/csi-relay-datagrams.test.mjs b/harness/ruview/test/csi-relay-datagrams.test.mjs new file mode 100644 index 000000000..819d4403c --- /dev/null +++ b/harness/ruview/test/csi-relay-datagrams.test.mjs @@ -0,0 +1,45 @@ +import { python } from './helpers/python.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import dgram from 'node:dgram'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { once } from 'node:events'; +const script = fileURLToPath(new URL('../../../scripts/csi-udp-relay.py', import.meta.url)); +async function relay(payload) { + const rx = dgram.createSocket('udp4'), probe = dgram.createSocket('udp4'), tx = dgram.createSocket('udp4'); + rx.bind(0, '127.0.0.1'); await once(rx, 'listening'); + probe.bind(0, '127.0.0.1'); await once(probe, 'listening'); + const listen = probe.address().port; await new Promise(resolve => probe.close(resolve)); + const child = spawn(python, ['-u', script, '--listen-host', '127.0.0.1', '--listen', String(listen), '--forward', String(rx.address().port), '--quiet']); + let timer; + try { + await new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error('relay startup timeout')), 2000); + child.stdout.once('data', () => { clearTimeout(timer); resolve(); }); + child.once('error', reject); + }); + const result = new Promise((resolve, reject) => { + timer = setTimeout(() => reject(new Error('relay did not forward datagram')), 700); + rx.once('message', value => { clearTimeout(timer); resolve(value); }); + }); + tx.send(payload, listen, '127.0.0.1'); + return await result; + } finally { + clearTimeout(timer); tx.close(); rx.close(); + const exited = once(child, 'exit'); child.kill('SIGINT'); await exited; + } +} +test('public loopback relay preserves full maximum ADR018 multi-antenna packet', async () => { + const packet = Buffer.alloc(20 + 4 * 256 * 2); + packet.writeUInt32LE(0xC5110001); packet[4] = 7; packet[5] = 4; packet.writeUInt16LE(256, 6); + packet.writeUInt32LE(2437, 8); packet.writeUInt32LE(12, 12); packet.writeInt8(-50, 16); + for (let i = 20; i < packet.length; i++) packet[i] = i % 256; + assert.deepEqual(await relay(packet), packet); +}); +test('public loopback relay preserves empty UDP datagrams', async () => { + assert.deepEqual(await relay(Buffer.alloc(0)), Buffer.alloc(0)); +}); +test('public loopback relay retains ordinary small datagrams unchanged', async () => { + const packet = Buffer.from([0, 255, 3, 4]); assert.deepEqual(await relay(packet), packet); +}); diff --git a/harness/ruview/test/csi-transcode-iq.test.mjs b/harness/ruview/test/csi-transcode-iq.test.mjs new file mode 100644 index 000000000..2db09e431 --- /dev/null +++ b/harness/ruview/test/csi-transcode-iq.test.mjs @@ -0,0 +1,34 @@ +import { python } from './helpers/python.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +const script = fileURLToPath(new URL('../../../scripts/esp32_jsonl_to_rvcsi.py', import.meta.url)); +function transcode(iq) { + const dir = mkdtempSync(join(tmpdir(), 'ruview-transcode-')); + try { + const input = join(dir, 'in.jsonl'), output = join(dir, 'out.rvcsi'); + const frame = iq_hex => ({ type: 'raw_csi', iq_hex, rssi: -50, ts_ns: 123, channel: 6 }); + writeFileSync(input, [frame('0304'), frame(iq), frame('0608')].map(JSON.stringify).join('\n')+'\n'); + const result = spawnSync(python, [script, input, output], { encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + const frames = readFileSync(output, 'utf8').trim().split('\n').map(JSON.parse).slice(1); + return { frames, stats: JSON.parse(result.stdout.split('transcode stats: ')[1].split('\n')[0]) }; + } finally { rmSync(dir, { recursive: true, force: true }); } +} +for (const [name, iq] of [['incomplete pair', '03047f'], ['invalid hex', 'not-hex']]) { + test(`public transcode CLI quarantines ${name} and keeps later valid samples`, () => { + const { frames, stats } = transcode(iq); + assert.deepEqual(frames.map(f => f.amplitude), [[5], [10]]); + assert.deepEqual(frames.map(f => f.frame_id), [0, 1]); + assert.equal(stats.read, 3); assert.equal(stats.written, 2); assert.equal(stats.rej_len, 1); + }); +} +test('public transcode CLI retains complete signed IQ pairs', () => { + const { frames, stats } = transcode('fa08'); + assert.deepEqual(frames[1].i_values, [8]); assert.deepEqual(frames[1].q_values, [-6]); + assert.deepEqual(frames[1].amplitude, [10]); assert.equal(stats.rej_len, 0); assert.equal(stats.written, 3); +}); diff --git a/harness/ruview/test/csi-transcode-source-preservation.test.mjs b/harness/ruview/test/csi-transcode-source-preservation.test.mjs new file mode 100644 index 000000000..e98b27843 --- /dev/null +++ b/harness/ruview/test/csi-transcode-source-preservation.test.mjs @@ -0,0 +1,34 @@ +import { python } from './helpers/python.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, readFileSync, linkSync, symlinkSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +const script=fileURLToPath(new URL('../../../scripts/esp32_jsonl_to_rvcsi.py',import.meta.url)); +for(const alias of ['identical path','hard link','symbolic link']){ + test(`public converter CLI preserves source capture when output is ${alias}`,t=>{ + const dir=mkdtempSync(join(tmpdir(),'ruview-transcode-source-')); + try{ + const input=join(dir,'input.csi.jsonl');let output=input; + const original=JSON.stringify({type:'raw_csi',iq_hex:'0304',rssi:-50,ts_ns:123,channel:6})+'\n';writeFileSync(input,original); + if(alias!=='identical path'){ + output=join(dir,'output.rvcsi'); + if(alias==='hard link')linkSync(input,output); + else { + try { symlinkSync(input,output); } + catch(error) { + if(process.platform==='win32' && error.code==='EPERM'){ + t.skip('Windows account lacks file symlink privilege'); return; + } + throw error; + } + } + } + const r=spawnSync(python,[script,input,output],{encoding:'utf8'}); + assert.equal(readFileSync(input,'utf8'),original,'input capture must remain byte-for-byte intact'); + assert.equal(r.status,2);assert.match(r.stderr,/different files/i); + }finally{rmSync(dir,{recursive:true,force:true});} + }); +} diff --git a/harness/ruview/test/devices.test.mjs b/harness/ruview/test/devices.test.mjs index bb1a9c052..acb4a6cb0 100644 --- a/harness/ruview/test/devices.test.mjs +++ b/harness/ruview/test/devices.test.mjs @@ -154,7 +154,7 @@ test('RPLIDAR scan stream parses with resync and summarizes coverage', async () const r = await readRplidar({ port: '/dev/ttyUSB1', seconds: 2 }, deps); assert.equal(r.ok, true); assert.equal(r.angularCoverage, 1); - assert.deepEqual(r.rangeMm, { min: 1000, median: 1180, max: 1350 }); + assert.deepEqual(r.rangeMm, { min: 1000, median: 1175, max: 1350 }); const pumpArgs = deps.calls[0].args; assert.deepEqual(pumpArgs.slice(-3), ['a520', 'a525', 'low'], 'SCAN on start, STOP on exit, A1 motor via DTR'); }); @@ -181,7 +181,7 @@ test('iPhone LiDAR relay client returns statistics only and keeps the token out assert.equal(r.ok, true, JSON.stringify(r)); assert.equal(r.frames, 1); assert.equal(r.rejectedFrames, 1); - assert.equal(r.last.medianDepthM, 1.504); + assert.equal(r.last.medianDepthM, 1.5035); assert.match(opened, /token=tok123/); assert.equal(r.relay, 'ws://10.0.0.5:8787/ws/lidar', 'token never echoed'); assert.ok(!JSON.stringify(r).includes('tok123')); diff --git a/harness/ruview/test/doctor-corrupt-manifest.test.mjs b/harness/ruview/test/doctor-corrupt-manifest.test.mjs new file mode 100644 index 000000000..e29686001 --- /dev/null +++ b/harness/ruview/test/doctor-corrupt-manifest.test.mjs @@ -0,0 +1,59 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cpSync, mkdtempSync, writeFileSync, readFileSync, unlinkSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createHash } from 'node:crypto'; +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); +const sha = bytes => createHash('sha256').update(bytes).digest('hex'); +const offline = { findRepoRoot: () => null, which: () => null, python: () => null }; +async function copyPackage(t, damage) { + const root = mkdtempSync(join(tmpdir(), 'ruview-doctor-manifest-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + for (const entry of ['src', 'brain', 'package.json']) cpSync(join(packageRoot, entry), join(root, entry), { recursive: true }); + cpSync(join(packageRoot, '.harness'), join(root, '.harness'), { recursive: true }); + writeFileSync(join(root, 'integrity-data.txt'), 'owned fixture'); + const raw = JSON.stringify({ files: { 'package.json': sha(readFileSync(join(root, 'package.json'))), 'integrity-data.txt': sha('owned fixture') } }); + writeFileSync(join(root, '.harness', 'manifest.json'), raw); + writeFileSync(join(root, '.harness', 'manifest.sha256'), sha(raw)); + if (damage === 'json') writeFileSync(join(root, '.harness', 'manifest.json'), '{"schema":2,'); + if (damage === 'checksum') unlinkSync(join(root, '.harness', 'manifest.sha256')); + if (damage === 'checksum-directory') { unlinkSync(join(root, '.harness', 'manifest.sha256')); mkdirSync(join(root, '.harness', 'manifest.sha256')); } + if (damage === 'data-directory') { unlinkSync(join(root, 'integrity-data.txt')); mkdirSync(join(root, 'integrity-data.txt')); } + const { createRuView } = await import(pathToFileURL(join(root, 'src', 'sdk.js')).href); + return createRuView({ strict: true, deps: offline }).doctor({ groups: ['harness'] }); +} + +test('public SDK doctor reports malformed manifest as a structured integrity warning', async t => { + const report = await copyPackage(t, 'json'); + const check = report.checks.find(c => c.id === 'package-integrity'); + assert.equal(check.status, 'warn'); + assert.match(check.detail, /manifest\.json/); + assert.match(check.remedy, /Reinstall/); + assert.equal(report.ok, true, 'existing integrity warning policy is preserved'); +}); + +test('public SDK doctor reports a missing checksum file without throwing', async t => { + const report = await copyPackage(t, 'checksum'); + const check = report.checks.find(c => c.id === 'package-integrity'); + assert.equal(check.status, 'warn'); + assert.match(check.detail, /manifest\.sha256.*missing/); + assert.equal(report.ok, true); +}); + +test('valid copied package control keeps successful integrity diagnostics', async t => { + const report = await copyPackage(t); + assert.equal(report.checks.find(c => c.id === 'package-integrity').status, 'pass'); + assert.equal(report.ok, true); +}); + +for (const [damage, pattern] of [['checksum-directory', /manifest\.sha256:unreadable/], ['data-directory', /integrity-data\.txt:unreadable/]]) { + test(`public SDK doctor reports unreadable ${damage} as a warning`, async t => { + const report = await copyPackage(t, damage); + const check = report.checks.find(c => c.id === 'package-integrity'); + assert.equal(check.status, 'warn'); + assert.match(check.detail, pattern); + assert.equal(report.ok, true); + }); +} diff --git a/harness/ruview/test/esphome-role-kinds.test.mjs b/harness/ruview/test/esphome-role-kinds.test.mjs new file mode 100644 index 000000000..2b3792f57 --- /dev/null +++ b/harness/ruview/test/esphome-role-kinds.test.mjs @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import net from 'node:net'; +import { encodeFrame, encodeVarint, readEsphome, splitFrames } from '../src/devices/esphome.js'; +const str = (f, s) => { const b = Buffer.from(s); return Buffer.concat([encodeVarint((f << 3) | 2), encodeVarint(b.length), b]); }; +const uint = (f, n) => Buffer.concat([encodeVarint(f << 3), encodeVarint(n)]); +const key = (f, k) => { const b = Buffer.alloc(4); b.writeUInt32LE(k); return Buffer.concat([encodeVarint((f << 3) | 5), b]); }; +const float = (f, v) => { const b = Buffer.alloc(4); b.writeFloatLE(v); return Buffer.concat([encodeVarint((f << 3) | 5), b]); }; +async function device(t, numeric) { + const sockets = new Set(); + const server = net.createServer(sock => { + sockets.add(sock); sock.on('close', () => sockets.delete(sock)); sock.on('error', () => {}); + let buf = Buffer.alloc(0); + sock.on('data', bytes => { + buf = Buffer.concat([buf, bytes]); const decoded = splitFrames(buf); buf = decoded.rest; + for (const { type } of decoded.frames) { + if (type === 1) sock.write(encodeFrame(2)); + if (type === 3) sock.write(encodeFrame(4)); + if (type === 11) { + // Common alarm/status entities precede the corresponding scalar. + sock.write(encodeFrame(12, Buffer.concat([str(1, 'heart_alarm'), key(2, 1), str(3, 'Heart alarm')]))); + sock.write(encodeFrame(18, Buffer.concat([str(1, 'distance_status'), key(2, 2), str(3, 'Distance status')]))); + sock.write(encodeFrame(12, Buffer.concat([str(1, 'breathing_alarm'), key(2, 3), str(3, 'Breathing alarm')]))); + if (numeric) { + for (const [k, name] of [[4, 'Heart rate'], [5, 'Distance'], [6, 'Breathing rate']]) { + sock.write(encodeFrame(16, Buffer.concat([str(1, name.toLowerCase().replaceAll(' ', '_')), key(2, k), str(3, name), str(6, k === 5 ? 'cm' : 'bpm')]))); + } + } + sock.write(encodeFrame(19)); + } + if (type === 20) { + sock.write(encodeFrame(21, Buffer.concat([key(1, 1), uint(2, 1)]))); + sock.write(encodeFrame(27, Buffer.concat([key(1, 2), str(2, '50')]))); + sock.write(encodeFrame(21, Buffer.concat([key(1, 3), uint(2, 1)]))); + if (numeric) for (const [k, value] of [[4, 72], [5, 120], [6, 15]]) sock.write(encodeFrame(25, Buffer.concat([key(1, k), float(2, value)]))); + } + } + }); + }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + t.after(async () => { for (const s of sockets) s.destroy(); await new Promise(r => server.close(r)); }); + return readEsphome({ host: '127.0.0.1', api_port: server.address().port, seconds: 1 }); +} + +test('public ESPHome reader selects numeric sensors over named alarm/status entities', async t => { + const result = await device(t, true); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.deepEqual([result.heartBpmMean, result.distanceCmMean, result.breathingBpmMean], [72, 120, 15]); + assert.deepEqual(result.entities.slice(0, 3).map(e => e.role), [undefined, undefined, undefined]); + assert.deepEqual(result.entities.slice(0, 3).map(e => e.last), [true, '50', true], 'non-numeric entities remain available as raw states'); +}); + +test('named non-numeric states alone do not manufacture numeric radar measurements', async t => { + const result = await device(t, false); + assert.equal(result.ok, true); + assert.deepEqual([result.heartBpmMean, result.distanceCmMean, result.breathingBpmMean], [null, null, null]); + assert.equal(result.stateUpdates, 3); +}); diff --git a/harness/ruview/test/esphome.test.mjs b/harness/ruview/test/esphome.test.mjs index 1406e2be3..d21844ca1 100644 --- a/harness/ruview/test/esphome.test.mjs +++ b/harness/ruview/test/esphome.test.mjs @@ -13,9 +13,15 @@ const pbKey = (f, k) => { const b = Buffer.alloc(4); b.writeUInt32LE(k); return const pbFloat = (f, v) => { const b = Buffer.alloc(4); b.writeFloatLE(v); return Buffer.concat([encodeVarint((f << 3) | 5), b]); }; /** A fake MR60BHA2 kit. mode: 'ok' | 'encrypted' | 'password'. */ -function fakeDevice({ mode = 'ok', states } = {}) { +function fakeDevice({ mode = 'ok', states, distanceUnit = 'cm' } = {}) { const seen = []; const server = net.createServer((sock) => { + sock.on('error', (error) => { + // Rejecting encryption/password intentionally closes the reader socket. + // Only that fixture lifecycle may reset; all unexpected errors still fail. + if ((mode === 'encrypted' || mode === 'password') && error.code === 'ECONNRESET') return; + throw error; + }); if (mode === 'encrypted') { sock.write(Buffer.from([0x01, 0x00, 0x00])); return; } let buf = Buffer.alloc(0); sock.on('data', (d) => { @@ -31,13 +37,13 @@ function fakeDevice({ mode = 'ok', states } = {}) { sock.write(encodeFrame(12, Buffer.concat([pbStr(1, 'person_information'), pbKey(2, 1), pbStr(3, 'Person Information')]))); sock.write(encodeFrame(16, Buffer.concat([pbStr(1, 'real_time_heart_rate'), pbKey(2, 2), pbStr(3, 'Real-time heart rate'), pbStr(6, 'bpm')]))); sock.write(encodeFrame(16, Buffer.concat([pbStr(1, 'real_time_respiratory_rate'), pbKey(2, 3), pbStr(3, 'Real-time respiratory rate')]))); - sock.write(encodeFrame(16, Buffer.concat([pbStr(1, 'distance'), pbKey(2, 4), pbStr(3, 'Distance to detection object'), pbStr(6, 'cm')]))); + sock.write(encodeFrame(16, Buffer.concat([pbStr(1, 'distance'), pbKey(2, 4), pbStr(3, 'Distance to detection object'), pbStr(6, distanceUnit)]))); sock.write(encodeFrame(15, pbStr(1, 'rgb_light'))); // a light: ignored by the read-only client sock.write(encodeFrame(19)); } if (type === 20) { sock.write(encodeFrame(7)); // device pings the client; it must answer - for (const s of states) sock.write(encodeFrame(s.type, s.payload)); + sock.write(Buffer.concat(states.map((s) => encodeFrame(s.type, s.payload)))); } } }); @@ -59,6 +65,8 @@ test('reads presence and vitals from an ESPHome radar kit, honouring missing_sta assert.equal(r.breathingBpmMean, 15, 'missing_state samples are excluded'); assert.equal(r.distanceCmMean, 40.5, 'zero distance (proto3 default) is not averaged in'); assert.equal(r.stateUpdates, 7); + assert.equal(r.entities.find((e) => e.role === 'distanceCm').updates, 2); + assert.equal(r.entities.find((e) => e.role === 'distanceCm').droppedSamples, 0); assert.deepEqual(r.entities.map((e) => e.role), ['present', 'heartBpm', 'breathingBpm', 'distanceCm']); assert.ok(dev.seen.includes(8), 'answered the device ping'); assert.ok(!dev.seen.some((ty) => [30, 31, 32, 33].includes(ty)), 'never sends a command'); @@ -100,3 +108,74 @@ test('mmwave tool routes source=esphome and validates per-source arguments', asy const empty = summarizeEsphome({ device: {}, entities: new Map(), updates: 0 }, 5); assert.deepEqual([empty.presentNow, empty.heartBpmMean, empty.updateRateHz], [null, null, 0]); }); + +for (const [unit, distance] of [['mm', 500], ['m', 0.5], ['cm', 50]]) { + test(`native ESPHome distance ${unit} is reported in centimetres`, async (t) => { + const dev = await fakeDevice({ states: [sensor(4, distance)], distanceUnit: unit }); + t.after(() => dev.server.close()); + const result = await readEsphome({ host: '127.0.0.1', api_port: dev.port, seconds: 1 }); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.distanceCmMean, 50); + }); +} +test('entity update counters retain arrivals beyond the aggregation sample cap', async (t) => { + const dev = await fakeDevice({ states: Array.from({ length: 10_001 }, () => sensor(4, 50)) }); + t.after(() => dev.server.close()); + const result = await readEsphome({ host: '127.0.0.1', api_port: dev.port, seconds: 1 }); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.stateUpdates, 10_001); + const distance = result.entities.find((entity) => entity.role === 'distanceCm'); + assert.equal(distance.updates, 10_001); + assert.equal(distance.sampledUpdates, 10_000); + assert.equal(distance.droppedSamples, 1); + assert.equal(result.distanceCmMean, 50); +}); +for (const value of [NaN, Infinity, -Infinity]) { + test(`native ESPHome treats nonfinite sensor value ${value} as unavailable`, async (t) => { + const dev = await fakeDevice({ states: [sensor(4, 50), { type: 25, payload: Buffer.concat([pbKey(1, 4), pbFloat(2, value)]) }] }); + t.after(() => dev.server.close()); + const result = await readEsphome({ host: '127.0.0.1', api_port: dev.port, seconds: 1 }); + assert.equal(result.ok, true); + assert.equal(result.distanceCmMean, 50); + assert.equal(result.entities.find((entity) => entity.role === 'distanceCm').last, null); + assert.equal(result.stateUpdates, 2); + }); +} +// Controlled resets reproduce the owned server-side rejection lifecycle without +// relying on OS scheduling to deliver a late ECONNRESET after the test ends. +async function withFixtureSocket(mode, inspect) { + const dev = await fakeDevice({ mode, states: [] }); + let peer; + dev.server.once('connection', socket => { peer = socket; }); + try { + const report = await readEsphome({ host: '127.0.0.1', api_port: dev.port, seconds: 0.01 }); + assert.ok(peer, 'actual reader must connect to the owned fixture'); + inspect(peer, report); + } finally { + // Cleanup must not emit a second uncontrolled error in the failing baseline. + peer?.on('error', () => {}); + peer?.destroy(); + await new Promise(resolve => dev.server.close(resolve)); + } +} +for (const mode of ['encrypted', 'password']) { + test(`owned ${mode} rejection fixture handles its expected connection reset`, async () => { + await withFixtureSocket(mode, (socket, report) => { + assert.equal(report.reason, mode === 'encrypted' ? 'esphome_encrypted' : 'esphome_password_required'); + const reset = Object.assign(new Error('controlled rejection reset'), { code: 'ECONNRESET' }); + assert.doesNotThrow(() => socket.emit('error', reset)); + }); + }); +} +test('owned rejection fixture still fails unexpected socket error codes', async () => { + await withFixtureSocket('encrypted', socket => { + const unexpected = Object.assign(new Error('unexpected fixture I/O'), { code: 'EIO' }); + assert.throws(() => socket.emit('error', unexpected), /unexpected fixture I\/O/); + }); +}); +test('ordinary owned fixture still fails connection resets', async () => { + await withFixtureSocket('ok', socket => { + const reset = Object.assign(new Error('unexpected ordinary reset'), { code: 'ECONNRESET' }); + assert.throws(() => socket.emit('error', reset), /unexpected ordinary reset/); + }); +}); diff --git a/harness/ruview/test/firmware.test.mjs b/harness/ruview/test/firmware.test.mjs index 131d7315a..f2786aaa0 100644 --- a/harness/ruview/test/firmware.test.mjs +++ b/harness/ruview/test/firmware.test.mjs @@ -101,6 +101,46 @@ test('4 MB variant prefers 4 MB images and 8 MB refuses a 4 MB partition table', } finally { rmSync(dir, { recursive: true, force: true }); } }); +test('official C6 bundle names verify checksums and remain scoped to the C6 variant', () => { + const dir = mkdtempSync(join(tmpdir(), 'ruview-fw-c6-')); + try { + const files = { + 'bootloader.bin': 'C6_BOOT', + 'partition-table-c6.bin': 'C6_PT', + 'esp32-csi-node-c6.bin': 'C6_APP', + 'ota_data_initial.bin': 'C6_OTA', + }; + for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body); + writeFileSync(join(dir, 'SHA256SUMS.txt'), Object.entries(files).map(([name, body]) => `${sha(body)} ${name}`).join('\n')); + const bundle = resolveBundle(dir, 'c6'); + assert.equal(bundle.chip, 'esp32c6'); + assert.equal(bundle.flashSize, '4MB'); + assert.deepEqual(bundle.images.map(({ relative, integrity }) => [relative, integrity]), [ + ['bootloader.bin', 'verified'], ['partition-table-c6.bin', 'verified'], + ['ota_data_initial.bin', 'verified'], ['esp32-csi-node-c6.bin', 'verified'], + ]); + for (const variant of ['s3-8mb', 's3-4mb']) { + assert.throws(() => resolveBundle(dir, variant), (error) => error.reason === 'image_missing'); + } + // A local build may also be copied into the folder: prefer the release's + // explicit C6 images, never accidentally fall back to an unrelated app. + writeFileSync(join(dir, 'esp32-csi-node.bin'), 'OTHER_APP'); + assert.equal(resolveBundle(dir, 'c6').images.find((image) => image.role === 'app').relative, 'esp32-csi-node-c6.bin'); + writeFileSync(join(dir, 'esp32-csi-node-c6.bin'), 'TAMPERED'); + assert.throws(() => resolveBundle(dir, 'c6'), (error) => error.reason === 'checksum_mismatch'); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + +test('C6 keeps generic ESP-IDF build directory support', () => { + const dir = makeBundle(); + try { + const bundle = resolveBundle(dir, 'c6'); + assert.equal(bundle.chip, 'esp32c6'); + assert.equal(bundle.images.find((image) => image.role === 'app').relative, 'esp32-csi-node.bin'); + assert.ok(bundle.images.every((image) => image.integrity === 'verified')); + } finally { rmSync(dir, { recursive: true, force: true }); } +}); + test('flash without confirm is a plan and touches no hardware', async () => { const dir = makeBundle({ otadata: false }); const deps = fakeDeps(); diff --git a/harness/ruview/test/fixtures/device-types.mts b/harness/ruview/test/fixtures/device-types.mts new file mode 100644 index 000000000..14146d8f6 --- /dev/null +++ b/harness/ruview/test/fixtures/device-types.mts @@ -0,0 +1,18 @@ +import { createRuView } from '../../src/sdk.js'; +const ruview = createRuView(); +ruview.devices.mmwave({ source: 'esphome', host: '127.0.0.1', api_port: 6053, seconds: 1 }); +ruview.devices.mmwave({ port: 'COM3', model: 'mr60bha2' }); +ruview.devices.mmwave({ source: 'serial', port: 'COM3', model: 'ld2410' }); +ruview.devices.esp32({ spectrum: true, spectrum_bins: 8, spectrum_frames: 8, udp_port: 5005 }); +ruview.devices.lidar({ source: 'iphone', url: 'ws://127.0.0.1:8787/ws/lidar', max_frames: 1 }); +ruview.devices.lidar({ source: 'rplidar', port: 'COM3' }); +// @ts-expect-error an ESPHome device still needs its host +ruview.devices.mmwave({ source: 'esphome' }); +// @ts-expect-error default serial access still needs its port +ruview.devices.mmwave({ model: 'mr60bha2' }); +// @ts-expect-error numeric capture option remains numeric +ruview.devices.esp32({ spectrum_frames: 'eight' }); +// @ts-expect-error iPhone packet cap remains numeric +ruview.devices.lidar({ source: 'iphone', url: 'ws://127.0.0.1:8787/ws/lidar', max_frames: 'one' }); +// @ts-expect-error known backends remain constrained +ruview.devices.esp32({ backend: 'gpu' }); diff --git a/harness/ruview/test/helpers/python.mjs b/harness/ruview/test/helpers/python.mjs new file mode 100644 index 000000000..02d37c69f --- /dev/null +++ b/harness/ruview/test/helpers/python.mjs @@ -0,0 +1,11 @@ +import { spawnSync } from 'node:child_process'; + +// Windows commonly installs python.exe without a python3.exe alias. +const candidates = process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python']; +export const python = candidates.find(command => { + const result = spawnSync(command, ['-c', 'import sys; assert sys.version_info >= (3, 9)'], { + stdio: 'ignore', timeout: 5000, windowsHide: true, + }); + return result.status === 0; +}); +if (!python) throw new Error('Capture regression tests require Python 3.9 or newer on PATH'); diff --git a/harness/ruview/test/iphone-close.test.mjs b/harness/ruview/test/iphone-close.test.mjs new file mode 100644 index 000000000..4c1577c6a --- /dev/null +++ b/harness/ruview/test/iphone-close.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { createHash } from 'node:crypto'; +import { readIphoneLidar } from '../src/devices/lidar.js'; + +// A real owned loopback WebSocket producer: upgrade, optional depth frame, +// close handshake. No injected client event callbacks or hardware. +async function relay(t, { frame = true, code = 1000 } = {}) { + const server = http.createServer(); + const sockets = new Set(); + server.on('connection', s => { sockets.add(s); s.on('close', () => sockets.delete(s)); }); + t.after(async () => { for (const s of sockets) s.destroy(); await new Promise(r => server.close(r)); }); + server.on('upgrade', (req, socket) => { + const accept = createHash('sha1').update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); + socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`); + if (frame) { + const mm = Buffer.alloc(2); mm.writeUInt16LE(1234); + const packet = { type: 'ruview.lidar.depth.v1', depth: { encoding: 'u16le-mm+u8-confidence', width: 1, height: 1, millimetersBase64: mm.toString('base64'), confidenceBase64: Buffer.from([1]).toString('base64') } }; + const payload = Buffer.from(JSON.stringify(packet)); + const header = Buffer.alloc(4); header[0] = 0x81; header[1] = 126; header.writeUInt16BE(payload.length, 2); + socket.write(Buffer.concat([header, payload])); + } + const close = Buffer.from([0x88, 2, 0, 0]); close.writeUInt16BE(code, 2); + socket.end(close); + }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + return `ws://127.0.0.1:${server.address().port}/ws/lidar`; +} + +test('public iPhone reader completes when a successful relay stream closes', async t => { + const url = await relay(t); + const started = performance.now(); + const result = await readIphoneLidar({ url, seconds: 2 }); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.frames, 1); + assert.equal(result.last.medianDepthM, 1.234); + assert.ok(performance.now() - started < 1000, 'closed relay must finish before the capture timeout'); +}); + +test('normal empty relay close completes as no_frames', async t => { + const url = await relay(t, { frame: false }); + const started = performance.now(); + const result = await readIphoneLidar({ url, seconds: 2 }); + assert.equal(result.ok, false); + assert.equal(result.reason, 'no_frames'); + assert.ok(performance.now() - started < 1000, 'empty closed relay must finish before timeout'); +}); + +test('abnormal empty relay close and packet limit controls retain semantics', async t => { + const denied = await readIphoneLidar({ url: await relay(t, { frame: false, code: 1008 }), seconds: 2 }); + assert.equal(denied.reason, 'relay_closed'); + assert.match(denied.detail, /1008/); + const limited = await readIphoneLidar({ url: await relay(t), seconds: 2, max_frames: 1 }); + assert.equal(limited.ok, true); + assert.equal(limited.frames, 1); +}); diff --git a/harness/ruview/test/ld2410-distance.test.mjs b/harness/ruview/test/ld2410-distance.test.mjs new file mode 100644 index 000000000..27959168a --- /dev/null +++ b/harness/ruview/test/ld2410-distance.test.mjs @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { execTool } from '../src/exec.js'; +import { readMmwave } from '../src/devices/mmwave.js'; +const python = ['python3', 'python'].find(p => spawnSync(p, ['--version'], { stdio: 'ignore' }).status === 0); +function target(state, moving = 0, stationary = 0) { + const body = Buffer.alloc(13); body[0] = 2; body[1] = 0xaa; body[2] = state; + body.writeUInt16LE(moving, 3); body[5] = 60; body.writeUInt16LE(stationary, 6); body[8] = 60; + body.writeUInt16LE(state === 1 || state === 3 ? moving : stationary, 9); body[11] = 0x55; + return Buffer.concat([Buffer.from([0xf4, 0xf3, 0xf2, 0xf1, 13, 0]), body, Buffer.from([0xf8, 0xf7, 0xf6, 0xf5])]); +} +async function read(t, frames) { + const dir = mkdtempSync(join(tmpdir(), 'ruview-ld2410-distance-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(join(dir, 'serial.py'), [ + 'import time', 'class Serial:', + ` def __init__(self,*a,**k): self.chunk=bytes.fromhex('${Buffer.concat(frames).toString('hex')}')`, + ' def read(self,n):', ' if self.chunk: b=self.chunk; self.chunk=b""; return b', + ' time.sleep(0.01); return b""', ' def close(self): pass', + ].join('\n')); + return readMmwave({ port: 'COM3', model: 'ld2410', seconds: 1 }, { + python: () => python, exec: (cmd, args, opts) => execTool(cmd, args, { ...opts, cwd: dir }), + }); +} + +test('public LD2410 reader does not average absent targets as zero-distance observations', { skip: !python }, async t => { + const result = await read(t, [target(1, 90), target(0)]); + assert.equal(result.ok, true); + assert.equal(result.frames, 2); + assert.equal(result.presentFraction, 0.5); + assert.equal(result.distanceCmMean, 90); + assert.equal(result.last[1].distanceCm, 0, 'raw no-target sentinel remains available'); +}); + +test('all-absent LD2410 capture has no measured mean distance', { skip: !python }, async t => { + const result = await read(t, [target(0), target(0)]); + assert.equal(result.ok, true); + assert.equal(result.presentFraction, 0); + assert.equal(result.distanceCmMean, null); +}); + +test('moving and stationary positive-distance controls remain averaged', { skip: !python }, async t => { + const result = await read(t, [target(1, 90), target(2, 0, 210)]); + assert.equal(result.ok, true); + assert.equal(result.presentFraction, 1); + assert.equal(result.distanceCmMean, 150); +}); diff --git a/harness/ruview/test/lidar-large-scan.test.mjs b/harness/ruview/test/lidar-large-scan.test.mjs new file mode 100644 index 000000000..72147b980 --- /dev/null +++ b/harness/ruview/test/lidar-large-scan.test.mjs @@ -0,0 +1,28 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readRplidar } from '../src/devices/lidar.js'; + +function scan(count) { + const bytes = Buffer.alloc(count * 5); + for (let i = 0; i < count; i++) { + const offset = i * 5; + bytes[offset] = 2; // start flag false and inverse flag true + bytes[offset + 1] = 1; // angle validity bit + bytes.writeUInt16LE((1000 + i % 3) * 4, offset + 3); + } + return bytes; +} + +for (const count of [3, 200_000]) { + test(`public RPLIDAR reader reduces ${count} valid points within capture byte cap`, async () => { + const bytes = scan(count); + const result = await readRplidar({ port: '/dev/ttyUSB0', seconds: 2 }, { + python: () => 'fixture-python', + exec: async () => ({ ok: true, stdout: `HEX ${bytes.toString('hex')}\nEND ${bytes.length}\n` }), + }); + assert.equal(result.ok, true); + assert.equal(result.validPoints, count); + assert.deepEqual(result.rangeMm, { min: 1000, median: 1001, max: 1002 }); + assert.equal(result.angularCoverage, 0.03); + }); +} diff --git a/harness/ruview/test/lidar-median.test.mjs b/harness/ruview/test/lidar-median.test.mjs new file mode 100644 index 000000000..88d2e1ff0 --- /dev/null +++ b/harness/ruview/test/lidar-median.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readIphoneLidar, readRplidar } from '../src/devices/lidar.js'; + +for (const [distances, expected] of [[[1000, 4000], 2500], [[4000, 1000, 2000], 2000], [[0, 1000, 4000], 2500]]) { + test(`LiDAR public readers calculate median of ${distances}`, async () => { + const bytes = Buffer.alloc(distances.length * 5); + const mm = Buffer.alloc(distances.length * 2); + distances.forEach((distance, i) => { + bytes[i * 5] = 2; bytes[i * 5 + 1] = 1; bytes.writeUInt16LE(distance * 4, i * 5 + 3); + mm.writeUInt16LE(distance, i * 2); + }); + const scan = await readRplidar({ port: '/dev/ttyUSB0', seconds: 1 }, { python: () => 'fixture-python', exec: async () => ({ ok: true, stdout: `HEX ${bytes.toString('hex')}\n` }) }); + assert.equal(scan.rangeMm.median, expected); + const packet = { type: 'ruview.lidar.depth.v1', depth: { width: distances.length, height: 1, encoding: 'u16le-mm+u8-confidence', millimetersBase64: mm.toString('base64'), confidenceBase64: Buffer.alloc(distances.length, 2).toString('base64') } }; + class FixtureWebSocket { + constructor() { setImmediate(() => { this.onopen?.(); this.onmessage?.({ data: JSON.stringify(packet) }); }); } + close() {} + } + const depth = await readIphoneLidar({ url: 'ws://127.0.0.1:8787/ws/lidar', max_frames: 1 }, { WebSocket: FixtureWebSocket, env: {} }); + assert.equal(depth.last.medianDepthM, expected / 1000); + }); +} diff --git a/harness/ruview/test/mcp-apps.test.mjs b/harness/ruview/test/mcp-apps.test.mjs index dcd036823..e2f011068 100644 --- a/harness/ruview/test/mcp-apps.test.mjs +++ b/harness/ruview/test/mcp-apps.test.mjs @@ -2,6 +2,9 @@ // negotiation, and the authenticated Streamable HTTP transport. import test from 'node:test'; import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import http from 'node:http'; +import { createInterface } from 'node:readline'; import { handleRpc, negotiateProtocol, SUPPORTED_PROTOCOLS, toCallToolResult } from '../src/mcp-server.js'; import { generateToken, httpGrants, startMcpHttp } from '../src/mcp-http.js'; import { CONSOLE_HTML, CONSOLE_URI, UI_MIME, UI_TOOLS } from '../src/ui/console-widget.js'; @@ -119,3 +122,54 @@ test('HTTP transport: auth, origin, method, size, protocol header, notifications test('HTTP transport refuses short tokens', async () => { await assert.rejects(startMcpHttp({ port: 0, token: 'short' }), /at least 16/); }); + + +test('HTTP transport rejects malformed request targets and continues serving', { timeout: 10_000 }, async (t) => { + // A child isolates the real transport: the old parser exception terminates it. + const moduleUrl = new URL('../src/mcp-http.js', import.meta.url).href; + const token = 'fixture-token-0123456789'; + const child = spawn(process.execPath, ['--input-type=module', '-e', ` + import { startMcpHttp } from ${JSON.stringify(moduleUrl)}; + const srv = await startMcpHttp({ port: 0, token: ${JSON.stringify(token)}, log: () => {} }); + console.log(srv.server.address().port); + `], { stdio: ['ignore', 'pipe', 'pipe'] }); + const lines = createInterface({ input: child.stdout }); + let stderr = ''; + child.stderr.on('data', (chunk) => { stderr += chunk; }); + t.after(async () => { + lines.close(); + if (child.exitCode === null && child.signalCode === null) { + await new Promise((resolve) => { child.once('exit', resolve); child.kill(); }); + } + }); + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('HTTP fixture did not start')), 3000); + lines.once('line', (line) => { clearTimeout(timer); resolve(Number(line)); }); + child.once('error', (error) => { clearTimeout(timer); reject(error); }); + child.once('exit', () => { clearTimeout(timer); reject(new Error(`HTTP fixture exited: ${stderr}`)); }); + }); + const base = `http://127.0.0.1:${port}`; + assert.equal((await fetch(`${base}/healthz`)).status, 200); + const malformed = await new Promise((resolve) => { + const request = http.request({ host: '127.0.0.1', port, path: 'http://[', method: 'GET' }, (response) => { + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk) => { body += chunk; }); + response.on('end', () => resolve({ status: response.statusCode, body })); + }); + request.setTimeout(3000, () => request.destroy(new Error('HTTP fixture request timed out'))); + request.once('error', (error) => resolve({ error: error.code || error.message })); + request.end(); + }); + assert.equal(malformed.status, 400, 'malformed target must receive an HTTP response'); + assert.deepEqual(JSON.parse(malformed.body), { error: 'invalid_request_target' }); + assert.equal((await fetch(`${base}/healthz`)).status, 200, 'transport remains available'); + assert.equal((await fetch(`${base}/mcp`, { method: 'POST', body: '{}' })).status, 401); + const initialized = await fetch(`${base}/mcp`, { + method: 'POST', headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }), + }); + assert.equal(initialized.status, 200); + assert.equal((await initialized.json()).id, 1); + assert.equal(child.exitCode, null); +}); diff --git a/harness/ruview/test/mmwave-single-frame.test.mjs b/harness/ruview/test/mmwave-single-frame.test.mjs new file mode 100644 index 000000000..ad2bf9280 --- /dev/null +++ b/harness/ruview/test/mmwave-single-frame.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mr60Checksum, readMmwave } from '../src/devices/mmwave.js'; + +const header = [1, 0, 1, 0, 1, 0x0f, 9]; +const bytes = Buffer.from([...header, mr60Checksum(header), 1, mr60Checksum([1])]); +const deps = () => ({ python: () => 'fixture-python', exec: async () => ({ ok: true, stdout: `HEX ${bytes.toString('hex')}\nEND ${bytes.length}\n` }) }); + +test('known radar model returns its single valid received presence frame', async () => { + const result = await readMmwave({ port: '/dev/ttyUSB0', model: 'mr60bha2', seconds: 1 }, deps()); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.frames, 1); + assert.equal(result.presentFraction, 1); + assert.equal(result.model, 'mr60bha2'); +}); + +test('auto detection still requires multiple validating frames', async () => { + const result = await readMmwave({ port: '/dev/ttyUSB0', model: 'auto', seconds: 1 }, deps()); + assert.equal(result.ok, false); + assert.equal(result.reason, 'no_valid_frames'); +}); diff --git a/harness/ruview/test/monitor-flow.test.mjs b/harness/ruview/test/monitor-flow.test.mjs new file mode 100644 index 000000000..a5d799e0c --- /dev/null +++ b/harness/ruview/test/monitor-flow.test.mjs @@ -0,0 +1,55 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +const python = ['python3', 'python'].find(p => spawnSync(p, ['--version'], { stdio: 'ignore' }).status === 0); +function monitor(t, lines) { + const dir = mkdtempSync(join(tmpdir(), 'ruview-monitor-flow-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const hex = lines.map(l => Buffer.from(l + '\n').toString('hex')); + writeFileSync(join(dir, 'serial.py'), [ + 'import time', 'class Serial:', + ` def __init__(self,*a,**k): self.lines=${JSON.stringify(hex)}`, + ' def open(self): pass', + ' def readline(self):', + ' if self.lines: return bytes.fromhex(self.lines.pop(0))', + ' time.sleep(0.01); return b""', + ' def close(self): pass', + ].join('\n')); + const sdk = new URL('../src/sdk.js', import.meta.url).href; + const script = `const {createRuView}=await import(${JSON.stringify(sdk)}); console.log(JSON.stringify(await createRuView().firmware.monitor('COM3',0.15,115200)));`; + const child = spawnSync(process.execPath, ['--input-type=module', '-e', script], { + env: { ...process.env, PYTHONPATH: dir }, encoding: 'utf8', timeout: 15000, + }); + assert.equal(child.status, 0, child.stderr); + return JSON.parse(child.stdout.trim()); +} +const startup = [ + 'I (100) csi_collector: Early capture node_id=1 (before WiFi init, #232/#390)', + 'W (200) csi_collector: self-ping: no gateway IP yet; CSI relies on ambient frames (#954)', +]; + +test('public SDK monitor does not certify CSI flow from startup/configuration tags', { skip: !python }, t => { + const result = monitor(t, startup); + assert.equal(result.ok, false, JSON.stringify(result)); + assert.equal(result.csi_callbacks, 0); + assert.equal(result.lines, 2); + assert.match(result.hint, /no CSI log lines/i); +}); + +test('public SDK monitor counts only actual ESP32 and Realtek flow markers', { skip: !python }, t => { + const result = monitor(t, [...startup, 'I (300) csi_collector: CSI cb #1: len=128 rssi=-52 ch=6', 'RUVIEW_CSI: frame #10 seq=10']); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.csi_callbacks, 2); + assert.equal(result.lines, 4); +}); + +test('buffer starvation still refuses a monitor with a real frame marker', { skip: !python }, t => { + const result = monitor(t, ['RUVIEW_CSI: frame #10 seq=10', '[WLAN-W] lack of csi buf!']); + assert.equal(result.ok, false); + assert.equal(result.reason, 'csi_buffer_starvation'); + assert.equal(result.csi_callbacks, 1); + assert.equal(result.csi_buffer_starved, 1); +}); diff --git a/harness/ruview/test/overnight-capture-layout.test.mjs b/harness/ruview/test/overnight-capture-layout.test.mjs new file mode 100644 index 000000000..72944f5a2 --- /dev/null +++ b/harness/ruview/test/overnight-capture-layout.test.mjs @@ -0,0 +1,46 @@ +import { python } from './helpers/python.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import dgram from 'node:dgram'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const script = fileURLToPath(new URL('../../../scripts/overnight-empty-capture.py', import.meta.url)); +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +function packet(antennas = 1) { + const b = Buffer.alloc(20 + antennas * 4); + b.writeUInt32LE(0xC5110001); b[4] = 7; b[5] = antennas; b.writeUInt16LE(2, 6); + b.writeUInt32LE(2437, 8); b.writeUInt32LE(0x01020304, 12); b.writeInt8(-50, 16); b.writeInt8(-95, 17); b[18] = 1; b[19] = 3; + for (let i = 20; i < b.length; i += 4) Buffer.from([3, 4, 250, 8]).copy(b, i); + return b; +} +async function capture(packets) { + const dir = mkdtempSync(join(tmpdir(), 'ruview-overnight-')); + const probe = dgram.createSocket('udp4'); probe.bind(0, '127.0.0.1'); await once(probe, 'listening'); + const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); + const tx = dgram.createSocket('udp4'); + const child = spawn(python, ['-u', script, '--port', String(port), '--segments', '1', '--segment-seconds', '1', '--output', dir]); + const exited = once(child, 'exit'); + try { + for (let i = 0; !readdirSync(dir).length && i < 40; i++) await delay(25); + assert.ok(readdirSync(dir).length, 'capture must bind and open its segment'); + for (const p of packets) await new Promise((resolve, reject) => tx.send(p, port, '127.0.0.1', e => e ? reject(e) : resolve())); + const [code] = await exited; assert.equal(code, 0); + const text = readFileSync(join(dir, readdirSync(dir)[0]), 'utf8').trim(); + return text ? text.split('\n').map(JSON.parse) : []; + } finally { tx.close(); if (child.exitCode === null) { child.kill('SIGINT'); await exited; } rmSync(dir, { recursive: true, force: true }); } +} +test('public overnight capture records ADR018 metadata and only declared IQ payload', async () => { + const b = packet(2), [f] = await capture([Buffer.concat([b, Buffer.from([255, 255])])]); + assert.equal(f.rssi, -50); assert.equal(f.channel, 6); assert.equal(f.seq, 0x01020304); + assert.equal(f.n_antennas, 2); assert.equal(f.n_subcarriers, 2); assert.equal(f.subcarriers, 4); + assert.deepEqual(f.amplitudes, [5, 10, 5, 10]); assert.equal(f.iq_hex, b.subarray(20).toString('hex')); +}); +test('public overnight capture rejects wrong magic and truncated payloads while keeping valid frames', async () => { + const valid = packet(), wrong = Buffer.from(valid); wrong.writeUInt32LE(0xC5110003); + const frames = await capture([wrong, valid.subarray(0, valid.length - 1), valid]); + assert.equal(frames.length, 1); assert.deepEqual(frames[0].amplitudes, [5, 10]); +}); diff --git a/harness/ruview/test/remote.test.mjs b/harness/ruview/test/remote.test.mjs index acce5629a..0eb08f466 100644 --- a/harness/ruview/test/remote.test.mjs +++ b/harness/ruview/test/remote.test.mjs @@ -1,9 +1,9 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { buildSshArgs, restrictToOwner, runRemote, saveHost, shellQuote, validateHost } from '../src/remote.js'; import { isRemoteReadOnlyTool, runTool, TOOLS } from '../src/tools.js'; import { validateArguments } from '../src/policy.js'; @@ -17,13 +17,21 @@ test('host entries reject ssh option injection and shell metacharacters', () => assert.deepEqual(validateHost({ name: 'lab-pi', ssh: 'ruv@pi.local', port: 2222 }), { name: 'lab-pi', ssh: 'ruv@pi.local', port: 2222 }); }); -test('remote command is fully shell-quoted and ssh options are fixed', () => { +test('remote command is fully shell-quoted and ssh options are fixed', t => { assert.equal(shellQuote("a'b"), `'a'\\''b'`); const argv = buildSshArgs({ ssh: 'ruv@pi.local', port: 2222 }, ['npx', '-y', '@ruvnet/ruview@0.7.0', 'call', 'ruview_doctor', '--read-only', '--args-json', '{"x":"$(id); `rm`"}']); assert.deepEqual(argv.slice(0, 11), ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-o', 'StrictHostKeyChecking=yes', '-T', '-p', '2222', '--', 'ruv@pi.local']); const remote = argv.at(-1); + let shell = 'sh'; + if (process.platform === 'win32' && spawnSync(shell, ['-c', 'exit 0']).status !== 0) { + const git = spawnSync('where.exe', ['git'], { encoding: 'utf8' }).stdout?.trim().split(/\r?\n/)[0]; + const gitShell = git && resolve(dirname(git), '..', 'bin', 'sh.exe'); + if (!gitShell || !existsSync(gitShell)) { t.skip('POSIX shell unavailable; Linux CI exercises shell interpretation'); return; } + shell = gitShell; + } // Every token is single-quoted, so the remote shell sees literal text. - const sh = spawnSync('sh', ['-c', `for a in ${remote}; do printf '%s\\n' "$a"; done`], { encoding: 'utf8' }); + const sh = spawnSync(shell, ['-c', `for a in ${remote}; do printf '%s\\n' "$a"; done`], { encoding: 'utf8' }); + assert.equal(sh.status, 0, sh.stderr || sh.error?.message); assert.equal(sh.stdout.trim().split('\n').at(-1), '{"x":"$(id); `rm`"}'); }); diff --git a/harness/ruview/test/sdk-device-options.test.mjs b/harness/ruview/test/sdk-device-options.test.mjs new file mode 100644 index 000000000..9ea461c08 --- /dev/null +++ b/harness/ruview/test/sdk-device-options.test.mjs @@ -0,0 +1,78 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import net from 'node:net'; +import http from 'node:http'; +import dgram from 'node:dgram'; +import { createHash } from 'node:crypto'; +import { createRuView } from '../src/sdk.js'; +import { encodeFrame, encodeVarint, splitFrames } from '../src/devices/esphome.js'; +const str = (f, s) => { const b = Buffer.from(s); return Buffer.concat([encodeVarint((f << 3) | 2), encodeVarint(b.length), b]); }; +const key = (f, n) => { const b = Buffer.alloc(4); b.writeUInt32LE(n); return Buffer.concat([encodeVarint((f << 3) | 5), b]); }; +const float = (f, v) => { const b = Buffer.alloc(4); b.writeFloatLE(v); return Buffer.concat([encodeVarint((f << 3) | 5), b]); }; +const pause = ms => new Promise(r => setTimeout(r, ms)); + +async function ownServer(t, server) { + const sockets = new Set(); + server.on('connection', s => { sockets.add(s); s.on('close', () => sockets.delete(s)); s.on('error', () => {}); }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + t.after(async () => { for (const s of sockets) s.destroy(); await new Promise(r => server.close(r)); }); + return server.address().port; +} + +test('public SDK accepts ESPHome source arguments and reads a numeric state', async t => { + const server = net.createServer(sock => { + let buf = Buffer.alloc(0); + sock.on('data', data => { + buf = Buffer.concat([buf, data]); const parsed = splitFrames(buf); buf = parsed.rest; + for (const { type } of parsed.frames) { + if (type === 1) sock.write(encodeFrame(2)); + if (type === 3) sock.write(encodeFrame(4)); + if (type === 11) { + sock.write(encodeFrame(16, Buffer.concat([str(1, 'distance'), key(2, 1), str(3, 'Distance'), str(6, 'cm')]))); + sock.write(encodeFrame(19)); + } + if (type === 20) sock.write(encodeFrame(25, Buffer.concat([key(1, 1), float(2, 50)]))); + } + }); + }); + const port = await ownServer(t, server); + const result = await createRuView().devices.mmwave({ source: 'esphome', host: '127.0.0.1', api_port: port, seconds: 1 }); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.source, 'esphome'); + assert.equal(result.distanceCmMean, 50); +}); + +test('public SDK accepts spectrum options and returns real UDP amplitude rows', async t => { + const probe = dgram.createSocket('udp4'); await new Promise(r => probe.bind(0, '127.0.0.1', r)); + const port = probe.address().port; await new Promise(r => probe.close(r)); + const sender = dgram.createSocket('udp4'); t.after(() => sender.close()); + const pending = createRuView().devices.esp32({ bind: '127.0.0.1', udp_port: port, seconds: 1, max_packets: 1, spectrum: true, spectrum_bins: 8, spectrum_frames: 8 }); + await pause(100); + const packet = Buffer.alloc(20 + 16 * 2); packet.writeUInt32LE(0xC5110001, 0); packet[4] = 1; packet[5] = 1; packet.writeUInt16LE(16, 6); packet.writeUInt32LE(2437, 8); + for (let i = 20; i < packet.length; i += 2) { packet[i] = 3; packet[i + 1] = 4; } + sender.send(packet, port, '127.0.0.1'); + const result = await pending; + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.spectrum[0].bins, 8); + assert.deepEqual(result.spectrum[0].frames, [Array(8).fill(5)]); +}); + +test('public SDK accepts iPhone max_frames and terminates at the packet cap', async t => { + const server = http.createServer(); + server.on('upgrade', (req, sock) => { + const accept = createHash('sha1').update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); + sock.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`); + const mm = Buffer.alloc(2); mm.writeUInt16LE(1500); + const payload = Buffer.from(JSON.stringify({ type: 'ruview.lidar.depth.v1', depth: { encoding: 'u16le-mm+u8-confidence', width: 1, height: 1, millimetersBase64: mm.toString('base64'), confidenceBase64: Buffer.from([1]).toString('base64') } })); + const head = Buffer.alloc(4); head[0] = 0x81; head[1] = 126; head.writeUInt16BE(payload.length, 2); + // Leave the producer open: max_frames, rather than close/timeout, ends it. + sock.write(Buffer.concat([head, payload])); + }); + const port = await ownServer(t, server); + const start = performance.now(); + const result = await createRuView().devices.lidar({ source: 'iphone', url: `ws://127.0.0.1:${port}/ws/lidar`, seconds: 2, max_frames: 1 }); + assert.equal(result.ok, true, JSON.stringify(result)); + assert.equal(result.frames, 1); + assert.equal(result.last.medianDepthM, 1.5); + assert.ok(performance.now() - start < 1000, 'packet cap terminates before timeout'); +}); diff --git a/harness/ruview/test/sdk-doctor-strict.test.mjs b/harness/ruview/test/sdk-doctor-strict.test.mjs new file mode 100644 index 000000000..b95e7befd --- /dev/null +++ b/harness/ruview/test/sdk-doctor-strict.test.mjs @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRuView, RuViewError } from '../src/sdk.js'; +const deps = { python: () => null, findRepoRoot: () => null, fetch: async () => { throw new Error('offline fixture'); } }; +const options = { groups: ['sensing'], sensing_url: 'file:///invalid-protocol' }; + +test('strict SDK doctor rejects a failing diagnostic with its report attached', async () => { + const ruview = createRuView({ strict: true, deps }); + await assert.rejects(() => ruview.doctor(options), (error) => error instanceof RuViewError && error.tool === 'ruview_doctor' && error.result.ok === false && error.result.summary.fail === 1); +}); + +test('ordinary SDK doctor returns failing diagnostics; strict successful doctor remains a report', async () => { + const ordinary = await createRuView({ deps }).doctor(options); + assert.equal(ordinary.ok, false); + const successful = await createRuView({ strict: true, deps }).doctor({ groups: ['runtime'] }); + assert.equal(successful.ok, true); +}); diff --git a/harness/ruview/test/sdk.test.mjs b/harness/ruview/test/sdk.test.mjs index 3b84dba9a..f6cbabfd0 100644 --- a/harness/ruview/test/sdk.test.mjs +++ b/harness/ruview/test/sdk.test.mjs @@ -39,3 +39,18 @@ test('policy files list exactly the registry tools (no drift)', () => { const confirmed = Object.entries(TOOL_POLICY).filter(([, p]) => p.confirmField).map(([n]) => n).sort(); assert.deepEqual(Object.keys(mcp.dangerousTools).sort(), confirmed); }); + + +test('SDK forwards explicit search limits to the registry validator', async () => { + const sdk = createRuView(); + for (const limit of [0, NaN, -1, 26]) { + const result = await sdk.memorySearch('firmware', limit); + assert.equal(result.ok, false, `invalid limit ${String(limit)} must not become the default`); + assert.equal(result.reason, 'invalid_arguments'); + } + const strict = createRuView({ strict: true }); + await assert.rejects(() => strict.memorySearch('firmware', 0), + (error) => error instanceof RuViewError && error.reason === 'invalid_arguments'); + assert.deepEqual(await sdk.memorySearch('firmware'), await sdk.call('ruview_memory_search', { query: 'firmware' })); + assert.deepEqual(await sdk.memorySearch('firmware', 1), await sdk.call('ruview_memory_search', { query: 'firmware', limit: 1 })); +}); diff --git a/harness/ruview/test/serial-partial.test.mjs b/harness/ruview/test/serial-partial.test.mjs new file mode 100644 index 000000000..8f41d3fe1 --- /dev/null +++ b/harness/ruview/test/serial-partial.test.mjs @@ -0,0 +1,75 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { execTool } from '../src/exec.js'; +import { readSerial } from '../src/devices/serial-pump.js'; +import { readMmwave, mr60Checksum } from '../src/devices/mmwave.js'; +import { readRplidar } from '../src/devices/lidar.js'; +const python = ['python3', 'python'].find(p => spawnSync(p, ['--version'], { stdio: 'ignore' }).status === 0); +function fixture(t, bytes, interrupted) { + const dir = mkdtempSync(join(tmpdir(), 'ruview-partial-serial-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + writeFileSync(join(dir, 'serial.py'), [ + 'import time', + 'class Serial:', + ` def __init__(self, *a, **kw): self.first=True; self.chunk=bytes.fromhex('${bytes.toString('hex')}')`, + ' def read(self, n):', + ' if self.first: self.first=False; return self.chunk', + interrupted ? " raise RuntimeError('fixture disconnected after partial capture')" : " time.sleep(0.01); return b''", + ' def write(self, data): return len(data)', + ' def flush(self): pass', + ' def close(self): pass', + ].join('\n')); + return { python: () => python, exec: (cmd, args, opts) => execTool(cmd, args, { ...opts, cwd: dir }) }; +} +function mr(type, value) { + const payload = Buffer.alloc(4); payload.writeFloatLE(value); + const header = Buffer.from([1, 0, 0, 0, 4, type >> 8, type & 255]); + return Buffer.concat([header, Buffer.from([mr60Checksum(header)]), payload, Buffer.from([mr60Checksum(payload)])]); +} +const radar = Buffer.concat([mr(0x0a14, 15), mr(0x0a15, 72)]); +const scan = Buffer.from([5, 1, 0, 160, 15, 6, 1, 45, 64, 31]); + +test('public serial pump preserves bytes and flags an interrupted subprocess', { skip: !python }, async t => { + const result = await readSerial({ port: 'COM3', baud: 115200, seconds: 0.5 }, fixture(t, radar, true)); + assert.equal(result.ok, true, 'partial bytes remain available'); + assert.deepEqual(result.bytes, radar); + assert.equal(result.captureIncomplete, true); + assert.match(result.warnings.join(' '), /partial|incomplete/i); +}); + +test('public radar summary retains interrupted capture state', { skip: !python }, async t => { + const radarResult = await readMmwave({ port: 'COM3', model: 'mr60bha2', seconds: 1 }, fixture(t, radar, true)); + assert.equal(radarResult.ok, true); + assert.equal(radarResult.heartBpmMean, 72); + assert.equal(radarResult.captureIncomplete, true); +}); + +test('public LiDAR summary retains interrupted capture state', { skip: !python }, async t => { + const scanResult = await readRplidar({ port: 'COM3', seconds: 1 }, fixture(t, scan, true)); + assert.equal(scanResult.ok, true); + assert.equal(scanResult.validPoints, 2); + assert.equal(scanResult.captureIncomplete, true); + assert.match(scanResult.warnings.join(' '), /partial|incomplete/i); +}); + +test('normal pump completion retains the complete-capture control', { skip: !python }, async t => { + const result = await readSerial({ port: 'COM3', baud: 115200, seconds: 0.5 }, fixture(t, radar, false)); + assert.equal(result.ok, true); + assert.deepEqual(result.bytes, radar); + assert.notEqual(result.captureIncomplete, true); + assert.equal(result.warnings, undefined); +}); + +test('auto-detection retains probe values but flags failure of the remainder', { skip: !python }, async t => { + const good = fixture(t, radar, false); const bad = fixture(t, Buffer.alloc(0), true); + let calls = 0; + const result = await readMmwave({ port: 'COM3', seconds: 2.5 }, { python: good.python, exec: (...args) => (++calls === 1 ? good.exec : bad.exec)(...args) }); + assert.equal(result.ok, true); + assert.equal(result.frames, 2); + assert.equal(result.captureIncomplete, true); + assert.match(result.warnings.join(' '), /remaining.*failed/); +}); diff --git a/harness/ruview/test/training-capture-collisions.test.mjs b/harness/ruview/test/training-capture-collisions.test.mjs new file mode 100644 index 000000000..c6c4ac528 --- /dev/null +++ b/harness/ruview/test/training-capture-collisions.test.mjs @@ -0,0 +1,40 @@ +import { python } from './helpers/python.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import dgram from 'node:dgram'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtempSync, writeFileSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +const script = fileURLToPath(new URL('../../../scripts/collect-training-data.py', import.meta.url)); +async function record(dir, port, sequence) { + const child = spawn(python, ['-u', script, '--port', String(port), '--label', 'walking', '--duration', '0.02', '--output-dir', dir], { env: { ...process.env, PYTHONPATH: dir } }); + const exited = once(child, 'exit'); let timer; + const tx = dgram.createSocket('udp4'); + try { + await new Promise((resolve, reject) => { + let text = ''; timer = setTimeout(() => reject(new Error('collector startup timeout')), 2000); + child.stderr.on('data', b => { text += b; if (text.includes("Collecting 'walking'")) { clearTimeout(timer); resolve(); } }); + child.once('error', reject); + }); + const packet = Buffer.alloc(24); packet.writeUInt32LE(0xC5110001); packet[4]=1; packet[5]=1; packet.writeUInt16LE(2,6); packet.writeUInt32LE(2437,8); packet.writeUInt32LE(sequence,12); packet.writeInt8(-50,16); Buffer.from([3,4,6,8]).copy(packet,20); + await new Promise((resolve,reject)=>tx.send(packet,port,'127.0.0.1',e=>e?reject(e):resolve())); + const [code] = await exited; assert.equal(code,0); + } finally { clearTimeout(timer); tx.close(); if(child.exitCode===null){child.kill('SIGINT');await exited;} } +} +test('public collector CLI preserves distinct same-second sessions and matching metadata', async () => { + const dir=mkdtempSync(join(tmpdir(),'ruview-session-collision-')); + const probe=dgram.createSocket('udp4'); probe.bind(0,'127.0.0.1');await once(probe,'listening');const port=probe.address().port;await new Promise(resolve=>probe.close(resolve)); + try { + writeFileSync(join(dir,'sitecustomize.py'), 'import datetime\nclass Fixed(datetime.datetime):\n @classmethod\n def now(cls, tz=None):\n return cls(2026,10,11,12,0,0,tzinfo=tz)\ndatetime.datetime=Fixed\n'); + await record(dir,port,1); await record(dir,port,2); + const metadata=readdirSync(dir).filter(f=>f.endsWith('.csi.meta.json')).map(f=>JSON.parse(readFileSync(join(dir,f),'utf8'))); + assert.equal(metadata.length,2);assert.equal(new Set(metadata.map(m=>m.id)).size,2); + const sequences=[]; + for(const m of metadata){const frames=readFileSync(m.file_path,'utf8').trim().split('\n').map(JSON.parse);assert.equal(m.frame_count,frames.length);assert.equal(frames.length,1);sequences.push(frames[0].features.seq);} + assert.deepEqual(sequences.sort(),[1,2]); + const manifest=JSON.parse(readFileSync(join(dir,'manifest.json'),'utf8'));assert.equal(manifest.num_sessions,2);assert.equal(manifest.total_frames,2); + } finally {rmSync(dir,{recursive:true,force:true});} +}); diff --git a/harness/ruview/test/training-time.test.mjs b/harness/ruview/test/training-time.test.mjs new file mode 100644 index 000000000..7834d1a78 --- /dev/null +++ b/harness/ruview/test/training-time.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createRuView } from '../src/sdk.js'; +import { trainingGate } from '../src/training.js'; +const report = { model_score: 0.7, baseline_score: 0.5, split: 'chronological', n_test: 200, data: 'synthetic' }; + +test('public SDK accepts epoch-zero ISO chronology boundary', async () => { + const result = await createRuView().training.gate({ ...report, train_end: '1970-01-01T00:00:00.000Z', test_start: '1970-01-01T00:00:00.030Z' }); + assert.equal(result.verdict, 'PASS', JSON.stringify(result)); + assert.equal(result.evidence, 'SYNTHETIC'); +}); + +test('public training export treats numeric timestamps as milliseconds', () => { + const result = trainingGate({ ...report, train_end: 5, test_start: 30 }); + assert.equal(result.verdict, 'PASS', JSON.stringify(result)); +}); + +test('equal chronology and invalid date controls remain refused', async () => { + for (const [end, start] of [['1970-01-01T00:00:00Z', '1970-01-01T00:00:00Z'], ['invalid', '2026-01-02T00:00:00Z']]) { + assert.equal((await createRuView().training.gate({ ...report, train_end: end, test_start: start })).verdict, 'FAIL'); + } +}); diff --git a/harness/ruview/test/watch-gaps.test.mjs b/harness/ruview/test/watch-gaps.test.mjs new file mode 100644 index 000000000..39840647c --- /dev/null +++ b/harness/ruview/test/watch-gaps.test.mjs @@ -0,0 +1,62 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import dgram from 'node:dgram'; +import { spawn } from 'node:child_process'; +import { sparkline } from '../src/cli-ui.js'; +const pause = ms => new Promise(r => setTimeout(r, ms)); + +// Exercise the actual watch command with terminal rendering enabled. Only the +// stdout terminal capability is supplied; capture and parsing remain real. +test('CLI watch preserves an absent capture window in a returning node trend', { timeout: 12000 }, async t => { + const udp = dgram.createSocket('udp4'); + await new Promise(r => udp.bind(0, '127.0.0.1', r)); + const port = udp.address().port; + await new Promise(r => udp.close(r)); + const sender = dgram.createSocket('udp4'); + t.after(() => sender.close()); + const cli = new URL('../src/cli.js', import.meta.url).href; + const script = `Object.defineProperty(process.stdout,'isTTY',{value:true}); const {run}=await import(${JSON.stringify(cli)}); console.log('READY'); process.exitCode=await run(['esp32','--watch','--bind','127.0.0.1','--udp-port',${JSON.stringify(String(port))},'--seconds','1','--max-packets','1']);`; + const child = spawn(process.execPath, ['--input-type=module', '-e', script], { env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' } }); + t.after(async () => { + if (child.exitCode !== null || child.signalCode !== null) return; + const closed = new Promise(r => child.once('close', r)); + child.kill('SIGKILL'); + await closed; + }); + let output = ''; let errors = ''; + child.stdout.on('data', b => { output += b; }); + child.stderr.on('data', b => { errors += b; }); + async function waitFor(predicate) { + for (let n = 0; n < 500; n++) { + if (predicate()) return; + if (child.exitCode !== null) assert.fail(`child exited ${child.exitCode}: ${errors}\n${output}`); + await pause(10); + } + assert.fail(`CLI condition timeout: ${errors}\n${output}`); + } + const packet = Buffer.alloc(28); + packet.writeUInt32LE(0xC5110001, 0); packet[4] = 9; packet[5] = 1; packet.writeUInt16LE(4, 6); + packet.writeUInt32LE(2437, 8); packet.writeInt8(-52, 16); packet.writeInt8(-95, 17); + for (let i = 20; i < 28; i += 2) { packet[i] = 3; packet[i + 1] = 4; } + const windows = () => output.split('RUVIEW / NODE STREAM').length - 1; + await waitFor(() => output.includes('READY')); await pause(200); + sender.send(packet, port, '127.0.0.1'); + await waitFor(() => windows() === 1); + await waitFor(() => windows() === 2); // no packets in the second window + await pause(50); packet.writeUInt32LE(1, 12); sender.send(packet, port, '127.0.0.1'); + await waitFor(() => windows() === 3); + const last = output.split('RUVIEW / NODE STREAM').at(-1); + child.kill('SIGINT'); + const row = last.split('\n').find(l => l.startsWith('esp32')); + assert.ok(row, last); + // Packet-limited windows now use their measured elapsed times, so their + // rates can differ. The missing window must still occupy the middle slot. + assert.match(row, /[▁▂▃▄▅▆▇█]·[▁▂▃▄▅▆▇█]\s*$/, `one position per capture window: ${row}`); +}); + +test('sparkline keeps missing positions and finite scale controls', () => { + assert.equal(sparkline([5, null, 5]), '▄·▄'); + assert.equal(sparkline([null, NaN]), '··'); + assert.equal(sparkline([0, null, 8]), '▁·█'); + assert.equal(sparkline([]), ''); +}); diff --git a/python/README.md b/python/README.md index 8e43f8d8c..29287b688 100644 --- a/python/README.md +++ b/python/README.md @@ -126,7 +126,8 @@ listener.on(SemanticPrimitive.PossibleDistress, lambda e: alert(e)) client = RuViewMqttClient(broker_host="homeassistant.local") client.on_message( - "homeassistant/+/wifi_densepose_+/+/state", + # MQTT `+` must fill a whole topic level; the listener skips non-RuView topics. + "homeassistant/+/+/+/state", listener.handle_mqtt_message, ) client.start() @@ -139,8 +140,8 @@ client.wait_connected() import numpy as np from wifi_densepose import BfldFrame, BfldKind -# Parse compressed BFR from a Wireshark capture into a Complex64 ndarray ... -fb = np.zeros((2, 1, 996), dtype=np.complex64) # Nr=2 Nc=1 Nsc=996 for HE80 +# Parse compressed BFR from a Wireshark capture into a complex128 ndarray ... +fb = np.zeros((2, 1, 996), dtype=np.complex128) # Nr=2 Nc=1 Nsc=996 for HE80 frame = BfldFrame.from_compressed_feedback( timestamp_ms=ts, diff --git a/python/src/bindings/bfld.rs b/python/src/bindings/bfld.rs index bd83ac886..b7f61c05f 100644 --- a/python/src/bindings/bfld.rs +++ b/python/src/bindings/bfld.rs @@ -15,7 +15,7 @@ //! //! 1. Capture BFR frames with `tcpdump` / Wireshark + the BFR dissector //! (or via `mac80211` debugfs on Linux 6.10+) -//! 2. Parse the compressed feedback into a numpy Complex64 ndarray +//! 2. Parse the compressed feedback into a numpy complex128 ndarray //! `[Nr × Nc × Nsc]` using your favourite Python BFR parser //! 3. Construct `BfldFrame.from_compressed_feedback(...)` to hand the //! matrix to RuView @@ -103,7 +103,7 @@ impl PyBfldKind { /// One BFR snapshot: a compressed beamforming feedback matrix tagged /// with metadata (timestamp, sounding sequence, source MAC, kind). /// -/// Backing storage: a numpy Complex64 ndarray `[Nr × Nc × Nsc]`. The +/// Backing storage: a numpy complex128 ndarray `[Nr × Nc × Nsc]`. The /// Python constructor accepts the ndarray directly; under the hood we /// hold a `Vec` in row-major order. /// @@ -112,7 +112,7 @@ impl PyBfldKind { /// import numpy as np /// from wifi_densepose import BfldFrame, BfldKind /// -/// fb = np.zeros((2, 1, 996), dtype=np.complex64) # Nr=2, Nc=1, Nsc=996 +/// fb = np.zeros((2, 1, 996), dtype=np.complex128) # Nr=2, Nc=1, Nsc=996 /// frame = BfldFrame.from_compressed_feedback( /// timestamp_ms=1234, /// sounding_index=42, @@ -138,7 +138,7 @@ pub struct PyBfldFrame { #[pymethods] impl PyBfldFrame { - /// Construct from a pre-parsed Complex64 ndarray of shape + /// Construct from a pre-parsed numpy complex128 ndarray of shape /// `[n_rows, n_cols, n_subcarriers]`. The last dimension MUST /// match `kind.n_subcarriers`. #[staticmethod] @@ -212,7 +212,7 @@ impl PyBfldFrame { sum / self.matrix.len() as f64 } - /// Return the feedback matrix as a numpy Complex64 ndarray of + /// Return the feedback matrix as a numpy complex128 ndarray of /// shape `[n_rows, n_cols, n_subcarriers]`. Allocates a fresh /// Python-owned array; the BfldFrame keeps its own copy. fn feedback_matrix<'py>(&self, py: Python<'py>) -> Bound<'py, PyArray3> { diff --git a/python/tests/test_client_ha.py b/python/tests/test_client_ha.py index 4fbea64ff..05033b27b 100644 --- a/python/tests/test_client_ha.py +++ b/python/tests/test_client_ha.py @@ -65,6 +65,15 @@ def test_parse_discovery_topic_event() -> None: assert out == ("event", "aabbccddeeff", "fall") +def test_parse_discovery_topic_accepts_default_server_node_id() -> None: + """The sensing-server's node id defaults to its MQTT client id, + `wifi-densepose-<8 hex>`, with `-node` appended per node.""" + out = parse_discovery_topic( + "homeassistant/binary_sensor/wifi_densepose_wifi-densepose-1a2b3c4d-node1/presence/config" + ) + assert out == ("binary_sensor", "wifi-densepose-1a2b3c4d-node1", "presence") + + def test_parse_discovery_topic_returns_none_for_non_discovery() -> None: assert parse_discovery_topic("homeassistant/binary_sensor/foo/state") is None assert parse_discovery_topic("ruview/aabbccddeeff/raw/edge_vitals") is None diff --git a/python/tests/test_client_mqtt.py b/python/tests/test_client_mqtt.py index a06a230ab..963ee9d00 100644 --- a/python/tests/test_client_mqtt.py +++ b/python/tests/test_client_mqtt.py @@ -21,7 +21,11 @@ from typing import Any import pytest -from wifi_densepose.client import RuViewMqttClient +from wifi_densepose.client import ( + RuViewMqttClient, + SemanticPrimitive, + SemanticPrimitiveListener, +) from wifi_densepose.client.mqtt import _topic_matches @@ -185,6 +189,30 @@ def test_multiple_handlers_for_overlapping_patterns_all_fire() -> None: assert len(b_received) == 1 +def test_documented_ha_state_pattern_subscribes_and_reaches_listener() -> None: + """The subscription shown in python/README.md and the + SemanticPrimitiveListener docstring. paho rejects mid-level `+` + (e.g. `wifi_densepose_+`) with ValueError on subscribe.""" + listener = SemanticPrimitiveListener() + received: list[Any] = [] + listener.on(SemanticPrimitive.SomeoneSleeping, received.append) + + c = RuViewMqttClient() + c.on_message("homeassistant/+/+/+/state", listener.handle_mqtt_message) + + # The real paho client validates the filter before it checks for a + # connection, so this exercises the same subscribe() the broker path runs. + c._on_connect(c._client, None, None, 0) + + c._on_message(None, None, _fake_message( + "homeassistant/binary_sensor/wifi_densepose_aabb/someone_sleeping/state", + {"state": "ON", "confidence": 0.9}, + )) + assert [(e.kind, e.node_id, e.state) for e in received] == [ + (SemanticPrimitive.SomeoneSleeping, "aabb", "ON"), + ] + + # ─── on_connect path ───────────────────────────────────────────────── diff --git a/python/tests/test_client_primitives.py b/python/tests/test_client_primitives.py index 4b17af18c..e891d3185 100644 --- a/python/tests/test_client_primitives.py +++ b/python/tests/test_client_primitives.py @@ -22,12 +22,12 @@ def test_enum_covers_all_10_v1_primitives() -> None: "someone_sleeping", "possible_distress", "room_active", - "elderly_inactivity", + "elderly_inactivity_anomaly", "meeting_in_progress", "bathroom_occupied", "fall_risk_elevated", "bed_exit", - "no_movement_safety", + "no_movement", "multi_room_transition", } actual = {p.value for p in SemanticPrimitive} @@ -121,6 +121,34 @@ def test_listener_decodes_numeric_sensor_state() -> None: assert evt.state == "73" +def test_listener_decodes_server_score_payload() -> None: + """The sensing-server publishes fall_risk_elevated as a ScorePayload + (`{"score": ..., "ts": ...}`, mqtt/state.rs) with no `state` key.""" + listener = SemanticPrimitiveListener() + evt = listener.handle_mqtt_message( + "homeassistant/sensor/wifi_densepose_aabb/fall_risk_elevated/state", + json.dumps({"score": 73.0, "ts": "2026-10-11T12:00:00Z"}), + ) + assert evt is not None + assert evt.state == "73.0" + + +def test_listener_routes_server_topic_slugs() -> None: + """Slugs must match EntityKind::topic_slug() in the sensing-server's + mqtt/discovery.rs, or the server's state messages are dropped.""" + listener = SemanticPrimitiveListener() + received: list[SemanticPrimitive] = [] + listener.on_any(lambda e: received.append(e.kind)) + for slug in ("elderly_inactivity_anomaly", "no_movement"): + listener.handle_mqtt_message( + f"homeassistant/binary_sensor/wifi_densepose_aabb/{slug}/state", "ON" + ) + assert received == [ + SemanticPrimitive.ElderlyInactivityAnomaly, + SemanticPrimitive.NoMovementSafety, + ] + + def test_listener_decodes_bytes_payload() -> None: listener = SemanticPrimitiveListener() evt = listener.handle_mqtt_message( diff --git a/python/wifi_densepose/client/ha.py b/python/wifi_densepose/client/ha.py index e1f6f5648..0968861c9 100644 --- a/python/wifi_densepose/client/ha.py +++ b/python/wifi_densepose/client/ha.py @@ -40,7 +40,9 @@ from typing import Any, Iterable _DISCOVERY_TOPIC_RE = re.compile( r"^homeassistant/" r"(?P[A-Za-z_]+)/" - r"wifi_densepose_(?P[A-Za-z0-9]+)/" + # Node ids default to the server's MQTT client id (`wifi-densepose-`, + # plus `-node` per node), so accept `-`, `_` and `.` as well. + r"wifi_densepose_(?P[A-Za-z0-9_.\-]+)/" r"(?P[A-Za-z0-9_\-]+)/" r"config$" ) @@ -143,9 +145,10 @@ class HABlueprintHelper: structured queries against them. Intended use: subscribe a RuViewMqttClient to - `homeassistant/+/wifi_densepose_+/+/config`, feed every message - into `add_payload()`, then ask the helper "what entities does - node X expose?" or "what binary_sensors are presence-class?". + `homeassistant/+/+/+/config`, feed every message into + `add_payload()` (it ignores non-RuView topics), then ask the helper + "what entities does node X expose?" or "what binary_sensors are + presence-class?". """ def __init__(self) -> None: diff --git a/python/wifi_densepose/client/primitives.py b/python/wifi_densepose/client/primitives.py index daa75d606..4c0aa68cf 100644 --- a/python/wifi_densepose/client/primitives.py +++ b/python/wifi_densepose/client/primitives.py @@ -13,12 +13,12 @@ The 10 v1 primitives (ADR-115 §3.12.1): | `SomeoneSleeping` | `someone_sleeping` | binary_sensor | | `PossibleDistress` | `possible_distress` | binary_sensor + event | | `RoomActive` | `room_active` | binary_sensor | -| `ElderlyInactivityAnomaly` | `elderly_inactivity` | binary_sensor + event | +| `ElderlyInactivityAnomaly` | `elderly_inactivity_anomaly` | binary_sensor + event | | `MeetingInProgress` | `meeting_in_progress` | binary_sensor | | `BathroomOccupied` | `bathroom_occupied` | binary_sensor | | `FallRiskElevated` | `fall_risk_elevated` | sensor (0–100) + event | | `BedExit` | `bed_exit` | event | -| `NoMovementSafety` | `no_movement_safety` | binary_sensor + event | +| `NoMovementSafety` | `no_movement` | binary_sensor + event | | `MultiRoomTransition` | `multi_room_transition` | event | """ @@ -38,12 +38,12 @@ class SemanticPrimitive(enum.Enum): SomeoneSleeping = "someone_sleeping" PossibleDistress = "possible_distress" RoomActive = "room_active" - ElderlyInactivityAnomaly = "elderly_inactivity" + ElderlyInactivityAnomaly = "elderly_inactivity_anomaly" MeetingInProgress = "meeting_in_progress" BathroomOccupied = "bathroom_occupied" FallRiskElevated = "fall_risk_elevated" BedExit = "bed_exit" - NoMovementSafety = "no_movement_safety" + NoMovementSafety = "no_movement" MultiRoomTransition = "multi_room_transition" @classmethod @@ -96,7 +96,8 @@ class SemanticPrimitiveListener: client = RuViewMqttClient() client.on_message( - "homeassistant/+/wifi_densepose_+/+/state", + # MQTT `+` must fill a whole topic level; the listener skips non-RuView topics. + "homeassistant/+/+/+/state", listener.handle_mqtt_message, ) client.start() @@ -214,7 +215,8 @@ def _decode_event( return SemanticPrimitiveEvent( kind=primitive, node_id=node_id, - state=str(body.get("state", "")), + # Score sensors (fall_risk_elevated) publish `{"score": ..., "ts": ...}`. + state=str(body.get("state", body.get("score", ""))), confidence=float(body.get("confidence", 0.0)), explanation=expl_tuple, timestamp=float(body.get("timestamp", 0.0)), diff --git a/scripts/align-ground-truth.js b/scripts/align-ground-truth.js index ad8e06af1..a083030df 100644 --- a/scripts/align-ground-truth.js +++ b/scripts/align-ground-truth.js @@ -68,11 +68,22 @@ Options: process.exit(args.help ? 0 : 1); } -const WINDOW_FRAMES = parseInt(args['window-frames'], 10); -const WINDOW_MS = parseInt(args['window-ms'], 10); -const MIN_CAMERA_FRAMES = parseInt(args['min-camera-frames'], 10); -const MIN_CONFIDENCE = parseFloat(args['min-confidence']); -const CLOCK_OFFSET_MS = parseFloat(args['clock-offset-ms']); +function numericOption(name, accepts, requirement) { + const raw = args[name]; + const value = raw.trim() === '' ? NaN : Number(raw); + if (!Number.isFinite(value) || !accepts(value)) { + console.error(`ERROR: --${name} must be ${requirement}; received ${JSON.stringify(raw)}.`); + process.exit(2); + } + return value; +} + +const positiveInteger = value => Number.isSafeInteger(value) && value > 0; +const WINDOW_FRAMES = numericOption('window-frames', positiveInteger, 'a positive integer'); +const WINDOW_MS = numericOption('window-ms', value => value >= 0, 'a finite nonnegative number'); +const MIN_CAMERA_FRAMES = numericOption('min-camera-frames', positiveInteger, 'a positive integer'); +const MIN_CONFIDENCE = numericOption('min-confidence', value => value >= 0 && value <= 1, 'a finite number from 0 to 1'); +const CLOCK_OFFSET_MS = numericOption('clock-offset-ms', () => true, 'a finite number'); const NUM_KEYPOINTS = 17; // COCO 17-keypoint format // --------------------------------------------------------------------------- diff --git a/scripts/collect-training-data.py b/scripts/collect-training-data.py index 475bddefb..fad04f2a8 100644 --- a/scripts/collect-training-data.py +++ b/scripts/collect-training-data.py @@ -192,7 +192,26 @@ class CsiRecorder: self._file = None def open(self): - self._file = open(self.file_path, "a", encoding="utf-8") + # Whole-second names can collide between quick or concurrent captures. + # Allocate exclusively so one session never appends to another's data. + base_id = self.session_id + suffix = 0 + while True: + session_id = base_id if suffix == 0 else f"{base_id}-{suffix}" + file_path = self.output_dir / f"{session_id}.csi.jsonl" + meta_path = self.output_dir / f"{session_id}.csi.meta.json" + if meta_path.exists(): + suffix += 1 + continue + try: + self._file = open(file_path, "x", encoding="utf-8") + except FileExistsError: + suffix += 1 + continue + self.session_id = session_id + self.file_path = file_path + self.meta_path = meta_path + break log.info(f"Recording to: {self.file_path}") def write_frame(self, frame: dict): diff --git a/scripts/csi-udp-relay.py b/scripts/csi-udp-relay.py index 0c481749e..2b9c493b0 100644 --- a/scripts/csi-udp-relay.py +++ b/scripts/csi-udp-relay.py @@ -44,10 +44,10 @@ def main() -> None: try: while True: try: - data, src = rx.recvfrom(2048) + data, src = rx.recvfrom(65535) except socket.timeout: data = None - if data: + if data is not None: tx.sendto(data, dst) count += 1 last_src = src diff --git a/scripts/deep-scan.js b/scripts/deep-scan.js index ebff42fab..87617d741 100644 --- a/scripts/deep-scan.js +++ b/scripts/deep-scan.js @@ -41,7 +41,7 @@ server.on('message', (buf, rinfo) => { amps.push(Math.sqrt(I * I + Q * Q)); } if (!raw[nid]) raw[nid] = []; - raw[nid].push({ time: Date.now(), amps, rssi: buf.readInt8(5), nSub }); + raw[nid].push({ time: Date.now(), amps, rssi: buf.readInt8(16), nSub }); } else if (magic === 0xC5110002 && buf.length >= 32) { const br = buf.readUInt16LE(6) / 100; const hr = buf.readUInt32LE(8) / 10000; diff --git a/scripts/esp32_jsonl_to_rvcsi.py b/scripts/esp32_jsonl_to_rvcsi.py index 759955f57..23392cccd 100644 --- a/scripts/esp32_jsonl_to_rvcsi.py +++ b/scripts/esp32_jsonl_to_rvcsi.py @@ -11,6 +11,7 @@ Usage: esp32_jsonl_to_rvcsi.py [--limit N] """ import json import math +import os import sys # --- rvcsi_core::ValidationPolicy::default() ------------------------------- @@ -60,6 +61,10 @@ def main(): print(__doc__) sys.exit(2) in_path, out_path = sys.argv[1], sys.argv[2] + # Check file identity before opening output: aliases can share one inode. + if os.path.exists(out_path) and os.path.samefile(in_path, out_path): + print("Input and output must refer to different files.", file=sys.stderr) + sys.exit(2) limit = None if "--limit" in sys.argv: limit = int(sys.argv[sys.argv.index("--limit") + 1]) @@ -116,7 +121,15 @@ def main(): stats["read"] -= 1 break iq_hex = d.get("iq_hex", "") - raw = bytes.fromhex(iq_hex) + try: + raw = bytes.fromhex(iq_hex) + except (TypeError, ValueError): + stats["rej_len"] += 1 + continue + # A partial I/Q pair cannot be reconstructed from this recording. + if len(raw) % 2: + stats["rej_len"] += 1 + continue n_pairs = len(raw) // 2 # ESP-IDF CSI buffer layout: [imag0, real0, imag1, real1, ...] as int8 i_vals, q_vals, amp, ph = [], [], [], [] diff --git a/scripts/overnight-empty-capture.py b/scripts/overnight-empty-capture.py index 0eb8b9995..707341778 100644 --- a/scripts/overnight-empty-capture.py +++ b/scripts/overnight-empty-capture.py @@ -12,33 +12,19 @@ import argparse import json import os import socket -import struct +import importlib.util +from pathlib import Path import time -def parse_csi_packet(data): - """ADR-018 binary CSI packet → dict (same layout as record-csi-udp.py).""" - if len(data) < 8: - return None - node_id = data[4] - rssi = struct.unpack("b", bytes([data[6]]))[0] - channel = data[7] - iq = data[8:] - amplitudes = [] - for i in range(0, len(iq) - 1, 2): - I = struct.unpack("b", bytes([iq[i]]))[0] - Q = struct.unpack("b", bytes([iq[i + 1]]))[0] - amplitudes.append(round((I * I + Q * Q) ** 0.5, 2)) - return { - "type": "raw_csi", - "ts_ns": time.time_ns(), - "node_id": node_id, - "rssi": rssi, - "channel": channel, - "subcarriers": len(iq) // 2, - "amplitudes": amplitudes, - "iq_hex": iq.hex(), - } +# Share the reviewed ADR-018 decoder with the ordinary UDP recorder so segment +# capture cannot drift to an older header layout. +_parser_spec = importlib.util.spec_from_file_location( + "ruview_record_csi_udp", Path(__file__).with_name("record-csi-udp.py") +) +_parser_module = importlib.util.module_from_spec(_parser_spec) +_parser_spec.loader.exec_module(_parser_module) +parse_csi_packet = _parser_module.parse_csi_packet def main(): diff --git a/scripts/rf-scan-multifreq.js b/scripts/rf-scan-multifreq.js index b2e3093b2..1f24ac0bb 100644 --- a/scripts/rf-scan-multifreq.js +++ b/scripts/rf-scan-multifreq.js @@ -305,7 +305,7 @@ function parseFeaturePacket(buf) { if (magic !== FEATURE_MAGIC) return null; const features = []; - for (let i = 0; i < 8; i++) features.push(buf.readFloatLE(12 + i * 4)); + for (let i = 0; i < 8; i++) features.push(buf.readFloatLE(16 + i * 4)); return { nodeId: buf.readUInt8(4), seq: buf.readUInt16LE(6), features }; } diff --git a/scripts/rf-scan.js b/scripts/rf-scan.js index 139fa1696..a3e61db47 100644 --- a/scripts/rf-scan.js +++ b/scripts/rf-scan.js @@ -290,7 +290,7 @@ function parseFeaturePacket(buf) { const seq = buf.readUInt16LE(6); const features = []; for (let i = 0; i < 8; i++) { - features.push(buf.readFloatLE(12 + i * 4)); + features.push(buf.readFloatLE(16 + i * 4)); } return { nodeId, seq, features }; diff --git a/scripts/room-fingerprint.js b/scripts/room-fingerprint.js index 097a6e0c3..8fbb7a479 100644 --- a/scripts/room-fingerprint.js +++ b/scripts/room-fingerprint.js @@ -298,7 +298,7 @@ function parseFeatureUdp(buf) { const nodeId = buf.readUInt8(4); const features = []; for (let i = 0; i < 8; i++) { - features.push(buf.readFloatLE(12 + i * 4)); + features.push(buf.readFloatLE(16 + i * 4)); } return { timestamp: Date.now() / 1000, nodeId, features }; } diff --git a/v2/crates/wifi-densepose-mtk-bridge/src/main.rs b/v2/crates/wifi-densepose-mtk-bridge/src/main.rs index 2d6549050..380906cf1 100644 --- a/v2/crates/wifi-densepose-mtk-bridge/src/main.rs +++ b/v2/crates/wifi-densepose-mtk-bridge/src/main.rs @@ -307,8 +307,10 @@ fn listen( .map_err(BridgeError::Io)?; eprintln!("registered with CSIdump server at {server}"); } + // Report the bound address, not `bind`, so `--listen :0` names its port. eprintln!( - "listening for MtkCSIdump datagrams on {bind}, sources limited to {}", + "listening for MtkCSIdump datagrams on {}, sources limited to {}", + socket.local_addr().map_err(BridgeError::Io)?, args.listen_allow.join(",") ); diff --git a/v2/crates/wifi-densepose-mtk-bridge/tests/listen_gate.rs b/v2/crates/wifi-densepose-mtk-bridge/tests/listen_gate.rs index 0822a3c5f..0b32e1b57 100644 --- a/v2/crates/wifi-densepose-mtk-bridge/tests/listen_gate.rs +++ b/v2/crates/wifi-densepose-mtk-bridge/tests/listen_gate.rs @@ -3,19 +3,15 @@ //! `--listen` is the only route to a `mediatek:physical-unvalidated` label, so //! it must not accept datagrams from sources the operator did not name. -use std::io::Read; +use std::io::{BufRead, BufReader, Read}; use std::net::UdpSocket; use std::path::PathBuf; -use std::process::{Command, Stdio}; +use std::process::{Child, ChildStderr, Command, Stdio}; +use std::sync::mpsc; use std::time::Duration; fn bridge_bin() -> PathBuf { - let mut path = std::env::current_exe().expect("test exe path"); - path.pop(); - if path.ends_with("deps") { - path.pop(); - } - path.join("wifi-densepose-mtk-bridge") + PathBuf::from(env!("CARGO_BIN_EXE_wifi-densepose-mtk-bridge")) } /// One MtkCSIdump datagram: 20-byte packed header then `{f64 i, f64 q}` pairs. @@ -32,12 +28,53 @@ fn datagram(antenna: u32) -> Vec { buf } -fn free_port() -> u16 { - UdpSocket::bind("127.0.0.1:0") - .unwrap() - .local_addr() - .unwrap() - .port() +/// Start the bridge on an OS-assigned loopback port and wait for it to report +/// the bound address. Picking a free port first and passing it in races any +/// other socket that takes the port before the bridge binds it. +fn spawn_listening(args: &[&str]) -> (Child, BufReader, String, String) { + let mut child = Command::new(bridge_bin()) + .args(["--listen", "127.0.0.1:0"]) + .args(args) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn wifi-densepose-mtk-bridge"); + let mut stderr = BufReader::new(child.stderr.take().unwrap()); + let (send, receive) = mpsc::sync_channel(1); + let reader = std::thread::spawn(move || { + let mut seen = String::new(); + loop { + let mut line = String::new(); + match stderr.read_line(&mut line) { + Ok(0) => { + let _ = send.send(Err(format!("bridge exited before listening: {seen}"))); + return; + } + Err(error) => { + let _ = send.send(Err(format!("read bridge stderr: {error}"))); + return; + } + Ok(_) => {} + } + seen.push_str(&line); + if let Some(rest) = line.strip_prefix("listening for MtkCSIdump datagrams on ") { + let addr = rest.split(',').next().unwrap().to_string(); + let _ = send.send(Ok((stderr, addr, seen))); + return; + } + } + }); + match receive.recv_timeout(Duration::from_secs(10)) { + Ok(Ok((stderr, addr, seen))) => { + reader.join().expect("stderr reader panicked"); + (child, stderr, addr, seen) + } + error => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + panic!("bridge did not become ready within ten seconds: {error:?}"); + } + } } #[test] @@ -70,29 +107,21 @@ fn a_datagram_from_outside_the_allowlist_is_dropped_and_never_assembled() { sink.set_read_timeout(Some(Duration::from_millis(700))) .unwrap(); let sink_addr = sink.local_addr().unwrap().to_string(); - let listen_port = free_port(); // Allow only a network the loopback sender is not in. - let mut child = Command::new(bridge_bin()) - .args([ - "--listen", - &format!("127.0.0.1:{listen_port}"), - "--listen-allow", - "10.99.0.0/16", - "--sink", - &sink_addr, - "--node", - "listen-gate", - ]) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn wifi-densepose-mtk-bridge"); + let (mut child, mut child_stderr, listen_addr, mut stderr) = spawn_listening(&[ + "--listen-allow", + "10.99.0.0/16", + "--sink", + &sink_addr, + "--node", + "listen-gate", + ]); - std::thread::sleep(Duration::from_millis(500)); let sender = UdpSocket::bind("127.0.0.1:0").unwrap(); for antenna in 0..4u32 { sender - .send_to(&datagram(antenna % 2), format!("127.0.0.1:{listen_port}")) + .send_to(&datagram(antenna % 2), &listen_addr) .unwrap(); } std::thread::sleep(Duration::from_millis(500)); @@ -105,13 +134,7 @@ fn a_datagram_from_outside_the_allowlist_is_dropped_and_never_assembled() { ); child.kill().expect("kill bridge"); - let mut stderr = String::new(); - child - .stderr - .take() - .unwrap() - .read_to_string(&mut stderr) - .unwrap(); + child_stderr.read_to_string(&mut stderr).unwrap(); let _ = child.wait(); assert!(stderr.contains("rejected_source"), "{stderr}"); @@ -127,32 +150,22 @@ fn a_datagram_from_inside_the_allowlist_is_admitted() { let sink = UdpSocket::bind("127.0.0.1:0").unwrap(); sink.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); let sink_addr = sink.local_addr().unwrap().to_string(); - let listen_port = free_port(); // Same setup, but loopback is named this time. - let mut child = Command::new(bridge_bin()) - .args([ - "--listen", - &format!("127.0.0.1:{listen_port}"), - "--listen-allow", - "127.0.0.1", - "--sink", - &sink_addr, - "--node", - "listen-gate", - "--max-frames", - "1", - ]) - .stderr(Stdio::piped()) - .spawn() - .expect("spawn wifi-densepose-mtk-bridge"); + let (mut child, _stderr, listen_addr, _) = spawn_listening(&[ + "--listen-allow", + "127.0.0.1", + "--sink", + &sink_addr, + "--node", + "listen-gate", + "--max-frames", + "1", + ]); - std::thread::sleep(Duration::from_millis(500)); let sender = UdpSocket::bind("127.0.0.1:0").unwrap(); for antenna in 0..2u32 { - sender - .send_to(&datagram(antenna), format!("127.0.0.1:{listen_port}")) - .unwrap(); + sender.send_to(&datagram(antenna), &listen_addr).unwrap(); } let mut buf = vec![0u8; 65_535]; diff --git a/v2/crates/wifi-densepose-sensing-server/README.md b/v2/crates/wifi-densepose-sensing-server/README.md index 386dabca2..76aad8579 100644 --- a/v2/crates/wifi-densepose-sensing-server/README.md +++ b/v2/crates/wifi-densepose-sensing-server/README.md @@ -69,11 +69,11 @@ cargo build -p wifi-densepose-sensing-server # Run with default settings (HTTP :8080, UDP :5005, WS :8765) cargo run -p wifi-densepose-sensing-server -# Run with custom ports +# Run with custom ports (from v2/, where ../ui is the repo's UI) cargo run -p wifi-densepose-sensing-server -- \ --http-port 9000 \ --udp-port 5005 \ - --static-dir ./ui + --ui-path ../ui ``` ### Empty-room startup baseline @@ -101,15 +101,15 @@ use wifi_densepose_sensing_server::vital_signs::VitalSignDetector; // Create a detector with 20 Hz sample rate let mut detector = VitalSignDetector::new(20.0); -// Feed CSI amplitude samples -for amplitude in csi_amplitudes.iter() { - detector.push_sample(*amplitude); -} - -// Extract vital signs -if let Some(vitals) = detector.detect() { - println!("Breathing: {:.1} BPM", vitals.breathing_rate_bpm); - println!("Heart rate: {:.0} BPM", vitals.heart_rate_bpm); +// Feed one CSI frame at a time: per-subcarrier amplitude and phase +for (amplitude, phase) in csi_frames.iter() { + let vitals = detector.process_frame(amplitude, phase); + if let Some(bpm) = vitals.breathing_rate_bpm { + println!("Breathing: {bpm:.1} BPM"); + } + if let Some(bpm) = vitals.heart_rate_bpm { + println!("Heart rate: {bpm:.0} BPM"); + } } ``` From 720cfd7c8ae0f197ffa37873fe962cbb7945edf3 Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 12 Oct 2026 11:56:13 -0400 Subject: [PATCH 14/23] fix(remote): replace explicit Windows ACL grants with current-user access --- harness/ruview/.harness/manifest.json | 4 +-- harness/ruview/.harness/manifest.sha256 | 2 +- harness/ruview/src/remote.js | 29 ++++++++++++++++--- harness/ruview/test/remote.test.mjs | 38 +++++++++++++++++++++---- 4 files changed, 61 insertions(+), 12 deletions(-) diff --git a/harness/ruview/.harness/manifest.json b/harness/ruview/.harness/manifest.json index 84d7c157a..590a08714 100644 --- a/harness/ruview/.harness/manifest.json +++ b/harness/ruview/.harness/manifest.json @@ -66,7 +66,7 @@ "src/policy.js": "baad5eb8c3eb2e416e0a71b342ad850d7c8413d7d976571ebf596937f69da8e2", "src/process-runner.js": "49533b038044dfb8bc76ed01c030d06a9856ead0836157fb693e2a7d40f786d6", "src/redact.js": "ebf1afff46341078706b0401838c53db043603586e280d51ece5cf1feba35189", - "src/remote.js": "ca510804176205eca799b396c0f0bca72df4c08139547c66483885d38fed6815", + "src/remote.js": "68f901411063fd515a52dad41ffd5c599765a53de34a080715232825eaf207b3", "src/repo-trust.js": "06e2a94d7113ed936f208a12b7fcc785801c215a3e2c5e7418f6238d991a289c", "src/sdk.d.ts": "d4c80689248c197b2ddb1ebf4cd7d8f2e55716722074d9e1cdb8029b2dfa19f7", "src/sdk.js": "1ac29fb389ed86e0f62ecfddbbd31614c58e832a8181495caa83fe5cf8b30bc4", @@ -75,7 +75,7 @@ "src/training.js": "bd8a6c12eba4f9d2f8990fa9692cd04c743ea247f9b13e10f8717ceefcbd09ac", "src/ui/console-widget.js": "0d2f707d57b5ec54968dee1dc80f31c7db9152dcadfc48700c18379dabb55462" }, - "filesDigest": "52d75ef1509cb993b96291fd28836baeb284a05a99ae1e7fac6a1356f9d80b7a", + "filesDigest": "f609508982d1fdc67fa7b90e1286e25945f920ba45e8fde95f76234848cf3be9", "brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd", "developmentPins": { diff --git a/harness/ruview/.harness/manifest.sha256 b/harness/ruview/.harness/manifest.sha256 index 5cc32a1c3..6050050c5 100644 --- a/harness/ruview/.harness/manifest.sha256 +++ b/harness/ruview/.harness/manifest.sha256 @@ -1 +1 @@ -c76cd7d8c4e204d8b8ba2fa9fdc787fcaa6adcea7fb6f0c62328ce87d01ab390 manifest.json +4c69daba76af8460fe5101da07aeab58cd84376ca642280e038f52cf3c4676d6 manifest.json diff --git a/harness/ruview/src/remote.js b/harness/ruview/src/remote.js index 703396478..db0b7b659 100644 --- a/harness/ruview/src/remote.js +++ b/harness/ruview/src/remote.js @@ -9,7 +9,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { homedir, userInfo } from 'node:os'; +import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; const NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/; @@ -36,16 +36,37 @@ export function loadHosts(path = hostsFile()) { return { path, hosts: (data.hosts || []).map(validateHost) }; } +// A fixed program receives the path as JSON on stdin, never PowerShell source. +// Construct a fresh protected DACL: icacls /inheritance:r /grant:r leaves other +// explicit grants intact (including grants on hosted Windows temporary files). +const OWNER_ONLY_ACL = [ + "$ErrorActionPreference = 'Stop'", + '[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)', + '$request = [Console]::In.ReadToEnd() | ConvertFrom-Json', + '$sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User', + '$acl = [System.Security.AccessControl.FileSecurity]::new()', + '$acl.SetOwner($sid)', + '$acl.SetAccessRuleProtection($true, $false)', + '$rule = [System.Security.AccessControl.FileSystemAccessRule]::new($sid, [System.Security.AccessControl.FileSystemRights]::FullControl, [System.Security.AccessControl.AccessControlType]::Allow)', + '$acl.AddAccessRule($rule)', + '[System.IO.File]::SetAccessControl([string]$request.path, $acl)', + '$applied = Get-Acl -LiteralPath $request.path', + '$rules = @($applied.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]))', + 'if (-not $applied.AreAccessRulesProtected -or $rules.Count -ne 1 -or $rules[0].IdentityReference.Value -ne $sid.Value -or $rules[0].AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow -or $rules[0].FileSystemRights -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { throw "owner-only ACL verification failed" }', +].join('\n'); + /** * Windows ignores POSIX modes, so 0o600 alone leaves the file readable through * inherited ACLs. Replace them with a single full-control grant for the owner. * Fails closed: an unrestricted hosts file is an error, not a warning. */ -export function restrictToOwner(path, { platform = process.platform, exec = spawnSync, user = () => userInfo().username } = {}) { +export function restrictToOwner(path, { platform = process.platform, exec = spawnSync } = {}) { if (platform !== 'win32') return 'posix-0600'; - const r = exec('icacls', [path, '/inheritance:r', '/grant:r', `${user()}:F`], { encoding: 'utf8', windowsHide: true }); + const r = exec('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', OWNER_ONLY_ACL], { + input: JSON.stringify({ path }), encoding: 'utf8', windowsHide: true, timeout: 10_000, maxBuffer: 16_384, + }); if (r.error || r.status !== 0) { - throw new Error(`could not restrict ${path} to the current user (icacls exit ${r.status ?? r.error?.code}); the hosts file was not made private`); + throw new Error(`could not restrict ${path} to the current user (ACL helper exit ${r.status ?? r.error?.code}); the hosts file was not made private`); } return 'windows-acl-owner-only'; } diff --git a/harness/ruview/test/remote.test.mjs b/harness/ruview/test/remote.test.mjs index 0eb08f466..bfe8f1fb8 100644 --- a/harness/ruview/test/remote.test.mjs +++ b/harness/ruview/test/remote.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; -import { tmpdir } from 'node:os'; +import { tmpdir, userInfo } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { buildSshArgs, restrictToOwner, runRemote, saveHost, shellQuote, validateHost } from '../src/remote.js'; import { isRemoteReadOnlyTool, runTool, TOOLS } from '../src/tools.js'; @@ -64,6 +64,7 @@ test('hosts file is private and remote runs are read-only, validated, and versio const grants = acl.split('\n').map((l) => l.replace(path, '').trim()).filter((l) => /:\(/.test(l)); assert.equal(grants.length, 1, acl); assert.match(grants[0], /:\(F\)$/); + assert.ok(grants[0].toLowerCase().includes(`${userInfo().username.toLowerCase()}:(F)`.toLowerCase()), acl); } else { assert.equal(saved.access, 'posix-0600'); assert.equal(statSync(path).mode & 0o777, 0o600); @@ -109,11 +110,38 @@ test('`call --read-only` refuses mutating tools (what remote hosts execute)', () assert.equal(JSON.parse(allowed.stdout).verdict, 'FAIL'); }); -test('Windows hosts-file restriction fails closed when icacls fails', () => { +test('Windows hosts-file restriction removes explicit grants and handles literal Unicode paths', { skip: process.platform !== 'win32' }, async () => { + await withHosts(async (path) => { + // Wildcards and PowerShell expressions are legal filename characters. A + // literal path on stdin must neither expand nor execute any of this text. + const literal = join(dirname(path), "hosts-é [x] $(throw 'path-executed').json"); + saveHost({ name: 'pi', ssh: 'ruv@pi.local' }, literal); + const grant = spawnSync('icacls', [literal, '/grant', '*S-1-1-0:R', '*S-1-5-18:F'], { encoding: 'utf8' }); + assert.equal(grant.status, 0, grant.stderr || grant.stdout); + const before = spawnSync('icacls', [literal], { encoding: 'utf8' }); + assert.equal(before.status, 0, before.stderr); + assert.ok(before.stdout.split('\n').filter(line => /:\(/.test(line)).length >= 3, before.stdout); + assert.equal(restrictToOwner(literal), 'windows-acl-owner-only'); + const after = spawnSync('icacls', [literal], { encoding: 'utf8' }); + assert.equal(after.status, 0, after.stderr); + const grants = after.stdout.split('\n').map(line => line.replace(literal, '').trim()).filter(line => /:\(/.test(line)); + assert.equal(grants.length, 1, after.stdout); + assert.ok(grants[0].toLowerCase().includes(`${userInfo().username}:(F)`.toLowerCase()), after.stdout); + assert.equal(JSON.parse(readFileSync(literal, 'utf8')).hosts[0].name, 'pi'); + }); +}); + +test('Windows hosts-file restriction fails closed and keeps input out of PowerShell source', () => { const calls = []; - const ok = restrictToOwner('C:/x/hosts.json', { platform: 'win32', user: () => 'alice', exec: (cmd, args) => { calls.push([cmd, ...args]); return { status: 0 }; } }); + const path = "C:/x/hosts $(throw 'injected').json"; + const ok = restrictToOwner(path, { platform: 'win32', exec: (cmd, args, options) => { calls.push({ cmd, args, options }); return { status: 0 }; } }); assert.equal(ok, 'windows-acl-owner-only'); - assert.deepEqual(calls[0], ['icacls', 'C:/x/hosts.json', '/inheritance:r', '/grant:r', 'alice:F']); - assert.throws(() => restrictToOwner('C:/x/hosts.json', { platform: 'win32', user: () => 'alice', exec: () => ({ status: 5 }) }), /not made private/); + assert.equal(calls[0].cmd, 'powershell.exe'); + assert.deepEqual(calls[0].args.slice(0, 4), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command']); + assert.ok(!calls[0].args.at(-1).includes(path)); + assert.deepEqual(JSON.parse(calls[0].options.input), { path }); + for (const result of [{ status: 5 }, { status: null, error: { code: 'ENOENT' } }, { status: null, error: { code: 'ETIMEDOUT' } }]) { + assert.throws(() => restrictToOwner(path, { platform: 'win32', exec: () => result }), /not made private/); + } assert.equal(restrictToOwner('/tmp/h.json', { platform: 'linux', exec: () => { throw new Error('must not run'); } }), 'posix-0600'); }); From 99d9851173807cca5a5a70676b335d68250a3788 Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 12 Oct 2026 12:01:15 -0400 Subject: [PATCH 15/23] test(devices): align end-to-end medians with even-sample correction --- harness/ruview/test/e2e/devices.e2e.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/harness/ruview/test/e2e/devices.e2e.mjs b/harness/ruview/test/e2e/devices.e2e.mjs index 65c0a9aa9..fba03beaf 100644 --- a/harness/ruview/test/e2e/devices.e2e.mjs +++ b/harness/ruview/test/e2e/devices.e2e.mjs @@ -75,7 +75,9 @@ test('RPLIDAR over pyserial: SCAN/STOP handshake, full revolutions and coverage' assert.ok(r.json.revolutions >= 10, `revolutions=${r.json.revolutions}`); assert.equal(r.json.angularCoverage, 1); assert.equal(r.json.resyncBytes, 0); - assert.deepEqual(r.json.rangeMm, { min: 1200, median: 1500, max: 1500 }); + // Each complete synthetic sweep has 18 points at each distance; the two + // central distances are 1200 and 1500 mm, so their median is 1350 mm. + assert.deepEqual(r.json.rangeMm, { min: 1200, median: 1350, max: 1500 }); }); }); @@ -125,7 +127,8 @@ test('iPhone LiDAR through the real relay: frames accepted, token enforced and n phone.close(); assert.equal(ok.code, 0, JSON.stringify(ok.json)); assert.ok(ok.json.frames >= 10, `frames=${ok.json.frames}`); - assert.equal(ok.json.last.medianDepthM, 1.824); + // For these 192 pixels, sorted positions 96/97 are 1823/1824 mm. + assert.equal(ok.json.last.medianDepthM, 1.8235); assert.ok(!JSON.stringify(ok.json).includes(token), 'token never echoed'); assert.equal(bad.json.reason, 'connect_failed'); } finally { From b265454b938ba3b640fa203159b3a86133515ae7 Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 12 Oct 2026 12:09:26 -0400 Subject: [PATCH 16/23] fix(remote): isolate Windows PowerShell module search paths --- harness/ruview/.harness/manifest.json | 4 +-- harness/ruview/.harness/manifest.sha256 | 2 +- harness/ruview/src/remote.js | 8 ++++-- harness/ruview/test/remote.test.mjs | 38 +++++++++++++++++++++++-- 4 files changed, 45 insertions(+), 7 deletions(-) diff --git a/harness/ruview/.harness/manifest.json b/harness/ruview/.harness/manifest.json index 590a08714..1350e0f9c 100644 --- a/harness/ruview/.harness/manifest.json +++ b/harness/ruview/.harness/manifest.json @@ -66,7 +66,7 @@ "src/policy.js": "baad5eb8c3eb2e416e0a71b342ad850d7c8413d7d976571ebf596937f69da8e2", "src/process-runner.js": "49533b038044dfb8bc76ed01c030d06a9856ead0836157fb693e2a7d40f786d6", "src/redact.js": "ebf1afff46341078706b0401838c53db043603586e280d51ece5cf1feba35189", - "src/remote.js": "68f901411063fd515a52dad41ffd5c599765a53de34a080715232825eaf207b3", + "src/remote.js": "ea433cf719729963a50ebede9442136e72f1eff145c0b3c5e837c6c56fa1ccdf", "src/repo-trust.js": "06e2a94d7113ed936f208a12b7fcc785801c215a3e2c5e7418f6238d991a289c", "src/sdk.d.ts": "d4c80689248c197b2ddb1ebf4cd7d8f2e55716722074d9e1cdb8029b2dfa19f7", "src/sdk.js": "1ac29fb389ed86e0f62ecfddbbd31614c58e832a8181495caa83fe5cf8b30bc4", @@ -75,7 +75,7 @@ "src/training.js": "bd8a6c12eba4f9d2f8990fa9692cd04c743ea247f9b13e10f8717ceefcbd09ac", "src/ui/console-widget.js": "0d2f707d57b5ec54968dee1dc80f31c7db9152dcadfc48700c18379dabb55462" }, - "filesDigest": "f609508982d1fdc67fa7b90e1286e25945f920ba45e8fde95f76234848cf3be9", + "filesDigest": "edc6be28115ad21f18954066e989a6b4aaae2137b2f4e6d6571312beef610288", "brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4", "gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd", "developmentPins": { diff --git a/harness/ruview/.harness/manifest.sha256 b/harness/ruview/.harness/manifest.sha256 index 6050050c5..054df24f9 100644 --- a/harness/ruview/.harness/manifest.sha256 +++ b/harness/ruview/.harness/manifest.sha256 @@ -1 +1 @@ -4c69daba76af8460fe5101da07aeab58cd84376ca642280e038f52cf3c4676d6 manifest.json +0517e22827b8acc7d8550e1e560e4f13ca79397977c52c30cf23770dead2327a manifest.json diff --git a/harness/ruview/src/remote.js b/harness/ruview/src/remote.js index db0b7b659..b3c759ef9 100644 --- a/harness/ruview/src/remote.js +++ b/harness/ruview/src/remote.js @@ -60,10 +60,14 @@ const OWNER_ONLY_ACL = [ * inherited ACLs. Replace them with a single full-control grant for the owner. * Fails closed: an unrestricted hosts file is an error, not a warning. */ -export function restrictToOwner(path, { platform = process.platform, exec = spawnSync } = {}) { +export function restrictToOwner(path, { platform = process.platform, exec = spawnSync, env = process.env } = {}) { if (platform !== 'win32') return 'posix-0600'; + // pwsh -> node -> powershell.exe otherwise inherits PS7's incompatible + // module paths. Let Windows PowerShell construct its own defaults without + // changing the caller's environment or execution policy (about_PSModulePath). + const childEnv = Object.fromEntries(Object.entries(env).filter(([key]) => key.toUpperCase() !== 'PSMODULEPATH')); const r = exec('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', OWNER_ONLY_ACL], { - input: JSON.stringify({ path }), encoding: 'utf8', windowsHide: true, timeout: 10_000, maxBuffer: 16_384, + input: JSON.stringify({ path }), env: childEnv, encoding: 'utf8', windowsHide: true, timeout: 10_000, maxBuffer: 16_384, }); if (r.error || r.status !== 0) { throw new Error(`could not restrict ${path} to the current user (ACL helper exit ${r.status ?? r.error?.code}); the hosts file was not made private`); diff --git a/harness/ruview/test/remote.test.mjs b/harness/ruview/test/remote.test.mjs index bfe8f1fb8..5486190c5 100644 --- a/harness/ruview/test/remote.test.mjs +++ b/harness/ruview/test/remote.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { tmpdir, userInfo } from 'node:os'; import { dirname, join, resolve } from 'node:path'; @@ -131,15 +131,49 @@ test('Windows hosts-file restriction removes explicit grants and handles literal }); }); +test('Windows ACL helper isolates incompatible inherited PowerShell modules', { skip: process.platform !== 'win32' }, async () => { + await withHosts(async (path) => { + const modules = join(dirname(path), 'modules'); + const utility = join(modules, 'Microsoft.PowerShell.Utility'); + mkdirSync(utility, { recursive: true }); + // Reproduce pwsh7 -> node -> Windows PowerShell5 without installing pwsh: + // this higher-priority module advertises the cmdlet but requires PS7. + writeFileSync(join(utility, 'Microsoft.PowerShell.Utility.psd1'), + "@{ ModuleVersion='7.0.0'; PowerShellVersion='7.0'; CmdletsToExport=@('ConvertFrom-Json') }"); + const inherited = Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toUpperCase() !== 'PSMODULEPATH')); + inherited.PSModulePath = `${modules};${process.env.PSModulePath || ''}`; + const original = { ...inherited }; + const parentPath = process.env.PSModulePath; + const control = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', "'{}' | ConvertFrom-Json"], { + env: inherited, encoding: 'utf8', windowsHide: true, timeout: 10_000, + }); + assert.equal(control.status, 1, control.stderr); + assert.match(control.stderr, /CouldNotAutoloadMatchingModule/); + const saved = saveHost({ name: 'pi', ssh: 'ruv@pi.local' }, path, { env: inherited }); + assert.equal(saved.access, 'windows-acl-owner-only'); + assert.ok(Object.keys(inherited).length === Object.keys(original).length + && Object.entries(original).every(([key, value]) => inherited[key] === value), + 'the caller environment is unchanged'); + assert.equal(process.env.PSModulePath, parentPath, 'the process environment is unchanged'); + const acl = spawnSync('icacls', [path], { encoding: 'utf8' }); + assert.equal(acl.status, 0, acl.stderr); + const grants = acl.stdout.split('\n').filter(line => /:\(/.test(line)); + assert.equal(grants.length, 1, acl.stdout); + }); +}); + test('Windows hosts-file restriction fails closed and keeps input out of PowerShell source', () => { const calls = []; const path = "C:/x/hosts $(throw 'injected').json"; - const ok = restrictToOwner(path, { platform: 'win32', exec: (cmd, args, options) => { calls.push({ cmd, args, options }); return { status: 0 }; } }); + const env = { SystemRoot: 'C:/Windows', pSmOdUlEpAtH: 'incompatible-PS7-modules', KEEP: 'unchanged' }; + const ok = restrictToOwner(path, { platform: 'win32', env, exec: (cmd, args, options) => { calls.push({ cmd, args, options }); return { status: 0 }; } }); assert.equal(ok, 'windows-acl-owner-only'); assert.equal(calls[0].cmd, 'powershell.exe'); assert.deepEqual(calls[0].args.slice(0, 4), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command']); assert.ok(!calls[0].args.at(-1).includes(path)); assert.deepEqual(JSON.parse(calls[0].options.input), { path }); + assert.deepEqual(calls[0].options.env, { SystemRoot: 'C:/Windows', KEEP: 'unchanged' }); + assert.equal(env.pSmOdUlEpAtH, 'incompatible-PS7-modules'); for (const result of [{ status: 5 }, { status: null, error: { code: 'ENOENT' } }, { status: null, error: { code: 'ETIMEDOUT' } }]) { assert.throws(() => restrictToOwner(path, { platform: 'win32', exec: () => result }), /not made private/); } From b064acf57c0a3c2ae368e3f89f3dbb0f533bc042 Mon Sep 17 00:00:00 2001 From: ruv Date: Mon, 12 Oct 2026 12:20:40 -0400 Subject: [PATCH 17/23] test(harness): cover Node 20 WebSocket capability boundary --- harness/ruview/test/iphone-close.test.mjs | 21 ++++++++++++++++--- .../ruview/test/sdk-device-options.test.mjs | 14 ++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/harness/ruview/test/iphone-close.test.mjs b/harness/ruview/test/iphone-close.test.mjs index 4c1577c6a..520775012 100644 --- a/harness/ruview/test/iphone-close.test.mjs +++ b/harness/ruview/test/iphone-close.test.mjs @@ -4,6 +4,9 @@ import http from 'node:http'; import { createHash } from 'node:crypto'; import { readIphoneLidar } from '../src/devices/lidar.js'; +const hasNativeWebSocket = typeof globalThis.WebSocket === 'function'; +const needsWebSocket = { skip: !hasNativeWebSocket && 'requires the native WebSocket client (Node 22+)' }; + // A real owned loopback WebSocket producer: upgrade, optional depth frame, // close handshake. No injected client event callbacks or hardware. async function relay(t, { frame = true, code = 1000 } = {}) { @@ -28,7 +31,19 @@ async function relay(t, { frame = true, code = 1000 } = {}) { return `ws://127.0.0.1:${server.address().port}/ws/lidar`; } -test('public iPhone reader completes when a successful relay stream closes', async t => { +test('public iPhone reader reports an unavailable WebSocket without connecting', { skip: hasNativeWebSocket && 'native WebSocket is available' }, async t => { + const server = http.createServer(); + let connections = 0; + server.on('connection', socket => { connections++; socket.destroy(); }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + t.after(() => new Promise(r => server.close(r))); + const result = await readIphoneLidar({ url: `ws://127.0.0.1:${server.address().port}/ws/lidar`, seconds: 2 }); + assert.deepEqual(result, { ok: false, reason: 'websocket_unavailable', remedy: 'Use Node 22+ (built-in WebSocket client).' }); + await new Promise(r => setImmediate(r)); + assert.equal(connections, 0, 'unavailable transport must not open a connection'); +}); + +test('public iPhone reader completes when a successful relay stream closes', needsWebSocket, async t => { const url = await relay(t); const started = performance.now(); const result = await readIphoneLidar({ url, seconds: 2 }); @@ -38,7 +53,7 @@ test('public iPhone reader completes when a successful relay stream closes', asy assert.ok(performance.now() - started < 1000, 'closed relay must finish before the capture timeout'); }); -test('normal empty relay close completes as no_frames', async t => { +test('normal empty relay close completes as no_frames', needsWebSocket, async t => { const url = await relay(t, { frame: false }); const started = performance.now(); const result = await readIphoneLidar({ url, seconds: 2 }); @@ -47,7 +62,7 @@ test('normal empty relay close completes as no_frames', async t => { assert.ok(performance.now() - started < 1000, 'empty closed relay must finish before timeout'); }); -test('abnormal empty relay close and packet limit controls retain semantics', async t => { +test('abnormal empty relay close and packet limit controls retain semantics', needsWebSocket, async t => { const denied = await readIphoneLidar({ url: await relay(t, { frame: false, code: 1008 }), seconds: 2 }); assert.equal(denied.reason, 'relay_closed'); assert.match(denied.detail, /1008/); diff --git a/harness/ruview/test/sdk-device-options.test.mjs b/harness/ruview/test/sdk-device-options.test.mjs index 9ea461c08..787180cb7 100644 --- a/harness/ruview/test/sdk-device-options.test.mjs +++ b/harness/ruview/test/sdk-device-options.test.mjs @@ -10,6 +10,7 @@ const str = (f, s) => { const b = Buffer.from(s); return Buffer.concat([encodeVa const key = (f, n) => { const b = Buffer.alloc(4); b.writeUInt32LE(n); return Buffer.concat([encodeVarint((f << 3) | 5), b]); }; const float = (f, v) => { const b = Buffer.alloc(4); b.writeFloatLE(v); return Buffer.concat([encodeVarint((f << 3) | 5), b]); }; const pause = ms => new Promise(r => setTimeout(r, ms)); +const hasNativeWebSocket = typeof globalThis.WebSocket === 'function'; async function ownServer(t, server) { const sockets = new Set(); @@ -57,7 +58,18 @@ test('public SDK accepts spectrum options and returns real UDP amplitude rows', assert.deepEqual(result.spectrum[0].frames, [Array(8).fill(5)]); }); -test('public SDK accepts iPhone max_frames and terminates at the packet cap', async t => { +test('public SDK reports unavailable iPhone transport without connecting', { skip: hasNativeWebSocket && 'native WebSocket is available' }, async t => { + const server = http.createServer(); + let connections = 0; + server.on('connection', socket => { connections++; socket.destroy(); }); + const port = await ownServer(t, server); + const result = await createRuView().devices.lidar({ source: 'iphone', url: `ws://127.0.0.1:${port}/ws/lidar`, seconds: 2, max_frames: 1 }); + assert.deepEqual(result, { ok: false, reason: 'websocket_unavailable', remedy: 'Use Node 22+ (built-in WebSocket client).' }); + await new Promise(r => setImmediate(r)); + assert.equal(connections, 0, 'unavailable transport must not open a connection'); +}); + +test('public SDK accepts iPhone max_frames and terminates at the packet cap', { skip: !hasNativeWebSocket && 'requires the native WebSocket client (Node 22+)' }, async t => { const server = http.createServer(); server.on('upgrade', (req, sock) => { const accept = createHash('sha1').update(req.headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64'); From 5cfbb8d98d561bddb7c8918ae431407267517016 Mon Sep 17 00:00:00 2001 From: rUv Date: Mon, 12 Oct 2026 14:30:42 -0700 Subject: [PATCH 18/23] docs: restore original GIF at top of README (#2252) --- README.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 99d5a36fb..2e49ac9fc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # π RuView +

+ + RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi + +

+ [![RuView: spaces become signals, with an animated conceptual RF field](assets/readme/hero.svg)](docs/user-guide.md) @@ -15,17 +21,6 @@
-
-Watch the original RuView sensing demo - -

- - RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi - -

- -
-

RuView — WiFi becomes spatial awareness, with Ruflo coordination and a ruOS sensing workspace From 964377765a33417c8e9d45424a2c55dadeab4166 Mon Sep 17 00:00:00 2001 From: rUv Date: Mon, 12 Oct 2026 20:28:12 -0700 Subject: [PATCH 19/23] Improve README SVG spatial motion and typography (#2257) --- assets/readme/cognitum.svg | 20 +++++++++- assets/readme/feature-agents.svg | 20 +++++++++- assets/readme/feature-automation.svg | 20 +++++++++- assets/readme/feature-edge.svg | 20 +++++++++- assets/readme/feature-pose.svg | 20 +++++++++- assets/readme/feature-presence.svg | 20 +++++++++- assets/readme/feature-vitals.svg | 20 +++++++++- assets/readme/header-docs.svg | 20 +++++++++- assets/readme/header-edge.svg | 20 +++++++++- assets/readme/header-evidence.svg | 20 +++++++++- assets/readme/header-features.svg | 20 +++++++++- assets/readme/header-learning.svg | 20 +++++++++- assets/readme/hero.svg | 20 +++++++++- assets/readme/nav-agents.svg | 20 +++++++++- assets/readme/nav-hardware.svg | 20 +++++++++- assets/readme/nav-home.svg | 20 +++++++++- assets/readme/nav-models.svg | 20 +++++++++- assets/readme/nav-seed.svg | 20 +++++++++- assets/readme/nav-start.svg | 20 +++++++++- assets/readme/overview.svg | 20 +++++++++- docs/readme-visual-system.md | 17 ++++++++- scripts/render-readme-visuals.py | 55 ++++++++++++++++++++++++---- 22 files changed, 443 insertions(+), 29 deletions(-) diff --git a/assets/readme/cognitum.svg b/assets/readme/cognitum.svg index c5a7615ae..ac939dbd8 100644 --- a/assets/readme/cognitum.svg +++ b/assets/readme/cognitum.svg @@ -1 +1,19 @@ -Explore Cognitum OneAmbient Intelligence at the edge of the physical world. Open cognitum.one.COGNITUM ONEAmbient Intelligence.At the edge of the physical world.Explore the platform behind the ecosystem.Explore Cognitum +Explore Cognitum OneAmbient Intelligence at the edge of the physical world. Open cognitum.one.COGNITUM ONEAmbient Intelligence.At the edge of the physical world.Explore the platform behind the ecosystem.Explore Cognitum diff --git a/assets/readme/feature-agents.svg b/assets/readme/feature-agents.svg index 7cd6197bc..a1cc7cec7 100644 --- a/assets/readme/feature-agents.svg +++ b/assets/readme/feature-agents.svg @@ -1 +1,19 @@ -A guided AI operatorSource-cited setup and verification. CLI, MCP and agent workflows. Conceptual animation; follow the linked source for capability limits.RUVIEW / 06A guided AI operatorSource-cited setup and verification.CLI, MCP and agent workflows.TOOLKIT GUIDE +A guided AI operatorSource-cited setup and verification. CLI, MCP and agent workflows. Conceptual animation; follow the linked source for capability limits.RUVIEW / 06A guided AI operatorSource-cited setup and verification.CLI, MCP and agent workflows.TOOLKIT GUIDE diff --git a/assets/readme/feature-automation.svg b/assets/readme/feature-automation.svg index 8104d37ab..90599c6d6 100644 --- a/assets/readme/feature-automation.svg +++ b/assets/readme/feature-automation.svg @@ -1 +1,19 @@ -Local automationConnect rooms to your smart home. Home Assistant, MQTT and Matter. Conceptual animation; follow the linked source for capability limits.RUVIEW / 05Local automationConnect rooms to your smart home.Home Assistant, MQTT and Matter.INTEGRATION GUIDE +Local automationConnect rooms to your smart home. Home Assistant, MQTT and Matter. Conceptual animation; follow the linked source for capability limits.RUVIEW / 05Local automationConnect rooms to your smart home.Home Assistant, MQTT and Matter.INTEGRATION GUIDE diff --git a/assets/readme/feature-edge.svg b/assets/readme/feature-edge.svg index d3c589cc1..b0221bfbe 100644 --- a/assets/readme/feature-edge.svg +++ b/assets/readme/feature-edge.svg @@ -1 +1,19 @@ -Edge sensor meshProvision, capture and calibrate. Explore ESP32 firmware and runbooks. Conceptual animation; follow the linked source for capability limits.RUVIEW / 04Edge sensor meshProvision, capture and calibrate.Explore ESP32 firmware and runbooks.HARDWARE GUIDE +Edge sensor meshProvision, capture and calibrate. Explore ESP32 firmware and runbooks. Conceptual animation; follow the linked source for capability limits.RUVIEW / 04Edge sensor meshProvision, capture and calibrate.Explore ESP32 firmware and runbooks.HARDWARE GUIDE diff --git a/assets/readme/feature-pose.svg b/assets/readme/feature-pose.svg index eeaa9cb20..d1c606962 100644 --- a/assets/readme/feature-pose.svg +++ b/assets/readme/feature-pose.svg @@ -1 +1,19 @@ -Pose researchTrain and compare body keypoints. Live accuracy differs from benchmarks. Conceptual animation; follow the linked source for capability limits.RUVIEW / 03Pose researchTrain and compare body keypoints.Live accuracy differs from benchmarks.READ THE EVIDENCE +Pose researchTrain and compare body keypoints. Live accuracy differs from benchmarks. Conceptual animation; follow the linked source for capability limits.RUVIEW / 03Pose researchTrain and compare body keypoints.Live accuracy differs from benchmarks.READ THE EVIDENCE diff --git a/assets/readme/feature-presence.svg b/assets/readme/feature-presence.svg index 910d9d0d8..5d72b0028 100644 --- a/assets/readme/feature-presence.svg +++ b/assets/readme/feature-presence.svg @@ -1 +1,19 @@ -Presence & motionExplore changes in room occupancy. Inspect motion features from CSI. Conceptual animation; follow the linked source for capability limits.RUVIEW / 01Presence & motionExplore changes in room occupancy.Inspect motion features from CSI.SENSING GUIDE +Presence & motionExplore changes in room occupancy. Inspect motion features from CSI. Conceptual animation; follow the linked source for capability limits.RUVIEW / 01Presence & motionExplore changes in room occupancy.Inspect motion features from CSI.SENSING GUIDE diff --git a/assets/readme/feature-vitals.svg b/assets/readme/feature-vitals.svg index c2cb41c03..ff2eab093 100644 --- a/assets/readme/feature-vitals.svg +++ b/assets/readme/feature-vitals.svg @@ -1 +1,19 @@ -Breathing & vital signalsInspect phase-derived signal trends. Validate estimates on your hardware. Conceptual animation; follow the linked source for capability limits.RUVIEW / 02Breathing & vital signalsInspect phase-derived signal trends.Validate estimates on your hardware.SIGNAL PIPELINE +Breathing & vital signalsInspect phase-derived signal trends. Validate estimates on your hardware. Conceptual animation; follow the linked source for capability limits.RUVIEW / 02Breathing & vital signalsInspect phase-derived signal trends.Validate estimates on your hardware.SIGNAL PIPELINE diff --git a/assets/readme/header-docs.svg b/assets/readme/header-docs.svg index 50e9885e7..63d2989cd 100644 --- a/assets/readme/header-docs.svg +++ b/assets/readme/header-docs.svg @@ -1 +1,19 @@ -Choose your next stepAnimated section heading. GUIDES / HARDWARE / INTEGRATIONSChoose your next stepGUIDES / HARDWARE / INTEGRATIONS +Choose your next stepAnimated section heading. GUIDES / HARDWARE / INTEGRATIONSChoose your next stepGUIDES / HARDWARE / INTEGRATIONS diff --git a/assets/readme/header-edge.svg b/assets/readme/header-edge.svg index 1c989c9f9..53e4a07e2 100644 --- a/assets/readme/header-edge.svg +++ b/assets/readme/header-edge.svg @@ -1 +1,19 @@ -Intelligence at the edgeAnimated section heading. SENSORS / FIRMWARE / MODULESIntelligence at the edgeSENSORS / FIRMWARE / MODULES +Intelligence at the edgeAnimated section heading. SENSORS / FIRMWARE / MODULESIntelligence at the edgeSENSORS / FIRMWARE / MODULES diff --git a/assets/readme/header-evidence.svg b/assets/readme/header-evidence.svg index 2b8a102b0..ea4c6fc3b 100644 --- a/assets/readme/header-evidence.svg +++ b/assets/readme/header-evidence.svg @@ -1 +1,19 @@ -Evidence before accuracy claimsAnimated section heading. BENCHMARKS / REPRODUCERS / LIMITSEvidence before accuracy claimsBENCHMARKS / REPRODUCERS / LIMITS +Evidence before accuracy claimsAnimated section heading. BENCHMARKS / REPRODUCERS / LIMITSEvidence before accuracy claimsBENCHMARKS / REPRODUCERS / LIMITS diff --git a/assets/readme/header-features.svg b/assets/readme/header-features.svg index 8eb839430..f2b26dc39 100644 --- a/assets/readme/header-features.svg +++ b/assets/readme/header-features.svg @@ -1 +1,19 @@ -Explore the capabilitiesAnimated section heading. SENSING / MODELS / AUTOMATIONExplore the capabilitiesSENSING / MODELS / AUTOMATION +Explore the capabilitiesAnimated section heading. SENSING / MODELS / AUTOMATIONExplore the capabilitiesSENSING / MODELS / AUTOMATION diff --git a/assets/readme/header-learning.svg b/assets/readme/header-learning.svg index ee40ae04e..6e87da6d8 100644 --- a/assets/readme/header-learning.svg +++ b/assets/readme/header-learning.svg @@ -1 +1,19 @@ -From recordings to modelsAnimated section heading. CAPTURE / TRAIN / EVALUATEFrom recordings to modelsCAPTURE / TRAIN / EVALUATE +From recordings to modelsAnimated section heading. CAPTURE / TRAIN / EVALUATEFrom recordings to modelsCAPTURE / TRAIN / EVALUATE diff --git a/assets/readme/hero.svg b/assets/readme/hero.svg index 51e0d1d44..22e95393e 100644 --- a/assets/readme/hero.svg +++ b/assets/readme/hero.svg @@ -1 +1,19 @@ -RuView: spaces become signalsAnimated conceptual RF field and room geometry. Explore the RuView user guide. This illustration is not a sensor measurement.RUVIEW / COGNITUM ECOSYSTEMRuViewSpaces become signals.Signals become understanding.Explore RF sensing, edge inference and local automation.Explore the guideRF SENSING / EDGE MODELS / LOCAL ACTIONCONCEPTUAL RF FIELD +RuView: spaces become signalsAnimated conceptual RF field and room geometry. Explore the RuView user guide. This illustration is not a sensor measurement.RUVIEW / COGNITUM ECOSYSTEMRuViewSpaces become signals.Signals become understanding.Explore RF sensing, edge inference and local automation.Explore the guideRF SENSING / EDGE MODELS / LOCAL ACTIONCONCEPTUAL RF FIELD diff --git a/assets/readme/nav-agents.svg b/assets/readme/nav-agents.svg index 5b427af96..baee746a7 100644 --- a/assets/readme/nav-agents.svg +++ b/assets/readme/nav-agents.svg @@ -1 +1,19 @@ -AI toolkitOpen AI toolkit. Animated navigation icon.AI toolkit +AI toolkitOpen AI toolkit. Animated navigation icon.AI toolkit diff --git a/assets/readme/nav-hardware.svg b/assets/readme/nav-hardware.svg index ab83aa8ad..3dba35673 100644 --- a/assets/readme/nav-hardware.svg +++ b/assets/readme/nav-hardware.svg @@ -1 +1,19 @@ -HardwareOpen Hardware. Animated navigation icon.Hardware +HardwareOpen Hardware. Animated navigation icon.Hardware diff --git a/assets/readme/nav-home.svg b/assets/readme/nav-home.svg index 1ef980672..92d79328c 100644 --- a/assets/readme/nav-home.svg +++ b/assets/readme/nav-home.svg @@ -1 +1,19 @@ -Smart homeOpen Smart home. Animated navigation icon.Smart home +Smart homeOpen Smart home. Animated navigation icon.Smart home diff --git a/assets/readme/nav-models.svg b/assets/readme/nav-models.svg index 41f538a74..bfd2e3f63 100644 --- a/assets/readme/nav-models.svg +++ b/assets/readme/nav-models.svg @@ -1 +1,19 @@ -ModelsOpen Models. Animated navigation icon.Models +ModelsOpen Models. Animated navigation icon.Models diff --git a/assets/readme/nav-seed.svg b/assets/readme/nav-seed.svg index 0788f4381..4e665f0bc 100644 --- a/assets/readme/nav-seed.svg +++ b/assets/readme/nav-seed.svg @@ -1 +1,19 @@ -CognitumOpen Cognitum. Animated navigation icon.Cognitum +CognitumOpen Cognitum. Animated navigation icon.Cognitum diff --git a/assets/readme/nav-start.svg b/assets/readme/nav-start.svg index 2e911f115..ae9588436 100644 --- a/assets/readme/nav-start.svg +++ b/assets/readme/nav-start.svg @@ -1 +1,19 @@ -Start hereOpen Start here. Animated navigation icon.Start here +Start hereOpen Start here. Animated navigation icon.Start here diff --git a/assets/readme/overview.svg b/assets/readme/overview.svg index 27d98d2b8..b5957733f 100644 --- a/assets/readme/overview.svg +++ b/assets/readme/overview.svg @@ -1 +1,19 @@ -RuView sensing overviewConceptual sequence: capture RF, extract context, act locally. Links to architecture and user documentation.SIGNAL TO CONTEXTA sensing loop you can inspect.01Capture RFCSI from supported hardwareProvision and calibrate02Extract contextSignal processing and modelsCompare against real data03Act locallyEvents, APIs and integrationsApply rules and review outputsCONCEPTUAL PIPELINE / HARDWARE AND ENVIRONMENT VALIDATION MATTER +RuView sensing overviewConceptual sequence: capture RF, extract context, act locally. Links to architecture and user documentation.SIGNAL TO CONTEXTA sensing loop you can inspect.01Capture RFCSI from supported hardwareProvision and calibrate02Extract contextSignal processing and modelsCompare against real data03Act locallyEvents, APIs and integrationsApply rules and review outputsCONCEPTUAL PIPELINE / HARDWARE AND ENVIRONMENT VALIDATION MATTER diff --git a/docs/readme-visual-system.md b/docs/readme-visual-system.md index ad6fcd740..5a6c2f16c 100644 --- a/docs/readme-visual-system.md +++ b/docs/readme-visual-system.md @@ -1,6 +1,12 @@ # RuView README visual system -The README uses a Cognitum-inspired palette: midnight backgrounds, cyan gradients, wireframe geometry and restrained signal motion. It includes a hero, six feature cards, six navigation icons, an overview, five chapter headings and a Cognitum destination banner. +The README uses a Cognitum-inspired palette: midnight backgrounds, cyan gradients, +layered isometric geometry, moving scan planes, orbital markers and floating +icons. It includes a hero, six feature cards, six navigation icons, an overview, +five chapter headings and a Cognitum destination banner. Headlines use a heavier +Avenir Next / Segoe UI / Helvetica stack, with tighter spacing and a dedicated +system monospace stack for labels. Fonts remain local fallbacks so the images +also render without a network connection. ## Rebuild and check @@ -27,7 +33,14 @@ The committed SVGs are the deployment artifacts. The manifest in GitHub renders SVGs as images, so image-internal links are not used. Each SVG has an outer Markdown or HTML link in the README. Each card and navigation icon also has a plain text link for narrow screens and assistive technology. Source headings remain intact so existing README anchors still work. -Animation uses CSS only. All copy and essential geometry are visible without animation, JavaScript, remote fonts or remote images. `prefers-reduced-motion` disables decorative movement. The original GIF remains available in an expandable demo section; the existing ruOS promotion remains linked. +Animation uses CSS only. Depth is projected directly into SVG geometry rather +than relying on a WebGL canvas or CSS perspective. Translation is applied to +inner groups so it cannot overwrite the outer icon placement. Orbital motion +uses an explicit transform origin; scan planes sweep the wireframe volumes. +All copy and essential geometry are visible without animation, JavaScript, +remote fonts or remote images. `prefers-reduced-motion` disables decorative +movement. The original GIF remains at the top; the existing ruOS promotion +remains linked. Existing asset dimensions and README links are preserved. ## Content boundaries diff --git a/scripts/render-readme-visuals.py b/scripts/render-readme-visuals.py index e37a9c58b..16ae8b12f 100644 --- a/scripts/render-readme-visuals.py +++ b/scripts/render-readme-visuals.py @@ -7,7 +7,8 @@ from readme_visuals_xml import parse_svg ROOT=Path(__file__).resolve().parents[1];OUT=ROOT/'assets/readme';OUT.mkdir(parents=True,exist_ok=True) C='#72eee0';BLUE='#70b6ff';INK='#041117';WHITE='#ecfafb';MUTED='#9abac5' def text(x,y,value,size=16,color=MUTED,anchor='start',extra=''): - return f'{escape(value)}' + weight='font-weight="650" letter-spacing="-0.6"' if size>=25 else 'font-weight="500"' + return f'{escape(value)}' def path(d,color=C,width=1.5,extra=''): return f'' def circle(x,y,r,color=C,extra=''): @@ -42,9 +43,45 @@ def icon(kind,x,y,scale=1): s+=path('M-15 1L-5 11L17 -12',BLUE,2,extra='class="trace"') else: for j in range(3):s+=path(f'M-38 {j*14-20}L0 {j*14-38}L38 {j*14-20}L0 {j*14-2}Z',extra='class="breathe"') + base='' + base+=path('M-43 48L0 27L43 48L0 69Z','#285c68',1) + return f'{base}{s}' + +def spatial(x,y,scale=1): + """Projected geometry gives depth without scripts or CSS 3D dependencies.""" + s='' + s+='' + s+='' + for px,py in [(-40,6),(0,-15),(40,6),(-20,30),(20,30),(0,49)]: + s+=circle(px,py,2,BLUE,extra='fill="#70b6ff" class="spark"') + s+='' return f'{s}' -def frame(w,h,title,desc,body): +def _frame(w,h,title,desc,body): return f'''{escape(title)}{escape(desc)}{body}''' +MOTION='''''' + +def frame(w,h,title,desc,body): + value=_frame(w,h,title,desc,body) + return value[:value.index('')+8:] + manifest=[] def save(name,w,h,title,desc,body,target): s=frame(w,h,title,desc,body);raw=(s+'\n').encode('utf-8');parse_svg(raw);(OUT/(name+'.svg')).write_bytes(raw);manifest.append({'file':'assets/readme/'+name+'.svg','title':title,'target':target,'width':w,'height':h}) @@ -55,7 +92,11 @@ s+=text(49,419,'RF SENSING / EDGE MODELS / LOCAL ACTION',11,MUTED,extra='cla s+='' for i in range(9):s+=path(f'M{750+i*40} 282L{610+i*80} 443','#12343d',1) for y in [306,336,372,418]:s+=path(f'M680 {y}H1168','#12343d',1) -s+=path('M803 157L1008 106L1137 177L930 236ZM803 157V314L930 391L1137 324V177M930 236V391M1008 106V268L803 314M1008 268L1137 324','#4b9ba4',1.5) +s+=''+path('M803 157L1008 106L1137 177L930 236ZM803 157V314L930 391L1137 324V177M930 236V391M1008 106V268L803 314M1008 268L1137 324','#4b9ba4',1.5)+'' +s+='' +s+='' +for i in range(24): + s+=circle(849+(i%6)*44,198+(i//6)*35,1.7,BLUE,extra=f'fill="#70b6ff" class="spark" style="animation-delay:-{i%7/2}s"') s+=icon('presence',976,265,1.35)+icon('edge',780,349,.7) for i in range(4):s+=path(f'M{794+i*12} {333-i*9}Q{830+i*25} {245-i*24} {896+i*29} {207-i*3}',C,1.4,extra=f'class="flow" style="animation-delay:-{i}s" opacity="{.8-i*.12}"') for x,y in [(803,157),(1008,106),(1137,177),(930,236),(930,391),(803,314),(1137,324)]:s+=circle(x,y,3,extra='fill="#72eee0" class="breathe"') @@ -68,14 +109,14 @@ for k,label,target in nav:save('nav-'+k,180,116,label,'Open '+label+'. Animated cards=[('presence','01','Presence & motion','Explore changes in room occupancy.','Inspect motion features from CSI.','SENSING GUIDE','docs/user-guide.md'),('vitals','02','Breathing & vital signals','Inspect phase-derived signal trends.','Validate estimates on your hardware.','SIGNAL PIPELINE','v2/crates/wifi-densepose-signal/README.md'),('pose','03','Pose research','Train and compare body keypoints.','Live accuracy differs from benchmarks.','READ THE EVIDENCE','docs/benchmarks/pose-estimation-cog.md'),('edge','04','Edge sensor mesh','Provision, capture and calibrate.','Explore ESP32 firmware and runbooks.','HARDWARE GUIDE','firmware/esp32-csi-node/README.md'),('automation','05','Local automation','Connect rooms to your smart home.','Home Assistant, MQTT and Matter.','INTEGRATION GUIDE','docs/integrations/home-assistant.md'),('agents','06','A guided AI operator','Source-cited setup and verification.','CLI, MCP and agent workflows.','TOOLKIT GUIDE','harness/ruview/README.md')] for k,num,title,l1,l2,cta,target in cards: s=text(30,39,'RUVIEW / '+num,11,C,extra='class="mono"')+text(30,94,title,29,WHITE)+text(30,136,l1,16)+text(30,164,l2,16) - s+=''+icon(k,474,168,.83)+path('M30 204H550','#245360',1)+path('M30 204H550',C,1,extra='class="flow" opacity=".5"') + s+=''+spatial(480,161,.53)+icon(k,474,155,.68)+path('M30 204H550','#245360',1)+path('M30 204H550',C,1,extra='class="flow" opacity=".5"') s+=text(30,245,cta,12,C,extra='class="mono"')+path('M510 239H540M532 231L540 239L532 247',C,1.5) save('feature-'+k,580,276,title,l1+' '+l2+' Conceptual animation; follow the linked source for capability limits.',s,target) # Overview uses three clearly labelled stages rather than fabricated telemetry. s=text(36,44,'SIGNAL TO CONTEXT',12,C,extra='class="mono"')+text(36,89,'A sensing loop you can inspect.',34,WHITE) for i,(label,sub,kind,tail) in enumerate([('Capture RF','CSI from supported hardware','signal','Provision and calibrate'),('Extract context','Signal processing and models','models','Compare against real data'),('Act locally','Events, APIs and integrations','automation','Apply rules and review outputs')]): x=36+i*396;s+=f'' - s+=text(x+24,153,f'0{i+1}',12,C)+text(x+24,188,label,26,WHITE)+text(x+24,216,sub,15)+icon(kind,x+269,281,.62)+text(x+24,311,tail,13) + s+=text(x+24,153,f'0{i+1}',12,C)+text(x+24,188,label,26,WHITE)+text(x+24,216,sub,15)+spatial(x+269,278,.35)+icon(kind,x+269,270,.5)+text(x+24,311,tail,13) if i<2:s+=path(f'M{x+344} 229H{x+384}M{x+376} 221L{x+384} 229L{x+376} 237',C,2,extra='class="flow"') s+=text(36,373,'CONCEPTUAL PIPELINE / HARDWARE AND ENVIRONMENT VALIDATION MATTER',11,MUTED,extra='class="mono"') save('overview',1200,396,'RuView sensing overview','Conceptual sequence: capture RF, extract context, act locally. Links to architecture and user documentation.',s,'docs/user-guide.md') @@ -83,13 +124,13 @@ save('overview',1200,396,'RuView sensing overview','Conceptual sequence: capture headers=[('features','Explore the capabilities','SENSING / MODELS / AUTOMATION','presence','docs/user-guide.md'),('edge','Intelligence at the edge','SENSORS / FIRMWARE / MODULES','edge','firmware/esp32-csi-node/README.md'),('learning','From recordings to models','CAPTURE / TRAIN / EVALUATE','models','docs/user-guide.md'),('evidence','Evidence before accuracy claims','BENCHMARKS / REPRODUCERS / LIMITS','evidence','docs/benchmarks/pose-estimation-cog.md'),('docs','Choose your next step','GUIDES / HARDWARE / INTEGRATIONS','agents','docs/user-guide.md')] for k,title,sub,kind,target in headers: s=icon(kind,76,75,.75)+text(140,63,title,34,WHITE)+text(142,99,sub,12,C,extra='class="mono"') - for i in range(4):s+=path(f'M865 {44+i*17}Q960 {10+i*28} 1124 {52+i*17}',C,1,extra=f'class="flow" opacity="{.65-i*.1}"') + s+=spatial(1012,84,.55) s+=path('M1140 70H1160M1152 62L1160 70L1152 78',C,2) save('header-'+k,1200,148,title,'Animated section heading. '+sub,s,target) # Brand destination; original ruOS promotion remains in the README. s=text(44,42,'COGNITUM ONE',12,C,extra='class="mono"')+text(44,99,'Ambient Intelligence.',42,WHITE)+text(44,147,'At the edge of the physical world.',25,C)+text(44,190,'Explore the platform behind the ecosystem.',16) s+=''+text(65,254,'Explore Cognitum',18,INK)+path('M263 247H284M277 240L284 247L277 254',INK,2) -s+=''+icon('seed',945,150,2.4) +s+=''+spatial(945,173,1.55)+icon('seed',945,145,1.65) for j in range(3):s+=f'' save('cognitum',1200,304,'Explore Cognitum One','Ambient Intelligence at the edge of the physical world. Open cognitum.one.',s,'https://cognitum.one') (OUT/'manifest.json').write_bytes((json.dumps({'schema_version':1,'style':'Cognitum cyan on midnight','motion':'Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies','assets':manifest},indent=2)+'\n').encode('utf-8')) From 6699a2fc442f23aca5e1f5ebfba9361af9851c39 Mon Sep 17 00:00:00 2001 From: rUv Date: Mon, 12 Oct 2026 20:33:57 -0700 Subject: [PATCH 20/23] Match README spatial SVGs to RuView header palette and add RF wave motion --- README.md | 40 +++++++++--------- assets/readme/cognitum-spatial-v3.svg | 21 ++++++++++ assets/readme/feature-agents-spatial-v3.svg | 21 ++++++++++ .../readme/feature-automation-spatial-v3.svg | 21 ++++++++++ assets/readme/feature-edge-spatial-v3.svg | 21 ++++++++++ assets/readme/feature-pose-spatial-v3.svg | 21 ++++++++++ assets/readme/feature-presence-spatial-v3.svg | 21 ++++++++++ assets/readme/feature-vitals-spatial-v3.svg | 21 ++++++++++ assets/readme/header-docs-spatial-v3.svg | 21 ++++++++++ assets/readme/header-edge-spatial-v3.svg | 21 ++++++++++ assets/readme/header-evidence-spatial-v3.svg | 21 ++++++++++ assets/readme/header-features-spatial-v3.svg | 21 ++++++++++ assets/readme/header-learning-spatial-v3.svg | 21 ++++++++++ assets/readme/hero-spatial-v3.svg | 21 ++++++++++ assets/readme/manifest.json | 42 +++++++++---------- assets/readme/nav-agents-spatial-v3.svg | 21 ++++++++++ assets/readme/nav-hardware-spatial-v3.svg | 21 ++++++++++ assets/readme/nav-home-spatial-v3.svg | 21 ++++++++++ assets/readme/nav-models-spatial-v3.svg | 21 ++++++++++ assets/readme/nav-seed-spatial-v3.svg | 21 ++++++++++ assets/readme/nav-start-spatial-v3.svg | 21 ++++++++++ assets/readme/overview-spatial-v3.svg | 21 ++++++++++ docs/readme-visual-system.md | 6 ++- scripts/render-readme-visuals.py | 29 ++++++++++--- 24 files changed, 489 insertions(+), 48 deletions(-) create mode 100644 assets/readme/cognitum-spatial-v3.svg create mode 100644 assets/readme/feature-agents-spatial-v3.svg create mode 100644 assets/readme/feature-automation-spatial-v3.svg create mode 100644 assets/readme/feature-edge-spatial-v3.svg create mode 100644 assets/readme/feature-pose-spatial-v3.svg create mode 100644 assets/readme/feature-presence-spatial-v3.svg create mode 100644 assets/readme/feature-vitals-spatial-v3.svg create mode 100644 assets/readme/header-docs-spatial-v3.svg create mode 100644 assets/readme/header-edge-spatial-v3.svg create mode 100644 assets/readme/header-evidence-spatial-v3.svg create mode 100644 assets/readme/header-features-spatial-v3.svg create mode 100644 assets/readme/header-learning-spatial-v3.svg create mode 100644 assets/readme/hero-spatial-v3.svg create mode 100644 assets/readme/nav-agents-spatial-v3.svg create mode 100644 assets/readme/nav-hardware-spatial-v3.svg create mode 100644 assets/readme/nav-home-spatial-v3.svg create mode 100644 assets/readme/nav-models-spatial-v3.svg create mode 100644 assets/readme/nav-seed-spatial-v3.svg create mode 100644 assets/readme/nav-start-spatial-v3.svg create mode 100644 assets/readme/overview-spatial-v3.svg diff --git a/README.md b/README.md index 2e49ac9fc..85dc966e1 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,18 @@

-[![RuView: spaces become signals, with an animated conceptual RF field](assets/readme/hero.svg)](docs/user-guide.md) +[![RuView: spaces become signals, with an animated conceptual RF field](assets/readme/hero-spatial-v3.svg)](docs/user-guide.md) - - - + + + - - - + + +
Start here
Start here
Hardware
Hardware
Models
Models
Start here
Start here
Hardware
Hardware
Models
Models
AI toolkit
AI toolkit
Smart home
Smart home
Cognitum Seed
Cognitum Seed
AI toolkit
AI toolkit
Smart home
Smart home
Cognitum Seed
Cognitum Seed
@@ -42,7 +42,7 @@ Works natively with the four major smart-home ecosystems: **[Home Assistant](doc Every WiFi router already fills your space with radio waves. When people move, breathe, or even sit still, they disturb those waves in measurable ways. RuView captures these disturbances using Channel State Information (CSI) from low-cost ESP32 sensors and turns them into actionable data: who's there, what they're doing, and whether they're okay. -[![Explore RuView sensing, models and automation](assets/readme/header-features.svg)](docs/user-guide.md) +[![Explore RuView sensing, models and automation](assets/readme/header-features-spatial-v3.svg)](docs/user-guide.md) **What it senses:** - **Presence and occupancy** — detect people through walls, count them, track entries and exits @@ -63,16 +63,16 @@ Every WiFi router already fills your space with radio waves. When people move, b - - + + - - + + - - + +
Presence and motion: open documentation and current limitations
Presence and motion
Breathing and vital signals: open documentation and current limitations
Breathing and vital signals
Presence and motion: open documentation and current limitations
Presence and motion
Breathing and vital signals: open documentation and current limitations
Breathing and vital signals
Pose research and limitations: open documentation and current limitations
Pose research and limitations
Edge sensor mesh: open documentation and current limitations
Edge sensor mesh
Pose research and limitations: open documentation and current limitations
Pose research and limitations
Edge sensor mesh: open documentation and current limitations
Edge sensor mesh
Local automation: open documentation and current limitations
Local automation
Guided AI operator: open documentation and current limitations
Guided AI operator
Local automation: open documentation and current limitations
Local automation
Guided AI operator: open documentation and current limitations
Guided AI operator
@@ -296,7 +296,7 @@ The separate **17-keypoint pose-estimation model** is now published at [`ruvnet/ ### Results & proof -[![RuView visual guide: header-evidence](assets/readme/header-evidence.svg)](docs/benchmarks/pose-estimation-cog.md) +[![RuView visual guide: header-evidence](assets/readme/header-evidence-spatial-v3.svg)](docs/benchmarks/pose-estimation-cog.md) See the measured benchmarks, witness records, and one-command reproducibility check. @@ -352,7 +352,7 @@ number. The path to a first *reproducible* on-device baseline (PCK@20 ≥ 35%) i ## 🧩 Edge Module Catalog -[![RuView visual guide: header-edge](assets/readme/header-edge.svg)](firmware/esp32-csi-node/README.md) +[![RuView visual guide: header-edge](assets/readme/header-edge-spatial-v3.svg)](firmware/esp32-csi-node/README.md) Add signed modules for health, security, buildings, industry, research, AI, and more. @@ -528,7 +528,7 @@ Browse and install modules at [seed.cognitum.one/store](https://seed.cognitum.on ## 🔬 How It Works -[![RuView visual guide: overview](assets/readme/overview.svg)](docs/user-guide.md) +[![RuView visual guide: overview](assets/readme/overview-spatial-v3.svg)](docs/user-guide.md) WiFi routers flood every room with radio waves. When a person moves — or even breathes — those waves scatter differently. WiFi DensePose reads that scattering pattern and reconstructs what happened: @@ -654,7 +654,7 @@ These scenarios exploit WiFi's ability to penetrate solid materials — concrete ## 🧠 Self-Learning WiFi AI -[![RuView visual guide: header-learning](assets/readme/header-learning.svg)](docs/user-guide.md) +[![RuView visual guide: header-learning](assets/readme/header-learning-spatial-v3.svg)](docs/user-guide.md) Learn compact room fingerprints from raw CSI and adapt the model to each environment. @@ -785,7 +785,7 @@ For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.9.1`. The quick c ## 📖 Documentation -[![RuView visual guide: header-docs](assets/readme/header-docs.svg)](docs/user-guide.md) +[![RuView visual guide: header-docs](assets/readme/header-docs-spatial-v3.svg)](docs/user-guide.md) Start with the user, build, and calibration guides; expand for the full reference map. @@ -828,7 +828,7 @@ Start with the user, build, and calibration guides; expand for the full referenc > > Contributions and bug reports welcome at [Issues](https://github.com/ruvnet/RuView/issues). -[![Cognitum One: Ambient Intelligence at the edge of the physical world](assets/readme/cognitum.svg)](https://cognitum.one) +[![Cognitum One: Ambient Intelligence at the edge of the physical world](assets/readme/cognitum-spatial-v3.svg)](https://cognitum.one) ## 📄 License diff --git a/assets/readme/cognitum-spatial-v3.svg b/assets/readme/cognitum-spatial-v3.svg new file mode 100644 index 000000000..4de87d670 --- /dev/null +++ b/assets/readme/cognitum-spatial-v3.svg @@ -0,0 +1,21 @@ +Explore Cognitum OneAmbient Intelligence at the edge of the physical world. Open cognitum.one.COGNITUM ONEAmbient Intelligence.At the edge of the physical world.Explore the platform behind the ecosystem.Explore Cognitum diff --git a/assets/readme/feature-agents-spatial-v3.svg b/assets/readme/feature-agents-spatial-v3.svg new file mode 100644 index 000000000..648552f9d --- /dev/null +++ b/assets/readme/feature-agents-spatial-v3.svg @@ -0,0 +1,21 @@ +A guided AI operatorSource-cited setup and verification. CLI, MCP and agent workflows. Conceptual animation; follow the linked source for capability limits.RUVIEW / 06A guided AI operatorSource-cited setup and verification.CLI, MCP and agent workflows.TOOLKIT GUIDE diff --git a/assets/readme/feature-automation-spatial-v3.svg b/assets/readme/feature-automation-spatial-v3.svg new file mode 100644 index 000000000..d6799d208 --- /dev/null +++ b/assets/readme/feature-automation-spatial-v3.svg @@ -0,0 +1,21 @@ +Local automationConnect rooms to your smart home. Home Assistant, MQTT and Matter. Conceptual animation; follow the linked source for capability limits.RUVIEW / 05Local automationConnect rooms to your smart home.Home Assistant, MQTT and Matter.INTEGRATION GUIDE diff --git a/assets/readme/feature-edge-spatial-v3.svg b/assets/readme/feature-edge-spatial-v3.svg new file mode 100644 index 000000000..ad8d4c2a1 --- /dev/null +++ b/assets/readme/feature-edge-spatial-v3.svg @@ -0,0 +1,21 @@ +Edge sensor meshProvision, capture and calibrate. Explore ESP32 firmware and runbooks. Conceptual animation; follow the linked source for capability limits.RUVIEW / 04Edge sensor meshProvision, capture and calibrate.Explore ESP32 firmware and runbooks.HARDWARE GUIDE diff --git a/assets/readme/feature-pose-spatial-v3.svg b/assets/readme/feature-pose-spatial-v3.svg new file mode 100644 index 000000000..758dfd900 --- /dev/null +++ b/assets/readme/feature-pose-spatial-v3.svg @@ -0,0 +1,21 @@ +Pose researchTrain and compare body keypoints. Live accuracy differs from benchmarks. Conceptual animation; follow the linked source for capability limits.RUVIEW / 03Pose researchTrain and compare body keypoints.Live accuracy differs from benchmarks.READ THE EVIDENCE diff --git a/assets/readme/feature-presence-spatial-v3.svg b/assets/readme/feature-presence-spatial-v3.svg new file mode 100644 index 000000000..7bfac87aa --- /dev/null +++ b/assets/readme/feature-presence-spatial-v3.svg @@ -0,0 +1,21 @@ +Presence & motionExplore changes in room occupancy. Inspect motion features from CSI. Conceptual animation; follow the linked source for capability limits.RUVIEW / 01Presence & motionExplore changes in room occupancy.Inspect motion features from CSI.SENSING GUIDE diff --git a/assets/readme/feature-vitals-spatial-v3.svg b/assets/readme/feature-vitals-spatial-v3.svg new file mode 100644 index 000000000..b0c51082e --- /dev/null +++ b/assets/readme/feature-vitals-spatial-v3.svg @@ -0,0 +1,21 @@ +Breathing & vital signalsInspect phase-derived signal trends. Validate estimates on your hardware. Conceptual animation; follow the linked source for capability limits.RUVIEW / 02Breathing & vital signalsInspect phase-derived signal trends.Validate estimates on your hardware.SIGNAL PIPELINE diff --git a/assets/readme/header-docs-spatial-v3.svg b/assets/readme/header-docs-spatial-v3.svg new file mode 100644 index 000000000..765752a02 --- /dev/null +++ b/assets/readme/header-docs-spatial-v3.svg @@ -0,0 +1,21 @@ +Choose your next stepAnimated section heading. GUIDES / HARDWARE / INTEGRATIONSChoose your next stepGUIDES / HARDWARE / INTEGRATIONS diff --git a/assets/readme/header-edge-spatial-v3.svg b/assets/readme/header-edge-spatial-v3.svg new file mode 100644 index 000000000..5785798ce --- /dev/null +++ b/assets/readme/header-edge-spatial-v3.svg @@ -0,0 +1,21 @@ +Intelligence at the edgeAnimated section heading. SENSORS / FIRMWARE / MODULESIntelligence at the edgeSENSORS / FIRMWARE / MODULES diff --git a/assets/readme/header-evidence-spatial-v3.svg b/assets/readme/header-evidence-spatial-v3.svg new file mode 100644 index 000000000..f2a90ab13 --- /dev/null +++ b/assets/readme/header-evidence-spatial-v3.svg @@ -0,0 +1,21 @@ +Evidence before accuracy claimsAnimated section heading. BENCHMARKS / REPRODUCERS / LIMITSEvidence before accuracy claimsBENCHMARKS / REPRODUCERS / LIMITS diff --git a/assets/readme/header-features-spatial-v3.svg b/assets/readme/header-features-spatial-v3.svg new file mode 100644 index 000000000..e7122962a --- /dev/null +++ b/assets/readme/header-features-spatial-v3.svg @@ -0,0 +1,21 @@ +Explore the capabilitiesAnimated section heading. SENSING / MODELS / AUTOMATIONExplore the capabilitiesSENSING / MODELS / AUTOMATION diff --git a/assets/readme/header-learning-spatial-v3.svg b/assets/readme/header-learning-spatial-v3.svg new file mode 100644 index 000000000..f91a2201f --- /dev/null +++ b/assets/readme/header-learning-spatial-v3.svg @@ -0,0 +1,21 @@ +From recordings to modelsAnimated section heading. CAPTURE / TRAIN / EVALUATEFrom recordings to modelsCAPTURE / TRAIN / EVALUATE diff --git a/assets/readme/hero-spatial-v3.svg b/assets/readme/hero-spatial-v3.svg new file mode 100644 index 000000000..92cf8a045 --- /dev/null +++ b/assets/readme/hero-spatial-v3.svg @@ -0,0 +1,21 @@ +RuView: spaces become signalsAnimated conceptual RF field and room geometry. Explore the RuView user guide. This illustration is not a sensor measurement.RUVIEW / COGNITUM ECOSYSTEMRuViewSpaces become signals.Signals become understanding.Explore RF sensing, edge inference and local automation.Explore the guideRF SENSING / EDGE MODELS / LOCAL ACTIONCONCEPTUAL RF FIELD diff --git a/assets/readme/manifest.json b/assets/readme/manifest.json index c7a3e4c2f..2e7ef0b46 100644 --- a/assets/readme/manifest.json +++ b/assets/readme/manifest.json @@ -1,143 +1,143 @@ { "schema_version": 1, - "style": "Cognitum cyan on midnight", + "style": "RuView navy, electric blue, lime and amber", "motion": "Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies", "assets": [ { - "file": "assets/readme/hero.svg", + "file": "assets/readme/hero-spatial-v3.svg", "title": "RuView: spaces become signals", "target": "docs/user-guide.md", "width": 1200, "height": 450 }, { - "file": "assets/readme/nav-start.svg", + "file": "assets/readme/nav-start-spatial-v3.svg", "title": "Start here", "target": "docs/user-guide.md", "width": 180, "height": 116 }, { - "file": "assets/readme/nav-hardware.svg", + "file": "assets/readme/nav-hardware-spatial-v3.svg", "title": "Hardware", "target": "firmware/esp32-csi-node/README.md", "width": 180, "height": 116 }, { - "file": "assets/readme/nav-models.svg", + "file": "assets/readme/nav-models-spatial-v3.svg", "title": "Models", "target": "https://huggingface.co/ruvnet/wifi-densepose-pretrained", "width": 180, "height": 116 }, { - "file": "assets/readme/nav-agents.svg", + "file": "assets/readme/nav-agents-spatial-v3.svg", "title": "AI toolkit", "target": "harness/ruview/README.md", "width": 180, "height": 116 }, { - "file": "assets/readme/nav-home.svg", + "file": "assets/readme/nav-home-spatial-v3.svg", "title": "Smart home", "target": "docs/integrations/home-assistant.md", "width": 180, "height": 116 }, { - "file": "assets/readme/nav-seed.svg", + "file": "assets/readme/nav-seed-spatial-v3.svg", "title": "Cognitum", "target": "https://cognitum.one/seed", "width": 180, "height": 116 }, { - "file": "assets/readme/feature-presence.svg", + "file": "assets/readme/feature-presence-spatial-v3.svg", "title": "Presence & motion", "target": "docs/user-guide.md", "width": 580, "height": 276 }, { - "file": "assets/readme/feature-vitals.svg", + "file": "assets/readme/feature-vitals-spatial-v3.svg", "title": "Breathing & vital signals", "target": "v2/crates/wifi-densepose-signal/README.md", "width": 580, "height": 276 }, { - "file": "assets/readme/feature-pose.svg", + "file": "assets/readme/feature-pose-spatial-v3.svg", "title": "Pose research", "target": "docs/benchmarks/pose-estimation-cog.md", "width": 580, "height": 276 }, { - "file": "assets/readme/feature-edge.svg", + "file": "assets/readme/feature-edge-spatial-v3.svg", "title": "Edge sensor mesh", "target": "firmware/esp32-csi-node/README.md", "width": 580, "height": 276 }, { - "file": "assets/readme/feature-automation.svg", + "file": "assets/readme/feature-automation-spatial-v3.svg", "title": "Local automation", "target": "docs/integrations/home-assistant.md", "width": 580, "height": 276 }, { - "file": "assets/readme/feature-agents.svg", + "file": "assets/readme/feature-agents-spatial-v3.svg", "title": "A guided AI operator", "target": "harness/ruview/README.md", "width": 580, "height": 276 }, { - "file": "assets/readme/overview.svg", + "file": "assets/readme/overview-spatial-v3.svg", "title": "RuView sensing overview", "target": "docs/user-guide.md", "width": 1200, "height": 396 }, { - "file": "assets/readme/header-features.svg", + "file": "assets/readme/header-features-spatial-v3.svg", "title": "Explore the capabilities", "target": "docs/user-guide.md", "width": 1200, "height": 148 }, { - "file": "assets/readme/header-edge.svg", + "file": "assets/readme/header-edge-spatial-v3.svg", "title": "Intelligence at the edge", "target": "firmware/esp32-csi-node/README.md", "width": 1200, "height": 148 }, { - "file": "assets/readme/header-learning.svg", + "file": "assets/readme/header-learning-spatial-v3.svg", "title": "From recordings to models", "target": "docs/user-guide.md", "width": 1200, "height": 148 }, { - "file": "assets/readme/header-evidence.svg", + "file": "assets/readme/header-evidence-spatial-v3.svg", "title": "Evidence before accuracy claims", "target": "docs/benchmarks/pose-estimation-cog.md", "width": 1200, "height": 148 }, { - "file": "assets/readme/header-docs.svg", + "file": "assets/readme/header-docs-spatial-v3.svg", "title": "Choose your next step", "target": "docs/user-guide.md", "width": 1200, "height": 148 }, { - "file": "assets/readme/cognitum.svg", + "file": "assets/readme/cognitum-spatial-v3.svg", "title": "Explore Cognitum One", "target": "https://cognitum.one", "width": 1200, diff --git a/assets/readme/nav-agents-spatial-v3.svg b/assets/readme/nav-agents-spatial-v3.svg new file mode 100644 index 000000000..a000d5ca7 --- /dev/null +++ b/assets/readme/nav-agents-spatial-v3.svg @@ -0,0 +1,21 @@ +AI toolkitOpen AI toolkit. Animated navigation icon.AI toolkit diff --git a/assets/readme/nav-hardware-spatial-v3.svg b/assets/readme/nav-hardware-spatial-v3.svg new file mode 100644 index 000000000..8e1111bbc --- /dev/null +++ b/assets/readme/nav-hardware-spatial-v3.svg @@ -0,0 +1,21 @@ +HardwareOpen Hardware. Animated navigation icon.Hardware diff --git a/assets/readme/nav-home-spatial-v3.svg b/assets/readme/nav-home-spatial-v3.svg new file mode 100644 index 000000000..982eff662 --- /dev/null +++ b/assets/readme/nav-home-spatial-v3.svg @@ -0,0 +1,21 @@ +Smart homeOpen Smart home. Animated navigation icon.Smart home diff --git a/assets/readme/nav-models-spatial-v3.svg b/assets/readme/nav-models-spatial-v3.svg new file mode 100644 index 000000000..509177338 --- /dev/null +++ b/assets/readme/nav-models-spatial-v3.svg @@ -0,0 +1,21 @@ +ModelsOpen Models. Animated navigation icon.Models diff --git a/assets/readme/nav-seed-spatial-v3.svg b/assets/readme/nav-seed-spatial-v3.svg new file mode 100644 index 000000000..4d2645412 --- /dev/null +++ b/assets/readme/nav-seed-spatial-v3.svg @@ -0,0 +1,21 @@ +CognitumOpen Cognitum. Animated navigation icon.Cognitum diff --git a/assets/readme/nav-start-spatial-v3.svg b/assets/readme/nav-start-spatial-v3.svg new file mode 100644 index 000000000..dddb0a2e2 --- /dev/null +++ b/assets/readme/nav-start-spatial-v3.svg @@ -0,0 +1,21 @@ +Start hereOpen Start here. Animated navigation icon.Start here diff --git a/assets/readme/overview-spatial-v3.svg b/assets/readme/overview-spatial-v3.svg new file mode 100644 index 000000000..27ed568af --- /dev/null +++ b/assets/readme/overview-spatial-v3.svg @@ -0,0 +1,21 @@ +RuView sensing overviewConceptual sequence: capture RF, extract context, act locally. Links to architecture and user documentation.SIGNAL TO CONTEXTA sensing loop you can inspect.01Capture RFCSI from supported hardwareProvision and calibrate02Extract contextSignal processing and modelsCompare against real data03Act locallyEvents, APIs and integrationsApply rules and review outputsCONCEPTUAL PIPELINE / HARDWARE AND ENVIRONMENT VALIDATION MATTER diff --git a/docs/readme-visual-system.md b/docs/readme-visual-system.md index 5a6c2f16c..a4374fd66 100644 --- a/docs/readme-visual-system.md +++ b/docs/readme-visual-system.md @@ -1,6 +1,7 @@ # RuView README visual system -The README uses a Cognitum-inspired palette: midnight backgrounds, cyan gradients, +The README matches the RuView header palette: deep navy backgrounds, electric blue +RF fields, lime pose and signal accents, warm amber joints and soft white text, layered isometric geometry, moving scan planes, orbital markers and floating icons. It includes a hero, six feature cards, six navigation icons, an overview, five chapter headings and a Cognitum destination banner. Headlines use a heavier @@ -28,6 +29,9 @@ local link targets stay inside the repository, including after symlink resolutio The committed SVGs are the deployment artifacts. The manifest in `assets/readme/manifest.json` lists every destination. +Spatial artwork uses `-spatial-v3.svg` filenames so GitHub's image proxy requests +fresh content after the visual upgrade. Previous filenames remain available for +existing links; new README wrappers use the versioned assets. ## Links and rendering diff --git a/scripts/render-readme-visuals.py b/scripts/render-readme-visuals.py index 16ae8b12f..7fa6f0bb9 100644 --- a/scripts/render-readme-visuals.py +++ b/scripts/render-readme-visuals.py @@ -5,7 +5,8 @@ from html import escape import math,json from readme_visuals_xml import parse_svg ROOT=Path(__file__).resolve().parents[1];OUT=ROOT/'assets/readme';OUT.mkdir(parents=True,exist_ok=True) -C='#72eee0';BLUE='#70b6ff';INK='#041117';WHITE='#ecfafb';MUTED='#9abac5' +C='#78cfff';BLUE='#438fff';INK='#071124';WHITE='#f3f7ff';MUTED='#b0c2db' +LIME='#b8f36c';AMBER='#ffb45e' def text(x,y,value,size=16,color=MUTED,anchor='start',extra=''): weight='font-weight="650" letter-spacing="-0.6"' if size>=25 else 'font-weight="500"' return f'{escape(value)}' @@ -21,12 +22,13 @@ def icon(kind,x,y,scale=1): for r in [13,25,38]:s+=path(f'M{-r*.8} {-r*.4}Q0 {-r*1.4} {r*.8} {-r*.4}',extra=f'class="breathe" style="animation-delay:-{r/20}s"') s+=circle(0,20,4,extra='fill="#72eee0"')+path('M0 12V-1') elif kind in ['presence','pose']: - s+=circle(0,-28,8)+path('M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38',width=2) + s+=circle(0,-28,8,LIME)+path('M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38',LIME,width=2) + for px,py in [(0,-14),(0,12),(-24,-6),(24,-6)]:s+=circle(px,py,2.3,AMBER,extra=f'fill="{AMBER}" class="spark"') if kind=='pose': - for px,py in [(0,-14),(0,12),(-24,-6),(24,-6),(-18,38),(18,38)]:s+=circle(px,py,3,BLUE,extra='fill="#041117" class="breathe"') + for px,py in [(-18,38),(18,38)]:s+=circle(px,py,3,AMBER,extra='fill="#041117" class="breathe"') else:s+=f'' elif kind in ['vitals','models']: - s+=path('M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43',width=2,extra='class="trace"')+path('M-42 31H43','#214451',1) + s+=path('M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43',LIME,width=2,extra='class="trace"')+path('M-42 31H43','#214451',1) elif kind in ['edge','hardware']: s+='' s+='' @@ -55,6 +57,7 @@ def spatial(x,y,scale=1): for px,py in [(-40,6),(0,-15),(40,6),(-20,30),(20,30),(0,49)]: s+=circle(px,py,2,BLUE,extra='fill="#70b6ff" class="spark"') s+='' + s+='' return f'{s}' def _frame(w,h,title,desc,body): return f'''{escape(title)}{escape(desc)}{body}''' @@ -68,6 +71,7 @@ text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif} .scan{animation:scan 7s ease-in-out infinite} .orbit{transform-box:fill-box;transform-origin:center;animation:orbit 18s linear infinite} .spark{animation:spark 3.5s ease-in-out infinite} +.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite} @keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}} @keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}} @keyframes orbit{to{transform:rotate(360deg)}} @@ -75,15 +79,26 @@ text{font-family:"Avenir Next","Segoe UI",Helvetica,Arial,sans-serif} @keyframes flow{to{stroke-dashoffset:-136}} @keyframes breathe{50%{opacity:.4}} @keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}} +@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}} @media(prefers-reduced-motion:reduce){*{animation:none!important}} ''' def frame(w,h,title,desc,body): value=_frame(w,h,title,desc,body) - return value[:value.index('')+8:] + value=value[:value.index('')+8:] + palette={'#0b303a':'#102749','#06171e':'#071429','#030c13':'#03091a', + '#245360':'#2d4e77','#91f6df':'#9ee7ff','#26bad7':'#348bff', + '#35d4db':'#348bff','#72eee0':C,'#70b6ff':BLUE,'#041117':INK, + '#133a45':'#142c50','#285c68':'#2e527d','#0a2733':'#0b1b35', + '#286171':'#2e5b8e','#366d80':'#4473a5','#5fe3d8':'#438fff', + '#0a2630':'#0c2242','#12343d':'#162f50','#4b9ba4':'#5490c5', + '#071b23':'#091b35','#285360':'#315685','#214451':'#233f63','#3c8897':'#437cb2'} + for old,new in palette.items():value=value.replace(old,new) + return value manifest=[] def save(name,w,h,title,desc,body,target): + name+='-spatial-v3' s=frame(w,h,title,desc,body);raw=(s+'\n').encode('utf-8');parse_svg(raw);(OUT/(name+'.svg')).write_bytes(raw);manifest.append({'file':'assets/readme/'+name+'.svg','title':title,'target':target,'width':w,'height':h}) # Main title panel: a radio field passes through an illustrated room. s=text(48,44,'RUVIEW / COGNITUM ECOSYSTEM',13,C,extra='class="mono"')+text(48,121,'RuView',76,WHITE)+text(48,184,'Spaces become signals.',38,WHITE)+text(48,234,'Signals become understanding.',32,C)+text(50,279,'Explore RF sensing, edge inference and local automation.',18) @@ -95,6 +110,8 @@ for y in [306,336,372,418]:s+=path(f'M680 {y}H1168','#12343d',1) s+=''+path('M803 157L1008 106L1137 177L930 236ZM803 157V314L930 391L1137 324V177M930 236V391M1008 106V268L803 314M1008 268L1137 324','#4b9ba4',1.5)+'' s+='' s+='' +for i in range(3): + s+=f'' for i in range(24): s+=circle(849+(i%6)*44,198+(i//6)*35,1.7,BLUE,extra=f'fill="#70b6ff" class="spark" style="animation-delay:-{i%7/2}s"') s+=icon('presence',976,265,1.35)+icon('edge',780,349,.7) @@ -133,5 +150,5 @@ s+=''+ s+=''+spatial(945,173,1.55)+icon('seed',945,145,1.65) for j in range(3):s+=f'' save('cognitum',1200,304,'Explore Cognitum One','Ambient Intelligence at the edge of the physical world. Open cognitum.one.',s,'https://cognitum.one') -(OUT/'manifest.json').write_bytes((json.dumps({'schema_version':1,'style':'Cognitum cyan on midnight','motion':'Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies','assets':manifest},indent=2)+'\n').encode('utf-8')) +(OUT/'manifest.json').write_bytes((json.dumps({'schema_version':1,'style':'RuView navy, electric blue, lime and amber','motion':'Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies','assets':manifest},indent=2)+'\n').encode('utf-8')) print(f'Generated {len(manifest)} SVGs, {sum((ROOT/x["file"]).stat().st_size for x in manifest):,} bytes') From 1c678a71faf1a9d31bbc9f03bd7add76f0293069 Mon Sep 17 00:00:00 2001 From: rUv Date: Mon, 12 Oct 2026 20:52:23 -0700 Subject: [PATCH 21/23] Improve README spatial visuals with distinct sensing motion and typography --- README.md | 40 ++--- assets/readme/cognitum-spatial-v4.svg | 31 ++++ assets/readme/feature-agents-spatial-v4.svg | 31 ++++ .../readme/feature-automation-spatial-v4.svg | 31 ++++ assets/readme/feature-edge-spatial-v4.svg | 31 ++++ assets/readme/feature-pose-spatial-v4.svg | 31 ++++ assets/readme/feature-presence-spatial-v4.svg | 31 ++++ assets/readme/feature-vitals-spatial-v4.svg | 31 ++++ assets/readme/header-docs-spatial-v4.svg | 31 ++++ assets/readme/header-edge-spatial-v4.svg | 31 ++++ assets/readme/header-evidence-spatial-v4.svg | 31 ++++ assets/readme/header-features-spatial-v4.svg | 31 ++++ assets/readme/header-learning-spatial-v4.svg | 31 ++++ assets/readme/hero-spatial-v4.svg | 31 ++++ assets/readme/manifest.json | 44 +++--- assets/readme/nav-agents-spatial-v4.svg | 31 ++++ assets/readme/nav-hardware-spatial-v4.svg | 31 ++++ assets/readme/nav-home-spatial-v4.svg | 31 ++++ assets/readme/nav-models-spatial-v4.svg | 31 ++++ assets/readme/nav-seed-spatial-v4.svg | 31 ++++ assets/readme/nav-start-spatial-v4.svg | 31 ++++ assets/readme/overview-spatial-v4.svg | 31 ++++ docs/readme-visual-system.md | 14 +- scripts/render-readme-visuals.py | 137 +++++++++++------- 24 files changed, 753 insertions(+), 102 deletions(-) create mode 100644 assets/readme/cognitum-spatial-v4.svg create mode 100644 assets/readme/feature-agents-spatial-v4.svg create mode 100644 assets/readme/feature-automation-spatial-v4.svg create mode 100644 assets/readme/feature-edge-spatial-v4.svg create mode 100644 assets/readme/feature-pose-spatial-v4.svg create mode 100644 assets/readme/feature-presence-spatial-v4.svg create mode 100644 assets/readme/feature-vitals-spatial-v4.svg create mode 100644 assets/readme/header-docs-spatial-v4.svg create mode 100644 assets/readme/header-edge-spatial-v4.svg create mode 100644 assets/readme/header-evidence-spatial-v4.svg create mode 100644 assets/readme/header-features-spatial-v4.svg create mode 100644 assets/readme/header-learning-spatial-v4.svg create mode 100644 assets/readme/hero-spatial-v4.svg create mode 100644 assets/readme/nav-agents-spatial-v4.svg create mode 100644 assets/readme/nav-hardware-spatial-v4.svg create mode 100644 assets/readme/nav-home-spatial-v4.svg create mode 100644 assets/readme/nav-models-spatial-v4.svg create mode 100644 assets/readme/nav-seed-spatial-v4.svg create mode 100644 assets/readme/nav-start-spatial-v4.svg create mode 100644 assets/readme/overview-spatial-v4.svg diff --git a/README.md b/README.md index 85dc966e1..901cedbc1 100644 --- a/README.md +++ b/README.md @@ -6,18 +6,18 @@

-[![RuView: spaces become signals, with an animated conceptual RF field](assets/readme/hero-spatial-v3.svg)](docs/user-guide.md) +[![RuView: spaces become signals, with an animated conceptual RF field](assets/readme/hero-spatial-v4.svg)](docs/user-guide.md) - - - + + + - - - + + +
Start here
Start here
Hardware
Hardware
Models
Models
Start here
Start here
Hardware
Hardware
Models
Models
AI toolkit
AI toolkit
Smart home
Smart home
Cognitum Seed
Cognitum Seed
AI toolkit
AI toolkit
Smart home
Smart home
Cognitum Seed
Cognitum Seed
@@ -42,7 +42,7 @@ Works natively with the four major smart-home ecosystems: **[Home Assistant](doc Every WiFi router already fills your space with radio waves. When people move, breathe, or even sit still, they disturb those waves in measurable ways. RuView captures these disturbances using Channel State Information (CSI) from low-cost ESP32 sensors and turns them into actionable data: who's there, what they're doing, and whether they're okay. -[![Explore RuView sensing, models and automation](assets/readme/header-features-spatial-v3.svg)](docs/user-guide.md) +[![Explore RuView sensing, models and automation](assets/readme/header-features-spatial-v4.svg)](docs/user-guide.md) **What it senses:** - **Presence and occupancy** — detect people through walls, count them, track entries and exits @@ -63,16 +63,16 @@ Every WiFi router already fills your space with radio waves. When people move, b - - + + - - + + - - + +
Presence and motion: open documentation and current limitations
Presence and motion
Breathing and vital signals: open documentation and current limitations
Breathing and vital signals
Presence and motion: open documentation and current limitations
Presence and motion
Breathing and vital signals: open documentation and current limitations
Breathing and vital signals
Pose research and limitations: open documentation and current limitations
Pose research and limitations
Edge sensor mesh: open documentation and current limitations
Edge sensor mesh
Pose research and limitations: open documentation and current limitations
Pose research and limitations
Edge sensor mesh: open documentation and current limitations
Edge sensor mesh
Local automation: open documentation and current limitations
Local automation
Guided AI operator: open documentation and current limitations
Guided AI operator
Local automation: open documentation and current limitations
Local automation
Guided AI operator: open documentation and current limitations
Guided AI operator
@@ -296,7 +296,7 @@ The separate **17-keypoint pose-estimation model** is now published at [`ruvnet/ ### Results & proof -[![RuView visual guide: header-evidence](assets/readme/header-evidence-spatial-v3.svg)](docs/benchmarks/pose-estimation-cog.md) +[![RuView visual guide: header-evidence](assets/readme/header-evidence-spatial-v4.svg)](docs/benchmarks/pose-estimation-cog.md) See the measured benchmarks, witness records, and one-command reproducibility check. @@ -352,7 +352,7 @@ number. The path to a first *reproducible* on-device baseline (PCK@20 ≥ 35%) i ## 🧩 Edge Module Catalog -[![RuView visual guide: header-edge](assets/readme/header-edge-spatial-v3.svg)](firmware/esp32-csi-node/README.md) +[![RuView visual guide: header-edge](assets/readme/header-edge-spatial-v4.svg)](firmware/esp32-csi-node/README.md) Add signed modules for health, security, buildings, industry, research, AI, and more. @@ -528,7 +528,7 @@ Browse and install modules at [seed.cognitum.one/store](https://seed.cognitum.on ## 🔬 How It Works -[![RuView visual guide: overview](assets/readme/overview-spatial-v3.svg)](docs/user-guide.md) +[![RuView visual guide: overview](assets/readme/overview-spatial-v4.svg)](docs/user-guide.md) WiFi routers flood every room with radio waves. When a person moves — or even breathes — those waves scatter differently. WiFi DensePose reads that scattering pattern and reconstructs what happened: @@ -654,7 +654,7 @@ These scenarios exploit WiFi's ability to penetrate solid materials — concrete ## 🧠 Self-Learning WiFi AI -[![RuView visual guide: header-learning](assets/readme/header-learning-spatial-v3.svg)](docs/user-guide.md) +[![RuView visual guide: header-learning](assets/readme/header-learning-spatial-v4.svg)](docs/user-guide.md) Learn compact room fingerprints from raw CSI and adapt the model to each environment. @@ -785,7 +785,7 @@ For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.9.1`. The quick c ## 📖 Documentation -[![RuView visual guide: header-docs](assets/readme/header-docs-spatial-v3.svg)](docs/user-guide.md) +[![RuView visual guide: header-docs](assets/readme/header-docs-spatial-v4.svg)](docs/user-guide.md) Start with the user, build, and calibration guides; expand for the full reference map. @@ -828,7 +828,7 @@ Start with the user, build, and calibration guides; expand for the full referenc > > Contributions and bug reports welcome at [Issues](https://github.com/ruvnet/RuView/issues). -[![Cognitum One: Ambient Intelligence at the edge of the physical world](assets/readme/cognitum-spatial-v3.svg)](https://cognitum.one) +[![Cognitum One: Ambient Intelligence at the edge of the physical world](assets/readme/cognitum-spatial-v4.svg)](https://cognitum.one) ## 📄 License diff --git a/assets/readme/cognitum-spatial-v4.svg b/assets/readme/cognitum-spatial-v4.svg new file mode 100644 index 000000000..240cff69e --- /dev/null +++ b/assets/readme/cognitum-spatial-v4.svg @@ -0,0 +1,31 @@ +Explore Cognitum OneAmbient Intelligence at the edge of the physical world. Open cognitum.one.COGNITUM ONEAmbient Intelligence.At the edge of the physical world.Explore the platform behind the ecosystem.Explore Cognitum diff --git a/assets/readme/feature-agents-spatial-v4.svg b/assets/readme/feature-agents-spatial-v4.svg new file mode 100644 index 000000000..2de89cd32 --- /dev/null +++ b/assets/readme/feature-agents-spatial-v4.svg @@ -0,0 +1,31 @@ +A guided AI operatorSource-cited setup and verification. CLI, MCP and agent workflows. Conceptual animation; follow the linked source for capability limits.RUVIEW / 06A guided AI operatorSource-cited setup and verification.CLI, MCP and agent workflows.TOOLKIT GUIDE diff --git a/assets/readme/feature-automation-spatial-v4.svg b/assets/readme/feature-automation-spatial-v4.svg new file mode 100644 index 000000000..74f62b20b --- /dev/null +++ b/assets/readme/feature-automation-spatial-v4.svg @@ -0,0 +1,31 @@ +Local automationConnect rooms to your smart home. Home Assistant, MQTT and Matter. Conceptual animation; follow the linked source for capability limits.RUVIEW / 05Local automationConnect rooms to your smart home.Home Assistant, MQTT and Matter.INTEGRATION GUIDE diff --git a/assets/readme/feature-edge-spatial-v4.svg b/assets/readme/feature-edge-spatial-v4.svg new file mode 100644 index 000000000..dce2268e7 --- /dev/null +++ b/assets/readme/feature-edge-spatial-v4.svg @@ -0,0 +1,31 @@ +Edge sensor meshProvision, capture and calibrate. Explore ESP32 firmware and runbooks. Conceptual animation; follow the linked source for capability limits.RUVIEW / 04Edge sensor meshProvision, capture and calibrate.Explore ESP32 firmware and runbooks.HARDWARE GUIDE diff --git a/assets/readme/feature-pose-spatial-v4.svg b/assets/readme/feature-pose-spatial-v4.svg new file mode 100644 index 000000000..7c40ef3ad --- /dev/null +++ b/assets/readme/feature-pose-spatial-v4.svg @@ -0,0 +1,31 @@ +Pose researchTrain and compare body keypoints. Live accuracy differs from benchmarks. Conceptual animation; follow the linked source for capability limits.RUVIEW / 03Pose researchTrain and compare body keypoints.Live accuracy differs from benchmarks.READ THE EVIDENCE diff --git a/assets/readme/feature-presence-spatial-v4.svg b/assets/readme/feature-presence-spatial-v4.svg new file mode 100644 index 000000000..591f18684 --- /dev/null +++ b/assets/readme/feature-presence-spatial-v4.svg @@ -0,0 +1,31 @@ +Presence & motionExplore changes in room occupancy. Inspect motion features from CSI. Conceptual animation; follow the linked source for capability limits.RUVIEW / 01Presence & motionExplore changes in room occupancy.Inspect motion features from CSI.SENSING GUIDE diff --git a/assets/readme/feature-vitals-spatial-v4.svg b/assets/readme/feature-vitals-spatial-v4.svg new file mode 100644 index 000000000..ac4fd8788 --- /dev/null +++ b/assets/readme/feature-vitals-spatial-v4.svg @@ -0,0 +1,31 @@ +Breathing & vital signalsInspect phase-derived signal trends. Validate estimates on your hardware. Conceptual animation; follow the linked source for capability limits.RUVIEW / 02Breathing & vital signalsInspect phase-derived signal trends.Validate estimates on your hardware.SIGNAL PIPELINE diff --git a/assets/readme/header-docs-spatial-v4.svg b/assets/readme/header-docs-spatial-v4.svg new file mode 100644 index 000000000..c5929eb3e --- /dev/null +++ b/assets/readme/header-docs-spatial-v4.svg @@ -0,0 +1,31 @@ +Choose your next stepAnimated section heading. GUIDES / HARDWARE / INTEGRATIONSChoose your next stepGUIDES / HARDWARE / INTEGRATIONS diff --git a/assets/readme/header-edge-spatial-v4.svg b/assets/readme/header-edge-spatial-v4.svg new file mode 100644 index 000000000..c60261168 --- /dev/null +++ b/assets/readme/header-edge-spatial-v4.svg @@ -0,0 +1,31 @@ +Intelligence at the edgeAnimated section heading. SENSORS / FIRMWARE / MODULESIntelligence at the edgeSENSORS / FIRMWARE / MODULES diff --git a/assets/readme/header-evidence-spatial-v4.svg b/assets/readme/header-evidence-spatial-v4.svg new file mode 100644 index 000000000..cdf77aff8 --- /dev/null +++ b/assets/readme/header-evidence-spatial-v4.svg @@ -0,0 +1,31 @@ +Evidence before accuracy claimsAnimated section heading. BENCHMARKS / REPRODUCERS / LIMITSEvidence before accuracy claimsBENCHMARKS / REPRODUCERS / LIMITS diff --git a/assets/readme/header-features-spatial-v4.svg b/assets/readme/header-features-spatial-v4.svg new file mode 100644 index 000000000..df0b2191c --- /dev/null +++ b/assets/readme/header-features-spatial-v4.svg @@ -0,0 +1,31 @@ +Explore the capabilitiesAnimated section heading. SENSING / MODELS / AUTOMATIONExplore the capabilitiesSENSING / MODELS / AUTOMATION diff --git a/assets/readme/header-learning-spatial-v4.svg b/assets/readme/header-learning-spatial-v4.svg new file mode 100644 index 000000000..cc49ff710 --- /dev/null +++ b/assets/readme/header-learning-spatial-v4.svg @@ -0,0 +1,31 @@ +From recordings to modelsAnimated section heading. CAPTURE / TRAIN / EVALUATEFrom recordings to modelsCAPTURE / TRAIN / EVALUATE diff --git a/assets/readme/hero-spatial-v4.svg b/assets/readme/hero-spatial-v4.svg new file mode 100644 index 000000000..caad12866 --- /dev/null +++ b/assets/readme/hero-spatial-v4.svg @@ -0,0 +1,31 @@ +RuView: invisible signals, visible possibilityAnimated conceptual RF room with an articulated pose, moving scan plane and propagating waves. Illustration only, not a sensor measurement.RUVIEW / SPATIAL INTELLIGENCEInvisible signals.Visible possibility.Explore the space between sensing and understanding.WiFi sensing. Edge models. Local automation.Enter the fieldRF SENSING / OPEN RESEARCH / EDGE COMPUTECONCEPTUAL RF FIELD diff --git a/assets/readme/manifest.json b/assets/readme/manifest.json index 2e7ef0b46..c2555a5e0 100644 --- a/assets/readme/manifest.json +++ b/assets/readme/manifest.json @@ -1,143 +1,143 @@ { "schema_version": 1, - "style": "RuView navy, electric blue, lime and amber", + "style": "RuView spatial v4: navy glass, electric blue, lime and amber", "motion": "Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies", "assets": [ { - "file": "assets/readme/hero-spatial-v3.svg", - "title": "RuView: spaces become signals", + "file": "assets/readme/hero-spatial-v4.svg", + "title": "RuView: invisible signals, visible possibility", "target": "docs/user-guide.md", "width": 1200, "height": 450 }, { - "file": "assets/readme/nav-start-spatial-v3.svg", + "file": "assets/readme/nav-start-spatial-v4.svg", "title": "Start here", "target": "docs/user-guide.md", "width": 180, "height": 116 }, { - "file": "assets/readme/nav-hardware-spatial-v3.svg", + "file": "assets/readme/nav-hardware-spatial-v4.svg", "title": "Hardware", "target": "firmware/esp32-csi-node/README.md", "width": 180, "height": 116 }, { - "file": "assets/readme/nav-models-spatial-v3.svg", + "file": "assets/readme/nav-models-spatial-v4.svg", "title": "Models", "target": "https://huggingface.co/ruvnet/wifi-densepose-pretrained", "width": 180, "height": 116 }, { - "file": "assets/readme/nav-agents-spatial-v3.svg", + "file": "assets/readme/nav-agents-spatial-v4.svg", "title": "AI toolkit", "target": "harness/ruview/README.md", "width": 180, "height": 116 }, { - "file": "assets/readme/nav-home-spatial-v3.svg", + "file": "assets/readme/nav-home-spatial-v4.svg", "title": "Smart home", "target": "docs/integrations/home-assistant.md", "width": 180, "height": 116 }, { - "file": "assets/readme/nav-seed-spatial-v3.svg", + "file": "assets/readme/nav-seed-spatial-v4.svg", "title": "Cognitum", "target": "https://cognitum.one/seed", "width": 180, "height": 116 }, { - "file": "assets/readme/feature-presence-spatial-v3.svg", + "file": "assets/readme/feature-presence-spatial-v4.svg", "title": "Presence & motion", "target": "docs/user-guide.md", "width": 580, "height": 276 }, { - "file": "assets/readme/feature-vitals-spatial-v3.svg", + "file": "assets/readme/feature-vitals-spatial-v4.svg", "title": "Breathing & vital signals", "target": "v2/crates/wifi-densepose-signal/README.md", "width": 580, "height": 276 }, { - "file": "assets/readme/feature-pose-spatial-v3.svg", + "file": "assets/readme/feature-pose-spatial-v4.svg", "title": "Pose research", "target": "docs/benchmarks/pose-estimation-cog.md", "width": 580, "height": 276 }, { - "file": "assets/readme/feature-edge-spatial-v3.svg", + "file": "assets/readme/feature-edge-spatial-v4.svg", "title": "Edge sensor mesh", "target": "firmware/esp32-csi-node/README.md", "width": 580, "height": 276 }, { - "file": "assets/readme/feature-automation-spatial-v3.svg", + "file": "assets/readme/feature-automation-spatial-v4.svg", "title": "Local automation", "target": "docs/integrations/home-assistant.md", "width": 580, "height": 276 }, { - "file": "assets/readme/feature-agents-spatial-v3.svg", + "file": "assets/readme/feature-agents-spatial-v4.svg", "title": "A guided AI operator", "target": "harness/ruview/README.md", "width": 580, "height": 276 }, { - "file": "assets/readme/overview-spatial-v3.svg", + "file": "assets/readme/overview-spatial-v4.svg", "title": "RuView sensing overview", "target": "docs/user-guide.md", "width": 1200, "height": 396 }, { - "file": "assets/readme/header-features-spatial-v3.svg", + "file": "assets/readme/header-features-spatial-v4.svg", "title": "Explore the capabilities", "target": "docs/user-guide.md", "width": 1200, "height": 148 }, { - "file": "assets/readme/header-edge-spatial-v3.svg", + "file": "assets/readme/header-edge-spatial-v4.svg", "title": "Intelligence at the edge", "target": "firmware/esp32-csi-node/README.md", "width": 1200, "height": 148 }, { - "file": "assets/readme/header-learning-spatial-v3.svg", + "file": "assets/readme/header-learning-spatial-v4.svg", "title": "From recordings to models", "target": "docs/user-guide.md", "width": 1200, "height": 148 }, { - "file": "assets/readme/header-evidence-spatial-v3.svg", + "file": "assets/readme/header-evidence-spatial-v4.svg", "title": "Evidence before accuracy claims", "target": "docs/benchmarks/pose-estimation-cog.md", "width": 1200, "height": 148 }, { - "file": "assets/readme/header-docs-spatial-v3.svg", + "file": "assets/readme/header-docs-spatial-v4.svg", "title": "Choose your next step", "target": "docs/user-guide.md", "width": 1200, "height": 148 }, { - "file": "assets/readme/cognitum-spatial-v3.svg", + "file": "assets/readme/cognitum-spatial-v4.svg", "title": "Explore Cognitum One", "target": "https://cognitum.one", "width": 1200, diff --git a/assets/readme/nav-agents-spatial-v4.svg b/assets/readme/nav-agents-spatial-v4.svg new file mode 100644 index 000000000..f26b5a49c --- /dev/null +++ b/assets/readme/nav-agents-spatial-v4.svg @@ -0,0 +1,31 @@ +AI toolkitOpen AI toolkit. Animated navigation icon.AI toolkit diff --git a/assets/readme/nav-hardware-spatial-v4.svg b/assets/readme/nav-hardware-spatial-v4.svg new file mode 100644 index 000000000..93b6b7f29 --- /dev/null +++ b/assets/readme/nav-hardware-spatial-v4.svg @@ -0,0 +1,31 @@ +HardwareOpen Hardware. Animated navigation icon.Hardware diff --git a/assets/readme/nav-home-spatial-v4.svg b/assets/readme/nav-home-spatial-v4.svg new file mode 100644 index 000000000..2476bb444 --- /dev/null +++ b/assets/readme/nav-home-spatial-v4.svg @@ -0,0 +1,31 @@ +Smart homeOpen Smart home. Animated navigation icon.Smart home diff --git a/assets/readme/nav-models-spatial-v4.svg b/assets/readme/nav-models-spatial-v4.svg new file mode 100644 index 000000000..5cda54036 --- /dev/null +++ b/assets/readme/nav-models-spatial-v4.svg @@ -0,0 +1,31 @@ +ModelsOpen Models. Animated navigation icon.Models diff --git a/assets/readme/nav-seed-spatial-v4.svg b/assets/readme/nav-seed-spatial-v4.svg new file mode 100644 index 000000000..6e5b608dc --- /dev/null +++ b/assets/readme/nav-seed-spatial-v4.svg @@ -0,0 +1,31 @@ +CognitumOpen Cognitum. Animated navigation icon.Cognitum diff --git a/assets/readme/nav-start-spatial-v4.svg b/assets/readme/nav-start-spatial-v4.svg new file mode 100644 index 000000000..e8e2d54a7 --- /dev/null +++ b/assets/readme/nav-start-spatial-v4.svg @@ -0,0 +1,31 @@ +Start hereOpen Start here. Animated navigation icon.Start here diff --git a/assets/readme/overview-spatial-v4.svg b/assets/readme/overview-spatial-v4.svg new file mode 100644 index 000000000..68a4aae9a --- /dev/null +++ b/assets/readme/overview-spatial-v4.svg @@ -0,0 +1,31 @@ +RuView sensing overviewConceptual sequence: capture RF, extract context, act locally. Links to architecture and user documentation.SIGNAL TO CONTEXTA sensing loop you can inspect.01Capture RFCSI from supported hardwareProvision and calibrate02Extract contextSignal processing and modelsCompare against real data03Act locallyEvents, APIs and integrationsApply rules and review outputsCONCEPTUAL PIPELINE / HARDWARE AND ENVIRONMENT VALIDATION MATTER diff --git a/docs/readme-visual-system.md b/docs/readme-visual-system.md index a4374fd66..a03645e9b 100644 --- a/docs/readme-visual-system.md +++ b/docs/readme-visual-system.md @@ -2,10 +2,11 @@ The README matches the RuView header palette: deep navy backgrounds, electric blue RF fields, lime pose and signal accents, warm amber joints and soft white text, -layered isometric geometry, moving scan planes, orbital markers and floating -icons. It includes a hero, six feature cards, six navigation icons, an overview, +layered isometric geometry, moving scan planes, radar sweeps, articulated pose +limbs, breathing curves and separated hardware layers. Each feature uses its own +illustration rather than repeating the same wireframe cube. It includes a hero, six feature cards, six navigation icons, an overview, five chapter headings and a Cognitum destination banner. Headlines use a heavier -Avenir Next / Segoe UI / Helvetica stack, with tighter spacing and a dedicated +Avenir Next / Segoe UI Variable Display / Segoe UI / Helvetica stack, with tighter spacing and a dedicated system monospace stack for labels. Fonts remain local fallbacks so the images also render without a network connection. @@ -29,7 +30,7 @@ local link targets stay inside the repository, including after symlink resolutio The committed SVGs are the deployment artifacts. The manifest in `assets/readme/manifest.json` lists every destination. -Spatial artwork uses `-spatial-v3.svg` filenames so GitHub's image proxy requests +Spatial artwork uses `-spatial-v4.svg` filenames so GitHub's image proxy requests fresh content after the visual upgrade. Previous filenames remain available for existing links; new README wrappers use the versioned assets. @@ -39,8 +40,9 @@ GitHub renders SVGs as images, so image-internal links are not used. Each SVG ha Animation uses CSS only. Depth is projected directly into SVG geometry rather than relying on a WebGL canvas or CSS perspective. Translation is applied to -inner groups so it cannot overwrite the outer icon placement. Orbital motion -uses an explicit transform origin; scan planes sweep the wireframe volumes. +inner groups so it cannot overwrite the outer icon placement. Projected orbit planes stay fixed while dashed signals move along them; scan +planes sweep the wireframe volumes. Unused animation rules are removed from +each image to keep the complete active SVG set below 100 KB. All copy and essential geometry are visible without animation, JavaScript, remote fonts or remote images. `prefers-reduced-motion` disables decorative movement. The original GIF remains at the top; the existing ruOS promotion diff --git a/scripts/render-readme-visuals.py b/scripts/render-readme-visuals.py index 7fa6f0bb9..4456f190c 100644 --- a/scripts/render-readme-visuals.py +++ b/scripts/render-readme-visuals.py @@ -2,7 +2,7 @@ """Generate the RuView README visual system. Deterministic, no network.""" from pathlib import Path from html import escape -import math,json +import math,json,re from readme_visuals_xml import parse_svg ROOT=Path(__file__).resolve().parents[1];OUT=ROOT/'assets/readme';OUT.mkdir(parents=True,exist_ok=True) C='#78cfff';BLUE='#438fff';INK='#071124';WHITE='#f3f7ff';MUTED='#b0c2db' @@ -18,35 +18,39 @@ def circle(x,y,r,color=C,extra=''): def icon(kind,x,y,scale=1): s='' - if kind in ['signal','start']: - for r in [13,25,38]:s+=path(f'M{-r*.8} {-r*.4}Q0 {-r*1.4} {r*.8} {-r*.4}',extra=f'class="breathe" style="animation-delay:-{r/20}s"') - s+=circle(0,20,4,extra='fill="#72eee0"')+path('M0 12V-1') - elif kind in ['presence','pose']: - s+=circle(0,-28,8,LIME)+path('M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38',LIME,width=2) - for px,py in [(0,-14),(0,12),(-24,-6),(24,-6)]:s+=circle(px,py,2.3,AMBER,extra=f'fill="{AMBER}" class="spark"') - if kind=='pose': - for px,py in [(-18,38),(18,38)]:s+=circle(px,py,3,AMBER,extra='fill="#041117" class="breathe"') - else:s+=f'' + if kind in ['signal','start','presence']: + for r in [18,32,46]:s+=f'' + s+=path('M-46 0H46M0 -23V23',BLUE,.7) + s+=''+path('M0 0L40 -18',LIME,2)+circle(34,-15,3,LIME,extra=f'fill="{LIME}"')+'' + for px,py in [(-20,8),(12,13),(23,-8)]:s+=circle(px,py,3,AMBER,extra=f'fill="{AMBER}" class="spark"') + elif kind=='pose': + s+=circle(0,-32,7,LIME)+path('M0 -25L-3 -2L5 16',LIME,2.5) + s+=''+path('M-1 -18L-19 -5L-31 -14M-1 -18L18 -11L28 -25',LIME,2.5)+'' + s+=''+path('M5 16L-12 28L-22 47M5 16L24 28L33 44',LIME,2.5)+'' + for px,py in [(-1,-18),(-3,-2),(5,16),(-19,-5),(18,-11)]:s+=circle(px,py,2.8,AMBER,extra=f'fill="{AMBER}"') + s+=path('M-42 -42V-50H-30M30 -50H42V-42M42 45V53H30M-30 53H-42V45',BLUE,1) elif kind in ['vitals','models']: - s+=path('M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43',LIME,width=2,extra='class="trace"')+path('M-42 31H43','#214451',1) - elif kind in ['edge','hardware']: - s+='' - s+='' - for v in [-16,0,16]:s+=path(f'M{v} -36V-25M{v} 25V36M-36 {v}H-25M25 {v}H36') - s+=circle(0,0,4,extra='fill="#72eee0" class="breathe"') + for j in range(3): + s+=path(f'M-49 {j*12-14}C-34 {j*12-14} -30 {j*12-35} -17 {j*12-35}S0 {j*12+7} 14 {j*12+7}S34 {j*12-14} 49 {j*12-14}',C if j!=1 else LIME,1.5,extra=f'class="trace" style="animation-delay:-{j}s" opacity="{1-j*.2}"') + s+=''+path('M0 -42V40',AMBER,1)+circle(0,-8,3,AMBER,extra=f'fill="{AMBER}"')+'' + elif kind in ['edge','hardware','seed','evidence']: + for j in range(3): + yy=j*13 + s+=f'' + s+=path('M-12 -31L0 -37L12 -31L0 -25Z',LIME,2) + for px in [-26,0,26]:s+=path(f'M{px} 22V39',AMBER,1,extra='class="flow"') elif kind in ['home','automation']: - s+=path('M-37 -3L0 -34L37 -3M-28 -10V32H28V-10M-9 32V8H9V32',width=2) - s+=circle(0,-9,5,BLUE,extra='class="breathe"') + s+='' + s+=path('M-32 17V-16L0 -35L32 -16V17M-32 -16L0 3L32 -16M0 3V34',C,1.5) + s+=path('M-22 20L-10 13L12 25M-10 13V-5',LIME,1.5,extra='class="flow"') + for px,py in [(-22,20),(-10,-5),(12,25)]:s+=circle(px,py,4,AMBER,extra=f'fill="#102749" class="breathe"') elif kind in ['agent','agents']: - for px,py in [(0,-33),(-35,23),(35,23)]:s+=path(f'M0 0L{px} {py}',BLUE,1,extra='class="flow"')+circle(px,py,7,extra='class="breathe"') - s+=circle(0,0,15)+path('M-6 0L-1 5L8 -6') - elif kind in ['seed','evidence']: - s+=path('M0 -38L31 -20V18L0 38L-31 18V-20ZM-31 -20L0 -1L31 -20M0 -1V38',width=2) - s+=path('M-15 1L-5 11L17 -12',BLUE,2,extra='class="trace"') - else: - for j in range(3):s+=path(f'M-38 {j*14-20}L0 {j*14-38}L38 {j*14-20}L0 {j*14-2}Z',extra='class="breathe"') - base='' - base+=path('M-43 48L0 27L43 48L0 69Z','#285c68',1) + for px,py in [(0,-36),(-38,-10),(-23,31),(30,28),(39,-17)]: + s+=path(f'M0 0L{px} {py}',BLUE,1,extra='class="flow"') + s+=f'' + s+=circle(0,0,15,LIME)+path('M-6 0L-1 5L8 -6',LIME,2) + else:s+=path('M-35 0L0 -25L35 0L0 25Z',C,2) + base='' return f'{base}{s}' def spatial(x,y,scale=1): @@ -62,19 +66,29 @@ def spatial(x,y,scale=1): def _frame(w,h,title,desc,body): return f'''{escape(title)}{escape(desc)}{body}''' MOTION='''')+8:] + motion=MOTION + for name in ['float','flow','breathe','trace','scan','orbit','spark','wave','sweep','limb','stride','layer','cursor']: + if f'class="{name}"' not in body: + motion=re.sub(r'\.'+name+r'\{[^}]*\}', '', motion) + for name in ['float','flow','breathe','trace','scan','spark','wave','sweep','limb','layer','cursor']: + if f'animation:{name} ' not in motion: + marker='@keyframes '+name+'{'; start=motion.find(marker) + if start>=0: + pos=start+len(marker); depth=1 + while depth: + depth+=(motion[pos]=='{')-(motion[pos]=='}');pos+=1 + motion=motion[:start]+motion[pos:] + value=value[:value.index('')+8:] palette={'#0b303a':'#102749','#06171e':'#071429','#030c13':'#03091a', '#245360':'#2d4e77','#91f6df':'#9ee7ff','#26bad7':'#348bff', '#35d4db':'#348bff','#72eee0':C,'#70b6ff':BLUE,'#041117':INK, @@ -94,39 +120,42 @@ def frame(w,h,title,desc,body): '#0a2630':'#0c2242','#12343d':'#162f50','#4b9ba4':'#5490c5', '#071b23':'#091b35','#285360':'#315685','#214451':'#233f63','#3c8897':'#437cb2'} for old,new in palette.items():value=value.replace(old,new) - return value + rim=f'' + return value.replace('',rim+'') manifest=[] def save(name,w,h,title,desc,body,target): - name+='-spatial-v3' + name+='-spatial-v4' s=frame(w,h,title,desc,body);raw=(s+'\n').encode('utf-8');parse_svg(raw);(OUT/(name+'.svg')).write_bytes(raw);manifest.append({'file':'assets/readme/'+name+'.svg','title':title,'target':target,'width':w,'height':h}) -# Main title panel: a radio field passes through an illustrated room. -s=text(48,44,'RUVIEW / COGNITUM ECOSYSTEM',13,C,extra='class="mono"')+text(48,121,'RuView',76,WHITE)+text(48,184,'Spaces become signals.',38,WHITE)+text(48,234,'Signals become understanding.',32,C)+text(50,279,'Explore RF sensing, edge inference and local automation.',18) -s+=''+text(72,359,'Explore the guide',20,INK)+path('M272 352H292M285 345L292 352L285 359',INK,2) -s+=text(49,419,'RF SENSING / EDGE MODELS / LOCAL ACTION',11,MUTED,extra='class="mono"') -s+='' -for i in range(9):s+=path(f'M{750+i*40} 282L{610+i*80} 443','#12343d',1) -for y in [306,336,372,418]:s+=path(f'M680 {y}H1168','#12343d',1) -s+=''+path('M803 157L1008 106L1137 177L930 236ZM803 157V314L930 391L1137 324V177M930 236V391M1008 106V268L803 314M1008 268L1137 324','#4b9ba4',1.5)+'' -s+='' -s+='' -for i in range(3): - s+=f'' -for i in range(24): - s+=circle(849+(i%6)*44,198+(i//6)*35,1.7,BLUE,extra=f'fill="#70b6ff" class="spark" style="animation-delay:-{i%7/2}s"') -s+=icon('presence',976,265,1.35)+icon('edge',780,349,.7) -for i in range(4):s+=path(f'M{794+i*12} {333-i*9}Q{830+i*25} {245-i*24} {896+i*29} {207-i*3}',C,1.4,extra=f'class="flow" style="animation-delay:-{i}s" opacity="{.8-i*.12}"') -for x,y in [(803,157),(1008,106),(1137,177),(930,236),(930,391),(803,314),(1137,324)]:s+=circle(x,y,3,extra='fill="#72eee0" class="breathe"') +# Cinematic RF stage: authored projected planes, not a live measurement. +s=text(48,45,'RUVIEW / SPATIAL INTELLIGENCE',12,C,extra='class="mono"') +s+=text(44,140,'Invisible signals.',59,WHITE)+text(44,204,'Visible possibility.',55,LIME) +s+=text(48,256,'Explore the space between sensing and understanding.',18) +s+=text(48,284,'WiFi sensing. Edge models. Local automation.',18) +s+=''+text(72,358,'Enter the field',18,INK)+path('M246 351H266M259 344L266 351L259 358',INK,2) +s+=text(49,418,'RF SENSING / OPEN RESEARCH / EDGE COMPUTE',10,MUTED,extra='class="mono"') +s+='' +# Ground perspective and an exploded room volume. +for i in range(7):s+=path(f'M{785+i*24} 270L{648+i*91} 432','#1b3558',.8) +for j in range(5):s+=path(f'M{768-j*27} {286+j*31}H{1110+j*16}','#1b3558',.8) +s+='' +s+='' +s+='' +s+='' +for i in range(3):s+=f'' +s+=icon('pose',959,236,1.8)+icon('hardware',771,324,.72) +for j in range(3):s+=path(f'M795 {309+j*8}Q854 {245+j*14} 939 {223+j*17}',C,1.2,extra=f'class="flow" style="animation-delay:-{j}s"') +for px,py in [(780,153),(963,70),(1148,162),(1148,303),(960,400)]:s+=circle(px,py,3,AMBER,extra=f'fill="{AMBER}" class="spark"') s+=text(1148,419,'CONCEPTUAL RF FIELD',10,MUTED,'end',extra='class="mono"') -save('hero',1200,450,'RuView: spaces become signals','Animated conceptual RF field and room geometry. Explore the RuView user guide. This illustration is not a sensor measurement.',s,'docs/user-guide.md') +save('hero',1200,450,'RuView: invisible signals, visible possibility','Animated conceptual RF room with an articulated pose, moving scan plane and propagating waves. Illustration only, not a sensor measurement.',s,'docs/user-guide.md') # Visual navigation icons, each receives a real outer link in Markdown. nav=[('start','Start here','docs/user-guide.md'),('hardware','Hardware','firmware/esp32-csi-node/README.md'),('models','Models','https://huggingface.co/ruvnet/wifi-densepose-pretrained'),('agents','AI toolkit','harness/ruview/README.md'),('home','Smart home','docs/integrations/home-assistant.md'),('seed','Cognitum','https://cognitum.one/seed')] for k,label,target in nav:save('nav-'+k,180,116,label,'Open '+label+'. Animated navigation icon.',icon(k,90,45,.65)+text(90,98,label,16,WHITE,'middle'),target) # Capability cards. No unverified performance claims. cards=[('presence','01','Presence & motion','Explore changes in room occupancy.','Inspect motion features from CSI.','SENSING GUIDE','docs/user-guide.md'),('vitals','02','Breathing & vital signals','Inspect phase-derived signal trends.','Validate estimates on your hardware.','SIGNAL PIPELINE','v2/crates/wifi-densepose-signal/README.md'),('pose','03','Pose research','Train and compare body keypoints.','Live accuracy differs from benchmarks.','READ THE EVIDENCE','docs/benchmarks/pose-estimation-cog.md'),('edge','04','Edge sensor mesh','Provision, capture and calibrate.','Explore ESP32 firmware and runbooks.','HARDWARE GUIDE','firmware/esp32-csi-node/README.md'),('automation','05','Local automation','Connect rooms to your smart home.','Home Assistant, MQTT and Matter.','INTEGRATION GUIDE','docs/integrations/home-assistant.md'),('agents','06','A guided AI operator','Source-cited setup and verification.','CLI, MCP and agent workflows.','TOOLKIT GUIDE','harness/ruview/README.md')] for k,num,title,l1,l2,cta,target in cards: - s=text(30,39,'RUVIEW / '+num,11,C,extra='class="mono"')+text(30,94,title,29,WHITE)+text(30,136,l1,16)+text(30,164,l2,16) - s+=''+spatial(480,161,.53)+icon(k,474,155,.68)+path('M30 204H550','#245360',1)+path('M30 204H550',C,1,extra='class="flow" opacity=".5"') + s=text(30,39,'RUVIEW / '+num,11,C,extra='class="mono"')+text(30,89,title,25,WHITE)+text(30,126,l1,14)+text(30,151,l2,14) + s+=''+icon(k,480,144,1.12)+path('M30 204H550','#245360',1)+path('M30 204H550',C,1,extra='class="flow" opacity=".5"') s+=text(30,245,cta,12,C,extra='class="mono"')+path('M510 239H540M532 231L540 239L532 247',C,1.5) save('feature-'+k,580,276,title,l1+' '+l2+' Conceptual animation; follow the linked source for capability limits.',s,target) # Overview uses three clearly labelled stages rather than fabricated telemetry. @@ -150,5 +179,5 @@ s+=''+ s+=''+spatial(945,173,1.55)+icon('seed',945,145,1.65) for j in range(3):s+=f'' save('cognitum',1200,304,'Explore Cognitum One','Ambient Intelligence at the edge of the physical world. Open cognitum.one.',s,'https://cognitum.one') -(OUT/'manifest.json').write_bytes((json.dumps({'schema_version':1,'style':'RuView navy, electric blue, lime and amber','motion':'Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies','assets':manifest},indent=2)+'\n').encode('utf-8')) +(OUT/'manifest.json').write_bytes((json.dumps({'schema_version':1,'style':'RuView spatial v4: navy glass, electric blue, lime and amber','motion':'Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies','assets':manifest},indent=2)+'\n').encode('utf-8')) print(f'Generated {len(manifest)} SVGs, {sum((ROOT/x["file"]).stat().st_size for x in manifest):,} bytes') From 2b3138d9d09d6acfc6093328d578a282f4ca971f Mon Sep 17 00:00:00 2001 From: rUv Date: Mon, 12 Oct 2026 20:58:45 -0700 Subject: [PATCH 22/23] Replace README stick figure with shaded anatomical SVG human --- README.md | 4 +-- assets/readme/feature-pose-spatial-v5.svg | 31 ++++++++++++++++++++++ assets/readme/hero-spatial-v5.svg | 31 ++++++++++++++++++++++ assets/readme/manifest.json | 4 +-- docs/readme-visual-system.md | 6 +++++ scripts/render-readme-visuals.py | 32 +++++++++++++++++------ 6 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 assets/readme/feature-pose-spatial-v5.svg create mode 100644 assets/readme/hero-spatial-v5.svg diff --git a/README.md b/README.md index 901cedbc1..943918f5b 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

-[![RuView: spaces become signals, with an animated conceptual RF field](assets/readme/hero-spatial-v4.svg)](docs/user-guide.md) +[![RuView: spaces become signals, with an animated conceptual RF field](assets/readme/hero-spatial-v5.svg)](docs/user-guide.md) @@ -67,7 +67,7 @@ Every WiFi router already fills your space with radio waves. When people move, b - + diff --git a/assets/readme/feature-pose-spatial-v5.svg b/assets/readme/feature-pose-spatial-v5.svg new file mode 100644 index 000000000..102455d1d --- /dev/null +++ b/assets/readme/feature-pose-spatial-v5.svg @@ -0,0 +1,31 @@ +Pose researchTrain and compare body keypoints. Live accuracy differs from benchmarks. Conceptual animation; follow the linked source for capability limits.RUVIEW / 03Pose researchTrain and compare body keypoints.Live accuracy differs from benchmarks.READ THE EVIDENCE diff --git a/assets/readme/hero-spatial-v5.svg b/assets/readme/hero-spatial-v5.svg new file mode 100644 index 000000000..7d3d82053 --- /dev/null +++ b/assets/readme/hero-spatial-v5.svg @@ -0,0 +1,31 @@ +RuView: invisible signals, visible possibilityAnimated conceptual RF room with an anatomical human silhouette, moving scan plane and propagating waves. Illustration only, not a sensor measurement.RUVIEW / SPATIAL INTELLIGENCEInvisible signals.Visible possibility.Explore the space between sensing and understanding.WiFi sensing. Edge models. Local automation.Enter the fieldRF SENSING / OPEN RESEARCH / EDGE COMPUTECONCEPTUAL RF FIELD diff --git a/assets/readme/manifest.json b/assets/readme/manifest.json index c2555a5e0..65fbb38ff 100644 --- a/assets/readme/manifest.json +++ b/assets/readme/manifest.json @@ -4,7 +4,7 @@ "motion": "Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies", "assets": [ { - "file": "assets/readme/hero-spatial-v4.svg", + "file": "assets/readme/hero-spatial-v5.svg", "title": "RuView: invisible signals, visible possibility", "target": "docs/user-guide.md", "width": 1200, @@ -67,7 +67,7 @@ "height": 276 }, { - "file": "assets/readme/feature-pose-spatial-v4.svg", + "file": "assets/readme/feature-pose-spatial-v5.svg", "title": "Pose research", "target": "docs/benchmarks/pose-estimation-cog.md", "width": 580, diff --git a/docs/readme-visual-system.md b/docs/readme-visual-system.md index a03645e9b..f7d2f8050 100644 --- a/docs/readme-visual-system.md +++ b/docs/readme-visual-system.md @@ -53,3 +53,9 @@ remains linked. Existing asset dimensions and README links are preserved. The overview and RF room are conceptual illustrations, not live measurements or benchmark evidence. Feature cards link to current source documentation. Pose research is identified as such, and the signal card directs readers to hardware validation. No new speed, accuracy, medical or device-support claims are introduced. Sources: `docs/user-guide.md`, `docs/benchmarks/pose-estimation-cog.md`, `v2/crates/wifi-densepose-signal/README.md`, `firmware/esp32-csi-node/README.md`, `docs/integrations/home-assistant.md`, and `harness/ruview/README.md`. + +The hero and pose card use `-spatial-v5.svg`: an original anatomical human +silhouette with shaded torso, head, hands and feet, overlapping limbs, surface +contours and amber joint markers. Subtle limb motion pivots behind the torso +to preserve joint continuity. This is vector artwork, not a real body scan or +measured pose reconstruction. Other assets retain their v4 filenames. diff --git a/scripts/render-readme-visuals.py b/scripts/render-readme-visuals.py index 4456f190c..7a574bae9 100644 --- a/scripts/render-readme-visuals.py +++ b/scripts/render-readme-visuals.py @@ -24,11 +24,27 @@ def icon(kind,x,y,scale=1): s+=''+path('M0 0L40 -18',LIME,2)+circle(34,-15,3,LIME,extra=f'fill="{LIME}"')+'' for px,py in [(-20,8),(12,13),(23,-8)]:s+=circle(px,py,3,AMBER,extra=f'fill="{AMBER}" class="spark"') elif kind=='pose': - s+=circle(0,-32,7,LIME)+path('M0 -25L-3 -2L5 16',LIME,2.5) - s+=''+path('M-1 -18L-19 -5L-31 -14M-1 -18L18 -11L28 -25',LIME,2.5)+'' - s+=''+path('M5 16L-12 28L-22 47M5 16L24 28L33 44',LIME,2.5)+'' - for px,py in [(-1,-18),(-3,-2),(5,16),(-19,-5),(18,-11)]:s+=circle(px,py,2.8,AMBER,extra=f'fill="{AMBER}"') - s+=path('M-42 -42V-50H-30M30 -50H42V-42M42 45V53H30M-30 53H-42V45',BLUE,1) + # Authored anatomical mesh silhouette; a conceptual figure, not captured pose data. + s+='' + s+='' + # Far arm and leg provide occlusion and depth. + s+='' + s+='' + s+='' + # Near arm: shoulder, upper arm, elbow, forearm and closed hand. + s+='' + # Shoulder and chest taper into abdomen and pelvis. + s+='' + # Three-quarter head with jaw, nose and ear contour. + s+='' + s+='' + # Surface contours make volume legible at README scale. + s+=path('M-9 -23Q-2 -18 10 -23M-7 -15Q0 -11 9 -16M-6 -8Q1 -5 8 -9M-7 1Q1 5 9 1M0 -26L2 9M-4 -40Q0 -42 7 -40M2 -32L6 -34',LIME,.55,extra='opacity=".65"') + s+=path('M-5 -45Q1 -49 6 -46M-10 -22L-6 -8M9 17L17 28M-12 20L-20 42',C,.7,extra='opacity=".8"') + # Joint markers remain attached to the anatomical surface. + for px,py in [(-9,-24),(10,-23),(1,-12),(2,9)]: + s+=circle(px,py,3.5,AMBER,extra='stroke-opacity=".25"')+circle(px,py,1.5,AMBER,extra=f'fill="{AMBER}" class="spark"') + s+=path('M-42 -42V-53H-30M30 -53H42V-42M42 45V55H30M-30 55H-42V45',BLUE,.8) elif kind in ['vitals','models']: for j in range(3): s+=path(f'M-49 {j*12-14}C-34 {j*12-14} -30 {j*12-35} -17 {j*12-35}S0 {j*12+7} 14 {j*12+7}S34 {j*12-14} 49 {j*12-14}',C if j!=1 else LIME,1.5,extra=f'class="trace" style="animation-delay:-{j}s" opacity="{1-j*.2}"') @@ -85,7 +101,7 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, .layer{animation:layer 6s ease-in-out infinite} .cursor{animation:cursor 5s ease-in-out infinite} @keyframes sweep{0%,100%{transform:rotate(-20deg)}50%{transform:rotate(155deg)}} -@keyframes limb{0%,100%{transform:rotate(-9deg)}50%{transform:rotate(9deg)}} +@keyframes limb{0%,100%{transform:rotate(-3deg)}50%{transform:rotate(3deg)}} @keyframes layer{50%{transform:translateY(-5px)}} @keyframes cursor{0%,100%{transform:translateX(-35px);opacity:.3}50%{transform:translateX(35px);opacity:1}} @@ -125,7 +141,7 @@ def frame(w,h,title,desc,body): manifest=[] def save(name,w,h,title,desc,body,target): - name+='-spatial-v4' + name+='-spatial-v5' if name in ['hero','feature-pose'] else '-spatial-v4' s=frame(w,h,title,desc,body);raw=(s+'\n').encode('utf-8');parse_svg(raw);(OUT/(name+'.svg')).write_bytes(raw);manifest.append({'file':'assets/readme/'+name+'.svg','title':title,'target':target,'width':w,'height':h}) # Cinematic RF stage: authored projected planes, not a live measurement. s=text(48,45,'RUVIEW / SPATIAL INTELLIGENCE',12,C,extra='class="mono"') @@ -147,7 +163,7 @@ s+=icon('pose',959,236,1.8)+icon('hardware',771,324,.72) for j in range(3):s+=path(f'M795 {309+j*8}Q854 {245+j*14} 939 {223+j*17}',C,1.2,extra=f'class="flow" style="animation-delay:-{j}s"') for px,py in [(780,153),(963,70),(1148,162),(1148,303),(960,400)]:s+=circle(px,py,3,AMBER,extra=f'fill="{AMBER}" class="spark"') s+=text(1148,419,'CONCEPTUAL RF FIELD',10,MUTED,'end',extra='class="mono"') -save('hero',1200,450,'RuView: invisible signals, visible possibility','Animated conceptual RF room with an articulated pose, moving scan plane and propagating waves. Illustration only, not a sensor measurement.',s,'docs/user-guide.md') +save('hero',1200,450,'RuView: invisible signals, visible possibility','Animated conceptual RF room with an anatomical human silhouette, moving scan plane and propagating waves. Illustration only, not a sensor measurement.',s,'docs/user-guide.md') # Visual navigation icons, each receives a real outer link in Markdown. nav=[('start','Start here','docs/user-guide.md'),('hardware','Hardware','firmware/esp32-csi-node/README.md'),('models','Models','https://huggingface.co/ruvnet/wifi-densepose-pretrained'),('agents','AI toolkit','harness/ruview/README.md'),('home','Smart home','docs/integrations/home-assistant.md'),('seed','Cognitum','https://cognitum.one/seed')] for k,label,target in nav:save('nav-'+k,180,116,label,'Open '+label+'. Animated navigation icon.',icon(k,90,45,.65)+text(90,98,label,16,WHITE,'middle'),target) From a1ca023ee872683d160250d47ae3d4d228af55a0 Mon Sep 17 00:00:00 2001 From: rUv Date: Mon, 12 Oct 2026 21:04:27 -0700 Subject: [PATCH 23/23] Animate anatomical pose with opposing gait and perspective depth --- README.md | 4 +- assets/readme/feature-pose-spatial-v6.svg | 47 +++++++++++++++++++++++ assets/readme/hero-spatial-v6.svg | 47 +++++++++++++++++++++++ assets/readme/manifest.json | 4 +- docs/readme-visual-system.md | 13 +++++-- scripts/render-readme-visuals.py | 39 ++++++++++++++----- 6 files changed, 138 insertions(+), 16 deletions(-) create mode 100644 assets/readme/feature-pose-spatial-v6.svg create mode 100644 assets/readme/hero-spatial-v6.svg diff --git a/README.md b/README.md index 943918f5b..98c12d745 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@

-[![RuView: spaces become signals, with an animated conceptual RF field](assets/readme/hero-spatial-v5.svg)](docs/user-guide.md) +[![RuView: spaces become signals, with an animated conceptual RF field](assets/readme/hero-spatial-v6.svg)](docs/user-guide.md)
Breathing and vital signals: open documentation and current limitations
Breathing and vital signals
Pose research and limitations: open documentation and current limitations
Pose research and limitations
Pose research and limitations: open documentation and current limitations
Pose research and limitations
Edge sensor mesh: open documentation and current limitations
Edge sensor mesh
@@ -67,7 +67,7 @@ Every WiFi router already fills your space with radio waves. When people move, b - + diff --git a/assets/readme/feature-pose-spatial-v6.svg b/assets/readme/feature-pose-spatial-v6.svg new file mode 100644 index 000000000..9340c5627 --- /dev/null +++ b/assets/readme/feature-pose-spatial-v6.svg @@ -0,0 +1,47 @@ +Pose researchTrain and compare body keypoints. Live accuracy differs from benchmarks. Conceptual animation; follow the linked source for capability limits.RUVIEW / 03Pose researchTrain and compare body keypoints.Live accuracy differs from benchmarks.READ THE EVIDENCE diff --git a/assets/readme/hero-spatial-v6.svg b/assets/readme/hero-spatial-v6.svg new file mode 100644 index 000000000..55de901fc --- /dev/null +++ b/assets/readme/hero-spatial-v6.svg @@ -0,0 +1,47 @@ +RuView: invisible signals, visible possibilityAnimated conceptual RF room with an anatomical human silhouette, moving scan plane and propagating waves. Illustration only, not a sensor measurement.RUVIEW / SPATIAL INTELLIGENCEInvisible signals.Visible possibility.Explore the space between sensing and understanding.WiFi sensing. Edge models. Local automation.Enter the fieldRF SENSING / OPEN RESEARCH / EDGE COMPUTECONCEPTUAL RF FIELD diff --git a/assets/readme/manifest.json b/assets/readme/manifest.json index 65fbb38ff..fd65b4d1a 100644 --- a/assets/readme/manifest.json +++ b/assets/readme/manifest.json @@ -4,7 +4,7 @@ "motion": "Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies", "assets": [ { - "file": "assets/readme/hero-spatial-v5.svg", + "file": "assets/readme/hero-spatial-v6.svg", "title": "RuView: invisible signals, visible possibility", "target": "docs/user-guide.md", "width": 1200, @@ -67,7 +67,7 @@ "height": 276 }, { - "file": "assets/readme/feature-pose-spatial-v5.svg", + "file": "assets/readme/feature-pose-spatial-v6.svg", "title": "Pose research", "target": "docs/benchmarks/pose-estimation-cog.md", "width": 580, diff --git a/docs/readme-visual-system.md b/docs/readme-visual-system.md index f7d2f8050..a5235bf63 100644 --- a/docs/readme-visual-system.md +++ b/docs/readme-visual-system.md @@ -54,8 +54,15 @@ The overview and RF room are conceptual illustrations, not live measurements or Sources: `docs/user-guide.md`, `docs/benchmarks/pose-estimation-cog.md`, `v2/crates/wifi-densepose-signal/README.md`, `firmware/esp32-csi-node/README.md`, `docs/integrations/home-assistant.md`, and `harness/ruview/README.md`. -The hero and pose card use `-spatial-v5.svg`: an original anatomical human +The hero and pose card use `-spatial-v6.svg`: an original anatomical human silhouette with shaded torso, head, hands and feet, overlapping limbs, surface -contours and amber joint markers. Subtle limb motion pivots behind the torso -to preserve joint continuity. This is vector artwork, not a real body scan or +contours and amber joint markers. A 1.6-second gait drives opposing arm and leg swings, weight shifts and ground +shadow compression. An 8-second CSS perspective turn and moving surface light +give the anatomical illustration depth. Each limb pivots at its own shoulder +or hip. The effect remains a projected SVG illustration, not a skinned WebGL +model. The stylesheet disables gait and perspective movement for reduced motion. +Chrome 154 validation confirmed the 3D transform, opposing limb transforms, +animation in an HTML image, and a frozen standalone SVG under reduced motion. +Reduced-motion emulation did not freeze the embedded image in that test; image +preference propagation remains a browser-dependent limitation. This is vector artwork, not a real body scan or measured pose reconstruction. Other assets retain their v4 filenames. diff --git a/scripts/render-readme-visuals.py b/scripts/render-readme-visuals.py index 7a574bae9..7e36839f0 100644 --- a/scripts/render-readme-visuals.py +++ b/scripts/render-readme-visuals.py @@ -26,13 +26,13 @@ def icon(kind,x,y,scale=1): elif kind=='pose': # Authored anatomical mesh silhouette; a conceptual figure, not captured pose data. s+='' - s+='' + s+='' # Far arm and leg provide occlusion and depth. - s+='' - s+='' - s+='' + s+='' + s+='' + s+='' # Near arm: shoulder, upper arm, elbow, forearm and closed hand. - s+='' + s+='' # Shoulder and chest taper into abdomen and pelvis. s+='' # Three-quarter head with jaw, nose and ear contour. @@ -40,10 +40,12 @@ def icon(kind,x,y,scale=1): s+='' # Surface contours make volume legible at README scale. s+=path('M-9 -23Q-2 -18 10 -23M-7 -15Q0 -11 9 -16M-6 -8Q1 -5 8 -9M-7 1Q1 5 9 1M0 -26L2 9M-4 -40Q0 -42 7 -40M2 -32L6 -34',LIME,.55,extra='opacity=".65"') - s+=path('M-5 -45Q1 -49 6 -46M-10 -22L-6 -8M9 17L17 28M-12 20L-20 42',C,.7,extra='opacity=".8"') + s+=path('M-5 -45Q1 -49 6 -46M-10 -22L-6 -8',C,.7,extra='opacity=".8"') + s+='' # Joint markers remain attached to the anatomical surface. for px,py in [(-9,-24),(10,-23),(1,-12),(2,9)]: s+=circle(px,py,3.5,AMBER,extra='stroke-opacity=".25"')+circle(px,py,1.5,AMBER,extra=f'fill="{AMBER}" class="spark"') + s+='' s+=path('M-42 -42V-53H-30M30 -53H42V-42M42 45V55H30M-30 55H-42V45',BLUE,.8) elif kind in ['vitals','models']: for j in range(3): @@ -67,6 +69,9 @@ def icon(kind,x,y,scale=1): s+=circle(0,0,15,LIME)+path('M-6 0L-1 5L8 -6',LIME,2) else:s+=path('M-35 0L0 -25L35 0L0 25Z',C,2) base='' + if kind=='pose': + base='' + return f'{base}{s}' return f'{base}{s}' def spatial(x,y,scale=1): @@ -105,6 +110,22 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, @keyframes layer{50%{transform:translateY(-5px)}} @keyframes cursor{0%,100%{transform:translateX(-35px);opacity:.3}50%{transform:translateX(35px);opacity:1}} + +.humanTurn{transform-box:fill-box;transform-origin:center 85%;animation:humanTurn 8s ease-in-out infinite} +.humanBody{animation:humanBody 1.6s ease-in-out infinite} +.armFar{transform-origin:-9px -24px;animation:armSwing 1.6s ease-in-out infinite} +.armNear{transform-origin:10px -23px;animation:armSwing 1.6s ease-in-out infinite;animation-delay:-.8s} +.legFar{transform-origin:-3px 9px;animation:legSwing 1.6s ease-in-out infinite;animation-delay:-.8s} +.legNear{transform-origin:2px 9px;animation:legSwing 1.6s ease-in-out infinite} +.humanLight{animation:humanLight 8s ease-in-out infinite} +.gaitShadow{transform-box:fill-box;transform-origin:center;animation:gaitShadow 1.6s ease-in-out infinite} +@keyframes humanTurn{0%,100%{transform:perspective(450px) rotateY(-24deg) rotateZ(-2deg)}50%{transform:perspective(450px) rotateY(24deg) rotateZ(2deg)}} +@keyframes humanBody{0%,50%,100%{transform:translateY(0)}25%,75%{transform:translateY(-2px)}} +@keyframes armSwing{0%,100%{transform:rotate(-17deg)}50%{transform:rotate(17deg)}} +@keyframes legSwing{0%,100%{transform:rotate(-12deg)}50%{transform:rotate(12deg)}} +@keyframes humanLight{0%,100%{opacity:.1}50%{opacity:.45}} +@keyframes gaitShadow{0%,50%,100%{transform:scaleX(1);opacity:.7}25%,75%{transform:scaleX(.85);opacity:.45}} + @keyframes spark{50%{opacity:.25}} @keyframes flow{to{stroke-dashoffset:-136}} @keyframes breathe{50%{opacity:.4}} @@ -116,10 +137,10 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica, def frame(w,h,title,desc,body): value=_frame(w,h,title,desc,body) motion=MOTION - for name in ['float','flow','breathe','trace','scan','orbit','spark','wave','sweep','limb','stride','layer','cursor']: + for name in ['float','flow','breathe','trace','scan','orbit','spark','wave','sweep','limb','stride','layer','cursor','humanTurn','humanBody','armFar','armNear','legFar','legNear','humanLight','gaitShadow']: if f'class="{name}"' not in body: motion=re.sub(r'\.'+name+r'\{[^}]*\}', '', motion) - for name in ['float','flow','breathe','trace','scan','spark','wave','sweep','limb','layer','cursor']: + for name in ['float','flow','breathe','trace','scan','spark','wave','sweep','limb','layer','cursor','humanTurn','humanBody','armSwing','legSwing','humanLight','gaitShadow']: if f'animation:{name} ' not in motion: marker='@keyframes '+name+'{'; start=motion.find(marker) if start>=0: @@ -141,7 +162,7 @@ def frame(w,h,title,desc,body): manifest=[] def save(name,w,h,title,desc,body,target): - name+='-spatial-v5' if name in ['hero','feature-pose'] else '-spatial-v4' + name+='-spatial-v6' if name in ['hero','feature-pose'] else '-spatial-v4' s=frame(w,h,title,desc,body);raw=(s+'\n').encode('utf-8');parse_svg(raw);(OUT/(name+'.svg')).write_bytes(raw);manifest.append({'file':'assets/readme/'+name+'.svg','title':title,'target':target,'width':w,'height':h}) # Cinematic RF stage: authored projected planes, not a live measurement. s=text(48,45,'RUVIEW / SPATIAL INTELLIGENCE',12,C,extra='class="mono"')
Breathing and vital signals: open documentation and current limitations
Breathing and vital signals
Pose research and limitations: open documentation and current limitations
Pose research and limitations
Pose research and limitations: open documentation and current limitations
Pose research and limitations
Edge sensor mesh: open documentation and current limitations
Edge sensor mesh