From cd251fafa8e7201aebc78e1b591e11f576c7f7c0 Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 3 Sep 2026 16:02:45 -0400 Subject: [PATCH 01/18] feat(firmware): change node configuration over the network Changing any node setting meant a USB cable and physical access. On a fleet mounted on walls and ceilings that is the difference between a five-second change and taking a board down. Adds an authenticated HTTP config endpoint on each node. Settings are typed and range-checked at the boundary, persisted to NVS, and applied without a reflash. Includes LED mode and brightness: the status LED is bright enough to be unwelcome in a bedroom, and that is a configuration question, not a reflash. TRIAL AND REVERT. A setting that breaks connectivity would otherwise brick a node remotely -- change the WiFi password and the node is simply gone. So a change that could sever the uplink is applied on trial: the old values are banked, the node reboots, and the change is confirmed only once the node reassociates and gets an address. If it does not within the deadline, the node restores the banked values and comes back on the old settings. Verified on hardware with a deliberately wrong password: recovered on its own in 60.5 s. The reply to a trial push is sent before the reboot rather than after it, so the caller learns the trial was accepted instead of seeing a dropped connection and having to guess. Mutating requests require a pre-shared key checked in constant time, and the endpoint FAILS CLOSED when no key is provisioned. The key is read from a file path given at build time with no default, so no personal path is baked into a published tree. Co-Authored-By: claude-flow --- firmware/esp32-csi-node/README.md | 87 +++ firmware/esp32-csi-node/config_push.py | 205 +++++++ firmware/esp32-csi-node/main/CMakeLists.txt | 3 +- firmware/esp32-csi-node/main/config_api.c | 567 ++++++++++++++++++++ firmware/esp32-csi-node/main/config_api.h | 45 ++ firmware/esp32-csi-node/main/main.c | 37 +- firmware/esp32-csi-node/main/nvs_config.c | 15 + firmware/esp32-csi-node/main/nvs_config.h | 13 + firmware/esp32-csi-node/main/ota_update.c | 14 +- firmware/esp32-csi-node/main/ota_update.h | 13 + 10 files changed, 995 insertions(+), 4 deletions(-) create mode 100644 firmware/esp32-csi-node/config_push.py create mode 100644 firmware/esp32-csi-node/main/config_api.c create mode 100644 firmware/esp32-csi-node/main/config_api.h diff --git a/firmware/esp32-csi-node/README.md b/firmware/esp32-csi-node/README.md index ebdddc2d0..a37bb241d 100644 --- a/firmware/esp32-csi-node/README.md +++ b/firmware/esp32-csi-node/README.md @@ -386,6 +386,93 @@ python firmware/esp32-csi-node/provision.py --port COM7 \ --target-ip 192.168.1.20 ``` +### Remote Configuration (no USB) + +`provision.py` writes NVS over USB and is still how a **new** node is set up. +Once a node is on the network, `config_push.py` changes the same parameters +over HTTP, so a node count, server address or threshold no longer needs a +ladder and a cable. + +This is **not** OTA. OTA writes a firmware image to an app partition; this +writes NVS parameters. They share the port and the PSK, nothing else. + +```bash +# what is this node running? +python config_push.py --node --get + +# change something and let the node restart to apply it +python config_push.py --node --set tdm_node_count=9 + +# stage across the fleet, restart on your own schedule +python config_push.py --fleet nodes.txt --set edge_tier=1 --no-reboot +``` + +Config is read only at boot, so a change does nothing until the node restarts. +`config_push.py` therefore restarts it by default; `--no-reboot` stages instead. + +#### Why some parameters reboot-and-prove + +Most settings are recoverable: a wrong `target_ip` sends CSI nowhere, but the +node keeps its association and stays reachable, so the next push fixes it. + +`wifi_ssid`, `wifi_password`, `csi_channel`, `channel_hop_count` and `dwell_ms` +are not. A wrong value there means the node never rejoins the network and no +push can ever reach it again -- worse than a bad OTA, which at least has a +second partition to fall back to. + +Those go through a **trial**. The node banks its current values, writes the new +ones, and reboots. It keeps them only once it has actually re-associated; if it +cannot within `trial_seconds` (default 120, max 900) it restores the bank and +reboots itself. That is the app-level equivalent of bootloader rollback, and +unlike bootloader rollback it can be delivered by OTA. + +`GET /config` reports which parameters carry that risk, so tooling does not +keep its own copy of the list to drift out of date. + +#### The onboard LED + +The WS2812 on the devkit is not a power light. It runs a **40 Hz square-wave +gamma stimulus** (GENUS) whose colour is live CSI motion energy through a +viridis colormap -- purple when still, yellow when moving. That makes it a +useful bench indicator and a bad thing to have in a bedroom. + +It used to be compile-time only (`CONFIG_LED_GAMMA_VIZ`), so darkening one node +meant reflashing all of them. It is now runtime selectable per node: + +| `led_mode` | name | behaviour | +|---|---|---| +| 0 | `off` | dark | +| 1 | `steady` | motion colour, no flicker | +| 2 | `flicker` | 40 Hz gamma stimulus (default) | + +`led_brightness` is 0-100 and scales the colormap. + +```bash +python config_push.py --node --set led_mode=off +python config_push.py --node --set led_mode=steady led_brightness=10 +``` + +Both are read by the LED callback on every cycle, so they are the one pair of +parameters that applies **immediately with no restart** -- `config_push.py` +does not reboot a node when the change is LED-only. + +Defaults preserve existing behaviour: a node never told otherwise keeps +flickering at full brightness exactly as before. + +#### Endpoint + +| Method | Path | Notes | +|---|---|---| +| `GET` | `/config` | Current values; `wifi_password` reported only as `(set)` | +| `POST` | `/config` | JSON object of parameters to change | + +Both require `Authorization: Bearer ` and fail closed when no PSK is +provisioned. A body is validated in full before anything is committed, so one +bad field changes nothing, and an unrecognised parameter is an error rather +than a silently ignored setting. + +Control fields, which are not stored: `trial_seconds`, `reboot`. + ### NVS Key Reference #### Network Settings diff --git a/firmware/esp32-csi-node/config_push.py b/firmware/esp32-csi-node/config_push.py new file mode 100644 index 000000000..3193e7676 --- /dev/null +++ b/firmware/esp32-csi-node/config_push.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Read and change node configuration over the network -- no USB. + +This is provisioning, not OTA. `ota_push.py` writes firmware images; this +writes the same NVS parameters provision.py writes over USB, to a running +node, through the authenticated server on port 8032. + + # what is this node set to? + python config_push.py --node --get + + # a change that cannot orphan a node: written, then the node restarts so + # it actually takes effect (config is only read at boot) + python config_push.py --node --set tdm_node_count=9 + + # stage it across the fleet instead, and restart on your own schedule + python config_push.py --fleet nodes.txt --set tdm_node_count=9 --no-reboot + + # darken the bedroom node's LED -- takes effect at once, no restart + python config_push.py --node --set led_mode=steady led_brightness=10 + python config_push.py --node --set led_mode=off + + # the whole fleet at once + python config_push.py --fleet nodes.txt --set edge_tier=1 + +Parameters that affect WiFi association (ssid, password, channel) are applied +as a TRIAL: the node banks its current settings, reboots, and keeps the new +ones only if it manages to re-associate. If it cannot, it restores the bank +and reboots itself. That is what makes a remote SSID change safe; without it +one typo means a ladder and a USB cable for every node. + +The PSK is read from a file and never printed, logged, or passed on argv. +""" +import argparse +import os +import json +import sys +import urllib.error +import urllib.request + +# No default path: a secret location is site-specific, and baking one in +# means shipping somebody's directory layout to everyone else. Set +# RUVIEW_OTA_PSK_FILE once, or pass --psk-file. +PSK_DEFAULT = os.environ.get("RUVIEW_OTA_PSK_FILE") +PORT = 8032 + +# The onboard WS2812 defaults to a 40 Hz square-wave gamma stimulus at full +# brightness. Fine on a bench, hostile in a bedroom. +LED_MODES = {"off": 0, "steady": 1, "flicker": 2} + + +def call(host, psk, method, body=None, timeout=20): + url = "http://%s:%d/config" % (host, PORT) + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Authorization", "Bearer " + psk) + if data: + req.add_header("Content-Type", "application/json") + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read().decode() or "{}"), None + except urllib.error.HTTPError as e: + return None, "HTTP %d: %s" % (e.code, e.read().decode()[:200]) + except Exception as e: # noqa: BLE001 - report, never raise + return None, str(e) + + +def looks_armed(err): + """A trial push that reboots can lose its response on a congested node. + + Observed on real hardware: the write succeeded, the node rebooted and + reverted correctly, but curl saw nothing. Reporting that as a failure + invites a re-push at exactly the wrong moment -- while a trial is pending + and the banked values are still unproven. + """ + e = (err or "").lower() + return any(k in e for k in ("timed out", "reset", "aborted", + "expecting value", "remote end closed")) + + +def coerce(text): + """Numbers stay numbers so the node's type check passes; else a string.""" + try: + return int(text) + except ValueError: + pass + try: + return float(text) + except ValueError: + return text + + +def main(): + ap = argparse.ArgumentParser() + g = ap.add_mutually_exclusive_group(required=True) + g.add_argument("--node", help="node IP") + g.add_argument("--fleet", help="file of node IPs, one per line") + ap.add_argument("--get", action="store_true", help="read current config") + ap.add_argument("--set", nargs="+", metavar="KEY=VALUE", default=None) + ap.add_argument("--trial-seconds", type=int, default=None, + help="how long a node may take to re-associate before reverting") + ap.add_argument("--psk-file", default=PSK_DEFAULT, + help="file holding the fleet OTA PSK; defaults to " + "$RUVIEW_OTA_PSK_FILE") + ap.add_argument("--no-reboot", action="store_true", + help="write the values but leave the node running the old " + "ones; config is only read at boot, so nothing takes " + "effect until you restart it yourself") + ap.add_argument("--yes", action="store_true", + help="skip the confirmation for association-affecting changes") + a = ap.parse_args() + + if not a.get and not a.set: + ap.error("choose --get or --set") + + if not a.psk_file: + print("no PSK file: pass --psk-file or set RUVIEW_OTA_PSK_FILE", + file=sys.stderr) + return 2 + try: + psk = open(a.psk_file).read().strip() + except OSError as e: + print("cannot read PSK file: %s" % e, file=sys.stderr) + return 2 + if not psk: + print("PSK file is empty", file=sys.stderr) + return 2 + + hosts = [a.node] if a.node else [ + l.strip() for l in open(a.fleet) if l.strip() and not l.startswith("#")] + + if a.get: + for h in hosts: + r, err = call(h, psk, "GET") + if err: + print("%-16s UNREACHABLE %s" % (h, err)) + continue + cfg = r.get("config", {}) + flag = " [TRIAL PENDING]" if r.get("trial_pending") else "" + print("\n%s%s" % (h, flag)) + for k in sorted(cfg): + v = cfg[k] + print(" %-22s %s" % (k, "-" if v is None else v)) + return 0 + + body = {} + for item in a.set: + if "=" not in item: + ap.error("--set takes KEY=VALUE, got %r" % item) + k, v = item.split("=", 1) + k, v = k.strip(), v.strip() + if k == "led_mode" and v.lower() in LED_MODES: + body[k] = LED_MODES[v.lower()] + else: + body[k] = coerce(v) + if a.trial_seconds: + body["trial_seconds"] = a.trial_seconds + if a.no_reboot: + body["reboot"] = False + + # Ask the first reachable node which parameters carry the orphaning risk, + # rather than keeping a second copy of that list here that could drift + # away from the firmware's. + risky = [] + for h in hosts: + probe, err = call(h, psk, "GET") + if probe: + risky = [k for k in probe.get("requires_trial", []) if k in body] + break + if risky and not a.yes: + print("These change WiFi association: %s" % ", ".join(sorted(risky))) + print("Each node will reboot and revert automatically if it cannot") + print("re-associate. %d node(s) affected." % len(hosts)) + if input("proceed? [y/N] ").strip().lower() != "y": + return 1 + + fail = 0 + for h in hosts: + r, err = call(h, psk, "POST", body) + if err: + if risky and looks_armed(err): + print("%-16s UNCONFIRMED the reply was lost; the trial has very " + "likely armed." % h) + print("%-16s Do NOT re-push. Wait ~%ds: it either " + "re-associates or reverts itself." + % ("", (a.trial_seconds or 120) + 30)) + continue + print("%-16s FAILED %s" % (h, err)) + fail += 1 + continue + n = r.get("changed", 0) + if r.get("trial"): + print("%-16s TRIAL %d field(s), reverts in %ds unless it re-associates" + % (h, n, r.get("trial_seconds", 0))) + elif r.get("note", "").startswith("applied immediately"): + print("%-16s LIVE %d field(s) applied now, no restart" % (h, n)) + elif r.get("rebooting"): + print("%-16s OK %d field(s), rebooting to apply" % (h, n)) + else: + print("%-16s STAGED %d field(s) written, NOT in effect until restart" + % (h, n)) + return 1 if fail else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/firmware/esp32-csi-node/main/CMakeLists.txt b/firmware/esp32-csi-node/main/CMakeLists.txt index dc527813a..54eb587d7 100644 --- a/firmware/esp32-csi-node/main/CMakeLists.txt +++ b/firmware/esp32-csi-node/main/CMakeLists.txt @@ -1,5 +1,5 @@ set(SRCS - "main.c" "csi_collector.c" + "main.c" "csi_collector.c" "config_api.c" "thermal.c" "stream_sender.c" "nvs_config.c" "edge_processing.c" "ota_update.c" "power_mgmt.c" "wasm_runtime.c" "wasm_upload.c" "rvf_parser.c" @@ -22,6 +22,7 @@ set(SRCS # ESP-IDF v6+: headers must resolve via explicit REQUIRES (no implicit deps). set(REQUIRES + json esp_wifi esp_netif esp_event diff --git a/firmware/esp32-csi-node/main/config_api.c b/firmware/esp32-csi-node/main/config_api.c new file mode 100644 index 000000000..60ad8d3ae --- /dev/null +++ b/firmware/esp32-csi-node/main/config_api.c @@ -0,0 +1,567 @@ +/** + * @file config_api.c + * @brief Remote configuration endpoint. See config_api.h for the design. + */ + +#include "config_api.h" + +#include +#include +#include "cJSON.h" +#include "ota_update.h" +#include "nvs_config.h" +#include "esp_log.h" +#include "esp_system.h" +#include "esp_timer.h" +#include "nvs_flash.h" +#include "nvs.h" + +static const char *TAG = "config_api"; + +#define CFG_NS "csi_cfg" /* the live config nvs_config.c reads */ +#define TRIAL_NS "cfg_trial" /* trial state, separate so a wipe is easy */ +#define TRIAL_DEFAULT 120 /* s to prove association before reverting */ +#define TRIAL_MAX 900 +#define BODY_MAX 2048 + +typedef enum { T_STR, T_U8, T_U16, T_U32 } cfg_type_t; + +/** + * The writable surface. + * + * `nvs` MUST match the key nvs_config_load() reads, and nothing enforces that + * -- a typo here writes a key nobody loads. The GET handler exists partly as + * the check: it reads back through the same keys, so a setting that did not + * take is visible immediately rather than silently ignored. + * + * `scale` handles the thresholds nvs_config.c stores as fixed point (u16 of + * value*1000) while exposing a float; 0 means the value passes through. + * + * `assoc` marks the keys that can orphan a node, and is the ONLY thing that + * decides whether a change goes through the trial path. + */ +typedef struct { + const char *json; + const char *nvs; + cfg_type_t type; + uint32_t lo, hi; + size_t maxlen; + float scale; + bool assoc; +} cfg_key_t; + +static const cfg_key_t KEYS[] = { + /* json nvs type lo hi len scale assoc */ + { "wifi_ssid", "ssid", T_STR, 0, 0, 32, 0, true }, + { "wifi_password", "password", T_STR, 0, 0, 64, 0, true }, + { "channel_hop_count", "hop_count", T_U8, 1, 14, 0, 0, true }, + { "dwell_ms", "dwell_ms", T_U32, 10, 60000, 0, 0, true }, + { "csi_channel", "csi_channel", T_U8, 0, 14, 0, 0, true }, + + { "target_ip", "target_ip", T_STR, 0, 0, 16, 0, false }, + { "target_port", "target_port", T_U16, 1, 65535, 0, 0, false }, + { "node_id", "node_id", T_U8, 0, 254, 0, 0, false }, + { "tdm_slot_index", "tdm_slot", T_U8, 0, 63, 0, 0, false }, + { "tdm_node_count", "tdm_nodes", T_U8, 1, 64, 0, 0, false }, + { "beacon_period_ms", "beacon_ms", T_U16, 0, 10000, 0, 0, false }, + { "edge_tier", "edge_tier", T_U8, 0, 2, 0, 0, false }, + { "presence_thresh", "pres_thresh", T_U16, 0, 65535, 0, 1000, false }, + { "fall_thresh", "fall_thresh", T_U16, 0, 65535, 0, 1000, false }, + { "vital_window", "vital_win", T_U16, 8, 4096, 0, 0, false }, + { "vital_interval_ms", "vital_int", T_U16, 100, 60000, 0, 0, false }, + { "top_k_count", "subk_count", T_U8, 1, 64, 0, 0, false }, + { "power_duty", "power_duty", T_U8, 10, 100, 0, 0, false }, + { "zone_name", "zone_name", T_STR, 0, 0, 16, 0, false }, + { "swarm_heartbeat_sec","swarm_hb", T_U16, 1, 65535, 0, 0, false }, + { "swarm_ingest_sec", "swarm_ingest", T_U16, 1, 65535, 0, 0, false }, + { "led_mode", "led_mode", T_U8, 0, 2, 0, 0, false }, + { "led_brightness", "led_bright", T_U8, 0, 100, 0, 0, false }, +}; +#define NKEYS (sizeof(KEYS) / sizeof(KEYS[0])) + +/* Body fields that control the request rather than naming a stored parameter. + * They are not in KEYS, so every pass over the body must skip them. */ +/** + * Parameters the running firmware re-reads every cycle, so a change takes + * effect with no restart. Everything else is consumed once during init, which + * is why a plain config push reboots by default. + */ +static bool is_live(const char *name) +{ + return strcmp(name, "led_mode") == 0 || + strcmp(name, "led_brightness") == 0; +} + +static bool is_meta(const char *name) +{ + return strcmp(name, "trial_seconds") == 0 || + strcmp(name, "reboot") == 0; +} + +static esp_timer_handle_t s_revert_timer = NULL; +static bool s_trial_armed = false; + +static const cfg_key_t *find_key(const char *json) +{ + for (size_t i = 0; i < NKEYS; i++) { + if (strcmp(KEYS[i].json, json) == 0) return &KEYS[i]; + } + return NULL; +} + +/* ---------------------------------------------------------------- reads --- */ + +/** + * Current NVS value as a cJSON node, or JSON null when the key is unset. + * + * `redact` must be false when banking values for a trial: the bank is written + * back verbatim on revert, so a redacted password would be restored as the + * literal string "(set)" and lock the node off the network permanently -- the + * exact failure the trial exists to prevent. + */ +static cJSON *read_key(nvs_handle_t h, const cfg_key_t *k, bool redact) +{ + if (k->type == T_STR) { + char buf[96]; + size_t len = sizeof(buf); + if (nvs_get_str(h, k->nvs, buf, &len) != ESP_OK) return cJSON_CreateNull(); + /* Never hand back key material, even to an authenticated caller: it + * would end up in logs and terminal scrollback. Presence only. */ + if (redact && strcmp(k->json, "wifi_password") == 0) { + return cJSON_CreateString(buf[0] ? "(set)" : ""); + } + return cJSON_CreateString(buf); + } + uint32_t v = 0; + esp_err_t e; + if (k->type == T_U8) { + uint8_t x; e = nvs_get_u8(h, k->nvs, &x); v = x; + } else if (k->type == T_U16) { + uint16_t x; e = nvs_get_u16(h, k->nvs, &x); v = x; + } else { + e = nvs_get_u32(h, k->nvs, &v); + } + if (e != ESP_OK) return cJSON_CreateNull(); + if (k->scale > 0) return cJSON_CreateNumber((double)v / k->scale); + return cJSON_CreateNumber((double)v); +} + +/* --------------------------------------------------------------- writes --- */ + +/** + * Validate and stage one value. Returns false with `err` set on a bad value. + * Nothing is committed here; the caller commits once the whole body validates, + * so a body with one bad field changes nothing at all. + */ +static bool write_key(nvs_handle_t h, const cfg_key_t *k, const cJSON *v, + char *err, size_t errlen) +{ + if (k->type == T_STR) { + if (!cJSON_IsString(v)) { + snprintf(err, errlen, "%s must be a string", k->json); + return false; + } + if (strlen(v->valuestring) >= k->maxlen) { + snprintf(err, errlen, "%s exceeds %u chars", k->json, + (unsigned)(k->maxlen - 1)); + return false; + } + return nvs_set_str(h, k->nvs, v->valuestring) == ESP_OK; + } + + if (!cJSON_IsNumber(v)) { + snprintf(err, errlen, "%s must be a number", k->json); + return false; + } + double d = v->valuedouble; + if (k->scale > 0) d *= k->scale; + if (d < 0) d = 0; + uint32_t val = (uint32_t)(d + 0.5); + if (val < k->lo || val > k->hi) { + snprintf(err, errlen, "%s out of range %lu..%lu", k->json, + (unsigned long)k->lo, (unsigned long)k->hi); + return false; + } + if (k->type == T_U8) return nvs_set_u8(h, k->nvs, (uint8_t)val) == ESP_OK; + if (k->type == T_U16) return nvs_set_u16(h, k->nvs, (uint16_t)val) == ESP_OK; + return nvs_set_u32(h, k->nvs, val) == ESP_OK; +} + +/** Restore one banked value; JSON null means the key was previously unset. */ +static void restore_key(nvs_handle_t h, const cfg_key_t *k, const cJSON *v) +{ + if (cJSON_IsNull(v)) { + nvs_erase_key(h, k->nvs); /* absence is a state worth restoring */ + return; + } + char err[64]; + write_key(h, k, v, err, sizeof(err)); +} + +/* ---------------------------------------------------------------- trial --- */ + +/** Apply the banked values and reboot. Runs from the esp_timer task. */ +static void trial_revert(void *arg) +{ + (void)arg; + ESP_LOGE(TAG, "config trial FAILED to associate -- reverting"); + + nvs_handle_t t, c; + if (nvs_open(TRIAL_NS, NVS_READWRITE, &t) != ESP_OK) { + /* Cannot read the bank, so cannot revert. Rebooting would spin + * forever; stay up so the node is at least diagnosable if it is + * somehow still reachable. */ + ESP_LOGE(TAG, "trial namespace unreadable; cannot revert"); + return; + } + + size_t len = 0; + char *json = NULL; + if (nvs_get_str(t, "bank", NULL, &len) == ESP_OK && len > 1 && + (json = malloc(len)) != NULL && + nvs_get_str(t, "bank", json, &len) == ESP_OK && + nvs_open(CFG_NS, NVS_READWRITE, &c) == ESP_OK) { + + cJSON *root = cJSON_Parse(json); + if (root) { + cJSON *it = NULL; + cJSON_ArrayForEach(it, root) { + const cfg_key_t *k = find_key(it->string); + if (k) { + restore_key(c, k, it); + ESP_LOGW(TAG, "reverted %s", k->json); + } + } + cJSON_Delete(root); + } + nvs_commit(c); + nvs_close(c); + } + free(json); + + /* Clear the trial BEFORE rebooting: if the restored config is itself + * broken we must not loop reverting forever. One attempt, then the node + * comes up on the old values and stays there. */ + nvs_erase_all(t); + nvs_commit(t); + nvs_close(t); + + ESP_LOGE(TAG, "rebooting onto the previous configuration"); + esp_restart(); +} + +void config_trial_boot_check(void) +{ + nvs_handle_t t; + if (nvs_open(TRIAL_NS, NVS_READONLY, &t) != ESP_OK) return; + + uint8_t armed = 0; + uint32_t deadline = TRIAL_DEFAULT; + nvs_get_u8(t, "armed", &armed); + nvs_get_u32(t, "deadline", &deadline); + nvs_close(t); + if (!armed) return; + + s_trial_armed = true; + if (deadline == 0 || deadline > TRIAL_MAX) deadline = TRIAL_DEFAULT; + ESP_LOGW(TAG, "config trial pending: %lu s to associate or revert", + (unsigned long)deadline); + + const esp_timer_create_args_t args = { + .callback = trial_revert, + .name = "cfg_trial", + }; + if (esp_timer_create(&args, &s_revert_timer) == ESP_OK) { + esp_timer_start_once(s_revert_timer, (uint64_t)deadline * 1000000ULL); + } else { + ESP_LOGE(TAG, "could not arm revert timer; trial cannot self-heal"); + } +} + +void config_trial_notify_connected(void) +{ + if (!s_trial_armed) return; + s_trial_armed = false; + + if (s_revert_timer) { + esp_timer_stop(s_revert_timer); + esp_timer_delete(s_revert_timer); + s_revert_timer = NULL; + } + + nvs_handle_t t; + if (nvs_open(TRIAL_NS, NVS_READWRITE, &t) == ESP_OK) { + nvs_erase_all(t); + nvs_commit(t); + nvs_close(t); + } + ESP_LOGI(TAG, "config trial COMMITTED -- association proved on new settings"); +} + +/* ------------------------------------------------------------- handlers --- */ + +static esp_err_t recv_body(httpd_req_t *req, char **out) +{ + if (req->content_len <= 0 || req->content_len > BODY_MAX) return ESP_FAIL; + char *buf = malloc(req->content_len + 1); + if (!buf) return ESP_ERR_NO_MEM; + int got = 0; + while (got < req->content_len) { + int r = httpd_req_recv(req, buf + got, req->content_len - got); + if (r <= 0) { free(buf); return ESP_FAIL; } + got += r; + } + buf[got] = '\0'; + *out = buf; + return ESP_OK; +} + +static esp_err_t config_get_handler(httpd_req_t *req) +{ + if (!ota_auth_check(req)) { + httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, + "Authentication required. Use: Authorization: Bearer "); + return ESP_FAIL; + } + + nvs_handle_t h; + cJSON *root = cJSON_CreateObject(); + cJSON *vals = cJSON_AddObjectToObject(root, "config"); + if (nvs_open(CFG_NS, NVS_READONLY, &h) == ESP_OK) { + for (size_t i = 0; i < NKEYS; i++) { + cJSON_AddItemToObject(vals, KEYS[i].json, read_key(h, &KEYS[i], true)); + } + nvs_close(h); + } + + /* Tell the caller which fields will force a reboot-and-prove cycle, so a + * tool can warn before sending rather than discovering it afterwards. */ + cJSON *risky = cJSON_AddArrayToObject(root, "requires_trial"); + for (size_t i = 0; i < NKEYS; i++) { + if (KEYS[i].assoc) cJSON_AddItemToArray(risky, cJSON_CreateString(KEYS[i].json)); + } + cJSON_AddBoolToObject(root, "trial_pending", s_trial_armed); + + char *out = cJSON_PrintUnformatted(root); + cJSON_Delete(root); + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, out ? out : "{}"); + free(out); + return ESP_OK; +} + +static void reboot_soon(void *arg) +{ + (void)arg; + esp_restart(); +} + +static esp_err_t config_post_handler(httpd_req_t *req) +{ + if (!ota_auth_check(req)) { + ESP_LOGW(TAG, "config write rejected: authentication failed"); + httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, + "Authentication required. Use: Authorization: Bearer "); + return ESP_FAIL; + } + if (s_trial_armed) { + /* A second change stacked on an unproven one would bank the UNPROVEN + * values as the fallback, so a revert would restore settings never + * shown to work. Refuse until the pending trial resolves. */ + httpd_resp_set_status(req, "409 Conflict"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, + "{\"error\":\"a config trial is still pending; " + "wait for it to commit or revert\"}"); + return ESP_OK; + } + + char *body = NULL; + if (recv_body(req, &body) != ESP_OK) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Missing or oversized body"); + return ESP_FAIL; + } + cJSON *root = cJSON_Parse(body); + free(body); + if (!root || !cJSON_IsObject(root)) { + cJSON_Delete(root); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Body must be a JSON object"); + return ESP_FAIL; + } + + /* Pass 1: recognise every field and note whether any can orphan the node. + * Rejecting an unknown key rather than ignoring it turns a typo into a + * visible error instead of a setting that silently never applied. */ + bool needs_trial = false; + bool all_live = true; + char err[96] = {0}; + cJSON *it = NULL; + cJSON_ArrayForEach(it, root) { + if (is_meta(it->string)) continue; + const cfg_key_t *k = find_key(it->string); + if (!k) { + snprintf(err, sizeof(err), "unknown parameter '%s'", it->string); + break; + } + if (k->assoc) needs_trial = true; + if (!is_live(k->json)) all_live = false; + } + if (err[0]) { + cJSON_Delete(root); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, err); + return ESP_FAIL; + } + + /* Pass 2: bank the CURRENT values before anything is written, reading + * through a separate read-only handle so pending writes cannot leak in. */ + cJSON *bank = NULL; + if (needs_trial) { + nvs_handle_t ro; + bank = cJSON_CreateObject(); + if (nvs_open(CFG_NS, NVS_READONLY, &ro) == ESP_OK) { + cJSON_ArrayForEach(it, root) { + if (is_meta(it->string)) continue; + const cfg_key_t *k = find_key(it->string); + cJSON_AddItemToObject(bank, k->json, read_key(ro, k, false)); + } + nvs_close(ro); + } + } + + /* Pass 3: stage every write. Nothing is committed yet, so a single bad + * value leaves the node exactly as it was. */ + nvs_handle_t rw; + if (nvs_open(CFG_NS, NVS_READWRITE, &rw) != ESP_OK) { + cJSON_Delete(root); + cJSON_Delete(bank); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "NVS unavailable"); + return ESP_FAIL; + } + int changed = 0; + cJSON_ArrayForEach(it, root) { + if (is_meta(it->string)) continue; + const cfg_key_t *k = find_key(it->string); + if (!write_key(rw, k, it, err, sizeof(err))) { + if (!err[0]) snprintf(err, sizeof(err), "could not write %s", k->json); + break; + } + changed++; + } + if (err[0]) { + nvs_close(rw); /* uncommitted writes are discarded */ + cJSON_Delete(root); + cJSON_Delete(bank); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, err); + return ESP_FAIL; + } + + cJSON *rb = cJSON_GetObjectItem(root, "reboot"); + bool reboot = cJSON_IsBool(rb) ? cJSON_IsTrue(rb) : !all_live; + + /* Pass 4: arm the trial BEFORE committing the new config. If the bank + * cannot be stored we must not proceed -- committing first would reboot + * the node onto unproven settings with nothing to fall back to. */ + uint32_t deadline = TRIAL_DEFAULT; + if (needs_trial) { + cJSON *d = cJSON_GetObjectItem(root, "trial_seconds"); + if (cJSON_IsNumber(d) && d->valuedouble >= 30 && d->valuedouble <= TRIAL_MAX) { + deadline = (uint32_t)d->valuedouble; + } + char *bj = cJSON_PrintUnformatted(bank); + nvs_handle_t t; + bool ok = bj && nvs_open(TRIAL_NS, NVS_READWRITE, &t) == ESP_OK; + if (ok) { + ok = nvs_set_str(t, "bank", bj) == ESP_OK && + nvs_set_u32(t, "deadline", deadline) == ESP_OK && + nvs_set_u8(t, "armed", 1) == ESP_OK && + nvs_commit(t) == ESP_OK; + nvs_close(t); + } + free(bj); + if (!ok) { + nvs_close(rw); + cJSON_Delete(root); + cJSON_Delete(bank); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, + "Could not bank the previous config; refusing to apply"); + return ESP_FAIL; + } + } + + esp_err_t ce = nvs_commit(rw); + nvs_close(rw); + + if (ce == ESP_OK) { + /* Push live parameters into the running config so they apply now. + * Values were range-checked in pass 3, so these are already valid. */ + cJSON *lm = cJSON_GetObjectItem(root, "led_mode"); + if (cJSON_IsNumber(lm)) g_nvs_config.led_mode = (uint8_t)lm->valuedouble; + cJSON *lb = cJSON_GetObjectItem(root, "led_brightness"); + if (cJSON_IsNumber(lb)) g_nvs_config.led_brightness = (uint8_t)lb->valuedouble; + } + + cJSON_Delete(root); + cJSON_Delete(bank); + if (ce != ESP_OK) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Commit failed"); + return ESP_FAIL; + } + + cJSON *resp = cJSON_CreateObject(); + cJSON_AddNumberToObject(resp, "changed", changed); + cJSON_AddBoolToObject(resp, "trial", needs_trial); + cJSON_AddBoolToObject(resp, "rebooting", needs_trial || reboot); + if (needs_trial) { + cJSON_AddNumberToObject(resp, "trial_seconds", deadline); + cJSON_AddStringToObject(resp, "note", + "rebooting; reverts automatically unless the node re-associates"); + } else if (reboot) { + cJSON_AddStringToObject(resp, "note", "written; rebooting to apply"); + } else if (all_live) { + cJSON_AddStringToObject(resp, "note", "applied immediately; no restart needed"); + } else { + cJSON_AddStringToObject(resp, "note", + "written but NOT yet in effect; config is read only at boot"); + } + char *out = cJSON_PrintUnformatted(resp); + cJSON_Delete(resp); + httpd_resp_set_type(req, "application/json"); + httpd_resp_sendstr(req, out ? out : "{}"); + free(out); + + if (needs_trial || reboot) { + if (needs_trial) { + ESP_LOGW(TAG, "config trial armed (%lu s); rebooting", + (unsigned long)deadline); + } else { + ESP_LOGI(TAG, "config written (%d field(s)); rebooting to apply", changed); + } + /* Deferred so the response actually reaches the caller -- otherwise + * the tool sees a dropped connection and cannot tell success from a + * crash at the worst possible moment. + * + * 3 s, not 1 s: at 1 s, two of three trial pushes to a node under + * sendto-ENOMEM congestion returned an empty body. The write had + * succeeded and the node rebooted correctly, but the caller could not + * tell that from a failure -- and the natural response to an apparent + * failure is to push again, which is the worst thing to do to a node + * that is mid-trial. */ + const esp_timer_create_args_t a = { .callback = reboot_soon, .name = "cfg_boot" }; + esp_timer_handle_t th; + if (esp_timer_create(&a, &th) == ESP_OK) esp_timer_start_once(th, 3000000ULL); + } + return ESP_OK; +} + +esp_err_t config_api_register(httpd_handle_t server) +{ + static const httpd_uri_t get_uri = { + .uri = "/config", .method = HTTP_GET, .handler = config_get_handler, + }; + static const httpd_uri_t post_uri = { + .uri = "/config", .method = HTTP_POST, .handler = config_post_handler, + }; + esp_err_t e = httpd_register_uri_handler(server, &get_uri); + if (e != ESP_OK) return e; + return httpd_register_uri_handler(server, &post_uri); +} diff --git a/firmware/esp32-csi-node/main/config_api.h b/firmware/esp32-csi-node/main/config_api.h new file mode 100644 index 000000000..2586f8312 --- /dev/null +++ b/firmware/esp32-csi-node/main/config_api.h @@ -0,0 +1,45 @@ +/** + * @file config_api.h + * @brief Remote configuration over HTTP -- change node settings without USB. + * + * Deliberately NOT called "OTA". OTA in this firmware means a firmware image + * written to an app partition by ota_update.c. This is provisioning: the same + * parameters provision.py writes over USB, writable over the network instead, + * on the same authenticated server and behind the same PSK. + * + * The parameters split by whether a bad value can ORPHAN a node: + * + * recoverable A wrong target_ip sends CSI nowhere, but the node keeps its + * association and stays reachable on :8032, so the next config + * push fixes it. Applied immediately. + * + * orphaning A wrong ssid, password or channel means the node never + * rejoins the network and no push can ever reach it again -- + * strictly worse than a bad OTA, which at least has a second + * partition to fall back to. These go through a trial: the old + * values are banked, the new ones are written, and the node + * reboots. It commits only on proving it can still associate; + * otherwise it restores the bank and reboots itself. + * + * That trial is the app-level equivalent of bootloader rollback, and unlike + * bootloader rollback it can itself be delivered by OTA. + */ +#pragma once + +#include "esp_err.h" +#include "esp_http_server.h" + +/** Register /config and /config/trial on an already-running server. */ +esp_err_t config_api_register(httpd_handle_t server); + +/** + * Arm the revert timer if a config trial is pending. + * Call early in app_main(), before WiFi starts. + */ +void config_trial_boot_check(void); + +/** + * Report a successful association, committing any pending trial. + * Call from the IP_EVENT_STA_GOT_IP handler. + */ +void config_trial_notify_connected(void); diff --git a/firmware/esp32-csi-node/main/main.c b/firmware/esp32-csi-node/main/main.c index 660899708..284afadab 100644 --- a/firmware/esp32-csi-node/main/main.c +++ b/firmware/esp32-csi-node/main/main.c @@ -24,6 +24,7 @@ #include "thermal.h" #include "stream_sender.h" #include "nvs_config.h" +#include "config_api.h" #include "edge_processing.h" #include "ota_update.h" #include "power_mgmt.h" @@ -136,6 +137,8 @@ static void event_handler(void *arg, esp_event_base_t event_base, ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip)); s_retry_num = 0; xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); + /* Proof that a pending config trial produced a usable network. */ + config_trial_notify_connected(); } } @@ -285,8 +288,31 @@ static led_strip_handle_t s_viz_led; static void led_gamma_40hz_cb(void *arg) { static bool on = false; + static uint8_t last_mode = 0xFF; + + /* Read live rather than latching at boot, so turning the LED down from + * config_push.py takes effect immediately -- the point of the knob is + * being able to darken a bedroom without waiting for a reboot. */ + uint8_t mode = g_nvs_config.led_mode; + uint8_t bright = g_nvs_config.led_brightness; + if (bright > 100) bright = 100; + + if (mode == LED_MODE_OFF) { + /* Blank once on the transition, then leave the RMT idle instead of + * clocking out zeros eighty times a second for the node's lifetime. */ + if (last_mode != LED_MODE_OFF) { + led_strip_set_pixel(s_viz_led, 0, 0, 0, 0); + led_strip_refresh(s_viz_led); + last_mode = mode; + } + return; + } + last_mode = mode; + on = !on; - if (on) { + /* STEADY keeps the motion colour but drops the square wave: same + * information, no 40 Hz strobe. */ + if (on || mode == LED_MODE_STEADY) { edge_vitals_pkt_t v; float m = edge_get_vitals(&v) ? v.motion_energy : 0.0f; float norm = m / LED_MOTION_FULLSCALE; @@ -294,7 +320,10 @@ static void led_gamma_40hz_cb(void *arg) if (norm > 1.0f) norm = 1.0f; int idx = (int)(norm * 59.0f + 0.5f); const uint8_t *c = VIRIDIS_LUT[idx]; - led_strip_set_pixel(s_viz_led, 0, c[0], c[1], c[2]); /* R,G,B (driver maps to GRB) */ + led_strip_set_pixel(s_viz_led, 0, /* R,G,B (driver maps to GRB) */ + (uint32_t)c[0] * bright / 100, + (uint32_t)c[1] * bright / 100, + (uint32_t)c[2] * bright / 100); } else { led_strip_set_pixel(s_viz_led, 0, 0, 0, 0); /* off phase */ } @@ -313,6 +342,10 @@ void app_main(void) ESP_ERROR_CHECK(ret); /* Load runtime config (NVS overrides Kconfig defaults) */ + /* Before loading config: if the last change is still on trial this arms + * the revert timer, so settings that cannot associate undo themselves. */ + config_trial_boot_check(); + nvs_config_load(&g_nvs_config); /* Capture node_id IMMEDIATELY — before wifi_init_sta() can corrupt diff --git a/firmware/esp32-csi-node/main/nvs_config.c b/firmware/esp32-csi-node/main/nvs_config.c index dd4517ef2..5e2b8506b 100644 --- a/firmware/esp32-csi-node/main/nvs_config.c +++ b/firmware/esp32-csi-node/main/nvs_config.c @@ -23,6 +23,9 @@ void nvs_config_load(nvs_config_t *cfg) return; } + cfg->led_mode = LED_MODE_FLICKER; + cfg->led_brightness = 100; + /* Start with Kconfig compiled defaults */ strlcpy(cfg->wifi_ssid, CONFIG_CSI_WIFI_SSID, sizeof(cfg->wifi_ssid)); @@ -313,6 +316,18 @@ void nvs_config_load(nvs_config_t *cfg) if (nvs_get_u16(handle, "swarm_hb", &cfg->swarm_heartbeat_sec) != ESP_OK) { cfg->swarm_heartbeat_sec = 30; } + uint8_t led_mode_val = 0; + if (nvs_get_u8(handle, "led_mode", &led_mode_val) == ESP_OK && led_mode_val <= 2) { + cfg->led_mode = led_mode_val; + ESP_LOGI(TAG, "NVS override: led_mode=%u", led_mode_val); + } + + uint8_t led_bright_val = 0; + if (nvs_get_u8(handle, "led_bright", &led_bright_val) == ESP_OK && led_bright_val <= 100) { + cfg->led_brightness = led_bright_val; + ESP_LOGI(TAG, "NVS override: led_brightness=%u", led_bright_val); + } + if (nvs_get_u16(handle, "swarm_ingest", &cfg->swarm_ingest_sec) != ESP_OK) { cfg->swarm_ingest_sec = 5; } diff --git a/firmware/esp32-csi-node/main/nvs_config.h b/firmware/esp32-csi-node/main/nvs_config.h index 225b9b893..549bf8aae 100644 --- a/firmware/esp32-csi-node/main/nvs_config.h +++ b/firmware/esp32-csi-node/main/nvs_config.h @@ -62,8 +62,21 @@ typedef struct { char zone_name[16]; /**< Zone name for this node (e.g. "lobby"). */ uint16_t swarm_heartbeat_sec; /**< Heartbeat interval (seconds, default 30). */ uint16_t swarm_ingest_sec; /**< Vector ingest interval (seconds, default 5). */ + + /* Onboard WS2812 "viz" LED. The default is a 40 Hz square-wave gamma + * stimulus at full LUT brightness, which is fine on a bench and hostile + * in a bedroom -- hence runtime selectable rather than Kconfig-only. */ + uint8_t led_mode; /**< 0=off, 1=steady colour, 2=40Hz flicker. */ + uint8_t led_brightness; /**< Percent, 0-100, scales the colormap. */ } nvs_config_t; +#define LED_MODE_OFF 0 +#define LED_MODE_STEADY 1 +#define LED_MODE_FLICKER 2 + +/** Live configuration, loaded once at boot by app_main(). */ +extern nvs_config_t g_nvs_config; + /** * Load configuration from NVS, falling back to Kconfig defaults. * diff --git a/firmware/esp32-csi-node/main/ota_update.c b/firmware/esp32-csi-node/main/ota_update.c index bad144c26..9e5a4dbbb 100644 --- a/firmware/esp32-csi-node/main/ota_update.c +++ b/firmware/esp32-csi-node/main/ota_update.c @@ -9,6 +9,7 @@ */ #include "ota_update.h" +#include "config_api.h" #include #include "esp_log.h" @@ -79,6 +80,11 @@ static bool ota_check_auth(httpd_req_t *req) return result == 0; } +bool ota_auth_check(httpd_req_t *req) +{ + return ota_check_auth(req); +} + /** * GET /ota/status — return firmware version and partition info. */ @@ -214,7 +220,7 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle) { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = OTA_PORT; - config.max_uri_handlers = 12; /* Extra slots for WASM endpoints (ADR-040). */ + config.max_uri_handlers = 14; /* WASM endpoints (ADR-040) + /config. */ /* Increase receive timeout for large uploads. */ config.recv_wait_timeout = 30; @@ -243,6 +249,12 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle) }; httpd_register_uri_handler(server, &upload_uri); + /* Remote configuration shares this server so it inherits the OTA PSK, + * the port, and the same fail-closed auth path (config_api.c). */ + if (config_api_register(server) != ESP_OK) { + ESP_LOGW(TAG, "remote config endpoints unavailable"); + } + ESP_LOGI(TAG, "OTA HTTP server started on port %d", OTA_PORT); ESP_LOGI(TAG, " GET /ota/status — firmware version info"); ESP_LOGI(TAG, " POST /ota — upload new firmware binary"); diff --git a/firmware/esp32-csi-node/main/ota_update.h b/firmware/esp32-csi-node/main/ota_update.h index dd5cbde4a..fa4c75584 100644 --- a/firmware/esp32-csi-node/main/ota_update.h +++ b/firmware/esp32-csi-node/main/ota_update.h @@ -9,7 +9,10 @@ #ifndef OTA_UPDATE_H #define OTA_UPDATE_H +#include + #include "esp_err.h" +#include "esp_http_server.h" /* httpd_req_t, for ota_auth_check() */ /** * Initialize the OTA update HTTP server. @@ -30,4 +33,14 @@ esp_err_t ota_update_init(void); */ esp_err_t ota_update_init_ex(void **out_server); +/** + * Validate the Authorization: Bearer header against the provisioned OTA + * PSK, in constant time. Fails closed when no PSK is provisioned. + * + * Shared with config_api.c so remote configuration is gated by exactly the + * same secret and the same comparison as firmware upload -- a second + * implementation would be a second place for the check to rot. + */ +bool ota_auth_check(httpd_req_t *req); + #endif /* OTA_UPDATE_H */ From 2ab3300fd94b9255fba4bcb52aaefc4b32fde16b Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 10 Sep 2026 14:13:46 -0400 Subject: [PATCH 02/18] fix(firmware): source cJSON from the component manager on ESP-IDF 6 cJSON ships as the bundled `json` component through 5.x and moved out to the component manager in v6, where requiring it fails outright: Failed to resolve component 'json' required by component 'main': unknown name. Requiring it unconditionally kept the 5.4 lane green while breaking the 6.0.2 lane -- one toolchain is not the build matrix. `json` is now required only when IDF_VERSION_MAJOR < 6, and `espressif/cjson` is declared in idf_component.yml under an `idf_version >=6.0` rule so the two never coexist in one build. Verified building on esp32c6/16MB with ESP-IDF 5.4. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01PVWMiHQifoYXL7uL3bphrZ --- firmware/esp32-csi-node/main/CMakeLists.txt | 10 +++++++++- firmware/esp32-csi-node/main/idf_component.yml | 11 +++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/firmware/esp32-csi-node/main/CMakeLists.txt b/firmware/esp32-csi-node/main/CMakeLists.txt index 38c4c4074..2a2b21cb6 100644 --- a/firmware/esp32-csi-node/main/CMakeLists.txt +++ b/firmware/esp32-csi-node/main/CMakeLists.txt @@ -23,7 +23,6 @@ set(SRCS # ESP-IDF v6+: headers must resolve via explicit REQUIRES (no implicit deps). set(REQUIRES - json esp_wifi esp_netif esp_event @@ -59,6 +58,15 @@ if(IDF_VERSION_MAJOR GREATER_EQUAL 6) list(APPEND REQUIRES esp_driver_tsens) endif() +# cJSON (config_api.c) ships as the bundled `json` component up to ESP-IDF 5.x +# and was moved OUT to the component manager in v6, where requiring `json` +# fails outright: "Failed to resolve component 'json' required by component +# 'main': unknown name." Declaring it unconditionally broke the v6.0.2 lane +# while the 5.4 lane stayed green -- one toolchain is not the build matrix. +if(IDF_VERSION_MAJOR LESS 6) + list(APPEND REQUIRES json) +endif() + # ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding if(CONFIG_CSI_MOCK_ENABLED) list(APPEND SRCS "mock_csi.c" "rv_radio_ops_mock.c") diff --git a/firmware/esp32-csi-node/main/idf_component.yml b/firmware/esp32-csi-node/main/idf_component.yml index 4ec1d552a..af4a7bcc4 100644 --- a/firmware/esp32-csi-node/main/idf_component.yml +++ b/firmware/esp32-csi-node/main/idf_component.yml @@ -11,3 +11,14 @@ dependencies: ## Onboard WS2812 LED Disabling espressif/led_strip: "^3.0.0" + + ## cJSON for config_api.c. + ## Bundled as the `json` component up to ESP-IDF 5.x and moved out to the + ## component manager in v6, so it is pulled ONLY on the v6+ lane -- on 5.4 + ## `main/CMakeLists.txt` requires the built-in `json` instead. Taking it from + ## the registry on 5.x as well would mean two cJSON implementations in one + ## build. + espressif/cjson: + version: "^1.7.19" + rules: + - if: "idf_version >=6.0" From ddbe5645e9d1388050fdcab3e21aae4c3698fbf2 Mon Sep 17 00:00:00 2001 From: VG31OP Date: Tue, 13 Oct 2026 17:57:38 +0530 Subject: [PATCH 03/18] fix(firmware): sanitize Windows reserved device names in state paths On Windows, files whose basenames match DOS reserved device names (CON, PRN, AUX, NUL, COM1-9, LPT1-9) cannot be created, causing provision.py to fail with FileNotFoundError whenever Windows COM ports (e.g. COM7) were used. This also caused 16 test failures in the esp32-csi-node test suite on Windows. - Prefix reserved device names with '_' in _state_path_for. - Add legacy unprefixed path fallback in load_state and load_board_state for backward compatibility. - Add regression tests in test_provision_state.py for reserved device prefixing. - Add harness/homecore/** text=auto eol=lf in .gitattributes to maintain byte-for-byte provenance and prevent manifest.sha256 mismatch on Windows checkouts. Validation: All 73 esp32-csi-node tests pass on Windows, and all 120 harness/ruview security tests, package manifest, and brain verification pass. --- .gitattributes | 1 + firmware/esp32-csi-node/provision.py | 20 ++++++++++++++++++- .../tests/test_provision_state.py | 11 +++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.gitattributes b/.gitattributes index 290642ce1..8a73904ea 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ # The contributor harness hashes provenance inputs byte-for-byte. Keep text # files in this boundary on LF even when Windows enables core.autocrlf. harness/ruview/** text=auto eol=lf +harness/homecore/** text=auto eol=lf diff --git a/firmware/esp32-csi-node/provision.py b/firmware/esp32-csi-node/provision.py index 46a7aab31..a57fa3b14 100644 --- a/firmware/esp32-csi-node/provision.py +++ b/firmware/esp32-csi-node/provision.py @@ -136,9 +136,16 @@ def _default_state_dir() -> str: return os.path.join(base, "wifi-densepose", "esp32-provision-state") +_RESERVED_DEVICE_RE = re.compile( + r"^(con|prn|aux|nul|com[1-9]|lpt[1-9])$", re.IGNORECASE +) + + def _state_path_for(port: str, state_dir: str) -> str: """File path for a given serial port. Sanitize the port for filesystem use.""" safe = port.replace("/", "_").replace(":", "_").replace("\\", "_") + if _RESERVED_DEVICE_RE.match(safe): + safe = f"_{safe}" return os.path.join(state_dir, f"{safe}.json") @@ -306,7 +313,13 @@ def load_state(port: str, state_dir: str) -> dict: """Return the merged-state dict for `port`, or `{}` if absent / unreadable.""" path = _state_path_for(port, state_dir) if not os.path.isfile(path): - return {} + legacy = os.path.join( + state_dir, f"{port.replace('/', '_').replace(':', '_').replace('\\', '_')}.json" + ) + if legacy != path and os.path.isfile(legacy): + path = legacy + else: + return {} try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) @@ -443,6 +456,11 @@ def load_board_state(port: str, mac, state_dir: str): legacy = _state_path_for(port, state_dir) if os.path.isfile(legacy): return load_state(port, state_dir), legacy + unprefixed = os.path.join( + state_dir, f"{port.replace('/', '_').replace(':', '_').replace('\\', '_')}.json" + ) + if unprefixed != legacy and os.path.isfile(unprefixed): + return load_state(port, state_dir), unprefixed return {}, None diff --git a/firmware/esp32-csi-node/tests/test_provision_state.py b/firmware/esp32-csi-node/tests/test_provision_state.py index e55270e99..33af5ecc1 100644 --- a/firmware/esp32-csi-node/tests/test_provision_state.py +++ b/firmware/esp32-csi-node/tests/test_provision_state.py @@ -122,7 +122,16 @@ class TestStatePathSanitization(unittest.TestCase): def test_windows_com_port_is_safe(self): path = provision._state_path_for("COM7", "/tmp/x") - self.assertTrue(path.endswith("COM7.json")) + self.assertTrue(path.endswith("_COM7.json")) + + def test_reserved_dos_device_names_are_prefixed(self): + for name in ["CON", "PRN", "AUX", "NUL", "COM1", "COM9", "LPT1"]: + path = provision._state_path_for(name, "/tmp/x") + self.assertEqual(os.path.basename(path), f"_{name}.json") + + def test_non_reserved_ports_remain_unprefixed(self): + path = provision._state_path_for("/dev/ttyUSB0", "/tmp/x") + self.assertEqual(os.path.basename(path), "_dev_ttyUSB0.json") if __name__ == "__main__": From 9d7db3290a6a39df567534999ca1c78cf5a7d55b Mon Sep 17 00:00:00 2001 From: VG31OP Date: Tue, 13 Oct 2026 18:25:25 +0530 Subject: [PATCH 04/18] fix(cli): execute jest javascript entrypoint directly and add config unit tests --- tools/ruview-cli/package.json | 2 +- tools/ruview-cli/tests/config.test.ts | 44 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tools/ruview-cli/tests/config.test.ts diff --git a/tools/ruview-cli/package.json b/tools/ruview-cli/package.json index 1b88385e3..f97135f2c 100644 --- a/tools/ruview-cli/package.json +++ b/tools/ruview-cli/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsc", "dev": "tsc --watch", - "test": "node --experimental-vm-modules node_modules/.bin/jest --passWithNoTests", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "lint": "eslint src --ext .ts", "typecheck": "tsc --noEmit" }, diff --git a/tools/ruview-cli/tests/config.test.ts b/tools/ruview-cli/tests/config.test.ts new file mode 100644 index 000000000..e4188ae03 --- /dev/null +++ b/tools/ruview-cli/tests/config.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, beforeEach, afterAll } from "@jest/globals"; +import { loadConfig } from "../src/config.js"; + +describe("loadConfig()", () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it("loads default configuration when environment variables are unset", () => { + delete process.env["RUVIEW_SENSING_SERVER_URL"]; + delete process.env["RUVIEW_API_TOKEN"]; + delete process.env["RUVIEW_POSE_COG_BINARY"]; + delete process.env["RUVIEW_COUNT_COG_BINARY"]; + delete process.env["RUVIEW_JOBS_DIR"]; + + const config = loadConfig(); + expect(config.sensingServerUrl).toBe("http://localhost:3000"); + expect(config.apiToken).toBeUndefined(); + expect(config.poseCogBinary).toBe("cog-pose-estimation"); + expect(config.countCogBinary).toBe("cog-person-count"); + expect(config.jobsDir).toContain(".ruview"); + }); + + it("overrides default values with environment variables", () => { + process.env["RUVIEW_SENSING_SERVER_URL"] = "http://sensing.local:8080"; + process.env["RUVIEW_API_TOKEN"] = "secret-token-123"; + process.env["RUVIEW_POSE_COG_BINARY"] = "/opt/bin/pose"; + process.env["RUVIEW_COUNT_COG_BINARY"] = "/opt/bin/count"; + process.env["RUVIEW_JOBS_DIR"] = "/tmp/ruview-jobs"; + + const config = loadConfig(); + expect(config.sensingServerUrl).toBe("http://sensing.local:8080"); + expect(config.apiToken).toBe("secret-token-123"); + expect(config.poseCogBinary).toBe("/opt/bin/pose"); + expect(config.countCogBinary).toBe("/opt/bin/count"); + expect(config.jobsDir).toBe("/tmp/ruview-jobs"); + }); +}); From 39e2e6ab40d7b438e502402c7822766f6eb7a699 Mon Sep 17 00:00:00 2001 From: VG31OP Date: Tue, 13 Oct 2026 19:21:09 +0530 Subject: [PATCH 05/18] test(scripts): add unit test suite and configurable manifest/root support for fix-marker regression checker --- .github/workflows/fix-regression-guard.yml | 3 + scripts/check_fix_markers.py | 43 +++-- scripts/tests/test_check_fix_markers.py | 199 +++++++++++++++++++++ 3 files changed, 229 insertions(+), 16 deletions(-) create mode 100644 scripts/tests/test_check_fix_markers.py diff --git a/.github/workflows/fix-regression-guard.yml b/.github/workflows/fix-regression-guard.yml index 742d911dc..8c815a285 100644 --- a/.github/workflows/fix-regression-guard.yml +++ b/.github/workflows/fix-regression-guard.yml @@ -32,6 +32,9 @@ jobs: - name: Validate the manifest is well-formed JSON run: python -c "import json; json.load(open('scripts/fix-markers.json')); print('manifest OK')" + - name: Run unit tests for fix-marker checker + run: python -m unittest scripts/tests/test_check_fix_markers.py + - name: Check fix markers run: python scripts/check_fix_markers.py diff --git a/scripts/check_fix_markers.py b/scripts/check_fix_markers.py index ab21bad96..6e3893ac7 100644 --- a/scripts/check_fix_markers.py +++ b/scripts/check_fix_markers.py @@ -21,6 +21,7 @@ Usage:: python scripts/check_fix_markers.py --list # list all markers python scripts/check_fix_markers.py --json # machine-readable result python scripts/check_fix_markers.py --only RuView#396 RuView#521 + python scripts/check_fix_markers.py --manifest path/to/markers.json Exit codes: 0 = all markers OK, 1 = one or more regressions, 2 = bad manifest. """ @@ -64,11 +65,12 @@ class ManifestError(Exception): pass -def load_manifest() -> dict: - if not MANIFEST_PATH.exists(): - raise ManifestError(f"manifest not found: {MANIFEST_PATH}") +def load_manifest(path: Path | None = None) -> dict: + target_path = path or MANIFEST_PATH + if not target_path.exists(): + raise ManifestError(f"manifest not found: {target_path}") try: - data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + data = json.loads(target_path.read_text(encoding="utf-8")) except json.JSONDecodeError as e: raise ManifestError(f"manifest is not valid JSON: {e}") from e if not isinstance(data, dict) or not isinstance(data.get("markers"), list): @@ -82,12 +84,16 @@ def load_manifest() -> dict: def _pattern_found(text: str, pattern: str) -> bool: if len(pattern) >= 2 and pattern.startswith("/") and pattern.endswith("/"): - return re.search(pattern[1:-1], text, re.MULTILINE) is not None + try: + return re.search(pattern[1:-1], text, re.MULTILINE) is not None + except re.error as e: + raise ManifestError(f"invalid regex in pattern {pattern!r}: {e}") from e return pattern in text -def check_marker(marker: dict) -> tuple[bool, list[str]]: +def check_marker(marker: dict, root: Path | None = None) -> tuple[bool, list[str]]: """Return (ok, problems) for a single marker.""" + repo_root = root or REPO_ROOT problems: list[str] = [] files = marker.get("files", []) require = marker.get("require", []) @@ -99,7 +105,7 @@ def check_marker(marker: dict) -> tuple[bool, list[str]]: contents: dict[str, str] = {} for rel in files: - p = REPO_ROOT / rel + p = repo_root / rel if not p.exists(): problems.append(f"missing file: {rel}") continue @@ -109,13 +115,16 @@ def check_marker(marker: dict) -> tuple[bool, list[str]]: problems.append(f"cannot read {rel}: {e}") haystack = "\n".join(contents.values()) - for pat in require: - if not _pattern_found(haystack, pat): - problems.append(f"required marker absent (fix likely reverted): {pat!r}") - for pat in forbid: - for rel, text in contents.items(): - if _pattern_found(text, pat): - problems.append(f"forbidden pattern re-appeared in {rel} (bug re-introduced?): {pat!r}") + try: + for pat in require: + if not _pattern_found(haystack, pat): + problems.append(f"required marker absent (fix likely reverted): {pat!r}") + for pat in forbid: + for rel, text in contents.items(): + if _pattern_found(text, pat): + problems.append(f"forbidden pattern re-appeared in {rel} (bug re-introduced?): {pat!r}") + except ManifestError as e: + problems.append(str(e)) return (len(problems) == 0), problems @@ -136,10 +145,12 @@ def main(argv: list[str]) -> int: ap.add_argument("--list", action="store_true", help="list all markers and exit") ap.add_argument("--json", action="store_true", help="emit a JSON result object") ap.add_argument("--only", nargs="+", metavar="ID", help="only check the given marker ids") + ap.add_argument("--manifest", type=Path, default=MANIFEST_PATH, help="path to fix-markers manifest (default: scripts/fix-markers.json)") + ap.add_argument("--root", type=Path, default=REPO_ROOT, help="path to repository root (default: repo root)") args = ap.parse_args(argv) try: - manifest = load_manifest() + manifest = load_manifest(args.manifest) except ManifestError as e: print(RED(f"[manifest error] {e}"), file=sys.stderr) return 2 @@ -159,7 +170,7 @@ def main(argv: list[str]) -> int: results = [] failed = 0 for m in markers: - ok, problems = check_marker(m) + ok, problems = check_marker(m, root=args.root) results.append({"id": m["id"], "title": m.get("title", ""), "ok": ok, "problems": problems}) if not ok: failed += 1 diff --git a/scripts/tests/test_check_fix_markers.py b/scripts/tests/test_check_fix_markers.py new file mode 100644 index 000000000..ede449a28 --- /dev/null +++ b/scripts/tests/test_check_fix_markers.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Unit tests for scripts/check_fix_markers.py.""" + +from __future__ import annotations + +import io +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from scripts.check_fix_markers import ( + ManifestError, + _pattern_found, + check_marker, + load_manifest, + main, +) + + +class TestCheckFixMarkers(unittest.TestCase): + def setUp(self) -> None: + self.tmp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.tmp_dir.name) + + def tearDown(self) -> None: + self.tmp_dir.cleanup() + + def test_load_manifest_valid(self) -> None: + manifest_path = self.root / "fix-markers.json" + data = { + "schema_version": 1, + "markers": [ + {"id": "Test#1", "files": ["foo.txt"], "require": ["bar"]}, + {"id": "Test#2", "files": ["baz.txt"], "require": ["qux"]}, + ], + } + manifest_path.write_text(json.dumps(data), encoding="utf-8") + loaded = load_manifest(manifest_path) + self.assertEqual(len(loaded["markers"]), 2) + self.assertEqual(loaded["markers"][0]["id"], "Test#1") + + def test_load_manifest_not_found(self) -> None: + non_existent = self.root / "does_not_exist.json" + with self.assertRaises(ManifestError) as ctx: + load_manifest(non_existent) + self.assertIn("manifest not found", str(ctx.exception)) + + def test_load_manifest_invalid_json(self) -> None: + manifest_path = self.root / "bad.json" + manifest_path.write_text("not json content", encoding="utf-8") + with self.assertRaises(ManifestError) as ctx: + load_manifest(manifest_path) + self.assertIn("manifest is not valid JSON", str(ctx.exception)) + + def test_load_manifest_missing_markers(self) -> None: + manifest_path = self.root / "no_markers.json" + manifest_path.write_text(json.dumps({"schema_version": 1}), encoding="utf-8") + with self.assertRaises(ManifestError) as ctx: + load_manifest(manifest_path) + self.assertIn("must be an object with a 'markers' array", str(ctx.exception)) + + def test_load_manifest_duplicate_ids(self) -> None: + manifest_path = self.root / "dupes.json" + data = { + "markers": [ + {"id": "Dup#1", "files": ["a.txt"]}, + {"id": "Dup#1", "files": ["b.txt"]}, + ] + } + manifest_path.write_text(json.dumps(data), encoding="utf-8") + with self.assertRaises(ManifestError) as ctx: + load_manifest(manifest_path) + self.assertIn("duplicate marker ids", str(ctx.exception)) + + def test_pattern_found_literal(self) -> None: + self.assertTrue(_pattern_found("hello world", "world")) + self.assertFalse(_pattern_found("hello world", "earth")) + + def test_pattern_found_regex(self) -> None: + self.assertTrue(_pattern_found("rate = 120.0", r"/rate\s*=\s*\d+\.\d+/")) + self.assertFalse(_pattern_found("rate = invalid", r"/rate\s*=\s*\d+\.\d+/")) + + def test_pattern_found_invalid_regex(self) -> None: + with self.assertRaises(ManifestError) as ctx: + _pattern_found("some text", "/(unclosed paren/") + self.assertIn("invalid regex in pattern", str(ctx.exception)) + + def test_check_marker_pass(self) -> None: + target_file = self.root / "test.py" + target_file.write_text("def test_fix():\n return 'safe_value'\n", encoding="utf-8") + marker = { + "id": "Test#Pass", + "files": ["test.py"], + "require": ["safe_value"], + "forbid": ["unsafe_pattern"], + } + ok, problems = check_marker(marker, root=self.root) + self.assertTrue(ok) + self.assertEqual(problems, []) + + def test_check_marker_missing_file(self) -> None: + marker = { + "id": "Test#MissingFile", + "files": ["non_existent.py"], + "require": ["anything"], + } + ok, problems = check_marker(marker, root=self.root) + self.assertFalse(ok) + self.assertTrue(any("missing file: non_existent.py" in p for p in problems)) + + def test_check_marker_empty_files(self) -> None: + marker = {"id": "Test#NoFiles", "files": []} + ok, problems = check_marker(marker, root=self.root) + self.assertFalse(ok) + self.assertIn("marker lists no files", problems) + + def test_check_marker_missing_require(self) -> None: + target_file = self.root / "test.py" + target_file.write_text("def test_fix():\n pass\n", encoding="utf-8") + marker = { + "id": "Test#MissingReq", + "files": ["test.py"], + "require": ["required_constant"], + } + ok, problems = check_marker(marker, root=self.root) + self.assertFalse(ok) + self.assertTrue(any("required marker absent" in p for p in problems)) + + def test_check_marker_forbid_violation(self) -> None: + target_file = self.root / "test.py" + target_file.write_text("legacy_bug_reintroduced = True\n", encoding="utf-8") + marker = { + "id": "Test#ForbidViolated", + "files": ["test.py"], + "require": [], + "forbid": ["legacy_bug_reintroduced"], + } + ok, problems = check_marker(marker, root=self.root) + self.assertFalse(ok) + self.assertTrue(any("forbidden pattern re-appeared" in p for p in problems)) + + def test_main_cli_options(self) -> None: + sample_file = self.root / "code.txt" + sample_file.write_text("shipped_fix_verified", encoding="utf-8") + manifest_path = self.root / "manifest.json" + manifest_data = { + "schema_version": 1, + "markers": [ + { + "id": "M1", + "title": "Marker One", + "files": ["code.txt"], + "require": ["shipped_fix_verified"], + }, + { + "id": "M2", + "title": "Marker Two", + "files": ["code.txt"], + "require": ["missing_pattern"], + }, + ], + } + manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8") + + # --list + with patch("sys.stdout", new=io.StringIO()) as out: + code = main(["--manifest", str(manifest_path), "--list"]) + self.assertEqual(code, 0) + self.assertIn("2 fix markers tracked", out.getvalue()) + + # --only M1 (should pass) + with patch("sys.stdout", new=io.StringIO()): + code = main(["--manifest", str(manifest_path), "--root", str(self.root), "--only", "M1"]) + self.assertEqual(code, 0) + + # --only M2 (should fail because missing_pattern is absent) + with patch("sys.stdout", new=io.StringIO()): + code = main(["--manifest", str(manifest_path), "--root", str(self.root), "--only", "M2"]) + self.assertEqual(code, 1) + + # --only unknown + with patch("sys.stderr", new=io.StringIO()): + code = main(["--manifest", str(manifest_path), "--only", "UnknownID"]) + self.assertEqual(code, 2) + + # --json flag with M1 + with patch("sys.stdout", new=io.StringIO()) as out: + code = main(["--manifest", str(manifest_path), "--root", str(self.root), "--only", "M1", "--json"]) + self.assertEqual(code, 0) + payload = json.loads(out.getvalue()) + self.assertTrue(payload["ok"]) + self.assertEqual(payload["checked"], 1) + self.assertEqual(payload["failed"], 0) + + +if __name__ == "__main__": + unittest.main() From 285aea8f2de16f95564a08cb9982d3609162a967 Mon Sep 17 00:00:00 2001 From: rUv Date: Wed, 14 Oct 2026 04:13:50 -0700 Subject: [PATCH 06/18] feat(sensing): add W1700K advisory RF diagnostics Source tree verified against ruOS tested commit fa6b1bdf92e7959b8500d42e2a5c620e207e81b0. Synthetic pipeline validation only; hardware validation pending. --- .github/workflows/rf-diagnostics.yml | 44 ++++++ README.md | 3 + docs/adr/ADR-419-w1700k-rf-diagnostics.md | 40 +++++ docs/rf-diagnostics/README.md | 45 ++++++ docs/rf-diagnostics/app.js | 48 ++++++ docs/rf-diagnostics/browser-smoke.cjs | 29 ++++ docs/rf-diagnostics/index.html | 12 ++ docs/rf-diagnostics/model.js | 46 ++++++ docs/rf-diagnostics/model.test.cjs | 23 +++ docs/rf-diagnostics/style.css | 1 + tools/w1700k/.gitignore | 3 + tools/w1700k/Makefile | 15 ++ tools/w1700k/README.md | 117 ++++++++++++++ tools/w1700k/THIRD_PARTY.md | 10 ++ tools/w1700k/diagnostics.c | 176 ++++++++++++++++++++++ tools/w1700k/fixture.c | 43 ++++++ tools/w1700k/test_bridge.py | 122 +++++++++++++++ tools/w1700k/vendor/LICENSE.MIT | 30 ++++ tools/w1700k/vendor/w1700k_stream.h | 176 ++++++++++++++++++++++ tools/w1700k/verify_integration.py | 51 +++++++ 20 files changed, 1034 insertions(+) create mode 100644 .github/workflows/rf-diagnostics.yml create mode 100644 docs/adr/ADR-419-w1700k-rf-diagnostics.md create mode 100644 docs/rf-diagnostics/README.md create mode 100644 docs/rf-diagnostics/app.js create mode 100644 docs/rf-diagnostics/browser-smoke.cjs create mode 100644 docs/rf-diagnostics/index.html create mode 100644 docs/rf-diagnostics/model.js create mode 100644 docs/rf-diagnostics/model.test.cjs create mode 100644 docs/rf-diagnostics/style.css create mode 100644 tools/w1700k/.gitignore create mode 100644 tools/w1700k/Makefile create mode 100644 tools/w1700k/README.md create mode 100644 tools/w1700k/THIRD_PARTY.md create mode 100644 tools/w1700k/diagnostics.c create mode 100644 tools/w1700k/fixture.c create mode 100644 tools/w1700k/test_bridge.py create mode 100644 tools/w1700k/vendor/LICENSE.MIT create mode 100644 tools/w1700k/vendor/w1700k_stream.h create mode 100644 tools/w1700k/verify_integration.py diff --git a/.github/workflows/rf-diagnostics.yml b/.github/workflows/rf-diagnostics.yml new file mode 100644 index 000000000..59244a735 --- /dev/null +++ b/.github/workflows/rf-diagnostics.yml @@ -0,0 +1,44 @@ +name: W1700K RF diagnostics +on: + pull_request: + paths: ['tools/w1700k/**', 'docs/rf-diagnostics/**', '.github/workflows/rf-diagnostics.yml'] + push: + branches: [main] + paths: ['tools/w1700k/**', 'docs/rf-diagnostics/**', '.github/workflows/rf-diagnostics.yml'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: rf-diagnostics-${{ github.ref }} + cancel-in-progress: true +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Bridge ABI and failure tests + run: make -C tools/w1700k check + - name: Viewer validation tests + run: | + node --test docs/rf-diagnostics/model.test.cjs + node --check docs/rf-diagnostics/app.js + deploy: + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' + needs: verify + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Stage static viewer only + run: | + mkdir -p _site + cp docs/rf-diagnostics/index.html docs/rf-diagnostics/model.js docs/rf-diagnostics/app.js docs/rf-diagnostics/style.css _site/ + - name: Preserve existing Pages content + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./_site + destination_dir: rf-diagnostics + keep_files: true + commit_message: 'deploy(rf-diagnostics): ${{ github.sha }}' diff --git a/README.md b/README.md index 98c12d745..2b10abbba 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # π RuView +> **W1700K RF diagnostics:** [receiver bridge](tools/w1700k/README.md) · [browser viewer](https://ruvnet.github.io/RuView/rf-diagnostics/) · [integration design](docs/adr/ADR-419-w1700k-rf-diagnostics.md). +> Reads IQ locally and exports bounded quality summaries to RuField. Synthetic contract tests are separate from hardware validation. RF diagnostics only; no CSI extraction or presence detection. +

RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi diff --git a/docs/adr/ADR-419-w1700k-rf-diagnostics.md b/docs/adr/ADR-419-w1700k-rf-diagnostics.md new file mode 100644 index 000000000..2c6d45269 --- /dev/null +++ b/docs/adr/ADR-419-w1700k-rf-diagnostics.md @@ -0,0 +1,40 @@ +# ADR-419: W1700K diagnostics and RuField evidence boundary + +Status: implementation candidate; hardware validation pending. +Date: 2026-10-14 + +## Decision + +Use an original C consumer of the upstream MIT shared memory ABI, compact JSONL diagnostics, a local browser viewer, and a bounded RuField adapter. Spectrum quality is a separate modality and cannot contribute occupancy evidence. + +Upstream: h0m3us3r/w1700k-sdr commit 0b0413e4309f0c083702fa4eb2d1c2bca4532163, host/stream/include/w1700k_stream.h. Do not copy the GPL recorder. + +## Flow and invariants + +1. The upstream daemon publishes native-endian IQ in ABI v1 shared memory. +2. The consumer validates geometry, reads bounded slots, checks tags around copies, and calculates RMS and int16 rail fractions. +3. JSONL retains source mode, sample rate, completion index, loss and quality reasons. Raw IQ stays on the host. +4. The browser imports records locally. Importing live-labelled data is still file replay. +5. RuField preserves source labels, produces untrusted diagnostics and excludes them from occupancy fusion. + +Acceptance: synthetic ABI fixture through the real bridge, JSON validator, viewer and Rust adapter. Malformed geometry, truncation and invalid numbers fail closed. Unknown fields cannot smuggle IQ into downstream evidence. + +## Configuration and timing + +The header does not establish LO, gain, band or an atomic configuration generation. Caller frequency is advisory. All v1 records have config_verified=false and valid=false. Metrics remain useful for inspection; they cannot promote sensing claims. + +Monotonic nanoseconds are host-local, not UTC or cross-device synchronization. Event slots consume no sample time. Unknown sample position remains null. + +IQ scale is 8192. Flattening may legitimately exceed that amplitude; rail fraction counts int16 rails, not calibrated ADC clipping. + +## Alternatives and limits + +A second UDP receiver duplicates transport handling. Direct IQ into a CSI model violates its input contract. Upstream ci16 sidecars truncate gap indices and omit event positions, preventing exact arbitrary replay. Use shared memory and exact synthetic fixtures. + +No CSI extraction, passive radar, pose, hardware accuracy lift or sustained 320 Msps benchmark is claimed. No router flashing or transmission. + +## Security and release + +Bound file sizes, offsets, record lengths and numeric ranges. Reject unknown diagnostic fields. Local file import performs no upload. Trust enrollment is separate from parsing; synthetic evidence stays labelled. + +Publish tested source after independent review. Deploy only the viewer directory, preserving other Pages content. Roll back by reverting this change and its deployment. diff --git a/docs/rf-diagnostics/README.md b/docs/rf-diagnostics/README.md new file mode 100644 index 000000000..d3db42204 --- /dev/null +++ b/docs/rf-diagnostics/README.md @@ -0,0 +1,45 @@ +# RuView RF diagnostics viewer + +Open [index.html](index.html) in a modern browser, or use the deployed documentation page. This is a standalone local JSONL viewer, not a hardware connection. It has no runtime dependencies, telemetry, external assets, uploads or network API. + +1. Run the producer described in [tools/w1700k](../../tools/w1700k/) and save its diagnostic JSONL output. +2. Select **Import local JSONL**. Files must be at most 10 MiB, with 1 to 10,000 records. +3. Inspect relative RMS, numeric rail fraction, reported missing samples, reason counts and the last 500 observations. +4. Select **Clear** to remove the loaded observations, or **Load synthetic demo** for a deterministic synthetic example. + +Raw IQ and unknown fields are rejected. The viewer accepts the frozen `ruview.rf_diagnostics.v1` schema and requires `valid:false`, `config_verified:false` and the `configuration_unverified` reason. Integers must fit JavaScript's safe integer range; larger producer counters are rejected rather than silently rounded. + +## Evidence boundary + +| Producer source_kind | Display | +| --- | --- | +| synthetic | SYNTHETIC | +| captured_replay | CAPTURED REPLAY | +| live | LIVE UNVERIFIED | + +Every imported file is replay. Source labels are claims from the input and do not prove that a device was used. Mixed input displays all applicable labels. Nothing here establishes human presence, motion, pose, CSI or calibrated RF power. + +RMS is relative dBFS; numeric rails are an advisory clipping indicator. Missing sample totals sum the producer's per record values. Record order is preserved rather than interpreted as synchronized time. Host monotonic timestamps cannot establish wall clock freshness. The producer TTL is displayed but is never converted into a valid or fresh verdict for file replay. + +All text is rendered with textContent. A restrictive content security policy blocks network connections. Invalid imports clear the previous dataset to avoid showing stale values as the new result. + +## Verification + +From the repository root: + +```sh +node --test docs/rf-diagnostics/model.test.cjs +node --check docs/rf-diagnostics/app.js +``` + +Optional browser smoke with an existing Playwright installation and Chromium: + +```sh +PLAYWRIGHT_MODULE=/absolute/path/to/playwright node docs/rf-diagnostics/browser-smoke.cjs +``` + +The browser test checks mobile layout, synthetic rendering, actual local file import, unverified live labels, malformed input rejection, clear and zero external requests. It does not establish physical hardware performance. No package is installed by these commands. + +### Numeric contract + +Sample rates are unknown (0), 40, 80, 160 or 320 Msps. Centre frequency is unknown or 2 to 8 GHz. Each record describes zero or 2048 samples; missing sample counts are multiples of 2048. Absolute sample start remains null. RMS is bounded to −200 through 16 dBFS to accommodate numeric filter headroom, and an available RMS requires an available rail fraction. Reason names are from the producer whitelist and cannot repeat. diff --git a/docs/rf-diagnostics/app.js b/docs/rf-diagnostics/app.js new file mode 100644 index 000000000..c546118ee --- /dev/null +++ b/docs/rf-diagnostics/app.js @@ -0,0 +1,48 @@ +'use strict'; +const M=RFDiagnostics,$=id=>document.getElementById(id),ns='http://www.w3.org/2000/svg'; +let generation=0; +function render(rows){ + const last=rows.at(-1),kinds=[...new Set(rows.map(r=>M.modes[r.source_kind]))]; + $('mode').textContent=kinds.join(' + ')||'NO DATA'; + $('count').textContent=rows.length.toLocaleString(); + $('rms').textContent=last?.rms_dbfs==null?'—':last.rms_dbfs.toFixed(2); + $('rails').textContent=last?.rail_fraction==null?'—':(last.rail_fraction*100).toFixed(3)+'%'; + $('gaps').textContent=rows.length?rows.reduce((n,r)=>n+BigInt(r.missing_samples),0n).toLocaleString():'—'; + const reasons=new Map(); + for(const r of rows)for(const reason of new Set(r.invalid_reasons))reasons.set(reason,(reasons.get(reason)||0)+1); + $('reasons').replaceChildren(); + for(const [reason,count] of reasons){const li=document.createElement('li');li.textContent=reason+' · '+count.toLocaleString();$('reasons').append(li);} + $('meta').textContent=last?[ + 'Source: '+last.source_id,'Origin: '+last.source_kind+' (file/demo replay)', + 'Rate: '+(last.sample_rate_hz||'unknown')+' samples/s', + 'Centre: '+(last.center_frequency_hz??'unknown')+' Hz (unverified)', + 'Run / slot: '+last.run+' / '+last.slot_index, + 'Host monotonic ns: '+last.observed_monotonic_ns, + 'Absolute sample start: '+(last.sample_start??'unknown'), + 'Configuration verified: false','Valid for inference: false', + 'TTL: '+last.ttl_ms+' ms; replay freshness unknown' + ].join('\n'):'No observations'; + const tail=rows.slice(-500),values=tail.map(r=>r.rms_dbfs).filter(v=>v!==null); + const low=Math.min(...values),high=Math.max(...values),span=high-low||1; + $('chart').replaceChildren(); + let path=''; + tail.forEach((r,i)=>{ + if(r.rms_dbfs===null){path+=' ';return;} + const x=20+i*960/Math.max(1,tail.length-1),y=220-200*(r.rms_dbfs-low)/span; + path+=(i===0||tail[i-1].rms_dbfs===null?'M':'L')+x+','+y+' '; + if(r.missing_samples){const dot=document.createElementNS(ns,'circle');dot.setAttribute('cx',x);dot.setAttribute('cy',y);dot.setAttribute('r',4);dot.setAttribute('fill','#ffb66c');$('chart').append(dot);} + }); + const line=document.createElementNS(ns,'path');line.setAttribute('d',path);line.setAttribute('fill','none');line.setAttribute('stroke','#71e7db');line.setAttribute('stroke-width',2);$('chart').prepend(line); + $('range').textContent=values.length?'Range '+low.toFixed(2)+' to '+high.toFixed(2)+' dBFS · '+tail.length+' latest records':'No RMS observations'; + $('chart').setAttribute('aria-label',$('range').textContent); +} +$('demo').onclick=()=>{generation++;render(M.demo());$('status').textContent='Synthetic demonstration loaded. No hardware evidence.';}; +$('clear').onclick=()=>{generation++;render([]);$('file').value='';$('status').textContent='Cleared. No observations retained.';}; +$('file').onchange=async()=>{ + const ticket=++generation,file=$('file').files[0];if(!file)return; + try{ + if(file.size>M.MAX_BYTES)throw Error('File exceeds 10 MiB'); + const rows=M.parse(await file.text());if(ticket!==generation)return; + render(rows);$('status').textContent='Imported '+rows.length+' records. Source labels are unverified claims; this is file replay.'; + }catch(e){if(ticket!==generation)return;render([]);$('status').textContent='Import rejected: '+e.message;} +}; diff --git a/docs/rf-diagnostics/browser-smoke.cjs b/docs/rf-diagnostics/browser-smoke.cjs new file mode 100644 index 000000000..84e074804 --- /dev/null +++ b/docs/rf-diagnostics/browser-smoke.cjs @@ -0,0 +1,29 @@ +'use strict'; +const assert=require('node:assert/strict'),path=require('node:path'); +const {chromium}=require(process.env.PLAYWRIGHT_MODULE||'playwright'); +(async()=>{ + const browser=await chromium.launch({headless:true}); + try{ + const page=await browser.newPage({viewport:{width:390,height:844}}); + const errors=[],external=[]; + page.on('pageerror',e=>errors.push(e.message)); + page.on('request',r=>{if(/^https?:/.test(r.url()))external.push(r.url());}); + await page.goto('file://'+path.join(__dirname,'index.html')); + await page.click('#demo'); + assert.equal(await page.textContent('#mode'),'SYNTHETIC'); + assert.equal(await page.textContent('#count'),'96'); + assert(await page.locator('#chart path').getAttribute('d')); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); + const r=require('./model.js').demo()[0];r.source_kind='live'; + await page.setInputFiles('#file',{name:'live.jsonl',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(r))}); + await page.waitForFunction(()=>document.querySelector('#mode').textContent==='LIVE UNVERIFIED'); + assert.match(await page.textContent('#status'),/file replay/); + await page.setInputFiles('#file',{name:'bad.jsonl',mimeType:'application/json',buffer:Buffer.from('{"iq":[1,2]}')}); + await page.waitForFunction(()=>document.querySelector('#status').textContent.startsWith('Import rejected')); + assert.equal(await page.textContent('#count'),'0'); + await page.click('#demo');await page.click('#clear'); + assert.equal(await page.textContent('#count'),'0'); + assert.deepEqual(errors,[]);assert.deepEqual(external,[]); + console.log('PASS mobile render, demo, JSONL import, unverified live label, malformed rejection, clear, zero external requests'); + }finally{await browser.close();} +})().catch(e=>{console.error(e);process.exitCode=1}); diff --git a/docs/rf-diagnostics/index.html b/docs/rf-diagnostics/index.html new file mode 100644 index 000000000..479103c69 --- /dev/null +++ b/docs/rf-diagnostics/index.html @@ -0,0 +1,12 @@ + + + +RuView RF Diagnostics +

COGNITUM / RUVIEW / W1700K

NO DATA

RF diagnostics.

Understand the measurement before trusting the inference.

+

Local processing only. Maximum 10 MiB and 10,000 observations. No uploads.

+

Import bridge diagnostics or explore a clearly labelled synthetic example.

+ +

Latest RMS

—relative dBFS, not calibrated RF power

Latest rails

—samples at numeric limits

Reported missing

—sum of per record missing samples

Observations

0all v1 observations invalid
+

Relative RMS history

Record order, last 500 observations. Missing metrics break the line. Orange dots flag missing samples.

No observations

+

Invalidation reasons

  • No observations

Source metadata

No observations
+
RuField evidence boundary · 1 second TTL is producer metadata. Host monotonic time is not wall time; file replay cannot establish freshness.
diff --git a/docs/rf-diagnostics/model.js b/docs/rf-diagnostics/model.js new file mode 100644 index 000000000..f27e4fa29 --- /dev/null +++ b/docs/rf-diagnostics/model.js @@ -0,0 +1,46 @@ +(function(root){ +'use strict'; +const MAX_BYTES=10*1024*1024, MAX_RECORDS=10000; +const fields='schema source_kind source_id observed_monotonic_ns run slot_index sample_start sample_rate_hz center_frequency_hz config_verified valid invalid_reasons sample_count rms_dbfs rail_fraction missing_samples event_count overrun_slices ttl_ms'.split(' '); +const modes={synthetic:'SYNTHETIC',live:'LIVE UNVERIFIED',captured_replay:'CAPTURED REPLAY'}; +function validate(r){ + const fail=(s)=>{throw Error(s)}; + if(!r||Array.isArray(r)||typeof r!=='object')fail('Expected an object'); + for(const k of Object.keys(r))if(!fields.includes(k))fail('Unsupported field: '+k); + for(const k of fields)if(!Object.hasOwn(r,k))fail('Missing field: '+k); + if(r.schema!=='ruview.rf_diagnostics.v1')fail('Unsupported schema'); + if(!Object.hasOwn(modes,r.source_kind)||r.source_id!=='w1700k')fail('Unsupported source'); + for(const k of ['observed_monotonic_ns','run','slot_index','sample_rate_hz','sample_count','missing_samples','event_count','overrun_slices','ttl_ms']) + if(!Number.isSafeInteger(r[k])||r[k]<0)fail('Invalid '+k); + if(r.ttl_ms!==1000)fail('Invalid rate or TTL'); + for(const k of ['sample_start','center_frequency_hz']) + if(r[k]!==null&&(!Number.isSafeInteger(r[k])||r[k]<0))fail('Invalid '+k); + if(r.rms_dbfs!==null&&(typeof r.rms_dbfs!=='number'||!Number.isFinite(r.rms_dbfs)))fail('Invalid RMS'); + if(r.rail_fraction!==null&&(typeof r.rail_fraction!=='number'||!Number.isFinite(r.rail_fraction)||r.rail_fraction<0||r.rail_fraction>1))fail('Invalid rail fraction'); + if(r.config_verified!==false||r.valid!==false)fail('v1 must remain unverified and invalid'); + if(!Array.isArray(r.invalid_reasons)||r.invalid_reasons.length>32||!r.invalid_reasons.includes('configuration_unverified')||r.invalid_reasons.some(x=>typeof x!=='string'||!/^[a-z_]{1,64}$/.test(x)))fail('Invalid reasons'); + if(![0,40000000,80000000,160000000,320000000].includes(r.sample_rate_hz))fail('Invalid sample rate'); + if(r.sample_start!==null)fail('Absolute sample start is unavailable in v1'); + if(r.center_frequency_hz!==null&&(r.center_frequency_hz<2000000000||r.center_frequency_hz>8000000000))fail('Invalid centre frequency'); + if(![0,2048].includes(r.sample_count)||r.missing_samples%2048!==0)fail('Invalid sample count'); + if(r.rms_dbfs!==null&&(r.rms_dbfs< -200||r.rms_dbfs>16||r.rail_fraction===null))fail('Invalid RMS or missing rail fraction'); + const reasons=['configuration_unverified','lost','event','overrun','stale','tag_mismatch','run_change']; + if(new Set(r.invalid_reasons).size!==r.invalid_reasons.length||r.invalid_reasons.length>7||r.invalid_reasons.some(x=>!reasons.includes(x)))fail('Unsupported invalid reason'); + return r; +} +function parse(text){ + if(typeof text!=='string'||new TextEncoder().encode(text).length>MAX_BYTES)throw Error('File exceeds 10 MiB'); + const lines=text.split(/\r?\n/).filter(x=>x.trim()); + if(!lines.length||lines.length>MAX_RECORDS)throw Error('Expected 1 to 10,000 records'); + return lines.map((s,i)=>{try{return validate(JSON.parse(s))}catch(e){throw Error('Record '+(i+1)+': '+e.message)}}); +} +function demo(){return Array.from({length:96},(_,i)=>({ + schema:'ruview.rf_diagnostics.v1',source_kind:'synthetic',source_id:'w1700k', + observed_monotonic_ns:i*100000000,run:1,slot_index:i,sample_start:null, + sample_rate_hz:40000000,center_frequency_hz:2437000000,config_verified:false,valid:false, + invalid_reasons:i%23===0?['configuration_unverified','lost']:['configuration_unverified'], + sample_count:2048,rms_dbfs:-34+Math.sin(i/8)*6,rail_fraction:i%23===0?0.03:0, + missing_samples:i%23===0?2048:0,event_count:0,overrun_slices:0,ttl_ms:1000}));} +const api={MAX_BYTES,MAX_RECORDS,modes,validate,parse,demo}; +if(typeof module==='object')module.exports=api;else root.RFDiagnostics=api; +})(globalThis); diff --git a/docs/rf-diagnostics/model.test.cjs b/docs/rf-diagnostics/model.test.cjs new file mode 100644 index 000000000..5e7cb82aa --- /dev/null +++ b/docs/rf-diagnostics/model.test.cjs @@ -0,0 +1,23 @@ +'use strict'; +const {test}=require('node:test'),assert=require('node:assert/strict'),M=require('./model.js'); +const good=()=>M.demo()[0]; +test('synthetic fixture and JSONL roundtrip',()=>assert.equal(M.parse(M.demo().map(JSON.stringify).join('\n')).length,96)); +test('captured and live labels remain unverified',()=>{for(const source_kind of ['live','captured_replay'])assert.equal(M.validate({...good(),source_kind}).valid,false);}); +test('reject malformed, empty, oversized and too many records',()=>{ + for(const s of ['', '[]','{',' '.repeat(M.MAX_BYTES+1),Array(10002).fill('{}').join('\n')])assert.throws(()=>M.parse(s)); +}); +test('reject inference promotion, raw IQ, nonfinite metrics and unsafe indices',()=>{ + for(const patch of [{valid:true},{config_verified:true},{iq:[1,2]},{rms_dbfs:Infinity},{rail_fraction:2},{rail_fraction:NaN},{observed_monotonic_ns:2**53},{source_kind:'__proto__'},{ttl_ms:0},{invalid_reasons:['