Skip to main content

esp_csi_rs/
node.rs

1//! Node role/configuration types and the [`CSINode`] orchestrator.
2//!
3//! This module owns the user-facing description of a CSI node — its role
4//! ([`NodeRole`], and for a collector its capture path [`CollectorMode`]), the
5//! per-mode configs ([`EmitterConfig`], [`WifiSnifferConfig`],
6//! [`WifiStationConfig`], [`WifiApConfig`]), and the TX/RX toggles — plus
7//! [`CSINode`], whose `run` / `run_duration` wire up Wi-Fi, CSI, and the
8//! role-specific tasks. It also holds the shared stop signal and the per-run
9//! lifecycle helpers.
10//!
11//! There are exactly two roles. An **emitter** puts known RF energy into the
12//! channel and never captures; a **collector** captures the channel's response
13//! and delivers it. Everything else — station, softAP, promiscuous sniffer — is a
14//! *way of collecting*, not a role of its own.
15
16#[cfg(any(feature = "async-print", feature = "auto"))]
17use embassy_time::with_timeout;
18
19use embassy_futures::join::{join, join3};
20use embassy_futures::select::{Either, select};
21use embassy_time::{Duration, Timer};
22use enumset::EnumSet;
23#[cfg(feature = "esp32c5")]
24use esp_radio::wifi::BandMode;
25use esp_radio::wifi::sta::StationConfig;
26use esp_radio::wifi::{Interfaces, Protocol, Protocols, SecondaryChannel, WifiController};
27
28use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
29use embassy_sync::signal::Signal;
30use portable_atomic::Ordering;
31
32use crate::collector::ap::{ap_init, run_ap};
33use crate::collector::sta::{run_sta_connect, sta_init};
34use crate::config::CsiConfig as CsiConfiguration;
35use crate::central::esp_now::run_esp_now_central;
36use crate::central::esp_now_fast::run_esp_now_fast_collector;
37use crate::central_peripheral::{CentralOpMode, PeripheralOpMode};
38use crate::emitter::{EmitterConfig, run_emitter};
39use crate::peripheral::esp_now::run_esp_now_peripheral;
40use crate::peripheral::esp_now_fast::run_esp_now_fast_source;
41use crate::profile::{RadioProfile, StandardProfile};
42
43use crate::csi::delivery::{
44    CSINodeClient, CSI_OUTPUT_ENABLED, build_csi_config, run_process_csi_packet, set_csi,
45};
46use crate::log_ln;
47use crate::radio::{apply_ht40_channel, suppress_espnow_rx};
48#[cfg(feature = "esp32c5")]
49use crate::radio::{apply_band_auto, apply_band_for_channel};
50use crate::stats::set_seq_drop_detection;
51
52// Signals
53pub(crate) static STOP_SIGNAL: Signal<CriticalSectionRawMutex, ()> = Signal::new();
54
55/// Per-mutation radio-quiesce delay on C5 dual-band bring-up.
56///
57/// The C5 Wi-Fi ISR can wedge if a MAC interrupt fires mid-reconfiguration
58/// (`set_protocols` / `set_config` STA restart / `set_csi` / `set_channel`),
59/// tripping the interrupt watchdog (`handle_interrupts` backtrace at boot) or
60/// hard-freezing before any task runs. Dropping the ESP-NOW receive callback at
61/// bring-up (see [`crate::radio::suppress_espnow_rx`]) already shrinks that
62/// window; inserting a short settle *between* the mutations lets the MAC drain any
63/// pending interrupt before the next driver call, shrinking it further. This is a
64/// probabilistic mitigation, not a guarantee — the radio restart still races the
65/// MAC IRQ — so keeping the air quiet during a node's bring-up remains the most
66/// effective measure.
67#[cfg(feature = "esp32c5")]
68const C5_RADIO_SETTLE_MS: u64 = 60;
69
70/// Await a brief radio-settle delay on C5; no-op on every other chip.
71/// See [`C5_RADIO_SETTLE_MS`].
72async fn c5_radio_settle() {
73    #[cfg(feature = "esp32c5")]
74    Timer::after(Duration::from_millis(C5_RADIO_SETTLE_MS)).await;
75}
76
77async fn csi_data_collection(client: &mut CSINodeClient, duration: u64) {
78    #[cfg(any(feature = "async-print", feature = "auto"))]
79    if crate::logging::logging::is_async_logging_active() {
80        with_timeout(Duration::from_secs(duration), async {
81            loop {
82                client.print_csi_w_metadata().await;
83            }
84        })
85        .await
86        .unwrap_err();
87        client.send_stop().await;
88        return;
89    }
90
91    #[cfg(not(any(feature = "async-print", feature = "auto")))]
92    {
93        let _ = client;
94    }
95    Timer::after(Duration::from_secs(duration)).await;
96    client.send_stop().await;
97}
98
99async fn wait_for_stop() {
100    STOP_SIGNAL.wait().await;
101    STOP_SIGNAL.signal(());
102}
103
104async fn stop_after_duration(duration: u64) {
105    match select(
106        STOP_SIGNAL.wait(),
107        Timer::after(Duration::from_secs(duration)),
108    )
109    .await
110    {
111        Either::First(_) | Either::Second(_) => STOP_SIGNAL.signal(()),
112    }
113}
114
115/// Configuration for Wi-Fi Promiscuous Sniffer mode.
116///
117/// Construct with `WifiSnifferConfig::default()` then chain `with_channel`
118/// to override defaults.
119#[derive(Debug, Clone)]
120pub struct WifiSnifferConfig {
121    /// Optional MAC source filter (reserved — not yet wired into the
122    /// promiscuous filter setup).
123    #[allow(dead_code)]
124    mac_filter: Option<[u8; 6]>,
125    channel: u8,
126}
127
128impl Default for WifiSnifferConfig {
129    fn default() -> Self {
130        Self {
131            mac_filter: None,
132            // Channel 1 is typically less congested than 11 in dense
133            // residential / office environments.
134            channel: 1,
135        }
136    }
137}
138
139impl WifiSnifferConfig {
140    /// Override the channel the sniffer locks to.
141    ///
142    /// Must be a valid IEEE 802.11 **primary** channel number — pass the
143    /// primary, not the wider-channel center notation that routers
144    /// commonly display:
145    ///
146    /// - **2.4 GHz**: `1`–`14`
147    /// - **5 GHz**: `36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112,
148    ///   116, 120, 124, 128, 132, 136, 140, 144, 149, 153, 157, 161, 165`
149    ///   (regulatory-domain dependent — some restricted by `country_info`)
150    ///
151    /// Center-channel labels (`38, 46, ...` for HT40; `42, 58, 106, ...`
152    /// for VHT80; `50, 114` for VHT160; `154` for the 153/157 HT40 pair)
153    /// are **not** accepted here — `esp_wifi_set_channel` panics with
154    /// `InvalidArguments`. For example, a router showing "channel 154"
155    /// is using primary `153` (or `157`); pass that primary and the chip
156    /// will sniff the full 40 MHz block automatically per 802.11.
157    ///
158    /// On dual-band chips (currently ESP32-C5), the band is auto-selected
159    /// from the channel number — channels `>= 36` switch the radio to
160    /// `BandMode::_5G`, otherwise `BandMode::_2_4G`. On 2.4-GHz-only
161    /// chips, passing any 5 GHz channel will fail at runtime.
162    pub fn with_channel(mut self, channel: u8) -> Self {
163        self.channel = channel;
164        self
165    }
166
167    /// Configured channel (2.4 GHz: 1–14, 5 GHz: 36–165).
168    pub fn channel(&self) -> u8 {
169        self.channel
170    }
171}
172
173/// Configuration for Wi-Fi Station mode.
174#[derive(Debug, Clone)]
175pub struct WifiStationConfig {
176    /// Underlying esp-radio station configuration (SSID, auth, etc.).
177    pub client_config: StationConfig,
178    /// Primary channel of the target AP. On dual-band ESP32-C5 this selects
179    /// 2.4 vs 5 GHz (`set_band_mode`) before scan/association.
180    pub channel_hint: Option<u8>,
181}
182
183impl WifiStationConfig {
184    /// Build a station config from esp-radio's [`StationConfig`].
185    pub fn new(client_config: StationConfig) -> Self {
186        Self {
187            client_config,
188            channel_hint: None,
189        }
190    }
191
192    /// Pin the radio band from the AP's primary channel (C5 dual-band only).
193    pub fn with_channel_hint(mut self, channel: u8) -> Self {
194        self.channel_hint = Some(channel);
195        self
196    }
197}
198
199#[cfg(feature = "defmt")]
200impl defmt::Format for WifiStationConfig {
201    fn format(&self, fmt: defmt::Formatter<'_>) {
202        defmt::write!(fmt, "WifiStationConfig {{ client_config: <opaque> }}");
203    }
204}
205
206/// Configuration for self-contained softAP CSI collector mode.
207///
208/// Wraps esp-radio's [`AccessPointConfig`] (SSID, channel, auth, secondary
209/// channel) and the static IPv4 addressing used by the built-in DHCP server.
210/// The AP hands associating stations addresses from a lease pool in the AP's /24
211/// subnet with the gateway set to the AP itself.
212///
213/// `channel`/`secondary_channel` are duplicated here because esp-radio's
214/// `AccessPointConfig` fields are not externally readable; [`CSINode`] needs them
215/// for band/HT40 setup.
216///
217/// [`AccessPointConfig`]: esp_radio::wifi::ap::AccessPointConfig
218pub struct WifiApConfig {
219    /// Underlying esp-radio access-point configuration.
220    pub ap_config: esp_radio::wifi::ap::AccessPointConfig,
221    /// Primary channel the AP operates on (mirror of `ap_config`'s channel).
222    pub channel: u8,
223    /// Optional HT40 secondary channel (mirror of `ap_config`'s secondary).
224    pub secondary_channel: Option<SecondaryChannel>,
225    /// AP's static IPv4 address; also the gateway and DHCP server identifier.
226    pub ap_ipv4: core::net::Ipv4Addr,
227    /// First IPv4 address in the DHCP lease pool (typically `.2`).
228    pub lease_ipv4: core::net::Ipv4Addr,
229    /// Number of consecutive lease addresses starting at [`Self::lease_ipv4`]
230    /// (e.g. `3` → `.2`, `.3`, `.4`). Default `1` preserves the original
231    /// single-client behaviour.
232    pub lease_count: u8,
233    /// Whether to run the built-in DHCP server. When `false`, the AP only starts
234    /// + collects CSI (clients must self-assign IPs).
235    pub serve_dhcp: bool,
236    /// When `true`, every flood tick fires one unicast frame back-to-back to
237    /// **all** active leases instead of advancing one lease per tick (round-robin).
238    /// All associated stations then receive their downlink PPDU within tens of
239    /// microseconds of each other — temporally-synchronized multi-receiver CSI —
240    /// instead of being spread across the whole tick interval.
241    ///
242    /// This is the workable path to synchronized multi-receiver CSI. A single
243    /// group-addressed broadcast frame does *not* work on an ESP32 softAP:
244    /// broadcast/multicast is DTIM-buffered, dropped under a high-rate flood, and
245    /// only ever sent at the legacy basic rate — so it mostly never leaves the
246    /// radio and never honours a forced high-throughput TX rate. Only unicast
247    /// transmits immediately and honours the configured TX rate, so N unicast
248    /// frames per tick keep near-simultaneous arrival across receivers. Stations
249    /// must be **associated** — an unassociated receiver does not reliably
250    /// produce CSI from overheard frames.
251    ///
252    /// Per-receiver rate is the configured ping rate; total offered rate is
253    /// `rate * lease_count`, so lower the rate if airtime saturates. Default
254    /// `false` preserves per-lease round-robin. Set by [`Self::with_sync_burst`].
255    pub sync_burst: bool,
256}
257
258impl WifiApConfig {
259    /// Create a config from an [`AccessPointConfig`], its primary `channel`, and
260    /// optional HT40 `secondary` channel. Defaults the AP to `192.168.13.1/24`,
261    /// leases `192.168.13.2`, and enables the DHCP server.
262    ///
263    /// [`AccessPointConfig`]: esp_radio::wifi::ap::AccessPointConfig
264    pub fn new(
265        ap_config: esp_radio::wifi::ap::AccessPointConfig,
266        channel: u8,
267        secondary: Option<SecondaryChannel>,
268    ) -> Self {
269        Self {
270            ap_config,
271            channel,
272            secondary_channel: secondary,
273            ap_ipv4: core::net::Ipv4Addr::new(192, 168, 13, 1),
274            lease_ipv4: core::net::Ipv4Addr::new(192, 168, 13, 2),
275            lease_count: 1,
276            serve_dhcp: true,
277            sync_burst: false,
278        }
279    }
280
281    /// Override the AP/lease IPv4 addresses (must share a /24).
282    pub fn with_ipv4(mut self, ap: core::net::Ipv4Addr, lease: core::net::Ipv4Addr) -> Self {
283        self.ap_ipv4 = ap;
284        self.lease_ipv4 = lease;
285        self
286    }
287
288    /// Set the DHCP lease pool size (consecutive addresses from `lease_ipv4`).
289    pub fn with_lease_pool(mut self, count: u8) -> Self {
290        self.lease_count = count.max(1);
291        self
292    }
293
294    /// Lease address at `index` (`0` = `lease_ipv4`, `1` = next host, …).
295    pub fn lease_ip_at(&self, index: u8) -> core::net::Ipv4Addr {
296        let idx = index.min(self.lease_count.saturating_sub(1));
297        let mut oct = self.lease_ipv4.octets();
298        oct[3] = oct[3].saturating_add(idx);
299        core::net::Ipv4Addr::from(oct)
300    }
301
302    /// All configured pool addresses (up to [`Self::lease_count`]).
303    pub fn lease_pool(&self) -> heapless::Vec<core::net::Ipv4Addr, 8> {
304        let mut v = heapless::Vec::new();
305        for i in 0..self.lease_count.min(8) {
306            let _ = v.push(self.lease_ip_at(i));
307        }
308        v
309    }
310
311    /// Enable or disable the built-in DHCP server (default enabled).
312    pub fn with_dhcp_server(mut self, enabled: bool) -> Self {
313        self.serve_dhcp = enabled;
314        self
315    }
316
317    /// Fire one unicast frame back-to-back to every active lease per flood tick,
318    /// instead of unicasting round-robin (one lease per tick).
319    ///
320    /// All associated stations then receive their downlink PPDU within
321    /// microseconds of each other — synchronized multi-receiver CSI without the
322    /// round-robin spread. This is the workable substitute for a single broadcast
323    /// PPDU, which an ESP32 softAP can't reliably deliver (see [`Self::sync_burst`]).
324    /// Keep the DHCP server / lease pool enabled so stations associate as genuine
325    /// BSS members; only the per-tick transmit pattern changes.
326    pub fn with_sync_burst(mut self, enabled: bool) -> Self {
327        self.sync_burst = enabled;
328        self
329    }
330
331    /// Configured primary channel.
332    pub fn channel(&self) -> u8 {
333        self.channel
334    }
335
336    /// Configured HT40 secondary channel, or `None` for HT20.
337    pub fn secondary_channel(&self) -> Option<SecondaryChannel> {
338        self.secondary_channel
339    }
340}
341
342#[cfg(feature = "defmt")]
343impl defmt::Format for WifiApConfig {
344    fn format(&self, fmt: defmt::Formatter<'_>) {
345        defmt::write!(fmt, "WifiApConfig {{ ap_config: <opaque> }}");
346    }
347}
348
349/// How a collector obtains the frames it measures.
350///
351/// These are capture paths, not roles: each one ends with this node holding CSI.
352/// Which one to use depends on what traffic is available to measure.
353pub enum CollectorMode {
354    /// Lock a channel in promiscuous mode and measure every frame overheard.
355    ///
356    /// This is the capture path that pairs with an [`NodeRole::Emitter`]: the
357    /// emitter injects unassociated frames and the sniffer measures them, with no
358    /// association or handshake between the two.
359    Sniffer(WifiSnifferConfig),
360    /// Associate as a Wi-Fi station and measure CSI from received frames.
361    Station(WifiStationConfig),
362    /// Run a self-contained softAP: start an access point (plus a minimal DHCP
363    /// server) so a [`CollectorMode::Station`] node can associate and generate
364    /// steady uplink traffic, measured as CSI here.
365    AccessPoint(WifiApConfig),
366}
367
368/// What this node is for.
369///
370/// A CSI measurement needs energy in the channel and something to measure the
371/// channel's response. [`Emitter`](Self::Emitter) and [`Collector`](Self::Collector) are that
372/// split, and they are how every 802.11 capture path in this crate is expressed.
373///
374/// [`Central`](Self::Central) and [`Peripheral`](Self::Peripheral) are the older ESP-NOW taxonomy,
375/// restored alongside rather than folded into the two above. They are not a different spelling of
376/// emitter/collector: an ESP-NOW pair is a two-way exchange in which both ends transmit and the
377/// central also measures, so neither end maps onto a role defined by which direction it faces.
378/// Collapsing them was what removed ESP-NOW from the crate in the first place (b069331).
379pub enum NodeRole {
380    /// Transmit-only: force a TX PHY and loop-inject sounding frames. Never
381    /// captures CSI. See [`crate::emitter`].
382    Emitter(EmitterConfig),
383    /// Capture the channel response and deliver it, via the chosen capture path.
384    /// See [`crate::collector`].
385    Collector(CollectorMode),
386    /// Drive an ESP-NOW exchange, or one of the Wi-Fi modes that predate the emitter/collector
387    /// split. See [`crate::central`].
388    Central(CentralOpMode),
389    /// Respond to a central's ESP-NOW exchange. See [`crate::peripheral`].
390    Peripheral(PeripheralOpMode),
391}
392
393/// Placeholder for the central driver's unused `_mac_addr` parameter. See its call site.
394const UNSET_MAC: [u8; 6] = [0; 6];
395
396/// The ESP-NOW-era spelling of [`NodeRole`], kept so `Node::Central(..)` resolves for callers
397/// written against it. One type, two names — not a conversion.
398pub use NodeRole as Node;
399
400/// Controls whether TX and RX tasks are active for a node.
401///
402/// Defaults to both TX and RX enabled.
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub struct IOTaskConfig {
405    /// Enable transmit-side task work for the selected operation mode.
406    pub tx_enabled: bool,
407    /// Enable receive/process-side task work for the selected operation mode.
408    pub rx_enabled: bool,
409}
410
411impl IOTaskConfig {
412    /// Create a task configuration with explicit TX/RX state.
413    pub const fn new(tx_enabled: bool, rx_enabled: bool) -> Self {
414        Self {
415            tx_enabled,
416            rx_enabled,
417        }
418    }
419}
420
421impl Default for IOTaskConfig {
422    fn default() -> Self {
423        Self::new(true, true)
424    }
425}
426
427/// Hardware handles required to operate a node in either role.
428pub struct NodeHardware<'a> {
429    interfaces: &'a mut Interfaces<'static>,
430    controller: &'a mut WifiController<'static>,
431}
432
433impl<'a> NodeHardware<'a> {
434    /// Create a hardware bundle from the Wi-Fi `Interfaces` and `WifiController`.
435    pub fn new(
436        interfaces: &'a mut Interfaces<'static>,
437        controller: &'a mut WifiController<'static>,
438    ) -> Self {
439        Self {
440            interfaces,
441            controller,
442        }
443    }
444}
445
446pub(crate) fn reset_globals() {
447    // Close all CSI delivery gates so any late-firing WiFi callback runs
448    // are no-ops. The CSI callback stays registered with esp-radio after stop
449    // (the radio itself is still up), but with the gates closed the callback
450    // short-circuits before it touches the log channel or the user's callback.
451    // Without this, a collector keeps emitting CSI lines on the serial port
452    // well after `send_stop()`.
453    //
454    // The statistics counters are deliberately NOT cleared here. This runs at the END of
455    // `run_inner`, so clearing them destroyed the run's numbers at the moment the run finished —
456    // every post-collection `show-stats` on the classic AP/STA path reported `RX Total Packets: 0`
457    // however many frames the callback had counted, which reads as "the radio received nothing"
458    // when the truth was "the radio received plenty and the log path could not keep up". The
459    // counters are the only evidence a user has that captured CSI was dropped rather than never
460    // captured, and this was throwing that evidence away.
461    //
462    // `stats::reset` now runs at the START of a run instead (see `run_inner`), which is both what
463    // the README already documents ("counters reset on the start of each new `start` collection")
464    // and what the HE20 path already did via `stats_begin_run`.
465    crate::csi::delivery::reset();
466}
467
468/// Primary orchestration object for a CSI node.
469///
470/// Construct with [`CSINode::new`] (or [`CSINode::new_collector`] for the common
471/// case), configure optional protocol / traffic frequency, then call `run()`.
472pub struct CSINode<'a> {
473    role: NodeRole,
474    /// Whether captured CSI is delivered off-device. See
475    /// [`CSINode::set_csi_output_enabled`].
476    csi_output_enabled: bool,
477    io_tasks: IOTaskConfig,
478    /// CSI Configuration
479    csi_config: Option<CsiConfiguration>,
480    /// Traffic Generation Frequency
481    traffic_freq_hz: Option<u16>,
482    hardware: NodeHardware<'a>,
483    protocol: Option<Protocol>,
484    /// ICMP flood sends unsolicited echo replies (one-directional traffic)
485    /// instead of echo requests. See [`CSINode::set_flood_unsolicited_reply`].
486    flood_unsolicited_reply: bool,
487    /// Pluggable Wi-Fi bring-up back-end. Defaults to [`StandardProfile`];
488    /// override with [`CSINode::set_radio_profile`].
489    profile: &'static dyn RadioProfile,
490    /// How much this node does with the CSI it collects. Restored with the central/peripheral
491    /// taxonomy; see [`CollectionMode`](crate::CollectionMode).
492    collection_mode: crate::CollectionMode,
493    /// Forced ESP-NOW peer PHY rate, set by [`CSINode::set_rate`].
494    esp_now_rate: Option<esp_radio::esp_now::WifiPhyRate>,
495}
496
497impl<'a> CSINode<'a> {
498    /// Create a node in the given role.
499    ///
500    /// CSI output is enabled by default. An [`NodeRole::Emitter`] captures no CSI,
501    /// so the setting has no effect there.
502    pub fn new(
503        role: NodeRole,
504        csi_config: Option<CsiConfiguration>,
505        traffic_freq_hz: Option<u16>,
506        hardware: NodeHardware<'a>,
507    ) -> Self {
508        Self {
509            role,
510            csi_output_enabled: true,
511            io_tasks: IOTaskConfig::default(),
512            csi_config,
513            traffic_freq_hz,
514            hardware,
515            collection_mode: crate::CollectionMode::Collector,
516            esp_now_rate: None,
517            protocol: None,
518            flood_unsolicited_reply: false,
519            profile: &StandardProfile,
520        }
521    }
522
523    /// Convenience constructor for a collector node.
524    pub fn new_collector(
525        mode: CollectorMode,
526        csi_config: Option<CsiConfiguration>,
527        traffic_freq_hz: Option<u16>,
528        hardware: NodeHardware<'a>,
529    ) -> Self {
530        Self::new(
531            NodeRole::Collector(mode),
532            csi_config,
533            traffic_freq_hz,
534            hardware,
535        )
536    }
537
538    /// Convenience constructor for an emitter node.
539    pub fn new_emitter(config: EmitterConfig, hardware: NodeHardware<'a>) -> Self {
540        Self::new(NodeRole::Emitter(config), None, None, hardware)
541    }
542
543    /// Get the node's role.
544    pub fn get_role(&self) -> &NodeRole {
545        &self.role
546    }
547
548    /// Whether captured CSI is currently delivered off-device.
549    pub fn csi_output_enabled(&self) -> bool {
550        self.csi_output_enabled
551    }
552
553    /// If this is a collector, return its capture mode.
554    pub fn get_collector_mode(&self) -> Option<&CollectorMode> {
555        match &self.role {
556            NodeRole::Collector(mode) => Some(mode),
557            _ => None,
558        }
559    }
560
561    /// If this is an emitter, return its configuration.
562    pub fn get_emitter_config(&self) -> Option<&EmitterConfig> {
563        match &self.role {
564            NodeRole::Emitter(config) => Some(config),
565            _ => None,
566        }
567    }
568
569    /// If this is a central, return its operating mode.
570    pub fn get_central_mode(&self) -> Option<&CentralOpMode> {
571        match &self.role {
572            NodeRole::Central(mode) => Some(mode),
573            _ => None,
574        }
575    }
576
577    /// If this is a peripheral, return its operating mode.
578    pub fn get_peripheral_mode(&self) -> Option<&PeripheralOpMode> {
579        match &self.role {
580            NodeRole::Peripheral(mode) => Some(mode),
581            _ => None,
582        }
583    }
584
585    /// Update CSI configuration.
586    pub fn set_csi_config(&mut self, config: CsiConfiguration) {
587        self.csi_config = Some(config);
588    }
589
590    /// Update Wi-Fi Station configuration (only applies to a station collector).
591    pub fn set_station_config(&mut self, config: WifiStationConfig) {
592        if let NodeRole::Collector(CollectorMode::Station(_)) = &mut self.role {
593            self.role = NodeRole::Collector(CollectorMode::Station(config));
594        }
595    }
596
597    /// Set traffic generation frequency in Hz (station / softAP collectors).
598    pub fn set_traffic_frequency(&mut self, freq_hz: u16) {
599        self.traffic_freq_hz = Some(freq_hz);
600    }
601
602    /// Enable or disable delivery of captured CSI off-device.
603    ///
604    /// When disabled the radio still captures CSI — keeping the RX path and its
605    /// timing identical — but nothing is decoded, logged, or handed to a callback.
606    /// Useful for a node whose only job is to keep traffic on air, or for
607    /// measuring capture overhead without the delivery cost.
608    ///
609    /// Has no effect on an [`NodeRole::Emitter`], which captures nothing.
610    pub fn set_csi_output_enabled(&mut self, enabled: bool) {
611        self.csi_output_enabled = enabled;
612    }
613
614    /// Set TX/RX task enablement for the node.
615    /// Set how much this node does with the CSI it collects.
616    ///
617    /// A separate call rather than a fifth argument to [`CSINode::new`]: the four-argument form is
618    /// what the open-source CLI calls, and widening it would break every existing caller to serve
619    /// a mode most of them never set.
620    pub fn set_collection_mode(&mut self, mode: crate::CollectionMode) {
621        self.collection_mode = mode;
622    }
623
624    /// Which collection mode this node is running in.
625    pub fn collection_mode(&self) -> crate::CollectionMode {
626        self.collection_mode
627    }
628
629    /// Force the ESP-NOW peer PHY rate.
630    ///
631    /// ESP-NOW only, by design — a station derives its rate from the AP it associated with, and a
632    /// sniffer from whatever it overhears, so neither has a rate to force. Stored here and applied
633    /// when the ESP-NOW roles are wired into the run loop; until then it is recorded and unused
634    /// rather than silently dropped.
635    pub fn set_rate(&mut self, rate: esp_radio::esp_now::WifiPhyRate) {
636        self.esp_now_rate = Some(rate);
637    }
638
639    pub fn set_io_tasks(&mut self, io_tasks: IOTaskConfig) {
640        self.io_tasks = io_tasks;
641    }
642
643    /// Enable or disable TX task work.
644    pub fn set_tx_enabled(&mut self, enabled: bool) {
645        self.io_tasks.tx_enabled = enabled;
646    }
647
648    /// Enable or disable RX task work.
649    pub fn set_rx_enabled(&mut self, enabled: bool) {
650        self.io_tasks.rx_enabled = enabled;
651    }
652
653    /// Get current TX/RX task configuration.
654    pub fn get_io_tasks(&self) -> IOTaskConfig {
655        self.io_tasks
656    }
657
658    /// Replace the node's role.
659    pub fn set_role(&mut self, role: NodeRole) {
660        self.role = role;
661    }
662
663    /// Set Wi-Fi protocol (overrides default).
664    pub fn set_protocol(&mut self, protocol: Protocol) {
665        self.protocol = Some(protocol);
666    }
667
668    /// Install a Wi-Fi bring-up profile (overrides the default
669    /// [`StandardProfile`]). Pass a reference to a zero-sized profile value,
670    /// e.g. `node.set_radio_profile(&MyProfile);`.
671    pub fn set_radio_profile(&mut self, profile: &'static dyn RadioProfile) {
672        self.profile = profile;
673    }
674
675    /// Make the ICMP traffic flood send unsolicited echo **replies** instead
676    /// of echo requests.
677    ///
678    /// The peer's IP stack silently ignores an unsolicited reply, so the
679    /// generated traffic becomes strictly one-directional: the peer still
680    /// hardware-ACKs every data frame (rate control stays fed) and captures
681    /// CSI per frame, but never transmits an IP-level response. This halves
682    /// the on-air frame count versus request/reply and stabilizes the offered
683    /// rate under CSMA contention. Trade-off: this node receives no CSI back
684    /// from the peer's replies.
685    pub fn set_flood_unsolicited_reply(&mut self, enabled: bool) {
686        self.flood_unsolicited_reply = enabled;
687    }
688
689    /// Run the node for `duration` seconds with internal collection.
690    ///
691    /// This initializes Wi-Fi, configures CSI, and starts mode-specific tasks.
692    pub async fn run_duration(&mut self, duration: u64, client: &mut CSINodeClient) {
693        self.run_inner(Some(duration), Some(client)).await;
694    }
695
696    /// Shared implementation behind [`run`](Self::run) and
697    /// [`run_duration`](Self::run_duration).
698    ///
699    /// `duration`/`client` are `Some` only on the timed `run_duration` path:
700    /// when set, each mode arm runs an extra concurrent future that stops the
701    /// node after `duration` seconds (and, with RX enabled, drains CSI to the
702    /// logger via `client`). When `None` the node runs until externally
703    /// stopped via [`CSINodeClient::send_stop`].
704    async fn run_inner(&mut self, duration: Option<u64>, client: Option<&mut CSINodeClient>) {
705        // Zero the counters and stamp the capture start so `show-stats` describes THIS run, and
706        // still describes it after the run ends. Deliberately here rather than in `reset_globals`,
707        // which runs at stop — see the note there. Mirrors what the HE20 collector path already
708        // does with `stats_begin_run`.
709        #[cfg(feature = "statistics")]
710        crate::stats::stats_begin_run();
711
712        let interfaces = &mut self.hardware.interfaces;
713        let controller = &mut self.hardware.controller;
714
715        // Applied every run (not only when set) so the process-wide flood-kind
716        // flag never leaks from a previous, differently-configured run.
717        crate::collector::sta::set_icmp_flood_unsolicited(self.flood_unsolicited_reply);
718
719        // Applied every run, for the same reason as the flood flag above: the gate is
720        // process-wide, so a node left as a Listener by a previous run would silently stop
721        // processing CSI in this one. Read by the ESP-NOW central to decide whether it does
722        // anything with what it captures.
723        crate::set_runtime_collection_mode(
724            self.collection_mode == crate::CollectionMode::Collector,
725        );
726
727        // Deal with esp-radio's built-in ESP-NOW receive dispatcher before any other Wi-Fi
728        // reconfiguration runs — see `suppress_espnow_rx` for why this must happen this early.
729        //
730        // WHICH treatment depends on the role, and getting it wrong is silent. `suppress_espnow_rx`
731        // permanently UNREGISTERS the callback, which is right for the 802.11 roles — none of them
732        // reads ESP-NOW, and the stock dispatcher heap-allocates every overheard vendor action
733        // frame into a deque nothing drains. It is exactly wrong for the ESP-NOW roles, which would
734        // then be deaf: a peripheral would never see a control frame and a fast source would never
735        // hear the discovery beacon, both while looking perfectly healthy.
736        //
737        // So an ESP-NOW role installs the static-pool dispatcher instead, which replaces the
738        // allocating one rather than removing it — same protection against the heap growth, and the
739        // frames still arrive.
740        if matches!(&self.role, NodeRole::Central(_) | NodeRole::Peripheral(_)) {
741            crate::esp_now_pool::install();
742        } else {
743            suppress_espnow_rx();
744        }
745        // Let the freshly-constructed radio state settle before the first C5
746        // reconfiguration mutation (no-op off C5).
747        c5_radio_settle().await;
748
749        let is_ap = matches!(
750            &self.role,
751            NodeRole::Collector(CollectorMode::AccessPoint(_))
752        );
753        let is_sniffer = matches!(&self.role, NodeRole::Collector(CollectorMode::Sniffer(_)));
754        let is_emitter = matches!(&self.role, NodeRole::Emitter(_));
755
756        // An emitter never captures, so CSI is only ever armed for a collector.
757        // Everything downstream keys off this rather than re-testing the role.
758        let rx_enabled = self.io_tasks.rx_enabled && !is_emitter;
759
760        // Radio-profile back-end (Copy handle; does not alias `self.hardware`).
761        // `bringup` decides whether the profile takes over the extended Wi-Fi
762        // bring-up sequence for this role/protocol.
763        let profile = self.profile;
764        let bringup = profile.wants_bringup(&self.role, self.protocol);
765
766        // Apply protocol ladder before STA bring-up / CSI. Generic chip-level tuning
767        // lives in the radio profile; specialised back-ends may rebuild the set
768        // entirely. Skipped for an emitter, which pins its own protocol set during
769        // bring-up to match its forced TX PHY.
770        if let Some(protocol) = self.protocol.take() {
771            if !is_emitter {
772                let base = Protocols::default().with_2_4(protocol_ladder_2_4(protocol));
773                let protocols = profile.tune_protocols(&self.role, protocol, base);
774                controller.set_protocols(protocols).unwrap();
775                c5_radio_settle().await;
776            }
777            self.protocol = Some(protocol);
778        }
779
780        if bringup && !is_emitter {
781            profile.apply_bandwidth(controller);
782            c5_radio_settle().await;
783        }
784
785        // Tasks necessary for a station collector.
786        let sta_interface =
787            if let NodeRole::Collector(CollectorMode::Station(config)) = &self.role {
788                let ifaces = sta_init(
789                    &mut interfaces.station,
790                    config,
791                    controller,
792                    profile,
793                    bringup,
794                );
795                // Band selection comes *after* `sta_init`, which is what configures and
796                // starts the interface: `esp_wifi_set_band_mode` requires a started
797                // controller, so doing this first failed silently and left the station on
798                // whatever band a previous run had selected.
799                //
800                // With a channel hint, pin the band it implies. Without one, select both
801                // bands — pinning a single band would make an access point on the other
802                // one invisible, which presents as a bare "no access point found".
803                #[cfg(feature = "esp32c5")]
804                {
805                    match config.channel_hint {
806                        Some(channel) => apply_band_for_channel(controller, channel),
807                        None => apply_band_auto(controller),
808                    }
809                    c5_radio_settle().await;
810                }
811                Some(ifaces)
812            } else {
813                None
814            };
815        if bringup && sta_interface.is_some() {
816            profile.apply_protocols_post(controller);
817            c5_radio_settle().await;
818        }
819
820        // Self-contained softAP: bring up the AP-side embassy-net stack (static
821        // IP) and apply the AP config to the controller. `interfaces.access_point`
822        // is disjoint from `.station`/`.sniffer`, so this borrow is fine.
823        let ap_interface = if let NodeRole::Collector(CollectorMode::AccessPoint(config)) =
824            &self.role
825        {
826            #[cfg(feature = "esp32c5")]
827            if config.secondary_channel().is_none() {
828                apply_band_for_channel(controller, config.channel());
829            }
830            if let Some(secondary) = config.secondary_channel() {
831                apply_ht40_channel(controller, config.channel(), secondary);
832                c5_radio_settle().await;
833            }
834            let ifaces = ap_init(
835                &mut interfaces.access_point,
836                config,
837                controller,
838                profile,
839                bringup,
840            );
841            if bringup {
842                profile.apply_protocols_post(controller);
843            }
844            // The AP `set_config` restarts the radio; settle before `set_csi`.
845            c5_radio_settle().await;
846            Some(ifaces)
847        } else {
848            None
849        };
850
851        // Build CSI Configuration. An emitter captures nothing, so this is only
852        // meaningful for a collector — but it is cheap and keeps the flow linear.
853        let mut config = match self.csi_config {
854            Some(ref config) => {
855                log_ln!("CSI Configuration Set: {:?}", config);
856                build_csi_config(config)
857            }
858            None => {
859                let default_config = CsiConfiguration::default();
860                log_ln!(
861                    "No CSI Configuration Provided. Going with defaults: {:?}",
862                    default_config
863                );
864                build_csi_config(&default_config)
865            }
866        };
867        // Let the radio profile enable any extra acquisition modes it needs
868        // (default is a no-op) before the config is registered/cloned.
869        profile.tune_csi_acquisition(&mut config);
870
871        log_ln!("Wi-Fi Controller Started");
872        CSI_OUTPUT_ENABLED.store(self.csi_output_enabled, Ordering::Relaxed);
873        // Sequence-drop detection tracks per-source-MAC sequence numbers, so it
874        // works for any collector: the emitter's driver-assigned incrementing
875        // sequence numbers make gaps in a capture measurable.
876        set_seq_drop_detection(!is_emitter);
877
878        // Keep a clone so the STA recovery path in `run_sta_connect` can re-apply
879        // after a stop/start cycle (stop clears the CSI filter/callback).
880        //
881        // Only register the CSI callback when RX is actually enabled — otherwise
882        // the radio fires `capture_csi_info` for every overheard 802.11 frame on
883        // the WiFi task hot path for no purpose.
884        let csi_config_for_recovery = config.clone();
885        // The sniffer arm sets CSI after locking its channel; the AP arm sets it
886        // inside `run_ap`, because `set_config(AccessPoint)` restarts the radio and
887        // clears the CSI filter.
888        if rx_enabled && !is_sniffer && !is_ap {
889            set_csi(controller, config.clone());
890            // Settle after enabling CSI before the role task issues its first
891            // set_channel / TX so the run loop doesn't start into a pending IRQ.
892            c5_radio_settle().await;
893        }
894        // Immutable borrow of a *different* `interfaces` field than the station
895        // arm touches, so this disjoint borrow is fine. Used by the sniffer arm and
896        // to clear promiscuous mode on station shutdown.
897        let sniffer = &interfaces.sniffer;
898
899        match &self.role {
900            NodeRole::Emitter(emitter_config) => {
901                // The emitter owns its whole bring-up (forced TX PHY, unassociated
902                // interface start, channel lock) inside `run_emitter`, because the
903                // forced rate has to be applied before the interface starts.
904                let main_task = run_emitter(controller, interfaces, emitter_config);
905                drive_main(main_task, false, duration, client).await;
906            }
907            NodeRole::Collector(mode) => match mode {
908                CollectorMode::Sniffer(sniffer_config) => {
909                    #[cfg(feature = "esp32c5")]
910                    {
911                        let band = if sniffer_config.channel() >= 36 {
912                            BandMode::_5G
913                        } else {
914                            BandMode::_2_4G
915                        };
916                        controller.set_band_mode(band).unwrap();
917                    }
918                    sniffer.set_promiscuous_mode(true).unwrap();
919                    controller
920                        .set_channel(sniffer_config.channel(), SecondaryChannel::None)
921                        .unwrap();
922                    if bringup {
923                        profile.apply_sniffer_radio(controller);
924                        c5_radio_settle().await;
925                    }
926                    if rx_enabled {
927                        set_csi(controller, config.clone());
928                    }
929                    // The sniffer arm has no `main_task`, so it drives CSI
930                    // collection directly rather than through `drive_main`.
931                    match (duration, rx_enabled) {
932                        (Some(d), true) => {
933                            join(
934                                run_process_csi_packet(),
935                                csi_data_collection(client.unwrap(), d),
936                            )
937                            .await;
938                            // `csi_data_collection` signals stop, so the join
939                            // returns; this trailing await lets the rate task
940                            // observe the stop and exit (preserves prior behavior).
941                            run_process_csi_packet().await;
942                        }
943                        (Some(d), false) => stop_after_duration(d).await,
944                        (None, true) => run_process_csi_packet().await,
945                        (None, false) => wait_for_stop().await,
946                    }
947                    sniffer.set_promiscuous_mode(false).unwrap();
948                }
949                CollectorMode::AccessPoint(ap_config) => {
950                    // Start the AP, run the net stack + optional DHCP server, and
951                    // collect CSI from associated stations' uplink frames. CSI is
952                    // registered inside `run_ap` (after the AP-start radio restart).
953                    let (ap_stack, ap_runner) = ap_interface.unwrap();
954                    let main_task = run_ap(
955                        controller,
956                        ap_stack,
957                        ap_runner,
958                        ap_config,
959                        csi_config_for_recovery,
960                        self.io_tasks,
961                        self.traffic_freq_hz,
962                    );
963                    drive_main(main_task, rx_enabled, duration, client).await;
964                    sniffer.set_promiscuous_mode(false).unwrap();
965                }
966                CollectorMode::Station(_sta_config) => {
967                    // 1. Connect to the Wi-Fi network.
968                    // 2. Run DHCP / NTP sync if enabled in config.
969                    // 3. Drive STA connection handling and network operations.
970                    let (sta_stack, sta_runner) = sta_interface.unwrap();
971
972                    let main_task = run_sta_connect(
973                        controller,
974                        self.traffic_freq_hz,
975                        sta_stack,
976                        sta_runner,
977                        csi_config_for_recovery,
978                        self.io_tasks,
979                    );
980                    drive_main(main_task, rx_enabled, duration, client).await;
981                    // Clear promiscuous mode on shutdown. It is never enabled on
982                    // a STA interface, so this is a no-op — kept to match the
983                    // unconditional shutdown path the untimed `run()` always took.
984                    sniffer.set_promiscuous_mode(false).unwrap();
985                }
986            },
987
988            // ── ESP-NOW ───────────────────────────────────────────────────────────────────────
989            //
990            // The four drivers all take `&mut EspNow<'static>`, and this tree already has one:
991            // `interfaces.esp_now`, the same handle the emitter uses to transmit over ESP-NOW on
992            // classic chips. The pre-refactor loop built its own and threaded it through twenty
993            // integration points; none of that is needed here, and reproducing it would have been
994            // a second bring-up path to keep in step with this one.
995            //
996            // Borrowing note: the `sniffer` binding above holds `&interfaces.sniffer`. These arms
997            // take `&mut interfaces.esp_now`, a disjoint field, which the borrow checker accepts —
998            // and they must not touch `sniffer`, which is why none of them clears promiscuous mode
999            // on the way out. ESP-NOW never sets it.
1000            NodeRole::Central(mode) => match mode {
1001                CentralOpMode::EspNow(cfg) => {
1002                    // `is_collector` decides whether the central PROCESSES the CSI it captures or
1003                    // merely keeps it flowing — the `CollectionMode` distinction. It is read from
1004                    // the same runtime flag the delivery path uses, so a mode change mid-run is
1005                    // seen by both.
1006                    let main_task = run_esp_now_central(
1007                        &mut interfaces.esp_now,
1008                        // `run_esp_now_central` takes this as `_mac_addr` and does not read it —
1009                        // the peer MAC it actually uses comes from `EspNowConfig::peer_mac`. Passed
1010                        // as an explicit unset value rather than a plausible-looking address, so
1011                        // that if the driver ever starts reading it the result is obviously wrong
1012                        // rather than subtly wrong.
1013                        UNSET_MAC,
1014                        cfg,
1015                        self.traffic_freq_hz,
1016                        crate::IS_COLLECTOR.load(Ordering::Relaxed),
1017                        self.io_tasks,
1018                    );
1019                    drive_main(main_task, rx_enabled, duration, client).await;
1020                }
1021                CentralOpMode::EspNowFastCollector(cfg) => {
1022                    // Asymmetric simplex: this end beacons until it hears a source, then goes
1023                    // RX-only. All airtime belongs to the one transmitter, which is the whole point
1024                    // of the mode, so there is no TX task to drive alongside it.
1025                    let main_task = run_esp_now_fast_collector(
1026                        &mut interfaces.esp_now,
1027                        cfg,
1028                        self.io_tasks,
1029                    );
1030                    drive_main(main_task, rx_enabled, duration, client).await;
1031                }
1032                // The Wi-Fi modes of the central taxonomy are the SAME code as the collector arms
1033                // above — `central::{ap, sta}` is a re-export of `collector::{ap, sta}`, not a
1034                // second copy. Rather than duplicate two long bring-up sequences that would drift,
1035                // these are rejected at construction; a caller wanting them builds a
1036                // `NodeRole::Collector`, which is where that path lives now.
1037                CentralOpMode::WifiStation(_) | CentralOpMode::WifiAccessPoint(_) => {
1038                    log_ln!(
1039                        "central Wi-Fi modes are served by NodeRole::Collector — \
1040                         build CollectorMode::Station / ::AccessPoint instead"
1041                    );
1042                }
1043            },
1044            NodeRole::Peripheral(mode) => match mode {
1045                PeripheralOpMode::EspNow(cfg) => {
1046                    let main_task = run_esp_now_peripheral(
1047                        &mut interfaces.esp_now,
1048                        cfg,
1049                        self.traffic_freq_hz,
1050                        self.io_tasks,
1051                    );
1052                    drive_main(main_task, rx_enabled, duration, client).await;
1053                }
1054                PeripheralOpMode::EspNowFastSource(cfg) => {
1055                    // The source end of asymmetric simplex: a continuous forced-PHY unicast flood.
1056                    // It captures nothing, so RX is forced off regardless of `rx_enabled` — leaving
1057                    // the CSI rate task running here would compete for airtime with the flood it
1058                    // exists to produce.
1059                    let main_task = run_esp_now_fast_source(
1060                        &mut interfaces.esp_now,
1061                        cfg,
1062                        self.traffic_freq_hz,
1063                        self.io_tasks,
1064                    );
1065                    drive_main(main_task, false, duration, client).await;
1066                }
1067                // As above: the sniffer path is `CollectorMode::Sniffer`, not a second
1068                // implementation living under `peripheral`.
1069                PeripheralOpMode::WifiSniffer(_) => {
1070                    log_ln!(
1071                        "peripheral sniffer is served by NodeRole::Collector — \
1072                         build CollectorMode::Sniffer instead"
1073                    );
1074                }
1075            },
1076        }
1077
1078        STOP_SIGNAL.reset();
1079        reset_globals();
1080    }
1081
1082    /// Run the node until stopped.
1083    ///
1084    /// This initializes Wi-Fi, configures CSI, and starts mode-specific tasks.
1085    pub async fn run(&mut self) {
1086        self.run_inner(None, None).await;
1087    }
1088}
1089
1090/// Concurrent driver for a mode's `main_task`.
1091///
1092/// Joins `main_task` with the CSI rate task (RX enabled) or a stop waiter, and
1093/// — on the timed `run_duration` path (`duration`/`client` are `Some`) — a
1094/// third future that ends the run after `duration` seconds, draining CSI to the
1095/// logger via `client` when RX is enabled.
1096async fn drive_main(
1097    main_task: impl core::future::Future,
1098    rx_enabled: bool,
1099    duration: Option<u64>,
1100    client: Option<&mut CSINodeClient>,
1101) {
1102    match (duration, rx_enabled) {
1103        (Some(d), true) => {
1104            join3(
1105                main_task,
1106                run_process_csi_packet(),
1107                csi_data_collection(client.unwrap(), d),
1108            )
1109            .await;
1110        }
1111        (Some(d), false) => {
1112            join3(main_task, wait_for_stop(), stop_after_duration(d)).await;
1113        }
1114        (None, true) => {
1115            join(main_task, run_process_csi_packet()).await;
1116        }
1117        (None, false) => {
1118            join(main_task, wait_for_stop()).await;
1119        }
1120    }
1121}
1122
1123/// Expand a single requested 2.4 GHz protocol into the cumulative set the radio needs.
1124///
1125/// 802.11 protocol sets on 2.4 GHz are a **ladder**, not a choice: 11n is an extension of
1126/// 11b/11g and 11ax extends all three, so a station must advertise the rungs beneath the one
1127/// it wants. Advertising a lone bit (the previous `EnumSet::only(protocol)`) produces a set
1128/// no real link can use.
1129///
1130/// This was measured, not theorised. With an N-only set, an ESP32-C6 `sniffer` collected
1131/// **zero** HT20 frames from a working emitter — reproduced on both C6 boards, in both role
1132/// assignments — while ESP32-S3 collectors on the same link and the same config collected
1133/// normally (their driver tolerates the degenerate set). The emitter never hit this because
1134/// `emitter::phy` builds `B | G | N` for itself; only the collector path used `only()`.
1135///
1136/// `LR` is Espressif's proprietary long-range PHY rather than a rung on the ladder, so it
1137/// stays on its own. The 5 GHz-only rungs keep the previous one-bit behaviour instead of
1138/// being given an invented 2.4 GHz meaning — [`RadioProfile::tune_protocols`] owns the
1139/// 5 GHz set.
1140fn protocol_ladder_2_4(protocol: Protocol) -> EnumSet<Protocol> {
1141    match protocol {
1142        Protocol::B => EnumSet::only(Protocol::B),
1143        Protocol::G => Protocol::B | Protocol::G,
1144        Protocol::N => Protocol::B | Protocol::G | Protocol::N,
1145        Protocol::AX => Protocol::B | Protocol::G | Protocol::N | Protocol::AX,
1146        // Proprietary long-range, and the 5 GHz-only rungs: not part of the 2.4 GHz ladder.
1147        other => EnumSet::only(other),
1148    }
1149}
1150
1151#[cfg(test)]
1152mod protocol_ladder_tests {
1153    use super::*;
1154
1155    /// The rung under test must always be present, and every lower rung with it.
1156    #[test]
1157    fn each_rung_carries_the_ones_beneath_it() {
1158        assert_eq!(protocol_ladder_2_4(Protocol::B), EnumSet::only(Protocol::B));
1159        assert_eq!(protocol_ladder_2_4(Protocol::G), Protocol::B | Protocol::G);
1160        assert_eq!(
1161            protocol_ladder_2_4(Protocol::N),
1162            Protocol::B | Protocol::G | Protocol::N
1163        );
1164        assert_eq!(
1165            protocol_ladder_2_4(Protocol::AX),
1166            Protocol::B | Protocol::G | Protocol::N | Protocol::AX
1167        );
1168    }
1169
1170    /// Regression for the measured failure: an N-only 2.4 GHz set made an ESP32-C6
1171    /// collector capture zero HT20 frames. `N` must never be advertised alone.
1172    #[test]
1173    fn n_is_never_advertised_alone() {
1174        let set = protocol_ladder_2_4(Protocol::N);
1175        assert!(set.contains(Protocol::N));
1176        assert!(set.contains(Protocol::G), "11n needs 11g beneath it");
1177        assert!(set.contains(Protocol::B), "11n needs 11b beneath it");
1178        assert_ne!(set, EnumSet::only(Protocol::N));
1179    }
1180
1181    /// The collector ladder must match what the emitter already builds for itself in
1182    /// `emitter::phy` (`B | G | N`) — the two ends of an HT link have to agree, and the
1183    /// mismatch between them is exactly what this bug was.
1184    #[test]
1185    fn the_ht_rung_matches_the_emitters_own_set() {
1186        assert_eq!(
1187            protocol_ladder_2_4(Protocol::N),
1188            Protocol::B | Protocol::G | Protocol::N
1189        );
1190    }
1191
1192    /// `LR` is Espressif's proprietary long-range PHY, not a rung: it must stay alone,
1193    /// or enabling it would silently also advertise b/g/n.
1194    #[test]
1195    fn lr_stays_on_its_own() {
1196        assert_eq!(protocol_ladder_2_4(Protocol::LR), EnumSet::only(Protocol::LR));
1197    }
1198
1199    /// 5 GHz-only rungs keep the previous single-bit behaviour; the profile owns that band.
1200    #[test]
1201    fn five_ghz_rungs_are_untouched() {
1202        for p in [Protocol::A, Protocol::AC] {
1203            assert_eq!(protocol_ladder_2_4(p), EnumSet::only(p));
1204        }
1205    }
1206}