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:
rUv
2026-08-22 14:59:22 -04:00
committed by GitHub
parent a3b6e1d500
commit f3c361efd1
22 changed files with 579 additions and 908 deletions

View File

@@ -14,6 +14,33 @@ env:
PYTHON_VERSION: '3.11'
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)
sast:
name: Static Application Security Testing

View File

@@ -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% |
| **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% |
| **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 |
| **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 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 |
| **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 |
@@ -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
```
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
> **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.
**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 |
|----------|-------------|----------|------------|-------------|
| **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) |
| **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) |

View File

@@ -1,8 +1,14 @@
# RuView Calibration Service (reference implementation)
Turn a **shared WiFi-CSI pose base model** into a room-specific one with a **30-second labeled
calibration** and a **~11 KB per-room LoRA adapter**. This is the deployable resolution of the
cross-subject / cross-environment generalization problem (full study: [ADR-150 §3.33.6](../../docs/adr/ADR-150-rf-foundation-encoder.md)).
Fit a room-specific **~11 KB LoRA adapter** for a shared WiFi-CSI pose base from a short **labeled
capture**. This is a measured MM-Fi reference path for cross-subject / cross-environment adaptation
(full study: [ADR-150 §3.33.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
@@ -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` |
```bash
# Produce a cog-format per-room adapter for the deployed Rust pose engine:
python cog_calibrate.py --base pose_v1.safetensors --data calib.npz --out room.safetensors
# 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 cog-calib.npz --out room.safetensors
# then in the cog runtime:
cog-pose-estimation run --config <cfg> --adapter room.safetensors
```

View File

@@ -5,10 +5,15 @@ PCK@20 (MultiFormer Table VII metric: `‖predgt‖ ≤ 0.2·‖R-shoulder
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
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
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
| 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)
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
in ~37 KB int4** (with QAT) or **~73 KB int8** (no retraining) — deployable on the sensing node itself.
recovers it to 74.46%**, still beating MultiFormer. So a **SOTA-beating WiFi-pose model fits in
~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.
(We also tested flagship→tiny **knowledge distillation**: it did *not* help — the tiny students reach

View File

@@ -75,7 +75,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
| 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) |
| 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).
### 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
```bash
@@ -1458,7 +1474,7 @@ The pipeline runs 10 phases:
3. Subcarrier resampling (114->56 or 30->56 via Catmull-Rom interpolation)
4. Graph transformer construction (17 COCO keypoints, 16 bone edges)
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)
8. SONA adaptation (micro-LoRA + EWC++)
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)
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
2. **Collect** ~200 unlabeled CSI frames (10 seconds at 20 Hz)
3. The system automatically generates environment-specific LoRA weights via contrastive test-time training
4. No labels, no retraining, no user intervention
Current, testable calibration is the separate **labeled** reference in
`aether-arena/calibration/`: collect paired CSI/keypoint samples, then fit a model-specific LoRA
adapter. The MM-Fi transformer expects `[N,3,114,10]`; the cog expects `[N,56,20]`. Neither adapter
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 |
|-----------|-------------|
@@ -1489,7 +1509,7 @@ MERIDIAN components (all pure Rust, +12K parameters):
| Domain Factorizer | Separates pose-relevant from room-specific features |
| Geometry Encoder | Encodes AP positions (FiLM conditioning with DeepSets) |
| 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.
@@ -1647,7 +1667,7 @@ Not every ADR-295296 remediation item is preview-only. Three are live now:
| 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 |
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
- **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
@@ -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.
**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?**
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.

View File

@@ -1,13 +1,9 @@
# cargo-audit configuration — v2 workspace
# Managed by security audit (fix/security-audit-rustsec-clippy branch).
#
# This file suppresses advisories in two categories:
# A) CVE-bearing advisories in TRANSITIVE deps we cannot upgrade directly
# because the parent published crate (ruvector-core 2.2.0) has not yet
# 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.
# This file suppresses 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.
[advisories]
@@ -24,26 +20,6 @@
# Mitigation: Accept transitively until Tauri v2 drops GTK3 or a workspace
# override path becomes available.
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-0412", # gdk: unmaintained
"RUSTSEC-2024-0413", # atk: unmaintained

987
v2/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -255,7 +255,7 @@ midstreamer-attractor = "0.2"
# ruvector integration (published on crates.io)
# 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).
ruvector-core = "2.2.0"
ruvector-core = "2.3.0"
ruvector-mincut = "2.0.6"
ruvector-attn-mincut = "2.0.4"
ruvector-temporal-tensor = "2.0.6"

View File

@@ -31,10 +31,16 @@ homecore = { path = "../homecore", version = "0.1.0-alpha.0" }
# Async runtime
tokio = { version = "1", features = ["sync", "rt", "rt-multi-thread", "time", "macros"] }
# SQLite via sqlx — only the lite feature set; no postgres, no tls
sqlx = { version = "0.8.1", default-features = false, features = [
"runtime-tokio-native-tls",
"sqlite",
# SQLite-only SQLx crates, pinned in lockstep because their direct APIs are
# semver-exempt. Depending on the umbrella `sqlx` package also resolves its
# unused MySQL backend (and vulnerable `rsa`) into Cargo.lock.
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",
"uuid",
] }

View File

@@ -26,6 +26,19 @@ use homecore::StateMachine;
use crate::dedup::fnv64a_hash;
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 = (
i64,
String,

View File

@@ -39,7 +39,7 @@ serde = { workspace = true, features = ["derive"], optional = true }
serde_json = { workspace = true, optional = true }
# MQTT publisher backend (optional). Matches the `rumqttc` choice already in
# `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 }
[dev-dependencies]

View File

@@ -31,7 +31,7 @@ use crate::PrivacyClass;
/// };
/// 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 mut retained_pub = retained_pub.with_retain(true);
/// publish_discovery(&mut retained_pub, "seed-01", PrivacyClass::Anonymous)?;

View File

@@ -13,7 +13,7 @@
//! use wifi_densepose_bfld::{publish_event, RumqttPublisher};
//! 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);
//! thread::spawn(move || for _ in connection.iter() { /* drain */ });
//! // ... build BfldEvent ...
@@ -22,7 +22,7 @@
#![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::mqtt_topics::{Publish, TopicMessage};
@@ -60,7 +60,7 @@ impl RumqttPublisher {
/// shown in the module-level doc example).
#[must_use]
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)
}
@@ -87,7 +87,7 @@ impl RumqttPublisher {
/// opt in to the LWT without using `connect_with_lwt`.
#[must_use]
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
// dropped while HA was down.
let will = LastWill::new(
@@ -105,6 +105,10 @@ impl Publish for RumqttPublisher {
fn publish(&mut self, msg: &TopicMessage) -> Result<(), Self::Error> {
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),
)
}
}

View File

@@ -23,9 +23,7 @@ use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use rumqttc::{Client, Event, Incoming, MqttOptions, Packet, QoS};
use wifi_densepose_bfld::{
publish_event, BfldEvent, PrivacyClass, RumqttPublisher,
};
use wifi_densepose_bfld::{publish_event, BfldEvent, PrivacyClass, RumqttPublisher};
const SUBSCRIBE_TIMEOUT: Duration = Duration::from_secs(5);
const RECEIVE_TIMEOUT: Duration = Duration::from_secs(10);
@@ -69,9 +67,9 @@ fn spawn_subscriber(
port: u16,
topic_filter: &str,
) -> (Receiver<(String, String)>, Receiver<()>) {
let mut opts = MqttOptions::new(unique_client_id("bfld-sub"), host, port);
opts.set_keep_alive(Duration::from_secs(5));
let (client, mut connection) = Client::new(opts, 64);
let mut opts = MqttOptions::new(unique_client_id("bfld-sub"), (host, port));
opts.set_keep_alive(5);
let (client, mut connection) = Client::builder(opts).capacity(64).build();
client
.subscribe(topic_filter, QoS::AtLeastOnce)
.expect("subscribe enqueue");
@@ -79,13 +77,18 @@ fn spawn_subscriber(
let (incoming_tx, incoming_rx) = channel();
let (suback_tx, suback_rx) = channel();
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() {
match notification {
Ok(Event::Incoming(Packet::SubAck(_))) => {
let _ = suback_tx.send(());
}
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();
if incoming_tx.send((topic, payload)).is_err() {
break;
@@ -141,8 +144,8 @@ fn live_broker_anonymous_event_roundtrips_all_six_topics() {
// Publisher with its own connection. Spawn a thread iterating the
// Connection so publishes actually reach the broker.
let mut opts = MqttOptions::new(unique_client_id("bfld-pub"), &host, port);
opts.set_keep_alive(Duration::from_secs(5));
let mut opts = MqttOptions::new(unique_client_id("bfld-pub"), (host.as_str(), port));
opts.set_keep_alive(5);
let (mut publisher, mut pub_connection) = RumqttPublisher::connect(opts, 64);
thread::spawn(move || {
for _ in pub_connection.iter() { /* drain protocol events */ }
@@ -197,8 +200,8 @@ fn live_broker_restricted_event_omits_identity_risk() {
.recv_timeout(SUBSCRIBE_TIMEOUT)
.expect("SubAck within 5s");
let mut opts = MqttOptions::new(unique_client_id("bfld-pub-r"), &host, port);
opts.set_keep_alive(Duration::from_secs(5));
let mut opts = MqttOptions::new(unique_client_id("bfld-pub-r"), (host.as_str(), port));
opts.set_keep_alive(5);
let (mut publisher, mut pub_connection) = RumqttPublisher::connect(opts, 64);
thread::spawn(move || for _ in pub_connection.iter() {});
thread::sleep(Duration::from_millis(200));

View File

@@ -9,7 +9,7 @@ use wifi_densepose_bfld::{
};
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]
@@ -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
// own opts, then call with_lwt before passing to RumqttPublisher::connect.
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 (_publisher, _connection) = RumqttPublisher::connect(opts, 16);
}

View File

@@ -12,7 +12,7 @@ use wifi_densepose_bfld::{publish_event, BfldEvent, PrivacyClass, Publish, Rumqt
fn unreachable_opts() -> MqttOptions {
// Port 1 is reserved (RFC 1700) and the loopback address will refuse
// 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 {

View File

@@ -110,7 +110,7 @@ rand = "0.8"
# client (ADR-115 §10 references). `rustls` is preferred over openssl on
# Windows to keep parity with the rest of the workspace (`ureq` above also
# 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
# principle as `mqtt`: the heavy exporter stack (opentelemetry SDK +

View File

@@ -286,6 +286,9 @@ struct Esp32Frame {
/// ADR-110 byte 18: PPDU type the CSI was sampled from. Pre-ADR-110
/// firmware sends 0 ⇒ `PpduType::HtLegacy`.
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>,
phases: Vec<f64>,
}
@@ -675,6 +678,12 @@ struct NodeState {
latest_sync: Option<wifi_densepose_hardware::SyncPacket>,
/// Last time a sync packet from this node was received (for staleness).
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.
/// Replaces the hardcoded 20 Hz fallback in
/// `mesh_aligned_us_for_csi_frame` once `csi_fps_samples ≥ 5`.
@@ -832,6 +841,9 @@ impl NodeState {
/// staleness gate).
pub(crate) fn mesh_aligned_us(&self, local_at_frame_us: u64) -> Option<u64> {
let sync = self.latest_sync.as_ref()?;
if !sync.flags.is_valid {
return None;
}
let seen_at = self.latest_sync_at?;
// Drop stale syncs — firmware emits at ~0.5 Hz default, anything
// 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.
pub(crate) fn mesh_aligned_us_for_csi_frame(&self, frame_sequence: u32) -> Option<u64> {
let sync = self.latest_sync.as_ref()?;
if !sync.flags.is_valid {
return None;
}
let seen_at = self.latest_sync_at?;
if seen_at.elapsed() > std::time::Duration::from_secs(9) {
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
// samples; until then fall back to the 20 Hz firmware ceiling. The
// §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))
}
/// 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
/// CSI frame arrival. Call once per accepted CSI frame from
/// `udp_receiver_task`. Uses `last_frame_time` as the previous-frame
@@ -927,6 +959,21 @@ impl NodeState {
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 {
Self {
frame_history: VecDeque::new(),
@@ -951,6 +998,8 @@ impl NodeState {
edge_vitals: None,
latest_sync: None,
latest_sync_at: None,
latest_csi_sequence: None,
latest_csi_sync_valid: false,
csi_fps_ema: 20.0,
csi_fps_samples: 0,
latest_features: None,
@@ -1945,7 +1994,8 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
// [12..15] sequence (u32 LE)
// [16] rssi (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
// 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
@@ -1966,6 +2016,7 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
};
let noise_floor = buf[17] as i8;
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 n_pairs = n_antennas as usize * n_subcarriers as usize;
@@ -1995,6 +2046,7 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
rssi,
noise_floor,
ppdu_type,
adr018_flags,
amplitudes,
phases,
})
@@ -2024,7 +2076,7 @@ mod issue_1009_n_subcarriers_u16_tests {
buf[16] = (-40i8) as u8; // rssi
buf[17] = (-90i8) as u8; // noise_floor
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 {
buf[20 + k * 2] = (5 + (k % 40) as i8) as u8; // i
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.rssi, -40);
assert_eq!(frame.sequence, 42);
assert!(frame.adr018_flags.ieee802154_sync_valid);
}
#[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,
noise_floor: -90,
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
adr018_flags: wifi_densepose_hardware::Adr018Flags::default(),
amplitudes: multi_ap_frame.amplitudes.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,
noise_floor: -90,
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
adr018_flags: wifi_densepose_hardware::Adr018Flags::default(),
amplitudes: vec![signal_pct],
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,
noise_floor: -90,
ppdu_type: wifi_densepose_hardware::PpduType::HtLegacy,
adr018_flags: wifi_densepose_hardware::Adr018Flags::default(),
amplitudes,
phases,
}
@@ -6702,8 +6758,11 @@ async fn udp_receiver_task(
// ADR-110 iter 19 — feed the per-node fps EMA from real
// CSI arrivals. The helper sets `last_frame_time` as a
// side effect, so the previous bare assignment is gone.
let first_sensing_frame =
ns.observe_csi_frame_arrival(std::time::Instant::now());
let first_sensing_frame = ns.observe_accepted_csi_frame(
frame.sequence,
frame.adr018_flags.ieee802154_sync_valid,
std::time::Instant::now(),
);
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)");
}
@@ -9327,6 +9386,31 @@ mod sync_snapshot_helper_tests {
"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]
fn snapshot_reflects_leader_state() {
// Same data shape that /api/v1/mesh emits for a leader node.

View File

@@ -30,7 +30,10 @@
use std::sync::Arc;
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::task::JoinHandle;
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`].
fn build_mqtt_options(cfg: &MqttConfig) -> MqttOptions {
let mut opts = MqttOptions::new(&cfg.client_id, &cfg.host, cfg.port);
opts.set_keep_alive(Duration::from_secs(30));
let mut opts = MqttOptions::new(&cfg.client_id, (cfg.host.as_str(), cfg.port));
opts.set_keep_alive(30);
opts.set_clean_session(true);
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() {
opts.set_credentials(u, "");
opts.set_credentials(u.to_owned(), Vec::<u8>::new());
}
opts.set_transport(build_transport(&cfg.tls));
@@ -223,7 +226,8 @@ async fn run(
mut state_rx: broadcast::Receiver<VitalsSnapshot>,
) {
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(
cfg.privacy_mode,
@@ -369,7 +373,13 @@ async fn publish_all_discovery(
let cfg = b.build(e);
let topic = b.config_topic(e);
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(())
}
@@ -380,7 +390,13 @@ async fn publish_availability(
state: &str,
) -> Result<(), ClientError> {
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(())
}
@@ -441,7 +457,13 @@ async fn publish_state(client: &AsyncClient, m: &StateMessage) -> Result<(), Cli
1 => QoS::AtLeastOnce,
_ => 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)]

View File

@@ -24,7 +24,13 @@ const DEFAULT_FREQ_MHZ: u32 = 2437; // Channel 6
/// Monotonic reference point for timestamp generation. All node timestamps
/// 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
/// 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 phase = vec![0.0_f32; n_sub];
// Monotonic timestamp: microseconds since a shared process-local epoch.
// All nodes use the same reference so the fuser's guard_interval_us check
// compares apples to apples. No wall-clock mixing (immune to NTP jumps).
let timestamp_us = last_time.duration_since(*EPOCH).as_micros() as u64;
// Prefer the capture timestamp recovered from the node's mesh sync. This
// keeps UDP scheduling jitter out of the fuser's cross-node guard. Older
// firmware, stale sync state, and frames without the sync-valid bit retain
// 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 {
amplitude,
@@ -173,6 +187,7 @@ pub fn compute_person_score_from_amplitudes(amplitudes: &[f32]) -> f64 {
mod tests {
use super::*;
use std::collections::VecDeque;
use wifi_densepose_hardware::{SyncPacket, SyncPacketFlags};
/// Helper: build a minimal NodeState for testing. Uses `NodeState::new()`
/// then mutates the `pub(crate)` fields the bridge needs.
@@ -225,6 +240,99 @@ mod tests {
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]
fn heterogeneous_node_counts_canonicalize_and_fuse() {
// Issue #1170 regression: a mixed mesh with HT20 (64-bin) and HT40

View File

@@ -104,12 +104,11 @@ async fn subscribe_client(port: u16, topics: &[&str]) -> (AsyncClient, EventLoop
.unwrap_or(0);
let mut opts = MqttOptions::new(
format!("ruview-test-sub-{}-{}", std::process::id(), suffix),
"127.0.0.1",
port,
("127.0.0.1", port),
);
opts.set_keep_alive(Duration::from_secs(10));
opts.set_keep_alive(10);
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 {
client.subscribe(*t, QoS::AtLeastOnce).await.unwrap();
}
@@ -147,7 +146,11 @@ async fn collect_published(
let remain = until - tokio::time::Instant::now();
match timeout(remain, eventloop.poll()).await {
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(Err(e)) => {