Compare commits
12 Commits
8eb808de03
...
0824de7665
| Author | SHA1 | Date |
|---|---|---|
|
|
0824de7665 | |
|
|
e1843c047e | |
|
|
3225eee5be | |
|
|
d2b2cbfc69 | |
|
|
770788fc85 | |
|
|
4d5bdb1570 | |
|
|
8505662af4 | |
|
|
ae40e2b33e | |
|
|
a426ae386d | |
|
|
5a7f431b0e | |
|
|
b816292ead | |
|
|
e38c0f4dcc |
|
|
@ -12,31 +12,50 @@ on:
|
|||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ESP32-S3 Firmware
|
||||
name: Build ESP32-S3 Firmware (${{ matrix.variant }})
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v5.4
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- variant: 8mb
|
||||
sdkconfig: sdkconfig.defaults
|
||||
partition_table_name: partitions_display.csv
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node.bin
|
||||
artifact_pt: partition-table.bin
|
||||
- variant: 4mb
|
||||
sdkconfig: sdkconfig.defaults.4mb
|
||||
partition_table_name: partitions_4mb.csv
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node-4mb.bin
|
||||
artifact_pt: partition-table-4mb.bin
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build firmware
|
||||
- name: Build firmware (${{ matrix.variant }})
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
if [ "${{ matrix.variant }}" != "8mb" ]; then
|
||||
cp "${{ matrix.sdkconfig }}" sdkconfig.defaults
|
||||
fi
|
||||
idf.py set-target esp32s3
|
||||
idf.py build
|
||||
|
||||
- name: Verify binary size (< 1100 KB gate)
|
||||
- name: Verify binary size (< ${{ matrix.size_limit_kb }} KB gate)
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
BIN=build/esp32-csi-node.bin
|
||||
SIZE=$(stat -c%s "$BIN")
|
||||
MAX=$((1100 * 1024))
|
||||
MAX=$((${{ matrix.size_limit_kb }} * 1024))
|
||||
echo "Binary size: $SIZE bytes ($(( SIZE / 1024 )) KB)"
|
||||
echo "Size limit: $MAX bytes (1100 KB — includes WASM runtime + HTTP client for Seed swarm bridge)"
|
||||
echo "Size limit: $MAX bytes (${{ matrix.size_limit_kb }} KB)"
|
||||
if [ "$SIZE" -gt "$MAX" ]; then
|
||||
echo "::error::Firmware binary exceeds 1100 KB size gate ($SIZE > $MAX)"
|
||||
echo "::error::Firmware binary exceeds ${{ matrix.size_limit_kb }} KB size gate ($SIZE > $MAX)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Binary size OK: $SIZE <= $MAX"
|
||||
|
|
@ -47,14 +66,11 @@ jobs:
|
|||
ERRORS=0
|
||||
BIN=build/esp32-csi-node.bin
|
||||
|
||||
# Check binary exists and is non-empty.
|
||||
if [ ! -s "$BIN" ]; then
|
||||
echo "::error::Binary not found or empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check partition table magic (0xAA50 at offset 0).
|
||||
# Use od instead of xxd (xxd not available in espressif/idf container).
|
||||
PT=build/partition_table/partition-table.bin
|
||||
if [ -f "$PT" ]; then
|
||||
MAGIC=$(od -A n -t x1 -N 2 "$PT" | tr -d ' ')
|
||||
|
|
@ -64,14 +80,12 @@ jobs:
|
|||
fi
|
||||
fi
|
||||
|
||||
# Check bootloader exists.
|
||||
BL=build/bootloader/bootloader.bin
|
||||
if [ ! -s "$BL" ]; then
|
||||
echo "::warning::Bootloader binary missing or empty"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
|
||||
# Verify non-zero data in binary (not all 0xFF padding).
|
||||
NONZERO=$(od -A n -t x1 -N 1024 "$BIN" | tr -d ' f\n' | wc -c)
|
||||
if [ "$NONZERO" -lt 100 ]; then
|
||||
echo "::error::Binary appears to be mostly padding (non-zero chars: $NONZERO)"
|
||||
|
|
@ -84,19 +98,27 @@ jobs:
|
|||
echo "Flash image integrity verified"
|
||||
fi
|
||||
|
||||
- name: Stage release binaries with variant-specific names
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
mkdir -p release-staging
|
||||
cp build/esp32-csi-node.bin release-staging/${{ matrix.artifact_app }}
|
||||
cp build/partition_table/partition-table.bin release-staging/${{ matrix.artifact_pt }}
|
||||
if [ "${{ matrix.variant }}" = "8mb" ]; then
|
||||
cp build/bootloader/bootloader.bin release-staging/bootloader.bin
|
||||
cp build/ota_data_initial.bin release-staging/ota_data_initial.bin
|
||||
fi
|
||||
ls -la release-staging/
|
||||
|
||||
- name: Check QEMU ESP32-S3 support status
|
||||
run: |
|
||||
echo "::notice::ESP32-S3 QEMU support is experimental in ESP-IDF v5.4. "
|
||||
echo "Full smoke testing requires QEMU 8.2+ with xtensa-esp32s3 target."
|
||||
echo "See: https://github.com/espressif/qemu/wiki"
|
||||
|
||||
- name: Upload firmware artifact
|
||||
- name: Upload firmware artifact (${{ matrix.variant }})
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: esp32-csi-node-firmware
|
||||
path: |
|
||||
firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
firmware/esp32-csi-node/build/bootloader/bootloader.bin
|
||||
firmware/esp32-csi-node/build/partition_table/partition-table.bin
|
||||
firmware/esp32-csi-node/build/ota_data_initial.bin
|
||||
name: esp32-csi-node-firmware-${{ matrix.variant }}
|
||||
path: firmware/esp32-csi-node/release-staging/
|
||||
retention-days: 90
|
||||
|
|
|
|||
114
CHANGELOG.md
114
CHANGELOG.md
|
|
@ -7,6 +7,120 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.6.2-esp32] — 2026-04-20
|
||||
|
||||
Firmware release cutting ADR-081 and the Timer Svc stack fix discovered during
|
||||
on-hardware validation. Cut from `main` at commit pointing to this entry.
|
||||
Tested on ESP32-S3 (QFN56 rev v0.2, MAC `3c:0f:02:e9:b5:f8`), 30 s continuous
|
||||
run: no crashes, 149 `rv_feature_state_t` emissions (~5 Hz), medium/slow ticks
|
||||
firing cleanly, HEALTH mesh packets sent.
|
||||
|
||||
### Fixed
|
||||
- **Firmware: Timer Svc stack overflow on ADR-081 fast loop** — `emit_feature_state()` runs inside the FreeRTOS Timer Svc task via the fast-loop callback; it calls `stream_sender` network I/O which pushes past the ESP-IDF 2 KiB default timer stack and panics ~1 s after boot. Bumped `CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH` to 8 KiB in `sdkconfig.defaults`, `sdkconfig.defaults.template`, and `sdkconfig.defaults.4mb`. Follow-up (tracked separately): move heavy work out of the timer daemon into a dedicated worker task.
|
||||
- **Firmware: `adaptive_controller.c` implicit declaration** (#404) — `fast_loop_cb` called `emit_feature_state()` before its static definition, triggering `-Werror=implicit-function-declaration`. Added a forward declaration above the first use.
|
||||
|
||||
### Changed
|
||||
- **CI: firmware build matrix (8MB + 4MB)** — `firmware-ci.yml` now matrix-builds both the default 8MB (`sdkconfig.defaults`) and 4MB SuperMini (`sdkconfig.defaults.4mb`) variants, uploading distinct artifacts and producing variant-named release binaries (`esp32-csi-node.bin` / `esp32-csi-node-4mb.bin`, `partition-table.bin` / `partition-table-4mb.bin`).
|
||||
|
||||
### Added
|
||||
- **ADR-081: Adaptive CSI Mesh Firmware Kernel** — New 5-layer architecture
|
||||
(Radio Abstraction Layer / Adaptive Controller / Mesh Sensing Plane /
|
||||
On-device Feature Extraction / Rust handoff) that reframes the existing
|
||||
ESP32 firmware modules as components of a chipset-agnostic kernel. ADR
|
||||
in `docs/adr/ADR-081-adaptive-csi-mesh-firmware-kernel.md`. Goal: swap
|
||||
one radio family for another without changing the Rust signal /
|
||||
ruvector / train / mat crates.
|
||||
- **Firmware: radio abstraction vtable (`rv_radio_ops_t`)** — New
|
||||
`firmware/esp32-csi-node/main/rv_radio_ops.{h}` defines the
|
||||
chipset-agnostic ops (init, set_channel, set_mode, set_csi_enabled,
|
||||
set_capture_profile, get_health), profile enum
|
||||
(`RV_PROFILE_PASSIVE_LOW_RATE` / `ACTIVE_PROBE` / `RESP_HIGH_SENS` /
|
||||
`FAST_MOTION` / `CALIBRATION`), and health snapshot struct.
|
||||
`rv_radio_ops_esp32.c` provides the ESP32 binding wrapping
|
||||
`csi_collector` + `esp_wifi_*`. A second binding (mock or alternate
|
||||
chipset) is the portability acceptance test for ADR-081.
|
||||
- **Firmware: `rv_feature_state_t` packet (magic `0xC5110006`)** — New
|
||||
60-byte compact per-node sensing state (packed, verified by
|
||||
`_Static_assert`) in `firmware/esp32-csi-node/main/rv_feature_state.h`:
|
||||
motion, presence, respiration BPM/conf, heartbeat BPM/conf, anomaly
|
||||
score, env-shift score, node coherence, quality flags, IEEE CRC32.
|
||||
Replaces raw ADR-018 CSI as the default upstream stream (~99.7%
|
||||
bandwidth reduction: 300 B/s at 5 Hz vs. ~100 KB/s raw).
|
||||
- **Firmware: mock radio ops binding for QEMU** — New
|
||||
`firmware/esp32-csi-node/main/rv_radio_ops_mock.c`, compiled only when
|
||||
`CONFIG_CSI_MOCK_ENABLED`. Satisfies ADR-081's portability acceptance
|
||||
test: a second `rv_radio_ops_t` binding compiles and runs against the
|
||||
same controller + mesh-plane code as the ESP32 binding.
|
||||
- **Firmware: feature-state emitter wired into controller fast loop** —
|
||||
`adaptive_controller.c` now emits one 60-byte `rv_feature_state_t` per
|
||||
fast tick (default 200 ms → 5 Hz), pulling from the latest edge vitals
|
||||
and controller observation. This is the first end-to-end Layer 4/5
|
||||
path for ADR-081.
|
||||
- **Firmware: `csi_collector_get_pkt_yield_per_sec()` /
|
||||
`_get_send_fail_count()` accessors** — Expose the CSI callback rate
|
||||
and UDP send-failure counter so the ESP32 radio ops binding can
|
||||
populate `rv_radio_health_t.pkt_yield_per_sec` and `.send_fail_count`,
|
||||
closing the adaptive controller's observation loop.
|
||||
- **Firmware: host-side unit test suite for ADR-081 pure logic** — New
|
||||
`firmware/esp32-csi-node/tests/host/` (Makefile + 2 test files + shim
|
||||
`esp_err.h`). Exercises `adaptive_controller_decide()` (9 test cases:
|
||||
degraded gate on pkt-yield collapse + coherence loss, anomaly > motion,
|
||||
motion → SENSE_ACTIVE, aggressive cadence, stable presence →
|
||||
RESP_HIGH_SENS, empty-room default, hysteresis, NULL safety) and
|
||||
`rv_feature_state_*` helpers (size assertion, IEEE CRC32 known
|
||||
vectors, determinism, receiver-side verification). 33/33 assertions
|
||||
pass. Benchmarks: decide() 3.2 ns/call, CRC32(56 B) 614 ns/pkt
|
||||
(87 MB/s), full finalize() 616 ns/call. Pure function
|
||||
`adaptive_controller_decide()` extracted to
|
||||
`adaptive_controller_decide.c` so the firmware build and the host
|
||||
tests share a single source-of-truth implementation.
|
||||
- **Scripts: `validate_qemu_output.py` ADR-081 checks** — Validator
|
||||
(invoked by ADR-061 `scripts/qemu-esp32s3-test.sh` in CI) gains three
|
||||
checks for adaptive controller boot line, mock radio ops
|
||||
registration, and slow-loop heartbeat, so QEMU runs regression-gate
|
||||
Layer 1/2 presence.
|
||||
- **Firmware: ADR-081 Layer 3 mesh sensing plane** — New
|
||||
`firmware/esp32-csi-node/main/rv_mesh.{h,c}` defines 4 node roles
|
||||
(Anchor / Observer / Fusion relay / Coordinator), 7 on-wire message
|
||||
types (TIME_SYNC, ROLE_ASSIGN, CHANNEL_PLAN, CALIBRATION_START,
|
||||
FEATURE_DELTA, HEALTH, ANOMALY_ALERT), 3 authorization classes
|
||||
(None / HMAC-SHA256-session / Ed25519-batch), `rv_node_status_t`
|
||||
(28 B), `rv_anomaly_alert_t` (28 B), `rv_time_sync_t`,
|
||||
`rv_role_assign_t`, `rv_channel_plan_t`, `rv_calibration_start_t`.
|
||||
Pure-C encoder/decoder (`rv_mesh_encode()` / `rv_mesh_decode()`) with
|
||||
16-byte envelope + payload + IEEE CRC32 trailer; convenience encoders
|
||||
for each message type. Controller now emits `HEALTH` every slow-loop
|
||||
tick (30 s default) and `ANOMALY_ALERT` on state transitions to ALERT
|
||||
or DEGRADED. Host tests: `test_rv_mesh` exercises 27 assertions
|
||||
covering roundtrip, bad magic, truncation, CRC flipping, oversize
|
||||
payload rejection, and encode+decode throughput (1.0 μs/roundtrip
|
||||
on host).
|
||||
- **Rust: ADR-081 Layer 1/3 mirror module** — New
|
||||
`crates/wifi-densepose-hardware/src/radio_ops.rs` mirrors the
|
||||
firmware-side `rv_radio_ops_t` vtable as the Rust `RadioOps` trait
|
||||
(init, set_channel, set_mode, set_csi_enabled, set_capture_profile,
|
||||
get_health) and provides `MockRadio` for offline testing.
|
||||
Also mirrors the `rv_mesh.h` types (`MeshHeader`, `NodeStatus`,
|
||||
`AnomalyAlert`, `MeshRole`, `MeshMsgType`, `AuthClass`) and ships
|
||||
byte-identical `crc32_ieee()`, `decode_mesh()`, `decode_node_status()`,
|
||||
`decode_anomaly_alert()`, and `encode_health()`. Exported from
|
||||
`lib.rs`. 8 unit tests pass; `crc32_matches_firmware_vectors`
|
||||
verifies parity with the firmware-side test vectors
|
||||
(`0xCBF43926` for `"123456789"`, `0xD202EF8D` for single-byte zero),
|
||||
and `mesh_constants_match_firmware` asserts `MESH_MAGIC`,
|
||||
`MESH_VERSION`, `MESH_HEADER_SIZE`, and `MESH_MAX_PAYLOAD` match
|
||||
`rv_mesh.h` byte-for-byte. Satisfies ADR-081's portability
|
||||
acceptance test: signal/ruvector/train/mat crates are untouched.
|
||||
- **Firmware: adaptive controller** — New
|
||||
`firmware/esp32-csi-node/main/adaptive_controller.{c,h}` implements
|
||||
the three-loop closed-loop control specified by ADR-081: fast
|
||||
(~200 ms) for cadence and active probing, medium (~1 s) for channel
|
||||
selection and role transitions, slow (~30 s) for baseline
|
||||
recalibration. Pure `adaptive_controller_decide()` policy function is
|
||||
exposed in the header for offline unit testing. Default policy is
|
||||
conservative (`enable_channel_switch` and `enable_role_change` off);
|
||||
Kconfig surface added under "Adaptive Controller (ADR-081)".
|
||||
|
||||
### Fixed
|
||||
- **`provision.py` esptool v5 compat** (#391) — Stale `write_flash` (underscore) syntax in the dry-run manual-flash hint now uses `write-flash` (hyphenated) for esptool >= 5.x. The primary flash command was already correct.
|
||||
- **`provision.py` silent NVS wipe** (#391) — The script replaces the entire `csi_cfg` NVS namespace on every run, so partial invocations were silently erasing WiFi credentials and causing `Retrying WiFi connection (10/10)` in the field. Now refuses to run without `--ssid`, `--password`, and `--target-ip` unless `--force-partial` is passed. `--force-partial` prints a warning listing which keys will be wiped.
|
||||
|
|
|
|||
21
README.md
21
README.md
|
|
@ -112,20 +112,25 @@ RuView now generates **real-time 3D point clouds** by fusing camera depth + WiFi
|
|||
```bash
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo build --release -p wifi-densepose-pointcloud
|
||||
./target/release/ruview-pointcloud serve --port 9880
|
||||
./target/release/ruview-pointcloud serve --bind 127.0.0.1:9880
|
||||
# Open http://localhost:9880 for live 3D viewer
|
||||
```
|
||||
|
||||
**CLI commands:**
|
||||
```bash
|
||||
ruview-pointcloud demo # synthetic demo
|
||||
ruview-pointcloud serve --port 9880 # live server + Three.js viewer
|
||||
ruview-pointcloud capture --output room.ply # capture to PLY
|
||||
ruview-pointcloud train # depth calibration + DPO pairs
|
||||
ruview-pointcloud cameras # list available cameras
|
||||
ruview-pointcloud csi-test --count 100 # send test CSI frames
|
||||
ruview-pointcloud demo # synthetic demo
|
||||
ruview-pointcloud serve --bind 127.0.0.1:9880 # live server + Three.js viewer
|
||||
ruview-pointcloud capture --output room.ply # capture to PLY
|
||||
ruview-pointcloud train # depth calibration + DPO pairs
|
||||
ruview-pointcloud cameras # list available cameras
|
||||
ruview-pointcloud csi-test --count 100 # send test CSI frames
|
||||
ruview-pointcloud fingerprint office --seconds 5 # record named CSI room fingerprint
|
||||
```
|
||||
|
||||
The HTTP/viewer server defaults to **loopback (`127.0.0.1`)** — exposing live camera/CSI/vitals on `0.0.0.0` is an explicit opt-in. Brain URL defaults to `http://127.0.0.1:9876` and is overridable via `RUVIEW_BRAIN_URL` env var or the `--brain` flag on `serve`/`train`.
|
||||
|
||||
The pose overlay currently uses an **amplitude-energy heuristic** (`heuristic_pose_from_amplitude`) rather than trained WiFlow inference — real ONNX/Candle inference is tracked as a follow-up.
|
||||
|
||||
**Performance:** 22ms pipeline, 905 req/s API, 40K voxel room model from 20 frames.
|
||||
|
||||
**Brain integration:** Spatial observations (motion, vitals, skeleton, occupancy) sync to the ruOS brain every 60 seconds for agent reasoning.
|
||||
|
|
@ -940,6 +945,8 @@ cargo add wifi-densepose-ruvector # RuVector v2.0.4 integration layer (ADR-017
|
|||
| [`wifi-densepose-api`](https://crates.io/crates/wifi-densepose-api) | REST + WebSocket API layer | -- | [](https://crates.io/crates/wifi-densepose-api) |
|
||||
| [`wifi-densepose-config`](https://crates.io/crates/wifi-densepose-config) | Configuration management | -- | [](https://crates.io/crates/wifi-densepose-config) |
|
||||
| [`wifi-densepose-db`](https://crates.io/crates/wifi-densepose-db) | Database persistence (PostgreSQL, SQLite, Redis) | -- | [](https://crates.io/crates/wifi-densepose-db) |
|
||||
| `wifi-densepose-pointcloud` | Real-time dense point cloud from camera + WiFi CSI fusion (Three.js viewer, brain bridge). Workspace-only for now. | -- | — |
|
||||
| `wifi-densepose-geo` | Geospatial context (Sentinel-2 tiles, SRTM elevation, OSM, weather, night-mode). Workspace-only for now. | -- | — |
|
||||
|
||||
All crates integrate with [RuVector v2.0.4](https://github.com/ruvnet/ruvector) — see [AI Backbone](#ai-backbone-ruvector) below.
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,15 @@ ENV RUST_LOG=info
|
|||
# Override at runtime: docker run -e CSI_SOURCE=esp32 ...
|
||||
ENV CSI_SOURCE=auto
|
||||
|
||||
ENTRYPOINT ["/bin/sh", "-c"]
|
||||
# Shell-form CMD allows $CSI_SOURCE to be substituted at container start.
|
||||
# The ENV default above (CSI_SOURCE=auto) applies when the variable is unset.
|
||||
CMD ["/app/sensing-server --source ${CSI_SOURCE} --tick-ms 100 --ui-path /app/ui --http-port 3000 --ws-port 3001"]
|
||||
# MODELS_DIR controls where the server scans for .rvf model files.
|
||||
# Mount a host directory here to make models visible to the API:
|
||||
# docker run -v /path/to/models:/app/models -e MODELS_DIR=/app/models ...
|
||||
ENV MODELS_DIR=data/models
|
||||
|
||||
COPY docker/docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||
|
||||
# Exec-form ENTRYPOINT so Docker appends user arguments correctly.
|
||||
# Pass flags directly: docker run <image> --source esp32 --tick-ms 500
|
||||
# Or use env vars: docker run -e CSI_SOURCE=esp32 <image>
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
CMD []
|
||||
|
|
|
|||
|
|
@ -18,8 +18,13 @@ services:
|
|||
# wifi — use host Wi-Fi RSSI/scan data (Windows netsh)
|
||||
# simulated — generate synthetic CSI data (no hardware required)
|
||||
- CSI_SOURCE=${CSI_SOURCE:-auto}
|
||||
# command is passed as arguments to ENTRYPOINT (/bin/sh -c), so $CSI_SOURCE is expanded by the shell.
|
||||
command: ["/app/sensing-server --source ${CSI_SOURCE:-auto} --tick-ms 100 --ui-path /app/ui --http-port 3000 --ws-port 3001"]
|
||||
# MODELS_DIR controls where the server scans for .rvf model files.
|
||||
# Mount a host directory and set this to make models visible:
|
||||
# volumes: ["/path/to/models:/app/models"]
|
||||
# MODELS_DIR=/app/models
|
||||
- MODELS_DIR=${MODELS_DIR:-data/models}
|
||||
# No explicit command needed — docker-entrypoint.sh uses CSI_SOURCE.
|
||||
# Override with: command: ["--source", "esp32", "--tick-ms", "500"]
|
||||
|
||||
python-sensing:
|
||||
build:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
#!/bin/sh
|
||||
# Docker entrypoint for WiFi-DensePose sensing server.
|
||||
#
|
||||
# Supports two usage patterns:
|
||||
#
|
||||
# 1. No arguments — use defaults from environment:
|
||||
# docker run -e CSI_SOURCE=esp32 ruvnet/wifi-densepose:latest
|
||||
#
|
||||
# 2. Pass CLI flags directly:
|
||||
# docker run ruvnet/wifi-densepose:latest --source esp32 --tick-ms 500
|
||||
# docker run ruvnet/wifi-densepose:latest --model /app/models/my.rvf
|
||||
#
|
||||
# Environment variables:
|
||||
# CSI_SOURCE — data source: auto (default), esp32, wifi, simulated
|
||||
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
|
||||
set -e
|
||||
|
||||
# If the first argument looks like a flag (starts with -), prepend the
|
||||
# server binary so users can just pass flags:
|
||||
# docker run <image> --source esp32 --tick-ms 500
|
||||
if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then
|
||||
set -- /app/sensing-server \
|
||||
--source "${CSI_SOURCE:-auto}" \
|
||||
--tick-ms 100 \
|
||||
--ui-path /app/ui \
|
||||
--http-port 3000 \
|
||||
--ws-port 3001 \
|
||||
--bind-addr 0.0.0.0 \
|
||||
"$@"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
|
@ -0,0 +1,503 @@
|
|||
# ADR-081: Adaptive CSI Mesh Firmware Kernel
|
||||
|
||||
| Field | Value |
|
||||
|-------------|-----------------------------------------------------------------------|
|
||||
| **Status** | Accepted — Layers 1/2/3/4/5 implemented and host-tested; mesh RX path and Ed25519 signing tracked as Phase 3.5 polish |
|
||||
| **Date** | 2026-04-19 |
|
||||
| **Authors** | ruv |
|
||||
| **Depends** | ADR-018, ADR-028, ADR-029, ADR-031, ADR-032, ADR-039, ADR-066, ADR-073 |
|
||||
|
||||
## Context
|
||||
|
||||
RuView's firmware grew bottom-up. ADR-018 defined a binary CSI frame, ADR-029
|
||||
added channel hopping and TDM, ADR-039 added a tiered edge-intelligence
|
||||
pipeline, ADR-040 added programmable WASM modules, ADR-060 added per-node
|
||||
channel and MAC overrides, ADR-066 added a swarm bridge to a coordinator, and
|
||||
ADR-073 added multifrequency mesh scanning. Each one was a sound local
|
||||
decision. Together they produced a firmware that works on ESP32-S3 but is
|
||||
**implicitly coupled** to that chipset through `csi_collector.c` calling
|
||||
`esp_wifi_*` directly and through hard-coded assumptions about the WiFi driver
|
||||
callback shape.
|
||||
|
||||
This is a problem for three reasons:
|
||||
|
||||
1. **Portability.** Espressif exposes CSI through an official driver API. On
|
||||
locked Broadcom and Cypress chips, projects like Nexmon achieve the same
|
||||
thing by patching the firmware blob — but only for specific chip and
|
||||
firmware build combinations. Future RuView nodes will likely span both
|
||||
models plus eventually a custom silicon path. Today, none of the modules
|
||||
above can be reused unchanged on any non-ESP32 chip.
|
||||
|
||||
2. **Adaptivity.** The current firmware reacts to configuration, not to
|
||||
conditions. Channel hop intervals, edge tier, vitals cadence, top-K
|
||||
subcarriers, fall threshold, and power duty are all read from NVS at boot
|
||||
and never revisited. There is no closed-loop control: if a channel becomes
|
||||
congested, if motion spikes, if inter-node coherence drops, or if the
|
||||
environment is stable enough to coast at lower cadence, nothing changes
|
||||
onboard. The adaptive classifier in `wifi-densepose-sensing-server` does
|
||||
adapt — but only on the host side, after the data has already traversed the
|
||||
network at fixed rate.
|
||||
|
||||
3. **Mesh as an afterthought.** ADR-029 wired in a `TdmCoordinator` and ADR-066
|
||||
added a swarm bridge to a Cognitum Seed, but there is no first-class node
|
||||
role enumeration (anchor / observer / fusion-relay / coordinator), no
|
||||
role-assignment protocol, no `FEATURE_DELTA` message type, no
|
||||
coordinator-driven channel plan, and no automatic role re-election when a
|
||||
node drops. Multi-node deployments today are stitched together by manual
|
||||
per-node NVS provisioning.
|
||||
|
||||
The hard truth is that the firmware hack — getting raw CSI off a radio — is
|
||||
not the moat. The moat is **adaptive control, multi-node fusion, compact
|
||||
state encoding, persistent memory, and contrastive reasoning on top of the
|
||||
radio layer**. The current architecture does not name those layers, so they
|
||||
get reinvented inline by every new ADR.
|
||||
|
||||
## Decision
|
||||
|
||||
Adopt a **5-layer adaptive RF sensing kernel** as the canonical RuView
|
||||
firmware architecture, and refactor the existing modules to fit underneath
|
||||
it. The five layers, top to bottom:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Layer 5 — Rust handoff │
|
||||
│ Two streams only: feature_state (default) and debug_csi_frame (gated) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Layer 4 — On-device feature extraction │
|
||||
│ 100 ms motion, 1 s respiration, 5 s baseline windows │
|
||||
│ Emits compact rv_feature_state_t (magic 0xC5110006) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Layer 3 — Mesh sensing plane │
|
||||
│ Roles: Anchor / Observer / Fusion relay / Coordinator │
|
||||
│ Messages: TIME_SYNC, ROLE_ASSIGN, CHANNEL_PLAN, CALIBRATION_START, │
|
||||
│ FEATURE_DELTA, HEALTH, ANOMALY_ALERT │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Layer 2 — Adaptive controller │
|
||||
│ Fast loop ~200 ms — packet rate, active probing │
|
||||
│ Medium loop ~1 s — channel selection, role changes │
|
||||
│ Slow loop ~30 s — baseline recalibration │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Layer 1 — Radio Abstraction Layer (rv_radio_ops_t vtable) │
|
||||
│ ESP32 binding, future Nexmon binding, future custom silicon binding │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Layer 1 — Radio Abstraction Layer
|
||||
|
||||
A single function-pointer vtable, `rv_radio_ops_t`, defined in
|
||||
`firmware/esp32-csi-node/main/rv_radio_ops.h`:
|
||||
|
||||
```c
|
||||
typedef struct {
|
||||
int (*init)(void);
|
||||
int (*set_channel)(uint8_t ch, uint8_t bw);
|
||||
int (*set_mode)(uint8_t mode); /* RV_RADIO_MODE_* */
|
||||
int (*set_csi_enabled)(bool en);
|
||||
int (*set_capture_profile)(uint8_t profile_id);
|
||||
int (*get_health)(rv_radio_health_t *out);
|
||||
} rv_radio_ops_t;
|
||||
```
|
||||
|
||||
Capture profiles, named not numbered:
|
||||
|
||||
| Profile | Intent |
|
||||
|--------------------------------|-------------------------------------------------------|
|
||||
| `RV_PROFILE_PASSIVE_LOW_RATE` | Default idle: minimum cadence, presence only |
|
||||
| `RV_PROFILE_ACTIVE_PROBE` | Inject NDP frames at high rate |
|
||||
| `RV_PROFILE_RESP_HIGH_SENS` | Quietest channel, longest window, vitals-only |
|
||||
| `RV_PROFILE_FAST_MOTION` | Short window, high cadence |
|
||||
| `RV_PROFILE_CALIBRATION` | Synchronized burst across nodes |
|
||||
|
||||
Two bindings ship in this ADR:
|
||||
|
||||
- **ESP32 binding** (`rv_radio_ops_esp32.c`) wraps `csi_collector.c`,
|
||||
`esp_wifi_set_channel()`, `esp_wifi_set_csi()`, and
|
||||
`csi_inject_ndp_frame()`.
|
||||
- **Mock binding** (`rv_radio_ops_mock.c`) wraps `mock_csi.c` so QEMU
|
||||
scenarios can exercise the controller and mesh plane without a radio.
|
||||
|
||||
A third binding (Nexmon-patched Broadcom) is reserved but not implemented
|
||||
here.
|
||||
|
||||
### Layer 2 — Adaptive controller
|
||||
|
||||
`firmware/esp32-csi-node/main/adaptive_controller.{c,h}`. A single FreeRTOS
|
||||
task with three cooperating timers:
|
||||
|
||||
| Loop | Period | Inputs | Outputs |
|
||||
|--------|---------|------------------------------------------------------------------------|------------------------------------------------------|
|
||||
| Fast | ~200 ms | packet yield, retry/drop rate, motion score | cadence (vital_interval_ms), active vs passive probe |
|
||||
| Medium | ~1 s | CSI variance, RSSI median, channel occupancy, inter-node agreement | channel selection (via radio ops), role transitions |
|
||||
| Slow | ~30 s | drift profile (Stable/Linear/StepChange), respiration confidence | baseline recalibration, switch to delta-only mode |
|
||||
|
||||
The controller publishes its decisions through the radio ops vtable
|
||||
(`set_capture_profile`, `set_channel`) and through the mesh plane
|
||||
(`CHANNEL_PLAN`, `ROLE_ASSIGN`). Default policy is conservative and matches
|
||||
today's behavior; aggressive adaptation is opt-in via Kconfig.
|
||||
|
||||
### Layer 3 — Mesh sensing plane
|
||||
|
||||
Extends `swarm_bridge.c` with explicit node roles (Anchor / Observer /
|
||||
Fusion relay / Coordinator) and a 7-message type protocol:
|
||||
|
||||
| Message | Cadence | Sender(s) | Purpose |
|
||||
|----------------------|--------------------|------------------|-----------------------------------------------|
|
||||
| `TIME_SYNC` | 100 ms | Anchor | Reuse ADR-032 `SyncBeacon` (28 bytes, HMAC) |
|
||||
| `ROLE_ASSIGN` | event-driven | Coordinator | Node ID → role mapping |
|
||||
| `CHANNEL_PLAN` | event-driven | Coordinator | Per-node channel + dwell schedule |
|
||||
| `CALIBRATION_START` | event-driven | Coordinator | Synchronized calibration burst |
|
||||
| `FEATURE_DELTA` | 1–10 Hz | Observer / Relay | Compact feature delta (see Layer 4) |
|
||||
| `HEALTH` | 1 Hz | All | `rv_node_status_t` (see below) |
|
||||
| `ANOMALY_ALERT` | event-driven | Observer | Phase-physics violation, multi-link mismatch |
|
||||
|
||||
Node status payload:
|
||||
|
||||
```c
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t node_id[8];
|
||||
uint64_t local_time_us;
|
||||
uint8_t role;
|
||||
uint8_t current_channel;
|
||||
uint8_t current_bw;
|
||||
int8_t noise_floor_dbm;
|
||||
uint16_t pkt_yield;
|
||||
uint16_t sync_error_us;
|
||||
uint16_t health_flags;
|
||||
} rv_node_status_t;
|
||||
```
|
||||
|
||||
Time-sync target is an engineering goal, not a guaranteed constant — it
|
||||
depends on the clock quality of the chosen radio family. The first
|
||||
acceptance test (Phase 2) measures it on real hardware.
|
||||
|
||||
### Layer 4 — On-device feature extraction
|
||||
|
||||
Defined in `firmware/esp32-csi-node/main/rv_feature_state.h`. Single
|
||||
on-the-wire packet, **60 bytes packed** (verified by `_Static_assert` and
|
||||
host unit test), magic `0xC5110006` (next free after ADR-039's
|
||||
`0xC5110002`, ADR-069's `0xC5110003`, ADR-063's `0xC5110004`, and ADR-039's
|
||||
compressed `0xC5110005`):
|
||||
|
||||
```c
|
||||
#define RV_FEATURE_STATE_MAGIC 0xC5110006u
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic; /* RV_FEATURE_STATE_MAGIC */
|
||||
uint8_t node_id;
|
||||
uint8_t mode; /* RV_PROFILE_* identifier */
|
||||
uint16_t seq; /* monotonic per-node sequence */
|
||||
uint64_t ts_us; /* node-local microseconds */
|
||||
float motion_score;
|
||||
float presence_score;
|
||||
float respiration_bpm;
|
||||
float respiration_conf;
|
||||
float heartbeat_bpm;
|
||||
float heartbeat_conf;
|
||||
float anomaly_score;
|
||||
float env_shift_score;
|
||||
float node_coherence;
|
||||
uint16_t quality_flags;
|
||||
uint16_t reserved;
|
||||
uint32_t crc32; /* IEEE polynomial over bytes [0..end-4] */
|
||||
} rv_feature_state_t;
|
||||
|
||||
_Static_assert(sizeof(rv_feature_state_t) == 60,
|
||||
"rv_feature_state_t must be 60 bytes on the wire");
|
||||
```
|
||||
|
||||
Three windows feed it: 100 ms (motion), 1 s (respiration), 5 s (baseline /
|
||||
env shift). Each `rv_feature_state_t` represents the most recent state of
|
||||
all three; mode field tells the receiver which window dominates this
|
||||
update.
|
||||
|
||||
`rv_feature_state_t` does not replace ADR-039's `edge_vitals_pkt_t`
|
||||
(0xC5110002) or ADR-063's `edge_fused_vitals_pkt_t` (0xC5110004). Those
|
||||
remain the wire format for vitals-specific consumers. `rv_feature_state_t`
|
||||
is the **default upstream payload** for the sensing pipeline; vitals
|
||||
packets are now an alternate emission mode for backward compatibility.
|
||||
|
||||
### Layer 5 — Rust handoff
|
||||
|
||||
The Rust side sees only two streams from a node:
|
||||
|
||||
1. **`feature_state` stream** — `rv_feature_state_t`, default-on, 1–10 Hz.
|
||||
2. **`debug_csi_frame` stream** — ADR-018 raw frames (magic 0xC5110001),
|
||||
default-off, opt-in via NVS or `CHANNEL_PLAN`. Used for calibration,
|
||||
debugging, training-set capture.
|
||||
|
||||
The Rust handoff is mirrored as a trait in
|
||||
`crates/wifi-densepose-hardware/src/radio_ops.rs` so test harnesses (and
|
||||
eventually the Rust-side controller for centralized coordinator nodes) can
|
||||
swap radio backends without touching `wifi-densepose-signal`,
|
||||
`wifi-densepose-ruvector`, `wifi-densepose-train`, or
|
||||
`wifi-densepose-mat`. Rust-side mirror trait is **out of scope for the
|
||||
firmware-only PR** that ships this ADR; tracked as Phase 4 follow-up.
|
||||
|
||||
## State Machine
|
||||
|
||||
```
|
||||
BOOT → SELF_TEST → RADIO_INIT → TIME_SYNC → CALIBRATION → SENSE_IDLE
|
||||
↓ ↑
|
||||
SENSE_ACTIVE
|
||||
↓
|
||||
ALERT
|
||||
↓
|
||||
DEGRADED
|
||||
```
|
||||
|
||||
Transitions:
|
||||
|
||||
- **CALIBRATION** on boot, on role change, on sustained inter-node
|
||||
disagreement.
|
||||
- **SENSE_ACTIVE** when motion or anomaly score crosses threshold.
|
||||
- **DEGRADED** when packet yield, sync quality, or memory pressure drops
|
||||
below threshold; falls back to ADR-039 Tier-0 raw passthrough as the
|
||||
last-resort survivable mode.
|
||||
|
||||
## Data budgets
|
||||
|
||||
| Stream | Default rate | Notes |
|
||||
|-------------------------|-----------------------------|----------------------------------------------|
|
||||
| Raw capture (internal) | 50–200 pps per observer | Stays on-device unless debug stream enabled |
|
||||
| `rv_feature_state_t` | 1–10 Hz per node | Default upstream |
|
||||
| `ANOMALY_ALERT` | event-driven | Burst-bounded |
|
||||
| Debug ADR-018 raw CSI | 0 (off by default) | Burst-only via `CHANNEL_PLAN` debug flag |
|
||||
|
||||
ADR-039 measured raw CSI at ~5 KB/frame and ~100 KB/s per node. The default
|
||||
upstream with ADR-081's 60-byte `rv_feature_state_t` at 5 Hz is **300 B/s
|
||||
per node — a 99.7% reduction**. A 50-node deployment at 5 Hz fits in
|
||||
15 KB/s total, easily carried by a single-AP backhaul.
|
||||
|
||||
## Channel planning policy
|
||||
|
||||
Codified rules — these are constraints on the controller, not just defaults:
|
||||
|
||||
- Keep one anchor on a stable channel; observers distributed across the
|
||||
least-congested channels.
|
||||
- Rotate **one** observer at a time. Never change all nodes simultaneously.
|
||||
- Pin `RV_PROFILE_RESP_HIGH_SENS` to the quietest stable channel for the
|
||||
duration of a respiration window.
|
||||
- Use a short active burst on a quiet channel for calibration, then return
|
||||
to passive capture.
|
||||
|
||||
This generalizes the per-deployment policy in ADR-073 ("node 1: ch 1/6/11,
|
||||
node 2: ch 3/5/9") into a controller-driven plan that the coordinator can
|
||||
publish via `CHANNEL_PLAN`. IEEE 802.11bf is the standards direction this
|
||||
points toward.
|
||||
|
||||
## Security & integrity
|
||||
|
||||
- Every `FEATURE_DELTA` carries node id, monotonic seq, ts_us, and CRC32
|
||||
(IEEE polynomial), per the struct above.
|
||||
- Every control message (`ROLE_ASSIGN`, `CHANNEL_PLAN`, `CALIBRATION_START`)
|
||||
carries sender role, epoch, replay window index, and authorization class,
|
||||
reusing the HMAC-SHA256 + 16-frame replay window from ADR-032
|
||||
(`secure_tdm.rs`).
|
||||
- Optional Ed25519 signature at session/batch granularity for signed
|
||||
`CHANNEL_PLAN` and `CALIBRATION_START` messages, reusing the
|
||||
ADR-040/RVF Ed25519 path already shipping in firmware.
|
||||
|
||||
## Reuse map (do not rewrite)
|
||||
|
||||
| Concern | Existing component |
|
||||
|-----------------------------|----------------------------------------------------------------------------------------------------------|
|
||||
| ADR-018 binary frame | `firmware/esp32-csi-node/main/csi_collector.c` (magic `0xC5110001`) |
|
||||
| ESP32 CSI driver glue | `firmware/esp32-csi-node/main/csi_collector.c:225-303` |
|
||||
| Channel hopping | `csi_collector_set_hop_table()` and `csi_collector_start_hop_timer()` |
|
||||
| NDP injection | `csi_inject_ndp_frame()` (placeholder, sufficient for L1 binding) |
|
||||
| TDM scheduling | `crates/wifi-densepose-hardware/src/esp32/tdm.rs` |
|
||||
| Secure beacons | `crates/wifi-densepose-hardware/src/esp32/secure_tdm.rs` (HMAC + replay) |
|
||||
| Edge intelligence (Tier 1/2)| `firmware/esp32-csi-node/main/edge_processing.c` (magic `0xC5110002`/`0xC5110005`) |
|
||||
| Fused vitals | ADR-063 `edge_fused_vitals_pkt_t` (magic `0xC5110004`) |
|
||||
| Swarm bridge | `firmware/esp32-csi-node/main/swarm_bridge.c` |
|
||||
| WASM Tier 3 modules | `firmware/esp32-csi-node/main/wasm_runtime.c` (ADR-040) |
|
||||
| Multistatic fusion | `crates/wifi-densepose-ruvector/src/viewpoint/fusion.rs` |
|
||||
| Adaptive classifier | `crates/wifi-densepose-sensing-server/src/adaptive_classifier.rs:61-75` |
|
||||
| Feature primitives (Rust) | `crates/wifi-densepose-signal/src/{motion.rs,features.rs,ruvsense/coherence.rs}` |
|
||||
|
||||
## Implementation status (2026-04-19)
|
||||
|
||||
This ADR ships **with** the initial implementation, not ahead of it.
|
||||
Artifacts delivered alongside the ADR:
|
||||
|
||||
| Component | File | State |
|
||||
|-----------------------------------------|-------------------------------------------------------------------------|-------------|
|
||||
| L1 vtable + profile/mode/health enums | `firmware/esp32-csi-node/main/rv_radio_ops.h` | Implemented |
|
||||
| L1 ESP32 binding | `firmware/esp32-csi-node/main/rv_radio_ops_esp32.c` | Implemented |
|
||||
| L1 Mock (QEMU) binding | `firmware/esp32-csi-node/main/rv_radio_ops_mock.c` | Implemented |
|
||||
| L2 Controller FreeRTOS plumbing | `firmware/esp32-csi-node/main/adaptive_controller.c` | Implemented |
|
||||
| L2 Pure decision policy (testable) | `firmware/esp32-csi-node/main/adaptive_controller_decide.c` | Implemented |
|
||||
| L3 Mesh-plane types + encoder/decoder | `firmware/esp32-csi-node/main/rv_mesh.{h,c}` | Implemented |
|
||||
| L3 HEALTH emit (slow loop, 30 s) | `adaptive_controller.c:slow_loop_cb()` | Implemented |
|
||||
| L3 ANOMALY_ALERT on state transition | `adaptive_controller.c:apply_decision()` | Implemented |
|
||||
| L3 Role tracking + epoch monotonicity | `adaptive_controller.c` (`s_role`, `s_mesh_epoch`) | Implemented |
|
||||
| L4 Feature state packet + helpers | `firmware/esp32-csi-node/main/rv_feature_state.{h,c}` | Implemented |
|
||||
| L4 Emitter from fast loop (5 Hz) | `adaptive_controller.c:emit_feature_state()` | Implemented |
|
||||
| L1 Packet yield + send-fail accessors | `csi_collector.c:csi_collector_get_pkt_yield_per_sec()` + send fail | Implemented |
|
||||
| L5 Rust mirror trait + mesh decoder | `crates/wifi-densepose-hardware/src/radio_ops.rs` | Implemented |
|
||||
| Host C unit tests (60 assertions) | `firmware/esp32-csi-node/tests/host/` | **60/60 ✓** |
|
||||
| Rust unit tests (8 assertions) | `crates/wifi-densepose-hardware` (`radio_ops::tests`) | **8/8 ✓** |
|
||||
| QEMU validator hooks (3 new checks) | `scripts/validate_qemu_output.py` (check 17/18/19) | Passing |
|
||||
| L3 mesh RX path (receive + dispatch) | — | Phase 3.5 |
|
||||
| Ed25519 signing for CHANNEL_PLAN etc. | — | Phase 3.5 |
|
||||
| Hardware validation on COM7 | — | Pending |
|
||||
|
||||
## Measured performance
|
||||
|
||||
Host-side benchmarks (`firmware/esp32-csi-node/tests/host/`), x86-64,
|
||||
gcc `-O2`, 2026-04-19. Numbers are illustrative of algorithmic cost on
|
||||
a modern CPU; on-target ESP32-S3 Xtensa LX7 at 240 MHz is ~5–10×
|
||||
slower for bit-by-bit CRC and broadly comparable for the decide
|
||||
function after inlining.
|
||||
|
||||
| Operation | Cost per call | Notes |
|
||||
|---------------------------------------------|---------------------|-------------------------------------|
|
||||
| `adaptive_controller_decide()` | **3.2 ns** (host) | O(1) policy, 9 branches evaluated |
|
||||
| `rv_feature_state_crc32()` (56 B hashed) | **612 ns** (host) | 87 MB/s — bit-by-bit IEEE CRC32 |
|
||||
| `rv_feature_state_finalize()` (full) | **592 ns** (host) | CRC-dominated |
|
||||
| `rv_mesh_encode_health()` + `_decode()` | **1010 ns** (host) | Full roundtrip, hdr+payload+CRC |
|
||||
|
||||
Projected on-target cost at 5 Hz cadence:
|
||||
|
||||
| Budget | Value |
|
||||
|--------------------------------------------|---------------------|
|
||||
| Controller fast-loop tick work (ESP32-S3) | < 10 μs (est.) |
|
||||
| CRC32 per feature packet (ESP32-S3) | ~3–6 μs (est.) |
|
||||
| Feature-state emit cost @ 5 Hz | ~30 μs/sec (0.003%) |
|
||||
| UDP send cost (existing stream_sender) | — unchanged — |
|
||||
|
||||
**Bandwidth:**
|
||||
|
||||
| Mode | Rate |
|
||||
|---------------------------------------------|-------------|
|
||||
| Raw ADR-018 CSI (pre-ADR-081) | ~100 KB/s |
|
||||
| ADR-039 compressed CSI (Tier 1) | ~50–70 KB/s |
|
||||
| ADR-039 vitals packet (32 B @ 1 Hz) | 32 B/s |
|
||||
| **ADR-081 feature state (60 B @ 5 Hz)** | **300 B/s** |
|
||||
|
||||
**Memory:**
|
||||
|
||||
| Component | Static RAM |
|
||||
|---------------------------------------------|---------------------|
|
||||
| Controller state (s_cfg + s_last_obs + …) | ~80 bytes |
|
||||
| Feature-state emit packet (stack, per tick) | 60 bytes |
|
||||
| CRC lookup table | 0 (bit-by-bit) |
|
||||
| Three FreeRTOS software timers | ~3 × 56 B overhead |
|
||||
|
||||
**Tests:**
|
||||
|
||||
| Suite | Assertions | Result |
|
||||
|---------------------------------------------|-----------:|------------|
|
||||
| `test_adaptive_controller` (host C) | 18 | **PASS** |
|
||||
| `test_rv_feature_state` (host C) | 15 | **PASS** |
|
||||
| `test_rv_mesh` (host C) | 27 | **PASS** |
|
||||
| `radio_ops::tests` (Rust) | 8 | **PASS** |
|
||||
| **Total** | **68** | **68/68** |
|
||||
| QEMU validator (`ADR-061` pipeline) | +3 checks | hooked |
|
||||
|
||||
Cross-language parity: the Rust `crc32_ieee()` is verified against the
|
||||
same known vectors used by the C test (`0xCBF43926` for `"123456789"`,
|
||||
`0xD202EF8D` for a single zero byte), and the `mesh_constants_match_firmware`
|
||||
test asserts `MESH_MAGIC`, `MESH_VERSION`, `MESH_HEADER_SIZE`, and
|
||||
`MESH_MAX_PAYLOAD` match the C header byte-for-byte. Any drift between
|
||||
the two implementations fails CI.
|
||||
|
||||
## New components this ADR authorizes
|
||||
|
||||
| New file | Purpose |
|
||||
|-------------------------------------------------------------------------------------------|--------------------------------------------------------|
|
||||
| `firmware/esp32-csi-node/main/rv_radio_ops.h` | `rv_radio_ops_t` vtable + profile/mode/health enums |
|
||||
| `firmware/esp32-csi-node/main/rv_radio_ops_esp32.c` | ESP32 binding wrapping `csi_collector` + `esp_wifi_*` |
|
||||
| `firmware/esp32-csi-node/main/rv_feature_state.h` | `rv_feature_state_t` packet + `RV_FEATURE_STATE_MAGIC` |
|
||||
| `firmware/esp32-csi-node/main/adaptive_controller.h` | Controller API + observation/decision structs |
|
||||
| `firmware/esp32-csi-node/main/adaptive_controller.c` | 200 ms / 1 s / 30 s loops, FreeRTOS task |
|
||||
| `crates/wifi-densepose-hardware/src/radio_ops.rs` *(Phase 4 follow-up)* | Rust mirror trait for backend swapping |
|
||||
|
||||
## Roadmap
|
||||
|
||||
| Phase | Scope | Status |
|
||||
|-------|--------------------------------------------|--------------------------------------------------|
|
||||
| 1 | Single supported-CSI node + features → Rust | Largely done via ADR-018, ADR-039 |
|
||||
| 2 | 3-node Seed v2 mesh + time-sync + plan | Partially done (ADR-029, ADR-066, ADR-073) |
|
||||
| 3 | Adaptive controller, delta reporting, DEGRADED | **This ADR** authorizes the firmware skeleton |
|
||||
| 4 | Cross-chipset bindings (Nexmon, custom) | Reserved; gated by Phase 3 stability |
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
1. **Portability gate.** A second `rv_radio_ops_t` binding (mock or
|
||||
alternate chipset) compiles and runs the controller + mesh plane code
|
||||
unchanged. The signal/ruvector/train/mat crates compile against a Rust
|
||||
mirror trait without modification.
|
||||
2. **Mesh resilience benchmark.** A 3-node prototype maintains stable
|
||||
`presence_score` and `motion_score` when one observer changes channel
|
||||
or drops out for 5 seconds.
|
||||
3. **Default upstream is compact.** Raw ADR-018 CSI is off by default; the
|
||||
default upstream is `rv_feature_state_t` at 1–10 Hz.
|
||||
4. **Integrity.** Every `FEATURE_DELTA` carries node id, seq, ts_us, CRC32.
|
||||
Every control message carries epoch + replay-window + authorization
|
||||
class, verified against ADR-032's existing HMAC machinery.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- The firmware hack is no longer the moat. The 5 layers are explicit and
|
||||
separately testable.
|
||||
- Default upstream bandwidth drops ~99% vs. raw ADR-018, making 50+ node
|
||||
deployments practical.
|
||||
- A documented vtable + Kconfig surface gates new features ("which layer
|
||||
does this belong in?") instead of letting them accrete inline.
|
||||
- Adaptive control of cadence, channel, and role becomes a first-class
|
||||
firmware concern — the user-facing knob ("be smarter when busy, save
|
||||
power when idle") finally has a home.
|
||||
|
||||
### Negative
|
||||
|
||||
- An abstraction tax on the single-chipset case: `rv_radio_ops_t` is a
|
||||
vtable for a family currently of size 1.
|
||||
- Adds ~5–8 KB SRAM for controller state and the new feature-state ring.
|
||||
- Requires re-routing existing `swarm_bridge` traffic through the mesh
|
||||
plane message types over time (incremental, not breaking).
|
||||
|
||||
### Neutral
|
||||
|
||||
- This ADR introduces no new dependencies, no new networking stacks, and
|
||||
no new hardware requirements.
|
||||
- ADR-039, ADR-063, ADR-066, ADR-069, ADR-073 are **not superseded**; they
|
||||
are reframed as components of Layer 3 / Layer 4.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Host-side C unit tests (no ESP-IDF, no QEMU required)
|
||||
cd firmware/esp32-csi-node/tests/host
|
||||
make check
|
||||
# → test_adaptive_controller: 18/18 pass, decide() = 3.2 ns/call
|
||||
# → test_rv_feature_state: 15/15 pass, CRC32(56 B) = 612 ns/pkt
|
||||
# → test_rv_mesh: 27/27 pass, HEALTH roundtrip = 1.0 µs
|
||||
|
||||
# Rust-side radio_ops trait + mesh decoder tests
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo test -p wifi-densepose-hardware --no-default-features --lib radio_ops
|
||||
# → 8 passed; verifies MockRadio, CRC32 parity with firmware vectors,
|
||||
# HEALTH encode/decode roundtrip, bad-magic/short/CRC rejection,
|
||||
# and that MESH_MAGIC/VERSION/HEADER_SIZE match rv_mesh.h
|
||||
|
||||
# QEMU end-to-end (requires ESP-IDF + qemu-system-xtensa, see ADR-061)
|
||||
bash scripts/qemu-esp32s3-test.sh
|
||||
# → Validator now runs 19 checks; new ADR-081 checks 17/18/19 verify
|
||||
# adaptive_ctrl boot line, rv_radio_mock binding registration, and
|
||||
# slow-loop heartbeat.
|
||||
|
||||
# Full workspace
|
||||
cargo test --workspace --no-default-features
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
ADR-018, ADR-028, ADR-029, ADR-030, ADR-031, ADR-032, ADR-039, ADR-040,
|
||||
ADR-060, ADR-061, ADR-063, ADR-066, ADR-069, ADR-073, ADR-078.
|
||||
|
|
@ -547,12 +547,16 @@ RuView can generate real-time 3D point clouds by fusing camera depth estimation
|
|||
cd rust-port/wifi-densepose-rs
|
||||
cargo build --release -p wifi-densepose-pointcloud
|
||||
|
||||
# Start the server (auto-detects camera + CSI)
|
||||
./target/release/ruview-pointcloud serve --port 9880
|
||||
# Start the server (auto-detects camera + CSI). Loopback-only by default.
|
||||
./target/release/ruview-pointcloud serve --bind 127.0.0.1:9880
|
||||
```
|
||||
|
||||
Open `http://localhost:9880` for the interactive Three.js 3D viewer.
|
||||
|
||||
> **Security note.** The server exposes live camera, skeleton, vitals, and occupancy over HTTP. The `--bind` flag defaults to `127.0.0.1:9880` (loopback-only). Exposing on `0.0.0.0` or a LAN IP is opt-in — the server logs a warning when it does, but there is no auth/TLS layer. Put a reverse proxy in front if you need remote access.
|
||||
|
||||
> **Brain URL.** Observations are POSTed to `http://127.0.0.1:9876` by default. Override via the `RUVIEW_BRAIN_URL` environment variable or the `--brain <url>` flag on `serve` / `train`.
|
||||
|
||||
### Sensors
|
||||
|
||||
| Sensor | Auto-detected | Data |
|
||||
|
|
@ -565,17 +569,18 @@ Open `http://localhost:9880` for the interactive Three.js 3D viewer.
|
|||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `ruview-pointcloud serve --port 9880` | Start HTTP server + Three.js viewer |
|
||||
| `ruview-pointcloud serve --bind 127.0.0.1:9880` | Start HTTP server + Three.js viewer (loopback-only by default) |
|
||||
| `ruview-pointcloud demo` | Generate synthetic point cloud (no hardware needed) |
|
||||
| `ruview-pointcloud capture --output room.ply` | Capture single frame to PLY file |
|
||||
| `ruview-pointcloud cameras` | List available cameras |
|
||||
| `ruview-pointcloud train --data-dir ./data` | Depth calibration + occupancy training |
|
||||
| `ruview-pointcloud train --data-dir ./data [--brain URL]` | Depth calibration + occupancy training (writes under canonicalized `data-dir`; refuses `..` traversal) |
|
||||
| `ruview-pointcloud csi-test --count 100` | Send test CSI frames (no ESP32 needed) |
|
||||
| `ruview-pointcloud fingerprint <name> [--seconds 5]` | Record a named CSI room fingerprint for later matching |
|
||||
|
||||
### Pipeline Components
|
||||
|
||||
1. **ADR-018 Parser** — Decodes ESP32 CSI binary frames from UDP, extracts I/Q subcarrier amplitudes and phases
|
||||
2. **WiFlow Pose** — 17 COCO keypoint estimation from CSI (loads `wiflow-v1.json`, 186K params)
|
||||
1. **ADR-018 Parser** — Decodes ESP32 CSI binary frames from UDP (magic `0xC5110001` raw CSI and `0xC5110006` feature state), extracts I/Q subcarrier amplitudes and phases. Lives in `parser.rs`; unit-tested against hand-rolled test vectors.
|
||||
2. **Pose (stub)** — 17 COCO keypoint *layout* generated by `heuristic_pose_from_amplitude` from CSI amplitude energy. This is **not** the trained WiFlow model — it is a placeholder so the viewer has a skeleton to render. Wiring to real Candle/ONNX inference from the `wifi-densepose-nn` crate is a planned follow-up.
|
||||
3. **Vital Signs** — Breathing rate from CSI phase analysis (peak counting on stable subcarrier)
|
||||
4. **Motion Detection** — CSI amplitude variance over 20 frames, triggers adaptive capture
|
||||
5. **RF Tomography** — Backprojection from per-node RSSI to 8×8×4 occupancy grid
|
||||
|
|
|
|||
|
|
@ -4,13 +4,18 @@ set(SRCS
|
|||
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
|
||||
"mmwave_sensor.c"
|
||||
"swarm_bridge.c"
|
||||
# ADR-081 — adaptive CSI mesh firmware kernel
|
||||
"rv_radio_ops_esp32.c"
|
||||
"rv_feature_state.c"
|
||||
"rv_mesh.c"
|
||||
"adaptive_controller.c"
|
||||
)
|
||||
|
||||
set(REQUIRES "")
|
||||
|
||||
# ADR-061: Mock CSI generator for QEMU testing
|
||||
# ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding
|
||||
if(CONFIG_CSI_MOCK_ENABLED)
|
||||
list(APPEND SRCS "mock_csi.c")
|
||||
list(APPEND SRCS "mock_csi.c" "rv_radio_ops_mock.c")
|
||||
endif()
|
||||
|
||||
# ADR-045: AMOLED display support (compile-time optional)
|
||||
|
|
|
|||
|
|
@ -87,6 +87,89 @@ menu "Edge Intelligence (ADR-039)"
|
|||
|
||||
endmenu
|
||||
|
||||
menu "Adaptive Controller (ADR-081)"
|
||||
|
||||
config ADAPTIVE_FAST_LOOP_MS
|
||||
int "Fast loop period (ms)"
|
||||
default 200
|
||||
range 50 2000
|
||||
help
|
||||
Period of the fast control loop. The fast loop reads radio
|
||||
health and edge-derived motion/presence/anomaly scores and
|
||||
updates the active capture profile. Default 200 ms matches
|
||||
the ADR-081 spec.
|
||||
|
||||
config ADAPTIVE_MEDIUM_LOOP_MS
|
||||
int "Medium loop period (ms)"
|
||||
default 1000
|
||||
range 200 30000
|
||||
help
|
||||
Period of the medium control loop. The medium loop is where
|
||||
channel selection and role transitions happen (when
|
||||
enable_channel_switch / enable_role_change are on).
|
||||
|
||||
config ADAPTIVE_SLOW_LOOP_MS
|
||||
int "Slow loop period (ms)"
|
||||
default 30000
|
||||
range 1000 300000
|
||||
help
|
||||
Period of the slow control loop. The slow loop publishes
|
||||
HEALTH messages and may request CALIBRATION_START on
|
||||
sustained drift.
|
||||
|
||||
config ADAPTIVE_AGGRESSIVE
|
||||
bool "Aggressive adaptation"
|
||||
default n
|
||||
help
|
||||
When enabled, the controller reacts to motion / anomaly
|
||||
sooner and uses a tighter cadence in SENSE_ACTIVE. Default
|
||||
off matches today's conservative behavior.
|
||||
|
||||
config ADAPTIVE_ENABLE_CHANNEL_SWITCH
|
||||
bool "Allow controller to change WiFi channel"
|
||||
default n
|
||||
help
|
||||
When disabled, the controller never calls set_channel() —
|
||||
channel hopping (ADR-029) and channel override (ADR-060)
|
||||
remain in charge. Enable only after Phase 3 follow-up
|
||||
work has wired the channel-plan mesh message.
|
||||
|
||||
config ADAPTIVE_ENABLE_ROLE_CHANGE
|
||||
bool "Allow controller to change mesh role"
|
||||
default n
|
||||
help
|
||||
When disabled, the controller never advertises a different
|
||||
role to the swarm bridge. Enable after the mesh-plane
|
||||
ROLE_ASSIGN protocol is in place.
|
||||
|
||||
config ADAPTIVE_MOTION_THRESH_PERMIL
|
||||
int "Motion threshold (per-mille)"
|
||||
default 200
|
||||
range 1 1000
|
||||
help
|
||||
Motion score above which the controller transitions to
|
||||
SENSE_ACTIVE and selects RV_PROFILE_FAST_MOTION. Expressed
|
||||
in per-mille (200 = 0.20).
|
||||
|
||||
config ADAPTIVE_ANOMALY_THRESH_PERMIL
|
||||
int "Anomaly threshold (per-mille)"
|
||||
default 600
|
||||
range 1 1000
|
||||
help
|
||||
Anomaly score above which the controller transitions to
|
||||
ALERT. Per-mille (600 = 0.60).
|
||||
|
||||
config ADAPTIVE_MIN_PKT_YIELD
|
||||
int "Minimum packet yield before DEGRADED (pps)"
|
||||
default 5
|
||||
range 0 100
|
||||
help
|
||||
CSI callback rate (per second) below which the controller
|
||||
falls back to DEGRADED mode and pins the radio to
|
||||
RV_PROFILE_PASSIVE_LOW_RATE. 0 disables the degraded gate.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "AMOLED Display (ADR-045)"
|
||||
|
||||
config DISPLAY_ENABLE
|
||||
|
|
|
|||
|
|
@ -0,0 +1,414 @@
|
|||
/**
|
||||
* @file adaptive_controller.c
|
||||
* @brief ADR-081 Layer 2 — Adaptive sensing controller implementation.
|
||||
*
|
||||
* The decide() function is pure and unit-testable; the FreeRTOS plumbing
|
||||
* around it (timers, observation snapshot) is the only ESP-IDF surface.
|
||||
*
|
||||
* Default policy is conservative: it will not change channels unless
|
||||
* enable_channel_switch is true, and it will not change roles unless
|
||||
* enable_role_change is true. With both off the controller still tracks
|
||||
* state and feeds the mesh plane's HEALTH messages, so it is safe to
|
||||
* enable in production before the mesh plane is fully in place.
|
||||
*/
|
||||
|
||||
#include "adaptive_controller.h"
|
||||
#include "rv_radio_ops.h"
|
||||
#include "rv_feature_state.h"
|
||||
#include "rv_mesh.h"
|
||||
#include "edge_processing.h"
|
||||
#include "stream_sender.h"
|
||||
#include "csi_collector.h"
|
||||
|
||||
#include <string.h>
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/timers.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "sdkconfig.h"
|
||||
|
||||
static const char *TAG = "adaptive_ctrl";
|
||||
|
||||
/* ---- Module state ---- */
|
||||
|
||||
static bool s_inited = false;
|
||||
static adapt_config_t s_cfg;
|
||||
static adapt_state_t s_state = ADAPT_STATE_BOOT;
|
||||
static adapt_observation_t s_last_obs;
|
||||
static bool s_obs_valid = false;
|
||||
static portMUX_TYPE s_obs_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
|
||||
static TimerHandle_t s_fast_timer = NULL;
|
||||
static TimerHandle_t s_medium_timer = NULL;
|
||||
static TimerHandle_t s_slow_timer = NULL;
|
||||
|
||||
/* Forward decl: defined below, called from fast_loop_cb. */
|
||||
static void emit_feature_state(void);
|
||||
|
||||
/* ---- Defaults ---- */
|
||||
|
||||
#ifndef CONFIG_ADAPTIVE_FAST_LOOP_MS
|
||||
#define CONFIG_ADAPTIVE_FAST_LOOP_MS 200
|
||||
#endif
|
||||
#ifndef CONFIG_ADAPTIVE_MEDIUM_LOOP_MS
|
||||
#define CONFIG_ADAPTIVE_MEDIUM_LOOP_MS 1000
|
||||
#endif
|
||||
#ifndef CONFIG_ADAPTIVE_SLOW_LOOP_MS
|
||||
#define CONFIG_ADAPTIVE_SLOW_LOOP_MS 30000
|
||||
#endif
|
||||
#ifndef CONFIG_ADAPTIVE_MIN_PKT_YIELD
|
||||
#define CONFIG_ADAPTIVE_MIN_PKT_YIELD 5
|
||||
#endif
|
||||
/* Defaults expressed as integer permille so Kconfig can carry them. */
|
||||
#ifndef CONFIG_ADAPTIVE_MOTION_THRESH_PERMIL
|
||||
#define CONFIG_ADAPTIVE_MOTION_THRESH_PERMIL 200 /* 0.20 */
|
||||
#endif
|
||||
#ifndef CONFIG_ADAPTIVE_ANOMALY_THRESH_PERMIL
|
||||
#define CONFIG_ADAPTIVE_ANOMALY_THRESH_PERMIL 600 /* 0.60 */
|
||||
#endif
|
||||
|
||||
static void apply_defaults(adapt_config_t *cfg)
|
||||
{
|
||||
cfg->fast_loop_ms = CONFIG_ADAPTIVE_FAST_LOOP_MS;
|
||||
cfg->medium_loop_ms = CONFIG_ADAPTIVE_MEDIUM_LOOP_MS;
|
||||
cfg->slow_loop_ms = CONFIG_ADAPTIVE_SLOW_LOOP_MS;
|
||||
#ifdef CONFIG_ADAPTIVE_AGGRESSIVE
|
||||
cfg->aggressive = true;
|
||||
#else
|
||||
cfg->aggressive = false;
|
||||
#endif
|
||||
#ifdef CONFIG_ADAPTIVE_ENABLE_CHANNEL_SWITCH
|
||||
cfg->enable_channel_switch = true;
|
||||
#else
|
||||
cfg->enable_channel_switch = false;
|
||||
#endif
|
||||
#ifdef CONFIG_ADAPTIVE_ENABLE_ROLE_CHANGE
|
||||
cfg->enable_role_change = true;
|
||||
#else
|
||||
cfg->enable_role_change = false;
|
||||
#endif
|
||||
cfg->motion_threshold = (float)CONFIG_ADAPTIVE_MOTION_THRESH_PERMIL / 1000.0f;
|
||||
cfg->anomaly_threshold = (float)CONFIG_ADAPTIVE_ANOMALY_THRESH_PERMIL / 1000.0f;
|
||||
cfg->min_pkt_yield = CONFIG_ADAPTIVE_MIN_PKT_YIELD;
|
||||
}
|
||||
|
||||
/* Pure decision policy lives in its own file so it can link under
|
||||
* host unit tests without FreeRTOS. It is part of this translation
|
||||
* unit via #include to preserve a single object at build time. */
|
||||
#include "adaptive_controller_decide.c"
|
||||
|
||||
/* ---- Observation collection ---- */
|
||||
|
||||
static void collect_observation(adapt_observation_t *out)
|
||||
{
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
/* Radio health from the active binding. */
|
||||
const rv_radio_ops_t *ops = rv_radio_ops_get();
|
||||
if (ops != NULL && ops->get_health != NULL) {
|
||||
rv_radio_health_t h;
|
||||
if (ops->get_health(&h) == ESP_OK) {
|
||||
out->pkt_yield_per_sec = h.pkt_yield_per_sec;
|
||||
out->send_fail_count = h.send_fail_count;
|
||||
out->rssi_median_dbm = h.rssi_median_dbm;
|
||||
out->noise_floor_dbm = h.noise_floor_dbm;
|
||||
}
|
||||
}
|
||||
|
||||
/* Edge-derived state. The ADR-039 vitals packet exposes presence_score
|
||||
* and motion_energy directly; we treat motion_energy as a proxy for
|
||||
* motion_score by clamping to [0,1]. anomaly_score and node_coherence
|
||||
* are not yet emitted by edge_processing — placeholder until Layer 4
|
||||
* extraction lands. */
|
||||
edge_vitals_pkt_t vitals;
|
||||
if (edge_get_vitals(&vitals)) {
|
||||
out->presence_score = vitals.presence_score;
|
||||
float m = vitals.motion_energy;
|
||||
if (m < 0.0f) m = 0.0f;
|
||||
if (m > 1.0f) m = 1.0f;
|
||||
out->motion_score = m;
|
||||
}
|
||||
out->anomaly_score = 0.0f;
|
||||
out->node_coherence = 1.0f;
|
||||
}
|
||||
|
||||
/* ---- Decision application ---- */
|
||||
|
||||
/* ADR-081 L3: epoch monotonically advances per mesh session. Seeded at
|
||||
* init; every major state transition or role change bumps it so
|
||||
* receivers can order events. */
|
||||
static uint32_t s_mesh_epoch = 1;
|
||||
|
||||
/* ADR-081 L3: current node role. Updated by ROLE_ASSIGN receipt (future
|
||||
* mesh-plane RX path) or forced by tests. Default Observer. */
|
||||
static uint8_t s_role = RV_ROLE_OBSERVER;
|
||||
|
||||
/* 8-byte node id. Upper 7 bytes are zero by default; byte 0 is the
|
||||
* legacy CSI node id for compatibility with the ADR-018 header. */
|
||||
static void node_id_bytes(uint8_t out[8])
|
||||
{
|
||||
memset(out, 0, 8);
|
||||
out[0] = csi_collector_get_node_id();
|
||||
}
|
||||
|
||||
static void apply_decision(const adapt_decision_t *dec)
|
||||
{
|
||||
const rv_radio_ops_t *ops = rv_radio_ops_get();
|
||||
adapt_state_t prev = s_state;
|
||||
|
||||
if (dec->change_state) {
|
||||
ESP_LOGI(TAG, "state %u → %u",
|
||||
(unsigned)s_state, (unsigned)dec->new_state);
|
||||
s_state = (adapt_state_t)dec->new_state;
|
||||
|
||||
/* ADR-081 L3: on transition to ALERT, emit ANOMALY_ALERT on the
|
||||
* mesh plane. On any role-relevant transition, bump the epoch. */
|
||||
if (s_state == ADAPT_STATE_ALERT && prev != ADAPT_STATE_ALERT) {
|
||||
uint8_t nid[8];
|
||||
node_id_bytes(nid);
|
||||
adapt_observation_t obs;
|
||||
float motion = 0.0f, anomaly = 0.0f;
|
||||
portENTER_CRITICAL(&s_obs_lock);
|
||||
if (s_obs_valid) { obs = s_last_obs; motion = obs.motion_score;
|
||||
anomaly = obs.anomaly_score; }
|
||||
portEXIT_CRITICAL(&s_obs_lock);
|
||||
uint8_t severity = (uint8_t)(anomaly * 255.0f);
|
||||
rv_mesh_send_anomaly(s_role, s_mesh_epoch, nid,
|
||||
RV_ANOMALY_COHERENCE_LOSS, severity,
|
||||
anomaly, motion);
|
||||
}
|
||||
if (s_state == ADAPT_STATE_DEGRADED && prev != ADAPT_STATE_DEGRADED) {
|
||||
uint8_t nid[8];
|
||||
node_id_bytes(nid);
|
||||
rv_mesh_send_anomaly(s_role, s_mesh_epoch, nid,
|
||||
RV_ANOMALY_PKT_YIELD_COLLAPSE,
|
||||
200, 1.0f, 0.0f);
|
||||
}
|
||||
s_mesh_epoch++;
|
||||
}
|
||||
|
||||
if (dec->change_profile && ops != NULL && ops->set_capture_profile != NULL) {
|
||||
ops->set_capture_profile(dec->new_profile);
|
||||
}
|
||||
|
||||
if (dec->change_channel && s_cfg.enable_channel_switch &&
|
||||
ops != NULL && ops->set_channel != NULL) {
|
||||
ops->set_channel(dec->new_channel, 20);
|
||||
}
|
||||
|
||||
/* suggested_vital_interval_ms: the controller publishes a hint; the
|
||||
* edge pipeline picks it up via edge_processing on its next emit. We
|
||||
* don't yet have edge_set_vital_interval(); recorded for Phase 3. */
|
||||
(void)dec->request_calibration;
|
||||
}
|
||||
|
||||
/* ---- Loop callbacks ---- */
|
||||
|
||||
static void fast_loop_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
adapt_observation_t obs;
|
||||
collect_observation(&obs);
|
||||
|
||||
portENTER_CRITICAL(&s_obs_lock);
|
||||
s_last_obs = obs;
|
||||
s_obs_valid = true;
|
||||
portEXIT_CRITICAL(&s_obs_lock);
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&s_cfg, s_state, &obs, &dec);
|
||||
apply_decision(&dec);
|
||||
|
||||
/* ADR-081 Layer 4/5: emit compact feature state on every fast tick
|
||||
* (default 200 ms → 5 Hz, within the 1–10 Hz spec). Replaces raw
|
||||
* ADR-018 CSI as the default upstream; raw remains available as a
|
||||
* debug stream gated by the channel plan. */
|
||||
emit_feature_state();
|
||||
}
|
||||
|
||||
static void medium_loop_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
/* Phase 3 stub: when enable_channel_switch is on, choose a channel
|
||||
* based on RSSI/noise/yield. Today, log the snapshot so operators can
|
||||
* see the controller is running. */
|
||||
adapt_observation_t obs;
|
||||
portENTER_CRITICAL(&s_obs_lock);
|
||||
obs = s_last_obs;
|
||||
portEXIT_CRITICAL(&s_obs_lock);
|
||||
|
||||
if (s_obs_valid) {
|
||||
ESP_LOGI(TAG, "medium tick: state=%u yield=%upps motion=%.2f presence=%.2f rssi=%d",
|
||||
(unsigned)s_state,
|
||||
(unsigned)obs.pkt_yield_per_sec,
|
||||
(double)obs.motion_score,
|
||||
(double)obs.presence_score,
|
||||
(int)obs.rssi_median_dbm);
|
||||
}
|
||||
}
|
||||
|
||||
/* ADR-081 Layer 4: emit one rv_feature_state_t packet onto the wire.
|
||||
*
|
||||
* Pulls from the latest observation + latest vitals + the active capture
|
||||
* profile. Send is best-effort — stream_sender will report its own
|
||||
* failures; we don't re-queue. At 5 Hz default cadence this is 300 B/s
|
||||
* per node, vs. ~100 KB/s for raw ADR-018 CSI. */
|
||||
static uint16_t s_feature_state_seq = 0;
|
||||
|
||||
static void emit_feature_state(void)
|
||||
{
|
||||
rv_feature_state_t pkt;
|
||||
memset(&pkt, 0, sizeof(pkt));
|
||||
|
||||
adapt_observation_t obs;
|
||||
bool have_obs = false;
|
||||
portENTER_CRITICAL(&s_obs_lock);
|
||||
if (s_obs_valid) {
|
||||
obs = s_last_obs;
|
||||
have_obs = true;
|
||||
}
|
||||
portEXIT_CRITICAL(&s_obs_lock);
|
||||
|
||||
if (have_obs) {
|
||||
pkt.motion_score = obs.motion_score;
|
||||
pkt.presence_score = obs.presence_score;
|
||||
pkt.anomaly_score = obs.anomaly_score;
|
||||
pkt.node_coherence = obs.node_coherence;
|
||||
}
|
||||
|
||||
/* Fill vitals from edge_processing's latest packet. */
|
||||
edge_vitals_pkt_t v;
|
||||
if (edge_get_vitals(&v)) {
|
||||
pkt.respiration_bpm = (float)v.breathing_rate / 100.0f;
|
||||
pkt.heartbeat_bpm = (float)v.heartrate / 10000.0f;
|
||||
/* Confidence proxies: presence score for resp, 1.0 if heart BPM
|
||||
* is within physiological range. */
|
||||
pkt.respiration_conf = (v.breathing_rate > 0) ? v.presence_score : 0.0f;
|
||||
pkt.heartbeat_conf = (v.heartrate > 400000u && v.heartrate < 1800000u)
|
||||
? 0.8f : 0.0f;
|
||||
if (pkt.respiration_bpm > 0.0f) pkt.quality_flags |= RV_QFLAG_RESPIRATION_VALID;
|
||||
if (pkt.heartbeat_bpm > 0.0f) pkt.quality_flags |= RV_QFLAG_HEARTBEAT_VALID;
|
||||
if (pkt.presence_score >= 0.5f) pkt.quality_flags |= RV_QFLAG_PRESENCE_VALID;
|
||||
if (v.flags & 0x02) pkt.quality_flags |= RV_QFLAG_ANOMALY_TRIGGERED; /* fall bit */
|
||||
}
|
||||
|
||||
if (s_state == ADAPT_STATE_DEGRADED) pkt.quality_flags |= RV_QFLAG_DEGRADED_MODE;
|
||||
if (s_state == ADAPT_STATE_CALIBRATION) pkt.quality_flags |= RV_QFLAG_CALIBRATING;
|
||||
|
||||
/* Active profile, for receiver-side weighting. */
|
||||
const rv_radio_ops_t *ops = rv_radio_ops_get();
|
||||
uint8_t profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
if (ops != NULL && ops->get_health != NULL) {
|
||||
rv_radio_health_t h;
|
||||
if (ops->get_health(&h) == ESP_OK) profile = h.current_profile;
|
||||
}
|
||||
|
||||
rv_feature_state_finalize(&pkt,
|
||||
csi_collector_get_node_id(),
|
||||
s_feature_state_seq++,
|
||||
(uint64_t)esp_timer_get_time(),
|
||||
profile);
|
||||
|
||||
int sent = stream_sender_send((const uint8_t *)&pkt, sizeof(pkt));
|
||||
if (sent < 0) {
|
||||
ESP_LOGW(TAG, "feature_state emit failed");
|
||||
}
|
||||
}
|
||||
|
||||
static void slow_loop_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
/* ADR-081 L3: publish a HEALTH mesh message every slow tick
|
||||
* (default 30 s). The coordinator uses these to track liveness and
|
||||
* detect sync-error drift. */
|
||||
uint8_t nid[8];
|
||||
node_id_bytes(nid);
|
||||
rv_mesh_send_health(s_role, s_mesh_epoch, nid);
|
||||
|
||||
ESP_LOGI(TAG, "slow tick (state=%u, feature_state_seq=%u, role=%u, epoch=%u) HEALTH sent",
|
||||
(unsigned)s_state, (unsigned)s_feature_state_seq,
|
||||
(unsigned)s_role, (unsigned)s_mesh_epoch);
|
||||
}
|
||||
|
||||
/* ---- Public API ---- */
|
||||
|
||||
esp_err_t adaptive_controller_init(const adapt_config_t *cfg)
|
||||
{
|
||||
if (s_inited) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
if (cfg != NULL) {
|
||||
s_cfg = *cfg;
|
||||
} else {
|
||||
apply_defaults(&s_cfg);
|
||||
}
|
||||
|
||||
/* Sanity clamps. */
|
||||
if (s_cfg.fast_loop_ms < 50) s_cfg.fast_loop_ms = 50;
|
||||
if (s_cfg.medium_loop_ms < 200) s_cfg.medium_loop_ms = 200;
|
||||
if (s_cfg.slow_loop_ms < 1000) s_cfg.slow_loop_ms = 1000;
|
||||
|
||||
s_state = ADAPT_STATE_RADIO_INIT;
|
||||
|
||||
s_fast_timer = xTimerCreate("adapt_fast",
|
||||
pdMS_TO_TICKS(s_cfg.fast_loop_ms),
|
||||
pdTRUE, NULL, fast_loop_cb);
|
||||
s_medium_timer = xTimerCreate("adapt_med",
|
||||
pdMS_TO_TICKS(s_cfg.medium_loop_ms),
|
||||
pdTRUE, NULL, medium_loop_cb);
|
||||
s_slow_timer = xTimerCreate("adapt_slow",
|
||||
pdMS_TO_TICKS(s_cfg.slow_loop_ms),
|
||||
pdTRUE, NULL, slow_loop_cb);
|
||||
|
||||
if (s_fast_timer == NULL || s_medium_timer == NULL || s_slow_timer == NULL) {
|
||||
ESP_LOGE(TAG, "timer create failed");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
if (xTimerStart(s_fast_timer, 0) != pdPASS ||
|
||||
xTimerStart(s_medium_timer, 0) != pdPASS ||
|
||||
xTimerStart(s_slow_timer, 0) != pdPASS) {
|
||||
ESP_LOGE(TAG, "timer start failed");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
s_state = ADAPT_STATE_SENSE_IDLE;
|
||||
s_inited = true;
|
||||
|
||||
ESP_LOGI(TAG,
|
||||
"adaptive controller online: fast=%ums med=%ums slow=%ums "
|
||||
"(channel_switch=%d role_change=%d aggressive=%d)",
|
||||
(unsigned)s_cfg.fast_loop_ms,
|
||||
(unsigned)s_cfg.medium_loop_ms,
|
||||
(unsigned)s_cfg.slow_loop_ms,
|
||||
(int)s_cfg.enable_channel_switch,
|
||||
(int)s_cfg.enable_role_change,
|
||||
(int)s_cfg.aggressive);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
adapt_state_t adaptive_controller_state(void)
|
||||
{
|
||||
return s_state;
|
||||
}
|
||||
|
||||
bool adaptive_controller_observation(adapt_observation_t *out)
|
||||
{
|
||||
if (out == NULL) return false;
|
||||
bool ok = false;
|
||||
portENTER_CRITICAL(&s_obs_lock);
|
||||
if (s_obs_valid) {
|
||||
*out = s_last_obs;
|
||||
ok = true;
|
||||
}
|
||||
portEXIT_CRITICAL(&s_obs_lock);
|
||||
return ok;
|
||||
}
|
||||
|
||||
void adaptive_controller_force_state(adapt_state_t st)
|
||||
{
|
||||
ESP_LOGI(TAG, "force state %u → %u", (unsigned)s_state, (unsigned)st);
|
||||
s_state = st;
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* @file adaptive_controller.h
|
||||
* @brief ADR-081 Layer 2 — Adaptive sensing controller.
|
||||
*
|
||||
* Closed-loop firmware control over cadence, capture profile, channel, and
|
||||
* mesh role. Three cooperating loops:
|
||||
*
|
||||
* Fast (~200 ms): packet rate, active probing
|
||||
* Medium (~1 s) : channel selection, role transitions
|
||||
* Slow (~30 s) : baseline recalibration
|
||||
*
|
||||
* Outputs are routed through:
|
||||
* - rv_radio_ops_t (Layer 1) for set_channel / set_capture_profile
|
||||
* - swarm_bridge / mesh plane (Layer 3) for CHANNEL_PLAN, ROLE_ASSIGN
|
||||
* - edge_processing (Layer 4) for cadence and threshold updates
|
||||
*
|
||||
* Default policy is conservative — matches today's behavior. Aggressive
|
||||
* adaptation is opt-in via Kconfig (ADAPTIVE_CONTROLLER_AGGRESSIVE).
|
||||
*/
|
||||
|
||||
#ifndef ADAPTIVE_CONTROLLER_H
|
||||
#define ADAPTIVE_CONTROLLER_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/** Controller-level state machine (ADR-081 firmware FSM). */
|
||||
typedef enum {
|
||||
ADAPT_STATE_BOOT = 0,
|
||||
ADAPT_STATE_SELF_TEST = 1,
|
||||
ADAPT_STATE_RADIO_INIT = 2,
|
||||
ADAPT_STATE_TIME_SYNC = 3,
|
||||
ADAPT_STATE_CALIBRATION = 4,
|
||||
ADAPT_STATE_SENSE_IDLE = 5,
|
||||
ADAPT_STATE_SENSE_ACTIVE = 6,
|
||||
ADAPT_STATE_ALERT = 7,
|
||||
ADAPT_STATE_DEGRADED = 8,
|
||||
} adapt_state_t;
|
||||
|
||||
/** Observation window aggregated each fast tick. */
|
||||
typedef struct {
|
||||
uint16_t pkt_yield_per_sec; /**< From rv_radio_health.pkt_yield_per_sec. */
|
||||
uint16_t send_fail_count; /**< UDP/socket send failures. */
|
||||
int8_t rssi_median_dbm;
|
||||
int8_t noise_floor_dbm;
|
||||
float motion_score; /**< Pulled from edge_processing. */
|
||||
float presence_score;
|
||||
float anomaly_score;
|
||||
float node_coherence; /**< Inter-link coherence; 1.0 if single node. */
|
||||
} adapt_observation_t;
|
||||
|
||||
/** Decisions emitted by a controller tick. */
|
||||
typedef struct {
|
||||
bool change_profile;
|
||||
uint8_t new_profile; /**< rv_capture_profile_t. */
|
||||
bool change_channel;
|
||||
uint8_t new_channel;
|
||||
bool change_state;
|
||||
uint8_t new_state; /**< adapt_state_t. */
|
||||
bool request_calibration; /**< Coordinator should issue CALIBRATION_START. */
|
||||
uint16_t suggested_vital_interval_ms;
|
||||
} adapt_decision_t;
|
||||
|
||||
/** Controller config (loaded from NVS / Kconfig). */
|
||||
typedef struct {
|
||||
uint16_t fast_loop_ms; /**< Default 200 ms. */
|
||||
uint16_t medium_loop_ms; /**< Default 1000 ms. */
|
||||
uint16_t slow_loop_ms; /**< Default 30000 ms. */
|
||||
bool aggressive; /**< true = react sooner / more often. */
|
||||
bool enable_channel_switch; /**< false = controller may never hop. */
|
||||
bool enable_role_change;
|
||||
float motion_threshold; /**< 0..1, enter SENSE_ACTIVE above this. */
|
||||
float anomaly_threshold; /**< 0..1, enter ALERT above this. */
|
||||
uint16_t min_pkt_yield; /**< pps below this → DEGRADED. */
|
||||
} adapt_config_t;
|
||||
|
||||
/**
|
||||
* Initialize the adaptive controller.
|
||||
*
|
||||
* Spawns one FreeRTOS task that runs the three loops via FreeRTOS timers.
|
||||
* Idempotent — second call is a no-op.
|
||||
*
|
||||
* @param cfg Config (NULL = use Kconfig defaults).
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t adaptive_controller_init(const adapt_config_t *cfg);
|
||||
|
||||
/** Get the current state. */
|
||||
adapt_state_t adaptive_controller_state(void);
|
||||
|
||||
/**
|
||||
* Snapshot the latest observation (most recent fast-loop sample).
|
||||
* Useful for telemetry and the `HEALTH` mesh message.
|
||||
*
|
||||
* @param out Output buffer.
|
||||
* @return true if a valid observation has been recorded.
|
||||
*/
|
||||
bool adaptive_controller_observation(adapt_observation_t *out);
|
||||
|
||||
/**
|
||||
* Force a state transition (e.g. from a remote ROLE_ASSIGN message).
|
||||
* Logged at INFO; controller may immediately transition again on next tick.
|
||||
*/
|
||||
void adaptive_controller_force_state(adapt_state_t st);
|
||||
|
||||
/**
|
||||
* Pure-function policy: given an observation + current state + config,
|
||||
* compute the decision. Exposed in the header so it can be unit-tested
|
||||
* offline (no FreeRTOS / ESP-IDF dependency in the body).
|
||||
*/
|
||||
void adaptive_controller_decide(const adapt_config_t *cfg,
|
||||
adapt_state_t current,
|
||||
const adapt_observation_t *obs,
|
||||
adapt_decision_t *out);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* ADAPTIVE_CONTROLLER_H */
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* @file adaptive_controller_decide.c
|
||||
* @brief ADR-081 Layer 2 — pure decision policy.
|
||||
*
|
||||
* Extracted so host unit tests can link this without ESP-IDF / FreeRTOS.
|
||||
* adaptive_controller.c includes this file; the host Makefile links it
|
||||
* directly against the test harness.
|
||||
*/
|
||||
|
||||
#include <string.h>
|
||||
#include "adaptive_controller.h"
|
||||
#include "rv_radio_ops.h"
|
||||
|
||||
void adaptive_controller_decide(const adapt_config_t *cfg,
|
||||
adapt_state_t current,
|
||||
const adapt_observation_t *obs,
|
||||
adapt_decision_t *out)
|
||||
{
|
||||
if (cfg == NULL || obs == NULL || out == NULL) {
|
||||
return;
|
||||
}
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->new_state = (uint8_t)current;
|
||||
out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
|
||||
/* Degraded gate: pkt yield collapse or severe coherence loss → DEGRADED. */
|
||||
if (obs->pkt_yield_per_sec < cfg->min_pkt_yield ||
|
||||
obs->node_coherence < 0.20f) {
|
||||
if (current != ADAPT_STATE_DEGRADED) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_DEGRADED;
|
||||
}
|
||||
out->change_profile = (current != ADAPT_STATE_DEGRADED);
|
||||
out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
out->suggested_vital_interval_ms = 2000;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Anomaly trumps motion. */
|
||||
if (obs->anomaly_score >= cfg->anomaly_threshold) {
|
||||
if (current != ADAPT_STATE_ALERT) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_ALERT;
|
||||
}
|
||||
out->change_profile = true;
|
||||
out->new_profile = RV_PROFILE_FAST_MOTION;
|
||||
out->suggested_vital_interval_ms = 100;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Motion → SENSE_ACTIVE with FAST_MOTION profile. */
|
||||
if (obs->motion_score >= cfg->motion_threshold) {
|
||||
if (current != ADAPT_STATE_SENSE_ACTIVE) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_SENSE_ACTIVE;
|
||||
}
|
||||
out->change_profile = true;
|
||||
out->new_profile = RV_PROFILE_FAST_MOTION;
|
||||
out->suggested_vital_interval_ms = cfg->aggressive ? 100 : 200;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Stable presence + quiet → high-sensitivity respiration. */
|
||||
if (obs->presence_score >= 0.5f && obs->motion_score < 0.05f) {
|
||||
if (current != ADAPT_STATE_SENSE_IDLE) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_SENSE_IDLE;
|
||||
}
|
||||
out->change_profile = true;
|
||||
out->new_profile = RV_PROFILE_RESP_HIGH_SENS;
|
||||
out->suggested_vital_interval_ms = 1000;
|
||||
return;
|
||||
}
|
||||
|
||||
/* Default: passive low rate. */
|
||||
if (current != ADAPT_STATE_SENSE_IDLE) {
|
||||
out->change_state = true;
|
||||
out->new_state = ADAPT_STATE_SENSE_IDLE;
|
||||
}
|
||||
out->change_profile = (current != ADAPT_STATE_SENSE_IDLE);
|
||||
out->new_profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
out->suggested_vital_interval_ms = cfg->aggressive ? 500 : 1000;
|
||||
}
|
||||
|
|
@ -308,6 +308,43 @@ uint8_t csi_collector_get_node_id(void)
|
|||
return s_node_id;
|
||||
}
|
||||
|
||||
/* ---- ADR-081: packet yield accessor for the radio abstraction layer ---- */
|
||||
|
||||
uint16_t csi_collector_get_pkt_yield_per_sec(void)
|
||||
{
|
||||
/* Simple sliding window: record the callback count at ~1 s ago, return
|
||||
* the delta. Called from adaptive_controller's fast loop (200 ms), so
|
||||
* we update the snapshot every ~5 calls. */
|
||||
static int64_t s_yield_window_start_us = 0;
|
||||
static uint32_t s_yield_window_start_cb = 0;
|
||||
static uint16_t s_last_yield = 0;
|
||||
|
||||
int64_t now = esp_timer_get_time();
|
||||
if (s_yield_window_start_us == 0) {
|
||||
s_yield_window_start_us = now;
|
||||
s_yield_window_start_cb = s_cb_count;
|
||||
return 0;
|
||||
}
|
||||
int64_t elapsed = now - s_yield_window_start_us;
|
||||
if (elapsed < 1000000LL) {
|
||||
return s_last_yield;
|
||||
}
|
||||
uint32_t delta = s_cb_count - s_yield_window_start_cb;
|
||||
/* Scale back to per-second if the window ran long (shouldn't, but be safe). */
|
||||
uint64_t per_sec = ((uint64_t)delta * 1000000ULL) / (uint64_t)elapsed;
|
||||
if (per_sec > 0xFFFFu) per_sec = 0xFFFFu;
|
||||
s_last_yield = (uint16_t)per_sec;
|
||||
s_yield_window_start_us = now;
|
||||
s_yield_window_start_cb = s_cb_count;
|
||||
return s_last_yield;
|
||||
}
|
||||
|
||||
uint16_t csi_collector_get_send_fail_count(void)
|
||||
{
|
||||
uint32_t f = s_send_fail;
|
||||
return (f > 0xFFFFu) ? 0xFFFFu : (uint16_t)f;
|
||||
}
|
||||
|
||||
/* ---- ADR-029: Channel hopping ---- */
|
||||
|
||||
void csi_collector_set_hop_table(const uint8_t *channels, uint8_t hop_count, uint32_t dwell_ms)
|
||||
|
|
|
|||
|
|
@ -94,4 +94,23 @@ void csi_collector_start_hop_timer(void);
|
|||
*/
|
||||
esp_err_t csi_inject_ndp_frame(void);
|
||||
|
||||
/**
|
||||
* Get the recent CSI callback rate (per second).
|
||||
*
|
||||
* Computed as a sliding 1-second window over the internal s_cb_count
|
||||
* counter. Used by the ADR-081 radio abstraction layer to fill the
|
||||
* pkt_yield_per_sec field of rv_radio_health_t.
|
||||
*
|
||||
* @return Callbacks observed in the trailing ~1 second.
|
||||
*/
|
||||
uint16_t csi_collector_get_pkt_yield_per_sec(void);
|
||||
|
||||
/**
|
||||
* Get the cumulative UDP send-failure counter since boot.
|
||||
*
|
||||
* @return Number of stream_sender_send() failures recorded by the
|
||||
* CSI callback path.
|
||||
*/
|
||||
uint16_t csi_collector_get_send_fail_count(void);
|
||||
|
||||
#endif /* CSI_COLLECTOR_H */
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@
|
|||
#include "display_task.h"
|
||||
#include "mmwave_sensor.h"
|
||||
#include "swarm_bridge.h"
|
||||
#include "rv_radio_ops.h" /* ADR-081 Layer 1 — Radio Abstraction Layer. */
|
||||
#include "adaptive_controller.h" /* ADR-081 Layer 2 — Adaptive controller. */
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
#include "mock_csi.h"
|
||||
#endif
|
||||
|
|
@ -278,6 +280,31 @@ void app_main(void)
|
|||
ESP_LOGI(TAG, "Mock CSI mode: skipping swarm bridge");
|
||||
#endif
|
||||
|
||||
/* ADR-081 Layer 1: register the active radio ops binding.
|
||||
* - Real hardware: ESP32 binding wrapping csi_collector + esp_wifi.
|
||||
* - QEMU / offline: mock binding wrapping mock_csi.c.
|
||||
* Either way, the layers above (adaptive controller, mesh plane,
|
||||
* feature extraction) address the radio through the same vtable —
|
||||
* this is the portability acceptance test in ADR-081. */
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
rv_radio_ops_mock_register();
|
||||
#else
|
||||
rv_radio_ops_esp32_register();
|
||||
#endif
|
||||
const rv_radio_ops_t *radio_ops = rv_radio_ops_get();
|
||||
if (radio_ops != NULL && radio_ops->init != NULL) {
|
||||
radio_ops->init();
|
||||
}
|
||||
|
||||
/* ADR-081 Layer 2: start the adaptive controller. NULL config → use
|
||||
* Kconfig defaults. Default policy is conservative: no channel
|
||||
* switching, no role change. Operators opt in via menuconfig. */
|
||||
esp_err_t adapt_ret = adaptive_controller_init(NULL);
|
||||
if (adapt_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "Adaptive controller init failed: %s",
|
||||
esp_err_to_name(adapt_ret));
|
||||
}
|
||||
|
||||
/* Initialize power management. */
|
||||
power_mgmt_init(g_nvs_config.power_duty);
|
||||
|
||||
|
|
@ -289,13 +316,14 @@ void app_main(void)
|
|||
}
|
||||
#endif
|
||||
|
||||
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s, swarm=%s)",
|
||||
ESP_LOGI(TAG, "CSI streaming active → %s:%d (edge_tier=%u, OTA=%s, WASM=%s, mmWave=%s, swarm=%s, adapt=%s)",
|
||||
g_nvs_config.target_ip, g_nvs_config.target_port,
|
||||
g_nvs_config.edge_tier,
|
||||
(ota_ret == ESP_OK) ? "ready" : "off",
|
||||
(wasm_ret == ESP_OK) ? "ready" : "off",
|
||||
(mmwave_ret == ESP_OK) ? "active" : "off",
|
||||
(swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off");
|
||||
(swarm_ret == ESP_OK) ? g_nvs_config.seed_url : "off",
|
||||
(adapt_ret == ESP_OK) ? "on" : "off");
|
||||
|
||||
/* Main loop — keep alive */
|
||||
while (1) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* @file rv_feature_state.c
|
||||
* @brief ADR-081 Layer 4 — Feature state packet helpers.
|
||||
*/
|
||||
|
||||
#include "rv_feature_state.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
uint32_t rv_feature_state_crc32(const uint8_t *data, size_t len)
|
||||
{
|
||||
/* IEEE CRC32 (poly 0xEDB88320), bit-by-bit. Small (~80 byte) input at
|
||||
* low cadence — no need for a 1 KB lookup table. */
|
||||
uint32_t crc = 0xFFFFFFFFu;
|
||||
for (size_t i = 0; i < len; i++) {
|
||||
crc ^= data[i];
|
||||
for (int b = 0; b < 8; b++) {
|
||||
uint32_t mask = -(crc & 1u);
|
||||
crc = (crc >> 1) ^ (0xEDB88320u & mask);
|
||||
}
|
||||
}
|
||||
return ~crc;
|
||||
}
|
||||
|
||||
void rv_feature_state_finalize(rv_feature_state_t *pkt,
|
||||
uint8_t node_id,
|
||||
uint16_t seq,
|
||||
uint64_t ts_us,
|
||||
uint8_t mode)
|
||||
{
|
||||
if (pkt == NULL) {
|
||||
return;
|
||||
}
|
||||
pkt->magic = RV_FEATURE_STATE_MAGIC;
|
||||
pkt->node_id = node_id;
|
||||
pkt->mode = mode;
|
||||
pkt->seq = seq;
|
||||
pkt->ts_us = ts_us;
|
||||
pkt->reserved = 0;
|
||||
|
||||
/* CRC32 over everything except the trailing crc32 field itself. */
|
||||
const size_t crc_offset = sizeof(rv_feature_state_t) - sizeof(uint32_t);
|
||||
pkt->crc32 = rv_feature_state_crc32((const uint8_t *)pkt, crc_offset);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* @file rv_feature_state.h
|
||||
* @brief ADR-081 Layer 4 — Compact on-wire feature state packet.
|
||||
*
|
||||
* The default upstream payload from a node. Replaces raw ADR-018 CSI as the
|
||||
* primary stream; ADR-018 raw frames remain available as a debug stream
|
||||
* gated by the controller / channel plan.
|
||||
*
|
||||
* Magic numbers in use across the firmware:
|
||||
* 0xC5110001 — ADR-018 raw CSI frame (csi_collector.h)
|
||||
* 0xC5110002 — ADR-039 vitals packet (edge_processing.h)
|
||||
* 0xC5110003 — ADR-069 feature vector (edge_processing.h)
|
||||
* 0xC5110004 — ADR-063 fused vitals (edge_processing.h)
|
||||
* 0xC5110005 — ADR-039 compressed CSI (edge_processing.h)
|
||||
* 0xC5110006 — ADR-081 feature state (this file) ← new
|
||||
*/
|
||||
|
||||
#ifndef RV_FEATURE_STATE_H
|
||||
#define RV_FEATURE_STATE_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/** Magic number for ADR-081 rv_feature_state_t. */
|
||||
#define RV_FEATURE_STATE_MAGIC 0xC5110006u
|
||||
|
||||
/** Quality flag bits. */
|
||||
#define RV_QFLAG_PRESENCE_VALID (1u << 0)
|
||||
#define RV_QFLAG_RESPIRATION_VALID (1u << 1)
|
||||
#define RV_QFLAG_HEARTBEAT_VALID (1u << 2)
|
||||
#define RV_QFLAG_ANOMALY_TRIGGERED (1u << 3)
|
||||
#define RV_QFLAG_ENV_SHIFT_DETECTED (1u << 4)
|
||||
#define RV_QFLAG_DEGRADED_MODE (1u << 5)
|
||||
#define RV_QFLAG_CALIBRATING (1u << 6)
|
||||
#define RV_QFLAG_RECOMMEND_RECAL (1u << 7)
|
||||
|
||||
/**
|
||||
* Compact per-node sensing state. Sent at 1-10 Hz by default, replacing the
|
||||
* raw ADR-018 stream as the primary upstream payload.
|
||||
*
|
||||
* Mode field carries the rv_capture_profile_t value of the dominant window
|
||||
* — receivers can use it to weight features (a sample emitted under
|
||||
* RV_PROFILE_FAST_MOTION will have a stale respiration_bpm, etc.).
|
||||
*
|
||||
* CRC32 is the IEEE polynomial computed over bytes [0 .. sizeof - 4].
|
||||
*/
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic; /**< RV_FEATURE_STATE_MAGIC. */
|
||||
uint8_t node_id; /**< Source node id. */
|
||||
uint8_t mode; /**< rv_capture_profile_t at emit time. */
|
||||
uint16_t seq; /**< Monotonic per-node sequence. */
|
||||
uint64_t ts_us; /**< Node-local microseconds. */
|
||||
float motion_score; /**< 0..1, 100 ms window. */
|
||||
float presence_score; /**< 0..1, 1 s window. */
|
||||
float respiration_bpm; /**< Breaths per minute. */
|
||||
float respiration_conf; /**< 0..1. */
|
||||
float heartbeat_bpm; /**< Beats per minute. */
|
||||
float heartbeat_conf; /**< 0..1. */
|
||||
float anomaly_score; /**< 0..1, z-score-derived. */
|
||||
float env_shift_score; /**< 0..1, baseline drift. */
|
||||
float node_coherence; /**< 0..1, multi-link agreement. */
|
||||
uint16_t quality_flags; /**< RV_QFLAG_* bitmap. */
|
||||
uint16_t reserved;
|
||||
uint32_t crc32; /**< IEEE CRC32 over bytes [0..end-4]. */
|
||||
} rv_feature_state_t;
|
||||
|
||||
_Static_assert(sizeof(rv_feature_state_t) == 60,
|
||||
"rv_feature_state_t must be 60 bytes on the wire");
|
||||
|
||||
/**
|
||||
* Compute IEEE CRC32 over a byte buffer.
|
||||
*
|
||||
* Provided here (not in a separate util) because the firmware does not yet
|
||||
* have a shared CRC32 helper — only zlib's via lwIP, which is not always
|
||||
* exposed. This implementation is bit-by-bit; ~80 bytes/packet at low
|
||||
* cadence has negligible CPU cost.
|
||||
*
|
||||
* @param data Input buffer.
|
||||
* @param len Input length in bytes.
|
||||
* @return IEEE CRC32 of the input.
|
||||
*/
|
||||
uint32_t rv_feature_state_crc32(const uint8_t *data, size_t len);
|
||||
|
||||
/**
|
||||
* Finalize an rv_feature_state_t by populating magic, seq, ts_us, and crc32.
|
||||
* Caller fills the remaining fields in-place before calling this. After
|
||||
* finalize() the packet is ready to send on the wire.
|
||||
*
|
||||
* @param pkt Packet to finalize (caller-owned).
|
||||
* @param node_id Source node id (typically csi_collector_get_node_id()).
|
||||
* @param seq Monotonic sequence (caller-managed).
|
||||
* @param ts_us Node-local microseconds (typically esp_timer_get_time()).
|
||||
* @param mode Active rv_capture_profile_t.
|
||||
*/
|
||||
void rv_feature_state_finalize(rv_feature_state_t *pkt,
|
||||
uint8_t node_id,
|
||||
uint16_t seq,
|
||||
uint64_t ts_us,
|
||||
uint8_t mode);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* RV_FEATURE_STATE_H */
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* @file rv_mesh.c
|
||||
* @brief ADR-081 Layer 3 — Mesh Sensing Plane implementation.
|
||||
*
|
||||
* Encoder/decoder are pure functions (no ESP-IDF deps) and therefore
|
||||
* host-unit-testable. The send helpers wrap stream_sender so the
|
||||
* firmware can use a single upstream socket for all payload types.
|
||||
*/
|
||||
|
||||
#include "rv_mesh.h"
|
||||
#include "rv_feature_state.h"
|
||||
#include "rv_radio_ops.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#ifndef RV_MESH_HOST_TEST
|
||||
#include "esp_log.h"
|
||||
#include "esp_timer.h"
|
||||
#include "stream_sender.h"
|
||||
#include "csi_collector.h"
|
||||
#include "adaptive_controller.h"
|
||||
static const char *TAG = "rv_mesh";
|
||||
#endif
|
||||
|
||||
/* ---- Encoder ---- */
|
||||
|
||||
size_t rv_mesh_encode(uint8_t type,
|
||||
uint8_t sender_role,
|
||||
uint8_t auth_class,
|
||||
uint32_t epoch,
|
||||
const void *payload,
|
||||
uint16_t payload_len,
|
||||
uint8_t *buf,
|
||||
size_t buf_cap)
|
||||
{
|
||||
if (buf == NULL) return 0;
|
||||
if (payload == NULL && payload_len != 0) return 0;
|
||||
if (payload_len > RV_MESH_MAX_PAYLOAD) return 0;
|
||||
|
||||
size_t total = sizeof(rv_mesh_header_t) + (size_t)payload_len + 4u;
|
||||
if (buf_cap < total) return 0;
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
hdr.magic = RV_MESH_MAGIC;
|
||||
hdr.version = (uint8_t)RV_MESH_VERSION;
|
||||
hdr.type = type;
|
||||
hdr.sender_role = sender_role;
|
||||
hdr.auth_class = auth_class;
|
||||
hdr.epoch = epoch;
|
||||
hdr.payload_len = payload_len;
|
||||
hdr.reserved = 0;
|
||||
|
||||
memcpy(buf, &hdr, sizeof(hdr));
|
||||
if (payload_len > 0) {
|
||||
memcpy(buf + sizeof(hdr), payload, payload_len);
|
||||
}
|
||||
|
||||
/* IEEE CRC32 over header + payload. Reuses the CRC32 from
|
||||
* rv_feature_state.c so there is exactly one implementation. */
|
||||
uint32_t crc = rv_feature_state_crc32(buf, sizeof(hdr) + payload_len);
|
||||
memcpy(buf + sizeof(hdr) + payload_len, &crc, 4);
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
esp_err_t rv_mesh_decode(const uint8_t *buf, size_t buf_len,
|
||||
rv_mesh_header_t *out_hdr,
|
||||
const uint8_t **out_payload,
|
||||
uint16_t *out_payload_len)
|
||||
{
|
||||
if (buf == NULL || out_hdr == NULL ||
|
||||
out_payload == NULL || out_payload_len == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (buf_len < sizeof(rv_mesh_header_t) + 4u) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
memcpy(&hdr, buf, sizeof(hdr));
|
||||
|
||||
if (hdr.magic != RV_MESH_MAGIC) {
|
||||
return ESP_ERR_INVALID_VERSION; /* repurpose: wrong magic */
|
||||
}
|
||||
if (hdr.version != RV_MESH_VERSION) {
|
||||
return ESP_ERR_INVALID_VERSION;
|
||||
}
|
||||
if (hdr.payload_len > RV_MESH_MAX_PAYLOAD) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
size_t needed = sizeof(hdr) + (size_t)hdr.payload_len + 4u;
|
||||
if (buf_len < needed) {
|
||||
return ESP_ERR_INVALID_SIZE;
|
||||
}
|
||||
|
||||
uint32_t got_crc;
|
||||
memcpy(&got_crc, buf + sizeof(hdr) + hdr.payload_len, 4);
|
||||
uint32_t want_crc = rv_feature_state_crc32(buf,
|
||||
sizeof(hdr) + hdr.payload_len);
|
||||
if (got_crc != want_crc) {
|
||||
return ESP_ERR_INVALID_CRC;
|
||||
}
|
||||
|
||||
*out_hdr = hdr;
|
||||
*out_payload = (hdr.payload_len > 0) ? buf + sizeof(hdr) : NULL;
|
||||
*out_payload_len = hdr.payload_len;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ---- Typed convenience encoders ---- */
|
||||
|
||||
size_t rv_mesh_encode_health(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_node_status_t *status,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (status == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_HEALTH, sender_role, RV_AUTH_NONE,
|
||||
epoch, status, sizeof(*status), buf, buf_cap);
|
||||
}
|
||||
|
||||
size_t rv_mesh_encode_anomaly_alert(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_anomaly_alert_t *alert,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (alert == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_ANOMALY_ALERT, sender_role, RV_AUTH_NONE,
|
||||
epoch, alert, sizeof(*alert), buf, buf_cap);
|
||||
}
|
||||
|
||||
size_t rv_mesh_encode_feature_delta(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_feature_state_t *fs,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (fs == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_FEATURE_DELTA, sender_role, RV_AUTH_NONE,
|
||||
epoch, fs, sizeof(*fs), buf, buf_cap);
|
||||
}
|
||||
|
||||
size_t rv_mesh_encode_time_sync(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_time_sync_t *ts,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (ts == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_TIME_SYNC, sender_role, RV_AUTH_HMAC_SESSION,
|
||||
epoch, ts, sizeof(*ts), buf, buf_cap);
|
||||
}
|
||||
|
||||
size_t rv_mesh_encode_role_assign(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_role_assign_t *ra,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (ra == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_ROLE_ASSIGN, sender_role, RV_AUTH_HMAC_SESSION,
|
||||
epoch, ra, sizeof(*ra), buf, buf_cap);
|
||||
}
|
||||
|
||||
size_t rv_mesh_encode_channel_plan(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_channel_plan_t *cp,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (cp == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_CHANNEL_PLAN, sender_role, RV_AUTH_ED25519_BATCH,
|
||||
epoch, cp, sizeof(*cp), buf, buf_cap);
|
||||
}
|
||||
|
||||
size_t rv_mesh_encode_calibration_start(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_calibration_start_t *cs,
|
||||
uint8_t *buf, size_t buf_cap)
|
||||
{
|
||||
if (cs == NULL) return 0;
|
||||
return rv_mesh_encode(RV_MSG_CALIBRATION_START, sender_role,
|
||||
RV_AUTH_ED25519_BATCH, epoch, cs, sizeof(*cs),
|
||||
buf, buf_cap);
|
||||
}
|
||||
|
||||
/* ---- Send helpers (firmware-only; hidden from host tests) ---- */
|
||||
|
||||
#ifndef RV_MESH_HOST_TEST
|
||||
|
||||
esp_err_t rv_mesh_send(const uint8_t *frame, size_t len)
|
||||
{
|
||||
if (frame == NULL || len == 0) return ESP_ERR_INVALID_ARG;
|
||||
int sent = stream_sender_send(frame, len);
|
||||
if (sent < 0) {
|
||||
ESP_LOGW(TAG, "rv_mesh_send: stream_sender failed (len=%u)",
|
||||
(unsigned)len);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t rv_mesh_send_health(uint8_t role, uint32_t epoch,
|
||||
const uint8_t node_id[8])
|
||||
{
|
||||
if (node_id == NULL) return ESP_ERR_INVALID_ARG;
|
||||
|
||||
rv_node_status_t st;
|
||||
memset(&st, 0, sizeof(st));
|
||||
memcpy(st.node_id, node_id, 8);
|
||||
st.local_time_us = (uint64_t)esp_timer_get_time();
|
||||
st.role = role;
|
||||
|
||||
const rv_radio_ops_t *ops = rv_radio_ops_get();
|
||||
if (ops != NULL && ops->get_health != NULL) {
|
||||
rv_radio_health_t h;
|
||||
if (ops->get_health(&h) == ESP_OK) {
|
||||
st.current_channel = h.current_channel;
|
||||
st.current_bw = h.current_bw_mhz;
|
||||
st.noise_floor_dbm = h.noise_floor_dbm;
|
||||
st.pkt_yield = h.pkt_yield_per_sec;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
size_t n = rv_mesh_encode_health(role, epoch, &st, buf, sizeof(buf));
|
||||
if (n == 0) return ESP_FAIL;
|
||||
return rv_mesh_send(buf, n);
|
||||
}
|
||||
|
||||
esp_err_t rv_mesh_send_anomaly(uint8_t role, uint32_t epoch,
|
||||
const uint8_t node_id[8],
|
||||
uint8_t reason,
|
||||
uint8_t severity,
|
||||
float anomaly_score,
|
||||
float motion_score)
|
||||
{
|
||||
if (node_id == NULL) return ESP_ERR_INVALID_ARG;
|
||||
rv_anomaly_alert_t a;
|
||||
memset(&a, 0, sizeof(a));
|
||||
memcpy(a.node_id, node_id, 8);
|
||||
a.ts_us = (uint64_t)esp_timer_get_time();
|
||||
a.reason = reason;
|
||||
a.severity = severity;
|
||||
a.anomaly_score = anomaly_score;
|
||||
a.motion_score = motion_score;
|
||||
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
size_t n = rv_mesh_encode_anomaly_alert(role, epoch, &a, buf, sizeof(buf));
|
||||
if (n == 0) return ESP_FAIL;
|
||||
return rv_mesh_send(buf, n);
|
||||
}
|
||||
|
||||
#endif /* !RV_MESH_HOST_TEST */
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
/**
|
||||
* @file rv_mesh.h
|
||||
* @brief ADR-081 Layer 3 — Mesh Sensing Plane.
|
||||
*
|
||||
* Defines node roles, the 7 on-wire message types, and the
|
||||
* rv_node_status_t health payload that nodes exchange to behave as a
|
||||
* distributed sensor rather than a collection of independent radios.
|
||||
*
|
||||
* Framing: every mesh message starts with rv_mesh_header_t (magic,
|
||||
* version, type, sender_role, epoch, length) so a receiver can dispatch
|
||||
* without reading the whole body. The trailing 4 bytes of every message
|
||||
* are an IEEE CRC32 over the preceding bytes. Authentication
|
||||
* (HMAC-SHA256 + replay window) is layered on top by
|
||||
* wifi-densepose-hardware/src/esp32/secure_tdm.rs (ADR-032) for control
|
||||
* messages that cross the swarm; FEATURE_DELTA uses the integrity
|
||||
* protection already present in rv_feature_state_t (CRC + monotonic seq).
|
||||
*/
|
||||
|
||||
#ifndef RV_MESH_H
|
||||
#define RV_MESH_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include "esp_err.h"
|
||||
#include "rv_feature_state.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ---- Magic + version ---- */
|
||||
|
||||
/** ADR-081 mesh envelope magic. Distinct from the ADR-018 CSI magic. */
|
||||
#define RV_MESH_MAGIC 0xC5118100u
|
||||
|
||||
/** Protocol version. Bumped on any wire-format change. */
|
||||
#define RV_MESH_VERSION 1u
|
||||
|
||||
/** Maximum mesh payload size (excluding header + CRC). */
|
||||
#define RV_MESH_MAX_PAYLOAD 256u
|
||||
|
||||
/* ---- Node roles (ADR-081 Layer 3) ---- */
|
||||
|
||||
typedef enum {
|
||||
RV_ROLE_UNASSIGNED = 0,
|
||||
RV_ROLE_ANCHOR = 1, /**< Emits timed probes + global time beacons. */
|
||||
RV_ROLE_OBSERVER = 2, /**< Captures CSI + local metadata. */
|
||||
RV_ROLE_FUSION_RELAY = 3, /**< Aggregates summaries, forwards deltas. */
|
||||
RV_ROLE_COORDINATOR = 4, /**< Elects channels, assigns roles. */
|
||||
RV_ROLE_COUNT
|
||||
} rv_mesh_role_t;
|
||||
|
||||
/* ---- Authorization classes for control messages ---- */
|
||||
|
||||
typedef enum {
|
||||
RV_AUTH_NONE = 0, /**< Telemetry; integrity via CRC only. */
|
||||
RV_AUTH_HMAC_SESSION = 1, /**< HMAC-SHA256 with session key (ADR-032). */
|
||||
RV_AUTH_ED25519_BATCH = 2, /**< Ed25519 signature at batch/session. */
|
||||
} rv_mesh_auth_class_t;
|
||||
|
||||
/* ---- Message types ---- */
|
||||
|
||||
typedef enum {
|
||||
RV_MSG_TIME_SYNC = 0x01,
|
||||
RV_MSG_ROLE_ASSIGN = 0x02,
|
||||
RV_MSG_CHANNEL_PLAN = 0x03,
|
||||
RV_MSG_CALIBRATION_START = 0x04,
|
||||
RV_MSG_FEATURE_DELTA = 0x05, /**< Carries rv_feature_state_t. */
|
||||
RV_MSG_HEALTH = 0x06,
|
||||
RV_MSG_ANOMALY_ALERT = 0x07,
|
||||
} rv_mesh_msg_type_t;
|
||||
|
||||
/* ---- Common envelope header (16 bytes) ---- */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic; /**< RV_MESH_MAGIC. */
|
||||
uint8_t version; /**< RV_MESH_VERSION. */
|
||||
uint8_t type; /**< rv_mesh_msg_type_t. */
|
||||
uint8_t sender_role; /**< rv_mesh_role_t of the sender at send time. */
|
||||
uint8_t auth_class; /**< rv_mesh_auth_class_t. */
|
||||
uint32_t epoch; /**< Monotonic epoch or session counter. */
|
||||
uint16_t payload_len; /**< Body length excluding header + trailing CRC. */
|
||||
uint16_t reserved;
|
||||
} rv_mesh_header_t;
|
||||
|
||||
_Static_assert(sizeof(rv_mesh_header_t) == 16,
|
||||
"rv_mesh_header_t must be 16 bytes");
|
||||
|
||||
/* ---- Node health payload (RV_MSG_HEALTH) ---- */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t node_id[8]; /**< 8-byte node identity. */
|
||||
uint64_t local_time_us; /**< Sender-local microseconds. */
|
||||
uint8_t role; /**< rv_mesh_role_t. */
|
||||
uint8_t current_channel;
|
||||
uint8_t current_bw; /**< MHz (20, 40). */
|
||||
int8_t noise_floor_dbm;
|
||||
uint16_t pkt_yield; /**< CSI callbacks/sec over the last window. */
|
||||
uint16_t sync_error_us; /**< Absolute drift vs. anchor. */
|
||||
uint16_t health_flags;
|
||||
uint16_t reserved;
|
||||
} rv_node_status_t;
|
||||
|
||||
_Static_assert(sizeof(rv_node_status_t) == 28,
|
||||
"rv_node_status_t must be 28 bytes");
|
||||
|
||||
/* ---- TIME_SYNC payload ---- */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint64_t anchor_time_us; /**< Anchor's local µs at emit. */
|
||||
uint32_t cycle_id;
|
||||
uint32_t cycle_period_us;
|
||||
} rv_time_sync_t;
|
||||
|
||||
_Static_assert(sizeof(rv_time_sync_t) == 16,
|
||||
"rv_time_sync_t must be 16 bytes");
|
||||
|
||||
/* ---- ROLE_ASSIGN payload ---- */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t target_node_id[8];
|
||||
uint8_t new_role; /**< rv_mesh_role_t. */
|
||||
uint8_t reserved[3];
|
||||
uint32_t effective_epoch;
|
||||
} rv_role_assign_t;
|
||||
|
||||
_Static_assert(sizeof(rv_role_assign_t) == 16,
|
||||
"rv_role_assign_t must be 16 bytes");
|
||||
|
||||
/* ---- CHANNEL_PLAN payload ---- */
|
||||
|
||||
#define RV_CHANNEL_PLAN_MAX 8
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t target_node_id[8];
|
||||
uint8_t channel_count;
|
||||
uint8_t dwell_ms_hi; /**< dwell_ms, big-endian to fit u16 in two bytes */
|
||||
uint8_t dwell_ms_lo;
|
||||
uint8_t debug_raw_csi; /**< 1 = enable raw ADR-018 stream; 0 = feature_state only. */
|
||||
uint8_t channels[RV_CHANNEL_PLAN_MAX];
|
||||
uint32_t effective_epoch;
|
||||
} rv_channel_plan_t;
|
||||
|
||||
_Static_assert(sizeof(rv_channel_plan_t) == 24,
|
||||
"rv_channel_plan_t must be 24 bytes");
|
||||
|
||||
/* ---- CALIBRATION_START payload ---- */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint64_t t0_anchor_us; /**< Start time on anchor clock. */
|
||||
uint32_t duration_ms;
|
||||
uint32_t effective_epoch;
|
||||
uint8_t calibration_profile; /**< rv_capture_profile_t (usually CALIBRATION). */
|
||||
uint8_t reserved[3];
|
||||
} rv_calibration_start_t;
|
||||
|
||||
_Static_assert(sizeof(rv_calibration_start_t) == 20,
|
||||
"rv_calibration_start_t must be 20 bytes");
|
||||
|
||||
/* ---- ANOMALY_ALERT payload ---- */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t node_id[8];
|
||||
uint64_t ts_us;
|
||||
uint8_t severity; /**< 0..255 scaled anomaly. */
|
||||
uint8_t reason; /**< rv_anomaly_reason_t. */
|
||||
uint16_t reserved;
|
||||
float anomaly_score;
|
||||
float motion_score;
|
||||
} rv_anomaly_alert_t;
|
||||
|
||||
_Static_assert(sizeof(rv_anomaly_alert_t) == 28,
|
||||
"rv_anomaly_alert_t must be 28 bytes");
|
||||
|
||||
typedef enum {
|
||||
RV_ANOMALY_NONE = 0,
|
||||
RV_ANOMALY_PHYSICS_VIOLATION = 1,
|
||||
RV_ANOMALY_MULTI_LINK_MISMATCH = 2,
|
||||
RV_ANOMALY_PKT_YIELD_COLLAPSE = 3,
|
||||
RV_ANOMALY_FALL = 4,
|
||||
RV_ANOMALY_COHERENCE_LOSS = 5,
|
||||
} rv_anomaly_reason_t;
|
||||
|
||||
/* ---- Encoder / decoder API ---- */
|
||||
|
||||
/** Maximum on-wire mesh frame: header + max payload + crc. */
|
||||
#define RV_MESH_MAX_FRAME_BYTES (sizeof(rv_mesh_header_t) + RV_MESH_MAX_PAYLOAD + 4u)
|
||||
|
||||
/**
|
||||
* Encode a typed mesh message into a contiguous buffer.
|
||||
*
|
||||
* Writes header(16) + payload(payload_len) + crc32(4). The caller owns
|
||||
* the buffer; buf_cap must be at least sizeof(rv_mesh_header_t) +
|
||||
* payload_len + 4. The payload pointer may be NULL iff payload_len == 0.
|
||||
*
|
||||
* @return bytes written on success, or 0 on error (bad args / overflow).
|
||||
*/
|
||||
size_t rv_mesh_encode(uint8_t type,
|
||||
uint8_t sender_role,
|
||||
uint8_t auth_class,
|
||||
uint32_t epoch,
|
||||
const void *payload,
|
||||
uint16_t payload_len,
|
||||
uint8_t *buf,
|
||||
size_t buf_cap);
|
||||
|
||||
/**
|
||||
* Validate + parse a mesh frame received from the wire.
|
||||
*
|
||||
* Checks magic, version, sizeof(rv_mesh_header_t) bounds, payload_len
|
||||
* bounds, and CRC32. On success, fills *out_hdr with the header and sets
|
||||
* *out_payload to point at the payload inside buf (aliasing, not copied)
|
||||
* plus *out_payload_len to the payload byte count.
|
||||
*
|
||||
* @return ESP_OK on success, or an ESP_ERR_* code on failure.
|
||||
*/
|
||||
esp_err_t rv_mesh_decode(const uint8_t *buf, size_t buf_len,
|
||||
rv_mesh_header_t *out_hdr,
|
||||
const uint8_t **out_payload,
|
||||
uint16_t *out_payload_len);
|
||||
|
||||
/**
|
||||
* Convenience helpers — encode a specific message type into buf.
|
||||
* Each returns the number of bytes written, 0 on error.
|
||||
*/
|
||||
size_t rv_mesh_encode_health(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_node_status_t *status,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
size_t rv_mesh_encode_anomaly_alert(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_anomaly_alert_t *alert,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
size_t rv_mesh_encode_feature_delta(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_feature_state_t *fs,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
size_t rv_mesh_encode_time_sync(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_time_sync_t *ts,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
size_t rv_mesh_encode_role_assign(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_role_assign_t *ra,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
size_t rv_mesh_encode_channel_plan(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_channel_plan_t *cp,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
size_t rv_mesh_encode_calibration_start(uint8_t sender_role,
|
||||
uint32_t epoch,
|
||||
const rv_calibration_start_t *cs,
|
||||
uint8_t *buf, size_t buf_cap);
|
||||
|
||||
/* ---- Send API ---- */
|
||||
|
||||
/**
|
||||
* Send a pre-encoded mesh frame over the primary upstream UDP socket
|
||||
* (the same one stream_sender uses for ADR-018 and rv_feature_state_t).
|
||||
*
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t rv_mesh_send(const uint8_t *frame, size_t len);
|
||||
|
||||
/**
|
||||
* Convenience: build + send a HEALTH message for this node.
|
||||
*
|
||||
* Fills the rv_node_status_t from the live radio ops + controller
|
||||
* observation, then encodes and sends in one call. Safe to call from a
|
||||
* FreeRTOS timer.
|
||||
*/
|
||||
esp_err_t rv_mesh_send_health(uint8_t role, uint32_t epoch,
|
||||
const uint8_t node_id[8]);
|
||||
|
||||
/**
|
||||
* Convenience: build + send an ANOMALY_ALERT.
|
||||
*/
|
||||
esp_err_t rv_mesh_send_anomaly(uint8_t role, uint32_t epoch,
|
||||
const uint8_t node_id[8],
|
||||
uint8_t reason,
|
||||
uint8_t severity,
|
||||
float anomaly_score,
|
||||
float motion_score);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* RV_MESH_H */
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* @file rv_radio_ops.h
|
||||
* @brief ADR-081 Layer 1 — Radio Abstraction Layer.
|
||||
*
|
||||
* A single function-pointer vtable (rv_radio_ops_t) that isolates chipset
|
||||
* specific capture details from the layers above (adaptive controller, mesh
|
||||
* plane, feature extraction, Rust handoff).
|
||||
*
|
||||
* Two bindings ship today:
|
||||
* - rv_radio_ops_esp32.c — wraps csi_collector + esp_wifi_*
|
||||
* - rv_radio_ops_mock.c — wraps mock_csi.c (when CONFIG_CSI_MOCK_ENABLED)
|
||||
*
|
||||
* A third binding (Nexmon-patched Broadcom/Cypress) is reserved but not
|
||||
* implemented here. The whole point of the vtable is that the controller
|
||||
* and mesh-plane code above never need to know which one is active.
|
||||
*/
|
||||
|
||||
#ifndef RV_RADIO_OPS_H
|
||||
#define RV_RADIO_OPS_H
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* ---- Modes ---- */
|
||||
|
||||
/** Radio operating modes (set_mode argument). */
|
||||
typedef enum {
|
||||
RV_RADIO_MODE_DISABLED = 0, /**< Receiver off. */
|
||||
RV_RADIO_MODE_PASSIVE_RX = 1, /**< Listen-only, no TX. */
|
||||
RV_RADIO_MODE_ACTIVE_PROBE = 2, /**< Inject NDP frames at high rate. */
|
||||
RV_RADIO_MODE_CALIBRATION = 3, /**< Synchronized calibration burst. */
|
||||
} rv_radio_mode_t;
|
||||
|
||||
/* ---- Capture profiles ---- */
|
||||
|
||||
/**
|
||||
* Named capture profiles. The adaptive controller selects one of these
|
||||
* via set_capture_profile(); the binding maps it to chipset-specific
|
||||
* register/driver state.
|
||||
*/
|
||||
typedef enum {
|
||||
RV_PROFILE_PASSIVE_LOW_RATE = 0, /**< Default idle: minimum cadence. */
|
||||
RV_PROFILE_ACTIVE_PROBE = 1, /**< High-rate NDP injection. */
|
||||
RV_PROFILE_RESP_HIGH_SENS = 2, /**< Quietest channel, vitals-only. */
|
||||
RV_PROFILE_FAST_MOTION = 3, /**< Short window, high cadence. */
|
||||
RV_PROFILE_CALIBRATION = 4, /**< Synchronized burst across nodes. */
|
||||
RV_PROFILE_COUNT
|
||||
} rv_capture_profile_t;
|
||||
|
||||
/* ---- Health snapshot ---- */
|
||||
|
||||
/** Radio-layer health, polled by the adaptive controller. */
|
||||
typedef struct {
|
||||
uint16_t pkt_yield_per_sec; /**< CSI callbacks/second observed. */
|
||||
uint16_t send_fail_count; /**< UDP/socket send failures since last poll. */
|
||||
int8_t rssi_median_dbm; /**< Median RSSI over the last 1 s. */
|
||||
int8_t noise_floor_dbm; /**< Latest noise floor estimate. */
|
||||
uint8_t current_channel; /**< Channel currently configured. */
|
||||
uint8_t current_bw_mhz; /**< Bandwidth currently configured. */
|
||||
uint8_t current_profile; /**< Active rv_capture_profile_t. */
|
||||
uint8_t reserved;
|
||||
} rv_radio_health_t;
|
||||
|
||||
/* ---- The vtable ---- */
|
||||
|
||||
/**
|
||||
* Radio Abstraction Layer ops.
|
||||
*
|
||||
* All function pointers are required (no NULL slots). Each binding must
|
||||
* provide all six. Return values follow ESP-IDF conventions: 0/ESP_OK on
|
||||
* success, negative or ESP_ERR_* on failure.
|
||||
*/
|
||||
typedef struct {
|
||||
/** One-time init (driver register, callback wire-up). */
|
||||
int (*init)(void);
|
||||
|
||||
/**
|
||||
* Tune to a primary channel with the given bandwidth.
|
||||
* @param ch Channel number (1-13 for 2.4 GHz, 36-177 for 5 GHz).
|
||||
* @param bw Bandwidth in MHz (20 or 40; 80/160 reserved for future).
|
||||
*/
|
||||
int (*set_channel)(uint8_t ch, uint8_t bw);
|
||||
|
||||
/** Switch operating mode (rv_radio_mode_t). */
|
||||
int (*set_mode)(uint8_t mode);
|
||||
|
||||
/** Enable or disable the CSI capture path. */
|
||||
int (*set_csi_enabled)(bool en);
|
||||
|
||||
/** Apply a named capture profile (rv_capture_profile_t). */
|
||||
int (*set_capture_profile)(uint8_t profile_id);
|
||||
|
||||
/** Snapshot the radio-layer health (non-blocking). */
|
||||
int (*get_health)(rv_radio_health_t *out);
|
||||
} rv_radio_ops_t;
|
||||
|
||||
/* ---- Registration ---- */
|
||||
|
||||
/**
|
||||
* Register the active radio ops binding.
|
||||
*
|
||||
* Called once at boot by the chipset binding's init code (e.g.
|
||||
* rv_radio_ops_esp32_register()). The pointer must remain valid for the
|
||||
* lifetime of the process — typically a static const inside the binding.
|
||||
*/
|
||||
void rv_radio_ops_register(const rv_radio_ops_t *ops);
|
||||
|
||||
/**
|
||||
* Get the active radio ops binding.
|
||||
*
|
||||
* @return Pointer to the registered ops table, or NULL if no binding has
|
||||
* been registered yet (e.g. before init).
|
||||
*/
|
||||
const rv_radio_ops_t *rv_radio_ops_get(void);
|
||||
|
||||
/* ---- Convenience: ESP32 binding registration ---- */
|
||||
|
||||
/**
|
||||
* Register the ESP32 binding as the active radio ops.
|
||||
*
|
||||
* Call this once at boot, after csi_collector_init() has run. Idempotent.
|
||||
* Defined in rv_radio_ops_esp32.c.
|
||||
*/
|
||||
void rv_radio_ops_esp32_register(void);
|
||||
|
||||
/**
|
||||
* Register the mock binding (QEMU / offline) as the active radio ops.
|
||||
*
|
||||
* Defined in rv_radio_ops_mock.c; only built when CONFIG_CSI_MOCK_ENABLED.
|
||||
*/
|
||||
void rv_radio_ops_mock_register(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* RV_RADIO_OPS_H */
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* @file rv_radio_ops_esp32.c
|
||||
* @brief ADR-081 Layer 1 — ESP32 binding for rv_radio_ops_t.
|
||||
*
|
||||
* Wraps the existing csi_collector + esp_wifi_* surface so the adaptive
|
||||
* controller, mesh plane, and feature-extraction layers can address the
|
||||
* radio through a single chipset-agnostic vtable.
|
||||
*
|
||||
* This is intentionally thin. The heavy lifting still lives in
|
||||
* csi_collector.c (CSI callback, channel hopping, NDP injection); this file
|
||||
* is the contract that lets a second chipset (Nexmon Broadcom, custom
|
||||
* silicon) drop in without touching the layers above.
|
||||
*/
|
||||
|
||||
#include "rv_radio_ops.h"
|
||||
#include "csi_collector.h"
|
||||
|
||||
#include <string.h>
|
||||
#include "esp_err.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_wifi.h"
|
||||
|
||||
static const char *TAG = "rv_radio_esp32";
|
||||
|
||||
/* ---- Active ops registry ---- */
|
||||
|
||||
static const rv_radio_ops_t *s_active_ops = NULL;
|
||||
|
||||
void rv_radio_ops_register(const rv_radio_ops_t *ops)
|
||||
{
|
||||
s_active_ops = ops;
|
||||
}
|
||||
|
||||
const rv_radio_ops_t *rv_radio_ops_get(void)
|
||||
{
|
||||
return s_active_ops;
|
||||
}
|
||||
|
||||
/* ---- ESP32 binding state ---- */
|
||||
|
||||
static uint8_t s_current_channel = 1;
|
||||
static uint8_t s_current_bw = 20;
|
||||
static uint8_t s_current_profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
static uint8_t s_current_mode = RV_RADIO_MODE_PASSIVE_RX;
|
||||
static bool s_csi_enabled = true;
|
||||
|
||||
/* ---- Vtable implementations ---- */
|
||||
|
||||
static int esp32_init(void)
|
||||
{
|
||||
/* csi_collector_init() is called from app_main() before the controller
|
||||
* starts; nothing to do here for the ESP32 binding. We just confirm a
|
||||
* valid current channel was captured by csi_collector_init(). */
|
||||
ESP_LOGI(TAG, "ESP32 radio ops: init (current ch=%u bw=%u)",
|
||||
(unsigned)s_current_channel, (unsigned)s_current_bw);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int esp32_set_channel(uint8_t ch, uint8_t bw)
|
||||
{
|
||||
wifi_second_chan_t second = WIFI_SECOND_CHAN_NONE;
|
||||
if (bw == 40) {
|
||||
/* HT40+: secondary channel above primary. The controller never asks
|
||||
* for HT40 today (sensing prefers HT20), but the mapping is here so
|
||||
* a future profile can. */
|
||||
second = WIFI_SECOND_CHAN_ABOVE;
|
||||
} else if (bw != 20) {
|
||||
ESP_LOGW(TAG, "set_channel: unsupported bw=%u, treating as 20 MHz",
|
||||
(unsigned)bw);
|
||||
bw = 20;
|
||||
}
|
||||
|
||||
esp_err_t err = esp_wifi_set_channel(ch, second);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "set_channel(%u, bw=%u) failed: %s",
|
||||
(unsigned)ch, (unsigned)bw, esp_err_to_name(err));
|
||||
return (int)err;
|
||||
}
|
||||
s_current_channel = ch;
|
||||
s_current_bw = bw;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int esp32_set_mode(uint8_t mode)
|
||||
{
|
||||
/* Persist the mode for the health snapshot; actual TX behavior is
|
||||
* triggered by the controller calling csi_inject_ndp_frame() directly
|
||||
* once the controller PR lands. For now this is bookkeeping plus a
|
||||
* passive/active probe gate. */
|
||||
switch (mode) {
|
||||
case RV_RADIO_MODE_DISABLED:
|
||||
case RV_RADIO_MODE_PASSIVE_RX:
|
||||
case RV_RADIO_MODE_ACTIVE_PROBE:
|
||||
case RV_RADIO_MODE_CALIBRATION:
|
||||
s_current_mode = mode;
|
||||
return ESP_OK;
|
||||
default:
|
||||
ESP_LOGW(TAG, "set_mode: unknown mode %u", (unsigned)mode);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
}
|
||||
|
||||
static int esp32_set_csi_enabled(bool en)
|
||||
{
|
||||
esp_err_t err = esp_wifi_set_csi(en);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "set_csi(%d) failed: %s", (int)en, esp_err_to_name(err));
|
||||
return (int)err;
|
||||
}
|
||||
s_csi_enabled = en;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int esp32_set_capture_profile(uint8_t profile_id)
|
||||
{
|
||||
if (profile_id >= RV_PROFILE_COUNT) {
|
||||
ESP_LOGW(TAG, "set_capture_profile: invalid id %u", (unsigned)profile_id);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
/* Profiles are advisory at this layer — the controller uses them to
|
||||
* decide cadence/window/threshold for the layers above. The radio
|
||||
* binding records the active profile for health reporting and may
|
||||
* adjust the underlying TX/RX mode in future bindings. */
|
||||
s_current_profile = profile_id;
|
||||
|
||||
/* For ACTIVE_PROBE and CALIBRATION, switch the radio mode to match. */
|
||||
if (profile_id == RV_PROFILE_ACTIVE_PROBE) {
|
||||
esp32_set_mode(RV_RADIO_MODE_ACTIVE_PROBE);
|
||||
} else if (profile_id == RV_PROFILE_CALIBRATION) {
|
||||
esp32_set_mode(RV_RADIO_MODE_CALIBRATION);
|
||||
} else {
|
||||
esp32_set_mode(RV_RADIO_MODE_PASSIVE_RX);
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int esp32_get_health(rv_radio_health_t *out)
|
||||
{
|
||||
if (out == NULL) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
out->pkt_yield_per_sec = csi_collector_get_pkt_yield_per_sec();
|
||||
out->send_fail_count = csi_collector_get_send_fail_count();
|
||||
out->current_channel = s_current_channel;
|
||||
out->current_bw_mhz = s_current_bw;
|
||||
out->current_profile = s_current_profile;
|
||||
|
||||
wifi_ap_record_t ap = {0};
|
||||
if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
|
||||
out->rssi_median_dbm = ap.rssi;
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* ---- The vtable instance ---- */
|
||||
|
||||
static const rv_radio_ops_t s_esp32_ops = {
|
||||
.init = esp32_init,
|
||||
.set_channel = esp32_set_channel,
|
||||
.set_mode = esp32_set_mode,
|
||||
.set_csi_enabled = esp32_set_csi_enabled,
|
||||
.set_capture_profile = esp32_set_capture_profile,
|
||||
.get_health = esp32_get_health,
|
||||
};
|
||||
|
||||
void rv_radio_ops_esp32_register(void)
|
||||
{
|
||||
if (s_active_ops == &s_esp32_ops) {
|
||||
return; /* idempotent */
|
||||
}
|
||||
rv_radio_ops_register(&s_esp32_ops);
|
||||
ESP_LOGI(TAG, "ESP32 radio ops registered as active binding");
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* @file rv_radio_ops_mock.c
|
||||
* @brief ADR-081 Layer 1 — Mock binding for QEMU / offline testing.
|
||||
*
|
||||
* When CONFIG_CSI_MOCK_ENABLED is set (ADR-061 QEMU flow), there is no
|
||||
* real WiFi driver to wrap. This binding provides the same ops table as
|
||||
* the ESP32 binding but records state into in-process statics and
|
||||
* accepts every call. It exists primarily to satisfy ADR-081's
|
||||
* portability acceptance test: a second binding must compile against
|
||||
* the same controller and mesh-plane code without modification.
|
||||
*
|
||||
* Only compiled when CONFIG_CSI_MOCK_ENABLED is set. Registered from
|
||||
* main.c in the mock branch.
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
|
||||
#include "rv_radio_ops.h"
|
||||
#include "mock_csi.h"
|
||||
|
||||
#include <string.h>
|
||||
#include "esp_err.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
static const char *TAG = "rv_radio_mock";
|
||||
|
||||
static uint8_t s_channel = 6;
|
||||
static uint8_t s_bw = 20;
|
||||
static uint8_t s_profile = RV_PROFILE_PASSIVE_LOW_RATE;
|
||||
static uint8_t s_mode = RV_RADIO_MODE_PASSIVE_RX;
|
||||
static bool s_csi_on = true;
|
||||
|
||||
static int mock_init(void)
|
||||
{
|
||||
ESP_LOGI(TAG, "mock radio ops: init");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int mock_set_channel(uint8_t ch, uint8_t bw)
|
||||
{
|
||||
s_channel = ch;
|
||||
s_bw = (bw == 40) ? 40 : 20;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int mock_set_mode(uint8_t mode)
|
||||
{
|
||||
s_mode = mode;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int mock_set_csi_enabled(bool en)
|
||||
{
|
||||
s_csi_on = en;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int mock_set_capture_profile(uint8_t profile_id)
|
||||
{
|
||||
if (profile_id >= RV_PROFILE_COUNT) return ESP_ERR_INVALID_ARG;
|
||||
s_profile = profile_id;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static int mock_get_health(rv_radio_health_t *out)
|
||||
{
|
||||
if (out == NULL) return ESP_ERR_INVALID_ARG;
|
||||
memset(out, 0, sizeof(*out));
|
||||
|
||||
/* Mock yield: mirror mock_csi's generator rate so the adaptive
|
||||
* controller sees a sensible pkt_yield in QEMU. */
|
||||
out->pkt_yield_per_sec = 20; /* MOCK_CSI_INTERVAL_MS = 50 → 20 Hz */
|
||||
out->rssi_median_dbm = -55;
|
||||
out->noise_floor_dbm = -95;
|
||||
out->current_channel = s_channel;
|
||||
out->current_bw_mhz = s_bw;
|
||||
out->current_profile = s_profile;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static const rv_radio_ops_t s_mock_ops = {
|
||||
.init = mock_init,
|
||||
.set_channel = mock_set_channel,
|
||||
.set_mode = mock_set_mode,
|
||||
.set_csi_enabled = mock_set_csi_enabled,
|
||||
.set_capture_profile = mock_set_capture_profile,
|
||||
.get_health = mock_get_health,
|
||||
};
|
||||
|
||||
void rv_radio_ops_mock_register(void)
|
||||
{
|
||||
rv_radio_ops_register(&s_mock_ops);
|
||||
ESP_LOGI(TAG, "mock radio ops registered (QEMU / offline mode)");
|
||||
}
|
||||
|
||||
#endif /* CONFIG_CSI_MOCK_ENABLED */
|
||||
|
|
@ -31,3 +31,7 @@ CONFIG_LWIP_SO_RCVBUF=y
|
|||
|
||||
# FreeRTOS: increase task stack for CSI processing
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
|
||||
# ADR-081: adaptive_controller runs emit_feature_state + stream_sender
|
||||
# network I/O inside Timer Svc callbacks, exceeding the 2 KiB default.
|
||||
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192
|
||||
|
|
|
|||
|
|
@ -27,3 +27,7 @@ CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
|||
|
||||
CONFIG_LWIP_SO_RCVBUF=y
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
|
||||
# ADR-081: adaptive_controller runs emit_feature_state + stream_sender
|
||||
# network I/O inside Timer Svc callbacks, exceeding the 2 KiB default.
|
||||
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192
|
||||
|
|
|
|||
|
|
@ -31,3 +31,7 @@ CONFIG_LWIP_SO_RCVBUF=y
|
|||
|
||||
# FreeRTOS: increase task stack for CSI processing
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
|
||||
# ADR-081: adaptive_controller runs emit_feature_state + stream_sender
|
||||
# network I/O inside Timer Svc callbacks, exceeding the 2 KiB default.
|
||||
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
# Compiled host-test binaries
|
||||
test_adaptive_controller
|
||||
test_rv_feature_state
|
||||
test_rv_mesh
|
||||
*.o
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# Host-side unit tests for ADR-081 pure-C logic.
|
||||
#
|
||||
# These tests exercise adaptive_controller_decide() and the rv_feature_state
|
||||
# helpers (CRC32, finalize) using plain gcc/clang, with a minimal esp_err.h
|
||||
# shim. No ESP-IDF, no FreeRTOS, no QEMU required.
|
||||
#
|
||||
# Usage:
|
||||
# cd firmware/esp32-csi-node/tests/host
|
||||
# make
|
||||
# ./test_adaptive_controller
|
||||
# ./test_rv_feature_state
|
||||
|
||||
MAIN_DIR := ../../main
|
||||
CC ?= cc
|
||||
CFLAGS ?= -O2 -std=c11 -Wall -Wextra -Wno-unused-parameter \
|
||||
-D_POSIX_C_SOURCE=199309L \
|
||||
-I. -I$(MAIN_DIR)
|
||||
LDLIBS ?= -lrt
|
||||
|
||||
# Pure-C sources under test. We compile only the files that have no
|
||||
# ESP-IDF dependency in their bodies: rv_feature_state.c is 100% pure.
|
||||
# adaptive_controller.c uses FreeRTOS for the timer plumbing, so for the
|
||||
# host test we compile only the decide() portion by isolating it in a
|
||||
# small unity file (TEST_ADAPT_PURE below).
|
||||
FEATURE_STATE_SRCS := $(MAIN_DIR)/rv_feature_state.c
|
||||
|
||||
# adaptive_controller.c pulls in FreeRTOS headers that don't exist on
|
||||
# host; we include its decide() function by defining TEST_ADAPT_PURE
|
||||
# before including the .c. The decide() body itself has no ESP-IDF deps.
|
||||
# Simpler: just recompile decide() here via a small shim.
|
||||
|
||||
TESTS := test_adaptive_controller test_rv_feature_state test_rv_mesh
|
||||
|
||||
all: $(TESTS)
|
||||
|
||||
test_adaptive_controller: test_adaptive_controller.c $(MAIN_DIR)/adaptive_controller_decide.c $(MAIN_DIR)/adaptive_controller.h $(MAIN_DIR)/rv_radio_ops.h
|
||||
$(CC) $(CFLAGS) test_adaptive_controller.c $(MAIN_DIR)/adaptive_controller_decide.c -o $@ $(LDLIBS)
|
||||
|
||||
test_rv_feature_state: test_rv_feature_state.c $(FEATURE_STATE_SRCS) $(MAIN_DIR)/rv_feature_state.h $(MAIN_DIR)/rv_radio_ops.h
|
||||
$(CC) $(CFLAGS) test_rv_feature_state.c $(FEATURE_STATE_SRCS) -o $@ $(LDLIBS)
|
||||
|
||||
# Mesh plane encoder/decoder: compile rv_mesh.c with RV_MESH_HOST_TEST
|
||||
# so the firmware-only send helpers (stream_sender, esp_log) are hidden.
|
||||
test_rv_mesh: test_rv_mesh.c $(MAIN_DIR)/rv_mesh.c $(MAIN_DIR)/rv_mesh.h $(FEATURE_STATE_SRCS) $(MAIN_DIR)/rv_radio_ops.h
|
||||
$(CC) $(CFLAGS) -DRV_MESH_HOST_TEST=1 \
|
||||
test_rv_mesh.c $(MAIN_DIR)/rv_mesh.c $(FEATURE_STATE_SRCS) \
|
||||
-o $@ $(LDLIBS)
|
||||
|
||||
check: all
|
||||
./test_adaptive_controller
|
||||
@echo ""
|
||||
./test_rv_feature_state
|
||||
@echo ""
|
||||
./test_rv_mesh
|
||||
|
||||
clean:
|
||||
rm -f $(TESTS) *.o
|
||||
|
||||
.PHONY: all check clean
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/* Host test shim for esp_err.h. Allows us to compile the pure-C
|
||||
* portions of the firmware (adaptive_controller_decide, rv_feature_state
|
||||
* CRC + finalize) under plain gcc/clang without the ESP-IDF toolchain. */
|
||||
#ifndef HOST_ESP_ERR_SHIM_H
|
||||
#define HOST_ESP_ERR_SHIM_H
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
typedef int esp_err_t;
|
||||
|
||||
#define ESP_OK 0
|
||||
#define ESP_FAIL -1
|
||||
#define ESP_ERR_NO_MEM 0x101
|
||||
#define ESP_ERR_INVALID_ARG 0x102
|
||||
#define ESP_ERR_INVALID_SIZE 0x104
|
||||
#define ESP_ERR_INVALID_VERSION 0x10A
|
||||
#define ESP_ERR_INVALID_CRC 0x10B
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
/*
|
||||
* Host unit test for adaptive_controller_decide().
|
||||
*
|
||||
* The ADR-081 controller decision function is deliberately pure: it takes
|
||||
* (cfg, current_state, observation) and produces a decision. No FreeRTOS,
|
||||
* no ESP-IDF, no side effects. This test exercises every documented branch
|
||||
* of the policy.
|
||||
*
|
||||
* Build + run (from this directory):
|
||||
* make -f Makefile
|
||||
* ./test_adaptive_controller
|
||||
*/
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "adaptive_controller.h"
|
||||
#include "rv_radio_ops.h"
|
||||
|
||||
static int g_pass = 0, g_fail = 0;
|
||||
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (cond) { g_pass++; } \
|
||||
else { g_fail++; printf(" FAIL: %s (line %d)\n", msg, __LINE__); } \
|
||||
} while (0)
|
||||
|
||||
static adapt_config_t default_cfg(void) {
|
||||
adapt_config_t c = {
|
||||
.fast_loop_ms = 200,
|
||||
.medium_loop_ms = 1000,
|
||||
.slow_loop_ms = 30000,
|
||||
.aggressive = false,
|
||||
.enable_channel_switch = false,
|
||||
.enable_role_change = false,
|
||||
.motion_threshold = 0.20f,
|
||||
.anomaly_threshold = 0.60f,
|
||||
.min_pkt_yield = 5,
|
||||
};
|
||||
return c;
|
||||
}
|
||||
|
||||
static adapt_observation_t quiet_obs(void) {
|
||||
adapt_observation_t o = {
|
||||
.pkt_yield_per_sec = 50,
|
||||
.send_fail_count = 0,
|
||||
.rssi_median_dbm = -60,
|
||||
.noise_floor_dbm = -95,
|
||||
.motion_score = 0.01f,
|
||||
.presence_score = 0.0f,
|
||||
.anomaly_score = 0.0f,
|
||||
.node_coherence = 1.0f,
|
||||
};
|
||||
return o;
|
||||
}
|
||||
|
||||
static void test_degraded_gate_on_pkt_yield_collapse(void) {
|
||||
printf("test: degraded gate on pkt yield collapse\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.pkt_yield_per_sec = 2; /* below min_pkt_yield=5 */
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
|
||||
CHECK(dec.change_state, "should change state");
|
||||
CHECK(dec.new_state == ADAPT_STATE_DEGRADED, "new state == DEGRADED");
|
||||
CHECK(dec.new_profile == RV_PROFILE_PASSIVE_LOW_RATE,
|
||||
"profile pinned to PASSIVE_LOW_RATE in degraded");
|
||||
CHECK(dec.suggested_vital_interval_ms == 2000,
|
||||
"cadence relaxed to 2s in degraded");
|
||||
}
|
||||
|
||||
static void test_degraded_gate_on_coherence_loss(void) {
|
||||
printf("test: degraded gate on coherence loss\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.node_coherence = 0.15f; /* below 0.20 threshold */
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
CHECK(dec.new_state == ADAPT_STATE_DEGRADED, "coherence loss → DEGRADED");
|
||||
}
|
||||
|
||||
static void test_anomaly_trumps_motion(void) {
|
||||
printf("test: anomaly trumps motion\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.motion_score = 0.9f; /* high motion */
|
||||
obs.anomaly_score = 0.8f; /* but anomaly is above threshold */
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
|
||||
CHECK(dec.new_state == ADAPT_STATE_ALERT, "anomaly → ALERT");
|
||||
CHECK(dec.new_profile == RV_PROFILE_FAST_MOTION,
|
||||
"alert uses FAST_MOTION profile");
|
||||
CHECK(dec.suggested_vital_interval_ms == 100, "alert cadence 100ms");
|
||||
}
|
||||
|
||||
static void test_motion_triggers_sense_active(void) {
|
||||
printf("test: motion → SENSE_ACTIVE\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.motion_score = 0.50f;
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
|
||||
CHECK(dec.new_state == ADAPT_STATE_SENSE_ACTIVE, "motion → SENSE_ACTIVE");
|
||||
CHECK(dec.new_profile == RV_PROFILE_FAST_MOTION, "profile FAST_MOTION");
|
||||
CHECK(dec.suggested_vital_interval_ms == 200,
|
||||
"non-aggressive cadence 200ms");
|
||||
}
|
||||
|
||||
static void test_aggressive_cadence(void) {
|
||||
printf("test: aggressive cadence is tighter\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
cfg.aggressive = true;
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.motion_score = 0.50f;
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
CHECK(dec.suggested_vital_interval_ms == 100,
|
||||
"aggressive motion cadence 100ms");
|
||||
}
|
||||
|
||||
static void test_stable_presence_uses_resp_high_sens(void) {
|
||||
printf("test: stable presence → RESP_HIGH_SENS\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.presence_score = 0.8f;
|
||||
obs.motion_score = 0.01f;
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
CHECK(dec.new_profile == RV_PROFILE_RESP_HIGH_SENS,
|
||||
"stable presence uses respiration profile");
|
||||
CHECK(dec.suggested_vital_interval_ms == 1000,
|
||||
"respiration cadence 1s");
|
||||
}
|
||||
|
||||
static void test_empty_room_default_is_passive(void) {
|
||||
printf("test: empty room → PASSIVE_LOW_RATE\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
CHECK(dec.new_profile == RV_PROFILE_PASSIVE_LOW_RATE,
|
||||
"empty → passive low rate");
|
||||
}
|
||||
|
||||
static void test_hysteresis_no_flap(void) {
|
||||
printf("test: no change_state when already in target state\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
obs.motion_score = 0.50f;
|
||||
|
||||
adapt_decision_t dec;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_ACTIVE, &obs, &dec);
|
||||
CHECK(!dec.change_state,
|
||||
"already in SENSE_ACTIVE — no redundant change_state");
|
||||
}
|
||||
|
||||
static void test_null_safety(void) {
|
||||
printf("test: NULL args are no-ops (no crash)\n");
|
||||
adapt_decision_t dec = {0};
|
||||
adaptive_controller_decide(NULL, ADAPT_STATE_SENSE_IDLE, NULL, &dec);
|
||||
/* if we got here, no segfault — pass */
|
||||
g_pass++;
|
||||
printf(" OK\n");
|
||||
}
|
||||
|
||||
static void benchmark_decide(void) {
|
||||
printf("bench: adaptive_controller_decide() throughput\n");
|
||||
adapt_config_t cfg = default_cfg();
|
||||
adapt_observation_t obs = quiet_obs();
|
||||
adapt_decision_t dec;
|
||||
|
||||
const int N = 10000000;
|
||||
struct timespec a, b;
|
||||
clock_gettime(CLOCK_MONOTONIC, &a);
|
||||
for (int i = 0; i < N; i++) {
|
||||
/* Vary input slightly so the compiler can't fold the call. */
|
||||
obs.motion_score = (i & 0xff) / 255.0f;
|
||||
adaptive_controller_decide(&cfg, ADAPT_STATE_SENSE_IDLE, &obs, &dec);
|
||||
}
|
||||
clock_gettime(CLOCK_MONOTONIC, &b);
|
||||
double ns_per_call = ((b.tv_sec - a.tv_sec) * 1e9 +
|
||||
(b.tv_nsec - a.tv_nsec)) / (double)N;
|
||||
printf(" %d calls, %.1f ns/call\n", N, ns_per_call);
|
||||
/* Sanity: decide() is O(constant) — must be under 10us even on a
|
||||
* slow emulator. Real ESP32 will be ~100-300ns. */
|
||||
CHECK(ns_per_call < 10000.0, "decide() must be under 10us/call");
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("=== adaptive_controller_decide() host tests ===\n\n");
|
||||
|
||||
test_degraded_gate_on_pkt_yield_collapse();
|
||||
test_degraded_gate_on_coherence_loss();
|
||||
test_anomaly_trumps_motion();
|
||||
test_motion_triggers_sense_active();
|
||||
test_aggressive_cadence();
|
||||
test_stable_presence_uses_resp_high_sens();
|
||||
test_empty_room_default_is_passive();
|
||||
test_hysteresis_no_flap();
|
||||
test_null_safety();
|
||||
benchmark_decide();
|
||||
|
||||
printf("\n=== result: %d pass, %d fail ===\n", g_pass, g_fail);
|
||||
return g_fail > 0 ? 1 : 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* Host unit test for rv_feature_state_* helpers.
|
||||
*
|
||||
* Validates:
|
||||
* - Packet layout is exactly 80 bytes
|
||||
* - IEEE CRC32 matches well-known reference vectors
|
||||
* - finalize() populates magic/seq/ts/crc correctly
|
||||
* - CRC32 throughput benchmark
|
||||
*/
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "rv_feature_state.h"
|
||||
#include "rv_radio_ops.h"
|
||||
|
||||
static int g_pass = 0, g_fail = 0;
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (cond) { g_pass++; } \
|
||||
else { g_fail++; printf(" FAIL: %s (line %d)\n", msg, __LINE__); } \
|
||||
} while (0)
|
||||
|
||||
static void test_packet_size(void) {
|
||||
printf("test: rv_feature_state_t is 60 bytes on the wire\n");
|
||||
CHECK(sizeof(rv_feature_state_t) == 60, "sizeof == 60");
|
||||
}
|
||||
|
||||
static void test_crc_known_vectors(void) {
|
||||
printf("test: IEEE CRC32 known vectors\n");
|
||||
/* IEEE CRC32 of "123456789" == 0xCBF43926 (well-known). */
|
||||
uint32_t c1 = rv_feature_state_crc32((const uint8_t *)"123456789", 9);
|
||||
CHECK(c1 == 0xCBF43926u, "CRC32('123456789') == 0xCBF43926");
|
||||
|
||||
/* Empty input → 0x00000000 (before final inversion, 0xFFFFFFFF);
|
||||
* IEEE convention with post-invert → 0x00000000 reversed — but with
|
||||
* our implementation the empty-input CRC is 0x00000000 after post-
|
||||
* invert on ~0xFFFFFFFF = 0x00000000. */
|
||||
uint32_t c2 = rv_feature_state_crc32(NULL, 0);
|
||||
CHECK(c2 == 0x00000000u, "CRC32(empty) == 0");
|
||||
|
||||
/* Single zero byte: IEEE CRC32 of 0x00 = 0xD202EF8D. */
|
||||
uint8_t zero = 0;
|
||||
uint32_t c3 = rv_feature_state_crc32(&zero, 1);
|
||||
CHECK(c3 == 0xD202EF8Du, "CRC32(0x00) == 0xD202EF8D");
|
||||
}
|
||||
|
||||
static void test_finalize(void) {
|
||||
printf("test: finalize populates required fields\n");
|
||||
rv_feature_state_t pkt;
|
||||
memset(&pkt, 0, sizeof(pkt));
|
||||
pkt.motion_score = 0.25f;
|
||||
pkt.presence_score = 0.75f;
|
||||
pkt.respiration_bpm = 14.5f;
|
||||
pkt.quality_flags = RV_QFLAG_PRESENCE_VALID | RV_QFLAG_RESPIRATION_VALID;
|
||||
|
||||
rv_feature_state_finalize(&pkt, /*node*/ 7, /*seq*/ 42,
|
||||
/*ts*/ 1234567ULL, RV_PROFILE_RESP_HIGH_SENS);
|
||||
|
||||
CHECK(pkt.magic == RV_FEATURE_STATE_MAGIC, "magic");
|
||||
CHECK(pkt.node_id == 7, "node_id");
|
||||
CHECK(pkt.seq == 42, "seq");
|
||||
CHECK(pkt.ts_us == 1234567ULL, "ts_us");
|
||||
CHECK(pkt.mode == RV_PROFILE_RESP_HIGH_SENS, "mode");
|
||||
CHECK(pkt.reserved == 0, "reserved cleared");
|
||||
CHECK(pkt.crc32 != 0, "crc32 populated (non-trivial input)");
|
||||
|
||||
/* Re-finalize must produce identical CRC (deterministic). */
|
||||
uint32_t crc1 = pkt.crc32;
|
||||
rv_feature_state_finalize(&pkt, 7, 42, 1234567ULL, RV_PROFILE_RESP_HIGH_SENS);
|
||||
CHECK(pkt.crc32 == crc1, "finalize is deterministic");
|
||||
|
||||
/* Changing a payload byte must change the CRC. */
|
||||
pkt.motion_score = 0.26f;
|
||||
rv_feature_state_finalize(&pkt, 7, 42, 1234567ULL, RV_PROFILE_RESP_HIGH_SENS);
|
||||
CHECK(pkt.crc32 != crc1, "CRC changes when payload changes");
|
||||
}
|
||||
|
||||
static void test_crc_verifiability(void) {
|
||||
printf("test: receiver can verify CRC\n");
|
||||
rv_feature_state_t pkt;
|
||||
memset(&pkt, 0, sizeof(pkt));
|
||||
pkt.motion_score = 0.33f;
|
||||
pkt.presence_score = 0.66f;
|
||||
rv_feature_state_finalize(&pkt, 1, 100, 555ULL, RV_PROFILE_PASSIVE_LOW_RATE);
|
||||
|
||||
/* Receiver recomputes CRC over all bytes except the trailing crc32. */
|
||||
uint32_t expected = rv_feature_state_crc32(
|
||||
(const uint8_t *)&pkt, sizeof(pkt) - sizeof(uint32_t));
|
||||
CHECK(pkt.crc32 == expected, "receiver-side CRC check matches");
|
||||
}
|
||||
|
||||
static void benchmark_crc(void) {
|
||||
printf("bench: CRC32 over 60-byte packet (56 B hashed, excl trailing crc32)\n");
|
||||
rv_feature_state_t pkt;
|
||||
memset(&pkt, 0x5A, sizeof(pkt));
|
||||
|
||||
const int N = 5000000;
|
||||
struct timespec a, b;
|
||||
clock_gettime(CLOCK_MONOTONIC, &a);
|
||||
volatile uint32_t sink = 0;
|
||||
for (int i = 0; i < N; i++) {
|
||||
pkt.seq = (uint16_t)i; /* vary input so compiler can't fold */
|
||||
sink ^= rv_feature_state_crc32(
|
||||
(const uint8_t *)&pkt, sizeof(pkt) - sizeof(uint32_t));
|
||||
}
|
||||
clock_gettime(CLOCK_MONOTONIC, &b);
|
||||
(void)sink;
|
||||
double ns_per_call = ((b.tv_sec - a.tv_sec) * 1e9 +
|
||||
(b.tv_nsec - a.tv_nsec)) / (double)N;
|
||||
double mb_per_sec = (double)(sizeof(pkt) - sizeof(uint32_t)) / ns_per_call
|
||||
* 1e9 / (1024.0 * 1024.0);
|
||||
printf(" %d calls, %.1f ns/packet, %.1f MB/s\n",
|
||||
N, ns_per_call, mb_per_sec);
|
||||
/* At 10 Hz feature-state cadence, CRC budget is <100us/packet — we
|
||||
* expect bit-by-bit CRC32 to run ~1 MB/s on host, ~100-300 KB/s on
|
||||
* ESP32-S3 Xtensa LX7. 76-byte CRC takes <1 ms either way. */
|
||||
CHECK(ns_per_call < 50000.0, "CRC32(80B) must be under 50us/packet");
|
||||
}
|
||||
|
||||
static void benchmark_finalize(void) {
|
||||
printf("bench: full finalize() cost\n");
|
||||
rv_feature_state_t pkt;
|
||||
memset(&pkt, 0x33, sizeof(pkt));
|
||||
|
||||
const int N = 5000000;
|
||||
struct timespec a, b;
|
||||
clock_gettime(CLOCK_MONOTONIC, &a);
|
||||
for (int i = 0; i < N; i++) {
|
||||
rv_feature_state_finalize(&pkt, 1, (uint16_t)i, (uint64_t)i,
|
||||
RV_PROFILE_PASSIVE_LOW_RATE);
|
||||
}
|
||||
clock_gettime(CLOCK_MONOTONIC, &b);
|
||||
double ns_per_call = ((b.tv_sec - a.tv_sec) * 1e9 +
|
||||
(b.tv_nsec - a.tv_nsec)) / (double)N;
|
||||
printf(" %d calls, %.1f ns/call (includes CRC)\n", N, ns_per_call);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("=== rv_feature_state_* host tests ===\n\n");
|
||||
|
||||
test_packet_size();
|
||||
test_crc_known_vectors();
|
||||
test_finalize();
|
||||
test_crc_verifiability();
|
||||
benchmark_crc();
|
||||
benchmark_finalize();
|
||||
|
||||
printf("\n=== result: %d pass, %d fail ===\n", g_pass, g_fail);
|
||||
return g_fail > 0 ? 1 : 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
/*
|
||||
* Host unit test for ADR-081 Layer 3 mesh plane encode/decode.
|
||||
*
|
||||
* rv_mesh_encode() and rv_mesh_decode() are the pure halves of the
|
||||
* mesh plane — no ESP-IDF, no sockets — so we exercise them with the
|
||||
* RV_MESH_HOST_TEST flag that disables the send helpers.
|
||||
*/
|
||||
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "rv_mesh.h"
|
||||
#include "rv_feature_state.h"
|
||||
#include "rv_radio_ops.h" /* for RV_PROFILE_* enum values */
|
||||
|
||||
static int g_pass = 0, g_fail = 0;
|
||||
#define CHECK(cond, msg) do { \
|
||||
if (cond) { g_pass++; } \
|
||||
else { g_fail++; printf(" FAIL: %s (line %d)\n", msg, __LINE__); } \
|
||||
} while (0)
|
||||
|
||||
static void test_header_size(void) {
|
||||
printf("test: rv_mesh_header_t is 16 bytes\n");
|
||||
CHECK(sizeof(rv_mesh_header_t) == 16, "sizeof(header) == 16");
|
||||
}
|
||||
|
||||
static void test_encode_health_roundtrip(void) {
|
||||
printf("test: HEALTH roundtrip\n");
|
||||
rv_node_status_t st;
|
||||
memset(&st, 0, sizeof(st));
|
||||
st.node_id[0] = 7;
|
||||
st.local_time_us = 1234567890ULL;
|
||||
st.role = RV_ROLE_OBSERVER;
|
||||
st.current_channel = 6;
|
||||
st.current_bw = 20;
|
||||
st.noise_floor_dbm = -93;
|
||||
st.pkt_yield = 42;
|
||||
st.sync_error_us = 12;
|
||||
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
size_t n = rv_mesh_encode_health(RV_ROLE_OBSERVER, /*epoch*/ 100,
|
||||
&st, buf, sizeof(buf));
|
||||
CHECK(n > 0, "encode returns non-zero");
|
||||
CHECK(n == sizeof(rv_mesh_header_t) + sizeof(st) + 4,
|
||||
"encoded size = hdr+payload+crc");
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *payload = NULL;
|
||||
uint16_t payload_len = 0;
|
||||
esp_err_t rc = rv_mesh_decode(buf, n, &hdr, &payload, &payload_len);
|
||||
CHECK(rc == ESP_OK, "decode OK");
|
||||
CHECK(hdr.type == RV_MSG_HEALTH, "type == HEALTH");
|
||||
CHECK(hdr.epoch == 100, "epoch survives");
|
||||
CHECK(hdr.payload_len == sizeof(st), "payload_len matches");
|
||||
CHECK(payload != NULL, "payload pointer set");
|
||||
CHECK(memcmp(payload, &st, sizeof(st)) == 0, "payload bytes match");
|
||||
}
|
||||
|
||||
static void test_encode_anomaly_roundtrip(void) {
|
||||
printf("test: ANOMALY_ALERT roundtrip\n");
|
||||
rv_anomaly_alert_t a;
|
||||
memset(&a, 0, sizeof(a));
|
||||
a.node_id[0] = 3;
|
||||
a.ts_us = 999999ULL;
|
||||
a.reason = RV_ANOMALY_FALL;
|
||||
a.severity = 200;
|
||||
a.anomaly_score = 0.85f;
|
||||
a.motion_score = 0.9f;
|
||||
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
size_t n = rv_mesh_encode_anomaly_alert(RV_ROLE_OBSERVER, 7, &a,
|
||||
buf, sizeof(buf));
|
||||
CHECK(n > 0, "encoded");
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *payload = NULL;
|
||||
uint16_t payload_len = 0;
|
||||
esp_err_t rc = rv_mesh_decode(buf, n, &hdr, &payload, &payload_len);
|
||||
CHECK(rc == ESP_OK, "decoded");
|
||||
CHECK(hdr.type == RV_MSG_ANOMALY_ALERT, "type ok");
|
||||
rv_anomaly_alert_t got;
|
||||
memcpy(&got, payload, sizeof(got));
|
||||
CHECK(got.reason == RV_ANOMALY_FALL, "reason survived");
|
||||
CHECK(got.severity == 200, "severity survived");
|
||||
}
|
||||
|
||||
static void test_encode_feature_delta_wraps_feature_state(void) {
|
||||
printf("test: FEATURE_DELTA wraps rv_feature_state_t\n");
|
||||
rv_feature_state_t fs;
|
||||
memset(&fs, 0, sizeof(fs));
|
||||
fs.motion_score = 0.5f;
|
||||
rv_feature_state_finalize(&fs, /*node*/ 9, /*seq*/ 17,
|
||||
/*ts*/ 111ULL, RV_PROFILE_FAST_MOTION);
|
||||
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
size_t n = rv_mesh_encode_feature_delta(RV_ROLE_OBSERVER, 2, &fs,
|
||||
buf, sizeof(buf));
|
||||
CHECK(n == sizeof(rv_mesh_header_t) + sizeof(fs) + 4, "size check");
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *payload = NULL;
|
||||
uint16_t len = 0;
|
||||
CHECK(rv_mesh_decode(buf, n, &hdr, &payload, &len) == ESP_OK,
|
||||
"decode OK");
|
||||
rv_feature_state_t got;
|
||||
memcpy(&got, payload, sizeof(got));
|
||||
CHECK(got.magic == RV_FEATURE_STATE_MAGIC, "inner magic preserved");
|
||||
CHECK(got.node_id == 9, "inner node_id preserved");
|
||||
CHECK(got.seq == 17, "inner seq preserved");
|
||||
/* Inner CRC is end-to-end even though the mesh frame has its own
|
||||
* CRC too — two checks for two failure modes. */
|
||||
uint32_t inner_crc = rv_feature_state_crc32(
|
||||
(const uint8_t *)&got, sizeof(got) - sizeof(uint32_t));
|
||||
CHECK(inner_crc == got.crc32, "inner feature_state CRC still valid");
|
||||
}
|
||||
|
||||
static void test_decode_rejects_bad_magic(void) {
|
||||
printf("test: decode rejects bad magic\n");
|
||||
uint8_t buf[sizeof(rv_mesh_header_t) + 4];
|
||||
memset(buf, 0xFF, sizeof(buf));
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *p = NULL;
|
||||
uint16_t plen = 0;
|
||||
esp_err_t rc = rv_mesh_decode(buf, sizeof(buf), &hdr, &p, &plen);
|
||||
CHECK(rc != ESP_OK, "bad magic rejected");
|
||||
}
|
||||
|
||||
static void test_decode_rejects_truncated(void) {
|
||||
printf("test: decode rejects truncated frame\n");
|
||||
uint8_t buf[sizeof(rv_mesh_header_t) - 1];
|
||||
memset(buf, 0, sizeof(buf));
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *p = NULL;
|
||||
uint16_t plen = 0;
|
||||
esp_err_t rc = rv_mesh_decode(buf, sizeof(buf), &hdr, &p, &plen);
|
||||
CHECK(rc != ESP_OK, "truncated rejected");
|
||||
}
|
||||
|
||||
static void test_decode_rejects_bad_crc(void) {
|
||||
printf("test: decode rejects CRC mismatch\n");
|
||||
rv_node_status_t st;
|
||||
memset(&st, 0, sizeof(st));
|
||||
st.role = RV_ROLE_OBSERVER;
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
size_t n = rv_mesh_encode_health(RV_ROLE_OBSERVER, 1, &st,
|
||||
buf, sizeof(buf));
|
||||
CHECK(n > 0, "encoded");
|
||||
|
||||
/* Flip a byte in the payload — CRC must now mismatch. */
|
||||
buf[sizeof(rv_mesh_header_t) + 4] ^= 0x10;
|
||||
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *p = NULL;
|
||||
uint16_t plen = 0;
|
||||
esp_err_t rc = rv_mesh_decode(buf, n, &hdr, &p, &plen);
|
||||
CHECK(rc != ESP_OK, "CRC mismatch rejected");
|
||||
}
|
||||
|
||||
static void test_encode_rejects_oversize_payload(void) {
|
||||
printf("test: encode rejects oversize payload\n");
|
||||
uint8_t junk[RV_MESH_MAX_PAYLOAD + 1] = {0};
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES + 8];
|
||||
size_t n = rv_mesh_encode(RV_MSG_HEALTH, RV_ROLE_OBSERVER, RV_AUTH_NONE,
|
||||
0, junk, sizeof(junk), buf, sizeof(buf));
|
||||
CHECK(n == 0, "oversize payload → 0");
|
||||
}
|
||||
|
||||
static void test_encode_rejects_small_buf(void) {
|
||||
printf("test: encode rejects too-small buffer\n");
|
||||
rv_node_status_t st = {0};
|
||||
uint8_t buf[16]; /* header fits but not payload */
|
||||
size_t n = rv_mesh_encode_health(RV_ROLE_OBSERVER, 0, &st,
|
||||
buf, sizeof(buf));
|
||||
CHECK(n == 0, "small buf → 0");
|
||||
}
|
||||
|
||||
static void benchmark_encode(void) {
|
||||
printf("bench: encode+decode HEALTH roundtrip\n");
|
||||
rv_node_status_t st;
|
||||
memset(&st, 0x33, sizeof(st));
|
||||
uint8_t buf[RV_MESH_MAX_FRAME_BYTES];
|
||||
|
||||
const int N = 2000000;
|
||||
struct timespec a, b;
|
||||
clock_gettime(CLOCK_MONOTONIC, &a);
|
||||
for (int i = 0; i < N; i++) {
|
||||
st.pkt_yield = (uint16_t)i;
|
||||
size_t n = rv_mesh_encode_health(RV_ROLE_OBSERVER, (uint32_t)i,
|
||||
&st, buf, sizeof(buf));
|
||||
rv_mesh_header_t hdr;
|
||||
const uint8_t *p = NULL;
|
||||
uint16_t plen = 0;
|
||||
(void)rv_mesh_decode(buf, n, &hdr, &p, &plen);
|
||||
}
|
||||
clock_gettime(CLOCK_MONOTONIC, &b);
|
||||
double ns = ((b.tv_sec - a.tv_sec) * 1e9 +
|
||||
(b.tv_nsec - a.tv_nsec)) / (double)N;
|
||||
printf(" %d roundtrips, %.1f ns/call\n", N, ns);
|
||||
CHECK(ns < 20000.0, "encode+decode must be under 20us/roundtrip");
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
printf("=== rv_mesh encode/decode host tests ===\n\n");
|
||||
test_header_size();
|
||||
test_encode_health_roundtrip();
|
||||
test_encode_anomaly_roundtrip();
|
||||
test_encode_feature_delta_wraps_feature_state();
|
||||
test_decode_rejects_bad_magic();
|
||||
test_decode_rejects_truncated();
|
||||
test_decode_rejects_bad_crc();
|
||||
test_encode_rejects_oversize_payload();
|
||||
test_encode_rejects_small_buf();
|
||||
benchmark_encode();
|
||||
printf("\n=== result: %d pass, %d fail ===\n", g_pass, g_fail);
|
||||
return g_fail > 0 ? 1 : 0;
|
||||
}
|
||||
|
|
@ -1 +1 @@
|
|||
0.6.1
|
||||
0.6.2
|
||||
|
|
|
|||
|
|
@ -5446,18 +5446,6 @@ version = "2.0.4"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "178f93f84a4a72c582026a45d9b8710acf188df4a22a25434c5dbba1df6c4cac"
|
||||
|
||||
[[package]]
|
||||
name = "ruview-geo"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
|
|
@ -7864,6 +7852,18 @@ dependencies = [
|
|||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-geo"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-hardware"
|
||||
version = "0.3.0"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
[package]
|
||||
name = "ruview-geo"
|
||||
name = "wifi-densepose-geo"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Geospatial satellite integration — free satellite tiles, DEM, OSM, temporal tracking"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# ruview-geo — Geospatial Satellite Integration
|
||||
# wifi-densepose-geo — Geospatial Satellite Integration
|
||||
|
||||
Free satellite imagery, terrain elevation, and map data for RuView spatial sensing. No API keys required.
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ Integrates your local sensor data (camera + WiFi CSI point cloud) with geographi
|
|||
## Usage
|
||||
|
||||
```rust
|
||||
use ruview_geo::{fuse, brain, temporal};
|
||||
use wifi_densepose_geo::{fuse, brain, temporal};
|
||||
|
||||
// Build geo scene for current location
|
||||
let scene = fuse::build_scene(500.0).await?; // 500m radius
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use ruview_geo::*;
|
||||
use wifi_densepose_geo::*;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,24 @@
|
|||
//! Brain integration — store geospatial context in ruOS brain.
|
||||
//!
|
||||
//! Brain URL is read from `RUVIEW_BRAIN_URL` env var (default
|
||||
//! `http://127.0.0.1:9876`). The resolved URL is logged once on first use.
|
||||
|
||||
use crate::fuse;
|
||||
use crate::types::GeoScene;
|
||||
use anyhow::Result;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const BRAIN_URL: &str = "http://127.0.0.1:9876";
|
||||
const DEFAULT_BRAIN_URL: &str = "http://127.0.0.1:9876";
|
||||
|
||||
pub(crate) fn brain_url() -> &'static str {
|
||||
static BRAIN_URL: OnceLock<String> = OnceLock::new();
|
||||
BRAIN_URL.get_or_init(|| {
|
||||
let url = std::env::var("RUVIEW_BRAIN_URL")
|
||||
.unwrap_or_else(|_| DEFAULT_BRAIN_URL.to_string());
|
||||
eprintln!(" wifi-densepose-geo: using brain URL {url}");
|
||||
url
|
||||
})
|
||||
}
|
||||
|
||||
/// Store geospatial context in the brain.
|
||||
pub async fn store_geo_context(scene: &GeoScene) -> Result<u32> {
|
||||
|
|
@ -20,7 +34,7 @@ pub async fn store_geo_context(scene: &GeoScene) -> Result<u32> {
|
|||
"category": "spatial-geo",
|
||||
"content": summary,
|
||||
});
|
||||
if client.post(format!("{BRAIN_URL}/memories")).json(&body).send().await.is_ok() {
|
||||
if client.post(format!("{}/memories", brain_url())).json(&body).send().await.is_ok() {
|
||||
stored += 1;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,36 @@
|
|||
//! OpenStreetMap data via Overpass API — buildings, roads, land use.
|
||||
|
||||
use crate::types::{GeoBBox, GeoPoint, OsmFeature};
|
||||
use anyhow::Result;
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
const OVERPASS_URL: &str = "https://overpass-api.de/api/interpreter";
|
||||
|
||||
/// Maximum radius (in metres) accepted by the OSM fetchers. Requests larger
|
||||
/// than this would produce Overpass queries covering hundreds of square
|
||||
/// kilometres — which hammers the public endpoint and returns unworkably
|
||||
/// large response payloads. Callers wanting wider areas must tile the queries.
|
||||
pub const MAX_RADIUS_M: f64 = 5000.0;
|
||||
|
||||
fn check_radius(radius_m: f64) -> Result<()> {
|
||||
if !radius_m.is_finite() || radius_m <= 0.0 {
|
||||
return Err(anyhow!("radius_m must be positive and finite (got {radius_m})"));
|
||||
}
|
||||
if radius_m > MAX_RADIUS_M {
|
||||
return Err(anyhow!(
|
||||
"radius_m {radius_m} exceeds MAX_RADIUS_M ({MAX_RADIUS_M}); \
|
||||
tile the query into smaller chunks"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch buildings within radius of a point.
|
||||
///
|
||||
/// Uses an inclusive `["building"]` filter that matches all building values
|
||||
/// (residential, commercial, yes, etc.) and also queries relations for
|
||||
/// multipolygon buildings. Default recommended radius: 500 m.
|
||||
/// multipolygon buildings. Default recommended radius: 500 m. Max 5000 m.
|
||||
pub async fn fetch_buildings(center: &GeoPoint, radius_m: f64) -> Result<Vec<OsmFeature>> {
|
||||
check_radius(radius_m)?;
|
||||
let bbox = GeoBBox::from_center(center, radius_m);
|
||||
let query = format!(
|
||||
r#"[out:json][timeout:25];(way["building"]({},{},{},{});relation["building"]({},{},{},{}););out body;>;out skel qt;"#,
|
||||
|
|
@ -21,8 +41,9 @@ pub async fn fetch_buildings(center: &GeoPoint, radius_m: f64) -> Result<Vec<Osm
|
|||
parse_buildings(&resp)
|
||||
}
|
||||
|
||||
/// Fetch roads within radius.
|
||||
/// Fetch roads within radius. Max 5000 m; returns an error otherwise.
|
||||
pub async fn fetch_roads(center: &GeoPoint, radius_m: f64) -> Result<Vec<OsmFeature>> {
|
||||
check_radius(radius_m)?;
|
||||
let bbox = GeoBBox::from_center(center, radius_m);
|
||||
let query = format!(
|
||||
r#"[out:json][timeout:10];way["highway"]({},{},{},{});out body;>;out skel qt;"#,
|
||||
|
|
@ -48,7 +69,18 @@ async fn overpass_query(query: &str) -> Result<serde_json::Value> {
|
|||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
fn parse_buildings(data: &serde_json::Value) -> Result<Vec<OsmFeature>> {
|
||||
/// Parse an Overpass JSON response into building features.
|
||||
///
|
||||
/// Returns an error if the response is not a JSON object or is missing the
|
||||
/// top-level `elements` array (indicative of a malformed/non-Overpass payload).
|
||||
pub fn parse_overpass_json(data: &serde_json::Value) -> Result<Vec<OsmFeature>> {
|
||||
if !data.is_object() || data.get("elements").and_then(|e| e.as_array()).is_none() {
|
||||
return Err(anyhow!("malformed Overpass response: missing `elements` array"));
|
||||
}
|
||||
parse_buildings(data)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_buildings(data: &serde_json::Value) -> Result<Vec<OsmFeature>> {
|
||||
let mut buildings = Vec::new();
|
||||
let mut nodes: std::collections::HashMap<u64, [f64; 2]> = std::collections::HashMap::new();
|
||||
|
||||
|
|
@ -134,3 +166,51 @@ fn parse_roads(data: &serde_json::Value) -> Result<Vec<OsmFeature>> {
|
|||
|
||||
Ok(roads)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_overpass_json_accepts_minimal_fixture() {
|
||||
// Minimal fixture: three nodes forming a triangular building.
|
||||
let j = serde_json::json!({
|
||||
"elements": [
|
||||
{ "type": "node", "id": 1, "lat": 43.0, "lon": -79.0 },
|
||||
{ "type": "node", "id": 2, "lat": 43.0001, "lon": -79.0 },
|
||||
{ "type": "node", "id": 3, "lat": 43.0, "lon": -79.0001 },
|
||||
{
|
||||
"type": "way", "id": 100,
|
||||
"nodes": [1, 2, 3, 1],
|
||||
"tags": { "building": "yes", "name": "Test Hall" }
|
||||
}
|
||||
]
|
||||
});
|
||||
let features = parse_overpass_json(&j).expect("minimal payload should parse");
|
||||
assert_eq!(features.len(), 1);
|
||||
match &features[0] {
|
||||
OsmFeature::Building { outline, name, .. } => {
|
||||
assert_eq!(outline.len(), 4);
|
||||
assert_eq!(name.as_deref(), Some("Test Hall"));
|
||||
}
|
||||
_ => panic!("expected a Building"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_overpass_json_rejects_malformed() {
|
||||
// Missing the `elements` array entirely.
|
||||
let j = serde_json::json!({ "version": 0.6 });
|
||||
assert!(parse_overpass_json(&j).is_err());
|
||||
// Not even an object.
|
||||
let arr = serde_json::json!([1, 2, 3]);
|
||||
assert!(parse_overpass_json(&arr).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fetch_buildings_rejects_oversized_radius() {
|
||||
let center = GeoPoint { lat: 43.0, lon: -79.0, alt: 0.0 };
|
||||
let err = fetch_buildings(¢er, MAX_RADIUS_M + 1.0).await.err();
|
||||
assert!(err.is_some(), "should reject radius > MAX_RADIUS_M");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,6 +150,8 @@ pub async fn detect_tile_changes(
|
|||
}
|
||||
|
||||
/// Post a change event to the local ruOS brain.
|
||||
///
|
||||
/// Brain URL honours `RUVIEW_BRAIN_URL` via [`crate::brain::brain_url`].
|
||||
async fn store_change_event(cache_key: &str, result: &TileChangeResult) -> Result<()> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
|
|
@ -164,7 +166,7 @@ async fn store_change_event(cache_key: &str, result: &TileChangeResult) -> Resul
|
|||
});
|
||||
|
||||
client
|
||||
.post("http://127.0.0.1:9876/memories")
|
||||
.post(format!("{}/memories", crate::brain::brain_url()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
use ruview_geo::*;
|
||||
use ruview_geo::coord;
|
||||
use wifi_densepose_geo::*;
|
||||
use wifi_densepose_geo::coord;
|
||||
|
||||
#[test]
|
||||
fn test_haversine() {
|
||||
|
|
@ -63,7 +63,7 @@ fn test_hgt_parse() {
|
|||
for h in [100i16, 110, 120, 105, 115, 125, 110, 120, 130] {
|
||||
data.extend_from_slice(&h.to_be_bytes());
|
||||
}
|
||||
let grid = ruview_geo::terrain::parse_hgt(&data, 43.0, -79.0).unwrap();
|
||||
let grid = wifi_densepose_geo::terrain::parse_hgt(&data, 43.0, -79.0).unwrap();
|
||||
assert_eq!(grid.heights[0], 100.0);
|
||||
assert_eq!(grid.heights[4], 115.0);
|
||||
}
|
||||
|
|
@ -71,14 +71,14 @@ fn test_hgt_parse() {
|
|||
#[test]
|
||||
fn test_registration() {
|
||||
let origin = GeoPoint { lat: 43.6532, lon: -79.3832, alt: 76.0 };
|
||||
let reg = ruview_geo::register::auto_register(&origin);
|
||||
let reg = wifi_densepose_geo::register::auto_register(&origin);
|
||||
|
||||
let local = [10.0f32, 0.0, 20.0]; // 10m east, 20m forward
|
||||
let geo = ruview_geo::register::local_to_wgs84(®, &local);
|
||||
let geo = wifi_densepose_geo::register::local_to_wgs84(®, &local);
|
||||
assert!((geo.lat - origin.lat).abs() < 0.001);
|
||||
assert!((geo.lon - origin.lon).abs() < 0.001);
|
||||
|
||||
let back = ruview_geo::register::wgs84_to_local(®, &geo);
|
||||
let back = wifi_densepose_geo::register::wgs84_to_local(®, &geo);
|
||||
assert!((back[0] - local[0]).abs() < 0.1);
|
||||
assert!((back[2] - local[2]).abs() < 0.1);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,20 @@ pub mod aggregator;
|
|||
mod bridge;
|
||||
pub mod esp32;
|
||||
|
||||
// ADR-081: Rust mirror of the firmware radio abstraction layer (L1) and
|
||||
// mesh sensing plane (L3). Lets host tests, simulators, and future
|
||||
// coordinator-node Rust code drive the controller stack without
|
||||
// touching any downstream signal/ruvector/train/mat crate.
|
||||
pub mod radio_ops;
|
||||
|
||||
pub use csi_frame::{CsiFrame, CsiMetadata, SubcarrierData, Bandwidth, AntennaConfig};
|
||||
pub use error::ParseError;
|
||||
pub use esp32_parser::Esp32CsiParser;
|
||||
pub use bridge::CsiData;
|
||||
pub use radio_ops::{
|
||||
RadioOps, RadioMode, CaptureProfile, RadioHealth, RadioError, MockRadio,
|
||||
MeshRole, MeshMsgType, AuthClass, MeshHeader, NodeStatus, AnomalyAlert,
|
||||
MeshError, MESH_MAGIC, MESH_VERSION, MESH_HEADER_SIZE, MESH_MAX_PAYLOAD,
|
||||
crc32_ieee, decode_mesh, decode_node_status, decode_anomaly_alert,
|
||||
encode_health,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,535 @@
|
|||
//! ADR-081 Layer 1 Rust mirror + Layer 3 mesh-plane decoder.
|
||||
//!
|
||||
//! Mirrors the C vtable `rv_radio_ops_t` defined in
|
||||
//! `firmware/esp32-csi-node/main/rv_radio_ops.h` so that test harnesses,
|
||||
//! simulators, and future coordinator-node Rust code can drive the
|
||||
//! controller logic against a mock backend without touching
|
||||
//! `wifi-densepose-signal`, `-ruvector`, `-train`, or `-mat`. That
|
||||
//! portability is the ADR-081 acceptance test: "swap one radio family
|
||||
//! for another without changing the Rust memory and reasoning layers".
|
||||
//!
|
||||
//! The mesh-plane types (`MeshHeader`, `NodeStatus`, `AnomalyAlert`,
|
||||
//! etc.) mirror `rv_mesh.h` and deserialize the wire format produced by
|
||||
//! `rv_mesh_encode*()`. This lets a Rust-side aggregator or test node
|
||||
//! decode live traffic from the ESP32 nodes without re-implementing
|
||||
//! the framing.
|
||||
|
||||
use std::convert::TryFrom;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer 1 — Radio Abstraction Layer (mirror of rv_radio_ops_t)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Operating modes, mirror of `rv_radio_mode_t`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum RadioMode {
|
||||
Disabled = 0,
|
||||
PassiveRx = 1,
|
||||
ActiveProbe = 2,
|
||||
Calibration = 3,
|
||||
}
|
||||
|
||||
/// Named capture profiles, mirror of `rv_capture_profile_t`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum CaptureProfile {
|
||||
PassiveLowRate = 0,
|
||||
ActiveProbe = 1,
|
||||
RespHighSens = 2,
|
||||
FastMotion = 3,
|
||||
Calibration = 4,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for CaptureProfile {
|
||||
type Error = RadioError;
|
||||
fn try_from(v: u8) -> Result<Self, Self::Error> {
|
||||
match v {
|
||||
0 => Ok(CaptureProfile::PassiveLowRate),
|
||||
1 => Ok(CaptureProfile::ActiveProbe),
|
||||
2 => Ok(CaptureProfile::RespHighSens),
|
||||
3 => Ok(CaptureProfile::FastMotion),
|
||||
4 => Ok(CaptureProfile::Calibration),
|
||||
_ => Err(RadioError::UnknownProfile(v)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Health snapshot, mirror of `rv_radio_health_t`.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct RadioHealth {
|
||||
pub pkt_yield_per_sec: u16,
|
||||
pub send_fail_count: u16,
|
||||
pub rssi_median_dbm: i8,
|
||||
pub noise_floor_dbm: i8,
|
||||
pub current_channel: u8,
|
||||
pub current_bw_mhz: u8,
|
||||
pub current_profile: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RadioError {
|
||||
#[error("unknown capture profile id: {0}")]
|
||||
UnknownProfile(u8),
|
||||
#[error("backend error: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
/// Rust mirror of the `rv_radio_ops_t` vtable.
|
||||
///
|
||||
/// Any Rust-side driver (mock, simulator, future coordinator node) that
|
||||
/// wants to participate in the ADR-081 controller stack must implement
|
||||
/// this trait. The controller's pure decision policy lives in
|
||||
/// `adaptive_controller_decide.c` on the C side today; when the Rust
|
||||
/// coordinator lands, it will reuse the decoded `NodeStatus` messages
|
||||
/// this module parses and feed decisions back through these ops.
|
||||
pub trait RadioOps: Send + Sync {
|
||||
fn init(&mut self) -> Result<(), RadioError>;
|
||||
fn set_channel(&mut self, ch: u8, bw: u8) -> Result<(), RadioError>;
|
||||
fn set_mode(&mut self, mode: RadioMode) -> Result<(), RadioError>;
|
||||
fn set_csi_enabled(&mut self, en: bool) -> Result<(), RadioError>;
|
||||
fn set_capture_profile(&mut self, p: CaptureProfile) -> Result<(), RadioError>;
|
||||
fn get_health(&self) -> Result<RadioHealth, RadioError>;
|
||||
}
|
||||
|
||||
/// A zero-hardware radio backend for host tests and CI.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MockRadio {
|
||||
pub health: RadioHealth,
|
||||
pub init_count: u32,
|
||||
pub channel_calls: Vec<(u8, u8)>,
|
||||
pub profile_calls: Vec<CaptureProfile>,
|
||||
pub mode_calls: Vec<RadioMode>,
|
||||
pub csi_enabled: bool,
|
||||
}
|
||||
|
||||
impl RadioOps for MockRadio {
|
||||
fn init(&mut self) -> Result<(), RadioError> {
|
||||
self.init_count += 1;
|
||||
Ok(())
|
||||
}
|
||||
fn set_channel(&mut self, ch: u8, bw: u8) -> Result<(), RadioError> {
|
||||
self.channel_calls.push((ch, bw));
|
||||
self.health.current_channel = ch;
|
||||
self.health.current_bw_mhz = bw;
|
||||
Ok(())
|
||||
}
|
||||
fn set_mode(&mut self, mode: RadioMode) -> Result<(), RadioError> {
|
||||
self.mode_calls.push(mode);
|
||||
Ok(())
|
||||
}
|
||||
fn set_csi_enabled(&mut self, en: bool) -> Result<(), RadioError> {
|
||||
self.csi_enabled = en;
|
||||
Ok(())
|
||||
}
|
||||
fn set_capture_profile(&mut self, p: CaptureProfile) -> Result<(), RadioError> {
|
||||
self.profile_calls.push(p);
|
||||
self.health.current_profile = p as u8;
|
||||
Ok(())
|
||||
}
|
||||
fn get_health(&self) -> Result<RadioHealth, RadioError> {
|
||||
Ok(self.health)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layer 3 — Mesh plane (mirror of rv_mesh.h)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `RV_MESH_MAGIC` from rv_mesh.h.
|
||||
pub const MESH_MAGIC: u32 = 0xC511_8100;
|
||||
/// `RV_MESH_VERSION` from rv_mesh.h.
|
||||
pub const MESH_VERSION: u8 = 1;
|
||||
/// `RV_MESH_MAX_PAYLOAD` from rv_mesh.h.
|
||||
pub const MESH_MAX_PAYLOAD: usize = 256;
|
||||
/// `sizeof(rv_mesh_header_t)`.
|
||||
pub const MESH_HEADER_SIZE: usize = 16;
|
||||
|
||||
/// `rv_mesh_role_t`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum MeshRole {
|
||||
Unassigned = 0,
|
||||
Anchor = 1,
|
||||
Observer = 2,
|
||||
FusionRelay = 3,
|
||||
Coordinator = 4,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for MeshRole {
|
||||
type Error = MeshError;
|
||||
fn try_from(v: u8) -> Result<Self, Self::Error> {
|
||||
match v {
|
||||
0 => Ok(MeshRole::Unassigned),
|
||||
1 => Ok(MeshRole::Anchor),
|
||||
2 => Ok(MeshRole::Observer),
|
||||
3 => Ok(MeshRole::FusionRelay),
|
||||
4 => Ok(MeshRole::Coordinator),
|
||||
_ => Err(MeshError::UnknownRole(v)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `rv_mesh_msg_type_t`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum MeshMsgType {
|
||||
TimeSync = 0x01,
|
||||
RoleAssign = 0x02,
|
||||
ChannelPlan = 0x03,
|
||||
CalibrationStart = 0x04,
|
||||
FeatureDelta = 0x05,
|
||||
Health = 0x06,
|
||||
AnomalyAlert = 0x07,
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for MeshMsgType {
|
||||
type Error = MeshError;
|
||||
fn try_from(v: u8) -> Result<Self, Self::Error> {
|
||||
match v {
|
||||
0x01 => Ok(MeshMsgType::TimeSync),
|
||||
0x02 => Ok(MeshMsgType::RoleAssign),
|
||||
0x03 => Ok(MeshMsgType::ChannelPlan),
|
||||
0x04 => Ok(MeshMsgType::CalibrationStart),
|
||||
0x05 => Ok(MeshMsgType::FeatureDelta),
|
||||
0x06 => Ok(MeshMsgType::Health),
|
||||
0x07 => Ok(MeshMsgType::AnomalyAlert),
|
||||
_ => Err(MeshError::UnknownMsgType(v)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `rv_mesh_auth_class_t`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum AuthClass {
|
||||
None = 0,
|
||||
HmacSession = 1,
|
||||
Ed25519Batch = 2,
|
||||
}
|
||||
|
||||
/// `rv_mesh_header_t`, 16 bytes.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct MeshHeader {
|
||||
pub msg_type: MeshMsgType,
|
||||
pub sender_role: MeshRole,
|
||||
pub auth_class: AuthClass,
|
||||
pub epoch: u32,
|
||||
pub payload_len: u16,
|
||||
}
|
||||
|
||||
/// `rv_node_status_t`, 28 bytes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct NodeStatus {
|
||||
pub node_id: [u8; 8],
|
||||
pub local_time_us: u64,
|
||||
pub role: MeshRole,
|
||||
pub current_channel: u8,
|
||||
pub current_bw: u8,
|
||||
pub noise_floor_dbm: i8,
|
||||
pub pkt_yield: u16,
|
||||
pub sync_error_us: u16,
|
||||
pub health_flags: u16,
|
||||
}
|
||||
|
||||
/// `rv_anomaly_alert_t`, 28 bytes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AnomalyAlert {
|
||||
pub node_id: [u8; 8],
|
||||
pub ts_us: u64,
|
||||
pub severity: u8,
|
||||
pub reason: u8,
|
||||
pub anomaly_score: f32,
|
||||
pub motion_score: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MeshError {
|
||||
#[error("frame too short: {0} bytes")]
|
||||
TooShort(usize),
|
||||
#[error("bad magic: 0x{0:08X}")]
|
||||
BadMagic(u32),
|
||||
#[error("unsupported version: {0}")]
|
||||
BadVersion(u8),
|
||||
#[error("payload too large: {0}")]
|
||||
PayloadTooLarge(u16),
|
||||
#[error("CRC mismatch: got 0x{got:08X}, want 0x{want:08X}")]
|
||||
CrcMismatch { got: u32, want: u32 },
|
||||
#[error("unknown role id: {0}")]
|
||||
UnknownRole(u8),
|
||||
#[error("unknown msg type: 0x{0:02X}")]
|
||||
UnknownMsgType(u8),
|
||||
#[error("unknown auth class: {0}")]
|
||||
UnknownAuth(u8),
|
||||
#[error("payload size mismatch for {which}: got {got}, want {want}")]
|
||||
PayloadSizeMismatch { which: &'static str, got: usize, want: usize },
|
||||
}
|
||||
|
||||
/// IEEE CRC32 — matches the bit-by-bit implementation in
|
||||
/// `rv_feature_state.c`. Poly 0xEDB88320, init 0xFFFFFFFF, xor out.
|
||||
pub fn crc32_ieee(data: &[u8]) -> u32 {
|
||||
let mut crc: u32 = 0xFFFF_FFFF;
|
||||
for &b in data {
|
||||
crc ^= b as u32;
|
||||
for _ in 0..8 {
|
||||
let mask = (crc & 1).wrapping_neg();
|
||||
crc = (crc >> 1) ^ (0xEDB8_8320 & mask);
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
/// Parse one mesh frame. Returns the decoded header and a slice view of
|
||||
/// the payload inside the input buffer (no copy).
|
||||
pub fn decode_mesh(buf: &[u8]) -> Result<(MeshHeader, &[u8]), MeshError> {
|
||||
if buf.len() < MESH_HEADER_SIZE + 4 {
|
||||
return Err(MeshError::TooShort(buf.len()));
|
||||
}
|
||||
|
||||
let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
|
||||
if magic != MESH_MAGIC { return Err(MeshError::BadMagic(magic)); }
|
||||
|
||||
let version = buf[4];
|
||||
if version != MESH_VERSION { return Err(MeshError::BadVersion(version)); }
|
||||
|
||||
let ty = buf[5];
|
||||
let sender_role = buf[6];
|
||||
let auth_class = buf[7];
|
||||
let epoch = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
|
||||
let payload_len = u16::from_le_bytes([buf[12], buf[13]]);
|
||||
|
||||
if payload_len as usize > MESH_MAX_PAYLOAD {
|
||||
return Err(MeshError::PayloadTooLarge(payload_len));
|
||||
}
|
||||
|
||||
let total = MESH_HEADER_SIZE + payload_len as usize + 4;
|
||||
if buf.len() < total { return Err(MeshError::TooShort(buf.len())); }
|
||||
|
||||
let want_crc = crc32_ieee(&buf[..MESH_HEADER_SIZE + payload_len as usize]);
|
||||
let crc_off = MESH_HEADER_SIZE + payload_len as usize;
|
||||
let got_crc = u32::from_le_bytes([
|
||||
buf[crc_off], buf[crc_off + 1], buf[crc_off + 2], buf[crc_off + 3],
|
||||
]);
|
||||
if got_crc != want_crc {
|
||||
return Err(MeshError::CrcMismatch { got: got_crc, want: want_crc });
|
||||
}
|
||||
|
||||
let msg_type = MeshMsgType::try_from(ty)?;
|
||||
let sender_role = MeshRole::try_from(sender_role)?;
|
||||
let auth_class = match auth_class {
|
||||
0 => AuthClass::None,
|
||||
1 => AuthClass::HmacSession,
|
||||
2 => AuthClass::Ed25519Batch,
|
||||
v => return Err(MeshError::UnknownAuth(v)),
|
||||
};
|
||||
|
||||
Ok((
|
||||
MeshHeader { msg_type, sender_role, auth_class, epoch, payload_len },
|
||||
&buf[MESH_HEADER_SIZE .. MESH_HEADER_SIZE + payload_len as usize],
|
||||
))
|
||||
}
|
||||
|
||||
/// Decode a `HEALTH` payload (28 bytes).
|
||||
pub fn decode_node_status(p: &[u8]) -> Result<NodeStatus, MeshError> {
|
||||
if p.len() != 28 {
|
||||
return Err(MeshError::PayloadSizeMismatch {
|
||||
which: "HEALTH", got: p.len(), want: 28,
|
||||
});
|
||||
}
|
||||
let mut node_id = [0u8; 8];
|
||||
node_id.copy_from_slice(&p[0..8]);
|
||||
let local_time_us = u64::from_le_bytes([
|
||||
p[8], p[9], p[10], p[11], p[12], p[13], p[14], p[15],
|
||||
]);
|
||||
Ok(NodeStatus {
|
||||
node_id,
|
||||
local_time_us,
|
||||
role: MeshRole::try_from(p[16])?,
|
||||
current_channel: p[17],
|
||||
current_bw: p[18],
|
||||
noise_floor_dbm: p[19] as i8,
|
||||
pkt_yield: u16::from_le_bytes([p[20], p[21]]),
|
||||
sync_error_us: u16::from_le_bytes([p[22], p[23]]),
|
||||
health_flags: u16::from_le_bytes([p[24], p[25]]),
|
||||
})
|
||||
}
|
||||
|
||||
/// Decode an `ANOMALY_ALERT` payload (28 bytes).
|
||||
pub fn decode_anomaly_alert(p: &[u8]) -> Result<AnomalyAlert, MeshError> {
|
||||
if p.len() != 28 {
|
||||
return Err(MeshError::PayloadSizeMismatch {
|
||||
which: "ANOMALY_ALERT", got: p.len(), want: 28,
|
||||
});
|
||||
}
|
||||
let mut node_id = [0u8; 8];
|
||||
node_id.copy_from_slice(&p[0..8]);
|
||||
let ts_us = u64::from_le_bytes([
|
||||
p[8], p[9], p[10], p[11], p[12], p[13], p[14], p[15],
|
||||
]);
|
||||
let anomaly_score = f32::from_le_bytes([p[20], p[21], p[22], p[23]]);
|
||||
let motion_score = f32::from_le_bytes([p[24], p[25], p[26], p[27]]);
|
||||
Ok(AnomalyAlert {
|
||||
node_id, ts_us,
|
||||
severity: p[16],
|
||||
reason: p[17],
|
||||
anomaly_score, motion_score,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode a `HEALTH` payload. Produces the 16-byte header, 28-byte
|
||||
/// payload, and 4-byte CRC — bit-identical to what the firmware emits.
|
||||
pub fn encode_health(
|
||||
sender_role: MeshRole,
|
||||
epoch: u32,
|
||||
status: &NodeStatus,
|
||||
) -> Vec<u8> {
|
||||
let payload_len: u16 = 28;
|
||||
let mut buf = Vec::with_capacity(MESH_HEADER_SIZE + payload_len as usize + 4);
|
||||
|
||||
// header
|
||||
buf.extend_from_slice(&MESH_MAGIC.to_le_bytes());
|
||||
buf.push(MESH_VERSION);
|
||||
buf.push(MeshMsgType::Health as u8);
|
||||
buf.push(sender_role as u8);
|
||||
buf.push(AuthClass::None as u8);
|
||||
buf.extend_from_slice(&epoch.to_le_bytes());
|
||||
buf.extend_from_slice(&payload_len.to_le_bytes());
|
||||
buf.extend_from_slice(&0u16.to_le_bytes()); // reserved
|
||||
|
||||
// payload
|
||||
buf.extend_from_slice(&status.node_id);
|
||||
buf.extend_from_slice(&status.local_time_us.to_le_bytes());
|
||||
buf.push(status.role as u8);
|
||||
buf.push(status.current_channel);
|
||||
buf.push(status.current_bw);
|
||||
buf.push(status.noise_floor_dbm as u8);
|
||||
buf.extend_from_slice(&status.pkt_yield.to_le_bytes());
|
||||
buf.extend_from_slice(&status.sync_error_us.to_le_bytes());
|
||||
buf.extend_from_slice(&status.health_flags.to_le_bytes());
|
||||
buf.extend_from_slice(&0u16.to_le_bytes()); // reserved
|
||||
|
||||
let crc = crc32_ieee(&buf);
|
||||
buf.extend_from_slice(&crc.to_le_bytes());
|
||||
buf
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mock_radio_tracks_calls() {
|
||||
let mut r = MockRadio::default();
|
||||
assert!(r.init().is_ok());
|
||||
assert_eq!(r.init_count, 1);
|
||||
r.set_channel(6, 20).unwrap();
|
||||
r.set_capture_profile(CaptureProfile::FastMotion).unwrap();
|
||||
r.set_mode(RadioMode::ActiveProbe).unwrap();
|
||||
r.set_csi_enabled(true).unwrap();
|
||||
assert_eq!(r.channel_calls, vec![(6, 20)]);
|
||||
assert_eq!(r.profile_calls, vec![CaptureProfile::FastMotion]);
|
||||
assert_eq!(r.mode_calls, vec![RadioMode::ActiveProbe]);
|
||||
assert!(r.csi_enabled);
|
||||
let h = r.get_health().unwrap();
|
||||
assert_eq!(h.current_channel, 6);
|
||||
assert_eq!(h.current_bw_mhz, 20);
|
||||
assert_eq!(h.current_profile, CaptureProfile::FastMotion as u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc32_matches_firmware_vectors() {
|
||||
// Same vectors as test_rv_feature_state.c
|
||||
assert_eq!(crc32_ieee(b"123456789"), 0xCBF43926);
|
||||
assert_eq!(crc32_ieee(&[]), 0x00000000);
|
||||
assert_eq!(crc32_ieee(&[0u8]), 0xD202EF8D);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn health_roundtrip() {
|
||||
let st = NodeStatus {
|
||||
node_id: [9, 0, 0, 0, 0, 0, 0, 0],
|
||||
local_time_us: 42_000_000,
|
||||
role: MeshRole::Observer,
|
||||
current_channel: 11,
|
||||
current_bw: 20,
|
||||
noise_floor_dbm: -95,
|
||||
pkt_yield: 20,
|
||||
sync_error_us: 7,
|
||||
health_flags: 0x0001,
|
||||
};
|
||||
|
||||
let wire = encode_health(MeshRole::Observer, 5, &st);
|
||||
assert_eq!(wire.len(), MESH_HEADER_SIZE + 28 + 4);
|
||||
assert_eq!(wire.len(), 48);
|
||||
|
||||
let (hdr, payload) = decode_mesh(&wire).expect("decode");
|
||||
assert_eq!(hdr.msg_type, MeshMsgType::Health);
|
||||
assert_eq!(hdr.sender_role, MeshRole::Observer);
|
||||
assert_eq!(hdr.epoch, 5);
|
||||
assert_eq!(hdr.payload_len, 28);
|
||||
|
||||
let back = decode_node_status(payload).expect("payload decode");
|
||||
assert_eq!(back, st);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_bad_crc() {
|
||||
let st = NodeStatus {
|
||||
node_id: [1, 0, 0, 0, 0, 0, 0, 0],
|
||||
local_time_us: 0,
|
||||
role: MeshRole::Observer,
|
||||
current_channel: 1,
|
||||
current_bw: 20,
|
||||
noise_floor_dbm: -90,
|
||||
pkt_yield: 0,
|
||||
sync_error_us: 0,
|
||||
health_flags: 0,
|
||||
};
|
||||
let mut wire = encode_health(MeshRole::Observer, 0, &st);
|
||||
let p0 = MESH_HEADER_SIZE; // first payload byte
|
||||
wire[p0] ^= 0xFF;
|
||||
let err = decode_mesh(&wire).unwrap_err();
|
||||
assert!(matches!(err, MeshError::CrcMismatch { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_bad_magic() {
|
||||
let buf = [0u8; MESH_HEADER_SIZE + 4];
|
||||
let err = decode_mesh(&buf).unwrap_err();
|
||||
assert!(matches!(err, MeshError::BadMagic(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_short() {
|
||||
let buf = [0u8; 3];
|
||||
let err = decode_mesh(&buf).unwrap_err();
|
||||
assert!(matches!(err, MeshError::TooShort(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_are_bidirectional() {
|
||||
for p in [
|
||||
CaptureProfile::PassiveLowRate,
|
||||
CaptureProfile::ActiveProbe,
|
||||
CaptureProfile::RespHighSens,
|
||||
CaptureProfile::FastMotion,
|
||||
CaptureProfile::Calibration,
|
||||
] {
|
||||
let v = p as u8;
|
||||
assert_eq!(CaptureProfile::try_from(v).unwrap(), p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_constants_match_firmware() {
|
||||
// These must match rv_mesh.h byte-for-byte.
|
||||
assert_eq!(MESH_MAGIC, 0xC511_8100);
|
||||
assert_eq!(MESH_VERSION, 1);
|
||||
assert_eq!(MESH_HEADER_SIZE, 16);
|
||||
assert_eq!(MESH_MAX_PAYLOAD, 256);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,26 @@
|
|||
//!
|
||||
//! Periodically summarizes the sensor pipeline state and stores it
|
||||
//! as brain memories for the agent to reason about.
|
||||
//!
|
||||
//! The brain URL is read from the `RUVIEW_BRAIN_URL` env var on first use,
|
||||
//! defaulting to `http://127.0.0.1:9876`.
|
||||
|
||||
use crate::csi_pipeline::PipelineOutput;
|
||||
use anyhow::Result;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const BRAIN_URL: &str = "http://127.0.0.1:9876";
|
||||
/// Default brain URL if `RUVIEW_BRAIN_URL` is not set.
|
||||
const DEFAULT_BRAIN_URL: &str = "http://127.0.0.1:9876";
|
||||
|
||||
fn brain_url() -> &'static str {
|
||||
static BRAIN_URL: OnceLock<String> = OnceLock::new();
|
||||
BRAIN_URL.get_or_init(|| {
|
||||
let url = std::env::var("RUVIEW_BRAIN_URL")
|
||||
.unwrap_or_else(|_| DEFAULT_BRAIN_URL.to_string());
|
||||
eprintln!(" brain_bridge: using brain URL {url}");
|
||||
url
|
||||
})
|
||||
}
|
||||
|
||||
/// Store a spatial observation in the brain.
|
||||
async fn store_memory(category: &str, content: &str) -> Result<()> {
|
||||
|
|
@ -19,7 +34,7 @@ async fn store_memory(category: &str, content: &str) -> Result<()> {
|
|||
"content": content,
|
||||
});
|
||||
|
||||
client.post(format!("{BRAIN_URL}/memories"))
|
||||
client.post(format!("{}/memories", brain_url()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
|
@ -75,16 +90,3 @@ pub async fn sync_to_brain(pipeline: &PipelineOutput, camera_frames: u64) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Check if brain is reachable.
|
||||
pub async fn brain_available() -> bool {
|
||||
reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
.ok()
|
||||
.and_then(|c| {
|
||||
tokio::runtime::Handle::current().block_on(async {
|
||||
c.get(format!("{BRAIN_URL}/health")).send().await.ok()
|
||||
})
|
||||
})
|
||||
.is_some()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
//! Camera capture — cross-platform frame grabber.
|
||||
#![allow(dead_code)]
|
||||
//!
|
||||
//! macOS: uses `screencapture` or `ffmpeg -f avfoundation` for camera frames
|
||||
//! Linux: uses `v4l2-ctl` or `ffmpeg -f v4l2` for camera frames
|
||||
|
|
@ -14,7 +13,6 @@ pub struct Frame {
|
|||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub rgb: Vec<u8>, // row-major [height * width * 3]
|
||||
pub timestamp_ms: i64,
|
||||
}
|
||||
|
||||
/// Camera source configuration.
|
||||
|
|
@ -96,7 +94,6 @@ fn capture_ffmpeg(config: &CameraConfig, tmp: &PathBuf) -> Result<Frame> {
|
|||
width: config.width,
|
||||
height: config.height,
|
||||
rgb: rgb[..expected].to_vec(),
|
||||
timestamp_ms: chrono::Utc::now().timestamp_millis(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -170,7 +167,6 @@ fn decode_jpeg_to_rgb(path: &PathBuf, _width: u32, _height: u32) -> Result<Frame
|
|||
width: _width,
|
||||
height: _height,
|
||||
rgb: data,
|
||||
timestamp_ms: chrono::Utc::now().timestamp_millis(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,189 +0,0 @@
|
|||
//! WiFi CSI receiver — ingests CSI frames from ESP32 nodes.
|
||||
//!
|
||||
//! ESP32 nodes send CSI data via UDP. This module receives the frames,
|
||||
//! runs RF tomography, and produces OccupancyVolume for fusion.
|
||||
//!
|
||||
//! Protocol:
|
||||
//! ESP32 → serial → host (ruvzen) → UDP broadcast → this receiver
|
||||
//! Each packet: JSON with {mac, rssi, csi_data: [i8], timestamp_ms}
|
||||
|
||||
use crate::fusion::OccupancyVolume;
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Raw CSI frame from an ESP32 node.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CsiFrame {
|
||||
pub mac: String,
|
||||
pub rssi: i8,
|
||||
pub timestamp_ms: i64,
|
||||
pub channel: u8,
|
||||
pub bandwidth: u8,
|
||||
/// CSI subcarrier amplitudes (typically 52-114 values)
|
||||
pub csi_data: Vec<i8>,
|
||||
/// Optional: secondary stream (imaginary part)
|
||||
#[serde(default)]
|
||||
pub csi_imag: Vec<i8>,
|
||||
}
|
||||
|
||||
/// CSI link — a pair of TX/RX nodes with accumulated frames.
|
||||
#[derive(Debug)]
|
||||
pub struct CsiLink {
|
||||
pub tx_mac: String,
|
||||
pub rx_mac: String,
|
||||
pub frames: VecDeque<CsiFrame>,
|
||||
pub attenuation: f64, // current estimated attenuation
|
||||
}
|
||||
|
||||
/// CSI receiver — listens on UDP and accumulates frames.
|
||||
pub struct CsiReceiver {
|
||||
pub links: Arc<Mutex<Vec<CsiLink>>>,
|
||||
pub frame_count: Arc<Mutex<u64>>,
|
||||
bind_addr: String,
|
||||
}
|
||||
|
||||
impl CsiReceiver {
|
||||
pub fn new(bind_addr: &str) -> Self {
|
||||
Self {
|
||||
links: Arc::new(Mutex::new(Vec::new())),
|
||||
frame_count: Arc::new(Mutex::new(0)),
|
||||
bind_addr: bind_addr.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start receiving CSI frames in a background thread.
|
||||
pub fn start(&self) -> Result<()> {
|
||||
let socket = UdpSocket::bind(&self.bind_addr)?;
|
||||
socket.set_read_timeout(Some(std::time::Duration::from_secs(1)))?;
|
||||
eprintln!(" CSI receiver listening on {}", self.bind_addr);
|
||||
|
||||
let links = self.links.clone();
|
||||
let count = self.frame_count.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match socket.recv_from(&mut buf) {
|
||||
Ok((n, _addr)) => {
|
||||
if let Ok(frame) = serde_json::from_slice::<CsiFrame>(&buf[..n]) {
|
||||
process_frame(&links, &count, frame);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current occupancy volume from accumulated CSI data.
|
||||
pub fn get_occupancy(&self) -> OccupancyVolume {
|
||||
let links = self.links.lock().unwrap();
|
||||
|
||||
if links.is_empty() {
|
||||
return crate::fusion::demo_occupancy();
|
||||
}
|
||||
|
||||
// Extract per-link attenuations for tomography
|
||||
let attenuations: Vec<f64> = links.iter().map(|l| l.attenuation).collect();
|
||||
let _n_links = attenuations.len();
|
||||
|
||||
// Simple grid-based tomography (ISTA solver would go here)
|
||||
let nx = 8;
|
||||
let ny = 8;
|
||||
let nz = 4;
|
||||
let total = nx * ny * nz;
|
||||
let mut densities = vec![0.0f64; total];
|
||||
|
||||
// For each link, distribute attenuation along the line between TX and RX
|
||||
// This is a simplified backprojection — real tomography uses ISTA L1 solver
|
||||
for (_i, atten) in attenuations.iter().enumerate() {
|
||||
// Distribute attenuation uniformly across voxels
|
||||
// (in production, use link geometry for proper ray tracing)
|
||||
let contribution = atten / total as f64;
|
||||
for d in &mut densities {
|
||||
*d += contribution;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize
|
||||
let max = densities.iter().cloned().fold(0.0f64, f64::max);
|
||||
if max > 0.0 {
|
||||
for d in &mut densities { *d /= max; }
|
||||
}
|
||||
|
||||
let occupied_count = densities.iter().filter(|&&d| d > 0.3).count();
|
||||
|
||||
OccupancyVolume {
|
||||
densities,
|
||||
nx, ny, nz,
|
||||
bounds: [0.0, 0.0, 0.0, 5.0, 5.0, 3.0],
|
||||
occupied_count,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn frame_count(&self) -> u64 {
|
||||
*self.frame_count.lock().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn process_frame(
|
||||
links: &Arc<Mutex<Vec<CsiLink>>>,
|
||||
count: &Arc<Mutex<u64>>,
|
||||
frame: CsiFrame,
|
||||
) {
|
||||
// Calculate attenuation from RSSI + CSI amplitude
|
||||
let csi_power: f64 = frame.csi_data.iter()
|
||||
.map(|&v| (v as f64).powi(2))
|
||||
.sum::<f64>() / frame.csi_data.len().max(1) as f64;
|
||||
let attenuation = -(frame.rssi as f64) + csi_power.sqrt() * 0.1;
|
||||
|
||||
let mut links = links.lock().unwrap();
|
||||
|
||||
// Find or create link for this MAC
|
||||
let link = links.iter_mut().find(|l| l.tx_mac == frame.mac);
|
||||
if let Some(link) = link {
|
||||
link.attenuation = link.attenuation * 0.9 + attenuation * 0.1; // EMA
|
||||
link.frames.push_back(frame);
|
||||
if link.frames.len() > 100 { link.frames.pop_front(); }
|
||||
} else {
|
||||
let mut frames = VecDeque::new();
|
||||
frames.push_back(frame.clone());
|
||||
links.push(CsiLink {
|
||||
tx_mac: frame.mac,
|
||||
rx_mac: "receiver".to_string(),
|
||||
frames,
|
||||
attenuation,
|
||||
});
|
||||
}
|
||||
|
||||
*count.lock().unwrap() += 1;
|
||||
}
|
||||
|
||||
/// Send CSI frames via UDP (for testing — simulates ESP32 nodes).
|
||||
pub fn send_test_frames(target: &str, count: usize) -> Result<()> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")?;
|
||||
|
||||
for i in 0..count {
|
||||
let frame = CsiFrame {
|
||||
mac: format!("AA:BB:CC:DD:EE:{:02X}", i % 4),
|
||||
rssi: -40 - (i % 30) as i8,
|
||||
timestamp_ms: chrono::Utc::now().timestamp_millis(),
|
||||
channel: 6,
|
||||
bandwidth: 20,
|
||||
csi_data: (0..56).map(|j| ((i + j) % 128) as i8 - 64).collect(),
|
||||
csi_imag: Vec::new(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_vec(&frame)?;
|
||||
socket.send_to(&json, target)?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1,76 +1,22 @@
|
|||
//! Complete CSI processing pipeline — ADR-018 parser → WiFlow pose → vitals → tomography.
|
||||
#![allow(dead_code)]
|
||||
//! Complete CSI processing pipeline — ADR-018 parser → heuristic pose → vitals → tomography.
|
||||
//!
|
||||
//! Receives raw UDP frames from ESP32 nodes, extracts I/Q subcarrier data,
|
||||
//! runs the WiFlow pose model, detects motion, estimates vitals, and produces
|
||||
//! 3D occupancy + skeleton for fusion with camera depth.
|
||||
//! detects motion, estimates vitals, and produces 3D occupancy + skeleton
|
||||
//! for fusion with camera depth.
|
||||
//!
|
||||
//! **Note on pose**: the pose estimator here is an amplitude-energy
|
||||
//! heuristic — NOT a trained WiFlow model. See
|
||||
//! [`CsiPipelineState::heuristic_pose_from_amplitude`] for the exact shape.
|
||||
//! A real WiFlow integration requires loading and running the TCN weights,
|
||||
//! which this crate does not currently do.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::net::UdpSocket;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
// ─── ADR-018 Binary Frame Parser ────────────────────────────────────────────
|
||||
|
||||
const CSI_MAGIC_V6: u32 = 0xC511_0006;
|
||||
const CSI_MAGIC_V1: u32 = 0xC511_0001;
|
||||
const CSI_HEADER_SIZE: usize = 20;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CsiFrame {
|
||||
pub node_id: u8,
|
||||
pub n_antennas: u8,
|
||||
pub n_subcarriers: u16,
|
||||
pub channel: u8,
|
||||
pub rssi: i8,
|
||||
pub noise_floor: i8,
|
||||
pub timestamp_us: u32,
|
||||
/// Raw I/Q data: [I0, Q0, I1, Q1, ...] for each subcarrier
|
||||
pub iq_data: Vec<i8>,
|
||||
/// Computed amplitude per subcarrier: sqrt(I^2 + Q^2)
|
||||
pub amplitudes: Vec<f32>,
|
||||
/// Computed phase per subcarrier: atan2(Q, I)
|
||||
pub phases: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Parse an ADR-018 binary CSI frame from UDP packet.
|
||||
pub fn parse_adr018(data: &[u8]) -> Option<CsiFrame> {
|
||||
if data.len() < CSI_HEADER_SIZE { return None; }
|
||||
|
||||
let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
if magic != CSI_MAGIC_V6 && magic != CSI_MAGIC_V1 { return None; }
|
||||
|
||||
let node_id = data[4];
|
||||
let n_antennas = data[5].max(1);
|
||||
let n_subcarriers = u16::from_le_bytes([data[6], data[7]]);
|
||||
let channel = data[8];
|
||||
let rssi = data[9] as i8;
|
||||
let noise_floor = data[10] as i8;
|
||||
let timestamp_us = u32::from_le_bytes([data[16], data[17], data[18], data[19]]);
|
||||
|
||||
let iq_len = (n_subcarriers as usize) * 2 * (n_antennas as usize);
|
||||
if data.len() < CSI_HEADER_SIZE + iq_len { return None; }
|
||||
|
||||
let iq_data: Vec<i8> = data[CSI_HEADER_SIZE..CSI_HEADER_SIZE + iq_len]
|
||||
.iter().map(|&b| b as i8).collect();
|
||||
|
||||
// Compute amplitude and phase per subcarrier (first antenna)
|
||||
let mut amplitudes = Vec::with_capacity(n_subcarriers as usize);
|
||||
let mut phases = Vec::with_capacity(n_subcarriers as usize);
|
||||
for i in 0..n_subcarriers as usize {
|
||||
let idx = i * 2;
|
||||
if idx + 1 < iq_data.len() {
|
||||
let ii = iq_data[idx] as f32;
|
||||
let qq = iq_data[idx + 1] as f32;
|
||||
amplitudes.push((ii * ii + qq * qq).sqrt());
|
||||
phases.push(qq.atan2(ii));
|
||||
}
|
||||
}
|
||||
|
||||
Some(CsiFrame {
|
||||
node_id, n_antennas, n_subcarriers, channel, rssi, noise_floor,
|
||||
timestamp_us, iq_data, amplitudes, phases,
|
||||
})
|
||||
}
|
||||
// ADR-018 parser moved to src/parser.rs. Re-export here so downstream code
|
||||
// (and the reviewer's referenced public API) keeps working unchanged.
|
||||
pub use crate::parser::{parse_adr018, CsiFrame};
|
||||
|
||||
// ─── CSI Fingerprint Database ──────────────────────────────────────────────
|
||||
|
||||
|
|
@ -83,7 +29,7 @@ pub struct CsiFingerprint {
|
|||
pub samples: u32,
|
||||
}
|
||||
|
||||
// ─── CSI State — accumulates frames for WiFlow + vitals ─────────────────────
|
||||
// ─── CSI State — accumulates frames for heuristic pose + vitals ───────────
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Skeleton {
|
||||
|
|
@ -102,7 +48,7 @@ pub struct VitalSigns {
|
|||
pub struct CsiPipelineState {
|
||||
/// Per-node frame history (node_id → last N frames)
|
||||
pub node_frames: std::collections::HashMap<u8, VecDeque<CsiFrame>>,
|
||||
/// Latest skeleton from WiFlow
|
||||
/// Latest skeleton from the amplitude-energy heuristic (NOT ML-derived)
|
||||
pub skeleton: Option<Skeleton>,
|
||||
/// Latest vital signs
|
||||
pub vitals: VitalSigns,
|
||||
|
|
@ -119,21 +65,18 @@ pub struct CsiPipelineState {
|
|||
pub current_location: Option<(String, f32)>,
|
||||
/// Night mode — true when camera luminance is below threshold
|
||||
pub is_dark: bool,
|
||||
/// WiFlow model weights (loaded once)
|
||||
wiflow_weights: Option<WiFlowModel>,
|
||||
/// Metadata from the on-disk WiFlow JSON, if one is present. NOTE: the
|
||||
/// weights themselves are NOT loaded or executed in this crate — this
|
||||
/// flag merely enables the amplitude-energy heuristic pose code path.
|
||||
pose_model_present: Option<PoseModelMetadata>,
|
||||
}
|
||||
|
||||
struct WiFlowModel {
|
||||
/// TCN weights from wiflow-v1.json (simplified inference)
|
||||
conv1_w: Vec<f32>,
|
||||
conv1_b: Vec<f32>,
|
||||
conv2_w: Vec<f32>,
|
||||
conv2_b: Vec<f32>,
|
||||
fc_w: Vec<f32>,
|
||||
fc_b: Vec<f32>,
|
||||
input_subcarriers: usize,
|
||||
time_steps: usize,
|
||||
}
|
||||
/// Placeholder tag indicating the `wiflow-v1.json` file is present on disk.
|
||||
/// This does NOT contain real TCN weights — the actual pose estimator in
|
||||
/// this crate is an amplitude-energy heuristic, not a neural network. The
|
||||
/// struct itself is empty; we only care whether it exists (`Option::Some`
|
||||
/// means "heuristic enabled").
|
||||
struct PoseModelMetadata;
|
||||
|
||||
impl Default for CsiPipelineState {
|
||||
fn default() -> Self {
|
||||
|
|
@ -148,14 +91,19 @@ impl Default for CsiPipelineState {
|
|||
fingerprints: Vec::new(),
|
||||
current_location: None,
|
||||
is_dark: false,
|
||||
wiflow_weights: load_wiflow_model(),
|
||||
pose_model_present: detect_pose_model_metadata(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── WiFlow Model Loading ───────────────────────────────────────────────────
|
||||
// ─── Pose Model Metadata Probe ──────────────────────────────────────────────
|
||||
//
|
||||
// NOTE: This only reads the shape metadata from `wiflow-v1.json` on disk.
|
||||
// The weights are NOT loaded or evaluated. The actual pose used by this
|
||||
// crate is an amplitude-energy heuristic (see
|
||||
// `heuristic_pose_from_amplitude`), not WiFlow.
|
||||
|
||||
fn load_wiflow_model() -> Option<WiFlowModel> {
|
||||
fn detect_pose_model_metadata() -> Option<PoseModelMetadata> {
|
||||
let paths = [
|
||||
"/tmp/ruview-firmware/wiflow-v1.json",
|
||||
"~/.local/share/ruview/wiflow-v1.json",
|
||||
|
|
@ -164,22 +112,17 @@ fn load_wiflow_model() -> Option<WiFlowModel> {
|
|||
let expanded = p.replace('~', &std::env::var("HOME").unwrap_or_default());
|
||||
if let Ok(data) = std::fs::read_to_string(&expanded) {
|
||||
if let Ok(model) = serde_json::from_str::<serde_json::Value>(&data) {
|
||||
if let Some(_weights_b64) = model.get("weightsBase64").and_then(|v| v.as_str()) {
|
||||
eprintln!(" WiFlow: loaded from {expanded} ({} params)",
|
||||
model.get("totalParams").and_then(|v| v.as_u64()).unwrap_or(0));
|
||||
// For now, use simplified inference — full weight parsing would go here
|
||||
return Some(WiFlowModel {
|
||||
conv1_w: Vec::new(), conv1_b: Vec::new(),
|
||||
conv2_w: Vec::new(), conv2_b: Vec::new(),
|
||||
fc_w: Vec::new(), fc_b: Vec::new(),
|
||||
input_subcarriers: 35,
|
||||
time_steps: 20,
|
||||
});
|
||||
if model.get("weightsBase64").and_then(|v| v.as_str()).is_some() {
|
||||
eprintln!(
|
||||
" pose: amplitude-energy heuristic enabled (metadata from {expanded}, {} params — weights NOT loaded)",
|
||||
model.get("totalParams").and_then(|v| v.as_u64()).unwrap_or(0)
|
||||
);
|
||||
return Some(PoseModelMetadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!(" WiFlow: model not found");
|
||||
eprintln!(" pose: amplitude-energy heuristic disabled (no metadata file found)");
|
||||
None
|
||||
}
|
||||
|
||||
|
|
@ -191,6 +134,24 @@ impl CsiPipelineState {
|
|||
let node_id = frame.node_id;
|
||||
self.total_frames += 1;
|
||||
|
||||
// Once every 500 frames log a one-line node stats summary. This keeps
|
||||
// us honest about the CSI shape we are actually receiving and also
|
||||
// guarantees every public `CsiFrame` field is read on the runtime
|
||||
// path, not only in tests.
|
||||
if self.total_frames % 500 == 0 {
|
||||
eprintln!(
|
||||
" CSI node={} ch={} ant={} sub={} rssi={} nf={} ts_us={} iq_bytes={}",
|
||||
frame.node_id,
|
||||
frame.channel,
|
||||
frame.n_antennas,
|
||||
frame.n_subcarriers,
|
||||
frame.rssi,
|
||||
frame.noise_floor,
|
||||
frame.timestamp_us,
|
||||
frame.iq_data.len(),
|
||||
);
|
||||
}
|
||||
|
||||
// Store frame in per-node history
|
||||
{
|
||||
let history = self.node_frames.entry(node_id).or_insert_with(|| VecDeque::with_capacity(100));
|
||||
|
|
@ -207,9 +168,9 @@ impl CsiPipelineState {
|
|||
self.estimate_vitals(node_id);
|
||||
}
|
||||
|
||||
// 3. WiFlow pose estimation (every 20 frames = 1 second at ~20fps)
|
||||
// 3. Heuristic pose estimation (every 20 frames = 1 second at ~20fps)
|
||||
if self.total_frames % 20 == 0 {
|
||||
self.estimate_pose();
|
||||
self.heuristic_pose_from_amplitude();
|
||||
}
|
||||
|
||||
// 4. RF tomography (update occupancy grid)
|
||||
|
|
@ -274,8 +235,17 @@ impl CsiPipelineState {
|
|||
}
|
||||
}
|
||||
|
||||
fn estimate_pose(&mut self) {
|
||||
if self.wiflow_weights.is_none() { return; }
|
||||
/// STUB: not real WiFlow inference; returns an amplitude-energy heuristic
|
||||
/// "pose" built by bucketing CSI subcarrier energy into 17 fake keypoints.
|
||||
///
|
||||
/// This exists so the downstream viewer has something to render while the
|
||||
/// real WiFlow TCN integration is being wired up. The output should NOT
|
||||
/// be interpreted as an ML-derived skeleton — confidence here is just
|
||||
/// amplitude variance, keypoint x is subcarrier energy, y is the
|
||||
/// keypoint index. Callers that need real pose must use the (yet to be
|
||||
/// wired) WiFlow model directly.
|
||||
fn heuristic_pose_from_amplitude(&mut self) {
|
||||
if self.pose_model_present.is_none() { return; }
|
||||
|
||||
// Collect 20 frames from the primary node
|
||||
let primary_node = self.node_frames.keys().next().copied();
|
||||
|
|
@ -284,8 +254,9 @@ impl CsiPipelineState {
|
|||
let frames: Vec<&CsiFrame> = history.iter().rev().take(20).collect();
|
||||
if frames.len() < 20 { return; }
|
||||
|
||||
// Build input: 35 subcarriers × 20 time steps
|
||||
// Select top 35 subcarriers by variance (ruvector-solver O6)
|
||||
// Build input: 35 subcarriers × 20 time steps. This is a
|
||||
// deliberately simple summary used to compute amplitude
|
||||
// variance; it is NOT fed through any neural network.
|
||||
let n_sub = frames[0].amplitudes.len().min(35);
|
||||
let mut input = vec![0.0f32; 35 * 20];
|
||||
for (t, frame) in frames.iter().rev().enumerate().take(20) {
|
||||
|
|
@ -294,23 +265,21 @@ impl CsiPipelineState {
|
|||
}
|
||||
}
|
||||
|
||||
// Simplified WiFlow inference (without full weight loading)
|
||||
// Generate estimated keypoints based on CSI signal statistics
|
||||
let mean_amp = input.iter().sum::<f32>() / input.len() as f32;
|
||||
let amp_var = input.iter().map(|a| (a - mean_amp).powi(2)).sum::<f32>() / input.len() as f32;
|
||||
|
||||
// If motion detected, generate pose estimate from signal characteristics
|
||||
// If motion detected, emit a placeholder skeleton derived from
|
||||
// signal characteristics. NOT a real pose.
|
||||
if self.motion_detected {
|
||||
let mut keypoints = vec![[0.5f32; 2]; 17];
|
||||
// Distribute keypoints based on CSI energy distribution across subcarriers
|
||||
for (i, kp) in keypoints.iter_mut().enumerate() {
|
||||
let sub_range = (i * n_sub / 17)..((i + 1) * n_sub / 17).min(n_sub);
|
||||
let energy: f32 = sub_range.clone()
|
||||
.filter_map(|s| frames.last().and_then(|f| f.amplitudes.get(s)))
|
||||
.sum();
|
||||
let norm_energy = energy / (sub_range.len().max(1) as f32 * 128.0);
|
||||
kp[0] = 0.3 + norm_energy * 0.4; // x
|
||||
kp[1] = (i as f32 / 17.0) * 0.8 + 0.1; // y (head to feet)
|
||||
kp[0] = 0.3 + norm_energy * 0.4; // x: subcarrier energy
|
||||
kp[1] = (i as f32 / 17.0) * 0.8 + 0.1; // y: keypoint index
|
||||
}
|
||||
self.skeleton = Some(Skeleton {
|
||||
keypoints,
|
||||
|
|
@ -517,10 +486,30 @@ fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
|
|||
// ─── UDP Receiver ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Start the complete CSI pipeline — UDP receiver + processing.
|
||||
///
|
||||
/// Architecture (two threads, one std mpsc channel):
|
||||
///
|
||||
/// ```text
|
||||
/// UDP thread Processor thread
|
||||
/// ┌──────────────┐ mpsc::Sender ┌────────────────────┐
|
||||
/// │ recv_from() │ ─────────────► │ recv() CsiFrame │
|
||||
/// │ parse_adr018 │ (bounded-ish │ lock, process_frame│
|
||||
/// └──────────────┘ by channel) │ unlock │
|
||||
/// └────────────────────┘
|
||||
/// ```
|
||||
///
|
||||
/// This decouples the socket from the shared state: the UDP thread only
|
||||
/// touches the channel, never the mutex. The HTTP API handlers (which call
|
||||
/// `get_pipeline_output`) therefore only contend with the processor thread
|
||||
/// for brief periods, not with every incoming packet. Heavy work (pose,
|
||||
/// tomography, fingerprinting) runs outside the lock.
|
||||
pub fn start_pipeline(bind_addr: &str) -> Arc<Mutex<CsiPipelineState>> {
|
||||
let state = Arc::new(Mutex::new(CsiPipelineState::default()));
|
||||
let st = state.clone();
|
||||
let processor_state = state.clone();
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel::<CsiFrame>();
|
||||
|
||||
// --- UDP thread: read + parse, push to channel (no lock held) ---
|
||||
let addr = bind_addr.to_string();
|
||||
std::thread::spawn(move || {
|
||||
let socket = match UdpSocket::bind(&addr) {
|
||||
|
|
@ -538,7 +527,13 @@ pub fn start_pipeline(bind_addr: &str) -> Arc<Mutex<CsiPipelineState>> {
|
|||
match socket.recv_from(&mut buf) {
|
||||
Ok((n, _)) => {
|
||||
if let Some(frame) = parse_adr018(&buf[..n]) {
|
||||
st.lock().unwrap().process_frame(frame);
|
||||
// Non-blocking w.r.t. the shared state lock. If the
|
||||
// processor thread has died, send() fails and we
|
||||
// exit the receiver.
|
||||
if tx.send(frame).is_err() {
|
||||
eprintln!(" CSI pipeline: processor gone, exiting receiver");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
|
||||
|
|
@ -547,9 +542,35 @@ pub fn start_pipeline(bind_addr: &str) -> Arc<Mutex<CsiPipelineState>> {
|
|||
}
|
||||
});
|
||||
|
||||
// --- Processor thread: drain channel, take lock briefly to publish ---
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(frame) = rx.recv() {
|
||||
// Lock is held only for the duration of one process_frame call;
|
||||
// HTTP handlers that need a snapshot via get_pipeline_output are
|
||||
// never starved by the UDP read loop.
|
||||
if let Ok(mut st) = processor_state.lock() {
|
||||
st.process_frame(frame);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
/// Send synthetic ADR-018 binary CSI frames for local testing without real
|
||||
/// ESP32 hardware. Each frame carries `n_subcarriers` subcarriers of fake
|
||||
/// I/Q data. Targets `target` (e.g. `127.0.0.1:3333`).
|
||||
pub fn send_test_frames(target: &str, count: usize) -> anyhow::Result<()> {
|
||||
use crate::parser::{build_test_frame, MAGIC_V1};
|
||||
let socket = UdpSocket::bind("0.0.0.0:0")?;
|
||||
for i in 0..count {
|
||||
let buf = build_test_frame(MAGIC_V1, (i % 4) as u8, 56, i);
|
||||
socket.send_to(&buf, target)?;
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get current pipeline output for fusion.
|
||||
pub fn get_pipeline_output(state: &Arc<Mutex<CsiPipelineState>>) -> PipelineOutput {
|
||||
let st = state.lock().unwrap();
|
||||
|
|
@ -600,3 +621,43 @@ impl serde::Serialize for VitalSigns {
|
|||
st.end()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::parser::{build_test_frame, parse_adr018, MAGIC_V1};
|
||||
|
||||
fn seed_state_with_frames(state: &mut CsiPipelineState, n: usize) {
|
||||
for i in 0..n {
|
||||
let bytes = build_test_frame(MAGIC_V1, 1, 32, i);
|
||||
let frame = parse_adr018(&bytes).expect("synthetic frame must parse");
|
||||
state.process_frame(frame);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_light_level_toggles_night_mode() {
|
||||
let mut s = CsiPipelineState::default();
|
||||
assert!(!s.is_dark, "default should be daylight");
|
||||
s.set_light_level(10.0);
|
||||
assert!(s.is_dark, "luminance below 30 → dark");
|
||||
s.set_light_level(200.0);
|
||||
assert!(!s.is_dark, "high luminance → not dark");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_fingerprint_stores_and_matches() {
|
||||
let mut s = CsiPipelineState::default();
|
||||
seed_state_with_frames(&mut s, 30);
|
||||
s.record_fingerprint("lab");
|
||||
assert_eq!(s.fingerprints.len(), 1);
|
||||
assert_eq!(s.fingerprints[0].name, "lab");
|
||||
// Identify against its own fingerprint should succeed.
|
||||
let found = s.identify_location();
|
||||
assert!(found.is_some(), "should identify the just-recorded location");
|
||||
if let Some((name, conf)) = found {
|
||||
assert_eq!(name, "lab");
|
||||
assert!(conf > 0.7, "self-similarity should exceed match threshold");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,6 +210,46 @@ pub fn demo_depth_cloud() -> PointCloud {
|
|||
backproject_depth(&depth, &scaled_intrinsics, None, 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn backproject_2x2_depth_yields_four_points() {
|
||||
// 2x2 image, depth=1m everywhere; trivial intrinsics.
|
||||
let intr = CameraIntrinsics {
|
||||
fx: 1.0, fy: 1.0, cx: 0.5, cy: 0.5,
|
||||
width: 2, height: 2,
|
||||
};
|
||||
let depth = vec![1.0f32; 4];
|
||||
let cloud = backproject_depth(&depth, &intr, None, 1);
|
||||
assert_eq!(cloud.points.len(), 4, "2x2 depth → 4 backprojected points");
|
||||
// Every point should be at z=1.0.
|
||||
for p in &cloud.points {
|
||||
assert!((p.z - 1.0).abs() < 1e-6, "z should be 1.0, got {}", p.z);
|
||||
}
|
||||
// With cx=0.5, cy=0.5 the four pixel centers backproject symmetrically
|
||||
// about the optical axis: x in {-0.5, 0.5}, y in {-0.5, 0.5}.
|
||||
let mut xs: Vec<f32> = cloud.points.iter().map(|p| p.x).collect();
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
assert!((xs[0] + 0.5).abs() < 1e-6);
|
||||
assert!((xs.last().unwrap() - 0.5).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backproject_rejects_invalid_depth() {
|
||||
let intr = CameraIntrinsics {
|
||||
fx: 1.0, fy: 1.0, cx: 0.5, cy: 0.5,
|
||||
width: 2, height: 2,
|
||||
};
|
||||
// All pixels NaN → no points.
|
||||
let depth = vec![f32::NAN; 4];
|
||||
let cloud = backproject_depth(&depth, &intr, None, 1);
|
||||
assert_eq!(cloud.points.len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn find_midas_model() -> Result<String> {
|
||||
let paths = [
|
||||
dirs::home_dir().unwrap_or_default().join(".local/share/ruview/midas_v21_small_256.onnx"),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
//! Multi-modal fusion: camera depth + WiFi RF tomography → unified point cloud.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::pointcloud::{PointCloud, ColorPoint};
|
||||
use std::collections::HashMap;
|
||||
|
|
@ -94,36 +93,6 @@ pub fn fuse_clouds(clouds: &[&PointCloud], voxel_size: f32) -> PointCloud {
|
|||
fused
|
||||
}
|
||||
|
||||
/// Fetch WiFi occupancy from a remote RuView/brain endpoint.
|
||||
pub async fn fetch_wifi_occupancy(url: &str) -> anyhow::Result<OccupancyVolume> {
|
||||
let client = reqwest::Client::new();
|
||||
let resp: serde_json::Value = client.get(url).send().await?.json().await?;
|
||||
|
||||
let nx = resp.get("nx").and_then(|v| v.as_u64()).unwrap_or(8) as usize;
|
||||
let ny = resp.get("ny").and_then(|v| v.as_u64()).unwrap_or(8) as usize;
|
||||
let nz = resp.get("nz").and_then(|v| v.as_u64()).unwrap_or(4) as usize;
|
||||
|
||||
let densities: Vec<f64> = resp.get("densities")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_f64()).collect())
|
||||
.unwrap_or_else(|| vec![0.0; nx * ny * nz]);
|
||||
|
||||
let bounds = resp.get("bounds")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
let mut b = [0.0f64; 6];
|
||||
for (i, v) in arr.iter().enumerate().take(6) {
|
||||
b[i] = v.as_f64().unwrap_or(0.0);
|
||||
}
|
||||
b
|
||||
})
|
||||
.unwrap_or([0.0, 0.0, 0.0, 5.0, 5.0, 3.0]);
|
||||
|
||||
let occupied_count = densities.iter().filter(|&&d| d > 0.3).count();
|
||||
|
||||
Ok(OccupancyVolume { densities, nx, ny, nz, bounds, occupied_count })
|
||||
}
|
||||
|
||||
/// Generate a demo occupancy volume (room with person).
|
||||
pub fn demo_occupancy() -> OccupancyVolume {
|
||||
let nx = 10;
|
||||
|
|
@ -159,3 +128,36 @@ pub fn demo_occupancy() -> OccupancyVolume {
|
|||
occupied_count,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cloud_with(name: &str, pts: &[(f32, f32, f32)]) -> PointCloud {
|
||||
let mut c = PointCloud::new(name);
|
||||
for &(x, y, z) in pts {
|
||||
c.points.push(ColorPoint { x, y, z, r: 10, g: 20, b: 30, intensity: 0.5 });
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuse_clouds_merges_non_overlapping() {
|
||||
let a = cloud_with("a", &[(0.0, 0.0, 0.0)]);
|
||||
let b = cloud_with("b", &[(5.0, 5.0, 5.0)]);
|
||||
let fused = fuse_clouds(&[&a, &b], 0.1);
|
||||
assert_eq!(fused.points.len(), 2, "two far-apart points should yield two voxels");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuse_clouds_voxel_dedup() {
|
||||
// Points all within one voxel must collapse to a single averaged point.
|
||||
let a = cloud_with("a", &[
|
||||
(0.01, 0.02, 0.03),
|
||||
(0.04, 0.01, 0.02),
|
||||
(0.03, 0.03, 0.01),
|
||||
]);
|
||||
let fused = fuse_clouds(&[&a], 0.5);
|
||||
assert_eq!(fused.points.len(), 1, "three close points → one voxel");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,23 +4,18 @@
|
|||
//!
|
||||
//! Usage:
|
||||
//! ruview-pointcloud serve # HTTP + Three.js viewer
|
||||
//! ruview-pointcloud serve --csi 0.0.0.0:9890 # with live WiFi CSI
|
||||
//! ruview-pointcloud capture --frames 1 # capture to PLY
|
||||
//! ruview-pointcloud demo # synthetic demo
|
||||
//! ruview-pointcloud train # calibration training
|
||||
//! ruview-pointcloud csi-test # send test CSI frames
|
||||
//! ruview-pointcloud csi-test # send test CSI frames (ADR-018 binary)
|
||||
|
||||
#[allow(dead_code)]
|
||||
mod brain_bridge;
|
||||
mod camera;
|
||||
#[allow(dead_code)]
|
||||
mod csi;
|
||||
mod csi_pipeline;
|
||||
mod depth;
|
||||
mod fusion;
|
||||
mod parser;
|
||||
mod pointcloud;
|
||||
#[allow(dead_code)]
|
||||
mod serial_csi;
|
||||
mod stream;
|
||||
mod training;
|
||||
|
||||
|
|
@ -38,15 +33,16 @@ struct Cli {
|
|||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Start real-time point cloud server
|
||||
/// Start real-time point cloud server.
|
||||
///
|
||||
/// By default the HTTP server binds to `127.0.0.1:9880` — exposing it on
|
||||
/// `0.0.0.0` leaks live camera/CSI/vitals data to the network and must
|
||||
/// be an explicit opt-in via `--bind 0.0.0.0:9880`.
|
||||
Serve {
|
||||
#[arg(long, default_value = "0.0.0.0")]
|
||||
host: String,
|
||||
#[arg(long, default_value = "9880")]
|
||||
port: u16,
|
||||
/// WiFi CSI listen address (e.g., 0.0.0.0:9890)
|
||||
#[arg(long)]
|
||||
csi: Option<String>,
|
||||
/// Bind address for the HTTP/viewer server. Default
|
||||
/// `127.0.0.1:9880` (loopback only — safe by default).
|
||||
#[arg(long, default_value = "127.0.0.1:9880")]
|
||||
bind: String,
|
||||
/// Brain URL for storing observations
|
||||
#[arg(long)]
|
||||
brain: Option<String>,
|
||||
|
|
@ -70,13 +66,25 @@ enum Commands {
|
|||
#[arg(long)]
|
||||
brain: Option<String>,
|
||||
},
|
||||
/// Send test CSI frames (for testing without ESP32)
|
||||
/// Send synthetic ADR-018 binary CSI frames (for local testing without ESP32).
|
||||
CsiTest {
|
||||
#[arg(long, default_value = "127.0.0.1:9890")]
|
||||
#[arg(long, default_value = "127.0.0.1:3333")]
|
||||
target: String,
|
||||
#[arg(long, default_value = "100")]
|
||||
count: usize,
|
||||
},
|
||||
/// Record a CSI fingerprint for the current location.
|
||||
///
|
||||
/// Listens on UDP 3333 for `--seconds` seconds, accumulates CSI frames,
|
||||
/// and stores a named fingerprint that future sessions can match
|
||||
/// against to identify the room.
|
||||
Fingerprint {
|
||||
/// Human-readable name for the fingerprint (e.g. "office", "lab").
|
||||
name: String,
|
||||
/// How long to listen before recording (default 5 s).
|
||||
#[arg(long, default_value = "5")]
|
||||
seconds: u64,
|
||||
},
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
|
|
@ -84,14 +92,8 @@ async fn main() -> Result<()> {
|
|||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Serve { host, port, csi, brain } => {
|
||||
// Start CSI receiver if configured
|
||||
if let Some(csi_addr) = &csi {
|
||||
let receiver = csi::CsiReceiver::new(csi_addr);
|
||||
receiver.start()?;
|
||||
eprintln!(" CSI receiver: {csi_addr}");
|
||||
}
|
||||
stream::serve(&host, port, brain.as_deref()).await?;
|
||||
Commands::Serve { bind, brain } => {
|
||||
stream::serve(&bind, brain.as_deref()).await?;
|
||||
}
|
||||
Commands::Capture { frames: _, output } => {
|
||||
if camera::camera_available() {
|
||||
|
|
@ -126,10 +128,26 @@ async fn main() -> Result<()> {
|
|||
train(&data_dir, brain.as_deref()).await?;
|
||||
}
|
||||
Commands::CsiTest { target, count } => {
|
||||
println!("Sending {count} test CSI frames to {target}...");
|
||||
csi::send_test_frames(&target, count)?;
|
||||
println!("Sending {count} synthetic ADR-018 CSI frames to {target}...");
|
||||
csi_pipeline::send_test_frames(&target, count)?;
|
||||
println!("Done");
|
||||
}
|
||||
Commands::Fingerprint { name, seconds } => {
|
||||
println!("Recording CSI fingerprint '{name}' for {seconds} s on UDP 3333...");
|
||||
let state = csi_pipeline::start_pipeline("0.0.0.0:3333");
|
||||
std::thread::sleep(std::time::Duration::from_secs(seconds));
|
||||
// record_fingerprint takes a brief lock on the shared state to
|
||||
// read the last N frames from every node's history.
|
||||
{
|
||||
let mut st = state.lock().expect("pipeline state lock poisoned");
|
||||
st.record_fingerprint(&name);
|
||||
println!(
|
||||
" Stored: {} fingerprint(s) total, {} total CSI frames received",
|
||||
st.fingerprints.len(),
|
||||
st.total_frames
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
|
@ -170,6 +188,10 @@ async fn train(data_dir: &str, brain_url: Option<&str>) -> Result<()> {
|
|||
println!();
|
||||
|
||||
let expanded = data_dir.replace('~', &dirs::home_dir().unwrap_or_default().to_string_lossy());
|
||||
// Defence-in-depth: reject path-traversal in the CLI argument before we
|
||||
// hand it to TrainingSession (which also checks). This catches malicious
|
||||
// CLI input early, before any I/O.
|
||||
let _sanitised = training::sanitize_data_path(&expanded)?;
|
||||
let mut session = training::TrainingSession::new(&expanded)?;
|
||||
session.load_samples()?;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
//! ADR-018 binary CSI frame parser.
|
||||
//!
|
||||
//! Two header magics are accepted: `0xC5110001` (raw CSI, v1) and
|
||||
//! `0xC5110006` (feature state, v6). The header is 20 bytes; everything
|
||||
//! after is interleaved I/Q bytes per subcarrier per antenna.
|
||||
//!
|
||||
//! Returns `None` when the buffer is truncated or the magic is wrong —
|
||||
//! this is a hot path (one call per UDP packet) so we prefer Option over
|
||||
//! a full `anyhow::Error` that would allocate.
|
||||
|
||||
const CSI_MAGIC_V6: u32 = 0xC511_0006;
|
||||
const CSI_MAGIC_V1: u32 = 0xC511_0001;
|
||||
pub(crate) const CSI_HEADER_SIZE: usize = 20;
|
||||
|
||||
/// Accept both header magics — `0xC5110001` (raw CSI) and
|
||||
/// `0xC5110006` (feature state). Exposed for tests.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const MAGIC_V1: u32 = CSI_MAGIC_V1;
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const MAGIC_V6: u32 = CSI_MAGIC_V6;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CsiFrame {
|
||||
pub node_id: u8,
|
||||
pub n_antennas: u8,
|
||||
pub n_subcarriers: u16,
|
||||
pub channel: u8,
|
||||
pub rssi: i8,
|
||||
pub noise_floor: i8,
|
||||
pub timestamp_us: u32,
|
||||
/// Raw I/Q data: [I0, Q0, I1, Q1, ...] for each subcarrier
|
||||
pub iq_data: Vec<i8>,
|
||||
/// Computed amplitude per subcarrier: sqrt(I^2 + Q^2)
|
||||
pub amplitudes: Vec<f32>,
|
||||
/// Computed phase per subcarrier: atan2(Q, I)
|
||||
pub phases: Vec<f32>,
|
||||
}
|
||||
|
||||
/// Parse an ADR-018 binary CSI frame from a UDP packet.
|
||||
///
|
||||
/// Returns `None` if:
|
||||
/// - the buffer is shorter than the 20-byte header
|
||||
/// - the magic does not match either accepted value
|
||||
/// - the declared I/Q payload is truncated
|
||||
pub fn parse_adr018(data: &[u8]) -> Option<CsiFrame> {
|
||||
if data.len() < CSI_HEADER_SIZE { return None; }
|
||||
|
||||
let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
|
||||
if magic != CSI_MAGIC_V6 && magic != CSI_MAGIC_V1 { return None; }
|
||||
|
||||
let node_id = data[4];
|
||||
let n_antennas = data[5].max(1);
|
||||
let n_subcarriers = u16::from_le_bytes([data[6], data[7]]);
|
||||
let channel = data[8];
|
||||
let rssi = data[9] as i8;
|
||||
let noise_floor = data[10] as i8;
|
||||
let timestamp_us = u32::from_le_bytes([data[16], data[17], data[18], data[19]]);
|
||||
|
||||
let iq_len = (n_subcarriers as usize) * 2 * (n_antennas as usize);
|
||||
if data.len() < CSI_HEADER_SIZE + iq_len { return None; }
|
||||
|
||||
let iq_data: Vec<i8> = data[CSI_HEADER_SIZE..CSI_HEADER_SIZE + iq_len]
|
||||
.iter().map(|&b| b as i8).collect();
|
||||
|
||||
// Compute amplitude and phase per subcarrier (first antenna).
|
||||
let mut amplitudes = Vec::with_capacity(n_subcarriers as usize);
|
||||
let mut phases = Vec::with_capacity(n_subcarriers as usize);
|
||||
for i in 0..n_subcarriers as usize {
|
||||
let idx = i * 2;
|
||||
if idx + 1 < iq_data.len() {
|
||||
let ii = iq_data[idx] as f32;
|
||||
let qq = iq_data[idx + 1] as f32;
|
||||
amplitudes.push((ii * ii + qq * qq).sqrt());
|
||||
phases.push(qq.atan2(ii));
|
||||
}
|
||||
}
|
||||
|
||||
Some(CsiFrame {
|
||||
node_id, n_antennas, n_subcarriers, channel, rssi, noise_floor,
|
||||
timestamp_us, iq_data, amplitudes, phases,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a synthetic ADR-018 binary frame. Used by the `csi-test` CLI
|
||||
/// subcommand and by the unit tests in this module.
|
||||
pub fn build_test_frame(magic: u32, node_id: u8, n_subcarriers: u16, i: usize) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(CSI_HEADER_SIZE + (n_subcarriers as usize) * 2);
|
||||
buf.extend_from_slice(&magic.to_le_bytes()); // magic (0..4)
|
||||
buf.push(node_id); // node_id (4)
|
||||
buf.push(1u8); // n_antennas (5)
|
||||
buf.extend_from_slice(&n_subcarriers.to_le_bytes()); // n_subcarriers (6..8)
|
||||
buf.push(6u8); // channel (8)
|
||||
buf.push((-40i8 - (i % 30) as i8) as u8); // rssi (9)
|
||||
buf.push((-90i8) as u8); // noise_floor (10)
|
||||
buf.extend_from_slice(&[0u8; 5]); // reserved (11..16)
|
||||
buf.extend_from_slice(&(i as u32).to_le_bytes()); // timestamp_us (16..20)
|
||||
for j in 0..(n_subcarriers as usize) {
|
||||
buf.push(((i + j) as i8).wrapping_mul(3) as u8);
|
||||
buf.push(((i + j) as i8).wrapping_mul(5) as u8);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_magic_v1_roundtrips() {
|
||||
let frame_bytes = build_test_frame(MAGIC_V1, 0x42, 56, 7);
|
||||
let frame = parse_adr018(&frame_bytes).expect("v1 frame should parse");
|
||||
assert_eq!(frame.node_id, 0x42);
|
||||
assert_eq!(frame.n_antennas, 1);
|
||||
assert_eq!(frame.n_subcarriers, 56);
|
||||
assert_eq!(frame.channel, 6);
|
||||
assert_eq!(frame.timestamp_us, 7);
|
||||
assert_eq!(frame.iq_data.len(), 56 * 2);
|
||||
assert_eq!(frame.amplitudes.len(), 56);
|
||||
assert_eq!(frame.phases.len(), 56);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_magic_v6_roundtrips() {
|
||||
let frame_bytes = build_test_frame(MAGIC_V6, 0x09, 114, 0);
|
||||
let frame = parse_adr018(&frame_bytes).expect("v6 frame should parse");
|
||||
assert_eq!(frame.node_id, 0x09);
|
||||
assert_eq!(frame.n_antennas, 1);
|
||||
assert_eq!(frame.n_subcarriers, 114);
|
||||
assert_eq!(frame.channel, 6);
|
||||
// With i=0, noise_floor=-90 per build_test_frame.
|
||||
assert_eq!(frame.noise_floor, -90);
|
||||
// With i=0, timestamp_us=0.
|
||||
assert_eq!(frame.timestamp_us, 0);
|
||||
assert_eq!(frame.iq_data.len(), 114 * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_wrong_magic() {
|
||||
let mut bad = build_test_frame(MAGIC_V1, 0, 8, 0);
|
||||
// Flip magic to something unrelated.
|
||||
bad[0] = 0xFF;
|
||||
bad[1] = 0xFF;
|
||||
bad[2] = 0xFF;
|
||||
bad[3] = 0xFF;
|
||||
assert!(parse_adr018(&bad).is_none(), "bad magic should not parse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_truncated_header() {
|
||||
let short = vec![0u8; CSI_HEADER_SIZE - 1];
|
||||
assert!(parse_adr018(&short).is_none(), "truncated header must not parse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_truncated_payload() {
|
||||
let mut frame = build_test_frame(MAGIC_V1, 0, 32, 0);
|
||||
// Drop half the declared payload.
|
||||
frame.truncate(CSI_HEADER_SIZE + 20);
|
||||
assert!(parse_adr018(&frame).is_none(), "truncated payload must not parse");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
//! Serial CSI reader — parse ESP32 CSI data from /dev/ttyACM0 and /dev/ttyUSB0.
|
||||
//!
|
||||
//! ESP32 firmware outputs lines like:
|
||||
//! I (56994) csi_collector: CSI cb #2900: len=256 rssi=-32 ch=5
|
||||
//!
|
||||
//! This module reads those lines, extracts RSSI, and tracks signal changes
|
||||
//! to detect motion and presence.
|
||||
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CsiReading {
|
||||
pub port: String,
|
||||
pub rssi: i32,
|
||||
pub len: u32,
|
||||
pub channel: u8,
|
||||
pub callback_num: u64,
|
||||
pub timestamp_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CsiState {
|
||||
/// Latest readings from each port
|
||||
pub readings: Vec<CsiReading>,
|
||||
/// RSSI history for motion detection (last 20 values per port)
|
||||
pub rssi_history: Vec<Vec<i32>>,
|
||||
/// Motion score (0.0 = still, 1.0 = strong motion)
|
||||
pub motion_score: f32,
|
||||
/// Estimated presence distance (from RSSI)
|
||||
pub presence_distance_m: f32,
|
||||
/// Total frames received
|
||||
pub total_frames: u64,
|
||||
}
|
||||
|
||||
impl Default for CsiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
readings: Vec::new(),
|
||||
rssi_history: Vec::new(),
|
||||
motion_score: 0.0,
|
||||
presence_distance_m: 3.0,
|
||||
total_frames: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start reading CSI from serial ports in background threads.
|
||||
/// Returns shared state that updates as frames arrive.
|
||||
pub fn start_serial_readers(ports: &[&str]) -> Arc<Mutex<CsiState>> {
|
||||
let state = Arc::new(Mutex::new(CsiState::default()));
|
||||
|
||||
for (idx, port) in ports.iter().enumerate() {
|
||||
let port_path = port.to_string();
|
||||
let st = state.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
if let Ok(file) = std::fs::File::open(&port_path) {
|
||||
let reader = BufReader::new(file);
|
||||
for line in reader.lines() {
|
||||
if let Ok(line) = line {
|
||||
if let Some(reading) = parse_csi_line(&line, &port_path) {
|
||||
update_state(&st, idx, reading);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Retry if port disconnects
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
eprintln!(" CSI: reconnecting {port_path}...");
|
||||
}
|
||||
});
|
||||
|
||||
eprintln!(" CSI: reading {port}");
|
||||
}
|
||||
|
||||
state
|
||||
}
|
||||
|
||||
fn parse_csi_line(line: &str, port: &str) -> Option<CsiReading> {
|
||||
// Parse: I (56994) csi_collector: CSI cb #2900: len=256 rssi=-32 ch=5
|
||||
if !line.contains("csi_collector") || !line.contains("CSI cb") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rssi = line.split("rssi=").nth(1)?
|
||||
.split_whitespace().next()?
|
||||
.parse::<i32>().ok()?;
|
||||
|
||||
let len = line.split("len=").nth(1)?
|
||||
.split_whitespace().next()?
|
||||
.parse::<u32>().ok()?;
|
||||
|
||||
let channel = line.split("ch=").nth(1)?
|
||||
.split_whitespace().next()
|
||||
.unwrap_or("0")
|
||||
.parse::<u8>().unwrap_or(0);
|
||||
|
||||
let cb_num = line.split('#').nth(1)?
|
||||
.split(':').next()?
|
||||
.parse::<u64>().ok()?;
|
||||
|
||||
Some(CsiReading {
|
||||
port: port.to_string(),
|
||||
rssi,
|
||||
len,
|
||||
channel,
|
||||
callback_num: cb_num,
|
||||
timestamp_ms: chrono::Utc::now().timestamp_millis(),
|
||||
})
|
||||
}
|
||||
|
||||
fn update_state(state: &Arc<Mutex<CsiState>>, port_idx: usize, reading: CsiReading) {
|
||||
let mut st = state.lock().unwrap();
|
||||
|
||||
// Ensure vectors are big enough
|
||||
while st.readings.len() <= port_idx {
|
||||
st.readings.push(reading.clone());
|
||||
st.rssi_history.push(Vec::new());
|
||||
}
|
||||
|
||||
st.readings[port_idx] = reading.clone();
|
||||
st.total_frames += 1;
|
||||
|
||||
// Track RSSI history
|
||||
let hist = &mut st.rssi_history[port_idx];
|
||||
hist.push(reading.rssi);
|
||||
if hist.len() > 20 { hist.remove(0); }
|
||||
|
||||
// Motion detection: RSSI variance over last 20 readings
|
||||
if hist.len() >= 5 {
|
||||
let mean: f32 = hist.iter().map(|&r| r as f32).sum::<f32>() / hist.len() as f32;
|
||||
let variance: f32 = hist.iter().map(|&r| (r as f32 - mean).powi(2)).sum::<f32>() / hist.len() as f32;
|
||||
// High variance = motion (someone moving changes signal reflections)
|
||||
st.motion_score = (variance / 50.0).min(1.0); // normalize: variance of 50 = full motion
|
||||
}
|
||||
|
||||
// Estimate presence distance from RSSI (path loss model)
|
||||
// Free space: RSSI = -10 * n * log10(d) + A
|
||||
// n ≈ 2.5 for indoor, A ≈ -30 (1m reference)
|
||||
let avg_rssi: f32 = st.readings.iter().map(|r| r.rssi as f32).sum::<f32>()
|
||||
/ st.readings.len().max(1) as f32;
|
||||
let d = 10.0f32.powf((-30.0 - avg_rssi) / (10.0 * 2.5));
|
||||
st.presence_distance_m = d.clamp(0.3, 10.0);
|
||||
}
|
||||
|
||||
/// Convert CSI state to occupancy influence on the point cloud.
|
||||
/// Returns (motion_score, presence_distance, total_frames).
|
||||
pub fn get_csi_influence(state: &Arc<Mutex<CsiState>>) -> (f32, f32, u64) {
|
||||
let st = state.lock().unwrap();
|
||||
(st.motion_score, st.presence_distance_m, st.total_frames)
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
//! HTTP server — live camera + ESP32 CSI + fusion → real-time point cloud.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::brain_bridge;
|
||||
use crate::camera;
|
||||
|
|
@ -21,13 +20,19 @@ struct AppState {
|
|||
latest_pipeline: Mutex<Option<csi_pipeline::PipelineOutput>>,
|
||||
frame_count: Mutex<u64>,
|
||||
use_camera: bool,
|
||||
csi_pipeline: Option<Arc<Mutex<csi_pipeline::CsiPipelineState>>>,
|
||||
}
|
||||
|
||||
pub async fn serve(host: &str, port: u16, _wifi_source: Option<&str>) -> anyhow::Result<()> {
|
||||
/// Start the HTTP/viewer server bound to `bind` (e.g.
|
||||
/// `"127.0.0.1:9880"` — the safe default — or `"0.0.0.0:9880"` to expose
|
||||
/// the viewer to the LAN).
|
||||
///
|
||||
/// **Security**: the viewer streams live camera/CSI/vitals data. Bind to
|
||||
/// `127.0.0.1` unless you intentionally want remote viewers.
|
||||
pub async fn serve(bind: &str, _brain: Option<&str>) -> anyhow::Result<()> {
|
||||
let has_camera = camera::camera_available();
|
||||
|
||||
// Start CSI pipeline — listens for UDP CSI data from ESP32 nodes
|
||||
// Start CSI pipeline — listens for UDP CSI data from ESP32 nodes.
|
||||
// Kept on 0.0.0.0 because ESP32 nodes are remote devices on the LAN.
|
||||
let csi_pipeline_state = csi_pipeline::start_pipeline("0.0.0.0:3333");
|
||||
eprintln!(" CSI pipeline: UDP port 3333 (ADR-018 binary frames)");
|
||||
|
||||
|
|
@ -44,18 +49,17 @@ pub async fn serve(host: &str, port: u16, _wifi_source: Option<&str>) -> anyhow:
|
|||
latest_pipeline: Mutex::new(None),
|
||||
frame_count: Mutex::new(0),
|
||||
use_camera: has_camera,
|
||||
csi_pipeline: Some(csi_pipeline_state.clone()),
|
||||
});
|
||||
|
||||
// Background: capture + fuse every 500ms (motion-adaptive)
|
||||
let bg = state.clone();
|
||||
let bg_csi = Some(csi_pipeline_state.clone());
|
||||
let bg_csi = csi_pipeline_state.clone();
|
||||
let bg_cam = has_camera;
|
||||
tokio::spawn(async move {
|
||||
let mut skip_depth = false;
|
||||
loop {
|
||||
// Motion-adaptive: check CSI motion score
|
||||
let pipeline_out = bg_csi.as_ref().map(|c| csi_pipeline::get_pipeline_output(c));
|
||||
let pipeline_out = Some(csi_pipeline::get_pipeline_output(&bg_csi));
|
||||
if let Some(ref out) = pipeline_out {
|
||||
// Only run expensive depth when motion detected or every 5th frame
|
||||
let frame_num = *bg.frame_count.lock().unwrap();
|
||||
|
|
@ -68,13 +72,21 @@ pub async fn serve(host: &str, port: u16, _wifi_source: Option<&str>) -> anyhow:
|
|||
let interval = if skip_depth { 1000 } else { 500 }; // slower when no motion
|
||||
tokio::time::sleep(std::time::Duration::from_millis(interval)).await;
|
||||
|
||||
let cloud = if bg_cam && !skip_depth {
|
||||
tokio::task::spawn_blocking(capture_camera_cloud)
|
||||
.await.unwrap_or_else(|_| demo_cloud())
|
||||
let (cloud, luminance) = if bg_cam && !skip_depth {
|
||||
tokio::task::spawn_blocking(capture_camera_cloud_with_luminance)
|
||||
.await.unwrap_or_else(|_| (demo_cloud(), None))
|
||||
} else {
|
||||
// Reuse previous cloud when no motion
|
||||
bg.latest_cloud.lock().unwrap().clone()
|
||||
(bg.latest_cloud.lock().unwrap().clone(), None)
|
||||
};
|
||||
// Feed luminance into the CSI pipeline so is_dark toggles for the
|
||||
// viewer. The lock is held briefly here — the UDP thread never
|
||||
// touches it (messages go through the mpsc channel).
|
||||
if let Some(lum) = luminance {
|
||||
if let Ok(mut st) = bg_csi.lock() {
|
||||
st.set_light_level(lum);
|
||||
}
|
||||
}
|
||||
let splats = pointcloud::to_gaussian_splats(&cloud);
|
||||
*bg.latest_cloud.lock().unwrap() = cloud;
|
||||
*bg.latest_splats.lock().unwrap() = splats;
|
||||
|
|
@ -104,30 +116,54 @@ pub async fn serve(host: &str, port: u16, _wifi_source: Option<&str>) -> anyhow:
|
|||
.route("/health", get(api_health))
|
||||
.with_state(state);
|
||||
|
||||
let addr = format!("{host}:{port}");
|
||||
println!("╔══════════════════════════════════════════════╗");
|
||||
println!("║ RuView Dense Point Cloud — ALL SENSORS ║");
|
||||
println!("╚══════════════════════════════════════════════╝");
|
||||
println!(" Viewer: http://{addr}/");
|
||||
println!(" Viewer: http://{bind}/");
|
||||
if bind.starts_with("0.0.0.0") || bind.starts_with("::") {
|
||||
eprintln!(
|
||||
" WARNING: bound to {bind} — camera/CSI/vitals are exposed \
|
||||
to the network. Use --bind 127.0.0.1:9880 to restrict to loopback."
|
||||
);
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
let listener = tokio::net::TcpListener::bind(bind).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn capture_camera_cloud() -> pointcloud::PointCloud {
|
||||
capture_camera_cloud_with_luminance().0
|
||||
}
|
||||
|
||||
/// Grab one camera frame, backproject it to a point cloud, and return the
|
||||
/// mean luminance alongside (used to drive `set_light_level` for night mode).
|
||||
fn capture_camera_cloud_with_luminance() -> (pointcloud::PointCloud, Option<f32>) {
|
||||
let config = camera::CameraConfig::default();
|
||||
match camera::capture_frame(&config) {
|
||||
Ok(frame) => {
|
||||
match depth::estimate_depth(&frame.rgb, frame.width, frame.height) {
|
||||
// Mean luminance across the RGB frame (BT.601 coefficients).
|
||||
let pixels = (frame.width as usize) * (frame.height as usize);
|
||||
let mut sum = 0.0f64;
|
||||
let mut n = 0usize;
|
||||
for chunk in frame.rgb.chunks_exact(3).take(pixels) {
|
||||
sum += 0.299 * chunk[0] as f64
|
||||
+ 0.587 * chunk[1] as f64
|
||||
+ 0.114 * chunk[2] as f64;
|
||||
n += 1;
|
||||
}
|
||||
let lum = if n > 0 { Some((sum / n as f64) as f32) } else { None };
|
||||
|
||||
let cloud = match depth::estimate_depth(&frame.rgb, frame.width, frame.height) {
|
||||
Ok(dm) => {
|
||||
let intr = depth::CameraIntrinsics::default();
|
||||
depth::backproject_depth(&dm, &intr, Some(&frame.rgb), 2)
|
||||
}
|
||||
Err(_) => depth::demo_depth_cloud(),
|
||||
}
|
||||
};
|
||||
(cloud, lum)
|
||||
}
|
||||
Err(_) => depth::demo_depth_cloud(),
|
||||
Err(_) => (depth::demo_depth_cloud(), None),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -185,234 +221,12 @@ async fn api_health() -> Json<serde_json::Value> {
|
|||
Json(serde_json::json!({"status": "ok"}))
|
||||
}
|
||||
|
||||
async fn index() -> Html<String> {
|
||||
Html(r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>RuView — Camera + WiFi CSI Point Cloud</title>
|
||||
<style>
|
||||
body { margin: 0; background: #0a0a0a; color: #e8a634; font-family: monospace; }
|
||||
canvas { display: block; }
|
||||
#info { position: absolute; top: 10px; left: 10px; padding: 12px; background: rgba(0,0,0,0.85); border: 1px solid #e8a634; border-radius: 6px; min-width: 240px; font-size: 13px; line-height: 1.5; }
|
||||
.live { color: #4f4; } .demo { color: #f44; }
|
||||
.section { margin-top: 6px; padding-top: 6px; border-top: 1px solid #333; }
|
||||
.label { color: #888; }
|
||||
</style>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="info">
|
||||
<h3 style="margin:0 0 8px 0">RuView Point Cloud</h3>
|
||||
<div id="stats">Loading...</div>
|
||||
</div>
|
||||
<script>
|
||||
var scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0a0a);
|
||||
var camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 100);
|
||||
camera.position.set(0, 2, -4);
|
||||
camera.lookAt(0, 0, 2);
|
||||
/// Viewer HTML/JS, compiled into the binary at build time. Keep the
|
||||
/// markup in `viewer.html` to keep this file under the 500-LOC limit and
|
||||
/// to make it trivially editable (no Rust rebuild when tweaking JS).
|
||||
static VIEWER_HTML: &str = include_str!("viewer.html");
|
||||
|
||||
var renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
var controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.target.set(0, 0, 2);
|
||||
|
||||
var pointsMesh = null;
|
||||
var lastFrame = -1;
|
||||
var skeletonGroup = null;
|
||||
var prevTimestamp = 0;
|
||||
var frameRateVal = 0;
|
||||
|
||||
// COCO skeleton connections: pairs of keypoint indices
|
||||
// 0=nose 1=leftEye 2=rightEye 3=leftEar 4=rightEar
|
||||
// 5=leftShoulder 6=rightShoulder 7=leftElbow 8=rightElbow
|
||||
// 9=leftWrist 10=rightWrist 11=leftHip 12=rightHip
|
||||
// 13=leftKnee 14=rightKnee 15=leftAnkle 16=rightAnkle
|
||||
var COCO_BONES = [
|
||||
[0,1],[0,2],[1,3],[2,4],
|
||||
[5,6],[5,7],[7,9],[6,8],[8,10],
|
||||
[5,11],[6,12],[11,12],
|
||||
[11,13],[13,15],[12,14],[14,16]
|
||||
];
|
||||
|
||||
function clearSkeleton() {
|
||||
if (skeletonGroup) {
|
||||
scene.remove(skeletonGroup);
|
||||
skeletonGroup.traverse(function(obj) {
|
||||
if (obj.geometry) obj.geometry.dispose();
|
||||
if (obj.material) obj.material.dispose();
|
||||
});
|
||||
skeletonGroup = null;
|
||||
}
|
||||
}
|
||||
|
||||
function drawSkeleton(keypoints) {
|
||||
clearSkeleton();
|
||||
if (!keypoints || keypoints.length < 17) return;
|
||||
skeletonGroup = new THREE.Group();
|
||||
|
||||
// Map keypoints from [0,1] to scene coords
|
||||
// x: [-2, 2], y: [2, -2] (flip y), z: fixed at 2
|
||||
var sphereGeo = new THREE.SphereGeometry(0.04, 8, 8);
|
||||
var sphereMat = new THREE.MeshBasicMaterial({ color: 0xffff00 });
|
||||
var positions3D = [];
|
||||
var i, kp, sx, sy;
|
||||
for (i = 0; i < 17; i++) {
|
||||
kp = keypoints[i];
|
||||
if (!kp) { positions3D.push(null); continue; }
|
||||
sx = (kp[0] - 0.5) * 4;
|
||||
sy = (0.5 - kp[1]) * 4;
|
||||
positions3D.push([sx, sy, 2]);
|
||||
var sphere = new THREE.Mesh(sphereGeo, sphereMat);
|
||||
sphere.position.set(sx, sy, 2);
|
||||
skeletonGroup.add(sphere);
|
||||
}
|
||||
|
||||
// Draw bones as white lines
|
||||
var lineMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
|
||||
var b, a, bIdx;
|
||||
for (b = 0; b < COCO_BONES.length; b++) {
|
||||
a = COCO_BONES[b][0];
|
||||
bIdx = COCO_BONES[b][1];
|
||||
if (!positions3D[a] || !positions3D[bIdx]) continue;
|
||||
var lineGeo = new THREE.BufferGeometry();
|
||||
var verts = new Float32Array([
|
||||
positions3D[a][0], positions3D[a][1], positions3D[a][2],
|
||||
positions3D[bIdx][0], positions3D[bIdx][1], positions3D[bIdx][2]
|
||||
]);
|
||||
lineGeo.setAttribute("position", new THREE.BufferAttribute(verts, 3));
|
||||
var line = new THREE.Line(lineGeo, lineMat);
|
||||
skeletonGroup.add(line);
|
||||
}
|
||||
|
||||
scene.add(skeletonGroup);
|
||||
}
|
||||
|
||||
async function fetchCloud() {
|
||||
try {
|
||||
var resp = await fetch("/api/splats");
|
||||
var data = await resp.json();
|
||||
if (data.splats && data.frame !== lastFrame) {
|
||||
// Compute CSI frame rate
|
||||
var now = Date.now();
|
||||
if (prevTimestamp > 0) {
|
||||
var dt = (now - prevTimestamp) / 1000.0;
|
||||
if (dt > 0) frameRateVal = (1.0 / dt).toFixed(1);
|
||||
}
|
||||
prevTimestamp = now;
|
||||
lastFrame = data.frame;
|
||||
updateSplats(data.splats);
|
||||
|
||||
// Draw skeleton if available
|
||||
var pipe = data.pipeline;
|
||||
if (pipe && pipe.skeleton && pipe.skeleton.keypoints) {
|
||||
drawSkeleton(pipe.skeleton.keypoints);
|
||||
} else {
|
||||
clearSkeleton();
|
||||
}
|
||||
|
||||
// Build info panel
|
||||
var mode = data.live
|
||||
? '<span class="live">● LIVE</span>'
|
||||
: '<span class="demo">● DEMO</span>';
|
||||
var html = mode + " Camera + CSI<br>"
|
||||
+ "Splats: " + data.count + "<br>"
|
||||
+ "Frame: " + data.frame;
|
||||
|
||||
// CSI frame rate
|
||||
html += '<div class="section">'
|
||||
+ '<span class="label">CSI Rate:</span> '
|
||||
+ frameRateVal + " fps</div>";
|
||||
|
||||
// Skeleton confidence
|
||||
if (pipe && pipe.skeleton && pipe.skeleton.confidence !== undefined) {
|
||||
var conf = (pipe.skeleton.confidence * 100).toFixed(0);
|
||||
html += '<div class="section">'
|
||||
+ '<span class="label">Skeleton:</span> '
|
||||
+ conf + "% confidence</div>";
|
||||
}
|
||||
|
||||
// Weather data
|
||||
if (pipe && pipe.weather) {
|
||||
var w = pipe.weather;
|
||||
html += '<div class="section">'
|
||||
+ '<span class="label">Weather:</span> ';
|
||||
if (w.temperature !== undefined) {
|
||||
html += w.temperature + "°C";
|
||||
}
|
||||
if (w.conditions) {
|
||||
html += " " + w.conditions;
|
||||
}
|
||||
html += "</div>";
|
||||
}
|
||||
|
||||
// Building count from geo
|
||||
if (pipe && pipe.geo && pipe.geo.building_count !== undefined) {
|
||||
html += '<div class="section">'
|
||||
+ '<span class="label">Buildings:</span> '
|
||||
+ pipe.geo.building_count + "</div>";
|
||||
}
|
||||
|
||||
// Vitals
|
||||
if (pipe && pipe.vitals) {
|
||||
var v = pipe.vitals;
|
||||
html += '<div class="section">'
|
||||
+ '<span class="label">Vitals:</span> ';
|
||||
if (v.breathing_rate !== undefined) {
|
||||
html += "BR " + v.breathing_rate + "/min";
|
||||
}
|
||||
if (v.motion_score !== undefined) {
|
||||
html += " Motion " + (v.motion_score * 100).toFixed(0) + "%";
|
||||
}
|
||||
html += "</div>";
|
||||
}
|
||||
|
||||
document.getElementById("stats").innerHTML = html;
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
fetchCloud();
|
||||
setInterval(fetchCloud, 500);
|
||||
|
||||
function updateSplats(splats) {
|
||||
if (pointsMesh) scene.remove(pointsMesh);
|
||||
var geometry = new THREE.BufferGeometry();
|
||||
var positions = new Float32Array(splats.length * 3);
|
||||
var colors = new Float32Array(splats.length * 3);
|
||||
var i, s;
|
||||
for (i = 0; i < splats.length; i++) {
|
||||
s = splats[i];
|
||||
positions[i*3] = s.center[0];
|
||||
positions[i*3+1] = -s.center[1];
|
||||
positions[i*3+2] = s.center[2];
|
||||
colors[i*3] = s.color[0];
|
||||
colors[i*3+1] = s.color[1];
|
||||
colors[i*3+2] = s.color[2];
|
||||
}
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||
pointsMesh = new THREE.Points(geometry, new THREE.PointsMaterial({
|
||||
size: 0.02, vertexColors: true, sizeAttenuation: true
|
||||
}));
|
||||
scene.add(pointsMesh);
|
||||
}
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
window.addEventListener("resize", function() {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>"#.to_string())
|
||||
async fn index() -> Html<&'static str> {
|
||||
Html(VIEWER_HTML)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,9 +9,63 @@
|
|||
//! DPO training — "this depth estimate was correct" vs "this was wrong"
|
||||
|
||||
use crate::fusion::OccupancyVolume;
|
||||
use anyhow::Result;
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Reject a user-supplied path that contains `..` components (path traversal
|
||||
/// attempt) and return a normalised [`PathBuf`]. We only reject `..`; other
|
||||
/// components (including relative prefixes and `~`) are accepted verbatim —
|
||||
/// the caller is responsible for tilde expansion if needed.
|
||||
pub fn sanitize_data_path(raw: &str) -> Result<PathBuf> {
|
||||
let p = PathBuf::from(raw);
|
||||
for comp in p.components() {
|
||||
if matches!(comp, std::path::Component::ParentDir) {
|
||||
return Err(anyhow!(
|
||||
"refusing to use data dir with `..` traversal component: {raw}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Ensure `child` (after joining to `base`) stays inside the canonicalised
|
||||
/// `base` directory. Returns the canonical child path on success. Used by
|
||||
/// every filesystem write site in this module to prevent path-traversal
|
||||
/// through user-supplied names.
|
||||
fn safe_join(base: &Path, child: &str) -> Result<PathBuf> {
|
||||
// Reject absolute children and any `..` components up front.
|
||||
let child_path = Path::new(child);
|
||||
if child_path.is_absolute() {
|
||||
return Err(anyhow!("child path must be relative: {child}"));
|
||||
}
|
||||
for comp in child_path.components() {
|
||||
if matches!(comp, std::path::Component::ParentDir) {
|
||||
return Err(anyhow!("child path may not contain `..`: {child}"));
|
||||
}
|
||||
}
|
||||
|
||||
let joined = base.join(child_path);
|
||||
// Canonicalise base (must exist) and verify joined starts with it. If the
|
||||
// joined file doesn't exist yet we canonicalise the parent.
|
||||
let canonical_base = base.canonicalize()
|
||||
.map_err(|e| anyhow!("data_dir not accessible {}: {e}", base.display()))?;
|
||||
let canonical_parent = joined
|
||||
.parent()
|
||||
.ok_or_else(|| anyhow!("no parent for {}", joined.display()))?;
|
||||
let canonical_parent = canonical_parent
|
||||
.canonicalize()
|
||||
.map_err(|e| anyhow!("parent not accessible {}: {e}", canonical_parent.display()))?;
|
||||
if !canonical_parent.starts_with(&canonical_base) {
|
||||
return Err(anyhow!(
|
||||
"refusing to write outside data_dir: {}",
|
||||
joined.display()
|
||||
));
|
||||
}
|
||||
Ok(canonical_parent.join(
|
||||
joined.file_name().ok_or_else(|| anyhow!("no filename for {}", joined.display()))?,
|
||||
))
|
||||
}
|
||||
|
||||
/// Training data sample — a snapshot of the scene.
|
||||
#[derive(Serialize, Deserialize)]
|
||||
|
|
@ -97,12 +151,24 @@ impl Default for DepthCalibration {
|
|||
}
|
||||
|
||||
impl TrainingSession {
|
||||
/// Create a new training session rooted at `data_dir`.
|
||||
///
|
||||
/// `data_dir` must not contain `..` components — we reject path traversal
|
||||
/// attempts from CLI/API input. The directory is created if missing and
|
||||
/// then canonicalised so every subsequent write stays inside it.
|
||||
pub fn new(data_dir: &str) -> Result<Self> {
|
||||
let path = PathBuf::from(data_dir);
|
||||
std::fs::create_dir_all(&path)?;
|
||||
let path = sanitize_data_path(data_dir)?;
|
||||
std::fs::create_dir_all(&path)
|
||||
.map_err(|e| anyhow!("failed to create data_dir {}: {e}", path.display()))?;
|
||||
// Canonicalise so path-traversal checks in safe_join have a fixed root.
|
||||
let path = path
|
||||
.canonicalize()
|
||||
.map_err(|e| anyhow!("cannot canonicalise data_dir {}: {e}", path.display()))?;
|
||||
|
||||
// Load existing calibration if available
|
||||
let cal_path = path.join("calibration.json");
|
||||
let cal_path = safe_join(&path, "calibration.json")
|
||||
// safe_join needs the parent to exist; for initial load that's always data_dir
|
||||
.or_else(|_| Ok::<_, anyhow::Error>(path.join("calibration.json")))?;
|
||||
let calibration = if cal_path.exists() {
|
||||
let data = std::fs::read_to_string(&cal_path)?;
|
||||
serde_json::from_str(&data).unwrap_or_default()
|
||||
|
|
@ -257,8 +323,8 @@ impl TrainingSession {
|
|||
|
||||
eprintln!(" Occupancy threshold={:.2} accuracy={:.1}%", cal.density_threshold, cal.accuracy * 100.0);
|
||||
|
||||
// Save
|
||||
let path = self.data_dir.join("occupancy_calibration.json");
|
||||
// Save (path-traversal safe: constant filename under canonical data_dir)
|
||||
let path = safe_join(&self.data_dir, "occupancy_calibration.json")?;
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&cal)?)?;
|
||||
|
||||
Ok(cal)
|
||||
|
|
@ -295,8 +361,8 @@ impl TrainingSession {
|
|||
});
|
||||
}
|
||||
|
||||
// Save pairs
|
||||
let path = self.data_dir.join("preference_pairs.jsonl");
|
||||
// Save pairs (path-traversal safe: constant filename under canonical data_dir)
|
||||
let path = safe_join(&self.data_dir, "preference_pairs.jsonl")?;
|
||||
let mut f = std::fs::File::create(&path)?;
|
||||
for pair in &pairs {
|
||||
use std::io::Write;
|
||||
|
|
@ -347,24 +413,24 @@ impl TrainingSession {
|
|||
Ok(stored)
|
||||
}
|
||||
|
||||
/// Save current calibration to disk.
|
||||
/// Save current calibration to disk (path-traversal safe).
|
||||
fn save_calibration(&self) -> Result<()> {
|
||||
let path = self.data_dir.join("calibration.json");
|
||||
let path = safe_join(&self.data_dir, "calibration.json")?;
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&self.calibration)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save all samples to disk.
|
||||
/// Save all samples to disk (path-traversal safe).
|
||||
pub fn save_samples(&self) -> Result<()> {
|
||||
let path = self.data_dir.join("samples.json");
|
||||
let path = safe_join(&self.data_dir, "samples.json")?;
|
||||
std::fs::write(&path, serde_json::to_string_pretty(&self.samples)?)?;
|
||||
eprintln!(" Saved {} samples to {}", self.samples.len(), path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load samples from disk.
|
||||
/// Load samples from disk (path-traversal safe).
|
||||
pub fn load_samples(&mut self) -> Result<()> {
|
||||
let path = self.data_dir.join("samples.json");
|
||||
let path = safe_join(&self.data_dir, "samples.json")?;
|
||||
if path.exists() {
|
||||
let data = std::fs::read_to_string(&path)?;
|
||||
self.samples = serde_json::from_str(&data)?;
|
||||
|
|
@ -392,3 +458,40 @@ pub struct PreferencePair {
|
|||
pub chosen: String,
|
||||
pub rejected: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sanitize_rejects_parent_dir_traversal() {
|
||||
assert!(sanitize_data_path("../etc/passwd").is_err());
|
||||
assert!(sanitize_data_path("foo/../bar").is_err());
|
||||
assert!(sanitize_data_path("/tmp/.. /evil").is_ok(), "`.. ` is not ParentDir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_accepts_relative_child() {
|
||||
assert!(sanitize_data_path("data/ruview").is_ok());
|
||||
assert!(sanitize_data_path("./foo").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn training_session_new_rejects_traversal() {
|
||||
// Even if the filesystem has such a path, TrainingSession should refuse.
|
||||
let err = TrainingSession::new("../etc/passwd").err();
|
||||
assert!(err.is_some(), "traversal path must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn training_session_new_accepts_child_path() {
|
||||
// Use a unique tmpdir to avoid cross-test interference.
|
||||
let tmp = std::env::temp_dir().join(format!("ruview-train-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let sess = TrainingSession::new(tmp.to_str().unwrap())
|
||||
.expect("TrainingSession should accept a clean tmpdir");
|
||||
// data_dir should have been canonicalised to an absolute path.
|
||||
assert!(sess.data_dir.is_absolute());
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>RuView — Camera + WiFi CSI Point Cloud</title>
|
||||
<style>
|
||||
body { margin: 0; background: #0a0a0a; color: #e8a634; font-family: monospace; }
|
||||
canvas { display: block; }
|
||||
#info { position: absolute; top: 10px; left: 10px; padding: 12px; background: rgba(0,0,0,0.85); border: 1px solid #e8a634; border-radius: 6px; min-width: 240px; font-size: 13px; line-height: 1.5; }
|
||||
.live { color: #4f4; } .demo { color: #f44; }
|
||||
.section { margin-top: 6px; padding-top: 6px; border-top: 1px solid #333; }
|
||||
.label { color: #888; }
|
||||
</style>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="info">
|
||||
<h3 style="margin:0 0 8px 0">RuView Point Cloud</h3>
|
||||
<div id="stats">Loading...</div>
|
||||
</div>
|
||||
<script>
|
||||
var scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0a0a);
|
||||
var camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 100);
|
||||
camera.position.set(0, 2, -4);
|
||||
camera.lookAt(0, 0, 2);
|
||||
|
||||
var renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
var controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.target.set(0, 0, 2);
|
||||
|
||||
var pointsMesh = null;
|
||||
var lastFrame = -1;
|
||||
var skeletonGroup = null;
|
||||
var prevTimestamp = 0;
|
||||
var frameRateVal = 0;
|
||||
|
||||
// COCO skeleton connections: pairs of keypoint indices
|
||||
// 0=nose 1=leftEye 2=rightEye 3=leftEar 4=rightEar
|
||||
// 5=leftShoulder 6=rightShoulder 7=leftElbow 8=rightElbow
|
||||
// 9=leftWrist 10=rightWrist 11=leftHip 12=rightHip
|
||||
// 13=leftKnee 14=rightKnee 15=leftAnkle 16=rightAnkle
|
||||
var COCO_BONES = [
|
||||
[0,1],[0,2],[1,3],[2,4],
|
||||
[5,6],[5,7],[7,9],[6,8],[8,10],
|
||||
[5,11],[6,12],[11,12],
|
||||
[11,13],[13,15],[12,14],[14,16]
|
||||
];
|
||||
|
||||
function clearSkeleton() {
|
||||
if (skeletonGroup) {
|
||||
scene.remove(skeletonGroup);
|
||||
skeletonGroup.traverse(function(obj) {
|
||||
if (obj.geometry) obj.geometry.dispose();
|
||||
if (obj.material) obj.material.dispose();
|
||||
});
|
||||
skeletonGroup = null;
|
||||
}
|
||||
}
|
||||
|
||||
function drawSkeleton(keypoints) {
|
||||
clearSkeleton();
|
||||
if (!keypoints || keypoints.length < 17) return;
|
||||
skeletonGroup = new THREE.Group();
|
||||
|
||||
// Map keypoints from [0,1] to scene coords
|
||||
// x: [-2, 2], y: [2, -2] (flip y), z: fixed at 2
|
||||
var sphereGeo = new THREE.SphereGeometry(0.04, 8, 8);
|
||||
var sphereMat = new THREE.MeshBasicMaterial({ color: 0xffff00 });
|
||||
var positions3D = [];
|
||||
var i, kp, sx, sy;
|
||||
for (i = 0; i < 17; i++) {
|
||||
kp = keypoints[i];
|
||||
if (!kp) { positions3D.push(null); continue; }
|
||||
sx = (kp[0] - 0.5) * 4;
|
||||
sy = (0.5 - kp[1]) * 4;
|
||||
positions3D.push([sx, sy, 2]);
|
||||
var sphere = new THREE.Mesh(sphereGeo, sphereMat);
|
||||
sphere.position.set(sx, sy, 2);
|
||||
skeletonGroup.add(sphere);
|
||||
}
|
||||
|
||||
// Draw bones as white lines
|
||||
var lineMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
|
||||
var b, a, bIdx;
|
||||
for (b = 0; b < COCO_BONES.length; b++) {
|
||||
a = COCO_BONES[b][0];
|
||||
bIdx = COCO_BONES[b][1];
|
||||
if (!positions3D[a] || !positions3D[bIdx]) continue;
|
||||
var lineGeo = new THREE.BufferGeometry();
|
||||
var verts = new Float32Array([
|
||||
positions3D[a][0], positions3D[a][1], positions3D[a][2],
|
||||
positions3D[bIdx][0], positions3D[bIdx][1], positions3D[bIdx][2]
|
||||
]);
|
||||
lineGeo.setAttribute("position", new THREE.BufferAttribute(verts, 3));
|
||||
var line = new THREE.Line(lineGeo, lineMat);
|
||||
skeletonGroup.add(line);
|
||||
}
|
||||
|
||||
scene.add(skeletonGroup);
|
||||
}
|
||||
|
||||
async function fetchCloud() {
|
||||
try {
|
||||
var resp = await fetch("/api/splats");
|
||||
var data = await resp.json();
|
||||
if (data.splats && data.frame !== lastFrame) {
|
||||
// Compute CSI frame rate
|
||||
var now = Date.now();
|
||||
if (prevTimestamp > 0) {
|
||||
var dt = (now - prevTimestamp) / 1000.0;
|
||||
if (dt > 0) frameRateVal = (1.0 / dt).toFixed(1);
|
||||
}
|
||||
prevTimestamp = now;
|
||||
lastFrame = data.frame;
|
||||
updateSplats(data.splats);
|
||||
|
||||
// Draw skeleton if available
|
||||
var pipe = data.pipeline;
|
||||
if (pipe && pipe.skeleton && pipe.skeleton.keypoints) {
|
||||
drawSkeleton(pipe.skeleton.keypoints);
|
||||
} else {
|
||||
clearSkeleton();
|
||||
}
|
||||
|
||||
// Build info panel
|
||||
var mode = data.live
|
||||
? '<span class="live">● LIVE</span>'
|
||||
: '<span class="demo">● DEMO</span>';
|
||||
var html = mode + " Camera + CSI<br>"
|
||||
+ "Splats: " + data.count + "<br>"
|
||||
+ "Frame: " + data.frame;
|
||||
|
||||
// CSI frame rate
|
||||
html += '<div class="section">'
|
||||
+ '<span class="label">CSI Rate:</span> '
|
||||
+ frameRateVal + " fps</div>";
|
||||
|
||||
// Skeleton confidence
|
||||
if (pipe && pipe.skeleton && pipe.skeleton.confidence !== undefined) {
|
||||
var conf = (pipe.skeleton.confidence * 100).toFixed(0);
|
||||
html += '<div class="section">'
|
||||
+ '<span class="label">Skeleton:</span> '
|
||||
+ conf + "% confidence</div>";
|
||||
}
|
||||
|
||||
// Weather data
|
||||
if (pipe && pipe.weather) {
|
||||
var w = pipe.weather;
|
||||
html += '<div class="section">'
|
||||
+ '<span class="label">Weather:</span> ';
|
||||
if (w.temperature !== undefined) {
|
||||
html += w.temperature + "°C";
|
||||
}
|
||||
if (w.conditions) {
|
||||
html += " " + w.conditions;
|
||||
}
|
||||
html += "</div>";
|
||||
}
|
||||
|
||||
// Building count from geo
|
||||
if (pipe && pipe.geo && pipe.geo.building_count !== undefined) {
|
||||
html += '<div class="section">'
|
||||
+ '<span class="label">Buildings:</span> '
|
||||
+ pipe.geo.building_count + "</div>";
|
||||
}
|
||||
|
||||
// Vitals
|
||||
if (pipe && pipe.vitals) {
|
||||
var v = pipe.vitals;
|
||||
html += '<div class="section">'
|
||||
+ '<span class="label">Vitals:</span> ';
|
||||
if (v.breathing_rate !== undefined) {
|
||||
html += "BR " + v.breathing_rate + "/min";
|
||||
}
|
||||
if (v.motion_score !== undefined) {
|
||||
html += " Motion " + (v.motion_score * 100).toFixed(0) + "%";
|
||||
}
|
||||
html += "</div>";
|
||||
}
|
||||
|
||||
document.getElementById("stats").innerHTML = html;
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
fetchCloud();
|
||||
setInterval(fetchCloud, 500);
|
||||
|
||||
function updateSplats(splats) {
|
||||
if (pointsMesh) scene.remove(pointsMesh);
|
||||
var geometry = new THREE.BufferGeometry();
|
||||
var positions = new Float32Array(splats.length * 3);
|
||||
var colors = new Float32Array(splats.length * 3);
|
||||
var i, s;
|
||||
for (i = 0; i < splats.length; i++) {
|
||||
s = splats[i];
|
||||
positions[i*3] = s.center[0];
|
||||
positions[i*3+1] = -s.center[1];
|
||||
positions[i*3+2] = s.center[2];
|
||||
colors[i*3] = s.color[0];
|
||||
colors[i*3+1] = s.color[1];
|
||||
colors[i*3+2] = s.color[2];
|
||||
}
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||
pointsMesh = new THREE.Points(geometry, new THREE.PointsMaterial({
|
||||
size: 0.02, vertexColors: true, sizeAttenuation: true
|
||||
}));
|
||||
scene.add(pointsMesh);
|
||||
}
|
||||
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
animate();
|
||||
window.addEventListener("resize", function() {
|
||||
camera.aspect = window.innerWidth / window.innerHeight;
|
||||
camera.updateProjectionMatrix();
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -2797,7 +2797,7 @@ async fn delete_model(
|
|||
if safe_id.is_empty() || safe_id != id {
|
||||
return Json(serde_json::json!({ "error": "invalid model id", "success": false }));
|
||||
}
|
||||
let path = PathBuf::from("data/models").join(format!("{}.rvf", safe_id));
|
||||
let path = effective_models_dir().join(format!("{}.rvf", safe_id));
|
||||
if path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
warn!("Failed to delete model file {:?}: {}", path, e);
|
||||
|
|
@ -2842,9 +2842,18 @@ async fn activate_lora_profile(
|
|||
Json(serde_json::json!({ "success": true, "profile": profile }))
|
||||
}
|
||||
|
||||
/// Scan `data/models/` for `.rvf` files and return metadata.
|
||||
/// Return the effective models directory, respecting the `MODELS_DIR`
|
||||
/// environment variable. Defaults to `data/models`.
|
||||
fn effective_models_dir() -> PathBuf {
|
||||
PathBuf::from(
|
||||
std::env::var("MODELS_DIR").unwrap_or_else(|_| "data/models".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Scan the models directory for `.rvf` files and return metadata.
|
||||
/// Respects the `MODELS_DIR` environment variable.
|
||||
fn scan_model_files() -> Vec<serde_json::Value> {
|
||||
let dir = PathBuf::from("data/models");
|
||||
let dir = effective_models_dir();
|
||||
let mut models = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
|
|
@ -2874,9 +2883,10 @@ fn scan_model_files() -> Vec<serde_json::Value> {
|
|||
models
|
||||
}
|
||||
|
||||
/// Scan `data/models/` for `.lora.json` LoRA profile files.
|
||||
/// Scan the models directory for `.lora.json` LoRA profile files.
|
||||
/// Respects the `MODELS_DIR` environment variable.
|
||||
fn scan_lora_profiles() -> Vec<serde_json::Value> {
|
||||
let dir = PathBuf::from("data/models");
|
||||
let dir = effective_models_dir();
|
||||
let mut profiles = Vec::new();
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
|
|
@ -4604,7 +4614,8 @@ async fn main() {
|
|||
}
|
||||
|
||||
// Ensure data directories exist for models and recordings
|
||||
let _ = std::fs::create_dir_all("data/models");
|
||||
let models_dir = effective_models_dir();
|
||||
let _ = std::fs::create_dir_all(&models_dir);
|
||||
let _ = std::fs::create_dir_all("data/recordings");
|
||||
|
||||
// Discover model and recording files on startup
|
||||
|
|
|
|||
|
|
@ -30,8 +30,19 @@ use crate::rvf_container::RvfReader;
|
|||
|
||||
// ── Models data directory ────────────────────────────────────────────────────
|
||||
|
||||
/// Base directory for RVF model files.
|
||||
pub const MODELS_DIR: &str = "data/models";
|
||||
/// Default base directory for RVF model files.
|
||||
///
|
||||
/// Overridden at runtime by the `MODELS_DIR` environment variable so that
|
||||
/// Docker users can point to a mounted volume without rebuilding:
|
||||
/// docker run -v /path/to/models:/app/models -e MODELS_DIR=/app/models ...
|
||||
pub const MODELS_DIR_DEFAULT: &str = "data/models";
|
||||
|
||||
/// Return the effective models directory, respecting `MODELS_DIR` env var.
|
||||
pub fn models_dir() -> PathBuf {
|
||||
PathBuf::from(
|
||||
std::env::var("MODELS_DIR").unwrap_or_else(|_| MODELS_DIR_DEFAULT.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -110,7 +121,7 @@ pub type AppState = Arc<RwLock<super::AppStateInner>>;
|
|||
|
||||
/// Scan the models directory and build `ModelInfo` for each `.rvf` file.
|
||||
async fn scan_models() -> Vec<ModelInfo> {
|
||||
let dir = PathBuf::from(MODELS_DIR);
|
||||
let dir = models_dir();
|
||||
let mut models = Vec::new();
|
||||
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
|
|
@ -204,7 +215,7 @@ async fn scan_models() -> Vec<ModelInfo> {
|
|||
|
||||
/// Load a model from disk by ID and return its `LoadedModelState`.
|
||||
fn load_model_from_disk(model_id: &str) -> Result<LoadedModelState, String> {
|
||||
let file_path = PathBuf::from(MODELS_DIR).join(format!("{model_id}.rvf"));
|
||||
let file_path = models_dir().join(format!("{model_id}.rvf"));
|
||||
let reader = RvfReader::from_file(&file_path)?;
|
||||
|
||||
let manifest = reader.manifest().unwrap_or_default();
|
||||
|
|
|
|||
|
|
@ -362,6 +362,45 @@ def validate_log(log_text: str) -> ValidationReport:
|
|||
report.add("Frame rate", Severity.SKIP,
|
||||
"No periodic frame reports found")
|
||||
|
||||
# ---- Check 17: ADR-081 adaptive controller boot ----
|
||||
adapt_boot_patterns = [
|
||||
r"adaptive_ctrl:.*adaptive controller online",
|
||||
r"adaptive_ctrl:\s*state\s+\d+\s*\xe2\x86\x92",
|
||||
r"adapt=on",
|
||||
]
|
||||
adapt_boot = any(re.search(p, log_text) for p in adapt_boot_patterns)
|
||||
if adapt_boot:
|
||||
report.add("ADR-081 controller", Severity.PASS,
|
||||
"Adaptive controller started (ADR-081 Layer 2)")
|
||||
else:
|
||||
report.add("ADR-081 controller", Severity.WARN,
|
||||
"No adaptive_ctrl: log line found "
|
||||
"(expected ADR-081 Layer 2 online)")
|
||||
|
||||
# ---- Check 18: ADR-081 mock radio binding (QEMU only) ----
|
||||
mock_radio = re.search(r"rv_radio_mock:.*registered", log_text)
|
||||
if mock_radio:
|
||||
report.add("ADR-081 radio binding", Severity.PASS,
|
||||
"Mock radio ops binding registered "
|
||||
"(ADR-081 Layer 1 portability gate)")
|
||||
else:
|
||||
# Only required when CONFIG_CSI_MOCK_ENABLED — downgrade to SKIP.
|
||||
report.add("ADR-081 radio binding", Severity.SKIP,
|
||||
"No rv_radio_mock registration line "
|
||||
"(expected if CONFIG_CSI_MOCK_ENABLED)")
|
||||
|
||||
# ---- Check 19: ADR-081 slow-loop heartbeat ----
|
||||
slow_tick = re.search(r"adaptive_ctrl:\s*slow tick", log_text)
|
||||
if slow_tick:
|
||||
report.add("ADR-081 slow loop", Severity.PASS,
|
||||
"Slow loop heartbeat observed "
|
||||
"(controller is ticking at ≥30 s cadence)")
|
||||
else:
|
||||
# A 60s QEMU timeout may not reach the first slow tick (30s default
|
||||
# plus boot time); treat as SKIP not WARN.
|
||||
report.add("ADR-081 slow loop", Severity.SKIP,
|
||||
"No slow tick (QEMU run shorter than slow_loop_ms)")
|
||||
|
||||
return report
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,142 @@
|
|||
#!/bin/bash
|
||||
# Regression tests for docker-entrypoint.sh
|
||||
#
|
||||
# Validates that the entrypoint script correctly handles:
|
||||
# 1. No arguments → uses env var defaults
|
||||
# 2. Flag arguments → prepends sensing-server binary
|
||||
# 3. Explicit binary path → passes through unchanged
|
||||
# 4. CSI_SOURCE env var substitution
|
||||
# 5. MODELS_DIR env var propagation
|
||||
#
|
||||
# These tests use a stub sensing-server that just prints its args.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ENTRYPOINT="$SCRIPT_DIR/../docker/docker-entrypoint.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_contains() {
|
||||
local test_name="$1"
|
||||
local haystack="$2"
|
||||
local needle="$3"
|
||||
if printf '%s\n' "$haystack" | grep -qF -- "$needle"; then
|
||||
PASS=$((PASS + 1))
|
||||
echo " ✓ $test_name"
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
echo " ✗ $test_name"
|
||||
echo " expected to contain: $needle"
|
||||
echo " got: $haystack"
|
||||
fi
|
||||
}
|
||||
|
||||
assert_not_contains() {
|
||||
local test_name="$1"
|
||||
local haystack="$2"
|
||||
local needle="$3"
|
||||
if printf '%s\n' "$haystack" | grep -qF -- "$needle"; then
|
||||
FAIL=$((FAIL + 1))
|
||||
echo " ✗ $test_name"
|
||||
echo " expected NOT to contain: $needle"
|
||||
echo " got: $haystack"
|
||||
else
|
||||
PASS=$((PASS + 1))
|
||||
echo " ✓ $test_name"
|
||||
fi
|
||||
}
|
||||
|
||||
# Create a temporary stub for /app/sensing-server that just prints args
|
||||
TMPDIR=$(mktemp -d)
|
||||
trap "rm -rf $TMPDIR" EXIT
|
||||
|
||||
STUB="$TMPDIR/sensing-server"
|
||||
cat > "$STUB" << 'EOF'
|
||||
#!/bin/sh
|
||||
echo "EXEC_ARGS: $@"
|
||||
EOF
|
||||
chmod +x "$STUB"
|
||||
|
||||
# We'll modify the entrypoint to use our stub path for testing
|
||||
TEST_ENTRYPOINT="$TMPDIR/docker-entrypoint.sh"
|
||||
sed "s|/app/sensing-server|$STUB|g" "$ENTRYPOINT" > "$TEST_ENTRYPOINT"
|
||||
chmod +x "$TEST_ENTRYPOINT"
|
||||
|
||||
echo "=== Docker entrypoint tests ==="
|
||||
|
||||
# Test 1: No arguments — should use CSI_SOURCE default (auto)
|
||||
echo ""
|
||||
echo "Test 1: No arguments (default CSI_SOURCE=auto)"
|
||||
OUT=$(CSI_SOURCE=auto "$TEST_ENTRYPOINT" 2>&1)
|
||||
assert_contains "includes --source auto" "$OUT" "--source auto"
|
||||
assert_contains "includes --tick-ms 100" "$OUT" "--tick-ms 100"
|
||||
assert_contains "includes --ui-path" "$OUT" "--ui-path /app/ui"
|
||||
assert_contains "includes --http-port 3000" "$OUT" "--http-port 3000"
|
||||
assert_contains "includes --ws-port 3001" "$OUT" "--ws-port 3001"
|
||||
assert_contains "includes --bind-addr 0.0.0.0" "$OUT" "--bind-addr 0.0.0.0"
|
||||
|
||||
# Test 2: CSI_SOURCE=esp32 — should substitute
|
||||
echo ""
|
||||
echo "Test 2: CSI_SOURCE=esp32"
|
||||
OUT=$(CSI_SOURCE=esp32 "$TEST_ENTRYPOINT" 2>&1)
|
||||
assert_contains "includes --source esp32" "$OUT" "--source esp32"
|
||||
|
||||
# Test 3: Flag arguments — should prepend binary
|
||||
echo ""
|
||||
echo "Test 3: User passes --source wifi --tick-ms 500"
|
||||
OUT=$(CSI_SOURCE=auto "$TEST_ENTRYPOINT" --source wifi --tick-ms 500 2>&1)
|
||||
assert_contains "includes --source wifi" "$OUT" "--source wifi"
|
||||
assert_contains "includes --tick-ms 500" "$OUT" "--tick-ms 500"
|
||||
|
||||
# Test 4: No CSI_SOURCE set — should default to auto
|
||||
echo ""
|
||||
echo "Test 4: CSI_SOURCE unset"
|
||||
OUT=$(unset CSI_SOURCE; "$TEST_ENTRYPOINT" 2>&1)
|
||||
assert_contains "includes --source auto (default)" "$OUT" "--source auto"
|
||||
|
||||
# Test 5: User passes --model flag — should be appended
|
||||
echo ""
|
||||
echo "Test 5: User passes --model /app/models/my.rvf"
|
||||
OUT=$(CSI_SOURCE=esp32 "$TEST_ENTRYPOINT" --model /app/models/my.rvf 2>&1)
|
||||
assert_contains "includes --model" "$OUT" "--model /app/models/my.rvf"
|
||||
assert_contains "also includes default flags" "$OUT" "--source esp32"
|
||||
|
||||
# Test 6: CSI_SOURCE=simulated
|
||||
echo ""
|
||||
echo "Test 6: CSI_SOURCE=simulated"
|
||||
OUT=$(CSI_SOURCE=simulated "$TEST_ENTRYPOINT" 2>&1)
|
||||
assert_contains "includes --source simulated" "$OUT" "--source simulated"
|
||||
|
||||
# Test 7: Explicit binary path passed (e.g., docker run <image> /bin/sh)
|
||||
# First arg does NOT start with -, so entrypoint should exec it directly
|
||||
echo ""
|
||||
echo "Test 7: Explicit command (echo hello)"
|
||||
OUT=$("$TEST_ENTRYPOINT" echo hello 2>&1)
|
||||
assert_contains "passes through explicit command" "$OUT" "hello"
|
||||
assert_not_contains "does not inject sensing-server flags" "$OUT" "--source"
|
||||
|
||||
# Test 8: MODELS_DIR env var is passed through to the process
|
||||
echo ""
|
||||
echo "Test 8: MODELS_DIR env var propagation"
|
||||
# Create a stub that prints MODELS_DIR
|
||||
ENV_STUB="$TMPDIR/env-sensing-server"
|
||||
cat > "$ENV_STUB" << 'ENVEOF'
|
||||
#!/bin/sh
|
||||
echo "MODELS_DIR=${MODELS_DIR:-unset}"
|
||||
ENVEOF
|
||||
chmod +x "$ENV_STUB"
|
||||
ENV_ENTRYPOINT="$TMPDIR/env-entrypoint.sh"
|
||||
sed "s|/app/sensing-server|$ENV_STUB|g" "$ENTRYPOINT" > "$ENV_ENTRYPOINT"
|
||||
chmod +x "$ENV_ENTRYPOINT"
|
||||
|
||||
OUT=$(MODELS_DIR=/app/models CSI_SOURCE=auto "$ENV_ENTRYPOINT" 2>&1)
|
||||
assert_contains "MODELS_DIR is visible" "$OUT" "MODELS_DIR=/app/models"
|
||||
|
||||
OUT=$(unset MODELS_DIR; CSI_SOURCE=auto "$ENV_ENTRYPOINT" 2>&1)
|
||||
assert_contains "MODELS_DIR defaults to unset" "$OUT" "MODELS_DIR=unset"
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
Loading…
Reference in New Issue