From 67009e0d1e434023b47b809bb09d25a7bdcffe41 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Wed, 28 Oct 2026 14:03:31 -0700 Subject: [PATCH] Add ESP32 app slot selection to the CLI --- .github/workflows/run-unit-tests.yml | 1 + docs/cli_command_availability.md | 2 + docs/cli_commands.md | 30 +++ docs/esp32_partition_catalog.md | 7 + examples/companion_radio/MyMesh.cpp | 2 + examples/simple_repeater/MyMesh.h | 4 + examples/simple_room_server/MyMesh.h | 4 + src/helpers/BootSlot.cpp | 124 ++++++++++ src/helpers/BootSlot.h | 16 ++ src/helpers/CommonCLI.cpp | 3 + src/helpers/CommonCLI.h | 3 + test/fixtures/cli_reply_text.json | 6 +- test/test_boot_slot_cli.py | 345 +++++++++++++++++++++++++++ 13 files changed, 544 insertions(+), 3 deletions(-) create mode 100644 src/helpers/BootSlot.cpp create mode 100644 src/helpers/BootSlot.h create mode 100644 test/test_boot_slot_cli.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index ba4eea7d8..cffc53a52 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -612,6 +612,7 @@ jobs: python3 -B test/test_companion_rtc_terminal_input.py -v python3 -B test/test_esp32_cpu_clock.py -v python3 -B test/test_esp32_cpu_clock_board.py -v + python3 -B test/test_boot_slot_cli.py -v python3 -B test/test_esp32_power_test_controls.py -v python3 -B test/test_esp32_uart_quiet_session.py -v python3 -B test/test_companion_rxps_getters.py -v diff --git a/docs/cli_command_availability.md b/docs/cli_command_availability.md index 1333d4d9f..7a071820b 100644 --- a/docs/cli_command_availability.md +++ b/docs/cli_command_availability.md @@ -115,6 +115,7 @@ over the normal binary USB, BLE, or TCP connection: | [`board`](cli_commands.md#show-the-hardware-name) | Every Companion text terminal and command `0x42` | | [`version`](cli_commands.md#get-the-version) | Every Companion text terminal and protocol-v14 command `0x42`; returns the untruncated build identity | | [`get storage.layout`](cli_commands.md#show-the-storage-layout) | Every Companion text terminal and command `0x42`; reports the running platform's storage layout | +| [`get/set boot.slot`](cli_commands.md#select-the-next-application-slot-esp32) | ESP32 Companion terminal, command `0x42`, and authorized LoRa CLI; select an installed `app0` or `app1` image for the next reboot | | [`memory`](#memory) | ESP32 Full Companion text terminal and command `0x42` | | [`get/set radio.rxgain`](cli_commands.md#view-or-change-this-nodes-rx-boosted-gain-mode-sx12xx-and-lr1110-v1141) | Companion with a supported radio | | [`get/set radio.fem.rxgain`](cli_commands.md#view-or-change-the-lora-fem-receive-path-gain-state-on-supported-boards) | Companion on a board with controllable LoRa FEM LNA | @@ -323,6 +324,7 @@ should use command `0x42`. See [Companion radio binary protocol](companion_proto | Information | [`ver`](cli_commands.md#get-the-version) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | | Information | [`board`](cli_commands.md#show-the-hardware-name) | All text CLI roles | Yes | Yes | Yes | Yes | Yes | | Diagnostics | [`get storage.layout`](cli_commands.md#show-the-storage-layout) | Detected flash size and live partition table | Yes | Yes | Yes | Yes | Yes | +| Operational | [`get/set boot.slot`](cli_commands.md#select-the-next-application-slot-esp32) | Installed ESP32 `app0`/`app1`; selected image starts after `reboot` | Yes | Yes | Yes | Yes | Yes | | Diagnostics | [`memory`](#memory) | ESP32 heap and PSRAM summary | Yes | Yes | Yes | Yes | Yes | | Diagnostics | [`sensor`](#sensor-hardware-summary) | Hardware wiring summary | Yes | Yes | Yes | Yes | Yes | | Diagnostics | [`powerlog`](#powerlog) | Reset-reason summary | Yes | Yes | Yes | Yes | Yes | diff --git a/docs/cli_commands.md b/docs/cli_commands.md index c1d48e36f..97447173a 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -145,6 +145,36 @@ command aliases are listed there explicitly. --- +### Select the next application slot (ESP32) + +**Usage:** + +```text +get boot.slot +set boot.slot app1 +get boot.slot +reboot +``` + +Use `app0` or `app1` to select an installed application for the next reboot. +This works through the ESP32 Repeater, Room Server, Sensor and Companion CLI +with the usual administration permissions. Selection sends a reply and does +not reboot automatically. + +`get boot.slot` reports the running application, the next boot selection and +each slot's capacity, or `missing`. A capacity does not mean the slot contains +a valid image. Selection verifies the complete image through ESP-IDF and +rejects missing, corrupt, rejected or unconfirmed applications. It does not +create a second slot or install firmware. + +Finish or stop an active/staged OTA update before changing slots; stop the +WiFi OTA server if it is running. Use firmware for the exact board and installed +partition layout. Both applications share NVS and filesystem settings, and a +later OTA update can overwrite the inactive application or change the next +boot selection. Automatic rollback depends on the installed bootloader. + +--- + ### Enter the UF2 bootloader (nRF52 only) **Search terms:** bootloader mode, USB firmware update, UF2 mode. diff --git a/docs/esp32_partition_catalog.md b/docs/esp32_partition_catalog.md index 66354df43..d33e07006 100644 --- a/docs/esp32_partition_catalog.md +++ b/docs/esp32_partition_catalog.md @@ -18,6 +18,13 @@ Mixed layouts means some releases have dual-slot OTA and others do not. These are historical factory-image minimums, not measurements of your device; use `get storage.layout` to check its actual installed layout. +Use `get boot.slot` to see the running application, next boot selection and +installed `app0`/`app1` capacities. Select an existing application with +`set boot.slot app0` or `set boot.slot app1`, then run `reboot` to start it. +The command validates the image and refuses a missing slot; it does not change +the partition table. See [application slot selection](cli_commands.md#select-the-next-application-slot-esp32) +for update conflicts and shared settings. +
diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index fe7925a75..9512df67f 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -5,6 +5,7 @@ #include #endif #if defined(ESP32_PLATFORM) +#include #include #include #endif @@ -4146,6 +4147,7 @@ bool MyMesh::handleLocalControlCommand(const char* command, char* reply, if (mesh::powerdiag::handleCommand(command, reply, reply_size, millis())) return true; if (mesh::companion::handlePowerDiagnosticsCommand(board, command, reply, reply_size)) return true; #if defined(ESP32_PLATFORM) + if (mesh::esp32::handleBootSlotCommand(command, board, reply, reply_size)) return true; if (mesh::esp32::cpuclock::handleCommand(board, _prefs.cpu_freq_mhz, _prefs.powersaving_enabled != 0, command, reply, reply_size, [this]() { return savePrefs(); })) return true; diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index 9f93578bb..3709f71aa 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -2052,6 +2052,10 @@ public: return true; } +#if defined(ESP32_PLATFORM) + bool isDeferredOtaUpdatePending() const override { return _ota_update_at != 0; } +#endif + int getQueueSize() override { #ifdef WITH_MQTT_BRIDGE return mqtt_bridge ? mqtt_bridge->getQueueSize() : 0; diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index 3ad07fcec..1ae5fd6d2 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -1049,6 +1049,10 @@ public: return true; } +#if defined(ESP32_PLATFORM) + bool isDeferredOtaUpdatePending() const override { return _ota_update_at != 0; } +#endif + int getQueueSize() override { return bridge ? bridge->getQueueSize() : 0; } diff --git a/src/helpers/BootSlot.cpp b/src/helpers/BootSlot.cpp new file mode 100644 index 000000000..b0e739971 --- /dev/null +++ b/src/helpers/BootSlot.cpp @@ -0,0 +1,124 @@ +#include "BootSlot.h" + +#if defined(ESP32_PLATFORM) +#include +#include +#include +#include +#include +#include +#include "esp32/ManagementPassword.h" +#if defined(ENABLE_OTA) +#include "ota/OtaContext.h" +#endif + +namespace mesh { +namespace esp32 { +namespace { + +const esp_partition_t* appSlot(unsigned index) { + const auto subtype = static_cast( + ESP_PARTITION_SUBTYPE_APP_OTA_0 + index); + const char* label = index == 0 ? "app0" : "app1"; + return esp_partition_find_first(ESP_PARTITION_TYPE_APP, subtype, label); +} + +bool otaBusy() { + if (Update.isRunning()) return true; +#if defined(ENABLE_OTA) + // Status/selection must not allocate an OTA workspace or reopen flash staging. + const auto* context = mesh::ota::ota_context_if_active(); + if (context) { + const auto state = context->manager.fetchState(); + if ((state != mesh::ota::OtaManager::IDLE && + state != mesh::ota::OtaManager::COMPLETE && + state != mesh::ota::OtaManager::FAILED) || + context->apply_pending || context->bootloader_apply_pending || + context->fetch_store.staged_size() != 0 || context->folder_active || + context->serve_expected != 0 || context->manager.pendingServeJobs() || + context->manager.pendingManifestJobs()) return true; + } +#endif + return false; +} + +} // namespace + +bool handleBootSlotCommand(const char* command, MainBoard& board, char* reply, + size_t capacity, bool update_pending) { + if (!command) return false; + const bool get = strncmp(command, "get boot.slot", 13) == 0 && + (command[13] == 0 || command[13] == ' ' || command[13] == '\t'); + const bool set = strncmp(command, "set boot.slot", 13) == 0 && + (command[13] == 0 || command[13] == ' ' || command[13] == '\t'); + if (!get && !set) return false; + if (!reply || !capacity) return true; + + if (get) { + if (command[13] != 0) { + snprintf(reply, capacity, "Error: use get boot.slot"); + return true; + } + const auto* running = esp_ota_get_running_partition(); + const auto* boot = esp_ota_get_boot_partition(); + char sizes[2][24]; + for (unsigned index = 0; index < 2; ++index) { + const auto* partition = appSlot(index); + if (partition) snprintf(sizes[index], sizeof(sizes[index]), "%luK", + static_cast(partition->size / 1024)); + else snprintf(sizes[index], sizeof(sizes[index]), "missing"); + } + snprintf(reply, capacity, "> running=%s next=%s app0=%s app1=%s", + running ? running->label : "unknown", boot ? boot->label : "unknown", + sizes[0], sizes[1]); + return true; + } + + const char* value = command + 13; + while (*value == ' ' || *value == '\t') ++value; + if (strcmp(value, "app0") != 0 && strcmp(value, "app1") != 0) { + snprintf(reply, capacity, "Error: use set boot.slot app0|app1"); + return true; + } + + // HTTP upload tasks use the matching UploadGuard. Keep them excluded until + // the SDK has verified the image and committed the next-boot metadata. + managementPassword::ChangeGuard guard; + if (!guard.acquired() || update_pending || board.isOTAUpdateRunning() || otaBusy()) { + snprintf(reply, capacity, "Error: OTA busy/staged; stop uploads or use ota install / ota cancel first"); + return true; + } + const auto* partition = appSlot(value[3] - '0'); + if (!partition) { + snprintf(reply, capacity, "Error: %s missing from installed partition table", value); + return true; + } + esp_ota_img_states_t state; + const esp_err_t state_error = esp_ota_get_state_partition(partition, &state); + if (state_error != ESP_OK && state_error != ESP_ERR_NOT_FOUND) { + snprintf(reply, capacity, "Error: cannot read %s boot state (%ld)", value, + static_cast(state_error)); + return true; + } + if (state_error == ESP_OK && (state == ESP_OTA_IMG_INVALID || + state == ESP_OTA_IMG_ABORTED || + state == ESP_OTA_IMG_PENDING_VERIFY)) { + snprintf(reply, capacity, "Error: %s is rejected or unconfirmed", value); + return true; + } + + // This performs the complete SDK image/signature check before otadata is + // changed. A header or app-description check alone would accept corruption. + const esp_err_t result = esp_ota_set_boot_partition(partition); + if (result != ESP_OK) { + snprintf(reply, capacity, "Error: cannot select %s; invalid image or boot metadata (%ld)", + value, static_cast(result)); + return true; + } + snprintf(reply, capacity, "OK - boot.slot %s; reboot to apply", value); + return true; +} + +} // namespace esp32 +} // namespace mesh +#endif diff --git a/src/helpers/BootSlot.h b/src/helpers/BootSlot.h new file mode 100644 index 000000000..5c6bfb056 --- /dev/null +++ b/src/helpers/BootSlot.h @@ -0,0 +1,16 @@ +#pragma once + +#if defined(ESP32_PLATFORM) +#include + +namespace mesh { +class MainBoard; +namespace esp32 { + +// Select an installed application for the next boot; never reboot inline. +bool handleBootSlotCommand(const char* command, MainBoard& board, char* reply, + size_t capacity, bool update_pending = false); + +} // namespace esp32 +} // namespace mesh +#endif diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c86d96578..f37aa68b5 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -21,6 +21,7 @@ #include "HilStartupTrace.h" #include "FilePresence.h" #if defined(ESP32_PLATFORM) +#include "BootSlot.h" #include #include #endif @@ -3598,6 +3599,8 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re if (mesh::handleUsbLoggingWatchdogCommand(command, reply, 160)) return; if (mesh::powerdiag::handleCommand(command, reply, 157, millis())) return; #if defined(ESP32_PLATFORM) + if (mesh::esp32::handleBootSlotCommand(command, *_board, reply, 157, + _callbacks->isDeferredOtaUpdatePending())) return; if (mesh::esp32::cpuclock::handleCommand(*_board, _prefs->cpu_freq_mhz, _prefs->powersaving_enabled != 0, command, reply, 157, [this]() { return saveCommonPrefs(); })) return; diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index f032cfeb2..de57c465c 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -743,6 +743,9 @@ public: virtual bool beginDeferredOtaUpdate() { return false; }; +#if defined(ESP32_PLATFORM) + virtual bool isDeferredOtaUpdatePending() const { return false; } +#endif // Browser-based configuration portal. ESP32 infrastructure roles override // these; force_ap=true asks for the captive SoftAP even when WiFi is set. diff --git a/test/fixtures/cli_reply_text.json b/test/fixtures/cli_reply_text.json index 0f444e7da..b7fc31910 100644 --- a/test/fixtures/cli_reply_text.json +++ b/test/fixtures/cli_reply_text.json @@ -941,15 +941,15 @@ } ], "noncompact_tokens_sha256": { - "src/helpers/CommonCLI.cpp": "0a3d2e2eabd3e161e352176c4a6a4c4baa184944c08c93b522d4f1ddb5a24edd", + "src/helpers/CommonCLI.cpp": "ffde525e649544c1e7821f45810860f555bb4b2bfb9484923682c55830eee9ad", "examples/simple_repeater/MyMesh.cpp": "8567eeef2f327100e397c40b0aaf5b675fcdfea254987437d1ca8d005ad3a6f0", "src/helpers/RadioProfileCLI.cpp": "d641d16e872eba3e73b9b5a50f9718c10b0b7da72782f793706ac36b3724a624", - "src/helpers/WirelessControl.h": "bbde183f091a8afa7fd65c7b379783f8be2ac4e74ae6037a99a71fde3fb89f1e", + "src/helpers/WirelessControl.h": "f19bb602999118cc423ec74c7de7100ed91cb2c553295038077e52e321bb4e73", "src/helpers/PowerSavingDiagnostics.h": "18b78f5780046ea8c772c4ce6c337741ae0c027039d9399c94c5f4205d114000", "src/helpers/FloodAdvertCLI.h": "0f378e4d0f6c3b9b79991068a4b4208139484d2144ee1d7c5094d39043463be4", "src/helpers/LedSettings.cpp": "df0681234ca1337148ad59caa935857bc692f98bf18603ca6d099c0382028400", "src/helpers/StorageLayout.cpp": "5dd1c8bcbe2902e1f22d14f9ab425465bdc38926fae23989987d850b484cbb18", - "examples/companion_radio/MyMesh.cpp": "4ed72908fa1b986edb27c7a74acaccd5fda212c68f2f41537692739c040bb392", + "examples/companion_radio/MyMesh.cpp": "73a0ed65b2c37ea05c0d824e3f048bc0bb956c4f4d5944e43e01b91be93f0764", "src/helpers/CommonRadioPrefs.cpp": "16b1fb499426abde83566b6481df89b80302c72e00f0f4aa3843b6c9b9de4218", "examples/companion_radio/CompanionRetry.h": "e0ae10c7b2f778346f4645c4e147d87e1fbc7f8b99484752a8eed914bb21380f", "examples/companion_radio/Notifications.cpp": "173fb0d452fdd383585dd88ab35fbb079a90e068adca2db9bc44d604557d3873" diff --git a/test/test_boot_slot_cli.py b/test/test_boot_slot_cli.py new file mode 100644 index 000000000..7aa8bfbf2 --- /dev/null +++ b/test/test_boot_slot_cli.py @@ -0,0 +1,345 @@ +"""Execute ESP32 boot selection against mocked IDF flash and OTA services.""" +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + +HEADERS = { + "MeshCore.h": r''' +#pragma once +namespace mesh { +class MainBoard { +public: + bool ota_running = false; + bool isOTAUpdateRunning() const { return ota_running; } +}; +} +''', + "esp_partition.h": r''' +#pragma once +#include +#include +using esp_err_t = int; +constexpr esp_err_t ESP_OK = 0; +constexpr esp_err_t ESP_ERR_NOT_FOUND = 0x105; +constexpr esp_err_t ESP_ERR_INVALID_ARG = 0x102; +constexpr esp_err_t ESP_ERR_OTA_VALIDATE_FAILED = 0x1503; +constexpr esp_err_t ESP_FAIL = -1; +constexpr unsigned ESP_PARTITION_TYPE_APP = 0; +constexpr unsigned ESP_PARTITION_TYPE_DATA = 1; +constexpr unsigned ESP_PARTITION_SUBTYPE_APP_OTA_0 = 0x10; +constexpr unsigned ESP_PARTITION_SUBTYPE_APP_OTA_1 = 0x11; +using esp_partition_subtype_t = unsigned; +struct esp_partition_t { + unsigned type, subtype; + uint32_t address, size; + char label[17]; +}; +const esp_partition_t* esp_partition_find_first(unsigned, unsigned, const char*); +''', + "esp_err.h": '#pragma once\n#include "esp_partition.h"\n', + "esp_ota_ops.h": r''' +#pragma once +#include "esp_partition.h" +const esp_partition_t* esp_ota_get_running_partition(); +const esp_partition_t* esp_ota_get_boot_partition(); +esp_err_t esp_ota_set_boot_partition(const esp_partition_t*); +enum esp_ota_img_states_t { + ESP_OTA_IMG_NEW, ESP_OTA_IMG_PENDING_VERIFY, ESP_OTA_IMG_VALID, + ESP_OTA_IMG_INVALID, ESP_OTA_IMG_ABORTED, ESP_OTA_IMG_UNDEFINED +}; +esp_err_t esp_ota_get_state_partition(const esp_partition_t*, esp_ota_img_states_t*); +''', + "Update.h": r''' +#pragma once +struct UpdateClass { + bool running = false; + bool isRunning() const { return running; } +}; +extern UpdateClass Update; +''', + "ota/OtaContext.h": r''' +#pragma once +#include +namespace mesh { namespace ota { +struct OtaManager { + enum FetchState : uint8_t { + IDLE, WANT_MANIFEST, WANT_LEAVES, VERIFYING_STAGED, FETCHING, + COMPLETE, FAILED, PAUSED + }; + FetchState state = IDLE; + uint8_t serve_jobs = 0, manifest_jobs = 0; + FetchState fetchState() const { return state; } + uint8_t pendingServeJobs() const { return serve_jobs; } + uint8_t pendingManifestJobs() const { return manifest_jobs; } +}; +struct Store { + uint32_t staged = 0; + uint32_t staged_size() const { return staged; } +}; +struct OtaContext { + OtaManager manager; + Store fetch_store; + bool apply_pending = false, bootloader_apply_pending = false; + bool folder_active = false; + unsigned serve_expected = 0; +}; +extern OtaContext* active_context; +inline OtaContext* ota_context_if_active() { return active_context; } +}} +''', +} + +PRELUDE = r''' +#include "MeshCore.h" +#include "esp_partition.h" +#include "esp_ota_ops.h" +#include "Update.h" +#include "BootSlot.h" +#include "esp32/ManagementPassword.h" +#if ENABLE_OTA +#include "ota/OtaContext.h" +namespace mesh { namespace ota { OtaContext* active_context = nullptr; }} +#endif +#include +#include +#include + +UpdateClass Update; +static esp_partition_t app0{ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, + 0x10000, 0x1f0000, "app0"}; +static esp_partition_t app1{ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, + 0x200000, 0x1f0000, "app1"}; +static const esp_partition_t* running = &app0; +static const esp_partition_t* selected = &app0; +static bool have0 = true, have1 = true, valid0 = true, valid1 = true; +static bool fail_select = false; +static esp_err_t state_error = ESP_ERR_NOT_FOUND; +static esp_ota_img_states_t image_state = ESP_OTA_IMG_UNDEFINED; +static unsigned selections = 0; +static bool upload_blocked_during_verification = false; + +const esp_partition_t* esp_partition_find_first(unsigned type, unsigned subtype, const char* label) { + for (const auto* p : {have0 ? &app0 : nullptr, have1 ? &app1 : nullptr}) { + if (p && p->type == type && p->subtype == subtype && (!label || !strcmp(label, p->label))) return p; + } + return nullptr; +} +const esp_partition_t* esp_ota_get_running_partition() { return running; } +const esp_partition_t* esp_ota_get_boot_partition() { return selected; } +esp_err_t esp_ota_get_state_partition(const esp_partition_t*, esp_ota_img_states_t* out) { + *out = image_state; + return state_error; +} +esp_err_t esp_ota_set_boot_partition(const esp_partition_t* p) { + ++selections; + using namespace mesh::esp32::managementPassword; + Snapshot snapshot_value; + assert(snapshot(snapshot_value)); + upload_blocked_during_verification = !beginUpload(snapshot_value.generation); + if (!upload_blocked_during_verification) endUpload(); + if (fail_select || !(p == &app0 ? valid0 : valid1)) return ESP_ERR_OTA_VALIDATE_FAILED; + selected = p; + return ESP_OK; +} + +static mesh::MainBoard board; +static char reply[160]; +static bool command(const char* text) { + memset(reply, 0, sizeof(reply)); + return mesh::esp32::handleBootSlotCommand(text, board, reply, sizeof(reply)); +} +static void succeeds(const char* text) { + assert(command(text)); + assert(!strncmp(reply, "OK", 2)); +} +static void rejects(const char* text) { + const auto before = selected; + assert(command(text)); + assert(!strncmp(reply, "Error", 5) || !strncmp(reply, "ERR", 3)); + assert(selected == before); +} +static void initialize() { + assert(mesh::esp32::managementPassword::initialize("test-password")); +} +''' + + +class BootSlotCliTest(unittest.TestCase): + def run_fixture(self, source, ota=False): + with tempfile.TemporaryDirectory(prefix="mesh-boot-slot-") as temporary: + folder = Path(temporary) + for name, contents in HEADERS.items(): + path = folder / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents) + # Keep production code unchanged. The temporary include directory + # substitutes IDF services and OTA storage, not the CLI behavior. + production = folder / "BootSlot.cpp" + production.write_bytes((ROOT / "src/helpers/BootSlot.cpp").read_bytes()) + main = folder / "test.cpp" + main.write_text(PRELUDE + source) + executable = folder / "test" + flags = ["-DESP32_PLATFORM=1", "-DMESH_MANAGEMENT_PASSWORD_HOST_TEST=1"] + if ota: + flags += ["-DENABLE_OTA=1", "-DOTA_FLASH_STORE=1"] + result = subprocess.run( + [os.environ.get("CXX", "c++"), "-std=c++17", "-Wall", "-Wextra", + "-pthread", "-fsanitize=address,undefined", "-fno-sanitize-recover=all", + "-fno-pie", "-no-pie", *flags, "-I", str(folder), + "-I", str(ROOT / "src/helpers"), "-I", str(ROOT / "src"), + str(production), str(main), "-o", str(executable)], + text=True, capture_output=True, timeout=45, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + result = subprocess.run([str(executable)], text=True, capture_output=True, timeout=15) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_readback_selection_and_no_restart_or_unrelated_flash_writes(self): + self.run_fixture(r''' +int main() { + initialize(); + assert(command("get boot.slot")); + assert(strstr(reply, "running=app0") && strstr(reply, "next=app0")); + assert(strstr(reply, "app0=") && strstr(reply, "app1=")); + assert(selections == 0); + succeeds("set boot.slot app1"); + assert(selected == &app1 && running == &app0); + assert(upload_blocked_during_verification); + assert(command("get boot.slot") && strstr(reply, "next=app1")); + succeeds("set boot.slot app0"); + assert(selected == &app0 && running == &app0); + have1 = false; + assert(command("get boot.slot") && strstr(reply, "app1=missing")); + rejects("set boot.slot app1"); + // A factory-only app remains identifiable without inventing OTA slots. + esp_partition_t factory{ESP_PARTITION_TYPE_APP, 0, 0x10000, 0x300000, "factory"}; + running = selected = &factory; have0 = false; + assert(command("get boot.slot") && strstr(reply, "running=factory")); + assert(strstr(reply, "app0=missing") && strstr(reply, "app1=missing")); + rejects("set boot.slot app0"); + running = selected = nullptr; + assert(command("get boot.slot") && strstr(reply, "running=unknown") && strstr(reply, "next=unknown")); +} +''') + + def test_parser_boundaries_bad_images_and_idf_failure_leave_selection_alone(self): + self.run_fixture(r''' +int main() { + initialize(); + for (const char* text : {"get boot.slot.extra", "set boot.slotx app1", "get radio"}) + assert(!command(text)); + for (const char* text : {"set boot.slot", "set boot.slot app2", "set boot.slot nvs", + "set boot.slot app1 extra", "set boot.slot app10", "set boot.slot APP1", + "get boot.slot app1"}) rejects(text); + assert(selections == 0); + valid1 = false; rejects("set boot.slot app1"); + valid1 = true; fail_select = true; rejects("set boot.slot app1"); + fail_select = false; + // Labels alone cannot turn a data partition or wrong OTA subtype into an app. + app1.type = ESP_PARTITION_TYPE_DATA; rejects("set boot.slot app1"); + app1.type = ESP_PARTITION_TYPE_APP; + app1.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_0; rejects("set boot.slot app1"); + app1.subtype = ESP_PARTITION_SUBTYPE_APP_OTA_1; + strcpy(app1.label, "renamed"); rejects("set boot.slot app1"); + strcpy(app1.label, "app1"); succeeds("set boot.slot app1"); + // Null and zero-length outputs must not arm a slot, and tiny replies stay bounded. + selected = &app0; + mesh::esp32::handleBootSlotCommand("set boot.slot app1", board, nullptr, 0); + assert(selected == &app0); + char bounded[5] = {'x','x','x','x','!'}; + mesh::esp32::handleBootSlotCommand("get boot.slot", board, bounded, 4); + assert(bounded[3] == 0 && bounded[4] == '!'); +} +''') + + def test_wifi_update_and_cross_task_upload_lease_prevent_boot_mutation(self): + self.run_fixture(r''' +int main() { + initialize(); + board.ota_running = true; rejects("set boot.slot app1"); + assert(command("get boot.slot")); board.ota_running = false; + Update.running = true; rejects("set boot.slot app1"); Update.running = false; + assert(mesh::esp32::handleBootSlotCommand("set boot.slot app1", board, reply, sizeof(reply), true)); + assert(selected == &app0 && !strncmp(reply, "Error", 5)); + namespace password = mesh::esp32::managementPassword; + password::Snapshot credentials; assert(password::snapshot(credentials)); + { + password::UploadGuard upload(credentials.generation); assert(upload.acquired()); + rejects("set boot.slot app1"); + } + { + password::ChangeGuard change; assert(change.acquired()); + rejects("set boot.slot app1"); + } + assert(selections == 0); + succeeds("set boot.slot app1"); assert(upload_blocked_during_verification); + assert(password::beginUpload(credentials.generation)); password::endUpload(); +} +''') + + def test_lora_fetch_staging_apply_and_pending_jobs_prevent_boot_mutation(self): + self.run_fixture(r''' +int main() { + initialize(); + using namespace mesh::ota; + OtaContext context; active_context = &context; + for (auto state : {OtaManager::WANT_MANIFEST, OtaManager::WANT_LEAVES, + OtaManager::VERIFYING_STAGED, OtaManager::FETCHING, OtaManager::PAUSED}) { + context.manager.state = state; rejects("set boot.slot app1"); + } + context.manager.state = OtaManager::IDLE; + context.apply_pending = true; rejects("set boot.slot app1"); context.apply_pending = false; + context.bootloader_apply_pending = true; rejects("set boot.slot app1"); context.bootloader_apply_pending = false; + context.manager.serve_jobs = 1; rejects("set boot.slot app1"); context.manager.serve_jobs = 0; + context.manager.manifest_jobs = 1; rejects("set boot.slot app1"); context.manager.manifest_jobs = 0; + context.folder_active = true; rejects("set boot.slot app1"); context.folder_active = false; + context.serve_expected = 123; rejects("set boot.slot app1"); context.serve_expected = 0; + context.fetch_store.staged = 500; rejects("set boot.slot app1"); context.fetch_store.staged = 0; + assert(selections == 0); + for (auto state : {OtaManager::IDLE, OtaManager::COMPLETE, OtaManager::FAILED}) { + context.manager.state = state; + succeeds("set boot.slot app1"); succeeds("set boot.slot app0"); + } + active_context = nullptr; succeeds("set boot.slot app1"); +} +''', ota=True) + + def test_bootloader_rejected_and_unconfirmed_slots_cannot_be_rearmed(self): + self.run_fixture(r''' +int main() { + initialize(); + state_error = ESP_OK; + for (auto state : {ESP_OTA_IMG_INVALID, ESP_OTA_IMG_ABORTED, ESP_OTA_IMG_PENDING_VERIFY}) { + image_state = state; rejects("set boot.slot app1"); + } + state_error = ESP_FAIL; rejects("set boot.slot app1"); + assert(selections == 0); + state_error = ESP_OK; + for (auto state : {ESP_OTA_IMG_NEW, ESP_OTA_IMG_VALID, ESP_OTA_IMG_UNDEFINED}) { + image_state = state; succeeds("set boot.slot app1"); succeeds("set boot.slot app0"); + } + state_error = ESP_ERR_NOT_FOUND; succeeds("set boot.slot app1"); +} +''') + + def test_other_platforms_do_not_include_sdk_or_add_a_boot_selector(self): + with tempfile.TemporaryDirectory(prefix="mesh-boot-slot-excluded-") as temporary: + folder = Path(temporary) + result = subprocess.run( + [os.environ.get("CXX", "c++"), "-std=c++11", "-DNRF52_PLATFORM=1", + "-c", str(ROOT / "src/helpers/BootSlot.cpp"), "-o", str(folder / "slot.o")], + capture_output=True, text=True, timeout=30, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + symbols = subprocess.run(["nm", str(folder / "slot.o")], capture_output=True, text=True, timeout=10) + self.assertEqual(symbols.returncode, 0, symbols.stderr) + self.assertNotIn("handleBootSlotCommand", symbols.stdout) + + +if __name__ == "__main__": + unittest.main()