diff --git a/.github/workflows/dev-firmware-rolling.yml b/.github/workflows/dev-firmware-rolling.yml index d1ccf6ec..564e6813 100644 --- a/.github/workflows/dev-firmware-rolling.yml +++ b/.github/workflows/dev-firmware-rolling.yml @@ -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-\`. - 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 diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 830fc745..04620b3e 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -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. diff --git a/src/helpers/ota/OtaApply.cpp b/src/helpers/ota/OtaApply.cpp index 4e7fa075..f63f4d68 100644 --- a/src/helpers/ota/OtaApply.cpp +++ b/src/helpers/ota/OtaApply.cpp @@ -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 diff --git a/src/helpers/ota/OtaApply.h b/src/helpers/ota/OtaApply.h index 2b424e19..42e2f7b1 100644 --- a/src/helpers/ota/OtaApply.h +++ b/src/helpers/ota/OtaApply.h @@ -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 diff --git a/src/helpers/ota/OtaBlInfo.h b/src/helpers/ota/OtaBlInfo.h new file mode 100644 index 00000000..d22468fa --- /dev/null +++ b/src/helpers/ota/OtaBlInfo.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include + +// 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 diff --git a/src/helpers/ota/OtaCli.cpp b/src/helpers/ota/OtaCli.cpp index b71b7dd0..f49303b9 100644 --- a/src/helpers/ota/OtaCli.cpp +++ b/src/helpers/ota/OtaCli.cpp @@ -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 #include @@ -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 diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 7daf1082..2a67c238 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -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(); diff --git a/src/helpers/ota/OtaFlashLayout_nrf52.h b/src/helpers/ota/OtaFlashLayout_nrf52.h index 220a65be..289348fd 100644 --- a/src/helpers/ota/OtaFlashLayout_nrf52.h +++ b/src/helpers/ota/OtaFlashLayout_nrf52.h @@ -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 diff --git a/src/helpers/ota/OtaTargets.h b/src/helpers/ota/OtaTargets.h new file mode 100644 index 00000000..c1da545b --- /dev/null +++ b/src/helpers/ota/OtaTargets.h @@ -0,0 +1,328 @@ +#pragma once +#include + +// 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 diff --git a/tools/mota/README.md b/tools/mota/README.md index e5417d0d..06c8eb87 100644 --- a/tools/mota/README.md +++ b/tools/mota/README.md @@ -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 diff --git a/tools/mota/dev_motas.py b/tools/mota/dev_motas.py deleted file mode 100644 index 5b6ad90d..00000000 --- a/tools/mota/dev_motas.py +++ /dev/null @@ -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 : /firmware.bin (pio_endf has already appended EndF) - nRF52 : app region of /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 .full.mota and (best-effort) .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()) diff --git a/tools/mota/gen_targets.py b/tools/mota/gen_targets.py new file mode 100644 index 00000000..ff25928d --- /dev/null +++ b/tools/mota/gen_targets.py @@ -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 ", + "", + "// 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() diff --git a/tools/motatool/CMakeLists.txt b/tools/motatool/CMakeLists.txt index a13da0aa..4a153063 100644 --- a/tools/motatool/CMakeLists.txt +++ b/tools/motatool/CMakeLists.txt @@ -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) diff --git a/tools/motatool/README.md b/tools/motatool/README.md index 9ed451b9..d934b8c4 100644 --- a/tools/motatool/README.md +++ b/tools/motatool/README.md @@ -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). diff --git a/tools/motatool/src/input.cpp b/tools/motatool/src/input.cpp index ddc99f27..bb0a4b12 100644 --- a/tools/motatool/src/input.cpp +++ b/tools/motatool/src/input.cpp @@ -1,5 +1,7 @@ #include "input.h" #include "util.h" +#include +#include #include 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& 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& in, std::vector& 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 data; }; + std::vector 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 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& out) { + std::vector 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 diff --git a/tools/motatool/src/main.cpp b/tools/motatool/src/main.cpp index e38dc3eb..7e8bb0b6 100644 --- a/tools/motatool/src/main.cpp +++ b/tools/motatool/src/main.cpp @@ -112,6 +112,7 @@ static void help_build() { "\n" "INPUT (--fw is required)\n" " --fw 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 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") diff --git a/tools/motatool/src/mota.cpp b/tools/motatool/src/mota.cpp index 8703c65c..5b371965 100644 --- a/tools/motatool/src/mota.cpp +++ b/tools/motatool/src/mota.cpp @@ -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 + +#include #include +#include #include 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 leaf_hashes(const uint8_t* payload, uint32_t size, uint32_t bs) { +static std::vector leaf_hashes(const uint8_t *payload, uint32_t size, uint32_t bs) { uint32_t bc = (size + bs - 1) / bs; std::vector leaves(bc * 4); for (uint32_t i = 0; i < bc; i++) { @@ -39,37 +65,40 @@ static std::vector 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& b, Manifest& m) { +std::string parse(const std::vector &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& b, Manifest& m) { } // ---- verify ---------------------------------------------------------------------------------------- -std::vector verify(const std::vector& b) { +std::vector verify(const std::vector &b) { std::vector 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 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 verify(const std::vector& b) { } // ---- EndF ----------------------------------------------------------------------------------------- -bool has_endf(const std::vector& img) { +bool has_endf(const std::vector &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& img) { +FwIdent parse_endf_ident(const std::vector &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 ensure_endf(const std::vector& img, const FwIdent& id, - std::array& 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 ensure_endf(const std::vector &img, const FwIdent &id, + std::array &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 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& base_img, const std::vector& new_img, - uint32_t mem, uint32_t seg, std::vector& patch) { +static std::string detools_delta(const std::string &detools, uint8_t codec, + const std::vector &base_img, const std::vector &new_img, + uint32_t mem, uint32_t seg, std::vector &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 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 )"; - 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 )"; + 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& base_img, - const std::vector& patch, std::vector& out) { +std::string detools_apply_seq(const std::string &detools, const std::vector &base_img, + const std::vector &patch, std::vector &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); // - if (rc == 127) err = "could not run detools (install it / pass --detools )"; - 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); // + if (rc == 127) + err = "could not run detools (install it / pass --detools )"; + 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& out, std::string& suggested_name) { +std::string build(const BuildOpts &o, std::vector &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 new_bh{}, base_bh{}; + std::array new_bh{}, base_bh{}; std::vector new_img = ensure_endf(o.fw, ident, new_bh); uint8_t codec = o.codec; std::vector payload; - std::array base_hash{}; + std::array 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& 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& out, std::string& su std::vector 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& out, std::string& su // assemble the fixed 197-byte manifest-minus-leaves std::vector 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& 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: __v__.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 ""; } diff --git a/tools/motatool/src/mota.h b/tools/motatool/src/mota.h index cff21e3c..e3024027 100644 --- a/tools/motatool/src/mota.h +++ b/tools/motatool/src/mota.h @@ -53,6 +53,10 @@ std::vector ensure_endf(const std::vector& 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 fw; // NEW firmware (raw or already-EndF'd) diff --git a/tools/motatool/src/mota_format.h b/tools/motatool/src/mota_format.h index 1c0f875d..d26ac64e 100644 --- a/tools/motatool/src/mota_format.h +++ b/tools/motatool/src/mota_format.h @@ -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) diff --git a/tools/motatool/tests/test_motatool.cpp b/tools/motatool/tests/test_motatool.cpp index a5741264..87b7bd97 100644 --- a/tools/motatool/tests/test_motatool.cpp +++ b/tools/motatool/tests/test_motatool.cpp @@ -3,6 +3,7 @@ #include "mota.h" #include "crypto.h" #include "serve.h" +#include "input.h" #include "util.h" #include "mota_format.h" #include @@ -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 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 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();