Rename cli + fixes

This commit is contained in:
Valentin Kivachuk Burda
2026-06-27 20:01:54 +02:00
parent f15772d90c
commit d2000afe1d
7 changed files with 461 additions and 74 deletions
+19 -10
View File
@@ -5,6 +5,9 @@ written for developers who want to implement an interoperable peer (server, fetc
in another codebase or project. Everything below is implemented and hardware-verified in this repository;
where a section names a source file, that file is the authoritative reference for byte-level details.
> **Just want to update your node?** See the plain-language [OTA user guide](ota_user_guide.md) — this
> document is the technical/wire specification.
**Design goals**
- Distribute firmware over LoRa as a **self-verifying, resumable, BitTorrent-style block transfer** that
@@ -498,17 +501,23 @@ Heltec V3 over one USB cable, every block merkle-checked.
User-facing OTA data should travel via `CMD_OTA_*` companion binary frames; the text CLI below is
debug/operator oriented and replies are `snprintf`-bounded into a 160-byte buffer.
Commands take intuitive aliases (matched by the first word; see `is_cmd` in `OtaCli.cpp`) so they're easy
to type and read — `status`/`neighbors`/`pull`/`drop`/`applydelta` are the canonical names, the aliases are
the recommended user-facing forms. Output is plain-language (a user-facing guide lives at
[ota_user_guide.md](ota_user_guide.md)).
```
ota status session + self-fw summary
ota neighbors discovered mOTAs (queries sources; rows arrive async via OTA_HAVE)
ota announce serve self + send a beacon now
ota pull <#|mid8> fetch a chosen mOTA (manual; works regardless of autofetch)
ota drop drop the current fetch session (free the slot)
ota folder on|off attach/detach an external .mota folder (host daemon) ; bare = list
ota self print this firmware's EndF (body/image size, base_hash)
ota applydelta verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader
ota config [autofetch|autoinstall|checkpoint] ... show/set persisted policy
ota key add|list|rm <hex> trusted signer allowlist
ota help | ? list the commands
ota status | st (or bare `ota`) plain-language: running fw, the one fetch session (state/%/id), serving, keys
ota ls | neighbors | nbrs | updates | n discovered updates (queries sources; rows arrive async via OTA_HAVE)
ota get | pull | download <#|mid8> fetch a chosen mOTA (manual; works regardless of autofetch)
ota install | apply | applydelta verify + approve + (ESP32) apply / (nRF52) reboot-to-bootloader
ota cancel | drop | stop drop the current fetch session (frees the slot; stops re-seeding)
ota announce | adv serve self + send a beacon now
ota self | id print this firmware's EndF (body/image size, base_hash)
ota folder | fold [on|off] attach/detach an external .mota folder (host daemon) ; bare = list
ota config | cfg | set [autofetch|autoinstall|checkpoint] ... show/set persisted policy
ota key | keys [add|rm <hex>] trusted signer allowlist ; bare = list
ota dev ... bring-up helpers (stage/recv/serve/verify)
```
+222
View File
@@ -0,0 +1,222 @@
# Updating your node over the air (OTA) — user guide
This guide is for **node operators**: how to update your MeshCore device's firmware over the radio, in
plain language. No cables, no programmer — your node can download a new firmware from a neighbour and
install it. (For the technical wire format, see [the OTA protocol spec](ota_protocol.md).)
> **Is my node supported?** OTA works on **ESP32** boards (e.g. Heltec V3) and on the **RAK4631** (nRF52,
> which needs the special MeshCore bootloader). Other boards build fine but can't self-update yet.
---
## The important part first: it's safe
- **Nothing installs by itself.** Your node can *discover* and *download* an update in the background, but
it only **installs** when you say so (unless you deliberately turn on auto-install — see below).
- **Bad downloads can't sneak in.** Every piece of the firmware is checked against a cryptographic
fingerprint as it arrives, and the whole image is verified again before install. A corrupt or tampered
download is rejected, not installed.
- **You choose who to trust.** Updates can be *signed* by their author. You can tell your node to only
auto-install firmware signed by keys you've added.
- **It won't disrupt your mesh.** OTA traffic is always the **lowest priority** — your node only spends
spare airtime on it. Messages and routing always come first; a busy node simply updates later. Think of
it as *"eventually upgradable."*
- **It can recover.** If an install ever fails, the node falls back to a safe recovery mode (you can
re-flash a known-good firmware over USB) — it won't be left bricked.
---
## How to talk to your node
Connect to your node's **console** — usually a USB serial terminal at **115200 baud** (or whatever tool
you already use to manage the node). You type `ota ...` commands and the node replies in plain words.
The commands have short, friendly names (and most accept aliases, so you don't have to remember exact
spelling): type **`ota help`** any time to see the list, or just **`ota`** for a status summary.
---
## Common tasks
### 1. See what I'm running and whether anything is going on
```
ota status
```
Shows your current firmware version, your node's update "target" (its hardware/role id), and whether a
download is in progress.
### 2. Find updates available near me
```
ota ls
```
Your node asks around and lists the firmware updates other nodes nearby are offering, in plain words —
each with a **number**, its version, whether it's a full image or a small delta, how many nodes have it,
and how recently it was seen. For example:
```
Updates nearby (2 src) — `ota get <#>` to download:
1) v1.2.3 delta 3 nodes 5s
2) v1.2.0 full 1 node 12s [downloading]
```
Run it again after a few seconds — discovery happens in the background, so the list fills in. Nothing is
downloaded yet; this is just looking around. (`ota neighbors` / `ota updates` also work.)
### 3. Download an update
Pick one from the list by its **number**:
```
ota get 1
```
The node starts fetching it in the background, **at low priority**, a piece at a time — possibly from
several neighbours at once. Check progress any time with `ota status` (you'll see it climb, e.g.
`download: downloading 120/525 (23%)`). You can keep using your node normally meanwhile.
To **stop** a download you no longer want:
```
ota cancel
```
### 4. Install a downloaded update
Once `ota status` shows the download is **ready to install**:
```
ota install
```
The node verifies the firmware one last time, and if everything checks out it installs it and **reboots
into the new version**. If the check fails, it tells you why and does **not** install. (If you haven't
added the signer's key, an unsigned/untrusted image will only install with this explicit command — never
automatically.)
After it reboots, run `ota status` to confirm the new version.
### 5. If something goes wrong
- A download that stalls or gets interrupted just **resumes** later, or you can `ota cancel` and try again.
- If an **install** fails, the node won't boot a broken image — it lands in **recovery mode**:
- **RAK4631 / nRF52:** it appears as a USB drive; drag a known-good firmware `.uf2` onto it to recover.
- **ESP32:** it keeps the previous firmware in the other slot and rolls back.
- When in doubt, you can always re-flash over USB the normal way.
---
## Optional: let it update automatically
By default your node only *discovers* updates — it won't download or install on its own. If you want more
automation (e.g. for a remote node you can't easily reach), you can opt in. These settings are saved.
```
ota config autofetch any # auto-DOWNLOAD any compatible update for this node (still won't install)
ota config autofetch signed # auto-download only signed updates
ota config autofetch off # back to manual (default)
ota config autoinstall trusted # auto-INSTALL a downloaded update IF it's signed by a key you trust
ota config autoinstall off # never auto-install (default)
ota config # show the current settings
```
Recommended for most people: leave both **off** and update by hand. Use `autoinstall trusted` only once
you've added the signer's key (next section) and you trust them to push updates unattended.
---
## Optional: only trust updates from specific people
If you'll use auto-install, tell your node which signing keys to trust. The firmware author shares their
**public** key (a hex string); you add it:
```
ota key add <public-key-hex> # trust this signer
ota key list # show trusted signers
ota key rm <public-key-hex> # stop trusting one
```
Only updates signed by a trusted key are eligible for auto-install. Manual `ota install` still lets you
install anything yourself, on your own responsibility.
---
## Sharing updates with others (advanced)
### Relay a folder of firmware from a computer
If your node is connected to a computer (e.g. a gateway on a Raspberry Pi), it can **hand out** a whole
folder of firmware files to the mesh — without storing them itself. Useful for seeding a new release to a
remote area.
1. Put the firmware files (`.mota` files — see below) in a folder on the computer.
2. Run the helper daemon, pointing it at your node's USB port and the folder:
```
pip install pyserial
./tools/mota/mota_seeder.py --port /dev/ttyACM0 --dir ./my_firmware/ -v
```
It turns the relay on for you and then answers the node's requests. Your node now advertises those
updates to neighbours, who can `ota get` them like any other. (Details: [tools/mota/README.md](../tools/mota/README.md).)
To turn it off, stop the daemon (or run `ota folder off` on the node). `ota folder` on its own lists what
your node is currently offering.
### Everyone helps share
You don't have to be a gateway to help. Once **any** node finishes downloading an update, it automatically
offers it to *its* neighbours too. So a new firmware spreads outward node-to-node, instead of everyone
hammering the one node that had it first — and no node is ever overloaded, because all of this stays
lowest-priority.
---
## Where firmware files come from
OTA distributes **`.mota`** files — a packaged, verifiable firmware image (full image or a small "delta"
that only contains what changed). You get them by:
- **Downloading a build.** This fork publishes a rolling **`dev-latest`** release on GitHub with the
current firmware for many boards, each accompanied by a `.full.mota` and a tiny `.delta.mota`. Grab the
one for your board to test.
- **Building your own** with the `mota` packaging tool — see [tools/mota/README.md](../tools/mota/README.md)
(this is for people distributing updates, not everyday operators).
---
## Quick reference
| I want to… | Command |
|---|---|
| List all commands | `ota help` |
| See my firmware + any download | `ota status` (or just `ota`) |
| Find updates nearby | `ota ls` |
| Download update #1 | `ota get 1` |
| Cancel a download | `ota cancel` |
| Install a finished download | `ota install` |
| Turn on auto-download | `ota config autofetch any` |
| Turn on auto-install (trusted only) | `ota config autoinstall trusted` |
| Trust a signer | `ota key add <hex>` |
| Relay a folder (gateway) | `ota folder on` + the seeder daemon |
| List what I'm offering | `ota folder` |
(Older names still work too: `neighbors`/`updates` = `ls`, `pull` = `get`, `applydelta`/`apply` = `install`, `drop`/`stop` = `cancel`.)
---
## A few terms
- **Firmware** — the software running your node. Updating it can add features or fix bugs.
- **`.mota`** — a packaged firmware update file, with built-in integrity checks.
- **Target** — your node's hardware + role identity. Your node only auto-fetches updates built for the
same target, so it won't grab firmware meant for a different board.
- **Delta** — a small update containing only the changes from your current firmware (faster to send than a
full image). Your node rebuilds the complete firmware from it and verifies the result before installing.
- **Signed** — the update carries the author's cryptographic signature, so you can verify who made it.
For the full technical details (the file format and the radio protocol), see
[the OTA protocol spec](ota_protocol.md).
+2 -1
View File
@@ -45,7 +45,8 @@ static bool parse_manifest_fields(ByteReader& r, uint32_t signed_off, MotaManife
if (!r.ok) return false;
if (out.block_size_log2 == 0 || out.block_size_log2 > 24 || out.payload_size == 0) return false;
out.block_count = (out.payload_size + out.block_size() - 1) / out.block_size();
return out.block_count != 0;
// block_idx is uint16 on the wire; capping here also keeps block_count*4 (leaves length) from overflowing.
return out.block_count != 0 && out.block_count <= 0xFFFFu;
}
bool mota_parse(const uint8_t* buf, uint32_t len, MotaManifest& out) {
+102 -51
View File
@@ -28,9 +28,42 @@ static char fstate_char(OtaManager::FetchState s) {
}
}
static const char* codec_name(uint8_t c) {
return c == CODEC_FULL ? "full" : (c == CODEC_DETOOLS_SEQUENTIAL ? "seq"
: (c == CODEC_DETOOLS_INPLACE ? "inpl" : "?"));
// 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 "?";
}
}
// Decode the packed fw_version (MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre) into "v1.2.3" (or ".pre").
static void ver_str(char* out, size_t cap, uint32_t v) {
unsigned maj = v >> 24, min = (v >> 16) & 0xFF, pat = (v >> 8) & 0xFF, pre = v & 0xFF;
if (pre) snprintf(out, cap, "v%u.%u.%u.%u", maj, min, pat, pre);
else snprintf(out, cap, "v%u.%u.%u", maj, min, pat);
}
// 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
@@ -45,54 +78,68 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
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 (strncmp(a, "dev", 3) == 0 && (a[3] == 0 || a[3] == ' ')) {
const char* d = a + 3; while (*d == ' ') d++;
return handle_dev(d, reply, c);
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 ----
if (*a == 0 || strncmp(a, "status", 6) == 0) {
} 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 midhx[9]; strcpy(midhx, "-");
if (fs != OtaManager::IDLE) mesh::Utils::toHex(midhx, c.manager.fetchManifestId(), 4);
unsigned age = (fs != OtaManager::IDLE && c.session_started_ms) ? (unsigned)((millis() - c.session_started_ms) / 1000) : 0;
sprintf(reply, "OTA tgt=%08X fw=%s | self:%s%uK | sess:%c %u/%u mid=%s age=%us | serv:%s keys=%u",
(unsigned)board.getOtaTargetId(), selfhx, s ? "full " : "?",
(unsigned)((s ? fi.image_len : 0) / 1024), fstate_char(fs),
(unsigned)c.manager.blocksHave(), (unsigned)c.manager.blocksTotal(),
midhx, age, c.serving ? "on" : "off", (unsigned)c.allow.count());
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);
}
snprintf(reply, 160, "OTA | this fw %s (%uK) | %s | serving:%s (%u) | trusted keys:%u | target %08X",
selfhx, (unsigned)((s ? fi.image_len : 0) / 1024), dl,
c.serving ? "on" : "off", (unsigned)c.manager.servedCount(),
(unsigned)c.allow.count(), (unsigned)board.getOtaTargetId());
// ---- what's available around me (catalogued from beacons + OTA_HAVE), best/most-recent first ----
} else if (strncmp(a, "neighbors", 9) == 0 || strncmp(a, "nbrs", 4) == 0) {
} 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. The reply buffer is 160 B (serial / one LoRa packet for remote-admin) so writes are bounded.
// 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, "nbrs #:mid t=tgt codec seed age *=cur (src=%u)", (unsigned)c.manager.sourceCount());
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 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 < 60) { more++; continue; }
char midhx[9]; mesh::Utils::toHex(midhx, h->mid, 4);
if (CAP - n < 40) { more++; continue; }
bool on = cur && memcmp(cur, h->mid, 4) == 0;
uint32_t age = (now - h->last_ms) / 1000; if (age > 99999) age = 99999;
n += snprintf(reply + n, CAP - n, "\n %d:%s t=%08X %s seed=%u %us%s", shown + 1, midhx,
(unsigned)h->target_id, codec_name(h->codec), (unsigned)h->n_seeders,
(unsigned)age, on ? "*" : "");
char ver[20]; ver_str(ver, sizeof ver, h->fw_version);
n += snprintf(reply + n, CAP - n, "\n %d) %s %s %u node%s %us%s", shown + 1, ver,
codec_kind(h->codec), (unsigned)h->n_seeders, h->n_seeders == 1 ? "" : "s",
(unsigned)age, on ? " [downloading]" : "");
shown++;
}
if (more && n < CAP) snprintf(reply + n, CAP - n, "\n +%d more", more);
if (shown == 0) strcpy(reply, "nbrs: none yet — sources beacon periodically; re-run in a few s (queried now)");
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 (strncmp(a, "pull", 4) == 0 && (a[4] == 0 || a[4] == ' ')) {
const char* p = a + 4; while (*p == ' ') p++;
if (*p == 0) { strcpy(reply, "usage: ota pull <#|mid8> (see `ota neighbors`)"); return true; }
} 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);
@@ -101,7 +148,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
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 neighbor (see `ota neighbors`)"); return true; }
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();
@@ -110,7 +157,7 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
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 (strncmp(a, "drop", 4) == 0) {
} 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);
@@ -120,19 +167,19 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// ---- 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 (strncmp(a, "announce", 8) == 0) {
} 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 (strncmp(a, "self", 4) == 0) {
} 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);
sprintf(reply, "self body=%u image=%u base_hash=%s", (unsigned)fi.body_len, (unsigned)fi.image_len, hx);
} else if (strncmp(a, "applydelta", 10) == 0) {
} 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
@@ -151,8 +198,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
// ---- 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 (strncmp(a, "folder", 6) == 0) {
const char* p = a + 6; while (*p == ' ') p++;
} 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
@@ -176,8 +223,8 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
}
// ---- policy config (persisted via NodePrefs). conservative defaults: autofetch/autoinstall off ----
} else if (strncmp(a, "config", 6) == 0) {
const char* p = a + 6; while (*p == ' ') p++;
} 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
@@ -204,24 +251,28 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
(unsigned)c.manager.checkpoint_blocks(), (unsigned)c.allow.count());
}
// ---- trusted signer allowlist (security config; persisted) ----
} else if (strncmp(a, "key add ", 8) == 0) {
uint8_t pub[32];
if (mesh::Utils::fromHex(pub, 32, a + 8) && c.allow.add(pub)) { c.config_dirty = true; strcpy(reply, "OK key added (saved)"); }
else strcpy(reply, "ERR key");
} else if (strncmp(a, "key list", 8) == 0) {
int n = sprintf(reply, "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 += sprintf(reply + n, " %s", hx);
// ---- 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 if (strncmp(a, "key rm ", 7) == 0) {
uint8_t pub[32];
if (mesh::Utils::fromHex(pub, 32, a + 7) && c.allow.remove(pub)) { c.config_dirty = true; strcpy(reply, "OK removed (saved)"); }
else strcpy(reply, "ERR");
} else {
strcpy(reply, "ota: status|neighbors|announce|pull <#|mid>|drop|folder|config|self|applydelta|key|dev");
strcpy(reply, "Unknown OTA command. Type `ota help`.");
}
return true;
}
+19 -6
View File
@@ -432,10 +432,15 @@ void OtaManager::handleHave(const uint8_t* m, uint16_t n) {
if (slot < 0) {
slot = (_n_cat < OTA_MAX_CATALOG) ? _n_cat++ : lru;
_catalog[slot] = CatRow{};
memcpy(_catalog[slot].mid, mid, 4); memcpy(_catalog[slot].seeder0, hv.seeder_id, 4);
memcpy(_catalog[slot].mid, mid, 4);
memcpy(_catalog[slot].seeders[0], hv.seeder_id, 4);
_catalog[slot].n_seeders = 1;
} else if (memcmp(_catalog[slot].seeder0, hv.seeder_id, 4) != 0 && _catalog[slot].n_seeders < 255) {
_catalog[slot].n_seeders++; // another distinct source has it
} else {
CatRow& cc = _catalog[slot]; // count DISTINCT sources (no double-count)
bool known = false;
for (uint8_t k = 0; k < cc.n_seeders; k++)
if (memcmp(cc.seeders[k], hv.seeder_id, 4) == 0) { known = true; break; }
if (!known && cc.n_seeders < OTA_CAT_SEEDERS) memcpy(cc.seeders[cc.n_seeders++], hv.seeder_id, 4);
}
CatRow& c = _catalog[slot];
c.target_id = target; c.fw_version = fwver; c.codec = codec; c.flags = flags; c.last_ms = _now_ms;
@@ -465,7 +470,7 @@ void OtaManager::startFetch(const uint8_t* mid, uint32_t target) {
memcpy(_fid, mid, 4);
_rng = (rd_u32(_seeder_id) ^ rd_u32(_fid)) | 1u; // per-node block-pick/jitter sequence (distinct per node)
_fstate = WANT_MANIFEST;
_mf_total = 0; _mf_mask = 0; _mf_len = 0; // fresh manifest reassembly
_mf_total = 0; _mf_mask = 0; _mf_len = 0; _mf_retries = 0; // fresh manifest reassembly
GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4);
uint8_t b[16];
emit(b, encode_get_manifest(b, sizeof(b), gm), false);
@@ -497,6 +502,7 @@ void OtaManager::handleManifest(const uint8_t* m, uint16_t n) {
// a block must fit our reassembly buffer (and be non-empty) — reject an oversized block_size up front
if (bs == 0 || bs > OTA_MAX_BLOCK || payload_size == 0) { _fstate = FAILED; return; }
uint32_t bc = (payload_size + bs - 1) / bs;
if (bc > 0xFFFFu) { _fstate = FAILED; return; } // block_idx is uint16 on the wire — can't address more
memcpy(_froot, mf + 20, 4);
uint32_t leaves_off = 8 + mfl;
@@ -576,7 +582,12 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) {
return true;
}
_fstate = FETCHING; // resume fetching the holes
requestMissing();
// De-sync the first REQ exactly like a fresh fetch, so a coordinated reboot (whole site power-cycle)
// doesn't make every resuming node REQ in lockstep. loop() fires the first REQ once the hold elapses.
_rng = (rd_u32(_seeder_id) ^ rd_u32(_fid)) | 1u;
_req_hold_at = _now_ms + (rngNext() % OTA_REQ_SPREAD_MS);
_peer_req_block = 0xFFFFFFFFu; _peer_req_at = 0;
_loop_last_have = _have; _loop_last_mask = _reasm_mask; // "no progress yet" -> loop will request after the hold
return true;
}
@@ -726,7 +737,9 @@ void OtaManager::loop() {
sendQuery(_pq_seeder, _pq_digest, 0); // unfiltered: one broadcast HAVE serves everyone
}
if (_fstate == WANT_MANIFEST) {
// the MANIFEST reply may have been lost on a marginal link — retry GET_MANIFEST
// the MANIFEST reply may have been lost on a marginal link — retry GET_MANIFEST, but give up after a
// cap so an unreachable mid doesn't pin the single fetch slot (or emit) forever.
if (++_mf_retries > OTA_MANIFEST_MAX_RETRY) { _fstate = FAILED; return; }
GetManifestMsg gm; memcpy(gm.manifest_id, _fid, 4);
uint8_t b[16];
emit(b, encode_get_manifest(b, sizeof(b), gm), false);
+14 -6
View File
@@ -42,6 +42,9 @@ typedef bool (*ServeReadFn)(void* ctx, uint32_t off, uint8_t* buf, uint32_t len)
#ifndef OTA_MF_MAXFRAG
#define OTA_MF_MAXFRAG 4 // max manifest fragments (a signed v2 manifest is ~2)
#endif
#ifndef OTA_MANIFEST_MAX_RETRY
#define OTA_MANIFEST_MAX_RETRY 20 // give up (FAILED) after this many GET_MANIFEST retries — frees the slot
#endif
#ifndef OTA_MAX_SOURCES
#define OTA_MAX_SOURCES 12 // heard OTA sources (beacon senders) tracked (LRU); ~12 B each
#endif
@@ -204,12 +207,15 @@ public:
void on_message(const uint8_t* msg, uint16_t len); // feed one received OTA message
void loop(); // drive fetch (re-request missing blocks)
// Drop the current fetch session back to IDLE (so a fresh `ota pull` / advert starts a new one).
// Drop the current fetch session back to IDLE (so a fresh `ota pull` / advert starts a new one). Also
// stops re-seeding a previously-completed download — callers clear the staging store right after, so the
// re-seed view would otherwise advertise a mota we can no longer serve.
void reset_session() {
_fstate = IDLE; _have = 0; _req_count = 0;
_fstate = IDLE; _have = 0; _req_count = 0; _mf_retries = 0;
_reasm_block = 0xFFFFFFFFu; _reasm_mask = 0; _reasm_need = 0; _awaiting_proof = false;
_loop_last_have = 0; _loop_last_mask = 0;
_mf_total = 0; _mf_mask = 0; _mf_len = 0;
unserveFetched();
}
FetchState fetchState() const { return _fstate; }
@@ -218,13 +224,14 @@ public:
const uint8_t* fetchManifestId() const { return _fid; }
// --- discovery catalog (for `ota neighbors`): mOTAs heard around us via OTA_HAVE, deduped by mid ---
static const uint8_t OTA_CAT_SEEDERS = 4; // distinct sources tracked per catalog row (for "N nodes have it")
struct CatRow {
uint8_t mid[4];
uint32_t target_id, fw_version;
uint8_t codec, flags;
uint8_t seeder0[4]; // first source that advertised this mid
uint8_t n_seeders; // distinct sources advertising it (saturates) — "N nodes have it"
uint32_t have_max; // best block-count any source reported (== total when a full copy exists)
uint8_t seeders[OTA_CAT_SEEDERS][4]; // distinct sources advertising this mid (deduped; capped)
uint8_t n_seeders; // count of the above (capped at OTA_CAT_SEEDERS) — "N+ nodes have it"
uint32_t have_max; // best block-count any source reported (== total when a full copy exists)
uint32_t last_ms;
};
uint8_t catalogCount() const { return _n_cat; }
@@ -319,7 +326,8 @@ private:
uint16_t _loop_last_mask = 0; // fragment-level stall detection in loop()
uint8_t _reasm_buf[OTA_MAX_BLOCK];
// multi-fragment manifest reassembly (a signed v2 manifest exceeds one packet)
uint8_t _mf_buf[256];
uint8_t _mf_buf[OTA_MF_MAXFRAG * OTA_MF_FRAG]; // sized to the fragment cap so no valid manifest is silently dropped
uint16_t _mf_retries = 0; // GET_MANIFEST retries while WANT_MANIFEST (give up after a cap)
uint8_t _mf_total = 0; // frag_total of the manifest being reassembled (0 = none)
uint16_t _mf_mask = 0; // received manifest-fragment bitmap
uint32_t _mf_len = 0; // assembled manifest length (set by the last fragment)
+83
View File
@@ -70,6 +70,24 @@ TEST(OtaParse, RejectsTampering) {
EXPECT_FALSE(mota_parse(b.data(), b.size(), m));
}
// block_idx is a uint16 on the wire, so a manifest needing > 65535 blocks can't be addressed and must be
// rejected at parse (this also keeps block_count*4 from overflowing the leaves-length computation).
TEST(OtaParse, RejectsTooManyBlocks) {
auto manifest = [](uint32_t payload_size, uint8_t bsl) { // minimal unsigned-full manifest (93 B)
std::vector<uint8_t> m(93, 0);
m[0] = MOTA_FORMAT_VER; m[1] = MFLAG_FULL; m[2] = 0x12;
m[15] = payload_size; m[16] = payload_size >> 8; m[17] = payload_size >> 16; m[18] = payload_size >> 24;
m[19] = bsl;
return m;
};
MotaManifest mm;
auto over = manifest(65536u * 1024u, 10); // 65536 blocks -> rejected
EXPECT_FALSE(mota_parse_manifest(over.data(), over.size(), mm));
auto ok = manifest(65535u * 1024u, 10); // 65535 blocks -> allowed
EXPECT_TRUE(mota_parse_manifest(ok.data(), ok.size(), mm));
EXPECT_EQ(mm.block_count, 65535u);
}
TEST(OtaMerkle, RootMatchesVectorAndLeaves) {
MotaManifest m;
ASSERT_TRUE(mota_parse(MOTA_VEC, MOTA_VEC_LEN, m));
@@ -752,6 +770,71 @@ TEST(OtaTransfer, RejectsIncompatibleCodec) {
g_q.clear();
}
// Encode a 1-row OTA_HAVE from a specific seeder, carrying have_count (Phase-2 awareness).
static uint16_t make_have_row(uint8_t* buf, uint16_t cap, const uint8_t mid[4], uint32_t target,
uint32_t fwver, uint8_t codec, uint8_t flags,
const uint8_t seeder[4], uint16_t have_count) {
uint8_t row[OTA_HAVE_ROW_BYTES];
memcpy(row, mid, 4);
row[4]=target; row[5]=target>>8; row[6]=target>>16; row[7]=target>>24;
row[8]=fwver; row[9]=fwver>>8; row[10]=fwver>>16; row[11]=fwver>>24;
row[12]=codec; row[13]=flags;
row[14]=(uint8_t)(have_count & 0xFF); row[15]=(uint8_t)(have_count >> 8);
HaveMsg hv; memcpy(hv.seeder_id, seeder, 4); memset(hv.set_digest, 0, 4);
hv.frag_idx=0; hv.frag_total=1; hv.n_rows=1; hv.rows=row;
return encode_have(buf, cap, hv);
}
// Catalog accounting: "N nodes have it" must count DISTINCT seeders (a repeated HAVE from one node must
// not inflate it), and have_max tracks the best progress any source reported.
TEST(OtaCatalog, DistinctSeederCountAndHaveCount) {
OtaManager m; SendTo none{&m}; m.begin(SIM_TARGET_ID, sim_send, &none);
uint8_t b[64]; uint8_t mid[4]={9,9,9,9};
uint8_t s1[4]={1,0,0,0}, s2[4]={2,0,0,0};
m.on_message(b, make_have_row(b, sizeof b, mid, SIM_TARGET_ID, 0x01020300, CODEC_FULL, 0, s1, 5));
m.on_message(b, make_have_row(b, sizeof b, mid, SIM_TARGET_ID, 0x01020300, CODEC_FULL, 0, s1, 7)); // same seeder
ASSERT_EQ(m.catalogCount(), 1);
EXPECT_EQ(m.catalogRow(0)->n_seeders, 1); // counted once despite two HAVEs
EXPECT_EQ(m.catalogRow(0)->have_max, 7u); // max progress seen
m.on_message(b, make_have_row(b, sizeof b, mid, SIM_TARGET_ID, 0x01020300, CODEC_FULL, 0, s2, 3)); // new seeder
EXPECT_EQ(m.catalogRow(0)->n_seeders, 2);
EXPECT_EQ(m.catalogRow(0)->have_max, 7u); // still the max, not overwritten by the lower one
g_q.clear();
}
// An unanswered GET_MANIFEST must not pin the fetch slot forever: after OTA_MANIFEST_MAX_RETRY ticks with
// no manifest, the session gives up (FAILED) so a new pull can take the slot. (Lowest-priority + bounded.)
TEST(OtaTransfer, ManifestGiveUpAfterRetries) {
g_q.clear();
OtaManager client; OtaStoreRam<4096> store; SendTo to_none{&client};
client.begin(SIM_TARGET_ID, sim_send, &to_none);
client.set_fetch_store(&store);
uint8_t mid[4]={7,7,7,7};
client.pull(mid, SIM_TARGET_ID); // no server -> stuck WANT_MANIFEST
EXPECT_EQ(client.fetchState(), OtaManager::WANT_MANIFEST);
for (int i = 0; i < OTA_MANIFEST_MAX_RETRY + 2; i++) { g_clk += 5000; client.set_clock(g_clk); client.loop(); g_q.clear(); }
EXPECT_EQ(client.fetchState(), OtaManager::FAILED);
}
// Re-seeding a completed download must stop when the session is dropped (the staging store is cleared right
// after), so the node never advertises a mota it can no longer serve.
TEST(OtaSwarm, ReSeedStopsAfterDrop) {
g_q.clear();
OtaManager server, client; OtaStoreRam<4096> store;
SendTo to_client{&client}, to_server{&server};
server.begin(0, sim_send, &to_client);
client.begin(SIM_TARGET_ID, sim_send, &to_server);
client.set_fetch_store(&store);
client.set_autofetch(OtaManager::AUTOFETCH_ANY);
ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN));
server.announce();
pump(client);
ASSERT_EQ(client.fetchState(), OtaManager::COMPLETE);
EXPECT_EQ(client.servedCount(), 1); // re-seeding the completed download to peers
client.reset_session();
EXPECT_EQ(client.servedCount(), 0); // dropped -> stops advertising it
}
// --- detools delta decode (vendored detools C decoder, CRLE-only build) ----------------------
// Mirrors the device apply path (src/helpers/ota/OtaApply.cpp): base read via from_read/from_seek,
// patch streamed via patch_read, output written via to_write. Proves the on-device delta apply uses