From bc9f0e80cbaf2588c4c23da0f7d610f2b718cc7c Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 19 Sep 2026 11:44:05 -0400 Subject: [PATCH 1/3] Prepare DeskOS 1.8.0 production release [skip ci] --- README.md | 8 ++--- docs/COMPANION_3BYTE_COMPATIBILITY.md | 2 +- docs/DESKOS_MESHCORE_FEATURE_PARITY.md | 4 +-- docs/KNOWN_LIMITATIONS.md | 10 +++--- docs/RC1_RELEASE_EXECUTION_D1L.md | 8 ++--- docs/RELEASE_CHECKLIST.md | 6 ++-- docs/RELEASE_NOTES_1.8.0.md | 43 +++++++++++++++++++++++++ docs/ROADMAP.md | 19 ++++++++++- docs/USER_GUIDE_D1L.md | 4 +-- main/d1l_config.h | 2 +- tests/test_release_metadata_contract.py | 2 +- 11 files changed, 84 insertions(+), 24 deletions(-) create mode 100644 docs/RELEASE_NOTES_1.8.0.md diff --git a/README.md b/README.md index 0883bba7..e9fb9b08 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@

A bright, touch-first MeshCore desk for the SenseCAP Indicator D1L.

-DeskOS **1.8.0-rc.6** is the release candidate for the SenseCAP Indicator D1L. +DeskOS **1.8.0** is the stable release for the SenseCAP Indicator D1L. It uses the production `full_feature` profile with conditional SD-primary retained history (`conditional` storage mode). -[Release candidate](https://github.com/n30nex/DeskOS-MeshCore/releases/tag/v1.8.0-rc.6) +[Stable release](https://github.com/n30nex/DeskOS-MeshCore/releases/tag/v1.8.0) · [Browser flasher](https://flasher.canadaverse.org/) · [User guide](docs/USER_GUIDE_D1L.md) · [Product page](https://canadaverse.org/deskos/) @@ -18,7 +18,7 @@ It uses the production `full_feature` profile with conditional SD-primary retain The previous stable release is [1.7.12](https://github.com/n30nex/DeskOS-MeshCore/releases/tag/v1.7.12). -## What the candidate improves +## What is new in 1.8.0 - **Received hop counts** use the saved packet path. A nearby Public message no longer appears as 63 hops because of a fixed companion placeholder. @@ -70,7 +70,7 @@ The previous stable release is - An ordinary update can finish without optional SD storage. Fresh clean installations retain the complete three-stage verification. -See the [candidate release notes](docs/RELEASE_NOTES_1.8.0-rc.6.md) for the full +See the [release notes](docs/RELEASE_NOTES_1.8.0.md) for the full change and validation record. Earlier releases remain documented in the [roadmap](docs/ROADMAP.md) and their release notes. diff --git a/docs/COMPANION_3BYTE_COMPATIBILITY.md b/docs/COMPANION_3BYTE_COMPATIBILITY.md index 3c76708f..b730df26 100644 --- a/docs/COMPANION_3BYTE_COMPATIBILITY.md +++ b/docs/COMPANION_3BYTE_COMPATIBILITY.md @@ -1,6 +1,6 @@ # BLE and 3-Byte Companion Compatibility -Updated for DeskOS 1.8.0-rc.6 +Updated for DeskOS 1.8.0 MeshCore DeskOS D1L must be compatible with MeshCore companion clients in both meanings used by current MeshCore references. diff --git a/docs/DESKOS_MESHCORE_FEATURE_PARITY.md b/docs/DESKOS_MESHCORE_FEATURE_PARITY.md index 9157562f..5bb0b4ec 100644 --- a/docs/DESKOS_MESHCORE_FEATURE_PARITY.md +++ b/docs/DESKOS_MESHCORE_FEATURE_PARITY.md @@ -1,6 +1,6 @@ # DeskOS interface and MeshCore parity -This is the current product capability ledger for DeskOS 1.8.0-rc.6 on the +This is the current product capability ledger for DeskOS 1.8.0 on the SenseCAP Indicator D1L. The original mobile baseline was reviewed on 2026-08-08 against the official [MeshCore Android listing](https://play.google.com/store/apps/details?id=com.liamcottle.meshcore.android) and [MeshCore iOS 1.47.0 listing](https://apps.apple.com/gb/app/meshcore/id6742354151). @@ -30,7 +30,7 @@ the board/app documentation. DeskOS uses its own implementation and artwork; no WadaMesh source, fonts or assets were copied into the firmware. The target is a useful touch workflow on the 480×480 D1L. Existing phone -interoperability remains required. **This candidate does not claim complete +interoperability remains required. **This release does not claim complete WadaMesh feature parity.** The older mobile-completion states below do not close the additional differences in this table. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index 4bb3a16e..78a8c7ce 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -1,4 +1,4 @@ -# DeskOS D1L 1.8.0-rc.6 limitations +# DeskOS D1L 1.8.0 limitations The RC1 channel dead-end (#320) and Contacts navigation gap (#321) are fixed in the 1.2 implementation. These are the remaining intentional product limits: @@ -37,7 +37,7 @@ SD-primary storage. - New messages use a plausible sender timestamp or the trusted local arrival time. Older retained rows without either remain labelled `time unknown`. - Optional Indicator temperature, humidity, and CO2 sensor integration remains - future work and is not represented as live data in 1.8.0-rc.6. + future work and is not represented as live data in 1.8.0. See [`DESKOS_MESHCORE_FEATURE_PARITY.md`](DESKOS_MESHCORE_FEATURE_PARITY.md) for the complete mobile-to-D1L outcome matrix. @@ -49,6 +49,6 @@ editable plain-text excerpts, without structured cross-client quote identifiers. Drafts use prepared SD and remain session-only without it; the 72-entry limit never silently evicts another draft. Clipboard text stays on this D1L and clears when locked or restarted. See the pinned WadaMesh section of the existing parity -record for each area. This candidate retains the earlier physical acceptance -limits: candidate-specific phone results belong in the release record, and signed -SD installation/rollback and an RP2040 reflash need separate physical tests. +record for each area. Exact hardware acceptance and any remaining test limits belong in the tagged +release record. A stable release does not claim complete WadaMesh parity or +sensor responses that were not observed. diff --git a/docs/RC1_RELEASE_EXECUTION_D1L.md b/docs/RC1_RELEASE_EXECUTION_D1L.md index 833aa1c7..ea08db96 100644 --- a/docs/RC1_RELEASE_EXECUTION_D1L.md +++ b/docs/RC1_RELEASE_EXECUTION_D1L.md @@ -1,9 +1,9 @@ # DeskOS D1L release execution -## Current release procedure: 1.8.0-rc.6 +## Current release procedure: 1.8.0 -The maintainer re-enabled GitHub Actions for RC6. Dispatch the existing -`d1l-ci` workflow on the exact candidate ref with the SD bridge included. +The maintainer re-enabled GitHub Actions for the 1.8 production release. Dispatch the existing +`d1l-ci` workflow on the exact release ref with the SD bridge included. Require successful host checks, MeshCore conformance, RP2040 build and ESP32 build/packaging jobs. Download that run's `d1l-release-package` artifact and verify its source commit, manifest, signed update and checksums. Use that @@ -40,7 +40,7 @@ requests it. The 1.0 procedure below is historical. 5. Verify the exact running version/commit, identity, display, radio and retained storage. Exercise the changed behavior using production firmware. Record any physical update/phone paths that were not exercised explicitly. -6. Merge the tested source, tag `v1.8.0-rc.6`, and publish it as a prerelease. +6. Merge the tested source, tag `v1.8.0`, and publish it as the latest stable release after acceptance. Freshly download every asset and compare its bytes with staging. Update the existing flasher catalog and Canadaverse DeskOS page, then verify their public content and downloads. Remove obsolete build outputs only after diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index 0ba80d01..6abcc58c 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -9,9 +9,9 @@ SD-primary retained history when prepared storage is ready, visible live-only operation otherwise, and without silent default-NVS fallback. Historical RC2 artifacts remain bound to their original `core_1_0` profile. -## 1.8.0-rc.6 release candidate +## 1.8.0 production release -The maintainer re-enabled GitHub Actions for this candidate. Use the existing +The maintainer re-enabled GitHub Actions for this production release. Use the existing workflow's exact-source signed package; the attached D1L remains the physical acceptance target on the Pi 5. @@ -38,7 +38,7 @@ acceptance target on the Pi 5. are not new-candidate phone acceptance. - Exercise the actual flasher console against the D1L and verify its normal update, clean-install, failure and storage readiness paths locally. -- Publish `v1.8.0-rc.6` as a prerelease tied to the tested source. Freshly +- Publish `v1.8.0` as the latest stable release tied to the tested source. Freshly download and compare every public asset, update the product page and flasher, and verify public downloads and desktop/mobile layouts. - Retain the signed release, a device recovery copy and deployment rollback; diff --git a/docs/RELEASE_NOTES_1.8.0.md b/docs/RELEASE_NOTES_1.8.0.md new file mode 100644 index 00000000..26f3abbd --- /dev/null +++ b/docs/RELEASE_NOTES_1.8.0.md @@ -0,0 +1,43 @@ +# DeskOS D1L 1.8.0 + +DeskOS 1.8 brings the candidate-series improvements into the stable product +for the SenseCAP Indicator D1L. It uses the full-feature profile with prepared +SD storage for retained history and explicit live-only operation without SD. + +## Everyday use + +- Shared Chats, paged saved/discovered Contacts, Profile editing and six + editable quick replies make the touch interface easier to navigate. +- Conversation drafts save to prepared SD and return after restart. + Plain-text quote replies and the local clipboard preserve the current draft. +- Bluetooth channel sends wait for radio acceptance. A stuck radio has a + bounded recovery path and cannot silently leave later sends blocked. +- Received phone messages report their stored hop count. Nearby channel + messages no longer show a fixed 63-hop placeholder. Older phone-cached + messages retain their history rather than being erased by the update. +- Contact telemetry has sufficient companion-worker stack. Nearby discovery, + contact edits, confirmed DMs and authenticated repeater management retain + their explicit success, timeout and permission boundaries. +- Signed local-SD updates verify the image before and after writing the + inactive slot. The bootloader can return to the preceding image when a new + image fails boot acceptance. USB remains the recovery path. + +## Installation + +Existing DeskOS devices should use the preserving update, keeping identity, +contacts, settings and SD data. Fresh installation uses the full-clean ESP32 +image, complete RP2040 bridge and a prepared FAT32 card. No firmware or helper +formats the card. The package includes both USB paths, signed SD update files, +checksums and the installation guide. + +## Acceptance and scope + +The tagged GitHub release records the final clean source, Actions run, +artifact hashes, physical update/radio/phone results and any remaining limits. +Only observed results count as hardware acceptance. Radio telemetry and +discovery depend on compatible responding peers and their permissions. + +This release does not claim complete WadaMesh parity. English-only UI, +138-byte message text, exclusive Wi-Fi/Bluetooth modes, no onboard GPS or +battery sensor, and the documented unsupported advanced phone commands remain +the product boundaries. See KNOWN_LIMITATIONS.md and the compatibility guide. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ad047df8..e8b98d3f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -23,7 +23,8 @@ | **1.8.0-rc.3** | WadaMesh-guided Chats, paged Contacts and filters, Profile, quick replies, and clearer device controls | Previous candidate | | **1.8.0-rc.4** | Conversation drafts, plain-text quoted replies and on-device clipboard | Previous candidate; radio BUSY stall reproduced after release | | **1.8.0-rc.5** | Bounded radio recovery, confirmed companion sends, phone discovery/telemetry and reliable contact edits | Previous candidate; received-path label and phone telemetry restart reproduced | -| **1.8.0-rc.6** | Correct received hop counts and sufficient companion telemetry stack | Corrective candidate; exact acceptance recorded in the tagged release | +| **1.8.0-rc.6** | Correct received hop counts and sufficient companion telemetry stack | Published corrective candidate | +| **1.8.0** | Production release of the 1.8 messaging, companion and update improvements | Production acceptance and exact artifacts recorded in the tagged release | The release firmware is the ordinary public product. A controlled peer, Wi-Fi credentials, admin password, soak run, qualification firmware, or validation @@ -255,3 +256,19 @@ fixed marker, including multi-hop DMs, and gives the companion worker the same stack budget as the radio owner. USB diagnostics expose its minimum free stack so the physical test can verify the remaining margin. It preserves existing message stores and does not erase already-cached phone history. + +## 1.8.0: production release + +The stable release collects the 1.8 candidate improvements: conversation +drafts, quotes and clipboard; paged Chats/Contacts and editable Profile/quick +replies; bounded radio recovery; confirmed phone sends and correct receive +paths; and signed local-SD updates with flash verification and rollback. +The final release record identifies the exact Actions package and physical +device observations. Missing remote responses must remain explicit. + +Subsequent feature work remains separate: mentions, adjustable text size, +language/keyboard improvements, automatic daylight-saving handling, +selectable contact admission policies, and additional phone commands. +Optional sensors and the WadaMesh Lua/web/remote application suite are future +scope decisions. UI modularization (#6) and developer-check consolidation +(#17) remain maintenance work rather than stable-release requirements. diff --git a/docs/USER_GUIDE_D1L.md b/docs/USER_GUIDE_D1L.md index 96e702f4..5a94f65b 100644 --- a/docs/USER_GUIDE_D1L.md +++ b/docs/USER_GUIDE_D1L.md @@ -1,6 +1,6 @@ -# MeshCore DeskOS D1L 1.8.0-rc.6 User Guide +# MeshCore DeskOS D1L 1.8.0 User Guide -## Phone receive paths and telemetry in 1.8.0-rc.6 +## Phone receive paths and telemetry in 1.8.0 Incoming channel messages report the received packet's hop count, including zero hops when no repeater forwarded it. The radio path can differ from the diff --git a/main/d1l_config.h b/main/d1l_config.h index a67e3d66..2eda9d1a 100644 --- a/main/d1l_config.h +++ b/main/d1l_config.h @@ -1,7 +1,7 @@ #pragma once #define D1L_FIRMWARE_NAME "MeshCore DeskOS D1L" -#define D1L_FIRMWARE_VERSION "1.8.0-rc.6" +#define D1L_FIRMWARE_VERSION "1.8.0" #define D1L_CONSOLE_SCHEMA 1 #define D1L_DISPLAY_WIDTH 480 diff --git a/tests/test_release_metadata_contract.py b/tests/test_release_metadata_contract.py index 24bc552c..18070d76 100644 --- a/tests/test_release_metadata_contract.py +++ b/tests/test_release_metadata_contract.py @@ -15,7 +15,7 @@ def test_public_release_metadata_has_no_bringup_or_stub_markers(): mesh_source = read("main/mesh/meshcore_service.c") package_script = read("scripts/package_release_d1l.py") - assert '#define D1L_FIRMWARE_VERSION "1.8.0-rc.6"' in config + assert '#define D1L_FIRMWARE_VERSION "1.8.0"' in config assert "D1L_PHASE1_BRINGUP" not in cmake assert "phase1_stub" not in mesh_source assert "phase2_stub" not in console From d3e3e01ac643bb516ced28dc24332b69e9f22dcf Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 19 Sep 2026 13:21:01 -0400 Subject: [PATCH 2/3] Buffer USB console input before startup and storage work [skip ci] --- docs/RELEASE_NOTES_1.8.0.md | 2 ++ main/app_main.c | 3 +++ main/comms/usb_console.c | 20 ++++++++++++++++++++ main/comms/usb_console.h | 2 ++ tests/meshcore_oracle/manifest.json | 2 +- tests/usb_command_parser/manifest.json | 2 +- 6 files changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/RELEASE_NOTES_1.8.0.md b/docs/RELEASE_NOTES_1.8.0.md index 26f3abbd..2c700da3 100644 --- a/docs/RELEASE_NOTES_1.8.0.md +++ b/docs/RELEASE_NOTES_1.8.0.md @@ -10,6 +10,8 @@ SD storage for retained history and explicit live-only operation without SD. editable quick replies make the touch interface easier to navigate. - Conversation drafts save to prepared SD and return after restart. Plain-text quote replies and the local clipboard preserve the current draft. +- Buffered USB input keeps complete contact imports and longer console + commands intact while startup, storage or a preceding response is busy. - Bluetooth channel sends wait for radio acceptance. A stuck radio has a bounded recovery path and cannot silently leave later sends blocked. - Received phone messages report their stored hop count. Nearby channel diff --git a/main/app_main.c b/main/app_main.c index 26b58823..576c8d58 100644 --- a/main/app_main.c +++ b/main/app_main.c @@ -50,6 +50,9 @@ void app_main(void) ESP_LOGE(TAG, "secure random unavailable; identity/channel creation disabled: %s", esp_err_to_name(secure_random_ret)); } + /* Buffer UART input before startup/storage work or either console can + * delay consuming a complete host command. */ + ESP_ERROR_CHECK(d1l_usb_console_init()); esp_err_t nvs_ret = nvs_flash_init(); d1l_health_monitor_init(nvs_ret); d1l_event_log_init(); diff --git a/main/comms/usb_console.c b/main/comms/usb_console.c index 569227c4..3c9b9719 100644 --- a/main/comms/usb_console.c +++ b/main/comms/usb_console.c @@ -8,6 +8,8 @@ #include #include +#include "driver/uart.h" +#include "driver/uart_vfs.h" #include "esp_attr.h" #include "esp_err.h" #include "esp_heap_caps.h" @@ -9911,6 +9913,24 @@ static void handle_line(const d1l_usb_command_view_t *command) } } +esp_err_t d1l_usb_console_init(void) +{ +#if CONFIG_ESP_CONSOLE_UART + const uart_port_t port = (uart_port_t)CONFIG_ESP_CONSOLE_UART_NUM; + if (!uart_is_driver_installed(port)) { + /* The polling VFS only has the hardware FIFO. A complete contact URI + * can exceed it while the console is emitting the preceding reply. */ + const esp_err_t ret = uart_driver_install(port, 1024, 0, 0, NULL, 0); + if (ret != ESP_OK) { + return ret; + } + } + uart_vfs_dev_use_driver(port); +#endif + setvbuf(stdin, NULL, _IONBF, 0); + return ESP_OK; +} + void d1l_usb_console_run(void) { static char line[256] EXT_RAM_BSS_ATTR; diff --git a/main/comms/usb_console.h b/main/comms/usb_console.h index 586d0acf..1dd999ca 100644 --- a/main/comms/usb_console.h +++ b/main/comms/usb_console.h @@ -1,7 +1,9 @@ #pragma once +#include "esp_err.h" #include "storage/factory_reset.h" +esp_err_t d1l_usb_console_init(void); void d1l_usb_console_run(void); void d1l_usb_console_run_factory_reset_recovery( const d1l_factory_reset_status_t *boot_status); diff --git a/tests/meshcore_oracle/manifest.json b/tests/meshcore_oracle/manifest.json index 25c41a77..2ece5ef3 100644 --- a/tests/meshcore_oracle/manifest.json +++ b/tests/meshcore_oracle/manifest.json @@ -72,7 +72,7 @@ "main/app/qualification_hooks.h": "fc6c4e1c9e3663d6fc7fc7e41aaf028f9689138dec6341d3b59b5ae4e930954d", "main/comms/usb_command_parser.c": "a6f15df848e3dbf3b9a68fad0f74796f04f4addedacd1a38a4be30c3b1141578", "main/comms/usb_command_parser.h": "6aec4cc3e92d0990b43d0844f4255ca05145ec93441b5e0cad71bada21e2d3e3", - "main/comms/usb_console.c": "15c67f2848f244859dd9917b8427db9f86a267423ac9c1c49c686e615c4a0f6f", + "main/comms/usb_console.c": "572255920a5d3e66f18548b3426e48d588ff7bd9da1c4859363ab18b9c9eb8af", "main/mesh/advert_data.h": "a45356935ddf821911a6948923f215b2cae38cc158012b1d0e025ab17b992542", "main/mesh/contact_store.c": "8bfb46b051059c693ef344d0b857c3c97d9900d44ec7c038fe1695f1fdfe8445", "main/mesh/contact_store.h": "49213ca66ff795091f35470c003af85feb1d514d8bb88e6c336dd6599fbe322f", diff --git a/tests/usb_command_parser/manifest.json b/tests/usb_command_parser/manifest.json index 1e289773..51c2bb2a 100644 --- a/tests/usb_command_parser/manifest.json +++ b/tests/usb_command_parser/manifest.json @@ -25,7 +25,7 @@ "source_pins": { ".github/workflows/d1l-ci.yml": "9d8e102228991dd1ccd7482ab9e94ef0d4ce4af02a74ef9666d566764bfe29a5", "main/CMakeLists.txt": "ed656dc2308efbf8b0d87b0366d9e7866a0006dbbaada5d3c915fc1e333b31a8", - "main/comms/usb_console.c": "15c67f2848f244859dd9917b8427db9f86a267423ac9c1c49c686e615c4a0f6f", + "main/comms/usb_console.c": "572255920a5d3e66f18548b3426e48d588ff7bd9da1c4859363ab18b9c9eb8af", "main/comms/usb_command_parser.c": "a6f15df848e3dbf3b9a68fad0f74796f04f4addedacd1a38a4be30c3b1141578", "main/comms/usb_command_parser.h": "6aec4cc3e92d0990b43d0844f4255ca05145ec93441b5e0cad71bada21e2d3e3", "scripts/usb_command_parser_fuzz_d1l.py": "0c138efd789d8e73ad126624f4491bfcd5924ec01c0fa18f1ff2c88197dfe85a", From 4299398ef4a9d5802ddb5a28acd9bf2b18d49680 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 19 Sep 2026 18:27:42 -0400 Subject: [PATCH 3/3] Report durable signed-update boot outcomes [skip ci] --- docs/RELEASE_NOTES_1.8.0.md | 4 +- docs/USER_GUIDE_D1L.md | 4 +- main/update/update_manager.c | 74 +++++++++++---- tests/test_signed_update_native.py | 144 ++++++++++++++++++++++++++++- 4 files changed, 203 insertions(+), 23 deletions(-) diff --git a/docs/RELEASE_NOTES_1.8.0.md b/docs/RELEASE_NOTES_1.8.0.md index 2c700da3..0188549e 100644 --- a/docs/RELEASE_NOTES_1.8.0.md +++ b/docs/RELEASE_NOTES_1.8.0.md @@ -22,7 +22,9 @@ SD storage for retained history and explicit live-only operation without SD. their explicit success, timeout and permission boundaries. - Signed local-SD updates verify the image before and after writing the inactive slot. The bootloader can return to the preceding image when a new - image fails boot acceptance. USB remains the recovery path. + image fails boot acceptance. The update screen reports that rollback and + refreshes the security counter after a confirmed boot. Receipt-write failures + report an error instead of confirmation. USB remains the recovery path. ## Installation diff --git a/docs/USER_GUIDE_D1L.md b/docs/USER_GUIDE_D1L.md index 5a94f65b..a1f03e8a 100644 --- a/docs/USER_GUIDE_D1L.md +++ b/docs/USER_GUIDE_D1L.md @@ -307,7 +307,9 @@ restart before attempting another install. Updates cannot be triggered over RF. When the write completes, choose **Reboot to Update** and confirm it. The new image starts in pending-verification state. A successful normal boot confirms -it; a failed boot rolls back to the previous working slot. The published USB +it; a failed boot rolls back to the previous working slot. The update screen +reports that rollback. SD updates can take tens of minutes; keep D1L powered +until the reboot prompt appears. The published USB app BIN and full-clean 8 MB BIN remain the recovery paths if local update is unavailable. diff --git a/main/update/update_manager.c b/main/update/update_manager.c index d4caf49b..b5ff31ea 100644 --- a/main/update/update_manager.c +++ b/main/update/update_manager.c @@ -462,29 +462,55 @@ static esp_err_t save_pending(const d1l_update_manifest_t *manifest) return ret; } -static void clear_pending(bool confirmed) +static esp_err_t clear_pending(bool confirmed) { nvs_handle_t handle = 0U; - if (nvs_open(D1L_UPDATE_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) { - return; + esp_err_t ret = nvs_open(D1L_UPDATE_NAMESPACE, NVS_READWRITE, &handle); + if (ret != ESP_OK) { + return ret; } uint32_t pending_sequence = 0U; - (void)nvs_get_u32(handle, "pending_seq", &pending_sequence); - if (confirmed && pending_sequence > 0U) { + ret = nvs_get_u32(handle, "pending_seq", &pending_sequence); + if (ret == ESP_ERR_NVS_NOT_FOUND) { + nvs_close(handle); + return ESP_OK; + } + if (ret == ESP_OK && confirmed && pending_sequence > 0U) { uint32_t highest = 0U; - (void)nvs_get_u32(handle, "highest_seq", &highest); - if (pending_sequence > highest) { - (void)nvs_set_u32(handle, "highest_seq", pending_sequence); + ret = nvs_get_u32(handle, "highest_seq", &highest); + if (ret == ESP_ERR_NVS_NOT_FOUND) { + ret = ESP_OK; } - (void)nvs_set_str(handle, "last_result", "confirmed"); - } else if (pending_sequence > 0U) { - (void)nvs_set_str(handle, "last_result", "rolled_back"); - } - (void)nvs_erase_key(handle, "pending_seq"); - (void)nvs_erase_key(handle, "pending_sha"); - (void)nvs_erase_key(handle, "pending_ver"); - (void)nvs_commit(handle); + if (ret == ESP_OK && pending_sequence > highest) { + ret = nvs_set_u32(handle, "highest_seq", pending_sequence); + } + if (ret == ESP_OK) { + ret = nvs_set_str(handle, "last_result", "confirmed"); + } + } else if (ret == ESP_OK && pending_sequence > 0U) { + ret = nvs_set_str(handle, "last_result", "rolled_back"); + } + const char *const pending_keys[] = {"pending_seq", "pending_sha", "pending_ver"}; + for (size_t i = 0U; ret == ESP_OK && i < sizeof(pending_keys) / sizeof(pending_keys[0]); ++i) { + ret = nvs_erase_key(handle, pending_keys[i]); + if (ret == ESP_ERR_NVS_NOT_FOUND) { + ret = ESP_OK; + } + } + if (ret == ESP_OK) { + ret = nvs_commit(handle); + } nvs_close(handle); + if (ret == ESP_OK) { + const uint32_t highest = load_highest_sequence(); + portENTER_CRITICAL(&s_lock); + s_status.highest_security_sequence = highest; + if (!confirmed && pending_sequence > 0U) { + s_status.state = D1L_UPDATE_STATE_ROLLED_BACK; + } + portEXIT_CRITICAL(&s_lock); + } + return ret; } static esp_err_t run_install(void) @@ -631,7 +657,7 @@ static esp_err_t run_install(void) ret = esp_ota_set_boot_partition(target); } if (ret != ESP_OK) { - clear_pending(false); + (void)clear_pending(false); goto digest_cleanup; } d1l_event_log_append(D1L_EVENT_LOG_LEVEL_INFO, "update", "installed", @@ -696,7 +722,11 @@ esp_err_t d1l_update_boot_confirm(esp_err_t nvs_status) portEXIT_CRITICAL(&s_lock); if (state_ret != ESP_OK || image_state != ESP_OTA_IMG_PENDING_VERIFY) { - clear_pending(false); + const esp_err_t pending_ret = clear_pending(false); + if (pending_ret != ESP_OK) { + set_state(D1L_UPDATE_STATE_ERROR, pending_ret, 0U); + return pending_ret; + } return state_ret == ESP_ERR_NOT_SUPPORTED || state_ret == ESP_ERR_NOT_FOUND ? ESP_OK : state_ret; @@ -720,15 +750,19 @@ esp_err_t d1l_update_boot_confirm(esp_err_t nvs_status) esp_restart(); return ESP_FAIL; } - const esp_err_t ret = esp_ota_mark_app_valid_cancel_rollback(); + esp_err_t ret = esp_ota_mark_app_valid_cancel_rollback(); + if (ret == ESP_OK) { + ret = clear_pending(true); + } if (ret == ESP_OK) { - clear_pending(true); portENTER_CRITICAL(&s_lock); s_status.running_image_confirmed = true; portEXIT_CRITICAL(&s_lock); d1l_event_log_append(D1L_EVENT_LOG_LEVEL_INFO, "update", "boot_confirmed", "new image accepted; rollback cancelled"); + } else { + set_state(D1L_UPDATE_STATE_ERROR, ret, 0U); } return ret; } diff --git a/tests/test_signed_update_native.py b/tests/test_signed_update_native.py index 770f4362..618fd641 100644 --- a/tests/test_signed_update_native.py +++ b/tests/test_signed_update_native.py @@ -12,7 +12,7 @@ def function(source, name): - start = re.search(r"(?:static\s+)?(?:esp_err_t|void|bool)\s+" + name + r"\([^)]*\)\s*\{", source) + start = re.search(r"(?:static\s+)?(?:esp_err_t|void|bool|uint32_t)\s+" + name + r"\([^)]*\)\s*\{", source) assert start, name end, depth = start.end(), 1 while depth: @@ -172,6 +172,148 @@ def test_update_selects_only_verified_flash_and_honours_cancellation(updater, sc subprocess.run([str(updater), scenario], check=True) +@pytest.fixture(scope="module") +def boot_result(tmp_path_factory): + source = (ROOT / "main/update/update_manager.c").read_text() + code = r''' +#include +#include +#include +#include +#include "update/update_manager.h" +#define ESP_ERR_NVS_NOT_FOUND 0x1102 +#define NVS_READONLY 0 +#define NVS_READWRITE 1 +#define D1L_UPDATE_NAMESPACE "update" +#define D1L_UPDATE_PROJECT_NAME "meshcore_deskos_d1l" +#define D1L_UPDATE_MIN_INTERNAL_HEAP_BYTES 16384 +#define ESP_PARTITION_TYPE_APP 0 +#define ESP_PARTITION_SUBTYPE_APP_OTA_0 0x10 +#define ESP_PARTITION_SUBTYPE_APP_OTA_1 0x11 +#define MALLOC_CAP_INTERNAL 1 +#define MALLOC_CAP_8BIT 2 +#define D1L_EVENT_LOG_LEVEL_INFO 1 +#define D1L_EVENT_LOG_LEVEL_ERROR 2 +#define portENTER_CRITICAL(lock) ((void)(lock)) +#define portEXIT_CRITICAL(lock) ((void)(lock)) +typedef unsigned nvs_handle_t; +typedef enum { ESP_OTA_IMG_UNDEFINED, ESP_OTA_IMG_VALID, ESP_OTA_IMG_PENDING_VERIFY } esp_ota_img_states_t; +typedef struct { unsigned type, subtype; char label[17]; } esp_partition_t; +typedef struct { char project_name[32]; } esp_app_desc_t; +typedef struct { bool pending; uint32_t sequence, highest; char result[16]; } receipt_t; +static receipt_t durable = {true, 123, 41, ""}, staged; +static d1l_update_status_t s_status = {.highest_security_sequence = 41}; +static esp_partition_t partition = {0, 0x10, "ota_0"}; +static esp_ota_img_states_t image_state = ESP_OTA_IMG_VALID; +static int s_lock, failure; +static unsigned commits; +static esp_err_t nvs_open(const char *name, int mode, nvs_handle_t *handle) { + (void)name; (void)mode; + if (failure == 1) return ESP_FAIL; + staged = durable; *handle = 1; return ESP_OK; +} +static void nvs_close(nvs_handle_t handle) { (void)handle; } +static esp_err_t nvs_get_u32(nvs_handle_t handle, const char *key, uint32_t *value) { + (void)handle; + if (failure == 2) return ESP_FAIL; + if (!strcmp(key, "pending_seq")) { + if (!staged.pending) return ESP_ERR_NVS_NOT_FOUND; + *value = staged.sequence; + } else { assert(!strcmp(key, "highest_seq")); *value = staged.highest; } + return ESP_OK; +} +static esp_err_t nvs_set_u32(nvs_handle_t handle, const char *key, uint32_t value) { + (void)handle; assert(!strcmp(key, "highest_seq")); + if (failure == 3) return ESP_FAIL; + staged.highest = value; return ESP_OK; +} +static esp_err_t nvs_set_str(nvs_handle_t handle, const char *key, const char *value) { + (void)handle; assert(!strcmp(key, "last_result")); + strcpy(staged.result, value); return ESP_OK; +} +static esp_err_t nvs_erase_key(nvs_handle_t handle, const char *key) { + (void)handle; + if (failure == 4) return ESP_FAIL; + if (!strcmp(key, "pending_seq")) staged.pending = false; + return ESP_OK; +} +static esp_err_t nvs_commit(nvs_handle_t handle) { + (void)handle; ++commits; + if (failure == 5) return ESP_FAIL; + durable = staged; return ESP_OK; +} +static const esp_partition_t *esp_ota_get_running_partition(void) { return &partition; } +static esp_err_t esp_ota_get_state_partition(const esp_partition_t *p, esp_ota_img_states_t *state) { + assert(p == &partition); *state = image_state; return ESP_OK; +} +static esp_err_t esp_ota_get_partition_description(const esp_partition_t *p, esp_app_desc_t *out) { + assert(p == &partition); strcpy(out->project_name, D1L_UPDATE_PROJECT_NAME); return ESP_OK; +} +static unsigned heap_caps_get_free_size(unsigned flags) { (void)flags; return 49152; } +static esp_err_t esp_ota_mark_app_valid_cancel_rollback(void) { image_state = ESP_OTA_IMG_VALID; return ESP_OK; } +static esp_err_t esp_ota_mark_app_invalid_rollback_and_reboot(void) { assert(0); return ESP_FAIL; } +static void esp_restart(void) { assert(0); } +static void d1l_event_log_append(int level, const char *area, const char *event, const char *text) { + (void)level; (void)area; (void)event; (void)text; +} +''' + for name in ("set_state", "load_highest_sequence", "clear_pending", "d1l_update_boot_confirm"): + code += function(source, name) + "\n" + code += r''' +int main(int argc, char **argv) { + assert(argc == 2); + bool rollback = !strcmp(argv[1], "rollback"); + bool no_pending = !strcmp(argv[1], "no_pending"); + if (no_pending) durable.pending = false; + if (!rollback && !no_pending) { + image_state = ESP_OTA_IMG_PENDING_VERIFY; + partition.subtype = 0x11; strcpy(partition.label, "ota_1"); + } + if (!strcmp(argv[1], "monotonic")) durable.highest = 456; + if (!strcmp(argv[1], "open_failure")) failure = 1; + if (!strcmp(argv[1], "read_failure")) failure = 2; + if (!strcmp(argv[1], "write_failure")) failure = 3; + if (!strcmp(argv[1], "erase_failure")) failure = 4; + if (!strcmp(argv[1], "commit_failure")) failure = 5; + esp_err_t ret = d1l_update_boot_confirm(ESP_OK); + if (failure) { + assert(ret == ESP_FAIL && s_status.state == D1L_UPDATE_STATE_ERROR); + assert(s_status.last_error == ESP_FAIL && !s_status.running_image_confirmed); + assert(durable.pending && durable.highest == 41); + assert(s_status.highest_security_sequence == 41); + } else if (no_pending) { + assert(ret == ESP_OK && s_status.state == D1L_UPDATE_STATE_IDLE && commits == 0); + } else if (rollback) { + assert(ret == ESP_OK && s_status.state == D1L_UPDATE_STATE_ROLLED_BACK); + assert(!durable.pending && !strcmp(durable.result, "rolled_back")); + assert(s_status.highest_security_sequence == 41 && !s_status.running_image_confirmed); + } else { + assert(ret == ESP_OK && s_status.running_image_confirmed); + assert(!durable.pending && !strcmp(durable.result, "confirmed")); + assert(s_status.highest_security_sequence == durable.highest); + assert(durable.highest == (!strcmp(argv[1], "monotonic") ? 456 : 123)); + } + return 0; +} +''' + directory = tmp_path_factory.mktemp("update-boot-result") + program = directory / "boot.c" + program.write_text(code) + compiler = shutil.which("gcc") or shutil.which("clang") + assert compiler + binary = directory / "boot-result" + subprocess.run([compiler, "-std=c11", "-O2", "-I", str(ROOT / "main"), + "-I", str(ROOT / "tests/native/stubs"), str(program), "-o", str(binary)], check=True) + return binary + + +@pytest.mark.parametrize("scenario", ["rollback", "confirmed", "monotonic", "no_pending", + "open_failure", "read_failure", "write_failure", + "erase_failure", "commit_failure"]) +def test_update_boot_result_matches_durable_receipt(boot_result, scenario): + subprocess.run([str(boot_result), scenario], check=True) + + def test_signed_package_destinations_match_the_bridge_root(tmp_path, monkeypatch): from scripts import package_release_d1l