Merge branch 'feat/ota-branch-switch' into merge/ota-branch-into-observer-firmware-dev

# Conflicts:
#	.github/workflows/build-observer-firmwares.yml
#	examples/simple_repeater/MyMesh.cpp
#	examples/simple_room_server/MyMesh.cpp
#	src/helpers/ESP32Board.cpp
#	src/helpers/ESP32Board.h
#	test/test_config_serializer/test_config_serializer.cpp
This commit is contained in:
agessaman committed 2026-10-03 15:24:33 -07:00
commit ee88192acb
17 files changed
+383 -48

No files matched your search

@@ -67,6 +67,10 @@ env:
# The channel itself. Firmware fetches <OTA_MANIFEST_BASE>/<OTA_VARIANT>.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
+7 -15
View File
@@ -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
+9 -1
View File
@@ -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
# (<OTA_MANIFEST_BASE>/<OTA_VARIANT>.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
+6
View File
@@ -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
+2 -1
View File
@@ -8,6 +8,7 @@
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#endif
#include <helpers/OtaChannel.h>
#if defined(WITH_MQTT_NEIGHBORS)
#include <helpers/MQTTConnectionPolicy.h> // 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(_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);
+2 -1
View File
@@ -7,6 +7,7 @@
#if defined(ESP_PLATFORM)
#include <WiFi.h>
#endif
#include <helpers/OtaChannel.h>
#if defined(WITH_MQTT_NEIGHBORS)
#include <helpers/MQTTConnectionPolicy.h> // 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(_prefs.ota_channel), getFirmwareVer(), false, ota_reply)) {
Serial.print("OTA: aborted - "); Serial.println(ota_reply);
may_flash = false;
}
+17 -1
View File
@@ -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
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Verify every built observer binary is baked for the expected OTA channel.
Observer builds carry three manifest bases (src/helpers/OtaChannel.h), each stored
behind a tag so it can be read back from the binary:
ota-base-native:<url> the channel the build OTAs from by default
ota-base-stable:<url> production, the target of `ota branch prod`
ota-base-dev:<url> beta, the target of `ota branch beta`
Every build contains both channel URLs, so checking for the presence or absence
of a URL can no longer tell production from beta. This reads the tags instead and
fails unless, in every .bin in out/ (the files that get published), each tag appears with exactly one value, the stable
and dev tags match the workflow's URLs, and the native tag is the URL of the
channel named by --expect.
python3 scripts/verify_ota_channel.py --expect prod
python3 scripts/verify_ota_channel.py --self-test
URLs default to OTA_MANIFEST_BASE_URL, OTA_MANIFEST_BASE_STABLE_URL and
OTA_MANIFEST_BASE_DEV_URL from the environment. Stdlib only.
"""
import argparse
import os
import re
import sys
TAG_RE = re.compile(rb"ota-base-(native|stable|dev):([\x21-\x7e]*)\x00")
def read_tags(data):
tags = {"native": set(), "stable": set(), "dev": set()}
for m in TAG_RE.finditer(data):
tags[m.group(1).decode()].add(m.group(2).decode())
return tags
def check(data, expect, native_url, stable_url, dev_url):
"""Return a list of problems with one binary's tags (empty when it passes)."""
problems = []
if stable_url == dev_url:
problems.append("stable and dev URLs are identical (%s)" % stable_url)
expected_native = stable_url if expect == "prod" else dev_url
if native_url != expected_native:
problems.append("OTA_MANIFEST_BASE_URL %s is not the %s URL %s"
% (native_url, expect, expected_native))
tags = read_tags(data)
for name, want in (("native", expected_native), ("stable", stable_url), ("dev", dev_url)):
found = sorted(tags[name])
if found != [want]:
problems.append("ota-base-%s: expected [%s], found %s" % (name, want, found or "nothing"))
return problems
def find_bins(root):
for name in os.listdir(root):
if name.endswith(".bin"):
yield os.path.join(root, name)
def self_test():
P, B = "https://h/v", "https://h/beta/v"
def blob(native, stable=P, dev=B):
return b"\x00junk\x00ota-base-native:%s\x00ota-base-stable:%s\x00ota-base-dev:%s\x00" % (
native.encode(), stable.encode(), dev.encode())
cases = [
("prod build passes", blob(P), "prod", P, True),
("beta build passes", blob(B), "beta", B, True),
("beta build fails prod check", blob(B), "prod", P, False),
("prod build fails beta check", blob(P), "beta", B, False),
("missing tags fail", b"\x00https://h/v\x00https://h/beta/v\x00", "prod", P, False),
("wrong dev URL fails", blob(P, dev="https://other/v"), "prod", P, False),
("conflicting native tags fail", blob(P) + blob(B), "prod", P, False),
("workflow URL off-channel fails", blob(P), "prod", B, False),
]
failed = 0
for name, data, expect, native, ok in cases:
got = not check(data, expect, native, P, B)
if got != ok:
failed += 1
print("FAIL: %s" % name)
print("self-test: %d/%d passed" % (len(cases) - failed, len(cases)))
return 1 if failed else 0
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--expect", choices=("prod", "beta"))
ap.add_argument("--bin-dir", default="out")
ap.add_argument("--native-url", default=os.environ.get("OTA_MANIFEST_BASE_URL"))
ap.add_argument("--stable-url", default=os.environ.get("OTA_MANIFEST_BASE_STABLE_URL"))
ap.add_argument("--dev-url", default=os.environ.get("OTA_MANIFEST_BASE_DEV_URL"))
ap.add_argument("--self-test", action="store_true")
args = ap.parse_args()
if args.self_test:
return self_test()
if not args.expect:
ap.error("--expect is required")
for opt in ("native_url", "stable_url", "dev_url"):
if not getattr(args, opt):
ap.error("--%s (or its environment variable) is required" % opt.replace("_", "-"))
bins = sorted(find_bins(args.bin_dir)) if os.path.isdir(args.bin_dir) else []
if not bins:
print("ERROR: no .bin files in %s" % args.bin_dir, file=sys.stderr)
return 1
bad = 0
for path in bins:
with open(path, "rb") as f:
problems = check(f.read(), args.expect, args.native_url, args.stable_url, args.dev_url)
for p in problems:
print("ERROR: %s: %s" % (path, p), file=sys.stderr)
bad += bool(problems)
if bad:
print("ERROR: %d of %d builds are not baked for %s" % (bad, len(bins), args.expect), file=sys.stderr)
return 1
print("OK: %d builds default to %s (%s) and carry both channels" % (len(bins), args.expect, args.native_url))
return 0
if __name__ == "__main__":
sys.exit(main())
+1 -1
View File
@@ -75,7 +75,7 @@ public:
// 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; }
+2
View File
@@ -72,6 +72,7 @@ public:
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 @@ protected:
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);
+31 -2
View File
@@ -12,6 +12,7 @@
#include <Arduino.h>
#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: <cur> -> <target> (N behind|new base)",
// where <target> is "vX.Y.Z.B (hash)". Pull <target> 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] == ' ')) {
+17 -10
View File
@@ -270,6 +270,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 +280,12 @@ struct OtaTaskArgs {
static void ota_task_entry(void* param) {
OtaTaskArgs* a = static_cast<OtaTaskArgs*>(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 +293,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 +308,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 +344,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 +362,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 +447,14 @@ 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);
// Build counters are per channel, so build numbers only compare within the native channel.
bool cross_channel = (strcmp(manifest_base, OTA_MANIFEST_BASE) != 0);
int behind = 0;
bool up_to_date;
if (same_base && have_builds) {
if (cross_channel) {
up_to_date = hash_equal;
} else if (same_base && have_builds) {
behind = avail_build - own_build;
up_to_date = (behind <= 0);
} else if (diff_base) {
@@ -474,6 +479,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) {
@@ -530,7 +537,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;
}
+2 -2
View File
@@ -161,11 +161,11 @@ public:
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;
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <stdint.h>
#include <string.h>
// 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-<channel>:" 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;
}
@@ -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<const char*>(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 ───────────────────────────────────────────────────────
@@ -0,0 +1,46 @@
#include <gtest/gtest.h>
#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");
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+2
View File
@@ -1508,6 +1508,8 @@ var CLI_VERBS=[
["alert test","Send a test alert on the configured channel"],
["ota check","Check for a newer build (does not flash)"],
["ota update","Download and flash the newer build, then reboot"],
["ota branch","Show the OTA release channel"],
["ota branch ","Set the OTA release channel {prod|beta|default}"],
// `start ota` is absent: it binds port 80, which this portal is already using.
["start webconfig","Start this portal on the LAN"],
["start webconfig ap","Start this portal on its own setup AP"],