mirror of
https://github.com/ruvnet/RuView
synced 2026-08-10 20:31:42 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user