mirror of
https://github.com/mikecarper/MeshCore.git
synced 2026-09-01 19:18:17 +00:00
523 lines
30 KiB
C++
523 lines
30 KiB
C++
#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 <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::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 "<selector> [destination]": selector = #N / N (catalogue index) or mid hex; destination =
|
|
// flash | folder (MANDATORY - `folder` captures the .mota onto the connected motatool folder as <mid>.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 <#> <flash|folder> (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 <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 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 <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 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 <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 (!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
|