Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ compile_commands.json
.venv/
venv/
platformio.local.ini

__pycache__/
7 changes: 7 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,15 @@ build_firmware() {
# e.g: RAK_4631_Repeater-v1.0.0-SHA
FIRMWARE_FILENAME="$1-${FIRMWARE_VERSION_STRING}"

# OTA target id = sha2-256:4(env_name) as a little-endian uint32 (matches tools/mota target_id_for_env
# and the device's MainBoard::getOtaTargetId()). Harmless when OTA is disabled.
MOTA_TARGET_ID=$(python3 -c "import hashlib,sys;print('0x%08x'%int.from_bytes(hashlib.sha256(sys.argv[1].encode()).digest()[:4],'little'))" "$1" 2>/dev/null || echo "")

# add firmware version info to end of existing platformio build flags in environment vars
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DFIRMWARE_BUILD_DATE='\"${FIRMWARE_BUILD_DATE}\"' -DFIRMWARE_VERSION='\"${FIRMWARE_VERSION_STRING}\"'"
if [ -n "$MOTA_TARGET_ID" ]; then
export PLATFORMIO_BUILD_FLAGS="${PLATFORMIO_BUILD_FLAGS} -DMOTA_TARGET_ID=${MOTA_TARGET_ID}"
fi

# disable debug flags if requested
disable_debug_flags
Expand Down
600 changes: 600 additions & 0 deletions docs/ota_protocol.md

Large diffs are not rendered by default.

241 changes: 241 additions & 0 deletions docs/ota_user_guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
# 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 [yours] 3n 5s
2) v1.2.0 full [other hw] 1n 12s [downloading]
```

Each row shows the version, full-vs-delta, **whether it fits your node**, how many nodes have it, and how
long ago it was seen. The fit marker:

- **[yours]** β€” built for your exact hardware **and** role; safe to install.
- **[other hw]** β€” a different board or role (e.g. a companion image, or another board). Don't install it.
- **[?]** β€” can't tell (a build with no target id set, e.g. a bare IDE build rather than a release build).

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 advert 1440 # re-advertise this node every N minutes (default 1440 = 24h)
ota config advert 0 # disable periodic re-advertise (still advertises briefly at boot)

ota config hops 3 # how far OTA travels: accept from / relay up to N repeater hops (default 3)
ota config hops 0 # only exchange OTA with directly-connected nodes (never relay)

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. Build the helper tool once (`tools/motatool/`), then point it at your node and the folder β€” over the
node's **USB serial**, or over **WiFi** if it's an ESP32 companion on your network:
```
cmake -S tools/motatool -B tools/motatool/build && cmake --build tools/motatool/build
# over USB serial:
./tools/motatool/build/motatool serve --dir ./my_firmware/ --serial /dev/ttyACM0 -v
# …or over WiFi (ESP32 companion): the seeder is on a DEDICATED port (5001), separate from the
# phone-app port (5000), so a phone can stay connected while you serve:
./tools/motatool/build/motatool serve --dir ./my_firmware/ --tcp 192.168.1.50:5001 -v
```
It answers the node's requests; your node then advertises those updates to neighbours, who can
`ota get` them like any other. (A WiFi node prints its IP + seeder port to the serial log on connect.
Details: [tools/motatool/README.md](../tools/motatool/README.md).)

To stop, just stop the daemon β€” over WiFi the node auto-detaches when the connection closes; over USB you
can also run `ota folder off` on the node. `ota folder` on its own lists what your node is 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 changes: 1 addition & 1 deletion examples/companion_radio/MyMesh.h
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
#endif

#ifndef FIRMWARE_VERSION
#define FIRMWARE_VERSION "v1.16.0"
#define FIRMWARE_VERSION "v1.17.0"
#endif

#if defined(NRF52_PLATFORM) || defined(STM32_PLATFORM)
Expand Down
92 changes: 91 additions & 1 deletion examples/companion_radio/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,81 @@ void halt() {
unsigned long last_wifi_reconnect_attempt = 0;
#endif

/* WIFI OTA SEEDER β€” relay a host folder of .mota over WiFi (motatool `serve --tcp`), on a DEDICATED port
separate from the companion (TCP_PORT), so a phone app stays connected while motatool feeds updates. */
#if defined(ESP32) && defined(WIFI_SSID) && defined(ENABLE_OTA)
#include <helpers/ota/OtaContext.h>
#include <helpers/ota/MotaSourceSerial.h>
#ifndef OTA_SEEDER_TCP_PORT
#define OTA_SEEDER_TCP_PORT 5001
#endif
static WiFiServer ota_seeder_server(OTA_SEEDER_TCP_PORT);
static WiFiClient ota_seeder_client; // the live seeder connection (reused)
static mesh::ota::SerialMotaSource ota_seeder_source(ota_seeder_client, 3000);
static bool ota_seeder_attached = false;

// Accept one motatool connection at a time; while connected, register its folder as a serve source so
// the node advertises + relays it over LoRa. Drop the source the moment the connection closes.
static void ota_seeder_loop() {
if (ota_seeder_client && ota_seeder_client.connected()) return; // still serving the current client
if (ota_seeder_attached) { // previous client just disconnected
mesh::ota::ota_ctx().detach_folder();
mesh::ota::ota_ctx().manager.announce(); // served set shrank back to our own fw -> re-advertise
ota_seeder_attached = false;
WIFI_DEBUG_PRINTLN("OTA seeder: client disconnected, relay stopped");
}
WiFiClient c = ota_seeder_server.available();
if (c) {
ota_seeder_client = c; // rebind the persistent Stream to it
if (mesh::ota::ota_ctx().manager.add_source(&ota_seeder_source)) {
ota_seeder_attached = true;
mesh::ota::ota_ctx().manager.announce(); // new served set -> advertise the folder's fw to peers
WIFI_DEBUG_PRINTLN("OTA seeder: client connected, relaying its folder");
} else {
ota_seeder_client.stop(); // no free source slot
}
}
}
#endif

/* WIFI OTA CONSOLE β€” a tiny text CLI for OTA over WiFi. A WiFi companion has no serial text console, so
without this its OTA is only reachable through the phone app. Connect with e.g. `nc <ip> 5002` and type
`ota status` / `ota ls` / `ota announce` / ... β€” one client at a time, on a DEDICATED port separate from
the companion (5000) and the seeder (5001). */
#if defined(ESP32) && defined(WIFI_SSID) && defined(ENABLE_OTA)
#include <helpers/ota/OtaCli.h> // mesh::ota::handle_ota_command(line, reply, board)
#ifndef OTA_CONSOLE_TCP_PORT
#define OTA_CONSOLE_TCP_PORT 5002
#endif
static WiFiServer ota_console_server(OTA_CONSOLE_TCP_PORT);
static WiFiClient ota_console_client;
static char ota_console_line[128];
static uint8_t ota_console_len = 0;

static void ota_console_loop() {
if (!ota_console_client || !ota_console_client.connected()) {
WiFiClient c = ota_console_server.available();
if (c) { ota_console_client = c; ota_console_len = 0;
ota_console_client.print("OTA console β€” type `ota ...` (e.g. ota status / ota ls / ota announce)\r\n> "); }
return;
}
while (ota_console_client.available()) {
char ch = (char)ota_console_client.read();
if (ch == '\r' || ch == '\n') {
if (ota_console_len == 0) continue; // ignore blanks / the CRLF pair
ota_console_line[ota_console_len] = 0;
char reply[160]; reply[0] = 0;
if (!mesh::ota::handle_ota_command(ota_console_line, reply, board))
strcpy(reply, "only `ota ...` commands are supported on this console");
ota_console_client.print(" -> "); ota_console_client.print(reply); ota_console_client.print("\r\n> ");
ota_console_len = 0;
} else if (ota_console_len < sizeof(ota_console_line) - 1) {
ota_console_line[ota_console_len++] = ch;
}
}
}
#endif

void setup() {
Serial.begin(115200);

Expand Down Expand Up @@ -208,13 +283,24 @@ void setup() {
WIFI_DEBUG_PRINTLN("WiFi disconnected. Flagging for reconnect...");
wifi_needs_reconnect = true;
} else if (event == ARDUINO_EVENT_WIFI_STA_GOT_IP) {
WIFI_DEBUG_PRINTLN("WiFi connected successfully!");
WIFI_DEBUG_PRINTLN("connected! IP %s (companion app on :%d)",
WiFi.localIP().toString().c_str(), TCP_PORT);
wifi_needs_reconnect = false;
}
});

WiFi.begin(WIFI_SSID, WIFI_PWD);
// Disable WiFi modem power-save: its periodic modem/light-sleep stalls the SX1262 SPI+DIO servicing,
// which makes the LoRa radio go deaf (no TX/RX) while WiFi is associated. Required for LoRa+WiFi to
// coexist on this ESP32 β€” the small extra idle current is well worth a working radio.
WiFi.setSleep(false);
serial_interface.begin(TCP_PORT);
#ifdef ENABLE_OTA
ota_seeder_server.begin(); // dedicated OTA seeder port for `motatool serve --tcp` (relay over LoRa)
WIFI_DEBUG_PRINTLN("OTA seeder listening on :%d (motatool serve --tcp)", OTA_SEEDER_TCP_PORT);
ota_console_server.begin(); // dedicated OTA text-console port (`nc <ip> 5002` -> `ota ...`)
WIFI_DEBUG_PRINTLN("OTA console listening on :%d (nc <ip> %d, type `ota ...`)", OTA_CONSOLE_TCP_PORT, OTA_CONSOLE_TCP_PORT);
#endif
#elif defined(BLE_PIN_CODE)
serial_interface.begin(BLE_NAME_PREFIX, the_mesh.getNodePrefs()->node_name, the_mesh.getBLEPin());
#elif defined(SERIAL_RX)
Expand Down Expand Up @@ -257,6 +343,10 @@ void loop() {
}

#if defined(ESP32) && defined(WIFI_SSID)
#ifdef ENABLE_OTA
ota_seeder_loop(); // accept/drop a motatool `serve --tcp` connection on the dedicated seeder port
ota_console_loop(); // service the OTA text console (port 5002)
#endif
// Safely attempt to reconnect every 10 seconds if flagged
if (wifi_needs_reconnect && (millis() - last_wifi_reconnect_attempt > 10000)) {
WIFI_DEBUG_PRINTLN("Attempting manual WiFi reconnect...");
Expand Down
Loading