mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-21 22:22:33 -05:00
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 <ruv@ruv.net>
This commit is contained in:
1 parent
8d1a67d31b
commit
cd251fafa8
10 files changed
+995
-4
No files matched your search
@@ -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 <node-ip> --get
|
||||
|
||||
# change something and let the node restart to apply it
|
||||
python config_push.py --node <node-ip> --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 <node-ip> --set led_mode=off
|
||||
python config_push.py --node <node-ip> --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 <ota-psk>` 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
|
||||
|
||||
@@ -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 <node-ip> --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 <node-ip> --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 <node-ip> --set led_mode=steady led_brightness=10
|
||||
python config_push.py --node <node-ip> --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())
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* @file config_api.c
|
||||
* @brief Remote configuration endpoint. See config_api.h for the design.
|
||||
*/
|
||||
|
||||
#include "config_api.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#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 <psk>");
|
||||
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 <psk>");
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
#include "ota_update.h"
|
||||
#include "config_api.h"
|
||||
|
||||
#include <string.h>
|
||||
#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");
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
#ifndef OTA_UPDATE_H
|
||||
#define OTA_UPDATE_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#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 <psk> 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 */
|
||||
Reference in new issue
Block a user