diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index f4190f30..9539ad3d 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -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) diff --git a/examples/simple_repeater/MyMesh.cpp b/examples/simple_repeater/MyMesh.cpp index ca4cfad2..17df619c 100644 --- a/examples/simple_repeater/MyMesh.cpp +++ b/examples/simple_repeater/MyMesh.cpp @@ -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(); diff --git a/examples/simple_repeater/MyMesh.h b/examples/simple_repeater/MyMesh.h index fb091a4c..6a7aa66b 100644 --- a/examples/simple_repeater/MyMesh.h +++ b/examples/simple_repeater/MyMesh.h @@ -2,6 +2,9 @@ #include #include +#if defined(ENABLE_OTA) + #include +#endif #include #include @@ -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); diff --git a/examples/simple_room_server/MyMesh.h b/examples/simple_room_server/MyMesh.h index e9e53ec9..671c0df8 100644 --- a/examples/simple_room_server/MyMesh.h +++ b/examples/simple_room_server/MyMesh.h @@ -31,7 +31,7 @@ #endif #ifndef FIRMWARE_VERSION - #define FIRMWARE_VERSION "v1.16.0" + #define FIRMWARE_VERSION "v1.17.0" #endif #ifndef LORA_FREQ diff --git a/examples/simple_sensor/SensorMesh.h b/examples/simple_sensor/SensorMesh.h index 1d65b877..7cd53810 100644 --- a/examples/simple_sensor/SensorMesh.h +++ b/examples/simple_sensor/SensorMesh.h @@ -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" diff --git a/src/Mesh.cpp b/src/Mesh.cpp index e9b92262..6443c244 100644 --- a/src/Mesh.cpp +++ b/src/Mesh.cpp @@ -1,14 +1,111 @@ #include "Mesh.h" //#include +#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()); diff --git a/src/Mesh.h b/src/Mesh.h index 932541db..0d7215d4 100644 --- a/src/Mesh.h +++ b/src/Mesh.h @@ -2,6 +2,16 @@ #include +#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); diff --git a/src/MeshCore.h b/src/MeshCore.h index 89e60b1f..e8270053 100644 --- a/src/MeshCore.h +++ b/src/MeshCore.h @@ -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; } diff --git a/src/Packet.h b/src/Packet.h index c19d9e9d..e872a050 100644 --- a/src/Packet.h +++ b/src/Packet.h @@ -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 diff --git a/src/helpers/CommonCLI.cpp b/src/helpers/CommonCLI.cpp index c95e3e34..ca9d6264 100644 --- a/src/helpers/CommonCLI.cpp +++ b/src/helpers/CommonCLI.cpp @@ -4,6 +4,10 @@ #include "AdvertDataHelpers.h" #include "TxtDataHelpers.h" #include +#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); diff --git a/src/helpers/CommonCLI.h b/src/helpers/CommonCLI.h index f3abcf47..8090f702 100644 --- a/src/helpers/CommonCLI.h +++ b/src/helpers/CommonCLI.h @@ -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); diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp new file mode 100644 index 00000000..b5cb3406 --- /dev/null +++ b/src/helpers/ota/OtaCli.cpp @@ -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 +#include +#include +#include // 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 "); 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 "); 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 ` / `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 `)"); + } + + } 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 "); 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 diff --git a/src/helpers/ota/OtaCli.h b/src/helpers/ota/OtaCli.h new file mode 100644 index 00000000..987eebb5 --- /dev/null +++ b/src/helpers/ota/OtaCli.h @@ -0,0 +1,16 @@ +#pragma once + +#include + +// 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