Skip to content

Commit d793c1f

Browse files
authored
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
1 parent 3457610 commit d793c1f

File tree

5 files changed

+165
-2
lines changed

5 files changed

+165
-2
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# ADR-060: Provision Channel Override and MAC Address Filtering
2+
3+
- **Status:** Accepted
4+
- **Date:** 2026-03-12
5+
- **Issues:** [#247](https://github.com/ruvnet/RuView/issues/247), [#229](https://github.com/ruvnet/RuView/issues/229)
6+
7+
## Context
8+
9+
Two related provisioning gaps were reported by users:
10+
11+
1. **Channel mismatch (Issue #247):** The CSI collector initializes on the
12+
Kconfig default channel (typically 6), even when the ESP32 connects to an AP
13+
on a different channel (e.g. 11). On managed networks where the user cannot
14+
change the router channel, this makes nodes undiscoverable. The
15+
`provision.py` script has no `--channel` argument.
16+
17+
2. **Missing MAC filter (Issue #229):** The v0.2.0 release notes documented a
18+
`--filter-mac` argument for `provision.py`, but it was never implemented.
19+
The firmware's CSI callback accepts frames from all sources, causing signal
20+
mixing in multi-AP environments.
21+
22+
## Decision
23+
24+
### Channel configuration
25+
26+
- Add `--channel` argument to `provision.py` that writes a `csi_channel` key
27+
(u8) to NVS.
28+
- In `nvs_config.c`, read the `csi_channel` key and override
29+
`channel_list[0]` when present.
30+
- In `csi_collector_init()`, after WiFi connects, auto-detect the AP channel
31+
via `esp_wifi_sta_get_ap_info()` and use it as the default CSI channel when
32+
no NVS override is set. This ensures the CSI collector always matches the
33+
connected AP's channel without requiring manual provisioning.
34+
35+
### MAC address filtering
36+
37+
- Add `--filter-mac` argument to `provision.py` that writes a `filter_mac`
38+
key (6-byte blob) to NVS.
39+
- In `nvs_config.h`, add a `filter_mac[6]` field and `filter_mac_set` flag.
40+
- In `nvs_config.c`, read the `filter_mac` blob from NVS.
41+
- In the CSI callback (`wifi_csi_callback`), if `filter_mac_set` is true,
42+
compare the source MAC from the received frame against the configured MAC
43+
and drop non-matching frames.
44+
45+
### Provisioning flow
46+
47+
```
48+
python provision.py --port COM7 --channel 11
49+
python provision.py --port COM7 --filter-mac "AA:BB:CC:DD:EE:FF"
50+
python provision.py --port COM7 --channel 11 --filter-mac "AA:BB:CC:DD:EE:FF"
51+
```
52+
53+
## Consequences
54+
55+
- Users on managed networks can force the CSI channel to match their AP
56+
- Multi-AP environments can filter CSI to a single source
57+
- Auto-channel detection eliminates the most common misconfiguration
58+
- Backward compatible: existing provisioned nodes without these keys behave
59+
as before (use Kconfig default channel, accept all MACs)

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

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
*/
1313

1414
#include "csi_collector.h"
15+
#include "nvs_config.h"
1516
#include "stream_sender.h"
1617
#include "edge_processing.h"
1718

@@ -21,6 +22,9 @@
2122
#include "esp_timer.h"
2223
#include "sdkconfig.h"
2324

25+
/* ADR-060: Access the global NVS config for MAC filter and channel override. */
26+
extern nvs_config_t g_nvs_config;
27+
2428
/* ADR-057: Build-time guard — fail early if CSI is not enabled in sdkconfig.
2529
* Without this, the firmware compiles but crashes at runtime with:
2630
* "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
151155
static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
152156
{
153157
(void)ctx;
158+
159+
/* ADR-060: MAC address filtering — drop frames from non-matching sources. */
160+
if (g_nvs_config.filter_mac_set) {
161+
if (memcmp(info->mac, g_nvs_config.filter_mac, 6) != 0) {
162+
return; /* Source MAC doesn't match filter — skip frame. */
163+
}
164+
}
165+
154166
s_cb_count++;
155167

156168
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)
203215

204216
void csi_collector_init(void)
205217
{
218+
/* ADR-060: Determine the CSI channel.
219+
* Priority: 1) NVS override (--channel), 2) connected AP channel, 3) Kconfig default. */
220+
uint8_t csi_channel = (uint8_t)CONFIG_CSI_WIFI_CHANNEL;
221+
222+
if (g_nvs_config.csi_channel > 0) {
223+
/* Explicit NVS override via provision.py --channel */
224+
csi_channel = g_nvs_config.csi_channel;
225+
ESP_LOGI(TAG, "Using NVS channel override: %u", (unsigned)csi_channel);
226+
} else {
227+
/* Auto-detect from connected AP */
228+
wifi_ap_record_t ap_info;
229+
if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK && ap_info.primary > 0) {
230+
csi_channel = ap_info.primary;
231+
ESP_LOGI(TAG, "Auto-detected AP channel: %u", (unsigned)csi_channel);
232+
} else {
233+
ESP_LOGW(TAG, "Could not detect AP channel, using Kconfig default: %u",
234+
(unsigned)csi_channel);
235+
}
236+
}
237+
238+
/* Update the hop table's first channel to match. */
239+
s_hop_channels[0] = csi_channel;
240+
206241
/* Enable promiscuous mode — required for reliable CSI callbacks.
207242
* Without this, CSI only fires on frames destined to this station,
208243
* which may be very infrequent on a quiet network. */
@@ -230,8 +265,15 @@ void csi_collector_init(void)
230265
ESP_ERROR_CHECK(esp_wifi_set_csi_rx_cb(wifi_csi_callback, NULL));
231266
ESP_ERROR_CHECK(esp_wifi_set_csi(true));
232267

233-
ESP_LOGI(TAG, "CSI collection initialized (node_id=%d, channel=%d)",
234-
CONFIG_CSI_NODE_ID, CONFIG_CSI_WIFI_CHANNEL);
268+
if (g_nvs_config.filter_mac_set) {
269+
ESP_LOGI(TAG, "MAC filter active: %02x:%02x:%02x:%02x:%02x:%02x",
270+
g_nvs_config.filter_mac[0], g_nvs_config.filter_mac[1],
271+
g_nvs_config.filter_mac[2], g_nvs_config.filter_mac[3],
272+
g_nvs_config.filter_mac[4], g_nvs_config.filter_mac[5]);
273+
}
274+
275+
ESP_LOGI(TAG, "CSI collection initialized (node_id=%d, channel=%u)",
276+
CONFIG_CSI_NODE_ID, (unsigned)csi_channel);
235277
}
236278

237279
/* ---- ADR-029: Channel hopping ---- */

firmware/esp32-csi-node/main/nvs_config.c

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ void nvs_config_load(nvs_config_t *cfg)
9191
cfg->wasm_verify = 0; /* Kconfig disabled signature verification. */
9292
#endif
9393

94+
/* ADR-060: Channel override and MAC filter defaults. */
95+
cfg->csi_channel = 0; /* 0 = auto-detect from connected AP. */
96+
cfg->filter_mac_set = 0;
97+
memset(cfg->filter_mac, 0, 6);
98+
9499
/* Try to override from NVS */
95100
nvs_handle_t handle;
96101
esp_err_t err = nvs_open("csi_cfg", NVS_READONLY, &handle);
@@ -277,6 +282,26 @@ void nvs_config_load(nvs_config_t *cfg)
277282
ESP_LOGW(TAG, "wasm_verify=1 but no wasm_pubkey in NVS — uploads will be rejected");
278283
}
279284

285+
/* ADR-060: CSI channel override. */
286+
uint8_t csi_ch_val;
287+
if (nvs_get_u8(handle, "csi_channel", &csi_ch_val) == ESP_OK) {
288+
if ((csi_ch_val >= 1 && csi_ch_val <= 14) || (csi_ch_val >= 36 && csi_ch_val <= 177)) {
289+
cfg->csi_channel = csi_ch_val;
290+
ESP_LOGI(TAG, "NVS override: csi_channel=%u", (unsigned)cfg->csi_channel);
291+
} else {
292+
ESP_LOGW(TAG, "NVS csi_channel=%u invalid, ignored", (unsigned)csi_ch_val);
293+
}
294+
}
295+
296+
/* ADR-060: MAC address filter (6-byte blob). */
297+
size_t mac_len = 6;
298+
if (nvs_get_blob(handle, "filter_mac", cfg->filter_mac, &mac_len) == ESP_OK && mac_len == 6) {
299+
cfg->filter_mac_set = 1;
300+
ESP_LOGI(TAG, "NVS override: filter_mac=%02x:%02x:%02x:%02x:%02x:%02x",
301+
cfg->filter_mac[0], cfg->filter_mac[1], cfg->filter_mac[2],
302+
cfg->filter_mac[3], cfg->filter_mac[4], cfg->filter_mac[5]);
303+
}
304+
280305
/* Validate tdm_slot_index < tdm_node_count */
281306
if (cfg->tdm_slot_index >= cfg->tdm_node_count) {
282307
ESP_LOGW(TAG, "tdm_slot_index=%u >= tdm_node_count=%u, clamping to 0",

firmware/esp32-csi-node/main/nvs_config.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ typedef struct {
5050
uint8_t wasm_verify; /**< Require Ed25519 signature for uploads. */
5151
uint8_t wasm_pubkey[32]; /**< Ed25519 public key for WASM signature. */
5252
uint8_t wasm_pubkey_valid; /**< 1 if pubkey was loaded from NVS. */
53+
54+
/* ADR-060: Channel override and MAC address filtering */
55+
uint8_t csi_channel; /**< Explicit CSI channel override (0 = auto-detect). */
56+
uint8_t filter_mac[6]; /**< MAC address to filter CSI frames. */
57+
uint8_t filter_mac_set; /**< 1 if filter_mac was loaded from NVS. */
5358
} nvs_config_t;
5459

5560
/**

firmware/esp32-csi-node/provision.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@ def build_nvs_csv(args):
6464
writer.writerow(["vital_int", "data", "u16", str(args.vital_int)])
6565
if args.subk_count is not None:
6666
writer.writerow(["subk_count", "data", "u8", str(args.subk_count)])
67+
# ADR-060: Channel override and MAC filter
68+
if args.channel is not None:
69+
writer.writerow(["csi_channel", "data", "u8", str(args.channel)])
70+
if args.filter_mac is not None:
71+
mac_bytes = bytes(int(b, 16) for b in args.filter_mac.split(":"))
72+
# NVS blob: write as hex-encoded string for CSV compatibility
73+
writer.writerow(["filter_mac", "data", "hex2bin", mac_bytes.hex()])
6774
return buf.getvalue()
6875

6976

@@ -165,6 +172,10 @@ def main():
165172
parser.add_argument("--vital-win", type=int, help="Phase history window in frames (default: 300)")
166173
parser.add_argument("--vital-int", type=int, help="Vitals packet interval in ms (default: 1000)")
167174
parser.add_argument("--subk-count", type=int, help="Top-K subcarrier count (default: 32)")
175+
# ADR-060: Channel override and MAC filter
176+
parser.add_argument("--channel", type=int, help="CSI channel (1-14 for 2.4GHz, 36-177 for 5GHz). "
177+
"Overrides auto-detection from connected AP.")
178+
parser.add_argument("--filter-mac", type=str, help="MAC address to filter CSI frames (AA:BB:CC:DD:EE:FF)")
168179
parser.add_argument("--dry-run", action="store_true", help="Generate NVS binary but don't flash")
169180

170181
args = parser.parse_args()
@@ -176,6 +187,7 @@ def main():
176187
args.edge_tier is not None, args.pres_thresh is not None,
177188
args.fall_thresh is not None, args.vital_win is not None,
178189
args.vital_int is not None, args.subk_count is not None,
190+
args.channel is not None, args.filter_mac is not None,
179191
])
180192
if not has_value:
181193
parser.error("At least one config value must be specified")
@@ -186,6 +198,22 @@ def main():
186198
if args.tdm_slot is not None and args.tdm_slot >= args.tdm_total:
187199
parser.error(f"--tdm-slot ({args.tdm_slot}) must be less than --tdm-total ({args.tdm_total})")
188200

201+
# ADR-060: Validate channel and MAC filter
202+
if args.channel is not None:
203+
if not ((1 <= args.channel <= 14) or (36 <= args.channel <= 177)):
204+
parser.error(f"--channel must be 1-14 (2.4GHz) or 36-177 (5GHz), got {args.channel}")
205+
if args.filter_mac is not None:
206+
parts = args.filter_mac.split(":")
207+
if len(parts) != 6:
208+
parser.error(f"--filter-mac must be in AA:BB:CC:DD:EE:FF format, got '{args.filter_mac}'")
209+
try:
210+
for p in parts:
211+
val = int(p, 16)
212+
if val < 0 or val > 255:
213+
raise ValueError
214+
except ValueError:
215+
parser.error(f"--filter-mac contains invalid hex bytes: '{args.filter_mac}'")
216+
189217
print("Building NVS configuration:")
190218
if args.ssid:
191219
print(f" WiFi SSID: {args.ssid}")
@@ -212,6 +240,10 @@ def main():
212240
print(f" Vital Interval:{args.vital_int} ms")
213241
if args.subk_count is not None:
214242
print(f" Top-K Subcarr: {args.subk_count}")
243+
if args.channel is not None:
244+
print(f" CSI Channel: {args.channel}")
245+
if args.filter_mac is not None:
246+
print(f" Filter MAC: {args.filter_mac}")
215247

216248
csv_content = build_nvs_csv(args)
217249

0 commit comments

Comments
 (0)