wifi-densepose/firmware/esp32-csi-node/main/csi_collector.c

821 lines
32 KiB
C

/**
* @file csi_collector.c
* @brief CSI data collection and ADR-018 binary frame serialization.
*
* Registers the ESP-IDF WiFi CSI callback and serializes incoming CSI data
* into the ADR-018 binary frame format for UDP transmission.
*
* ADR-029 extensions:
* - Channel-hop table for multi-band sensing (channels 1/6/11 by default)
* - Timer-driven channel hopping at configurable dwell intervals
* - NDP frame injection stub for sensing-first TX
*/
#include "csi_collector.h"
#include "nvs_config.h"
#include "stream_sender.h"
#include "edge_processing.h"
#include <string.h>
#include <stdlib.h>
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_timer.h"
#include "nvs.h"
#include "nvs_flash.h"
#include "sdkconfig.h"
/* ADR-060: Access the global NVS config for MAC filter and channel override. */
extern nvs_config_t g_nvs_config;
/* Defensive fix (#232, #375, #385, #386, #390): capture NVS config fields into
* module-local statics BEFORE wifi_init_sta() runs, because WiFi driver init
* can corrupt g_nvs_config (confirmed on device 80:b5:4e:c1:be:b8).
* main.c calls csi_collector_set_node_id() immediately after nvs_config_load(),
* and all runtime paths use the local copies exclusively. */
static uint8_t s_node_id = 1;
static bool s_node_id_early_set = false;
/* Defensive copy of MAC filter config — the CSI callback fires at 100-500 Hz
* and reads filter_mac_set + filter_mac on every invocation. If wifi_init_sta()
* corrupts g_nvs_config, the callback would read garbage, potentially causing
* LoadProhibited panics (observed: Core 0 panic after ~2400 callbacks). */
static uint8_t s_filter_mac[6] = {0};
static bool s_filter_mac_set = false;
/* ADR-057: Build-time guard — fail early if CSI is not enabled in sdkconfig.
* Without this, the firmware compiles but crashes at runtime with:
* "E (xxxx) wifi:CSI not enabled in menuconfig!"
* which is confusing for users flashing pre-built binaries. */
#ifndef CONFIG_ESP_WIFI_CSI_ENABLED
#error "CONFIG_ESP_WIFI_CSI_ENABLED must be set in sdkconfig. " \
"Run: idf.py menuconfig -> Component config -> Wi-Fi -> Enable WiFi CSI, " \
"or copy sdkconfig.defaults.template to sdkconfig.defaults before building."
#endif
static const char *TAG = "csi_collector";
/* ──────────────────────────────────────────────────────────────────
* ADR-100: Gain Lock (AGC + FFT scale).
*
* ESP32 WiFi PHY applies automatic gain control per packet, which
* manifests as a 20-30 % slow drift in CSI amplitude even with a
* completely static room — masking the real modulation caused by
* body motion. Ported from Francesco Pace's ESPectre (GPLv3,
* https://github.com/francescopace/espectre).
*
* The first ~300 packets after boot are sampled. We take the median
* AGC + FFT gain values and freeze them with two undocumented PHY
* routines from the IDF blob. If the median AGC is below the safe
* threshold (sensor sits very close to the AP), we *don't* lock —
* forcing a low gain causes the RX path to freeze.
* Supported targets: ESP32-S3 / C3 / C6. Older parts skip silently.
* ──────────────────────────────────────────────────────────────── */
#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32C6
#define RV_GAIN_LOCK_SUPPORTED 1
/* Overlay struct on wifi_csi_info_t.rx_ctrl exposing the hidden agc/fft fields. */
typedef struct {
unsigned : 32; unsigned : 32; unsigned : 32;
unsigned : 32; unsigned : 32; unsigned : 16;
signed fft_gain : 8;
unsigned agc_gain : 8;
unsigned : 32; unsigned : 32;
unsigned : 32; unsigned : 32; unsigned : 32;
unsigned : 32;
} rv_phy_rx_ctrl_t;
extern void phy_fft_scale_force(bool force_en, int8_t force_value);
extern void phy_force_rx_gain(int force_en, int force_value);
/* ── ADR-108: NVS persistence of gain-lock values ────────────────
* After the first successful gain-lock, save AGC/FFT medians into NVS
* (namespace "csi_cfg", keys "gl_agc"/"gl_fft"). On subsequent boots
* the FW loads them and immediately forces the gain — reboot → CSI
* ready in ~0.5 s instead of ~3 s waiting for 300 calibration packets.
*
* Stored values are tied to: this sensor location + this AP MAC +
* this channel + this antenna orientation. If any of those change,
* the saved values may be wrong — but harmless: the WiFi PHY will
* just receive slightly off-optimal CSI until the operator triggers
* a re-calibration (today: clear NVS, reboot; future: dedicated REST).
*/
#define RV_GAIN_NVS_NS "csi_cfg"
#define RV_GAIN_NVS_K_AGC "gl_agc"
#define RV_GAIN_NVS_K_FFT "gl_fft"
/* ADR-111: BSSID of the AP that gain-lock was calibrated against.
* 6-byte blob. On boot, if the currently-connected AP MAC differs from
* the saved value, the cached AGC/FFT are ignored and a full calibration
* runs (gain-lock is tied to a specific AP path; swapping APs invalidates
* it). The new MAC is written alongside AGC/FFT after re-calibration. */
#define RV_GAIN_NVS_K_AP_MAC "gl_ap_mac"
static esp_err_t rv_gain_load_from_nvs(uint8_t *agc_out, int8_t *fft_out,
uint8_t mac_out[6])
{
nvs_handle_t h;
esp_err_t err = nvs_open(RV_GAIN_NVS_NS, NVS_READONLY, &h);
if (err != ESP_OK) return err;
uint8_t agc = 0;
int8_t fft = 0;
err = nvs_get_u8(h, RV_GAIN_NVS_K_AGC, &agc);
if (err == ESP_OK) err = nvs_get_i8(h, RV_GAIN_NVS_K_FFT, &fft);
/* AP MAC is optional — older NVS blobs predate ADR-111 and have only
* AGC+FFT. Treat a missing MAC as a wildcard match so a one-time
* upgrade doesn't force every node to do a full re-cal. */
if (err == ESP_OK && mac_out != NULL) {
size_t want = 6;
esp_err_t mac_err = nvs_get_blob(h, RV_GAIN_NVS_K_AP_MAC, mac_out, &want);
if (mac_err != ESP_OK || want != 6) {
memset(mac_out, 0, 6);
}
}
nvs_close(h);
if (err == ESP_OK) { *agc_out = agc; *fft_out = fft; }
return err;
}
static void rv_gain_save_to_nvs(uint8_t agc, int8_t fft, const uint8_t mac[6])
{
nvs_handle_t h;
esp_err_t err = nvs_open(RV_GAIN_NVS_NS, NVS_READWRITE, &h);
if (err != ESP_OK) {
ESP_LOGW("csi_collector", "gain-lock NVS save: nvs_open failed: %s",
esp_err_to_name(err));
return;
}
nvs_set_u8(h, RV_GAIN_NVS_K_AGC, agc);
nvs_set_i8(h, RV_GAIN_NVS_K_FFT, fft);
if (mac != NULL) {
nvs_set_blob(h, RV_GAIN_NVS_K_AP_MAC, mac, 6);
}
nvs_commit(h);
nvs_close(h);
}
#define RV_GAIN_CAL_PACKETS 300u
#define RV_GAIN_MIN_SAFE_AGC 30u /* < 30 → forcing freezes RX. */
static uint8_t s_agc_samples[RV_GAIN_CAL_PACKETS];
static int8_t s_fft_samples[RV_GAIN_CAL_PACKETS];
static uint16_t s_gain_pkt_count = 0;
static bool s_gain_locked = false;
static bool s_gain_skipped_strong = false;
static uint8_t s_gain_agc_value = 0;
static int8_t s_gain_fft_value = 0;
static int rv_cmp_u8(const void *a, const void *b) {
return (int)*(const uint8_t *)a - (int)*(const uint8_t *)b;
}
static int rv_cmp_i8(const void *a, const void *b) {
return (int)*(const int8_t *)a - (int)*(const int8_t *)b;
}
static void rv_gain_lock_process(const wifi_csi_info_t *info)
{
if (s_gain_locked || info == NULL) return;
/* ADR-108: short-circuit calibration if previous values are in NVS.
* ADR-111: also compare the saved BSSID with the currently-connected
* AP. If they differ, the cached gain is invalid (different AP path
* → different multipath, different optimal AGC) — discard it and run
* a full calibration against the new AP. */
static bool s_nvs_checked = false;
if (!s_nvs_checked) {
s_nvs_checked = true;
uint8_t agc = 0; int8_t fft = 0; uint8_t saved_mac[6] = {0};
if (rv_gain_load_from_nvs(&agc, &fft, saved_mac) == ESP_OK &&
agc >= RV_GAIN_MIN_SAFE_AGC)
{
/* Read the current AP MAC. If we can't (not connected yet)
* the gain-lock callback should not be firing at all — but
* be defensive and skip the cache if AP info is unavailable. */
wifi_ap_record_t ap;
bool ap_ok = (esp_wifi_sta_get_ap_info(&ap) == ESP_OK);
bool wildcard = true;
for (int i = 0; i < 6; i++) {
if (saved_mac[i] != 0) { wildcard = false; break; }
}
if (ap_ok && (wildcard ||
memcmp(saved_mac, ap.bssid, 6) == 0))
{
phy_fft_scale_force(true, fft);
phy_force_rx_gain(1, (int)agc);
s_gain_agc_value = agc;
s_gain_fft_value = fft;
s_gain_locked = true;
ESP_LOGI("csi_collector",
"gain-lock RESTORED from NVS: AGC=%u FFT=%d "
"AP=%02x:%02x:%02x:%02x:%02x:%02x%s",
(unsigned)agc, (int)fft,
ap.bssid[0], ap.bssid[1], ap.bssid[2],
ap.bssid[3], ap.bssid[4], ap.bssid[5],
wildcard ? " (legacy NVS, no MAC stored)" : "");
return;
}
if (ap_ok) {
ESP_LOGW("csi_collector",
"gain-lock NVS MISS: saved AP=%02x:%02x:%02x:%02x:%02x:%02x "
"→ current=%02x:%02x:%02x:%02x:%02x:%02x. Re-calibrating.",
saved_mac[0], saved_mac[1], saved_mac[2],
saved_mac[3], saved_mac[4], saved_mac[5],
ap.bssid[0], ap.bssid[1], ap.bssid[2],
ap.bssid[3], ap.bssid[4], ap.bssid[5]);
}
}
}
const rv_phy_rx_ctrl_t *phy = (const rv_phy_rx_ctrl_t *)info;
if (s_gain_pkt_count < RV_GAIN_CAL_PACKETS) {
s_agc_samples[s_gain_pkt_count] = phy->agc_gain;
s_fft_samples[s_gain_pkt_count] = phy->fft_gain;
s_gain_pkt_count++;
if (s_gain_pkt_count == RV_GAIN_CAL_PACKETS / 4 ||
s_gain_pkt_count == RV_GAIN_CAL_PACKETS / 2 ||
s_gain_pkt_count == (3u * RV_GAIN_CAL_PACKETS) / 4u) {
ESP_LOGI(TAG, "gain-lock cal %u%% (%u/%u, AGC=%u FFT=%d)",
(unsigned)((s_gain_pkt_count * 100u) / RV_GAIN_CAL_PACKETS),
(unsigned)s_gain_pkt_count, (unsigned)RV_GAIN_CAL_PACKETS,
(unsigned)phy->agc_gain, (int)phy->fft_gain);
}
return;
}
/* Reached the calibration target — compute medians, lock or skip. */
qsort(s_agc_samples, RV_GAIN_CAL_PACKETS, sizeof(uint8_t), rv_cmp_u8);
qsort(s_fft_samples, RV_GAIN_CAL_PACKETS, sizeof(int8_t), rv_cmp_i8);
s_gain_agc_value = s_agc_samples[RV_GAIN_CAL_PACKETS / 2];
s_gain_fft_value = s_fft_samples[RV_GAIN_CAL_PACKETS / 2];
if (s_gain_agc_value < RV_GAIN_MIN_SAFE_AGC) {
s_gain_skipped_strong = true;
ESP_LOGW(TAG,
"gain-lock SKIPPED: AGC median=%u < %u (signal too strong, "
"forcing would freeze RX). Move sensor 2-3 m from AP.",
(unsigned)s_gain_agc_value, (unsigned)RV_GAIN_MIN_SAFE_AGC);
} else {
phy_fft_scale_force(true, s_gain_fft_value);
phy_force_rx_gain(1, (int)s_gain_agc_value);
ESP_LOGI(TAG,
"gain-lock APPLIED: AGC=%u FFT=%d (median of %u packets) — "
"baseline drift should now collapse.",
(unsigned)s_gain_agc_value, (int)s_gain_fft_value,
(unsigned)RV_GAIN_CAL_PACKETS);
/* ADR-108: persist for next boot — short-circuit calibration.
* ADR-111: also persist the AP BSSID this calibration ran against
* so the boot-time short-circuit can detect AP swaps and discard
* stale gain values. */
uint8_t cur_mac[6] = {0};
wifi_ap_record_t ap;
if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
memcpy(cur_mac, ap.bssid, 6);
}
rv_gain_save_to_nvs(s_gain_agc_value, s_gain_fft_value, cur_mac);
ESP_LOGI(TAG,
"gain-lock PERSISTED to NVS (AGC=%u FFT=%d AP=%02x:%02x:%02x:%02x:%02x:%02x)",
(unsigned)s_gain_agc_value, (int)s_gain_fft_value,
cur_mac[0], cur_mac[1], cur_mac[2],
cur_mac[3], cur_mac[4], cur_mac[5]);
}
s_gain_locked = true;
}
#else
static inline void rv_gain_lock_process(const wifi_csi_info_t *info) { (void)info; }
#endif
static uint32_t s_sequence = 0;
static uint32_t s_cb_count = 0;
static uint32_t s_send_ok = 0;
static uint32_t s_send_fail = 0;
static uint32_t s_rate_skip = 0;
/**
* Minimum interval between UDP sends in microseconds.
* CSI callbacks can fire hundreds of times per second in promiscuous mode.
* We cap the send rate to avoid exhausting lwIP packet buffers (ENOMEM).
* Default: 20 ms = 50 Hz max send rate.
*/
/* Send rate cap reduced from 20 ms to 4 ms (250 Hz) so the host calibration
* UI can show every available frame. The real ceiling is whatever rate the
* WiFi CSI callback actually fires at (usually 5-50 Hz on a quiet LAN). */
#define CSI_MIN_SEND_INTERVAL_US (4 * 1000)
static int64_t s_last_send_us = 0;
/**
* Minimum interval between processing ANY CSI callback in microseconds.
* Promiscuous MGMT+DATA can fire 100-500+ times/sec. At rates above ~50 Hz,
* the WiFi FIQ handler (wDev_ProcessFiq) races with SPI flash cache operations,
* causing Core 0 LoadProhibited panics in cache_ll_l1_resume_icache.
*
* This early gate drops excess callbacks BEFORE any processing (serialization,
* UDP, edge enqueue), keeping the effective callback rate at ~50 Hz while
* preserving the full MGMT+DATA promiscuous filter and HT-LTF/STBC CSI quality.
*
* The WiFi hardware still captures all frames and the CSI data is generated,
* but we simply discard the excess in software. This reduces the time spent
* in callback context per second, giving the WiFi ISR more headroom.
*/
#define CSI_MIN_PROCESS_INTERVAL_US (20 * 1000) /* 50 Hz */
static int64_t s_last_process_us = 0;
static uint32_t s_early_drop = 0;
/* ---- ADR-029: Channel-hop state ---- */
/** Channel hop table (populated from NVS at boot or via set_hop_table). */
static uint8_t s_hop_channels[CSI_HOP_CHANNELS_MAX] = {1, 6, 11, 36, 40, 44};
/** Number of active channels in the hop table. 1 = single-channel (no hop). */
static uint8_t s_hop_count = 1;
/** Dwell time per channel in milliseconds. */
static uint32_t s_dwell_ms = 50;
/** Current index into s_hop_channels. */
static uint8_t s_hop_index = 0;
/** Handle for the periodic hop timer. NULL when timer is not running. */
static esp_timer_handle_t s_hop_timer = NULL;
/**
* Serialize CSI data into ADR-018 binary frame format.
*
* Layout:
* [0..3] Magic: 0xC5110001 (LE)
* [4] Node ID
* [5] Number of antennas (rx_ctrl.rx_ant + 1 if available, else 1)
* [6..7] Number of subcarriers (LE u16) = len / (2 * n_antennas)
* [8..11] Frequency MHz (LE u32) — derived from channel
* [12..15] Sequence number (LE u32)
* [16] RSSI (i8)
* [17] Noise floor (i8)
* [18..19] Reserved
* [20..] I/Q data (raw bytes from ESP-IDF callback)
* [20+iq_len .. 20+iq_len+3] ADR-106: sensor timestamp_us (u32 LE)
* from info->rx_ctrl.timestamp. Trailing
* 4 bytes — server parses opportunistically;
* old server tolerant of extra bytes.
*/
size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf_len)
{
if (info == NULL || buf == NULL || info->buf == NULL) {
return 0;
}
uint8_t n_antennas = 1; /* ESP32-S3 typically reports 1 antenna for CSI */
uint16_t iq_len = (uint16_t)info->len;
uint16_t n_subcarriers = iq_len / (2 * n_antennas);
size_t frame_size = CSI_HEADER_SIZE + iq_len + 4 /* ADR-106 trailing timestamp_us */;
if (frame_size > buf_len) {
ESP_LOGW(TAG, "Buffer too small: need %u, have %u", (unsigned)frame_size, (unsigned)buf_len);
return 0;
}
/* Derive frequency from channel number */
uint8_t channel = info->rx_ctrl.channel;
uint32_t freq_mhz;
if (channel >= 1 && channel <= 13) {
freq_mhz = 2412 + (channel - 1) * 5;
} else if (channel == 14) {
freq_mhz = 2484;
} else if (channel >= 36 && channel <= 177) {
freq_mhz = 5000 + channel * 5;
} else {
freq_mhz = 0;
}
/* Magic (LE) */
uint32_t magic = CSI_MAGIC;
memcpy(&buf[0], &magic, 4);
/* Node ID (captured at init into s_node_id to survive memory corruption
* that could clobber g_nvs_config.node_id - see #232/#375/#385/#390). */
buf[4] = s_node_id;
/* Number of antennas */
buf[5] = n_antennas;
/* Number of subcarriers (LE u16) */
memcpy(&buf[6], &n_subcarriers, 2);
/* Frequency MHz (LE u32) */
memcpy(&buf[8], &freq_mhz, 4);
/* Sequence number (LE u32) */
uint32_t seq = s_sequence++;
memcpy(&buf[12], &seq, 4);
/* RSSI (i8) */
buf[16] = (uint8_t)(int8_t)info->rx_ctrl.rssi;
/* Noise floor (i8) */
buf[17] = (uint8_t)(int8_t)info->rx_ctrl.noise_floor;
/* Reserved */
buf[18] = 0;
buf[19] = 0;
/* I/Q data */
memcpy(&buf[CSI_HEADER_SIZE], info->buf, iq_len);
/* ADR-106: trailing sensor µs timestamp from rx_ctrl.timestamp.
* This is monotonic µs since FW boot (per ESP-IDF docs) and lets
* the host align frames across nodes within ~µs once the boot
* offsets are learned. Old server ignores trailing bytes. */
uint32_t ts_us = info->rx_ctrl.timestamp;
memcpy(&buf[CSI_HEADER_SIZE + iq_len], &ts_us, 4);
return frame_size;
}
/**
* WiFi CSI callback — invoked by ESP-IDF when CSI data is available.
*/
static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
{
(void)ctx;
/* Early rate gate: drop excess callbacks to ~50 Hz to prevent
* SPI flash cache crash in WiFi ISR (wDev_ProcessFiq). */
int64_t now_us = esp_timer_get_time();
if ((now_us - s_last_process_us) < CSI_MIN_PROCESS_INTERVAL_US) {
s_early_drop++;
return;
}
s_last_process_us = now_us;
/* ADR-060: MAC address filtering — drop frames from non-matching sources.
* Uses defensively-copied s_filter_mac instead of g_nvs_config (which can
* be corrupted by wifi_init_sta — same root cause as the node_id clobber). */
if (s_filter_mac_set) {
if (memcmp(info->mac, s_filter_mac, 6) != 0) {
return; /* Source MAC doesn't match filter — skip frame. */
}
}
/* ADR-100: feed the gain-lock calibrator. No-op once locked / on
* unsupported targets. Runs before the heavy work so calibration
* happens during the first ~6 s after boot regardless of host traffic. */
rv_gain_lock_process(info);
s_cb_count++;
if (s_cb_count <= 3 || (s_cb_count % 100) == 0) {
ESP_LOGI(TAG, "CSI cb #%lu: len=%d rssi=%d ch=%d",
(unsigned long)s_cb_count, info->len,
info->rx_ctrl.rssi, info->rx_ctrl.channel);
}
uint8_t frame_buf[CSI_MAX_FRAME_SIZE];
size_t frame_len = csi_serialize_frame(info, frame_buf, sizeof(frame_buf));
if (frame_len > 0) {
/* Rate-limit UDP sends to avoid ENOMEM from lwIP pbuf exhaustion.
* In promiscuous mode, CSI callbacks can fire 100-500+ times/sec.
* We only need 20-50 Hz for the sensing pipeline. */
int64_t now = esp_timer_get_time();
if ((now - s_last_send_us) >= CSI_MIN_SEND_INTERVAL_US) {
int ret = stream_sender_send(frame_buf, frame_len);
if (ret > 0) {
s_send_ok++;
s_last_send_us = now;
} else {
s_send_fail++;
if (s_send_fail <= 5) {
ESP_LOGW(TAG, "sendto failed (fail #%lu)", (unsigned long)s_send_fail);
}
}
} else {
s_rate_skip++;
}
}
/* ADR-039: Enqueue raw I/Q into edge processing ring buffer. */
if (info->buf && info->len > 0) {
edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len,
(int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel);
}
}
/**
* Promiscuous mode callback — required for CSI to fire on all received frames.
* We don't need the packet content, just the CSI triggered by reception.
*/
static void wifi_promiscuous_cb(void *buf, wifi_promiscuous_pkt_type_t type)
{
/* No-op: CSI callback is registered separately and fires in parallel. */
(void)buf;
(void)type;
}
void csi_collector_set_node_id(uint8_t node_id)
{
s_node_id = node_id;
s_node_id_early_set = true;
ESP_LOGI(TAG, "Early capture node_id=%u (before WiFi init, #232/#390)",
(unsigned)node_id);
/* Also capture MAC filter config now — same struct, same corruption risk.
* The CSI callback reads filter_mac_set on every invocation (100-500 Hz),
* so a corrupted value could cause erratic filtering or crash. */
s_filter_mac_set = (g_nvs_config.filter_mac_set != 0);
if (s_filter_mac_set) {
memcpy(s_filter_mac, g_nvs_config.filter_mac, 6);
ESP_LOGI(TAG, "Early capture filter_mac=%02x:%02x:%02x:%02x:%02x:%02x",
s_filter_mac[0], s_filter_mac[1], s_filter_mac[2],
s_filter_mac[3], s_filter_mac[4], s_filter_mac[5]);
}
}
void csi_collector_init(void)
{
if (!s_node_id_early_set) {
/* Fallback: no early capture — use current g_nvs_config (may be clobbered). */
s_node_id = g_nvs_config.node_id;
ESP_LOGW(TAG, "Late capture node_id=%u (no early set_node_id call)",
(unsigned)s_node_id);
} else if (g_nvs_config.node_id != s_node_id) {
/* Canary: early capture disagrees with current g_nvs_config — corruption
* happened between nvs_config_load() and here (likely wifi_init_sta). */
ESP_LOGW(TAG, "node_id clobber CONFIRMED: early=%u g_nvs_config=%u "
"(WiFi init likely corrupted struct, using early value)",
(unsigned)s_node_id, (unsigned)g_nvs_config.node_id);
} else {
ESP_LOGI(TAG, "node_id=%u verified (early capture matches g_nvs_config)",
(unsigned)s_node_id);
}
/* Canary for filter_mac: check if WiFi init corrupted the filter fields. */
if (s_node_id_early_set) {
bool mac_set_now = (g_nvs_config.filter_mac_set != 0);
if (mac_set_now != s_filter_mac_set) {
ESP_LOGW(TAG, "filter_mac_set clobber CONFIRMED: early=%d g_nvs_config=%d",
(int)s_filter_mac_set, (int)mac_set_now);
} else if (s_filter_mac_set &&
memcmp(s_filter_mac, g_nvs_config.filter_mac, 6) != 0) {
ESP_LOGW(TAG, "filter_mac clobber CONFIRMED: bytes differ after WiFi init");
}
} else {
/* No early capture — grab filter config now (may already be corrupted). */
s_filter_mac_set = (g_nvs_config.filter_mac_set != 0);
if (s_filter_mac_set) {
memcpy(s_filter_mac, g_nvs_config.filter_mac, 6);
}
}
/* ADR-060: Determine the CSI channel.
* Priority: 1) NVS override (--channel), 2) connected AP channel, 3) Kconfig default. */
uint8_t csi_channel = (uint8_t)CONFIG_CSI_WIFI_CHANNEL;
if (g_nvs_config.csi_channel > 0) {
/* Explicit NVS override via provision.py --channel */
csi_channel = g_nvs_config.csi_channel;
ESP_LOGI(TAG, "Using NVS channel override: %u", (unsigned)csi_channel);
} else {
/* Auto-detect from connected AP */
wifi_ap_record_t ap_info;
if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK && ap_info.primary > 0) {
csi_channel = ap_info.primary;
ESP_LOGI(TAG, "Auto-detected AP channel: %u", (unsigned)csi_channel);
} else {
ESP_LOGW(TAG, "Could not detect AP channel, using Kconfig default: %u",
(unsigned)csi_channel);
}
}
/* Update the hop table's first channel to match. */
s_hop_channels[0] = csi_channel;
/* Disable WiFi modem sleep — reliable CSI capture needs the radio awake.
* The ESP-IDF STA default is WIFI_PS_MIN_MODEM, which lets the modem
* sleep between DTIM beacons; with the MGMT-only promiscuous filter
* (RuView#396) that starves the CSI callback and the per-second yield
* collapses toward 0 pps (RuView#521). Operators who want battery
* duty-cycling opt back in via power_mgmt_init() (provision.py
* --duty-cycle <N>), which runs after this and re-enables modem sleep. */
esp_err_t ps_err = esp_wifi_set_ps(WIFI_PS_NONE);
if (ps_err != ESP_OK) {
ESP_LOGW(TAG, "esp_wifi_set_ps(WIFI_PS_NONE) failed: %s — CSI yield may be low",
esp_err_to_name(ps_err));
} else {
ESP_LOGI(TAG, "WiFi modem sleep disabled (WIFI_PS_NONE) for CSI capture");
}
/* DO NOT enable promiscuous mode on these ESP32-S3 boards. Empirically,
* setting esp_wifi_set_promiscuous(true) while STA is connected suppresses
* the CSI RX callback entirely on this hardware revision — adaptive_ctrl
* reports yield=0pps forever. FW5.47 (esp32s3_csi_capture) works on the
* same boards using plain STA-mode CSI (no promiscuous), so we mirror
* that approach here. CSI fires for every frame the STA actually
* receives (beacons + unicast → ~10-20 Hz, same as edge_processing
* expects). */
ESP_LOGI(TAG, "Promiscuous mode SKIPPED (CSI via STA-only, broken otherwise on this board)");
wifi_csi_config_t csi_config = {
.lltf_en = true,
.htltf_en = true,
.stbc_htltf2_en = true,
.ltf_merge_en = true,
.channel_filter_en = false,
.manu_scale = false,
.shift = false,
};
ESP_ERROR_CHECK(esp_wifi_set_csi_config(&csi_config));
ESP_ERROR_CHECK(esp_wifi_set_csi_rx_cb(wifi_csi_callback, NULL));
ESP_ERROR_CHECK(esp_wifi_set_csi(true));
if (g_nvs_config.filter_mac_set) {
ESP_LOGI(TAG, "MAC filter active: %02x:%02x:%02x:%02x:%02x:%02x",
g_nvs_config.filter_mac[0], g_nvs_config.filter_mac[1],
g_nvs_config.filter_mac[2], g_nvs_config.filter_mac[3],
g_nvs_config.filter_mac[4], g_nvs_config.filter_mac[5]);
}
ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)",
(unsigned)s_node_id, (unsigned)csi_channel);
}
/* Accessor for other modules that need the authoritative runtime node_id. */
uint8_t csi_collector_get_node_id(void)
{
return s_node_id;
}
/* ---- ADR-081: packet yield accessor for the radio abstraction layer ---- */
uint16_t csi_collector_get_pkt_yield_per_sec(void)
{
/* Simple sliding window: record the callback count at ~1 s ago, return
* the delta. Called from adaptive_controller's fast loop (200 ms), so
* we update the snapshot every ~5 calls. */
static int64_t s_yield_window_start_us = 0;
static uint32_t s_yield_window_start_cb = 0;
static uint16_t s_last_yield = 0;
int64_t now = esp_timer_get_time();
if (s_yield_window_start_us == 0) {
s_yield_window_start_us = now;
s_yield_window_start_cb = s_cb_count;
return 0;
}
int64_t elapsed = now - s_yield_window_start_us;
if (elapsed < 1000000LL) {
return s_last_yield;
}
uint32_t delta = s_cb_count - s_yield_window_start_cb;
/* Scale back to per-second if the window ran long (shouldn't, but be safe). */
uint64_t per_sec = ((uint64_t)delta * 1000000ULL) / (uint64_t)elapsed;
if (per_sec > 0xFFFFu) per_sec = 0xFFFFu;
s_last_yield = (uint16_t)per_sec;
s_yield_window_start_us = now;
s_yield_window_start_cb = s_cb_count;
return s_last_yield;
}
uint16_t csi_collector_get_send_fail_count(void)
{
uint32_t f = s_send_fail;
return (f > 0xFFFFu) ? 0xFFFFu : (uint16_t)f;
}
/* ---- ADR-029: Channel hopping ---- */
void csi_collector_set_hop_table(const uint8_t *channels, uint8_t hop_count, uint32_t dwell_ms)
{
if (channels == NULL) {
ESP_LOGW(TAG, "csi_collector_set_hop_table: channels is NULL");
return;
}
if (hop_count == 0 || hop_count > CSI_HOP_CHANNELS_MAX) {
ESP_LOGW(TAG, "csi_collector_set_hop_table: invalid hop_count=%u (max=%u)",
(unsigned)hop_count, (unsigned)CSI_HOP_CHANNELS_MAX);
return;
}
if (dwell_ms < 10) {
ESP_LOGW(TAG, "csi_collector_set_hop_table: dwell_ms=%lu too small, clamping to 10",
(unsigned long)dwell_ms);
dwell_ms = 10;
}
memcpy(s_hop_channels, channels, hop_count);
s_hop_count = hop_count;
s_dwell_ms = dwell_ms;
s_hop_index = 0;
ESP_LOGI(TAG, "Hop table set: %u channels, dwell=%lu ms", (unsigned)hop_count,
(unsigned long)dwell_ms);
for (uint8_t i = 0; i < hop_count; i++) {
ESP_LOGI(TAG, " hop[%u] = channel %u", (unsigned)i, (unsigned)channels[i]);
}
}
void csi_hop_next_channel(void)
{
if (s_hop_count <= 1) {
/* Single-channel mode: no-op for backward compatibility. */
return;
}
s_hop_index = (s_hop_index + 1) % s_hop_count;
uint8_t channel = s_hop_channels[s_hop_index];
/*
* esp_wifi_set_channel() changes the primary channel.
* The second parameter is the secondary channel offset for HT40;
* we use HT20 (no secondary) for sensing.
*/
esp_err_t err = esp_wifi_set_channel(channel, WIFI_SECOND_CHAN_NONE);
if (err != ESP_OK) {
ESP_LOGW(TAG, "Channel hop to %u failed: %s", (unsigned)channel, esp_err_to_name(err));
} else if ((s_cb_count % 200) == 0) {
/* Periodic log to confirm hopping is working (not every hop). */
ESP_LOGI(TAG, "Hopped to channel %u (index %u/%u)",
(unsigned)channel, (unsigned)s_hop_index, (unsigned)s_hop_count);
}
}
/**
* Timer callback for channel hopping.
* Called every s_dwell_ms milliseconds from the esp_timer context.
*/
static void hop_timer_cb(void *arg)
{
(void)arg;
csi_hop_next_channel();
}
void csi_collector_start_hop_timer(void)
{
if (s_hop_count <= 1) {
ESP_LOGI(TAG, "Single-channel mode: hop timer not started");
return;
}
if (s_hop_timer != NULL) {
ESP_LOGW(TAG, "Hop timer already running");
return;
}
esp_timer_create_args_t timer_args = {
.callback = hop_timer_cb,
.arg = NULL,
.name = "csi_hop",
};
esp_err_t err = esp_timer_create(&timer_args, &s_hop_timer);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to create hop timer: %s", esp_err_to_name(err));
return;
}
uint64_t period_us = (uint64_t)s_dwell_ms * 1000;
err = esp_timer_start_periodic(s_hop_timer, period_us);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to start hop timer: %s", esp_err_to_name(err));
esp_timer_delete(s_hop_timer);
s_hop_timer = NULL;
return;
}
ESP_LOGI(TAG, "Hop timer started: period=%lu ms, channels=%u",
(unsigned long)s_dwell_ms, (unsigned)s_hop_count);
}
/* ---- ADR-029: NDP frame injection stub ---- */
esp_err_t csi_inject_ndp_frame(void)
{
/*
* TODO: Construct a proper 802.11 Null Data Packet frame.
*
* A real NDP is preamble-only (~24 us airtime, no payload) and is the
* sensing-first TX mechanism described in ADR-029. For now we send a
* minimal null-data frame as a placeholder so the API is wired up.
*
* Frame structure (IEEE 802.11 Null Data):
* FC (2) | Duration (2) | Addr1 (6) | Addr2 (6) | Addr3 (6) | SeqCtl (2)
* = 24 bytes total, no body, no FCS (hardware appends FCS).
*/
uint8_t ndp_frame[24];
memset(ndp_frame, 0, sizeof(ndp_frame));
/* Frame Control: Type=Data (0x02), Subtype=Null (0x04) -> 0x0048 */
ndp_frame[0] = 0x48;
ndp_frame[1] = 0x00;
/* Duration: 0 (let hardware fill) */
/* Addr1 (destination): broadcast */
memset(&ndp_frame[4], 0xFF, 6);
/* Addr2 (source): will be overwritten by hardware with own MAC */
/* Addr3 (BSSID): broadcast */
memset(&ndp_frame[16], 0xFF, 6);
esp_err_t err = esp_wifi_80211_tx(WIFI_IF_STA, ndp_frame, sizeof(ndp_frame), false);
if (err != ESP_OK) {
ESP_LOGW(TAG, "NDP inject failed: %s", esp_err_to_name(err));
}
return err;
}