Compare commits

..

5 Commits

Author SHA1 Message Date
ruv 82c6b8382b fix: rate-limit CSI sends and add ENOMEM backoff to prevent crash
The CSI callback fires for every WiFi frame in promiscuous mode
(100-500+ fps). Each call invoked sendto() synchronously, exhausting
lwIP packet buffers (errno 12 = ENOMEM). The rapid-fire failures
cascaded into a LoadProhibited guru meditation crash.

Two fixes:

1. csi_collector.c: Rate-limit UDP sends to 50 Hz (20ms interval).
   CSI frames arriving between sends are dropped — the sensing
   pipeline only needs 20-50 Hz.

2. stream_sender.c: When sendto fails with ENOMEM, suppress further
   sends for 100ms to let lwIP reclaim buffers. Logs the backoff
   event and resumes automatically.

Closes #127

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-03 15:59:44 -05:00
ruv b544545cb0 docs: ADR-044 provisioning tool enhancements
5-phase plan to close remaining gaps in provision.py:
- Phase 1: 7 missing NVS keys (hop_count, chan_list, dwell_ms,
  power_duty, wasm_max, wasm_verify, wasm_pubkey)
- Phase 2: JSON config file for mesh provisioning
- Phase 3: Named presets (basic, vitals, mesh-3, mesh-6-vitals)
- Phase 4: --read (dump NVS) and --verify (boot check)
- Phase 5: Auto-detect serial port

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-03 15:57:34 -05:00
rUv b6f7b8a74a fix: add TDM and edge intelligence flags to provision.py (#131)
The user guide and release notes document TDM and edge intelligence
provisioning flags but provision.py only accepted --ssid, --password,
--target-ip, --target-port, and --node-id.

Add all NVS keys the firmware actually reads:
- --tdm-slot / --tdm-total: TDM mesh slot assignment
- --edge-tier: edge processing tier (0=off, 1=stats, 2=vitals)
- --pres-thresh, --fall-thresh: detection thresholds
- --vital-win, --vital-int: vitals timing parameters
- --subk-count: top-K subcarrier selection

Also validates that --tdm-slot and --tdm-total are specified together
and that slot < total.

Closes #130
2026-03-03 15:53:43 -05:00
rUv 86f08303e6 docs: update changelog, user guide, and README for ADR-043 (#128)
- CHANGELOG: add ADR-043 entries (14 new API endpoints, WebSocket fix,
  mobile WS fix, 25 real mobile tests)
- README: update ADR count from 41 to 43
- CLAUDE.md: update ADR count from 32 to 43
- User guide: add 14 new REST endpoints to API reference table, note
  that /ws/sensing is available on the HTTP port, update ADR count
2026-03-03 15:21:52 -05:00
rUv d4fb7d30d3 fix: complete sensing server API, WebSocket connectivity, and mobile tests (#125)
The web UI had persistent 404 errors on model, recording, and training
endpoints, and the sensing WebSocket never connected on Dashboard/Live
Demo tabs because sensingService.start() was only called lazily on
Sensing tab visit.

Server (main.rs):
- Add 14 fully-functional Axum handlers: model CRUD (7), recording
  lifecycle (4), training control (3)
- Scan data/models/ and data/recordings/ at startup
- Recording writes CSI frames to .jsonl via tokio background task
- Model load/unload lifecycle with state tracking

Web UI (app.js):
- Import and start sensingService early in initializeServices() so
  Dashboard and Live Demo tabs connect to /ws/sensing immediately

Mobile (ws.service.ts):
- Fix WebSocket URL builder to use same-origin port instead of
  hardcoded port 3001

Mobile (jest.config.js):
- Fix testPathIgnorePatterns that was ignoring the entire test directory

Mobile (25 test files):
- Replace all it.todo() placeholder tests with real implementations
  covering components, services, stores, hooks, screens, and utils

ADR-043 documents all changes.
2026-03-03 13:27:03 -05:00
8 changed files with 383 additions and 30 deletions
+10
View File
@@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Sensing server UI API completion (ADR-043)** — 14 fully-functional REST endpoints for model management, CSI recording, and training control
- Model CRUD: `GET /api/v1/models`, `GET /api/v1/models/active`, `POST /api/v1/models/load`, `POST /api/v1/models/unload`, `DELETE /api/v1/models/:id`, `GET /api/v1/models/lora/profiles`, `POST /api/v1/models/lora/activate`
- CSI recording: `GET /api/v1/recording/list`, `POST /api/v1/recording/start`, `POST /api/v1/recording/stop`, `DELETE /api/v1/recording/:id`
- Training control: `GET /api/v1/train/status`, `POST /api/v1/train/start`, `POST /api/v1/train/stop`
- Recording writes CSI frames to `.jsonl` files via tokio background task
- Model/recording directories scanned at startup, state managed via `Arc<RwLock<AppStateInner>>`
- **25 real mobile tests** replacing `it.todo()` placeholders — 205 assertions covering components, services, stores, hooks, screens, and utils
- **Project MERIDIAN (ADR-027)** — Cross-environment domain generalization for WiFi pose estimation (1,858 lines, 72 tests)
- `HardwareNormalizer` — Catmull-Rom cubic interpolation resamples any hardware CSI to canonical 56 subcarriers; z-score + phase sanitization
- `DomainFactorizer` + `GradientReversalLayer` — adversarial disentanglement of pose-relevant vs environment-specific features
@@ -23,6 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- ADR-025: macOS CoreWLAN WiFi Sensing (ORCA)
### Fixed
- **WebSocket "RECONNECTING" on Dashboard/Live Demo** — `sensingService.start()` now called on app init in `app.js` so WebSocket connects immediately instead of waiting for Sensing tab visit
- **Mobile WebSocket port** — `ws.service.ts` `buildWsUrl()` uses same-origin port instead of hardcoded port 3001
- **Mobile Jest config** — `testPathIgnorePatterns` no longer silently ignores the entire test directory
- Removed synthetic byte counters from Python `MacosWifiCollector` — now reports `tx_bytes=0, rx_bytes=0` instead of fake incrementing values
---
+2 -2
View File
@@ -57,7 +57,7 @@ All 5 ruvector crates integrated in workspace:
- `ruvector-attention``model.rs` (apply_spatial_attention) + `bvp.rs`
### Architecture Decisions
32 ADRs in `docs/adr/` (ADR-001 through ADR-032). Key ones:
43 ADRs in `docs/adr/` (ADR-001 through ADR-043). Key ones:
- ADR-014: SOTA signal processing (Accepted)
- ADR-015: MM-Fi + Wi-Pose training datasets (Accepted)
- ADR-016: RuVector training pipeline integration (Accepted — complete)
@@ -173,7 +173,7 @@ Active feature branch: `ruvsense-full-implementation` (PR #77)
## File Organization
- NEVER save to root folder — use the directories below
- `docs/adr/` — Architecture Decision Records (32 ADRs)
- `docs/adr/` — Architecture Decision Records (43 ADRs)
- `docs/ddd/` — Domain-Driven Design models
- `rust-port/wifi-densepose-rs/crates/` — Rust workspace crates (15 crates)
- `rust-port/wifi-densepose-rs/crates/wifi-densepose-signal/src/ruvsense/` — RuvSense multistatic modules (14 files)
+1 -1
View File
@@ -50,7 +50,7 @@ docker run -p 3000:3000 ruvnet/wifi-densepose:latest
| [User Guide](docs/user-guide.md) | Step-by-step guide: installation, first run, API usage, hardware setup, training |
| [WiFi-Mat User Guide](docs/wifi-mat-user-guide.md) | Disaster response module: search & rescue, START triage |
| [Build Guide](docs/build-guide.md) | Building from source (Rust and Python) |
| [Architecture Decisions](docs/adr/) | 41 ADRs covering signal processing, training, hardware, security, domain generalization, multistatic sensing, CRV signal-line integration, edge intelligence |
| [Architecture Decisions](docs/adr/) | 43 ADRs covering signal processing, training, hardware, security, domain generalization, multistatic sensing, CRV signal-line integration, edge intelligence, WASM edge modules, UI API completion |
| [DDD Domain Model](docs/ddd/ruvsense-domain-model.md) | RuvSense bounded contexts, aggregates, domain events, and ubiquitous language |
---
@@ -0,0 +1,214 @@
# ADR-044: Provisioning Tool Enhancements
**Status**: Proposed
**Date**: 2026-03-03
**Deciders**: @ruvnet
**Supersedes**: None
**Related**: ADR-029, ADR-032, ADR-039, ADR-040
---
## Context
The ESP32-S3 CSI node provisioning script (`firmware/esp32-csi-node/provision.py`) is the primary tool for configuring pre-built firmware binaries without recompiling. It writes NVS key-value pairs that the firmware reads at boot.
After #131 added TDM and edge intelligence flags, the script now covers the most-requested NVS keys. However, there remain gaps between what the firmware reads from NVS (`nvs_config.c`, 20 keys) and what the provisioning script can write (13 keys). Additionally, the script lacks usability features that would help field operators deploying multi-node meshes.
### Gap 1: Missing NVS Keys (7 keys)
The firmware reads these NVS keys at boot but the provisioning script has no corresponding CLI flags:
| NVS Key | Type | Firmware Default | Purpose |
|---------|------|-----------------|---------|
| `hop_count` | u8 | 1 (no hop) | Number of channels to hop through |
| `chan_list` | blob (u8[6]) | {1,6,11} | Channel numbers for hopping sequence |
| `dwell_ms` | u32 | 100 | Time to dwell on each channel before hopping (ms) |
| `power_duty` | u8 | 100 | Power duty cycle percentage (10-100%) for battery life |
| `wasm_max` | u8 | 4 | Max concurrent WASM modules (ADR-040) |
| `wasm_verify` | u8 | 0 | Require Ed25519 signature for WASM uploads (0/1) |
| `wasm_pubkey` | blob (32B) | zeros | Ed25519 public key for WASM signature verification |
### Gap 2: No Read-Back
There is no way to read the current NVS configuration from a device. Field operators must remember what was provisioned or reflash everything. This is especially problematic for multi-node meshes where each node has different TDM slots.
### Gap 3: No Verification
After flashing, there is no automated check that the device booted successfully with the new configuration. Operators must manually run a serial monitor and inspect logs.
### Gap 4: No Config File Support
Provisioning a 6-node mesh requires running the script 6 times with largely overlapping flags (same SSID, password, target IP) and only TDM slot varying. There is no way to define a mesh configuration in a file.
### Gap 5: No Presets
Common deployment scenarios (single-node basic, 3-node mesh, 6-node mesh with vitals) require operators to know which flags to combine. Named presets would lower the barrier to entry.
### Gap 6: No Auto-Detect
The `--port` flag is required even though the script could auto-detect connected ESP32-S3 devices via `esptool.py`.
---
## Decision
Enhance `provision.py` with the following capabilities, implemented incrementally.
### Phase 1: Complete NVS Coverage
Add flags for all remaining firmware NVS keys:
```
--hop-count N Channel hop count (1=no hop, default: 1)
--channels 1,6,11 Comma-separated channel list for hopping
--dwell-ms N Dwell time per channel in ms (default: 100)
--power-duty N Power duty cycle 10-100% (default: 100)
--wasm-max N Max concurrent WASM modules 1-8 (default: 4)
--wasm-verify Require Ed25519 signature for WASM uploads
--wasm-pubkey FILE Path to Ed25519 public key file (32 bytes raw or PEM)
```
Validation:
- `--channels` length must match `--hop-count`
- `--power-duty` clamped to 10-100
- `--wasm-pubkey` implies `--wasm-verify`
### Phase 2: Config File and Mesh Provisioning
Add `--config FILE` to load settings from a JSON or TOML file:
```json
{
"common": {
"ssid": "SensorNet",
"password": "secret",
"target_ip": "192.168.1.20",
"target_port": 5005,
"edge_tier": 2
},
"nodes": [
{ "port": "COM7", "node_id": 0, "tdm_slot": 0 },
{ "port": "COM8", "node_id": 1, "tdm_slot": 1 },
{ "port": "COM9", "node_id": 2, "tdm_slot": 2 }
]
}
```
`--config mesh.json` provisions all listed nodes in sequence, computing `tdm_total` automatically from the `nodes` array length.
### Phase 3: Presets
Add `--preset NAME` for common deployment profiles:
| Preset | What It Sets |
|--------|-------------|
| `basic` | Single node, edge_tier=0, no TDM, no hopping |
| `vitals` | Single node, edge_tier=2, vital_int=1000, subk_count=32 |
| `mesh-3` | 3-node TDM, edge_tier=1, hop_count=3, channels=1,6,11 |
| `mesh-6-vitals` | 6-node TDM, edge_tier=2, hop_count=3, channels=1,6,11, vital_int=500 |
Presets set defaults that can be overridden by explicit flags.
### Phase 4: Read-Back and Verify
Add `--read` to dump the current NVS configuration from a connected device:
```bash
python provision.py --port COM7 --read
# Output:
# ssid: SensorNet
# target_ip: 192.168.1.20
# tdm_slot: 0
# tdm_nodes: 3
# edge_tier: 2
# ...
```
Implementation: use `esptool.py read_flash` to read the NVS partition, then parse the NVS binary format to extract key-value pairs.
Add `--verify` to provision and then confirm the device booted:
```bash
python provision.py --port COM7 --ssid "Net" --password "pass" --target-ip 192.168.1.20 --verify
# After flash, opens serial monitor for 5 seconds
# Checks for "CSI streaming active" log line
# Reports PASS or FAIL
```
### Phase 5: Auto-Detect Port
When `--port` is omitted, scan for connected ESP32-S3 devices:
```bash
python provision.py --ssid "Net" --password "pass" --target-ip 192.168.1.20
# Auto-detected ESP32-S3 on COM7 (Silicon Labs CP210x)
# Proceed? [Y/n]
```
Implementation: use `esptool.py` or `serial.tools.list_ports` to enumerate ports.
---
## Rationale
### Why incremental phases?
Phase 1 is a small diff that closes the NVS coverage gap immediately. Phases 2-5 add progressively more UX polish. Each phase is independently useful and can be shipped separately.
### Why JSON config over YAML/TOML?
JSON requires no additional Python dependencies (stdlib `json` module). TOML requires `tomllib` (Python 3.11+) or `tomli`. JSON is sufficient for this use case.
### Why not a GUI?
The target users are embedded developers and field operators who are already running `esptool` from the command line. A TUI/GUI would add dependencies and complexity for minimal benefit.
---
## Consequences
### Positive
- **Complete NVS coverage**: Every firmware-readable key can be set from the provisioning tool
- **Mesh provisioning in one command**: `--config mesh.json` replaces 6 separate invocations
- **Lower barrier to entry**: Presets eliminate the need to know which flags to combine
- **Auditability**: `--read` lets operators inspect and verify deployed configurations
- **Fewer mis-provisions**: `--verify` catches flashing failures before the operator walks away
### Negative
- **NVS binary parsing** (Phase 4) requires understanding the ESP-IDF NVS binary format, which is not officially documented as a stable API
- **Auto-detect** (Phase 5) may produce false positives if other ESP32 variants are connected
### Risks
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| NVS binary format changes in ESP-IDF v6 | Low | Medium | Pin to known ESP-IDF NVS page format; add format version check |
| `--verify` serial parsing is fragile | Medium | Low | Match on stable log tag `[CSI_MAIN]`; timeout after 10s |
| Config file credentials in plaintext | Medium | Medium | Document that config files should not be committed; add `.gitignore` pattern |
---
## Implementation Priority
| Phase | Effort | Impact | Priority |
|-------|--------|--------|----------|
| Phase 1: Complete NVS coverage | Small (1 file, ~50 lines) | High — closes feature gap | P0 |
| Phase 2: Config file + mesh | Medium (~100 lines) | High — biggest UX win | P1 |
| Phase 3: Presets | Small (~40 lines) | Medium — convenience | P2 |
| Phase 4: Read-back + verify | Medium (~150 lines) | Medium — debugging aid | P2 |
| Phase 5: Auto-detect | Small (~30 lines) | Low — minor convenience | P3 |
---
## References
- `firmware/esp32-csi-node/main/nvs_config.h` — NVS config struct (20 fields)
- `firmware/esp32-csi-node/main/nvs_config.c` — NVS read logic (20 keys)
- `firmware/esp32-csi-node/provision.py` — Current provisioning script (13 of 20 keys)
- ADR-029: RuvSense multistatic sensing mode (TDM, channel hopping)
- ADR-032: Multistatic mesh security hardening (mesh keys)
- ADR-039: ESP32-S3 edge intelligence (edge tiers, vitals)
- ADR-040: WASM programmable sensing (WASM modules, signature verification)
- Issue #130: Provisioning script doesn't support TDM
+18 -2
View File
@@ -301,6 +301,20 @@ Base URL: `http://localhost:3000` (Docker) or `http://localhost:8080` (binary de
| `GET` | `/api/v1/model/layers` | Progressive model loading status | Layer A/B/C load state |
| `GET` | `/api/v1/model/sona/profiles` | SONA adaptation profiles | List of environment profiles |
| `POST` | `/api/v1/model/sona/activate` | Activate a SONA profile for a specific room | `{"profile":"kitchen"}` |
| `GET` | `/api/v1/models` | List available RVF model files | `{"models":[...],"count":0}` |
| `GET` | `/api/v1/models/active` | Currently loaded model (or null) | `{"model":null}` |
| `POST` | `/api/v1/models/load` | Load a model by ID | `{"status":"loaded","model_id":"..."}` |
| `POST` | `/api/v1/models/unload` | Unload the active model | `{"status":"unloaded"}` |
| `DELETE` | `/api/v1/models/:id` | Delete a model file from disk | `{"status":"deleted"}` |
| `GET` | `/api/v1/models/lora/profiles` | List LoRA adapter profiles | `{"profiles":[]}` |
| `POST` | `/api/v1/models/lora/activate` | Activate a LoRA profile | `{"status":"activated"}` |
| `GET` | `/api/v1/recording/list` | List CSI recording sessions | `{"recordings":[...],"count":0}` |
| `POST` | `/api/v1/recording/start` | Start recording CSI frames to JSONL | `{"status":"recording","session_id":"..."}` |
| `POST` | `/api/v1/recording/stop` | Stop the active recording | `{"status":"stopped","duration_secs":...}` |
| `DELETE` | `/api/v1/recording/:id` | Delete a recording file | `{"status":"deleted"}` |
| `GET` | `/api/v1/train/status` | Training run status | `{"phase":"idle"}` |
| `POST` | `/api/v1/train/start` | Start a training run | `{"status":"started"}` |
| `POST` | `/api/v1/train/stop` | Stop the active training run | `{"status":"stopped"}` |
### Example: Get Vital Signs
@@ -347,7 +361,9 @@ curl -s http://localhost:3000/api/v1/pose/current | python -m json.tool
Real-time sensing data is available via WebSocket.
**URL:** `ws://localhost:3001/ws/sensing` (Docker) or `ws://localhost:8765/ws/sensing` (binary default).
**URL:** `ws://localhost:3000/ws/sensing` (same port as HTTP — recommended) or `ws://localhost:3001/ws/sensing` (dedicated WS port).
> **Note:** The `/ws/sensing` WebSocket endpoint is available on both the HTTP port (3000) and the dedicated WebSocket port (3001/8765). The web UI uses the HTTP port so only one port needs to be exposed. The dedicated WS port remains available for backward compatibility.
### Python Example
@@ -812,7 +828,7 @@ The Rust implementation (v2) is 810x faster than Python (v1) for the full CSI pi
## Further Reading
- [Architecture Decision Records](../docs/adr/) - 33 ADRs covering all design decisions
- [Architecture Decision Records](../docs/adr/) - 43 ADRs covering all design decisions
- [WiFi-Mat Disaster Response Guide](wifi-mat-user-guide.md) - Search & rescue module
- [Build Guide](build-guide.md) - Detailed build instructions
- [RuVector](https://github.com/ruvnet/ruvector) - Signal intelligence crate ecosystem
+26 -7
View File
@@ -27,6 +27,16 @@ 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.
*/
#define CSI_MIN_SEND_INTERVAL_US (20 * 1000)
static int64_t s_last_send_us = 0;
/* ---- ADR-029: Channel-hop state ---- */
@@ -143,14 +153,23 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
size_t frame_len = csi_serialize_frame(info, frame_buf, sizeof(frame_buf));
if (frame_len > 0) {
int ret = stream_sender_send(frame_buf, frame_len);
if (ret > 0) {
s_send_ok++;
} else {
s_send_fail++;
if (s_send_fail <= 5) {
ESP_LOGW(TAG, "sendto failed (fail #%lu)", (unsigned long)s_send_fail);
/* 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++;
}
}
+40 -1
View File
@@ -9,6 +9,7 @@
#include <string.h>
#include "esp_log.h"
#include "esp_timer.h"
#include "lwip/sockets.h"
#include "lwip/netdb.h"
#include "sdkconfig.h"
@@ -18,6 +19,17 @@ static const char *TAG = "stream_sender";
static int s_sock = -1;
static struct sockaddr_in s_dest_addr;
/**
* ENOMEM backoff state.
* When sendto fails with ENOMEM (errno 12), we suppress further sends for
* a cooldown period to let lwIP reclaim packet buffers. Without this,
* rapid-fire CSI callbacks can exhaust the pbuf pool and crash the device.
*/
static int64_t s_backoff_until_us = 0; /* esp_timer timestamp to resume */
#define ENOMEM_COOLDOWN_MS 100 /* suppress sends for 100 ms */
#define ENOMEM_LOG_INTERVAL 50 /* log every Nth suppressed send */
static uint32_t s_enomem_suppressed = 0;
static int sender_init_internal(const char *ip, uint16_t port)
{
s_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
@@ -57,10 +69,37 @@ int stream_sender_send(const uint8_t *data, size_t len)
return -1;
}
/* ENOMEM backoff: if we recently exhausted lwIP buffers, skip sends
* until the cooldown expires. This prevents the cascade of failed
* sendto calls that leads to a guru meditation crash. */
if (s_backoff_until_us > 0) {
int64_t now = esp_timer_get_time();
if (now < s_backoff_until_us) {
s_enomem_suppressed++;
if ((s_enomem_suppressed % ENOMEM_LOG_INTERVAL) == 1) {
ESP_LOGW(TAG, "sendto suppressed (ENOMEM backoff, %lu dropped)",
(unsigned long)s_enomem_suppressed);
}
return -1;
}
/* Cooldown expired — resume sending */
ESP_LOGI(TAG, "ENOMEM backoff expired, resuming sends (%lu were suppressed)",
(unsigned long)s_enomem_suppressed);
s_backoff_until_us = 0;
s_enomem_suppressed = 0;
}
int sent = sendto(s_sock, data, len, 0,
(struct sockaddr *)&s_dest_addr, sizeof(s_dest_addr));
if (sent < 0) {
ESP_LOGW(TAG, "sendto failed: errno %d", errno);
if (errno == ENOMEM) {
/* Start backoff to let lwIP reclaim buffers */
s_backoff_until_us = esp_timer_get_time() +
(int64_t)ENOMEM_COOLDOWN_MS * 1000;
ESP_LOGW(TAG, "sendto ENOMEM — backing off for %d ms", ENOMEM_COOLDOWN_MS);
} else {
ESP_LOGW(TAG, "sendto failed: errno %d", errno);
}
return -1;
}
+72 -17
View File
@@ -30,22 +30,40 @@ NVS_PARTITION_OFFSET = 0x9000
NVS_PARTITION_SIZE = 0x6000 # 24 KiB
def build_nvs_csv(ssid, password, target_ip, target_port, node_id):
def build_nvs_csv(args):
"""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)])
if args.ssid:
writer.writerow(["ssid", "data", "string", args.ssid])
if args.password is not None:
writer.writerow(["password", "data", "string", args.password])
if args.target_ip:
writer.writerow(["target_ip", "data", "string", args.target_ip])
if args.target_port is not None:
writer.writerow(["target_port", "data", "u16", str(args.target_port)])
if args.node_id is not None:
writer.writerow(["node_id", "data", "u8", str(args.node_id)])
# TDM mesh settings
if args.tdm_slot is not None:
writer.writerow(["tdm_slot", "data", "u8", str(args.tdm_slot)])
if args.tdm_total is not None:
writer.writerow(["tdm_nodes", "data", "u8", str(args.tdm_total)])
# Edge intelligence settings (ADR-039)
if args.edge_tier is not None:
writer.writerow(["edge_tier", "data", "u8", str(args.edge_tier)])
if args.pres_thresh is not None:
writer.writerow(["pres_thresh", "data", "u16", str(args.pres_thresh)])
if args.fall_thresh is not None:
writer.writerow(["fall_thresh", "data", "u16", str(args.fall_thresh)])
if args.vital_win is not None:
writer.writerow(["vital_win", "data", "u16", str(args.vital_win)])
if args.vital_int is not None:
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)])
return buf.getvalue()
@@ -127,14 +145,37 @@ def main():
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)")
# TDM mesh settings
parser.add_argument("--tdm-slot", type=int, help="TDM slot index for this node (0-based)")
parser.add_argument("--tdm-total", type=int, help="Total number of TDM nodes in mesh")
# Edge intelligence settings (ADR-039)
parser.add_argument("--edge-tier", type=int, choices=[0, 1, 2],
help="Edge processing tier: 0=off, 1=stats, 2=vitals")
parser.add_argument("--pres-thresh", type=int, help="Presence detection threshold (default: 50)")
parser.add_argument("--fall-thresh", type=int, help="Fall detection threshold (default: 500)")
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)")
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)")
has_value = any([
args.ssid, args.password is not None, args.target_ip,
args.target_port, args.node_id is not None,
args.tdm_slot is not None, args.tdm_total is not None,
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,
])
if not has_value:
parser.error("At least one config value must be specified")
# Validate TDM: if one is given, both should be
if (args.tdm_slot is not None) != (args.tdm_total is not None):
parser.error("--tdm-slot and --tdm-total must be specified together")
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})")
print("Building NVS configuration:")
if args.ssid:
@@ -147,9 +188,23 @@ def main():
print(f" Target Port: {args.target_port}")
if args.node_id is not None:
print(f" Node ID: {args.node_id}")
if args.tdm_slot is not None:
print(f" TDM Slot: {args.tdm_slot} of {args.tdm_total}")
if args.edge_tier is not None:
tier_desc = {0: "off (raw CSI)", 1: "stats", 2: "vitals"}
print(f" Edge Tier: {args.edge_tier} ({tier_desc.get(args.edge_tier, '?')})")
if args.pres_thresh is not None:
print(f" Pres Thresh: {args.pres_thresh}")
if args.fall_thresh is not None:
print(f" Fall Thresh: {args.fall_thresh}")
if args.vital_win is not None:
print(f" Vital Window: {args.vital_win} frames")
if args.vital_int is not None:
print(f" Vital Interval:{args.vital_int} ms")
if args.subk_count is not None:
print(f" Top-K Subcarr: {args.subk_count}")
csv_content = build_nvs_csv(args.ssid, args.password, args.target_ip,
args.target_port, args.node_id)
csv_content = build_nvs_csv(args)
try:
nvs_bin = generate_nvs_binary(csv_content, NVS_PARTITION_SIZE)