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

This commit is contained in:
agessaman
2026-10-03 15:46:51 -07:00
8 changed files with 162 additions and 8 deletions
+1 -1
View File
@@ -1741,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(ota_resolve_base(_prefs.ota_channel), 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);
+2
View File
@@ -123,6 +123,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;
@@ -441,6 +442,7 @@ public:
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
+1 -1
View File
@@ -1617,7 +1617,7 @@ void MyMesh::loop() {
}
char ota_reply[160];
if (may_flash && !_cli.getBoard()->otaFromManifest(ota_resolve_base(_prefs.ota_channel), 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;
}
+2
View File
@@ -136,6 +136,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;
@@ -433,6 +434,7 @@ public:
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;
}
+10 -2
View File
@@ -2,7 +2,8 @@
"""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:
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`
@@ -26,6 +27,7 @@ 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):
@@ -49,6 +51,10 @@ def check(data, expect, native_url, stable_url, 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
@@ -62,7 +68,7 @@ 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" % (
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 = [
@@ -74,6 +80,8 @@ def self_test():
("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:
+59 -4
View File
@@ -78,6 +78,9 @@ bool ESP32Board::startOTAUpdate(const char* id, char reply[], bool force_ap) {
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <esp_partition.h>
#include <esp_ota_ops.h>
#include <esp_image_format.h>
#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.
@@ -123,6 +126,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
@@ -334,13 +362,14 @@ bool ESP32Board::otaFromManifestImpl(const char* manifest_base, const char* curr
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.
// 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 (cross_channel) {
up_to_date = hash_equal;
up_to_date = false;
} else if (same_base && have_builds) {
behind = avail_build - own_build;
up_to_date = (behind <= 0);
@@ -411,9 +440,35 @@ bool ESP32Board::otaFromManifestImpl(const char* manifest_base, const char* curr
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;
+54
View File
@@ -76,3 +76,57 @@ static inline bool ota_parse_channel(const char* arg, uint8_t* out) {
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
#ifndef OTA_STATE_GEN
#define OTA_STATE_GEN 1
#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 "<gen>[+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.
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 (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;
}
@@ -40,6 +40,39 @@ 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 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();