From 7a8b72de7683ae1fa5ce02ef0fe804bbd26e54b8 Mon Sep 17 00:00:00 2001 From: Deploy Bot Date: Tue, 28 Apr 2026 14:08:26 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(firmware,sensing-server):=20pull-based?= =?UTF-8?q?=20OTA=20=E2=80=94=20client=20+=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ESP32 nodes poll GET /api/v1/firmware/latest and self-upgrade when the server advertises a newer version. SHA-256 verified; ESP-IDF rollback failsafe reverts on crash in the first boot window. Server side: new firmware_registry module (in-memory manifest holder, set_current, is_update_available, sha256_bytes/sha256_file helpers, 11 unit tests). Three HTTP endpoints wired into sensing-server: GET /api/v1/firmware/latest GET /api/v1/firmware/download POST /api/v1/firmware/upload?version=X[&sha256=HEX] Startup scan of --firmware-dir seeds the registry from the newest .bin. Firmware client (ota_pull.c, +413 LOC): polling task, SHA-256 verify, OTA partition write, BLE stop guard, uptime guard, graceful retry on error. Includes ADR-094 design record and CHANGELOG entry. --- CHANGELOG.md | 12 + docs/adr/ADR-094-pull-based-ota.md | 109 +++++ firmware/esp32-csi-node/main/CMakeLists.txt | 1 + firmware/esp32-csi-node/main/ota_pull.c | 412 ++++++++++++++++ firmware/esp32-csi-node/main/ota_pull.h | 30 ++ v2/Cargo.lock | 114 ++++- .../wifi-densepose-sensing-server/Cargo.toml | 4 + .../src/firmware_registry.rs | 452 ++++++++++++++++++ .../wifi-densepose-sensing-server/src/main.rs | 236 +++++++++ 9 files changed, 1366 insertions(+), 4 deletions(-) create mode 100644 docs/adr/ADR-094-pull-based-ota.md create mode 100644 firmware/esp32-csi-node/main/ota_pull.c create mode 100644 firmware/esp32-csi-node/main/ota_pull.h create mode 100644 v2/crates/wifi-densepose-sensing-server/src/firmware_registry.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index eb52f0697..00ea1fa94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Pull-based OTA firmware updates** (ADR-094) — + ESP32 sensing nodes now poll `GET /api/v1/firmware/latest` on a configurable + interval (default 5 min) and self-upgrade when the server advertises a newer + version. SHA-256 integrity is verified before writing the OTA partition; the + ESP-IDF rollback mechanism reverts automatically on crash within the first + boot window. New firmware client: `firmware/esp32-csi-node/main/ota_pull.c` + (+413 LOC). New server registry module: `firmware_registry.rs` (11 unit + tests). New server endpoints: `GET /api/v1/firmware/latest`, + `GET /api/v1/firmware/download`, `POST /api/v1/firmware/upload`. + Operators stage firmware via upload; nodes fetch updates without any + push-side connectivity to individual node IPs. See `docs/adr/ADR-094-pull-based-ota.md`. + - **`nvsim` crate — deterministic NV-diamond magnetometer pipeline simulator** (ADR-089) — New standalone leaf crate at `v2/crates/nvsim` modeling a forward-only magnetic sensing path: scene → source synthesis (Biot–Savart, dipole, diff --git a/docs/adr/ADR-094-pull-based-ota.md b/docs/adr/ADR-094-pull-based-ota.md new file mode 100644 index 000000000..6a2979de8 --- /dev/null +++ b/docs/adr/ADR-094-pull-based-ota.md @@ -0,0 +1,109 @@ +# ADR-094: Pull-based OTA Firmware Update + +## Status + +Proposed + +## Context + +ESP32 sensing nodes deployed in user homes need firmware updates without +operator-side push access. Push-based OTA (server initiates upgrades to a +known set of node IPs) is operationally heavy for consumer-grade deployments: + +- Operators must enumerate every node's IP address and schedule rollouts. +- Nodes that come online intermittently or behind NAT get missed entirely. +- A node in a bad state (e.g. hung at startup) may never receive a push. + +For a consumer sensing system where nodes are embedded in rooms and accessed +infrequently, this creates a support burden and leaves nodes on stale firmware. + +## Decision + +Adopt a pull-based OTA model: each node periodically polls a server manifest +endpoint and self-upgrades when a newer version is available. Operators publish +new firmware to the server; nodes fetch it at their next poll cycle. + +## Architecture + +### Server side — `firmware_registry` module + +`v2/crates/wifi-densepose-sensing-server/src/firmware_registry.rs` provides +a pure-data, transport-agnostic registry: + +- `FirmwareRegistry` — in-memory holder for the currently-blessed firmware + binary: version, SHA-256 hex digest, byte size, file path, compile time. +- `set_current(path)` — reads a file from disk, computes SHA-256, parses the + version string from either a sidecar `.manifest.json` or the filename + (patterns: `esp32-csi-node-0.8.0-watchdog.bin`). +- `is_update_available(running_version)` — simple string comparison helper. +- `sha256_bytes(&[u8])` + `sha256_file(Path)` — pure-Rust SHA-256 helpers + using the `sha2` crate. +- Minimum firmware size: 256 KB (rejects truncated uploads). +- 11 unit tests covering hex encoding, version parsing, manifest sidecar + priority, size rejection, missing-file rejection, and SHA-256 round-trips. + +### Server HTTP endpoints (wired in `main.rs`) + +| Method | Path | Purpose | +|--------|------|---------| +| `GET` | `/api/v1/firmware/latest` | Returns `{available, version, sha256, size, compile_time, download_url}` | +| `GET` | `/api/v1/firmware/download` | Streams binary with `X-Firmware-Version` + `X-Firmware-Sha256` headers | +| `POST` | `/api/v1/firmware/upload?version=X[&sha256=HEX]` | Operator uploads; server computes SHA-256, optionally verifies client-supplied hash, writes to `/esp32-csi-node-.bin` | + +On startup the server scans `--firmware-dir` (env `FIRMWARE_DIR`, default +`/app/data/firmware`) for the newest `.bin` by mtime and seeds the registry. +This is non-fatal — the server starts normally if no firmware is staged. + +### Firmware client — `ota_pull` module + +`firmware/esp32-csi-node/main/ota_pull.c` (+413 LOC): + +1. `GET /api/v1/firmware/latest` — parse `{available, version, sha256, size}`. +2. Compare `version` with the compile-time `esp_app_desc.version`. +3. If newer: `GET /api/v1/firmware/download` — write binary to the ESP-IDF + OTA partition via `esp_ota_ops`. +4. Verify SHA-256 of downloaded bytes against the server-advertised hash. +5. Call `esp_ota_set_boot_partition` and `esp_restart()`. + +Guards: +- Waits for `OTA_MIN_UPTIME_SEC` (300 s) before first check — avoids + boot-loop on a node that OTA'd to bad firmware. +- Stops BLE before flashing to prevent Core 1 StoreProhibited crash. +- Aborts if the download exceeds `OTA_MAX_SIZE`. +- Graceful failure on network error — retries on next poll cycle. + +Poll interval: `OTA_CHECK_INTERVAL_SEC` = 300 s (configurable at compile time). + +### Rollback (ESP-IDF built-in) + +The ESP-IDF OTA partition scheme includes an application rollback mechanism. +After `esp_ota_set_boot_partition`, the new firmware must call +`esp_ota_mark_app_valid_cancel_rollback()` within a configurable window, or +the bootloader rolls back to the previous partition. `ota_pull.c` relies on +the existing `ota_update.c` canary task for this confirmation. + +## Consequences + +**Positive:** +- Zero operator action for routine upgrades; nodes that come online late catch + up automatically on their next poll cycle. +- Tolerates intermittent connectivity — retry is just the next poll tick. +- No inbound firewall holes required — nodes initiate all connections. +- Latecomers behind NAT/CGNAT are handled identically to nodes on the LAN. + +**Negative:** +- Upgrade latency is up to one poll interval (default 5 minutes). +- The manifest endpoint is discoverable; anyone who can reach the server can + learn the current firmware version and download the binary. Mitigated by + network segmentation; manifest signing is out of scope for this ADR. +- Poll traffic at scale: 11 nodes × 1 req/5 min = ~2 req/min steady-state. + Negligible. + +## Related + +- Firmware client: `firmware/esp32-csi-node/main/ota_pull.c` + `ota_pull.h` +- Server registry: `v2/crates/wifi-densepose-sensing-server/src/firmware_registry.rs` +- Server wiring: `v2/crates/wifi-densepose-sensing-server/src/main.rs` + (routes `/api/v1/firmware/*`, `AppStateInner::firmware_registry`, `scan_firmware_dir`) +- ADR-018: ESP32 binary frame format (firmware identity) +- ADR-057: Firmware CSI build guard diff --git a/firmware/esp32-csi-node/main/CMakeLists.txt b/firmware/esp32-csi-node/main/CMakeLists.txt index 6f0930a53..8b2733f4a 100644 --- a/firmware/esp32-csi-node/main/CMakeLists.txt +++ b/firmware/esp32-csi-node/main/CMakeLists.txt @@ -4,6 +4,7 @@ set(SRCS "wasm_runtime.c" "wasm_upload.c" "rvf_parser.c" "mmwave_sensor.c" "swarm_bridge.c" + "ota_pull.c" # ADR-081 — adaptive CSI mesh firmware kernel "rv_radio_ops_esp32.c" "rv_feature_state.c" diff --git a/firmware/esp32-csi-node/main/ota_pull.c b/firmware/esp32-csi-node/main/ota_pull.c new file mode 100644 index 000000000..6a8c99da2 --- /dev/null +++ b/firmware/esp32-csi-node/main/ota_pull.c @@ -0,0 +1,412 @@ +/** + * @file ota_pull.c + * @brief Pull-based OTA client for RuView CSI nodes (#38). + * + * Periodically checks the sensing server's firmware registry for updates. + * If a newer version is available, downloads the binary via HTTP GET, + * writes it to the OTA partition, verifies SHA256, and reboots. + * + * Flow: + * 1. GET /api/v1/firmware/latest → { available, version, sha256, size } + * 2. Compare version with current (compile-time esp_app_desc) + * 3. If newer: GET /api/v1/firmware/download → write to OTA partition + * 4. Verify SHA256 matches the server's advertised hash + * 5. Set boot partition and restart + * + * Guards: + * - Only attempts OTA if uptime > OTA_MIN_UPTIME_SEC (default 300s) + * - Stops BLE before OTA to avoid Core 1 StoreProhibited crash + * - Aborts if download exceeds OTA_MAX_SIZE + */ + +#include "ota_pull.h" + +#include +#include +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "esp_ota_ops.h" +#include "esp_http_client.h" +#include "esp_app_desc.h" +#include "esp_system.h" +#include "mbedtls/sha256.h" +#include "nvs_config.h" +#include "sdkconfig.h" + +#ifdef CONFIG_BT_NIMBLE_ENABLED +#include "host/ble_gap.h" +#endif + +static const char *TAG = "ota_pull"; + +/** Check interval in seconds (default: every 5 minutes). */ +#define OTA_CHECK_INTERVAL_SEC 300 + +/** Minimum uptime before attempting OTA (avoid boot-loop on bad firmware). */ +#define OTA_MIN_UPTIME_SEC 300 + +/** Maximum firmware size (1.5 MB). */ +#define OTA_MAX_SIZE (1500 * 1024) + +/** HTTP receive buffer size. */ +#define OTA_BUF_SIZE 1024 + +/** Saved server parameters. */ +static char s_server_ip[16]; +static uint16_t s_server_port; + +extern nvs_config_t g_nvs_config; + +/** + * Stop BLE before OTA to prevent StoreProhibited crash. + */ +static void ota_pull_stop_ble(void) +{ +#ifdef CONFIG_BT_NIMBLE_ENABLED + ble_gap_adv_stop(); + ble_gap_disc_cancel(); + ESP_LOGI(TAG, "BLE stopped for pull-OTA"); +#endif +} + +/** + * Compare semantic versions. Returns: + * >0 if remote is newer + * 0 if equal + * <0 if remote is older + * + * Simple comparison: parse "X.Y.Z" and compare numerically. + * Falls back to strcmp if parsing fails. + */ +static int version_compare(const char *local, const char *remote) +{ + int lmaj = 0, lmin = 0, lpat = 0; + int rmaj = 0, rmin = 0, rpat = 0; + + if (sscanf(local, "%d.%d.%d", &lmaj, &lmin, &lpat) != 3 || + sscanf(remote, "%d.%d.%d", &rmaj, &rmin, &rpat) != 3) { + return strcmp(remote, local); + } + + if (rmaj != lmaj) return rmaj - lmaj; + if (rmin != lmin) return rmin - lmin; + return rpat - lpat; +} + +/** + * Fetch JSON from a URL. Caller must free() the returned buffer. + * Returns NULL on failure. + */ +static char *http_get_json(const char *url) +{ + esp_http_client_config_t config = { + .url = url, + .timeout_ms = 10000, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) return NULL; + + esp_err_t err = esp_http_client_open(client, 0); + if (err != ESP_OK) { + esp_http_client_cleanup(client); + return NULL; + } + + int content_length = esp_http_client_fetch_headers(client); + if (content_length <= 0 || content_length > 4096) { + esp_http_client_close(client); + esp_http_client_cleanup(client); + return NULL; + } + + char *buf = malloc(content_length + 1); + if (!buf) { + esp_http_client_close(client); + esp_http_client_cleanup(client); + return NULL; + } + + int read = esp_http_client_read(client, buf, content_length); + buf[read > 0 ? read : 0] = '\0'; + + esp_http_client_close(client); + esp_http_client_cleanup(client); + return buf; +} + +/** + * Simple JSON string extraction: find "key":"value" and copy value to out. + * Returns true if found. + */ +static bool json_get_string(const char *json, const char *key, char *out, size_t out_len) +{ + char pattern[64]; + snprintf(pattern, sizeof(pattern), "\"%s\":\"", key); + const char *start = strstr(json, pattern); + if (!start) return false; + + start += strlen(pattern); + const char *end = strchr(start, '"'); + if (!end || (size_t)(end - start) >= out_len) return false; + + memcpy(out, start, end - start); + out[end - start] = '\0'; + return true; +} + +/** + * Simple JSON boolean extraction: find "key":true/false. + */ +static bool json_get_bool(const char *json, const char *key) +{ + char pattern[64]; + snprintf(pattern, sizeof(pattern), "\"%s\":true", key); + return strstr(json, pattern) != NULL; +} + +/** + * Download firmware and write to OTA partition with SHA256 verification. + * Returns ESP_OK on success. + */ +static esp_err_t download_and_flash(const char *download_url, const char *expected_sha256) +{ + const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL); + if (!update_partition) { + ESP_LOGE(TAG, "No OTA partition available"); + return ESP_ERR_NOT_FOUND; + } + + esp_http_client_config_t config = { + .url = download_url, + .timeout_ms = 30000, + .buffer_size = OTA_BUF_SIZE, + }; + + esp_http_client_handle_t client = esp_http_client_init(&config); + if (!client) return ESP_FAIL; + + esp_err_t err = esp_http_client_open(client, 0); + if (err != ESP_OK) { + ESP_LOGE(TAG, "HTTP open failed: %s", esp_err_to_name(err)); + esp_http_client_cleanup(client); + return err; + } + + int content_length = esp_http_client_fetch_headers(client); + ESP_LOGI(TAG, "Firmware download: %d bytes", content_length); + + if (content_length <= 0 || content_length > OTA_MAX_SIZE) { + ESP_LOGE(TAG, "Invalid firmware size: %d", content_length); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return ESP_ERR_INVALID_SIZE; + } + + /* Begin OTA write. */ + esp_ota_handle_t ota_handle; + err = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_begin failed: %s", esp_err_to_name(err)); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return err; + } + + /* SHA256 context for verification. */ + mbedtls_sha256_context sha_ctx; + mbedtls_sha256_init(&sha_ctx); + mbedtls_sha256_starts(&sha_ctx, 0); /* 0 = SHA-256 (not SHA-224) */ + + char buf[OTA_BUF_SIZE]; + int total = 0; + + while (total < content_length) { + int read = esp_http_client_read(client, buf, sizeof(buf)); + if (read <= 0) { + if (read == 0) break; /* EOF */ + ESP_LOGE(TAG, "Read error at byte %d", total); + esp_ota_abort(ota_handle); + mbedtls_sha256_free(&sha_ctx); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return ESP_FAIL; + } + + err = esp_ota_write(ota_handle, buf, read); + if (err != ESP_OK) { + ESP_LOGE(TAG, "OTA write failed at byte %d: %s", total, esp_err_to_name(err)); + esp_ota_abort(ota_handle); + mbedtls_sha256_free(&sha_ctx); + esp_http_client_close(client); + esp_http_client_cleanup(client); + return err; + } + + mbedtls_sha256_update(&sha_ctx, (const unsigned char *)buf, read); + total += read; + + if ((total % (64 * 1024)) == 0) { + ESP_LOGI(TAG, "OTA progress: %d / %d bytes (%.0f%%)", + total, content_length, + (float)total * 100.0f / (float)content_length); + } + } + + esp_http_client_close(client); + esp_http_client_cleanup(client); + + /* Finalize SHA256. */ + unsigned char sha_hash[32]; + mbedtls_sha256_finish(&sha_ctx, sha_hash); + mbedtls_sha256_free(&sha_ctx); + + /* Convert to hex string for comparison. */ + char sha_hex[65]; + for (int i = 0; i < 32; i++) { + snprintf(sha_hex + i * 2, 3, "%02x", sha_hash[i]); + } + + if (expected_sha256[0] != '\0' && strcmp(sha_hex, expected_sha256) != 0) { + ESP_LOGE(TAG, "SHA256 mismatch! expected=%s got=%s", expected_sha256, sha_hex); + esp_ota_abort(ota_handle); + return ESP_ERR_INVALID_CRC; + } + + ESP_LOGI(TAG, "SHA256 verified: %s", sha_hex); + + /* End OTA (validates image header). */ + err = esp_ota_end(ota_handle); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_ota_end failed: %s", esp_err_to_name(err)); + return err; + } + + /* Set boot partition. */ + 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)); + return err; + } + + ESP_LOGI(TAG, "OTA update successful! Downloaded %d bytes to partition '%s'", + total, update_partition->label); + return ESP_OK; +} + +/** + * Check for firmware update and apply if available. + */ +static void check_for_update(void) +{ + const esp_app_desc_t *app = esp_app_get_description(); + + /* Build URL for firmware/latest endpoint. */ + char url[128]; + snprintf(url, sizeof(url), "http://%s:%u/api/v1/firmware/latest", + s_server_ip, (unsigned)s_server_port); + + ESP_LOGI(TAG, "Checking for update: %s (current: %s)", url, app->version); + + char *json = http_get_json(url); + if (!json) { + ESP_LOGD(TAG, "Failed to fetch firmware info"); + return; + } + + if (!json_get_bool(json, "available")) { + ESP_LOGD(TAG, "No firmware available on server"); + free(json); + return; + } + + char remote_version[32] = {0}; + char remote_sha256[65] = {0}; + json_get_string(json, "version", remote_version, sizeof(remote_version)); + json_get_string(json, "sha256", remote_sha256, sizeof(remote_sha256)); + free(json); + + if (remote_version[0] == '\0') { + ESP_LOGW(TAG, "Server returned available=true but no version"); + return; + } + + int cmp = version_compare(app->version, remote_version); + if (cmp <= 0) { + ESP_LOGD(TAG, "Current version %s is up to date (server: %s)", + app->version, remote_version); + return; + } + + ESP_LOGI(TAG, "Update available: %s → %s", app->version, remote_version); + + /* Stop BLE before OTA. */ + ota_pull_stop_ble(); + + /* Build download URL. */ + char download_url[128]; + snprintf(download_url, sizeof(download_url), "http://%s:%u/api/v1/firmware/download", + s_server_ip, (unsigned)s_server_port); + + esp_err_t err = download_and_flash(download_url, remote_sha256); + if (err == ESP_OK) { + ESP_LOGI(TAG, "Rebooting to apply update..."); + vTaskDelay(pdMS_TO_TICKS(1000)); + esp_restart(); + } else { + ESP_LOGE(TAG, "OTA update failed: %s — will retry next cycle", esp_err_to_name(err)); + /* Don't restart on failure — let the node continue operating. */ + } +} + +/** + * FreeRTOS task: periodically check for updates. + */ +static void ota_pull_task(void *arg) +{ + (void)arg; + + /* Wait for minimum uptime before first check. */ + ESP_LOGI(TAG, "OTA pull client waiting %d s before first check", OTA_MIN_UPTIME_SEC); + vTaskDelay(pdMS_TO_TICKS(OTA_MIN_UPTIME_SEC * 1000)); + + while (1) { + uint32_t uptime_sec = (uint32_t)(esp_timer_get_time() / 1000000ULL); + if (uptime_sec >= OTA_MIN_UPTIME_SEC) { + check_for_update(); + } + vTaskDelay(pdMS_TO_TICKS(OTA_CHECK_INTERVAL_SEC * 1000)); + } +} + +esp_err_t ota_pull_init(const char *server_ip, uint16_t server_port) +{ + if (!server_ip) return ESP_ERR_INVALID_ARG; + + strncpy(s_server_ip, server_ip, sizeof(s_server_ip) - 1); + s_server_ip[sizeof(s_server_ip) - 1] = '\0'; + s_server_port = server_port; + + BaseType_t ret = xTaskCreatePinnedToCore( + ota_pull_task, + "ota_pull", + 6144, /* stack — needs room for HTTP client + SHA256 */ + NULL, + 1, /* low priority */ + NULL, + 0 /* core 0 — keep core 1 free for CSI */ + ); + + if (ret != pdPASS) { + ESP_LOGE(TAG, "Failed to create OTA pull task"); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "OTA pull client started → %s:%u (check every %d s, min uptime %d s)", + s_server_ip, (unsigned)s_server_port, + OTA_CHECK_INTERVAL_SEC, OTA_MIN_UPTIME_SEC); + return ESP_OK; +} diff --git a/firmware/esp32-csi-node/main/ota_pull.h b/firmware/esp32-csi-node/main/ota_pull.h new file mode 100644 index 000000000..3fc65df6d --- /dev/null +++ b/firmware/esp32-csi-node/main/ota_pull.h @@ -0,0 +1,30 @@ +/** + * @file ota_pull.h + * @brief Pull-based OTA client for RuView CSI nodes (#38). + * + * Periodically polls the sensing server's firmware registry and + * applies updates automatically when a newer version is available. + */ + +#ifndef OTA_PULL_H +#define OTA_PULL_H + +#include "esp_err.h" +#include + +/** + * Initialize and start the pull-based OTA client task. + * + * Creates a FreeRTOS task that: + * 1. Waits OTA_MIN_UPTIME_SEC (300s) after boot + * 2. Polls GET /api/v1/firmware/latest every OTA_CHECK_INTERVAL_SEC (300s) + * 3. Downloads and flashes new firmware when available + * 4. Verifies SHA256 before rebooting + * + * @param server_ip IPv4 address of the sensing server. + * @param server_port HTTP port (typically 4000). + * @return ESP_OK on success. + */ +esp_err_t ota_pull_init(const char *server_ip, uint16_t server_port); + +#endif /* OTA_PULL_H */ diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 2425594e1..ae8097c16 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -231,6 +231,18 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -318,7 +330,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-tungstenite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -871,6 +883,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -2371,6 +2400,16 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "byteorder", + "num-traits", +] + [[package]] name = "heapless" version = "0.6.1" @@ -3892,13 +3931,35 @@ name = "nvsim" version = "0.3.0" dependencies = [ "approx 0.5.1", + "criterion", + "js-sys", "rand 0.8.5", "rand_chacha 0.3.1", "serde", + "serde-wasm-bindgen", "serde_json", "sha2", "thiserror 1.0.69", "tracing", + "wasm-bindgen", +] + +[[package]] +name = "nvsim-server" +version = "0.3.0" +dependencies = [ + "axum", + "clap", + "futures-util", + "nvsim", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tower 0.4.13", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", ] [[package]] @@ -4487,6 +4548,26 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -5278,7 +5359,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", - "tower", + "tower 0.5.3", "tower-http 0.6.8", "tower-service", "url", @@ -5311,7 +5392,7 @@ dependencies = [ "sync_wrapper 1.0.2", "tokio", "tokio-util", - "tower", + "tower 0.5.3", "tower-http 0.6.8", "tower-service", "url", @@ -7379,6 +7460,27 @@ dependencies = [ "zip 0.6.6", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "hdrhistogram", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.3" @@ -7401,8 +7503,10 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ + "async-compression", "bitflags 2.11.0", "bytes", + "futures-core", "futures-util", "http 1.4.0", "http-body 1.0.1", @@ -7433,7 +7537,7 @@ dependencies = [ "http-body 1.0.1", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", ] @@ -8418,7 +8522,9 @@ dependencies = [ "ruvector-mincut", "serde", "serde_json", + "sha2", "tempfile", + "thiserror 1.0.69", "tokio", "tower-http 0.5.2", "tracing", diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 0647e8e9d..475d4df59 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -50,5 +50,9 @@ wifi-densepose-wifiscan = { version = "0.3.0", path = "../wifi-densepose-wifisca # build without vcpkg/openblas (issue #366, #415). wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false } +# SHA-256 for firmware registry integrity verification (pull-based OTA, ADR-094) +sha2 = { workspace = true } +thiserror = { workspace = true } + [dev-dependencies] tempfile = "3.10" diff --git a/v2/crates/wifi-densepose-sensing-server/src/firmware_registry.rs b/v2/crates/wifi-densepose-sensing-server/src/firmware_registry.rs new file mode 100644 index 000000000..cb700c46f --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/firmware_registry.rs @@ -0,0 +1,452 @@ +//! Firmware registry for pull-based OTA. +//! +//! Holds the currently-blessed ESP32 firmware binary in memory, along with +//! its SHA-256 hash and version metadata. Nodes poll the server to discover +//! whether an update is available and can then download the binary via HTTP. +//! +//! Workflow: +//! 1. Operator uploads a new firmware binary via `POST /api/v1/firmware/upload`. +//! 2. Server computes SHA-256, parses version from filename/sidecar, and stores +//! it on disk at the configured firmware path. +//! 3. Server loads the metadata into the in-memory registry. +//! 4. Nodes `GET /api/v1/firmware/latest` periodically. Response includes +//! version, sha256, size, and download_url. +//! 5. If a node's running version differs, it calls `GET /api/v1/firmware/download` +//! to fetch the bytes and applies them via the existing ota_update handler. +//! +//! This module is deliberately transport-agnostic: HTTP handlers live in +//! `main.rs`. The registry provides pure data and file I/O helpers. + +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/// Errors that can arise while loading or registering firmware. +#[derive(Debug, thiserror::Error)] +pub enum FirmwareRegistryError { + /// File not found at the given path. + #[error("firmware file not found: {0}")] + NotFound(PathBuf), + + /// I/O error reading the file. + #[error("firmware I/O error: {0}")] + Io(String), + + /// Could not parse a version from the filename or sidecar manifest. + #[error("could not parse firmware version from {0}")] + VersionParse(String), + + /// File is empty or smaller than the minimum expected binary size. + #[error("firmware file too small ({size} bytes, need >= {min})")] + TooSmall { size: u64, min: u64 }, +} + +impl From for FirmwareRegistryError { + fn from(err: std::io::Error) -> Self { + FirmwareRegistryError::Io(err.to_string()) + } +} + +/// Metadata describing a single firmware binary. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FirmwareMetadata { + /// Semver-ish version string (e.g. "0.8.0-watchdog"). + pub version: String, + /// Lowercase hex SHA-256 of the binary bytes. + pub sha256: String, + /// Size in bytes. + pub size_bytes: u64, + /// Absolute path to the binary on disk. + pub file_path: PathBuf, + /// Optional human-readable compile time (ISO-8601 or whatever was in the + /// sidecar manifest). None if not known. + pub compile_time: Option, +} + +/// Optional sidecar manifest shipped next to a firmware binary. If a file +/// `.manifest.json` exists alongside the binary, its contents are +/// loaded and used to populate version/compile_time. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FirmwareManifest { + /// Explicit version override. Takes precedence over filename parsing. + pub version: Option, + /// Optional compile-time string. + pub compile_time: Option, +} + +/// In-memory registry of the current firmware. +#[derive(Debug, Clone, Default)] +pub struct FirmwareRegistry { + current: Option, +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +/// Minimum plausible firmware size — anything smaller is rejected as +/// corrupt/truncated (ESP32-S3 app binaries are always >> 256 KB). +const MIN_FIRMWARE_SIZE_BYTES: u64 = 256 * 1024; + +impl FirmwareRegistry { + /// Create an empty registry with no current firmware loaded. + pub fn new() -> Self { + Self { current: None } + } + + /// Return the currently-blessed firmware metadata, if any. + pub fn current(&self) -> Option<&FirmwareMetadata> { + self.current.as_ref() + } + + /// Clear the current firmware. + pub fn clear(&mut self) { + self.current = None; + } + + /// Set the current firmware from a file on disk. + /// + /// Reads the file, computes SHA-256, and parses the version from either: + /// 1. A sidecar `.manifest.json` file, or + /// 2. The filename itself (looking for a substring like `-0.8.0.bin`). + /// + /// Returns the resulting metadata on success. + pub fn set_current>( + &mut self, + path: P, + ) -> Result { + let path = path.as_ref(); + if !path.exists() { + return Err(FirmwareRegistryError::NotFound(path.to_path_buf())); + } + + let metadata = fs::metadata(path)?; + let size_bytes = metadata.len(); + if size_bytes < MIN_FIRMWARE_SIZE_BYTES { + return Err(FirmwareRegistryError::TooSmall { + size: size_bytes, + min: MIN_FIRMWARE_SIZE_BYTES, + }); + } + + let sha256 = sha256_file(path)?; + let (version, compile_time) = resolve_version(path)?; + + let meta = FirmwareMetadata { + version, + sha256, + size_bytes, + file_path: path.to_path_buf(), + compile_time, + }; + self.current = Some(meta.clone()); + Ok(meta) + } + + /// Check whether the registry considers a given running version to be + /// out-of-date. Returns `true` if the current firmware version is non-None + /// and differs from `running_version`. Nodes that don't report a version + /// pass `None` and are always told to update. + pub fn is_update_available(&self, running_version: Option<&str>) -> bool { + match (&self.current, running_version) { + (Some(cur), Some(running)) => cur.version != running, + (Some(_), None) => true, + (None, _) => false, + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Compute the lowercase-hex SHA-256 of a file on disk. +fn sha256_file(path: &Path) -> Result { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(hex_encode(&hasher.finalize())) +} + +/// Compute SHA-256 of an in-memory byte slice (lowercase hex). +pub fn sha256_bytes(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + hex_encode(&hasher.finalize()) +} + +/// Minimal hex encoder — avoids pulling in an extra crate. +fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for &b in bytes { + out.push(HEX[(b >> 4) as usize] as char); + out.push(HEX[(b & 0x0f) as usize] as char); + } + out +} + +/// Resolve firmware version from either a sidecar manifest or the filename. +/// +/// Sidecar priority: `.manifest.json` — if present, its `version` field +/// wins. Otherwise, parse the filename for a version token like `0.8.0` or +/// `0.8.0-watchdog`. If neither yields a version, return an error. +fn resolve_version( + path: &Path, +) -> Result<(String, Option), FirmwareRegistryError> { + // 1. Sidecar manifest. + let mut manifest_path = path.as_os_str().to_os_string(); + manifest_path.push(".manifest.json"); + let manifest_path = PathBuf::from(manifest_path); + + if manifest_path.exists() { + if let Ok(bytes) = fs::read(&manifest_path) { + if let Ok(m) = serde_json::from_slice::(&bytes) { + if let Some(v) = m.version { + return Ok((v, m.compile_time)); + } + } + } + } + + // 2. Filename parsing — expects patterns like: + // esp32-csi-node-0.8.0.bin + // esp32-csi-node-0.8.0-watchdog.bin + // current-0.8.0.bin + // 0.8.0.bin + let filename = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + if let Some(v) = parse_version_from_filename(filename) { + return Ok((v, None)); + } + + Err(FirmwareRegistryError::VersionParse(filename.to_string())) +} + +/// Extract a semver-ish version token from a firmware filename stem. +/// +/// Looks for the first run of digits containing at least one dot, optionally +/// followed by a `-suffix`. Examples: +/// "esp32-csi-node-0.8.0" → "0.8.0" +/// "esp32-csi-node-0.8.0-watchdog" → "0.8.0-watchdog" +/// "current-0.8.0-rc1" → "0.8.0-rc1" +/// "esp32-csi-node" → None +fn parse_version_from_filename(stem: &str) -> Option { + // Find the first character that starts a digit-dot pattern. + let bytes = stem.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i].is_ascii_digit() { + // Scan forward while we see digits, dots, then optionally a + // single dash followed by [alphanumeric | dot]. + let start = i; + let mut saw_dot = false; + while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') { + if bytes[i] == b'.' { + saw_dot = true; + } + i += 1; + } + if saw_dot { + // Extend into an optional pre-release suffix like "-rc1" or "-watchdog" + if i < bytes.len() && bytes[i] == b'-' { + let mut j = i + 1; + while j < bytes.len() + && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'.' || bytes[j] == b'-') + { + j += 1; + } + if j > i + 1 { + return Some(stem[start..j].to_string()); + } + } + return Some(stem[start..i].to_string()); + } + } + i += 1; + } + None +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write_fake_firmware(path: &Path, size: usize) { + let mut f = fs::File::create(path).unwrap(); + let chunk = vec![0xABu8; 4096]; + let mut written = 0; + while written < size { + let n = chunk.len().min(size - written); + f.write_all(&chunk[..n]).unwrap(); + written += n; + } + } + + #[test] + fn test_sha256_bytes_known_vector() { + // SHA-256 of "abc" = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad + let s = sha256_bytes(b"abc"); + assert_eq!( + s, + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn test_hex_encode() { + assert_eq!(hex_encode(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef"); + assert_eq!(hex_encode(&[]), ""); + assert_eq!(hex_encode(&[0x00, 0xff]), "00ff"); + } + + #[test] + fn test_parse_version_from_filename() { + assert_eq!( + parse_version_from_filename("esp32-csi-node-0.8.0"), + Some("0.8.0".to_string()) + ); + assert_eq!( + parse_version_from_filename("esp32-csi-node-0.8.0-watchdog"), + Some("0.8.0-watchdog".to_string()) + ); + assert_eq!( + parse_version_from_filename("current-0.8.0-rc1"), + Some("0.8.0-rc1".to_string()) + ); + assert_eq!( + parse_version_from_filename("0.8.0"), + Some("0.8.0".to_string()) + ); + assert_eq!(parse_version_from_filename("esp32-csi-node"), None); + } + + #[test] + fn test_set_current_with_manifest() { + let dir = tempfile::tempdir().unwrap(); + let bin_path = dir.path().join("esp32-csi-node.bin"); + write_fake_firmware(&bin_path, 300 * 1024); + + let manifest_path = dir.path().join("esp32-csi-node.bin.manifest.json"); + let manifest = r#"{"version":"0.9.0-test","compile_time":"2026-04-09T17:00:00Z"}"#; + fs::write(&manifest_path, manifest).unwrap(); + + let mut registry = FirmwareRegistry::new(); + let meta = registry.set_current(&bin_path).unwrap(); + assert_eq!(meta.version, "0.9.0-test"); + assert_eq!(meta.compile_time.as_deref(), Some("2026-04-09T17:00:00Z")); + assert_eq!(meta.size_bytes, 300 * 1024); + assert_eq!(meta.sha256.len(), 64); + } + + #[test] + fn test_set_current_with_filename_version() { + let dir = tempfile::tempdir().unwrap(); + let bin_path = dir.path().join("esp32-csi-node-0.8.0-watchdog.bin"); + write_fake_firmware(&bin_path, 300 * 1024); + + let mut registry = FirmwareRegistry::new(); + let meta = registry.set_current(&bin_path).unwrap(); + assert_eq!(meta.version, "0.8.0-watchdog"); + assert!(meta.compile_time.is_none()); + } + + #[test] + fn test_set_current_rejects_too_small() { + let dir = tempfile::tempdir().unwrap(); + let bin_path = dir.path().join("tiny-0.1.0.bin"); + write_fake_firmware(&bin_path, 1024); + + let mut registry = FirmwareRegistry::new(); + let err = registry.set_current(&bin_path).unwrap_err(); + assert!(matches!(err, FirmwareRegistryError::TooSmall { .. })); + } + + #[test] + fn test_set_current_rejects_missing_file() { + let mut registry = FirmwareRegistry::new(); + let err = registry.set_current("/nonexistent/firmware.bin").unwrap_err(); + assert!(matches!(err, FirmwareRegistryError::NotFound(_))); + } + + #[test] + fn test_set_current_rejects_unparseable_version() { + let dir = tempfile::tempdir().unwrap(); + let bin_path = dir.path().join("esp32-csi-node.bin"); + write_fake_firmware(&bin_path, 300 * 1024); + + let mut registry = FirmwareRegistry::new(); + let err = registry.set_current(&bin_path).unwrap_err(); + assert!(matches!(err, FirmwareRegistryError::VersionParse(_))); + } + + #[test] + fn test_is_update_available() { + let mut registry = FirmwareRegistry::new(); + // Empty registry never offers updates + assert!(!registry.is_update_available(Some("0.1.0"))); + assert!(!registry.is_update_available(None)); + + // Seed with a known version + let dir = tempfile::tempdir().unwrap(); + let bin_path = dir.path().join("fw-0.8.0.bin"); + write_fake_firmware(&bin_path, 300 * 1024); + registry.set_current(&bin_path).unwrap(); + + // Same version -> no update + assert!(!registry.is_update_available(Some("0.8.0"))); + // Different version -> update + assert!(registry.is_update_available(Some("0.7.0"))); + // Unknown version -> update (safest assumption) + assert!(registry.is_update_available(None)); + } + + #[test] + fn test_sha256_file_matches_bytes() { + let dir = tempfile::tempdir().unwrap(); + let bin_path = dir.path().join("fw-0.1.0.bin"); + let size = 300 * 1024; + write_fake_firmware(&bin_path, size); + + let mut registry = FirmwareRegistry::new(); + let meta = registry.set_current(&bin_path).unwrap(); + + let bytes = fs::read(&bin_path).unwrap(); + let direct = sha256_bytes(&bytes); + assert_eq!(meta.sha256, direct); + } + + #[test] + fn test_clear() { + let dir = tempfile::tempdir().unwrap(); + let bin_path = dir.path().join("fw-0.1.0.bin"); + write_fake_firmware(&bin_path, 300 * 1024); + + let mut registry = FirmwareRegistry::new(); + registry.set_current(&bin_path).unwrap(); + assert!(registry.current().is_some()); + registry.clear(); + assert!(registry.current().is_none()); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index a8b207e47..84f7dbb05 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -20,9 +20,11 @@ mod rvf_pipeline; mod tracker_bridge; pub mod types; mod vital_signs; +mod firmware_registry; // Training pipeline modules (exposed via lib.rs) use wifi_densepose_sensing_server::{graph_transformer, trainer, dataset, embedding}; +use firmware_registry::{FirmwareRegistry, sha256_bytes}; use std::collections::{HashMap, VecDeque}; use ruvector_mincut::{DynamicMinCut, MinCutBuilder}; @@ -166,6 +168,13 @@ struct Args { /// Start field model calibration on boot (empty room required) #[arg(long)] calibrate: bool, + + /// Directory holding ESP32 firmware binaries for pull-based OTA (ADR-094). + /// On startup, the newest `.bin` file in this directory is registered + /// as the current firmware. Operators upload new versions via + /// `POST /api/v1/firmware/upload`. + #[arg(long, default_value = "/app/data/firmware", env = "FIRMWARE_DIR")] + firmware_dir: PathBuf, } // ── Data types ─────────────────────────────────────────────────────────────── @@ -642,6 +651,16 @@ struct AppStateInner { multistatic_fuser: MultistaticFuser, /// SVD-based room field model for eigenvalue person counting (None until calibration). field_model: Option, + // ── Firmware registry (pull-based OTA, ADR-094) ────────────────────── + /// In-memory registry of the currently-blessed ESP32 firmware binary. + /// Nodes poll `GET /api/v1/firmware/latest` to learn the current version + /// and download it via `GET /api/v1/firmware/download`. Operators upload + /// new binaries via `POST /api/v1/firmware/upload`. + firmware_registry: Arc>, + /// Directory where firmware binaries live on disk. Default: + /// `/app/data/firmware` inside the Docker container (volume-mounted + /// from a configurable host path via FIRMWARE_DIR env var). + firmware_dir: PathBuf, } /// If no ESP32 frame arrives within this duration, source reverts to offline. @@ -3443,6 +3462,192 @@ async fn calibration_status(State(state): State) -> Json Result, String> { + if !dir.exists() { + return Ok(None); + } + let mut newest: Option<(std::time::SystemTime, PathBuf)> = None; + let mut entries = tokio::fs::read_dir(dir) + .await + .map_err(|e| format!("read_dir({}): {}", dir.display(), e))?; + while let Some(entry) = entries + .next_entry() + .await + .map_err(|e| format!("next_entry: {e}"))? + { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("bin") { + continue; + } + let meta = match tokio::fs::metadata(&path).await { + Ok(m) => m, + Err(_) => continue, + }; + let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH); + match &newest { + None => newest = Some((mtime, path)), + Some((prev_mtime, _)) if mtime > *prev_mtime => newest = Some((mtime, path)), + _ => {} + } + } + Ok(newest.map(|(_, p)| p)) +} + +/// GET /api/v1/firmware/latest — query whether a firmware update is available. +async fn firmware_latest_endpoint(State(state): State) -> Json { + let reg_arc = { state.read().await.firmware_registry.clone() }; + let reg = reg_arc.read().await; + match reg.current() { + Some(meta) => Json(serde_json::json!({ + "available": true, + "version": meta.version, + "sha256": meta.sha256, + "size": meta.size_bytes, + "compile_time": meta.compile_time, + "download_url": "/api/v1/firmware/download", + })), + None => Json(serde_json::json!({ + "available": false, + "message": "No firmware registered. Upload via POST /api/v1/firmware/upload.", + })), + } +} + +/// GET /api/v1/firmware/download — stream the current firmware binary. +async fn firmware_download_endpoint( + State(state): State, +) -> Result { + let reg_arc = { state.read().await.firmware_registry.clone() }; + let reg = reg_arc.read().await; + let meta = match reg.current() { + Some(m) => m.clone(), + None => return Err(axum::http::StatusCode::NOT_FOUND), + }; + drop(reg); + + let bytes = match tokio::fs::read(&meta.file_path).await { + Ok(b) => b, + Err(e) => { + error!("firmware_download: read {}: {}", meta.file_path.display(), e); + return Err(axum::http::StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + let response = axum::response::Response::builder() + .status(axum::http::StatusCode::OK) + .header("Content-Type", "application/octet-stream") + .header("Content-Length", bytes.len().to_string()) + .header( + "Content-Disposition", + format!("attachment; filename=\"esp32-csi-node-{}.bin\"", meta.version), + ) + .header("X-Firmware-Version", meta.version.clone()) + .header("X-Firmware-Sha256", meta.sha256.clone()) + .body(axum::body::Body::from(bytes)) + .map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(response) +} + +/// POST /api/v1/firmware/upload — operator uploads a new firmware binary. +/// +/// Accepts `application/octet-stream` body. Query params: +/// `?version=` required — the semver-ish version to register. +/// `?sha256=` optional — if provided, must match the computed SHA-256. +/// +/// Writes the binary to `/esp32-csi-node-.bin` and +/// registers it as the current firmware. +#[derive(Debug, serde::Deserialize)] +struct FirmwareUploadQuery { + version: String, + sha256: Option, +} + +async fn firmware_upload_endpoint( + State(state): State, + axum::extract::Query(query): axum::extract::Query, + body: axum::body::Bytes, +) -> Result, (axum::http::StatusCode, String)> { + if body.len() < 256 * 1024 { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + format!("firmware too small ({} bytes)", body.len()), + )); + } + if body.len() > 2 * 1024 * 1024 { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + format!("firmware too large ({} bytes)", body.len()), + )); + } + + let computed_sha = sha256_bytes(&body); + if let Some(expected) = query.sha256.as_ref() { + if !expected.eq_ignore_ascii_case(&computed_sha) { + return Err(( + axum::http::StatusCode::BAD_REQUEST, + format!( + "sha256 mismatch: client={} server={}", + expected, computed_sha + ), + )); + } + } + + let fw_dir = { state.read().await.firmware_dir.clone() }; + if let Err(e) = tokio::fs::create_dir_all(&fw_dir).await { + error!("firmware_upload: create_dir_all({}): {}", fw_dir.display(), e); + return Err(( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + format!("mkdir failed: {e}"), + )); + } + + // Sanitize version for filesystem use. + let safe_version: String = query + .version + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }) + .collect(); + let filename = format!("esp32-csi-node-{}.bin", safe_version); + let dest = fw_dir.join(&filename); + + if let Err(e) = tokio::fs::write(&dest, &body).await { + error!("firmware_upload: write {}: {}", dest.display(), e); + return Err(( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + format!("write failed: {e}"), + )); + } + + // Re-register from disk so size and sha256 come from a single source of truth. + let reg_arc = { state.read().await.firmware_registry.clone() }; + let mut reg = reg_arc.write().await; + match reg.set_current(&dest) { + Ok(meta) => { + info!( + "Firmware uploaded: version={} sha256={} size={} path={}", + meta.version, meta.sha256, meta.size_bytes, dest.display() + ); + Ok(Json(serde_json::json!({ + "status": "ok", + "version": meta.version, + "sha256": meta.sha256, + "size": meta.size_bytes, + "path": meta.file_path, + }))) + } + Err(e) => Err(( + axum::http::StatusCode::BAD_REQUEST, + format!("registry set_current failed: {e}"), + )), + } +} + /// Generate a simple timestamp string (epoch seconds) for recording IDs. fn chrono_timestamp() -> u64 { std::time::SystemTime::now() @@ -4841,8 +5046,35 @@ async fn main() { } else { None }, + // Firmware registry (pull-based OTA, ADR-094) — seeded from disk below. + firmware_registry: Arc::new(tokio::sync::RwLock::new(FirmwareRegistry::new())), + firmware_dir: args.firmware_dir.clone(), })); + // Scan firmware_dir for a current binary and seed the registry. Non-fatal + // on failure — the server still runs if no firmware is staged. + { + let state_ref = state.clone(); + let fw_dir = args.firmware_dir.clone(); + tokio::spawn(async move { + match scan_firmware_dir(&fw_dir).await { + Ok(Some(path)) => { + let reg_arc = { state_ref.read().await.firmware_registry.clone() }; + let mut reg = reg_arc.write().await; + match reg.set_current(&path) { + Ok(meta) => info!( + "Firmware registry: loaded {} (sha256={}…, {} bytes) from {}", + meta.version, &meta.sha256[..16], meta.size_bytes, path.display() + ), + Err(e) => warn!("Firmware registry: failed to load {}: {}", path.display(), e), + } + } + Ok(None) => info!("Firmware registry: no firmware found in {}", fw_dir.display()), + Err(e) => warn!("Firmware registry: scan failed for {}: {}", fw_dir.display(), e), + } + }); + } + // Start background tasks based on source match source { "esp32" => { @@ -4941,6 +5173,10 @@ async fn main() { .route("/api/v1/calibration/start", post(calibration_start)) .route("/api/v1/calibration/stop", post(calibration_stop)) .route("/api/v1/calibration/status", get(calibration_status)) + // Firmware registry / pull-based OTA (ADR-094) + .route("/api/v1/firmware/latest", get(firmware_latest_endpoint)) + .route("/api/v1/firmware/download", get(firmware_download_endpoint)) + .route("/api/v1/firmware/upload", post(firmware_upload_endpoint)) // Static UI files .nest_service("/ui", ServeDir::new(&ui_path)) .layer(SetResponseHeaderLayer::overriding( From 41d3378817495fc8cd0c3503196f78fc4913304b Mon Sep 17 00:00:00 2001 From: Deploy Bot Date: Tue, 28 Apr 2026 14:16:57 -0400 Subject: [PATCH 2/2] docs(adr): renumber ADR-094 to ADR-095 to avoid collision with body tracking PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #487 (body tracking platform) was filed first and claims ADR-094. Rename docs/adr/ADR-094-pull-based-ota.md → ADR-095-pull-based-ota.md and update all references in CHANGELOG.md, Cargo.toml, and main.rs comments. --- CHANGELOG.md | 4 ++-- ...094-pull-based-ota.md => ADR-095-pull-based-ota.md} | 2 +- v2/crates/wifi-densepose-sensing-server/Cargo.toml | 2 +- v2/crates/wifi-densepose-sensing-server/src/main.rs | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) rename docs/adr/{ADR-094-pull-based-ota.md => ADR-095-pull-based-ota.md} (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00ea1fa94..efca7ca8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **Pull-based OTA firmware updates** (ADR-094) — +- **Pull-based OTA firmware updates** (ADR-095) — ESP32 sensing nodes now poll `GET /api/v1/firmware/latest` on a configurable interval (default 5 min) and self-upgrade when the server advertises a newer version. SHA-256 integrity is verified before writing the OTA partition; the @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 tests). New server endpoints: `GET /api/v1/firmware/latest`, `GET /api/v1/firmware/download`, `POST /api/v1/firmware/upload`. Operators stage firmware via upload; nodes fetch updates without any - push-side connectivity to individual node IPs. See `docs/adr/ADR-094-pull-based-ota.md`. + push-side connectivity to individual node IPs. See `docs/adr/ADR-095-pull-based-ota.md`. - **`nvsim` crate — deterministic NV-diamond magnetometer pipeline simulator** (ADR-089) — New standalone leaf crate at `v2/crates/nvsim` modeling a forward-only diff --git a/docs/adr/ADR-094-pull-based-ota.md b/docs/adr/ADR-095-pull-based-ota.md similarity index 99% rename from docs/adr/ADR-094-pull-based-ota.md rename to docs/adr/ADR-095-pull-based-ota.md index 6a2979de8..2b733cdd1 100644 --- a/docs/adr/ADR-094-pull-based-ota.md +++ b/docs/adr/ADR-095-pull-based-ota.md @@ -1,4 +1,4 @@ -# ADR-094: Pull-based OTA Firmware Update +# ADR-095: Pull-based OTA Firmware Update ## Status diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 475d4df59..f6b00bdf7 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -50,7 +50,7 @@ wifi-densepose-wifiscan = { version = "0.3.0", path = "../wifi-densepose-wifisca # build without vcpkg/openblas (issue #366, #415). wifi-densepose-signal = { version = "0.3.0", path = "../wifi-densepose-signal", default-features = false } -# SHA-256 for firmware registry integrity verification (pull-based OTA, ADR-094) +# SHA-256 for firmware registry integrity verification (pull-based OTA, ADR-095) sha2 = { workspace = true } thiserror = { workspace = true } diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 84f7dbb05..80d760a7b 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -169,7 +169,7 @@ struct Args { #[arg(long)] calibrate: bool, - /// Directory holding ESP32 firmware binaries for pull-based OTA (ADR-094). + /// Directory holding ESP32 firmware binaries for pull-based OTA (ADR-095). /// On startup, the newest `.bin` file in this directory is registered /// as the current firmware. Operators upload new versions via /// `POST /api/v1/firmware/upload`. @@ -651,7 +651,7 @@ struct AppStateInner { multistatic_fuser: MultistaticFuser, /// SVD-based room field model for eigenvalue person counting (None until calibration). field_model: Option, - // ── Firmware registry (pull-based OTA, ADR-094) ────────────────────── + // ── Firmware registry (pull-based OTA, ADR-095) ────────────────────── /// In-memory registry of the currently-blessed ESP32 firmware binary. /// Nodes poll `GET /api/v1/firmware/latest` to learn the current version /// and download it via `GET /api/v1/firmware/download`. Operators upload @@ -3462,7 +3462,7 @@ async fn calibration_status(State(state): State) -> Json