ota: CLI commands + node/CommonCLI integration + example hooks

This commit is contained in:
Valentin Kivachuk Burda committed 2026-06-29 20:24:24 +02:00
1 parent 9beaf6848e
commit 543c90119e
13 files changed
+695 -9

No files matched your search

+1 -1
View File
@@ -12,7 +12,7 @@
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "v1.16.0"
#define FIRMWARE_VERSION "v1.17.0"
#endif
#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
+4 -2
View File
@@ -926,8 +926,10 @@ MyMesh::MyMesh(mesh::MainBoard &board, mesh::Radio &radio, mesh::MillisecondCloc
memset(default_scope.key, 0, sizeof(default_scope.key));
}
// OTA mesh-integration (receive/begin/loop) is centralized in mesh::Mesh — no per-example wiring.
void MyMesh::begin(FILESYSTEM *fs) {
mesh::Mesh::begin();
mesh::Mesh::begin(); // also starts OTA (ota_ctx().begin) for all roles
_fs = fs;
// load persisted prefs
_cli.loadPrefs(_fs);
@@ -1268,7 +1270,7 @@ void MyMesh::loop() {
bridge.loop();
#endif
mesh::Mesh::loop();
mesh::Mesh::loop(); // also drives the OTA fetch loop (centralized in mesh::Mesh)
if (next_flood_advert && millisHasNowPassed(next_flood_advert)) {
mesh::Packet *pkt = createSelfAdvert();
+5 -1
View File
@@ -2,6 +2,9 @@
#include <Arduino.h>
#include <Mesh.h>
#if defined(ENABLE_OTA)
#include <helpers/ota/OtaContext.h>
#endif
#include <RTClib.h>
#include <target.h>
@@ -73,7 +76,7 @@ struct NeighbourInfo {
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "v1.16.0"
#define FIRMWARE_VERSION "v1.17.0"
#endif
#define FIRMWARE_ROLE "repeater"
@@ -175,6 +178,7 @@ protected:
void onPeerDataRecv(mesh::Packet* packet, uint8_t type, int sender_idx, const uint8_t* secret, uint8_t* data, size_t len) override;
bool onPeerPathRecv(mesh::Packet* packet, int sender_idx, const uint8_t* secret, uint8_t* path, uint8_t path_len, uint8_t extra_type, uint8_t* extra, uint8_t extra_len) override;
void onControlDataRecv(mesh::Packet* packet) override;
// OTA mesh-integration is centralized in mesh::Mesh (no per-example onOtaRecv / send adapter / tick).
void sendFloodReply(mesh::Packet* packet, unsigned long delay_millis, uint8_t path_hash_size);
+1 -1
View File
@@ -31,7 +31,7 @@
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "v1.16.0"
#define FIRMWARE_VERSION "v1.17.0"
#endif
#ifndef LORA_FREQ
+1 -1
View File
@@ -38,7 +38,7 @@
#endif
#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "v1.16.0"
#define FIRMWARE_VERSION "v1.17.0"
#endif
#define FIRMWARE_ROLE "sensor"
+145
View File
@@ -1,14 +1,111 @@
#include "Mesh.h"
//#include <Arduino.h>
#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
#include "helpers/ota/OtaSelf.h" // ota_self_firmware -> auto-advertise our own image
#ifndef OTA_ANNOUNCE_BOOT_MS
#define OTA_ANNOUNCE_BOOT_MS 30000UL // first self-advert ~30 s after boot (let the node settle)
#endif
#ifndef OTA_ANNOUNCE_BURST
#define OTA_ANNOUNCE_BURST 4 // a few closely-spaced boot adverts so co-booting peers catch one
#endif
#ifndef OTA_ANNOUNCE_BURST_MS
#define OTA_ANNOUNCE_BURST_MS 45000UL // spacing during the boot burst (~3 min total), then ...
#endif
#ifndef OTA_ANNOUNCE_INTERVAL_MS
#define OTA_ANNOUNCE_INTERVAL_MS 86400000UL // ... every 24 h — all lowest priority, duty-gated
#endif
#endif
namespace mesh {
#if defined(ENABLE_OTA)
// Adapter so the portable OtaManager can emit packets through the mesh (lowest priority, hop-capped).
void Mesh::otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool /*flood*/) {
Mesh* m = (Mesh*)ctx;
Packet* p = m->createOtaPacket(msg, len);
if (p) m->sendOtaFlood(p);
}
#endif
void Mesh::begin() {
Dispatcher::begin();
#if defined(ENABLE_OTA)
uint32_t my_tid = 0;
#ifdef MOTA_TARGET_ID
my_tid = (uint32_t)(MOTA_TARGET_ID); // sha2-256:4(env name), injected by build.sh
#endif
const char* my_hw = "";
#ifdef MOTA_HW_ID
my_hw = MOTA_HW_ID; // human-readable hardware tag (per-variant), for the apply hw gate
#endif
ota::ota_ctx().begin(my_tid, Mesh::otaSendAdapter, this, my_hw); // also sets the platform apply codec
ota::ota_ctx().manager.set_seeder_id(self_id.pub_key); // node id (pubkey[0:4]) for advert seeder count
_next_ota_announce = futureMillis(OTA_ANNOUNCE_BOOT_MS); // advertise our own fw shortly after boot
#endif
}
void Mesh::loop() {
Dispatcher::loop();
#if defined(ENABLE_OTA)
// 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
// outbound queue drains) after a short grace to let it be queued, with a hard cap for a busy node
// whose queue never idles.
{
ota::OtaContext& oc = ota::ota_ctx();
if (oc.apply_pending) {
if (oc.apply_at == 0) {
oc.apply_at = futureMillis(1500);
oc.apply_hard = futureMillis(15000);
} else if (millisHasNowPassed(oc.apply_at) &&
(_mgr->getOutboundTotal() == 0 || millisHasNowPassed(oc.apply_hard))) {
ota::ota_reboot_to_apply(); // does not return
}
}
}
if (millisHasNowPassed(_next_ota_tick)) {
// 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 (!_ota_resumed) {
_ota_resumed = true;
ota::OtaContext& oc = ota::ota_ctx();
if (oc.manager.fetchState() == ota::OtaManager::IDLE && oc.manager.resumeStaged(nullptr)
&& oc.manager.fetchState() == ota::OtaManager::COMPLETE) {
oc.manager.reset_session(); // don't auto-adopt a complete staged container on boot
}
}
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
_next_ota_tick = futureMillis(3000);
}
if (millisHasNowPassed(_next_ota_announce)) { // auto-advertise so peers discover us (tiny beacon)
ota::OtaContext& oc = ota::ota_ctx();
// 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.
if (!oc.serving) oc.serving = ota::ota_serve_self(oc, 0);
oc.manager.announce();
// boot burst (a few closely-spaced adverts so a co-booting peer catches one), then settle to daily
_next_ota_announce = futureMillis(_ota_announce_count < OTA_ANNOUNCE_BURST
? OTA_ANNOUNCE_BURST_MS : OTA_ANNOUNCE_INTERVAL_MS);
if (_ota_announce_count < 250) _ota_announce_count++;
}
{ // 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) {
_ota_autoinstall_tried = false;
} else if (!_ota_autoinstall_tried && !oc.apply_pending
&& oc.autoinstall == ota::OtaContext::AUTOINSTALL_TRUSTED
&& oc.manager.fetched_is_signed()) {
_ota_autoinstall_tried = true;
char msg[100];
oc.apply_fetched(msg); // arms + sets apply_pending only if signed & allowlisted; refused otherwise
}
}
#endif
}
bool Mesh::allowPacketForward(const mesh::Packet* packet) {
@@ -313,6 +410,31 @@ DispatcherAction Mesh::onRecvPacket(Packet* pkt) {
}
break;
#if defined(ENABLE_OTA)
case PAYLOAD_TYPE_OTA: {
// ALWAYS process every received copy: OTA handlers are idempotent, and "eventually reliable"
// retries deliberately re-send IDENTICAL requests — if we gated processing on hasSeen(), the
// dedup would suppress those retries and the transfer could never recover from a lost reply.
// hasSeen() is used ONLY to avoid re-flooding the same packet more than once.
bool seen = _tables->hasSeen(pkt);
ota::ota_ctx().manager.set_clock(_ms->getMillis()); // discovery jitter/ages
ota::ota_ctx().manager.on_message(pkt->payload, pkt->payload_len); // central OTA receive (beacon/query/
// have/manifest/data/proof; all roles)
ota::ota_ctx().track_session(ota::ota_ctx().manager.fetchState(), _ms->getMillis());
onOtaRecv(pkt); // optional per-example hook
// Re-flood with a hop cap and the LOWEST priority, so OTA never competes with mesh traffic.
uint8_t n = pkt->getPathHashCount();
if (!seen && pkt->isRouteFlood() && !pkt->isMarkedDoNotRetransmit()
&& n < getOtaHopLimit()
&& (n + 1) * pkt->getPathHashSize() <= MAX_PATH_SIZE
&& allowPacketForward(pkt)) {
self_id.copyHashTo(&pkt->path[n * pkt->getPathHashSize()], pkt->getPathHashSize());
pkt->setPathHashCount(n + 1);
action = ACTION_RETRANSMIT_DELAYED(OTA_TX_PRIORITY, getRetransmitDelay(pkt));
}
break;
}
#endif
default:
MESH_DEBUG_PRINTLN("%s Mesh::onRecvPacket(): unknown payload type, header: %d", getLogDateTime(), (int) pkt->header);
// Don't flood route unknown packet types! action = routeRecvPacket(pkt);
@@ -623,6 +745,29 @@ Packet* Mesh::createControlData(const uint8_t* data, size_t len) {
return packet;
}
#if defined(ENABLE_OTA)
Packet* Mesh::createOtaPacket(const uint8_t* data, size_t len) {
if (len > sizeof(Packet::payload)) return NULL;
Packet* packet = obtainNewPacket();
if (packet == NULL) {
MESH_DEBUG_PRINTLN("%s Mesh::createOtaPacket(): error, packet pool empty", getLogDateTime());
return NULL;
}
packet->header = (PAYLOAD_TYPE_OTA << PH_TYPE_SHIFT); // ROUTE_TYPE_* set by sendOtaFlood
memcpy(packet->payload, data, len);
packet->payload_len = len;
return packet;
}
void Mesh::sendOtaFlood(Packet* packet, uint32_t delay_millis) {
packet->header &= ~PH_ROUTE_MASK;
packet->header |= ROUTE_TYPE_FLOOD;
packet->setPathHashSizeAndCount(1, 0);
_tables->hasSeen(packet); // mark as sent, in case it floods back to us
sendPacket(packet, OTA_TX_PRIORITY, delay_millis);
}
#endif
void Mesh::sendFlood(Packet* packet, uint32_t delay_millis, uint8_t path_hash_size) {
if (packet->getPayloadType() == PAYLOAD_TYPE_TRACE) {
MESH_DEBUG_PRINTLN("%s Mesh::sendFlood(): TRACE type not suspported", getLogDateTime());
+37
View File
@@ -2,6 +2,16 @@
#include <Dispatcher.h>
#if defined(ENABLE_OTA)
// OTA-over-LoRa: lowest TX priority (selected only after all real traffic) + default hop cap.
#ifndef OTA_TX_PRIORITY
#define OTA_TX_PRIORITY 250
#endif
#ifndef OTA_HOP_LIMIT_DEFAULT
#define OTA_HOP_LIMIT_DEFAULT 3
#endif
#endif
namespace mesh {
class GroupChannel {
@@ -144,6 +154,26 @@ protected:
*/
virtual void onRawDataRecv(Packet* packet) { }
#if defined(ENABLE_OTA)
/**
* \brief An OTA-over-LoRa packet (PAYLOAD_TYPE_OTA) has been received. Subclasses forward the
* payload bytes to their OtaManager. See docs/ota_protocol.md.
*/
virtual void onOtaRecv(Packet* packet) { }
/** \returns the max hop count for forwarding OTA flood packets (default 3). */
virtual uint8_t getOtaHopLimit() const { return OTA_HOP_LIMIT_DEFAULT; }
// OTA mesh-integration is centralized in Mesh::begin()/loop()/dispatch, so every role (repeater,
// companion, room, sensor, ...) gets fetch/serve/apply without per-example wiring.
static void otaSendAdapter(void* ctx, const uint8_t* msg, uint16_t len, bool flood);
unsigned long _next_ota_tick = 0;
unsigned long _next_ota_announce = 0; // auto-advertise our own fw: boot burst + every OTA_ANNOUNCE_INTERVAL
uint8_t _ota_announce_count = 0; // adverts sent so far (boot burst before settling to daily)
bool _ota_resumed = false; // one-shot: resumed an interrupted fetch staged in flash on boot
bool _ota_autoinstall_tried = false; // attempted auto-install for the current COMPLETE fetch
#endif
/**
* \brief Perform search of local DB of matching GroupChannels.
* \param channels OUT - store matching channels in this array, up to max_matches
@@ -192,6 +222,13 @@ public:
Packet* createPathReturn(const uint8_t* dest_hash, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len);
Packet* createPathReturn(const Identity& dest, const uint8_t* secret, const uint8_t* path, uint8_t path_len, uint8_t extra_type, const uint8_t*extra, size_t extra_len);
Packet* createRawData(const uint8_t* data, size_t len);
#if defined(ENABLE_OTA)
// Build a PAYLOAD_TYPE_OTA packet from raw OTA message bytes (route set by sendOtaFlood).
Packet* createOtaPacket(const uint8_t* data, size_t len);
// Flood-send at the lowest priority (so OTA never competes with mesh traffic).
void sendOtaFlood(Packet* packet, uint32_t delay_millis = 0);
#endif
Packet* createTrace(uint32_t tag, uint32_t auth_code, uint8_t flags = 0);
Packet* createControlData(const uint8_t* data, size_t len);
+21
View File
@@ -67,6 +67,27 @@ public:
virtual bool setLoRaFemLnaEnabled(bool enable) { return false; }
virtual bool canControlLoRaFemLna() const { return false; }
virtual bool isLoRaFemLnaEnabled() const { return false; }
#if defined(ENABLE_OTA)
// 4-byte build-target discriminator for OTA-over-LoRa (docs/ota_protocol.md §9). Default is the
// MOTA_TARGET_ID build flag injected by build.sh; 0 when unset (e.g. a bare IDE build).
virtual uint32_t getOtaTargetId() const {
#ifdef MOTA_TARGET_ID
return (uint32_t)(MOTA_TARGET_ID);
#else
return 0;
#endif
}
// Human-readable hardware tag (<=32 ASCII chars, e.g. "RAK4631") naming the hardware this firmware can
// boot on. Same tag == bootable-compatible; the OTA applier refuses a `.mota` whose hw_id differs (brick-
// safety). Defined per-variant via the MOTA_HW_ID build flag; "" when unset (then the check is skipped).
virtual const char* getOtaHwId() const {
#ifdef MOTA_HW_ID
return MOTA_HW_ID;
#else
return "";
#endif
}
#endif
// Power management interface (boards with power management override these)
virtual bool isExternalPowered() { return false; }
+1
View File
@@ -28,6 +28,7 @@ namespace mesh {
#define PAYLOAD_TYPE_TRACE 0x09 // trace a path, collecting SNR for each hop
#define PAYLOAD_TYPE_MULTIPART 0x0A // packet is one of a set of packets
#define PAYLOAD_TYPE_CONTROL 0x0B // a control/discovery packet
#define PAYLOAD_TYPE_OTA 0x0C // OTA-over-LoRa firmware distribution (see docs/ota_protocol.md)
//...
#define PAYLOAD_TYPE_RAW_CUSTOM 0x0F // custom packet as raw bytes, for applications with custom encryption, payloads, etc
+58 -3
View File
@@ -4,6 +4,10 @@
#include "AdvertDataHelpers.h"
#include "TxtDataHelpers.h"
#include <RTClib.h>
#if defined(ENABLE_OTA)
#include "ota/OtaCli.h"
#include "ota/OtaContext.h" // persist/sync OTA policy + signer allowlist with NodePrefs
#endif
#ifndef BRIDGE_MAX_BAUD
#define BRIDGE_MAX_BAUD 115200
@@ -28,15 +32,33 @@ static bool isValidName(const char *n) {
}
void CommonCLI::loadPrefs(FILESYSTEM* fs) {
bool loaded = false;
if (fs->exists("/com_prefs")) {
loadPrefsInt(fs, "/com_prefs"); // new filename
loadPrefsInt(fs, "/com_prefs"); loaded = true; // new filename
} else if (fs->exists("/node_prefs")) {
loadPrefsInt(fs, "/node_prefs");
savePrefs(fs); // save to new filename
fs->remove("/node_prefs"); // remove old
loaded = true;
}
#if defined(ENABLE_OTA)
if (loaded) syncOtaConfigFromPrefs(); // persisted OTA policy/keys -> OtaContext (else keep safe defaults)
#endif
}
#if defined(ENABLE_OTA)
// Push the persisted OTA policy + signer allowlist into the running OtaContext (called after load).
void CommonCLI::syncOtaConfigFromPrefs() {
mesh::ota::OtaContext& c = mesh::ota::ota_ctx();
c.manager.set_autofetch(_prefs->ota_autofetch);
c.manager.set_checkpoint_blocks(_prefs->ota_checkpoint_blocks);
c.autoinstall = _prefs->ota_autoinstall;
c.allow.clear();
for (uint8_t i = 0; i < _prefs->ota_signer_count && i < MAX_OTA_SIGNERS; i++)
c.allow.add(_prefs->ota_signers[i]);
}
#endif
void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
#if defined(RP2040_PLATFORM)
File file = fs->open(filename, "r");
@@ -93,7 +115,16 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
file.read((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292
file.read((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293
file.read((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294
// next: 295
// OTA config (295+). Default first so older prefs files (which lack these) keep conservative
// defaults: a short file makes the reads below no-ops (read returns 0 bytes, values unchanged).
_prefs->ota_autofetch = 0; _prefs->ota_autoinstall = 0; _prefs->ota_signer_count = 0;
_prefs->ota_checkpoint_blocks = 4; // = OTA_CHECKPOINT_BLOCKS; older prefs lack it -> stays at default
file.read((uint8_t *)&_prefs->ota_autofetch, sizeof(_prefs->ota_autofetch)); // 295
file.read((uint8_t *)&_prefs->ota_autoinstall, sizeof(_prefs->ota_autoinstall)); // 296
file.read((uint8_t *)&_prefs->ota_signer_count, sizeof(_prefs->ota_signer_count)); // 297
file.read((uint8_t *)_prefs->ota_signers, sizeof(_prefs->ota_signers)); // 298
file.read((uint8_t *)&_prefs->ota_checkpoint_blocks, sizeof(_prefs->ota_checkpoint_blocks)); // 426
// next: 428
// sanitise bad pref values
_prefs->rx_delay_base = constrain(_prefs->rx_delay_base, 0, 20.0f);
@@ -125,6 +156,10 @@ void CommonCLI::loadPrefsInt(FILESYSTEM* fs, const char* filename) {
_prefs->rx_boosted_gain = constrain(_prefs->rx_boosted_gain, 0, 1); // boolean
_prefs->radio_fem_rxgain = constrain(_prefs->radio_fem_rxgain, 0, 1); // boolean
_prefs->cad_enabled = constrain(_prefs->cad_enabled, 0, 1); // boolean
_prefs->ota_autofetch = constrain(_prefs->ota_autofetch, 0, 2);
_prefs->ota_autoinstall = constrain(_prefs->ota_autoinstall, 0, 1);
if (_prefs->ota_checkpoint_blocks > 4096) _prefs->ota_checkpoint_blocks = 4; // 0=never; cap absurd
if (_prefs->ota_signer_count > 4) _prefs->ota_signer_count = 0; // corrupt count -> drop keys
file.close();
}
@@ -190,7 +225,12 @@ void CommonCLI::savePrefs(FILESYSTEM* fs) {
file.write((uint8_t *)&_prefs->flood_max_advert, sizeof(_prefs->flood_max_advert)); // 292
file.write((uint8_t *)&_prefs->radio_fem_rxgain, sizeof(_prefs->radio_fem_rxgain)); // 293
file.write((uint8_t *)&_prefs->cad_enabled, sizeof(_prefs->cad_enabled)); // 294
// next: 295
file.write((uint8_t *)&_prefs->ota_autofetch, sizeof(_prefs->ota_autofetch)); // 295
file.write((uint8_t *)&_prefs->ota_autoinstall, sizeof(_prefs->ota_autoinstall)); // 296
file.write((uint8_t *)&_prefs->ota_signer_count, sizeof(_prefs->ota_signer_count)); // 297
file.write((uint8_t *)_prefs->ota_signers, sizeof(_prefs->ota_signers)); // 298
file.write((uint8_t *)&_prefs->ota_checkpoint_blocks, sizeof(_prefs->ota_checkpoint_blocks)); // 426
// next: 428
file.close();
}
@@ -312,6 +352,21 @@ void CommonCLI::handleCommand(uint32_t sender_timestamp, char* command, char* re
sprintf(reply, "%s (Build: %s)", _callbacks->getFirmwareVer(), _callbacks->getBuildDate());
} else if (memcmp(command, "board", 5) == 0) {
sprintf(reply, "%s", _board->getManufacturerName());
#if defined(ENABLE_OTA)
} else if (memcmp(command, "ota", 3) == 0 && (command[3] == 0 || command[3] == ' ')) {
mesh::ota::handle_ota_command(command, reply, *_board);
if (mesh::ota::ota_ctx().config_dirty) { // a policy/key changed via the CLI -> persist it
mesh::ota::OtaContext& c = mesh::ota::ota_ctx();
_prefs->ota_autofetch = c.manager.autofetch();
_prefs->ota_checkpoint_blocks = c.manager.checkpoint_blocks();
_prefs->ota_autoinstall = c.autoinstall;
_prefs->ota_signer_count = c.allow.count();
for (uint8_t i = 0; i < c.allow.count() && i < MAX_OTA_SIGNERS; i++)
memcpy(_prefs->ota_signers[i], c.allow.get(i), 32);
_callbacks->savePrefs();
c.config_dirty = false;
}
#endif
} else if (memcmp(command, "sensor get ", 11) == 0) {
const char* key = command + 11;
const char* val = _sensors->getSettingByKey(key);
+9
View File
@@ -65,6 +65,12 @@ struct NodePrefs { // persisted to file
uint8_t path_hash_mode; // which path mode to use when sending
uint8_t loop_detect;
uint8_t cad_enabled; // hardware Channel Activity Detection before TX (boolean)
// OTA config (persisted; synced to OtaContext on load, written on change). 0 = conservative defaults.
uint8_t ota_autofetch; // OtaManager AUTOFETCH_* (0=off, 1=any-compatible, 2=signed-only)
uint8_t ota_autoinstall; // OtaContext AUTOINSTALL_* (0=off, 1=trusted-only)
uint8_t ota_signer_count; // # of allowlisted signer pubkeys below
uint8_t ota_signers[4][32]; // trusted Ed25519 signer pubkeys (== MAX_OTA_SIGNERS)
uint16_t ota_checkpoint_blocks; // resume checkpoint cadence (blocks); 0=never. Default 4 (runtime-tunable)
};
class CommonCLICallbacks {
@@ -129,6 +135,9 @@ class CommonCLI {
mesh::RTCClock* getRTCClock() { return _rtc; }
void savePrefs();
void loadPrefsInt(FILESYSTEM* _fs, const char* filename);
#if defined(ENABLE_OTA)
void syncOtaConfigFromPrefs(); // persisted OTA policy + signer allowlist -> running OtaContext
#endif
void handleRegionCmd(char* command, char* reply);
void handleGetCmd(uint32_t sender_timestamp, char* command, char* reply);
+396
View File
@@ -0,0 +1,396 @@
#include "OtaCli.h"
#include "OtaContext.h"
#include "OtaVerify.h"
#include "OtaSelf.h"
#include "OtaTargets.h" // ota_target_env_name(): human-readable name for a target_id (no string on the wire)
#if defined(NRF52_PLATFORM)
#include "OtaBlInfo.h" // ota_bootloader_caps(): can this device's bootloader apply a .mota?
#endif
#include "Utils.h"
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <Arduino.h> // millis() for session-age display (device-only command surface)
namespace mesh {
namespace ota {
static uint32_t parse_u32(const char* s) {
uint32_t n = 0;
while (*s == ' ') s++;
while (*s >= '0' && *s <= '9') n = n * 10 + (uint32_t)(*s++ - '0');
return n;
}
static char fstate_char(OtaManager::FetchState s) {
switch (s) {
case OtaManager::IDLE: return 'I';
case OtaManager::WANT_MANIFEST: return 'W';
case OtaManager::FETCHING: return 'F';
case OtaManager::COMPLETE: return 'C';
default: return 'X';
}
}
// For users, the only distinction that matters is full image vs. delta (which delta codec is internal).
static const char* codec_kind(uint8_t c) { return c == CODEC_FULL ? "full" : "delta"; }
// A plain-language word for the fetch state (shown in `ota status`).
static const char* state_word(OtaManager::FetchState s) {
switch (s) {
case OtaManager::IDLE: return "idle";
case OtaManager::WANT_MANIFEST: return "starting";
case OtaManager::FETCHING: return "downloading";
case OtaManager::COMPLETE: return "ready to install";
case OtaManager::FAILED: return "failed";
default: return "?";
}
}
// Render the packed fw_version as "v1.2.3" (or "v1.2.3.4" when a prerelease byte is set).
static void ver_str(char* out, size_t cap, uint32_t v) {
FwVersion fw = FwVersion::unpack(v);
if (fw.prerelease) snprintf(out, cap, "v%u.%u.%u.%u", fw.major, fw.minor, fw.patch, fw.prerelease);
else snprintf(out, cap, "v%u.%u.%u", fw.major, fw.minor, fw.patch);
}
// Match the first word of `a` against any of the '|'-separated names (so commands have intuitive aliases
// and short forms); on a match, point `*rest` at the argument text. Keeps the dispatch table readable.
static bool is_cmd(const char* a, const char* names, const char** rest) {
size_t tlen = 0; while (a[tlen] && a[tlen] != ' ') tlen++;
for (const char* s = names; *s; ) {
const char* d = s; while (*d && *d != '|') d++;
if ((size_t)(d - s) == tlen && tlen && strncmp(a, s, tlen) == 0) {
const char* r = a + tlen; while (*r == ' ') r++;
if (rest) *rest = r;
return true;
}
s = (*d == '|') ? d + 1 : d;
}
return false;
}
// The everyday OTA surface is BitTorrent-shaped: `ota` shows what you're holding (your running firmware
// as a full mOTA + your one fetch session), `ota neighbors` shows the mOTAs heard around you, `ota pull`
// starts fetching one, `ota drop` frees the session. The raw primitives (manual content load, low-level
// apply steps) live under `ota dev ...` so they don't clutter the everyday surface. Every reply fits one
// packet so it works as remote-admin over LoRa.
static bool handle_dev(const char* d, char* reply, OtaContext& c);
bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board) {
const char* a = command + 3;
if (*a != 0 && *a != ' ') return false;
while (*a == ' ') a++;
OtaContext& c = ota_ctx();
const char* rest = a;
// ---- raw / internal primitives, tucked under `ota dev ...` ----
if (is_cmd(a, "dev", &rest)) {
return handle_dev(rest, reply, c);
}
// ---- help: list the commands in plain words (aliases in parentheses) ----
if (is_cmd(a, "help|?|h", &rest)) {
snprintf(reply, 160,
"OTA: status | ls=find updates | get <#>=download | install | cancel | announce | self | "
"folder | config | key. Try `ota ls`.");
// ---- inventory dashboard: running fw (self), the one fetch session, serving state ----
} else if (*a == 0 || is_cmd(a, "status|st", &rest)) {
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();
char dl[80];
if (fs == OtaManager::IDLE) {
strcpy(dl, "no download");
} else {
char midhx[9]; mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4);
unsigned have = (unsigned)c.manager.blocksHave(), tot = (unsigned)c.manager.blocksTotal();
unsigned pct = tot ? (unsigned)((uint64_t)have * 100 / tot) : 0;
unsigned age = c.session_started_ms ? (unsigned)((millis() - c.session_started_ms) / 1000) : 0;
snprintf(dl, sizeof dl, "download: %s %u/%u (%u%%) id=%s %us", state_word(fs), have, tot, pct, midhx, age);
}
const char* hw = (c.hw_id[0]) ? c.hw_id : "?";
const char* tenv = ota_target_env_name(c.manager.target()); // env name, or "?" if not in the table
int n = snprintf(reply, 160, "OTA | this fw %s (%uK) hw=%s | %s | serving:%s (%u) | keys:%u | target:%08X (%s)",
selfhx, (unsigned)((s ? fi.image_len : 0) / 1024), hw, dl,
c.serving ? "on" : "off", (unsigned)c.manager.servedCount(),
(unsigned)c.allow.count(), (unsigned)c.manager.target(), tenv ? tenv : "?");
#if defined(NRF52_PLATFORM)
// nRF52 applies via the bootloader — show (cached) whether it can, so `ota get`/`install` won't surprise.
// blrc = the bootloader's last in-place-apply code (diagnostic; 0xB8=success, see ota_delta.c).
if (n < 146) n += snprintf(reply + n, 160 - n, " | bl:%s blrc:%02X",
c.bootloaderCaps().present ? "apply" : "NONE", ota_bootloader_last_rc());
#endif
// ---- what's available around me (catalogued from beacons + OTA_HAVE), best/most-recent first ----
} else if (is_cmd(a, "neighbors|nbrs|updates|ls|n", &rest)) {
// Kick a fresh round of catalog queries (async — rows arrive over the next seconds); render what we
// have now in plain words. The reply buffer is 160 B (serial / one LoRa packet for remote-admin), so
// writes are bounded and extra rows collapse to "+N more".
c.manager.queryAll();
const int CAP = 160;
int n = snprintf(reply, CAP, "Updates nearby (%u src) — `ota get <#>` to download:",
(unsigned)c.manager.sourceCount());
const uint8_t* cur = (c.manager.fetchState() != OtaManager::IDLE) ? c.manager.fetchManifestId() : nullptr;
uint32_t myt = c.manager.target(); // effective target (EndF identity if present, else build flag)
uint32_t now = millis(); int shown = 0, more = 0;
for (uint8_t i = 0; i < c.manager.catalogCount(); i++) {
const OtaManager::CatRow* h = c.manager.catalogRow(i);
if (CAP - n < 48) { more++; continue; }
bool on = cur && memcmp(cur, h->mid, 4) == 0;
uint32_t age = (now - h->last_ms) / 1000; if (age > 99999) age = 99999;
char ver[20]; ver_str(ver, sizeof ver, h->fw_version);
// What is this update for? "yours" if same hw+role as us; else the target's env name when we know it
// (named locally from its 4-byte target_id — no string travels on the wire); else other hw / '?'.
const char* fit;
const char* env = ota_target_env_name(h->target_id);
if (myt && h->target_id == myt) fit = "yours";
else if (env) fit = env;
else fit = (h->target_id == 0) ? "?" : "other hw";
n += snprintf(reply + n, CAP - n, "\n %d) %s %s [%s] %un %us%s", shown + 1, ver,
codec_kind(h->codec), fit, (unsigned)h->n_seeders, (unsigned)age,
on ? " [downloading]" : "");
shown++;
}
if (more && n < CAP) snprintf(reply + n, CAP - n, "\n +%d more", more);
if (shown == 0) strcpy(reply, "No updates seen yet — re-run `ota ls` in a few seconds (just asked around).");
// ---- start fetching a specific catalogued mOTA (by list index or manifest_id) ----
} else if (is_cmd(a, "pull|get|download", &rest)) {
const char* p = rest;
if (*p == 0) { strcpy(reply, "usage: ota get <#> (see the numbers in `ota ls`)"); return true; }
const OtaManager::CatRow* sel = nullptr; uint8_t mid[4];
if (*p == '#' || (p[0] >= '1' && p[0] <= '9' && (p[1] == 0 || p[1] == ' '))) { // index among catalogue
int idx = atoi(*p == '#' ? p + 1 : p);
if (idx >= 1 && idx <= c.manager.catalogCount()) sel = c.manager.catalogRow((uint8_t)(idx - 1));
} else if (mesh::Utils::fromHex(mid, 4, p)) { // explicit manifest_id
for (uint8_t i = 0; i < c.manager.catalogCount(); i++)
if (memcmp(c.manager.catalogRow(i)->mid, mid, 4) == 0) { sel = c.manager.catalogRow(i); break; }
}
if (!sel) { strcpy(reply, "ERR no such update (see the numbers in `ota ls`)"); return true; }
if (c.apply_pending) { strcpy(reply, "ERR busy applying"); return true; }
uint8_t selmid[4]; uint32_t seltgt = sel->target_id; memcpy(selmid, sel->mid, 4); // sel may move on reset
c.manager.reset_session(); c.fetch_store.clear();
c.manager.pull(selmid, seltgt); // sets want + begins the manifest fetch now
char midhx[9]; mesh::Utils::toHex(midhx, selmid, 4);
sprintf(reply, "OK pulling mid=%s target=%08X (low priority)", midhx, (unsigned)seltgt);
// ---- discard the current session (e.g. a stalled old fetch) to free the slot ----
} else if (is_cmd(a, "drop|cancel|stop", &rest)) {
OtaManager::FetchState fs = c.manager.fetchState();
char midhx[9]; strcpy(midhx, "-");
if (fs != OtaManager::IDLE) mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4);
c.manager.reset_session(); c.manager.want(0); c.manager.want_mid(nullptr);
c.fetch_store.clear(); c.serving = false; c.serve_expected = 0; c.session_started_ms = 0;
sprintf(reply, "OK dropped session (was %c mid=%s); slot free for a new pull", fstate_char(fs), midhx);
// ---- 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 (!c.serving) c.serving = ota_serve_self(c, 0);
c.manager.announce();
sprintf(reply, "OK beacon sent (serving=%s)", c.serving ? "self fw" : "nothing");
// ---- running firmware identity (compare against a delta's base_hash) ----
} else if (is_cmd(a, "self|id", &rest)) {
SelfFwInfo fi;
if (!ota_self_firmware(fi) || !fi.valid) { strcpy(reply, "ERR no EndF (firmware lacks the trailer?)"); return true; }
char hx[17]; mesh::Utils::toHex(hx, fi.body_hash, 8);
int n = snprintf(reply, 160, "self body=%u image=%u base_hash=%s", (unsigned)fi.body_len, (unsigned)fi.image_len, hx);
#if defined(NRF52_PLATFORM)
// nRF52 applies via the bootloader, so surface whether THIS device's bootloader can (delta install gate)
const OtaBlCaps& bl = c.bootloaderCaps(); // cached (flash scanned once)
if (bl.present) snprintf(reply + n, 160 - n, " | bootloader: apply OK (abi=%u codecs=0x%x)", bl.apply_abi, bl.codec_mask);
else snprintf(reply + n, 160 - n, " | bootloader: NO mota-apply support (delta install will refuse)");
#endif
} else if (is_cmd(a, "install|apply|applydelta", &rest)) {
// Apply the fetched update. Destructive (reflashes + reboots) and GATED, not interactive (no "type
// 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 (c.manager.fetchState() != OtaManager::COMPLETE || c.fetch_store.staged_size() == 0) {
sprintf(reply, "ERR no complete update fetched (fetch=%c %u/%u)",
fstate_char(c.manager.fetchState()), (unsigned)c.manager.blocksHave(),
(unsigned)c.manager.blocksTotal());
return true;
}
// On success the slot is armed but NOT yet rebooted — defer so this reply reaches the operator first;
// the mesh loop reboots once it has been transmitted (same path used by auto-install).
char m2[100];
bool ok = c.apply_fetched(m2);
sprintf(reply, "%s | %s", ok ? "OK" : "ERR", m2);
// ---- external folder relay: advertise + serve `.mota` from a host daemon over the seeder UART, so the
// node hosts MANY images (any architecture) it doesn't hold in flash. Trustless (fetchers verify). --
} else if (is_cmd(a, "folder|fold", &rest)) {
const char* p = rest;
if (strncmp(p, "on", 2) == 0) {
#if defined(OTA_FOLDER_SERIAL)
if (!c.serving) c.serving = ota_serve_self(c, 0); // keep serving our own fw alongside the folder
char m2[120]; c.attach_folder(m2, sizeof(m2)); c.manager.announce();
strncpy(reply, m2, 159); reply[159] = 0;
#else
strcpy(reply, "ERR not built with OTA_FOLDER_SERIAL (set the seeder UART in platformio.ini)");
#endif
} else if (strncmp(p, "off", 3) == 0) {
c.detach_folder(); c.manager.announce();
strcpy(reply, "OK folder detached (still serving own fw)");
} 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());
for (uint8_t i = 0; i < c.manager.servedCount() && n < 148; i++) {
const OtaManager::ServeEntry* e = c.manager.servedEntry(i);
if (!e) break;
char midhx[9]; mesh::Utils::toHex(midhx, e->mid, 4);
n += snprintf(reply + n, 159 - n, " %s%s/%08X", e->is_self ? "*" : "", midhx, (unsigned)e->target_id);
}
}
// ---- policy config (persisted via NodePrefs). conservative defaults: autofetch/autoinstall off ----
} else if (is_cmd(a, "config|cfg|set", &rest)) {
const char* p = rest;
if (strncmp(p, "autofetch ", 10) == 0) {
const char* v = p + 10;
uint8_t pol = strncmp(v, "any", 3) == 0 ? OtaManager::AUTOFETCH_ANY
: strncmp(v, "signed", 6) == 0 ? OtaManager::AUTOFETCH_SIGNED
: strncmp(v, "off", 3) == 0 ? OtaManager::AUTOFETCH_OFF : 0xFF;
if (pol == 0xFF) { strcpy(reply, "ERR usage: ota config autofetch <off|any|signed>"); return true; }
c.manager.set_autofetch(pol); c.config_dirty = true; strcpy(reply, "OK autofetch updated (saved)");
} else if (strncmp(p, "autoinstall ", 12) == 0) {
const char* v = p + 12;
uint8_t pol = strncmp(v, "trusted", 7) == 0 ? OtaContext::AUTOINSTALL_TRUSTED
: strncmp(v, "off", 3) == 0 ? OtaContext::AUTOINSTALL_OFF : 0xFF;
if (pol == 0xFF) { strcpy(reply, "ERR usage: ota config autoinstall <off|trusted>"); return true; }
c.autoinstall = pol; c.config_dirty = true; strcpy(reply, "OK autoinstall updated (saved)");
} else if (strncmp(p, "checkpoint ", 11) == 0) { // resume checkpoint cadence (blocks; 0=never)
long n = atol(p + 11);
if (n < 0 || n > 4096) { strcpy(reply, "ERR usage: ota config checkpoint <0..4096> (blocks; 0=never)"); return true; }
c.manager.set_checkpoint_blocks((uint16_t)n); c.config_dirty = true;
sprintf(reply, "OK checkpoint every %ld blocks (saved)%s", n, n == 0 ? " — periodic resume disabled" : "");
} else { // show current policy
uint8_t af = c.manager.autofetch();
sprintf(reply, "ota config: autofetch=%s autoinstall=%s checkpoint=%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.allow.count());
}
// ---- trusted signer allowlist (security config; persisted): `ota key add|rm <hex>` / `ota key` lists ----
} else if (is_cmd(a, "key|keys", &rest)) {
const char* p = rest;
if (strncmp(p, "add ", 4) == 0) {
uint8_t pub[32];
if (mesh::Utils::fromHex(pub, 32, p + 4) && c.allow.add(pub)) { c.config_dirty = true; strcpy(reply, "OK key added (saved)"); }
else strcpy(reply, "ERR key");
} else if (strncmp(p, "rm ", 3) == 0 || strncmp(p, "remove ", 7) == 0) {
uint8_t pub[32]; const char* h = p + (p[0] == 'r' && p[1] == 'm' ? 3 : 7);
if (mesh::Utils::fromHex(pub, 32, h) && c.allow.remove(pub)) { c.config_dirty = true; strcpy(reply, "OK removed (saved)"); }
else strcpy(reply, "ERR");
} else { // bare `ota key` (or `key list`) -> show them
int n = snprintf(reply, 160, "trusted signer keys (%u):", (unsigned)c.allow.count());
for (uint8_t i = 0; i < c.allow.count() && n < 140; i++) {
char hx[17]; mesh::Utils::toHex(hx, c.allow.get(i), 8);
n += snprintf(reply + n, 160 - n, " %s", hx);
}
if (c.allow.count() == 0) strcpy(reply, "no trusted signer keys yet (add one with `ota key add <hex>`)");
}
} else {
strcpy(reply, "Unknown OTA command. Type `ota help`.");
}
return true;
}
// 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 (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); }
else { memset(c.serve_buf, 0xFF, sz); c.serve_expected = sz; c.serving = false;
sprintf(reply, "OK stage %u bytes", (unsigned)sz); }
} else if (strncmp(d, "recv ", 5) == 0) {
const char* p = d + 5; uint32_t off = parse_u32(p);
const char* hex = strchr(p, ' ');
if (!hex) { strcpy(reply, "ERR usage: ota dev recv <off> <hex>"); return true; }
hex++;
int blen = (int)strlen(hex) / 2;
uint8_t tmp[80];
if (blen <= 0 || blen > (int)sizeof(tmp) || !mesh::Utils::fromHex(tmp, blen, hex)) strcpy(reply, "ERR hex");
else if (off + blen > c.serve_expected) strcpy(reply, "ERR off>size (stage first)");
else { memcpy(c.serve_buf + off, tmp, blen); sprintf(reply, "OK %d@%u", blen, (unsigned)off); }
} else if (strncmp(d, "serve self", 10) == 0) { // host our own running firmware, served from flash
if (ota_serve_self(c, 0)) {
c.serving = true;
char midhx[9]; mesh::Utils::toHex(midhx, c.serve_self_manifest + 20, 4);
uint32_t img = (uint32_t)c.serve_self_manifest[11] | ((uint32_t)c.serve_self_manifest[12] << 8)
| ((uint32_t)c.serve_self_manifest[13] << 16) | ((uint32_t)c.serve_self_manifest[14] << 24);
sprintf(reply, "OK serving self fw mid=%s (%u B, flash-backed) — peers can pull it", midhx, (unsigned)img);
} else strcpy(reply, "ERR serve self (no EndF / image too big / OOM)");
} else if (strncmp(d, "serve", 5) == 0) {
c.serving = c.manager.serve(c.serve_buf, c.serve_expected);
if (!c.serving) { strcpy(reply, "ERR serve (bad .mota)"); return true; }
VerifyResult r = ota_verify(c.serve_buf, c.serve_expected, c.allow);
sprintf(reply, "OK serving | root=%d img=%d sig=%d trust=%d", r.root_ok, r.image_ok, r.sig_ok, r.trusted);
} else if (strncmp(d, "resume", 6) == 0) { // re-adopt a container already staged in flash (test/debug)
bool ok = c.manager.resumeStaged(nullptr);
sprintf(reply, "%s resume: sess=%c %u/%u", ok ? "OK" : "ERR", fstate_char(c.manager.fetchState()),
(unsigned)c.manager.blocksHave(), (unsigned)c.manager.blocksTotal());
} else if (strncmp(d, "announce", 8) == 0) {
if (!c.serving) { strcpy(reply, "ERR not serving (ota dev serve first)"); return true; }
c.manager.announce();
strcpy(reply, "OK announced");
} else if (strncmp(d, "verify", 6) == 0) {
const uint8_t* buf; uint32_t len;
if (c.manager.fetchState() == OtaManager::COMPLETE) { buf = c.fetch_store.data(); len = c.fetch_store.staged_size(); }
else { buf = c.serve_buf; len = c.serve_expected; }
if (len == 0 || !buf) { strcpy(reply, "ERR nothing to verify (flash-staged: applydelta verifies)"); return true; }
VerifyResult r = ota_verify(buf, len, c.allow);
sprintf(reply, "verify parsed=%d root=%d img=%d signed=%d sig=%d trust=%d | ok=%d auto=%d",
r.parsed, r.root_ok, r.image_ok, r.is_signed, r.sig_ok, r.trusted, r.integrity_ok(), r.auto_appliable());
} else if (strncmp(d, "want ", 5) == 0) {
const char* p = d + 5; while (*p == ' ') p++;
if (strncmp(p, "auto", 4) == 0) { c.manager.want(0); c.manager.want_mid(nullptr); strcpy(reply, "OK auto (own target only)"); }
else { uint32_t t = (uint32_t)strtoul(p, nullptr, 16); c.manager.want(t); c.manager.want_mid(nullptr);
sprintf(reply, "OK cross-target: will fetch %08X (you ensure HW compatible)", (unsigned)t); }
} else if (strncmp(d, "apply", 5) == 0) {
const char* sub = d + 5; while (*sub == ' ') sub++;
if (strncmp(sub, "slot", 4) == 0) {
uint32_t addr = 0, size = 0;
if (ota_apply_slot_info(&addr, &size)) sprintf(reply, "inactive slot addr=0x%X size=%u", (unsigned)addr, (unsigned)size);
else strcpy(reply, "ERR no A/B slot (apply unsupported on this build)");
} else if (strncmp(sub, "manifest", 8) == 0) {
if (ota_apply_set_manifest(c.serve_buf, c.serve_expected, c.allow, c.apply_st))
sprintf(reply, "manifest ok img=%u sig=%d trust=%d", (unsigned)c.apply_st.image_size, c.apply_st.sig_ok, c.apply_st.trusted);
else strcpy(reply, "ERR manifest parse / not full-image / unsupported");
} else if (strncmp(sub, "verify", 6) == 0) {
bool ok = ota_apply_verify_slot(c.apply_st);
sprintf(reply, "slot image_hash %s (size=%u)", ok ? "MATCH" : "MISMATCH", (unsigned)c.apply_st.image_size);
} else if (strncmp(sub, "commit", 6) == 0) {
if (!c.apply_st.slot_ok) { strcpy(reply, "ERR run 'ota dev apply verify' first (slot must match)"); return true; }
ota_apply_commit(); // set boot partition + reboot; no return
strcpy(reply, "ERR commit failed (no A/B slot?)");
} else {
strcpy(reply, "ERR ota dev apply (slot|manifest|verify|commit)");
}
} else if (strncmp(d, "clear", 5) == 0) {
c.serve_expected = 0; c.serving = false; c.fetch_store.clear(); c.manager.reset_session();
strcpy(reply, "OK cleared");
} else {
strcpy(reply, "ota dev: stage|recv|serve|announce|verify|want|apply slot|manifest|verify|commit|clear");
}
return true;
}
} // namespace ota
} // namespace mesh
+16
View File
@@ -0,0 +1,16 @@
#pragma once
#include <MeshCore.h>
// Text-CLI surface for OTA (P3/P5). Wired from CommonCLI (and reachable over LoRa remote-admin).
// Kept out of CommonCLI.cpp itself so the OTA state (allowlist, staging store) lives in the OTA module.
namespace mesh {
namespace ota {
// Handle an "ota ..." command. `command` is the full line (starts with "ota"). Fills `reply`
// (<= ~160 bytes, as per the CLI buffer). Returns true if it was an OTA command.
bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board);
} // namespace ota
} // namespace mesh