mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-19 21:30:06 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ef6b96fe1 | ||
|
|
2d5472372f | ||
|
|
efdf97ecc1 | ||
|
|
46d80ffc3a | ||
|
|
0b15c0ebc9 | ||
|
|
682a6f4538 | ||
|
|
a72174c571 | ||
|
|
fd323e483e | ||
|
|
9f0821cf69 | ||
|
|
69fc9efff7 | ||
|
|
20386cf313 | ||
|
|
c4fd6d9b36 | ||
|
|
e223d86ba2 | ||
|
|
52e5ebefbd | ||
|
|
ca44cd1a7b | ||
|
|
5fe0a6a48d | ||
|
|
da1ef6a17d | ||
|
|
b6c2fb5b7d | ||
|
|
b8b70b7df7 | ||
|
|
bb5d903724 | ||
|
|
1e2348abef | ||
|
|
2493185ab8 | ||
|
|
678f63f4f3 | ||
|
|
0aba0fc467 | ||
|
|
6f550c39ed | ||
|
|
e9d0cef610 | ||
|
|
bb22f1b185 | ||
|
|
7bc5fe1a53 | ||
|
|
51e1788c7e | ||
|
|
7b62297f3e | ||
|
|
890e521a3a | ||
|
|
444184f9ef | ||
|
|
7955af5bc0 | ||
|
|
273a2a8536 | ||
|
|
add0755958 | ||
|
|
de5f7edd16 | ||
|
|
69e0729c61 | ||
|
|
9baaf8434f | ||
|
|
27eefb6a7f | ||
|
|
c6f7e8ca0d | ||
|
|
474f1a904e | ||
|
|
ddaa54eba0 | ||
|
|
b3d31510ee | ||
|
|
bce512cb8d | ||
|
|
9950ecc340 | ||
|
|
e0c4207bd5 | ||
|
|
044a41d2de | ||
|
|
8d243a67f4 | ||
|
|
ee6717f035 | ||
|
|
f5a36666fc | ||
|
|
d78a221607 | ||
|
|
db81dce7ba | ||
|
|
3a8ec8139e | ||
|
|
98c1d4a1e9 | ||
|
|
c00deadaf7 | ||
|
|
985e5d9449 | ||
|
|
b033fd309e | ||
|
|
76af08a9b0 | ||
|
|
951ffcc254 | ||
|
|
cbd84acf6c | ||
|
|
a1d7eb0bfd | ||
|
|
3741e7ba43 | ||
|
|
fabb0c8233 | ||
|
|
da2304985a | ||
|
|
8c6d29bf83 | ||
|
|
5cffe9e7db | ||
|
|
c51a1cf888 | ||
|
|
522374584c | ||
|
|
5755d9f11f | ||
|
|
3bf993275e | ||
|
|
05ea4feaed | ||
|
|
7af5477f04 | ||
|
|
84f5e19e9d | ||
|
|
1a50162598 | ||
|
|
0eb952a9c5 | ||
|
|
08f0f8859b | ||
|
|
8c63d7b558 | ||
|
|
e351d0fce1 | ||
|
|
ea6da967d2 | ||
|
|
f44f25d596 | ||
|
|
8ecac18978 | ||
|
|
e5f88b88fe | ||
|
|
ebb303cbb8 | ||
|
|
c80f618119 | ||
|
|
3b542f8a85 | ||
|
|
ef01c9e2be | ||
|
|
abeca0f0ed | ||
|
|
5a36be17d2 | ||
|
|
559528155f | ||
|
|
f161abf582 | ||
|
|
a4f80e63a2 | ||
|
|
43f5f99108 | ||
|
|
f8ab204711 | ||
|
|
0df5069831 | ||
|
|
f6a3a3579f | ||
|
|
d25fb8192e |
No files matched your search
@@ -204,7 +204,7 @@ jobs:
|
||||
node-version: '22'
|
||||
|
||||
- name: Run UI unit tests
|
||||
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
|
||||
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs ui/services/sensing.service.test.mjs ui/components/DashboardTab.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
|
||||
|
||||
# Unit and Integration Tests
|
||||
# Python pytest matrix — runs against the archived v1 Python tree.
|
||||
|
||||
@@ -322,3 +322,6 @@ v2/crates/ruview-offaxis/pkg/
|
||||
scripts/fal-visual-teacher/training-data/
|
||||
scripts/fal-visual-teacher/training-data.zip
|
||||
scripts/fal-visual-teacher/output/
|
||||
|
||||
# Trained adaptive classifier (auto-created at runtime; #2135)
|
||||
/v2/data/adaptive_model.json
|
||||
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Community batch (PRs #2100-#2157 by aepod, reviewed and integrated)
|
||||
|
||||
Integrated as one set: conflicts resolved, review findings fixed, validated on the integrated branch. Hardware figures cited by the PRs are the author's own live runs on ESP32-S3 nodes and were not independently reproduced here.
|
||||
|
||||
- **Security and honest status.** Refuse a routable HTTP bind when API auth is off (#2134). `--privacy-mode` now strips biometrics from REST, WebSocket and recordings, not only MQTT (#2125; the stripped-key list is an exact-match allowlist, so a new biometric field must be added to it). `/api/v1/status` waits for a real frame and unknown `--source` values are rejected (#2126; breaking for `--source macos|linux|windows`). A stale `/sensing/latest` reports `{"status":"stale"}` after 10 s (#2119). Measured stream fps and real WebSocket client counts (#2127). Absent nodes no longer count as one person (#2132). ESP32 frames with zero antennas or subcarriers are dropped (#2118). Dead nodes are evicted on a timer (#2120). Recordings and models stay under `--data-dir` (#2129). Boolean env flags accept 1/0/yes/no/on/off (#2108). Docker UDP ingest posture is explicit (#2107).
|
||||
- **CSI pipeline.** The grid gate locks on the majority frame shape and `/health` reports a stalled pipeline (#2157; a partial fix, mixed 192/306 frame shapes still drop frames, so it does not close #1894). Phase is combined across nodes only when they declare a shared reference (#2156). Point-cloud and hardware parsers use the ADR-018 layout and accept more than 256 subcarriers (#2155; the bandwidth rule for counts above 64 is CLAIMED, inferred from the datasheet). Per-node adaptive training from real recordings (#2101, #2103); a run that fell back to the live buffer now reports `degraded: true`. Gaussian decay no longer compounds across calls (#2140).
|
||||
- **MediaTek (ADR-266/267).** Legacy and unknown PPDU types in MTC1 (#2112), per-device ingest capped at 64 devices (#2113), a labelled amplitude heuristic and activity index (#2114; CLAIMED, not an occupancy estimate), an MT7981 bridge (#2115), and the OpenWrt WN586X3 patch set and guide (#2116; the vendored CSI patch is BSD-3-Clause-Clear, see `NOTICE`).
|
||||
- **MQTT / Home Assistant.** Semantic states wired into the publisher, entities announced only when sourced, persisted client id (#2117). Existing Home Assistant installs keep retained discovery configs for the six removed entities until they are cleared manually.
|
||||
- **Provisioning and firmware.** Wi-Fi password kept owner-only and out of argv and `--state` (#2153); state keyed by board MAC (#2152); `--dry-run` and a failed `--reset` leave state intact (#2111); OTA images confirmed by a bounded health check, which means only "reached the network and sent CSI" (#2100, ADR-379).
|
||||
- **Ground truth and spatial.** Radar window adapter and auto-labeller with a session-disjoint split and a median count baseline; radar labels carry `evidence: CLAIMED` (#2143, #2144). Node self-survey, check-only (#2145, ADR-381). The `spatial.evidence.v1` export defaults to the CODE proof grade and MEASURED requires a reproducer (#2141, ADR-382). Loopback UDP tee (#2102, ADR-380).
|
||||
- **Tooling.** `prove.sh` reports INCOMPLETE instead of PASS when a gate is skipped (#2130); ADR-018 capture scripts (#2131); API token on the ground-truth script (#2104); the UI asks for the API token instead of retrying forever (#2128); macOS redacted-link handling (#2105).
|
||||
- **Not included:** #2106 (MacWifi.app, draft) and #2142 (MR60 probe change, needs a captured MR60BHA2 boot log). `v2/data/adaptive_model.json` (41.5% training accuracy) is no longer tracked (#2135).
|
||||
|
||||
### Added
|
||||
|
||||
- **`@ruvnet/ruview` 0.9.1: live sensing views inside Claude Code (ADR-377, ADR-378).** The package now ships `ruview-live`, a Claude Code mod. Type `/ruview` and a pane opens next to the conversation showing what your sensors see right now, refreshed automatically. It has three views, switched with keys `1` `2` `3` or with `/ruview overview|waterfall|radar`:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
RuView third-party notices
|
||||
|
||||
This repository is licensed MIT OR Apache-2.0 (see LICENSE). The items below
|
||||
are vendored third-party material that keeps its own licence and is not
|
||||
covered by that dual licence.
|
||||
|
||||
firmware/openwrt-wn586x3/patches/1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch
|
||||
What: MediaTek's mt76 mt7915 CSI patch, rebased onto openwrt/mt76
|
||||
eb567bc7 (the revision pinned by OpenWrt 24.10.8).
|
||||
Author: Bo Jiao <Bo.Jiao@mediatek.com> (original author line kept in the
|
||||
patch header); copyright MediaTek Inc.
|
||||
Origin: mediatek/mtk-openwrt-feeds @ a15454c888f4f4144c50e33b5feef4f247c5f78b
|
||||
Licence: BSD-3-Clause-Clear (SPDX identifier,
|
||||
https://spdx.org/licenses/BSD-3-Clause-Clear.html). The patch
|
||||
carries the SPDX tags and MediaTek copyright lines in its added
|
||||
files; the licence text is not reproduced here.
|
||||
Scope: A vendored patch applied to an OpenWrt/mt76 build. It is not part
|
||||
of the Rust crates, is not compiled by the Cargo workspace, and is
|
||||
not linked into any RuView binary.
|
||||
|
||||
See firmware/openwrt-wn586x3/README.md for the other patches in that
|
||||
directory and the licence each one keeps.
|
||||
@@ -6,6 +6,12 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://ruos.cognitum.one">
|
||||
<img src="assets/ruos-animated.svg" alt="RuView — WiFi becomes spatial awareness, with Ruflo coordination and a ruOS sensing workspace" width="100%">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
## **See through walls with WiFi** ##
|
||||
|
||||
@@ -141,8 +147,11 @@ RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the
|
||||
```bash
|
||||
# Option 1: Docker (simulated data, no hardware needed)
|
||||
docker pull ruvnet/wifi-densepose:latest
|
||||
docker run -p 3000:3000 ruvnet/wifi-densepose:latest
|
||||
export RUVIEW_API_TOKEN=$(openssl rand -hex 32) # required; the container exits 64 without it
|
||||
docker run -p 127.0.0.1:3000:3000 -e RUVIEW_API_TOKEN ruvnet/wifi-densepose:latest
|
||||
# Open http://localhost:3000
|
||||
# ESP32 nodes in Docker also need a UDP source guard (RUVIEW_UDP_ALLOW);
|
||||
# see docs/user-guide.md "Receiving ESP32 frames in Docker".
|
||||
|
||||
# Option 2a: Live sensing with ESP32-S3 hardware ($9)
|
||||
# Flash firmware, provision WiFi, and start sensing:
|
||||
|
||||
@@ -20,6 +20,10 @@ func main() {
|
||||
exit(1)
|
||||
}
|
||||
let sample: [String: Any] = [
|
||||
// Always the connected link. macOS redacts ssid/bssid without
|
||||
// Location Services; rssi/noise/channel stay real, so mark the
|
||||
// sample as the connected link for the Rust adapter.
|
||||
"connected": true,
|
||||
"ssid": interface.ssid() ?? "",
|
||||
"bssid": interface.bssid() ?? "00:00:00:00:00:00",
|
||||
"channel": channel.channelNumber,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 960 540" role="img" aria-labelledby="title desc">
|
||||
<title id="title">RuView · WiFi becomes spatial awareness.</title><desc id="desc">RuView-focused animation showing WiFi sensors, spatial perception, Ruflo coordination and an illustrative ruOS sensing workspace. Room occupancy and actions are synthetic examples, not live measurements.</desc>
|
||||
<defs><radialGradient id="bg"><stop stop-color="#151518" /><stop offset="1" stop-color="#07080b" /></radialGradient><radialGradient id="aura"><stop stop-color="#edc65a" stop-opacity=".23" /><stop offset=".4" stop-color="#ba8823" stop-opacity=".07" /><stop offset="1" stop-color="#ba8823" stop-opacity="0" /></radialGradient><linearGradient id="gold" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#fff4b1" /><stop offset=".5" stop-color="#ebc456" /><stop offset="1" stop-color="#96702f" /></linearGradient><linearGradient id="panel" x2="0" y2="1"><stop stop-color="#191a20" /><stop offset="1" stop-color="#0d0e13" /></linearGradient><pattern id="grid" width="36" height="36" patternUnits="userSpaceOnUse"><circle cx="18" cy="18" r=".65" fill="#d4c78e" opacity=".22" /></pattern><filter id="bloom" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="4" /></filter><clipPath id="frame"><rect width="960" height="540" rx="18" /></clipPath><clipPath id="line1"><rect x="64" y="155" width="850" height="93" /></clipPath><clipPath id="line2"><rect x="64" y="244" width="850" height="100" /></clipPath><g id="crown" fill="url(#gold)"><path d="M0 5l8 7L15 0l7 12 8-7-4 23H4z" /><rect x="4" y="31" width="22" height="3" rx="1" /></g>
|
||||
<style>
|
||||
text{font-family:Arial,Helvetica,sans-serif;fill:#f4f2ed}.mono{font-family:monospace}.muted{fill:#888993}.gold{fill:#e8c565}.scene{opacity:0}.end{opacity:1}.wire{fill:none;stroke:#c2a155;stroke-width:1}.all{animation-duration:18s;animation-iteration-count:infinite;animation-timing-function:cubic-bezier(.22,.65,.3,1)}
|
||||
@keyframes intro{0%,17%{opacity:1;transform:translateY(0)}22%,94%{opacity:0;transform:translateY(-12px)}100%{opacity:1;transform:translateY(0)}}
|
||||
@keyframes network{0%,20%{opacity:0;transform:translateY(18px) scale(.98)}25%,44%{opacity:1;transform:translateY(0) scale(1)}49%,100%{opacity:0;transform:translateY(-15px) scale(1.02)}}
|
||||
@keyframes work{0%,46%{opacity:0;transform:translateY(18px)}51%,66%{opacity:1;transform:translateY(0)}71%,100%{opacity:0;transform:translateY(-14px)}}
|
||||
@keyframes finale{0%,69%{opacity:0}74%,92%{opacity:1}97%,100%{opacity:0}}
|
||||
.intro{animation-name:intro}.network{animation-name:network;transform-origin:480px 270px}.work{animation-name:work}.end{animation-name:finale}
|
||||
@keyframes reveal1{0%,69%{transform:translateY(100px)}77%,100%{transform:translateY(0)}}@keyframes reveal2{0%,73%{transform:translateY(110px)}81%,100%{transform:translateY(0)}}.headline1{animation-name:reveal1}.headline2{animation-name:reveal2}
|
||||
@keyframes breathe{50%{opacity:.6;transform:scale(1.12)}}.breathe{animation:breathe 4.5s ease-in-out infinite;transform-origin:480px 239px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}.orbit{animation:spin 18s linear infinite;transform-origin:480px 239px}
|
||||
@keyframes send{0%,9%{transform:translate(0,0)}14%,100%{transform:translate(-14px,-19px)}}.cursor{animation-name:send}
|
||||
@keyframes type{0%,2%{clip-path:inset(0 100% 0 0)}12%,100%{clip-path:inset(0 0 0 0)}}.typing{animation-name:type;animation-timing-function:steps(26,end)}
|
||||
@keyframes task{0%,49%{transform:translateY(25px);opacity:0}55%,100%{transform:translateY(0);opacity:1}}.task{animation-name:task}.task2{animation-delay:.3s}.task3{animation-delay:.6s}
|
||||
@keyframes grow{0%,54%{transform:scaleX(0)}63%,100%{transform:scaleX(1)}}.grow{animation-name:grow;transform-box:fill-box;transform-origin:left}
|
||||
@keyframes tick{0%,62%{opacity:0}65%,100%{opacity:1}}.tick{animation-name:tick}
|
||||
@keyframes trace{0%,22%{stroke-dashoffset:1}38%,100%{stroke-dashoffset:0}}.trace{stroke-dasharray:1;animation-name:trace}
|
||||
@keyframes drift{50%{transform:translateY(-10px);opacity:.8}}.dust{animation:drift 9s ease-in-out infinite}
|
||||
@media(prefers-reduced-motion:reduce){*{animation:none!important}.scene{opacity:0}.end{opacity:1}.moving{display:none}}
|
||||
|
||||
@keyframes signal{0%,13%{transform:translate(480px,205px)}20%{transform:translate(708px,301px)}26%,43%{transform:translate(480px,239px)}49%{transform:translate(215px,155px)}55%{transform:translate(479px,155px)}61%{transform:translate(743px,155px)}72%,93%{transform:translate(782px,307px)}100%{transform:translate(480px,205px)}}
|
||||
.signal{animation-name:signal}.signal-glow{animation:shimmer 3s ease-in-out infinite}@keyframes shimmer{50%{opacity:.45}}
|
||||
@keyframes haloExpand{0%,12%{opacity:0;transform:scale(.5)}16%{opacity:.7}23%,100%{opacity:0;transform:scale(2.5)}}.clickring{animation-name:haloExpand;transform-origin:708px 301px}
|
||||
@keyframes aurora{0%,100%{transform:translate(-30px,10px) rotate(-4deg)}50%{transform:translate(30px,-10px) rotate(4deg)}}.aurora{animation:aurora 18s ease-in-out infinite;transform-origin:480px 270px}
|
||||
@keyframes dash{to{stroke-dashoffset:-80}}.stream{stroke-dasharray:2 18;animation:dash 3s linear infinite}
|
||||
@keyframes count{0%,52%{opacity:0;transform:translateY(8px)}58%,100%{opacity:1;transform:translateY(0)}}.detail{animation-name:count}
|
||||
@keyframes buildLine{0%,51%{stroke-dashoffset:1}64%,100%{stroke-dashoffset:0}}.buildLine{stroke-dasharray:1;animation-name:buildLine}
|
||||
@keyframes underline{0%,78%{transform:scaleX(0)}85%,100%{transform:scaleX(1)}}.underline{animation-name:underline;transform-origin:66px 350px}
|
||||
@keyframes dock{0%,49%{opacity:0;transform:translateY(14px)}55%,100%{opacity:1;transform:translateY(0)}}.dock{animation-name:dock}
|
||||
@media(prefers-reduced-motion:reduce){.signal,.clickring{display:none}}
|
||||
|
||||
@keyframes vortexCW{to{transform:rotate(360deg)}}@keyframes vortexCCW{to{transform:rotate(-360deg)}}
|
||||
.vortex{transform-origin:480px 270px;animation:vortexCW 36s linear infinite}.counter{transform-origin:480px 270px;animation:vortexCCW 54s linear infinite}
|
||||
@keyframes current{to{stroke-dashoffset:-1200}}.current{animation:current 9s linear infinite}
|
||||
@keyframes bgEnergy{0%,12%,100%{opacity:.22}22%,40%{opacity:.6}52%,65%{opacity:.4}74%,84%{opacity:.7}94%{opacity:.28}}.bgEnergy{animation:bgEnergy 18s ease-in-out infinite}
|
||||
@keyframes wave{0%{opacity:0;transform:scale(.3)}20%{opacity:.35}80%{opacity:.07}100%{opacity:0;transform:scale(1.9)}}.wave{transform-origin:480px 270px;animation:wave 9s cubic-bezier(.15,.35,.5,1) infinite}
|
||||
@keyframes glimmer{0%,100%{opacity:.08}50%{opacity:.72}}.glimmer{animation:glimmer 6s ease-in-out infinite}
|
||||
@keyframes comet{0%{opacity:0;transform:translate(-160px,70px)}12%{opacity:.65}58%{opacity:.25}75%,100%{opacity:0;transform:translate(1060px,-300px)}}.comet{animation:comet 9s linear infinite}
|
||||
@keyframes beam{0%,100%{transform:translate(-150px,0) rotate(-12deg);opacity:0}35%{opacity:.18}65%{opacity:.07;transform:translate(1050px,0) rotate(-12deg)}80%{opacity:0}}.beam{animation:beam 18s ease-in-out infinite;transform-origin:center}
|
||||
@media(prefers-reduced-motion:reduce){.dynamic-bg{display:none}}
|
||||
|
||||
.all{animation-duration:24s}
|
||||
@keyframes work{0%,46%{opacity:0;transform:translateY(18px)}50%,72%{opacity:1;transform:translateY(0)}76%,100%{opacity:0;transform:translateY(-14px)}}
|
||||
@keyframes finale{0%,75%{opacity:0}79%,93%{opacity:1}97%,100%{opacity:0}}
|
||||
@keyframes reveal1{0%,75%{transform:translateY(100px)}82%,100%{transform:translateY(0)}}
|
||||
@keyframes reveal2{0%,78%{transform:translateY(110px)}85%,100%{transform:translateY(0)}}
|
||||
@keyframes signal{0%,13%{transform:translate(480px,205px)}20%{transform:translate(708px,301px)}26%,43%{transform:translate(480px,239px)}49%{transform:translate(803px,182px)}55%{transform:translate(803px,235px)}63%,72%{transform:translate(803px,288px)}80%,93%{transform:translate(782px,307px)}100%{transform:translate(480px,205px)}}
|
||||
@keyframes browserOpen{0%,48%{opacity:0;transform:translateY(20px) scale(.96)}53%,100%{opacity:1;transform:translateY(0) scale(1)}}.browserOpen{animation-name:browserOpen;transform-origin:380px 230px}
|
||||
@keyframes terminalOpen{0%,53%{opacity:0;transform:translateY(14px)}58%,100%{opacity:1;transform:translateY(0)}}.terminalOpen{animation-name:terminalOpen}
|
||||
@keyframes terminalText{0%,57%{opacity:0}60%,100%{opacity:1}}.terminalText{animation-name:terminalText}
|
||||
@keyframes done{0%,65%{opacity:0}69%,100%{opacity:1}}.done{animation-name:done}
|
||||
@keyframes pointerMove{0%,50%{transform:translate(350px,275px)}55%{transform:translate(529px,267px)}59%{transform:translate(570px,325px)}66%,100%{transform:translate(738px,346px)}}.desktopPointer{animation-name:pointerMove}
|
||||
@keyframes chartGrow{0%,53%{transform:scaleY(.05)}63%,100%{transform:scaleY(1)}}.chartGrow{animation-name:chartGrow;transform-box:fill-box;transform-origin:bottom}
|
||||
</style><radialGradient id="readability"><stop stop-color="#08090e" stop-opacity=".58" /><stop offset=".65" stop-color="#08090e" stop-opacity=".38" /><stop offset="1" stop-color="#08090e" stop-opacity="0" /></radialGradient><linearGradient id="beamGold"><stop stop-color="#e7bf56" stop-opacity="0" /><stop offset=".5" stop-color="#efd57f" stop-opacity=".45" /><stop offset="1" stop-color="#e7bf56" stop-opacity="0" /></linearGradient></defs><g clip-path="url(#frame)"><rect width="960" height="540" fill="url(#bg)" /><rect width="960" height="540" fill="url(#grid)" opacity=".35" /><ellipse cx="490" cy="250" rx="460" ry="320" fill="url(#aura)" />
|
||||
<g><g class="aurora" fill="none" stroke="#c6a457" opacity=".11"><ellipse cx="480" cy="260" rx="470" ry="156" transform="rotate(-18 480 260)" /><ellipse cx="480" cy="260" rx="480" ry="166" transform="rotate(-18 480 260)" /><ellipse cx="480" cy="260" rx="495" ry="180" transform="rotate(-18 480 260)" /></g></g><g><g class="dynamic-bg"><g class="bgEnergy"><rect class="beam" x="0" y="-120" width="160" height="850" fill="url(#beamGold)" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-0s" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-3s" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-6s" /><g class="vortex" fill="none"><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-0.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(19 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(19 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-1.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(38 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(38 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-3.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(57 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(57 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-4.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(76 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(76 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-6.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(95 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(95 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-7.5s" opacity=".65" /></g><g class="counter" fill="none"><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-0.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(-19 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(-19 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-1.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(-38 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(-38 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-3.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(-57 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(-57 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-4.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(-76 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(-76 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-6.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(-95 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(-95 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-7.5s" opacity=".65" /></g><circle cx="10.1" cy="303.9" r="1.3" fill="#ffe4a0Line truncated
|
||||
<g class="scene intro all"><circle cx="480" cy="205" r="125" fill="url(#aura)" /><circle cx="480" cy="205" r="28" stroke="#f3d576" stroke-width="1" fill="none" /><text x="480" y="126" text-anchor="middle" font-size="32" letter-spacing="-1">Your room is already sending signals.</text>
|
||||
<rect x="215" y="269" width="530" height="64" rx="12" fill="url(#panel)" stroke="#554b31" /><text x="239" y="307" font-size="17" class="mono typing all">Sense. Understand. Act.</text><rect x="689" y="282" width="38" height="38" rx="9" fill="url(#gold)" /><path d="M708 307v-13m-5 5 5-5 5 5" fill="none" stroke="#222018" stroke-width="2" stroke-linecap="round" />
|
||||
<path class="cursor all" d="M731 323l0 27 7-8 8 1z" fill="#faf8e7" stroke="#151515" stroke-width="1.4" />
|
||||
<text x="480" y="382" text-anchor="middle" font-size="11" letter-spacing="3" class="mono muted">WIFI SENSING. NO CAMERAS.</text><g><circle class="clickring all" cx="708" cy="301" r="25" fill="none" stroke="#fae29b" /></g><g><g fill="none" stroke="#e7c467" opacity=".65"><path d="M437 214Q480 161 523 214" /><path d="M420 205Q480 133 540 205" opacity=".4" /><path d="M404 196Q480 108 556 196" opacity=".2" /></g></g></g>
|
||||
<g class="scene network all"><text x="480" y="107" text-anchor="middle" font-size="30" letter-spacing="-.7">From radio signals to useful actions.</text>
|
||||
<g><path d="M480 239C548 323 639 357 735 350" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C420 328 311 358 225 350" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C520 143 640 148 714 193" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C440 148 322 143 247 193" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><path d="M480 239C440 148 322 143 247 193" class="wire" opacity=".2" /><path d="M480 239C440 148 322 143 247 193" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-0.0s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-0.0s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-1.2s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-1.2s" repeatCount="indefinite" /></circle></g><path d="M480 239C520 143 640 148 714 193" class="wire" opacity=".2" /><path d="M480 239C520 143 640 148 714 193" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-0.2s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-0.2s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-1.4s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-1.4s" repeatCount="indefinite" /></circle></g><path d="M480 239C420 328 311 358 225 350" class="wire" opacity=".2" /><path d="M480 239C420 328 311 358 225 350" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-0.4s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-0.4s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-1.6s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-1.6s" repeatCount="indefinite" /></circle></g><path d="M480 239C548 323 639 357 735 350" class="wire" opacity=".2" /><path d="M480 239C548 323 639 357 735 350" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-0.6000000000000001s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-0.6000000000000001s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-1.8s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-1.8s" repeatCount="indefinite" /></circle></g><circle cx="480" cy="239" r="98" fill="url(#aura)" class="breathe" /><circle cx="480" cy="239" r="57" fill="#111115" stroke="#6c5830" /><circle cx="480" cy="239" r="66" fill="none" stroke="#eac55e" stroke-opacity=".6" stroke-dasharray="38 170" class="orbit" /><use href="#crown" transform="translate(465 221)" /><rect x="147" y="169" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="164" cy="191" r="3" fill="#e8c667" /><text x="177" y="195" font-size="18">WiFi sensors</text><text x="177" y="212" font-size="8" class="mono muted" letter-spacing="1.8">CAPTURE</text><rect x="614" y="169" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="631" cy="191" r="3" fill="#e8c667" /><text x="644" y="195" font-size="18">RuView</text><text x="644" y="212" font-size="8" class="mono muted" letter-spacing="1.8">PERCEIVE</text><rect x="125" y="326" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="142" cy="348" r="3" fill="#e8c667" /><text x="155" y="352" font-sizeLine truncated
|
||||
<g class="scene work all"><g><text x="480" y="92" text-anchor="middle" font-size="28" letter-spacing="-.6">A sensing workspace, powered by RuView.</text>
|
||||
<rect x="58" y="113" width="844" height="331" rx="12" fill="#090c12" stroke="#6a5931" />
|
||||
<path d="M70 144H890" stroke="#292b31" /><circle cx="76" cy="129" r="3" fill="#dcbf65" /><circle cx="87" cy="129" r="3" fill="#5b5c64" /><circle cx="98" cy="129" r="3" fill="#5b5c64" /><text x="480" y="133" text-anchor="middle" class="mono muted" font-size="9">ruOS / Desktops / ruview-workspace</text>
|
||||
<rect x="59" y="145" width="125" height="287" fill="#0c0e13" />
|
||||
<use href="#crown" transform="translate(72 158) scale(.4)" /><text x="91" y="170" font-size="15" font-weight="700">ruOS</text>
|
||||
<rect x="67" y="209" width="107" height="25" rx="5" fill="#302819" stroke="#63512c" />
|
||||
<rect x="76" y="194" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="201" font-size="10" class="muted">Overview</text><rect x="76" y="222" width="7" height="6" rx="1" fill="none" stroke="#e1c577" /><text x="91" y="229" font-size="10" class="gold">Desktops</text><rect x="76" y="250" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="257" font-size="10" class="muted">Terminal</text><rect x="76" y="278" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="285" font-size="10" class="muted">Swarms</text><rect x="76" y="306" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="313" font-size="10" class="muted">Network</text><rect x="76" y="334" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="341" font-size="10" class="muted">Usage</text><rect x="76" y="362" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="369" font-size="10" class="muted">Settings</text><text x="200" y="169" font-size="14" font-weight="700">RuView workspace</text><circle cx="364" cy="165" r="3" fill="#b9cb91" /><text x="374" y="169" font-size="9" class="muted">Running</text>
|
||||
<text x="882" y="167" text-anchor="end" font-size="8" class="mono gold">SYNTHETIC DEMO</text>
|
||||
<rect x="197" y="183" width="469" height="244" rx="7" fill="#14131a" stroke="#32313d" /><path d="M197 373Q370 180 666 285M197 330Q440 390 666 210" fill="none" stroke="#b49548" opacity=".18" />
|
||||
<g class="browserOpen all"><rect x="209" y="195" width="308" height="163" rx="7" fill="#191d26" stroke="#5a5961" />
|
||||
<rect x="209" y="195" width="308" height="24" rx="7" fill="#262a33" /><text x="221" y="211" font-size="8" class="mono muted">RuView / Room overview</text><circle cx="503" cy="207" r="2" fill="#e3c477" />
|
||||
<text x="225" y="241" font-size="13" font-weight="700">Room activity</text><text x="225" y="257" font-size="9" class="muted">Synthetic scene · Presence and motion</text>
|
||||
<rect x="224" y="329" width="83" height="16" rx="4" fill="#4a3d22" /><text x="233" y="340" font-size="8" class="gold">Sensor connected</text><g><path d="M229 270H494V318H229zM390 270v18m0 12v18" fill="#121921" stroke="#727781" stroke-width="1.5" /><path d="M235 278h34v15h-34zM464 280h22v24h-22z" fill="#303843" stroke="#707786" /><circle cx="285" cy="300" r="4" fill="#f0cd70" /><circle cx="285" cy="300" r="16" fill="none" stroke="#d8b353" opacity=".4" /><circle cx="326" cy="287" r="5" fill="#b7cbd0" /><path d="M326 293v12m-7-8h14m-7 8-5 8m5-8 5 8" fill="none" stroke="#b7cbd0" stroke-width="2" /><path d="M292 290Q310 273 326 287M294 306Q312 312 326 302" fill="none" stroke="#e1bd5f" stroke-dasharray="3 5" class="stream" /></g></g>
|
||||
<g class="terminalOpen all"><rect x="366" y="287" width="284" height="117" rx="6" fill="#090c12" stroke="#777061" /><rect x="367" y="288" width="282" height="21" rx="5" fill="#25272f" /><text x="380" y="302" font-size="9" class="mono">Terminal</text>
|
||||
<text x="380" y="324" font-size="9" class="mono gold">› Room event received</text><g class="terminalText all"><text x="380" y="343" font-size="9" class="mono muted">✓ Presence detected (demo)</text><text x="380" y="358" font-size="9" class="mono muted">✓ Rule checked</text></g><text x="380" y="384" font-size="10" class="mono gold done all">✓ Action ready for review</text></g>
|
||||
<rect x="680" y="183" width="207" height="244" rx="7" fill="#12151d" stroke="#33333c" /><text x="695" y="204" font-size="12" font-weight="700">Ruflo agent team</text><path d="M694 216h178" stroke="#34323a" />
|
||||
<circle cx="699" cy="235" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="239" font-size="11">Sense</text><text x="713" y="254" font-size="8" class="muted">Read room events from RuView</text><circle cx="699" cy="288" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="292" font-size="11">Evaluate</text><text x="713" y="307" font-size="8" class="muted">Check the configured rule</text><circle cx="699" cy="341" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="345" font-size="11">Act</text><text x="713" y="360" font-size="8" class="muted">Request an approved action</text><g class="done all"><rect x="693" y="381" width="181" height="29" rx="5" fill="#332b1b" stroke="#89713b" /><text x="784" y="400" text-anchor="middle" font-size="10" class="gold">✓ Room event processed</text></g>
|
||||
<g class="desktopPointer all"><path d="M0 0v20l5-6 8 1z" fill="#fff2ce" stroke="#18181a" stroke-width="1" /></g>
|
||||
<text x="480" y="469" text-anchor="middle" class="gold" font-size="14">Example: room activity → rule check → action review.</text></g></g><g class="scene end all"><ellipse cx="810" cy="290" rx="260" ry="240" fill="url(#aura)" />
|
||||
<text x="66" y="133" class="mono gold" font-size="11" letter-spacing="3">RUVIEW PERCEPTION. RUFLO COORDINATION.</text>
|
||||
<g clip-path="url(#line1)"><text class="headline1 all" x="61" y="233" font-size="72" font-weight="700" letter-spacing="-3">WiFi becomes</text></g>
|
||||
<g clip-path="url(#line2)"><text class="headline2 all" x="61" y="322" font-size="72" font-weight="700" letter-spacing="-3">spatial awareness<tspan class="gold">.</tspan></text></g>
|
||||
<path d="M66 382H894" stroke="#3b3528" /><text x="66" y="420" class="mono gold" font-size="17">ruos.cognitum.one</text><text x="892" y="420" text-anchor="end" font-size="10" class="mono muted" letter-spacing="2">BY COGNITUM</text><g><path class="underline all" d="M66 350h310" stroke="url(#gold)" stroke-width="2" /></g></g>
|
||||
<path d="M40 490H920" stroke="#26242a" /><text x="40" y="513" class="mono muted" font-size="9" letter-spacing="1.5">RUVIEW + RUFLO + RUOS</text><circle cx="853" cy="509" r="2.5" fill="#dfbf67" /><text x="866" y="512" class="mono gold" font-size="9">READY</text><g><g class="signal all"><circle r="45" fill="url(#aura)" class="signal-glow" /><circle r="10" fill="#ebc65d" opacity=".5" filter="url(#bloom)" /><circle r="4.5" fill="#ffe5a1" /><circle r="8" fill="none" stroke="#e9c56c" opacity=".3" /></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 37 KiB |
@@ -37,6 +37,31 @@ services:
|
||||
# volumes: ["/path/to/models:/app/models"]
|
||||
# MODELS_DIR=/app/models
|
||||
- MODELS_DIR=${MODELS_DIR:-data/models}
|
||||
# Auth (issue #864). The entrypoint refuses to start (exit 64) unless
|
||||
# RUVIEW_API_TOKEN is set. Export it before `docker compose up`:
|
||||
# export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
|
||||
# Bare names pass the host value through only when it is set.
|
||||
- RUVIEW_API_TOKEN
|
||||
# ESP32 UDP ingest (ADR-296, issue #2089). The UDP receiver defaults to
|
||||
# 127.0.0.1, which cannot receive from the published 5005/udp port.
|
||||
# Set ONE source guard and the entrypoint binds 0.0.0.0:
|
||||
# RUVIEW_UDP_ALLOW=<node-ip-or-cidr> restrict sources (preferred)
|
||||
# RUVIEW_UDP_INSECURE_LAN=true accept the spoofing risk
|
||||
# Docker Desktop and OrbStack on macOS rewrite the source address to the
|
||||
# bridge gateway, so a LAN-subnet allowlist drops every frame. There,
|
||||
# allow the gateway instead:
|
||||
# docker network inspect <project>_default \
|
||||
# --format '{{(index .IPAM.Config 0).Gateway}}'
|
||||
# RUVIEW_UDP_ALLOW=<gateway>/32
|
||||
# The gateway admits anything that reaches the published port, so keep
|
||||
# 5005/udp off untrusted networks.
|
||||
- RUVIEW_UDP_ALLOW
|
||||
- RUVIEW_UDP_INSECURE_LAN
|
||||
- RUVIEW_UDP_BIND
|
||||
# Browsing the UI from another machine also needs the host name or IP
|
||||
# in SENSING_ALLOWED_HOSTS (otherwise 421), and the TCP ports above
|
||||
# published beyond 127.0.0.1.
|
||||
- SENSING_ALLOWED_HOSTS
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
# Default is `auto`. Set CSI_SOURCE=simulated when you want
|
||||
# fake data tagged as such; never set it implicitly.
|
||||
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
|
||||
# RUVIEW_UDP_ALLOW / RUVIEW_UDP_INSECURE_LAN — ADR-296 UDP source guard.
|
||||
# Setting either makes the entrypoint bind UDP on 0.0.0.0 so
|
||||
# ESP32 frames on the published 5005/udp port can arrive.
|
||||
# RUVIEW_UDP_BIND — explicit UDP bind; overrides the choice above.
|
||||
set -e
|
||||
|
||||
# ── Issue #864: fail-closed on default posture ───────────────────────────────
|
||||
@@ -34,8 +38,8 @@ set -e
|
||||
#
|
||||
# Escape hatches (operator must opt in explicitly):
|
||||
# * Set RUVIEW_API_TOKEN to a strong secret → auth enabled on /api/v1/*.
|
||||
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 → preserves the pre-fix behaviour;
|
||||
# only safe on an isolated trust boundary.
|
||||
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 (or true/yes/on, any case) →
|
||||
# preserves the pre-fix behaviour; only safe on an isolated trust boundary.
|
||||
# * Set RUVIEW_BIND_ADDR to a loopback / private interface → unauth is fine
|
||||
# when the socket isn't reachable. The auto-bind nudges toward 127.0.0.1.
|
||||
#
|
||||
@@ -45,7 +49,12 @@ set -e
|
||||
case "${1:-}" in
|
||||
cog-ha-matter|ha-matter|homecore|homecore-server) ;;
|
||||
*)
|
||||
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" != "1" ]; then
|
||||
# Same spellings the sensing-server accepts for its boolean env flags.
|
||||
case "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" in
|
||||
1|[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Oo][Nn]) __allow_unauth=1 ;;
|
||||
*) __allow_unauth=0 ;;
|
||||
esac
|
||||
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "$__allow_unauth" != "1" ]; then
|
||||
# If the operator hasn't overridden the bind, refuse outright on
|
||||
# the default 0.0.0.0. If they've nailed it to loopback (or a
|
||||
# specific private address they trust), let it run.
|
||||
@@ -97,6 +106,50 @@ esac
|
||||
# server binary so users can just pass flags:
|
||||
# docker run <image> --source esp32 --tick-ms 500
|
||||
if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then
|
||||
# ── Issue #2089: UDP CSI ingest posture inside a container ───────────────
|
||||
# ADR-296 defaults the UDP receiver to 127.0.0.1. Inside a container that
|
||||
# bind can never receive from a published port, so no node ever appears.
|
||||
# We bind 0.0.0.0 only when the operator has configured an ADR-296 guard
|
||||
# (RUVIEW_UDP_ALLOW or RUVIEW_UDP_INSECURE_LAN); without one the server
|
||||
# would refuse a routable bind, so we keep loopback and say why instead.
|
||||
# An explicit RUVIEW_UDP_BIND or --udp-bind always wins.
|
||||
# The binary parses RUVIEW_UDP_INSECURE_LAN itself (1/yes/on/true, any
|
||||
# case); here we only need to know whether it is truthy to pick the bind.
|
||||
__insecure_lan=0
|
||||
case "$(printf '%s' "${RUVIEW_UDP_INSECURE_LAN:-}" | tr 'A-Z' 'a-z')" in
|
||||
1|true|yes|on) __insecure_lan=1 ;;
|
||||
esac
|
||||
__udp_bind_arg=0
|
||||
__udp_guard_arg=0
|
||||
for __arg in "$@"; do
|
||||
case "$__arg" in
|
||||
--udp-bind|--udp-bind=*) __udp_bind_arg=1 ;;
|
||||
--udp-allow|--udp-allow=*|--udp-insecure-lan) __udp_guard_arg=1 ;;
|
||||
esac
|
||||
done
|
||||
if [ -z "${RUVIEW_UDP_BIND:-}" ] && [ "$__udp_bind_arg" = 0 ]; then
|
||||
if [ -n "${RUVIEW_UDP_ALLOW:-}" ] || [ "$__insecure_lan" = 1 ] \
|
||||
|| [ "$__udp_guard_arg" = 1 ]; then
|
||||
export RUVIEW_UDP_BIND=0.0.0.0
|
||||
echo "[entrypoint] UDP CSI receiver: binding 0.0.0.0 inside the container" >&2
|
||||
echo "[entrypoint] (a source guard is set: RUVIEW_UDP_ALLOW or RUVIEW_UDP_INSECURE_LAN)." >&2
|
||||
else
|
||||
case "${CSI_SOURCE:-auto}" in
|
||||
simulated|simulate) ;;
|
||||
*)
|
||||
echo "[entrypoint] NOTE: UDP CSI receiver stays on 127.0.0.1 (ADR-296 default)." >&2
|
||||
echo "[entrypoint] Inside a container that bind cannot receive ESP32 frames from" >&2
|
||||
echo "[entrypoint] a published 5005/udp port, so no node will appear. To ingest:" >&2
|
||||
echo "[entrypoint] -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> (restrict sources), or" >&2
|
||||
echo "[entrypoint] -e RUVIEW_UDP_INSECURE_LAN=true (accept spoofing risk)" >&2
|
||||
echo "[entrypoint] Docker Desktop/OrbStack on macOS rewrite the source to the bridge" >&2
|
||||
echo "[entrypoint] gateway, so the allowlist must name <gateway>/32, not the LAN subnet." >&2
|
||||
echo "[entrypoint] See https://github.com/ruvnet/RuView/issues/2089" >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
set -- /app/sensing-server \
|
||||
--source "${CSI_SOURCE:-auto}" \
|
||||
--tick-ms 100 \
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-03-01 |
|
||||
| **Updated** | 2026-10-01: macOS 27 also redacts SSID without Location Services; the connected link is kept with a synthetic id, and `auto`+wifi keeps UDP bound (see Amendment 1) |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **ORCA** — OS-native Radio Channel Acquisition |
|
||||
| **Relates to** | ADR-013 (Feature-Level Sensing Commodity Gear), ADR-022 (Windows WiFi Enhanced Fidelity), ADR-014 (SOTA Signal Processing), ADR-018 (ESP32 Dev Implementation) |
|
||||
@@ -313,3 +314,22 @@ All verification on Mac Mini (M2 Pro, macOS 26.3).
|
||||
- ADR-022: Windows WiFi Enhanced Fidelity (analogous platform adapter)
|
||||
- ADR-013: Feature-Level Sensing from Commodity Gear
|
||||
- Issue [#56](https://github.com/ruvnet/wifi-densepose/issues/56): macOS support request
|
||||
|
||||
---
|
||||
|
||||
## Amendment 1 (2026-10-01): SSID is also redacted; keep the connected link
|
||||
|
||||
**Observed on macOS 27 (M-series Mac, CoreWLAN, unsigned CLI helper).** Without Location Services, `mac_wifi --scan-once` returns `"ssid":""` and `"bssid":"00:00:00:00:00:00"`, while `rssi`, `noise`, `channel` and `tx_rate` are real. The §1 assumption that "SSID + RSSI + channel" survive no longer holds. An unsigned CLI helper never calls `CLLocationManager`, so it never appears in Location Services and cannot be granted access.
|
||||
|
||||
Before this amendment, `resolve_bssid` abstained when both identities were blank (to avoid merging unrelated networks), so every macOS scan was dropped and `--source wifi` produced nothing, with no error.
|
||||
|
||||
**Change.**
|
||||
1. `--scan-once` marks its single sample `"connected": true`. It is always the one connected link, so it cannot collide with another network.
|
||||
2. A redacted sample marked `connected` gets a stable, locally administered synthetic BSSID derived from a reserved key plus the channel. The SSID stays empty; none is invented. Unmarked redacted lines still abstain.
|
||||
3. `--source auto` that resolves to host WiFi now also binds the UDP receiver, and `wifi_task` yields while the source is a live `esp32`. ESP32 nodes that start after the 2 s boot probe still promote the source, matching the #1004 rule for the simulator.
|
||||
4. A missing helper logs one clear error with the build command instead of a warning on every tick.
|
||||
|
||||
**Measured (2026-10-01, this change):** `--source wifi` → source `wifi:unnamed`, sensing ticks advance. `--source auto` on a quiet port → `wifi` with `udp_receiver=true`; relaying real ADR-018 frames promoted the source to `esp32`, with all 5 nodes listed.
|
||||
|
||||
**Still open:** real BSSID/SSID would need a signed `.app` helper with `NSLocationWhenInUseUsageDescription` that requests authorization. The helper still lives under `archive/v1` with no build step.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|-------|-------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-03-02 |
|
||||
| **Updated** | 2026-10-04 — multistatic fusion combines phase across nodes only when every frame declares the same shared phase reference; independent ESP32 nodes and the amplitude-only server bridge fuse amplitude only and report the reason (`phase_fusion` in `/health/ready`, issue #1752). |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **RuvSense** -- RuVector-Enhanced Sensing for Multistatic Fidelity |
|
||||
| **Relates to** | ADR-012 (ESP32 Mesh), ADR-014 (SOTA Signal Processing), ADR-016 (RuVector Training), ADR-017 (RuVector Signal+MAT), ADR-018 (ESP32 Implementation), ADR-024 (AETHER Embeddings), ADR-026 (Survivor Track Lifecycle), ADR-027 (MERIDIAN Generalization) |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | **Accepted** (MQTT track P1–P7 + P8a + P9 + P10 shipped 2026-05-23 in PR #778, 410 lib tests, witness bundle VERIFIED) / **Proposed** (Matter SDK wiring P8b deferred to v0.7.1 per §9.10) |
|
||||
| **Date** | 2026-05-23 |
|
||||
| **Date** | 2026-05-23 (amended 2026-10-02: P4.5 status note, §11) |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **HA-DISCO** (MQTT) + **HA-FABRIC** (Matter) + **HA-MIND** (semantic primitives) |
|
||||
| **Relates to** | ADR-018 (CSI binary frame format), ADR-021 (ESP32 vitals), ADR-031 (RuView sensing-first), ADR-039 (edge vitals packet 0xC511_0002), ADR-079 (camera ground-truth), ADR-103 (cog-person-count), ADR-110 (ESP32-C6 firmware), ADR-114 (cog-quantum-vitals) |
|
||||
@@ -440,7 +440,7 @@ This means: **adding a new primitive is one file change**. No MQTT schema rev, n
|
||||
| **P2** | HA discovery message emitter. New module `mqtt_discovery.rs`. Emits all entity `config` topics on connect + every `--mqtt-refresh-secs`. Schema-validated against HA's published JSON schema. | pending |
|
||||
| **P3** | State publication. Subscribe to internal `tokio::broadcast` channel (the one `tx.send(json)` writes to on line 3983 of `main.rs`). Translate `edge_vitals` / `sensing_update` / `pose_data` messages into per-entity state payloads. Apply rate-limit + privacy-mode filters. | pending |
|
||||
| **P4** | Integration tests: dockerised mosquitto in CI (extend `.github/workflows/firmware-qemu.yml` pattern), schema-validate every emitted config against HA's `homeassistant/components/mqtt` JSON schemas (pin to a tested HA version). Add a smoke test that brings up sensing-server in `--source mock --mqtt`, subscribes with `paho-mqtt` test client, asserts on entity creation. | pending |
|
||||
| **P4.5** | **Semantic inference layer (HA-MIND).** New module `semantic_inference.rs` implementing the 10 v1 primitives from §3.12. Output broadcast channel consumed by both MQTT publisher (P3) and Matter bridge (P8). Per-primitive precision/recall baselines published to `docs/integrations/semantic-primitives-metrics.md`. Unit tests per FSM + integration tests via replay of ADR-079 paired captures. | pending |
|
||||
| **P4.5** | **Semantic inference layer (HA-MIND).** New module `semantic_inference.rs` implementing the 10 v1 primitives from §3.12. Output broadcast channel consumed by both MQTT publisher (P3) and Matter bridge (P8). Per-primitive precision/recall baselines published to `docs/integrations/semantic-primitives-metrics.md`. Unit tests per FSM + integration tests via replay of ADR-079 paired captures. | partial — 6 of 10 primitives on the live MQTT publisher; see §11 |
|
||||
| **P5** | Docs: new `docs/integrations/home-assistant.md` with screenshots of the HA UI after auto-discovery completes, example HA dashboard YAML (Lovelace card configs), 8 starter blueprints from §3.12.2 (distress notify, wake routine, hallway dim, elderly anomaly alert, meeting lights, bathroom fan, fall-risk escalate, auto-arm security), and the raw-channel example automations: "turn on hall light when presence ON", "send notification on fall_detected event", "log HR/BR to InfluxDB". | pending |
|
||||
| **P6** | Ship `--mqtt` in the next sensing-server release (target: v0.7.0). Demo end-to-end on `cognitum-v0` against a Mosquitto add-on running on a Home Assistant OS install. Update README hardware-options table with "Works with Home Assistant" badge. | pending |
|
||||
| **P7** | Matter Bridge spike: build a throwaway prototype with `matter-rs` exposing one `OccupancySensor` endpoint + one `GenericSwitch` for fall. Pair against Apple Home, Google Home, and HA's Matter integration. Decision gate: if pairing works on all three, proceed to P8; if blocked, switch to chip-tool FFI and re-spike. | pending |
|
||||
@@ -667,4 +667,57 @@ Empty as of 2026-05-23. New questions discovered during implementation will be f
|
||||
|
||||
---
|
||||
|
||||
## 11. Status note: P4.5 semantic states on the live publisher (2026-10-02)
|
||||
|
||||
Issues [#2085](https://github.com/ruvnet/RuView/issues/2085) and [#2093](https://github.com/ruvnet/RuView/issues/2093) found that discovery announced 20 entities per device while only 6 ever received state, that `presence/state` was published on every CSI frame, and that HA device ids changed on every restart. The FSMs in `crate::semantic` existed but had no caller outside their tests. This note records what is now wired and what is not.
|
||||
|
||||
### 11.1 What changed
|
||||
|
||||
- **Semantic primitives run on the live path.** `mqtt::planner` holds one `SemanticBus` per node, sampled at 1 Hz (the rate the FSM constants assume). The bus feeds only the MQTT publisher. Matter does not consume it yet, so the "output broadcast channel shared with Matter" in P4.5 is not built.
|
||||
- **Announce only what has a source.** Six primitives are announced: `room_active`, `no_movement`, `elderly_inactivity_anomaly`, `someone_sleeping`, `possible_distress` and `fall_risk_elevated`. Four are not: `meeting_in_progress` needs a 1–20 % motion level, but per-node motion is a three-level classification; `bathroom_occupied`, `bed_exit` and `multi_room_transition` need zones, and the broadcast carries none (the §3.12.5 zone file is not implemented). For the same reason `zone_occupancy` (§3.1) and `pose` are not announced: `pose_keypoints` is never populated on the broadcast, and `persons[].keypoints` are synthesized.
|
||||
- **Per-entity availability (§3.6).** An announced entity is `online` only while its input exists: vitals while the calibration-gated estimate is published, `fall` while the node sends `edge_vitals`, `motion_energy` and `rssi` while present, and semantic primitives after the 60 s warmup (sleeping and distress also need breathing or heart rate, fall risk needs the fall detector). Otherwise it is `offline` and no state is sent. Transitions publish at once; all availability topics are re-sent every 30 s.
|
||||
- **No default values.** `motion_energy` was always `0.0` (MEASURED: 176 of 176 samples) because the bridge never set it. It now carries the node's `motion_band_power`, or the room value, or nothing. `fall` is driven by the rising edge of `edge_vitals.fall_detected`. Only `sensing_update` produces snapshots, which removes the phantom aggregate device that `edge_vitals` frames created. `present_still` now maps to 0 % motion instead of 100 %.
|
||||
- **Rates (§3.7).** Binary states publish on change, plus a 60 s retained heartbeat, as §3.5 already specified. Sensors keep their configured rates.
|
||||
- **Stable device identity (§3.8).** The default client id is now `wifi-densepose-<8 hex>`, persisted in `<data-dir>/mqtt_client_id`. §3.8 specified `wifi-densepose-<hostname>`; a persisted random id avoids a hostname dependency and survives hostname changes. `--mqtt-client-id` still overrides it.
|
||||
|
||||
### 11.2 Evidence
|
||||
|
||||
Unit tests (CODE-DERIVED, run with `cargo test -p wifi-densepose-sensing-server --features mqtt --lib -- mqtt:: semantic::`): `mqtt::planner::tests` covers the entity → state mapping, unavailable-without-source, warmup, on-change with heartbeat at a 73 frame/s input, sensor rates, reset and privacy mode. `mqtt::bridge::tests` covers message-type filtering, fall edges and motion energy. `mqtt::config::tests` covers client-id persistence across two "restarts" in a temp dir, override and malformed-file recovery.
|
||||
|
||||
Live measurement (MEASURED, 2026-10-02): one ESP32 node streaming CSI and `edge_vitals` over UDP to a macOS host; `sensing-server --source esp32 --mqtt` against `eclipse-mosquitto:2` in Docker on a non-default port; every topic captured with a `paho-mqtt` subscriber on `#`. "Before" is `03a89793` (main), "after" is this change, built `--release --features mqtt`, same command and window.
|
||||
|
||||
```bash
|
||||
# Broker: mosquitto.conf = "listener 1883 0.0.0.0 / allow_anonymous true / persistence false"
|
||||
docker run -d --rm -p 127.0.0.1:18830:1883 \
|
||||
-v "$PWD/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro" eclipse-mosquitto:2
|
||||
# Run A (180 s, then SIGTERM) and run B (restart, 45 s) share one --data-dir.
|
||||
# A paho-mqtt subscriber on '#' starts 2 s before each run.
|
||||
RUVIEW_API_TOKEN=<random> sensing-server --source esp32 --udp-port 5006 \
|
||||
--udp-bind 0.0.0.0 --udp-allow <node-subnet> --http-port 3000 --ws-port 3001 \
|
||||
--no-mdns --data-dir <dir> --mqtt --mqtt-host 127.0.0.1 --mqtt-port 18830
|
||||
```
|
||||
|
||||
| Measure (180 s window unless noted) | Before (`03a89793`) | After |
|
||||
|---|---|---|
|
||||
| HA devices announced in run A | 2 (one phantom aggregate + `-node1`) | 1 (`-node1`) |
|
||||
| Entities announced per device | 20 | 15 |
|
||||
| Entities that received state (node device) | 6 | 10 |
|
||||
| Announced entities without state that still reported `online` | 14 | 1 (`fall`: an event, and no fall occurred) |
|
||||
| `presence/state` messages (node device) | 6,814 (37.9 / s) | 4 (one per change, plus 60 s heartbeat) |
|
||||
| `motion_energy` values | `0.0` in 176 of 176 | node band power, 177 samples |
|
||||
| Device id, run A → run B (restart, same `--data-dir`) | `wifi-densepose-<pid A>` → `<pid B>` (4 devices on the broker after B) | same persisted id in both runs (1 device) |
|
||||
| `offline` published on SIGTERM | no | no (unchanged; see 11.3) |
|
||||
| Same, plus `--privacy-mode` (75 s, fresh `--data-dir`) | — | 13 entities announced; no `heart_rate`, `breathing_rate` or `pose` topic for that device |
|
||||
|
||||
After the change, five announced entities had no state: `heart_rate` and `breathing_rate`, because no calibration had been run; `someone_sleeping` and `possible_distress`, which depend on them; and `fall`, which is an event and no fall occurred. The first four were `offline`. `fall` was `online`, because the node sent `edge_vitals`. Before the change all 14 entities without state still reported `online`. Only one node was streaming during this measurement. #2085 used five nodes and measured about 73 `presence` messages per second per node.
|
||||
|
||||
### 11.3 Not done
|
||||
|
||||
- Semantic precision/recall on real captures. The figures in `semantic-primitives-metrics.md` have no reproducer in the repository and stay CLAIMED.
|
||||
- Zone and threshold files, `--semantic*` / `--no-semantic` flags on `sensing-server`, the `reason` attribute (§3.12.4), and `semantic_events.jsonl`.
|
||||
- MQTT last-will and offline-on-shutdown (§3.6). The server registers no LWT and SIGTERM does not publish `offline`, so availability stays as last published after the server stops.
|
||||
- Matter consumption of the semantic states.
|
||||
|
||||
---
|
||||
|
||||
*ADR-115 is the integration story that turns RuView from "another sensing platform" into "drop-in upgrade for any HA install **and** any Matter-controller home." MQTT carries the rich, differentiated telemetry; Matter carries the standardised subset across every controller ecosystem. Numbers 111 and 112 remain reserved per the project ADR-numbering policy.*
|
||||
@@ -66,10 +66,64 @@ hardware claims.
|
||||
timestamps, sequence behavior, calibration, maximum size, and report rate.
|
||||
- Validate OpenWrt One first, then BPI-R3 4x4, before considering MT7996/MLO.
|
||||
|
||||
## Amendment 1 — vendor CSI on MT7981, still unvalidated
|
||||
|
||||
- **Date**: 2026-09-19, recorded here 2026-10-01
|
||||
- **Status**: proposed. One board model (two units). The hardware gates below are still open.
|
||||
|
||||
The 2026-07-18 decision said the public `mt76` tree has no supported CSI export,
|
||||
and that a firmware change might be required before physical CSI exists. Both
|
||||
statements need a narrower reading.
|
||||
|
||||
Mainline `openwrt/mt76` still has no CSI export. MediaTek's public OpenWrt feed
|
||||
does: vendor id `0x0ce7`, MCU event `0xc2`, patch
|
||||
`1001-wifi-mt76-mt7915-csi-implement-csi-support`. On a Wavlink WL-WN586X3
|
||||
Rev A (MT7981BA + MT7976CN + MT7531AE) that patch, rebased onto the `mt76`
|
||||
revision pinned by OpenWrt 24.10.8, produced complex samples without a firmware
|
||||
blob swap. The `0xc2` hook is already in the MT7981 firmware that the OpenWrt
|
||||
24.10.8 image carries. The
|
||||
rebased patch and the two userspace fixes are in `firmware/openwrt-wn586x3/`.
|
||||
|
||||
What the first capture was: one record, 2.4 GHz, 20 MHz, legacy OFDM, 64
|
||||
subcarriers, a single chain. CSI arrived only while a client sent frames.
|
||||
The rate is client traffic, not a sensor sample clock. The capture is not in
|
||||
this repository.
|
||||
|
||||
What it was not:
|
||||
|
||||
- Not a calibration, a sequence check, a timestamp check, or a repeatability run.
|
||||
The label on the wire is `mediatek:physical-unvalidated`. Plain `mediatek` stays
|
||||
reserved for a frame that has passed those gates.
|
||||
- Not a 4x4 array. Each radio is two chains. The four antennas are a 2.4 GHz pair
|
||||
and a 5 GHz pair. Two routers are two receivers. Cross-box phase is not a
|
||||
shared local oscillator, so it is not a beam.
|
||||
- Not the OpenWrt One or the BPI-R3. Those remain the upstream-friendly targets
|
||||
in the original decision. The WN586X3 is the board this path was actually run on.
|
||||
|
||||
`wifi-densepose-mtk-bridge` translates `mt76-vendor` JSON, or an MtkCSIdump
|
||||
datagram, into ADR-267 MTC1 and sends it to the sensing server. The server keeps
|
||||
one snapshot and a bounded ring of per-subcarrier amplitude and phase per
|
||||
`device_id`. It runs `mediatek-amplitude-heuristic-v0` on mean and peak amplitude,
|
||||
and `activity-index-v0` on per-subcarrier fluctuation. Both are `CLAIMED`. Neither
|
||||
is the ESP32 vital-sign path.
|
||||
|
||||
One measured property supports further work, and nothing more. Offline, on six
|
||||
local dump captures from two units (2.4 GHz, HT20, chains rx0 and rx1 of transmit
|
||||
stream 0), the cross-chain phase `arg(H0·conj(H1))`, summed over subcarriers,
|
||||
moved 0.014-0.039 rad (p50) between adjacent frames for well-sampled
|
||||
transmitters. Raw per-chain phase moved about 1.56 rad (p50), which is what
|
||||
uniformly random phase gives (MEASURED; reproducer
|
||||
`scripts/mediatek-csi-phase-stability.py`). That shows a stable observable inside
|
||||
one radio. It does not show that the observable tracks people.
|
||||
|
||||
The operator guide is [`docs/mediatek-router-csi.md`](../mediatek-router-csi.md).
|
||||
|
||||
## Links
|
||||
|
||||
- [ADR-123: BFLD capture path](ADR-123-bfld-capture-path-nexmon-and-esp32.md)
|
||||
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
|
||||
- [ADR-267: MediaTek MIMO CSI wire protocol](ADR-267-mediatek-mimo-csi-wire-protocol.md)
|
||||
- [MediaTek router CSI guide](../mediatek-router-csi.md)
|
||||
- [upstream mt76](https://github.com/openwrt/mt76)
|
||||
- [OpenWrt One](https://openwrt.org/toh/openwrt/one)
|
||||
- [MediaTek OpenWrt feed](https://git01.mediatek.com/openwrt/feeds/mtk-openwrt-feeds/)
|
||||
@@ -34,6 +34,81 @@ Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
|
||||
- Flags distinguish calibrated, saturated, time-synchronized, dropped-predecessor,
|
||||
and synthetic frames. Synthetic provenance cannot be cleared by downstream code.
|
||||
|
||||
## Amendment 1 — PPDU types `Legacy` and `Unknown`
|
||||
|
||||
- **Date**: 2026-09-19
|
||||
- **Status**: proposed. Demonstrated on one WL-WN586X3 capture. Not a maintainer acceptance.
|
||||
- **Scope**: `PpduType` discriminants only. The 72-byte header layout, CRC,
|
||||
payload ordering, flags, and caps are unchanged.
|
||||
|
||||
### What changed
|
||||
|
||||
`PpduType` gains two variants:
|
||||
|
||||
| Value | Name | Meaning |
|
||||
|---|---|---|
|
||||
| 6 | `Legacy` | Pre-HT: 802.11b CCK or 802.11a/g OFDM |
|
||||
| 7 | `Unknown` | A PPDU format the producer could not classify |
|
||||
|
||||
Values 1-5 (`Ht`, `Vht`, `HeSu`, `HeMu`, `Eht`) are untouched.
|
||||
|
||||
### Why
|
||||
|
||||
The original set assumed every CSI report would be HT or later. The first real
|
||||
MT7981 capture disproved that. A Wavlink WL-WN586X3 (MT7981B + MT7976C) running
|
||||
an OpenWrt 24.10.8 image built with the patches in `firmware/openwrt-wn586x3/`
|
||||
reported `rx_mode = 1` (`MT_PHY_TYPE_OFDM`) for a client associated on 2.4 GHz
|
||||
channel 6 at 20 MHz. The bridge had no way to express it and dropped the frame.
|
||||
|
||||
That was a real data loss, not a corner case. MediaTek's CSI driver treats legacy
|
||||
OFDM as a first-class mode: `mt7915_vendor_csi_tone_mask` skips only
|
||||
`MT_PHY_TYPE_CCK`, and its `mode_map` is keyed by `MT_PHY_TYPE_OFDM`, `_HT`,
|
||||
`_VHT` and `_HE_SU`, giving OFDM its own tone-mask group
|
||||
(`1001-wifi-mt76-mt7915-csi-implement-csi-support.patch:1070-1088`). Association
|
||||
and management exchanges are legacy-rate by design, so a sensing deployment will
|
||||
see these continuously.
|
||||
|
||||
`Unknown` exists so the enum's gaps (5-7 and 12 in `mt76_phy_type`) and future
|
||||
modes are carried explicitly rather than guessed at or dropped. It also covers
|
||||
transports that omit the field: the MtkCSIdump UDP datagram carries no `rx_mode`,
|
||||
and reporting `Unknown` there is honest where defaulting to a concrete format was
|
||||
not.
|
||||
|
||||
### Compatibility
|
||||
|
||||
- **Forward, not backward.** A decoder built before this amendment rejects
|
||||
discriminants 6 and 7 with `UnknownPpduType`, because the original parser
|
||||
rejects unknown types by design. `TryFrom<u8>` returns `Err` rather than
|
||||
panicking, so an old decoder fails safely — it just drops legacy frames, and
|
||||
does so silently. **Rebuild MTC1 consumers after this codec change.**
|
||||
- **Scope is narrower than "the workspace".** `wifi-densepose-hardware` defines
|
||||
three independent `PpduType` enums — `csi_frame.rs` (ESP32),
|
||||
`qualcomm_csi.rs`, and `mediatek_csi.rs` — and this amendment touches only the
|
||||
MediaTek one. Neither of the others changes, so no other vendor bridge is
|
||||
affected. The complete set of `mediatek_csi::PpduType` consumers is:
|
||||
`wifi-densepose-hardware`'s own `mediatek-csi-sim` binary,
|
||||
`wifi-densepose-mtk-bridge`, and `wifi-densepose-sensing-server`, which renders the
|
||||
value with `{:?}` and so needed no code change. In particular the
|
||||
`ppdu_type > 3` bound in `bootstrap_baseline.rs` is fed from the **ESP32**
|
||||
enum via `CsiGridKey::from_frame(&Esp32Frame)`, not from MediaTek frames, so
|
||||
`Legacy` and `Unknown` cannot reach it.
|
||||
- Because the variants were added rather than renumbered, and Rust requires
|
||||
exhaustive matches, the compiler locates any consumer that must be updated.
|
||||
Nothing outside the files above needed one.
|
||||
- Producers must emit 6 only for frames that genuinely are pre-HT, and 7 only
|
||||
where the format is truly unclassified. Neither is a dumping ground for a
|
||||
mapping the producer has not done.
|
||||
- The mapping from `rx_mode` to `PpduType` is now infallible, so no MTC1 producer
|
||||
may drop a frame on PPDU grounds.
|
||||
- Unchanged: synthetic provenance still cannot be cleared by downstream code.
|
||||
|
||||
### Observed dimensions
|
||||
|
||||
The same capture assembled to 1x1x64. That is the client's behaviour, not a
|
||||
protocol limit: the handset sent single-stream frames to a 2.4 GHz 20 MHz AP.
|
||||
Expect 1x2 or 2x2 on 5 GHz, and size consumers for the chipset profile's
|
||||
`max_chains` rather than for what one capture happened to show.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
@@ -59,3 +134,8 @@ Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
|
||||
- [ADR-266: MediaTek Filogic CSI platform](ADR-266-mediatek-filogic-csi-platform.md)
|
||||
- [ADR-018: ESP32 binary CSI framing](ADR-018-esp32-csi-frame-protocol.md)
|
||||
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
|
||||
- Amendment 1 implementation: `v2/crates/wifi-densepose-hardware/src/mediatek_csi.rs`
|
||||
(codec) and `v2/crates/wifi-densepose-mtk-bridge` (`ppdu_from_rx_mode`). The
|
||||
motivating capture is a lab record and is not in this tree: it contains a
|
||||
client transmitter address and raw CSI. The in-repo check is a fabricated
|
||||
legacy-OFDM record in `wifi-densepose-mtk-bridge/tests/hardware_capture.rs`.
|
||||
@@ -0,0 +1,199 @@
|
||||
# ADR-379: An OTA'd image confirms itself with a bounded health check, or rolls back
|
||||
|
||||
**Status:** Proposed. Hardware-verified on 2026-09-29 on an ESP32-S3 (node 4) and an ESP32-C6 (node 5).
|
||||
**Date:** 2026-09-29
|
||||
**Numbering:** On 2026-10-02 `main` had up to ADR-378 and open PRs claimed
|
||||
nothing higher. If another branch also takes 379, renumber whichever merges
|
||||
second.
|
||||
|
||||
## Context
|
||||
|
||||
Every 16 MB node was flashed with `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`
|
||||
(`sdkconfig.defaults.16mb`). With that option the bootloader starts a freshly
|
||||
OTA'd image in `ESP_OTA_IMG_PENDING_VERIFY`. The app must then call
|
||||
`esp_ota_mark_app_valid_cancel_rollback()`. If it doesn't, the bootloader marks
|
||||
the image `ABORTED` at the next reset and boots the previous slot.
|
||||
|
||||
Nothing in `firmware/esp32-csi-node/main` made that call, and `nm` on the ELF
|
||||
confirmed the symbol was not linked. RUNBOOK.md said an
|
||||
`ota_rollback_boot_check()` "sits in the app", but no such function existed
|
||||
anywhere in the tree. The consequences:
|
||||
|
||||
- An image delivered by `POST /ota` ran until the next reset, then reverted
|
||||
silently. OTA updates were not durable.
|
||||
- While the image was pending, `esp_ota_begin()` refused a second OTA with
|
||||
`ESP_ERR_OTA_ROLLBACK_INVALID_STATE`.
|
||||
- Nothing checked health, so a bad image was never rolled back on purpose. It
|
||||
only reverted if it happened to crash or be power-cycled.
|
||||
|
||||
A second defect meant none of this was ever reached: **OTA never succeeded
|
||||
on this firmware before this change.** MEASURED on 2026-09-29, on node 4 (an
|
||||
ESP32-S3 running the v0.8.12 16 MB build, with the OTA key provisioned):
|
||||
|
||||
- A `POST /ota` of a valid 1,228,672 B image logged `OTA update started`, then
|
||||
`stack overflow in task httpd`, then `rst:0xc RTC_SW_CPU_RST`. The node came
|
||||
back on its old slot. Both attempts failed the same way.
|
||||
- The cause: the server used `HTTPD_DEFAULT_CONFIG()`, which gives a 4096 B
|
||||
task stack. The handler held a 1 KB receive buffer on that stack, while
|
||||
calling `esp_ota_*` and formatting a float progress log.
|
||||
|
||||
## Decision
|
||||
|
||||
At boot, `ota_health_start()` (in `main/ota_health.c`, called early in
|
||||
`app_main`) reads the running partition's state:
|
||||
|
||||
| state | meaning here | action |
|
||||
|---|---|---|
|
||||
| `PENDING_VERIFY` | just OTA'd, rollback armed | run the health check |
|
||||
| `VALID` | USB-flashed with `ota_data_initial.bin` (the bootloader writes VALID), or already confirmed | nothing |
|
||||
| `UNDEFINED` | selected by a build without rollback; boots unconditionally | nothing |
|
||||
| `NEW` | bootloader lacks rollback, so it never promoted the image | nothing |
|
||||
| error (`NOT_FOUND`/`NOT_SUPPORTED`) | no otadata entry, or factory partition | nothing |
|
||||
|
||||
For this table I read the ESP-IDF v5.5 source: `bootloader_utility.c`
|
||||
(`set_actual_ota_seq`, the NEW to PENDING_VERIFY to ABORTED transitions) and
|
||||
`esp_ota_ops.c`.
|
||||
|
||||
**The health check.** A low-priority task polls once a second and feeds
|
||||
`ota_health_step()`, which is a pure function in `ota_health.h`:
|
||||
|
||||
- **Signals, both latched:** the STA got an IP (`IP_EVENT_STA_GOT_IP`, via
|
||||
`ota_health_note_got_ip()` in `main.c`'s handler), and the stream sender
|
||||
accepted at least one CSI frame (`csi_collector_get_send_ok_count() > 0`).
|
||||
The second signal covers driver callback, serialization and `sendto()`
|
||||
together.
|
||||
- **Minimum uptime** `CONFIG_OTA_HEALTH_MIN_UPTIME_S` (default 30 s): the image
|
||||
is not confirmed before this point even with both signals present. An image
|
||||
that crashes soon after starting its pipelines still reverts, because a crash
|
||||
while pending is a rollback.
|
||||
- **Deadline** `CONFIG_OTA_HEALTH_TIMEOUT_S` (default 120 s, measured from
|
||||
boot). Both signals are required by then. The minimum uptime is clamped to
|
||||
the deadline.
|
||||
- **Pass:** `esp_ota_mark_app_valid_cancel_rollback()`, which logs
|
||||
`marked valid after <reason> in <ms> ms`.
|
||||
- **Fail:** logs `health check failed: <reason>, rolling back`, then calls
|
||||
`esp_ota_mark_app_invalid_rollback_and_reboot()`. If no other bootable slot
|
||||
exists, that call returns an error. The node then logs and stays up rather
|
||||
than reboot-looping.
|
||||
- **Terminal verdict:** once the check has confirmed or rolled back, it never
|
||||
changes.
|
||||
|
||||
**Negative-test hook.** `CONFIG_OTA_HEALTH_FORCE_FAIL` (default n, test only)
|
||||
makes the check roll back at the point where it would have confirmed. Use it to
|
||||
prove rollback end to end.
|
||||
|
||||
**Remote visibility.** `GET /ota/status` now includes `ota_state` (`valid`,
|
||||
`pending_verify`, `new`, `undefined`, `other`, or `none`). An operator can
|
||||
confirm durability without a serial console.
|
||||
|
||||
**The httpd stack.** The OTA/WASM HTTP server now sets `config.stack_size =
|
||||
CONFIG_OTA_HTTPD_STACK_SIZE`, which defaults to 8192. Three smaller changes go
|
||||
with it:
|
||||
|
||||
- The OTA receive buffer is static rather than on the stack. Handlers run one
|
||||
at a time on the single httpd task, so one buffer is enough.
|
||||
- The progress log prints an integer percent instead of a float.
|
||||
- `GET /wasm/list` keeps its 2 KB JSON buffer on the heap, not the stack.
|
||||
|
||||
`recv_wait_timeout` stays at 30 s. Each `POST /ota` and `POST /wasm/upload` now
|
||||
logs `httpd stack after <handler>: N of M bytes never used`, so the size can
|
||||
be re-tuned from device measurements.
|
||||
|
||||
Sizing: this is an estimate from static data, not a device measurement. It
|
||||
comes from a `-fstack-usage` build of the S3 16 MB configuration with IDF v5.5.
|
||||
|
||||
- **Frame sizes along the OTA path** (bytes):
|
||||
|
||||
| function | bytes |
|
||||
|---|---|
|
||||
| `httpd_thread` | 144 |
|
||||
| `httpd_sess_process` | 32 |
|
||||
| `httpd_req_new` | 176 |
|
||||
| `httpd_uri` | 64 |
|
||||
| `ota_upload_handler` | 192 (1,216 with the old 1 KB buffer) |
|
||||
| `esp_ota_end` | 48 |
|
||||
| `image_validate` | 304 |
|
||||
| `esp_image_verify` | 32 |
|
||||
| `image_load` | 80 |
|
||||
| `process_segments` | 112 |
|
||||
| SHA-256 | about 110 |
|
||||
| mmap/flash read | about 190 |
|
||||
|
||||
- That chain comes to about 1.5 KB now, and about 2.5 KB with the old buffer.
|
||||
- Two things are not in the static data:
|
||||
- libc's `vfprintf` behind every `ESP_LOG`. This build uses full newlib,
|
||||
not nano, and the old progress line formatted a float.
|
||||
- The flash-chip driver, which is reached through function pointers.
|
||||
|
||||
Together they plausibly add 1 to 1.5 KB. That explains an overflow at 4096
|
||||
with the old handler.
|
||||
- The WASM upload path adds `wasm_upload_process` (336) and
|
||||
`rvf_verify_signature` (352), plus a wasm3 module load that wasn't measured.
|
||||
- 8192 leaves roughly 4 to 5 KB above the estimated worst case, and costs
|
||||
4 KB of internal RAM. The high-water-mark log is the check on that estimate.
|
||||
|
||||
When the build has no `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` (the 8 MB S3 and
|
||||
4 MB C6 CI lanes), both entry points compile to no-ops.
|
||||
|
||||
## Consequences
|
||||
|
||||
- OTA updates become durable on rollback-enabled nodes once the image passes
|
||||
the check. A second OTA is accepted after confirmation, which can come as
|
||||
early as `MIN_UPTIME_S`.
|
||||
- A bad image now reverts instead of limping, but "bad" only means it failed
|
||||
to reach the network or deliver CSI. An image that passes and then
|
||||
misbehaves later is not caught.
|
||||
- If the AP is down when a node boots an OTA'd image, a good image rolls back.
|
||||
That is the safe direction: push it again later.
|
||||
- Mock/QEMU builds that skip Wi-Fi can never pass. They must not enable
|
||||
bootloader rollback, and none do today.
|
||||
- Bootloader capability is still invisible remotely (RUNBOOK §2). A node
|
||||
whose bootloader lacks rollback reports `ota_state: "new"` after an OTA. That
|
||||
is the first remote hint of which bootloader a board has.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Host test `firmware/esp32-csi-node/test/test_ota_health.c` runs in
|
||||
`make host_tests`, which CI runs. It covers pass, each timeout reason, the
|
||||
terminal verdict, force-fail, the clamp, and zero soak. SYNTHETIC: it
|
||||
exercises the decision function, not a device.
|
||||
- ESP-IDF v5.5 S3 and C6 builds with rollback enabled link
|
||||
`esp_ota_mark_app_valid_cancel_rollback` (verified with `nm`). A build is not
|
||||
hardware evidence.
|
||||
- The httpd overflow is MEASURED on hardware (node 4, above).
|
||||
- Hardware verification, MEASURED on 2026-09-29 on node 4 (ESP32-S3,
|
||||
16 MB, rollback bootloader, OTA key provisioned). The
|
||||
build from this branch was first written over USB. The pushes were made from
|
||||
a Pi 5 with the `sensor-ota-push` cog.
|
||||
1. Positive (MEASURED, S3). An image built from this branch was pushed to `ota_1`: HTTP 200,
|
||||
1,230,848 B in 22 s.
|
||||
- The node logged "httpd stack after POST /ota: 4900 of 8192 bytes never
|
||||
used", so the upload's peak stack use is about 3.3 KB. With the old 1 KB
|
||||
on-stack buffer that is over 4 KB, which is consistent with the
|
||||
overflow.
|
||||
- It rebooted and logged "OTA image pending verify on ota_1".
|
||||
- `/ota/status` then read `ota_state: valid`.
|
||||
- After a hard reset it came back on the same build in `ota_1`, still
|
||||
`valid`, so the update is durable.
|
||||
2. Negative (MEASURED, S3 forced-fail rollback). A `CONFIG_OTA_HEALTH_FORCE_FAIL=y` build was pushed to `ota_0`:
|
||||
HTTP 200.
|
||||
- `/ota/status` showed it running as `pending_verify` for about 30 s.
|
||||
- The node then rebooted and came back on the previous build in `ota_1`,
|
||||
`valid`, so rollback works.
|
||||
3. One push attempt failed mid-upload when a serial capture was opened on the
|
||||
node's USB-Serial-JTAG port at the same moment. The node reset and was
|
||||
unharmed. Don't hold the USB console open while pushing OTA to a
|
||||
USB-attached node.
|
||||
- C6 positive, MEASURED on 2026-09-29 on node 5 (ESP32-C6, 8 MB
|
||||
flash, 4 MB layout, rollback bootloader).
|
||||
- The branch build was first written over USB and the OTA key provisioned.
|
||||
- A second build was pushed from the Pi 5 cog to `ota_1`: HTTP 200 in 11 s.
|
||||
- `/ota/status` showed `pending_verify`, then `valid` about 28 s later.
|
||||
- After a hard reset it stayed on the new build, `valid`.
|
||||
- Not yet verified, so CLAIMED (no log supports these):
|
||||
- the 120 s no-IP timeout path (CLAIMED);
|
||||
- a true power cycle (CLAIMED; a hard reset via RTS was used instead);
|
||||
- the C6 negative/rollback test (CLAIMED; run on the S3 only).
|
||||
- A pass of this check means only that the node reached the network and sent
|
||||
CSI. It does not validate sensing quality, calibration, or anything else the
|
||||
image does.
|
||||
@@ -0,0 +1,58 @@
|
||||
# ADR 380: Loopback UDP tee for second consumers of the CSI stream
|
||||
|
||||
## Status
|
||||
|
||||
Proposed. Unit tested, and smoke tested on loopback: a datagram sent to the
|
||||
server arrived byte for byte at the tee target. Not yet exercised against a
|
||||
live multi node fleet.
|
||||
|
||||
Date: 2026-09-28
|
||||
|
||||
Numbering: On 2026-10-02 `main` had up to ADR 378 and open PRs claimed nothing
|
||||
higher; ADR 379 is the OTA first-boot health check. If another branch also
|
||||
takes 380, renumber whichever merges second.
|
||||
|
||||
## Context
|
||||
|
||||
A five node deployment (four ESP32 S3, one ESP32 C6) streams ADR 018 frames to
|
||||
one sensing server. A second consumer cannot see the raw stream.
|
||||
|
||||
Each node sends to a single `target_ip` (`firmware/esp32-csi-node/main/stream_sender.c`).
|
||||
The server's WebSocket and REST outputs carry per node amplitude only: no
|
||||
phase, no I/Q, no sequence number, and no `0xC511A110` sync packets. A fusion
|
||||
host that aligns CSI with camera, radar, or lidar needs all of those. Binding a
|
||||
second listener to the CSI port is not an option, and an external relay in
|
||||
front of the port would make every source loopback, which the ADR 296
|
||||
allowlist always admits.
|
||||
|
||||
## Decision
|
||||
|
||||
`--udp-tee` (env `RUVIEW_UDP_TEE`) copies every datagram the source allowlist
|
||||
admits, byte for byte, to up to four `ip:port` targets.
|
||||
|
||||
- Targets must be loopback, so raw CSI never leaves the host through the tee.
|
||||
- A target may not use port 0 or the listener's own port, which would loop.
|
||||
- Sends are non blocking. A copy that cannot be queued is dropped and counted,
|
||||
so a slow consumer never stalls the receive loop.
|
||||
- The copy happens after the ADR 296 allowlist check and before any parsing,
|
||||
so consumers see exactly what the server admitted, in every wire format.
|
||||
- The tee is off unless configured.
|
||||
|
||||
## Consequences
|
||||
|
||||
A fusion host or Cognitum cog can subscribe to the full data plane by reading
|
||||
a loopback port. A consumer that today binds the CSI port itself can keep its
|
||||
code and move to the tee port instead.
|
||||
|
||||
The tee is byte transparent, so it needs no change when the ADR 018 header or
|
||||
a later wire version changes.
|
||||
|
||||
Each target costs one `send_to` per admitted datagram. Four targets is the
|
||||
limit.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo test -p wifi-densepose-sensing-server --no-default-features --lib udp_tee
|
||||
```
|
||||
@@ -0,0 +1,272 @@
|
||||
# ADR-381: Node self-survey from pairwise ranging (check mode first)
|
||||
|
||||
- **Status**: Proposed
|
||||
- **Date**: 2026-10-04
|
||||
- **Deciders**: RuView maintainers; acceptance pending
|
||||
- **Tags**: node-positions, ranging, ftm, uwb, multistatic, least-authority
|
||||
- **Related**: ADR-018 (ESP32 wire format), ADR-110 (ESP-NOW time sync),
|
||||
ADR-141 (privacy control plane), ADR-144 (UWB range-constraint fusion),
|
||||
ADR-281 (BLE Channel Sounding ranging evidence), ADR-308 (placement
|
||||
optimizer), rufield ADR-261 (BLE evidence and Channel Sounding adapter
|
||||
boundary, `vendor/rufield/docs/ADR-261-ble-evidence-and-channel-sounding.md`)
|
||||
- **Issues and PRs**: #1804, #1866, #2109 (show which nodes use the default
|
||||
position), #2133 (give the governed fuser the configured node positions)
|
||||
|
||||
## Context
|
||||
|
||||
Node positions reach the sensing server only through `--node-positions`. The
|
||||
operator types it by hand, keyed by node id (`id:x,y,z;...`) or by list index
|
||||
when the prefix is omitted. Three kinds of mistake pass silently today:
|
||||
|
||||
- **Wrong ids.** A config keyed by ids that do not match the live nodes looks
|
||||
applied and does nothing (#1804, #1866). #2109 makes the fallback to the
|
||||
default position visible, and #2133 makes sure both fusers receive the
|
||||
map. Neither can tell whether a correctly-keyed position is *right*.
|
||||
- **Wrong units.** Positions measured in feet and typed as metres are 3.28×
|
||||
too large. Nothing checks the scale.
|
||||
- **Stale positions.** A node moved after setup keeps its old coordinates.
|
||||
|
||||
Each one degrades multistatic fusion without an error. The repository has no
|
||||
node-to-node ranging and no anchor-free solver. ADR-144 models
|
||||
anchor-to-*tag* UWB ranges for people tracking
|
||||
(`wifi-densepose-mat/src/localization/range_constraint.rs`), not ranges
|
||||
between the fixed nodes themselves. ADR-281 computes BLE Channel Sounding
|
||||
ranging evidence, but there is no CS-capable radio in the fleet: rufield
|
||||
ADR-261 notes that the ESP32-S3 does not implement CS.
|
||||
|
||||
The ESP32-S3 and C6 nodes do support Wi-Fi Fine Timing Measurement (802.11mc
|
||||
RTT, station initiator ↔ SoftAP responder) in mainline ESP-IDF. So a ranging
|
||||
source exists on hardware we already have. The question is how good it is.
|
||||
|
||||
### What FTM measured
|
||||
|
||||
Bench, 2026-10-04 (UTC): two ESP32-S3 boards (rev v0.2), ESP-IDF v5.5
|
||||
`examples/wifi/ftm`, SoftAP responder on channel 1 at 20 MHz, 32 FTM frames
|
||||
per session, line of sight, tape ground truth. All rows MEASURED; the
|
||||
reproducer is in Appendix A.
|
||||
|
||||
| Tape (m) | Result |
|
||||
|---|---|
|
||||
| 0.00 | median 0.00 m |
|
||||
| 0.76 | median 0.00 m. Readings clamp to 0 below about 0.8 m. The first three sessions after a responder start read 1.8 to 3.0 m |
|
||||
| 1.14 | five responder starts: medians 0.60, 1.50, 2.25 and 3.08 m, plus a 7.7 m run at −68 dBm with a 1.92 m session spread (placement outlier) |
|
||||
| 3.71 | median 7.80 m (one start) |
|
||||
|
||||
- **Precision within one responder start:** ±0.2 to 0.4 m (standard
|
||||
deviation 0.19 to 0.31 m). A 40-session run over about two minutes showed
|
||||
no drift beyond that.
|
||||
- **Bias between responder starts:** −0.5 to +1.9 m at 1.14 m, with the
|
||||
boards untouched. A plain-versus-auto-start responder A/B test showed the
|
||||
responder build is not the cause.
|
||||
- **Quantisation:** every distance is a multiple of 0.15 m (1 ns of RTT).
|
||||
- **Not tested:** 40 MHz, other channels, `ftm -R -o` offset calibration, a
|
||||
second pair, non-line-of-sight, CSI coexistence, raw t1..t4 timestamps.
|
||||
|
||||
FTM is precise inside one start and inaccurate across starts. It can catch
|
||||
mistakes worth metres. It cannot place a node to decimetres. UWB (Qorvo
|
||||
DW3000 family) is the expected accuracy path: about 0.1 m line of sight is
|
||||
CLAIMED by the vendor, and we have no measurement of our own yet.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. A source-agnostic `RangeObservation` record
|
||||
|
||||
```text
|
||||
RangeObservation {
|
||||
initiator: u8, // node_id, as in --node-positions
|
||||
responder: u8,
|
||||
range_m: f64, // session median
|
||||
sigma_m: f64, // session spread, 0 if unknown
|
||||
method: Ftm | Uwb | BleCs | Manual,
|
||||
samples: u16, // valid frames / exchanges
|
||||
rssi_dbm: Option<i16>,
|
||||
bandwidth_mhz: Option<u16>,
|
||||
at_us: u64, // host receive time
|
||||
responder_boot: Option<u32>, // boot counter or nonce of the responder
|
||||
calibrated: bool, // set only by the host calibration step
|
||||
}
|
||||
```
|
||||
|
||||
`responder_boot` is required for calibration. The FTM bias moved with every
|
||||
responder start, so an offset is only valid within the boot it was measured
|
||||
in.
|
||||
|
||||
On the wire, nodes report ranges either in a new ADR-018-style packet with
|
||||
its own magic, or as an extension of the existing sync packet. This ADR does
|
||||
not choose between them. The host-side record and solver come first. The
|
||||
wire format follows the first firmware that emits ranges.
|
||||
|
||||
### 2. Host-side check mode (warn-only)
|
||||
|
||||
Given the configured positions and a batch of observations, check mode
|
||||
reports:
|
||||
|
||||
- **Per-link residuals.** `measured − ‖p_a − p_b‖`, with a tolerance of
|
||||
`floor + k·σ`. σ is the larger of the median session sigma and the spread
|
||||
across sessions. Each link is `ok`, `outlier`, `too_short` (configured
|
||||
length below the source's minimum), `weak` (every session below the RSSI
|
||||
gate), or `noisy` (σ above the source's maximum).
|
||||
- **Unit mismatch.** A least-squares scale `s` in `measured ≈ s·expected`.
|
||||
If `s` sits within a band of 0.3048 (feet), 0.0254 (inches), 0.01 (cm) or
|
||||
0.001 (mm), and rescaling removes outliers, the check reports the unit.
|
||||
- **Id swap.** For up to 8 ranged nodes, it scores every assignment of the
|
||||
configured positions to ids. The cost is a truncated quadratic in residual
|
||||
over tolerance, so one bad link cannot dominate. If another assignment
|
||||
cuts the cost to at most half and the configured cost is material, the
|
||||
check suggests that assignment, reported as `(node, position_of)` pairs.
|
||||
- **Moved node.** If most of a node's links are outliers (at least 2, and a
|
||||
strict majority), the node was probably moved. Outlier links left over are
|
||||
reported one at a time as possible bias, a body or wall in the path, or a
|
||||
bad session, and not as a configuration error.
|
||||
- **Unknown and unranged nodes.** Observations naming unconfigured ids, and
|
||||
configured nodes that no scored link reaches.
|
||||
|
||||
Distances do not change under rotation, reflection or translation, so check
|
||||
mode compares distances and never coordinates. Where the layout is
|
||||
symmetric, it says so instead of guessing:
|
||||
|
||||
- A relabelling that is a symmetry of the layout is **invisible**. For
|
||||
example, ids rotated 180° in a rectangle of corner nodes. Check mode
|
||||
reports such a layout as consistent.
|
||||
- When several assignments fit equally well, check mode prefers the one that
|
||||
changes the fewest ids, since a typo moves few. It reports how many
|
||||
assignments tied. If the fewest-changes assignments are themselves tied,
|
||||
as with a swap along one wall of a rectangle (its mirror image is the
|
||||
opposite wall's swap), it reports `id_swap_ambiguous` and names nothing.
|
||||
|
||||
Corner-mounted nodes in a rectangular room are the common case and are
|
||||
nearly symmetric. Operators should expect ambiguity reports there, and a
|
||||
fifth node off the axes removes most of them.
|
||||
|
||||
**Authority.** Check mode never writes positions, never edits config, and
|
||||
never changes fusion. Its output is a report for the startup log and a
|
||||
read-only status field. The report shape is JSON-serialisable; the field
|
||||
name follows #2109's `node_positions` block once that lands.
|
||||
|
||||
**Tolerances come from measured data:**
|
||||
|
||||
| Source | `floor_m` | `calibrated_floor_m` | `max_sigma_m` | `min_expected_m` | Basis |
|
||||
|---|---|---|---|---|---|
|
||||
| FTM, ESP32-S3, 20 MHz | 2.0 | 0.6 | 1.0 | 1.0 | MEASURED, bench above: bias −0.5 to +1.9 m between starts; spread ≤ 0.31 m within a start (the 1.92 m outlier excepted); clamp below about 0.8 m |
|
||||
| UWB | 0.3 | 0.3 | 0.3 | 0.0 | ESTIMATE until our own UWB bench. Vendor accuracy is CLAIMED |
|
||||
|
||||
With `floor_m = 2.0`, the one 3.71 m start (+4.1 m) would be flagged as a
|
||||
single-link outlier. That is uncalibrated FTM as it stands. Single-link
|
||||
outliers stay low-severity on purpose, and the strong findings (unit, swap,
|
||||
moved node) need several links to agree.
|
||||
|
||||
### 3. Per-boot calibration
|
||||
|
||||
An offset is recorded per `(initiator, responder, responder_boot)` from
|
||||
sessions at a known reference distance of at least 1.0 m:
|
||||
`offset = median(range) − reference`. It applies only to observations with
|
||||
the same key. After a responder reboot, observations pass through
|
||||
uncalibrated and get the wider floor.
|
||||
|
||||
Zero-separation calibration (ESP-IDF's `cm0` / `ftm -R -o`) is not used,
|
||||
because short ranges clamp to 0 on this hardware. In practice, the reference
|
||||
is one tape-measured link per responder per boot, or a UWB link once UWB
|
||||
exists. That the bias is stable over a whole boot, and not only for two
|
||||
minutes, is untested (ESTIMATE). Measuring it is the first FTM follow-up.
|
||||
|
||||
### 4. Fill mode, gated off
|
||||
|
||||
Fill mode (solve positions with classical-MDS initialisation, weighted
|
||||
robust SMACOF and gauge fixing, then write a file the operator passes to
|
||||
`--node-positions`) is **not implemented** and stays disabled for a source
|
||||
until all of these hold:
|
||||
|
||||
- the source has **MEASURED** link-error evidence with a reproducer;
|
||||
- the evidence covers at least 6 ground-truth links;
|
||||
- the p90 link error is **≤ 0.3 m**.
|
||||
|
||||
FTM's measured errors are metres, so it fails this gate. UWB is expected to
|
||||
pass it, but that is unproven. Even when the gate opens, fill mode writes a
|
||||
proposal file. The server never rewrites its own positions.
|
||||
|
||||
### 5. Pluggable sources
|
||||
|
||||
Every source produces `RangeObservation`, and check mode does not care which
|
||||
one did. Only the tolerance profile changes.
|
||||
|
||||
| Source | Status | How it plugs in |
|
||||
|---|---|---|
|
||||
| **FTM** (ESP32-S3/C6) | Now, for check mode | APSTA node runs a short scheduled survey window. One node is the SoftAP responder and the others initiate a directed session. `rtt_ns`, RSSI and session spread map to `range_m`, `rssi_dbm`, `sigma_m`. Airtime cost on the CSI channel is unmeasured |
|
||||
| **UWB** (DW3000 family, e.g. DWM3001CDK over USB) | After its own bench | Two-way ranging between nodes. Shares hardware with ADR-144's anchor-tag model, but node↔node ranges feed this ADR and anchor↔tag ranges keep feeding ADR-144 |
|
||||
| **BLE CS** | When a CS-capable radio exists | Through the rufield ADR-261 adapter boundary, using ADR-281's cross-validated phase/RTT ranging. A `Divergent` anomaly drops the observation. Single-mechanism evidence gets the wider floor |
|
||||
| **Manual** | Now | A taped distance entered by the operator. Mainly useful as the per-boot calibration reference |
|
||||
|
||||
### 6. Prototype
|
||||
|
||||
`v2/crates/ruview-survey` implements sections 1 to 4 on the host, with no
|
||||
I/O, no clock and no randomness, and bounded inputs: 64 nodes, 10 000
|
||||
observations, 100 m range, and the swap search capped at 8 nodes.
|
||||
Integration with the sensing server is a follow-up and depends on #2109 for
|
||||
where the report surfaces. The crate's tests are SYNTHETIC: generated layouts
|
||||
with FTM-like bias and noise from the envelope above. They cover consistent
|
||||
configs, feet and centimetre mistakes, a two-id swap, a three-id rotation,
|
||||
symmetric invisibility and ambiguity, a moved node, a bench-style single-link
|
||||
outlier, a noisy link, per-boot calibration, and the fill gate.
|
||||
`examples/check_ranges.rs` runs check mode on a JSONL file.
|
||||
|
||||
## Consequences
|
||||
|
||||
- An operator gets a warning when ranges and `--node-positions` disagree
|
||||
grossly, with no new authority: check mode is read-only.
|
||||
- FTM on nodes needs APSTA and a SoftAP beacon on the sensing channel. Its
|
||||
cost to CSI yield must be measured before the firmware side ships.
|
||||
- The FTM tolerances rest on one evening with one pair. They are parameters,
|
||||
not constants, and must be revised from more data (40 MHz, more pairs,
|
||||
non-line-of-sight).
|
||||
- Ranges between fixed nodes are not person data. Ranges to phones or tags
|
||||
are, and remain under ADR-141 and ADR-144.
|
||||
- A symmetric layout can hide a relabelling entirely. Check mode documents
|
||||
this and does not claim to catch it.
|
||||
|
||||
## Evidence rule
|
||||
|
||||
No self-survey accuracy claim without a MEASURED reproducer: tape or laser
|
||||
ground truth, at least 4 nodes, line of sight plus at least one
|
||||
non-line-of-sight case, every number tagged. The prototype's tests are
|
||||
SYNTHETIC and show logic, not radio performance.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Treat FTM as accurate enough to fill positions.** Rejected: the measured
|
||||
bias between starts (−0.5 to +1.9 m at 1.14 m) and the +4.1 m at 3.71 m
|
||||
rule it out.
|
||||
- **Drop FTM.** Rejected for now. It costs nothing on existing hardware, it
|
||||
catches metre-scale mistakes, and it may suit coarse gating, calibrated
|
||||
pairs within a boot, and time sync. It is parked and revisited after UWB.
|
||||
- **Zero-separation `cm0` calibration.** Rejected: readings clamp to 0 below
|
||||
about 0.8 m.
|
||||
- **Server auto-corrects positions.** Rejected under least authority. Fill
|
||||
mode, when enabled, writes a proposal file only.
|
||||
- **LoRa SX1280 ranging, router (MT7981) FTM, raw I/Q phase ranging,
|
||||
CSI-amplitude ranging.** Rejected or deferred: wrong distance regime, no
|
||||
driver support, unproven, or too inaccurate respectively.
|
||||
|
||||
## Open questions
|
||||
|
||||
- Does one calibration offset hold for a whole responder boot? For both
|
||||
roles? For other initiators against the same responder?
|
||||
- Is the post-start transient (1.8 to 3.0 m in the first three sessions at
|
||||
0.76 m) real, and how long does it last?
|
||||
- Does 40 MHz shrink the bias between starts?
|
||||
- What does an FTM survey window cost in CSI frames on the same channel?
|
||||
- Can FTM t1..t4 timestamps be tied to the CSI timestamp clock for sync?
|
||||
- The wire format: new magic, or an extension of the sync packet?
|
||||
|
||||
## Appendix A: FTM bench reproducer
|
||||
|
||||
1. Flash ESP-IDF v5.5 `examples/wifi/ftm` on two ESP32-S3 boards
|
||||
(`CONFIG_ESP_WIFI_FTM_ENABLE=y`, the example's default).
|
||||
2. Responder console: `ap <ssid> <password>` (channel 1, 20 MHz defaults).
|
||||
Use throwaway credentials and do not commit them.
|
||||
3. Initiator console, at a taped separation: `ftm -I -s <ssid>`, 32 frames,
|
||||
10 to 40 sessions per distance. Record `Avg raw RTT`, `Avg RSSI`,
|
||||
`Estimated Distance` and the valid-frame count for each session.
|
||||
4. Reset the responder and repeat at the same distance to see the bias move
|
||||
between starts.
|
||||
5. Report the median, spread and error per run, tagged MEASURED, with the
|
||||
raw per-session data alongside.
|
||||
@@ -0,0 +1,137 @@
|
||||
# ADR-382: `spatial.evidence.v1` export of RF Gaussians and link observations
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-10-03
|
||||
- **Deciders**: RuView maintainers
|
||||
- **Tags**: rf, gaussian, spatial, evidence, wire-format, interop
|
||||
- **Related**: ADR-273 (unified RF world model), ADR-275 (RF-aware Gaussian
|
||||
memory), ADR-142 (temporal voxel evidence). Origin of the wire format:
|
||||
WeftOS ADR-107 §7 ("Spatial evidence engine and workspace"), amended
|
||||
2026-10-03.
|
||||
- **Crate**: `v2/crates/ruview-spatial-evidence`
|
||||
|
||||
Numbering: on 2026-10-03 `main` had up to ADR-378, open PRs claimed 379
|
||||
and 380, and 381 was taken by another branch.
|
||||
|
||||
## Context
|
||||
|
||||
`ruview-unified` keeps a map of RF Gaussians (ADR-275) and a Friis channel
|
||||
model that turns a TX/RX link into an expected amplitude. A spatial evidence
|
||||
engine fuses many sensor types (tape measurements, radar, ToF, UWB, RF) into
|
||||
an occupancy octree and promotes stable geometry (walls, furniture) into room
|
||||
models. That engine reads a versioned JSONL format, `spatial.evidence.v1`,
|
||||
specified in WeftOS ADR-107 §7.
|
||||
|
||||
RuView should be able to feed it without depending on it, and without a
|
||||
reader of this repository having to go to another project to learn what the
|
||||
export writes. This ADR records the part of the contract RuView emits.
|
||||
|
||||
## Decision
|
||||
|
||||
1. A leaf crate, `ruview-spatial-evidence`, converts `ruview-unified` output
|
||||
to `spatial.evidence.v1` lines. Its only internal dependency is
|
||||
`ruview-unified`. The wire shapes are local serde structs; the contract is
|
||||
the JSON, not a shared crate.
|
||||
2. It emits two record types, `rf_gaussian` and `rf_link_observation`. It
|
||||
does not mirror the other v1 types and rejects them as unknown when
|
||||
parsing.
|
||||
3. Every record is validated against the v1 rules below before it is
|
||||
serialised, so a bad value fails in RuView rather than in the consumer.
|
||||
4. If WeftOS ADR-107 §7 changes the envelope or either RF body, this ADR is
|
||||
amended in the same change that updates the crate.
|
||||
|
||||
### Envelope (every record)
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `schema` | exactly `spatial.evidence.v1` |
|
||||
| `type` | `rf_gaussian` or `rf_link_observation` here |
|
||||
| `t_ns` | u64, ns since the Unix epoch |
|
||||
| `frame` | `room_enu` |
|
||||
| `region` | region id, e.g. `region/urth/meso/<room>` |
|
||||
| `source_id` | device or operator id, never a person |
|
||||
| `uncertainty_m` | 1σ position uncertainty, metres, in (0, 100] |
|
||||
| `provenance` | `{receipt, producer, proof}`; `proof` is `MEASURED`, `CODE` or `SYNTHETIC` |
|
||||
|
||||
Ids (`region`, `source_id`, `receipt`, `producer`) match
|
||||
`[A-Za-z0-9._:/-@]{1,128}`. A line is at most 16 KiB.
|
||||
|
||||
**Frame.** `room_enu` is room-local, origin at the room's SW floor corner,
|
||||
x east, y north, z up, metres. Coordinates are within ±1000 m.
|
||||
|
||||
**Angles.** Every yaw in v1 is degrees counter-clockwise from room +x
|
||||
(east): 0 faces +x, 90 faces +y. The two RF types have no yaw field. The
|
||||
`rf_gaussian` orientation is a body-to-room quaternion, and a positive
|
||||
rotation about +z turns the Gaussian's local x axis counter-clockwise from
|
||||
east, the same sense.
|
||||
|
||||
### `rf_gaussian`
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `position` | [x, y, z] m |
|
||||
| `scale` | per-axis σ, m, each in [1e-6, 1e4] |
|
||||
| `orientation` | quaternion `[w, x, y, z]`, finite, norm > 1e-6 |
|
||||
| `occupancy` | peak extinction, nepers/m, in [0, 1e6] |
|
||||
| `confidence` | [0, 1] |
|
||||
| `motion` | `static`, `slow` or `fast` |
|
||||
| `role`? | `absorber` or `reflector`; set only on priors sent back to RuView |
|
||||
|
||||
The exporter maps `RfGaussian` field for field. `uncertainty_m` is the
|
||||
geometric mean of the three σ, clamped to the envelope range, because
|
||||
`RfGaussian` has no separate position covariance. The receipt is
|
||||
`gauss:<device>:<t_ns>:<index>`. A Gaussian whose provenance is `synthetic`
|
||||
is always tagged `SYNTHETIC`; a caller can lower the proof tag, never raise
|
||||
it.
|
||||
|
||||
### `rf_link_observation`
|
||||
|
||||
| Field | Rule |
|
||||
|---|---|
|
||||
| `tx`, `rx` | antenna positions, m |
|
||||
| `freq_hz` | [1e8, 3e11] |
|
||||
| `excess_loss_db` | `20·log10(free / measured)`, in [−60, 200]; negative is constructive multipath |
|
||||
|
||||
The free-space reference comes from `ruview-unified`'s channel model with an
|
||||
empty map, so it is the same Friis amplitude the ADR-275 inverse update uses.
|
||||
|
||||
### Examples
|
||||
|
||||
These two lines are the WeftOS ADR-107 §7 examples. The crate's tests parse
|
||||
them and check that its own output has the same shape.
|
||||
|
||||
```jsonl
|
||||
{"schema":"spatial.evidence.v1","type":"rf_link_observation","t_ns":1759500001300000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"node-2","uncertainty_m":1.0,"provenance":{"receipt":"csi:node1-node2:000881","producer":"ruview-adapter@0.1","proof":"MEASURED"},"tx":[6.20,3.50,1.10],"rx":[0.20,0.20,1.10],"freq_hz":2437000000.0,"excess_loss_db":4.2}
|
||||
{"schema":"spatial.evidence.v1","type":"rf_gaussian","t_ns":1759500002000000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"ruview-unified","uncertainty_m":0.5,"provenance":{"receipt":"gauss:7f3a","producer":"ruview-unified@0.3","proof":"CODE"},"position":[3.2,0.0,1.2],"scale":[0.05,1.6,1.2],"orientation":[1,0,0,0],"occupancy":0.7,"confidence":0.8,"motion":"static","role":"absorber"}
|
||||
```
|
||||
|
||||
### Versioning
|
||||
|
||||
Additive optional fields may appear inside v1 and older readers ignore them.
|
||||
New record types may also be added inside v1 (the 2026-10-03 amendment added
|
||||
`tof_depth` and `radar_range`, and an optional `sensor` block on
|
||||
`radar_track_point`); a reader that does not know a type rejects it as
|
||||
unknown rather than misreading it. Removing, renaming or changing the
|
||||
meaning of a field requires `spatial.evidence.v2`, and the parser reports a
|
||||
v2 line as a version mismatch.
|
||||
|
||||
## Consequences
|
||||
|
||||
- RuView output can feed a spatial evidence engine with no new runtime
|
||||
dependency, and the contract is readable from this repository.
|
||||
- The engine can send promoted walls and objects back as `rf_gaussian`
|
||||
priors with a `role`. That gives ADR-275's inverse update a geometric
|
||||
starting point instead of an empty map.
|
||||
- The struct definitions are duplicated between the two projects. The
|
||||
golden-line tests catch drift in the RF types; a change on the WeftOS side
|
||||
still needs someone to amend this ADR and the crate.
|
||||
|
||||
## Limitations
|
||||
|
||||
- Nothing calls the crate yet. The sensing server does not export records,
|
||||
and there is no adapter that turns an inbound `rf_gaussian` prior into an
|
||||
`RfGaussian` in the live map.
|
||||
- The tests are CODE-level (deterministic conversions and validation). No
|
||||
MEASURED claim is made about what the exported evidence does to a room
|
||||
model.
|
||||
- `uncertainty_m` for a Gaussian is a stand-in derived from its extent.
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
Latest proposed decisions:
|
||||
|
||||
- [ADR-379: An OTA'd image confirms itself with a bounded health check, or rolls back](ADR-379-ota-first-boot-health-check.md)
|
||||
- [ADR-380: Loopback UDP tee for second consumers of the CSI stream](ADR-380-loopback-udp-tee.md)
|
||||
- [ADR-382: `spatial.evidence.v1` export of RF Gaussians and link observations](ADR-382-spatial-evidence-rf-export.md)
|
||||
- [ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells](ADR-378-ruview-live-showcase-views.md)
|
||||
- [ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`](ADR-377-ruview-live-claude-code-mod.md)
|
||||
- [ADR-376: `ruview` — one npm install for every RuView component](ADR-376-ruview-umbrella-npm-package.md)
|
||||
@@ -74,6 +77,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-040](ADR-040-wasm-programmable-sensing.md) | WASM Programmable Sensing (Tier 3) | Accepted |
|
||||
| [ADR-041](ADR-041-wasm-module-collection.md) | WASM Module Collection (65 edge modules) | Accepted (hardware-validated) |
|
||||
| [ADR-044](ADR-044-provisioning-tool-enhancements.md) | Provisioning Tool Enhancements | Proposed |
|
||||
| [ADR-379](ADR-379-ota-first-boot-health-check.md) | OTA'd image confirms itself with a bounded health check (IP + CSI frame) or rolls back; OTA httpd stack sized from device data | Proposed (host-tested; hardware-verified on one S3 and one C6) |
|
||||
| [ADR-110](ADR-110-esp32-c6-firmware-extension.md) | ESP32-C6 firmware extension — Wi-Fi 6 / 802.15.4 / TWT / LP-core | Accepted, P1-P10 complete, firmware-side substrate closed at **[v0.7.0-esp32](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32)**. Companion docs: [`WITNESS-LOG-110`](../WITNESS-LOG-110.md) (13 §A0.x entries · 99.56 % cross-board RX · **104.1 µs smoothed sync stdev** · ≤100 µs target met), [`ADR-110-REVIEW-GUIDE`](../ADR-110-REVIEW-GUIDE.md) (one-page reviewer tour), [`ADR-110-BRANCH-STATE`](../ADR-110-BRANCH-STATE.md) (coordination map vs `feat/adr-115-ha-mqtt-matter`). Host decoders + tests: Python `SyncPacketParser` (10) + Rust `wifi_densepose_hardware::SyncPacket` (15), cross-language hex pin gates drift. |
|
||||
|
||||
### Signal processing and sensing
|
||||
@@ -181,6 +185,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-294](ADR-294-wifi-veil-integration.md) | WiFi Veil integration — emission-shaping countermeasure as advisory BFLD dependency | Accepted (initial implementation) |
|
||||
| [ADR-295](ADR-295-source-provenance-state-machine.md) | Source provenance state machine — synthetic can never present as live | Accepted (initial implementation) |
|
||||
| [ADR-296](ADR-296-sensor-data-plane-bind-hardening.md) | Sensor data-plane hardening — UDP bind control and source allowlist (step one) | Accepted (initial implementation) |
|
||||
| [ADR-380](ADR-380-loopback-udp-tee.md) | Loopback UDP tee for second consumers of the CSI stream (`--udp-tee`) | Proposed |
|
||||
| [ADR-297](ADR-297-multi-node-semantic-correctness.md) | Multi-node semantic correctness — per-node inference, node-keyed rate limiting, stale state | Accepted (initial implementation) |
|
||||
| [ADR-298](ADR-298-model-release-sanity-gates.md) | Model release sanity gates — block degenerate and mislabeled model artifacts | Accepted (initial implementation) |
|
||||
| [ADR-299](ADR-299-csi-data-incident-repo-controls.md) | Repository CSI data-incident controls — ignore rules and pre-commit/CI policy check | Accepted (controls implemented; tree remediation gated) |
|
||||
|
||||
@@ -33,59 +33,82 @@ cargo run --release -p wifi-densepose-sensing-server \
|
||||
--mqtt-username homeassistant
|
||||
```
|
||||
|
||||
Within ~5 seconds of starting, Home Assistant should auto-create:
|
||||
Within ~5 seconds of the first CSI frame, Home Assistant should auto-create:
|
||||
|
||||
- One **device** per RuView node (named after the MAC or the `friendly_name` from your zones config)
|
||||
- 17+ **entities** per device (presence, person count, heart rate, breathing rate, motion, fall events, signal strength, zones, and the 10 semantic primitives)
|
||||
- One **device** per RuView node, named `RuView node <client-id>-node<N>` (see [Device identity](#device-identity))
|
||||
- **15 entities** per device (13 with `--privacy-mode`): the raw signals and six semantic primitives listed in the [Entity reference](#entity-reference)
|
||||
|
||||
If nothing appears in HA's Settings → Devices, see [Troubleshooting](#troubleshooting).
|
||||
|
||||
### 3. Stop the publisher cleanly
|
||||
|
||||
Ctrl-C — the publisher pushes `offline` to every availability topic before disconnect so HA marks all entities unavailable instantly. A `kill -9` triggers MQTT LWT, which has the same effect within ~30 s.
|
||||
Ctrl-C (SIGINT/SIGTERM) stops the server. It does **not** publish `offline` first, and no MQTT last-will is registered, so every availability topic keeps its last retained value until the server starts again (MEASURED: no availability message in the last 6 s of a SIGTERM'd run, see [ADR-115 §11](../adr/ADR-115-home-assistant-integration.md#11-status-note-p45-semantic-states-on-the-live-publisher-2026-10-02)). While the server runs, a node that stops sending frames goes `offline` at the next 30 s availability heartbeat.
|
||||
|
||||
---
|
||||
|
||||
## Entity reference
|
||||
|
||||
RuView publishes three classes of entity. Names below are the `unique_id` slugs — Home Assistant assigns friendly names automatically.
|
||||
Names below are the `unique_id` slugs; Home Assistant assigns friendly names automatically. Every entity has its own availability topic (`…/<slug>/availability`, retained). An entity is `online` only while the server has a real source for it. Otherwise it is `offline`, no state is published, and Home Assistant shows **unavailable** rather than a made-up value.
|
||||
|
||||
### Raw signals (11 entities)
|
||||
### Raw signals (9 entities)
|
||||
|
||||
| HA entity | Slug | HA component | Unit | Source field |
|
||||
| HA entity | Slug | HA component | Unit | Source | Available when |
|
||||
|---|---|---|---|---|---|
|
||||
| Presence | `presence` | `binary_sensor` | — | `nodes[].node_inference.classification != "absent"` | always |
|
||||
| Person count | `person_count` | `sensor` | persons | `persons[]` length, else `estimated_persons` (room-level, same on every node) | always |
|
||||
| Motion level | `motion_level` | `sensor` | % | node classification: `present_moving` = 100, `present_still`/`absent` = 0 | always |
|
||||
| Presence score | `presence_score` | `sensor` | % | node classification confidence × 100 while present, else 0 | always |
|
||||
| Motion energy | `motion_energy` | `sensor` | (dimensionless) | `node_features[].features.motion_band_power`, else the room `features.motion_band_power` | a band-power value was seen in the last 30 s |
|
||||
| Signal strength | `rssi` | `sensor` | dBm | `nodes[].rssi_dbm` | an RSSI was seen in the last 30 s |
|
||||
| Heart rate | `heart_rate` | `sensor` | bpm | `vital_signs.heart_rate_bpm` (room-level) | a published estimate in the last 30 s |
|
||||
| Breathing rate | `breathing_rate` | `sensor` | bpm | `vital_signs.breathing_rate_bpm` (room-level) | a published estimate in the last 30 s |
|
||||
| Fall detected | `fall` | `event` | — | rising edge of the node's `edge_vitals.fall_detected` | the node sent `edge_vitals` in the last 30 s |
|
||||
|
||||
The server publishes heart and breathing rate only after explicit calibration, with exactly one occupant and a passing quality gate. Until then both entities stay unavailable. Heart rate and breathing rate are **biometric**: `--privacy-mode` removes them from discovery and state (see [Privacy](#privacy)).
|
||||
|
||||
### Semantic automation primitives (6 entities)
|
||||
|
||||
Small finite-state machines run inside the server, one set per node, sampled once per second ([ADR-115 §3.12](../adr/ADR-115-home-assistant-integration.md#312-semantic-automation-primitives-ha-mind), P4.5). All of them are unavailable for the first 60 s after start (warmup). After that each publishes its current state, then on every change.
|
||||
|
||||
| HA entity | Slug | HA component | What it fires on | Available when |
|
||||
|---|---|---|---|---|
|
||||
| Presence | `presence` | `binary_sensor` | — | `edge_vitals.presence` |
|
||||
| Person count | `person_count` | `sensor` | persons | `edge_vitals.n_persons` |
|
||||
| Heart rate | `heart_rate` | `sensor` | bpm | `edge_vitals.heartrate_bpm` |
|
||||
| Breathing rate | `breathing_rate` | `sensor` | bpm | `edge_vitals.breathing_rate_bpm` |
|
||||
| Motion level | `motion_level` | `sensor` | % | `edge_vitals.motion` × 100 |
|
||||
| Motion energy | `motion_energy` | `sensor` | (dimensionless) | `edge_vitals.motion_energy` |
|
||||
| Fall detected | `fall` | `event` | — | `edge_vitals.fall_detected` |
|
||||
| Presence score | `presence_score` | `sensor` | % | `edge_vitals.presence_score` × 100 |
|
||||
| Signal strength | `rssi` | `sensor` | dBm | `edge_vitals.rssi` |
|
||||
| Zone occupancy | `zone_occupancy` | `binary_sensor` | — | `sensing_update.zones` |
|
||||
| Pose keypoints | `pose` | `sensor` (attrs) | — | `pose_data.keypoints` (opt-in via `--mqtt-publish-pose`) |
|
||||
| Room active | `room_active` | `binary_sensor` | presence + motion >10% within a 30 s window; off after 10 min idle | after warmup |
|
||||
| No movement (safety) | `no_movement` | `binary_sensor` | presence + motion <1% for 30 min | after warmup |
|
||||
| Elderly inactivity anomaly | `elderly_inactivity_anomaly` | `binary_sensor` | stillness > 2× the longest stillness seen since start (floor 30 min) | after warmup |
|
||||
| Someone sleeping | `someone_sleeping` | `binary_sensor` | presence + motion <5% + BR ∈ [8,20] bpm for 5 min | after warmup, while breathing rate is available |
|
||||
| Possible distress | `possible_distress` | `binary_sensor` | HR ≥ 1.5× resting baseline + motion >20% + no fall, for 60 s | after warmup, while heart rate is available |
|
||||
| Fall risk elevated | `fall_risk_elevated` | `sensor` | 0–100 score: 10 per fall in 24 h + 50 × motion variance over 60 s | after warmup, while the fall detector is available |
|
||||
|
||||
Heart rate, breathing rate, and pose are **biometric** entities — they are stripped from MQTT (and never published over Matter) when `--privacy-mode` is set. See [Privacy](#privacy) below.
|
||||
Per-node motion is a three-level classification, so the motion thresholds above effectively mean "moving" or "not moving". The precision and recall figures in [`semantic-primitives-metrics.md`](./semantic-primitives-metrics.md) are CLAIMED: the replay tooling they cite is not in the repository. No `reason` attribute is published yet; the explanation tags stay server-side.
|
||||
|
||||
### Semantic automation primitives (10 entities)
|
||||
### Not announced
|
||||
|
||||
These are the inferred high-level states that customer automations actually use. Each one is a small finite-state machine running server-side with explicit warmup, hysteresis, and refractory windows. Per-primitive precision/recall is published in [`semantic-primitives-metrics.md`](./semantic-primitives-metrics.md).
|
||||
These entities from ADR-115 §3.1 and §3.12 are not announced, because the sensing broadcast has nothing to drive them. Announcing them only produced entities stuck at "unknown" ([#2085](https://github.com/ruvnet/RuView/issues/2085)).
|
||||
|
||||
| HA entity | Slug | HA component | What it fires on |
|
||||
|---|---|---|---|
|
||||
| Someone sleeping | `someone_sleeping` | `binary_sensor` | presence + motion<5% + BR ∈ [8,20] bpm sustained for 5 min |
|
||||
| Possible distress | `possible_distress` | `binary_sensor` | HR > 1.5× baseline + motion >20% + no fall, sustained 60 s |
|
||||
| Room active | `room_active` | `binary_sensor` | motion >10% in a 30-s rolling window |
|
||||
| Elderly inactivity anomaly | `elderly_inactivity_anomaly` | `binary_sensor` | idle > 2× observed-max-idle baseline |
|
||||
| Meeting in progress | `meeting_in_progress` | `binary_sensor` | ≥2 persons + low-amplitude motion for 10 min |
|
||||
| Bathroom occupied | `bathroom_occupied` | `binary_sensor` | presence + active zone tagged `bathroom` |
|
||||
| Fall risk elevated | `fall_risk_elevated` | `sensor` | 0–100 score; event fires on ≥70 crossing |
|
||||
| Bed exit (overnight) | `bed_exit` | `event` | sleeping → presence leaves bed zone between 22:00–06:00 |
|
||||
| No movement (safety) | `no_movement` | `binary_sensor` | presence + motion <1% for 30 min |
|
||||
| Multi-room transition | `multi_room_transition` | `event` | zone X exit + zone Y enter within 10 s |
|
||||
| Slug | Why not |
|
||||
|---|---|
|
||||
| `zone_occupancy`, `bathroom_occupied`, `bed_exit`, `multi_room_transition` | the broadcast carries no zones, and the server loads no zone-tag map |
|
||||
| `meeting_in_progress` | needs a 1–20 % motion level; per-node motion is three-level |
|
||||
| `pose` | `pose_keypoints` is never populated on the broadcast, and the `persons[].keypoints` skeletons are synthesized. `--mqtt-publish-pose` is accepted but logs a warning and announces nothing |
|
||||
|
||||
Every state change carries a `reason` attribute (e.g. `["motion<5%", "br=12bpm", "presence=true"]`) so you can template against it in HA automations to understand why an automation triggered.
|
||||
### Publish rates and retention
|
||||
|
||||
| Entity kind | When it publishes | QoS / retain |
|
||||
|---|---|---|
|
||||
| `presence` and semantic `binary_sensor`s | on change, plus a heartbeat every 60 s if unchanged | 1 / retained |
|
||||
| `person_count`, `motion_level`, `motion_energy`, `presence_score`, `fall_risk_elevated` | at most `--mqtt-rate-motion` / `--mqtt-rate-count` (default 1 Hz) | 0 / not retained |
|
||||
| `heart_rate`, `breathing_rate` | at most `--mqtt-rate-vitals` (default 0.2 Hz) | 0 / not retained |
|
||||
| `rssi` | at most `--mqtt-rate-rssi` (default 0.1 Hz) | 0 / not retained |
|
||||
| `fall` | once per detected fall | 1 / not retained |
|
||||
| availability | on every transition, and all topics every 30 s | 1 / retained |
|
||||
|
||||
Before [#2093](https://github.com/ruvnet/RuView/issues/2093), `presence` was published on every CSI frame (MEASURED 37.9 msg/s with one node, 1 msg per ~45 s after the fix; see [ADR-115 §11](../adr/ADR-115-home-assistant-integration.md#11-status-note-p45-semantic-states-on-the-live-publisher-2026-10-02)).
|
||||
|
||||
### Device identity
|
||||
|
||||
Each node's HA device id is `wifi_densepose_<client-id>-node<N>`, where `<N>` is the node id from the CSI frames. Without `--mqtt-client-id`, the server creates `wifi-densepose-<8 hex digits>` on first start and keeps it in `<data-dir>/mqtt_client_id`, so device ids survive restarts. Keep `--data-dir` on persistent storage (a Docker volume, for example). If the file cannot be written, the server logs a warning and falls back to a per-process id for that run. `--mqtt-client-id` overrides the persisted id. Use it to pin ids across reinstalls or to run two servers against one broker.
|
||||
|
||||
Releases before this change used `wifi-densepose-<pid>`, so every restart created a new set of devices. To remove the leftovers, publish an empty retained message to each stale `homeassistant/+/wifi_densepose_<old-id>…/+/config` topic, or delete the devices in Home Assistant.
|
||||
|
||||
### Matter device-type mapping
|
||||
|
||||
@@ -115,7 +138,7 @@ Per ADR-115 §3.11.1, the Matter Bridge exposes a subset on standard clusters so
|
||||
| `--mqtt-port <PORT>` | 1883 (8883 with TLS) | Broker port |
|
||||
| `--mqtt-username <U>` | — | Username for broker auth |
|
||||
| `--mqtt-password-env <VAR>` | `MQTT_PASSWORD` | Env var holding the password |
|
||||
| `--mqtt-client-id <ID>` | `wifi-densepose-<hostname>` | MQTT client ID |
|
||||
| `--mqtt-client-id <ID>` | persisted `wifi-densepose-<hex>` in `<data-dir>/mqtt_client_id` | MQTT client ID; prefixes every HA device id |
|
||||
| `--mqtt-prefix <PREFIX>` | `homeassistant` | Discovery topic prefix |
|
||||
| `--mqtt-tls` | off | Encrypt connection |
|
||||
| `--mqtt-ca-file <PATH>` | — | Pinned CA for TLS / mTLS |
|
||||
@@ -126,43 +149,21 @@ Per ADR-115 §3.11.1, the Matter Bridge exposes a subset on standard clusters so
|
||||
| `--mqtt-rate-motion <HZ>` | 1.0 | Motion publish rate (Hz) |
|
||||
| `--mqtt-rate-count <HZ>` | 1.0 | Person-count publish rate (Hz) |
|
||||
| `--mqtt-rate-rssi <HZ>` | 0.1 | RSSI publish rate (Hz) |
|
||||
| `--mqtt-publish-pose` | off | Enable pose-keypoint publication |
|
||||
| `--mqtt-rate-pose <HZ>` | 1.0 | Pose publish rate when enabled |
|
||||
| `--privacy-mode` | off | Strip HR/BR/pose from MQTT and Matter |
|
||||
| `--mqtt-publish-pose` | off | Accepted; no pose source exists, so it only logs a warning |
|
||||
| `--mqtt-rate-pose <HZ>` | 1.0 | Unused while no pose entity is announced |
|
||||
| `--privacy-mode` | off | Strip HR/BR/pose from MQTT, Matter, REST, WebSocket and recordings |
|
||||
| `--data-dir <DIR>` | `data` | Holds `mqtt_client_id` (and other server state) |
|
||||
| `--matter` | off | Enable the HA-FABRIC Matter Bridge |
|
||||
| `--matter-setup-file <PATH>` | — | Where to write the QR + manual code |
|
||||
| `--matter-reset` | off | Wipe fabric credentials and re-commission |
|
||||
| `--matter-vendor-id <VID>` | `0xFFF1` (dev) | CSA-assigned vendor ID |
|
||||
| `--matter-product-id <PID>` | `0x8001` | Product ID |
|
||||
| `--semantic` | on | Enable inference layer |
|
||||
| `--semantic-thresholds-file <PATH>` | — | Per-primitive threshold overrides |
|
||||
| `--semantic-zones-file <PATH>` | — | Zone-tag map (`bathroom`, `bedroom`, …) |
|
||||
| `--no-semantic <PRIMITIVE>` | — | Disable a specific primitive (repeatable) |
|
||||
|
||||
### Zone tag file format
|
||||
The `--semantic`, `--semantic-thresholds-file`, `--semantic-zones-file` and `--no-semantic` flags from ADR-115 §3.12.5 are **not accepted by `sensing-server` yet**. The primitives always run with the defaults in `PrimitiveConfig`, and no zone map is loaded.
|
||||
|
||||
```yaml
|
||||
# semantic-zones.yaml — passed to --semantic-zones-file
|
||||
zones:
|
||||
bathroom: ["zone_3", "zone_7"]
|
||||
bedroom: ["zone_1"]
|
||||
kitchen: ["zone_2"]
|
||||
living: ["zone_5"]
|
||||
bed_zones: ["zone_1"]
|
||||
```
|
||||
### Zone tags and threshold overrides (not implemented)
|
||||
|
||||
### Threshold overrides
|
||||
|
||||
```yaml
|
||||
# semantic-thresholds.yaml — passed to --semantic-thresholds-file
|
||||
sleep_dwell_secs: 300
|
||||
distress_hr_multiple: 1.5
|
||||
room_active_motion_threshold: 0.10
|
||||
elderly_anomaly_multiple: 2.0
|
||||
meeting_min_persons: 2
|
||||
no_movement_dwell_secs: 1800
|
||||
fall_risk_event_threshold: 70.0
|
||||
```
|
||||
ADR-115 §3.12.5 specifies a zone-tag file and a threshold-override file. Neither is read by `sensing-server` today, which is why the zone-based primitives are not announced.
|
||||
|
||||
---
|
||||
|
||||
@@ -170,9 +171,11 @@ fall_risk_event_threshold: 70.0
|
||||
|
||||
When deploying in **healthcare**, **AAL (aging-in-place)**, or **commercial** settings, set `--privacy-mode`. This:
|
||||
|
||||
- **Strips** heart rate, breathing rate, and pose keypoints from every outbound MQTT publication.
|
||||
- **Suppresses discovery** for those entities entirely — HA never even sees they exist.
|
||||
- **Keeps every semantic primitive enabled.** Sleeping / distress / room-active / etc are *inferred* states. The inference happens server-side and only the boolean or score crosses the wire. This is the architectural win that makes the platform deployable in regulated contexts.
|
||||
- **Strips** heart rate and breathing rate from every outbound MQTT publication and, in the same way, from REST responses, WebSocket frames and recordings (pose is never announced over MQTT; see [Not announced](#not-announced)).
|
||||
- **Suppresses discovery** for those entities entirely: Home Assistant never sees them, and no availability or state topic is published for them.
|
||||
- **Keeps the semantic primitives enabled.** `someone_sleeping` and `possible_distress` still use breathing and heart rate *inside the server*; only the ON/OFF state crosses the wire (ADR-115 §3.12.3). If that is too much for your deployment, drop those two entities in Home Assistant.
|
||||
|
||||
With `--privacy-mode`, 13 entities are announced per node. Privacy mode reduces what leaves the server. It does not by itself make a deployment compliant with any regulation.
|
||||
|
||||
Always pair `--privacy-mode` with `--mqtt-tls` on non-localhost brokers.
|
||||
|
||||
@@ -210,7 +213,6 @@ action:
|
||||
title: "Possible distress detected"
|
||||
message: >
|
||||
RuView flagged sustained elevated heart rate + agitated motion.
|
||||
Reason: {{ state_attr(trigger.entity_id, 'reason') }}.
|
||||
```
|
||||
|
||||
### 2. Dim hallway when someone is sleeping
|
||||
@@ -256,6 +258,8 @@ action:
|
||||
|
||||
### 3. Wake-up routine on bed exit
|
||||
|
||||
> `bed_exit` is not published by `sensing-server` today (it needs a bed-zone map; see [Not announced](#not-announced)). This blueprint will not fire until that lands.
|
||||
|
||||
```yaml
|
||||
blueprint:
|
||||
name: RuView — wake-up routine on bed exit
|
||||
@@ -335,8 +339,8 @@ cards:
|
||||
entity: binary_sensor.ruview_kitchen_presence
|
||||
name: Kitchen
|
||||
- type: tile
|
||||
entity: binary_sensor.ruview_bathroom_occupied
|
||||
name: Bathroom
|
||||
entity: binary_sensor.ruview_bedroom_room_active
|
||||
name: Bedroom active
|
||||
```
|
||||
|
||||
---
|
||||
@@ -373,6 +377,15 @@ All three accept the same HA discovery topics RuView publishes. Performance and
|
||||
wscat -c ws://localhost:8765/ws/sensing
|
||||
```
|
||||
3. Confirm rate limits aren't dropping everything: `--mqtt-rate-vitals 1.0` for diagnosis (default 0.2 Hz = every 5 s).
|
||||
4. Unchanged binary states (presence, the semantic primitives) publish once per change and then every 60 s. A flat line in HA history is expected while nothing changes.
|
||||
|
||||
### Entity shows "unavailable"
|
||||
|
||||
The server has no source for it right now: heart and breathing rate need explicit calibration and a single occupant, `fall` needs a node that sends `edge_vitals`, and the semantic primitives wait 60 s after start (`someone_sleeping` and `possible_distress` also need breathing or heart rate). Check the availability topic with `mosquitto_sub -t 'homeassistant/+/+/<slug>/availability' -v`.
|
||||
|
||||
### Duplicate devices after a restart
|
||||
|
||||
Older releases derived device ids from the process id. See [Device identity](#device-identity) for how to remove the stale devices; the current default id is persisted in `--data-dir`.
|
||||
|
||||
### "Plaintext MQTT on non-localhost broker" WARN
|
||||
|
||||
@@ -391,7 +404,7 @@ Per [ADR-115 §3.9](../adr/ADR-115-home-assistant-integration.md#39-tls--auth),
|
||||
|
||||
## Applications — what people actually do with this
|
||||
|
||||
The 21 entities per node — 11 raw signals (presence, person count, breathing, heart rate, motion, RSSI, etc.) and 10 inferred semantic states (someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting-in-progress, bathroom-occupied, fall-risk-elevated, bed-exit, no-movement, multi-room-transition) — slot into Home Assistant like any other sensor. The list below groups real-world uses so you can pick the ones that match your space.
|
||||
The 15 entities per node (9 raw signals and 6 semantic states, see the [Entity reference](#entity-reference)) slot into Home Assistant like any other sensor. Some uses below rely on entities that are not announced yet (zones, bathroom, bed exit, meeting); those wait on a zone source or a finer motion level. The list below groups real-world uses so you can pick the ones that match your space.
|
||||
|
||||
### Personal & home
|
||||
|
||||
@@ -495,13 +508,9 @@ A few patterns appear over and over; if you understand these you can build most
|
||||
|
||||
### What about regulated environments?
|
||||
|
||||
Run RuView with `--privacy-mode` and only the 10 inferred semantic states reach Home Assistant — heart rate, breathing rate, and pose values are stripped at the MQTT wire. Per ADR-115 §6, this passes:
|
||||
With `--privacy-mode`, heart rate and breathing rate never reach the broker; their entities are not even announced. The semantic states still reach Home Assistant, and two of them (`someone_sleeping`, `possible_distress`) are derived from those vitals inside the server, so their ON/OFF values are themselves health-related information.
|
||||
|
||||
- **HIPAA-style minimum-necessary** (no biometric numbers leave the device)
|
||||
- **GDPR purpose-limitation** (the inferred states are the smallest dataset that supports the automation)
|
||||
- **CCPA "sensitive personal information"** (no health data crosses the wire)
|
||||
|
||||
The fall-risk-elevated / possible-distress / someone-sleeping flags still work — they're computed *inside* the sensor pipeline and only the boolean outputs are published. That's the architectural win that makes RuView deployable in care homes, hospitals, schools, and shared-housing scenarios where raw biometrics would be a non-starter.
|
||||
Privacy mode narrows what crosses the MQTT wire. It is not a compliance certification, and RuView makes no HIPAA, GDPR or CCPA compliance claim. Whether a deployment meets a regulation depends on the whole system: broker access control and TLS, Home Assistant's recorder retention, who can see the dashboards, consent, and local law. Assess that with whoever is responsible for compliance in your setting.
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
# MediaTek Router CSI (MT7981, experimental)
|
||||
|
||||
RuView can take channel state information (CSI) from a MediaTek Filogic
|
||||
router, such as an MT7981B with an MT7976C radio running OpenWrt with
|
||||
MediaTek's vendor CSI patch. The router reports CSI. A host-side bridge
|
||||
converts it to RuView's MTC1 wire format (ADR-267). The sensing server ingests
|
||||
it like any other UDP source.
|
||||
|
||||
**Status: experimental.** This page documents a working data path, measured
|
||||
on one board model. It makes no sensing-quality claim. Frames from this path
|
||||
carry the provenance label `mediatek:physical-unvalidated`. The ADR-266
|
||||
hardware gates are still open: calibration, sequence, timestamp, and
|
||||
repeatability.
|
||||
|
||||
Developed in whitsentry, a RuView-based project.
|
||||
|
||||
Evidence tags used below: **MEASURED** (with a reproducer), **CODE-DERIVED**
|
||||
(read from source or build scripts, not run here), **CLAIMED** (stated, not
|
||||
independently verified), **SYNTHETIC** (simulated or fabricated data).
|
||||
|
||||
## Contents
|
||||
|
||||
- [Supported hardware](#supported-hardware)
|
||||
- [How the path works](#how-the-path-works)
|
||||
- [Build the OpenWrt image](#build-the-openwrt-image)
|
||||
- [Arm CSI on the router](#arm-csi-on-the-router)
|
||||
- [Run the bridge](#run-the-bridge)
|
||||
- [Start the sensing server](#start-the-sensing-server)
|
||||
- [What the data is, and is not](#what-the-data-is-and-is-not)
|
||||
- [Evidence](#evidence)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Reference](#reference)
|
||||
|
||||
## Supported hardware
|
||||
|
||||
| Board | SoC + radio | Status |
|
||||
|---|---|---|
|
||||
| Wavlink WL-WN586X3 **Rev A** | MT7981BA + MT7976CN (switch MT7531AE) | Run on two units. CSI captured from both is analysed offline below (MEASURED). Live delivery to the sensing server is CLAIMED from local run logs that are not in the tree. |
|
||||
| OpenWrt One | MT7981B + MT7976C | CLAIMED: same silicon per the OpenWrt hardware table and ADR-266. Not run with this patch set. |
|
||||
| Xiaomi AX3000T | MT7981B + MT7976C | CLAIMED: same silicon per the OpenWrt hardware table. Not run with this patch set. |
|
||||
| Other MT7981/MT7986 boards | — | Untested. The CSI patch targets the `mt7915` driver family, so other Filogic radios may work, but nothing here shows that. |
|
||||
|
||||
The board patch in `firmware/openwrt-wn586x3/` is specific to the WN586X3
|
||||
**Rev A**. The CSI driver patch and the two userspace fixes do not depend on
|
||||
the board.
|
||||
|
||||
Each MT7976C radio has **two receive chains**. The WN586X3's four external
|
||||
antennas are a 2.4 GHz pair and a 5 GHz pair. It is not a 4x4 array, and two
|
||||
routers are two independent receivers, not one phased array.
|
||||
|
||||
## How the path works
|
||||
|
||||
```
|
||||
router (OpenWrt 24.10.8) host
|
||||
┌────────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ mt76 + MediaTek CSI patch │ │ wifi-densepose-mtk-bridge │
|
||||
│ firmware MCU event 0xc2 │ JSON dump │ groups chains into frames, │
|
||||
│ mt76-vendor dump csi ─────────┼──────────────►│ applies provenance gate, │
|
||||
│ CSIdump (UDP, optional) ───────┼──── UDP ─────►│ emits ADR-267 MTC1 │
|
||||
└────────────────────────────────┘ └──────────────┬───────────────┘
|
||||
│ UDP :5005
|
||||
┌──────────────▼───────────────┐
|
||||
│ sensing-server │
|
||||
│ --source mediatek │
|
||||
│ /api/v1/csi/mediatek/* │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
Two transports leave the router:
|
||||
|
||||
- **`mt76-vendor <iface> dump csi <n> <file>`** writes a JSON array of
|
||||
per-chain records: timestamp, transmitter address, RSSI, SNR, bandwidth,
|
||||
PPDU mode, tx/rx chain index, chain marker, and I/Q. This is the richer
|
||||
path, and the one that carried sustained use. You copy the file to the host
|
||||
(for example, a loop over SSH) and replay it through the bridge.
|
||||
- **`CSIdump`** (MtkCSIdump) streams one antenna per UDP datagram and drops
|
||||
RSSI, PPDU mode, and transmitter identity. The bridge emits one 1x1 frame per
|
||||
datagram. This path was verified end to end once, as a recording. It has not
|
||||
run as a standing service.
|
||||
|
||||
## Build the OpenWrt image
|
||||
|
||||
The patch set lives in [`firmware/openwrt-wn586x3/`](../firmware/openwrt-wn586x3/README.md).
|
||||
It is not a firmware image. Build and hash your own.
|
||||
|
||||
| Patch | Applies to |
|
||||
|---|---|
|
||||
| `1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch` | `mt76` package (`package/kernel/mt76/patches/`) |
|
||||
| `mt76-vendor-csi-dump-bounds.patch` | `mt76-vendor` source (`feed/app/mt76-vendor/src/csi.c` in `mediatek/mtk-openwrt-feeds`) |
|
||||
| `csidump-nl80211-attr-enum.patch` | MtkCSIdump `src/wifi_drv_api/mt76_api.cpp` (only if you use CSIdump) |
|
||||
| `0001-wn586x3-reva-single-image-sysupgrade-and-generic-spi-nor.patch` | `target/linux/mediatek` (WN586X3 Rev A only) |
|
||||
|
||||
The route that produced the tested image (CODE-DERIVED from its build scripts)
|
||||
used the stock OpenWrt **24.10.8** `mediatek/filogic` SDK and ImageBuilder in
|
||||
an x86_64 Linux container:
|
||||
|
||||
1. **SDK.** Unpack `openwrt-sdk-24.10.8-mediatek-filogic`, then run
|
||||
`./scripts/feeds update -a && ./scripts/feeds install -a`.
|
||||
2. **CSI driver.** Copy the rebased `1001-...` patch into
|
||||
`feeds/base/package/kernel/mt76/patches/`. Raise `PKG_RELEASE` in that
|
||||
package's `Makefile` (the tested build used `99`) so the ImageBuilder
|
||||
prefers your build over the CDN package of the same version.
|
||||
3. **mt76-vendor.** Add MediaTek's `mt76-vendor` package from
|
||||
`mtk-openwrt-feeds` as `package/mt76-vendor`, with the bounds patch applied
|
||||
to `src/csi.c`.
|
||||
4. **Configure and compile.** Select `CONFIG_TARGET_mediatek_filogic_DEVICE_wavlink_wl-wn586x3`,
|
||||
`kmod-mt7915e`, `mt76-vendor`, and `libnl-tiny`. Then run
|
||||
`make package/feeds/base/mt76/compile package/mt76-vendor/compile`.
|
||||
5. **Image.** Copy the built `.ipk` files into the ImageBuilder's `packages/`
|
||||
and run:
|
||||
```bash
|
||||
make image PROFILE=wavlink_wl-wn586x3 \
|
||||
PACKAGES="kmod-mt7915e kmod-mt7981-firmware mt7981-wo-firmware mt76-vendor libnl-tiny"
|
||||
```
|
||||
6. **Check the manifest.** The image's `.manifest` must list `kmod-mt7915e`
|
||||
at your raised release (for example `-r99`) and `mt76-vendor`. If it lists
|
||||
the CDN release, the image has no CSI.
|
||||
|
||||
**Rev A board patch.** An ImageBuilder cannot compile a device tree, and its
|
||||
`make image` reuses the prebuilt DTB and FIT kernel. The tested Rev A build
|
||||
compiled the patched DTB itself, rebuilt the FIT kernel around it, added the
|
||||
patched `lib/upgrade/platform.sh` as a rootfs overlay, then checked that the
|
||||
final sysupgrade image embedded that DTB. A full OpenWrt buildroot applies
|
||||
`target/` patches directly and avoids those steps. That route was not tested
|
||||
here.
|
||||
|
||||
Flash only a Rev A unit, keep the stock firmware backup, and follow the
|
||||
OpenWrt wiki and forum thread for this board. Neither this guide nor the patch
|
||||
set names a LAN, an account, or a password.
|
||||
|
||||
**Firmware blobs.** The CSI report is MCU event `0xc2`. The MT7981 firmware in
|
||||
the tested image (`kmod-mt7981-firmware`, `mt7981-wo-firmware` from OpenWrt
|
||||
24.10.8) already emits it, so no firmware blob swap was needed on the tested
|
||||
board. Other firmware versions are untested.
|
||||
|
||||
## Arm CSI on the router
|
||||
|
||||
CSI follows traffic from **associated clients**. With nothing associated and
|
||||
transmitting, there is no CSI. Run these on the router, as root, against the AP
|
||||
interface (`phy0-ap0` was the 2.4 GHz AP on the tested board). The sequence
|
||||
below is CODE-DERIVED from the capture scripts used on the tested board:
|
||||
|
||||
```sh
|
||||
mt76-vendor phy0-ap0 set csi ctrl=0,0,0,0 # disarm and reset
|
||||
mt76-vendor phy0-ap0 set csi interval=1000
|
||||
mt76-vendor phy0-ap0 set csi ctrl=2,3,0,34 # frame-type filter, as used on
|
||||
mt76-vendor phy0-ap0 set csi ctrl=2,9,1,0 # the tested board (data frames)
|
||||
mt76-vendor phy0-ap0 set csi ctrl=1,0,0,0 # arm
|
||||
```
|
||||
|
||||
Check that the driver is collecting:
|
||||
|
||||
```sh
|
||||
cat /sys/kernel/debug/ieee80211/phy0/mt76/csi_stats # data_cnt should climb
|
||||
iw dev phy0-ap0 station dump | grep -E "Station|signal"
|
||||
```
|
||||
|
||||
Pull a batch:
|
||||
|
||||
```sh
|
||||
rm -f /tmp/csi.json # the tool APPENDS; remove first
|
||||
mt76-vendor phy0-ap0 dump csi 64 /tmp/csi.json
|
||||
```
|
||||
|
||||
`dump csi` needs the filename argument. Without it, the tool returns nothing.
|
||||
|
||||
## Run the bridge
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo build --release -p wifi-densepose-mtk-bridge
|
||||
B=./target/release/wifi-densepose-mtk-bridge
|
||||
```
|
||||
|
||||
**Dump path (recommended).** Copy `/tmp/csi.json` to the host, then replay it.
|
||||
A file cannot prove it came off a radio, so `--replay` refuses to start until
|
||||
you either attest the hardware or declare the file synthetic:
|
||||
|
||||
```bash
|
||||
$B --replay csi.json --captured-on <model>/<firmware> \
|
||||
--node <node-name> --center-freq-khz <khz> --sink 127.0.0.1:5005
|
||||
```
|
||||
|
||||
- `--node` is hashed into the 64-bit `device_id`. Give each router its own name.
|
||||
- `--center-freq-khz` is the AP channel's centre frequency, for example
|
||||
`2437000` for 2.4 GHz channel 6.
|
||||
- `--captured-on` takes `<model>/<firmware>`, for example
|
||||
`WN586X3-RevA/openwrt-24.10.8-csi`. It is logged at startup.
|
||||
|
||||
For a continuous feed, repeat dump, copy, and replay in a loop, one loop per
|
||||
router. Keep a copy of each dump only if you need it, and never commit one
|
||||
(see below).
|
||||
|
||||
**Streaming path (CSIdump).** Start `CSIdump phy0-ap0 <rate> 8888` on the
|
||||
router. Then, on the host:
|
||||
|
||||
```bash
|
||||
$B --listen 0.0.0.0:8888 --csidump <router-ip>:8888 --listen-allow <router-ip> \
|
||||
--node <node-name> --center-freq-khz <khz> --sink 127.0.0.1:5005 \
|
||||
--record captures/<node-name>.mtkcap
|
||||
```
|
||||
|
||||
`--listen-allow` is mandatory. Datagrams from any other source are dropped and
|
||||
counted. CSIdump disarms CSI when it receives SIGTERM. The dump path and
|
||||
CSIdump cannot share the driver's ring, so run one or the other.
|
||||
|
||||
**No router.** The fabricated fixture exercises the whole host path. It must
|
||||
be replayed with `--synthetic`, and the server labels it `mediatek:simulated`:
|
||||
|
||||
```bash
|
||||
$B --replay crates/wifi-densepose-mtk-bridge/fixtures/FABRICATED-mt76-vendor-dump-mt7981-bw80.synthetic.json \
|
||||
--synthetic --node demo --sink 127.0.0.1:5005
|
||||
```
|
||||
|
||||
The ADR-266 simulator is another synthetic source:
|
||||
|
||||
```bash
|
||||
cargo run -p wifi-densepose-hardware --bin mediatek-csi-sim -- \
|
||||
--profile mt7981 --udp 127.0.0.1:5005 --frames 1000 --realtime
|
||||
```
|
||||
|
||||
Full flag reference: [`v2/crates/wifi-densepose-mtk-bridge/README.md`](../v2/crates/wifi-densepose-mtk-bridge/README.md).
|
||||
|
||||
## Start the sensing server
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo run --release -p wifi-densepose-sensing-server -- --source mediatek \
|
||||
--activity-state <state-dir>/activity-floor.json
|
||||
```
|
||||
|
||||
- `--source mediatek` binds the UDP receiver (loopback by default, ADR-296) and
|
||||
does **not** start the ESP32-shaped simulator. Before this source existed,
|
||||
`--source mediatek` bound nothing.
|
||||
- If the bridge runs on another host, bind a routable address with
|
||||
`--udp-bind` and name the bridge host in `--udp-allow`.
|
||||
- `--activity-state` is optional. It persists each device's learned
|
||||
quiet-room activity floor so that a restart does not relearn it.
|
||||
- `--csi-ring <n>` sets frames retained per device for inspection (default
|
||||
256, about 0.5 MiB per 2x2 device at 64 subcarriers).
|
||||
|
||||
Endpoints:
|
||||
|
||||
| Route | Returns |
|
||||
|---|---|
|
||||
| `GET /api/v1/csi/mediatek/latest` | Last MTC1 snapshot, any device |
|
||||
| `GET /api/v1/csi/mediatek/devices` | One snapshot per `device_id`, with `age_ms`. Devices silent for over 30 s are omitted. |
|
||||
| `GET /api/v1/csi/mediatek/devices/:device_id/frames?n=&fields=amp,phase` | Newest `n` frames of per-chain, per-subcarrier amplitude and phase |
|
||||
| `GET /api/v1/csi/mediatek/devices/:device_id/summary` | Per-chain amplitude mean/std per subcarrier, RSSI per Rx chain, frame rate, heuristic window stats |
|
||||
| `GET /api/v1/csi/mediatek/activity?seconds=300` | About 1 Hz history of the relative activity index |
|
||||
| `GET /api/v1/sensing/latest` | Room update. On this source, `classifier` is `mediatek-amplitude-heuristic-v0`. |
|
||||
|
||||
Check the label on every snapshot:
|
||||
|
||||
| `source` | Meaning |
|
||||
|---|---|
|
||||
| `mediatek:simulated` | `SYNTHETIC` flag set. Nothing downstream can clear it. |
|
||||
| `mediatek:physical-unvalidated` | Real silicon, not calibrated. Everything this path produces today. |
|
||||
| `mediatek` | Real and calibrated. Reserved. Nothing emits it yet. |
|
||||
|
||||
## What the data is, and is not
|
||||
|
||||
**It is** complex channel estimates per receive chain from a real MT7976C
|
||||
radio, carried end to end with provenance intact.
|
||||
|
||||
**It is not:**
|
||||
|
||||
- **A sensor with its own clock.** The report rate is the clients' traffic
|
||||
rate. An idle phone gives a few frames per second, and a busy one gives
|
||||
hundreds. With no associated, transmitting client, there is no data. A
|
||||
stationary device that keeps transmitting to the AP (an "illuminator", for
|
||||
example an ESP32 sending a steady packet stream) gives a regular rate
|
||||
(CLAIMED: the 09-20 and 09-24 captures below include an illuminator's
|
||||
frames; transmitter identity was not re-checked in this analysis).
|
||||
- **Calibrated.** `scale` is 1.0 in raw firmware units. Nothing has been
|
||||
checked against a reference. The ADR-266 gates are open.
|
||||
- **Validated for presence, motion, or vital signs.** The room output on this
|
||||
source comes from `mediatek-amplitude-heuristic-v0` (presence and motion from
|
||||
mean amplitude) and `activity-index-v0` (fluctuation against a learned
|
||||
floor). Both are **CLAIMED**. Neither has been scored against labelled ground
|
||||
truth. `activity` blocks carry `is_occupancy_estimate: false`. These outputs
|
||||
do not feed the validated ESP32 vital-sign pipeline.
|
||||
- **Coherent across routers.** Each radio has its own oscillator. Cross-chain
|
||||
phase is meaningful within one radio, not between two boxes.
|
||||
- **Free of personal data.** A raw dump holds client transmitter addresses
|
||||
(MACs) and channel samples that encode movement. RuView policy (ADR-299)
|
||||
forbids committing captures. Keep them out of the repository, and run
|
||||
`scripts/csi-data-policy-check.sh --staged` before committing near capture
|
||||
directories.
|
||||
|
||||
## Evidence
|
||||
|
||||
| Claim | Tag | Basis |
|
||||
|---|---|---|
|
||||
| A real WL-WN586X3 frame decodes and reaches the server as `mediatek:physical-unvalidated` | MEASURED (path) | First record, 2026-09-19: legacy OFDM, 20 MHz, 64 subcarriers, one chain. Its shape is pinned by `wifi-densepose-mtk-bridge/tests/hardware_capture.rs`, which uses a fabricated record. The capture itself is not in the tree. |
|
||||
| Cross-chain phase is steady frame to frame on one MT7976C radio, and raw per-chain phase is not | MEASURED (offline) | See below |
|
||||
| `mediatek-amplitude-heuristic-v0`, `activity-index-v0` | CLAIMED | No labelled evaluation |
|
||||
| ADR-266 simulator and bridge fixtures | SYNTHETIC | Labelled on the wire |
|
||||
|
||||
**Cross-chain phase stability.** Six local dump captures from two WN586X3 units,
|
||||
2.4 GHz, 20 MHz HT frames carrying chains (tx0, rx0) and (tx0, rx1), adjacent
|
||||
frames under 100 ms apart, 56 subcarriers (DC and guard bins excluded). The
|
||||
two 2026-09-19 captures were analysed whole; the four 09-20 and 09-24 captures
|
||||
by their first 20 MB each.
|
||||
|
||||
| Quantity, per adjacent-frame step | Well-sampled transmitters (6, at 825 to 14,104 frames each) |
|
||||
|---|---|
|
||||
| Raw per-chain phase `arg(H0[k])`, per bin, p50 | 1.557 to 1.601 rad, with 49.6 to 50.9 % of steps over π/2 |
|
||||
| Cross-chain phase `arg(H0[k]·conj(H1[k]))`, per bin, p50 | 0.023 to 0.056 rad |
|
||||
| Cross-chain phase summed coherently over bins, p50 / p90 / p99 | 0.014–0.039 / 0.205–0.358 / 0.437–0.820 rad |
|
||||
|
||||
Uniformly random phase gives a step p50 of π/2 = 1.571 rad, and half of all
|
||||
steps exceed π/2. Raw per-chain phase is indistinguishable from that, as
|
||||
expected with per-packet CFO and phase offset. In the cross-chain term those
|
||||
offsets cancel, and the step drops to hundredths of a radian. Steps are
|
||||
wrapped, so p99 can never exceed π, and is not evidence by itself.
|
||||
|
||||
Transmitters with few, irregular frames (229 to 595 frames) were much worse:
|
||||
coherent-sum p50 0.177 to 0.456 rad, p99 1.857 to 2.849 rad. So the result
|
||||
holds only with a steady frame rate.
|
||||
|
||||
This shows a **stable phase observable exists** within one radio. It does
|
||||
**not** show that the observable tracks people, breathing, or anything else.
|
||||
|
||||
Reproduce it on your own capture (one `dump csi` array per line):
|
||||
|
||||
```bash
|
||||
python3 scripts/mediatek-csi-phase-stability.py <capture.jsonl> [max_MB]
|
||||
```
|
||||
|
||||
The script prints transmitters as `TX-A`, `TX-B`, and so on, never addresses.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---|---|---|
|
||||
| `csi_stats` `data_cnt` stays at 0 | No associated client transmitting, or CSI not armed | Associate a client and generate traffic, then re-run the arm sequence |
|
||||
| `data_cnt` stopped after a client roamed or the filter changed | Changing the CSI filter stops capture | Re-run the arm sequence ending in `ctrl=1,0,0,0`. Re-arming periodically is a reasonable guard. |
|
||||
| `mt76-vendor dump csi` prints nothing | No filename argument | Use `dump csi <n> <file>` |
|
||||
| `mt76-vendor dump csi` segfaults | Unpatched tool: the driver can return n+1 records | Rebuild with `mt76-vendor-csi-dump-bounds.patch` |
|
||||
| Bridge warns about repeated `(ta, ts, tx_idx, rx_idx)` chains | The dump file was not removed between dumps, and the tool appends | `rm -f` the file before every dump |
|
||||
| `--replay` refuses to start | Provenance not declared | Add `--captured-on <model>/<firmware>`, or `--synthetic` for fabricated input |
|
||||
| `--captured-on` is rejected | The input declares itself synthetic (sidecar, `.synthetic.` name, or capture header) | Expected. Synthetic is sticky. Use `--synthetic`. |
|
||||
| `--listen` refuses to start | No `--listen-allow` | Name the router's address or subnet |
|
||||
| `rejected_source` counts climb on `--listen` | The router's address is not in `--listen-allow` | Fix the allowlist |
|
||||
| CSIdump runs but nothing arrives | Unpatched CSIdump uses an off-by-one netlink attribute enum against this driver | Rebuild with `csidump-nl80211-attr-enum.patch` |
|
||||
| CSI stopped after CSIdump exited | CSIdump disarms CSI on SIGTERM | Re-run the arm sequence |
|
||||
| `/api/v1/csi/mediatek/devices` is empty | Server not on `--source mediatek` (or `auto` without UDP), wrong `--sink` port, or a non-loopback bridge not in `--udp-allow` | Check `--source`, port 5005, `--udp-bind`, and `--udp-allow` |
|
||||
| Two routers show as one device | Same `--node` on both bridges | Give each router its own `--node` (or `--device-id`) |
|
||||
| A frame shows `ppdu_type: legacy` | Normal. Association and management traffic is pre-HT. | None. ADR-267 Amendment 1 carries these frames rather than dropping them. |
|
||||
| Room status reads `unknown` for a device | The heuristic abstains below its confidence threshold | Expected with sparse frames. It is not an "absent" call. |
|
||||
| Room reads `absent` with `confidence: 0.0` | Every device abstained, so there is no evidence either way | Treat confidence 0 as "no information", not as an empty room |
|
||||
| Image flashes, but there is no `mt76-vendor` or CSI | The ImageBuilder used the CDN `kmod-mt7915e` | Check the manifest for your raised `PKG_RELEASE` |
|
||||
|
||||
## Reference
|
||||
|
||||
- [ADR-266: MediaTek Filogic CSI platform](adr/ADR-266-mediatek-filogic-csi-platform.md) (Amendment 1: vendor CSI on MT7981)
|
||||
- [ADR-267: MediaTek MIMO CSI wire protocol](adr/ADR-267-mediatek-mimo-csi-wire-protocol.md) (Amendment 1: `Legacy` and `Unknown` PPDU types)
|
||||
- [`firmware/openwrt-wn586x3/`](../firmware/openwrt-wn586x3/README.md): patches, licences, origins
|
||||
- [`v2/crates/wifi-densepose-mtk-bridge/`](../v2/crates/wifi-densepose-mtk-bridge/README.md): bridge transports, provenance rules, frame mapping
|
||||
- [MediaTek OpenWrt feeds](https://git01.mediatek.com/openwrt/feeds/mtk-openwrt-feeds/), [MtkCSIdump](https://github.com/MtkWifiRev/MtkCSIdump), [OpenWrt One](https://openwrt.org/toh/openwrt/one)
|
||||
@@ -111,7 +111,12 @@ handler (`health_ready`, `main.rs:8128,8133`) and return a `trust` block
|
||||
```json
|
||||
{
|
||||
"status": "ready",
|
||||
"source": "esp32",
|
||||
"source_state": "live_unverified",
|
||||
"waiting_for_frames": false,
|
||||
"last_frame_age_ms": 42,
|
||||
"trust": {
|
||||
"node_positions": { "1": [0.5, 0.0, 1.0], "2": [4.0, 3.0, 1.0] },
|
||||
"last_witness": "…64 hex chars or null…",
|
||||
"effective_class": "Anonymous | Restricted | …",
|
||||
"demoted": false,
|
||||
@@ -122,6 +127,11 @@ handler (`health_ready`, `main.rs:8128,8133`) and return a `trust` block
|
||||
}
|
||||
```
|
||||
|
||||
`node_positions` is the `--node-positions` map the governed fuser holds,
|
||||
keyed by node id. It is `{}` when no positions are configured, and then every
|
||||
node fuses at the origin. A node the map does not name also fuses at the
|
||||
origin.
|
||||
|
||||
**This is a real, currently-shipped diagnostic surface — but it is honestly
|
||||
limited.** It tells you *that* errors are occurring and *that* the current
|
||||
class is demoted, and the total count, but not *why* for your specific run:
|
||||
|
||||
+93
-22
@@ -21,6 +21,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
||||
- [Windows WiFi (RSSI Only)](#windows-wifi-rssi-only)
|
||||
- [ESP32-S3 (Full CSI)](#esp32-s3-full-csi)
|
||||
- [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced)
|
||||
- [MediaTek Router CSI (Experimental)](#mediatek-router-csi-experimental)
|
||||
- [Connect Mesh Data to the Dashboard and Observatory](#connect-mesh-data-to-the-dashboard-and-observatory)
|
||||
- [Cognitum Spaces activation](#cognitum-spaces-activation)
|
||||
- [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
|
||||
@@ -109,7 +110,7 @@ Multi-architecture image (amd64 + arm64). Works on Intel/AMD and Apple Silicon M
|
||||
| `simulated` | Generate synthetic CSI frames (no hardware required) |
|
||||
| `wifi` | Host Wi-Fi RSSI (not available inside containers) |
|
||||
|
||||
Example: `docker run -e CSI_SOURCE=esp32 -p 3000:3000 -p 5005:5005/udp ruvnet/wifi-densepose:latest`
|
||||
Example: `docker run -e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> -p 127.0.0.1:3000:3000 -p 5005:5005/udp ruvnet/wifi-densepose:latest`. The container exits with code 64 unless `RUVIEW_API_TOKEN` is set, and receives no ESP32 frames until a UDP source guard is set; see [Receiving ESP32 frames in Docker](#receiving-esp32-frames-in-docker).
|
||||
|
||||
### From Source (Rust)
|
||||
|
||||
@@ -261,8 +262,9 @@ Non-interactive:
|
||||
### 30-Second Demo (Docker)
|
||||
|
||||
```bash
|
||||
# Pull and run
|
||||
docker run -p 3000:3000 -p 3001:3001 ruvnet/wifi-densepose:latest
|
||||
# Pull and run (the container exits with code 64 without an API token)
|
||||
export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
|
||||
docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -e RUVIEW_API_TOKEN ruvnet/wifi-densepose:latest
|
||||
|
||||
# Open the UI in your browser
|
||||
# http://localhost:3000
|
||||
@@ -302,7 +304,11 @@ All endpoints return JSON. In simulated mode, data is generated from a determini
|
||||
|
||||
## Data Sources
|
||||
|
||||
The `--source` flag controls where CSI data comes from.
|
||||
The `--source` flag controls where CSI data comes from. Valid values are
|
||||
`auto`, `esp32`, `wifi`, and `simulated` (alias `simulate`), plus the vendor
|
||||
feeds `mediatek`, `qualcomm`, `realtek` (RTL8720F radar), and `realtek_csi`
|
||||
(RTL8721Dx CSI), which bind the UDP receiver like `esp32`. Any other value
|
||||
stops the server at startup with an error that lists them.
|
||||
|
||||
### Simulated Mode (No Hardware)
|
||||
|
||||
@@ -335,26 +341,24 @@ docker run --network host ruvnet/wifi-densepose:latest --source wifi --tick-ms 5
|
||||
|
||||
### macOS WiFi (RSSI Only)
|
||||
|
||||
Uses CoreWLAN via a Swift helper binary. macOS Sonoma 14.4+ redacts real BSSIDs; the adapter generates deterministic synthetic MACs so the multi-BSSID pipeline still works.
|
||||
Uses CoreWLAN via a Swift helper binary. macOS Sonoma 14.4+ redacts real BSSIDs; the adapter generates deterministic synthetic MACs so the multi-BSSID pipeline still works. On macOS, `--source wifi` selects the CoreWLAN scanner; there is no separate `macos` value.
|
||||
|
||||
```bash
|
||||
# Compile the Swift helper (once)
|
||||
swiftc -O archive/v1/src/sensing/mac_wifi.swift -o mac_wifi
|
||||
|
||||
# Run natively
|
||||
./target/release/sensing-server --source macos --http-port 3000 --ws-port 3001 --tick-ms 500
|
||||
./target/release/sensing-server --source wifi --http-port 3000 --ws-port 3001 --tick-ms 500
|
||||
```
|
||||
|
||||
See [ADR-025](adr/ADR-025-macos-corewlan-wifi-sensing.md) for details.
|
||||
|
||||
### Linux WiFi (RSSI Only)
|
||||
|
||||
Uses `iw dev <iface> scan` to capture RSSI. Requires `CAP_NET_ADMIN` (root) for active scans; use `scan dump` for cached results without root.
|
||||
|
||||
```bash
|
||||
# Run natively (requires root for active scanning)
|
||||
sudo ./target/release/sensing-server --source linux --http-port 3000 --ws-port 3001 --tick-ms 500
|
||||
```
|
||||
The sensing server does not have a Linux RSSI source yet. `--source wifi`
|
||||
on Linux runs the Windows `netsh` scanner, which is not present there, and
|
||||
`--source linux` is rejected at startup. Use an ESP32 node (`--source esp32`)
|
||||
on Linux.
|
||||
|
||||
### ESP32-S3 (Full CSI)
|
||||
|
||||
@@ -365,11 +369,39 @@ Real Channel State Information at 20 Hz with 56-192 subcarriers. Required for po
|
||||
./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001
|
||||
|
||||
# Docker (use CSI_SOURCE environment variable)
|
||||
docker run -p 3000:3000 -p 3001:3001 -p 5005:5005/udp -e CSI_SOURCE=esp32 ruvnet/wifi-densepose:latest
|
||||
docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \
|
||||
-e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> \
|
||||
ruvnet/wifi-densepose:latest
|
||||
```
|
||||
|
||||
The ESP32 nodes stream binary CSI frames over UDP to port 5005. See [Hardware Setup](#esp32-s3-mesh) for flashing instructions.
|
||||
|
||||
#### Receiving ESP32 frames in Docker
|
||||
|
||||
The sensing server binds its UDP receiver to `127.0.0.1` by default (ADR-296). Inside a container that address cannot receive anything from a published `5005/udp` port, so no node ever appears. The image's entrypoint binds UDP on `0.0.0.0` only when you set a source guard, and prints a note at startup when you have not:
|
||||
|
||||
| Variable | Effect |
|
||||
|----------|--------|
|
||||
| `RUVIEW_API_TOKEN` | Required. Without it (or `RUVIEW_ALLOW_UNAUTHENTICATED=1`) the container exits with code 64. |
|
||||
| `RUVIEW_UDP_ALLOW=<ip-or-cidr>` | Accept frames only from these sources. Preferred. |
|
||||
| `RUVIEW_UDP_INSECURE_LAN=true` | Accept frames from any source. The UDP data plane is not authenticated, so only use this on a trusted network. |
|
||||
| `RUVIEW_UDP_BIND` | Explicit UDP bind address. Overrides the entrypoint's choice. |
|
||||
| `SENSING_ALLOWED_HOSTS=<host-ip>` | Needed to open the UI from another machine; otherwise the server answers 421. That also requires publishing the TCP ports beyond `127.0.0.1`. |
|
||||
|
||||
**macOS (Docker Desktop, OrbStack):** datagrams reach the container from the Docker bridge gateway, not from the node's LAN address, so an allowlist that names your LAN subnet drops every frame. Allow the gateway instead:
|
||||
|
||||
```bash
|
||||
export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
|
||||
GW=$(docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}')
|
||||
docker run --rm -d --name ruview \
|
||||
-p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \
|
||||
-e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW="$GW/32" \
|
||||
ruvnet/wifi-densepose:latest
|
||||
curl -s -H "Authorization: Bearer $RUVIEW_API_TOKEN" localhost:3000/api/v1/nodes
|
||||
```
|
||||
|
||||
Allowing the gateway admits anything that can reach the published port, so keep `5005/udp` off untrusted networks. Node identity still comes from the frame payload. With Docker Compose the service runs on the project network (`<project>_default`), so inspect that network's gateway rather than `bridge`. Docker Desktop on Windows has a related source-address problem; see [TROUBLESHOOTING.md](TROUBLESHOOTING.md).
|
||||
|
||||
### ESP32 Multistatic Mesh (Advanced)
|
||||
|
||||
For higher accuracy with through-wall tracking, deploy 3-6 ESP32-S3 nodes in a **multistatic mesh** configuration. Each node acts as both transmitter and receiver, creating multiple sensing paths through the environment.
|
||||
@@ -391,6 +423,16 @@ The mesh uses a **Time-Division Multiplexing (TDM)** protocol so nodes take turn
|
||||
|
||||
See [ADR-029](adr/ADR-029-ruvsense-multistatic-sensing-mode.md) and [ADR-032](adr/ADR-032-multistatic-mesh-security-hardening.md) for the full design.
|
||||
|
||||
### MediaTek Router CSI (Experimental)
|
||||
|
||||
An OpenWrt router with an MT7981B + MT7976C radio (tested on a Wavlink WL-WN586X3 Rev A) can report CSI through MediaTek's vendor CSI patch. A host-side bridge converts it to MTC1 (ADR-267) for the sensing server:
|
||||
|
||||
```bash
|
||||
./target/release/sensing-server --source mediatek --udp-port 5005 --http-port 3000 --ws-port 3001
|
||||
```
|
||||
|
||||
Frames are labelled `mediatek:physical-unvalidated`. The presence and activity outputs on this source are uncalibrated heuristics with no sensing-quality validation. CSI exists only while associated clients transmit. See the [MediaTek Router CSI guide](mediatek-router-csi.md) for the OpenWrt build, arming CSI, the bridge, and limitations.
|
||||
|
||||
### Connect Mesh Data to the Dashboard and Observatory
|
||||
|
||||
If a standalone `aggregator` command prints live packets, the ESP32 fleet is already reaching that host. To visualize the same data, stop the standalone aggregator and run `sensing-server` on that same host and UDP port. The sensing server is the aggregator used by the REST API, WebSocket stream, dashboard, and Observatory.
|
||||
@@ -407,13 +449,17 @@ cargo run -p wifi-densepose-sensing-server -- \
|
||||
|
||||
# Docker
|
||||
docker run --rm \
|
||||
-e RUVIEW_API_TOKEN \
|
||||
-e CSI_SOURCE=esp32 \
|
||||
-p 3000:3000 \
|
||||
-p 3001:3001 \
|
||||
-e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> \
|
||||
-p 127.0.0.1:3000:3000 \
|
||||
-p 127.0.0.1:3001:3001 \
|
||||
-p 5005:5005/udp \
|
||||
ruvnet/wifi-densepose:latest
|
||||
```
|
||||
|
||||
On macOS Docker the allowlist must name the bridge gateway; see [Receiving ESP32 frames in Docker](#receiving-esp32-frames-in-docker).
|
||||
|
||||
Open the UI from the sensing server, not from a local file:
|
||||
|
||||
| View | URL |
|
||||
@@ -425,10 +471,20 @@ Use these checks before debugging the browser:
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/health
|
||||
curl http://localhost:3000/api/v1/status
|
||||
curl http://localhost:3000/api/v1/nodes
|
||||
curl http://localhost:3000/api/v1/sensing/latest
|
||||
```
|
||||
|
||||
`/api/v1/status` reports whether frames are actually arriving:
|
||||
|
||||
| `source_state` | Meaning |
|
||||
|---|---|
|
||||
| `disconnected` | A live source is configured but no frame has arrived yet. `waiting_for_frames` is `true` and `last_frame_age_ms` is `null`. |
|
||||
| `live_unverified` | Frames are arriving. `last_frame_age_ms` is under 5000. |
|
||||
| `stale` | Frames arrived, then stopped for more than 5 seconds. For a hardware source, `source` gains an `:offline` suffix (for example `esp32:offline`). |
|
||||
| `synthetic` | Simulated data. Never shown as live. |
|
||||
|
||||
If the ESP32 nodes are provisioned with `--target-ip <AGGREGATOR_HOST>`, that IP must be the machine running `sensing-server`. Only one process can receive UDP `:5005` at a time, so leave the standalone hardware `aggregator` off while the dashboard or Observatory is live.
|
||||
|
||||
### Cognitum Spaces activation
|
||||
@@ -587,6 +643,8 @@ Base URL: `http://localhost:3000` (Docker) or `http://localhost:8080` (binary de
|
||||
| `GET` | `/api/v1/mesh/metrics` | ADR-110 mesh state in Prometheus exposition format ([iter 36](adr/ADR-110-esp32-c6-firmware-extension.md)) | `wifi_densepose_mesh_offset_us{node="9"} 1163565\n…` |
|
||||
| `GET` | `/api/field` | ADR-262 P3 — latest **signed RuField `FieldEvent`s** from the live sensing cycle, plus the signer pubkey + a `dev_signing_key` flag. Only egress-safe (P1/P2) events are surfaced; identity/biometric (P4/P5) and raw (P0) are held edge-local | `{"spec":"rufield","signer_pubkey_hex":"…","dev_signing_key":true,"events":[…]}` |
|
||||
|
||||
Recording and model files live under the server's `--data-dir` (env `RUVIEW_DATA_DIR`, default `data`, resolved against the directory the server was started from): recordings in `<data-dir>/recordings/`, `.rvf` models in `<data-dir>/models/` unless `MODELS_DIR` is set, and the adaptive classifier in `<data-dir>/adaptive_model.json`.
|
||||
|
||||
### RuField surface (ADR-262 P3)
|
||||
|
||||
RuView's live WiFi-CSI sensing now also speaks the standalone **RuField MFS** wire format. Each governed sensing cycle is converted (via the `wifi-densepose-rufield` anti-corruption bridge) into a **signed** `FieldEvent` (`Modality::WifiCsi`, ed25519 `ProvenanceRef`) and surfaced on two additive endpoints:
|
||||
@@ -843,7 +901,14 @@ Full design + operator guide: [`docs/integrations/home-assistant.md`](integratio
|
||||
sensing-server --mqtt --mqtt-host <broker> --mqtt-tls --privacy-mode
|
||||
```
|
||||
|
||||
`--privacy-mode` strips heart rate, breathing rate, and pose keypoints from MQTT **and** Matter — they never reach the wire. Semantic primitives stay published because they're inferred *states* server-side, not biometric *values*. This is the architectural win that makes ADR-115 healthcare- and enterprise-deployable.
|
||||
`--privacy-mode` (env `RUVIEW_PRIVACY_MODE`) is a server-wide flag. It withholds heart rate, breathing rate, and pose keypoints from every output:
|
||||
|
||||
- REST responses: the fields are removed from every JSON body (`/api/v1/sensing/latest`, `/api/v1/vital-signs`, `/api/v1/edge-vitals`, `/api/v1/pose/current`, ...). `/api/v1/pose/current?view=both|refined` returns `403` with `"code": "privacy_mode"`.
|
||||
- WebSocket streams: `/ws/sensing` and `/api/v1/stream/pose` frames are filtered the same way.
|
||||
- Recordings started with `POST /api/v1/recording/start` are written already filtered.
|
||||
- MQTT and Matter: those entities are neither announced nor published.
|
||||
|
||||
Presence, motion, person count, zones and the coarse posture label are still served, and semantic primitives stay published because they're inferred *states*, not biometric *values*. Some of those states (sleeping, possible distress) are derived from vitals, so they are still health-related information. Privacy mode reduces what leaves the server. It does not by itself make a deployment compliant with any regulation.
|
||||
|
||||
### Matter Bridge (Apple Home / Google Home / Alexa / SmartThings)
|
||||
|
||||
@@ -1277,7 +1342,7 @@ The Rust sensing server binary accepts the following flags:
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--source` | `auto` | Data source: `auto`, `simulate`, `wifi`, `esp32` |
|
||||
| `--source` | `auto` | Data source: `auto`, `esp32`, `wifi`, `simulated` (alias `simulate`), `mediatek`, `qualcomm`, `realtek`, `realtek_csi`. Other values are rejected at startup |
|
||||
| `--http-port` | `8080` | HTTP port for REST API and UI |
|
||||
| `--ws-port` | `8765` | WebSocket port |
|
||||
| `--udp-port` | `5005` | UDP port for ESP32 CSI frames |
|
||||
@@ -1494,7 +1559,7 @@ curl -X POST http://localhost:3000/api/v1/recording/start \
|
||||
curl -X POST http://localhost:3000/api/v1/recording/stop
|
||||
```
|
||||
|
||||
Recordings are saved as JSONL files in `data/recordings/`. Filenames must start with `train_` and contain a class keyword:
|
||||
Recordings are saved as JSONL files in `recordings/` under the server's `--data-dir` (default `data`, so `data/recordings/` relative to the directory the server was started from). Filenames must start with `train_` and contain a class keyword:
|
||||
|
||||
| Filename pattern | Class |
|
||||
|-----------------|-------|
|
||||
@@ -1511,7 +1576,7 @@ Train the adaptive classifier from your labeled recordings:
|
||||
curl -X POST http://localhost:3000/api/v1/adaptive/train
|
||||
```
|
||||
|
||||
The server trains a multiclass logistic regression on 15 features using mini-batch SGD (200 epochs). Training completes in under 1 second for typical recording sets. The trained model is saved to `data/adaptive_model.json` and automatically loaded on server restart.
|
||||
The server trains a multiclass logistic regression on 15 features using mini-batch SGD (200 epochs). Training completes in under 1 second for typical recording sets. The trained model is saved to `adaptive_model.json` in the `--data-dir` (default `data/adaptive_model.json`) and automatically loaded on server restart.
|
||||
|
||||
**Check model status:**
|
||||
|
||||
@@ -1531,7 +1596,7 @@ Once trained, the adaptive model runs automatically:
|
||||
|
||||
1. Each CSI frame is classified using the learned weights instead of static thresholds
|
||||
2. Model confidence is blended with smoothed threshold confidence (70/30 split)
|
||||
3. The model persists across server restarts (loaded from `data/adaptive_model.json`)
|
||||
3. The model persists across server restarts (loaded from `<data-dir>/adaptive_model.json`)
|
||||
|
||||
**Tips for better accuracy:**
|
||||
|
||||
@@ -2047,8 +2112,10 @@ Binary size: 990 KB (8MB flash, 52% free) or 773 KB (4MB flash). v0.5.0 adds mmW
|
||||
# From source
|
||||
./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001
|
||||
|
||||
# Docker (use CSI_SOURCE environment variable)
|
||||
docker run -p 3000:3000 -p 3001:3001 -p 5005:5005/udp -e CSI_SOURCE=esp32 ruvnet/wifi-densepose:latest
|
||||
# Docker (see "Receiving ESP32 frames in Docker" for the UDP source guard)
|
||||
docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \
|
||||
-e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> \
|
||||
ruvnet/wifi-densepose:latest
|
||||
```
|
||||
|
||||
See [ADR-018](../docs/adr/ADR-018-esp32-dev-implementation.md), [ADR-029](../docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md), and [Tutorial #34](https://github.com/ruvnet/RuView/issues/34).
|
||||
@@ -2377,6 +2444,8 @@ For production deployments with both Rust and Python services:
|
||||
|
||||
```bash
|
||||
cd docker
|
||||
export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
|
||||
export RUVIEW_UDP_ALLOW=<node-ip-or-cidr> # macOS Docker: <gateway>/32
|
||||
docker compose up
|
||||
```
|
||||
|
||||
@@ -2384,6 +2453,8 @@ This starts:
|
||||
- Rust sensing server on ports 3000 (HTTP), 3001 (WS), 5005 (UDP)
|
||||
- Python legacy server on ports 8080 (HTTP), 8765 (WS)
|
||||
|
||||
The sensing server exits with code 64 when `RUVIEW_API_TOKEN` is unset. Without `RUVIEW_UDP_ALLOW` or `RUVIEW_UDP_INSECURE_LAN=true` it starts but receives no ESP32 frames. `docker/docker-compose.yml` passes these variables through from your shell, along with `RUVIEW_UDP_BIND` and `SENSING_ALLOWED_HOSTS`. See [Receiving ESP32 frames in Docker](#receiving-esp32-frames-in-docker).
|
||||
|
||||
---
|
||||
|
||||
## Testing Firmware Without Hardware (QEMU)
|
||||
|
||||
@@ -98,6 +98,13 @@ For an existing provisioned node, back up its current application and inspect
|
||||
`http://DEVICE_IP:8032/ota/status` before choosing an application-only update.
|
||||
Writing only offset `0x20000` is safe only when the status endpoint reports
|
||||
`running_partition` as `ota_0` and the downloaded image matches the board.
|
||||
|
||||
After an OTA, the same endpoint's `ota_state` must read `valid` before you
|
||||
power-cycle the node or push again. `pending_verify` means the image has not
|
||||
passed its first-boot health check yet and would revert on reset. A passed
|
||||
check means only that the node reached the network and sent CSI. See
|
||||
[RUNBOOK §2.1](RUNBOOK.md) and ADR-379.
|
||||
|
||||
The full bundles do not include NVS, so the documented four-offset install
|
||||
preserves WiFi and node configuration while replacing the boot and application
|
||||
images.
|
||||
@@ -161,10 +168,16 @@ board default and emits no antenna-path claim.
|
||||
### 3. Provision WiFi credentials (no reflash needed)
|
||||
|
||||
```bash
|
||||
# Keep the WiFi password in an owner-only file, not on the command line.
|
||||
printf '%s\n' 'YourPass' > ~/.wifi-pass && chmod 600 ~/.wifi-pass
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "YourSSID" --password "YourPass" --target-ip 192.168.1.20
|
||||
--ssid "YourSSID" --password-file ~/.wifi-pass --target-ip 192.168.1.20
|
||||
```
|
||||
|
||||
Leave out both `--password` and `--password-file` and the script asks for the
|
||||
password when run from a terminal. `--password` still works, but it shows up in
|
||||
`ps` and shell history.
|
||||
|
||||
### 4. Start the sensing server
|
||||
|
||||
```bash
|
||||
@@ -440,10 +453,15 @@ The easiest way to write NVS settings:
|
||||
```bash
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "MyWiFi" \
|
||||
--password "MyPassword" \
|
||||
--password-file ~/.wifi-pass \
|
||||
--target-ip 192.168.1.20
|
||||
```
|
||||
|
||||
The password file must be owner-only (`chmod 600`); one trailing newline is
|
||||
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.
|
||||
|
||||
### NVS Key Reference
|
||||
|
||||
#### Network Settings
|
||||
@@ -632,6 +650,7 @@ cargo build -p wifi-densepose-wasm-edge --target wasm32-unknown-unknown --releas
|
||||
| `main/nvs_config.c` / `.h` | Runtime configuration: loads Kconfig defaults, overrides from NVS |
|
||||
| `main/edge_processing.c` / `.h` | Tier 0-2 DSP pipeline: SPSC ring buffer, biquad IIR filters, Welford stats, BPM extraction, presence, fall detection |
|
||||
| `main/ota_update.c` / `.h` | HTTP OTA firmware update server on port 8032 |
|
||||
| `main/ota_health.c` / `.h` | First-boot health check that confirms an OTA'd image or rolls it back (ADR-379) |
|
||||
| `main/power_mgmt.c` / `.h` | Battery-aware light sleep duty cycling |
|
||||
| `main/wasm_runtime.c` / `.h` | WASM3 interpreter: module slots, host API bindings, budget guard, per-frame dispatch |
|
||||
| `main/wasm_upload.c` / `.h` | HTTP endpoints for WASM module upload, list, start, stop, delete |
|
||||
|
||||
@@ -31,9 +31,9 @@ It does **not** pick up `sdkconfig.defaults.16mb`, and that file is where
|
||||
`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y` lives.
|
||||
|
||||
Build without it and you get an image that is correct in every visible respect
|
||||
and **cannot roll back a bad OTA** — while `ota_rollback_boot_check()` sits in
|
||||
the app expecting the capability. Silent, and only discoverable by pushing a
|
||||
deliberately bad image.
|
||||
and **cannot roll back a bad OTA**, while `main/ota_health.c` sits in the app
|
||||
expecting the capability (it compiles to a no-op without it). Silent, and only
|
||||
discoverable by pushing a deliberately bad image. See §2.1.
|
||||
|
||||
That is why the command above concatenates all three explicitly rather than
|
||||
relying on discovery.
|
||||
@@ -104,6 +104,48 @@ Corollary: "every node reports health" proves the **app** is current and proves
|
||||
nothing about the bootloader. Do not answer a bootloader question with app
|
||||
evidence.
|
||||
|
||||
### 2.1 An OTA'd image must confirm itself (ADR-379)
|
||||
|
||||
On a rollback-capable bootloader an OTA'd image boots **`PENDING_VERIFY`**.
|
||||
Until it is confirmed, the **next reset reverts to the previous slot** and
|
||||
`POST /ota` refuses a second update. `main/ota_health.c` does the confirming:
|
||||
|
||||
- It runs only when the running image is `PENDING_VERIFY`. USB-flashed images
|
||||
(the bootloader writes `VALID`), already-confirmed images, `UNDEFINED` and
|
||||
`NEW` are left alone.
|
||||
- It confirms once the STA has an IP **and** the stream sender has accepted at
|
||||
least one CSI frame, but not before `CONFIG_OTA_HEALTH_MIN_UPTIME_S`
|
||||
(default 30 s). Log: `ota_health: marked valid after ... in <ms> ms`.
|
||||
- If both signals have not arrived by `CONFIG_OTA_HEALTH_TIMEOUT_S` (default
|
||||
120 s from boot), it logs `health check failed: <reason>, rolling back` and
|
||||
reboots into the previous slot. A crash or reset before confirmation is also
|
||||
a rollback.
|
||||
- A pass means only "reached the network and sent CSI". It does not show that
|
||||
sensing, calibration, or any other function of the new image works.
|
||||
- `GET /ota/status` reports `ota_state`. `pending_verify` means the image is
|
||||
not yet durable. `valid` means it survives a power cycle. `new` after an OTA
|
||||
means this board's bootloader has **no** rollback support.
|
||||
|
||||
Consequence for rollouts: wait for `ota_state: "valid"` before pushing again,
|
||||
and before power-cycling. If the AP is down when the node boots, a good image
|
||||
rolls back. That is expected; re-push it.
|
||||
|
||||
**Hardware check, one node.** It needs a node with the OTA PSK provisioned and
|
||||
a rollback bootloader.
|
||||
|
||||
1. Note `version` and `running_partition` from `/ota/status`.
|
||||
2. OTA-push the new image. Watch the log for `OTA image pending verify`, then
|
||||
`marked valid after ... in <ms> ms`. `/ota/status` must show the new
|
||||
version, the other partition, and `ota_state: "valid"`.
|
||||
3. Power-cycle it. It must come back on the same version and partition with
|
||||
`ota_state: "valid"`. Before ADR-379 it came back on the old one.
|
||||
|
||||
**Negative check.** Build with `CONFIG_OTA_HEALTH_FORCE_FAIL=y` and push it.
|
||||
The log must show `health check failed: forced failure ..., rolling back` at
|
||||
about `MIN_UPTIME_S`, and the node must come back on the step-2 version and
|
||||
partition. Then push a normal image to restore it. Never leave a FORCE_FAIL
|
||||
image in `release_bins`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Deploying
|
||||
@@ -113,6 +155,16 @@ evidence.
|
||||
Works end-to-end and is the default for anything that is not a bootloader or
|
||||
partition change. Roll to **one node**, soak it, then the rest.
|
||||
|
||||
**Before ADR-379, `POST /ota` never completed on this firmware.** MEASURED
|
||||
2026-09-29 on node 4 (S3, v0.8.12): it overflowed the 4 KB httpd stack
|
||||
(`stack overflow in task httpd`, `RTC_SW_CPU_RST`), and the node came back on
|
||||
its old slot. The client sees `connection reset`. Any board still on an older
|
||||
build has to be moved to a fixed one **over USB**.
|
||||
|
||||
The server now runs with `CONFIG_OTA_HTTPD_STACK_SIZE` (8192). Each upload
|
||||
logs `httpd stack after POST /ota: N of 8192 bytes never used`. Record that
|
||||
number when you verify a node, because it is the evidence the size rests on.
|
||||
|
||||
### Over USB — bootloader, partition table, or recovery
|
||||
|
||||
```bash
|
||||
|
||||
@@ -2,7 +2,7 @@ set(SRCS
|
||||
"main.c" "csi_collector.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" "power_mgmt.c"
|
||||
"edge_processing.c" "ota_update.c" "ota_health.c" "power_mgmt.c"
|
||||
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
|
||||
"mmwave_sensor.c"
|
||||
"swarm_bridge.c"
|
||||
|
||||
@@ -332,6 +332,25 @@ menu "WASM Programmable Sensing (ADR-040)"
|
||||
|
||||
endmenu
|
||||
|
||||
menu "OTA / WASM HTTP server (ADR-379)"
|
||||
|
||||
config OTA_HTTPD_STACK_SIZE
|
||||
int "Stack size of the OTA / WASM HTTP server task (bytes)"
|
||||
range 4096 16384
|
||||
default 8192
|
||||
help
|
||||
HTTPD_DEFAULT_CONFIG() gives 4096. MEASURED 2026-09-29 on an ESP32-S3
|
||||
(node 4, v0.8.12): every POST /ota overflowed it ("stack overflow in
|
||||
task httpd", RTC_SW_CPU_RST), so no OTA had ever completed on this
|
||||
firmware. The same task runs POST /wasm/upload (signature check plus
|
||||
wasm3 module load) and GET /wasm/list.
|
||||
|
||||
Every OTA and WASM upload logs "httpd stack after <handler>: N of M
|
||||
bytes never used". Lower this only with those numbers in hand, and keep
|
||||
at least 1 KB unused on the worst handler.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "ESP32-C6 capabilities (ADR-110)"
|
||||
depends on IDF_TARGET_ESP32C6
|
||||
|
||||
@@ -674,6 +693,50 @@ config UPLINK_WATCHDOG_TIMEOUT_S
|
||||
|
||||
endmenu
|
||||
|
||||
menu "OTA health check (ADR-379)"
|
||||
|
||||
config OTA_HEALTH_TIMEOUT_S
|
||||
int "Seconds an OTA'd image has to prove itself before rolling back"
|
||||
depends on BOOTLOADER_APP_ROLLBACK_ENABLE
|
||||
range 30 900
|
||||
default 120
|
||||
help
|
||||
Only applies when the running image is ESP_OTA_IMG_PENDING_VERIFY, i.e.
|
||||
it was just delivered by OTA to a node whose bootloader has
|
||||
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE. USB-flashed and already-confirmed
|
||||
images are never checked.
|
||||
|
||||
Healthy means: the STA got an IP, and the stream sender accepted at least
|
||||
one CSI frame. Both must happen within this many seconds of boot, or the
|
||||
image is marked invalid and the node reboots into the previous slot.
|
||||
|
||||
Measured from boot, and the WiFi boot wait alone can take about two
|
||||
minutes against an absent AP, so an AP outage during a rollout rolls a
|
||||
good image back. That is the safe direction: re-push it later.
|
||||
|
||||
config OTA_HEALTH_MIN_UPTIME_S
|
||||
int "Minimum uptime before an OTA'd image may be confirmed"
|
||||
depends on BOOTLOADER_APP_ROLLBACK_ENABLE
|
||||
range 0 600
|
||||
default 30
|
||||
help
|
||||
Holds confirmation back for this long after boot even once both signals
|
||||
are in, so an image that crashes shortly after starting its pipelines
|
||||
still reverts (a crash while PENDING_VERIFY is a rollback). Clamped to
|
||||
OTA_HEALTH_TIMEOUT_S.
|
||||
|
||||
config OTA_HEALTH_FORCE_FAIL
|
||||
bool "TEST ONLY: fail the OTA health check to exercise rollback"
|
||||
depends on BOOTLOADER_APP_ROLLBACK_ENABLE
|
||||
default n
|
||||
help
|
||||
Builds an image that rolls itself back at the point it would otherwise
|
||||
confirm. OTA-push it to a node to prove rollback works end to end: the
|
||||
node should reboot into the previous image. Has no effect when USB
|
||||
flashed (such an image is not PENDING_VERIFY). Never ship this.
|
||||
|
||||
endmenu
|
||||
|
||||
config CSI_GATE_MESH_ALIGNED
|
||||
bool "Align the CSI rate gate to mesh time (enables cross-node pairing)"
|
||||
default y
|
||||
|
||||
@@ -750,6 +750,11 @@ uint16_t csi_collector_get_send_fail_count(void)
|
||||
return (f > 0xFFFFu) ? 0xFFFFu : (uint16_t)f;
|
||||
}
|
||||
|
||||
uint32_t csi_collector_get_send_ok_count(void)
|
||||
{
|
||||
return s_send_ok;
|
||||
}
|
||||
|
||||
/* The gate here is fixed at compile time (mesh-aligned bucketing, falling
|
||||
* back to elapsed time when unsynced), so there is no mode or period to
|
||||
* report. Return the explicit sentinel rather than a number that reads as
|
||||
|
||||
@@ -139,6 +139,18 @@ uint16_t csi_collector_get_pkt_yield_per_sec(void);
|
||||
*/
|
||||
uint16_t csi_collector_get_send_fail_count(void);
|
||||
|
||||
/**
|
||||
* Get the cumulative count of CSI frames the stream sender accepted since boot.
|
||||
*
|
||||
* Nonzero means the whole capture path worked at least once: the driver
|
||||
* delivered a CSI callback, the frame serialized, and sendto() accepted it.
|
||||
* Used by the OTA health check (ota_health.c) as its "operational" signal.
|
||||
*
|
||||
* @return Number of successful stream_sender_send() calls from the CSI
|
||||
* callback path.
|
||||
*/
|
||||
uint32_t csi_collector_get_send_ok_count(void);
|
||||
|
||||
/** Reported by the gate accessors when this build has no selectable gate. */
|
||||
#define CSI_GATE_NOT_CONFIGURABLE 0xFF
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include "serial_onboarding.h"
|
||||
#include "edge_processing.h"
|
||||
#include "ota_update.h"
|
||||
#include "ota_health.h"
|
||||
#include "power_mgmt.h"
|
||||
#include "wasm_runtime.h"
|
||||
#include "wasm_upload.h"
|
||||
@@ -142,6 +143,7 @@ static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
|
||||
ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip));
|
||||
s_retry_num = 0;
|
||||
ota_health_note_got_ip();
|
||||
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
|
||||
}
|
||||
}
|
||||
@@ -338,6 +340,11 @@ void app_main(void)
|
||||
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
|
||||
target_name, app_desc->version, g_nvs_config.node_id);
|
||||
|
||||
/* ADR-379: an OTA'd image boots PENDING_VERIFY and reverts at the next
|
||||
* reset unless it confirms itself. Start the clock before anything that
|
||||
* can block (WiFi waits up to ~2 min for an AP), so the deadline is real. */
|
||||
ota_health_start();
|
||||
|
||||
/* Apply the opt-in XIAO RF path before WiFi starts. Generic C6 boards keep
|
||||
* ownership of GPIO3 and GPIO14 because the default implementation is a
|
||||
* no-op unless CONFIG_C6_XIAO_ANTENNA_SELECT is enabled. */
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/* First-boot health check for an OTA-delivered image. See ota_health.h and
|
||||
* ADR-379 for why it exists; the decision itself is ota_health_step(). */
|
||||
#include "ota_health.h"
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#ifdef CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_timer.h"
|
||||
|
||||
#include "csi_collector.h"
|
||||
|
||||
static const char *TAG = "ota_health";
|
||||
|
||||
#define OTA_HEALTH_POLL_MS 1000
|
||||
|
||||
/* Written from the event loop task, read from the health task. A single
|
||||
* aligned bool is enough; it only ever goes false -> true. */
|
||||
static volatile bool s_got_ip;
|
||||
|
||||
static const char *state_name(esp_ota_img_states_t st)
|
||||
{
|
||||
switch (st) {
|
||||
case ESP_OTA_IMG_NEW: return "NEW";
|
||||
case ESP_OTA_IMG_PENDING_VERIFY: return "PENDING_VERIFY";
|
||||
case ESP_OTA_IMG_VALID: return "VALID";
|
||||
case ESP_OTA_IMG_INVALID: return "INVALID";
|
||||
case ESP_OTA_IMG_ABORTED: return "ABORTED";
|
||||
case ESP_OTA_IMG_UNDEFINED: return "UNDEFINED";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
static void ota_health_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
const ota_health_cfg_t cfg = {
|
||||
.timeout_ms = (uint32_t)CONFIG_OTA_HEALTH_TIMEOUT_S * 1000u,
|
||||
.min_uptime_ms = (uint32_t)CONFIG_OTA_HEALTH_MIN_UPTIME_S * 1000u,
|
||||
#ifdef CONFIG_OTA_HEALTH_FORCE_FAIL
|
||||
.force_fail = true,
|
||||
#else
|
||||
.force_fail = false,
|
||||
#endif
|
||||
};
|
||||
ota_health_sm_t sm;
|
||||
ota_health_sm_init(&sm);
|
||||
|
||||
for (;;) {
|
||||
uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
ota_health_verdict_t v = ota_health_step(&sm, &cfg, s_got_ip,
|
||||
csi_collector_get_send_ok_count() > 0,
|
||||
now_ms);
|
||||
if (v == OTA_HEALTH_MARK_VALID) {
|
||||
esp_err_t err = esp_ota_mark_app_valid_cancel_rollback();
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "marked valid after %s in %lu ms",
|
||||
sm.reason, (unsigned long)now_ms);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "mark valid failed: %s (image stays PENDING_VERIFY "
|
||||
"and reverts on the next reset)", esp_err_to_name(err));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (v == OTA_HEALTH_ROLLBACK) {
|
||||
ESP_LOGE(TAG, "health check failed: %s, rolling back (%lu ms)",
|
||||
sm.reason, (unsigned long)now_ms);
|
||||
/* Returns only on failure: no other bootable slot. Rebooting then
|
||||
* would just loop, so stay up; the next reset aborts this image. */
|
||||
esp_err_t err = esp_ota_mark_app_invalid_rollback_and_reboot();
|
||||
ESP_LOGE(TAG, "rollback not possible: %s -- staying on this image",
|
||||
esp_err_to_name(err));
|
||||
break;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(OTA_HEALTH_POLL_MS));
|
||||
}
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
void ota_health_start(void)
|
||||
{
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
esp_ota_img_states_t st;
|
||||
esp_err_t err = esp_ota_get_state_partition(running, &st);
|
||||
|
||||
/* ESP_ERR_NOT_SUPPORTED: running from factory. ESP_ERR_NOT_FOUND: no
|
||||
* otadata entry for this slot (erased otadata). Neither can be rolled
|
||||
* back, so there is nothing to confirm. */
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGI(TAG, "running %s has no OTA state (%s): no health check",
|
||||
running ? running->label : "?", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
|
||||
/* VALID: USB-flashed with ota_data_initial (the bootloader writes VALID)
|
||||
* or already confirmed. UNDEFINED: selected by a build without rollback;
|
||||
* the bootloader boots it unconditionally. NEW: the bootloader has no
|
||||
* rollback support, so it never promoted the image to PENDING_VERIFY.
|
||||
* None of these revert on reset. */
|
||||
if (st != ESP_OTA_IMG_PENDING_VERIFY) {
|
||||
ESP_LOGI(TAG, "running %s state %s: no health check",
|
||||
running->label, state_name(st));
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGW(TAG, "OTA image pending verify on %s: need IP + CSI frame within %d s "
|
||||
"(min uptime %d s)%s",
|
||||
running->label, CONFIG_OTA_HEALTH_TIMEOUT_S,
|
||||
CONFIG_OTA_HEALTH_MIN_UPTIME_S,
|
||||
#ifdef CONFIG_OTA_HEALTH_FORCE_FAIL
|
||||
" -- FORCE_FAIL test build, will roll back"
|
||||
#else
|
||||
""
|
||||
#endif
|
||||
);
|
||||
|
||||
if (xTaskCreate(ota_health_task, "ota_health", 4096, NULL, 2, NULL) != pdPASS) {
|
||||
/* Without the task nothing confirms the image, which is the same as
|
||||
* failing the check: the next reset reverts it. Say so. */
|
||||
ESP_LOGE(TAG, "health task create failed; image will revert on next reset");
|
||||
}
|
||||
}
|
||||
|
||||
void ota_health_note_got_ip(void)
|
||||
{
|
||||
s_got_ip = true;
|
||||
}
|
||||
|
||||
#else /* !CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE */
|
||||
|
||||
/* Without bootloader rollback no image is ever PENDING_VERIFY. */
|
||||
void ota_health_start(void) {}
|
||||
void ota_health_note_got_ip(void) {}
|
||||
|
||||
#endif /* CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE */
|
||||
@@ -0,0 +1,110 @@
|
||||
/* First-boot health check for an OTA-delivered image (ADR-379).
|
||||
*
|
||||
* With CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE the bootloader starts a freshly
|
||||
* OTA'd image in ESP_OTA_IMG_PENDING_VERIFY. Until the app confirms itself,
|
||||
* the next reset reverts to the previous slot and esp_ota_begin() refuses a
|
||||
* second OTA. Nothing confirmed it before this module existed, so every OTA
|
||||
* on a rollback-enabled node was undone by the next power cycle.
|
||||
*
|
||||
* The decision logic is a pure function kept in this header, like
|
||||
* thermal_next_state(), so the host test exercises the exact code the device
|
||||
* runs. ota_health.c only feeds it events and a clock and acts on the verdict. */
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
typedef enum {
|
||||
OTA_HEALTH_WAIT = 0, /* keep checking */
|
||||
OTA_HEALTH_MARK_VALID, /* confirm the image: esp_ota_mark_app_valid_cancel_rollback() */
|
||||
OTA_HEALTH_ROLLBACK, /* esp_ota_mark_app_invalid_rollback_and_reboot() */
|
||||
} ota_health_verdict_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t timeout_ms; /* deadline, measured from boot */
|
||||
uint32_t min_uptime_ms; /* earliest the image may be confirmed; clamped to timeout_ms */
|
||||
bool force_fail; /* test hook: roll back where it would have confirmed */
|
||||
} ota_health_cfg_t;
|
||||
|
||||
typedef struct {
|
||||
bool got_ip; /* STA associated and holds an IPv4 address (latched) */
|
||||
bool csi_delivered; /* stream sender accepted >= 1 CSI frame (latched) */
|
||||
ota_health_verdict_t verdict;
|
||||
const char *reason; /* set once verdict leaves WAIT; static string */
|
||||
} ota_health_sm_t;
|
||||
|
||||
static inline void ota_health_sm_init(ota_health_sm_t *sm)
|
||||
{
|
||||
sm->got_ip = false;
|
||||
sm->csi_delivered = false;
|
||||
sm->verdict = OTA_HEALTH_WAIT;
|
||||
sm->reason = "waiting";
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the health check.
|
||||
*
|
||||
* Pure: no globals, no hardware. Both signals latch, because each proves a
|
||||
* capability the image has, and a later AP outage is not evidence against the
|
||||
* image. Once the verdict leaves WAIT it is terminal: later calls return it
|
||||
* unchanged, so a caller that polls after acting cannot act twice.
|
||||
*
|
||||
* @param sm State, initialised with ota_health_sm_init().
|
||||
* @param cfg Timeout, minimum uptime, force-fail hook.
|
||||
* @param got_ip Event seen since the last step: STA got an IP.
|
||||
* @param csi_delivered Event seen since the last step: a CSI frame was sent.
|
||||
* @param now_ms Milliseconds since boot.
|
||||
* @return The verdict. sm->reason names why, for the log.
|
||||
*/
|
||||
static inline ota_health_verdict_t ota_health_step(ota_health_sm_t *sm,
|
||||
const ota_health_cfg_t *cfg,
|
||||
bool got_ip,
|
||||
bool csi_delivered,
|
||||
uint32_t now_ms)
|
||||
{
|
||||
if (sm->verdict != OTA_HEALTH_WAIT) {
|
||||
return sm->verdict;
|
||||
}
|
||||
|
||||
sm->got_ip = sm->got_ip || got_ip;
|
||||
sm->csi_delivered = sm->csi_delivered || csi_delivered;
|
||||
|
||||
/* A minimum uptime above the timeout could never be satisfied; that would
|
||||
* turn every OTA into a rollback. Clamp rather than trust the config. */
|
||||
uint32_t min_up = cfg->min_uptime_ms < cfg->timeout_ms
|
||||
? cfg->min_uptime_ms : cfg->timeout_ms;
|
||||
|
||||
if (sm->got_ip && sm->csi_delivered && now_ms >= min_up) {
|
||||
if (cfg->force_fail) {
|
||||
sm->verdict = OTA_HEALTH_ROLLBACK;
|
||||
sm->reason = "forced failure (CONFIG_OTA_HEALTH_FORCE_FAIL)";
|
||||
} else {
|
||||
sm->verdict = OTA_HEALTH_MARK_VALID;
|
||||
sm->reason = "Wi-Fi got IP and CSI frame delivered";
|
||||
}
|
||||
return sm->verdict;
|
||||
}
|
||||
|
||||
if (now_ms >= cfg->timeout_ms) {
|
||||
sm->verdict = OTA_HEALTH_ROLLBACK;
|
||||
if (!sm->got_ip && !sm->csi_delivered) {
|
||||
sm->reason = "timeout: no IP and no CSI frame delivered";
|
||||
} else if (!sm->got_ip) {
|
||||
sm->reason = "timeout: no IP";
|
||||
} else {
|
||||
sm->reason = "timeout: no CSI frame delivered";
|
||||
}
|
||||
return sm->verdict;
|
||||
}
|
||||
|
||||
return OTA_HEALTH_WAIT;
|
||||
}
|
||||
|
||||
/* Device side (ota_health.c). No-ops when the bootloader has no rollback. */
|
||||
|
||||
/** Call once, early in app_main. Starts the check only if the running image
|
||||
* is ESP_OTA_IMG_PENDING_VERIFY; otherwise logs the state and returns. */
|
||||
void ota_health_start(void);
|
||||
|
||||
/** Call from the IP_EVENT_STA_GOT_IP handler. Cheap; safe from any task. */
|
||||
void ota_health_note_got_ip(void);
|
||||
@@ -17,6 +17,8 @@
|
||||
#include "esp_app_desc.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "nvs.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
static const char *TAG = "ota_update";
|
||||
|
||||
@@ -88,14 +90,26 @@ static esp_err_t ota_status_handler(httpd_req_t *req)
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
const esp_partition_t *update = esp_ota_get_next_update_partition(NULL);
|
||||
|
||||
/* ADR-379: lets an operator confirm remotely that an OTA'd image left
|
||||
* pending_verify (and so survives a power cycle) without a serial log. */
|
||||
esp_ota_img_states_t st;
|
||||
const char *ota_state = "none";
|
||||
if (running && esp_ota_get_state_partition(running, &st) == ESP_OK) {
|
||||
ota_state = st == ESP_OTA_IMG_VALID ? "valid" :
|
||||
st == ESP_OTA_IMG_PENDING_VERIFY ? "pending_verify" :
|
||||
st == ESP_OTA_IMG_NEW ? "new" :
|
||||
st == ESP_OTA_IMG_UNDEFINED ? "undefined" : "other";
|
||||
}
|
||||
|
||||
char response[512];
|
||||
int len = snprintf(response, sizeof(response),
|
||||
"{\"version\":\"%s\",\"date\":\"%s\",\"time\":\"%s\","
|
||||
"\"running_partition\":\"%s\",\"next_partition\":\"%s\","
|
||||
"\"max_size\":%lu}",
|
||||
"\"ota_state\":\"%s\",\"max_size\":%lu}",
|
||||
app->version, app->date, app->time,
|
||||
running ? running->label : "unknown",
|
||||
update ? update->label : "none",
|
||||
ota_state,
|
||||
(unsigned long)(update ? update->size : 0));
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
@@ -103,10 +117,28 @@ static esp_err_t ota_status_handler(httpd_req_t *req)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Receive chunk for POST /ota. Static, not on the httpd task stack: handlers
|
||||
* run one at a time on the single httpd task, so one buffer is enough, and
|
||||
* 1 KB was a quarter of the default 4 KB stack (ADR-379).
|
||||
* NOT reentrant: this shared buffer is safe only because a single httpd task
|
||||
* serves requests. Do not use it from another task or a multi-threaded
|
||||
* httpd configuration. */
|
||||
static char s_ota_rx_buf[1024];
|
||||
|
||||
void ota_update_log_httpd_stack(const char *what)
|
||||
{
|
||||
/* ESP-IDF FreeRTOS counts stack in bytes. */
|
||||
ESP_LOGI(TAG, "httpd stack after %s: %u of %d bytes never used",
|
||||
what, (unsigned)uxTaskGetStackHighWaterMark(NULL),
|
||||
CONFIG_OTA_HTTPD_STACK_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /ota — receive and flash firmware binary.
|
||||
* POST /ota — receive and flash firmware binary. Returns ESP_OK only after the
|
||||
* new slot is set as boot partition and the response is sent; the caller
|
||||
* reboots.
|
||||
*/
|
||||
static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
static esp_err_t ota_upload_receive(httpd_req_t *req)
|
||||
{
|
||||
/* ADR-050: Authenticate before accepting firmware upload. */
|
||||
if (!ota_check_auth(req)) {
|
||||
@@ -144,12 +176,12 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
}
|
||||
|
||||
/* Read firmware in chunks. */
|
||||
char buf[1024];
|
||||
char *buf = s_ota_rx_buf;
|
||||
int received = 0;
|
||||
int total = 0;
|
||||
|
||||
while (total < req->content_len) {
|
||||
received = httpd_req_recv(req, buf, sizeof(buf));
|
||||
received = httpd_req_recv(req, buf, sizeof(s_ota_rx_buf));
|
||||
if (received <= 0) {
|
||||
if (received == HTTPD_SOCK_ERR_TIMEOUT) {
|
||||
continue; /* Retry on timeout. */
|
||||
@@ -173,9 +205,11 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
|
||||
total += received;
|
||||
if ((total % (64 * 1024)) == 0) {
|
||||
ESP_LOGI(TAG, "OTA progress: %d / %d bytes (%.0f%%)",
|
||||
/* Integer percent: %f pulls newlib's float formatter onto this
|
||||
* task's stack for no benefit. */
|
||||
ESP_LOGI(TAG, "OTA progress: %d / %d bytes (%d%%)",
|
||||
total, req->content_len,
|
||||
(float)total * 100.0f / (float)req->content_len);
|
||||
(int)((int64_t)total * 100 / req->content_len));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,12 +235,24 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
const char *resp = "{\"status\":\"ok\",\"message\":\"OTA update successful. Rebooting...\"}";
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, strlen(resp));
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Delay briefly to let the response flush, then reboot. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
esp_restart();
|
||||
static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
{
|
||||
esp_err_t ret = ota_upload_receive(req);
|
||||
|
||||
return ESP_OK; /* Never reached. */
|
||||
/* The deepest path this server runs is esp_ota_end() -> esp_image_verify().
|
||||
* Log the headroom it left so the stack size is set from evidence, not
|
||||
* guessed a second time. */
|
||||
ota_update_log_httpd_stack("POST /ota");
|
||||
|
||||
if (ret == ESP_OK) {
|
||||
/* Delay briefly to let the response flush, then reboot. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
esp_restart();
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/** Internal: start the HTTP server and register OTA endpoints. */
|
||||
@@ -214,6 +260,11 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle)
|
||||
{
|
||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||
config.server_port = OTA_PORT;
|
||||
/* HTTPD_DEFAULT_CONFIG gives 4096 B. MEASURED 2026-09-29 on an S3 (node 4,
|
||||
* v0.8.12): POST /ota overflowed it every time ("stack overflow in task
|
||||
* 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). */
|
||||
/* Increase receive timeout for large uploads. */
|
||||
config.recv_wait_timeout = 30;
|
||||
|
||||
@@ -30,4 +30,13 @@ esp_err_t ota_update_init(void);
|
||||
*/
|
||||
esp_err_t ota_update_init_ex(void **out_server);
|
||||
|
||||
/**
|
||||
* 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
|
||||
* CONFIG_OTA_HTTPD_STACK_SIZE is backed by a measured margin.
|
||||
*
|
||||
* @param what Short label for the handler, e.g. "POST /ota".
|
||||
*/
|
||||
void ota_update_log_httpd_stack(const char *what);
|
||||
|
||||
#endif /* OTA_UPDATE_H */
|
||||
@@ -24,7 +24,9 @@
|
||||
#include "wasm_runtime.h"
|
||||
#include "rvf_parser.h"
|
||||
#include "nvs_config.h"
|
||||
#include "ota_update.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include "esp_log.h"
|
||||
@@ -82,7 +84,7 @@ static uint8_t *receive_body(httpd_req_t *req, int *out_len)
|
||||
* POST /wasm/upload — Upload RVF or raw .wasm
|
||||
* ====================================================================== */
|
||||
|
||||
static esp_err_t wasm_upload_handler(httpd_req_t *req)
|
||||
static esp_err_t wasm_upload_process(httpd_req_t *req)
|
||||
{
|
||||
int total = 0;
|
||||
uint8_t *buf = receive_body(req, &total);
|
||||
@@ -215,6 +217,15 @@ static esp_err_t wasm_upload_handler(httpd_req_t *req)
|
||||
(void)format;
|
||||
}
|
||||
|
||||
/* Signature check and wasm3 module load run on the shared httpd task; log
|
||||
* the headroom they left (ADR-379, CONFIG_OTA_HTTPD_STACK_SIZE). */
|
||||
static esp_err_t wasm_upload_handler(httpd_req_t *req)
|
||||
{
|
||||
esp_err_t ret = wasm_upload_process(req);
|
||||
ota_update_log_httpd_stack("POST /wasm/upload");
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* GET /wasm/list — List module slots
|
||||
* ====================================================================== */
|
||||
@@ -237,18 +248,24 @@ static esp_err_t wasm_list_handler(httpd_req_t *req)
|
||||
uint8_t count = 0;
|
||||
wasm_runtime_get_info(info, &count);
|
||||
|
||||
/* Build JSON array (larger buffer for manifest fields). */
|
||||
char response[2048];
|
||||
/* Build JSON array (larger buffer for manifest fields). Heap, not stack:
|
||||
* 2 KB was half the httpd task's former 4 KB stack. */
|
||||
const size_t resp_size = 2048;
|
||||
char *response = malloc(resp_size);
|
||||
if (response == NULL) {
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
int pos = 0;
|
||||
pos += snprintf(response + pos, sizeof(response) - pos,
|
||||
pos += snprintf(response + pos, resp_size - pos,
|
||||
"{\"modules\":[");
|
||||
|
||||
for (uint8_t i = 0; i < WASM_MAX_MODULES; i++) {
|
||||
if (i > 0) pos += snprintf(response + pos, sizeof(response) - pos, ",");
|
||||
if (i > 0) pos += snprintf(response + pos, resp_size - pos, ",");
|
||||
uint32_t mean_us = (info[i].frame_count > 0)
|
||||
? (info[i].total_us / info[i].frame_count) : 0;
|
||||
const char *name = info[i].module_name[0] ? info[i].module_name : "";
|
||||
pos += snprintf(response + pos, sizeof(response) - pos,
|
||||
pos += snprintf(response + pos, resp_size - pos,
|
||||
"{\"id\":%u,\"state\":\"%s\",\"name\":\"%s\","
|
||||
"\"binary_size\":%lu,\"caps\":\"0x%04lx\","
|
||||
"\"frame_count\":%lu,\"event_count\":%lu,\"error_count\":%lu,"
|
||||
@@ -266,11 +283,12 @@ static esp_err_t wasm_list_handler(httpd_req_t *req)
|
||||
(unsigned long)info[i].budget_faults);
|
||||
}
|
||||
|
||||
pos += snprintf(response + pos, sizeof(response) - pos,
|
||||
pos += snprintf(response + pos, resp_size - pos,
|
||||
"],\"loaded\":%u,\"max\":%d}", count, WASM_MAX_MODULES);
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, response, pos);
|
||||
free(response);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Usage:
|
||||
--password "..." --target-ip 192.168.1.20
|
||||
|
||||
Requirements:
|
||||
pip install 'esptool>=5.0' nvs-partition-gen
|
||||
pip install 'esptool>=5.0' esp-idf-nvs-partition-gen
|
||||
(or use the nvs_partition_gen.py bundled with ESP-IDF)
|
||||
|
||||
ADDITIVE-BY-DEFAULT (issue #391, #574 phase 1):
|
||||
@@ -28,6 +28,11 @@ ADDITIVE-BY-DEFAULT (issue #391, #574 phase 1):
|
||||
3. Generate + flash NVS from the merged state.
|
||||
4. Write the merged state back to the state file.
|
||||
|
||||
State is keyed by the board's MAC, read with esptool, so a board plugged
|
||||
into a port another board used doesn't inherit its node_id (#1755).
|
||||
Port-keyed files from earlier versions move to the board's record the
|
||||
first time that board is provisioned.
|
||||
|
||||
Net effect: partial reconfigure works the way users expect. Pass `--reset`
|
||||
to wipe both the state file AND the device NVS for first-time provisioning
|
||||
of a recycled board.
|
||||
@@ -41,9 +46,12 @@ ADDITIVE-BY-DEFAULT (issue #391, #574 phase 1):
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import getpass
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import re
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -129,6 +137,129 @@ def _state_path_for(port: str, state_dir: str) -> str:
|
||||
return os.path.join(state_dir, f"{safe}.json")
|
||||
|
||||
|
||||
# State files hold the WiFi password and seed token in cleartext (#1754), so the
|
||||
# directory is owner-only and every file in it is 0600.
|
||||
STATE_DIR_MODE = 0o700
|
||||
STATE_FILE_MODE = 0o600
|
||||
|
||||
# Values `--state` hides unless `--show-secrets` is passed (#1754).
|
||||
SECRET_ATTRS = ("password", "seed_token")
|
||||
|
||||
|
||||
def _restrict_mode(path: str, mode: int) -> None:
|
||||
"""chmod `path` to `mode` if it is a real file or dir owned by this user.
|
||||
|
||||
Never raises: a read-only or unusual filesystem must not block provisioning,
|
||||
but a secret left readable by others is reported. Symlinks are not followed,
|
||||
so a planted link can't redirect the chmod.
|
||||
"""
|
||||
try:
|
||||
st = os.lstat(path)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as exc:
|
||||
print(f"WARNING: could not stat {path}: {exc}", file=sys.stderr)
|
||||
return
|
||||
if stat.S_ISLNK(st.st_mode):
|
||||
print(f"WARNING: not changing permissions through symlink {path}", file=sys.stderr)
|
||||
return
|
||||
if hasattr(os, "getuid") and st.st_uid != os.getuid():
|
||||
print(f"WARNING: {path} is not owned by you; permissions left unchanged",
|
||||
file=sys.stderr)
|
||||
return
|
||||
if stat.S_IMODE(st.st_mode) == mode:
|
||||
return
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
except OSError as exc:
|
||||
print(f"WARNING: could not set {oct(mode)} on {path}: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def harden_state_dir(state_dir: str) -> None:
|
||||
"""Make the state dir 0700 and its state/temp files 0600.
|
||||
|
||||
Covers files written by earlier versions of this script, which used the
|
||||
default umask (0755 dir, 0644 files). POSIX only: chmod can't set
|
||||
owner-only ACLs on Windows.
|
||||
"""
|
||||
if sys.platform == "win32" or not os.path.isdir(state_dir):
|
||||
return
|
||||
_restrict_mode(state_dir, STATE_DIR_MODE)
|
||||
try:
|
||||
names = os.listdir(state_dir)
|
||||
except OSError:
|
||||
return
|
||||
for name in names:
|
||||
if name.endswith((".json", ".tmp")):
|
||||
_restrict_mode(os.path.join(state_dir, name), STATE_FILE_MODE)
|
||||
|
||||
|
||||
def _write_private(path: str, data: bytes) -> None:
|
||||
"""Write a credential-bearing file (state, NVS CSV or binary) as 0600."""
|
||||
flags = (os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
||||
| getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0))
|
||||
try:
|
||||
fd = os.open(path, flags, STATE_FILE_MODE)
|
||||
except OSError as exc:
|
||||
# O_NOFOLLOW makes a pre-existing symlink fail here (ELOOP).
|
||||
if os.path.islink(path):
|
||||
raise SystemExit(
|
||||
f"ERROR: refusing to write credentials through a symlink: {path}. "
|
||||
f"Remove it and rerun.") from exc
|
||||
raise
|
||||
with os.fdopen(fd, "wb") as f:
|
||||
# O_CREAT ignores the mode when the file already exists.
|
||||
if hasattr(os, "fchmod"):
|
||||
os.fchmod(f.fileno(), STATE_FILE_MODE)
|
||||
f.write(data)
|
||||
|
||||
|
||||
def redact_secrets(state: dict) -> dict:
|
||||
"""Copy of `state` with secret values replaced by a fixed marker."""
|
||||
shown = dict(state)
|
||||
for name in SECRET_ATTRS:
|
||||
if shown.get(name) is not None:
|
||||
shown[name] = "(set)" if shown[name] else "(empty)"
|
||||
return shown
|
||||
|
||||
|
||||
def read_password_file(path: str, allow_insecure: bool = False) -> str:
|
||||
"""Read the WiFi password from `path`, dropping one trailing newline.
|
||||
|
||||
Keeps the password out of argv and shell history (#1754). Refuses a file
|
||||
that group or others can read unless `allow_insecure` is set. Raises
|
||||
ValueError with a message meant for the user.
|
||||
"""
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
mode = stat.S_IMODE(os.fstat(f.fileno()).st_mode)
|
||||
data = f.read()
|
||||
except FileNotFoundError:
|
||||
raise ValueError(f"--password-file {path} does not exist") from None
|
||||
except OSError as exc:
|
||||
raise ValueError(f"could not read --password-file {path}: {exc}") from None
|
||||
if sys.platform != "win32" and mode & 0o044:
|
||||
if not allow_insecure:
|
||||
raise ValueError(
|
||||
f"--password-file {path} is readable by group or others "
|
||||
f"(mode {oct(mode)}). Run 'chmod 600 {path}', or pass "
|
||||
f"--allow-insecure-password-file."
|
||||
)
|
||||
print(f"WARNING: --password-file {path} is readable by group or others "
|
||||
f"(mode {oct(mode)}).", file=sys.stderr)
|
||||
try:
|
||||
text = data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
raise ValueError(f"--password-file {path} is not UTF-8 text") from None
|
||||
if text.endswith("\r\n"):
|
||||
text = text[:-2]
|
||||
elif text.endswith("\n"):
|
||||
text = text[:-1]
|
||||
if not text:
|
||||
raise ValueError(f"--password-file {path} is empty")
|
||||
return text
|
||||
|
||||
|
||||
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)
|
||||
@@ -145,15 +276,140 @@ def load_state(port: str, state_dir: str) -> dict:
|
||||
|
||||
|
||||
def save_state(port: str, state_dir: str, state: dict) -> str:
|
||||
"""Write `state` to the per-port file, creating dirs as needed. Returns path."""
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
"""Write `state` to the per-port file, creating dirs as needed. Returns path.
|
||||
|
||||
The file holds secrets, so it is written 0600 inside a 0700 dir (#1754).
|
||||
"""
|
||||
os.makedirs(state_dir, mode=STATE_DIR_MODE, exist_ok=True)
|
||||
harden_state_dir(state_dir)
|
||||
path = _state_path_for(port, state_dir)
|
||||
# Sort keys for deterministic on-disk content (easier to diff).
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp, path)
|
||||
# mkstemp picks a unique name and opens it O_EXCL with mode 0600, so a
|
||||
# stale temp file or a planted symlink can't receive the secret.
|
||||
fd, tmp = tempfile.mkstemp(
|
||||
dir=state_dir, prefix=os.path.basename(path) + ".", suffix=".tmp"
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
if hasattr(os, "fchmod"):
|
||||
os.fchmod(f.fileno(), STATE_FILE_MODE)
|
||||
# Sort keys for deterministic on-disk content (easier to diff).
|
||||
json.dump(state, f, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Board identity (#1755)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Serial port names get reused when boards are swapped, so a port-keyed state
|
||||
# file hands one board's node_id to the next. State is keyed by the chip's
|
||||
# base MAC instead. Each record also stores the MAC and the port it was last
|
||||
# written from, so --state can find it without opening the port.
|
||||
|
||||
STATE_MAC_KEY = "_chip_mac"
|
||||
STATE_PORT_KEY = "_port"
|
||||
|
||||
_ESPTOOL_MAC_RE = re.compile(
|
||||
r"^\s*(BASE MAC|MAC):\s*((?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})\s*$", re.MULTILINE
|
||||
)
|
||||
|
||||
|
||||
def normalize_mac(value: str):
|
||||
"""Return `value` as lowercase aa:bb:cc:dd:ee:ff, or None if it isn't a MAC."""
|
||||
parts = re.split(r"[:-]", value.strip())
|
||||
if len(parts) != 6 or not all(re.fullmatch(r"[0-9a-fA-F]{2}", p) for p in parts):
|
||||
return None
|
||||
return ":".join(p.lower() for p in parts)
|
||||
|
||||
|
||||
def parse_esptool_mac(output: str):
|
||||
"""Return the base MAC from `esptool read_mac` output, or None.
|
||||
|
||||
Most chips print one 6-byte "MAC:" line. EUI-64 chips (C5, C6, H2) print an
|
||||
8-byte "MAC:" line, then "BASE MAC:" with the 6-byte address.
|
||||
"""
|
||||
found = {label: mac.lower() for label, mac in _ESPTOOL_MAC_RE.findall(output)}
|
||||
return found.get("BASE MAC") or found.get("MAC")
|
||||
|
||||
|
||||
def read_chip_mac(port: str, baud: int, chip: str):
|
||||
"""Read the connected board's base MAC with esptool. None if it can't."""
|
||||
cmd = [
|
||||
sys.executable, "-m", "esptool",
|
||||
"--chip", chip,
|
||||
"--port", port,
|
||||
"--baud", str(baud),
|
||||
"read_mac",
|
||||
]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
return parse_esptool_mac(result.stdout)
|
||||
|
||||
|
||||
def mac_state_key(mac: str) -> str:
|
||||
"""State-file key for a board, e.g. mac-aabbccddeeff."""
|
||||
return "mac-" + mac.replace(":", "")
|
||||
|
||||
|
||||
def boards_last_on_port(port: str, state_dir: str) -> list:
|
||||
"""MACs of boards whose state was last written from `port`, newest first."""
|
||||
if not os.path.isdir(state_dir):
|
||||
return []
|
||||
found = []
|
||||
for name in os.listdir(state_dir):
|
||||
if not (name.startswith("mac-") and name.endswith(".json")):
|
||||
continue
|
||||
data = load_state(name[:-len(".json")], state_dir)
|
||||
mac = data.get(STATE_MAC_KEY)
|
||||
if data.get(STATE_PORT_KEY) == port and mac:
|
||||
found.append((os.path.getmtime(os.path.join(state_dir, name)), mac))
|
||||
return [mac for _, mac in sorted(found, reverse=True)]
|
||||
|
||||
|
||||
def load_board_state(port: str, mac, state_dir: str):
|
||||
"""Return (prior state, legacy path) for the board `mac` on `port`.
|
||||
|
||||
Without a MAC this is the old port-keyed lookup. With one, the board's own
|
||||
record wins. If it has none but a port-keyed file from an earlier version
|
||||
exists, that file is returned for migration along with its path.
|
||||
"""
|
||||
if mac is None:
|
||||
return load_state(port, state_dir), None
|
||||
prior = load_state(mac_state_key(mac), state_dir)
|
||||
if prior:
|
||||
return prior, None
|
||||
legacy = _state_path_for(port, state_dir)
|
||||
if os.path.isfile(legacy):
|
||||
return load_state(port, state_dir), legacy
|
||||
return {}, None
|
||||
|
||||
|
||||
def save_board_state(port: str, mac, state_dir: str, state: dict, legacy=None) -> str:
|
||||
"""Persist `state` under the board's MAC (or the port if the MAC is unknown).
|
||||
|
||||
A migrated port-keyed file is removed afterwards, so no other board on that
|
||||
port can pick it up.
|
||||
"""
|
||||
if mac is None:
|
||||
return save_state(port, state_dir, state)
|
||||
state = dict(state)
|
||||
state[STATE_MAC_KEY] = mac
|
||||
state[STATE_PORT_KEY] = port
|
||||
path = save_state(mac_state_key(mac), state_dir, state)
|
||||
if legacy and os.path.isfile(legacy):
|
||||
os.unlink(legacy)
|
||||
return path
|
||||
|
||||
|
||||
@@ -239,11 +495,12 @@ def build_nvs_csv(args):
|
||||
|
||||
def generate_nvs_binary(csv_content, size):
|
||||
"""Generate an NVS partition binary from CSV using nvs_partition_gen.py."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f_csv:
|
||||
f_csv.write(csv_content)
|
||||
csv_path = f_csv.name
|
||||
|
||||
bin_path = csv_path.replace(".csv", ".bin")
|
||||
# Both files carry the WiFi password. A private 0700 dir keeps the
|
||||
# generator's output (written with the default umask) away from other users.
|
||||
work_dir = tempfile.mkdtemp(prefix="provision-nvs-")
|
||||
csv_path = os.path.join(work_dir, "nvs.csv")
|
||||
bin_path = os.path.join(work_dir, "nvs.bin")
|
||||
_write_private(csv_path, csv_content.encode("utf-8"))
|
||||
|
||||
try:
|
||||
# Method 1: subprocess invocation (most reliable across package versions)
|
||||
@@ -282,6 +539,10 @@ def generate_nvs_binary(csv_content, size):
|
||||
for p in (csv_path, bin_path):
|
||||
if os.path.isfile(p):
|
||||
os.unlink(p)
|
||||
try:
|
||||
os.rmdir(work_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def flash_nvs(port, baud, nvs_bin, chip):
|
||||
@@ -324,7 +585,16 @@ def main():
|
||||
)
|
||||
parser.add_argument("--baud", type=int, default=460800, help="Flash baud rate (default: 460800)")
|
||||
parser.add_argument("--ssid", help="WiFi SSID")
|
||||
parser.add_argument("--password", help="WiFi password")
|
||||
password_source = parser.add_mutually_exclusive_group()
|
||||
password_source.add_argument("--password",
|
||||
help="WiFi password. Visible in ps and shell history; "
|
||||
"prefer --password-file.")
|
||||
password_source.add_argument("--password-file", metavar="PATH",
|
||||
help="Read the WiFi password from PATH (one trailing newline "
|
||||
"is dropped). The file must not be readable by group or others.")
|
||||
parser.add_argument("--allow-insecure-password-file", action="store_true",
|
||||
help="Accept a --password-file that group or others can read, with a "
|
||||
"warning (e.g. a read-only 0444 secrets mount).")
|
||||
parser.add_argument("--target-ip", help="Aggregator host IP (e.g. 192.168.1.20)")
|
||||
parser.add_argument("--target-port", type=int, help="Aggregator UDP port (default: 5005)")
|
||||
parser.add_argument("--node-id", type=int, help="Node ID 0-255 (default: 1)")
|
||||
@@ -360,30 +630,100 @@ def main():
|
||||
"error when no prior state file exists. The script now merges "
|
||||
"with prior state by default, so this flag is rarely needed.")
|
||||
parser.add_argument("--reset", action="store_true",
|
||||
help="Wipe this machine's per-port state file before merging. "
|
||||
help="Ignore this machine's per-port state file when merging; it is "
|
||||
"replaced after a successful flash. "
|
||||
"Use for first-time provisioning of a recycled board where "
|
||||
"previously-staged keys should NOT be re-applied.")
|
||||
parser.add_argument("--state-dir", default=_default_state_dir(),
|
||||
help="Override the per-user state directory (default: per-OS user config dir).")
|
||||
parser.add_argument("--mac", type=str,
|
||||
help="Board MAC (AA:BB:CC:DD:EE:FF) whose state to use. Normally read "
|
||||
"from the chip with esptool; --state and --dry-run never open the "
|
||||
"port, so pass it there to pick a board.")
|
||||
parser.add_argument("--state", action="store_true",
|
||||
help="Print the merged state that WOULD be flashed for this port and exit. "
|
||||
"Useful for debugging which keys are about to land on the device.")
|
||||
"Useful for debugging which keys are about to land on the device. "
|
||||
"The WiFi password and seed token are shown as (set)/(empty).")
|
||||
parser.add_argument("--show-secrets", action="store_true",
|
||||
help="With --state, print the WiFi password and seed token in clear.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# State written by older versions may be 0644. Tighten it before any read
|
||||
# or early exit (#1754).
|
||||
harden_state_dir(args.state_dir)
|
||||
|
||||
if args.password_file is not None:
|
||||
try:
|
||||
args.password = read_password_file(
|
||||
args.password_file, args.allow_insecure_password_file)
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
elif args.password is not None:
|
||||
print("WARNING: --password is visible in ps and shell history; "
|
||||
"use --password-file instead.", file=sys.stderr)
|
||||
cli_ssid, cli_password = args.ssid, args.password
|
||||
# --- Board identity (#1755) ---
|
||||
# State is keyed by the chip's MAC so a board swapped onto a reused port
|
||||
# doesn't inherit the previous board's node_id.
|
||||
if args.mac is not None:
|
||||
chip_mac = normalize_mac(args.mac)
|
||||
if chip_mac is None:
|
||||
parser.error(f"--mac must be in AA:BB:CC:DD:EE:FF format, got '{args.mac}'")
|
||||
elif args.state:
|
||||
# Inspection only: don't open the port; show the board last
|
||||
# provisioned from it.
|
||||
recent = boards_last_on_port(args.port, args.state_dir)
|
||||
chip_mac = recent[0] if recent else None
|
||||
if chip_mac:
|
||||
print(f"Showing board {chip_mac}, last provisioned on {args.port}. "
|
||||
f"Pass --mac to pick another.", file=sys.stderr)
|
||||
elif args.dry_run:
|
||||
# No board involved; keep the port-keyed state as before.
|
||||
chip_mac = None
|
||||
else:
|
||||
chip_mac = read_chip_mac(args.port, args.baud, args.chip)
|
||||
if chip_mac:
|
||||
print(f"Board MAC: {chip_mac}")
|
||||
else:
|
||||
print(f"WARNING: could not read the board's MAC with esptool, so state "
|
||||
f"stays keyed by port {args.port}. Pass --mac to key it by board.",
|
||||
file=sys.stderr)
|
||||
legacy = None
|
||||
|
||||
# --- Per-port state load + merge (additive-by-default, #391 / #574) ---
|
||||
if args.reset:
|
||||
# Don't delete the state file here: validation below can still fail,
|
||||
# and a failed run must not lose the record. A successful flash
|
||||
# overwrites it with the reset (CLI-only) state.
|
||||
path = _state_path_for(args.port, args.state_dir)
|
||||
if os.path.isfile(path):
|
||||
os.unlink(path)
|
||||
print(f"--reset: removed state file {path}", file=sys.stderr)
|
||||
print(f"--reset: ignoring state file {path}", file=sys.stderr)
|
||||
prior = {}
|
||||
else:
|
||||
prior = load_state(args.port, args.state_dir)
|
||||
prior, legacy = load_board_state(args.port, chip_mac, args.state_dir)
|
||||
if legacy:
|
||||
print(f"Using port-keyed state {legacy} for board {chip_mac}; it moves to "
|
||||
f"the board's own record when state is next saved. If this isn't the "
|
||||
f"board last provisioned on {args.port}, rerun with --reset.",
|
||||
file=sys.stderr)
|
||||
merged = merge_state_into_args(args, prior)
|
||||
|
||||
# A new --ssid with no password given: ask for it on a terminal instead of
|
||||
# requiring it on the command line. The saved password is reused only when
|
||||
# it belongs to the same SSID. Without a terminal (scripts, CI) nothing is
|
||||
# asked and the WiFi-credential check below applies as before.
|
||||
if (not args.state and cli_ssid is not None and cli_password is None
|
||||
and (prior.get("password") is None or prior.get("ssid") != cli_ssid)
|
||||
and sys.stdin.isatty()):
|
||||
args.password = getpass.getpass(f"WiFi password for {cli_ssid}: ")
|
||||
merged["password"] = args.password
|
||||
|
||||
if args.state:
|
||||
print(json.dumps(merged, indent=2, sort_keys=True))
|
||||
shown = merged if args.show_secrets else redact_secrets(merged)
|
||||
print(json.dumps(shown, indent=2, sort_keys=True))
|
||||
if shown != merged:
|
||||
print("Secrets hidden; pass --show-secrets to print them.", file=sys.stderr)
|
||||
return
|
||||
|
||||
if not has_config_value(args):
|
||||
@@ -403,11 +743,12 @@ def main():
|
||||
] if val is None or val == ""
|
||||
]
|
||||
if wifi_trio_missing and not args.force_partial:
|
||||
state_key = mac_state_key(chip_mac) if chip_mac else args.port
|
||||
parser.error(
|
||||
f"Missing required WiFi credentials after merging prior state: "
|
||||
f"{', '.join(wifi_trio_missing)}.\n"
|
||||
f"\n"
|
||||
f" No per-port state file at {_state_path_for(args.port, args.state_dir)}\n"
|
||||
f" No saved state at {_state_path_for(state_key, args.state_dir)}\n"
|
||||
f" and the CLI didn't include them. Either pass --ssid + --password + --target-ip\n"
|
||||
f" on this run, or add --force-partial to flash without WiFi.\n"
|
||||
)
|
||||
@@ -487,8 +828,7 @@ def main():
|
||||
print(f"\nError generating NVS binary: {e}", file=sys.stderr)
|
||||
print("\nFallback: save CSV and flash manually with ESP-IDF tools.", file=sys.stderr)
|
||||
fallback_path = "nvs_config.csv"
|
||||
with open(fallback_path, "w") as f:
|
||||
f.write(csv_content)
|
||||
_write_private(fallback_path, csv_content.encode("utf-8"))
|
||||
print(f"Saved NVS CSV to {fallback_path}", file=sys.stderr)
|
||||
print(f"Flash with: python $IDF_PATH/components/nvs_flash/"
|
||||
f"nvs_partition_generator/nvs_partition_gen.py generate "
|
||||
@@ -497,22 +837,20 @@ def main():
|
||||
|
||||
if args.dry_run:
|
||||
out = "nvs_provision.bin"
|
||||
with open(out, "wb") as f:
|
||||
f.write(nvs_bin)
|
||||
_write_private(out, nvs_bin)
|
||||
print(f"NVS binary saved to {out} ({len(nvs_bin)} bytes)")
|
||||
print(f"Flash manually: python -m esptool --chip {args.chip} --port {args.port} "
|
||||
f"write_flash 0x9000 {out}")
|
||||
# Persist merged state even on dry-run so a subsequent real flash from
|
||||
# this machine sees the same staged config.
|
||||
path = save_state(args.port, args.state_dir, merged)
|
||||
print(f"State persisted to {path}")
|
||||
# Don't persist state on dry-run: nothing reached the device, so the
|
||||
# next real run must not merge on top of values that were never flashed.
|
||||
print("Dry run: state file not updated.")
|
||||
return
|
||||
|
||||
flash_nvs(args.port, args.baud, nvs_bin, args.chip)
|
||||
# Persist merged state after a successful flash so future partial
|
||||
# invocations from this machine merge on top of what's actually on the
|
||||
# device. This is the heart of the additive-by-default fix (#391/#574).
|
||||
path = save_state(args.port, args.state_dir, merged)
|
||||
path = save_board_state(args.port, chip_mac, args.state_dir, merged, legacy)
|
||||
print(f"State persisted to {path}")
|
||||
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_16mb.csv"
|
||||
|
||||
# Firmware rollback: an OTA'd image boots PENDING_VERIFY and must confirm
|
||||
# itself, or the bootloader reverts on the next boot. See ota_rollback_* in
|
||||
# ota_update.c for when confirmation happens and why.
|
||||
# itself, or the bootloader reverts on the next boot. main/ota_health.c does
|
||||
# the confirming (IP + one CSI frame within OTA_HEALTH_TIMEOUT_S), ADR-379.
|
||||
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
|
||||
|
||||
# Coredump to flash. The 64K `coredump` partition has existed in
|
||||
|
||||
@@ -56,10 +56,11 @@ FUZZ_JOBS ?= 1
|
||||
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \
|
||||
test_thermal run_thermal test_csi_sanitize run_csi_sanitize \
|
||||
test_c6_antenna run_c6_antenna test_serial_onboarding run_serial_onboarding \
|
||||
run_delivery_contract
|
||||
run_delivery_contract test_ota_health run_ota_health
|
||||
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
|
||||
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
|
||||
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding \
|
||||
test_ota_health
|
||||
|
||||
# --- XIAO ESP32-C6 RF switch selection ---
|
||||
# Host-side truth table for the GPIO14 antenna selector. GPIO3 is always driven
|
||||
@@ -133,12 +134,21 @@ test_serial_onboarding: test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onb
|
||||
run_serial_onboarding: test_serial_onboarding
|
||||
./test_serial_onboarding
|
||||
|
||||
# --- OTA first-boot health check (ADR-379) ---
|
||||
# Host-side. Exercises ota_health_step() from ../main/ota_health.h directly --
|
||||
# the decision the device runs to confirm or roll back an OTA'd image.
|
||||
test_ota_health: test_ota_health.c $(MAIN_DIR)/ota_health.h
|
||||
cc -std=c99 -Wall -Wextra -Werror -I$(MAIN_DIR) -o $@ $<
|
||||
|
||||
run_ota_health: test_ota_health
|
||||
./test_ota_health
|
||||
|
||||
run_delivery_contract:
|
||||
python3 test_delivery_contract.py
|
||||
|
||||
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \
|
||||
run_c6_antenna run_serial_onboarding run_delivery_contract
|
||||
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding + delivery contract)"
|
||||
run_c6_antenna run_serial_onboarding run_delivery_contract run_ota_health
|
||||
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding + delivery contract + OTA health)"
|
||||
|
||||
# --- Serialize fuzzer ---
|
||||
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
|
||||
@@ -175,5 +185,6 @@ run_all: run_serialize run_edge run_nvs
|
||||
|
||||
clean:
|
||||
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
|
||||
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
|
||||
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding \
|
||||
test_ota_health
|
||||
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @file test_ota_health.c
|
||||
* @brief Pins the OTA first-boot health check decision (ADR-379).
|
||||
*
|
||||
* Exercises `ota_health_step()` from ../main/ota_health.h directly -- the same
|
||||
* function the device build runs, not a copy -- so the test and the firmware
|
||||
* cannot drift apart.
|
||||
*
|
||||
* The regression that motivated this: nothing ever called
|
||||
* esp_ota_mark_app_valid_cancel_rollback(), so on rollback-enabled nodes every
|
||||
* OTA'd image reverted at the next reset, and a bad image was never caught.
|
||||
*
|
||||
* Config under test: timeout 120 s, minimum uptime 30 s.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ota_health.h"
|
||||
|
||||
#define TIMEOUT_MS 120000u
|
||||
#define MIN_UP_MS 30000u
|
||||
|
||||
static int g_failures = 0;
|
||||
|
||||
static const char *name(ota_health_verdict_t v)
|
||||
{
|
||||
switch (v) {
|
||||
case OTA_HEALTH_WAIT: return "WAIT";
|
||||
case OTA_HEALTH_MARK_VALID: return "MARK_VALID";
|
||||
case OTA_HEALTH_ROLLBACK: return "ROLLBACK";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
static void expect(ota_health_verdict_t got, ota_health_verdict_t want,
|
||||
const char *why)
|
||||
{
|
||||
if (got != want) {
|
||||
printf(" FAIL: got %s, expected %s\n %s\n", name(got), name(want), why);
|
||||
g_failures++;
|
||||
}
|
||||
}
|
||||
|
||||
static void expect_reason(const ota_health_sm_t *sm, const char *needle,
|
||||
const char *why)
|
||||
{
|
||||
if (strstr(sm->reason, needle) == NULL) {
|
||||
printf(" FAIL: reason \"%s\" lacks \"%s\"\n %s\n",
|
||||
sm->reason, needle, why);
|
||||
g_failures++;
|
||||
}
|
||||
}
|
||||
|
||||
static const ota_health_cfg_t CFG = { TIMEOUT_MS, MIN_UP_MS, false };
|
||||
|
||||
int main(void)
|
||||
{
|
||||
ota_health_sm_t sm;
|
||||
|
||||
printf("ota health check (timeout %u ms, min uptime %u ms)\n",
|
||||
TIMEOUT_MS, MIN_UP_MS);
|
||||
|
||||
/* Healthy boot: IP at 4 s, first CSI frame at 6 s, confirmed at 30 s. */
|
||||
ota_health_sm_init(&sm);
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 1000), OTA_HEALTH_WAIT,
|
||||
"nothing yet, well inside the deadline");
|
||||
expect(ota_health_step(&sm, &CFG, true, false, 4000), OTA_HEALTH_WAIT,
|
||||
"IP alone is not enough");
|
||||
expect(ota_health_step(&sm, &CFG, false, true, 6000), OTA_HEALTH_WAIT,
|
||||
"both signals in, but below the minimum uptime");
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 29999), OTA_HEALTH_WAIT,
|
||||
"one ms short of the minimum uptime");
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 30000), OTA_HEALTH_MARK_VALID,
|
||||
"latched IP + CSI at the minimum uptime must confirm the image");
|
||||
expect_reason(&sm, "IP", "the log names the signals that passed");
|
||||
|
||||
/* Terminal: a verdict never changes, even past the deadline. */
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 500000), OTA_HEALTH_MARK_VALID,
|
||||
"a confirmed image must not later be rolled back by the same check");
|
||||
|
||||
/* Signals arriving after the minimum uptime confirm immediately. */
|
||||
ota_health_sm_init(&sm);
|
||||
expect(ota_health_step(&sm, &CFG, true, true, 45000), OTA_HEALTH_MARK_VALID,
|
||||
"both signals after min uptime -> confirm now");
|
||||
|
||||
/* No IP by the deadline. */
|
||||
ota_health_sm_init(&sm);
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 119999), OTA_HEALTH_WAIT,
|
||||
"one ms before the deadline");
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 120000), OTA_HEALTH_ROLLBACK,
|
||||
"no signals at the deadline -> roll back");
|
||||
expect_reason(&sm, "no IP and no CSI", "both missing");
|
||||
|
||||
/* IP but the CSI pipeline never delivers. */
|
||||
ota_health_sm_init(&sm);
|
||||
ota_health_step(&sm, &CFG, true, false, 5000);
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 120000), OTA_HEALTH_ROLLBACK,
|
||||
"associated but no CSI frame -> roll back");
|
||||
expect_reason(&sm, "no CSI frame", "names the missing CSI signal");
|
||||
|
||||
/* CSI sent (can happen only with IP in practice) but no IP event seen. */
|
||||
ota_health_sm_init(&sm);
|
||||
ota_health_step(&sm, &CFG, false, true, 5000);
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 130000), OTA_HEALTH_ROLLBACK,
|
||||
"no IP -> roll back");
|
||||
expect_reason(&sm, "no IP", "names the missing IP signal");
|
||||
|
||||
/* Rollback is terminal too. */
|
||||
expect(ota_health_step(&sm, &CFG, true, true, 131000), OTA_HEALTH_ROLLBACK,
|
||||
"late signals must not resurrect a rolled-back verdict");
|
||||
|
||||
/* Force-fail hook rolls back exactly where it would have confirmed. */
|
||||
{
|
||||
const ota_health_cfg_t ff = { TIMEOUT_MS, MIN_UP_MS, true };
|
||||
ota_health_sm_init(&sm);
|
||||
expect(ota_health_step(&sm, &ff, true, true, 10000), OTA_HEALTH_WAIT,
|
||||
"force-fail still waits for the minimum uptime");
|
||||
expect(ota_health_step(&sm, &ff, false, false, 30000), OTA_HEALTH_ROLLBACK,
|
||||
"force-fail must roll back instead of confirming");
|
||||
expect_reason(&sm, "FORCE_FAIL", "names the test hook");
|
||||
}
|
||||
|
||||
/* Minimum uptime above the timeout is clamped, not a guaranteed rollback. */
|
||||
{
|
||||
const ota_health_cfg_t bad = { 60000u, 600000u, false };
|
||||
ota_health_sm_init(&sm);
|
||||
expect(ota_health_step(&sm, &bad, true, true, 59999), OTA_HEALTH_WAIT,
|
||||
"clamped min uptime: still waiting just before it");
|
||||
expect(ota_health_step(&sm, &bad, false, false, 60000), OTA_HEALTH_MARK_VALID,
|
||||
"healthy at the deadline must confirm, not roll back");
|
||||
}
|
||||
|
||||
/* Zero minimum uptime confirms on the first step with both signals. */
|
||||
{
|
||||
const ota_health_cfg_t zero = { TIMEOUT_MS, 0u, false };
|
||||
ota_health_sm_init(&sm);
|
||||
expect(ota_health_step(&sm, &zero, true, true, 0), OTA_HEALTH_MARK_VALID,
|
||||
"no soak configured -> confirm immediately");
|
||||
}
|
||||
|
||||
if (g_failures) {
|
||||
printf("FAILED: %d case(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("OK: all OTA health cases pass\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Side-effect tests for provision.py --dry-run and --reset (#2096).
|
||||
|
||||
main() runs in-process with the NVS generator and the flasher stubbed out, so
|
||||
no serial port is opened and no ESP-IDF tooling is needed. State lives in a
|
||||
per-test temp dir passed via --state-dir.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PROVISION_PATH = Path(__file__).resolve().parents[1] / "provision.py"
|
||||
SPEC = importlib.util.spec_from_file_location("provision", PROVISION_PATH)
|
||||
provision = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(provision)
|
||||
|
||||
PORT = "/dev/null-test"
|
||||
FAKE_NVS = b"\xab" * 64
|
||||
PRIOR = {
|
||||
"ssid": "TESTSSID",
|
||||
"password": "TESTPASS",
|
||||
"target_ip": "127.0.0.1",
|
||||
"node_id": 1,
|
||||
}
|
||||
WIFI = ["--ssid", "TESTSSID", "--password", "TESTPASS", "--target-ip", "127.0.0.1"]
|
||||
|
||||
|
||||
class ProvisionSideEffectTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="provision-side-effects-")
|
||||
self.state_dir = os.path.join(self.root, "state")
|
||||
self.cwd = os.path.join(self.root, "cwd")
|
||||
os.makedirs(self.cwd)
|
||||
self.state_path = provision._state_path_for(PORT, self.state_dir)
|
||||
self.flash = mock.Mock()
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.root, ignore_errors=True)
|
||||
|
||||
def run_main(self, *argv):
|
||||
"""Run provision.main() with stubs; return the SystemExit code or None."""
|
||||
old_cwd = os.getcwd()
|
||||
os.chdir(self.cwd)
|
||||
try:
|
||||
with mock.patch.object(sys, "argv", ["provision.py", "--port", PORT,
|
||||
"--state-dir", self.state_dir, *argv]), \
|
||||
mock.patch.object(provision, "generate_nvs_binary",
|
||||
return_value=FAKE_NVS), \
|
||||
mock.patch.object(provision, "flash_nvs", self.flash), \
|
||||
contextlib.redirect_stdout(io.StringIO()), \
|
||||
contextlib.redirect_stderr(io.StringIO()):
|
||||
provision.main()
|
||||
except SystemExit as exc:
|
||||
return exc.code
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
return None
|
||||
|
||||
def seed_state(self):
|
||||
provision.save_state(PORT, self.state_dir, PRIOR)
|
||||
with open(self.state_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
def read_state_bytes(self):
|
||||
with open(self.state_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
def test_dry_run_does_not_create_state(self):
|
||||
self.assertIsNone(self.run_main(*WIFI, "--node-id", "1", "--dry-run"))
|
||||
self.assertFalse(os.path.exists(self.state_path))
|
||||
self.assertFalse(os.path.exists(self.state_dir))
|
||||
self.flash.assert_not_called()
|
||||
|
||||
def test_dry_run_does_not_modify_existing_state(self):
|
||||
before = self.seed_state()
|
||||
self.assertIsNone(self.run_main("--node-id", "7", "--dry-run"))
|
||||
self.assertEqual(self.read_state_bytes(), before)
|
||||
self.flash.assert_not_called()
|
||||
|
||||
def test_dry_run_still_writes_requested_binary(self):
|
||||
# The binary is the dry-run's documented output (ADR-061,
|
||||
# scripts/qemu-mesh-test.sh), so it is kept.
|
||||
self.run_main(*WIFI, "--dry-run")
|
||||
with open(os.path.join(self.cwd, "nvs_provision.bin"), "rb") as f:
|
||||
self.assertEqual(f.read(), FAKE_NVS)
|
||||
|
||||
def test_failed_reset_keeps_state(self):
|
||||
before = self.seed_state()
|
||||
# The issue's repro: no WiFi trio after reset, so validation fails.
|
||||
self.assertEqual(self.run_main("--reset", "--node-id", "3"), 2)
|
||||
self.assertEqual(self.read_state_bytes(), before)
|
||||
self.flash.assert_not_called()
|
||||
|
||||
def test_reset_dry_run_keeps_state(self):
|
||||
before = self.seed_state()
|
||||
self.assertIsNone(self.run_main("--reset", *WIFI, "--node-id", "3", "--dry-run"))
|
||||
self.assertEqual(self.read_state_bytes(), before)
|
||||
|
||||
def test_reset_with_failed_flash_keeps_state(self):
|
||||
before = self.seed_state()
|
||||
self.flash.side_effect = subprocess.CalledProcessError(2, "esptool")
|
||||
with self.assertRaises(subprocess.CalledProcessError):
|
||||
self.run_main("--reset", *WIFI, "--node-id", "3")
|
||||
self.assertEqual(self.read_state_bytes(), before)
|
||||
|
||||
def test_reset_with_successful_flash_replaces_state(self):
|
||||
provision.save_state(PORT, self.state_dir, {**PRIOR, "zone": "lobby"})
|
||||
self.assertIsNone(self.run_main("--reset", *WIFI, "--node-id", "3"))
|
||||
self.flash.assert_called_once()
|
||||
state = provision.load_state(PORT, self.state_dir)
|
||||
self.assertEqual(state["node_id"], 3)
|
||||
self.assertNotIn("zone", state)
|
||||
|
||||
def test_real_flash_still_persists_merged_state(self):
|
||||
self.seed_state()
|
||||
self.assertIsNone(self.run_main("--zone", "lobby"))
|
||||
self.flash.assert_called_once()
|
||||
state = provision.load_state(PORT, self.state_dir)
|
||||
self.assertEqual(state["zone"], "lobby")
|
||||
for key, value in PRIOR.items():
|
||||
self.assertEqual(state[key], value)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Tests for keying provision.py state by board MAC instead of serial port (#1755).
|
||||
|
||||
main() runs in-process. esptool's MAC read, the NVS generator and the flasher
|
||||
are all stubbed, so no serial port is opened and nothing is flashed. MACs and
|
||||
credentials are placeholders.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import csv
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PROVISION_PATH = Path(__file__).resolve().parents[1] / "provision.py"
|
||||
SPEC = importlib.util.spec_from_file_location("provision", PROVISION_PATH)
|
||||
provision = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(provision)
|
||||
|
||||
PORT = "/dev/cu.usbmodem-test"
|
||||
OTHER_PORT = "/dev/cu.usbmodem-other"
|
||||
MAC_A = "02:00:00:00:00:0a"
|
||||
MAC_B = "02:00:00:00:00:0b"
|
||||
CREDS = ("--ssid", "test-ssid", "--password", "fake-pass-not-real",
|
||||
"--target-ip", "192.0.2.10")
|
||||
|
||||
# Shapes of `esptool read_mac` output (esptool/cmds.py read_mac in 4.x and 5.x).
|
||||
ESPTOOL_V5_S3 = "Connected to ESP32-S3 on /dev/x:\nMAC: 02:00:00:00:00:0a\n"
|
||||
ESPTOOL_V4_S3 = "Chip is ESP32-S3\nMAC: 02:00:00:00:00:0a\nHard resetting via RTS pin...\n"
|
||||
ESPTOOL_V5_C6 = (
|
||||
"MAC: 02:00:00:ff:fe:00:00:0a\n"
|
||||
"BASE MAC: 02:00:00:00:00:0a\n"
|
||||
"MAC_EXT: ff:fe\n"
|
||||
)
|
||||
|
||||
|
||||
class TestParseEsptoolMac(unittest.TestCase):
|
||||
def test_six_byte_mac_line(self):
|
||||
self.assertEqual(provision.parse_esptool_mac(ESPTOOL_V5_S3), MAC_A)
|
||||
self.assertEqual(provision.parse_esptool_mac(ESPTOOL_V4_S3), MAC_A)
|
||||
|
||||
def test_eui64_chips_use_the_base_mac(self):
|
||||
# C5/C6 print an 8-byte "MAC:" line first; that isn't the base MAC.
|
||||
self.assertEqual(provision.parse_esptool_mac(ESPTOOL_V5_C6), MAC_A)
|
||||
|
||||
def test_no_mac_in_output(self):
|
||||
self.assertIsNone(provision.parse_esptool_mac("A fatal error occurred\n"))
|
||||
|
||||
def test_normalize_mac(self):
|
||||
self.assertEqual(provision.normalize_mac("02-00-00-00-00-0A"), MAC_A)
|
||||
self.assertIsNone(provision.normalize_mac("02:00:00:00:00"))
|
||||
self.assertIsNone(provision.normalize_mac("02:00:00:00:00:zz"))
|
||||
|
||||
|
||||
class TestReadChipMac(unittest.TestCase):
|
||||
def test_runs_esptool_read_mac_on_the_port(self):
|
||||
done = subprocess.CompletedProcess([], 0, stdout=ESPTOOL_V5_C6, stderr="")
|
||||
with mock.patch.object(provision.subprocess, "run", return_value=done) as run:
|
||||
self.assertEqual(provision.read_chip_mac(PORT, 115200, "auto"), MAC_A)
|
||||
cmd = run.call_args.args[0]
|
||||
self.assertIn("read_mac", cmd)
|
||||
self.assertEqual(cmd[cmd.index("--port") + 1], PORT)
|
||||
|
||||
def test_failure_returns_none(self):
|
||||
failed = subprocess.CompletedProcess([], 2, stdout="", stderr="no port")
|
||||
with mock.patch.object(provision.subprocess, "run", return_value=failed):
|
||||
self.assertIsNone(provision.read_chip_mac(PORT, 115200, "auto"))
|
||||
with mock.patch.object(provision.subprocess, "run",
|
||||
side_effect=subprocess.TimeoutExpired("esptool", 30)):
|
||||
self.assertIsNone(provision.read_chip_mac(PORT, 115200, "auto"))
|
||||
|
||||
|
||||
class _MainCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="provision-mac-")
|
||||
self.state_dir = os.path.join(self.root, "state")
|
||||
self.flashed_csv = []
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.root, ignore_errors=True)
|
||||
|
||||
def run_main(self, *argv, mac=None):
|
||||
"""Run main() with the board `mac` attached; return (code, stdout, stderr)."""
|
||||
def fake_generate(csv_content, _size):
|
||||
self.flashed_csv.append(csv_content)
|
||||
return b"nvs"
|
||||
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
code = 0
|
||||
old_cwd = os.getcwd()
|
||||
os.chdir(self.root)
|
||||
try:
|
||||
with mock.patch.object(sys, "argv", ["provision.py", *argv,
|
||||
"--state-dir", self.state_dir]), \
|
||||
mock.patch.object(provision, "read_chip_mac", return_value=mac,
|
||||
create=True) as read, \
|
||||
mock.patch.object(provision, "generate_nvs_binary",
|
||||
side_effect=fake_generate), \
|
||||
mock.patch.object(provision, "flash_nvs"), \
|
||||
contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||
self.read_mac = read
|
||||
try:
|
||||
provision.main()
|
||||
except SystemExit as exc:
|
||||
code = exc.code
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
return code, out.getvalue(), err.getvalue()
|
||||
|
||||
def last_flashed(self):
|
||||
rows = csv.DictReader(io.StringIO(self.flashed_csv[-1]))
|
||||
return {row["key"]: row["value"] for row in rows}
|
||||
|
||||
def board_file(self, mac):
|
||||
return provision._state_path_for(provision.mac_state_key(mac), self.state_dir)
|
||||
|
||||
|
||||
class TestBoardKeyedState(_MainCase):
|
||||
def test_second_board_on_same_port_does_not_inherit_node_id(self):
|
||||
self.assertEqual(self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)[0], 0)
|
||||
|
||||
code, _, _ = self.run_main("--port", PORT, *CREDS, mac=MAC_B)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertNotIn("node_id", self.last_flashed())
|
||||
|
||||
def test_second_board_without_credentials_is_not_given_the_first_boards(self):
|
||||
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
|
||||
|
||||
code, _, err = self.run_main("--port", PORT, "--node-id", "3", mac=MAC_B)
|
||||
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("Missing required WiFi credentials", err)
|
||||
|
||||
def test_board_keeps_its_state_on_a_different_port(self):
|
||||
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
|
||||
|
||||
code, _, _ = self.run_main("--port", OTHER_PORT, "--zone", "lab", mac=MAC_A)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
flashed = self.last_flashed()
|
||||
self.assertEqual(flashed["node_id"], "2")
|
||||
self.assertEqual(flashed["ssid"], "test-ssid")
|
||||
|
||||
def test_record_carries_board_identity(self):
|
||||
self.run_main("--port", PORT, *CREDS, mac=MAC_A)
|
||||
|
||||
with open(self.board_file(MAC_A), encoding="utf-8") as f:
|
||||
saved = json.load(f)
|
||||
self.assertEqual(saved[provision.STATE_MAC_KEY], MAC_A)
|
||||
self.assertEqual(saved[provision.STATE_PORT_KEY], PORT)
|
||||
self.assertNotIn("_chip_mac", self.flashed_csv[-1])
|
||||
|
||||
def test_unreadable_mac_falls_back_to_port_keyed_state(self):
|
||||
code, _, err = self.run_main("--port", PORT, *CREDS, mac=None)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("could not read the board's MAC", err)
|
||||
self.assertTrue(os.path.isfile(provision._state_path_for(PORT, self.state_dir)))
|
||||
|
||||
def test_reset_ignores_the_boards_record(self):
|
||||
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
|
||||
|
||||
self.run_main("--port", PORT, "--reset", *CREDS, mac=MAC_A)
|
||||
|
||||
self.assertNotIn("node_id", self.last_flashed())
|
||||
|
||||
def test_invalid_mac_flag_is_rejected(self):
|
||||
code, _, err = self.run_main("--port", PORT, "--mac", "not-a-mac", "--state")
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("--mac", err)
|
||||
|
||||
|
||||
class TestMigration(_MainCase):
|
||||
def write_legacy(self, port, state):
|
||||
os.makedirs(self.state_dir, exist_ok=True)
|
||||
path = provision._state_path_for(port, self.state_dir)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(state, f)
|
||||
return path
|
||||
|
||||
def test_port_keyed_record_moves_to_the_board_and_is_retired(self):
|
||||
legacy = self.write_legacy(PORT, {
|
||||
"ssid": "test-ssid", "password": "fake-pass-not-real",
|
||||
"target_ip": "192.0.2.10", "node_id": 5,
|
||||
})
|
||||
|
||||
code, _, err = self.run_main("--port", PORT, "--zone", "lab", mac=MAC_A)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("port-keyed state", err)
|
||||
self.assertEqual(self.last_flashed()["node_id"], "5")
|
||||
self.assertFalse(os.path.exists(legacy))
|
||||
with open(self.board_file(MAC_A), encoding="utf-8") as f:
|
||||
self.assertEqual(json.load(f)["node_id"], 5)
|
||||
|
||||
# The next board on that port starts clean.
|
||||
self.run_main("--port", PORT, *CREDS, mac=MAC_B)
|
||||
self.assertNotIn("node_id", self.last_flashed())
|
||||
|
||||
def test_board_record_wins_over_a_port_keyed_file(self):
|
||||
self.run_main("--port", OTHER_PORT, *CREDS, "--node-id", "2", mac=MAC_A)
|
||||
self.write_legacy(PORT, {"node_id": 9})
|
||||
|
||||
code, _, _ = self.run_main("--port", PORT, "--zone", "lab", mac=MAC_A)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(self.last_flashed()["node_id"], "2")
|
||||
|
||||
|
||||
class TestNoSerialForInspection(_MainCase):
|
||||
def test_state_shows_last_board_on_port_without_opening_it(self):
|
||||
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
|
||||
|
||||
code, out, err = self.run_main("--port", PORT, "--state", mac=MAC_B)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.read_mac.assert_not_called()
|
||||
self.assertEqual(json.loads(out)["node_id"], 2)
|
||||
self.assertIn(MAC_A, err)
|
||||
|
||||
def test_state_with_mac_flag_selects_that_board(self):
|
||||
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
|
||||
self.run_main("--port", PORT, *CREDS, "--node-id", "3", mac=MAC_B)
|
||||
|
||||
code, out, _ = self.run_main("--port", PORT, "--state", "--mac", MAC_A.upper())
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(json.loads(out)["node_id"], 2)
|
||||
|
||||
def test_dry_run_does_not_open_the_port(self):
|
||||
code, _, _ = self.run_main("--port", PORT, *CREDS, "--dry-run", mac=MAC_A)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.read_mac.assert_not_called()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Tests for how provision.py protects the WiFi password and seed token (#1754).
|
||||
|
||||
The state file, the --dry-run NVS binary and the fallback CSV all hold the
|
||||
secrets in clear, so they must be owner-only, and --state must not print them
|
||||
unless asked. main() runs in-process with the NVS generator stubbed, so no
|
||||
serial port is opened and no ESP-IDF tooling is needed. All credentials here
|
||||
are placeholders.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
PROVISION_PATH = Path(__file__).resolve().parents[1] / "provision.py"
|
||||
SPEC = importlib.util.spec_from_file_location("provision", PROVISION_PATH)
|
||||
provision = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(provision)
|
||||
|
||||
FAKE_PASSWORD = "fake-pass-not-real"
|
||||
FAKE_TOKEN = "fake-token-not-real"
|
||||
POSIX_ONLY = unittest.skipIf(sys.platform == "win32", "POSIX permission model only")
|
||||
|
||||
|
||||
def mode_of(path):
|
||||
return stat.S_IMODE(os.lstat(path).st_mode)
|
||||
|
||||
|
||||
class _TempDirCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.root = tempfile.mkdtemp(prefix="provision-secrets-")
|
||||
self.state_dir = os.path.join(self.root, "state")
|
||||
# The default umask is what made files 0644 in the first place.
|
||||
self.old_umask = os.umask(0o022)
|
||||
|
||||
def tearDown(self):
|
||||
os.umask(self.old_umask)
|
||||
shutil.rmtree(self.root, ignore_errors=True)
|
||||
|
||||
def run_main(self, *argv, cwd=None):
|
||||
"""Run provision.main() with argv; return (exit code, stdout, stderr)."""
|
||||
out, err = io.StringIO(), io.StringIO()
|
||||
code = 0
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(cwd or self.root)
|
||||
with mock.patch.object(sys, "argv", ["provision.py", *argv]), \
|
||||
contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
|
||||
try:
|
||||
provision.main()
|
||||
except SystemExit as exc:
|
||||
code = exc.code
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
return code, out.getvalue(), err.getvalue()
|
||||
|
||||
|
||||
@POSIX_ONLY
|
||||
class TestStateFilePermissions(_TempDirCase):
|
||||
def test_new_state_dir_and_file_are_owner_only(self):
|
||||
path = provision.save_state("COM7", self.state_dir, {"password": FAKE_PASSWORD})
|
||||
self.assertEqual(mode_of(self.state_dir), 0o700)
|
||||
self.assertEqual(mode_of(path), 0o600)
|
||||
|
||||
def test_save_tightens_files_left_by_an_older_version(self):
|
||||
os.makedirs(self.state_dir)
|
||||
os.chmod(self.state_dir, 0o755)
|
||||
legacy = provision._state_path_for("/dev/ttyUSB0", self.state_dir)
|
||||
stale_tmp = legacy + ".tmp"
|
||||
for path in (legacy, stale_tmp):
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump({"password": FAKE_PASSWORD}, f)
|
||||
os.chmod(path, 0o644)
|
||||
|
||||
provision.save_state("COM7", self.state_dir, {"ssid": "test-ssid"})
|
||||
|
||||
self.assertEqual(mode_of(self.state_dir), 0o700)
|
||||
self.assertEqual(mode_of(legacy), 0o600)
|
||||
self.assertEqual(mode_of(stale_tmp), 0o600)
|
||||
|
||||
def test_planted_symlink_at_old_temp_path_does_not_receive_secret(self):
|
||||
os.makedirs(self.state_dir, mode=0o700)
|
||||
victim = os.path.join(self.root, "victim.txt")
|
||||
with open(victim, "w", encoding="utf-8") as f:
|
||||
f.write("original")
|
||||
os.symlink(victim, provision._state_path_for("COM7", self.state_dir) + ".tmp")
|
||||
|
||||
provision.save_state("COM7", self.state_dir, {"password": FAKE_PASSWORD})
|
||||
|
||||
with open(victim, encoding="utf-8") as f:
|
||||
self.assertEqual(f.read(), "original")
|
||||
|
||||
def test_hardening_does_not_chmod_through_a_symlink(self):
|
||||
os.makedirs(self.state_dir, mode=0o700)
|
||||
victim = os.path.join(self.root, "victim.txt")
|
||||
with open(victim, "w", encoding="utf-8") as f:
|
||||
f.write("not a state file")
|
||||
os.chmod(victim, 0o644)
|
||||
os.symlink(victim, os.path.join(self.state_dir, "link.json"))
|
||||
|
||||
with contextlib.redirect_stderr(io.StringIO()):
|
||||
provision.harden_state_dir(self.state_dir)
|
||||
|
||||
self.assertEqual(mode_of(victim), 0o644)
|
||||
|
||||
def test_state_inspection_repairs_legacy_permissions(self):
|
||||
# --state never writes, so the repair has to happen before the read.
|
||||
os.makedirs(self.state_dir)
|
||||
os.chmod(self.state_dir, 0o755)
|
||||
legacy = provision._state_path_for("COM7", self.state_dir)
|
||||
with open(legacy, "w", encoding="utf-8") as f:
|
||||
json.dump({"password": FAKE_PASSWORD}, f)
|
||||
os.chmod(legacy, 0o644)
|
||||
|
||||
code, _, _ = self.run_main("--port", "COM7", "--state-dir", self.state_dir, "--state")
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(mode_of(self.state_dir), 0o700)
|
||||
self.assertEqual(mode_of(legacy), 0o600)
|
||||
|
||||
|
||||
class TestStateOutputMasking(_TempDirCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
provision.save_state("COM7", self.state_dir, {
|
||||
"ssid": "test-ssid",
|
||||
"password": FAKE_PASSWORD,
|
||||
"seed_token": FAKE_TOKEN,
|
||||
"target_ip": "192.0.2.10",
|
||||
})
|
||||
|
||||
def test_state_hides_password_and_seed_token(self):
|
||||
code, out, err = self.run_main(
|
||||
"--port", "COM7", "--state-dir", self.state_dir, "--state")
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertNotIn(FAKE_PASSWORD, out)
|
||||
self.assertNotIn(FAKE_TOKEN, out)
|
||||
shown = json.loads(out)
|
||||
self.assertEqual(shown["password"], "(set)")
|
||||
self.assertEqual(shown["seed_token"], "(set)")
|
||||
self.assertEqual(shown["ssid"], "test-ssid")
|
||||
self.assertIn("--show-secrets", err)
|
||||
|
||||
def test_show_secrets_prints_them(self):
|
||||
code, out, _ = self.run_main(
|
||||
"--port", "COM7", "--state-dir", self.state_dir, "--state", "--show-secrets")
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
shown = json.loads(out)
|
||||
self.assertEqual(shown["password"], FAKE_PASSWORD)
|
||||
self.assertEqual(shown["seed_token"], FAKE_TOKEN)
|
||||
|
||||
def test_redact_marks_empty_and_leaves_absent_alone(self):
|
||||
shown = provision.redact_secrets({"password": "", "ssid": "test-ssid"})
|
||||
self.assertEqual(shown, {"password": "(empty)", "ssid": "test-ssid"})
|
||||
|
||||
|
||||
@POSIX_ONLY
|
||||
class TestIntermediateArtifacts(_TempDirCase):
|
||||
ARGS = ("--port", "COM7", "--ssid", "test-ssid", "--password", FAKE_PASSWORD,
|
||||
"--target-ip", "192.0.2.10", "--dry-run")
|
||||
|
||||
def test_dry_run_binary_is_owner_only_even_if_it_already_existed(self):
|
||||
out_path = os.path.join(self.root, "nvs_provision.bin")
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(b"old")
|
||||
os.chmod(out_path, 0o644)
|
||||
|
||||
with mock.patch.object(provision, "generate_nvs_binary",
|
||||
return_value=b"nvs-with-" + FAKE_PASSWORD.encode()):
|
||||
code, _, _ = self.run_main(*self.ARGS, "--state-dir", self.state_dir)
|
||||
|
||||
self.assertIn(code, (0, None))
|
||||
self.assertEqual(mode_of(out_path), 0o600)
|
||||
|
||||
def test_fallback_csv_is_owner_only(self):
|
||||
with mock.patch.object(provision, "generate_nvs_binary",
|
||||
side_effect=RuntimeError("generator missing")):
|
||||
code, _, _ = self.run_main(*self.ARGS, "--state-dir", self.state_dir)
|
||||
|
||||
self.assertEqual(code, 1)
|
||||
csv_path = os.path.join(self.root, "nvs_config.csv")
|
||||
self.assertEqual(mode_of(csv_path), 0o600)
|
||||
|
||||
def test_generator_files_live_in_a_private_dir(self):
|
||||
seen = {}
|
||||
|
||||
def fake_generator(cmd, **_kwargs):
|
||||
csv_path, bin_path = cmd[-3], cmd[-2]
|
||||
seen["dir_mode"] = mode_of(os.path.dirname(bin_path))
|
||||
seen["csv_mode"] = mode_of(csv_path)
|
||||
with open(bin_path, "wb") as f:
|
||||
f.write(b"nvs")
|
||||
|
||||
# A shared temp dir like Linux /tmp, rather than macOS's per-user one.
|
||||
shared_tmp = os.path.join(self.root, "shared-tmp")
|
||||
os.makedirs(shared_tmp)
|
||||
os.chmod(shared_tmp, 0o755)
|
||||
with mock.patch.object(provision.tempfile, "tempdir", shared_tmp), \
|
||||
mock.patch.object(provision.subprocess, "check_call",
|
||||
side_effect=fake_generator):
|
||||
self.assertEqual(provision.generate_nvs_binary("key,type\n", 0x6000), b"nvs")
|
||||
|
||||
self.assertEqual(seen["dir_mode"], 0o700)
|
||||
self.assertEqual(seen["csv_mode"], 0o600)
|
||||
|
||||
|
||||
class TestPasswordInput(_TempDirCase):
|
||||
"""Ways to give the password without putting it on the command line."""
|
||||
|
||||
BASE = ("--port", "COM7", "--target-ip", "192.0.2.10")
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.flashed_csv = []
|
||||
|
||||
def password_file(self, content, mode=0o600):
|
||||
path = os.path.join(self.root, "wifi-pass.txt")
|
||||
with open(path, "w", encoding="utf-8", newline="") as f:
|
||||
f.write(content)
|
||||
os.chmod(path, mode)
|
||||
return path
|
||||
|
||||
def provision(self, *argv, tty=False, typed=None):
|
||||
"""Run main() with flashing stubbed; return (code, stderr, getpass mock)."""
|
||||
def fake_generate(csv_content, _size):
|
||||
self.flashed_csv.append(csv_content)
|
||||
return b"nvs"
|
||||
|
||||
stdin = mock.Mock()
|
||||
stdin.isatty.return_value = tty
|
||||
# read_chip_mac only exists once state is keyed by board MAC (#1755);
|
||||
# stub it so these tests never reach esptool or a serial port.
|
||||
with mock.patch.object(provision, "generate_nvs_binary", side_effect=fake_generate), \
|
||||
mock.patch.object(provision, "flash_nvs"), \
|
||||
mock.patch.object(provision, "read_chip_mac", return_value=None,
|
||||
create=True), \
|
||||
mock.patch.object(sys, "stdin", stdin), \
|
||||
mock.patch("getpass.getpass", return_value=typed) as prompt:
|
||||
code, _, err = self.run_main(*self.BASE, *argv, "--state-dir", self.state_dir)
|
||||
return code, err, prompt
|
||||
|
||||
def flashed_password(self):
|
||||
rows = provision.csv.DictReader(io.StringIO(self.flashed_csv[-1]))
|
||||
return {row["key"]: row["value"] for row in rows}.get("password")
|
||||
|
||||
def test_password_file_is_read_and_one_newline_dropped(self):
|
||||
path = self.password_file(FAKE_PASSWORD + " \n\n")
|
||||
|
||||
code, err, _ = self.provision("--ssid", "test-ssid", "--password-file", path)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
# Only one newline goes; trailing spaces and the second newline stay.
|
||||
self.assertEqual(self.flashed_password(), FAKE_PASSWORD + " \n")
|
||||
self.assertNotIn("visible in ps", err)
|
||||
|
||||
def test_password_file_crlf_counts_as_one_newline(self):
|
||||
path = self.password_file(FAKE_PASSWORD + "\r\n")
|
||||
self.provision("--ssid", "test-ssid", "--password-file", path)
|
||||
self.assertEqual(self.flashed_password(), FAKE_PASSWORD)
|
||||
|
||||
@POSIX_ONLY
|
||||
def test_group_or_world_readable_password_file_is_refused(self):
|
||||
for mode in (0o644, 0o640, 0o604):
|
||||
with self.subTest(mode=oct(mode)):
|
||||
path = self.password_file(FAKE_PASSWORD + "\n", mode)
|
||||
code, err, _ = self.provision("--ssid", "test-ssid", "--password-file", path)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("chmod 600", err)
|
||||
|
||||
@POSIX_ONLY
|
||||
def test_insecure_password_file_allowed_with_flag_and_warning(self):
|
||||
path = self.password_file(FAKE_PASSWORD + "\n", 0o644)
|
||||
|
||||
code, err, _ = self.provision("--ssid", "test-ssid", "--password-file", path,
|
||||
"--allow-insecure-password-file")
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("readable by group or others", err)
|
||||
self.assertEqual(self.flashed_password(), FAKE_PASSWORD)
|
||||
|
||||
def test_missing_or_empty_password_file_is_an_error(self):
|
||||
missing = os.path.join(self.root, "nope.txt")
|
||||
code, err, _ = self.provision("--ssid", "test-ssid", "--password-file", missing)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("does not exist", err)
|
||||
|
||||
empty = self.password_file("\n")
|
||||
code, err, _ = self.provision("--ssid", "test-ssid", "--password-file", empty)
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("is empty", err)
|
||||
|
||||
def test_password_and_password_file_are_mutually_exclusive(self):
|
||||
path = self.password_file(FAKE_PASSWORD)
|
||||
|
||||
code, err, _ = self.provision("--ssid", "test-ssid", "--password", FAKE_PASSWORD,
|
||||
"--password-file", path)
|
||||
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("not allowed with argument", err)
|
||||
|
||||
def test_password_on_command_line_warns(self):
|
||||
code, err, _ = self.provision("--ssid", "test-ssid", "--password", FAKE_PASSWORD)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn("--password is visible in ps and shell history", err)
|
||||
self.assertIn("--password-file", err)
|
||||
|
||||
def test_terminal_prompts_for_password_of_a_new_ssid(self):
|
||||
code, _, prompt = self.provision("--ssid", "test-ssid", tty=True, typed=FAKE_PASSWORD)
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
prompt.assert_called_once()
|
||||
self.assertEqual(self.flashed_password(), FAKE_PASSWORD)
|
||||
self.assertEqual(provision.load_state("COM7", self.state_dir)["password"], FAKE_PASSWORD)
|
||||
|
||||
def test_no_prompt_when_saved_password_matches_the_ssid(self):
|
||||
provision.save_state("COM7", self.state_dir,
|
||||
{"ssid": "test-ssid", "password": FAKE_PASSWORD})
|
||||
|
||||
code, _, prompt = self.provision("--ssid", "test-ssid", tty=True, typed="unused")
|
||||
|
||||
self.assertEqual(code, 0)
|
||||
prompt.assert_not_called()
|
||||
self.assertEqual(self.flashed_password(), FAKE_PASSWORD)
|
||||
|
||||
def test_no_prompt_without_a_terminal(self):
|
||||
code, err, prompt = self.provision("--ssid", "test-ssid", tty=False, typed="unused")
|
||||
|
||||
prompt.assert_not_called()
|
||||
self.assertEqual(code, 2)
|
||||
self.assertIn("--password", err)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,78 @@
|
||||
# WL-WN586X3 CSI patches
|
||||
|
||||
OpenWrt patches that made a Wavlink WL-WN586X3 Rev A emit CSI into
|
||||
`wifi-densepose-mtk-bridge`. Chipset on the board that was flashed: MT7981BA,
|
||||
MT7976CN, MT7531AE. Image base: OpenWrt 24.10.8. This directory is the patch
|
||||
set. It is not a firmware image, a sysupgrade binary, or a capture.
|
||||
|
||||
Developed in whitsentry, a RuView-based project. The user guide, covering
|
||||
the build, arming CSI, the bridge, the server, and limitations, is
|
||||
[`docs/mediatek-router-csi.md`](../../docs/mediatek-router-csi.md).
|
||||
|
||||
Provenance of frames from this path is `mediatek:physical-unvalidated` until
|
||||
the ADR-266 gates (calibration, sequence, timestamp, repeatability) pass on
|
||||
this model. Two of these radios are two receivers. They are not one phased array.
|
||||
|
||||
## Files
|
||||
|
||||
| Patch | What it is | License of the base |
|
||||
|---|---|---|
|
||||
| `patches/1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch` | MediaTek's 2022 mt7915 CSI patch, rebased onto `openwrt/mt76` `eb567bc7` (the revision OpenWrt 24.10.8 pins). Two dead-code warnings were removed so `-Werror` builds. | BSD-3-Clause-Clear, MediaTek |
|
||||
| `patches/0001-wn586x3-reva-single-image-sysupgrade-and-generic-spi-nor.patch` | Board support ported from [dadogroove/openwrt `wl-wn586x3`](https://github.com/dadogroove/openwrt/tree/wl-wn586x3) @ `b080a655` onto the 24.10.8 tag. Single-image sysupgrade, generic SPI-NOR (the Boya flash path is a deletion of quad-SPI width, not a new chip driver), factory MAC offsets. | OpenWrt target patches, GPL-2.0 |
|
||||
| `patches/mt76-vendor-csi-dump-bounds.patch` | `mt76-vendor dump csi` wrote past its buffer once a dump held more records than requested. The driver seeds its counter and then decrements, so a request for N can return N+1. Also bounds `snprintf` and checks attribute presence. Base: `mediatek/mtk-openwrt-feeds` @ `a15454c8`, `feed/app/mt76-vendor/src/csi.c`. | GPL-2.0, MediaTek |
|
||||
| `patches/csidump-nl80211-attr-enum.patch` | [MtkCSIdump](https://github.com/MtkWifiRev/MtkCSIdump) @ `276b4b08` was one attribute off from this driver (`STA_INTERVAL`, `DATA_NUM`). The dump callback returned nothing and the ring sat full. | Apache-2.0 (MtkCSIdump `LICENSE.txt`) |
|
||||
|
||||
Do not copy `mt76` itself, the imagebuilder download cache, `out/`, stock
|
||||
backups, or anything under a `captures/` directory into this repo.
|
||||
|
||||
### Licences
|
||||
|
||||
These files are patches against third-party code. Each one keeps the licence
|
||||
of the code it modifies, as listed in the table above. They are not covered
|
||||
by the repository's MIT/Apache-2.0 licence. In particular, the `0001-` board
|
||||
patch and `mt76-vendor-csi-dump-bounds.patch` modify GPL-2.0 sources, and the
|
||||
`1001-` CSI patch is MediaTek's BSD-3-Clause-Clear work with its original
|
||||
author line kept. Nothing here is a firmware blob, a private header, or SDK
|
||||
code (ADR-266 decision 5).
|
||||
|
||||
## What the radio actually did
|
||||
|
||||
CSI follows client traffic. An idle associated station is a few frames per
|
||||
second. The vendor dump tool opens its output with `fopen(path, "a+")`, so a
|
||||
loop that does not remove the file replays every earlier batch. Changing the
|
||||
CSI filter stops capture until `ctrl=1,0,0,0` is sent again. `CSIdump` disarms
|
||||
CSI on SIGTERM; an explicit disarm is the right stop, and `kill -9` is how the
|
||||
lab kept the radio armed, which is a bug in the tool rather than a procedure
|
||||
to keep.
|
||||
|
||||
`mt76-vendor` JSON is the path that keeps RSSI, SNR, transmit address, bandwidth,
|
||||
PPDU mode, and per-chain indices. The CSIdump UDP datagram has none of those
|
||||
and sends antennas one after another, so the bridge emits one 1x1 frame per
|
||||
datagram. Prefer the dump path when the chain layout matters. Neither stock
|
||||
tool prints `pkt_sn`, which the firmware event already carries, so the bridge
|
||||
cannot see loss on an unpatched dumper.
|
||||
|
||||
Each radio is two chains. The four external antennas are a 2.4 GHz pair and a
|
||||
5 GHz pair. Live CSI in the lab was 2x2 on 2.4 GHz. The 5 GHz radio was idle
|
||||
for sensing.
|
||||
|
||||
## Build outline
|
||||
|
||||
Apply the rebased CSI patch to the `mt76` tree OpenWrt 24.10.8 builds, and the
|
||||
board patch to `target/linux/mediatek`. Point the image at the patched
|
||||
`mt76-vendor` and, if you use it, the patched CSIdump. Flash only a Rev A unit,
|
||||
and only an image you built and hashed yourself. This README does not name a
|
||||
LAN, an account, or a password.
|
||||
|
||||
Host side, after the radio is emitting:
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo build -p wifi-densepose-mtk-bridge
|
||||
./target/debug/wifi-densepose-mtk-bridge --help
|
||||
```
|
||||
|
||||
Replay stays on loopback unless you choose otherwise. `--listen` refuses to
|
||||
start without `--listen-allow`. `--replay` of a file refuses to start until
|
||||
you pass `--synthetic` or `--captured-on <model>/<firmware>`. A filename
|
||||
containing `.synthetic.` cannot be attested as hardware.
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
Subject: [PATCH] mediatek: wl-wn586x3 Rev A - single-image sysupgrade, generic SPI-NOR
|
||||
|
||||
Origin: https://github.com/dadogroove/openwrt/tree/wl-wn586x3
|
||||
branch wl-wn586x3 @ b080a6550ec6928400735dab4837601efaaf6243 (2026-09-19),
|
||||
which tracks openwrt/main. Ported here onto the v24.10.8 tag.
|
||||
Forum: https://forum.openwrt.org/t/wavlink-wl-wn586x3-ver-a-installation-advice/243893
|
||||
|
||||
Four separable changes, all in target/, none touching package/kernel/mt76, so
|
||||
this is orthogonal to the CSI driver patch and composes with it.
|
||||
|
||||
1. Single-image sysupgrade (filogic.mk, platform.sh). Replaces the stock
|
||||
wn586x3 recipe with the append-kernel/append-rootfs/append-metadata form
|
||||
that wl-wn573hx3 already uses in 24.10.8, adds a `fit lzma` KERNEL, drops
|
||||
IMAGE_SIZE 15424k to 14336k, marks the firmware partition `denx,fit`, and
|
||||
adds the board to the default_do_upgrade case list. One sysupgrade.bin then
|
||||
serves both the initial mtd write and later sysupgrades. This is the
|
||||
soft-brick fix dadogroove landed 2026-04-25.
|
||||
|
||||
2. Generic SPI-NOR operation (the Boya fix). There is NO Boya BY25Q128AS
|
||||
driver patch anywhere - the fix is a DELETION. Dropping
|
||||
spi-tx-bus-width/spi-rx-bus-width = <4> stops the controller driving the
|
||||
chip in quad mode, so the generic jedec,spi-nor binding is sufficient and
|
||||
the chip no longer needs to be recognised by name. spi-max-frequency also
|
||||
goes 25 -> 50 MHz.
|
||||
|
||||
3. MAC addressing. Sources both ethernet MACs from absolute factory offsets
|
||||
0x3fff4/0x3fffa instead of the mac-base cell at hw+0x44e, and drops the
|
||||
wifi mac-address cell so wifi takes its MAC from the EEPROM. Entangled with
|
||||
(2) only by file, but NOT separable within this patch: the eth nodes and the
|
||||
cell definitions must move together. This CHANGES the MACs the board
|
||||
presents versus an official 24.10.8 image. Adopted because dadogroove has
|
||||
the hardware and his build is the one known to boot on Boya units.
|
||||
|
||||
4. Cosmetic: gpio-keys node renamed button-reset -> button-0; mdio_pins
|
||||
pinctrl moved onto ð.
|
||||
|
||||
Adaptations made while porting to 24.10.8:
|
||||
- platform.sh: the case list is ordered differently; wl-wn586x3 was inserted
|
||||
next to the wl-wn573hx3 entry that is already there.
|
||||
- &wifi: 24.10.8 takes the EEPROM via `mediatek,mtd-eeprom = <&factory 0x0>`,
|
||||
not snapshot's eeprom_factory_0 nvmem cell, so only the mac-address cell
|
||||
was removed.
|
||||
- filogic.mk hunk landed at a -1824 line offset; content identical.
|
||||
Verified: no dangling DTS labels. NOT yet verified: DTB compilation, and
|
||||
nothing has been flashed.
|
||||
---
|
||||
diff --git a/target/linux/mediatek/dts/mt7981b-wavlink-wl-wn586x3.dts b/target/linux/mediatek/dts/mt7981b-wavlink-wl-wn586x3.dts
|
||||
index 62be0faa4af3..2260df89e9b5 100644
|
||||
--- a/target/linux/mediatek/dts/mt7981b-wavlink-wl-wn586x3.dts
|
||||
+++ b/target/linux/mediatek/dts/mt7981b-wavlink-wl-wn586x3.dts
|
||||
@@ -24,7 +24,7 @@
|
||||
gpio-keys {
|
||||
compatible = "gpio-keys";
|
||||
|
||||
- button-reset {
|
||||
+ button-0 {
|
||||
label = "reset";
|
||||
linux,code = <KEY_RESTART>;
|
||||
gpios = <&pio 11 GPIO_ACTIVE_LOW>;
|
||||
@@ -79,13 +79,17 @@
|
||||
};
|
||||
|
||||
ð {
|
||||
+
|
||||
+ pinctrl-names = "default";
|
||||
+ pinctrl-0 = <&mdio_pins>;
|
||||
+
|
||||
status = "okay";
|
||||
|
||||
gmac0: mac@0 {
|
||||
compatible = "mediatek,eth-mac";
|
||||
reg = <0>;
|
||||
phy-mode = "2500base-x";
|
||||
- nvmem-cells = <&macaddr_hw_44e 0>;
|
||||
+ nvmem-cells = <&macaddr_factory_3fff4>;
|
||||
nvmem-cell-names = "mac-address";
|
||||
|
||||
fixed-link {
|
||||
@@ -100,7 +104,7 @@
|
||||
reg = <1>;
|
||||
phy-mode = "gmii";
|
||||
phy-handle = <&int_gbe_phy>;
|
||||
- nvmem-cells = <&macaddr_hw_44e 1>;
|
||||
+ nvmem-cells = <&macaddr_factory_3fffa>;
|
||||
nvmem-cell-names = "mac-address";
|
||||
};
|
||||
};
|
||||
@@ -118,8 +122,6 @@
|
||||
};
|
||||
|
||||
&spi0 {
|
||||
- pinctrl-names = "default";
|
||||
- pinctrl-0 = <&spi0_flash_pins>;
|
||||
status = "disabled";
|
||||
};
|
||||
|
||||
@@ -135,9 +137,7 @@
|
||||
compatible = "jedec,spi-nor";
|
||||
reg = <0>;
|
||||
|
||||
- spi-max-frequency = <25000000>;
|
||||
- spi-tx-bus-width = <4>;
|
||||
- spi-rx-bus-width = <4>;
|
||||
+ spi-max-frequency = <50000000>;
|
||||
|
||||
partitions {
|
||||
compatible = "fixed-partitions";
|
||||
@@ -166,10 +166,12 @@
|
||||
#address-cells = <1>;
|
||||
#size-cells = <1>;
|
||||
|
||||
- macaddr_factory_4: macaddr@4 {
|
||||
- compatible = "mac-base";
|
||||
- reg = <0x4 0x6>;
|
||||
- #nvmem-cell-cells = <1>;
|
||||
+ macaddr_factory_3fff4: macaddr@3fff4 {
|
||||
+ reg = <0x3fff4 0x6>;
|
||||
+ };
|
||||
+
|
||||
+ macaddr_factory_3fffa: macaddr@3fffa {
|
||||
+ reg = <0x3fffa 0x6>;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -181,6 +183,7 @@
|
||||
};
|
||||
|
||||
partition@f0000 {
|
||||
+ compatible = "denx,fit";
|
||||
label = "firmware";
|
||||
reg = <0x180000 0xe00000>;
|
||||
};
|
||||
@@ -189,17 +192,6 @@
|
||||
label = "hw";
|
||||
reg = <0xf80000 0x80000>;
|
||||
|
||||
- nvmem-layout {
|
||||
- compatible = "fixed-layout";
|
||||
- #address-cells = <1>;
|
||||
- #size-cells = <1>;
|
||||
-
|
||||
- macaddr_hw_44e: macaddr@44e {
|
||||
- compatible = "mac-base";
|
||||
- reg = <0x44e 0x11>;
|
||||
- #nvmem-cell-cells = <1>;
|
||||
- };
|
||||
- };
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -265,6 +257,4 @@
|
||||
&wifi {
|
||||
status = "okay";
|
||||
mediatek,mtd-eeprom = <&factory 0x0>;
|
||||
- nvmem-cells = <&macaddr_factory_4 0>;
|
||||
- nvmem-cell-names = "mac-address";
|
||||
};
|
||||
diff --git a/target/linux/mediatek/filogic/base-files/lib/upgrade/platform.sh b/target/linux/mediatek/filogic/base-files/lib/upgrade/platform.sh
|
||||
index 8af61b028926..4a71285bea01 100644
|
||||
--- a/target/linux/mediatek/filogic/base-files/lib/upgrade/platform.sh
|
||||
+++ b/target/linux/mediatek/filogic/base-files/lib/upgrade/platform.sh
|
||||
@@ -137,6 +137,7 @@ platform_do_upgrade() {
|
||||
cudy,wr3000-v1|\
|
||||
yuncore,ax835|\
|
||||
wavlink,wl-wn573hx3|\
|
||||
+ wavlink,wl-wn586x3|\
|
||||
totolink,x6000r)
|
||||
default_do_upgrade "$1"
|
||||
;;
|
||||
diff --git a/target/linux/mediatek/image/filogic.mk b/target/linux/mediatek/image/filogic.mk
|
||||
index b7f8ffd47c51..756dc86b8541 100644
|
||||
--- a/target/linux/mediatek/image/filogic.mk
|
||||
+++ b/target/linux/mediatek/image/filogic.mk
|
||||
@@ -1910,8 +1910,12 @@ define Device/wavlink_wl-wn586x3
|
||||
DEVICE_DTS := mt7981b-wavlink-wl-wn586x3
|
||||
DEVICE_DTS_DIR := ../dts
|
||||
DEVICE_DTS_LOADADDR := 0x47000000
|
||||
- IMAGE_SIZE := 15424k
|
||||
DEVICE_PACKAGES := kmod-mt7915e kmod-mt7981-firmware mt7981-wo-firmware
|
||||
+ IMAGE_SIZE := 14336k
|
||||
+ IMAGES := sysupgrade.bin
|
||||
+ KERNEL := kernel-bin | lzma | \
|
||||
+ fit lzma $$(KDIR)/image-$$(firstword $$(DEVICE_DTS)).dtb
|
||||
+ IMAGE/sysupgrade.bin := append-kernel | pad-to 128k | append-rootfs | pad-rootfs | check-size | append-metadata
|
||||
endef
|
||||
TARGET_DEVICES += wavlink_wl-wn586x3
|
||||
|
||||
+1332
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,128 @@
|
||||
Fix: CSIdump forwarded nothing against the mt76 CSI driver built here.
|
||||
Base: github.com/MtkWifiRev/MtkCSIdump @ 276b4b0814a283dcbc6544a76033e0e37154f092
|
||||
|
||||
CSIdump was written against a DIFFERENT mt76 vendor ABI than the 2022
|
||||
MediaTek CSI patch we build. Two enumerators are missing from its copies of
|
||||
the attribute enums, so every attribute number past them was off by one:
|
||||
CTRL: our driver has STA_INTERVAL between INTERVAL and DUMP_NUM
|
||||
DATA: our driver has DATA_NUM between TA and I, and ends CHAIN_INFO not H_IDX
|
||||
|
||||
Failure chain. CSIdump put DUMP_NUM at attr 8, which is the driver's
|
||||
STA_INTERVAL (policy NLA_U32); a 2-byte u16 payload failed the driver's
|
||||
nla_parse minimum-length check, so the dumpit returned -EINVAL and emitted
|
||||
NOTHING. The ring therefore never drained (a unit sat pinned at 3000) and the
|
||||
dump callback never ran. Even had the request landed, the driver replies with
|
||||
DATA at attr 10 while CSIdump looked at attr 9, so the callback would have
|
||||
returned NL_SKIP at its first check with no message printed. And the error
|
||||
print used a stale ret that was always 0, so any failure read as 'Success'.
|
||||
---
|
||||
--- /tmp/mt76_api.cpp.orig 2026-09-19 23:14:12
|
||||
+++ src/wifi_drv_api/mt76_api.cpp 2026-09-19 23:14:12
|
||||
@@ -47,10 +47,12 @@
|
||||
MTK_VENDOR_ATTR_CSI_CTRL_CFG_VAL2,
|
||||
MTK_VENDOR_ATTR_CSI_CTRL_MAC_ADDR,
|
||||
MTK_VENDOR_ATTR_CSI_CTRL_INTERVAL,
|
||||
+ MTK_VENDOR_ATTR_CSI_CTRL_STA_INTERVAL,
|
||||
|
||||
MTK_VENDOR_ATTR_CSI_CTRL_DUMP_NUM,
|
||||
|
||||
MTK_VENDOR_ATTR_CSI_CTRL_DATA,
|
||||
+ MTK_VENDOR_ATTR_CSI_CTRL_DUMP_MAC_FILTER,
|
||||
|
||||
/* keep last */
|
||||
NUM_MTK_VENDOR_ATTRS_CSI_CTRL,
|
||||
@@ -70,6 +72,7 @@
|
||||
MTK_VENDOR_ATTR_CSI_DATA_BW,
|
||||
MTK_VENDOR_ATTR_CSI_DATA_CH_IDX,
|
||||
MTK_VENDOR_ATTR_CSI_DATA_TA,
|
||||
+ MTK_VENDOR_ATTR_CSI_DATA_NUM,
|
||||
MTK_VENDOR_ATTR_CSI_DATA_I,
|
||||
MTK_VENDOR_ATTR_CSI_DATA_Q,
|
||||
MTK_VENDOR_ATTR_CSI_DATA_INFO,
|
||||
@@ -80,7 +83,7 @@
|
||||
MTK_VENDOR_ATTR_CSI_DATA_TX_ANT,
|
||||
MTK_VENDOR_ATTR_CSI_DATA_RX_ANT,
|
||||
MTK_VENDOR_ATTR_CSI_DATA_MODE,
|
||||
- MTK_VENDOR_ATTR_CSI_DATA_H_IDX,
|
||||
+ MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO,
|
||||
|
||||
/* keep last */
|
||||
NUM_MTK_VENDOR_ATTRS_CSI_DATA,
|
||||
@@ -123,7 +126,8 @@
|
||||
csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_TX_ANT].type = NLA_U8;
|
||||
csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT].type = NLA_U8;
|
||||
csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_MODE].type = NLA_U8;
|
||||
- csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_H_IDX].type = NLA_U32;
|
||||
+ csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_NUM].type = NLA_U32;
|
||||
+ csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO].type = NLA_U32;
|
||||
}
|
||||
~MT76APIPrivate()
|
||||
{
|
||||
@@ -172,7 +176,7 @@
|
||||
tb_data[MTK_VENDOR_ATTR_CSI_DATA_Q] &&
|
||||
tb_data[MTK_VENDOR_ATTR_CSI_DATA_INFO] &&
|
||||
tb_data[MTK_VENDOR_ATTR_CSI_DATA_MODE] &&
|
||||
- tb_data[MTK_VENDOR_ATTR_CSI_DATA_H_IDX]))
|
||||
+ tb_data[MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO]))
|
||||
{
|
||||
fprintf(stderr, "Attributes error for CSI data\n");
|
||||
return NL_SKIP;
|
||||
@@ -192,7 +196,7 @@
|
||||
c->rx_idx = nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT]);
|
||||
|
||||
c->ext_info = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_INFO]);
|
||||
- c->h_idx = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_H_IDX]);
|
||||
+ c->h_idx = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO]);
|
||||
|
||||
c->ts = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_TS]);
|
||||
|
||||
@@ -271,8 +275,9 @@
|
||||
|
||||
nla_nest_end(msg, data);
|
||||
|
||||
- if (unl_genl_request(&unl, msg, md_csi_dump_cb, NULL))
|
||||
- fprintf(stderr, "nl80211 call failed: %s\n", strerror(-ret));
|
||||
+ ret = unl_genl_request(&unl, msg, md_csi_dump_cb, NULL);
|
||||
+ if (ret)
|
||||
+ fprintf(stderr, "csi dump: nl80211 call failed: %s\n", strerror(-ret));
|
||||
|
||||
unl_free(&unl);
|
||||
}
|
||||
--- /tmp/motion_detector.cpp.orig 2026-09-19 23:14:58
|
||||
+++ src/motion_detector.cpp 2026-09-19 23:14:58
|
||||
@@ -1,3 +1,4 @@
|
||||
+#include <cstdio>
|
||||
#include "motion_detector.h"
|
||||
#include "wifi_drv_api/mt76_api.h"
|
||||
#include "parsers/parser_mt76.h"
|
||||
@@ -35,6 +36,12 @@
|
||||
std::vector<csi_data *> *list = wifi.motion_detection_dump(ifname.c_str(), pkt_num);
|
||||
oldTime = currTime;
|
||||
|
||||
+ /* Visibility: without this a failing dump is completely silent, which is
|
||||
+ * exactly how the enum/ABI mismatch went unnoticed. */
|
||||
+ fprintf(stderr, "csi: dump returned %zu records (%d ms since last)\n",
|
||||
+ list ? list->size() : (size_t)0, diff);
|
||||
+
|
||||
+ unsigned sentThisInterval = 0;
|
||||
dataMutex.lock();
|
||||
unsigned antIdx = antMonIdx;
|
||||
dataMutex.unlock();
|
||||
@@ -49,6 +56,7 @@
|
||||
if (!packet.empty()) {
|
||||
std::vector<std::vector<double>> singlePacket = { packet };
|
||||
sendCsiDataUdp(singlePacket, i);
|
||||
+ sentThisInterval++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +64,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
+ if (sentThisInterval)
|
||||
+ fprintf(stderr, "csi: sent %u udp datagrams\n", sentThisInterval);
|
||||
+
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(interval));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
Fix for mt76-vendor 'dump csi' SIGSEGV.
|
||||
Base: mediatek/mtk-openwrt-feeds @ a15454c888f4f4144c50e33b5feef4f247c5f78b
|
||||
feed/app/mt76-vendor/src/csi.c
|
||||
|
||||
Root cause: the driver seeds its per-dump counter on the FIRST dumpit
|
||||
callback and only then decrements it, so a request for CSI_DUMP_PER_NUM=3
|
||||
yields FOUR records. Userspace sized csi[] for pkt_num but looped pkt_num/3
|
||||
times, storing up to 4*(pkt_num/3) records with no bounds check, so any dump
|
||||
with >= pkt_num records buffered wrote past the allocation. A dump that found
|
||||
only one record buffered never overran, which is why the single association
|
||||
record succeeded and every data-frame dump crashed.
|
||||
|
||||
Also fixed: the JSON writer passed the full MAX_BUF_SIZE to every snprintf
|
||||
instead of the remaining space, and three attributes were dereferenced without
|
||||
being in the presence check.
|
||||
---
|
||||
--- /tmp/csi.c.orig 2026-09-19 19:46:49
|
||||
+++ src/csi.c 2026-09-19 19:47:16
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
struct csi_data *csi;
|
||||
int csi_idx;
|
||||
+int csi_max; /* entries allocated in csi[] */
|
||||
|
||||
static struct nla_policy csi_ctrl_policy[NUM_MTK_VENDOR_ATTRS_CSI_CTRL] = {
|
||||
[MTK_VENDOR_ATTR_CSI_CTRL_CFG] = { .type = NLA_NESTED },
|
||||
@@ -51,8 +52,13 @@
|
||||
struct nlattr *cur;
|
||||
size_t idx;
|
||||
int rem;
|
||||
- struct csi_data *c = &csi[csi_idx];
|
||||
+ struct csi_data *c;
|
||||
|
||||
+ if (!csi || csi_idx >= csi_max)
|
||||
+ return NL_SKIP;
|
||||
+
|
||||
+ c = &csi[csi_idx];
|
||||
+
|
||||
attr = unl_find_attr(&unl, msg, NL80211_ATTR_VENDOR_DATA);
|
||||
if (!attr) {
|
||||
fprintf(stderr, "Testdata attribute not found\n");
|
||||
@@ -90,15 +96,20 @@
|
||||
c->pri_ch_idx = nla_get_u8(tb_data[MTK_VENDOR_ATTR_CSI_DATA_CH_IDX]);
|
||||
c->rx_mode = nla_get_u8(tb_data[MTK_VENDOR_ATTR_CSI_DATA_MODE]);
|
||||
|
||||
- c->tx_idx = nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_TX_ANT]);
|
||||
- c->rx_idx = nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT]);
|
||||
+ c->tx_idx = tb_data[MTK_VENDOR_ATTR_CSI_DATA_TX_ANT] ?
|
||||
+ nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_TX_ANT]) : 0;
|
||||
+ c->rx_idx = tb_data[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT] ?
|
||||
+ nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT]) : 0;
|
||||
|
||||
c->ext_info = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_INFO]);
|
||||
c->chain_info = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO]);
|
||||
|
||||
c->ts = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_TS]);
|
||||
|
||||
- c->data_num = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_NUM]);
|
||||
+ c->data_num = tb_data[MTK_VENDOR_ATTR_CSI_DATA_NUM] ?
|
||||
+ nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_NUM]) : 0;
|
||||
+ if (c->data_num > CSI_BW160_DATA_COUNT)
|
||||
+ c->data_num = CSI_BW160_DATA_COUNT;
|
||||
|
||||
idx = 0;
|
||||
nla_for_each_nested(cur, tb_data[MTK_VENDOR_ATTR_CSI_DATA_TA], rem) {
|
||||
@@ -125,7 +136,7 @@
|
||||
|
||||
static int mt76_csi_to_json(const char *name)
|
||||
{
|
||||
-#define MAX_BUF_SIZE 6000
|
||||
+#define MAX_BUF_SIZE 16384 /* 2 x 512 samples of "-32768," plus header */
|
||||
FILE *f;
|
||||
int i, ret = -ENOMEM;
|
||||
|
||||
@@ -142,7 +153,7 @@
|
||||
|
||||
for (i = 0; i < csi_idx; i++) {
|
||||
struct csi_data *c = &csi[i];
|
||||
- char *pos, *buf;
|
||||
+ char *pos, *buf, *end;
|
||||
int j;
|
||||
|
||||
buf = malloc(MAX_BUF_SIZE);
|
||||
@@ -150,40 +161,51 @@
|
||||
goto out;
|
||||
|
||||
pos = buf;
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", '[');
|
||||
+ end = buf + MAX_BUF_SIZE;
|
||||
+#define EMIT(...) do { \
|
||||
+ int _n = snprintf(pos, (size_t)(end - pos), __VA_ARGS__); \
|
||||
+ if (_n < 0 || _n >= (int)(end - pos)) { \
|
||||
+ fprintf(stderr, "csi json: record %d truncated\n", i); \
|
||||
+ free(buf); \
|
||||
+ goto out; \
|
||||
+ } \
|
||||
+ pos += _n; \
|
||||
+ } while (0)
|
||||
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->ts);
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "\"%02x%02x%02x%02x%02x%02x\",", c->ta[0], c->ta[1], c->ta[2], c->ta[3], c->ta[4], c->ta[5]);
|
||||
+ EMIT("%c", '[');
|
||||
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->rssi);
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%u,", c->snr);
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%u,", c->data_bw);
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%u,", c->pri_ch_idx);
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%u,", c->rx_mode);
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->tx_idx);
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->rx_idx);
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->chain_info);
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->ext_info);
|
||||
+ EMIT("%d,", c->ts);
|
||||
+ EMIT("\"%02x%02x%02x%02x%02x%02x\",", c->ta[0], c->ta[1], c->ta[2], c->ta[3], c->ta[4], c->ta[5]);
|
||||
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", '[');
|
||||
- for (j = 0; j < c->data_num; j++) {
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%d", c->data_i[j]);
|
||||
- if (j != (c->data_num - 1))
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, ",");
|
||||
- }
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%c,", ']');
|
||||
+ EMIT("%d,", c->rssi);
|
||||
+ EMIT("%u,", c->snr);
|
||||
+ EMIT("%u,", c->data_bw);
|
||||
+ EMIT("%u,", c->pri_ch_idx);
|
||||
+ EMIT("%u,", c->rx_mode);
|
||||
+ EMIT("%d,", c->tx_idx);
|
||||
+ EMIT("%d,", c->rx_idx);
|
||||
+ EMIT("%d,", c->chain_info);
|
||||
+ EMIT("%d,", c->ext_info);
|
||||
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", '[');
|
||||
+ EMIT("%c", '[');
|
||||
for (j = 0; j < c->data_num; j++) {
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%d", c->data_q[j]);
|
||||
+ EMIT("%d", c->data_i[j]);
|
||||
if (j != (c->data_num - 1))
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, ",");
|
||||
+ EMIT(",");
|
||||
}
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", ']');
|
||||
+ EMIT("%c,", ']');
|
||||
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", ']');
|
||||
+ EMIT("%c", '[');
|
||||
+ for (j = 0; j < c->data_num; j++) {
|
||||
+ EMIT("%d", c->data_q[j]);
|
||||
+ if (j != (c->data_num - 1))
|
||||
+ EMIT(",");
|
||||
+ }
|
||||
+ EMIT("%c", ']');
|
||||
+
|
||||
+ EMIT("%c", ']');
|
||||
if (i != csi_idx - 1)
|
||||
- pos += snprintf(pos, MAX_BUF_SIZE, ",");
|
||||
+ EMIT(",");
|
||||
|
||||
if (fwrite(buf, 1, pos - buf, f) != (pos - buf)) {
|
||||
perror("fwrite");
|
||||
@@ -192,6 +214,7 @@
|
||||
}
|
||||
|
||||
free(buf);
|
||||
+#undef EMIT
|
||||
}
|
||||
|
||||
if (fwrite("]", 1, 1, f) != 1) {
|
||||
@@ -221,10 +244,21 @@
|
||||
return -EINVAL;
|
||||
|
||||
#define CSI_DUMP_PER_NUM 3
|
||||
+/* The driver seeds its per-dump counter on the first callback and only then
|
||||
+ * decrements it, so a request for N yields N+1 records. Size for that. */
|
||||
+#define CSI_DUMP_PER_REQ (CSI_DUMP_PER_NUM + 1)
|
||||
csi_idx = 0;
|
||||
- csi = (struct csi_data *)calloc(pkt_num, sizeof(*csi));
|
||||
+ csi_max = pkt_num;
|
||||
+ csi = (struct csi_data *)calloc(csi_max, sizeof(*csi));
|
||||
+ if (!csi) {
|
||||
+ fprintf(stderr, "csi: cannot allocate %d records\n", csi_max);
|
||||
+ return -ENOMEM;
|
||||
+ }
|
||||
|
||||
- for (i = 0; i < pkt_num / CSI_DUMP_PER_NUM; i++) {
|
||||
+ for (i = 0; i < DIV_ROUND_UP(pkt_num, CSI_DUMP_PER_REQ); i++) {
|
||||
+ if (csi_idx >= csi_max)
|
||||
+ break;
|
||||
+
|
||||
if (unl_genl_init(&unl, "nl80211") < 0) {
|
||||
fprintf(stderr, "Failed to connect to nl80211\n");
|
||||
return 2;
|
||||
@@ -113,21 +113,62 @@ def ensure_model(cache_dir: Path) -> Path:
|
||||
return model_path
|
||||
|
||||
|
||||
def post_json(url: str, payload: dict | None = None, timeout: float = 5.0) -> bool:
|
||||
"""POST JSON to a URL. Returns True on success, False on failure."""
|
||||
def load_api_token(token_file: str | None) -> str | None:
|
||||
"""Bearer token for the sensing server: --token-file, else RUVIEW_API_TOKEN."""
|
||||
if token_file:
|
||||
try:
|
||||
return Path(token_file).read_text(encoding="utf-8").strip() or None
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
print(
|
||||
f"ERROR: cannot read --token-file {token_file!r}: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
return os.environ.get("RUVIEW_API_TOKEN", "").strip() or None
|
||||
|
||||
|
||||
def post_json(
|
||||
url: str,
|
||||
payload: dict | None = None,
|
||||
token: str | None = None,
|
||||
timeout: float = 5.0,
|
||||
) -> bool:
|
||||
"""POST JSON to a sensing-server endpoint.
|
||||
|
||||
Returns True only when the server accepted the request. The recording
|
||||
endpoints answer HTTP 200 with ``{"success": false, "error": ...}`` for
|
||||
refusals such as "recording already in progress", so the body is checked.
|
||||
"""
|
||||
data = json.dumps(payload or {}).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return 200 <= resp.status < 300
|
||||
raw = resp.read() or b"{}"
|
||||
except Exception as exc:
|
||||
print(f"WARNING: POST {url} failed: {exc}", file=sys.stderr)
|
||||
return False
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
except ValueError as exc:
|
||||
print(
|
||||
f"WARNING: POST {url} returned a non-JSON body: {exc}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
if not isinstance(body, dict):
|
||||
print(
|
||||
f"WARNING: POST {url} returned unexpected JSON "
|
||||
f"({type(body).__name__}), expected an object",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return False
|
||||
if body.get("success") is False:
|
||||
print(f"WARNING: POST {url} refused: {body.get('error')}", file=sys.stderr)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def draw_skeleton(frame: np.ndarray, keypoints: list[list[float]], w: int, h: int):
|
||||
@@ -185,7 +226,20 @@ def main():
|
||||
"(ADR-152 S2.1.3); adds room-frame keypoint rays + transceiver geometry "
|
||||
"to every record",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token-file",
|
||||
default=None,
|
||||
help="File holding the sensing-server API bearer token "
|
||||
"(default: the RUVIEW_API_TOKEN environment variable)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-no-csi",
|
||||
action="store_true",
|
||||
help="Keep capturing camera keypoints even if the CSI recording cannot "
|
||||
"be started (the output is then unpaired and useless for training)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
api_token = load_api_token(args.token_file)
|
||||
|
||||
if not args.calibration:
|
||||
print(
|
||||
@@ -248,25 +302,36 @@ def main():
|
||||
)
|
||||
landmarker = PoseLandmarker.create_from_options(options)
|
||||
|
||||
# --- Output file ---
|
||||
# --- Start CSI recording (named after the keypoints file so they pair) ---
|
||||
timestamp_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
recording_id = f"gt_{timestamp_str}"
|
||||
recording_url_start = f"{args.server}/api/v1/recording/start"
|
||||
recording_url_stop = f"{args.server}/api/v1/recording/stop"
|
||||
csi_started = post_json(recording_url_start, {"id": recording_id}, api_token)
|
||||
if csi_started:
|
||||
print(f"CSI recording '{recording_id}' started on sensing server.")
|
||||
elif args.allow_no_csi:
|
||||
print(
|
||||
"WARNING: Could not start CSI recording; capturing unpaired "
|
||||
"camera keypoints (--allow-no-csi).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
"ERROR: Could not start CSI recording, so keypoints would have no "
|
||||
"CSI to pair with. Check the server URL and RUVIEW_API_TOKEN / "
|
||||
"--token-file, or pass --allow-no-csi.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
cap.release()
|
||||
landmarker.close()
|
||||
sys.exit(1)
|
||||
|
||||
# --- Output file ---
|
||||
out_path = output_dir / f"keypoints_{timestamp_str}.jsonl"
|
||||
out_file = open(out_path, "w", encoding="utf-8")
|
||||
print(f"Output: {out_path}")
|
||||
|
||||
# --- Start CSI recording ---
|
||||
recording_url_start = f"{args.server}/api/v1/recording/start"
|
||||
recording_url_stop = f"{args.server}/api/v1/recording/stop"
|
||||
csi_started = post_json(recording_url_start)
|
||||
if csi_started:
|
||||
print("CSI recording started on sensing server.")
|
||||
else:
|
||||
print(
|
||||
"WARNING: Could not start CSI recording. "
|
||||
"Camera keypoints will still be captured.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# --- Graceful shutdown ---
|
||||
shutdown_requested = False
|
||||
|
||||
@@ -369,7 +434,7 @@ def main():
|
||||
|
||||
# Stop CSI recording
|
||||
if csi_started:
|
||||
if post_json(recording_url_stop):
|
||||
if post_json(recording_url_stop, token=api_token):
|
||||
print("CSI recording stopped.")
|
||||
else:
|
||||
print("WARNING: Failed to stop CSI recording.", file=sys.stderr)
|
||||
|
||||
@@ -61,9 +61,25 @@ MAGIC_FEATURES = 0xC5110003
|
||||
FEATURE_PKT_FMT = "<IBBHq8f"
|
||||
FEATURE_PKT_SIZE = struct.calcsize(FEATURE_PKT_FMT) # 48 bytes
|
||||
|
||||
# Raw CSI header: magic(4) + node_id(1) + antenna_cfg(1) + n_sub(2) + rssi(1) + noise(1) + channel(1) + reserved(1) + timestamp_ms(4)
|
||||
RAW_CSI_HDR_FMT = "<IBBHbbBxI"
|
||||
RAW_CSI_HDR_SIZE = struct.calcsize(RAW_CSI_HDR_FMT) # 16 bytes
|
||||
# Raw CSI header, as written by csi_collector.c and read by esp32_parser.rs:
|
||||
# magic(4) + node_id(1) + n_antennas(1) + n_subcarriers(2) + freq_mhz(4) +
|
||||
# sequence(4) + rssi(1) + noise_floor(1) + ppdu_type(1) + flags(1), then
|
||||
# n_antennas * n_subcarriers int8 I/Q pairs.
|
||||
RAW_CSI_HDR_FMT = "<IBBHIIbbBB"
|
||||
RAW_CSI_HDR_SIZE = struct.calcsize(RAW_CSI_HDR_FMT) # 20 bytes
|
||||
RAW_CSI_MAX_ANTENNAS = 4
|
||||
RAW_CSI_MAX_SUBCARRIERS = 256
|
||||
|
||||
|
||||
def freq_to_channel(freq_mhz: int) -> int:
|
||||
"""802.11 channel number for a 2.4/5 GHz centre frequency (0 if unknown)."""
|
||||
if freq_mhz == 2484:
|
||||
return 14
|
||||
if 2412 <= freq_mhz <= 2472:
|
||||
return (freq_mhz - 2407) // 5
|
||||
if 5000 <= freq_mhz <= 5900:
|
||||
return (freq_mhz - 5000) // 5
|
||||
return 0
|
||||
|
||||
|
||||
# ── Packet parsing ───────────────────────────────────────────────────────────
|
||||
@@ -114,22 +130,22 @@ def _parse_feature_packet(data: bytes) -> Optional[dict]:
|
||||
def _parse_raw_csi_packet(data: bytes) -> Optional[dict]:
|
||||
"""Parse ADR-018 raw CSI frame with full subcarrier data."""
|
||||
try:
|
||||
magic, node_id, ant_cfg, n_sub, rssi, noise, channel, ts_ms = struct.unpack_from(
|
||||
RAW_CSI_HDR_FMT, data
|
||||
)
|
||||
(magic, node_id, n_ant, n_sub, freq_mhz, seq,
|
||||
rssi, noise, ppdu_type, flags) = struct.unpack_from(RAW_CSI_HDR_FMT, data)
|
||||
except struct.error:
|
||||
return None
|
||||
|
||||
if magic != MAGIC_CSI_RAW:
|
||||
return None
|
||||
|
||||
# Subcarrier data follows header as int16 I/Q pairs
|
||||
payload_offset = RAW_CSI_HDR_SIZE
|
||||
expected_bytes = n_sub * 2 * 2 # n_sub * (I + Q) * int16
|
||||
if len(data) < payload_offset + expected_bytes:
|
||||
if not 1 <= n_ant <= RAW_CSI_MAX_ANTENNAS or n_sub > RAW_CSI_MAX_SUBCARRIERS:
|
||||
return None
|
||||
|
||||
iq_data = struct.unpack_from(f"<{n_sub * 2}h", data, payload_offset)
|
||||
# I/Q data follows the header as int8 pairs, antenna-major
|
||||
n_pairs = n_ant * n_sub
|
||||
if len(data) < RAW_CSI_HDR_SIZE + n_pairs * 2:
|
||||
return None
|
||||
|
||||
iq_data = struct.unpack_from(f"<{n_pairs * 2}b", data, RAW_CSI_HDR_SIZE)
|
||||
# Convert I/Q pairs to amplitude
|
||||
subcarriers = []
|
||||
for i in range(0, len(iq_data), 2):
|
||||
@@ -137,13 +153,18 @@ def _parse_raw_csi_packet(data: bytes) -> Optional[dict]:
|
||||
amplitude = (real ** 2 + imag ** 2) ** 0.5
|
||||
subcarriers.append(amplitude)
|
||||
|
||||
# The header has no timestamp; CsiRecorder stamps the receive time.
|
||||
return {
|
||||
"type": "raw_csi",
|
||||
"node_id": node_id,
|
||||
"antenna_config": ant_cfg,
|
||||
"antenna_config": n_ant,
|
||||
"n_antennas": n_ant,
|
||||
"n_subcarriers": n_sub,
|
||||
"channel": channel,
|
||||
"timestamp": ts_ms / 1000.0,
|
||||
"freq_mhz": freq_mhz,
|
||||
"channel": freq_to_channel(freq_mhz),
|
||||
"seq": seq,
|
||||
"ppdu_type": ppdu_type,
|
||||
"flags": flags,
|
||||
"subcarriers": subcarriers,
|
||||
"rssi": float(rssi),
|
||||
"noise_floor": float(noise),
|
||||
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cross-chain phase stability of MediaTek mt76 vendor CSI (offline, read-only).
|
||||
|
||||
Reproducer for the MEASURED figures in docs/mediatek-router-csi.md. Input is a
|
||||
capture made on the router by repeated `mt76-vendor <iface> dump csi <n> <file>`,
|
||||
one dump (a JSON array of 13/14-element records) per line. Captures hold client
|
||||
transmitter addresses and raw channel samples: keep them out of git. This script
|
||||
never prints an address; transmitters are reported as TX-A, TX-B, ...
|
||||
|
||||
For every HT (rx_mode 2) frame that carries both receive chains of transmit
|
||||
stream 0, it compares:
|
||||
raw per-chain phase arg(H0[k]) between adjacent frames, and
|
||||
cross cross-chain phase arg(H0[k] * conj(H1[k])), per bin and summed
|
||||
coherently over bins.
|
||||
CFO, SFO and the per-packet phase offset are common to both chains of one
|
||||
radio, so they cancel in the cross term. Adjacent means the frames are under
|
||||
100 ms apart. Steps are wrapped to [-pi, pi], so p99 can never exceed pi; read
|
||||
p50 against pi/2 (1.571 rad), which is what uniformly random phase gives.
|
||||
|
||||
usage: python3 mediatek-csi-phase-stability.py <capture.jsonl> [max_MB]
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
|
||||
U32 = 1 << 32
|
||||
MAX_DT = 0.1 # s; consecutive frames closer than this count as adjacent
|
||||
GUARD = set(range(29, 36)) # BW20 bins 29..35: guard tones
|
||||
VALID = np.array([k for k in range(1, 64) if k not in GUARD]) # DC excluded
|
||||
KPHYS = np.where(VALID < 32, VALID, VALID - 64).astype(float)
|
||||
|
||||
|
||||
def iter_lines(path, max_bytes=None):
|
||||
read = 0
|
||||
with open(path, "rb") as fh:
|
||||
for raw in fh:
|
||||
read += len(raw)
|
||||
raw = raw.strip()
|
||||
if raw:
|
||||
try:
|
||||
yield json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
pass # truncated tail line
|
||||
if max_bytes and read >= max_bytes:
|
||||
return
|
||||
|
||||
|
||||
def frames_from_line(recs, txidx):
|
||||
"""Group a dump's records into PPDUs: per transmitter, consecutive records
|
||||
sharing ts, closed on chain_info BIT15 or a repeated (tx, rx) slot."""
|
||||
frames, open_ = [], {}
|
||||
for r in recs:
|
||||
ts = r[0] % U32
|
||||
if r[1] not in txidx:
|
||||
n = len(txidx)
|
||||
txidx[r[1]] = "TX-" + chr(ord("A") + n) if n < 26 else f"TX-{n}"
|
||||
tx = txidx[r[1]]
|
||||
key = (r[7], r[8])
|
||||
h = np.asarray(r[11], np.float64) + 1j * np.asarray(r[12], np.float64)
|
||||
cur = open_.get(tx)
|
||||
if cur is not None and (cur["ts"] != ts or key in cur["H"]):
|
||||
frames.append(cur)
|
||||
cur = None
|
||||
if cur is None:
|
||||
cur = {"tx": tx, "ts": ts, "rx_mode": r[6], "H": {}}
|
||||
open_[tx] = cur
|
||||
cur["H"][key] = h
|
||||
if r[9] & (1 << 15):
|
||||
frames.append(cur)
|
||||
del open_[tx]
|
||||
frames.extend(open_.values())
|
||||
return frames
|
||||
|
||||
|
||||
def load(path, max_bytes=None):
|
||||
"""Per transmitter: unwrapped time (s) and chains (0,0), (0,1) of HT frames.
|
||||
Within a dump the signed ts delta is used; between dumps the unsigned delta
|
||||
mod 2^32, so a gap over 71.6 min between dumps would alias."""
|
||||
txidx, per = {}, {}
|
||||
t_unwrapped, prev_ts = None, None
|
||||
for recs in iter_lines(path, max_bytes):
|
||||
first = True
|
||||
for f in frames_from_line(recs, txidx):
|
||||
ts = f["ts"]
|
||||
if prev_ts is None:
|
||||
t_unwrapped = 0
|
||||
elif first:
|
||||
t_unwrapped += (ts - prev_ts) % U32
|
||||
else:
|
||||
t_unwrapped += ((ts - prev_ts + (U32 >> 1)) % U32) - (U32 >> 1)
|
||||
prev_ts, first = ts, False
|
||||
H = f["H"]
|
||||
if (0, 0) not in H or (0, 1) not in H or f["rx_mode"] != 2:
|
||||
continue
|
||||
d = per.setdefault(f["tx"], {"t": [], "h0": [], "h1": []})
|
||||
d["t"].append(t_unwrapped / 1e6)
|
||||
d["h0"].append(H[(0, 0)][VALID].astype(np.complex64))
|
||||
d["h1"].append(H[(0, 1)][VALID].astype(np.complex64))
|
||||
out = {}
|
||||
for tx, d in per.items():
|
||||
t = np.asarray(d["t"])
|
||||
srt = np.argsort(t, kind="stable")
|
||||
out[tx] = {"t": t[srt], "h0": np.vstack(d["h0"])[srt], "h1": np.vstack(d["h1"])[srt]}
|
||||
return out
|
||||
|
||||
|
||||
def wrap(x):
|
||||
return (x + np.pi) % (2 * np.pi) - np.pi
|
||||
|
||||
|
||||
def sanitize(phase_rows):
|
||||
"""Remove per-frame linear phase (offset + slope over subcarrier index)."""
|
||||
order = np.argsort(KPHYS)
|
||||
k = KPHYS[order]
|
||||
u = np.unwrap(phase_rows[:, order], axis=1)
|
||||
A = np.vstack([k, np.ones_like(k)]).T
|
||||
coef, *_ = np.linalg.lstsq(A, u.T, rcond=None)
|
||||
res = u - (A @ coef).T
|
||||
out = np.empty_like(res)
|
||||
out[:, order] = res
|
||||
return out
|
||||
|
||||
|
||||
def step_stats(phase, t):
|
||||
dt = np.diff(t)
|
||||
ok = (dt > 0) & (dt < MAX_DT)
|
||||
st = np.abs(wrap(np.diff(phase, axis=0)))[ok].ravel()
|
||||
if st.size == 0:
|
||||
return None
|
||||
p = np.percentile(st, [50, 90, 99])
|
||||
return {"n": int(st.size), "p50": round(float(p[0]), 3), "p90": round(float(p[1]), 3),
|
||||
"p99": round(float(p[2]), 3), "frac_gt_pi_2": round(float((st > np.pi / 2).mean()), 4)}
|
||||
|
||||
|
||||
def analyse(d):
|
||||
t, h0, h1 = d["t"], d["h0"].astype(complex), d["h1"].astype(complex)
|
||||
raw0 = np.angle(h0)
|
||||
x = h0 * np.conj(h1)
|
||||
dt = np.diff(t)
|
||||
adj = dt[(dt > 0) & (dt < MAX_DT)]
|
||||
return {
|
||||
"frames": int(len(t)),
|
||||
"span_s": round(float(t[-1] - t[0]), 1),
|
||||
"adjacent_pairs": int(adj.size),
|
||||
"adjacent_dt_ms_p50": round(float(np.median(adj) * 1e3), 2) if adj.size else None,
|
||||
"steps": {
|
||||
"raw_chain0_per_bin": step_stats(raw0, t),
|
||||
"sanitized_chain0_per_bin": step_stats(sanitize(raw0), t),
|
||||
"cross_per_bin": step_stats(np.angle(x), t),
|
||||
"cross_coherent_sum": step_stats(np.angle(x.sum(1)), t),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(__doc__)
|
||||
path = sys.argv[1]
|
||||
mb = float(sys.argv[2]) if len(sys.argv) > 2 else None
|
||||
data = load(path, int(mb * 1e6) if mb else None)
|
||||
out = {"sampled_MB": mb or "all", "tx": {}}
|
||||
for tx, d in sorted(data.items()):
|
||||
if len(d["t"]) >= 50:
|
||||
out["tx"][tx] = analyse(d)
|
||||
print(json.dumps(out, indent=1))
|
||||
+50
-14
@@ -12,17 +12,29 @@
|
||||
#
|
||||
# Exit code 0 only if every NON-gated claim passes. Gated claims never fail the
|
||||
# run; they print exactly what they need (libtorch, a GPU, a dataset) so you can
|
||||
# reproduce them yourself.
|
||||
# reproduce them yourself. If a hard gate (workspace tests, Python proof) cannot
|
||||
# run here, the result is INCOMPLETE and the exit code is 2.
|
||||
#
|
||||
# Before running: stop any local sensing server on UDP 5005. Older versions of
|
||||
# v2/crates/wifi-densepose-sensing-server/tests/multi_node_test.rs send
|
||||
# synthetic frames to 127.0.0.1:5005 (#2086).
|
||||
#
|
||||
# The Python proof uses $PYTHON if set, else python3, else python. It needs the
|
||||
# pinned deps in archive/v1/requirements-lock.txt, e.g.:
|
||||
# python3 -m venv .venv && .venv/bin/pip install -r archive/v1/requirements-lock.txt
|
||||
# PYTHON=.venv/bin/python bash scripts/prove.sh
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
FULL=0; [ "${1:-}" = "--full" ] && FULL=1
|
||||
|
||||
pass=0; fail=0; skip=0
|
||||
pass=0; fail=0; skip=0; incomplete=0
|
||||
PASS(){ echo " [PASS] $1"; pass=$((pass+1)); }
|
||||
FAIL(){ echo " [FAIL] $1"; fail=$((fail+1)); }
|
||||
SKIP(){ echo " [CLAIMED — not reproduced here] $1"; skip=$((skip+1)); }
|
||||
# A hard gate that could not run on this machine. Never reported as PASS.
|
||||
NOTRUN(){ echo " [NOT RUN] $1"; incomplete=$((incomplete+1)); }
|
||||
hr(){ echo "------------------------------------------------------------"; }
|
||||
|
||||
echo "RuView / wifi-densepose — PROOF harness"
|
||||
@@ -32,6 +44,7 @@ hr
|
||||
|
||||
# ── 1. HARD GATE: Rust workspace tests (no native libs required) ────────────
|
||||
echo "[1] Rust workspace tests (cargo test --workspace --no-default-features)"
|
||||
echo " note: stop any local sensing server on UDP 5005 first (#2086)"
|
||||
if command -v cargo >/dev/null 2>&1; then
|
||||
if ( cd v2 && cargo test --workspace --no-default-features ) > /tmp/prove_ws.log 2>&1; then
|
||||
n=$(grep -oE "result: ok\. [0-9]+ passed" /tmp/prove_ws.log | grep -oE "[0-9]+" | awk '{s+=$1} END {print s}')
|
||||
@@ -40,20 +53,40 @@ if command -v cargo >/dev/null 2>&1; then
|
||||
FAIL "workspace tests — see /tmp/prove_ws.log (grep 'test result: FAILED')"
|
||||
fi
|
||||
else
|
||||
SKIP "cargo not installed — install Rust to run the workspace gate"
|
||||
NOTRUN "cargo not installed — install Rust to run the workspace gate"
|
||||
fi
|
||||
hr
|
||||
|
||||
# ── 2. HARD GATE: deterministic Python pipeline proof (SHA-256) ─────────────
|
||||
echo "[2] Deterministic CSI pipeline proof (archive/v1/data/proof/verify.py)"
|
||||
if command -v python >/dev/null 2>&1; then
|
||||
if python archive/v1/data/proof/verify.py > /tmp/prove_py.log 2>&1 && grep -q "VERDICT: PASS" /tmp/prove_py.log; then
|
||||
PY="${PYTHON:-}"
|
||||
if [ -z "$PY" ]; then
|
||||
if command -v python3 >/dev/null 2>&1; then PY=python3
|
||||
elif command -v python >/dev/null 2>&1; then PY=python
|
||||
fi
|
||||
fi
|
||||
LOCK=archive/v1/requirements-lock.txt
|
||||
if [ -z "$PY" ] || ! command -v "$PY" >/dev/null 2>&1; then
|
||||
NOTRUN "no Python found (tried PYTHON='${PYTHON:-}', python3, python) — install Python 3.10+ to run the deterministic proof"
|
||||
elif ! "$PY" -c "import numpy, scipy" > /tmp/prove_py_deps.log 2>&1; then
|
||||
NOTRUN "$PY is missing the proof deps ($(tail -1 /tmp/prove_py_deps.log)). Install them: $PY -m venv .venv && .venv/bin/pip install -r $LOCK, then re-run with PYTHON=.venv/bin/python"
|
||||
else
|
||||
echo " python: $PY ($("$PY" -c 'import sys; print(sys.version.split()[0])'))"
|
||||
# The published hash was generated with the versions pinned in the lockfile;
|
||||
# other versions can change floating-point results.
|
||||
for pkg in numpy scipy; do
|
||||
want=$(grep -E "^$pkg==" "$LOCK" | cut -d= -f3)
|
||||
have=$("$PY" -c "import $pkg; print($pkg.__version__)")
|
||||
[ "$want" = "$have" ] || echo " warning: $pkg $have installed, $LOCK pins $want — a hash mismatch may be a version mismatch"
|
||||
done
|
||||
# Single-threaded BLAS/FFT, as in CI: threaded reduction order is not deterministic.
|
||||
if OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 \
|
||||
VECLIB_MAXIMUM_THREADS=1 NUMEXPR_NUM_THREADS=1 \
|
||||
"$PY" archive/v1/data/proof/verify.py > /tmp/prove_py.log 2>&1 && grep -q "VERDICT: PASS" /tmp/prove_py.log; then
|
||||
PASS "Python proof VERDICT: PASS (bit-exact SHA-256 of reference features)"
|
||||
else
|
||||
FAIL "Python proof — see /tmp/prove_py.log"
|
||||
fi
|
||||
else
|
||||
SKIP "python not installed — install Python 3.10+ to run the deterministic proof"
|
||||
fi
|
||||
hr
|
||||
|
||||
@@ -61,7 +94,7 @@ hr
|
||||
# Format: claim_test <crate> <test-name-filter> <human claim> [extra cargo args]
|
||||
claim_test(){
|
||||
local crate="$1" filt="$2" desc="$3"; shift 3
|
||||
if ! command -v cargo >/dev/null 2>&1; then SKIP "$desc (cargo missing)"; return; fi
|
||||
if ! command -v cargo >/dev/null 2>&1; then NOTRUN "$desc (cargo missing)"; return; fi
|
||||
if ( cd v2 && cargo test -p "$crate" "$@" "$filt" ) > /tmp/prove_claim.log 2>&1 \
|
||||
&& grep -qE "test result: ok\. [1-9]" /tmp/prove_claim.log; then
|
||||
PASS "$desc"
|
||||
@@ -79,7 +112,7 @@ claim_test(){
|
||||
# Variant for workspace-excluded crates (e.g. wasm-edge): run from the crate dir.
|
||||
claim_test_indir(){
|
||||
local dir="$1" filt="$2" desc="$3"; shift 3
|
||||
if ! command -v cargo >/dev/null 2>&1; then SKIP "$desc (cargo missing)"; return; fi
|
||||
if ! command -v cargo >/dev/null 2>&1; then NOTRUN "$desc (cargo missing)"; return; fi
|
||||
if ( cd "$dir" && cargo test "$@" "$filt" ) > /tmp/prove_claim.log 2>&1 \
|
||||
&& grep -qE "test result: ok\. [1-9]" /tmp/prove_claim.log; then
|
||||
PASS "$desc"
|
||||
@@ -137,11 +170,14 @@ fi
|
||||
hr
|
||||
|
||||
# ── verdict ──────────────────────────────────────────────────────────────────
|
||||
echo "VERDICT: $pass verified · $fail failed · $skip claimed-not-reproduced-here"
|
||||
if [ "$fail" -eq 0 ]; then
|
||||
echo "RESULT: PASS — every reproducible claim verified on this machine."
|
||||
exit 0
|
||||
else
|
||||
echo "VERDICT: $pass verified · $fail failed · $incomplete not run · $skip claimed-not-reproduced-here"
|
||||
if [ "$fail" -ne 0 ]; then
|
||||
echo "RESULT: FAIL — $fail claim(s) did not reproduce. See the /tmp/prove_*.log files."
|
||||
exit 1
|
||||
elif [ "$incomplete" -ne 0 ]; then
|
||||
echo "RESULT: INCOMPLETE — $incomplete hard-gate check(s) could not run on this machine (see [NOT RUN] above)."
|
||||
exit 2
|
||||
else
|
||||
echo "RESULT: PASS — every reproducible claim verified on this machine."
|
||||
exit 0
|
||||
fi
|
||||
+41
-13
@@ -18,24 +18,45 @@ import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
# ADR-018 header, as written by csi_collector.c and read by esp32_parser.rs:
|
||||
# magic u32 @0, node_id @4, n_antennas @5, n_subcarriers u16 @6, freq_mhz u32 @8,
|
||||
# sequence u32 @12, rssi i8 @16, noise_floor i8 @17, ppdu_type @18, flags @19,
|
||||
# then n_antennas * n_subcarriers int8 I/Q pairs from @20.
|
||||
CSI_MAGIC = 0xC5110001
|
||||
CSI_HDR_FMT = "<IBBHIIbbBB"
|
||||
CSI_HDR_SIZE = struct.calcsize(CSI_HDR_FMT) # 20 bytes
|
||||
|
||||
|
||||
def freq_to_channel(freq_mhz):
|
||||
"""802.11 channel number for a 2.4/5 GHz centre frequency (0 if unknown)."""
|
||||
if freq_mhz == 2484:
|
||||
return 14
|
||||
if 2412 <= freq_mhz <= 2472:
|
||||
return (freq_mhz - 2407) // 5
|
||||
if 5000 <= freq_mhz <= 5900:
|
||||
return (freq_mhz - 5000) // 5
|
||||
return 0
|
||||
|
||||
|
||||
def parse_csi_packet(data):
|
||||
"""Parse ADR-018 binary CSI packet into dict."""
|
||||
if len(data) < 8:
|
||||
"""Parse ADR-018 binary CSI packet into dict, or None if it is not one."""
|
||||
if len(data) < CSI_HDR_SIZE:
|
||||
return None
|
||||
|
||||
# ADR-018 header: [magic(2), len(2), node_id(1), seq(1), rssi(1), channel(1), iq_data...]
|
||||
# Simplified: extract what we can from the raw packet
|
||||
node_id = data[4] if len(data) > 4 else 0
|
||||
rssi = struct.unpack('b', bytes([data[6]]))[0] if len(data) > 6 else 0
|
||||
channel = data[7] if len(data) > 7 else 0
|
||||
(magic, node_id, n_antennas, n_sub, freq_mhz, seq,
|
||||
rssi, noise_floor, ppdu_type, flags) = struct.unpack_from(CSI_HDR_FMT, data)
|
||||
# Same antenna/subcarrier limits as esp32_parser.rs
|
||||
if magic != CSI_MAGIC or not 1 <= n_antennas <= 4 or n_sub > 256:
|
||||
return None
|
||||
|
||||
# IQ data starts at offset 8
|
||||
iq_data = data[8:] if len(data) > 8 else b''
|
||||
n_subcarriers = len(iq_data) // 2 # I,Q pairs
|
||||
n_pairs = n_antennas * n_sub
|
||||
iq_data = data[CSI_HDR_SIZE:CSI_HDR_SIZE + n_pairs * 2]
|
||||
if len(iq_data) < n_pairs * 2:
|
||||
return None
|
||||
|
||||
# Compute amplitudes
|
||||
amplitudes = []
|
||||
for i in range(0, len(iq_data) - 1, 2):
|
||||
for i in range(0, len(iq_data), 2):
|
||||
I = struct.unpack('b', bytes([iq_data[i]]))[0]
|
||||
Q = struct.unpack('b', bytes([iq_data[i + 1]]))[0]
|
||||
amplitudes.append(round((I * I + Q * Q) ** 0.5, 2))
|
||||
@@ -46,9 +67,16 @@ def parse_csi_packet(data):
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z"),
|
||||
"ts_ns": time.time_ns(),
|
||||
"node_id": node_id,
|
||||
"seq": seq,
|
||||
"rssi": rssi,
|
||||
"channel": channel,
|
||||
"subcarriers": n_subcarriers,
|
||||
"noise_floor": noise_floor,
|
||||
"freq_mhz": freq_mhz,
|
||||
"channel": freq_to_channel(freq_mhz),
|
||||
"ppdu_type": ppdu_type,
|
||||
"flags": flags,
|
||||
"n_antennas": n_antennas,
|
||||
"n_subcarriers": n_sub,
|
||||
"subcarriers": n_pairs, # I/Q pairs in iq_hex (n_antennas * n_subcarriers)
|
||||
"amplitudes": amplitudes,
|
||||
"iq_hex": iq_data.hex(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ADR-018 header parsing in the UDP capture scripts.
|
||||
|
||||
Builds real ADR-018 frames with the layout used by
|
||||
v2/crates/wifi-densepose-hardware/src/esp32_parser.rs (build_test_frame) and
|
||||
firmware/esp32-csi-node/main/csi_collector.c, then checks the fields that
|
||||
scripts/collect-training-data.py and scripts/record-csi-udp.py decode.
|
||||
|
||||
Run: python -m pytest scripts/tests/test_capture_adr018.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
|
||||
ADR018_MAGIC = 0xC5110001
|
||||
FEATURE_MAGIC = 0xC5110003
|
||||
|
||||
|
||||
def load_script(filename: str):
|
||||
"""Import a hyphenated script from scripts/ as a module."""
|
||||
name = filename.replace("-", "_").removesuffix(".py")
|
||||
spec = importlib.util.spec_from_file_location(name, SCRIPTS_DIR / filename)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
collect = load_script("collect-training-data.py")
|
||||
record = load_script("record-csi-udp.py")
|
||||
|
||||
|
||||
def build_frame(
|
||||
node_id: int,
|
||||
n_antennas: int,
|
||||
pairs: list[tuple[int, int]],
|
||||
*,
|
||||
freq_mhz: int = 2437,
|
||||
seq: int = 1,
|
||||
rssi: int = -50,
|
||||
noise: int = -95,
|
||||
ppdu: int = 0,
|
||||
flags: int = 0,
|
||||
magic: int = ADR018_MAGIC,
|
||||
) -> bytes:
|
||||
"""ADR-018 frame, same byte layout as esp32_parser.rs build_test_frame_with_he.
|
||||
|
||||
0 magic u32 | 4 node_id | 5 n_antennas | 6 n_subcarriers u16 | 8 freq_mhz u32
|
||||
| 12 sequence u32 | 16 rssi i8 | 17 noise i8 | 18 ppdu | 19 flags | 20 I/Q i8 pairs
|
||||
"""
|
||||
n_sub = len(pairs) // n_antennas if n_antennas else len(pairs)
|
||||
buf = bytearray()
|
||||
buf += struct.pack("<I", magic)
|
||||
buf.append(node_id)
|
||||
buf.append(n_antennas)
|
||||
buf += struct.pack("<H", n_sub)
|
||||
buf += struct.pack("<I", freq_mhz)
|
||||
buf += struct.pack("<I", seq)
|
||||
buf += struct.pack("<b", rssi)
|
||||
buf += struct.pack("<b", noise)
|
||||
buf.append(ppdu)
|
||||
buf.append(flags)
|
||||
for i, q in pairs:
|
||||
buf += struct.pack("<bb", i, q)
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
# 64 subcarriers, first pair (3, 4) -> amplitude 5, last pair (-6, 8) -> 10.
|
||||
PAIRS_64 = [(3, 4)] + [(1, -1)] * 62 + [(-6, 8)]
|
||||
# Sequence chosen so bytes 14/15 (seq high bytes) differ from rssi/noise.
|
||||
SEQ = 0x01020304
|
||||
|
||||
|
||||
def test_builder_matches_rust_header_size():
|
||||
frame = build_frame(1, 1, [(0, 0)] * 64)
|
||||
assert len(frame) == 20 + 64 * 2 # 148 bytes, the HT20 frame size in #1005
|
||||
|
||||
|
||||
# ── collect-training-data.py ────────────────────────────────────────────────
|
||||
|
||||
class TestCollectTrainingData:
|
||||
def test_single_antenna_header_fields(self):
|
||||
frame = build_frame(7, 1, PAIRS_64, seq=SEQ, rssi=-50, noise=-95,
|
||||
ppdu=1, flags=0x01)
|
||||
out = collect.parse_packet(frame)
|
||||
assert out is not None
|
||||
assert out["type"] == "raw_csi"
|
||||
assert out["node_id"] == 7
|
||||
assert out["antenna_config"] == 1
|
||||
assert out["n_antennas"] == 1
|
||||
assert out["n_subcarriers"] == 64
|
||||
assert out["freq_mhz"] == 2437
|
||||
assert out["channel"] == 6
|
||||
assert out["seq"] == SEQ
|
||||
assert out["rssi"] == -50.0
|
||||
assert out["noise_floor"] == -95.0
|
||||
assert out["ppdu_type"] == 1
|
||||
assert out["flags"] == 0x01
|
||||
|
||||
def test_single_antenna_amplitudes_from_int8_pairs(self):
|
||||
out = collect.parse_packet(build_frame(7, 1, PAIRS_64))
|
||||
amps = out["subcarriers"]
|
||||
assert len(amps) == 64
|
||||
assert amps[0] == pytest.approx(5.0)
|
||||
assert amps[1] == pytest.approx(math.sqrt(2))
|
||||
assert amps[-1] == pytest.approx(10.0)
|
||||
|
||||
def test_multi_antenna_reads_all_pairs(self):
|
||||
pairs = [(3, 4)] * 4 + [(6, 8)] * 4 # antenna 0, then antenna 1
|
||||
out = collect.parse_packet(build_frame(2, 2, pairs))
|
||||
assert out["n_antennas"] == 2
|
||||
assert out["n_subcarriers"] == 4
|
||||
assert out["subcarriers"] == pytest.approx([5.0] * 4 + [10.0] * 4)
|
||||
|
||||
def test_5ghz_channel(self):
|
||||
out = collect.parse_packet(build_frame(1, 1, PAIRS_64, freq_mhz=5180))
|
||||
assert out["freq_mhz"] == 5180
|
||||
assert out["channel"] == 36
|
||||
|
||||
def test_no_header_timestamp_so_recorder_uses_receive_time(self, tmp_path):
|
||||
out = collect.parse_packet(build_frame(3, 1, PAIRS_64, seq=SEQ))
|
||||
assert "timestamp" not in out
|
||||
rec = collect.CsiRecorder(str(tmp_path), "t", "walking")
|
||||
rec.open()
|
||||
before = time.time()
|
||||
rec.write_frame(out)
|
||||
rec.close()
|
||||
line = json.loads(rec.file_path.read_text().splitlines()[0])
|
||||
assert before - 1 <= line["timestamp"] <= time.time() + 1
|
||||
assert line["rssi"] == -50.0
|
||||
assert line["features"]["seq"] == SEQ
|
||||
assert len(line["subcarriers"]) == 64
|
||||
|
||||
def test_trailing_bytes_ignored(self):
|
||||
out = collect.parse_packet(build_frame(1, 1, PAIRS_64) + b"\xff" * 8)
|
||||
assert len(out["subcarriers"]) == 64
|
||||
|
||||
def test_truncated_iq_rejected(self):
|
||||
assert collect.parse_packet(build_frame(1, 1, PAIRS_64)[:-1]) is None
|
||||
|
||||
def test_short_header_rejected(self):
|
||||
assert collect.parse_packet(build_frame(1, 1, PAIRS_64)[:19]) is None
|
||||
|
||||
def test_zero_antennas_rejected(self):
|
||||
frame = bytearray(build_frame(1, 1, PAIRS_64))
|
||||
frame[5] = 0
|
||||
assert collect.parse_packet(bytes(frame)) is None
|
||||
|
||||
def test_feature_packet_still_parses(self):
|
||||
feats = [0.1 * i for i in range(8)]
|
||||
pkt = struct.pack("<IBBHq8f", FEATURE_MAGIC, 4, 0, 9, 123456, *feats)
|
||||
out = collect.parse_packet(pkt)
|
||||
assert out["type"] == "features"
|
||||
assert out["node_id"] == 4
|
||||
assert out["seq"] == 9
|
||||
assert out["features"] == pytest.approx(feats)
|
||||
|
||||
|
||||
# ── record-csi-udp.py ───────────────────────────────────────────────────────
|
||||
|
||||
class TestRecordCsiUdp:
|
||||
def test_single_antenna_header_fields(self):
|
||||
frame = build_frame(7, 1, PAIRS_64, seq=SEQ, rssi=-50, noise=-95)
|
||||
out = record.parse_csi_packet(frame)
|
||||
assert out is not None
|
||||
assert out["node_id"] == 7
|
||||
assert out["rssi"] == -50
|
||||
assert out["noise_floor"] == -95
|
||||
assert out["freq_mhz"] == 2437
|
||||
assert out["channel"] == 6
|
||||
assert out["seq"] == SEQ
|
||||
assert out["n_antennas"] == 1
|
||||
assert out["n_subcarriers"] == 64
|
||||
|
||||
def test_iq_payload_starts_at_offset_20(self):
|
||||
frame = build_frame(7, 1, PAIRS_64)
|
||||
out = record.parse_csi_packet(frame)
|
||||
assert out["subcarriers"] == 64
|
||||
assert out["iq_hex"] == frame[20:].hex()
|
||||
assert len(out["amplitudes"]) == 64
|
||||
assert out["amplitudes"][0] == 5.0
|
||||
assert out["amplitudes"][-1] == 10.0
|
||||
|
||||
def test_multi_antenna(self):
|
||||
pairs = [(3, 4)] * 4 + [(6, 8)] * 4
|
||||
out = record.parse_csi_packet(build_frame(2, 2, pairs))
|
||||
assert out["n_antennas"] == 2
|
||||
assert out["n_subcarriers"] == 4
|
||||
assert out["subcarriers"] == 8
|
||||
assert out["amplitudes"] == [5.0] * 4 + [10.0] * 4
|
||||
|
||||
def test_trailing_bytes_not_treated_as_iq(self):
|
||||
frame = build_frame(1, 1, PAIRS_64)
|
||||
out = record.parse_csi_packet(frame + b"\x7f" * 6)
|
||||
assert out["iq_hex"] == frame[20:].hex()
|
||||
|
||||
def test_other_magic_rejected(self):
|
||||
feats = [0.0] * 8
|
||||
pkt = struct.pack("<IBBHq8f", FEATURE_MAGIC, 4, 0, 9, 123456, *feats)
|
||||
assert record.parse_csi_packet(pkt) is None
|
||||
|
||||
def test_truncated_iq_rejected(self):
|
||||
assert record.parse_csi_packet(build_frame(1, 1, PAIRS_64)[:-1]) is None
|
||||
|
||||
def test_short_header_rejected(self):
|
||||
assert record.parse_csi_packet(build_frame(1, 1, PAIRS_64)[:19]) is None
|
||||
|
||||
def test_timestamps_present(self):
|
||||
out = record.parse_csi_packet(build_frame(1, 1, PAIRS_64))
|
||||
assert out["timestamp"].endswith("Z")
|
||||
assert isinstance(out["ts_ns"], int)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Error-path tests for collect-ground-truth's HTTP / token helpers.
|
||||
|
||||
Uses a local stub HTTP server; no camera, no MediaPipe model.
|
||||
|
||||
Run: python -m pytest scripts/tests/test_collect_ground_truth_http.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "collect-ground-truth.py"
|
||||
|
||||
|
||||
def _load_module():
|
||||
try:
|
||||
import mediapipe # noqa: F401
|
||||
except Exception:
|
||||
mp = types.ModuleType("mediapipe")
|
||||
tasks = types.ModuleType("mediapipe.tasks")
|
||||
py = types.ModuleType("mediapipe.tasks.python")
|
||||
vision = types.ModuleType("mediapipe.tasks.python.vision")
|
||||
py.BaseOptions = object
|
||||
vision.PoseLandmarker = vision.PoseLandmarkerOptions = vision.RunningMode = object
|
||||
sys.modules.update({
|
||||
"mediapipe": mp,
|
||||
"mediapipe.tasks": tasks,
|
||||
"mediapipe.tasks.python": py,
|
||||
"mediapipe.tasks.python.vision": vision,
|
||||
})
|
||||
spec = importlib.util.spec_from_file_location("collect_ground_truth", SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
cgt = _load_module()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stub_server():
|
||||
"""Yield a function that serves one fixed (status, body) and returns its URL."""
|
||||
servers = []
|
||||
|
||||
def start(body: bytes, status: int = 200) -> str:
|
||||
class H(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
srv = HTTPServer(("127.0.0.1", 0), H)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
servers.append(srv)
|
||||
return f"http://127.0.0.1:{srv.server_port}/rec"
|
||||
|
||||
yield start
|
||||
for s in servers:
|
||||
s.shutdown()
|
||||
s.server_close()
|
||||
|
||||
|
||||
def test_post_json_success(stub_server):
|
||||
assert cgt.post_json(stub_server(b'{"success": true}')) is True
|
||||
|
||||
|
||||
def test_post_json_refusal(stub_server, capsys):
|
||||
url = stub_server(b'{"success": false, "error": "busy"}')
|
||||
assert cgt.post_json(url) is False
|
||||
assert "refused" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_post_json_non_json_200_body(stub_server, capsys):
|
||||
assert cgt.post_json(stub_server(b"<html>proxy login</html>")) is False
|
||||
assert "non-JSON" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_post_json_non_object_json(stub_server, capsys):
|
||||
assert cgt.post_json(stub_server(b"[1, 2]")) is False
|
||||
assert "expected an object" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_post_json_connection_refused(capsys):
|
||||
assert cgt.post_json("http://127.0.0.1:1/rec", timeout=1.0) is False
|
||||
assert "failed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_missing_token_file_exits_nonzero(tmp_path, capsys):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cgt.load_api_token(str(tmp_path / "nope.token"))
|
||||
assert exc.value.code not in (0, None)
|
||||
assert "--token-file" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_unreadable_token_file_exits_nonzero(tmp_path, capsys):
|
||||
f = tmp_path / "bad.token"
|
||||
f.write_bytes(b"\xff\xfe\x00bad")
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cgt.load_api_token(str(f))
|
||||
assert exc.value.code not in (0, None)
|
||||
|
||||
|
||||
def test_token_file_ok(tmp_path):
|
||||
f = tmp_path / "t"
|
||||
f.write_text(" secret \n", encoding="utf-8")
|
||||
assert cgt.load_api_token(str(f)) == "secret"
|
||||
+2
-1
@@ -82,13 +82,14 @@ ui/
|
||||
|
||||
## Data Sources
|
||||
|
||||
The sensing service (`sensing.service.js`) supports three connection states:
|
||||
The sensing service (`sensing.service.js`) reports these connection states:
|
||||
|
||||
| State | Banner Color | Description |
|
||||
|-------|-------------|-------------|
|
||||
| **LIVE - ESP32** | Green | Connected to the Rust sensing server receiving real CSI data |
|
||||
| **RECONNECTING** | Yellow (pulsing) | WebSocket disconnected, retrying (up to 20 attempts) |
|
||||
| **SIMULATED DATA** | Red | Fallback to client-side simulation after 5+ failed reconnects |
|
||||
| **API TOKEN REQUIRED** | Red | The server has `RUVIEW_API_TOKEN` set and this browser has no valid token. Retries stop; click the banner or open Settings → API Access |
|
||||
|
||||
Simulated frames include a `_simulated: true` marker so code can detect synthetic data.
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { poseService } from '../services/pose.service.js';
|
||||
import { sensingService } from '../services/sensing.service.js';
|
||||
|
||||
export class DashboardTab {
|
||||
static STREAM_HEALTH_REFRESH_DELAY_MS = 500;
|
||||
|
||||
constructor(containerElement) {
|
||||
this.container = containerElement;
|
||||
this.statsElements = {};
|
||||
@@ -65,8 +67,9 @@ export class DashboardTab {
|
||||
});
|
||||
|
||||
// Subscribe to sensing service state changes for data source indicator
|
||||
this._sensingUnsub = sensingService.onStateChange(() => {
|
||||
this._sensingUnsub = sensingService.onStateChange((state) => {
|
||||
this.updateDataSourceIndicator();
|
||||
this._refreshStreamHealthOn(state);
|
||||
});
|
||||
// Also update on data — catches source changes mid-stream
|
||||
this._sensingDataUnsub = sensingService.onData(() => {
|
||||
@@ -84,6 +87,23 @@ export class DashboardTab {
|
||||
healthService.startHealthMonitoring(30000);
|
||||
}
|
||||
|
||||
// #2087: the Streaming card comes from /health, polled every 30 s. The first
|
||||
// poll runs before this page's own socket has connected (with auth on, the
|
||||
// ticket round-trip makes that gap wider), so the card read "IDLE, 0
|
||||
// client(s)" for up to 30 s while data was streaming. Re-read health when
|
||||
// the socket opens or drops. The short delay lets the server register the
|
||||
// socket before it is counted.
|
||||
_refreshStreamHealthOn(state) {
|
||||
if (state === this._lastSensingState) return;
|
||||
const wasConnected = this._lastSensingState === 'connected';
|
||||
this._lastSensingState = state;
|
||||
if (state !== 'connected' && !wasConnected) return;
|
||||
clearTimeout(this._streamHealthTimer);
|
||||
this._streamHealthTimer = setTimeout(() => {
|
||||
healthService.getSystemHealth().catch(() => { /* next poll retries */ });
|
||||
}, DashboardTab.STREAM_HEALTH_REFRESH_DELAY_MS);
|
||||
}
|
||||
|
||||
// Update the data source indicator on the dashboard
|
||||
updateDataSourceIndicator() {
|
||||
const el = this.container.querySelector('#dashboard-datasource');
|
||||
@@ -97,6 +117,7 @@ export class DashboardTab {
|
||||
'reconnecting': { text: 'RECONNECTING', status: 'degraded', msg: 'Attempting to connect...' },
|
||||
'unreachable': { text: 'NO DATA', status: 'unhealthy', msg: 'Server unreachable — readings below are stale' },
|
||||
'simulated': { text: 'INVENTED', status: 'unhealthy', msg: 'Browser-generated data, not measured' },
|
||||
'auth-required': { text: 'TOKEN REQUIRED', status: 'unhealthy', msg: 'Paste the server API token in Settings \u2192 API Access' },
|
||||
};
|
||||
const cfg = config[ds] || config['reconnecting'];
|
||||
el.className = `component-status status-${cfg.status}`;
|
||||
@@ -427,6 +448,7 @@ export class DashboardTab {
|
||||
this.healthSubscription();
|
||||
}
|
||||
if (this._sensingUnsub) this._sensingUnsub();
|
||||
clearTimeout(this._streamHealthTimer);
|
||||
if (this._sensingDataUnsub) this._sensingDataUnsub();
|
||||
|
||||
if (this.statsInterval) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Executed regression test for #2087: the dashboard Streaming card read
|
||||
// "IDLE, 0 client(s)" while the page's own socket was streaming, because the
|
||||
// card is fed by a 30 s /health poll whose first read lands before the socket
|
||||
// connects.
|
||||
//
|
||||
// Run: node --test ui/components/DashboardTab.test.mjs
|
||||
//
|
||||
// This EXECUTES DashboardTab's refresh wiring in Node with fake timers and a
|
||||
// stubbed health service. It does not render the card in a browser.
|
||||
|
||||
import { test, beforeEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
globalThis.localStorage = { getItem: () => null, setItem() {}, removeItem() {} };
|
||||
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({}) });
|
||||
|
||||
const { DashboardTab } = await import('./DashboardTab.js');
|
||||
const { healthService } = await import('../services/health.service.js');
|
||||
|
||||
let healthCalls;
|
||||
let tab;
|
||||
|
||||
beforeEach(() => {
|
||||
healthCalls = 0;
|
||||
healthService.getSystemHealth = async () => { healthCalls++; return {}; };
|
||||
mock.timers.enable({ apis: ['setTimeout'] });
|
||||
tab = new DashboardTab({ querySelector: () => null });
|
||||
});
|
||||
|
||||
const advance = () => mock.timers.tick(DashboardTab.STREAM_HEALTH_REFRESH_DELAY_MS);
|
||||
|
||||
test('health is re-read once the sensing socket connects', () => {
|
||||
tab._refreshStreamHealthOn('connecting');
|
||||
advance();
|
||||
assert.equal(healthCalls, 0, 'nothing to report until the socket is up');
|
||||
|
||||
tab._refreshStreamHealthOn('connected');
|
||||
assert.equal(healthCalls, 0, 'waits for the server to register the socket');
|
||||
advance();
|
||||
assert.equal(healthCalls, 1);
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
test('health is re-read when a connected socket drops', () => {
|
||||
tab._refreshStreamHealthOn('connected');
|
||||
advance();
|
||||
tab._refreshStreamHealthOn('reconnecting');
|
||||
advance();
|
||||
assert.equal(healthCalls, 2);
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
test('repeated notifications of the same state do not refetch', () => {
|
||||
// onStateChange also fires on dataSource changes with an unchanged state.
|
||||
tab._refreshStreamHealthOn('connected');
|
||||
tab._refreshStreamHealthOn('connected');
|
||||
tab._refreshStreamHealthOn('connected');
|
||||
advance();
|
||||
assert.equal(healthCalls, 1);
|
||||
mock.timers.reset();
|
||||
});
|
||||
|
||||
test('retry churn while never connected does not poll health', () => {
|
||||
for (const s of ['connecting', 'reconnecting', 'connecting', 'reconnecting']) {
|
||||
tab._refreshStreamHealthOn(s);
|
||||
advance();
|
||||
}
|
||||
assert.equal(healthCalls, 0);
|
||||
mock.timers.reset();
|
||||
});
|
||||
@@ -1320,6 +1320,7 @@ export class LiveDemoTab {
|
||||
if (ds === 'server-simulated') return 'Active \u2014 Simulated Data';
|
||||
if (ds === 'unreachable') return 'No Data \u2014 Server Unreachable';
|
||||
if (ds === 'simulated') return 'INVENTED DATA \u2014 Not Measured';
|
||||
if (ds === 'auth-required') return 'API Token Required';
|
||||
return 'Connecting...';
|
||||
}
|
||||
|
||||
@@ -1334,6 +1335,7 @@ export class LiveDemoTab {
|
||||
'reconnecting': { text: 'RECONNECTING TO SERVER...', cls: 'demo-source-reconnecting' },
|
||||
'unreachable': { text: 'NO DATA \u2014 Server Unreachable, Display Is Stale', cls: 'demo-source-offline' },
|
||||
'simulated': { text: 'INVENTED DATA \u2014 Generated By Your Browser, Not Measured', cls: 'demo-source-offline' },
|
||||
'auth-required': { text: 'API TOKEN REQUIRED \u2014 Settings \u2192 API Access', cls: 'demo-source-offline' },
|
||||
};
|
||||
const cfg = config[ds] || config['reconnecting'];
|
||||
banner.textContent = cfg.text;
|
||||
@@ -1374,6 +1376,7 @@ export class LiveDemoTab {
|
||||
'server-simulated': 'Connected \u2014 Simulated',
|
||||
'reconnecting': 'Reconnecting...',
|
||||
'simulated': 'Offline \u2014 Simulated',
|
||||
'auth-required': 'API token required',
|
||||
};
|
||||
const label = dsLabels[ds] || this.state.connectionState;
|
||||
elements.connectionStatus.textContent = label;
|
||||
|
||||
@@ -216,6 +216,7 @@ export class SensingTab {
|
||||
connected: 'Connected',
|
||||
reconnecting: 'Reconnecting...',
|
||||
simulated: 'Simulated',
|
||||
'auth-required': 'API token required',
|
||||
};
|
||||
dot.className = 'sensing-dot ' + state;
|
||||
text.textContent = stateLabels[state] || state;
|
||||
@@ -230,6 +231,7 @@ export class SensingTab {
|
||||
'reconnecting': { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' },
|
||||
'unreachable': { text: 'NO DATA \u2014 SERVER UNREACHABLE', cls: 'sensing-source-simulated' },
|
||||
'simulated': { text: 'INVENTED DATA \u2014 NOT MEASURED', cls: 'sensing-source-simulated' },
|
||||
'auth-required': { text: 'API TOKEN REQUIRED \u2014 SETTINGS \u2192 API ACCESS', cls: 'sensing-source-simulated' },
|
||||
};
|
||||
const cfg = bannerConfig[dataSource] || bannerConfig.reconnecting;
|
||||
banner.textContent = cfg.text;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { withWsTicket } from './ws-ticket.js';
|
||||
import { resolveWsUrl, probeWsAuthRequired } from './ws-ticket.js';
|
||||
import { apiService } from './api.service.js';
|
||||
/**
|
||||
* Sensing WebSocket Service
|
||||
@@ -70,7 +70,7 @@ function clientSimulationAllowed() {
|
||||
}
|
||||
}
|
||||
|
||||
class SensingService {
|
||||
export class SensingService {
|
||||
constructor() {
|
||||
/** @type {WebSocket|null} */
|
||||
this._ws = null;
|
||||
@@ -84,13 +84,19 @@ class SensingService {
|
||||
// Bumped per connect attempt and by `stop()`; lets a socket recognise that
|
||||
// it has been abandoned and stop driving reconnect state.
|
||||
this._connectEpoch = 0;
|
||||
// Connection state: disconnected | connecting | connected | reconnecting | simulated
|
||||
// Connection state: disconnected | connecting | connected | reconnecting |
|
||||
// simulated | auth-required
|
||||
this._state = 'disconnected';
|
||||
// #2099: why the server refused us, while in `auth-required`:
|
||||
// 'missing' — the server needs a token and the browser has none
|
||||
// 'rejected' — the stored token was refused
|
||||
this._authRequired = null;
|
||||
// Data-source label exposed to the UI:
|
||||
// "live" — real ESP32 hardware connected
|
||||
// "server-simulated" — server is running but using synthetic data (no hardware)
|
||||
// "reconnecting" — WebSocket disconnected, retrying
|
||||
// "simulated" — client-side fallback simulation (server unreachable)
|
||||
// "auth-required" — the server refused us for lack of a valid token
|
||||
this._dataSource = 'reconnecting';
|
||||
// The raw source string from the server (e.g. "esp32", "simulated", "simulate")
|
||||
this._serverSource = null;
|
||||
@@ -152,6 +158,14 @@ class SensingService {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
/**
|
||||
* #2099: null, or why the server refused the stream —
|
||||
* 'missing' (no token in this browser) or 'rejected' (stored token refused).
|
||||
*/
|
||||
get authRequired() {
|
||||
return this._authRequired;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current data source label.
|
||||
* "live" — frames are arriving from the real ESP32 over WebSocket
|
||||
@@ -194,8 +208,9 @@ class SensingService {
|
||||
this._setState('connecting');
|
||||
|
||||
let url = SENSING_WS_URL;
|
||||
let authRequired = null;
|
||||
try {
|
||||
url = await withWsTicket(SENSING_WS_URL);
|
||||
({ url, authRequired } = await resolveWsUrl(SENSING_WS_URL));
|
||||
} catch {
|
||||
// Ticket minting is best-effort: against a server with auth off, or one
|
||||
// predating ADR-272, connecting without a ticket is correct.
|
||||
@@ -208,6 +223,15 @@ class SensingService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (authRequired) {
|
||||
// #2099: the stored token was refused. Retrying with it cannot succeed.
|
||||
this._connectInFlight = false;
|
||||
this._enterAuthRequired(authRequired);
|
||||
return;
|
||||
}
|
||||
const ticketed = url !== SENSING_WS_URL;
|
||||
let opened = false;
|
||||
|
||||
let ws;
|
||||
try {
|
||||
ws = new WebSocket(url);
|
||||
@@ -230,6 +254,8 @@ class SensingService {
|
||||
return;
|
||||
}
|
||||
console.info('[Sensing] Connected to', SENSING_WS_URL);
|
||||
opened = true;
|
||||
this._authRequired = null;
|
||||
this._reconnectAttempt = 0;
|
||||
this._stopSimulation();
|
||||
this._setState('connected');
|
||||
@@ -261,7 +287,11 @@ class SensingService {
|
||||
}
|
||||
console.info('[Sensing] Connection closed (code=%d)', evt.code);
|
||||
this._ws = null;
|
||||
if (evt.code !== 1000) {
|
||||
if (evt.code !== 1000 && !opened && !ticketed) {
|
||||
// #2099: a refused upgrade looks like any other failure from here, so
|
||||
// ask the server whether it wants a token before retrying forever.
|
||||
void this._handleRefusedConnect(epoch);
|
||||
} else if (evt.code !== 1000) {
|
||||
this._scheduleReconnect();
|
||||
} else {
|
||||
this._setState('disconnected');
|
||||
@@ -270,6 +300,39 @@ class SensingService {
|
||||
};
|
||||
}
|
||||
|
||||
async _handleRefusedConnect(epoch) {
|
||||
const reason = await probeWsAuthRequired();
|
||||
// stop(), reconnect() or another attempt took over during the probe.
|
||||
if (epoch !== this._connectEpoch || this._ws) return;
|
||||
if (reason) {
|
||||
this._enterAuthRequired(reason);
|
||||
} else {
|
||||
this._scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #2099: stop the retry loop and say why. Nothing is retried until the user
|
||||
* supplies a token (QuickSettings reloads the page on save) or asks for a
|
||||
* reconnect.
|
||||
*/
|
||||
_enterAuthRequired(reason) {
|
||||
console.warn('[Sensing] Server requires an API token (%s); not retrying', reason);
|
||||
this._clearTimers();
|
||||
this._reconnectAttempt = 0;
|
||||
this._authRequired = reason;
|
||||
this._setState('auth-required');
|
||||
this._setDataSource('auth-required');
|
||||
}
|
||||
|
||||
/** Retry now, e.g. from the header's reconnect button. */
|
||||
reconnect() {
|
||||
this._clearTimers();
|
||||
this._authRequired = null;
|
||||
this._reconnectAttempt = 0;
|
||||
void this._connect();
|
||||
}
|
||||
|
||||
_scheduleReconnect() {
|
||||
// A retry is already pending; stacking timers would multiply the attempt
|
||||
// count for a single outage.
|
||||
@@ -529,7 +592,7 @@ class SensingService {
|
||||
/**
|
||||
* Update the dataSource label and notify state listeners so the UI can
|
||||
* react without needing a separate subscription.
|
||||
* @param {'live'|'server-simulated'|'reconnecting'|'simulated'} source
|
||||
* @param {'live'|'server-simulated'|'reconnecting'|'unreachable'|'simulated'|'auth-required'} source
|
||||
*/
|
||||
_setDataSource(source) {
|
||||
if (source === this._dataSource) return;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// Executed regression test for #2099: with RUVIEW_API_TOKEN set on the server
|
||||
// and no token in the browser, the sensing service retried forever and the
|
||||
// header said "Connecting..." with no hint that a token was needed.
|
||||
//
|
||||
// Run: node --test ui/services/sensing.service.test.mjs
|
||||
//
|
||||
// This EXECUTES SensingService in Node with a stub WebSocket that refuses the
|
||||
// upgrade the way a browser reports a 401 (close code 1006, no open), and a
|
||||
// stubbed fetch standing in for the server. It does not drive a browser or the
|
||||
// page wiring (banner, header widget, QuickSettings).
|
||||
|
||||
import { test, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const STORAGE_KEY = 'ruview-api-token';
|
||||
|
||||
let stored = {};
|
||||
let fetchCalls = [];
|
||||
// Server stand-in: auth on, no session. Status probe and ticket mint both 401.
|
||||
let ticketStatus = 401;
|
||||
let sockets = [];
|
||||
let refuseUpgrade = true;
|
||||
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (k in stored ? stored[k] : null),
|
||||
setItem: (k, v) => { stored[k] = String(v); },
|
||||
removeItem: (k) => { delete stored[k]; },
|
||||
};
|
||||
globalThis.fetch = async (path, init = {}) => {
|
||||
fetchCalls.push([path, init]);
|
||||
if (path === '/api/v1/ws-ticket') {
|
||||
const ok = ticketStatus === 200;
|
||||
return { ok, status: ticketStatus, json: async () => (ok ? { ticket: 'T' } : {}) };
|
||||
}
|
||||
return { ok: true, status: 200, json: async () => ({ source: 'simulated' }) };
|
||||
};
|
||||
|
||||
class FakeWebSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = 0;
|
||||
sockets.push(this);
|
||||
setTimeout(() => {
|
||||
if (refuseUpgrade) {
|
||||
this.readyState = 3;
|
||||
this.onerror?.({});
|
||||
this.onclose?.({ code: 1006 });
|
||||
} else {
|
||||
this.readyState = 1;
|
||||
this.onopen?.();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
close() { this.readyState = 3; }
|
||||
}
|
||||
globalThis.WebSocket = FakeWebSocket;
|
||||
|
||||
const { SensingService } = await import('./sensing.service.js');
|
||||
|
||||
const settle = (ms = 30) => new Promise((r) => setTimeout(r, ms));
|
||||
let svc;
|
||||
|
||||
beforeEach(() => {
|
||||
stored = {};
|
||||
fetchCalls = [];
|
||||
ticketStatus = 401;
|
||||
sockets = [];
|
||||
refuseUpgrade = true;
|
||||
svc = new SensingService();
|
||||
});
|
||||
|
||||
afterEach(() => svc.stop());
|
||||
|
||||
test('no token + server requires one: stops retrying and says why', async () => {
|
||||
const states = [];
|
||||
svc.onStateChange((s) => states.push(s));
|
||||
svc.start();
|
||||
await settle();
|
||||
|
||||
assert.equal(svc.state, 'auth-required');
|
||||
assert.equal(svc.dataSource, 'auth-required');
|
||||
assert.equal(svc.authRequired, 'missing');
|
||||
assert.equal(svc._reconnectTimer, null, 'no retry may be pending');
|
||||
|
||||
// Before the fix this kept opening sockets on a 1-16 s back-off forever.
|
||||
await settle(1200);
|
||||
assert.equal(sockets.length, 1, 'exactly one refused attempt, then no retries');
|
||||
assert.ok(!states.includes('reconnecting'), `went through reconnecting: ${states}`);
|
||||
});
|
||||
|
||||
test('a stored token the server rejects is reported without opening a socket', async () => {
|
||||
stored[STORAGE_KEY] = 'stale';
|
||||
svc.start();
|
||||
await settle();
|
||||
assert.equal(svc.state, 'auth-required');
|
||||
assert.equal(svc.authRequired, 'rejected');
|
||||
assert.equal(sockets.length, 0);
|
||||
});
|
||||
|
||||
test('a plain outage still reconnects (auth off, server down)', async () => {
|
||||
// Server unreachable: the probe's fetch fails too.
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => { throw new Error('ECONNREFUSED'); };
|
||||
try {
|
||||
svc.start();
|
||||
await settle();
|
||||
assert.equal(svc.state, 'reconnecting');
|
||||
assert.equal(svc.authRequired, null);
|
||||
assert.ok(svc._reconnectTimer, 'a retry must be scheduled');
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('auth off and server up: no probe on the happy path', async () => {
|
||||
ticketStatus = 200;
|
||||
refuseUpgrade = false;
|
||||
svc.start();
|
||||
await settle();
|
||||
assert.equal(svc.state, 'connected');
|
||||
assert.ok(!fetchCalls.some(([p]) => p === '/api/v1/ws-ticket'),
|
||||
'no ticket request when no token is stored and the socket opens');
|
||||
});
|
||||
|
||||
test('reconnect() leaves auth-required and tries again', async () => {
|
||||
svc.start();
|
||||
await settle();
|
||||
assert.equal(svc.state, 'auth-required');
|
||||
|
||||
refuseUpgrade = false;
|
||||
svc.reconnect();
|
||||
await settle();
|
||||
assert.equal(svc.state, 'connected');
|
||||
assert.equal(svc.authRequired, null);
|
||||
});
|
||||
+59
-10
@@ -27,12 +27,16 @@ function storedToken() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a ticket. Returns null when no token is configured (auth is off, so no
|
||||
* ticket is needed) or when the server does not offer the endpoint.
|
||||
* Mint a ticket. Resolves to `{ ticket, authRequired }`:
|
||||
*
|
||||
* - `ticket` is null when no token is configured (auth is off, so no ticket is
|
||||
* needed) or when the server does not offer the endpoint.
|
||||
* - `authRequired` is `'rejected'` when the stored token was refused (401/403),
|
||||
* so the caller can stop retrying with it. It is null otherwise.
|
||||
*/
|
||||
async function mintTicket() {
|
||||
const token = storedToken();
|
||||
if (!token) return null;
|
||||
if (!token) return { ticket: null, authRequired: null };
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/v1/ws-ticket', {
|
||||
@@ -42,18 +46,66 @@ async function mintTicket() {
|
||||
// 404 means a server predating ADR-272: it still exempts WebSockets, so
|
||||
// connecting without a ticket is correct there. Treated as "no ticket
|
||||
// needed" rather than as an error, so the UI works against both.
|
||||
if (resp.status === 404) return null;
|
||||
if (resp.status === 404) return { ticket: null, authRequired: null };
|
||||
// #2099: a wrong or revoked token. Retrying with it can never succeed.
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
console.warn('[ws-ticket] stored API token was rejected:', resp.status);
|
||||
return { ticket: null, authRequired: 'rejected' };
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.warn('[ws-ticket] mint failed:', resp.status);
|
||||
return null;
|
||||
return { ticket: null, authRequired: null };
|
||||
}
|
||||
const body = await resp.json();
|
||||
return body.ticket || null;
|
||||
return { ticket: body.ticket || null, authRequired: null };
|
||||
} catch (err) {
|
||||
// Offline, or the server is down. The socket attempt will fail on its own
|
||||
// and the caller's reconnect logic handles it; failing loudly here would
|
||||
// just duplicate that.
|
||||
console.warn('[ws-ticket] mint error:', err.message);
|
||||
return { ticket: null, authRequired: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `withWsTicket`, but also reports when the stored token was rejected.
|
||||
* Resolves to `{ url, authRequired }` with `authRequired` null or `'rejected'`.
|
||||
*
|
||||
* @param {string} url ws:// or wss:// URL
|
||||
* @returns {Promise<{url: string, authRequired: null|'rejected'}>}
|
||||
*/
|
||||
export async function resolveWsUrl(url) {
|
||||
const { ticket, authRequired } = await mintTicket();
|
||||
if (!ticket) return { url, authRequired };
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return { url: `${url}${sep}ticket=${encodeURIComponent(ticket)}`, authRequired: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* #2099: ask the server whether a socket refusal was an auth refusal.
|
||||
*
|
||||
* A browser cannot see the status of a refused WebSocket upgrade; it only gets
|
||||
* a close event. So after an unticketed socket closes without ever opening,
|
||||
* repeat the ticket request with no bearer. The server answers 401 exactly
|
||||
* when it requires a credential the browser does not have (a signed-in session
|
||||
* cookie still counts, since `fetch` sends it same-origin). Returns
|
||||
* `'missing'` in that case and null for anything else, including a network
|
||||
* failure, which is an outage rather than an auth problem.
|
||||
*
|
||||
* Only called on the failure path, so a server with auth off sees no extra
|
||||
* request while it is reachable.
|
||||
*
|
||||
* @returns {Promise<null|'missing'>}
|
||||
*/
|
||||
export async function probeWsAuthRequired() {
|
||||
if (storedToken()) return null; // mintTicket already judged the stored token
|
||||
try {
|
||||
const resp = await fetch('/api/v1/ws-ticket', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
return resp.status === 401 || resp.status === 403 ? 'missing' : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -70,8 +122,5 @@ async function mintTicket() {
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export async function withWsTicket(url) {
|
||||
const ticket = await mintTicket();
|
||||
if (!ticket) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}ticket=${encodeURIComponent(ticket)}`;
|
||||
return (await resolveWsUrl(url)).url;
|
||||
}
|
||||
@@ -111,3 +111,57 @@ test('a fresh ticket is minted per call and never reused', async () => {
|
||||
assert.equal(await withWsTicket('ws://h/ws/sensing'), 'ws://h/ws/sensing?ticket=T2');
|
||||
assert.equal(fetchCalls.length, 2, 'each connect attempt must mint its own');
|
||||
});
|
||||
|
||||
// ---- #2099: auth-required detection ----------------------------------------
|
||||
|
||||
const { resolveWsUrl, probeWsAuthRequired } = await import('./ws-ticket.js');
|
||||
|
||||
test('a stored token that the server refuses is reported as rejected', async () => {
|
||||
// Retrying with a token the server has refused can never succeed, so the
|
||||
// caller needs to know rather than loop.
|
||||
stored[STORAGE_KEY] = 'wrong';
|
||||
for (const status of [401, 403]) {
|
||||
fetchImpl = async () => ({ ok: false, status, json: async () => ({}) });
|
||||
const r = await resolveWsUrl('ws://host/ws/sensing');
|
||||
assert.deepEqual(r, { url: 'ws://host/ws/sensing', authRequired: 'rejected' });
|
||||
}
|
||||
});
|
||||
|
||||
test('withWsTicket keeps its string contract when the token is refused', async () => {
|
||||
stored[STORAGE_KEY] = 'wrong';
|
||||
fetchImpl = async () => ({ ok: false, status: 401, json: async () => ({}) });
|
||||
assert.equal(await withWsTicket('ws://host/ws/sensing'), 'ws://host/ws/sensing');
|
||||
});
|
||||
|
||||
test('a 503 or a 404 is not an auth refusal', async () => {
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
for (const status of [404, 503]) {
|
||||
fetchImpl = async () => ({ ok: false, status, json: async () => ({}) });
|
||||
assert.equal((await resolveWsUrl('ws://h/ws/sensing')).authRequired, null);
|
||||
}
|
||||
});
|
||||
|
||||
test('the probe reports a missing token when the server answers 401 with no bearer', async () => {
|
||||
fetchImpl = async () => ({ ok: false, status: 401, json: async () => ({}) });
|
||||
assert.equal(await probeWsAuthRequired(), 'missing');
|
||||
const [path, init] = fetchCalls[0];
|
||||
assert.equal(path, '/api/v1/ws-ticket');
|
||||
assert.equal(init.method, 'POST');
|
||||
assert.equal(init.headers, undefined, 'the probe must not send a bearer');
|
||||
assert.equal(init.credentials, 'same-origin', 'a signed-in session cookie still counts');
|
||||
});
|
||||
|
||||
test('the probe treats success, other errors and network failure as not-auth', async () => {
|
||||
fetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ ticket: 'T' }) });
|
||||
assert.equal(await probeWsAuthRequired(), null, 'auth off or a valid session');
|
||||
fetchImpl = async () => ({ ok: false, status: 500, json: async () => ({}) });
|
||||
assert.equal(await probeWsAuthRequired(), null);
|
||||
fetchImpl = async () => { throw new Error('offline'); };
|
||||
assert.equal(await probeWsAuthRequired(), null, 'an outage is not an auth problem');
|
||||
});
|
||||
|
||||
test('the probe does not second-guess a stored token', async () => {
|
||||
stored[STORAGE_KEY] = 'b';
|
||||
assert.equal(await probeWsAuthRequired(), null);
|
||||
assert.equal(fetchCalls.length, 0);
|
||||
});
|
||||
@@ -3,6 +3,11 @@
|
||||
|
||||
import { sensingService } from '../services/sensing.service.js';
|
||||
|
||||
/** Ask QuickSettings to open on its API Access section (#2099). */
|
||||
export function openApiAccess(reason) {
|
||||
document.dispatchEvent(new CustomEvent('ruview-open-api-access', { detail: { reason } }));
|
||||
}
|
||||
|
||||
export class ConnectionStatus {
|
||||
constructor() {
|
||||
this.widget = null;
|
||||
@@ -27,6 +32,15 @@ export class ConnectionStatus {
|
||||
</button>
|
||||
`;
|
||||
|
||||
// #2099: while the server wants a token, the widget is the way to it.
|
||||
this.widget.addEventListener('click', (e) => {
|
||||
if (sensingService.state !== 'auth-required') return;
|
||||
if (e.target.closest('.conn-status-reconnect')) return;
|
||||
// QuickSettings closes on any outside click; this click must not count.
|
||||
e.stopPropagation();
|
||||
openApiAccess(sensingService.authRequired);
|
||||
});
|
||||
|
||||
this.widget.querySelector('.conn-status-reconnect').addEventListener('click', () => {
|
||||
this.setStatus('reconnecting', 'Reconnecting...');
|
||||
sensingService.reconnect?.();
|
||||
@@ -58,6 +72,17 @@ export class ConnectionStatus {
|
||||
this.setStatus('connected', label);
|
||||
} else if (state === 'connecting' || state === 'reconnecting') {
|
||||
this.setStatus('reconnecting', 'Connecting...');
|
||||
} else if (state === 'auth-required') {
|
||||
this.setStatus('error', 'Token required');
|
||||
this.widget.title = 'This server requires an API token. Click to open Settings \u2192 API Access.';
|
||||
// Open the panel once per page load; after that the widget stays
|
||||
// clickable but does not keep popping the panel up.
|
||||
if (!this._openedApiAccess) {
|
||||
this._openedApiAccess = true;
|
||||
// Deferred so QuickSettings, initialised after this widget in the same
|
||||
// synchronous pass, is listening by the time the event fires.
|
||||
setTimeout(() => openApiAccess(sensingService.authRequired), 0);
|
||||
}
|
||||
} else if (state === 'error') {
|
||||
this.setStatus('error', 'Error');
|
||||
} else {
|
||||
@@ -67,6 +92,8 @@ export class ConnectionStatus {
|
||||
|
||||
setStatus(status, label) {
|
||||
if (!this.widget) return;
|
||||
if (sensingService.state !== 'auth-required') this.widget.removeAttribute('title');
|
||||
this.widget.style.cursor = sensingService.state === 'auth-required' ? 'pointer' : '';
|
||||
this.widget.className = `conn-status conn-status-${status}`;
|
||||
this.widget.querySelector('.conn-status-label').textContent = label;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
// meaningful.
|
||||
|
||||
import { sensingService } from '../services/sensing.service.js';
|
||||
import { openApiAccess } from './connection-status.js';
|
||||
|
||||
const STYLE_ID = 'ruview-data-source-banner-style';
|
||||
const BAR_ID = 'ruview-data-source-banner';
|
||||
@@ -37,6 +38,13 @@ const CONFIG = {
|
||||
bg: '#6b0f6b',
|
||||
fg: '#ffc9ff',
|
||||
},
|
||||
// #2099: the server refused the stream. Clicking the bar opens API Access.
|
||||
'auth-required': {
|
||||
text: 'API TOKEN REQUIRED — this server needs its API token. Click here, or Settings → API Access',
|
||||
textRejected: 'API TOKEN REJECTED — the saved token was refused. Click here, or Settings → API Access',
|
||||
bg: '#5c1010',
|
||||
fg: '#ffc9c9',
|
||||
},
|
||||
};
|
||||
|
||||
const CSS = `
|
||||
@@ -90,6 +98,12 @@ export class DataSourceBanner {
|
||||
// message describes context, not an error to act on right now.
|
||||
this._bar.setAttribute('aria-live', 'polite');
|
||||
document.body.appendChild(this._bar);
|
||||
this._bar.addEventListener('click', (e) => {
|
||||
if (sensingService.dataSource !== 'auth-required') return;
|
||||
// QuickSettings closes on any outside click; this click must not count.
|
||||
e.stopPropagation();
|
||||
openApiAccess(sensingService.authRequired);
|
||||
});
|
||||
}
|
||||
|
||||
// dataSource changes are published on the state-listener channel.
|
||||
@@ -107,7 +121,9 @@ export class DataSourceBanner {
|
||||
return;
|
||||
}
|
||||
|
||||
this._bar.textContent = cfg.text;
|
||||
this._bar.textContent =
|
||||
cfg.textRejected && sensingService.authRequired === 'rejected' ? cfg.textRejected : cfg.text;
|
||||
this._bar.style.cursor = sensingService.dataSource === 'auth-required' ? 'pointer' : '';
|
||||
this._bar.style.background = cfg.bg;
|
||||
this._bar.style.color = cfg.fg;
|
||||
this._bar.classList.add('is-visible');
|
||||
|
||||
@@ -196,6 +196,10 @@ export class QuickSettings {
|
||||
|
||||
document.body.appendChild(this.panel);
|
||||
|
||||
// #2099: the dashboard asks for this when the server refused the stream
|
||||
// for lack of a valid token.
|
||||
document.addEventListener('ruview-open-api-access', (e) => this.openApiAccess(e.detail?.reason));
|
||||
|
||||
// Close on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (this.isOpen && !this.panel.contains(e.target) && !this.button.contains(e.target)) {
|
||||
@@ -245,6 +249,22 @@ export class QuickSettings {
|
||||
void refreshSignInPanel(this.panel);
|
||||
}
|
||||
|
||||
/** Open on the API Access section and say why (#2099). */
|
||||
openApiAccess(reason) {
|
||||
this.open();
|
||||
const status = this.panel.querySelector('#qs-api-token-status');
|
||||
if (status) {
|
||||
status.textContent = reason === 'rejected'
|
||||
? 'The server rejected the saved token. Paste the current RUVIEW_API_TOKEN and Save & Apply.'
|
||||
: 'This server requires an API token. Paste RUVIEW_API_TOKEN and Save & Apply.';
|
||||
}
|
||||
const input = this.panel.querySelector('#qs-api-token');
|
||||
if (input) {
|
||||
input.scrollIntoView?.({ block: 'center' });
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
this.panel.classList.remove('open');
|
||||
|
||||
Generated
+31
@@ -9559,6 +9559,16 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-spatial-evidence"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"ruview-unified",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-spatial-memory"
|
||||
version = "0.3.1"
|
||||
@@ -9573,6 +9583,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-survey"
|
||||
version = "0.3.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruview-swarm"
|
||||
version = "0.1.0"
|
||||
@@ -13311,6 +13330,17 @@ dependencies = [
|
||||
"wifi-densepose-signal",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-mtk-bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"wifi-densepose-hardware",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-nn"
|
||||
version = "0.3.2"
|
||||
@@ -13387,6 +13417,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
"wifi-densepose-hardware",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -21,6 +21,8 @@ members = [
|
||||
"crates/wifi-densepose-mat",
|
||||
"crates/wifi-densepose-train",
|
||||
"crates/wifi-densepose-sensing-server",
|
||||
# ADR-266/267: MT7981 vendor CSI (mt76) -> MTC1 bridge.
|
||||
"crates/wifi-densepose-mtk-bridge",
|
||||
"crates/wifi-densepose-aether", # ADR-185 §13 — AETHER pure-compute leaf (std-only)
|
||||
"crates/wifi-densepose-privshield", # ADR-291 — VEIL privacy shield (compliant-waveform anti-sensing; std-only leaf)
|
||||
"crates/wifi-densepose-wifiscan",
|
||||
@@ -120,12 +122,16 @@ members = [
|
||||
"crates/ruview-counterfactual",# ADR-313 counterfactual spatial inference
|
||||
"crates/ruview-infogain", # ADR-314 information-gain scheduler
|
||||
"crates/ruview-active", # ADR-309 active sensing control
|
||||
"crates/ruview-survey", # ADR-381 node self-survey (check mode)
|
||||
# ADR-324 — clean-room Kooima off-axis (head-coupled perspective) projection.
|
||||
# Dependency-free native core; wasm-bindgen surface only on wasm32.
|
||||
"crates/ruview-offaxis",
|
||||
# ADR-368 — portable compute kernel for the `@ruvnet/ruview-kernel` npm
|
||||
# package: one bounded JSON ABI, wasm32 (zero-import) + napi-rs transports.
|
||||
"crates/ruview-kernel",
|
||||
# spatial.evidence.v1 emitter (WeftOS ADR-107 wire contract): RF Gaussians
|
||||
# and link measurements as JSONL evidence. Leaf over ruview-unified.
|
||||
"crates/ruview-spatial-evidence",
|
||||
# ADR-348 — RuForecast extracted to the ruvnet/RuForecast submodule
|
||||
# (see crates/ruforecast in the exclude list below); path-deps only.
|
||||
]
|
||||
|
||||
@@ -9,8 +9,7 @@ repository.workspace = true
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
ruview-ontology = { path = "../ruview-ontology" }
|
||||
ruview-evidence = { path = "../ruview-evidence" }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Label RuView recordings with occupancy from radar logs.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p ruview-groundtruth --example radar_autolabel -- \
|
||||
//! --session mon data/recordings/mon.csi.jsonl radar/mon.jsonl \
|
||||
//! --session tue data/recordings/tue.csi.jsonl radar/tue.jsonl \
|
||||
//! --test-session tue --offset-ms 0 --out labels.jsonl
|
||||
//! ```
|
||||
//!
|
||||
//! Writes one labelled window per line (to `--out`, or stdout) and a JSON
|
||||
//! summary to stderr: per-session kept/skipped counts and, when
|
||||
//! `--test-session` is given, the session-disjoint majority/median baseline.
|
||||
//! `--offset-ms` is the recording clock minus the radar clock and applies to
|
||||
//! every session. `--allow-unverified` accepts radar windows whose source is
|
||||
//! not flagged as verified hardware.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use ruview_groundtruth::{
|
||||
auto_label, majority_baseline, parse_radar_jsonl, parse_recording_jsonl,
|
||||
session_disjoint_split, AutoLabelConfig, LabelledWindow, RadarAdapterConfig, RadarLabels,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
struct Args {
|
||||
sessions: Vec<(String, String, String)>,
|
||||
test_sessions: Vec<String>,
|
||||
out: Option<String>,
|
||||
label: AutoLabelConfig,
|
||||
radar: RadarAdapterConfig,
|
||||
}
|
||||
|
||||
fn next(it: &mut impl Iterator<Item = String>, flag: &str) -> Result<String, String> {
|
||||
it.next().ok_or_else(|| format!("{flag} needs a value"))
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Args, String> {
|
||||
let mut a = Args {
|
||||
sessions: Vec::new(),
|
||||
test_sessions: Vec::new(),
|
||||
out: None,
|
||||
label: AutoLabelConfig::default(),
|
||||
radar: RadarAdapterConfig::default(),
|
||||
};
|
||||
let mut it = std::env::args().skip(1);
|
||||
while let Some(flag) = it.next() {
|
||||
match flag.as_str() {
|
||||
"--session" => {
|
||||
let name = next(&mut it, "--session")?;
|
||||
let rec = next(&mut it, "--session")?;
|
||||
let radar = next(&mut it, "--session")?;
|
||||
a.sessions.push((name, rec, radar));
|
||||
}
|
||||
"--test-session" => a.test_sessions.push(next(&mut it, &flag)?),
|
||||
"--out" => a.out = Some(next(&mut it, &flag)?),
|
||||
"--offset-ms" => {
|
||||
a.label.radar_offset_ms = next(&mut it, &flag)?
|
||||
.parse()
|
||||
.map_err(|e| format!("--offset-ms: {e}"))?;
|
||||
}
|
||||
"--min-frames" => {
|
||||
a.label.min_csi_frames = next(&mut it, &flag)?
|
||||
.parse()
|
||||
.map_err(|e| format!("--min-frames: {e}"))?;
|
||||
}
|
||||
"--empty-hold-ms" => {
|
||||
a.label.empty_hold_ms = next(&mut it, &flag)?
|
||||
.parse()
|
||||
.map_err(|e| format!("--empty-hold-ms: {e}"))?;
|
||||
}
|
||||
"--allow-unverified" => a.radar.require_verified = false,
|
||||
other => return Err(format!("unknown argument {other}")),
|
||||
}
|
||||
}
|
||||
if a.sessions.is_empty() {
|
||||
return Err("at least one --session NAME RECORDING RADAR is required".into());
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let args = parse_args()?;
|
||||
let mut all: Vec<LabelledWindow> = Vec::new();
|
||||
let mut per_session = Vec::new();
|
||||
|
||||
for (name, rec_path, radar_path) in &args.sessions {
|
||||
let rec = fs::read_to_string(rec_path).map_err(|e| format!("{rec_path}: {e}"))?;
|
||||
let rad = fs::read_to_string(radar_path).map_err(|e| format!("{radar_path}: {e}"))?;
|
||||
let frames = parse_recording_jsonl(&rec).map_err(|e| format!("{rec_path}: {e}"))?;
|
||||
let windows = parse_radar_jsonl(&rad).map_err(|e| format!("{radar_path}: {e}"))?;
|
||||
let labels = RadarLabels::from_windows(name, "radar", &windows, args.radar)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let out = auto_label(name, &frames, &labels, args.label).map_err(|e| e.to_string())?;
|
||||
per_session.push(json!({
|
||||
"session": name,
|
||||
"csi_frames": frames.len(),
|
||||
"radar_windows": windows.len(),
|
||||
"kept": out.windows.len(),
|
||||
"skipped": out.skipped,
|
||||
"radar_unknown": out.radar_unknown,
|
||||
}));
|
||||
all.extend(out.windows);
|
||||
}
|
||||
|
||||
let mut sink: Box<dyn Write> = match &args.out {
|
||||
Some(p) => Box::new(fs::File::create(p).map_err(|e| format!("{p}: {e}"))?),
|
||||
None => Box::new(std::io::stdout().lock()),
|
||||
};
|
||||
for w in &all {
|
||||
let line = serde_json::to_string(w).map_err(|e| e.to_string())?;
|
||||
writeln!(sink, "{line}").map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
let baseline = if args.test_sessions.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let names: Vec<&str> = args.test_sessions.iter().map(String::as_str).collect();
|
||||
let split = session_disjoint_split(&all, &names).map_err(|e| e.to_string())?;
|
||||
let b = majority_baseline(&split.train, &split.test).map_err(|e| e.to_string())?;
|
||||
Some(json!({
|
||||
"train_sessions": split.train_sessions,
|
||||
"test_sessions": split.test_sessions,
|
||||
"baseline": b,
|
||||
}))
|
||||
};
|
||||
let summary = json!({
|
||||
"offset_ms": args.label.radar_offset_ms,
|
||||
"label_config": args.label,
|
||||
"radar_config": args.radar,
|
||||
"sessions": per_session,
|
||||
"split": baseline,
|
||||
});
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&summary).map_err(|e| e.to_string())?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("radar_autolabel: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
//! Occupancy auto-labelling from a RuView recording plus a radar log.
|
||||
//!
|
||||
//! A RuView recording (`.csi.jsonl`, one line per CSI frame in the
|
||||
//! `RecordedFrame` shape of the sensing server's `recording.rs`) and a radar log (see [`crate::radar`]) cover the same session. This
|
||||
//! module cuts the recording at the radar's window boundaries and gives each
|
||||
//! cut an occupancy label (empty / occupied) and a person count taken from the
|
||||
//! radar. The output is training and evaluation data for CSI occupancy models;
|
||||
//! the radar never becomes an input to them.
|
||||
//!
|
||||
//! The radar and the recording are stamped by different clocks. The offset
|
||||
//! between them is a required, reported parameter, never estimated silently.
|
||||
//!
|
||||
//! Windows are dropped, with a counted reason, when the radar window has no
|
||||
//! label, when too few CSI frames fall inside it, or when it says "empty" too
|
||||
//! soon after an occupied window (a tracker can lose a person who stops
|
||||
//! moving, so a short gap is more likely a miss than an exit).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agreement::EvidenceGrade;
|
||||
use crate::error::{check_bound, check_nonempty, GroundTruthError};
|
||||
use crate::radar::{LabelStatus, RadarLabels, UnknownReason};
|
||||
use crate::series::MAX_SAMPLES;
|
||||
|
||||
/// The largest recording line accepted, in bytes, bounding allocation.
|
||||
pub const MAX_RECORDING_LINE_BYTES: usize = 1024 * 1024;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RecordedFrameLine {
|
||||
timestamp: f64,
|
||||
#[serde(default)]
|
||||
subcarriers: Vec<f64>,
|
||||
#[serde(default)]
|
||||
rssi: Option<f64>,
|
||||
}
|
||||
|
||||
/// One CSI frame from a recording, reduced to what windowing needs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CsiFrame {
|
||||
/// Frame time on the recording clock, Unix milliseconds.
|
||||
pub at_unix_ms: i64,
|
||||
/// Received signal strength, dBm, when recorded.
|
||||
pub rssi: Option<f64>,
|
||||
/// Mean subcarrier amplitude of the frame, when it has subcarriers.
|
||||
pub amplitude_mean: Option<f64>,
|
||||
}
|
||||
|
||||
/// Parse a RuView `.csi.jsonl` recording. Blank lines are skipped; a malformed
|
||||
/// line or a non-finite timestamp is a row-numbered error.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GroundTruthError::InvalidRow`] or [`GroundTruthError::TooManySamples`].
|
||||
pub fn parse_recording_jsonl(text: &str) -> Result<Vec<CsiFrame>, GroundTruthError> {
|
||||
let mut out = Vec::new();
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let row = i + 1;
|
||||
if line.len() > MAX_RECORDING_LINE_BYTES {
|
||||
return Err(GroundTruthError::InvalidRow {
|
||||
row,
|
||||
reason: format!(
|
||||
"line is {} bytes, max {MAX_RECORDING_LINE_BYTES}",
|
||||
line.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
let f: RecordedFrameLine =
|
||||
serde_json::from_str(line).map_err(|e| GroundTruthError::InvalidRow {
|
||||
row,
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
if !f.timestamp.is_finite() || f.timestamp < 0.0 {
|
||||
return Err(GroundTruthError::InvalidRow {
|
||||
row,
|
||||
reason: "timestamp must be finite, non-negative Unix seconds".into(),
|
||||
});
|
||||
}
|
||||
let finite: Vec<f64> = f
|
||||
.subcarriers
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|v| v.is_finite())
|
||||
.collect();
|
||||
out.push(CsiFrame {
|
||||
at_unix_ms: (f.timestamp * 1000.0).round() as i64,
|
||||
rssi: f.rssi.filter(|r| r.is_finite()),
|
||||
amplitude_mean: (!finite.is_empty())
|
||||
.then(|| finite.iter().map(|v| v.abs()).sum::<f64>() / finite.len() as f64),
|
||||
});
|
||||
if out.len() > MAX_SAMPLES {
|
||||
return Err(GroundTruthError::TooManySamples {
|
||||
len: out.len(),
|
||||
max: MAX_SAMPLES,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Occupancy class of a window.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Occupancy {
|
||||
/// The radar saw nobody.
|
||||
Empty,
|
||||
/// The radar saw at least one person.
|
||||
Occupied,
|
||||
}
|
||||
|
||||
/// A compact summary of the CSI frames inside one window.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CsiWindowSummary {
|
||||
/// Frames in the window.
|
||||
pub n_frames: usize,
|
||||
/// Mean RSSI over frames that recorded one.
|
||||
pub rssi_mean: Option<f64>,
|
||||
/// Mean of per-frame mean amplitude.
|
||||
pub amplitude_mean: Option<f64>,
|
||||
/// Population variance of per-frame mean amplitude, a crude motion proxy.
|
||||
pub amplitude_var: Option<f64>,
|
||||
}
|
||||
|
||||
fn mean_var(xs: &[f64]) -> (Option<f64>, Option<f64>) {
|
||||
if xs.is_empty() {
|
||||
return (None, None);
|
||||
}
|
||||
let n = xs.len() as f64;
|
||||
let m = xs.iter().sum::<f64>() / n;
|
||||
let v = xs.iter().map(|x| (x - m) * (x - m)).sum::<f64>() / n;
|
||||
(Some(m), Some(v))
|
||||
}
|
||||
|
||||
fn summarize(frames: &[CsiFrame]) -> CsiWindowSummary {
|
||||
let rssi: Vec<f64> = frames.iter().filter_map(|f| f.rssi).collect();
|
||||
let amp: Vec<f64> = frames.iter().filter_map(|f| f.amplitude_mean).collect();
|
||||
let (amplitude_mean, amplitude_var) = mean_var(&);
|
||||
CsiWindowSummary {
|
||||
n_frames: frames.len(),
|
||||
rssi_mean: mean_var(&rssi).0,
|
||||
amplitude_mean,
|
||||
amplitude_var,
|
||||
}
|
||||
}
|
||||
|
||||
fn claimed() -> EvidenceGrade {
|
||||
EvidenceGrade::Claimed
|
||||
}
|
||||
|
||||
/// One labelled window of a recording.
|
||||
///
|
||||
/// The label comes from a radar, not a human, so `evidence` is always
|
||||
/// [`EvidenceGrade::Claimed`].
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LabelledWindow {
|
||||
/// Always [`EvidenceGrade::Claimed`] (radar-derived, not human ground truth).
|
||||
#[serde(default = "claimed")]
|
||||
pub evidence: EvidenceGrade,
|
||||
/// Session (recording) name; the unit of a disjoint split.
|
||||
pub session: String,
|
||||
/// Window start on the recording clock, Unix milliseconds.
|
||||
pub start_ms: i64,
|
||||
/// Window end on the recording clock, Unix milliseconds.
|
||||
pub end_ms: i64,
|
||||
/// Occupancy label.
|
||||
pub occupancy: Occupancy,
|
||||
/// Person count, when the radar reported one.
|
||||
pub person_count: Option<u32>,
|
||||
/// Lowest count the radar window carried.
|
||||
pub count_low: Option<u32>,
|
||||
/// Highest count the radar window carried.
|
||||
pub count_high: Option<u32>,
|
||||
/// Index of the radar window in its log.
|
||||
pub radar_window_index: usize,
|
||||
/// Summary of the CSI frames in the window.
|
||||
pub csi: CsiWindowSummary,
|
||||
}
|
||||
|
||||
/// Why a radar window produced no labelled window.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SkipReason {
|
||||
/// The radar window had no label.
|
||||
RadarUnknown,
|
||||
/// Fewer CSI frames than `min_csi_frames` fell inside the window.
|
||||
TooFewCsiFrames,
|
||||
/// An empty label within `empty_hold_ms` of an occupied one.
|
||||
RecentlyOccupied,
|
||||
}
|
||||
|
||||
/// How a session is labelled.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AutoLabelConfig {
|
||||
/// Added to radar timestamps to put them on the recording clock,
|
||||
/// milliseconds (recording clock minus radar clock).
|
||||
pub radar_offset_ms: i64,
|
||||
/// Fewest CSI frames a window needs to be kept.
|
||||
pub min_csi_frames: usize,
|
||||
/// An empty window starting within this long after an occupied window
|
||||
/// ends is dropped, milliseconds. `0` keeps every empty window.
|
||||
pub empty_hold_ms: i64,
|
||||
}
|
||||
|
||||
impl Default for AutoLabelConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
radar_offset_ms: 0,
|
||||
min_csi_frames: 5,
|
||||
empty_hold_ms: 5_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The labelled windows of one session plus what was dropped and why.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AutoLabelOutput {
|
||||
/// Session name.
|
||||
pub session: String,
|
||||
/// The configuration used, including the clock offset.
|
||||
pub config: AutoLabelConfig,
|
||||
/// Kept windows, in time order.
|
||||
pub windows: Vec<LabelledWindow>,
|
||||
/// Dropped radar windows by reason.
|
||||
pub skipped: BTreeMap<SkipReason, usize>,
|
||||
/// Unlabelled radar windows by the radar adapter's reason.
|
||||
pub radar_unknown: BTreeMap<UnknownReason, usize>,
|
||||
}
|
||||
|
||||
/// Label one session.
|
||||
///
|
||||
/// `frames` need not be sorted. Radar labels are processed in time order.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GroundTruthError::EmptyField`]/[`GroundTruthError::TooLong`] for a bad
|
||||
/// session name, or [`GroundTruthError::InvalidConfig`] for a negative hold.
|
||||
pub fn auto_label(
|
||||
session: &str,
|
||||
frames: &[CsiFrame],
|
||||
radar: &RadarLabels,
|
||||
cfg: AutoLabelConfig,
|
||||
) -> Result<AutoLabelOutput, GroundTruthError> {
|
||||
check_nonempty("session", session)?;
|
||||
check_bound("session", session)?;
|
||||
if cfg.empty_hold_ms < 0 {
|
||||
return Err(GroundTruthError::InvalidConfig {
|
||||
reason: "empty_hold_ms must be non-negative",
|
||||
});
|
||||
}
|
||||
let mut frames = frames.to_vec();
|
||||
frames.sort_by_key(|f| f.at_unix_ms);
|
||||
|
||||
let mut labels: Vec<_> = radar.labels.iter().collect();
|
||||
labels.sort_by_key(|l| (l.window_start_ms, l.window_end_ms));
|
||||
|
||||
let mut out = AutoLabelOutput {
|
||||
session: session.to_owned(),
|
||||
config: cfg,
|
||||
windows: Vec::new(),
|
||||
skipped: BTreeMap::new(),
|
||||
radar_unknown: BTreeMap::new(),
|
||||
};
|
||||
let mut last_occupied_end: Option<i64> = None;
|
||||
|
||||
for l in labels {
|
||||
if let LabelStatus::Unknown(r) = l.status {
|
||||
*out.radar_unknown.entry(r).or_default() += 1;
|
||||
*out.skipped.entry(SkipReason::RadarUnknown).or_default() += 1;
|
||||
continue;
|
||||
}
|
||||
let Some(present) = l.presence else {
|
||||
*out.skipped.entry(SkipReason::RadarUnknown).or_default() += 1;
|
||||
continue;
|
||||
};
|
||||
let start = l.window_start_ms.saturating_add(cfg.radar_offset_ms);
|
||||
let end = l.window_end_ms.saturating_add(cfg.radar_offset_ms);
|
||||
let occupancy = if present {
|
||||
Occupancy::Occupied
|
||||
} else {
|
||||
Occupancy::Empty
|
||||
};
|
||||
|
||||
if occupancy == Occupancy::Occupied {
|
||||
last_occupied_end = Some(end);
|
||||
} else if matches!(last_occupied_end, Some(e) if start.saturating_sub(e) < cfg.empty_hold_ms)
|
||||
{
|
||||
*out.skipped.entry(SkipReason::RecentlyOccupied).or_default() += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Half-open [start, end); a zero-length window takes the frames at `start`.
|
||||
let lo = frames.partition_point(|f| f.at_unix_ms < start);
|
||||
let hi = if end > start {
|
||||
frames.partition_point(|f| f.at_unix_ms < end)
|
||||
} else {
|
||||
frames.partition_point(|f| f.at_unix_ms <= start)
|
||||
};
|
||||
let inside = &frames[lo..hi];
|
||||
if inside.len() < cfg.min_csi_frames.max(1) {
|
||||
*out.skipped.entry(SkipReason::TooFewCsiFrames).or_default() += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
out.windows.push(LabelledWindow {
|
||||
evidence: l.evidence,
|
||||
session: session.to_owned(),
|
||||
start_ms: start,
|
||||
end_ms: end,
|
||||
occupancy,
|
||||
person_count: l.person_count,
|
||||
count_low: l.uncertainty.count_low,
|
||||
count_high: l.uncertainty.count_high,
|
||||
radar_window_index: l.window_index,
|
||||
csi: summarize(inside),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::radar::{RadarAdapterConfig, RadarWindow, RadarWindowSource};
|
||||
|
||||
/// Synthetic CSI at 10 Hz from `t0` for `secs` seconds.
|
||||
fn csi(t0_ms: i64, secs: i64) -> Vec<CsiFrame> {
|
||||
(0..secs * 10)
|
||||
.map(|i| CsiFrame {
|
||||
at_unix_ms: t0_ms + i * 100,
|
||||
rssi: Some(-50.0),
|
||||
amplitude_mean: Some(10.0 + (i % 3) as f64),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn radar(t0_ms: i64, counts: &[Option<u32>]) -> RadarLabels {
|
||||
let windows: Vec<RadarWindow> = counts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, c)| RadarWindow {
|
||||
timestamp_ms: t0_ms + (i as i64 + 1) * 1000,
|
||||
window_start_ms: Some(t0_ms + i as i64 * 1000),
|
||||
health: Some("ok".into()),
|
||||
quality: Some(1.0),
|
||||
source: Some(RadarWindowSource {
|
||||
verified: c.is_some(),
|
||||
simulated: false,
|
||||
}),
|
||||
presence: None,
|
||||
target_count: Some(c.unwrap_or(0)),
|
||||
max_target_count: None,
|
||||
distance_cm: None,
|
||||
targets: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
RadarLabels::from_windows("radar-1", "ld2450", &windows, RadarAdapterConfig::default())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_recording_lines() {
|
||||
let text = r#"{"timestamp":1700000000.25,"subcarriers":[1.0,-3.0],"rssi":-48.0,"noise_floor":-90.0,"features":{}}
|
||||
|
||||
{"timestamp":1700000000.35,"subcarriers":[],"rssi":-49.0,"noise_floor":-90.0,"features":{}}"#;
|
||||
let f = parse_recording_jsonl(text).unwrap();
|
||||
assert_eq!(f.len(), 2);
|
||||
assert_eq!(f[0].at_unix_ms, 1_700_000_000_250);
|
||||
assert_eq!(f[0].amplitude_mean, Some(2.0));
|
||||
assert_eq!(f[1].amplitude_mean, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_recording_rows_are_numbered() {
|
||||
let err = parse_recording_jsonl("{\"timestamp\":1.0}\nnope").unwrap_err();
|
||||
assert!(matches!(err, GroundTruthError::InvalidRow { row: 2, .. }));
|
||||
let err = parse_recording_jsonl("{\"timestamp\":-1.0}").unwrap_err();
|
||||
assert!(matches!(err, GroundTruthError::InvalidRow { row: 1, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_follow_radar_windows_and_count_frames() {
|
||||
let r = radar(0, &[Some(0), Some(1), Some(2)]);
|
||||
let cfg = AutoLabelConfig {
|
||||
empty_hold_ms: 0,
|
||||
..AutoLabelConfig::default()
|
||||
};
|
||||
let out = auto_label("s1", &csi(0, 3), &r, cfg).unwrap();
|
||||
assert_eq!(out.windows.len(), 3);
|
||||
assert_eq!(out.windows[0].occupancy, Occupancy::Empty);
|
||||
assert_eq!(out.windows[2].person_count, Some(2));
|
||||
assert!(out.windows.iter().all(|w| w.csi.n_frames == 10));
|
||||
assert_eq!(out.windows[1].csi.rssi_mean, Some(-50.0));
|
||||
assert!(out.skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clock_offset_moves_radar_onto_the_recording() {
|
||||
// Radar clock runs 60 s behind the recording.
|
||||
let r = radar(0, &[Some(1), Some(1)]);
|
||||
let frames = csi(60_000, 2);
|
||||
let none = auto_label("s1", &frames, &r, AutoLabelConfig::default()).unwrap();
|
||||
assert_eq!(none.windows.len(), 0);
|
||||
assert_eq!(none.skipped.get(&SkipReason::TooFewCsiFrames), Some(&2));
|
||||
|
||||
let cfg = AutoLabelConfig {
|
||||
radar_offset_ms: 60_000,
|
||||
..AutoLabelConfig::default()
|
||||
};
|
||||
let out = auto_label("s1", &frames, &r, cfg).unwrap();
|
||||
assert_eq!(out.windows.len(), 2);
|
||||
assert_eq!(out.windows[0].start_ms, 60_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_empty_gap_after_occupancy_is_dropped() {
|
||||
let r = radar(
|
||||
0,
|
||||
&[
|
||||
Some(1),
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(0),
|
||||
],
|
||||
);
|
||||
let cfg = AutoLabelConfig {
|
||||
empty_hold_ms: 3_000,
|
||||
..AutoLabelConfig::default()
|
||||
};
|
||||
let out = auto_label("s1", &csi(0, 7), &r, cfg).unwrap();
|
||||
// Occupied ends at 1000; empties starting at 1000, 2000, 3000 are within 3 s.
|
||||
assert_eq!(out.skipped.get(&SkipReason::RecentlyOccupied), Some(&3));
|
||||
let kept: Vec<_> = out
|
||||
.windows
|
||||
.iter()
|
||||
.map(|w| (w.start_ms, w.occupancy))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
kept,
|
||||
[
|
||||
(0, Occupancy::Occupied),
|
||||
(4000, Occupancy::Empty),
|
||||
(5000, Occupancy::Empty),
|
||||
(6000, Occupancy::Empty)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_radar_windows_are_counted_by_reason() {
|
||||
let r = radar(0, &[Some(1), None, Some(1)]);
|
||||
let out = auto_label("s1", &csi(0, 3), &r, AutoLabelConfig::default()).unwrap();
|
||||
assert_eq!(out.windows.len(), 2);
|
||||
assert_eq!(out.skipped.get(&SkipReason::RadarUnknown), Some(&1));
|
||||
assert_eq!(out.radar_unknown.get(&UnknownReason::Unverified), Some(&1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_inputs_are_rejected() {
|
||||
let r = radar(0, &[Some(1)]);
|
||||
assert!(auto_label("", &[], &r, AutoLabelConfig::default()).is_err());
|
||||
let cfg = AutoLabelConfig {
|
||||
empty_hold_ms: -1,
|
||||
..AutoLabelConfig::default()
|
||||
};
|
||||
assert!(auto_label("s", &[], &r, cfg).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
//! Session-disjoint splits and the trivial baseline for occupancy labels.
|
||||
//!
|
||||
//! Windows from one session are strongly correlated, so a split that puts
|
||||
//! windows of the same session on both sides leaks. [`session_disjoint_split`]
|
||||
//! splits by session only and refuses a split that cannot be disjoint.
|
||||
//!
|
||||
//! An occupancy or count accuracy means little without the score of a model
|
||||
//! that ignores the CSI entirely. [`majority_baseline`] fits that model on the
|
||||
//! training side (majority occupancy class, median person count) and scores it
|
||||
//! on the test side. Report a model's numbers next to this baseline.
|
||||
//!
|
||||
//! Limits of what this baseline and split establish:
|
||||
//! - A session split does not give independence across rooms or days. Two
|
||||
//! sessions recorded in the same room, or on the same day, share layout,
|
||||
//! occupants and RF environment, so a model can still look better than it
|
||||
//! would in a new room. Split by room or day when the claim needs it.
|
||||
//! - The labeller's empty-hold guard (`empty_hold_ms`) is a backward-looking
|
||||
//! heuristic: it only suppresses an `Empty` label shortly after an occupied
|
||||
//! one and cannot see a person who is present but stationary or out of the
|
||||
//! radar's view.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::autolabel::{LabelledWindow, Occupancy};
|
||||
use crate::error::GroundTruthError;
|
||||
|
||||
/// Train and test windows with no session on both sides.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SessionSplit {
|
||||
/// Sessions on the training side, sorted.
|
||||
pub train_sessions: Vec<String>,
|
||||
/// Sessions on the test side, sorted.
|
||||
pub test_sessions: Vec<String>,
|
||||
/// Training windows.
|
||||
pub train: Vec<LabelledWindow>,
|
||||
/// Test windows.
|
||||
pub test: Vec<LabelledWindow>,
|
||||
}
|
||||
|
||||
/// Split windows by session: sessions named in `test_sessions` go to the test
|
||||
/// side, every other session to training.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GroundTruthError::InvalidSplit`] when a named test session has no
|
||||
/// windows, or when either side would be empty.
|
||||
pub fn session_disjoint_split(
|
||||
windows: &[LabelledWindow],
|
||||
test_sessions: &[&str],
|
||||
) -> Result<SessionSplit, GroundTruthError> {
|
||||
let present: BTreeSet<&str> = windows.iter().map(|w| w.session.as_str()).collect();
|
||||
let test: BTreeSet<&str> = test_sessions.iter().copied().collect();
|
||||
if test.iter().any(|s| !present.contains(s)) {
|
||||
return Err(GroundTruthError::InvalidSplit {
|
||||
reason: "a test session has no labelled windows",
|
||||
});
|
||||
}
|
||||
let (te, tr): (Vec<_>, Vec<_>) = windows
|
||||
.iter()
|
||||
.cloned()
|
||||
.partition(|w| test.contains(w.session.as_str()));
|
||||
if te.is_empty() || tr.is_empty() {
|
||||
return Err(GroundTruthError::InvalidSplit {
|
||||
reason: "both sides of a session split need at least one session",
|
||||
});
|
||||
}
|
||||
Ok(SessionSplit {
|
||||
train_sessions: present.difference(&test).map(|s| (*s).to_owned()).collect(),
|
||||
test_sessions: test.iter().map(|s| (*s).to_owned()).collect(),
|
||||
train: tr,
|
||||
test: te,
|
||||
})
|
||||
}
|
||||
|
||||
/// The no-CSI baseline, fitted on training windows and scored on test windows.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BaselineReport {
|
||||
/// Training windows.
|
||||
pub train_windows: usize,
|
||||
/// Test windows.
|
||||
pub test_windows: usize,
|
||||
/// Test windows per occupancy class.
|
||||
pub test_class_counts: BTreeMap<Occupancy, usize>,
|
||||
/// The training majority class (ties go to `Empty`).
|
||||
pub majority_occupancy: Occupancy,
|
||||
/// Accuracy of always predicting the majority class on the test side.
|
||||
pub majority_accuracy: f64,
|
||||
/// Median training person count (mean of the two middle values for an even
|
||||
/// number of windows), when any training window has a count. The median
|
||||
/// minimises mean absolute error, so it is the strongest constant
|
||||
/// predictor for [`BaselineReport::count_mae`].
|
||||
pub median_count: Option<f64>,
|
||||
/// Mean absolute error of predicting `median_count` on test windows with a
|
||||
/// count.
|
||||
pub count_mae: Option<f64>,
|
||||
/// Test windows with a count.
|
||||
pub count_test_windows: usize,
|
||||
}
|
||||
|
||||
/// Fit and score the majority-class / median-count baseline.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GroundTruthError::InvalidSplit`] when either side is empty.
|
||||
pub fn majority_baseline(
|
||||
train: &[LabelledWindow],
|
||||
test: &[LabelledWindow],
|
||||
) -> Result<BaselineReport, GroundTruthError> {
|
||||
if train.is_empty() || test.is_empty() {
|
||||
return Err(GroundTruthError::InvalidSplit {
|
||||
reason: "baseline needs training and test windows",
|
||||
});
|
||||
}
|
||||
let occupied = train
|
||||
.iter()
|
||||
.filter(|w| w.occupancy == Occupancy::Occupied)
|
||||
.count();
|
||||
let majority_occupancy = if occupied * 2 > train.len() {
|
||||
Occupancy::Occupied
|
||||
} else {
|
||||
Occupancy::Empty
|
||||
};
|
||||
|
||||
let mut test_class_counts = BTreeMap::new();
|
||||
for w in test {
|
||||
*test_class_counts.entry(w.occupancy).or_insert(0usize) += 1;
|
||||
}
|
||||
let hits = test_class_counts
|
||||
.get(&majority_occupancy)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
|
||||
let train_counts: Vec<f64> = train
|
||||
.iter()
|
||||
.filter_map(|w| w.person_count)
|
||||
.map(f64::from)
|
||||
.collect();
|
||||
let median_count = median(&train_counts);
|
||||
let test_counts: Vec<f64> = test
|
||||
.iter()
|
||||
.filter_map(|w| w.person_count)
|
||||
.map(f64::from)
|
||||
.collect();
|
||||
let count_mae = match median_count {
|
||||
Some(m) if !test_counts.is_empty() => {
|
||||
Some(test_counts.iter().map(|c| (c - m).abs()).sum::<f64>() / test_counts.len() as f64)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(BaselineReport {
|
||||
train_windows: train.len(),
|
||||
test_windows: test.len(),
|
||||
test_class_counts,
|
||||
majority_occupancy,
|
||||
majority_accuracy: hits as f64 / test.len() as f64,
|
||||
median_count,
|
||||
count_mae,
|
||||
count_test_windows: test_counts.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Median of `values`; the mean of the two middle values when the length is
|
||||
/// even. `None` for an empty slice.
|
||||
fn median(values: &[f64]) -> Option<f64> {
|
||||
if values.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut v = values.to_vec();
|
||||
v.sort_by(f64::total_cmp);
|
||||
let n = v.len();
|
||||
Some(if n % 2 == 1 {
|
||||
v[n / 2]
|
||||
} else {
|
||||
(v[n / 2 - 1] + v[n / 2]) / 2.0
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::autolabel::CsiWindowSummary;
|
||||
|
||||
fn w(session: &str, i: i64, count: u32) -> LabelledWindow {
|
||||
LabelledWindow {
|
||||
evidence: crate::agreement::EvidenceGrade::Claimed,
|
||||
session: session.into(),
|
||||
start_ms: i * 1000,
|
||||
end_ms: i * 1000 + 1000,
|
||||
occupancy: if count > 0 {
|
||||
Occupancy::Occupied
|
||||
} else {
|
||||
Occupancy::Empty
|
||||
},
|
||||
person_count: Some(count),
|
||||
count_low: Some(count),
|
||||
count_high: Some(count),
|
||||
radar_window_index: i as usize,
|
||||
csi: CsiWindowSummary {
|
||||
n_frames: 10,
|
||||
rssi_mean: None,
|
||||
amplitude_mean: None,
|
||||
amplitude_var: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn windows() -> Vec<LabelledWindow> {
|
||||
let mut v = Vec::new();
|
||||
for (i, c) in [0, 1, 1, 2].iter().enumerate() {
|
||||
v.push(w("mon", i as i64, *c));
|
||||
}
|
||||
for (i, c) in [0, 0, 1].iter().enumerate() {
|
||||
v.push(w("tue", i as i64, *c));
|
||||
}
|
||||
for (i, c) in [1, 0].iter().enumerate() {
|
||||
v.push(w("wed", i as i64, *c));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_keeps_sessions_on_one_side() {
|
||||
let s = session_disjoint_split(&windows(), &["tue"]).unwrap();
|
||||
assert_eq!(s.train_sessions, ["mon", "wed"]);
|
||||
assert_eq!(s.test_sessions, ["tue"]);
|
||||
assert_eq!((s.train.len(), s.test.len()), (6, 3));
|
||||
let train: BTreeSet<_> = s.train.iter().map(|w| &w.session).collect();
|
||||
assert!(s.test.iter().all(|w| !train.contains(&w.session)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impossible_splits_are_refused() {
|
||||
let all = windows();
|
||||
assert!(session_disjoint_split(&all, &[]).is_err());
|
||||
assert!(session_disjoint_split(&all, &["mon", "tue", "wed"]).is_err());
|
||||
assert!(session_disjoint_split(&all, &["thu"]).is_err());
|
||||
let one: Vec<_> = all.into_iter().filter(|w| w.session == "mon").collect();
|
||||
assert!(session_disjoint_split(&one, &["mon"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_matches_hand_computed_fixture() {
|
||||
let s = session_disjoint_split(&windows(), &["tue"]).unwrap();
|
||||
// Train: mon [0,1,1,2] + wed [1,0] -> 4 occupied of 6, median count 1.
|
||||
// Test: tue [0,0,1] -> majority "occupied" is right once in three.
|
||||
let b = majority_baseline(&s.train, &s.test).unwrap();
|
||||
assert_eq!(b.majority_occupancy, Occupancy::Occupied);
|
||||
assert!((b.majority_accuracy - 1.0 / 3.0).abs() < 1e-12);
|
||||
assert!((b.median_count.unwrap() - 1.0).abs() < 1e-12);
|
||||
// Test tue [0,0,1] against a constant 1: errors 1, 1, 0.
|
||||
let mae = 2.0 / 3.0;
|
||||
assert!((b.count_mae.unwrap() - mae).abs() < 1e-12);
|
||||
assert_eq!(b.count_test_windows, 3);
|
||||
assert_eq!(b.test_class_counts.get(&Occupancy::Empty), Some(&2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_is_mae_optimal_and_handles_even_and_odd() {
|
||||
assert_eq!(median(&[]), None);
|
||||
assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0));
|
||||
assert_eq!(median(&[0.0, 0.0, 1.0, 9.0]), Some(0.5));
|
||||
// Skewed train set: the mean (2.0) would score worse on MAE than the
|
||||
// median (0.0) against test counts [0, 0, 0].
|
||||
let train = vec![w("a", 0, 0), w("a", 1, 0), w("a", 2, 6)];
|
||||
let test = vec![w("b", 0, 0), w("b", 1, 0), w("b", 2, 0)];
|
||||
let b = majority_baseline(&train, &test).unwrap();
|
||||
assert_eq!(b.median_count, Some(0.0));
|
||||
assert_eq!(b.count_mae, Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tie_goes_to_empty_and_missing_counts_are_none() {
|
||||
let mut train = vec![w("a", 0, 0), w("a", 1, 1)];
|
||||
for t in &mut train {
|
||||
t.person_count = None;
|
||||
}
|
||||
let test = vec![w("b", 0, 0)];
|
||||
let b = majority_baseline(&train, &test).unwrap();
|
||||
assert_eq!(b.majority_occupancy, Occupancy::Empty);
|
||||
assert_eq!(b.majority_accuracy, 1.0);
|
||||
assert_eq!((b.median_count, b.count_mae), (None, None));
|
||||
assert!(majority_baseline(&[], &test).is_err());
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,21 @@ pub enum GroundTruthError {
|
||||
/// Human-readable reason.
|
||||
reason: &'static str,
|
||||
},
|
||||
/// A row of an untrusted input file could not be read.
|
||||
#[error("row {row}: {reason}")]
|
||||
InvalidRow {
|
||||
/// 1-based row (line) number.
|
||||
row: usize,
|
||||
/// Human-readable reason.
|
||||
reason: String,
|
||||
},
|
||||
/// A train/test split could not be made session-disjoint, or a side was
|
||||
/// empty.
|
||||
#[error("invalid split: {reason}")]
|
||||
InvalidSplit {
|
||||
/// Human-readable reason.
|
||||
reason: &'static str,
|
||||
},
|
||||
/// A failure raised by the [`ruview_evidence`] ledger boundary when
|
||||
/// emitting a record.
|
||||
#[error(transparent)]
|
||||
|
||||
@@ -24,6 +24,14 @@
|
||||
//! └─ to_evidence_record → ruview_evidence ledger
|
||||
//! ```
|
||||
//!
|
||||
//! Radar reference logs (one JSON summary per window) enter through
|
||||
//! [`RadarLabels`], which turns each window into presence, count and range
|
||||
//! labels with an uncertainty and a reason when a window cannot be labelled.
|
||||
//! [`auto_label`] pairs those labels with a RuView recording to make
|
||||
//! occupancy training windows; [`session_disjoint_split`] and
|
||||
//! [`majority_baseline`] give the split and the baseline any accuracy built
|
||||
//! on them must be reported against (see `examples/radar_autolabel.rs`).
|
||||
//!
|
||||
//! ## Honesty and determinism
|
||||
//!
|
||||
//! - **Canonical vocabulary (ADR-300 rule 3):** the report speaks the
|
||||
@@ -48,8 +56,11 @@
|
||||
|
||||
mod agreement;
|
||||
mod align;
|
||||
mod autolabel;
|
||||
mod baseline;
|
||||
mod error;
|
||||
mod model;
|
||||
mod radar;
|
||||
mod scope;
|
||||
mod series;
|
||||
mod source;
|
||||
@@ -58,8 +69,18 @@ pub use agreement::{AgreementMetrics, AgreementReport, EvidenceGrade, GradingPol
|
||||
pub use align::{
|
||||
estimate_alignment, Alignment, AlignmentConfig, MAX_GRID_POINTS, MAX_LAG_STEPS,
|
||||
};
|
||||
pub use autolabel::{
|
||||
auto_label, parse_recording_jsonl, AutoLabelConfig, AutoLabelOutput, CsiFrame,
|
||||
CsiWindowSummary, LabelledWindow, Occupancy, SkipReason, MAX_RECORDING_LINE_BYTES,
|
||||
};
|
||||
pub use baseline::{majority_baseline, session_disjoint_split, BaselineReport, SessionSplit};
|
||||
pub use error::{GroundTruthError, MAX_STR_LEN};
|
||||
pub use model::{DataProvenance, Measurand, Reading, ReadingKind};
|
||||
pub use radar::{
|
||||
label_window, parse_radar_jsonl, LabelStatus, LabelUncertainty, PresenceBasis,
|
||||
RadarAdapterConfig, RadarLabel, RadarLabels, RadarTarget, RadarWindow, RadarWindowSource,
|
||||
UnknownReason, LABEL_ABSENT, LABEL_PRESENT, MAX_RADAR_LINE_BYTES, MAX_RADAR_TARGETS,
|
||||
};
|
||||
pub use scope::{DistanceBand, LineOfSight, MotionState, SessionScope, MAX_SUBJECTS};
|
||||
pub use series::{EstimateSeries, ReferenceObservation, ReferenceSeries, MAX_SAMPLES};
|
||||
pub use source::{ReferenceModality, ReferenceSource};
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
//! mmWave radar windows as reference labels (ADR-303 §1).
|
||||
//!
|
||||
//! A radar reader (an LD2450-class target tracker, an LD6002/MR60-class vitals
|
||||
//! radar, or anything that writes the same shape) summarises its UART stream
|
||||
//! into one JSON object per time window. This module turns those windows into
|
||||
//! reference labels for presence, person count and nearest-target range, each
|
||||
//! carrying where it came from and how uncertain it is, and then into the
|
||||
//! crate's [`ReferenceSeries`] so the usual alignment and agreement machinery
|
||||
//! applies.
|
||||
//!
|
||||
//! The radar stays on the validation plane: nothing here feeds an estimator.
|
||||
//!
|
||||
//! ## Input shape
|
||||
//!
|
||||
//! One JSON object per line. Every field except `timestamp_ms` is optional and
|
||||
//! unknown fields are ignored, so a reader that reports less (no targets, no
|
||||
//! presence flag) still parses:
|
||||
//!
|
||||
//! ```json
|
||||
//! {"timestamp_ms": 1700000001000, "window_start_ms": 1700000000000,
|
||||
//! "health": "ok", "quality": 1.0,
|
||||
//! "source": {"verified": true, "simulated": false},
|
||||
//! "presence": true, "target_count": 2, "max_target_count": 2,
|
||||
//! "distance_cm": 140.0,
|
||||
//! "targets": [{"x_m": 0.4, "y_m": 1.3}, {"x_m": -0.9, "y_m": 2.2}]}
|
||||
//! ```
|
||||
//!
|
||||
//! ## What a label means
|
||||
//!
|
||||
//! - **Presence** comes from the explicit `presence` flag when there is one,
|
||||
//! otherwise from the count. If the flag and the count disagree, the window is
|
||||
//! [`LabelStatus::Unknown`], never resolved by guessing.
|
||||
//! - **Count** is reported as an interval: the low and high of every count the
|
||||
//! window carries (`target_count`, `max_target_count`, the number of
|
||||
//! `targets`). The label value is the latest-frame count when present. A
|
||||
//! radar that tracks moving targets can miss a person who is sitting still,
|
||||
//! so a count is a lower bound in practice; the interval only captures
|
||||
//! disagreement inside the window.
|
||||
//! - **Range** is the nearest target's planar distance `sqrt(x² + y²)`, or
|
||||
//! `distance_cm` when no target positions are given. Its uncertainty is the
|
||||
//! configured `range_sigma_m`, which is a datasheet figure (CLAIMED), not a
|
||||
//! measured one.
|
||||
//! - A window from a simulated source, an unverified source, or a reader that
|
||||
//! reports `health: "no_source"` is `Unknown` with a reason.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::agreement::EvidenceGrade;
|
||||
use crate::error::{check_bound, GroundTruthError};
|
||||
use crate::model::Measurand;
|
||||
use crate::series::{ReferenceObservation, ReferenceSeries, MAX_SAMPLES};
|
||||
use crate::source::{ReferenceModality, ReferenceSource};
|
||||
|
||||
/// The largest single JSONL line accepted, in bytes, bounding allocation.
|
||||
pub const MAX_RADAR_LINE_BYTES: usize = 64 * 1024;
|
||||
/// The most targets read from one window; extra entries are ignored.
|
||||
pub const MAX_RADAR_TARGETS: usize = 64;
|
||||
/// Label used for an occupied window in a presence series.
|
||||
pub const LABEL_PRESENT: &str = "present";
|
||||
/// Label used for an empty window in a presence series.
|
||||
pub const LABEL_ABSENT: &str = "absent";
|
||||
|
||||
/// One target position in the radar's local frame, metres (x across, y away).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RadarTarget {
|
||||
/// Lateral offset, metres.
|
||||
pub x_m: f64,
|
||||
/// Distance away from the radar face, metres.
|
||||
pub y_m: f64,
|
||||
}
|
||||
|
||||
/// Where a radar window came from, as reported by its reader.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RadarWindowSource {
|
||||
/// True only when checksum-valid frames arrived from real hardware.
|
||||
#[serde(default)]
|
||||
pub verified: bool,
|
||||
/// True when the window came from a simulator or replay generator.
|
||||
#[serde(default)]
|
||||
pub simulated: bool,
|
||||
}
|
||||
|
||||
/// One radar summary window as written by a reader.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RadarWindow {
|
||||
/// End of the window, Unix milliseconds.
|
||||
pub timestamp_ms: i64,
|
||||
/// Start of the window, Unix milliseconds.
|
||||
#[serde(default)]
|
||||
pub window_start_ms: Option<i64>,
|
||||
/// Reader health: `ok`, `degraded` or `no_source`.
|
||||
#[serde(default)]
|
||||
pub health: Option<String>,
|
||||
/// Reader-reported frame quality in `[0, 1]`.
|
||||
#[serde(default)]
|
||||
pub quality: Option<f64>,
|
||||
/// Source flags.
|
||||
#[serde(default)]
|
||||
pub source: Option<RadarWindowSource>,
|
||||
/// Explicit presence flag (vitals-class radars).
|
||||
#[serde(default)]
|
||||
pub presence: Option<bool>,
|
||||
/// Targets in the latest frame of the window.
|
||||
#[serde(default)]
|
||||
pub target_count: Option<u32>,
|
||||
/// Most targets in any frame of the window.
|
||||
#[serde(default)]
|
||||
pub max_target_count: Option<u32>,
|
||||
/// Distance to the nearest target, centimetres.
|
||||
#[serde(default)]
|
||||
pub distance_cm: Option<f64>,
|
||||
/// Target positions.
|
||||
#[serde(default)]
|
||||
pub targets: Vec<RadarTarget>,
|
||||
}
|
||||
|
||||
impl RadarWindow {
|
||||
/// The time a label from this window is stamped at: the window midpoint
|
||||
/// when the start is known, else the window end.
|
||||
#[must_use]
|
||||
pub fn label_time_ms(&self) -> i64 {
|
||||
match self.window_start_ms {
|
||||
Some(s) if s <= self.timestamp_ms => s + (self.timestamp_ms - s) / 2,
|
||||
_ => self.timestamp_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse radar windows from JSONL text. Blank lines are skipped. A line that
|
||||
/// is not a valid window is a row-numbered error, never silently dropped.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GroundTruthError::InvalidRow`] for an over-long or malformed line, and
|
||||
/// [`GroundTruthError::TooManySamples`] past [`MAX_SAMPLES`] windows.
|
||||
pub fn parse_radar_jsonl(text: &str) -> Result<Vec<RadarWindow>, GroundTruthError> {
|
||||
let mut out = Vec::new();
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let row = i + 1;
|
||||
if line.len() > MAX_RADAR_LINE_BYTES {
|
||||
return Err(GroundTruthError::InvalidRow {
|
||||
row,
|
||||
reason: format!("line is {} bytes, max {MAX_RADAR_LINE_BYTES}", line.len()),
|
||||
});
|
||||
}
|
||||
let mut w: RadarWindow =
|
||||
serde_json::from_str(line).map_err(|e| GroundTruthError::InvalidRow {
|
||||
row,
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
w.targets.truncate(MAX_RADAR_TARGETS);
|
||||
if let Some(h) = &w.health {
|
||||
check_bound("health", h)?;
|
||||
}
|
||||
out.push(w);
|
||||
if out.len() > MAX_SAMPLES {
|
||||
return Err(GroundTruthError::TooManySamples {
|
||||
len: out.len(),
|
||||
max: MAX_SAMPLES,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How radar windows are turned into labels.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RadarAdapterConfig {
|
||||
/// One-sigma range uncertainty of a single target, metres. A datasheet
|
||||
/// figure; it is reported with every range label.
|
||||
pub range_sigma_m: f64,
|
||||
/// Targets farther than this are ignored as implausible, metres.
|
||||
pub max_range_m: f64,
|
||||
/// Refuse windows whose source is not flagged `verified`.
|
||||
pub require_verified: bool,
|
||||
/// Accept windows from a simulated source. Off by default: a simulated
|
||||
/// radar is not ground truth.
|
||||
pub allow_simulated: bool,
|
||||
/// Windows below this reader quality are refused. `0.0` accepts all.
|
||||
pub min_quality: f64,
|
||||
}
|
||||
|
||||
impl Default for RadarAdapterConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
range_sigma_m: 0.15,
|
||||
max_range_m: 10.0,
|
||||
require_verified: true,
|
||||
allow_simulated: false,
|
||||
min_quality: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RadarAdapterConfig {
|
||||
/// Validate the configuration.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GroundTruthError::InvalidConfig`] for a non-finite or negative sigma,
|
||||
/// a non-positive range limit, or a quality floor outside `[0, 1]`.
|
||||
pub fn validate(&self) -> Result<(), GroundTruthError> {
|
||||
if !self.range_sigma_m.is_finite() || self.range_sigma_m < 0.0 {
|
||||
return Err(GroundTruthError::InvalidConfig {
|
||||
reason: "range_sigma_m must be finite and non-negative",
|
||||
});
|
||||
}
|
||||
if !self.max_range_m.is_finite() || self.max_range_m <= 0.0 {
|
||||
return Err(GroundTruthError::InvalidConfig {
|
||||
reason: "max_range_m must be finite and positive",
|
||||
});
|
||||
}
|
||||
if !(0.0..=1.0).contains(&self.min_quality) {
|
||||
return Err(GroundTruthError::InvalidConfig {
|
||||
reason: "min_quality must be within [0, 1]",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a window produced no label.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum UnknownReason {
|
||||
/// The reader had no sensor data for the window.
|
||||
NoSource,
|
||||
/// The source was not flagged as verified hardware.
|
||||
Unverified,
|
||||
/// The source was a simulator.
|
||||
Simulated,
|
||||
/// The reader's quality was below the configured floor.
|
||||
LowQuality,
|
||||
/// The presence flag and the target count disagree.
|
||||
PresenceCountConflict,
|
||||
/// The window carried neither a presence flag nor any count.
|
||||
NoObservation,
|
||||
/// The window's timestamps were inconsistent (start after end).
|
||||
BadTimestamps,
|
||||
}
|
||||
|
||||
/// Whether a window yielded a label.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "status", content = "reason")]
|
||||
pub enum LabelStatus {
|
||||
/// The window produced a label.
|
||||
Labelled,
|
||||
/// The window produced no label; UNKNOWN is a value, not an error.
|
||||
Unknown(UnknownReason),
|
||||
}
|
||||
|
||||
/// Uncertainty attached to one radar label.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LabelUncertainty {
|
||||
/// Lowest count the window carries.
|
||||
pub count_low: Option<u32>,
|
||||
/// Highest count the window carries.
|
||||
pub count_high: Option<u32>,
|
||||
/// One-sigma range uncertainty, metres (from configuration, CLAIMED).
|
||||
pub range_sigma_m: Option<f64>,
|
||||
/// Reader-reported quality, when given.
|
||||
pub quality: Option<f64>,
|
||||
}
|
||||
|
||||
/// Where a radar label came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PresenceBasis {
|
||||
/// The window's explicit presence flag (agreeing with the count, if any).
|
||||
PresenceFlag,
|
||||
/// Derived from the target count.
|
||||
TargetCount,
|
||||
}
|
||||
|
||||
fn claimed() -> EvidenceGrade {
|
||||
EvidenceGrade::Claimed
|
||||
}
|
||||
|
||||
/// One radar window turned into reference labels.
|
||||
///
|
||||
/// Radar labels are **not** human ground truth: a tracker misses stationary
|
||||
/// people and can ghost on clutter, and its range sigma is a datasheet figure.
|
||||
/// Every label therefore carries [`EvidenceGrade::Claimed`] in `evidence`; it
|
||||
/// is never `Measured` until a labeller checks it against an independent
|
||||
/// human count.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RadarLabel {
|
||||
/// Always [`EvidenceGrade::Claimed`] (radar-derived, not human-verified).
|
||||
#[serde(default = "claimed")]
|
||||
pub evidence: EvidenceGrade,
|
||||
/// Label time, Unix milliseconds (window midpoint, see
|
||||
/// [`RadarWindow::label_time_ms`]).
|
||||
pub at_unix_ms: i64,
|
||||
/// Window start, Unix milliseconds (the end when the reader omits it).
|
||||
pub window_start_ms: i64,
|
||||
/// Window end, Unix milliseconds.
|
||||
pub window_end_ms: i64,
|
||||
/// Whether a label was produced.
|
||||
pub status: LabelStatus,
|
||||
/// Someone present.
|
||||
pub presence: Option<bool>,
|
||||
/// What presence was derived from.
|
||||
pub presence_basis: Option<PresenceBasis>,
|
||||
/// Person count (latest-frame count when given).
|
||||
pub person_count: Option<u32>,
|
||||
/// Nearest-target planar range, metres.
|
||||
pub nearest_range_m: Option<f64>,
|
||||
/// Uncertainty of the above.
|
||||
pub uncertainty: LabelUncertainty,
|
||||
/// 0-based index of the source window in its log.
|
||||
pub window_index: usize,
|
||||
}
|
||||
|
||||
impl RadarLabel {
|
||||
fn unknown(w: &RadarWindow, index: usize, reason: UnknownReason) -> Self {
|
||||
Self {
|
||||
evidence: EvidenceGrade::Claimed,
|
||||
at_unix_ms: w.label_time_ms(),
|
||||
window_start_ms: w.window_start_ms.unwrap_or(w.timestamp_ms),
|
||||
window_end_ms: w.timestamp_ms,
|
||||
status: LabelStatus::Unknown(reason),
|
||||
presence: None,
|
||||
presence_basis: None,
|
||||
person_count: None,
|
||||
nearest_range_m: None,
|
||||
uncertainty: LabelUncertainty {
|
||||
count_low: None,
|
||||
count_high: None,
|
||||
range_sigma_m: None,
|
||||
quality: w.quality,
|
||||
},
|
||||
window_index: index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn one radar window into a label.
|
||||
#[must_use]
|
||||
pub fn label_window(w: &RadarWindow, index: usize, cfg: &RadarAdapterConfig) -> RadarLabel {
|
||||
if matches!(w.window_start_ms, Some(s) if s > w.timestamp_ms) {
|
||||
return RadarLabel::unknown(w, index, UnknownReason::BadTimestamps);
|
||||
}
|
||||
if w.health.as_deref() == Some("no_source") {
|
||||
return RadarLabel::unknown(w, index, UnknownReason::NoSource);
|
||||
}
|
||||
let src = w.source.unwrap_or_default();
|
||||
if src.simulated && !cfg.allow_simulated {
|
||||
return RadarLabel::unknown(w, index, UnknownReason::Simulated);
|
||||
}
|
||||
if cfg.require_verified && !src.verified && !(src.simulated && cfg.allow_simulated) {
|
||||
return RadarLabel::unknown(w, index, UnknownReason::Unverified);
|
||||
}
|
||||
if cfg.min_quality > 0.0 && !matches!(w.quality, Some(q) if q >= cfg.min_quality) {
|
||||
return RadarLabel::unknown(w, index, UnknownReason::LowQuality);
|
||||
}
|
||||
|
||||
let in_range: Vec<&RadarTarget> = w
|
||||
.targets
|
||||
.iter()
|
||||
.filter(|t| t.x_m.is_finite() && t.y_m.is_finite())
|
||||
.filter(|t| t.x_m.hypot(t.y_m) <= cfg.max_range_m)
|
||||
.collect();
|
||||
|
||||
let positions = (!w.targets.is_empty()).then_some(in_range.len() as u32);
|
||||
let counts = [w.target_count, w.max_target_count, positions];
|
||||
let count_low = counts.iter().flatten().copied().min();
|
||||
let count_high = counts.iter().flatten().copied().max();
|
||||
let person_count = w.target_count.or(positions).or(w.max_target_count);
|
||||
|
||||
let (presence, basis) = match (w.presence, person_count) {
|
||||
(Some(p), Some(c)) if p != (c > 0) => {
|
||||
return RadarLabel::unknown(w, index, UnknownReason::PresenceCountConflict);
|
||||
}
|
||||
(Some(p), _) => (p, PresenceBasis::PresenceFlag),
|
||||
(None, Some(c)) => (c > 0, PresenceBasis::TargetCount),
|
||||
(None, None) => return RadarLabel::unknown(w, index, UnknownReason::NoObservation),
|
||||
};
|
||||
|
||||
let nearest_range_m = in_range
|
||||
.iter()
|
||||
.map(|t| t.x_m.hypot(t.y_m))
|
||||
.fold(None, |acc: Option<f64>, r| {
|
||||
Some(acc.map_or(r, |a| a.min(r)))
|
||||
})
|
||||
.or_else(|| {
|
||||
w.distance_cm
|
||||
.filter(|d| presence && d.is_finite() && *d > 0.0)
|
||||
.map(|d| d / 100.0)
|
||||
.filter(|r| *r <= cfg.max_range_m)
|
||||
});
|
||||
|
||||
RadarLabel {
|
||||
evidence: EvidenceGrade::Claimed,
|
||||
at_unix_ms: w.label_time_ms(),
|
||||
window_start_ms: w.window_start_ms.unwrap_or(w.timestamp_ms),
|
||||
window_end_ms: w.timestamp_ms,
|
||||
status: LabelStatus::Labelled,
|
||||
presence: Some(presence),
|
||||
presence_basis: Some(basis),
|
||||
person_count,
|
||||
nearest_range_m,
|
||||
uncertainty: LabelUncertainty {
|
||||
count_low,
|
||||
count_high,
|
||||
range_sigma_m: nearest_range_m.map(|_| cfg.range_sigma_m),
|
||||
quality: w.quality,
|
||||
},
|
||||
window_index: index,
|
||||
}
|
||||
}
|
||||
|
||||
/// Labels for a whole radar log, with the source they are attributed to.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RadarLabels {
|
||||
/// The reference source (always [`ReferenceModality::MmWave`]).
|
||||
pub source: ReferenceSource,
|
||||
/// The configuration the labels were made with.
|
||||
pub config: RadarAdapterConfig,
|
||||
/// One label per window, in log order.
|
||||
pub labels: Vec<RadarLabel>,
|
||||
}
|
||||
|
||||
impl RadarLabels {
|
||||
/// Label every window of a radar log.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`GroundTruthError::InvalidConfig`] for a bad configuration, or a
|
||||
/// metadata error from [`ReferenceSource::new`].
|
||||
pub fn from_windows(
|
||||
name: &str,
|
||||
device: &str,
|
||||
windows: &[RadarWindow],
|
||||
cfg: RadarAdapterConfig,
|
||||
) -> Result<Self, GroundTruthError> {
|
||||
cfg.validate()?;
|
||||
let source = ReferenceSource::new(ReferenceModality::MmWave, name, device, "fmcw-radar")?;
|
||||
let labels = windows
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, w)| label_window(w, i, &cfg))
|
||||
.collect();
|
||||
Ok(Self {
|
||||
source,
|
||||
config: cfg,
|
||||
labels,
|
||||
})
|
||||
}
|
||||
|
||||
/// Labelled windows only.
|
||||
pub fn labelled(&self) -> impl Iterator<Item = &RadarLabel> {
|
||||
self.labels
|
||||
.iter()
|
||||
.filter(|l| l.status == LabelStatus::Labelled)
|
||||
}
|
||||
|
||||
fn series(
|
||||
&self,
|
||||
measurand: Measurand,
|
||||
f: impl Fn(&RadarLabel) -> Option<ReferenceObservation>,
|
||||
) -> Result<ReferenceSeries, GroundTruthError> {
|
||||
let samples = self.labelled().filter_map(f).collect();
|
||||
ReferenceSeries::new(self.source.clone(), measurand, samples)
|
||||
}
|
||||
|
||||
/// A [`Measurand::Presence`] series of `present`/`absent` labels.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`ReferenceSeries::new`] (an empty or non-monotonic series).
|
||||
pub fn presence_series(&self) -> Result<ReferenceSeries, GroundTruthError> {
|
||||
self.series(Measurand::Presence, |l| {
|
||||
l.presence.map(|p| {
|
||||
ReferenceObservation::label(
|
||||
l.at_unix_ms,
|
||||
if p { LABEL_PRESENT } else { LABEL_ABSENT },
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// A [`Measurand::PersonCount`] series.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`ReferenceSeries::new`].
|
||||
pub fn person_count_series(&self) -> Result<ReferenceSeries, GroundTruthError> {
|
||||
self.series(Measurand::PersonCount, |l| {
|
||||
l.person_count
|
||||
.map(|c| ReferenceObservation::scalar(l.at_unix_ms, f64::from(c)))
|
||||
})
|
||||
}
|
||||
|
||||
/// A [`Measurand::RangeMeters`] series of nearest-target range.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`ReferenceSeries::new`].
|
||||
pub fn range_series(&self) -> Result<ReferenceSeries, GroundTruthError> {
|
||||
self.series(Measurand::RangeMeters, |l| {
|
||||
l.nearest_range_m
|
||||
.map(|r| ReferenceObservation::scalar(l.at_unix_ms, r))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Unit tests for the radar window adapter.
|
||||
|
||||
use super::*;
|
||||
use crate::model::Reading;
|
||||
|
||||
fn win(t: i64) -> RadarWindow {
|
||||
RadarWindow {
|
||||
timestamp_ms: t + 1000,
|
||||
window_start_ms: Some(t),
|
||||
health: Some("ok".into()),
|
||||
quality: Some(1.0),
|
||||
source: Some(RadarWindowSource {
|
||||
verified: true,
|
||||
simulated: false,
|
||||
}),
|
||||
presence: None,
|
||||
target_count: None,
|
||||
max_target_count: None,
|
||||
distance_cm: None,
|
||||
targets: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cfg() -> RadarAdapterConfig {
|
||||
RadarAdapterConfig::default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_tracker_and_vitals_lines_and_ignores_unknown_fields() {
|
||||
let text = r#"
|
||||
{"timestamp_ms":2000,"window_start_ms":1000,"health":"ok","quality":1.0,"source":{"kind":"uart","verified":true,"simulated":false},"target_count":2,"max_target_count":3,"targets":[{"slot":1,"x_m":0.3,"y_m":1.2,"speed_mps":0.1},{"slot":2,"x_m":-1.0,"y_m":2.0}],"frames":10}
|
||||
|
||||
{"timestamp_ms":3000,"presence":true,"distance_cm":150.0,"heart_rate_bpm":61.0}
|
||||
"#;
|
||||
let w = parse_radar_jsonl(text).unwrap();
|
||||
assert_eq!(w.len(), 2);
|
||||
assert_eq!(w[0].targets.len(), 2);
|
||||
assert_eq!(w[0].max_target_count, Some(3));
|
||||
assert_eq!(w[1].presence, Some(true));
|
||||
assert_eq!(w[1].window_start_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_line_is_a_row_numbered_error() {
|
||||
let err = parse_radar_jsonl("{\"timestamp_ms\":1}\n\nnot json\n").unwrap_err();
|
||||
assert!(matches!(err, GroundTruthError::InvalidRow { row: 3, .. }));
|
||||
let err = parse_radar_jsonl("{\"presence\":true}").unwrap_err();
|
||||
assert!(matches!(err, GroundTruthError::InvalidRow { row: 1, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_long_line_is_rejected() {
|
||||
let line = format!(
|
||||
"{{\"timestamp_ms\":1,\"health\":\"{}\"}}",
|
||||
"x".repeat(MAX_RADAR_LINE_BYTES)
|
||||
);
|
||||
let err = parse_radar_jsonl(&line).unwrap_err();
|
||||
assert!(matches!(err, GroundTruthError::InvalidRow { row: 1, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracker_window_gives_count_interval_and_nearest_range() {
|
||||
let mut w = win(0);
|
||||
w.target_count = Some(2);
|
||||
w.max_target_count = Some(3);
|
||||
w.targets = vec![
|
||||
RadarTarget { x_m: 0.6, y_m: 0.8 },
|
||||
RadarTarget { x_m: 0.0, y_m: 2.5 },
|
||||
];
|
||||
let l = label_window(&w, 0, &cfg());
|
||||
assert_eq!(l.status, LabelStatus::Labelled);
|
||||
assert_eq!(l.presence, Some(true));
|
||||
assert_eq!(l.presence_basis, Some(PresenceBasis::TargetCount));
|
||||
assert_eq!(l.person_count, Some(2));
|
||||
assert_eq!(
|
||||
(l.uncertainty.count_low, l.uncertainty.count_high),
|
||||
(Some(2), Some(3))
|
||||
);
|
||||
assert!((l.nearest_range_m.unwrap() - 1.0).abs() < 1e-12);
|
||||
assert_eq!(l.uncertainty.range_sigma_m, Some(0.15));
|
||||
assert_eq!(l.at_unix_ms, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tracker_window_is_absent_with_zero_count() {
|
||||
let mut w = win(0);
|
||||
w.target_count = Some(0);
|
||||
let l = label_window(&w, 0, &cfg());
|
||||
assert_eq!(l.presence, Some(false));
|
||||
assert_eq!(l.person_count, Some(0));
|
||||
assert_eq!(l.nearest_range_m, None);
|
||||
assert_eq!(l.uncertainty.range_sigma_m, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vitals_window_uses_presence_flag_and_distance() {
|
||||
let mut w = win(0);
|
||||
w.presence = Some(true);
|
||||
w.distance_cm = Some(180.0);
|
||||
let l = label_window(&w, 0, &cfg());
|
||||
assert_eq!(l.presence_basis, Some(PresenceBasis::PresenceFlag));
|
||||
assert_eq!(l.person_count, None);
|
||||
assert!((l.nearest_range_m.unwrap() - 1.8).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_count_conflict_is_unknown() {
|
||||
let mut w = win(0);
|
||||
w.presence = Some(false);
|
||||
w.target_count = Some(1);
|
||||
let l = label_window(&w, 0, &cfg());
|
||||
assert_eq!(
|
||||
l.status,
|
||||
LabelStatus::Unknown(UnknownReason::PresenceCountConflict)
|
||||
);
|
||||
assert_eq!(l.presence, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untrusted_sources_are_unknown() {
|
||||
let mut w = win(0);
|
||||
w.target_count = Some(1);
|
||||
|
||||
let mut sim = w.clone();
|
||||
sim.source = Some(RadarWindowSource {
|
||||
verified: false,
|
||||
simulated: true,
|
||||
});
|
||||
assert_eq!(
|
||||
label_window(&sim, 0, &cfg()).status,
|
||||
LabelStatus::Unknown(UnknownReason::Simulated)
|
||||
);
|
||||
let allow = RadarAdapterConfig {
|
||||
allow_simulated: true,
|
||||
..cfg()
|
||||
};
|
||||
assert_eq!(label_window(&sim, 0, &allow).status, LabelStatus::Labelled);
|
||||
|
||||
let mut unv = w.clone();
|
||||
unv.source = None;
|
||||
assert_eq!(
|
||||
label_window(&unv, 0, &cfg()).status,
|
||||
LabelStatus::Unknown(UnknownReason::Unverified)
|
||||
);
|
||||
let lax = RadarAdapterConfig {
|
||||
require_verified: false,
|
||||
..cfg()
|
||||
};
|
||||
assert_eq!(label_window(&unv, 0, &lax).status, LabelStatus::Labelled);
|
||||
|
||||
let mut dead = w.clone();
|
||||
dead.health = Some("no_source".into());
|
||||
assert_eq!(
|
||||
label_window(&dead, 0, &cfg()).status,
|
||||
LabelStatus::Unknown(UnknownReason::NoSource)
|
||||
);
|
||||
|
||||
let mut low = w.clone();
|
||||
low.quality = Some(0.2);
|
||||
let strict = RadarAdapterConfig {
|
||||
min_quality: 0.5,
|
||||
..cfg()
|
||||
};
|
||||
assert_eq!(
|
||||
label_window(&low, 0, &strict).status,
|
||||
LabelStatus::Unknown(UnknownReason::LowQuality)
|
||||
);
|
||||
low.quality = None;
|
||||
assert_eq!(
|
||||
label_window(&low, 0, &strict).status,
|
||||
LabelStatus::Unknown(UnknownReason::LowQuality)
|
||||
);
|
||||
|
||||
let mut nothing = win(0);
|
||||
nothing.targets.clear();
|
||||
assert_eq!(
|
||||
label_window(¬hing, 0, &cfg()).status,
|
||||
LabelStatus::Unknown(UnknownReason::NoObservation)
|
||||
);
|
||||
|
||||
let mut bad = w;
|
||||
bad.window_start_ms = Some(bad.timestamp_ms + 1);
|
||||
assert_eq!(
|
||||
label_window(&bad, 0, &cfg()).status,
|
||||
LabelStatus::Unknown(UnknownReason::BadTimestamps)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_and_non_finite_targets_are_ignored() {
|
||||
let mut w = win(0);
|
||||
w.targets = vec![
|
||||
RadarTarget {
|
||||
x_m: 0.0,
|
||||
y_m: 40.0,
|
||||
},
|
||||
RadarTarget {
|
||||
x_m: f64::NAN,
|
||||
y_m: 1.0,
|
||||
},
|
||||
];
|
||||
let l = label_window(&w, 0, &cfg());
|
||||
assert_eq!(l.person_count, Some(0));
|
||||
assert_eq!(l.presence, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn series_feed_the_agreement_machinery() {
|
||||
let mut windows = Vec::new();
|
||||
for (i, n) in [0u32, 1, 2, 1].iter().enumerate() {
|
||||
let mut w = win(i as i64 * 1000);
|
||||
w.target_count = Some(*n);
|
||||
w.targets = (0..*n)
|
||||
.map(|k| RadarTarget {
|
||||
x_m: 0.0,
|
||||
y_m: 1.0 + f64::from(k),
|
||||
})
|
||||
.collect();
|
||||
windows.push(w);
|
||||
}
|
||||
let mut unknown = win(10_000);
|
||||
unknown.source = None;
|
||||
windows.push(unknown);
|
||||
|
||||
let labels = RadarLabels::from_windows("radar-1", "ld2450", &windows, cfg()).unwrap();
|
||||
assert_eq!(labels.labels.len(), 5);
|
||||
assert_eq!(labels.labelled().count(), 4);
|
||||
assert_eq!(labels.source.modality, ReferenceModality::MmWave);
|
||||
|
||||
let p = labels.presence_series().unwrap();
|
||||
let got: Vec<&str> = p
|
||||
.samples()
|
||||
.iter()
|
||||
.filter_map(|s| s.reading.as_label())
|
||||
.collect();
|
||||
assert_eq!(got, ["absent", "present", "present", "present"]);
|
||||
|
||||
let c = labels.person_count_series().unwrap();
|
||||
assert_eq!(c.samples()[2].reading, Reading::Scalar(2.0));
|
||||
|
||||
let r = labels.range_series().unwrap();
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r.samples()[0].reading, Reading::Scalar(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_config_is_rejected() {
|
||||
let bad = RadarAdapterConfig {
|
||||
range_sigma_m: -1.0,
|
||||
..cfg()
|
||||
};
|
||||
assert!(RadarLabels::from_windows("r", "d", &[], bad).is_err());
|
||||
let bad = RadarAdapterConfig {
|
||||
min_quality: 2.0,
|
||||
..cfg()
|
||||
};
|
||||
assert!(bad.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radar_presence_grades_an_rf_estimate() {
|
||||
use crate::{
|
||||
AgreementMetrics, AgreementReport, AlignmentConfig, DataProvenance, DistanceBand,
|
||||
EstimateSeries, GradingPolicy, LineOfSight, MotionState, SessionScope,
|
||||
};
|
||||
use ruview_ontology::EvidenceLevel;
|
||||
|
||||
let truth = [0u32, 0, 1, 1, 1, 0];
|
||||
let windows: Vec<RadarWindow> = truth
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| {
|
||||
let mut w = win(i as i64 * 1000);
|
||||
w.target_count = Some(*n);
|
||||
w
|
||||
})
|
||||
.collect();
|
||||
let labels = RadarLabels::from_windows("radar-1", "ld2450", &windows, cfg()).unwrap();
|
||||
let reference = labels.presence_series().unwrap();
|
||||
|
||||
// RF estimate stamped at the same midpoints, wrong on one window.
|
||||
let est_labels = [
|
||||
"absent", "present", "present", "present", "present", "absent",
|
||||
];
|
||||
let est = EstimateSeries::new(
|
||||
Measurand::Presence,
|
||||
"rf-presence-v1",
|
||||
DataProvenance::Real,
|
||||
est_labels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| ReferenceObservation::label(i as i64 * 1000 + 500, *l))
|
||||
.collect(),
|
||||
)
|
||||
.unwrap();
|
||||
let scope =
|
||||
SessionScope::new(1, MotionState::Mixed, LineOfSight::Los, DistanceBand::Near).unwrap();
|
||||
let align = AlignmentConfig {
|
||||
grid_ms: 1000,
|
||||
max_lag_ms: 0,
|
||||
max_gap_ms: 400,
|
||||
};
|
||||
let policy = GradingPolicy::new(0.5, EvidenceLevel::L2, None).unwrap();
|
||||
let report = AgreementReport::build(&est, &reference, scope, &align, 0.0, &policy).unwrap();
|
||||
match report.metrics {
|
||||
AgreementMetrics::Categorical { n_agree, .. } => assert_eq!(n_agree, 5),
|
||||
other => panic!("expected categorical metrics, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_are_deterministic() {
|
||||
let mut w = win(0);
|
||||
w.target_count = Some(1);
|
||||
w.targets = vec![RadarTarget { x_m: 0.3, y_m: 0.4 }];
|
||||
assert_eq!(label_window(&w, 0, &cfg()), label_window(&w, 0, &cfg()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn radar_labels_are_claimed_never_measured() {
|
||||
use crate::agreement::EvidenceGrade;
|
||||
let mut present = win(0);
|
||||
present.target_count = Some(1);
|
||||
let unknown = win(1000); // no observation -> Unknown
|
||||
for w in [present, unknown] {
|
||||
assert_eq!(label_window(&w, 0, &cfg()).evidence, EvidenceGrade::Claimed);
|
||||
}
|
||||
// A label deserialised from older JSON without the field is also Claimed.
|
||||
let mut v = serde_json::to_value(label_window(&win(0), 0, &cfg())).unwrap();
|
||||
v.as_object_mut().unwrap().remove("evidence");
|
||||
let back: RadarLabel = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back.evidence, EvidenceGrade::Claimed);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! End-to-end on SYNTHETIC data: recording + radar JSONL text through
|
||||
//! labelling, a session-disjoint split and the baseline.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use ruview_groundtruth::{
|
||||
auto_label, majority_baseline, parse_radar_jsonl, parse_recording_jsonl,
|
||||
session_disjoint_split, AutoLabelConfig, LabelledWindow, Occupancy, RadarAdapterConfig,
|
||||
RadarLabels,
|
||||
};
|
||||
|
||||
/// A session: 10 Hz CSI and one 1 s radar window per entry of `counts`. The
|
||||
/// radar clock runs `skew_ms` behind the recording.
|
||||
fn session(t0_ms: i64, skew_ms: i64, counts: &[u32]) -> (String, String) {
|
||||
let mut rec = String::new();
|
||||
for i in 0..counts.len() as i64 * 10 {
|
||||
let t = (t0_ms + i * 100) as f64 / 1000.0;
|
||||
rec.push_str(&format!(
|
||||
"{{\"timestamp\":{t},\"subcarriers\":[1.0,{}],\"rssi\":-55.0,\"noise_floor\":-90.0,\"features\":{{}}}}\n",
|
||||
1 + i % 3
|
||||
));
|
||||
}
|
||||
let mut radar = String::new();
|
||||
for (i, c) in counts.iter().enumerate() {
|
||||
let start = t0_ms - skew_ms + i as i64 * 1000;
|
||||
let targets: Vec<String> = (0..*c)
|
||||
.map(|k| format!("{{\"x_m\":0.2,\"y_m\":{}}}", 1 + k))
|
||||
.collect();
|
||||
radar.push_str(&format!(
|
||||
"{{\"timestamp_ms\":{},\"window_start_ms\":{start},\"health\":\"ok\",\"source\":{{\"verified\":true}},\"target_count\":{c},\"targets\":[{}]}}\n",
|
||||
start + 1000,
|
||||
targets.join(",")
|
||||
));
|
||||
}
|
||||
(rec, radar)
|
||||
}
|
||||
|
||||
fn label(name: &str, t0_ms: i64, counts: &[u32]) -> Vec<LabelledWindow> {
|
||||
let skew = 2_500;
|
||||
let (rec, radar) = session(t0_ms, skew, counts);
|
||||
let frames = parse_recording_jsonl(&rec).unwrap();
|
||||
let windows = parse_radar_jsonl(&radar).unwrap();
|
||||
let labels =
|
||||
RadarLabels::from_windows(name, "ld2450", &windows, RadarAdapterConfig::default()).unwrap();
|
||||
let cfg = AutoLabelConfig {
|
||||
radar_offset_ms: skew,
|
||||
min_csi_frames: 5,
|
||||
empty_hold_ms: 0,
|
||||
};
|
||||
let out = auto_label(name, &frames, &labels, cfg).unwrap();
|
||||
assert_eq!(out.windows.len(), counts.len(), "{name}: {:?}", out.skipped);
|
||||
out.windows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_sessions_label_split_and_baseline() {
|
||||
let mut all = label("a", 1_000_000, &[0, 1, 1, 2, 0]);
|
||||
all.extend(label("b", 2_000_000, &[0, 0, 0, 1]));
|
||||
all.extend(label("c", 3_000_000, &[1, 1, 0]));
|
||||
|
||||
// The clock offset lands each label on its own second of CSI.
|
||||
assert!(all.iter().all(|w| w.csi.n_frames == 10));
|
||||
assert_eq!(all[3].person_count, Some(2));
|
||||
assert_eq!(all[0].occupancy, Occupancy::Empty);
|
||||
// Radar-derived labels are never human ground truth.
|
||||
assert!(all.iter().all(|w| w.evidence == ruview_groundtruth::EvidenceGrade::Claimed));
|
||||
|
||||
let split = session_disjoint_split(&all, &["b"]).unwrap();
|
||||
let train: BTreeSet<_> = split.train.iter().map(|w| w.session.clone()).collect();
|
||||
let test: BTreeSet<_> = split.test.iter().map(|w| w.session.clone()).collect();
|
||||
assert!(train.is_disjoint(&test));
|
||||
|
||||
// Train a+c: occupied 5 of 8 -> majority occupied; test b is 1 of 4 occupied.
|
||||
let b = majority_baseline(&split.train, &split.test).unwrap();
|
||||
assert_eq!(b.majority_occupancy, Occupancy::Occupied);
|
||||
assert!((b.majority_accuracy - 0.25).abs() < 1e-12);
|
||||
// Train a+c counts sorted: 0,0,0,1,1,1,1,2 -> median 1.
|
||||
assert!((b.median_count.unwrap() - 1.0).abs() < 1e-12);
|
||||
|
||||
// Same inputs, same outputs.
|
||||
let again = majority_baseline(&split.train, &split.test).unwrap();
|
||||
assert_eq!(b, again);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "ruview-spatial-evidence"
|
||||
description = "Emit RuView RF Gaussians and link measurements as spatial.evidence.v1 JSONL (ADR-382)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
# Standalone emitter: the v1 wire shapes are mirrored here with local serde
|
||||
# structs, so this crate never depends on the WeftOS consumer. The only
|
||||
# internal dependency is `ruview-unified`, the source of the Gaussians and
|
||||
# of the Friis free-space reference.
|
||||
[dependencies]
|
||||
ruview-unified = { path = "../ruview-unified" }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
missing_docs = "warn"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
@@ -0,0 +1,128 @@
|
||||
//! `RfGaussian` / `GaussianMap` to `rf_gaussian` records.
|
||||
|
||||
use ruview_unified::gaussian::{GaussianMap, MotionState, RfGaussian};
|
||||
|
||||
use crate::wire::{
|
||||
sanitize_id, EvidenceError, EvidenceRecord, Motion, ProofTag, Provenance, RecordBody,
|
||||
RecordMeta, RfGaussianRecord,
|
||||
};
|
||||
|
||||
/// Source id used when a Gaussian's device id sanitises to nothing.
|
||||
const FALLBACK_SOURCE: &str = "ruview-unified";
|
||||
/// Bounds for the envelope uncertainty derived from a Gaussian's scale.
|
||||
const MIN_UNCERTAINTY_M: f64 = 1e-3;
|
||||
const MAX_UNCERTAINTY_M: f64 = 100.0;
|
||||
|
||||
/// Caller context for exporting Gaussians.
|
||||
///
|
||||
/// Build it with [`GaussianExport::new`], which defaults the proof tag to
|
||||
/// `CODE`. A non-synthetic record is `MEASURED` only when the caller also names
|
||||
/// a reproducer ([`GaussianExport::measured`]); asking for `MEASURED` without
|
||||
/// one is an error, so the tag cannot be raised by assertion alone.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct GaussianExport {
|
||||
/// Urth region id the room frame belongs to.
|
||||
pub region: String,
|
||||
/// Producer id written into provenance.
|
||||
pub producer: String,
|
||||
/// Strongest proof tag the caller claims. A Gaussian with synthetic
|
||||
/// RuView provenance is emitted as `SYNTHETIC` regardless.
|
||||
pub proof: ProofTag,
|
||||
/// Id of the reproducer (script, capture or log) behind a `MEASURED` claim.
|
||||
/// Required, non-blank, when `proof` is `MEASURED`. It is a gate only: v1
|
||||
/// has no wire field for it, so it is not serialised.
|
||||
pub reproducer: Option<String>,
|
||||
}
|
||||
|
||||
impl GaussianExport {
|
||||
/// Context with the default proof tag, `CODE`, and no reproducer.
|
||||
pub fn new(region: impl Into<String>, producer: impl Into<String>) -> Self {
|
||||
Self {
|
||||
region: region.into(),
|
||||
producer: producer.into(),
|
||||
proof: ProofTag::Code,
|
||||
reproducer: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Claim `MEASURED`, backed by the named reproducer.
|
||||
#[must_use]
|
||||
pub fn measured(mut self, reproducer: impl Into<String>) -> Self {
|
||||
self.proof = ProofTag::Measured;
|
||||
self.reproducer = Some(reproducer.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
fn motion(m: MotionState) -> Motion {
|
||||
match m {
|
||||
MotionState::Static => Motion::Static,
|
||||
MotionState::Slow => Motion::Slow,
|
||||
MotionState::Fast => Motion::Fast,
|
||||
}
|
||||
}
|
||||
|
||||
/// Envelope uncertainty for a Gaussian: the geometric mean of its three
|
||||
/// σ, clamped to the v1 range. `RfGaussian` has no separate position
|
||||
/// covariance, so its extent is the honest stand-in.
|
||||
fn uncertainty(scale: [f64; 3]) -> f64 {
|
||||
let gm = (scale[0] * scale[1] * scale[2]).cbrt();
|
||||
gm.clamp(MIN_UNCERTAINTY_M, MAX_UNCERTAINTY_M)
|
||||
}
|
||||
|
||||
/// Convert one Gaussian. `index` is its position in the map and makes the
|
||||
/// receipt unique within one export.
|
||||
pub fn gaussian_to_record(
|
||||
g: &RfGaussian,
|
||||
index: usize,
|
||||
ctx: &GaussianExport,
|
||||
) -> Result<EvidenceRecord, EvidenceError> {
|
||||
let source_id = sanitize_id(&g.provenance.device_id, FALLBACK_SOURCE);
|
||||
let proof = if g.provenance.synthetic {
|
||||
ProofTag::Synthetic
|
||||
} else if ctx.proof == ProofTag::Measured
|
||||
&& ctx.reproducer.as_deref().map_or(true, |r| r.trim().is_empty())
|
||||
{
|
||||
return Err(EvidenceError::MissingReproducer);
|
||||
} else {
|
||||
ctx.proof
|
||||
};
|
||||
let receipt = sanitize_id(
|
||||
&format!("gauss:{source_id}:{}:{index}", g.timestamp_ns),
|
||||
"gauss",
|
||||
);
|
||||
let meta = RecordMeta {
|
||||
t_ns: g.timestamp_ns,
|
||||
region: ctx.region.clone(),
|
||||
source_id,
|
||||
uncertainty_m: uncertainty(g.scale),
|
||||
provenance: Provenance {
|
||||
receipt,
|
||||
producer: ctx.producer.clone(),
|
||||
proof,
|
||||
},
|
||||
};
|
||||
let body = RfGaussianRecord {
|
||||
position: g.position,
|
||||
scale: g.scale,
|
||||
orientation: g.orientation,
|
||||
occupancy: g.occupancy,
|
||||
confidence: g.confidence,
|
||||
motion: motion(g.motion),
|
||||
role: None,
|
||||
};
|
||||
EvidenceRecord::new(meta, RecordBody::RfGaussian(body))
|
||||
}
|
||||
|
||||
/// Convert every Gaussian in a map, in the map's storage order. Fails on
|
||||
/// the first Gaussian that does not satisfy the v1 rules.
|
||||
pub fn map_to_records(
|
||||
map: &GaussianMap,
|
||||
ctx: &GaussianExport,
|
||||
) -> Result<Vec<EvidenceRecord>, EvidenceError> {
|
||||
map.gaussians()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, g)| gaussian_to_record(g, i, ctx))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
//! # `ruview-spatial-evidence` — RuView output as `spatial.evidence.v1` JSONL
|
||||
//!
|
||||
//! `spatial.evidence.v1` is the versioned, one-record-per-line wire format
|
||||
//! of the WeftOS spatial evidence engine. ADR-382 records the RF part of the
|
||||
//! contract in this repository; WeftOS ADR-107 §7 is its origin. This crate is
|
||||
//! the RuView-side emitter for the two RF record types RuView can produce:
|
||||
//!
|
||||
//! - [`gaussian`]: `ruview_unified` [`RfGaussian`](ruview_unified::gaussian::RfGaussian)s
|
||||
//! (one by one or a whole [`GaussianMap`](ruview_unified::gaussian::GaussianMap))
|
||||
//! become `rf_gaussian` records.
|
||||
//! - [`link`]: a TX/RX link with a measured and a free-space amplitude
|
||||
//! becomes an `rf_link_observation` record carrying `excess_loss_db`.
|
||||
//!
|
||||
//! The wire shapes live in [`wire`] as local serde structs. Nothing here
|
||||
//! depends on WeftOS: the contract is the JSON, not a shared crate.
|
||||
//! Every record is validated against the v1 rules before it is serialised
|
||||
//! ([`to_line`]), so a bad value fails here rather than in the consumer.
|
||||
//!
|
||||
//! ## Proof tags
|
||||
//!
|
||||
//! Each record carries `provenance.proof` (`MEASURED` / `CODE` /
|
||||
//! `SYNTHETIC`). A Gaussian whose RuView provenance says `synthetic` is
|
||||
//! always emitted as `SYNTHETIC`, whatever the caller asks for; the tag can
|
||||
//! be lowered by the caller, never raised. The default tag is `CODE`
|
||||
//! ([`GaussianExport::new`]); `MEASURED` needs a non-empty reproducer id
|
||||
//! ([`GaussianExport::measured`]) or the export fails with
|
||||
//! [`EvidenceError::MissingReproducer`]. The link path still takes the proof
|
||||
//! tag from the caller's [`RecordMeta`] unchecked.
|
||||
//!
|
||||
//! ## Golden fixtures
|
||||
//!
|
||||
//! The golden lines in `tests/evidence.rs` are copied from WeftOS ADR-107 §7.
|
||||
//! Origin: WeftOS ADR-107 contract, no in-repo spec beyond ADR-382's summary
|
||||
//! of it.
|
||||
|
||||
pub mod gaussian;
|
||||
pub mod link;
|
||||
pub mod wire;
|
||||
|
||||
pub use gaussian::{gaussian_to_record, map_to_records, GaussianExport};
|
||||
pub use link::{free_space_amplitude, LinkMeasurement};
|
||||
pub use wire::{
|
||||
parse_line, to_jsonl, to_line, EvidenceError, EvidenceRecord, Frame, Motion, ProofTag,
|
||||
Provenance, RecordBody, RecordMeta, RfGaussianRecord, RfLinkObservation, RfRole,
|
||||
MAX_LINE_BYTES, SCHEMA_V1,
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Link measurements to `rf_link_observation` records.
|
||||
|
||||
use ruview_unified::gaussian::{channel_gain, GaussianMap};
|
||||
|
||||
use crate::wire::{EvidenceError, EvidenceRecord, RecordBody, RecordMeta, RfLinkObservation};
|
||||
|
||||
/// Friis free-space amplitude `λ / (4πd)` between two antennas, taken from
|
||||
/// `ruview_unified`'s channel model with an empty map so the reference is
|
||||
/// exactly the one RuView's inverse update uses. `None` when the antennas
|
||||
/// coincide or the frequency is not a positive finite number.
|
||||
pub fn free_space_amplitude(tx: [f64; 3], rx: [f64; 3], freq_hz: f64) -> Option<f64> {
|
||||
let d2: f64 = (0..3).map(|i| (rx[i] - tx[i]).powi(2)).sum();
|
||||
if !(d2.is_finite() && d2 > 1e-18 && freq_hz.is_finite() && freq_hz > 0.0) {
|
||||
return None;
|
||||
}
|
||||
Some(channel_gain(&GaussianMap::new(1.0), tx, rx, freq_hz).norm())
|
||||
}
|
||||
|
||||
/// One link: geometry plus measured and free-space amplitudes (linear,
|
||||
/// same units).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LinkMeasurement {
|
||||
/// Transmitter antenna position, metres.
|
||||
pub tx: [f64; 3],
|
||||
/// Receiver antenna position, metres.
|
||||
pub rx: [f64; 3],
|
||||
/// Carrier frequency, Hz.
|
||||
pub freq_hz: f64,
|
||||
/// Measured amplitude.
|
||||
pub measured_amplitude: f64,
|
||||
/// Free-space (reference) amplitude.
|
||||
pub free_space_amplitude: f64,
|
||||
}
|
||||
|
||||
impl LinkMeasurement {
|
||||
/// A link whose reference is the Friis amplitude for its geometry.
|
||||
pub fn against_friis(
|
||||
tx: [f64; 3],
|
||||
rx: [f64; 3],
|
||||
freq_hz: f64,
|
||||
measured_amplitude: f64,
|
||||
) -> Result<Self, EvidenceError> {
|
||||
let free = free_space_amplitude(tx, rx, freq_hz)
|
||||
.ok_or(EvidenceError::OutOfRange("link geometry"))?;
|
||||
Ok(Self {
|
||||
tx,
|
||||
rx,
|
||||
freq_hz,
|
||||
measured_amplitude,
|
||||
free_space_amplitude: free,
|
||||
})
|
||||
}
|
||||
|
||||
/// `20·log10(free / measured)`: positive when the link is weaker than
|
||||
/// free space. Both amplitudes must be positive and finite.
|
||||
pub fn excess_loss_db(&self) -> Result<f64, EvidenceError> {
|
||||
for (v, field) in [
|
||||
(self.measured_amplitude, "measured_amplitude"),
|
||||
(self.free_space_amplitude, "free_space_amplitude"),
|
||||
] {
|
||||
if !v.is_finite() {
|
||||
return Err(EvidenceError::NonFinite(field));
|
||||
}
|
||||
if v <= 0.0 {
|
||||
return Err(EvidenceError::OutOfRange(field));
|
||||
}
|
||||
}
|
||||
Ok(20.0 * (self.free_space_amplitude / self.measured_amplitude).log10())
|
||||
}
|
||||
|
||||
/// Build a validated `rf_link_observation` record.
|
||||
pub fn to_record(&self, meta: RecordMeta) -> Result<EvidenceRecord, EvidenceError> {
|
||||
let body = RfLinkObservation {
|
||||
tx: self.tx,
|
||||
rx: self.rx,
|
||||
freq_hz: self.freq_hz,
|
||||
excess_loss_db: self.excess_loss_db()?,
|
||||
};
|
||||
EvidenceRecord::new(meta, RecordBody::RfLinkObservation(body))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
//! `spatial.evidence.v1` envelope, the two RF record bodies, and the
|
||||
//! boundary validation rules (ADR-382, which records WeftOS ADR-107 §7).
|
||||
//!
|
||||
//! Only the RF types RuView emits are mirrored. A line of any other `type`
|
||||
//! (`shell_measure`, `pose`, `radar_track_point`, `uwb_echo`, `imu_event`,
|
||||
//! `human_confirm`, `tof_depth`, `radar_range`) is rejected by
|
||||
//! [`parse_line`] here as an unknown type, which is correct for an emitter:
|
||||
//! it never needs to read those lines back.
|
||||
//!
|
||||
//! Angles: every yaw in v1 is degrees counter-clockwise from room +x
|
||||
//! (east), so 0 faces +x and 90 faces +y. The RF types carry no yaw field;
|
||||
//! an `rf_gaussian` orientation is a room-ENU rotation quaternion, whose
|
||||
//! rotation about +z follows the same counter-clockwise sense.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The only schema string v1 accepts.
|
||||
pub const SCHEMA_V1: &str = "spatial.evidence.v1";
|
||||
/// Longest accepted line, bytes.
|
||||
pub const MAX_LINE_BYTES: usize = 16 * 1024;
|
||||
|
||||
const MAX_COORD_M: f64 = 1_000.0;
|
||||
const MAX_UNCERTAINTY_M: f64 = 100.0;
|
||||
const MAX_ID_LEN: usize = 128;
|
||||
|
||||
/// Why a record was rejected.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum EvidenceError {
|
||||
/// Not JSON, wrong shape, or an unknown `type`.
|
||||
#[error("parse error: {0}")]
|
||||
Parse(String),
|
||||
/// `schema` missing or not `spatial.evidence.v1`.
|
||||
#[error("unknown schema version {0:?}")]
|
||||
UnknownVersion(String),
|
||||
/// A numeric field is NaN or infinite.
|
||||
#[error("non-finite value in {0}")]
|
||||
NonFinite(&'static str),
|
||||
/// A numeric field is outside its accepted range.
|
||||
#[error("value out of range in {0}")]
|
||||
OutOfRange(&'static str),
|
||||
/// An id is empty, too long, or has disallowed characters.
|
||||
#[error("invalid text in {0}")]
|
||||
BadText(&'static str),
|
||||
/// Line exceeds [`MAX_LINE_BYTES`].
|
||||
#[error("line is {0} bytes, max {MAX_LINE_BYTES}")]
|
||||
LineTooLong(usize),
|
||||
/// `MEASURED` was requested without a non-empty reproducer id.
|
||||
#[error("MEASURED proof requires a non-empty reproducer id")]
|
||||
MissingReproducer,
|
||||
}
|
||||
|
||||
type R = Result<(), EvidenceError>;
|
||||
|
||||
/// Coordinate frame. v1 has one: room-local ENU, origin at the room's SW
|
||||
/// floor corner, x east, y north, z up, metres.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Frame {
|
||||
/// Room ENU.
|
||||
RoomEnu,
|
||||
}
|
||||
|
||||
/// Proof discipline tag, ordered weakest to strongest.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum ProofTag {
|
||||
/// Simulated or generated.
|
||||
Synthetic,
|
||||
/// Derived by code from other evidence.
|
||||
Code,
|
||||
/// Direct physical measurement with a reproducer.
|
||||
Measured,
|
||||
}
|
||||
|
||||
/// Where a record came from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Provenance {
|
||||
/// Unique receipt id for this record.
|
||||
pub receipt: String,
|
||||
/// Tool that produced the line (`ruview-spatial-evidence@0.1`, ...).
|
||||
pub producer: String,
|
||||
/// MEASURED / CODE / SYNTHETIC.
|
||||
pub proof: ProofTag,
|
||||
}
|
||||
|
||||
/// One evidence line: the common envelope plus a flattened typed body.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EvidenceRecord {
|
||||
/// Always [`SCHEMA_V1`].
|
||||
pub schema: String,
|
||||
/// Observation time, ns since the Unix epoch.
|
||||
pub t_ns: u64,
|
||||
/// Coordinate frame.
|
||||
pub frame: Frame,
|
||||
/// Urth region id (`region/urth/meso/<room>`).
|
||||
pub region: String,
|
||||
/// Device or operator id (never a person).
|
||||
pub source_id: String,
|
||||
/// 1σ position uncertainty, metres, in (0, 100].
|
||||
pub uncertainty_m: f64,
|
||||
/// Provenance envelope.
|
||||
pub provenance: Provenance,
|
||||
/// `type` tag and type-specific fields.
|
||||
#[serde(flatten)]
|
||||
pub body: RecordBody,
|
||||
}
|
||||
|
||||
/// Envelope fields a caller supplies for one record.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RecordMeta {
|
||||
/// Observation time, ns since the Unix epoch.
|
||||
pub t_ns: u64,
|
||||
/// Urth region id.
|
||||
pub region: String,
|
||||
/// Device id.
|
||||
pub source_id: String,
|
||||
/// 1σ position uncertainty, metres.
|
||||
pub uncertainty_m: f64,
|
||||
/// Provenance envelope.
|
||||
pub provenance: Provenance,
|
||||
}
|
||||
|
||||
impl EvidenceRecord {
|
||||
/// Assemble a v1 record and validate it.
|
||||
pub fn new(meta: RecordMeta, body: RecordBody) -> Result<Self, EvidenceError> {
|
||||
let rec = Self {
|
||||
schema: SCHEMA_V1.to_string(),
|
||||
t_ns: meta.t_ns,
|
||||
frame: Frame::RoomEnu,
|
||||
region: meta.region,
|
||||
source_id: meta.source_id,
|
||||
uncertainty_m: meta.uncertainty_m,
|
||||
provenance: meta.provenance,
|
||||
body,
|
||||
};
|
||||
rec.validate()?;
|
||||
Ok(rec)
|
||||
}
|
||||
|
||||
/// Check every field against the v1 rules.
|
||||
pub fn validate(&self) -> R {
|
||||
if self.schema != SCHEMA_V1 {
|
||||
return Err(EvidenceError::UnknownVersion(self.schema.clone()));
|
||||
}
|
||||
id(&self.region, "region")?;
|
||||
id(&self.source_id, "source_id")?;
|
||||
id(&self.provenance.receipt, "provenance.receipt")?;
|
||||
id(&self.provenance.producer, "provenance.producer")?;
|
||||
finite(self.uncertainty_m, "uncertainty_m")?;
|
||||
if !(self.uncertainty_m > 0.0 && self.uncertainty_m <= MAX_UNCERTAINTY_M) {
|
||||
return Err(EvidenceError::OutOfRange("uncertainty_m"));
|
||||
}
|
||||
match &self.body {
|
||||
RecordBody::RfLinkObservation(l) => l.validate(),
|
||||
RecordBody::RfGaussian(g) => g.validate(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The record bodies this crate emits, tagged by `"type"`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum RecordBody {
|
||||
/// One RF link measurement.
|
||||
RfLinkObservation(RfLinkObservation),
|
||||
/// One RF Gaussian (ADR-275 shape).
|
||||
RfGaussian(RfGaussianRecord),
|
||||
}
|
||||
|
||||
/// Excess attenuation over free space between two antennas.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RfLinkObservation {
|
||||
/// Transmitter antenna position, metres.
|
||||
pub tx: [f64; 3],
|
||||
/// Receiver antenna position, metres.
|
||||
pub rx: [f64; 3],
|
||||
/// Carrier frequency, Hz.
|
||||
pub freq_hz: f64,
|
||||
/// Loss beyond Friis, dB (negative = constructive multipath).
|
||||
pub excess_loss_db: f64,
|
||||
}
|
||||
|
||||
impl RfLinkObservation {
|
||||
fn validate(&self) -> R {
|
||||
point(&self.tx, "tx")?;
|
||||
point(&self.rx, "rx")?;
|
||||
in_range(self.freq_hz, 1e8, 3e11, "freq_hz")?;
|
||||
in_range(self.excess_loss_db, -60.0, 200.0, "excess_loss_db")
|
||||
}
|
||||
}
|
||||
|
||||
/// Coarse motion class (mirrors `ruview_unified::gaussian::MotionState`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Motion {
|
||||
/// Structural / furniture.
|
||||
Static,
|
||||
/// Breathing, posture shifts.
|
||||
Slow,
|
||||
/// Walking speed or faster.
|
||||
Fast,
|
||||
}
|
||||
|
||||
/// Role of a Gaussian exported as an RF prior.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RfRole {
|
||||
/// Attenuates links passing through it.
|
||||
Absorber,
|
||||
/// Dominant reflecting surface.
|
||||
Reflector,
|
||||
}
|
||||
|
||||
/// Neutral RF Gaussian shape (the fields of `RfGaussian` that cross the wire).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RfGaussianRecord {
|
||||
/// Centre, metres.
|
||||
pub position: [f64; 3],
|
||||
/// Per-axis σ, metres.
|
||||
pub scale: [f64; 3],
|
||||
/// Orientation quaternion `[w, x, y, z]`, body to room ENU (a positive
|
||||
/// rotation about +z turns the local x axis counter-clockwise from east).
|
||||
pub orientation: [f64; 4],
|
||||
/// Peak extinction, nepers/metre.
|
||||
pub occupancy: f64,
|
||||
/// Confidence in `[0, 1]`.
|
||||
pub confidence: f64,
|
||||
/// Motion class.
|
||||
pub motion: Motion,
|
||||
/// Prior role; absent on records RuView emits.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<RfRole>,
|
||||
}
|
||||
|
||||
impl RfGaussianRecord {
|
||||
fn validate(&self) -> R {
|
||||
point(&self.position, "position")?;
|
||||
self.scale
|
||||
.iter()
|
||||
.try_for_each(|s| in_range(*s, 1e-6, 1e4, "scale"))?;
|
||||
self.orientation
|
||||
.iter()
|
||||
.try_for_each(|q| finite(*q, "orientation"))?;
|
||||
let n = self.orientation.iter().map(|q| q * q).sum::<f64>().sqrt();
|
||||
if n <= 1e-6 {
|
||||
return Err(EvidenceError::OutOfRange("orientation"));
|
||||
}
|
||||
in_range(self.occupancy, 0.0, 1e6, "occupancy")?;
|
||||
in_range(self.confidence, 0.0, 1.0, "confidence")
|
||||
}
|
||||
}
|
||||
|
||||
fn finite(v: f64, field: &'static str) -> R {
|
||||
if v.is_finite() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(EvidenceError::NonFinite(field))
|
||||
}
|
||||
}
|
||||
|
||||
fn in_range(v: f64, lo: f64, hi: f64, field: &'static str) -> R {
|
||||
finite(v, field)?;
|
||||
if (lo..=hi).contains(&v) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(EvidenceError::OutOfRange(field))
|
||||
}
|
||||
}
|
||||
|
||||
fn point(p: &[f64; 3], field: &'static str) -> R {
|
||||
p.iter()
|
||||
.try_for_each(|v| in_range(*v, -MAX_COORD_M, MAX_COORD_M, field))
|
||||
}
|
||||
|
||||
/// Ids: ASCII alphanumerics plus `._:/-@`, 1..=128 bytes.
|
||||
fn id(s: &str, field: &'static str) -> R {
|
||||
if is_valid_id(s) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(EvidenceError::BadText(field))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_id_byte(b: u8) -> bool {
|
||||
b.is_ascii_alphanumeric() || b"._:/-@".contains(&b)
|
||||
}
|
||||
|
||||
pub(crate) fn is_valid_id(s: &str) -> bool {
|
||||
!s.is_empty() && s.len() <= MAX_ID_LEN && s.bytes().all(is_id_byte)
|
||||
}
|
||||
|
||||
/// Map arbitrary text to a valid id: disallowed bytes become `-`, the
|
||||
/// result is cut to 128 bytes, and empty input yields `fallback`.
|
||||
pub(crate) fn sanitize_id(s: &str, fallback: &str) -> String {
|
||||
let out: String = s
|
||||
.bytes()
|
||||
.take(MAX_ID_LEN)
|
||||
.map(|b| if is_id_byte(b) { b as char } else { '-' })
|
||||
.collect();
|
||||
if out.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate and serialise one record as a single JSONL line (no newline).
|
||||
pub fn to_line(rec: &EvidenceRecord) -> Result<String, EvidenceError> {
|
||||
rec.validate()?;
|
||||
let line = serde_json::to_string(rec).map_err(|e| EvidenceError::Parse(e.to_string()))?;
|
||||
if line.len() > MAX_LINE_BYTES {
|
||||
return Err(EvidenceError::LineTooLong(line.len()));
|
||||
}
|
||||
Ok(line)
|
||||
}
|
||||
|
||||
/// Serialise records as a JSONL document, one line each with a trailing
|
||||
/// newline. Fails on the first invalid record, so no partial file is built.
|
||||
pub fn to_jsonl(records: &[EvidenceRecord]) -> Result<String, EvidenceError> {
|
||||
let mut out = String::new();
|
||||
for rec in records {
|
||||
out.push_str(&to_line(rec)?);
|
||||
out.push('\n');
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Parse and validate one line of a type this crate mirrors.
|
||||
pub fn parse_line(line: &str) -> Result<EvidenceRecord, EvidenceError> {
|
||||
if line.len() > MAX_LINE_BYTES {
|
||||
return Err(EvidenceError::LineTooLong(line.len()));
|
||||
}
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(line).map_err(|e| EvidenceError::Parse(e.to_string()))?;
|
||||
match value.get("schema").and_then(|s| s.as_str()) {
|
||||
Some(SCHEMA_V1) => {}
|
||||
Some(other) => return Err(EvidenceError::UnknownVersion(other.to_string())),
|
||||
None => return Err(EvidenceError::UnknownVersion(String::new())),
|
||||
}
|
||||
let rec: EvidenceRecord =
|
||||
serde_json::from_value(value).map_err(|e| EvidenceError::Parse(e.to_string()))?;
|
||||
rec.validate()?;
|
||||
Ok(rec)
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
//! Wire-contract tests: golden lines match the ADR-382 examples (copied
|
||||
//! verbatim from WeftOS ADR-107 §7), conversions mirror their sources, and
|
||||
//! invalid values never serialise.
|
||||
|
||||
use ruview_spatial_evidence::{
|
||||
free_space_amplitude, gaussian_to_record, map_to_records, parse_line, to_jsonl, to_line,
|
||||
EvidenceError, GaussianExport, LinkMeasurement, Motion, ProofTag, Provenance, RecordBody,
|
||||
RecordMeta,
|
||||
};
|
||||
use ruview_unified::gaussian::{GaussianMap, MotionState, Provenance as UProv, RfGaussian};
|
||||
use serde_json::Value;
|
||||
|
||||
/// ADR-382 / ADR-107 §7 example lines for the two RF types, verbatim.
|
||||
const ADR_RF_LINK: &str = r#"{"schema":"spatial.evidence.v1","type":"rf_link_observation","t_ns":1759500001300000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"node-2","uncertainty_m":1.0,"provenance":{"receipt":"csi:node1-node2:000881","producer":"ruview-adapter@0.1","proof":"MEASURED"},"tx":[6.20,3.50,1.10],"rx":[0.20,0.20,1.10],"freq_hz":2437000000.0,"excess_loss_db":4.2}"#;
|
||||
const ADR_RF_GAUSSIAN: &str = r#"{"schema":"spatial.evidence.v1","type":"rf_gaussian","t_ns":1759500002000000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"ruview-unified","uncertainty_m":0.5,"provenance":{"receipt":"gauss:7f3a","producer":"ruview-unified@0.3","proof":"CODE"},"position":[3.2,0.0,1.2],"scale":[0.05,1.6,1.2],"orientation":[1,0,0,0],"occupancy":0.7,"confidence":0.8,"motion":"static","role":"absorber"}"#;
|
||||
|
||||
/// The 2026-10-03 ADR-107 amendment's mounted-sensor lines, verbatim. This
|
||||
/// crate does not mirror them, so they must fail as unknown types.
|
||||
const ADR_TOF_DEPTH: &str = r#"{"schema":"spatial.evidence.v1","type":"tof_depth","t_ns":1759500004000000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"sen0628-1","uncertainty_m":0.02,"provenance":{"receipt":"sen0628-1:000042","producer":"sen0628-tof@0.1.0","proof":"MEASURED"},"position":[4.80,0.05,1.60],"yaw_deg":90.0,"pitch_deg":-35.0,"fov_deg":[60.0,60.0],"grid":[4,4],"range_mm":[0,0,0,0,3290,3105,3110,3302,2512,1190,1185,2530,2005,1072,1066,2011],"valid":[false,false,false,false,true,true,true,true,true,true,true,true,true,true,true,true]}"#;
|
||||
const ADR_RADAR_RANGE: &str = r#"{"schema":"spatial.evidence.v1","type":"radar_range","t_ns":1759500004100000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"rd03e-1","uncertainty_m":0.3,"provenance":{"receipt":"rd03e-1:000007","producer":"rd-03e@0.1.0","proof":"MEASURED"},"position":[0.10,1.83,1.00],"yaw_deg":0.0,"fov_deg":[90.0,60.0],"range_m":2.45,"targets":1}"#;
|
||||
const ADR_RADAR_TRACK_SENSOR: &str = r#"{"schema":"spatial.evidence.v1","type":"radar_track_point","t_ns":1759500001350000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"ld2450-1","uncertainty_m":0.3,"provenance":{"receipt":"ld2450-1:000124","producer":"ld2450-radar@0.1.0","proof":"MEASURED"},"track":1,"position":[2.60,1.30,0.0],"sensor":{"position":[0.50,0.50,1.80],"yaw_deg":26.0,"pitch_deg":-10.0,"elev_half_deg":35.0,"az_half_deg":60.0}}"#;
|
||||
|
||||
const REGION: &str = "region/urth/meso/test-room";
|
||||
|
||||
fn meta(
|
||||
t_ns: u64,
|
||||
source: &str,
|
||||
unc: f64,
|
||||
receipt: &str,
|
||||
producer: &str,
|
||||
proof: ProofTag,
|
||||
) -> RecordMeta {
|
||||
RecordMeta {
|
||||
t_ns,
|
||||
region: REGION.into(),
|
||||
source_id: source.into(),
|
||||
uncertainty_m: unc,
|
||||
provenance: Provenance {
|
||||
receipt: receipt.into(),
|
||||
producer: producer.into(),
|
||||
proof,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse JSON with every number as f64, so `1` and `1.0` compare equal.
|
||||
fn json(s: &str) -> Value {
|
||||
fn norm(v: Value) -> Value {
|
||||
match v {
|
||||
Value::Number(n) => Value::from(n.as_f64().expect("finite")),
|
||||
Value::Array(a) => Value::Array(a.into_iter().map(norm).collect()),
|
||||
Value::Object(o) => Value::Object(o.into_iter().map(|(k, v)| (k, norm(v))).collect()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
norm(serde_json::from_str(s).expect("json"))
|
||||
}
|
||||
|
||||
fn gaussian(device: &str, synthetic: bool) -> RfGaussian {
|
||||
RfGaussian::new(
|
||||
[3.2, 0.0, 1.2],
|
||||
[0.05, 1.6, 1.2],
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
0.7,
|
||||
0.8,
|
||||
1_759_500_002_000_000_000,
|
||||
300.0,
|
||||
UProv {
|
||||
device_id: device.into(),
|
||||
model_version: 3,
|
||||
synthetic,
|
||||
},
|
||||
)
|
||||
.expect("valid gaussian")
|
||||
}
|
||||
|
||||
fn export(proof: ProofTag) -> GaussianExport {
|
||||
GaussianExport {
|
||||
region: REGION.into(),
|
||||
producer: "ruview-unified@0.3".into(),
|
||||
proof,
|
||||
reproducer: Some("test-repro".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_defaults_to_code() {
|
||||
let ctx = GaussianExport::new(REGION, "ruview-unified@0.3");
|
||||
assert_eq!(ctx.proof, ProofTag::Code);
|
||||
let rec = gaussian_to_record(&gaussian("esp32-node-1", false), 0, &ctx).unwrap();
|
||||
assert_eq!(rec.provenance.proof, ProofTag::Code);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measured_requires_a_non_empty_reproducer() {
|
||||
let g = gaussian("esp32-node-1", false);
|
||||
let mut ctx = GaussianExport::new(REGION, "p@0.1");
|
||||
ctx.proof = ProofTag::Measured;
|
||||
for bad in [None, Some(String::new()), Some(" ".to_string())] {
|
||||
ctx.reproducer = bad;
|
||||
assert_eq!(
|
||||
gaussian_to_record(&g, 0, &ctx),
|
||||
Err(EvidenceError::MissingReproducer)
|
||||
);
|
||||
}
|
||||
let ok = gaussian_to_record(&g, 0, &GaussianExport::new(REGION, "p@0.1").measured("run-42"))
|
||||
.unwrap();
|
||||
assert_eq!(ok.provenance.proof, ProofTag::Measured);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_gaussian_needs_no_reproducer() {
|
||||
let mut ctx = GaussianExport::new(REGION, "p@0.1");
|
||||
ctx.proof = ProofTag::Measured;
|
||||
let rec = gaussian_to_record(&gaussian("sim", true), 0, &ctx).unwrap();
|
||||
assert_eq!(rec.provenance.proof, ProofTag::Synthetic);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adr_examples_parse_and_reserialise_to_the_same_shape() {
|
||||
for line in [ADR_RF_LINK, ADR_RF_GAUSSIAN] {
|
||||
let rec = parse_line(line).expect("ADR example parses");
|
||||
assert_eq!(json(&to_line(&rec).unwrap()), json(line));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_rf_link_line_matches_adr_example() {
|
||||
let m = LinkMeasurement {
|
||||
tx: [6.20, 3.50, 1.10],
|
||||
rx: [0.20, 0.20, 1.10],
|
||||
freq_hz: 2_437_000_000.0,
|
||||
measured_amplitude: 1.0,
|
||||
free_space_amplitude: 10f64.powf(4.2 / 20.0),
|
||||
};
|
||||
let rec = m
|
||||
.to_record(meta(
|
||||
1_759_500_001_300_000_000,
|
||||
"node-2",
|
||||
1.0,
|
||||
"csi:node1-node2:000881",
|
||||
"ruview-adapter@0.1",
|
||||
ProofTag::Measured,
|
||||
))
|
||||
.unwrap();
|
||||
let mut got = json(&to_line(&rec).unwrap());
|
||||
// Float round-off in 20·log10(10^(4.2/20)): compare that field numerically.
|
||||
let loss = got["excess_loss_db"].as_f64().unwrap();
|
||||
assert!((loss - 4.2).abs() < 1e-12);
|
||||
got["excess_loss_db"] = json("4.2");
|
||||
assert_eq!(got, json(ADR_RF_LINK));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_rf_gaussian_line_matches_adr_example_except_role() {
|
||||
let rec = gaussian_to_record(
|
||||
&gaussian("ruview-unified", false),
|
||||
0,
|
||||
&export(ProofTag::Code),
|
||||
)
|
||||
.unwrap();
|
||||
let line = to_line(&rec).unwrap();
|
||||
assert_eq!(
|
||||
line,
|
||||
r#"{"schema":"spatial.evidence.v1","t_ns":1759500002000000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"ruview-unified","uncertainty_m":0.45788569702133275,"provenance":{"receipt":"gauss:ruview-unified:1759500002000000000:0","producer":"ruview-unified@0.3","proof":"CODE"},"type":"rf_gaussian","position":[3.2,0.0,1.2],"scale":[0.05,1.6,1.2],"orientation":[1.0,0.0,0.0,0.0],"occupancy":0.7,"confidence":0.8,"motion":"static"}"#
|
||||
);
|
||||
// Same field set as the ADR example once the export-only `role` and
|
||||
// the per-record receipt/uncertainty are aligned.
|
||||
let mut got = json(&line);
|
||||
let want = json(ADR_RF_GAUSSIAN);
|
||||
got["role"] = want["role"].clone();
|
||||
got["provenance"]["receipt"] = want["provenance"]["receipt"].clone();
|
||||
got["uncertainty_m"] = want["uncertainty_m"].clone();
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_gaussian_cannot_be_raised_to_measured() {
|
||||
let rec = gaussian_to_record(&gaussian("sim", true), 0, &export(ProofTag::Measured)).unwrap();
|
||||
assert_eq!(rec.provenance.proof, ProofTag::Synthetic);
|
||||
let rec =
|
||||
gaussian_to_record(&gaussian("esp32-node-1", false), 0, &export(ProofTag::Code)).unwrap();
|
||||
assert_eq!(rec.provenance.proof, ProofTag::Code);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_id_is_sanitised_into_a_valid_source_id() {
|
||||
let rec = gaussian_to_record(
|
||||
&gaussian("node 1 (kitchen)", false),
|
||||
4,
|
||||
&export(ProofTag::Code),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(rec.source_id, "node-1--kitchen-");
|
||||
let rec = gaussian_to_record(&gaussian("", false), 0, &export(ProofTag::Code)).unwrap();
|
||||
assert_eq!(rec.source_id, "ruview-unified");
|
||||
assert!(to_line(&rec).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_export_mirrors_every_gaussian_and_motion_class() {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
let mut moving = gaussian("node-5", false);
|
||||
moving.position = [1.0, 1.0, 1.0];
|
||||
moving.scale = [0.3, 0.3, 0.9];
|
||||
moving.motion = MotionState::Fast;
|
||||
map.insert(gaussian("node-1", false));
|
||||
map.insert(moving);
|
||||
let recs = map_to_records(&map, &export(ProofTag::Code)).unwrap();
|
||||
assert_eq!(recs.len(), map.len());
|
||||
for (rec, g) in recs.iter().zip(map.gaussians()) {
|
||||
let RecordBody::RfGaussian(body) = &rec.body else {
|
||||
panic!("wrong type")
|
||||
};
|
||||
assert_eq!(body.position, g.position);
|
||||
assert_eq!(body.scale, g.scale);
|
||||
assert_eq!(body.orientation, g.orientation);
|
||||
assert_eq!(rec.t_ns, g.timestamp_ns);
|
||||
}
|
||||
assert!(recs
|
||||
.iter()
|
||||
.any(|r| matches!(&r.body, RecordBody::RfGaussian(b) if b.motion == Motion::Fast)));
|
||||
// Deterministic, newline-terminated JSONL that parses back line by line.
|
||||
let doc = to_jsonl(&recs).unwrap();
|
||||
assert_eq!(
|
||||
doc,
|
||||
to_jsonl(&map_to_records(&map, &export(ProofTag::Code)).unwrap()).unwrap()
|
||||
);
|
||||
// serde_json's default float parse may differ by one ulp, so compare
|
||||
// the uncertainty numerically and everything else exactly.
|
||||
let back: Vec<_> = doc.lines().map(|l| parse_line(l).unwrap()).collect();
|
||||
assert_eq!(back.len(), recs.len());
|
||||
for (b, r) in back.iter().zip(&recs) {
|
||||
assert!((b.uncertainty_m - r.uncertainty_m).abs() < 1e-12);
|
||||
assert_eq!(
|
||||
(&b.body, &b.provenance, b.t_ns),
|
||||
(&r.body, &r.provenance, r.t_ns)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn friis_reference_and_excess_loss() {
|
||||
let (tx, rx, f) = ([0.4, 0.4, 0.9], [6.2, 3.46, 0.91], 2.437e9);
|
||||
let d = ((5.8f64).powi(2) + 3.06f64.powi(2) + 0.01f64.powi(2)).sqrt();
|
||||
let want = 299_792_458.0 / f / (4.0 * std::f64::consts::PI * d);
|
||||
let free = free_space_amplitude(tx, rx, f).unwrap();
|
||||
assert!((free - want).abs() / want < 1e-9);
|
||||
assert_eq!(free_space_amplitude(tx, tx, f), None);
|
||||
assert_eq!(free_space_amplitude(tx, rx, f64::NAN), None);
|
||||
|
||||
let at_free = LinkMeasurement::against_friis(tx, rx, f, free).unwrap();
|
||||
assert!(at_free.excess_loss_db().unwrap().abs() < 1e-12);
|
||||
let halved = LinkMeasurement::against_friis(tx, rx, f, free / 2.0).unwrap();
|
||||
assert!((halved.excess_loss_db().unwrap() - 6.020_599_913).abs() < 1e-6);
|
||||
let stronger = LinkMeasurement::against_friis(tx, rx, f, free * 2.0).unwrap();
|
||||
assert!(stronger.excess_loss_db().unwrap() < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_link_amplitudes_and_out_of_range_loss_are_rejected() {
|
||||
let base = LinkMeasurement {
|
||||
tx: [0.0; 3],
|
||||
rx: [1.0, 0.0, 0.0],
|
||||
freq_hz: 2.437e9,
|
||||
measured_amplitude: 1.0,
|
||||
free_space_amplitude: 1.0,
|
||||
};
|
||||
let m = |measured: f64, free: f64| LinkMeasurement {
|
||||
measured_amplitude: measured,
|
||||
free_space_amplitude: free,
|
||||
..base.clone()
|
||||
};
|
||||
let mk = || meta(1, "node-1", 1.0, "r-1", "p@0.1", ProofTag::Code);
|
||||
assert_eq!(
|
||||
m(0.0, 1.0).excess_loss_db(),
|
||||
Err(EvidenceError::OutOfRange("measured_amplitude"))
|
||||
);
|
||||
assert_eq!(
|
||||
m(f64::NAN, 1.0).excess_loss_db(),
|
||||
Err(EvidenceError::NonFinite("measured_amplitude"))
|
||||
);
|
||||
assert_eq!(
|
||||
m(1.0, -1.0).excess_loss_db(),
|
||||
Err(EvidenceError::OutOfRange("free_space_amplitude"))
|
||||
);
|
||||
// 1e-11 below free space is 220 dB: outside the v1 range [-60, 200].
|
||||
assert_eq!(
|
||||
m(1e-11, 1.0).to_record(mk()),
|
||||
Err(EvidenceError::OutOfRange("excess_loss_db"))
|
||||
);
|
||||
let far = LinkMeasurement {
|
||||
tx: [0.0, 0.0, 2000.0],
|
||||
..base.clone()
|
||||
};
|
||||
assert_eq!(far.to_record(mk()), Err(EvidenceError::OutOfRange("tx")));
|
||||
let low = LinkMeasurement {
|
||||
freq_hz: 1e6,
|
||||
..base
|
||||
};
|
||||
assert_eq!(
|
||||
low.to_record(mk()),
|
||||
Err(EvidenceError::OutOfRange("freq_hz"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_rules_match_v1() {
|
||||
let link = LinkMeasurement {
|
||||
tx: [0.0; 3],
|
||||
rx: [1.0, 0.0, 0.0],
|
||||
freq_hz: 2.437e9,
|
||||
measured_amplitude: 1.0,
|
||||
free_space_amplitude: 1.0,
|
||||
};
|
||||
let rec = |src: &str, unc: f64, receipt: &str| {
|
||||
link.to_record(meta(1, src, unc, receipt, "p@0.1", ProofTag::Code))
|
||||
};
|
||||
assert_eq!(
|
||||
rec("node 1", 1.0, "r"),
|
||||
Err(EvidenceError::BadText("source_id"))
|
||||
);
|
||||
assert_eq!(rec("", 1.0, "r"), Err(EvidenceError::BadText("source_id")));
|
||||
assert_eq!(
|
||||
rec(&"a".repeat(129), 1.0, "r"),
|
||||
Err(EvidenceError::BadText("source_id"))
|
||||
);
|
||||
assert_eq!(
|
||||
rec("n", 0.0, "r"),
|
||||
Err(EvidenceError::OutOfRange("uncertainty_m"))
|
||||
);
|
||||
assert_eq!(
|
||||
rec("n", 100.5, "r"),
|
||||
Err(EvidenceError::OutOfRange("uncertainty_m"))
|
||||
);
|
||||
assert_eq!(
|
||||
rec("n", f64::INFINITY, "r"),
|
||||
Err(EvidenceError::NonFinite("uncertainty_m"))
|
||||
);
|
||||
assert_eq!(
|
||||
rec("n", 1.0, "bad\nreceipt"),
|
||||
Err(EvidenceError::BadText("provenance.receipt"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_line_rejects_wrong_version_type_overflow_and_length() {
|
||||
let v2 = ADR_RF_LINK.replace("spatial.evidence.v1", "spatial.evidence.v2");
|
||||
assert_eq!(
|
||||
parse_line(&v2),
|
||||
Err(EvidenceError::UnknownVersion("spatial.evidence.v2".into()))
|
||||
);
|
||||
let unschema = ADR_RF_LINK.replace(r#""schema":"spatial.evidence.v1","#, "");
|
||||
assert_eq!(
|
||||
parse_line(&unschema),
|
||||
Err(EvidenceError::UnknownVersion(String::new()))
|
||||
);
|
||||
let unknown = ADR_RF_LINK.replace("rf_link_observation", "rf_teleport");
|
||||
assert!(matches!(parse_line(&unknown), Err(EvidenceError::Parse(_))));
|
||||
let overflow = ADR_RF_LINK.replace("4.2}", "1e999}");
|
||||
assert!(matches!(
|
||||
parse_line(&overflow),
|
||||
Err(EvidenceError::Parse(_))
|
||||
));
|
||||
let bad_quat = ADR_RF_GAUSSIAN.replace("[1,0,0,0]", "[0,0,0,0]");
|
||||
assert_eq!(
|
||||
parse_line(&bad_quat),
|
||||
Err(EvidenceError::OutOfRange("orientation"))
|
||||
);
|
||||
let bad_conf = ADR_RF_GAUSSIAN.replace("\"confidence\":0.8", "\"confidence\":1.5");
|
||||
assert_eq!(
|
||||
parse_line(&bad_conf),
|
||||
Err(EvidenceError::OutOfRange("confidence"))
|
||||
);
|
||||
let long = format!("{}{}", ADR_RF_LINK, " ".repeat(16 * 1024));
|
||||
assert!(matches!(
|
||||
parse_line(&long),
|
||||
Err(EvidenceError::LineTooLong(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_rf_types_are_unknown_to_this_emitter() {
|
||||
for line in [ADR_TOF_DEPTH, ADR_RADAR_RANGE, ADR_RADAR_TRACK_SENSOR] {
|
||||
let err = parse_line(line).expect_err("not mirrored here");
|
||||
let EvidenceError::Parse(msg) = err else {
|
||||
panic!("expected an unknown-type parse error, got {err:?}")
|
||||
};
|
||||
assert!(msg.contains("unknown variant"), "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gaussian_orientation_follows_the_v1_yaw_convention() {
|
||||
// v1 yaw is counter-clockwise from room +x. A quaternion for +90° about
|
||||
// +z must therefore turn the Gaussian's local x axis onto room +y, and
|
||||
// reach the wire unchanged.
|
||||
let h = std::f64::consts::FRAC_PI_4;
|
||||
let mut g = gaussian("node-1", false);
|
||||
g.orientation = [h.cos(), 0.0, 0.0, h.sin()];
|
||||
let r = g.rotation();
|
||||
let local_x = [r[0][0], r[1][0], r[2][0]];
|
||||
for (got, want) in local_x.iter().zip([0.0, 1.0, 0.0]) {
|
||||
assert!((got - want).abs() < 1e-12, "{local_x:?}");
|
||||
}
|
||||
let rec = gaussian_to_record(&g, 0, &export(ProofTag::Code)).unwrap();
|
||||
let RecordBody::RfGaussian(body) = &rec.body else {
|
||||
panic!("wrong type")
|
||||
};
|
||||
assert_eq!(body.orientation, g.orientation);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "ruview-survey"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Node self-survey check mode: compare pairwise node ranges with configured node positions (ADR-381)"
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Run check mode on a JSONL file of range observations.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo run -p ruview-survey --example check_ranges -- \
|
||||
//! --node-positions "1:0,0,1.5;2:4.2,0,1.5;3:4.2,3.6,1.5" \
|
||||
//! --observations ranges.jsonl [--profile ftm|uwb]
|
||||
//! ```
|
||||
//!
|
||||
//! Each line of `ranges.jsonl` is one `RangeObservation`, for example
|
||||
//! `{"initiator":1,"responder":2,"range_m":4.6,"sigma_m":0.3,"method":"ftm"}`.
|
||||
//! Prints the report as JSON. Exit status 0 when consistent, 1 on warnings
|
||||
//! or insufficient data, 2 on bad input. Never writes anything.
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use ruview_survey::{check, parse_node_positions, CheckParams, RangeObservation, Verdict};
|
||||
|
||||
/// Largest observations file read, in bytes.
|
||||
const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
|
||||
|
||||
fn run() -> Result<Verdict, String> {
|
||||
let mut positions = None;
|
||||
let mut path = None;
|
||||
let mut profile = "ftm".to_string();
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
let mut value = || args.next().ok_or(format!("{arg} needs a value"));
|
||||
match arg.as_str() {
|
||||
"--node-positions" => positions = Some(value()?),
|
||||
"--observations" => path = Some(value()?),
|
||||
"--profile" => profile = value()?,
|
||||
other => return Err(format!("unknown argument {other}")),
|
||||
}
|
||||
}
|
||||
let positions = parse_node_positions(&positions.ok_or("--node-positions is required")?)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let path = path.ok_or("--observations is required")?;
|
||||
let params = match profile.as_str() {
|
||||
"ftm" => CheckParams::ftm_uncalibrated(),
|
||||
"uwb" => CheckParams::uwb(),
|
||||
other => return Err(format!("unknown profile {other} (ftm|uwb)")),
|
||||
};
|
||||
|
||||
let meta = std::fs::metadata(&path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !meta.is_file() || meta.len() > MAX_FILE_BYTES {
|
||||
return Err(format!(
|
||||
"{path}: not a regular file under {MAX_FILE_BYTES} bytes"
|
||||
));
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let observations = text
|
||||
.lines()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| !l.trim().is_empty())
|
||||
.map(|(i, l)| {
|
||||
serde_json::from_str::<RangeObservation>(l)
|
||||
.map_err(|e| format!("{path}:{}: {e}", i + 1))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let report = check(&positions, &observations, ¶ms).map_err(|e| e.to_string())?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?
|
||||
);
|
||||
Ok(report.verdict)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
match run() {
|
||||
Ok(Verdict::Consistent) => ExitCode::SUCCESS,
|
||||
Ok(_) => ExitCode::from(1),
|
||||
Err(e) => {
|
||||
eprintln!("check_ranges: {e}");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Per-boot range calibration.
|
||||
//!
|
||||
//! On the 2026-10-04 FTM bench the spread inside one responder start was
|
||||
//! ±0.2 to 0.4 m with no drift beyond that over two minutes (40 sessions,
|
||||
//! half medians 3.30 and 3.00 m), but the bias changed with
|
||||
//! every responder start (−0.5 to +1.9 m at 1.14 m). So an offset is only
|
||||
//! trusted for the link and responder boot it was measured on. An
|
||||
//! observation from any other boot passes through uncalibrated.
|
||||
//!
|
||||
//! The reference must be a known non-trivial distance. Zero-separation
|
||||
//! calibration (ESP-IDF's `cm0`) is suspect on this hardware because
|
||||
//! readings clamped to 0 below about 0.8 m.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{median, RangeObservation, SurveyError};
|
||||
|
||||
/// Shortest reference distance accepted, in metres. Below this the bench
|
||||
/// readings clamped to zero, so an offset measured there is meaningless.
|
||||
pub const MIN_REFERENCE_M: f64 = 1.0;
|
||||
|
||||
/// Offsets keyed by `(initiator, responder, responder_boot)`.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct CalibrationTable {
|
||||
offsets: BTreeMap<(u8, u8, u32), f64>,
|
||||
}
|
||||
|
||||
impl CalibrationTable {
|
||||
/// An empty table.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Record an offset from sessions taken at a known reference distance.
|
||||
///
|
||||
/// Every observation must share initiator, responder and responder boot.
|
||||
/// The offset is `median(range_m) - reference_m` and is returned.
|
||||
pub fn record_reference(
|
||||
&mut self,
|
||||
observations: &[RangeObservation],
|
||||
reference_m: f64,
|
||||
) -> Result<f64, SurveyError> {
|
||||
if !reference_m.is_finite() || reference_m < MIN_REFERENCE_M {
|
||||
return Err(SurveyError::Calibration(format!(
|
||||
"reference {reference_m} m is below {MIN_REFERENCE_M} m"
|
||||
)));
|
||||
}
|
||||
let first = observations
|
||||
.first()
|
||||
.ok_or_else(|| SurveyError::Calibration("no reference observations".into()))?;
|
||||
let boot = first.responder_boot.ok_or_else(|| {
|
||||
SurveyError::Calibration("reference observations carry no responder boot".into())
|
||||
})?;
|
||||
let key = (first.initiator, first.responder, boot);
|
||||
let mut ranges = Vec::with_capacity(observations.len());
|
||||
for o in observations {
|
||||
o.validate()?;
|
||||
if (o.initiator, o.responder, o.responder_boot) != (key.0, key.1, Some(key.2)) {
|
||||
return Err(SurveyError::Calibration(
|
||||
"reference observations mix links or responder boots".into(),
|
||||
));
|
||||
}
|
||||
ranges.push(o.range_m);
|
||||
}
|
||||
let offset = median(&ranges).unwrap_or(0.0) - reference_m;
|
||||
self.offsets.insert(key, offset);
|
||||
Ok(offset)
|
||||
}
|
||||
|
||||
/// The offset for an observation's link and boot, if one was recorded.
|
||||
pub fn offset_for(&self, obs: &RangeObservation) -> Option<f64> {
|
||||
let boot = obs.responder_boot?;
|
||||
self.offsets
|
||||
.get(&(obs.initiator, obs.responder, boot))
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Return a calibrated copy when an offset matches, else an unchanged copy.
|
||||
pub fn apply(&self, obs: &RangeObservation) -> RangeObservation {
|
||||
let mut out = obs.clone();
|
||||
if obs.calibrated {
|
||||
return out;
|
||||
}
|
||||
if let Some(offset) = self.offset_for(obs) {
|
||||
out.range_m = (obs.range_m - offset).max(0.0);
|
||||
out.calibrated = true;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Number of recorded offsets.
|
||||
pub fn len(&self) -> usize {
|
||||
self.offsets.len()
|
||||
}
|
||||
|
||||
/// True when no offsets are recorded.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.offsets.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn obs(range: f64, boot: Option<u32>) -> RangeObservation {
|
||||
RangeObservation {
|
||||
responder_boot: boot,
|
||||
..RangeObservation::ftm(1, 2, range, 0.3)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_applies_only_to_the_same_boot() {
|
||||
let mut t = CalibrationTable::new();
|
||||
let off = t
|
||||
.record_reference(
|
||||
&[obs(3.0, Some(7)), obs(3.1, Some(7)), obs(3.2, Some(7))],
|
||||
1.143,
|
||||
)
|
||||
.unwrap();
|
||||
assert!((off - (3.1 - 1.143)).abs() < 1e-9);
|
||||
|
||||
let same = t.apply(&obs(5.0, Some(7)));
|
||||
assert!(same.calibrated);
|
||||
assert!((same.range_m - (5.0 - off)).abs() < 1e-9);
|
||||
|
||||
let other_boot = t.apply(&obs(5.0, Some(8)));
|
||||
assert!(!other_boot.calibrated);
|
||||
assert_eq!(other_boot.range_m, 5.0);
|
||||
|
||||
let no_boot = t.apply(&obs(5.0, None));
|
||||
assert!(!no_boot.calibrated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_short_reference_and_mixed_links() {
|
||||
let mut t = CalibrationTable::new();
|
||||
assert!(t.record_reference(&[obs(0.0, Some(1))], 0.5).is_err());
|
||||
assert!(t.record_reference(&[obs(1.0, None)], 1.2).is_err());
|
||||
assert!(t
|
||||
.record_reference(&[obs(1.0, Some(1)), obs(1.0, Some(2))], 1.2)
|
||||
.is_err());
|
||||
assert!(t.record_reference(&[], 1.2).is_err());
|
||||
assert!(t.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibrated_range_never_goes_negative() {
|
||||
let mut t = CalibrationTable::new();
|
||||
t.record_reference(&[obs(3.0, Some(1))], 1.0).unwrap();
|
||||
assert_eq!(t.apply(&obs(0.5, Some(1))).range_m, 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
//! Check mode: compare measured node ranges with configured node positions.
|
||||
//!
|
||||
//! Warn-only. [`check`] returns a [`CheckReport`]; nothing in this module
|
||||
//! produces positions to write back.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::positions::distance;
|
||||
use crate::report::{
|
||||
CheckParams, CheckReport, Finding, LinkResidual, LinkStatus, ScaleFit, Verdict,
|
||||
};
|
||||
use crate::{median, NodePositions, RangeObservation, SurveyError, MAX_OBSERVATIONS};
|
||||
|
||||
/// Largest node set the id-swap search permutes (8! = 40 320 assignments).
|
||||
pub const MAX_SWAP_NODES: usize = 8;
|
||||
|
||||
/// Unit factors tried by the scale check: configured value × factor = metres.
|
||||
const UNIT_FACTORS: [(&str, f64); 4] = [
|
||||
("feet", 0.3048),
|
||||
("inches", 0.0254),
|
||||
("centimetres", 0.01),
|
||||
("millimetres", 0.001),
|
||||
];
|
||||
|
||||
/// Cap on one link's contribution to the assignment cost, in squared
|
||||
/// tolerances, so one bad link cannot dominate the id-swap search.
|
||||
const LINK_COST_CAP: f64 = 4.0;
|
||||
|
||||
/// Compare observations with configured positions. Never mutates either.
|
||||
pub fn check(
|
||||
positions: &NodePositions,
|
||||
observations: &[RangeObservation],
|
||||
params: &CheckParams,
|
||||
) -> Result<CheckReport, SurveyError> {
|
||||
if observations.len() > MAX_OBSERVATIONS {
|
||||
return Err(SurveyError::TooMany {
|
||||
what: "observations",
|
||||
got: observations.len(),
|
||||
max: MAX_OBSERVATIONS,
|
||||
});
|
||||
}
|
||||
let mut findings = Vec::new();
|
||||
let mut unknown: BTreeMap<u8, usize> = BTreeMap::new();
|
||||
let mut by_pair: BTreeMap<(u8, u8), Vec<&RangeObservation>> = BTreeMap::new();
|
||||
for o in observations {
|
||||
o.validate()?;
|
||||
let mut known = true;
|
||||
for node in [o.initiator, o.responder] {
|
||||
if !positions.contains_key(&node) {
|
||||
*unknown.entry(node).or_default() += 1;
|
||||
known = false;
|
||||
}
|
||||
}
|
||||
if known {
|
||||
by_pair.entry(o.pair()).or_default().push(o);
|
||||
}
|
||||
}
|
||||
for (node, n) in unknown {
|
||||
findings.push(Finding::UnknownNode {
|
||||
node,
|
||||
observations: n,
|
||||
});
|
||||
}
|
||||
|
||||
let links: Vec<LinkResidual> = by_pair
|
||||
.iter()
|
||||
.map(|(&(a, b), obs)| link_residual(a, b, obs, positions, params))
|
||||
.collect();
|
||||
let scored: Vec<&LinkResidual> = links
|
||||
.iter()
|
||||
.filter(|l| matches!(l.status, LinkStatus::Ok | LinkStatus::Outlier))
|
||||
.collect();
|
||||
|
||||
let ranged: BTreeSet<u8> = scored.iter().flat_map(|l| [l.a, l.b]).collect();
|
||||
if !scored.is_empty() {
|
||||
for node in positions.keys().filter(|n| !ranged.contains(n)) {
|
||||
findings.push(Finding::UnrangedNode { node: *node });
|
||||
}
|
||||
}
|
||||
if scored.is_empty() {
|
||||
return Ok(CheckReport {
|
||||
links,
|
||||
scale: None,
|
||||
findings,
|
||||
verdict: Verdict::Insufficient,
|
||||
});
|
||||
}
|
||||
|
||||
let scale = fit_scale(&scored);
|
||||
let unit = scale
|
||||
.as_ref()
|
||||
.and_then(|s| unit_mismatch(s.scale, &scored, params));
|
||||
let explained = unit.is_some();
|
||||
findings.extend(unit);
|
||||
|
||||
let mut swapped = false;
|
||||
if !explained {
|
||||
if ranged.len() > MAX_SWAP_NODES {
|
||||
findings.push(Finding::SwapSearchSkipped {
|
||||
nodes: ranged.len(),
|
||||
});
|
||||
} else if ranged.len() >= 3 {
|
||||
if let Some(f) = swap_search(&ranged, &scored, positions, params) {
|
||||
swapped = matches!(f, Finding::IdSwapSuggested { .. });
|
||||
findings.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !explained && !swapped {
|
||||
let moved = moved_nodes(&ranged, &scored);
|
||||
for l in scored.iter().filter(|l| l.status == LinkStatus::Outlier) {
|
||||
if !moved.contains(&l.a) && !moved.contains(&l.b) {
|
||||
findings.push(Finding::LinkOutlier {
|
||||
a: l.a,
|
||||
b: l.b,
|
||||
residual_m: l.residual_m,
|
||||
});
|
||||
}
|
||||
}
|
||||
for node in &moved {
|
||||
let touching: Vec<&&LinkResidual> = scored
|
||||
.iter()
|
||||
.filter(|l| l.a == *node || l.b == *node)
|
||||
.collect();
|
||||
findings.push(Finding::NodeMoved {
|
||||
node: *node,
|
||||
flagged_links: touching
|
||||
.iter()
|
||||
.filter(|l| l.status == LinkStatus::Outlier)
|
||||
.count(),
|
||||
links: touching.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let verdict = if findings.iter().any(Finding::is_warning) {
|
||||
Verdict::Warnings
|
||||
} else {
|
||||
Verdict::Consistent
|
||||
};
|
||||
Ok(CheckReport {
|
||||
links,
|
||||
scale,
|
||||
findings,
|
||||
verdict,
|
||||
})
|
||||
}
|
||||
|
||||
fn link_residual(
|
||||
a: u8,
|
||||
b: u8,
|
||||
obs: &[&RangeObservation],
|
||||
positions: &NodePositions,
|
||||
params: &CheckParams,
|
||||
) -> LinkResidual {
|
||||
let usable: Vec<&&RangeObservation> = obs
|
||||
.iter()
|
||||
.filter(|o| o.rssi_dbm.is_none_or(|r| r >= params.min_rssi_dbm))
|
||||
.collect();
|
||||
let pool: Vec<&&RangeObservation> = if usable.is_empty() {
|
||||
obs.iter().collect()
|
||||
} else {
|
||||
usable.clone()
|
||||
};
|
||||
let ranges: Vec<f64> = pool.iter().map(|o| o.range_m).collect();
|
||||
let sigmas: Vec<f64> = pool.iter().map(|o| o.sigma_m).collect();
|
||||
let measured = median(&ranges).unwrap_or(0.0);
|
||||
let session_sigma = median(&sigmas).unwrap_or(0.0);
|
||||
let spread = if ranges.len() >= 3 {
|
||||
let mean = ranges.iter().sum::<f64>() / ranges.len() as f64;
|
||||
(ranges.iter().map(|r| (r - mean).powi(2)).sum::<f64>() / ranges.len() as f64).sqrt()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let sigma = session_sigma.max(spread);
|
||||
let calibrated = pool.iter().all(|o| o.calibrated);
|
||||
let expected = distance(&positions[&a], &positions[&b]);
|
||||
let floor = if calibrated {
|
||||
params.calibrated_floor_m
|
||||
} else {
|
||||
params.floor_m
|
||||
};
|
||||
let tolerance = floor + params.sigma_k * sigma;
|
||||
let residual = measured - expected;
|
||||
let status = if usable.is_empty() {
|
||||
LinkStatus::Weak
|
||||
} else if expected < params.min_expected_m {
|
||||
LinkStatus::TooShort
|
||||
} else if sigma > params.max_sigma_m {
|
||||
LinkStatus::Noisy
|
||||
} else if residual.abs() > tolerance {
|
||||
LinkStatus::Outlier
|
||||
} else {
|
||||
LinkStatus::Ok
|
||||
};
|
||||
LinkResidual {
|
||||
a,
|
||||
b,
|
||||
measured_m: measured,
|
||||
expected_m: expected,
|
||||
residual_m: residual,
|
||||
tolerance_m: tolerance,
|
||||
sigma_m: sigma,
|
||||
observations: pool.len(),
|
||||
calibrated,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
fn fit_scale(scored: &[&LinkResidual]) -> Option<ScaleFit> {
|
||||
let usable: Vec<&&LinkResidual> = scored.iter().filter(|l| l.expected_m > 0.0).collect();
|
||||
if usable.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let num: f64 = usable.iter().map(|l| l.measured_m * l.expected_m).sum();
|
||||
let den: f64 = usable.iter().map(|l| l.expected_m * l.expected_m).sum();
|
||||
(den > 0.0).then(|| ScaleFit {
|
||||
scale: num / den,
|
||||
links: usable.len(),
|
||||
})
|
||||
}
|
||||
|
||||
fn unit_mismatch(scale: f64, scored: &[&LinkResidual], params: &CheckParams) -> Option<Finding> {
|
||||
let outliers = scored
|
||||
.iter()
|
||||
.filter(|l| l.status == LinkStatus::Outlier)
|
||||
.count();
|
||||
if outliers == 0 {
|
||||
return None;
|
||||
}
|
||||
UNIT_FACTORS.iter().find_map(|&(unit, factor)| {
|
||||
if ((scale / factor) - 1.0).abs() > params.unit_band {
|
||||
return None;
|
||||
}
|
||||
let rescaled = scored
|
||||
.iter()
|
||||
.filter(|l| (l.measured_m - l.expected_m * factor).abs() > l.tolerance_m)
|
||||
.count();
|
||||
(rescaled < outliers).then(|| Finding::UnitMismatch {
|
||||
unit: unit.to_string(),
|
||||
factor,
|
||||
fitted_scale: scale,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn swap_search(
|
||||
ranged: &BTreeSet<u8>,
|
||||
scored: &[&LinkResidual],
|
||||
positions: &NodePositions,
|
||||
params: &CheckParams,
|
||||
) -> Option<Finding> {
|
||||
let nodes: Vec<u8> = ranged.iter().copied().collect();
|
||||
let index: BTreeMap<u8, usize> = nodes.iter().enumerate().map(|(i, n)| (*n, i)).collect();
|
||||
let pos: Vec<[f64; 3]> = nodes.iter().map(|n| positions[n]).collect();
|
||||
let links: Vec<(usize, usize, f64, f64)> = scored
|
||||
.iter()
|
||||
.map(|l| (index[&l.a], index[&l.b], l.measured_m, l.tolerance_m))
|
||||
.collect();
|
||||
let cost = |perm: &[usize]| -> f64 {
|
||||
links
|
||||
.iter()
|
||||
.map(|&(i, j, m, tol)| {
|
||||
let r = (m - distance(&pos[perm[i]], &pos[perm[j]])) / tol;
|
||||
(r * r).min(LINK_COST_CAP)
|
||||
})
|
||||
.sum()
|
||||
};
|
||||
|
||||
let n = nodes.len();
|
||||
let identity: Vec<usize> = (0..n).collect();
|
||||
let configured_cost = cost(&identity);
|
||||
let mut all: Vec<(f64, Vec<usize>)> = Vec::new();
|
||||
for_each_permutation(n, |perm| all.push((cost(perm), perm.to_vec())));
|
||||
let best = all.iter().map(|(c, _)| *c).fold(f64::INFINITY, f64::min);
|
||||
let tie = best + 0.1 * best + 0.05;
|
||||
if configured_cost <= tie {
|
||||
return None;
|
||||
}
|
||||
if configured_cost < params.min_swap_cost || best > params.swap_gain * configured_cost {
|
||||
return None;
|
||||
}
|
||||
// Several assignments can fit equally well: ranges cannot tell a layout
|
||||
// from its reflections, and corner-mounted nodes in a rectangular room
|
||||
// are nearly symmetric. Prefer the assignment that changes the fewest
|
||||
// ids (an operator typo moves few ids), and give up only when that
|
||||
// minimum is itself tied.
|
||||
let changed = |perm: &[usize]| perm.iter().enumerate().filter(|(i, p)| i != *p).count();
|
||||
let winners: Vec<&(f64, Vec<usize>)> = all.iter().filter(|(c, _)| *c <= tie).collect();
|
||||
let fewest = winners.iter().map(|(_, p)| changed(p)).min()?;
|
||||
let minimal: Vec<&&(f64, Vec<usize>)> = winners
|
||||
.iter()
|
||||
.filter(|(_, p)| changed(p) == fewest)
|
||||
.collect();
|
||||
if minimal.len() > 1 {
|
||||
return Some(Finding::IdSwapAmbiguous {
|
||||
equivalent_assignments: winners.len(),
|
||||
});
|
||||
}
|
||||
let (best_cost, perm) = minimal[0];
|
||||
let assignment = perm
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, p)| *i != **p)
|
||||
.map(|(i, p)| (nodes[i], nodes[*p]))
|
||||
.collect();
|
||||
Some(Finding::IdSwapSuggested {
|
||||
assignment,
|
||||
cost_configured: configured_cost,
|
||||
cost_suggested: *best_cost,
|
||||
equivalent_assignments: winners.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Visit every permutation of `0..n` (Heap's algorithm, iterative).
|
||||
fn for_each_permutation(n: usize, mut visit: impl FnMut(&[usize])) {
|
||||
let mut perm: Vec<usize> = (0..n).collect();
|
||||
let mut c = vec![0usize; n];
|
||||
visit(&perm);
|
||||
let mut i = 1;
|
||||
while i < n {
|
||||
if c[i] < i {
|
||||
if i % 2 == 0 {
|
||||
perm.swap(0, i);
|
||||
} else {
|
||||
perm.swap(c[i], i);
|
||||
}
|
||||
visit(&perm);
|
||||
c[i] += 1;
|
||||
i = 1;
|
||||
} else {
|
||||
c[i] = 0;
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn moved_nodes(ranged: &BTreeSet<u8>, scored: &[&LinkResidual]) -> BTreeSet<u8> {
|
||||
ranged
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|node| {
|
||||
let touching: Vec<&&LinkResidual> = scored
|
||||
.iter()
|
||||
.filter(|l| l.a == *node || l.b == *node)
|
||||
.collect();
|
||||
let flagged = touching
|
||||
.iter()
|
||||
.filter(|l| l.status == LinkStatus::Outlier)
|
||||
.count();
|
||||
flagged >= 2 && 2 * flagged > touching.len()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn heap_visits_every_permutation_once() {
|
||||
for n in 0..=5 {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut count = 0;
|
||||
for_each_permutation(n, |p| {
|
||||
seen.insert(p.to_vec());
|
||||
count += 1;
|
||||
});
|
||||
let expected: usize = (1..=n).product();
|
||||
assert_eq!(count, expected.max(1));
|
||||
assert_eq!(seen.len(), expected.max(1));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_scored_links_is_insufficient() {
|
||||
let positions = crate::parse_node_positions("1:0,0,0;2:0.5,0,0").unwrap();
|
||||
let obs = [RangeObservation::ftm(1, 2, 0.0, 0.1)];
|
||||
let r = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap();
|
||||
assert_eq!(r.verdict, Verdict::Insufficient);
|
||||
assert_eq!(r.links[0].status, LinkStatus::TooShort);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_many_observations() {
|
||||
let positions = crate::parse_node_positions("1:0,0,0;2:3,0,0").unwrap();
|
||||
let obs = vec![RangeObservation::ftm(1, 2, 3.0, 0.1); MAX_OBSERVATIONS + 1];
|
||||
assert!(matches!(
|
||||
check(&positions, &obs, &CheckParams::ftm_uncalibrated()),
|
||||
Err(SurveyError::TooMany { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Fill-mode gate.
|
||||
//!
|
||||
//! Fill mode would write solved positions to a file the operator then passes
|
||||
//! to `--node-positions`. It is not implemented here. This gate records the
|
||||
//! ADR-381 rule so that whatever implements it later cannot be enabled on a
|
||||
//! source that has not earned it: MEASURED evidence, enough links, and a p90
|
||||
//! link error of at most [`FILL_MAX_P90_M`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::RangeMethod;
|
||||
|
||||
/// Largest p90 link error, in metres, at which fill mode may be enabled.
|
||||
pub const FILL_MAX_P90_M: f64 = 0.3;
|
||||
|
||||
/// Fewest ground-truth links behind the p90 figure.
|
||||
pub const FILL_MIN_LINKS: usize = 6;
|
||||
|
||||
/// Evidence tag of a source accuracy figure (repository rule).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum EvidenceTag {
|
||||
/// Our own bench, with a reproducer.
|
||||
Measured,
|
||||
/// Vendor or paper figure we have not reproduced.
|
||||
Claimed,
|
||||
/// Simulation.
|
||||
Synthetic,
|
||||
}
|
||||
|
||||
/// Accuracy evidence for one ranging source.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SourceEvidence {
|
||||
/// The source.
|
||||
pub method: RangeMethod,
|
||||
/// p90 of |measured − ground truth| over the links below, in metres.
|
||||
pub p90_link_error_m: f64,
|
||||
/// Number of ground-truth links behind the figure.
|
||||
pub links: usize,
|
||||
/// Evidence tag.
|
||||
pub tag: EvidenceTag,
|
||||
}
|
||||
|
||||
/// Why fill mode stays off.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "reason")]
|
||||
pub enum FillBlocked {
|
||||
/// No evidence was supplied for the source.
|
||||
NoEvidence,
|
||||
/// The evidence is not MEASURED.
|
||||
NotMeasured {
|
||||
/// The tag that was supplied.
|
||||
tag: EvidenceTag,
|
||||
},
|
||||
/// Too few ground-truth links.
|
||||
TooFewLinks {
|
||||
/// Links supplied.
|
||||
links: usize,
|
||||
},
|
||||
/// p90 link error above [`FILL_MAX_P90_M`].
|
||||
TooInaccurate {
|
||||
/// p90 supplied.
|
||||
p90_link_error_m: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Decide whether fill mode may run for a source.
|
||||
pub fn fill_gate(evidence: Option<&SourceEvidence>) -> Result<(), FillBlocked> {
|
||||
let e = evidence.ok_or(FillBlocked::NoEvidence)?;
|
||||
if e.tag != EvidenceTag::Measured {
|
||||
return Err(FillBlocked::NotMeasured { tag: e.tag });
|
||||
}
|
||||
if e.links < FILL_MIN_LINKS {
|
||||
return Err(FillBlocked::TooFewLinks { links: e.links });
|
||||
}
|
||||
if !e.p90_link_error_m.is_finite() || e.p90_link_error_m > FILL_MAX_P90_M {
|
||||
return Err(FillBlocked::TooInaccurate {
|
||||
p90_link_error_m: e.p90_link_error_m,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ev(p90: f64, links: usize, tag: EvidenceTag) -> SourceEvidence {
|
||||
SourceEvidence {
|
||||
method: RangeMethod::Uwb,
|
||||
p90_link_error_m: p90,
|
||||
links,
|
||||
tag,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gate_blocks_until_measured_and_accurate() {
|
||||
assert_eq!(fill_gate(None), Err(FillBlocked::NoEvidence));
|
||||
assert!(matches!(
|
||||
fill_gate(Some(&ev(0.1, 10, EvidenceTag::Claimed))),
|
||||
Err(FillBlocked::NotMeasured { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
fill_gate(Some(&ev(0.1, 3, EvidenceTag::Measured))),
|
||||
Err(FillBlocked::TooFewLinks { .. })
|
||||
));
|
||||
// FTM on the 2026-10-04 bench: errors of metres.
|
||||
assert!(matches!(
|
||||
fill_gate(Some(&ev(4.1, 8, EvidenceTag::Measured))),
|
||||
Err(FillBlocked::TooInaccurate { .. })
|
||||
));
|
||||
assert!(fill_gate(Some(&ev(f64::NAN, 8, EvidenceTag::Measured))).is_err());
|
||||
assert_eq!(fill_gate(Some(&ev(0.3, 6, EvidenceTag::Measured))), Ok(()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! # `ruview-survey` — node self-survey, check mode (ADR-381)
|
||||
//!
|
||||
//! **Check mode only. Warn-only. This crate never writes node positions.**
|
||||
//!
|
||||
//! `--node-positions` is typed by hand and keyed by node id (#1804, #1866).
|
||||
//! A swapped id, positions entered in feet, or a node moved after setup all
|
||||
//! degrade multistatic fusion without any error. This crate takes pairwise
|
||||
//! node-to-node ranges ([`RangeObservation`], from Wi-Fi FTM today and UWB
|
||||
//! later) and the configured positions, and reports:
|
||||
//!
|
||||
//! - a per-link residual (measured minus configured distance) with a
|
||||
//! tolerance and a status ([`LinkResidual`]);
|
||||
//! - a fitted scale, and a unit-mismatch finding when the scale sits near a
|
||||
//! feet/inches/centimetre factor ([`Finding::UnitMismatch`]);
|
||||
//! - a suggested id swap when a different assignment of the configured
|
||||
//! positions to node ids fits much better ([`Finding::IdSwapSuggested`]),
|
||||
//! or an explicit ambiguity when the layout is symmetric;
|
||||
//! - a node whose links disagree as a group ([`Finding::NodeMoved`]).
|
||||
//!
|
||||
//! Ranges see only distances, so any rotation, reflection or translation of
|
||||
//! the layout fits equally well. Check mode compares distances, not
|
||||
//! coordinates, and reports symmetric ambiguity rather than guessing.
|
||||
//!
|
||||
//! ## Evidence
|
||||
//!
|
||||
//! Default tolerances for FTM ([`CheckParams::ftm_uncalibrated`]) come from a
|
||||
//! MEASURED two-board ESP32-S3 bench on 2026-10-04 (20 MHz, channel 1, 32
|
||||
//! frames per session). Within one responder start the spread was ±0.2 to
|
||||
//! 0.4 m, but the bias moved from −0.5 to +1.9 m between responder starts at
|
||||
//! 1.14 m, a 3.71 m link read 7.8 m, and readings clamp to 0 below about
|
||||
//! 0.8 m. That is good enough to catch gross configuration mistakes and not
|
||||
//! good enough to place nodes. Fill mode (writing solved positions) is gated
|
||||
//! off by [`fill_gate`] until a source has a MEASURED p90 link error of at
|
||||
//! most [`FILL_MAX_P90_M`].
|
||||
//!
|
||||
//! ## Determinism and bounds
|
||||
//!
|
||||
//! No I/O, no clock, no randomness. Inputs are bounded ([`MAX_NODES`],
|
||||
//! [`MAX_OBSERVATIONS`], [`MAX_RANGE_M`]); the id-swap search runs only up to
|
||||
//! [`MAX_SWAP_NODES`] nodes. Malformed input yields a typed [`SurveyError`],
|
||||
//! never a panic.
|
||||
//!
|
||||
//! ```
|
||||
//! use ruview_survey::*;
|
||||
//!
|
||||
//! let positions = parse_node_positions("1:0,0,1;2:4,0,1;3:0,3,1").unwrap();
|
||||
//! let obs = [
|
||||
//! RangeObservation::ftm(1, 2, 4.2, 0.3),
|
||||
//! RangeObservation::ftm(1, 3, 3.1, 0.3),
|
||||
//! RangeObservation::ftm(2, 3, 5.4, 0.3),
|
||||
//! ];
|
||||
//! let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap();
|
||||
//! assert_eq!(report.verdict, Verdict::Consistent);
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
mod calibration;
|
||||
mod check;
|
||||
mod fill;
|
||||
mod observation;
|
||||
mod positions;
|
||||
mod report;
|
||||
|
||||
pub use calibration::CalibrationTable;
|
||||
pub use check::{check, MAX_SWAP_NODES};
|
||||
pub use fill::{fill_gate, EvidenceTag, FillBlocked, SourceEvidence, FILL_MAX_P90_M};
|
||||
pub use observation::{RangeMethod, RangeObservation, MAX_OBSERVATIONS, MAX_RANGE_M};
|
||||
pub use positions::{parse_node_positions, NodePositions, MAX_NODES};
|
||||
pub use report::{CheckParams, CheckReport, Finding, LinkResidual, LinkStatus, ScaleFit, Verdict};
|
||||
|
||||
/// Errors from malformed survey input.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum SurveyError {
|
||||
/// A range observation failed validation.
|
||||
#[error("invalid range observation: {0}")]
|
||||
InvalidObservation(String),
|
||||
/// The node position string failed to parse.
|
||||
#[error("invalid node positions: {0}")]
|
||||
InvalidPositions(String),
|
||||
/// An input exceeded its bound.
|
||||
#[error("too many {what}: {got} > {max}")]
|
||||
TooMany {
|
||||
/// What overflowed.
|
||||
what: &'static str,
|
||||
/// How many were supplied.
|
||||
got: usize,
|
||||
/// The bound.
|
||||
max: usize,
|
||||
},
|
||||
/// A calibration reference was unusable.
|
||||
#[error("calibration: {0}")]
|
||||
Calibration(String),
|
||||
}
|
||||
|
||||
/// Median of a non-empty slice. Returns `None` for an empty slice.
|
||||
pub(crate) fn median(values: &[f64]) -> Option<f64> {
|
||||
if values.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut v = values.to_vec();
|
||||
v.sort_by(|a, b| a.total_cmp(b));
|
||||
let mid = v.len() / 2;
|
||||
Some(if v.len() % 2 == 0 {
|
||||
(v[mid - 1] + v[mid]) / 2.0
|
||||
} else {
|
||||
v[mid]
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::median;
|
||||
|
||||
#[test]
|
||||
fn median_handles_odd_even_and_empty() {
|
||||
assert_eq!(median(&[]), None);
|
||||
assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0));
|
||||
assert_eq!(median(&[4.0, 1.0, 2.0, 3.0]), Some(2.5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! [`RangeObservation`]: one measured distance between two fixed nodes.
|
||||
//!
|
||||
//! The record is source-agnostic. FTM, UWB, BLE Channel Sounding and a tape
|
||||
//! measure all produce the same shape; [`RangeMethod`] says which one did.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::SurveyError;
|
||||
|
||||
/// Largest range accepted, in metres. Anything longer is a parse or unit
|
||||
/// error for an indoor deployment.
|
||||
pub const MAX_RANGE_M: f64 = 100.0;
|
||||
|
||||
/// Most observations accepted by one [`crate::check`] call.
|
||||
pub const MAX_OBSERVATIONS: usize = 10_000;
|
||||
|
||||
/// Which ranging source produced an observation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RangeMethod {
|
||||
/// Wi-Fi Fine Timing Measurement (802.11mc RTT), ESP32 STA to SoftAP.
|
||||
Ftm,
|
||||
/// UWB two-way ranging (ADR-144 hardware family).
|
||||
Uwb,
|
||||
/// Bluetooth Channel Sounding, through the BLE CS adapter boundary.
|
||||
BleCs,
|
||||
/// Operator-entered distance (tape or laser).
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// One measured node-to-node distance.
|
||||
///
|
||||
/// For FTM, `initiator` is the station and `responder` is the SoftAP. The
|
||||
/// pair is unordered for geometry, but the roles matter for calibration
|
||||
/// because the measured bias moved with each responder start.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RangeObservation {
|
||||
/// Node id that initiated the exchange (`node_id` in `--node-positions`).
|
||||
pub initiator: u8,
|
||||
/// Node id that responded.
|
||||
pub responder: u8,
|
||||
/// Reported distance in metres (session median for FTM).
|
||||
pub range_m: f64,
|
||||
/// One-sigma spread of the session in metres. Use 0 when unknown.
|
||||
#[serde(default)]
|
||||
pub sigma_m: f64,
|
||||
/// Ranging method.
|
||||
pub method: RangeMethod,
|
||||
/// Number of valid frames or exchanges behind `range_m`.
|
||||
#[serde(default)]
|
||||
pub samples: u16,
|
||||
/// Median RSSI of the session, if the source reports one.
|
||||
#[serde(default)]
|
||||
pub rssi_dbm: Option<i16>,
|
||||
/// Channel bandwidth in MHz, if relevant (FTM: 20 or 40).
|
||||
#[serde(default)]
|
||||
pub bandwidth_mhz: Option<u16>,
|
||||
/// Host receive time in microseconds since the Unix epoch.
|
||||
#[serde(default)]
|
||||
pub at_us: u64,
|
||||
/// Boot counter or boot nonce of the responder. Calibration is only
|
||||
/// valid within one responder boot.
|
||||
#[serde(default)]
|
||||
pub responder_boot: Option<u32>,
|
||||
/// True once a per-boot calibration offset has been applied.
|
||||
#[serde(default)]
|
||||
pub calibrated: bool,
|
||||
}
|
||||
|
||||
impl RangeObservation {
|
||||
/// A minimal FTM observation, mostly for tests and examples.
|
||||
pub fn ftm(initiator: u8, responder: u8, range_m: f64, sigma_m: f64) -> Self {
|
||||
Self {
|
||||
initiator,
|
||||
responder,
|
||||
range_m,
|
||||
sigma_m,
|
||||
method: RangeMethod::Ftm,
|
||||
samples: 0,
|
||||
rssi_dbm: None,
|
||||
bandwidth_mhz: None,
|
||||
at_us: 0,
|
||||
responder_boot: None,
|
||||
calibrated: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The unordered node pair, smaller id first.
|
||||
pub fn pair(&self) -> (u8, u8) {
|
||||
if self.initiator <= self.responder {
|
||||
(self.initiator, self.responder)
|
||||
} else {
|
||||
(self.responder, self.initiator)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject self links, non-finite values and out-of-range distances.
|
||||
pub fn validate(&self) -> Result<(), SurveyError> {
|
||||
if self.initiator == self.responder {
|
||||
return Err(SurveyError::InvalidObservation(format!(
|
||||
"node {} ranged to itself",
|
||||
self.initiator
|
||||
)));
|
||||
}
|
||||
if !self.range_m.is_finite() || !(0.0..=MAX_RANGE_M).contains(&self.range_m) {
|
||||
return Err(SurveyError::InvalidObservation(format!(
|
||||
"range {} m for {}-{} is outside 0..={MAX_RANGE_M}",
|
||||
self.range_m, self.initiator, self.responder
|
||||
)));
|
||||
}
|
||||
if !self.sigma_m.is_finite() || !(0.0..=MAX_RANGE_M).contains(&self.sigma_m) {
|
||||
return Err(SurveyError::InvalidObservation(format!(
|
||||
"sigma {} m for {}-{} is not a finite non-negative value",
|
||||
self.sigma_m, self.initiator, self.responder
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pair_is_unordered() {
|
||||
assert_eq!(RangeObservation::ftm(5, 2, 1.0, 0.1).pair(), (2, 5));
|
||||
assert_eq!(RangeObservation::ftm(2, 5, 1.0, 0.1).pair(), (2, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_rejects_bad_values() {
|
||||
assert!(RangeObservation::ftm(1, 1, 1.0, 0.1).validate().is_err());
|
||||
assert!(RangeObservation::ftm(1, 2, -0.1, 0.1).validate().is_err());
|
||||
assert!(RangeObservation::ftm(1, 2, f64::NAN, 0.1)
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(RangeObservation::ftm(1, 2, 101.0, 0.1).validate().is_err());
|
||||
assert!(RangeObservation::ftm(1, 2, 1.0, f64::INFINITY)
|
||||
.validate()
|
||||
.is_err());
|
||||
assert!(RangeObservation::ftm(1, 2, 0.0, 0.0).validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_with_optional_fields_missing() {
|
||||
let o: RangeObservation =
|
||||
serde_json::from_str(r#"{"initiator":1,"responder":2,"range_m":3.5,"method":"ftm"}"#)
|
||||
.unwrap();
|
||||
assert_eq!(o.sigma_m, 0.0);
|
||||
assert_eq!(o.responder_boot, None);
|
||||
assert!(!o.calibrated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Parse the sensing server's `--node-positions` string.
|
||||
//!
|
||||
//! Same grammar as `field_bridge::parse_node_position_entries`:
|
||||
//! `node_id:x,y,z;node_id:x,y,z` or, without the prefix, `x,y,z;...` keyed by
|
||||
//! list index from 0. The server skips bad entries with a warning; a checker
|
||||
//! should not, so this parser returns an error instead.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::SurveyError;
|
||||
|
||||
/// Most node positions accepted.
|
||||
pub const MAX_NODES: usize = 64;
|
||||
|
||||
/// Configured positions keyed by node id, in metres.
|
||||
pub type NodePositions = BTreeMap<u8, [f64; 3]>;
|
||||
|
||||
/// Parse a `--node-positions` value into a keyed map.
|
||||
pub fn parse_node_positions(input: &str) -> Result<NodePositions, SurveyError> {
|
||||
let entries: Vec<&str> = input
|
||||
.split(';')
|
||||
.map(str::trim)
|
||||
.filter(|e| !e.is_empty())
|
||||
.collect();
|
||||
if entries.len() > MAX_NODES {
|
||||
return Err(SurveyError::TooMany {
|
||||
what: "node positions",
|
||||
got: entries.len(),
|
||||
max: MAX_NODES,
|
||||
});
|
||||
}
|
||||
let mut map = NodePositions::new();
|
||||
for (idx, entry) in entries.iter().enumerate() {
|
||||
let (id, triplet) = match entry.split_once(':') {
|
||||
Some((id, rest)) => {
|
||||
let id = id.trim().parse::<u8>().map_err(|_| {
|
||||
SurveyError::InvalidPositions(format!(
|
||||
"entry {idx} '{entry}': node id must be 0..=255"
|
||||
))
|
||||
})?;
|
||||
(id, rest)
|
||||
}
|
||||
None => (
|
||||
u8::try_from(idx).map_err(|_| {
|
||||
SurveyError::InvalidPositions(format!("entry {idx}: index exceeds 255"))
|
||||
})?,
|
||||
*entry,
|
||||
),
|
||||
};
|
||||
let parts: Vec<&str> = triplet.split(',').map(str::trim).collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(SurveyError::InvalidPositions(format!(
|
||||
"entry {idx} '{entry}': expected x,y,z"
|
||||
)));
|
||||
}
|
||||
let mut xyz = [0.0f64; 3];
|
||||
for (slot, part) in xyz.iter_mut().zip(&parts) {
|
||||
let v = part.parse::<f64>().map_err(|_| {
|
||||
SurveyError::InvalidPositions(format!(
|
||||
"entry {idx} '{entry}': '{part}' is not a number"
|
||||
))
|
||||
})?;
|
||||
if !v.is_finite() {
|
||||
return Err(SurveyError::InvalidPositions(format!(
|
||||
"entry {idx} '{entry}': non-finite coordinate"
|
||||
)));
|
||||
}
|
||||
*slot = v;
|
||||
}
|
||||
if map.insert(id, xyz).is_some() {
|
||||
return Err(SurveyError::InvalidPositions(format!(
|
||||
"node {id} is given twice"
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Euclidean distance between two positions.
|
||||
pub(crate) fn distance(a: &[f64; 3], b: &[f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_keyed_and_indexed_forms() {
|
||||
let keyed = parse_node_positions("3:0,0,1.5; 7:4.5,0,1.5").unwrap();
|
||||
assert_eq!(keyed.len(), 2);
|
||||
assert_eq!(keyed[&7], [4.5, 0.0, 1.5]);
|
||||
|
||||
let indexed = parse_node_positions("0,0,1;2,0,1").unwrap();
|
||||
assert_eq!(indexed[&1], [2.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_entries() {
|
||||
assert!(parse_node_positions("1:0,0").is_err());
|
||||
assert!(parse_node_positions("x:0,0,0").is_err());
|
||||
assert!(parse_node_positions("1:0,a,0").is_err());
|
||||
assert!(parse_node_positions("1:0,0,inf").is_err());
|
||||
assert!(parse_node_positions("1:0,0,0;1:1,1,1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_is_empty_map() {
|
||||
assert!(parse_node_positions("").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_too_many_entries() {
|
||||
let s = (0..=MAX_NODES)
|
||||
.map(|i| format!("{i}:0,0,0"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
assert!(matches!(
|
||||
parse_node_positions(&s),
|
||||
Err(SurveyError::TooMany { .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Check-mode parameters and report types.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Tolerances and gates for [`crate::check`].
|
||||
///
|
||||
/// A link's sigma is the larger of the median per-session sigma and, with
|
||||
/// three or more sessions, the spread of the session ranges. Its tolerance
|
||||
/// is `floor + sigma_k × sigma`, where `floor` is
|
||||
/// [`CheckParams::calibrated_floor_m`] when every observation on the link was
|
||||
/// calibrated for the current responder boot and [`CheckParams::floor_m`]
|
||||
/// otherwise. A link whose sigma exceeds [`CheckParams::max_sigma_m`] is not
|
||||
/// scored: a wide tolerance would otherwise accept anything.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct CheckParams {
|
||||
/// Tolerance floor for uncalibrated links, in metres.
|
||||
pub floor_m: f64,
|
||||
/// Tolerance floor for calibrated links, in metres.
|
||||
pub calibrated_floor_m: f64,
|
||||
/// Multiplier on the per-link sigma.
|
||||
pub sigma_k: f64,
|
||||
/// Links whose configured length is below this are not scored.
|
||||
pub min_expected_m: f64,
|
||||
/// Observations below this RSSI are dropped.
|
||||
pub min_rssi_dbm: i16,
|
||||
/// Links with a sigma above this are not scored.
|
||||
pub max_sigma_m: f64,
|
||||
/// A swap is suggested only if it cuts the assignment cost to at most
|
||||
/// this fraction of the configured assignment's cost.
|
||||
pub swap_gain: f64,
|
||||
/// A swap is suggested only if the configured assignment's cost is at
|
||||
/// least this (in squared tolerances).
|
||||
pub min_swap_cost: f64,
|
||||
/// Relative band around a unit factor that counts as a unit match.
|
||||
pub unit_band: f64,
|
||||
}
|
||||
|
||||
impl CheckParams {
|
||||
/// Wi-Fi FTM, ESP32-S3, 20 MHz, from the 2026-10-04 bench (MEASURED).
|
||||
///
|
||||
/// - `floor_m = 2.0`: bias between responder starts at 1.14 m ranged
|
||||
/// from −0.5 to +1.9 m. One start at 3.71 m read +4.1 m and will be
|
||||
/// flagged as a link outlier; that is the honest state of uncalibrated
|
||||
/// FTM, so a single-link outlier is reported as such and not as a
|
||||
/// configuration error.
|
||||
/// - `calibrated_floor_m = 0.6`: spread within one start was ±0.2 to
|
||||
/// 0.4 m with no drift beyond that over two minutes. Longer stability is untested
|
||||
/// (ESTIMATE).
|
||||
/// - `min_expected_m = 1.0`: readings clamped to 0 below about 0.8 m.
|
||||
/// - `min_rssi_dbm = -70`: Espressif's guidance. The bench's 7.7 m
|
||||
/// placement outlier had a median of −68 dBm and passed this gate.
|
||||
/// - `max_sigma_m = 1.0`: that outlier's session spread was 1.92 m; every
|
||||
/// other run's spread was 0.49 m or less.
|
||||
pub fn ftm_uncalibrated() -> Self {
|
||||
Self {
|
||||
floor_m: 2.0,
|
||||
calibrated_floor_m: 0.6,
|
||||
sigma_k: 3.0,
|
||||
min_expected_m: 1.0,
|
||||
min_rssi_dbm: -70,
|
||||
max_sigma_m: 1.0,
|
||||
swap_gain: 0.5,
|
||||
min_swap_cost: 1.0,
|
||||
unit_band: 0.2,
|
||||
}
|
||||
}
|
||||
|
||||
/// UWB two-way ranging. ESTIMATE pending our own bench: published
|
||||
/// line-of-sight accuracy is about 0.1 m (CLAIMED).
|
||||
pub fn uwb() -> Self {
|
||||
Self {
|
||||
floor_m: 0.3,
|
||||
calibrated_floor_m: 0.3,
|
||||
sigma_k: 3.0,
|
||||
min_expected_m: 0.0,
|
||||
min_rssi_dbm: i16::MIN,
|
||||
max_sigma_m: 0.3,
|
||||
swap_gain: 0.5,
|
||||
min_swap_cost: 1.0,
|
||||
unit_band: 0.1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of one link.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LinkStatus {
|
||||
/// Residual within tolerance.
|
||||
Ok,
|
||||
/// Residual beyond tolerance.
|
||||
Outlier,
|
||||
/// Configured length below `min_expected_m`; not scored.
|
||||
TooShort,
|
||||
/// Every observation was below `min_rssi_dbm`; not scored.
|
||||
Weak,
|
||||
/// Sigma above `max_sigma_m`; not scored.
|
||||
Noisy,
|
||||
}
|
||||
|
||||
/// Measured versus configured length of one node pair.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct LinkResidual {
|
||||
/// Smaller node id.
|
||||
pub a: u8,
|
||||
/// Larger node id.
|
||||
pub b: u8,
|
||||
/// Median measured range, metres.
|
||||
pub measured_m: f64,
|
||||
/// Distance between the configured positions, metres.
|
||||
pub expected_m: f64,
|
||||
/// `measured_m − expected_m`.
|
||||
pub residual_m: f64,
|
||||
/// Tolerance applied.
|
||||
pub tolerance_m: f64,
|
||||
/// Link sigma (see [`CheckParams`]).
|
||||
pub sigma_m: f64,
|
||||
/// Observations behind the median.
|
||||
pub observations: usize,
|
||||
/// True if every observation used was calibrated.
|
||||
pub calibrated: bool,
|
||||
/// Status.
|
||||
pub status: LinkStatus,
|
||||
}
|
||||
|
||||
/// Least-squares scale `s` in `measured ≈ s × expected`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct ScaleFit {
|
||||
/// Fitted scale.
|
||||
pub scale: f64,
|
||||
/// Links used.
|
||||
pub links: usize,
|
||||
}
|
||||
|
||||
/// A warning or note from [`crate::check`].
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||
pub enum Finding {
|
||||
/// Observations name a node that has no configured position.
|
||||
UnknownNode {
|
||||
/// Node id.
|
||||
node: u8,
|
||||
/// Observations dropped.
|
||||
observations: usize,
|
||||
},
|
||||
/// A configured node has no scored link, so it was not checked.
|
||||
UnrangedNode {
|
||||
/// Node id.
|
||||
node: u8,
|
||||
},
|
||||
/// The fitted scale matches a unit factor: positions were probably
|
||||
/// entered in that unit while the server reads metres.
|
||||
UnitMismatch {
|
||||
/// Unit name.
|
||||
unit: String,
|
||||
/// Factor from that unit to metres.
|
||||
factor: f64,
|
||||
/// Fitted scale.
|
||||
fitted_scale: f64,
|
||||
},
|
||||
/// Another assignment of the configured positions fits much better.
|
||||
/// Each pair is `(node, position_of)`: the node's ranges match the
|
||||
/// position configured for `position_of`. When several assignments fit
|
||||
/// equally well, this is the one that changes the fewest ids.
|
||||
IdSwapSuggested {
|
||||
/// Changed assignments only.
|
||||
assignment: Vec<(u8, u8)>,
|
||||
/// Cost of the configured assignment.
|
||||
cost_configured: f64,
|
||||
/// Cost of the suggested assignment.
|
||||
cost_suggested: f64,
|
||||
/// Assignments that fit as well as the suggestion, itself included.
|
||||
/// Above 1, the layout is (nearly) symmetric and the suggestion
|
||||
/// rests on the fewest-changes rule.
|
||||
equivalent_assignments: usize,
|
||||
},
|
||||
/// The configured assignment fits badly and the best-fitting
|
||||
/// assignments that change the fewest ids are tied (symmetric layout);
|
||||
/// no single swap can be named.
|
||||
IdSwapAmbiguous {
|
||||
/// Number of equally good alternative assignments.
|
||||
equivalent_assignments: usize,
|
||||
},
|
||||
/// Most of a node's links disagree: it was probably moved.
|
||||
NodeMoved {
|
||||
/// Node id.
|
||||
node: u8,
|
||||
/// Outlier links touching it.
|
||||
flagged_links: usize,
|
||||
/// Scored links touching it.
|
||||
links: usize,
|
||||
},
|
||||
/// One link is out of tolerance with no configuration explanation
|
||||
/// (bias, a body or wall in the path, or a bad session).
|
||||
LinkOutlier {
|
||||
/// Smaller node id.
|
||||
a: u8,
|
||||
/// Larger node id.
|
||||
b: u8,
|
||||
/// Residual, metres.
|
||||
residual_m: f64,
|
||||
},
|
||||
/// Too many nodes for the exhaustive id-swap search.
|
||||
SwapSearchSkipped {
|
||||
/// Nodes with scored links.
|
||||
nodes: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl Finding {
|
||||
/// True for findings that should raise the verdict to warnings.
|
||||
pub fn is_warning(&self) -> bool {
|
||||
!matches!(
|
||||
self,
|
||||
Finding::UnrangedNode { .. } | Finding::SwapSearchSkipped { .. }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Overall result.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Verdict {
|
||||
/// Every scored link agrees with the configuration.
|
||||
Consistent,
|
||||
/// At least one warning finding.
|
||||
Warnings,
|
||||
/// No link could be scored.
|
||||
Insufficient,
|
||||
}
|
||||
|
||||
/// Result of [`crate::check`].
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct CheckReport {
|
||||
/// One entry per observed pair whose nodes are both configured.
|
||||
pub links: Vec<LinkResidual>,
|
||||
/// Scale fit over scored links, when at least two exist.
|
||||
pub scale: Option<ScaleFit>,
|
||||
/// Findings, in the order they were derived.
|
||||
pub findings: Vec<Finding>,
|
||||
/// Verdict.
|
||||
pub verdict: Verdict,
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
//! SYNTHETIC check-mode scenarios. Ranges are generated from a known layout
|
||||
//! with FTM-like bias and noise taken from the 2026-10-04 bench envelope
|
||||
//! (bias −0.5 to +1.9 m per link, spread ±0.3 m). No hardware is involved.
|
||||
|
||||
use ruview_survey::*;
|
||||
|
||||
/// Asymmetric five-node layout in a 10 × 6 m space, metres.
|
||||
const TRUTH: [(u8, [f64; 3]); 5] = [
|
||||
(1, [0.3, 0.2, 1.0]),
|
||||
(2, [9.6, 0.5, 1.2]),
|
||||
(3, [8.8, 5.7, 1.0]),
|
||||
(4, [1.1, 5.2, 1.4]),
|
||||
(5, [4.4, 2.1, 0.8]),
|
||||
];
|
||||
|
||||
fn positions_string(layout: &[(u8, [f64; 3])], scale: f64) -> String {
|
||||
layout
|
||||
.iter()
|
||||
.map(|(id, p)| format!("{id}:{},{},{}", p[0] * scale, p[1] * scale, p[2] * scale))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";")
|
||||
}
|
||||
|
||||
fn dist(a: &[f64; 3], b: &[f64; 3]) -> f64 {
|
||||
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
|
||||
}
|
||||
|
||||
/// Deterministic pseudo-random value in [0, 1) from a seed.
|
||||
fn unit(seed: u64) -> f64 {
|
||||
let x = seed
|
||||
.wrapping_mul(6_364_136_223_846_793_005)
|
||||
.wrapping_add(1_442_695_040_888_963_407);
|
||||
((x >> 11) as f64) / ((1u64 << 53) as f64)
|
||||
}
|
||||
|
||||
/// Ranges for every pair of `layout`: per-link bias in [bias_lo, bias_hi]
|
||||
/// plus ±`noise` m session noise (reported as sigma), `sessions` per link.
|
||||
fn ranges_with(
|
||||
layout: &[(u8, [f64; 3])],
|
||||
bias_lo: f64,
|
||||
bias_hi: f64,
|
||||
noise: f64,
|
||||
sessions: u64,
|
||||
) -> Vec<RangeObservation> {
|
||||
let mut out = Vec::new();
|
||||
for (i, (a, pa)) in layout.iter().enumerate() {
|
||||
for (b, pb) in layout.iter().skip(i + 1) {
|
||||
let link_seed = u64::from(*a) * 131 + u64::from(*b);
|
||||
let bias = bias_lo + (bias_hi - bias_lo) * unit(link_seed);
|
||||
for s in 0..sessions {
|
||||
let jitter = noise * (2.0 * unit(link_seed * 977 + s) - 1.0);
|
||||
let range = (dist(pa, pb) + bias + jitter).max(0.0);
|
||||
out.push(RangeObservation {
|
||||
rssi_dbm: Some(-55),
|
||||
..RangeObservation::ftm(*a, *b, range, noise)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// FTM-like ranges: ±0.3 m session noise, as on the bench.
|
||||
fn ranges(
|
||||
layout: &[(u8, [f64; 3])],
|
||||
bias_lo: f64,
|
||||
bias_hi: f64,
|
||||
sessions: u64,
|
||||
) -> Vec<RangeObservation> {
|
||||
ranges_with(layout, bias_lo, bias_hi, 0.3, sessions)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn correct_config_with_ftm_bias_is_consistent() {
|
||||
let positions = parse_node_positions(&positions_string(&TRUTH, 1.0)).unwrap();
|
||||
let obs = ranges(&TRUTH, -0.5, 1.9, 5);
|
||||
let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap();
|
||||
assert_eq!(report.verdict, Verdict::Consistent, "{report:#?}");
|
||||
assert_eq!(report.links.len(), 10);
|
||||
assert!(report.links.iter().all(|l| l.status == LinkStatus::Ok));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_entered_in_feet_are_flagged_as_unit_mismatch() {
|
||||
// Operator measured in feet and typed the numbers as if they were metres.
|
||||
let feet = 1.0 / 0.3048;
|
||||
let positions = parse_node_positions(&positions_string(&TRUTH, feet)).unwrap();
|
||||
let obs = ranges(&TRUTH, -0.5, 1.9, 5);
|
||||
let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap();
|
||||
assert_eq!(report.verdict, Verdict::Warnings);
|
||||
let unit = report
|
||||
.findings
|
||||
.iter()
|
||||
.find_map(|f| match f {
|
||||
Finding::UnitMismatch {
|
||||
unit, fitted_scale, ..
|
||||
} => Some((unit.clone(), *fitted_scale)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no unit finding: {report:#?}"));
|
||||
assert_eq!(unit.0, "feet");
|
||||
assert!((0.25..0.37).contains(&unit.1), "scale {}", unit.1);
|
||||
// The unit finding explains the residuals; no swap or per-link noise.
|
||||
assert!(!report.findings.iter().any(|f| matches!(
|
||||
f,
|
||||
Finding::IdSwapSuggested { .. } | Finding::LinkOutlier { .. } | Finding::NodeMoved { .. }
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_in_centimetres_are_flagged() {
|
||||
let positions = parse_node_positions(&positions_string(&TRUTH, 100.0)).unwrap();
|
||||
let obs = ranges(&TRUTH, -0.5, 1.9, 3);
|
||||
let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap();
|
||||
assert!(report
|
||||
.findings
|
||||
.iter()
|
||||
.any(|f| matches!(f, Finding::UnitMismatch { unit, .. } if unit == "centimetres")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapped_ids_are_suggested_back() {
|
||||
// The operator typed node 2's position under id 4 and vice versa.
|
||||
let mut configured = TRUTH;
|
||||
configured[1].1 = TRUTH[3].1;
|
||||
configured[3].1 = TRUTH[1].1;
|
||||
let positions = parse_node_positions(&positions_string(&configured, 1.0)).unwrap();
|
||||
let obs = ranges(&TRUTH, -0.5, 1.9, 5);
|
||||
let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap();
|
||||
assert_eq!(report.verdict, Verdict::Warnings);
|
||||
let (assignment, equivalent) = report
|
||||
.findings
|
||||
.iter()
|
||||
.find_map(|f| match f {
|
||||
Finding::IdSwapSuggested {
|
||||
assignment,
|
||||
equivalent_assignments,
|
||||
..
|
||||
} => Some((assignment.clone(), *equivalent_assignments)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no swap finding: {report:#?}"));
|
||||
assert_eq!(assignment, vec![(2, 4), (4, 2)]);
|
||||
// Nodes 1-4 sit near the corners of a rectangle, so at FTM tolerances a
|
||||
// four-id rotation fits about as well. The fewest-changes rule picks
|
||||
// the two-id swap and the tie is reported.
|
||||
assert!(equivalent >= 2, "{report:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_way_rotation_is_suggested_back() {
|
||||
// Ids 1, 3, 5 rotated: 1 got 3's position, 3 got 5's, 5 got 1's.
|
||||
let mut configured = TRUTH;
|
||||
configured[0].1 = TRUTH[2].1;
|
||||
configured[2].1 = TRUTH[4].1;
|
||||
configured[4].1 = TRUTH[0].1;
|
||||
let positions = parse_node_positions(&positions_string(&configured, 1.0)).unwrap();
|
||||
let obs = ranges(&TRUTH, -0.5, 1.9, 5);
|
||||
let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap();
|
||||
let assignment = report
|
||||
.findings
|
||||
.iter()
|
||||
.find_map(|f| match f {
|
||||
Finding::IdSwapSuggested { assignment, .. } => Some(assignment.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no swap finding: {report:#?}"));
|
||||
// Node 1's ranges match the position configured for node 5, and so on.
|
||||
assert_eq!(assignment, vec![(1, 5), (3, 1), (5, 3)]);
|
||||
}
|
||||
|
||||
/// A 6 × 4 m rectangle with a node in each corner.
|
||||
const RECT: [(u8, [f64; 3]); 4] = [
|
||||
(1, [0.0, 0.0, 1.0]),
|
||||
(2, [6.0, 0.0, 1.0]),
|
||||
(3, [6.0, 4.0, 1.0]),
|
||||
(4, [0.0, 4.0, 1.0]),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn relabelling_that_is_a_symmetry_is_invisible() {
|
||||
// Ids rotated 180 degrees (1<->3, 2<->4). Every pairwise distance is
|
||||
// unchanged, so ranges cannot see it and the check must not invent a
|
||||
// swap. Check mode cannot catch this class of mistake.
|
||||
let mut configured = RECT;
|
||||
configured[0].1 = RECT[2].1;
|
||||
configured[2].1 = RECT[0].1;
|
||||
configured[1].1 = RECT[3].1;
|
||||
configured[3].1 = RECT[1].1;
|
||||
let positions = parse_node_positions(&positions_string(&configured, 1.0)).unwrap();
|
||||
let obs = ranges_with(&RECT, 0.0, 0.05, 0.05, 3);
|
||||
let report = check(&positions, &obs, &CheckParams::uwb()).unwrap();
|
||||
assert_eq!(report.verdict, Verdict::Consistent, "{report:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_along_a_wall_of_a_rectangle_is_ambiguous() {
|
||||
// Ids 1 and 2 swapped along the long wall. Mirroring the rectangle
|
||||
// turns that into a 3<->4 swap with identical distances, so both
|
||||
// two-id fixes fit equally and the check must say it cannot choose.
|
||||
let mut configured = RECT;
|
||||
configured[0].1 = RECT[1].1;
|
||||
configured[1].1 = RECT[0].1;
|
||||
let positions = parse_node_positions(&positions_string(&configured, 1.0)).unwrap();
|
||||
let obs = ranges_with(&RECT, 0.0, 0.05, 0.05, 3);
|
||||
let report = check(&positions, &obs, &CheckParams::uwb()).unwrap();
|
||||
assert_eq!(report.verdict, Verdict::Warnings);
|
||||
assert!(
|
||||
report
|
||||
.findings
|
||||
.iter()
|
||||
.any(|f| matches!(f, Finding::IdSwapAmbiguous { equivalent_assignments } if *equivalent_assignments >= 2)),
|
||||
"{report:#?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_node_is_flagged_without_blaming_others() {
|
||||
// Node 5 was moved 2 m after setup; its configured position is stale.
|
||||
// UWB-grade ranges: uncalibrated FTM tolerances hide moves this size.
|
||||
let mut actual = TRUTH;
|
||||
actual[4].1 = [2.6, 1.2, 0.8];
|
||||
let positions = parse_node_positions(&positions_string(&TRUTH, 1.0)).unwrap();
|
||||
let obs = ranges_with(&actual, 0.0, 0.1, 0.05, 5);
|
||||
let report = check(&positions, &obs, &CheckParams::uwb()).unwrap();
|
||||
assert_eq!(report.verdict, Verdict::Warnings);
|
||||
let moved: Vec<u8> = report
|
||||
.findings
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
Finding::NodeMoved { node, .. } => Some(*node),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(moved, vec![5], "{report:#?}");
|
||||
assert!(!report
|
||||
.findings
|
||||
.iter()
|
||||
.any(|f| matches!(f, Finding::IdSwapSuggested { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bench_style_single_link_outlier_is_reported_as_link_outlier() {
|
||||
// One link over-reads by +4.1 m, as the 3.71 m bench start did.
|
||||
let positions = parse_node_positions(&positions_string(&TRUTH, 1.0)).unwrap();
|
||||
let mut obs = ranges(&TRUTH, 0.0, 0.5, 3);
|
||||
for o in obs.iter_mut().filter(|o| o.pair() == (1, 5)) {
|
||||
o.range_m += 4.1;
|
||||
}
|
||||
let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap();
|
||||
assert_eq!(report.verdict, Verdict::Warnings);
|
||||
assert!(report
|
||||
.findings
|
||||
.iter()
|
||||
.any(|f| matches!(f, Finding::LinkOutlier { a: 1, b: 5, .. })));
|
||||
assert!(!report.findings.iter().any(|f| matches!(
|
||||
f,
|
||||
Finding::IdSwapSuggested { .. } | Finding::NodeMoved { .. } | Finding::UnitMismatch { .. }
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_boot_calibration_tightens_tolerance() {
|
||||
// Same responder boot for every session; one reference link at a taped
|
||||
// 3.0 m recovers a +0.7 m bias, so a 1.5 m misplacement that
|
||||
// uncalibrated FTM cannot see becomes visible.
|
||||
let positions = parse_node_positions("1:0,0,1;2:5,0,1").unwrap();
|
||||
let boot = Some(42);
|
||||
let reference: Vec<RangeObservation> = (0..5)
|
||||
.map(|_| RangeObservation {
|
||||
responder_boot: boot,
|
||||
..RangeObservation::ftm(1, 2, 3.0 + 0.7, 0.3)
|
||||
})
|
||||
.collect();
|
||||
let mut table = CalibrationTable::new();
|
||||
let offset = table.record_reference(&reference, 3.0).unwrap();
|
||||
assert!((offset - 0.7).abs() < 1e-9);
|
||||
|
||||
// Node 2 actually sits at 6.5 m, not the configured 5.0 m.
|
||||
let live = RangeObservation {
|
||||
responder_boot: boot,
|
||||
..RangeObservation::ftm(1, 2, 6.5 + 0.7, 0.1)
|
||||
};
|
||||
let raw = check(
|
||||
&positions,
|
||||
std::slice::from_ref(&live),
|
||||
&CheckParams::ftm_uncalibrated(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(raw.links[0].status, LinkStatus::Ok);
|
||||
|
||||
let calibrated = table.apply(&live);
|
||||
let report = check(&positions, &[calibrated], &CheckParams::ftm_uncalibrated()).unwrap();
|
||||
assert!(report.links[0].calibrated);
|
||||
assert_eq!(report.links[0].status, LinkStatus::Outlier, "{report:#?}");
|
||||
|
||||
// After a responder reboot the offset no longer applies.
|
||||
let rebooted = RangeObservation {
|
||||
responder_boot: Some(43),
|
||||
..live
|
||||
};
|
||||
assert!(!table.apply(&rebooted).calibrated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_unranged_nodes_are_reported() {
|
||||
let positions = parse_node_positions("1:0,0,1;2:4,0,1;3:0,3,1;9:2,2,1").unwrap();
|
||||
let obs = [
|
||||
RangeObservation::ftm(1, 2, 4.0, 0.2),
|
||||
RangeObservation::ftm(1, 3, 3.0, 0.2),
|
||||
RangeObservation::ftm(2, 3, 5.0, 0.2),
|
||||
RangeObservation::ftm(1, 7, 2.0, 0.2),
|
||||
];
|
||||
let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap();
|
||||
assert!(report.findings.contains(&Finding::UnknownNode {
|
||||
node: 7,
|
||||
observations: 1
|
||||
}));
|
||||
assert!(report.findings.contains(&Finding::UnrangedNode { node: 9 }));
|
||||
assert_eq!(report.verdict, Verdict::Warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weak_sessions_are_not_scored() {
|
||||
let positions = parse_node_positions("1:0,0,1;2:4,0,1").unwrap();
|
||||
let obs = [RangeObservation {
|
||||
rssi_dbm: Some(-80),
|
||||
..RangeObservation::ftm(1, 2, 12.0, 0.2)
|
||||
}];
|
||||
let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap();
|
||||
assert_eq!(report.links[0].status, LinkStatus::Weak);
|
||||
assert_eq!(report.verdict, Verdict::Insufficient);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noisy_link_is_not_scored() {
|
||||
// Shaped like the bench's 7.7 m run at 1.14 m: −68 dBm passes the RSSI
|
||||
// gate, but the session ranges spread 5.25 to 12.3 m.
|
||||
let positions = parse_node_positions("1:0,0,1;2:4,0,1;3:0,3,1").unwrap();
|
||||
let mut obs = vec![
|
||||
RangeObservation::ftm(1, 3, 3.2, 0.3),
|
||||
RangeObservation::ftm(2, 3, 5.3, 0.3),
|
||||
];
|
||||
for r in [5.25, 6.9, 7.7, 7.75, 9.6, 12.3] {
|
||||
obs.push(RangeObservation {
|
||||
rssi_dbm: Some(-68),
|
||||
..RangeObservation::ftm(1, 2, r, 0.3)
|
||||
});
|
||||
}
|
||||
let report = check(&positions, &obs, &CheckParams::ftm_uncalibrated()).unwrap();
|
||||
let link = report.links.iter().find(|l| (l.a, l.b) == (1, 2)).unwrap();
|
||||
assert_eq!(link.status, LinkStatus::Noisy, "{report:#?}");
|
||||
assert!(link.sigma_m > 1.0);
|
||||
assert_eq!(report.verdict, Verdict::Consistent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_serializes_to_json() {
|
||||
let positions = parse_node_positions(&positions_string(&TRUTH, 1.0)).unwrap();
|
||||
let report = check(
|
||||
&positions,
|
||||
&ranges(&TRUTH, 0.0, 0.5, 2),
|
||||
&CheckParams::ftm_uncalibrated(),
|
||||
)
|
||||
.unwrap();
|
||||
let json = serde_json::to_value(&report).unwrap();
|
||||
assert_eq!(json["verdict"], "consistent");
|
||||
assert_eq!(json["links"].as_array().unwrap().len(), 10);
|
||||
}
|
||||
@@ -137,6 +137,7 @@ impl GaussianMap {
|
||||
e.doppler_variance = (wa * e.doppler_variance + wb * g.doppler_variance) / wsum;
|
||||
e.confidence = (wa + wb - wa * wb).clamp(0.0, 1.0);
|
||||
e.first_seen_ns = e.first_seen_ns.min(g.first_seen_ns);
|
||||
e.decayed_to_ns = e.decayed_to_ns.max(g.decayed_to_ns);
|
||||
if g.timestamp_ns >= e.timestamp_ns {
|
||||
e.timestamp_ns = g.timestamp_ns;
|
||||
e.provenance = g.provenance;
|
||||
@@ -181,9 +182,15 @@ impl GaussianMap {
|
||||
/// repeatedly observed — `τ_eff = τ · (1 + ln(1 + lifetime/τ))` with
|
||||
/// `lifetime = last_seen − first_seen`. A wall confirmed for hours
|
||||
/// outlives a transient echo seen once, even at equal nominal τ.
|
||||
///
|
||||
/// Only the interval since `max(decayed_to_ns, timestamp_ns)` is applied,
|
||||
/// so `decay(t1); decay(t2)` equals `decay(t2)`: the result does not
|
||||
/// depend on how often the caller ticks.
|
||||
pub fn decay(&mut self, now_ns: u64) {
|
||||
for g in &mut self.gaussians {
|
||||
let dt_s = (now_ns.saturating_sub(g.timestamp_ns)) as f64 / 1e9;
|
||||
let from_ns = g.decayed_to_ns.max(g.timestamp_ns);
|
||||
let dt_s = (now_ns.saturating_sub(from_ns)) as f64 / 1e9;
|
||||
g.decayed_to_ns = from_ns.max(now_ns);
|
||||
let lifetime_s = (g.timestamp_ns.saturating_sub(g.first_seen_ns)) as f64 / 1e9;
|
||||
// `RfGaussian::new` validates `decay_tau_s > 0`, but the field is
|
||||
// mutable after construction (`gaussians_mut`); re-clamp here so a
|
||||
@@ -269,6 +276,7 @@ impl GaussianMap {
|
||||
e.confidence = (wa + wb - wa * wb).clamp(0.0, 1.0);
|
||||
e.first_seen_ns = e.first_seen_ns.min(partner.first_seen_ns);
|
||||
e.timestamp_ns = e.timestamp_ns.max(partner.timestamp_ns);
|
||||
e.decayed_to_ns = e.decayed_to_ns.max(partner.decayed_to_ns);
|
||||
for r in partner.source_receipts {
|
||||
if !e.source_receipts.contains(&r)
|
||||
&& e.source_receipts.len() < super::primitive::MAX_SOURCE_RECEIPTS
|
||||
@@ -532,6 +540,61 @@ mod tests {
|
||||
assert!((map.gaussians()[0].position[0]).abs() < 1e-9, "survivor is the wall");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_is_independent_of_call_frequency() {
|
||||
// A transient (lifetime 0 ⇒ τ_eff = τ) and a wall seen for 30 min
|
||||
// (stretched τ_eff), both last observed at t = 1800 s.
|
||||
let build = || {
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
let mut wall = g_at([0.0, 0.0, 1.0], 0.9, 1_800_000_000_000);
|
||||
wall.first_seen_ns = 0;
|
||||
map.insert(wall);
|
||||
map.insert(g_at([6.0, 0.0, 1.0], 0.9, 1_800_000_000_000));
|
||||
map
|
||||
};
|
||||
let end_ns = 1_890_000_000_000; // 90 s after the last observation
|
||||
|
||||
let mut once = build();
|
||||
once.decay(end_ns);
|
||||
|
||||
let mut stepped = build();
|
||||
for k in 1..=90u64 {
|
||||
stepped.decay(1_800_000_000_000 + k * 1_000_000_000);
|
||||
}
|
||||
// Repeating the final timestamp must be a no-op.
|
||||
stepped.decay(end_ns);
|
||||
|
||||
assert_eq!(once.len(), 2, "both survive 90 s of single-call decay");
|
||||
assert_eq!(stepped.len(), once.len(), "no extra pruning");
|
||||
for (a, b) in once.gaussians().iter().zip(stepped.gaussians()) {
|
||||
assert!(
|
||||
(a.confidence - b.confidence).abs() < 1e-12,
|
||||
"decay(t) once gave {} but stepped decay up to t gave {}",
|
||||
a.confidence,
|
||||
b.confidence
|
||||
);
|
||||
}
|
||||
// Transient: exactly 0.9·e^{-90/60}.
|
||||
let transient = &once.gaussians()[1];
|
||||
assert!((transient.confidence - 0.9 * (-1.5f64).exp()).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_persistence_holds_under_frequent_decay() {
|
||||
// Same scenario as `long_lived_structure_outlives_transients_at_equal_tau`,
|
||||
// but decay runs every 10 s instead of once at the end.
|
||||
let mut map = GaussianMap::new(1.0);
|
||||
let mut wall = g_at([0.0, 0.0, 1.0], 0.9, 1_800_000_000_000);
|
||||
wall.first_seen_ns = 0;
|
||||
map.insert(wall);
|
||||
map.insert(g_at([6.0, 0.0, 1.0], 0.9, 1_800_000_000_000));
|
||||
for k in 1..=30u64 {
|
||||
map.decay(1_800_000_000_000 + k * 10_000_000_000);
|
||||
}
|
||||
assert_eq!(map.len(), 1, "only the long-lived structure survives");
|
||||
assert!(map.gaussians()[0].position[0].abs() < 1e-9, "wall survives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_pass_collapses_mutual_overlaps_but_respects_semantics() {
|
||||
// Two overlapping unlabeled Gaussians that insert-fusion misses
|
||||
|
||||
@@ -118,6 +118,12 @@ pub struct RfGaussian {
|
||||
pub first_seen_ns: u64,
|
||||
/// Last-updated timestamp, ns since epoch.
|
||||
pub timestamp_ns: u64,
|
||||
/// Time up to which `confidence` has already been decayed, ns since
|
||||
/// epoch. [`super::map::GaussianMap::decay`] only applies the interval
|
||||
/// after `max(decayed_to_ns, timestamp_ns)`, so repeated calls compose
|
||||
/// instead of compounding. Defaults to 0 for older serialized maps.
|
||||
#[serde(default)]
|
||||
pub decayed_to_ns: u64,
|
||||
/// Confidence e-folding time in seconds (decay clock).
|
||||
pub decay_tau_s: f64,
|
||||
/// Evidence provenance.
|
||||
@@ -204,6 +210,7 @@ impl RfGaussian {
|
||||
confidence,
|
||||
first_seen_ns: timestamp_ns,
|
||||
timestamp_ns,
|
||||
decayed_to_ns: timestamp_ns,
|
||||
decay_tau_s,
|
||||
provenance,
|
||||
source_receipts: Vec::new(),
|
||||
|
||||
@@ -11,7 +11,7 @@ use wifi_densepose_engine::StreamingEngine;
|
||||
use wifi_densepose_geo::types::GeoRegistration;
|
||||
use wifi_densepose_signal::hardware_norm::{CanonicalCsiFrame, HardwareType};
|
||||
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
|
||||
use wifi_densepose_signal::ruvsense::MultiBandCsiFrame;
|
||||
use wifi_densepose_signal::ruvsense::{MultiBandCsiFrame, PhaseReference};
|
||||
|
||||
fn node_frame(node_id: u8, ts_us: u64, n_sub: usize) -> MultiBandCsiFrame {
|
||||
MultiBandCsiFrame {
|
||||
@@ -24,6 +24,7 @@ fn node_frame(node_id: u8, ts_us: u64, n_sub: usize) -> MultiBandCsiFrame {
|
||||
}],
|
||||
frequencies_mhz: vec![2412],
|
||||
coherence: 0.9,
|
||||
phase_reference: PhaseReference::NodeLocal,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ use wifi_densepose_bfld::{PrivacyAction, PrivacyClass, PrivacyMode, PrivacyModeR
|
||||
use wifi_densepose_geo::types::GeoRegistration;
|
||||
use wifi_densepose_ruvector::viewpoint::coherence::ClockQualityScore;
|
||||
use wifi_densepose_signal::ruvsense::fusion_quality::CalibrationId;
|
||||
use wifi_densepose_signal::ruvsense::multistatic::{MultistaticConfig, MultistaticFuser};
|
||||
use wifi_densepose_signal::ruvsense::multistatic::{MultistaticConfig, MultistaticFuser, PhaseFusion};
|
||||
use wifi_densepose_signal::ruvsense::{
|
||||
ArrayCoordinator, ArrayCoordinatorConfig, ArrayNodeInput, ChangePoint, DirectionalEvidence,
|
||||
EvolutionTracker, MultiBandCsiFrame, QualityScore, ReflectorObservation, RfSlam,
|
||||
@@ -109,6 +109,10 @@ pub struct TrustedOutput {
|
||||
/// structural event for the recalibration advisor and names the nodes
|
||||
/// (`weak_side`) closest to splitting off — failure/jamming triage.
|
||||
pub mesh: Option<MeshPartitionReport>,
|
||||
/// Whether phase was combined across nodes this cycle, and why not when it
|
||||
/// was not (issue #1752). Amplitude-only sources report
|
||||
/// `AmplitudeOnly(PhaseUnavailable)`.
|
||||
pub phase_fusion: PhaseFusion,
|
||||
}
|
||||
|
||||
/// Composition root for the RuView streaming engine.
|
||||
@@ -260,6 +264,14 @@ impl StreamingEngine {
|
||||
self.fuser.set_node_positions_by_id(positions);
|
||||
}
|
||||
|
||||
/// Node positions the governed fuser currently holds, keyed by node id.
|
||||
/// Empty until [`Self::set_node_positions`] is called, in which case every
|
||||
/// node fuses at the origin.
|
||||
#[must_use]
|
||||
pub fn node_positions(&self) -> &std::collections::HashMap<u8, [f32; 3]> {
|
||||
self.fuser.node_positions_by_id()
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
|
||||
/// cycle on, the adapter id is part of provenance `model_version` — and
|
||||
/// therefore of the witness — so the exact weights shaping inference are
|
||||
@@ -594,6 +606,7 @@ impl StreamingEngine {
|
||||
witness,
|
||||
recalibration_recommended,
|
||||
mesh,
|
||||
phase_fusion: fused.phase_fusion,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -715,6 +728,7 @@ fn demote_one(c: PrivacyClass) -> PrivacyClass {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wifi_densepose_signal::hardware_norm::{CanonicalCsiFrame, HardwareType};
|
||||
use wifi_densepose_signal::ruvsense::PhaseReference;
|
||||
|
||||
fn node_frame(node_id: u8, ts_us: u64, n_sub: usize) -> MultiBandCsiFrame {
|
||||
MultiBandCsiFrame {
|
||||
@@ -727,6 +741,7 @@ mod tests {
|
||||
}],
|
||||
frequencies_mhz: vec![2412],
|
||||
coherence: 0.9,
|
||||
phase_reference: PhaseReference::NodeLocal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -737,6 +752,20 @@ mod tests {
|
||||
(e, room)
|
||||
}
|
||||
|
||||
/// Issue #1752: independent node oscillators never yield a coherent cycle;
|
||||
/// the trusted output says phase was not combined and why.
|
||||
#[test]
|
||||
fn independent_nodes_report_amplitude_only_phase_fusion() {
|
||||
use wifi_densepose_signal::ruvsense::NonCoherentReason;
|
||||
let (mut e, room) = engine();
|
||||
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
|
||||
let out = e.process_cycle(&frames, CalibrationId(1), room, 10_000).unwrap();
|
||||
assert_eq!(
|
||||
out.phase_fusion,
|
||||
PhaseFusion::AmplitudeOnly(NonCoherentReason::IndependentClocks)
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end trust invariant: a clean cycle produces a SemanticState whose
|
||||
/// provenance names evidence + model + calibration + privacy decision, and
|
||||
/// the calibration id flows from input → QualityScore → provenance.
|
||||
@@ -1015,6 +1044,7 @@ mod tests {
|
||||
}],
|
||||
frequencies_mhz: vec![2412],
|
||||
coherence: 0.9,
|
||||
phase_reference: PhaseReference::NodeLocal,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,8 +79,13 @@ pub fn ruview_sibling_packet_name(magic: u32) -> Option<&'static str> {
|
||||
/// ADR-018 header size in bytes (before I/Q data).
|
||||
const HEADER_SIZE: usize = 20;
|
||||
|
||||
/// Maximum valid subcarrier count for ESP32 (80 MHz bandwidth).
|
||||
const MAX_SUBCARRIERS: usize = 256;
|
||||
/// Maximum I/Q pairs in one frame (all antennas together). The firmware
|
||||
/// serializes into `CSI_MAX_FRAME_SIZE = CSI_HEADER_SIZE + 4 * 256 * 2`
|
||||
/// (`firmware/esp32-csi-node/main/csi_collector.h`) and drops anything larger,
|
||||
/// so a frame never carries more than 2048 I/Q bytes = 1024 pairs. Real
|
||||
/// ESP32-S3 frames carry up to 306 subcarriers on one antenna (merged
|
||||
/// LLTF/HT-LTF buffers), which the old 256-subcarrier cap rejected.
|
||||
const MAX_IQ_PAIRS: usize = 1024;
|
||||
|
||||
/// Maximum antenna count for ESP32.
|
||||
const MAX_ANTENNAS: u8 = 4;
|
||||
@@ -152,10 +157,11 @@ impl Esp32CsiParser {
|
||||
message: "Failed to read subcarrier count".into(),
|
||||
})? as usize;
|
||||
|
||||
if n_subcarriers > MAX_SUBCARRIERS {
|
||||
let max_subcarriers = MAX_IQ_PAIRS / n_antennas as usize;
|
||||
if n_subcarriers > max_subcarriers {
|
||||
return Err(ParseError::InvalidSubcarrierCount {
|
||||
count: n_subcarriers,
|
||||
max: MAX_SUBCARRIERS,
|
||||
max: max_subcarriers,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -252,7 +258,13 @@ impl Esp32CsiParser {
|
||||
// HT/legacy keeps the count heuristic, with 64 included in the 20 MHz
|
||||
// bucket: ESP32 HT20 CSI delivers the full 64-bin FFT grid (live
|
||||
// capture evidence: 148-byte frames = 64 subcarriers on a 20 MHz
|
||||
// channel, issue #1005).
|
||||
// channel, issue #1005). Larger counts come from merged LLTF/HT-LTF
|
||||
// buffers (e.g. 192 = 3 x 64, 306) on 20/40 MHz channels; ESP32-family
|
||||
// radios do not use 80/160 MHz, so anything above 64 maps to 40 MHz.
|
||||
//
|
||||
// CLAIMED: the "HT/legacy count above 64 means 40 MHz" rule is inferred
|
||||
// from the datasheet, not measured on these frames. ESP32-C5/C6 are not
|
||||
// separately covered by it.
|
||||
let bandwidth = if ppdu_type.is_he() {
|
||||
if adr018_flags.bw40 || n_subcarriers > 256 {
|
||||
Bandwidth::Bw40
|
||||
@@ -262,9 +274,7 @@ impl Esp32CsiParser {
|
||||
} else {
|
||||
match n_subcarriers {
|
||||
0..=64 => Bandwidth::Bw20,
|
||||
65..=128 => Bandwidth::Bw40,
|
||||
129..=242 => Bandwidth::Bw80,
|
||||
_ => Bandwidth::Bw160,
|
||||
_ => Bandwidth::Bw40,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -577,4 +587,59 @@ mod tests {
|
||||
assert_eq!(frame.subcarrier_count(), 12); // 3 antennas * 4 subcarriers
|
||||
assert_eq!(frame.metadata.antenna_config.rx_antennas, 3);
|
||||
}
|
||||
|
||||
// ── Real-hardware subcarrier counts ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn s3_306_subcarrier_single_antenna_frame_parses() {
|
||||
// ESP32-S3 nodes on firmware 0.8.12 at 2432 MHz emit 1-antenna frames
|
||||
// with 306 subcarriers (612 I/Q bytes) alongside 192/128/64 counts.
|
||||
let pairs: Vec<(i8, i8)> = (0..306)
|
||||
.map(|k| ((k % 7) as i8, -((k % 5) as i8)))
|
||||
.collect();
|
||||
let data = build_test_frame(2, 1, &pairs);
|
||||
assert_eq!(data.len(), HEADER_SIZE + 612);
|
||||
let (frame, consumed) = Esp32CsiParser::parse_frame(&data).expect("306-sc frame");
|
||||
assert_eq!(consumed, data.len());
|
||||
assert_eq!(frame.metadata.n_subcarriers, 306);
|
||||
assert_eq!(frame.subcarrier_count(), 306);
|
||||
assert_eq!(frame.metadata.rssi_dbm, -50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ht_bandwidth_never_exceeds_40mhz_for_esp32_counts() {
|
||||
// ESP32-family radios use 20/40 MHz channels. Merged-LTF buffers
|
||||
// (192 = 3 x 64, 306) must not be classified as 80/160 MHz.
|
||||
for &n in &[64usize, 128, 188, 192, 306] {
|
||||
let pairs = vec![(1i8, 1i8); n];
|
||||
let (frame, _) = Esp32CsiParser::parse_frame(&build_test_frame(1, 1, &pairs))
|
||||
.unwrap_or_else(|e| panic!("{n}-sc frame: {e}"));
|
||||
let bw = frame.metadata.bandwidth;
|
||||
assert!(
|
||||
matches!(bw, Bandwidth::Bw20 | Bandwidth::Bw40),
|
||||
"{n} subcarriers classified as {bw:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iq_pairs_beyond_firmware_buffer_are_rejected() {
|
||||
// Firmware CSI_MAX_FRAME_SIZE leaves 2048 I/Q bytes = 1024 pairs.
|
||||
let max = vec![(0i8, 0i8); MAX_IQ_PAIRS];
|
||||
assert!(Esp32CsiParser::parse_frame(&build_test_frame(1, 1, &max)).is_ok());
|
||||
let over = vec![(0i8, 0i8); MAX_IQ_PAIRS + 1];
|
||||
assert!(matches!(
|
||||
Esp32CsiParser::parse_frame(&build_test_frame(1, 1, &over)),
|
||||
Err(ParseError::InvalidSubcarrierCount { .. })
|
||||
));
|
||||
// The bound is on total pairs: 2 antennas x 600 subcarriers = 1200.
|
||||
let two_ant = vec![(0i8, 0i8); 1200];
|
||||
assert!(matches!(
|
||||
Esp32CsiParser::parse_frame(&build_test_frame(1, 2, &two_ant)),
|
||||
Err(ParseError::InvalidSubcarrierCount {
|
||||
count: 600,
|
||||
max: 512
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,17 @@ impl TryFrom<u8> for ElementFormat {
|
||||
}
|
||||
}
|
||||
|
||||
/// PPDU format a CSI report was measured on.
|
||||
///
|
||||
/// `Legacy` and `Unknown` were added after a real MT7981 capture (a 2.4 GHz
|
||||
/// client on channel 6) reported `rx_mode = MT_PHY_TYPE_OFDM`. MediaTek's CSI
|
||||
/// driver treats legacy OFDM as a first-class mode — its tone-mask table gives
|
||||
/// `MT_PHY_TYPE_OFDM` its own group — so a vendor bridge must be able to carry
|
||||
/// one. Without these, such a frame had to be dropped, which lost real data.
|
||||
///
|
||||
/// Wire note: a decoder older than this change rejects discriminants 6 and 7 as
|
||||
/// `UnknownPpduType`. Producers should only emit them for frames that genuinely
|
||||
/// are pre-HT or unrecognised.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[repr(u8)]
|
||||
pub enum PpduType {
|
||||
@@ -93,6 +104,11 @@ pub enum PpduType {
|
||||
HeSu = 3,
|
||||
HeMu = 4,
|
||||
Eht = 5,
|
||||
/// Pre-HT: 802.11b CCK or 802.11a/g OFDM.
|
||||
Legacy = 6,
|
||||
/// A PPDU format the producer could not classify. Carried rather than
|
||||
/// guessed, so the frame survives and the ambiguity stays visible.
|
||||
Unknown = 7,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for PpduType {
|
||||
@@ -104,6 +120,8 @@ impl TryFrom<u8> for PpduType {
|
||||
3 => Ok(Self::HeSu),
|
||||
4 => Ok(Self::HeMu),
|
||||
5 => Ok(Self::Eht),
|
||||
6 => Ok(Self::Legacy),
|
||||
7 => Ok(Self::Unknown),
|
||||
_ => Err(CsiParseError::UnknownPpduType(value)),
|
||||
}
|
||||
}
|
||||
@@ -628,6 +646,27 @@ mod tests {
|
||||
let w = f.to_bytes().unwrap();
|
||||
assert_eq!(CsiFrame::from_bytes(&w).unwrap().0, f);
|
||||
}
|
||||
#[test]
|
||||
fn legacy_and_unknown_ppdu_types_round_trip() {
|
||||
// A real MT7981 capture reported MT_PHY_TYPE_OFDM, which has no HT/VHT/HE
|
||||
// equivalent; these two discriminants exist so such a frame is carried
|
||||
// rather than dropped.
|
||||
let mut s = MediatekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
for want in [PpduType::Legacy, PpduType::Unknown] {
|
||||
let mut f = s.next_frame();
|
||||
f.ppdu_type = want;
|
||||
let w = f.to_bytes().unwrap();
|
||||
let (decoded, _) = CsiFrame::from_bytes(&w).unwrap();
|
||||
assert_eq!(decoded.ppdu_type, want);
|
||||
}
|
||||
assert_eq!(PpduType::try_from(6u8).unwrap(), PpduType::Legacy);
|
||||
assert_eq!(PpduType::try_from(7u8).unwrap(), PpduType::Unknown);
|
||||
assert!(matches!(
|
||||
PpduType::try_from(8u8),
|
||||
Err(CsiParseError::UnknownPpduType(8))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc_corruption_is_rejected() {
|
||||
let mut s = MediatekCsiSimulator::new(SimulatorConfig::default()).unwrap();
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "wifi-densepose-mtk-bridge"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
description = "Bridge MediaTek MT7981 (mt76) vendor CSI into the ADR-267 MTC1 wire protocol"
|
||||
license = "MIT OR Apache-2.0"
|
||||
authors.workspace = true
|
||||
readme = "README.md"
|
||||
|
||||
[lib]
|
||||
name = "wifi_densepose_mtk_bridge"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "wifi-densepose-mtk-bridge"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
wifi-densepose-hardware = { path = "../wifi-densepose-hardware" }
|
||||
clap = { version = "4.4", features = ["derive"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
Loaded 100 of 158 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user