#include "OtaCli.h" #include "OtaContext.h" #include "FolderMotaStore.h" // `ota pull <#> folder` destination (set_mid on the connected folder store) #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::WANT_LEAVES: return 'L'; case OtaManager::FETCHING: return 'F'; case OtaManager::COMPLETE: return 'C'; case OtaManager::PAUSED: return 'P'; 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::WANT_LEAVES: return "validating seed"; case OtaManager::FETCHING: return "downloading"; case OtaManager::COMPLETE: return "ready to install"; case OtaManager::FAILED: return "failed"; case OtaManager::PAUSED: return "paused (folder link lost - reconnect to resume)"; default: return "?"; } } // A compact one-word fetch state for the dense `ota stats` line (state_word's phrases are too long). static const char* state_short(OtaManager::FetchState s) { switch (s) { case OtaManager::WANT_MANIFEST: return "manifest"; case OtaManager::WANT_LEAVES: return "leaves"; case OtaManager::FETCHING: return "dl"; case OtaManager::COMPLETE: return "done"; case OtaManager::FAILED: return "failed"; case OtaManager::PAUSED: return "paused"; default: return "idle"; } } // 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 | stats=admin ids/hashes | 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 // ---- admin OTA stats: crypto identities (our fw's content-id + body_hash), serving set, live fetch, // policy - one dense line. The remote-admin CLI path is admin-gated, so this is admin-only over the // mesh (send it from the app's repeater command screen, or the WiFi/serial OTA console). } else if (is_cmd(a, "stats", &rest)) { // our running fw's merkle content-id (mid) comes from the self serve entry; the EndF body_hash (fw // identity, matched against a delta base) is separate - surface BOTH (only body_hash showed before). const OtaManager::ServeEntry* self = nullptr; for (uint8_t i = 0; i < c.manager.servedCount(); i++) { const OtaManager::ServeEntry* e = c.manager.servedEntry(i); if (e && e->is_self) { self = e; break; } } SelfFwInfo fi; bool sok = ota_self_firmware(fi); char midhx[9], bodyhx[9], verbuf[20], dighx[9]; if (self) mesh::Utils::toHex(midhx, self->mid, 4); else strcpy(midhx, "?"); if (sok && fi.valid) mesh::Utils::toHex(bodyhx, fi.body_hash, 4); else strcpy(bodyhx, "?"); if (self) ver_str(verbuf, sizeof verbuf, self->fw_version); else strcpy(verbuf, "v?"); uint8_t dig[4]; c.manager.servedDigest(dig); mesh::Utils::toHex(dighx, dig, 4); OtaManager::FetchState fs = c.manager.fetchState(); char fbuf[72]; if (fs == OtaManager::IDLE) { strcpy(fbuf, "fetch idle"); } else { char fmid[9]; mesh::Utils::toHex(fmid, 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(fbuf, sizeof fbuf, "fetch %s %u/%u %u%% id=%s %us", state_short(fs), have, tot, pct, fmid, age); } uint8_t af = c.manager.autofetch(); snprintf(reply, 160, "OTA | fw %s id=%s body=%s %ub %uK | serv %u dg=%s | %s | af=%s hops=%u", verbuf, midhx, bodyhx, (unsigned)(self ? self->have_count : 0), (unsigned)((sok ? fi.image_len : 0) / 1024), (unsigned)c.manager.servedCount(), dighx, fbuf, af == OtaManager::AUTOFETCH_ANY ? "any" : af == OtaManager::AUTOFETCH_SIGNED ? "signed" : "off", (unsigned)c.manager.max_hops()); // ---- 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()); OtaManager::FetchState fs = c.manager.fetchState(); const uint8_t* cur = (fs != 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; // Tag the active session by real fetch state (not always "downloading" - COMPLETE is ready). const char* tag = ""; if (on) { if (fs == OtaManager::COMPLETE) tag = " [ready]"; else if (fs == OtaManager::PAUSED) tag = " [paused]"; else if (fs == OtaManager::FAILED) tag = " [failed]"; else tag = " [downloading]"; // WANT_*/FETCHING } 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 target (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 the raw // target_id hex (an env this build's OtaTargets.h table doesn't know) or '?' for an unset target. char hwbuf[16]; 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 if (h->target_id == 0) fit = "?"; else { snprintf(hwbuf, sizeof hwbuf, "hw %08X", (unsigned)h->target_id); fit = hwbuf; } 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, tag); 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; while (*p == ' ') p++; // split into " [destination]": selector = #N / N (catalogue index) or mid hex; destination = // flash | folder (MANDATORY - `folder` captures the .mota onto the connected motatool folder as .mota). char selstr[24]; int i = 0; while (p[i] && p[i] != ' ' && i < (int)sizeof(selstr) - 1) { selstr[i] = p[i]; i++; } selstr[i] = 0; const char* dst = p + i; while (*dst == ' ') dst++; if (selstr[0] == 0) { strcpy(reply, "usage: ota pull <#> (see `ota ls`)"); return true; } // resolve the catalogue row (index or explicit manifest_id) const OtaManager::CatRow* sel = nullptr; uint8_t mid[4]; bool isnum = (selstr[0] == '#'); if (!isnum) { isnum = true; for (const char* x = selstr; *x; x++) if (*x < '0' || *x > '9') { isnum = false; break; } } if (isnum) { int idx = atoi(selstr[0] == '#' ? selstr + 1 : selstr); if (idx >= 1 && idx <= c.manager.catalogCount()) sel = c.manager.catalogRow((uint8_t)(idx - 1)); } else if (mesh::Utils::fromHex(mid, 4, selstr)) { for (uint8_t k = 0; k < c.manager.catalogCount(); k++) if (memcmp(c.manager.catalogRow(k)->mid, mid, 4) == 0) { sel = c.manager.catalogRow(k); break; } } 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 (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); return true; } if (c.apply_pending) { strcpy(reply, "ERR busy applying"); return true; } // optional 3rd token: `folder validate` -> leaf-diff warm-start against a seed motatool staged (capture only) bool validate = false; { const char* q = dst; while (*q && *q != ' ') q++; while (*q == ' ') q++; if (strncmp(q, "validate", 8) == 0) validate = true; } 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) { store = &c.fetch_store; c.fetch_store.clear(); dname = "flash"; validate = false; // seed lives in the folder #if defined(NRF52_PLATFORM) c.manager.set_accept_full(false); // nRF52 flash can install only in-place deltas #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; } c.folder_dest->set_mid(selmid); store = c.folder_dest; dname = validate ? "folder+validate" : "folder"; #if defined(NRF52_PLATFORM) c.manager.set_accept_full(true); // capture can store a full image; it is not applied #endif } else { strcpy(reply, "ERR destination must be `flash` or `folder`"); return true; } c.manager.reset_session(); c.manager.set_fetch_store(store); // stage this pull to the chosen destination c.manager.pull(selmid, seltgt, validate); // sets want + begins the manifest fetch now char midhx[9]; mesh::Utils::toHex(midhx, selmid, 4); snprintf(reply, 160, "OK pulling mid=%s -> %s (low priority)", midhx, dname); // ---- 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.manager.set_fetch_store(&c.fetch_store); // revert to the default flash store (a folder pull switched it) #if defined(NRF52_PLATFORM) c.manager.set_accept_full(false); #endif c.fetch_store.clear(); c.serving = false; c.serve_expected = 0; c.session_started_ms = 0; snprintf(reply, 160, "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 if (strncmp(p, "advert ", 7) == 0) { // beacon re-advertise cadence (minutes; 0=disable) long m = atol(p + 7); if (m < 0 || m > 10080) { strcpy(reply, "ERR usage: ota config advert <0..10080> (minutes; 0=disable)"); return true; } c.manager.set_advert_mins((uint16_t)m); c.config_dirty = true; sprintf(reply, "OK re-advertise every %ld min (saved)%s", m, m == 0 ? " - periodic advert disabled" : ""); } else if (strncmp(p, "hops ", 5) == 0) { // OTA flood reach in hops (0 = direct only) long h = atol(p + 5); if (h < 0 || h > 8) { strcpy(reply, "ERR usage: ota config hops <0..8> (hops; 0 = direct only)"); return true; } c.manager.set_max_hops((uint8_t)h); c.config_dirty = true; sprintf(reply, "OK OTA reach = %ld hop%s (saved)%s", h, h == 1 ? "" : "s", h == 0 ? " - direct only" : ""); } else { // show current policy uint8_t af = c.manager.autofetch(); 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()); } // ---- 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 if (!c.ensureServeBuffer()) { strcpy(reply, "ERR stage OOM"); } else { c.manager.clear_primary(); c.serving = false; memset(c.serve_buf, 0xFF, sz); c.serve_expected = sz; 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 (!c.serve_buf || 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) { if (!c.serve_buf || c.serve_expected == 0) { strcpy(reply, "ERR nothing staged"); return true; } 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 payload=%d img=%d sig=%d trust=%d", r.root_ok, r.payload_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_buf ? c.serve_expected : 0; } 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 payload=%d img=%d signed=%d sig=%d trust=%d | ok=%d auto=%d", r.parsed, r.root_ok, r.payload_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 (c.serve_buf && 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.manager.clear_primary(); c.serve_expected = 0; c.serving = false; c.releaseServeBuffer(); 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