mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb996294fb | |||
| be4dad6ede | |||
| c965e3e6c0 | |||
| 833ac84059 |
@@ -112,12 +112,19 @@ node scripts/rf-scan.js --port 5006 # Live RF room scan
|
||||
node scripts/snn-csi-processor.js --port 5006 # SNN real-time learning
|
||||
node scripts/mincut-person-counter.js --port 5006 # Correct person counting
|
||||
|
||||
# Option 4: Python — talk to a RuView node from your own code (ADR-117)
|
||||
pip install "wifi-densepose[client]" # ~250 KB compiled wheel, abi3-py310
|
||||
# Option 4: Python — live on PyPI (ADR-117)
|
||||
pip install ruview # or: pip install wifi-densepose
|
||||
# Both ship the same compiled PyO3 wheel (~250 KB, abi3-py310, Linux/macOS/Windows).
|
||||
# Add [client] for the asyncio WebSocket + paho-mqtt clients:
|
||||
pip install "ruview[client]" # or: pip install "wifi-densepose[client]"
|
||||
|
||||
# from ruview import BreathingExtractor, HeartRateExtractor # equivalent to:
|
||||
# from wifi_densepose import BreathingExtractor, HeartRateExtractor
|
||||
# from wifi_densepose.client import SensingClient, RuViewMqttClient
|
||||
# from ruview.client import SensingClient, RuViewMqttClient
|
||||
```
|
||||
|
||||
[](https://pypi.org/project/ruview/) [](https://pypi.org/project/wifi-densepose/)
|
||||
|
||||
> [!NOTE]
|
||||
> **CSI-capable hardware recommended.** Presence, vital signs, through-wall sensing, and all advanced capabilities require Channel State Information (CSI) from an ESP32-S3 ($9) or research NIC. The Docker image runs with simulated data for evaluation. Consumer WiFi laptops provide RSSI-only presence detection.
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ pub struct BfldFrameHeader {
|
||||
}
|
||||
```
|
||||
|
||||
Total header size: 40 bytes (validated by `static_assertions::const_assert_eq!`).
|
||||
Total header size: **86 bytes packed** (validated by `static_assertions::const_assert_eq!` in `wifi-densepose-bfld/src/frame.rs`). Earlier drafts stated 40 bytes — that was a counting error caught during P1 scaffold; see AC1 below.
|
||||
|
||||
### 2.2 Payload structure
|
||||
|
||||
@@ -144,7 +144,7 @@ Rejected: CRC must be computed after the payload, so its value would otherwise f
|
||||
|
||||
## 5. Acceptance Criteria
|
||||
|
||||
- [ ] **AC1**: `BfldFrameHeader` size is exactly 40 bytes on x86_64, aarch64, and xtensa-esp32s3.
|
||||
- [ ] **AC1**: `BfldFrameHeader` size is exactly **86 bytes** (packed) on x86_64, aarch64, and xtensa-esp32s3. The size was initially documented as 40 bytes during ADR drafting — that was a counting error; the implementation in `wifi-densepose-bfld/src/frame.rs` enforces the correct value via `const_assert_eq!`.
|
||||
- [ ] **AC2**: 1,000 serializations of a fixed `BfiCapture` fixture produce a bit-identical BLAKE3 hash.
|
||||
- [ ] **AC3**: `privacy_class = 0` frame returned through `NetworkSink::publish()` returns `Err(BfldError::PrivacyViolation)`.
|
||||
- [ ] **AC4**: Payload CRC32 mismatch causes `BfldFrame::parse()` to return `Err(BfldError::Crc)` without exposing partial payload state.
|
||||
|
||||
+41
-11
@@ -166,24 +166,48 @@ See the full crate list and dependency order in [CLAUDE.md](../CLAUDE.md#crate-p
|
||||
|
||||
### Python wheel (pip) — ADR-117
|
||||
|
||||
The `wifi-densepose` PyPI wheel is a PyO3 binding to the Rust core. It
|
||||
ships compiled DSP (~250 KB, Linux/macOS/Windows × abi3-py310) plus an
|
||||
opt-in pure-Python WebSocket/MQTT client for talking to a live RuView
|
||||
sensing-server.
|
||||
The Python API ships as **two interchangeable PyPI packages** — same
|
||||
compiled PyO3 wheel under both names; pick whichever import name
|
||||
reads better in your code:
|
||||
|
||||
| PyPI | Install | Latest | Import |
|
||||
|---|---|---|---|
|
||||
| [`ruview`](https://pypi.org/project/ruview/) | `pip install ruview` | `2.0.0a1` | `from ruview import ...` |
|
||||
| [`wifi-densepose`](https://pypi.org/project/wifi-densepose/) | `pip install wifi-densepose` | `2.0.0a1` | `from wifi_densepose import ...` |
|
||||
|
||||
```bash
|
||||
pip install wifi-densepose # core DSP only
|
||||
pip install "wifi-densepose[client]" # + websockets + paho-mqtt
|
||||
pip install ruview # core DSP (~250 KB compiled wheel)
|
||||
pip install "ruview[client]" # + asyncio WebSocket + paho-mqtt
|
||||
```
|
||||
|
||||
```python
|
||||
from wifi_densepose import BreathingExtractor, HeartRateExtractor
|
||||
from wifi_densepose.client import SensingClient, RuViewMqttClient
|
||||
# vitals
|
||||
from ruview import BreathingExtractor, HeartRateExtractor
|
||||
br = BreathingExtractor.esp32_default() # 56 subcarriers @ 100 Hz, 30s window
|
||||
|
||||
# live sensing-server stream
|
||||
from ruview.client import SensingClient, EdgeVitalsMessage
|
||||
async with SensingClient("ws://localhost:8765/ws/sensing") as c:
|
||||
async for msg in c.stream():
|
||||
if isinstance(msg, EdgeVitalsMessage):
|
||||
print(msg.breathing_rate_bpm, msg.heartrate_bpm)
|
||||
|
||||
# Home Assistant semantic primitives (ADR-115 HA-MIND)
|
||||
from ruview.client import (
|
||||
RuViewMqttClient, SemanticPrimitive, SemanticPrimitiveListener,
|
||||
)
|
||||
```
|
||||
|
||||
The legacy `wifi-densepose==1.1.0` FastAPI server is end-of-life;
|
||||
`wifi-densepose==1.99.0` is a tombstone that raises `ImportError`
|
||||
with a migration URL.
|
||||
The wheels ship for Linux (x86_64, aarch64 via sdist), macOS (sdist),
|
||||
and Windows (amd64 wheel). Stable ABI (`abi3-py310`) — one binary
|
||||
covers Python 3.10+. Multi-arch native wheels are produced by the
|
||||
[pip-release.yml](../.github/workflows/pip-release.yml) cibuildwheel
|
||||
matrix on each `v*-pip` tag.
|
||||
|
||||
> **Migrating from v1.x?** The legacy `wifi-densepose==1.1.0` FastAPI
|
||||
> server is end-of-life. `wifi-densepose==1.99.0` is a tombstone that
|
||||
> raises `ImportError` with a migration URL; upgrade to `>=2.0.0a1`
|
||||
> (or switch to `ruview`).
|
||||
|
||||
To build the wheel from source (e.g. for a local change):
|
||||
|
||||
@@ -192,8 +216,14 @@ git clone https://github.com/ruvnet/RuView.git
|
||||
cd RuView/python
|
||||
pip install maturin>=1.7
|
||||
maturin develop --release
|
||||
pytest tests/ # 183 tests
|
||||
pytest bench/ --benchmark-only # 12 hot-path benchmarks
|
||||
```
|
||||
|
||||
Full API + tests breakdown is on the PyPI front page:
|
||||
[wifi-densepose on PyPI](https://pypi.org/project/wifi-densepose/) ·
|
||||
[ruview on PyPI](https://pypi.org/project/ruview/).
|
||||
|
||||
### Guided Installer
|
||||
|
||||
An interactive installer that detects your hardware and recommends a profile:
|
||||
|
||||
Generated
+15
@@ -7255,6 +7255,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strength_reduce"
|
||||
version = "0.2.4"
|
||||
@@ -9133,6 +9139,15 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-bfld"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"static_assertions",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wifi-densepose-cli"
|
||||
version = "0.3.0"
|
||||
|
||||
@@ -42,6 +42,11 @@ members = [
|
||||
# ADR-115 MQTT publisher as a Seed-installable artifact with
|
||||
# mDNS, embedded broker, RuVector thresholds, Ed25519 witness.
|
||||
"crates/cog-ha-matter",
|
||||
# ADR-118: BFLD — Beamforming Feedback Layer for Detection. The
|
||||
# privacy/safety layer that measures and gates identity leakage from
|
||||
# WiFi BFI captures. Sub-ADRs: 119 (frame), 120 (privacy class),
|
||||
# 121 (identity risk), 122 (HA/Matter), 123 (capture path).
|
||||
"crates/wifi-densepose-bfld",
|
||||
# rvCSI — edge RF sensing runtime (ADR-095 platform, ADR-096 FFI/crate layout):
|
||||
# lives in its own repo (https://github.com/ruvnet/rvcsi), vendored here as
|
||||
# `vendor/rvcsi` and published to crates.io as `rvcsi-*` 0.3.x. Depend on the
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "wifi-densepose-bfld"
|
||||
description = "BFLD — Beamforming Feedback Layer for Detection. Privacy-gated WiFi BFI sensing primitives. See ADR-118."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
documentation.workspace = true
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["std"]
|
||||
std = []
|
||||
# Soul Signature integration (ADR-118 §1.4, ADR-120 §2.7, ADR-121 §2.6) —
|
||||
# enables privacy_class = 1 (derived) mode and the SoulMatchOracle gate
|
||||
# exemption. Disabled by default per the structural class-2 default.
|
||||
soul-signature = []
|
||||
|
||||
[dependencies]
|
||||
thiserror.workspace = true
|
||||
static_assertions = "1.1"
|
||||
|
||||
[dev-dependencies]
|
||||
proptest.workspace = true
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
missing_docs = "warn"
|
||||
|
||||
[lints.clippy]
|
||||
all = "warn"
|
||||
pedantic = "warn"
|
||||
nursery = "warn"
|
||||
module_name_repetitions = "allow"
|
||||
missing_const_for_fn = "allow"
|
||||
missing_panics_doc = "allow"
|
||||
@@ -0,0 +1,177 @@
|
||||
//! `BfldFrame` wire-format primitives. See ADR-119.
|
||||
//!
|
||||
//! The header is `#[repr(C, packed)]` so the wire byte order is fixed across
|
||||
//! x86_64, aarch64, and xtensa-esp32s3 — and so the witness-bundle pattern
|
||||
//! (ADR-028) extends cleanly to BFLD frames.
|
||||
//!
|
||||
//! All multi-byte integers serialize as **little-endian**. The
|
||||
//! `to_le_bytes`/`from_le_bytes` helpers encode/decode without `unsafe`, which
|
||||
//! is forbidden in this crate; the encoded bytes are the canonical wire form.
|
||||
|
||||
use static_assertions::const_assert_eq;
|
||||
|
||||
use crate::BfldError;
|
||||
|
||||
/// Magic value identifying a `BfldFrame`. Reads as "BFLD" in hex-dump tools.
|
||||
pub const BFLD_MAGIC: u32 = 0xBF1D_0001;
|
||||
|
||||
/// Current `BfldFrame` major version. Bumps on any incompatible layout change.
|
||||
pub const BFLD_VERSION: u16 = 1;
|
||||
|
||||
/// Size of the packed header in bytes. Asserted at compile time below.
|
||||
///
|
||||
/// Note: ADR-119 AC1 initially claimed 40 bytes — that was a counting error.
|
||||
/// Actual packed layout sums to 86. Updated 2026-05-24 to match implementation.
|
||||
pub const BFLD_HEADER_SIZE: usize = 86;
|
||||
|
||||
/// Flag bits in `BfldFrameHeader::flags`. See ADR-119 §2.1.
|
||||
pub mod flags {
|
||||
/// Payload contains an optional CSI delta section.
|
||||
pub const HAS_CSI_DELTA: u16 = 1 << 0;
|
||||
/// `privacy_mode` is engaged: identity-derived fields suppressed.
|
||||
pub const PRIVACY_MODE: u16 = 1 << 1;
|
||||
/// ESP32-S3 self-only adapter (ADR-123 §2.5): no `identity_risk_score`.
|
||||
pub const SELF_ONLY: u16 = 1 << 3;
|
||||
}
|
||||
|
||||
/// On-the-wire BFLD frame header. 86 bytes, little-endian, packed.
|
||||
#[repr(C, packed)]
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct BfldFrameHeader {
|
||||
/// Must equal [`BFLD_MAGIC`].
|
||||
pub magic: u32,
|
||||
/// Layout version. Currently [`BFLD_VERSION`].
|
||||
pub version: u16,
|
||||
/// Flag bits — see [`flags`].
|
||||
pub flags: u16,
|
||||
/// Monotonic capture-clock timestamp in nanoseconds.
|
||||
pub timestamp_ns: u64,
|
||||
/// BLAKE3-keyed(site_salt, ap_mac)[0..16] — ADR-120 §2.3.
|
||||
pub ap_hash: [u8; 16],
|
||||
/// BLAKE3-keyed(site_salt ‖ day_epoch, sta_mac)[0..16] — daily-rotated.
|
||||
pub sta_hash: [u8; 16],
|
||||
/// Ephemeral session identifier, rotated on capture-session boundary.
|
||||
pub session_id: [u8; 16],
|
||||
/// 802.11 channel number.
|
||||
pub channel: u16,
|
||||
/// Channel bandwidth in MHz: 20 / 40 / 80 / 160.
|
||||
pub bandwidth_mhz: u16,
|
||||
/// Received signal strength in dBm.
|
||||
pub rssi_dbm: i16,
|
||||
/// Noise floor in dBm.
|
||||
pub noise_floor_dbm: i16,
|
||||
/// Number of OFDM subcarriers represented.
|
||||
pub n_subcarriers: u16,
|
||||
/// Number of transmit antennas.
|
||||
pub n_tx: u8,
|
||||
/// Number of receive antennas.
|
||||
pub n_rx: u8,
|
||||
/// 0=f32, 1=i16, 2=i8, 3=packed (4-bit nibbles).
|
||||
pub quantization: u8,
|
||||
/// `PrivacyClass` byte — see ADR-120 §2.1.
|
||||
pub privacy_class: u8,
|
||||
/// Length of the payload section in bytes.
|
||||
pub payload_len: u32,
|
||||
/// CRC-32/ISO-HDLC over payload bytes only.
|
||||
pub payload_crc32: u32,
|
||||
}
|
||||
|
||||
const_assert_eq!(core::mem::size_of::<BfldFrameHeader>(), BFLD_HEADER_SIZE);
|
||||
|
||||
impl BfldFrameHeader {
|
||||
/// Build a header with `magic` and `version` already set correctly.
|
||||
/// All other fields default to zero — caller fills them in.
|
||||
#[must_use]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
magic: BFLD_MAGIC,
|
||||
version: BFLD_VERSION,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to canonical little-endian wire form (86 bytes).
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub fn to_le_bytes(&self) -> [u8; BFLD_HEADER_SIZE] {
|
||||
let mut buf = [0u8; BFLD_HEADER_SIZE];
|
||||
let mut o = 0usize;
|
||||
|
||||
// Copy locally to dodge `#[repr(packed)]` unaligned-borrow warnings.
|
||||
let magic = self.magic;
|
||||
let version = self.version;
|
||||
let flags = self.flags;
|
||||
let timestamp_ns = self.timestamp_ns;
|
||||
let channel = self.channel;
|
||||
let bandwidth_mhz = self.bandwidth_mhz;
|
||||
let rssi_dbm = self.rssi_dbm;
|
||||
let noise_floor_dbm = self.noise_floor_dbm;
|
||||
let n_subcarriers = self.n_subcarriers;
|
||||
let payload_len = self.payload_len;
|
||||
let payload_crc32 = self.payload_crc32;
|
||||
|
||||
buf[o..o + 4].copy_from_slice(&magic.to_le_bytes()); o += 4;
|
||||
buf[o..o + 2].copy_from_slice(&version.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&flags.to_le_bytes()); o += 2;
|
||||
buf[o..o + 8].copy_from_slice(×tamp_ns.to_le_bytes()); o += 8;
|
||||
buf[o..o + 16].copy_from_slice(&self.ap_hash); o += 16;
|
||||
buf[o..o + 16].copy_from_slice(&self.sta_hash); o += 16;
|
||||
buf[o..o + 16].copy_from_slice(&self.session_id); o += 16;
|
||||
buf[o..o + 2].copy_from_slice(&channel.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&bandwidth_mhz.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&rssi_dbm.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&noise_floor_dbm.to_le_bytes()); o += 2;
|
||||
buf[o..o + 2].copy_from_slice(&n_subcarriers.to_le_bytes()); o += 2;
|
||||
buf[o] = self.n_tx; o += 1;
|
||||
buf[o] = self.n_rx; o += 1;
|
||||
buf[o] = self.quantization; o += 1;
|
||||
buf[o] = self.privacy_class; o += 1;
|
||||
buf[o..o + 4].copy_from_slice(&payload_len.to_le_bytes()); o += 4;
|
||||
buf[o..o + 4].copy_from_slice(&payload_crc32.to_le_bytes()); o += 4;
|
||||
|
||||
debug_assert_eq!(o, BFLD_HEADER_SIZE);
|
||||
buf
|
||||
}
|
||||
|
||||
/// Parse from canonical little-endian wire form.
|
||||
///
|
||||
/// Returns [`BfldError::InvalidMagic`] if the magic prefix is wrong, and
|
||||
/// [`BfldError::UnsupportedVersion`] for a version this build cannot decode.
|
||||
/// Field-level validation (CRC, payload_len bounds) is deliberately *not*
|
||||
/// performed here — that lives at the frame-level parser.
|
||||
pub fn from_le_bytes(bytes: &[u8; BFLD_HEADER_SIZE]) -> Result<Self, BfldError> {
|
||||
let magic = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
|
||||
if magic != BFLD_MAGIC {
|
||||
return Err(BfldError::InvalidMagic(magic));
|
||||
}
|
||||
let version = u16::from_le_bytes(bytes[4..6].try_into().unwrap());
|
||||
if version != BFLD_VERSION {
|
||||
return Err(BfldError::UnsupportedVersion(version));
|
||||
}
|
||||
|
||||
let mut h = Self {
|
||||
magic,
|
||||
version,
|
||||
flags: u16::from_le_bytes(bytes[6..8].try_into().unwrap()),
|
||||
timestamp_ns: u64::from_le_bytes(bytes[8..16].try_into().unwrap()),
|
||||
ap_hash: [0; 16],
|
||||
sta_hash: [0; 16],
|
||||
session_id: [0; 16],
|
||||
channel: u16::from_le_bytes(bytes[64..66].try_into().unwrap()),
|
||||
bandwidth_mhz: u16::from_le_bytes(bytes[66..68].try_into().unwrap()),
|
||||
rssi_dbm: i16::from_le_bytes(bytes[68..70].try_into().unwrap()),
|
||||
noise_floor_dbm: i16::from_le_bytes(bytes[70..72].try_into().unwrap()),
|
||||
n_subcarriers: u16::from_le_bytes(bytes[72..74].try_into().unwrap()),
|
||||
n_tx: bytes[74],
|
||||
n_rx: bytes[75],
|
||||
quantization: bytes[76],
|
||||
privacy_class: bytes[77],
|
||||
payload_len: u32::from_le_bytes(bytes[78..82].try_into().unwrap()),
|
||||
payload_crc32: u32::from_le_bytes(bytes[82..86].try_into().unwrap()),
|
||||
};
|
||||
h.ap_hash.copy_from_slice(&bytes[16..32]);
|
||||
h.sta_hash.copy_from_slice(&bytes[32..48]);
|
||||
h.session_id.copy_from_slice(&bytes[48..64]);
|
||||
Ok(h)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
//! # BFLD — Beamforming Feedback Layer for Detection
|
||||
//!
|
||||
//! Privacy-gated WiFi sensing primitives derived from 802.11ac/ax Beamforming
|
||||
//! Feedback Information (BFI). See [`docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md`](../../../docs/adr/ADR-118-bfld-beamforming-feedback-layer-for-detection.md).
|
||||
//!
|
||||
//! ## Three structural invariants
|
||||
//!
|
||||
//! - **I1**: Raw BFI never exits the node.
|
||||
//! - **I2**: Identity embedding is in-RAM-only.
|
||||
//! - **I3**: Cross-site identity correlation is cryptographically impossible.
|
||||
//!
|
||||
//! Status: P1 in progress — frame format + sink marker traits. P2–P6 follow.
|
||||
|
||||
#![cfg_attr(not(feature = "std"), no_std)]
|
||||
|
||||
pub mod frame;
|
||||
pub mod sink;
|
||||
|
||||
pub use frame::{BfldFrameHeader, BFLD_MAGIC, BFLD_VERSION, BFLD_HEADER_SIZE};
|
||||
pub use sink::{check_class, LocalSink, MatterSink, NetworkSink, Sink};
|
||||
|
||||
/// Privacy classification carried in every `BfldFrame`. See ADR-120 §2.1.
|
||||
#[repr(u8)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum PrivacyClass {
|
||||
/// Local-only research data including raw BFI matrix. Never networked.
|
||||
Raw = 0,
|
||||
/// Operator-acknowledged research mode over LAN. Downsampled angles +
|
||||
/// identity_embedding + identity_risk_score available. Required for
|
||||
/// Soul Signature deployments (ADR-120 §2.7).
|
||||
Derived = 1,
|
||||
/// Production default: aggregate sensing only, no identity-derived fields.
|
||||
Anonymous = 2,
|
||||
/// Care-home / regulated deployments: class 2 minus risk score and hash.
|
||||
Restricted = 3,
|
||||
}
|
||||
|
||||
impl PrivacyClass {
|
||||
/// Returns `true` if frames of this class may cross a `NetworkSink`.
|
||||
/// Class 0 (`Raw`) is local-only by structural invariant I1.
|
||||
#[must_use]
|
||||
pub const fn allows_network(self) -> bool {
|
||||
!matches!(self, Self::Raw)
|
||||
}
|
||||
|
||||
/// Returns `true` if frames of this class may cross the Matter boundary.
|
||||
/// Only classes 2 and 3 are Matter-eligible. See ADR-122 §2.4.
|
||||
#[must_use]
|
||||
pub const fn allows_matter(self) -> bool {
|
||||
matches!(self, Self::Anonymous | Self::Restricted)
|
||||
}
|
||||
|
||||
/// Returns the byte value of this class (0..=3) for serialization.
|
||||
#[must_use]
|
||||
pub const fn as_u8(self) -> u8 {
|
||||
self as u8
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<u8> for PrivacyClass {
|
||||
type Error = BfldError;
|
||||
|
||||
fn try_from(value: u8) -> Result<Self, Self::Error> {
|
||||
match value {
|
||||
0 => Ok(Self::Raw),
|
||||
1 => Ok(Self::Derived),
|
||||
2 => Ok(Self::Anonymous),
|
||||
3 => Ok(Self::Restricted),
|
||||
other => Err(BfldError::InvalidPrivacyClass(other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors produced by BFLD operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BfldError {
|
||||
/// Header magic did not match `BFLD_MAGIC`.
|
||||
#[error("invalid BFLD magic: expected 0x{BFLD_MAGIC:08X}, got 0x{0:08X}")]
|
||||
InvalidMagic(u32),
|
||||
|
||||
/// Header version unsupported.
|
||||
#[error("unsupported BFLD version: {0}")]
|
||||
UnsupportedVersion(u16),
|
||||
|
||||
/// Payload CRC32 mismatch — frame corrupted or tampered.
|
||||
#[error("payload CRC mismatch: expected 0x{expected:08X}, got 0x{actual:08X}")]
|
||||
Crc { expected: u32, actual: u32 },
|
||||
|
||||
/// Attempted to publish a class-0 (`Raw`) frame through a network sink.
|
||||
/// Enforces structural invariant I1.
|
||||
#[error("privacy violation: {reason}")]
|
||||
PrivacyViolation { reason: &'static str },
|
||||
|
||||
/// Byte value did not map to any defined `PrivacyClass` (0..=3).
|
||||
#[error("invalid PrivacyClass byte: {0}")]
|
||||
InvalidPrivacyClass(u8),
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Sink marker traits — structural enforcement of invariant I1.
|
||||
//!
|
||||
//! Every output destination (memory buffer, MQTT topic, Matter cluster) implements
|
||||
//! exactly one of [`LocalSink`], [`NetworkSink`], or [`MatterSink`]. The associated
|
||||
//! constant [`Sink::MIN_CLASS`] declares the lowest `PrivacyClass` value that sink
|
||||
//! is willing to accept; the runtime gate [`check_class`] enforces this on every
|
||||
//! publish.
|
||||
//!
|
||||
//! Mapping (ADR-120 §2.2, ADR-122 §2.4):
|
||||
//!
|
||||
//! | Sink trait | `MIN_CLASS` | Accepts classes |
|
||||
//! |---------------|----------------------|-----------------|
|
||||
//! | `LocalSink` | `PrivacyClass::Raw` | 0, 1, 2, 3 |
|
||||
//! | `NetworkSink` | `PrivacyClass::Derived` | 1, 2, 3 |
|
||||
//! | `MatterSink` | `PrivacyClass::Anonymous` | 2, 3 |
|
||||
//!
|
||||
//! `MatterSink: NetworkSink` — every Matter sink is also a network sink.
|
||||
|
||||
use crate::{BfldError, PrivacyClass};
|
||||
|
||||
/// Base sink trait. Every sink type declares the minimum `PrivacyClass` it accepts.
|
||||
pub trait Sink {
|
||||
/// Lowest privacy class (highest information density) this sink will publish.
|
||||
const MIN_CLASS: PrivacyClass;
|
||||
/// Human-readable sink kind, used in `BfldError::PrivacyViolation` messages.
|
||||
const KIND: &'static str;
|
||||
}
|
||||
|
||||
/// Marker for sinks that stay on the originating node (memory, in-RAM channel,
|
||||
/// local file with explicit operator opt-in). Accepts every class including `Raw`.
|
||||
pub trait LocalSink: Sink {}
|
||||
|
||||
/// Marker for sinks that cross the node boundary (MQTT, HTTP, gRPC). Rejects
|
||||
/// `Raw` frames by structural invariant I1.
|
||||
pub trait NetworkSink: Sink {}
|
||||
|
||||
/// Marker for sinks that bridge into the Matter cluster surface. Rejects `Raw`
|
||||
/// and `Derived`; the `cog-ha-matter` boundary filter consumes only classes 2/3.
|
||||
pub trait MatterSink: NetworkSink {}
|
||||
|
||||
/// Runtime gate. Returns `Ok(())` if `class` is acceptable for `S`, otherwise
|
||||
/// returns `BfldError::PrivacyViolation` with the offending sink kind.
|
||||
///
|
||||
/// Class numerical order *is* meaningful here: a sink that accepts `MIN_CLASS`
|
||||
/// also accepts every higher-numbered class (less identity content). The check
|
||||
/// is therefore a simple `>=` on the byte representation.
|
||||
pub fn check_class<S: Sink>(class: PrivacyClass) -> Result<(), BfldError> {
|
||||
if class.as_u8() >= S::MIN_CLASS.as_u8() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(BfldError::PrivacyViolation {
|
||||
reason: S::KIND,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- Default sink types ----------------------------------------------------
|
||||
//
|
||||
// Concrete sinks live in downstream crates (emitter.rs, mqtt.rs, the cog-ha-matter
|
||||
// Matter bridge). These three "kind tags" are convenient zero-sized stand-ins for
|
||||
// unit tests and for the privacy_gate compile-time tables.
|
||||
|
||||
/// Zero-sized tag: a local in-memory ring buffer or file sink.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct LocalKind;
|
||||
|
||||
impl Sink for LocalKind {
|
||||
const MIN_CLASS: PrivacyClass = PrivacyClass::Raw;
|
||||
const KIND: &'static str = "LocalKind";
|
||||
}
|
||||
impl LocalSink for LocalKind {}
|
||||
|
||||
/// Zero-sized tag: a generic network sink (MQTT, HTTP, gRPC).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct NetworkKind;
|
||||
|
||||
impl Sink for NetworkKind {
|
||||
const MIN_CLASS: PrivacyClass = PrivacyClass::Derived;
|
||||
const KIND: &'static str = "NetworkKind";
|
||||
}
|
||||
impl NetworkSink for NetworkKind {}
|
||||
|
||||
/// Zero-sized tag: the Matter cluster boundary in `cog-ha-matter`.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct MatterKind;
|
||||
|
||||
impl Sink for MatterKind {
|
||||
const MIN_CLASS: PrivacyClass = PrivacyClass::Anonymous;
|
||||
const KIND: &'static str = "MatterKind";
|
||||
}
|
||||
impl NetworkSink for MatterKind {}
|
||||
impl MatterSink for MatterKind {}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Acceptance test ADR-119 AC1: `BfldFrameHeader` size is platform-stable.
|
||||
//!
|
||||
//! The static assertion in `frame.rs` already enforces this at compile time on
|
||||
//! the local target. This runtime test exists so CI surfaces the failure with
|
||||
//! a useful message rather than a `const_assert_eq!` link error.
|
||||
|
||||
use wifi_densepose_bfld::{BfldFrameHeader, BFLD_HEADER_SIZE, BFLD_MAGIC, BFLD_VERSION};
|
||||
|
||||
#[test]
|
||||
fn header_size_is_86_bytes() {
|
||||
assert_eq!(
|
||||
core::mem::size_of::<BfldFrameHeader>(),
|
||||
BFLD_HEADER_SIZE,
|
||||
"BfldFrameHeader must be exactly {BFLD_HEADER_SIZE} bytes (packed)",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn magic_reads_as_bfld_in_hex() {
|
||||
// 0xBF1D_0001 — "BF1D" looks like "BFLD" in xxd output; final 0001 is the
|
||||
// major version that lives in the dedicated `version` field as well.
|
||||
assert_eq!(BFLD_MAGIC, 0xBF1D_0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_is_one() {
|
||||
assert_eq!(BFLD_VERSION, 1);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Acceptance tests for `BfldFrameHeader` serialization (ADR-119 AC5/AC6).
|
||||
|
||||
use wifi_densepose_bfld::frame::flags;
|
||||
use wifi_densepose_bfld::{BfldError, BfldFrameHeader, BFLD_HEADER_SIZE, BFLD_MAGIC};
|
||||
|
||||
fn sample_header() -> BfldFrameHeader {
|
||||
let mut h = BfldFrameHeader::empty();
|
||||
h.flags = flags::HAS_CSI_DELTA | flags::PRIVACY_MODE;
|
||||
h.timestamp_ns = 0x0123_4567_89AB_CDEF;
|
||||
h.ap_hash = [0xAA; 16];
|
||||
h.sta_hash = [0xBB; 16];
|
||||
h.session_id = [0xCC; 16];
|
||||
h.channel = 36;
|
||||
h.bandwidth_mhz = 80;
|
||||
h.rssi_dbm = -55;
|
||||
h.noise_floor_dbm = -95;
|
||||
h.n_subcarriers = 234;
|
||||
h.n_tx = 3;
|
||||
h.n_rx = 4;
|
||||
h.quantization = 1;
|
||||
h.privacy_class = 2;
|
||||
h.payload_len = 12_345;
|
||||
h.payload_crc32 = 0xDEAD_BEEF;
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_roundtrip_preserves_all_fields() {
|
||||
let original = sample_header();
|
||||
let bytes = original.to_le_bytes();
|
||||
let parsed = BfldFrameHeader::from_le_bytes(&bytes).expect("parse must succeed");
|
||||
|
||||
assert_eq!({ parsed.magic }, BFLD_MAGIC);
|
||||
assert_eq!({ parsed.version }, 1);
|
||||
assert_eq!({ parsed.flags }, flags::HAS_CSI_DELTA | flags::PRIVACY_MODE);
|
||||
assert_eq!({ parsed.timestamp_ns }, 0x0123_4567_89AB_CDEF);
|
||||
assert_eq!(parsed.ap_hash, [0xAA; 16]);
|
||||
assert_eq!(parsed.sta_hash, [0xBB; 16]);
|
||||
assert_eq!(parsed.session_id, [0xCC; 16]);
|
||||
assert_eq!({ parsed.channel }, 36);
|
||||
assert_eq!({ parsed.bandwidth_mhz }, 80);
|
||||
assert_eq!({ parsed.rssi_dbm }, -55);
|
||||
assert_eq!({ parsed.noise_floor_dbm }, -95);
|
||||
assert_eq!({ parsed.n_subcarriers }, 234);
|
||||
assert_eq!(parsed.n_tx, 3);
|
||||
assert_eq!(parsed.n_rx, 4);
|
||||
assert_eq!(parsed.quantization, 1);
|
||||
assert_eq!(parsed.privacy_class, 2);
|
||||
assert_eq!({ parsed.payload_len }, 12_345);
|
||||
assert_eq!({ parsed.payload_crc32 }, 0xDEAD_BEEF);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_serialization_is_deterministic() {
|
||||
let h = sample_header();
|
||||
let a = h.to_le_bytes();
|
||||
let b = h.to_le_bytes();
|
||||
assert_eq!(a, b, "two serializations of the same header must be bit-identical");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_magic_is_at_offset_zero_little_endian() {
|
||||
let bytes = sample_header().to_le_bytes();
|
||||
// BFLD_MAGIC = 0xBF1D_0001 → little-endian: 01 00 1D BF
|
||||
assert_eq!(&bytes[0..4], &[0x01, 0x00, 0x1D, 0xBF]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsing_rejects_invalid_magic() {
|
||||
let mut bytes = sample_header().to_le_bytes();
|
||||
bytes[0] = 0xFF; // clobber magic
|
||||
match BfldFrameHeader::from_le_bytes(&bytes) {
|
||||
Err(BfldError::InvalidMagic(got)) => {
|
||||
assert_ne!(got, BFLD_MAGIC);
|
||||
}
|
||||
other => panic!("expected InvalidMagic, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsing_rejects_unsupported_version() {
|
||||
let mut bytes = sample_header().to_le_bytes();
|
||||
bytes[4] = 99; // version field at offset 4 (LE u16)
|
||||
bytes[5] = 0;
|
||||
match BfldFrameHeader::from_le_bytes(&bytes) {
|
||||
Err(BfldError::UnsupportedVersion(v)) => assert_eq!(v, 99),
|
||||
other => panic!("expected UnsupportedVersion, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_size_is_constant() {
|
||||
assert_eq!(sample_header().to_le_bytes().len(), BFLD_HEADER_SIZE);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Acceptance tests for ADR-120 §2.2 sink marker enforcement (invariant I1).
|
||||
|
||||
use wifi_densepose_bfld::sink::{LocalKind, MatterKind, NetworkKind};
|
||||
use wifi_densepose_bfld::{check_class, BfldError, PrivacyClass};
|
||||
|
||||
// --- PrivacyClass::try_from ----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn privacy_class_try_from_accepts_all_four_valid_bytes() {
|
||||
assert_eq!(PrivacyClass::try_from(0).unwrap(), PrivacyClass::Raw);
|
||||
assert_eq!(PrivacyClass::try_from(1).unwrap(), PrivacyClass::Derived);
|
||||
assert_eq!(PrivacyClass::try_from(2).unwrap(), PrivacyClass::Anonymous);
|
||||
assert_eq!(PrivacyClass::try_from(3).unwrap(), PrivacyClass::Restricted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_class_try_from_rejects_out_of_range_bytes() {
|
||||
for b in [4u8, 5, 7, 17, 42, 100, 200, 255] {
|
||||
match PrivacyClass::try_from(b) {
|
||||
Err(BfldError::InvalidPrivacyClass(got)) => assert_eq!(got, b),
|
||||
other => panic!("expected InvalidPrivacyClass({b}), got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn privacy_class_byte_roundtrip_is_stable() {
|
||||
for c in [
|
||||
PrivacyClass::Raw,
|
||||
PrivacyClass::Derived,
|
||||
PrivacyClass::Anonymous,
|
||||
PrivacyClass::Restricted,
|
||||
] {
|
||||
assert_eq!(PrivacyClass::try_from(c.as_u8()).unwrap(), c);
|
||||
}
|
||||
}
|
||||
|
||||
// --- LocalSink accepts everything ---------------------------------------
|
||||
|
||||
#[test]
|
||||
fn local_sink_accepts_all_classes() {
|
||||
for c in [
|
||||
PrivacyClass::Raw,
|
||||
PrivacyClass::Derived,
|
||||
PrivacyClass::Anonymous,
|
||||
PrivacyClass::Restricted,
|
||||
] {
|
||||
check_class::<LocalKind>(c).expect("LocalSink must accept every class");
|
||||
}
|
||||
}
|
||||
|
||||
// --- NetworkSink rejects Raw, accepts the rest --------------------------
|
||||
|
||||
#[test]
|
||||
fn network_sink_rejects_raw_frames() {
|
||||
let err = check_class::<NetworkKind>(PrivacyClass::Raw).unwrap_err();
|
||||
match err {
|
||||
BfldError::PrivacyViolation { reason } => assert_eq!(reason, "NetworkKind"),
|
||||
other => panic!("expected PrivacyViolation, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_sink_accepts_derived_anonymous_restricted() {
|
||||
for c in [
|
||||
PrivacyClass::Derived,
|
||||
PrivacyClass::Anonymous,
|
||||
PrivacyClass::Restricted,
|
||||
] {
|
||||
check_class::<NetworkKind>(c)
|
||||
.expect("NetworkSink must accept Derived/Anonymous/Restricted");
|
||||
}
|
||||
}
|
||||
|
||||
// --- MatterSink rejects Raw and Derived ---------------------------------
|
||||
|
||||
#[test]
|
||||
fn matter_sink_rejects_raw_and_derived() {
|
||||
for c in [PrivacyClass::Raw, PrivacyClass::Derived] {
|
||||
let err = check_class::<MatterKind>(c).unwrap_err();
|
||||
match err {
|
||||
BfldError::PrivacyViolation { reason } => assert_eq!(reason, "MatterKind"),
|
||||
other => panic!("expected PrivacyViolation for {c:?}, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matter_sink_accepts_anonymous_and_restricted() {
|
||||
for c in [PrivacyClass::Anonymous, PrivacyClass::Restricted] {
|
||||
check_class::<MatterKind>(c).expect("MatterSink must accept anonymous + restricted");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user