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 */