diff --git a/docs/development/firmware-upgrades.md b/docs/development/firmware-upgrades.md index 055f05f..08ee0c1 100644 --- a/docs/development/firmware-upgrades.md +++ b/docs/development/firmware-upgrades.md @@ -109,7 +109,8 @@ crashes/restarts before confirmation are handled by the rollback-enabled bootloa Confirmation does not establish audible quality or long-term runtime stability. The signed request remains durable through boot-slot selection and startup. -A cut before selection retains the queue for a fresh safe-window download. +A cut before selection retains a Wi-Fi queue for a fresh safe-window download. +USB requests instead require an explicit owner decision and never become network downloads. A selected candidate waits for restart without erasing or selecting another slot. Startup reports rollback only when the requested inactive image is marked invalid or aborted by OTA metadata; the running requested version reports success after @@ -156,3 +157,8 @@ successful slot switching, power/network interruption, failed startup, queue cancellation/restoration and concurrent networking/audio heap/fragmentation. Synthetic CI audio must never be installed or played. Publication and physical installation remain separate approvals. + +The additive [USB application-update protocol](usb-firmware-updates.md) reuses +these trust, partition and startup contracts. It waits for owner power handoff +after selection. The companion website keeps public USB writes disabled until +new transport-specific physical qualification and a capable release are available. diff --git a/docs/development/usb-firmware-updates.md b/docs/development/usb-firmware-updates.md new file mode 100644 index 0000000..aee204f --- /dev/null +++ b/docs/development/usb-firmware-updates.md @@ -0,0 +1,107 @@ +# Preserving USB application updates + +This additive development protocol streams a signed application into the existing +inactive 2 MiB OTA slot. It does not write the bootloader, partition table, +settings/credentials, prayer-consumption history or 3.5 MiB shared audio. The +public website keeps USB updating disabled until transport-specific physical +qualification and publication of a capable release. Legacy released firmware +continues to support credential recovery; it needs an initial Wi-Fi or maintainer +update before using this extension. + +The [development validation report](usb-update-validation-2026-10-08.md) records +automated results, matched OTA measurements and the outstanding physical limits. + +## Trust and ownership + +The firmware owns signature validation, hardware/layout/settings/audio-format +compatibility, stable newer-version checks, the 1.5 MiB application budget, image +hash and embedded version validation, inactive-slot selection, startup confirmation +and rollback. It reuses the [existing upgrade contracts](firmware-upgrades.md). +BEGIN requires a confirmed running application, compatible rollback bootloader, +healthy services/storage, no playback and a safe prayer window. A missing clock +permits transfer only while automatic playback is blocked and services are healthy. +No firmware-health or rollback requirement is relaxed for Atom-only USB power. + +USB and Wi-Fi update ownership are exclusive. A pending credential join/scan blocks +BEGIN; active USB requests block other USB credential/scan mutations and authenticated +HTTP mutations except authenticated `POST /api/stop` with an empty JSON object. +Stop remains available during transfer, interruption and power handoff. It stops +playback without changing USB ownership or the durable update request; ordinary +host, origin, JSON and authentication checks still apply. Read-only status and +identity remain available. No USB transfer +automatically restarts, retries chunks or becomes a Wi-Fi download. + +## Wire contract + +Use existing OpenAthan extension framing at 115200 baud. RPC request type 3 and +response type 4 retain their field encoding. Error type 2 retains codes 1 invalid, +2 unsupported and 255 busy/storage/uncertain selection. + +| Command | Fields | Successful response | +| --- | --- | --- | +| INFO `0x10` | none | `1`, installed version, source commit, `supported`/`unsupported`, state, `confirmed`/`pending`, result, queued version or empty, received application bytes | +| BEGIN `0x11` | descriptor bytes, 1–8192 | nonzero token as 16 lowercase hexadecimal digits | +| VERIFY `0x12` | token | `accepted`, verified offered version | +| FINISH `0x13` | token | `verified`, selected version | +| ABORT `0x14` | token | `aborted` | +| ABORT `0x14` | `interrupted` | `aborted`, only for a rebooted incomplete USB request with no selection marker | + +DATA type 5 payload is a 13-byte header plus 1–242 bytes: little-endian uint64 +token, uint8 kind (0 descriptor, 1 application), little-endian uint32 exact byte +offset. ACK type 6 returns the same token/kind with the next offset and no data. +Out-of-order, duplicate, stale-token and oversized chunks are rejected, not replayed. +No flash erase occurs before VERIFY validates the complete signed descriptor. +Application chunks are copied into bounded internal stack RAM for flash writes; +the descriptor scratch allocation is released after verification or abort. + +One browser owner permits one outstanding RPC or chunk. Every mutation is sent +once. Missing/malformed acknowledgements or disconnects block subsequent writes; +read-only reconciliation remains allowed. FINISH errors and failed post-FINISH +readback require uncertainty handling even if a device error was received, because +selection may already have happened. A transfer idle for more than 40 seconds +aborts before selection. Once selected, a timeout cannot erase or cancel it. + +## Durable request and power handoff + +Existing Wi-Fi requests remain schema 1 with `queue` and `expected`. USB requests +use schema 2 with `source: "usb"`, the signed `queue` and `expected`; no source is +inferred from old records. The request is committed before erase, and the expected +version before boot selection. Only confirmed capable firmware creates USB records. +If interrupted before selection, reconciliation reports `usb_interrupted` and +waits for explicit USB discard. If the exact candidate is selected, it reports +`awaiting_power` and never reboots automatically. A selection error retains the +journal and reports `usb_selection_uncertain` until authoritative reconciliation. +Unreadable boot selection, missing image identification after a completed handoff, +or contradictory metadata also retain this uncertain state; they never authorize +another transfer or journal clearing. Recovered reads return a selected matching +candidate to `awaiting_power`, including a request whose `expected` marker is empty. +A successful read selecting the confirmed running application is required before +reporting an interrupted transfer. An absent inactive-slot OTA entry is normal after +staging and cannot establish rejection by itself. `ABORT interrupted` requires +confirmed startup health and repeats the selection check under the updater lock +before clearing an interrupted request. Rollback requires an identified requested +image with a successfully read `INVALID` or `ABORTED` state. A newer running +version supersedes an old USB request only when selection proves the candidate +is unselected; version comparison alone cannot clear a pending selection. + +After FINISH, the browser reports **written and verified**, closes USB and directs +the owner to unplug Atom USB and power only the Pyramid bottom port. Startup +success is a separate observation on the device page. Existing 30-second health +confirmation and 90-second failed-startup rollback apply; no network or valid clock +is required for confirmation. The running requested version or an actually rejected +candidate clears bookkeeping through the existing success/rollback reconciliation. +Settings/history/audio formats and the previous application remain compatible. + +## Required qualification + +Host and browser tests establish software contracts, not physical preservation, +power behavior or memory headroom. Use the provisioning hardware runbook for fresh +preflight, current-state readback and private backups. Retain source/artifact hashes +and test actual Chrome and Edge USB ownership; Atom-only transfer and bottom-only +startup; success and failed-startup rollback; interruptions before descriptor +verification, during writes and around selection; wrong signatures/images/tokens/ +offsets; competing Wi-Fi/settings activity; and before/after settings, credentials, +consumption history and audio evidence. Measure runtime heap/fragmentation and +prayer/audio responsiveness during transfer separately from binary size. Do not +install synthetic CI recordings. Keep public activation and firmware publication +separately reviewed after these results. diff --git a/docs/development/usb-update-validation-2026-10-08.md b/docs/development/usb-update-validation-2026-10-08.md new file mode 100644 index 0000000..cd25c42 --- /dev/null +++ b/docs/development/usb-update-validation-2026-10-08.md @@ -0,0 +1,176 @@ +# USB application-update development validation, 2026-10-08 + +This report covers the additive USB updater and device-local status presentation +in the `codex/usb-updates` change based on `310b978`. These are development builds, +not release candidates or physical acceptance. The companion website retains +`usbUpdateEnabled: false` until new qualification and a capable release. + +## Automated behavior + +- CMake/CTest: all 16 host tests passed. +- Required Python runner: all 103 tests passed without skips, using the resolved + ArduinoJson headers and built audio/settings adapters. +- Production updater runtime checks passed with UBSan for existing version + reconciliation, the new USB sender/receiver contract, ordinary isolated-build + rejection and qualification startup success/failure. +- USB cases cover bad signatures before erase, exact token/offset and duplicate + rejection, conflicting Wi-Fi updates, offline-clock transfer with healthy + services, unhealthy-service refusal, hash mismatch, idle abort, a power cut + between durable handoff and selection, startup success/rollback, and selection + errors both before and after metadata changed. USB interruption never starts + an HTTP download; verified selection never automatically reboots. +- Chromium and WebKit device UI cases cover USB transfer/handoff messages and + blocked network update/cancel controls. Browser endpoints are simulated with + the production Digest verifier. +- Companion website validation covers actual published v0.4.0 signed descriptor + and OTA import, signature/image corruption rejection, session ownership, + ambiguous finalization, disabled public writes, whole-flow desktop/mobile + browser behavior and production simulator exclusion. It consumes artifacts + without compiling firmware. + +## Initial USB implementation measurements + +Baseline and candidate use identical configuration, compile-only synthetic audio, +qualification trust material and pinned Python 3.13.0, ESPHome 2026.9.0, ESP-IDF +5.5.5 and Xtensa compiler 14.2.0_20260121. The startup-failure variant uses +qualification version v0.0.3 and its existing health-failure switch. Each build +passed `check_feasibility.py`, including image/partition and dependency checks. +No synthetic recording was installed or played. + +| Variant | Baseline OTA bytes | Candidate OTA bytes | Delta | Remaining 1.5 MiB budget | Free 2 MiB slot bytes | +| --- | ---: | ---: | ---: | ---: | ---: | +| Reference | 1,265,760 | 1,273,472 | +7,712 | 299,392 | 823,680 | +| Provisioning isolated | 1,267,552 | 1,274,848 | +7,296 | 298,016 | 822,304 | +| Upgrade qualification | 1,272,928 | 1,280,400 | +7,472 | 292,464 | 816,752 | +| Startup failure | 1,272,928 | 1,280,400 | +7,472 | 292,464 | 816,752 | + +Candidate static RAM is 115,395 bytes for reference/provisioning and 115,467 bytes +for qualification variants. These figures do not establish runtime heap, +fragmentation or PSRAM headroom. Both application slots, the 3.5 MiB shared audio +partition and bootloader allowlist remain unchanged. + +Initial candidate OTA SHA-256 identities from these measured development builds: + +| Variant | SHA-256 | +| --- | --- | +| Reference | `964b5517d4be6ad32bc0d3e9c0ce8a2b62f308d51f71f0b7e2d83941cb7b2fa2` | +| Provisioning | `5f871b4ecff9b7c6d0b8b53159e1b0567ef30c06fc5af124c668654965e5636f` | +| Qualification | `5721e64e734ec62ce6687525dbf4c290a7f17b227d8ece49126b1ffdd24d264d` | +| Startup failure | `c53b8f67f36023a36919471fcc0541f78180809629a6d63dc50c491acc94625e` | + +## USB reconciliation correction + +The correction compares exact initial PR head `4143747` with the shared USB +selection classifier and fresh interrupted-discard guard. Both stages used the +same pinned toolchain, configurations, synthetic audio, qualification trust and +`development` build identity described above. All four before/after builds passed +capacity, dependency, partition and image-consistency checks. + +The regression failed against the original updater: unreadable selection became +`usb_interrupted` even though the candidate remained selected. Corrected UBSan +runtime checks cover failed boot/description/state reads, absent state entries, +mismatched versions and unexpected partitions after successful or ambiguous +FINISH; repeated loops/restart; requests with an already-empty handoff marker; +read recovery; changed selection at discard time; incomplete unselected images; +true rejection, healthy/unhealthy startup and superseded USB requests whose +older candidate is still selected. Uncertain or selected requests +retain their journal and USB ownership without writes, new selection, reboot or +HTTP requests. A positive current-slot selection permits explicit incomplete +transfer discard, including absent inactive metadata. Existing Wi-Fi, ordinary +isolated and qualification profiles also pass. + +CMake/CTest passed all 16 tests; the complete required Python suite passed all 103 +without skips. The resolved SDK request/header regressions passed. Focused +Chromium/WebKit checks passed both USB-status cases, including uncertainty +keeping network actions disabled and cancellation hidden. The complete device UI +rerun passed all 466 tests without skips. + +| Variant | Before OTA bytes | Corrected OTA bytes | Delta | Remaining 1.5 MiB budget | Free 2 MiB slot bytes | +| --- | ---: | ---: | ---: | ---: | ---: | +| Reference | 1,273,472 | 1,274,144 | +672 | 298,720 | 823,008 | +| Provisioning isolated | 1,274,848 | 1,275,568 | +720 | 297,296 | 821,584 | +| Upgrade qualification | 1,280,400 | 1,281,184 | +784 | 291,680 | 815,968 | +| Startup failure | 1,280,400 | 1,281,184 | +784 | 291,680 | 815,968 | + +Static RAM remains 115,395 bytes for reference/provisioning and 115,467 bytes for +qualification variants. The correction introduces no new dependency or persistent +allocation. Runtime heap/fragmentation and PSRAM remain physically unqualified. +Both application slots and shared audio retain their existing layout. + +Corrected development-build OTA identities: + +| Variant | SHA-256 | +| --- | --- | +| Reference | `5e371b03e527b76b6b74a162c7b71e6d53db0fc27c0f1497fbb5934578b8895b` | +| Provisioning isolated | `663f9a09d770c0bc90f6118e4498e856dde60749f98af6264c0350078b4baa2c` | +| Upgrade qualification | `c2c19a64f5c300c730b9500cbddcced06aaf98938f4781e3953e9edfcc3dc627` | +| Startup failure | `2bfc1d9601d9699a0f392c611f0feeb4058dd097ed28f8106625a86e1ba89c89` | + +## Playback Stop correction + +USB ownership now permits only the exact authenticated `POST /api/stop` endpoint +alongside existing reads. It still applies maintenance, host, method, origin, +JSON content-type, Digest authentication and rate-limit checks before dispatching +to the unchanged local API. Other mutations remain blocked throughout transfer, +interruption and power handoff. + +The new UBSan HTTP regression compiles the production handler, host validator +and helpers unchanged with the real Digest verifier and USB updater. Platform +I/O is deterministic and local API dispatch is observed through a test adapter. +It fails against `39c3d14`: a partial application transfer leaves a durable +request after simulated restart, and authenticated Stop returns 409. The +correction returns 200 in `usb_interrupted`, `usb_descriptor`, `usb_receiving`, +`usb_selection_uncertain` and `awaiting_power`. It preserves USB ownership, +the journal, NVS commit count, OTA writes/erases, boot selection and reboot count. +Other writes and similar Stop URLs remain blocked; unauthenticated, invalid +origin/host/content-type/method, replayed and rate-limited requests cannot stop +playback. + +Production and isolated local API tests use the real scheduler to start a +scheduled prayer, reject malformed/non-object/nonempty Stop payloads, then verify +valid Stop returns `playing: false` without changing saved settings or any +persisted settings/history records. CMake/CTest passed all 16 tests; the required +Python suite passed all 103 without skips. All eleven updater/HTTP runtime +profiles passed with UBSan, and the resolved SDK request/header regression passed. + +The complete device UI suite passed all 486 Chromium/WebKit cases without skips +using Node 24.19.0 and locked Playwright 1.62.1. Twenty new cases verify Stop in +Today and Settings across all five USB states, authoritative stopped feedback, +unchanged device settings/update state and blocked network update controls. +Browser endpoints remain simulated with the production Digest verifier. + +Matched builds compare `39c3d14` with the Stop correction using the same pinned +toolchain, configurations, synthetic audio, qualification trust and `development` +build identity described above. All eight builds passed capacity, dependency, +partition and image-consistency checks. + +| Variant | Before OTA bytes | Corrected OTA bytes | Delta | Remaining 1.5 MiB budget | Free 2 MiB slot bytes | +| --- | ---: | ---: | ---: | ---: | ---: | +| Reference | 1,274,144 | 1,274,176 | +32 | 298,688 | 822,976 | +| Provisioning isolated | 1,275,568 | 1,275,616 | +48 | 297,248 | 821,536 | +| Upgrade qualification | 1,281,184 | 1,281,216 | +32 | 291,648 | 815,936 | +| Startup failure | 1,281,184 | 1,281,216 | +32 | 291,648 | 815,936 | + +Static RAM remains 115,395 bytes for reference/provisioning and 115,467 bytes for +qualification variants. No new dependency, persistent allocation or partition +change is introduced. These figures do not establish runtime heap or PSRAM +headroom, and no hardware was accessed or changed. + +Stop-corrected development-build OTA identities: + +| Variant | SHA-256 | +| --- | --- | +| Reference | `4068373c9713a7879792b48f143ce33aaff3a12e1312136db5cb7ea5d1410b40` | +| Provisioning isolated | `02cc91ae6e4001dd6d1420b4e95b5437dd7297d2d387592cd31b435d1b8a02ef` | +| Upgrade qualification | `2c03cdc00e3e33b8326e69e37070e12e0db12bd008870f394c061937a607995a` | +| Startup failure | `63c95565f3a36c713ee7e493363b7c2c6a3715aa921e386c7ee690ee9c1bc188` | + +## Physical and runtime limits + +No hardware was accessed or changed. Atom-only USB health/transfer, bottom-power +startup, actual credential/settings/history/audio preservation, interruption +recovery, failed-startup rollback and concurrent audio/network heap/fragmentation +remain untested for this transport. Existing Wi-Fi qualification cannot substitute +for these results. Follow the [USB qualification contract](usb-firmware-updates.md) +and current hardware runbooks before activation. Firmware publication and public +USB enablement require separate review and approval. diff --git a/firmware/esphome/components/openathan_device/device.cpp b/firmware/esphome/components/openathan_device/device.cpp index db74a6a..6649571 100644 --- a/firmware/esphome/components/openathan_device/device.cpp +++ b/firmware/esphome/components/openathan_device/device.cpp @@ -138,6 +138,12 @@ void Device::result_(bool extension, uint8_t command, const std::vector fields; @@ -168,7 +174,19 @@ void Device::serial_request(const Frame& request) { return; } #endif - if (maintenance_) { + if (request.extension && command == ::openathan::usb_upgrade::INFO && fields.empty()) { + result_(true, command, upgrade_.usb_info()); return; + } + if (request.extension && command >= ::openathan::usb_upgrade::BEGIN && command <= ::openathan::usb_upgrade::ABORT) { + if (maintenance_ || (command == ::openathan::usb_upgrade::BEGIN && (wifi_attempt_.active() || scanning_))) { + send_(true, 2, {255}); return; + } + std::vector reply; + const auto error = upgrade_.usb_command(command, fields, reply); + if (error) send_(true, 2, {error}); else result_(true, command, reply); + return; + } + if (maintenance_ || (upgrade_.usb_busy() && !((request.extension && command == 1) || (!request.extension && (command == 2 || command == 3))))) { send_(request.extension, 2, {255}); return; } @@ -232,7 +250,7 @@ void Device::serial_request(const Frame& request) { send_(false, 1, {wifi_state_()}); if (wifi_state_() == 4) result_(false, command, urls_()); } else if (command == 3) - result_(false, command, {"OpenAthan", "1-dev", "ESP32-S3", hostname_}); + result_(false, command, {"OpenAthan", OPENATHAN_FIRMWARE_VERSION, "ESP32-S3", hostname_}); else if (command == 4) { if (wifi_attempt_.active() || scanning_) { send_(false, 2, {255}); @@ -419,6 +437,10 @@ void Device::handle_http_(HttpExchange& request) { request.response.assign(reinterpret_cast(assets_[i].data), assets_[i].size); return; } + if (upgrade_.usb_busy() && request.method != "GET" && + !(request.method == "POST" && request.uri == "/api/stop")) { + error(request, 409, "Finish or reconcile the USB update before another change"); return; + } api_->set_context(hostname_, wifi::global_wifi_component->is_connected()); api_->handle(request); } diff --git a/firmware/esphome/components/openathan_device/upgrade.cpp b/firmware/esphome/components/openathan_device/upgrade.cpp index 1efb72e..0b3d4c4 100644 --- a/firmware/esphome/components/openathan_device/upgrade.cpp +++ b/firmware/esphome/components/openathan_device/upgrade.cpp @@ -131,7 +131,8 @@ bool Upgrade::descriptor_(const std::string &envelope, UpgradeRelease &release) bool Upgrade::persist_(const std::string &queue, const std::string &expected) { if (!storage_ok_) return false; JsonDocument doc; - doc["schema"] = 1; doc["queue"] = queue; doc["expected"] = expected; + doc["schema"] = usb_request_ ? 2 : 1; doc["queue"] = queue; doc["expected"] = expected; + if (usb_request_) doc["source"] = "usb"; std::string record; serializeJson(doc, record); if (nvs_set_blob(nvs_, "request", record.data(), record.size()) != ESP_OK || nvs_commit(nvs_) != ESP_OK) { @@ -173,8 +174,10 @@ void Upgrade::begin(openathan_component::OpenAthan *athan, bool server_ready) { std::string record(size, '\0'); JsonDocument doc; if (nvs_get_blob(nvs_, "request", record.data(), &size) != ESP_OK || deserializeJson(doc, record) || - doc.size() != 3 || doc["schema"].as() != 1 || + ((doc.size() != 3 || doc["schema"].as() != 1) && + (doc.size() != 4 || doc["schema"].as() != 2 || doc["source"].as() != "usb")) || !doc["queue"].is() || !doc["expected"].is()) { storage_ok_ = false; return; } + usb_request_ = doc["schema"].as() == 2; expected_ = doc["expected"].as(); if (!expected_.empty() && !release_version(expected_)) { storage_ok_ = false; return; } const auto queue = doc["queue"].as(); @@ -196,6 +199,7 @@ void Upgrade::snapshot(JsonObject root) { std::lock_guard lock(mutex_); root["version"] = OPENATHAN_FIRMWARE_VERSION; root["supported"] = bootloader_ok_; + if (usb_busy()) root["transport"] = "usb"; #ifdef OPENATHAN_UPGRADE_QUALIFICATION qualification_.snapshot(root); #endif @@ -215,6 +219,7 @@ void Upgrade::snapshot(JsonObject root) { } int Upgrade::action(const std::string &action, JsonObjectConst input, std::string &error) { std::lock_guard lock(mutex_); + if (usb_busy()) { error = "A USB update must be reconciled through USB"; return 409; } #ifdef OPENATHAN_UPGRADE_QUALIFICATION const bool ready = qualification_.services_ready(confirmed_, action); #else @@ -405,11 +410,15 @@ void Upgrade::loop(bool connected) { if (pending && esp_ota_mark_app_valid_cancel_rollback() != ESP_OK) return; confirmed_ = true; } - if (!storage_ok_ || active_) return; + if (usb_token_ && now - usb_last_ms_ > 40000) { + usb_abort_(); error_ = "USB transfer interrupted; check status before another attempt"; + } + if (!storage_ok_ || active_ || usb_token_) return; #ifdef OPENATHAN_UPGRADE_QUALIFICATION if (qualification_.selected_loop(safe_)) return; #endif - if (!queued_.envelope.empty() && !newer_release(queued_.version, OPENATHAN_FIRMWARE_VERSION)) { + if (!queued_.envelope.empty() && !newer_release(queued_.version, OPENATHAN_FIRMWARE_VERSION) && + (!usb_request_ || queued_.version == OPENATHAN_FIRMWARE_VERSION)) { // A preserving USB update can fulfill or supersede a queued Wi-Fi request // without setting our handoff marker. A valid old request is not corruption. const bool fulfilled = queued_.version == OPENATHAN_FIRMWARE_VERSION; @@ -418,6 +427,26 @@ void Upgrade::loop(bool connected) { if (persist_("", "")) { expected_.clear(); queued_ = {}; } } if (!storage_ok_) return; + if (usb_request_) { + if (queued_.envelope.empty()) usb_request_ = false; + else { + // USB requests never fall through to Wi-Fi's interrupted-handoff retry. + // Preserve their journal until boot selection is positively established. + const auto selection = usb_selection_(); + const bool superseded = !newer_release(queued_.version, OPENATHAN_FIRMWARE_VERSION); + if (selection == UsbSelection::UNKNOWN) state_ = "usb_selection_uncertain"; + else if (selection == UsbSelection::SELECTED) state_ = "awaiting_power"; + else if (selection == UsbSelection::REJECTED || superseded) { + result_ = superseded ? "superseded" : "rolled_back"; + state_ = superseded ? "current" : result_; + if (persist_("", "")) { expected_.clear(); queued_ = {}; usb_request_ = false; } + } else { + if (!expected_.empty() && !persist_(queued_.envelope, "")) return; + expected_.clear(); state_ = "usb_interrupted"; + } + return; + } + } if (!expected_.empty() #ifdef OPENATHAN_UPGRADE_QUALIFICATION && qualification_.should_reconcile(*this) @@ -437,8 +466,8 @@ void Upgrade::loop(bool connected) { } else if (identified && esp_ota_get_boot_partition() == candidate) { // Selection completed, but this application is still running. Do not // erase either slot or rewrite boot metadata while handoff is pending. - state_ = "restarting"; - if (safe_) App.safe_reboot(); + state_ = usb_request_ ? "awaiting_power" : "restarting"; + if (!usb_request_ && safe_) App.safe_reboot(); return; } else { // OTA begin invalidates the inactive slot's previous rollback metadata. diff --git a/firmware/esphome/components/openathan_device/upgrade.h b/firmware/esphome/components/openathan_device/upgrade.h index 4ed0932..ca58393 100644 --- a/firmware/esphome/components/openathan_device/upgrade.h +++ b/firmware/esphome/components/openathan_device/upgrade.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include "openathan/usb_upgrade.h" #include "esphome/components/openathan/openathan.h" #ifdef OPENATHAN_UPGRADE_QUALIFICATION #include "esphome/components/openathan_upgrade_qualification/qualification.h" @@ -18,11 +20,16 @@ struct UpgradeRelease { }; class Upgrade : public UpgradeApi { public: + ~Upgrade() override; void begin(openathan_component::OpenAthan *athan, bool server_ready); void loop(bool connected); void shutdown() { cancel_ = true; safe_ = false; } void snapshot(JsonObject root) override; int action(const std::string &action, JsonObjectConst input, std::string &error) override; + std::vector usb_info(); + uint8_t usb_command(uint8_t command, const std::vector &fields, std::vector &reply); + bool usb_chunk(const ::openathan::usb_upgrade::Chunk &chunk); + bool usb_busy() const { return usb_token_ || usb_request_; } private: static void worker_(void *argument); void run_(); @@ -30,6 +37,10 @@ class Upgrade : public UpgradeApi { bool descriptor_(const std::string &envelope, UpgradeRelease &release); bool persist_(const std::string &queue, const std::string &expected); void fail_(const char *message); + enum class UsbSelection { UNKNOWN, SELECTED, UNSELECTED, REJECTED }; + UsbSelection usb_selection_(); + bool usb_safe_(); + bool usb_abort_(); openathan_component::OpenAthan *athan_{}; std::mutex mutex_; UpgradeRelease offered_, queued_; @@ -41,6 +52,13 @@ class Upgrade : public UpgradeApi { std::atomic received_{0}; uint64_t boot_ms_{}, next_check_ms_{}, retry_ms_{}; int64_t last_check_{}; + bool usb_request_{}; + uint64_t usb_token_{}, usb_last_ms_{}; + uint32_t usb_descriptor_bytes_{}; + std::string usb_descriptor_; + uint32_t usb_handle_{}; + mbedtls_sha256_context usb_hash_{}; + bool usb_hash_live_{}; #ifdef OPENATHAN_UPGRADE_QUALIFICATION friend class openathan_upgrade_qualification::Qualification; openathan_upgrade_qualification::Qualification qualification_; diff --git a/firmware/esphome/components/openathan_device/usb_upgrade.cpp b/firmware/esphome/components/openathan_device/usb_upgrade.cpp new file mode 100644 index 0000000..c4757f0 --- /dev/null +++ b/firmware/esphome/components/openathan_device/usb_upgrade.cpp @@ -0,0 +1,180 @@ +#include "upgrade.h" +#include "esphome/components/openathan/storage_config.h" +#include +#include +#include +#include +#include +#include + +namespace esphome::openathan_device { +namespace { +uint64_t usb_now() { return esp_timer_get_time() / 1000; } +std::string token_text(uint64_t token) { + std::string text(16, '0'); + for (unsigned i = 0; i < 16; ++i) text[15-i] = "0123456789abcdef"[(token >> (4*i)) & 15]; + return text; +} +} +Upgrade::~Upgrade() { + if (usb_handle_) esp_ota_abort(usb_handle_); + if (usb_hash_live_) mbedtls_sha256_free(&usb_hash_); +} +bool Upgrade::usb_safe_() { + if (!confirmed_ || !storage_ok_ || !bootloader_ok_) return false; + const auto status = athan_->status(); + const auto *settings = athan_->settings_service(); + if (!server_ready_ || athan_->is_failed() || !settings || !settings->healthy() || + std::string(athan_->setup_state()) == "storage_fault" || !athan_->upgrade_health()) return false; + if (status.playing || !boot_scheduler_healthy(status.fault)) return false; + const auto clock = athan_->read(); + // With no clock, automatic prayer playback is blocked. USB needs no network. + return !clock.valid || upgrade_window(false, true, athan_->activated(), status.automatic_ready, + clock.utc, status.next ? std::optional(status.next->utc) : std::nullopt); +} +std::vector Upgrade::usb_info() { + std::lock_guard lock(mutex_); + return {"1", OPENATHAN_FIRMWARE_VERSION, OPENATHAN_BUILD_COMMIT, + bootloader_ok_ ? "supported" : "unsupported", storage_ok_ ? state_ : "storage_fault", + confirmed_ ? "confirmed" : "pending", result_, queued_.version, std::to_string(received_.load())}; +} +Upgrade::UsbSelection Upgrade::usb_selection_() { + // Called with mutex_ held. Empty expected can also be a handoff marker that + // an older firmware cleared; it is never proof that selection did not happen. + const auto *running = esp_ota_get_running_partition(); + const auto *candidate = esp_ota_get_next_update_partition(nullptr); + if (!running || !candidate || candidate == running || queued_.envelope.empty()) return UsbSelection::UNKNOWN; + esp_app_desc_t description{}; + const bool identified = esp_ota_get_partition_description(candidate, &description) == ESP_OK && + queued_.version == description.version; + esp_ota_img_states_t image_state{}; + const auto state_result = identified ? esp_ota_get_state_partition(candidate, &image_state) : ESP_ERR_NOT_FOUND; + // Read selection last, including when the inactive OTA entry is absent. + const auto *boot = esp_ota_get_boot_partition(); + if (!boot || (boot != running && boot != candidate)) return UsbSelection::UNKNOWN; + if (boot == candidate) { + if (!identified || state_result != ESP_OK || image_state == ESP_OTA_IMG_INVALID || + image_state == ESP_OTA_IMG_ABORTED) return UsbSelection::UNKNOWN; + return UsbSelection::SELECTED; + } + if ((!expected_.empty() && !identified) || (state_result != ESP_OK && state_result != ESP_ERR_NOT_FOUND)) + return UsbSelection::UNKNOWN; + if (!expected_.empty() && identified && state_result == ESP_OK && + (image_state == ESP_OTA_IMG_INVALID || image_state == ESP_OTA_IMG_ABORTED)) return UsbSelection::REJECTED; + // OTA begin can leave no inactive entry, and a partial image may have no + // readable description. The successful running-slot selection is the proof. + return UsbSelection::UNSELECTED; +} +bool Upgrade::usb_abort_() { + if (!expected_.empty() || state_ == "awaiting_power") return false; + if (usb_handle_) { esp_ota_abort(usb_handle_); usb_handle_ = 0; } + if (usb_hash_live_) { mbedtls_sha256_free(&usb_hash_); usb_hash_live_ = false; } + usb_token_ = 0; std::string{}.swap(usb_descriptor_); usb_descriptor_bytes_ = 0; staged_ = nullptr; + if (usb_request_ && !persist_("", "")) return false; + usb_request_ = false; queued_ = {}; received_ = 0; state_ = "idle"; + return true; +} +uint8_t Upgrade::usb_command(uint8_t command, const std::vector &fields, + std::vector &reply) { + using namespace ::openathan::usb_upgrade; + std::lock_guard lock(mutex_); + if (command == BEGIN) { +#ifndef OPENATHAN_UPGRADE_QUALIFICATION + if (openathan_storage::TEST_MODE) return 2; +#endif + if (fields.size() != 1) return 1; + uint32_t size = 0; + const auto &text = fields[0]; + auto [end, error] = std::from_chars(text.data(), text.data()+text.size(), size); + if (error != std::errc{} || end != text.data()+text.size() || !size || size > 8192) return 1; + if (active_ || usb_busy() || !queued_.envelope.empty() || !usb_safe_()) return 255; + // The capability is available only after this firmware's own health check. + usb_token_ = (uint64_t(esp_random()) << 32) | esp_random(); + if (!usb_token_) usb_token_ = 1; + usb_descriptor_bytes_ = size; usb_descriptor_.clear(); usb_descriptor_.reserve(size); + usb_last_ms_ = usb_now(); received_ = 0; state_ = "usb_descriptor"; error_.clear(); + reply = {token_text(usb_token_)}; return 0; + } + if (command == ABORT && fields == std::vector{"interrupted"} && usb_request_ && !usb_token_) { + if (!confirmed_ || !storage_ok_ || !expected_.empty() || state_ != "usb_interrupted") return 255; + const auto selection = usb_selection_(); + if (selection != UsbSelection::UNSELECTED) { + state_ = selection == UsbSelection::SELECTED ? "awaiting_power" : "usb_selection_uncertain"; + return 255; + } + if (!usb_abort_()) return 255; + reply = {"aborted"}; return 0; + } + if (fields.size() != 1 || !usb_token_ || fields[0] != token_text(usb_token_)) return 1; + usb_last_ms_ = usb_now(); + if (command == ABORT) { + if (!usb_abort_()) return 255; + reply = {"aborted"}; return 0; + } + if (command == VERIFY) { + if (state_ != "usb_descriptor" || usb_descriptor_.size() != usb_descriptor_bytes_) return 1; + UpgradeRelease release; + if (!descriptor_(usb_descriptor_, release) || !newer_release(release.version, OPENATHAN_FIRMWARE_VERSION)) { + usb_abort_(); return 1; + } + if (!usb_safe_()) { usb_abort_(); return 255; } + usb_request_ = true; + if (!persist_(release.envelope, "")) { usb_abort_(); return 255; } + queued_ = release; + const auto *partition = esp_ota_get_next_update_partition(nullptr); + if (!partition || partition->size != 0x200000 || esp_ota_begin(partition, release.bytes, &usb_handle_) != ESP_OK) { + usb_abort_(); return 255; + } + mbedtls_sha256_init(&usb_hash_); usb_hash_live_ = true; + if (mbedtls_sha256_starts(&usb_hash_, 0) != 0) { usb_abort_(); return 255; } + std::string{}.swap(usb_descriptor_); state_ = "usb_receiving"; + reply = {"accepted", release.version}; return 0; + } + if (command == FINISH) { + if (state_ != "usb_receiving" || received_ != queued_.bytes || !usb_safe_()) return 255; + uint8_t digest[32]; + if (mbedtls_sha256_finish(&usb_hash_, digest) != 0) { usb_abort_(); return 255; } + mbedtls_sha256_free(&usb_hash_); usb_hash_live_ = false; + std::string actual; + for (auto byte : digest) { actual += "0123456789abcdef"[byte >> 4]; actual += "0123456789abcdef"[byte & 15]; } + if (actual != queued_.sha256) { usb_abort_(); return 1; } + const auto handle = usb_handle_; usb_handle_ = 0; + if (esp_ota_end(handle) != ESP_OK) { usb_abort_(); return 1; } + const auto *partition = esp_ota_get_next_update_partition(nullptr); + esp_app_desc_t description{}; + if (!partition || esp_ota_get_partition_description(partition, &description) != ESP_OK || + queued_.version != description.version) { usb_abort_(); return 1; } + // Persist the handoff before selecting. A lost reply is never permission to retry. + if (!persist_(queued_.envelope, queued_.version)) return 255; + expected_ = queued_.version; + usb_token_ = 0; + if (esp_ota_set_boot_partition(partition) != ESP_OK) { + state_ = "usb_selection_uncertain"; return 255; + } + state_ = "awaiting_power"; + reply = {"verified", queued_.version}; return 0; + } + return 2; +} +bool Upgrade::usb_chunk(const ::openathan::usb_upgrade::Chunk &chunk) { + std::lock_guard lock(mutex_); + if (!usb_token_ || chunk.token != usb_token_) return false; + if (chunk.kind == 0) { + if (state_ != "usb_descriptor" || chunk.offset != usb_descriptor_.size() || + chunk.bytes.size() > usb_descriptor_bytes_ - usb_descriptor_.size()) return false; + usb_descriptor_.append(reinterpret_cast(chunk.bytes.data()), chunk.bytes.size()); + } else { + if (state_ != "usb_receiving" || chunk.offset != received_ || + chunk.bytes.size() > queued_.bytes - received_ || !usb_safe_()) return false; + // Keep the write buffer in internal stack RAM while flash disables the PSRAM cache. + std::array internal{}; + std::copy(chunk.bytes.begin(), chunk.bytes.end(), internal.begin()); + if (esp_ota_write(usb_handle_, internal.data(), chunk.bytes.size()) != ESP_OK || + mbedtls_sha256_update(&usb_hash_, internal.data(), chunk.bytes.size()) != 0) { + usb_abort_(); return false; + } + received_ += chunk.bytes.size(); + } + usb_last_ms_ = usb_now(); return true; +} +} // namespace esphome::openathan_device diff --git a/lib/openathan-core/include/openathan/usb_upgrade.h b/lib/openathan-core/include/openathan/usb_upgrade.h new file mode 100644 index 0000000..c0fb81f --- /dev/null +++ b/lib/openathan-core/include/openathan/usb_upgrade.h @@ -0,0 +1,29 @@ +#pragma once +#include +#include +#include +#include + +namespace openathan::usb_upgrade { +// OATHAN v1 additive commands. Improv and credential RPCs remain unchanged. +constexpr uint8_t INFO = 0x10, BEGIN = 0x11, VERIFY = 0x12, FINISH = 0x13, ABORT = 0x14; +constexpr uint8_t DATA = 5, ACK = 6; +constexpr size_t HEADER = 13, CHUNK = 255 - HEADER; +struct Chunk { uint64_t token; uint8_t kind; uint32_t offset; std::span bytes; }; +inline std::optional decode(std::span bytes) { + if (bytes.size() <= HEADER || bytes.size() > 255 || bytes[8] > 1) return {}; + uint64_t token = 0; uint32_t offset = 0; + for (unsigned i = 0; i < 8; ++i) token |= uint64_t(bytes[i]) << (8 * i); + for (unsigned i = 0; i < 4; ++i) offset |= uint32_t(bytes[9 + i]) << (8 * i); + if (!token) return {}; + return Chunk{token, bytes[8], offset, bytes.subspan(HEADER)}; +} +inline std::vector ack(const Chunk &chunk) { + std::vector out(HEADER); + for (unsigned i = 0; i < 8; ++i) out[i] = uint8_t(chunk.token >> (8 * i)); + out[8] = chunk.kind; + const uint32_t next = chunk.offset + chunk.bytes.size(); + for (unsigned i = 0; i < 4; ++i) out[9 + i] = uint8_t(next >> (8 * i)); + return out; +} +} // namespace openathan::usb_upgrade diff --git a/tests/device_http_tests.cpp b/tests/device_http_tests.cpp new file mode 100644 index 0000000..9752399 --- /dev/null +++ b/tests/device_http_tests.cpp @@ -0,0 +1,219 @@ +// Exercise the unchanged production HTTP entrypoint and Digest verifier with +// the real USB updater. LocalApi dispatch is observed here; its payload handling +// and playback/storage effects are exercised by settings_adapter_tests.cpp. +#define main existing_ota_tests +#include "upgrade_runtime_tests.cpp" +#undef main +#include "digest.h" +#include + +void esp_fill_random(void *buffer, size_t size) { + static uint8_t nonce{}; + auto *bytes = static_cast(buffer); + for (size_t i = 0; i < size; ++i) bytes[i] = ++nonce; +} +namespace esphome::wifi { +struct WifiProbe { bool is_connected() const { return true; } }; +inline WifiProbe wifi_probe; +inline WifiProbe *global_wifi_component = &wifi_probe; +} +namespace esphome::openathan_device { +// HTTP-task semaphore/cancellation plumbing is outside handle_http_. +struct HttpExchange { + std::string method, uri, body, response, host, origin, site, authorization, content_type, challenge; + std::string type{"application/json"}; + int code{200}; + bool gzip{}; +}; +struct ApiProbe { + openathan_component::OpenAthan &athan; + unsigned calls{}, stop_calls{}; + void set_context(const std::string &host, bool connected) { + assert(host == "device.local" && connected); + } + void handle(HttpExchange &request) { + ++calls; + if (request.method == "POST") { + assert(request.uri == "/api/stop" && request.body == "{}"); + ++stop_calls; + athan.sample.playing = false; + } + JsonDocument status; + status["playing"] = athan.sample.playing; + serializeJson(status, request.response); + } +}; +struct Device { + Device(Upgrade &upgrade, openathan_component::OpenAthan &athan) : api_storage{athan}, upgrade_(upgrade) { + auth_.configure("OpenAthan-test", md5_hex("admin:OpenAthan-test:test password")); + } + void handle_http_(HttpExchange &request); + bool valid_host_(const std::string &host) const; + std::string ip_() const { return "192.0.2.1"; } + bool maintenance_{}; + std::string hostname_{"device.local"}; + DigestAuth auth_; + uint64_t auth_window_{}; + unsigned auth_attempts_{}; + struct Asset { const uint8_t *data{}; size_t size{}; const char *type{}; } assets_[3]; + ApiProbe api_storage; + ApiProbe *api_{&api_storage}; + Upgrade &upgrade_; +}; +namespace { +#include "device_http_helpers.inc" +} +#include "device_http_handler.inc" +} + +struct Client { + esphome::openathan_device::Device &device; + std::string nonce; + unsigned count{}; + static HttpExchange request(const std::string &method, const std::string &uri) { + HttpExchange exchange; + exchange.method = method; exchange.uri = uri; exchange.body = "{}"; + exchange.host = "device.local"; exchange.origin = "http://device.local"; + exchange.site = "same-origin"; exchange.content_type = "application/json"; + return exchange; + } + explicit Client(esphome::openathan_device::Device &value) : device(value) { + auto exchange = request("GET", "/api/status"); + device.handle_http_(exchange); + assert(exchange.code == 401 && device.api_storage.calls == 0); + const auto start = exchange.challenge.find("nonce=\""); + assert(start != std::string::npos); + nonce = exchange.challenge.substr(start + 7, 48); + } + HttpExchange signed_request(const std::string &method, const std::string &uri, bool advance = true) { + if (advance) now_us += 1000000; + auto exchange = request(method, uri); + char nc[9]; std::snprintf(nc, sizeof(nc), "%08x", ++count); + const std::string cnonce = "http-test-client"; + const auto response = md5_hex(md5_hex("admin:OpenAthan-test:test password") + ":" + nonce + ":" + nc + + ":" + cnonce + ":auth:" + md5_hex(method + ":" + uri)); + exchange.authorization = "Digest username=\"admin\", realm=\"OpenAthan-test\", nonce=\"" + nonce + + "\", uri=\"" + uri + "\", algorithm=MD5, qop=auth, nc=" + nc + ", cnonce=\"" + cnonce + + "\", response=\"" + response + "\""; + return exchange; + } + HttpExchange call(const std::string &method, const std::string &uri) { + auto exchange = signed_request(method, uri); + device.handle_http_(exchange); + return exchange; + } +}; + +void check_http(Upgrade &upgrade, esphome::openathan_component::OpenAthan &athan) { + assert(upgrade.usb_busy()); + const auto before_state = state(upgrade), journal = saved_record; + const auto commits = nvs_commits, erase_count = erases, write_count = writes, selections = boot_selections; + const auto abort_count = aborts; + auto *const selected = boot_partition; + esphome::openathan_device::Device device(upgrade, athan); + Client client(device); + athan.sample.playing = true; + const auto status = client.call("GET", "/api/status"); + assert(status.code == 200); + JsonDocument payload; assert(!deserializeJson(payload, status.response) && payload["playing"] == true); + auto stop = client.call("POST", "/api/stop"); + std::cout << before_state << ": authenticated Stop=" << stop.code << std::endl; + assert(stop.code == 200 && device.api_storage.stop_calls == 1 && !athan.sample.playing); + assert(!deserializeJson(payload, stop.response) && payload["playing"] == false); + assert(client.call("POST", "/api/stop").code == 200 && device.api_storage.stop_calls == 2); + + const auto calls = device.api_storage.calls; + for (const char *path : {"/api/settings", "/api/preview", "/api/activate", "/api/skip", "/api/cancel-skip", + "/api/lights", "/api/display", "/api/time-format", "/api/firmware/check", "/api/firmware/install", + "/api/firmware/cancel", "/api/stop/", "/api/stop?ignored=true", "/api/stop-extra"}) { + assert(client.call("POST", path).code == 409); + } + assert(device.api_storage.calls == calls); + athan.sample.playing = true; + auto denied = [&](HttpExchange exchange, int code) { + const auto before_calls = device.api_storage.calls; + device.handle_http_(exchange); + assert(exchange.code == code && athan.sample.playing && device.api_storage.calls == before_calls); + }; + denied(Client::request("POST", "/api/stop"), 401); + auto invalid = client.signed_request("POST", "/api/stop"); invalid.authorization = "Digest invalid"; denied(invalid, 401); + invalid = client.signed_request("POST", "/api/stop"); invalid.host = "other.local"; denied(invalid, 403); + invalid = client.signed_request("POST", "/api/stop"); invalid.origin = "http://other.local"; denied(invalid, 403); + invalid = client.signed_request("POST", "/api/stop"); invalid.origin.clear(); invalid.site = "cross-site"; denied(invalid, 403); + invalid = client.signed_request("POST", "/api/stop"); invalid.content_type = "text/plain"; denied(invalid, 403); + denied(client.signed_request("PUT", "/api/stop"), 400); + device.maintenance_ = true; denied(client.signed_request("POST", "/api/stop"), 503); device.maintenance_ = false; + + // A signed request cannot be replayed to stop a later playback. + auto once = client.signed_request("POST", "/api/stop"); device.handle_http_(once); assert(once.code == 200); + athan.sample.playing = true; + auto replay = Client::request("POST", "/api/stop"); replay.authorization = once.authorization; denied(replay, 401); + device.auth_.configure("OpenAthan-test", ""); denied(Client::request("POST", "/api/stop"), 503); + + esphome::openathan_device::Device limited(upgrade, athan); Client rapid(limited); + for (unsigned i = 0; i < 20; ++i) { + auto read = rapid.signed_request("GET", "/api/status", false); limited.handle_http_(read); assert(read.code == 200); + } + auto excess = rapid.signed_request("POST", "/api/stop", false); limited.handle_http_(excess); + assert(excess.code == 429 && limited.api_storage.stop_calls == 0 && athan.sample.playing); + assert(upgrade.usb_busy() && state(upgrade) == before_state && saved_record == journal); + assert(nvs_commits == commits && erases == erase_count && writes == write_count && boot_selections == selections); + assert(aborts == abort_count && boot_partition == selected && esphome::App.reboots == 0 && http_requests.empty()); +} + +using namespace ::openathan::usb_upgrade; +std::string descriptor(Upgrade &upgrade) { + std::vector reply; + assert(upgrade.usb_command(BEGIN, {std::to_string(descriptor_response.size())}, reply) == 0); + assert(reply.size() == 1); + return reply[0]; +} +uint64_t verified(Upgrade &upgrade) { + const auto token_text = descriptor(upgrade); + const auto token = std::stoull(token_text, nullptr, 16); + for (size_t offset = 0; offset < descriptor_response.size(); offset += CHUNK) { + const auto bytes = std::span(reinterpret_cast(descriptor_response.data() + offset), + std::min(CHUNK, descriptor_response.size() - offset)); + assert(upgrade.usb_chunk({token, 0, uint32_t(offset), bytes})); + } + std::vector reply; + assert(upgrade.usb_command(VERIFY, {token_text}, reply) == 0); + return token; +} +void app_chunk(Upgrade &upgrade, uint64_t token, size_t offset) { + const auto bytes = std::span(reinterpret_cast(application_response.data() + offset), + std::min(CHUNK, application_response.size() - offset)); + assert(upgrade.usb_chunk({token, 1, uint32_t(offset), bytes})); +} +int main() { + // An actual partial transfer leaves its journal intact across restart. Normal + // scheduled playback can resume long after the interruption. + reset(); esphome::openathan_component::OpenAthan athan; + { + Upgrade transfer; begin(transfer, athan); + const auto token = verified(transfer); app_chunk(transfer, token, 0); + assert(state(transfer) == "usb_receiving" && writes == 1); + } + { + Upgrade restarted; begin(restarted, athan); now_us += 1000000000; restarted.loop(true); + assert(state(restarted) == "usb_interrupted"); check_http(restarted, athan); + } + for (const char *stage : {"usb_descriptor", "usb_receiving", "usb_selection_uncertain", "awaiting_power"}) { + reset(); esphome::openathan_component::OpenAthan current; + Upgrade upgrade; begin(upgrade, current); + if (std::string(stage) == "usb_descriptor") { + descriptor(upgrade); + } else { + const auto token = verified(upgrade); app_chunk(upgrade, token, 0); + if (std::string(stage) != "usb_receiving") { + for (size_t offset = CHUNK; offset < application_response.size(); offset += CHUNK) app_chunk(upgrade, token, offset); + selection_fails = selection_writes_before_failure = std::string(stage) == "usb_selection_uncertain"; + char text[17]; std::snprintf(text, sizeof(text), "%016llx", static_cast(token)); + std::vector reply; + assert(upgrade.usb_command(FINISH, {text}, reply) == (selection_fails ? 255 : 0)); + } + } + assert(state(upgrade) == stage); check_http(upgrade, current); + } + std::cout << "Production HTTP Stop access and USB ownership checks passed" << std::endl; +} diff --git a/tests/device_shutdown_tests.cpp b/tests/device_shutdown_tests.cpp index f38983f..b24eeb7 100644 --- a/tests/device_shutdown_tests.cpp +++ b/tests/device_shutdown_tests.cpp @@ -13,6 +13,7 @@ void Device::loop() { } void Device::on_wifi_scan_results(const wifi::wifi_scan_vector_t&) {} void Device::on_wifi_connect_state(StringRef, std::span) {} +Upgrade::~Upgrade() = default; void Upgrade::snapshot(JsonObject) {} int Upgrade::action(const std::string& action, JsonObjectConst, std::string&) { if (action == "arm_safe_worker") { cancel_ = false; safe_ = true; return 200; } diff --git a/tests/device_ui.test.cjs b/tests/device_ui.test.cjs index 3c25963..02d25cd 100644 --- a/tests/device_ui.test.cjs +++ b/tests/device_ui.test.cjs @@ -553,6 +553,46 @@ for(const browserName of (process.env.OPENATHAN_TEST_BROWSERS||'chromium').split }finally{await browser.close();await f.close();} }); } + test(`${browserName}: USB transfer and power handoff block network update controls`,async()=>{ + const f=await fixture();f.state.device.firmware={version:'v0.4.0',state:'usb_receiving',transport:'usb',queued_version:'v0.5.0',received:100,available:{version:'v0.5.0',bytes:300},error:''}; + const o=await open(f,browserName),{page}=o; + try { + await settings(page);await page.waitForFunction(()=>document.querySelector('#firmware-status').textContent.includes('over USB')); + assert.ok(await page.locator('#firmware-check').isDisabled()); + assert.ok(await page.locator('#firmware-cancel').isHidden()); + assert.ok(await page.locator('#firmware-install').isHidden()); + f.state.device.firmware={version:'v0.4.0',state:'usb_selection_uncertain',transport:'usb',queued_version:'v0.5.0',error:''}; + await page.waitForFunction(()=>document.querySelector('#firmware-status').textContent.includes('handoff is uncertain')); + assert.ok(await page.locator('#firmware-check').isDisabled()); + assert.ok(await page.locator('#firmware-cancel').isHidden()); + assert.ok(await page.locator('#firmware-install').isHidden()); + f.state.device.firmware={version:'v0.4.0',state:'awaiting_power',transport:'usb',queued_version:'v0.5.0',error:''}; + await page.waitForFunction(()=>document.querySelector('#firmware-status').textContent.includes('Pyramid bottom port')); + assert.ok(await page.locator('#firmware-check').isDisabled()); + assert.ok(await page.locator('#firmware-cancel').isHidden()); + assert.equal(f.state.posts.filter(p=>p.url.startsWith('/api/firmware/')).length,0); + }finally{await o.close();} + }); + for(const usbState of ['usb_descriptor','usb_receiving','usb_interrupted','usb_selection_uncertain','awaiting_power'])for(const view of ['today','settings'])test(`${browserName}: ${view} Stop remains available during ${usbState}`,async()=>{ + const f=await fixture();f.state.device.playing=true; + f.state.device.firmware={version:'v0.4.0',state:usbState,transport:'usb',queued_version:'v0.5.0',received:100,error:''}; + const before=structuredClone(f.state.device),o=await open(f,browserName),{page}=o; + try{ + if(view==='settings')await settings(page); + const button=page.locator(view==='settings'?'#settings-stop':'#stop'); + assert.ok(await button.isVisible());assert.ok(await button.isEnabled()); + await button.click();await button.waitFor({state:'hidden'});await requestsFinished(page); + assert.match(await page.locator(view==='settings'?'#settings-playback-feedback':'#action-feedback').textContent(),/Playback stopped/); + assert.deepEqual(f.state.posts.map(post=>({url:post.url,payload:post.payload})),[{url:'/api/stop',payload:{}}]); + assert.deepEqual(f.state.device,{...before,playing:false}); + if(view==='today')await settings(page); + assert.ok(await page.locator('#settings-stop').isHidden()); + assert.ok(await page.locator('#firmware-check').isDisabled()); + assert.ok(await page.locator('#firmware-cancel').isHidden()); + assert.ok(await page.locator('#firmware-install').isHidden()); + await page.locator('[data-view="today"]').click();assert.ok(await page.locator('#stop').isHidden()); + }finally{await o.close();} + }); test(`${browserName}: a queued task-start failure stays visibly cancellable`,async()=>{ const f=await fixture();f.state.device.firmware={version:'v0.2.0',state:'queued',queued_version:'v0.3.0',error:'Not enough memory to start the update; retrying after five minutes'}; const browser=await ({chromium,webkit}[browserName]).launch({headless:true}); diff --git a/tests/settings_adapter_tests.cpp b/tests/settings_adapter_tests.cpp index 73a97dc..20d9f76 100644 --- a/tests/settings_adapter_tests.cpp +++ b/tests/settings_adapter_tests.cpp @@ -476,6 +476,32 @@ static void light_api() { nvs_test::fail_commit=false; f.device.set_light_output(nullptr); CHECK(call("POST",body()).code==404); } +static void local_api_stop() { + using namespace esphome::openathan_device; + Fixture f; f.begin(); f.device.utc=epoch({2026,9,25},5)-1; f.device.update(); f.device.step(1); + CHECK(f.audio.active && f.device.status().playing); + LocalApi api(&f.device,nullptr,0); + auto call=[&](const char *uri,const std::string &body) { + ApiExchange request; request.method="POST"; request.uri=uri; request.body=body; + api.handle(request); return request; + }; + const auto settings=*f.device.settings_service()->saved(); + const auto records=nvs_test::committed; + const auto writes=nvs_test::writes, commits=nvs_test::commits, stops=f.audio.stops; + for (const char *body : {"", "{broken", "[]", "null"}) { + CHECK(call("/api/stop",body).code==400 && f.audio.active && f.audio.stops==stops); + } + CHECK(call("/api/stop",R"({"unexpected":true})").code==404 && f.audio.active && f.audio.stops==stops); + for (const char *uri : {"/api/stop/", "/api/stop?ignored=true", "/api/stop-extra"}) { + CHECK(call(uri,"{}").code==404 && f.audio.active && f.audio.stops==stops); + } + const auto stopped=call("/api/stop","{}"); JsonDocument status; + CHECK(stopped.code==200 && !deserializeJson(status,stopped.response) && status["playing"]==false); + CHECK(!f.audio.active && f.audio.stops==stops+1 && !f.device.status().playing); + CHECK(call("/api/stop","{}").code==200 && !f.audio.active); + CHECK(*f.device.settings_service()->saved()==settings && nvs_test::committed==records); + CHECK(nvs_test::writes==writes && nvs_test::commits==commits); +} static void local_api_date() { using namespace esphome::openathan_device; Fixture f;f.begin();f.device.utc=epoch({2026,9,25},23); @@ -712,6 +738,6 @@ int main() { display_adapter(); display_integration(); light_time_and_setup(); light_integration(); updates_and_replay(); volume_and_faults(); timezones(); occurrence_identity(); setup_gate_and_preview(); maintenance_latches_writes(); #ifdef OPENATHAN_JSON_TEST - display_api(); time_format_api(); light_api(); json_transport(); coordinate_roundtrip(); local_api(); local_api_date(); local_api_coordinates(); + display_api(); time_format_api(); light_api(); json_transport(); coordinate_roundtrip(); local_api(); local_api_stop(); local_api_date(); local_api_coordinates(); #endif } diff --git a/tests/upgrade_runtime_tests.cpp b/tests/upgrade_runtime_tests.cpp index da9a73a..fa8f388 100644 --- a/tests/upgrade_runtime_tests.cpp +++ b/tests/upgrade_runtime_tests.cpp @@ -31,7 +31,9 @@ void nvs_close(nvs_handle_t){} int nvs_erase_all(nvs_handle_t){assert(false);return -1;} std::string state(Upgrade &update){JsonDocument doc;update.snapshot(doc.to());return doc["state"].as();} int action(Upgrade &update,const char *name,const char *version=nullptr){JsonDocument doc;auto root=doc.to();if(version)root["version"]=version;std::string error;return update.action(name,root,error);} -void reset(){reset_http();assert(live_read_buffers==0);read_buffer_allocation_fails=false;read_buffer_allocation_attempts=0;now_us=0;saved_record.clear();staged_record.clear();commit_ok=true;cut_after_commit=false;signature_valid=true;boot_readable=true;transfer_fail=false;on_open=on_read=nullptr;pending_worker=nullptr;task_available=true;task_attempts=0;boot_selections=aborts=rollbacks=confirmations=erases=writes=live_ota_handles=0;begin_allocation_fails=begin_erase_fails=false;image_valid=true;running_state=ESP_OTA_IMG_VALID;inactive_state=ESP_OTA_IMG_UNDEFINED;boot_partition=&running;on_boot_selection=nullptr;inactive_version="v0.3.0";esphome::App.reboots=0; +void reset(){reset_http();assert(live_read_buffers==0);read_buffer_allocation_fails=false;read_buffer_allocation_attempts=0;now_us=0;saved_record.clear();staged_record.clear();commit_ok=true;cut_after_commit=false;signature_valid=true;boot_readable=true;transfer_fail=false;on_open=on_read=nullptr;pending_worker=nullptr;task_available=true;task_attempts=0;boot_selections=aborts=rollbacks=confirmations=erases=writes=live_ota_handles=0;begin_allocation_fails=begin_erase_fails=false;image_valid=true; + boot_query_fails=description_query_fails=candidate_query_fails=false;inactive_state_result=ESP_OK;selection_fails=selection_writes_before_failure=false;metadata_erased=false;nvs_commits=0; + running_state=ESP_OTA_IMG_VALID;inactive_state=ESP_OTA_IMG_UNDEFINED;boot_partition=&running;on_boot_selection=nullptr;inactive_version="v0.3.0";esphome::App.reboots=0; application_response=std::string(512,'a');uint8_t bytes[32];mbedtls_sha256(reinterpret_cast(application_response.data()),application_response.size(),bytes,0); std::string hash;for(auto b:bytes){hash+="0123456789abcdef"[b>>4];hash+="0123456789abcdef"[b&15];} JsonDocument payload;payload["schema"]=1;payload["version"]="v0.3.0";payload["commit"]=std::string(40,'a');payload["hardware"]="atoms3r-c126-pyramid-a167";payload["layout"]="dual-2m-audio-3_5m-v1";payload["storageFormat"]=1;payload["audioFormat"]=1;payload["rollback"]=true;payload["bytes"]=512;payload["sha256"]=hash; diff --git a/tests/upgrade_stubs/esp_ota_ops.h b/tests/upgrade_stubs/esp_ota_ops.h index 9d69723..49e949a 100644 --- a/tests/upgrade_stubs/esp_ota_ops.h +++ b/tests/upgrade_stubs/esp_ota_ops.h @@ -4,6 +4,7 @@ #include #include using esp_ota_handle_t=unsigned; +constexpr int ESP_ERR_NOT_FOUND=2; enum esp_ota_img_states_t {ESP_OTA_IMG_UNDEFINED,ESP_OTA_IMG_NEW,ESP_OTA_IMG_PENDING_VERIFY, ESP_OTA_IMG_VALID,ESP_OTA_IMG_INVALID,ESP_OTA_IMG_ABORTED}; struct esp_app_desc_t {char version[32];}; @@ -13,6 +14,9 @@ inline esp_ota_img_states_t running_state=ESP_OTA_IMG_VALID; inline esp_ota_img_states_t inactive_state=ESP_OTA_IMG_UNDEFINED; inline const esp_partition_t *boot_partition=&running; inline void (*on_boot_selection)()=nullptr; +inline bool selection_fails=false, selection_writes_before_failure=false; +inline bool boot_query_fails=false, description_query_fails=false, candidate_query_fails=false; +inline int inactive_state_result=ESP_OK; inline std::string inactive_version="v0.3.0"; inline std::string flashed; inline unsigned boot_selections{},aborts{},rollbacks{},confirmations{}; @@ -21,14 +25,14 @@ inline unsigned live_ota_handles{}; inline bool begin_allocation_fails=false,begin_erase_fails=false; inline bool image_valid=true; inline const esp_partition_t *esp_ota_get_running_partition(){return &running;} -inline const esp_partition_t *esp_ota_get_boot_partition(){return boot_partition;} -inline const esp_partition_t *esp_ota_get_next_update_partition(void *){return &inactive;} +inline const esp_partition_t *esp_ota_get_boot_partition(){return boot_query_fails?nullptr:boot_partition;} +inline const esp_partition_t *esp_ota_get_next_update_partition(void *){return candidate_query_fails?nullptr:&inactive;} inline int esp_ota_begin(const esp_partition_t *,unsigned,unsigned *handle){if(begin_allocation_fails)return -1;*handle=++live_ota_handles;++erases;if(begin_erase_fails)return -1;flashed.clear();inactive_state=ESP_OTA_IMG_UNDEFINED;return 0;} inline int esp_ota_write(unsigned,const void *data,size_t size){++writes;flashed.append(static_cast(data),size);return 0;} inline int esp_ota_abort(unsigned handle){assert(handle && live_ota_handles);++aborts;--live_ota_handles;return 0;} inline int esp_ota_end(unsigned handle){assert(handle && live_ota_handles);--live_ota_handles;return image_valid?0:-1;} -inline int esp_ota_get_partition_description(const esp_partition_t *,esp_app_desc_t *desc){strcpy(desc->version,inactive_version.c_str());return 0;} -inline int esp_ota_get_state_partition(const esp_partition_t *partition,esp_ota_img_states_t *state){*state=partition==&running?running_state:inactive_state;return partition==&running&&metadata_erased?-1:0;} +inline int esp_ota_get_partition_description(const esp_partition_t *,esp_app_desc_t *desc){if(description_query_fails)return -1;strcpy(desc->version,inactive_version.c_str());return 0;} +inline int esp_ota_get_state_partition(const esp_partition_t *partition,esp_ota_img_states_t *state){*state=partition==&running?running_state:inactive_state;return partition==&running?(metadata_erased?-1:0):inactive_state_result;} inline int esp_ota_mark_app_valid_cancel_rollback(){++confirmations;running_state=ESP_OTA_IMG_VALID;return 0;} inline int esp_ota_mark_app_invalid_rollback_and_reboot(){++rollbacks;return 0;} -inline int esp_ota_set_boot_partition(const esp_partition_t *partition){++boot_selections;boot_partition=partition;if(partition==&running){metadata_erased=false;running_state=ESP_OTA_IMG_NEW;}else inactive_state=ESP_OTA_IMG_NEW;if(on_boot_selection)on_boot_selection();return 0;} +inline int esp_ota_set_boot_partition(const esp_partition_t *partition){++boot_selections;if(selection_fails&&!selection_writes_before_failure)return -1;boot_partition=partition;if(partition==&running){metadata_erased=false;running_state=ESP_OTA_IMG_NEW;}else inactive_state=ESP_OTA_IMG_NEW;if(on_boot_selection)on_boot_selection();return selection_fails?-1:0;} diff --git a/tests/usb_upgrade_tests.cpp b/tests/usb_upgrade_tests.cpp new file mode 100644 index 0000000..7324262 --- /dev/null +++ b/tests/usb_upgrade_tests.cpp @@ -0,0 +1,186 @@ +// Reuse the production updater's deterministic flash/NVS/clock adapters. +#define main existing_ota_tests +#include "upgrade_runtime_tests.cpp" +#undef main +using namespace ::openathan::usb_upgrade; +uint64_t token_value(const std::string &text) { return std::stoull(text, nullptr, 16); } +std::string start_usb(Upgrade &u) { + std::vector reply; + assert(u.usb_command(BEGIN,{std::to_string(descriptor_response.size())},reply)==0); + assert(reply.size()==1); + const auto token=token_value(reply[0]); + for(size_t offset=0;offset(descriptor_response.data()+offset),std::min(CHUNK,descriptor_response.size()-offset)); + assert(u.usb_chunk({token,0,uint32_t(offset),data})); + } + return reply[0]; +} +void send_app(Upgrade &u,const std::string &text) { + for(size_t offset=0;offset(application_response.data()+offset),std::min(CHUNK,application_response.size()-offset)); + assert(u.usb_chunk({token_value(text),1,uint32_t(offset),data})); + } +} +enum class MetadataFault { BOOT, DESCRIPTION, STATE, MISSING_STATE, REJECTED_STATE, VERSION, CANDIDATE, OTHER_BOOT }; +void metadata_fault(MetadataFault fault) { + switch(fault) { + case MetadataFault::BOOT: boot_query_fails=true;break; + case MetadataFault::DESCRIPTION: description_query_fails=true;break; + case MetadataFault::STATE: inactive_state_result=-1;break; + case MetadataFault::MISSING_STATE: inactive_state_result=ESP_ERR_NOT_FOUND;break; + case MetadataFault::REJECTED_STATE: inactive_state=ESP_OTA_IMG_ABORTED;break; + case MetadataFault::VERSION: inactive_version="v0.2.1";break; + case MetadataFault::CANDIDATE: candidate_query_fails=true;break; + case MetadataFault::OTHER_BOOT: { + static const esp_partition_t other{0x200000,0x410000};boot_partition=&other;break; + } + } +} +void restore_selected_metadata() { + boot_query_fails=description_query_fails=candidate_query_fails=false; + inactive_state_result=ESP_OK;inactive_state=ESP_OTA_IMG_NEW;inactive_version="v0.3.0";boot_partition=&inactive; +} +void save_usb_request(const char *expected) { + JsonDocument record;record["schema"]=2;record["source"]="usb"; + record["queue"]=descriptor_response;record["expected"]=expected;serializeJson(record,saved_record); +} +void expect_usb_blocked(Upgrade &u,const std::string &record) { + const auto commits=nvs_commits,erase_count=erases,write_count=writes,selection_count=boot_selections; + std::vector reply; + assert(u.usb_busy() && saved_record==record); + assert(u.usb_command(ABORT,{"interrupted"},reply)==255); + assert(u.usb_command(BEGIN,{"200"},reply)==255); + assert(action(u,"check")==409 && action(u,"cancel")==409 && action(u,"install","v0.3.0")==409); + assert(saved_record==record && nvs_commits==commits && erases==erase_count && writes==write_count && boot_selections==selection_count); + assert(esphome::App.reboots==0 && http_requests.empty()); +} +void check_selection_reads() { + for(bool failed_finish:{false,true}) for(auto fault:{MetadataFault::BOOT,MetadataFault::DESCRIPTION, + MetadataFault::STATE,MetadataFault::MISSING_STATE,MetadataFault::REJECTED_STATE, + MetadataFault::VERSION,MetadataFault::CANDIDATE,MetadataFault::OTHER_BOOT}) { + reset();Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a); + const auto token=start_usb(u);std::vector reply; + assert(u.usb_command(VERIFY,{token},reply)==0);send_app(u,token); + selection_fails=selection_writes_before_failure=failed_finish; + assert(u.usb_command(FINISH,{token},reply)==(failed_finish?255:0)); + const auto record=saved_record;const auto commits=nvs_commits; + const auto erase_count=erases,write_count=writes,selection_count=boot_selections;metadata_fault(fault); + for(unsigned i=0;i<3;++i) {now_us+=100000000;u.loop(true);assert(state(u)=="usb_selection_uncertain");expect_usb_blocked(u,record);} + assert(nvs_commits==commits && erases==erase_count && writes==write_count && boot_selections==selection_count); + Upgrade restarted;begin(restarted,a); + assert(state(restarted)=="usb_selection_uncertain");expect_usb_blocked(restarted,record); + restore_selected_metadata();restarted.loop(true); + assert(state(restarted)=="awaiting_power" && nvs_commits==commits);expect_usb_blocked(restarted,record); + assert(erases==erase_count && writes==write_count && boot_selections==selection_count); + } + // A previous firmware may already have dropped its handoff marker. Empty + // expected is never sufficient evidence that its selected image is discardable. + for(bool unreadable:{false,true}) { + reset();save_usb_request("");boot_partition=&inactive;inactive_state=ESP_OTA_IMG_NEW; + boot_query_fails=unreadable;const auto record=saved_record; + Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a); + assert(state(u)==(unreadable?"usb_selection_uncertain":"awaiting_power"));expect_usb_blocked(u,record); + restore_selected_metadata();u.loop(true);assert(state(u)=="awaiting_power");expect_usb_blocked(u,record); + } + // Missing inactive metadata is normal after esp_ota_begin. A successfully + // read current boot selection permits recovery, including a partial image. + for(const char *expected:{"","v0.3.0"}) { + reset();save_usb_request(expected);inactive_state_result=ESP_ERR_NOT_FOUND; + description_query_fails=std::string(expected).empty(); + Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a); + assert(state(u)=="usb_interrupted");expect_record(descriptor_response,""); + std::vector reply;assert(u.usb_command(ABORT,{"interrupted"},reply)==0); + assert(!u.usb_busy() && erases==0 && writes==0 && boot_selections==0 && http_requests.empty()); + } + // Status is only an observation: discard must re-read metadata at command time. + for(bool selected:{false,true}) { + reset();save_usb_request("");Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a); + assert(state(u)=="usb_interrupted");const auto record=saved_record; + if(selected) {boot_partition=&inactive;inactive_state=ESP_OTA_IMG_NEW;} else boot_query_fails=true; + expect_usb_blocked(u,record); + assert(state(u)==(selected?"awaiting_power":"usb_selection_uncertain")); + boot_query_fails=false;boot_partition=&running;u.loop(true);assert(state(u)=="usb_interrupted"); + std::vector reply;assert(u.usb_command(ABORT,{"interrupted"},reply)==0 && !u.usb_busy()); + } + for(auto rejection:{ESP_OTA_IMG_INVALID,ESP_OTA_IMG_ABORTED}) { + reset();save_usb_request("v0.3.0");inactive_state=rejection; + Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a); + expect_result(u,"rolled_back");assert(!u.usb_busy() && boot_selections==0 && writes==0); + } + reset();save_usb_request("");Upgrade pending;esphome::openathan_component::OpenAthan a; + pending.begin(&a,true);const auto record=saved_record;std::vector reply; + assert(pending.usb_command(ABORT,{"interrupted"},reply)==255 && saved_record==record); +} +int main() { + if(std::string(OPENATHAN_FIRMWARE_VERSION)=="v0.4.0") { + // A maintainer-installed newer app can supersede a USB request, but an + // older candidate may still be selected. Supersession must not erase proof. + for(const char *expected:{"","v0.3.0"}) for(bool unreadable:{false,true}) { + reset();save_usb_request(expected);boot_partition=&inactive;inactive_state=ESP_OTA_IMG_NEW; + boot_query_fails=unreadable;const auto record=saved_record; + Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a); + assert(state(u)==(unreadable?"usb_selection_uncertain":"awaiting_power"));expect_usb_blocked(u,record); + restore_selected_metadata();u.loop(true);assert(state(u)=="awaiting_power");expect_usb_blocked(u,record); + boot_partition=&running;u.loop(true);assert(state(u)=="current" && !u.usb_busy()); + expect_result(u,"superseded");expect_record("",""); + assert(erases==0 && writes==0 && boot_selections==0 && esphome::App.reboots==0 && http_requests.empty()); + } + std::cout<<"Superseded USB requests require positive boot-selection reconciliation\n";return 0; + } + if(std::string(OPENATHAN_FIRMWARE_VERSION)=="v0.3.0") { + for(const char *expected:{"","v0.3.0"}) { + reset();save_usb_request(expected);running_state=ESP_OTA_IMG_PENDING_VERIFY; + Upgrade u;esphome::openathan_component::OpenAthan a;u.begin(&a,true); + const auto record=saved_record;std::vector reply; + assert(u.usb_command(ABORT,{"interrupted"},reply)==255 && saved_record==record); + now_us=30000000;u.loop(false); + expect_result(u,"success");assert(confirmations==1&&boot_selections==0&&writes==0); + assert(!u.usb_busy());expect_record("",""); + reset();save_usb_request(expected);running_state=ESP_OTA_IMG_PENDING_VERIFY; + Upgrade unhealthy;a.health=false;begin(unhealthy,a);const auto pending_record=saved_record; + assert(unhealthy.usb_command(ABORT,{"interrupted"},reply)==255 && saved_record==pending_record); + now_us=90000000;unhealthy.loop(false);assert(confirmations==0 && rollbacks==1 && saved_record==pending_record); + } + std::cout<<"USB handoff confirms only after startup health\n";return 0; + } + check_selection_reads(); + {reset();Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a);signature_valid=false; + const auto token=start_usb(u);std::vector reply; + assert(u.usb_command(VERIFY,{token},reply)==1);assert(erases==0&&writes==0&&saved_record.empty());} + {reset();Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a);a.clock.valid=false;a.health=false; + std::vector reply;assert(u.usb_command(BEGIN,{"200"},reply)==255&&erases==0);} + {reset();Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a);const auto token=start_usb(u);std::vector reply; + assert(u.usb_command(VERIFY,{token},reply)==0);assert(erases==1&&writes==0);assert(action(u,"check")==409); + auto data=std::span(reinterpret_cast(application_response.data()),size_t{CHUNK}); + assert(!u.usb_chunk({token_value(token),1,1,data}));assert(!u.usb_chunk({token_value(token)+1,1,0,data})); + assert(u.usb_chunk({token_value(token),1,0,data}));assert(!u.usb_chunk({token_value(token),1,0,data})); + assert(u.usb_command(ABORT,{token},reply)==0&&live_ota_handles==0&&boot_selections==0);} + {reset();Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a);a.clock.valid=false; + const auto token=start_usb(u);std::vector reply;assert(u.usb_command(VERIFY,{token},reply)==0); + send_app(u,token);assert(u.usb_command(FINISH,{token},reply)==0);assert(reply[0]=="verified");assert(state(u)=="awaiting_power"&&boot_selections==1); + const auto record=saved_record;now_us+=100000000;u.loop(true);assert(saved_record==record&&esphome::App.reboots==0&&http_requests.empty());} + {reset();Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a);const auto token=start_usb(u);std::vector reply; + assert(u.usb_command(VERIFY,{token},reply)==0);application_response[0]='b';send_app(u,token); + assert(u.usb_command(FINISH,{token},reply)==1&&boot_selections==0&&live_ota_handles==0);} + {reset();Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a);const auto token=start_usb(u);std::vector reply; + assert(u.usb_command(VERIFY,{token},reply)==0);now_us+=41000000;u.loop(false);assert(aborts==1&&boot_selections==0&&!u.usb_busy());} + {reset();Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a);const auto token=start_usb(u);std::vector reply; + assert(u.usb_command(VERIFY,{token},reply)==0);send_app(u,token);cut_after_commit=true; + try{u.usb_command(FINISH,{token},reply);assert(false);}catch(const PowerCut&){}cut_after_commit=false; + Upgrade restarted;begin(restarted,a);restarted.loop(true);assert(state(restarted)=="usb_interrupted"&&http_requests.empty()&&boot_selections==0); + assert(restarted.usb_command(ABORT,{"interrupted"},reply)==0&&!restarted.usb_busy());} + {reset();Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a);const auto token=start_usb(u);std::vector reply; + assert(u.usb_command(VERIFY,{token},reply)==0);send_app(u,token);assert(u.usb_command(FINISH,{token},reply)==0); + inactive_state=ESP_OTA_IMG_ABORTED;boot_partition=&running;Upgrade restarted;begin(restarted,a); + expect_result(restarted,"rolled_back");assert(!restarted.usb_busy()&&http_requests.empty());} + for(bool selected : {false,true}) { + reset();Upgrade u;esphome::openathan_component::OpenAthan a;begin(u,a);const auto token=start_usb(u);std::vector reply; + assert(u.usb_command(VERIFY,{token},reply)==0);send_app(u,token); + selection_fails=true;selection_writes_before_failure=selected; + assert(u.usb_command(FINISH,{token},reply)==255&&state(u)=="usb_selection_uncertain"); + assert(!saved_record.empty()&&u.usb_busy());u.loop(true); + assert(state(u)==(selected?"awaiting_power":"usb_interrupted")&&http_requests.empty()&&esphome::App.reboots==0); + selection_fails=false;selection_writes_before_failure=false; + } + std::cout<<"USB signature, transfer ordering, power handoff, timeout and rollback checks passed\n"; +} diff --git a/tools/check_upgrade_runtime.py b/tools/check_upgrade_runtime.py index c197dff..a08e32e 100755 --- a/tools/check_upgrade_runtime.py +++ b/tools/check_upgrade_runtime.py @@ -3,6 +3,7 @@ import argparse import os from pathlib import Path +import re import subprocess import tempfile ROOT = Path(__file__).resolve().parents[1] @@ -19,20 +20,42 @@ print("Qualification component refuses production and incomplete test profiles") with tempfile.TemporaryDirectory(prefix="openathan-upgrade-runtime-") as temporary: output = Path(temporary) / "test" - def run(source, version, extra=()): + # Compile the HTTP entrypoint unchanged, as in check_upgrade_http.py. Only + # platform I/O and API dispatch are substituted; the USB gate and all access + # checks come from production source. Fail rather than silently omit a body. + device = (ROOT / "firmware/esphome/components/openathan_device/device.cpp").read_text() + def function(signature): + match = re.search(r"^" + re.escape(signature) + r" \{(?:[^\n]*\}\n|.*?^\}\n)", device, re.M | re.S) + if not match: + raise ValueError(f"Production HTTP function not found: {signature}") + return match.group(0) + (Path(temporary) / "device_http_helpers.inc").write_text("\n".join(function(signature) for signature in ( + "uint64_t now_ms()", "std::string random_nonce()", + "void error(HttpExchange& request, int code, const char* message)"))) + (Path(temporary) / "device_http_handler.inc").write_text("\n".join(function(signature) for signature in ( + "bool Device::valid_host_(const std::string& host) const", + "void Device::handle_http_(HttpExchange& request)"))) + def run(source, version, extra=(), additional=()): subprocess.run([os.environ.get("CXX", "c++"), "-std=c++20", "-fsanitize=undefined", "-fno-sanitize-recover=all", '-DOPENATHAN_ROLLBACK_BOOTLOADERS="c35020473aed1b4642cd726cad727b63fff2824ad68cedd7ffb73c7cbd890479"', f'-DOPENATHAN_FIRMWARE_VERSION="{version}"', '-DOPENATHAN_BUILD_COMMIT="test"', '-DOPENATHAN_UPGRADE_PUBLIC_KEY="test key"', *extra, "-I"+str(ROOT / "tests/upgrade_stubs"), "-I"+str(ROOT / "tests/stubs"), "-I"+str(args.arduinojson), + "-I"+temporary, "-I"+str(ROOT / "firmware"), "-I"+str(ROOT / "firmware/esphome/components/openathan_device"), "-I"+str(ROOT / "lib/openathan-core/include"), str(ROOT / "tests" / source), str(ROOT / "firmware/esphome/components/openathan_device/upgrade.cpp"), + str(ROOT / "firmware/esphome/components/openathan_device/usb_upgrade.cpp"), + *additional, *([str(ROOT / "firmware/esphome/components/openathan_upgrade_qualification/qualification.cpp")] if "-DOPENATHAN_UPGRADE_QUALIFICATION" in extra else []), *([] if os.uname().sysname == "Darwin" else ["-lcrypto"]), "-o", str(output)], check=True) subprocess.run([output], check=True) + run("device_http_tests.cpp", "v0.2.0", additional=(str(ROOT / "firmware/esphome/components/openathan_device/digest.cpp"),)) for version in ("v0.2.0", "v0.2.1", "v0.3.0", "v0.4.0"): run("upgrade_runtime_tests.cpp", version) + run("usb_upgrade_tests.cpp", "v0.2.0") + run("usb_upgrade_tests.cpp", "v0.3.0") + run("usb_upgrade_tests.cpp", "v0.4.0") run("upgrade_isolated_tests.cpp", "v0.2.0", ["-DOPENATHAN_PROVISIONING_TEST_STORAGE"]) for failure in ("false", "true"): run("upgrade_qualification_tests.cpp", "v0.2.0", [ diff --git a/web/device-ui/README.md b/web/device-ui/README.md index 01c9b13..443ce08 100644 --- a/web/device-ui/README.md +++ b/web/device-ui/README.md @@ -179,7 +179,11 @@ The [firmware upgrade flow](../../docs/development/firmware-upgrades.md) checks for stable releases and queues owner-requested application updates between prayers. The authenticated **Updates** group provides check, install, cancel and reconnect/status controls. The public USB installer remains a fresh-install and -credential-recovery tool. Quran/adhkar and owner-facing audio replacement remain +credential-recovery tool, with a separately gated preserving USB updater for +capable firmware. While a USB request is active, the local Updates group displays +transfer, interruption or owner power-handoff status and hides network cancellation; +firmware rejects competing mutations. See the [USB protocol](../../docs/development/usb-firmware-updates.md). +Quran/adhkar and owner-facing audio replacement remain future work. The [redesign validation report](../../docs/development/device-ui-redesign-validation-2026-10-05.md) diff --git a/web/device-ui/app.js b/web/device-ui/app.js index f76d4e2..2fe6108 100644 --- a/web/device-ui/app.js +++ b/web/device-ui/app.js @@ -1577,6 +1577,11 @@ const firmwareMessages = { success: "Firmware updated successfully.", rolled_back: "The update could not start successfully. The previous firmware has been restored.", failed: "The update could not complete. Check again to retry.", + usb_descriptor: "Receiving a signed update descriptor over USB…", + usb_receiving: "Receiving firmware over USB. Keep Atom USB connected.", + usb_interrupted: "USB transfer interrupted. Reconnect through USB setup to discard it before another update.", + usb_selection_uncertain: "USB handoff is uncertain. Reconnect and read update status before another action.", + awaiting_power: "Update written and verified. Unplug Atom USB and power only through the Pyramid bottom port.", storage_fault: "Update storage is unavailable. Restart the device; saved data has been retained." }; function renderFirmware(state = firmware.data) { @@ -1592,7 +1597,7 @@ function renderFirmware(state = firmware.data) { `Installed: ${state.version}${state.queued_version ? ` · Queued: ${state.queued_version}` : state.available ? ` · Available: ${state.available.version}` : ""}` ); const progress = - state.state === "downloading" && state.total + ["downloading", "usb_receiving"].includes(state.state) && state.total ? ` ${Math.floor((100 * state.received) / state.total)}%` : ""; text( @@ -1615,7 +1620,7 @@ function renderFirmware(state = firmware.data) { ? `Last checked: ${new Date(state.last_check * 1000).toLocaleString(undefined, { hour12: formatHours() === 12, timeZone: "UTC" }) + " UTC"}` : "No successful update check yet." ); - const working = ["checking", "queued", "downloading", "verifying", "restarting"].includes( + const working = ["checking", "queued", "downloading", "verifying", "restarting", "usb_descriptor", "usb_receiving", "usb_interrupted", "usb_selection_uncertain", "awaiting_power"].includes( state.state ); $("firmware-check").disabled = @@ -1633,7 +1638,7 @@ function renderFirmware(state = firmware.data) { firmwareUncertain || state.state === "storage_fault" || state.supported === false; - $("firmware-cancel").hidden = !["queued", "downloading", "verifying"].includes(state.state); + $("firmware-cancel").hidden = state.transport === "usb" || !["queued", "downloading", "verifying"].includes(state.state); $("firmware-cancel").disabled = firmwareBusy || writing() || !connected || firmwareUncertain; $("firmware-notes").hidden = !state.available; if (state.available)