feat(adr-115): POST /ota/set-target — set CSI target IP/port via WiFi

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 <noreply@anthropic.com>
This commit is contained in:
arsen 2026-05-17 18:27:06 +07:00
parent 54adc48b2e
commit 7d3e0c2d7e
3 changed files with 295 additions and 0 deletions

View File

@ -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

View File

@ -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 <psk> # 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 `0255`.
* Single `:` separator.
* Port `165535`, 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 <psk>`. 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

View File

@ -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 0255); port 165535.
*
* 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 0255. */
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 <psk>");
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;