From 7113e726468a6f1a2a49e441ce7247f316ac5082 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 20 Jun 2026 10:36:26 -0400 Subject: [PATCH 1/4] Add OTA candidate cache --- docs/tdeck-feature-inventory.md | 6 +- docs/tdeck-firmware-roadmap.md | 15 +- docs/tdeck-ota-manifest.md | 63 +++++++- docs/tdeck-upgrade-path.md | 24 ++- sim/main_sim.c | 45 +++++- src/ota_fetch_sim.c | 36 +++++ src/ota_fetch_tdeck.cpp | 138 +++++++++++++++++ src/serial_cli.cpp | 57 ++++++- src/services/mesh.h | 21 +++ src/services/mesh_core.c | 59 +++++++ src/services/ota_fetch.h | 19 +++ src/services/store.c | 262 ++++++++++++++++++++++++++++++++ 12 files changed, 722 insertions(+), 23 deletions(-) create mode 100644 src/ota_fetch_sim.c create mode 100644 src/ota_fetch_tdeck.cpp create mode 100644 src/services/ota_fetch.h diff --git a/docs/tdeck-feature-inventory.md b/docs/tdeck-feature-inventory.md index ad3fb6e..fd9518e 100644 --- a/docs/tdeck-feature-inventory.md +++ b/docs/tdeck-feature-inventory.md @@ -135,10 +135,10 @@ Status labels: | Device PIN/password | Partial | `docs/tdeck-device-security.md`; serial `security status`, `security set`, `security check`, `security clear`, and `security test`; `security.cfg` stores a salted, iterated SHA-256 verifier instead of plaintext PINs | Add Settings/lock-screen UI, unlock state, forgotten-PIN recovery language, and encrypted-store integration. | | Encrypted local store | Planned | README Device security note; Phase 12 roadmap | Encrypt messages, keys, identity, and app data when password is set; migrate existing plaintext stores. | | Wi-Fi credential hardening | Functional for T-Deck, sim file-backed | T-Deck `lz_store_save_wifi/load_wifi` use ESP32 NVS with legacy `wifi.cfg` migration/removal; serial `wifi` reports `cred=nvs` without printing the password | Native simulator intentionally keeps file-backed credentials for repeatable desktop tests; broaden later if encrypted whole-store support lands. | -| OTA firmware update | Planned | Partition table and design spec | Implement download, hash verify, inactive-slot write, rollback UX. | +| OTA firmware update | Partial | Partition table, design spec, cached manifest validation, and verified OTA candidate cache | Implement manifest fetch, inactive-slot write, boot-partition switch, and rollback UX. | | Feedback Manager | Partial | A minimal service boundary records app notification requests and exposes serial `feedback status|test` plus `app notify test` diagnostics | Centralize LED, buzzer, keyboard/display feedback, DND, priority queues, and emergency behavior. | -| OTA firmware update | Partial | `docs/tdeck-ota-manifest.md`; `partitions.csv`; serial `ota status` and `ota test`; bounded cached-manifest validator rejects bad schema, board, URL, SHA-256, and oversized binaries before any updater trusts them | Implement Wi-Fi fetch/download, binary hash verify, inactive-slot write, boot-partition switch, rollback UX, update UI, and feedback routing. | -| OTA firmware update | Planned/Partial | Partition table; OTA boot health/rollback policy selftest plus serial diagnostics | Wire manifest download, SHA256 verification, inactive-slot writes, ESP32 OTA state calls, and update UI. | +| OTA firmware update | Partial | `docs/tdeck-ota-manifest.md`; `partitions.csv`; serial `ota status`, `ota fetch`, `ota stage`, `ota clear`, and `ota test`; bounded cached-manifest validator rejects bad schema, board, URL, SHA-256, and oversized binaries before any updater trusts them; verified candidate cache promotes `firmware.bin` only after exact size/SHA-256 match and preserves a prior candidate after failed staging | Implement manifest fetch, inactive-slot write, boot-partition switch, rollback UX, update UI, and feedback routing. | +| OTA firmware update | Partial | Partition table; OTA boot health/rollback policy selftest plus serial diagnostics; verified candidate download/stage/clear cache | Wire inactive-slot writes, ESP32 OTA state calls, and update UI. | | Feedback Manager | Planned | Design spec section 8 | Centralize LED, buzzer, keyboard/display feedback and DND. | | Emergency beacon | Planned | Design spec section 12, disabled Emergency UI row | Requires Feedback Manager and dual-network messaging. | | BLE companion | Partial, needs validation | NimBLE-based Meshtastic GATT service, official UUIDs, raw `ToRadio` writes, queued `FromRadio` reads, `FromNum` notifications, UI toggle, and serial selftest/status | Validate with the official Meshtastic app over BLE before calling V0.5 complete. | diff --git a/docs/tdeck-firmware-roadmap.md b/docs/tdeck-firmware-roadmap.md index e04f738..30a1046 100644 --- a/docs/tdeck-firmware-roadmap.md +++ b/docs/tdeck-firmware-roadmap.md @@ -412,16 +412,21 @@ Goal: update the OS without USB flashing. **Status:** partial. Implemented: a bounded `limitlezz.ota_manifest.v1` validator, cached manifest discovery from SD/appfs, serial `ota status`, serial -`ota test`, and native selftest coverage. Fetch/download, binary hash verify, -inactive-slot write, boot-partition switch, rollback, UI, and feedback routing -remain TODO. +`ota fetch`, `ota stage`, `ota clear`, `ota test`, verified candidate +firmware cache with exact size/SHA-256 checks, and native selftest coverage. +Manifest fetch, inactive-slot write, boot-partition switch, rollback UI, update +screen, and feedback routing remain TODO. Deliverables: - Implement firmware update manifest alongside the app catalog. Implemented: `docs/tdeck-ota-manifest.md` plus cached manifest diagnostics. -- Download firmware binary over Wi-Fi. -- Verify SHA256 before writing. +- Download firmware binary over Wi-Fi. Implemented for cached manifests: + `ota fetch` downloads `firmware_url` into a temporary candidate file and + promotes it only after exact size and SHA-256 verification. +- Verify SHA256 before writing. Implemented for the candidate cache: + `ota fetch` and `ota stage ` both verify against the cached manifest, + preserving any prior verified candidate after failure. - Write to inactive OTA partition. - Set OTA boot partition and reboot. - Support rollback if new firmware fails to mark itself healthy. Initial diff --git a/docs/tdeck-ota-manifest.md b/docs/tdeck-ota-manifest.md index 1c95c1c..8833753 100644 --- a/docs/tdeck-ota-manifest.md +++ b/docs/tdeck-ota-manifest.md @@ -1,21 +1,27 @@ # T-Deck OTA Firmware Manifest -This is the first Phase 10 OTA increment: a bounded manifest contract and -diagnostics path. It validates update metadata before any Wi-Fi downloader, -inactive-slot writer, boot-partition switch, or rollback flow trusts it. +This Phase 10 OTA slice defines a bounded manifest contract plus a verified +candidate firmware cache. It validates update metadata and candidate binaries +before any inactive-slot writer, boot-partition switch, or rollback flow trusts +them. Implemented: - `ota status` over the USB serial console. +- `ota fetch` over the USB serial console. This downloads the cached + manifest's `firmware_url` over Wi-Fi, writes a bounded temporary file, verifies + exact size and SHA-256, then atomically promotes it to the OTA cache. +- `ota stage ` over the USB serial console. This verifies a local + candidate file against the cached manifest and promotes it to the same cache. +- `ota clear` over the USB serial console. - `ota test` over the USB serial console. -- Native simulator selftest coverage for valid and invalid manifests. +- Native simulator selftest coverage for valid/invalid manifests, candidate + staging, size mismatch rejection, prior-candidate preservation, and clearing. - Cached manifest discovery from SD/local storage and the `appfs` partition. Still TODO: - fetch the manifest over Wi-Fi -- download the firmware binary -- verify the binary SHA-256 after download - write to the inactive OTA slot - set the OTA boot partition and mark the new firmware healthy - rollback UX and failure recovery @@ -32,6 +38,17 @@ The firmware looks for one cached JSON manifest at the first matching path: The native simulator uses the same layout under its data directory, for example `lzdata/ota/manifest.json` and `lzdata/appfs/ota/manifest.json`. +Verified candidates are cached under the primary data directory: + +```text +/sd/limitlezz/ota/firmware.bin +``` + +Downloads and local staging first write `firmware.bin.tmp`. A candidate is +promoted to `firmware.bin` only after exact byte-count and SHA-256 verification +against the cached manifest. A failed stage/download removes only the temporary +file and leaves any prior verified candidate intact. + ## Schema The manifest is a tiny top-level JSON object. The parser is intentionally @@ -80,6 +97,7 @@ Fresh hardware with no cached manifest: ```text lz> ota status ota manifest: no cached manifest +ota candidate: none (no candidate) ``` Valid cached manifest: @@ -88,6 +106,39 @@ Valid cached manifest: lz> ota status ota manifest: valid version=0.97.0 channel=beta board=tdeck size=1539920 source=/sd/limitlezz/ota/manifest.json firmware: https://updates.example/tdeck/0.97.0/firmware.bin +ota candidate: none (no candidate) +``` + +Verified candidate ready: + +```text +lz> ota status +ota manifest: valid version=0.97.0 channel=beta board=tdeck size=1539920 source=/sd/limitlezz/ota/manifest.json +firmware: https://updates.example/tdeck/0.97.0/firmware.bin +ota candidate: ready version=0.97.0 channel=beta size=1539920 path=/sd/limitlezz/ota/firmware.bin sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +``` + +Fetch and verify the manifest's firmware URL: + +```text +lz> ota fetch +[ok] OTA candidate downloaded and verified +ota candidate: ready version=0.97.0 channel=beta size=1539920 path=/sd/limitlezz/ota/firmware.bin sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +``` + +Stage and verify a local file: + +```text +lz> ota stage /sd/limitlezz/ota/downloads/firmware.bin +[ok] OTA candidate staged and verified +ota candidate: ready version=0.97.0 channel=beta size=1539920 path=/sd/limitlezz/ota/firmware.bin sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +``` + +Clear the candidate cache: + +```text +lz> ota clear +[ok] OTA candidate cleared ``` Built-in parser proof: diff --git a/docs/tdeck-upgrade-path.md b/docs/tdeck-upgrade-path.md index 2eb59c5..038dc13 100644 --- a/docs/tdeck-upgrade-path.md +++ b/docs/tdeck-upgrade-path.md @@ -3,7 +3,9 @@ This guide documents the upgrade path that is available today. LimitlezzOS has an OTA-capable partition layout, but over-the-air firmware updates are still roadmap work. Current releases upgrade through a USB flash of an exact release -or GitHub Actions artifact. +or GitHub Actions artifact. The firmware can now verify and cache an OTA +candidate binary, but it does not yet write that candidate to an inactive boot +slot. ## Current Support @@ -13,12 +15,12 @@ Supported today: - USB upgrade from an exact `Firmware CI` artifact - same-version reflash for recovery or validation - rollback by reflashing a previously saved known-good artifact +- OTA manifest diagnostics plus verified candidate download/staging cache - persistent SD-backed user data when the SD card and store remain intact - persistent NVS-backed Wi-Fi credentials on T-Deck hardware Not supported yet: -- downloading firmware over Wi-Fi - writing a candidate to the inactive OTA slot from the device UI - automatic rollback based on a firmware health marker - signed OTA manifests or release-channel selection @@ -149,6 +151,24 @@ If Windows loses `COM8` during the USB boot handoff, wait for the T-Deck to re-enumerate or reset/replug the device. Do not retarget the smoke test to another COM port unless that device's ownership has been confirmed. +## OTA Candidate Cache + +This is a pre-install diagnostic path, not an upgrade path yet. With a cached +manifest at `/sd/limitlezz/ota/manifest.json`, the device can download or stage +a candidate firmware image, verify its exact byte count and SHA-256, and keep it +at `/sd/limitlezz/ota/firmware.bin` for the future inactive-slot writer. + +```sh +ota status +ota fetch +ota stage /sd/limitlezz/ota/downloads/firmware.bin +ota clear +``` + +Do not claim an OTA upgrade from this evidence alone. A release still upgrades +only after the candidate is written to an inactive OTA slot, selected for boot, +confirmed healthy after reboot, and covered by rollback behavior. + ## Rollback Rollback today means reflashing a previous known-good artifact over USB. diff --git a/sim/main_sim.c b/sim/main_sim.c index 3073a12..e05e1b0 100644 --- a/sim/main_sim.c +++ b/sim/main_sim.c @@ -1166,6 +1166,11 @@ static int codec_selftest(void) { extern void lz_store_init(const char *datadir); extern bool lz_store_ota_manifest_status(lz_ota_manifest_t *out); + extern bool lz_store_ota_candidate_status(lz_ota_candidate_t *out); + extern bool lz_store_stage_ota_candidate_file(const char *source_path, + lz_ota_candidate_t *out, + char *err, int err_cap); + extern bool lz_store_clear_ota_candidate(char *err, int err_cap); extern int lz_store_ota_manifest_selftest(char *buf, int n); sim_reset_dir("lzdata_ota"); sim_mkdirs("lzdata_ota/ota"); @@ -1174,16 +1179,50 @@ static int codec_selftest(void) fputs("{\"schema\":\"limitlezz.ota_manifest.v1\",\"version\":\"0.97.0\"," "\"channel\":\"beta\",\"board\":\"tdeck\"," "\"firmware_url\":\"https://updates.limitlezz.example/tdeck/0.97.0/firmware.bin\"," - "\"sha256\":\"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\"," - "\"size\":1539920}", mf); + "\"sha256\":\"8ec52e1da1f141ae5cae9272c4d3dc24bbca01238549c6d9231ff49c1c6dda65\"," + "\"size\":27}", mf); fclose(mf); } lz_store_init("lzdata_ota"); lz_ota_manifest_t ota; CHECK(lz_store_ota_manifest_status(&ota) && ota.valid, "OTA manifest status accepts cached valid manifest"); - CHECK(strcmp(ota.version, "0.97.0") == 0 && ota.size_bytes == 1539920u, + CHECK(strcmp(ota.version, "0.97.0") == 0 && ota.size_bytes == 27u, "OTA manifest status keeps version and size"); + lz_ota_candidate_t cand; + CHECK(!lz_store_ota_candidate_status(&cand) && !cand.present && + strcmp(cand.error, "no candidate") == 0, + "OTA candidate status starts empty"); + FILE *cf = fopen("lzdata_ota/candidate.bin", "wb"); + if(cf) { + fputs("Limitlezz OTA candidate v1\n", cf); + fclose(cf); + } + char ota_err[64] = {0}; + CHECK(lz_store_stage_ota_candidate_file("lzdata_ota/candidate.bin", + &cand, ota_err, sizeof ota_err) && + cand.present && cand.valid && cand.size_bytes == 27u && + cand.size_match && cand.sha_match && + strcmp(cand.version, "0.97.0") == 0, + "OTA candidate staging verifies size and SHA"); + CHECK(lz_store_ota_candidate_status(&cand) && cand.valid && + strstr(cand.path, "ota/firmware.bin") != NULL, + "OTA candidate status reloads verified cache"); + cf = fopen("lzdata_ota/bad-candidate.bin", "wb"); + if(cf) { + fputs("bad firmware body\n", cf); + fclose(cf); + } + CHECK(!lz_store_stage_ota_candidate_file("lzdata_ota/bad-candidate.bin", + &cand, ota_err, sizeof ota_err) && + strcmp(ota_err, "size mismatch") == 0, + "OTA candidate staging rejects wrong size before install"); + CHECK(lz_store_ota_candidate_status(&cand) && cand.valid, + "OTA failed staging leaves prior candidate intact"); + CHECK(lz_store_clear_ota_candidate(ota_err, sizeof ota_err), + "OTA candidate clear succeeds"); + CHECK(!lz_store_ota_candidate_status(&cand) && !cand.present, + "OTA candidate clear removes cached firmware"); mf = fopen("lzdata_ota/ota/manifest.json", "wb"); if(mf) { diff --git a/src/ota_fetch_sim.c b/src/ota_fetch_sim.c new file mode 100644 index 0000000..cd83ed9 --- /dev/null +++ b/src/ota_fetch_sim.c @@ -0,0 +1,36 @@ +#ifdef LZ_TARGET_SIM + +#include "services/ota_fetch.h" +#include +#include + +static void ota_fetch_err(char *err, int err_cap, const char *msg) +{ + if(err && err_cap > 0) snprintf(err, (size_t)err_cap, "%s", msg); +} + +static bool ota_fetch_url_ok(const char *url) +{ + return url && + (strncmp(url, "http://", 7) == 0 || strncmp(url, "https://", 8) == 0); +} + +bool lz_ota_fetch_to_file(const char *url, const char *path, + uint32_t expected_size, + char *err, int err_cap) +{ + (void)expected_size; + ota_fetch_err(err, err_cap, ""); + if(!path || !path[0]) { + ota_fetch_err(err, err_cap, "missing target"); + return false; + } + if(!ota_fetch_url_ok(url)) { + ota_fetch_err(err, err_cap, "bad url"); + return false; + } + ota_fetch_err(err, err_cap, "fetch unavailable"); + return false; +} + +#endif diff --git a/src/ota_fetch_tdeck.cpp b/src/ota_fetch_tdeck.cpp new file mode 100644 index 0000000..cbe094c --- /dev/null +++ b/src/ota_fetch_tdeck.cpp @@ -0,0 +1,138 @@ +#ifdef LZ_TARGET_TDECK + +#include +#include +#include +#include +#include "services/mesh.h" +#include "services/ota_fetch.h" +#include "services/wifi.h" +#include +#include + +static void ota_fetch_err(char *err, int err_cap, const char *msg) +{ + if(err && err_cap > 0) snprintf(err, (size_t)err_cap, "%s", msg); +} + +static bool ota_fetch_url_ok(const char *url) +{ + return url && + (strncmp(url, "http://", 7) == 0 || strncmp(url, "https://", 8) == 0); +} + +static bool ota_fetch_read_body(HTTPClient &http, const char *path, + uint32_t expected_size, + char *err, int err_cap) +{ + int declared = http.getSize(); + if(declared > 0 && (uint32_t)declared != expected_size) { + ota_fetch_err(err, err_cap, "size mismatch"); + return false; + } + if(expected_size == 0 || expected_size > LZ_OTA_SLOT_MAX_BYTES) { + ota_fetch_err(err, err_cap, "bad size"); + return false; + } + + FILE *f = fopen(path, "wb"); + if(!f) { + ota_fetch_err(err, err_cap, "candidate write failed"); + return false; + } + + WiFiClient *stream = http.getStreamPtr(); + uint8_t buf[512]; + uint32_t total = 0; + uint32_t idle_deadline = millis() + 8000u; + bool ok = true; + while(http.connected() || stream->available()) { + int avail = stream->available(); + if(avail <= 0) { + if((int32_t)(millis() - idle_deadline) >= 0) break; + delay(1); + continue; + } + idle_deadline = millis() + 8000u; + int want = avail; + if(want > (int)sizeof buf) want = (int)sizeof buf; + int got = stream->readBytes(buf, (size_t)want); + if(got <= 0) continue; + if(UINT32_MAX - total < (uint32_t)got || total + (uint32_t)got > expected_size) { + ota_fetch_err(err, err_cap, "candidate too large"); + ok = false; + break; + } + if(fwrite(buf, 1, (size_t)got, f) != (size_t)got) { + ota_fetch_err(err, err_cap, "candidate write failed"); + ok = false; + break; + } + total += (uint32_t)got; + } + + if(fclose(f) != 0 && ok) { + ota_fetch_err(err, err_cap, "candidate write failed"); + ok = false; + } + if(ok && total != expected_size) { + ota_fetch_err(err, err_cap, "size mismatch"); + ok = false; + } + if(!ok) remove(path); + return ok; +} + +static bool ota_fetch_with_client(WiFiClient &client, const char *url, + const char *path, uint32_t expected_size, + char *err, int err_cap) +{ + HTTPClient http; + http.setTimeout(8000); + if(!http.begin(client, url)) { + ota_fetch_err(err, err_cap, "http begin failed"); + return false; + } + int code = http.GET(); + if(code != HTTP_CODE_OK) { + char msg[32]; + snprintf(msg, sizeof msg, "http %d", code); + ota_fetch_err(err, err_cap, msg); + http.end(); + return false; + } + bool ok = ota_fetch_read_body(http, path, expected_size, err, err_cap); + http.end(); + return ok; +} + +bool lz_ota_fetch_to_file(const char *url, const char *path, + uint32_t expected_size, + char *err, int err_cap) +{ + ota_fetch_err(err, err_cap, ""); + if(!path || !path[0]) { + ota_fetch_err(err, err_cap, "missing target"); + return false; + } + if(!ota_fetch_url_ok(url)) { + ota_fetch_err(err, err_cap, "bad url"); + return false; + } + if(lz_wifi_status() != LZ_WIFI_CONNECTED || WiFi.status() != WL_CONNECTED) { + ota_fetch_err(err, err_cap, "wifi offline"); + return false; + } + + remove(path); + if(strncmp(url, "https://", 8) == 0) { + WiFiClientSecure client; + client.setInsecure(); /* TODO: pin the update host before public OTA release. */ + return ota_fetch_with_client(client, url, path, expected_size, err, err_cap); + } + + WiFiClient client; + return ota_fetch_with_client(client, url, path, expected_size, err, err_cap); +} + +#endif diff --git a/src/serial_cli.cpp b/src/serial_cli.cpp index 26510bc..6f92531 100644 --- a/src/serial_cli.cpp +++ b/src/serial_cli.cpp @@ -86,11 +86,11 @@ static void cmd_help(void) " nodes [test] list heard nodes / test node DB schema\n" " send broadcast text on the channel\n" " stats radio TX/RX + airtime utilization\n" - " ota [status|test] cached OTA firmware manifest diagnostics\n" + " ota status|fetch|stage|clear|test OTA manifest/candidate diagnostics\n" " security [status|test|set |check |clear ]\n" " wifi [scan|on|off] wifi status / control\n" " settings [test] persisted settings schema diagnostics\n" - " ota boot-policy|boot-test OTA rollback policy diagnostics\n" + " ota boot-policy|boot-test OTA rollback policy diagnostics\n" " sys battery, uptime, memory\n" " power battery warning policy and current action\n" " id this node's identity\n" @@ -526,6 +526,26 @@ static void cmd_stats(void) (unsigned)st.tx_count, (unsigned)st.rx_count, st.util_pct); } +static void ota_print_candidate(void) +{ + lz_ota_candidate_t c; + lz_svc_ota_candidate_status(&c); + if(!c.present) { + Serial.printf("ota candidate: none"); + if(c.error[0]) Serial.printf(" (%s)", c.error); + Serial.println(); + } else if(!c.valid) { + Serial.printf("ota candidate: invalid path=%s size=%lu expected=%lu error=\"%s\"\n", + c.path, (unsigned long)c.size_bytes, + (unsigned long)c.expected_size_bytes, + c.error[0] ? c.error : "invalid"); + } else { + Serial.printf("ota candidate: ready version=%s channel=%s size=%lu path=%s sha=%s\n", + c.version, c.channel, (unsigned long)c.size_bytes, + c.path, c.sha256); + } +} + static void cmd_ota(char *args) { if(args && strcmp(args, "boot-test") == 0) { @@ -563,8 +583,36 @@ static void cmd_ota(char *args) Serial.println(b); return; } + if(args && strcmp(args, "fetch") == 0) { + char err[64] = {0}; + lz_ota_candidate_t c; + if(lz_svc_ota_fetch_candidate(&c, err, sizeof err)) + Serial.println("[ok] OTA candidate downloaded and verified"); + else + Serial.printf("[err] %s\n", err[0] ? err : "OTA candidate fetch failed"); + ota_print_candidate(); + return; + } + if(args && strncmp(args, "stage ", 6) == 0) { + char err[64] = {0}; + lz_ota_candidate_t c; + if(lz_svc_ota_stage_candidate_file(args + 6, &c, err, sizeof err)) + Serial.println("[ok] OTA candidate staged and verified"); + else + Serial.printf("[err] %s\n", err[0] ? err : "OTA candidate stage failed"); + ota_print_candidate(); + return; + } + if(args && strcmp(args, "clear") == 0) { + char err[64] = {0}; + if(lz_svc_clear_ota_candidate(err, sizeof err)) + Serial.println("[ok] OTA candidate cleared"); + else + Serial.printf("[err] %s\n", err[0] ? err : "OTA candidate clear failed"); + return; + } if(args && args[0] && strcmp(args, "status") != 0) { - Serial.println("usage: ota [status|test|boot-policy ...|boot-test]"); + Serial.println("usage: ota [status|fetch|stage |clear|test|boot-policy ...|boot-test]"); return; } @@ -581,7 +629,8 @@ static void cmd_ota(char *args) (unsigned long)m.size_bytes, m.source); Serial.printf("firmware: %s\n", m.firmware_url); } - } + ota_print_candidate(); +} static void cmd_security(char *args) { if(args && strcmp(args, "test") == 0) { diff --git a/src/services/mesh.h b/src/services/mesh.h index 81cb0b1..3ef94bc 100644 --- a/src/services/mesh.h +++ b/src/services/mesh.h @@ -279,6 +279,22 @@ typedef struct { char notes_url[96]; } lz_ota_manifest_t; +typedef struct { + bool present; /* candidate firmware.bin exists in the OTA cache */ + bool valid; /* present and matches the cached manifest size/hash */ + bool manifest_found; + bool manifest_valid; + bool size_match; + bool sha_match; + char path[112]; + char error[48]; + char version[24]; + char channel[16]; + char sha256[65]; + uint32_t size_bytes; + uint32_t expected_size_bytes; +} lz_ota_candidate_t; + typedef struct { bool configured; /* a device PIN verifier exists */ bool valid; /* false = security.cfg is corrupt/unsupported */ @@ -334,6 +350,11 @@ int lz_svc_app_catalog_diag(char *buf, int n); int lz_svc_app_catalog_selftest(char *buf, int n); bool lz_svc_ota_manifest_status(lz_ota_manifest_t *out); int lz_svc_ota_manifest_selftest(char *buf, int n); +bool lz_svc_ota_candidate_status(lz_ota_candidate_t *out); +bool lz_svc_ota_stage_candidate_file(const char *source_path, lz_ota_candidate_t *out, + char *err, int err_cap); +bool lz_svc_ota_fetch_candidate(lz_ota_candidate_t *out, char *err, int err_cap); +bool lz_svc_clear_ota_candidate(char *err, int err_cap); bool lz_svc_security_status(lz_security_status_t *out); bool lz_svc_security_set_pin(const char *pin, char *err, int err_cap); bool lz_svc_security_check_pin(const char *pin); diff --git a/src/services/mesh_core.c b/src/services/mesh_core.c index afd12ea..3a516d3 100644 --- a/src/services/mesh_core.c +++ b/src/services/mesh_core.c @@ -4,6 +4,7 @@ * real SX1262 driver both call the lz_core_on_* hooks below. */ #include "mesh.h" +#include "ota_fetch.h" #include #include #include @@ -40,6 +41,13 @@ int lz_store_app_catalog_diag(char *buf, int n); int lz_store_app_catalog_selftest(char *buf, int n); bool lz_store_ota_manifest_status(lz_ota_manifest_t *out); int lz_store_ota_manifest_selftest(char *buf, int n); +bool lz_store_ota_candidate_status(lz_ota_candidate_t *out); +bool lz_store_ota_candidate_tmp_path(char *out, int cap, char *err, int err_cap); +bool lz_store_discard_ota_candidate_tmp(void); +bool lz_store_commit_ota_candidate_tmp(lz_ota_candidate_t *out, char *err, int err_cap); +bool lz_store_stage_ota_candidate_file(const char *source_path, lz_ota_candidate_t *out, + char *err, int err_cap); +bool lz_store_clear_ota_candidate(char *err, int err_cap); bool lz_store_security_status(lz_security_status_t *out); bool lz_store_security_set_pin(const char *pin, char *err, int err_cap); bool lz_store_security_check_pin(const char *pin); @@ -502,6 +510,57 @@ int lz_svc_ota_manifest_selftest(char *buf, int n) return lz_store_ota_manifest_selftest(buf, n); } +bool lz_svc_ota_candidate_status(lz_ota_candidate_t *out) +{ + return lz_store_ota_candidate_status(out); +} + +bool lz_svc_ota_stage_candidate_file(const char *source_path, + lz_ota_candidate_t *out, + char *err, int err_cap) +{ + return lz_store_stage_ota_candidate_file(source_path, out, err, err_cap); +} + +bool lz_svc_ota_fetch_candidate(lz_ota_candidate_t *out, char *err, int err_cap) +{ + if(err && err_cap > 0) err[0] = 0; + lz_ota_manifest_t manifest; + if(!lz_store_ota_manifest_status(&manifest) || !manifest.valid) { + if(err && err_cap > 0) + snprintf(err, (size_t)err_cap, "%s", + manifest.error[0] ? manifest.error : "no valid manifest"); + if(out) { + memset(out, 0, sizeof *out); + out->manifest_found = manifest.found; + out->manifest_valid = manifest.valid; + snprintf(out->error, sizeof out->error, "%s", + manifest.error[0] ? manifest.error : "no valid manifest"); + } + return false; + } + + char tmp[128]; + if(!lz_store_ota_candidate_tmp_path(tmp, sizeof tmp, err, err_cap)) + return false; + if(!lz_ota_fetch_to_file(manifest.firmware_url, tmp, manifest.size_bytes, + err, err_cap)) { + lz_store_discard_ota_candidate_tmp(); + if(out) lz_store_ota_candidate_status(out); + return false; + } + if(!lz_store_commit_ota_candidate_tmp(out, err, err_cap)) { + lz_store_discard_ota_candidate_tmp(); + return false; + } + return true; +} + +bool lz_svc_clear_ota_candidate(char *err, int err_cap) +{ + return lz_store_clear_ota_candidate(err, err_cap); +} + bool lz_svc_security_status(lz_security_status_t *out) { return lz_store_security_status(out); diff --git a/src/services/ota_fetch.h b/src/services/ota_fetch.h new file mode 100644 index 0000000..1c363b4 --- /dev/null +++ b/src/services/ota_fetch.h @@ -0,0 +1,19 @@ +#ifndef LZ_OTA_FETCH_H +#define LZ_OTA_FETCH_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +bool lz_ota_fetch_to_file(const char *url, const char *path, + uint32_t expected_size, + char *err, int err_cap); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/services/store.c b/src/services/store.c index adff88e..e1f470c 100644 --- a/src/services/store.c +++ b/src/services/store.c @@ -1793,6 +1793,268 @@ bool lz_store_ota_manifest_status(lz_ota_manifest_t *out) return false; } +static void ota_candidate_paths(char *dir, size_t dir_n, + char *live, size_t live_n, + char *tmp, size_t tmp_n) +{ + if(dir && dir_n > 0) path_join(dir, dir_n, g_dir, "ota"); + if(live && live_n > 0) { + char d[128]; + path_join(d, sizeof d, g_dir, "ota"); + path_join(live, live_n, d, "firmware.bin"); + } + if(tmp && tmp_n > 0) { + char d[128]; + path_join(d, sizeof d, g_dir, "ota"); + path_join(tmp, tmp_n, d, "firmware.bin.tmp"); + } +} + +static bool ota_candidate_mkdir(char *dir, size_t dir_n, char *err, int err_cap) +{ + if(!g_persist) { + set_err(err, err_cap, "storage unavailable"); + return false; + } + ota_candidate_paths(dir, dir_n, NULL, 0, NULL, 0); + if(!path_mkdir(dir) || !path_is_dir(dir)) { + set_err(err, err_cap, "ota mkdir failed"); + return false; + } + return true; +} + +static void ota_candidate_copy_manifest(lz_ota_candidate_t *out, + const lz_ota_manifest_t *manifest) +{ + if(!out || !manifest) return; + out->manifest_found = manifest->found; + out->manifest_valid = manifest->valid; + if(manifest->valid) { + snprintf(out->version, sizeof out->version, "%s", manifest->version); + snprintf(out->channel, sizeof out->channel, "%s", manifest->channel); + out->expected_size_bytes = manifest->size_bytes; + } +} + +static bool ota_candidate_fail(lz_ota_candidate_t *out, char *err, int err_cap, + const char *msg) +{ + char safe[48]; + snprintf(safe, sizeof safe, "%s", msg ? msg : "candidate invalid"); + if(out) { + out->valid = false; + snprintf(out->error, sizeof out->error, "%s", safe); + } + set_err(err, err_cap, safe); + return false; +} + +static bool ota_candidate_check_file(const char *path, + const lz_ota_manifest_t *manifest, + lz_ota_candidate_t *out, + char *err, int err_cap) +{ + if(out) { + memset(out, 0, sizeof *out); + if(path) snprintf(out->path, sizeof out->path, "%s", path); + ota_candidate_copy_manifest(out, manifest); + } + set_err(err, err_cap, ""); + if(!path || !path[0] || !path_is_file(path)) + return ota_candidate_fail(out, err, err_cap, "no candidate"); + + if(out) out->present = true; + struct stat st; + if(stat(path, &st) != 0 || S_ISDIR(st.st_mode)) + return ota_candidate_fail(out, err, err_cap, "candidate unreadable"); + if(st.st_size < 0 || (unsigned long long)st.st_size > UINT32_MAX) + return ota_candidate_fail(out, err, err_cap, "candidate too large"); + uint32_t size = (uint32_t)st.st_size; + if(out) out->size_bytes = size; + + if(!manifest || !manifest->valid) + return ota_candidate_fail(out, err, err_cap, "no valid manifest"); + if(size != manifest->size_bytes) { + if(out) out->size_match = false; + return ota_candidate_fail(out, err, err_cap, "size mismatch"); + } + if(out) out->size_match = true; + + char actual[LZ_SHA256_HEX_LEN + 1]; + if(!lz_store_file_sha256(path, actual, sizeof actual, err, err_cap)) + return ota_candidate_fail(out, err, err_cap, err && err[0] ? err : "hash failed"); + if(out) snprintf(out->sha256, sizeof out->sha256, "%s", actual); + if(!sha256_hex_equal(actual, manifest->sha256)) { + if(out) out->sha_match = false; + return ota_candidate_fail(out, err, err_cap, "sha mismatch"); + } + if(out) { + out->sha_match = true; + out->valid = true; + out->error[0] = 0; + } + return true; +} + +bool lz_store_ota_candidate_status(lz_ota_candidate_t *out) +{ + if(!out) return false; + memset(out, 0, sizeof *out); + lz_ota_manifest_t manifest; + lz_store_ota_manifest_status(&manifest); + ota_candidate_copy_manifest(out, &manifest); + + if(!g_persist) + return ota_candidate_fail(out, NULL, 0, "storage unavailable"); + char live[128]; + ota_candidate_paths(NULL, 0, live, sizeof live, NULL, 0); + return ota_candidate_check_file(live, &manifest, out, NULL, 0); +} + +bool lz_store_ota_candidate_tmp_path(char *out, int cap, char *err, int err_cap) +{ + if(out && cap > 0) out[0] = 0; + set_err(err, err_cap, ""); + if(!out || cap <= 0) { + set_err(err, err_cap, "path buffer small"); + return false; + } + char dir[128], tmp[128]; + if(!ota_candidate_mkdir(dir, sizeof dir, err, err_cap)) return false; + ota_candidate_paths(NULL, 0, NULL, 0, tmp, sizeof tmp); + remove(tmp); + snprintf(out, (size_t)cap, "%s", tmp); + return true; +} + +bool lz_store_discard_ota_candidate_tmp(void) +{ + if(!g_persist) return false; + char tmp[128]; + ota_candidate_paths(NULL, 0, NULL, 0, tmp, sizeof tmp); + remove(tmp); + return true; +} + +bool lz_store_commit_ota_candidate_tmp(lz_ota_candidate_t *out, char *err, int err_cap) +{ + set_err(err, err_cap, ""); + lz_ota_manifest_t manifest; + if(!lz_store_ota_manifest_status(&manifest) || !manifest.valid) { + lz_store_discard_ota_candidate_tmp(); + return ota_candidate_fail(out, err, err_cap, + manifest.error[0] ? manifest.error : "no valid manifest"); + } + + char live[128], tmp[128], backup[132]; + ota_candidate_paths(NULL, 0, live, sizeof live, tmp, sizeof tmp); + snprintf(backup, sizeof backup, "%s.bak", live); + lz_ota_candidate_t staged; + if(!ota_candidate_check_file(tmp, &manifest, &staged, err, err_cap)) { + lz_store_discard_ota_candidate_tmp(); + if(out) *out = staged; + return false; + } + + bool had_live = path_is_file(live); + remove(backup); + if(had_live && rename(live, backup) != 0) { + lz_store_discard_ota_candidate_tmp(); + return ota_candidate_fail(out, err, err_cap, "candidate commit failed"); + } + if(rename(tmp, live) != 0) { + if(had_live) rename(backup, live); + lz_store_discard_ota_candidate_tmp(); + return ota_candidate_fail(out, err, err_cap, "candidate commit failed"); + } + if(had_live) remove(backup); + return lz_store_ota_candidate_status(out); +} + +bool lz_store_stage_ota_candidate_file(const char *source_path, + lz_ota_candidate_t *out, + char *err, int err_cap) +{ + set_err(err, err_cap, ""); + if(!source_path || !source_path[0]) { + return ota_candidate_fail(out, err, err_cap, "missing source"); + } + lz_ota_manifest_t manifest; + if(!lz_store_ota_manifest_status(&manifest) || !manifest.valid) { + return ota_candidate_fail(out, err, err_cap, + manifest.error[0] ? manifest.error : "no valid manifest"); + } + + char tmp[128]; + if(!lz_store_ota_candidate_tmp_path(tmp, sizeof tmp, err, err_cap)) + return ota_candidate_fail(out, err, err_cap, err && err[0] ? err : "candidate path failed"); + + FILE *src = fopen(source_path, "rb"); + if(!src) { + lz_store_discard_ota_candidate_tmp(); + return ota_candidate_fail(out, err, err_cap, "source missing"); + } + FILE *dst = fopen(tmp, "wb"); + if(!dst) { + fclose(src); + lz_store_discard_ota_candidate_tmp(); + return ota_candidate_fail(out, err, err_cap, "candidate write failed"); + } + + uint8_t buf[512]; + uint32_t total = 0; + bool ok = true; + size_t nread = 0; + while((nread = fread(buf, 1, sizeof buf, src)) > 0) { + if(UINT32_MAX - total < nread || total + (uint32_t)nread > manifest.size_bytes) { + ok = false; + set_err(err, err_cap, "candidate too large"); + break; + } + if(fwrite(buf, 1, nread, dst) != nread) { + ok = false; + set_err(err, err_cap, "candidate write failed"); + break; + } + total += (uint32_t)nread; + } + if(ferror(src) && ok) { + ok = false; + set_err(err, err_cap, "source read failed"); + } + if(fclose(dst) != 0 && ok) { + ok = false; + set_err(err, err_cap, "candidate write failed"); + } + fclose(src); + if(ok && total != manifest.size_bytes) { + ok = false; + set_err(err, err_cap, "size mismatch"); + } + if(!ok) { + lz_store_discard_ota_candidate_tmp(); + return ota_candidate_fail(out, err, err_cap, err && err[0] ? err : "candidate invalid"); + } + return lz_store_commit_ota_candidate_tmp(out, err, err_cap); +} + +bool lz_store_clear_ota_candidate(char *err, int err_cap) +{ + set_err(err, err_cap, ""); + if(!g_persist) { + set_err(err, err_cap, "storage unavailable"); + return false; + } + char live[128], tmp[128], backup[132]; + ota_candidate_paths(NULL, 0, live, sizeof live, tmp, sizeof tmp); + snprintf(backup, sizeof backup, "%s.bak", live); + remove(tmp); + remove(live); + remove(backup); + return true; +} + int lz_store_ota_manifest_selftest(char *buf, int n) { if(!buf || n <= 0) return 0; From efcc9dc71c06bc9f62e654264e13da2e6f803020 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 20 Jun 2026 19:32:23 -0400 Subject: [PATCH 2/4] Add OTA inactive slot writer --- docs/tdeck-feature-inventory.md | 8 +- docs/tdeck-firmware-roadmap.md | 14 ++- docs/tdeck-ota-manifest.md | 35 ++++-- docs/tdeck-upgrade-path.md | 22 ++-- sim/main_sim.c | 12 ++ src/ota_install_sim.c | 68 +++++++++++ src/ota_install_tdeck.cpp | 204 ++++++++++++++++++++++++++++++++ src/serial_cli.cpp | 43 ++++++- src/services/mesh.h | 15 +++ src/services/mesh_core.c | 25 ++++ src/services/ota_install.h | 20 ++++ 11 files changed, 440 insertions(+), 26 deletions(-) create mode 100644 src/ota_install_sim.c create mode 100644 src/ota_install_tdeck.cpp create mode 100644 src/services/ota_install.h diff --git a/docs/tdeck-feature-inventory.md b/docs/tdeck-feature-inventory.md index fd9518e..f8b19be 100644 --- a/docs/tdeck-feature-inventory.md +++ b/docs/tdeck-feature-inventory.md @@ -31,7 +31,7 @@ Status labels: | Feature | Status | Evidence | Gap / Next Action | | --- | --- | --- | --- | | T-Deck PlatformIO firmware target | Functional, CI-covered, needs hardware validation | `platformio.ini`, `src/main_tdeck.cpp`; `.github/workflows/firmware.yml` builds `pio run -e tdeck`, captures size output, and uploads firmware artifacts | Keep validating board profile against actual T-Deck flash/PSRAM during hardware smoke runs. | -| OTA-ready partition layout | Partial | `partitions.csv` has `ota_0`, `ota_1`, `otadata`, `config`, `appfs` | OTA service and update UI are not implemented. | +| OTA-ready partition layout | Partial | `partitions.csv` has `ota_0`, `ota_1`, `otadata`, `config`, `appfs`; serial OTA diagnostics can write a verified image to the inactive slot while leaving boot unchanged | Boot switch, rollback confirmation, and update UI are not implemented. | | Display and LVGL shell | Functional, needs validation | LovyanGFX ST7789 setup, LVGL buffers, UI screens | Use `docs/tdeck-release-checklist.md` for every release hardware pass. | | Trackball and keyboard input | Functional, needs validation | GPIO interrupts and I2C keyboard polling in `main_tdeck.cpp` | Use the release checklist for hardware input proof; add input regression tests in simulator. | | Display and LVGL shell | Functional, needs validation | LovyanGFX ST7789 setup, LVGL buffers, UI screens | Hardware flash/smoke checklist needed for every release. | @@ -135,10 +135,10 @@ Status labels: | Device PIN/password | Partial | `docs/tdeck-device-security.md`; serial `security status`, `security set`, `security check`, `security clear`, and `security test`; `security.cfg` stores a salted, iterated SHA-256 verifier instead of plaintext PINs | Add Settings/lock-screen UI, unlock state, forgotten-PIN recovery language, and encrypted-store integration. | | Encrypted local store | Planned | README Device security note; Phase 12 roadmap | Encrypt messages, keys, identity, and app data when password is set; migrate existing plaintext stores. | | Wi-Fi credential hardening | Functional for T-Deck, sim file-backed | T-Deck `lz_store_save_wifi/load_wifi` use ESP32 NVS with legacy `wifi.cfg` migration/removal; serial `wifi` reports `cred=nvs` without printing the password | Native simulator intentionally keeps file-backed credentials for repeatable desktop tests; broaden later if encrypted whole-store support lands. | -| OTA firmware update | Partial | Partition table, design spec, cached manifest validation, and verified OTA candidate cache | Implement manifest fetch, inactive-slot write, boot-partition switch, and rollback UX. | +| OTA firmware update | Partial | Partition table, design spec, cached manifest validation, verified OTA candidate cache, and serial inactive-slot writer with boot unchanged | Implement manifest fetch, boot-partition switch, and rollback UX. | | Feedback Manager | Partial | A minimal service boundary records app notification requests and exposes serial `feedback status|test` plus `app notify test` diagnostics | Centralize LED, buzzer, keyboard/display feedback, DND, priority queues, and emergency behavior. | -| OTA firmware update | Partial | `docs/tdeck-ota-manifest.md`; `partitions.csv`; serial `ota status`, `ota fetch`, `ota stage`, `ota clear`, and `ota test`; bounded cached-manifest validator rejects bad schema, board, URL, SHA-256, and oversized binaries before any updater trusts them; verified candidate cache promotes `firmware.bin` only after exact size/SHA-256 match and preserves a prior candidate after failed staging | Implement manifest fetch, inactive-slot write, boot-partition switch, rollback UX, update UI, and feedback routing. | -| OTA firmware update | Partial | Partition table; OTA boot health/rollback policy selftest plus serial diagnostics; verified candidate download/stage/clear cache | Wire inactive-slot writes, ESP32 OTA state calls, and update UI. | +| OTA firmware update | Partial | `docs/tdeck-ota-manifest.md`; `partitions.csv`; serial `ota status`, `ota fetch`, `ota stage`, `ota clear`, `ota write`, `ota write-test`, and `ota test`; bounded cached-manifest validator rejects bad schema, board, URL, SHA-256, and oversized binaries before any updater trusts them; verified candidate cache promotes `firmware.bin` only after exact size/SHA-256 match and preserves a prior candidate after failed staging; inactive-slot writer leaves boot unchanged | Implement manifest fetch, boot-partition switch, rollback UX, update UI, and feedback routing. | +| OTA firmware update | Partial | Partition table; OTA boot health/rollback policy selftest plus serial diagnostics; verified candidate download/stage/clear cache; ESP32 OTA begin/write/end path for inactive-slot writes | Wire boot partition selection, rollback state calls, and update UI. | | Feedback Manager | Planned | Design spec section 8 | Centralize LED, buzzer, keyboard/display feedback and DND. | | Emergency beacon | Planned | Design spec section 12, disabled Emergency UI row | Requires Feedback Manager and dual-network messaging. | | BLE companion | Partial, needs validation | NimBLE-based Meshtastic GATT service, official UUIDs, raw `ToRadio` writes, queued `FromRadio` reads, `FromNum` notifications, UI toggle, and serial selftest/status | Validate with the official Meshtastic app over BLE before calling V0.5 complete. | diff --git a/docs/tdeck-firmware-roadmap.md b/docs/tdeck-firmware-roadmap.md index 30a1046..439982d 100644 --- a/docs/tdeck-firmware-roadmap.md +++ b/docs/tdeck-firmware-roadmap.md @@ -412,10 +412,11 @@ Goal: update the OS without USB flashing. **Status:** partial. Implemented: a bounded `limitlezz.ota_manifest.v1` validator, cached manifest discovery from SD/appfs, serial `ota status`, serial -`ota fetch`, `ota stage`, `ota clear`, `ota test`, verified candidate -firmware cache with exact size/SHA-256 checks, and native selftest coverage. -Manifest fetch, inactive-slot write, boot-partition switch, rollback UI, update -screen, and feedback routing remain TODO. +`ota fetch`, `ota stage`, `ota clear`, `ota write`, `ota write-test`, `ota test`, +verified candidate firmware cache with exact size/SHA-256 checks, inactive-slot +write without boot selection, and native selftest coverage. Manifest fetch, +boot-partition switch, rollback UI, update screen, and feedback routing remain +TODO. Deliverables: @@ -427,7 +428,10 @@ Deliverables: - Verify SHA256 before writing. Implemented for the candidate cache: `ota fetch` and `ota stage ` both verify against the cached manifest, preserving any prior verified candidate after failure. -- Write to inactive OTA partition. +- Write to inactive OTA partition. Implemented as a guarded serial path: + `ota write` writes a verified cached candidate and `ota write-test` copies + the running image for COM8 hardware smoke proof. Both leave boot selection + unchanged. - Set OTA boot partition and reboot. - Support rollback if new firmware fails to mark itself healthy. Initial implementation: a native-tested OTA boot policy chooses clean, pending diff --git a/docs/tdeck-ota-manifest.md b/docs/tdeck-ota-manifest.md index 8833753..e08ff17 100644 --- a/docs/tdeck-ota-manifest.md +++ b/docs/tdeck-ota-manifest.md @@ -1,9 +1,9 @@ # T-Deck OTA Firmware Manifest -This Phase 10 OTA slice defines a bounded manifest contract plus a verified -candidate firmware cache. It validates update metadata and candidate binaries -before any inactive-slot writer, boot-partition switch, or rollback flow trusts -them. +This Phase 10 OTA slice defines a bounded manifest contract, a verified +candidate firmware cache, and an inactive-slot writer. It validates update +metadata and candidate binaries before any boot-partition switch or rollback +flow trusts them. Implemented: @@ -14,16 +14,21 @@ Implemented: - `ota stage ` over the USB serial console. This verifies a local candidate file against the cached manifest and promotes it to the same cache. - `ota clear` over the USB serial console. +- `ota write` over the USB serial console. This writes the verified candidate + cache into the inactive OTA slot and leaves the boot partition unchanged. +- `ota write-test` over the USB serial console. This copies the currently + running valid firmware image into the inactive OTA slot as a hardware smoke + path, also leaving the boot partition unchanged. - `ota test` over the USB serial console. - Native simulator selftest coverage for valid/invalid manifests, candidate - staging, size mismatch rejection, prior-candidate preservation, and clearing. + staging, inactive-slot writer dispatch, size mismatch rejection, + prior-candidate preservation, and clearing. - Cached manifest discovery from SD/local storage and the `appfs` partition. Still TODO: - fetch the manifest over Wi-Fi -- write to the inactive OTA slot -- set the OTA boot partition and mark the new firmware healthy +- set the OTA boot partition, reboot, and mark the new firmware healthy - rollback UX and failure recovery - user-facing update screen and Feedback Manager progress routing @@ -134,6 +139,22 @@ lz> ota stage /sd/limitlezz/ota/downloads/firmware.bin ota candidate: ready version=0.97.0 channel=beta size=1539920 path=/sd/limitlezz/ota/firmware.bin sha=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef ``` +Write the verified candidate to the inactive OTA slot without changing boot: + +```text +lz> ota write +[ok] OTA candidate written to inactive slot; boot unchanged +ota write: ok source=candidate running=ota_0 inactive=ota_1 addr=0x00510000 size=5242880 bytes=1539920 boot-set=no +``` + +Hardware smoke the same inactive-slot writer without preparing an SD candidate: + +```text +lz> ota write-test +[ok] OTA inactive-slot write selftest passed; boot unchanged +ota write: ok source=running-copy running=ota_0 inactive=ota_1 addr=0x00510000 size=5242880 bytes=1539920 boot-set=no +``` + Clear the candidate cache: ```text diff --git a/docs/tdeck-upgrade-path.md b/docs/tdeck-upgrade-path.md index 038dc13..81eb78f 100644 --- a/docs/tdeck-upgrade-path.md +++ b/docs/tdeck-upgrade-path.md @@ -4,8 +4,8 @@ This guide documents the upgrade path that is available today. LimitlezzOS has an OTA-capable partition layout, but over-the-air firmware updates are still roadmap work. Current releases upgrade through a USB flash of an exact release or GitHub Actions artifact. The firmware can now verify and cache an OTA -candidate binary, but it does not yet write that candidate to an inactive boot -slot. +candidate binary and write it to the inactive OTA slot from serial diagnostics, +but it does not yet select that slot for boot or mark the new firmware healthy. ## Current Support @@ -16,12 +16,13 @@ Supported today: - same-version reflash for recovery or validation - rollback by reflashing a previously saved known-good artifact - OTA manifest diagnostics plus verified candidate download/staging cache +- serial inactive-slot write diagnostics that leave boot unchanged - persistent SD-backed user data when the SD card and store remain intact - persistent NVS-backed Wi-Fi credentials on T-Deck hardware Not supported yet: -- writing a candidate to the inactive OTA slot from the device UI +- selecting an OTA candidate for boot from the device UI - automatic rollback based on a firmware health marker - signed OTA manifests or release-channel selection @@ -151,23 +152,28 @@ If Windows loses `COM8` during the USB boot handoff, wait for the T-Deck to re-enumerate or reset/replug the device. Do not retarget the smoke test to another COM port unless that device's ownership has been confirmed. -## OTA Candidate Cache +## OTA Candidate Cache And Slot Write -This is a pre-install diagnostic path, not an upgrade path yet. With a cached +This is a pre-boot diagnostic path, not a full upgrade path yet. With a cached manifest at `/sd/limitlezz/ota/manifest.json`, the device can download or stage a candidate firmware image, verify its exact byte count and SHA-256, and keep it -at `/sd/limitlezz/ota/firmware.bin` for the future inactive-slot writer. +at `/sd/limitlezz/ota/firmware.bin`. The serial writer can then copy that +verified candidate into the inactive OTA slot, or copy the currently running +image into the inactive slot for a hardware smoke test. Both paths leave the +boot partition unchanged. ```sh ota status ota fetch ota stage /sd/limitlezz/ota/downloads/firmware.bin +ota write +ota write-test ota clear ``` Do not claim an OTA upgrade from this evidence alone. A release still upgrades -only after the candidate is written to an inactive OTA slot, selected for boot, -confirmed healthy after reboot, and covered by rollback behavior. +only after the candidate is selected for boot, confirmed healthy after reboot, +and covered by rollback behavior. ## Rollback diff --git a/sim/main_sim.c b/sim/main_sim.c index e05e1b0..7f69ff7 100644 --- a/sim/main_sim.c +++ b/sim/main_sim.c @@ -1171,6 +1171,8 @@ static int codec_selftest(void) lz_ota_candidate_t *out, char *err, int err_cap); extern bool lz_store_clear_ota_candidate(char *err, int err_cap); + extern bool lz_svc_ota_write_candidate(lz_ota_install_t *out, char *err, int err_cap); + extern bool lz_svc_ota_write_selftest(lz_ota_install_t *out, char *err, int err_cap); extern int lz_store_ota_manifest_selftest(char *buf, int n); sim_reset_dir("lzdata_ota"); sim_mkdirs("lzdata_ota/ota"); @@ -1208,6 +1210,16 @@ static int codec_selftest(void) CHECK(lz_store_ota_candidate_status(&cand) && cand.valid && strstr(cand.path, "ota/firmware.bin") != NULL, "OTA candidate status reloads verified cache"); + lz_ota_install_t inst; + CHECK(lz_svc_ota_write_candidate(&inst, ota_err, sizeof ota_err) && + inst.ok && inst.candidate_valid && !inst.boot_partition_set && + inst.bytes_written == 27u && + strcmp(inst.partition_label, "sim_ota_1") == 0, + "OTA candidate writer targets inactive slot without switching boot"); + CHECK(lz_svc_ota_write_selftest(&inst, ota_err, sizeof ota_err) && + inst.ok && inst.copied_running_image && + !inst.boot_partition_set && inst.bytes_written > 0, + "OTA inactive-slot write selftest keeps boot unchanged"); cf = fopen("lzdata_ota/bad-candidate.bin", "wb"); if(cf) { fputs("bad firmware body\n", cf); diff --git a/src/ota_install_sim.c b/src/ota_install_sim.c new file mode 100644 index 0000000..d86bd26 --- /dev/null +++ b/src/ota_install_sim.c @@ -0,0 +1,68 @@ +#ifdef LZ_TARGET_SIM + +#include "services/ota_install.h" +#include +#include +#include + +static void ota_install_err(char *err, int err_cap, const char *msg) +{ + if(err && err_cap > 0) snprintf(err, (size_t)err_cap, "%s", msg ? msg : ""); +} + +static bool sim_install_fail(lz_ota_install_t *out, char *err, int err_cap, + const char *msg) +{ + char msg_copy[48]; + snprintf(msg_copy, sizeof msg_copy, "%s", msg ? msg : "ota install failed"); + ota_install_err(err, err_cap, msg_copy); + if(out) { + out->ok = false; + snprintf(out->error, sizeof out->error, "%s", msg_copy); + } + return false; +} + +bool lz_ota_install_file_to_inactive(const char *path, uint32_t expected_size, + lz_ota_install_t *out, char *err, int err_cap) +{ + ota_install_err(err, err_cap, ""); + if(out) memset(out, 0, sizeof *out); + if(!path || !path[0]) return sim_install_fail(out, err, err_cap, "missing candidate"); + if(expected_size == 0 || expected_size > LZ_OTA_SLOT_MAX_BYTES) + return sim_install_fail(out, err, err_cap, "bad candidate size"); + + struct stat st; + if(stat(path, &st) != 0 || st.st_size < 0) + return sim_install_fail(out, err, err_cap, "candidate unreadable"); + if((uint32_t)st.st_size != expected_size) + return sim_install_fail(out, err, err_cap, "size mismatch"); + + if(out) { + out->ok = true; + out->candidate_valid = true; + snprintf(out->partition_label, sizeof out->partition_label, "sim_ota_1"); + snprintf(out->running_label, sizeof out->running_label, "sim_ota_0"); + out->bytes_written = expected_size; + out->partition_size = LZ_OTA_SLOT_MAX_BYTES; + } + return true; +} + +bool lz_ota_install_running_copy_test(lz_ota_install_t *out, char *err, int err_cap) +{ + ota_install_err(err, err_cap, ""); + if(out) { + memset(out, 0, sizeof *out); + out->ok = true; + out->candidate_valid = true; + out->copied_running_image = true; + snprintf(out->partition_label, sizeof out->partition_label, "sim_ota_1"); + snprintf(out->running_label, sizeof out->running_label, "sim_ota_0"); + out->bytes_written = 1536; + out->partition_size = LZ_OTA_SLOT_MAX_BYTES; + } + return true; +} + +#endif diff --git a/src/ota_install_tdeck.cpp b/src/ota_install_tdeck.cpp new file mode 100644 index 0000000..732724a --- /dev/null +++ b/src/ota_install_tdeck.cpp @@ -0,0 +1,204 @@ +#ifdef LZ_TARGET_TDECK + +#include "services/ota_install.h" +#include +#include +#include +#include +#include +#include +#include + +static void ota_install_err(char *err, int err_cap, const char *msg) +{ + if(err && err_cap > 0) snprintf(err, (size_t)err_cap, "%s", msg ? msg : ""); +} + +static bool install_fail(lz_ota_install_t *out, char *err, int err_cap, + const char *msg) +{ + char msg_copy[48]; + snprintf(msg_copy, sizeof msg_copy, "%s", msg ? msg : "ota install failed"); + ota_install_err(err, err_cap, msg_copy); + if(out) { + out->ok = false; + snprintf(out->error, sizeof out->error, "%s", msg_copy); + } + return false; +} + +static void fill_partitions(lz_ota_install_t *out, const esp_partition_t *running, + const esp_partition_t *update) +{ + if(!out) return; + if(running) snprintf(out->running_label, sizeof out->running_label, "%s", running->label); + if(update) { + snprintf(out->partition_label, sizeof out->partition_label, "%s", update->label); + out->partition_address = update->address; + out->partition_size = update->size; + } +} + +static bool write_stream_to_partition(FILE *f, uint32_t expected_size, + lz_ota_install_t *out, char *err, int err_cap) +{ + if(!f) return install_fail(out, err, err_cap, "candidate unreadable"); + if(expected_size == 0 || expected_size > LZ_OTA_SLOT_MAX_BYTES) + return install_fail(out, err, err_cap, "bad candidate size"); + + const esp_partition_t *running = esp_ota_get_running_partition(); + const esp_partition_t *update = esp_ota_get_next_update_partition(NULL); + fill_partitions(out, running, update); + if(!running || !update) + return install_fail(out, err, err_cap, "ota partition unavailable"); + if(expected_size > update->size) + return install_fail(out, err, err_cap, "candidate too large"); + + esp_ota_handle_t handle = 0; + esp_err_t e = esp_ota_begin(update, expected_size, &handle); + if(e != ESP_OK) + return install_fail(out, err, err_cap, "ota begin failed"); + + uint8_t *buf = (uint8_t *)malloc(1024); + if(!buf) { + esp_ota_abort(handle); + return install_fail(out, err, err_cap, "ota buffer unavailable"); + } + + bool ok = true; + uint32_t total = 0; + while(total < expected_size) { + uint32_t left = expected_size - total; + size_t want = left < 1024u ? (size_t)left : 1024u; + size_t got = fread(buf, 1, want, f); + if(got == 0) { + ok = false; + ota_install_err(err, err_cap, ferror(f) ? "candidate read failed" : "size mismatch"); + break; + } + e = esp_ota_write(handle, buf, got); + if(e != ESP_OK) { + ok = false; + ota_install_err(err, err_cap, "ota write failed"); + break; + } + total += (uint32_t)got; + delay(0); + } + free(buf); + + if(ok) { + int extra = fgetc(f); + if(extra != EOF) { + ok = false; + ota_install_err(err, err_cap, "candidate too large"); + } + } + + if(!ok) { + esp_ota_abort(handle); + return install_fail(out, err, err_cap, err && err[0] ? err : "ota write failed"); + } + + e = esp_ota_end(handle); + if(e != ESP_OK) + return install_fail(out, err, err_cap, "ota image invalid"); + + if(out) { + out->ok = true; + out->candidate_valid = true; + out->boot_partition_set = false; + out->bytes_written = total; + out->error[0] = 0; + } + return true; +} + +bool lz_ota_install_file_to_inactive(const char *path, uint32_t expected_size, + lz_ota_install_t *out, char *err, int err_cap) +{ + ota_install_err(err, err_cap, ""); + if(out) memset(out, 0, sizeof *out); + if(!path || !path[0]) return install_fail(out, err, err_cap, "missing candidate"); + + FILE *f = fopen(path, "rb"); + if(!f) return install_fail(out, err, err_cap, "candidate unreadable"); + bool ok = write_stream_to_partition(f, expected_size, out, err, err_cap); + fclose(f); + return ok; +} + +bool lz_ota_install_running_copy_test(lz_ota_install_t *out, char *err, int err_cap) +{ + ota_install_err(err, err_cap, ""); + if(out) memset(out, 0, sizeof *out); + + const esp_partition_t *running = esp_ota_get_running_partition(); + const esp_partition_t *update = esp_ota_get_next_update_partition(NULL); + fill_partitions(out, running, update); + if(!running || !update) + return install_fail(out, err, err_cap, "ota partition unavailable"); + + esp_partition_pos_t pos; + pos.offset = running->address; + pos.size = running->size; + esp_image_metadata_t meta; + if(esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &pos, &meta) != ESP_OK || + meta.image_len == 0 || meta.image_len > running->size || + meta.image_len > update->size) + return install_fail(out, err, err_cap, "running image invalid"); + + esp_ota_handle_t handle = 0; + esp_err_t e = esp_ota_begin(update, meta.image_len, &handle); + if(e != ESP_OK) + return install_fail(out, err, err_cap, "ota begin failed"); + + uint8_t *buf = (uint8_t *)malloc(1024); + if(!buf) { + esp_ota_abort(handle); + return install_fail(out, err, err_cap, "ota buffer unavailable"); + } + + bool ok = true; + uint32_t total = 0; + while(total < meta.image_len) { + uint32_t left = meta.image_len - total; + size_t want = left < 1024u ? (size_t)left : 1024u; + e = esp_partition_read(running, total, buf, want); + if(e != ESP_OK) { + ok = false; + ota_install_err(err, err_cap, "running image read failed"); + break; + } + e = esp_ota_write(handle, buf, want); + if(e != ESP_OK) { + ok = false; + ota_install_err(err, err_cap, "ota write failed"); + break; + } + total += (uint32_t)want; + delay(0); + } + free(buf); + + if(!ok) { + esp_ota_abort(handle); + return install_fail(out, err, err_cap, err && err[0] ? err : "ota write failed"); + } + + e = esp_ota_end(handle); + if(e != ESP_OK) + return install_fail(out, err, err_cap, "ota image invalid"); + + if(out) { + out->ok = true; + out->candidate_valid = true; + out->copied_running_image = true; + out->boot_partition_set = false; + out->bytes_written = total; + out->error[0] = 0; + } + return true; +} + +#endif diff --git a/src/serial_cli.cpp b/src/serial_cli.cpp index 6f92531..8ebf6f6 100644 --- a/src/serial_cli.cpp +++ b/src/serial_cli.cpp @@ -86,7 +86,7 @@ static void cmd_help(void) " nodes [test] list heard nodes / test node DB schema\n" " send broadcast text on the channel\n" " stats radio TX/RX + airtime utilization\n" - " ota status|fetch|stage|clear|test OTA manifest/candidate diagnostics\n" + " ota status|fetch|stage|clear|write|write-test|test OTA manifest/candidate diagnostics\n" " security [status|test|set |check |clear ]\n" " wifi [scan|on|off] wifi status / control\n" " settings [test] persisted settings schema diagnostics\n" @@ -546,6 +546,25 @@ static void ota_print_candidate(void) } } +static void ota_print_install(const lz_ota_install_t *inst) +{ + if(!inst) return; + if(!inst->ok) { + Serial.printf("ota write: failed"); + if(inst->error[0]) Serial.printf(" error=\"%s\"", inst->error); + Serial.println(); + return; + } + Serial.printf("ota write: ok source=%s running=%s inactive=%s addr=0x%08lx size=%lu bytes=%lu boot-set=%s\n", + inst->copied_running_image ? "running-copy" : "candidate", + inst->running_label[0] ? inst->running_label : "?", + inst->partition_label[0] ? inst->partition_label : "?", + (unsigned long)inst->partition_address, + (unsigned long)inst->partition_size, + (unsigned long)inst->bytes_written, + inst->boot_partition_set ? "yes" : "no"); +} + static void cmd_ota(char *args) { if(args && strcmp(args, "boot-test") == 0) { @@ -611,8 +630,28 @@ static void cmd_ota(char *args) Serial.printf("[err] %s\n", err[0] ? err : "OTA candidate clear failed"); return; } + if(args && strcmp(args, "write") == 0) { + char err[64] = {0}; + lz_ota_install_t inst; + if(lz_svc_ota_write_candidate(&inst, err, sizeof err)) + Serial.println("[ok] OTA candidate written to inactive slot; boot unchanged"); + else + Serial.printf("[err] %s\n", err[0] ? err : "OTA candidate write failed"); + ota_print_install(&inst); + return; + } + if(args && strcmp(args, "write-test") == 0) { + char err[64] = {0}; + lz_ota_install_t inst; + if(lz_svc_ota_write_selftest(&inst, err, sizeof err)) + Serial.println("[ok] OTA inactive-slot write selftest passed; boot unchanged"); + else + Serial.printf("[err] %s\n", err[0] ? err : "OTA write selftest failed"); + ota_print_install(&inst); + return; + } if(args && args[0] && strcmp(args, "status") != 0) { - Serial.println("usage: ota [status|fetch|stage |clear|test|boot-policy ...|boot-test]"); + Serial.println("usage: ota [status|fetch|stage |clear|write|write-test|test|boot-policy ...|boot-test]"); return; } diff --git a/src/services/mesh.h b/src/services/mesh.h index 3ef94bc..306397f 100644 --- a/src/services/mesh.h +++ b/src/services/mesh.h @@ -295,6 +295,19 @@ typedef struct { uint32_t expected_size_bytes; } lz_ota_candidate_t; +typedef struct { + bool ok; + bool candidate_valid; + bool copied_running_image; /* hardware smoke path: current valid app -> inactive slot */ + bool boot_partition_set; /* false until the explicit boot-switch slice lands */ + char partition_label[17]; + char running_label[17]; + char error[48]; + uint32_t bytes_written; + uint32_t partition_address; + uint32_t partition_size; +} lz_ota_install_t; + typedef struct { bool configured; /* a device PIN verifier exists */ bool valid; /* false = security.cfg is corrupt/unsupported */ @@ -355,6 +368,8 @@ bool lz_svc_ota_stage_candidate_file(const char *source_path, lz_ota_candidate_t char *err, int err_cap); bool lz_svc_ota_fetch_candidate(lz_ota_candidate_t *out, char *err, int err_cap); bool lz_svc_clear_ota_candidate(char *err, int err_cap); +bool lz_svc_ota_write_candidate(lz_ota_install_t *out, char *err, int err_cap); +bool lz_svc_ota_write_selftest(lz_ota_install_t *out, char *err, int err_cap); bool lz_svc_security_status(lz_security_status_t *out); bool lz_svc_security_set_pin(const char *pin, char *err, int err_cap); bool lz_svc_security_check_pin(const char *pin); diff --git a/src/services/mesh_core.c b/src/services/mesh_core.c index 3a516d3..bfe0f58 100644 --- a/src/services/mesh_core.c +++ b/src/services/mesh_core.c @@ -4,6 +4,7 @@ * real SX1262 driver both call the lz_core_on_* hooks below. */ #include "mesh.h" +#include "ota_install.h" #include "ota_fetch.h" #include #include @@ -561,6 +562,30 @@ bool lz_svc_clear_ota_candidate(char *err, int err_cap) return lz_store_clear_ota_candidate(err, err_cap); } +bool lz_svc_ota_write_candidate(lz_ota_install_t *out, char *err, int err_cap) +{ + if(err && err_cap > 0) err[0] = 0; + if(out) memset(out, 0, sizeof *out); + + lz_ota_candidate_t c; + if(!lz_store_ota_candidate_status(&c) || !c.valid) { + const char *msg = c.error[0] ? c.error : "no verified candidate"; + if(err && err_cap > 0) snprintf(err, (size_t)err_cap, "%s", msg); + if(out) { + out->candidate_valid = false; + snprintf(out->error, sizeof out->error, "%s", msg); + } + return false; + } + + return lz_ota_install_file_to_inactive(c.path, c.size_bytes, out, err, err_cap); +} + +bool lz_svc_ota_write_selftest(lz_ota_install_t *out, char *err, int err_cap) +{ + return lz_ota_install_running_copy_test(out, err, err_cap); +} + bool lz_svc_security_status(lz_security_status_t *out) { return lz_store_security_status(out); diff --git a/src/services/ota_install.h b/src/services/ota_install.h new file mode 100644 index 0000000..f26bddc --- /dev/null +++ b/src/services/ota_install.h @@ -0,0 +1,20 @@ +#ifndef LZ_OTA_INSTALL_H +#define LZ_OTA_INSTALL_H + +#include +#include +#include "mesh.h" + +#ifdef __cplusplus +extern "C" { +#endif + +bool lz_ota_install_file_to_inactive(const char *path, uint32_t expected_size, + lz_ota_install_t *out, char *err, int err_cap); +bool lz_ota_install_running_copy_test(lz_ota_install_t *out, char *err, int err_cap); + +#ifdef __cplusplus +} +#endif + +#endif From 1bf7aa71619e2487a4aa10162cdd3f1316dd8e49 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 20 Jun 2026 19:39:45 -0400 Subject: [PATCH 3/4] Support legacy esptool smoke flashing --- scripts/tdeck_smoke.py | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/scripts/tdeck_smoke.py b/scripts/tdeck_smoke.py index 06d493d..ad4c295 100644 --- a/scripts/tdeck_smoke.py +++ b/scripts/tdeck_smoke.py @@ -74,6 +74,35 @@ def find_esptool_cmd() -> list[str]: ) +def esptool_flash_syntax(esptool_cmd: list[str]) -> dict[str, str]: + probe = subprocess.run( + [*esptool_cmd, "--help"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + help_text = probe.stdout or "" + modern = "write-flash" in help_text or "default-reset" in help_text + if modern: + return { + "before": "default-reset", + "after": "hard-reset", + "write_flash": "write-flash", + "flash_mode": "--flash-mode", + "flash_freq": "--flash-freq", + "flash_size": "--flash-size", + } + return { + "before": "default_reset", + "after": "hard_reset", + "write_flash": "write_flash", + "flash_mode": "--flash_mode", + "flash_freq": "--flash_freq", + "flash_size": "--flash_size", + } + + def find_boot_app0(artifact_dir: Path | None = None) -> Path: if artifact_dir is not None: bundled = artifact_dir / "boot_app0.bin" @@ -105,6 +134,7 @@ def require_artifacts(project_dir: Path, env_name: str, artifact_dir: Path | Non def nostub_upload(project_dir: Path, env_name: str, port: str, baud: int, artifact_dir: Path | None) -> None: bootloader, partitions, boot_app0, firmware = require_artifacts(project_dir, env_name, artifact_dir) esptool_cmd = find_esptool_cmd() + syntax = esptool_flash_syntax(esptool_cmd) run( [ *esptool_cmd, @@ -115,16 +145,16 @@ def nostub_upload(project_dir: Path, env_name: str, port: str, baud: int, artifa "--baud", str(baud), "--before", - "default-reset", + syntax["before"], "--after", - "hard-reset", + syntax["after"], "--no-stub", - "write-flash", - "--flash-mode", + syntax["write_flash"], + syntax["flash_mode"], "dio", - "--flash-freq", + syntax["flash_freq"], "80m", - "--flash-size", + syntax["flash_size"], "16MB", "0x0", str(bootloader), From 3be4249fcce5b1732364b17f6bf2bc9a15cd319a Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 20 Jun 2026 20:04:55 -0400 Subject: [PATCH 4/4] Add OTA boot slot diagnostics --- docs/tdeck-feature-inventory.md | 8 +-- docs/tdeck-firmware-roadmap.md | 17 ++++--- docs/tdeck-ota-manifest.md | 35 ++++++++++++- docs/tdeck-upgrade-path.md | 14 ++++-- sim/main_sim.c | 19 +++++++ src/ota_install_sim.c | 61 ++++++++++++++++++++++ src/ota_install_tdeck.cpp | 89 +++++++++++++++++++++++++++++++++ src/serial_cli.cpp | 56 ++++++++++++++++++++- src/services/mesh.h | 19 +++++++ src/services/mesh_core.c | 24 +++++++++ src/services/ota_install.h | 3 ++ 11 files changed, 327 insertions(+), 18 deletions(-) diff --git a/docs/tdeck-feature-inventory.md b/docs/tdeck-feature-inventory.md index f8b19be..8e6582d 100644 --- a/docs/tdeck-feature-inventory.md +++ b/docs/tdeck-feature-inventory.md @@ -31,7 +31,7 @@ Status labels: | Feature | Status | Evidence | Gap / Next Action | | --- | --- | --- | --- | | T-Deck PlatformIO firmware target | Functional, CI-covered, needs hardware validation | `platformio.ini`, `src/main_tdeck.cpp`; `.github/workflows/firmware.yml` builds `pio run -e tdeck`, captures size output, and uploads firmware artifacts | Keep validating board profile against actual T-Deck flash/PSRAM during hardware smoke runs. | -| OTA-ready partition layout | Partial | `partitions.csv` has `ota_0`, `ota_1`, `otadata`, `config`, `appfs`; serial OTA diagnostics can write a verified image to the inactive slot while leaving boot unchanged | Boot switch, rollback confirmation, and update UI are not implemented. | +| OTA-ready partition layout | Partial | `partitions.csv` has `ota_0`, `ota_1`, `otadata`, `config`, `appfs`; serial OTA diagnostics can write a verified image to the inactive slot and select that slot for next boot | Rollback confirmation and update UI are not implemented. | | Display and LVGL shell | Functional, needs validation | LovyanGFX ST7789 setup, LVGL buffers, UI screens | Use `docs/tdeck-release-checklist.md` for every release hardware pass. | | Trackball and keyboard input | Functional, needs validation | GPIO interrupts and I2C keyboard polling in `main_tdeck.cpp` | Use the release checklist for hardware input proof; add input regression tests in simulator. | | Display and LVGL shell | Functional, needs validation | LovyanGFX ST7789 setup, LVGL buffers, UI screens | Hardware flash/smoke checklist needed for every release. | @@ -135,10 +135,10 @@ Status labels: | Device PIN/password | Partial | `docs/tdeck-device-security.md`; serial `security status`, `security set`, `security check`, `security clear`, and `security test`; `security.cfg` stores a salted, iterated SHA-256 verifier instead of plaintext PINs | Add Settings/lock-screen UI, unlock state, forgotten-PIN recovery language, and encrypted-store integration. | | Encrypted local store | Planned | README Device security note; Phase 12 roadmap | Encrypt messages, keys, identity, and app data when password is set; migrate existing plaintext stores. | | Wi-Fi credential hardening | Functional for T-Deck, sim file-backed | T-Deck `lz_store_save_wifi/load_wifi` use ESP32 NVS with legacy `wifi.cfg` migration/removal; serial `wifi` reports `cred=nvs` without printing the password | Native simulator intentionally keeps file-backed credentials for repeatable desktop tests; broaden later if encrypted whole-store support lands. | -| OTA firmware update | Partial | Partition table, design spec, cached manifest validation, verified OTA candidate cache, and serial inactive-slot writer with boot unchanged | Implement manifest fetch, boot-partition switch, and rollback UX. | +| OTA firmware update | Partial | Partition table, design spec, cached manifest validation, verified OTA candidate cache, serial inactive-slot writer, and serial boot-slot selection/mark-valid diagnostics | Implement manifest fetch, reboot orchestration, and rollback UX. | | Feedback Manager | Partial | A minimal service boundary records app notification requests and exposes serial `feedback status|test` plus `app notify test` diagnostics | Centralize LED, buzzer, keyboard/display feedback, DND, priority queues, and emergency behavior. | -| OTA firmware update | Partial | `docs/tdeck-ota-manifest.md`; `partitions.csv`; serial `ota status`, `ota fetch`, `ota stage`, `ota clear`, `ota write`, `ota write-test`, and `ota test`; bounded cached-manifest validator rejects bad schema, board, URL, SHA-256, and oversized binaries before any updater trusts them; verified candidate cache promotes `firmware.bin` only after exact size/SHA-256 match and preserves a prior candidate after failed staging; inactive-slot writer leaves boot unchanged | Implement manifest fetch, boot-partition switch, rollback UX, update UI, and feedback routing. | -| OTA firmware update | Partial | Partition table; OTA boot health/rollback policy selftest plus serial diagnostics; verified candidate download/stage/clear cache; ESP32 OTA begin/write/end path for inactive-slot writes | Wire boot partition selection, rollback state calls, and update UI. | +| OTA firmware update | Partial | `docs/tdeck-ota-manifest.md`; `partitions.csv`; serial `ota status`, `ota fetch`, `ota stage`, `ota clear`, `ota write`, `ota write-test`, `ota slot-status`, `ota set-test-boot`, `ota mark-valid`, and `ota test`; bounded cached-manifest validator rejects bad schema, board, URL, SHA-256, and oversized binaries before any updater trusts them; verified candidate cache promotes `firmware.bin` only after exact size/SHA-256 match and preserves a prior candidate after failed staging; inactive-slot writer and boot selection are available as diagnostics | Implement manifest fetch, rollback UX, update UI, and feedback routing. | +| OTA firmware update | Partial | Partition table; OTA boot health/rollback policy selftest plus serial diagnostics; verified candidate download/stage/clear cache; ESP32 OTA begin/write/end path for inactive-slot writes; ESP32 boot partition selection and mark-valid diagnostics | Wire rollback failure handling, reboot UX, and update UI. | | Feedback Manager | Planned | Design spec section 8 | Centralize LED, buzzer, keyboard/display feedback and DND. | | Emergency beacon | Planned | Design spec section 12, disabled Emergency UI row | Requires Feedback Manager and dual-network messaging. | | BLE companion | Partial, needs validation | NimBLE-based Meshtastic GATT service, official UUIDs, raw `ToRadio` writes, queued `FromRadio` reads, `FromNum` notifications, UI toggle, and serial selftest/status | Validate with the official Meshtastic app over BLE before calling V0.5 complete. | diff --git a/docs/tdeck-firmware-roadmap.md b/docs/tdeck-firmware-roadmap.md index 439982d..5b61214 100644 --- a/docs/tdeck-firmware-roadmap.md +++ b/docs/tdeck-firmware-roadmap.md @@ -413,10 +413,11 @@ Goal: update the OS without USB flashing. **Status:** partial. Implemented: a bounded `limitlezz.ota_manifest.v1` validator, cached manifest discovery from SD/appfs, serial `ota status`, serial `ota fetch`, `ota stage`, `ota clear`, `ota write`, `ota write-test`, `ota test`, -verified candidate firmware cache with exact size/SHA-256 checks, inactive-slot -write without boot selection, and native selftest coverage. Manifest fetch, -boot-partition switch, rollback UI, update screen, and feedback routing remain -TODO. +`ota slot-status`, `ota set-test-boot`, `ota mark-valid`, verified candidate +firmware cache with exact size/SHA-256 checks, inactive-slot write, +low-level boot partition selection, and native selftest coverage. Manifest +fetch, user-facing reboot orchestration, rollback UI, update screen, and +feedback routing remain TODO. Deliverables: @@ -432,10 +433,14 @@ Deliverables: `ota write` writes a verified cached candidate and `ota write-test` copies the running image for COM8 hardware smoke proof. Both leave boot selection unchanged. -- Set OTA boot partition and reboot. +- Set OTA boot partition and reboot. Low-level selection is implemented: + `ota set-test-boot` copies the running app into the inactive slot and selects + it for the next boot. The reboot remains explicit for COM8 validation and + user-facing confirmation still needs UI work. - Support rollback if new firmware fails to mark itself healthy. Initial implementation: a native-tested OTA boot policy chooses clean, pending - verification, mark-valid, and rollback actions before partition/API wiring. + verification, mark-valid, and rollback actions; serial `ota mark-valid` + calls the ESP-IDF mark-valid API for the running app. - Add update UI with simple confirmation language. - Route OTA progress and failure state through Feedback Manager. diff --git a/docs/tdeck-ota-manifest.md b/docs/tdeck-ota-manifest.md index e08ff17..8ac3084 100644 --- a/docs/tdeck-ota-manifest.md +++ b/docs/tdeck-ota-manifest.md @@ -19,6 +19,14 @@ Implemented: - `ota write-test` over the USB serial console. This copies the currently running valid firmware image into the inactive OTA slot as a hardware smoke path, also leaving the boot partition unchanged. +- `ota slot-status` over the USB serial console. This reports the running, + configured boot, and inactive OTA partitions plus OTA image state when the + bootloader exposes it. +- `ota set-test-boot` over the USB serial console. This copies the currently + running image into the inactive slot, selects that inactive slot for the next + boot, and returns without rebooting. +- `ota mark-valid` over the USB serial console. This calls the ESP-IDF + mark-valid API for the currently running app and reports slot status. - `ota test` over the USB serial console. - Native simulator selftest coverage for valid/invalid manifests, candidate staging, inactive-slot writer dispatch, size mismatch rejection, @@ -28,8 +36,8 @@ Implemented: Still TODO: - fetch the manifest over Wi-Fi -- set the OTA boot partition, reboot, and mark the new firmware healthy -- rollback UX and failure recovery +- user-confirmed reboot orchestration after boot-slot selection +- rollback UX and failure recovery beyond the low-level mark-valid hook - user-facing update screen and Feedback Manager progress routing ## Cache Paths @@ -155,6 +163,29 @@ lz> ota write-test ota write: ok source=running-copy running=ota_0 inactive=ota_1 addr=0x00510000 size=5242880 bytes=1539920 boot-set=no ``` +Inspect and select the test boot slot: + +```text +lz> ota slot-status +ota slots: running=ota_0@0x00010000 state=unset boot=ota_0@0x00010000 state=unset inactive=ota_1@0x00510000 boot-matches-running=yes pending=no + +lz> ota set-test-boot +[ok] OTA copied current app and selected inactive slot for next boot; reset to test +ota write: ok source=running-copy running=ota_0 inactive=ota_1 addr=0x00510000 size=5242880 bytes=1539920 boot-set=yes +ota slots: running=ota_0@0x00010000 state=unset boot=ota_1@0x00510000 state=unset inactive=ota_1@0x00510000 boot-matches-running=no pending=no +``` + +After reset, confirm the running slot and mark the image valid: + +```text +lz> ota slot-status +ota slots: running=ota_1@0x00510000 state=unset boot=ota_1@0x00510000 state=unset inactive=ota_0@0x00010000 boot-matches-running=yes pending=no + +lz> ota mark-valid +[ok] OTA running app marked valid +ota slots: running=ota_1@0x00510000 state=unset boot=ota_1@0x00510000 state=unset inactive=ota_0@0x00010000 boot-matches-running=yes pending=no +``` + Clear the candidate cache: ```text diff --git a/docs/tdeck-upgrade-path.md b/docs/tdeck-upgrade-path.md index 81eb78f..eaa7008 100644 --- a/docs/tdeck-upgrade-path.md +++ b/docs/tdeck-upgrade-path.md @@ -4,8 +4,9 @@ This guide documents the upgrade path that is available today. LimitlezzOS has an OTA-capable partition layout, but over-the-air firmware updates are still roadmap work. Current releases upgrade through a USB flash of an exact release or GitHub Actions artifact. The firmware can now verify and cache an OTA -candidate binary and write it to the inactive OTA slot from serial diagnostics, -but it does not yet select that slot for boot or mark the new firmware healthy. +candidate binary, write it to the inactive OTA slot, select a copied-current +image for next boot from serial diagnostics, and mark the running app valid. +It does not yet provide a user-facing OTA update flow or rollback UX. ## Current Support @@ -17,6 +18,8 @@ Supported today: - rollback by reflashing a previously saved known-good artifact - OTA manifest diagnostics plus verified candidate download/staging cache - serial inactive-slot write diagnostics that leave boot unchanged +- serial boot-slot selection and mark-valid diagnostics for copied-current-image + hardware proof - persistent SD-backed user data when the SD card and store remain intact - persistent NVS-backed Wi-Fi credentials on T-Deck hardware @@ -168,12 +171,15 @@ ota fetch ota stage /sd/limitlezz/ota/downloads/firmware.bin ota write ota write-test +ota slot-status +ota set-test-boot +ota mark-valid ota clear ``` Do not claim an OTA upgrade from this evidence alone. A release still upgrades -only after the candidate is selected for boot, confirmed healthy after reboot, -and covered by rollback behavior. +only after a verified candidate is selected for boot from the update flow, +confirmed healthy after reboot, and covered by rollback behavior. ## Rollback diff --git a/sim/main_sim.c b/sim/main_sim.c index 7f69ff7..435fe15 100644 --- a/sim/main_sim.c +++ b/sim/main_sim.c @@ -1173,6 +1173,12 @@ static int codec_selftest(void) extern bool lz_store_clear_ota_candidate(char *err, int err_cap); extern bool lz_svc_ota_write_candidate(lz_ota_install_t *out, char *err, int err_cap); extern bool lz_svc_ota_write_selftest(lz_ota_install_t *out, char *err, int err_cap); + extern bool lz_svc_ota_slot_status(lz_ota_slot_status_t *out, char *err, int err_cap); + extern bool lz_svc_ota_set_test_boot(lz_ota_install_t *install, + lz_ota_slot_status_t *slot, + char *err, int err_cap); + extern bool lz_svc_ota_mark_running_valid(lz_ota_slot_status_t *out, + char *err, int err_cap); extern int lz_store_ota_manifest_selftest(char *buf, int n); sim_reset_dir("lzdata_ota"); sim_mkdirs("lzdata_ota/ota"); @@ -1220,6 +1226,19 @@ static int codec_selftest(void) inst.ok && inst.copied_running_image && !inst.boot_partition_set && inst.bytes_written > 0, "OTA inactive-slot write selftest keeps boot unchanged"); + lz_ota_slot_status_t slot; + CHECK(lz_svc_ota_slot_status(&slot, ota_err, sizeof ota_err) && + slot.ok && slot.boot_matches_running && + strcmp(slot.running_label, "sim_ota_0") == 0, + "OTA slot status reports current boot slot"); + CHECK(lz_svc_ota_set_test_boot(&inst, &slot, ota_err, sizeof ota_err) && + inst.ok && inst.boot_partition_set && + !slot.boot_matches_running && + strcmp(slot.boot_label, "sim_ota_1") == 0, + "OTA set-test-boot selects inactive slot after copied image"); + CHECK(lz_svc_ota_mark_running_valid(&slot, ota_err, sizeof ota_err) && + slot.ok && strcmp(slot.running_state, "valid") == 0, + "OTA mark-valid reports running slot status"); cf = fopen("lzdata_ota/bad-candidate.bin", "wb"); if(cf) { fputs("bad firmware body\n", cf); diff --git a/src/ota_install_sim.c b/src/ota_install_sim.c index d86bd26..18811a6 100644 --- a/src/ota_install_sim.c +++ b/src/ota_install_sim.c @@ -10,6 +10,11 @@ static void ota_install_err(char *err, int err_cap, const char *msg) if(err && err_cap > 0) snprintf(err, (size_t)err_cap, "%s", msg ? msg : ""); } +static bool g_sim_inactive_written; +static int g_sim_running_slot; +static int g_sim_boot_slot; +static bool g_sim_running_valid = true; + static bool sim_install_fail(lz_ota_install_t *out, char *err, int err_cap, const char *msg) { @@ -46,6 +51,7 @@ bool lz_ota_install_file_to_inactive(const char *path, uint32_t expected_size, out->bytes_written = expected_size; out->partition_size = LZ_OTA_SLOT_MAX_BYTES; } + g_sim_inactive_written = true; return true; } @@ -62,7 +68,62 @@ bool lz_ota_install_running_copy_test(lz_ota_install_t *out, char *err, int err_ out->bytes_written = 1536; out->partition_size = LZ_OTA_SLOT_MAX_BYTES; } + g_sim_inactive_written = true; return true; } +static void sim_slot_label(char *out, size_t n, int slot) +{ + snprintf(out, n, "sim_ota_%d", slot ? 1 : 0); +} + +static bool sim_fill_slot_status(lz_ota_slot_status_t *out) +{ + if(!out) return false; + memset(out, 0, sizeof *out); + out->ok = true; + out->boot_matches_running = g_sim_boot_slot == g_sim_running_slot; + out->running_pending_verify = !g_sim_running_valid; + sim_slot_label(out->running_label, sizeof out->running_label, g_sim_running_slot); + sim_slot_label(out->boot_label, sizeof out->boot_label, g_sim_boot_slot); + sim_slot_label(out->inactive_label, sizeof out->inactive_label, 1 - g_sim_running_slot); + snprintf(out->running_state, sizeof out->running_state, "%s", + g_sim_running_valid ? "valid" : "pending"); + snprintf(out->boot_state, sizeof out->boot_state, "%s", + g_sim_boot_slot == g_sim_running_slot ? out->running_state : "new"); + out->running_address = g_sim_running_slot ? 0x00510000u : 0x00010000u; + out->boot_address = g_sim_boot_slot ? 0x00510000u : 0x00010000u; + out->inactive_address = (1 - g_sim_running_slot) ? 0x00510000u : 0x00010000u; + return true; +} + +bool lz_ota_slot_status(lz_ota_slot_status_t *out, char *err, int err_cap) +{ + ota_install_err(err, err_cap, ""); + if(!out) return false; + return sim_fill_slot_status(out); +} + +bool lz_ota_set_inactive_boot(lz_ota_slot_status_t *out, char *err, int err_cap) +{ + ota_install_err(err, err_cap, ""); + if(!g_sim_inactive_written) { + if(out) { + memset(out, 0, sizeof *out); + snprintf(out->error, sizeof out->error, "inactive image missing"); + } + ota_install_err(err, err_cap, "inactive image missing"); + return false; + } + g_sim_boot_slot = 1 - g_sim_running_slot; + return sim_fill_slot_status(out); +} + +bool lz_ota_mark_running_valid(lz_ota_slot_status_t *out, char *err, int err_cap) +{ + ota_install_err(err, err_cap, ""); + g_sim_running_valid = true; + return sim_fill_slot_status(out); +} + #endif diff --git a/src/ota_install_tdeck.cpp b/src/ota_install_tdeck.cpp index 732724a..12ec3f1 100644 --- a/src/ota_install_tdeck.cpp +++ b/src/ota_install_tdeck.cpp @@ -39,6 +39,58 @@ static void fill_partitions(lz_ota_install_t *out, const esp_partition_t *runnin } } +static const char *ota_state_label(esp_ota_img_states_t state) +{ + switch(state) { + case ESP_OTA_IMG_NEW: return "new"; + case ESP_OTA_IMG_PENDING_VERIFY: return "pending"; + case ESP_OTA_IMG_VALID: return "valid"; + case ESP_OTA_IMG_INVALID: return "invalid"; + case ESP_OTA_IMG_ABORTED: return "aborted"; + case ESP_OTA_IMG_UNDEFINED: return "undefined"; + default: return "unknown"; + } +} + +static void fill_state_label(const esp_partition_t *part, char *out, size_t n) +{ + if(!out || n == 0) return; + snprintf(out, n, "unknown"); + if(!part) return; + esp_ota_img_states_t state; + esp_err_t e = esp_ota_get_state_partition(part, &state); + if(e == ESP_OK) snprintf(out, n, "%s", ota_state_label(state)); + else if(e == ESP_ERR_NOT_FOUND) snprintf(out, n, "unset"); +} + +static bool fill_slot_status(lz_ota_slot_status_t *out, char *err, int err_cap) +{ + ota_install_err(err, err_cap, ""); + if(!out) return false; + memset(out, 0, sizeof *out); + const esp_partition_t *running = esp_ota_get_running_partition(); + const esp_partition_t *boot = esp_ota_get_boot_partition(); + const esp_partition_t *inactive = esp_ota_get_next_update_partition(NULL); + if(!running || !boot || !inactive) { + snprintf(out->error, sizeof out->error, "ota partition unavailable"); + ota_install_err(err, err_cap, out->error); + return false; + } + + out->ok = true; + snprintf(out->running_label, sizeof out->running_label, "%s", running->label); + snprintf(out->boot_label, sizeof out->boot_label, "%s", boot->label); + snprintf(out->inactive_label, sizeof out->inactive_label, "%s", inactive->label); + fill_state_label(running, out->running_state, sizeof out->running_state); + fill_state_label(boot, out->boot_state, sizeof out->boot_state); + out->running_pending_verify = strcmp(out->running_state, "pending") == 0; + out->boot_matches_running = running->address == boot->address; + out->running_address = running->address; + out->boot_address = boot->address; + out->inactive_address = inactive->address; + return true; +} + static bool write_stream_to_partition(FILE *f, uint32_t expected_size, lz_ota_install_t *out, char *err, int err_cap) { @@ -201,4 +253,41 @@ bool lz_ota_install_running_copy_test(lz_ota_install_t *out, char *err, int err_ return true; } +bool lz_ota_slot_status(lz_ota_slot_status_t *out, char *err, int err_cap) +{ + return fill_slot_status(out, err, err_cap); +} + +bool lz_ota_set_inactive_boot(lz_ota_slot_status_t *out, char *err, int err_cap) +{ + ota_install_err(err, err_cap, ""); + if(out) memset(out, 0, sizeof *out); + const esp_partition_t *inactive = esp_ota_get_next_update_partition(NULL); + if(!inactive) { + if(out) snprintf(out->error, sizeof out->error, "ota partition unavailable"); + return install_fail(NULL, err, err_cap, "ota partition unavailable"); + } + esp_err_t e = esp_ota_set_boot_partition(inactive); + if(e != ESP_OK) { + if(out) snprintf(out->error, sizeof out->error, "boot partition failed"); + return install_fail(NULL, err, err_cap, "boot partition failed"); + } + return fill_slot_status(out, err, err_cap); +} + +bool lz_ota_mark_running_valid(lz_ota_slot_status_t *out, char *err, int err_cap) +{ + ota_install_err(err, err_cap, ""); + esp_err_t e = esp_ota_mark_app_valid_cancel_rollback(); + if(e != ESP_OK) { + if(out) { + memset(out, 0, sizeof *out); + snprintf(out->error, sizeof out->error, "mark valid failed"); + } + ota_install_err(err, err_cap, "mark valid failed"); + return false; + } + return fill_slot_status(out, err, err_cap); +} + #endif diff --git a/src/serial_cli.cpp b/src/serial_cli.cpp index 8ebf6f6..fea0f56 100644 --- a/src/serial_cli.cpp +++ b/src/serial_cli.cpp @@ -86,7 +86,7 @@ static void cmd_help(void) " nodes [test] list heard nodes / test node DB schema\n" " send broadcast text on the channel\n" " stats radio TX/RX + airtime utilization\n" - " ota status|fetch|stage|clear|write|write-test|test OTA manifest/candidate diagnostics\n" + " ota status|fetch|stage|clear|write|write-test|slot-status|set-test-boot|mark-valid|test OTA diagnostics\n" " security [status|test|set |check |clear ]\n" " wifi [scan|on|off] wifi status / control\n" " settings [test] persisted settings schema diagnostics\n" @@ -565,6 +565,28 @@ static void ota_print_install(const lz_ota_install_t *inst) inst->boot_partition_set ? "yes" : "no"); } +static void ota_print_slots(const lz_ota_slot_status_t *st) +{ + if(!st) return; + if(!st->ok) { + Serial.printf("ota slots: failed"); + if(st->error[0]) Serial.printf(" error=\"%s\"", st->error); + Serial.println(); + return; + } + Serial.printf("ota slots: running=%s@0x%08lx state=%s boot=%s@0x%08lx state=%s inactive=%s@0x%08lx boot-matches-running=%s pending=%s\n", + st->running_label[0] ? st->running_label : "?", + (unsigned long)st->running_address, + st->running_state[0] ? st->running_state : "?", + st->boot_label[0] ? st->boot_label : "?", + (unsigned long)st->boot_address, + st->boot_state[0] ? st->boot_state : "?", + st->inactive_label[0] ? st->inactive_label : "?", + (unsigned long)st->inactive_address, + st->boot_matches_running ? "yes" : "no", + st->running_pending_verify ? "yes" : "no"); +} + static void cmd_ota(char *args) { if(args && strcmp(args, "boot-test") == 0) { @@ -650,8 +672,38 @@ static void cmd_ota(char *args) ota_print_install(&inst); return; } + if(args && strcmp(args, "slot-status") == 0) { + char err[64] = {0}; + lz_ota_slot_status_t st; + if(!lz_svc_ota_slot_status(&st, err, sizeof err)) + Serial.printf("[err] %s\n", err[0] ? err : "OTA slot status failed"); + ota_print_slots(&st); + return; + } + if(args && strcmp(args, "set-test-boot") == 0) { + char err[64] = {0}; + lz_ota_install_t inst; + lz_ota_slot_status_t st; + if(lz_svc_ota_set_test_boot(&inst, &st, err, sizeof err)) + Serial.println("[ok] OTA copied current app and selected inactive slot for next boot; reset to test"); + else + Serial.printf("[err] %s\n", err[0] ? err : "OTA set test boot failed"); + ota_print_install(&inst); + ota_print_slots(&st); + return; + } + if(args && strcmp(args, "mark-valid") == 0) { + char err[64] = {0}; + lz_ota_slot_status_t st; + if(lz_svc_ota_mark_running_valid(&st, err, sizeof err)) + Serial.println("[ok] OTA running app marked valid"); + else + Serial.printf("[err] %s\n", err[0] ? err : "OTA mark valid failed"); + ota_print_slots(&st); + return; + } if(args && args[0] && strcmp(args, "status") != 0) { - Serial.println("usage: ota [status|fetch|stage |clear|write|write-test|test|boot-policy ...|boot-test]"); + Serial.println("usage: ota [status|fetch|stage |clear|write|write-test|slot-status|set-test-boot|mark-valid|test|boot-policy ...|boot-test]"); return; } diff --git a/src/services/mesh.h b/src/services/mesh.h index 306397f..32735e9 100644 --- a/src/services/mesh.h +++ b/src/services/mesh.h @@ -308,6 +308,21 @@ typedef struct { uint32_t partition_size; } lz_ota_install_t; +typedef struct { + bool ok; + bool boot_matches_running; + bool running_pending_verify; + char running_label[17]; + char boot_label[17]; + char inactive_label[17]; + char running_state[18]; + char boot_state[18]; + char error[48]; + uint32_t running_address; + uint32_t boot_address; + uint32_t inactive_address; +} lz_ota_slot_status_t; + typedef struct { bool configured; /* a device PIN verifier exists */ bool valid; /* false = security.cfg is corrupt/unsupported */ @@ -370,6 +385,10 @@ bool lz_svc_ota_fetch_candidate(lz_ota_candidate_t *out, char *err, int err_cap) bool lz_svc_clear_ota_candidate(char *err, int err_cap); bool lz_svc_ota_write_candidate(lz_ota_install_t *out, char *err, int err_cap); bool lz_svc_ota_write_selftest(lz_ota_install_t *out, char *err, int err_cap); +bool lz_svc_ota_slot_status(lz_ota_slot_status_t *out, char *err, int err_cap); +bool lz_svc_ota_set_test_boot(lz_ota_install_t *install, lz_ota_slot_status_t *slot, + char *err, int err_cap); +bool lz_svc_ota_mark_running_valid(lz_ota_slot_status_t *out, char *err, int err_cap); bool lz_svc_security_status(lz_security_status_t *out); bool lz_svc_security_set_pin(const char *pin, char *err, int err_cap); bool lz_svc_security_check_pin(const char *pin); diff --git a/src/services/mesh_core.c b/src/services/mesh_core.c index bfe0f58..1e451eb 100644 --- a/src/services/mesh_core.c +++ b/src/services/mesh_core.c @@ -586,6 +586,30 @@ bool lz_svc_ota_write_selftest(lz_ota_install_t *out, char *err, int err_cap) return lz_ota_install_running_copy_test(out, err, err_cap); } +bool lz_svc_ota_slot_status(lz_ota_slot_status_t *out, char *err, int err_cap) +{ + return lz_ota_slot_status(out, err, err_cap); +} + +bool lz_svc_ota_set_test_boot(lz_ota_install_t *install, lz_ota_slot_status_t *slot, + char *err, int err_cap) +{ + if(err && err_cap > 0) err[0] = 0; + if(install) memset(install, 0, sizeof *install); + if(slot) memset(slot, 0, sizeof *slot); + if(!lz_ota_install_running_copy_test(install, err, err_cap)) + return false; + if(!lz_ota_set_inactive_boot(slot, err, err_cap)) + return false; + if(install) install->boot_partition_set = true; + return true; +} + +bool lz_svc_ota_mark_running_valid(lz_ota_slot_status_t *out, char *err, int err_cap) +{ + return lz_ota_mark_running_valid(out, err, err_cap); +} + bool lz_svc_security_status(lz_security_status_t *out) { return lz_store_security_status(out); diff --git a/src/services/ota_install.h b/src/services/ota_install.h index f26bddc..8139fe2 100644 --- a/src/services/ota_install.h +++ b/src/services/ota_install.h @@ -12,6 +12,9 @@ extern "C" { bool lz_ota_install_file_to_inactive(const char *path, uint32_t expected_size, lz_ota_install_t *out, char *err, int err_cap); bool lz_ota_install_running_copy_test(lz_ota_install_t *out, char *err, int err_cap); +bool lz_ota_slot_status(lz_ota_slot_status_t *out, char *err, int err_cap); +bool lz_ota_set_inactive_boot(lz_ota_slot_status_t *out, char *err, int err_cap); +bool lz_ota_mark_running_valid(lz_ota_slot_status_t *out, char *err, int err_cap); #ifdef __cplusplus }