merge: integrate reviewed release changes and dependency remediation

This commit is contained in:
ruv committed 2026-10-21 13:18:14 -04:00
commit da177d60f9
81 files changed
+2877 -455

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 \
+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.
+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}}
+87
View File
@@ -530,6 +530,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())
+10 -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" "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"
@@ -64,6 +64,15 @@ if(IDF_VERSION_MAJOR GREATER_EQUAL 6)
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
if(CONFIG_CSI_MOCK_ENABLED)
list(APPEND SRCS "mock_csi.c" "rv_radio_ops_mock.c")
+626
View File
@@ -0,0 +1,626 @@
/**
* @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 "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
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
* -- 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;
static const cfg_key_t 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, 1, 14, 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, 8, 4096, 0, 0, false },
{ "vital_interval_ms", "vital_int", T_U16, 100, 60000, 0, 0, false },
{ "top_k_count", "subk_count", T_U8, 1, 64, 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 },
};
#define NKEYS (sizeof(KEYS) / sizeof(KEYS[0]))
/* Body fields that control the request rather than naming a stored parameter.
* They are not in KEYS, so every pass over the body must skip them. */
/**
* Parameters the running firmware re-reads every cycle, so a change takes
* effect with no restart. Everything else is consumed once during init, which
* is why a plain config push reboots by default.
*/
static bool is_live(const char *name)
{
return strcmp(name, "led_mode") == 0 ||
strcmp(name, "led_brightness") == 0;
}
static bool is_meta(const char *name)
{
return strcmp(name, "trial_seconds") == 0 ||
strcmp(name, "reboot") == 0;
}
static esp_timer_handle_t s_revert_timer = NULL;
static bool s_trial_armed = false;
static const cfg_key_t *find_key(const char *json)
{
for (size_t i = 0; i < NKEYS; i++) {
if (strcmp(KEYS[i].json, json) == 0) return &KEYS[i];
}
return NULL;
}
/* ---------------------------------------------------------------- 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)
{
if (k->type == T_STR) {
char buf[96];
size_t len = sizeof(buf);
if (nvs_get_str(h, k->nvs, buf, &len) != 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; e = nvs_get_u8(h, k->nvs, &x); v = x;
} else if (k->type == T_U16) {
uint16_t x; e = nvs_get_u16(h, k->nvs, &x); v = x;
} else {
e = nvs_get_u32(h, k->nvs, &v);
}
if (e != ESP_OK) return cJSON_CreateNull();
if (k->scale > 0) return cJSON_CreateNumber((double)v / k->scale);
return cJSON_CreateNumber((double)v);
}
/* --------------------------------------------------------------- writes --- */
/**
* Validate and stage one value. Returns false with `err` set on a bad value.
* Nothing is committed here; the caller commits once the whole body validates,
* so a body with one bad field changes nothing at all.
*/
static bool write_key(nvs_handle_t h, const cfg_key_t *k, const cJSON *v,
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 (strlen(v->valuestring) >= k->maxlen) {
snprintf(err, errlen, "%s exceeds %u chars", k->json,
(unsigned)(k->maxlen - 1));
return false;
}
return nvs_set_str(h, k->nvs, v->valuestring) == ESP_OK;
}
if (!cJSON_IsNumber(v)) {
snprintf(err, errlen, "%s must be a number", k->json);
return false;
}
double d = v->valuedouble;
if (k->scale > 0) d *= k->scale;
if (d < 0) d = 0;
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 (k->type == T_U8) return nvs_set_u8(h, k->nvs, (uint8_t)val) == ESP_OK;
if (k->type == T_U16) return nvs_set_u16(h, k->nvs, (uint16_t)val) == ESP_OK;
return nvs_set_u32(h, k->nvs, val) == ESP_OK;
}
/** Restore one banked value; JSON null means the key was previously unset. */
static void restore_key(nvs_handle_t h, const cfg_key_t *k, const cJSON *v)
{
if (cJSON_IsNull(v)) {
nvs_erase_key(h, k->nvs); /* absence is a state worth restoring */
return;
}
char err[64];
write_key(h, k, v, err, sizeof(err));
}
/* ---------------------------------------------------------------- trial --- */
/** Apply the banked values and reboot. Runs from the esp_timer task. */
static void trial_revert(void *arg)
{
(void)arg;
ESP_LOGE(TAG, "config trial FAILED to associate -- reverting");
nvs_handle_t t, c;
if (nvs_open(TRIAL_NS, NVS_READWRITE, &t) != ESP_OK) {
/* Cannot read the bank, so cannot revert. Rebooting would spin
* forever; stay up so the node is at least diagnosable if it is
* somehow still reachable. */
ESP_LOGE(TAG, "trial namespace unreadable; cannot revert");
return;
}
size_t len = 0;
char *json = NULL;
if (nvs_get_str(t, "bank", NULL, &len) == ESP_OK && len > 1 &&
(json = malloc(len)) != NULL &&
nvs_get_str(t, "bank", json, &len) == ESP_OK &&
nvs_open(CFG_NS, NVS_READWRITE, &c) == ESP_OK) {
cJSON *root = cJSON_Parse(json);
if (root) {
cJSON *it = NULL;
cJSON_ArrayForEach(it, root) {
const cfg_key_t *k = find_key(it->string);
if (k) {
restore_key(c, k, it);
ESP_LOGW(TAG, "reverted %s", k->json);
}
}
cJSON_Delete(root);
}
nvs_commit(c);
nvs_close(c);
}
free(json);
/* Clear the trial BEFORE rebooting: if the restored config is itself
* broken we must not loop reverting forever. One attempt, then the node
* comes up on the old values and stays there. */
nvs_erase_all(t);
nvs_commit(t);
nvs_close(t);
ESP_LOGE(TAG, "rebooting onto the previous configuration");
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;
uint32_t deadline = TRIAL_DEFAULT;
nvs_get_u8(t, "armed", &armed);
nvs_get_u32(t, "deadline", &deadline);
nvs_close(t);
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) 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;
}
nvs_handle_t t;
if (nvs_open(TRIAL_NS, NVS_READWRITE, &t) == ESP_OK) {
nvs_erase_all(t);
nvs_commit(t);
nvs_close(t);
}
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 < NKEYS; i++) {
cJSON_AddItemToObject(vals, KEYS[i].json, read_key(h, &KEYS[i], true));
}
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 < NKEYS; i++) {
if (KEYS[i].assoc) cJSON_AddItemToArray(risky, cJSON_CreateString(KEYS[i].json));
}
cJSON_AddBoolToObject(root, "trial_pending", s_trial_armed);
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 (s_trial_armed) {
/* 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 (is_meta(it->string)) continue;
const cfg_key_t *k = find_key(it->string);
if (!k) {
snprintf(err, sizeof(err), "unknown parameter '%s'", it->string);
break;
}
if (k->assoc) needs_trial = true;
if (!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: bank the CURRENT values before anything is written, reading
* through a separate read-only handle so pending writes cannot leak in. */
cJSON *bank = NULL;
if (needs_trial) {
nvs_handle_t ro;
bank = cJSON_CreateObject();
if (nvs_open(CFG_NS, NVS_READONLY, &ro) == ESP_OK) {
cJSON_ArrayForEach(it, root) {
if (is_meta(it->string)) continue;
const cfg_key_t *k = find_key(it->string);
cJSON_AddItemToObject(bank, k->json, read_key(ro, k, false));
}
nvs_close(ro);
}
}
/* Pass 3: stage every write. Nothing is committed yet, so a single bad
* value leaves the node exactly as it was. */
nvs_handle_t rw;
if (nvs_open(CFG_NS, NVS_READWRITE, &rw) != ESP_OK) {
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 (is_meta(it->string)) continue;
const cfg_key_t *k = find_key(it->string);
if (!write_key(rw, k, it, err, sizeof(err))) {
if (!err[0]) snprintf(err, sizeof(err), "could not write %s", k->json);
break;
}
changed++;
}
if (err[0]) {
nvs_close(rw); /* uncommitted writes are discarded */
cJSON_Delete(root);
cJSON_Delete(bank);
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;
/* Pass 4: arm the trial BEFORE committing the new config. If the bank
* cannot be stored we must not proceed -- committing first would reboot
* the node onto unproven settings with nothing to fall back to. */
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;
}
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, "armed", 1) == ESP_OK &&
nvs_commit(t) == ESP_OK;
nvs_close(t);
}
free(bj);
if (!ok) {
nvs_close(rw);
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;
}
}
esp_err_t ce = nvs_commit(rw);
nvs_close(rw);
if (ce == ESP_OK) {
/* Push live parameters into the running config so they apply now.
* Values were range-checked in pass 3, 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);
if (ce != ESP_OK) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Commit failed");
return ESP_FAIL;
}
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) esp_timer_start_once(th, 3000000ULL);
}
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);
@@ -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
+15
View File
@@ -23,6 +23,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));
@@ -313,6 +316,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;
}
+13
View File
@@ -62,8 +62,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.
*
+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
+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
@@ -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__":
+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
+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 */
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""SYNTHETIC cross-repository acceptance, never hardware evidence."""
import argparse
import json
from pathlib import Path
import subprocess
import tempfile
def run(args, **kw):
return subprocess.run([str(a) for a in args], check=True, capture_output=True, timeout=30, **kw)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--rufield-bin", type=Path, required=True)
args = parser.parse_args()
base = Path(__file__).resolve().parent
model = base.parent.parent / "docs/rf-diagnostics/model.js"
run(["make", "-C", base, "all"])
scenarios = ("normal", "gap", "event", "flat", "tag", "badkind")
with tempfile.TemporaryDirectory() as temp:
for scenario in scenarios:
fixture = Path(temp) / "stream.shm"
run([base / "build/w1700k-fixture", fixture, scenario])
output = run([base / "build/w1700k-diagnostics", "--input", fixture,
"--source-kind", "synthetic", "--once"]).stdout
records = [json.loads(line) for line in output.splitlines()]
assert len(records) == 4
run(["node", "-e", "const m=require(process.argv[1]);m.parse(require('fs').readFileSync(0,'utf8'));", model], input=output)
events = [json.loads(line) for line in run([args.rufield_bin.resolve()], input=output).stdout.splitlines()]
assert len(events) == len(records)
for record, event in zip(records, events):
assert record["valid"] is False and record["config_verified"] is False
assert event["sensor"]["modality"] == "rf_spectrum_quality"
assert event["provenance"]["synthetic"] is True
assert event["observation"]["confidence"] == 0
assert event["observation"]["labels"] == []
assert event["observation"]["attributes"]["trust"] == "unverified_advisory"
if scenario == "gap":
assert sum(r["missing_samples"] for r in records) == 2048
if scenario == "event":
assert sum(r["event_count"] for r in records) == 1
assert sum(r["sample_count"] for r in records) == 6144
if scenario in ("tag", "badkind"):
assert records[1]["sample_count"] == 0
if scenario == "flat":
assert records[1]["rms_dbfs"] > 0 and records[1]["rail_fraction"] == 0
print(f"PASS {scenario}: C ABI -> JSONL -> browser validator -> RuField")
print("SYNTHETIC INTEGRATION PASS; hardware validation pending")
if __name__ == "__main__":
main()
+59 -3
View File
@@ -1,10 +1,20 @@
# cargo-audit configuration — v2 workspace
# Managed by security audit (fix/security-audit-rustsec-clippy branch).
#
# This file suppresses UNMAINTAINED-only advisories (no CVE) flowing through
# This file records every advisory that the gating Security Scanning job is
# allowed to skip, together with the reason it is acceptable here. Most
# entries are UNMAINTAINED-only advisories (no CVE) flowing through
# dependencies that are purely transitive / build-time and have no
# user-facing attack surface in this workspace.
# Each entry documents the root cause and the mitigation path.
# user-facing attack surface in this workspace; each entry documents the
# root cause and the mitigation path. A few entries are real CVEs whose
# vulnerable code path is proven unreachable from this workspace — those
# carry an explicit reachability proof and an upstream release condition.
#
# NOTE: cargo-audit resolves this file relative to the CURRENT WORKING
# DIRECTORY (./.cargo/audit.toml), never relative to the --file lockfile it
# audits. The workflow therefore runs the audit with working-directory: v2.
# Running it from the repository root silently audits with cargo-audit's
# defaults and ignores nothing.
[advisories]
@@ -89,6 +99,13 @@ ignore = [
# Unmaintained. No CVE. Build-time proc-macro; zero runtime exposure.
"RUSTSEC-2024-0370", # proc-macro-error: unmaintained
# -----------------------------------------------------------------------
# proc-macro-error2 — RUSTSEC-2026-0173
# Unmaintained (author-confirmed). No CVE. Build-time proc-macro;
# zero runtime exposure. Same family and same mitigation path as
# RUSTSEC-2024-0370 above.
"RUSTSEC-2026-0173", # proc-macro-error2: unmaintained
# -----------------------------------------------------------------------
# rand <0.9 — RUSTSEC-2026-0097
# Unsound: the rand 0.8 BlockRng64 implementation can panic and expose
@@ -127,4 +144,43 @@ ignore = [
"RUSTSEC-2025-0081", # unic-char-property
"RUSTSEC-2025-0098", # unic-ucd-version
"RUSTSEC-2025-0100", # unic-ucd-ident
# -----------------------------------------------------------------------
# zip 0.6.x / 6.x / 7.x — RUSTSEC-2026-0367, RUSTSEC-2026-0368,
# RUSTSEC-2026-0369
# Three privilege-escalation advisories, all confined to the zip crate's
# EXTRACTION path:
# 0367 — SUID/SGID/sticky bits carried in `external_attributes` survive
# extraction instead of being masked to safe permission bits.
# 0368 — `ZipArchive::extract` creates a symlink whose target bytes are
# handed to the platform symlink API without confining the
# target to the destination directory.
# 0369 — `ZipStreamReader::extract` / `ZipArchive::extract` apply Unix
# permissions THROUGH an extracted symlink without validating
# that the symlink target stays inside the destination.
# All three are fixed only in zip >= 9.0.0.
#
# NOT REACHABLE from this workspace: the vulnerable API is
# `ZipArchive::extract` / `ZipStreamReader::extract`, and no crate in
# the resolved graph calls either one.
# - ndarray-npy 0.10.0 (src/npz.rs) uses ZipArchive::new + by_name /
# by_index for reading and ZipWriter for writing.
# - candle-core 0.4.1 (src/npy.rs, src/pickle.rs) and 0.9.2
# (src/py.rs, src/pickle.rs) use the same read/write subset.
# - torch-sys 0.24.0 (build.rs) extracts the libtorch archive with its
# own per-entry loop — archive.by_index(i), fs::File::create and
# io::copy — so it never calls extract(), never creates a symlink
# (directory entries are skipped) and never applies the archive's
# Unix permission bits (files are created with umask defaults).
# The single archive it does extract is the pinned libtorch build
# downloaded over HTTPS from download.pytorch.org.
#
# Mitigation: a version bump is not possible yet — no published release
# of the parents moves to zip >= 9.0.0 (ndarray-npy 0.10.0 -> zip ^6,
# candle-core 0.11.0 -> zip ^8.6, tch/torch-sys 0.26.0 -> zip ^0.6).
# Re-evaluate when candle-core / ndarray-npy / tch ship a release that
# adopts zip 9.x, then delete this block and bump the lockfile.
"RUSTSEC-2026-0367", # zip: SUID/SGID preserved during extraction
"RUSTSEC-2026-0368", # zip: extract() symlink target escapes destination
"RUSTSEC-2026-0369", # zip: extract() applies permissions through symlinks
]
Generated
+206 -110
View File
@@ -1544,7 +1544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
"windows-sys 0.48.0",
]
[[package]]
@@ -1553,7 +1553,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -1791,36 +1791,36 @@ dependencies = [
[[package]]
name = "cranelift-assembler-x64"
version = "0.123.16"
version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4353b283b6c76205abc8eab20b41838e47510c9669ed83c27f7bca8cd1c262f"
checksum = "b3741b9ad77e89c710586b2fcdd619baf28482d92b70d88cecb2a081dd912ae6"
dependencies = [
"cranelift-assembler-x64-meta",
]
[[package]]
name = "cranelift-assembler-x64-meta"
version = "0.123.16"
version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61f6d6eb342f6c48d97278c7549c5b91756f03db80bce9cdeddaa536c356c937"
checksum = "8243e149085051b23faebd352ef7031aeebc5f45e63aaefd63ee7f810c22c7df"
dependencies = [
"cranelift-srcgen",
]
[[package]]
name = "cranelift-bforest"
version = "0.123.16"
version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c746ef625773869efaffda6918a2b73f973c73e419e1de20833f3a1a9d614006"
checksum = "c538af3aee13eb6aa8a85d2ef85b4d0aa41623dae8b19f59bbacfd5d6c114920"
dependencies = [
"cranelift-entity",
]
[[package]]
name = "cranelift-bitset"
version = "0.123.16"
version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5447cd11203bcb5b71bb91e1e8b8470f350870ca837847bf82ec2e25f55fcd7b"
checksum = "2292ea3964d7d751031da1a1261d64feb09e89bd4fbebf3c78f43f9c34c20a11"
dependencies = [
"serde",
"serde_derive",
@@ -1828,9 +1828,9 @@ dependencies = [
[[package]]
name = "cranelift-codegen"
version = "0.123.16"
version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ac26ac02abded3bb8223f0cf8a0055c6284be55ca9a6c380f78494bdf686adc"
checksum = "8967a699a4982d02c32246a00f2aa4cee5bccfaaa042608d5e151cb99cba210e"
dependencies = [
"bumpalo",
"cranelift-assembler-x64",
@@ -1855,9 +1855,9 @@ dependencies = [
[[package]]
name = "cranelift-codegen-meta"
version = "0.123.16"
version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c4a9197c03f7b75c228a66ecaf0b7469e0f9b86fe3cf2cf7ebfbcaacfce0a9b"
checksum = "e258eeeb6903b4e91e314a3667ab2a1e2924738ef2df08bdb50355e38decc749"
dependencies = [
"cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
@@ -1868,24 +1868,24 @@ dependencies = [
[[package]]
name = "cranelift-codegen-shared"
version = "0.123.16"
version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3279a717ec8773811c148650b77cb7c554c6898c858cb547d3da477a8bca21b3"
checksum = "e1d36ff0de30460c01dad152ca2097bcabef2f16c659b9ffbca2f67e9bbdb321"
[[package]]
name = "cranelift-control"
version = "0.123.16"
version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e2a7ad0219d6f4a4899e5e93d5a4944a79dd6cffe2f6d6fba4d6b2ac165a154"
checksum = "acc880d91877efb6199031bda872726ca3287dc1e18ccf3f61c07bfdab62930b"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
version = "0.123.16"
version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "554aaa52367f0396570d031aa0bdedfff4f63794022dc418d0cdcf8f6d321b60"
checksum = "5f7af321c4b6e5be4729e4a5b1ed0fc78fc31695c1ecd3c9b45b91908bf3d0a3"
dependencies = [
"cranelift-bitset",
"serde",
@@ -1894,9 +1894,9 @@ dependencies = [
[[package]]
name = "cranelift-frontend"
version = "0.123.16"
version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72db97f8a7ea1626ecfcac1adf664cba1f1d68448abbb36b7dbaa0a6b78bc11f"
checksum = "3463f1b26faac3491008cede8e9cdf7ceb83139143bd92ba86c14b0882aaf1bb"
dependencies = [
"cranelift-codegen",
"log",
@@ -1906,15 +1906,15 @@ dependencies = [
[[package]]
name = "cranelift-isle"
version = "0.123.16"
version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17a237c16305f95ff07031be4a5226a4c0753761f40bbad2105c758a33aee692"
checksum = "90b76554b329634c4241c5670a96058affca99ac3b33daa14fb221bd68c0038e"
[[package]]
name = "cranelift-native"
version = "0.123.16"
version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd6f62b06973b3e5fd813167b01958b09f634bfa59a8f5db2cd49b930d93aa6f"
checksum = "36e7c67bcfd30a8160187a6d52b2ec0cbe45698a9d61f12d1292bd24701f7ed2"
dependencies = [
"cranelift-codegen",
"libc",
@@ -1923,9 +1923,9 @@ dependencies = [
[[package]]
name = "cranelift-srcgen"
version = "0.123.16"
version = "0.123.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd76efe2edc25d9f062aaf0a16c7528a30c8ba5782afe6296237ed48d8d6127c"
checksum = "1ccca83c45ddfe0412b1b2e4fe509d28d1bdbc014e306574a848519741bebc36"
[[package]]
name = "crc"
@@ -3333,7 +3333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -3364,13 +3364,13 @@ checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fastbloom"
version = "0.14.1"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4"
checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8"
dependencies = [
"getrandom 0.3.4",
"foldhash 0.2.0",
"libm",
"rand 0.9.2",
"portable-atomic",
"siphasher 1.0.2",
]
@@ -4304,11 +4304,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -4318,11 +4316,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"rand_core 0.10.1",
"wasip2",
"wasip3",
"wasm-bindgen",
]
[[package]]
@@ -5231,7 +5231,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.7",
"webpki-roots 1.0.10",
]
[[package]]
@@ -5549,7 +5549,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5677,19 +5677,68 @@ dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"jni-sys 0.3.0",
"log",
"thiserror 1.0.69",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
dependencies = [
"cfg-if",
"combine",
"jni-macros",
"jni-sys 0.4.1",
"log",
"simd_cesu8",
"thiserror 2.0.18",
"walkdir",
"windows-link 0.2.1",
]
[[package]]
name = "jni-macros"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
dependencies = [
"proc-macro2",
"quote",
"rustc_version",
"simd_cesu8",
"syn 2.0.117",
]
[[package]]
name = "jni-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "jni-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
dependencies = [
"jni-sys-macros",
]
[[package]]
name = "jni-sys-macros"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "jobserver"
version = "0.1.34"
@@ -6178,7 +6227,7 @@ dependencies = [
"js-sys",
"quinn",
"rcgen",
"rustls-platform-verifier",
"rustls-platform-verifier 0.6.2",
"serde",
"thiserror 2.0.18",
"tokio",
@@ -6609,7 +6658,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
dependencies = [
"bitflags 2.11.0",
"jni-sys",
"jni-sys 0.3.0",
"log",
"ndk-sys",
"num_enum",
@@ -6629,7 +6678,7 @@ version = "0.6.0+11769913"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
dependencies = [
"jni-sys",
"jni-sys 0.3.0",
]
[[package]]
@@ -7272,7 +7321,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
@@ -8051,7 +8100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools 0.14.0",
"itertools 0.10.5",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -8088,9 +8137,9 @@ dependencies = [
[[package]]
name = "pulley-interpreter"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f09f3fdea932cc327d051b2aaccfc82fddcd49997e8579f3d91ae3468a954b94"
checksum = "c956c7140270b72e3bf03381a51f2eb79c70fd6881fc17cd027fa7cd3f56ae5a"
dependencies = [
"cranelift-bitset",
"log",
@@ -8100,9 +8149,9 @@ dependencies = [
[[package]]
name = "pulley-macros"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96da696c8995a3033eb1287ee418895592da41a5d549b8615b5f1e5851318b48"
checksum = "74db420d7ce2bb03eed5b81c1f778bda5771b3fd818e2b496dfc3a2783ceb60e"
dependencies = [
"proc-macro2",
"quote",
@@ -8208,20 +8257,21 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.15"
version = "0.11.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc"
dependencies = [
"bytes",
"fastbloom",
"getrandom 0.3.4",
"getrandom 0.4.1",
"lru-slab",
"rand 0.9.2",
"rand 0.10.2",
"rand_pcg 0.10.2",
"ring",
"rustc-hash 2.1.1",
"rustls",
"rustls-pki-types",
"rustls-platform-verifier",
"rustls-platform-verifier 0.7.1",
"slab",
"thiserror 2.0.18",
"tinyvec",
@@ -8240,7 +8290,7 @@ dependencies = [
"once_cell",
"socket2 0.6.2",
"tracing",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -8278,7 +8328,7 @@ dependencies = [
"rand_chacha 0.2.2",
"rand_core 0.5.1",
"rand_hc",
"rand_pcg",
"rand_pcg 0.2.1",
]
[[package]]
@@ -8424,6 +8474,15 @@ dependencies = [
"rand_core 0.5.1",
]
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rand_xorshift"
version = "0.4.0"
@@ -8717,7 +8776,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.7",
"webpki-roots 1.0.10",
]
[[package]]
@@ -9104,14 +9163,14 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
name = "rustls"
version = "0.23.45"
version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"log",
"once_cell",
@@ -9152,17 +9211,38 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni",
"jni 0.21.1",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-platform-verifier-android 0.1.1",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
name = "rustls-platform-verifier"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys",
"jni 0.22.4",
"log",
"once_cell",
"rustls",
"rustls-native-certs",
"rustls-platform-verifier-android 0.2.0",
"rustls-webpki",
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
]
[[package]]
@@ -9172,10 +9252,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
name = "rustls-platform-verifier-android"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -10247,6 +10333,16 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simd_cesu8"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
dependencies = [
"rustc_version",
"simdutf8",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
@@ -10768,7 +10864,7 @@ dependencies = [
"gdkwayland-sys",
"gdkx11-sys",
"gtk",
"jni",
"jni 0.21.1",
"libc",
"log",
"ndk",
@@ -10840,7 +10936,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"jni",
"jni 0.21.1",
"libc",
"log",
"mime",
@@ -11027,7 +11123,7 @@ dependencies = [
"dpi",
"gtk",
"http",
"jni",
"jni 0.21.1",
"objc2",
"objc2-ui-kit",
"objc2-web-kit",
@@ -11050,7 +11146,7 @@ checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e"
dependencies = [
"gtk",
"http",
"jni",
"jni 0.21.1",
"log",
"objc2",
"objc2-app-kit",
@@ -11144,7 +11240,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -12510,9 +12606,9 @@ dependencies = [
[[package]]
name = "wasmtime"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1edc1374519a6d4c3eb99e0598f2ce069e6feb63de65bed7d0240a5f84b58c3b"
checksum = "60e5f244a2c9d40afa9e9a607ee7f048c0648bb1c22cc28b130c5fc71e23d71d"
dependencies = [
"addr2line",
"anyhow",
@@ -12565,9 +12661,9 @@ dependencies = [
[[package]]
name = "wasmtime-environ"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7e05a9ebefb914d4df7928652e27aa1e89e1ca23c5ab73c647316783f3bcbc2"
checksum = "3e246cd5107aa406cfa4acf6942e864bd189ae8136bab853365147da131c9ca3"
dependencies = [
"anyhow",
"cpp_demangle",
@@ -12592,18 +12688,18 @@ dependencies = [
[[package]]
name = "wasmtime-internal-asm-macros"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fcf4b431fb653aefd2cc3c3a65ea78988acf5a7626cb825c54b5aab5aa1a8e"
checksum = "5380a8106fea8bfedb7208bb11ced9e7488ee44394ca889ede73acd3bdfd9d0b"
dependencies = [
"cfg-if",
]
[[package]]
name = "wasmtime-internal-cache"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65b514e39cd914f34a135ce7696373287ccb9611b083cbe244a48c4903380fb5"
checksum = "4108e1964b3a02b4b5604ea1a2918a0cbcab512e5de0ccf4c062fc6c06b2eb8e"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -12621,9 +12717,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-component-macro"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92fe9cf6ca766adc68b5ed4474155e88cc97e79d13c63450fe91779de9c41a3"
checksum = "8278aa2bb237788ac9e3a70c5bea3ea3de6b4f95fb003df303858941871b8c4e"
dependencies = [
"anyhow",
"proc-macro2",
@@ -12636,15 +12732,15 @@ dependencies = [
[[package]]
name = "wasmtime-internal-component-util"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36b8553647a6ae550ec3bca45759565204af763c37b1b51d5e45d6c5a26a1e32"
checksum = "838c652b6cafd82a432a3a2c7af5bea4c6957d218aafbe50018e0297486ab7cc"
[[package]]
name = "wasmtime-internal-cranelift"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bd306f478b4a68d94a80e64d1a92871b697188eafe8bfc387f865085c818a04"
checksum = "7339fdd64a3fde574505adea23244e065d5b1de0e72c8dd87e150f5d4ab63a63"
dependencies = [
"anyhow",
"cfg-if",
@@ -12669,9 +12765,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-fiber"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f9bb63081293450d0dfb439f246339e91e34d51c09b1310bdd297b67be5933"
checksum = "6f3afc72d3c72f71d4ebc72e83f479ba8ca24409c41d43e5e0228f0bf82b873e"
dependencies = [
"anyhow",
"cc",
@@ -12685,9 +12781,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-jit-debug"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c04edbaf79fb10460ee0867a829f831b7afa8d5d8c9d32287d88b3f1aaef79"
checksum = "21d33adf2d1eae36df9b6b033a1d549c7553ea607f4f9a81f75d51627725c354"
dependencies = [
"cc",
"object",
@@ -12697,9 +12793,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-jit-icache-coherence"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e2b3b397afafb7e6b3603dd3f867cc626d3bde726bd65ce01443a70fe66e1c0"
checksum = "5bde2fcf14cc76084242c6fe792e49778b5a9008c7df28ca4fe21c35c69d5f55"
dependencies = [
"anyhow",
"cfg-if",
@@ -12709,24 +12805,24 @@ dependencies = [
[[package]]
name = "wasmtime-internal-math"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2509a3a45fbdc79eab62680df8f8d5ffe5420c3bf7f0c2083d49110c09c61b34"
checksum = "4bd982249e933e730859f84c17254c7e95f38c3b0cdf81695bfc6a270e237852"
dependencies = [
"libm",
]
[[package]]
name = "wasmtime-internal-slab"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0985eb32142c197f1aa5d50e94498a769f55095e8ed2838eff386c735f9da72f"
checksum = "8280b359384cc412bddf0023193eeaffadcb0d7fbbf860f2b7e0580e8806a344"
[[package]]
name = "wasmtime-internal-unwinder"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b88fa7f08080eeb4926d6062c2ae8c38c19a2b1a6d0f01c569b827d987f4909"
checksum = "bb43477c41769fec0d031d32cb3236195acfb1e6aa4efdf463e5fd001c23f6cc"
dependencies = [
"anyhow",
"cfg-if",
@@ -12737,9 +12833,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-versioned-export-macros"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfff26e96f2539205ff7c049e4dff8343a479722bbbae88bc4da54adde12c1fa"
checksum = "b33afac17819f2f767ea280b4b87a6c95458ef1ae43da410cfc5b26813ecb78f"
dependencies = [
"proc-macro2",
"quote",
@@ -12748,9 +12844,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-winch"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "176d188460e507807f9f8b7521d7c1dc62b356fa221b4e2ce566e88652294d9d"
checksum = "1757c8b0626385c93994c7a3c7a23ee5b469061465b601c1fdce71ff61b11c82"
dependencies = [
"anyhow",
"cranelift-codegen",
@@ -12765,9 +12861,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-wit-bindgen"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d3c06e67956fabd2fa3b1aa94200779cc0df8100dc45c6a9b80fda67f565697"
checksum = "061dd5bae2e618b32a8ed2acda5953d07dd308ac0e3f7960afc62ccdf6df7c44"
dependencies = [
"anyhow",
"bitflags 2.11.0",
@@ -12876,9 +12972,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
version = "1.0.6"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [
"rustls-pki-types",
]
@@ -12889,14 +12985,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.7",
"webpki-roots 1.0.10",
]
[[package]]
name = "webpki-roots"
version = "1.0.7"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
dependencies = [
"rustls-pki-types",
]
@@ -13664,7 +13760,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@@ -13675,9 +13771,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winch-codegen"
version = "36.0.16"
version = "36.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cde193a101057fce046a3fff773932419b7861f6bb95632d01bca90843c9ce51"
checksum = "69940a29fc6b01b0e6b141912b700c17eaf956d58d63bc0c6e733435caf020e4"
dependencies = [
"anyhow",
"cranelift-assembler-x64",
@@ -14450,7 +14546,7 @@ dependencies = [
"html5ever",
"http",
"javascriptcore-rs",
"jni",
"jni 0.21.1",
"kuchikiki",
"libc",
"ndk",
+19
View File
@@ -268,6 +268,25 @@ wiremock = "0.5"
# 0.1.0 was yanked; upgrade to latest 0.3/0.2 releases which pull in
# quinn-proto >=0.11.14 (fixes RUSTSEC-2026-0037) and
# rustls-webpki >=0.103.13 (fixes RUSTSEC-2026-0049/0098/0099/0104).
#
# The transitive security floors live in Cargo.lock, which the gating
# Security Scanning job audits: quinn-proto >=0.11.18
# (RUSTSEC-2026-0338/0339/0340/0341/0342), rustls >=0.23.46
# (RUSTSEC-2026-0346/0347/0348/0349) and wasmtime >=36.0.19
# (RUSTSEC-2026-0363/0379/0380). A plain `cargo update` can walk them backwards;
# keep the lockfile at or above these until the direct requirements
# enforce them.
#
# The TLS trust anchors carry floors for the same reason: rustls-webpki
# >=0.103.16 (RUSTSEC-2026-0382) rejects certificate paths that contain
# distrusted emSign intermediate public keys, and webpki-roots >=1.0.10 /
# webpki-root-certs >=1.0.10 (RUSTSEC-2026-0381/0383) drop the emSign ECC
# Root CA - G3 anchor that made those paths reachable. The private keys of
# the distrusted emSign intermediates are public, so an affected build can
# be talked into accepting a forged certificate chain. Those crates are
# transitive too, so the lockfile is the only place the floor can live:
# bump them explicitly instead of letting a plain `cargo update` walk them
# backwards.
midstreamer-quic = "0.3"
midstreamer-scheduler = "0.2"
midstreamer-temporal-compare = "0.2"
+27 -2
View File
@@ -6,7 +6,7 @@
# Run:
# docker run --rm -p 7878:7878 nvsim-server:latest
FROM rust:1.81-slim-bookworm@sha256:f9fb6bdb0483de4ade93b262a3f6cf8c2985fca1d34784914bbcabd5a34d3197 AS builder
FROM rust:1.89-slim-bookworm@sha256:d7fc7de78bb8c1469933aeecbf801314d30d7d6e9f0578bba4cfa285bfa37fe6 AS builder
WORKDIR /build
# Debian security revisions intentionally float within the immutable base
# snapshot so rebuilds receive patched packages without brittle version pins.
@@ -18,7 +18,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Copy a stub workspace Cargo.toml + Cargo.lock that only references
# nvsim and nvsim-server. This dodges the full RuView workspace's
# wifi-densepose-* deps, none of which nvsim-server uses.
#
# The real manifest is kept under a side name so the stub below can adopt its
# [workspace.package] version. Hard-coding that version here drifts as soon as
# the workspace is bumped, and the drift breaks nvsim-server's path+version
# requirement on nvsim (nvsim = { path = "../nvsim", version = "..." }).
COPY Cargo.lock Cargo.lock
# Builder image pinned to the same release as [toolchain] channel in
# rust-toolchain.toml. The declared channel travels with the build context
# and is checked below, so bumping one without the other fails the build
# loudly instead of silently compiling on a stale rustc.
COPY rust-toolchain.toml toolchain.expected.toml
COPY Cargo.toml workspace.Cargo.toml.real
COPY crates/nvsim crates/nvsim
COPY crates/nvsim-server crates/nvsim-server
@@ -28,7 +39,7 @@ resolver = "2"
members = ["crates/nvsim", "crates/nvsim-server"]
[workspace.package]
version = "0.3.0"
version = "@WS_VERSION@"
edition = "2021"
authors = ["rUv <ruv@ruv.net>"]
license = "MIT OR Apache-2.0"
@@ -47,6 +58,20 @@ tower-http = { version = "0.5", features = ["cors", "trace", "compression-gzip"]
criterion = "0.5"
EOF
# Adopt the real workspace version so the stub cannot drift from it.
RUN WS_VERSION="$(sed -n '/^\[workspace\.package\]/,/^\[/{s/^version *= *"\([^"]*\)".*/\1/p}' workspace.Cargo.toml.real | head -n1)" \
&& test -n "$WS_VERSION" \
&& echo "stub workspace version: $WS_VERSION" \
&& sed -i "s|@WS_VERSION@|$WS_VERSION|" Cargo.toml \
&& rm workspace.Cargo.toml.real
RUN set -eu; \
want="$(sed -n 's/^channel *= *"\(.*\)"/\1/p' toolchain.expected.toml | head -n1)"; \
have="$(rustc --version | cut -d' ' -f2)"; \
echo "rust-toolchain.toml channel=$want builder rustc=$have"; \
case "$have" in "$want"|"$want".*) ;; \
*) echo "builder rustc $have does not satisfy rust-toolchain.toml channel $want; bump the FROM tag in this Dockerfile to match" >&2; exit 1 ;; \
esac
RUN cargo build --release -p nvsim-server --bin nvsim-server
FROM debian:bookworm-slim@sha256:7b140f374b289a7c2befc338f42ebe6441b7ea838a042bbd5acbfca6ec875818
@@ -30,11 +30,19 @@ function formatLastSeen(iso: string): string {
export function NodeCard({ node, onClick }: NodeCardProps) {
const isOnline = node.health === "online";
const Component = onClick ? "button" : "div";
return (
<div
onClick={() => onClick?.(node)}
<Component
{...(onClick ? { type: "button" as const } : {})}
onClick={onClick ? () => onClick(node) : undefined}
aria-label={onClick ? `View details for ${node.friendly_name || node.hostname || `Node ${node.node_id}`}` : undefined}
style={{
background: "var(--bg-elevated)",
width: "100%",
textAlign: "left",
font: "inherit",
color: "inherit",
border: "1px solid var(--border)",
borderRadius: 8,
padding: "var(--space-4)",
@@ -114,7 +122,7 @@ export function NodeCard({ node, onClick }: NodeCardProps) {
<DetailRow label="Uptime" value={formatUptime(node.uptime_secs)} mono />
<DetailRow label="Seen" value={formatLastSeen(node.last_seen)} />
</div>
</div>
</Component>
);
}
+1 -1
+1 -1
+1 -1