Compare commits

...
24 Commits
Author SHA1 Message Date
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 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 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 d779961a55 fix(virtual-view): validate bounded plain data before broker calls 2026-10-10 13:29:51 -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
rUv 88daeb8cf2 Add virtual-view prototype: integrations/virtual-view/README.md 2026-10-09 05:40:33 -07:00
rUv f8750a4a50 Add virtual-view prototype: integrations/virtual-view/adapter.test.mjs 2026-10-09 05:40:32 -07:00
rUv 2c7a0194f4 Add virtual-view prototype: integrations/virtual-view/adapter.mjs 2026-10-09 05:40:30 -07:00
rUv 0ef6b96fe1 docs: describe RuView-focused README animation 2026-10-06 09:25:36 -07:00
rUv 2d5472372f docs: focus animation on RuView sensing and spatial awareness 2026-10-06 09:25:34 -07:00
rUv efdf97ecc1 docs: add ruOS animation below RuView header 2026-10-06 09:12:38 -07:00
rUv 46d80ffc3a docs: upload Ruflo-powered ruOS desktop animation 2026-10-06 09:12:36 -07:00
56 changed files with 2163 additions and 87 deletions

No files matched your search

+3
View File
@@ -206,6 +206,9 @@ jobs:
- name: Run UI unit tests
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs ui/services/sensing.service.test.mjs ui/components/DashboardTab.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
- name: Run virtual-view adapter boundary tests
run: node --test integrations/virtual-view/adapter.test.mjs
# Unit and Integration Tests
# Python pytest matrix — runs against the archived v1 Python tree.
# `continue-on-error: true` for the same reason as code-quality above:
+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)"
+56
View File
@@ -1,11 +1,37 @@
# π 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%">
</a>
</p>
## **See through walls with WiFi** ##
@@ -21,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
@@ -37,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>
@@ -257,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>
@@ -311,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>
@@ -485,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:
```
@@ -609,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>
@@ -738,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>
@@ -779,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

+87
View File
@@ -0,0 +1,87 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 960 540" role="img" aria-labelledby="title desc">
<title id="title">RuView · WiFi becomes spatial awareness.</title><desc id="desc">RuView-focused animation showing WiFi sensors, spatial perception, Ruflo coordination and an illustrative ruOS sensing workspace. Room occupancy and actions are synthetic examples, not live measurements.</desc>
<defs><radialGradient id="bg"><stop stop-color="#151518" /><stop offset="1" stop-color="#07080b" /></radialGradient><radialGradient id="aura"><stop stop-color="#edc65a" stop-opacity=".23" /><stop offset=".4" stop-color="#ba8823" stop-opacity=".07" /><stop offset="1" stop-color="#ba8823" stop-opacity="0" /></radialGradient><linearGradient id="gold" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#fff4b1" /><stop offset=".5" stop-color="#ebc456" /><stop offset="1" stop-color="#96702f" /></linearGradient><linearGradient id="panel" x2="0" y2="1"><stop stop-color="#191a20" /><stop offset="1" stop-color="#0d0e13" /></linearGradient><pattern id="grid" width="36" height="36" patternUnits="userSpaceOnUse"><circle cx="18" cy="18" r=".65" fill="#d4c78e" opacity=".22" /></pattern><filter id="bloom" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="4" /></filter><clipPath id="frame"><rect width="960" height="540" rx="18" /></clipPath><clipPath id="line1"><rect x="64" y="155" width="850" height="93" /></clipPath><clipPath id="line2"><rect x="64" y="244" width="850" height="100" /></clipPath><g id="crown" fill="url(#gold)"><path d="M0 5l8 7L15 0l7 12 8-7-4 23H4z" /><rect x="4" y="31" width="22" height="3" rx="1" /></g>
<style>
text{font-family:Arial,Helvetica,sans-serif;fill:#f4f2ed}.mono{font-family:monospace}.muted{fill:#888993}.gold{fill:#e8c565}.scene{opacity:0}.end{opacity:1}.wire{fill:none;stroke:#c2a155;stroke-width:1}.all{animation-duration:18s;animation-iteration-count:infinite;animation-timing-function:cubic-bezier(.22,.65,.3,1)}
@keyframes intro{0%,17%{opacity:1;transform:translateY(0)}22%,94%{opacity:0;transform:translateY(-12px)}100%{opacity:1;transform:translateY(0)}}
@keyframes network{0%,20%{opacity:0;transform:translateY(18px) scale(.98)}25%,44%{opacity:1;transform:translateY(0) scale(1)}49%,100%{opacity:0;transform:translateY(-15px) scale(1.02)}}
@keyframes work{0%,46%{opacity:0;transform:translateY(18px)}51%,66%{opacity:1;transform:translateY(0)}71%,100%{opacity:0;transform:translateY(-14px)}}
@keyframes finale{0%,69%{opacity:0}74%,92%{opacity:1}97%,100%{opacity:0}}
.intro{animation-name:intro}.network{animation-name:network;transform-origin:480px 270px}.work{animation-name:work}.end{animation-name:finale}
@keyframes reveal1{0%,69%{transform:translateY(100px)}77%,100%{transform:translateY(0)}}@keyframes reveal2{0%,73%{transform:translateY(110px)}81%,100%{transform:translateY(0)}}.headline1{animation-name:reveal1}.headline2{animation-name:reveal2}
@keyframes breathe{50%{opacity:.6;transform:scale(1.12)}}.breathe{animation:breathe 4.5s ease-in-out infinite;transform-origin:480px 239px}
@keyframes spin{to{transform:rotate(360deg)}}.orbit{animation:spin 18s linear infinite;transform-origin:480px 239px}
@keyframes send{0%,9%{transform:translate(0,0)}14%,100%{transform:translate(-14px,-19px)}}.cursor{animation-name:send}
@keyframes type{0%,2%{clip-path:inset(0 100% 0 0)}12%,100%{clip-path:inset(0 0 0 0)}}.typing{animation-name:type;animation-timing-function:steps(26,end)}
@keyframes task{0%,49%{transform:translateY(25px);opacity:0}55%,100%{transform:translateY(0);opacity:1}}.task{animation-name:task}.task2{animation-delay:.3s}.task3{animation-delay:.6s}
@keyframes grow{0%,54%{transform:scaleX(0)}63%,100%{transform:scaleX(1)}}.grow{animation-name:grow;transform-box:fill-box;transform-origin:left}
@keyframes tick{0%,62%{opacity:0}65%,100%{opacity:1}}.tick{animation-name:tick}
@keyframes trace{0%,22%{stroke-dashoffset:1}38%,100%{stroke-dashoffset:0}}.trace{stroke-dasharray:1;animation-name:trace}
@keyframes drift{50%{transform:translateY(-10px);opacity:.8}}.dust{animation:drift 9s ease-in-out infinite}
@media(prefers-reduced-motion:reduce){*{animation:none!important}.scene{opacity:0}.end{opacity:1}.moving{display:none}}
@keyframes signal{0%,13%{transform:translate(480px,205px)}20%{transform:translate(708px,301px)}26%,43%{transform:translate(480px,239px)}49%{transform:translate(215px,155px)}55%{transform:translate(479px,155px)}61%{transform:translate(743px,155px)}72%,93%{transform:translate(782px,307px)}100%{transform:translate(480px,205px)}}
.signal{animation-name:signal}.signal-glow{animation:shimmer 3s ease-in-out infinite}@keyframes shimmer{50%{opacity:.45}}
@keyframes haloExpand{0%,12%{opacity:0;transform:scale(.5)}16%{opacity:.7}23%,100%{opacity:0;transform:scale(2.5)}}.clickring{animation-name:haloExpand;transform-origin:708px 301px}
@keyframes aurora{0%,100%{transform:translate(-30px,10px) rotate(-4deg)}50%{transform:translate(30px,-10px) rotate(4deg)}}.aurora{animation:aurora 18s ease-in-out infinite;transform-origin:480px 270px}
@keyframes dash{to{stroke-dashoffset:-80}}.stream{stroke-dasharray:2 18;animation:dash 3s linear infinite}
@keyframes count{0%,52%{opacity:0;transform:translateY(8px)}58%,100%{opacity:1;transform:translateY(0)}}.detail{animation-name:count}
@keyframes buildLine{0%,51%{stroke-dashoffset:1}64%,100%{stroke-dashoffset:0}}.buildLine{stroke-dasharray:1;animation-name:buildLine}
@keyframes underline{0%,78%{transform:scaleX(0)}85%,100%{transform:scaleX(1)}}.underline{animation-name:underline;transform-origin:66px 350px}
@keyframes dock{0%,49%{opacity:0;transform:translateY(14px)}55%,100%{opacity:1;transform:translateY(0)}}.dock{animation-name:dock}
@media(prefers-reduced-motion:reduce){.signal,.clickring{display:none}}
@keyframes vortexCW{to{transform:rotate(360deg)}}@keyframes vortexCCW{to{transform:rotate(-360deg)}}
.vortex{transform-origin:480px 270px;animation:vortexCW 36s linear infinite}.counter{transform-origin:480px 270px;animation:vortexCCW 54s linear infinite}
@keyframes current{to{stroke-dashoffset:-1200}}.current{animation:current 9s linear infinite}
@keyframes bgEnergy{0%,12%,100%{opacity:.22}22%,40%{opacity:.6}52%,65%{opacity:.4}74%,84%{opacity:.7}94%{opacity:.28}}.bgEnergy{animation:bgEnergy 18s ease-in-out infinite}
@keyframes wave{0%{opacity:0;transform:scale(.3)}20%{opacity:.35}80%{opacity:.07}100%{opacity:0;transform:scale(1.9)}}.wave{transform-origin:480px 270px;animation:wave 9s cubic-bezier(.15,.35,.5,1) infinite}
@keyframes glimmer{0%,100%{opacity:.08}50%{opacity:.72}}.glimmer{animation:glimmer 6s ease-in-out infinite}
@keyframes comet{0%{opacity:0;transform:translate(-160px,70px)}12%{opacity:.65}58%{opacity:.25}75%,100%{opacity:0;transform:translate(1060px,-300px)}}.comet{animation:comet 9s linear infinite}
@keyframes beam{0%,100%{transform:translate(-150px,0) rotate(-12deg);opacity:0}35%{opacity:.18}65%{opacity:.07;transform:translate(1050px,0) rotate(-12deg)}80%{opacity:0}}.beam{animation:beam 18s ease-in-out infinite;transform-origin:center}
@media(prefers-reduced-motion:reduce){.dynamic-bg{display:none}}
.all{animation-duration:24s}
@keyframes work{0%,46%{opacity:0;transform:translateY(18px)}50%,72%{opacity:1;transform:translateY(0)}76%,100%{opacity:0;transform:translateY(-14px)}}
@keyframes finale{0%,75%{opacity:0}79%,93%{opacity:1}97%,100%{opacity:0}}
@keyframes reveal1{0%,75%{transform:translateY(100px)}82%,100%{transform:translateY(0)}}
@keyframes reveal2{0%,78%{transform:translateY(110px)}85%,100%{transform:translateY(0)}}
@keyframes signal{0%,13%{transform:translate(480px,205px)}20%{transform:translate(708px,301px)}26%,43%{transform:translate(480px,239px)}49%{transform:translate(803px,182px)}55%{transform:translate(803px,235px)}63%,72%{transform:translate(803px,288px)}80%,93%{transform:translate(782px,307px)}100%{transform:translate(480px,205px)}}
@keyframes browserOpen{0%,48%{opacity:0;transform:translateY(20px) scale(.96)}53%,100%{opacity:1;transform:translateY(0) scale(1)}}.browserOpen{animation-name:browserOpen;transform-origin:380px 230px}
@keyframes terminalOpen{0%,53%{opacity:0;transform:translateY(14px)}58%,100%{opacity:1;transform:translateY(0)}}.terminalOpen{animation-name:terminalOpen}
@keyframes terminalText{0%,57%{opacity:0}60%,100%{opacity:1}}.terminalText{animation-name:terminalText}
@keyframes done{0%,65%{opacity:0}69%,100%{opacity:1}}.done{animation-name:done}
@keyframes pointerMove{0%,50%{transform:translate(350px,275px)}55%{transform:translate(529px,267px)}59%{transform:translate(570px,325px)}66%,100%{transform:translate(738px,346px)}}.desktopPointer{animation-name:pointerMove}
@keyframes chartGrow{0%,53%{transform:scaleY(.05)}63%,100%{transform:scaleY(1)}}.chartGrow{animation-name:chartGrow;transform-box:fill-box;transform-origin:bottom}
</style><radialGradient id="readability"><stop stop-color="#08090e" stop-opacity=".58" /><stop offset=".65" stop-color="#08090e" stop-opacity=".38" /><stop offset="1" stop-color="#08090e" stop-opacity="0" /></radialGradient><linearGradient id="beamGold"><stop stop-color="#e7bf56" stop-opacity="0" /><stop offset=".5" stop-color="#efd57f" stop-opacity=".45" /><stop offset="1" stop-color="#e7bf56" stop-opacity="0" /></linearGradient></defs><g clip-path="url(#frame)"><rect width="960" height="540" fill="url(#bg)" /><rect width="960" height="540" fill="url(#grid)" opacity=".35" /><ellipse cx="490" cy="250" rx="460" ry="320" fill="url(#aura)" />
<g><g class="aurora" fill="none" stroke="#c6a457" opacity=".11"><ellipse cx="480" cy="260" rx="470" ry="156" transform="rotate(-18 480 260)" /><ellipse cx="480" cy="260" rx="480" ry="166" transform="rotate(-18 480 260)" /><ellipse cx="480" cy="260" rx="495" ry="180" transform="rotate(-18 480 260)" /></g></g><g><g class="dynamic-bg"><g class="bgEnergy"><rect class="beam" x="0" y="-120" width="160" height="850" fill="url(#beamGold)" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-0s" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-3s" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-6s" /><g class="vortex" fill="none"><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-0.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(19 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(19 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-1.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(38 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(38 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-3.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(57 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(57 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-4.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(76 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(76 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-6.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(95 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(95 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-7.5s" opacity=".65" /></g><g class="counter" fill="none"><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-0.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(-19 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(-19 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-1.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(-38 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(-38 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-3.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(-57 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(-57 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-4.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(-76 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(-76 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-6.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(-95 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(-95 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-7.5s" opacity=".65" /></g><circle cx="10.1" cy="303.9" r="1.3" fill="#ffe4a0Line truncated
<g class="scene intro all"><circle cx="480" cy="205" r="125" fill="url(#aura)" /><circle cx="480" cy="205" r="28" stroke="#f3d576" stroke-width="1" fill="none" /><text x="480" y="126" text-anchor="middle" font-size="32" letter-spacing="-1">Your room is already sending signals.</text>
<rect x="215" y="269" width="530" height="64" rx="12" fill="url(#panel)" stroke="#554b31" /><text x="239" y="307" font-size="17" class="mono typing all">Sense. Understand. Act.</text><rect x="689" y="282" width="38" height="38" rx="9" fill="url(#gold)" /><path d="M708 307v-13m-5 5 5-5 5 5" fill="none" stroke="#222018" stroke-width="2" stroke-linecap="round" />
<path class="cursor all" d="M731 323l0 27 7-8 8 1z" fill="#faf8e7" stroke="#151515" stroke-width="1.4" />
<text x="480" y="382" text-anchor="middle" font-size="11" letter-spacing="3" class="mono muted">WIFI SENSING. NO CAMERAS.</text><g><circle class="clickring all" cx="708" cy="301" r="25" fill="none" stroke="#fae29b" /></g><g><g fill="none" stroke="#e7c467" opacity=".65"><path d="M437 214Q480 161 523 214" /><path d="M420 205Q480 133 540 205" opacity=".4" /><path d="M404 196Q480 108 556 196" opacity=".2" /></g></g></g>
<g class="scene network all"><text x="480" y="107" text-anchor="middle" font-size="30" letter-spacing="-.7">From radio signals to useful actions.</text>
<g><path d="M480 239C548 323 639 357 735 350" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C420 328 311 358 225 350" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C520 143 640 148 714 193" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C440 148 322 143 247 193" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><path d="M480 239C440 148 322 143 247 193" class="wire" opacity=".2" /><path d="M480 239C440 148 322 143 247 193" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-0.0s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-0.0s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-1.2s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-1.2s" repeatCount="indefinite" /></circle></g><path d="M480 239C520 143 640 148 714 193" class="wire" opacity=".2" /><path d="M480 239C520 143 640 148 714 193" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-0.2s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-0.2s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-1.4s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-1.4s" repeatCount="indefinite" /></circle></g><path d="M480 239C420 328 311 358 225 350" class="wire" opacity=".2" /><path d="M480 239C420 328 311 358 225 350" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-0.4s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-0.4s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-1.6s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-1.6s" repeatCount="indefinite" /></circle></g><path d="M480 239C548 323 639 357 735 350" class="wire" opacity=".2" /><path d="M480 239C548 323 639 357 735 350" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-0.6000000000000001s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-0.6000000000000001s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-1.8s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-1.8s" repeatCount="indefinite" /></circle></g><circle cx="480" cy="239" r="98" fill="url(#aura)" class="breathe" /><circle cx="480" cy="239" r="57" fill="#111115" stroke="#6c5830" /><circle cx="480" cy="239" r="66" fill="none" stroke="#eac55e" stroke-opacity=".6" stroke-dasharray="38 170" class="orbit" /><use href="#crown" transform="translate(465 221)" /><rect x="147" y="169" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="164" cy="191" r="3" fill="#e8c667" /><text x="177" y="195" font-size="18">WiFi sensors</text><text x="177" y="212" font-size="8" class="mono muted" letter-spacing="1.8">CAPTURE</text><rect x="614" y="169" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="631" cy="191" r="3" fill="#e8c667" /><text x="644" y="195" font-size="18">RuView</text><text x="644" y="212" font-size="8" class="mono muted" letter-spacing="1.8">PERCEIVE</text><rect x="125" y="326" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="142" cy="348" r="3" fill="#e8c667" /><text x="155" y="352" font-sizeLine truncated
<g class="scene work all"><g><text x="480" y="92" text-anchor="middle" font-size="28" letter-spacing="-.6">A sensing workspace, powered by RuView.</text>
<rect x="58" y="113" width="844" height="331" rx="12" fill="#090c12" stroke="#6a5931" />
<path d="M70 144H890" stroke="#292b31" /><circle cx="76" cy="129" r="3" fill="#dcbf65" /><circle cx="87" cy="129" r="3" fill="#5b5c64" /><circle cx="98" cy="129" r="3" fill="#5b5c64" /><text x="480" y="133" text-anchor="middle" class="mono muted" font-size="9">ruOS / Desktops / ruview-workspace</text>
<rect x="59" y="145" width="125" height="287" fill="#0c0e13" />
<use href="#crown" transform="translate(72 158) scale(.4)" /><text x="91" y="170" font-size="15" font-weight="700">ruOS</text>
<rect x="67" y="209" width="107" height="25" rx="5" fill="#302819" stroke="#63512c" />
<rect x="76" y="194" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="201" font-size="10" class="muted">Overview</text><rect x="76" y="222" width="7" height="6" rx="1" fill="none" stroke="#e1c577" /><text x="91" y="229" font-size="10" class="gold">Desktops</text><rect x="76" y="250" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="257" font-size="10" class="muted">Terminal</text><rect x="76" y="278" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="285" font-size="10" class="muted">Swarms</text><rect x="76" y="306" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="313" font-size="10" class="muted">Network</text><rect x="76" y="334" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="341" font-size="10" class="muted">Usage</text><rect x="76" y="362" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="369" font-size="10" class="muted">Settings</text><text x="200" y="169" font-size="14" font-weight="700">RuView workspace</text><circle cx="364" cy="165" r="3" fill="#b9cb91" /><text x="374" y="169" font-size="9" class="muted">Running</text>
<text x="882" y="167" text-anchor="end" font-size="8" class="mono gold">SYNTHETIC DEMO</text>
<rect x="197" y="183" width="469" height="244" rx="7" fill="#14131a" stroke="#32313d" /><path d="M197 373Q370 180 666 285M197 330Q440 390 666 210" fill="none" stroke="#b49548" opacity=".18" />
<g class="browserOpen all"><rect x="209" y="195" width="308" height="163" rx="7" fill="#191d26" stroke="#5a5961" />
<rect x="209" y="195" width="308" height="24" rx="7" fill="#262a33" /><text x="221" y="211" font-size="8" class="mono muted">RuView / Room overview</text><circle cx="503" cy="207" r="2" fill="#e3c477" />
<text x="225" y="241" font-size="13" font-weight="700">Room activity</text><text x="225" y="257" font-size="9" class="muted">Synthetic scene · Presence and motion</text>
<rect x="224" y="329" width="83" height="16" rx="4" fill="#4a3d22" /><text x="233" y="340" font-size="8" class="gold">Sensor connected</text><g><path d="M229 270H494V318H229zM390 270v18m0 12v18" fill="#121921" stroke="#727781" stroke-width="1.5" /><path d="M235 278h34v15h-34zM464 280h22v24h-22z" fill="#303843" stroke="#707786" /><circle cx="285" cy="300" r="4" fill="#f0cd70" /><circle cx="285" cy="300" r="16" fill="none" stroke="#d8b353" opacity=".4" /><circle cx="326" cy="287" r="5" fill="#b7cbd0" /><path d="M326 293v12m-7-8h14m-7 8-5 8m5-8 5 8" fill="none" stroke="#b7cbd0" stroke-width="2" /><path d="M292 290Q310 273 326 287M294 306Q312 312 326 302" fill="none" stroke="#e1bd5f" stroke-dasharray="3 5" class="stream" /></g></g>
<g class="terminalOpen all"><rect x="366" y="287" width="284" height="117" rx="6" fill="#090c12" stroke="#777061" /><rect x="367" y="288" width="282" height="21" rx="5" fill="#25272f" /><text x="380" y="302" font-size="9" class="mono">Terminal</text>
<text x="380" y="324" font-size="9" class="mono gold">› Room event received</text><g class="terminalText all"><text x="380" y="343" font-size="9" class="mono muted">✓ Presence detected (demo)</text><text x="380" y="358" font-size="9" class="mono muted">✓ Rule checked</text></g><text x="380" y="384" font-size="10" class="mono gold done all">✓ Action ready for review</text></g>
<rect x="680" y="183" width="207" height="244" rx="7" fill="#12151d" stroke="#33333c" /><text x="695" y="204" font-size="12" font-weight="700">Ruflo agent team</text><path d="M694 216h178" stroke="#34323a" />
<circle cx="699" cy="235" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="239" font-size="11">Sense</text><text x="713" y="254" font-size="8" class="muted">Read room events from RuView</text><circle cx="699" cy="288" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="292" font-size="11">Evaluate</text><text x="713" y="307" font-size="8" class="muted">Check the configured rule</text><circle cx="699" cy="341" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="345" font-size="11">Act</text><text x="713" y="360" font-size="8" class="muted">Request an approved action</text><g class="done all"><rect x="693" y="381" width="181" height="29" rx="5" fill="#332b1b" stroke="#89713b" /><text x="784" y="400" text-anchor="middle" font-size="10" class="gold">✓ Room event processed</text></g>
<g class="desktopPointer all"><path d="M0 0v20l5-6 8 1z" fill="#fff2ce" stroke="#18181a" stroke-width="1" /></g>
<text x="480" y="469" text-anchor="middle" class="gold" font-size="14">Example: room activity → rule check → action review.</text></g></g><g class="scene end all"><ellipse cx="810" cy="290" rx="260" ry="240" fill="url(#aura)" />
<text x="66" y="133" class="mono gold" font-size="11" letter-spacing="3">RUVIEW PERCEPTION. RUFLO COORDINATION.</text>
<g clip-path="url(#line1)"><text class="headline1 all" x="61" y="233" font-size="72" font-weight="700" letter-spacing="-3">WiFi becomes</text></g>
<g clip-path="url(#line2)"><text class="headline2 all" x="61" y="322" font-size="72" font-weight="700" letter-spacing="-3">spatial awareness<tspan class="gold">.</tspan></text></g>
<path d="M66 382H894" stroke="#3b3528" /><text x="66" y="420" class="mono gold" font-size="17">ruos.cognitum.one</text><text x="892" y="420" text-anchor="end" font-size="10" class="mono muted" letter-spacing="2">BY COGNITUM</text><g><path class="underline all" d="M66 350h310" stroke="url(#gold)" stroke-width="2" /></g></g>
<path d="M40 490H920" stroke="#26242a" /><text x="40" y="513" class="mono muted" font-size="9" letter-spacing="1.5">RUVIEW + RUFLO + RUOS</text><circle cx="853" cy="509" r="2.5" fill="#dfbf67" /><text x="866" y="512" class="mono gold" font-size="9">READY</text><g><g class="signal all"><circle r="45" fill="url(#aura)" class="signal-glow" /><circle r="10" fill="#ebc65d" opacity=".5" filter="url(#bloom)" /><circle r="4.5" fill="#ffe5a1" /><circle r="8" fill="none" stroke="#e9c56c" opacity=".3" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 37 KiB

+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`.
+29 -6
View File
@@ -1911,31 +1911,51 @@ A 3-6 node ESP32-S3 mesh provides full CSI at 20 Hz. Total cost: ~$54 for a 3-no
**Flashing firmware:**
Pre-built binaries are available at [Releases](https://github.com/ruvnet/RuView/releases):
Use versioned bundles from [Releases](https://github.com/ruvnet/RuView/releases),
not the historical binaries in `firmware/esp32-csi-node/release_bins/`.
The 0.8.14 prerelease fixes USB device identity and adds OTA key provisioning.
It has CI build/host-test coverage; new physical-board qualification remains
pending. The previous stable firmware is 0.8.8.
| Release | What It Includes | Tag |
|---------|-----------------|-----|
| [v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32) | **Latest — ADR-110 firmware-side substrate closed.** Adds ESP-NOW mesh substrate with quantified ≤100 µs alignment (104.1 µs smoothed stdev, 3.95× suppression, 99.56 % cross-board match measured live), 32-byte sync-packet UDP emission with operator-tunable cadence, ADR-018 byte 19 bit 4 wire-fix sourced from working ESP-NOW path, Python SyncPacketParser stub for host wiring ([WITNESS-LOG-110 §A0.7-§A0.13](WITNESS-LOG-110.md)) | `v0.7.0-esp32` |
| [v0.8.14](https://github.com/ruvnet/RuView/releases/tag/v0.8.14-esp32) | **Prerelease:** corrected C6 base-MAC identity, OTA key provisioning without a host key cache; physical qualification pending | `v0.8.14-esp32` |
| [v0.8.8](https://github.com/ruvnet/RuView/releases/tag/v0.8.8-esp32) | Previous stable firmware: consistent occupancy and rate-aware C6 processing; see [release evidence](releases/v0.8.8-esp32.md) | `v0.8.8-esp32` |
| [v0.7.0](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32) | Historical ADR-110 firmware-side substrate: ESP-NOW synchronization and sync-packet UDP emission ([WITNESS-LOG-110 §A0.7-§A0.13](WITNESS-LOG-110.md)) | `v0.7.0-esp32` |
| [v0.6.9](https://github.com/ruvnet/RuView/releases/tag/v0.6.9-esp32) | Sync-packet UDP emission, `CONFIG_C6_SYNC_EVERY_N_FRAMES` tunable cadence | `v0.6.9-esp32` |
| [v0.6.8](https://github.com/ruvnet/RuView/releases/tag/v0.6.8-esp32) | ESP-NOW EMA-smoothed cross-board offset (3.95× suppression, 104 µs stdev) | `v0.6.8-esp32` |
| [v0.6.7](https://github.com/ruvnet/RuView/releases/tag/v0.6.7-esp32) | Real LP-core motion-gate RISC-V program (B4 code path complete) + Wi-Fi 6 soft-AP with TWT Responder for two-board iTWT benches (B1/B2 unblock) | `v0.6.7-esp32` |
| [v0.5.0](https://github.com/ruvnet/RuView/releases/tag/v0.5.0-esp32) | **Stable (S3 mesh, recommended)** — mmWave sensor fusion (MR60BHA2/LD2410 auto-detect), 48-byte fused vitals, all v0.4.3.1 fixes | `v0.5.0-esp32` |
| [v0.5.0](https://github.com/ruvnet/RuView/releases/tag/v0.5.0-esp32) | Historical S3 mesh release — mmWave sensor fusion (MR60BHA2/LD2410 auto-detect), 48-byte fused vitals, all v0.4.3.1 fixes | `v0.5.0-esp32` |
| [v0.4.3.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.3.1-esp32) | Fall detection fix ([#263](https://github.com/ruvnet/RuView/issues/263)), 4MB flash ([#265](https://github.com/ruvnet/RuView/issues/265)), watchdog fix ([#266](https://github.com/ruvnet/RuView/issues/266)) | `v0.4.3.1-esp32` |
| [v0.4.1](https://github.com/ruvnet/RuView/releases/tag/v0.4.1-esp32) | CSI build fix, compile guard, AMOLED display, edge intelligence ([ADR-057](../docs/adr/ADR-057-firmware-csi-build-guard.md)) | `v0.4.1-esp32` |
| [v0.3.0-alpha](https://github.com/ruvnet/RuView/releases/tag/v0.3.0-alpha-esp32) | Alpha — adds on-device edge intelligence (ADR-039) | `v0.3.0-alpha-esp32` |
| [v0.2.0](https://github.com/ruvnet/RuView/releases/tag/v0.2.0-esp32) | Raw CSI streaming, TDM, channel hopping, QUIC mesh | `v0.2.0-esp32` |
> **Important:** Always use **v0.4.3.1 or later**. Earlier versions have false fall detection alerts (v0.4.2 and below) and CSI disabled in the build config (pre-v0.4.1).
For 0.8.14, select the correct archive, verify its release `SHA256SUMS.txt`,
extract into a separate directory, then verify the enclosed checksums:
| Archive | Board configuration |
|---------|---------------------|
| `esp32-csi-node-firmware-8mb.tar.gz` | ESP32-S3 display build, 8 MB flash |
| `esp32-csi-node-firmware-4mb.tar.gz` | ESP32-S3 headless build, 4 MB layout |
| `esp32-csi-node-firmware-c6-4mb.tar.gz` | ESP32-C6 research target, 4 MB layout |
Bare 8 MB DevKitC boards need the source `sdkconfig.defaults.devkitc` overlay;
do not use the display build. See the [firmware guide](../firmware/esp32-csi-node/README.md#0-download-a-versioned-firmware-bundle)
for board selection, C6 filenames, backup and OTA guidance.
```bash
# Flash an ESP32-S3 with 8MB flash (most boards)
# From the extracted ESP32-S3 display / 8 MB bundle:
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
write-flash --flash-mode dio --flash-size 8MB --flash-freq 80m \
0x0 bootloader.bin 0x8000 partition-table.bin \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node.bin
```
**4MB flash boards** (e.g. ESP32-S3 SuperMini 4MB): download `esp32-csi-node-s3-4mb.bin` + `partition-table-s3-4mb.bin` from the [v0.6.7 release](https://github.com/ruvnet/RuView/releases/tag/v0.6.7-esp32) (882 KB binary, 52 % partition slack) and use `--flash-size 4MB`:
**4 MB headless S3 boards:** use the matching application and partition table
from `esp32-csi-node-firmware-4mb.tar.gz` on the
[0.8.14 prerelease](https://github.com/ruvnet/RuView/releases/tag/v0.8.14-esp32)
and select `--flash-size 4MB`:
```bash
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
@@ -1952,6 +1972,9 @@ python firmware/esp32-csi-node/provision.py --port COM7 \
```
Replace `192.168.1.20` with the IP of the machine running the sensing server.
To enable authenticated OTA updates, add `--ota-psk-file` with a private
64-hex key file. The key must be resupplied on later NVS writes; it is never
cached in host state. See [OTA key provisioning](../firmware/esp32-csi-node/README.md#ota-key-provisioning).
**Mesh key provisioning (secure mode):**
+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
+50
View File
@@ -0,0 +1,50 @@
# Virtual-view broker adapter (research prototype)
Opt-in integration with Dream Machine's `scripts/virtual-view-broker.mjs`.
Disabled by default. No cameras, model calls, network, hardware commands or
automatic promotion. It is NOT part of the Rust production perception pipeline.
The reviewed companion is Dream Machine commit
[`5707c825b2c065de44abed02594e1abf5eab95ea`](https://github.com/ruvnet/dream-machine/tree/5707c825b2c065de44abed02594e1abf5eab95ea),
merged in [Dream Machine PR #162](https://github.com/ruvnet/dream-machine/pull/162).
Use a checkout at that revision when reproducing the integration.
```js
import { generateKeyPairSync } from 'node:crypto';
import { broker } from '../../../dream-machine/scripts/virtual-view-broker.mjs';
import { createVirtualViewAdapter } from './adapter.mjs';
// Example in-memory research identity only; use a trusted host signer in deployment.
const { privateKey } = generateKeyPairSync('ed25519');
const adapter = createVirtualViewAdapter({ broker, privateKey, enabled: true });
// adapter.observe(calibratedSnapshot) returns RENDER, SKIP or BLOCK plus authority:none.
```
The example assumes sibling RuView and Dream Machine clones; adjust the import
from the executing module's location. Pass calibrated `virtual-view.v1` scene
snapshots in room-local ENU metres. See Dream Machine ADR-0108 for every field,
threshold, trust boundary, receipt and acceptance rule. The adapter does not
transform raw `spatial.evidence.v1` RF Gaussians or `ruview.lidar.depth.v1` packets:
neither supplies the independently tracked manipulation scene this method needs.
The injected broker is trusted executable code, not sandboxed by this adapter.
The no-network/no-actuation behavior applies to this adapter and the reviewed
companion broker. Before reading scene markers or calling the broker, the adapter
checks the entire snapshot as bounded plain JSON using property descriptors.
It rejects proxies, getters, inherited/custom prototypes, nonfinite values,
sparse arrays and non-JSON data, including nested values. Structural limits match
the reviewed companion: depth 12, 20,000 values, 1,024 array elements, 64 object
properties, 128-character keys and 4,096-character strings. Full geometry,
provenance-label and signing-key validation remain the companion broker's responsibility.
```sh
node --test integrations/virtual-view/adapter.test.mjs
# From the sibling Dream Machine checkout:
node scripts/bench-virtual-view.mjs ../ruview/integrations/virtual-view/adapter.mjs
```
The cross-repository benchmark exercises this adapter with the real broker,
renders 256-object synthetic scenes, signs and replays receipts. It measures local
CPU overhead only. Required robot evidence remains missing: +20 points on two of
three held-out tasks, no regression beyond five points, added p95 below 150 ms,
at least 95% pre-action drift detection, and replayable receipts for all trials.
No receipt grants actuator authority. Do not wire this to live robot control.
+58
View File
@@ -0,0 +1,58 @@
// Opt-in cross-repository prototype. Imports no hardware, network or model client.
import { types } from 'node:util';
const snapshotError = () => new TypeError(
'calibrated metric scene snapshot required; raw RF/LiDAR packets and non-JSON data are not supported',
);
// Structural preflight only, matching the reviewed companion's JSON budgets.
// Never invoke input methods, getters or proxy traps while inspecting data.
function validateSnapshotData(value, depth = 0, budget = { nodes: 0 }) {
if (depth > 12 || ++budget.nodes > 20000) throw snapshotError();
if (value === null || typeof value === 'boolean') return;
if (typeof value === 'string' && value.length <= 4096) return;
if (typeof value === 'number' && Number.isFinite(value)) return;
if (typeof value !== 'object' || types.isProxy(value)) throw snapshotError();
const array = Array.isArray(value);
if (Object.getPrototypeOf(value) !== (array ? Array.prototype : Object.prototype)) {
throw snapshotError();
}
if (array) {
const length = Object.getOwnPropertyDescriptor(value, 'length').value;
if (length > 1024) throw snapshotError();
if (Reflect.ownKeys(value).length !== length + 1) throw snapshotError();
for (let index = 0; index < length; index++) {
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) throw snapshotError();
validateSnapshotData(descriptor.value, depth + 1, budget);
}
return;
}
const keys = Reflect.ownKeys(value);
if (keys.length > 64) throw snapshotError();
for (const key of keys) {
if (typeof key !== 'string' || key.length > 128) throw snapshotError();
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) throw snapshotError();
validateSnapshotData(descriptor.value, depth + 1, budget);
}
}
export function createVirtualViewAdapter({ broker, privateKey, enabled = false }) {
if (typeof broker !== 'function' || typeof enabled !== 'boolean') throw new TypeError('adapter configuration');
return Object.freeze({
observe(snapshot) {
if (!enabled) return { decision: 'BLOCK', reason: 'disabled', authority: 'none', svg: null };
validateSnapshotData(snapshot);
// Explicit metric scene contract only. RF Gaussians are not manipulation geometry.
const schema = snapshot && Object.getOwnPropertyDescriptor(snapshot, 'schema');
const frame = snapshot && Object.getOwnPropertyDescriptor(snapshot, 'frame');
if (schema?.value !== 'virtual-view.v1' || frame?.value !== 'room_enu') {
throw snapshotError();
}
return broker(snapshot, { privateKey });
},
hardwareActuation: false,
});
}
+170
View File
@@ -0,0 +1,170 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createVirtualViewAdapter } from './adapter.mjs';
test('disabled by default and never actuates', () => {
const adapter = createVirtualViewAdapter({ broker: () => { throw Error('not called'); } });
let reads = 0;
const snapshot = { get schema() { reads++; throw Error('not read'); } };
assert.deepEqual(adapter.observe(snapshot), {
decision: 'BLOCK', reason: 'disabled', authority: 'none', svg: null,
});
assert.equal(reads, 0);
assert.equal(adapter.hardwareActuation, false);
assert.equal(Object.isFrozen(adapter), true);
});
test('raw RF, LiDAR and malformed scene markers never reach the broker', () => {
let calls = 0;
const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } });
for (const snapshot of [
null, undefined, 'virtual-view.v1', [], {},
{ schema: 'spatial.evidence.v1', frame: 'room_enu' },
{ schema: 'ruview.lidar.depth.v1', frame: 'room_enu' },
{ schema: 'virtual-view.v1' },
{ schema: 'virtual-view.v1', frame: 'camera' },
Object.create({ schema: 'virtual-view.v1', frame: 'room_enu' }),
Object.defineProperty({ frame: 'room_enu' }, 'schema', { value: 'virtual-view.v1' }),
]) {
assert.throws(() => adapter.observe(snapshot), /calibrated metric scene snapshot required/);
}
assert.equal(calls, 0);
});
test('scene marker getters are rejected without executing input code', () => {
let reads = 0, calls = 0;
const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } });
for (const key of ['schema', 'frame']) {
const snapshot = { schema: 'virtual-view.v1', frame: 'room_enu' };
Object.defineProperty(snapshot, key, {
enumerable: true,
get() { reads++; return key === 'schema' ? 'virtual-view.v1' : 'room_enu'; },
});
assert.throws(() => adapter.observe(snapshot), /calibrated metric scene snapshot required/);
}
assert.equal(reads, 0);
assert.equal(calls, 0);
});
test('proxy snapshots are rejected without running inspection traps', () => {
let traps = 0, calls = 0;
const snapshot = new Proxy({ schema: 'virtual-view.v1', frame: 'room_enu' }, {
get() { traps++; throw Error('get trap'); },
getPrototypeOf() { traps++; throw Error('prototype trap'); },
getOwnPropertyDescriptor() { traps++; throw Error('descriptor trap'); },
});
const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } });
assert.throws(() => adapter.observe(snapshot), /calibrated metric scene snapshot required/);
assert.equal(traps, 0);
assert.equal(calls, 0);
});
test('nested object and array getters are rejected without reads or broker calls', () => {
let reads = 0, calls = 0;
const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } });
const getter = { enumerable: true, get() { reads++; return 0; } };
const object = Object.defineProperty({}, 'uncertaintyM', getter);
const array = Object.defineProperty([0], '0', getter);
for (const extra of [object, [object], { nested: array }]) {
assert.throws(() => adapter.observe({ schema: 'virtual-view.v1', frame: 'room_enu', extra }),
/calibrated metric scene snapshot required/);
}
assert.equal(reads, 0);
assert.equal(calls, 0);
});
test('nested proxies and custom array prototypes never execute input code', () => {
let traps = 0, calls = 0;
const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } });
const handler = {
get() { traps++; throw Error('get trap'); },
getPrototypeOf() { traps++; throw Error('prototype trap'); },
ownKeys() { traps++; throw Error('keys trap'); },
getOwnPropertyDescriptor() { traps++; throw Error('descriptor trap'); },
};
const custom = Object.setPrototypeOf([], {
get [Symbol.iterator]() { traps++; throw Error('iterator getter'); },
});
class SceneArray extends Array {}
for (const extra of [
{ nested: new Proxy({}, handler) },
[new Proxy([], handler)],
{ nested: custom },
new SceneArray(),
]) {
assert.throws(() => adapter.observe({ schema: 'virtual-view.v1', frame: 'room_enu', extra }),
/calibrated metric scene snapshot required/);
}
assert.equal(traps, 0);
assert.equal(calls, 0);
});
test('cyclic, non-JSON and oversized structures are rejected before the broker', () => {
let calls = 0;
const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } });
const cyclic = {}; cyclic.self = cyclic;
let deep = 0;
for (let index = 0; index < 13; index++) deep = { next: deep };
const sparse = Array(2); sparse[1] = 0;
const extraProperty = [0]; extraProperty.extra = 0;
for (const extra of [
cyclic, deep, sparse, extraProperty, NaN, Infinity, undefined, () => {}, new Date(),
'x'.repeat(4097), Array(1025).fill(0),
{ ['x'.repeat(129)]: 0 },
Object.fromEntries(Array.from({ length: 65 }, (_, index) => [`k${index}`, 0])),
Array.from({ length: 20 }, () => Array(1024).fill(0)),
]) {
assert.throws(() => adapter.observe({ schema: 'virtual-view.v1', frame: 'room_enu', extra }),
/calibrated metric scene snapshot required/);
}
assert.equal(calls, 0);
});
test('a complete 256-object synthetic scene stays within the structural budget', () => {
const scene = {
schema: 'virtual-view.v1', sceneId: 'fixture', requestId: 'request:1', policyId: 'fixed',
revision: 1, frame: 'room_enu', proof: 'SYNTHETIC', nowMs: 1000, observedMs: 990,
contact: 'static', target: 'o0', reference: 'o1', physicalAxis: 'x',
objects: Array.from({ length: 256 }, (_, index) => ({
id: `o${index}`, observed: [index / 100, index % 3, 0.1],
predicted: [index / 100, index % 3, 0.1], size: [0.01, 0.01, 0.01],
uncertaintyM: 0.015, sourceReceipt: `fixture:${index}`,
})),
};
let calls = 0;
const result = { authority: 'none' };
const adapter = createVirtualViewAdapter({ enabled: true, broker: received => {
calls++;
assert.equal(received, scene);
return result;
} });
assert.equal(adapter.observe(scene), result);
assert.equal(calls, 1);
});
test('explicit immutable scene and signer are forwarded unchanged to trusted broker', () => {
const snapshot = Object.freeze({ schema: 'virtual-view.v1', frame: 'room_enu' });
const privateKey = Object.freeze({ researchSigner: true });
const result = Object.freeze({ decision: 'SKIP', authority: 'none', svg: null });
let calls = 0;
const adapter = createVirtualViewAdapter({ enabled: true, privateKey, broker: (received, options) => {
calls++;
assert.equal(received, snapshot);
assert.equal(options.privateKey, privateKey);
return result;
} });
assert.equal(adapter.observe(snapshot), result);
assert.equal(calls, 1);
});
test('full scene validation remains delegated and broker rejection propagates', () => {
const rejection = new TypeError('missing tracked scene geometry');
const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { throw rejection; } });
assert.throws(() => adapter.observe({ schema: 'virtual-view.v1', frame: 'room_enu' }),
error => error === rejection);
});
test('enablement must be explicit and configuration types are checked', () => {
for (const configuration of [
{ broker: null }, { broker: () => {}, enabled: 'true' }, { broker: () => {}, enabled: 1 },
]) assert.throws(() => createVirtualViewAdapter(configuration), /adapter configuration/);
});
+50
View File
@@ -0,0 +1,50 @@
"""Bounded, hardened XML parsing for the README visual maintenance tools."""
from pathlib import Path, PurePosixPath, PureWindowsPath
from defusedxml import ElementTree
# Keep the visual verifier's existing exclusive per-asset size limit.
MAX_SVG_BYTES = 20_000
def resolve_repo_file(root: Path, relative: str, *, asset: bool = False) -> Path:
"""Resolve a portable manifest path inside its trusted repository boundary."""
if (
not isinstance(relative, str)
or not relative
or "\\" in relative
or ":" in relative
or "\x00" in relative
or PurePosixPath(relative).is_absolute()
or PureWindowsPath(relative).drive
or ".." in relative.split("/")
):
raise ValueError("Expected a repository-relative path without traversal")
repository = root.resolve()
boundary = repository / "assets" / "readme" if asset else repository
resolved = (repository / relative).resolve()
if not resolved.is_relative_to(boundary):
raise ValueError("Manifest path escapes its allowed repository directory")
if not resolved.is_file():
raise ValueError("Manifest path must name an existing file")
return resolved
def parse_svg(raw: bytes):
"""Reject oversized input and DTD/entity declarations before building a tree."""
if not isinstance(raw, bytes):
raise TypeError("SVG input must be bytes")
if len(raw) >= MAX_SVG_BYTES:
raise ValueError(f"SVG must be smaller than {MAX_SVG_BYTES} bytes")
return ElementTree.fromstring(
raw, forbid_dtd=True, forbid_entities=True, forbid_external=True
)
def read_svg(path: Path):
"""Read at most the rejection boundary, including when a file grows mid-read."""
with path.open("rb") as stream:
raw = stream.read(MAX_SVG_BYTES)
return raw, parse_svg(raw)
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Generate the RuView README visual system. Deterministic, no network."""
from pathlib import Path
from html import escape
import math,json
from readme_visuals_xml import parse_svg
ROOT=Path(__file__).resolve().parents[1];OUT=ROOT/'assets/readme';OUT.mkdir(parents=True,exist_ok=True)
C='#72eee0';BLUE='#70b6ff';INK='#041117';WHITE='#ecfafb';MUTED='#9abac5'
def text(x,y,value,size=16,color=MUTED,anchor='start',extra=''):
return f'<text x="{x}" y="{y}" font-size="{size}" fill="{color}" text-anchor="{anchor}" {extra}>{escape(value)}</text>'
def path(d,color=C,width=1.5,extra=''):
return f'<path d="{d}" fill="none" stroke="{color}" stroke-width="{width}" stroke-linecap="round" stroke-linejoin="round" {extra}/>'
def circle(x,y,r,color=C,extra=''):
fill = '' if 'fill=' in extra else 'fill="none"'
return f'<circle cx="{x}" cy="{y}" r="{r}" stroke="{color}" {fill} {extra}/>'
def icon(kind,x,y,scale=1):
s=''
if kind in ['signal','start']:
for r in [13,25,38]:s+=path(f'M{-r*.8} {-r*.4}Q0 {-r*1.4} {r*.8} {-r*.4}',extra=f'class="breathe" style="animation-delay:-{r/20}s"')
s+=circle(0,20,4,extra='fill="#72eee0"')+path('M0 12V-1')
elif kind in ['presence','pose']:
s+=circle(0,-28,8)+path('M0 -20V12M-24 -6L0 -14L24 -6M0 12L-18 38M0 12L18 38',width=2)
if kind=='pose':
for px,py in [(0,-14),(0,12),(-24,-6),(24,-6),(-18,38),(18,38)]:s+=circle(px,py,3,BLUE,extra='fill="#041117" class="breathe"')
else:s+=f'<ellipse cx="0" cy="14" rx="40" ry="13" fill="none" stroke="{BLUE}" stroke-dasharray="3 8" class="flow"/>'
elif kind in ['vitals','models']:
s+=path('M-42 6H-27L-18 -10L-9 25L3 -25L14 10L24 -3L31 6H43',width=2,extra='class="trace"')+path('M-42 31H43','#214451',1)
elif kind in ['edge','hardware']:
s+='<rect x="-25" y="-25" width="50" height="50" rx="7" fill="#0a2630" stroke="#72eee0"/>'
s+='<rect x="-13" y="-13" width="26" height="26" rx="3" fill="none" stroke="#70b6ff"/>'
for v in [-16,0,16]:s+=path(f'M{v} -36V-25M{v} 25V36M-36 {v}H-25M25 {v}H36')
s+=circle(0,0,4,extra='fill="#72eee0" class="breathe"')
elif kind in ['home','automation']:
s+=path('M-37 -3L0 -34L37 -3M-28 -10V32H28V-10M-9 32V8H9V32',width=2)
s+=circle(0,-9,5,BLUE,extra='class="breathe"')
elif kind in ['agent','agents']:
for px,py in [(0,-33),(-35,23),(35,23)]:s+=path(f'M0 0L{px} {py}',BLUE,1,extra='class="flow"')+circle(px,py,7,extra='class="breathe"')
s+=circle(0,0,15)+path('M-6 0L-1 5L8 -6')
elif kind in ['seed','evidence']:
s+=path('M0 -38L31 -20V18L0 38L-31 18V-20ZM-31 -20L0 -1L31 -20M0 -1V38',width=2)
s+=path('M-15 1L-5 11L17 -12',BLUE,2,extra='class="trace"')
else:
for j in range(3):s+=path(f'M-38 {j*14-20}L0 {j*14-38}L38 {j*14-20}L0 {j*14-2}Z',extra='class="breathe"')
return f'<g transform="translate({x} {y}) scale({scale})">{s}</g>'
def frame(w,h,title,desc,body):
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}" role="img" aria-labelledby="title desc"><title id="title">{escape(title)}</title><desc id="desc">{escape(desc)}</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="{w-1}" height="{h-1}" rx="18" fill="url(#wash)" stroke="#245360"/>{body}</svg>'''
manifest=[]
def save(name,w,h,title,desc,body,target):
s=frame(w,h,title,desc,body);raw=(s+'\n').encode('utf-8');parse_svg(raw);(OUT/(name+'.svg')).write_bytes(raw);manifest.append({'file':'assets/readme/'+name+'.svg','title':title,'target':target,'width':w,'height':h})
# Main title panel: a radio field passes through an illustrated room.
s=text(48,44,'RUVIEW / COGNITUM ECOSYSTEM',13,C,extra='class="mono"')+text(48,121,'RuView',76,WHITE)+text(48,184,'Spaces become signals.',38,WHITE)+text(48,234,'Signals become understanding.',32,C)+text(50,279,'Explore RF sensing, edge inference and local automation.',18)
s+='<rect x="48" y="326" width="270" height="52" rx="12" fill="url(#accent)"/>'+text(72,359,'Explore the guide',20,INK)+path('M272 352H292M285 345L292 352L285 359',INK,2)
s+=text(49,419,'RF SENSING / EDGE MODELS / LOCAL ACTION',11,MUTED,extra='class="mono"')
s+='<circle cx="950" cy="235" r="235" fill="url(#halo)"/>'
for i in range(9):s+=path(f'M{750+i*40} 282L{610+i*80} 443','#12343d',1)
for y in [306,336,372,418]:s+=path(f'M680 {y}H1168','#12343d',1)
s+=path('M803 157L1008 106L1137 177L930 236ZM803 157V314L930 391L1137 324V177M930 236V391M1008 106V268L803 314M1008 268L1137 324','#4b9ba4',1.5)
s+=icon('presence',976,265,1.35)+icon('edge',780,349,.7)
for i in range(4):s+=path(f'M{794+i*12} {333-i*9}Q{830+i*25} {245-i*24} {896+i*29} {207-i*3}',C,1.4,extra=f'class="flow" style="animation-delay:-{i}s" opacity="{.8-i*.12}"')
for x,y in [(803,157),(1008,106),(1137,177),(930,236),(930,391),(803,314),(1137,324)]:s+=circle(x,y,3,extra='fill="#72eee0" class="breathe"')
s+=text(1148,419,'CONCEPTUAL RF FIELD',10,MUTED,'end',extra='class="mono"')
save('hero',1200,450,'RuView: spaces become signals','Animated conceptual RF field and room geometry. Explore the RuView user guide. This illustration is not a sensor measurement.',s,'docs/user-guide.md')
# Visual navigation icons, each receives a real outer link in Markdown.
nav=[('start','Start here','docs/user-guide.md'),('hardware','Hardware','firmware/esp32-csi-node/README.md'),('models','Models','https://huggingface.co/ruvnet/wifi-densepose-pretrained'),('agents','AI toolkit','harness/ruview/README.md'),('home','Smart home','docs/integrations/home-assistant.md'),('seed','Cognitum','https://cognitum.one/seed')]
for k,label,target in nav:save('nav-'+k,180,116,label,'Open '+label+'. Animated navigation icon.',icon(k,90,45,.65)+text(90,98,label,16,WHITE,'middle'),target)
# Capability cards. No unverified performance claims.
cards=[('presence','01','Presence & motion','Explore changes in room occupancy.','Inspect motion features from CSI.','SENSING GUIDE','docs/user-guide.md'),('vitals','02','Breathing & vital signals','Inspect phase-derived signal trends.','Validate estimates on your hardware.','SIGNAL PIPELINE','v2/crates/wifi-densepose-signal/README.md'),('pose','03','Pose research','Train and compare body keypoints.','Live accuracy differs from benchmarks.','READ THE EVIDENCE','docs/benchmarks/pose-estimation-cog.md'),('edge','04','Edge sensor mesh','Provision, capture and calibrate.','Explore ESP32 firmware and runbooks.','HARDWARE GUIDE','firmware/esp32-csi-node/README.md'),('automation','05','Local automation','Connect rooms to your smart home.','Home Assistant, MQTT and Matter.','INTEGRATION GUIDE','docs/integrations/home-assistant.md'),('agents','06','A guided AI operator','Source-cited setup and verification.','CLI, MCP and agent workflows.','TOOLKIT GUIDE','harness/ruview/README.md')]
for k,num,title,l1,l2,cta,target in cards:
s=text(30,39,'RUVIEW / '+num,11,C,extra='class="mono"')+text(30,94,title,29,WHITE)+text(30,136,l1,16)+text(30,164,l2,16)
s+='<circle cx="465" cy="170" r="110" fill="url(#halo)"/>'+icon(k,474,168,.83)+path('M30 204H550','#245360',1)+path('M30 204H550',C,1,extra='class="flow" opacity=".5"')
s+=text(30,245,cta,12,C,extra='class="mono"')+path('M510 239H540M532 231L540 239L532 247',C,1.5)
save('feature-'+k,580,276,title,l1+' '+l2+' Conceptual animation; follow the linked source for capability limits.',s,target)
# Overview uses three clearly labelled stages rather than fabricated telemetry.
s=text(36,44,'SIGNAL TO CONTEXT',12,C,extra='class="mono"')+text(36,89,'A sensing loop you can inspect.',34,WHITE)
for i,(label,sub,kind,tail) in enumerate([('Capture RF','CSI from supported hardware','signal','Provision and calibrate'),('Extract context','Signal processing and models','models','Compare against real data'),('Act locally','Events, APIs and integrations','automation','Apply rules and review outputs')]):
x=36+i*396;s+=f'<rect x="{x}" y="120" width="336" height="220" rx="14" fill="#071b23" stroke="#285360"/>'
s+=text(x+24,153,f'0{i+1}',12,C)+text(x+24,188,label,26,WHITE)+text(x+24,216,sub,15)+icon(kind,x+269,281,.62)+text(x+24,311,tail,13)
if i<2:s+=path(f'M{x+344} 229H{x+384}M{x+376} 221L{x+384} 229L{x+376} 237',C,2,extra='class="flow"')
s+=text(36,373,'CONCEPTUAL PIPELINE / HARDWARE AND ENVIRONMENT VALIDATION MATTER',11,MUTED,extra='class="mono"')
save('overview',1200,396,'RuView sensing overview','Conceptual sequence: capture RF, extract context, act locally. Links to architecture and user documentation.',s,'docs/user-guide.md')
# Chapter headings act as visual landmarks, all remain links.
headers=[('features','Explore the capabilities','SENSING / MODELS / AUTOMATION','presence','docs/user-guide.md'),('edge','Intelligence at the edge','SENSORS / FIRMWARE / MODULES','edge','firmware/esp32-csi-node/README.md'),('learning','From recordings to models','CAPTURE / TRAIN / EVALUATE','models','docs/user-guide.md'),('evidence','Evidence before accuracy claims','BENCHMARKS / REPRODUCERS / LIMITS','evidence','docs/benchmarks/pose-estimation-cog.md'),('docs','Choose your next step','GUIDES / HARDWARE / INTEGRATIONS','agents','docs/user-guide.md')]
for k,title,sub,kind,target in headers:
s=icon(kind,76,75,.75)+text(140,63,title,34,WHITE)+text(142,99,sub,12,C,extra='class="mono"')
for i in range(4):s+=path(f'M865 {44+i*17}Q960 {10+i*28} 1124 {52+i*17}',C,1,extra=f'class="flow" opacity="{.65-i*.1}"')
s+=path('M1140 70H1160M1152 62L1160 70L1152 78',C,2)
save('header-'+k,1200,148,title,'Animated section heading. '+sub,s,target)
# Brand destination; original ruOS promotion remains in the README.
s=text(44,42,'COGNITUM ONE',12,C,extra='class="mono"')+text(44,99,'Ambient Intelligence.',42,WHITE)+text(44,147,'At the edge of the physical world.',25,C)+text(44,190,'Explore the platform behind the ecosystem.',16)
s+='<rect x="44" y="224" width="256" height="46" rx="11" fill="url(#accent)"/>'+text(65,254,'Explore Cognitum',18,INK)+path('M263 247H284M277 240L284 247L277 254',INK,2)
s+='<circle cx="947" cy="151" r="173" fill="url(#halo)"/>'+icon('seed',945,150,2.4)
for j in range(3):s+=f'<ellipse cx="945" cy="150" rx="{140+j*20}" ry="{30+j*13}" transform="rotate(-25 945 150)" fill="none" stroke="#3c8897" stroke-dasharray="5 18" class="flow"/>'
save('cognitum',1200,304,'Explore Cognitum One','Ambient Intelligence at the edge of the physical world. Open cognitum.one.',s,'https://cognitum.one')
(OUT/'manifest.json').write_bytes((json.dumps({'schema_version':1,'style':'Cognitum cyan on midnight','motion':'Decorative CSS; prefers-reduced-motion supported; no script or remote dependencies','assets':manifest},indent=2)+'\n').encode('utf-8'))
print(f'Generated {len(manifest)} SVGs, {sum((ROOT/x["file"]).stat().st_size for x in manifest):,} bytes')
+2
View File
@@ -0,0 +1,2 @@
# Development-only dependency for README SVG generation and verification.
defusedxml==0.7.1
+152
View File
@@ -0,0 +1,152 @@
"""Security regressions for the README visual tools' shared XML boundary."""
import io
from pathlib import Path
import sys
import tempfile
import unittest
from unittest.mock import patch
from defusedxml.common import DTDForbidden
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import readme_visuals_xml as svg_xml
class ReadmeVisualXmlTests(unittest.TestCase):
def test_manifest_paths_reject_absolute_and_traversal_before_resolution(self):
for relative in [
'', '/tmp/outside.svg', '//server/share/outside.svg',
'C:/outside.svg', 'C:outside.svg', r'\\server\share\outside.svg',
r'\outside.svg', r'\\?\C:\outside.svg', 'file.svg:stream',
'../outside.svg', 'assets/readme/../../../outside.svg',
r'assets\readme\..\outside.svg', 'assets/readme/\x00.svg',
]:
with self.subTest(relative=relative):
with patch.object(Path, 'resolve') as resolve:
with self.assertRaises(ValueError):
svg_xml.resolve_repo_file(Path('repository'), relative)
resolve.assert_not_called()
def test_manifest_paths_keep_assets_and_targets_in_separate_boundaries(self):
with tempfile.TemporaryDirectory() as directory:
repository = Path(directory).resolve()
asset = repository / 'assets' / 'readme' / 'ordinary.svg'
asset.parent.mkdir(parents=True)
asset.write_bytes(b'<svg/>')
target = repository / 'README.md'
target.write_bytes(b'# Readme\n')
self.assertEqual(svg_xml.resolve_repo_file(repository, 'assets/readme/ordinary.svg', asset=True), asset)
self.assertEqual(svg_xml.resolve_repo_file(repository, 'README.md'), target)
with patch.object(Path, 'is_file') as is_file:
with self.assertRaises(ValueError):
svg_xml.resolve_repo_file(repository, 'README.md', asset=True)
is_file.assert_not_called()
def test_manifest_paths_reject_symlink_escape_before_file_inspection(self):
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
repository = base / 'repository'
assets = repository / 'assets' / 'readme'
assets.mkdir(parents=True)
outside = base / 'outside.svg'
outside.write_bytes(b'<svg/>')
try:
(assets / 'escape.svg').symlink_to(outside)
(repository / 'escaped-directory').symlink_to(base, target_is_directory=True)
except (OSError, NotImplementedError) as error:
if sys.platform == 'win32' and (isinstance(error, NotImplementedError) or getattr(error, 'winerror', None) == 1314):
self.skipTest('Windows symlink privilege unavailable')
raise
for relative, asset in [('assets/readme/escape.svg', True), ('escaped-directory/outside.svg', False)]:
with self.subTest(relative=relative):
with patch.object(Path, 'is_file') as is_file:
with self.assertRaises(ValueError):
svg_xml.resolve_repo_file(repository, relative, asset=asset)
is_file.assert_not_called()
def test_asset_directory_symlink_cannot_redefine_trusted_boundary(self):
with tempfile.TemporaryDirectory() as directory:
base = Path(directory)
repository = base / 'repository'
(repository / 'assets').mkdir(parents=True)
outside = base / 'outside'
outside.mkdir()
(outside / 'escape.svg').write_bytes(b'<svg/>')
try:
(repository / 'assets' / 'readme').symlink_to(outside, target_is_directory=True)
except (OSError, NotImplementedError) as error:
if sys.platform == 'win32' and (isinstance(error, NotImplementedError) or getattr(error, 'winerror', None) == 1314):
self.skipTest('Windows symlink privilege unavailable')
raise
with patch.object(Path, 'is_file') as is_file:
with self.assertRaises(ValueError):
svg_xml.resolve_repo_file(repository, 'assets/readme/escape.svg', asset=True)
is_file.assert_not_called()
def test_plain_utf8_svg_parses(self):
root = svg_xml.parse_svg('<svg><title>Présence</title></svg>'.encode('utf-8'))
self.assertEqual(root.tag, 'svg')
self.assertEqual(root.find('title').text, 'Présence')
def test_dtd_and_entity_payloads_are_rejected(self):
for raw in [
b'<!DOCTYPE svg><svg/>',
b'<!DOCTYPE svg [<!ENTITY payload "expanded">]><svg>&payload;</svg>',
b'<!DOCTYPE svg [<!ENTITY payload SYSTEM "file:///private.txt">]><svg>&payload;</svg>',
b'<!DOCTYPE svg SYSTEM "https://example.invalid/remote.dtd"><svg/>',
]:
with self.subTest(raw=raw):
with self.assertRaises(DTDForbidden):
svg_xml.parse_svg(raw)
def test_size_limit_is_enforced_before_parser_runs(self):
for size in [svg_xml.MAX_SVG_BYTES, svg_xml.MAX_SVG_BYTES + 1]:
with self.subTest(size=size):
with patch.object(svg_xml.ElementTree, 'fromstring') as parser:
with self.assertRaisesRegex(ValueError, 'smaller than 20000 bytes'):
svg_xml.parse_svg(b' ' * size)
parser.assert_not_called()
def test_limit_counts_utf8_bytes_and_allows_last_valid_size(self):
valid = b'<svg/>' + b' ' * (svg_xml.MAX_SVG_BYTES - 7)
self.assertEqual(len(valid), svg_xml.MAX_SVG_BYTES - 1)
self.assertEqual(svg_xml.parse_svg(valid).tag, 'svg')
text = '<svg>' + 'é' * 10000 + '</svg>'
self.assertLess(len(text), svg_xml.MAX_SVG_BYTES)
with self.assertRaises(ValueError):
svg_xml.parse_svg(text.encode('utf-8'))
def test_reader_never_requests_unbounded_input(self):
class BoundedReader(io.BytesIO):
requested = None
def read(self, size=-1):
self.requested = size
if size < 0 or size > svg_xml.MAX_SVG_BYTES:
raise AssertionError('unbounded read')
return super().read(size)
stream = BoundedReader(b' ' * (svg_xml.MAX_SVG_BYTES * 2))
with patch.object(Path, 'open', return_value=stream):
with patch.object(svg_xml.ElementTree, 'fromstring') as parser:
with self.assertRaises(ValueError):
svg_xml.read_svg(Path('oversized.svg'))
parser.assert_not_called()
self.assertEqual(stream.requested, svg_xml.MAX_SVG_BYTES)
def test_file_reader_uses_same_hardened_parser(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / 'fixture.svg'
raw = b'<svg><title>ordinary</title></svg>'
path.write_bytes(raw)
read, root = svg_xml.read_svg(path)
self.assertEqual(read, raw)
self.assertEqual(root.find('title').text, 'ordinary')
path.write_bytes(b'<!DOCTYPE svg><svg/>')
with self.assertRaises(DTDForbidden):
svg_xml.read_svg(path)
if __name__ == '__main__':
unittest.main()
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env python3
"""Verify the generated SVGs and GitHub-compatible image link wrappers."""
from pathlib import Path
import json,re
from urllib.parse import urlparse
from readme_visuals_xml import read_svg,resolve_repo_file
ROOT=Path(__file__).resolve().parents[1];NS='{http://www.w3.org/2000/svg}'
manifest=json.loads((ROOT/'assets/readme/manifest.json').read_text(encoding='utf-8'));readme=(ROOT/'README.md').read_text(encoding='utf-8');total=0
for asset in manifest['assets']:
file=resolve_repo_file(ROOT,asset['file'],asset=True);data,root=read_svg(file);raw=data.decode('utf-8');total+=len(data)
assert root.tag==NS+'svg' and root.get('viewBox'),asset['file']
assert root.find(NS+'title') is not None and root.find(NS+'desc') is not None
ids=[e.get('id') for e in root.iter() if e.get('id')];assert len(ids)==len(set(ids))
assert 'prefers-reduced-motion:reduce' in raw and '@keyframes' in raw
for e in root.iter():
assert e.tag not in [NS+'script',NS+'foreignObject',NS+'image'],asset['file']
assert all(not k.lower().startswith('on') for k in e.attrib)
assert not any(k.endswith('href') for k in e.attrib),'Links belong to README wrappers'
assert not re.search(r'url\((?!#)',raw),'Remote dependency'
target=asset['target'];parsed=urlparse(target)
if parsed.scheme:
assert parsed.scheme=='https' and parsed.hostname in ['cognitum.one','huggingface.co']
else:resolve_repo_file(ROOT,target.split('#')[0])
escaped=re.escape(asset['file']);destination=re.escape(target)
linked=bool(re.search(r'\[!\[[^\]]*\]\('+escaped+r'\)\]\('+destination+r'\)',readme))
linked=linked or bool(re.search(r'<a href="'+destination+r'">\s*<img src="'+escaped+r'"',readme))
assert linked,('Missing clickable wrapper',asset['file'])
assert total<100000
print(f'PASS: {len(manifest["assets"])} accessible animated SVGs, link targets and README wrappers; {total:,} bytes. No scripts, remote resources or SVG-internal links.')
@@ -536,7 +536,7 @@ async fn ingest_loop(
if let Some(sess) = active.as_ref() {
if Instant::now() >= sess.deadline {
let frames = sess.recorder.frames_recorded() as usize;
if frames >= 10 {
if frames >= sess.target_frames {
if let Some(done) = active.take() {
let _ = finalize(done, &output_dir, &status).await;
}
@@ -544,8 +544,9 @@ async fn ingest_loop(
// not enough frames — abort honestly rather than emit a bad baseline
done.motion_flagged = false;
let note = format!(
"aborted: only {frames} frames in the time window (need >=10) — \
"aborted: only {frames} frames in the time window (need >= {}) — \
is the ESP32 streaming to udp:{}? ",
done.target_frames,
status.read().await.udp_port
);
let snap = session_snapshot(&done, "aborted", Some(note.clone()));
@@ -615,19 +616,34 @@ async fn finalize(
status.write().await.session = Some(snap);
}
let baseline: BaselineCalibration = sess
.recorder
.finalize()
.map_err(|e| format!("finalize failed: {e}"))?;
// `CalibrationRecorder::finalize` consumes the recorder, so retain a
// terminal snapshot before attempting the fallible operation.
let mut aborted_snapshot = session_snapshot(&sess, "aborted", None);
let baseline: BaselineCalibration = match sess.recorder.finalize() {
Ok(baseline) => baseline,
Err(e) => {
let message = format!("finalize failed: {e}");
let note = format!("aborted: {message}");
aborted_snapshot.note = Some(note.clone());
status.write().await.session = Some(aborted_snapshot);
eprintln!("[calibrate-serve] {note}");
return Err(message);
}
};
let (amp_mean_avg, amp_var_avg, disp_avg) = baseline_averages(&baseline);
let uuid = baseline.calibration_uuid().to_string();
let path = format!("{output_dir}/{room_id}-{uuid}.bin");
let bytes = baseline.to_bytes();
// Async write — never block the ingest task's UDP/command path.
tokio::fs::write(&path, &bytes)
.await
.map_err(|e| format!("cannot write {path}: {e}"))?;
if let Err(error) = tokio::fs::write(&path, &bytes).await {
let message = format!("cannot write {path}: {error}");
let note = format!("aborted: {message}");
aborted_snapshot.note = Some(note.clone());
status.write().await.session = Some(aborted_snapshot);
eprintln!("[calibrate-serve] {note}");
return Err(message);
}
let summary = ResultSummary {
calibration_id: uuid,
@@ -1055,6 +1071,97 @@ mod tests {
assert_eq!(a.udp_port, 5005);
}
#[tokio::test]
async fn finalize_insufficient_frames_marks_session_aborted() {
let dir = tempfile::tempdir().unwrap();
let status = Arc::new(RwLock::new(SharedStatus::default()));
let sess = ActiveSession {
recorder: CalibrationRecorder::new(tier_config("ht20")),
room_id: "test".into(),
tier: "ht20".into(),
started: Instant::now(),
deadline: Instant::now(),
target_frames: 1,
z_median: 0.0,
z_max: 0.0,
motion_flagged: false,
};
let err = finalize(sess, dir.path().to_str().unwrap(), &status)
.await
.expect_err("an empty recorder cannot finalize");
assert!(err.contains("finalize failed"));
let guard = status.read().await;
let session = guard.session.as_ref().expect("terminal session status");
assert_eq!(session.state, "aborted");
assert!(session.note.as_deref().unwrap_or_default().contains("finalize failed"));
}
fn complete_test_session() -> ActiveSession {
let mut config = tier_config("ht20");
config.min_frames = 2;
let mut recorder = CalibrationRecorder::new(config);
let mut packet = vec![0u8; 20 + 64 * 2];
packet[..4].copy_from_slice(&0xC511_0001u32.to_le_bytes());
packet[4] = 1;
packet[5] = 1;
packet[6..8].copy_from_slice(&64u16.to_le_bytes());
packet[8..12].copy_from_slice(&2437u32.to_le_bytes());
packet[20..].fill(3);
let frame = parse_csi_packet(&packet, "ht20").unwrap();
recorder.record(&frame).unwrap();
recorder.record(&frame).unwrap();
ActiveSession {
recorder,
room_id: "test".into(),
tier: "ht20".into(),
started: Instant::now(),
deadline: Instant::now(),
target_frames: 2,
z_median: 0.0,
z_max: 0.0,
motion_flagged: false,
}
}
#[tokio::test]
async fn finalize_write_failure_marks_session_aborted_without_result() {
let dir = tempfile::tempdir().unwrap();
let blocked = dir.path().join("not-a-directory");
std::fs::write(&blocked, b"owned test fixture").unwrap();
let status = Arc::new(RwLock::new(SharedStatus::default()));
let error = finalize(complete_test_session(), blocked.to_str().unwrap(), &status)
.await
.expect_err("a regular file cannot hold the baseline");
assert!(error.starts_with("cannot write "));
let guard = status.read().await;
let session = guard.session.as_ref().unwrap();
assert_eq!(session.state, "aborted");
assert!(session.note.as_deref().unwrap().contains("cannot write "));
assert!(guard.last_result.is_none());
assert_eq!(std::fs::read(blocked).unwrap(), b"owned test fixture");
}
#[tokio::test]
async fn finalize_success_publishes_a_readable_baseline_and_complete_status() {
let dir = tempfile::tempdir().unwrap();
let status = Arc::new(RwLock::new(SharedStatus::default()));
let summary = finalize(complete_test_session(), dir.path().to_str().unwrap(), &status)
.await
.unwrap();
let bytes = std::fs::read(&summary.output_path).unwrap();
let baseline = BaselineCalibration::from_bytes(&bytes).unwrap();
assert_eq!(baseline.frame_count, 2);
assert_eq!(summary.saved_bytes, bytes.len());
let guard = status.read().await;
let session = guard.session.as_ref().unwrap();
assert_eq!(session.state, "complete");
assert_eq!(session.progress, 1.0);
assert!(session.note.is_none());
assert_eq!(guard.last_result.as_ref().unwrap().calibration_id, summary.calibration_id);
}
#[test]
fn sanitize_blocks_path_traversal() {
assert_eq!(sanitize_room_id("../../etc/passwd"), "etcpasswd");
@@ -88,7 +88,29 @@ cargo run -p wifi-densepose-sensing-server -- \
Complete the normal empty-room calibration, then call
`POST /api/v1/calibration/bootstrap/promote`. The server measures a separate 12-sample holdout
before storing an aggregate field-model snapshot. On a later start with the same installation ID,
before storing an aggregate field-model snapshot. Promotion requires all of the following:
- Exactly 12 fresh samples, spaced at least one second apart, with no stale samples.
- At least 10 of the 12 samples classified as empty.
- Zero samples containing numeric vital-sign estimates.
The 10-of-12 tolerance applies only to the empty classification. A holdout with 12 empty samples
and five samples containing vital-sign estimates still fails; a vital-sign estimate does not
prove occupancy, but it prevents storing the startup baseline under this conservative check.
Completed holdout evaluations return the existing `validation` counters and additive
`validation_diagnostics.requirements` and `validation_diagnostics.failures` fields. A failed
check retains `success: false`, `error_code`, and `error`; the error describes the observed
counts and required limits. Each failure identifies its `check`, `observed` and `required`
counts, and `comparison`, so clients can explain every failed condition. For example, five
vital-sign samples produce a `vital_sign_sample_count` failure with `observed: 5`,
`required: 0`, and `comparison: "at_most"`.
The bounded refinement endpoint, `POST /api/v1/calibration/bootstrap/refine`, also requires
12 fresh samples with zero stale or vital-sign samples. Its diagnostics omit the empty-sample
minimum because refinement does not apply that promotion-only check.
On a later start with the same installation ID,
the snapshot is restored with `bootstrap_only` authority. It can reduce startup background false
positives, but cannot authorize calibrated evidence or numeric heart and breathing rates. Use
`POST /api/v1/calibration/reset` with administrator scope to remove it.
@@ -90,6 +90,131 @@ pub struct BootstrapValidationResult {
pub passed: bool,
}
/// Independent ADR-355 requirements. Refinement does not count empty votes.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct BootstrapValidationRequirements {
pub sample_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub min_empty_sample_count: Option<usize>,
pub max_stale_sample_count: usize,
pub max_vital_sign_sample_count: usize,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct BootstrapValidationFailure {
pub check: &'static str,
pub observed: usize,
pub required: usize,
pub comparison: &'static str,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct BootstrapValidationDiagnostics {
pub requirements: BootstrapValidationRequirements,
pub failures: Vec<BootstrapValidationFailure>,
}
impl BootstrapValidationResult {
pub fn diagnostics(&self) -> BootstrapValidationDiagnostics {
validation_diagnostics(
self.sample_count,
Some(self.empty_sample_count),
self.stale_sample_count,
self.vital_sign_sample_count,
)
}
}
impl BootstrapValidationDiagnostics {
/// Describe every failed check, without interpreting estimates as people.
pub fn failure_summary(&self) -> String {
self.failures
.iter()
.map(|failure| match failure.check {
"sample_count" => format!(
"{} samples collected (exactly {} required); check the CSI stream",
failure.observed, failure.required,
),
"empty_sample_count" => format!(
"{} samples reported empty (at least {} required); vacate the room and check for moving objects",
failure.observed, failure.required,
),
"stale_sample_count" => format!(
"{} stale samples ({} allowed); check the CSI stream",
failure.observed, failure.required,
),
_ => format!(
"numeric vital-sign estimates in {}/{} samples ({} allowed); vacate the room and check for moving objects",
failure.observed, self.requirements.sample_count, failure.required,
),
})
.collect::<Vec<_>>()
.join("; ")
}
}
/// Explain existing gate counters without changing how samples are counted.
/// Promotion supplies all attempted samples; refinement supplies only fresh
/// residuals. Stale samples are a separate check, never added to sample_count.
pub fn validation_diagnostics(
sample_count: usize,
empty_sample_count: Option<usize>,
stale_sample_count: usize,
vital_sign_sample_count: usize,
) -> BootstrapValidationDiagnostics {
let requirements = BootstrapValidationRequirements {
sample_count: BOOTSTRAP_VALIDATION_SAMPLES,
min_empty_sample_count: empty_sample_count.map(|_| BOOTSTRAP_VALIDATION_MIN_EMPTY),
max_stale_sample_count: 0,
max_vital_sign_sample_count: 0,
};
let mut failures = Vec::new();
let checks = [
(
"sample_count",
sample_count,
requirements.sample_count,
"equal",
sample_count != requirements.sample_count,
),
(
"empty_sample_count",
empty_sample_count.unwrap_or(0),
BOOTSTRAP_VALIDATION_MIN_EMPTY,
"at_least",
empty_sample_count.is_some_and(|count| count < BOOTSTRAP_VALIDATION_MIN_EMPTY),
),
(
"stale_sample_count",
stale_sample_count,
requirements.max_stale_sample_count,
"at_most",
stale_sample_count != 0,
),
(
"vital_sign_sample_count",
vital_sign_sample_count,
requirements.max_vital_sign_sample_count,
"at_most",
vital_sign_sample_count != 0,
),
];
for (check, observed, required, comparison, failed) in checks {
if failed {
failures.push(BootstrapValidationFailure {
check,
observed,
required,
comparison,
});
}
}
BootstrapValidationDiagnostics {
requirements,
failures,
}
}
#[derive(Debug, Error)]
pub enum BootstrapBaselineError {
#[error("a stable installation_id is required for a local bootstrap baseline")]
@@ -505,6 +630,167 @@ mod tests {
assert!(!evaluate_validation(&vital).passed);
}
fn empty_validation_samples(count: usize) -> Vec<BootstrapValidationSample> {
vec![
BootstrapValidationSample {
fresh_tick: true,
calibrated_empty: true,
vital_signs_absent: true,
};
count
]
}
#[test]
fn validation_diagnostics_keep_empty_tolerance_separate_from_vitals() {
let mut samples = empty_validation_samples(12);
samples[0].calibrated_empty = false;
samples[1].calibrated_empty = false;
let passing = evaluate_validation(&samples);
assert!(passing.passed);
assert_eq!(passing.empty_sample_count, 10);
assert!(passing.diagnostics().failures.is_empty());
assert!(passing.diagnostics().failure_summary().is_empty());
samples[2].calibrated_empty = false;
let rejected = evaluate_validation(&samples);
assert!(!rejected.passed);
assert_eq!(
rejected.diagnostics().failures,
vec![BootstrapValidationFailure {
check: "empty_sample_count",
observed: 9,
required: 10,
comparison: "at_least",
}]
);
}
#[test]
fn validation_diagnostics_explain_numeric_vital_veto_even_when_all_empty() {
for count in [1, 5] {
let mut samples = empty_validation_samples(12);
for sample in samples.iter_mut().take(count) {
sample.vital_signs_absent = false;
}
let result = evaluate_validation(&samples);
assert!(!result.passed);
assert_eq!(result.empty_sample_count, 12);
let diagnostics = result.diagnostics();
let json = serde_json::to_value(&diagnostics).unwrap();
assert_eq!(
json["requirements"],
serde_json::json!({
"sample_count": 12,
"min_empty_sample_count": 10,
"max_stale_sample_count": 0,
"max_vital_sign_sample_count": 0,
})
);
assert_eq!(
json["failures"],
serde_json::json!([{
"check": "vital_sign_sample_count",
"observed": count,
"required": 0,
"comparison": "at_most",
}])
);
assert!(diagnostics.failure_summary().contains(&format!(
"numeric vital-sign estimates in {count}/12 samples (0 allowed)"
)));
}
}
#[test]
fn validation_diagnostics_reject_stale_incomplete_and_oversized_windows() {
for count in [0, 11, 13] {
let result = evaluate_validation(&empty_validation_samples(count));
assert!(!result.passed);
let diagnostics = result.diagnostics();
assert_eq!(diagnostics.failures[0].check, "sample_count");
assert_eq!(diagnostics.failures[0].observed, count);
assert_eq!(diagnostics.failures[0].required, 12);
assert!(diagnostics
.failure_summary()
.contains("check the CSI stream"));
}
let mut samples = empty_validation_samples(12);
samples[0].fresh_tick = false;
let result = evaluate_validation(&samples);
assert!(!result.passed);
assert_eq!(result.sample_count, 12);
assert_eq!(result.empty_sample_count, 11);
assert_eq!(
result.diagnostics().failures,
vec![BootstrapValidationFailure {
check: "stale_sample_count",
observed: 1,
required: 0,
comparison: "at_most",
}]
);
}
#[test]
fn validation_diagnostics_report_all_simultaneous_failures() {
let mut samples = empty_validation_samples(11);
samples[0].fresh_tick = false;
samples[1].calibrated_empty = false;
samples[2].vital_signs_absent = false;
let result = evaluate_validation(&samples);
assert!(!result.passed);
let diagnostics = result.diagnostics();
assert_eq!(
diagnostics
.failures
.iter()
.map(|failure| failure.check)
.collect::<Vec<_>>(),
[
"sample_count",
"empty_sample_count",
"stale_sample_count",
"vital_sign_sample_count",
]
);
let summary = diagnostics.failure_summary();
for expected in [
"11 samples collected (exactly 12 required)",
"9 samples reported empty (at least 10 required)",
"1 stale samples (0 allowed)",
"numeric vital-sign estimates in 1/12 samples (0 allowed)",
] {
assert!(summary.contains(expected), "missing {expected}: {summary}");
}
}
#[test]
fn refinement_diagnostics_keep_fresh_count_and_omit_empty_vote_requirement() {
let diagnostics = validation_diagnostics(11, None, 1, 5);
let json = serde_json::to_value(&diagnostics).unwrap();
assert!(json["requirements"].get("min_empty_sample_count").is_none());
assert_eq!(
diagnostics
.failures
.iter()
.map(|failure| failure.check)
.collect::<Vec<_>>(),
[
"sample_count",
"stale_sample_count",
"vital_sign_sample_count",
]
);
assert_eq!(diagnostics.failures[0].observed, 11);
assert!(diagnostics
.failure_summary()
.contains("5/12 samples (0 allowed)"));
let passing = validation_diagnostics(12, None, 0, 0);
assert!(passing.failures.is_empty());
assert!(passing.failure_summary().is_empty());
}
#[test]
fn restart_verifies_the_exact_stored_payload_bytes() {
let dir = tempfile::tempdir().unwrap();
@@ -24,6 +24,9 @@ pub const CALIBRATION_MODEL_RECEIPT_SCHEMA: &str =
pub const CALIBRATED_PRESENCE_EVIDENCE_SCHEMA: &str =
"ruview.calibration.calibrated-presence-evidence.v2";
/// Schema for bounded explanations, independent from calibration authority.
pub const DETECTOR_DIAGNOSTICS_SCHEMA: &str = "ruview.calibration.detector-diagnostics.v1";
/// Length-only canonicalizer for calibration frames (issue #1170 pattern,
/// shared with `multistatic_bridge`). Raw ESP32 amplitudes arrive at the
/// hardware's native width (HT20 ≈ 64, HT40 ≈ 128/192); the FieldModel is
@@ -90,7 +93,7 @@ fn mean_frame(frames: &[Vec<f64>]) -> Option<Vec<f64>> {
Some(mean)
}
fn perturbation_occupancy(field: &FieldModel, frames: &[Vec<f64>]) -> Option<usize> {
fn perturbation_occupancy_with_diagnostics(field: &FieldModel, frames: &[Vec<f64>]) -> Option<(usize, OccupancyDiagnostics)> {
let adaptive_threshold = field.empty_room_residual_energy_threshold();
let frame = match adaptive_threshold {
Some(_) => mean_frame(frames)?,
@@ -122,7 +125,19 @@ fn perturbation_occupancy(field: &FieldModel, frames: &[Vec<f64>]) -> Option<usi
}
}
};
Some(count)
let threshold = adaptive_threshold.unwrap_or(1.0).max(1.0);
let energy = perturbation.total_energy;
Some((count, OccupancyDiagnostics {
residual_energy: energy.is_finite().then_some(energy),
decision_threshold: Some(threshold),
signed_margin: (energy - threshold).is_finite().then_some(energy - threshold),
window_frames: frames.len(),
fallback_reason: Some("eigenvalue_estimator_unavailable"),
}))
}
fn perturbation_occupancy(field: &FieldModel, frames: &[Vec<f64>]) -> Option<usize> {
perturbation_occupancy_with_diagnostics(field, frames).map(|(count, _)| count)
}
/// Provenance for a count produced by the calibrated field model. Callers
@@ -143,10 +158,21 @@ impl CalibratedOccupancyMethod {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Explanation of the very same scoring operation; it grants no authority.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize)]
pub struct OccupancyDiagnostics {
pub residual_energy: Option<f64>,
pub decision_threshold: Option<f64>,
pub signed_margin: Option<f64>,
pub window_frames: usize,
pub fallback_reason: Option<&'static str>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CalibratedOccupancy {
pub person_count: usize,
pub method: CalibratedOccupancyMethod,
pub diagnostics: OccupancyDiagnostics,
}
/// Create a FieldModelConfig for single-link mode (one ESP32 node = one link).
@@ -284,11 +310,15 @@ pub fn calibrated_occupancy(
return Some(CalibratedOccupancy {
person_count: person_count.min(MAX_SINGLE_LINK_OCCUPANCY),
method: CalibratedOccupancyMethod::Eigenvalue,
diagnostics: OccupancyDiagnostics {
residual_energy: None, decision_threshold: None, signed_margin: None,
window_frames: frames.len(), fallback_reason: None,
},
});
}
let (person_count, diagnostics) = perturbation_occupancy_with_diagnostics(field, &frames)?;
Some(CalibratedOccupancy {
person_count: perturbation_occupancy(field, &frames)?,
method: CalibratedOccupancyMethod::PerturbationEnergy,
person_count, method: CalibratedOccupancyMethod::PerturbationEnergy, diagnostics,
})
}
@@ -799,6 +829,27 @@ mod tests {
);
}
#[test]
fn diagnostic_margin_explains_the_same_perturbation_decision() {
let field = fresh_test_model();
let baseline = field.modes().unwrap().baseline[0].clone();
for offset in [0.0, 0.3, 5.0, 20.0] {
let history: VecDeque<Vec<f64>> = (0..50)
.map(|_| baseline.iter().map(|value| value + offset).collect()).collect();
let result = calibrated_occupancy(&field, &history, 1_500_000).unwrap();
assert_eq!(result.person_count, occupancy_or_fallback(&field, &history, 1_500_000, 0.0, 0));
if result.method == CalibratedOccupancyMethod::PerturbationEnergy {
let energy = result.diagnostics.residual_energy.unwrap();
let threshold = result.diagnostics.decision_threshold.unwrap();
assert_eq!(result.diagnostics.signed_margin, Some(energy - threshold));
assert_eq!(result.person_count > 0, energy > threshold);
assert_eq!(result.diagnostics.window_frames, 50);
} else {
assert_eq!(result.diagnostics.signed_margin, None);
}
}
}
#[test]
fn occupancy_falls_back_after_expiry_without_a_process_restart() {
let field = fresh_test_model();
@@ -474,6 +474,19 @@ struct CalibratedPresenceEvidence {
inference_method: String,
presence: bool,
person_count: usize,
#[serde(skip)]
diagnostics: Option<DetectorDiagnostics>,
}
/// Bounded decision explanation, separate from the strict authority envelope.
#[derive(Debug, Clone, Serialize)]
struct DetectorDiagnostics {
schema: &'static str,
scored_node_id: u8,
source_age_ms: u64,
source_sequence: u32,
required_window_frames: usize,
runtime: field_bridge::OccupancyDiagnostics,
}
/// Sensing update broadcast to WebSocket clients
@@ -495,6 +508,8 @@ struct SensingUpdate {
/// active model receipt. Omitted when no calibrated result is available.
#[serde(default, skip_serializing_if = "Option::is_none")]
calibrated_presence_evidence: Option<CalibratedPresenceEvidence>,
#[serde(skip_deserializing, skip_serializing_if = "Option::is_none")]
detector_diagnostics: Option<DetectorDiagnostics>,
// ── ADR-022 Phase 3: Enhanced multi-BSSID pipeline fields ──
/// Enhanced motion estimate from multi-BSSID pipeline.
#[serde(skip_serializing_if = "Option::is_none")]
@@ -2793,6 +2808,17 @@ impl AppStateInner {
inference_method: occupancy.method.wire_name().to_string(),
presence: occupancy.person_count > 0,
person_count: occupancy.person_count,
diagnostics: self.calibration_grid_binding.and_then(|binding| {
let node = self.node_states.get(&binding.source_node_id)?;
Some(DetectorDiagnostics {
schema: field_bridge::DETECTOR_DIAGNOSTICS_SCHEMA,
scored_node_id: binding.source_node_id,
source_age_ms: node.field_model_latest_seen?.elapsed().as_millis().min(u64::MAX as u128) as u64,
source_sequence: node.field_model_latest_sequence?,
required_window_frames: self.field_model.as_ref()?.modes()?.baseline_runtime_window_size.unwrap_or(50),
runtime: occupancy.diagnostics,
})
}),
})
}
@@ -3235,6 +3261,21 @@ mod calibration_expiry_tests {
state
}
#[test]
fn diagnostics_stay_outside_the_strict_calibration_authority_envelope() {
let state = state_with_runtime_window();
let evidence = state.calibrated_presence_evidence(5, 77, 1_500).unwrap();
let diagnostic = evidence.diagnostics.as_ref().expect("bound source diagnostics");
assert_eq!(diagnostic.scored_node_id, 5);
assert_eq!(diagnostic.runtime.window_frames, 50);
let encoded = serde_json::to_value(&evidence).unwrap();
assert!(encoded.get("diagnostics").is_none());
assert_eq!(encoded.as_object().unwrap().len(), 13);
assert_eq!(encoded["person_count"], 0);
assert_eq!(serde_json::to_value(diagnostic).unwrap()["schema"],
field_bridge::DETECTOR_DIAGNOSTICS_SCHEMA);
}
#[test]
fn calibrated_empty_blocks_vitals_even_when_room_heuristic_reports_one() {
let state = state_with_runtime_window();
@@ -5058,6 +5099,7 @@ async fn wifi_task(state: SharedState, tick_ms: u64) {
),
vital_signs: None,
calibrated_presence_evidence: None,
detector_diagnostics: None,
enhanced_motion,
enhanced_breathing,
posture: posture_str,
@@ -5227,6 +5269,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
),
vital_signs: None,
calibrated_presence_evidence: None,
detector_diagnostics: None,
enhanced_motion: None,
enhanced_breathing: None,
posture: None,
@@ -6117,6 +6160,7 @@ mod latest_update_staleness_tests {
classifier: None,
activity: None,
calibrated_presence_evidence: None,
detector_diagnostics: None,
enhanced_motion: None,
enhanced_breathing: None,
posture: None,
@@ -10036,12 +10080,14 @@ async fn calibration_promote_bootstrap(
}
let validation = bootstrap_baseline::evaluate_validation(&samples);
let validation_diagnostics = validation.diagnostics();
if !validation.passed {
return Json(serde_json::json!({
"success": false,
"error_code": "bootstrap_validation_failed",
"error": "The held out empty room check did not pass. The model was not stored.",
"error": format!("The held out empty room check did not pass: {}. The model was not stored.", validation_diagnostics.failure_summary()),
"validation": validation,
"validation_diagnostics": validation_diagnostics,
}));
}
@@ -10107,6 +10153,7 @@ async fn calibration_promote_bootstrap(
"success": true,
"message": "Validated startup baseline stored locally.",
"validation": validation,
"validation_diagnostics": validation_diagnostics,
"holdout_window_size": holdout_window_size,
"bootstrap_baseline": stored,
"calibrated_evidence_authorized": false,
@@ -10236,6 +10283,12 @@ async fn calibration_refine_bootstrap(
+ Duration::from_millis(BOOTSTRAP_VALIDATION_MIN_SPACING_MS);
}
let validation_diagnostics = bootstrap_baseline::validation_diagnostics(
residuals.len(),
None,
stale_sample_count,
vital_sign_sample_count,
);
if residuals.len() != BOOTSTRAP_VALIDATION_SAMPLES
|| stale_sample_count != 0
|| vital_sign_sample_count != 0
@@ -10243,12 +10296,13 @@ async fn calibration_refine_bootstrap(
return Json(serde_json::json!({
"success": false,
"error_code": "bootstrap_refinement_validation_failed",
"error": "The fresh empty-room refinement gate did not pass. The stored baseline was not changed.",
"error": format!("The fresh empty-room refinement gate did not pass: {}. The stored baseline was not changed.", validation_diagnostics.failure_summary()),
"validation": {
"sample_count": residuals.len(),
"stale_sample_count": stale_sample_count,
"vital_sign_sample_count": vital_sign_sample_count,
},
"validation_diagnostics": validation_diagnostics,
}));
}
@@ -10335,6 +10389,7 @@ async fn calibration_refine_bootstrap(
Json(serde_json::json!({
"success": true,
"message": "The local empty-room startup baseline was refined.",
"validation_diagnostics": validation_diagnostics,
"validation": {
"sample_count": residuals.len(),
"stale_sample_count": stale_sample_count,
@@ -11709,6 +11764,7 @@ fn mediatek_room_update(
},
vital_signs: None,
calibrated_presence_evidence: None,
detector_diagnostics: None,
enhanced_motion: None,
enhanced_breathing: None,
posture: None,
@@ -12629,6 +12685,7 @@ async fn udp_receiver_task(
classification,
signal_field,
vital_signs: published_vitals,
detector_diagnostics: calibrated_presence_evidence.as_ref().and_then(|e| e.diagnostics.clone()),
calibrated_presence_evidence,
enhanced_motion: None,
enhanced_breathing: None,
@@ -13124,6 +13181,7 @@ async fn udp_receiver_task(
&sub_variances,
),
vital_signs: published_vitals,
detector_diagnostics: calibrated_presence_evidence.as_ref().and_then(|e| e.diagnostics.clone()),
calibrated_presence_evidence,
enhanced_motion: None,
enhanced_breathing: None,
@@ -13364,6 +13422,7 @@ async fn simulated_data_task(state: SharedState, tick_ms: u64) {
),
vital_signs: None,
calibrated_presence_evidence: None,
detector_diagnostics: None,
enhanced_motion: None,
enhanced_breathing: None,
posture: None,
@@ -16068,6 +16127,7 @@ mod observatory_persons_field_position_tests {
signal_field,
vital_signs: None,
calibrated_presence_evidence: None,
detector_diagnostics: None,
enhanced_motion: None,
enhanced_breathing: None,
posture: None,