esp_csi_rs/node.rs
1//! Node topology/configuration types and the [`CSINode`] orchestrator.
2//!
3//! This module owns the user-facing description of a CSI node — its role
4//! ([`Node`] / [`CentralOpMode`] / [`PeripheralOpMode`]), the per-mode configs
5//! ([`EspNowConfig`], [`WifiSnifferConfig`], [`WifiStationConfig`]), the
6//! collection and TX/RX toggles — and [`CSINode`], whose `run` / `run_duration`
7//! wire up Wi-Fi, CSI, and the mode-specific tasks. It also holds the shared
8//! stop signal and the per-run lifecycle helpers.
9
10#[cfg(any(feature = "async-print", feature = "auto"))]
11use embassy_time::with_timeout;
12
13use embassy_futures::join::{join, join3};
14use embassy_futures::select::{select, Either};
15use embassy_time::{Duration, Timer};
16use enumset::EnumSet;
17use esp_radio::esp_now::WifiPhyRate;
18use esp_radio::wifi::sta::StationConfig;
19use esp_radio::wifi::{Interfaces, Protocol, Protocols, SecondaryChannel, WifiController};
20#[cfg(feature = "esp32c5")]
21use esp_radio::wifi::BandMode;
22
23use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
24use embassy_sync::signal::Signal;
25use portable_atomic::Ordering;
26
27use crate::central::esp_now::run_esp_now_central;
28use crate::central::sta::{run_sta_connect, sta_init};
29use crate::config::CsiConfig as CsiConfiguration;
30use crate::peripheral::esp_now::run_esp_now_peripheral;
31
32use crate::csi::delivery::{build_csi_config, run_process_csi_packet, set_csi, CSINodeClient, IS_COLLECTOR};
33use crate::espnow_phy::{
34 apply_espnow_band_for_channel, apply_espnow_ht40_mode, install_static_espnow_recv, takeover_esp_now_recv,
35 with_espnow_recv_suspended,
36};
37use crate::espnow_phy::bring_up_espnow_sta;
38use crate::log_ln;
39use crate::stats::set_seq_drop_detection;
40
41// Signals
42pub(crate) static STOP_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
43
44/// Per-mutation radio-quiesce delay on C5 dual-band bring-up.
45///
46/// The C5 Wi-Fi ISR can wedge if a MAC interrupt fires mid-reconfiguration
47/// (`set_protocols` / `set_config` STA restart / `set_csi` / `set_channel`),
48/// tripping the interrupt watchdog (`handle_interrupts` backtrace at boot) or
49/// hard-freezing before any task runs. `with_espnow_recv_suspended` already
50/// shrinks that window; inserting a short settle *between* the mutations lets
51/// the MAC drain any pending interrupt before the next driver call, shrinking it
52/// further. This is a probabilistic mitigation, not a guarantee — the radio
53/// restart still races the MAC IRQ — so keeping no ESP-NOW traffic on air during
54/// a node's bring-up remains the most effective measure.
55#[cfg(feature = "esp32c5")]
56const C5_RADIO_SETTLE_MS: u64 = 60;
57
58/// Await a brief radio-settle delay on C5; no-op on every other chip.
59/// See [`C5_RADIO_SETTLE_MS`].
60async fn c5_radio_settle() {
61 #[cfg(feature = "esp32c5")]
62 Timer::after(Duration::from_millis(C5_RADIO_SETTLE_MS)).await;
63}
64
65async fn csi_data_collection(client: &mut CSINodeClient, duration: u64) {
66 #[cfg(any(feature = "async-print", feature = "auto"))]
67 if crate::logging::logging::is_async_logging_active() {
68 with_timeout(Duration::from_secs(duration), async {
69 loop {
70 client.print_csi_w_metadata().await;
71 }
72 })
73 .await
74 .unwrap_err();
75 client.send_stop().await;
76 return;
77 }
78
79 #[cfg(not(any(feature = "async-print", feature = "auto")))]
80 {
81 let _ = client;
82 }
83 Timer::after(Duration::from_secs(duration)).await;
84 client.send_stop().await;
85}
86
87async fn wait_for_stop() {
88 STOP_SIGNAL.wait().await;
89 STOP_SIGNAL.signal(());
90}
91
92async fn stop_after_duration(duration: u64) {
93 match select(STOP_SIGNAL.wait(), Timer::after(Duration::from_secs(duration))).await {
94 Either::First(_) | Either::Second(_) => STOP_SIGNAL.signal(()),
95 }
96}
97
98/// Configuration for ESP-NOW traffic generation.
99///
100/// Used by both Central and Peripheral nodes when operating in ESP-NOW mode.
101/// Construct with `EspNowConfig::default()` then chain `with_channel` /
102/// `with_phy_rate` to override defaults — both nodes must agree on the
103/// channel for ESP-NOW frames to be received.
104pub struct EspNowConfig {
105 phy_rate: WifiPhyRate,
106 pub(crate) channel: u8,
107 /// Optional pre-configured peer MAC. When `None` (default) the pair uses
108 /// automatic, magic-prefix-based pairing. When `Some`, the magic prefix is
109 /// dropped from every frame and the source-MAC filter is the discriminator
110 /// from the first frame — both nodes must each be configured with the
111 /// other's MAC.
112 peer_mac: Option<[u8; 6]>,
113 /// Optional HT40 secondary channel. When `Some`, the node runs HT40 (40 MHz)
114 /// on `channel` + this secondary; when `None`, HT20. Only meaningful when
115 /// `force_phy` is set.
116 secondary_channel: Option<SecondaryChannel>,
117 /// When set, the node forces the ESP-NOW TX PHY (`phy_rate` +
118 /// HT20/HT40 from `secondary_channel`) via a per-peer rate config — which
119 /// requires bringing the radio up in started STA mode. When clear (default),
120 /// the radio is left in its default state and ESP-NOW frames go out at the
121 /// driver's default (legacy) PHY. Set by `with_phy_rate` / `with_ht40`.
122 force_phy: bool,
123}
124
125impl Default for EspNowConfig {
126 fn default() -> Self {
127 Self {
128 phy_rate: WifiPhyRate::RateMcs0Lgi,
129 // Channel 1 is empirically less congested than 11 in most
130 // residential / office environments — APs on auto-select tend
131 // to bias toward 11 because it's the upper bound in US/EU.
132 // Override with `with_channel` if your environment differs.
133 channel: 1,
134 peer_mac: None,
135 secondary_channel: None,
136 force_phy: false,
137 }
138 }
139}
140
141impl EspNowConfig {
142 /// Override the 2.4 GHz channel (1–14). Both central and peripheral
143 /// must be configured with the same channel.
144 pub fn with_channel(mut self, channel: u8) -> Self {
145 self.channel = channel;
146 self
147 }
148
149 /// Force the ESP-NOW TX PHY rate (e.g. `RateMcs0Lgi` … `RateMcs7Lgi`, or a
150 /// legacy rate). Applied per-peer via `esp_now_set_peer_rate_config`, which
151 /// brings the radio up in started STA mode. Combine with [`with_ht40`] for
152 /// a 40 MHz bandwidth; without it the rate is sent at HT20 (for MCS rates)
153 /// or the matching legacy mode. Without calling this (or `with_ht40`) the
154 /// PHY is left at the driver default.
155 ///
156 /// [`with_ht40`]: EspNowConfig::with_ht40
157 pub fn with_phy_rate(mut self, phy_rate: WifiPhyRate) -> Self {
158 self.phy_rate = phy_rate;
159 self.force_phy = true;
160 self
161 }
162
163 /// Pre-configure the peer's MAC address for manual pairing.
164 ///
165 /// Switches off automatic magic-prefix pairing: no magic is sent, and each
166 /// node accepts frames only from the configured peer MAC (source-MAC
167 /// filtering applies from the first frame). The central must be given the
168 /// peripheral's MAC and vice-versa, and both nodes must use the same
169 /// pairing mode for frames to parse.
170 pub fn with_peer_mac(mut self, peer_mac: [u8; 6]) -> Self {
171 self.peer_mac = Some(peer_mac);
172 self
173 }
174
175 /// Configured 2.4 GHz channel.
176 pub fn channel(&self) -> u8 {
177 self.channel
178 }
179
180 /// Configured PHY rate.
181 pub fn phy_rate(&self) -> &WifiPhyRate {
182 &self.phy_rate
183 }
184
185 /// Configured peer MAC for manual pairing, or `None` for automatic
186 /// magic-prefix pairing.
187 pub fn peer_mac(&self) -> Option<[u8; 6]> {
188 self.peer_mac
189 }
190
191 /// Run the ESP-NOW TX at HT40 (40 MHz) with `secondary` as the HT40
192 /// secondary channel, using the configured [`with_phy_rate`] (default
193 /// `RateMcs0Lgi`). Implies `force_phy`. Without this the PHY is HT20 (if a
194 /// rate is forced) or the driver default. Verify on-air (CSI `bandwidth`
195 /// field) that HT40 actually engaged.
196 ///
197 /// [`with_phy_rate`]: EspNowConfig::with_phy_rate
198 pub fn with_ht40(mut self, secondary: SecondaryChannel) -> Self {
199 self.secondary_channel = Some(secondary);
200 self.force_phy = true;
201 self
202 }
203
204 /// Configured HT40 secondary channel, or `None` for HT20.
205 pub fn secondary_channel(&self) -> Option<SecondaryChannel> {
206 self.secondary_channel
207 }
208
209 /// Whether the ESP-NOW TX PHY (rate + bandwidth) is forced via a per-peer
210 /// rate config (set by [`with_phy_rate`] / [`with_ht40`]).
211 ///
212 /// [`with_phy_rate`]: EspNowConfig::with_phy_rate
213 /// [`with_ht40`]: EspNowConfig::with_ht40
214 pub fn force_phy(&self) -> bool {
215 self.force_phy
216 }
217}
218
219/// Configuration for Wi-Fi Promiscuous Sniffer mode.
220///
221/// Construct with `WifiSnifferConfig::default()` then chain `with_channel`
222/// to override defaults.
223#[derive(Debug, Clone)]
224pub struct WifiSnifferConfig {
225 /// Optional MAC source filter (reserved — not yet wired into the
226 /// promiscuous filter setup).
227 #[allow(dead_code)]
228 mac_filter: Option<[u8; 6]>,
229 channel: u8,
230}
231
232impl Default for WifiSnifferConfig {
233 fn default() -> Self {
234 Self {
235 mac_filter: None,
236 // Match `EspNowConfig` default — channel 1 is typically less
237 // congested than 11 in dense residential / office environments.
238 channel: 1,
239 }
240 }
241}
242
243impl WifiSnifferConfig {
244 /// Override the channel the sniffer locks to.
245 ///
246 /// Must be a valid IEEE 802.11 **primary** channel number — pass the
247 /// primary, not the wider-channel center notation that routers
248 /// commonly display:
249 ///
250 /// - **2.4 GHz**: `1`–`14`
251 /// - **5 GHz**: `36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112,
252 /// 116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165`
253 /// (regulatory-domain dependent — some restricted by `country_info`)
254 ///
255 /// Center-channel labels (`38, 46, ...` for HT40; `42, 58, 106, ...`
256 /// for VHT80; `50, 114` for VHT160; `154` for the 153/157 HT40 pair)
257 /// are **not** accepted here — `esp_wifi_set_channel` panics with
258 /// `InvalidArguments`. For example, a router showing "channel 154"
259 /// is using primary `153` (or `157`); pass that primary and the chip
260 /// will sniff the full 40 MHz block automatically per 802.11.
261 ///
262 /// On dual-band chips (currently ESP32-C5), the band is auto-selected
263 /// from the channel number — channels `>= 36` switch the radio to
264 /// `BandMode::_5G`, otherwise `BandMode::_2_4G`. On 2.4-GHz-only
265 /// chips, passing any 5 GHz channel will fail at runtime.
266 pub fn with_channel(mut self, channel: u8) -> Self {
267 self.channel = channel;
268 self
269 }
270
271 /// Configured channel (2.4 GHz: 1–14, 5 GHz: 36–165).
272 pub fn channel(&self) -> u8 {
273 self.channel
274 }
275}
276
277/// Configuration for Wi-Fi Station mode.
278#[derive(Debug, Clone)]
279pub struct WifiStationConfig {
280 /// Underlying esp-radio station configuration (SSID, auth, etc.).
281 pub client_config: StationConfig,
282}
283
284#[cfg(feature = "defmt")]
285impl defmt::Format for WifiStationConfig {
286 fn format(&self, fmt: defmt::Formatter<'_>) {
287 defmt::write!(fmt, "WifiStationConfig {{ client_config: <opaque> }}");
288 }
289}
290
291// Enum for Central modes, each wrapping its specific config.
292
293/// Central node operational modes.
294pub enum CentralOpMode {
295 /// Drive an ESP-NOW exchange with a peripheral node.
296 EspNow(EspNowConfig),
297 /// Associate as a Wi-Fi station to harvest CSI from received frames.
298 WifiStation(WifiStationConfig),
299}
300
301// Enum for Peripheral modes, each wrapping its specific config.
302/// Peripheral node operational modes.
303pub enum PeripheralOpMode {
304 /// Reply to a central's ESP-NOW control frames.
305 EspNow(EspNowConfig),
306 /// Run as a Wi-Fi promiscuous sniffer; CSI is captured from every
307 /// frame received on the locked channel.
308 WifiSniffer(WifiSnifferConfig),
309}
310
311/// High-level node type and mode.
312pub enum Node {
313 /// Run as the peripheral side of the chosen [`PeripheralOpMode`].
314 Peripheral(PeripheralOpMode),
315 /// Run as the central side of the chosen [`CentralOpMode`].
316 Central(CentralOpMode),
317}
318
319/// CSI collection behavior for the node.
320///
321/// Use `Listener` to keep CSI traffic flowing without processing packets,
322/// or `Collector` to actively process CSI data. Note: `Listener` combined with
323/// a sniffer node makes the sniffer effectively useless because no CSI data is
324/// processed.
325#[derive(PartialEq, Eq, Clone, Copy)]
326pub enum CollectionMode {
327 /// Enables CSI collection and processes CSI data.
328 Collector,
329 /// Enables CSI collection but does not process CSI data.
330 Listener,
331}
332
333/// Controls whether TX and RX tasks are active for a node.
334///
335/// Defaults to both TX and RX enabled.
336#[derive(Debug, Clone, Copy, PartialEq, Eq)]
337pub struct IOTaskConfig {
338 /// Enable transmit-side task work for the selected operation mode.
339 pub tx_enabled: bool,
340 /// Enable receive/process-side task work for the selected operation mode.
341 pub rx_enabled: bool,
342}
343
344impl IOTaskConfig {
345 /// Create a task configuration with explicit TX/RX state.
346 pub const fn new(tx_enabled: bool, rx_enabled: bool) -> Self {
347 Self {
348 tx_enabled,
349 rx_enabled,
350 }
351 }
352}
353
354impl Default for IOTaskConfig {
355 fn default() -> Self {
356 Self::new(true, true)
357 }
358}
359
360/// Hardware handles required to operate a CSI node.
361pub struct CSINodeHardware<'a> {
362 interfaces: &'a mut Interfaces<'static>,
363 controller: &'a mut WifiController<'static>,
364}
365
366impl<'a> CSINodeHardware<'a> {
367 /// Create a hardware bundle from the Wi-Fi `Interfaces` and `WifiController`.
368 pub fn new(
369 interfaces: &'a mut Interfaces<'static>,
370 controller: &'a mut WifiController<'static>,
371 ) -> Self {
372 Self {
373 interfaces,
374 controller,
375 }
376 }
377}
378
379pub(crate) fn reset_globals() {
380 // Close all CSI delivery gates so any late-firing WiFi callback runs
381 // are no-ops, then clear the statistics counters. The CSI callback stays
382 // registered with esp-radio after stop (the radio itself is still up),
383 // but with the gates closed the callback short-circuits before it touches
384 // the log channel or the user's callback. Without this, sniffer/ESP-NOW/STA
385 // nodes keep emitting CSI lines on the serial port well after `send_stop()`.
386 crate::csi::delivery::reset();
387 crate::stats::reset();
388}
389
390/// Primary orchestration object for CSI collection.
391///
392/// Construct a node with `CSINode::new` or `CSINode::new_central_node`, configure
393/// optional protocol/rate/traffic frequency, then call `run()`.
394pub struct CSINode<'a> {
395 kind: Node,
396 collection_mode: CollectionMode,
397 io_tasks: IOTaskConfig,
398 /// CSI Configuration
399 csi_config: Option<CsiConfiguration>,
400 /// Traffic Generation Frequency
401 traffic_freq_hz: Option<u16>,
402 hardware: CSINodeHardware<'a>,
403 protocol: Option<Protocol>,
404}
405
406impl<'a> CSINode<'a> {
407 /// Create a new node with explicit `Node` kind.
408 pub fn new(
409 kind: Node,
410 collection_mode: CollectionMode,
411 csi_config: Option<CsiConfiguration>,
412 traffic_freq_hz: Option<u16>,
413 hardware: CSINodeHardware<'a>,
414 ) -> Self {
415 Self {
416 kind,
417 collection_mode,
418 io_tasks: IOTaskConfig::default(),
419 csi_config,
420 traffic_freq_hz,
421 hardware,
422 protocol: None,
423 }
424 }
425
426 /// Convenience constructor for a central node.
427 pub fn new_central_node(
428 op_mode: CentralOpMode,
429 collection_mode: CollectionMode,
430 csi_config: Option<CsiConfiguration>,
431 traffic_freq_hz: Option<u16>,
432 hardware: CSINodeHardware<'a>,
433 ) -> Self {
434 Self {
435 kind: Node::Central(op_mode),
436 collection_mode,
437 io_tasks: IOTaskConfig::default(),
438 csi_config,
439 traffic_freq_hz,
440 hardware,
441 protocol: None,
442 }
443 }
444
445 /// Get the node type and operation mode.
446 pub fn get_node_type(&self) -> &Node {
447 &self.kind
448 }
449
450 /// Get the current collection mode.
451 pub fn get_collection_mode(&self) -> CollectionMode {
452 self.collection_mode
453 }
454
455 /// If central, return the active central op mode.
456 pub fn get_central_op_mode(&self) -> Option<&CentralOpMode> {
457 match &self.kind {
458 Node::Central(mode) => Some(mode),
459 Node::Peripheral(_) => None,
460 }
461 }
462
463 /// If peripheral, return the active peripheral op mode.
464 pub fn get_peripheral_op_mode(&self) -> Option<&PeripheralOpMode> {
465 match &self.kind {
466 Node::Peripheral(mode) => Some(mode),
467 Node::Central(_) => None,
468 }
469 }
470
471 /// Update CSI configuration.
472 pub fn set_csi_config(&mut self, config: CsiConfiguration) {
473 self.csi_config = Some(config);
474 }
475
476 /// Update Wi-Fi Station configuration (only applies to central station mode).
477 pub fn set_station_config(&mut self, config: WifiStationConfig) {
478 if let Node::Central(CentralOpMode::WifiStation(_)) = &mut self.kind {
479 self.kind = Node::Central(CentralOpMode::WifiStation(config));
480 }
481 }
482
483 /// Set traffic generation frequency in Hz (ESP-NOW modes).
484 pub fn set_traffic_frequency(&mut self, freq_hz: u16) {
485 self.traffic_freq_hz = Some(freq_hz);
486 }
487
488 /// Set collection mode for the node.
489 pub fn set_collection_mode(&mut self, mode: CollectionMode) {
490 self.collection_mode = mode;
491 }
492
493 /// Set TX/RX task enablement for the node.
494 pub fn set_io_tasks(&mut self, io_tasks: IOTaskConfig) {
495 self.io_tasks = io_tasks;
496 }
497
498 /// Enable or disable TX task work.
499 pub fn set_tx_enabled(&mut self, enabled: bool) {
500 self.io_tasks.tx_enabled = enabled;
501 }
502
503 /// Enable or disable RX task work.
504 pub fn set_rx_enabled(&mut self, enabled: bool) {
505 self.io_tasks.rx_enabled = enabled;
506 }
507
508 /// Get current TX/RX task configuration.
509 pub fn get_io_tasks(&self) -> IOTaskConfig {
510 self.io_tasks
511 }
512
513 /// Replace the node kind/mode.
514 pub fn set_op_mode(&mut self, mode: Node) {
515 self.kind = mode;
516 }
517
518 /// Set Wi-Fi protocol (overrides default).
519 pub fn set_protocol(&mut self, protocol: Protocol) {
520 self.protocol = Some(protocol);
521 }
522
523 /// Set the ESP-NOW TX PHY rate after construction.
524 ///
525 /// Equivalent to [`EspNowConfig::with_phy_rate`]: forces the per-peer PHY
526 /// (and brings the radio up in started STA mode). Combine with
527 /// `EspNowConfig::with_ht40` for 40 MHz. No effect on non-ESP-NOW nodes —
528 /// STA / sniffer rates are driven by their own configuration, not here.
529 pub fn set_rate(&mut self, rate: WifiPhyRate) {
530 match &mut self.kind {
531 Node::Central(CentralOpMode::EspNow(cfg))
532 | Node::Peripheral(PeripheralOpMode::EspNow(cfg)) => {
533 cfg.phy_rate = rate;
534 cfg.force_phy = true;
535 }
536 _ => {}
537 }
538 }
539
540 /// Run the node for `duration` seconds with internal collection.
541 ///
542 /// This initializes Wi-Fi, configures CSI, and starts mode-specific tasks.
543 pub async fn run_duration(&mut self, duration: u64, client: &mut CSINodeClient) {
544 self.run_inner(Some(duration), Some(client)).await;
545 }
546
547 /// Shared implementation behind [`run`](Self::run) and
548 /// [`run_duration`](Self::run_duration).
549 ///
550 /// `duration`/`client` are `Some` only on the timed `run_duration` path:
551 /// when set, each mode arm runs an extra concurrent future that stops the
552 /// node after `duration` seconds (and, with RX enabled, drains CSI to the
553 /// logger via `client`). When `None` the node runs until externally
554 /// stopped via [`CSINodeClient::send_stop`].
555 async fn run_inner(&mut self, duration: Option<u64>, client: Option<&mut CSINodeClient>) {
556 let interfaces = &mut self.hardware.interfaces;
557 let controller = &mut self.hardware.controller;
558
559 // Take over esp-radio's ESP-NOW receive dispatcher *first*, before any
560 // other Wi-Fi reconfiguration runs (`set_protocols`, `set_csi`) — see
561 // `takeover_esp_now_recv` for why this must happen this early.
562 takeover_esp_now_recv(matches!(
563 &self.kind,
564 Node::Peripheral(PeripheralOpMode::WifiSniffer(_))
565 ));
566 // Let the freshly-constructed radio/ESP-NOW state settle before the
567 // first C5 reconfiguration mutation (no-op off C5).
568 c5_radio_settle().await;
569
570 let espnow_ht40 = matches!(
571 &self.kind,
572 Node::Peripheral(PeripheralOpMode::EspNow(c)) | Node::Central(CentralOpMode::EspNow(c))
573 if c.secondary_channel().is_some()
574 );
575
576 // Apply protocol before STA bring-up / CSI — on C5, recv must stay
577 // suspended across every controller mutation to avoid ISR WDT trips.
578 if let Some(protocol) = self.protocol.take() {
579 let old_protocol = reconstruct_protocol(&protocol);
580 let mut protocols = Protocols::default().with_2_4(EnumSet::only(protocol));
581 // ESP-NOW peer rate config fails / misbehaves with 802.11ax enabled on 5G.
582 #[cfg(feature = "esp32c5")]
583 if matches!(
584 &self.kind,
585 Node::Peripheral(PeripheralOpMode::EspNow(_))
586 | Node::Central(CentralOpMode::EspNow(_))
587 ) {
588 protocols = protocols.with_5(Protocol::A | Protocol::N);
589 }
590 with_espnow_recv_suspended(|| {
591 controller.set_protocols(protocols).unwrap();
592 });
593 self.protocol = Some(old_protocol);
594 c5_radio_settle().await;
595 }
596
597 // Started STA mode is required for ESP-NOW CSI capture (RX path) and for
598 // forced-PHY / manual-unicast TX. On C5 dual-band, skip STA for TX-only
599 // broadcast (no peer_mac, no RX) — restarting STA there can wedge the
600 // Wi-Fi ISR when the TX loop starts immediately afterward.
601 if matches!(
602 &self.kind,
603 Node::Peripheral(PeripheralOpMode::EspNow(c)) | Node::Central(CentralOpMode::EspNow(c))
604 if self.io_tasks.rx_enabled
605 || {
606 #[cfg(not(feature = "esp32c5"))]
607 {
608 c.force_phy()
609 }
610 #[cfg(feature = "esp32c5")]
611 {
612 c.peer_mac().is_some()
613 }
614 }
615 ) {
616 with_espnow_recv_suspended(|| {
617 bring_up_espnow_sta(controller, false);
618 });
619 // The STA restart is the riskiest C5 op — settle before the next
620 // mutation (set_csi) so a post-restart MAC interrupt can drain.
621 c5_radio_settle().await;
622 }
623
624 // Tasks Necessary for Central Station & Sniffer
625 let sta_interface = if let Node::Central(CentralOpMode::WifiStation(config)) = &self.kind {
626 Some(sta_init(&mut interfaces.station, config, controller))
627 } else {
628 None
629 };
630
631 // Build CSI Configuration
632 let config = match self.csi_config {
633 Some(ref config) => {
634 log_ln!("CSI Configuration Set: {:?}", config);
635 build_csi_config(config)
636 }
637 None => {
638 let default_config = CsiConfiguration::default();
639 log_ln!(
640 "No CSI Configuration Provided. Going with defaults: {:?}",
641 default_config
642 );
643 build_csi_config(&default_config)
644 }
645 };
646
647 // Apply Protocol if specified — handled above (before STA bring-up).
648
649 log_ln!("Wi-Fi Controller Started");
650 let is_collector = self.collection_mode == CollectionMode::Collector;
651 IS_COLLECTOR.store(is_collector, Ordering::Relaxed);
652 set_seq_drop_detection(matches!(
653 &self.kind,
654 Node::Peripheral(PeripheralOpMode::EspNow(_))
655 | Node::Central(CentralOpMode::EspNow(_))
656 ));
657
658 // Set Peripheral/Central to Collect CSI. Keep a clone so the STA
659 // recovery path in run_sta_connect can re-apply after a stop/start
660 // cycle (stop clears the CSI filter/callback).
661 //
662 // Only register the CSI callback when RX is actually enabled —
663 // otherwise the radio fires `capture_csi_info` for every overheard
664 // 802.11 frame (beacons, neighbour ESP-NOW, retries) on the WiFi
665 // task hot path, stealing cycles from the central TX-completion
666 // ISR for no purpose.
667 let csi_config_for_recovery = config.clone();
668 let is_sniffer = matches!(
669 &self.kind,
670 Node::Peripheral(PeripheralOpMode::WifiSniffer(_))
671 );
672 if self.io_tasks.rx_enabled && !is_sniffer && !espnow_ht40 {
673 with_espnow_recv_suspended(|| {
674 set_csi(controller, config.clone());
675 });
676 // Settle after enabling CSI before the mode task issues its first
677 // set_channel / TX so the run loop doesn't start into a pending IRQ.
678 c5_radio_settle().await;
679 }
680 let rx_enabled = self.io_tasks.rx_enabled;
681 // Immutable borrow of a *different* `interfaces` field than the ESP-NOW
682 // arms touch (`esp_now` / `station`), so this disjoint borrow is fine.
683 // Used by the sniffer arm and to clear promiscuous mode on WifiStation
684 // shutdown.
685 let sniffer = &interfaces.sniffer;
686
687 // Initialize Nodes based on type
688 match &self.kind {
689 Node::Peripheral(op_mode) => match op_mode {
690 PeripheralOpMode::EspNow(esp_now_config) => {
691 // Initialize as Peripheral node with EspNowConfig
692 // Non-HT40 path on dual-band C5: select band from primary
693 // channel as well, so a prior 5 GHz app doesn't leave this
694 // run pinned to 5 GHz when channel is 2.4 GHz (e.g. ch 11).
695 #[cfg(feature = "esp32c5")]
696 if esp_now_config.secondary_channel().is_none() {
697 with_espnow_recv_suspended(|| {
698 apply_espnow_band_for_channel(controller, esp_now_config.channel());
699 });
700 }
701 // HT40: set the secondary channel before the run loop (which
702 // then skips its own `esp_now.set_channel`). The TX rate/PHY
703 // is forced per-peer inside the run loops (see
704 // `set_peer_espnow_phy`); `esp_now.set_rate` is unused — it
705 // routes to the deprecated `esp_wifi_config_espnow_rate`.
706 if let Some(secondary) = esp_now_config.secondary_channel() {
707 with_espnow_recv_suspended(|| {
708 apply_espnow_ht40_mode(
709 controller,
710 esp_now_config.channel(),
711 secondary,
712 );
713 });
714 install_static_espnow_recv();
715 c5_radio_settle().await;
716 if rx_enabled {
717 with_espnow_recv_suspended(|| {
718 set_csi(controller, config.clone());
719 });
720 c5_radio_settle().await;
721 }
722 }
723
724 let main_task = run_esp_now_peripheral(
725 &mut interfaces.esp_now,
726 esp_now_config,
727 self.traffic_freq_hz,
728 self.io_tasks,
729 );
730 drive_main(main_task, rx_enabled, duration, client).await;
731 }
732 PeripheralOpMode::WifiSniffer(sniffer_config) => {
733 #[cfg(feature = "esp32c5")]
734 {
735 let band = if sniffer_config.channel() >= 36 {
736 BandMode::_5G
737 } else {
738 BandMode::_2_4G
739 };
740 controller.set_band_mode(band).unwrap();
741 }
742 sniffer.set_promiscuous_mode(true).unwrap();
743 controller
744 .set_channel(sniffer_config.channel(), SecondaryChannel::None)
745 .unwrap();
746 if rx_enabled {
747 set_csi(controller, config.clone());
748 }
749 // ESP-NOW's heap-allocating `rcv_cb` was already dropped at
750 // the top of `run_inner` via `takeover_esp_now_recv`, so
751 // overheard vendor frames are discarded at the C layer.
752 //
753 // The sniffer arm has no `main_task`, so it drives CSI
754 // collection directly rather than through `drive_main`.
755 match (duration, rx_enabled) {
756 (Some(d), true) => {
757 join(
758 run_process_csi_packet(),
759 csi_data_collection(client.unwrap(), d),
760 )
761 .await;
762 // `csi_data_collection` signals stop, so the join
763 // returns; this trailing await lets the rate task
764 // observe the stop and exit (preserves prior behavior).
765 run_process_csi_packet().await;
766 }
767 (Some(d), false) => stop_after_duration(d).await,
768 (None, true) => run_process_csi_packet().await,
769 (None, false) => wait_for_stop().await,
770 }
771 sniffer.set_promiscuous_mode(false).unwrap();
772 }
773 },
774 Node::Central(op_mode) => match op_mode {
775 CentralOpMode::EspNow(esp_now_config) => {
776 // Initialize as Central node with EspNowConfig.
777 // Non-HT40 path on dual-band C5: select band from primary
778 // channel as well, so a prior 5 GHz app doesn't leave this
779 // run pinned to 5 GHz when channel is 2.4 GHz (e.g. ch 11).
780 #[cfg(feature = "esp32c5")]
781 if esp_now_config.secondary_channel().is_none() {
782 with_espnow_recv_suspended(|| {
783 apply_espnow_band_for_channel(controller, esp_now_config.channel());
784 });
785 }
786 // HT40 handling mirrors the peripheral ESP-NOW arm above.
787 if let Some(secondary) = esp_now_config.secondary_channel() {
788 with_espnow_recv_suspended(|| {
789 apply_espnow_ht40_mode(
790 controller,
791 esp_now_config.channel(),
792 secondary,
793 );
794 });
795 install_static_espnow_recv();
796 c5_radio_settle().await;
797 if rx_enabled {
798 with_espnow_recv_suspended(|| {
799 set_csi(controller, config.clone());
800 });
801 c5_radio_settle().await;
802 }
803 }
804
805 let main_task = run_esp_now_central(
806 &mut interfaces.esp_now,
807 interfaces.station.mac_address(),
808 esp_now_config,
809 self.traffic_freq_hz,
810 is_collector,
811 self.io_tasks,
812 );
813 drive_main(main_task, rx_enabled, duration, client).await;
814 }
815 CentralOpMode::WifiStation(_sta_config) => {
816 // Initialize as Wifi Station Collector with WifiStationConfig
817 // 1. Connect to Wi-Fi network, etc.
818 // 2. Run DHCP, NTP sync if enabled in config, etc.
819 // 3. Spawn STA Connection Handling Task
820 // 4. Spawn STA Network Operation Task
821 let (sta_stack, sta_runner) = sta_interface.unwrap();
822
823 let main_task = run_sta_connect(
824 controller,
825 self.traffic_freq_hz,
826 sta_stack,
827 sta_runner,
828 csi_config_for_recovery,
829 self.io_tasks,
830 );
831 drive_main(main_task, rx_enabled, duration, client).await;
832 // Clear promiscuous mode on shutdown. It is never enabled on
833 // a STA interface, so this is a no-op — kept to match the
834 // unconditional shutdown path the untimed `run()` always took.
835 sniffer.set_promiscuous_mode(false).unwrap();
836 }
837 },
838 }
839
840 STOP_SIGNAL.reset();
841 reset_globals();
842 }
843
844 /// Run the node until stopped.
845 ///
846 /// This initializes Wi-Fi, configures CSI, and starts mode-specific tasks.
847 pub async fn run(&mut self) {
848 self.run_inner(None, None).await;
849 }
850}
851
852/// Concurrent driver for a mode's `main_task`.
853///
854/// Joins `main_task` with the CSI rate task (RX enabled) or a stop waiter, and
855/// — on the timed `run_duration` path (`duration`/`client` are `Some`) — a
856/// third future that ends the run after `duration` seconds, draining CSI to the
857/// logger via `client` when RX is enabled.
858async fn drive_main(
859 main_task: impl core::future::Future,
860 rx_enabled: bool,
861 duration: Option<u64>,
862 client: Option<&mut CSINodeClient>,
863) {
864 match (duration, rx_enabled) {
865 (Some(d), true) => {
866 join3(
867 main_task,
868 run_process_csi_packet(),
869 csi_data_collection(client.unwrap(), d),
870 )
871 .await;
872 }
873 (Some(d), false) => {
874 join3(main_task, wait_for_stop(), stop_after_duration(d)).await;
875 }
876 (None, true) => {
877 join(main_task, run_process_csi_packet()).await;
878 }
879 (None, false) => {
880 join(main_task, wait_for_stop()).await;
881 }
882 }
883}
884
885fn reconstruct_protocol(protocol: &Protocol) -> Protocol {
886 match protocol {
887 Protocol::B => Protocol::B,
888 Protocol::G => Protocol::G,
889 Protocol::N => Protocol::N,
890 Protocol::LR => Protocol::LR,
891 Protocol::A => Protocol::A,
892 Protocol::AC => Protocol::AC,
893 Protocol::AX => Protocol::AX,
894 _ => Protocol::N,
895 }
896}