mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-26 02:04:55 +00:00
fix: align multistatic CSI time and clear Rust advisories (#1669)
Use mesh-aligned capture timestamps, remediate Rust advisories, harden the audit gate, and correct deployment claims. Includes the live MQTT subscriber lifetime fix verified against Mosquitto.
This commit is contained in:
27
.github/workflows/security-scan.yml
vendored
27
.github/workflows/security-scan.yml
vendored
@@ -14,6 +14,33 @@ env:
|
|||||||
PYTHON_VERSION: '3.11'
|
PYTHON_VERSION: '3.11'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Rust dependency advisories are deterministic for the checked-in lockfile,
|
||||||
|
# so this job gates the PR and retains the exact machine-readable report.
|
||||||
|
rust-audit:
|
||||||
|
name: Rust Dependency Audit
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||||
|
|
||||||
|
- name: Install cargo-audit
|
||||||
|
run: cargo install cargo-audit --locked --version 0.22.2
|
||||||
|
|
||||||
|
- name: Audit the checked-in Rust lockfile
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
cargo audit --file v2/Cargo.lock --json | tee v2/cargo-audit.json
|
||||||
|
|
||||||
|
- name: Upload Rust advisory report
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: cargo-audit-report
|
||||||
|
path: v2/cargo-audit.json
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
# Static Application Security Testing (SAST)
|
# Static Application Security Testing (SAST)
|
||||||
sast:
|
sast:
|
||||||
name: Static Application Security Testing
|
name: Static Application Security Testing
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -245,8 +245,8 @@ See the measured benchmarks, witness records, and one-command reproducibility ch
|
|||||||
|------|-------|---------|
|
|------|-------|---------|
|
||||||
| **MM-Fi pose model (SOTA)** | [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) | 82.69% torso-PCK@20 (single) · 83.59% (ensemble+TTA) · 75K-param micro variant 74.30% |
|
| **MM-Fi pose model (SOTA)** | [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose) | 82.69% torso-PCK@20 (single) · 83.59% (ensemble+TTA) · 75K-param micro variant 74.30% |
|
||||||
| **AetherArena benchmark Space** | [`ruvnet/aether-arena`](https://huggingface.co/spaces/ruvnet/aether-arena) | self-correcting, auditable MM-Fi leaderboard |
|
| **AetherArena benchmark Space** | [`ruvnet/aether-arena`](https://huggingface.co/spaces/ruvnet/aether-arena) | self-correcting, auditable MM-Fi leaderboard |
|
||||||
| **Full MM-Fi study (honest picture)** | [`docs/benchmarks/mmfi-wifi-sensing-study.md`](docs/benchmarks/mmfi-wifi-sensing-study.md) | pose + action; zero-shot cross-subject ~64%, +~30 s in-room calibration → 72.2% |
|
| **Full MM-Fi study (honest picture)** | [`docs/benchmarks/mmfi-wifi-sensing-study.md`](docs/benchmarks/mmfi-wifi-sensing-study.md) | pose + action; zero-shot cross-subject ~64%, labeled in-room calibration → 72.2% |
|
||||||
| **Efficiency frontier** | [`docs/benchmarks/wifi-pose-efficiency-frontier.md`](docs/benchmarks/wifi-pose-efficiency-frontier.md) | SOTA-beating WiFi pose in a 20 KB int4 edge model |
|
| **Efficiency frontier** | [`docs/benchmarks/wifi-pose-efficiency-frontier.md`](docs/benchmarks/wifi-pose-efficiency-frontier.md) | SOTA-beating MM-Fi pose in a ~37 KB int4 model; live ESP32 compatibility not established |
|
||||||
| **Pretrained encoder** | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) | 82.3% held-out temporal-triplet, 8 KB int4 |
|
| **Pretrained encoder** | [`ruvnet/wifi-densepose-pretrained`](https://huggingface.co/ruvnet/wifi-densepose-pretrained) | 82.3% held-out temporal-triplet, 8 KB int4 |
|
||||||
| **Reproducible proof (Trust Kill Switch)** | [`archive/v1/data/proof/verify.py`](archive/v1/data/proof/verify.py) + [`expected_features.sha256`](archive/v1/data/proof/expected_features.sha256) | one-command deterministic pipeline replay (SHA-256 of output vs published hash) |
|
| **Reproducible proof (Trust Kill Switch)** | [`archive/v1/data/proof/verify.py`](archive/v1/data/proof/verify.py) + [`expected_features.sha256`](archive/v1/data/proof/expected_features.sha256) | one-command deterministic pipeline replay (SHA-256 of output vs published hash) |
|
||||||
| **Benchmark-proof ADR** | [ADR-168](docs/adr/ADR-168-benchmark-proof.md) | how the numbers are produced and verified |
|
| **Benchmark-proof ADR** | [ADR-168](docs/adr/ADR-168-benchmark-proof.md) | how the numbers are produced and verified |
|
||||||
@@ -488,12 +488,20 @@ Neural Network: processed signals → 17 body keypoints + vital signs + room mod
|
|||||||
Output: real-time pose, breathing, heart rate, room fingerprint, drift alerts
|
Output: real-time pose, breathing, heart rate, room fingerprint, drift alerts
|
||||||
```
|
```
|
||||||
|
|
||||||
No training cameras required — the [Self-Learning system (ADR-024)](docs/adr/ADR-024-contrastive-csi-embedding-model.md) bootstraps from raw WiFi data alone. [MERIDIAN (ADR-027)](docs/adr/ADR-027-cross-environment-domain-generalization.md) ensures the model works in any room, not just the one it trained in.
|
The [Self-Learning system (ADR-024)](docs/adr/ADR-024-contrastive-csi-embedding-model.md) provides
|
||||||
|
camera-free representation-learning components. Cross-room pose remains a separate, data-gated
|
||||||
|
problem: [MERIDIAN (ADR-027)](docs/adr/ADR-027-cross-environment-domain-generalization.md) is
|
||||||
|
**Proposed**, while the measured calibration reference requires labeled CSI/keypoint pairs and
|
||||||
|
model-specific adapters. See the [model compatibility boundary](docs/user-guide.md#model-and-capture-compatibility).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏢 Use Cases & Applications
|
## 🏢 Use Cases & Applications
|
||||||
|
|
||||||
|
> **Safety boundary:** these are research and prototype applications, not medical devices,
|
||||||
|
> emergency systems, or safety-certified controls. Vital-sign and pose outputs require independent
|
||||||
|
> validation on the exact hardware, room, subjects, and failure conditions before operational use.
|
||||||
|
|
||||||
WiFi sensing works anywhere WiFi exists. No new hardware in most cases — just software on existing access points or a $8 ESP32 add-on. Because there are no cameras, deployments avoid privacy regulations (GDPR video, HIPAA imaging) by design.
|
WiFi sensing works anywhere WiFi exists. No new hardware in most cases — just software on existing access points or a $8 ESP32 add-on. Because there are no cameras, deployments avoid privacy regulations (GDPR video, HIPAA imaging) by design.
|
||||||
|
|
||||||
**Scaling:** Each AP distinguishes ~3-5 people (56 subcarriers). Multi-AP multiplies linearly — a 4-AP retail mesh covers ~15-20 occupants. No hard software limit; the practical ceiling is signal physics.
|
**Scaling:** Each AP distinguishes ~3-5 people (56 subcarriers). Multi-AP multiplies linearly — a 4-AP retail mesh covers ~15-20 occupants. No hard software limit; the practical ceiling is signal physics.
|
||||||
@@ -528,7 +536,7 @@ WiFi sensing works anywhere WiFi exists. No new hardware in most cases — just
|
|||||||
| Use Case | What It Does | Hardware | Key Metric | Edge Module |
|
| Use Case | What It Does | Hardware | Key Metric | Edge Module |
|
||||||
|----------|-------------|----------|------------|-------------|
|
|----------|-------------|----------|------------|-------------|
|
||||||
| **Smart home automation** | Room-level presence triggers (lights, HVAC, music) that work through walls — no dead zones, no motion-sensor timeouts | 2-3 ESP32-S3 nodes ($24) | Through-wall range ~5m | [HVAC Presence](docs/edge-modules/building.md), [Lighting Zones](docs/edge-modules/building.md) |
|
| **Smart home automation** | Room-level presence triggers (lights, HVAC, music) that work through walls — no dead zones, no motion-sensor timeouts | 2-3 ESP32-S3 nodes ($24) | Through-wall range ~5m | [HVAC Presence](docs/edge-modules/building.md), [Lighting Zones](docs/edge-modules/building.md) |
|
||||||
| **Fitness & sports** | Rep counting, posture correction, breathing cadence during exercise — no wearable, no camera in locker rooms | 3+ ESP32-S3 mesh | Pose: 17 keypoints | [Breathing Sync](docs/edge-modules/exotic.md), [Gait Analysis](docs/edge-modules/medical.md) |
|
| **Fitness & sports research** | Explore motion and breathing cadence without a wearable or camera; reliable posture correction requires a validated compatible pose model | 3+ ESP32-S3 mesh + edge host | Prototype; no live S3 pose accuracy claim | [Breathing Sync](docs/edge-modules/exotic.md), [Gait Analysis](docs/edge-modules/medical.md) |
|
||||||
| **Childcare & schools** | Naptime breathing monitoring, playground headcount, restricted-area alerts — privacy-safe for minors | 2-4 ESP32-S3 per zone | Breathing: ±1 BPM | [Sleep Apnea](docs/edge-modules/medical.md), [Perimeter Breach](docs/edge-modules/security.md) |
|
| **Childcare & schools** | Naptime breathing monitoring, playground headcount, restricted-area alerts — privacy-safe for minors | 2-4 ESP32-S3 per zone | Breathing: ±1 BPM | [Sleep Apnea](docs/edge-modules/medical.md), [Perimeter Breach](docs/edge-modules/security.md) |
|
||||||
| **Event venues & concerts** | Crowd density mapping, crush-risk detection via breathing compression, emergency evacuation flow tracking | Multi-AP mesh (4-8 APs) | Density per m² | [Customer Flow](docs/edge-modules/retail.md), [Panic Motion](docs/edge-modules/security.md) |
|
| **Event venues & concerts** | Crowd density mapping, crush-risk detection via breathing compression, emergency evacuation flow tracking | Multi-AP mesh (4-8 APs) | Density per m² | [Customer Flow](docs/edge-modules/retail.md), [Panic Motion](docs/edge-modules/security.md) |
|
||||||
| **Stadiums & arenas** | Section-level occupancy for dynamic pricing, concession staffing, emergency egress flow modeling | Enterprise AP grid | 15-20 per AP mesh | [Dwell Heatmap](docs/edge-modules/retail.md), [Queue Length](docs/edge-modules/retail.md) |
|
| **Stadiums & arenas** | Section-level occupancy for dynamic pricing, concession staffing, emergency egress flow modeling | Enterprise AP grid | 15-20 per AP mesh | [Dwell Heatmap](docs/edge-modules/retail.md), [Queue Length](docs/edge-modules/retail.md) |
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
# RuView Calibration Service (reference implementation)
|
# RuView Calibration Service (reference implementation)
|
||||||
|
|
||||||
Turn a **shared WiFi-CSI pose base model** into a room-specific one with a **30-second labeled
|
Fit a room-specific **~11 KB LoRA adapter** for a shared WiFi-CSI pose base from a short **labeled
|
||||||
calibration** and a **~11 KB per-room LoRA adapter**. This is the deployable resolution of the
|
capture**. This is a measured MM-Fi reference path for cross-subject / cross-environment adaptation
|
||||||
cross-subject / cross-environment generalization problem (full study: [ADR-150 §3.3–3.6](../../docs/adr/ADR-150-rf-foundation-encoder.md)).
|
(full study: [ADR-150 §3.3–3.6](../../docs/adr/ADR-150-rf-foundation-encoder.md)); it is not proof of
|
||||||
|
plug-and-play adaptation from a live ESP32 stream.
|
||||||
|
|
||||||
|
> **Not the proposed MERIDIAN fast path.** Both producers below require paired CSI and keypoint
|
||||||
|
> labels, and their tensor shapes and adapter files are model-specific. ADR-027's automatic,
|
||||||
|
> unlabeled 10-second MERIDIAN calibration remains **Proposed** and is not implemented as an
|
||||||
|
> end-to-end deployment command.
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
@@ -66,8 +72,8 @@ Adapters are **model-specific**. There are two calibration producers here:
|
|||||||
| `cog_calibrate.py` | cog **conv+MLP** (`pose_v1.safetensors`, 56×20) | `[N,56,20]` | `.safetensors` (`fc1.a`/`fc1.b`/`fc2.a`/`fc2.b`) | Rust `cog-pose-estimation run --adapter` |
|
| `cog_calibrate.py` | cog **conv+MLP** (`pose_v1.safetensors`, 56×20) | `[N,56,20]` | `.safetensors` (`fc1.a`/`fc1.b`/`fc2.a`/`fc2.b`) | Rust `cog-pose-estimation run --adapter` |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Produce a cog-format per-room adapter for the deployed Rust pose engine:
|
# Produce a cog-format per-room adapter from X:[N,56,20], Y:[N,17,2]:
|
||||||
python cog_calibrate.py --base pose_v1.safetensors --data calib.npz --out room.safetensors
|
python cog_calibrate.py --base pose_v1.safetensors --data cog-calib.npz --out room.safetensors
|
||||||
# then in the cog runtime:
|
# then in the cog runtime:
|
||||||
cog-pose-estimation run --config <cfg> --adapter room.safetensors
|
cog-pose-estimation run --config <cfg> --adapter room.safetensors
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ PCK@20 (MultiFormer Table VII metric: `‖pred−gt‖ ≤ 0.2·‖R-shoulder
|
|||||||
|
|
||||||
The flagship [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose)
|
The flagship [`ruvnet/wifi-densepose-mmfi-pose`](https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose)
|
||||||
reaches **83.59%** torso-PCK@20 (vs MultiFormer 72.25%, CSI2Pose 68.41%). But the headline number
|
reaches **83.59%** torso-PCK@20 (vs MultiFormer 72.25%, CSI2Pose 68.41%). But the headline number
|
||||||
isn't the whole story for **edge deployment** — on a Raspberry Pi / ESP32-class target, *params and
|
isn't the whole story for **edge deployment** — on a Raspberry Pi-class edge host, *params and
|
||||||
latency* matter as much as accuracy. So we swept model size to map the **accuracy-per-parameter
|
latency* matter as much as accuracy. So we swept model size to map the **accuracy-per-parameter
|
||||||
frontier**: how small can a WiFi-CSI pose model be and still beat the prior published SOTA?
|
frontier**: how small can a WiFi-CSI pose model be and still beat the prior published SOTA?
|
||||||
|
|
||||||
|
> **Hardware compatibility boundary.** These models consume MM-Fi tensors shaped
|
||||||
|
> `[3,114,10]`. Parameter size alone does not make that input, model architecture, or runtime
|
||||||
|
> compatible with an ESP32-S3/C6 capture node. The measurements below are dataset and x86/GPU
|
||||||
|
> measurements; no ESP32 inference latency or live ESP32-to-MM-Fi adapter is claimed.
|
||||||
|
|
||||||
## The frontier
|
## The frontier
|
||||||
|
|
||||||
| Model | Params | Latency (batch=1) | torso-PCK@20 | vs SOTA (72.25%) |
|
| Model | Params | Latency (batch=1) | torso-PCK@20 | vs SOTA (72.25%) |
|
||||||
@@ -38,8 +43,10 @@ Size alone isn't the claim — what matters is **accuracy at the deployed precis
|
|||||||
|
|
||||||
**The honest edge result:** `micro` is **lossless at int8 (73.5 KB, 74.70%)**, and at **int4 (36.7 KB)
|
**The honest edge result:** `micro` is **lossless at int8 (73.5 KB, 74.70%)**, and at **int4 (36.7 KB)
|
||||||
naïve post-training quantization falls below SOTA (70.21%) — but quantization-aware training fully
|
naïve post-training quantization falls below SOTA (70.21%) — but quantization-aware training fully
|
||||||
recovers it to 74.46%**, still beating MultiFormer. So a **SOTA-beating WiFi-pose model genuinely runs
|
recovers it to 74.46%**, still beating MultiFormer. So a **SOTA-beating WiFi-pose model fits in
|
||||||
in ~37 KB int4** (with QAT) or **~73 KB int8** (no retraining) — deployable on the sensing node itself.
|
~37 KB int4** (with QAT) or **~73 KB int8** (no retraining). That is a model-footprint result, not
|
||||||
|
evidence that it runs on an ESP32 sensing node; a compatible capture adapter and embedded runtime
|
||||||
|
still need to be implemented and measured.
|
||||||
`nano` (40K params) sits at the SOTA line in fp32 and is best treated as int8.
|
`nano` (40K params) sits at the SOTA line in fp32 and is best treated as int8.
|
||||||
|
|
||||||
(We also tested flagship→tiny **knowledge distillation**: it did *not* help — the tiny students reach
|
(We also tested flagship→tiny **knowledge distillation**: it did *not* help — the tiny students reach
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
|||||||
|
|
||||||
| Option | Cost | Capabilities |
|
| Option | Cost | Capabilities |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| ESP32-S3 mesh (3-6 boards) | ~$54 | Full CSI: pose, breathing, heartbeat, presence |
|
| ESP32-S3 mesh (3-6 boards) | ~$54 | CSI capture for presence, motion, and vital-sign heuristics; live 17-keypoint pose remains below-target and is not a validated capability |
|
||||||
| Intel 5300 / Atheros AR9580 | $50-100 | Full CSI with 3x3 MIMO (Linux only) |
|
| Intel 5300 / Atheros AR9580 | $50-100 | Full CSI with 3x3 MIMO (Linux only) |
|
||||||
| Any WiFi laptop | $0 | RSSI-only: coarse presence and motion detection |
|
| Any WiFi laptop | $0 | RSSI-only: coarse presence and motion detection |
|
||||||
|
|
||||||
@@ -1214,6 +1214,22 @@ levels. Read the label, not the headline ([ADR-187](adr/ADR-187-archive-v1-depre
|
|||||||
|
|
||||||
**Does it actually run, and can a single ESP32 do pose? ([#509](https://github.com/ruvnet/RuView/issues/509), [#1125](https://github.com/ruvnet/RuView/issues/1125))** Yes, it runs, and the results are reproducible: the deterministic signal-pipeline proof (`python archive/v1/data/proof/verify.py`, must print `VERDICT: PASS`), the committed pose training dump (`v2/crates/cog-pose-estimation/cog/artifacts/train_results.json`), and the auditable MM-Fi arena all back specific numbers. But a single-antenna, 56-subcarrier CSI stream at a 20-frame window does *not* carry the fine-grained spatial information the multi-antenna NIC research relies on — so the shippable pose accuracy the project stands behind today is the **MM-Fi benchmark number**, not a live single-ESP32 number. The path to a first reproducible on-device baseline (PCK@20 ≥ 35%) is tracked in [ADR-079](adr/ADR-079-camera-ground-truth-training.md) / [#645](https://github.com/ruvnet/RuView/issues/645).
|
**Does it actually run, and can a single ESP32 do pose? ([#509](https://github.com/ruvnet/RuView/issues/509), [#1125](https://github.com/ruvnet/RuView/issues/1125))** Yes, it runs, and the results are reproducible: the deterministic signal-pipeline proof (`python archive/v1/data/proof/verify.py`, must print `VERDICT: PASS`), the committed pose training dump (`v2/crates/cog-pose-estimation/cog/artifacts/train_results.json`), and the auditable MM-Fi arena all back specific numbers. But a single-antenna, 56-subcarrier CSI stream at a 20-frame window does *not* carry the fine-grained spatial information the multi-antenna NIC research relies on — so the shippable pose accuracy the project stands behind today is the **MM-Fi benchmark number**, not a live single-ESP32 number. The path to a first reproducible on-device baseline (PCK@20 ≥ 35%) is tracked in [ADR-079](adr/ADR-079-camera-ground-truth-training.md) / [#645](https://github.com/ruvnet/RuView/issues/645).
|
||||||
|
|
||||||
|
### Model and capture compatibility
|
||||||
|
|
||||||
|
These artifacts share a pose objective, but not an input contract or adapter format. A checkpoint
|
||||||
|
is usable only when capture preprocessing, tensor shape, architecture, and runtime all match.
|
||||||
|
|
||||||
|
| Artifact/path | Required input | Measured status | Live ESP32 compatibility |
|
||||||
|
|---------------|----------------|-----------------|--------------------------|
|
||||||
|
| MM-Fi flagship and `micro` transformer | `[N,3,114,10]` amplitude | **MEASURED** on MM-Fi `random_split`; calibration reference is Python `.npz` LoRA | No direct S3/C6 path is validated; resampling a 56-tone SISO stream does not recreate three-antenna MM-Fi input |
|
||||||
|
| Cog `pose_v1.safetensors` | `[N,56,20]` amplitude | **MEASURED** PCK@20 = 3.0%, below the ≥35% target; cog-format LoRA is `.safetensors` | Shape matches the canonical window, but the documented live runtime remains below-target/stub and is not a reliable pose claim |
|
||||||
|
| Viewer `heuristic_pose_from_amplitude` | live canonical amplitude | Skeleton-layout placeholder, not a trained pose model | Renders a demonstrator skeleton only; it is not pose accuracy evidence |
|
||||||
|
| MERIDIAN automatic unlabeled calibration | proposed ~200-frame target-room capture | **PROPOSED** in ADR-027; no validated end-to-end command | Not available. The current calibration tools require paired CSI/keypoint labels and model-specific inputs |
|
||||||
|
|
||||||
|
Calibration files are not interchangeable: `calibrate.py` targets the MM-Fi transformer, while
|
||||||
|
`cog_calibrate.py` targets the cog conv+MLP. See the
|
||||||
|
[calibration reference](../aether-arena/calibration/README.md) for their exact schemas.
|
||||||
|
|
||||||
### Download
|
### Download
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1458,7 +1474,7 @@ The pipeline runs 10 phases:
|
|||||||
3. Subcarrier resampling (114->56 or 30->56 via Catmull-Rom interpolation)
|
3. Subcarrier resampling (114->56 or 30->56 via Catmull-Rom interpolation)
|
||||||
4. Graph transformer construction (17 COCO keypoints, 16 bone edges)
|
4. Graph transformer construction (17 COCO keypoints, 16 bone edges)
|
||||||
5. Cross-attention training (CSI features -> body pose)
|
5. Cross-attention training (CSI features -> body pose)
|
||||||
6. **Domain-adversarial training** (MERIDIAN: gradient reversal + virtual domain augmentation)
|
6. Experimental domain-adversarial components (MERIDIAN research modules; not a validated automatic deployment path)
|
||||||
7. Composite loss optimization (MSE + CE + UV + temporal + bone + symmetry)
|
7. Composite loss optimization (MSE + CE + UV + temporal + bone + symmetry)
|
||||||
8. SONA adaptation (micro-LoRA + EWC++)
|
8. SONA adaptation (micro-LoRA + EWC++)
|
||||||
9. Sparse inference optimization (hot/cold neuron partitioning)
|
9. Sparse inference optimization (hot/cold neuron partitioning)
|
||||||
@@ -1474,14 +1490,18 @@ Progressive loading enables instant startup (Layer A loads in <5ms with basic in
|
|||||||
|
|
||||||
### Cross-Environment Adaptation (MERIDIAN)
|
### Cross-Environment Adaptation (MERIDIAN)
|
||||||
|
|
||||||
Models trained in one room typically lose 40-70% accuracy in a new room due to different WiFi multipath patterns. The MERIDIAN system (ADR-027) solves this with a 10-second automatic calibration:
|
Models trained in one room can lose substantial accuracy in a new room because the multipath
|
||||||
|
distribution changes. ADR-027 proposes an automatic adaptation design, and the Rust tree contains
|
||||||
|
individual research components, but RuView does **not** currently provide a validated command that
|
||||||
|
turns ~200 unlabeled frames into a working room adapter.
|
||||||
|
|
||||||
1. **Deploy** the trained model in a new room
|
Current, testable calibration is the separate **labeled** reference in
|
||||||
2. **Collect** ~200 unlabeled CSI frames (10 seconds at 20 Hz)
|
`aether-arena/calibration/`: collect paired CSI/keypoint samples, then fit a model-specific LoRA
|
||||||
3. The system automatically generates environment-specific LoRA weights via contrastive test-time training
|
adapter. The MM-Fi transformer expects `[N,3,114,10]`; the cog expects `[N,56,20]`. Neither adapter
|
||||||
4. No labels, no retraining, no user intervention
|
loads into the other model, and neither result establishes live ESP32 pose accuracy without a
|
||||||
|
leakage-free held-out capture and mean-pose baseline.
|
||||||
|
|
||||||
MERIDIAN components (all pure Rust, +12K parameters):
|
ADR-027 research components:
|
||||||
|
|
||||||
| Component | What it does |
|
| Component | What it does |
|
||||||
|-----------|-------------|
|
|-----------|-------------|
|
||||||
@@ -1489,7 +1509,7 @@ MERIDIAN components (all pure Rust, +12K parameters):
|
|||||||
| Domain Factorizer | Separates pose-relevant from room-specific features |
|
| Domain Factorizer | Separates pose-relevant from room-specific features |
|
||||||
| Geometry Encoder | Encodes AP positions (FiLM conditioning with DeepSets) |
|
| Geometry Encoder | Encodes AP positions (FiLM conditioning with DeepSets) |
|
||||||
| Virtual Augmentor | Generates synthetic environments for robust training |
|
| Virtual Augmentor | Generates synthetic environments for robust training |
|
||||||
| Rapid Adaptation | 10-second unsupervised calibration via contrastive TTT |
|
| Rapid Adaptation | Proposed unlabeled contrastive TTT; not wired as a validated deployment workflow |
|
||||||
|
|
||||||
See [ADR-027](adr/ADR-027-cross-environment-domain-generalization.md) for the full design.
|
See [ADR-027](adr/ADR-027-cross-environment-domain-generalization.md) for the full design.
|
||||||
|
|
||||||
@@ -1647,7 +1667,7 @@ Not every ADR-295–296 remediation item is preview-only. Three are live now:
|
|||||||
|
|
||||||
| Target | Use case | Source target flag | Notes |
|
| Target | Use case | Source target flag | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **ESP32-S3** (default) | Production CSI mesh, 17-keypoint pose | `idf.py set-target esp32s3` | Dual-core 240 MHz, PSRAM, native USB-OTG, DVP camera path |
|
| **ESP32-S3** (default) | Production CSI capture mesh; presence/motion/vital heuristics | `idf.py set-target esp32s3` | Dual-core 240 MHz, PSRAM, native USB-OTG, DVP camera path; live 17-keypoint pose is not validated |
|
||||||
| **ESP32-C6** ([ADR-110](adr/ADR-110-esp32-c6-firmware-extension.md)) | Wi-Fi 6 / 802.15.4 research, battery seed nodes | `idf.py set-target esp32c6` | Single-core 160 MHz, no PSRAM, 802.11ax HE PHY, 802.15.4 (Thread/Zigbee), LP-core hibernation ~5 µA |
|
| **ESP32-C6** ([ADR-110](adr/ADR-110-esp32-c6-firmware-extension.md)) | Wi-Fi 6 / 802.15.4 research, battery seed nodes | `idf.py set-target esp32c6` | Single-core 160 MHz, no PSRAM, 802.11ax HE PHY, 802.15.4 (Thread/Zigbee), LP-core hibernation ~5 µA |
|
||||||
|
|
||||||
The same `firmware/esp32-csi-node` source tree builds for both. ESP-IDF picks up `sdkconfig.defaults.esp32c6` automatically when the target is set to `esp32c6`; otherwise it uses `sdkconfig.defaults` (S3). All C6-only modules are `#ifdef`-gated, so the S3 build is byte-identical to today.
|
The same `firmware/esp32-csi-node` source tree builds for both. ESP-IDF picks up `sdkconfig.defaults.esp32c6` automatically when the target is set to `esp32c6`; otherwise it uses `sdkconfig.defaults` (S3). All C6-only modules are `#ifdef`-gated, so the S3 build is byte-identical to today.
|
||||||
@@ -2071,7 +2091,11 @@ Pre-trained models are available on HuggingFace:
|
|||||||
- **SOTA MM-Fi pose model** (82.69% torso-PCK@20) — https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose
|
- **SOTA MM-Fi pose model** (82.69% torso-PCK@20) — https://huggingface.co/ruvnet/wifi-densepose-mmfi-pose
|
||||||
- **AetherArena leaderboard Space** — https://huggingface.co/spaces/ruvnet/aether-arena
|
- **AetherArena leaderboard Space** — https://huggingface.co/spaces/ruvnet/aether-arena
|
||||||
|
|
||||||
Download and start sensing immediately — no datasets, no GPU, no training needed. Results are reproducible via `python archive/v1/data/proof/verify.py` (deterministic SHA-256 proof) — see [ADR-168](adr/ADR-168-benchmark-proof.md).
|
The encoder artifact can be downloaded for its documented inference path without retraining. The
|
||||||
|
MM-Fi pose checkpoint is benchmark evidence, not a drop-in live ESP32 model; it requires its exact
|
||||||
|
input contract and runtime described in [Model and capture compatibility](#model-and-capture-compatibility).
|
||||||
|
The deterministic signal-pipeline proof is reproducible via `python archive/v1/data/proof/verify.py`
|
||||||
|
(see [ADR-168](adr/ADR-168-benchmark-proof.md)), but that proof does not validate pose accuracy.
|
||||||
|
|
||||||
### Quick Start with Pre-Trained Models
|
### Quick Start with Pre-Trained Models
|
||||||
|
|
||||||
@@ -2636,7 +2660,12 @@ No. Run `docker run -p 3000:3000 ruvnet/wifi-densepose:latest` and open `http://
|
|||||||
No. Consumer WiFi exposes only RSSI (one number per access point), not CSI (56+ complex subcarrier values per frame). RSSI supports coarse presence and motion detection. Full pose estimation requires CSI-capable hardware like an ESP32-S3 ($8) or a research NIC.
|
No. Consumer WiFi exposes only RSSI (one number per access point), not CSI (56+ complex subcarrier values per frame). RSSI supports coarse presence and motion detection. Full pose estimation requires CSI-capable hardware like an ESP32-S3 ($8) or a research NIC.
|
||||||
|
|
||||||
**Q: How accurate is the pose estimation?**
|
**Q: How accurate is the pose estimation?**
|
||||||
Accuracy depends on hardware and environment. With a 3-node ESP32 mesh in a single room, the system tracks 17 COCO keypoints. The core algorithm follows the CMU "DensePose From WiFi" paper ([arXiv:2301.00250](https://arxiv.org/abs/2301.00250)). The MERIDIAN domain generalization system (ADR-027) reduces cross-environment accuracy loss from 40-70% to under 15% via 10-second automatic calibration.
|
The strongest published RuView result is the MM-Fi transformer benchmark (82.69% torso-PCK@20 on
|
||||||
|
the matched `random_split` protocol), not a live ESP32 result. The committed cog model measured
|
||||||
|
3.0% PCK@20 on its holdout, below the ≥35% target, and the viewer's live skeleton is a heuristic
|
||||||
|
placeholder. No measured claim currently shows a 3-node ESP32 mesh reliably tracking 17 keypoints.
|
||||||
|
ADR-027's 10-second unlabeled MERIDIAN adaptation is Proposed; the available measured calibration
|
||||||
|
reference uses labeled, model-specific samples.
|
||||||
|
|
||||||
**Q: Does it work through walls?**
|
**Q: Does it work through walls?**
|
||||||
Yes. WiFi signals penetrate non-metallic materials (drywall, wood, concrete up to ~30cm). Metal walls/doors significantly attenuate the signal. With a single AP the effective through-wall range is approximately 5 meters. With a 3-6 node multistatic mesh (ADR-029), attention-weighted cross-viewpoint fusion extends the effective range to ~8 meters through standard residential walls.
|
Yes. WiFi signals penetrate non-metallic materials (drywall, wood, concrete up to ~30cm). Metal walls/doors significantly attenuate the signal. With a single AP the effective through-wall range is approximately 5 meters. With a 3-6 node multistatic mesh (ADR-029), attention-weighted cross-viewpoint fusion extends the effective range to ~8 meters through standard residential walls.
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
# cargo-audit configuration — v2 workspace
|
# cargo-audit configuration — v2 workspace
|
||||||
# Managed by security audit (fix/security-audit-rustsec-clippy branch).
|
# Managed by security audit (fix/security-audit-rustsec-clippy branch).
|
||||||
#
|
#
|
||||||
# This file suppresses advisories in two categories:
|
# This file suppresses UNMAINTAINED-only advisories (no CVE) flowing through
|
||||||
# A) CVE-bearing advisories in TRANSITIVE deps we cannot upgrade directly
|
# dependencies that are purely transitive / build-time and have no
|
||||||
# because the parent published crate (ruvector-core 2.2.0) has not yet
|
# user-facing attack surface in this workspace.
|
||||||
# published a version with the fix. These are tracked as issues.
|
|
||||||
# B) UNMAINTAINED-only advisories (no CVE) flowing through dependencies
|
|
||||||
# that are purely transitive / build-time and have no user-facing attack
|
|
||||||
# surface in this workspace.
|
|
||||||
# Each entry documents the root cause and the mitigation path.
|
# Each entry documents the root cause and the mitigation path.
|
||||||
|
|
||||||
[advisories]
|
[advisories]
|
||||||
@@ -24,26 +20,6 @@
|
|||||||
# Mitigation: Accept transitively until Tauri v2 drops GTK3 or a workspace
|
# Mitigation: Accept transitively until Tauri v2 drops GTK3 or a workspace
|
||||||
# override path becomes available.
|
# override path becomes available.
|
||||||
ignore = [
|
ignore = [
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# CATEGORY A — transitive CVEs from ruvector-core 2.2.0 → reqwest 0.11
|
|
||||||
# ruvector-core 2.2.0 (latest on crates.io) depends on reqwest 0.11.27,
|
|
||||||
# which pulls in rustls 0.21 / rustls-webpki 0.101.7. We cannot upgrade
|
|
||||||
# this without a new ruvector-core release. Tracked in issue #812.
|
|
||||||
# The workspace's own TLS stack uses rustls-webpki 0.103.13 (patched);
|
|
||||||
# the vulnerable 0.101.7 instance is not reachable from our TLS code.
|
|
||||||
"RUSTSEC-2026-0098", # rustls-webpki 0.101.7: URI name constraint bypass
|
|
||||||
"RUSTSEC-2026-0099", # rustls-webpki 0.101.7: wildcard name constraint bypass
|
|
||||||
"RUSTSEC-2026-0104", # rustls-webpki 0.101.7: reachable panic in CRL parsing
|
|
||||||
# quinn-proto 0.11.13 is also pulled through midstreamer-quic 0.3 (now
|
|
||||||
# upgraded). The remaining 0.11.13 instance comes from the same
|
|
||||||
# ruvector-core transitive chain. Tracked in issue #812.
|
|
||||||
"RUSTSEC-2026-0037", # quinn-proto 0.11.13: DoS in Quinn endpoints
|
|
||||||
# CRL Distribution Point matching bug — same ruvector-core / reqwest 0.11
|
|
||||||
# transitive chain; rustls-webpki 0.101.7 also affected.
|
|
||||||
"RUSTSEC-2026-0049", # rustls-webpki <0.103.10: CRL authority matching
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# CATEGORY B — unmaintained / no CVE
|
|
||||||
"RUSTSEC-2024-0411", # gdkwayland-sys: unmaintained
|
"RUSTSEC-2024-0411", # gdkwayland-sys: unmaintained
|
||||||
"RUSTSEC-2024-0412", # gdk: unmaintained
|
"RUSTSEC-2024-0412", # gdk: unmaintained
|
||||||
"RUSTSEC-2024-0413", # atk: unmaintained
|
"RUSTSEC-2024-0413", # atk: unmaintained
|
||||||
|
|||||||
987
v2/Cargo.lock
generated
987
v2/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -255,7 +255,7 @@ midstreamer-attractor = "0.2"
|
|||||||
# ruvector integration (published on crates.io)
|
# ruvector integration (published on crates.io)
|
||||||
# Vendored at origin/main (a083bd77f) in vendor/ruvector; using crates.io versions
|
# Vendored at origin/main (a083bd77f) in vendor/ruvector; using crates.io versions
|
||||||
# until published. Bumps per ADR-152 §2.6 (2026-06-10 vendor sync survey).
|
# until published. Bumps per ADR-152 §2.6 (2026-06-10 vendor sync survey).
|
||||||
ruvector-core = "2.2.0"
|
ruvector-core = "2.3.0"
|
||||||
ruvector-mincut = "2.0.6"
|
ruvector-mincut = "2.0.6"
|
||||||
ruvector-attn-mincut = "2.0.4"
|
ruvector-attn-mincut = "2.0.4"
|
||||||
ruvector-temporal-tensor = "2.0.6"
|
ruvector-temporal-tensor = "2.0.6"
|
||||||
|
|||||||
@@ -31,10 +31,16 @@ homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
|
|||||||
# Async runtime
|
# Async runtime
|
||||||
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
|
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
|
||||||
|
|
||||||
# SQLite via sqlx — only the lite feature set; no postgres, no tls
|
# SQLite-only SQLx crates, pinned in lockstep because their direct APIs are
|
||||||
sqlx = { version = "0.8.1", default-features = false, features = [
|
# semver-exempt. Depending on the umbrella `sqlx` package also resolves its
|
||||||
"runtime-tokio-native-tls",
|
# unused MySQL backend (and vulnerable `rsa`) into Cargo.lock.
|
||||||
"sqlite",
|
sqlx-core = { version = "=0.8.6", default-features = false, features = [
|
||||||
|
"_rt-tokio",
|
||||||
|
"chrono",
|
||||||
|
"uuid",
|
||||||
|
] }
|
||||||
|
sqlx-sqlite = { version = "=0.8.6", default-features = false, features = [
|
||||||
|
"bundled",
|
||||||
"chrono",
|
"chrono",
|
||||||
"uuid",
|
"uuid",
|
||||||
] }
|
] }
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ use homecore::StateMachine;
|
|||||||
use crate::dedup::fnv64a_hash;
|
use crate::dedup::fnv64a_hash;
|
||||||
use crate::schema::ALL_DDL;
|
use crate::schema::ALL_DDL;
|
||||||
|
|
||||||
|
// Preserve the narrow `sqlx::*` call surface used in this module while
|
||||||
|
// depending only on SQLx core + SQLite. The umbrella crate resolves unused
|
||||||
|
// database backends into Cargo.lock, including MySQL's vulnerable RSA stack.
|
||||||
|
mod sqlx {
|
||||||
|
pub use sqlx_core::error::Error;
|
||||||
|
pub use sqlx_core::query::query;
|
||||||
|
pub use sqlx_core::query_as::query_as;
|
||||||
|
|
||||||
|
pub mod sqlite {
|
||||||
|
pub use sqlx_sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type SearchStateRecord = (
|
type SearchStateRecord = (
|
||||||
i64,
|
i64,
|
||||||
String,
|
String,
|
||||||
|
|||||||
Submodule v2/crates/ruview-swarm updated: 267aba5be2...5cc4b8625f
@@ -39,7 +39,7 @@ serde = { workspace = true, features = ["derive"], optional = true }
|
|||||||
serde_json = { workspace = true, optional = true }
|
serde_json = { workspace = true, optional = true }
|
||||||
# MQTT publisher backend (optional). Matches the `rumqttc` choice already in
|
# MQTT publisher backend (optional). Matches the `rumqttc` choice already in
|
||||||
# `wifi-densepose-sensing-server` so both crates share TLS / version posture.
|
# `wifi-densepose-sensing-server` so both crates share TLS / version posture.
|
||||||
rumqttc = { version = "0.24", default-features = false, features = ["use-rustls"], optional = true }
|
rumqttc = { package = "rumqttc-v4-next", version = "0.34", default-features = false, features = ["use-rustls-ring"], optional = true }
|
||||||
wifi-veil = { workspace = true, optional = true }
|
wifi-veil = { workspace = true, optional = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ use crate::PrivacyClass;
|
|||||||
/// };
|
/// };
|
||||||
/// use rumqttc::MqttOptions;
|
/// use rumqttc::MqttOptions;
|
||||||
///
|
///
|
||||||
/// let opts = MqttOptions::new("seed-01", "broker.local", 1883);
|
/// let opts = MqttOptions::new("seed-01", ("broker.local", 1883));
|
||||||
/// let (retained_pub, _conn) = RumqttPublisher::connect(opts.clone(), 64);
|
/// let (retained_pub, _conn) = RumqttPublisher::connect(opts.clone(), 64);
|
||||||
/// let mut retained_pub = retained_pub.with_retain(true);
|
/// let mut retained_pub = retained_pub.with_retain(true);
|
||||||
/// publish_discovery(&mut retained_pub, "seed-01", PrivacyClass::Anonymous)?;
|
/// publish_discovery(&mut retained_pub, "seed-01", PrivacyClass::Anonymous)?;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
//! use wifi_densepose_bfld::{publish_event, RumqttPublisher};
|
//! use wifi_densepose_bfld::{publish_event, RumqttPublisher};
|
||||||
//! use rumqttc::MqttOptions;
|
//! use rumqttc::MqttOptions;
|
||||||
//!
|
//!
|
||||||
//! let opts = MqttOptions::new("seed-01", "broker.local", 1883);
|
//! let opts = MqttOptions::new("seed-01", ("broker.local", 1883));
|
||||||
//! let (mut publisher, mut connection) = RumqttPublisher::connect(opts, 100);
|
//! let (mut publisher, mut connection) = RumqttPublisher::connect(opts, 100);
|
||||||
//! thread::spawn(move || for _ in connection.iter() { /* drain */ });
|
//! thread::spawn(move || for _ in connection.iter() { /* drain */ });
|
||||||
//! // ... build BfldEvent ...
|
//! // ... build BfldEvent ...
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
#![cfg(feature = "mqtt")]
|
#![cfg(feature = "mqtt")]
|
||||||
|
|
||||||
use rumqttc::{Client, Connection, LastWill, MqttOptions, QoS};
|
use rumqttc::{Client, Connection, LastWill, MqttOptions, PublishOptions, QoS};
|
||||||
|
|
||||||
use crate::availability::{availability_topic, PAYLOAD_NOT_AVAILABLE};
|
use crate::availability::{availability_topic, PAYLOAD_NOT_AVAILABLE};
|
||||||
use crate::mqtt_topics::{Publish, TopicMessage};
|
use crate::mqtt_topics::{Publish, TopicMessage};
|
||||||
@@ -60,7 +60,7 @@ impl RumqttPublisher {
|
|||||||
/// shown in the module-level doc example).
|
/// shown in the module-level doc example).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn connect(opts: MqttOptions, capacity: usize) -> (Self, Connection) {
|
pub fn connect(opts: MqttOptions, capacity: usize) -> (Self, Connection) {
|
||||||
let (client, connection) = Client::new(opts, capacity);
|
let (client, connection) = Client::builder(opts).capacity(capacity).build();
|
||||||
(Self::new(client, QoS::AtLeastOnce), connection)
|
(Self::new(client, QoS::AtLeastOnce), connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ impl RumqttPublisher {
|
|||||||
/// opt in to the LWT without using `connect_with_lwt`.
|
/// opt in to the LWT without using `connect_with_lwt`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn with_lwt(mut opts: MqttOptions, node_id: &str) -> MqttOptions {
|
pub fn with_lwt(mut opts: MqttOptions, node_id: &str) -> MqttOptions {
|
||||||
// rumqttc 0.24 LastWill::new takes (topic, message, qos, retain).
|
// LastWill::new takes (topic, message, qos, retain).
|
||||||
// retain = true so HA sees "offline" on next start even if the session
|
// retain = true so HA sees "offline" on next start even if the session
|
||||||
// dropped while HA was down.
|
// dropped while HA was down.
|
||||||
let will = LastWill::new(
|
let will = LastWill::new(
|
||||||
@@ -105,6 +105,10 @@ impl Publish for RumqttPublisher {
|
|||||||
|
|
||||||
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error> {
|
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error> {
|
||||||
self.client
|
self.client
|
||||||
.publish(&msg.topic, self.qos, self.retain, msg.payload.as_bytes())
|
.publish(
|
||||||
|
&msg.topic,
|
||||||
|
msg.payload.as_bytes(),
|
||||||
|
PublishOptions::new(self.qos).retain(self.retain),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ use std::thread;
|
|||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use rumqttc::{Client, Event, Incoming, MqttOptions, Packet, QoS};
|
use rumqttc::{Client, Event, Incoming, MqttOptions, Packet, QoS};
|
||||||
use wifi_densepose_bfld::{
|
use wifi_densepose_bfld::{publish_event, BfldEvent, PrivacyClass, RumqttPublisher};
|
||||||
publish_event, BfldEvent, PrivacyClass, RumqttPublisher,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUBSCRIBE_TIMEOUT: Duration = Duration::from_secs(5);
|
const SUBSCRIBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(10);
|
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
@@ -69,9 +67,9 @@ fn spawn_subscriber(
|
|||||||
port: u16,
|
port: u16,
|
||||||
topic_filter: &str,
|
topic_filter: &str,
|
||||||
) -> (Receiver<(String, String)>, Receiver<()>) {
|
) -> (Receiver<(String, String)>, Receiver<()>) {
|
||||||
let mut opts = MqttOptions::new(unique_client_id("bfld-sub"), host, port);
|
let mut opts = MqttOptions::new(unique_client_id("bfld-sub"), (host, port));
|
||||||
opts.set_keep_alive(Duration::from_secs(5));
|
opts.set_keep_alive(5);
|
||||||
let (client, mut connection) = Client::new(opts, 64);
|
let (client, mut connection) = Client::builder(opts).capacity(64).build();
|
||||||
client
|
client
|
||||||
.subscribe(topic_filter, QoS::AtLeastOnce)
|
.subscribe(topic_filter, QoS::AtLeastOnce)
|
||||||
.expect("subscribe enqueue");
|
.expect("subscribe enqueue");
|
||||||
@@ -79,13 +77,18 @@ fn spawn_subscriber(
|
|||||||
let (incoming_tx, incoming_rx) = channel();
|
let (incoming_tx, incoming_rx) = channel();
|
||||||
let (suback_tx, suback_rx) = channel();
|
let (suback_tx, suback_rx) = channel();
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
|
// rumqttc-v4-next stops the connection once every request sender is
|
||||||
|
// dropped. Keep the subscriber client alive for as long as its pump
|
||||||
|
// thread runs; otherwise the broker sees a clean disconnect directly
|
||||||
|
// after SUBACK and no subsequent publications can be delivered.
|
||||||
|
let _client_guard = client;
|
||||||
for notification in connection.iter() {
|
for notification in connection.iter() {
|
||||||
match notification {
|
match notification {
|
||||||
Ok(Event::Incoming(Packet::SubAck(_))) => {
|
Ok(Event::Incoming(Packet::SubAck(_))) => {
|
||||||
let _ = suback_tx.send(());
|
let _ = suback_tx.send(());
|
||||||
}
|
}
|
||||||
Ok(Event::Incoming(Incoming::Publish(p))) => {
|
Ok(Event::Incoming(Incoming::Publish(p))) => {
|
||||||
let topic = p.topic.clone();
|
let topic = String::from_utf8_lossy(&p.topic).to_string();
|
||||||
let payload = String::from_utf8_lossy(&p.payload).to_string();
|
let payload = String::from_utf8_lossy(&p.payload).to_string();
|
||||||
if incoming_tx.send((topic, payload)).is_err() {
|
if incoming_tx.send((topic, payload)).is_err() {
|
||||||
break;
|
break;
|
||||||
@@ -141,8 +144,8 @@ fn live_broker_anonymous_event_roundtrips_all_six_topics() {
|
|||||||
|
|
||||||
// Publisher with its own connection. Spawn a thread iterating the
|
// Publisher with its own connection. Spawn a thread iterating the
|
||||||
// Connection so publishes actually reach the broker.
|
// Connection so publishes actually reach the broker.
|
||||||
let mut opts = MqttOptions::new(unique_client_id("bfld-pub"), &host, port);
|
let mut opts = MqttOptions::new(unique_client_id("bfld-pub"), (host.as_str(), port));
|
||||||
opts.set_keep_alive(Duration::from_secs(5));
|
opts.set_keep_alive(5);
|
||||||
let (mut publisher, mut pub_connection) = RumqttPublisher::connect(opts, 64);
|
let (mut publisher, mut pub_connection) = RumqttPublisher::connect(opts, 64);
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
for _ in pub_connection.iter() { /* drain protocol events */ }
|
for _ in pub_connection.iter() { /* drain protocol events */ }
|
||||||
@@ -197,8 +200,8 @@ fn live_broker_restricted_event_omits_identity_risk() {
|
|||||||
.recv_timeout(SUBSCRIBE_TIMEOUT)
|
.recv_timeout(SUBSCRIBE_TIMEOUT)
|
||||||
.expect("SubAck within 5s");
|
.expect("SubAck within 5s");
|
||||||
|
|
||||||
let mut opts = MqttOptions::new(unique_client_id("bfld-pub-r"), &host, port);
|
let mut opts = MqttOptions::new(unique_client_id("bfld-pub-r"), (host.as_str(), port));
|
||||||
opts.set_keep_alive(Duration::from_secs(5));
|
opts.set_keep_alive(5);
|
||||||
let (mut publisher, mut pub_connection) = RumqttPublisher::connect(opts, 64);
|
let (mut publisher, mut pub_connection) = RumqttPublisher::connect(opts, 64);
|
||||||
thread::spawn(move || for _ in pub_connection.iter() {});
|
thread::spawn(move || for _ in pub_connection.iter() {});
|
||||||
thread::sleep(Duration::from_millis(200));
|
thread::sleep(Duration::from_millis(200));
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use wifi_densepose_bfld::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
fn unreachable_opts(client_id: &str) -> MqttOptions {
|
fn unreachable_opts(client_id: &str) -> MqttOptions {
|
||||||
MqttOptions::new(client_id, "127.0.0.1", 1)
|
MqttOptions::new(client_id, ("127.0.0.1", 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -89,7 +89,7 @@ fn caller_built_options_can_opt_in_via_with_lwt_then_pass_to_connect() {
|
|||||||
// Operators with custom MqttOptions (e.g., TLS, credentials) build their
|
// Operators with custom MqttOptions (e.g., TLS, credentials) build their
|
||||||
// own opts, then call with_lwt before passing to RumqttPublisher::connect.
|
// own opts, then call with_lwt before passing to RumqttPublisher::connect.
|
||||||
let mut opts = unreachable_opts("bfld-lwt-6");
|
let mut opts = unreachable_opts("bfld-lwt-6");
|
||||||
opts.set_keep_alive(std::time::Duration::from_secs(30));
|
opts.set_keep_alive(30);
|
||||||
let opts = with_lwt(opts, "seed-01");
|
let opts = with_lwt(opts, "seed-01");
|
||||||
let (_publisher, _connection) = RumqttPublisher::connect(opts, 16);
|
let (_publisher, _connection) = RumqttPublisher::connect(opts, 16);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use wifi_densepose_bfld::{publish_event, BfldEvent, PrivacyClass, Publish, Rumqt
|
|||||||
fn unreachable_opts() -> MqttOptions {
|
fn unreachable_opts() -> MqttOptions {
|
||||||
// Port 1 is reserved (RFC 1700) and the loopback address will refuse
|
// Port 1 is reserved (RFC 1700) and the loopback address will refuse
|
||||||
// immediately — perfect for a construction smoke test that must not block.
|
// immediately — perfect for a construction smoke test that must not block.
|
||||||
MqttOptions::new("bfld-smoke-iter23", "127.0.0.1", 1)
|
MqttOptions::new("bfld-smoke-iter23", ("127.0.0.1", 1))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sample_event() -> BfldEvent {
|
fn sample_event() -> BfldEvent {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ rand = "0.8"
|
|||||||
# client (ADR-115 §10 references). `rustls` is preferred over openssl on
|
# client (ADR-115 §10 references). `rustls` is preferred over openssl on
|
||||||
# Windows to keep parity with the rest of the workspace (`ureq` above also
|
# Windows to keep parity with the rest of the workspace (`ureq` above also
|
||||||
# uses rustls).
|
# uses rustls).
|
||||||
rumqttc = { version = "0.24", default-features = false, features = ["use-rustls"], optional = true }
|
rumqttc = { package = "rumqttc-v4-next", version = "0.34", default-features = false, features = ["use-rustls-ring"], optional = true }
|
||||||
|
|
||||||
# `otel` feature — OTLP log export (`telemetry` module). Same gating
|
# `otel` feature — OTLP log export (`telemetry` module). Same gating
|
||||||
# principle as `mqtt`: the heavy exporter stack (opentelemetry SDK +
|
# principle as `mqtt`: the heavy exporter stack (opentelemetry SDK +
|
||||||
|
|||||||
@@ -286,6 +286,9 @@ struct Esp32Frame {
|
|||||||
/// ADR-110 byte 18: PPDU type the CSI was sampled from. Pre-ADR-110
|
/// ADR-110 byte 18: PPDU type the CSI was sampled from. Pre-ADR-110
|
||||||
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
|
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
|
||||||
ppdu_type: wifi_densepose_hardware::PpduType,
|
ppdu_type: wifi_densepose_hardware::PpduType,
|
||||||
|
/// ADR-110 byte 19 metadata, including whether this frame was captured
|
||||||
|
/// while the node had a valid IEEE 802.15.4 mesh-time solution.
|
||||||
|
adr018_flags: wifi_densepose_hardware::Adr018Flags,
|
||||||
amplitudes: Vec<f64>,
|
amplitudes: Vec<f64>,
|
||||||
phases: Vec<f64>,
|
phases: Vec<f64>,
|
||||||
}
|
}
|
||||||
@@ -675,6 +678,12 @@ struct NodeState {
|
|||||||
latest_sync: Option<wifi_densepose_hardware::SyncPacket>,
|
latest_sync: Option<wifi_densepose_hardware::SyncPacket>,
|
||||||
/// Last time a sync packet from this node was received (for staleness).
|
/// Last time a sync packet from this node was received (for staleness).
|
||||||
latest_sync_at: Option<std::time::Instant>,
|
latest_sync_at: Option<std::time::Instant>,
|
||||||
|
/// Sequence number of the newest CSI frame admitted to `frame_history`.
|
||||||
|
/// Kept alongside the history so multistatic fusion can timestamp the
|
||||||
|
/// exact sample it consumes, rather than the host's UDP arrival time.
|
||||||
|
latest_csi_sequence: Option<u32>,
|
||||||
|
/// Whether byte 19 bit 4 marked that newest admitted CSI frame as synced.
|
||||||
|
latest_csi_sync_valid: bool,
|
||||||
/// ADR-110 iter 18: EMA-tracked CSI frame rate for this node.
|
/// ADR-110 iter 18: EMA-tracked CSI frame rate for this node.
|
||||||
/// Replaces the hardcoded 20 Hz fallback in
|
/// Replaces the hardcoded 20 Hz fallback in
|
||||||
/// `mesh_aligned_us_for_csi_frame` once `csi_fps_samples ≥ 5`.
|
/// `mesh_aligned_us_for_csi_frame` once `csi_fps_samples ≥ 5`.
|
||||||
@@ -832,6 +841,9 @@ impl NodeState {
|
|||||||
/// staleness gate).
|
/// staleness gate).
|
||||||
pub(crate) fn mesh_aligned_us(&self, local_at_frame_us: u64) -> Option<u64> {
|
pub(crate) fn mesh_aligned_us(&self, local_at_frame_us: u64) -> Option<u64> {
|
||||||
let sync = self.latest_sync.as_ref()?;
|
let sync = self.latest_sync.as_ref()?;
|
||||||
|
if !sync.flags.is_valid {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let seen_at = self.latest_sync_at?;
|
let seen_at = self.latest_sync_at?;
|
||||||
// Drop stale syncs — firmware emits at ~0.5 Hz default, anything
|
// Drop stale syncs — firmware emits at ~0.5 Hz default, anything
|
||||||
// older than 9 s likely means the mesh transport dropped.
|
// older than 9 s likely means the mesh transport dropped.
|
||||||
@@ -850,10 +862,20 @@ impl NodeState {
|
|||||||
/// no fresh sync has been observed for this node.
|
/// no fresh sync has been observed for this node.
|
||||||
pub(crate) fn mesh_aligned_us_for_csi_frame(&self, frame_sequence: u32) -> Option<u64> {
|
pub(crate) fn mesh_aligned_us_for_csi_frame(&self, frame_sequence: u32) -> Option<u64> {
|
||||||
let sync = self.latest_sync.as_ref()?;
|
let sync = self.latest_sync.as_ref()?;
|
||||||
|
if !sync.flags.is_valid {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let seen_at = self.latest_sync_at?;
|
let seen_at = self.latest_sync_at?;
|
||||||
if seen_at.elapsed() > std::time::Duration::from_secs(9) {
|
if seen_at.elapsed() > std::time::Duration::from_secs(9) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
// A recently-received sync datagram can overtake an older CSI
|
||||||
|
// datagram in UDP delivery order. Only extrapolate forward (including
|
||||||
|
// a genuine u32 wrap); otherwise fall back to host arrival time.
|
||||||
|
let delta_frames = frame_sequence.wrapping_sub(sync.sequence);
|
||||||
|
if delta_frames > i32::MAX as u32 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
// Iter 18: use the measured per-node fps once we have ≥5 inter-frame
|
// Iter 18: use the measured per-node fps once we have ≥5 inter-frame
|
||||||
// samples; until then fall back to the 20 Hz firmware ceiling. The
|
// samples; until then fall back to the 20 Hz firmware ceiling. The
|
||||||
// §A0.12 capture showed real bench fps ≈ 10, so the measured value
|
// §A0.12 capture showed real bench fps ≈ 10, so the measured value
|
||||||
@@ -862,6 +884,16 @@ impl NodeState {
|
|||||||
Some(sync.mesh_aligned_us_for_sequence(frame_sequence, fps))
|
Some(sync.mesh_aligned_us_for_sequence(frame_sequence, fps))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mesh timestamp for the newest CSI frame admitted to `frame_history`.
|
||||||
|
/// Both the frame-level sync-valid bit and a fresh, valid sync packet are
|
||||||
|
/// required; callers retain their existing host-arrival fallback.
|
||||||
|
pub(crate) fn mesh_aligned_us_for_latest_csi_frame(&self) -> Option<u64> {
|
||||||
|
if !self.latest_csi_sync_valid {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.mesh_aligned_us_for_csi_frame(self.latest_csi_sequence?)
|
||||||
|
}
|
||||||
|
|
||||||
/// ADR-110 iter 18 — update the per-node observed-fps EMA from a fresh
|
/// ADR-110 iter 18 — update the per-node observed-fps EMA from a fresh
|
||||||
/// CSI frame arrival. Call once per accepted CSI frame from
|
/// CSI frame arrival. Call once per accepted CSI frame from
|
||||||
/// `udp_receiver_task`. Uses `last_frame_time` as the previous-frame
|
/// `udp_receiver_task`. Uses `last_frame_time` as the previous-frame
|
||||||
@@ -927,6 +959,21 @@ impl NodeState {
|
|||||||
first_sensing_frame
|
first_sensing_frame
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record an accepted CSI sample and preserve the wire metadata needed by
|
||||||
|
/// the multistatic bridge to recover capture time. Grid-rejected frames
|
||||||
|
/// intentionally use `observe_csi_frame_arrival` directly because they do
|
||||||
|
/// not replace the sample at the back of `frame_history`.
|
||||||
|
pub(crate) fn observe_accepted_csi_frame(
|
||||||
|
&mut self,
|
||||||
|
sequence: u32,
|
||||||
|
sync_valid: bool,
|
||||||
|
now: std::time::Instant,
|
||||||
|
) -> bool {
|
||||||
|
self.latest_csi_sequence = Some(sequence);
|
||||||
|
self.latest_csi_sync_valid = sync_valid;
|
||||||
|
self.observe_csi_frame_arrival(now)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn new() -> Self {
|
pub(crate) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
frame_history: VecDeque::new(),
|
frame_history: VecDeque::new(),
|
||||||
@@ -951,6 +998,8 @@ impl NodeState {
|
|||||||
edge_vitals: None,
|
edge_vitals: None,
|
||||||
latest_sync: None,
|
latest_sync: None,
|
||||||
latest_sync_at: None,
|
latest_sync_at: None,
|
||||||
|
latest_csi_sequence: None,
|
||||||
|
latest_csi_sync_valid: false,
|
||||||
csi_fps_ema: 20.0,
|
csi_fps_ema: 20.0,
|
||||||
csi_fps_samples: 0,
|
csi_fps_samples: 0,
|
||||||
latest_features: None,
|
latest_features: None,
|
||||||
@@ -1945,7 +1994,8 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
|||||||
// [12..15] sequence (u32 LE)
|
// [12..15] sequence (u32 LE)
|
||||||
// [16] rssi (i8)
|
// [16] rssi (i8)
|
||||||
// [17] noise_floor (i8)
|
// [17] noise_floor (i8)
|
||||||
// [18..19] reserved
|
// [18] PPDU type
|
||||||
|
// [19] ADR-018 flags (bit 4 = IEEE 802.15.4 sync valid)
|
||||||
// [20..] I/Q data
|
// [20..] I/Q data
|
||||||
// Issue #1005: until 2026-06 this code read n_subcarriers from byte 6
|
// Issue #1005: until 2026-06 this code read n_subcarriers from byte 6
|
||||||
// alone (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the
|
// alone (an ESP32-C6 HE-SU frame's 256 = 0x0100 LE decoded as 0 — the
|
||||||
@@ -1966,6 +2016,7 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
|||||||
};
|
};
|
||||||
let noise_floor = buf[17] as i8;
|
let noise_floor = buf[17] as i8;
|
||||||
let ppdu_type = wifi_densepose_hardware::PpduType::from_byte(buf[18]);
|
let ppdu_type = wifi_densepose_hardware::PpduType::from_byte(buf[18]);
|
||||||
|
let adr018_flags = wifi_densepose_hardware::Adr018Flags::from_byte(buf[19]);
|
||||||
|
|
||||||
let iq_start = 20;
|
let iq_start = 20;
|
||||||
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
||||||
@@ -1995,6 +2046,7 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
|||||||
rssi,
|
rssi,
|
||||||
noise_floor,
|
noise_floor,
|
||||||
ppdu_type,
|
ppdu_type,
|
||||||
|
adr018_flags,
|
||||||
amplitudes,
|
amplitudes,
|
||||||
phases,
|
phases,
|
||||||
})
|
})
|
||||||
@@ -2024,7 +2076,7 @@ mod issue_1009_n_subcarriers_u16_tests {
|
|||||||
buf[16] = (-40i8) as u8; // rssi
|
buf[16] = (-40i8) as u8; // rssi
|
||||||
buf[17] = (-90i8) as u8; // noise_floor
|
buf[17] = (-90i8) as u8; // noise_floor
|
||||||
buf[18] = 0; // ppdu_type
|
buf[18] = 0; // ppdu_type
|
||||||
buf[19] = 0;
|
buf[19] = 0x10; // ADR-018: IEEE 802.15.4 sync valid
|
||||||
for k in 0..n_subcarriers as usize {
|
for k in 0..n_subcarriers as usize {
|
||||||
buf[20 + k * 2] = (5 + (k % 40) as i8) as u8; // i
|
buf[20 + k * 2] = (5 + (k % 40) as i8) as u8; // i
|
||||||
buf[20 + k * 2 + 1] = (k % 30) as u8; // q
|
buf[20 + k * 2 + 1] = (k % 30) as u8; // q
|
||||||
@@ -2047,6 +2099,7 @@ mod issue_1009_n_subcarriers_u16_tests {
|
|||||||
assert_eq!(frame.node_id, 7);
|
assert_eq!(frame.node_id, 7);
|
||||||
assert_eq!(frame.rssi, -40);
|
assert_eq!(frame.rssi, -40);
|
||||||
assert_eq!(frame.sequence, 42);
|
assert_eq!(frame.sequence, 42);
|
||||||
|
assert!(frame.adr018_flags.ieee802154_sync_valid);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2941,6 +2994,7 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
|||||||
rssi: first_rssi.clamp(-128.0, 127.0) as i8,
|
rssi: first_rssi.clamp(-128.0, 127.0) as i8,
|
||||||
noise_floor: -90,
|
noise_floor: -90,
|
||||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||||
|
adr018_flags: wifi_densepose_hardware::Adr018Flags::default(),
|
||||||
amplitudes: multi_ap_frame.amplitudes.clone(),
|
amplitudes: multi_ap_frame.amplitudes.clone(),
|
||||||
phases: multi_ap_frame.phases.clone(),
|
phases: multi_ap_frame.phases.clone(),
|
||||||
};
|
};
|
||||||
@@ -3129,6 +3183,7 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
|||||||
rssi: rssi_dbm as i8,
|
rssi: rssi_dbm as i8,
|
||||||
noise_floor: -90,
|
noise_floor: -90,
|
||||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||||
|
adr018_flags: wifi_densepose_hardware::Adr018Flags::default(),
|
||||||
amplitudes: vec![signal_pct],
|
amplitudes: vec![signal_pct],
|
||||||
phases: vec![0.0],
|
phases: vec![0.0],
|
||||||
};
|
};
|
||||||
@@ -3504,6 +3559,7 @@ fn generate_simulated_frame(tick: u64) -> Esp32Frame {
|
|||||||
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
|
rssi: (-40.0 + 5.0 * (t * 0.2).sin()) as i8,
|
||||||
noise_floor: -90,
|
noise_floor: -90,
|
||||||
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
|
||||||
|
adr018_flags: wifi_densepose_hardware::Adr018Flags::default(),
|
||||||
amplitudes,
|
amplitudes,
|
||||||
phases,
|
phases,
|
||||||
}
|
}
|
||||||
@@ -6702,8 +6758,11 @@ async fn udp_receiver_task(
|
|||||||
// ADR-110 iter 19 — feed the per-node fps EMA from real
|
// ADR-110 iter 19 — feed the per-node fps EMA from real
|
||||||
// CSI arrivals. The helper sets `last_frame_time` as a
|
// CSI arrivals. The helper sets `last_frame_time` as a
|
||||||
// side effect, so the previous bare assignment is gone.
|
// side effect, so the previous bare assignment is gone.
|
||||||
let first_sensing_frame =
|
let first_sensing_frame = ns.observe_accepted_csi_frame(
|
||||||
ns.observe_csi_frame_arrival(std::time::Instant::now());
|
frame.sequence,
|
||||||
|
frame.adr018_flags.ieee802154_sync_valid,
|
||||||
|
std::time::Instant::now(),
|
||||||
|
);
|
||||||
if first_sensing_frame && telemetry::curated_events_enabled() {
|
if first_sensing_frame && telemetry::curated_events_enabled() {
|
||||||
info!(name: semconv::EVENT_RUVIEW_NODE_ONLINE, { "ruview.node.id" = node_id }, "node {node_id} online (CSI)");
|
info!(name: semconv::EVENT_RUVIEW_NODE_ONLINE, { "ruview.node.id" = node_id }, "node {node_id} online (CSI)");
|
||||||
}
|
}
|
||||||
@@ -9327,6 +9386,31 @@ mod sync_snapshot_helper_tests {
|
|||||||
"10 s old sync must trigger the 9 s staleness gate");
|
"10 s old sync must trigger the 9 s staleness gate");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn latest_csi_mesh_time_requires_both_validity_signals() {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
let mut ns = NodeState::new();
|
||||||
|
ns.apply_sync_packet(populated_sync(9), now);
|
||||||
|
|
||||||
|
ns.observe_accepted_csi_frame(21, false, now);
|
||||||
|
assert!(
|
||||||
|
ns.mesh_aligned_us_for_latest_csi_frame().is_none(),
|
||||||
|
"an unsynchronized CSI capture must use the host-time fallback"
|
||||||
|
);
|
||||||
|
|
||||||
|
ns.observe_accepted_csi_frame(21, true, now + std::time::Duration::from_millis(50));
|
||||||
|
assert_eq!(
|
||||||
|
ns.mesh_aligned_us_for_latest_csi_frame(),
|
||||||
|
Some(27_684_885)
|
||||||
|
);
|
||||||
|
|
||||||
|
ns.latest_sync.as_mut().unwrap().flags.is_valid = false;
|
||||||
|
assert!(
|
||||||
|
ns.mesh_aligned_us_for_latest_csi_frame().is_none(),
|
||||||
|
"an invalid sync packet must not timestamp even a flagged CSI frame"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_reflects_leader_state() {
|
fn snapshot_reflects_leader_state() {
|
||||||
// Same data shape that /api/v1/mesh emits for a leader node.
|
// Same data shape that /api/v1/mesh emits for a leader node.
|
||||||
|
|||||||
@@ -30,7 +30,10 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use rumqttc::{AsyncClient, ClientError, EventLoop, MqttOptions, QoS, Transport, TlsConfiguration};
|
use rumqttc::{
|
||||||
|
AsyncClient, ClientError, EventLoop, MqttOptions, PublishOptions, QoS, Transport,
|
||||||
|
TlsConfiguration,
|
||||||
|
};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
@@ -70,14 +73,14 @@ const NODE_SNAPSHOT_STALE_AFTER: Duration = Duration::from_secs(10);
|
|||||||
|
|
||||||
/// Build a `rumqttc::MqttOptions` from validated [`MqttConfig`].
|
/// Build a `rumqttc::MqttOptions` from validated [`MqttConfig`].
|
||||||
fn build_mqtt_options(cfg: &MqttConfig) -> MqttOptions {
|
fn build_mqtt_options(cfg: &MqttConfig) -> MqttOptions {
|
||||||
let mut opts = MqttOptions::new(&cfg.client_id, &cfg.host, cfg.port);
|
let mut opts = MqttOptions::new(&cfg.client_id, (cfg.host.as_str(), cfg.port));
|
||||||
opts.set_keep_alive(Duration::from_secs(30));
|
opts.set_keep_alive(30);
|
||||||
opts.set_clean_session(true);
|
opts.set_clean_session(true);
|
||||||
|
|
||||||
if let (Some(u), Some(p)) = (cfg.username.as_deref(), cfg.password.as_deref()) {
|
if let (Some(u), Some(p)) = (cfg.username.as_deref(), cfg.password.as_deref()) {
|
||||||
opts.set_credentials(u, p);
|
opts.set_credentials(u.to_owned(), p.as_bytes().to_vec());
|
||||||
} else if let Some(u) = cfg.username.as_deref() {
|
} else if let Some(u) = cfg.username.as_deref() {
|
||||||
opts.set_credentials(u, "");
|
opts.set_credentials(u.to_owned(), Vec::<u8>::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.set_transport(build_transport(&cfg.tls));
|
opts.set_transport(build_transport(&cfg.tls));
|
||||||
@@ -223,7 +226,8 @@ async fn run(
|
|||||||
mut state_rx: broadcast::Receiver<VitalsSnapshot>,
|
mut state_rx: broadcast::Receiver<VitalsSnapshot>,
|
||||||
) {
|
) {
|
||||||
let opts = build_mqtt_options(&cfg);
|
let opts = build_mqtt_options(&cfg);
|
||||||
let (client, mut eventloop): (AsyncClient, EventLoop) = AsyncClient::new(opts, 256);
|
let (client, mut eventloop): (AsyncClient, EventLoop) =
|
||||||
|
AsyncClient::builder(opts).capacity(256).build();
|
||||||
|
|
||||||
let entities = DiscoveryBuilder::enabled_entities(
|
let entities = DiscoveryBuilder::enabled_entities(
|
||||||
cfg.privacy_mode,
|
cfg.privacy_mode,
|
||||||
@@ -369,7 +373,13 @@ async fn publish_all_discovery(
|
|||||||
let cfg = b.build(e);
|
let cfg = b.build(e);
|
||||||
let topic = b.config_topic(e);
|
let topic = b.config_topic(e);
|
||||||
let payload = serde_json::to_string(&cfg).expect("discovery payload always serialises");
|
let payload = serde_json::to_string(&cfg).expect("discovery payload always serialises");
|
||||||
client.publish(&topic, QoS::AtLeastOnce, true, payload).await?;
|
client
|
||||||
|
.publish(
|
||||||
|
&topic,
|
||||||
|
payload,
|
||||||
|
PublishOptions::new(QoS::AtLeastOnce).retained(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -380,7 +390,13 @@ async fn publish_availability(
|
|||||||
state: &str,
|
state: &str,
|
||||||
) -> Result<(), ClientError> {
|
) -> Result<(), ClientError> {
|
||||||
for topic in &avail.online_topics {
|
for topic in &avail.online_topics {
|
||||||
client.publish(topic, QoS::AtLeastOnce, true, state).await?;
|
client
|
||||||
|
.publish(
|
||||||
|
topic,
|
||||||
|
state,
|
||||||
|
PublishOptions::new(QoS::AtLeastOnce).retained(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -441,7 +457,13 @@ async fn publish_state(client: &AsyncClient, m: &StateMessage) -> Result<(), Cli
|
|||||||
1 => QoS::AtLeastOnce,
|
1 => QoS::AtLeastOnce,
|
||||||
_ => QoS::ExactlyOnce,
|
_ => QoS::ExactlyOnce,
|
||||||
};
|
};
|
||||||
client.publish(&m.topic, qos, m.retain, m.payload.clone()).await
|
client
|
||||||
|
.publish(
|
||||||
|
&m.topic,
|
||||||
|
m.payload.clone(),
|
||||||
|
PublishOptions::new(qos).retain(m.retain),
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ const DEFAULT_FREQ_MHZ: u32 = 2437; // Channel 6
|
|||||||
|
|
||||||
/// Monotonic reference point for timestamp generation. All node timestamps
|
/// Monotonic reference point for timestamp generation. All node timestamps
|
||||||
/// are relative to this instant, avoiding wall-clock/monotonic mixing issues.
|
/// are relative to this instant, avoiding wall-clock/monotonic mixing issues.
|
||||||
static EPOCH: LazyLock<Instant> = LazyLock::new(Instant::now);
|
/// Backdate the lazy initialization beyond the active-node window so frames
|
||||||
|
/// recorded just before the first bridge call retain their arrival-time skew.
|
||||||
|
static EPOCH: LazyLock<Instant> = LazyLock::new(|| {
|
||||||
|
Instant::now()
|
||||||
|
.checked_sub(STALE_THRESHOLD + STALE_THRESHOLD)
|
||||||
|
.unwrap_or_else(Instant::now)
|
||||||
|
});
|
||||||
|
|
||||||
/// Shared length-only canonicalizer (issue #1170). The default 56-tone grid
|
/// Shared length-only canonicalizer (issue #1170). The default 56-tone grid
|
||||||
/// matches what `MultistaticFuser` (ADR-154) expects. Stateless and immutable,
|
/// matches what `MultistaticFuser` (ADR-154) expects. Stateless and immutable,
|
||||||
@@ -54,10 +60,18 @@ pub fn node_frame_from_state(node_id: u8, ns: &NodeState) -> Option<MultiBandCsi
|
|||||||
let n_sub = amplitude.len();
|
let n_sub = amplitude.len();
|
||||||
let phase = vec![0.0_f32; n_sub];
|
let phase = vec![0.0_f32; n_sub];
|
||||||
|
|
||||||
// Monotonic timestamp: microseconds since a shared process-local epoch.
|
// Prefer the capture timestamp recovered from the node's mesh sync. This
|
||||||
// All nodes use the same reference so the fuser's guard_interval_us check
|
// keeps UDP scheduling jitter out of the fuser's cross-node guard. Older
|
||||||
// compares apples to apples. No wall-clock mixing (immune to NTP jumps).
|
// firmware, stale sync state, and frames without the sync-valid bit retain
|
||||||
let timestamp_us = last_time.duration_since(*EPOCH).as_micros() as u64;
|
// the process-local host-arrival fallback.
|
||||||
|
let timestamp_us = ns
|
||||||
|
.mesh_aligned_us_for_latest_csi_frame()
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
last_time
|
||||||
|
.checked_duration_since(*EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_micros() as u64
|
||||||
|
});
|
||||||
|
|
||||||
let canonical = CanonicalCsiFrame {
|
let canonical = CanonicalCsiFrame {
|
||||||
amplitude,
|
amplitude,
|
||||||
@@ -173,6 +187,7 @@ pub fn compute_person_score_from_amplitudes(amplitudes: &[f32]) -> f64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
use wifi_densepose_hardware::{SyncPacket, SyncPacketFlags};
|
||||||
|
|
||||||
/// Helper: build a minimal NodeState for testing. Uses `NodeState::new()`
|
/// Helper: build a minimal NodeState for testing. Uses `NodeState::new()`
|
||||||
/// then mutates the `pub(crate)` fields the bridge needs.
|
/// then mutates the `pub(crate)` fields the bridge needs.
|
||||||
@@ -225,6 +240,99 @@ mod tests {
|
|||||||
assert_eq!(ch.hardware_type, HardwareType::Esp32S3);
|
assert_eq!(ch.hardware_type, HardwareType::Esp32S3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mark_mesh_timed_frame(
|
||||||
|
ns: &mut NodeState,
|
||||||
|
node_id: u8,
|
||||||
|
sync_sequence: u32,
|
||||||
|
frame_sequence: u32,
|
||||||
|
mesh_epoch_us: u64,
|
||||||
|
host_arrival: Instant,
|
||||||
|
) {
|
||||||
|
ns.apply_sync_packet(
|
||||||
|
SyncPacket {
|
||||||
|
node_id,
|
||||||
|
proto_ver: 1,
|
||||||
|
flags: SyncPacketFlags {
|
||||||
|
is_leader: node_id == 1,
|
||||||
|
is_valid: true,
|
||||||
|
smoothed_used: node_id != 1,
|
||||||
|
},
|
||||||
|
local_us: 10_000_000,
|
||||||
|
epoch_us: mesh_epoch_us,
|
||||||
|
sequence: sync_sequence,
|
||||||
|
},
|
||||||
|
Instant::now(),
|
||||||
|
);
|
||||||
|
ns.observe_accepted_csi_frame(frame_sequence, true, host_arrival);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mesh_timestamp_replaces_skewed_host_arrival_time() {
|
||||||
|
let mut history = VecDeque::new();
|
||||||
|
history.push_back(vec![10.0, 20.0, 30.0]);
|
||||||
|
let host_arrival = Instant::now();
|
||||||
|
let mut ns = make_node_state(history, None, 0);
|
||||||
|
mark_mesh_timed_frame(&mut ns, 1, 100, 101, 1_000_000, host_arrival);
|
||||||
|
|
||||||
|
let frame = node_frame_from_state(1, &ns).expect("mesh-timed frame");
|
||||||
|
assert_eq!(frame.timestamp_us, 1_050_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mesh_time_allows_fusion_despite_udp_arrival_skew() {
|
||||||
|
let base = Instant::now() - Duration::from_millis(500);
|
||||||
|
let mut states = HashMap::new();
|
||||||
|
|
||||||
|
let mut first_history = VecDeque::new();
|
||||||
|
first_history.push_back(vec![1.0; 64]);
|
||||||
|
let mut first = make_node_state(first_history, None, 0);
|
||||||
|
mark_mesh_timed_frame(&mut first, 1, 100, 101, 1_000_000, base);
|
||||||
|
states.insert(1, first);
|
||||||
|
|
||||||
|
let mut second_history = VecDeque::new();
|
||||||
|
second_history.push_back(vec![1.1; 64]);
|
||||||
|
let mut second = make_node_state(second_history, None, 0);
|
||||||
|
mark_mesh_timed_frame(
|
||||||
|
&mut second,
|
||||||
|
2,
|
||||||
|
200,
|
||||||
|
201,
|
||||||
|
1_005_000,
|
||||||
|
base + Duration::from_millis(200),
|
||||||
|
);
|
||||||
|
states.insert(2, second);
|
||||||
|
|
||||||
|
let frames = node_frames_from_states(&states);
|
||||||
|
let spread = frames.iter().map(|f| f.timestamp_us).max().unwrap()
|
||||||
|
- frames.iter().map(|f| f.timestamp_us).min().unwrap();
|
||||||
|
assert_eq!(spread, 5_000, "mesh capture spread, not 200 ms UDP skew");
|
||||||
|
assert!(
|
||||||
|
MultistaticFuser::new().fuse(&frames).is_ok(),
|
||||||
|
"mesh-aligned frames inside the 60 ms guard must fuse"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsynchronized_frames_keep_host_arrival_guard() {
|
||||||
|
let base = Instant::now() - Duration::from_millis(500);
|
||||||
|
let mut states = HashMap::new();
|
||||||
|
|
||||||
|
for (node_id, arrival) in [
|
||||||
|
(1, base),
|
||||||
|
(2, base + Duration::from_millis(200)),
|
||||||
|
] {
|
||||||
|
let mut history = VecDeque::new();
|
||||||
|
history.push_back(vec![1.0; 64]);
|
||||||
|
states.insert(node_id, make_node_state(history, Some(arrival), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
let frames = node_frames_from_states(&states);
|
||||||
|
assert!(
|
||||||
|
MultistaticFuser::new().fuse(&frames).is_err(),
|
||||||
|
"without valid mesh time, 200 ms arrival skew must still trip the 60 ms guard"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn heterogeneous_node_counts_canonicalize_and_fuse() {
|
fn heterogeneous_node_counts_canonicalize_and_fuse() {
|
||||||
// Issue #1170 regression: a mixed mesh with HT20 (64-bin) and HT40
|
// Issue #1170 regression: a mixed mesh with HT20 (64-bin) and HT40
|
||||||
|
|||||||
@@ -104,12 +104,11 @@ async fn subscribe_client(port: u16, topics: &[&str]) -> (AsyncClient, EventLoop
|
|||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
let mut opts = MqttOptions::new(
|
let mut opts = MqttOptions::new(
|
||||||
format!("ruview-test-sub-{}-{}", std::process::id(), suffix),
|
format!("ruview-test-sub-{}-{}", std::process::id(), suffix),
|
||||||
"127.0.0.1",
|
("127.0.0.1", port),
|
||||||
port,
|
|
||||||
);
|
);
|
||||||
opts.set_keep_alive(Duration::from_secs(10));
|
opts.set_keep_alive(10);
|
||||||
opts.set_clean_session(true);
|
opts.set_clean_session(true);
|
||||||
let (client, mut eventloop) = AsyncClient::new(opts, 256);
|
let (client, mut eventloop) = AsyncClient::builder(opts).capacity(256).build();
|
||||||
for t in topics {
|
for t in topics {
|
||||||
client.subscribe(*t, QoS::AtLeastOnce).await.unwrap();
|
client.subscribe(*t, QoS::AtLeastOnce).await.unwrap();
|
||||||
}
|
}
|
||||||
@@ -147,7 +146,11 @@ async fn collect_published(
|
|||||||
let remain = until - tokio::time::Instant::now();
|
let remain = until - tokio::time::Instant::now();
|
||||||
match timeout(remain, eventloop.poll()).await {
|
match timeout(remain, eventloop.poll()).await {
|
||||||
Ok(Ok(Event::Incoming(Packet::Publish(p)))) => {
|
Ok(Ok(Event::Incoming(Packet::Publish(p)))) => {
|
||||||
out.push((p.topic, p.payload.to_vec(), p.retain));
|
out.push((
|
||||||
|
String::from_utf8_lossy(&p.topic).to_string(),
|
||||||
|
p.payload.to_vec(),
|
||||||
|
p.retain,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Ok(Ok(_)) => {} // ignore other events
|
Ok(Ok(_)) => {} // ignore other events
|
||||||
Ok(Err(e)) => {
|
Ok(Err(e)) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user