Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
f04d163
feat(ota): channel-base resolver + arg parser with native tests
Elektr0Vodka Sep 14, 2026
3910489
feat(ota): persist ota_channel selector in NodePrefs
Elektr0Vodka Sep 14, 2026
942a19e
feat(ota): pass runtime manifest base through otaFromManifest
Elektr0Vodka Sep 14, 2026
ea541e1
feat(ota): add 'ota branch [stable|dev|default]' command
Elektr0Vodka Sep 14, 2026
763ba83
build(ota): bake stable + dev manifest bases into observer builds
Elektr0Vodka Sep 14, 2026
2633b42
fix(ota): compare by commit hash when targeting another channel
agessaman Oct 3, 2026
4afb3f7
build(ota): point the stable/dev channel bases at observer.gessaman.com
agessaman Oct 3, 2026
9c65e19
feat(ota): prod/beta channel names for ota branch, with stable/dev al…
agessaman Oct 3, 2026
65d80bc
ci(ota): verify each published binary's native channel by tag
agessaman Oct 3, 2026
ee88192
Merge branch 'feat/ota-branch-switch' into merge/ota-branch-into-obse…
agessaman Oct 3, 2026
a1943a8
fix(ota): always offer another channel's image, even from the same co…
agessaman Oct 3, 2026
f652e7d
fix(ota): flash the channel ota update checked, not a later ota branch
agessaman Oct 3, 2026
ed8af1b
fix(ota): refuse a channel switch to a build that cannot carry this node
agessaman Oct 3, 2026
baf01a5
Merge branch 'feat/ota-branch-switch' into merge/ota-branch-into-obse…
agessaman Oct 3, 2026
a0856be
feat(ota): dev stores /mqtt.json, so its state generation is 2
agessaman Oct 3, 2026
8ec31dd
fix(ota): skip the bare compat search literal when scanning an image
agessaman Oct 3, 2026
45d8f15
Merge branch 'feat/ota-branch-switch' into merge/ota-branch-into-obse…
agessaman Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 7 additions & 14 deletions .github/workflows/build-observer-firmwares-beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
22 changes: 7 additions & 15 deletions .github/workflows/build-observer-firmwares.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/cli_commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion examples/simple_repeater/MyMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions examples/simple_repeater/MyMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion examples/simple_room_server/MyMesh.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions examples/simple_room_server/MyMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down
18 changes: 17 additions & 1 deletion platformio.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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
133 changes: 133 additions & 0 deletions scripts/verify_ota_channel.py
Original file line number Diff line number Diff line change
@@ -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:<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")
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())
2 changes: 1 addition & 1 deletion src/MeshCore.h
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
2 changes: 2 additions & 0 deletions src/helpers/CommonCLI.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading