feat(firmware,sensing-server): pull-based OTA — client + registry

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.
This commit is contained in:
Deploy Bot 2026-04-28 14:08:26 -04:00
parent 36e70bf229
commit 7a8b72de76
9 changed files with 1366 additions and 4 deletions

View File

@ -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 (BiotSavart, dipole,

View File

@ -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 `<firmware_dir>/esp32-csi-node-<version>.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

View File

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

View File

@ -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 <string.h>
#include <stdio.h>
#include <stdlib.h>
#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;
}

View File

@ -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 <stdint.h>
/**
* 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 */

114
v2/Cargo.lock generated
View File

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

View File

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

View File

@ -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<std::io::Error> 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<String>,
}
/// Optional sidecar manifest shipped next to a firmware binary. If a file
/// `<name>.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<String>,
/// Optional compile-time string.
pub compile_time: Option<String>,
}
/// In-memory registry of the current firmware.
#[derive(Debug, Clone, Default)]
pub struct FirmwareRegistry {
current: Option<FirmwareMetadata>,
}
// ---------------------------------------------------------------------------
// 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 `<name>.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<P: AsRef<Path>>(
&mut self,
path: P,
) -> Result<FirmwareMetadata, FirmwareRegistryError> {
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<String, FirmwareRegistryError> {
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: `<path>.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<String>), 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::<FirmwareManifest>(&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<String> {
// 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());
}
}

View File

@ -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<FieldModel>,
// ── 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<tokio::sync::RwLock<FirmwareRegistry>>,
/// 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<SharedState>) -> Json<serde_json
}
}
// ── Firmware Registry Endpoints (pull-based OTA, ADR-094) ────────────────────
/// Scan a firmware directory and return the newest .bin file by mtime.
/// Returns `Ok(None)` if the directory exists but contains no .bin files.
async fn scan_firmware_dir(dir: &std::path::Path) -> Result<Option<PathBuf>, 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<SharedState>) -> Json<serde_json::Value> {
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<SharedState>,
) -> Result<axum::response::Response, axum::http::StatusCode> {
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=<string>` required — the semver-ish version to register.
/// `?sha256=<hex>` optional — if provided, must match the computed SHA-256.
///
/// Writes the binary to `<firmware_dir>/esp32-csi-node-<version>.bin` and
/// registers it as the current firmware.
#[derive(Debug, serde::Deserialize)]
struct FirmwareUploadQuery {
version: String,
sha256: Option<String>,
}
async fn firmware_upload_endpoint(
State(state): State<SharedState>,
axum::extract::Query(query): axum::extract::Query<FirmwareUploadQuery>,
body: axum::body::Bytes,
) -> Result<Json<serde_json::Value>, (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(