Add full Companion OTA source builds

This commit is contained in:
mikecarper committed 2026-08-10 12:39:07 -07:00
1 parent 1bceda54a6
commit 88ba5b26cb
19 files changed
+1212 -86

No files matched your search

+12 -2
View File
@@ -3,7 +3,9 @@
#if defined(ENABLE_OTA)
#include "helpers/ota/OtaContext.h" // OTA mesh-integration is centralized here so every role gets it
#include "helpers/ota/OtaProtocol.h" // decode_adv -> the `ota neighbors` discovery table
#if !defined(OTA_SEEDER_ONLY)
#include "helpers/ota/OtaSelf.h" // ota_self_firmware -> auto-advertise our own image
#endif
#if defined(ESP32_PLATFORM) && (defined(WIFI_OTA_SEEDER) || defined(WIFI_SSID))
#include "helpers/esp32/WiFiOtaSeeder.h"
#endif
@@ -280,6 +282,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
}
}
#if defined(ENABLE_OTA)
#if !defined(OTA_SEEDER_ONLY)
// Deferred apply-reboot: a verified `ota applydelta` approves the update but does NOT reboot inline,
// so its "verified; applying" reply can be delivered first (over LoRa that reply is the operator's
// only confirmation the apply started). Reboot once that reply has actually been transmitted (the
@@ -297,6 +300,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
}
}
}
#endif
const bool ota_active = isTempRadioActive();
if (!ota_active) {
_ota_temp_was_active = false;
@@ -312,6 +316,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
// one-shot on first tick: resume an interrupted fetch left staged in flash before a reboot. Only adopt
// a PARTIAL container (continue fetching the holes); a COMPLETE one is left for manual/auto-install,
// not re-adopted at boot. requestMissing() (inside resumeStaged) drives the rest via REQ/DATA.
#if !defined(OTA_SEEDER_ONLY)
if (!_ota_resumed) {
_ota_resumed = true;
ota::OtaContext& oc = ota::ota_ctx();
@@ -320,6 +325,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
oc.manager.reset_session(); // don't auto-adopt a complete staged container on boot
}
}
#endif
ota::ota_ctx().manager.set_clock(_ms->getMillis()); // for discovery jitter/ages + the pending-query timer
ota::ota_ctx().manager.loop(); // re-request still-missing OTA blocks + fire scheduled queries
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
@@ -332,9 +338,11 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
bool in_burst = _ota_announce_count < OTA_ANNOUNCE_BURST;
uint32_t mins = oc.manager.advert_mins(); // periodic cadence in minutes; 0 = disabled (boot burst only)
if (in_burst || mins != 0) {
// To be discoverable as a source of our OWN firmware, set up flash-backed self-serve once; then the
// beacon (announce) advertises our served set and peers can QUERY + fetch it.
// Install-capable nodes add their running firmware to the served set.
// Seeder-only nodes advertise only host-provided folder entries.
#if !defined(OTA_SEEDER_ONLY)
if (!oc.serving) oc.serving = ota::ota_serve_self(oc, 0);
#endif
oc.manager.announce();
if (_ota_announce_count < 250) _ota_announce_count++;
}
@@ -345,6 +353,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
: OTA_ANNOUNCE_DISABLED_POLL_MS;
_next_ota_announce = futureMillis(gap);
}
#if !defined(OTA_SEEDER_ONLY)
{ // auto-install (once per COMPLETE fetch): only signed images, and apply_fetched enforces trust
ota::OtaContext& oc = ota::ota_ctx();
if (oc.manager.fetchState() != ota::OtaManager::COMPLETE) {
@@ -358,6 +367,7 @@ void __attribute__((noinline)) Mesh::serviceLoopMaintenance() {
}
}
#endif
#endif
}
bool Mesh::allowPacketTransmit(const Packet* packet) const {
+26 -9
View File
@@ -8,24 +8,28 @@
void ArduinoSerialInterface::resetReceiveState() {
_state = RECV_STATE_IDLE;
_controlSequencePos = 0;
_secondaryControlSequencePos = 0;
_frame_len = 0;
rx_len = 0;
}
bool ArduinoSerialInterface::checkControlSequence(uint8_t c) {
if (_controlSequence == nullptr || _controlSequence[0] == 0) return false;
bool ArduinoSerialInterface::checkControlSequence(uint8_t c,
const char* sequence,
size_t& position,
bool& received) {
if (sequence == nullptr || sequence[0] == 0) return false;
if (c == (uint8_t)_controlSequence[_controlSequencePos]) {
_controlSequencePos++;
if (_controlSequence[_controlSequencePos] == 0) {
_controlSequencePos = 0;
_controlSequenceReceived = true;
if (c == (uint8_t)sequence[position]) {
position++;
if (sequence[position] == 0) {
position = 0;
received = true;
return true;
}
} else {
// Preserve a possible new match when this byte is also the first byte of
// the sequence (notably useful for sequences beginning with "+++").
_controlSequencePos = c == (uint8_t)_controlSequence[0] ? 1 : 0;
position = c == (uint8_t)sequence[0] ? 1 : 0;
}
return false;
}
@@ -33,6 +37,7 @@ bool ArduinoSerialInterface::checkControlSequence(uint8_t c) {
void ArduinoSerialInterface::setPassthroughMode(bool enabled) {
_passthroughMode = enabled;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
resetReceiveState();
}
@@ -42,14 +47,22 @@ bool ArduinoSerialInterface::takeControlSequence() {
return received;
}
bool ArduinoSerialInterface::takeSecondaryControlSequence() {
bool received = _secondaryControlSequenceReceived;
_secondaryControlSequenceReceived = false;
return received;
}
void ArduinoSerialInterface::enable() {
_isEnabled = true;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
resetReceiveState();
}
void ArduinoSerialInterface::disable() {
_isEnabled = false;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
resetReceiveState();
}
@@ -90,7 +103,11 @@ size_t ArduinoSerialInterface::checkRecvFrame(uint8_t dest[]) {
switch (_state) {
case RECV_STATE_IDLE:
if (checkControlSequence((uint8_t)c)) {
if (checkControlSequence((uint8_t)c, _controlSequence,
_controlSequencePos, _controlSequenceReceived)
|| checkControlSequence((uint8_t)c, _secondaryControlSequence,
_secondaryControlSequencePos,
_secondaryControlSequenceReceived)) {
// Leave any following bytes buffered for the passthrough consumer.
return 0;
}
+13 -4
View File
@@ -7,29 +7,37 @@ class ArduinoSerialInterface : public BaseSerialInterface {
bool _isEnabled;
bool _passthroughMode;
bool _controlSequenceReceived;
bool _secondaryControlSequenceReceived;
uint8_t _state;
size_t _controlSequencePos;
size_t _secondaryControlSequencePos;
uint16_t _frame_len;
uint16_t rx_len;
Stream* _serial;
const char* _controlSequence;
const char* _secondaryControlSequence;
uint8_t rx_buf[MAX_FRAME_SIZE];
bool checkControlSequence(uint8_t c);
bool checkControlSequence(uint8_t c, const char* sequence,
size_t& position, bool& received);
void resetReceiveState();
public:
ArduinoSerialInterface()
: _isEnabled(false), _passthroughMode(false),
_controlSequenceReceived(false), _state(0), _controlSequencePos(0),
_controlSequenceReceived(false), _secondaryControlSequenceReceived(false),
_state(0), _controlSequencePos(0), _secondaryControlSequencePos(0),
_frame_len(0), rx_len(0), _serial(nullptr),
_controlSequence(nullptr) {}
_controlSequence(nullptr), _secondaryControlSequence(nullptr) {}
void begin(Stream& serial, const char* controlSequence = nullptr) {
void begin(Stream& serial, const char* controlSequence = nullptr,
const char* secondaryControlSequence = nullptr) {
_serial = &serial;
_controlSequence = controlSequence;
_secondaryControlSequence = secondaryControlSequence;
_passthroughMode = false;
_controlSequenceReceived = false;
_secondaryControlSequenceReceived = false;
resetReceiveState();
#ifdef RAK_4631
pinMode(WB_IO2, OUTPUT);
@@ -44,6 +52,7 @@ public:
// Returns true once for each complete control sequence received while the
// binary frame parser was idle.
bool takeControlSequence();
bool takeSecondaryControlSequence();
// BaseSerialInterface methods
void enable() override;
+61 -1
View File
@@ -127,12 +127,26 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// ---- help: list the commands in plain words (aliases in parentheses) ----
if (is_cmd(a, "help|?|h", &rest)) {
#if defined(OTA_SEEDER_ONLY)
snprintf(reply, 160,
"OTA seeder: status | stats | ls=find images | get <#> folder=capture | cancel | "
"announce | folder | config. LoRa install is disabled.");
#else
snprintf(reply, 160,
"OTA: status | stats=admin ids/hashes | ls=find updates | get <#>=download | install | cancel | "
"announce | self | folder | cache | config | key. Use `ota ls [page]`.");
#endif
// ---- inventory dashboard: running fw (self), the one fetch session, serving state ----
} else if (*a == 0 || is_cmd(a, "status|st", &rest)) {
#if defined(OTA_SEEDER_ONLY)
uint8_t dig[4]; c.manager.servedDigest(dig);
char dighx[9]; mesh::Utils::toHex(dighx, dig, 4);
snprintf(reply, 160,
"OTA seeder | install:disabled | folder:%s | serving:%u dg=%s | target:00000000 (source only)",
c.folder_active ? c.folder_dest_info : "not connected",
(unsigned)c.manager.servedCount(), dighx);
#else
SelfFwInfo fi; bool s = ota_self_firmware(fi);
char selfhx[9]; if (s && fi.valid) mesh::Utils::toHex(selfhx, fi.body_hash, 4); else strcpy(selfhx, "?");
OtaManager::FetchState fs = c.manager.fetchState();
@@ -164,6 +178,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
#endif
if (n < 146) n += snprintf(reply + n, 160 - n, " | bl:%s blrc:%02X",
bl_state, ota_bootloader_last_rc());
#endif
#endif
// ---- admin OTA stats: crypto identities (our fw's content-id + body_hash), serving set, live fetch,
@@ -279,12 +294,20 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
if (!sel) { strcpy(reply, "ERR no such update (see the numbers in `ota ls`)"); return true; }
// destination is MANDATORY: with none given, show the choices (flash always; folder iff a link is up).
if (*dst == 0) {
#if defined(OTA_SEEDER_ONLY)
if (c.folder_dest)
snprintf(reply, 160, "choose a destination: `ota pull %s folder` (folder: %s)",
selstr, c.folder_dest_info);
else
strcpy(reply, "ERR no folder connected (run motatool serve --tcp)");
#else
if (c.folder_dest)
snprintf(reply, 160, "choose a destination: `ota pull %s flash` | `ota pull %s folder` (folder: %s)",
selstr, selstr, c.folder_dest_info);
else
snprintf(reply, 160, "choose a destination: `ota pull %s flash` (folder: none connected - motatool serve)",
selstr);
#endif
return true;
}
if (c.apply_pending) { strcpy(reply, "ERR busy applying"); return true; }
@@ -295,12 +318,17 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
uint8_t selmid[4]; uint32_t seltgt = sel->target_id; memcpy(selmid, sel->mid, 4); // sel may move on reset
OtaStore* store; const char* dname;
if (strncmp(dst, "flash", 5) == 0) {
#if defined(OTA_SEEDER_ONLY)
strcpy(reply, "ERR seeder-only build cannot stage or install firmware; use `folder`");
return true;
#else
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
c.stopSdCacheFetch(); // manual install download takes priority over archiving
#endif
store = &c.fetch_store; c.fetch_store.clear(); dname = "flash"; validate = false; // seed lives in the folder
#if defined(NRF52_PLATFORM) && !defined(OTA_SD_STORE)
c.manager.set_accept_full(false); // nRF52 flash can install only in-place deltas
#endif
#endif
} else if (strncmp(dst, "folder", 6) == 0) {
if (!c.folder_dest) { strcpy(reply, "ERR no folder connected (run motatool serve --tcp/--serial)"); return true; }
@@ -337,9 +365,15 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// ---- broadcast our tiny beacon so peers discover us. If not already serving, set up flash-backed
// self-serve first (so we're a real, fetchable source of our own running firmware). ----
} else if (is_cmd(a, "announce|adv", &rest)) {
#if defined(OTA_SEEDER_ONLY)
c.manager.announce();
snprintf(reply, 160, "OK beacon sent (serving=%u host mOTA)",
(unsigned)c.manager.servedCount());
#else
if (!c.serving) c.serving = ota_serve_self(c, 0);
c.manager.announce();
sprintf(reply, "OK beacon sent (serving=%s)", c.serving ? "self fw" : "nothing");
#endif
// ---- running firmware identity (compare against a delta's base_hash) ----
} else if (is_cmd(a, "self|id", &rest)) {
@@ -366,7 +400,10 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// yes" round-trip - unreliable over LoRa): refuse unless the fetch is COMPLETE, then the apply path
// validates in order (payload hash -> built-for-this-firmware -> signature/trust) and returns the
// FIRST failing gate, so the operator knows exactly why it refused; it proceeds only if all pass.
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
#if defined(OTA_SEEDER_ONLY)
strcpy(reply, "ERR seeder-only build cannot install firmware via LoRa");
return true;
#elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
if (c.sdCacheFetching()) {
strcpy(reply, "ERR SD archive capture is active; use `ota cancel` before installing");
return true;
@@ -390,7 +427,9 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
const char* p = rest;
if (strncmp(p, "on", 2) == 0) {
#if defined(OTA_FOLDER_SERIAL)
#if !defined(OTA_SEEDER_ONLY)
if (!c.serving) c.serving = ota_serve_self(c, 0); // keep serving our own fw alongside the folder
#endif
char m2[120]; c.attach_folder(m2, sizeof(m2)); c.manager.announce();
strncpy(reply, m2, 159); reply[159] = 0;
#else
@@ -398,7 +437,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
#endif
} else if (strncmp(p, "off", 3) == 0) {
c.detach_folder(); c.manager.announce();
#if defined(OTA_SEEDER_ONLY)
strcpy(reply, "OK folder detached (serving nothing)");
#else
strcpy(reply, "OK folder detached (still serving own fw)");
#endif
} else { // status + list served entries (* = our own fw)
int n = snprintf(reply, 159, "folder=%s serving=%u:", c.folder_active ? "on" : "off",
(unsigned)c.manager.servedCount());
@@ -459,6 +502,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
else
snprintf(reply, 160, "OK SD OTA archive capture %s (saved on card)", enabled ? "on" : "off");
} else
#endif
#if defined(OTA_SEEDER_ONLY)
if (strncmp(p, "autofetch ", 10) == 0 || strncmp(p, "autoinstall ", 12) == 0) {
strcpy(reply, "ERR seeder-only build keeps autofetch and autoinstall off");
} else
#endif
if (strncmp(p, "autofetch ", 10) == 0) {
const char* v = p + 10;
@@ -499,12 +547,18 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
c.autoinstall == OtaContext::AUTOINSTALL_TRUSTED ? "trusted" : "off",
(unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(),
(unsigned)c.manager.max_hops(), (unsigned)c.allow.count());
#else
#if defined(OTA_SEEDER_ONLY)
sprintf(reply, "ota config: mode=seeder-only autofetch=off autoinstall=off checkpoint=%u advert=%umin hops=%u",
(unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(),
(unsigned)c.manager.max_hops());
#else
sprintf(reply, "ota config: autofetch=%s autoinstall=%s checkpoint=%u advert=%umin hops=%u keys=%u (persisted)",
af == OtaManager::AUTOFETCH_ANY ? "any" : af == OtaManager::AUTOFETCH_SIGNED ? "signed" : "off",
c.autoinstall == OtaContext::AUTOINSTALL_TRUSTED ? "trusted" : "off",
(unsigned)c.manager.checkpoint_blocks(), (unsigned)c.manager.advert_mins(),
(unsigned)c.manager.max_hops(), (unsigned)c.allow.count());
#endif
#endif
}
@@ -536,6 +590,11 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// Raw / internal primitives (manual content load + low-level apply steps), under `ota dev ...`.
static bool handle_dev(const char* d, char* reply, OtaContext& c) {
#if defined(OTA_SEEDER_ONLY)
(void)d;
(void)c;
strcpy(reply, "ERR ota dev staging/apply is disabled on this seeder-only build");
#else
if (strncmp(d, "stage ", 6) == 0) {
uint32_t sz = parse_u32(d + 6);
if (sz == 0 || sz > OTA_SERVE_BUF_SIZE) { sprintf(reply, "ERR size 1..%u", OTA_SERVE_BUF_SIZE); }
@@ -646,6 +705,7 @@ static bool handle_dev(const char* d, char* reply, OtaContext& c) {
} else {
strcpy(reply, "ota dev: stage|recv|serve|announce|verify|want|apply slot|manifest|verify|commit|clear");
}
#endif
return true;
}
+32 -3
View File
@@ -44,9 +44,13 @@ namespace ota {
class FolderMotaStore; // pull destination over the seeder link (full type only where instantiated/used)
#ifndef OTA_SERVE_BUF_SIZE
#if defined(OTA_SEEDER_ONLY)
// Source-only Companions never accept the manual in-memory stage command.
// Keep the member valid without reserving 16 KB of nRF52840 SRAM.
#define OTA_SERVE_BUF_SIZE 1
// nRF52 self-serving streams from flash; this buffer is only for the manual `ota dev stage` helper.
// Keep it to one flash page so the OTA singleton does not consume another 16 KB of scarce SRAM.
#if defined(NRF52_PLATFORM) && (defined(OTA_FLASH_STORE) || defined(OTA_SD_STORE))
#elif defined(NRF52_PLATFORM) && (defined(OTA_FLASH_STORE) || defined(OTA_SD_STORE))
#define OTA_SERVE_BUF_SIZE 4096
#else
#define OTA_SERVE_BUF_SIZE 16384
@@ -58,7 +62,12 @@ class FolderMotaStore; // pull destination over the seeder link (full type onl
struct OtaContext {
OtaManager manager;
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
#if defined(OTA_SEEDER_ONLY)
// A seeder-only node never stages an image for itself. Keep a valid default
// store object for OtaManager, while folder captures replace it with the
// host-backed FolderMotaStore for the duration of the pull.
OtaStoreRam<1> fetch_store;
#elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
OtaStoreSdNrf52 fetch_store; // MeshTower V2: persistent SD staging, full + delta
OtaCacheSdNrf52 sd_cache; // persistent capture + source for every OTA container heard
#elif defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE)
@@ -131,6 +140,11 @@ struct OtaContext {
// so the deferred-reboot path (mesh loop) takes over. Caller ensures the fetch is COMPLETE. Shared by
// manual `ota applydelta` and the auto-install path.
bool apply_fetched(char* msg) {
#if defined(OTA_SEEDER_ONLY)
strncpy(msg, "refused: this build serves mOTA images but cannot install one", 96);
msg[95] = 0;
return false;
#else
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
if (sdCacheFetching()) {
strncpy(msg, "refused: SD archive capture owns the OTA receive slot", 96);
@@ -165,6 +179,7 @@ struct OtaContext {
#endif
if (ok) apply_pending = true;
return ok;
#endif
}
// Deferred apply-reboot: a verified `ota applydelta` approves the update but does NOT reboot inline,
@@ -231,8 +246,13 @@ struct OtaContext {
folder_active = true;
_folder_link = link;
_folder_source = source;
#if defined(OTA_SEEDER_ONLY)
snprintf(msg, cap, "OK folder attached (%s) - serving %u host mOTA total",
label ? label : "external", (unsigned)manager.servedCount());
#else
snprintf(msg, cap, "OK folder attached (%s) - serving %u mOTA total (own fw + folder)",
label ? label : "external", (unsigned)manager.servedCount());
#endif
return true;
}
@@ -379,10 +399,19 @@ struct OtaContext {
if (_fi.target_id) target_id = _fi.target_id;
if (_fi.hw_id[0]) hw = _fi.hw_id;
}
#if defined(OTA_SEEDER_ONLY)
// This role advertises only host-provided containers. A zero local target
// prevents auto-selection of firmware for the seeder itself.
target_id = 0;
#endif
manager.begin(target_id, send, ctx);
if (hw) { strncpy(hw_id, hw, sizeof(hw_id) - 1); hw_id[sizeof(hw_id) - 1] = 0; }
// a node only fetches firmware it can apply: ESP32 A/B -> sequential, nRF52 single-slot -> in-place
#if defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
#if defined(OTA_SEEDER_ONLY)
manager.set_accept_full(true);
manager.set_autofetch(OtaManager::AUTOFETCH_OFF);
autoinstall = AUTOINSTALL_OFF;
#elif defined(NRF52_PLATFORM) && defined(OTA_SD_STORE)
manager.set_accept_full(true);
manager.set_apply_codec(CODEC_DETOOLS_INPLACE);
#elif defined(NRF52_PLATFORM)