mirror of
https://github.com/ruvnet/RuView
synced 2026-06-21 12:13:19 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0fe10b3dc | |||
| 915943cef4 |
@@ -1,3 +1,6 @@
|
||||
# Local machine configuration (not shared)
|
||||
CLAUDE.local.md
|
||||
|
||||
# ESP32 firmware build artifacts and local config (contains WiFi credentials)
|
||||
firmware/esp32-csi-node/build/
|
||||
firmware/esp32-csi-node/sdkconfig
|
||||
|
||||
+7
-7
@@ -612,7 +612,7 @@ A 3-6 node ESP32-S3 mesh provides full CSI at 20 Hz. Total cost: ~$54 for a 3-no
|
||||
|
||||
**Flashing firmware:**
|
||||
|
||||
Pre-built binaries are available at [Releases](https://github.com/ruvnet/wifi-densepose/releases/tag/v0.1.0-esp32).
|
||||
Pre-built binaries are available at [Releases](https://github.com/ruvnet/wifi-densepose/releases/tag/v0.2.0-esp32).
|
||||
|
||||
```bash
|
||||
# Flash an ESP32-S3 (requires esptool: pip install esptool)
|
||||
@@ -624,7 +624,7 @@ python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
|
||||
**Provisioning:**
|
||||
|
||||
```bash
|
||||
python scripts/provision.py --port COM7 \
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "YourWiFi" --password "YourPassword" --target-ip 192.168.1.20
|
||||
```
|
||||
|
||||
@@ -635,7 +635,7 @@ Replace `192.168.1.20` with the IP of the machine running the sensing server.
|
||||
For multistatic mesh deployments with authenticated beacons (ADR-032), provision a shared mesh key:
|
||||
|
||||
```bash
|
||||
python scripts/provision.py --port COM7 \
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "YourWiFi" --password "YourPassword" --target-ip 192.168.1.20 \
|
||||
--mesh-key "$(openssl rand -hex 32)"
|
||||
```
|
||||
@@ -648,13 +648,13 @@ Each node in a multistatic mesh needs a unique TDM slot ID (0-based):
|
||||
|
||||
```bash
|
||||
# Node 0 (slot 0) — first transmitter
|
||||
python scripts/provision.py --port COM7 --tdm-slot 0 --tdm-total 3
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 --tdm-slot 0 --tdm-total 3
|
||||
|
||||
# Node 1 (slot 1)
|
||||
python scripts/provision.py --port COM8 --tdm-slot 1 --tdm-total 3
|
||||
python firmware/esp32-csi-node/provision.py --port COM8 --tdm-slot 1 --tdm-total 3
|
||||
|
||||
# Node 2 (slot 2)
|
||||
python scripts/provision.py --port COM9 --tdm-slot 2 --tdm-total 3
|
||||
python firmware/esp32-csi-node/provision.py --port COM9 --tdm-slot 2 --tdm-total 3
|
||||
```
|
||||
|
||||
**Start the aggregator:**
|
||||
@@ -720,7 +720,7 @@ docker run -p 3000:3000 -p 3001:3001 ruvnet/wifi-densepose:latest
|
||||
### ESP32: No data arriving
|
||||
|
||||
1. Verify the ESP32 is connected to the same WiFi network
|
||||
2. Check the target IP matches the sensing server machine: `python scripts/provision.py --port COM7 --target-ip <YOUR_IP>`
|
||||
2. Check the target IP matches the sensing server machine: `python firmware/esp32-csi-node/provision.py --port COM7 --target-ip <YOUR_IP>`
|
||||
3. Verify UDP port 5005 is not blocked by firewall
|
||||
4. Test with: `nc -lu 5005` (Linux) or similar UDP listener
|
||||
|
||||
|
||||
@@ -39,4 +39,18 @@ menu "CSI Node Configuration"
|
||||
help
|
||||
WiFi channel to listen on for CSI data.
|
||||
|
||||
config CSI_FILTER_MAC
|
||||
string "CSI source MAC filter (AA:BB:CC:DD:EE:FF or empty)"
|
||||
default ""
|
||||
help
|
||||
When set to a valid MAC address (e.g. "AA:BB:CC:DD:EE:FF"),
|
||||
only CSI frames from that transmitter are processed. All
|
||||
other frames are silently dropped. This prevents signal
|
||||
mixing in multi-AP environments.
|
||||
|
||||
Leave empty to accept CSI from all transmitters.
|
||||
|
||||
Can be overridden at runtime via NVS key "filter_mac"
|
||||
(6-byte blob) without reflashing.
|
||||
|
||||
endmenu
|
||||
|
||||
@@ -26,6 +26,15 @@ 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_filtered = 0;
|
||||
|
||||
/* ---- MAC address filter (Issue #98) ---- */
|
||||
|
||||
/** When non-zero, only CSI from s_filter_mac is accepted. */
|
||||
static uint8_t s_filter_enabled = 0;
|
||||
|
||||
/** The accepted transmitter MAC address (6 bytes). */
|
||||
static uint8_t s_filter_mac[6] = {0};
|
||||
|
||||
/* ---- ADR-029: Channel-hop state ---- */
|
||||
|
||||
@@ -124,18 +133,52 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf
|
||||
return frame_size;
|
||||
}
|
||||
|
||||
void csi_collector_set_filter_mac(const uint8_t *mac)
|
||||
{
|
||||
if (mac == NULL) {
|
||||
s_filter_enabled = 0;
|
||||
memset(s_filter_mac, 0, 6);
|
||||
ESP_LOGI(TAG, "MAC filter disabled — accepting CSI from all transmitters");
|
||||
} else {
|
||||
memcpy(s_filter_mac, mac, 6);
|
||||
s_filter_enabled = 1;
|
||||
ESP_LOGI(TAG, "MAC filter enabled: only accepting %02X:%02X:%02X:%02X:%02X:%02X",
|
||||
mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
|
||||
}
|
||||
s_filtered = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* WiFi CSI callback — invoked by ESP-IDF when CSI data is available.
|
||||
*
|
||||
* When a MAC filter is active, frames from non-matching transmitters are
|
||||
* silently dropped to prevent signal mixing in multi-AP environments.
|
||||
*/
|
||||
static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
|
||||
{
|
||||
(void)ctx;
|
||||
s_cb_count++;
|
||||
|
||||
/* ---- MAC address filter (Issue #98) ---- */
|
||||
if (s_filter_enabled) {
|
||||
if (memcmp(info->mac, s_filter_mac, 6) != 0) {
|
||||
s_filtered++;
|
||||
if (s_filtered <= 3 || (s_filtered % 500) == 0) {
|
||||
ESP_LOGD(TAG, "Filtered CSI from %02X:%02X:%02X:%02X:%02X:%02X (dropped %lu)",
|
||||
info->mac[0], info->mac[1], info->mac[2],
|
||||
info->mac[3], info->mac[4], info->mac[5],
|
||||
(unsigned long)s_filtered);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (s_cb_count <= 3 || (s_cb_count % 100) == 0) {
|
||||
ESP_LOGI(TAG, "CSI cb #%lu: len=%d rssi=%d ch=%d",
|
||||
ESP_LOGI(TAG, "CSI cb #%lu: len=%d rssi=%d ch=%d mac=%02X:%02X:%02X:%02X:%02X:%02X",
|
||||
(unsigned long)s_cb_count, info->len,
|
||||
info->rx_ctrl.rssi, info->rx_ctrl.channel);
|
||||
info->rx_ctrl.rssi, info->rx_ctrl.channel,
|
||||
info->mac[0], info->mac[1], info->mac[2],
|
||||
info->mac[3], info->mac[4], info->mac[5]);
|
||||
}
|
||||
|
||||
uint8_t frame_buf[CSI_MAX_FRAME_SIZE];
|
||||
|
||||
@@ -22,12 +22,28 @@
|
||||
/** Maximum number of channels in the hop table (ADR-029). */
|
||||
#define CSI_HOP_CHANNELS_MAX 6
|
||||
|
||||
/** Length of a MAC address in bytes. */
|
||||
#define CSI_MAC_LEN 6
|
||||
|
||||
/**
|
||||
* Initialize CSI collection.
|
||||
* Registers the WiFi CSI callback.
|
||||
*/
|
||||
void csi_collector_init(void);
|
||||
|
||||
/**
|
||||
* Set a MAC address filter for CSI collection.
|
||||
*
|
||||
* When set, only CSI frames from the specified transmitter MAC are processed;
|
||||
* all others are silently dropped. This prevents signal mixing in multi-AP
|
||||
* environments.
|
||||
*
|
||||
* Pass NULL to disable filtering (accept CSI from all transmitters).
|
||||
*
|
||||
* @param mac 6-byte MAC address to accept, or NULL to disable filtering.
|
||||
*/
|
||||
void csi_collector_set_filter_mac(const uint8_t *mac);
|
||||
|
||||
/**
|
||||
* Serialize CSI data into ADR-018 binary frame format.
|
||||
*
|
||||
|
||||
@@ -134,6 +134,13 @@ void app_main(void)
|
||||
/* Initialize CSI collection */
|
||||
csi_collector_init();
|
||||
|
||||
/* Apply MAC address filter if configured (Issue #98) */
|
||||
if (s_cfg.filter_mac_enabled) {
|
||||
csi_collector_set_filter_mac(s_cfg.filter_mac);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "No MAC filter — accepting CSI from all transmitters");
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "CSI streaming active → %s:%d",
|
||||
s_cfg.target_ip, s_cfg.target_port);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "nvs_config.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include "esp_log.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "nvs.h"
|
||||
@@ -51,6 +52,29 @@ void nvs_config_load(nvs_config_t *cfg)
|
||||
cfg->tdm_slot_index = 0;
|
||||
cfg->tdm_node_count = 1;
|
||||
|
||||
/* MAC filter: default disabled (all zeros) */
|
||||
memset(cfg->filter_mac, 0, 6);
|
||||
cfg->filter_mac_enabled = 0;
|
||||
|
||||
/* Parse compile-time Kconfig MAC filter if set (format: "AA:BB:CC:DD:EE:FF") */
|
||||
#ifdef CONFIG_CSI_FILTER_MAC
|
||||
{
|
||||
const char *mac_str = CONFIG_CSI_FILTER_MAC;
|
||||
unsigned int m[6];
|
||||
if (mac_str[0] != '\0' &&
|
||||
sscanf(mac_str, "%x:%x:%x:%x:%x:%x",
|
||||
&m[0], &m[1], &m[2], &m[3], &m[4], &m[5]) == 6) {
|
||||
for (int i = 0; i < 6; i++) {
|
||||
cfg->filter_mac[i] = (uint8_t)m[i];
|
||||
}
|
||||
cfg->filter_mac_enabled = 1;
|
||||
ESP_LOGI(TAG, "Kconfig MAC filter: %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]);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/* Try to override from NVS */
|
||||
nvs_handle_t handle;
|
||||
esp_err_t err = nvs_open("csi_cfg", NVS_READONLY, &handle);
|
||||
@@ -152,6 +176,27 @@ void nvs_config_load(nvs_config_t *cfg)
|
||||
}
|
||||
}
|
||||
|
||||
/* MAC filter (stored as a 6-byte blob in NVS key "filter_mac") */
|
||||
uint8_t mac_blob[6];
|
||||
size_t mac_len = 6;
|
||||
if (nvs_get_blob(handle, "filter_mac", mac_blob, &mac_len) == ESP_OK && mac_len == 6) {
|
||||
/* Check it's not all zeros (which would mean "no filter") */
|
||||
uint8_t is_zero = 1;
|
||||
for (int i = 0; i < 6; i++) {
|
||||
if (mac_blob[i] != 0) { is_zero = 0; break; }
|
||||
}
|
||||
if (!is_zero) {
|
||||
memcpy(cfg->filter_mac, mac_blob, 6);
|
||||
cfg->filter_mac_enabled = 1;
|
||||
ESP_LOGI(TAG, "NVS override: filter_mac=%02X:%02X:%02X:%02X:%02X:%02X",
|
||||
mac_blob[0], mac_blob[1], mac_blob[2],
|
||||
mac_blob[3], mac_blob[4], mac_blob[5]);
|
||||
} else {
|
||||
cfg->filter_mac_enabled = 0;
|
||||
ESP_LOGI(TAG, "NVS override: filter_mac disabled (all zeros)");
|
||||
}
|
||||
}
|
||||
|
||||
/* 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",
|
||||
|
||||
@@ -35,6 +35,10 @@ typedef struct {
|
||||
uint32_t dwell_ms; /**< Dwell time per channel in ms. */
|
||||
uint8_t tdm_slot_index; /**< This node's TDM slot index (0-based). */
|
||||
uint8_t tdm_node_count; /**< Total nodes in the TDM schedule. */
|
||||
|
||||
/* MAC address filter for CSI source selection (Issue #98) */
|
||||
uint8_t filter_mac[6]; /**< Transmitter MAC to accept (all zeros = no filter). */
|
||||
uint8_t filter_mac_enabled; /**< 1 = filter active, 0 = accept all. */
|
||||
} nvs_config_t;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ESP32-S3 CSI Node Provisioning Script
|
||||
|
||||
Writes WiFi credentials and aggregator target to the ESP32's NVS partition
|
||||
so users can configure a pre-built firmware binary without recompiling.
|
||||
|
||||
Usage:
|
||||
python provision.py --port COM7 --ssid "MyWiFi" --password "secret" --target-ip 192.168.1.20
|
||||
|
||||
Requirements:
|
||||
pip install esptool nvs-partition-gen
|
||||
(or use the nvs_partition_gen.py bundled with ESP-IDF)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
# NVS partition table offset — default for ESP-IDF 4MB flash with standard
|
||||
# partition scheme. The "nvs" partition starts at 0x9000 (36864) and is
|
||||
# 0x6000 (24576) bytes.
|
||||
NVS_PARTITION_OFFSET = 0x9000
|
||||
NVS_PARTITION_SIZE = 0x6000 # 24 KiB
|
||||
|
||||
|
||||
def build_nvs_csv(ssid, password, target_ip, target_port, node_id):
|
||||
"""Build an NVS CSV string for the csi_cfg namespace."""
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(["key", "type", "encoding", "value"])
|
||||
writer.writerow(["csi_cfg", "namespace", "", ""])
|
||||
if ssid:
|
||||
writer.writerow(["ssid", "data", "string", ssid])
|
||||
if password is not None:
|
||||
writer.writerow(["password", "data", "string", password])
|
||||
if target_ip:
|
||||
writer.writerow(["target_ip", "data", "string", target_ip])
|
||||
if target_port is not None:
|
||||
writer.writerow(["target_port", "data", "u16", str(target_port)])
|
||||
if node_id is not None:
|
||||
writer.writerow(["node_id", "data", "u8", str(node_id)])
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def generate_nvs_binary(csv_content, size):
|
||||
"""Generate an NVS partition binary from CSV using nvs_partition_gen.py."""
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f_csv:
|
||||
f_csv.write(csv_content)
|
||||
csv_path = f_csv.name
|
||||
|
||||
bin_path = csv_path.replace(".csv", ".bin")
|
||||
|
||||
try:
|
||||
# Try the pip-installed version first
|
||||
try:
|
||||
import nvs_partition_gen
|
||||
nvs_partition_gen.generate(csv_path, bin_path, size)
|
||||
with open(bin_path, "rb") as f:
|
||||
return f.read()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fall back to calling the ESP-IDF script directly
|
||||
idf_path = os.environ.get("IDF_PATH", "")
|
||||
gen_script = os.path.join(idf_path, "components", "nvs_flash",
|
||||
"nvs_partition_generator", "nvs_partition_gen.py")
|
||||
if os.path.isfile(gen_script):
|
||||
subprocess.check_call([
|
||||
sys.executable, gen_script, "generate",
|
||||
csv_path, bin_path, hex(size)
|
||||
])
|
||||
with open(bin_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
# Last resort: try as a module
|
||||
subprocess.check_call([
|
||||
sys.executable, "-m", "nvs_partition_gen", "generate",
|
||||
csv_path, bin_path, hex(size)
|
||||
])
|
||||
with open(bin_path, "rb") as f:
|
||||
return f.read()
|
||||
|
||||
finally:
|
||||
for p in (csv_path, bin_path):
|
||||
if os.path.isfile(p):
|
||||
os.unlink(p)
|
||||
|
||||
|
||||
def flash_nvs(port, baud, nvs_bin):
|
||||
"""Flash the NVS partition binary to the ESP32."""
|
||||
with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f:
|
||||
f.write(nvs_bin)
|
||||
bin_path = f.name
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
sys.executable, "-m", "esptool",
|
||||
"--chip", "esp32s3",
|
||||
"--port", port,
|
||||
"--baud", str(baud),
|
||||
"write_flash",
|
||||
hex(NVS_PARTITION_OFFSET), bin_path,
|
||||
]
|
||||
print(f"Flashing NVS partition ({len(nvs_bin)} bytes) to {port}...")
|
||||
subprocess.check_call(cmd)
|
||||
print("NVS provisioning complete!")
|
||||
finally:
|
||||
os.unlink(bin_path)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Provision ESP32-S3 CSI Node with WiFi and aggregator settings",
|
||||
epilog="Example: python provision.py --port COM7 --ssid MyWiFi --password secret --target-ip 192.168.1.20",
|
||||
)
|
||||
parser.add_argument("--port", required=True, help="Serial port (e.g. COM7, /dev/ttyUSB0)")
|
||||
parser.add_argument("--baud", type=int, default=460800, help="Flash baud rate (default: 460800)")
|
||||
parser.add_argument("--ssid", help="WiFi SSID")
|
||||
parser.add_argument("--password", help="WiFi password")
|
||||
parser.add_argument("--target-ip", help="Aggregator host IP (e.g. 192.168.1.20)")
|
||||
parser.add_argument("--target-port", type=int, help="Aggregator UDP port (default: 5005)")
|
||||
parser.add_argument("--node-id", type=int, help="Node ID 0-255 (default: 1)")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Generate NVS binary but don't flash")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not any([args.ssid, args.password is not None, args.target_ip,
|
||||
args.target_port, args.node_id is not None]):
|
||||
parser.error("At least one config value must be specified "
|
||||
"(--ssid, --password, --target-ip, --target-port, --node-id)")
|
||||
|
||||
print("Building NVS configuration:")
|
||||
if args.ssid:
|
||||
print(f" WiFi SSID: {args.ssid}")
|
||||
if args.password is not None:
|
||||
print(f" WiFi Password: {'*' * len(args.password)}")
|
||||
if args.target_ip:
|
||||
print(f" Target IP: {args.target_ip}")
|
||||
if args.target_port:
|
||||
print(f" Target Port: {args.target_port}")
|
||||
if args.node_id is not None:
|
||||
print(f" Node ID: {args.node_id}")
|
||||
|
||||
csv_content = build_nvs_csv(args.ssid, args.password, args.target_ip,
|
||||
args.target_port, args.node_id)
|
||||
|
||||
try:
|
||||
nvs_bin = generate_nvs_binary(csv_content, NVS_PARTITION_SIZE)
|
||||
except Exception as e:
|
||||
print(f"\nError generating NVS binary: {e}", file=sys.stderr)
|
||||
print("\nFallback: save CSV and flash manually with ESP-IDF tools.", file=sys.stderr)
|
||||
fallback_path = "nvs_config.csv"
|
||||
with open(fallback_path, "w") as f:
|
||||
f.write(csv_content)
|
||||
print(f"Saved NVS CSV to {fallback_path}", file=sys.stderr)
|
||||
print(f"Flash with: python $IDF_PATH/components/nvs_flash/"
|
||||
f"nvs_partition_generator/nvs_partition_gen.py generate "
|
||||
f"{fallback_path} nvs.bin 0x6000", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if args.dry_run:
|
||||
out = "nvs_provision.bin"
|
||||
with open(out, "wb") as f:
|
||||
f.write(nvs_bin)
|
||||
print(f"NVS binary saved to {out} ({len(nvs_bin)} bytes)")
|
||||
print(f"Flash manually: python -m esptool --chip esp32s3 --port {args.port} "
|
||||
f"write_flash 0x9000 {out}")
|
||||
return
|
||||
|
||||
flash_nvs(args.port, args.baud, nvs_bin)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user