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:
parent
54adc48b2e
commit
7d3e0c2d7e
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 `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 <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
|
||||
|
|
@ -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 <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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue