Skip to main content

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}