diff --git a/.github/workflows/build-observer-firmwares-beta.yml b/.github/workflows/build-observer-firmwares-beta.yml index a81e348442..bf219e9246 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 81312ea2ac..e8bea6f368 100644 --- a/.github/workflows/build-observer-firmwares.yml +++ b/.github/workflows/build-observer-firmwares.yml @@ -54,6 +54,10 @@ env: # 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: @@ -144,21 +148,9 @@ jobs: run: /usr/bin/env bash build.sh build-firmware ${{ matrix.shard.envs }} - name: Verify production channel is baked in - shell: bash - run: | - # Mirror of the beta workflow's guard, which production lacked. Fail fast - # rather than publish firmware that would OTA itself onto the wrong - # channel: the manifest base is a compile-time -D, so a build that lost - # it (or picked up beta's) is invisible until a node tries `ota check`. - 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: production manifest base missing from $BIN" >&2; exit 1 - fi - if strings "$BIN" | grep -qF 'https://observer.gessaman.com/beta/v'; then - echo "ERROR: beta manifest base present in a production 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 + # 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 diff --git a/build.sh b/build.sh index 5d6ae4439c..1d6783fb38 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 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 # (/.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/cli_commands.md b/docs/cli_commands.md index fc6568f7e0..d85590d119 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -89,6 +89,12 @@ This document provides an overview of CLI commands that can be sent to MeshCore - `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. - `stop ota` — stops an idle manual-OTA web server, releases port 80, and re-enables automatic network switching. It refuses while a firmware upload is in progress. +### 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/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index dfd407b66d..61cff7609e 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -8,6 +8,7 @@ #if defined(ESP_PLATFORM) #include #endif +#include #if defined(WITH_MQTT_NEIGHBORS) #include // kSyncedClockEpoch #endif @@ -1820,7 +1821,7 @@ void MyMesh::loop() { setBridgeState(true); otaAlert(bridge->isRunning() ? "OTA aborted: MQTT stop unclean, bridge resumed" : "OTA aborted: MQTT stop unproven, bridge resumes when it completes"); - } else if (!_cli.getBoard()->otaFromManifest(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 344d493288..8f2be8bf6a 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -126,6 +126,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; @@ -480,6 +481,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 1eba3892fd..5c590e2e90 100644 --- a/examples/simple_room_server/MyMesh.cpp +++ b/examples/simple_room_server/MyMesh.cpp @@ -7,6 +7,7 @@ #if defined(ESP_PLATFORM) #include #endif +#include #if defined(WITH_MQTT_NEIGHBORS) #include // kSyncedClockEpoch #endif @@ -1689,7 +1690,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(_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 1870df79bb..3c7bd12139 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -143,6 +143,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; @@ -476,6 +477,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; } diff --git a/platformio.ini b/platformio.ini index 85c532e552..5592cf4f50 100644 --- a/platformio.ini +++ b/platformio.ini @@ -182,7 +182,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> @@ -210,3 +210,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/scripts/verify_ota_channel.py b/scripts/verify_ota_channel.py new file mode 100644 index 0000000000..6b74030b45 --- /dev/null +++ b/scripts/verify_ota_channel.py @@ -0,0 +1,133 @@ +#!/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 (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` + 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") +COMPAT_RE = re.compile(rb"ota-compat:([0-9]+(?:\+[a-z]+)*)\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")) + # 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 + + +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\x00ota-compat:1\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), + ("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: + 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()) diff --git a/src/MeshCore.h b/src/MeshCore.h index 0d372ba43b..7da86a2548 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -75,7 +75,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 isPwrMgtInitialised() const { return false; } diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index 8437699402..b6de12f9bb 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -72,6 +72,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. @@ -221,6 +222,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 2696b8363d..4af86b3b12 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) @@ -1222,7 +1223,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 @@ -1230,7 +1231,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 @@ -1263,6 +1264,34 @@ 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 && (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, + // 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 (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 [prod|beta|default] (aliases: stable, dev)"); + } else { + _prefs->ota_channel = ch; + savePrefs(); + snprintf(reply, 160, "channel set to %s, base %s; run ota update to switch", + 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 19c3e5cc9f..ed0d9fc8c9 100644 --- a/src/helpers/ESP32Board.cpp +++ b/src/helpers/ESP32Board.cpp @@ -186,6 +186,9 @@ void ESP32Board::maintainOTAUpdate(uint32_t now_ms) { #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. @@ -231,6 +234,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 @@ -270,6 +298,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; @@ -279,12 +308,12 @@ 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 @@ -292,7 +321,7 @@ bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char rep // freed when the task exits; on a successful update the chip reboots inside it. NetworkLink& network = activeNetworkLink(); network.lockSwitching(); - 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) { @@ -307,7 +336,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; @@ -343,10 +372,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"); @@ -361,7 +390,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; @@ -446,10 +475,15 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char 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); + // 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 (same_base && have_builds) { + if (cross_channel) { + up_to_date = false; + } else if (same_base && have_builds) { behind = avail_build - own_build; up_to_date = (behind <= 0); } else if (diff_base) { @@ -474,6 +508,8 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char 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) { @@ -517,9 +553,35 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char 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; @@ -530,7 +592,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 02d0ac4bfd..5bb2b02d67 100644 --- a/src/helpers/ESP32Board.h +++ b/src/helpers/ESP32Board.h @@ -161,11 +161,11 @@ class ESP32Board : public mesh::MainBoard { bool stopOTAUpdate(char* reply) override; bool isOTAUpdateInProgress() const override; void maintainOTAUpdate(uint32_t now_ms) 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..ecfd9075fe --- /dev/null +++ b/src/helpers/OtaChannel.h @@ -0,0 +1,135 @@ +#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, // 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 (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: 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_tagged_native + sizeof(OTA_BASE_TAG_NATIVE) - 1; + } +#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 "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 +// (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, "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; +} + +// 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 +// 2: /mqtt.json +#ifndef OTA_STATE_GEN +#define OTA_STATE_GEN 2 +#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. 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; +} + +// 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_config_serializer/test_config_serializer.cpp b/test/test_config_serializer/test_config_serializer.cpp index c9b07d7abe..e987ebf0d6 100644 --- a/test/test_config_serializer/test_config_serializer.cpp +++ b/test/test_config_serializer/test_config_serializer.cpp @@ -467,6 +467,35 @@ TEST(DynamicConfigSerializer, LoadCustom_Basic) { EXPECT_TRUE(g2); EXPECT_STREQ("Scott", tmp); } +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 ─────────────────────────────────────────────────────── 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..ce6d025142 --- /dev/null +++ b/test/test_ota_channel/test_ota_channel.cpp @@ -0,0 +1,83 @@ +#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, 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("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("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), "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"); +} + +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 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); +} +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(); +} diff --git a/webui/index.html b/webui/index.html index daac4f52a8..85d3924398 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1508,6 +1508,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"],