ADR-152: WiFi-Pose SOTA 2026 intake — WiFlow-STD benchmark, Rust integrations, ADR-153 802.11bf layer, efficiency frontier (#1008)

* feat(calibration): NodeGeometry transceiver-geometry recording (ADR-152 §2.1.1)

PerceptAlign-motivated geometry capture at enrollment: per-node optional
records (position, antenna orientation, inter-node distances, acquisition
method) — recorded when known, never required. Event-sourced via
EnrollmentEvent::GeometryRecorded (latest recording wins); persisted on
SpecialistBank with serde defaults so pre-ADR-152 bank JSON loads cleanly
(fixture-proven, and geometry-free banks serialize byte-shape-identical
to the old schema); threaded through MultiNodeMixture as data only — the
learned geometry embeddings and algorithmic fusion use are §2.1.2,
deliberately deferred until the ADR-151 P6 LoRA heads exist.

Geometry recorded from now on means banks captured today remain usable
for layout-conditioned training later — you can't retroactively add
geometry to data you didn't record.

8 new tests (3 geometry, 2 anchor, 2 bank, 1 multistatic) + full-loop
extension (2-node geometry, one tape-measured + one unknown, surviving
the bank JSON round-trip the runtime loads from). 50/50 calibration
(both feature configs) + 23 CLI tests green.

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(training): two-checkerboard camera↔room calibration for ADR-079 labels (ADR-152 §2.1.3)

Defends the camera-supervised pipeline against PerceptAlign's
"coordinate overfitting": MediaPipe keypoints were emitted in raw camera
coordinates with no shared frame and no transceiver-geometry metadata —
the exact label shape that memorizes deployment layout and collapses
cross-layout.

- scripts/calibrate-camera-room.py + calibration_lib.py: OpenCV
  two-checkerboard calibration → versioned bundle JSON (intrinsics,
  camera→room extrinsics, checkerboard spec, transceiver geometry,
  sha256 calibration_id). Intrinsics resolve from file > cache >
  multi-view computation > loud-warning 2-view fallback.
- collect-ground-truth.py --calibration <bundle>: every sample gains
  keypoints_room (unit bearing rays from the camera center in the room
  frame — documented projective alignment; raw image coords preserved
  so training chooses), camera_origin_room, calibration_id, and the
  transceiver geometry stamp. Without the flag, output is byte-identical
  to before (tested) + a one-line ADR-152 warning.

Design finding (recorded for ADR-152): a single planar checkerboard's
corner grid is centrosymmetric — the reversed corner ordering fits a
ghost camera pose with IDENTICAL reprojection error, so per-board flip
disambiguation is mathematically ill-posed. solve_two_board_extrinsics
solves the joint wall+floor set over all 4 flip combinations, where the
minimum is unique — an independent reason the TWO-checkerboard method is
required, beyond what PerceptAlign states.

15 headless pytest tests green (synthetic corners: extrinsics recovery
incl. ghost resolution, bundle round-trip + hash stability, ray
transforms w/ distortion + cross-resolution, no-calibration byte
identity).

Co-Authored-By: RuFlo <ruv@ruv.net>

* feat(benchmarks): WiFlow-STD reproduction harness + measurement (a) results (ADR-152 §2.2)

Shipped checkpoint REFUTED (0.08% PCK@20, wrong keypoint normalization);
6 reproducibility defects documented (broken imports, corrupted dataset
tail with float32-max garbage that NaN-poisons fp16 BatchNorm, unreachable
test phase). After repairs, retraining with upstream defaults reproduces
96.09% PCK@20 full-test / 96.61% corruption-free (published 97.25%) on
RTX 5080. Claims graded MEASURED-EQUIVALENT; 2.23M params + ~0.055 GFLOPs
verified. Third-party code/weights/data stay out of tree (gitignored).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat: ADR-152 Rust integrations + ADR-153 802.11bf protocol model

- calibration: GeometryEmbedding — 32-slot permutation-invariant NodeGeometry
  featurization for future LoRA-head conditioning (ADR-152 §2.1.2); derived
  SpecialistBank::geometry_embedding() accessor; 59 tests
- train: MaePretrainConfig + patchify/random-mask with UNSW measured recipe
  (80% masking, (30,3) patches; ADR-152 §2.3, arXiv 2511.18792); strict
  no-truncate/no-NaN policy; proptest properties
- train: WiFlowStdModel — tch-gated port of the verified ~96%-PCK@20
  WiFlow-STD architecture (ADR-152 §2.2 beyond-SOTA); ungated param formula
  pinned to 2,225,042; 15/17-keypoint support; 239 crate tests
- hardware: ieee80211bf forward-compatibility protocol model (ADR-153):
  SpecProfile gates, SensingCapabilities negotiation, required ConsentMode,
  session FSM, SensingTransport + SimTransport + OpportunisticCsiBridge;
  full acceptance checklist covered; 156+4 tests
- deps: ruvector bumps per ADR-152 §2.6 survey (mincut/solver 2.0.6,
  attention 2.1.0, gnn 2.2.0); vendor/ruvector synced to a083bd77f
- docs: ADR-153 accepted; ADR-152 §2.2 status, §2.4 amendment, §2.6 added

Workspace: 162 test suites green (--no-default-features); Python proof PASS.
Known pre-existing flake: homecore-api env_empty_falls_back_to_defaults
(unserialized env-var mutation) — untouched, follow-up.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs: CHANGELOG + CLAUDE.md entries for ADR-152 integrations and ADR-153

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix(train): repair tch-backend bit-rot — gated path compiles and tests run again

Mechanical API refresh against current tch: Vec::from(Tensor) -> try_from
(+ explicit flatten), numel() usize cast, Rem/div ops -> remainder() /
divide_scalar_mode(floor) — the latter fixed a silent true-division bug in
heatmap argmax decoding; clamp(1.0, f64::MAX) -> clamp_min (torch 2.x scalar
overflow panic); petgraph EdgeRef import; missing EvalMetrics and
verify_checkpoint_dir APIs that tests documented. wiflow_std roundtrip test
uses safetensors (.pt _save_parameters roundtrip broken in torch 2.11
Windows). Gated: 349 passed (incl. all 20 wiflow_std); ungated: unchanged.
Known pre-existing: gaussian-heatmap convention mismatch (2 tests), proof
seed race under parallel threads — documented, deliberate follow-ups.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(train): WiFlow-STD PyTorch->tch weight import + numerical parity proof

export_to_safetensors.py maps the retrained checkpoint (295 tensors -> 248
mapped, param sum exactly 2,225,042; num_batches_tracked dropped) into a
tch-loadable safetensors plus a deterministic parity fixture. Gated #[ignore]
integration test loads it strictly and asserts forward-pass agreement:
max abs diff 1.192e-7 on the seed-42 fixture. dump_variable_names test makes
the tch name layout authoritative. Zero architecture discrepancies found.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix: workflow-review findings — BN gamma init, ThresholdParams serde, init docs

Concurrent validation workflow (2 review lanes + adversarial verification,
13 agents): 5 confirmed findings, 3 refuted. Fixes:
- wiflow_std: pin BatchNorm gamma to 1.0 (tch default draws Uniform(0,1) —
  silently halves activations in from-scratch training; loaded checkpoints
  unaffected, parity re-verified after the change)
- wiflow_std: document the conv-init divergences vs the reference's
  effective kaiming_normal(fan_out) re-init (from-scratch dynamics only)
- ieee80211bf: ThresholdParams deserialization validates via try_from so
  the <=100 invariant holds for untrusted payloads (+ rejection test)

Benchmarks (release, ruvzen): GeometryEmbedding 1.84us/call (542k/s),
MAE tokenization 7.38us/window (135k/s), 802.11bf FSM 8.9M events/s —
nothing suspicious.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(adr): ADR-152 §2.1.4 gate resolved — PerceptAlign repo MIT, dataset on HF

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(benchmarks): edge optimization measured + measurement (b) blocked + 92.9% retraction

Edge optimization (ADR-152 optimize track): ONNX Runtime fp32 is the CPU
latency win (3.2 ms/window, ~3.4x faster than torch, parity 2.4e-7); ORT
dynamic int8 reaches 2.44 MB (paper's ~2.2 MB claim plausible only via
conv-capable toolchains; -0.16pt PCK@20, +18% MPJPE, 2x slower); torch
dynamic quant converts 0% of this conv-only model; fp16 halves storage free
but is slower on CPU.

Measurement (b) BLOCKED-ON-DATA: only 1,077 paired ESP32 windows exist
(stop rule <2k). Forensic recheck of the surviving April holdout RETRACTS
the ADR-079 '92.9% PCK@20' figure: constant-output model, absolute (not
torso) threshold, 69 near-static frames — mean predictor scores 100% under
that protocol; torso-PCK@20 is 19.1%. Corroborates PR #535. Stale citations
removed from user-guide, readme-details, ADR-152 §2.1.3; no-citation rule
extended to ADR-079 accuracy claims. Unblock: >=2k-window multi-pose paired
session + torso-PCK re-baseline.

Co-Authored-By: claude-flow <ruv@ruv.net>

* docs(user-guide): corrected camera-supervised collection tutorial

Step 0 CSI-rate check + session-length math (window yield = frames/20 —
the May session's 8x under-delivery was a ~12 Hz CSI rate, not an aligner
bug); two-checkerboard calibration step (ADR-152 §2.1.3); pose-variety and
confidence guidance; torso-normalized PCK + temporal-split + pred-variance
eval protocol (lessons from the 92.9% retraction); scale presets re-keyed
to realistic window counts.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(benchmarks): static PTQ int8 (calibrated) results + overnight capture script

Conv-only static QDQ beats dynamic int8 on accuracy (PCK@20 96.61-96.63%
vs 96.52%, MPJPE +10% vs +18% over fp32) at ~equal size/latency; all-ops
QDQ strictly worse (int8 activations through attention glue). Entropy
calibration verified bit-identical to MinMax on this data. Deployment:
ONNX fp32 for speed (3.2ms), static conv-only QDQ for smallest (2.53MB).

Also: scripts/overnight-empty-capture.py — segmented UDP CSI recorder for
empty-room baselines (no glob collisions, detach-safe).

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(benchmarks): measurement (b) MEASURED — optimization transfer only, mean-pose baseline wins

WiFlow-STD fine-tuned on 2,046 fresh single-room ESP32 paired windows
(temporal 70/15/15, 70->540 adapter, K=17): pretrained-init 65% PCK@20 vs
scratch 0% (optimization transfer) but frozen-trunk ~0% (no feature
transfer), and NOTHING beats the mean-pose baseline (95.9% PCK@20 —
single subject, near-static normalized coords). Honesty gates held: pred
std 0.0113 (non-constant model) but mean-baseline dominance means no
citable CSI->pose capability from this data. ADR-152 open question 1
answered partially; definitive answer needs multi-subject/position data.

Two new aligner findings: heterogeneous csi_shape with silent zero-padding
(~20%), and extractCsiMatrix's transposed shape label (frame-major data,
[nSc, nFrames] label) — fixes pending.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(benchmarks): efficiency sweep MEASURED — half model dominates full reference

Compact WiFlow-STD variants on the same data/split/protocol: half (843,834
params, 0.38x) strictly dominates the 2.23M reference (PCK@20 96.62 vs
96.61, PCK@50 99.47 vs 99.11, MPJPE 0.00898 vs 0.0094) — the published
architecture is over-parameterized for its own benchmark. quarter (338k)
96.05%; tiny (56,290 params, 1/39.5) holds 94.11% — a ~220KB fp32 edge
candidate. In-domain caveats recorded; cross-domain untested.

Co-Authored-By: claude-flow <ruv@ruv.net>

* feat(train): compact WiFlow-STD presets in Rust + tiny edge artifact (ADR-152)

WiFlowStdConfig gains half()/quarter()/tiny() mirroring the overnight sweep
exactly: TcnGroupsMode (Fixed/Gcd/Depthwise), input_pw_groups, derived
stride schedule and decoder-mid (all default to upstream behavior; legacy
serde JSON unaffected). Param formulas pin to trained ground truth first
try: 843,834 / 338,600 / 56,290; default 2,225,042 pin and 1.192e-7 parity
unchanged. 248 tests green.

Tiny edge artifact (tiny_edge_bench.py): ONNX fp32 = 295 KB, 0.66 ms/win
(~1,500/s CPU), 94.11% PCK@20 (matches sweep clean-test exactly; parity
1.49e-7). Static int8 is a bad trade at this scale (-1.43pt, +19% MPJPE,
-16% size, slower) — recorded as negative result. Export note: width-16
breaks AdaptiveAvgPool((15,1)) TorchScript export; replaced by exact
mean+matmul equivalent, proven by parity.

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix: resolve all 10 confirmed code-review findings (7-angle review, 20/20 verified)

wiflow_std: min_feature_width (default 15) replaces the keypoints->stride
coupling — for_keypoints(17) now provably builds the trained [2,2,2,2]
graph and pools 15->17, matching the validated Python protocol (pinned by
tests); param_count() total on invalid configs; random_mask returns Result
and rejects non-finite/out-of-range ratios; trainer checkpoints switched
to safetensors (.pt VarStore roundtrip broken on Windows torch 2.11).

ieee80211bf: SBP proxy now re-triggers instances and relays reports via
Action::RelaySbpReport -> SensingFrame::SbpReport (clients consume via
their existing path); missed_instances reset on success = consecutive
semantics; SessionTable gains a guarded SBP entry point + unknown-id drop
counter; initiator-role sessions reject inbound setup/SBP requests
(RejectedNotSupported) closing the idle hijack; StartSetup/StartSbp
outside Idle return InvalidStateForCommand; SBP validation unified
through evaluate_setup with a 1:1 SetupStatus->SbpStatus mapping.
events.rs split out to honor the 500-line cap.

calibration/cli: enrollment geometry now actually reaches trained banks —
both production call sites attach .with_geometry; --geometry flag on
train-room and POST /enroll/geometry + train-body geometry on
calibrate-serve give production a recording surface; geometry-free banks
log the ADR-152 §2.1.2 note.

benchmarks: corruption masks committed as ground truth (unregenerable
after in-place cleaning; verified bit-identical regeneration from the
pristine copy) + generate_corruption_masks.py producer; _bench_common.py
dedups the 5x-copied shim/evaluate/seed/remap (post-refactor PCK@20
re-verified equal to the last digit); remote scripts get the mmap patch;
tiny_edge --calib validated multiple-of-64; onnx_bench --help no longer
executes (and overwrote) the export — artifact restored byte-exact.

Workspace: 2,963 tests passed, 0 failed; Python proof PASS.

Co-Authored-By: claude-flow <ruv@ruv.net>

* ci: build workspace tests without debuginfo — runner disk exhaustion

The combined 38-crate debug target exceeds the GitHub runner's disk
('final link failed: No space left on device'); the same tree measured
151GB locally with full debuginfo. CARGO_PROFILE_{DEV,TEST}_DEBUG=0
shrinks the target ~5-10x; debuginfo serves no purpose in CI test runs.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
rUv
2026-06-11 17:02:23 -04:00
committed by GitHub
parent 29de574e63
commit 17471e93ff
79 changed files with 14132 additions and 117 deletions
@@ -0,0 +1,108 @@
//! Session FSM I/O types for the 802.11bf sensing model: events in
//! ([`SessionEvent`]), actions out ([`Action`]), close reasons, static
//! configuration, and the state enum.
//!
//! Split from [`super::session`] to keep each file under the ADR-153
//! 500-line maintainability cap; the canonical public path re-exports
//! these from [`super::session`].
use super::messages::{
CsiReportPayload, SbpRequest, SbpResponse, SbpStatus, SensingMeasurementInstance,
SensingMeasurementReport, SensingMeasurementSetupRequest, SensingMeasurementSetupResponse,
SensingSessionTermination, TerminationReason,
};
use super::types::{MeasurementInstanceId, SensingCapabilities, SetupStatus, SpecProfile};
/// Session FSM states.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionState {
Idle,
SetupNegotiating,
Active,
Terminating,
}
/// Inputs to the session FSM. `Start*` are local commands; `*Received` are
/// frames from the peer; `Timeout`/`InstanceElapsed` are scheduler ticks.
#[derive(Debug, Clone, PartialEq)]
pub enum SessionEvent {
/// Local command (initiator): begin setup negotiation.
StartSetup(SensingMeasurementSetupRequest),
/// Local command (initiator): request sensing-by-proxy from an AP.
StartSbp(SbpRequest),
SetupRequestReceived(SensingMeasurementSetupRequest),
SetupResponseReceived(SensingMeasurementSetupResponse),
SbpRequestReceived(SbpRequest),
SbpResponseReceived(SbpResponse),
/// Scheduler tick: the negotiated periodicity elapsed (the
/// measurement-driving endpoint — initiator or SBP proxy — emits the
/// next measurement-instance trigger).
InstanceElapsed,
/// A sensing receiver captured a measurement for an instance (payload is
/// fed by the transport/bridge — see `OpportunisticCsiBridge`).
MeasurementCaptured {
instance_id: MeasurementInstanceId,
payload: CsiReportPayload,
},
ReportReceived(SensingMeasurementReport),
/// Generic timeout tick for the current state.
Timeout,
/// Local command: terminate the session.
Terminate(TerminationReason),
TerminationReceived(SensingSessionTermination),
}
/// Outputs of the session FSM. `Send*`/`TriggerInstance`/`RelaySbpReport`
/// go to the transport; `DeliverReport`/`SessionClosed` go to the local
/// consumer.
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
SendSetupRequest(SensingMeasurementSetupRequest),
SendSetupResponse(SensingMeasurementSetupResponse),
SendSbpRequest(SbpRequest),
SendSbpResponse(SbpResponse),
TriggerInstance(SensingMeasurementInstance),
SendReport(SensingMeasurementReport),
DeliverReport(SensingMeasurementReport),
/// SBP proxy mode: forward a report received from the sensing responder
/// to the SBP client. The transport maps this to a frame toward the
/// client (`SensingFrame::SbpReport`), distinct from `SendReport`,
/// which travels toward the sensing initiator.
RelaySbpReport(SensingMeasurementReport),
SendTermination(SensingSessionTermination),
SessionClosed(CloseReason),
}
/// Why a session returned to Idle.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum CloseReason {
SetupRejected(SetupStatus),
SbpRejected(SbpStatus),
Terminated(TerminationReason),
/// Terminating-state quiescence completed (no peer echo required).
Completed,
}
/// Static configuration for a sensing session.
#[derive(Debug, Clone, PartialEq)]
pub struct SessionConfig {
/// Spec profile this endpoint advertises/accepts.
pub profile: SpecProfile,
/// Capability set used to evaluate inbound setups.
pub capabilities: SensingCapabilities,
/// Consecutive negotiation timeouts before aborting to Idle.
pub max_setup_timeouts: u8,
/// Consecutive missed instances (Active timeouts) before terminating.
pub max_missed_instances: u8,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
profile: SpecProfile::Ieee80211Bf2025,
capabilities: SensingCapabilities::sim_full(),
max_setup_timeouts: 3,
max_missed_instances: 5,
}
}
}
@@ -0,0 +1,200 @@
//! Procedure message types for the 802.11bf sensing model: measurement
//! setup request/response, measurement instance, CSI-variant measurement
//! report, sensing-by-proxy (SBP) exchange, session termination, and the
//! minimal DMG (>45 GHz) stubs. Negotiation-core types (identifiers,
//! parameters, capabilities, statuses) live in [`super::types`].
use serde::{Deserialize, Serialize};
use super::types::{
BfError, MeasurementInstanceId, MeasurementSetupId, MeasurementSetupParams, SetupStatus,
SpecProfile, MAX_REPORT_SUBCARRIERS,
};
/// Sensing measurement setup request (initiator → responder).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SensingMeasurementSetupRequest {
/// Version gate for the negotiated surface.
pub profile: SpecProfile,
pub setup_id: MeasurementSetupId,
pub params: MeasurementSetupParams,
}
impl SensingMeasurementSetupRequest {
pub fn validate(&self) -> Result<(), BfError> {
self.params.validate()
}
}
/// Sensing measurement setup response (responder → initiator).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SensingMeasurementSetupResponse {
pub setup_id: MeasurementSetupId,
pub status: SetupStatus,
}
/// One scheduled sensing measurement instance within an active setup.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SensingMeasurementInstance {
pub setup_id: MeasurementSetupId,
pub instance_id: MeasurementInstanceId,
/// Deterministic schedule offset of this instance (µs since setup
/// activation; synthesized from the negotiated periodicity).
pub timestamp_us: u64,
}
/// CSI-variant sensing measurement report payload (amplitude/phase per
/// usable subcarrier, averaged over the measurement instance).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CsiReportPayload {
pub n_subcarriers: u16,
pub amplitudes: Vec<f32>,
pub phases: Vec<f32>,
}
impl CsiReportPayload {
/// Boundary validation: shape coherence and value sanity. Rejects NaN,
/// infinities, and negative amplitudes from adversarial peers.
pub fn validate(&self) -> Result<(), BfError> {
if self.n_subcarriers == 0 {
return Err(BfError::EmptyPayload);
}
if self.n_subcarriers > MAX_REPORT_SUBCARRIERS {
return Err(BfError::PayloadTooLarge {
count: self.n_subcarriers,
});
}
let declared = self.n_subcarriers as usize;
if self.amplitudes.len() != declared || self.phases.len() != declared {
return Err(BfError::PayloadLengthMismatch {
declared,
amplitudes: self.amplitudes.len(),
phases: self.phases.len(),
});
}
for (index, a) in self.amplitudes.iter().enumerate() {
if !a.is_finite() || *a < 0.0 {
return Err(BfError::PayloadValueInvalid { index });
}
}
for (index, p) in self.phases.iter().enumerate() {
if !p.is_finite() {
return Err(BfError::PayloadValueInvalid { index });
}
}
Ok(())
}
/// Mean amplitude across subcarriers (threshold-trigger metric).
pub fn mean_amplitude(&self) -> f64 {
if self.amplitudes.is_empty() {
return 0.0;
}
self.amplitudes.iter().map(|a| *a as f64).sum::<f64>() / self.amplitudes.len() as f64
}
}
/// Sensing measurement report (sensing receiver → initiator).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SensingMeasurementReport {
pub setup_id: MeasurementSetupId,
pub instance_id: MeasurementInstanceId,
pub payload: CsiReportPayload,
}
impl SensingMeasurementReport {
pub fn validate(&self) -> Result<(), BfError> {
self.payload.validate()
}
}
/// Sensing-by-Proxy (SBP) request: a non-AP STA asks an AP to act as sensing
/// initiator on its behalf and forward the resulting reports.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SbpRequest {
pub profile: SpecProfile,
/// Setup ID the proxy uses for the sensing it conducts on our behalf.
pub proxy_setup_id: MeasurementSetupId,
pub params: MeasurementSetupParams,
}
impl SbpRequest {
pub fn validate(&self) -> Result<(), BfError> {
self.params.validate()
}
}
/// Status carried by an SBP response.
///
/// Mirrors [`SetupStatus`] 1:1 (see the `From<SetupStatus>` impl): an SBP
/// request is validated through the same chain as a direct setup, so every
/// rejection class must survive the proxy translation.
/// `RejectedNotSupported` additionally covers a proxy that lacks the SBP
/// capability itself.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SbpStatus {
Accepted,
RejectedNotSupported,
RejectedUnsupportedParams,
RejectedSetupIdCollision,
RejectedIncompatibleProfile,
RejectedByPolicy,
RejectedCapacity,
}
impl From<SetupStatus> for SbpStatus {
/// 1:1 mapping from the direct-setup status space, keeping the SBP path
/// on the single `evaluate_setup` validation chain (no SBP-only policy
/// drift or bypass).
fn from(status: SetupStatus) -> Self {
match status {
SetupStatus::Accepted => SbpStatus::Accepted,
SetupStatus::RejectedNotSupported => SbpStatus::RejectedNotSupported,
SetupStatus::RejectedUnsupportedParams => SbpStatus::RejectedUnsupportedParams,
SetupStatus::RejectedSetupIdCollision => SbpStatus::RejectedSetupIdCollision,
SetupStatus::RejectedIncompatibleProfile => SbpStatus::RejectedIncompatibleProfile,
SetupStatus::RejectedByPolicy => SbpStatus::RejectedByPolicy,
SetupStatus::RejectedCapacity => SbpStatus::RejectedCapacity,
}
}
}
/// Sensing-by-Proxy (SBP) response (proxy AP → requesting STA).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SbpResponse {
pub proxy_setup_id: MeasurementSetupId,
pub status: SbpStatus,
}
/// Reason carried by a sensing session termination.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TerminationReason {
InitiatorRequested,
ResponderRequested,
Timeout,
PolicyChange,
}
/// Sensing measurement setup termination (either side may send).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct SensingSessionTermination {
pub setup_id: MeasurementSetupId,
pub reason: TerminationReason,
}
/// Minimal stub for DMG/EDMG (>45 GHz) sensing types. The standard also
/// covers directional multi-gigabit sensing; this model does not elaborate
/// it beyond a typed placeholder (ADR-153 scope: sub-7 GHz focus).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DmgSensingType {
Monostatic,
Bistatic,
Multistatic,
}
/// Placeholder for a future DMG sensing setup surface.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct DmgSensingSetupStub {
pub setup_id: MeasurementSetupId,
pub sensing_type: DmgSensingType,
}
@@ -0,0 +1,78 @@
//! IEEE 802.11bf-2025 WLAN sensing — forward-compatibility protocol model
//! (ADR-153, amending ADR-152 §2.4).
//!
//! # Why this exists
//!
//! IEEE 802.11bf-2025 ("WLAN Sensing") was **published 2025-09-26** (verified
//! against the IEEE SA record — ADR-152 §1.1 F4, evidence grade MEASURED).
//! Sensing standardization is complete for sub-7 GHz and >45 GHz (DMG) bands,
//! with formal sensing measurement setup, measurement instance,
//! feedback/reporting, and sensing-by-proxy (SBP) procedures.
//!
//! **No commodity silicon — ESP32 parts included — implements the standard
//! yet.** ADR-152 §2.4 originally decided "track silicon; no code now";
//! ADR-153 amends that clause: build the typed protocol surface now, so
//! RuView can adopt standardized sensing the day any chipset exposes it.
//! This layer is simulation-tested forward compatibility — the OTA binding
//! lands when silicon does. Today's opportunistic CSI extraction (ADR-018 /
//! ADR-028) remains the backend, mapped onto the standardized report path by
//! [`transport::OpportunisticCsiBridge`].
//!
//! > This module is not a certified 802.11bf implementation. It models the
//! > public procedure shape needed by RuView and RuvSense, while intentionally
//! > avoiding OTA frame binding until chipset support and vendor APIs exist.
//!
//! # Layout
//!
//! - [`types`] — typed structures for the sensing procedures (setup, roles,
//! measurement instances, CSI-variant reports, SBP, termination), plus the
//! ADR-153 future-proofing surfaces: [`types::SpecProfile`] version gates,
//! [`types::SensingCapabilities`] negotiation, and required
//! [`types::ConsentMode`] governance metadata on every setup.
//! - [`messages`] — the procedure message types (setup request/response,
//! measurement instance, CSI-variant report, SBP exchange, termination).
//! - [`session`] — deterministic event-driven session FSM:
//! `Idle → SetupNegotiating → Active → Terminating → Idle`, with explicit
//! rejection paths, timeout handling, single-role enforcement, and the
//! first-class SBP proxy mode. No async, no clocks.
//! - [`events`] — the FSM I/O types ([`events::SessionEvent`],
//! [`events::Action`], close reasons, configuration), re-exported via
//! [`session`].
//! - [`table`] — responder-side setup registry (setup-ID collision and
//! capacity rejection paths, for direct setups and SBP alike).
//! - [`transport`] — the [`transport::SensingTransport`] seam, the
//! [`transport::SimTransport`] test double, and the ESP32 bridge.
pub mod events;
pub mod messages;
pub mod session;
pub mod table;
pub mod transport;
pub mod types;
pub use messages::{
CsiReportPayload, DmgSensingSetupStub, DmgSensingType, SbpRequest, SbpResponse, SbpStatus,
SensingMeasurementInstance, SensingMeasurementReport, SensingMeasurementSetupRequest,
SensingMeasurementSetupResponse, SensingSessionTermination, TerminationReason,
};
pub use session::{Action, CloseReason, SensingSession, SessionConfig, SessionEvent, SessionState};
pub use table::SessionTable;
pub use transport::{
action_to_frame, frame_to_event, OpportunisticCsiBridge, SensingFrame, SensingTransport,
SimTransport, TransportError,
};
pub use types::{
bandwidth_mhz, BfError, ConsentMode, MeasurementInstanceId, MeasurementSetupId,
MeasurementSetupParams, ReportingConfig, SensingCapabilities, SensingRole, SetupStatus,
SpecProfile, ThresholdParams, TransceiverRole, MAX_BURST_INSTANCES, MAX_PERIOD_MS,
MAX_REPORT_SUBCARRIERS, MAX_SETUP_ID, MIN_PERIOD_MS,
};
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_fsm;
#[cfg(test)]
mod tests_sbp;
#[cfg(test)]
mod testutil;
@@ -0,0 +1,499 @@
//! Sensing session state machine for the 802.11bf forward-compatibility model.
//!
//! Deterministic, event-driven, no async, no clocks: callers inject
//! [`SessionEvent`]s (including `Timeout` ticks) and act on the returned
//! [`Action`]s. State flow (ADR-153):
//!
//! ```text
//! Idle → SetupNegotiating → Active → Terminating → Idle
//! ```
//!
//! Rejection paths: unsupported parameters / incompatible profile / policy
//! (responder responds with a rejected setup status), setup-ID collision
//! ([`super::table::SessionTable`]), and negotiation timeout (typed
//! [`BfError::NegotiationTimeout`] + reset to Idle).
//!
//! **Single-role design:** a session is constructed as initiator or responder
//! and keeps that role for its whole lifetime. An initiator-role session
//! receiving a peer's setup or SBP request answers `RejectedNotSupported`
//! instead of accepting — a peer must never be able to hijack a session out
//! of its configured role. Endpoints that play both roles run one session per
//! role (or a [`super::table::SessionTable`] for the responder side).
//!
//! **SBP proxy mode:** a responder session that accepts an SBP request
//! becomes a first-class proxy ([`SensingSession::is_sbp_proxy`]): it drives
//! the standard initiator path toward the actual sensing responder —
//! including re-triggering measurement instances on
//! [`SessionEvent::InstanceElapsed`] — and relays every received report to
//! the SBP client via [`Action::RelaySbpReport`], in addition to local
//! [`Action::DeliverReport`] delivery.
//!
//! Local `Start*` commands issued outside Idle are caller bugs and surface
//! as typed [`BfError::InvalidStateForCommand`]; genuinely ignorable stray
//! frames/ticks remain silent no-ops. The FSM I/O types live in
//! [`super::events`] and are re-exported here.
use super::messages::{
SbpRequest, SbpResponse, SbpStatus, SensingMeasurementInstance, SensingMeasurementReport,
SensingMeasurementSetupRequest, SensingMeasurementSetupResponse, SensingSessionTermination,
TerminationReason,
};
use super::types::{
BfError, MeasurementInstanceId, MeasurementSetupId, MeasurementSetupParams, ReportingConfig,
SensingRole, SetupStatus,
};
pub use super::events::{Action, CloseReason, SessionConfig, SessionEvent, SessionState};
/// One sensing session (one measurement setup) on one endpoint.
#[derive(Debug, Clone)]
pub struct SensingSession {
role: SensingRole,
state: SessionState,
config: SessionConfig,
/// Last setup request we sent (for negotiation re-sends).
pending_request: Option<SensingMeasurementSetupRequest>,
/// Negotiated (or in-negotiation) setup.
setup: Option<(MeasurementSetupId, MeasurementSetupParams)>,
/// True when this session awaits proxied sensing (SBP client).
sbp_client: bool,
/// True when this responder-role session proxies sensing for an SBP
/// client: it drives the initiator path toward the sensing responder
/// and relays received reports back to the client.
sbp_proxy: bool,
setup_timeouts: u8,
missed_instances: u8,
instance_counter: u32,
/// Mean amplitude of the last *reported* measurement (threshold trigger).
last_reported_mean: Option<f64>,
}
impl SensingSession {
pub fn new_initiator(config: SessionConfig) -> Self {
Self::new(SensingRole::Initiator, config)
}
pub fn new_responder(config: SessionConfig) -> Self {
Self::new(SensingRole::Responder, config)
}
fn new(role: SensingRole, config: SessionConfig) -> Self {
Self {
role,
state: SessionState::Idle,
config,
pending_request: None,
setup: None,
sbp_client: false,
sbp_proxy: false,
setup_timeouts: 0,
missed_instances: 0,
instance_counter: 0,
last_reported_mean: None,
}
}
pub fn state(&self) -> SessionState {
self.state
}
pub fn role(&self) -> SensingRole {
self.role
}
/// True when this session is acting as an SBP proxy (accepted via
/// [`SessionEvent::SbpRequestReceived`]); cleared on reset to Idle.
pub fn is_sbp_proxy(&self) -> bool {
self.sbp_proxy
}
pub fn setup_id(&self) -> Option<MeasurementSetupId> {
self.setup.as_ref().map(|(id, _)| *id)
}
/// Drive the FSM with one event. Protocol-level rejections surface as
/// `Ok` actions (responses to the peer); malformed/adversarial input,
/// out-of-state local commands, and negotiation timeout surface as typed
/// `Err` (never a panic).
pub fn handle(&mut self, event: SessionEvent) -> Result<Vec<Action>, BfError> {
match self.state {
SessionState::Idle => self.handle_idle(event),
SessionState::SetupNegotiating => self.handle_negotiating(event),
SessionState::Active => self.handle_active(event),
SessionState::Terminating => self.handle_terminating(event),
}
}
fn handle_idle(&mut self, event: SessionEvent) -> Result<Vec<Action>, BfError> {
match event {
SessionEvent::StartSetup(req) => {
if self.role != SensingRole::Initiator {
return Err(BfError::InvalidStateForCommand {
state: "Idle (responder cannot StartSetup)",
});
}
req.validate()?;
self.setup = Some((req.setup_id, req.params.clone()));
self.pending_request = Some(req.clone());
self.setup_timeouts = 0;
self.state = SessionState::SetupNegotiating;
Ok(vec![Action::SendSetupRequest(req)])
}
SessionEvent::StartSbp(sbp) => {
if self.role != SensingRole::Initiator {
return Err(BfError::InvalidStateForCommand {
state: "Idle (responder cannot StartSbp)",
});
}
sbp.validate()?;
self.setup = Some((sbp.proxy_setup_id, sbp.params.clone()));
self.sbp_client = true;
self.setup_timeouts = 0;
self.state = SessionState::SetupNegotiating;
Ok(vec![Action::SendSbpRequest(sbp)])
}
SessionEvent::SetupRequestReceived(req) => {
let response = |status| {
Action::SendSetupResponse(SensingMeasurementSetupResponse {
setup_id: req.setup_id,
status,
})
};
// Single-role design (module docs): an initiator-role
// session never accepts a peer's setup request — accepting
// here would let a peer hijack the session into the
// responder path.
if self.role != SensingRole::Responder {
return Ok(vec![response(SetupStatus::RejectedNotSupported)]);
}
match self.evaluate_setup(&req) {
SetupStatus::Accepted => {
self.setup = Some((req.setup_id, req.params.clone()));
self.missed_instances = 0;
self.last_reported_mean = None;
self.state = SessionState::Active;
Ok(vec![response(SetupStatus::Accepted)])
}
status => Ok(vec![response(status)]),
}
}
SessionEvent::SbpRequestReceived(sbp) => {
// Single-role design: only responder-role sessions proxy.
if self.role != SensingRole::Responder {
return Ok(vec![Action::SendSbpResponse(SbpResponse {
proxy_setup_id: sbp.proxy_setup_id,
status: SbpStatus::RejectedNotSupported,
})]);
}
Ok(self.handle_sbp_request(sbp))
}
// Stray frames/ticks in Idle are ignored, not errors.
_ => Ok(vec![]),
}
}
/// SBP proxy path: accept the request, then run the *standard initiator
/// path* toward the actual sensing responder. No direct sensor coupling —
/// the proxied setup is an ordinary `SendSetupRequest` on the transport.
///
/// Validation is the single [`Self::evaluate_setup`] chain: the proxied
/// setup request is built first and evaluated exactly as a direct setup
/// would be, with the resulting [`SetupStatus`] mapped 1:1 onto
/// [`SbpStatus`] — no SBP-only re-implementation that could drift from
/// (or bypass) the setup policy.
fn handle_sbp_request(&mut self, sbp: SbpRequest) -> Vec<Action> {
let respond = |status| {
Action::SendSbpResponse(SbpResponse {
proxy_setup_id: sbp.proxy_setup_id,
status,
})
};
// SBP-specific capability gate; everything else is the setup chain.
if !self.config.capabilities.sensing_by_proxy {
return vec![respond(SbpStatus::RejectedNotSupported)];
}
let req = SensingMeasurementSetupRequest {
profile: sbp.profile.clone(),
setup_id: sbp.proxy_setup_id,
params: sbp.params.clone(),
};
match self.evaluate_setup(&req) {
SetupStatus::Accepted => {}
status => return vec![respond(SbpStatus::from(status))],
}
self.setup = Some((req.setup_id, req.params.clone()));
self.pending_request = Some(req.clone());
self.sbp_proxy = true;
self.setup_timeouts = 0;
self.state = SessionState::SetupNegotiating;
vec![respond(SbpStatus::Accepted), Action::SendSetupRequest(req)]
}
fn evaluate_setup(&self, req: &SensingMeasurementSetupRequest) -> SetupStatus {
if !self.config.profile.accepts(&req.profile) {
return SetupStatus::RejectedIncompatibleProfile;
}
match req.validate() {
Err(BfError::SensingDisabledByPolicy) => return SetupStatus::RejectedByPolicy,
Err(_) => return SetupStatus::RejectedUnsupportedParams,
Ok(()) => {}
}
match self.config.capabilities.evaluate(&req.params) {
Err(status) => status,
Ok(()) => SetupStatus::Accepted,
}
}
fn handle_negotiating(&mut self, event: SessionEvent) -> Result<Vec<Action>, BfError> {
match event {
SessionEvent::SetupResponseReceived(resp) => {
let expected = match self.setup_id() {
Some(id) => id,
None => return Ok(vec![]),
};
if resp.setup_id != expected {
return Err(BfError::SetupIdMismatch {
expected: expected.value(),
got: resp.setup_id.value(),
});
}
match resp.status {
SetupStatus::Accepted => {
self.setup_timeouts = 0;
self.missed_instances = 0;
self.state = SessionState::Active;
match self.next_instance_record() {
Some(instance) => Ok(vec![Action::TriggerInstance(instance)]),
None => Ok(vec![]),
}
}
status => {
self.reset();
Ok(vec![Action::SessionClosed(CloseReason::SetupRejected(
status,
))])
}
}
}
SessionEvent::SbpResponseReceived(resp) if self.sbp_client => {
let expected = match self.setup_id() {
Some(id) => id,
None => return Ok(vec![]),
};
if resp.proxy_setup_id != expected {
return Err(BfError::SetupIdMismatch {
expected: expected.value(),
got: resp.proxy_setup_id.value(),
});
}
match resp.status {
SbpStatus::Accepted => {
// Proxied reports will arrive via ReportReceived.
self.setup_timeouts = 0;
self.state = SessionState::Active;
Ok(vec![])
}
status => {
self.reset();
Ok(vec![Action::SessionClosed(CloseReason::SbpRejected(
status,
))])
}
}
}
SessionEvent::Timeout => {
self.setup_timeouts = self.setup_timeouts.saturating_add(1);
if self.setup_timeouts >= self.config.max_setup_timeouts {
let setup_id = self.setup_id().map(|id| id.value()).unwrap_or(0);
let attempts = self.setup_timeouts;
self.reset();
Err(BfError::NegotiationTimeout { setup_id, attempts })
} else if let Some(req) = &self.pending_request {
Ok(vec![Action::SendSetupRequest(req.clone())])
} else {
Ok(vec![])
}
}
SessionEvent::Terminate(reason) => {
self.reset();
Ok(vec![Action::SessionClosed(CloseReason::Terminated(reason))])
}
SessionEvent::TerminationReceived(term) => {
self.reset();
Ok(vec![Action::SessionClosed(CloseReason::Terminated(
term.reason,
))])
}
// Local Start* outside Idle is a caller bug — typed error.
SessionEvent::StartSetup(_) | SessionEvent::StartSbp(_) => {
Err(BfError::InvalidStateForCommand {
state: "SetupNegotiating",
})
}
// Genuinely ignorable stray frames/ticks are no-ops.
_ => Ok(vec![]),
}
}
fn handle_active(&mut self, event: SessionEvent) -> Result<Vec<Action>, BfError> {
match event {
SessionEvent::InstanceElapsed => {
// The measurement-driving endpoint re-triggers here: the
// initiator, or an SBP proxy running the initiator path
// toward the sensing responder. SBP *clients* only consume
// proxied reports and never trigger instances.
let drives_instances =
(self.role == SensingRole::Initiator || self.sbp_proxy) && !self.sbp_client;
if drives_instances {
match self.next_instance_record() {
Some(instance) => Ok(vec![Action::TriggerInstance(instance)]),
None => Ok(vec![]),
}
} else {
Ok(vec![])
}
}
SessionEvent::MeasurementCaptured {
instance_id,
payload,
} => {
payload.validate()?;
let (setup_id, params) = match &self.setup {
Some((id, p)) => (*id, p.clone()),
None => return Ok(vec![]),
};
// A successful capture means this instance was not missed —
// the missed-instance budget counts *consecutive* misses,
// so it resets here even when threshold-based reporting
// suppresses the report below.
self.missed_instances = 0;
let mean = payload.mean_amplitude();
let should_report = match params.reporting {
ReportingConfig::EveryInstance => true,
ReportingConfig::ThresholdBased(threshold) => match self.last_reported_mean {
None => true,
Some(previous) => threshold.exceeds(previous, mean),
},
};
if !should_report {
return Ok(vec![]);
}
self.last_reported_mean = Some(mean);
Ok(vec![Action::SendReport(SensingMeasurementReport {
setup_id,
instance_id,
payload,
})])
}
SessionEvent::ReportReceived(report) => {
report.validate()?;
let expected = match self.setup_id() {
Some(id) => id,
None => return Ok(vec![]),
};
if report.setup_id != expected {
return Err(BfError::SetupIdMismatch {
expected: expected.value(),
got: report.setup_id.value(),
});
}
self.missed_instances = 0;
if self.sbp_proxy {
// Proxy mode: deliver to the local consumer *and* relay
// toward the SBP client on the transport.
Ok(vec![
Action::DeliverReport(report.clone()),
Action::RelaySbpReport(report),
])
} else {
Ok(vec![Action::DeliverReport(report)])
}
}
SessionEvent::Timeout => {
self.missed_instances = self.missed_instances.saturating_add(1);
if self.missed_instances >= self.config.max_missed_instances {
self.state = SessionState::Terminating;
Ok(self.termination_actions(TerminationReason::Timeout))
} else {
Ok(vec![])
}
}
SessionEvent::Terminate(reason) => {
self.state = SessionState::Terminating;
Ok(self.termination_actions(reason))
}
SessionEvent::TerminationReceived(term) => {
self.reset();
Ok(vec![Action::SessionClosed(CloseReason::Terminated(
term.reason,
))])
}
// Local Start* outside Idle is a caller bug — typed error.
SessionEvent::StartSetup(_) | SessionEvent::StartSbp(_) => {
Err(BfError::InvalidStateForCommand { state: "Active" })
}
// Genuinely ignorable stray frames (duplicate setup/SBP traffic)
// are no-ops.
_ => Ok(vec![]),
}
}
fn handle_terminating(&mut self, event: SessionEvent) -> Result<Vec<Action>, BfError> {
match event {
SessionEvent::TerminationReceived(term) => {
self.reset();
Ok(vec![Action::SessionClosed(CloseReason::Terminated(
term.reason,
))])
}
// No peer echo is required: a quiescence tick completes teardown.
SessionEvent::Timeout => {
self.reset();
Ok(vec![Action::SessionClosed(CloseReason::Completed)])
}
// Local Start* outside Idle is a caller bug — typed error.
SessionEvent::StartSetup(_) | SessionEvent::StartSbp(_) => {
Err(BfError::InvalidStateForCommand {
state: "Terminating",
})
}
_ => Ok(vec![]),
}
}
fn termination_actions(&self, reason: TerminationReason) -> Vec<Action> {
match self.setup_id() {
Some(setup_id) => vec![Action::SendTermination(SensingSessionTermination {
setup_id,
reason,
})],
None => vec![],
}
}
fn next_instance_record(&mut self) -> Option<SensingMeasurementInstance> {
let (setup_id, params) = match &self.setup {
Some((id, p)) => (*id, p.clone()),
None => return None,
};
let n = self.instance_counter;
self.instance_counter = self.instance_counter.wrapping_add(1);
Some(SensingMeasurementInstance {
setup_id,
instance_id: MeasurementInstanceId::new((n % 256) as u8),
timestamp_us: u64::from(n) * u64::from(params.period_ms) * 1_000,
})
}
fn reset(&mut self) {
self.state = SessionState::Idle;
self.pending_request = None;
self.setup = None;
self.sbp_client = false;
self.sbp_proxy = false;
self.setup_timeouts = 0;
self.missed_instances = 0;
self.instance_counter = 0;
self.last_reported_mean = None;
}
}
@@ -0,0 +1,136 @@
//! Responder-side setup registry for the 802.11bf sensing model — enforces
//! the setup-ID-collision and capacity rejection paths a single session
//! cannot see on its own (ADR-153 acceptance: duplicate setup ID rejected).
//! Both entry points — direct setups ([`SessionTable::handle_setup_request`])
//! and sensing-by-proxy ([`SessionTable::handle_sbp_request`]) — share the
//! same guards and the same per-setup session storage.
use std::collections::BTreeMap;
use super::messages::{
SbpRequest, SbpResponse, SbpStatus, SensingMeasurementSetupRequest,
SensingMeasurementSetupResponse,
};
use super::session::{Action, SensingSession, SessionConfig, SessionEvent, SessionState};
use super::types::{BfError, MeasurementSetupId, SetupStatus};
/// Responder-side registry of sensing sessions keyed by setup ID.
///
/// Enforces the setup-ID-collision and capacity rejection paths the single
/// session cannot see on its own.
#[derive(Debug)]
pub struct SessionTable {
config: SessionConfig,
sessions: BTreeMap<u8, SensingSession>,
/// Events dropped because no session owned the setup ID (see
/// [`Self::handle_for`]).
unknown_setup_drops: u64,
}
impl SessionTable {
pub fn new(config: SessionConfig) -> Self {
Self {
config,
sessions: BTreeMap::new(),
unknown_setup_drops: 0,
}
}
/// Number of setups not in Idle.
pub fn active_setups(&self) -> usize {
self.sessions
.values()
.filter(|s| s.state() != SessionState::Idle)
.count()
}
pub fn session(&self, setup_id: MeasurementSetupId) -> Option<&SensingSession> {
self.sessions.get(&setup_id.value())
}
/// Count of events dropped by [`Self::handle_for`] because the setup ID
/// was unknown — lets an AP spot peers addressing setups it never
/// accepted without turning stray frames into errors.
pub fn unknown_setup_drops(&self) -> u64 {
self.unknown_setup_drops
}
/// Route an inbound setup request, rejecting setup-ID collisions and
/// capacity overruns before delegating to a responder session.
pub fn handle_setup_request(
&mut self,
req: SensingMeasurementSetupRequest,
) -> Result<Vec<Action>, BfError> {
let reject = |setup_id, status| {
Ok(vec![Action::SendSetupResponse(
SensingMeasurementSetupResponse { setup_id, status },
)])
};
if self.is_collision(req.setup_id) {
return reject(req.setup_id, SetupStatus::RejectedSetupIdCollision);
}
if self.at_capacity() {
return reject(req.setup_id, SetupStatus::RejectedCapacity);
}
let key = req.setup_id.value();
let mut session = SensingSession::new_responder(self.config.clone());
let actions = session.handle(SessionEvent::SetupRequestReceived(req))?;
self.sessions.insert(key, session);
Ok(actions)
}
/// Route an inbound SBP request, rejecting proxy-setup-ID collisions and
/// capacity overruns before delegating to a (new) proxy session — the
/// SBP mirror of [`Self::handle_setup_request`], so a table-driven AP
/// accepts SBP end-to-end instead of silently dropping it.
pub fn handle_sbp_request(&mut self, sbp: SbpRequest) -> Result<Vec<Action>, BfError> {
let reject = |proxy_setup_id, status| {
Ok(vec![Action::SendSbpResponse(SbpResponse {
proxy_setup_id,
status,
})])
};
if self.is_collision(sbp.proxy_setup_id) {
return reject(sbp.proxy_setup_id, SbpStatus::RejectedSetupIdCollision);
}
if self.at_capacity() {
return reject(sbp.proxy_setup_id, SbpStatus::RejectedCapacity);
}
let key = sbp.proxy_setup_id.value();
let mut session = SensingSession::new_responder(self.config.clone());
let actions = session.handle(SessionEvent::SbpRequestReceived(sbp))?;
self.sessions.insert(key, session);
Ok(actions)
}
/// Route any other event to the session owning `setup_id`.
///
/// Frames addressing an unknown setup are dropped *by design* (stray
/// frames are ignored, not errors), but the drop is observable through
/// [`Self::unknown_setup_drops`].
pub fn handle_for(
&mut self,
setup_id: MeasurementSetupId,
event: SessionEvent,
) -> Result<Vec<Action>, BfError> {
match self.sessions.get_mut(&setup_id.value()) {
Some(session) => session.handle(event),
None => {
self.unknown_setup_drops = self.unknown_setup_drops.saturating_add(1);
Ok(vec![])
}
}
}
/// A non-Idle session already owns this setup ID.
fn is_collision(&self, setup_id: MeasurementSetupId) -> bool {
self.sessions
.get(&setup_id.value())
.is_some_and(|existing| existing.state() != SessionState::Idle)
}
/// The active-setup budget is exhausted.
fn at_capacity(&self) -> bool {
self.active_setups() >= self.config.capabilities.max_active_setups as usize
}
}
@@ -0,0 +1,269 @@
//! ADR-153 acceptance tests — types (serde round trips, boundary
//! validation), the SimTransport double, and the ESP32 CSI bridge.
//! FSM/timeout/threshold/SBP coverage lives in [`super::tests_fsm`].
//! All tests are hardware-free (simulation only).
use super::messages::*;
use super::testutil::{csi_frame, params, payload, setup_request};
use super::transport::{
OpportunisticCsiBridge, SensingFrame, SensingTransport, SimTransport, TransportError,
};
use super::types::*;
// ---------- serde round trips ----------
#[test]
fn serde_round_trips_setup_instance_report_sbp_termination() {
let req = setup_request(7);
let json = serde_json::to_string(&req).unwrap();
assert_eq!(
serde_json::from_str::<SensingMeasurementSetupRequest>(&json).unwrap(),
req
);
let resp = SensingMeasurementSetupResponse {
setup_id: req.setup_id,
status: SetupStatus::Accepted,
};
let json = serde_json::to_string(&resp).unwrap();
assert_eq!(
serde_json::from_str::<SensingMeasurementSetupResponse>(&json).unwrap(),
resp
);
let instance = SensingMeasurementInstance {
setup_id: req.setup_id,
instance_id: MeasurementInstanceId::new(3),
timestamp_us: 300_000,
};
let json = serde_json::to_string(&instance).unwrap();
assert_eq!(
serde_json::from_str::<SensingMeasurementInstance>(&json).unwrap(),
instance
);
let report = SensingMeasurementReport {
setup_id: req.setup_id,
instance_id: MeasurementInstanceId::new(3),
payload: payload(42.0),
};
let json = serde_json::to_string(&report).unwrap();
assert_eq!(
serde_json::from_str::<SensingMeasurementReport>(&json).unwrap(),
report
);
let sbp = SbpRequest {
profile: SpecProfile::VendorExtension("acme-presensing".into()),
proxy_setup_id: req.setup_id,
params: params(),
};
let json = serde_json::to_string(&sbp).unwrap();
assert_eq!(serde_json::from_str::<SbpRequest>(&json).unwrap(), sbp);
let sbp_resp = SbpResponse {
proxy_setup_id: req.setup_id,
status: SbpStatus::Accepted,
};
let json = serde_json::to_string(&sbp_resp).unwrap();
assert_eq!(
serde_json::from_str::<SbpResponse>(&json).unwrap(),
sbp_resp
);
let term = SensingSessionTermination {
setup_id: req.setup_id,
reason: TerminationReason::InitiatorRequested,
};
let json = serde_json::to_string(&term).unwrap();
assert_eq!(
serde_json::from_str::<SensingSessionTermination>(&json).unwrap(),
term
);
}
#[test]
fn serde_rejects_out_of_range_setup_id() {
assert!(serde_json::from_str::<MeasurementSetupId>("200").is_err());
assert!(serde_json::from_str::<MeasurementSetupId>("127").is_ok());
}
#[test]
fn serde_rejects_out_of_range_threshold_params() {
assert!(serde_json::from_str::<ThresholdParams>(r#"{"delta_percent":255}"#).is_err());
let ok = serde_json::from_str::<ThresholdParams>(r#"{"delta_percent":100}"#).unwrap();
assert_eq!(ok.delta_percent(), 100);
}
// ---------- validation, no panics ----------
#[test]
fn setup_id_construction_never_panics_and_bounds_hold() {
for v in 0u8..=255 {
let result = MeasurementSetupId::new(v);
assert_eq!(result.is_ok(), v <= MAX_SETUP_ID);
}
}
#[test]
fn params_validation_rejects_malformed() {
let mut p = params();
p.period_ms = MIN_PERIOD_MS - 1;
assert!(matches!(p.validate(), Err(BfError::InvalidPeriod { .. })));
p = params();
p.period_ms = MAX_PERIOD_MS + 1;
assert!(matches!(p.validate(), Err(BfError::InvalidPeriod { .. })));
p = params();
p.burst_instances = 0;
assert!(matches!(
p.validate(),
Err(BfError::InvalidBurstInstances { .. })
));
p = params();
p.burst_instances = MAX_BURST_INSTANCES + 1;
assert!(matches!(
p.validate(),
Err(BfError::InvalidBurstInstances { .. })
));
p = params();
p.initiator_role = TransceiverRole::Receiver; // no transmitter anywhere
assert!(matches!(
p.validate(),
Err(BfError::InvalidTransceiverRoles)
));
p = params();
p.consent = ConsentMode::Disabled;
assert!(matches!(
p.validate(),
Err(BfError::SensingDisabledByPolicy)
));
assert!(ThresholdParams::new(101).is_err());
assert!(ThresholdParams::new(100).is_ok());
}
#[test]
fn payload_validation_rejects_adversarial_values_without_panic() {
let adversarial = [
CsiReportPayload {
n_subcarriers: 0,
amplitudes: vec![],
phases: vec![],
},
CsiReportPayload {
n_subcarriers: u16::MAX,
amplitudes: vec![1.0; 4],
phases: vec![0.0; 4],
},
CsiReportPayload {
n_subcarriers: 4,
amplitudes: vec![1.0; 3],
phases: vec![0.0; 4],
},
CsiReportPayload {
n_subcarriers: 2,
amplitudes: vec![f32::NAN, 1.0],
phases: vec![0.0; 2],
},
CsiReportPayload {
n_subcarriers: 2,
amplitudes: vec![1.0, f32::INFINITY],
phases: vec![0.0; 2],
},
CsiReportPayload {
n_subcarriers: 2,
amplitudes: vec![-1.0, 1.0],
phases: vec![0.0; 2],
},
CsiReportPayload {
n_subcarriers: 2,
amplitudes: vec![1.0; 2],
phases: vec![f32::NEG_INFINITY, 0.0],
},
];
for p in adversarial {
assert!(p.validate().is_err());
}
assert!(payload(5.0).validate().is_ok());
}
#[test]
fn spec_profile_compatibility() {
let published = SpecProfile::Ieee80211Bf2025;
assert!(published.accepts(&SpecProfile::DraftCompatible));
assert!(published.accepts(&SpecProfile::Ieee80211Bf2025));
assert!(!published.accepts(&SpecProfile::VendorExtension("x".into())));
let vendor = SpecProfile::VendorExtension("x".into());
assert!(vendor.accepts(&SpecProfile::VendorExtension("x".into())));
assert!(!vendor.accepts(&SpecProfile::VendorExtension("y".into())));
}
// ---------- bridge: ESP32 CSI → standardized report ----------
#[test]
fn bridge_maps_csi_batches_to_measurement_reports() {
let setup_id = MeasurementSetupId::new(1).unwrap();
let mut bridge = OpportunisticCsiBridge::new(setup_id, 4).unwrap();
assert!(OpportunisticCsiBridge::new(setup_id, 0).is_err());
// 3 frames: no report yet. 4th completes the instance batch.
for _ in 0..3 {
assert!(bridge.ingest(&csi_frame(8, 30, 40)).is_none());
}
let report = bridge
.ingest(&csi_frame(8, 30, 40))
.expect("batch complete");
assert_eq!(report.setup_id, setup_id);
assert_eq!(report.instance_id.value(), 0);
assert_eq!(report.payload.n_subcarriers, 8);
assert!(report.payload.validate().is_ok());
// |30 + 40i| = 50 on every subcarrier of every frame.
assert!(report
.payload
.amplitudes
.iter()
.all(|a| (a - 50.0).abs() < 1e-3));
// Invalid (all-zero) frames are skipped and do not advance the batch.
for _ in 0..10 {
assert!(bridge.ingest(&csi_frame(8, 0, 0)).is_none());
}
// A mid-batch subcarrier-shape change restarts the batch on the new shape.
assert!(bridge.ingest(&csi_frame(8, 10, 0)).is_none());
assert!(bridge.ingest(&csi_frame(4, 10, 0)).is_none()); // restart at n=4
for _ in 0..2 {
assert!(bridge.ingest(&csi_frame(4, 10, 0)).is_none());
}
let report = bridge.ingest(&csi_frame(4, 10, 0)).expect("second batch");
assert_eq!(report.instance_id.value(), 1); // instance counter advanced
assert_eq!(report.payload.n_subcarriers, 4);
}
// ---------- transport ----------
#[test]
fn sim_transport_scripted_responses_and_failures() {
let mut t = SimTransport::new();
let resp = SensingMeasurementSetupResponse {
setup_id: MeasurementSetupId::new(7).unwrap(),
status: SetupStatus::Accepted,
};
t.script_response(SensingFrame::SetupResponse(resp));
assert!(t.poll_frame().is_none());
t.send_setup_request(setup_request(7)).unwrap();
assert_eq!(t.poll_frame(), Some(SensingFrame::SetupResponse(resp)));
assert_eq!(t.sent().len(), 1);
let mut tiny = SimTransport::with_capacity(1);
tiny.send_setup_request(setup_request(1)).unwrap();
assert_eq!(
tiny.send_setup_request(setup_request(2)),
Err(TransportError::QueueFull { capacity: 1 })
);
let mut down = SimTransport::new();
down.set_link_down(true);
assert_eq!(
down.send_setup_request(setup_request(1)),
Err(TransportError::LinkDown)
);
}
@@ -0,0 +1,489 @@
//! ADR-153 acceptance tests — session FSM full cycle, rejection paths,
//! timeout handling, threshold-based reporting, single-role enforcement,
//! and adversarial no-panic coverage. SBP flows live in [`super::tests_sbp`];
//! type/serde/transport/bridge tests in [`super::tests`]. All tests are
//! hardware-free (simulation only).
use super::messages::*;
use super::session::{
Action, CloseReason, SensingSession, SessionConfig, SessionEvent, SessionState,
};
use super::table::SessionTable;
use super::testutil::{dispatch, ferry, params, payload, pump, setup_request};
use super::transport::{SensingFrame, SimTransport};
use super::types::*;
use crate::csi_frame::Bandwidth;
// ---------- FSM: full cycle ----------
#[test]
fn fsm_full_cycle_setup_measure_report_terminate() {
let cfg = SessionConfig::default();
let mut initiator = SensingSession::new_initiator(cfg.clone());
let mut responder = SensingSession::new_responder(cfg);
let mut wire_i = SimTransport::new();
let mut wire_r = SimTransport::new();
// Idle → SetupNegotiating
dispatch(
&mut initiator,
SessionEvent::StartSetup(setup_request(7)),
&mut wire_i,
);
assert_eq!(initiator.state(), SessionState::SetupNegotiating);
// Responder accepts → Active
ferry(&mut wire_i, &mut wire_r);
pump(&mut responder, &mut wire_r);
assert_eq!(responder.state(), SessionState::Active);
// Initiator sees Accepted → Active + first instance trigger on the wire
ferry(&mut wire_r, &mut wire_i);
pump(&mut initiator, &mut wire_i);
assert_eq!(initiator.state(), SessionState::Active);
assert!(wire_i
.sent()
.iter()
.any(|f| matches!(f, SensingFrame::InstanceTrigger(i) if i.setup_id.value() == 7)));
// Responder captures a measurement → report on the wire
wire_i.drain_sent();
let actions = dispatch(
&mut responder,
SessionEvent::MeasurementCaptured {
instance_id: MeasurementInstanceId::new(0),
payload: payload(10.0),
},
&mut wire_r,
);
assert!(actions.iter().any(|a| matches!(a, Action::SendReport(_))));
// Initiator delivers the report to its consumer
ferry(&mut wire_r, &mut wire_i);
let actions = pump(&mut initiator, &mut wire_i);
assert!(actions
.iter()
.any(|a| matches!(a, Action::DeliverReport(_))));
// Active → Terminating → Idle (peer notified, quiescence completes)
wire_i.drain_sent();
dispatch(
&mut initiator,
SessionEvent::Terminate(TerminationReason::InitiatorRequested),
&mut wire_i,
);
assert_eq!(initiator.state(), SessionState::Terminating);
ferry(&mut wire_i, &mut wire_r);
let actions = pump(&mut responder, &mut wire_r);
assert!(actions.iter().any(|a| matches!(
a,
Action::SessionClosed(CloseReason::Terminated(
TerminationReason::InitiatorRequested
))
)));
assert_eq!(responder.state(), SessionState::Idle);
let actions = initiator.handle(SessionEvent::Timeout).unwrap();
assert!(actions
.iter()
.any(|a| matches!(a, Action::SessionClosed(CloseReason::Completed))));
assert_eq!(initiator.state(), SessionState::Idle);
}
// ---------- FSM: rejection paths ----------
#[test]
fn responder_rejects_unsupported_bandwidth_and_initiator_resets() {
let mut cfg = SessionConfig::default();
cfg.capabilities = SensingCapabilities::esp32_opportunistic(); // max 40 MHz
let mut responder = SensingSession::new_responder(cfg);
let mut initiator = SensingSession::new_initiator(SessionConfig::default());
let mut req = setup_request(3);
req.params.bandwidth = Bandwidth::Bw80;
initiator
.handle(SessionEvent::StartSetup(req.clone()))
.unwrap();
let actions = responder
.handle(SessionEvent::SetupRequestReceived(req))
.unwrap();
let resp = match &actions[..] {
[Action::SendSetupResponse(r)] => *r,
other => panic!("expected single rejection response, got {other:?}"),
};
assert_eq!(resp.status, SetupStatus::RejectedUnsupportedParams);
assert_eq!(responder.state(), SessionState::Idle);
let actions = initiator
.handle(SessionEvent::SetupResponseReceived(resp))
.unwrap();
assert!(actions.iter().any(|a| matches!(
a,
Action::SessionClosed(CloseReason::SetupRejected(
SetupStatus::RejectedUnsupportedParams
))
)));
assert_eq!(initiator.state(), SessionState::Idle);
}
#[test]
fn invalid_period_rejected_on_both_sides() {
let mut req = setup_request(4);
req.params.period_ms = 1; // below MIN_PERIOD_MS
let mut initiator = SensingSession::new_initiator(SessionConfig::default());
assert!(matches!(
initiator.handle(SessionEvent::StartSetup(req.clone())),
Err(BfError::InvalidPeriod { period_ms: 1 })
));
assert_eq!(initiator.state(), SessionState::Idle);
let mut responder = SensingSession::new_responder(SessionConfig::default());
let actions = responder
.handle(SessionEvent::SetupRequestReceived(req))
.unwrap();
assert!(matches!(
actions[..],
[Action::SendSetupResponse(SensingMeasurementSetupResponse {
status: SetupStatus::RejectedUnsupportedParams,
..
})]
));
}
#[test]
fn duplicate_setup_id_rejected_by_session_table() {
let mut table = SessionTable::new(SessionConfig::default());
let actions = table.handle_setup_request(setup_request(9)).unwrap();
assert!(matches!(
actions[..],
[Action::SendSetupResponse(SensingMeasurementSetupResponse {
status: SetupStatus::Accepted,
..
})]
));
let actions = table.handle_setup_request(setup_request(9)).unwrap();
assert!(matches!(
actions[..],
[Action::SendSetupResponse(SensingMeasurementSetupResponse {
status: SetupStatus::RejectedSetupIdCollision,
..
})]
));
assert_eq!(table.active_setups(), 1);
}
#[test]
fn capacity_and_policy_and_profile_rejections() {
// Capacity
let mut cfg = SessionConfig::default();
cfg.capabilities.max_active_setups = 1;
let mut table = SessionTable::new(cfg);
table.handle_setup_request(setup_request(1)).unwrap();
let actions = table.handle_setup_request(setup_request(2)).unwrap();
assert!(matches!(
actions[..],
[Action::SendSetupResponse(SensingMeasurementSetupResponse {
status: SetupStatus::RejectedCapacity,
..
})]
));
// Consent policy
let mut responder = SensingSession::new_responder(SessionConfig::default());
let mut req = setup_request(5);
req.params.consent = ConsentMode::Disabled;
let actions = responder
.handle(SessionEvent::SetupRequestReceived(req))
.unwrap();
assert!(matches!(
actions[..],
[Action::SendSetupResponse(SensingMeasurementSetupResponse {
status: SetupStatus::RejectedByPolicy,
..
})]
));
// Incompatible profile
let mut cfg = SessionConfig::default();
cfg.profile = SpecProfile::VendorExtension("acme".into());
let mut responder = SensingSession::new_responder(cfg);
let actions = responder
.handle(SessionEvent::SetupRequestReceived(setup_request(6)))
.unwrap();
assert!(matches!(
actions[..],
[Action::SendSetupResponse(SensingMeasurementSetupResponse {
status: SetupStatus::RejectedIncompatibleProfile,
..
})]
));
}
// ---------- FSM: timeouts ----------
#[test]
fn negotiation_timeout_returns_typed_error_and_resets_to_idle() {
let mut initiator = SensingSession::new_initiator(SessionConfig::default()); // 3 timeouts
initiator
.handle(SessionEvent::StartSetup(setup_request(7)))
.unwrap();
// First two timeouts re-send the pending request.
for _ in 0..2 {
let actions = initiator.handle(SessionEvent::Timeout).unwrap();
assert!(matches!(actions[..], [Action::SendSetupRequest(_)]));
assert_eq!(initiator.state(), SessionState::SetupNegotiating);
}
// Third gives up: typed error + Idle.
assert_eq!(
initiator.handle(SessionEvent::Timeout),
Err(BfError::NegotiationTimeout {
setup_id: 7,
attempts: 3
})
);
assert_eq!(initiator.state(), SessionState::Idle);
}
#[test]
fn active_missed_instance_timeouts_terminate_session() {
let mut responder = SensingSession::new_responder(SessionConfig::default()); // 5 missed max
responder
.handle(SessionEvent::SetupRequestReceived(setup_request(2)))
.unwrap();
assert_eq!(responder.state(), SessionState::Active);
for _ in 0..4 {
assert!(responder.handle(SessionEvent::Timeout).unwrap().is_empty());
}
let actions = responder.handle(SessionEvent::Timeout).unwrap();
assert!(matches!(
actions[..],
[Action::SendTermination(SensingSessionTermination {
reason: TerminationReason::Timeout,
..
})]
));
assert_eq!(responder.state(), SessionState::Terminating);
let actions = responder.handle(SessionEvent::Timeout).unwrap();
assert!(matches!(
actions[..],
[Action::SessionClosed(CloseReason::Completed)]
));
assert_eq!(responder.state(), SessionState::Idle);
}
// ---------- threshold-based reporting ----------
#[test]
fn threshold_report_emitted_only_when_threshold_crossed() {
let mut responder = SensingSession::new_responder(SessionConfig::default());
let mut req = setup_request(8);
req.params.reporting = ReportingConfig::ThresholdBased(ThresholdParams::new(20).unwrap());
responder
.handle(SessionEvent::SetupRequestReceived(req))
.unwrap();
let capture = |mean: f32| SessionEvent::MeasurementCaptured {
instance_id: MeasurementInstanceId::new(0),
payload: payload(mean),
};
// First measurement always reported (establishes the baseline).
let actions = responder.handle(capture(100.0)).unwrap();
assert!(matches!(actions[..], [Action::SendReport(_)]));
// +10% — below threshold, suppressed; baseline stays at 100.
assert!(responder.handle(capture(110.0)).unwrap().is_empty());
// +19% vs the *reported* baseline — still suppressed.
assert!(responder.handle(capture(119.0)).unwrap().is_empty());
// +50% — crossed, reported, baseline moves to 150.
let actions = responder.handle(capture(150.0)).unwrap();
assert!(matches!(actions[..], [Action::SendReport(_)]));
// 150 → 125 is ~16.7% — suppressed against the new baseline.
assert!(responder.handle(capture(125.0)).unwrap().is_empty());
}
// ---------- consecutive missed-instance semantics ----------
#[test]
fn missed_instance_budget_is_consecutive_not_cumulative() {
// Review finding 2: a successful measurement must reset the
// missed-instance counter — `max_missed_instances` bounds *consecutive*
// misses (as documented on SessionConfig), not cumulative ones.
let mut responder = SensingSession::new_responder(SessionConfig::default()); // 5 missed max
responder
.handle(SessionEvent::SetupRequestReceived(setup_request(2)))
.unwrap();
assert_eq!(responder.state(), SessionState::Active);
let capture = || SessionEvent::MeasurementCaptured {
instance_id: MeasurementInstanceId::new(0),
payload: payload(10.0),
};
// Miss 4, then succeed once...
for _ in 0..4 {
assert!(responder.handle(SessionEvent::Timeout).unwrap().is_empty());
}
let actions = responder.handle(capture()).unwrap();
assert!(matches!(actions[..], [Action::SendReport(_)]));
// ...so 4 more misses still leave the session alive.
for _ in 0..4 {
assert!(responder.handle(SessionEvent::Timeout).unwrap().is_empty());
assert_eq!(responder.state(), SessionState::Active);
}
// The 5th consecutive miss terminates.
let actions = responder.handle(SessionEvent::Timeout).unwrap();
assert!(matches!(
actions[..],
[Action::SendTermination(SensingSessionTermination {
reason: TerminationReason::Timeout,
..
})]
));
assert_eq!(responder.state(), SessionState::Terminating);
}
// ---------- single-role enforcement & out-of-state commands ----------
#[test]
fn initiator_role_session_rejects_inbound_setup_and_sbp_requests() {
// Review finding 4a: single-role design — a peer must not be able to
// hijack an initiator-role session into the responder path.
let mut initiator = SensingSession::new_initiator(SessionConfig::default());
let actions = initiator
.handle(SessionEvent::SetupRequestReceived(setup_request(3)))
.unwrap();
assert!(matches!(
actions[..],
[Action::SendSetupResponse(SensingMeasurementSetupResponse {
status: SetupStatus::RejectedNotSupported,
..
})]
));
assert_eq!(initiator.state(), SessionState::Idle);
let sbp = SbpRequest {
profile: SpecProfile::Ieee80211Bf2025,
proxy_setup_id: MeasurementSetupId::new(4).unwrap(),
params: params(),
};
let actions = initiator
.handle(SessionEvent::SbpRequestReceived(sbp))
.unwrap();
assert!(matches!(
actions[..],
[Action::SendSbpResponse(SbpResponse {
status: SbpStatus::RejectedNotSupported,
..
})]
));
assert_eq!(initiator.state(), SessionState::Idle);
assert!(!initiator.is_sbp_proxy());
}
#[test]
fn local_start_commands_error_outside_idle() {
// Review finding 4b: StartSetup/StartSbp outside Idle are caller bugs
// and must surface as typed errors, not silent no-ops.
let sbp = SbpRequest {
profile: SpecProfile::Ieee80211Bf2025,
proxy_setup_id: MeasurementSetupId::new(13).unwrap(),
params: params(),
};
let start_err = |s: &mut SensingSession, expected: SessionState| {
assert!(matches!(
s.handle(SessionEvent::StartSetup(setup_request(8))),
Err(BfError::InvalidStateForCommand { .. })
));
assert!(matches!(
s.handle(SessionEvent::StartSbp(sbp.clone())),
Err(BfError::InvalidStateForCommand { .. })
));
// The rejected commands must not disturb the session.
assert_eq!(s.state(), expected);
};
let mut s = SensingSession::new_initiator(SessionConfig::default());
s.handle(SessionEvent::StartSetup(setup_request(7)))
.unwrap();
start_err(&mut s, SessionState::SetupNegotiating);
s.handle(SessionEvent::SetupResponseReceived(
SensingMeasurementSetupResponse {
setup_id: MeasurementSetupId::new(7).unwrap(),
status: SetupStatus::Accepted,
},
))
.unwrap();
start_err(&mut s, SessionState::Active);
// Genuinely ignorable stray frames remain no-ops in Active.
assert!(s
.handle(SessionEvent::SbpResponseReceived(SbpResponse {
proxy_setup_id: MeasurementSetupId::new(7).unwrap(),
status: SbpStatus::Accepted,
}))
.unwrap()
.is_empty());
s.handle(SessionEvent::Terminate(
TerminationReason::InitiatorRequested,
))
.unwrap();
start_err(&mut s, SessionState::Terminating);
}
// ---------- adversarial: no panics anywhere ----------
#[test]
fn malformed_and_out_of_state_events_never_panic() {
let junk_payload = CsiReportPayload {
n_subcarriers: 3,
amplitudes: vec![f32::NAN, -5.0, f32::INFINITY],
phases: vec![f32::NAN],
};
let bad_report = SensingMeasurementReport {
setup_id: MeasurementSetupId::new(99).unwrap(),
instance_id: MeasurementInstanceId::new(255),
payload: junk_payload.clone(),
};
let events: Vec<SessionEvent> = vec![
SessionEvent::StartSetup(setup_request(0)),
SessionEvent::StartSbp(SbpRequest {
profile: SpecProfile::DraftCompatible,
proxy_setup_id: MeasurementSetupId::new(0).unwrap(),
params: params(),
}),
SessionEvent::SetupRequestReceived(setup_request(127)),
SessionEvent::SetupResponseReceived(SensingMeasurementSetupResponse {
setup_id: MeasurementSetupId::new(50).unwrap(),
status: SetupStatus::RejectedCapacity,
}),
SessionEvent::SbpResponseReceived(SbpResponse {
proxy_setup_id: MeasurementSetupId::new(50).unwrap(),
status: SbpStatus::RejectedByPolicy,
}),
SessionEvent::InstanceElapsed,
SessionEvent::MeasurementCaptured {
instance_id: MeasurementInstanceId::new(0),
payload: junk_payload,
},
SessionEvent::ReportReceived(bad_report),
SessionEvent::Timeout,
SessionEvent::Terminate(TerminationReason::PolicyChange),
SessionEvent::TerminationReceived(SensingSessionTermination {
setup_id: MeasurementSetupId::new(1).unwrap(),
reason: TerminationReason::Timeout,
}),
];
// Drive both roles through every event repeatedly from whatever state
// each lands in; typed errors are fine, panics are not.
for session in [
&mut SensingSession::new_initiator(SessionConfig::default()),
&mut SensingSession::new_responder(SessionConfig::default()),
] {
for _ in 0..4 {
for event in &events {
let _ = session.handle(event.clone());
}
}
}
}
@@ -0,0 +1,338 @@
//! ADR-153 sensing-by-proxy (SBP) acceptance tests — proxy lifecycle
//! (re-triggering + report relay), client flow, table-driven AP entry
//! point, and the single-validation-path status mapping. Other FSM tests
//! live in [`super::tests_fsm`]; type/serde/transport/bridge tests in
//! [`super::tests`]. All tests are hardware-free (simulation only).
use super::messages::*;
use super::session::{
Action, CloseReason, SensingSession, SessionConfig, SessionEvent, SessionState,
};
use super::table::SessionTable;
use super::testutil::{params, payload};
use super::transport::{action_to_frame, frame_to_event, SensingFrame};
use super::types::*;
use crate::csi_frame::Bandwidth;
fn sbp_request(id: u8) -> SbpRequest {
SbpRequest {
profile: SpecProfile::Ieee80211Bf2025,
proxy_setup_id: MeasurementSetupId::new(id).unwrap(),
params: params(),
}
}
#[test]
fn sbp_proxy_request_maps_to_standard_responder_path() {
// Proxy AP: accepts the SBP request and initiates an ordinary setup
// toward the sensing responder — no direct sensor coupling.
let mut proxy = SensingSession::new_responder(SessionConfig::default());
let actions = proxy
.handle(SessionEvent::SbpRequestReceived(sbp_request(11)))
.unwrap();
let forwarded = match &actions[..] {
[Action::SendSbpResponse(SbpResponse {
status: SbpStatus::Accepted,
..
}), Action::SendSetupRequest(req)] => req.clone(),
other => panic!("expected SBP accept + setup request, got {other:?}"),
};
assert_eq!(proxy.state(), SessionState::SetupNegotiating);
assert_eq!(forwarded.setup_id.value(), 11);
// The forwarded request drives a *normal* responder session.
let mut responder = SensingSession::new_responder(SessionConfig::default());
let actions = responder
.handle(SessionEvent::SetupRequestReceived(forwarded))
.unwrap();
let resp = match &actions[..] {
[Action::SendSetupResponse(r)] => *r,
other => panic!("expected accept, got {other:?}"),
};
assert_eq!(resp.status, SetupStatus::Accepted);
proxy
.handle(SessionEvent::SetupResponseReceived(resp))
.unwrap();
assert_eq!(proxy.state(), SessionState::Active);
}
#[test]
fn sbp_client_flow_and_rejections() {
let mut client = SensingSession::new_initiator(SessionConfig::default());
let sbp = sbp_request(12);
let actions = client.handle(SessionEvent::StartSbp(sbp.clone())).unwrap();
assert!(matches!(actions[..], [Action::SendSbpRequest(_)]));
let accept = SbpResponse {
proxy_setup_id: sbp.proxy_setup_id,
status: SbpStatus::Accepted,
};
client
.handle(SessionEvent::SbpResponseReceived(accept))
.unwrap();
assert_eq!(client.state(), SessionState::Active);
// Proxied report is delivered to the local consumer.
let report = SensingMeasurementReport {
setup_id: sbp.proxy_setup_id,
instance_id: MeasurementInstanceId::new(0),
payload: payload(1.0),
};
let actions = client.handle(SessionEvent::ReportReceived(report)).unwrap();
assert!(matches!(actions[..], [Action::DeliverReport(_)]));
// A proxy without SBP capability rejects.
let mut cfg = SessionConfig::default();
cfg.capabilities.sensing_by_proxy = false;
let mut no_sbp = SensingSession::new_responder(cfg);
let actions = no_sbp
.handle(SessionEvent::SbpRequestReceived(sbp))
.unwrap();
assert!(matches!(
actions[..],
[Action::SendSbpResponse(SbpResponse {
status: SbpStatus::RejectedNotSupported,
..
})]
));
assert_eq!(no_sbp.state(), SessionState::Idle);
}
#[test]
fn sbp_proxy_full_lifecycle_retriggers_and_relays() {
// Review finding 1: the SBP proxy is a first-class mode — after the
// proxied setup is accepted it keeps driving measurement instances on
// InstanceElapsed (like an initiator) and relays every received report
// to the SBP client in addition to local delivery.
let mut proxy = SensingSession::new_responder(SessionConfig::default());
// Accept: SBP response to the client + proxied setup to the responder.
let actions = proxy
.handle(SessionEvent::SbpRequestReceived(sbp_request(21)))
.unwrap();
let forwarded = match &actions[..] {
[Action::SendSbpResponse(SbpResponse {
status: SbpStatus::Accepted,
..
}), Action::SendSetupRequest(req)] => req.clone(),
other => panic!("expected SBP accept + setup request, got {other:?}"),
};
assert!(proxy.is_sbp_proxy());
// Responder accepts → proxy Active, instance 0 triggered.
let actions = proxy
.handle(SessionEvent::SetupResponseReceived(
SensingMeasurementSetupResponse {
setup_id: forwarded.setup_id,
status: SetupStatus::Accepted,
},
))
.unwrap();
assert_eq!(proxy.state(), SessionState::Active);
match &actions[..] {
[Action::TriggerInstance(i)] => assert_eq!(i.instance_id.value(), 0),
other => panic!("expected instance 0 trigger, got {other:?}"),
}
// InstanceElapsed re-triggers instance 1+ (proxy drives the schedule).
let actions = proxy.handle(SessionEvent::InstanceElapsed).unwrap();
match &actions[..] {
[Action::TriggerInstance(i)] => assert_eq!(i.instance_id.value(), 1),
other => panic!("expected instance 1 trigger, got {other:?}"),
}
// A report from the sensing responder is delivered locally AND relayed.
let report = SensingMeasurementReport {
setup_id: forwarded.setup_id,
instance_id: MeasurementInstanceId::new(1),
payload: payload(5.0),
};
let actions = proxy
.handle(SessionEvent::ReportReceived(report.clone()))
.unwrap();
assert_eq!(
actions,
vec![
Action::DeliverReport(report.clone()),
Action::RelaySbpReport(report.clone()),
]
);
// The relay action maps to a frame toward the SBP client, which
// consumes it through the standard report path.
let frame = action_to_frame(&Action::RelaySbpReport(report.clone())).unwrap();
assert_eq!(frame, SensingFrame::SbpReport(report.clone()));
assert_eq!(
frame_to_event(frame),
Some(SessionEvent::ReportReceived(report))
);
// Terminate cleanly: notify the responder, quiesce back to Idle.
let actions = proxy
.handle(SessionEvent::Terminate(
TerminationReason::InitiatorRequested,
))
.unwrap();
assert!(matches!(actions[..], [Action::SendTermination(_)]));
assert_eq!(proxy.state(), SessionState::Terminating);
let actions = proxy.handle(SessionEvent::Timeout).unwrap();
assert!(matches!(
actions[..],
[Action::SessionClosed(CloseReason::Completed)]
));
assert_eq!(proxy.state(), SessionState::Idle);
assert!(!proxy.is_sbp_proxy());
}
#[test]
fn session_table_routes_sbp_end_to_end() {
// Review finding 3: the table has a first-class SBP entry point with
// the same collision/capacity guards as direct setups — a table-driven
// AP accepts SBP instead of silently dropping it.
let mut table = SessionTable::new(SessionConfig::default());
let actions = table.handle_sbp_request(sbp_request(31)).unwrap();
let forwarded = match &actions[..] {
[Action::SendSbpResponse(SbpResponse {
status: SbpStatus::Accepted,
..
}), Action::SendSetupRequest(req)] => req.clone(),
other => panic!("expected SBP accept + setup request, got {other:?}"),
};
let setup_id = forwarded.setup_id;
assert_eq!(table.active_setups(), 1);
assert!(table.session(setup_id).unwrap().is_sbp_proxy());
// Proxy-setup-ID collision while the first proxy is live.
let actions = table.handle_sbp_request(sbp_request(31)).unwrap();
assert!(matches!(
actions[..],
[Action::SendSbpResponse(SbpResponse {
status: SbpStatus::RejectedSetupIdCollision,
..
})]
));
// Drive the proxied negotiation to Active through the table.
let actions = table
.handle_for(
setup_id,
SessionEvent::SetupResponseReceived(SensingMeasurementSetupResponse {
setup_id,
status: SetupStatus::Accepted,
}),
)
.unwrap();
assert!(matches!(actions[..], [Action::TriggerInstance(_)]));
assert_eq!(
table.session(setup_id).unwrap().state(),
SessionState::Active
);
// Reports relay to the SBP client through the table-owned proxy.
let report = SensingMeasurementReport {
setup_id,
instance_id: MeasurementInstanceId::new(0),
payload: payload(2.0),
};
let actions = table
.handle_for(setup_id, SessionEvent::ReportReceived(report.clone()))
.unwrap();
assert!(actions.contains(&Action::RelaySbpReport(report)));
// Capacity guard mirrors the direct-setup path.
let mut cfg = SessionConfig::default();
cfg.capabilities.max_active_setups = 1;
let mut small = SessionTable::new(cfg);
small.handle_sbp_request(sbp_request(1)).unwrap();
let actions = small.handle_sbp_request(sbp_request(2)).unwrap();
assert!(matches!(
actions[..],
[Action::SendSbpResponse(SbpResponse {
status: SbpStatus::RejectedCapacity,
..
})]
));
// Unknown-setup drops are observable, not silent (finding 3).
assert_eq!(table.unknown_setup_drops(), 0);
let actions = table
.handle_for(MeasurementSetupId::new(99).unwrap(), SessionEvent::Timeout)
.unwrap();
assert!(actions.is_empty());
assert_eq!(table.unknown_setup_drops(), 1);
}
#[test]
fn sbp_validation_shares_setup_chain_with_one_to_one_status_mapping() {
// Review finding 5: SBP requests are validated by building the proxied
// setup request first and running it through the single evaluate_setup
// chain — statuses map 1:1, so no rejection class is folded away and no
// setup policy can be bypassed via SBP.
// Incompatible profile now surfaces as its own status (the old
// duplicated SBP chain folded it into RejectedUnsupportedParams).
let mut cfg = SessionConfig::default();
cfg.profile = SpecProfile::VendorExtension("acme".into());
let mut proxy = SensingSession::new_responder(cfg);
let actions = proxy
.handle(SessionEvent::SbpRequestReceived(sbp_request(41)))
.unwrap();
assert!(matches!(
actions[..],
[Action::SendSbpResponse(SbpResponse {
status: SbpStatus::RejectedIncompatibleProfile,
..
})]
));
// Consent policy rejection passes through unchanged.
let mut proxy = SensingSession::new_responder(SessionConfig::default());
let mut sbp = sbp_request(42);
sbp.params.consent = ConsentMode::Disabled;
let actions = proxy.handle(SessionEvent::SbpRequestReceived(sbp)).unwrap();
assert!(matches!(
actions[..],
[Action::SendSbpResponse(SbpResponse {
status: SbpStatus::RejectedByPolicy,
..
})]
));
// Capability rejection (bandwidth beyond the advertised maximum).
let mut cfg = SessionConfig::default();
cfg.capabilities.max_bandwidth_mhz = 40;
let mut proxy = SensingSession::new_responder(cfg);
let mut sbp = sbp_request(43);
sbp.params.bandwidth = Bandwidth::Bw80;
let actions = proxy.handle(SessionEvent::SbpRequestReceived(sbp)).unwrap();
assert!(matches!(
actions[..],
[Action::SendSbpResponse(SbpResponse {
status: SbpStatus::RejectedUnsupportedParams,
..
})]
));
// The status translation itself is exhaustive and 1:1.
let pairs = [
(SetupStatus::Accepted, SbpStatus::Accepted),
(
SetupStatus::RejectedNotSupported,
SbpStatus::RejectedNotSupported,
),
(
SetupStatus::RejectedUnsupportedParams,
SbpStatus::RejectedUnsupportedParams,
),
(
SetupStatus::RejectedSetupIdCollision,
SbpStatus::RejectedSetupIdCollision,
),
(
SetupStatus::RejectedIncompatibleProfile,
SbpStatus::RejectedIncompatibleProfile,
),
(SetupStatus::RejectedByPolicy, SbpStatus::RejectedByPolicy),
(SetupStatus::RejectedCapacity, SbpStatus::RejectedCapacity),
];
for (setup, sbp) in pairs {
assert_eq!(SbpStatus::from(setup), sbp);
}
}
@@ -0,0 +1,101 @@
//! Shared helpers for the ADR-153 acceptance tests (hardware-free).
use chrono::Utc;
use super::messages::{CsiReportPayload, SensingMeasurementSetupRequest};
use super::session::{Action, SensingSession, SessionEvent};
use super::transport::{action_to_frame, frame_to_event, SensingTransport, SimTransport};
use super::types::{
ConsentMode, MeasurementSetupId, MeasurementSetupParams, ReportingConfig, SpecProfile,
TransceiverRole,
};
use crate::csi_frame::{
Adr018Flags, AntennaConfig, Bandwidth, CsiFrame, CsiMetadata, PpduType, SubcarrierData,
};
pub(super) fn params() -> MeasurementSetupParams {
MeasurementSetupParams {
bandwidth: Bandwidth::Bw20,
period_ms: 100,
burst_instances: 4,
reporting: ReportingConfig::EveryInstance,
initiator_role: TransceiverRole::Transmitter,
responder_role: TransceiverRole::Receiver,
consent: ConsentMode::ExplicitConsent,
}
}
pub(super) fn setup_request(id: u8) -> SensingMeasurementSetupRequest {
SensingMeasurementSetupRequest {
profile: SpecProfile::Ieee80211Bf2025,
setup_id: MeasurementSetupId::new(id).unwrap(),
params: params(),
}
}
pub(super) fn payload(mean: f32) -> CsiReportPayload {
CsiReportPayload {
n_subcarriers: 4,
amplitudes: vec![mean; 4],
phases: vec![0.25; 4],
}
}
pub(super) fn csi_frame(n: usize, i: i16, q: i16) -> CsiFrame {
CsiFrame {
metadata: CsiMetadata {
timestamp: Utc::now(),
node_id: 1,
n_antennas: 1,
n_subcarriers: n as u16,
channel_freq_mhz: 2437,
rssi_dbm: -50,
noise_floor_dbm: -95,
bandwidth: Bandwidth::Bw20,
antenna_config: AntennaConfig::default(),
sequence: 0,
ppdu_type: PpduType::HtLegacy,
adr018_flags: Adr018Flags::default(),
},
subcarriers: (0..n)
.map(|k| SubcarrierData {
i,
q,
index: k as i16,
})
.collect(),
}
}
/// Drive a session, forwarding wire-bound actions onto a transport.
pub(super) fn dispatch(
s: &mut SensingSession,
event: SessionEvent,
out: &mut SimTransport,
) -> Vec<Action> {
let actions = s.handle(event).expect("handle must not error");
for a in &actions {
if let Some(f) = action_to_frame(a) {
out.send_frame(f).expect("send must not error");
}
}
actions
}
pub(super) fn ferry(from: &mut SimTransport, to: &mut SimTransport) {
for f in from.drain_sent() {
to.push_inbound(f);
}
}
/// Consume inbound frames on `wire`, sending any resulting outbound frames
/// back onto the same transport's sent log.
pub(super) fn pump(s: &mut SensingSession, wire: &mut SimTransport) -> Vec<Action> {
let mut all = Vec::new();
while let Some(frame) = wire.poll_frame() {
if let Some(event) = frame_to_event(frame) {
all.extend(dispatch(s, event, wire));
}
}
all
}
@@ -0,0 +1,318 @@
//! Transport abstraction for the 802.11bf forward-compatibility model.
//!
//! [`SensingTransport`] is the seam where a real chipset binding will land
//! when commodity silicon implements IEEE 802.11bf-2025 (none does today —
//! ADR-152 F4, ADR-153). Until then:
//!
//! - [`SimTransport`] is a scriptable in-memory test double for protocol
//! tests in CI (no hardware).
//! - [`OpportunisticCsiBridge`] maps today's opportunistic ESP32 CSI
//! extraction (ADR-018 frames parsed by [`crate::Esp32CsiParser`] and
//! delivered by [`crate::aggregator::Esp32Aggregator`]) onto the
//! standardized report path: one measurement instance ≈ one batch of
//! [`CsiFrame`]s.
//!
//! **Replaceability benchmark (ADR-153):** consumers must depend only on
//! `SensingTransport` plus the report types in [`super::types`] — a future
//! chipset adapter replaces `OpportunisticCsiBridge` without touching them.
use std::collections::VecDeque;
use thiserror::Error;
use super::messages::{
CsiReportPayload, SbpRequest, SbpResponse, SensingMeasurementInstance,
SensingMeasurementReport, SensingMeasurementSetupRequest, SensingMeasurementSetupResponse,
SensingSessionTermination,
};
use super::session::Action;
use super::types::{BfError, MeasurementInstanceId, MeasurementSetupId, MAX_REPORT_SUBCARRIERS};
use crate::csi_frame::CsiFrame;
/// Frames exchanged between sensing endpoints. This is a *logical* frame
/// set — no OTA encoding is defined until silicon exists to bind to.
#[derive(Debug, Clone, PartialEq)]
pub enum SensingFrame {
SetupRequest(SensingMeasurementSetupRequest),
SetupResponse(SensingMeasurementSetupResponse),
InstanceTrigger(SensingMeasurementInstance),
Report(SensingMeasurementReport),
SbpRequest(SbpRequest),
SbpResponse(SbpResponse),
/// Proxied measurement report forwarded by an SBP proxy toward its SBP
/// client ([`Action::RelaySbpReport`]) — distinct from [`Self::Report`],
/// which travels toward the sensing initiator.
SbpReport(SensingMeasurementReport),
Termination(SensingSessionTermination),
}
/// Errors surfaced by a sensing transport.
#[derive(Debug, Clone, PartialEq, Error)]
pub enum TransportError {
#[error("transport link down")]
LinkDown,
#[error("transport queue full (capacity {capacity})")]
QueueFull { capacity: usize },
}
/// Frame-exchange abstraction for sensing endpoints.
///
/// The required surface is deliberately tiny (`send_frame`/`poll_frame`);
/// the named helpers are convenience wrappers so call sites read like the
/// standard's procedures.
pub trait SensingTransport {
/// Queue one logical frame toward the peer.
fn send_frame(&mut self, frame: SensingFrame) -> Result<(), TransportError>;
/// Pop the next inbound frame, if any.
fn poll_frame(&mut self) -> Option<SensingFrame>;
fn send_setup_request(
&mut self,
req: SensingMeasurementSetupRequest,
) -> Result<(), TransportError> {
self.send_frame(SensingFrame::SetupRequest(req))
}
fn send_setup_response(
&mut self,
resp: SensingMeasurementSetupResponse,
) -> Result<(), TransportError> {
self.send_frame(SensingFrame::SetupResponse(resp))
}
fn trigger_measurement_instance(
&mut self,
instance: SensingMeasurementInstance,
) -> Result<(), TransportError> {
self.send_frame(SensingFrame::InstanceTrigger(instance))
}
fn send_report(&mut self, report: SensingMeasurementReport) -> Result<(), TransportError> {
self.send_frame(SensingFrame::Report(report))
}
fn send_termination(
&mut self,
termination: SensingSessionTermination,
) -> Result<(), TransportError> {
self.send_frame(SensingFrame::Termination(termination))
}
}
/// Map a session [`Action`] to the frame it puts on the wire, if any.
/// `DeliverReport`/`SessionClosed` are local-consumer actions and map to `None`.
pub fn action_to_frame(action: &Action) -> Option<SensingFrame> {
match action {
Action::SendSetupRequest(req) => Some(SensingFrame::SetupRequest(req.clone())),
Action::SendSetupResponse(resp) => Some(SensingFrame::SetupResponse(*resp)),
Action::SendSbpRequest(req) => Some(SensingFrame::SbpRequest(req.clone())),
Action::SendSbpResponse(resp) => Some(SensingFrame::SbpResponse(*resp)),
Action::TriggerInstance(instance) => Some(SensingFrame::InstanceTrigger(*instance)),
Action::SendReport(report) => Some(SensingFrame::Report(report.clone())),
Action::RelaySbpReport(report) => Some(SensingFrame::SbpReport(report.clone())),
Action::SendTermination(term) => Some(SensingFrame::Termination(*term)),
Action::DeliverReport(_) | Action::SessionClosed(_) => None,
}
}
/// Map an inbound frame to the session event it raises on the receiver.
///
/// `InstanceTrigger` maps to `None`: a sensing receiver pairs the trigger
/// with locally captured CSI and raises `MeasurementCaptured` itself (see
/// [`OpportunisticCsiBridge`]).
pub fn frame_to_event(frame: SensingFrame) -> Option<super::session::SessionEvent> {
use super::session::SessionEvent as E;
match frame {
SensingFrame::SetupRequest(req) => Some(E::SetupRequestReceived(req)),
SensingFrame::SetupResponse(resp) => Some(E::SetupResponseReceived(resp)),
SensingFrame::Report(report) => Some(E::ReportReceived(report)),
// The SBP client consumes proxied reports through the standard
// report path (its session is in sbp_client mode).
SensingFrame::SbpReport(report) => Some(E::ReportReceived(report)),
SensingFrame::SbpRequest(req) => Some(E::SbpRequestReceived(req)),
SensingFrame::SbpResponse(resp) => Some(E::SbpResponseReceived(resp)),
SensingFrame::Termination(term) => Some(E::TerminationReceived(term)),
SensingFrame::InstanceTrigger(_) => None,
}
}
/// In-memory scriptable transport test double.
///
/// Every successful `send_frame` is recorded in [`SimTransport::sent`]; if a
/// scripted response is queued, it is moved to the inbound queue so the next
/// `poll_frame` returns it — letting tests script a peer without one.
#[derive(Debug, Default)]
pub struct SimTransport {
sent: Vec<SensingFrame>,
inbound: VecDeque<SensingFrame>,
scripted: VecDeque<SensingFrame>,
link_down: bool,
capacity: usize,
}
impl SimTransport {
pub fn new() -> Self {
Self {
capacity: 1024,
..Default::default()
}
}
pub fn with_capacity(capacity: usize) -> Self {
Self {
capacity,
..Default::default()
}
}
/// Frames sent so far, in order.
pub fn sent(&self) -> &[SensingFrame] {
&self.sent
}
/// Drain the sent log (useful when ferrying frames between two doubles).
pub fn drain_sent(&mut self) -> Vec<SensingFrame> {
std::mem::take(&mut self.sent)
}
/// Queue a frame as if the peer transmitted it.
pub fn push_inbound(&mut self, frame: SensingFrame) {
self.inbound.push_back(frame);
}
/// Script a response: the next successful send moves it to the inbound
/// queue (one scripted frame consumed per send).
pub fn script_response(&mut self, frame: SensingFrame) {
self.scripted.push_back(frame);
}
pub fn set_link_down(&mut self, down: bool) {
self.link_down = down;
}
}
impl SensingTransport for SimTransport {
fn send_frame(&mut self, frame: SensingFrame) -> Result<(), TransportError> {
if self.link_down {
return Err(TransportError::LinkDown);
}
if self.sent.len() >= self.capacity {
return Err(TransportError::QueueFull {
capacity: self.capacity,
});
}
self.sent.push(frame);
if let Some(response) = self.scripted.pop_front() {
self.inbound.push_back(response);
}
Ok(())
}
fn poll_frame(&mut self) -> Option<SensingFrame> {
self.inbound.pop_front()
}
}
/// Adapter mapping today's opportunistic ESP32 CSI extraction onto the
/// standardized sensing report path.
///
/// A "measurement instance" is approximated by one batch of `batch_size`
/// ADR-018 [`CsiFrame`]s from a node (as produced by
/// [`crate::aggregator::Esp32Aggregator`]'s mpsc channel). Amplitudes are
/// averaged arithmetically; phases via the circular mean (consistent with
/// the RuvSense `phase_align` treatment of LO phase). Invalid frames
/// ([`CsiFrame::is_valid`] false) are skipped; a mid-batch subcarrier-shape
/// change (node reconfiguration) restarts the batch on the new shape.
///
/// This is the *interim backend*: when 802.11bf silicon exists, a chipset
/// adapter producing the same [`SensingMeasurementReport`]s replaces this
/// bridge with no change to consumers (ADR-153 replaceability benchmark).
#[derive(Debug)]
pub struct OpportunisticCsiBridge {
setup_id: MeasurementSetupId,
batch_size: usize,
instance_counter: u32,
amp_accum: Vec<f64>,
phase_cos_accum: Vec<f64>,
phase_sin_accum: Vec<f64>,
frames_in_batch: usize,
}
impl OpportunisticCsiBridge {
pub fn new(setup_id: MeasurementSetupId, batch_size: usize) -> Result<Self, BfError> {
if batch_size == 0 {
return Err(BfError::InvalidBatchSize { got: 0 });
}
Ok(Self {
setup_id,
batch_size,
instance_counter: 0,
amp_accum: Vec::new(),
phase_cos_accum: Vec::new(),
phase_sin_accum: Vec::new(),
frames_in_batch: 0,
})
}
pub fn setup_id(&self) -> MeasurementSetupId {
self.setup_id
}
pub fn batch_size(&self) -> usize {
self.batch_size
}
/// Feed one parsed CSI frame; returns a standardized measurement report
/// when a batch completes. Never panics on malformed frames.
pub fn ingest(&mut self, frame: &CsiFrame) -> Option<SensingMeasurementReport> {
if !frame.is_valid() || frame.subcarrier_count() > MAX_REPORT_SUBCARRIERS as usize {
return None;
}
let (amplitudes, phases) = frame.to_amplitude_phase();
if self.frames_in_batch == 0 || amplitudes.len() != self.amp_accum.len() {
// Fresh batch (or node reconfigured mid-batch — restart on the
// new subcarrier shape, dropping the partial batch).
self.amp_accum = vec![0.0; amplitudes.len()];
self.phase_cos_accum = vec![0.0; amplitudes.len()];
self.phase_sin_accum = vec![0.0; amplitudes.len()];
self.frames_in_batch = 0;
}
for (i, (a, p)) in amplitudes.iter().zip(phases.iter()).enumerate() {
self.amp_accum[i] += a;
self.phase_cos_accum[i] += p.cos();
self.phase_sin_accum[i] += p.sin();
}
self.frames_in_batch += 1;
if self.frames_in_batch < self.batch_size {
return None;
}
let scale = self.frames_in_batch as f64;
let payload = CsiReportPayload {
n_subcarriers: self.amp_accum.len() as u16,
amplitudes: self.amp_accum.iter().map(|a| (a / scale) as f32).collect(),
phases: self
.phase_sin_accum
.iter()
.zip(self.phase_cos_accum.iter())
.map(|(s, c)| s.atan2(*c) as f32)
.collect(),
};
self.amp_accum.clear();
self.phase_cos_accum.clear();
self.phase_sin_accum.clear();
self.frames_in_batch = 0;
let n = self.instance_counter;
self.instance_counter = self.instance_counter.wrapping_add(1);
let report = SensingMeasurementReport {
setup_id: self.setup_id,
instance_id: MeasurementInstanceId::new((n % 256) as u8),
payload,
};
// Boundary check before handing to consumers; drop instead of panic.
report.validate().ok()?;
Some(report)
}
}
@@ -0,0 +1,398 @@
//! Typed structures for IEEE 802.11bf-2025 WLAN sensing procedures.
//!
//! Sub-7 GHz focus; DMG (>45 GHz) types are stubbed minimally. Concept names
//! follow the standard's procedure vocabulary descriptively — "Sensing
//! Measurement Setup", "Sensing Measurement Instance", "Sensing Measurement
//! Report", "Sensing by Proxy (SBP)", session termination — without claiming
//! clause-level conformance. See [`crate::ieee80211bf`] module docs and
//! ADR-153 for framing; ADR-152 §1.1 F4 for the standards-body evidence.
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::csi_frame::Bandwidth;
/// Largest measurement setup identifier accepted by this model (7-bit space;
/// chosen conservatively — the standard encodes the Measurement Setup ID in a
/// compact identifier field).
pub const MAX_SETUP_ID: u8 = 127;
/// Minimum measurement-instance periodicity accepted by this model.
pub const MIN_PERIOD_MS: u32 = 10;
/// Maximum measurement-instance periodicity accepted by this model (1 hour).
pub const MAX_PERIOD_MS: u32 = 3_600_000;
/// Maximum measurement instances per burst accepted by this model.
pub const MAX_BURST_INSTANCES: u8 = 64;
/// Maximum subcarriers in a CSI-variant report payload (matches the 160 MHz
/// usable-subcarrier count, [`Bandwidth::Bw160`]).
pub const MAX_REPORT_SUBCARRIERS: u16 = 484;
/// Errors produced by validation at the protocol-model boundary.
///
/// Adversarial or malformed input must surface as one of these — never a
/// panic (crate rule: input validation at system boundaries).
#[derive(Debug, Clone, PartialEq, Error)]
pub enum BfError {
/// Measurement setup ID outside the accepted identifier space.
#[error("invalid measurement setup ID {value} (valid 0..={MAX_SETUP_ID})")]
InvalidSetupId { value: u8 },
/// Measurement periodicity outside the accepted range.
#[error("measurement period {period_ms} ms out of range ({MIN_PERIOD_MS}..={MAX_PERIOD_MS})")]
InvalidPeriod { period_ms: u32 },
/// Instances-per-burst outside the accepted range.
#[error("burst instance count {count} out of range (1..={MAX_BURST_INSTANCES})")]
InvalidBurstInstances { count: u8 },
/// Threshold-based reporting parameter outside 0..=100 percent.
#[error("reporting threshold {value}% out of range (0..=100)")]
InvalidThreshold { value: u8 },
/// The initiator/responder transceiver roles leave the measurement with
/// no sensing transmitter or no sensing receiver.
#[error("transceiver roles leave no sensing transmitter/receiver pair")]
InvalidTransceiverRoles,
/// Setup carries [`ConsentMode::Disabled`] — sensing must not start.
#[error("sensing disabled by consent policy")]
SensingDisabledByPolicy,
/// Report payload declares zero subcarriers.
#[error("report payload empty")]
EmptyPayload,
/// Report payload claims more subcarriers than this model supports.
#[error("report payload claims {count} subcarriers (max {MAX_REPORT_SUBCARRIERS})")]
PayloadTooLarge { count: u16 },
/// Declared subcarrier count and vector lengths disagree.
#[error(
"report payload length mismatch: declared {declared}, amplitudes {amplitudes}, phases {phases}"
)]
PayloadLengthMismatch {
declared: usize,
amplitudes: usize,
phases: usize,
},
/// A payload value is NaN/infinite, or an amplitude is negative.
#[error("report payload value at index {index} is not finite (or negative amplitude)")]
PayloadValueInvalid { index: usize },
/// A frame referenced a setup ID that does not match the session.
#[error("setup ID mismatch: session {expected}, frame {got}")]
SetupIdMismatch { expected: u8, got: u8 },
/// Sensing measurement setup negotiation timed out (session resets to Idle).
#[error("negotiation timed out for setup {setup_id} after {attempts} attempts")]
NegotiationTimeout { setup_id: u8, attempts: u8 },
/// A local command (`StartSetup`/`StartSbp`) was issued in a state or
/// role that cannot accept it.
#[error("command not valid in state {state}")]
InvalidStateForCommand { state: &'static str },
/// CSI bridge batch size must be at least one frame.
#[error("invalid CSI batch size {got} (must be >= 1)")]
InvalidBatchSize { got: usize },
}
/// Version gate for every negotiated surface (ADR-153).
///
/// Vendors will expose partial or renamed capabilities before full
/// IEEE 802.11bf-2025 conformance; tagging setups and capability
/// advertisements with a profile keeps that drift explicit.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SpecProfile {
/// Pre-publication draft semantics (D-series compatible behavior).
DraftCompatible,
/// Published standard semantics (IEEE 802.11bf-2025, published 2025-09-26).
Ieee80211Bf2025,
/// Vendor-specific extension or renamed capability set.
VendorExtension(String),
}
impl SpecProfile {
/// Whether a peer advertising `self` accepts a setup tagged `requested`.
///
/// Published-standard peers accept draft-compatible requests; vendor
/// extensions must match exactly.
pub fn accepts(&self, requested: &SpecProfile) -> bool {
self == requested
|| matches!(
(self, requested),
(SpecProfile::Ieee80211Bf2025, SpecProfile::DraftCompatible)
)
}
}
/// Consent/governance mode carried by every sensing measurement setup
/// (ADR-153: sensing is presence inference, not just radio telemetry).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ConsentMode {
/// Lab/bench use only; not a deployment consent basis.
LabOnly,
/// Sensed persons gave explicit consent.
ExplicitConsent,
/// Enterprise-managed policy authorizes sensing.
ManagedEnterprisePolicy,
/// Sensing administratively disabled — setups must be rejected.
Disabled,
}
/// WLAN sensing procedure role: sensing initiator or sensing responder.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SensingRole {
Initiator,
Responder,
}
/// Per-measurement-instance role: sensing transmitter, sensing receiver,
/// or both (a STA may act as either within a measurement instance).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransceiverRole {
Transmitter,
Receiver,
TransmitterReceiver,
}
impl TransceiverRole {
pub fn is_transmitter(self) -> bool {
matches!(self, Self::Transmitter | Self::TransmitterReceiver)
}
pub fn is_receiver(self) -> bool {
matches!(self, Self::Receiver | Self::TransmitterReceiver)
}
}
/// Identifier of a sensing measurement setup ("Measurement Setup ID").
///
/// Validated newtype: construction and deserialization both reject values
/// above [`MAX_SETUP_ID`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "u8", into = "u8")]
pub struct MeasurementSetupId(u8);
impl MeasurementSetupId {
pub fn new(value: u8) -> Result<Self, BfError> {
if value > MAX_SETUP_ID {
Err(BfError::InvalidSetupId { value })
} else {
Ok(Self(value))
}
}
pub fn value(self) -> u8 {
self.0
}
}
impl TryFrom<u8> for MeasurementSetupId {
type Error = BfError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<MeasurementSetupId> for u8 {
fn from(id: MeasurementSetupId) -> u8 {
id.0
}
}
/// Identifier of a sensing measurement instance within a setup
/// ("Measurement Instance ID"). Wraps modulo 256.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct MeasurementInstanceId(u8);
impl MeasurementInstanceId {
pub fn new(value: u8) -> Self {
Self(value)
}
pub fn value(self) -> u8 {
self.0
}
pub fn wrapping_next(self) -> Self {
Self(self.0.wrapping_add(1))
}
}
/// Channel width of a bandwidth variant in MHz (capability comparisons).
pub fn bandwidth_mhz(bw: Bandwidth) -> u16 {
match bw {
Bandwidth::Bw20 => 20,
Bandwidth::Bw40 => 40,
Bandwidth::Bw80 => 80,
Bandwidth::Bw160 => 160,
}
}
/// Threshold-based reporting parameters: a report is generated only when the
/// measurement changes by at least `delta_percent` relative to the last
/// reported measurement (normalized-change trigger).
///
/// Deserialization validates through [`ThresholdParams::new`] so the
/// `delta_percent <= 100` invariant holds on every construction path,
/// including untrusted wire/persisted payloads (same convention as
/// [`MeasurementSetupId`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "RawThresholdParams")]
pub struct ThresholdParams {
delta_percent: u8,
}
#[derive(Deserialize)]
struct RawThresholdParams {
delta_percent: u8,
}
impl TryFrom<RawThresholdParams> for ThresholdParams {
type Error = BfError;
fn try_from(raw: RawThresholdParams) -> Result<Self, Self::Error> {
Self::new(raw.delta_percent)
}
}
impl ThresholdParams {
pub fn new(delta_percent: u8) -> Result<Self, BfError> {
if delta_percent > 100 {
Err(BfError::InvalidThreshold {
value: delta_percent,
})
} else {
Ok(Self { delta_percent })
}
}
pub fn delta_percent(self) -> u8 {
self.delta_percent
}
/// Whether the change from `previous` to `current` crosses the threshold.
pub fn exceeds(self, previous: f64, current: f64) -> bool {
let denom = previous.abs().max(f64::EPSILON);
((current - previous).abs() / denom) * 100.0 >= self.delta_percent as f64
}
}
/// Reporting discipline negotiated in the sensing measurement setup.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ReportingConfig {
/// Report every measurement instance.
EveryInstance,
/// Threshold-based reporting (report only on significant change).
ThresholdBased(ThresholdParams),
}
/// Parameters of a sensing measurement setup ("Sensing Measurement Setup
/// element" parameters, sub-7 GHz). Consent metadata is **required**.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MeasurementSetupParams {
/// Sounding bandwidth.
pub bandwidth: Bandwidth,
/// Periodicity of measurement instances, in milliseconds.
pub period_ms: u32,
/// Measurement instances per burst.
pub burst_instances: u8,
/// Reporting discipline (per-instance or threshold-based).
pub reporting: ReportingConfig,
/// Transceiver role the initiator takes during measurement instances.
pub initiator_role: TransceiverRole,
/// Transceiver role the responder takes during measurement instances.
pub responder_role: TransceiverRole,
/// Required governance metadata (ADR-153 privacy requirement).
pub consent: ConsentMode,
}
impl MeasurementSetupParams {
/// Boundary validation: range checks plus role/consent coherence.
pub fn validate(&self) -> Result<(), BfError> {
if self.period_ms < MIN_PERIOD_MS || self.period_ms > MAX_PERIOD_MS {
return Err(BfError::InvalidPeriod {
period_ms: self.period_ms,
});
}
if self.burst_instances == 0 || self.burst_instances > MAX_BURST_INSTANCES {
return Err(BfError::InvalidBurstInstances {
count: self.burst_instances,
});
}
let has_tx = self.initiator_role.is_transmitter() || self.responder_role.is_transmitter();
let has_rx = self.initiator_role.is_receiver() || self.responder_role.is_receiver();
if !has_tx || !has_rx {
return Err(BfError::InvalidTransceiverRoles);
}
if self.consent == ConsentMode::Disabled {
return Err(BfError::SensingDisabledByPolicy);
}
Ok(())
}
}
/// Capability advertisement for capability negotiation (ADR-153): no
/// hardcoded ESP32 assumptions in the future-silicon path.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SensingCapabilities {
pub sub_7_ghz: bool,
pub dmg: bool,
pub edmg: bool,
pub csi_report: bool,
pub threshold_reporting: bool,
pub sensing_by_proxy: bool,
pub max_bandwidth_mhz: u16,
pub max_period_ms: u32,
pub max_active_setups: u16,
}
impl SensingCapabilities {
/// Permissive capability set for simulation and tests.
pub fn sim_full() -> Self {
Self {
sub_7_ghz: true,
dmg: false,
edmg: false,
csi_report: true,
threshold_reporting: true,
sensing_by_proxy: true,
max_bandwidth_mhz: 160,
max_period_ms: MAX_PERIOD_MS,
max_active_setups: 8,
}
}
/// What today's opportunistic ESP32 CSI extraction (ADR-018/ADR-028) can
/// honor when mapped through [`crate::ieee80211bf::transport::OpportunisticCsiBridge`].
pub fn esp32_opportunistic() -> Self {
Self {
sub_7_ghz: true,
dmg: false,
edmg: false,
csi_report: true,
threshold_reporting: true,
sensing_by_proxy: false,
max_bandwidth_mhz: 40,
max_period_ms: 60_000,
max_active_setups: 4,
}
}
/// Evaluate setup parameters against this capability set; `Err` carries
/// the protocol-level rejection status to return to the peer.
pub fn evaluate(&self, params: &MeasurementSetupParams) -> Result<(), SetupStatus> {
if !self.sub_7_ghz || !self.csi_report {
return Err(SetupStatus::RejectedUnsupportedParams);
}
if bandwidth_mhz(params.bandwidth) > self.max_bandwidth_mhz {
return Err(SetupStatus::RejectedUnsupportedParams);
}
if params.period_ms > self.max_period_ms {
return Err(SetupStatus::RejectedUnsupportedParams);
}
if matches!(params.reporting, ReportingConfig::ThresholdBased(_))
&& !self.threshold_reporting
{
return Err(SetupStatus::RejectedUnsupportedParams);
}
Ok(())
}
}
/// Status carried by a sensing measurement setup response.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SetupStatus {
Accepted,
/// The receiving endpoint does not act as a sensing responder for this
/// request — e.g. an initiator-role session received a setup request
/// (single-role design, see [`crate::ieee80211bf::session`]).
RejectedNotSupported,
RejectedUnsupportedParams,
RejectedSetupIdCollision,
RejectedIncompatibleProfile,
RejectedByPolicy,
RejectedCapacity,
}
@@ -40,6 +40,12 @@ mod csi_frame;
mod error;
pub mod esp32;
mod esp32_parser;
// ADR-153: IEEE 802.11bf-2025 forward-compatibility protocol model
// (sensing setup / measurement instance / report / SBP / termination).
// Simulation-tested; no commodity silicon implements the standard yet —
// the OpportunisticCsiBridge maps today's ESP32 CSI extraction onto the
// standardized report path until an OTA binding exists.
pub mod ieee80211bf;
pub mod sync_packet;
// ADR-081: Rust mirror of the firmware radio abstraction layer (L1) and