esp-csi-rs 0.13.0

Wi-Fi Channel State Information (CSI) collection on ESP32, ESP32-C3, ESP32-C5, ESP32-C6 and ESP32-S3 for no_std Rust: sniffer, station, access point, emitter and ESP-NOW nodes
Documentation
//! The **Emitter** mode: transmit-only sounding.
//!
//! An emitter node is a **sensing transmitter that reports nothing** (see [`crate::model`]): it
//! originates the network's traffic and captures nothing. It exists to put known RF energy into the
//! channel so that other nodes — typically [sniffers](crate::CsiNode::sniffer) — can measure the channel's response to
//! it. It never associates: it forces its interface to a fixed TX PHY (see [`phy`]) and sends a
//! rate-agnostic frame (see [`frame`]) at a configured period.
//!
//! Because the frame carries no meaning, an emitter needs no peer, no handshake,
//! and no protocol — which is what makes it compose into any topology. One
//! emitter measured by many nodes, or several emitters distinguished by their
//! source MAC, are both just deployment choices.
//!
//! # Chip support
//!
//! How the frames leave the radio is chosen at compile time from the chip feature, because the
//! transport that radiates is not the same on every part:
//!
//! | Chip | Transport | Status |
//! |---|---|---|
//! | ESP32-C5, ESP32-C6 | Raw injection (`esp_wifi_80211_tx`) | Verified — roughly 90 CSI reports/s at a 10 ms period, measured at a paired sniffer |
//! | ESP32, ESP32-C3, ESP32-S3 | ESP-NOW broadcast (the `espnow` module) | Works; broadcast frames are never ACKed, so the offered rate stays flat whether or not anyone is listening |
//!
//! Raw injection is not offered on the classic parts because it does not radiate there: on the
//! ESP32-S3 `esp_wifi_80211_tx` returns `ESP_OK` for every frame and nothing reaches any receiver.
//! `docs/emitter-support.md` has the detail.

#[cfg(not(any(feature = "esp32c5", feature = "esp32c6")))]
pub mod espnow;
pub mod frame;
pub mod phy;

use embassy_futures::select::{Either, select};
use embassy_time::{Duration, Timer};

use esp_radio::wifi::ap::AccessPointConfig;
use esp_radio::wifi::sta::StationConfig;
use esp_radio::wifi::{Config, SecondaryChannel, WifiController};

use crate::radio::RadioInterfaces;

use crate::radio::apply_band_for_channel;
use crate::{STOP_SIGNAL, log_ln};

use frame::{BROADCAST, PROBE_FRAME_LEN, build_probe_frame};
#[cfg(any(feature = "esp32c5", feature = "esp32c6"))]
use frame::inject_probe_once;

/// Largest frame the CPU-utilization harness can pad up to. Sized to the 802.11
/// MTU rather than the minimum sounding frame so the experiment can sweep
/// on-air frame size without a reallocation.
#[cfg(feature = "cpu-test-tx")]
const CPU_TEST_MAX_FRAME_LEN: usize = 1500;

/// The padded buffer must still hold a minimum well-formed sounding frame.
#[cfg(feature = "cpu-test-tx")]
const _: () = assert!(CPU_TEST_MAX_FRAME_LEN >= PROBE_FRAME_LEN);

/// The TX PHY an emitter forces: format, bandwidth and secondary channel.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
#[cfg_attr(feature = "defmt", derive(defmt::Format))]
#[non_exhaustive]
pub enum EmitterPhy {
    /// HT20 (802.11n) — 20 MHz, no secondary channel.
    Ht20,
    /// HT40 (802.11n) — 40 MHz with the secondary channel above the primary.
    Ht40Above,
    /// HT40 (802.11n) — 40 MHz with the secondary channel below the primary.
    Ht40Below,
    /// HE20 (802.11ax SU) — 20 MHz. Needs an 802.11ax PHY: ESP32-C5 / C6 only.
    /// Pair with a receiver that requests HE-LTF acquisition
    /// ([`phy::he20_csi_acquisition`]) to receive the full ~242-subcarrier estimate.
    #[cfg(any(feature = "esp32c5", feature = "esp32c6"))]
    He20,
}

/// Name of [`EmitterPhy`] before 0.12, when an emitter could only force HT.
#[deprecated(since = "0.12.0", note = "renamed to `EmitterPhy`")]
pub type HtBandwidth = EmitterPhy;

impl EmitterPhy {
    /// Whether this is a 40 MHz configuration.
    pub fn is_forty(self) -> bool {
        matches!(self, EmitterPhy::Ht40Above | EmitterPhy::Ht40Below)
    }

    /// Whether this forces an 802.11ax (HE) PHY.
    pub fn is_he(self) -> bool {
        #[cfg(any(feature = "esp32c5", feature = "esp32c6"))]
        {
            matches!(self, EmitterPhy::He20)
        }
        #[cfg(not(any(feature = "esp32c5", feature = "esp32c6")))]
        {
            false
        }
    }

    /// The secondary-channel offset this PHY implies.
    pub fn secondary(self) -> SecondaryChannel {
        match self {
            EmitterPhy::Ht40Above => SecondaryChannel::Above,
            EmitterPhy::Ht40Below => SecondaryChannel::Below,
            _ => SecondaryChannel::None,
        }
    }

    /// ESP-IDF `wifi_phy_mode_t` value to force.
    fn phymode(self) -> u32 {
        match self {
            EmitterPhy::Ht20 => phy::WIFI_PHY_MODE_HT20,
            EmitterPhy::Ht40Above | EmitterPhy::Ht40Below => phy::WIFI_PHY_MODE_HT40,
            #[cfg(any(feature = "esp32c5", feature = "esp32c6"))]
            EmitterPhy::He20 => phy::WIFI_PHY_MODE_HE20,
        }
    }
}

/// TX parameters for an emitter.
#[derive(Clone, Copy)]
pub struct EmitterConfig {
    /// Primary channel. Every node in a capture set must share it.
    pub channel: u8,
    /// The forced TX PHY: HT20, HT40 with the secondary channel above/below the
    /// primary, or HE20 on the C5/C6.
    pub phy: EmitterPhy,
    /// Destination address of injected frames (broadcast by default). Addressing
    /// a specific receiver tends to raise that receiver's CSI callback rate.
    pub dst_mac: [u8; 6],
    /// Delay between injected frames (20 ms ≈ 50 frames/s by default).
    pub period: Duration,
    /// Inject on the STA interface (`true`) or the AP interface (`false`).
    pub use_sta_if: bool,
}

impl EmitterConfig {
    /// New config on `channel` forcing `phy`, broadcast destination, 20 ms
    /// period, STA interface.
    pub fn new(channel: u8, phy: EmitterPhy) -> Self {
        Self {
            channel,
            phy,
            dst_mac: BROADCAST,
            period: Duration::from_millis(20),
            use_sta_if: true,
        }
    }

    /// Set the destination address of injected frames.
    pub fn with_dst_mac(mut self, dst_mac: [u8; 6]) -> Self {
        self.dst_mac = dst_mac;
        self
    }

    /// Set the delay between injected frames.
    pub fn with_period(mut self, period: Duration) -> Self {
        self.period = period;
        self
    }

    /// Inject on the AP interface instead of the STA interface.
    pub fn with_ap_interface(mut self) -> Self {
        self.use_sta_if = false;
        self
    }

    /// An emitter takes part in no sensing session — it sounds for whoever listens — so it has no
    /// session role (`None`). There is no setter for this.
    pub const fn session_role() -> Option<crate::SessionRole> {
        None
    }

    /// An emitter is always a [`Transmitter`](crate::SensingRole::Transmitter): it sends the
    /// sounding PPDUs and measures nothing. There is no setter for this.
    pub const fn sensing_role() -> crate::SensingRole {
        crate::SensingRole::Transmitter
    }

    /// An emitter never reports ([`Reporting::None`](crate::Reporting::None)): it captures
    /// nothing, so it has nothing to report. There is no setter for this.
    pub const fn get_reporting() -> crate::Reporting {
        crate::Reporting::None
    }
}

impl Default for EmitterConfig {
    fn default() -> Self {
        Self::new(1, EmitterPhy::Ht20)
    }
}

#[cfg(feature = "defmt")]
impl defmt::Format for EmitterConfig {
    fn format(&self, fmt: defmt::Formatter<'_>) {
        defmt::write!(
            fmt,
            "EmitterConfig {{ channel: {}, phy: {}, period_ms: {} }}",
            self.channel,
            self.phy,
            self.period.as_millis()
        );
    }
}

/// Force the TX PHY, start the interface **without associating**, and lock the
/// channel.
///
/// The order matters: the forced TX rate has to be applied before `set_config`
/// starts the interface, and the bandwidth/protocol set has to be re-applied
/// after, because `set_config` embeds its own defaults.
fn bringup(controller: &mut WifiController<'_>, cfg: &EmitterConfig) {
    let forty = cfg.phy.is_forty();
    let phymode = cfg.phy.phymode();
    let force = |sta: bool| {
        if sta {
            phy::force_tx_sta_before_start(phymode)
        } else {
            phy::force_tx_ap(phymode)
        }
    };

    // `set_config` only calls `esp_wifi_start()` when the *mode* changes, so the
    // forced rate has to be applied before it — hence the `_before_start` names.
    // The driver's status code is reported rather than discarded: a rate that was
    // never applied produces an emitter that looks healthy while transmitting in
    // the wrong format, or not at all.
    let rc = force(cfg.use_sta_if);
    let started = if cfg.use_sta_if {
        controller.set_config(&Config::Station(StationConfig::default()))
    } else {
        controller.set_config(&Config::AccessPoint(AccessPointConfig::default()))
    };
    if started.is_err() {
        log_ln!("emitter: set_config failed");
    }

    // Re-apply after `set_config`, which embeds its own defaults, then re-force the
    // rate: `set_config` may stop/start the interface, and a restart drops a rate
    // that was configured before it.
    apply_band_for_channel(controller, cfg.channel);
    phy::apply_ht_bandwidth(controller, forty);
    phy::apply_tx_protocols(controller, cfg.phy.is_he());
    if controller
        .set_channel(cfg.channel, cfg.phy.secondary())
        .is_err()
    {
        log_ln!("emitter: set_channel failed");
    }
    let rc_post = force(cfg.use_sta_if);
    // The pre-start attempt can fail while the interface is not yet up; only the post-start result
    // decides the format frames go out in.
    if rc_post != 0 {
        log_ln!(
            "Emitter: forced TX PHY rejected (pre-start rc={}, post-start rc={}); frames will not use the requested format",
            rc,
            rc_post
        );
    }
}

/// Run the emitter: bring up the radio, then loop-inject until stopped.
pub(crate) async fn run_emitter(
    controller: &mut WifiController<'static>,
    interfaces: &mut RadioInterfaces,
    cfg: &EmitterConfig,
) {
    bringup(controller, cfg);
    // Transport is per PHY generation: the C5/C6 inject raw frames (measured working, and the
    // same path an out-of-tree profile uses), while the classic MACs accept raw injection and
    // never radiate it, so they transmit over ESP-NOW instead.
    #[cfg(not(any(feature = "esp32c5", feature = "esp32c6")))]
    espnow::bringup(
        controller,
        &interfaces.esp_now,
        &cfg.dst_mac,
        cfg.channel,
        cfg.phy.is_forty(),
    );

    // Source address is the interface the frames actually leave from, so a
    // receiver can attribute each frame to this emitter.
    let src = if cfg.use_sta_if {
        interfaces.station.mac_address()
    } else {
        interfaces.access_point.mac_address()
    };

    // The CPU-utilization experiment pads the frame, so size the buffer for the
    // largest frame that harness can ask for rather than the minimum.
    #[cfg(feature = "cpu-test-tx")]
    let mut frame = [0u8; CPU_TEST_MAX_FRAME_LEN];
    #[cfg(not(feature = "cpu-test-tx"))]
    let mut frame = [0u8; PROBE_FRAME_LEN];

    let len = build_probe_frame(&src, &cfg.dst_mac, &mut frame);

    // Raw injection goes through the sniffer handle, and on some parts the MAC will
    // only radiate an injected frame while promiscuous mode is enabled. It costs an
    // emitter nothing to enable it -- no CSI is armed and no RX callback is
    // registered on this role -- so enable it unconditionally rather than making
    // callers discover a chip-specific precondition.
    if interfaces.sniffer.set_promiscuous_mode(true).is_err() {
        log_ln!("emitter: enabling promiscuous mode failed; raw TX may not radiate");
    }

    log_ln!(
        "Emitter running: ch {}, {:?}, {} ms period, src {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
        cfg.channel,
        cfg.phy,
        cfg.period.as_millis(),
        src[0],
        src[1],
        src[2],
        src[3],
        src[4],
        src[5]
    );

    // A raw injection that the driver rejects is silent otherwise: the loop keeps
    // running and the node looks healthy while nothing reaches the air. Report the
    // first rejection (and only the first, to keep the hot path quiet) so a
    // "receiver sees nothing" investigation starts at the right end.
    let mut reported_failure = false;

    loop {
        // Under the CPU-utilization harness the rate, frame size, and silence are
        // steered per phase from the experiment's schedule; otherwise the
        // configured period and minimum frame are used unchanged.
        #[cfg(feature = "cpu-test-tx")]
        let (period, len, paused) = {
            use portable_atomic::Ordering;
            let rate = crate::TEST_TX_RATE_HZ.load(Ordering::Relaxed).max(1);
            let want = crate::TEST_TX_PAYLOAD_B.load(Ordering::Relaxed) as usize;
            (
                Duration::from_micros(1_000_000 / rate as u64),
                len.max(want.min(CPU_TEST_MAX_FRAME_LEN)),
                crate::TEST_TX_PAUSED.load(Ordering::Relaxed),
            )
        };
        #[cfg(not(feature = "cpu-test-tx"))]
        let (period, len, paused) = (cfg.period, len, false);

        if !paused {
            #[cfg(not(any(feature = "esp32c5", feature = "esp32c6")))]
            let sent = espnow::send_once(&mut interfaces.esp_now, &cfg.dst_mac, &frame[..len]);
            #[cfg(any(feature = "esp32c5", feature = "esp32c6"))]
            let sent =
                inject_probe_once(&mut interfaces.sniffer, cfg.use_sta_if, &frame[..len]).is_ok();
            match sent {
                true => {
                    // Count accepted frames so `get_pps_tx` / `get_total_tx_packets`
                    // report an emitter's offered rate. Without this an emitter looks
                    // idle in `show-stats`, which is exactly the wrong signal when
                    // diagnosing "the receiver sees nothing".
                    #[cfg(feature = "statistics")]
                    crate::stats::record_tx();
                }
                false => {
                    if !reported_failure {
                        reported_failure = true;
                        log_ln!("Emitter: ESP-NOW send rejected by the driver");
                    }
                }
            }
        }
        match select(STOP_SIGNAL.wait(), Timer::after(period)).await {
            Either::First(_) => {
                log_ln!("STOP signal received, shutting down emitter...");
                STOP_SIGNAL.signal(());
                return;
            }
            Either::Second(_) => {}
        }
    }
}