feat(firmware): --channel and --filter-mac provisioning (ADR-060)

- provision.py: add --channel (CSI channel override) and --filter-mac
  (AA:BB:CC:DD:EE:FF format) arguments with validation
- nvs_config: add csi_channel, filter_mac[6], filter_mac_set fields;
  read from NVS on boot
- csi_collector: auto-detect AP channel when no NVS override is set;
  filter CSI frames by source MAC when filter_mac is configured
- ADR-060 documents the design and rationale

Fixes #247, fixes #229
This commit is contained in:
rUv 2026-03-13 08:27:08 -04:00 committed by GitHub
parent 3457610c9f
commit d793c1f49f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 165 additions and 2 deletions

View file

@ -12,6 +12,7 @@
*/
#include "csi_collector.h"
#include "nvs_config.h"
#include "stream_sender.h"
#include "edge_processing.h"
@ -21,6 +22,9 @@
#include "esp_timer.h"
#include "sdkconfig.h"
/* ADR-060: Access the global NVS config for MAC filter and channel override. */
extern nvs_config_t g_nvs_config;
/* 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!"
@ -151,6 +155,14 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf
static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
{
(void)ctx;
/* ADR-060: MAC address filtering — drop frames from non-matching sources. */
if (g_nvs_config.filter_mac_set) {
if (memcmp(info->mac, g_nvs_config.filter_mac, 6) != 0) {
return; /* Source MAC doesn't match filter — skip frame. */
}
}
s_cb_count++;
if (s_cb_count <= 3 || (s_cb_count % 100) == 0) {
@ -203,6 +215,29 @@ static void wifi_promiscuous_cb(void *buf, wifi_promiscuous_pkt_type_t type)
void csi_collector_init(void)
{
/* 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;
/* Enable promiscuous mode — required for reliable CSI callbacks.
* Without this, CSI only fires on frames destined to this station,
* which may be very infrequent on a quiet network. */
@ -230,8 +265,15 @@ void csi_collector_init(void)
ESP_ERROR_CHECK(esp_wifi_set_csi_rx_cb(wifi_csi_callback, NULL));
ESP_ERROR_CHECK(esp_wifi_set_csi(true));
ESP_LOGI(TAG, "CSI collection initialized (node_id=%d, channel=%d)",
CONFIG_CSI_NODE_ID, CONFIG_CSI_WIFI_CHANNEL);
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=%d, channel=%u)",
CONFIG_CSI_NODE_ID, (unsigned)csi_channel);
}
/* ---- ADR-029: Channel hopping ---- */

View file

@ -91,6 +91,11 @@ void nvs_config_load(nvs_config_t *cfg)
cfg->wasm_verify = 0; /* Kconfig disabled signature verification. */
#endif
/* ADR-060: Channel override and MAC filter defaults. */
cfg->csi_channel = 0; /* 0 = auto-detect from connected AP. */
cfg->filter_mac_set = 0;
memset(cfg->filter_mac, 0, 6);
/* Try to override from NVS */
nvs_handle_t handle;
esp_err_t err = nvs_open("csi_cfg", NVS_READONLY, &handle);
@ -277,6 +282,26 @@ void nvs_config_load(nvs_config_t *cfg)
ESP_LOGW(TAG, "wasm_verify=1 but no wasm_pubkey in NVS — uploads will be rejected");
}
/* ADR-060: CSI channel override. */
uint8_t csi_ch_val;
if (nvs_get_u8(handle, "csi_channel", &csi_ch_val) == ESP_OK) {
if ((csi_ch_val >= 1 && csi_ch_val <= 14) || (csi_ch_val >= 36 && csi_ch_val <= 177)) {
cfg->csi_channel = csi_ch_val;
ESP_LOGI(TAG, "NVS override: csi_channel=%u", (unsigned)cfg->csi_channel);
} else {
ESP_LOGW(TAG, "NVS csi_channel=%u invalid, ignored", (unsigned)csi_ch_val);
}
}
/* ADR-060: MAC address filter (6-byte blob). */
size_t mac_len = 6;
if (nvs_get_blob(handle, "filter_mac", cfg->filter_mac, &mac_len) == ESP_OK && mac_len == 6) {
cfg->filter_mac_set = 1;
ESP_LOGI(TAG, "NVS override: filter_mac=%02x:%02x:%02x:%02x:%02x:%02x",
cfg->filter_mac[0], cfg->filter_mac[1], cfg->filter_mac[2],
cfg->filter_mac[3], cfg->filter_mac[4], cfg->filter_mac[5]);
}
/* Validate tdm_slot_index < tdm_node_count */
if (cfg->tdm_slot_index >= cfg->tdm_node_count) {
ESP_LOGW(TAG, "tdm_slot_index=%u >= tdm_node_count=%u, clamping to 0",

View file

@ -50,6 +50,11 @@ typedef struct {
uint8_t wasm_verify; /**< Require Ed25519 signature for uploads. */
uint8_t wasm_pubkey[32]; /**< Ed25519 public key for WASM signature. */
uint8_t wasm_pubkey_valid; /**< 1 if pubkey was loaded from NVS. */
/* ADR-060: Channel override and MAC address filtering */
uint8_t csi_channel; /**< Explicit CSI channel override (0 = auto-detect). */
uint8_t filter_mac[6]; /**< MAC address to filter CSI frames. */
uint8_t filter_mac_set; /**< 1 if filter_mac was loaded from NVS. */
} nvs_config_t;
/**

View file

@ -64,6 +64,13 @@ def build_nvs_csv(args):
writer.writerow(["vital_int", "data", "u16", str(args.vital_int)])
if args.subk_count is not None:
writer.writerow(["subk_count", "data", "u8", str(args.subk_count)])
# ADR-060: Channel override and MAC filter
if args.channel is not None:
writer.writerow(["csi_channel", "data", "u8", str(args.channel)])
if args.filter_mac is not None:
mac_bytes = bytes(int(b, 16) for b in args.filter_mac.split(":"))
# NVS blob: write as hex-encoded string for CSV compatibility
writer.writerow(["filter_mac", "data", "hex2bin", mac_bytes.hex()])
return buf.getvalue()
@ -165,6 +172,10 @@ def main():
parser.add_argument("--vital-win", type=int, help="Phase history window in frames (default: 300)")
parser.add_argument("--vital-int", type=int, help="Vitals packet interval in ms (default: 1000)")
parser.add_argument("--subk-count", type=int, help="Top-K subcarrier count (default: 32)")
# ADR-060: Channel override and MAC filter
parser.add_argument("--channel", type=int, help="CSI channel (1-14 for 2.4GHz, 36-177 for 5GHz). "
"Overrides auto-detection from connected AP.")
parser.add_argument("--filter-mac", type=str, help="MAC address to filter CSI frames (AA:BB:CC:DD:EE:FF)")
parser.add_argument("--dry-run", action="store_true", help="Generate NVS binary but don't flash")
args = parser.parse_args()
@ -176,6 +187,7 @@ def main():
args.edge_tier is not None, args.pres_thresh is not None,
args.fall_thresh is not None, args.vital_win is not None,
args.vital_int is not None, args.subk_count is not None,
args.channel is not None, args.filter_mac is not None,
])
if not has_value:
parser.error("At least one config value must be specified")
@ -186,6 +198,22 @@ def main():
if args.tdm_slot is not None and args.tdm_slot >= args.tdm_total:
parser.error(f"--tdm-slot ({args.tdm_slot}) must be less than --tdm-total ({args.tdm_total})")
# ADR-060: Validate channel and MAC filter
if args.channel is not None:
if not ((1 <= args.channel <= 14) or (36 <= args.channel <= 177)):
parser.error(f"--channel must be 1-14 (2.4GHz) or 36-177 (5GHz), got {args.channel}")
if args.filter_mac is not None:
parts = args.filter_mac.split(":")
if len(parts) != 6:
parser.error(f"--filter-mac must be in AA:BB:CC:DD:EE:FF format, got '{args.filter_mac}'")
try:
for p in parts:
val = int(p, 16)
if val < 0 or val > 255:
raise ValueError
except ValueError:
parser.error(f"--filter-mac contains invalid hex bytes: '{args.filter_mac}'")
print("Building NVS configuration:")
if args.ssid:
print(f" WiFi SSID: {args.ssid}")
@ -212,6 +240,10 @@ def main():
print(f" Vital Interval:{args.vital_int} ms")
if args.subk_count is not None:
print(f" Top-K Subcarr: {args.subk_count}")
if args.channel is not None:
print(f" CSI Channel: {args.channel}")
if args.filter_mac is not None:
print(f" Filter MAC: {args.filter_mac}")
csv_content = build_nvs_csv(args)