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}