From f04d163bea10c6a3d4641fc6f76189d334f7768b Mon Sep 17 00:00:00 2001 From: Elektr0Vodka <211697683+Elektr0Vodka@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:05:58 +0200 Subject: [PATCH 01/14] feat(ota): channel-base resolver + arg parser with native tests (cherry picked from commit 52a4ccc39093891b64e3752316fe7c191fd9b9ca) --- platformio.ini | 18 ++++++- src/helpers/OtaChannel.h | 62 ++++++++++++++++++++++ test/test_ota_channel/test_ota_channel.cpp | 34 ++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/helpers/OtaChannel.h create mode 100644 test/test_ota_channel/test_ota_channel.cpp diff --git a/platformio.ini b/platformio.ini index 6c012f1e52..d478690818 100644 --- a/platformio.ini +++ b/platformio.ini @@ -177,7 +177,7 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem +test_ignore = test_kiss_modem, test_ota_channel build_src_filter = -<*> +<../src/Utils.cpp> @@ -203,3 +203,19 @@ build_src_filter = lib_deps = google/googletest @ 1.17.0 bblanchon/ArduinoJson @ 7.4.3 + +[env:native_ota_channel] +platform = native +test_framework = googletest +build_flags = -std=c++17 + -I test/mocks + -I src + -DOTA_MANIFEST_BASE="\"https://stable.example/mqtt/v\"" + -DOTA_MANIFEST_BASE_STABLE="\"https://stable.example/mqtt/v\"" + -DOTA_MANIFEST_BASE_DEV="\"https://dev.example/mqtt/dev/v\"" +test_build_src = yes +test_filter = test_ota_channel +build_src_filter = + -<*> +lib_deps = + google/googletest @ 1.17.0 diff --git a/src/helpers/OtaChannel.h b/src/helpers/OtaChannel.h new file mode 100644 index 0000000000..d589854bce --- /dev/null +++ b/src/helpers/OtaChannel.h @@ -0,0 +1,62 @@ +#pragma once +#include +#include + +// OTA release-channel selector, persisted in NodePrefs::ota_channel. +enum OtaChannel : uint8_t { + OTA_CH_NATIVE = 0, // follow the channel this build was made for + OTA_CH_STABLE = 1, + OTA_CH_DEV = 2, +}; + +// Resolve the effective manifest base URL for a channel selector. +// build.sh injects the three bases as compile-time macros: +// OTA_MANIFEST_BASE = this build's native channel (defined on every OTA build) +// OTA_MANIFEST_BASE_STABLE = stable channel +// OTA_MANIFEST_BASE_DEV = dev channel +// stable/dev fall back to the native base when their macro is undefined (legacy/local +// builds that only define OTA_MANIFEST_BASE), so this never returns nullptr on an +// OTA-capable build. On a non-OTA build it returns nullptr. +static inline const char* ota_resolve_base(uint8_t channel) { +#if defined(OTA_MANIFEST_BASE) + switch (channel) { + case OTA_CH_STABLE: +#if defined(OTA_MANIFEST_BASE_STABLE) + return OTA_MANIFEST_BASE_STABLE; +#else + return OTA_MANIFEST_BASE; +#endif + case OTA_CH_DEV: +#if defined(OTA_MANIFEST_BASE_DEV) + return OTA_MANIFEST_BASE_DEV; +#else + return OTA_MANIFEST_BASE; +#endif + case OTA_CH_NATIVE: + default: + return OTA_MANIFEST_BASE; + } +#else + (void)channel; + return nullptr; +#endif +} + +// Human label for a selector (for the `ota branch` report). +static inline const char* ota_channel_name(uint8_t channel) { + switch (channel) { + case OTA_CH_STABLE: return "stable"; + case OTA_CH_DEV: return "dev"; + default: return "native"; + } +} + +// Parse an `ota branch` argument. Returns true and sets *out on a known keyword +// (stable|dev|default; "default" -> native); returns false and leaves *out untouched +// otherwise. +static inline bool ota_parse_channel(const char* arg, uint8_t* out) { + if (strcmp(arg, "stable") == 0) { *out = OTA_CH_STABLE; return true; } + if (strcmp(arg, "dev") == 0) { *out = OTA_CH_DEV; return true; } + if (strcmp(arg, "default") == 0) { *out = OTA_CH_NATIVE; return true; } + return false; +} diff --git a/test/test_ota_channel/test_ota_channel.cpp b/test/test_ota_channel/test_ota_channel.cpp new file mode 100644 index 0000000000..699ffb70cd --- /dev/null +++ b/test/test_ota_channel/test_ota_channel.cpp @@ -0,0 +1,34 @@ +#include +#include "helpers/OtaChannel.h" + +// The three base URLs are provided as -D macros by the test env (see platformio.ini). +TEST(OtaChannel, ResolvesNativeToBaseMacro) { + EXPECT_STREQ(ota_resolve_base(OTA_CH_NATIVE), "https://stable.example/mqtt/v"); +} +TEST(OtaChannel, ResolvesStable) { + EXPECT_STREQ(ota_resolve_base(OTA_CH_STABLE), "https://stable.example/mqtt/v"); +} +TEST(OtaChannel, ResolvesDev) { + EXPECT_STREQ(ota_resolve_base(OTA_CH_DEV), "https://dev.example/mqtt/dev/v"); +} +TEST(OtaChannel, ParseKnownKeywords) { + uint8_t ch = 99; + EXPECT_TRUE(ota_parse_channel("stable", &ch)); EXPECT_EQ(ch, OTA_CH_STABLE); + EXPECT_TRUE(ota_parse_channel("dev", &ch)); EXPECT_EQ(ch, OTA_CH_DEV); + EXPECT_TRUE(ota_parse_channel("default", &ch)); EXPECT_EQ(ch, OTA_CH_NATIVE); +} +TEST(OtaChannel, ParseRejectsUnknownAndLeavesOutputUntouched) { + uint8_t ch = 7; + EXPECT_FALSE(ota_parse_channel("beta", &ch)); + EXPECT_EQ(ch, 7); +} +TEST(OtaChannel, NameLabels) { + EXPECT_STREQ(ota_channel_name(OTA_CH_NATIVE), "native"); + EXPECT_STREQ(ota_channel_name(OTA_CH_STABLE), "stable"); + EXPECT_STREQ(ota_channel_name(OTA_CH_DEV), "dev"); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} From 3910489414f00d16f377c5051cae47051f2e1194 Mon Sep 17 00:00:00 2001 From: Elektr0Vodka <211697683+Elektr0Vodka@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:07:12 +0200 Subject: [PATCH 02/14] feat(ota): persist ota_channel selector in NodePrefs (cherry picked from commit db1db5b57ecce408c7441cb6aa70f29608b09603) --- src/helpers/CommonCLI.h | 2 ++ .../test_config_serializer.cpp | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index e0a0a71b86..db77ac8db6 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -70,6 +70,7 @@ class NodePrefs : public ConfigSerializer { uint8_t path_hash_mode = 0; // which path mode to use when sending uint8_t loop_detect = 0; uint8_t cad_enabled = 0; // hardware Channel Activity Detection before TX (boolean) + uint8_t ota_channel = 0; // OTA release channel selector: 0=native, 1=stable, 2=dev uint8_t extra_sf[4]; // NOTE: observer settings (MQTT/WiFi/timezone/SNMP/alert) are not in NodePrefs. @@ -182,6 +183,7 @@ class NodePrefs : public ConfigSerializer { def("lat", node_lat); def("lon", node_lon); def("disc_mod", discovery_mod_timestamp); // gates 'since'-filtered DISCOVER replies + def("ota_ch", ota_channel); // OTA release channel: 0=native, 1=stable, 2=dev def("radio", radio); def("bridge", bridge); def("gps", gps); diff --git a/test/test_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp index 27c3c8119e..1fbe1c8ad4 100644 --- a/test/test_config_serializer/test_config_serializer.cpp +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -214,6 +214,35 @@ TEST(NodePrefs, FemGainSettingsRoundTrip) { EXPECT_EQ(1, loaded.radio_fem_txgain); } +TEST(NodePrefs, OtaChannelDefaultsToNative) { + NodePrefs prefs; + EXPECT_EQ(0, prefs.ota_channel); // 0 == native +} + +TEST(NodePrefs, OtaChannelRoundTrip) { + NodePrefs saved; + saved.ota_channel = 2; // dev + + MockPrintStream output; + ASSERT_TRUE(saved.saveSerial(output)); + std::string serialised(reinterpret_cast(output.getBytes()), output.getLength()); + EXPECT_NE(std::string::npos, serialised.find("ota_ch:2")); + + MockInputStream input(serialised.c_str()); + NodePrefs loaded; + loaded.ota_channel = 1; // start different + ASSERT_TRUE(loaded.loadSerial(input)) << serialised; + EXPECT_EQ(2, loaded.ota_channel); +} + +TEST(NodePrefs, OtaChannelMissingKeyKeepsDefault) { + // A /prefs.json written before this field existed has no ota_ch key. + MockInputStream input("{name:\"n\"}"); + NodePrefs loaded; // ota_channel default 0 (native) + ASSERT_TRUE(loaded.loadSerial(input)); + EXPECT_EQ(0, loaded.ota_channel); +} + // ── main ─────────────────────────────────────────────────────── From 942a19eb63947ec71a7f1769ad74374ae3e2e939 Mon Sep 17 00:00:00 2001 From: Elektr0Vodka <211697683+Elektr0Vodka@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:15:20 +0200 Subject: [PATCH 03/14] feat(ota): pass runtime manifest base through otaFromManifest (cherry picked from commit 0bee84c0546780b2e7147148c32087e92d894ac0) --- examples/simple_repeater/MyMesh.cpp | 3 ++- examples/simple_room_server/MyMesh.cpp | 3 ++- src/MeshCore.h | 2 +- src/helpers/CommonCLI_Observer.cpp | 5 +++-- src/helpers/ESP32Board.cpp | 19 ++++++++++--------- src/helpers/ESP32Board.h | 4 ++-- 6 files changed, 20 insertions(+), 16 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index 7e209cfb2c..b378965767 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -2,6 +2,7 @@ #include #include // for qsort() #include +#include #if defined(WITH_MQTT_NEIGHBORS) #include // kSyncedClockEpoch #endif @@ -1740,7 +1741,7 @@ void MyMesh::loop() { Serial.println("OTA: aborted, MQTT stop did not complete cleanly - resuming bridge"); otaAlert("OTA aborted: MQTT stop unclean, bridge resumed"); setBridgeState(true); - } else if (!_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) { + } else if (!_cli.getBoard()->otaFromManifest(ota_resolve_base(_prefs.ota_channel), getFirmwareVer(), false, ota_reply)) { Serial.print("OTA: aborted, resuming bridge - "); Serial.println(ota_reply); char ota_alert_msg[160]; snprintf(ota_alert_msg, sizeof(ota_alert_msg), "OTA aborted: %s", ota_reply); diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 9fe5a9c8a8..2e97d49177 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1,6 +1,7 @@ #include "MyMesh.h" #include #include +#include #if defined(WITH_MQTT_NEIGHBORS) #include // kSyncedClockEpoch #endif @@ -1616,7 +1617,7 @@ void MyMesh::loop() { } char ota_reply[160]; - if (may_flash && !_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) { + if (may_flash && !_cli.getBoard()->otaFromManifest(ota_resolve_base(_prefs.ota_channel), getFirmwareVer(), false, ota_reply)) { Serial.print("OTA: aborted - "); Serial.println(ota_reply); may_flash = false; } diff --git a/src/MeshCore.h b/src/MeshCore.h index 15ccdc1562..32d669739c 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -67,7 +67,7 @@ class MainBoard { // Pull-based OTA: fetch the firmware build for this variant from a baked-in manifest and flash it. // current_ver is the running firmware version string (used to skip if already up to date); when // dry_run is true the build is only reported, not flashed. Observer (ESP32+WiFi) builds only. - virtual bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) { return false; } + virtual bool otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) { return false; } // LoRa front-end-module LNA (RX gain) control. Only FEM-equipped boards override // these; others report they can't control it. Driven by NodePrefs.radio_fem_rxgain. diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index c1b7e74a11..c6d44f49c7 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -12,6 +12,7 @@ #include #include "CommonCLI.h" +#include "OtaChannel.h" #include "TxtDataHelpers.h" #include "AlertReporter.h" // for alertReporterBannedChannelMatch[Hex]() #include "MQTTObserverValidation.h" // pure input validators (host-testable) @@ -1088,7 +1089,7 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, // MQTT bridge UP: the slim per-variant manifest is tiny, so the fetch only // costs a single TLS handshake (no large JSON doc) — which fits alongside // the live MQTT sessions even on no-PSRAM boards. No bridge bounce needed. - _board->otaFromManifest(_callbacks->getFirmwareVer(), true, reply); + _board->otaFromManifest(ota_resolve_base(_prefs->ota_channel), _callbacks->getFirmwareVer(), true, reply); } else { // `ota update`: cheap pre-check first (plain HTTP, bridge stays up). Only // schedule the real update — which tears the bridge down, flashes, and @@ -1096,7 +1097,7 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, // returns true iff so; otherwise it leaves the explanation (up to date / // cable flash / error) in reply, which we send without disturbing the // bridge or misleading the user with a "Beginning update..." that no-ops. - if (_board->otaFromManifest(_callbacks->getFirmwareVer(), true, reply)) { + if (_board->otaFromManifest(ota_resolve_base(_prefs->ota_channel), _callbacks->getFirmwareVer(), true, reply)) { // reply now holds "update available: -> (N behind|new base)", // where is "vX.Y.Z.B (hash)". Pull out for a friendlier // start message. The "-> " ... trailing " (" framing is produced by diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 120203ab05..36db7fa914 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -162,6 +162,7 @@ static void ota_partitionSignature(char* out, size_t out_sz) { // which stays valid because that function blocks until the worker signals done. struct OtaTaskArgs { ESP32Board* self; + const char* manifest_base; const char* current_ver; bool dry_run; char* reply; @@ -171,18 +172,18 @@ struct OtaTaskArgs { static void ota_task_entry(void* param) { OtaTaskArgs* a = static_cast(param); - a->result = a->self->otaFromManifestImpl(a->current_ver, a->dry_run, a->reply); + a->result = a->self->otaFromManifestImpl(a->manifest_base, a->current_ver, a->dry_run, a->reply); a->done = true; // on a successful `ota update` we reboot before reaching here vTaskDelete(nullptr); } -bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char reply[]) { +bool ESP32Board::otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) { // The TLS handshake (cert-bundle verify) + JSON parse / HTTPUpdate use far more // stack than the ~8 KB loop task offers — especially when reached via the deep // mesh-receive call chain (it overflows the loopTask canary). Run the work in a // dedicated 24 KB-stack task and block here until it finishes. The big stack is // freed when the task exits; on a successful update the chip reboots inside it. - OtaTaskArgs args = { this, current_ver, dry_run, reply, false, false }; + OtaTaskArgs args = { this, manifest_base, current_ver, dry_run, reply, false, false }; TaskHandle_t handle = nullptr; BaseType_t ok = xTaskCreatePinnedToCore(ota_task_entry, "ota", 24576, &args, 5, &handle, 1); if (ok != pdPASS) { @@ -195,7 +196,7 @@ bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char rep return args.result; } -bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char reply[]) { +bool ESP32Board::otaFromManifestImpl(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) { #if !defined(OTA_MANIFEST_BASE) || !defined(OTA_VARIANT) strcpy(reply, "ERR: OTA not configured (build via build.sh)"); return false; @@ -230,10 +231,10 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char // and the handshake + the bridge both fail). This only reads version info; the // firmware download below (ota update) is always TLS-verified. Requires the // manifest host to serve /v over HTTP (no forced HTTPS redirect). - if (strncmp(OTA_MANIFEST_BASE, "https://", 8) == 0) { - snprintf(murl, sizeof(murl), "http://%s/%s.json", OTA_MANIFEST_BASE + 8, OTA_VARIANT); + if (strncmp(manifest_base, "https://", 8) == 0) { + snprintf(murl, sizeof(murl), "http://%s/%s.json", manifest_base + 8, OTA_VARIANT); } else { - snprintf(murl, sizeof(murl), "%s/%s.json", OTA_MANIFEST_BASE, OTA_VARIANT); + snprintf(murl, sizeof(murl), "%s/%s.json", manifest_base, OTA_VARIANT); } if (!http.begin(murl)) { strcpy(reply, "ERR: manifest connect failed"); @@ -248,7 +249,7 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char mclient.setCACertBundle(rootca_crt_bundle_start); #endif mclient.setTimeout(15000); - snprintf(murl, sizeof(murl), "%s/%s.json", OTA_MANIFEST_BASE, OTA_VARIANT); + snprintf(murl, sizeof(murl), "%s/%s.json", manifest_base, OTA_VARIANT); if (!http.begin(mclient, murl)) { strcpy(reply, "ERR: manifest connect failed"); return false; @@ -417,7 +418,7 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char #endif // OTA_MANIFEST_BASE && OTA_VARIANT } #else -bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char reply[]) { +bool ESP32Board::otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) { strcpy(reply, "ERR: not supported"); return false; } diff --git a/src/helpers/ESP32Board.h b/src/helpers/ESP32Board.h index 3ecf6735ec..af81971948 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -155,11 +155,11 @@ class ESP32Board : public mesh::MainBoard { } bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) override; - bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) override; + bool otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) override; // Heavy body (TLS + JSON / HTTPUpdate). Runs in a dedicated large-stack task // spawned by otaFromManifest() — public only so that task entry point can call // it; not meant to be invoked directly. - bool otaFromManifestImpl(const char* current_ver, bool dry_run, char reply[]); + bool otaFromManifestImpl(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]); void setInhibitSleep(bool inhibit) { inhibit_sleep = inhibit; From ea541e182a20ee89c977f02e375e0ba77d2caae0 Mon Sep 17 00:00:00 2001 From: Elektr0Vodka <211697683+Elektr0Vodka@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:29:28 +0200 Subject: [PATCH 04/14] feat(ota): add 'ota branch [stable|dev|default]' command (cherry picked from commit 240804999edb284fd276489e6898ccad8a19a8e7) --- src/helpers/CommonCLI_Observer.cpp | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index c6d44f49c7..e4d71182df 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -1130,6 +1130,35 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, } #else strcpy(reply, "ERR: online OTA not supported on this build"); +#endif + return true; + } else if (memcmp(command, "ota branch", 10) == 0) { + // Switch (or report) the OTA release channel this device pulls from. The + // selection is persisted (NodePrefs::ota_channel) and resolved to a baked-in + // base URL by ota_resolve_base(); it changes only WHERE updates are fetched, + // never the running image's reported version. Reachable from any admin path, + // same as `ota update`. +#if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_BASE) + const char* arg = command + 10; + while (*arg == ' ') arg++; + if (*arg == 0) { + snprintf(reply, 160, "channel: %s (%s), base %s", + ota_channel_name(_prefs->ota_channel), + _prefs->ota_channel == OTA_CH_NATIVE ? "native" : "override", + ota_resolve_base(_prefs->ota_channel)); + } else { + uint8_t ch; + if (!ota_parse_channel(arg, &ch)) { + strcpy(reply, "ERR: usage ota branch [stable|dev|default]"); + } else { + _prefs->ota_channel = ch; + savePrefs(); + snprintf(reply, 160, "channel set to %s, base %s", + ota_channel_name(ch), ota_resolve_base(ch)); + } + } +#else + strcpy(reply, "ERR: online OTA not supported on this build"); #endif return true; } else if (memcmp(command, "start webconfig", 15) == 0 && (command[15] == 0 || command[15] == ' ')) { From 763ba835c18379784ce37f1dfba9d3941b384310 Mon Sep 17 00:00:00 2001 From: Elektr0Vodka <211697683+Elektr0Vodka@users.noreply.github.com> Date: Mon, 14 Sep 2026 04:29:28 +0200 Subject: [PATCH 05/14] build(ota): bake stable + dev manifest bases into observer builds (cherry picked from commit 29011959170db757ec5364de88f6beeaae82b995) --- build.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/build.sh b/build.sh index 5d6ae4439c..a6ad41c63e 100755 --- a/build.sh +++ b/build.sh @@ -203,10 +203,18 @@ build_firmware() { # the .ini declarations were removed rather than overridden. OTA_MANIFEST_BASE_URL="${OTA_MANIFEST_BASE_URL:-https://observer.gessaman.com/v}" + # Both named channel bases are baked into EVERY observer build so `ota branch` + # can re-point a device at either channel at runtime. OTA_MANIFEST_BASE above stays + # the build's NATIVE channel (= stable base for stable builds, dev base for dev + # builds), so `ota branch default` resolves correctly. These two must match the + # paths DutchMeshCore-OTA serves (feat/dev-stable-ota-channels): /mqtt/v + /mqtt/dev/v. + OTA_MANIFEST_BASE_STABLE_URL="${OTA_MANIFEST_BASE_STABLE_URL:-https://ota.dutchmeshcore.nl/mqtt/v}" + OTA_MANIFEST_BASE_DEV_URL="${OTA_MANIFEST_BASE_DEV_URL:-https://ota.dutchmeshcore.nl/mqtt/dev/v}" + # add firmware version info to end of existing platformio build flags in environment vars. # OTA_VARIANT is the env name ($1) — it selects this build's slim per-variant manifest # (/.json) that the observer pull-OTA fetches. - export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${EMBEDDED_VERSION_STRING}\"' -DOTA_VARIANT='\"$1\"' -DOTA_MANIFEST_BASE='\"${OTA_MANIFEST_BASE_URL}\"'" + export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${EMBEDDED_VERSION_STRING}\"' -DOTA_VARIANT='\"$1\"' -DOTA_MANIFEST_BASE='\"${OTA_MANIFEST_BASE_URL}\"' -DOTA_MANIFEST_BASE_STABLE='\"${OTA_MANIFEST_BASE_STABLE_URL}\"' -DOTA_MANIFEST_BASE_DEV='\"${OTA_MANIFEST_BASE_DEV_URL}\"'" # disable debug flags if requested disable_debug_flags From 2633b4267cdabfc351ce8b3aa9c59ab020497cec Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 15:17:46 -0700 Subject: [PATCH 06/14] fix(ota): compare by commit hash when targeting another channel Build counters are per channel, so a cross-channel build number is not comparable: a switch to a channel with a lower counter reported up to date and never happened. Off the native channel, update unless the hash matches. --- src/helpers/ESP32Board.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 36db7fa914..206a3a7c1c 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -334,10 +334,14 @@ bool ESP32Board::otaFromManifestImpl(const char* manifest_base, const char* curr bool same_base = (own_base[0] && avail_base[0] && strcmp(own_base, avail_base) == 0); bool have_builds = (own_build >= 0 && avail_build >= 0); bool diff_base = (own_base[0] && avail_base[0] && !same_base); + // Build counters are per channel, so build numbers only compare within the native channel. + bool cross_channel = (strcmp(manifest_base, OTA_MANIFEST_BASE) != 0); int behind = 0; bool up_to_date; - if (same_base && have_builds) { + if (cross_channel) { + up_to_date = hash_equal; + } else if (same_base && have_builds) { behind = avail_build - own_build; up_to_date = (behind <= 0); } else if (diff_base) { @@ -362,6 +366,8 @@ bool ESP32Board::otaFromManifestImpl(const char* manifest_base, const char* curr if (dry_run) { if (up_to_date) { snprintf(reply, 160, "up to date: %s", avail_disp); + } else if (cross_channel) { + snprintf(reply, 160, "update available: %s -> %s (channel switch)%s", own_disp, avail_disp, pc_note); } else if (same_base && have_builds) { snprintf(reply, 160, "update available: %s -> %s (%d behind)%s", own_disp, avail_disp, behind, pc_note); } else if (diff_base) { From 4afb3f7e3f0a03b58b770a6403eba5a25bbf6f87 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 15:17:46 -0700 Subject: [PATCH 07/14] build(ota): point the stable/dev channel bases at observer.gessaman.com --- build.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/build.sh b/build.sh index a6ad41c63e..1d6783fb38 100755 --- a/build.sh +++ b/build.sh @@ -206,10 +206,10 @@ build_firmware() { # Both named channel bases are baked into EVERY observer build so `ota branch` # can re-point a device at either channel at runtime. OTA_MANIFEST_BASE above stays # the build's NATIVE channel (= stable base for stable builds, dev base for dev - # builds), so `ota branch default` resolves correctly. These two must match the - # paths DutchMeshCore-OTA serves (feat/dev-stable-ota-channels): /mqtt/v + /mqtt/dev/v. - OTA_MANIFEST_BASE_STABLE_URL="${OTA_MANIFEST_BASE_STABLE_URL:-https://ota.dutchmeshcore.nl/mqtt/v}" - OTA_MANIFEST_BASE_DEV_URL="${OTA_MANIFEST_BASE_DEV_URL:-https://ota.dutchmeshcore.nl/mqtt/dev/v}" + # builds), so `ota branch default` resolves correctly. These must match the + # production (/v) and beta (/beta/v) manifest paths. + OTA_MANIFEST_BASE_STABLE_URL="${OTA_MANIFEST_BASE_STABLE_URL:-https://observer.gessaman.com/v}" + OTA_MANIFEST_BASE_DEV_URL="${OTA_MANIFEST_BASE_DEV_URL:-https://observer.gessaman.com/beta/v}" # add firmware version info to end of existing platformio build flags in environment vars. # OTA_VARIANT is the env name ($1) — it selects this build's slim per-variant manifest From 9c65e198f1ab1b44f608fdb5df25956eb513e9f4 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 15:19:06 -0700 Subject: [PATCH 08/14] feat(ota): prod/beta channel names for ota branch, with stable/dev aliases Report the selected channel and the build's own channel, tag each baked-in base for CI, and list ota branch in the CLI docs and portal autocomplete. --- docs/cli_commands.md | 6 ++ src/helpers/CommonCLI_Observer.cpp | 11 ++-- src/helpers/OtaChannel.h | 68 +++++++++++++--------- test/test_ota_channel/test_ota_channel.cpp | 22 +++++-- webui/index.html | 2 + 5 files changed, 72 insertions(+), 37 deletions(-) diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 057fc0e2a0..5267f53060 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -88,6 +88,12 @@ This document provides an overview of CLI commands that can be sent to MeshCore - `start ota` — serves the ElegantOTA web upload page on the station IP if joined to a Wi-Fi network, otherwise raises the `MeshCore-OTA` Wi-Fi hotspot. - `start ota ap` — always raises the `MeshCore-OTA` Wi-Fi hotspot, even when joined to a network. Use this when the network applies client isolation and the station IP isn't reachable. +### Switch the OTA release channel (observer builds) + +- `ota branch` — shows the selected channel, the channel this build was made for, and the manifest base `ota check`/`ota update` will use. +- `ota branch prod` (alias `stable`) / `ota branch beta` (alias `dev`) — pull future `ota update`s from that channel. The selection is saved; run `ota update` to switch. +- `ota branch default` — follow the channel this build was made for. + --- ### Erase/Factory Reset diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index e4d71182df..5ef7287a6c 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -1132,7 +1132,7 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, strcpy(reply, "ERR: online OTA not supported on this build"); #endif return true; - } else if (memcmp(command, "ota branch", 10) == 0) { + } else if (memcmp(command, "ota branch", 10) == 0 && (command[10] == 0 || command[10] == ' ')) { // Switch (or report) the OTA release channel this device pulls from. The // selection is persisted (NodePrefs::ota_channel) and resolved to a baked-in // base URL by ota_resolve_base(); it changes only WHERE updates are fetched, @@ -1142,18 +1142,17 @@ bool CommonCLI::handleObserverCommand(uint32_t sender_timestamp, char* command, const char* arg = command + 10; while (*arg == ' ') arg++; if (*arg == 0) { - snprintf(reply, 160, "channel: %s (%s), base %s", - ota_channel_name(_prefs->ota_channel), - _prefs->ota_channel == OTA_CH_NATIVE ? "native" : "override", + snprintf(reply, 160, "channel: %s (build: %s), base %s", + ota_channel_name(_prefs->ota_channel), ota_native_channel_name(), ota_resolve_base(_prefs->ota_channel)); } else { uint8_t ch; if (!ota_parse_channel(arg, &ch)) { - strcpy(reply, "ERR: usage ota branch [stable|dev|default]"); + strcpy(reply, "ERR: usage ota branch [prod|beta|default] (aliases: stable, dev)"); } else { _prefs->ota_channel = ch; savePrefs(); - snprintf(reply, 160, "channel set to %s, base %s", + snprintf(reply, 160, "channel set to %s, base %s; run ota update to switch", ota_channel_name(ch), ota_resolve_base(ch)); } } diff --git a/src/helpers/OtaChannel.h b/src/helpers/OtaChannel.h index d589854bce..2918d83d4a 100644 --- a/src/helpers/OtaChannel.h +++ b/src/helpers/OtaChannel.h @@ -5,36 +5,44 @@ // OTA release-channel selector, persisted in NodePrefs::ota_channel. enum OtaChannel : uint8_t { OTA_CH_NATIVE = 0, // follow the channel this build was made for - OTA_CH_STABLE = 1, - OTA_CH_DEV = 2, + OTA_CH_STABLE = 1, // production + OTA_CH_DEV = 2, // beta }; // Resolve the effective manifest base URL for a channel selector. // build.sh injects the three bases as compile-time macros: // OTA_MANIFEST_BASE = this build's native channel (defined on every OTA build) -// OTA_MANIFEST_BASE_STABLE = stable channel -// OTA_MANIFEST_BASE_DEV = dev channel +// OTA_MANIFEST_BASE_STABLE = stable (production) channel +// OTA_MANIFEST_BASE_DEV = dev (beta) channel // stable/dev fall back to the native base when their macro is undefined (legacy/local // builds that only define OTA_MANIFEST_BASE), so this never returns nullptr on an // OTA-capable build. On a non-OTA build it returns nullptr. -static inline const char* ota_resolve_base(uint8_t channel) { +// +// Each base is stored behind an "ota-base-:" tag so CI can read a binary's +// channels back with `strings` (scripts/verify_ota_channel.sh). Returning a pointer +// into the tagged array keeps the tag referenced, so the linker cannot drop it. #if defined(OTA_MANIFEST_BASE) - switch (channel) { - case OTA_CH_STABLE: -#if defined(OTA_MANIFEST_BASE_STABLE) - return OTA_MANIFEST_BASE_STABLE; -#else - return OTA_MANIFEST_BASE; +#define OTA_BASE_TAG_NATIVE "ota-base-native:" +#define OTA_BASE_TAG_STABLE "ota-base-stable:" +#define OTA_BASE_TAG_DEV "ota-base-dev:" +#if !defined(OTA_MANIFEST_BASE_STABLE) +#define OTA_MANIFEST_BASE_STABLE OTA_MANIFEST_BASE #endif - case OTA_CH_DEV: -#if defined(OTA_MANIFEST_BASE_DEV) - return OTA_MANIFEST_BASE_DEV; -#else - return OTA_MANIFEST_BASE; +#if !defined(OTA_MANIFEST_BASE_DEV) +#define OTA_MANIFEST_BASE_DEV OTA_MANIFEST_BASE +#endif +static const char ota_tagged_native[] = OTA_BASE_TAG_NATIVE OTA_MANIFEST_BASE; +static const char ota_tagged_stable[] = OTA_BASE_TAG_STABLE OTA_MANIFEST_BASE_STABLE; +static const char ota_tagged_dev[] = OTA_BASE_TAG_DEV OTA_MANIFEST_BASE_DEV; #endif + +static inline const char* ota_resolve_base(uint8_t channel) { +#if defined(OTA_MANIFEST_BASE) + switch (channel) { + case OTA_CH_STABLE: return ota_tagged_stable + sizeof(OTA_BASE_TAG_STABLE) - 1; + case OTA_CH_DEV: return ota_tagged_dev + sizeof(OTA_BASE_TAG_DEV) - 1; case OTA_CH_NATIVE: - default: - return OTA_MANIFEST_BASE; + default: return ota_tagged_native + sizeof(OTA_BASE_TAG_NATIVE) - 1; } #else (void)channel; @@ -45,18 +53,26 @@ static inline const char* ota_resolve_base(uint8_t channel) { // Human label for a selector (for the `ota branch` report). static inline const char* ota_channel_name(uint8_t channel) { switch (channel) { - case OTA_CH_STABLE: return "stable"; - case OTA_CH_DEV: return "dev"; - default: return "native"; + case OTA_CH_STABLE: return "prod"; + case OTA_CH_DEV: return "beta"; + default: return "default"; } } +// Label for the channel this build was made for, by matching its native base. +static inline const char* ota_native_channel_name() { + const char* native = ota_resolve_base(OTA_CH_NATIVE); + if (native == nullptr) return "none"; + if (strcmp(native, ota_resolve_base(OTA_CH_STABLE)) == 0) return "prod"; + if (strcmp(native, ota_resolve_base(OTA_CH_DEV)) == 0) return "beta"; + return "custom"; +} + // Parse an `ota branch` argument. Returns true and sets *out on a known keyword -// (stable|dev|default; "default" -> native); returns false and leaves *out untouched -// otherwise. +// (prod|stable, beta|dev, default); returns false and leaves *out untouched otherwise. static inline bool ota_parse_channel(const char* arg, uint8_t* out) { - if (strcmp(arg, "stable") == 0) { *out = OTA_CH_STABLE; return true; } - if (strcmp(arg, "dev") == 0) { *out = OTA_CH_DEV; return true; } - if (strcmp(arg, "default") == 0) { *out = OTA_CH_NATIVE; return true; } + if (strcmp(arg, "prod") == 0 || strcmp(arg, "stable") == 0) { *out = OTA_CH_STABLE; return true; } + if (strcmp(arg, "beta") == 0 || strcmp(arg, "dev") == 0) { *out = OTA_CH_DEV; return true; } + if (strcmp(arg, "default") == 0) { *out = OTA_CH_NATIVE; return true; } return false; } diff --git a/test/test_ota_channel/test_ota_channel.cpp b/test/test_ota_channel/test_ota_channel.cpp index 699ffb70cd..d2a4e482ca 100644 --- a/test/test_ota_channel/test_ota_channel.cpp +++ b/test/test_ota_channel/test_ota_channel.cpp @@ -11,21 +11,33 @@ TEST(OtaChannel, ResolvesStable) { TEST(OtaChannel, ResolvesDev) { EXPECT_STREQ(ota_resolve_base(OTA_CH_DEV), "https://dev.example/mqtt/dev/v"); } +TEST(OtaChannel, BasesAreStoredBehindCiTags) { + EXPECT_STREQ(ota_tagged_native, "ota-base-native:https://stable.example/mqtt/v"); + EXPECT_STREQ(ota_tagged_stable, "ota-base-stable:https://stable.example/mqtt/v"); + EXPECT_STREQ(ota_tagged_dev, "ota-base-dev:https://dev.example/mqtt/dev/v"); +} TEST(OtaChannel, ParseKnownKeywords) { uint8_t ch = 99; + EXPECT_TRUE(ota_parse_channel("prod", &ch)); EXPECT_EQ(ch, OTA_CH_STABLE); EXPECT_TRUE(ota_parse_channel("stable", &ch)); EXPECT_EQ(ch, OTA_CH_STABLE); - EXPECT_TRUE(ota_parse_channel("dev", &ch)); EXPECT_EQ(ch, OTA_CH_DEV); + EXPECT_TRUE(ota_parse_channel("beta", &ch)); EXPECT_EQ(ch, OTA_CH_DEV); EXPECT_TRUE(ota_parse_channel("default", &ch)); EXPECT_EQ(ch, OTA_CH_NATIVE); + EXPECT_TRUE(ota_parse_channel("dev", &ch)); EXPECT_EQ(ch, OTA_CH_DEV); } TEST(OtaChannel, ParseRejectsUnknownAndLeavesOutputUntouched) { uint8_t ch = 7; - EXPECT_FALSE(ota_parse_channel("beta", &ch)); + EXPECT_FALSE(ota_parse_channel("production", &ch)); + EXPECT_FALSE(ota_parse_channel("Beta", &ch)); + EXPECT_FALSE(ota_parse_channel("", &ch)); EXPECT_EQ(ch, 7); } TEST(OtaChannel, NameLabels) { - EXPECT_STREQ(ota_channel_name(OTA_CH_NATIVE), "native"); - EXPECT_STREQ(ota_channel_name(OTA_CH_STABLE), "stable"); - EXPECT_STREQ(ota_channel_name(OTA_CH_DEV), "dev"); + EXPECT_STREQ(ota_channel_name(OTA_CH_NATIVE), "default"); + EXPECT_STREQ(ota_channel_name(OTA_CH_STABLE), "prod"); + EXPECT_STREQ(ota_channel_name(OTA_CH_DEV), "beta"); +} +TEST(OtaChannel, NativeChannelNameMatchesBase) { + EXPECT_STREQ(ota_native_channel_name(), "prod"); } int main(int argc, char** argv) { diff --git a/webui/index.html b/webui/index.html index ab7b43d6a7..30cf4ceaf4 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1495,6 +1495,8 @@

Rebooting…

["alert test","Send a test alert on the configured channel"], ["ota check","Check for a newer build (does not flash)"], ["ota update","Download and flash the newer build, then reboot"], + ["ota branch","Show the OTA release channel"], + ["ota branch ","Set the OTA release channel {prod|beta|default}"], // `start ota` is absent: it binds port 80, which this portal is already using. ["start webconfig","Start this portal on the LAN"], ["start webconfig ap","Start this portal on its own setup AP"], From 65d80bc908a721e76ffdd60499c305aa47205328 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 15:24:11 -0700 Subject: [PATCH 09/14] ci(ota): verify each published binary's native channel by tag Every observer build now carries both channel URLs, so checking for a URL's presence or absence can no longer tell prod from beta (and the old checks would reject every build). Read the ota-base-* tags from every .bin in out/ instead of a single ELF, with URLs shared by build.sh and both workflows. --- .../build-observer-firmwares-beta.yml | 21 +-- .../workflows/build-observer-firmwares.yml | 15 +++ scripts/verify_ota_channel.py | 125 ++++++++++++++++++ 3 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 scripts/verify_ota_channel.py diff --git a/.github/workflows/build-observer-firmwares-beta.yml b/.github/workflows/build-observer-firmwares-beta.yml index 8618da4b99..0fa2c9fd53 100644 --- a/.github/workflows/build-observer-firmwares-beta.yml +++ b/.github/workflows/build-observer-firmwares-beta.yml @@ -67,6 +67,10 @@ env: # The channel itself. Firmware fetches /.json, # so this URL is what keeps beta nodes on beta. OTA_MANIFEST_BASE_URL: https://observer.gessaman.com/beta/v + # Both channel bases are baked into every build so `ota branch prod|beta` can + # move a node between channels. Identical in both observer workflows. + OTA_MANIFEST_BASE_STABLE_URL: https://observer.gessaman.com/v + OTA_MANIFEST_BASE_DEV_URL: https://observer.gessaman.com/beta/v # Marks the embedded version, e.g. v1.16.0.3-observer-beta-dev-abc1234, so `ver` # (and the MQTT firmware_version / SNMP) identify BOTH the channel and its # provenance: this channel is built from the upstream-dev-merged line, so "dev" @@ -168,20 +172,9 @@ jobs: run: /usr/bin/env bash build.sh build-firmware ${{ matrix.shard.envs }} - name: Verify beta channel is baked in - shell: bash - run: | - # Fail fast rather than publish firmware that would OTA itself onto the - # production channel. Checks one built binary actually carries the beta - # manifest URL and does NOT carry the production one. - BIN=$(find .pio/build -name firmware.elf | head -1) - if [ -z "$BIN" ]; then echo "no ELF found to verify" >&2; exit 1; fi - if ! strings "$BIN" | grep -qF "$OTA_MANIFEST_BASE_URL"; then - echo "ERROR: beta manifest base missing from $BIN" >&2; exit 1 - fi - if strings "$BIN" | grep -qE 'https://observer\.gessaman\.com/v"?$'; then - echo "ERROR: production manifest base present in a beta build" >&2; exit 1 - fi - echo "OK: $BIN carries $OTA_MANIFEST_BASE_URL" + # Fail fast rather than publish firmware that would OTA itself onto the + # production channel: checks every .bin the shard publishes. + run: python3 scripts/verify_ota_channel.py --expect beta - name: Upload Shard Artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/build-observer-firmwares.yml b/.github/workflows/build-observer-firmwares.yml index 13d9cda0fb..9aebe03fdb 100644 --- a/.github/workflows/build-observer-firmwares.yml +++ b/.github/workflows/build-observer-firmwares.yml @@ -48,6 +48,16 @@ env: # repo). Baked into the slim OTA manifests' file URLs; must stay consistent # with config.json's staticPath. STATIC_PATH: https://observer-fw.gessaman.com + # Where the firmware fetches .json from — this URL *is* the release + # channel. Stated explicitly (it equals build.sh's default) rather than left + # unset, so "Verify production channel is baked in" below can assert against the + # same value the build was handed instead of a second hardcoded copy. Must match + # build.sh's OTA_MANIFEST_BASE_URL default and the Pages path serving flasher/v. + OTA_MANIFEST_BASE_URL: https://observer.gessaman.com/v + # Both channel bases are baked into every build so `ota branch prod|beta` can + # move a node between channels. Identical in both observer workflows. + OTA_MANIFEST_BASE_STABLE_URL: https://observer.gessaman.com/v + OTA_MANIFEST_BASE_DEV_URL: https://observer.gessaman.com/beta/v jobs: @@ -137,6 +147,11 @@ jobs: FIRMWARE_BUILD_NUMBER: ${{ needs.enumerate.outputs.build_number }} run: /usr/bin/env bash build.sh build-firmware ${{ matrix.shard.envs }} + - name: Verify production channel is baked in + # Fail fast rather than publish firmware that would OTA itself onto the + # wrong channel: checks every .bin the shard publishes. + run: python3 scripts/verify_ota_channel.py --expect prod + - name: Upload Shard Artifact uses: actions/upload-artifact@v4 with: diff --git a/scripts/verify_ota_channel.py b/scripts/verify_ota_channel.py new file mode 100644 index 0000000000..15d91c26fd --- /dev/null +++ b/scripts/verify_ota_channel.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Verify every built observer binary is baked for the expected OTA channel. + +Observer builds carry three manifest bases (src/helpers/OtaChannel.h), each stored +behind a tag so it can be read back from the binary: + + ota-base-native: the channel the build OTAs from by default + ota-base-stable: production, the target of `ota branch prod` + ota-base-dev: beta, the target of `ota branch beta` + +Every build contains both channel URLs, so checking for the presence or absence +of a URL can no longer tell production from beta. This reads the tags instead and +fails unless, in every .bin in out/ (the files that get published), each tag appears with exactly one value, the stable +and dev tags match the workflow's URLs, and the native tag is the URL of the +channel named by --expect. + + python3 scripts/verify_ota_channel.py --expect prod + python3 scripts/verify_ota_channel.py --self-test + +URLs default to OTA_MANIFEST_BASE_URL, OTA_MANIFEST_BASE_STABLE_URL and +OTA_MANIFEST_BASE_DEV_URL from the environment. Stdlib only. +""" +import argparse +import os +import re +import sys + +TAG_RE = re.compile(rb"ota-base-(native|stable|dev):([\x21-\x7e]*)\x00") + + +def read_tags(data): + tags = {"native": set(), "stable": set(), "dev": set()} + for m in TAG_RE.finditer(data): + tags[m.group(1).decode()].add(m.group(2).decode()) + return tags + + +def check(data, expect, native_url, stable_url, dev_url): + """Return a list of problems with one binary's tags (empty when it passes).""" + problems = [] + if stable_url == dev_url: + problems.append("stable and dev URLs are identical (%s)" % stable_url) + expected_native = stable_url if expect == "prod" else dev_url + if native_url != expected_native: + problems.append("OTA_MANIFEST_BASE_URL %s is not the %s URL %s" + % (native_url, expect, expected_native)) + tags = read_tags(data) + for name, want in (("native", expected_native), ("stable", stable_url), ("dev", dev_url)): + found = sorted(tags[name]) + if found != [want]: + problems.append("ota-base-%s: expected [%s], found %s" % (name, want, found or "nothing")) + return problems + + +def find_bins(root): + for name in os.listdir(root): + if name.endswith(".bin"): + yield os.path.join(root, name) + + +def self_test(): + P, B = "https://h/v", "https://h/beta/v" + + def blob(native, stable=P, dev=B): + return b"\x00junk\x00ota-base-native:%s\x00ota-base-stable:%s\x00ota-base-dev:%s\x00" % ( + native.encode(), stable.encode(), dev.encode()) + + cases = [ + ("prod build passes", blob(P), "prod", P, True), + ("beta build passes", blob(B), "beta", B, True), + ("beta build fails prod check", blob(B), "prod", P, False), + ("prod build fails beta check", blob(P), "beta", B, False), + ("missing tags fail", b"\x00https://h/v\x00https://h/beta/v\x00", "prod", P, False), + ("wrong dev URL fails", blob(P, dev="https://other/v"), "prod", P, False), + ("conflicting native tags fail", blob(P) + blob(B), "prod", P, False), + ("workflow URL off-channel fails", blob(P), "prod", B, False), + ] + failed = 0 + for name, data, expect, native, ok in cases: + got = not check(data, expect, native, P, B) + if got != ok: + failed += 1 + print("FAIL: %s" % name) + print("self-test: %d/%d passed" % (len(cases) - failed, len(cases))) + return 1 if failed else 0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--expect", choices=("prod", "beta")) + ap.add_argument("--bin-dir", default="out") + ap.add_argument("--native-url", default=os.environ.get("OTA_MANIFEST_BASE_URL")) + ap.add_argument("--stable-url", default=os.environ.get("OTA_MANIFEST_BASE_STABLE_URL")) + ap.add_argument("--dev-url", default=os.environ.get("OTA_MANIFEST_BASE_DEV_URL")) + ap.add_argument("--self-test", action="store_true") + args = ap.parse_args() + + if args.self_test: + return self_test() + if not args.expect: + ap.error("--expect is required") + for opt in ("native_url", "stable_url", "dev_url"): + if not getattr(args, opt): + ap.error("--%s (or its environment variable) is required" % opt.replace("_", "-")) + + bins = sorted(find_bins(args.bin_dir)) if os.path.isdir(args.bin_dir) else [] + if not bins: + print("ERROR: no .bin files in %s" % args.bin_dir, file=sys.stderr) + return 1 + bad = 0 + for path in bins: + with open(path, "rb") as f: + problems = check(f.read(), args.expect, args.native_url, args.stable_url, args.dev_url) + for p in problems: + print("ERROR: %s: %s" % (path, p), file=sys.stderr) + bad += bool(problems) + if bad: + print("ERROR: %d of %d builds are not baked for %s" % (bad, len(bins), args.expect), file=sys.stderr) + return 1 + print("OK: %d builds default to %s (%s) and carry both channels" % (len(bins), args.expect, args.native_url)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From a1943a8eac75ec7b29b486889bd404861de5356a Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 15:37:49 -0700 Subject: [PATCH 10/14] fix(ota): always offer another channel's image, even from the same commit Prod and beta are often built from one commit (e.g. after a dev->prod merge), so a hash match does not mean the images are the same: the native base differs. After the flash the target channel is native, so this cannot loop. --- src/helpers/ESP32Board.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index 206a3a7c1c..d36661d086 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -334,13 +334,14 @@ bool ESP32Board::otaFromManifestImpl(const char* manifest_base, const char* curr bool same_base = (own_base[0] && avail_base[0] && strcmp(own_base, avail_base) == 0); bool have_builds = (own_build >= 0 && avail_build >= 0); bool diff_base = (own_base[0] && avail_base[0] && !same_base); - // Build counters are per channel, so build numbers only compare within the native channel. + // Another channel's image always differs (its native base does), even from the same + // commit; build counters are per channel, so build numbers only compare natively. bool cross_channel = (strcmp(manifest_base, OTA_MANIFEST_BASE) != 0); int behind = 0; bool up_to_date; if (cross_channel) { - up_to_date = hash_equal; + up_to_date = false; } else if (same_base && have_builds) { behind = avail_build - own_build; up_to_date = (behind <= 0); From f652e7d185f8752bb1f8b8a73fb97a8031a01a65 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 15:37:49 -0700 Subject: [PATCH 11/14] fix(ota): flash the channel ota update checked, not a later ota branch The deferred flash re-read the channel preference ~2.5 s after ota update acknowledged its target; capture it when the update is scheduled. --- examples/simple_repeater/MyMesh.cpp | 2 +- examples/simple_repeater/MyMesh.h | 2 ++ examples/simple_room_server/MyMesh.cpp | 2 +- examples/simple_room_server/MyMesh.h | 2 ++ 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index b378965767..be201fb4d5 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -1741,7 +1741,7 @@ void MyMesh::loop() { Serial.println("OTA: aborted, MQTT stop did not complete cleanly - resuming bridge"); otaAlert("OTA aborted: MQTT stop unclean, bridge resumed"); setBridgeState(true); - } else if (!_cli.getBoard()->otaFromManifest(ota_resolve_base(_prefs.ota_channel), getFirmwareVer(), false, ota_reply)) { + } else if (!_cli.getBoard()->otaFromManifest(ota_resolve_base(_ota_update_channel), getFirmwareVer(), false, ota_reply)) { Serial.print("OTA: aborted, resuming bridge - "); Serial.println(ota_reply); char ota_alert_msg[160]; snprintf(ota_alert_msg, sizeof(ota_alert_msg), "OTA aborted: %s", ota_reply); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index a232ef5202..c1e03a8e32 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -123,6 +123,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks CayenneLPP telemetry; unsigned long set_radio_at, revert_radio_at; unsigned long _ota_update_at = 0; // deferred `ota update` fire time (0 = none scheduled) + uint8_t _ota_update_channel = 0; // channel `ota update` checked; a later `ota branch` cannot retarget it float pending_freq; float pending_bw; uint8_t pending_sf; @@ -441,6 +442,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks bool beginDeferredOtaUpdate() override { _ota_update_at = millis() + 2500; if (_ota_update_at == 0) _ota_update_at = 1; // 0 means "none" + _ota_update_channel = _prefs.ota_channel; #if defined(WITH_MQTT_BRIDGE) // Broadcast START now, while the loop still runs (the 2.5 s reply window): // the deferred flash blocks the loop and, on success, reboots — so a start diff --git a/examples/simple_room_server/MyMesh.cpp b/examples/simple_room_server/MyMesh.cpp index 2e97d49177..079e43fa01 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -1617,7 +1617,7 @@ void MyMesh::loop() { } char ota_reply[160]; - if (may_flash && !_cli.getBoard()->otaFromManifest(ota_resolve_base(_prefs.ota_channel), getFirmwareVer(), false, ota_reply)) { + if (may_flash && !_cli.getBoard()->otaFromManifest(ota_resolve_base(_ota_update_channel), getFirmwareVer(), false, ota_reply)) { Serial.print("OTA: aborted - "); Serial.println(ota_reply); may_flash = false; } diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index b0eb4e8f33..ef7fb51552 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -136,6 +136,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks TransportKey default_scope; unsigned long set_radio_at, revert_radio_at; unsigned long _ota_update_at = 0; // deferred `ota update` fire time (0 = none scheduled) + uint8_t _ota_update_channel = 0; // channel `ota update` checked; a later `ota branch` cannot retarget it float pending_freq; float pending_bw; uint8_t pending_sf; @@ -433,6 +434,7 @@ class MyMesh : public mesh::Mesh, public CommonCLICallbacks bool beginDeferredOtaUpdate() override { _ota_update_at = millis() + 2500; if (_ota_update_at == 0) _ota_update_at = 1; // 0 means "none" + _ota_update_channel = _prefs.ota_channel; return true; } From ed8af1b7ca317785cecb164b09368b309122081d Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 15:46:46 -0700 Subject: [PATCH 12/14] fix(ota): refuse a channel switch to a build that cannot carry this node A switch can land on an older build: e.g. prod cannot read dev's /mqtt.json, and prod's M7 image has no Ethernet. Each image carries an ota-compat tag (state generation + transports). A switch is flashed without rebooting, the new image's tag is read (bounded by its length), and boot is pointed back at the running image unless the target keeps this node's state and transports. CI now requires the tag in every published binary. --- scripts/verify_ota_channel.py | 12 ++++- src/helpers/ESP32Board.cpp | 58 +++++++++++++++++++++- src/helpers/OtaChannel.h | 54 ++++++++++++++++++++ test/test_ota_channel/test_ota_channel.cpp | 33 ++++++++++++ 4 files changed, 153 insertions(+), 4 deletions(-) diff --git a/scripts/verify_ota_channel.py b/scripts/verify_ota_channel.py index 15d91c26fd..6b74030b45 100644 --- a/scripts/verify_ota_channel.py +++ b/scripts/verify_ota_channel.py @@ -2,7 +2,8 @@ """Verify every built observer binary is baked for the expected OTA channel. Observer builds carry three manifest bases (src/helpers/OtaChannel.h), each stored -behind a tag so it can be read back from the binary: +behind a tag so it can be read back from the binary (plus an ota-compat tag, which a +channel switch reads from the downloaded image before booting it): ota-base-native: the channel the build OTAs from by default ota-base-stable: production, the target of `ota branch prod` @@ -26,6 +27,7 @@ import sys TAG_RE = re.compile(rb"ota-base-(native|stable|dev):([\x21-\x7e]*)\x00") +COMPAT_RE = re.compile(rb"ota-compat:([0-9]+(?:\+[a-z]+)*)\x00") def read_tags(data): @@ -49,6 +51,10 @@ def check(data, expect, native_url, stable_url, dev_url): found = sorted(tags[name]) if found != [want]: problems.append("ota-base-%s: expected [%s], found %s" % (name, want, found or "nothing")) + # Without its compat tag a build cannot be the target of a channel switch. + compat = sorted({m.group(1).decode() for m in COMPAT_RE.finditer(data)}) + if len(compat) != 1: + problems.append("ota-compat: expected one value, found %s" % (compat or "nothing")) return problems @@ -62,7 +68,7 @@ def self_test(): P, B = "https://h/v", "https://h/beta/v" def blob(native, stable=P, dev=B): - return b"\x00junk\x00ota-base-native:%s\x00ota-base-stable:%s\x00ota-base-dev:%s\x00" % ( + return b"\x00junk\x00ota-base-native:%s\x00ota-base-stable:%s\x00ota-base-dev:%s\x00ota-compat:1\x00" % ( native.encode(), stable.encode(), dev.encode()) cases = [ @@ -74,6 +80,8 @@ def blob(native, stable=P, dev=B): ("wrong dev URL fails", blob(P, dev="https://other/v"), "prod", P, False), ("conflicting native tags fail", blob(P) + blob(B), "prod", P, False), ("workflow URL off-channel fails", blob(P), "prod", B, False), + ("missing compat tag fails", blob(P).replace(b"ota-compat:1", b"ota-compat:"), "prod", P, False), + ("conflicting compat tags fail", blob(P) + b"ota-compat:2+eth\x00", "prod", P, False), ] failed = 0 for name, data, expect, native, ok in cases: diff --git a/src/helpers/ESP32Board.cpp b/src/helpers/ESP32Board.cpp index d36661d086..7c013c23b2 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -78,6 +78,9 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) { #include #include #include +#include +#include +#include "OtaChannel.h" // Embedded CA bundle (produced by board_build.embed_files). Weak so non-bundle // builds still link; we check for presence at runtime. @@ -123,6 +126,31 @@ static void ota_parseVersion(const char* ver, char* base_out, size_t base_sz, in } } +// Read the compat tag of the image just written to `part`, bounded by the image's own +// length: bytes past it can be a stale, tagged image from an earlier flash. +static bool ota_readImageCompat(const esp_partition_t* part, OtaCompat* out) { + const esp_partition_pos_t pos = { part->address, part->size }; + esp_image_metadata_t meta; + if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &pos, &meta) != ESP_OK) return false; + static const size_t kChunk = 2048, kTail = 64; // tail > tag + value, so a split tag is seen whole + static uint8_t buf[kTail + kChunk]; + size_t have = 0; + for (uint32_t off = 0; off < meta.image_len;) { + size_t n = meta.image_len - off; + if (n > kChunk) n = kChunk; + if (esp_partition_read(part, off, buf + have, n) != ESP_OK) return false; + have += n; + off += n; + const char* v = ota_compat_find(buf, have); + if (v) return ota_compat_parse(v, out); + if (have > kTail) { + memmove(buf, buf + have - kTail, kTail); + have = kTail; + } + } + return false; +} + // Canonical signature of the FLASHED partition table — MUST match // scripts/partition_signature.py: each entry "type:subtype:offset:size" in // lowercase hex, sorted by offset, joined by ','. Lets `ota update` compare the @@ -412,9 +440,35 @@ bool ESP32Board::otaFromManifestImpl(const char* manifest_base, const char* curr int d = (int)((int64_t)cur * 10 / total); if (d != ota_progress_decile) { ota_progress_decile = d; Serial.printf("OTA: %d%%\n", d * 10); } }); - httpUpdate.onEnd([]() { Serial.println("OTA: write complete, rebooting..."); }); - httpUpdate.rebootOnUpdate(true); // reboots into the new image on success + httpUpdate.onEnd([]() { Serial.println("OTA: write complete"); }); + // A channel switch is checked before it may boot (below); native updates reboot here. + httpUpdate.rebootOnUpdate(!cross_channel); t_httpUpdate_return ret = httpUpdate.update(uclient, file_url); + if (ret == HTTP_UPDATE_OK && cross_channel) { + const esp_partition_t* running = esp_ota_get_running_partition(); + const esp_partition_t* target = esp_ota_get_boot_partition(); + OtaCompat own = {0, 0}, img = {0, 0}; + const char* volatile own_tag = ota_compat_tag; // volatile: the tag must stay in the image + ota_compat_parse(own_tag + sizeof(OTA_COMPAT_TAG) - 1, &own); + bool tagged = target && target != running && ota_readImageCompat(target, &img); + if (tagged && ota_compat_ok(own, img)) { + Serial.println("OTA: channel switch compatible, rebooting..."); + delay(100); + ESP.restart(); + } + // Point boot back at the running image so the refused build never starts. + bool reverted = (esp_ota_set_boot_partition(running) == ESP_OK); + inhibit_sleep = false; + if (!tagged) { + snprintf(reply, 160, "ERR: channel switch refused: %s has no compat tag; cable flash%s", + avail_disp, reverted ? "" : " [boot revert FAILED]"); + } else { + snprintf(reply, 160, "ERR: channel switch refused: target compat %d/%x < this node %d/%x; cable flash%s", + img.gen, img.caps, own.gen, own.caps, reverted ? "" : " [boot revert FAILED]"); + } + Serial.print("OTA: "); Serial.println(reply); + return false; + } // Only reached on failure (success reboots inside update()). inhibit_sleep = false; diff --git a/src/helpers/OtaChannel.h b/src/helpers/OtaChannel.h index 2918d83d4a..974cc4ba9c 100644 --- a/src/helpers/OtaChannel.h +++ b/src/helpers/OtaChannel.h @@ -76,3 +76,57 @@ static inline bool ota_parse_channel(const char* arg, uint8_t* out) { if (strcmp(arg, "default") == 0) { *out = OTA_CH_NATIVE; return true; } return false; } + +// Compatibility tag, read from a downloaded image before a channel switch boots it. +// A switch can land on an older build, which would boot without state it cannot read +// or without a transport this node depends on. Bump OTA_STATE_GEN when a build starts +// storing state that older builds cannot read. +// 1: /prefs.json + /mqtt_prefs +#ifndef OTA_STATE_GEN +#define OTA_STATE_GEN 1 +#endif +#define OTA_CAP_ETH 0x01 // carries MQTT over Ethernet +#if defined(NETWORK_PREFER_ETHERNET) +#define OTA_CAPS_STR "+eth" +#else +#define OTA_CAPS_STR "" +#endif +#define OTA_STR_(x) #x +#define OTA_STR(x) OTA_STR_(x) +#define OTA_COMPAT_TAG "ota-compat:" +static const char ota_compat_tag[] = OTA_COMPAT_TAG OTA_STR(OTA_STATE_GEN) OTA_CAPS_STR; + +struct OtaCompat { + int gen; + uint8_t caps; +}; + +// Parse the tag value "[+cap...]"; unknown caps are ignored. +static inline bool ota_compat_parse(const char* s, OtaCompat* out) { + if (*s < '0' || *s > '9') return false; + out->gen = 0; + out->caps = 0; + while (*s >= '0' && *s <= '9') out->gen = out->gen * 10 + (*s++ - '0'); + while (*s == '+') { + const char* cap = ++s; + while (*s && *s != '+') s++; + if ((size_t)(s - cap) == 3 && memcmp(cap, "eth", 3) == 0) out->caps |= OTA_CAP_ETH; + } + return *s == 0; +} + +// Find the tag in an image chunk; returns the NUL-terminated value text, or nullptr when +// the chunk holds no complete tag. +static inline const char* ota_compat_find(const uint8_t* buf, size_t len) { + const size_t tag_len = sizeof(OTA_COMPAT_TAG) - 1; + for (size_t i = 0; i + tag_len < len; i++) { + if (memcmp(buf + i, OTA_COMPAT_TAG, tag_len) != 0) continue; + if (memchr(buf + i + tag_len, 0, len - i - tag_len)) return (const char*)buf + i + tag_len; + } + return nullptr; +} + +// A target must read this node's state and keep every transport this node has. +static inline bool ota_compat_ok(const OtaCompat& own, const OtaCompat& target) { + return target.gen >= own.gen && (target.caps & own.caps) == own.caps; +} diff --git a/test/test_ota_channel/test_ota_channel.cpp b/test/test_ota_channel/test_ota_channel.cpp index d2a4e482ca..75ce673de1 100644 --- a/test/test_ota_channel/test_ota_channel.cpp +++ b/test/test_ota_channel/test_ota_channel.cpp @@ -40,6 +40,39 @@ TEST(OtaChannel, NativeChannelNameMatchesBase) { EXPECT_STREQ(ota_native_channel_name(), "prod"); } +TEST(OtaCompat, OwnTagParses) { + OtaCompat own; + ASSERT_TRUE(ota_compat_parse(ota_compat_tag + sizeof(OTA_COMPAT_TAG) - 1, &own)); + EXPECT_EQ(own.gen, OTA_STATE_GEN); + EXPECT_EQ(own.caps, 0); +} +TEST(OtaCompat, ParsesCapsAndRejectsJunk) { + OtaCompat c; + ASSERT_TRUE(ota_compat_parse("12+eth+future", &c)); + EXPECT_EQ(c.gen, 12); + EXPECT_EQ(c.caps, OTA_CAP_ETH); + EXPECT_FALSE(ota_compat_parse("", &c)); + EXPECT_FALSE(ota_compat_parse("x1", &c)); + EXPECT_FALSE(ota_compat_parse("2eth", &c)); +} +TEST(OtaCompat, FindsCompleteTagOnly) { + const char img[] = "\xe9junk\0ota-compat:2+eth\0tail"; + const char* v = ota_compat_find((const uint8_t*)img, sizeof(img)); + ASSERT_NE(v, nullptr); + EXPECT_STREQ(v, "2+eth"); + const char cut[] = "junk ota-compat:2+e"; // value runs past the chunk end + EXPECT_EQ(ota_compat_find((const uint8_t*)cut, sizeof(cut) - 1), nullptr); + const char none[] = "ota-compat"; + EXPECT_EQ(ota_compat_find((const uint8_t*)none, sizeof(none)), nullptr); +} +TEST(OtaCompat, TargetMustKeepStateAndTransports) { + EXPECT_TRUE(ota_compat_ok({2, 0}, {2, 0})); + EXPECT_TRUE(ota_compat_ok({1, 0}, {2, OTA_CAP_ETH})); + EXPECT_FALSE(ota_compat_ok({2, 0}, {1, 0})); // cannot read /mqtt.json + EXPECT_FALSE(ota_compat_ok({2, OTA_CAP_ETH}, {2, 0})); // drops Ethernet + EXPECT_TRUE(ota_compat_ok({2, OTA_CAP_ETH}, {3, OTA_CAP_ETH})); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); From a0856bed728d769114c34f13de34f0454c5dacf1 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 15:47:01 -0700 Subject: [PATCH 13/14] feat(ota): dev stores /mqtt.json, so its state generation is 2 Builds that only read /mqtt_prefs (gen 1) are refused as channel-switch targets until they can read /mqtt.json. --- src/helpers/OtaChannel.h | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/helpers/OtaChannel.h b/src/helpers/OtaChannel.h index 974cc4ba9c..cd2524c363 100644 --- a/src/helpers/OtaChannel.h +++ b/src/helpers/OtaChannel.h @@ -82,8 +82,9 @@ static inline bool ota_parse_channel(const char* arg, uint8_t* out) { // or without a transport this node depends on. Bump OTA_STATE_GEN when a build starts // storing state that older builds cannot read. // 1: /prefs.json + /mqtt_prefs +// 2: /mqtt.json #ifndef OTA_STATE_GEN -#define OTA_STATE_GEN 1 +#define OTA_STATE_GEN 2 #endif #define OTA_CAP_ETH 0x01 // carries MQTT over Ethernet #if defined(NETWORK_PREFER_ETHERNET) From 8ec31dda71f867566a09c04704050626f243e629 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 3 Oct 2026 15:52:21 -0700 Subject: [PATCH 14/14] fix(ota): skip the bare compat search literal when scanning an image Every image also holds "ota-compat:" with no value (the search string); finding it first made every channel switch look untagged. --- src/helpers/OtaChannel.h | 4 +++- test/test_ota_channel/test_ota_channel.cpp | 4 ++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/helpers/OtaChannel.h b/src/helpers/OtaChannel.h index 974cc4ba9c..4c3b81b5f8 100644 --- a/src/helpers/OtaChannel.h +++ b/src/helpers/OtaChannel.h @@ -116,11 +116,13 @@ static inline bool ota_compat_parse(const char* s, OtaCompat* out) { } // Find the tag in an image chunk; returns the NUL-terminated value text, or nullptr when -// the chunk holds no complete tag. +// the chunk holds no complete tag. Skips the bare OTA_COMPAT_TAG search literal, which +// every image also contains. static inline const char* ota_compat_find(const uint8_t* buf, size_t len) { const size_t tag_len = sizeof(OTA_COMPAT_TAG) - 1; for (size_t i = 0; i + tag_len < len; i++) { if (memcmp(buf + i, OTA_COMPAT_TAG, tag_len) != 0) continue; + if (buf[i + tag_len] < '0' || buf[i + tag_len] > '9') continue; if (memchr(buf + i + tag_len, 0, len - i - tag_len)) return (const char*)buf + i + tag_len; } return nullptr; diff --git a/test/test_ota_channel/test_ota_channel.cpp b/test/test_ota_channel/test_ota_channel.cpp index 75ce673de1..ce6d025142 100644 --- a/test/test_ota_channel/test_ota_channel.cpp +++ b/test/test_ota_channel/test_ota_channel.cpp @@ -62,6 +62,10 @@ TEST(OtaCompat, FindsCompleteTagOnly) { EXPECT_STREQ(v, "2+eth"); const char cut[] = "junk ota-compat:2+e"; // value runs past the chunk end EXPECT_EQ(ota_compat_find((const uint8_t*)cut, sizeof(cut) - 1), nullptr); + const char literal_first[] = "ota-compat:\0code\0ota-compat:1\0"; // search literal precedes the tag + v = ota_compat_find((const uint8_t*)literal_first, sizeof(literal_first)); + ASSERT_NE(v, nullptr); + EXPECT_STREQ(v, "1"); const char none[] = "ota-compat"; EXPECT_EQ(ota_compat_find((const uint8_t*)none, sizeof(none)), nullptr); }