diff --git a/build.sh b/build.sh index 1dc4aba355..3596cca6b0 100755 --- a/build.sh +++ b/build.sh @@ -220,10 +220,18 @@ build_firmware() { # the .ini declarations were removed rather than overridden. OTA_MANIFEST_BASE_URL="${OTA_MANIFEST_BASE_URL:-https://ota.dutchmeshcore.nl/mqtt/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 diff --git a/docs/superpowers/plans/2026-09-14-ota-device-channel-switch.md b/docs/superpowers/plans/2026-09-14-ota-device-channel-switch.md new file mode 100644 index 0000000000..07b3a686ec --- /dev/null +++ b/docs/superpowers/plans/2026-09-14-ota-device-channel-switch.md @@ -0,0 +1,559 @@ +# On-device `ota branch` channel switch — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let an operator switch which OTA release channel (stable/dev) an observer device pulls updates from at runtime, via a persisted `ota branch [stable|dev|default]` command, instead of the channel being fixed at build time. + +**Architecture:** A header-only resolver maps a 1-byte `NodePrefs::ota_channel` selector (0=native, 1=stable, 2=dev) to one of three compile-time base URLs baked by `build.sh` (`OTA_MANIFEST_BASE` = native, `OTA_MANIFEST_BASE_STABLE`, `OTA_MANIFEST_BASE_DEV`). `otaFromManifest` gains a `manifest_base` parameter; every OTA call site resolves the base from prefs, so both the `ota check` path and the deferred flash path honour the selected channel. Reporting (`ver`/MQTT/SNMP) is unchanged. + +**Tech Stack:** C++ (Arduino/ESP32), PlatformIO, ConfigSerializer JSON prefs (`/prefs.json`), GoogleTest native test env. + +**Base branch:** `origin/dmc-observer-dev` @ `7e3e8b76` (this worktree is already reset onto it). Design spec: `docs/superpowers/specs/2026-09-14-ota-device-channel-switch-design.md`. + +**External dependency (NOT in this plan):** `DutchMeshCore-OTA` `feat/dev-stable-ota-channels` (serves `/mqtt/dev/v` + `/mqtt/dev/fw`, tag `observer-mqtt-dev`) must be merged/deployed and the Cloudflare "Always Use HTTPS: Off" + bot-challenge Skip rules extended to `/mqtt/dev/*`, or `ota branch dev` + `ota check` returns an HTTP/connect error (stable unaffected). + +--- + +## File Structure + +- **Create** `src/helpers/OtaChannel.h` — channel enum + `ota_resolve_base()` + `ota_parse_channel()` + `ota_channel_name()`. Header-only, internal-linkage inline functions. Single responsibility: map selector ⇄ base URL/name. +- **Create** `test/test_ota_channel/test_ota_channel.cpp` — native GoogleTest for the resolver/parser. +- **Modify** `platformio.ini` — add `[env:native_ota_channel]`. +- **Modify** `src/helpers/CommonCLI.h` — add `uint8_t ota_channel = 0;` field + `def("ota_ch", ota_channel);`. +- **Modify** `src/MeshCore.h` — add `manifest_base` param to the `otaFromManifest` virtual. +- **Modify** `src/helpers/ESP32Board.h` / `.cpp` — thread `manifest_base` through both `otaFromManifest` defs, `otaFromManifestImpl`, and `OtaTaskArgs`. +- **Modify** `src/helpers/CommonCLI_Observer.cpp` — add the `ota branch` command; pass resolved base into the `ota check`/`ota update` calls. +- **Modify** `examples/simple_repeater/MyMesh.cpp` + `examples/simple_room_server/MyMesh.cpp` — pass resolved base at the deferred flash call. +- **Modify** `build.sh` — bake `OTA_MANIFEST_BASE_STABLE` + `OTA_MANIFEST_BASE_DEV`. + +Signature chosen: `otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[])`. + +--- + +## Task 1: OtaChannel.h resolver + parser (TDD, native test) + +**Files:** +- Create: `src/helpers/OtaChannel.h` +- Create: `test/test_ota_channel/test_ota_channel.cpp` +- Modify: `platformio.ini` (add `[env:native_ota_channel]`) + +- [ ] **Step 1: Write the failing test** + +Create `test/test_ota_channel/test_ota_channel.cpp`: + +```cpp +#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(); +} +``` + +Add `[env:native_ota_channel]` to `platformio.ini` (after `[env:native_region_gating]`, mirror its style): + +```ini +[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 +``` + +Also add `test_ota_channel` to the `test_ignore` line of `[env:native]` (line ~183), so the catch-all `native` env doesn't compile this test without the OTA macros defined: + +```ini +test_ignore = test_kiss_modem, test_region_gating, test_ota_channel +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pio test -e native_ota_channel` +Expected: FAIL/ERRORED — `helpers/OtaChannel.h` not found (file doesn't exist yet). +(Windows note: a failing/blocked native env reports as ERRORED in the pio summary; if the summary is unclear, run the built binary directly, e.g. `.pio/build/native_ota_channel/program.exe`, to see RED output.) + +- [ ] **Step 3: Write the header** + +Create `src/helpers/OtaChannel.h`: + +```cpp +#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; +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pio test -e native_ota_channel` +Expected: PASS — 6 tests (`OtaChannel.*`) green. + +- [ ] **Step 5: Commit** + +```bash +git add src/helpers/OtaChannel.h test/test_ota_channel/test_ota_channel.cpp platformio.ini +git commit -m "feat(ota): channel-base resolver + arg parser with native tests" +``` + +--- + +## Task 2: Add the `ota_channel` prefs field (TDD via config-serializer round-trip) + +**Files:** +- Modify: `test/test_config_serializer/test_config_serializer.cpp` (add round-trip test) +- Modify: `src/helpers/CommonCLI.h` (NodePrefs field + `structure()`) + +Spec §5.3 (default, missing-key, round-trip) is verified here — `test_config_serializer` already round-trips real `NodePrefs` natively (e.g. `TEST(NodePrefs, FemGainSettingsRoundTrip)`) and runs under `[env:native]`, needing no OTA macros. + +- [ ] **Step 1: Write the failing test** + +In `test/test_config_serializer/test_config_serializer.cpp`, add after the `FemGainSettingsRoundTrip` test: + +```cpp +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); +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `pio test -e native -f test_config_serializer` +Expected: FAIL — compile error, `NodePrefs` has no member `ota_channel`. + +- [ ] **Step 3: Add the field** + +In `src/helpers/CommonCLI.h`, in `class NodePrefs`, add the field immediately after `uint8_t dutycycle_auto = 1;` (line 78): + +```cpp + uint8_t ota_channel = 0; // OTA release channel selector: 0=native, 1=stable, 2=dev +``` + +- [ ] **Step 4: Serialize it** + +In the top-level `NodePrefs::structure()` (the block ending at line 240 with `def("custom", custom);`), add after `def("disc_mod", discovery_mod_timestamp);` (line 233): + +```cpp + def("ota_ch", ota_channel); // OTA release channel: 0=native, 1=stable, 2=dev +``` + +Named JSON key ⇒ an existing `/prefs.json` without `ota_ch` loads the default `0` (native). No memset, no byte-offset append. + +- [ ] **Step 5: Run to verify it passes** + +Run: `pio test -e native -f test_config_serializer` +Expected: PASS — including the three new `NodePrefs.OtaChannel*` tests. + +- [ ] **Step 6: Commit** + +```bash +git add test/test_config_serializer/test_config_serializer.cpp src/helpers/CommonCLI.h +git commit -m "feat(ota): persist ota_channel selector in NodePrefs" +``` + +--- + +## Task 3: Thread `manifest_base` through the OTA fetch path + +Signature change + all call sites in one commit so the tree stays compilable. + +**Files:** +- Modify: `src/MeshCore.h:75` +- Modify: `src/helpers/ESP32Board.h:161,165` +- Modify: `src/helpers/ESP32Board.cpp` (OtaTaskArgs ~163, ota_task_entry ~174, otaFromManifest ~179, otaFromManifestImpl ~198 incl. lines 233/234/236/251, stub ~420) +- Modify: `src/helpers/CommonCLI_Observer.cpp` (`#include`, calls at ~1271 and ~1279) +- Modify: `examples/simple_repeater/MyMesh.cpp:2008` +- Modify: `examples/simple_room_server/MyMesh.cpp:1623` + +- [ ] **Step 1: Base virtual (`src/MeshCore.h:75`)** + +Replace: + +```cpp + virtual bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) { return false; } +``` + +with: + +```cpp + virtual bool otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) { return false; } +``` + +- [ ] **Step 2: ESP32Board declarations (`src/helpers/ESP32Board.h`)** + +Line 161 → `bool otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) override;` +Line 165 → `bool otaFromManifestImpl(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]);` + +- [ ] **Step 3: ESP32Board.cpp — task args + entry + both defs + impl** + +`OtaTaskArgs` (add a field): + +```cpp +struct OtaTaskArgs { + ESP32Board* self; + const char* manifest_base; + const char* current_ver; + bool dry_run; + char* reply; + volatile bool result; + volatile bool done; +}; +``` + +`ota_task_entry` (line 174): + +```cpp + a->result = a->self->otaFromManifestImpl(a->manifest_base, a->current_ver, a->dry_run, a->reply); +``` + +`otaFromManifest` (line 179) — new signature + args init: + +```cpp +bool ESP32Board::otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) { + // ... unchanged comment block ... + OtaTaskArgs args = { this, manifest_base, current_ver, dry_run, reply, false, false }; +``` + +`otaFromManifestImpl` (line 198) — new signature: + +```cpp +bool ESP32Board::otaFromManifestImpl(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) { +``` + +Inside the impl, replace every `OTA_MANIFEST_BASE` **usage** (NOT the `#if !defined(OTA_MANIFEST_BASE)` guard on line 199, which stays) with `manifest_base`: +- line 233: `if (strncmp(manifest_base, "https://", 8) == 0) {` +- line 234: `snprintf(murl, sizeof(murl), "http://%s/%s.json", manifest_base + 8, OTA_VARIANT);` +- line 236: `snprintf(murl, sizeof(murl), "%s/%s.json", manifest_base, OTA_VARIANT);` +- line 251: `snprintf(murl, sizeof(murl), "%s/%s.json", manifest_base, OTA_VARIANT);` + +`#else` stub (line 420): + +```cpp +bool ESP32Board::otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) { + strcpy(reply, "ERR: not supported"); + return false; +} +``` + +- [ ] **Step 4: CommonCLI_Observer.cpp call sites** + +Add near the top with the other includes: + +```cpp +#include "OtaChannel.h" +``` + +Update the two calls in the `ota check`/`ota update` block (currently `_board->otaFromManifest(_callbacks->getFirmwareVer(), true, reply)` at ~1271 and inside the `if (...)` at ~1279) to pass the resolved base first: + +```cpp + _board->otaFromManifest(ota_resolve_base(_prefs->ota_channel), _callbacks->getFirmwareVer(), true, reply); +``` + +```cpp + if (_board->otaFromManifest(ota_resolve_base(_prefs->ota_channel), _callbacks->getFirmwareVer(), true, reply)) { +``` + +- [ ] **Step 5: MyMesh deferred flash call sites** + +In **`examples/simple_repeater/MyMesh.cpp`** add near its includes: + +```cpp +#include +``` + +Line 2008, replace: + +```cpp + } else if (!_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) { +``` + +with: + +```cpp + } else if (!_cli.getBoard()->otaFromManifest(ota_resolve_base(_prefs.ota_channel), getFirmwareVer(), false, ota_reply)) { +``` + +In **`examples/simple_room_server/MyMesh.cpp`** add near its includes: + +```cpp +#include +``` + +Line 1623, replace: + +```cpp + if (may_flash && !_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) { +``` + +with: + +```cpp + if (may_flash && !_cli.getBoard()->otaFromManifest(ota_resolve_base(_prefs.ota_channel), getFirmwareVer(), false, ota_reply)) { +``` + +(`_prefs` is a `NodePrefs` member of MyMesh — accessed as `_prefs.` here, vs `_prefs->` in CommonCLI where it is a pointer.) + +- [ ] **Step 6: Verify it compiles** + +Run: `export FIRMWARE_VERSION=v0.0.0-test && sh build.sh build-firmware Heltec_v3_repeater_observer_mqtt` +Expected: build SUCCEEDS (links). This exercises the new signature across MeshCore.h, ESP32Board, CommonCLI_Observer, and repeater MyMesh. (Room-server path is covered in Task 6.) + +- [ ] **Step 7: Commit** + +```bash +git add src/MeshCore.h src/helpers/ESP32Board.h src/helpers/ESP32Board.cpp src/helpers/CommonCLI_Observer.cpp examples/simple_repeater/MyMesh.cpp examples/simple_room_server/MyMesh.cpp +git commit -m "feat(ota): pass runtime manifest base through otaFromManifest" +``` + +--- + +## Task 4: The `ota branch` command + +**Files:** +- Modify: `src/helpers/CommonCLI_Observer.cpp` (new else-if beside the `ota check`/`ota update` block; `#include "OtaChannel.h"` already added in Task 3) + +- [ ] **Step 1: Add the command handler** + +Immediately AFTER the closing of the `ota check`/`ota update` else-if block (the `return true;` that ends it, ~line 1315) and BEFORE the next `} else if (memcmp(command, "start webconfig"...`, insert: + +```cpp + } 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; +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `export FIRMWARE_VERSION=v0.0.0-test && sh build.sh build-firmware Heltec_v3_repeater_observer_mqtt` +Expected: build SUCCEEDS. + +- [ ] **Step 3: Commit** + +```bash +git add src/helpers/CommonCLI_Observer.cpp +git commit -m "feat(ota): add 'ota branch [stable|dev|default]' command" +``` + +--- + +## Task 5: Bake both channel bases in build.sh + +**Files:** +- Modify: `build.sh` (OTA flags export at line 226; env-default section ~221) + +- [ ] **Step 1: Add the two base env vars** + +In `build.sh`, right after line 221 (`OTA_MANIFEST_BASE_URL="${OTA_MANIFEST_BASE_URL:-https://ota.dutchmeshcore.nl/mqtt/v}"`), add: + +```bash + # 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}" +``` + +- [ ] **Step 2: Inject them as -D flags** + +Extend the `export PLATFORMIO_BUILD_FLAGS=...` line (226) by appending two flags before the closing quote: + +```bash + 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}\"'" +``` + +- [ ] **Step 3: Verify the flags reach the build** + +Run: `export FIRMWARE_VERSION=v0.0.0-test && sh build.sh build-firmware Heltec_v3_repeater_observer_mqtt` +Expected: build SUCCEEDS. Optionally confirm the macros are present: +`grep -R "OTA_MANIFEST_BASE_DEV" .pio/build/Heltec_v3_repeater_observer_mqtt/ 2>/dev/null | head` — or inspect the compile command via `pio run -e Heltec_v3_repeater_observer_mqtt -v 2>&1 | grep OTA_MANIFEST_BASE_DEV | head -1`. + +- [ ] **Step 4: Commit** + +```bash +git add build.sh +git commit -m "build(ota): bake stable + dev manifest bases into observer builds" +``` + +--- + +## Task 6: End-to-end verification + +**Files:** none (verification only) + +- [ ] **Step 1: Native tests green** + +Run: `pio test -e native_ota_channel` +Expected: PASS (6 `OtaChannel.*` tests). + +- [ ] **Step 2: Repeater observer builds** + +Run: `export FIRMWARE_VERSION=v0.0.0-test && sh build.sh build-firmware Heltec_v3_repeater_observer_mqtt` +Expected: SUCCESS; a `.bin` appears under `out/`. + +- [ ] **Step 3: Room-server observer builds** (covers the room-server MyMesh call site) + +Run: `export FIRMWARE_VERSION=v0.0.0-test && sh build.sh build-firmware Heltec_v3_room_server_observer_mqtt` +Expected: SUCCESS. +(If `Heltec_v3_room_server_observer_mqtt` is not a valid env, pick any `*_room_server_observer_mqtt` env from `sh build.sh list`.) + +- [ ] **Step 4: Sanity of the guard on a non-OTA build (optional)** + +Run: `export FIRMWARE_VERSION=v0.0.0-test && sh build.sh build-firmware Heltec_v3_repeater` +Expected: SUCCESS — the `ota branch` handler compiles down to the `ERR: online OTA not supported on this build` arm (no `OTA_MANIFEST_BASE`), and `NodePrefs::ota_channel` is a harmless unused byte. + +- [ ] **Step 5: Update the spec's dependency checklist status if anything changed; no commit needed unless edited.** + +--- + +## Post-implementation notes + +- **On-device manual check (when hardware is available):** `ota branch` → reports `native`; `ota branch dev` → `channel set to dev, base https://ota.dutchmeshcore.nl/mqtt/dev/v`; reboot; `ota branch` → still `dev (override)` (persistence); `ota branch default` → back to `native`. `ota branch dev; ota check` returns an HTTP error until the server dev channel is deployed — expected. +- **Do not push and do not add AI co-author trailers** (standing user preference). Leave integration (PR/merge) to the user. diff --git a/docs/superpowers/specs/2026-09-14-ota-device-channel-switch-design.md b/docs/superpowers/specs/2026-09-14-ota-device-channel-switch-design.md new file mode 100644 index 0000000000..94381d048b --- /dev/null +++ b/docs/superpowers/specs/2026-09-14-ota-device-channel-switch-design.md @@ -0,0 +1,166 @@ +# Design: on-device `ota branch` channel switch + +**Date:** 2026-09-14 +**Repo:** `Dutch-MeshCore/MeshCore` (firmware, device side only) +**Base branch:** `origin/dmc-observer-dev` @ `7e3e8b76` (canonical; local `dmc-observer-dev` +@ `80f426ee` was stale and must not be used). +**Feature:** let an operator switch which OTA release channel (stable vs dev) a device +pulls updates from, at runtime, with a persisted `ota branch` command — instead of the +channel being fixed at build time. + +--- + +## 1. Background / current behaviour + +Observer builds (`*_observer_mqtt`, repeater + room server) already have a pull-OTA: + +- `ota check` / `ota update` in `src/helpers/CommonCLI_Observer.cpp`, guarded by + `#if defined(WITH_MQTT_BRIDGE) && defined(OTA_MANIFEST_BASE)`. +- The work is done by `Board::otaFromManifest(current_ver, dry_run, reply)` + (base virtual `src/MeshCore.h`, real impl `ESP32Board::otaFromManifestImpl`). +- The manifest URL is `/.json`. Both macros are baked + in by `build.sh` (`-DOTA_MANIFEST_BASE=…`, `-DOTA_VARIANT=`); the base defaults + to `https://ota.dutchmeshcore.nl/mqtt/v` and is overridable via `OTA_MANIFEST_BASE_URL`. +- build.sh comment, verbatim: *"this URL IS the channel: a device only ever sees updates + published under the base it was built with."* + +So today the channel is 100% compile-time. There is **no runtime override** and **no +`ota branch` command**. A stable-flashed device can never look at the dev channel (or +vice versa) without re-flashing. + +## 2. Goal + +Add a persisted, runtime channel selector so an operator can do: + +``` +ota branch -> report current channel + resolved base URL +ota branch stable -> pull from the stable channel +ota branch dev -> pull from the dev channel +ota branch default -> clear override, follow the build's native channel +``` + +The selection changes only **where the device looks for updates**. It does not change the +running image's reported version (`ver` / MQTT `firmware_version` / SNMP stay truthful). + +## 3. Non-goals + +- Server-side channel plumbing. Already implemented in the **separate** `DutchMeshCore-OTA` + repo on branch `feat/dev-stable-ota-channels` (also on `origin`): nginx serves + `/mqtt/dev/v/` + `/mqtt/dev/fw/`, poller segregates by GitHub release tag + `observer-mqtt-dev`. **External dependency (out of scope here):** that branch must be + merged → deployed, and Cloudflare must get the same "Always Use HTTPS: Off" + + bot-challenge Skip rules for `ota.dutchmeshcore.nl/mqtt/dev/*` that the stable + `/mqtt/v/*` and `/mqtt/fw/*` paths already have. Until then, `ota branch dev` + + `ota check` returns an HTTP/connect error (the honest failure), while stable is + unaffected. +- Arbitrary base-URL override (`ota base `). Rejected as YAGNI. +- Per-build one-shot revert after update. Rejected: scope is "same as `ota update`". + +## 4. Design + +### 4.1 CLI (`CommonCLI_Observer.cpp`, beside the `ota check`/`ota update` block) + +Parse `ota branch` and, optionally, a trailing `stable` | `dev` | `default`. + +- No arg → report, e.g. + `> channel: dev (override), base https://ota.dutchmeshcore.nl/mqtt/dev/v` + or `> channel: stable (native), base https://ota.dutchmeshcore.nl/mqtt/v`. +- `stable` / `dev` / `default` → set the prefs field, persist, reply `> channel set to `. +- Anything else → `ERR: usage ota branch [stable|dev|default]`. +- Same guard as the OTA block; on non-OTA builds → `ERR: online OTA not supported on this build`. +- **Scope:** identical to `ota update` — reachable from any admin path (serial / mesh + admin / MQTT). Reads (no-arg) allowed everywhere. + +### 4.2 Storage + +Add `uint8_t ota_channel` to the observer repeater/room-server `NodePrefs`: + +| value | meaning | +|-------|---------| +| `0` | native (default — follow the build's own channel) | +| `1` | stable | +| `2` | dev | + +Persisted via `ConfigSerializer::def("ota_channel", ota_channel)` in that struct's +serialize list. Because `/prefs.json` uses **named JSON keys**, an existing file without +the key loads the C++ default (`0`/native) — fully back-compatible. Do **not** memset the +struct and do **not** use byte-offset append (ConfigSerializer gotchas for this fork). + +### 4.3 Build wiring (`build.sh`, observer path) + +Today build.sh bakes only `OTA_MANIFEST_BASE` (= the build's own channel). Add two more +`-D` flags to **every** observer build so a device knows both channels' bases regardless +of which one it was built for: + +``` +-DOTA_MANIFEST_BASE_STABLE='"https://ota.dutchmeshcore.nl/mqtt/v"' +-DOTA_MANIFEST_BASE_DEV='"https://ota.dutchmeshcore.nl/mqtt/dev/v"' +``` + +`OTA_MANIFEST_BASE` stays = the native channel (unchanged), so `native` resolves to the +right place on both stable and dev builds. The dev URL matches `DutchMeshCore-OTA`'s +`feat/dev-stable-ota-channels` exactly. + +### 4.4 Resolution helper + +A single function maps the stored enum + the compile-time macros → an effective base URL: + +``` +0/native -> OTA_MANIFEST_BASE +1/stable -> OTA_MANIFEST_BASE_STABLE (fallback OTA_MANIFEST_BASE if undefined) +2/dev -> OTA_MANIFEST_BASE_DEV (fallback OTA_MANIFEST_BASE if undefined) +``` + +Fallback covers legacy/local builds that only define `OTA_MANIFEST_BASE`. The macros are +global `-D`, so the helper can live wherever prefs + macros meet (the CLI/observer TU). + +### 4.5 Threading the base into the OTA path + +`otaFromManifest` gains a `const char* manifest_base` parameter: + +- base virtual `src/MeshCore.h:75` +- `ESP32Board::otaFromManifest` + `otaFromManifestImpl` (`ESP32Board.h`/`.cpp`); the impl + uses `manifest_base` in place of the literal `OTA_MANIFEST_BASE` when building `murl` + (both the plain-HTTP check path and the HTTPS update path). The + `#if !defined(OTA_MANIFEST_BASE)…` "not configured" guard stays. +- Call sites resolve the base from prefs and pass it: + - `CommonCLI_Observer.cpp` `ota check` and the `ota update` pre-check. + - `examples/simple_repeater/MyMesh.cpp` and `examples/simple_room_server/MyMesh.cpp` + deferred flash path (`beginDeferredOtaUpdate` → later `otaFromManifest(…, false, …)`). + These **re-resolve from prefs at flash time**, so the channel selected at check time + is the one that flashes (no need to stash the URL across the defer). + +### 4.6 Reporting + +Unchanged. `ota check`'s existing "new base" wording already fires when the target +differs from the running version, so switching channel then `ota check` naturally shows +the new channel's candidate build. + +## 5. Testing (native test env) + +Pure-logic unit tests (board OTA mocked; no network): + +1. **Arg parsing:** `stable`/`dev`/`default` set the expected enum; unknown → usage error; + no-arg → report string contains the resolved base. +2. **Resolution:** native/stable/dev → correct macro URL; undefined dev/stable macro → + fallback to `OTA_MANIFEST_BASE`. +3. **Prefs round-trip:** default is `0`/native; a `/prefs.json` without `ota_channel` + loads as native; set→serialize→load preserves the value. + +Windows PlatformIO note: a failing gtest env shows as ERRORED — run the built +`program.exe` directly to see RED output. + +## 6. External dependency checklist (tracked, not built here) + +- [ ] Merge `DutchMeshCore-OTA` `feat/dev-stable-ota-channels` → master, deploy. +- [ ] Cloudflare: replicate the `/mqtt/v/*` + `/mqtt/fw/*` "Always Use HTTPS: Off" and + bot-challenge Skip rules for `/mqtt/dev/v/*` + `/mqtt/dev/fw/*`. +- [ ] Confirm the dev-channel firmware workflow publishes to the `observer-mqtt-dev` tag + (matches the poller's `DEV_RELEASE_TAG`). + +## 7. Open item for implementation + +Feature branch base: re-create `claude/ota-branch-device-switch-1103b6` off +`origin/dmc-observer-dev` @ `7e3e8b76`. The branch currently carries 4 unrelated +main-based commits (incl. "ci: surface … build workflows on main") — decide whether to +preserve those elsewhere or start the feature branch fresh before writing code. diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index a834f4f211..29ad791de3 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 @@ -2005,7 +2006,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 e6bed7d60c..ad0bab55e9 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 @@ -1620,7 +1621,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/platformio.ini b/platformio.ini index 4a8b5ce957..f6a9e5f2dd 100644 --- a/platformio.ini +++ b/platformio.ini @@ -180,7 +180,7 @@ build_flags = -std=c++17 -I src -I test/mocks test_build_src = yes -test_ignore = test_kiss_modem, test_region_gating +test_ignore = test_kiss_modem, test_region_gating, test_ota_channel build_src_filter = -<*> +<../src/Utils.cpp> @@ -227,3 +227,19 @@ build_src_filter = +<../src/helpers/RegionMap.cpp> lib_deps = google/googletest @ 1.17.0 + +[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/MeshCore.h b/src/MeshCore.h index adfc1c9eb3..dc6bb479c4 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -72,7 +72,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; } // Power management interface (boards with power management override these) virtual bool isExternalPowered() { return false; } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 3aa1e755a5..52bdf14a8e 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -76,6 +76,7 @@ class NodePrefs : public ConfigSerializer { uint8_t dc_gate_threshold = 70; // TX duty-cycle % above which outer regions start being gated uint8_t dc_gate_hysteresis = 10;// recover margin in %: re-enable regions below (threshold - hysteresis) uint8_t dutycycle_auto = 1; // derive the duty cycle limit from freq (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. @@ -231,6 +232,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/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index adc0d7b54e..8e3358de69 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) @@ -1268,7 +1269,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 @@ -1276,7 +1277,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 @@ -1309,6 +1310,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] == ' ')) { 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 08e264bc01..bc655e2eb6 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -158,11 +158,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; 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_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp index c9b07d7abe..4ef0c72ab0 100644 --- a/test/test_config_serializer/test_config_serializer.cpp +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -273,6 +273,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); +} + TEST(NodePrefs, TxPowerRemainsSignedThroughRadioPrefs) { NodePrefs prefs; prefs.tx_power_dbm = -9; 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(); +}