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}