From b159737edeed452826eaa627e7e3d5adb54dc40c Mon Sep 17 00:00:00 2001 From: Valentin Kivachuk Burda Date: Fri, 3 Jul 2026 08:02:37 +0200 Subject: [PATCH] =?UTF-8?q?ota:=20admin=20OTA=20statistics=20=E2=80=94=20`?= =?UTF-8?q?ota=20stats`=20reply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an admin-only `ota stats` reply: one dense line with the running firmware's merkle content-id (mid) AND its EndF body_hash (only body_hash was surfaced before), version, served-set count + digest, live fetch state/progress, and policy — snprintf-bounded to the 160-byte reply. The remote CLI path is already admin-gated, so it's admin-only over the mesh (send it from the app's repeater command screen, or the WiFi/serial OTA console). A new servedDigest() accessor exposes the beacon set-digest. HW-verified on RAK4631: reports the fw identity + live fetch state (incl. during a warm-start capture). --- docs/ota_user_guide.md | 11 ++++++++ src/helpers/ota/OtaCli.cpp | 52 ++++++++++++++++++++++++++++++++++-- src/helpers/ota/OtaManager.h | 2 ++ 3 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 4d961d90..1e74c3ec 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -47,6 +47,16 @@ ota status Shows your current firmware version, your node's update "target" (its hardware/role id), and whether a download is in progress. +For a denser admin view — your firmware's content id (`mid`) **and** its body hash, the fingerprint of the +set you're serving, live download progress, and the current policy — use: + +``` +ota stats +``` + +On a remote node this is **admin-only** (the remote command console requires the admin password) — send it +from the app's repeater command screen, or the WiFi/serial OTA console. + ### 2. Find updates available near me ``` @@ -229,6 +239,7 @@ that only contains what changed). You get them by: |---|---| | List all commands | `ota help` | | See my firmware + any download | `ota status` (or just `ota`) | +| Admin: ids/hashes + serving + policy | `ota stats` (admin-only remotely) | | Find updates nearby | `ota ls` | | Download update #1 | `ota get 1` | | Cancel a download | `ota cancel` | diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index 0441b052..039f10f1 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -52,6 +52,19 @@ static const char* state_word(OtaManager::FetchState s) { } } +// 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); @@ -97,8 +110,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board // ---- help: list the commands in plain words (aliases in parentheses) ---- if (is_cmd(a, "help|?|h", &rest)) { snprintf(reply, 160, - "OTA: status | ls=find updates | get <#>=download | install | cancel | announce | self | " - "folder | config | key. Try `ota ls`."); + "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)) { @@ -128,6 +141,41 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board 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 diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index 295f5d28..e3cab71b 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -174,6 +174,8 @@ public: uint8_t servedCount() const { return _n_serve; } // total mOTAs we offer (own fw + folder) // Read-only view of one served entry (for `ota serve` listing): mid/target/fwver/codec/flags + is_self. const ServeEntry* servedEntry(uint8_t i) const { return i < _n_serve ? &_serve[i] : nullptr; } + // 4-byte fingerprint of our served set (== the set_digest carried in the beacon); for admin OTA stats. + void servedDigest(uint8_t out[4]) const { setDigest(out); } // Broadcast the tiny per-node BEACON (OTA_ADV): seeder_id + count + set-digest of everything we serve. // Constant size regardless of how many mOTAs — peers ask for the catalog via OTA_QUERY only on interest.