Merge 41d3378817 into df9d3b0eea
This commit is contained in:
commit
7d3f04d2f5
12
CHANGELOG.md
12
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-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
|
||||
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-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
|
||||
magnetic sensing path: scene → source synthesis (Biot–Savart, dipole,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
# ADR-095: 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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-095)
|
||||
sha2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.10"
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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-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`.
|
||||
#[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-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
|
||||
/// 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-095) ────────────────────
|
||||
|
||||
/// 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-095) — 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-095)
|
||||
.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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue