mirror of
https://github.com/agessaman/MeshCore.git
synced 2026-10-22 17:31:28 +00:00
Merge branch 'feat/ota-branch-switch' into merge/ota-branch-into-observer-firmware
This commit is contained in:
commit
cbb809b842
17 files changed
+391
-33
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
|
||||
|
||||
@@ -48,6 +48,16 @@ env:
|
||||
# repo). Baked into the slim OTA manifests' file URLs; must stay consistent
|
||||
# with config.json's staticPath.
|
||||
STATIC_PATH: https://observer-fw.gessaman.com
|
||||
# Where the firmware fetches <OTA_VARIANT>.json from — this URL *is* the release
|
||||
# channel. Stated explicitly (it equals build.sh's default) rather than left
|
||||
# unset, so "Verify production channel is baked in" below can assert against the
|
||||
# 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:
|
||||
|
||||
@@ -137,6 +147,11 @@ jobs:
|
||||
FIRMWARE_BUILD_NUMBER: ${{ needs.enumerate.outputs.build_number }}
|
||||
run: /usr/bin/env bash build.sh build-firmware ${{ matrix.shard.envs }}
|
||||
|
||||
- name: Verify production channel is baked in
|
||||
# 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
|
||||
with:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -88,6 +88,12 @@ This document provides an overview of CLI commands that can be sent to MeshCore
|
||||
- `start ota` — serves the ElegantOTA web upload page on the station IP if joined to a Wi-Fi network, otherwise raises the `MeshCore-OTA` Wi-Fi hotspot.
|
||||
- `start ota ap` — always raises the `MeshCore-OTA` Wi-Fi hotspot, even when joined to a network. Use this when the network applies client isolation and the station IP isn't reachable.
|
||||
|
||||
### Switch the OTA release channel (observer builds)
|
||||
|
||||
- `ota branch` — shows the selected channel, the channel this build was made for, and the manifest base `ota check`/`ota update` will use.
|
||||
- `ota branch prod` (alias `stable`) / `ota branch beta` (alias `dev`) — pull future `ota update`s from that channel. The selection is saved; run `ota update` to switch.
|
||||
- `ota branch default` — follow the channel this build was made for.
|
||||
|
||||
---
|
||||
|
||||
### Erase/Factory Reset
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include <algorithm>
|
||||
#include <stdlib.h> // for qsort()
|
||||
#include <helpers/RxReservePacketManager.h>
|
||||
#include <helpers/OtaChannel.h>
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
#include <helpers/MQTTConnectionPolicy.h> // kSyncedClockEpoch
|
||||
#endif
|
||||
@@ -1740,7 +1741,7 @@ void MyMesh::loop() {
|
||||
Serial.println("OTA: aborted, MQTT stop did not complete cleanly - resuming bridge");
|
||||
otaAlert("OTA aborted: MQTT stop unclean, bridge resumed");
|
||||
setBridgeState(true);
|
||||
} else if (!_cli.getBoard()->otaFromManifest(getFirmwareVer(), false, ota_reply)) {
|
||||
} else if (!_cli.getBoard()->otaFromManifest(ota_resolve_base(_prefs.ota_channel), getFirmwareVer(), false, ota_reply)) {
|
||||
Serial.print("OTA: aborted, resuming bridge - "); Serial.println(ota_reply);
|
||||
char ota_alert_msg[160];
|
||||
snprintf(ota_alert_msg, sizeof(ota_alert_msg), "OTA aborted: %s", ota_reply);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "MyMesh.h"
|
||||
#include <algorithm>
|
||||
#include <helpers/RxReservePacketManager.h>
|
||||
#include <helpers/OtaChannel.h>
|
||||
#if defined(WITH_MQTT_NEIGHBORS)
|
||||
#include <helpers/MQTTConnectionPolicy.h> // kSyncedClockEpoch
|
||||
#endif
|
||||
@@ -1616,7 +1617,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
@@ -177,7 +177,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>
|
||||
@@ -203,3 +203,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
|
||||
@@ -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
@@ -67,7 +67,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; }
|
||||
|
||||
// LoRa front-end-module LNA (RX gain) control. Only FEM-equipped boards override
|
||||
// these; others report they can't control it. Driven by NodePrefs.radio_fem_rxgain.
|
||||
|
||||
@@ -70,6 +70,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.
|
||||
@@ -182,6 +183,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);
|
||||
|
||||
@@ -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)
|
||||
@@ -1088,7 +1089,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
|
||||
@@ -1096,7 +1097,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
|
||||
@@ -1129,6 +1130,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
@@ -162,6 +162,7 @@ static void ota_partitionSignature(char* out, size_t out_sz) {
|
||||
// which stays valid because that function blocks until the worker signals done.
|
||||
struct OtaTaskArgs {
|
||||
ESP32Board* self;
|
||||
const char* manifest_base;
|
||||
const char* current_ver;
|
||||
bool dry_run;
|
||||
char* reply;
|
||||
@@ -171,18 +172,18 @@ struct OtaTaskArgs {
|
||||
|
||||
static void ota_task_entry(void* param) {
|
||||
OtaTaskArgs* a = static_cast<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
|
||||
// dedicated 24 KB-stack task and block here until it finishes. The big stack is
|
||||
// freed when the task exits; on a successful update the chip reboots inside it.
|
||||
OtaTaskArgs args = { this, current_ver, dry_run, reply, false, false };
|
||||
OtaTaskArgs args = { this, manifest_base, current_ver, dry_run, reply, false, false };
|
||||
TaskHandle_t handle = nullptr;
|
||||
BaseType_t ok = xTaskCreatePinnedToCore(ota_task_entry, "ota", 24576, &args, 5, &handle, 1);
|
||||
if (ok != pdPASS) {
|
||||
@@ -195,7 +196,7 @@ bool ESP32Board::otaFromManifest(const char* current_ver, bool dry_run, char rep
|
||||
return args.result;
|
||||
}
|
||||
|
||||
bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char reply[]) {
|
||||
bool ESP32Board::otaFromManifestImpl(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) {
|
||||
#if !defined(OTA_MANIFEST_BASE) || !defined(OTA_VARIANT)
|
||||
strcpy(reply, "ERR: OTA not configured (build via build.sh)");
|
||||
return false;
|
||||
@@ -230,10 +231,10 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char
|
||||
// and the handshake + the bridge both fail). This only reads version info; the
|
||||
// firmware download below (ota update) is always TLS-verified. Requires the
|
||||
// manifest host to serve /v over HTTP (no forced HTTPS redirect).
|
||||
if (strncmp(OTA_MANIFEST_BASE, "https://", 8) == 0) {
|
||||
snprintf(murl, sizeof(murl), "http://%s/%s.json", OTA_MANIFEST_BASE + 8, OTA_VARIANT);
|
||||
if (strncmp(manifest_base, "https://", 8) == 0) {
|
||||
snprintf(murl, sizeof(murl), "http://%s/%s.json", manifest_base + 8, OTA_VARIANT);
|
||||
} else {
|
||||
snprintf(murl, sizeof(murl), "%s/%s.json", OTA_MANIFEST_BASE, OTA_VARIANT);
|
||||
snprintf(murl, sizeof(murl), "%s/%s.json", manifest_base, OTA_VARIANT);
|
||||
}
|
||||
if (!http.begin(murl)) {
|
||||
strcpy(reply, "ERR: manifest connect failed");
|
||||
@@ -248,7 +249,7 @@ bool ESP32Board::otaFromManifestImpl(const char* current_ver, bool dry_run, char
|
||||
mclient.setCACertBundle(rootca_crt_bundle_start);
|
||||
#endif
|
||||
mclient.setTimeout(15000);
|
||||
snprintf(murl, sizeof(murl), "%s/%s.json", OTA_MANIFEST_BASE, OTA_VARIANT);
|
||||
snprintf(murl, sizeof(murl), "%s/%s.json", manifest_base, OTA_VARIANT);
|
||||
if (!http.begin(mclient, murl)) {
|
||||
strcpy(reply, "ERR: manifest connect failed");
|
||||
return false;
|
||||
@@ -333,10 +334,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) {
|
||||
@@ -361,6 +366,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) {
|
||||
@@ -417,7 +424,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;
|
||||
}
|
||||
|
||||
@@ -155,11 +155,11 @@ public:
|
||||
}
|
||||
|
||||
bool startOTAUpdate(const char* id, char reply[], bool force_ap = false) override;
|
||||
bool otaFromManifest(const char* current_ver, bool dry_run, char reply[]) override;
|
||||
bool otaFromManifest(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]) override;
|
||||
// Heavy body (TLS + JSON / HTTPUpdate). Runs in a dedicated large-stack task
|
||||
// spawned by otaFromManifest() — public only so that task entry point can call
|
||||
// it; not meant to be invoked directly.
|
||||
bool otaFromManifestImpl(const char* current_ver, bool dry_run, char reply[]);
|
||||
bool otaFromManifestImpl(const char* manifest_base, const char* current_ver, bool dry_run, char reply[]);
|
||||
|
||||
void setInhibitSleep(bool inhibit) {
|
||||
inhibit_sleep = inhibit;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -214,6 +214,35 @@ TEST(NodePrefs, FemGainSettingsRoundTrip) {
|
||||
EXPECT_EQ(1, loaded.radio_fem_txgain);
|
||||
}
|
||||
|
||||
TEST(NodePrefs, OtaChannelDefaultsToNative) {
|
||||
NodePrefs prefs;
|
||||
EXPECT_EQ(0, prefs.ota_channel); // 0 == native
|
||||
}
|
||||
|
||||
TEST(NodePrefs, OtaChannelRoundTrip) {
|
||||
NodePrefs saved;
|
||||
saved.ota_channel = 2; // dev
|
||||
|
||||
MockPrintStream output;
|
||||
ASSERT_TRUE(saved.saveSerial(output));
|
||||
std::string serialised(reinterpret_cast<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();
|
||||
}
|
||||
@@ -1495,6 +1495,8 @@ var CLI_VERBS=[
|
||||
["alert test","Send a test alert on the configured channel"],
|
||||
["ota check","Check for a newer build (does not flash)"],
|
||||
["ota update","Download and flash the newer build, then reboot"],
|
||||
["ota branch","Show the OTA release channel"],
|
||||
["ota branch ","Set the OTA release channel {prod|beta|default}"],
|
||||
// `start ota` is absent: it binds port 80, which this portal is already using.
|
||||
["start webconfig","Start this portal on the LAN"],
|
||||
["start webconfig ap","Start this portal on its own setup AP"],
|
||||
|
||||
Reference in new issue
Block a user