Skip to main content

esp_csi_rs/wire/
setup.rs

1//! A measurement setup, in IEEE 802.11bf's terms.
2//!
3//! 802.11bf negotiates a *measurement setup* before any sounding: how often to sound, at what
4//! bandwidth, which responders take part, and what form their reports take. Each sounding sequence
5//! under it is a *measurement instance*. This type carries the same parameters, split the way a
6//! sniffer forces them apart: an [`AcquisitionFilter`] (what a node accepts, which every mode has)
7//! and optional [`StimulusParams`] (how the channel is sounded, which only a node that sounds it
8//! has).
9//!
10//! A controller can speak these terms today. A node maps the parameters onto whatever its mode
11//! actually does — an ESP-NOW initiator's periodicity becomes its packet rate — and refuses the ones
12//! it cannot honour rather than ignoring them. When an 802.11bf mode exists, the same setup maps
13//! onto a negotiated one, with no change for the controller.
14//!
15//! Like the rest of [`crate::wire`], this module has no `esp-*` imports.
16
17use heapless::Vec;
18use postcard::experimental::max_size::MaxSize;
19use serde::{Deserialize, Serialize};
20
21use super::{Bandwidth, PpduFormat};
22
23/// Responders a setup can name.
24pub const MAX_RESPONDERS: usize = 8;
25
26/// A measurement setup: an id, what to accept, and — for a node that sounds the channel — how to
27/// sound it.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, MaxSize)]
29#[non_exhaustive]
30pub struct MeasurementSetup {
31    /// Setup id. Stamped on every measurement taken under it, as
32    /// [`Stimulus::Controlled::setup_id`](super::Stimulus::Controlled).
33    pub id: u8,
34    /// What a node accepts.
35    pub acquisition: AcquisitionFilter,
36    /// How the channel is sounded. `None` for a node that does not sound it — a sniffer.
37    pub stimulus: Option<StimulusParams>,
38}
39
40impl MeasurementSetup {
41    /// A setup that accepts everything and sounds nothing.
42    pub fn new(id: u8) -> Self {
43        Self {
44            id,
45            acquisition: AcquisitionFilter::default(),
46            stimulus: None,
47        }
48    }
49
50    /// Set what to accept.
51    pub fn with_acquisition(mut self, acquisition: AcquisitionFilter) -> Self {
52        self.acquisition = acquisition;
53        self
54    }
55
56    /// Set how to sound the channel.
57    pub fn with_stimulus(mut self, stimulus: StimulusParams) -> Self {
58        self.stimulus = Some(stimulus);
59        self
60    }
61}
62
63/// Which measurements a node accepts.
64#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, MaxSize)]
65#[non_exhaustive]
66pub struct AcquisitionFilter {
67    /// Primary channel. `None` keeps the mode's own.
68    pub channel: Option<u8>,
69    /// Bandwidth. `None` accepts whatever arrives.
70    pub bandwidth: Option<Bandwidth>,
71    /// PPDU formats to acquire. Empty accepts every format.
72    pub ppdu_formats: PpduSet,
73    /// Accept only this transmitter. `None` accepts any.
74    pub transmitter: Option<[u8; 6]>,
75}
76
77/// A set of [`PpduFormat`]s.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, MaxSize)]
79pub struct PpduSet(u16);
80
81impl PpduSet {
82    /// The empty set, which a filter reads as "every format".
83    pub const ANY: Self = Self(0);
84
85    const fn bit(f: PpduFormat) -> u16 {
86        1 << match f {
87            PpduFormat::Unknown => 0,
88            PpduFormat::Dsss => 1,
89            PpduFormat::NonHt => 2,
90            PpduFormat::Ht => 3,
91            PpduFormat::Vht => 4,
92            PpduFormat::VhtMu => 5,
93            PpduFormat::HeSu => 6,
94            PpduFormat::HeMu => 7,
95            PpduFormat::HeErSu => 8,
96            PpduFormat::HeTb => 9,
97            PpduFormat::Eht => 10,
98        }
99    }
100
101    /// This set plus `f`.
102    pub const fn with(self, f: PpduFormat) -> Self {
103        Self(self.0 | Self::bit(f))
104    }
105
106    /// Whether `f` is in the set. The empty set contains every format.
107    pub const fn contains(self, f: PpduFormat) -> bool {
108        self.0 == 0 || self.0 & Self::bit(f) != 0
109    }
110
111    /// Whether the set is empty (every format accepted).
112    pub const fn is_any(self) -> bool {
113        self.0 == 0
114    }
115
116    /// Whether every format in the set is an HE (802.11ax) format.
117    pub const fn only_he(self) -> bool {
118        let he = Self::bit(PpduFormat::HeSu)
119            | Self::bit(PpduFormat::HeMu)
120            | Self::bit(PpduFormat::HeErSu)
121            | Self::bit(PpduFormat::HeTb);
122        self.0 != 0 && self.0 & !he == 0
123    }
124
125    /// Whether the set excludes the legacy formats (DSSS and non-HT OFDM).
126    pub const fn excludes_legacy(self) -> bool {
127        self.0 != 0 && self.0 & (Self::bit(PpduFormat::Dsss) | Self::bit(PpduFormat::NonHt)) == 0
128    }
129}
130
131/// How the channel is sounded.
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, MaxSize)]
133#[non_exhaustive]
134pub struct StimulusParams {
135    /// Interval between soundings, microseconds.
136    pub periodicity_us: u32,
137    /// Sounding bandwidth.
138    pub bandwidth: Bandwidth,
139    /// The form a measurement report takes.
140    pub report: ReportType,
141    /// Subcarrier grouping of a [`ReportType::Grouped`] report (1 = no grouping).
142    pub ng: u8,
143    /// Bits per quantised value of a [`ReportType::Grouped`] report.
144    pub nb: u8,
145    /// Trigger-based sounding (the access point triggers responders) rather than non-TB.
146    pub trigger_based: bool,
147    /// Responders taking part. Empty means any that pairs.
148    pub responders: Vec<[u8; 6], MAX_RESPONDERS>,
149}
150
151impl StimulusParams {
152    /// Sound every `periodicity_us` at `bandwidth`, raw reports, no grouping, non-TB, any
153    /// responder.
154    pub fn new(periodicity_us: u32, bandwidth: Bandwidth) -> Self {
155        Self {
156            periodicity_us,
157            bandwidth,
158            report: ReportType::Raw,
159            ng: 1,
160            nb: 8,
161            trigger_based: false,
162            responders: Vec::new(),
163        }
164    }
165
166    /// Sounding rate in Hz implied by the periodicity, saturated to `u16`.
167    pub const fn rate_hz(&self) -> u16 {
168        if self.periodicity_us == 0 {
169            return u16::MAX;
170        }
171        let hz = 1_000_000 / self.periodicity_us;
172        if hz > u16::MAX as u32 { u16::MAX } else if hz == 0 { 1 } else { hz as u16 }
173    }
174}
175
176/// The form of a measurement report.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, MaxSize)]
178#[non_exhaustive]
179pub enum ReportType {
180    /// The full channel estimate as the receiver produced it.
181    Raw,
182    /// A grouped, quantised report ([`CsiPayload::Grouped`](super::CsiPayload::Grouped)).
183    Grouped,
184}
185
186/// Why a node could not take on a measurement setup.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188#[non_exhaustive]
189pub enum SetupError {
190    /// The mode does not sound the channel, but the setup asked it to.
191    NoStimulus,
192    /// The mode sounds the channel, and the setup's stimulus asks for something it cannot do. The
193    /// field names which parameter.
194    UnsupportedStimulus(&'static str),
195    /// The acquisition filter asks for something this node cannot select. The field names which
196    /// parameter.
197    UnsupportedAcquisition(&'static str),
198}