Skip to main content

esp_csi_rs/
node.rs

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