target_id human readable + motatool

This commit is contained in:
Valentin Kivachuk Burda committed 2026-06-29 01:46:29 +02:00
1 parent e7f05eec52
commit 40d1d29a55
20 files changed
+914 -263

No files matched your search

+9 -23
View File
@@ -1,9 +1,10 @@
# Rolling DEV firmware release (fork convenience).
#
# On every push, rebuild a representative set of OTA-capable firmwares (ESP32 + nRF52, one per board family
# plus the RAK4631 / Heltec V3 test boards in every role) and replace the `dev-latest` prerelease assets.
# Each firmware ships a full + same-image-delta `.mota` so the OTA format/transport can be exercised per
# board. UNSIGNED dev builds for testing only.
# plus the RAK4631 / Heltec V3 test boards in every role) and replace the `dev-latest` prerelease assets
# with the flashable binaries (ESP32: .bin/-merged.bin; nRF52: .uf2/.zip). UNSIGNED dev builds for testing.
# OTA `.mota` are not packaged here — build them on demand from a firmware with tools/motatool (which reads
# a .bin or nRF52 .hex directly).
#
# Design (atomic, never-empty): each board builds into a workflow ARTIFACT; a final `release` job collects
# them and ATOMICALLY recreates the release at the end. So if the run is cancelled by the next push, or some
@@ -80,23 +81,8 @@ jobs:
- name: Build ${{ matrix.env }}
run: /usr/bin/env bash build.sh build-firmware ${{ matrix.env }}
- name: Package .mota (full + same-image delta)
run: |
set -euo pipefail
pip install --quiet detools intelhex # detools: delta encoder (motatool shells to it); intelhex: nRF52 .hex extraction
# build the motatool packager (C++; OpenSSL for sha256+ed25519)
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libssl-dev cmake g++ >/dev/null
cmake -S tools/motatool -B tools/motatool/build -DCMAKE_BUILD_TYPE=Release >/dev/null
cmake --build tools/motatool/build -j"$(nproc)" >/dev/null
SHA=$(git rev-parse --short HEAD)
python3 tools/mota/dev_motas.py \
--env "${{ matrix.env }}" --platform "${{ matrix.platform }}" \
--build-dir ".pio/build/${{ matrix.env }}" --target-env "${{ matrix.env }}" \
--out-prefix "out/${{ matrix.env }}-${FIRMWARE_VERSION}-${SHA}" --work /tmp \
--motatool "$PWD/tools/motatool/build/motatool" \
|| echo "::warning::.mota packaging failed for ${{ matrix.env }}"
# build.sh writes the flashable firmware into out/ (ESP32: .bin + -merged.bin; nRF52: .uf2 + .zip).
# OTA .mota are NOT packaged in CI — build them locally with tools/motatool when needed.
- name: Upload this board's artifact
uses: actions/upload-artifact@v7
@@ -149,9 +135,9 @@ jobs:
⚠️ Unsigned dev builds for testing only. Assets are replaced on every push (this release always
tracks the latest commit). Embedded firmware version string: \`dev-<short-sha>\`.
A representative set of OTA-capable boards (ESP32 + nRF52). Each firmware ships a \`.full.mota\`
(the flashable image) and a \`.delta.mota\` (a same-image patch — intentionally tiny — to exercise
the delta path: sequential on ESP32, in-place on nRF52/RAK4631).
A representative set of OTA-capable boards (ESP32 + nRF52): flashable firmware binaries
(ESP32 \`.bin\`/\`-merged.bin\`, nRF52 \`.uf2\`/\`.zip\`). To make an OTA \`.mota\` from one of
these, use \`tools/motatool\` (it reads a \`.bin\` or nRF52 \`.hex\` directly).
EOF
)
# delete + recreate so removed boards don't leave stale assets; assets are ready, so it's atomic
+5
View File
@@ -442,6 +442,11 @@ All serving stays reactive and lowest-priority, so seeding never competes with r
- **`target_id` vs `hw_id`** — complementary, not redundant: `target_id` is the fetch-routing key
(hw + role + partition); `hw_id` is the human-readable brick-safety key (hardware only). Same board, two
roles ⇒ same `hw_id`, different `target_id`.
- **Naming a `target_id` locally:** only the 4-byte `target_id` ever travels on the wire. To show *which*
board/role a target is, a node (and `motatool`) reverse-looks-it-up in `src/helpers/ota/OtaTargets.h` —
a generated `target_id → env-name` table covering every `ENABLE_OTA` env (`tools/mota/gen_targets.py`,
resolved from `pio project config`). So `ota ls` can render `[Heltec_v3_repeater]` for a neighbour's
beacon without the string being transmitted. Unknown ids show as `other hw` / `N/A`.
- **`fw_version`:** packed comparable uint32 (`MAJOR<<24 | MINOR<<16 | PATCH<<8 | pre`); also self-described
in EndF. `ota ls` decodes it for display and flags each update `[yours]` / `[other hw]` / `[?]` by
comparing the advertised `target_id` to the node's own.
+21
View File
@@ -20,6 +20,7 @@
#include "OtaVerify.h"
#include "OtaSelf.h"
#include "OtaFlashLayout_nrf52.h"
#include "OtaBlInfo.h" // read the bootloader capability marker before arming an apply
#include "flash/flash_nrf5x.h" // Adafruit core internal-flash driver (has its own extern "C")
#include "nrf.h"
#include "nrf_soc.h"
@@ -426,6 +427,13 @@ void ota_reboot_to_apply() { // public: set the apply magic +
NVIC_SystemReset(); // does not return
}
uint8_t ota_bootloader_last_rc() { // bootloader's last in-place-apply code, stashed in GPREGRET2
uint32_t v = 0;
uint8_t en = 0; sd_softdevice_is_enabled(&en);
if (en) sd_power_gpregret_get(1, &v); else v = NRF_POWER->GPREGRET2;
return (uint8_t)v;
}
bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlist& allow,
ApplyState& st, char* msg) {
st = ApplyState();
@@ -436,6 +444,18 @@ bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len, const SignerAllowlis
memcpy(st.image_hash, m.image_hash, 32);
st.manifest_ok = true;
// 0) THIS device's bootloader must be able to apply this .mota — otherwise staging + approving + rebooting
// just bounces back unchanged (a legacy/stock/older-OTAFIX bootloader). Refuse here, before any reboot.
{
OtaBlCaps bl = ota_bootloader_caps();
if (!bl.present) { strcpy(msg, "this bootloader has no OTA-apply support — update the bootloader first"); return false; }
if (bl.apply_abi < m.format_ver || !(bl.codec_mask & (1u << m.codec_id))) {
snprintf(msg, 159, "bootloader too old to apply this update (bl abi=%u codecs=0x%x; need fmt>=%u codec=%u) — update the bootloader",
bl.apply_abi, bl.codec_mask, m.format_ver, m.codec_id);
return false;
}
}
// Gated verification, in order, returning the FIRST failing reason (the bootloader re-checks integrity
// again before booting, so authenticity is gated here and re-validated there):
VerifyResult vr = ota_verify(buf, len, allow);
@@ -481,6 +501,7 @@ bool ota_apply_commit() { return false; }
bool ota_apply_detools_mota(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "unsupported"); return false; }
bool ota_apply_mota_nrf52(const uint8_t*, uint32_t, const SignerAllowlist&, ApplyState& st, char* msg) { st = ApplyState(); strcpy(msg, "unsupported"); return false; }
void ota_reboot_to_apply() {}
uint8_t ota_bootloader_last_rc() { return 0; }
#endif
+4
View File
@@ -58,5 +58,9 @@ bool ota_apply_mota_nrf52(const uint8_t* buf, uint32_t len,
// into the slot already armed by ota_apply_detools_mota.
void ota_reboot_to_apply();
// DIAGNOSTIC (nRF52): the bootloader stashes its last in-place-apply bail/progress code in GPREGRET2 (see
// ota_delta.c). Read it back so `ota status` can show why an apply didn't take. 0 / other platforms = n/a.
uint8_t ota_bootloader_last_rc();
} // namespace ota
} // namespace mesh
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <stdint.h>
#include <string.h>
// Read the bootloader's OTA capability marker so the app can decide, BEFORE staging+approving+rebooting,
// whether THIS device's bootloader can actually apply a `.mota`. Without this the app would reboot into a
// bootloader that silently can't apply (stock Adafruit, or an OLDER OTAFIX predating a `.mota` format
// change) and the device would just come back up unchanged.
//
// Mirror of Adafruit_nRF52_Bootloader_OTAFIX/src/ota_bl_info.h — keep byte-identical.
// nRF52 only (the bootloader flash is memory-mapped + readable by the app); a no-op elsewhere.
#if defined(NRF52_PLATFORM)
#include "OtaFlashLayout_nrf52.h"
#endif
namespace mesh {
namespace ota {
// 16-byte marker: magic[8] "MOTABLDR" + apply_abi(2) + codec_mask(2) + reserved(4).
static const uint8_t OTA_BL_MAGIC[8] = { 'M','O','T','A','B','L','D','R' };
struct OtaBlCaps {
bool present = false;
uint16_t apply_abi = 0; // max .mota format_ver the bootloader can apply
uint16_t codec_mask = 0; // bit i set => can apply codec_id i (in-place delta = bit 2)
};
// Scan the bootloader flash region for the marker. Returns {present=false} if not found / non-nRF52.
inline OtaBlCaps ota_bootloader_caps() {
OtaBlCaps c;
#if defined(NRF52_PLATFORM)
const uint8_t* lo = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_START;
const uint8_t* hi = (const uint8_t*)(uintptr_t)MOTA_NRF52_BL_END;
for (const uint8_t* p = lo; p + 16 <= hi; p++) {
if (p[0] != OTA_BL_MAGIC[0] || memcmp(p, OTA_BL_MAGIC, 8) != 0) continue;
c.present = true;
c.apply_abi = (uint16_t)(p[8] | ((uint16_t)p[9] << 8));
c.codec_mask = (uint16_t)(p[10] | ((uint16_t)p[11] << 8));
break;
}
#endif
return c;
}
// True if this device's bootloader can apply a .mota of the given format_ver + codec_id.
inline bool ota_bootloader_can_apply(uint8_t format_ver, uint8_t codec_id) {
OtaBlCaps c = ota_bootloader_caps();
return c.present && c.apply_abi >= format_ver && (c.codec_mask & (1u << codec_id)) != 0;
}
} // namespace ota
} // namespace mesh
+27 -5
View File
@@ -2,6 +2,10 @@
#include "OtaContext.h"
#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>
@@ -107,10 +111,17 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
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 : "?";
snprintf(reply, 160, "OTA | this fw %s (%uK) hw=%s | %s | serving:%s (%u) | keys:%u | target:%08X",
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());
(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
// ---- 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)) {
@@ -130,8 +141,13 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
bool on = cur && memcmp(cur, h->mid, 4) == 0;
uint32_t age = (now - h->last_ms) / 1000; if (age > 99999) age = 99999;
char ver[20]; ver_str(ver, sizeof ver, h->fw_version);
// is this update for THIS node (same hw+role)? '?' when either target id is unset (e.g. a manual build)
const char* fit = (myt == 0 || h->target_id == 0) ? "?" : (h->target_id == myt ? "yours" : "other hw");
// What is this update for? "yours" if same 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 other hw / '?'.
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 fit = (h->target_id == 0) ? "?" : "other hw";
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,
on ? " [downloading]" : "");
@@ -181,7 +197,13 @@ bool handle_ota_command(const char* command, char* reply, mesh::MainBoard& board
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);
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
+10
View File
@@ -8,6 +8,7 @@
#include "OtaApply.h"
#include "OtaFormat.h"
#include "OtaSelf.h" // ota_self_firmware() — prefer self-describing EndF identity at begin()
#include "OtaBlInfo.h" // bootloader OTA-apply capability marker (nRF52); cached after first read
#if defined(NRF52_PLATFORM) && defined(OTA_FLASH_STORE)
#include "OtaStoreFlashNrf52.h"
#elif defined(ESP32_PLATFORM) && defined(OTA_FLASH_STORE)
@@ -120,6 +121,15 @@ struct OtaContext {
uint32_t apply_at = 0; // earliest reboot time (lets the reply get queued + start sending)
uint32_t apply_hard = 0; // hard cap, in case the TX queue never idles on a busy node
// Bootloader OTA-apply capability (nRF52): can THIS device's bootloader apply a .mota? Read from flash
// ONCE (the scan is ~40 KB) and cached in RAM. On other platforms present=false (apply is in-app).
OtaBlCaps _bl_caps;
bool _bl_caps_read = false;
const OtaBlCaps& bootloaderCaps() {
if (!_bl_caps_read) { _bl_caps = ota_bootloader_caps(); _bl_caps_read = true; }
return _bl_caps;
}
// --- discovery: the "what mOTAs are available around me" view ----------------------------------
// The catalog (heard mOTAs) + the heard-sources table now live in OtaManager (built from beacons +
// OTA_HAVE catalog replies, the two-tier discovery). `ota neighbors` renders manager.catalogRow();
+6
View File
@@ -29,5 +29,11 @@ static const uint8_t GPREGRET_OTA_APPLY = 0x6Au; // distinct from DFU
// also bounds writes to < the (scanned) mota start, so a mis-sized memory still fails safe.
static const uint32_t MOTA_NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB (APP_BASE .. 0xBE000)
// Bootloader flash region (nRF52840: 39 KB ending just below the CF2/MBR-params pages). The app scans
// this for the bootloader capability marker (OtaBlInfo.h) to know whether THIS device's bootloader can
// actually apply a .mota before staging+approving+rebooting.
static const uint32_t MOTA_NRF52_BL_START = 0x000F4000u;
static const uint32_t MOTA_NRF52_BL_END = 0x000FE000u;
} // namespace ota
} // namespace mesh
+328
View File
@@ -0,0 +1,328 @@
#pragma once
#include <stdint.h>
// AUTO-GENERATED by tools/mota/gen_targets.py — do not edit by hand.
// 308 OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32)
// to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT
// transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes.
namespace mesh { namespace ota {
// target_id -> env name, or nullptr if unknown. Linear scan (table is small; lookups are rare).
inline const char* ota_target_env_name(uint32_t target_id) {
static const struct { uint32_t id; const char* env; } T[] = {
{ 0x149bcb23, "Ebyte_EoRa-S3_companion_radio_ble" },
{ 0x30d4e442, "Ebyte_EoRa-S3_companion_radio_usb" },
{ 0x263a8d80, "Ebyte_EoRa-S3_kiss_modem" },
{ 0x9070ae2c, "Ebyte_EoRa-S3_Repeater" },
{ 0xf190d834, "Ebyte_EoRa-S3_room_server" },
{ 0x32890982, "Ebyte_EoRa-S3_terminal_chat" },
{ 0xacb599d5, "GAT562_30S_Mesh_Kit_companion_radio_ble" },
{ 0x82d3b273, "GAT562_30S_Mesh_Kit_companion_radio_usb" },
{ 0xe96a6af8, "GAT562_30S_Mesh_Kit_kiss_modem" },
{ 0x70d8478d, "GAT562_30S_Mesh_Kit_repeater" },
{ 0xf9986af5, "GAT562_30S_Mesh_Kit_room_server" },
{ 0x98519a1d, "GAT562_Mesh_EVB_Pro_kiss_modem" },
{ 0x06b71719, "GAT562_Mesh_EVB_Pro_repeater" },
{ 0x4c0461c8, "GAT562_Mesh_EVB_Pro_room_server" },
{ 0x4fa88992, "GAT562_Mesh_Tracker_Pro_companion_radio_ble" },
{ 0xcd6604d0, "GAT562_Mesh_Tracker_Pro_companion_radio_usb" },
{ 0xc54ea258, "GAT562_Mesh_Tracker_Pro_kiss_modem" },
{ 0x0b3985a8, "GAT562_Mesh_Tracker_Pro_repeater" },
{ 0x03d9704a, "GAT562_Mesh_Tracker_Pro_room_server" },
{ 0x8456f753, "GAT562_Mesh_Watch13_companion_radio_ble" },
{ 0xc8104197, "GAT562_Mesh_Watch13_kiss_modem" },
{ 0xf5eb61ec, "Generic_E22_kiss_modem" },
{ 0x70d2d590, "Generic_E22_sx1262_repeater" },
{ 0xe1a7ffdf, "Generic_E22_sx1262_repeater_bridge_espnow" },
{ 0xd5e04824, "Generic_E22_sx1268_repeater" },
{ 0x61a7b2dd, "Generic_E22_sx1268_repeater_bridge_espnow" },
{ 0xa416bb20, "Generic_ESPNOW_comp_radio_usb" },
{ 0x875f3744, "Generic_ESPNOW_repeatr" },
{ 0xdaf349be, "Generic_ESPNOW_room_svr" },
{ 0x93b93199, "Generic_ESPNOW_terminal_chat" },
{ 0x5293da13, "Heltec_ct62_companion_radio_ble" },
{ 0xf2eec98c, "Heltec_ct62_companion_radio_usb" },
{ 0x48a986f1, "Heltec_ct62_kiss_modem" },
{ 0xd2cf978c, "Heltec_ct62_repeater" },
{ 0x53626334, "Heltec_ct62_repeater_bridge_espnow" },
{ 0xefe4f19f, "Heltec_ct62_sensor" },
{ 0xfcaf3730, "Heltec_E213_companion_radio_ble" },
{ 0xd90deef7, "Heltec_E213_companion_radio_usb" },
{ 0x2d2a4089, "Heltec_E213_kiss_modem" },
{ 0x89ea5c4e, "Heltec_E213_repeater" },
{ 0xc0eb712a, "Heltec_E213_repeater_bridge_espnow" },
{ 0x67293f43, "Heltec_E213_room_server" },
{ 0x3f364910, "Heltec_E290_companion_ble" },
{ 0xac4b4eb5, "Heltec_E290_companion_usb" },
{ 0xe077fb8d, "Heltec_E290_kiss_modem" },
{ 0x2bbe7fd8, "Heltec_E290_repeater" },
{ 0x9e40157c, "Heltec_E290_repeater_bridge_espnow" },
{ 0xb12e6e01, "Heltec_E290_room_server" },
{ 0x17d5e1ff, "Heltec_T190_companion_radio_ble_" },
{ 0xc99313c6, "Heltec_T190_companion_radio_usb_" },
{ 0x21a73973, "Heltec_T190_kiss_modem" },
{ 0x3067f656, "Heltec_T190_repeater_" },
{ 0xfdb25057, "Heltec_T190_repeater_bridge_espnow_" },
{ 0x0f1767cd, "Heltec_T190_room_server_" },
{ 0x73387372, "heltec_tracker_v2_companion_radio_ble" },
{ 0xe6eeffe1, "heltec_tracker_v2_companion_radio_usb" },
{ 0x35f4e7ac, "heltec_tracker_v2_companion_radio_wifi" },
{ 0xb62a1e75, "heltec_tracker_v2_kiss_modem" },
{ 0x9e64845d, "heltec_tracker_v2_repeater" },
{ 0x19232e8b, "heltec_tracker_v2_repeater_bridge_espnow" },
{ 0x38b948b4, "heltec_tracker_v2_room_server" },
{ 0xed21ca4f, "heltec_tracker_v2_sensor" },
{ 0x9f5f39a7, "heltec_tracker_v2_terminal_chat" },
{ 0x081d2219, "Heltec_v2_companion_radio_ble" },
{ 0xc283ad60, "Heltec_v2_companion_radio_usb" },
{ 0x4fbdc1e1, "Heltec_v2_companion_radio_wifi" },
{ 0xc797fd73, "Heltec_v2_kiss_modem" },
{ 0xab9871c8, "Heltec_v2_repeater" },
{ 0x5caaf9bf, "Heltec_v2_repeater_bridge_espnow" },
{ 0x2075687c, "Heltec_v2_room_server" },
{ 0x5d726067, "Heltec_v2_terminal_chat" },
{ 0x9969bbc9, "Heltec_v3_companion_radio_ble" },
{ 0x22f900de, "Heltec_v3_companion_radio_usb" },
{ 0xaca0e153, "Heltec_v3_companion_radio_wifi" },
{ 0xcb1c2e65, "Heltec_v3_kiss_modem" },
{ 0x9f2a6b84, "Heltec_v3_ota_test" },
{ 0xd1b29b18, "Heltec_v3_repeater" },
{ 0x644bb68d, "Heltec_v3_repeater_bridge_espnow" },
{ 0xd19a9759, "Heltec_v3_repeater_bridge_rs232" },
{ 0xc59d294e, "Heltec_v3_room_server" },
{ 0x21519537, "Heltec_v3_sensor" },
{ 0x382bb181, "Heltec_v3_terminal_chat" },
{ 0x01d8f124, "heltec_v4_companion_radio_ble" },
{ 0x1a4d0096, "heltec_v4_companion_radio_usb" },
{ 0x34240e36, "heltec_v4_companion_radio_wifi" },
{ 0xd522a14f, "heltec_v4_expansionkit_repeater" },
{ 0xf75feb3e, "heltec_v4_kiss_modem" },
{ 0xe792a051, "heltec_v4_repeater" },
{ 0x2d5ea842, "heltec_v4_repeater_bridge_espnow" },
{ 0xeed78ce4, "heltec_v4_room_server" },
{ 0x0b6847ed, "heltec_v4_sensor" },
{ 0xab3e1ad3, "heltec_v4_terminal_chat" },
{ 0x60fca2ae, "heltec_v4_tft_companion_radio_ble" },
{ 0xf6b40343, "heltec_v4_tft_companion_radio_usb" },
{ 0x76640bcc, "heltec_v4_tft_companion_radio_wifi" },
{ 0x87513d56, "heltec_v4_tft_repeater" },
{ 0xca812b41, "heltec_v4_tft_repeater_bridge_espnow" },
{ 0x18594231, "heltec_v4_tft_room_server" },
{ 0x188f3ac1, "heltec_v4_tft_sensor" },
{ 0x402055ec, "heltec_v4_tft_terminal_chat" },
{ 0x12393f1f, "Heltec_Wireless_Paper_companion_radio_ble" },
{ 0xed3ef5b8, "Heltec_Wireless_Paper_companion_radio_usb" },
{ 0x8ea86381, "Heltec_Wireless_Paper_kiss_modem" },
{ 0x09f57289, "Heltec_Wireless_Paper_repeater" },
{ 0x148aeaab, "Heltec_Wireless_Paper_repeater_bridge_espnow" },
{ 0x6e9f7fec, "Heltec_Wireless_Paper_room_server" },
{ 0x6a25d024, "Heltec_Wireless_Tracker_companion_radio_ble" },
{ 0xc4a11710, "Heltec_Wireless_Tracker_companion_radio_usb" },
{ 0x753947a4, "Heltec_Wireless_Tracker_kiss_modem" },
{ 0xe6fa98b4, "Heltec_Wireless_Tracker_repeater" },
{ 0xf8c27cf4, "Heltec_Wireless_Tracker_repeater_bridge_espnow" },
{ 0x90e227d9, "Heltec_Wireless_Tracker_room_server" },
{ 0x812f5e52, "Heltec_WSL3_companion_radio_ble" },
{ 0x1ddd15c8, "Heltec_WSL3_companion_radio_usb" },
{ 0x49d67d0d, "Heltec_WSL3_companion_radio_wifi" },
{ 0xbc003322, "Heltec_WSL3_repeater" },
{ 0x835b70c0, "Heltec_WSL3_repeater_bridge_espnow" },
{ 0xd7460575, "Heltec_WSL3_repeater_bridge_rs232" },
{ 0x33e2c171, "Heltec_WSL3_room_server" },
{ 0xa165ae99, "Heltec_WSL3_sensor" },
{ 0x967ccaee, "LilyGo_T3S3_sx1262_companion_radio_ble" },
{ 0x5f7c7688, "LilyGo_T3S3_sx1262_companion_radio_usb" },
{ 0xbd04e6e7, "LilyGo_T3S3_sx1262_kiss_modem" },
{ 0x1aeac884, "LilyGo_T3S3_sx1262_repeater" },
{ 0x07849209, "LilyGo_T3S3_sx1262_repeater_bridge_espnow" },
{ 0xbfb60e7c, "LilyGo_T3S3_sx1262_room_server" },
{ 0x96036a68, "LilyGo_T3S3_sx1262_terminal_chat" },
{ 0xb76a745c, "LilyGo_T3S3_sx1276_companion_radio_ble" },
{ 0x43023193, "LilyGo_T3S3_sx1276_companion_radio_usb" },
{ 0xa7d97db7, "LilyGo_T3S3_sx1276_kiss_modem" },
{ 0x468a133e, "LilyGo_T3S3_sx1276_repeater" },
{ 0xb59ea2ed, "LilyGo_T3S3_sx1276_repeater_bridge_espnow" },
{ 0xbe372c6d, "LilyGo_T3S3_sx1276_room_server" },
{ 0x6215e9eb, "LilyGo_T3S3_sx1276_terminal_chat" },
{ 0x3898ea56, "LilyGo_TBeam_1W_companion_radio_ble" },
{ 0x4f722e70, "LilyGo_TBeam_1W_companion_radio_usb" },
{ 0x694eed25, "LilyGo_TBeam_1W_companion_radio_wifi" },
{ 0x85b8b9d7, "LilyGo_TBeam_1W_kiss_modem" },
{ 0xe8421e36, "LilyGo_TBeam_1W_repeater" },
{ 0x82eaaf9e, "LilyGo_TBeam_1W_repeater_bridge_espnow" },
{ 0xe4a2de74, "LilyGo_TBeam_1W_room_server" },
{ 0xaeb637f1, "LilyGo_TDeck_companion_radio_ble" },
{ 0x86813271, "LilyGo_TDeck_companion_radio_usb" },
{ 0x67b2ff84, "LilyGo_TDeck_kiss_modem" },
{ 0xd0e2e616, "LilyGo_TDeck_repeater" },
{ 0x3f9edfe3, "LilyGo_TETH_Elite_sx1262_companion_radio_ble" },
{ 0xc23f8082, "LilyGo_TETH_Elite_sx1262_companion_radio_usb" },
{ 0x8f256e56, "LilyGo_TETH_Elite_sx1262_repeater" },
{ 0xf1407f39, "LilyGo_TETH_Elite_sx1262_room_server" },
{ 0xe90a3181, "LilyGo_Tlora_C6_companion_radio_ble_" },
{ 0x3b1b4237, "LilyGo_Tlora_C6_kiss_modem" },
{ 0x21c6d08d, "LilyGo_Tlora_C6_repeater_" },
{ 0x34302c14, "LilyGo_Tlora_C6_room_server_" },
{ 0x268d6fc1, "LilyGo_TLora_V2_1_1_6_companion_radio_ble" },
{ 0x3c172d27, "LilyGo_TLora_V2_1_1_6_companion_radio_usb" },
{ 0xf7c5d584, "LilyGo_TLora_V2_1_1_6_companion_radio_wifi" },
{ 0x81960cc0, "LilyGo_TLora_V2_1_1_6_kiss_modem" },
{ 0x504020ea, "LilyGo_TLora_V2_1_1_6_repeater" },
{ 0x8e5c0c88, "LilyGo_TLora_V2_1_1_6_repeater_bridge_espnow" },
{ 0x4e42e0ad, "LilyGo_TLora_V2_1_1_6_repeater_bridge_rs232" },
{ 0xa2ce002f, "LilyGo_TLora_V2_1_1_6_room_server" },
{ 0x6f16479e, "LilyGo_TLora_V2_1_1_6_terminal_chat" },
{ 0x1018e5d1, "M5Stack_Unit_C6L_companion_radio_ble" },
{ 0xc0ab5040, "M5Stack_Unit_C6L_companion_radio_usb" },
{ 0xa2bf90bd, "M5Stack_Unit_C6L_kiss_modem" },
{ 0xe6bff8f9, "M5Stack_Unit_C6L_repeater" },
{ 0xb9cd26cb, "M5Stack_Unit_C6L_room_server" },
{ 0xdbfe2675, "Meshadventurer_sx1262_companion_radio_ble" },
{ 0xb00241d0, "Meshadventurer_sx1262_companion_radio_usb" },
{ 0x88e77fee, "Meshadventurer_sx1262_kiss_modem" },
{ 0xae2d9caa, "Meshadventurer_sx1262_repeater" },
{ 0xecea445b, "Meshadventurer_sx1262_repeater_bridge_espnow" },
{ 0xa0043e00, "Meshadventurer_sx1262_room_server" },
{ 0x26bbf4ed, "Meshadventurer_sx1262_terminal_chat" },
{ 0xdcc0f158, "Meshadventurer_sx1268_companion_radio_ble" },
{ 0xe3320463, "Meshadventurer_sx1268_companion_radio_usb" },
{ 0x852b928a, "Meshadventurer_sx1268_kiss_modem" },
{ 0x0ec870e9, "Meshadventurer_sx1268_repeater" },
{ 0xc845072e, "Meshadventurer_sx1268_repeater_bridge_espnow" },
{ 0x8f00c1f2, "Meshadventurer_sx1268_room_server" },
{ 0xcea95e20, "Meshadventurer_sx1268_terminal_chat" },
{ 0xd45277ff, "Meshimi_companion_radio_ble_" },
{ 0x2cad9a9d, "Meshimi_repeater_" },
{ 0xf1c1641c, "nibble_screen_connect_companion_radio_ble" },
{ 0x8dcc5d89, "nibble_screen_connect_companion_radio_usb" },
{ 0x6139a846, "nibble_screen_connect_companion_radio_wifi" },
{ 0xc0dbcae4, "nibble_screen_connect_kiss_modem" },
{ 0xcd0bdd06, "nibble_screen_connect_repeater" },
{ 0x9ecb53b1, "nibble_screen_connect_repeater_bridge_espnow" },
{ 0x82f6d321, "nibble_screen_connect_room_server" },
{ 0xb10261c8, "nibble_screen_connect_terminal_chat" },
{ 0xdba69401, "R1Neo_companion_radio_ble" },
{ 0xcb6555ee, "R1Neo_companion_radio_usb" },
{ 0xd60daf25, "R1Neo_kiss_modem" },
{ 0x410bd800, "R1Neo_repeater" },
{ 0xd8641710, "R1Neo_room_server" },
{ 0x4d0b1601, "R1Neo_sensor" },
{ 0xab2ac09e, "R1Neo_terminal_chat" },
{ 0xa17fc4f7, "RAK_3112_companion_radio_ble" },
{ 0x90c4eb3f, "RAK_3112_companion_radio_usb" },
{ 0x19d9ce91, "RAK_3112_companion_radio_wifi" },
{ 0x121cc72f, "RAK_3112_kiss_modem" },
{ 0xfa8510fa, "RAK_3112_repeater" },
{ 0xd34d8461, "RAK_3112_repeater_bridge_espnow" },
{ 0x98077550, "RAK_3112_repeater_bridge_rs232" },
{ 0x2ed959aa, "RAK_3112_room_server" },
{ 0xced7e127, "RAK_3112_sensor" },
{ 0x6925aaf0, "RAK_3112_terminal_chat" },
{ 0x9f4d58ac, "RAK_4631_companion_radio_ble" },
{ 0x48d3b5d4, "RAK_4631_companion_radio_usb" },
{ 0x14e6965d, "RAK_4631_kiss_modem" },
{ 0x04d413fd, "RAK_4631_repeater" },
{ 0x22ea8497, "RAK_4631_repeater_bridge_rs232_serial1" },
{ 0x21bae522, "RAK_4631_repeater_bridge_rs232_serial2" },
{ 0x626d80ed, "RAK_4631_room_server" },
{ 0x8b1d6850, "RAK_4631_sensor" },
{ 0xf1d3c5a8, "RAK_4631_terminal_chat" },
{ 0xc6d55752, "RAK_WisMesh_Tag_companion_radio_ble" },
{ 0x60683191, "RAK_WisMesh_Tag_companion_radio_usb" },
{ 0x8a7cb79b, "RAK_WisMesh_Tag_kiss_modem" },
{ 0x4fa4ae55, "RAK_WisMesh_Tag_repeater" },
{ 0x9cb0b232, "RAK_WisMesh_Tag_room_server" },
{ 0x51fa1a49, "RAK_WisMesh_Tag_sensor" },
{ 0xd904c2d5, "SenseCapIndicator-ESPNow_comp_radio_usb" },
{ 0xd0df8303, "Station_G2_companion_radio_ble" },
{ 0x5c1a54f8, "Station_G2_companion_radio_usb" },
{ 0x79cc029e, "Station_G2_companion_radio_wifi" },
{ 0x41197b92, "Station_G2_kiss_modem" },
{ 0x05747eb6, "Station_G2_logging_repeater" },
{ 0x75587f24, "Station_G2_logging_repeater_bridge_espnow" },
{ 0xf2128c81, "Station_G2_repeater" },
{ 0x1b34e7b4, "Station_G2_repeater_bridge_espnow" },
{ 0x73c4a019, "Station_G2_room_server" },
{ 0x8e549407, "Station_G3_ESP32_companion_radio_ble" },
{ 0x1deac038, "Station_G3_ESP32_companion_radio_usb" },
{ 0x2a2f303b, "Station_G3_ESP32_companion_radio_wifi" },
{ 0x0ba4453d, "Station_G3_ESP32_kiss_modem" },
{ 0xf8d1958f, "Station_G3_ESP32_logging_repeater" },
{ 0x3c49caf8, "Station_G3_ESP32_repeater" },
{ 0x91878dd8, "Station_G3_ESP32_room_server" },
{ 0x16482630, "T_Beam_S3_Supreme_SX1262_companion_radio_ble" },
{ 0x01c43d49, "T_Beam_S3_Supreme_SX1262_companion_radio_wifi" },
{ 0x51fe3801, "T_Beam_S3_Supreme_SX1262_kiss_modem" },
{ 0x857149e5, "T_Beam_S3_Supreme_SX1262_repeater" },
{ 0xd9d64938, "T_Beam_S3_Supreme_SX1262_repeater_bridge_espnow" },
{ 0x9952fbe3, "T_Beam_S3_Supreme_SX1262_room_server" },
{ 0xdc27c37c, "Tbeam_SX1262_companion_radio_ble" },
{ 0xb27514a3, "Tbeam_SX1262_kiss_modem" },
{ 0xe9ce038c, "Tbeam_SX1262_repeater" },
{ 0x06406dd4, "Tbeam_SX1262_repeater_bridge_espnow" },
{ 0x189660b3, "Tbeam_SX1262_room_server" },
{ 0x2082078d, "Tbeam_SX1276_companion_radio_ble" },
{ 0x3750e80d, "Tbeam_SX1276_kiss_modem" },
{ 0xe4b49973, "Tbeam_SX1276_repeater" },
{ 0x0c0c4605, "Tbeam_SX1276_repeater_bridge_espnow" },
{ 0x5a79bac1, "Tbeam_SX1276_room_server" },
{ 0x8eaa289e, "Tenstar_C3_sx1262_kiss_modem" },
{ 0x70e91bd9, "Tenstar_C3_sx1262_repeater" },
{ 0x5a7e9c2d, "Tenstar_C3_sx1262_repeater_bridge_espnow" },
{ 0x1211b151, "Tenstar_C3_sx1268_kiss_modem" },
{ 0x0a32044b, "Tenstar_C3_sx1268_repeater" },
{ 0xa13fe853, "Tenstar_C3_sx1268_repeater_bridge_espnow" },
{ 0xd7598668, "ThinkNode_M2_companion_radio_ble" },
{ 0x413287d8, "ThinkNode_M2_companion_radio_serial" },
{ 0xe8cd2377, "ThinkNode_M2_companion_radio_usb" },
{ 0x3309bdaf, "ThinkNode_M2_companion_radio_wifi" },
{ 0x7330afbb, "ThinkNode_M2_kiss_modem" },
{ 0x9df48ce6, "ThinkNode_M2_Repeater" },
{ 0xa5cb783a, "ThinkNode_M2_Repeater_bridge_espnow" },
{ 0x4b9f8283, "ThinkNode_M2_room_server" },
{ 0x311846c9, "ThinkNode_M2_terminal_chat" },
{ 0xf2d61a1c, "ThinkNode_M5_companion_radio_ble" },
{ 0x1da30414, "ThinkNode_M5_companion_radio_serial" },
{ 0xae8fe1c9, "ThinkNode_M5_companion_radio_usb" },
{ 0x67acd73f, "ThinkNode_M5_companion_radio_wifi" },
{ 0xfb118ed8, "ThinkNode_M5_kiss_modem" },
{ 0xe4213729, "ThinkNode_M5_Repeater" },
{ 0x1298038d, "ThinkNode_M5_Repeater_bridge_espnow" },
{ 0x2c47acff, "ThinkNode_M5_room_server" },
{ 0x7d641646, "ThinkNode_M5_terminal_chat" },
{ 0x37443a5b, "WHY2025_badge_companion_radio_ble_" },
{ 0x603384a7, "WHY2025_badge_repeater_" },
{ 0x67843a5f, "Xiao_C3_companion_radio_ble" },
{ 0xfad357ab, "Xiao_C3_companion_radio_usb" },
{ 0x16af5c67, "Xiao_C3_companion_radio_wifi" },
{ 0x8c105104, "Xiao_C3_kiss_modem" },
{ 0xa80042a0, "Xiao_C3_repeater" },
{ 0x4bff0748, "Xiao_C3_room_server" },
{ 0x77c8f2b5, "Xiao_C6_companion_radio_ble_" },
{ 0x71ee0e15, "Xiao_C6_kiss_modem" },
{ 0x8824cd0c, "Xiao_C6_repeater_" },
{ 0x6658b418, "Xiao_S3_companion_radio_ble" },
{ 0x964f4f5c, "Xiao_S3_companion_radio_usb" },
{ 0x0a5dc760, "Xiao_S3_kiss_modem" },
{ 0xd08496ce, "Xiao_S3_repeater" },
{ 0x293ac2c8, "Xiao_S3_repeater_bridge_espnow" },
{ 0x60ebfa87, "Xiao_S3_room_server" },
{ 0x60f5fdc2, "Xiao_S3_sensor" },
{ 0xe8f79d22, "Xiao_S3_WIO_companion_radio_ble" },
{ 0x9f27be15, "Xiao_S3_WIO_companion_radio_serial" },
{ 0x396fecc7, "Xiao_S3_WIO_companion_radio_usb" },
{ 0xf94e3407, "Xiao_S3_WIO_companion_radio_wifi" },
{ 0xe1930103, "Xiao_S3_WIO_kiss_modem" },
{ 0x9f19bcd1, "Xiao_S3_WIO_repeater" },
{ 0xfcebff61, "Xiao_S3_WIO_repeater_bridge_espnow" },
{ 0xccb734e2, "Xiao_S3_WIO_room_server" },
{ 0xf967a300, "Xiao_S3_WIO_sensor" },
{ 0xa0cc1c36, "Xiao_S3_WIO_terminal_chat" },
};
for (unsigned i = 0; i < sizeof(T) / sizeof(T[0]); i++)
if (T[i].id == target_id) return T[i].env;
return nullptr;
}
} } // namespace mesh::ota
+2 -1
View File
@@ -25,9 +25,9 @@ Uses the repo's Python venv (`meshcore/`). Dependencies: `detools` (delta), `cry
| `motalib.py` | Core logic: multihash, EndF, merkle tree+proofs, manifest/container build/parse/verify. The **reference implementation** of the spec and the unit-test oracle. Imported by everything below. |
| `pio_endf.py` | **PlatformIO post-build hook** (wired in `platformio.ini`) that injects the `EndF` self-identity trailer into the flashed firmware (`-D ENABLE_OTA`). |
| `gen_vectors.py` | Generates `test/test_ota/mota_vectors.h` — the cross-check vectors the native C++ tests run against. |
| `gen_targets.py` | Generates `src/helpers/ota/OtaTargets.h` — the `target_id → env-name` table (every `ENABLE_OTA` env, resolved from `pio project config`). Shared by the firmware and `motatool` so a node can name a target seen over the air without sending the string. Regenerate when the OTA env set changes. |
| `test_mota.py` | Unit tests for `motalib` (run directly or via pytest). |
| `endf.py` | Standalone `EndF` trailer injector (idempotent) — handy for one-off `.bin` patching. |
| `dev_motas.py` | CI packaging step for the rolling dev release: extracts the OTA image from a build (incl. nRF52 `.hex`) and drives `motatool build` for a full + same-image-delta `.mota`. |
| `extract_apply.py` | Dev helper: split a firmware into payload + fixed-manifest bytes for the apply test. |
## Tests
@@ -36,6 +36,7 @@ Uses the repo's Python venv (`meshcore/`). Dependencies: `detools` (delta), `cry
./meshcore/bin/python tools/mota/test_mota.py # EndF, merkle+proofs, full/delta, signing,
# tamper detection, approval enforcement
./meshcore/bin/python tools/mota/gen_vectors.py # regenerate the native-test cross-check vectors
./meshcore/bin/python tools/mota/gen_targets.py # regenerate src/helpers/ota/OtaTargets.h (needs `pio`)
```
## `EndF` build integration
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env python3
"""Package a full + same-image-delta .mota for ONE built firmware (dev rolling release).
CI orchestration on top of the `motatool` packager: extracts the OTA image (BODY||EndF) from a
PlatformIO build (the part motatool can't do for nRF52 .hex), then runs `motatool build` twice:
- full .mota (codec full) — the flashable image, universally applicable
- delta .mota (same-image) — base == target, so the patch is tiny (a format/transport demo).
ESP32 -> sequential ; nRF52 -> in-place (RAK4631 flash layout).
Image source per platform:
ESP32 : <build_dir>/firmware.bin (pio_endf has already appended EndF)
nRF52 : app region of <build_dir>/firmware.hex, extracted via intelhex (EndF already appended to the .hex)
Usage:
dev_motas.py --env RAK_4631_repeater --platform NRF52 --build-dir .pio/build/RAK_4631_repeater \
--target-env RAK_4631_repeater --out-prefix out/RAK_4631_repeater-dev-abc1234 \
--motatool ./tools/motatool/build/motatool
Writes <out-prefix>.full.mota and (best-effort) <out-prefix>.delta.mota.
"""
import argparse, os, subprocess, sys
# the motatool binary (build it from tools/motatool/); overridable via --motatool or $MOTATOOL
DEFAULT_MOTATOOL = os.environ.get("MOTATOOL", "motatool")
# nRF52 (RAK4631) flash layout — keep in sync with src/helpers/ota/OtaFlashLayout_nrf52.h
NRF52_APP_BASE = 0x26000
NRF52_FS_START = 0xD4000
NRF52_INPLACE_MEMORY = NRF52_FS_START - NRF52_APP_BASE # 0xAE000 working size for in-place apply
NRF52_INPLACE_SEGMENT = 4096
def extract_image(platform, build_dir, work):
"""Return the path to the OTA image (BODY||EndF) as a raw .bin, or None if not found."""
if platform == "ESP32":
bin_path = os.path.join(build_dir, "firmware.bin")
return bin_path if os.path.isfile(bin_path) else None
if platform == "NRF52":
hex_path = os.path.join(build_dir, "firmware.hex")
if not os.path.isfile(hex_path):
return None
from intelhex import IntelHex
ih = IntelHex(hex_path)
# the app .hex is a single contiguous segment [APP_BASE .. app_end]; take it verbatim
start = ih.minaddr()
data = ih.tobinarray(start=start, end=ih.maxaddr())
out = os.path.join(work, "app_image.bin")
with open(out, "wb") as f:
f.write(bytes(data))
return out
return None
def run_mota(motatool, args):
print(" +", motatool, "build", " ".join(args))
subprocess.run([motatool, "build", *args], check=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--env", required=True)
ap.add_argument("--platform", required=True, choices=["ESP32", "NRF52"])
ap.add_argument("--build-dir", required=True)
ap.add_argument("--target-env", required=True)
ap.add_argument("--out-prefix", required=True)
ap.add_argument("--work", default=".")
ap.add_argument("--motatool", default=DEFAULT_MOTATOOL, help="path to the motatool binary")
a = ap.parse_args()
img = extract_image(a.platform, a.build_dir, a.work)
if not img:
print(f"::warning::no OTA image for {a.env} ({a.platform}) — skipping .mota")
return 0
print(f"OTA image for {a.env}: {img} ({os.path.getsize(img)} bytes)")
# full .mota (always). motatool hardwires crle for deltas — the codec the on-device applier expects.
run_mota(a.motatool, ["--fw", img, "--target-env", a.target_env, "--fw-version", "0.0.0",
"--codec", "full", "--out", a.out_prefix + ".full.mota"])
# same-image delta .mota (best-effort; codec per platform's applier)
try:
if a.platform == "ESP32":
run_mota(a.motatool, ["--fw", img, "--base", img, "--target-env", a.target_env, "--fw-version", "0.0.1",
"--codec", "sequential", "--out", a.out_prefix + ".delta.mota"])
else: # NRF52 in-place
run_mota(a.motatool, ["--fw", img, "--base", img, "--target-env", a.target_env, "--fw-version", "0.0.1",
"--codec", "inplace",
"--inplace-memory", str(NRF52_INPLACE_MEMORY), "--inplace-segment", str(NRF52_INPLACE_SEGMENT),
"--out", a.out_prefix + ".delta.mota"])
except subprocess.CalledProcessError as e:
print(f"::warning::delta .mota for {a.env} failed ({e}); full .mota still produced")
return 0
if __name__ == "__main__":
sys.exit(main())
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Generate src/helpers/ota/OtaTargets.h — the target_id -> env-name table for OTA-capable envs.
A target_id is sha2-256:4(pio_env_name); it travels in beacons / the .mota manifest as 4 bytes. This
table lets a node (and motatool) print the human-readable env name for a target seen over the air, WITHOUT
ever transmitting the string. The set of envs is resolved from PlatformIO (every env whose build_flags
include ENABLE_OTA), so it stays in sync with what actually ships OTA.
Run: ./meshcore/bin/python tools/mota/gen_targets.py # resolves via `pio project config`
./meshcore/bin/python tools/mota/gen_targets.py cfg.json # or reuse a cached config dump (faster)
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import motalib as ml
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "src" / "helpers" / "ota" / "OtaTargets.h"
def resolved_config():
if len(sys.argv) > 1 and os.path.isfile(sys.argv[1]):
return json.load(open(sys.argv[1]))
pio = os.environ.get("PIO", "pio")
out = subprocess.run([pio, "project", "config", "--json-output"],
cwd=ROOT, capture_output=True, text=True, check=True).stdout
return json.loads(out)
def ota_envs(cfg):
envs = []
for section, opts in cfg:
if not section.startswith("env:"):
continue
for k, v in opts:
if k == "build_flags":
flags = " ".join(v) if isinstance(v, list) else str(v)
if "ENABLE_OTA" in flags:
envs.append(section[4:])
break
return sorted(set(envs))
def main():
envs = ota_envs(resolved_config())
rows = [(ml.target_id_for_env(e), e) for e in envs]
seen = {}
for tid, e in rows: # sha2-256:4 over ~300 names — verify no collisions
if tid in seen and seen[tid] != e:
print(f"WARNING: target_id collision 0x{tid:08x}: {seen[tid]!r} vs {e!r}", file=sys.stderr)
seen[tid] = e
rows.sort(key=lambda r: r[1].lower()) # by env name (readable, deterministic)
out = [
"#pragma once",
"#include <stdint.h>",
"",
"// AUTO-GENERATED by tools/mota/gen_targets.py — do not edit by hand.",
f"// {len(rows)} OTA-capable PlatformIO envs. Maps target_id (= sha2-256:4 of the env name, LE uint32)",
"// to the human-readable env name, so a node/tool can name a target seen over the air WITHOUT",
"// transmitting the string in the .mota / LoRa protocol. Regenerate when the OTA env set changes.",
"",
"namespace mesh { namespace ota {",
"",
"// target_id -> env name, or nullptr if unknown. Linear scan (table is small; lookups are rare).",
"inline const char* ota_target_env_name(uint32_t target_id) {",
" static const struct { uint32_t id; const char* env; } T[] = {",
]
out += [f' {{ 0x{tid:08x}, "{e}" }},' for tid, e in rows]
out += [
" };",
" for (unsigned i = 0; i < sizeof(T) / sizeof(T[0]); i++)",
" if (T[i].id == target_id) return T[i].env;",
" return nullptr;",
"}",
"",
"} } // namespace mesh::ota",
]
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text("\n".join(out) + "\n")
print(f"wrote {OUT} ({len(rows)} OTA envs)")
if __name__ == "__main__":
main()
+6 -1
View File
@@ -21,7 +21,12 @@ add_library(motatool_core STATIC
src/input.cpp
src/serve.cpp
)
target_include_directories(motatool_core PUBLIC src)
# `src` for the tool's own headers; the repo's helpers/ota for the shared, generated OtaTargets.h
# (target_id -> env-name table — single source of truth with the firmware, no hand-maintained copy).
target_include_directories(motatool_core PUBLIC
src
"${CMAKE_CURRENT_SOURCE_DIR}/../../src/helpers/ota"
)
target_link_libraries(motatool_core PUBLIC OpenSSL::Crypto)
add_executable(motatool src/main.cpp)
+5 -4
View File
@@ -106,8 +106,9 @@ Android NDK, so the validation path ports as-is.
## Relationship to `tools/mota/`
`motatool` is the user-facing CLI: it replaced the old Python `mota.py` (build/verify/inspect/keygen) and
`mota_seeder.py` (serve), which have been removed. The Python `tools/mota/` directory remains as the
`motatool` is the user-facing CLI: it replaced the old Python `mota.py` (build/verify/inspect/keygen),
`mota_seeder.py` (serve), and `dev_motas.py` (CI `.mota` packaging — its nRF52 `.hex` extraction is now a
built-in `motatool` input format), all removed. The Python `tools/mota/` directory remains as the
**reference implementation** (`motalib.py`, the spec oracle + unit tests) and the **firmware build/test
glue** (`pio_endf.py` build hook, `gen_vectors.py` test vectors, `dev_motas.py` CI packaging — which now
drives `motatool`). Their `.mota` output is byte-identical (verified by cross-checks).
glue** (`pio_endf.py` build hook, `gen_vectors.py` test vectors). Their `.mota` output is byte-identical
(verified by cross-checks).
+88 -20
View File
@@ -1,5 +1,7 @@
#include "input.h"
#include "util.h"
#include <cctype>
#include <cstring>
#include <unistd.h>
namespace mota {
@@ -8,27 +10,93 @@ bool is_url(const std::string& s) {
return s.rfind("http://", 0) == 0 || s.rfind("https://", 0) == 0;
}
std::string read_input(const std::string& src, std::vector<uint8_t>& out) {
if (!is_url(src)) {
if (!read_file(src, out)) return "cannot read file: " + src;
if (out.empty()) return "file is empty: " + src;
return "";
static bool ends_with_ci(const std::string& s, const std::string& suf) {
if (s.size() < suf.size()) return false;
for (size_t i = 0; i < suf.size(); i++)
if (std::tolower((unsigned char)s[s.size() - suf.size() + i]) != std::tolower((unsigned char)suf[i])) return false;
return true;
}
// Parse Intel HEX text into the flat binary it represents (min..max address, gaps filled 0xFF).
// nRF52/STM32 PlatformIO builds emit firmware.hex; pio_endf appends the EndF inside it, so the extracted
// binary IS the OTA image (BODY||EndF) starting at the app base — exactly what `build` wants.
static std::string parse_intel_hex(const std::vector<uint8_t>& in, std::vector<uint8_t>& out) {
const std::string s((const char*)in.data(), in.size());
auto nib = [](char c) -> int {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
};
struct Seg { uint64_t addr; std::vector<uint8_t> data; };
std::vector<Seg> segs;
uint64_t base = 0, lo = UINT64_MAX, hi = 0;
size_t i = 0;
bool saw_eof = false;
while (i < s.size()) {
if (s[i] != ':') { i++; continue; } // tolerate CR/LF/whitespace between records
size_t p = i + 1;
auto rb = [&](uint8_t& v) -> bool {
if (p + 2 > s.size()) return false;
int h = nib(s[p]), l = nib(s[p + 1]);
if (h < 0 || l < 0) return false;
v = (uint8_t)((h << 4) | l); p += 2; return true;
};
uint8_t len, ah, al, type;
if (!rb(len) || !rb(ah) || !rb(al) || !rb(type)) return "malformed Intel HEX record";
uint8_t sum = (uint8_t)(len + ah + al + type);
std::vector<uint8_t> data(len);
for (uint8_t k = 0; k < len; k++) { if (!rb(data[k])) return "truncated Intel HEX data"; sum += data[k]; }
uint8_t cks;
if (!rb(cks)) return "missing Intel HEX checksum";
if ((uint8_t)(sum + cks) != 0) return "Intel HEX checksum error";
uint16_t addr = (uint16_t)((ah << 8) | al);
if (type == 0x00) { // data
uint64_t a = base + addr;
if (a < lo) lo = a;
if (a + len > hi) hi = a + len;
segs.push_back({a, std::move(data)});
} else if (type == 0x04) { // extended linear address (upper 16 bits)
if (len != 2) return "bad Intel HEX ELA record";
base = (uint64_t)((data[0] << 8) | data[1]) << 16;
} else if (type == 0x02) { // extended segment address
if (len != 2) return "bad Intel HEX ESA record";
base = (uint64_t)((data[0] << 8) | data[1]) << 4;
} else if (type == 0x01) { // EOF
saw_eof = true; break;
} // 0x03/0x05 (start address) ignored
i = p;
}
// URL: download to a temp file via curl (fallback wget), then read it back.
char tmp[] = "/tmp/motadlXXXXXX";
int fd = mkstemp(tmp);
if (fd < 0) return "mkstemp failed";
close(fd);
// curl: -f fail on HTTP error, -L follow redirects, -s silent, -S show errors, -o out
int rc = run_argv({"curl", "-fLsS", "-o", tmp, src});
if (rc == 127) // no curl -> try wget
rc = run_argv({"wget", "-q", "-O", tmp, src});
std::string err;
if (rc == 127) err = "neither curl nor wget is available to fetch " + src;
else if (rc != 0) err = "download failed (exit " + std::to_string(rc) + "): " + src;
else if (!read_file(tmp, out) || out.empty()) err = "downloaded file is empty: " + src;
unlink(tmp);
return err;
if (segs.empty()) return "no data records in Intel HEX";
if (!saw_eof) return "Intel HEX missing EOF record";
out.assign((size_t)(hi - lo), 0xFF);
for (auto& sg : segs) std::memcpy(out.data() + (size_t)(sg.addr - lo), sg.data.data(), sg.data.size());
return "";
}
std::string read_input(const std::string& src, std::vector<uint8_t>& out) {
std::vector<uint8_t> raw;
if (!is_url(src)) {
if (!read_file(src, raw)) return "cannot read file: " + src;
} else {
// URL: download to a temp file via curl (fallback wget), then read it back.
char tmp[] = "/tmp/motadlXXXXXX";
int fd = mkstemp(tmp);
if (fd < 0) return "mkstemp failed";
close(fd);
int rc = run_argv({"curl", "-fLsS", "-o", tmp, src}); // -f fail, -L follow, -sS silent+show-errors
if (rc == 127) rc = run_argv({"wget", "-q", "-O", tmp, src});
std::string err;
if (rc == 127) err = "neither curl nor wget is available to fetch " + src;
else if (rc != 0) err = "download failed (exit " + std::to_string(rc) + "): " + src;
else if (!read_file(tmp, raw)) err = "could not read the downloaded file: " + src;
unlink(tmp);
if (!err.empty()) return err;
}
if (raw.empty()) return "input is empty: " + src;
if (ends_with_ci(src, ".hex")) return parse_intel_hex(raw, out); // Intel HEX -> flat image
out = std::move(raw);
return "";
}
} // namespace mota
+11 -3
View File
@@ -112,6 +112,7 @@ static void help_build() {
"\n"
"INPUT (--fw is required)\n"
" --fw <file|url> NEW firmware. A local path OR an http(s):// URL (downloaded with curl/wget).\n"
" A .bin is used as-is; a .hex (nRF52/STM32 build) is parsed to its flat image first.\n"
" --base <file|url> previous firmware to diff against -> makes a delta (omit it for a full image).\n"
"\n"
"IDENTITY (optional — auto-read from the firmware's EndF)\n"
@@ -235,6 +236,12 @@ static void help_keygen() {
static std::string hex8(uint32_t v) { char x[9]; std::snprintf(x, 9, "%08X", v); return x; }
// human-readable label for a target_id (its PlatformIO env name), or "N/A" if not in the known table
static std::string target_label(uint32_t t) {
std::string n = target_env_name(t);
return n.empty() ? "N/A" : n;
}
static int cmd_verify(const Args& a) {
if (a.has("help")) { help_verify(); return 0; }
if (a.pos.empty()) { help_verify(); return 2; }
@@ -274,7 +281,7 @@ static int cmd_verify(const Args& a) {
}
if (probs.empty()) {
std::cout << "OK " << f << " : " << (m.is_full() ? "full" : "delta")
<< " target=" << hex8(m.target_id)
<< " target=" << hex8(m.target_id) << " [" << target_label(m.target_id) << "]"
<< " v" << version_str(m.fw_version) << " hw=" << (m.hw_id_str().empty()? "?":m.hw_id_str())
<< " " << (m.is_signed() ? "signed" : "unsigned")
<< " blocks=" << m.block_count << " size=" << blob.size() << "\n";
@@ -303,7 +310,8 @@ static int cmd_inspect(const Args& a) {
<< "flags : 0x" << [&]{char x[3];std::snprintf(x,3,"%02x",m.flags);return std::string(x);}()
<< " FULL=" << (m.is_full()?"true":"false") << " SIGNED=" << (m.is_signed()?"true":"false") << "\n"
<< "hash_algo : 0x12 (sha2-256)\n"
<< "target_id : 0x" << [&]{char x[9];std::snprintf(x,9,"%08x",m.target_id);return std::string(x);}() << "\n"
<< "target_id : 0x" << [&]{char x[9];std::snprintf(x,9,"%08x",m.target_id);return std::string(x);}()
<< " (" << target_label(m.target_id) << ")\n"
<< "fw_version : " << version_str(m.fw_version) << " (0x"
<< [&]{char x[9];std::snprintf(x,9,"%08x",m.fw_version);return std::string(x);}() << ")\n"
<< "image_size : " << m.image_size << "\n"
@@ -449,7 +457,7 @@ static int cmd_serve(const Args& a) {
for (const auto& s : folder.all()) {
std::cout << " - " << fs::path(s.path).filename().string()
<< " : mid=" << to_hex(s.m.merkle_root.data(), 4)
<< " target=" << [&]{char x[9];std::snprintf(x,9,"%08X",s.m.target_id);return std::string(x);}()
<< " target=" << hex8(s.m.target_id) << " [" << target_label(s.m.target_id) << "]"
<< " v" << version_str(s.m.fw_version)
<< " " << (s.m.is_full() ? "full" : (s.m.codec_id == CODEC_DETOOLS_INPLACE ? "ipdelta" : "seqdelta"))
<< " " << (s.m.is_signed() ? "signed" : "unsigned")
+202 -108
View File
@@ -1,34 +1,60 @@
#include "mota.h"
#include "OtaTargets.h" // shared with the firmware (generated): target_id -> env name
#include "crypto.h"
#include "util.h"
#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <sstream>
namespace mota {
// ---- merkle (mirror of src/helpers/ota/MerkleTree.cpp) --------------------------------------------
static void merkle_leaf(uint8_t out[4], const uint8_t* block, size_t len) { sha256_trunc(out, 4, block, len); }
static void merkle_combine(uint8_t out[4], const uint8_t* left, const uint8_t* right) {
uint8_t buf[8]; std::memcpy(buf, left, 4); std::memcpy(buf + 4, right, 4); sha256_trunc(out, 4, buf, 8);
static void merkle_leaf(uint8_t out[4], const uint8_t *block, size_t len) {
sha256_trunc(out, 4, block, len);
}
static void merkle_root(uint8_t out[4], const uint8_t* leaves, uint32_t count) {
if (count == 0) { std::memset(out, 0, 4); return; }
if (count == 1) { std::memcpy(out, leaves, 4); return; }
uint8_t peaks[32][4]; bool valid[32] = {false};
for (uint32_t i = 0; i < count; i++) {
uint8_t cur[4]; std::memcpy(cur, leaves + (size_t)i * 4, 4);
uint32_t level = 0;
while (valid[level]) { merkle_combine(cur, peaks[level], cur); valid[level] = false; level++; }
std::memcpy(peaks[level], cur, 4); valid[level] = true;
static void merkle_combine(uint8_t out[4], const uint8_t *left, const uint8_t *right) {
uint8_t buf[8];
std::memcpy(buf, left, 4);
std::memcpy(buf + 4, right, 4);
sha256_trunc(out, 4, buf, 8);
}
static void merkle_root(uint8_t out[4], const uint8_t *leaves, uint32_t count) {
if (count == 0) {
std::memset(out, 0, 4);
return;
}
int level = 0; while (level < 32 && !valid[level]) level++;
uint8_t acc[4]; std::memcpy(acc, peaks[level], 4);
for (int l = level + 1; l < 32; l++) if (valid[l]) merkle_combine(acc, peaks[l], acc);
if (count == 1) {
std::memcpy(out, leaves, 4);
return;
}
uint8_t peaks[32][4];
bool valid[32] = { false };
for (uint32_t i = 0; i < count; i++) {
uint8_t cur[4];
std::memcpy(cur, leaves + (size_t)i * 4, 4);
uint32_t level = 0;
while (valid[level]) {
merkle_combine(cur, peaks[level], cur);
valid[level] = false;
level++;
}
std::memcpy(peaks[level], cur, 4);
valid[level] = true;
}
int level = 0;
while (level < 32 && !valid[level])
level++;
uint8_t acc[4];
std::memcpy(acc, peaks[level], 4);
for (int l = level + 1; l < 32; l++)
if (valid[l]) merkle_combine(acc, peaks[l], acc);
std::memcpy(out, acc, 4);
}
// leaves[] over the payload (last block short, no padding)
static std::vector<uint8_t> leaf_hashes(const uint8_t* payload, uint32_t size, uint32_t bs) {
static std::vector<uint8_t> leaf_hashes(const uint8_t *payload, uint32_t size, uint32_t bs) {
uint32_t bc = (size + bs - 1) / bs;
std::vector<uint8_t> leaves(bc * 4);
for (uint32_t i = 0; i < bc; i++) {
@@ -39,37 +65,40 @@ static std::vector<uint8_t> leaf_hashes(const uint8_t* payload, uint32_t size, u
}
std::string Manifest::hw_id_str() const {
size_t n = 0; while (n < hw_id.size() && hw_id[n]) n++;
return std::string((const char*)hw_id.data(), n);
size_t n = 0;
while (n < hw_id.size() && hw_id[n])
n++;
return std::string((const char *)hw_id.data(), n);
}
// ---- parse -----------------------------------------------------------------------------------------
std::string parse(const std::vector<uint8_t>& b, Manifest& m) {
std::string parse(const std::vector<uint8_t> &b, Manifest &m) {
m = Manifest();
if (b.size() < 8 + MOTA_MFL + 5) return "too small for a .mota";
if (std::memcmp(b.data(), MOTA_MAGIC, 4) != 0) return "bad MAGIC (not a .mota)";
uint32_t total = rd_u32(b.data() + 4);
if (total != b.size()) return "MOTA_TOTAL_SIZE != file length";
if (std::memcmp(b.data() + b.size() - 5, MOTA_TRAILER, 5) != 0) return "bad TRAILER";
const uint8_t* mf = b.data() + 8; // manifest start
const uint8_t *mf = b.data() + 8; // manifest start
m.format_ver = mf[M_OFF_FORMAT_VER];
if (m.format_ver != FORMAT_VER) return "unsupported format_ver";
m.flags = mf[M_OFF_FLAGS];
m.hash_algo = mf[M_OFF_HASH_ALGO];
m.target_id = rd_u32(mf + M_OFF_TARGET_ID);
m.fw_version = rd_u32(mf + M_OFF_FW_VERSION);
m.image_size = rd_u32(mf + M_OFF_IMAGE_SIZE);
m.payload_size = rd_u32(mf + M_OFF_PAYLOAD_SIZE);
m.flags = mf[M_OFF_FLAGS];
m.hash_algo = mf[M_OFF_HASH_ALGO];
m.target_id = rd_u32(mf + M_OFF_TARGET_ID);
m.fw_version = rd_u32(mf + M_OFF_FW_VERSION);
m.image_size = rd_u32(mf + M_OFF_IMAGE_SIZE);
m.payload_size = rd_u32(mf + M_OFF_PAYLOAD_SIZE);
m.block_size_log2 = mf[M_OFF_BLOCK_SIZE_LOG2];
std::memcpy(m.merkle_root.data(), mf + M_OFF_MERKLE_ROOT, 4);
std::memcpy(m.image_hash.data(), mf + M_OFF_IMAGE_HASH, 32);
m.codec_id = mf[M_OFF_CODEC_ID];
std::memcpy(m.hw_id.data(), mf + M_OFF_HW_ID, 32);
std::memcpy(m.base_hash.data(), mf + M_OFF_BASE_HASH, 8);
std::memcpy(m.signer.data(), mf + M_OFF_SIGNER, 32);
std::memcpy(m.signature.data(), mf + M_OFF_SIGNATURE, 64);
std::memcpy(m.approval.data(), mf + M_OFF_APPROVAL, 4);
if (m.block_size_log2 == 0 || m.block_size_log2 > 24 || m.payload_size == 0) return "bad block_size/payload";
std::memcpy(m.image_hash.data(), mf + M_OFF_IMAGE_HASH, 32);
m.codec_id = mf[M_OFF_CODEC_ID];
std::memcpy(m.hw_id.data(), mf + M_OFF_HW_ID, 32);
std::memcpy(m.base_hash.data(), mf + M_OFF_BASE_HASH, 8);
std::memcpy(m.signer.data(), mf + M_OFF_SIGNER, 32);
std::memcpy(m.signature.data(), mf + M_OFF_SIGNATURE, 64);
std::memcpy(m.approval.data(), mf + M_OFF_APPROVAL, 4);
if (m.block_size_log2 == 0 || m.block_size_log2 > 24 || m.payload_size == 0)
return "bad block_size/payload";
m.block_count = (m.payload_size + m.block_size() - 1) / m.block_size();
if (m.block_count == 0 || m.block_count > 0xFFFFu) return "block_count out of range";
if (m.total_size() != b.size()) return "geometry (leaves+payload) != file length";
@@ -77,26 +106,29 @@ std::string parse(const std::vector<uint8_t>& b, Manifest& m) {
}
// ---- verify ----------------------------------------------------------------------------------------
std::vector<std::string> verify(const std::vector<uint8_t>& b) {
std::vector<std::string> verify(const std::vector<uint8_t> &b) {
std::vector<std::string> probs;
Manifest m;
std::string e = parse(b, m);
if (!e.empty()) { probs.push_back(e); return probs; } // unparseable: a single, fatal problem
if (!e.empty()) {
probs.push_back(e);
return probs;
} // unparseable: a single, fatal problem
const uint8_t* leaves = b.data() + m.leaves_off();
const uint8_t* payload = b.data() + m.payload_off();
const uint8_t *leaves = b.data() + m.leaves_off();
const uint8_t *payload = b.data() + m.payload_off();
// recompute leaves[] from the payload -> catches payload corruption
std::vector<uint8_t> calc = leaf_hashes(payload, m.payload_size, m.block_size());
if (calc.size() != m.block_count * 4u || std::memcmp(calc.data(), leaves, calc.size()) != 0)
probs.push_back("leaves[] do not match the payload (corruption)");
uint8_t root[4]; merkle_root(root, leaves, m.block_count);
if (std::memcmp(root, m.merkle_root.data(), 4) != 0)
probs.push_back("merkle_root mismatch");
uint8_t root[4];
merkle_root(root, leaves, m.block_count);
if (std::memcmp(root, m.merkle_root.data(), 4) != 0) probs.push_back("merkle_root mismatch");
if (m.is_full()) {
auto h = mh32(payload, m.payload_size); // full: payload IS the image
auto h = mh32(payload, m.payload_size); // full: payload IS the image
if (std::memcmp(h.data(), m.image_hash.data(), 32) != 0)
probs.push_back("image_hash mismatch (full image)");
}
@@ -113,58 +145,76 @@ std::vector<std::string> verify(const std::vector<uint8_t>& b) {
}
// ---- EndF -----------------------------------------------------------------------------------------
bool has_endf(const std::vector<uint8_t>& img) {
bool has_endf(const std::vector<uint8_t> &img) {
if (img.size() < ENDF_LEN) return false;
const uint8_t* t = img.data() + img.size() - ENDF_LEN;
const uint8_t *t = img.data() + img.size() - ENDF_LEN;
if (std::memcmp(t, ENDF_MAGIC, 4) != 0) return false;
if (rd_u32(t + 4) != img.size() - ENDF_LEN) return false;
auto h = mh8(img.data(), img.size() - ENDF_LEN);
return std::memcmp(h.data(), t + 8, 8) == 0;
}
FwIdent parse_endf_ident(const std::vector<uint8_t>& img) {
FwIdent parse_endf_ident(const std::vector<uint8_t> &img) {
FwIdent id;
if (!has_endf(img)) return id;
const uint8_t* t = img.data() + img.size() - ENDF_LEN;
const uint8_t *t = img.data() + img.size() - ENDF_LEN;
id.fw_version = rd_u32(t + ENDF_OFF_FWVER);
id.target_id = rd_u32(t + ENDF_OFF_TARGET);
size_t n = 0; while (n < HW_ID_LEN && t[ENDF_OFF_HWID + n]) n++;
id.hw_id.assign((const char*)t + ENDF_OFF_HWID, n);
id.target_id = rd_u32(t + ENDF_OFF_TARGET);
size_t n = 0;
while (n < HW_ID_LEN && t[ENDF_OFF_HWID + n])
n++;
id.hw_id.assign((const char *)t + ENDF_OFF_HWID, n);
return id;
}
std::vector<uint8_t> ensure_endf(const std::vector<uint8_t>& img, const FwIdent& id,
std::array<uint8_t,8>& body_hash8) {
if (has_endf(img)) { // already trailed: keep its identity, read its hash
const uint8_t* t = img.data() + img.size() - ENDF_LEN;
std::vector<uint8_t> ensure_endf(const std::vector<uint8_t> &img, const FwIdent &id,
std::array<uint8_t, 8> &body_hash8) {
if (has_endf(img)) { // already trailed: keep its identity, read its hash
const uint8_t *t = img.data() + img.size() - ENDF_LEN;
std::memcpy(body_hash8.data(), t + 8, 8);
return img;
}
body_hash8 = mh8(img.data(), img.size());
std::vector<uint8_t> out = img;
out.insert(out.end(), ENDF_MAGIC, ENDF_MAGIC + 4);
uint8_t u[4]; wr_u32(u, (uint32_t)img.size()); out.insert(out.end(), u, u + 4);
uint8_t u[4];
wr_u32(u, (uint32_t)img.size());
out.insert(out.end(), u, u + 4);
out.insert(out.end(), body_hash8.begin(), body_hash8.end());
wr_u32(u, id.fw_version); out.insert(out.end(), u, u + 4);
wr_u32(u, id.target_id); out.insert(out.end(), u, u + 4);
uint8_t hw[HW_ID_LEN] = {0};
wr_u32(u, id.fw_version);
out.insert(out.end(), u, u + 4);
wr_u32(u, id.target_id);
out.insert(out.end(), u, u + 4);
uint8_t hw[HW_ID_LEN] = { 0 };
std::memcpy(hw, id.hw_id.data(), id.hw_id.size() < HW_ID_LEN ? id.hw_id.size() : HW_ID_LEN);
out.insert(out.end(), hw, hw + HW_ID_LEN);
return out;
}
uint32_t target_id_for_env(const std::string& env) {
auto h = mh4((const uint8_t*)env.data(), env.size());
uint32_t target_id_for_env(const std::string &env) {
auto h = mh4((const uint8_t *)env.data(), env.size());
return rd_u32(h.data());
}
bool pack_version(const std::string& s, uint32_t& out) {
uint32_t parts[4] = {0,0,0,0}; int n = 0; bool any = false;
std::stringstream ss(s); std::string tok;
std::string target_env_name(uint32_t target_id) {
// Exhaustive target_id -> env-name table, shared verbatim with the firmware and generated from the live
// PlatformIO config (every ENABLE_OTA env) by tools/mota/gen_targets.py. See OtaTargets.h.
const char *env = mesh::ota::ota_target_env_name(target_id);
return env ? env : "";
}
bool pack_version(const std::string &s, uint32_t &out) {
uint32_t parts[4] = { 0, 0, 0, 0 };
int n = 0;
bool any = false;
std::stringstream ss(s);
std::string tok;
while (std::getline(ss, tok, '.') && n < 4) {
if (tok.empty()) return false;
for (char c : tok) if (c < '0' || c > '9') return false;
parts[n++] = (uint32_t)std::strtoul(tok.c_str(), nullptr, 10); any = true;
for (char c : tok)
if (c < '0' || c > '9') return false;
parts[n++] = (uint32_t)std::strtoul(tok.c_str(), nullptr, 10);
any = true;
}
if (!any) return false;
out = ((parts[0] & 0xFF) << 24) | ((parts[1] & 0xFF) << 16) | ((parts[2] & 0xFF) << 8) | (parts[3] & 0xFF);
@@ -173,71 +223,94 @@ bool pack_version(const std::string& s, uint32_t& out) {
// ---- build ----------------------------------------------------------------------------------------
// detools delta encode: write base/new images to temp files, run the detools CLI, read the patch back.
static std::string detools_delta(const std::string& detools, uint8_t codec,
const std::vector<uint8_t>& base_img, const std::vector<uint8_t>& new_img,
uint32_t mem, uint32_t seg, std::vector<uint8_t>& patch) {
static std::string detools_delta(const std::string &detools, uint8_t codec,
const std::vector<uint8_t> &base_img, const std::vector<uint8_t> &new_img,
uint32_t mem, uint32_t seg, std::vector<uint8_t> &patch) {
char fb[] = "/tmp/motaXXXXXX", fn[] = "/tmp/motaXXXXXX", fp[] = "/tmp/motaXXXXXX";
int a = mkstemp(fb), c = mkstemp(fn), d = mkstemp(fp);
if (a < 0 || c < 0 || d < 0) return "mkstemp failed";
close(a); close(c); close(d);
close(a);
close(c);
close(d);
std::string err;
if (!write_file(fb, base_img.data(), base_img.size()) || !write_file(fn, new_img.data(), new_img.size())) {
err = "writing temp images failed";
} else {
std::vector<std::string> argv;
if (codec == CODEC_DETOOLS_SEQUENTIAL)
argv = {detools, "create_patch", "-c", "crle", "-t", "sequential", fb, fn, fp};
argv = { detools, "create_patch", "-c", "crle", "-t", "sequential", fb, fn, fp };
else
argv = {detools, "create_patch_in_place", "-c", "crle",
"--memory-size", std::to_string(mem), "--segment-size", std::to_string(seg), fb, fn, fp};
int rc = run_argv(argv, /*quiet=*/true); // hide detools' success chatter; errors keep stderr
if (rc == 127) err = "could not run detools (install it / pass --detools <path>)";
else if (rc != 0) err = "detools exited with code " + std::to_string(rc);
else if (!read_file(fp, patch) || patch.empty()) err = "reading detools patch failed";
argv = { detools,
"create_patch_in_place",
"-c",
"crle",
"--memory-size",
std::to_string(mem),
"--segment-size",
std::to_string(seg),
fb,
fn,
fp };
int rc = run_argv(argv, /*quiet=*/true); // hide detools' success chatter; errors keep stderr
if (rc == 127)
err = "could not run detools (install it / pass --detools <path>)";
else if (rc != 0)
err = "detools exited with code " + std::to_string(rc);
else if (!read_file(fp, patch) || patch.empty())
err = "reading detools patch failed";
}
unlink(fb); unlink(fn); unlink(fp);
unlink(fb);
unlink(fn);
unlink(fp);
return err;
}
std::string detools_apply_seq(const std::string& detools, const std::vector<uint8_t>& base_img,
const std::vector<uint8_t>& patch, std::vector<uint8_t>& out) {
std::string detools_apply_seq(const std::string &detools, const std::vector<uint8_t> &base_img,
const std::vector<uint8_t> &patch, std::vector<uint8_t> &out) {
char ff[] = "/tmp/motaXXXXXX", fpp[] = "/tmp/motaXXXXXX", ft[] = "/tmp/motaXXXXXX";
int a = mkstemp(ff), c = mkstemp(fpp), d = mkstemp(ft);
if (a < 0 || c < 0 || d < 0) return "mkstemp failed";
close(a); close(c); close(d);
close(a);
close(c);
close(d);
std::string err;
if (!write_file(ff, base_img.data(), base_img.size()) || !write_file(fpp, patch.data(), patch.size())) {
err = "writing temp files failed";
} else {
int rc = run_argv({detools, "apply_patch", ff, fpp, ft}, /*quiet=*/true); // <from> <patch> <to>
if (rc == 127) err = "could not run detools (install it / pass --detools <path>)";
else if (rc != 0) err = "detools apply_patch exited with code " + std::to_string(rc);
else if (!read_file(ft, out) || out.empty()) err = "reading reconstructed image failed";
int rc = run_argv({ detools, "apply_patch", ff, fpp, ft }, /*quiet=*/true); // <from> <patch> <to>
if (rc == 127)
err = "could not run detools (install it / pass --detools <path>)";
else if (rc != 0)
err = "detools apply_patch exited with code " + std::to_string(rc);
else if (!read_file(ft, out) || out.empty())
err = "reading reconstructed image failed";
}
unlink(ff); unlink(fpp); unlink(ft);
unlink(ff);
unlink(fpp);
unlink(ft);
return err;
}
std::string build(const BuildOpts& o, std::vector<uint8_t>& out, std::string& suggested_name) {
std::string build(const BuildOpts &o, std::vector<uint8_t> &out, std::string &suggested_name) {
bool is_delta = !o.base.empty();
// resolve identity: explicit flags override the firmware's self-describing EndF
FwIdent fid = parse_endf_ident(o.fw);
uint32_t target = o.have_target ? o.target_id : fid.target_id;
uint32_t fwver = o.have_fwver ? o.fw_version : fid.fw_version;
std::string hw = !o.hw_id.empty() ? o.hw_id : fid.hw_id;
FwIdent ident{fwver, target, hw};
uint32_t target = o.have_target ? o.target_id : fid.target_id;
uint32_t fwver = o.have_fwver ? o.fw_version : fid.fw_version;
std::string hw = !o.hw_id.empty() ? o.hw_id : fid.hw_id;
FwIdent ident{ fwver, target, hw };
std::array<uint8_t,8> new_bh{}, base_bh{};
std::array<uint8_t, 8> new_bh{}, base_bh{};
std::vector<uint8_t> new_img = ensure_endf(o.fw, ident, new_bh);
uint8_t codec = o.codec;
std::vector<uint8_t> payload;
std::array<uint8_t,8> base_hash{};
std::array<uint8_t, 8> base_hash{};
if (!is_delta) {
codec = CODEC_FULL;
payload = new_img; // full: payload IS the image
payload = new_img; // full: payload IS the image
} else {
if (codec == CODEC_FULL) return "a base image was given but --codec is full";
FwIdent base_id = parse_endf_ident(o.base);
@@ -245,14 +318,14 @@ std::string build(const BuildOpts& o, std::vector<uint8_t>& out, std::string& su
base_hash = base_bh;
// cross-hardware delta guard (read from EndF identity, not filenames)
if (!o.force && base_id.any() && fid.any()) {
bool hw_ok = base_id.hw_id.empty() || fid.hw_id.empty() || base_id.hw_id == fid.hw_id;
bool hw_ok = base_id.hw_id.empty() || fid.hw_id.empty() || base_id.hw_id == fid.hw_id;
bool tgt_ok = !base_id.target_id || !fid.target_id || base_id.target_id == fid.target_id;
if (!hw_ok || !tgt_ok)
return "base/target firmware identity differ (hw '" + base_id.hw_id + "' vs '" + fid.hw_id +
"') — refusing cross-hardware delta (use --force to override)";
}
std::string e = detools_delta(o.detools, codec, base_img, new_img,
o.inplace_memory, o.inplace_segment, payload);
std::string e =
detools_delta(o.detools, codec, base_img, new_img, o.inplace_memory, o.inplace_segment, payload);
if (!e.empty()) return e;
}
@@ -260,7 +333,8 @@ std::string build(const BuildOpts& o, std::vector<uint8_t>& out, std::string& su
std::vector<uint8_t> leaves = leaf_hashes(payload.data(), (uint32_t)payload.size(), bs);
uint32_t bc = (uint32_t)leaves.size() / 4;
if (bc == 0 || bc > 0xFFFFu) return "payload yields an invalid block count";
uint8_t root[4]; merkle_root(root, leaves.data(), bc);
uint8_t root[4];
merkle_root(root, leaves.data(), bc);
auto image_hash = mh32(new_img.data(), new_img.size());
bool signed_ = !o.sign_priv.empty();
@@ -268,18 +342,27 @@ std::string build(const BuildOpts& o, std::vector<uint8_t>& out, std::string& su
// assemble the fixed 197-byte manifest-minus-leaves
std::vector<uint8_t> mf(MOTA_MFL, 0);
mf[M_OFF_FORMAT_VER] = FORMAT_VER; mf[M_OFF_FLAGS] = flags; mf[M_OFF_HASH_ALGO] = HASH_ALGO_SHA256;
mf[M_OFF_FORMAT_VER] = FORMAT_VER;
mf[M_OFF_FLAGS] = flags;
mf[M_OFF_HASH_ALGO] = HASH_ALGO_SHA256;
wr_u32(mf.data() + M_OFF_TARGET_ID, target);
wr_u32(mf.data() + M_OFF_FW_VERSION, fwver);
wr_u32(mf.data() + M_OFF_IMAGE_SIZE, image_size);
wr_u32(mf.data() + M_OFF_PAYLOAD_SIZE, (uint32_t)payload.size());
// block_size_log2
{ uint32_t v = bs, l = 0; while (v > 1) { v >>= 1; l++; } mf[M_OFF_BLOCK_SIZE_LOG2] = (uint8_t)l; }
{
uint32_t v = bs, l = 0;
while (v > 1) {
v >>= 1;
l++;
}
mf[M_OFF_BLOCK_SIZE_LOG2] = (uint8_t)l;
}
std::memcpy(mf.data() + M_OFF_MERKLE_ROOT, root, 4);
std::memcpy(mf.data() + M_OFF_IMAGE_HASH, image_hash.data(), 32);
mf[M_OFF_CODEC_ID] = codec;
std::memcpy(mf.data() + M_OFF_HW_ID, hw.data(), hw.size() < HW_ID_LEN ? hw.size() : HW_ID_LEN);
if (is_delta) std::memcpy(mf.data() + M_OFF_BASE_HASH, base_hash.data(), 8); // zero for full
if (is_delta) std::memcpy(mf.data() + M_OFF_BASE_HASH, base_hash.data(), 8); // zero for full
if (signed_) {
uint8_t pub[32];
if (o.sign_priv.size() != 32) return "signing key must be a 32-byte raw private seed";
@@ -293,21 +376,32 @@ std::string build(const BuildOpts& o, std::vector<uint8_t>& out, std::string& su
// container = MAGIC(4) total(4) manifest leaves[] payload trailer(5)
uint32_t total = 8 + MOTA_MFL + bc * 4 + (uint32_t)payload.size() + 5;
out.clear(); out.reserve(total);
// nRF52 in-place: the staged container sits below the apply workspace; if it's too big it overruns the
// workspace and the bootloader apply fails (DETOOLS_IO_FAILED). Warn so it's caught before shipping.
if (is_delta && codec == CODEC_DETOOLS_INPLACE && total > NRF52_MAX_INPLACE_MOTA)
std::fprintf(stderr, "warning: in-place delta is %u B, exceeding the nRF52 staging room (%u B) — it will "
"NOT apply on the device. Shrink the delta (smaller change) or use a smaller image.\n",
total, NRF52_MAX_INPLACE_MOTA);
out.clear();
out.reserve(total);
out.insert(out.end(), MOTA_MAGIC, MOTA_MAGIC + 4);
uint8_t u[4]; wr_u32(u, total); out.insert(out.end(), u, u + 4);
uint8_t u[4];
wr_u32(u, total);
out.insert(out.end(), u, u + 4);
out.insert(out.end(), mf.begin(), mf.end());
out.insert(out.end(), leaves.begin(), leaves.end());
out.insert(out.end(), payload.begin(), payload.end());
out.insert(out.end(), MOTA_TRAILER, MOTA_TRAILER + 5);
// suggested name: <hw|fw>_<target8>_v<ver>_<kind>_<mid8>.mota (descriptive + unique, one folder)
const char* kind = !is_delta ? "full" : (codec == CODEC_DETOOLS_INPLACE ? "ipdelta" : "seqdelta");
char tgt[9]; std::snprintf(tgt, sizeof(tgt), "%08X", target);
char vbuf[24]; std::snprintf(vbuf, sizeof(vbuf), "%u.%u.%u",
(fwver >> 24) & 0xFF, (fwver >> 16) & 0xFF, (fwver >> 8) & 0xFF);
suggested_name = (hw.empty() ? std::string("fw") : hw) + "_" + tgt + "_v" + vbuf + "_" + kind +
"_" + to_hex(root, 4) + ".mota";
const char *kind = !is_delta ? "full" : (codec == CODEC_DETOOLS_INPLACE ? "ipdelta" : "seqdelta");
char tgt[9];
std::snprintf(tgt, sizeof(tgt), "%08X", target);
char vbuf[24];
std::snprintf(vbuf, sizeof(vbuf), "%u.%u.%u", (fwver >> 24) & 0xFF, (fwver >> 16) & 0xFF,
(fwver >> 8) & 0xFF);
suggested_name = (hw.empty() ? std::string("fw") : hw) + "_" + tgt + "_v" + vbuf + "_" + kind + "_" +
to_hex(root, 4) + ".mota";
return "";
}
+4
View File
@@ -53,6 +53,10 @@ std::vector<uint8_t> ensure_endf(const std::vector<uint8_t>& image, const FwIden
uint32_t target_id_for_env(const std::string& env); // sha2-256:4(env) as LE uint32
bool pack_version(const std::string& s, uint32_t& out); // "1.16.0[.pre]" -> packed uint32
// Reverse-lookup a target_id to its PlatformIO env name from a static table of known OTA-capable envs
// (target_id = sha2-256:4(env_name)). Returns "" if not in the table.
std::string target_env_name(uint32_t target_id);
// ---- build ----
struct BuildOpts {
std::vector<uint8_t> fw; // NEW firmware (raw or already-EndF'd)
+13 -3
View File
@@ -61,10 +61,20 @@ static constexpr uint32_t MOTA_SIGNED_LEN = 129; // signature covers manifest
static constexpr uint32_t DEFAULT_BLOCK_SIZE = 1024;
static constexpr uint8_t DEFAULT_BLOCK_SIZE_LOG2 = 10;
// nRF52 in-place workspace (FS_START 0xD4000 - APP_BASE 0x26000), segment = flash page.
// Mirror of OtaFlashLayout_nrf52.h; used as the default --inplace-memory.
static constexpr uint32_t NRF52_INPLACE_MEMORY = 0x000D4000u - 0x00026000u; // 0xAE000
// nRF52 in-place apply workspace — MUST match src/helpers/ota/OtaFlashLayout_nrf52.h
// (MOTA_NRF52_INPLACE_MEMORY). It is NOT the full [APP_BASE, FS_START) span: the staged .mota itself sits
// just below FS_START, so the bootloader's workspace ends at the staged container (ws_hi = mota_addr), not
// at FS_START. The workspace is [APP_BASE, 0xBE000) = 0x98000, leaving 0xBE000..0xD4000 (~88 KB) for the
// staged delta. A patch built with a larger memory_size overruns the workspace at apply -> DETOOLS_IO_FAILED
// (the apply silently fails and the device just reboots). Reproduced + verified by the bootloader apply
// simulation (Adafruit_nRF52_Bootloader_OTAFIX/test/apply_sim).
static constexpr uint32_t NRF52_INPLACE_MEMORY = 0x00098000u; // 608 KB: [APP_BASE, 0xBE000)
static constexpr uint32_t NRF52_INPLACE_SEGMENT = 4096;
// The staged .mota sits in [APP_BASE+memory, FS_START); a larger container would push its start below the
// workspace end and break the apply the same way. Warn (motatool) / fail (bootloader) past this.
static constexpr uint32_t NRF52_FLASH_SPAN = 0x000D4000u - 0x00026000u; // 0xAE000
static constexpr uint32_t NRF52_MAX_INPLACE_MOTA = NRF52_FLASH_SPAN - NRF52_INPLACE_MEMORY; // 0x16000 (~90 KB)
static_assert(NRF52_INPLACE_MEMORY < NRF52_FLASH_SPAN, "in-place workspace must leave room below FS_START for the staged .mota");
// ---- mota-seeder transport protocol (mirror of src/helpers/ota/MotaSeederProto.h) ----
// Request (client -> server): 'M' 'S' op(1) args... xsum(1 = XOR of op+args)
+28
View File
@@ -3,6 +3,7 @@
#include "mota.h"
#include "crypto.h"
#include "serve.h"
#include "input.h"
#include "util.h"
#include "mota_format.h"
#include <cstring>
@@ -70,6 +71,32 @@ static void t_version_target() {
// known target ids from the firmware builds (ties motatool's hashing to the device)
CHECK(target_id_for_env("Heltec_v3_repeater") == 0xd1b29b18u);
CHECK(target_id_for_env("RAK_4631_repeater") == 0x04d413fdu);
// reverse lookup table (human-readable target_id) -> env name, "" when unknown
CHECK(target_env_name(0xd1b29b18u) == "Heltec_v3_repeater");
CHECK(target_env_name(0x04d413fdu) == "RAK_4631_repeater");
CHECK(target_env_name(0xDEADBEEFu).empty());
// the shared (generated) table must round-trip with our own hashing for a spread of OTA envs —
// this catches any drift between the table's stored ids and sha2-256:4(env)
for (const char* e : {"Tbeam_SX1262_repeater", "Xiao_C3_repeater", "ThinkNode_M2_room_server",
"Ebyte_EoRa-S3_companion_radio_ble", "Heltec_v3_companion_radio_usb"})
CHECK(target_env_name(target_id_for_env(e)) == e);
}
static void t_intel_hex() {
g_test = "intel hex";
fs::path hx = fs::temp_directory_path() / "motatool_test.hex";
// 3 data bytes (01 02 03) at addr 0, then EOF (checksum F7 = two's-complement of the byte sum)
std::string ok = ":03000000010203F7\n:00000001FF\n";
write_file(hx.string(), (const uint8_t*)ok.data(), ok.size());
std::vector<uint8_t> out;
CHECK(read_input(hx.string(), out).empty());
CHECK(out.size() == 3 && out[0] == 1 && out[1] == 2 && out[2] == 3);
// wrong checksum -> rejected
std::string bad = ":03000000010203F8\n:00000001FF\n";
write_file(hx.string(), (const uint8_t*)bad.data(), bad.size());
std::vector<uint8_t> o2;
CHECK(!read_input(hx.string(), o2).empty());
fs::remove(hx);
}
static void t_endf() {
@@ -261,6 +288,7 @@ static void t_folder_and_seeder() {
int main() {
t_crypto();
t_version_target();
t_intel_hex();
t_endf();
t_build_full();
t_build_signed_and_tamper();