Merge pull request #2289 from ruvnet/integrate/release-20261017

chore: integrate open PRs, rufield b74bc03, STBC edge fix (#2286)
This commit is contained in:
rUv authored and GitHub committed 2026-10-22 11:08:33 -04:00
commit fefb0f918e
111 files changed
+4262 -480

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
# The contributor harness hashes provenance inputs byte-for-byte. Keep text
# files in this boundary on LF even when Windows enables core.autocrlf.
harness/ruview/** text=auto eol=lf
harness/homecore/** text=auto eol=lf
@@ -32,6 +32,9 @@ jobs:
- name: Validate the manifest is well-formed JSON
run: python -c "import json; json.load(open('scripts/fix-markers.json')); print('manifest OK')"
- name: Run unit tests for fix-marker checker
run: python -m unittest scripts/tests/test_check_fix_markers.py
- name: Check fix markers
run: python scripts/check_fix_markers.py
+15 -5
View File
@@ -1,6 +1,7 @@
name: nvsim-server → ghcr.io
# Builds and publishes the nvsim-server Docker image to ghcr.io on:
# Builds and publishes the nvsim-server Docker image under the repository
# owner (ghcr.io/<owner>/nvsim-server) on:
# - push to main affecting nvsim-server or nvsim
# - tag push matching nvsim-server-v*
# - manual workflow_dispatch
@@ -31,6 +32,10 @@ jobs:
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
# ghcr.io is always the target; there is no Docker Hub leg here.
# The image path is derived from the repository owner because a fork
# cannot push to the upstream's `ruvnet` namespace — that push
# fails with `denied: The requested installation does not exist`.
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
with:
registry: ghcr.io
@@ -41,7 +46,7 @@ jobs:
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302
with:
images: ghcr.io/ruvnet/nvsim-server
images: ghcr.io/${{ github.repository_owner }}/nvsim-server
tags: |
type=ref,event=branch
type=ref,event=tag
@@ -61,11 +66,16 @@ jobs:
platforms: linux/amd64
- name: Smoke-test the image
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/nvsim-server
run: |
docker pull ghcr.io/ruvnet/nvsim-server:sha-${GITHUB_SHA::7} || \
docker pull ghcr.io/ruvnet/nvsim-server:latest
# `type=sha,format=short` is emitted on every event, so this is
# the one tag that always exists after a successful push. The
# old `|| latest` fallback hid a failed push and only worked on
# the default branch, so tag builds never reached this step.
docker pull "${IMAGE}:sha-${GITHUB_SHA::7}"
docker run --rm -d --name nvsim-test -p 7878:7878 \
ghcr.io/ruvnet/nvsim-server:latest
"${IMAGE}:sha-${GITHUB_SHA::7}"
sleep 4
curl -fsS http://localhost:7878/api/health
docker stop nvsim-test
+44
View File
@@ -0,0 +1,44 @@
name: W1700K RF diagnostics
on:
pull_request:
paths: ['tools/w1700k/**', 'docs/rf-diagnostics/**', '.github/workflows/rf-diagnostics.yml']
push:
branches: [main]
paths: ['tools/w1700k/**', 'docs/rf-diagnostics/**', '.github/workflows/rf-diagnostics.yml']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: rf-diagnostics-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Bridge ABI and failure tests
run: make -C tools/w1700k check
- name: Viewer validation tests
run: |
node --test docs/rf-diagnostics/model.test.cjs
node --check docs/rf-diagnostics/app.js
deploy:
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
needs: verify
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Stage static viewer only
run: |
mkdir -p _site
cp docs/rf-diagnostics/index.html docs/rf-diagnostics/model.js docs/rf-diagnostics/app.js docs/rf-diagnostics/style.css _site/
- name: Preserve existing Pages content
uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./_site
destination_dir: rf-diagnostics
keep_files: true
commit_message: 'deploy(rf-diagnostics): ${{ github.sha }}'
+8 -1
View File
@@ -29,9 +29,16 @@ jobs:
run: cargo install cargo-audit --locked --version 0.22.2
- name: Audit the checked-in Rust lockfile
# cargo-audit resolves .cargo/audit.toml relative to the CURRENT
# WORKING DIRECTORY, not relative to the --file lockfile, and it has no
# flag to point at a config path. Auditing from the repository root
# therefore ran with cargo-audit's defaults and applied none of the
# reviewed ignores in v2/.cargo/audit.toml. Run inside the v2 workspace
# so that policy file is actually loaded.
working-directory: v2
run: |
set -o pipefail
cargo audit --file v2/Cargo.lock --json | tee v2/cargo-audit.json
cargo audit --file Cargo.lock --json | tee cargo-audit.json
- name: Upload Rust advisory report
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+41 -14
View File
@@ -1,12 +1,16 @@
name: wifi-densepose sensing-server → Docker Hub + ghcr.io
# Build + publish the `wifi-densepose` sensing-server image to both Docker Hub
# (`ruvnet/wifi-densepose`) and ghcr.io (`ghcr.io/ruvnet/wifi-densepose`) on:
# Build + publish the `wifi-densepose` sensing-server image under the repository
# owner (`docker.io/<owner>/wifi-densepose` and `ghcr.io/<owner>/wifi-densepose`)
# on:
# - push to main affecting the Dockerfile, the server crate, the UI assets,
# or this workflow itself,
# - tag push matching v* (release builds),
# - manual workflow_dispatch.
#
# Docker Hub is only published when DOCKERHUB_USERNAME/DOCKERHUB_TOKEN exist;
# ghcr.io always publishes.
#
# Closes #520 and #514: the stale `:latest` is rebuilt and pushed automatically
# whenever the surface that produces it changes, and the Dockerfile fails the
# build if the observatory/pose-fusion UI assets ever go missing again.
@@ -61,17 +65,42 @@ jobs:
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
- name: Log in to Docker Hub
# Bypassing docker/login-action@v3: the action kept emitting
# "malformed HTTP Authorization header" against a known-good
# dckr_pat_* token (verified by direct curl against the Hub API).
# `docker login --password-stdin` is the documented credential
# path and avoids whatever encoding step the action injects.
# Publish under the repository owner instead of a hard-coded `ruvnet`
# namespace: a fork cannot push to the upstream's Docker Hub or ghcr.io
# namespaces, and `github.repository_owner` resolves correctly for both
# the upstream and every fork.
#
# Docker Hub is optional. The login was previously hard-required, so a
# repository without DOCKERHUB_USERNAME/DOCKERHUB_TOKEN failed at
# `docker login` ("Must provide --username with --password-stdin") before
# it ever built anything. ghcr.io needs no secret, so it stays the
# always-on target.
#
# Bypassing docker/login-action@v3: the action kept emitting
# "malformed HTTP Authorization header" against a known-good
# dckr_pat_* token (verified by direct curl against the Hub API).
# `docker login --password-stdin` is the documented credential
# path and avoids whatever encoding step the action injects.
- name: Resolve publish targets
id: targets
env:
DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
OWNER: ${{ github.repository_owner }}
run: |
printf '%s' "$DH_TOKEN" | docker login docker.io -u "$DH_USER" --password-stdin
set -uo pipefail
image="wifi-densepose"
images="ghcr.io/${OWNER}/${image}"
if [ -n "$DH_USER" ] && [ -n "$DH_TOKEN" ]; then
images="docker.io/${OWNER}/${image}"$'\n'"${images}"
printf '%s' "$DH_TOKEN" | docker login docker.io -u "$DH_USER" --password-stdin
else
echo "::warning::DOCKERHUB_USERNAME/DOCKERHUB_TOKEN are not configured; this run publishes to ghcr.io only."
fi
echo "images<<EOF" >> "$GITHUB_OUTPUT"
echo "$images" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
echo "ghcr_image=ghcr.io/${OWNER}/${image}" >> "$GITHUB_OUTPUT"
- name: Log in to ghcr.io
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
@@ -84,9 +113,7 @@ jobs:
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302
with:
images: |
docker.io/ruvnet/wifi-densepose
ghcr.io/ruvnet/wifi-densepose
images: ${{ steps.targets.outputs.images }}
tags: |
type=ref,event=branch
type=ref,event=tag
@@ -123,7 +150,7 @@ jobs:
- name: Smoke-test image assets + LAN-mode HTTP
run: |
set -euo pipefail
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
IMAGE="${{ steps.targets.outputs.ghcr_image }}:sha-${GITHUB_SHA::7}"
docker pull "$IMAGE"
docker run --rm --entrypoint sh "$IMAGE" -c \
'ls /app/ui/observatory.html /app/ui/pose-fusion.html /app/ui/index.html /app/ui/viz.html >/dev/null'
@@ -147,7 +174,7 @@ jobs:
- name: Smoke-test the bearer-token auth path
run: |
set -euo pipefail
IMAGE="ghcr.io/ruvnet/wifi-densepose:sha-${GITHUB_SHA::7}"
IMAGE="${{ steps.targets.outputs.ghcr_image }}:sha-${GITHUB_SHA::7}"
docker run -d --name auth \
-p 3000:3000 \
-e CSI_SOURCE=simulated \
+2
View File
@@ -3,7 +3,9 @@ CLAUDE.local.md
# ESP32 firmware build artifacts and local config (contains WiFi credentials)
firmware/esp32-csi-node/build/
firmware/esp32-csi-node/build-*/
firmware/esp32-csi-node/sdkconfig
firmware/esp32-csi-node/sdkconfig.*-release*
firmware/esp32-csi-node/sdkconfig.defaults
firmware/esp32-csi-node/sdkconfig.old
# Downloaded WASM3 source (fetched at configure time)
+15
View File
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Community batch (PRs #2100-#2157 by aepod, reviewed and integrated)
Integrated as one set: conflicts resolved, review findings fixed, validated on the integrated branch. Hardware figures cited by the PRs are the author's own live runs on ESP32-S3 nodes and were not independently reproduced here.
@@ -726,6 +727,20 @@ Model release (no new firmware binary). Firmware remains at v0.6.0-esp32.
### Verified
- Real hardware: ESP32-S3 (COM7) WiFi CSI + ESP32-C6/MR60BHA2 (COM4) 60 GHz mmWave running concurrently. HR=75 bpm, BR=25/min at 52 cm range. All 11 QEMU CI jobs green.
## [0.8.15-esp32] - 2026-10-21
### ESP32 CSI input and onboarding corrections
- Process the bounded prefix of S3 STBC CSI frames instead of discarding the
entire frame and starving edge vitals/features (#2286). Raw CSI is unchanged.
- Bound gateway ping timeouts to one probe interval and document that APs
transmitting ICMP at 802.11b rates require a separate CSI traffic source
(#2287). Add a duration/rate-bounded LAN UDP traffic helper.
- Prepare the USB serial/JTAG driver before starting IDF 6 onboarding, reuse
an installed driver, and propagate initialization failures (#2302).
- Add production-code host regressions. These software checks do not establish
physical-board qualification, sensing accuracy, or medical validity.
## [v0.4.3-esp32] — 2026-03-15
### Fixed
+3
View File
@@ -1,5 +1,8 @@
# π RuView
> **W1700K RF diagnostics:** [receiver bridge](tools/w1700k/README.md) · [browser viewer](https://ruvnet.github.io/RuView/rf-diagnostics/) · [integration design](docs/adr/ADR-419-w1700k-rf-diagnostics.md).
> Reads IQ locally and exports bounded quality summaries to RuField. Synthetic contract tests are separate from hardware validation. RF diagnostics only; no CSI extraction or presence detection.
<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%">
+1 -10
View File
@@ -3,8 +3,6 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{stroke-dasharray:3 14;animation:flow 8s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@@ -12,20 +10,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
.layer{animation:layer 6s ease-in-out infinite}
@keyframes layer{50%{transform:translateY(-5px)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="303" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="44" y="42" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">COGNITUM ONE</text><text x="44" y="99" font-size="42" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Ambient Intelligence.</text><text x="44" y="147" font-size="25" fill="#78cfff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >At the edge of the physical world.</text><text x="44" y="190" font-size="16" fill="#b0c2db" text-anchor="start" font-weight="500" >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="#071124" text-anchor="start" font-weight="500" >Explore Cognitum</text><path d="M263 247H284M277 240L284 247L277 254" fill="none" stroke="#071124" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="947" cy="151" r="173" fill="url(#halo)"/><g transform="translate(945 173) scale(1.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><g transform="translate(945 145) scale(1.65)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><g class="layer" style="animation-delay:-0.0s"><path d="M-38 -23L0 -42L38 -23L0 -4Z" fill="#102749" stroke="#78cfff"/><path d="M-38 -23V-16L0 3L38 -16V-23M0 -4V3" fill="#09172c" stroke="#315685"/></g><g class="layer" style="animation-delay:-0.7s"><path d="M-38 -10L0 -29L38 -10L0 9Z" fill="#102749" stroke="#438fff"/><path d="M-38 -10V-3L0 16L38 -3V-10M0 9V16" fill="#09172c" stroke="#315685"/></g><g class="layer" style="animation-delay:-1.4s"><path d="M-38 3L0 -16L38 3L0 22Z" fill="#102749" stroke="#438fff"/><path d="M-38 3V10L0 29L38 10V3M0 22V29" fill="#09172c" stroke="#315685"/></g><path d="M-12 -31L0 -37L12 -31L0 -25Z" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-26 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M0 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M26 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/></g></g><ellipse cx="945" cy="150" rx="140" ry="30" transform="rotate(-25 945 150)" fill="none" stroke="#437cb2" stroke-dasharray="5 18" class="flow"/><ellipse cx="945" cy="150" rx="160" ry="43" transform="rotate(-25 945 150)" fill="none" stroke="#437cb2" stroke-dasharray="5 18" class="flow"/><ellipse cx="945" cy="150" rx="180" ry="56" transform="rotate(-25 945 150)" fill="none" stroke="#437cb2" stroke-dasharray="5 18" class="flow"/><path d="M24 1H1176" stroke="url(#accent)" opacity=".55"/><path d="M24 303H1176" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -4,28 +4,12 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="30" y="39" font-size="11" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / 06</text><text x="30" y="89" font-size="25" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >A guided AI operator</text><text x="30" y="126" font-size="14" fill="#b0c2db" text-anchor="start" font-weight="500" >Source-cited setup and verification.</text><text x="30" y="151" font-size="14" fill="#b0c2db" text-anchor="start" font-weight="500" >CLI, MCP and agent workflows.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 144) scale(1.12)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><path d="M0 0L0 -36" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M0 -43l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><path d="M0 0L-38 -10" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M-38 -17l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><path d="M0 0L-23 31" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M-23 24l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><path d="M0 0L30 28" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M30 21l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><path d="M0 0L39 -17" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M39 -24l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><circle cx="0" cy="0" r="15" stroke="#b8f36c" fill="none" /><path d="M-6 0L-1 5L8 -6" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></g></g><path d="M30 204H550" fill="none" stroke="#2d4e77" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#78cfff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">TOOLKIT GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M24 1H556" stroke="url(#accent)" opacity=".55"/><path d="M24 275H556" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -4,28 +4,12 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="30" y="39" font-size="11" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / 05</text><text x="30" y="89" font-size="25" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Local automation</text><text x="30" y="126" font-size="14" fill="#b0c2db" text-anchor="start" font-weight="500" >Connect rooms to your smart home.</text><text x="30" y="151" font-size="14" fill="#b0c2db" text-anchor="start" font-weight="500" >Home Assistant, MQTT and Matter.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 144) scale(1.12)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><path d="M-43 12L0 -10L43 12L0 35Z" fill="#102749" stroke="#438fff"/><path d="M-32 17V-16L0 -35L32 -16V17M-32 -16L0 3L32 -16M0 3V34" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M-22 20L-10 13L12 25M-10 13V-5" fill="none" stroke="#b8f36c" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="-22" cy="20" r="4" stroke="#ffb45e" fill="#102749" class="breathe"/><circle cx="-10" cy="-5" r="4" stroke="#ffb45e" fill="#102749" class="breathe"/><circle cx="12" cy="25" r="4" stroke="#ffb45e" fill="#102749" class="breathe"/></g></g><path d="M30 204H550" fill="none" stroke="#2d4e77" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#78cfff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">INTEGRATION GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M24 1H556" stroke="url(#accent)" opacity=".55"/><path d="M24 275H556" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

-16
View File
@@ -3,29 +3,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
.layer{animation:layer 6s ease-in-out infinite}
@keyframes layer{50%{transform:translateY(-5px)}}
@keyframes flow{to{stroke-dashoffset:-136}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="30" y="39" font-size="11" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / 04</text><text x="30" y="89" font-size="25" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Edge sensor mesh</text><text x="30" y="126" font-size="14" fill="#b0c2db" text-anchor="start" font-weight="500" >Provision, capture and calibrate.</text><text x="30" y="151" font-size="14" fill="#b0c2db" text-anchor="start" font-weight="500" >Explore ESP32 firmware and runbooks.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 144) scale(1.12)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><g class="layer" style="animation-delay:-0.0s"><path d="M-38 -23L0 -42L38 -23L0 -4Z" fill="#102749" stroke="#78cfff"/><path d="M-38 -23V-16L0 3L38 -16V-23M0 -4V3" fill="#09172c" stroke="#315685"/></g><g class="layer" style="animation-delay:-0.7s"><path d="M-38 -10L0 -29L38 -10L0 9Z" fill="#102749" stroke="#438fff"/><path d="M-38 -10V-3L0 16L38 -3V-10M0 9V16" fill="#09172c" stroke="#315685"/></g><g class="layer" style="animation-delay:-1.4s"><path d="M-38 3L0 -16L38 3L0 22Z" fill="#102749" stroke="#438fff"/><path d="M-38 3V10L0 29L38 10V3M0 22V29" fill="#09172c" stroke="#315685"/></g><path d="M-12 -31L0 -37L12 -31L0 -25Z" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-26 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M0 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M26 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/></g></g><path d="M30 204H550" fill="none" stroke="#2d4e77" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#78cfff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">HARDWARE GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M24 1H556" stroke="url(#accent)" opacity=".55"/><path d="M24 275H556" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

-20
View File
@@ -1,28 +1,11 @@
<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="#102749"/><stop offset=".55" stop-color="#071429"/><stop offset="1" stop-color="#03091a"/></linearGradient><linearGradient id="accent"><stop stop-color="#9ee7ff"/><stop offset="1" stop-color="#348bff"/></linearGradient><radialGradient id="halo"><stop stop-color="#348bff" stop-opacity=".17"/><stop offset="1" stop-color="#348bff" stop-opacity="0"/></radialGradient></defs><style>
text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.humanTurn{transform-box:fill-box;transform-origin:center 85%;animation:humanTurn 8s ease-in-out infinite}
.humanBody{animation:humanBody 1.6s ease-in-out infinite}
.armFar{transform-origin:-9px -24px;animation:armSwing 1.6s ease-in-out infinite}
@@ -40,8 +23,5 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="30" y="39" font-size="11" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / 03</text><text x="30" y="89" font-size="25" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Pose research</text><text x="30" y="126" font-size="14" fill="#b0c2db" text-anchor="start" font-weight="500" >Train and compare body keypoints.</text><text x="30" y="151" font-size="14" fill="#b0c2db" text-anchor="start" font-weight="500" >Live accuracy differs from benchmarks.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 144) scale(1.12)"><ellipse cy="53" rx="37" ry="7" fill="#020816" class="gaitShadow"/><g class="humanBody"><defs><linearGradient id="humanSurface" x1="0" y1="0" x2="1" y2=".3"><stop stop-color="#153d48"/><stop offset=".42" stop-color="#338469"/><stop offset=".7" stop-color="#83c766"/><stop offset="1" stop-color="#163c4a"/></linearGradient><linearGradient id="humanRear"><stop stop-color="#0f2d40"/><stop offset="1" stop-color="#397a68"/></linearGradient></defs><g class="humanTurn"><g stroke="#88d783" stroke-width=".65" stroke-linejoin="round"><g class="armFar"><path d="M-9 -25C-15 -25 -18 -19 -21 -13L-29 -5C-32 -3 -33 1 -30 3L-25 2L-14 -7C-9 -12 -6 -19 -6 -23Z" fill="url(#humanRear)"/></g><g class="legFar"><path d="M-6 5C-13 10 -15 18 -17 26L-25 43L-29 47C-30 49 -28 51 -24 51L-17 50L-16 46L-8 30C-4 23 2 15 3 10Z" fill="url(#humanRear)"/><circle cx="-15" cy="26" r="1.8" fill="#ffb45e"/></g><g class="legNear"><path d="M3 5C10 7 14 17 19 23C24 28 23 35 24 41L29 45C32 47 32 49 28 50L20 49C17 48 17 46 17 42L14 32C8 28 2 23 -3 16L-4 10Z" fill="url(#humanSurface)"/><circle cx="19" cy="28" r="1.8" fill="#ffb45e"/></g><g class="armNear"><path d="M7 -26C14 -25 17 -20 20 -13L24 -17L29 -24C30 -27 33 -29 35 -26L35 -22L30 -16L24 -8C22 -5 18 -5 16 -8L8 -17Z" fill="url(#humanSurface)"/></g><path d="M-4 -32L4 -33L6 -28C11 -27 14 -24 13 -19L9 -5L10 7C10 13 2 15 -5 12L-10 7L-8 -5L-12 -20C-13 -25 -8 -28 -5 -28Z" fill="url(#humanSurface)"/><path d="M-6 -42C-6 -48 2 -51 7 -47C10 -45 10 -41 9 -39L11 -36L8 -35L7 -31L3 -29L-3 -32L-5 -37Z" fill="url(#humanSurface)"/></g><path d="M-9 -23Q-2 -18 10 -23M-7 -15Q0 -11 9 -16M-6 -8Q1 -5 8 -9M-7 1Q1 5 9 1M0 -26L2 9M-4 -40Q0 -42 7 -40M2 -32L6 -34" fill="none" stroke="#b8f36c" stroke-width="0.55" stroke-linecap="round" stroke-linejoin="round" opacity=".65"/><path d="M-5 -45Q1 -49 6 -46M-10 -22L-6 -8" fill="none" stroke="#78cfff" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" opacity=".8"/><path d="M-8 -25Q-2 -21 1 -10L3 9Q-5 8 -6 3L-5 -8Z" fill="#a7ebff" class="humanLight" opacity=".2"/><circle cx="-9" cy="-24" r="3.5" stroke="#ffb45e" fill="none" stroke-opacity=".25"/><circle cx="-9" cy="-24" r="1.5" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="10" cy="-23" r="3.5" stroke="#ffb45e" fill="none" stroke-opacity=".25"/><circle cx="10" cy="-23" r="1.5" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="1" cy="-12" r="3.5" stroke="#ffb45e" fill="none" stroke-opacity=".25"/><circle cx="1" cy="-12" r="1.5" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="2" cy="9" r="3.5" stroke="#ffb45e" fill="none" stroke-opacity=".25"/><circle cx="2" cy="9" r="1.5" stroke="#ffb45e" fill="#ffb45e" class="spark"/></g><path d="M-42 -42V-53H-30M30 -53H42V-42M42 45V55H30M-30 55H-42V45" fill="none" stroke="#438fff" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" /></g></g><path d="M30 204H550" fill="none" stroke="#2d4e77" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#78cfff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">READ THE EVIDENCE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M24 1H556" stroke="url(#accent)" opacity=".55"/><path d="M24 275H556" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -3,29 +3,15 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
.sweep{animation:sweep 5s ease-in-out infinite;transform-origin:0 0}
@keyframes sweep{0%,100%{transform:rotate(-20deg)}50%{transform:rotate(155deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="30" y="39" font-size="11" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / 01</text><text x="30" y="89" font-size="25" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Presence &amp; motion</text><text x="30" y="126" font-size="14" fill="#b0c2db" text-anchor="start" font-weight="500" >Explore changes in room occupancy.</text><text x="30" y="151" font-size="14" fill="#b0c2db" text-anchor="start" font-weight="500" >Inspect motion features from CSI.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 144) scale(1.12)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><ellipse rx="18" ry="8.64" fill="none" stroke="#78cfff" opacity=".4"/><ellipse rx="32" ry="15.36" fill="none" stroke="#78cfff" opacity=".4"/><ellipse rx="46" ry="22.08" fill="none" stroke="#78cfff" opacity=".4"/><path d="M-46 0H46M0 -23V23" fill="none" stroke="#438fff" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" /><g class="sweep"><path d="M0 0L40 -18" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="34" cy="-15" r="3" stroke="#b8f36c" fill="#b8f36c"/></g><circle cx="-20" cy="8" r="3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="12" cy="13" r="3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="23" cy="-8" r="3" stroke="#ffb45e" fill="#ffb45e" class="spark"/></g></g><path d="M30 204H550" fill="none" stroke="#2d4e77" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#78cfff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">SENSING GUIDE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M24 1H556" stroke="url(#accent)" opacity=".55"/><path d="M24 275H556" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

+1 -15
View File
@@ -3,29 +3,15 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
.cursor{animation:cursor 5s ease-in-out infinite}
@keyframes cursor{0%,100%{transform:translateX(-35px);opacity:.3}50%{transform:translateX(35px);opacity:1}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="579" height="275" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="30" y="39" font-size="11" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / 02</text><text x="30" y="89" font-size="25" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Breathing &amp; vital signals</text><text x="30" y="126" font-size="14" fill="#b0c2db" text-anchor="start" font-weight="500" >Inspect phase-derived signal trends.</text><text x="30" y="151" font-size="14" fill="#b0c2db" text-anchor="start" font-weight="500" >Validate estimates on your hardware.</text><circle cx="465" cy="170" r="110" fill="url(#halo)"/><g transform="translate(480 144) scale(1.12)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><path d="M-49 -14C-34 -14 -30 -35 -17 -35S0 7 14 7S34 -14 49 -14" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="trace" style="animation-delay:-0s" opacity="1.0"/><path d="M-49 -2C-34 -2 -30 -23 -17 -23S0 19 14 19S34 -2 49 -2" fill="none" stroke="#b8f36c" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="trace" style="animation-delay:-1s" opacity="0.8"/><path d="M-49 10C-34 10 -30 -11 -17 -11S0 31 14 31S34 10 49 10" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="trace" style="animation-delay:-2s" opacity="0.6"/><g class="cursor"><path d="M0 -42V40" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-8" r="3" stroke="#ffb45e" fill="#ffb45e"/></g></g></g><path d="M30 204H550" fill="none" stroke="#2d4e77" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><path d="M30 204H550" fill="none" stroke="#78cfff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow" opacity=".5"/><text x="30" y="245" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">SIGNAL PIPELINE</text><path d="M510 239H540M532 231L540 239L532 247" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M24 1H556" stroke="url(#accent)" opacity=".55"/><path d="M24 275H556" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

-9
View File
@@ -4,7 +4,6 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{stroke-dasharray:3 14;animation:flow 8s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@@ -15,17 +14,9 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(76 75) scale(0.75)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><path d="M0 0L0 -36" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M0 -43l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><path d="M0 0L-38 -10" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M-38 -17l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><path d="M0 0L-23 31" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M-23 24l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><path d="M0 0L30 28" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M30 21l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><path d="M0 0L39 -17" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M39 -24l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><circle cx="0" cy="0" r="15" stroke="#b8f36c" fill="none" /><path d="M-6 0L-1 5L8 -6" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="140" y="63" font-size="34" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Choose your next step</text><text x="142" y="99" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">GUIDES / HARDWARE / INTEGRATIONS</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M24 1H1176" stroke="url(#accent)" opacity=".55"/><path d="M24 147H1176" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

+1 -10
View File
@@ -3,8 +3,6 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{stroke-dasharray:3 14;animation:flow 8s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@@ -12,20 +10,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
.layer{animation:layer 6s ease-in-out infinite}
@keyframes layer{50%{transform:translateY(-5px)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(76 75) scale(0.75)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><g class="layer" style="animation-delay:-0.0s"><path d="M-38 -23L0 -42L38 -23L0 -4Z" fill="#102749" stroke="#78cfff"/><path d="M-38 -23V-16L0 3L38 -16V-23M0 -4V3" fill="#09172c" stroke="#315685"/></g><g class="layer" style="animation-delay:-0.7s"><path d="M-38 -10L0 -29L38 -10L0 9Z" fill="#102749" stroke="#438fff"/><path d="M-38 -10V-3L0 16L38 -3V-10M0 9V16" fill="#09172c" stroke="#315685"/></g><g class="layer" style="animation-delay:-1.4s"><path d="M-38 3L0 -16L38 3L0 22Z" fill="#102749" stroke="#438fff"/><path d="M-38 3V10L0 29L38 10V3M0 22V29" fill="#09172c" stroke="#315685"/></g><path d="M-12 -31L0 -37L12 -31L0 -25Z" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-26 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M0 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M26 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/></g></g><text x="140" y="63" font-size="34" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Intelligence at the edge</text><text x="142" y="99" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">SENSORS / FIRMWARE / MODULES</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M24 1H1176" stroke="url(#accent)" opacity=".55"/><path d="M24 147H1176" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

+1 -10
View File
@@ -3,8 +3,6 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{stroke-dasharray:3 14;animation:flow 8s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@@ -12,20 +10,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
.layer{animation:layer 6s ease-in-out infinite}
@keyframes layer{50%{transform:translateY(-5px)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(76 75) scale(0.75)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><g class="layer" style="animation-delay:-0.0s"><path d="M-38 -23L0 -42L38 -23L0 -4Z" fill="#102749" stroke="#78cfff"/><path d="M-38 -23V-16L0 3L38 -16V-23M0 -4V3" fill="#09172c" stroke="#315685"/></g><g class="layer" style="animation-delay:-0.7s"><path d="M-38 -10L0 -29L38 -10L0 9Z" fill="#102749" stroke="#438fff"/><path d="M-38 -10V-3L0 16L38 -3V-10M0 9V16" fill="#09172c" stroke="#315685"/></g><g class="layer" style="animation-delay:-1.4s"><path d="M-38 3L0 -16L38 3L0 22Z" fill="#102749" stroke="#438fff"/><path d="M-38 3V10L0 29L38 10V3M0 22V29" fill="#09172c" stroke="#315685"/></g><path d="M-12 -31L0 -37L12 -31L0 -25Z" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-26 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M0 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M26 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/></g></g><text x="140" y="63" font-size="34" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Evidence before accuracy claims</text><text x="142" y="99" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">BENCHMARKS / REPRODUCERS / LIMITS</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M24 1H1176" stroke="url(#accent)" opacity=".55"/><path d="M24 147H1176" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

@@ -2,9 +2,6 @@
text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{stroke-dasharray:3 14;animation:flow 8s linear infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@@ -13,19 +10,12 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
.sweep{animation:sweep 5s ease-in-out infinite;transform-origin:0 0}
@keyframes sweep{0%,100%{transform:rotate(-20deg)}50%{transform:rotate(155deg)}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="147" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(76 75) scale(0.75)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><ellipse rx="18" ry="8.64" fill="none" stroke="#78cfff" opacity=".4"/><ellipse rx="32" ry="15.36" fill="none" stroke="#78cfff" opacity=".4"/><ellipse rx="46" ry="22.08" fill="none" stroke="#78cfff" opacity=".4"/><path d="M-46 0H46M0 -23V23" fill="none" stroke="#438fff" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" /><g class="sweep"><path d="M0 0L40 -18" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="34" cy="-15" r="3" stroke="#b8f36c" fill="#b8f36c"/></g><circle cx="-20" cy="8" r="3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="12" cy="13" r="3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="23" cy="-8" r="3" stroke="#ffb45e" fill="#ffb45e" class="spark"/></g></g><text x="140" y="63" font-size="34" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Explore the capabilities</text><text x="142" y="99" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">SENSING / MODELS / AUTOMATION</text><g transform="translate(1012 84) scale(0.55)"><path d="M-82 36L0 -6L82 36L0 78Z" fill="#0b1b35" stroke="#2e5b8e"/><path d="M-82 36V-52L0 -94L82 -52V36M-82 -52L0 -10L82 -52M0 -10V78" fill="none" stroke="#4473a5"/><g class="scan"><path d="M-81 32L0 -10L81 32L0 74Z" fill="#438fff" fill-opacity=".12" stroke="#78cfff" stroke-opacity=".7"/></g><circle cx="-40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="-15" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="40" cy="6" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="-20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="20" cy="30" r="2" stroke="#438fff" fill="#438fff" class="spark"/><circle cx="0" cy="49" r="2" stroke="#438fff" fill="#438fff" class="spark"/><g class="orbit"><ellipse cx="0" cy="4" rx="102" ry="30" fill="none" stroke="#438fff" stroke-opacity=".4" stroke-dasharray="2 8"/><circle cx="102" cy="4" r="4" fill="#9ee7ff"/></g><ellipse cx="0" cy="36" rx="85" ry="28" fill="none" stroke="#438fff" class="wave"/></g><path d="M1140 70H1160M1152 62L1160 70L1152 78" fill="none" stroke="#78cfff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M24 1H1176" stroke="url(#accent)" opacity=".55"/><path d="M24 147H1176" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

+2 -10
View File
@@ -2,8 +2,6 @@
text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
.scan{animation:scan 7s ease-in-out infinite}
.orbit{stroke-dasharray:3 14;animation:flow 8s linear infinite}
@@ -12,19 +10,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
.cursor{animation:cursor 5s ease-in-out infinite}
@keyframes cursor{0%,100%{transform:translateX(-35px);opacity:.3}50%{transform:translateX(35px);opacity:1}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

-12
View File
@@ -3,26 +3,16 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.scan{animation:scan 7s ease-in-out infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
.wave{transform-box:fill-box;transform-origin:center;animation:wave 4s ease-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
.layer{animation:layer 6s ease-in-out infinite}
@keyframes layer{50%{transform:translateY(-5px)}}
.humanTurn{transform-box:fill-box;transform-origin:center 85%;animation:humanTurn 8s ease-in-out infinite}
.humanBody{animation:humanBody 1.6s ease-in-out infinite}
.armFar{transform-origin:-9px -24px;animation:armSwing 1.6s ease-in-out infinite}
@@ -40,8 +30,6 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes wave{0%{transform:scale(.5);opacity:.7}100%{transform:scale(1.3);opacity:0}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="1199" height="449" rx="18" fill="url(#wash)" stroke="#2d4e77"/><text x="48" y="45" font-size="12" fill="#78cfff" text-anchor="start" font-weight="500" class="mono">RUVIEW / SPATIAL INTELLIGENCE</text><text x="44" y="140" font-size="59" fill="#f3f7ff" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Invisible signals.</text><text x="44" y="204" font-size="55" fill="#b8f36c" text-anchor="start" font-weight="650" letter-spacing="-0.6" >Visible possibility.</text><text x="48" y="256" font-size="18" fill="#b0c2db" text-anchor="start" font-weight="500" >Explore the space between sensing and understanding.</text><text x="48" y="284" font-size="18" fill="#b0c2db" text-anchor="start" font-weight="500" >WiFi sensing. Edge models. Local automation.</text><rect x="48" y="327" width="240" height="49" rx="24" fill="url(#accent)"/><text x="72" y="358" font-size="18" fill="#071124" text-anchor="start" font-weight="500" >Enter the field</text><path d="M246 351H266M259 344L266 351L259 358" fill="none" stroke="#071124" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><text x="49" y="418" font-size="10" fill="#b0c2db" text-anchor="start" font-weight="500" class="mono">RF SENSING / OPEN RESEARCH / EDGE COMPUTE</text><circle cx="955" cy="230" r="234" fill="url(#halo)"/><path d="M785 270L648 432" fill="none" stroke="#1b3558" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" /><path d="M809 270L739 432" fill="none" stroke="#1b3558" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" /><path d="M833 270L830 432" fill="none" stroke="#1b3558" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" /><path d="M857 270L921 432" fill="none" stroke="#1b3558" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" /><path d="M881 270L1012 432" fill="none" stroke="#1b3558" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" /><path d="M905 270L1103 432" fill="none" stroke="#1b3558" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" /><path d="M929 270L1194 432" fill="none" stroke="#1b3558" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" /><path d="M768 286H1110" fill="none" stroke="#1b3558" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" /><path d="M741 317H1126" fill="none" stroke="#1b3558" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" /><path d="M714 348H1142" fill="none" stroke="#1b3558" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" /><path d="M687 379H1158" fill="none" stroke="#1b3558" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" /><path d="M660 410H1174" fill="none" stroke="#1b3558" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round" /><path d="M780 295L963 211L1148 303L960 400Z" fill="#0d2342" fill-opacity=".65" stroke="#315685"/><path d="M780 295V153L963 70L1148 162V303M780 153L960 244L1148 162M960 244V400" fill="none" stroke="#438fff" opacity=".55"/><path d="M963 70V211L1148 303V162Z" fill="#438fff" fill-opacity=".05"/><g class="scan"><path d="M780 286L963 202L1148 294L960 390Z" fill="#78cfff" fill-opacity=".08" stroke="#78cfff" stroke-opacity=".6"/></g><g transform="translate(957 320)"><ellipse rx="90" ry="30" fill="none" stroke="#78cfff" class="wave" style="animation-delay:-0.0s"/></g><g transform="translate(957 320)"><ellipse rx="116" ry="38" fill="none" stroke="#78cfff" class="wave" style="animation-delay:-1.3s"/></g><g transform="translate(957 320)"><ellipse rx="142" ry="46" fill="none" stroke="#78cfff" class="wave" style="animation-delay:-2.6s"/></g><g transform="translate(959 236) scale(1.8)"><ellipse cy="53" rx="37" ry="7" fill="#020816" class="gaitShadow"/><g class="humanBody"><defs><linearGradient id="humanSurface" x1="0" y1="0" x2="1" y2=".3"><stop stop-color="#153d48"/><stop offset=".42" stop-color="#338469"/><stop offset=".7" stop-color="#83c766"/><stop offset="1" stop-color="#163c4a"/></linearGradient><linearGradient id="humanRear"><stop stop-color="#0f2d40"/><stop offset="1" stop-color="#397a68"/></linearGradient></defs><g class="humanTurn"><g stroke="#88d783" stroke-width=".65" stroke-linejoin="round"><g class="armFar"><path d="M-9 -25C-15 -25 -18 -19 -21 -13L-29 -5C-32 -3 -33 1 -30 3L-25 2L-14 -7C-9 -12 -6 -19 -6 -23Z" fill="url(#humanRear)"/></g><g class="legFar"><path d="M-6 5C-13 10 -15 18 -17 26L-25 43L-29 47C-30 49 -28 51 -24 51L-17 50L-16 46L-8 30C-4 23 2 15 3 10Z" fill="url(#humanRear)"/><circle cx="-15" cy="26" r="1.8" fill="#ffb45e"/></g><g class="legNear"><path d="M3 5C10 7 14 17 19 23C24 28 23 35 24 41L29 45C32 47 32 49 28 50L20 49C17 48 17 46 17 42L14 32C8 28 2 23 -3 16L-4 10Z" fill="url(#humanSurface)"/><circle cx="19" cy="28" r="1.8" fill="#ffb45e"/></g><g class="armNear"><path d="M7 -26C14 -25 17 -20 20 -13L24 -17L29 -24C30 -27 33 -29 35 -26L35 -22L30 -16L24 -8C22 -5 18 -5 16 -8L8 -17Z" fill="url(#humanSurface)"/></g><path d="M-4 -32L4 -33L6 -28C11Line truncated

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

-16
View File
@@ -4,28 +4,12 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(90 45) scale(0.65)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><path d="M0 0L0 -36" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M0 -43l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><path d="M0 0L-38 -10" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M-38 -17l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><path d="M0 0L-23 31" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M-23 24l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><path d="M0 0L30 28" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M30 21l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><path d="M0 0L39 -17" fill="none" stroke="#438fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M39 -24l7 4v8l-7 4l-7 -4v-8Z" fill="#102749" stroke="#78cfff" class="breathe"/><circle cx="0" cy="0" r="15" stroke="#b8f36c" fill="none" /><path d="M-6 0L-1 5L8 -6" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></g></g><text x="90" y="98" font-size="16" fill="#f3f7ff" text-anchor="middle" font-weight="500" >AI toolkit</text><path d="M24 1H156" stroke="url(#accent)" opacity=".55"/><path d="M24 115H156" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

-16
View File
@@ -3,29 +3,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
.layer{animation:layer 6s ease-in-out infinite}
@keyframes layer{50%{transform:translateY(-5px)}}
@keyframes flow{to{stroke-dashoffset:-136}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(90 45) scale(0.65)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><g class="layer" style="animation-delay:-0.0s"><path d="M-38 -23L0 -42L38 -23L0 -4Z" fill="#102749" stroke="#78cfff"/><path d="M-38 -23V-16L0 3L38 -16V-23M0 -4V3" fill="#09172c" stroke="#315685"/></g><g class="layer" style="animation-delay:-0.7s"><path d="M-38 -10L0 -29L38 -10L0 9Z" fill="#102749" stroke="#438fff"/><path d="M-38 -10V-3L0 16L38 -3V-10M0 9V16" fill="#09172c" stroke="#315685"/></g><g class="layer" style="animation-delay:-1.4s"><path d="M-38 3L0 -16L38 3L0 22Z" fill="#102749" stroke="#438fff"/><path d="M-38 3V10L0 29L38 10V3M0 22V29" fill="#09172c" stroke="#315685"/></g><path d="M-12 -31L0 -37L12 -31L0 -25Z" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-26 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M0 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M26 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/></g></g><text x="90" y="98" font-size="16" fill="#f3f7ff" text-anchor="middle" font-weight="500" >Hardware</text><path d="M24 1H156" stroke="url(#accent)" opacity=".55"/><path d="M24 115H156" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

-16
View File
@@ -4,28 +4,12 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
.breathe{animation:breathe 4s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(90 45) scale(0.65)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><path d="M-43 12L0 -10L43 12L0 35Z" fill="#102749" stroke="#438fff"/><path d="M-32 17V-16L0 -35L32 -16V17M-32 -16L0 3L32 -16M0 3V34" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /><path d="M-22 20L-10 13L12 25M-10 13V-5" fill="none" stroke="#b8f36c" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="flow"/><circle cx="-22" cy="20" r="4" stroke="#ffb45e" fill="#102749" class="breathe"/><circle cx="-10" cy="-5" r="4" stroke="#ffb45e" fill="#102749" class="breathe"/><circle cx="12" cy="25" r="4" stroke="#ffb45e" fill="#102749" class="breathe"/></g></g><text x="90" y="98" font-size="16" fill="#f3f7ff" text-anchor="middle" font-weight="500" >Smart home</text><path d="M24 1H156" stroke="url(#accent)" opacity=".55"/><path d="M24 115H156" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

-16
View File
@@ -2,30 +2,14 @@
text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.trace{stroke-dasharray:400;animation:trace 6s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
.cursor{animation:cursor 5s ease-in-out infinite}
@keyframes cursor{0%,100%{transform:translateX(-35px);opacity:.3}50%{transform:translateX(35px);opacity:1}}
@keyframes trace{0%,100%{stroke-dashoffset:0}50%{stroke-dashoffset:90}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(90 45) scale(0.65)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><path d="M-49 -14C-34 -14 -30 -35 -17 -35S0 7 14 7S34 -14 49 -14" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="trace" style="animation-delay:-0s" opacity="1.0"/><path d="M-49 -2C-34 -2 -30 -23 -17 -23S0 19 14 19S34 -2 49 -2" fill="none" stroke="#b8f36c" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="trace" style="animation-delay:-1s" opacity="0.8"/><path d="M-49 10C-34 10 -30 -11 -17 -11S0 31 14 31S34 10 49 10" fill="none" stroke="#78cfff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="trace" style="animation-delay:-2s" opacity="0.6"/><g class="cursor"><path d="M0 -42V40" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" /><circle cx="0" cy="-8" r="3" stroke="#ffb45e" fill="#ffb45e"/></g></g></g><text x="90" y="98" font-size="16" fill="#f3f7ff" text-anchor="middle" font-weight="500" >Models</text><path d="M24 1H156" stroke="url(#accent)" opacity=".55"/><path d="M24 115H156" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

-16
View File
@@ -3,29 +3,13 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.flow{stroke-dasharray:5 12;animation:flow 5s linear infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
.layer{animation:layer 6s ease-in-out infinite}
@keyframes layer{50%{transform:translateY(-5px)}}
@keyframes flow{to{stroke-dashoffset:-136}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(90 45) scale(0.65)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><g class="layer" style="animation-delay:-0.0s"><path d="M-38 -23L0 -42L38 -23L0 -4Z" fill="#102749" stroke="#78cfff"/><path d="M-38 -23V-16L0 3L38 -16V-23M0 -4V3" fill="#09172c" stroke="#315685"/></g><g class="layer" style="animation-delay:-0.7s"><path d="M-38 -10L0 -29L38 -10L0 9Z" fill="#102749" stroke="#438fff"/><path d="M-38 -10V-3L0 16L38 -3V-10M0 9V16" fill="#09172c" stroke="#315685"/></g><g class="layer" style="animation-delay:-1.4s"><path d="M-38 3L0 -16L38 3L0 22Z" fill="#102749" stroke="#438fff"/><path d="M-38 3V10L0 29L38 10V3M0 22V29" fill="#09172c" stroke="#315685"/></g><path d="M-12 -31L0 -37L12 -31L0 -25Z" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><path d="M-26 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M0 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/><path d="M26 22V39" fill="none" stroke="#ffb45e" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="flow"/></g></g><text x="90" y="98" font-size="16" fill="#f3f7ff" text-anchor="middle" font-weight="500" >Cognitum</text><path d="M24 1H156" stroke="url(#accent)" opacity=".55"/><path d="M24 115H156" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

-16
View File
@@ -2,30 +2,14 @@
text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,Arial,sans-serif}
.mono{font-family:"SFMono-Regular",Consolas,"Liberation Mono",monospace;letter-spacing:1.6px}
.float{animation:float 5s ease-in-out infinite}
.spark{animation:spark 3.5s ease-in-out infinite}
@keyframes float{0%,100%{transform:translate(0,0)}50%{transform:translate(3px,-10px)}}
.sweep{animation:sweep 5s ease-in-out infinite;transform-origin:0 0}
@keyframes sweep{0%,100%{transform:rotate(-20deg)}50%{transform:rotate(155deg)}}
@keyframes spark{50%{opacity:.25}}
@media(prefers-reduced-motion:reduce){*{animation:none!important}}
</style><rect x=".5" y=".5" width="179" height="115" rx="18" fill="url(#wash)" stroke="#2d4e77"/><g transform="translate(90 45) scale(0.65)"><ellipse cy="53" rx="48" ry="8" fill="#020816" opacity=".65"/><g class="float"><ellipse rx="18" ry="8.64" fill="none" stroke="#78cfff" opacity=".4"/><ellipse rx="32" ry="15.36" fill="none" stroke="#78cfff" opacity=".4"/><ellipse rx="46" ry="22.08" fill="none" stroke="#78cfff" opacity=".4"/><path d="M-46 0H46M0 -23V23" fill="none" stroke="#438fff" stroke-width="0.7" stroke-linecap="round" stroke-linejoin="round" /><g class="sweep"><path d="M0 0L40 -18" fill="none" stroke="#b8f36c" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /><circle cx="34" cy="-15" r="3" stroke="#b8f36c" fill="#b8f36c"/></g><circle cx="-20" cy="8" r="3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="12" cy="13" r="3" stroke="#ffb45e" fill="#ffb45e" class="spark"/><circle cx="23" cy="-8" r="3" stroke="#ffb45e" fill="#ffb45e" class="spark"/></g></g><text x="90" y="98" font-size="16" fill="#f3f7ff" text-anchor="middle" font-weight="500" >Start here</text><path d="M24 1H156" stroke="url(#accent)" opacity=".55"/><path d="M24 115H156" stroke="#438fff" opacity=".15"/></svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+2 -5
View File
@@ -13,15 +13,12 @@ text{font-family:"Avenir Next","Segoe UI Variable Display","Segoe UI",Helvetica,
@keyframes scan{0%,100%{transform:translateY(0)}50%{transform:translateY(-76px)}}
.sweep{animation:sweep 5s ease-in-out infinite;transform-origin:0 0}
.cursor{animation:cursor 5s ease-in-out infinite}
@keyframes sweep{0%,100%{transform:rotate(-20deg)}50%{transform:rotate(155deg)}}
@keyframes cursor{0%,100%{transform:translateX(-35px);opacity:.3}50%{transform:translateX(35px);opacity:1}}
@keyframes spark{50%{opacity:.25}}
@keyframes flow{to{stroke-dashoffset:-136}}
@keyframes breathe{50%{opacity:.4}}

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+3 -3
View File
@@ -97,7 +97,7 @@ export class NvAppStore extends LitElement {
.chip .swatch {
width: 7px; height: 7px; border-radius: 50%;
}
.chip .count { color: var(--ink-3); font-size: 10px; }
.chip .count { color: var(--ink-2); font-size: 10px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -149,7 +149,7 @@ export class NvAppStore extends LitElement {
.badge.budget { color: var(--accent-2); border-color: oklch(0.78 0.12 195 / 0.3); }
.badge.rt-running { color: var(--ok); border-color: oklch(0.78 0.14 145 / 0.5); background: oklch(0.78 0.14 145 / 0.08); }
.badge.rt-simulated { color: var(--accent); border-color: oklch(0.78 0.14 70 / 0.5); background: oklch(0.78 0.14 70 / 0.08); }
.badge.rt-mesh-only { color: var(--ink-3); border-color: var(--line); }
.badge.rt-mesh-only { color: var(--ink-2); border-color: var(--line); }
.events-feed {
background: var(--bg-2);
border: 1px solid var(--line);
@@ -388,7 +388,7 @@ export class NvAppStore extends LitElement {
<div class="card-foot">
<span class="events">${app.crate}</span>
${evCount > 0 ? html`<span class="card-events-count">⚡ ${evCount} ev</span>` : ''}
<span class="toggle ${active ? 'on' : ''}" role="switch"
<span class="toggle ${active ? 'on' : ''}" role="switch" aria-label=${`Enable ${app.name}`}
aria-checked=${active}
data-app-toggle=${app.id}
@click=${() => this.toggle(app)}></span>
+1 -1
View File
@@ -66,7 +66,7 @@ export class NvConsole extends LitElement {
border-left: 2px solid transparent;
}
.line:hover { background: var(--bg-1); }
.ts { color: var(--ink-4); font-size: 10.5px; padding-top: 1px; }
.ts { color: var(--ink-3); font-size: 10.5px; padding-top: 1px; }
.lvl {
font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.04em; padding-top: 1px;
+3 -3
View File
@@ -394,7 +394,7 @@ export class NvGhostMurmur extends LitElement {
<span class="lbl">Distance from sensor</span>
<span class="val" id="demo-dist-val">${this.formatDistance(this.distanceM)}</span>
</div>
<input type="range" id="demo-distance"
<input type="range" id="demo-distance" aria-label="Distance from sensor"
min="-2" max="5" step="0.05"
.value=${String(Math.log10(this.distanceM))}
@input=${(e: Event) => { this.distanceM = Math.pow(10, +(e.target as HTMLInputElement).value); }} />
@@ -407,7 +407,7 @@ export class NvGhostMurmur extends LitElement {
<span class="lbl">Heart dipole moment</span>
<span class="val" id="demo-moment-val">${m.toExponential(2)} A·m²</span>
</div>
<input type="range" id="demo-moment"
<input type="range" id="demo-moment" aria-label="Heart dipole moment"
min="-10" max="-6" step="0.05"
.value=${String(this.momentLog10)}
@input=${(e: Event) => { this.momentLog10 = +(e.target as HTMLInputElement).value; }} />
@@ -536,7 +536,7 @@ export class NvGhostMurmur extends LitElement {
</div>
<h2>RuView's three-tier mesh — what is actually buildable</h2>
<div class="architecture"> ┌──────────────────────────┐
<div class="architecture" tabindex="0" role="group" aria-label="RuView three-tier mesh architecture"> ┌──────────────────────────┐
│ Tier 3 — NV-diamond │ Range: 0.1–2 m (lab)
│ magnetometer ring │ Status: nvsim simulator only
│ (close-confirm) │ Hardware: $$$ (≥$8k DNV-B1)
+1 -1
View File
@@ -423,7 +423,7 @@ export class NvInspector extends LitElement {
role="tab" aria-selected=${this.tab === 'witness'}
@click=${() => this.tab = 'witness'}>Witness</button>
</div>
<div class="body" role="tabpanel">
<div class="body" role="tabpanel" tabindex="0">
${this.renderHeader()}
${this.tab === 'signal' ? this.renderSignalTab()
: this.tab === 'frame' ? this.renderFrameTab()
+18 -2
View File
@@ -185,9 +185,20 @@ export class NvOnboarding extends LitElement {
z-index: 240;
display: grid; place-items: center;
opacity: 0; pointer-events: none;
transition: opacity 0.18s;
/* A dismissed modal must leave the rendering + accessibility tree, not
just fade to opacity 0. While the 0.18s fade-out runs, automated
scanners (and mid-animation users) still see the panel, and contrast
tooling composites the fading text over the dimmed backdrop and
reports false failures. Delaying visibility by the fade duration keeps
the animation intact while making the closed state truly absent. */
visibility: hidden;
transition: opacity 0.18s, visibility 0s linear 0.18s;
}
:host([open]) {
opacity: 1; pointer-events: auto;
visibility: visible;
transition: opacity 0.18s, visibility 0s;
}
:host([open]) { opacity: 1; pointer-events: auto; }
.card {
background: var(--bg-1);
border: 1px solid var(--line-2);
@@ -329,6 +340,11 @@ export class NvOnboarding extends LitElement {
this.open = true;
this.setAttribute('open', '');
}
// Signals that the async storage read above has settled. Until it is set,
// "the tour was already seen" and "the decision has not been made yet"
// are indistinguishable, so a caller can act in the gap and then watch the
// modal open underneath it.
this.setAttribute('ready', '');
}
override disconnectedCallback(): void {
super.disconnectedCallback();
@@ -172,7 +172,7 @@ export class NvSettingsDrawer extends LitElement {
<div class="desc">Stops the rotating diamond, animated field lines, and chart easing. Auto-on if your system has the prefers-reduced-motion preference set.</div>
</div>
<span class="toggle ${motionReduced.value ? 'on' : ''}"
role="switch" aria-checked=${motionReduced.value}
role="switch" aria-checked=${motionReduced.value} aria-label="Reduce motion"
@click=${() => motionReduced.value = !motionReduced.value}></span>
</div>
</div>
@@ -185,7 +185,7 @@ export class NvSettingsDrawer extends LitElement {
<div class="desc">When you change a Tunables slider or load a new scene, push the change to the worker without a manual restart.</div>
</div>
<span class="toggle ${autoUpdate.value ? 'on' : ''}"
role="switch" aria-checked=${autoUpdate.value}
role="switch" aria-checked=${autoUpdate.value} aria-label="Auto-rerun on edit"
@click=${() => autoUpdate.value = !autoUpdate.value}></span>
</div>
</div>
+25 -6
View File
@@ -15,13 +15,32 @@ test.describe('axe-core a11y smoke', () => {
for (const view of VIEWS) {
test(`view: ${view}`, async ({ page }) => {
await page.goto('/');
// Dismiss the welcome modal if it auto-shows.
await page.evaluate(() => {
const sr = (document.querySelector('nv-app') as HTMLElement & { shadowRoot: ShadowRoot }).shadowRoot;
const ob = sr.querySelector('nv-onboarding') as HTMLElement | null;
if (ob?.hasAttribute('open')) {
(ob.shadowRoot?.querySelector('.skip') as HTMLElement | null)?.click();
// Dismiss the welcome modal if it auto-shows, then wait until it has left
// the accessibility tree. A single poll handles every race at once,
// because each of these was observed as an intermittent failure across
// chromium/firefox/webkit:
// - `nv-app` may not have upgraded yet (no shadowRoot to query);
// - Lit may not have upgraded `nv-onboarding` yet, so a `.skip` click
// would be a silent no-op;
// - `nv-onboarding` decides whether to open inside an async storage
// read, so before its `ready` attribute is set "not open" only means
// "not decided yet". Treating that as dismissed let the modal open
// underneath the scan.
// - the modal fades out over 180ms while staying in the DOM. Scanning
// inside that window samples a half-transparent panel, and contrast
// tooling composites the fading text over the dimmed backdrop into
// false failures no user ever sees.
await page.waitForFunction(() => {
const app = document.querySelector('nv-app') as (HTMLElement & { shadowRoot: ShadowRoot }) | null;
if (!app?.shadowRoot) return false;
const ob = app.shadowRoot.querySelector('nv-onboarding') as HTMLElement | null;
if (!ob) return true;
if (!ob.shadowRoot || !ob.hasAttribute('ready')) return false;
if (ob.hasAttribute('open')) {
(ob.shadowRoot.querySelector('.skip') as HTMLElement | null)?.click();
return false;
}
return getComputedStyle(ob).visibility === 'hidden';
});
// Navigate to the view via the rail button (except for home which is default).
if (view !== 'home') {
+6
View File
@@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
@@ -66,6 +67,11 @@ export default defineConfig({
},
},
},
test: {
// tests/ holds Playwright specs (run via `npm run test:e2e`), not Vitest.
exclude: ['**/node_modules/**', '**/dist/**', 'tests/**'],
passWithNoTests: true,
},
server: {
port: 5173,
strictPort: true,
+40
View File
@@ -0,0 +1,40 @@
# ADR-419: W1700K diagnostics and RuField evidence boundary
Status: implementation candidate; hardware validation pending.
Date: 2026-10-14
## Decision
Use an original C consumer of the upstream MIT shared memory ABI, compact JSONL diagnostics, a local browser viewer, and a bounded RuField adapter. Spectrum quality is a separate modality and cannot contribute occupancy evidence.
Upstream: h0m3us3r/w1700k-sdr commit 0b0413e4309f0c083702fa4eb2d1c2bca4532163, host/stream/include/w1700k_stream.h. Do not copy the GPL recorder.
## Flow and invariants
1. The upstream daemon publishes native-endian IQ in ABI v1 shared memory.
2. The consumer validates geometry, reads bounded slots, checks tags around copies, and calculates RMS and int16 rail fractions.
3. JSONL retains source mode, sample rate, completion index, loss and quality reasons. Raw IQ stays on the host.
4. The browser imports records locally. Importing live-labelled data is still file replay.
5. RuField preserves source labels, produces untrusted diagnostics and excludes them from occupancy fusion.
Acceptance: synthetic ABI fixture through the real bridge, JSON validator, viewer and Rust adapter. Malformed geometry, truncation and invalid numbers fail closed. Unknown fields cannot smuggle IQ into downstream evidence.
## Configuration and timing
The header does not establish LO, gain, band or an atomic configuration generation. Caller frequency is advisory. All v1 records have config_verified=false and valid=false. Metrics remain useful for inspection; they cannot promote sensing claims.
Monotonic nanoseconds are host-local, not UTC or cross-device synchronization. Event slots consume no sample time. Unknown sample position remains null.
IQ scale is 8192. Flattening may legitimately exceed that amplitude; rail fraction counts int16 rails, not calibrated ADC clipping.
## Alternatives and limits
A second UDP receiver duplicates transport handling. Direct IQ into a CSI model violates its input contract. Upstream ci16 sidecars truncate gap indices and omit event positions, preventing exact arbitrary replay. Use shared memory and exact synthetic fixtures.
No CSI extraction, passive radar, pose, hardware accuracy lift or sustained 320 Msps benchmark is claimed. No router flashing or transmission.
## Security and release
Bound file sizes, offsets, record lengths and numeric ranges. Reject unknown diagnostic fields. Local file import performs no upload. Trust enrollment is separate from parsing; synthetic evidence stays labelled.
Publish tested source after independent review. Deploy only the viewer directory, preserving other Pages content. Roll back by reverting this change and its deployment.
+34
View File
@@ -0,0 +1,34 @@
# RuView ESP32 firmware 0.8.15 prerelease
Firmware 0.8.15 improves CSI processing and remote configuration recovery for
ESP32-S3 and ESP32-C6 nodes.
## Changes
- S3 STBC frames now contribute their bounded CSI prefix to edge processing
instead of being discarded, preserving input for vitals and feature output.
- Gateway self-ping waits are capped to one probe interval. AP-specific ICMP
airtime remains a deployment factor; `scripts/csi-udp-poke.py` provides a
bounded private-LAN traffic source when CSI traffic is otherwise sparse.
- USB serial/JTAG onboarding prepares or reuses the IDF 6 driver and reports
setup failures.
- The config API validates complete requests before writing and retains a
durable rollback bank across failed writes and recovery attempts.
- Rust dependencies were updated for current Wasmtime and WebPKI advisories;
the audit policy retains narrowly documented ignores for ZIP extraction
advisories whose vulnerable extraction APIs are not used by the workspace.
## Validation
- Firmware host suite passed, including production-code STBC, self-ping,
configuration rules, and write-through NVS rollback fault injection.
- ESP32-C6 firmware built with ESP-IDF 6.0.2 and fits the 4 MB partition map.
- The attached ESP32-C6 booted 0.8.15 from its existing OTA slot after a full
read-only flash backup. A 30-second sample counted 441 raw CSI, 26 vitals,
and 30 feature packets, with zero short packets, presence/count
contradictions, or timestamp reversals. Raw CSI and vitals values were not
retained.
- A prior paired check on 0.8.14 recorded 28.97 raw packets/s at baseline and
29.60 packets/s with bounded host traffic. This is AP-specific transport
evidence. This prerelease makes no sensing-accuracy or S3 physical
qualification claim. See the [candidate validation record](../validation/2026-10-20-esp32-csi-input-release-candidate.md).
+45
View File
@@ -0,0 +1,45 @@
# RuView RF diagnostics viewer
Open [index.html](index.html) in a modern browser, or use the deployed documentation page. This is a standalone local JSONL viewer, not a hardware connection. It has no runtime dependencies, telemetry, external assets, uploads or network API.
1. Run the producer described in [tools/w1700k](../../tools/w1700k/) and save its diagnostic JSONL output.
2. Select **Import local JSONL**. Files must be at most 10 MiB, with 1 to 10,000 records.
3. Inspect relative RMS, numeric rail fraction, reported missing samples, reason counts and the last 500 observations.
4. Select **Clear** to remove the loaded observations, or **Load synthetic demo** for a deterministic synthetic example.
Raw IQ and unknown fields are rejected. The viewer accepts the frozen `ruview.rf_diagnostics.v1` schema and requires `valid:false`, `config_verified:false` and the `configuration_unverified` reason. Integers must fit JavaScript's safe integer range; larger producer counters are rejected rather than silently rounded.
## Evidence boundary
| Producer source_kind | Display |
| --- | --- |
| synthetic | SYNTHETIC |
| captured_replay | CAPTURED REPLAY |
| live | LIVE UNVERIFIED |
Every imported file is replay. Source labels are claims from the input and do not prove that a device was used. Mixed input displays all applicable labels. Nothing here establishes human presence, motion, pose, CSI or calibrated RF power.
RMS is relative dBFS; numeric rails are an advisory clipping indicator. Missing sample totals sum the producer's per record values. Record order is preserved rather than interpreted as synchronized time. Host monotonic timestamps cannot establish wall clock freshness. The producer TTL is displayed but is never converted into a valid or fresh verdict for file replay.
All text is rendered with textContent. A restrictive content security policy blocks network connections. Invalid imports clear the previous dataset to avoid showing stale values as the new result.
## Verification
From the repository root:
```sh
node --test docs/rf-diagnostics/model.test.cjs
node --check docs/rf-diagnostics/app.js
```
Optional browser smoke with an existing Playwright installation and Chromium:
```sh
PLAYWRIGHT_MODULE=/absolute/path/to/playwright node docs/rf-diagnostics/browser-smoke.cjs
```
The browser test checks mobile layout, synthetic rendering, actual local file import, unverified live labels, malformed input rejection, clear and zero external requests. It does not establish physical hardware performance. No package is installed by these commands.
### Numeric contract
Sample rates are unknown (0), 40, 80, 160 or 320 Msps. Centre frequency is unknown or 2 to 8 GHz. Each record describes zero or 2048 samples; missing sample counts are multiples of 2048. Absolute sample start remains null. RMS is bounded to −200 through 16 dBFS to accommodate numeric filter headroom, and an available RMS requires an available rail fraction. Reason names are from the producer whitelist and cannot repeat.
+48
View File
@@ -0,0 +1,48 @@
'use strict';
const M=RFDiagnostics,$=id=>document.getElementById(id),ns='http://www.w3.org/2000/svg';
let generation=0;
function render(rows){
const last=rows.at(-1),kinds=[...new Set(rows.map(r=>M.modes[r.source_kind]))];
$('mode').textContent=kinds.join(' + ')||'NO DATA';
$('count').textContent=rows.length.toLocaleString();
$('rms').textContent=last?.rms_dbfs==null?'—':last.rms_dbfs.toFixed(2);
$('rails').textContent=last?.rail_fraction==null?'—':(last.rail_fraction*100).toFixed(3)+'%';
$('gaps').textContent=rows.length?rows.reduce((n,r)=>n+BigInt(r.missing_samples),0n).toLocaleString():'—';
const reasons=new Map();
for(const r of rows)for(const reason of new Set(r.invalid_reasons))reasons.set(reason,(reasons.get(reason)||0)+1);
$('reasons').replaceChildren();
for(const [reason,count] of reasons){const li=document.createElement('li');li.textContent=reason+' · '+count.toLocaleString();$('reasons').append(li);}
$('meta').textContent=last?[
'Source: '+last.source_id,'Origin: '+last.source_kind+' (file/demo replay)',
'Rate: '+(last.sample_rate_hz||'unknown')+' samples/s',
'Centre: '+(last.center_frequency_hz??'unknown')+' Hz (unverified)',
'Run / slot: '+last.run+' / '+last.slot_index,
'Host monotonic ns: '+last.observed_monotonic_ns,
'Absolute sample start: '+(last.sample_start??'unknown'),
'Configuration verified: false','Valid for inference: false',
'TTL: '+last.ttl_ms+' ms; replay freshness unknown'
].join('\n'):'No observations';
const tail=rows.slice(-500),values=tail.map(r=>r.rms_dbfs).filter(v=>v!==null);
const low=Math.min(...values),high=Math.max(...values),span=high-low||1;
$('chart').replaceChildren();
let path='';
tail.forEach((r,i)=>{
if(r.rms_dbfs===null){path+=' ';return;}
const x=20+i*960/Math.max(1,tail.length-1),y=220-200*(r.rms_dbfs-low)/span;
path+=(i===0||tail[i-1].rms_dbfs===null?'M':'L')+x+','+y+' ';
if(r.missing_samples){const dot=document.createElementNS(ns,'circle');dot.setAttribute('cx',x);dot.setAttribute('cy',y);dot.setAttribute('r',4);dot.setAttribute('fill','#ffb66c');$('chart').append(dot);}
});
const line=document.createElementNS(ns,'path');line.setAttribute('d',path);line.setAttribute('fill','none');line.setAttribute('stroke','#71e7db');line.setAttribute('stroke-width',2);$('chart').prepend(line);
$('range').textContent=values.length?'Range '+low.toFixed(2)+' to '+high.toFixed(2)+' dBFS · '+tail.length+' latest records':'No RMS observations';
$('chart').setAttribute('aria-label',$('range').textContent);
}
$('demo').onclick=()=>{generation++;render(M.demo());$('status').textContent='Synthetic demonstration loaded. No hardware evidence.';};
$('clear').onclick=()=>{generation++;render([]);$('file').value='';$('status').textContent='Cleared. No observations retained.';};
$('file').onchange=async()=>{
const ticket=++generation,file=$('file').files[0];if(!file)return;
try{
if(file.size>M.MAX_BYTES)throw Error('File exceeds 10 MiB');
const rows=M.parse(await file.text());if(ticket!==generation)return;
render(rows);$('status').textContent='Imported '+rows.length+' records. Source labels are unverified claims; this is file replay.';
}catch(e){if(ticket!==generation)return;render([]);$('status').textContent='Import rejected: '+e.message;}
};
+29
View File
@@ -0,0 +1,29 @@
'use strict';
const assert=require('node:assert/strict'),path=require('node:path');
const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright');
(async()=>{
const browser=await chromium.launch({headless:true});
try{
const page=await browser.newPage({viewport:{width:390,height:844}});
const errors=[],external=[];
page.on('pageerror',e=>errors.push(e.message));
page.on('request',r=>{if(/^https?:/.test(r.url()))external.push(r.url());});
await page.goto('file://'+path.join(__dirname,'index.html'));
await page.click('#demo');
assert.equal(await page.textContent('#mode'),'SYNTHETIC');
assert.equal(await page.textContent('#count'),'96');
assert(await page.locator('#chart path').getAttribute('d'));
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
const r=require('./model.js').demo()[0];r.source_kind='live';
await page.setInputFiles('#file',{name:'live.jsonl',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(r))});
await page.waitForFunction(()=>document.querySelector('#mode').textContent==='LIVE UNVERIFIED');
assert.match(await page.textContent('#status'),/file replay/);
await page.setInputFiles('#file',{name:'bad.jsonl',mimeType:'application/json',buffer:Buffer.from('{"iq":[1,2]}')});
await page.waitForFunction(()=>document.querySelector('#status').textContent.startsWith('Import rejected'));
assert.equal(await page.textContent('#count'),'0');
await page.click('#demo');await page.click('#clear');
assert.equal(await page.textContent('#count'),'0');
assert.deepEqual(errors,[]);assert.deepEqual(external,[]);
console.log('PASS mobile render, demo, JSONL import, unverified live label, malformed rejection, clear, zero external requests');
}finally{await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1});
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'none'; img-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'">
<title>RuView RF Diagnostics</title><link rel="stylesheet" href="style.css"><script defer src="model.js"></script><script defer src="app.js"></script></head>
<body><main><header><p class="eyebrow">COGNITUM / RUVIEW / W1700K</p><span id="mode" class="badge">NO DATA</span><h1>RF diagnostics.</h1><p class="intro">Understand the measurement before trusting the inference.</p></header>
<section class="controls" aria-label="Import controls"><label class="button">Import local JSONL<input id="file" type="file" accept=".jsonl,.ndjson,.json,application/json"></label><button id="demo">Load synthetic demo</button><button id="clear">Clear</button><p>Local processing only. Maximum 10 MiB and 10,000 observations. No uploads.</p></section>
<p id="status" role="status">Import bridge diagnostics or explore a clearly labelled synthetic example.</p>
<aside>ADVISORY ONLY · Configuration is unverified. These records cannot establish presence, pose or CSI accuracy. Imported files are replay, never an active hardware connection.</aside>
<section class="metrics" aria-label="Latest observation"><article><p>Latest RMS</p><strong id="rms">—</strong><small>relative dBFS, not calibrated RF power</small></article><article><p>Latest rails</p><strong id="rails">—</strong><small>samples at numeric limits</small></article><article><p>Reported missing</p><strong id="gaps">—</strong><small>sum of per record missing samples</small></article><article><p>Observations</p><strong id="count">0</strong><small>all v1 observations invalid</small></article></section>
<section class="panel"><h2>Relative RMS history</h2><p>Record order, last 500 observations. Missing metrics break the line. Orange dots flag missing samples.</p><svg id="chart" viewBox="0 0 1000 240" role="img" aria-label="Relative RMS history; import a diagnostics file"></svg><p id="range">No observations</p></section>
<div class="split"><section class="panel"><h2>Invalidation reasons</h2><ul id="reasons"><li>No observations</li></ul></section><section class="panel"><h2>Source metadata</h2><pre id="meta">No observations</pre></section></div>
<footer>RuField evidence boundary · 1 second TTL is producer metadata. Host monotonic time is not wall time; file replay cannot establish freshness.</footer></main></body></html>
+46
View File
@@ -0,0 +1,46 @@
(function(root){
'use strict';
const MAX_BYTES=10*1024*1024, MAX_RECORDS=10000;
const fields='schema source_kind source_id observed_monotonic_ns run slot_index sample_start sample_rate_hz center_frequency_hz config_verified valid invalid_reasons sample_count rms_dbfs rail_fraction missing_samples event_count overrun_slices ttl_ms'.split(' ');
const modes={synthetic:'SYNTHETIC',live:'LIVE UNVERIFIED',captured_replay:'CAPTURED REPLAY'};
function validate(r){
const fail=(s)=>{throw Error(s)};
if(!r||Array.isArray(r)||typeof r!=='object')fail('Expected an object');
for(const k of Object.keys(r))if(!fields.includes(k))fail('Unsupported field: '+k);
for(const k of fields)if(!Object.hasOwn(r,k))fail('Missing field: '+k);
if(r.schema!=='ruview.rf_diagnostics.v1')fail('Unsupported schema');
if(!Object.hasOwn(modes,r.source_kind)||r.source_id!=='w1700k')fail('Unsupported source');
for(const k of ['observed_monotonic_ns','run','slot_index','sample_rate_hz','sample_count','missing_samples','event_count','overrun_slices','ttl_ms'])
if(!Number.isSafeInteger(r[k])||r[k]<0)fail('Invalid '+k);
if(r.ttl_ms!==1000)fail('Invalid rate or TTL');
for(const k of ['sample_start','center_frequency_hz'])
if(r[k]!==null&&(!Number.isSafeInteger(r[k])||r[k]<0))fail('Invalid '+k);
if(r.rms_dbfs!==null&&(typeof r.rms_dbfs!=='number'||!Number.isFinite(r.rms_dbfs)))fail('Invalid RMS');
if(r.rail_fraction!==null&&(typeof r.rail_fraction!=='number'||!Number.isFinite(r.rail_fraction)||r.rail_fraction<0||r.rail_fraction>1))fail('Invalid rail fraction');
if(r.config_verified!==false||r.valid!==false)fail('v1 must remain unverified and invalid');
if(!Array.isArray(r.invalid_reasons)||r.invalid_reasons.length>32||!r.invalid_reasons.includes('configuration_unverified')||r.invalid_reasons.some(x=>typeof x!=='string'||!/^[a-z_]{1,64}$/.test(x)))fail('Invalid reasons');
if(![0,40000000,80000000,160000000,320000000].includes(r.sample_rate_hz))fail('Invalid sample rate');
if(r.sample_start!==null)fail('Absolute sample start is unavailable in v1');
if(r.center_frequency_hz!==null&&(r.center_frequency_hz<2000000000||r.center_frequency_hz>8000000000))fail('Invalid centre frequency');
if(![0,2048].includes(r.sample_count)||r.missing_samples%2048!==0)fail('Invalid sample count');
if(r.rms_dbfs!==null&&(r.rms_dbfs< -200||r.rms_dbfs>16||r.rail_fraction===null))fail('Invalid RMS or missing rail fraction');
const reasons=['configuration_unverified','lost','event','overrun','stale','tag_mismatch','run_change'];
if(new Set(r.invalid_reasons).size!==r.invalid_reasons.length||r.invalid_reasons.length>7||r.invalid_reasons.some(x=>!reasons.includes(x)))fail('Unsupported invalid reason');
return r;
}
function parse(text){
if(typeof text!=='string'||new TextEncoder().encode(text).length>MAX_BYTES)throw Error('File exceeds 10 MiB');
const lines=text.split(/\r?\n/).filter(x=>x.trim());
if(!lines.length||lines.length>MAX_RECORDS)throw Error('Expected 1 to 10,000 records');
return lines.map((s,i)=>{try{return validate(JSON.parse(s))}catch(e){throw Error('Record '+(i+1)+': '+e.message)}});
}
function demo(){return Array.from({length:96},(_,i)=>({
schema:'ruview.rf_diagnostics.v1',source_kind:'synthetic',source_id:'w1700k',
observed_monotonic_ns:i*100000000,run:1,slot_index:i,sample_start:null,
sample_rate_hz:40000000,center_frequency_hz:2437000000,config_verified:false,valid:false,
invalid_reasons:i%23===0?['configuration_unverified','lost']:['configuration_unverified'],
sample_count:2048,rms_dbfs:-34+Math.sin(i/8)*6,rail_fraction:i%23===0?0.03:0,
missing_samples:i%23===0?2048:0,event_count:0,overrun_slices:0,ttl_ms:1000}));}
const api={MAX_BYTES,MAX_RECORDS,modes,validate,parse,demo};
if(typeof module==='object')module.exports=api;else root.RFDiagnostics=api;
})(globalThis);
+23
View File
@@ -0,0 +1,23 @@
'use strict';
const {test}=require('node:test'),assert=require('node:assert/strict'),M=require('./model.js');
const good=()=>M.demo()[0];
test('synthetic fixture and JSONL roundtrip',()=>assert.equal(M.parse(M.demo().map(JSON.stringify).join('\n')).length,96));
test('captured and live labels remain unverified',()=>{for(const source_kind of ['live','captured_replay'])assert.equal(M.validate({...good(),source_kind}).valid,false);});
test('reject malformed, empty, oversized and too many records',()=>{
for(const s of ['', '[]','{',' '.repeat(M.MAX_BYTES+1),Array(10002).fill('{}').join('\n')])assert.throws(()=>M.parse(s));
});
test('reject inference promotion, raw IQ, nonfinite metrics and unsafe indices',()=>{
for(const patch of [{valid:true},{config_verified:true},{iq:[1,2]},{rms_dbfs:Infinity},{rail_fraction:2},{rail_fraction:NaN},{observed_monotonic_ns:2**53},{source_kind:'__proto__'},{ttl_ms:0},{invalid_reasons:['<script>']},{invalid_reasons:[]}])assert.throws(()=>M.validate({...good(),...patch}));
});
test('reject missing fields with record index',()=>{
const r=good();delete r.sample_start;assert.throws(()=>M.parse(JSON.stringify(r)),/Record 1: Missing field/);
});
test('allow unavailable measurements but retain invalid evidence',()=>{
assert.equal(M.validate({...good(),rms_dbfs:null,rail_fraction:null,center_frequency_hz:null,sample_rate_hz:0}).valid,false);
});
test('match producer and RuField numeric contract',()=>{
for(const patch of [{sample_start:0},{sample_count:4096},{missing_samples:128},{sample_rate_hz:123},{center_frequency_hz:1999999999},{center_frequency_hz:8000000001},{rms_dbfs:17},{rms_dbfs:-201},{rms_dbfs:-6,rail_fraction:null},{invalid_reasons:['configuration_unverified','configuration_unverified']},{invalid_reasons:['configuration_unverified','other']}])
assert.throws(()=>M.validate({...good(),...patch}));
for(const patch of [{center_frequency_hz:7576000000},{rms_dbfs:15.05},{sample_count:0,rms_dbfs:null,rail_fraction:null},{sample_rate_hz:0},{missing_samples:2048}])M.validate({...good(),...patch});
});
+1
View File
@@ -0,0 +1 @@
:root{color-scheme:dark;font-family:Inter,ui-sans-serif,system-ui,sans-serif;background:#080f17;color:#d9e8ee}*{box-sizing:border-box}body{margin:0;background:radial-gradient(ellipse at 85% 0,#113642,transparent 50%)}main{max-width:1200px;margin:auto;padding:48px 28px}header{position:relative}.eyebrow{letter-spacing:.2em;color:#62d9da;font-size:12px;font-weight:700}h1{font-size:clamp(38px,7vw,76px);letter-spacing:-.055em;margin:18px 0}.intro{font-size:20px;color:#a4bcc6}.badge{display:inline-block;padding:8px 14px;border:1px solid #b89851;border-radius:32px;color:#f5d18c;font-size:12px;font-weight:bold}.controls{margin-top:32px;display:flex;gap:12px;flex-wrap:wrap;align-items:center}.controls p{flex-basis:100%;font-size:13px;color:#a4bcc6}button,.button{font:inherit;font-size:14px;border:1px solid #38616f;background:#152b37;color:#e7faff;border-radius:10px;padding:13px 18px;cursor:pointer}.button{background:#77e9dd;color:#082b2c;font-weight:700;position:relative;overflow:hidden}.button input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}button:hover,.button:hover{filter:brightness(1.2)}button:focus-visible,.button:focus-within{outline:3px solid #fff;outline-offset:3px}aside{border-left:3px solid #e5b85e;padding:16px 20px;background:#30291c;font-size:14px;line-height:1.6;color:#f5ddab}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:24px 0}.metrics article,.panel{border:1px solid #25414d;background:#0d1c26;border-radius:14px;padding:22px}.metrics p{font-size:13px;color:#9cb6c1}.metrics strong{font-size:30px;color:#79eddf;display:block;margin:16px 0}.metrics small{color:#9cb6c1;font-size:11px}.panel{margin-bottom:18px}h2{font-size:18px;margin-top:0}.panel p,footer{color:#9cb6c1;font-size:13px;line-height:1.6}svg{width:100%;height:auto;display:block;background:linear-gradient(#132c37,#0d1c26);border-radius:8px}.split{display:grid;grid-template-columns:1fr 1fr;gap:18px}pre{white-space:pre-wrap;word-break:break-word;color:#b7d1da;line-height:1.7;font-size:12px}li{margin-bottom:10px;overflow-wrap:anywhere}#status{font-size:14px;min-height:22px}footer{margin:24px 0} @media(max-width:700px){main{padding:28px 16px}.metrics{grid-template-columns:1fr 1fr}.split{grid-template-columns:1fr;gap:0}.metrics article{padding:14px}.metrics strong{font-size:24px}}
@@ -0,0 +1,64 @@
# ESP32 CSI input release candidate, updated 2026-10-21
Status: software checks and a short ESP32-C6 boot/transport check passed.
Full release acceptance remains gated on the latest hosted checks. No sensing
accuracy or ESP32-S3 physical qualification is claimed.
Candidate source: `f03d5ac06` on `integrate/release-20261017`. Changes cover
#2286, #2287, #2302, configuration validation and rollback recovery (#2289),
and the Rust audit/dependency fixes (#2315).
## Software evidence
- Firmware `make host_tests` passed under `espressif/idf:v5.4`, including
production-code STBC, self-ping, configuration rules, write-through NVS
rollback fault injection, onboarding, and OTA-health checks.
- The rollback regression executes the production HTTP handler with real
cJSON and deterministic write-through NVS failure injection. It covers
partial writes, failed restore/erase/commit, retained recovery state, write
locking, and boot-time rollback.
- ESP32-C6 built with ESP-IDF 6.0.2. Image size is 1,123,408 bytes
(`0x112450`), within the 1,900,544-byte app slot (`0x1d0000`).
- Local `cargo audit` from `v2/` found zero unignored vulnerabilities. The
hosted Rust dependency audit passed on the preceding release head; the
corrected current head is running fresh checks.
- The focused BFLD changelog test passed 5/5 after keeping the existing
unreleased BFLD entry ahead of the 0.8.15 release heading.
## Attached hardware
The attached ESP32-C6 revision 0.2 has 8 MB flash. A read-only full flash
backup of 8,388,608 bytes completed at 115200 baud and is stored outside the
repository under the user's local qualification directory. No backup or raw
device data is included in this repository.
The 0.8.15 application was written to the existing `ota_0` app slot at
`0x20000`; the partition table and NVS were not changed. esptool verified the
write. `/ota/status` reported version `0.8.15`, `running_partition=ota_0`, and
`ota_state=valid` after reboot.
A 30-second in-memory UDP sample after the update counted 441 raw CSI packets,
26 vitals packets, and 30 feature packets. Raw packet subcarrier counts were
256: 199, 64: 237, and 128: 5. The sample had zero short packets, zero
presence/count contradictions, and zero vitals timestamp reversals. Raw CSI
and vitals values were not persisted.
An earlier paired 30-second transport check on 0.8.14 recorded 28.97 raw
packets/s at baseline and 29.60 packets/s while a bounded host UDP traffic
source ran. This is AP-specific transport evidence, not proof of sensing
accuracy or a universal minimum rate.
The C6 check does not qualify the S3-specific STBC correction. It was a short
boot/stream check, not a five-minute stability run or an accuracy test. The
CP210x UART bridge did not provide the native USB serial/JTAG boot log, so
physical IDF 6 USB onboarding receive behavior remains unverified.
## Release boundary
The firmware workflow publishes ESP32 tags as prereleases and states that CI
and host tests do not establish physical-board qualification or sensing
accuracy. The release remains a prerelease. The latest hosted run on the
previous candidate passed firmware matrices, security checks, and audit; one
workspace test failed because the 0.8.15 changelog heading interrupted an
existing BFLD unreleased section. That layout is corrected, and current-head
checks must finish green before merge and publication.
+138 -1
View File
@@ -19,7 +19,22 @@ 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.14 prerelease: device identity and OTA provisioning
## Firmware 0.8.15 prerelease: CSI reliability and recoverable configuration
Version 0.8.15 preserves bounded CSI input for ESP32-S3 STBC frames, caps
gateway ping waits to one probe interval, and prepares IDF 6 USB serial/JTAG
before onboarding. The authenticated remote configuration endpoint validates
whole requests before writing and retains a durable rollback bank if a write
or recovery step fails.
The attached ESP32-C6 booted 0.8.15 from its existing OTA slot after a complete
read-only flash backup. A 30-second runtime sample received raw CSI, vitals,
and feature packets with no malformed packets or presence/count contradictions.
This short transport check does not establish sensing accuracy, long-run
stability, or the S3-specific STBC behavior. See the
[candidate validation record](../../docs/validation/2026-10-20-esp32-csi-input-release-candidate.md).
### 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.
@@ -54,6 +69,41 @@ On ESP32 C6, the default optional mmWave UART uses GPIO 4 and GPIO 5. Firmware
refuses any mmWave configuration that overlaps the active console pins. This
prevents a sensor probe from taking over UART0 RX and disabling Mac onboarding.
## Low CSI yield despite self-ping (#2287)
Gateway ICMP replies do not guarantee CSI: some access points transmit replies
using 802.11b DSSS/CCK rates, which provide no CSI. Successful pings alone do
not establish a healthy sensing stream. The ping timeout is bounded to one
probe interval so a lost reply cannot stall probing for a full second.
When raw CSI stays low, run the bounded UDP traffic helper from a LAN host,
preferably wired to the router. Substitute the sensing node's private IPv4
address and use an unused UDP port on that node:
```bash
python scripts/csi-udp-poke.py 192.168.1.100 --hz 50 --seconds 60
```
No UDP service on the node is required. This does not query gateway DNS or
assume a gateway echo service. AP transmit-rate selection still determines
CSI cadence; compare actual received frames before and during the helper
run. The helper stops after the requested duration and caps its requested
rate at 50 Hz.
On S3, STBC frames can carry 192 I/Q entries. Edge processing uses the first
128 entries rather than dropping the entire frame (#2286), while raw CSI
preserves the full payload. This restores DSP input availability; it does
not establish vital-sign accuracy. Production-code host regressions run via
`make host_tests` in `firmware/esp32-csi-node/test`.
## Onboarding console transport
The standard C6 configuration uses native USB serial/JTAG for the interactive
onboarding console. A separate CP210x UART bridge can flash the same chip but
does not carry that native USB console. Use the board's native USB connector
for runtime logs and onboarding, or explicitly build a UART-console variant.
Do not interpret a silent UART bridge as proof of a failed boot.
## Firmware 0.8.8 in plain language
Release 0.8.8 makes the sensing stream more internally consistent and easier
@@ -495,6 +545,93 @@ 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.
### Remote Configuration (no USB)
`provision.py` writes NVS over USB and is still how a **new** node is set up.
Once a node is on the network, `config_push.py` changes the same parameters
over HTTP, so a node count, server address or threshold no longer needs a
ladder and a cable.
This is **not** OTA. OTA writes a firmware image to an app partition; this
writes NVS parameters. They share the port and the PSK, nothing else.
```bash
# what is this node running?
python config_push.py --node <node-ip> --get
# change something and let the node restart to apply it
python config_push.py --node <node-ip> --set tdm_node_count=9
# stage across the fleet, restart on your own schedule
python config_push.py --fleet nodes.txt --set edge_tier=1 --no-reboot
```
Config is read only at boot, so a change does nothing until the node restarts.
`config_push.py` therefore restarts it by default; `--no-reboot` stages instead.
#### Why some parameters reboot-and-prove
Most settings are recoverable: a wrong `target_ip` sends CSI nowhere, but the
node keeps its association and stays reachable, so the next push fixes it.
`wifi_ssid`, `wifi_password`, `csi_channel`, `channel_hop_count` and `dwell_ms`
are not. A wrong value there means the node never rejoins the network and no
push can ever reach it again -- worse than a bad OTA, which at least has a
second partition to fall back to.
Those go through a **trial**. The node banks its current values, writes the new
ones, and reboots. It keeps them only once it has actually re-associated; if it
cannot within `trial_seconds` (default 120, max 900) it restores the bank and
reboots itself. That is the app-level equivalent of bootloader rollback, and
unlike bootloader rollback it can be delivered by OTA.
`GET /config` reports which parameters carry that risk, so tooling does not
keep its own copy of the list to drift out of date.
#### The onboard LED
The WS2812 on the devkit is not a power light. It runs a **40 Hz square-wave
gamma stimulus** (GENUS) whose colour is live CSI motion energy through a
viridis colormap -- purple when still, yellow when moving. That makes it a
useful bench indicator and a bad thing to have in a bedroom.
It used to be compile-time only (`CONFIG_LED_GAMMA_VIZ`), so darkening one node
meant reflashing all of them. It is now runtime selectable per node:
| `led_mode` | name | behaviour |
|---|---|---|
| 0 | `off` | dark |
| 1 | `steady` | motion colour, no flicker |
| 2 | `flicker` | 40 Hz gamma stimulus (default) |
`led_brightness` is 0-100 and scales the colormap.
```bash
python config_push.py --node <node-ip> --set led_mode=off
python config_push.py --node <node-ip> --set led_mode=steady led_brightness=10
```
Both are read by the LED callback on every cycle, so they are the one pair of
parameters that applies **immediately with no restart** -- `config_push.py`
does not reboot a node when the change is LED-only.
Defaults preserve existing behaviour: a node never told otherwise keeps
flickering at full brightness exactly as before.
#### Endpoint
| Method | Path | Notes |
|---|---|---|
| `GET` | `/config` | Current values; `wifi_password` reported only as `(set)` |
| `POST` | `/config` | JSON object of parameters to change |
Both require `Authorization: Bearer <ota-psk>` and fail closed when no PSK is
provisioned. A body is validated in full before anything is committed, so one
bad field changes nothing, and an unrecognised parameter is an error rather
than a silently ignored setting.
Control fields, which are not stored: `trial_seconds`, `reboot`.
### OTA key provisioning
Create a random 256-bit key in a private file outside the repository. For
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Read and change node configuration over the network -- no USB.
This is provisioning, not OTA. `ota_push.py` writes firmware images; this
writes the same NVS parameters provision.py writes over USB, to a running
node, through the authenticated server on port 8032.
# what is this node set to?
python config_push.py --node <node-ip> --get
# a change that cannot orphan a node: written, then the node restarts so
# it actually takes effect (config is only read at boot)
python config_push.py --node <node-ip> --set tdm_node_count=9
# stage it across the fleet instead, and restart on your own schedule
python config_push.py --fleet nodes.txt --set tdm_node_count=9 --no-reboot
# darken the bedroom node's LED -- takes effect at once, no restart
python config_push.py --node <node-ip> --set led_mode=steady led_brightness=10
python config_push.py --node <node-ip> --set led_mode=off
# the whole fleet at once
python config_push.py --fleet nodes.txt --set edge_tier=1
Parameters that affect WiFi association (ssid, password, channel) are applied
as a TRIAL: the node banks its current settings, reboots, and keeps the new
ones only if it manages to re-associate. If it cannot, it restores the bank
and reboots itself. That is what makes a remote SSID change safe; without it
one typo means a ladder and a USB cable for every node.
The PSK is read from a file and never printed, logged, or passed on argv.
"""
import argparse
import os
import json
import sys
import urllib.error
import urllib.request
# No default path: a secret location is site-specific, and baking one in
# means shipping somebody's directory layout to everyone else. Set
# RUVIEW_OTA_PSK_FILE once, or pass --psk-file.
PSK_DEFAULT = os.environ.get("RUVIEW_OTA_PSK_FILE")
PORT = 8032
# The onboard WS2812 defaults to a 40 Hz square-wave gamma stimulus at full
# brightness. Fine on a bench, hostile in a bedroom.
LED_MODES = {"off": 0, "steady": 1, "flicker": 2}
def call(host, psk, method, body=None, timeout=20):
url = "http://%s:%d/config" % (host, PORT)
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", "Bearer " + psk)
if data:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode() or "{}"), None
except urllib.error.HTTPError as e:
return None, "HTTP %d: %s" % (e.code, e.read().decode()[:200])
except Exception as e: # noqa: BLE001 - report, never raise
return None, str(e)
def looks_armed(err):
"""A trial push that reboots can lose its response on a congested node.
Observed on real hardware: the write succeeded, the node rebooted and
reverted correctly, but curl saw nothing. Reporting that as a failure
invites a re-push at exactly the wrong moment -- while a trial is pending
and the banked values are still unproven.
"""
e = (err or "").lower()
return any(k in e for k in ("timed out", "reset", "aborted",
"expecting value", "remote end closed"))
def coerce(text):
"""Numbers stay numbers so the node's type check passes; else a string."""
try:
return int(text)
except ValueError:
pass
try:
return float(text)
except ValueError:
return text
def main():
ap = argparse.ArgumentParser()
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--node", help="node IP")
g.add_argument("--fleet", help="file of node IPs, one per line")
ap.add_argument("--get", action="store_true", help="read current config")
ap.add_argument("--set", nargs="+", metavar="KEY=VALUE", default=None)
ap.add_argument("--trial-seconds", type=int, default=None,
help="how long a node may take to re-associate before reverting")
ap.add_argument("--psk-file", default=PSK_DEFAULT,
help="file holding the fleet OTA PSK; defaults to "
"$RUVIEW_OTA_PSK_FILE")
ap.add_argument("--no-reboot", action="store_true",
help="write the values but leave the node running the old "
"ones; config is only read at boot, so nothing takes "
"effect until you restart it yourself")
ap.add_argument("--yes", action="store_true",
help="skip the confirmation for association-affecting changes")
a = ap.parse_args()
if not a.get and not a.set:
ap.error("choose --get or --set")
if not a.psk_file:
print("no PSK file: pass --psk-file or set RUVIEW_OTA_PSK_FILE",
file=sys.stderr)
return 2
try:
psk = open(a.psk_file).read().strip()
except OSError as e:
print("cannot read PSK file: %s" % e, file=sys.stderr)
return 2
if not psk:
print("PSK file is empty", file=sys.stderr)
return 2
hosts = [a.node] if a.node else [
l.strip() for l in open(a.fleet) if l.strip() and not l.startswith("#")]
if a.get:
for h in hosts:
r, err = call(h, psk, "GET")
if err:
print("%-16s UNREACHABLE %s" % (h, err))
continue
cfg = r.get("config", {})
flag = " [TRIAL PENDING]" if r.get("trial_pending") else ""
print("\n%s%s" % (h, flag))
for k in sorted(cfg):
v = cfg[k]
print(" %-22s %s" % (k, "-" if v is None else v))
return 0
body = {}
for item in a.set:
if "=" not in item:
ap.error("--set takes KEY=VALUE, got %r" % item)
k, v = item.split("=", 1)
k, v = k.strip(), v.strip()
if k == "led_mode" and v.lower() in LED_MODES:
body[k] = LED_MODES[v.lower()]
else:
body[k] = coerce(v)
if a.trial_seconds:
body["trial_seconds"] = a.trial_seconds
if a.no_reboot:
body["reboot"] = False
# Ask the first reachable node which parameters carry the orphaning risk,
# rather than keeping a second copy of that list here that could drift
# away from the firmware's.
risky = []
for h in hosts:
probe, err = call(h, psk, "GET")
if probe:
risky = [k for k in probe.get("requires_trial", []) if k in body]
break
if risky and not a.yes:
print("These change WiFi association: %s" % ", ".join(sorted(risky)))
print("Each node will reboot and revert automatically if it cannot")
print("re-associate. %d node(s) affected." % len(hosts))
if input("proceed? [y/N] ").strip().lower() != "y":
return 1
fail = 0
for h in hosts:
r, err = call(h, psk, "POST", body)
if err:
if risky and looks_armed(err):
print("%-16s UNCONFIRMED the reply was lost; the trial has very "
"likely armed." % h)
print("%-16s Do NOT re-push. Wait ~%ds: it either "
"re-associates or reverts itself."
% ("", (a.trial_seconds or 120) + 30))
continue
print("%-16s FAILED %s" % (h, err))
fail += 1
continue
n = r.get("changed", 0)
if r.get("trial"):
print("%-16s TRIAL %d field(s), reverts in %ds unless it re-associates"
% (h, n, r.get("trial_seconds", 0)))
elif r.get("note", "").startswith("applied immediately"):
print("%-16s LIVE %d field(s) applied now, no restart" % (h, n))
elif r.get("rebooting"):
print("%-16s OK %d field(s), rebooting to apply" % (h, n))
else:
print("%-16s STAGED %d field(s) written, NOT in effect until restart"
% (h, n))
return 1 if fail else 0
if __name__ == "__main__":
sys.exit(main())
+13 -1
View File
@@ -1,5 +1,5 @@
set(SRCS
"main.c" "csi_collector.c" "node_log.c"
"main.c" "csi_collector.c" "config_api.c" "config_rules.c" "node_log.c"
"thermal.c" "stream_sender.c" "nvs_config.c"
"serial_onboarding.c" "serial_onboarding_protocol.c"
"edge_processing.c" "ota_update.c" "ota_health.c" "power_mgmt.c"
@@ -59,6 +59,18 @@ endif()
# component. Keep the 5.4 lane working while declaring the v6 dependency.
if(IDF_VERSION_MAJOR GREATER_EQUAL 6)
list(APPEND REQUIRES esp_driver_tsens)
if(IDF_TARGET STREQUAL "esp32c6" OR IDF_TARGET STREQUAL "esp32s3")
list(APPEND REQUIRES esp_driver_usb_serial_jtag)
endif()
endif()
# cJSON (config_api.c) ships as the bundled `json` component up to ESP-IDF 5.x
# and was moved OUT to the component manager in v6, where requiring `json`
# fails outright: "Failed to resolve component 'json' required by component
# 'main': unknown name." Declaring it unconditionally broke the v6.0.2 lane
# while the 5.4 lane stayed green -- one toolchain is not the build matrix.
if(IDF_VERSION_MAJOR LESS 6)
list(APPEND REQUIRES json)
endif()
# ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding
+757
View File
@@ -0,0 +1,757 @@
/**
* @file config_api.c
* @brief Remote configuration endpoint. See config_api.h for the design.
*/
#include "config_api.h"
#include <string.h>
#include <stdlib.h>
#include "cJSON.h"
#include "ota_update.h"
#include "nvs_config.h"
#include "config_rules.h"
#include "esp_log.h"
#include "esp_system.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "nvs_flash.h"
#include "nvs.h"
static const char *TAG = "config_api";
/* POST /link/reassoc state (handler near config_api_register). */
#define REASSOC_MIN_INTERVAL_US (60LL * 1000 * 1000)
static uint32_t s_reassoc_count;
static int64_t s_reassoc_last_us;
static esp_timer_handle_t s_reassoc_timer;
#define CFG_NS "csi_cfg" /* the live config nvs_config.c reads */
#define TRIAL_NS "cfg_trial" /* trial state, separate so a wipe is easy */
#define TRIAL_DEFAULT 120 /* s to prove association before reverting */
#define TRIAL_MAX 900
#define BODY_MAX 2048
/* The writable surface, the value rules and the "is this field live" test
* live in config_rules.c, so the host test can pin them without a device. */
static esp_timer_handle_t s_revert_timer = NULL;
/**
* A boot trial is pending: the node came up on settings nobody has proved and
* will revert them unless it associates. Set by config_trial_boot_check() and
* cleared by config_trial_notify_connected() when the association proves the
* new settings.
*/
static bool s_trial_armed = false;
/**
* A change has been accepted and the node is about to reboot onto it.
*
* This is deliberately NOT s_trial_armed. The boot trial is about settings
* that are already stored; this is about a request that was just accepted and
* has not been rebooted into yet. They must stay separate because an
* association event on the OLD running configuration calls
* config_trial_notify_connected(), which clears s_trial_armed and erases the
* trial bank. If the pending state rode on s_trial_armed, that event would
* unlock the API during the three seconds before the reboot, and the second
* request would bank the FIRST request's unproven settings as the fallback --
* so a revert would restore settings never shown to work.
*/
static volatile bool s_pending_reboot = false;
/* ---------------------------------------------------------------- reads --- */
/**
* Current NVS value as a cJSON node, or JSON null when the key is unset.
*
* `redact` must be false when banking values for a trial: the bank is written
* back verbatim on revert, so a redacted password would be restored as the
* literal string "(set)" and lock the node off the network permanently -- the
* exact failure the trial exists to prevent.
*/
static cJSON *read_key(nvs_handle_t h, const cfg_key_t *k, bool redact,
esp_err_t *read_status)
{
if (read_status) *read_status = ESP_OK;
if (k->type == T_STR) {
char buf[96];
size_t len = sizeof(buf);
esp_err_t e = nvs_get_str(h, k->nvs, buf, &len);
if (read_status) *read_status = e;
if (e != ESP_OK) return cJSON_CreateNull();
/* Never hand back key material, even to an authenticated caller: it
* would end up in logs and terminal scrollback. Presence only. */
if (redact && strcmp(k->json, "wifi_password") == 0) {
return cJSON_CreateString(buf[0] ? "(set)" : "");
}
return cJSON_CreateString(buf);
}
uint32_t v = 0;
esp_err_t e;
if (k->type == T_U8) {
uint8_t x = 0; e = nvs_get_u8(h, k->nvs, &x); v = x;
} else if (k->type == T_U16) {
uint16_t x = 0; e = nvs_get_u16(h, k->nvs, &x); v = x;
} else {
e = nvs_get_u32(h, k->nvs, &v);
}
if (read_status) *read_status = e;
if (e != ESP_OK) return cJSON_CreateNull();
if (k->scale > 0) return cJSON_CreateNumber((double)v / k->scale);
return cJSON_CreateNumber((double)v);
}
/* --------------------------------------------------------------- writes --- */
/**
* Stage one value.
*
* The value MUST already have been accepted by config_validate_value(): this
* function only stores. Splitting the two is the whole point -- see the
* handler below, where every field is validated before any field is written.
*
* `num` is the integer to store, already scaled and rounded. It is ignored
* for T_STR, whose text comes from `v`.
*/
static bool store_key(nvs_handle_t h, const cfg_key_t *k, const cJSON *v,
uint32_t num, char *err, size_t errlen)
{
if (k->type == T_STR) {
if (!cJSON_IsString(v)) {
snprintf(err, errlen, "%s must be a string", k->json);
return false;
}
if (nvs_set_str(h, k->nvs, v->valuestring) != ESP_OK) {
snprintf(err, errlen, "could not write %s", k->json);
return false;
}
return true;
}
esp_err_t e;
if (k->type == T_U8) e = nvs_set_u8(h, k->nvs, (uint8_t)num);
else if (k->type == T_U16) e = nvs_set_u16(h, k->nvs, (uint16_t)num);
else e = nvs_set_u32(h, k->nvs, num);
if (e != ESP_OK) {
snprintf(err, errlen, "could not write %s", k->json);
return false;
}
return true;
}
/**
* Decode a cJSON value into the shape the rules work with.
*
* A field the body carries as JSON null is treated as absent: the rules
* reject it, because "unset" is expressed by omitting the key.
*/
static void decode_value(const cJSON *v, cfg_value_t *out)
{
out->kind = CFG_VAL_NONE;
out->str = NULL;
out->num = 0.0;
if (!v) return;
if (cJSON_IsString(v)) {
out->kind = CFG_VAL_STR;
out->str = v->valuestring;
} else if (cJSON_IsNumber(v)) {
out->kind = CFG_VAL_NUM;
out->num = v->valuedouble;
}
}
/**
* Validate one field of a request without storing anything.
*
* Returns false with `err` set on a bad value. `scaled` receives the integer
* that would be stored.
*/
static bool validate_field(const cfg_key_t *k, const cJSON *v, uint32_t *scaled,
char *err, size_t errlen)
{
cfg_value_t val;
decode_value(v, &val);
return config_validate_value(k, &val, scaled, err, errlen);
}
/**
* The integer to store for a banked value.
*
* The bank holds a value exactly as it was read from NVS, so it is in range
* for the key's type -- but it may be OUTSIDE the range the API accepts
* today (an older firmware wrote vital_window=4096, which the loader
* ignores). A revert has to put back exactly what was there, so it stores the
* value as read and never re-validates it against the current limits.
*/
static uint32_t banked_number(const cfg_key_t *k, const cJSON *v)
{
double d = v->valuedouble;
if (k->scale > 0) d *= k->scale;
if (!(d >= 0.0)) return 0; /* NaN or negative */
if (d > 4294967295.0) return 4294967295u; /* unreachable for a banked value */
return (uint32_t)(d + 0.5);
}
/** Restore one banked value; JSON null means the key was previously unset. */
static bool restore_key(nvs_handle_t h, const cfg_key_t *k, const cJSON *v)
{
if (cJSON_IsNull(v)) {
esp_err_t e = nvs_erase_key(h, k->nvs);
return e == ESP_OK || e == ESP_ERR_NVS_NOT_FOUND;
}
char err[64];
return store_key(h, k, v, banked_number(k, v), err, sizeof(err));
}
/**
* Put every banked value back.
*
* Used when a write failed part way through: the node is left holding a mix
* of old and new values, and the only honest way out is to restore the whole
* bank rather than leave it half applied.
*/
static bool restore_bank(nvs_handle_t h, const cJSON *bank)
{
bool ok = true;
cJSON *it = NULL;
cJSON_ArrayForEach(it, bank) {
const cfg_key_t *k = config_find_key(it->string);
if (!k || !restore_key(h, k, it)) {
ESP_LOGE(TAG, "could not restore banked key");
ok = false;
}
}
return ok;
}
/* Recovery metadata is discarded only after the previous state is durable. */
static bool clear_recovery_bank(void)
{
nvs_handle_t h;
if (nvs_open(TRIAL_NS, NVS_READWRITE, &h) != ESP_OK) return false;
bool ok = nvs_erase_all(h) == ESP_OK && nvs_commit(h) == ESP_OK;
nvs_close(h);
return ok;
}
static bool finish_recovery_bank(bool needs_trial)
{
if (!needs_trial) return clear_recovery_bank();
nvs_handle_t h;
if (nvs_open(TRIAL_NS, NVS_READWRITE, &h) != ESP_OK) return false;
bool ok = nvs_set_u8(h, "rollback", 0) == ESP_OK && nvs_commit(h) == ESP_OK;
nvs_close(h);
return ok;
}
/* ---------------------------------------------------------------- trial --- */
/** Apply the banked values and reboot. Runs from the esp_timer task. */
static void trial_revert(void *arg)
{
(void)arg;
s_pending_reboot = true;
nvs_handle_t t;
if (nvs_open(TRIAL_NS, NVS_READONLY, &t) != ESP_OK) {
ESP_LOGE(TAG, "recovery bank unreadable; retaining write lock");
return;
}
size_t len = 0;
char *json = NULL;
bool loaded = nvs_get_str(t, "bank", NULL, &len) == ESP_OK && len > 1 &&
len <= BODY_MAX * 2 && (json = malloc(len)) != NULL &&
nvs_get_str(t, "bank", json, &len) == ESP_OK;
nvs_close(t);
cJSON *bank = loaded ? cJSON_Parse(json) : NULL;
free(json);
bool restored = false;
nvs_handle_t c;
if (cJSON_IsObject(bank) && nvs_open(CFG_NS, NVS_READWRITE, &c) == ESP_OK) {
restored = restore_bank(c, bank) && nvs_commit(c) == ESP_OK;
nvs_close(c);
}
cJSON_Delete(bank);
if (!restored || !clear_recovery_bank()) {
ESP_LOGE(TAG, "recovery failed; keeping bank and write lock for retry");
return;
}
ESP_LOGW(TAG, "previous configuration restored; rebooting");
esp_restart();
}
void config_trial_boot_check(void)
{
nvs_handle_t t;
if (nvs_open(TRIAL_NS, NVS_READONLY, &t) != ESP_OK) return;
uint8_t armed = 0;
uint8_t rollback = 0;
uint32_t deadline = TRIAL_DEFAULT;
nvs_get_u8(t, "armed", &armed);
nvs_get_u8(t, "rollback", &rollback);
nvs_get_u32(t, "deadline", &deadline);
nvs_close(t);
if (rollback) {
trial_revert(NULL);
return;
}
if (!armed) return;
s_trial_armed = true;
if (deadline == 0 || deadline > TRIAL_MAX) deadline = TRIAL_DEFAULT;
ESP_LOGW(TAG, "config trial pending: %lu s to associate or revert",
(unsigned long)deadline);
const esp_timer_create_args_t args = {
.callback = trial_revert,
.name = "cfg_trial",
};
if (esp_timer_create(&args, &s_revert_timer) == ESP_OK) {
esp_timer_start_once(s_revert_timer, (uint64_t)deadline * 1000000ULL);
} else {
ESP_LOGE(TAG, "could not arm revert timer; trial cannot self-heal");
}
}
void config_trial_notify_connected(void)
{
if (!s_trial_armed || s_pending_reboot) return;
if (!clear_recovery_bank()) {
ESP_LOGE(TAG, "could not commit trial; preserving recovery state");
return;
}
s_trial_armed = false;
if (s_revert_timer) {
esp_timer_stop(s_revert_timer);
esp_timer_delete(s_revert_timer);
s_revert_timer = NULL;
}
ESP_LOGI(TAG, "config trial COMMITTED -- association proved on new settings");
}
/* ------------------------------------------------------------- handlers --- */
static esp_err_t recv_body(httpd_req_t *req, char **out)
{
if (req->content_len <= 0 || req->content_len > BODY_MAX) return ESP_FAIL;
char *buf = malloc(req->content_len + 1);
if (!buf) return ESP_ERR_NO_MEM;
int got = 0;
while (got < req->content_len) {
int r = httpd_req_recv(req, buf + got, req->content_len - got);
if (r <= 0) { free(buf); return ESP_FAIL; }
got += r;
}
buf[got] = '\0';
*out = buf;
return ESP_OK;
}
static esp_err_t config_get_handler(httpd_req_t *req)
{
if (!ota_auth_check(req)) {
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
"Authentication required. Use: Authorization: Bearer <psk>");
return ESP_FAIL;
}
nvs_handle_t h;
cJSON *root = cJSON_CreateObject();
cJSON *vals = cJSON_AddObjectToObject(root, "config");
if (nvs_open(CFG_NS, NVS_READONLY, &h) == ESP_OK) {
for (size_t i = 0; i < CONFIG_KEY_COUNT; i++) {
cJSON_AddItemToObject(vals, CONFIG_KEYS[i].json, read_key(h, &CONFIG_KEYS[i], true, NULL));
}
nvs_close(h);
}
/* Tell the caller which fields will force a reboot-and-prove cycle, so a
* tool can warn before sending rather than discovering it afterwards. */
cJSON *risky = cJSON_AddArrayToObject(root, "requires_trial");
for (size_t i = 0; i < CONFIG_KEY_COUNT; i++) {
if (CONFIG_KEYS[i].assoc) cJSON_AddItemToArray(risky, cJSON_CreateString(CONFIG_KEYS[i].json));
}
cJSON_AddBoolToObject(root, "trial_pending", s_trial_armed);
cJSON_AddBoolToObject(root, "write_blocked",
!config_write_allowed(s_trial_armed, s_pending_reboot));
cJSON_AddNumberToObject(root, "reassoc_count", s_reassoc_count);
cJSON_AddNumberToObject(root, "reassoc_last_s_ago",
s_reassoc_last_us ? (double)((esp_timer_get_time() - s_reassoc_last_us) / 1000000) : -1);
char *out = cJSON_PrintUnformatted(root);
cJSON_Delete(root);
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, out ? out : "{}");
free(out);
return ESP_OK;
}
static void reboot_soon(void *arg)
{
(void)arg;
esp_restart();
}
static esp_err_t config_post_handler(httpd_req_t *req)
{
if (!ota_auth_check(req)) {
ESP_LOGW(TAG, "config write rejected: authentication failed");
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
"Authentication required. Use: Authorization: Bearer <psk>");
return ESP_FAIL;
}
if (!config_write_allowed(s_trial_armed, s_pending_reboot)) {
/* A second change stacked on an unproven one would bank the UNPROVEN
* values as the fallback, so a revert would restore settings never
* shown to work. Refuse until the pending trial resolves. */
httpd_resp_set_status(req, "409 Conflict");
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req,
"{\"error\":\"a config trial is still pending; "
"wait for it to commit or revert\"}");
return ESP_OK;
}
char *body = NULL;
if (recv_body(req, &body) != ESP_OK) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing or oversized body");
return ESP_FAIL;
}
cJSON *root = cJSON_Parse(body);
free(body);
if (!root || !cJSON_IsObject(root)) {
cJSON_Delete(root);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Body must be a JSON object");
return ESP_FAIL;
}
/* Pass 1: recognise every field and note whether any can orphan the node.
* Rejecting an unknown key rather than ignoring it turns a typo into a
* visible error instead of a setting that silently never applied. */
bool needs_trial = false;
bool all_live = true;
char err[96] = {0};
cJSON *it = NULL;
cJSON_ArrayForEach(it, root) {
if (config_is_meta(it->string)) continue;
const cfg_key_t *k = config_find_key(it->string);
if (!k) {
snprintf(err, sizeof(err), "unknown parameter '%s'", it->string);
break;
}
if (k->assoc) needs_trial = true;
if (!config_is_live(k->json)) all_live = false;
}
if (err[0]) {
cJSON_Delete(root);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, err);
return ESP_FAIL;
}
/* Pass 2: validate EVERY value before touching storage.
*
* nvs_set_* writes through to flash in the setter itself. In ESP-IDF 5.4
* nvs_open() hands back an NVSHandleSimple, whose setters call
* Storage::writeItem() straight away and whose commit() only returns
* ESP_OK -- closing the handle does NOT discard what was written. A
* request that validated and wrote in the same pass could therefore leave
* a REJECTED request half applied: {"wifi_ssid":"wrong-network",
* "edge_tier":"invalid"} would answer 400 with the SSID already changed
* and no recovery bank armed, so the next reboot could leave the node
* unreachable with no way back.
*
* Validating the whole body first makes that impossible: a body with one
* bad field changes nothing at all. */
cJSON_ArrayForEach(it, root) {
if (config_is_meta(it->string)) continue;
const cfg_key_t *k = config_find_key(it->string);
uint32_t num = 0;
if (!validate_field(k, it, &num, err, sizeof(err))) {
if (!err[0]) snprintf(err, sizeof(err), "invalid value for %s", k->json);
break;
}
}
if (err[0]) {
cJSON_Delete(root);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, err);
return ESP_FAIL;
}
cJSON *rb = cJSON_GetObjectItem(root, "reboot");
bool reboot = cJSON_IsBool(rb) ? cJSON_IsTrue(rb) : !all_live;
uint32_t deadline = TRIAL_DEFAULT;
if (needs_trial) {
cJSON *d = cJSON_GetObjectItem(root, "trial_seconds");
if (cJSON_IsNumber(d) && d->valuedouble >= 30 && d->valuedouble <= TRIAL_MAX) {
deadline = (uint32_t)d->valuedouble;
}
}
/* Pass 3: bank the CURRENT values before anything is written, reading
* through a separate read-only handle so pending writes cannot leak in.
* The bank is always built, not only for a trial: a write that fails part
* way through has to be undone, and the bank is what it is undone from. */
cJSON *bank = cJSON_CreateObject();
if (!bank) {
cJSON_Delete(root);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
{
nvs_handle_t ro;
if (nvs_open(CFG_NS, NVS_READONLY, &ro) != ESP_OK) {
/* Without a bank there is nothing to revert to, and a write that
* failed half way through could not be undone. Refuse the change
* rather than apply one the node could not back out of. */
cJSON_Delete(root);
cJSON_Delete(bank);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Could not read the current config");
return ESP_FAIL;
}
cJSON_ArrayForEach(it, root) {
if (config_is_meta(it->string)) continue;
const cfg_key_t *k = config_find_key(it->string);
esp_err_t read_status;
cJSON *value = read_key(ro, k, false, &read_status);
if (!value || (read_status != ESP_OK && read_status != ESP_ERR_NVS_NOT_FOUND) ||
!cJSON_AddItemToObject(bank, k->json, value)) {
cJSON_Delete(value);
nvs_close(ro);
cJSON_Delete(root);
cJSON_Delete(bank);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Could not snapshot the complete current config");
return ESP_FAIL;
}
}
nvs_close(ro);
}
/* Pass 4: persist the recovery state BEFORE the first live write.
*
* If the bank cannot be stored we must not proceed: writing first would
* leave the node on unproven settings with nothing to fall back to. Once
* the bank IS on flash the node can get back to the previous
* configuration even if everything after this point fails. */
{
char *bj = cJSON_PrintUnformatted(bank);
nvs_handle_t t;
bool ok = bj && nvs_open(TRIAL_NS, NVS_READWRITE, &t) == ESP_OK;
if (ok) {
ok = nvs_set_str(t, "bank", bj) == ESP_OK &&
nvs_set_u32(t, "deadline", deadline) == ESP_OK &&
nvs_set_u8(t, "rollback", 1) == ESP_OK &&
nvs_set_u8(t, "armed", needs_trial ? 1 : 0) == ESP_OK &&
nvs_commit(t) == ESP_OK;
nvs_close(t);
}
free(bj);
if (!ok) {
cJSON_Delete(root);
cJSON_Delete(bank);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Could not bank the previous config; refusing to apply");
return ESP_FAIL;
}
}
/* From here the node has a way back, and a change is in flight. Lock the
* API for the rest of this request: a second request would bank the
* UNPROVEN settings over the good ones, so a revert would restore
* settings never shown to work. */
s_pending_reboot = true;
/* Pass 5: write the live values. Every one of them was accepted in pass
* 2, and the recovery bank is already on flash. */
nvs_handle_t rw;
if (nvs_open(CFG_NS, NVS_READWRITE, &rw) != ESP_OK) {
s_pending_reboot = !clear_recovery_bank();
cJSON_Delete(root);
cJSON_Delete(bank);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "NVS unavailable");
return ESP_FAIL;
}
int changed = 0;
cJSON_ArrayForEach(it, root) {
if (config_is_meta(it->string)) continue;
const cfg_key_t *k = config_find_key(it->string);
/* Re-validate instead of carrying the value from pass 2: the rules
* are cheap and deterministic, and this way the integer that reaches
* the flash is the one the rules accepted at write time. */
uint32_t num = 0;
if (!validate_field(k, it, &num, err, sizeof(err)) ||
!store_key(rw, k, it, num, err, sizeof(err))) {
if (!err[0]) snprintf(err, sizeof(err), "could not write %s", k->json);
break;
}
changed++;
}
esp_err_t ce = err[0] ? ESP_FAIL : nvs_commit(rw);
nvs_close(rw);
if (ce == ESP_OK && !finish_recovery_bank(needs_trial)) ce = ESP_FAIL;
if (err[0] || ce != ESP_OK) {
/* A write failed part way through: the node is holding a mix of old
* and new values. Put the whole bank back rather than leave it half
* applied, and disarm the trial armed a moment ago. */
ESP_LOGE(TAG, "config write failed (%s); restoring the previous config", err);
nvs_handle_t r;
bool restored = false;
if (nvs_open(CFG_NS, NVS_READWRITE, &r) == ESP_OK) {
restored = restore_bank(r, bank) && nvs_commit(r) == ESP_OK;
nvs_close(r);
}
if (restored) restored = clear_recovery_bank();
/* A failed restore must retain the persisted bank and write lock.
* The rollback marker makes the next boot retry recovery BEFORE WiFi,
* instead of promoting a partially written config on association. */
s_pending_reboot = !restored;
cJSON_Delete(root);
cJSON_Delete(bank);
if (!restored) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Write failed and the previous config could not be restored");
return ESP_FAIL;
}
if (ce != ESP_OK && !err[0]) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Commit failed");
return ESP_FAIL;
}
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, err);
return ESP_FAIL;
}
/* Push live parameters into the running config so they apply now.
* Values were range-checked in pass 2, so these are already valid. */
cJSON *lm = cJSON_GetObjectItem(root, "led_mode");
if (cJSON_IsNumber(lm)) g_nvs_config.led_mode = (uint8_t)lm->valuedouble;
cJSON *lb = cJSON_GetObjectItem(root, "led_brightness");
if (cJSON_IsNumber(lb)) g_nvs_config.led_brightness = (uint8_t)lb->valuedouble;
cJSON_Delete(root);
cJSON_Delete(bank);
cJSON *resp = cJSON_CreateObject();
cJSON_AddNumberToObject(resp, "changed", changed);
cJSON_AddBoolToObject(resp, "trial", needs_trial);
cJSON_AddBoolToObject(resp, "rebooting", needs_trial || reboot);
if (needs_trial) {
cJSON_AddNumberToObject(resp, "trial_seconds", deadline);
cJSON_AddStringToObject(resp, "note",
"rebooting; reverts automatically unless the node re-associates");
} else if (reboot) {
cJSON_AddStringToObject(resp, "note", "written; rebooting to apply");
} else if (all_live) {
cJSON_AddStringToObject(resp, "note", "applied immediately; no restart needed");
} else {
cJSON_AddStringToObject(resp, "note",
"written but NOT yet in effect; config is read only at boot");
}
char *out = cJSON_PrintUnformatted(resp);
cJSON_Delete(resp);
httpd_resp_set_type(req, "application/json");
httpd_resp_sendstr(req, out ? out : "{}");
free(out);
if (needs_trial || reboot) {
if (needs_trial) {
ESP_LOGW(TAG, "config trial armed (%lu s); rebooting",
(unsigned long)deadline);
} else {
ESP_LOGI(TAG, "config written (%d field(s)); rebooting to apply", changed);
}
/* Deferred so the response actually reaches the caller -- otherwise
* the tool sees a dropped connection and cannot tell success from a
* crash at the worst possible moment.
*
* 3 s, not 1 s: at 1 s, two of three trial pushes to a node under
* sendto-ENOMEM congestion returned an empty body. The write had
* succeeded and the node rebooted correctly, but the caller could not
* tell that from a failure -- and the natural response to an apparent
* failure is to push again, which is the worst thing to do to a node
* that is mid-trial. */
const esp_timer_create_args_t a = { .callback = reboot_soon, .name = "cfg_boot" };
esp_timer_handle_t th;
if (esp_timer_create(&a, &th) == ESP_OK) {
if (esp_timer_start_once(th, 3000000ULL) != ESP_OK) {
esp_timer_delete(th);
ESP_LOGE(TAG, "reboot timer failed; retaining write lock until reset");
}
} else {
/* No reboot means no pending window. Leaving the lock set would
* refuse every later write for the life of the process. */
ESP_LOGE(TAG, "could not schedule the reboot; clearing the pending lock");
/* Keep the bank protected from a second request until reset. */
}
} else {
/* Applied immediately, nothing in flight. */
s_pending_reboot = false;
}
return ESP_OK;
}
/* POST /link/reassoc: drop and re-join the AP so rate control starts fresh.
* Measured on a 9-node ESP32-C6 fleet: a node whose uplink PHY rate falls to
* 1-2 Mbps stays there for 24-425 min without help, and that one slow node
* fills the channel for everyone. A fresh association restored the rate every
* time (15 of 15 requests that reached the node, 10-09..10-16). Detection is
* left to the caller (e.g. a service watching the AP's per-client rx rate);
* the 60 s floor here only stops a runaway caller. The reconnect is the
* normal STA_DISCONNECTED path in main.c, with its usual backoff. */
static void reassoc_timer_cb(void *arg)
{
(void)arg;
esp_err_t r = esp_wifi_disconnect();
ESP_LOGW(TAG, "link reassoc #%lu: esp_wifi_disconnect -> %s",
(unsigned long)s_reassoc_count, esp_err_to_name(r));
}
static esp_err_t link_reassoc_handler(httpd_req_t *req)
{
if (!ota_auth_check(req)) {
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
"Authentication required. Use: Authorization: Bearer <psk>");
return ESP_FAIL;
}
int64_t now = esp_timer_get_time();
httpd_resp_set_type(req, "application/json");
if (s_reassoc_last_us != 0 && now - s_reassoc_last_us < REASSOC_MIN_INTERVAL_US) {
httpd_resp_set_status(req, "429 Too Many Requests");
return httpd_resp_sendstr(req, "{\"error\":\"reassoc requested < 60 s ago\"}");
}
if (s_reassoc_timer == NULL) {
const esp_timer_create_args_t args = { .callback = reassoc_timer_cb, .name = "reassoc" };
if (esp_timer_create(&args, &s_reassoc_timer) != ESP_OK) {
return httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "timer create failed");
}
}
s_reassoc_count++;
s_reassoc_last_us = now;
/* Reply first; drop the link 200 ms later so the response gets out. */
esp_err_t sent = httpd_resp_sendstr(req, "{\"status\":\"reassociating\"}");
esp_timer_stop(s_reassoc_timer);
esp_timer_start_once(s_reassoc_timer, 200 * 1000);
return sent;
}
esp_err_t config_api_register(httpd_handle_t server)
{
static const httpd_uri_t get_uri = {
.uri = "/config", .method = HTTP_GET, .handler = config_get_handler,
};
static const httpd_uri_t post_uri = {
.uri = "/config", .method = HTTP_POST, .handler = config_post_handler,
};
static const httpd_uri_t reassoc_uri = {
.uri = "/link/reassoc", .method = HTTP_POST, .handler = link_reassoc_handler,
};
esp_err_t e = httpd_register_uri_handler(server, &get_uri);
if (e != ESP_OK) return e;
e = httpd_register_uri_handler(server, &post_uri);
if (e != ESP_OK) return e;
return httpd_register_uri_handler(server, &reassoc_uri);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* @file config_api.h
* @brief Remote configuration over HTTP -- change node settings without USB.
*
* Deliberately NOT called "OTA". OTA in this firmware means a firmware image
* written to an app partition by ota_update.c. This is provisioning: the same
* parameters provision.py writes over USB, writable over the network instead,
* on the same authenticated server and behind the same PSK.
*
* The parameters split by whether a bad value can ORPHAN a node:
*
* recoverable A wrong target_ip sends CSI nowhere, but the node keeps its
* association and stays reachable on :8032, so the next config
* push fixes it. Applied immediately.
*
* orphaning A wrong ssid, password or channel means the node never
* rejoins the network and no push can ever reach it again --
* strictly worse than a bad OTA, which at least has a second
* partition to fall back to. These go through a trial: the old
* values are banked, the new ones are written, and the node
* reboots. It commits only on proving it can still associate;
* otherwise it restores the bank and reboots itself.
*
* That trial is the app-level equivalent of bootloader rollback, and unlike
* bootloader rollback it can itself be delivered by OTA.
*/
#pragma once
#include "esp_err.h"
#include "esp_http_server.h"
/** Register /config and /config/trial on an already-running server. */
esp_err_t config_api_register(httpd_handle_t server);
/**
* Arm the revert timer if a config trial is pending.
* Call early in app_main(), before WiFi starts.
*/
void config_trial_boot_check(void);
/**
* Report a successful association, committing any pending trial.
* Call from the IP_EVENT_STA_GOT_IP handler.
*/
void config_trial_notify_connected(void);
@@ -0,0 +1,36 @@
/**
* @file config_limits.h
* @brief Canonical acceptance limits for the NVS-backed configuration.
*
* The HTTP configuration API and the boot loader MUST agree on these ranges.
* A value the API accepts but the loader rejects is the worst kind of bug in
* this subsystem: the API answers 200, the GET endpoint reads the stored value
* back, and the running configuration silently keeps its default. The value
* looks applied and is not, and only a reboot reveals it.
*
* One definition, two consumers -- config_api.c (the API table) and
* nvs_config.c (the loader) -- so the two cannot drift again. The host test in
* test/test_config_rules.c pins the boundaries.
*
* Dependency-free on purpose: the host test includes this header without
* ESP-IDF, exactly like the other pure headers in this firmware.
*/
#pragma once
/* Vital-sign window, in samples. The loader accepted 32..256; the API used to
* accept 8..4096, so 8..31 and 257..4096 were stored, reported as applied and
* then ignored at boot. */
#define CFG_LIMIT_VITAL_WINDOW_MIN 32u
#define CFG_LIMIT_VITAL_WINDOW_MAX 256u
/* Subcarriers kept per packet. The loader accepted 1..32; the API used to
* accept 1..64, so 33..64 were stored, reported as applied and then ignored at
* boot. */
#define CFG_LIMIT_TOP_K_MIN 1u
#define CFG_LIMIT_TOP_K_MAX 32u
/* Channels the node hops over. The loader accepts 1..NVS_CFG_HOP_MAX, which is
* the size of the channel list; the API used to accept 1..14, so 7..14 were
* stored, reported as applied and then ignored at boot. */
#define CFG_LIMIT_HOP_COUNT_MIN 1u
#define CFG_LIMIT_HOP_COUNT_MAX 6u
+132
View File
@@ -0,0 +1,132 @@
/**
* @file config_rules.c
* @brief The writable configuration surface and the rules that accept it.
*
* See config_rules.h. No ESP-IDF, no cJSON: this file is compiled on the host
* by test/test_config_rules.c as well as on the device.
*/
#include "config_rules.h"
#include <math.h>
#include <stdio.h>
#include <string.h>
const cfg_key_t CONFIG_KEYS[] = {
/* json nvs type lo hi len scale assoc */
{ "wifi_ssid", "ssid", T_STR, 0, 0, 32, 0, true },
{ "wifi_password", "password", T_STR, 0, 0, 64, 0, true },
{ "channel_hop_count", "hop_count", T_U8, CFG_LIMIT_HOP_COUNT_MIN,
CFG_LIMIT_HOP_COUNT_MAX,
0, 0, true },
{ "dwell_ms", "dwell_ms", T_U32, 10, 60000, 0, 0, true },
{ "csi_channel", "csi_channel", T_U8, 0, 14, 0, 0, true },
{ "target_ip", "target_ip", T_STR, 0, 0, 16, 0, false },
{ "target_port", "target_port", T_U16, 1, 65535, 0, 0, false },
{ "node_id", "node_id", T_U8, 0, 254, 0, 0, false },
{ "tdm_slot_index", "tdm_slot", T_U8, 0, 63, 0, 0, false },
{ "tdm_node_count", "tdm_nodes", T_U8, 1, 64, 0, 0, false },
{ "beacon_period_ms", "beacon_ms", T_U16, 0, 10000, 0, 0, false },
{ "edge_tier", "edge_tier", T_U8, 0, 2, 0, 0, false },
{ "presence_thresh", "pres_thresh", T_U16, 0, 65535, 0, 1000, false },
{ "fall_thresh", "fall_thresh", T_U16, 0, 65535, 0, 1000, false },
{ "vital_window", "vital_win", T_U16, CFG_LIMIT_VITAL_WINDOW_MIN,
CFG_LIMIT_VITAL_WINDOW_MAX,
0, 0, false },
{ "vital_interval_ms", "vital_int", T_U16, 100, 60000, 0, 0, false },
{ "top_k_count", "subk_count", T_U8, CFG_LIMIT_TOP_K_MIN,
CFG_LIMIT_TOP_K_MAX,
0, 0, false },
{ "power_duty", "power_duty", T_U8, 10, 100, 0, 0, false },
{ "zone_name", "zone_name", T_STR, 0, 0, 16, 0, false },
{ "swarm_heartbeat_sec","swarm_hb", T_U16, 1, 65535, 0, 0, false },
{ "swarm_ingest_sec", "swarm_ingest", T_U16, 1, 65535, 0, 0, false },
{ "led_mode", "led_mode", T_U8, 0, 2, 0, 0, false },
{ "led_brightness", "led_bright", T_U8, 0, 100, 0, 0, false },
};
const size_t CONFIG_KEY_COUNT = sizeof(CONFIG_KEYS) / sizeof(CONFIG_KEYS[0]);
const cfg_key_t *config_find_key(const char *json)
{
if (!json) return NULL;
for (size_t i = 0; i < CONFIG_KEY_COUNT; i++) {
if (strcmp(CONFIG_KEYS[i].json, json) == 0) return &CONFIG_KEYS[i];
}
return NULL;
}
bool config_validate_value(const cfg_key_t *k, const cfg_value_t *v,
uint32_t *scaled, char *err, size_t errlen)
{
if (k->type == T_STR) {
if (v->kind != CFG_VAL_STR || v->str == NULL) {
snprintf(err, errlen, "%s must be a string", k->json);
return false;
}
if (strlen(v->str) >= k->maxlen) {
snprintf(err, errlen, "%s exceeds %u chars", k->json,
(unsigned)(k->maxlen - 1));
return false;
}
return true;
}
if (v->kind != CFG_VAL_NUM) {
snprintf(err, errlen, "%s must be a number", k->json);
return false;
}
double d = v->num;
if (k->scale > 0) d *= k->scale;
/* Reject a value that is not a number at all -- NaN, or an infinity the
* scale just overflowed into. The negative clamp below would turn -inf
* into a perfectly valid 0, and the old code cast before it range-checked,
* so 1e300 became an arbitrary integer. */
if (!isfinite(d)) {
snprintf(err, errlen, "%s is not a finite number", k->json);
return false;
}
if (d < 0) d = 0; /* negatives clamp to 0 */
/* Reject BEFORE the cast. A NaN, an infinity or a number past the
* accepted window must never be truncated into a value that looks valid:
* the old code cast first, so 1e300 became an arbitrary integer. The
* half-step tolerance keeps the rounding of the old path: a value that
* rounds inside the window is accepted, one that rounds outside is not. */
if (!(d >= (double)k->lo - 0.5 && d <= (double)k->hi + 0.5)) {
snprintf(err, errlen, "%s out of range %lu..%lu", k->json,
(unsigned long)k->lo, (unsigned long)k->hi);
return false;
}
uint32_t val = (uint32_t)(d + 0.5);
if (val < k->lo || val > k->hi) {
snprintf(err, errlen, "%s out of range %lu..%lu", k->json,
(unsigned long)k->lo, (unsigned long)k->hi);
return false;
}
if (scaled) *scaled = val;
return true;
}
bool config_write_allowed(bool trial_armed, bool pending_reboot)
{
return !trial_armed && !pending_reboot;
}
bool config_is_live(const char *name)
{
return strcmp(name, "led_mode") == 0 ||
strcmp(name, "led_brightness") == 0;
}
bool config_is_meta(const char *name)
{
return strcmp(name, "trial_seconds") == 0 ||
strcmp(name, "reboot") == 0;
}
@@ -0,0 +1,89 @@
/**
* @file config_rules.h
* @brief The writable configuration surface and the rules that accept it.
*
* Deliberately free of ESP-IDF and of cJSON: the table and the validation
* live here so that test/test_config_rules.c can pin them on the host,
* without a device. The HTTP handler decodes a request into a cfg_value_t and
* asks these rules whether the value is acceptable -- it never decides on its
* own, and it never writes a value these rules have not accepted.
*/
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include "config_limits.h"
typedef enum { T_STR, T_U8, T_U16, T_U32 } cfg_type_t;
/**
* The writable surface.
*
* `nvs` MUST match the key nvs_config_load() reads, and nothing enforces that
* match at compile time -- a typo here writes a key nobody loads. The GET
* handler exists partly as the check: it reads back through the same keys, so
* a setting that did not take is visible immediately rather than silently
* ignored.
*
* `scale` handles the thresholds nvs_config.c stores as fixed point (u16 of
* value*1000) while exposing a float; 0 means the value passes through.
*
* `assoc` marks the keys that can orphan a node, and is the ONLY thing that
* decides whether a change goes through the trial path.
*/
typedef struct {
const char *json;
const char *nvs;
cfg_type_t type;
uint32_t lo, hi;
size_t maxlen;
float scale;
bool assoc;
} cfg_key_t;
/** One decoded request value, so the rules never have to know about cJSON. */
typedef enum { CFG_VAL_NONE, CFG_VAL_STR, CFG_VAL_NUM } cfg_val_kind_t;
typedef struct {
cfg_val_kind_t kind;
const char *str; /**< CFG_VAL_STR: the string as it arrived. */
double num; /**< CFG_VAL_NUM: the number as it arrived. */
} cfg_value_t;
/** The writable surface, in GET order. */
extern const cfg_key_t CONFIG_KEYS[];
extern const size_t CONFIG_KEY_COUNT;
/** The key for a JSON field name, or NULL when the name is not writable. */
const cfg_key_t *config_find_key(const char *json);
/**
* Accept or reject one value.
*
* Returns false with `err` set on the first problem. Nothing is written and
* nothing is committed: the caller must validate EVERY field of a request
* before it writes ANY of them, because a rejected request has to leave the
* node exactly as it was.
*
* On success `scaled` receives the integer to store (the value multiplied by
* the key's scale and rounded); it is unused for T_STR.
*/
bool config_validate_value(const cfg_key_t *k, const cfg_value_t *v,
uint32_t *scaled, char *err, size_t errlen);
/**
* True when a request may write.
*
* False while a boot trial is pending (the trial may still revert) and false
* while a change is in flight (a second change would bank the UNPROVEN values
* as the fallback, so a revert would restore settings never shown to work).
*/
bool config_write_allowed(bool trial_armed, bool pending_reboot);
/** True for the parameters the running firmware re-reads every cycle. */
bool config_is_live(const char *name);
/** True for the body fields that control the request rather than a parameter. */
bool config_is_meta(const char *name);
+5 -4
View File
@@ -483,9 +483,9 @@ static void wifi_promiscuous_cb(void *buf, wifi_promiscuous_pkt_type_t type)
* are sparse beacons (often non-OFDM DSSS), so wifi_csi_callback can starve to
* yield=0pps -> DEGRADED -> motion/presence=0 (#521, #954).
*
* This guarantees a ~50 Hz OFDM unicast floor by pinging the STA's own gateway:
* the router's ICMP echo replies are OFDM frames destined to this station, which
* drive the CSI engine regardless of promiscuous filter state or ambient traffic.
* Ping the STA's gateway to request unicast traffic. Some APs send ICMP replies
* at 802.11b rates, which produce no CSI (#2287); this is not a guaranteed
* OFDM floor. Such APs need a LAN host sending unicast UDP to the node.
* It is ADDITIVE — promiscuous capture (#396/#893) is left fully intact so
* multistatic/multi-node sensing still hears other stations' frames. Mirrors
* Espressif's esp-csi csi_recv_router reference.
@@ -517,6 +517,7 @@ static void csi_start_self_ping(void)
cfg.target_addr = target;
cfg.count = ESP_PING_COUNT_INFINITE;
cfg.interval_ms = CSI_SELF_PING_INTERVAL_MS;
cfg.timeout_ms = CSI_SELF_PING_INTERVAL_MS;
cfg.data_size = 1;
cfg.task_stack_size = 4096;
@@ -529,7 +530,7 @@ static void csi_start_self_ping(void)
if (esp_ping_new_session(&cfg, &cbs, &s_self_ping) == ESP_OK && s_self_ping != NULL) {
esp_ping_start(s_self_ping);
ESP_LOGI(TAG, "self-ping started -> %s @%dHz (CSI OFDM source, fix #521/#954)",
ESP_LOGI(TAG, "self-ping started -> %s @%dHz (CSI yield depends on AP reply rate, #2287)",
gw_str, CONFIG_CSI_SELF_PING_HZ);
} else {
ESP_LOGW(TAG, "self-ping: esp_ping_new_session failed");
@@ -1062,7 +1062,13 @@ static void send_feature_vector(void)
static void process_frame(const edge_ring_slot_t *slot)
{
uint16_t n_subcarriers = slot->iq_len / 2;
if (n_subcarriers == 0 || n_subcarriers > EDGE_MAX_SUBCARRIERS) return;
if (n_subcarriers == 0) return;
/* S3 STBC frames append a second HT-LTF block (192 entries total).
* Process the bounded LLTF + HT-LTF prefix instead of starving edge
* output (#2286). Raw CSI streaming retains the complete payload. */
if (n_subcarriers > EDGE_MAX_SUBCARRIERS) {
n_subcarriers = EDGE_MAX_SUBCARRIERS;
}
s_frame_count++;
s_latest_rssi = slot->rssi;
@@ -37,19 +37,20 @@
* Max subcarriers per frame the edge pipeline will process.
*
* Target-conditional since 2026-08-28. The original 128 was an ESP32-S3
* assumption: pre-HE chips report at most 128 CSI bins. ADR-110 onboarded the
* DSP budget: pre-HE STBC frames can contain 192 bins, of which the first
* 128 are processed (#2286). ADR-110 onboarded the
* ESP32-C6 to the *capture* path but not to this one, and a C6 associated to an
* HE-capable AP delivers HE20 frames with 256 bins (iq_len = 512 bytes).
*
* `process_frame()` guards with `n_subcarriers > EDGE_MAX_SUBCARRIERS -> return`,
* Historically, `process_frame()` rejected frames above this budget,
* so on C6 every frame was rejected and the whole edge pipeline — vitals,
* presence, fall detection, per-slot counting — silently did nothing. The Edge
* DSP task started, logged its banner, and never processed a frame. Confirmed
* on hardware: no edge_proc log past init and no vitals packet ever reaching
* the sink.
*
* HE-capable parts get 256; pre-HE parts keep 128 so they don't pay ~3.5 KB of
* .bss they can never use. CONFIG_SOC_WIFI_HE_SUPPORT is the same switch
* HE-capable parts get 256; pre-HE parts keep 128 to bound DSP state while
* accepting the prefix of longer STBC payloads. CONFIG_SOC_WIFI_HE_SUPPORT is the same switch
* csi_collector.c uses to pick its rx_ctrl layout.
*/
#if CONFIG_SOC_WIFI_HE_SUPPORT
@@ -12,3 +12,14 @@ dependencies:
## Onboard WS2812 LED Disabling
# 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"
## cJSON for config_api.c.
## Bundled as the `json` component up to ESP-IDF 5.x and moved out to the
## component manager in v6, so it is pulled ONLY on the v6+ lane -- on 5.4
## `main/CMakeLists.txt` requires the built-in `json` instead. Taking it from
## the registry on 5.x as well would mean two cJSON implementations in one
## build.
espressif/cjson:
version: "^1.7.19"
rules:
- if: "idf_version >=6.0"
+35 -2
View File
@@ -25,6 +25,7 @@
#include "thermal.h"
#include "stream_sender.h"
#include "nvs_config.h"
#include "config_api.h"
#include "serial_onboarding.h"
#include "edge_processing.h"
#include "ota_update.h"
@@ -145,6 +146,8 @@ static void event_handler(void *arg, esp_event_base_t event_base,
s_retry_num = 0;
ota_health_note_got_ip();
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
/* Proof that a pending config trial produced a usable network. */
config_trial_notify_connected();
}
}
@@ -294,8 +297,31 @@ static led_strip_handle_t s_viz_led;
static void led_gamma_40hz_cb(void *arg)
{
static bool on = false;
static uint8_t last_mode = 0xFF;
/* Read live rather than latching at boot, so turning the LED down from
* config_push.py takes effect immediately -- the point of the knob is
* being able to darken a bedroom without waiting for a reboot. */
uint8_t mode = g_nvs_config.led_mode;
uint8_t bright = g_nvs_config.led_brightness;
if (bright > 100) bright = 100;
if (mode == LED_MODE_OFF) {
/* Blank once on the transition, then leave the RMT idle instead of
* clocking out zeros eighty times a second for the node's lifetime. */
if (last_mode != LED_MODE_OFF) {
led_strip_set_pixel(s_viz_led, 0, 0, 0, 0);
led_strip_refresh(s_viz_led);
last_mode = mode;
}
return;
}
last_mode = mode;
on = !on;
if (on) {
/* STEADY keeps the motion colour but drops the square wave: same
* information, no 40 Hz strobe. */
if (on || mode == LED_MODE_STEADY) {
edge_vitals_pkt_t v;
float m = edge_get_vitals(&v) ? v.motion_energy : 0.0f;
float norm = m / LED_MOTION_FULLSCALE;
@@ -303,7 +329,10 @@ static void led_gamma_40hz_cb(void *arg)
if (norm > 1.0f) norm = 1.0f;
int idx = (int)(norm * 59.0f + 0.5f);
const uint8_t *c = VIRIDIS_LUT[idx];
led_strip_set_pixel(s_viz_led, 0, c[0], c[1], c[2]); /* R,G,B (driver maps to GRB) */
led_strip_set_pixel(s_viz_led, 0, /* R,G,B (driver maps to GRB) */
(uint32_t)c[0] * bright / 100,
(uint32_t)c[1] * bright / 100,
(uint32_t)c[2] * bright / 100);
} else {
led_strip_set_pixel(s_viz_led, 0, 0, 0, 0); /* off phase */
}
@@ -322,6 +351,10 @@ void app_main(void)
ESP_ERROR_CHECK(ret);
/* Load runtime config (NVS overrides Kconfig defaults) */
/* Before loading config: if the last change is still on trial this arms
* the revert timer, so settings that cannot associate undo themselves. */
config_trial_boot_check();
nvs_config_load(&g_nvs_config);
/* Capture node_id IMMEDIATELY — before wifi_init_sta() can corrupt
+26 -2
View File
@@ -9,11 +9,19 @@
#include "nvs_config.h"
#include <string.h>
#include "csi_collector.h"
#include "esp_log.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "sdkconfig.h"
/* The hop list holds CSI_HOP_CHANNELS_MAX channels, so the loader cannot
* accept a hop count the list cannot hold -- and the configuration API must
* not accept one either. The header used to say "must match"; now it is
* enforced at compile time. */
_Static_assert(CSI_HOP_CHANNELS_MAX == CFG_LIMIT_HOP_COUNT_MAX,
"the hop limit must match the channel list size");
static const char *TAG = "nvs_config";
void nvs_config_load(nvs_config_t *cfg)
@@ -23,6 +31,9 @@ void nvs_config_load(nvs_config_t *cfg)
return;
}
cfg->led_mode = LED_MODE_FLICKER;
cfg->led_brightness = 100;
/* Start with Kconfig compiled defaults */
strlcpy(cfg->wifi_ssid, CONFIG_CSI_WIFI_SSID, sizeof(cfg->wifi_ssid));
@@ -217,7 +228,8 @@ void nvs_config_load(nvs_config_t *cfg)
uint16_t vital_win_val;
if (nvs_get_u16(handle, "vital_win", &vital_win_val) == ESP_OK) {
if (vital_win_val >= 32 && vital_win_val <= 256) {
if (vital_win_val >= CFG_LIMIT_VITAL_WINDOW_MIN &&
vital_win_val <= CFG_LIMIT_VITAL_WINDOW_MAX) {
cfg->vital_window = vital_win_val;
ESP_LOGI(TAG, "NVS override: vital_window=%u", cfg->vital_window);
}
@@ -233,7 +245,7 @@ void nvs_config_load(nvs_config_t *cfg)
uint8_t topk_val;
if (nvs_get_u8(handle, "subk_count", &topk_val) == ESP_OK) {
if (topk_val >= 1 && topk_val <= 32) {
if (topk_val >= CFG_LIMIT_TOP_K_MIN && topk_val <= CFG_LIMIT_TOP_K_MAX) {
cfg->top_k_count = topk_val;
ESP_LOGI(TAG, "NVS override: top_k_count=%u", (unsigned)cfg->top_k_count);
}
@@ -313,6 +325,18 @@ void nvs_config_load(nvs_config_t *cfg)
if (nvs_get_u16(handle, "swarm_hb", &cfg->swarm_heartbeat_sec) != ESP_OK) {
cfg->swarm_heartbeat_sec = 30;
}
uint8_t led_mode_val = 0;
if (nvs_get_u8(handle, "led_mode", &led_mode_val) == ESP_OK && led_mode_val <= 2) {
cfg->led_mode = led_mode_val;
ESP_LOGI(TAG, "NVS override: led_mode=%u", led_mode_val);
}
uint8_t led_bright_val = 0;
if (nvs_get_u8(handle, "led_bright", &led_bright_val) == ESP_OK && led_bright_val <= 100) {
cfg->led_brightness = led_bright_val;
ESP_LOGI(TAG, "NVS override: led_brightness=%u", led_bright_val);
}
if (nvs_get_u16(handle, "swarm_ingest", &cfg->swarm_ingest_sec) != ESP_OK) {
cfg->swarm_ingest_sec = 5;
}
+19 -2
View File
@@ -13,13 +13,17 @@
#include <stdint.h>
#include "config_limits.h"
/** Maximum lengths for NVS string fields. */
#define NVS_CFG_SSID_MAX 33
#define NVS_CFG_PASS_MAX 65
#define NVS_CFG_IP_MAX 16
/** Maximum channels in the hop list (must match CSI_HOP_CHANNELS_MAX). */
#define NVS_CFG_HOP_MAX 6
/** Maximum channels in the hop list (must match CSI_HOP_CHANNELS_MAX).
* Derived from the shared limit so the loader and the configuration API can
* never disagree about how many channels the node may hop over. */
#define NVS_CFG_HOP_MAX CFG_LIMIT_HOP_COUNT_MAX
/** Runtime configuration loaded from NVS or Kconfig defaults. */
typedef struct {
@@ -62,8 +66,21 @@ typedef struct {
char zone_name[16]; /**< Zone name for this node (e.g. "lobby"). */
uint16_t swarm_heartbeat_sec; /**< Heartbeat interval (seconds, default 30). */
uint16_t swarm_ingest_sec; /**< Vector ingest interval (seconds, default 5). */
/* Onboard WS2812 "viz" LED. The default is a 40 Hz square-wave gamma
* stimulus at full LUT brightness, which is fine on a bench and hostile
* in a bedroom -- hence runtime selectable rather than Kconfig-only. */
uint8_t led_mode; /**< 0=off, 1=steady colour, 2=40Hz flicker. */
uint8_t led_brightness; /**< Percent, 0-100, scales the colormap. */
} nvs_config_t;
#define LED_MODE_OFF 0
#define LED_MODE_STEADY 1
#define LED_MODE_FLICKER 2
/** Live configuration, loaded once at boot by app_main(). */
extern nvs_config_t g_nvs_config;
/**
* Load configuration from NVS, falling back to Kconfig defaults.
*
@@ -0,0 +1,28 @@
#ifndef RUVIEW_ONBOARDING_CONSOLE_H
#define RUVIEW_ONBOARDING_CONSOLE_H
#include "esp_err.h"
#include "esp_idf_version.h"
#if defined(CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG) && ESP_IDF_VERSION_MAJOR >= 6
#include "driver/usb_serial_jtag.h"
#include "driver/usb_serial_jtag_vfs.h"
#endif
static inline esp_err_t ruview_onboarding_console_prepare(void)
{
#if defined(CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG) && ESP_IDF_VERSION_MAJOR >= 6
/* IDF 6 VFS nonblocking reads consult the driver's RX ring buffer even
* when the default polling console has not installed that driver. Boot
* output works in that state, but every onboarding read stays empty. */
if (!usb_serial_jtag_is_driver_installed()) {
usb_serial_jtag_driver_config_t config = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT();
const esp_err_t result = usb_serial_jtag_driver_install(&config);
if (result != ESP_OK) return result;
}
usb_serial_jtag_vfs_use_driver();
#endif
return ESP_OK;
}
#endif
+13 -1
View File
@@ -9,6 +9,7 @@
*/
#include "ota_update.h"
#include "config_api.h"
#include <string.h>
#include "esp_log.h"
@@ -81,6 +82,11 @@ static bool ota_check_auth(httpd_req_t *req)
return result == 0;
}
bool ota_auth_check(httpd_req_t *req)
{
return ota_check_auth(req);
}
/**
* GET /ota/status — return firmware version and partition info.
*/
@@ -265,7 +271,7 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle)
* httpd", RTC_SW_CPU_RST), so no OTA had ever completed on this firmware.
* See CONFIG_OTA_HTTPD_STACK_SIZE for the sizing. */
config.stack_size = CONFIG_OTA_HTTPD_STACK_SIZE;
config.max_uri_handlers = 12; /* Extra slots for WASM endpoints (ADR-040). */
config.max_uri_handlers = 14; /* WASM endpoints (ADR-040) + /config. */
/* Increase receive timeout for large uploads. */
config.recv_wait_timeout = 30;
@@ -294,6 +300,12 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle)
};
httpd_register_uri_handler(server, &upload_uri);
/* Remote configuration shares this server so it inherits the OTA PSK,
* the port, and the same fail-closed auth path (config_api.c). */
if (config_api_register(server) != ESP_OK) {
ESP_LOGW(TAG, "remote config endpoints unavailable");
}
ESP_LOGI(TAG, "OTA HTTP server started on port %d", OTA_PORT);
ESP_LOGI(TAG, " GET /ota/status — firmware version info");
ESP_LOGI(TAG, " POST /ota — upload new firmware binary");
+13
View File
@@ -9,7 +9,10 @@
#ifndef OTA_UPDATE_H
#define OTA_UPDATE_H
#include <stdbool.h>
#include "esp_err.h"
#include "esp_http_server.h" /* httpd_req_t, for ota_auth_check() */
/**
* Initialize the OTA update HTTP server.
@@ -30,6 +33,16 @@ esp_err_t ota_update_init(void);
*/
esp_err_t ota_update_init_ex(void **out_server);
/**
* Validate the Authorization: Bearer <psk> header against the provisioned OTA
* PSK, in constant time. Fails closed when no PSK is provisioned.
*
* Shared with config_api.c so remote configuration is gated by exactly the
* same secret and the same comparison as firmware upload -- a second
* implementation would be a second place for the check to rot.
*/
bool ota_auth_check(httpd_req_t *req);
/**
* Log the httpd task's stack high-water mark (bytes never used). Call from an
* HTTP handler on the OTA server after its deepest work, so the configured
@@ -2,6 +2,7 @@
#include "serial_onboarding_protocol.h"
#include "onboarding_identity.h"
#include "onboarding_console.h"
#include <errno.h>
#include <fcntl.h>
@@ -157,6 +158,8 @@ static void serial_onboarding_task(void *argument)
esp_err_t serial_onboarding_start(const nvs_config_t *config)
{
if (config == NULL) return ESP_ERR_INVALID_ARG;
const esp_err_t console_result = ruview_onboarding_console_prepare();
if (console_result != ESP_OK) return console_result;
memcpy(&s_current_config, config, sizeof(s_current_config));
const BaseType_t created = xTaskCreate(serial_onboarding_task, "serial_onboard",
4096, NULL, 4, NULL);
+25 -1
View File
@@ -136,9 +136,22 @@ def _default_state_dir() -> str:
return os.path.join(base, "wifi-densepose", "esp32-provision-state")
_RESERVED_DEVICE_RE = re.compile(
r"^(con|prn|aux|nul|com[1-9]|lpt[1-9])$", re.IGNORECASE
)
def _unprefixed_state_name(port: str) -> str:
"""Pre-reserved-name-fix state filename for `port` (no leading underscore)."""
safe = port.replace("/", "_").replace(":", "_").replace("\\", "_")
return f"{safe}.json"
def _state_path_for(port: str, state_dir: str) -> str:
"""File path for a given serial port. Sanitize the port for filesystem use."""
safe = port.replace("/", "_").replace(":", "_").replace("\\", "_")
if _RESERVED_DEVICE_RE.match(safe):
safe = f"_{safe}"
return os.path.join(state_dir, f"{safe}.json")
@@ -306,7 +319,13 @@ 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)
if not os.path.isfile(path):
return {}
legacy = os.path.join(
state_dir, _unprefixed_state_name(port)
)
if legacy != path and os.path.isfile(legacy):
path = legacy
else:
return {}
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
@@ -443,6 +462,11 @@ def load_board_state(port: str, mac, state_dir: str):
legacy = _state_path_for(port, state_dir)
if os.path.isfile(legacy):
return load_state(port, state_dir), legacy
unprefixed = os.path.join(
state_dir, _unprefixed_state_name(port)
)
if unprefixed != legacy and os.path.isfile(unprefixed):
return load_state(port, state_dir), unprefixed
return {}, None
+48 -6
View File
@@ -38,6 +38,7 @@ CFLAGS = -fsanitize=fuzzer,address,undefined -g -O1 \
STUBS_SRC = stubs/esp_stubs.c
MAIN_DIR = ../main
CJSON_DIR ?= $(if $(wildcard $(IDF_PATH)/components/json/cJSON/cJSON.h),$(IDF_PATH)/components/json/cJSON,$(MAIN_DIR)/../managed_components/espressif__cjson/cJSON)
# GNU ld and Apple's ld spell dead section elimination differently.
# Keep the host sanitation test portable across the Linux CI runner and macOS.
@@ -56,11 +57,12 @@ 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_onboarding_identity
test_config_rules run_config_rules \
run_delivery_contract test_ota_health run_ota_health run_onboarding_identity run_onboarding_console
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 \
test_ota_health
test_ota_health test_config_rules
# --- XIAO ESP32-C6 RF switch selection ---
# Host-side truth table for the GPIO14 antenna selector. GPIO3 is always driven
@@ -140,6 +142,9 @@ test_onboarding_identity: test_onboarding_identity.c $(MAIN_DIR)/onboarding_iden
run_onboarding_identity: test_onboarding_identity
./test_onboarding_identity
run_onboarding_console:
python3 test_onboarding_console.py
# --- 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.
@@ -149,12 +154,49 @@ test_ota_health: test_ota_health.c $(MAIN_DIR)/ota_health.h
run_ota_health: test_ota_health
./test_ota_health
# --- Configuration API rules (config API <-> boot loader agreement) ---
# Host-side, no ESP-IDF. Links the SAME rules the firmware links
# (../main/config_rules.c) so the test and the device cannot disagree:
# the API must not accept a value the boot loader would silently ignore, a
# rejected request must change nothing, and a second request must be refused
# while a change is in flight.
test_config_rules: test_config_rules.c $(MAIN_DIR)/config_rules.c $(MAIN_DIR)/config_rules.h $(MAIN_DIR)/config_limits.h
cc -std=c99 -Wall -Wextra -Werror -I$(MAIN_DIR) -o $@ $< $(MAIN_DIR)/config_rules.c -lm
run_config_rules: test_config_rules
./test_config_rules
# --- Configuration API write-through rollback and boot recovery ---
# Compiles the production handler against deterministic, write-through NVS
# stubs and injects each failure between partial writes and restoration.
test_config_recovery: test_config_recovery.c $(MAIN_DIR)/config_api.c $(MAIN_DIR)/config_rules.c $(MAIN_DIR)/config_limits.h $(CJSON_DIR)/cJSON.h $(CJSON_DIR)/cJSON.c $(wildcard config_stubs/*)
cc -std=c99 -Wall -Wextra -Wno-unused-variable -Wno-unused-parameter -Werror \
-Iconfig_stubs -Istubs -I$(MAIN_DIR) -I$(CJSON_DIR) \
test_config_recovery.c $(MAIN_DIR)/config_rules.c $(CJSON_DIR)/cJSON.c -lm -o $@
run_config_recovery: test_config_recovery
./test_config_recovery
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_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)"
test_edge_stbc: test_edge_stbc.c $(MAIN_DIR)/edge_processing.c $(MAIN_DIR)/edge_processing.h $(STUBS_SRC)
cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) test_edge_stbc.c $(STUBS_SRC) -o $@ -lm
.PHONY: run_edge_stbc run_self_ping
run_edge_stbc: test_edge_stbc
./test_edge_stbc
test_self_ping: test_self_ping.c $(MAIN_DIR)/csi_collector.c $(STUBS_SRC)
cc -std=c99 -Wall -Wextra -Istubs -I$(MAIN_DIR) -ffunction-sections test_self_ping.c $(STUBS_SRC) $(DEAD_STRIP_LDFLAGS) -o $@ -lm
run_self_ping: test_self_ping
./test_self_ping
host_tests: run_self_ping run_edge_stbc run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \
run_c6_antenna run_serial_onboarding run_onboarding_identity run_onboarding_console run_delivery_contract run_ota_health \
run_config_rules run_config_recovery
@echo "Host tests passed (firmware contracts, DSP, configuration recovery, onboarding and OTA health)"
# --- Serialize fuzzer ---
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
@@ -192,5 +234,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_onboarding_identity
test_ota_health test_onboarding_identity test_config_rules test_edge_stbc test_self_ping test_config_recovery
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/
@@ -0,0 +1,58 @@
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef int esp_err_t;
#define ESP_OK 0
#define ESP_FAIL -1
#define ESP_ERR_NO_MEM 0x101
#define ESP_ERR_NVS_NOT_FOUND 0x1102
#define ESP_LOGE(...) ((void)0)
#define ESP_LOGW(...) ((void)0)
#define ESP_LOGI(...) ((void)0)
typedef int nvs_handle_t;
#define NVS_READONLY 0
#define NVS_READWRITE 1
esp_err_t nvs_open(const char *, int, nvs_handle_t *);
void nvs_close(nvs_handle_t);
esp_err_t nvs_commit(nvs_handle_t);
esp_err_t nvs_get_str(nvs_handle_t, const char *, char *, size_t *);
esp_err_t nvs_get_u8(nvs_handle_t, const char *, uint8_t *);
esp_err_t nvs_get_u16(nvs_handle_t, const char *, uint16_t *);
esp_err_t nvs_get_u32(nvs_handle_t, const char *, uint32_t *);
esp_err_t nvs_set_str(nvs_handle_t, const char *, const char *);
esp_err_t nvs_set_u8(nvs_handle_t, const char *, uint8_t);
esp_err_t nvs_set_u16(nvs_handle_t, const char *, uint16_t);
esp_err_t nvs_set_u32(nvs_handle_t, const char *, uint32_t);
esp_err_t nvs_erase_key(nvs_handle_t, const char *);
esp_err_t nvs_erase_all(nvs_handle_t);
typedef struct { const char *body; int content_len, offset, status; } httpd_req_t;
typedef void *httpd_handle_t;
typedef struct { const char *uri; int method; esp_err_t (*handler)(httpd_req_t *); } httpd_uri_t;
#define HTTP_GET 0
#define HTTP_POST 1
#define HTTPD_400_BAD_REQUEST 400
#define HTTPD_403_FORBIDDEN 403
#define HTTPD_500_INTERNAL_SERVER_ERROR 500
static inline int httpd_req_recv(httpd_req_t *r, char *b, size_t n) {
if (n > (size_t)(r->content_len - r->offset)) n = r->content_len - r->offset;
memcpy(b, r->body + r->offset, n); r->offset += n; return (int)n;
}
static inline esp_err_t httpd_resp_send_err(httpd_req_t *r, int s, const char *m) { (void)m; r->status = s; return ESP_OK; }
static inline esp_err_t httpd_resp_sendstr(httpd_req_t *r, const char *s) { (void)s; if (!r->status) r->status = 200; return ESP_OK; }
static inline esp_err_t httpd_resp_set_type(httpd_req_t *r, const char *s) { (void)r; (void)s; return ESP_OK; }
static inline esp_err_t httpd_resp_set_status(httpd_req_t *r, const char *s) { r->status = atoi(s); return ESP_OK; }
static inline esp_err_t httpd_register_uri_handler(httpd_handle_t h, const httpd_uri_t *u) { (void)h; (void)u; return ESP_OK; }
typedef void *esp_timer_handle_t;
typedef struct { void (*callback)(void *); const char *name; } esp_timer_create_args_t;
esp_err_t esp_timer_create(const esp_timer_create_args_t *, esp_timer_handle_t *);
esp_err_t esp_timer_start_once(esp_timer_handle_t, uint64_t);
static inline esp_err_t esp_timer_stop(esp_timer_handle_t h) { (void)h; return ESP_OK; }
static inline esp_err_t esp_timer_delete(esp_timer_handle_t h) { (void)h; return ESP_OK; }
static inline int64_t esp_timer_get_time(void) { return 100000000; }
void esp_restart(void);
static inline esp_err_t esp_wifi_disconnect(void) { return ESP_OK; }
static inline const char *esp_err_to_name(esp_err_t e) { (void)e; return "synthetic"; }
@@ -0,0 +1,2 @@
#pragma once
#include "config_test_sdk.h"
@@ -0,0 +1,2 @@
#pragma once
#include "config_test_sdk.h"
@@ -0,0 +1,2 @@
#pragma once
#include "config_test_sdk.h"
@@ -0,0 +1,2 @@
#pragma once
#include "config_test_sdk.h"
@@ -0,0 +1,2 @@
#pragma once
#include "config_test_sdk.h"
@@ -0,0 +1,2 @@
#pragma once
#include "config_test_sdk.h"
@@ -0,0 +1,2 @@
#pragma once
#include "config_test_sdk.h"
@@ -0,0 +1,2 @@
#pragma once
#include "config_test_sdk.h"
@@ -28,7 +28,7 @@
*/
static uint8_t validate_hop_count(uint8_t val)
{
if (val >= 1 && val <= NVS_CFG_HOP_MAX) return val;
if (val >= CFG_LIMIT_HOP_COUNT_MIN && val <= CFG_LIMIT_HOP_COUNT_MAX) return val;
return 0;
}
@@ -61,11 +61,14 @@ static uint8_t validate_edge_tier(uint8_t val)
}
/**
* Validate vital_window (32-256).
* Validate vital_window.
*
* The bounds come from config_limits.h, the same header the loader and the
* configuration API use, so the fuzzer cannot drift away from the firmware.
*/
static uint16_t validate_vital_window(uint16_t val)
{
if (val >= 32 && val <= 256) return val;
if (val >= CFG_LIMIT_VITAL_WINDOW_MIN && val <= CFG_LIMIT_VITAL_WINDOW_MAX) return val;
return 0;
}
@@ -83,7 +86,7 @@ static uint16_t validate_vital_interval(uint16_t val)
*/
static uint8_t validate_top_k(uint8_t val)
{
if (val >= 1 && val <= 32) return val;
if (val >= CFG_LIMIT_TOP_K_MIN && val <= CFG_LIMIT_TOP_K_MAX) return val;
return 0;
}
@@ -0,0 +1,140 @@
/* SYNTHETIC: run the real HTTP handler and boot recovery against write-through
* NVS fault injection. No device, credentials or RF data are used. */
#include "config_test_sdk.h"
#include "cJSON.h"
#include <assert.h>
#include "../main/config_api.c"
nvs_config_t g_nvs_config;
static cJSON *db[3];
static int live_sets, fail_set_at, restarts;
static bool fail_restore, fail_erase, fail_bank_clear, fail_read, fail_restore_open;
static bool fail_timer, fail_commit;
static void put(nvs_handle_t h, const char *key, cJSON *value)
{
cJSON_DeleteItemFromObjectCaseSensitive(db[h], key);
assert(cJSON_AddItemToObject(db[h], key, value));
}
esp_err_t nvs_open(const char *name, int mode, nvs_handle_t *h)
{
*h = strcmp(name, "csi_cfg") == 0 ? 1 : 2;
if (*h == 1 && mode == NVS_READWRITE && fail_restore_open && live_sets >= 2) return ESP_FAIL;
return ESP_OK;
}
void nvs_close(nvs_handle_t h) { (void)h; }
esp_err_t nvs_commit(nvs_handle_t h) { return h == 1 && fail_commit ? ESP_FAIL : ESP_OK; }
esp_err_t nvs_get_str(nvs_handle_t h, const char *key, char *value, size_t *size)
{
if (h == 1 && fail_read) return ESP_FAIL;
cJSON *v = cJSON_GetObjectItemCaseSensitive(db[h], key);
if (!cJSON_IsString(v)) return ESP_ERR_NVS_NOT_FOUND;
size_t n = strlen(v->valuestring) + 1;
if (value) { if (*size < n) return ESP_FAIL; memcpy(value, v->valuestring, n); }
*size = n; return ESP_OK;
}
static esp_err_t get_num(nvs_handle_t h, const char *key, uint32_t *value)
{
cJSON *v = cJSON_GetObjectItemCaseSensitive(db[h], key);
if (!cJSON_IsNumber(v)) return ESP_ERR_NVS_NOT_FOUND;
*value = (uint32_t)v->valuedouble; return ESP_OK;
}
esp_err_t nvs_get_u8(nvs_handle_t h, const char *k, uint8_t *v) { uint32_t n=0; int e=get_num(h,k,&n); *v=(uint8_t)n; return e; }
esp_err_t nvs_get_u16(nvs_handle_t h, const char *k, uint16_t *v) { uint32_t n=0; int e=get_num(h,k,&n); *v=(uint16_t)n; return e; }
esp_err_t nvs_get_u32(nvs_handle_t h, const char *k, uint32_t *v) { return get_num(h,k,v); }
static esp_err_t set_value(nvs_handle_t h, const char *k, cJSON *v)
{
if (h == 1) {
live_sets++;
if ((fail_set_at && live_sets == fail_set_at) ||
(fail_restore && live_sets > fail_set_at)) { cJSON_Delete(v); return ESP_FAIL; }
}
put(h,k,v); return ESP_OK; /* setters immediately change persistent state */
}
esp_err_t nvs_set_str(nvs_handle_t h, const char *k, const char *v) { return set_value(h,k,cJSON_CreateString(v)); }
esp_err_t nvs_set_u8(nvs_handle_t h, const char *k, uint8_t v) { return set_value(h,k,cJSON_CreateNumber(v)); }
esp_err_t nvs_set_u16(nvs_handle_t h, const char *k, uint16_t v) { return set_value(h,k,cJSON_CreateNumber(v)); }
esp_err_t nvs_set_u32(nvs_handle_t h, const char *k, uint32_t v) { return set_value(h,k,cJSON_CreateNumber(v)); }
esp_err_t nvs_erase_key(nvs_handle_t h, const char *k)
{
if (fail_erase) return ESP_FAIL;
if (!cJSON_GetObjectItemCaseSensitive(db[h],k)) return ESP_ERR_NVS_NOT_FOUND;
cJSON_DeleteItemFromObjectCaseSensitive(db[h],k); return ESP_OK;
}
esp_err_t nvs_erase_all(nvs_handle_t h)
{
if (h == 2 && fail_bank_clear) return ESP_FAIL;
cJSON_Delete(db[h]); db[h]=cJSON_CreateObject(); return ESP_OK;
}
esp_err_t esp_timer_create(const esp_timer_create_args_t *a, esp_timer_handle_t *h) { (void)a; *h=(void *)1; return fail_timer ? ESP_FAIL : ESP_OK; }
esp_err_t esp_timer_start_once(esp_timer_handle_t h, uint64_t us) { (void)h; (void)us; return fail_timer ? ESP_FAIL : ESP_OK; }
void esp_restart(void) { restarts++; }
bool ota_auth_check(httpd_req_t *r) { (void)r; return true; }
void ota_update_log_httpd_stack(const char *s) { (void)s; }
static void reset(void)
{
for (int i=1;i<=2;i++) { cJSON_Delete(db[i]); db[i]=cJSON_CreateObject(); }
put(1,"ssid",cJSON_CreateString("old-network"));
put(1,"edge_tier",cJSON_CreateNumber(0));
live_sets=fail_set_at=restarts=0;
fail_restore=fail_erase=fail_bank_clear=fail_read=fail_restore_open=fail_timer=fail_commit=false;
s_pending_reboot=s_trial_armed=false; s_revert_timer=NULL;
}
static int request(const char *body)
{
httpd_req_t req={.body=body,.content_len=(int)strlen(body)};
config_post_handler(&req); return req.status;
}
static bool bank_present(void) { return cJSON_IsString(cJSON_GetObjectItem(db[2],"bank")); }
static void assert_old(void) { assert(strcmp(cJSON_GetObjectItem(db[1],"ssid")->valuestring,"old-network")==0); }
int main(void)
{
const char *change="{\"wifi_ssid\":\"new-network\",\"edge_tier\":1}";
reset();
assert(request("{\"wifi_ssid\":\"new-network\",\"edge_tier\":\"invalid\"}")==400);
assert_old(); assert(live_sets==0 && !bank_present());
reset(); fail_read=true;
assert(request(change)==500); assert(live_sets==0); assert_old();
reset();
assert(request("{\"led_mode\":1,\"reboot\":false}")==200);
assert(!s_pending_reboot && !bank_present() && g_nvs_config.led_mode==1);
reset(); fail_set_at=2;
assert(request(change)==500); assert_old();
assert(!s_pending_reboot && !bank_present());
for (int mode=0;mode<3;mode++) {
reset(); fail_set_at=2;
fail_restore=mode==0; fail_restore_open=mode==1; fail_bank_clear=mode==2;
assert(request(change)==500);
assert(bank_present() && s_pending_reboot);
int previous=live_sets;
assert(request("{\"led_mode\":1}")==409 && live_sets==previous);
config_trial_notify_connected(); assert(bank_present());
/* A reset must restore before association can promote mixed values. */
s_pending_reboot=s_trial_armed=false;
fail_restore=fail_restore_open=fail_bank_clear=false; fail_set_at=0;
config_trial_boot_check();
assert_old(); assert(restarts==1 && !bank_present());
}
reset(); fail_set_at=2; fail_restore=true;
assert(request(change)==500);
trial_revert(NULL); assert(bank_present() && restarts==0 && s_pending_reboot);
reset(); fail_erase=true;
cJSON *absent=cJSON_CreateNull();
assert(!restore_key(1,config_find_key("led_mode"),absent)); cJSON_Delete(absent);
reset(); fail_timer=true;
assert(request(change)==200); assert(bank_present() && s_pending_reboot);
assert(request("{\"led_mode\":1}")==409);
reset();
assert(request(change)==200); assert(bank_present() && s_pending_reboot);
config_trial_notify_connected(); assert(bank_present()); /* old connection */
s_pending_reboot=s_trial_armed=false;
config_trial_boot_check(); assert(s_trial_armed && bank_present());
config_trial_notify_connected(); assert(!s_trial_armed && !bank_present());
reset(); fail_commit=true;
assert(request(change)==500); assert(bank_present() && s_pending_reboot);
for (int i=1;i<=2;i++) cJSON_Delete(db[i]);
puts("PASS: production HTTP/NVS validation, rollback faults, write lock and boot recovery");
return 0;
}
@@ -0,0 +1,281 @@
/**
* Host-side test for the configuration rules.
*
* Pins the behaviour the HTTP configuration API depends on, without a device:
*
* - the API table and the boot loader agree on the accepted ranges, so a
* value the API accepts is a value the loader will actually apply;
* - a value outside the range is rejected BEFORE anything is written, so a
* rejected request leaves the node exactly as it was;
* - a second request is refused while a change is in flight or a trial is
* pending, so a revert can never restore settings that were never shown
* to work.
*
* The rules themselves live in ../main/config_rules.c, which is the same
* translation unit the firmware links -- so this test and the device cannot
* disagree.
*
* Build and run:
* make run_config_rules
*/
#include <stdio.h>
#include <string.h>
#include <math.h>
#include "config_rules.h"
static int failures;
#define CHECK(cond, ...) \
do { \
if (!(cond)) { \
failures++; \
printf("FAIL %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n"); \
} \
} while (0)
/** Accept a numeric value for a key; returns the integer that would be stored. */
static bool accept_num(const char *json, double num, uint32_t *out)
{
const cfg_key_t *k = config_find_key(json);
if (!k) {
failures++;
printf("FAIL unknown key '%s'\n", json);
return false;
}
char err[96] = {0};
cfg_value_t v = { .kind = CFG_VAL_NUM, .str = NULL, .num = num };
uint32_t scaled = 0;
if (!config_validate_value(k, &v, &scaled, err, sizeof(err))) {
failures++;
printf("FAIL %s=%g was rejected: %s\n", json, num, err);
return false;
}
if (out) *out = scaled;
return true;
}
static bool reject_num(const char *json, double num)
{
const cfg_key_t *k = config_find_key(json);
if (!k) {
failures++;
printf("FAIL unknown key '%s'\n", json);
return false;
}
char err[96] = {0};
cfg_value_t v = { .kind = CFG_VAL_NUM, .str = NULL, .num = num };
uint32_t scaled = 0;
if (config_validate_value(k, &v, &scaled, err, sizeof(err))) {
failures++;
printf("FAIL %s=%g was accepted but must be rejected\n", json, num);
return false;
}
return true;
}
/* ------------------------------------------------------------------ table --- */
/**
* The API table must carry the shared limits verbatim.
*
* This is the guard against the drift that made the API accept values the
* loader silently ignored.
*/
static void test_table_matches_shared_limits(void)
{
const cfg_key_t *k;
k = config_find_key("vital_window");
CHECK(k != NULL, "vital_window must be writable");
if (k) {
CHECK(k->lo == CFG_LIMIT_VITAL_WINDOW_MIN,
"vital_window min is %lu, expected %u",
(unsigned long)k->lo, CFG_LIMIT_VITAL_WINDOW_MIN);
CHECK(k->hi == CFG_LIMIT_VITAL_WINDOW_MAX,
"vital_window max is %lu, expected %u",
(unsigned long)k->hi, CFG_LIMIT_VITAL_WINDOW_MAX);
}
k = config_find_key("top_k_count");
CHECK(k != NULL, "top_k_count must be writable");
if (k) {
CHECK(k->lo == CFG_LIMIT_TOP_K_MIN,
"top_k_count min is %lu, expected %u",
(unsigned long)k->lo, CFG_LIMIT_TOP_K_MIN);
CHECK(k->hi == CFG_LIMIT_TOP_K_MAX,
"top_k_count max is %lu, expected %u",
(unsigned long)k->hi, CFG_LIMIT_TOP_K_MAX);
}
k = config_find_key("channel_hop_count");
CHECK(k != NULL, "channel_hop_count must be writable");
if (k) {
CHECK(k->lo == CFG_LIMIT_HOP_COUNT_MIN,
"channel_hop_count min is %lu, expected %u",
(unsigned long)k->lo, CFG_LIMIT_HOP_COUNT_MIN);
CHECK(k->hi == CFG_LIMIT_HOP_COUNT_MAX,
"channel_hop_count max is %lu, expected %u",
(unsigned long)k->hi, CFG_LIMIT_HOP_COUNT_MAX);
}
}
/* -------------------------------------------------------------- validation --- */
/**
* The boundaries the loader accepts.
*
* A value just outside the range must be rejected, so it never reaches NVS to
* be stored, reported as applied and then ignored at boot.
*/
static void test_range_boundaries(void)
{
/* vital_window: the loader accepts 32..256. */
accept_num("vital_window", 32, NULL);
accept_num("vital_window", 256, NULL);
reject_num("vital_window", 31);
reject_num("vital_window", 257);
reject_num("vital_window", 4096);
/* top_k_count: the loader accepts 1..32. */
accept_num("top_k_count", 1, NULL);
accept_num("top_k_count", 32, NULL);
reject_num("top_k_count", 0);
reject_num("top_k_count", 33);
reject_num("top_k_count", 64);
/* channel_hop_count: the channel list holds 6 entries. */
accept_num("channel_hop_count", 1, NULL);
accept_num("channel_hop_count", 6, NULL);
reject_num("channel_hop_count", 0);
reject_num("channel_hop_count", 7);
reject_num("channel_hop_count", 14);
}
/**
* A number that cannot be represented must be rejected, not truncated into a
* value that looks valid. The old code cast before it range-checked, so a
* huge number became an arbitrary integer.
*/
static void test_rejects_unrepresentable_numbers(void)
{
reject_num("node_id", 1e300);
reject_num("node_id", INFINITY);
reject_num("node_id", -INFINITY);
reject_num("node_id", NAN);
reject_num("vital_window", 1e300);
reject_num("top_k_count", 1e300);
}
/** A field of the wrong type is rejected. */
static void test_rejects_wrong_type(void)
{
const cfg_key_t *k = config_find_key("wifi_ssid");
CHECK(k != NULL, "wifi_ssid must be writable");
if (!k) return;
char err[96] = {0};
uint32_t scaled = 0;
cfg_value_t num = { .kind = CFG_VAL_NUM, .str = NULL, .num = 5 };
CHECK(!config_validate_value(k, &num, &scaled, err, sizeof(err)),
"a number must not be accepted for a string field");
cfg_value_t str = { .kind = CFG_VAL_STR, .str = "x", .num = 0 };
k = config_find_key("vital_window");
CHECK(!config_validate_value(k, &str, &scaled, err, sizeof(err)),
"a string must not be accepted for a numeric field");
}
/** A string longer than the field can hold is rejected. */
static void test_rejects_overlong_string(void)
{
const cfg_key_t *k = config_find_key("wifi_ssid");
CHECK(k != NULL, "wifi_ssid must be writable");
if (!k) return;
char long_ssid[64];
memset(long_ssid, 'a', sizeof(long_ssid) - 1);
long_ssid[sizeof(long_ssid) - 1] = '\0';
char err[96] = {0};
uint32_t scaled = 0;
cfg_value_t v = { .kind = CFG_VAL_STR, .str = long_ssid, .num = 0 };
CHECK(!config_validate_value(k, &v, &scaled, err, sizeof(err)),
"an over-long SSID must be rejected");
}
/** The scaled fields store the value multiplied by the fixed-point scale. */
static void test_scaled_fields(void)
{
uint32_t stored = 0;
/* presence_thresh is stored as value*1000. */
accept_num("presence_thresh", 1.5, &stored);
CHECK(stored == 1500, "presence_thresh 1.5 must store 1500, got %lu",
(unsigned long)stored);
/* 65.535 is the largest value that fits the u16 the loader reads. */
accept_num("presence_thresh", 65.535, &stored);
CHECK(stored == 65535, "presence_thresh 65.535 must store 65535, got %lu",
(unsigned long)stored);
reject_num("presence_thresh", 65.536);
reject_num("presence_thresh", 1000.0);
}
/* ------------------------------------------------------------------ gating --- */
/**
* A write is allowed only when no trial is pending and no change is in
* flight. The second case is the one that let a second request bank the
* first request's unproven settings.
*/
static void test_write_gating(void)
{
CHECK(config_write_allowed(false, false), "a quiet node must accept writes");
CHECK(!config_write_allowed(true, false),
"a pending trial must refuse writes");
CHECK(!config_write_allowed(false, true),
"a change in flight must refuse writes");
CHECK(!config_write_allowed(true, true),
"a pending trial and a change in flight must refuse writes");
}
/** The classification of the request-control fields and the live fields. */
static void test_field_classification(void)
{
CHECK(config_is_meta("reboot"), "reboot is a request-control field");
CHECK(config_is_meta("trial_seconds"), "trial_seconds is a request-control field");
CHECK(!config_is_meta("vital_window"), "vital_window is a parameter");
CHECK(config_is_live("led_mode"), "led_mode is applied immediately");
CHECK(config_is_live("led_brightness"), "led_brightness is applied immediately");
CHECK(!config_is_live("vital_window"), "vital_window is read at boot only");
/* The keys that can orphan a node are the ones that go through a trial. */
CHECK(config_find_key("wifi_ssid")->assoc, "wifi_ssid must require a trial");
CHECK(config_find_key("wifi_password")->assoc, "wifi_password must require a trial");
CHECK(!config_find_key("vital_window")->assoc,
"vital_window must not require a trial");
CHECK(!config_find_key("led_mode")->assoc, "led_mode must not require a trial");
}
int main(void)
{
test_table_matches_shared_limits();
test_range_boundaries();
test_rejects_unrepresentable_numbers();
test_rejects_wrong_type();
test_rejects_overlong_string();
test_scaled_fields();
test_write_gating();
test_field_classification();
if (failures) {
printf("config rules: %d check(s) FAILED\n", failures);
return 1;
}
printf("config rules: all checks passed\n");
return 0;
}
@@ -0,0 +1,39 @@
/* SYNTHETIC: execute the production DSP, including STBC frames. */
#define _USE_MATH_DEFINES
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#define TickType_t unsigned
#define portNUM_PROCESSORS 2
#include "../main/edge_processing.c"
#include <assert.h>
#include <stdio.h>
nvs_config_t g_nvs_config;
uint8_t csi_collector_get_node_id(void) { return 1; }
int main(void)
{
edge_config_t config = {.top_k_count = EDGE_TOP_K, .vital_interval_ms = 1};
assert(edge_processing_init(&config) == ESP_OK);
edge_ring_slot_t slot = {0};
process_frame(&slot);
assert(s_frame_count == 0);
/* Alternate ordinary HT20 and STBC shapes. Every complete frame must
* advance the production DSP clock; the oversized suffix is bounded. */
const uint16_t lengths[] = {256, 384, 256, 384, EDGE_MAX_IQ_BYTES};
for (unsigned i = 0; i < sizeof(lengths) / sizeof(lengths[0]); i++) {
slot.iq_len = lengths[i];
slot.timestamp_us = (i + 1) * 50000;
slot.rssi = -45;
memset(slot.iq_data, 1, sizeof(slot.iq_data));
process_frame(&slot);
assert(s_frame_count == i + 1);
unsigned bins = lengths[i] / 2;
if (bins > EDGE_MAX_SUBCARRIERS) bins = EDGE_MAX_SUBCARRIERS;
assert(s_subcarrier_var[bins - 1].count > 0);
assert(s_pkt_valid);
}
puts("PASS: production DSP accepts HT20/STBC and bounds oversized input");
return 0;
}
@@ -0,0 +1,70 @@
"""Execute the production console preparation across driver and IDF seams."""
import pathlib
import subprocess
import tempfile
import unittest
class OnboardingConsoleTests(unittest.TestCase):
def test_driver_lifecycle_and_legacy_boundary(self):
with tempfile.TemporaryDirectory() as directory:
root = pathlib.Path(directory)
(root / "driver").mkdir()
(root / "esp_err.h").write_text(
"#pragma once\ntypedef int esp_err_t;\n#define ESP_OK 0\n")
(root / "esp_idf_version.h").write_text(
"#pragma once\n#ifndef ESP_IDF_VERSION_MAJOR\n"
"#define ESP_IDF_VERSION_MAJOR 6\n#endif\n")
(root / "driver/usb_serial_jtag.h").write_text(
"#pragma once\n#include <stdbool.h>\n#include <stdint.h>\n"
"#include \"esp_err.h\"\n"
"typedef struct { uint32_t tx_buffer_size, rx_buffer_size; } usb_serial_jtag_driver_config_t;\n"
"#define USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT() (usb_serial_jtag_driver_config_t){256,256}\n"
"bool usb_serial_jtag_is_driver_installed(void);\n"
"esp_err_t usb_serial_jtag_driver_install(usb_serial_jtag_driver_config_t *config);\n")
(root / "driver/usb_serial_jtag_vfs.h").write_text(
"#pragma once\nvoid usb_serial_jtag_vfs_use_driver(void);\n")
(root / "probe.c").write_text(r'''
#include <assert.h>
#include <stdbool.h>
#include "onboarding_console.h"
#include "driver/usb_serial_jtag.h"
static bool installed;
static int install_result, installs, switches;
bool usb_serial_jtag_is_driver_installed(void) { return installed; }
esp_err_t usb_serial_jtag_driver_install(usb_serial_jtag_driver_config_t *config) {
assert(config->rx_buffer_size > 0 && config->tx_buffer_size > 0);
installs++;
if (install_result == 0) installed = true;
return install_result;
}
void usb_serial_jtag_vfs_use_driver(void) { assert(installed); switches++; }
int main(void) {
#if defined(CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG) && ESP_IDF_VERSION_MAJOR >= 6
assert(ruview_onboarding_console_prepare() == 0);
assert(installs == 1 && switches == 1);
assert(ruview_onboarding_console_prepare() == 0);
assert(installs == 1 && switches == 2);
installed = false;
install_result = -7;
assert(ruview_onboarding_console_prepare() == -7);
assert(installs == 2 && switches == 2);
#else
assert(ruview_onboarding_console_prepare() == 0);
assert(installs == 0 && switches == 0);
#endif
return 0;
}
''')
production = pathlib.Path(__file__).resolve().parents[1] / "main"
for defines in [[], ["-DCONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=1"],
["-DCONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=1", "-DESP_IDF_VERSION_MAJOR=5"]]:
binary = root / "probe"
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
*defines, "-I", str(root), "-I", str(production),
str(root / "probe.c"), "-o", str(binary)], check=True)
subprocess.run([str(binary)], check=True)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,54 @@
/* SYNTHETIC: exercise the production ping configuration with a fake AP. */
#include "esp_netif.h"
#include "ping/ping_sock.h"
#include <assert.h>
static esp_ping_config_t observed;
static unsigned sessions, starts;
static esp_netif_t *test_netif(const char *key)
{
assert(strcmp(key, "WIFI_STA_DEF") == 0);
return (esp_netif_t *)&observed;
}
static esp_err_t test_ip(esp_netif_t *netif, esp_netif_ip_info_t *ip)
{
assert(netif != NULL);
ip->gw.addr = 0x0101a8c0;
return ESP_OK;
}
static esp_err_t test_session(const esp_ping_config_t *config,
const esp_ping_callbacks_t *callbacks,
esp_ping_handle_t *handle)
{
assert(callbacks->on_ping_timeout != NULL);
observed = *config;
sessions++;
*handle = &observed;
return ESP_OK;
}
static esp_err_t test_start(esp_ping_handle_t handle)
{
assert(handle == &observed);
starts++;
return ESP_OK;
}
#define esp_netif_get_handle_from_ifkey test_netif
#define esp_netif_get_ip_info test_ip
#define esp_ping_new_session test_session
#define esp_ping_start test_start
#include "../main/csi_collector.c"
nvs_config_t g_nvs_config;
int main(void)
{
csi_start_self_ping();
assert(sessions == 1 && starts == 1);
assert(observed.interval_ms == CSI_SELF_PING_INTERVAL_MS);
assert(observed.timeout_ms == observed.interval_ms);
assert(observed.count == ESP_PING_COUNT_INFINITE);
csi_start_self_ping();
assert(sessions == 1 && starts == 1);
puts("PASS: production ping timeout is bounded to one interval");
return 0;
}
@@ -122,7 +122,16 @@ class TestStatePathSanitization(unittest.TestCase):
def test_windows_com_port_is_safe(self):
path = provision._state_path_for("COM7", "/tmp/x")
self.assertTrue(path.endswith("COM7.json"))
self.assertTrue(path.endswith("_COM7.json"))
def test_reserved_dos_device_names_are_prefixed(self):
for name in ["CON", "PRN", "AUX", "NUL", "COM1", "COM9", "LPT1"]:
path = provision._state_path_for(name, "/tmp/x")
self.assertEqual(os.path.basename(path), f"_{name}.json")
def test_non_reserved_ports_remain_unprefixed(self):
path = provision._state_path_for("/dev/ttyUSB0", "/tmp/x")
self.assertEqual(os.path.basename(path), "_dev_ttyUSB0.json")
if __name__ == "__main__":
+1 -1
View File
@@ -1 +1 @@
0.8.14
0.8.15
+27 -16
View File
@@ -21,6 +21,7 @@ Usage::
python scripts/check_fix_markers.py --list # list all markers
python scripts/check_fix_markers.py --json # machine-readable result
python scripts/check_fix_markers.py --only RuView#396 RuView#521
python scripts/check_fix_markers.py --manifest path/to/markers.json
Exit codes: 0 = all markers OK, 1 = one or more regressions, 2 = bad manifest.
"""
@@ -64,11 +65,12 @@ class ManifestError(Exception):
pass
def load_manifest() -> dict:
if not MANIFEST_PATH.exists():
raise ManifestError(f"manifest not found: {MANIFEST_PATH}")
def load_manifest(path: Path | None = None) -> dict:
target_path = path or MANIFEST_PATH
if not target_path.exists():
raise ManifestError(f"manifest not found: {target_path}")
try:
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
data = json.loads(target_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
raise ManifestError(f"manifest is not valid JSON: {e}") from e
if not isinstance(data, dict) or not isinstance(data.get("markers"), list):
@@ -82,12 +84,16 @@ def load_manifest() -> dict:
def _pattern_found(text: str, pattern: str) -> bool:
if len(pattern) >= 2 and pattern.startswith("/") and pattern.endswith("/"):
return re.search(pattern[1:-1], text, re.MULTILINE) is not None
try:
return re.search(pattern[1:-1], text, re.MULTILINE) is not None
except re.error as e:
raise ManifestError(f"invalid regex in pattern {pattern!r}: {e}") from e
return pattern in text
def check_marker(marker: dict) -> tuple[bool, list[str]]:
def check_marker(marker: dict, root: Path | None = None) -> tuple[bool, list[str]]:
"""Return (ok, problems) for a single marker."""
repo_root = root or REPO_ROOT
problems: list[str] = []
files = marker.get("files", [])
require = marker.get("require", [])
@@ -99,7 +105,7 @@ def check_marker(marker: dict) -> tuple[bool, list[str]]:
contents: dict[str, str] = {}
for rel in files:
p = REPO_ROOT / rel
p = repo_root / rel
if not p.exists():
problems.append(f"missing file: {rel}")
continue
@@ -109,13 +115,16 @@ def check_marker(marker: dict) -> tuple[bool, list[str]]:
problems.append(f"cannot read {rel}: {e}")
haystack = "\n".join(contents.values())
for pat in require:
if not _pattern_found(haystack, pat):
problems.append(f"required marker absent (fix likely reverted): {pat!r}")
for pat in forbid:
for rel, text in contents.items():
if _pattern_found(text, pat):
problems.append(f"forbidden pattern re-appeared in {rel} (bug re-introduced?): {pat!r}")
try:
for pat in require:
if not _pattern_found(haystack, pat):
problems.append(f"required marker absent (fix likely reverted): {pat!r}")
for pat in forbid:
for rel, text in contents.items():
if _pattern_found(text, pat):
problems.append(f"forbidden pattern re-appeared in {rel} (bug re-introduced?): {pat!r}")
except ManifestError as e:
problems.append(str(e))
return (len(problems) == 0), problems
@@ -136,10 +145,12 @@ def main(argv: list[str]) -> int:
ap.add_argument("--list", action="store_true", help="list all markers and exit")
ap.add_argument("--json", action="store_true", help="emit a JSON result object")
ap.add_argument("--only", nargs="+", metavar="ID", help="only check the given marker ids")
ap.add_argument("--manifest", type=Path, default=MANIFEST_PATH, help="path to fix-markers manifest (default: scripts/fix-markers.json)")
ap.add_argument("--root", type=Path, default=REPO_ROOT, help="path to repository root (default: repo root)")
args = ap.parse_args(argv)
try:
manifest = load_manifest()
manifest = load_manifest(args.manifest)
except ManifestError as e:
print(RED(f"[manifest error] {e}"), file=sys.stderr)
return 2
@@ -159,7 +170,7 @@ def main(argv: list[str]) -> int:
results = []
failed = 0
for m in markers:
ok, problems = check_marker(m)
ok, problems = check_marker(m, root=args.root)
results.append({"id": m["id"], "title": m.get("title", ""), "ok": ok, "problems": problems})
if not ok:
failed += 1
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Request CSI opportunities with bounded LAN unicast UDP (#2287).
Run on a wired LAN host when the AP sends ICMP at 802.11b rates. No response
service or DNS request is needed. AP rate selection still determines CSI yield.
"""
import argparse
import ipaddress
import socket
import time
def lan_address(value):
address = ipaddress.IPv4Address(value)
if not any(address in network for network in (
ipaddress.IPv4Network('10.0.0.0/8'),
ipaddress.IPv4Network('172.16.0.0/12'),
ipaddress.IPv4Network('192.168.0.0/16'),
)) or int(address) & 255 in (0, 255):
raise argparse.ArgumentTypeError('use a private LAN unicast IPv4 address')
return str(address)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('node', type=lan_address)
parser.add_argument('--port', type=int, default=5501)
parser.add_argument('--hz', type=int, choices=range(10, 51), default=50)
parser.add_argument('--seconds', type=int, default=60)
args = parser.parse_args()
if not 1024 <= args.port <= 65535:
parser.error('--port must be 1024..65535; use an unused node UDP port')
if not 1 <= args.seconds <= 3600:
parser.error('--seconds must be 1..3600')
count = 0
interval = 1 / args.hz
end = time.monotonic() + args.seconds
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sender:
while time.monotonic() < end:
started = time.monotonic()
sender.sendto(b'RUVIEW_CSI_POKE_V1', (args.node, args.port))
count += 1
time.sleep(max(0, interval - (time.monotonic() - started)))
print(f'Sent {count} UDP datagrams. Measure received CSI separately; '
'sent traffic does not establish CSI yield or sensing accuracy.')
if __name__ == '__main__':
main()
+2 -2
View File
@@ -139,7 +139,7 @@ def frame(w,h,title,desc,body):
motion=MOTION
for name in ['float','flow','breathe','trace','scan','orbit','spark','wave','sweep','limb','stride','layer','cursor','humanTurn','humanBody','armFar','armNear','legFar','legNear','humanLight','gaitShadow']:
if f'class="{name}"' not in body:
motion=re.sub(r'\.'+name+r'\{[^}]*\}', '', motion)
motion=re.sub(r'\.'+name+r'\{[^}]*\}\n?', '', motion)
for name in ['float','flow','breathe','trace','scan','spark','wave','sweep','limb','layer','cursor','humanTurn','humanBody','armSwing','legSwing','humanLight','gaitShadow']:
if f'animation:{name} ' not in motion:
marker='@keyframes '+name+'{'; start=motion.find(marker)
@@ -147,7 +147,7 @@ def frame(w,h,title,desc,body):
pos=start+len(marker); depth=1
while depth:
depth+=(motion[pos]=='{')-(motion[pos]=='}');pos+=1
motion=motion[:start]+motion[pos:]
motion=motion[:start]+motion[pos:].removeprefix('\n')
value=value[:value.index('<style>')]+motion+value[value.index('</style>')+8:]
palette={'#0b303a':'#102749','#06171e':'#071429','#030c13':'#03091a',
'#245360':'#2d4e77','#91f6df':'#9ee7ff','#26bad7':'#348bff',
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Unit tests for scripts/check_fix_markers.py."""
from __future__ import annotations
import io
import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from scripts.check_fix_markers import (
ManifestError,
_pattern_found,
check_marker,
load_manifest,
main,
)
class TestCheckFixMarkers(unittest.TestCase):
def setUp(self) -> None:
self.tmp_dir = tempfile.TemporaryDirectory()
self.root = Path(self.tmp_dir.name)
def tearDown(self) -> None:
self.tmp_dir.cleanup()
def test_load_manifest_valid(self) -> None:
manifest_path = self.root / "fix-markers.json"
data = {
"schema_version": 1,
"markers": [
{"id": "Test#1", "files": ["foo.txt"], "require": ["bar"]},
{"id": "Test#2", "files": ["baz.txt"], "require": ["qux"]},
],
}
manifest_path.write_text(json.dumps(data), encoding="utf-8")
loaded = load_manifest(manifest_path)
self.assertEqual(len(loaded["markers"]), 2)
self.assertEqual(loaded["markers"][0]["id"], "Test#1")
def test_load_manifest_not_found(self) -> None:
non_existent = self.root / "does_not_exist.json"
with self.assertRaises(ManifestError) as ctx:
load_manifest(non_existent)
self.assertIn("manifest not found", str(ctx.exception))
def test_load_manifest_invalid_json(self) -> None:
manifest_path = self.root / "bad.json"
manifest_path.write_text("not json content", encoding="utf-8")
with self.assertRaises(ManifestError) as ctx:
load_manifest(manifest_path)
self.assertIn("manifest is not valid JSON", str(ctx.exception))
def test_load_manifest_missing_markers(self) -> None:
manifest_path = self.root / "no_markers.json"
manifest_path.write_text(json.dumps({"schema_version": 1}), encoding="utf-8")
with self.assertRaises(ManifestError) as ctx:
load_manifest(manifest_path)
self.assertIn("must be an object with a 'markers' array", str(ctx.exception))
def test_load_manifest_duplicate_ids(self) -> None:
manifest_path = self.root / "dupes.json"
data = {
"markers": [
{"id": "Dup#1", "files": ["a.txt"]},
{"id": "Dup#1", "files": ["b.txt"]},
]
}
manifest_path.write_text(json.dumps(data), encoding="utf-8")
with self.assertRaises(ManifestError) as ctx:
load_manifest(manifest_path)
self.assertIn("duplicate marker ids", str(ctx.exception))
def test_pattern_found_literal(self) -> None:
self.assertTrue(_pattern_found("hello world", "world"))
self.assertFalse(_pattern_found("hello world", "earth"))
def test_pattern_found_regex(self) -> None:
self.assertTrue(_pattern_found("rate = 120.0", r"/rate\s*=\s*\d+\.\d+/"))
self.assertFalse(_pattern_found("rate = invalid", r"/rate\s*=\s*\d+\.\d+/"))
def test_pattern_found_invalid_regex(self) -> None:
with self.assertRaises(ManifestError) as ctx:
_pattern_found("some text", "/(unclosed paren/")
self.assertIn("invalid regex in pattern", str(ctx.exception))
def test_check_marker_pass(self) -> None:
target_file = self.root / "test.py"
target_file.write_text("def test_fix():\n return 'safe_value'\n", encoding="utf-8")
marker = {
"id": "Test#Pass",
"files": ["test.py"],
"require": ["safe_value"],
"forbid": ["unsafe_pattern"],
}
ok, problems = check_marker(marker, root=self.root)
self.assertTrue(ok)
self.assertEqual(problems, [])
def test_check_marker_missing_file(self) -> None:
marker = {
"id": "Test#MissingFile",
"files": ["non_existent.py"],
"require": ["anything"],
}
ok, problems = check_marker(marker, root=self.root)
self.assertFalse(ok)
self.assertTrue(any("missing file: non_existent.py" in p for p in problems))
def test_check_marker_empty_files(self) -> None:
marker = {"id": "Test#NoFiles", "files": []}
ok, problems = check_marker(marker, root=self.root)
self.assertFalse(ok)
self.assertIn("marker lists no files", problems)
def test_check_marker_missing_require(self) -> None:
target_file = self.root / "test.py"
target_file.write_text("def test_fix():\n pass\n", encoding="utf-8")
marker = {
"id": "Test#MissingReq",
"files": ["test.py"],
"require": ["required_constant"],
}
ok, problems = check_marker(marker, root=self.root)
self.assertFalse(ok)
self.assertTrue(any("required marker absent" in p for p in problems))
def test_check_marker_forbid_violation(self) -> None:
target_file = self.root / "test.py"
target_file.write_text("legacy_bug_reintroduced = True\n", encoding="utf-8")
marker = {
"id": "Test#ForbidViolated",
"files": ["test.py"],
"require": [],
"forbid": ["legacy_bug_reintroduced"],
}
ok, problems = check_marker(marker, root=self.root)
self.assertFalse(ok)
self.assertTrue(any("forbidden pattern re-appeared" in p for p in problems))
def test_main_cli_options(self) -> None:
sample_file = self.root / "code.txt"
sample_file.write_text("shipped_fix_verified", encoding="utf-8")
manifest_path = self.root / "manifest.json"
manifest_data = {
"schema_version": 1,
"markers": [
{
"id": "M1",
"title": "Marker One",
"files": ["code.txt"],
"require": ["shipped_fix_verified"],
},
{
"id": "M2",
"title": "Marker Two",
"files": ["code.txt"],
"require": ["missing_pattern"],
},
],
}
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
# --list
with patch("sys.stdout", new=io.StringIO()) as out:
code = main(["--manifest", str(manifest_path), "--list"])
self.assertEqual(code, 0)
self.assertIn("2 fix markers tracked", out.getvalue())
# --only M1 (should pass)
with patch("sys.stdout", new=io.StringIO()):
code = main(["--manifest", str(manifest_path), "--root", str(self.root), "--only", "M1"])
self.assertEqual(code, 0)
# --only M2 (should fail because missing_pattern is absent)
with patch("sys.stdout", new=io.StringIO()):
code = main(["--manifest", str(manifest_path), "--root", str(self.root), "--only", "M2"])
self.assertEqual(code, 1)
# --only unknown
with patch("sys.stderr", new=io.StringIO()):
code = main(["--manifest", str(manifest_path), "--only", "UnknownID"])
self.assertEqual(code, 2)
# --json flag with M1
with patch("sys.stdout", new=io.StringIO()) as out:
code = main(["--manifest", str(manifest_path), "--root", str(self.root), "--only", "M1", "--json"])
self.assertEqual(code, 0)
payload = json.loads(out.getvalue())
self.assertTrue(payload["ok"])
self.assertEqual(payload["checked"], 1)
self.assertEqual(payload["failed"], 0)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -15,7 +15,7 @@
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "node --experimental-vm-modules node_modules/.bin/jest --passWithNoTests",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit"
},
+44
View File
@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach, afterAll } from "@jest/globals";
import { loadConfig } from "../src/config.js";
describe("loadConfig()", () => {
const originalEnv = process.env;
beforeEach(() => {
process.env = { ...originalEnv };
});
afterAll(() => {
process.env = originalEnv;
});
it("loads default configuration when environment variables are unset", () => {
delete process.env["RUVIEW_SENSING_SERVER_URL"];
delete process.env["RUVIEW_API_TOKEN"];
delete process.env["RUVIEW_POSE_COG_BINARY"];
delete process.env["RUVIEW_COUNT_COG_BINARY"];
delete process.env["RUVIEW_JOBS_DIR"];
const config = loadConfig();
expect(config.sensingServerUrl).toBe("http://localhost:3000");
expect(config.apiToken).toBeUndefined();
expect(config.poseCogBinary).toBe("cog-pose-estimation");
expect(config.countCogBinary).toBe("cog-person-count");
expect(config.jobsDir).toContain(".ruview");
});
it("overrides default values with environment variables", () => {
process.env["RUVIEW_SENSING_SERVER_URL"] = "http://sensing.local:8080";
process.env["RUVIEW_API_TOKEN"] = "secret-token-123";
process.env["RUVIEW_POSE_COG_BINARY"] = "/opt/bin/pose";
process.env["RUVIEW_COUNT_COG_BINARY"] = "/opt/bin/count";
process.env["RUVIEW_JOBS_DIR"] = "/tmp/ruview-jobs";
const config = loadConfig();
expect(config.sensingServerUrl).toBe("http://sensing.local:8080");
expect(config.apiToken).toBe("secret-token-123");
expect(config.poseCogBinary).toBe("/opt/bin/pose");
expect(config.countCogBinary).toBe("/opt/bin/count");
expect(config.jobsDir).toBe("/tmp/ruview-jobs");
});
});
+3
View File
@@ -0,0 +1,3 @@
build/
__pycache__/
*.shm
+15
View File
@@ -0,0 +1,15 @@
CC ?= cc
CFLAGS ?= -O2 -std=c11 -Wall -Wextra -Werror
BUILD ?= build
.PHONY: all check test clean
all: $(BUILD)/w1700k-diagnostics $(BUILD)/w1700k-fixture
$(BUILD):
mkdir -p $@
$(BUILD)/w1700k-diagnostics: diagnostics.c vendor/w1700k_stream.h | $(BUILD)
$(CC) $(CFLAGS) $< -lm -o $@
$(BUILD)/w1700k-fixture: fixture.c vendor/w1700k_stream.h | $(BUILD)
$(CC) $(CFLAGS) $< -o $@
check test:
python3 test_bridge.py
clean:
rm -f $(BUILD)/w1700k-diagnostics $(BUILD)/w1700k-fixture
+117
View File
@@ -0,0 +1,117 @@
# W1700K RF diagnostics bridge
Status: **SYNTHETIC tested**, hardware validation pending. This read-only Linux
consumer produces advisory RF quality JSONL for RuView and RuField. It does not
extract CSI, classify presence, estimate pose, or output calibrated RF power.
## Build and use
Requires a C11 compiler, make, Python 3 for tests, and upstream w1700k-stream
for a live sensor. No network access or runtime dependencies.
```sh
make -C tools/w1700k
make -C tools/w1700k check
tools/w1700k/build/w1700k-diagnostics --once
tools/w1700k/build/w1700k-diagnostics --follow --timeout-seconds 60 --max-records 4096
```
The default input is /dev/shm/w1700k-stream. The input is never written and radio
controls are never changed. --source-kind defaults to live; synthetic and
captured_replay are explicit caller assertions, not cryptographic provenance.
Optional --center-frequency-hz accepts 2 through 8 GHz, but remains unverified.
Generate an ABI-complete **synthetic** fixture and compact diagnostics:
```sh
tools/w1700k/build/w1700k-fixture /tmp/w1700k-synthetic.shm normal
tools/w1700k/build/w1700k-diagnostics --input /tmp/w1700k-synthetic.shm --source-kind synthetic --once > /tmp/w1700k-synthetic.jsonl
```
Other fixture modes: gap, event, flat, tag, magic, ring, offset, overflow,
seqlock, truncated, stale, idle, run_overflow. Fixtures are test-only generators;
do not overwrite a running service's shared memory. Arbitrary ci16 recorder
outputs are not accepted: their missing event positions cannot recover timing.
## Evidence contract
One JSON object per slot, schema ruview.rf_diagnostics.v1. Fields:
| Field | Meaning |
|---|---|
| source_kind, source_id | Caller assertion live, synthetic or captured_replay; w1700k |
| observed_monotonic_ns | Local host CLOCK_MONOTONIC read time, never UTC or cross-host comparable |
| run, slot_index | Upstream run and DMA completion identity, not a sample timestamp |
| sample_start | Always null in v1; prior event history is not available |
| sample_rate_hz | Control-word claim, 0 unknown or 40/80/160/320 MHz |
| center_frequency_hz | Null or caller-supplied claim; not observed from hardware |
| config_verified, valid | Always false: ABI lacks actual LO, gain, band and configuration generation |
| invalid_reasons | configuration_unverified plus lost/event/overrun/stale/tag_mismatch/run_change |
| sample_count | 2048 for a stable sample slot, otherwise 0 |
| rms_dbfs | 10 log10(mean(I²+Q²)/8192²), null at zero power or no samples |
| rail_fraction | Fraction of int16 components on either storage rail, null without samples |
| missing_samples | 2048 for one LOST slot; 0 for EVENT and unknown overrun composition |
| event_count, overrun_slices | Per-record events or skipped overwritten completions |
| ttl_ms | 1000; receiving systems must start their own local expiry timer |
14-bit full scale is 8192. Filter headroom can legitimately exceed that value;
positive dBFS is not clipping. Rail fraction is an indicator, not proof of ADC
clipping. Complex RMS is normalized per complex sample, not per component.
Metrics are advisory even when numeric. Nothing here may promote human presence
or override RuField's missing calibration/configuration gates.
## Safety and operating limits
Header magic/version, fixed geometry, power-of-two bounded ring, aligned
nonoverlapping offsets, arithmetic overflow, exact file size and tag identity
ranges are checked. Position reads have a 64-attempt bound. Input is opened
read-only with no symlink following and must be a regular file (including SHM).
The bridge uses Linux pread snapshots and acquire fences, then tag/copy/tag
validation, rather than exposing mmap memory to untrusted concurrent truncation.
Short reads fail with exit 2 instead of SIGBUS. This is intended for the trusted
local upstream producer on a native ABI host, not an adversarial writer capable
of forging consistent tags. It does not claim that fences convert kernel copies
into cross-process C atomic loads; hardware qualification must validate this
snapshot strategy on target architectures.
Live mode rejects stale or future heartbeats with an invalid stale record and
exit 3. Captured replay and synthetic inputs deliberately do not compare saved
heartbeats with the current host. A producer restart may leave the old inode
open; heartbeat expiry requires restart/reopen of this consumer. No automatic
reconnect or permission expansion.
--once takes the newest available bounded tail (64 records by default).
--follow starts at that same tail and follows until timeout, record limit,
interrupt or failure. Polling is 10 ms. This is a bounded diagnostic sampler,
not a promise of lossless full-bandwidth capture. Detected overruns are invalid
records; their unknown mix of samples/events is never converted to invented
missing sample time. Existing backlog intentionally outside the initial tail is
not treated as a newly observed overrun. Hardware throughput is unmeasured.
No raw IQ is exported. JSONL contains no decoded payloads, identifiers, MAC
addresses or presence claims. Do not commit live recordings. Shipping evidence
must distinguish fixtures from real device captures.
## Cross-repository acceptance
Build the RuField adapter from the companion ruvnet/rufield checkout:
```sh
cd ../rufield
cargo build -p rufield-adapters --bin rf-diagnostics-replay
cd ../RuView
python3 tools/w1700k/verify_integration.py --rufield-bin ../rufield/target/debug/rf-diagnostics-replay
```
This checks six synthetic ABI scenarios through the C bridge, browser validator and RuField adapter. It verifies gap/event semantics and that diagnostic evidence remains unsigned, synthetic and zero-confidence. It does not establish real receiver performance.
For interactive inspection, open [the local viewer](../../docs/rf-diagnostics/index.html) or [the hosted viewer](https://ruvnet.github.io/RuView/rf-diagnostics/) and import the bridge JSONL. Files remain in your browser. An imported live-labelled file is replay, not a live connection.
Convert diagnostic JSONL to advisory FieldEvents:
```sh
../rufield/target/debug/rf-diagnostics-replay < diagnostics.jsonl > advisory-events.jsonl
```
These events cannot contribute spatial/occupancy fusion. Automatic suppression of other sensors needs a future association and timing policy. CSI extraction, spectrum bins and hardware accuracy benchmarks remain future work.
+10
View File
@@ -0,0 +1,10 @@
# Third-party notice
vendor/w1700k_stream.h is copied unchanged from:
https://github.com/h0m3us3r/w1700k-sdr
commit 0b0413e4309f0c083702fa4eb2d1c2bca4532163
path host/stream/include/w1700k_stream.h
It is explicitly SPDX-License-Identifier: MIT. Its MIT license text is retained
in vendor/LICENSE.MIT. No GPL recorder implementation was copied. Bridge and
fixture code are original integration code licensed MIT.
+176
View File
@@ -0,0 +1,176 @@
/* SPDX-License-Identifier: MIT
* RuView advisory W1700K diagnostics; no CSI or presence inference.
* Local trusted producer only. pread avoids SIGBUS on concurrent truncation.
*/
#define _POSIX_C_SOURCE 200809L
#include "vendor/w1700k_stream.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include <limits.h>
#include <inttypes.h>
#include <stdatomic.h>
#include <signal.h>
#define MAX_RING 1048576ULL
#define MAX_RECORDS 4096
static volatile sig_atomic_t stop;
static void stopped(int sig) { (void)sig; stop=1; }
static void die(const char *s) { fprintf(stderr,"w1700k-diagnostics: %s\n",s); exit(2); }
static void rd(int fd, void *p, size_t n, uint64_t off) {
size_t got=0;
while(got<n) {
ssize_t k=pread(fd,(char*)p+got,n-got,(off_t)(off+got));
if(k<0 && errno==EINTR) continue;
if(k<=0) die("short read or input changed");
got+=(size_t)k;
}
atomic_thread_fence(memory_order_acquire);
}
static uint64_t u64(int fd, uint64_t off) { uint64_t x; rd(fd,&x,8,off); return x; }
static uint64_t number(const char *s, uint64_t max) {
if(!s[0]) die("empty numeric argument");
for(const char *p=s;*p;p++) if(*p<'0'||*p>'9') die("invalid numeric argument");
errno=0; char *end; unsigned long long n=strtoull(s,&end,10);
if(errno || *end || n>max) die("numeric argument out of range");
return n;
}
static void header(int fd, struct w1700k_stream_header *h) {
struct stat st;
if(fstat(fd,&st) || !S_ISREG(st.st_mode)) die("input must be regular file or POSIX SHM");
rd(fd,h,sizeof(*h),0);
if(h->magic!=W1700K_STREAM_MAGIC || h->version!=1 ||
h->header_bytes!=4096 || h->slice_samples!=2048) die("unsupported ABI header");
uint64_t r=h->ring_slices;
if(!r || r>MAX_RING || (r&(r-1))) die("invalid ring size");
if(h->tags_offset<4096 || h->tags_offset%8 ||
h->tags_offset>UINT64_MAX-r*8 ||
h->samples_offset<h->tags_offset+r*8 || h->samples_offset%8 ||
h->samples_offset>UINT64_MAX-r*8192 ||
h->total_bytes!=h->samples_offset+r*8192 ||
h->total_bytes>(uint64_t)INT64_MAX || st.st_size<0 ||
h->total_bytes!=(uint64_t)st.st_size) die("invalid layout or truncated file");
}
static void position(int fd,uint64_t *run,uint64_t *newest) {
for(int i=0;i<64;i++) {
uint64_t s=u64(fd,offsetof(struct w1700k_stream_header,position_seq));
*run=u64(fd,offsetof(struct w1700k_stream_header,run));
*newest=u64(fd,offsetof(struct w1700k_stream_header,newest));
uint64_t t=u64(fd,offsetof(struct w1700k_stream_header,position_seq));
if(!(s&1) && s==t) {
if((!*run && *newest) || *run>=(1ULL<<21) || *newest>=(1ULL<<40)) die("tag identity overflow");
return;
}
}
die("position seqlock did not stabilize");
}
static int stale(int fd) {
uint64_t hb=u64(fd,offsetof(struct w1700k_stream_header,heartbeat_ns));
uint64_t now=w1700k_stream_now_ns();
return hb>now || now-hb>=1000000000ULL;
}
static void emit(const char *source,uint64_t run,uint64_t index,uint64_t rate,
uint64_t center,const char *reason,uint64_t count,double power,
double rail,uint64_t missing,uint64_t events,uint64_t overrun) {
printf("{\"schema\":\"ruview.rf_diagnostics.v1\",\"source_kind\":\"%s\","
"\"source_id\":\"w1700k\",\"observed_monotonic_ns\":%" PRIu64 ","
"\"run\":%" PRIu64 ",\"slot_index\":%" PRIu64 ",\"sample_start\":null,"
"\"sample_rate_hz\":%" PRIu64 ",\"center_frequency_hz\":",
source,w1700k_stream_now_ns(),run,index,rate);
if(center) printf("%" PRIu64,center); else printf("null");
printf(",\"config_verified\":false,\"valid\":false,"
"\"invalid_reasons\":[\"configuration_unverified\"");
if(reason) printf(",\"%s\"",reason);
printf("],\"sample_count\":%" PRIu64 ",\"rms_dbfs\":",count);
if(count && power>0) printf("%.6f",10*log10(power)); else printf("null");
printf(",\"rail_fraction\":");
if(count) printf("%.8f",rail); else printf("null");
printf(",\"missing_samples\":%" PRIu64 ",\"event_count\":%" PRIu64 ","
"\"overrun_slices\":%" PRIu64 ",\"ttl_ms\":1000}\n",missing,events,overrun);
if(fflush(stdout) || ferror(stdout)) die("output failed");
}
int main(int argc,char **argv) {
const char *path="/dev/shm/w1700k-stream",*source="live";
uint64_t center=0,limit=64,timeout=10; int follow=0;
for(int i=1;i<argc;i++) {
if(!strcmp(argv[i],"--once")) { follow=0; continue; }
if(!strcmp(argv[i],"--follow")) { follow=1; continue; }
if(!strcmp(argv[i],"--help")) {
puts("w1700k-diagnostics [--input PATH] [--source-kind live|synthetic|captured_replay] [--once|--follow] [--max-records 1..4096] [--timeout-seconds 1..3600] [--center-frequency-hz HZ]\nRead-only advisory diagnostics. Configuration is always unverified. Source kind is caller asserted. No CSI, presence or spectrum bins.");
return 0;
}
if(i+1>=argc) die("missing option value");
const char *key=argv[i],*value=argv[++i];
if(!strcmp(key,"--input")) path=value;
else if(!strcmp(key,"--source-kind")) source=value;
else if(!strcmp(key,"--center-frequency-hz")) center=number(value,100000000000ULL);
else if(!strcmp(key,"--max-records")) limit=number(value,MAX_RECORDS);
else if(!strcmp(key,"--timeout-seconds")) timeout=number(value,3600);
else die("unknown option");
}
if(strcmp(source,"live") && strcmp(source,"synthetic") && strcmp(source,"captured_replay"))
die("invalid source kind");
if(!limit || !timeout) die("zero limit");
if(center && (center<2000000000ULL || center>8000000000ULL)) die("center frequency outside RF bands");
int fd=open(path,O_RDONLY|O_CLOEXEC|O_NOFOLLOW|O_NONBLOCK);
if(fd<0) die("cannot open input");
struct w1700k_stream_header h; header(fd,&h);
uint64_t run=0,next=0,newest=0,emitted=0;
position(fd,&run,&newest);
uint64_t backlog=newest<h.ring_slices?newest:h.ring_slices;
if(backlog>limit) backlog=limit;
next=newest-backlog;
uint64_t deadline=w1700k_stream_now_ns()+timeout*1000000000ULL;
signal(SIGINT,stopped); signal(SIGTERM,stopped);
while(!stop && emitted<limit && w1700k_stream_now_ns()<deadline) {
struct w1700k_stream_header check; header(fd,&check);
if(memcmp(&h,&check,offsetof(struct w1700k_stream_header,service_pid)))
die("layout changed");
uint64_t current; position(fd,&current,&newest);
uint64_t rate=u64(fd,offsetof(struct w1700k_stream_header,radio_sample_rate));
if(rate && rate!=40000000 && rate!=80000000 && rate!=160000000 && rate!=320000000) die("unsupported sample rate");
if(!strcmp(source,"live") && stale(fd)) {
emit(source,current,next,rate,center,"stale",0,0,0,0,0,0); close(fd); return 3;
}
if(current!=run) {
emit(source,current,0,rate,center,"run_change",0,0,0,0,0,0);
emitted++; run=current; next=newest; continue;
}
if(newest<next) die("frontier moved backward");
if(newest-next>h.ring_slices) {
uint64_t lost=newest-next-h.ring_slices;
emit(source,run,next,rate,center,"overrun",0,0,0,0,0,lost);
emitted++; next=newest-h.ring_slices; continue;
}
if(next==newest) {
if(!follow) break;
struct timespec pause={0,10000000}; nanosleep(&pause,NULL); continue;
}
uint64_t slot=next&(h.ring_slices-1),tag=u64(fd,h.tags_offset+slot*8);
uint64_t expected=w1700k_stream_tag(run,next),count=0,missing=0,events=0;
double power=0,rail=0; const char *reason=NULL;
if((tag&~W1700K_STREAM_TAG_KIND)!=expected) reason="tag_mismatch";
else if((tag&W1700K_STREAM_TAG_KIND) && ((tag&W1700K_STREAM_TAG_KIND)&((tag&W1700K_STREAM_TAG_KIND)-1))) reason="tag_mismatch";
else if(tag&W1700K_STREAM_TAG_EVENT) { reason="event"; events=1; }
else if(tag&W1700K_STREAM_TAG_LOST) { reason="lost"; missing=2048; }
else {
int16_t iq[4096]; rd(fd,iq,sizeof(iq),h.samples_offset+slot*8192);
uint64_t after=u64(fd,h.tags_offset+slot*8);
if(tag!=after) reason="tag_mismatch";
else {
count=2048;
for(int j=0;j<4096;j++) {
double x=(double)iq[j]/8192.0; power+=x*x;
if(iq[j]==INT16_MIN || iq[j]==INT16_MAX) rail++;
}
power/=2048; rail/=4096;
}
}
if(tag!=u64(fd,h.tags_offset+slot*8)) {
reason="tag_mismatch"; count=0; missing=0; events=0;
}
emit(source,run,next,rate,center,reason,count,power,rail,missing,events,0);
next++; emitted++;
}
close(fd); return 0;
}
+43
View File
@@ -0,0 +1,43 @@
/* SPDX-License-Identifier: MIT */
/* SYNTHETIC ABI fixture generator, not hardware evidence. */
#define _POSIX_C_SOURCE 200809L
#include "vendor/w1700k_stream.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc,char **argv) {
if(argc!=3) return 2;
FILE *f=fopen(argv[1],"wb"); if(!f) return 2;
struct w1700k_stream_header h={0};
h.magic=W1700K_STREAM_MAGIC; h.version=1; h.header_bytes=4096; h.slice_samples=2048;
h.ring_slices=8; h.tags_offset=4096; h.samples_offset=4160; h.total_bytes=69700-4;
h.run=1; h.newest=4; h.radio_sample_rate=40000000; h.heartbeat_ns=w1700k_stream_now_ns();
const char *kind=argv[2];
if(!strcmp(kind,"magic")) h.magic=0;
if(!strcmp(kind,"ring")) h.ring_slices=3;
if(!strcmp(kind,"offset")) h.samples_offset=8;
if(!strcmp(kind,"overflow")) h.tags_offset=UINT64_MAX-7;
if(!strcmp(kind,"seqlock")) h.position_seq=1;
if(!strcmp(kind,"stale")) h.heartbeat_ns=1;
if(!strcmp(kind,"idle")) h.newest=0;
if(!strcmp(kind,"run_overflow")) h.run=1ULL<<21;
char *data=calloc(1,69696); if(!data) return 2;
memcpy(data,&h,sizeof(h));
uint64_t *tags=(uint64_t*)(data+4096);
for(int i=0;i<4;i++) {
tags[i]=w1700k_stream_tag(1,i);
int16_t *iq=(int16_t*)(data+4160+i*8192);
for(int j=0;j<2048;j++) { iq[j*2]=4096; iq[j*2+1]=0; }
}
if(!strcmp(kind,"gap")) tags[1]|=W1700K_STREAM_TAG_LOST;
if(!strcmp(kind,"event")) tags[1]|=W1700K_STREAM_TAG_EVENT;
if(!strcmp(kind,"badkind")) tags[1]|=W1700K_STREAM_TAG_EVENT|W1700K_STREAM_TAG_LOST;
if(!strcmp(kind,"tag")) tags[1]=0;
if(!strcmp(kind,"flat")) {
tags[1]|=W1700K_STREAM_TAG_FLAT;
int16_t *iq=(int16_t*)(data+4160+8192);
for(int j=0;j<4096;j++) iq[j]=16384;
}
size_t size=!strcmp(kind,"truncated")?4096:69696;
int ok=fwrite(data,1,size,f)==size; free(data); fclose(f); return ok?0:2;
}
+122
View File
@@ -0,0 +1,122 @@
"""SYNTHETIC ABI and failure-path tests; no physical sensor claims."""
import json
import math
import os
from pathlib import Path
import struct
import subprocess
import tempfile
import time
import unittest
HERE = Path(__file__).resolve().parent
class BridgeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.TemporaryDirectory()
cls.bin = Path(cls.tmp.name) / "diagnostics"
cls.fixture_bin = Path(cls.tmp.name) / "fixture"
for source, target in [("diagnostics.c", cls.bin), ("fixture.c", cls.fixture_bin)]:
subprocess.run(["cc", "-std=c11", "-Wall", "-Wextra", "-Werror",
"-O2", str(HERE/source), "-lm", "-o", str(target)], check=True)
@classmethod
def tearDownClass(cls):
cls.tmp.cleanup()
def fixture(self, kind="normal"):
path = Path(self.tmp.name) / (kind + ".shm")
subprocess.run([str(self.fixture_bin), str(path), kind], check=True)
return path
def invoke(self, kind="normal", *args):
return subprocess.run([str(self.bin), "--input", str(self.fixture(kind)),
"--source-kind", "synthetic", *args],
capture_output=True, text=True, timeout=4)
def test_normal(self):
p = self.invoke()
self.assertEqual(p.returncode, 0, p.stderr)
rows = [json.loads(x) for x in p.stdout.splitlines()]
self.assertEqual(len(rows), 4)
for x in rows:
self.assertEqual(x["sample_count"], 2048)
self.assertAlmostEqual(x["rms_dbfs"], 20*math.log10(.5), places=5)
self.assertEqual(x["rail_fraction"], 0)
self.assertFalse(x["valid"])
self.assertFalse(x["config_verified"])
self.assertIsNone(x["sample_start"])
self.assertEqual(x["source_kind"], "synthetic")
def test_invalid_headers(self):
for kind in ["magic", "ring", "offset", "overflow", "seqlock", "truncated", "run_overflow"]:
with self.subTest(kind=kind):
p = self.invoke(kind)
self.assertEqual(p.returncode, 2)
self.assertEqual(p.stdout, "")
def test_gap_event_and_tag(self):
for kind, reason in [("gap", "lost"), ("event", "event"), ("tag", "tag_mismatch"), ("badkind", "tag_mismatch")]:
p = self.invoke(kind)
rows = list(map(json.loads, p.stdout.splitlines()))
self.assertIn(reason, rows[1]["invalid_reasons"])
self.assertEqual(rows[1]["sample_count"], 0)
self.assertIsNone(rows[1]["rms_dbfs"])
self.assertEqual(rows[1]["missing_samples"], 2048 if kind == "gap" else 0)
def test_flat_headroom_not_clipping(self):
rows = list(map(json.loads, self.invoke("flat").stdout.splitlines()))
self.assertEqual(rows[1]["rail_fraction"], 0)
self.assertGreater(rows[1]["rms_dbfs"], 0)
def test_live_stale(self):
p = self.invoke("stale", "--source-kind", "live")
self.assertEqual(p.returncode, 3)
self.assertIn("stale", json.loads(p.stdout)["invalid_reasons"])
def test_bounded_tail(self):
rows = list(map(json.loads, self.invoke("normal", "--max-records", "2").stdout.splitlines()))
self.assertEqual([r["slot_index"] for r in rows], [2, 3])
def test_idle_timeout(self):
start = time.monotonic()
p = self.invoke("idle", "--follow", "--timeout-seconds", "1")
self.assertEqual(p.returncode, 0)
self.assertEqual(p.stdout, "")
self.assertLess(time.monotonic()-start, 2)
def test_concurrent_truncate(self):
path = self.fixture("idle")
p = subprocess.Popen([str(self.bin), "--input", str(path), "--source-kind", "synthetic",
"--follow", "--timeout-seconds", "2"], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
time.sleep(.05)
with path.open("wb"):
pass
p.communicate(timeout=3)
self.assertEqual(p.returncode, 2) # no SIGBUS
def test_overrun(self):
path = self.fixture("idle")
p = subprocess.Popen([str(self.bin), "--input", str(path), "--source-kind", "synthetic",
"--follow", "--max-records", "1", "--timeout-seconds", "2"],
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
time.sleep(.05)
with path.open("r+b", buffering=0) as f:
f.seek(128); f.write(struct.pack("=Q", 1))
f.seek(144); f.write(struct.pack("=Q", 20))
f.seek(128); f.write(struct.pack("=Q", 2))
out, err = p.communicate(timeout=3)
self.assertEqual(p.returncode, 0, err)
row = json.loads(out)
self.assertIn("overrun", row["invalid_reasons"])
self.assertEqual(row["overrun_slices"], 12)
self.assertEqual(row["missing_samples"], 0) # event slots have no sample time
def test_symlink_rejected(self):
link = Path(self.tmp.name)/"symlink"
if link.exists(): link.unlink()
link.symlink_to(self.fixture())
p = subprocess.run([str(self.bin), "--input", str(link)], capture_output=True)
self.assertEqual(p.returncode, 2)
def test_wide_center(self):
p = self.invoke("normal", "--center-frequency-hz", "7576000000")
self.assertEqual(p.returncode, 0, p.stderr)
row = json.loads(p.stdout.splitlines()[0])
self.assertEqual(row["center_frequency_hz"], 7576000000)
self.assertFalse(row["config_verified"])
def test_bad_args(self):
for args in [["--source-kind","madeup"], ["--max-records","0"], ["--max-records","999999"],
["--timeout-seconds","-1"], ["--center-frequency-hz","nan"]]:
p = self.invoke("normal", *args)
self.assertEqual(p.returncode, 2)
if __name__ == "__main__":
unittest.main(verbosity=2)
+30
View File
@@ -0,0 +1,30 @@
Valid-License-Identifier: MIT
SPDX-URL: https://spdx.org/licenses/MIT.html
Usage-Guide:
To use the MIT License put the following SPDX tag/value pair into a
comment according to the placement guidelines in the licensing rules
documentation:
SPDX-License-Identifier: MIT
License-Text:
MIT License
Copyright (c) <year> <copyright holders>
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+176
View File
@@ -0,0 +1,176 @@
/* SPDX-License-Identifier: MIT */
/*
* The W1700K's IQ stream as every consumer on the host reads it: the shared memory of
* w1700k-stream (host/stream). One POSIX shared-memory object, by default /dev/shm/w1700k-stream,
* mapped by the service and by each consumer. Native byte order and alignment; every word shared
* between processes is read and written with the __atomic builtins, from C and C++ alike.
*
* Layout: the header (4 KiB), then one 64-bit tag per slot, then the samples: per slot one slice
* of W1700K_STREAM_SLICE complex samples, int16 I then int16 Q, on the scale of the radio's 14-bit
* samples (full scale W1700K_STREAM_FULL_SCALE). The int16 range above it is headroom for the
* correction filter's gain; a sample beyond it saturates and is counted.
*
* Slot `index & (ring_slices - 1)` holds slice `index` of run `run` (a run is one stream, numbered
* from 1; `index` counts the unit's DMA completions from the run's first, 0..). Its tag is
* w1700k_stream_tag(run, index), with W1700K_STREAM_TAG_EVENT for a firmware event (no samples and
* no sample time: the slices either side are contiguous), W1700K_STREAM_TAG_LOST for a completion
* that never arrived (no samples; the sample time is lost), and W1700K_STREAM_TAG_FLAT on a slice
* the correction filter produced. A reader reads the tag (acquire), the samples, then the tag
* again: a changed tag means the service overwrote the slot meanwhile (a seqlock).
*
* The service publishes slots in index order. `newest` is the frontier: every slot of the run
* below it is final. Read run and newest together with w1700k_stream_position().
*/
#ifndef W1700K_STREAM_H
#define W1700K_STREAM_H
#include <errno.h>
#include <fcntl.h>
#include <stddef.h>
#include <stdint.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
#define W1700K_STREAM_NAME "/w1700k-stream"
#define W1700K_STREAM_MAGIC 0x4d525453u /* "STRM" */
#define W1700K_STREAM_VERSION 1u
#define W1700K_STREAM_SLICE 2048u /* complex samples per slot */
#define W1700K_STREAM_FULL_SCALE 8192 /* the radio's 14-bit full scale */
#define W1700K_STREAM_HEADER_BYTES 4096u
#define W1700K_STREAM_TAG_EVENT (1ull << 63)
#define W1700K_STREAM_TAG_LOST (1ull << 62)
#define W1700K_STREAM_TAG_FLAT (1ull << 61)
#define W1700K_STREAM_TAG_KIND (W1700K_STREAM_TAG_EVENT | W1700K_STREAM_TAG_LOST | W1700K_STREAM_TAG_FLAT)
struct w1700k_stream_header {
/* layout, fixed at creation */
uint32_t magic, version;
uint32_t header_bytes, slice_samples;
uint64_t ring_slices; /* a power of two */
uint64_t tags_offset, samples_offset, total_bytes;
uint64_t service_pid;
uint64_t heartbeat_ns; /* CLOCK_MONOTONIC, refreshed about every 100 ms */
uint64_t reserved0[8];
/* the published position */
uint64_t position_seq; /* odd while the service writes run and newest */
uint64_t run; /* 1..; 0: no stream yet */
uint64_t newest; /* slots of the run below this are final */
uint64_t run_started_ns, last_any_ns; /* CLOCK_MONOTONIC */
uint64_t received; /* the newest completion received + 1 (ahead of newest) */
uint64_t reserved1[10];
/* counters since the service started */
uint64_t datagrams, bytes;
uint64_t slices, packed, events, lost; /* completions filed: samples (packed among them), events, lost */
uint64_t pieces, bad, late, before_start, duplicates, socket_drops;
/* the export's integrity: raw slices with spare bits set (words), and slices or 32-byte lines
* equal to the same slice or line one lap (128 completions) earlier: a capture or packer
* that repeated old data */
uint64_t spare_words, stale_slices, stale_lines;
uint64_t flattened, saturated; /* slices the correction produced; samples clipped to int16 */
uint64_t overruns; /* of the lost: arrived, but overwritten in the service's receive
* ring before it was published (the service fell behind) */
uint64_t reserved2[12];
/* control: written by consumers, read by the service */
uint64_t restart_requests; /* +1: the next completion starts a new run */
uint64_t flatten; /* 1: correct the decimation's roll-off where it applies */
uint64_t radio_sample_rate; /* samples/s; 0: unknown */
uint64_t radio_capture_rate; /* samples/s before the stream (640/2: 640e6); 0: unknown */
uint64_t radio_filter_hz; /* the analog filter's width; 0: unknown */
uint64_t reserved3[11];
/* the service's view of the control */
uint64_t flatten_active; /* the correction applies to the slices being published */
uint64_t flatten_rate; /* the sample rate whose correction applies; 0: none */
uint64_t reserved4[14];
};
#define w1700k_stream_load(p) __atomic_load_n((p), __ATOMIC_ACQUIRE)
#define w1700k_stream_store(p, v) __atomic_store_n((p), (v), __ATOMIC_RELEASE)
static inline uint64_t w1700k_stream_tag(uint64_t run, uint64_t index)
{
return (run << 40) | (index + 1);
}
static inline uint64_t *w1700k_stream_tags(const struct w1700k_stream_header *h)
{
return (uint64_t *)((char *)h + h->tags_offset);
}
static inline int16_t *w1700k_stream_slice(const struct w1700k_stream_header *h, uint64_t index)
{
return (int16_t *)((char *)h + h->samples_offset) +
(size_t)(index & (h->ring_slices - 1)) * W1700K_STREAM_SLICE * 2;
}
/* run and newest, read consistently. */
static inline void w1700k_stream_position(const struct w1700k_stream_header *h, uint64_t *run, uint64_t *newest)
{
uint64_t s0, s1;
do {
s0 = w1700k_stream_load(&h->position_seq);
*run = w1700k_stream_load(&h->run);
*newest = w1700k_stream_load(&h->newest);
s1 = w1700k_stream_load(&h->position_seq);
} while ((s0 & 1) || s0 != s1);
}
static inline uint64_t w1700k_stream_now_ns(void)
{
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t);
return (uint64_t)t.tv_sec * 1000000000ull + (uint64_t)t.tv_nsec;
}
/* The service is up: it refreshed its heartbeat within the last second. */
static inline int w1700k_stream_alive(const struct w1700k_stream_header *h)
{
return w1700k_stream_now_ns() - w1700k_stream_load(&h->heartbeat_ns) < 1000000000ull;
}
/*
* Maps the service's shared memory (name NULL: W1700K_STREAM_NAME); `writable` for a consumer that
* writes the control words. Returns NULL with errno set: ENOENT while no service has created it,
* EPROTO for another layout. *bytes receives the mapping's length (for munmap), *inode the
* object's inode (a restarted service creates a new one).
*/
static inline struct w1700k_stream_header *w1700k_stream_open(const char *name, int writable, size_t *bytes,
uint64_t *inode)
{
struct w1700k_stream_header *h;
struct stat st;
int fd = shm_open(name ? name : W1700K_STREAM_NAME, writable ? O_RDWR : O_RDONLY, 0);
if (fd < 0)
return NULL;
if (fstat(fd, &st) || (size_t)st.st_size < W1700K_STREAM_HEADER_BYTES) {
close(fd);
errno = EPROTO;
return NULL;
}
h = (struct w1700k_stream_header *)mmap(NULL, (size_t)st.st_size,
writable ? PROT_READ | PROT_WRITE : PROT_READ, MAP_SHARED, fd, 0);
close(fd);
if (h == MAP_FAILED)
return NULL;
if (h->magic != W1700K_STREAM_MAGIC || h->version != W1700K_STREAM_VERSION ||
h->slice_samples != W1700K_STREAM_SLICE || h->total_bytes != (uint64_t)st.st_size) {
munmap(h, (size_t)st.st_size);
errno = EPROTO;
return NULL;
}
*bytes = (size_t)st.st_size;
if (inode)
*inode = (uint64_t)st.st_ino;
return h;
}
#endif /* W1700K_STREAM_H */
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more