Skip to main content

esp_csi_rs/
model.rs

1//! The node model: the attributes that describe a node in a CSI sensing network, named in IEEE
2//! 802.11bf's terms wherever the standard has a term.
3//!
4//! The normative description is `docs/network-model.md`, included below so that the document and
5//! the types it describes cannot drift: there is one copy, and it reaches docs.rs, GitHub and every
6//! README link from the same file. No other document in the ecosystem restates it.
7//!
8//! The types in this module are that document's image. Three rules connect them:
9//!
10//! 1. The attributes are **independent** ([`Mode`], [`SessionRole`], [`SensingRole`],
11//!    [`Reporting`]). Collapsing any two loses a configuration somebody deploys.
12//! 2. **Illegal combinations are not offered.** Each mode exposes only the attributes it admits;
13//!    the fixed ones are `const` accessors with no setter to call.
14//! 3. The roles and the reporting are **computed from the mode**, never stored beside it. A second
15//!    home for a value is a second place to get it wrong.
16//!
17//! Every public enum here is `#[non_exhaustive]`, so a new mode — a negotiated IEEE 802.11bf
18//! sensing mode, say — or a new kind of reporting is a minor release, not a breaking one. Inside
19//! this crate the matches stay exhaustive, so a new variant is still a compile error everywhere it
20//! has to be handled.
21//!
22#![doc = include_str!("../../docs/network-model.md")]
23
24/// A node's part in a sensing session: IEEE 802.11bf's *sensing initiator* or *sensing
25/// responder*.
26///
27/// The initiator is the node that originates the soundings a session measures; a responder takes
28/// part in soundings an initiator set up. A node that takes part in no session — a sniffer
29/// observing traffic it neither originates nor answers, or an emitter sounding for whoever listens
30/// — has no session role, which the accessors report as `None` (an *observer*).
31///
32/// The host that starts and stops a run is **not** an initiator; it is the [`Controller`], which is
33/// never a node.
34///
35/// | Mode | Session role |
36/// |---|---|
37/// | ESP-NOW | initiator or responder (configurable) |
38/// | ESP-NOW simplex | source: initiator; peer: responder |
39/// | Wi-Fi station | initiator when it drives the measured traffic, otherwise `None` |
40/// | Wi-Fi access point | initiator |
41/// | Wi-Fi sniffer | `None` |
42/// | Emitter | `None` |
43#[cfg_attr(feature = "defmt", derive(defmt::Format))]
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45#[non_exhaustive]
46pub enum SessionRole {
47    /// IEEE 802.11bf sensing initiator: originates the soundings that constitute a session.
48    Initiator,
49    /// IEEE 802.11bf sensing responder: takes part in soundings an initiator set up.
50    Responder,
51}
52
53/// What starts and stops a run: the host tier, never a node.
54///
55/// Whatever calls [`CsiNode::run`](crate::CsiNode::run) and
56/// [`CsiNodeClient::send_stop`](crate::CsiNodeClient::send_stop) is the run's controller. In
57/// practice that is the serial console of `esp-csi-cli-rs`, the HTTP control route of
58/// `csi-webserver`, the on-device UI task of `esp-csi-litetui-rs`, or your own application.
59///
60/// Controlling a run is not initiating a session: a [`SessionRole::Initiator`] is a node that
61/// originates soundings inside the WLAN. In IEEE 802.11bf terms the controller is the layer above
62/// the MAC that requests measurements through its service interface. No node reports this role;
63/// the type exists so the crates that drive a node can name it instead of inventing a word.
64#[cfg_attr(feature = "defmt", derive(defmt::Format))]
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub struct Controller;
67
68/// Which end of a sounding a node is: IEEE 802.11bf's *sensing transmitter* and *sensing
69/// receiver*.
70///
71/// The sensing transmitter sends the PPDUs a measurement is taken from; the sensing receiver
72/// measures them. A node in a symmetric exchange does both.
73///
74/// | Mode | Sensing role |
75/// |---|---|
76/// | ESP-NOW | transmitter-receiver |
77/// | ESP-NOW simplex | source: transmitter; peer: receiver |
78/// | Wi-Fi station | transmitter-receiver when it drives the traffic, otherwise receiver |
79/// | Wi-Fi access point | receiver |
80/// | Wi-Fi sniffer | receiver |
81/// | Emitter | transmitter |
82#[cfg_attr(feature = "defmt", derive(defmt::Format))]
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84#[non_exhaustive]
85pub enum SensingRole {
86    /// Sends the PPDUs used for sensing and measures nothing.
87    Transmitter,
88    /// Measures PPDUs another node sends.
89    Receiver,
90    /// Both sends the PPDUs used for sensing and measures the ones it receives.
91    TransmitterReceiver,
92}
93
94impl SensingRole {
95    /// Whether this node measures the channel.
96    pub const fn receives(self) -> bool {
97        !matches!(self, Self::Transmitter)
98    }
99
100    /// Whether this node sends the PPDUs used for sensing.
101    pub const fn transmits(self) -> bool {
102        !matches!(self, Self::Receiver)
103    }
104}
105
106/// Where a reported measurement goes.
107#[cfg_attr(feature = "defmt", derive(defmt::Format))]
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109#[non_exhaustive]
110pub enum ReportTo {
111    /// Emitted to the host output (the serial log in the configured [`LogMode`](crate::LogMode)),
112    /// and delivered to any on-device consumer as well.
113    #[default]
114    Host,
115    /// Delivered only to on-device consumers — the callback registered with
116    /// [`set_csi_callback`](crate::set_csi_callback) or the queue behind
117    /// [`CsiNodeClient::next_csi_packet`](crate::CsiNodeClient::next_csi_packet) — and never
118    /// written to the host output.
119    Local,
120}
121
122/// Whether, and how often, a node's measurements leave it.
123///
124/// Every variant except [`None`](Self::None) delivers measurements; they differ in which ones. A
125/// node that reports nothing is not an idle node — it is one that must exist for the measurement to
126/// happen but contributes no data: the responder an initiator needs something to sound, a node that
127/// keeps traffic on the channel while another measures, or a receiver with its output switched off
128/// to separate acquisition cost from delivery cost.
129///
130/// [`ThresholdBased`](Self::ThresholdBased) is IEEE 802.11bf's threshold-based reporting: the node
131/// reports only while the channel is changing. It is useful well before 802.11bf hardware exists,
132/// because gating delivery on motion **on the device** takes the load off a USB or UART link.
133/// [`Decimate`](Self::Decimate) thins a steady stream to every *n*th measurement.
134///
135/// Whether a node reports at all is also an ESP-NOW protocol field. An initiator announces it in
136/// [`ControlPacket::initiator_reports`](crate::ControlPacket), and a responder that hears an
137/// initiator reporting [`None`](Self::None) promotes itself to `Report { to: Host }` so the pair
138/// still produces a dataset. That promotion happens at runtime and does not rewrite the node's
139/// configuration, so [`CsiNode::reporting`](crate::CsiNode::reporting) reports what was configured
140/// while [`runtime_reporting`](crate::runtime_reporting) reports what is in force.
141#[cfg_attr(feature = "defmt", derive(defmt::Format))]
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143#[non_exhaustive]
144pub enum Reporting {
145    /// Report every measurement — its own, and any reported to it by peers — to `to`.
146    Report {
147        /// Where the measurements go.
148        to: ReportTo,
149    },
150    /// Report only while the channel is moving: IEEE 802.11bf threshold-based reporting. See
151    /// [`Threshold`].
152    ThresholdBased(Threshold),
153    /// Report every *n*th measurement. `Decimate(1)` reports everything; `0` is treated as 1.
154    Decimate(u16),
155    /// Report nothing. Participates in the channel and in capture without contributing data or
156    /// paying the delivery cost.
157    None,
158}
159
160impl Reporting {
161    /// `Report { to: ReportTo::Host }`: report every measurement to the host. The default.
162    pub const HOST: Self = Self::Report { to: ReportTo::Host };
163
164    /// Whether this reports anything at all — everything except [`None`](Self::None).
165    pub const fn reports(self) -> bool {
166        !matches!(self, Self::None)
167    }
168}
169
170impl Default for Reporting {
171    fn default() -> Self {
172        Self::HOST
173    }
174}
175
176/// Parameters of [`Reporting::ThresholdBased`].
177///
178/// The node keeps a slow running average of the channel's amplitude profile over up to 32 sampled
179/// subcarriers, and scores every measurement by how far its profile's **shape** departs from that
180/// average, on a `0..=65535` scale. Both profiles are normalised by their own totals first, so the
181/// receiver's per-frame gain changes score nothing; `65535` is a profile with no overlap at all. A
182/// measurement scoring at least `level` is reported and opens a `hold_ms` window in which every
183/// measurement is reported, so a movement is captured whole rather than as its loudest frames.
184///
185/// **Calibrate `level` on your own link.** Raw 8-bit estimates carry per-frame quantisation noise,
186/// so a still room does not score zero. Measured on two ESP32-C5s at HE20 (245 subcarriers,
187/// 100 frames/s, an empty room), single-frame scores had a median of ~2700 and a 99th percentile
188/// of ~4300. Run the node with [`variation_only`](Self::variation_only) first, read the scores it
189/// reports while nothing moves, and set `level` comfortably above their high percentile; the level
190/// that separates motion from stillness has not been measured here and depends on the room.
191#[cfg_attr(feature = "defmt", derive(defmt::Format))]
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub struct Threshold {
194    /// Score at or above which a measurement is reported.
195    pub level: u16,
196    /// How long to keep reporting after a measurement crosses `level`, in milliseconds.
197    pub hold_ms: u16,
198    /// Report only the score, as a [`CsiPayload::Variation`](crate::wire::CsiPayload::Variation),
199    /// instead of the full measurement. The lightest report a node can send.
200    pub variation_only: bool,
201}
202
203impl Threshold {
204    /// Report full measurements scoring at least `level`, holding for `hold_ms`.
205    pub const fn new(level: u16, hold_ms: u16) -> Self {
206        Self {
207            level,
208            hold_ms,
209            variation_only: false,
210        }
211    }
212
213    /// Report only the score, not the measurement.
214    pub const fn variation_only(mut self) -> Self {
215        self.variation_only = true;
216        self
217    }
218}
219
220/// Which end of an asymmetric ESP-NOW simplex exchange a node is.
221///
222/// The ends are built through [`SimplexConfig::source`] and [`SimplexConfig::peer`], which take
223/// different arguments because the two ends consume different settings.
224#[cfg_attr(feature = "defmt", derive(defmt::Format))]
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226#[non_exhaustive]
227pub enum SimplexEnd {
228    /// The flooding end: an initiator and sensing transmitter that reports nothing.
229    Source,
230    /// The measuring end: a receive-only responder and sensing receiver that reports everything.
231    Peer,
232}
233
234/// Configuration for the asymmetric ESP-NOW simplex exchange.
235///
236/// One node owns all the transmit airtime and floods; the other goes receive-only after discovery
237/// and measures the flood. Leaving the channel to a single transmitter is what makes this the
238/// highest-rate pairing the crate offers.
239///
240/// **The two ends are not interchangeable, so they are not built the same way.** The source needs a
241/// PHY rate, a bandwidth and a periodicity; the peer forces no TX PHY at all — it only receives —
242/// so handing it those settings would silently ignore them. Each constructor takes only what its
243/// end consumes.
244///
245/// The session role follows the soundings, as it does everywhere else in this model: the node that
246/// floods is the [`Initiator`](SessionRole::Initiator), and the node that beacons to be found and
247/// then falls silent is the [`Responder`](SessionRole::Responder).
248pub struct SimplexConfig {
249    end: SimplexEnd,
250    inner: crate::EspNowConfig,
251}
252
253impl SimplexConfig {
254    /// The flooding end: an initiator and sensing transmitter that reports nothing.
255    ///
256    /// Listens for the peer's discovery beacon, learns its MAC, registers it as a unicast peer with
257    /// a forced PHY, then unicasts a continuous max-rate flood. It measures nothing, so its receive
258    /// path is disabled regardless of how the node is otherwise configured — a CSI rate task here
259    /// would compete for the airtime the flood exists to fill.
260    ///
261    /// Takes the full [`EspNowConfig`](crate::EspNowConfig) because this end is where channel, PHY
262    /// rate and HT40 all apply. Any session role or reporting set on that config is ignored: this
263    /// end is fixed.
264    pub fn source(config: crate::EspNowConfig) -> Self {
265        Self {
266            end: SimplexEnd::Source,
267            inner: config
268                .session(SessionRole::Initiator)
269                .reporting(Reporting::None),
270        }
271    }
272
273    /// The measuring end: a responder and sensing receiver that reports every measurement.
274    ///
275    /// Broadcasts a sparse (~1 Hz) discovery beacon until it hears a [`source`](Self::source), then
276    /// stops beaconing entirely and goes receive-only, capturing CSI from the source's flood.
277    ///
278    /// Takes only a channel, because that is all this end uses. There is no PHY rate to force — the
279    /// peer never transmits once paired — and no periodicity, because it sounds nothing.
280    pub fn peer(channel: u8) -> Self {
281        Self {
282            end: SimplexEnd::Peer,
283            inner: crate::EspNowConfig::default()
284                .with_channel(channel)
285                .session(SessionRole::Responder)
286                .reporting(Reporting::HOST),
287        }
288    }
289
290    /// Pair with a known MAC instead of discovering one. Both ends must be given the other's.
291    pub fn with_peer_mac(mut self, mac: [u8; 6]) -> Self {
292        self.inner = self.inner.with_peer_mac(mac);
293        self
294    }
295
296    /// `Initiator` for the source, `Responder` for the peer. Fixed by the end.
297    pub fn session_role(&self) -> SessionRole {
298        match self.end {
299            SimplexEnd::Source => SessionRole::Initiator,
300            SimplexEnd::Peer => SessionRole::Responder,
301        }
302    }
303
304    /// `Transmitter` for the source, `Receiver` for the peer. Fixed by the end.
305    pub fn sensing_role(&self) -> SensingRole {
306        match self.end {
307            SimplexEnd::Source => SensingRole::Transmitter,
308            SimplexEnd::Peer => SensingRole::Receiver,
309        }
310    }
311
312    /// `None` for the source, `Report { to: Host }` for the peer. Fixed by the end.
313    pub fn get_reporting(&self) -> Reporting {
314        self.inner.get_reporting()
315    }
316
317    /// Which end this is.
318    pub fn end(&self) -> SimplexEnd {
319        self.end
320    }
321
322    /// Whether this is the flooding end.
323    pub fn is_source(&self) -> bool {
324        self.end == SimplexEnd::Source
325    }
326
327    pub(crate) fn inner(&self) -> &crate::EspNowConfig {
328        &self.inner
329    }
330
331    pub(crate) fn set_channel(&mut self, ch: u8) {
332        self.inner.channel = ch;
333    }
334}
335
336/// How a node reaches the channel.
337///
338/// This is the model's extension point and it is deliberately abstract: it names the link a node
339/// uses to take part in the network, not a property of CSI measurement itself. Any link that can
340/// excite a channel and yield a channel measurement can supply a mode.
341///
342/// Each variant admits only the attribute combinations that are meaningful for that link, and it
343/// admits them through its config type rather than through a rule written down somewhere:
344///
345/// | Mode | Session role | Sensing role | Reporting |
346/// |---|---|---|---|
347/// | [`EspNow`](Self::EspNow) | initiator or responder | transmitter-receiver | any |
348/// | [`EspNowSimplex`](Self::EspNowSimplex) | source: initiator; peer: responder | source: transmitter; peer: receiver | source: none; peer: report |
349/// | [`Sniffer`](Self::Sniffer) | none (observer) | receiver | report, threshold-based or decimate — never `None` |
350/// | [`Station`](Self::Station) | initiator or none | transmitter-receiver or receiver | any |
351/// | [`AccessPoint`](Self::AccessPoint) | initiator | receiver | any |
352/// | [`Emitter`](Self::Emitter) | none | transmitter | none |
353#[non_exhaustive]
354pub enum Mode {
355    /// Connectionless symmetric exchange: auto-pairing, optional forced-PHY unicast replies, no AP
356    /// and no association. The only mode that admits a star — one initiator against many
357    /// responders.
358    EspNow(crate::EspNowConfig),
359    /// Connectionless asymmetric exchange: the source owns all transmit airtime and the peer is
360    /// receive-only after discovery. The highest CSI rate of any pairing here.
361    EspNowSimplex(SimplexConfig),
362    /// Promiscuous capture on a locked channel. The traffic is whatever is already on air, so this
363    /// is the one mode that needs no second device — and the one that pairs with an emitter.
364    Sniffer(crate::WifiSnifferConfig),
365    /// Associates to an ESP softAP or a commercial router and measures the frames it receives.
366    Station(crate::WifiStationConfig),
367    /// Self-contained softAP with DHCP; associated stations generate the uplink that is measured.
368    AccessPoint(crate::WifiApConfig),
369    /// Unassociated transmit-only sounding: force a TX PHY and loop-inject frames. Needs no peer,
370    /// no handshake and no protocol, which is what lets it compose with anything.
371    Emitter(crate::EmitterConfig),
372}
373
374impl Mode {
375    /// This node's IEEE 802.11bf session role, or `None` for a node that takes part in no session
376    /// (an observer).
377    ///
378    /// Computed, never stored: the modes that fix this attribute return a constant, and the modes
379    /// that admit a choice delegate to their config. There is no second copy of the value on
380    /// [`CsiNode`](crate::CsiNode) that could disagree with the mode it was built from.
381    pub fn session_role(&self) -> Option<SessionRole> {
382        match self {
383            Self::EspNow(c) => Some(c.session_role()),
384            Self::EspNowSimplex(c) => Some(c.session_role()),
385            Self::Sniffer(_) => crate::WifiSnifferConfig::session_role(),
386            Self::Station(c) => c.session_role(),
387            Self::AccessPoint(_) => crate::WifiApConfig::session_role(),
388            Self::Emitter(_) => crate::EmitterConfig::session_role(),
389        }
390    }
391
392    /// This node's IEEE 802.11bf sensing role: whether it sends the sensing PPDUs, measures them,
393    /// or both.
394    pub fn sensing_role(&self) -> SensingRole {
395        match self {
396            Self::EspNow(c) => c.sensing_role(),
397            Self::EspNowSimplex(c) => c.sensing_role(),
398            Self::Sniffer(_) => crate::WifiSnifferConfig::sensing_role(),
399            Self::Station(c) => c.sensing_role(),
400            Self::AccessPoint(_) => crate::WifiApConfig::sensing_role(),
401            Self::Emitter(_) => crate::EmitterConfig::sensing_role(),
402        }
403    }
404
405    /// Whether, and how often, this node's measurements leave it, **as configured**. See
406    /// [`runtime_reporting`](crate::runtime_reporting) for the value in force.
407    pub fn reporting(&self) -> Reporting {
408        match self {
409            Self::EspNow(c) => c.get_reporting(),
410            Self::EspNowSimplex(c) => c.get_reporting(),
411            Self::Sniffer(c) => c.get_reporting(),
412            Self::Station(c) => c.get_reporting(),
413            Self::AccessPoint(c) => c.get_reporting(),
414            Self::Emitter(_) => crate::EmitterConfig::get_reporting(),
415        }
416    }
417}
418
419/// What a [`RadioProfile`](crate::RadioProfile) is told about the node it is configuring.
420///
421/// A view rather than the mode itself, and `#[non_exhaustive]` on purpose. Profiles live
422/// out-of-tree, so every change to what they are handed is a breaking change for someone; passing a
423/// struct means the node can start offering a profile more of its configuration or its hardware
424/// without changing the trait's signature again.
425///
426/// [`Mode`] stays exhaustive inside this crate, because there a new mode *should* be a compile
427/// error in the dispatch that has to handle it.
428#[non_exhaustive]
429pub struct NodeView<'a> {
430    mode: &'a Mode,
431}
432
433impl<'a> NodeView<'a> {
434    pub(crate) fn new(mode: &'a Mode) -> Self {
435        Self { mode }
436    }
437
438    /// How the node reaches the channel.
439    pub fn mode(&self) -> &Mode {
440        self.mode
441    }
442
443    /// The node's session role, or `None` for an observer.
444    pub fn session_role(&self) -> Option<SessionRole> {
445        self.mode.session_role()
446    }
447
448    /// The node's sensing role.
449    pub fn sensing_role(&self) -> SensingRole {
450        self.mode.sensing_role()
451    }
452
453    /// Whether the node's measurements leave it, as configured.
454    pub fn reporting(&self) -> Reporting {
455        self.mode.reporting()
456    }
457}