From e48792e17820ac41fe65c7b53fd1d02c678b84f7 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 19 Sep 2026 08:30:52 -0700 Subject: [PATCH] Add one-way OTA target migration aliases --- build.sh | 43 +++++++++++++++++++++- docs/ota_protocol.md | 7 +++- src/helpers/ota/OtaContext.h | 5 +++ src/helpers/ota/OtaManager.cpp | 8 +++- src/helpers/ota/OtaManager.h | 9 +++++ test/test_full_infrastructure_selection.sh | 9 +++++ test/test_ota/test_ota_core.cpp | 25 +++++++++++++ 7 files changed, 102 insertions(+), 4 deletions(-) diff --git a/build.sh b/build.sh index 4d8e12975..c5e11c329 100755 --- a/build.sh +++ b/build.sh @@ -2411,6 +2411,33 @@ get_exact_identity_full_pio_env() { fi } +# A legacy observer FULL image can make one automatic OTA hop to the ordinary +# target whose exact-identity FULL image now replaces it. The old image keeps +# its own target ID until that hop, so deployed nodes remain discoverable and +# manual pulls stay target-specific. Only same-partition policy targets get an +# alias; partition-changing targets must continue through their existing path. +get_exact_identity_full_migration_target() { + local env_name=$1 + local successor="" + + case "${env_name,,}" in + *_observer_mqtt) + successor=${env_name%_observer_mqtt} + ;; + *_observer_mqtt_) + successor=${env_name%_observer_mqtt_} + ;; + *) + return 1 + ;; + esac + + is_esp32_full_only_bulk_target "$env_name" || return 1 + is_esp32_full_only_bulk_target "$successor" || return 1 + [ "$(get_exact_identity_full_pio_env "$successor")" = "$env_name" ] || return 1 + printf '%s\n' "$successor" +} + apply_esp32_full_shared_bridge_profile() { local env_name=$1 @@ -4262,6 +4289,8 @@ build_firmware() { local firmware_filename local mota_target_id local mota_target_flag="" + local mota_migration_target="" + local mota_migration_flag="" local original_platformio_build_flags local original_platformio_build_unflags local original_platformio_build_src_filter @@ -4352,6 +4381,18 @@ build_firmware() { || is_companion_radio_full_target "$env_name"; then BUILD_PROFILE_FOR_TARGET="full" fi + + if [ "$ESP32_FULL_BUILD" = "1" ]; then + mota_migration_target=$(get_exact_identity_full_migration_target "$env_name") || mota_migration_target="" + if [ -n "$mota_migration_target" ]; then + local mota_migration_target_id + mota_migration_target_id=$(python3 -c "import hashlib,sys;print('0x%08x'%int.from_bytes(hashlib.sha256(sys.argv[1].encode()).digest()[:4],'little'))" "$mota_migration_target" 2>/dev/null || echo "") + if [ -n "$mota_migration_target_id" ]; then + mota_migration_flag=" -DMOTA_MIGRATION_TARGET_ID=${mota_migration_target_id}" + echo "Legacy FULL ${env_name} will accept successor mOTA target ${mota_migration_target}." + fi + fi + fi configure_unified_full_infrastructure_output "$pio_env_name" echo "Effective feature profile for ${env_name}: ${BUILD_PROFILE_FOR_TARGET}" @@ -4431,7 +4472,7 @@ build_firmware() { original_platformio_extra_scripts="" fi - export PLATFORMIO_BUILD_FLAGS="${original_platformio_build_flags} -DFIRMWARE_BUILD_DATE='\"${firmware_build_date}\"' -DFIRMWARE_BUILD_EPOCH=${firmware_build_epoch} -DFIRMWARE_VERSION='\"${embedded_version_string}\"' -DOTA_VARIANT='\"${env_name}\"'${mota_target_flag}" + export PLATFORMIO_BUILD_FLAGS="${original_platformio_build_flags} -DFIRMWARE_BUILD_DATE='\"${firmware_build_date}\"' -DFIRMWARE_BUILD_EPOCH=${firmware_build_epoch} -DFIRMWARE_VERSION='\"${embedded_version_string}\"' -DOTA_VARIANT='\"${env_name}\"'${mota_target_flag}${mota_migration_flag}" disable_debug_flags "$env_name" apply_debug_overrides "$env_name" apply_mqtt_bridge_override "$env_name" diff --git a/docs/ota_protocol.md b/docs/ota_protocol.md index 4e6961603..d4bc2d9ac 100644 --- a/docs/ota_protocol.md +++ b/docs/ota_protocol.md @@ -712,6 +712,11 @@ transmission per hop. - **`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`. +- **One-way target migration:** a release may compile `MOTA_MIGRATION_TARGET_ID` into a legacy image. While + that image is running, automatic fetch accepts only the declared successor target; manual pulls remain + unchanged. The staged manifest must still match the requested successor target, the signed `hw_id`, image + hash, codec, and partition/layout checks. After reboot the successor's EndF becomes the node's normal + identity, so later updates use the canonical target. The migration alias is not used for bootloader packages. - **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`, @@ -736,7 +741,7 @@ transmission per hop. the app mints an authenticated one-reset media authorization for OTAFIX. **Transfer needs no trust** - blocks are content-addressed against the manifest's merkle root. - **Policies (persisted):** `autofetch` in {off, any, signed} (default off) gates automatic block fetching of - own-target adverts; `autoinstall` in {off, trusted} (default off) gates auto-apply of a COMPLETE signed + + own-target (or an explicitly compiled one-way successor) adverts; `autoinstall` in {off, trusted} (default off) gates auto-apply of a COMPLETE signed + allowlisted fetch. Conservative defaults: a fresh node discovers + announces but never fetches/installs without operator intent. - **Supersession:** a newer version announced mid-download does not abort the in-progress transfer diff --git a/src/helpers/ota/OtaContext.h b/src/helpers/ota/OtaContext.h index 838a6717f..3c4acd4f0 100644 --- a/src/helpers/ota/OtaContext.h +++ b/src/helpers/ota/OtaContext.h @@ -647,6 +647,11 @@ struct OtaContext { target_id = 0; #endif manager.begin(target_id, send, ctx); +#if defined(MOTA_MIGRATION_TARGET_ID) + // Legacy observer images keep their deployed target identity for serving + // and diagnostics, but automatically accept one canonical successor image. + manager.set_auto_migration_target((uint32_t)(MOTA_MIGRATION_TARGET_ID)); +#endif // Every OTA-capable application can receive ordinary raw RFC1951 transport blocks. OtaManager advertises // that permission only because this exact, bounded decoder is registered; non-OTA builds do not compile // OtaContext or link the decoder implementation. diff --git a/src/helpers/ota/OtaManager.cpp b/src/helpers/ota/OtaManager.cpp index 038dd489f..0bafeb3db 100644 --- a/src/helpers/ota/OtaManager.cpp +++ b/src/helpers/ota/OtaManager.cpp @@ -81,6 +81,7 @@ uint8_t* OtaManager::ensureScratch() { void OtaManager::begin(uint32_t my_target_id, OtaSend send, void* ctx) { _target = my_target_id; _send = send; _ctx = ctx; + _migration_target = 0; _fstate = IDLE; _have = 0; _fbc = 0; _paused_from = IDLE; _fetch_error = FETCH_ERROR_NONE; @@ -1099,7 +1100,8 @@ bool OtaManager::wantRow(const uint8_t* mid, uint32_t target, uint32_t fw_versio return memcmp(mid, _desired_mid, 4) == 0 && (_desired_target == 0 || target == _desired_target); if (_desired_target) return target == _desired_target; // cross-target want (role switch) if (_autofetch == AUTOFETCH_OFF) return false; // discover only - if (target != _target) return false; // auto-fetch = our own target + const uint32_t auto_target = _migration_target ? _migration_target : _target; + if (target != auto_target) return false; // own target or one-way successor if (_autofetch == AUTOFETCH_SIGNED && !(flags & MFLAG_SIGNED)) return false; // signed-only policy if (_enforce_auto_version && !ota_trusted_auto_version_allows(_running_fw_version, fw_version)) return false; @@ -1608,7 +1610,9 @@ bool OtaManager::resumeStaged(const uint8_t* want_mid) { return false; } const bool automatic_resume = want_mid == nullptr; - const uint32_t expected_target = automatic_resume ? _target : _fexpected_target; + const uint32_t expected_target = automatic_resume + ? (_migration_target ? _migration_target : _target) + : _fexpected_target; if (!_fetch->reopenFor(want_mid, expected_target)) return false; uint32_t total = _fetch->staged_size(); uint8_t hdr[8]; diff --git a/src/helpers/ota/OtaManager.h b/src/helpers/ota/OtaManager.h index e2614cb11..a519e8f46 100644 --- a/src/helpers/ota/OtaManager.h +++ b/src/helpers/ota/OtaManager.h @@ -455,6 +455,14 @@ public: static const uint8_t AUTOFETCH_OFF = 0, AUTOFETCH_ANY = 1, AUTOFETCH_SIGNED = 2; void set_autofetch(uint8_t p) { _autofetch = p; reDiscover(); } uint8_t autofetch() const { return _autofetch; } + // One-way release migration for a legacy identity. Automatic OTA follows the + // successor target while the running firmware continues to report its own + // target until that successor is installed. Manual pulls are unaffected. + void set_auto_migration_target(uint32_t target_id) { + _migration_target = target_id != _target ? target_id : 0; + reDiscover(); + } + uint32_t auto_migration_target() const { return _migration_target; } void set_auto_version_floor(uint32_t running_version, bool enforce = true) { _running_fw_version = running_version; _enforce_auto_version = enforce; @@ -728,6 +736,7 @@ private: MerkleAccumulator _resume_merkle; uint32_t _req_start = 0, _req_count = 0; // most recently requested block + active flight size uint32_t _desired_target = 0; // manual cross-target override (0 = auto / own target) + uint32_t _migration_target = 0; // one-way automatic successor target for legacy releases uint8_t _desired_mid[4] = {0,0,0,0}; // pull a specific manifest_id (see want_mid) bool _have_desired_mid = false; uint8_t _apply_codec = CODEC_DETOOLS_SEQUENTIAL; // platform's delta codec (OtaContext sets it) diff --git a/test/test_full_infrastructure_selection.sh b/test/test_full_infrastructure_selection.sh index 1bf0adba5..eab9dc5a6 100644 --- a/test/test_full_infrastructure_selection.sh +++ b/test/test_full_infrastructure_selection.sh @@ -71,6 +71,15 @@ done [ "$(get_exact_identity_full_pio_env heltec_v4_r8_sensor)" \ = heltec_v4_r8_sensor ] \ || fail "V4 R8 sensor should retain its own feature base" +[ "$(get_exact_identity_full_migration_target heltec_v4_r8_repeater_observer_mqtt)" \ + = heltec_v4_r8_repeater ] \ + || fail "legacy V4 R8 observer lacks its canonical migration target" +if get_exact_identity_full_migration_target heltec_v4_r8_repeater >/dev/null; then + fail "ordinary V4 R8 target unexpectedly has a migration alias" +fi +if get_exact_identity_full_migration_target heltec_v4_r8_repeater_observer_mqtt_sim >/dev/null; then + fail "simulated observer target unexpectedly received a migration alias" +fi # Portable images and exact reduced OTA identities must not be consolidated. for target in heltec_v4_r8_repeater heltec_v4_r8_room_server \ diff --git a/test/test_ota/test_ota_core.cpp b/test/test_ota/test_ota_core.cpp index d247807ed..0b89461ec 100644 --- a/test/test_ota/test_ota_core.cpp +++ b/test/test_ota/test_ota_core.cpp @@ -2443,6 +2443,31 @@ TEST(OtaTransfer, TwoManagersFullTransfer) { EXPECT_TRUE(mota_check_image_hash_full(m)); } +TEST(OtaTransfer, LegacyIdentityAutomaticallyFetchesItsSuccessor) { + g_q.clear(); + OtaManager server, client; + OtaStoreRam<4096> store; + SendTo to_client{&client}, to_server{&server}; + const uint32_t legacy_target = SIM_TARGET_ID ^ 0x13579BDFu; + + server.begin(/*server's own target irrelevant for serving*/ 0, sim_send, &to_client); + client.begin(legacy_target, sim_send, &to_server); + client.set_auto_migration_target(SIM_TARGET_ID); + client.set_fetch_store(&store); + client.set_autofetch(OtaManager::AUTOFETCH_ANY); + + ASSERT_TRUE(server.serve(SIM_MOTA, SIM_MOTA_LEN)); + server.announce(); + pump(client, &server); + + ASSERT_EQ(client.fetchState(), OtaManager::COMPLETE); + MotaManifest migrated; + ASSERT_TRUE(mota_parse(store.data(), store.staged_size(), migrated)); + EXPECT_EQ(migrated.target_id, SIM_TARGET_ID); + EXPECT_EQ(client.target(), legacy_target); // identity changes only after reboot/apply + EXPECT_EQ(client.auto_migration_target(), SIM_TARGET_ID); +} + TEST(OtaTransfer, LegacyOneKilobyteV2CodecStillStagesOriginalContainer) { g_q.clear(); MotaManifest manifest;