From 7d3e0c2d7e7057412d3e29f1ea438b9953d3fc11 Mon Sep 17 00:00:00 2001 From: arsen Date: Sun, 17 May 2026 18:27:06 +0700 Subject: [PATCH] =?UTF-8?q?feat(adr-115):=20POST=20/ota/set-target=20?= =?UTF-8?q?=E2=80=94=20set=20CSI=20target=20IP/port=20via=20WiFi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New REST endpoint on FW HTTP server (port 8032) writes csi_cfg/target_ip + target_port to NVS and reboots. Body is plain text "IPv4:PORT" (e.g. 192.168.0.103:5005). Verified on both 192.168.0.100 and 192.168.0.101 — sensors silent after Mac IP move came back online in ~3 min instead of needing USB. Same PSK auth as /ota/recalibrate (ADR-050). Strict body parser rejects malformed input before touching NVS. Binary size +1 KB. Co-Authored-By: Claude Opus 4.7 --- CHECKLIST.md | 3 + docs/adr/ADR-115-fw-set-target-rest.md | 161 ++++++++++++++++++++++ firmware/esp32-csi-node/main/ota_update.c | 131 ++++++++++++++++++ 3 files changed, 295 insertions(+) create mode 100644 docs/adr/ADR-115-fw-set-target-rest.md diff --git a/CHECKLIST.md b/CHECKLIST.md index 661a4269..9a8cee2f 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -65,6 +65,9 @@ each with explicit reason) listed at the bottom. no USB needed (commit f92807cd) - [x] **ADR-109** Track AP MAC in `gl_ap_mac` NVS — auto-invalidate stale gain-lock on AP swap (commit f92807cd) +- [x] **ADR-115** `POST /ota/set-target` — repoint CSI aggregator + (`csi_cfg/target_ip` + `target_port`) without USB; recovered + both nodes after Mac IP move TP-Link → .103 ### Tests / fixtures diff --git a/docs/adr/ADR-115-fw-set-target-rest.md b/docs/adr/ADR-115-fw-set-target-rest.md new file mode 100644 index 00000000..4e8c883f --- /dev/null +++ b/docs/adr/ADR-115-fw-set-target-rest.md @@ -0,0 +1,161 @@ +# ADR-115 — FW REST endpoint to repoint CSI aggregator without USB + +**Status**: Accepted +**Date**: 2026-05-17 +**Scope**: `firmware/esp32-csi-node/main/ota_update.c` +(`ota_set_target_handler`, `parse_ip_port`, URI registration on port 8032). + +## Context + +After moving the Mac from Tran Thanh T3 (192.168.1.x) to TP-Link_8340 +(192.168.0.x) for low-latency sensor proximity, both ESP32-S3 nodes +held a stale `csi_cfg/target_ip` in NVS — they were silently streaming +CSI into the previous LAN and the new server on `0.0.0.0:5005` saw +zero frames for ~5 minutes despite both nodes being WiFi-reachable +and responding on `:8032/ota/status`. + +Existing tools didn't cover this: + +* `provision.py` writes `target_ip` via USB serial — requires + physical access to the sensor. +* `/ota/recalibrate` (ADR-109) only erases gain-lock keys + (`gl_agc/gl_fft/gl_ap_mac`) — intentionally doesn't touch + network config. +* Rebuilding FW with a new `CONFIG_CSI_TARGET_IP` would only help if + NVS is also wiped, since the NVS override always beats the + compile-time default. + +Recurring operational need: every Mac IP change, every network +move, every router swap requires the operator to crawl behind the +sensor with a USB cable. Not acceptable. + +## Decisions + +### D1 — `POST /ota/set-target` HTTP endpoint + +New handler on the existing OTA HTTP server (port 8032). Body is +plain text `"IPv4:PORT"` with optional trailing CR/LF, e.g. +`192.168.0.103:5005`. No JSON dependency — `cJSON` is not used +elsewhere in this FW. + +``` +POST /ota/set-target HTTP/1.1 +Content-Type: text/plain +Authorization: Bearer # only if ota_psk provisioned + +192.168.0.103:5005 +``` + +Response: + +```json +{"status":"ok","target_ip":"192.168.0.103","target_port":5005,"message":"rebooting"} +``` + +Followed by `vTaskDelay(1s)` + `esp_restart()` so the new value is +picked up by `nvs_config_load` on next boot. + +### D2 — Strict body parser (no `inet_pton` dependency) + +`parse_ip_port` validates: + +* Exactly 4 dot-separated octets, each `0–255`. +* Single `:` separator. +* Port `1–65535`, max 5 digits. +* Trailing whitespace/CR/LF tolerated. + +Rejects malformed input with HTTP 400 *before* touching NVS — a +sensor with an unparseable IP would lose its only network identity. + +### D3 — Same NVS namespace + keys that `nvs_config.c` reads + +```c +nvs_open("csi_cfg", NVS_READWRITE, &h); +nvs_set_str(h, "target_ip", ip); +nvs_set_u16(h, "target_port", port); +nvs_commit(h); +``` + +Matches the keys already read by `nvs_config_load` at boot, so the +change is picked up without any FW code change beyond this handler. + +### D4 — Auth model identical to `/ota/recalibrate` + +Uses the same `ota_check_auth` PSK gate (ADR-050). If +`security/ota_psk` is empty, the endpoint is open (dev mode); when +set, requires `Authorization: Bearer `. Same threat model and +permissive default as `/ota` itself. + +### D5 — No partial-write atomicity gymnastics + +We write `target_ip`, then `target_port`, then commit. If a power +cut happens between `set_str` and `set_u16`, NVS keeps the previous +`target_port` (since uncommitted writes don't persist) — safe +behaviour. No need for a temp-key + rename dance. + +## Files Touched + +``` +firmware/esp32-csi-node/main/ota_update.c + + #include "nvs_config.h" (NVS_CFG_IP_MAX) + + parse_ip_port helper + + ota_set_target_handler + + URI registration in ota_update_start_server + + log line in startup banner +docs/adr/ADR-115-fw-set-target-rest.md (this) +``` + +Binary size delta: `esp32-csi-node.bin` 854 KB → 855 KB (+~1 KB). +58 % of OTA partition free, plenty of margin. + +## Verified Acceptance + +Sequence on both live nodes (192.168.0.100, 192.168.0.101): + +1. `python3 scripts/ota-deploy.sh 192.168.0.100 192.168.0.101` → + `running_partition` flipped on both (`ota_1↔ota_0`). +2. `curl -X POST -d '192.168.0.103:5005' .../ota/set-target` → + `{"status":"ok","target_ip":"192.168.0.103","target_port":5005,...}` + on both nodes. +3. After 25 s reboot+WiFi+CSI startup, sensing-server log: + ``` + keepalive: learned address for node 2 = 192.168.0.100:63940 + keepalive: ping -i 0.040 192.168.0.100 for node 2 + keepalive: learned address for node 1 = 192.168.0.101:63844 + keepalive: ping -i 0.040 192.168.0.101 for node 1 + ``` +4. `GET /api/v1/sensing/latest` → live classification + (`motion_level: active`, presence: true) with non-zero + per-node features (`drift_score: 0.41`, `dominant_freq_hz: 6.3`, + `mean_rssi: -57`). + +End-to-end recovery time from broken stream → live CSI: **~3 min** +(build 0, since FW was already built; flash 17 s; set-target + +reboot ~25 s; first ping-driven CSI batch ~5 s). + +## Open Items + +* **Persist last-known-good target as fallback** — if a bad + `target_ip` is committed (e.g. operator types Mac's old IP) the + sensor goes silent until the next set-target call. A + `csi_cfg/target_ip_lkg` snapshot updated on every successful + keepalive-driven UDP send would let the sensor self-revert after + N silent seconds. ~1 h FW. +* **Track AP MAC alongside target** — ADR-108 / ADR-111 already + invalidate gain-lock on AP change; same pattern could + auto-invalidate target on subnet change (sensor sees its DHCP + lease is on a different /24 than `target_ip` → blank target, + refuse to send until operator confirms). ~1 h FW. +* **REST endpoint to read current target** — `GET /ota/target` + returning `{"target_ip":..., "target_port":...}`. Operator can + diagnose "where is this sensor pointed?" without USB. ~15 min FW. + +## References + +* ADR-050 — OTA PSK auth that gates this endpoint +* ADR-100 — TP-Link WISP deployment that triggered the Mac-IP move +* ADR-108 — FW NVS persistence patterns (same namespace, same approach) +* ADR-109 — `/ota/recalibrate` precedent (same handler shape, same + reboot semantics) +* `scripts/provision.py` — original USB-only NVS provisioning path + that this ADR replaces for the network-config case diff --git a/firmware/esp32-csi-node/main/ota_update.c b/firmware/esp32-csi-node/main/ota_update.c index fb196c67..5ad89498 100644 --- a/firmware/esp32-csi-node/main/ota_update.c +++ b/firmware/esp32-csi-node/main/ota_update.c @@ -17,6 +17,7 @@ #include "esp_app_desc.h" #include "nvs_flash.h" #include "nvs.h" +#include "nvs_config.h" /* NVS_CFG_IP_MAX */ static const char *TAG = "ota_update"; @@ -150,6 +151,126 @@ static esp_err_t ota_recalibrate_handler(httpd_req_t *req) return ESP_OK; /* unreachable */ } +/** + * POST /ota/set-target — write csi_cfg/target_ip + target_port to NVS, reboot. + * + * ADR-115: lets the operator point sensors at a new aggregator (Mac IP + * change, network move) without USB. Body is plain text "IP:PORT" with + * trailing newline tolerated, e.g. "192.168.0.103:5005". IP validated + * by inet_pton-like check (4 dot-separated octets 0–255); port 1–65535. + * + * Persists into the same `csi_cfg` namespace that `nvs_config.c` reads + * at boot — next reboot picks up the new target. + */ +static bool parse_ip_port(const char *s, char *ip_out, size_t ip_cap, uint16_t *port_out) +{ + /* Tolerate trailing whitespace/CR/LF. */ + size_t n = strlen(s); + while (n > 0 && (s[n - 1] == '\n' || s[n - 1] == '\r' || s[n - 1] == ' ' || s[n - 1] == '\t')) { + n--; + } + const char *colon = NULL; + for (size_t i = 0; i < n; i++) { + if (s[i] == ':') { colon = &s[i]; break; } + } + if (!colon) return false; + size_t ip_len = (size_t)(colon - s); + if (ip_len == 0 || ip_len >= ip_cap) return false; + memcpy(ip_out, s, ip_len); + ip_out[ip_len] = '\0'; + /* Validate 4 octets 0–255. */ + int oct_count = 0, val = -1; + for (size_t i = 0; i <= ip_len; i++) { + char c = ip_out[i]; + if (c == '.' || c == '\0') { + if (val < 0 || val > 255) return false; + oct_count++; + val = -1; + } else if (c >= '0' && c <= '9') { + val = (val < 0 ? 0 : val) * 10 + (c - '0'); + } else { + return false; + } + } + if (oct_count != 4) return false; + /* Parse port. */ + long port = 0; + const char *p = colon + 1; + size_t plen = n - ip_len - 1; + if (plen == 0 || plen > 5) return false; + for (size_t i = 0; i < plen; i++) { + if (p[i] < '0' || p[i] > '9') return false; + port = port * 10 + (p[i] - '0'); + } + if (port < 1 || port > 65535) return false; + *port_out = (uint16_t)port; + return true; +} + +static esp_err_t ota_set_target_handler(httpd_req_t *req) +{ + if (!ota_check_auth(req)) { + ESP_LOGW(TAG, "/ota/set-target rejected: authentication failed"); + httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, + "Authentication required. Use: Authorization: Bearer "); + return ESP_FAIL; + } + + /* Body is short: "IPv4:port" + optional CRLF. 32 bytes is plenty. */ + char body[40] = {0}; + int total = 0; + while (total < (int)sizeof(body) - 1) { + int r = httpd_req_recv(req, body + total, sizeof(body) - 1 - total); + if (r <= 0) { + if (r == HTTPD_SOCK_ERR_TIMEOUT) continue; + break; + } + total += r; + } + body[total < 0 ? 0 : total] = '\0'; + + char ip[NVS_CFG_IP_MAX] = {0}; + uint16_t port = 0; + if (!parse_ip_port(body, ip, sizeof(ip), &port)) { + ESP_LOGW(TAG, "/ota/set-target rejected: invalid body '%s'", body); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, + "Body must be 'IPv4:PORT', e.g. '192.168.0.103:5005'"); + return ESP_FAIL; + } + + nvs_handle_t h; + esp_err_t err = nvs_open("csi_cfg", NVS_READWRITE, &h); + if (err != ESP_OK) { + ESP_LOGE(TAG, "/ota/set-target: nvs_open(csi_cfg) failed: %s", + esp_err_to_name(err)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "NVS open failed"); + return ESP_FAIL; + } + err = nvs_set_str(h, "target_ip", ip); + if (err == ESP_OK) err = nvs_set_u16(h, "target_port", port); + if (err == ESP_OK) err = nvs_commit(h); + nvs_close(h); + if (err != ESP_OK) { + ESP_LOGE(TAG, "/ota/set-target: NVS write failed: %s", esp_err_to_name(err)); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "NVS write failed"); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "/ota/set-target: csi_cfg/target_ip=%s target_port=%u; rebooting in 1s", + ip, (unsigned)port); + + char resp[120]; + int rlen = snprintf(resp, sizeof(resp), + "{\"status\":\"ok\",\"target_ip\":\"%s\",\"target_port\":%u,\"message\":\"rebooting\"}", + ip, (unsigned)port); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, rlen); + + vTaskDelay(pdMS_TO_TICKS(1000)); + esp_restart(); + return ESP_OK; /* unreachable */ +} + /** * POST /ota — receive and flash firmware binary. */ @@ -312,10 +433,20 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle) }; httpd_register_uri_handler(server, &recalibrate_uri); + /* ADR-115: REST endpoint to change CSI aggregator target without USB. */ + httpd_uri_t set_target_uri = { + .uri = "/ota/set-target", + .method = HTTP_POST, + .handler = ota_set_target_handler, + .user_ctx = NULL, + }; + httpd_register_uri_handler(server, &set_target_uri); + 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"); ESP_LOGI(TAG, " POST /ota/recalibrate — clear gain-lock NVS + reboot"); + ESP_LOGI(TAG, " POST /ota/set-target — set CSI target IP:port in NVS + reboot"); if (out_handle) *out_handle = server; return ESP_OK;