wifi-densepose/firmware/esp32-csi-node/main/ota_update.c

478 lines
17 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @file ota_update.c
* @brief HTTP OTA firmware update for ESP32-S3 CSI Node.
*
* Uses ESP-IDF's native OTA API with rollback support.
* The HTTP server runs on port 8032 and accepts:
* POST /ota — firmware binary payload (application/octet-stream)
* GET /ota/status — current firmware version and partition info
*/
#include "ota_update.h"
#include <string.h>
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_http_server.h"
#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";
/** OTA HTTP server port. */
#define OTA_PORT 8032
/** Maximum firmware size (900 KB — matches CI binary size gate). */
#define OTA_MAX_SIZE (900 * 1024)
/** NVS namespace and key for the OTA pre-shared key. */
#define OTA_NVS_NAMESPACE "security"
#define OTA_NVS_KEY "ota_psk"
/** Maximum PSK length (hex-encoded SHA-256). */
#define OTA_PSK_MAX_LEN 65
/** Cached PSK loaded from NVS at init time. Empty = auth disabled. */
static char s_ota_psk[OTA_PSK_MAX_LEN] = {0};
/**
* ADR-050: Verify the Authorization header contains the correct PSK.
* Returns true if auth is disabled (no PSK provisioned) or if the
* Bearer token matches the stored PSK.
*/
static bool ota_check_auth(httpd_req_t *req)
{
if (s_ota_psk[0] == '\0') {
/* No PSK provisioned — auth disabled (permissive for dev). */
return true;
}
char auth_header[128] = {0};
if (httpd_req_get_hdr_value_str(req, "Authorization", auth_header,
sizeof(auth_header)) != ESP_OK) {
return false;
}
/* Expect "Bearer <psk>" */
const char *prefix = "Bearer ";
if (strncmp(auth_header, prefix, strlen(prefix)) != 0) {
return false;
}
const char *token = auth_header + strlen(prefix);
/* Constant-time comparison to prevent timing attacks. */
size_t psk_len = strlen(s_ota_psk);
size_t tok_len = strlen(token);
if (psk_len != tok_len) return false;
volatile uint8_t result = 0;
for (size_t i = 0; i < psk_len; i++) {
result |= (uint8_t)(s_ota_psk[i] ^ token[i]);
}
return result == 0;
}
/**
* GET /ota/status — return firmware version and partition info.
*/
static esp_err_t ota_status_handler(httpd_req_t *req)
{
const esp_app_desc_t *app = esp_app_get_description();
const esp_partition_t *running = esp_ota_get_running_partition();
const esp_partition_t *update = esp_ota_get_next_update_partition(NULL);
char response[512];
int len = snprintf(response, sizeof(response),
"{\"version\":\"%s\",\"date\":\"%s\",\"time\":\"%s\","
"\"running_partition\":\"%s\",\"next_partition\":\"%s\","
"\"max_size\":%d}",
app->version, app->date, app->time,
running ? running->label : "unknown",
update ? update->label : "none",
OTA_MAX_SIZE);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, response, len);
return ESP_OK;
}
/**
* POST /ota/recalibrate — clear cached gain-lock NVS keys and reboot.
*
* ADR-109: lets the operator force a full gain-lock re-calibration from
* the server without a USB connection. Erases csi_cfg/gl_agc, gl_fft, and
* gl_ap_mac (ADR-111), then calls esp_restart(). Next boot finds no NVS
* cache and runs the 300-packet calibration as if it were a fresh device.
*/
static esp_err_t ota_recalibrate_handler(httpd_req_t *req)
{
if (!ota_check_auth(req)) {
ESP_LOGW(TAG, "/ota/recalibrate rejected: authentication failed");
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
"Authentication required. Use: Authorization: Bearer <psk>");
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/recalibrate: 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;
}
/* Erase all three keys defensively — ignore individual ESP_ERR_NVS_NOT_FOUND
* (key already absent on a never-calibrated device). */
(void)nvs_erase_key(h, "gl_agc");
(void)nvs_erase_key(h, "gl_fft");
(void)nvs_erase_key(h, "gl_ap_mac");
err = nvs_commit(h);
nvs_close(h);
if (err != ESP_OK) {
ESP_LOGE(TAG, "/ota/recalibrate: nvs_commit failed: %s",
esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"NVS commit failed");
return ESP_FAIL;
}
ESP_LOGI(TAG, "/ota/recalibrate: gain-lock NVS cleared; rebooting in 1s");
const char *resp =
"{\"status\":\"ok\",\"message\":\"gain-lock NVS cleared; rebooting\"}";
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, strlen(resp));
vTaskDelay(pdMS_TO_TICKS(1000));
esp_restart();
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.
*/
static esp_err_t ota_upload_handler(httpd_req_t *req)
{
/* ADR-050: Authenticate before accepting firmware upload. */
if (!ota_check_auth(req)) {
ESP_LOGW(TAG, "OTA upload rejected: authentication failed");
httpd_resp_send_err(req, HTTPD_403_FORBIDDEN,
"Authentication required. Use: Authorization: Bearer <psk>");
return ESP_FAIL;
}
ESP_LOGI(TAG, "OTA update started, content_length=%d", req->content_len);
if (req->content_len <= 0 || req->content_len > OTA_MAX_SIZE) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
"Invalid firmware size (must be 1B - 900KB)");
return ESP_FAIL;
}
const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL);
if (update_partition == NULL) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"No OTA partition available");
return ESP_FAIL;
}
esp_ota_handle_t ota_handle;
/* Issue #556: use OTA_SIZE_UNKNOWN (full partition erase) instead of
* OTA_WITH_SEQUENTIAL_WRITES. When the new image is smaller than the
* one previously written to the target slot, sequential writes leave
* the tail of the old code in place. The image header SHA covers
* only the declared image span, but residual code at stale offsets
* can still be reached via IRAM jump tables / .literal pools on some
* v5.2 ABIs and crash the new app on first boot, which then looks
* like "OTA didn't take". Full erase up-front avoids this entirely
* at the cost of one extra ~1.5 s erase before write starts. */
esp_err_t err = esp_ota_begin(update_partition, OTA_SIZE_UNKNOWN, &ota_handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"OTA begin failed");
return ESP_FAIL;
}
/* Read firmware in chunks. */
char buf[1024];
int received = 0;
int total = 0;
while (total < req->content_len) {
received = httpd_req_recv(req, buf, sizeof(buf));
if (received <= 0) {
if (received == HTTPD_SOCK_ERR_TIMEOUT) {
continue; /* Retry on timeout. */
}
ESP_LOGE(TAG, "OTA receive error at byte %d", total);
esp_ota_abort(ota_handle);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Receive error");
return ESP_FAIL;
}
err = esp_ota_write(ota_handle, buf, received);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_write failed at byte %d: %s",
total, esp_err_to_name(err));
esp_ota_abort(ota_handle);
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"OTA write failed");
return ESP_FAIL;
}
total += received;
if ((total % (64 * 1024)) == 0) {
ESP_LOGI(TAG, "OTA progress: %d / %d bytes (%.0f%%)",
total, req->content_len,
(float)total * 100.0f / (float)req->content_len);
}
}
err = esp_ota_end(ota_handle);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_end failed: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"OTA validation failed");
return ESP_FAIL;
}
err = esp_ota_set_boot_partition(update_partition);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err));
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
"Set boot partition failed");
return ESP_FAIL;
}
ESP_LOGI(TAG, "OTA update successful! Rebooting to partition '%s'...",
update_partition->label);
const char *resp = "{\"status\":\"ok\",\"message\":\"OTA update successful. Rebooting...\"}";
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, strlen(resp));
/* Delay briefly to let the response flush, then reboot. */
vTaskDelay(pdMS_TO_TICKS(1000));
esp_restart();
return ESP_OK; /* Never reached. */
}
/** Internal: start the HTTP server and register OTA endpoints. */
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). */
/* Increase receive timeout for large uploads. */
config.recv_wait_timeout = 30;
/* Issue #556: httpd default stack is 4096 B, which overflows during
* esp_ota_end()'s image-verify (SHA256 streaming + mmap segment walk
* eats ~3 KB on top of the request handler frame). Empirically observed
* "***ERROR*** A stack overflow in task httpd has been detected"
* immediately after esp_image: segment dumps when OTA reaches verify.
* 8 KB gives a clean margin without hurting the typical idle case. */
config.stack_size = 8192;
httpd_handle_t server = NULL;
esp_err_t err = httpd_start(&server, &config);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to start OTA HTTP server on port %d: %s",
OTA_PORT, esp_err_to_name(err));
if (out_handle) *out_handle = NULL;
return err;
}
httpd_uri_t status_uri = {
.uri = "/ota/status",
.method = HTTP_GET,
.handler = ota_status_handler,
.user_ctx = NULL,
};
httpd_register_uri_handler(server, &status_uri);
httpd_uri_t upload_uri = {
.uri = "/ota",
.method = HTTP_POST,
.handler = ota_upload_handler,
.user_ctx = NULL,
};
httpd_register_uri_handler(server, &upload_uri);
/* ADR-109: REST trigger for full gain-lock re-calibration. */
httpd_uri_t recalibrate_uri = {
.uri = "/ota/recalibrate",
.method = HTTP_POST,
.handler = ota_recalibrate_handler,
.user_ctx = NULL,
};
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;
}
esp_err_t ota_update_init(void)
{
/* ADR-050: Load OTA PSK from NVS if provisioned. */
nvs_handle_t nvs;
if (nvs_open(OTA_NVS_NAMESPACE, NVS_READONLY, &nvs) == ESP_OK) {
size_t len = sizeof(s_ota_psk);
if (nvs_get_str(nvs, OTA_NVS_KEY, s_ota_psk, &len) == ESP_OK) {
ESP_LOGI(TAG, "OTA PSK loaded from NVS (%d chars) — authentication enabled", (int)len - 1);
} else {
ESP_LOGW(TAG, "No OTA PSK in NVS — OTA authentication DISABLED (provision with nvs_set)");
}
nvs_close(nvs);
} else {
ESP_LOGW(TAG, "NVS namespace '%s' not found — OTA authentication DISABLED", OTA_NVS_NAMESPACE);
}
return ota_start_server(NULL);
}
esp_err_t ota_update_init_ex(void **out_server)
{
return ota_start_server((httpd_handle_t *)out_server);
}