diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 057fc0e2a..5267f5306 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 e4d71182d..5ef7287a6 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 d589854bc..2918d83d4 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. +// +// 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) +#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 +#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: -#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_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 699ffb70c..d2a4e482c 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 ab7b43d6a..30cf4ceaf 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1495,6 +1495,8 @@ var CLI_VERBS=[ ["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"],