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.
+
Board
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()