esp_csi_rs/node.rs
1//! The [`CsiNode`] orchestrator and the Wi-Fi mode configs.
2//!
3//! [`CsiNode`] holds one [`Mode`] and one [`MeasurementSetup`], and its
4//! `run` / `run_duration` wire up Wi-Fi, CSI and the mode's tasks. This module also owns the
5//! configs of the three Wi-Fi modes ([`WifiSnifferConfig`], [`WifiStationConfig`],
6//! [`WifiApConfig`]), the hardware bundle ([`NodeHardware`]), the TX/RX toggles ([`IOTaskConfig`]),
7//! the shared stop signal and the per-run lifecycle helpers. The emitter's config is
8//! [`EmitterConfig`] and the ESP-NOW modes' is [`EspNowConfig`](crate::EspNowConfig).
9//!
10//! A node is described by the attributes in [`crate::model`]: its session role, sensing role and
11//! reporting are read back from the mode, never stored beside it. Each mode's config exposes only
12//! the attributes that mode admits, so a node that the model rules out cannot be built.
13
14#[cfg(any(feature = "async-print", feature = "auto"))]
15use embassy_time::with_timeout;
16
17use embassy_futures::join::{join, join3};
18use embassy_futures::select::{Either, select};
19use embassy_time::{Duration, Timer};
20use enumset::EnumSet;
21#[cfg(feature = "esp32c5")]
22use esp_radio::wifi::BandMode;
23use esp_radio::wifi::sta::StationConfig;
24use esp_radio::wifi::{Protocol, Protocols, SecondaryChannel, WifiController};
25
26use crate::radio::RadioInterfaces;
27
28use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
29use embassy_sync::signal::Signal;
30use portable_atomic::Ordering;
31
32use crate::wifi::ap::{ap_init, run_ap};
33use crate::wifi::sta::{run_sta_connect, sta_init};
34use crate::CsiConfig;
35use crate::MeasurementSetup;
36use crate::emitter::{EmitterConfig, run_emitter};
37use crate::initiator::esp_now::run_esp_now_initiator;
38use crate::initiator::esp_now_simplex::run_esp_now_simplex_source;
39use crate::model::{Mode, NodeView, SimplexConfig};
40use crate::responder::esp_now::run_esp_now_responder;
41use crate::responder::esp_now_simplex::run_esp_now_simplex_peer;
42use crate::profile::{RadioProfile, StandardProfile};
43
44use crate::csi::delivery::{
45 CsiNodeClient, build_csi_config, run_process_csi_packet, set_csi,
46};
47use crate::log_ln;
48use crate::radio::{apply_ht40_channel, idle_station_for_espnow, suppress_espnow_rx};
49#[cfg(feature = "esp32c5")]
50use crate::radio::{apply_band_auto, apply_band_for_channel};
51use crate::stats::set_seq_drop_detection;
52
53// Signals
54pub(crate) static STOP_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
55
56/// Per-mutation radio-quiesce delay on C5 dual-band bring-up.
57///
58/// The C5 Wi-Fi ISR can wedge if a MAC interrupt fires mid-reconfiguration
59/// (`set_protocols` / `set_config` STA restart / `set_csi` / `set_channel`),
60/// tripping the interrupt watchdog (`handle_interrupts` backtrace at boot) or
61/// hard-freezing before any task runs. Dropping the ESP-NOW receive callback at
62/// bring-up (see [`crate::radio::suppress_espnow_rx`]) already shrinks that
63/// window; inserting a short settle *between* the mutations lets the MAC drain any
64/// pending interrupt before the next driver call, shrinking it further. This is a
65/// probabilistic mitigation, not a guarantee — the radio restart still races the
66/// MAC IRQ — so keeping the air quiet during a node's bring-up remains the most
67/// effective measure.
68#[cfg(feature = "esp32c5")]
69const C5_RADIO_SETTLE_MS: u64 = 60;
70
71/// Await a brief radio-settle delay on C5; no-op on every other chip.
72/// See [`C5_RADIO_SETTLE_MS`].
73async fn c5_radio_settle() {
74 #[cfg(feature = "esp32c5")]
75 Timer::after(Duration::from_millis(C5_RADIO_SETTLE_MS)).await;
76}
77
78async fn csi_data_collection(client: &mut CsiNodeClient, duration: u64) {
79 #[cfg(any(feature = "async-print", feature = "auto"))]
80 if crate::logging::logging::is_async_logging_active() {
81 with_timeout(Duration::from_secs(duration), async {
82 loop {
83 client.print_csi_w_metadata().await;
84 }
85 })
86 .await
87 .unwrap_err();
88 client.send_stop().await;
89 return;
90 }
91
92 #[cfg(not(any(feature = "async-print", feature = "auto")))]
93 {
94 let _ = client;
95 }
96 Timer::after(Duration::from_secs(duration)).await;
97 client.send_stop().await;
98}
99
100async fn wait_for_stop() {
101 STOP_SIGNAL.wait().await;
102 STOP_SIGNAL.signal(());
103}
104
105async fn stop_after_duration(duration: u64) {
106 match select(
107 STOP_SIGNAL.wait(),
108 Timer::after(Duration::from_secs(duration)),
109 )
110 .await
111 {
112 Either::First(_) | Either::Second(_) => STOP_SIGNAL.signal(()),
113 }
114}
115
116/// Configuration for Wi-Fi Promiscuous Sniffer mode.
117///
118/// Construct with `WifiSnifferConfig::default()` then chain `with_channel`
119/// to override defaults.
120#[derive(Debug, Clone)]
121pub struct WifiSnifferConfig {
122 /// Optional MAC source filter (reserved — not yet wired into the
123 /// promiscuous filter setup).
124 #[allow(dead_code)]
125 mac_filter: Option<[u8; 6]>,
126 channel: u8,
127 reporting: crate::Reporting,
128}
129
130impl WifiSnifferConfig {
131 /// A sniffer takes part in no sensing session: it observes traffic it neither originates nor
132 /// answers, so its session role is `None` (an observer). There is no setter for this.
133 pub const fn session_role() -> Option<crate::SessionRole> {
134 None
135 }
136
137 /// A sniffer is always a [`Receiver`](crate::SensingRole::Receiver): it never transmits.
138 /// There is no setter for this.
139 pub const fn sensing_role() -> crate::SensingRole {
140 crate::SensingRole::Receiver
141 }
142
143 /// How this sniffer reports: [`Reporting::HOST`](crate::Reporting::HOST) unless set with
144 /// [`with_report_to`](Self::with_report_to), [`with_threshold`](Self::with_threshold) or
145 /// [`with_decimation`](Self::with_decimation). Never [`None`](crate::Reporting::None): a sniffer
146 /// that does not report observes nothing, so there is no way to configure one.
147 pub fn get_reporting(&self) -> crate::Reporting {
148 self.reporting
149 }
150
151 /// Report every measurement to `to`.
152 pub fn with_report_to(mut self, to: crate::ReportTo) -> Self {
153 self.reporting = crate::Reporting::Report { to };
154 self
155 }
156
157 /// Report only while the channel is moving (threshold-based reporting). See
158 /// [`Threshold`](crate::Threshold).
159 pub fn with_threshold(mut self, threshold: crate::Threshold) -> Self {
160 self.reporting = crate::Reporting::ThresholdBased(threshold);
161 self
162 }
163
164 /// Report every `n`th measurement.
165 pub fn with_decimation(mut self, n: u16) -> Self {
166 self.reporting = crate::Reporting::Decimate(n.max(1));
167 self
168 }
169}
170
171impl Default for WifiSnifferConfig {
172 fn default() -> Self {
173 Self {
174 mac_filter: None,
175 reporting: crate::Reporting::HOST,
176 // Channel 1 is typically less congested than 11 in dense
177 // residential / office environments.
178 channel: 1,
179 }
180 }
181}
182
183impl WifiSnifferConfig {
184 /// Override the channel the sniffer locks to.
185 ///
186 /// Must be a valid IEEE 802.11 **primary** channel number — pass the
187 /// primary, not the wider-channel center notation that routers
188 /// commonly display:
189 ///
190 /// - **2.4 GHz**: `1`–`14`
191 /// - **5 GHz**: `36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112,
192 /// 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165`
193 /// (regulatory-domain dependent — some restricted by `country_info`)
194 ///
195 /// Center-channel labels (`38, 46, ...` for HT40; `42, 58, 106, ...`
196 /// for VHT80; `50, 114` for VHT160; `154` for the 153/157 HT40 pair)
197 /// are **not** accepted here — `esp_wifi_set_channel` panics with
198 /// `InvalidArguments`. For example, a router showing "channel 154"
199 /// is using primary `153` (or `157`); pass that primary and the chip
200 /// will sniff the full 40 MHz block automatically per 802.11.
201 ///
202 /// On dual-band chips (currently ESP32-C5), the band is auto-selected
203 /// from the channel number — channels `>= 36` switch the radio to
204 /// `BandMode::_5G`, otherwise `BandMode::_2_4G`. On 2.4-GHz-only
205 /// chips, passing any 5 GHz channel will fail at runtime.
206 pub fn with_channel(mut self, channel: u8) -> Self {
207 self.channel = channel;
208 self
209 }
210
211 /// Configured channel (2.4 GHz: 1–14, 5 GHz: 36–165).
212 pub fn channel(&self) -> u8 {
213 self.channel
214 }
215}
216
217/// Configuration for Wi-Fi Station mode.
218#[derive(Debug, Clone)]
219pub struct WifiStationConfig {
220 /// Underlying esp-radio station configuration (SSID, auth, etc.).
221 pub client_config: StationConfig,
222 /// Primary channel of the target AP. On dual-band ESP32-C5 this selects
223 /// 2.4 vs 5 GHz (`set_band_mode`) before scan/association.
224 pub channel_hint: Option<u8>,
225 /// Whether this station drives the measured traffic. A station that pings its gateway to keep
226 /// the link busy originates the soundings and so is the session's
227 /// [`Initiator`](crate::SessionRole::Initiator); one that merely measures an already-busy link
228 /// takes part in no session it set up and has no session role (`None`).
229 session: Option<crate::SessionRole>,
230 /// Whether, and how often, this node reports the CSI it captures.
231 reporting: crate::Reporting,
232}
233
234impl WifiStationConfig {
235 /// Build a station config from esp-radio's [`StationConfig`].
236 pub fn new(client_config: StationConfig) -> Self {
237 Self {
238 client_config,
239 channel_hint: None,
240 session: Some(crate::SessionRole::Initiator),
241 reporting: crate::Reporting::HOST,
242 }
243 }
244
245 /// Set whether this station drives the measured traffic: `Some(Initiator)` when its gateway
246 /// ping (the [periodicity](crate::MeasurementSetup::periodicity_hz)) is the traffic being
247 /// measured, `None` when it passively measures a link something else keeps busy. Defaults to
248 /// `Some(Initiator)`.
249 ///
250 /// The value describes the node; the ping itself runs whenever a periodicity is set.
251 pub fn session(mut self, role: Option<crate::SessionRole>) -> Self {
252 self.session = role;
253 self
254 }
255
256 /// Set whether, and how often, this node reports its CSI. Defaults to
257 /// [`Reporting::HOST`](crate::Reporting::HOST).
258 pub fn reporting(mut self, reporting: crate::Reporting) -> Self {
259 self.reporting = reporting;
260 self
261 }
262
263 /// Whether this station drives the measured traffic (`Some(Initiator)`) or not (`None`).
264 pub fn session_role(&self) -> Option<crate::SessionRole> {
265 self.session
266 }
267
268 /// [`TransmitterReceiver`](crate::SensingRole::TransmitterReceiver) for a station that drives
269 /// the traffic it measures, [`Receiver`](crate::SensingRole::Receiver) otherwise.
270 pub fn sensing_role(&self) -> crate::SensingRole {
271 match self.session {
272 Some(crate::SessionRole::Initiator) => crate::SensingRole::TransmitterReceiver,
273 _ => crate::SensingRole::Receiver,
274 }
275 }
276
277 /// Whether, and how often, this node reports the CSI it captures, as configured.
278 pub fn get_reporting(&self) -> crate::Reporting {
279 self.reporting
280 }
281
282 /// Pin the radio band from the AP's primary channel (C5 dual-band only).
283 pub fn with_channel_hint(mut self, channel: u8) -> Self {
284 self.channel_hint = Some(channel);
285 self
286 }
287}
288
289#[cfg(feature = "defmt")]
290impl defmt::Format for WifiStationConfig {
291 fn format(&self, fmt: defmt::Formatter<'_>) {
292 defmt::write!(fmt, "WifiStationConfig {{ client_config: <opaque> }}");
293 }
294}
295
296/// Configuration for the self-contained softAP mode.
297///
298/// Wraps esp-radio's [`AccessPointConfig`] (SSID, channel, auth, secondary
299/// channel) and the static IPv4 addressing used by the built-in DHCP server.
300/// The AP hands associating stations addresses from a lease pool in the AP's /24
301/// subnet with the gateway set to the AP itself.
302///
303/// `channel`/`secondary_channel` are duplicated here because esp-radio's
304/// `AccessPointConfig` fields are not externally readable; [`CsiNode`] needs them
305/// for band/HT40 setup.
306///
307/// [`AccessPointConfig`]: esp_radio::wifi::ap::AccessPointConfig
308pub struct WifiApConfig {
309 /// Underlying esp-radio access-point configuration.
310 pub ap_config: esp_radio::wifi::ap::AccessPointConfig,
311 /// Primary channel the AP operates on (mirror of `ap_config`'s channel).
312 pub channel: u8,
313 /// Optional HT40 secondary channel (mirror of `ap_config`'s secondary).
314 pub secondary_channel: Option<SecondaryChannel>,
315 /// AP's static IPv4 address; also the gateway and DHCP server identifier.
316 pub ap_ipv4: core::net::Ipv4Addr,
317 /// First IPv4 address in the DHCP lease pool (typically `.2`).
318 pub lease_ipv4: core::net::Ipv4Addr,
319 /// Number of consecutive lease addresses starting at [`Self::lease_ipv4`]
320 /// (e.g. `3` → `.2`, `.3`, `.4`). Default `1` preserves the original
321 /// single-client behaviour.
322 pub lease_count: u8,
323 /// Whether to run the built-in DHCP server. When `false`, the AP only starts
324 /// + collects CSI (clients must self-assign IPs).
325 pub serve_dhcp: bool,
326 /// Whether this node reports the CSI it captures. The access point's roles are not
327 /// configurable — it is the session's initiator by construction — but whether its measurements
328 /// leave it is.
329 reporting: crate::Reporting,
330 /// When `true`, every flood tick fires one unicast frame back-to-back to
331 /// **all** active leases instead of advancing one lease per tick (round-robin).
332 /// All associated stations then receive their downlink PPDU within tens of
333 /// microseconds of each other — temporally-synchronized multi-receiver CSI —
334 /// instead of being spread across the whole tick interval.
335 ///
336 /// This is the workable path to synchronized multi-receiver CSI. A single
337 /// group-addressed broadcast frame does *not* work on an ESP32 softAP:
338 /// broadcast/multicast is DTIM-buffered, dropped under a high-rate flood, and
339 /// only ever sent at the legacy basic rate — so it mostly never leaves the
340 /// radio and never honours a forced high-throughput TX rate. Only unicast
341 /// transmits immediately and honours the configured TX rate, so N unicast
342 /// frames per tick keep near-simultaneous arrival across receivers. Stations
343 /// must be **associated** — an unassociated receiver does not reliably
344 /// produce CSI from overheard frames.
345 ///
346 /// Per-receiver rate is the configured ping rate; total offered rate is
347 /// `rate * lease_count`, so lower the rate if airtime saturates. Default
348 /// `false` preserves per-lease round-robin. Set by [`Self::with_sync_burst`].
349 pub sync_burst: bool,
350}
351
352impl WifiApConfig {
353 /// Create a config from an [`AccessPointConfig`], its primary `channel`, and
354 /// optional HT40 `secondary` channel. Defaults the AP to `192.168.13.1/24`,
355 /// leases `192.168.13.2`, and enables the DHCP server.
356 ///
357 /// [`AccessPointConfig`]: esp_radio::wifi::ap::AccessPointConfig
358 pub fn new(
359 ap_config: esp_radio::wifi::ap::AccessPointConfig,
360 channel: u8,
361 secondary: Option<SecondaryChannel>,
362 ) -> Self {
363 Self {
364 ap_config,
365 channel,
366 secondary_channel: secondary,
367 ap_ipv4: core::net::Ipv4Addr::new(192, 168, 13, 1),
368 lease_ipv4: core::net::Ipv4Addr::new(192, 168, 13, 2),
369 lease_count: 1,
370 serve_dhcp: true,
371 sync_burst: false,
372 reporting: crate::Reporting::HOST,
373 }
374 }
375
376 /// An access point is always the session's [`Initiator`](crate::SessionRole::Initiator): its
377 /// beacons and the downlink it drives to its stations originate the session, so there is no
378 /// setter for this.
379 pub const fn session_role() -> Option<crate::SessionRole> {
380 Some(crate::SessionRole::Initiator)
381 }
382
383 /// An access point is a [`Receiver`](crate::SensingRole::Receiver): it measures the uplink its
384 /// associated stations send. There is no setter for this.
385 pub const fn sensing_role() -> crate::SensingRole {
386 crate::SensingRole::Receiver
387 }
388
389 /// Set whether, and how often, this node reports its CSI. Defaults to
390 /// [`Reporting::HOST`](crate::Reporting::HOST).
391 pub fn reporting(mut self, reporting: crate::Reporting) -> Self {
392 self.reporting = reporting;
393 self
394 }
395
396 /// Whether, and how often, this node reports the CSI it captures, as configured.
397 pub fn get_reporting(&self) -> crate::Reporting {
398 self.reporting
399 }
400
401 /// Override the AP/lease IPv4 addresses (must share a /24).
402 pub fn with_ipv4(mut self, ap: core::net::Ipv4Addr, lease: core::net::Ipv4Addr) -> Self {
403 self.ap_ipv4 = ap;
404 self.lease_ipv4 = lease;
405 self
406 }
407
408 /// Set the DHCP lease pool size (consecutive addresses from `lease_ipv4`).
409 pub fn with_lease_pool(mut self, count: u8) -> Self {
410 self.lease_count = count.max(1);
411 self
412 }
413
414 /// Lease address at `index` (`0` = `lease_ipv4`, `1` = next host, …).
415 pub fn lease_ip_at(&self, index: u8) -> core::net::Ipv4Addr {
416 let idx = index.min(self.lease_count.saturating_sub(1));
417 let mut oct = self.lease_ipv4.octets();
418 oct[3] = oct[3].saturating_add(idx);
419 core::net::Ipv4Addr::from(oct)
420 }
421
422 /// All configured pool addresses (up to [`Self::lease_count`]).
423 pub fn lease_pool(&self) -> heapless::Vec<core::net::Ipv4Addr, 8> {
424 let mut v = heapless::Vec::new();
425 for i in 0..self.lease_count.min(8) {
426 let _ = v.push(self.lease_ip_at(i));
427 }
428 v
429 }
430
431 /// Enable or disable the built-in DHCP server (default enabled).
432 pub fn with_dhcp_server(mut self, enabled: bool) -> Self {
433 self.serve_dhcp = enabled;
434 self
435 }
436
437 /// Fire one unicast frame back-to-back to every active lease per flood tick,
438 /// instead of unicasting round-robin (one lease per tick).
439 ///
440 /// All associated stations then receive their downlink PPDU within
441 /// microseconds of each other — synchronized multi-receiver CSI without the
442 /// round-robin spread. This is the workable substitute for a single broadcast
443 /// PPDU, which an ESP32 softAP can't reliably deliver (see [`Self::sync_burst`]).
444 /// Keep the DHCP server / lease pool enabled so stations associate as genuine
445 /// BSS members; only the per-tick transmit pattern changes.
446 pub fn with_sync_burst(mut self, enabled: bool) -> Self {
447 self.sync_burst = enabled;
448 self
449 }
450
451 /// Configured primary channel.
452 pub fn channel(&self) -> u8 {
453 self.channel
454 }
455
456 /// Configured HT40 secondary channel, or `None` for HT20.
457 pub fn secondary_channel(&self) -> Option<SecondaryChannel> {
458 self.secondary_channel
459 }
460}
461
462#[cfg(feature = "defmt")]
463impl defmt::Format for WifiApConfig {
464 fn format(&self, fmt: defmt::Formatter<'_>) {
465 defmt::write!(fmt, "WifiApConfig {{ ap_config: <opaque> }}");
466 }
467}
468
469/// Placeholder for the initiator driver's unused `_mac_addr` parameter. See its call site.
470const UNSET_MAC: [u8; 6] = [0; 6];
471
472/// Controls whether TX and RX tasks are active for a node.
473///
474/// Defaults to both TX and RX enabled.
475#[derive(Debug, Clone, Copy, PartialEq, Eq)]
476pub struct IOTaskConfig {
477 /// Enable transmit-side task work for the selected operation mode.
478 pub tx_enabled: bool,
479 /// Enable receive/process-side task work for the selected operation mode.
480 pub rx_enabled: bool,
481}
482
483impl IOTaskConfig {
484 /// Create a task configuration with explicit TX/RX state.
485 pub const fn new(tx_enabled: bool, rx_enabled: bool) -> Self {
486 Self {
487 tx_enabled,
488 rx_enabled,
489 }
490 }
491}
492
493impl Default for IOTaskConfig {
494 fn default() -> Self {
495 Self::new(true, true)
496 }
497}
498
499/// Hardware handles required to operate a node in any mode.
500pub struct NodeHardware<'a> {
501 interfaces: RadioInterfaces,
502 controller: &'a mut WifiController<'static>,
503}
504
505impl<'a> NodeHardware<'a> {
506 /// Create a hardware bundle from the `WifiController`.
507 ///
508 /// Claims the station and access-point interfaces, the sniffer and ESP-NOW from the controller.
509 /// Build one per controller: the interfaces are singletons, so a second bundle panics.
510 pub fn new(controller: &'a mut WifiController<'static>) -> Self {
511 Self {
512 interfaces: RadioInterfaces::claim(controller),
513 controller,
514 }
515 }
516}
517
518pub(crate) fn reset_globals() {
519 // Close all CSI delivery gates so any late-firing WiFi callback runs
520 // are no-ops. The CSI callback stays registered with esp-radio after stop
521 // (the radio itself is still up), but with the gates closed the callback
522 // short-circuits before it touches the log channel or the user's callback.
523 // Without this, a receiver keeps emitting CSI lines on the serial port
524 // well after `send_stop()`.
525 //
526 // The statistics counters are deliberately NOT cleared here. This runs at the END of
527 // `run_inner`, so clearing them destroyed the run's numbers at the moment the run finished —
528 // every post-collection `show-stats` on the classic AP/STA path reported `RX Total Packets: 0`
529 // however many frames the callback had counted, which reads as "the radio received nothing"
530 // when the truth was "the radio received plenty and the log path could not keep up". The
531 // counters are the only evidence a user has that captured CSI was dropped rather than never
532 // captured, and this was throwing that evidence away.
533 //
534 // `stats::reset` now runs at the START of a run instead (see `run_inner`), which is both what
535 // the README already documents ("counters reset on the start of each new `start` collection")
536 // and what an out-of-tree run loop does via `stats_begin_run`.
537 crate::csi::delivery::reset();
538}
539
540/// Primary orchestration object for a CSI node.
541///
542/// Construct with a per-mode constructor — [`CsiNode::sniffer`], [`CsiNode::station`],
543/// [`CsiNode::access_point`], [`CsiNode::emitter`], [`CsiNode::esp_now`],
544/// [`CsiNode::esp_now_simplex_source`] or [`CsiNode::esp_now_simplex_peer`] — or with
545/// [`CsiNode::new`] and a [`Mode`]. Every constructor except the emitter's takes a
546/// [`MeasurementSetup`] (periodicity, bandwidth, acquisition); configure an optional protocol,
547/// then call `run()`.
548pub struct CsiNode<'a> {
549 /// How this node reaches the channel. The node's roles and reporting are read back from it
550 /// rather than stored alongside it — see [`Mode`].
551 mode: Mode,
552 io_tasks: IOTaskConfig,
553 /// Periodicity, bandwidth, acquisition and setup id.
554 setup: MeasurementSetup,
555 hardware: NodeHardware<'a>,
556 protocol: Option<Protocol>,
557 /// ICMP flood sends unsolicited echo replies (one-directional traffic)
558 /// instead of echo requests. See [`CsiNode::set_flood_unsolicited_reply`].
559 flood_unsolicited_reply: bool,
560 /// Pluggable Wi-Fi bring-up back-end. Defaults to [`StandardProfile`];
561 /// override with [`CsiNode::set_radio_profile`].
562 profile: &'static dyn RadioProfile,
563}
564
565impl<'a> CsiNode<'a> {
566 /// Create a node that reaches the channel through `mode`, measuring under `setup`.
567 ///
568 /// The node's session role, sensing role and reporting come from the mode's config — see the
569 /// per-mode constructors below, which are the ergonomic way in.
570 pub fn new(mode: Mode, setup: MeasurementSetup, hardware: NodeHardware<'a>) -> Self {
571 Self {
572 mode,
573 io_tasks: IOTaskConfig::default(),
574 setup,
575 hardware,
576 protocol: None,
577 flood_unsolicited_reply: false,
578 profile: &StandardProfile,
579 }
580 }
581
582 /// A node in the symmetric ESP-NOW exchange. Initiator or responder, reporting or not — set
583 /// both on the [`EspNowConfig`](crate::EspNowConfig); every combination is meaningful. The
584 /// setup's periodicity is the initiator's control-packet rate and the responder's reply
585 /// pacing.
586 pub fn esp_now(
587 config: crate::EspNowConfig,
588 setup: MeasurementSetup,
589 hardware: NodeHardware<'a>,
590 ) -> Self {
591 Self::new(Mode::EspNow(config), setup, hardware)
592 }
593
594 /// The flooding end of the asymmetric ESP-NOW exchange: an **initiator and sensing transmitter
595 /// that reports nothing**. It owns all the transmit airtime and measures nothing, so the
596 /// setup's acquisition is ignored; its periodicity paces the flood.
597 pub fn esp_now_simplex_source(
598 config: crate::EspNowConfig,
599 setup: MeasurementSetup,
600 hardware: NodeHardware<'a>,
601 ) -> Self {
602 Self::new(
603 Mode::EspNowSimplex(SimplexConfig::source(config)),
604 setup,
605 hardware,
606 )
607 }
608
609 /// The measuring end of the asymmetric ESP-NOW exchange: a **responder and sensing receiver
610 /// that reports every measurement**. It beacons until it is found, then goes receive-only; it
611 /// sounds nothing, so the setup's periodicity is ignored.
612 pub fn esp_now_simplex_peer(
613 channel: u8,
614 setup: MeasurementSetup,
615 hardware: NodeHardware<'a>,
616 ) -> Self {
617 Self::new(
618 Mode::EspNowSimplex(SimplexConfig::peer(channel)),
619 setup,
620 hardware,
621 )
622 }
623
624 /// A promiscuous sniffer: an **observer** (no session role) and sensing receiver, always. It
625 /// never transmits, and a sniffer that does not report observes nothing. It sounds nothing, so
626 /// the setup's periodicity is ignored.
627 pub fn sniffer(
628 config: WifiSnifferConfig,
629 setup: MeasurementSetup,
630 hardware: NodeHardware<'a>,
631 ) -> Self {
632 Self::new(Mode::Sniffer(config), setup, hardware)
633 }
634
635 /// A Wi-Fi station associated to an access point or a commercial router. The setup's
636 /// periodicity is its gateway ping rate; it is the session's initiator when that ping is the
637 /// traffic being measured (see [`WifiStationConfig::session`]).
638 pub fn station(
639 config: WifiStationConfig,
640 setup: MeasurementSetup,
641 hardware: NodeHardware<'a>,
642 ) -> Self {
643 Self::new(Mode::Station(config), setup, hardware)
644 }
645
646 /// A self-contained softAP with DHCP. Always the session's **initiator** and a sensing
647 /// receiver; reporting is configurable. The setup's periodicity is its downlink flood rate.
648 pub fn access_point(
649 config: WifiApConfig,
650 setup: MeasurementSetup,
651 hardware: NodeHardware<'a>,
652 ) -> Self {
653 Self::new(Mode::AccessPoint(config), setup, hardware)
654 }
655
656 /// An unassociated transmit-only sounding node: a **sensing transmitter that reports nothing**,
657 /// with no session role, always. It measures nothing and takes its period from the
658 /// [`EmitterConfig`], so it takes no measurement setup.
659 pub fn emitter(config: EmitterConfig, hardware: NodeHardware<'a>) -> Self {
660 Self::new(Mode::Emitter(config), MeasurementSetup::new(), hardware)
661 }
662
663 /// How this node reaches the channel.
664 pub fn mode(&self) -> &Mode {
665 &self.mode
666 }
667
668 /// The node's IEEE 802.11bf session role, or `None` for an observer. Computed from the mode.
669 pub fn session_role(&self) -> Option<crate::SessionRole> {
670 self.mode.session_role()
671 }
672
673 /// The node's IEEE 802.11bf sensing role. Computed from the mode.
674 pub fn sensing_role(&self) -> crate::SensingRole {
675 self.mode.sensing_role()
676 }
677
678 /// Whether, and how often, this node's measurements leave it, **as configured**. A responder
679 /// paired with a silent initiator promotes itself mid-run without rewriting its configuration,
680 /// so this and [`runtime_reporting`](crate::runtime_reporting) can legitimately differ.
681 pub fn reporting(&self) -> crate::Reporting {
682 self.mode.reporting()
683 }
684
685 /// The node's measurement setup.
686 pub fn measurement_setup(&self) -> &MeasurementSetup {
687 &self.setup
688 }
689
690 /// If this is an emitter, return its configuration.
691 pub fn get_emitter_config(&self) -> Option<&EmitterConfig> {
692 match &self.mode {
693 Mode::Emitter(config) => Some(config),
694 _ => None,
695 }
696 }
697
698 /// Take on an IEEE 802.11bf-style [`MeasurementSetup`](crate::wire::MeasurementSetup).
699 ///
700 /// The setup is mapped onto what this mode actually does, and refused — with nothing changed —
701 /// if any parameter cannot be honoured:
702 ///
703 /// - `id` is stamped on every controlled sounding's
704 /// [`Stimulus`](crate::wire::Stimulus::Controlled); each ESP-NOW control frame is one
705 /// measurement instance.
706 /// - `acquisition.channel` replaces the mode's channel (the station's channel hint).
707 /// - `acquisition.transmitter` becomes the CSI peer filter ([`crate::set_csi_peer_filter`]).
708 /// - `acquisition.ppdu_formats`: every format, HE only (ESP32-C5/C6 HE-LTF acquisition), or
709 /// no legacy formats. Other subsets are refused.
710 /// - `acquisition.bandwidth` and `stimulus.bandwidth` must match the mode's configured width.
711 /// - `stimulus.periodicity_us` becomes the node's
712 /// [periodicity](MeasurementSetup::periodicity_hz) — the ESP-NOW initiator's control-packet
713 /// rate, the station's or access point's ping rate — or the emitter's frame period. A mode
714 /// that does not sound the channel (a sniffer, an ESP-NOW responder or simplex peer) refuses
715 /// any stimulus.
716 /// - `stimulus.responders`: none, or exactly one, which becomes the CSI peer filter.
717 /// - Grouped reports, `ng`/`nb` other than 1/8, and trigger-based sounding are refused: the
718 /// radio produces raw estimates from non-TB soundings only.
719 pub fn apply_measurement_setup(
720 &mut self,
721 setup: &crate::wire::MeasurementSetup,
722 ) -> Result<(), crate::wire::SetupError> {
723 use crate::wire::{ReportType, SetupError};
724
725 let width = self.configured_bandwidth();
726 let sounds = match &self.mode {
727 Mode::EspNow(c) => c.session_role() == crate::SessionRole::Initiator,
728 Mode::EspNowSimplex(c) => c.is_source(),
729 Mode::Station(c) => c.session_role() == Some(crate::SessionRole::Initiator),
730 Mode::AccessPoint(_) | Mode::Emitter(_) => true,
731 Mode::Sniffer(_) => false,
732 };
733
734 // Validate everything before changing anything.
735 let acq = &setup.acquisition;
736 if let (Some(want), Some(have)) = (acq.bandwidth, width)
737 && want != have
738 {
739 return Err(SetupError::UnsupportedAcquisition("bandwidth"));
740 }
741 let formats = acq.ppdu_formats;
742 let he_only = formats.only_he();
743 if he_only && !cfg!(any(feature = "esp32c5", feature = "esp32c6")) {
744 return Err(SetupError::UnsupportedAcquisition("ppdu_formats"));
745 }
746 if !formats.is_any() && !he_only && !formats.excludes_legacy() {
747 return Err(SetupError::UnsupportedAcquisition("ppdu_formats"));
748 }
749 let mut filter = acq.transmitter;
750 let mut rate = None;
751 if let Some(st) = &setup.stimulus {
752 if !sounds {
753 return Err(SetupError::NoStimulus);
754 }
755 if st.report != ReportType::Raw {
756 return Err(SetupError::UnsupportedStimulus("report"));
757 }
758 if st.ng != 1 || st.nb != 8 {
759 return Err(SetupError::UnsupportedStimulus("ng/nb"));
760 }
761 if st.trigger_based {
762 return Err(SetupError::UnsupportedStimulus("trigger_based"));
763 }
764 if width.is_some_and(|w| w != st.bandwidth) {
765 return Err(SetupError::UnsupportedStimulus("bandwidth"));
766 }
767 match st.responders.as_slice() {
768 [] => {}
769 [one] => match filter {
770 Some(t) if t != *one => {
771 return Err(SetupError::UnsupportedStimulus("responders"));
772 }
773 _ => filter = Some(*one),
774 },
775 _ => return Err(SetupError::UnsupportedStimulus("responders")),
776 }
777 rate = Some(st.rate_hz());
778 }
779
780 // Apply.
781 if let Some(ch) = acq.channel {
782 self.set_mode_channel(ch);
783 }
784 if let Some(mac) = filter {
785 crate::set_csi_peer_filter(mac);
786 }
787 if he_only {
788 #[cfg(any(feature = "esp32c5", feature = "esp32c6"))]
789 {
790 self.setup.set_acquisition(Some(CsiConfig::he20()));
791 }
792 } else if formats.excludes_legacy() {
793 #[cfg(any(feature = "esp32c5", feature = "esp32c6"))]
794 {
795 let mut c = self.setup.get_acquisition().cloned().unwrap_or_default();
796 c.acquire_csi_legacy = 0;
797 #[cfg(feature = "esp32c5")]
798 {
799 c.acquire_csi_force_lltf = false;
800 }
801 self.setup.set_acquisition(Some(c));
802 }
803 #[cfg(not(any(feature = "esp32c5", feature = "esp32c6")))]
804 crate::set_csi_min_sig_mode(1);
805 }
806 if let Some(hz) = rate {
807 match &mut self.mode {
808 Mode::Emitter(c) => {
809 c.period = Duration::from_micros(1_000_000 / hz.max(1) as u64);
810 }
811 _ => self.setup.set_periodicity_hz(Some(hz)),
812 }
813 }
814 self.setup.set_id(setup.id);
815 Ok(())
816 }
817
818 /// The channel width the mode is configured for, where it fixes one.
819 fn configured_bandwidth(&self) -> Option<crate::wire::Bandwidth> {
820 use crate::wire::Bandwidth;
821 let forty = match &self.mode {
822 Mode::EspNow(c) => c.secondary_channel().is_some(),
823 Mode::EspNowSimplex(c) => c.inner().secondary_channel().is_some(),
824 Mode::AccessPoint(c) => c.secondary_channel.is_some(),
825 Mode::Emitter(c) => c.phy.is_forty(),
826 // A station's width is whatever it associates at; a sniffer's follows the PPDU.
827 Mode::Station(_) | Mode::Sniffer(_) => return None,
828 };
829 Some(if forty { Bandwidth::Mhz40 } else { Bandwidth::Mhz20 })
830 }
831
832 /// Replace the mode's primary channel.
833 fn set_mode_channel(&mut self, ch: u8) {
834 match &mut self.mode {
835 Mode::EspNow(c) => c.channel = ch,
836 Mode::EspNowSimplex(c) => c.set_channel(ch),
837 Mode::Sniffer(c) => c.channel = ch,
838 Mode::Station(c) => c.channel_hint = Some(ch),
839 Mode::AccessPoint(c) => {
840 c.channel = ch;
841 c.ap_config = c.ap_config.clone().with_channel(ch);
842 }
843 Mode::Emitter(c) => c.channel = ch,
844 }
845 }
846
847 /// Replace the measurement setup.
848 pub fn set_measurement_setup(&mut self, setup: MeasurementSetup) {
849 self.setup = setup;
850 }
851
852 /// Replace the acquisition (CSI configuration) of the measurement setup.
853 pub fn set_csi_config(&mut self, config: CsiConfig) {
854 self.setup.set_acquisition(Some(config));
855 }
856
857 /// Update Wi-Fi Station configuration (only applies to a station node).
858 pub fn set_station_config(&mut self, config: WifiStationConfig) {
859 if let Mode::Station(_) = &self.mode {
860 self.mode = Mode::Station(config);
861 }
862 }
863
864 /// Set the periodicity of the measurement setup, in Hz. See
865 /// [`MeasurementSetup::periodicity_hz`] for which modes read it.
866 pub fn set_periodicity_hz(&mut self, hz: u16) {
867 self.setup.set_periodicity_hz(Some(hz));
868 }
869
870 /// Replace the TX/RX task configuration. See [`IOTaskConfig`]; the two fields can also be set
871 /// one at a time with [`set_tx_enabled`](Self::set_tx_enabled) and
872 /// [`set_rx_enabled`](Self::set_rx_enabled).
873 pub fn set_io_tasks(&mut self, io_tasks: IOTaskConfig) {
874 self.io_tasks = io_tasks;
875 }
876
877 /// Enable or disable TX task work.
878 pub fn set_tx_enabled(&mut self, enabled: bool) {
879 self.io_tasks.tx_enabled = enabled;
880 }
881
882 /// Enable or disable RX task work.
883 pub fn set_rx_enabled(&mut self, enabled: bool) {
884 self.io_tasks.rx_enabled = enabled;
885 }
886
887 /// Get current TX/RX task configuration.
888 pub fn get_io_tasks(&self) -> IOTaskConfig {
889 self.io_tasks
890 }
891
892 /// Replace how this node reaches the channel, and with it the roles and reporting the new mode
893 /// implies. Used to run one node through several configurations without reconstructing it —
894 /// see `examples/runtime_config.rs`.
895 pub fn set_mode(&mut self, mode: Mode) {
896 self.mode = mode;
897 }
898
899 /// Set Wi-Fi protocol (overrides default).
900 pub fn set_protocol(&mut self, protocol: Protocol) {
901 self.protocol = Some(protocol);
902 }
903
904 /// Install a Wi-Fi bring-up profile (overrides the default
905 /// [`StandardProfile`]). Pass a reference to a zero-sized profile value,
906 /// e.g. `node.set_radio_profile(&MyProfile);`.
907 pub fn set_radio_profile(&mut self, profile: &'static dyn RadioProfile) {
908 self.profile = profile;
909 }
910
911 /// Make the ICMP traffic flood send unsolicited echo **replies** instead
912 /// of echo requests.
913 ///
914 /// The peer's IP stack silently ignores an unsolicited reply, so the
915 /// generated traffic becomes strictly one-directional: the peer still
916 /// hardware-ACKs every data frame (rate control stays fed) and captures
917 /// CSI per frame, but never transmits an IP-level response. This halves
918 /// the on-air frame count versus request/reply and stabilizes the offered
919 /// rate under CSMA contention. Trade-off: this node receives no CSI back
920 /// from the peer's replies.
921 pub fn set_flood_unsolicited_reply(&mut self, enabled: bool) {
922 self.flood_unsolicited_reply = enabled;
923 }
924
925 /// Run the node for `duration` seconds with internal draining of the measurements.
926 ///
927 /// This initializes Wi-Fi, configures CSI, and starts mode-specific tasks.
928 pub async fn run_duration(&mut self, duration: u64, client: &mut CsiNodeClient) {
929 self.run_inner(Some(duration), Some(client)).await;
930 }
931
932 /// Shared implementation behind [`run`](Self::run) and
933 /// [`run_duration`](Self::run_duration).
934 ///
935 /// `duration`/`client` are `Some` only on the timed `run_duration` path:
936 /// when set, each mode arm runs an extra concurrent future that stops the
937 /// node after `duration` seconds (and, with RX enabled, drains CSI to the
938 /// logger via `client`). When `None` the node runs until externally
939 /// stopped via [`CsiNodeClient::send_stop`].
940 async fn run_inner(&mut self, duration: Option<u64>, client: Option<&mut CsiNodeClient>) {
941 // Zero the counters and stamp the capture start so `show-stats` describes THIS run, and
942 // still describes it after the run ends. Deliberately here rather than in `reset_globals`,
943 // which runs at stop — see the note there. Mirrors what an out-of-tree receiver path
944 // does with `stats_begin_run`.
945 #[cfg(feature = "statistics")]
946 crate::stats::stats_begin_run();
947
948 // A requested width the mode's own config contradicts is reported, not applied: the
949 // config is where the width is set (see `MeasurementSetup::bandwidth`).
950 if let (Some(want), Some(have)) = (self.setup.get_bandwidth(), self.configured_bandwidth())
951 && want != have
952 {
953 let mhz = |b: crate::wire::Bandwidth| -> u16 {
954 use crate::wire::Bandwidth::*;
955 match b {
956 Mhz20 => 20,
957 Mhz40 => 40,
958 Mhz80 => 80,
959 Mhz160 => 160,
960 Mhz320 => 320,
961 }
962 };
963 log_ln!(
964 "Measurement setup asks for {} MHz but the mode is configured for {} MHz; running at {} MHz",
965 mhz(want),
966 mhz(have),
967 mhz(have)
968 );
969 }
970
971 let interfaces = &mut self.hardware.interfaces;
972 let controller = &mut self.hardware.controller;
973
974 // Applied every run (not only when set) so the process-wide flood-kind
975 // flag never leaks from a previous, differently-configured run.
976 crate::wifi::sta::set_icmp_flood_unsolicited(self.flood_unsolicited_reply);
977
978 // Applied every run, for the same reason as the flood flag above: the gate is
979 // process-wide, so a node left reporting `None` by a previous run would silently stop
980 // processing CSI in this one. Read by the ESP-NOW initiator to announce whether it
981 // reports.
982 crate::set_runtime_reporting(self.mode.reporting());
983
984 // Session context for this run's frames. The ESP-NOW modes sound the channel themselves,
985 // so their measurements are of a controlled stimulus; every other mode measures traffic it
986 // did not originate. A sniffer has no other way to attribute that traffic, so it captures
987 // the MAC header digest by default.
988 crate::csi::session::begin_run(
989 matches!(
990 self.mode,
991 Mode::EspNow(_) | Mode::EspNowSimplex(_)
992 ),
993 matches!(self.mode, Mode::Sniffer(_)),
994 );
995 crate::csi::session::set_setup_id(self.setup.get_id());
996
997 // Deal with esp-radio's built-in ESP-NOW receive dispatcher before any other Wi-Fi
998 // reconfiguration runs — see `suppress_espnow_rx` for why this must happen this early.
999 //
1000 // WHICH treatment depends on the role, and getting it wrong is silent. `suppress_espnow_rx`
1001 // permanently UNREGISTERS the callback, which is right for the 802.11 roles — none of them
1002 // reads ESP-NOW, and the stock dispatcher heap-allocates every overheard vendor action
1003 // frame into a deque nothing drains. It is exactly wrong for the ESP-NOW roles, which would
1004 // then be deaf: a responder would never see a control frame and a simplex source would never
1005 // hear the discovery beacon, both while looking perfectly healthy.
1006 //
1007 // So an ESP-NOW role installs the static-pool dispatcher instead, which replaces the
1008 // allocating one rather than removing it — same protection against the heap growth, and the
1009 // frames still arrive.
1010 if matches!(
1011 &self.mode,
1012 Mode::EspNow(_) | Mode::EspNowSimplex(_)
1013 ) {
1014 crate::esp_now_pool::install();
1015 idle_station_for_espnow(controller).await;
1016 } else {
1017 suppress_espnow_rx();
1018 }
1019 // Let the freshly-constructed radio state settle before the first C5
1020 // reconfiguration mutation (no-op off C5).
1021 c5_radio_settle().await;
1022
1023 let is_ap = matches!(&self.mode, Mode::AccessPoint(_));
1024 let is_sniffer = matches!(&self.mode, Mode::Sniffer(_));
1025 let is_emitter = matches!(&self.mode, Mode::Emitter(_));
1026
1027 // An emitter never captures, so CSI is only ever armed for a receiver.
1028 // Everything downstream keys off this rather than re-testing the role.
1029 let rx_enabled = self.io_tasks.rx_enabled && !is_emitter;
1030
1031 // Radio-profile back-end (Copy handle; does not alias `self.hardware`).
1032 // `bringup` decides whether the profile takes over the extended Wi-Fi
1033 // bring-up sequence for this role/protocol.
1034 let profile = self.profile;
1035 // An HE20 ESP-NOW peer rate is refused (`InvalidArgument`) unless the interface advertises
1036 // 802.11ax, so `EspNowConfig::with_he20` implies the AX protocol set when none was asked
1037 // for. Measured on ESP32-C5: without it no frame rides HE20 and no CSI arrives at all; with
1038 // it both directions deliver full HE-LTF estimates.
1039 #[cfg(any(feature = "esp32c5", feature = "esp32c6"))]
1040 if self.protocol.is_none() {
1041 let he20 = match &self.mode {
1042 Mode::EspNow(c) => c.he20(),
1043 Mode::EspNowSimplex(c) => c.inner().he20(),
1044 _ => false,
1045 };
1046 if he20 {
1047 self.protocol = Some(Protocol::AX);
1048 }
1049 }
1050
1051 let bringup = profile.wants_bringup(NodeView::new(&self.mode), self.protocol);
1052
1053 // Apply protocol ladder before STA bring-up / CSI. Generic chip-level tuning
1054 // lives in the radio profile; specialised back-ends may rebuild the set
1055 // entirely. Skipped for an emitter, which pins its own protocol set during
1056 // bring-up to match its forced TX PHY.
1057 if let Some(protocol) = self.protocol.take() {
1058 if !is_emitter {
1059 let base = Protocols::default().with_2_4(protocol_ladder_2_4(protocol));
1060 let protocols =
1061 profile.tune_protocols(NodeView::new(&self.mode), protocol, base);
1062 controller.set_protocols(protocols).unwrap();
1063 c5_radio_settle().await;
1064 }
1065 self.protocol = Some(protocol);
1066 }
1067
1068 if bringup && !is_emitter {
1069 profile.apply_bandwidth(controller);
1070 c5_radio_settle().await;
1071 }
1072
1073 // Tasks necessary for a station receiver.
1074 let sta_interface =
1075 if let Mode::Station(config) = &self.mode {
1076 let ifaces = sta_init(
1077 &mut interfaces.station,
1078 config,
1079 controller,
1080 profile,
1081 bringup,
1082 );
1083 // Band selection comes *after* `sta_init`, which is what configures and
1084 // starts the interface: `esp_wifi_set_band_mode` requires a started
1085 // controller, so doing this first failed silently and left the station on
1086 // whatever band a previous run had selected.
1087 //
1088 // With a channel hint, pin the band it implies. Without one, select both
1089 // bands — pinning a single band would make an access point on the other
1090 // one invisible, which presents as a bare "no access point found".
1091 #[cfg(feature = "esp32c5")]
1092 {
1093 match config.channel_hint {
1094 Some(channel) => apply_band_for_channel(controller, channel),
1095 None => apply_band_auto(controller),
1096 }
1097 c5_radio_settle().await;
1098 }
1099 Some(ifaces)
1100 } else {
1101 None
1102 };
1103 if bringup && sta_interface.is_some() {
1104 profile.apply_protocols_post(controller);
1105 c5_radio_settle().await;
1106 }
1107
1108 // Self-contained softAP: bring up the AP-side embassy-net stack (static
1109 // IP) and apply the AP config to the controller. `interfaces.access_point`
1110 // is disjoint from `.station`/`.sniffer`, so this borrow is fine.
1111 let ap_interface = if let Mode::AccessPoint(config) = &self.mode {
1112 #[cfg(feature = "esp32c5")]
1113 if config.secondary_channel().is_none() {
1114 apply_band_for_channel(controller, config.channel());
1115 }
1116 if let Some(secondary) = config.secondary_channel() {
1117 apply_ht40_channel(controller, config.channel(), secondary);
1118 c5_radio_settle().await;
1119 }
1120 let ifaces = ap_init(
1121 &mut interfaces.access_point,
1122 config,
1123 controller,
1124 profile,
1125 bringup,
1126 );
1127 if bringup {
1128 profile.apply_protocols_post(controller);
1129 }
1130 // The AP `set_config` restarts the radio; settle before `set_csi`.
1131 c5_radio_settle().await;
1132 Some(ifaces)
1133 } else {
1134 None
1135 };
1136
1137 // Build CSI Configuration. An emitter captures nothing, so this is only
1138 // meaningful for a receiver — but it is cheap and keeps the flow linear.
1139 let mut config = match self.setup.get_acquisition() {
1140 Some(config) => {
1141 log_ln!("CSI Configuration Set: {:?}", config);
1142 build_csi_config(config)
1143 }
1144 None => {
1145 let default_config = CsiConfig::default();
1146 log_ln!(
1147 "No CSI Configuration Provided. Going with defaults: {:?}",
1148 default_config
1149 );
1150 build_csi_config(&default_config)
1151 }
1152 };
1153 // Let the radio profile enable any extra acquisition modes it needs
1154 // (default is a no-op) before the config is registered/cloned.
1155 profile.tune_csi_acquisition(&mut config);
1156
1157 log_ln!("Wi-Fi Controller Started");
1158 // The reporting reaches the delivery gate through `set_runtime_reporting` above, not
1159 // through here: `CSI_OUTPUT_ENABLED` is the user's runtime override and writing the
1160 // configured reporting into it would make `set-csi-output --enabled=true` unable to
1161 // re-enable a node that had been configured to report nothing.
1162 // Sequence-drop detection tracks per-source-MAC sequence numbers, so it
1163 // works for any receiver: the emitter's driver-assigned incrementing
1164 // sequence numbers make gaps in a capture measurable.
1165 set_seq_drop_detection(!is_emitter);
1166
1167 // Keep a clone so the STA recovery path in `run_sta_connect` can re-apply
1168 // after a stop/start cycle (stop clears the CSI filter/callback).
1169 //
1170 // Only register the CSI callback when RX is actually enabled — otherwise
1171 // the radio fires `capture_csi_info` for every overheard 802.11 frame on
1172 // the WiFi task hot path for no purpose.
1173 let csi_config_for_recovery = config.clone();
1174 // The sniffer arm sets CSI after locking its channel; the AP arm sets it
1175 // inside `run_ap`, because `set_config(AccessPoint)` restarts the radio and
1176 // clears the CSI filter.
1177 if rx_enabled && !is_sniffer && !is_ap {
1178 set_csi(controller, config.clone());
1179 // Settle after enabling CSI before the role task issues its first
1180 // set_channel / TX so the run loop doesn't start into a pending IRQ.
1181 c5_radio_settle().await;
1182 }
1183 // Immutable borrow of a *different* `interfaces` field than the station
1184 // arm touches, so this disjoint borrow is fine. Used by the sniffer arm and
1185 // to clear promiscuous mode on station shutdown.
1186 let sniffer = &interfaces.sniffer;
1187
1188 match &self.mode {
1189 Mode::Emitter(emitter_config) => {
1190 // The emitter owns its whole bring-up (forced TX PHY, unassociated
1191 // interface start, channel lock) inside `run_emitter`, because the
1192 // forced rate has to be applied before the interface starts.
1193 let main_task = run_emitter(controller, interfaces, emitter_config);
1194 drive_main(main_task, false, duration, client).await;
1195 }
1196 Mode::Sniffer(sniffer_config) => {
1197 #[cfg(feature = "esp32c5")]
1198 {
1199 let band = if sniffer_config.channel() >= 36 {
1200 BandMode::_5G
1201 } else {
1202 BandMode::_2_4G
1203 };
1204 controller.set_band_mode(band).unwrap();
1205 }
1206 sniffer.set_promiscuous_mode(true).unwrap();
1207 controller
1208 .set_channel(sniffer_config.channel(), SecondaryChannel::None)
1209 .unwrap();
1210 if bringup {
1211 profile.apply_sniffer_radio(controller);
1212 c5_radio_settle().await;
1213 }
1214 if rx_enabled {
1215 set_csi(controller, config.clone());
1216 }
1217 // The sniffer arm has no `main_task`, so it drives CSI
1218 // collection directly rather than through `drive_main`.
1219 match (duration, rx_enabled) {
1220 (Some(d), true) => {
1221 join(
1222 run_process_csi_packet(),
1223 csi_data_collection(client.unwrap(), d),
1224 )
1225 .await;
1226 // `csi_data_collection` signals stop, so the join
1227 // returns; this trailing await lets the rate task
1228 // observe the stop and exit (preserves prior behavior).
1229 run_process_csi_packet().await;
1230 }
1231 (Some(d), false) => stop_after_duration(d).await,
1232 (None, true) => run_process_csi_packet().await,
1233 (None, false) => wait_for_stop().await,
1234 }
1235 sniffer.set_promiscuous_mode(false).unwrap();
1236 }
1237 Mode::AccessPoint(ap_config) => {
1238 // Start the AP, run the net stack + optional DHCP server, and
1239 // collect CSI from associated stations' uplink frames. CSI is
1240 // registered inside `run_ap` (after the AP-start radio restart).
1241 let (ap_stack, ap_runner) = ap_interface.unwrap();
1242 let main_task = run_ap(
1243 controller,
1244 ap_stack,
1245 ap_runner,
1246 ap_config,
1247 csi_config_for_recovery,
1248 self.io_tasks,
1249 self.setup.get_periodicity_hz(),
1250 );
1251 drive_main(main_task, rx_enabled, duration, client).await;
1252 sniffer.set_promiscuous_mode(false).unwrap();
1253 }
1254 Mode::Station(_sta_config) => {
1255 // 1. Connect to the Wi-Fi network.
1256 // 2. Run DHCP / NTP sync if enabled in config.
1257 // 3. Drive STA connection handling and network operations.
1258 let (sta_stack, sta_runner) = sta_interface.unwrap();
1259
1260 let main_task = run_sta_connect(
1261 controller,
1262 self.setup.get_periodicity_hz(),
1263 sta_stack,
1264 sta_runner,
1265 csi_config_for_recovery,
1266 self.io_tasks,
1267 );
1268 drive_main(main_task, rx_enabled, duration, client).await;
1269 // Clear promiscuous mode on shutdown. It is never enabled on
1270 // a STA interface, so this is a no-op — kept to match the
1271 // unconditional shutdown path the untimed `run()` always took.
1272 sniffer.set_promiscuous_mode(false).unwrap();
1273 }
1274
1275 // ── ESP-NOW ───────────────────────────────────────────────────────────────────────
1276 //
1277 // The four drivers all take `&mut EspNow`, and this tree already has one:
1278 // `interfaces.esp_now`, the same handle the emitter uses to transmit over ESP-NOW on
1279 // classic chips. The pre-refactor loop built its own and threaded it through twenty
1280 // integration points; none of that is needed here, and reproducing it would have been
1281 // a second bring-up path to keep in step with this one.
1282 //
1283 // Borrowing note: the `sniffer` binding above holds `&interfaces.sniffer`. These arms
1284 // take `&mut interfaces.esp_now`, a disjoint field, which the borrow checker accepts —
1285 // and they must not touch `sniffer`, which is why none of them clears promiscuous mode
1286 // on the way out. ESP-NOW never sets it.
1287 Mode::EspNow(cfg) => {
1288 // The symmetric exchange has two ends and the session role picks which one this
1289 // node drives: the initiator originates the control traffic, the responder answers
1290 // it. Both measure, which is why this is the one mode where every combination of
1291 // session role and reporting is meaningful.
1292 match cfg.session_role() {
1293 crate::SessionRole::Initiator => {
1294 // `reports` is announced to the responders in every control packet, so a
1295 // responder paired with a silent initiator can promote itself. It is read
1296 // from the same runtime flag the delivery path uses.
1297 let main_task = run_esp_now_initiator(
1298 &mut interfaces.esp_now,
1299 // `run_esp_now_initiator` takes this as `_mac_addr` and does not read
1300 // it — the peer MAC it actually uses comes from
1301 // `EspNowConfig::peer_mac`. Passed as an explicit unset value rather
1302 // than a plausible-looking address, so that if the driver ever starts
1303 // reading it the result is obviously wrong rather than subtly wrong.
1304 UNSET_MAC,
1305 cfg,
1306 self.setup.get_periodicity_hz(),
1307 crate::REPORTS.load(Ordering::Relaxed),
1308 self.io_tasks,
1309 );
1310 drive_main(main_task, rx_enabled, duration, client).await;
1311 }
1312 crate::SessionRole::Responder => {
1313 let main_task = run_esp_now_responder(
1314 &mut interfaces.esp_now,
1315 cfg,
1316 self.setup.get_periodicity_hz(),
1317 self.io_tasks,
1318 );
1319 drive_main(main_task, rx_enabled, duration, client).await;
1320 }
1321 }
1322 }
1323 Mode::EspNowSimplex(cfg) => {
1324 // Asymmetric simplex. The `source` end owns all the transmit airtime; the `peer`
1325 // end beacons until it is found and then goes receive-only. Both drivers take the
1326 // same `EspNowConfig`, so the end is what selects between them: the source is the
1327 // initiator (`initiator::esp_now_simplex`), the peer the responder
1328 // (`responder::esp_now_simplex`).
1329 if cfg.is_source() {
1330 // It captures nothing, so RX is forced off regardless of `rx_enabled` — a CSI
1331 // rate task here would compete for the airtime the flood exists to fill.
1332 let main_task = run_esp_now_simplex_source(
1333 &mut interfaces.esp_now,
1334 cfg.inner(),
1335 self.setup.get_periodicity_hz(),
1336 self.io_tasks,
1337 );
1338 drive_main(main_task, false, duration, client).await;
1339 } else {
1340 let main_task = run_esp_now_simplex_peer(
1341 &mut interfaces.esp_now,
1342 cfg.inner(),
1343 self.io_tasks,
1344 );
1345 drive_main(main_task, rx_enabled, duration, client).await;
1346 }
1347 }
1348 }
1349
1350 STOP_SIGNAL.reset();
1351 reset_globals();
1352 }
1353
1354 /// Run the node until stopped.
1355 ///
1356 /// This initializes Wi-Fi, configures CSI, and starts mode-specific tasks.
1357 pub async fn run(&mut self) {
1358 self.run_inner(None, None).await;
1359 }
1360}
1361
1362/// Concurrent driver for a mode's `main_task`.
1363///
1364/// Joins `main_task` with the CSI rate task (RX enabled) or a stop waiter, and
1365/// — on the timed `run_duration` path (`duration`/`client` are `Some`) — a
1366/// third future that ends the run after `duration` seconds, draining CSI to the
1367/// logger via `client` when RX is enabled.
1368async fn drive_main(
1369 main_task: impl core::future::Future,
1370 rx_enabled: bool,
1371 duration: Option<u64>,
1372 client: Option<&mut CsiNodeClient>,
1373) {
1374 match (duration, rx_enabled) {
1375 (Some(d), true) => {
1376 join3(
1377 main_task,
1378 run_process_csi_packet(),
1379 csi_data_collection(client.unwrap(), d),
1380 )
1381 .await;
1382 }
1383 (Some(d), false) => {
1384 join3(main_task, wait_for_stop(), stop_after_duration(d)).await;
1385 }
1386 (None, true) => {
1387 join(main_task, run_process_csi_packet()).await;
1388 }
1389 (None, false) => {
1390 join(main_task, wait_for_stop()).await;
1391 }
1392 }
1393}
1394
1395/// Expand a single requested 2.4 GHz protocol into the cumulative set the radio needs.
1396///
1397/// 802.11 protocol sets on 2.4 GHz are a **ladder**, not a choice: 11n is an extension of
1398/// 11b/11g, and the ESP32-C5/C6 rung above it extends all three, so a station must advertise
1399/// the rungs beneath the one it wants. Advertising a lone bit (the previous `EnumSet::only(protocol)`) produces a set
1400/// no real link can use.
1401///
1402/// This was measured, not theorised. With an N-only set, an ESP32-C6 `sniffer` collected
1403/// **zero** HT20 frames from a working emitter — reproduced on both C6 boards, in both role
1404/// assignments — while ESP32-S3 receivers on the same link and the same config collected
1405/// normally (their driver tolerates the degenerate set). The emitter never hit this because
1406/// `emitter::phy` builds `B | G | N` for itself; only the receiver path used `only()`.
1407///
1408/// `LR` is Espressif's proprietary long-range PHY rather than a rung on the ladder, so it
1409/// stays on its own. The 5 GHz-only rungs keep the previous one-bit behaviour instead of
1410/// being given an invented 2.4 GHz meaning — [`RadioProfile::tune_protocols`] owns the
1411/// 5 GHz set.
1412fn protocol_ladder_2_4(protocol: Protocol) -> EnumSet<Protocol> {
1413 match protocol {
1414 Protocol::B => EnumSet::only(Protocol::B),
1415 Protocol::G => Protocol::B | Protocol::G,
1416 Protocol::N => Protocol::B | Protocol::G | Protocol::N,
1417 // Proprietary long-range, and the 5 GHz-only rungs: not part of the 2.4 GHz ladder.
1418 Protocol::LR | Protocol::A | Protocol::AC => EnumSet::only(protocol),
1419 // The rung above N (ESP32-C5/C6 only) extends all three beneath it.
1420 higher => Protocol::B | Protocol::G | Protocol::N | higher,
1421 }
1422}
1423
1424#[cfg(test)]
1425mod protocol_ladder_tests {
1426 use super::*;
1427
1428 /// The rung under test must always be present, and every lower rung with it.
1429 #[test]
1430 fn each_rung_carries_the_ones_beneath_it() {
1431 assert_eq!(protocol_ladder_2_4(Protocol::B), EnumSet::only(Protocol::B));
1432 assert_eq!(protocol_ladder_2_4(Protocol::G), Protocol::B | Protocol::G);
1433 assert_eq!(
1434 protocol_ladder_2_4(Protocol::N),
1435 Protocol::B | Protocol::G | Protocol::N
1436 );
1437 // Every rung above N carries B | G | N beneath it.
1438 for p in EnumSet::<Protocol>::all().iter().filter(|p| {
1439 !matches!(
1440 p,
1441 Protocol::B | Protocol::G | Protocol::N | Protocol::LR | Protocol::A | Protocol::AC
1442 )
1443 }) {
1444 assert_eq!(protocol_ladder_2_4(p), Protocol::B | Protocol::G | Protocol::N | p);
1445 }
1446 }
1447
1448 /// Regression for the measured failure: an N-only 2.4 GHz set made an ESP32-C6
1449 /// receiver capture zero HT20 frames. `N` must never be advertised alone.
1450 #[test]
1451 fn n_is_never_advertised_alone() {
1452 let set = protocol_ladder_2_4(Protocol::N);
1453 assert!(set.contains(Protocol::N));
1454 assert!(set.contains(Protocol::G), "11n needs 11g beneath it");
1455 assert!(set.contains(Protocol::B), "11n needs 11b beneath it");
1456 assert_ne!(set, EnumSet::only(Protocol::N));
1457 }
1458
1459 /// The receiver ladder must match what the emitter already builds for itself in
1460 /// `emitter::phy` (`B | G | N`) — the two ends of an HT link have to agree, and the
1461 /// mismatch between them is exactly what this bug was.
1462 #[test]
1463 fn the_ht_rung_matches_the_emitters_own_set() {
1464 assert_eq!(
1465 protocol_ladder_2_4(Protocol::N),
1466 Protocol::B | Protocol::G | Protocol::N
1467 );
1468 }
1469
1470 /// `LR` is Espressif's proprietary long-range PHY, not a rung: it must stay alone,
1471 /// or enabling it would silently also advertise b/g/n.
1472 #[test]
1473 fn lr_stays_on_its_own() {
1474 assert_eq!(protocol_ladder_2_4(Protocol::LR), EnumSet::only(Protocol::LR));
1475 }
1476
1477 /// 5 GHz-only rungs keep the previous single-bit behaviour; the profile owns that band.
1478 #[test]
1479 fn five_ghz_rungs_are_untouched() {
1480 for p in [Protocol::A, Protocol::AC] {
1481 assert_eq!(protocol_ladder_2_4(p), EnumSet::only(p));
1482 }
1483 }
1484}