Compare commits

...
Author SHA1 Message Date
rUv 0f95193190 Merge pull request #2248 from ruvnet/codex/toolkit-release-20261012
fix(toolkit): integrate capture/device corrections for 0.9.2
2026-10-12 09:54:03 -07:00
rUv 15fd5eebdc Merge pull request #2246 from ruvnet/codex/sensing-release-20261012
fix(calibration): finish failed sessions and reconcile decision diagnostics
2026-10-12 09:27:10 -07:00
ruv b064acf57c test(harness): cover Node 20 WebSocket capability boundary 2026-10-12 12:20:40 -04:00
rUv 4413491370 Merge pull request #2247 from ruvnet/codex/firmware-release-20261012
fix(firmware): correct identity and secure OTA provisioning for 0.8.14
2026-10-12 09:17:31 -07:00
ruv b265454b93 fix(remote): isolate Windows PowerShell module search paths 2026-10-12 12:09:26 -04:00
ruv 99d9851173 test(devices): align end-to-end medians with even-sample correction 2026-10-12 12:01:15 -04:00
ruv 720cfd7c8a fix(remote): replace explicit Windows ACL grants with current-user access 2026-10-12 11:56:13 -04:00
ruv 2c1222789d Merge remote-tracking branch 'origin/main' into codex/toolkit-release-20261012 2026-10-12 11:49:52 -04:00
ruv 3620abff12 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.
2026-10-12 11:49:52 -04:00
ruv a937a5fd20 fix(firmware): correct device identity and provision OTA keys for 0.8.14 2026-10-12 11:48:38 -04:00
ruv 9d863c3d22 fix(cli): report calibration persistence failures as aborted 2026-10-12 11:43:04 -04:00
mallesh-bot 99cea559f9 fix(cli): retain abort status after finalize failure 2026-10-12 11:39:41 -04:00
mallesh-bot 18d98d9452 fix(cli): abort incomplete calibration sessions 2026-10-12 11:39:40 -04:00
ruv 4eb991a101 Merge decision diagnostics with landed holdout explanations 2026-10-12 11:39:24 -04:00
rUv 1c435a0a79 Merge pull request #2190 from ruvnet/docs/cognitum-animated-visuals
docs: add Cognitum-style animated README visuals and linked capability cards
2026-10-12 08:38:54 -07:00
rUv 3c8f7ed96e Merge pull request #2189 from ruvnet/prototype/uncertainty-virtual-view
Add opt-in Dream Machine virtual-view research adapter
2026-10-12 08:38:50 -07:00
rUv 3b0b8d72df Merge pull request #2198 from ruvnet/codex/bootstrap-holdout-diagnostics-20261010
fix(sensing-server): explain bootstrap holdout failures
2026-10-12 08:38:07 -07:00
Dragan Spiridonov ce93244117 feat(sensing): integrate decision and holdout diagnostics for Mac qualification 2026-10-11 14:05:06 +02:00
ruv 8a140c1906 fix(docs): harden README visual XML and path validation 2026-10-10 13:41:26 -04:00
ruv f59fe9d7c2 fix(sensing-server): explain bootstrap holdout failures 2026-10-10 13:22:26 -04:00
rUv a71634ae8a Merge pull request #2192 from clonea1/contrib/pin-led-strip-idf54
fix(firmware): pin led_strip to 3.0.x; 3.1.0 breaks ESP-IDF 5.4 builds
2026-10-10 10:15:56 -07:00
JoeandClaude Opus 5.5 f1ff58afe7 fix(firmware): pin led_strip to 3.0.x; 3.1.0 breaks ESP-IDF 5.4 builds
led_strip 3.1.0~1 calls rmt_tx_switch_gpio (IDF >= 5.5 API), which is
undeclared on ESP-IDF 5.4 (src/led_strip_rmt_dev.c:189), so every
clean-checkout firmware build fails. Constrain to ~3.0.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:28:05 -04:00
rUv 4696804b23 docs: add Cognitum-style animated RuView visual system 2026-10-09 05:45:16 -07:00
159 changed files with 4702 additions and 436 deletions

No files matched your search

+10 -6
View File
@@ -504,7 +504,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
@@ -531,18 +531,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
+22 -2
View File
@@ -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/*
+44
View File
@@ -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)"
+16 -2
View File
@@ -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
+52 -2
View File
@@ -1,11 +1,31 @@
# π RuView
[![RuView: spaces become signals, with an animated conceptual RF field](assets/readme/hero.svg)](docs/user-guide.md)
<table>
<tr>
<td align="center" width="33%"><a href="docs/user-guide.md"><img src="assets/readme/nav-start.svg" width="180" alt="Start here" /></a><br/><a href="docs/user-guide.md">Start here</a></td>
<td align="center" width="33%"><a href="firmware/esp32-csi-node/README.md"><img src="assets/readme/nav-hardware.svg" width="180" alt="Hardware" /></a><br/><a href="firmware/esp32-csi-node/README.md">Hardware</a></td>
<td align="center" width="33%"><a href="https://huggingface.co/ruvnet/wifi-densepose-pretrained"><img src="assets/readme/nav-models.svg" width="180" alt="Models" /></a><br/><a href="https://huggingface.co/ruvnet/wifi-densepose-pretrained">Models</a></td>
</tr>
<tr>
<td align="center" width="33%"><a href="harness/ruview/README.md"><img src="assets/readme/nav-agents.svg" width="180" alt="AI toolkit" /></a><br/><a href="harness/ruview/README.md">AI toolkit</a></td>
<td align="center" width="33%"><a href="docs/integrations/home-assistant.md"><img src="assets/readme/nav-home.svg" width="180" alt="Smart home" /></a><br/><a href="docs/integrations/home-assistant.md">Smart home</a></td>
<td align="center" width="33%"><a href="https://cognitum.one/seed"><img src="assets/readme/nav-seed.svg" width="180" alt="Cognitum Seed" /></a><br/><a href="https://cognitum.one/seed">Cognitum Seed</a></td>
</tr>
</table>
<details>
<summary>Watch the original RuView sensing demo</summary>
<p align="center">
<a href="https://cognitum.one/seed">
<img src="assets/ruview-hero-h3-v3.gif" alt="RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi" width="100%">
</a>
</p>
</details>
<p align="center">
<a href="https://ruos.cognitum.one">
<img src="assets/ruos-animated.svg" alt="RuView — WiFi becomes spatial awareness, with Ruflo coordination and a ruOS sensing workspace" width="100%">
@@ -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
<!-- ruview-capability-cards -->
<table>
<tr>
<td width="50%"><a href="docs/user-guide.md"><img src="assets/readme/feature-presence.svg" width="580" alt="Presence and motion: open documentation and current limitations" /></a><br/><a href="docs/user-guide.md">Presence and motion</a></td>
<td width="50%"><a href="v2/crates/wifi-densepose-signal/README.md"><img src="assets/readme/feature-vitals.svg" width="580" alt="Breathing and vital signals: open documentation and current limitations" /></a><br/><a href="v2/crates/wifi-densepose-signal/README.md">Breathing and vital signals</a></td>
</tr>
<tr>
<td width="50%"><a href="docs/benchmarks/pose-estimation-cog.md"><img src="assets/readme/feature-pose.svg" width="580" alt="Pose research and limitations: open documentation and current limitations" /></a><br/><a href="docs/benchmarks/pose-estimation-cog.md">Pose research and limitations</a></td>
<td width="50%"><a href="firmware/esp32-csi-node/README.md"><img src="assets/readme/feature-edge.svg" width="580" alt="Edge sensor mesh: open documentation and current limitations" /></a><br/><a href="firmware/esp32-csi-node/README.md">Edge sensor mesh</a></td>
</tr>
<tr>
<td width="50%"><a href="docs/integrations/home-assistant.md"><img src="assets/readme/feature-automation.svg" width="580" alt="Local automation: open documentation and current limitations" /></a><br/><a href="docs/integrations/home-assistant.md">Local automation</a></td>
<td width="50%"><a href="harness/ruview/README.md"><img src="assets/readme/feature-agents.svg" width="580" alt="Guided AI operator: open documentation and current limitations" /></a><br/><a href="harness/ruview/README.md">Guided AI operator</a></td>
</tr>
</table>
<details>
<summary><strong>RuView MetaHarness</strong> — guided operation for humans and AI agents</summary>
@@ -133,8 +171,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).
@@ -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.
<details>
@@ -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.
<details>
@@ -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.
<details>
@@ -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.
<details>
@@ -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.
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="304" viewBox="0 0 1200 304" role="img" aria-labelledby="title desc"><title id="title">Explore Cognitum One</title><desc id="desc">Ambient Intelligence at the edge of the physical world. Open cognitum.one.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="1199" height="303" rx="18" fill="url(#wash)" stroke="#245360"/><text x="44" y="42" font-size="12" fill="#72eee0" text-anchor="start" class="mono">COGNITUM ONE</text><text x="44" y="99" font-size="42" fill="#ecfafb" text-anchor="start" >Ambient Intelligence.</text><text x="44" y="147" font-size="25" fill="#72eee0" text-anchor="start" >At the edge of the physical world.</text><text x="44" y="190" font-size="16" fill="#9abac5" text-anchor="start" >Explore the platform behind the ecosystem.</text><rect x="44" y="224" width="256" height="46" rx="11" fill="url(#accent)"/><text x="65" y="254" font-size="18" fill="#041117" text-anchor="start" >Explore Cognitum</text><path d="M263 247H284M277 240L284 247L277 254" fill="none" stroke="#041117" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="947" cy="151" r="173" fill="url(#halo)"/><g transform="translate(945 150) scale(2.4)"><path d="M0 -38L31 -20V18L0 38L-31 18V-20ZM-31 -20L0 -1L31 -20M0 -1V38" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-15 1L-5 11L17 -12" fill="none" stroke="#70b6ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/></g><ellipse cx="945" cy="150" rx="140" ry="30" transform="rotate(-25 945 150)" fill="none" stroke="#3c8897" stroke-dasharray="5 18" class="flow"/><ellipse cx="945" cy="150" rx="160" ry="43" transform="rotate(-25 945 150)" fill="none" stroke="#3c8897" stroke-dasharray="5 18" class="flow"/><ellipse cx="945" cy="150" rx="180" ry="56" transform="rotate(-25 945 150)" fill="none" stroke="#3c8897" stroke-dasharray="5 18" class="flow"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">A guided AI operator</title><desc id="desc">Source-cited setup and verification. CLI, MCP and agent workflows. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#245360"/><text x="30" y="39" font-size="11" fill="#72eee0" text-anchor="start" class="mono">RUVIEW / 06</text><text x="30" y="94" font-size="29" fill="#ecfafb" text-anchor="start" >A guided AI operator</text><text x="30" y="136" font-size="16" fill="#9abac5" text-anchor="start" >Source-cited setup and verification.</text><text x="30" y="164" font-size="16" fill="#9abac5" text-anchor="start" >CLI, MCP and agent workflows.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(474 168) scale(0.83)"><path d="M0 0L0 -33" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="0" cy="-33" r="7" stroke="#72eee0" fill="none" class="breathe"/><path d="M0 0L-35 23" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="-35" cy="23" r="7" stroke="#72eee0" fill="none" class="breathe"/><path d="M0 0L35 23" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="35" cy="23" r="7" stroke="#72eee0" fill="none" class="breathe"/><circle cx="0" cy="0" r="15" stroke="#72eee0" fill="none" /><path d="M-6 0L-1 5L8 -6" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g><path d="M30 204H550" fill="none" stroke="#245360" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#72eee0" text-anchor="start" class="mono">TOOLKIT GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Local automation</title><desc id="desc">Connect rooms to your smart home. Home Assistant, MQTT and Matter. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#245360"/><text x="30" y="39" font-size="11" fill="#72eee0" text-anchor="start" class="mono">RUVIEW / 05</text><text x="30" y="94" font-size="29" fill="#ecfafb" text-anchor="start" >Local automation</text><text x="30" y="136" font-size="16" fill="#9abac5" text-anchor="start" >Connect rooms to your smart home.</text><text x="30" y="164" font-size="16" fill="#9abac5" text-anchor="start" >Home Assistant, MQTT and Matter.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(474 168) scale(0.83)"><path d="M-37 -3L0 -34L37 -3M-28 -10V32H28V-10M-9 32V8H9V32" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-9" r="5" stroke="#70b6ff" fill="none" class="breathe"/></g><path d="M30 204H550" fill="none" stroke="#245360" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#72eee0" text-anchor="start" class="mono">INTEGRATION GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Edge sensor mesh</title><desc id="desc">Provision, capture and calibrate. Explore ESP32 firmware and runbooks. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#245360"/><text x="30" y="39" font-size="11" fill="#72eee0" text-anchor="start" class="mono">RUVIEW / 04</text><text x="30" y="94" font-size="29" fill="#ecfafb" text-anchor="start" >Edge sensor mesh</text><text x="30" y="136" font-size="16" fill="#9abac5" text-anchor="start" >Provision, capture and calibrate.</text><text x="30" y="164" font-size="16" fill="#9abac5" text-anchor="start" >Explore ESP32 firmware and runbooks.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(474 168) scale(0.83)"><rect x="-25" y="-25" width="50" height="50" rx="7" fill="#0a2630" stroke="#72eee0"/><rect x="-13" y="-13" width="26" height="26" rx="3" fill="none" stroke="#70b6ff"/><path d="M-16 -36V-25M-16 25V36M-36 -16H-25M25 -16H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M0 -36V-25M0 25V36M-36 0H-25M25 0H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16 -36V-25M16 25V36M-36 16H-25M25 16H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="0" r="4" stroke="#72eee0" fill="#72eee0" class="breathe"/></g><path d="M30 204H550" fill="none" stroke="#245360" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#72eee0" text-anchor="start" class="mono">HARDWARE GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Pose research</title><desc id="desc">Train and compare body keypoints. Live accuracy differs from benchmarks. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#245360"/><text x="30" y="39" font-size="11" fill="#72eee0" text-anchor="start" class="mono">RUVIEW / 03</text><text x="30" y="94" font-size="29" fill="#ecfafb" text-anchor="start" >Pose research</text><text x="30" y="136" font-size="16" fill="#9abac5" text-anchor="start" >Train and compare body keypoints.</text><text x="30" y="164" font-size="16" fill="#9abac5" text-anchor="start" >Live accuracy differs from benchmarks.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(474 168) scale(0.83)"><circle cx="0" cy="-28" r="8" stroke="#72eee0" fill="none" /><path d="M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-14" r="3" stroke="#70b6ff" fill="#041117" class="breathe"/><circle cx="0" cy="12" r="3" stroke="#70b6ff" fill="#041117" class="breathe"/><circle cx="-24" cy="-6" r="3" stroke="#70b6ff" fill="#041117" class="breathe"/><circle cx="24" cy="-6" r="3" stroke="#70b6ff" fill="#041117" class="breathe"/><circle cx="-18" cy="38" r="3" stroke="#70b6ff" fill="#041117" class="breathe"/><circle cx="18" cy="38" r="3" stroke="#70b6ff" fill="#041117" class="breathe"/></g><path d="M30 204H550" fill="none" stroke="#245360" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#72eee0" text-anchor="start" class="mono">READ THE EVIDENCE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Presence &amp; motion</title><desc id="desc">Explore changes in room occupancy. Inspect motion features from CSI. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#245360"/><text x="30" y="39" font-size="11" fill="#72eee0" text-anchor="start" class="mono">RUVIEW / 01</text><text x="30" y="94" font-size="29" fill="#ecfafb" text-anchor="start" >Presence &amp; motion</text><text x="30" y="136" font-size="16" fill="#9abac5" text-anchor="start" >Explore changes in room occupancy.</text><text x="30" y="164" font-size="16" fill="#9abac5" text-anchor="start" >Inspect motion features from CSI.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(474 168) scale(0.83)"><circle cx="0" cy="-28" r="8" stroke="#72eee0" fill="none" /><path d="M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><ellipse cx="0" cy="14" rx="40" ry="13" fill="none" stroke="#70b6ff" stroke-dasharray="3 8" class="flow"/></g><path d="M30 204H550" fill="none" stroke="#245360" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#72eee0" text-anchor="start" class="mono">SENSING GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="580" height="276" viewBox="0 0 580 276" role="img" aria-labelledby="title desc"><title id="title">Breathing &amp; vital signals</title><desc id="desc">Inspect phase-derived signal trends. Validate estimates on your hardware. Conceptual animation; follow the linked source for capability limits.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#245360"/><text x="30" y="39" font-size="11" fill="#72eee0" text-anchor="start" class="mono">RUVIEW / 02</text><text x="30" y="94" font-size="29" fill="#ecfafb" text-anchor="start" >Breathing &amp; vital signals</text><text x="30" y="136" font-size="16" fill="#9abac5" text-anchor="start" >Inspect phase-derived signal trends.</text><text x="30" y="164" font-size="16" fill="#9abac5" text-anchor="start" >Validate estimates on your hardware.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(474 168) scale(0.83)"><path d="M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/><path d="M-42 31H43" fill="none" stroke="#214451" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></g><path d="M30 204H550" fill="none" stroke="#245360" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#72eee0" text-anchor="start" class="mono">SIGNAL PIPELINE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">Choose your next step</title><desc id="desc">Animated section heading. GUIDES / HARDWARE / INTEGRATIONS</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(76 75) scale(0.75)"><path d="M0 0L0 -33" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="0" cy="-33" r="7" stroke="#72eee0" fill="none" class="breathe"/><path d="M0 0L-35 23" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="-35" cy="23" r="7" stroke="#72eee0" fill="none" class="breathe"/><path d="M0 0L35 23" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="35" cy="23" r="7" stroke="#72eee0" fill="none" class="breathe"/><circle cx="0" cy="0" r="15" stroke="#72eee0" fill="none" /><path d="M-6 0L-1 5L8 -6" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g><text x="140" y="63" font-size="34" fill="#ecfafb" text-anchor="start" >Choose your next step</text><text x="142" y="99" font-size="12" fill="#72eee0" text-anchor="start" class="mono">GUIDES / HARDWARE / INTEGRATIONS</text><path d="M865 44Q960 10 1124 52" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.65"/><path d="M865 61Q960 38 1124 69" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.55"/><path d="M865 78Q960 66 1124 86" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.45"/><path d="M865 95Q960 94 1124 103" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.35"/><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">Intelligence at the edge</title><desc id="desc">Animated section heading. SENSORS / FIRMWARE / MODULES</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(76 75) scale(0.75)"><rect x="-25" y="-25" width="50" height="50" rx="7" fill="#0a2630" stroke="#72eee0"/><rect x="-13" y="-13" width="26" height="26" rx="3" fill="none" stroke="#70b6ff"/><path d="M-16 -36V-25M-16 25V36M-36 -16H-25M25 -16H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M0 -36V-25M0 25V36M-36 0H-25M25 0H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16 -36V-25M16 25V36M-36 16H-25M25 16H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="0" r="4" stroke="#72eee0" fill="#72eee0" class="breathe"/></g><text x="140" y="63" font-size="34" fill="#ecfafb" text-anchor="start" >Intelligence at the edge</text><text x="142" y="99" font-size="12" fill="#72eee0" text-anchor="start" class="mono">SENSORS / FIRMWARE / MODULES</text><path d="M865 44Q960 10 1124 52" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.65"/><path d="M865 61Q960 38 1124 69" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.55"/><path d="M865 78Q960 66 1124 86" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.45"/><path d="M865 95Q960 94 1124 103" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.35"/><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">Evidence before accuracy claims</title><desc id="desc">Animated section heading. BENCHMARKS / REPRODUCERS / LIMITS</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(76 75) scale(0.75)"><path d="M0 -38L31 -20V18L0 38L-31 18V-20ZM-31 -20L0 -1L31 -20M0 -1V38" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-15 1L-5 11L17 -12" fill="none" stroke="#70b6ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/></g><text x="140" y="63" font-size="34" fill="#ecfafb" text-anchor="start" >Evidence before accuracy claims</text><text x="142" y="99" font-size="12" fill="#72eee0" text-anchor="start" class="mono">BENCHMARKS / REPRODUCERS / LIMITS</text><path d="M865 44Q960 10 1124 52" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.65"/><path d="M865 61Q960 38 1124 69" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.55"/><path d="M865 78Q960 66 1124 86" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.45"/><path d="M865 95Q960 94 1124 103" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.35"/><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">Explore the capabilities</title><desc id="desc">Animated section heading. SENSING / MODELS / AUTOMATION</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(76 75) scale(0.75)"><circle cx="0" cy="-28" r="8" stroke="#72eee0" fill="none" /><path d="M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><ellipse cx="0" cy="14" rx="40" ry="13" fill="none" stroke="#70b6ff" stroke-dasharray="3 8" class="flow"/></g><text x="140" y="63" font-size="34" fill="#ecfafb" text-anchor="start" >Explore the capabilities</text><text x="142" y="99" font-size="12" fill="#72eee0" text-anchor="start" class="mono">SENSING / MODELS / AUTOMATION</text><path d="M865 44Q960 10 1124 52" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.65"/><path d="M865 61Q960 38 1124 69" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.55"/><path d="M865 78Q960 66 1124 86" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.45"/><path d="M865 95Q960 94 1124 103" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.35"/><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="148" viewBox="0 0 1200 148" role="img" aria-labelledby="title desc"><title id="title">From recordings to models</title><desc id="desc">Animated section heading. CAPTURE / TRAIN / EVALUATE</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(76 75) scale(0.75)"><path d="M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/><path d="M-42 31H43" fill="none" stroke="#214451" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></g><text x="140" y="63" font-size="34" fill="#ecfafb" text-anchor="start" >From recordings to models</text><text x="142" y="99" font-size="12" fill="#72eee0" text-anchor="start" class="mono">CAPTURE / TRAIN / EVALUATE</text><path d="M865 44Q960 10 1124 52" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.65"/><path d="M865 61Q960 38 1124 69" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.55"/><path d="M865 78Q960 66 1124 86" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.45"/><path d="M865 95Q960 94 1124 103" fill="none" stroke="#72eee0" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity="0.35"/><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="450" viewBox="0 0 1200 450" role="img" aria-labelledby="title desc"><title id="title">RuView: spaces become signals</title><desc id="desc">Animated conceptual RF field and room geometry. Explore the RuView user guide. This illustration is not a sensor measurement.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="1199" height="449" rx="18" fill="url(#wash)" stroke="#245360"/><text x="48" y="44" font-size="13" fill="#72eee0" text-anchor="start" class="mono">RUVIEW / COGNITUM ECOSYSTEM</text><text x="48" y="121" font-size="76" fill="#ecfafb" text-anchor="start" >RuView</text><text x="48" y="184" font-size="38" fill="#ecfafb" text-anchor="start" >Spaces become signals.</text><text x="48" y="234" font-size="32" fill="#72eee0" text-anchor="start" >Signals become understanding.</text><text x="50" y="279" font-size="18" fill="#9abac5" text-anchor="start" >Explore RF sensing, edge inference and local automation.</text><rect x="48" y="326" width="270" height="52" rx="12" fill="url(#accent)"/><text x="72" y="359" font-size="20" fill="#041117" text-anchor="start" >Explore the guide</text><path d="M272 352H292M285 345L292 352L285 359" fill="none" stroke="#041117" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><text x="49" y="419" font-size="11" fill="#9abac5" text-anchor="start" class="mono">RF SENSING / EDGE MODELS / LOCAL ACTION</text><circle cx="950" cy="235" r="235" fill="url(#halo)"/><path d="M750 282L610 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M790 282L690 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M830 282L770 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M870 282L850 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M910 282L930 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M950 282L1010 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M990 282L1090 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M1030 282L1170 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M1070 282L1250 443" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M680 306H1168" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M680 336H1168" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M680 372H1168" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M680 418H1168" fill="none" stroke="#12343d" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M803 157L1008 106L1137 177L930 236ZM803 157V314L930 391L1137 324V177M930 236V391M1008 106V268L803 314M1008 268L1137 324" fill="none" stroke="#4b9ba4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><g transform="translate(976 265) scale(1.35)"><circle cx="0" cy="-28" r="8" stroke="#72eee0" fill="none" /><path d="M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><ellipse cx="0" cy="14" rx="40" ry="13" fill="none" stroke="#70b6ff" stroke-dasharray="3 8" class="flow"/></g><g transform="translate(780 349) scale(0.7)"><rect x="-25" y="-25" width="50" height="50" rx="7" fill="#0a2630" stroke="#72eee0"/><rect x="-13" y="-13" width="26" height="26" rx="3" fill="none" stroke="#70b6ff"/><path d="M-16 -36V-25M-16 25V36M-36 -16H-25M25 Line truncated

After

Width:  |  Height:  |  Size: 6.8 KiB

+147
View File
@@ -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
}
]
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">AI toolkit</title><desc id="desc">Open AI toolkit. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(90 45) scale(0.65)"><path d="M0 0L0 -33" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="0" cy="-33" r="7" stroke="#72eee0" fill="none" class="breathe"/><path d="M0 0L-35 23" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="-35" cy="23" r="7" stroke="#72eee0" fill="none" class="breathe"/><path d="M0 0L35 23" fill="none" stroke="#70b6ff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="35" cy="23" r="7" stroke="#72eee0" fill="none" class="breathe"/><circle cx="0" cy="0" r="15" stroke="#72eee0" fill="none" /><path d="M-6 0L-1 5L8 -6" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g><text x="90" y="98" font-size="16" fill="#ecfafb" text-anchor="middle" >AI toolkit</text></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Hardware</title><desc id="desc">Open Hardware. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(90 45) scale(0.65)"><rect x="-25" y="-25" width="50" height="50" rx="7" fill="#0a2630" stroke="#72eee0"/><rect x="-13" y="-13" width="26" height="26" rx="3" fill="none" stroke="#70b6ff"/><path d="M-16 -36V-25M-16 25V36M-36 -16H-25M25 -16H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M0 -36V-25M0 25V36M-36 0H-25M25 0H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M16 -36V-25M16 25V36M-36 16H-25M25 16H36" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="0" r="4" stroke="#72eee0" fill="#72eee0" class="breathe"/></g><text x="90" y="98" font-size="16" fill="#ecfafb" text-anchor="middle" >Hardware</text></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Smart home</title><desc id="desc">Open Smart home. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(90 45) scale(0.65)"><path d="M-37 -3L0 -34L37 -3M-28 -10V32H28V-10M-9 32V8H9V32" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-9" r="5" stroke="#70b6ff" fill="none" class="breathe"/></g><text x="90" y="98" font-size="16" fill="#ecfafb" text-anchor="middle" >Smart home</text></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Models</title><desc id="desc">Open Models. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(90 45) scale(0.65)"><path d="M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/><path d="M-42 31H43" fill="none" stroke="#214451" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></g><text x="90" y="98" font-size="16" fill="#ecfafb" text-anchor="middle" >Models</text></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Cognitum</title><desc id="desc">Open Cognitum. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(90 45) scale(0.65)"><path d="M0 -38L31 -20V18L0 38L-31 18V-20ZM-31 -20L0 -1L31 -20M0 -1V38" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-15 1L-5 11L17 -12" fill="none" stroke="#70b6ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/></g><text x="90" y="98" font-size="16" fill="#ecfafb" text-anchor="middle" >Cognitum</text></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="116" viewBox="0 0 180 116" role="img" aria-labelledby="title desc"><title id="title">Start here</title><desc id="desc">Open Start here. Animated navigation icon.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#245360"/><g transform="translate(90 45) scale(0.65)"><path d="M-10.4 -5.2Q0 -18.2 10.4 -5.2" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-0.65s"/><path d="M-20.0 -10.0Q0 -35.0 20.0 -10.0" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-1.25s"/><path d="M-30.400000000000002 -15.200000000000001Q0 -53.199999999999996 30.400000000000002 -15.200000000000001" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-1.9s"/><circle cx="0" cy="20" r="4" stroke="#72eee0" fill="#72eee0"/><path d="M0 12V-1" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g><text x="90" y="98" font-size="16" fill="#ecfafb" text-anchor="middle" >Start here</text></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="396" viewBox="0 0 1200 396" role="img" aria-labelledby="title desc"><title id="title">RuView sensing overview</title><desc id="desc">Conceptual sequence: capture RF, extract context, act locally. Links to architecture and user documentation.</desc><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#0b303a"/><stop offset=".55" stop-color="#06171e"/><stop offset="1" stop-color="#030c13"/></linearGradient><linearGradient id="accent"><stop stop-color="#91f6df"/><stop offset="1" stop-color="#26bad7"/></linearGradient><radialGradient id="halo"><stop stop-color="#35d4db" stop-opacity=".17"/><stop offset="1" stop-color="#35d4db" stop-opacity="0"/></radialGradient></defs><style>text{font-family:Arial,Helvetica,sans-serif}.mono{font-family:ui-monospace,Consolas,monospace;letter-spacing:2px}.flow{stroke-dasharray:5 12;animation:flow 12s linear infinite}.breathe{animation:breathe 6s ease-in-out infinite}.trace{stroke-dasharray:400;stroke-dashoffset:0;animation:trace 10s ease-in-out infinite}.float{animation:float 9s ease-in-out infinite}@keyframes flow{to{stroke-dashoffset:-136}}@keyframes breathe{50%{opacity:.4}}@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}@keyframes float{50%{transform:translateY(-6px)}}@media(prefers-reduced-motion:reduce){*{animation:none!important}}</style><rect x=".5" y=".5" width="1199" height="395" rx="18" fill="url(#wash)" stroke="#245360"/><text x="36" y="44" font-size="12" fill="#72eee0" text-anchor="start" class="mono">SIGNAL TO CONTEXT</text><text x="36" y="89" font-size="34" fill="#ecfafb" text-anchor="start" >A sensing loop you can inspect.</text><rect x="36" y="120" width="336" height="220" rx="14" fill="#071b23" stroke="#285360"/><text x="60" y="153" font-size="12" fill="#72eee0" text-anchor="start" >01</text><text x="60" y="188" font-size="26" fill="#ecfafb" text-anchor="start" >Capture RF</text><text x="60" y="216" font-size="15" fill="#9abac5" text-anchor="start" >CSI from supported hardware</text><g transform="translate(305 281) scale(0.62)"><path d="M-10.4 -5.2Q0 -18.2 10.4 -5.2" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-0.65s"/><path d="M-20.0 -10.0Q0 -35.0 20.0 -10.0" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-1.25s"/><path d="M-30.400000000000002 -15.200000000000001Q0 -53.199999999999996 30.400000000000002 -15.200000000000001" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="breathe" style="animation-delay:-1.9s"/><circle cx="0" cy="20" r="4" stroke="#72eee0" fill="#72eee0"/><path d="M0 12V-1" fill="none" stroke="#72eee0" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></g><text x="60" y="311" font-size="13" fill="#9abac5" text-anchor="start" >Provision and calibrate</text><path d="M380 229H420M412 221L420 229L412 237" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flow"/><rect x="432" y="120" width="336" height="220" rx="14" fill="#071b23" stroke="#285360"/><text x="456" y="153" font-size="12" fill="#72eee0" text-anchor="start" >02</text><text x="456" y="188" font-size="26" fill="#ecfafb" text-anchor="start" >Extract context</text><text x="456" y="216" font-size="15" fill="#9abac5" text-anchor="start" >Signal processing and models</text><g transform="translate(701 281) scale(0.62)"><path d="M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="trace"/><path d="M-42 31H43" fill="none" stroke="#214451" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /></g><text x="456" y="311" font-size="13" fill="#9abac5" text-anchor="start" >Compare against real data</text><path d="M776 229H816M808 221L816 229L808 237" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="flow"/><rect x="828" y="120" width="336" height="220" rx="14" fill="#071b23" stroke="#285360"/><text x="852" y="153" font-size="12" fill="#72eee0" text-anchor="start" >03</text><text x="852" y="188" font-size="26" fill="#ecfafb" text-anchor="start" >Act locally</text><text x="852" y="216" font-size="15" fill="#9abac5" text-anchor="start" >Events, APIs and integrations</text><g transform="translate(1097 281) scale(0.62)"><path d="M-37 -3L0 -34L37 -3M-28 -10V32H28V-10M-9 32V8H9V32" fill="none" stroke="#72eee0" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-9" r="5" stroke="#70b6ff" fill="none" class="breathe"/></g><text x="852" y="311" font-size="13" fill="#9abac5" text-anchor="start" >Apply rules and review outputs</text><text x="36" y="373" font-size="11" fill="#9abac5" text-anchor="start" class="mono">CONCEPTUALine truncated

After

Width:  |  Height:  |  Size: 4.9 KiB

+8 -6
View File
@@ -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 <node-ip-or-cidr>` (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 <host>: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
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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
```
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+4 -5
View File
@@ -153,14 +153,11 @@ Per ADR-115 §3.11.1, the Matter Bridge exposes a subset on standard clusters so
| `--mqtt-rate-pose <HZ>` | 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 <DIR>` | `data` | Holds `mqtt_client_id` (and other server state) |
| `--matter` | off | Enable the HA-FABRIC Matter Bridge |
| `--matter-setup-file <PATH>` | — | Where to write the QR + manual code |
| `--matter-reset` | off | Wipe fabric credentials and re-commission |
| `--matter-vendor-id <VID>` | `0xFFF1` (dev) | CSA-assigned vendor ID |
| `--matter-product-id <PID>` | `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".
+65 -65
View File
@@ -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.
</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.
</details>
@@ -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) |
</details>
@@ -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) |
</details>
@@ -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) |
</details>
@@ -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) |
</details>
@@ -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.
</details>
<details>
<summary><strong>🔍 Independent Capability Audit (ADR-028)</strong> — 1,031 tests, SHA-256 proof, self-verifying witness bundle</summary>
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 |
</details>
@@ -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).
</details>
@@ -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).
</details>
@@ -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).
</details>
@@ -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)
</details>
@@ -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.
</details>
@@ -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).
</details>
@@ -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)
</details>
@@ -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.
</details>
<details>
<summary><a id="ruv-neural"></a><strong>🧠 rUv Neural</strong> — Brain topology analysis ecosystem for neural decoding and medical sensing</summary>
[**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.
</details>
@@ -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` |
</details>
@@ -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.
</details>
@@ -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.
</details>
@@ -1657,10 +1657,10 @@ wifi-densepose tasks list # List background tasks
<details>
<summary><strong>Documentation Links</strong></summary>
- [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)
</details>
+36
View File
@@ -0,0 +1,36 @@
# 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 -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
```
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
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`.
+61 -39
View File
@@ -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 <node-ip-or-cidr>
# 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 <node-ip-or-cidr>
```
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 <node-ip-or-cidr> \
--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 <node-ip-or-cidr>
# 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 <node-ip-or-cidr>
```
Progressive loading enables instant startup (Layer A loads in <5ms with basic inference), with full model loading in the background.
@@ -1911,31 +1918,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,18 +1979,11 @@ 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):**
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 +2129,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 <node-ip-or-cidr>
# 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 +2399,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/<recording>.csi.jsonl
```
> **Note:** These are pre-screening tools, not medical devices. Consult a healthcare professional for diagnosis.
@@ -2837,7 +2858,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 <node-ip-or-cidr>` 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
+83 -13
View File
@@ -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 |
---
@@ -10,4 +10,5 @@ dependencies:
espressif/esp_lcd_touch: "^1.0"
## Onboard WS2812 LED Disabling
espressif/led_strip: "^3.0.0"
# 3.1.0 needs the IDF >= 5.5 API rmt_tx_switch_gpio; stay on 3.0.x for IDF 5.4
espressif/led_strip: "~3.0.3"
@@ -0,0 +1,39 @@
#ifndef RUVIEW_ONBOARDING_IDENTITY_H
#define RUVIEW_ONBOARDING_IDENTITY_H
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#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
+3 -3
View File
@@ -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 <hex>` over USB-CDC without
* the operator can `provision.py --ota-psk-file <path>` 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 <hex>)");
ESP_LOGW(TAG, "OTA rejected: no PSK in NVS (run provision.py --ota-psk-file <path>)");
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 <hex>). Fail-closed per RuView#596.");
"provisioned (provision.py --ota-psk-file <path>). Fail-closed per RuView#596.");
}
nvs_close(nvs);
} else {
@@ -1,6 +1,7 @@
#include "serial_onboarding.h"
#include "serial_onboarding_protocol.h"
#include "onboarding_identity.h"
#include <errno.h>
#include <fcntl.h>
@@ -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 "
+91 -3
View File
@@ -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"))
@@ -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.
@@ -0,0 +1 @@
0.8.12
+9 -3
View File
@@ -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/
@@ -0,0 +1,8 @@
#ifndef RUVIEW_TEST_ESP_MAC_H
#define RUVIEW_TEST_ESP_MAC_H
#include <stdint.h>
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
@@ -0,0 +1,13 @@
#ifndef RUVIEW_TEST_PSA_CRYPTO_H
#define RUVIEW_TEST_PSA_CRYPTO_H
#include <stddef.h>
#include <stdint.h>
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
@@ -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])
@@ -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 <assert.h>
#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;
}
@@ -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()
+1 -1
View File
@@ -1 +1 @@
0.8.12
0.8.14
+19 -19
View File
@@ -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/remote.js": "ea433cf719729963a50ebede9442136e72f1eff145c0b3c5e837c6c56fa1ccdf",
"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": "edc6be28115ad21f18954066e989a6b4aaae2137b2f4e6d6571312beef610288",
"brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
"gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd",
"developmentPins": {
+1 -1
View File
@@ -1 +1 @@
0f73608c5acd0547ef850c0944ef76633a40e957c226ce555309832f87491a1c manifest.json
0517e22827b8acc7d8550e1e560e4f13ca79397977c52c30cf23770dead2327a manifest.json
+2 -1
View File
@@ -309,7 +309,8 @@ Clients that can send headers use `Authorization: Bearer <token>` on
`http://127.0.0.1:8790/mcp`. ChatGPT connectors cannot, so expose the port over
HTTPS (a tunnel or reverse proxy) and add `https://<host>/mcp/<token>` as the
connector URL. Write grants (`hardware-write`, `workspace-write`) are never
honoured over HTTP: flashing and calibration stay on the CLI and stdio.
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.
+2 -2
View File
@@ -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"
+2 -2
View File
@@ -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"
+4 -3
View File
@@ -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. */
+11 -3
View File
@@ -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;
}
+48 -15
View File
@@ -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) };
+12 -7
View File
@@ -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;
+8 -3
View File
@@ -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.
};
});
}
+12 -3
View File
@@ -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)',
};
}
+4 -1
View File
@@ -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 } : {}) };
}
+23 -7
View File
@@ -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) || '';
+4 -3
View File
@@ -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(' | ')})`);
+4 -1
View File
@@ -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);
+29 -4
View File
@@ -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,41 @@ 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, env = process.env } = {}) {
if (platform !== 'win32') return 'posix-0600';
const r = exec('icacls', [path, '/inheritance:r', '/grant:r', `${user()}:F`], { encoding: 'utf8', windowsHide: true });
// 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 }), 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 (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';
}
+4 -2
View File
@@ -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<ToolResult>;
mmwave(args: { port: string; model?: 'auto' | 'mr60bha2' | 'ld2410'; seconds?: number }): Promise<ToolResult>;
lidar(args: { source: 'rplidar'; port: string; baud?: 115200 | 256000 | 460800 | 921600; seconds?: number } | { source: 'iphone'; url: string; seconds?: number }): Promise<ToolResult>;
mmwave(args: { source?: 'serial'; port: string; model?: 'auto' | 'mr60bha2' | 'ld2410'; seconds?: number } | { source: 'esphome'; host: string; api_port?: number; seconds?: number }): Promise<ToolResult>;
lidar(args: { source: 'rplidar'; port: string; baud?: 115200 | 256000 | 460800 | 921600; seconds?: number } | { source: 'iphone'; url: string; seconds?: number; max_frames?: number }): Promise<ToolResult>;
};
hosts: {
list(): Promise<ToolResult>;
+4 -4
View File
@@ -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),
+1 -1
View File
@@ -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()',
+3 -2
View File
@@ -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.');
}
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more