From d793c1f49fbacc77f6a28b76bfd3f1ac6aa1b455 Mon Sep 17 00:00:00 2001 From: rUv Date: Fri, 13 Mar 2026 08:27:08 -0400 Subject: [PATCH] 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 --- .../ADR-060-provision-channel-mac-filter.md | 59 +++++++++++++++++++ firmware/esp32-csi-node/main/csi_collector.c | 46 ++++++++++++++- firmware/esp32-csi-node/main/nvs_config.c | 25 ++++++++ firmware/esp32-csi-node/main/nvs_config.h | 5 ++ firmware/esp32-csi-node/provision.py | 32 ++++++++++ 5 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 docs/adr/ADR-060-provision-channel-mac-filter.md diff --git a/docs/adr/ADR-060-provision-channel-mac-filter.md b/docs/adr/ADR-060-provision-channel-mac-filter.md new file mode 100644 index 00000000..afc18857 --- /dev/null +++ b/docs/adr/ADR-060-provision-channel-mac-filter.md @@ -0,0 +1,59 @@ +# ADR-060: Provision Channel Override and MAC Address Filtering + +- **Status:** Accepted +- **Date:** 2026-03-12 +- **Issues:** [#247](https://github.com/ruvnet/RuView/issues/247), [#229](https://github.com/ruvnet/RuView/issues/229) + +## Context + +Two related provisioning gaps were reported by users: + +1. **Channel mismatch (Issue #247):** The CSI collector initializes on the + Kconfig default channel (typically 6), even when the ESP32 connects to an AP + on a different channel (e.g. 11). On managed networks where the user cannot + change the router channel, this makes nodes undiscoverable. The + `provision.py` script has no `--channel` argument. + +2. **Missing MAC filter (Issue #229):** The v0.2.0 release notes documented a + `--filter-mac` argument for `provision.py`, but it was never implemented. + The firmware's CSI callback accepts frames from all sources, causing signal + mixing in multi-AP environments. + +## Decision + +### Channel configuration + +- Add `--channel` argument to `provision.py` that writes a `csi_channel` key + (u8) to NVS. +- In `nvs_config.c`, read the `csi_channel` key and override + `channel_list[0]` when present. +- In `csi_collector_init()`, after WiFi connects, auto-detect the AP channel + via `esp_wifi_sta_get_ap_info()` and use it as the default CSI channel when + no NVS override is set. This ensures the CSI collector always matches the + connected AP's channel without requiring manual provisioning. + +### MAC address filtering + +- Add `--filter-mac` argument to `provision.py` that writes a `filter_mac` + key (6-byte blob) to NVS. +- In `nvs_config.h`, add a `filter_mac[6]` field and `filter_mac_set` flag. +- In `nvs_config.c`, read the `filter_mac` blob from NVS. +- In the CSI callback (`wifi_csi_callback`), if `filter_mac_set` is true, + compare the source MAC from the received frame against the configured MAC + and drop non-matching frames. + +### Provisioning flow + +``` +python provision.py --port COM7 --channel 11 +python provision.py --port COM7 --filter-mac "AA:BB:CC:DD:EE:FF" +python provision.py --port COM7 --channel 11 --filter-mac "AA:BB:CC:DD:EE:FF" +``` + +## Consequences + +- Users on managed networks can force the CSI channel to match their AP +- Multi-AP environments can filter CSI to a single source +- Auto-channel detection eliminates the most common misconfiguration +- Backward compatible: existing provisioned nodes without these keys behave + as before (use Kconfig default channel, accept all MACs) diff --git a/firmware/esp32-csi-node/main/csi_collector.c b/firmware/esp32-csi-node/main/csi_collector.c index 0d73875c..a63450dc 100644 --- a/firmware/esp32-csi-node/main/csi_collector.c +++ b/firmware/esp32-csi-node/main/csi_collector.c @@ -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 ---- */ diff --git a/firmware/esp32-csi-node/main/nvs_config.c b/firmware/esp32-csi-node/main/nvs_config.c index 6f6be6ad..6494d0e7 100644 --- a/firmware/esp32-csi-node/main/nvs_config.c +++ b/firmware/esp32-csi-node/main/nvs_config.c @@ -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", diff --git a/firmware/esp32-csi-node/main/nvs_config.h b/firmware/esp32-csi-node/main/nvs_config.h index f9c5f6ea..1a49efaa 100644 --- a/firmware/esp32-csi-node/main/nvs_config.h +++ b/firmware/esp32-csi-node/main/nvs_config.h @@ -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; /** diff --git a/firmware/esp32-csi-node/provision.py b/firmware/esp32-csi-node/provision.py index 752044fe..83f93068 100644 --- a/firmware/esp32-csi-node/provision.py +++ b/firmware/esp32-csi-node/provision.py @@ -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)