diff --git a/build_legacy.sh b/build_legacy.sh index 3b4eb5fca..f33279ab4 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -2428,7 +2428,8 @@ get_unified_full_infrastructure_target() { # These plain Full recipes retain more routing capacity than their MQTT # siblings (T-Beam flood rules / room neighbors, TLora repeater neighbors). - # They are intentional alternatives, not duplicate Full artifacts. + # Keep them available for an explicit exact-identity build; the ordinary + # release chooses the MQTT observer for these board/role groups. case "${mqtt_base,,}" in tbeam_sx1262_repeater|tbeam_sx1276_repeater|\ tbeam_sx1262_room_server|tbeam_sx1276_room_server|\ @@ -5412,8 +5413,10 @@ configure_effective_build_profile() { if [ "$EXACT_IDENTITY_FULL_BUILD" = "1" ]; then if [ "$command_name" != "build-firmware" ] \ || [ "${#RESOLVED_BUILD_TARGETS[@]}" -ne 1 ] \ - || ! is_esp32_canonical_full_release_target "$target"; then - echo "--full-exact requires one canonical ESP32 infrastructure target." + || [ "${PIO_ENV_PLATFORM_BY_NAME[$target]:-}" != ESP32_PLATFORM ] \ + || ! supports_esp32_full_build "$target" \ + || is_esp32_companion_build "$target"; then + echo "--full-exact requires one supported ESP32 infrastructure target." return 1 fi BUILD_PROFILE_EFFECTIVE="full" @@ -5810,6 +5813,69 @@ get_esp32_full_profile_target() { echo "$target" } +# The ordinary release offers one Full image per physical board and role. +# Keep the exact environment names for direct --full-exact builds and for +# legacy/partition migration packages; this selection applies only to bulk +# Full passes. Include the board in the key so similarly named recipes cannot +# collapse distinct hardware. +get_ordinary_full_group_key() { + local target=$1 + local base=${target%_} + local role_base=$base + + [ "${PIO_ENV_PLATFORM_BY_NAME[$target]:-}" = ESP32_PLATFORM ] || return 1 + supports_esp32_full_build "$target" || return 1 + case "${base,,}" in + *_repeater_observer_mqtt|*_room_server_observer_mqtt) + role_base=${base%_observer_mqtt} ;; + *_repeater_bridge_espnow) + role_base=${base%_bridge_espnow} ;; + *_repeater|*_room_server) ;; + *) role_base=$base ;; + esac + printf '%s|%s\n' "${PIO_ENV_BOARD_BY_NAME[$target]:-$target}" "${role_base,,}" +} + +get_ordinary_full_priority() { + local target=$1 + local base=${target%_} + case "${base,,}" in + *_observer_mqtt) echo 20 ;; + *_bridge_espnow) echo 10 ;; + *) + # G2's deployed Full identity is the observer. Other audited plain + # targets keep their own identity, with observer sources where needed. + if is_ordinary_partition_migration_full_target "$target" \ + || is_esp32_full_only_bulk_target "$target"; then + echo 30 + else + echo 0 + fi ;; + esac +} + +select_ordinary_full_targets() { + local target candidate key priority + local -a keys=() + local -A choice=() best_priority=() + for target in "$@"; do + candidate=$(get_esp32_full_profile_target "$target") + key=$(get_ordinary_full_group_key "$candidate") || continue + priority=$(get_ordinary_full_priority "$candidate") + if [ -z "${choice[$key]+x}" ]; then + keys+=("$key") + choice[$key]=$candidate + best_priority[$key]=$priority + elif [ "$priority" -gt "${best_priority[$key]}" ]; then + choice[$key]=$candidate + best_priority[$key]=$priority + fi + done + for key in "${keys[@]}"; do + printf '%s\n' "${choice[$key]}" + done +} + has_esp32_full_profile() { local target local candidate="" @@ -5845,6 +5911,15 @@ run_full_esp32_profile() { for target in "${targets[@]}"; do full_profile_target=$(get_esp32_full_profile_target "$target") + # An earlier exact-identity pass already emitted this release's chosen + # Full image. Do this before resolving a plain target to its observer + # source, which would otherwise emit a second OTA identity. + if { [ "${PARTITION_MIGRATION_FULL_PROFILE_ACTIVE:-0}" = 1 ] \ + && is_ordinary_partition_migration_full_target "$full_profile_target"; } \ + || { [ "${FULL_ONLY_EXACT_PROFILE_ACTIVE:-0}" = 1 ] \ + && is_esp32_full_only_bulk_target "$full_profile_target"; }; then + continue + fi # Use the same exact-board choice as single-target auto builds, including # historical trailing-underscore recipes such as Heltec T190. mqtt_target=$(get_unified_full_infrastructure_target "$full_profile_target") || mqtt_target="" @@ -5866,21 +5941,6 @@ run_full_esp32_profile() { if [ -z "$full_target" ] || ! supports_esp32_full_build "$full_target"; then continue fi - # The canonical normal-target FULL artifact was already emitted by the - # partition-migration pass. Do not rebuild it as an identical fallback. - # Observer identities are intentionally not skipped: existing observer - # nodes still need their exact legacy mOTA asset during the transition. - if [ "${PARTITION_MIGRATION_FULL_PROFILE_ACTIVE:-0}" = "1" ] \ - && is_esp32_partition_migration_full_target "$full_target"; then - continue - fi - # The logging matrix has already emitted these approved targets under - # their exact environment names. Do not replace that identity with a - # generic MQTT/unified sibling in this legacy resolution pass. - if [ "${FULL_ONLY_EXACT_PROFILE_ACTIVE:-0}" = "1" ] \ - && is_esp32_full_only_bulk_target "$full_target"; then - continue - fi if [ -z "${seen_full_targets[$full_target]+x}" ]; then full_targets+=("$full_target") seen_full_targets["$full_target"]=1 @@ -6029,9 +6089,23 @@ run_full_esp32_build_targets() { local profile_mode=$1 shift local targets=("$@") + local ordinary_full_targets=() + local full_only_targets=() + local target local profile_name="FULL unified" local build_status=0 local pass_status=0 + local FULL_ONLY_EXACT_PROFILE_ACTIVE=0 + local PARTITION_MIGRATION_FULL_PROFILE_ACTIVE=0 + + if [ "${SINGLE_TARGET_FULL_BUILD:-0}" = 1 ]; then + # A named single-target build is an explicit operator choice. Keep its + # historical observer/bridge resolution rather than applying bulk release + # identity selection to it. + ordinary_full_targets=("${targets[@]}") + else + mapfile -t ordinary_full_targets < <(select_ordinary_full_targets "${targets[@]}") + fi if [ "$profile_mode" = "fallback" ]; then profile_name="FULL logging fallback" @@ -6039,15 +6113,32 @@ run_full_esp32_build_targets() { LOGGING_MATRIX_FAILURES=() if [ "$profile_mode" = "fallback" ]; then - run_full_esp32_profile "${profile_name}-only build" "fallback" "${targets[@]}" + run_full_esp32_profile "${profile_name}-only build" "fallback" "${ordinary_full_targets[@]}" build_status=$? else - run_full_esp32_profile "${profile_name} build" "unified" "${targets[@]}" + if [ "${SINGLE_TARGET_FULL_BUILD:-0}" != 1 ]; then + for target in "${ordinary_full_targets[@]}"; do + if is_esp32_full_only_bulk_target "$target"; then + full_only_targets+=("$target") + fi + done + run_full_only_esp32_profile "${full_only_targets[@]}" + pass_status=$? + if [ "$pass_status" -eq 130 ]; then return 130; fi + if [ "$pass_status" -ne 0 ]; then build_status=1; fi + run_partition_migration_full_esp32_profile "${ordinary_full_targets[@]}" + pass_status=$? + if [ "$pass_status" -eq 130 ]; then return 130; fi + if [ "$pass_status" -ne 0 ]; then build_status=1; fi + FULL_ONLY_EXACT_PROFILE_ACTIVE=1 + PARTITION_MIGRATION_FULL_PROFILE_ACTIVE=1 + fi + run_full_esp32_profile "${profile_name} build" "unified" "${ordinary_full_targets[@]}" pass_status=$? if [ "$pass_status" -eq 130 ]; then return 130; fi if [ "$pass_status" -ne 0 ]; then build_status=1; fi - run_full_esp32_profile "FULL logging fallback build" "fallback" "${targets[@]}" + run_full_esp32_profile "FULL logging fallback build" "fallback" "${ordinary_full_targets[@]}" pass_status=$? if [ "$pass_status" -eq 130 ]; then return 130; fi if [ "$pass_status" -ne 0 ]; then build_status=1; fi @@ -6070,6 +6161,7 @@ run_logging_matrix_build_targets() { local target local standard_targets=() local full_only_targets=() + local ordinary_full_targets=() local original_meshdebug_override=$MESHDEBUG_OVERRIDE local original_packet_logging_override=$PACKET_LOGGING_OVERRIDE local original_mqtt_bridge_override=$MQTT_BRIDGE_OVERRIDE @@ -6094,6 +6186,7 @@ run_logging_matrix_build_targets() { fi LOGGING_MATRIX_FAILURES=() LOGGING_MATRIX_DEFERRED_TARGETS=() + mapfile -t ordinary_full_targets < <(select_ordinary_full_targets "${targets[@]}") if [ "${REQUIRE_OTA_UPDATES:-0}" = "1" ]; then printf '%s\n' "${OTA_EXCLUDED_TARGETS[@]}" > "${OUTPUT_DIR}/ota-excluded-targets.txt" fi @@ -6102,14 +6195,11 @@ run_logging_matrix_build_targets() { for target in "${targets[@]}"; do if is_esp32_full_only_bulk_target "$target"; then - full_only_exact_count=$((full_only_exact_count + 1)) # Full companion environments already select their complete profile in # build_firmware, so keep them in the ordinary pass to preserve their # established companion-specific recipe. if is_companion_radio_full_target "$target"; then standard_targets+=("$target") - else - full_only_targets+=("$target") fi continue fi @@ -6129,6 +6219,14 @@ run_logging_matrix_build_targets() { fi done + for target in "${ordinary_full_targets[@]}"; do + if is_esp32_full_only_bulk_target "$target" \ + && ! is_companion_radio_full_target "$target"; then + full_only_targets+=("$target") + full_only_exact_count=$((full_only_exact_count + 1)) + fi + done + echo "Profile 1/2: building ${#standard_targets[@]} standard target(s); ${merged_usb_logging_count} embed runtime-controlled USB logging in the ordinary artifact." if [ "$constrained_merged_logging_count" -gt 0 ]; then echo "Keeping verbose MESH_DEBUG off for ${constrained_merged_logging_count} size-constrained STM32 target(s); packet logging remains available at runtime." @@ -6139,7 +6237,7 @@ run_logging_matrix_build_targets() { if [ "$full_only_exact_count" -gt 0 ]; then echo "Publishing ${full_only_exact_count} audited ESP32 target(s) as their exact-identity FULL release only; explicit --standard remains available for recovery." fi - for target in "${targets[@]}"; do + for target in "${ordinary_full_targets[@]}"; do if is_ordinary_partition_migration_full_target "$target"; then partition_migration_full_count=$((partition_migration_full_count + 1)) fi @@ -6166,19 +6264,19 @@ run_logging_matrix_build_targets() { if [ "$pass_status" -eq 130 ]; then return 130; fi if [ "$pass_status" -ne 0 ]; then build_status=1; fi - run_partition_migration_full_esp32_profile "${targets[@]}" + run_partition_migration_full_esp32_profile "${ordinary_full_targets[@]}" pass_status=$? if [ "$pass_status" -eq 130 ]; then return 130; fi if [ "$pass_status" -ne 0 ]; then build_status=1; fi PARTITION_MIGRATION_FULL_PROFILE_ACTIVE=1 FULL_ONLY_EXACT_PROFILE_ACTIVE=1 - run_full_esp32_profile "FULL unified pass" "unified" "${targets[@]}" + run_full_esp32_profile "FULL unified pass" "unified" "${ordinary_full_targets[@]}" pass_status=$? if [ "$pass_status" -eq 130 ]; then return 130; fi if [ "$pass_status" -ne 0 ]; then build_status=1; fi - run_full_esp32_profile "FULL logging fallback pass" "fallback" "${targets[@]}" + run_full_esp32_profile "FULL logging fallback pass" "fallback" "${ordinary_full_targets[@]}" pass_status=$? if [ "$pass_status" -eq 130 ]; then return 130; fi if [ "$pass_status" -ne 0 ]; then build_status=1; fi diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index 9d0b39566..83f72de06 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -46,15 +46,22 @@ an old or failed artifact without a verified sidecar. For a single ESP32 repeater or room-server target, `auto` selects its matching combined Full MQTT/USB/WiFi image when that image covers the plain Full recipe. The direct observer and Full-matrix paths use the same `full-usb-wifi-ota` -artifact and runtime output controls. Plain Full T-Beam SX1262/SX1276 profiles -and the TLora V2 repeater remain alternatives because their MQTT siblings have -lower routing-table capacities. +artifact and runtime output controls. The ordinary bulk release publishes one +Full OTA identity per exact ESP32 board and role. Audited plain targets keep +their identity while compiling the combined observer/ESP-NOW sources; G2 keeps +its deployed observer identity. T-Beam SX1262/SX1276 and TLora V2 choose the +observer in the ordinary release. Their plain Full recipes remain available +as explicit alternatives with different routing capacities. Build one with +`bash build.sh build-firmware --full-exact`, then deliberately install +it over LoRa with `ota pull flash` and `ota install` on compatible +hardware and partition layouts. Standard and reduced OTA images, including images that fit the 1.25 MiB portable application slot, remain available and remain in the standard matrix pass. Select `--build-profile standard` to retain the original target and partition contract. Moving from a plain target to its MQTT sibling changes the -LoRa OTA target identity; existing nodes need an appropriate wired migration. +LoRa OTA target identity; a partition layout change needs its matching +migration step. For the normal-role partition migrations, the matrix also publishes a canonical Full image under the normal target identity. Install its matching merged image over USB once; mOTA rejects the incompatible partition signature diff --git a/docs/ota_user_guide.md b/docs/ota_user_guide.md index 25910d5bd..ca55def26 100644 --- a/docs/ota_user_guide.md +++ b/docs/ota_user_guide.md @@ -205,7 +205,9 @@ long ago it was seen. The fit marker: in-place delta. A matched external-QSPI nRF52 can accept that full codec. - **[rescue]** - an installable in-place nRF52 delta for the same target, but this running firmware has no valid app-side EndF. It requires the explicit rescue download and install flow below. -- **[name]** - a different known board or role (for example `[ProMicro_companion_radio_usb]`). Don't install it. +- **[name]** - a different known board or role (for example `[ProMicro_companion_radio_usb]`). + Install it only if you deliberately chose a different role for the same hardware; the installer + still checks the hardware identity and partition layout. - **[?]** - can't tell (a build with no target id set, e.g. a bare IDE build rather than a release build). Run it again after a few seconds - discovery happens in the background, so the list fills in. Nothing is @@ -233,6 +235,16 @@ host folder - nothing is staged on this node. That's how you grab an **exact cop firmware** off the mesh (to a `.mota` file) so you can later build a *delta* against firmware you don't otherwise have. (`ota get` is an alias.) +**Deliberate alternate image.** An operator can choose a different target ID +for the same physical board by selecting its exact manifest ID from `ota ls`, +then running `ota pull flash` and `ota install`. The target ID controls +which image is discovered and fetched; it does not lock manual installs to +the current role. The hardware identity, codec, image hash, and partition +checks still run. Automatic updates follow the firmware's selected target +(or its declared one-way successor), so use `ota config autofetch off` and +`ota config autoinstall off` if you want to keep an alternate image without +automatic convergence. + **`validate` (warm-start, advanced).** Capturing a full image over the radio is slow. If you have a *similar* build on the computer (e.g. a fresh recompile of the same firmware), run motatool with `--seed ` and add **`validate`**: the node fetches just the target's block fingerprints, keeps diff --git a/scripts/build_local_release.py b/scripts/build_local_release.py index f35fa83e0..1ecba77ae 100644 --- a/scripts/build_local_release.py +++ b/scripts/build_local_release.py @@ -97,6 +97,47 @@ def build_migration_artifacts(work: Path, version: str, short_source: str, raise RuntimeError(f"migration utility {bridge} failed; see {log}") +def select_ordinary_full_records(records: list[dict]) -> list[dict]: + """Keep one ESP32 Full OTA identity per board/role in the public matrix. + + Explicit alternate images and old partition identities still belong in + their separately built migration packages or direct --full-exact builds. + This also removes stale sibling artifacts when a matrix build is resumed. + """ + chosen: dict[str, tuple[int, dict]] = {} + passthrough: list[dict] = [] + for record in records: + manifest = record["manifest"] + if manifest["platform"] != "ESP32_PLATFORM" or manifest["build_profile"] != "full": + passthrough.append(record) + continue + target = manifest["target"] + base = target.rstrip("_") + kind = "plain" + if base.lower().endswith("_observer_mqtt"): + base = base[:-len("_observer_mqtt")] + kind = "observer" + elif base.lower().endswith("_bridge_espnow"): + base = base[:-len("_bridge_espnow")] + kind = "bridge" + key = base.lower() + # G2's observer is the deployed Full identity. The other listed + # observer/bridge recipes offer features that their plain images do + # not combine, so keep the richer ordinary release choice. + special = key in { + "station_g2_repeater", "station_g2_room_server", + "tbeam_sx1262_repeater", "tbeam_sx1276_repeater", + "tbeam_sx1262_room_server", "tbeam_sx1276_room_server", + "lilygo_tlora_v2_1_1_6_repeater", + "meshadventurer_sx1262_repeater", "meshadventurer_sx1268_repeater", + } + priority = {"observer": 20, "bridge": 10, "plain": 0 if special else 30}[kind] + old = chosen.get(key) + if old is None or priority > old[0]: + chosen[key] = (priority, record) + return passthrough + [item[1] for item in chosen.values()] + + def stage_release(work: Path, migration_work: Path, destination: Path, version: str, source: str) -> None: if destination.exists(): @@ -105,10 +146,7 @@ def stage_release(work: Path, migration_work: Path, destination: Path, version: short_source = source[:8] tooling_source = git("rev-parse", "HEAD") artifact_version = f"{version}-{short_source}" - records = [record for record in collect_artifacts(work, artifact_version) - if not (record["manifest"]["build_profile"] == "full" - and record["manifest"]["target"] in { - "Station_G2_repeater", "Station_G2_room_server"})] + records = select_ordinary_full_records(collect_artifacts(work, artifact_version)) if not records: raise ValueError("firmware matrix produced no qualified artifacts") diff --git a/test/test_full_infrastructure_selection.sh b/test/test_full_infrastructure_selection.sh index 27c56b747..1f40b240c 100644 --- a/test/test_full_infrastructure_selection.sh +++ b/test/test_full_infrastructure_selection.sh @@ -30,9 +30,6 @@ assert_auto_unified() ( } run_command build-firmware >/dev/null [ "${built[*]}" = "$expected" ] || fail "auto emitted duplicate Full artifacts" - built=() - run_full_esp32_build_targets all "$plain" "$expected" >/dev/null - [ "${built[*]}" = "$expected" ] || fail "matrix emitted duplicate Full artifacts" ) assert_auto_unified heltec_v4_r8_repeater heltec_v4_r8_repeater_observer_mqtt @@ -62,6 +59,28 @@ assert_auto_unified Station_G2_room_server Station_G2_room_server_observer_mqtt run_command build-firmware >/dev/null ) +# Operators may still build an alternate Full identity that is omitted from +# the ordinary bulk release, including capacity-specific plain recipes. +for alternate in Tbeam_SX1262_repeater \ + Tbeam_SX1262_repeater_bridge_espnow \ + LilyGo_TLora_V2_1_1_6_repeater; do + ( + BUILD_PROFILE_OVERRIDE=full + BUILD_PROFILE_EXPLICIT=1 + SINGLE_TARGET_FULL_BUILD=1 + EXACT_IDENTITY_FULL_BUILD=1 + RESOLVED_BUILD_TARGETS=("$alternate") + configure_effective_build_profile build-firmware >/dev/null + [ "${RESOLVED_BUILD_TARGETS[*]}" = "$alternate" ] \ + || fail "alternate Full target changed identity: $alternate" + run_logged_build_targets() { + [ "$*" = "$alternate" ] || fail "alternate Full built another target" + [ "$ESP32_FULL_BUILD" = 1 ] || fail "alternate Full lost expanded partitions" + } + run_command build-firmware >/dev/null + ) +done + # Only targets whose standard and FULL board recipes retain the exact same # partition table may omit their redundant portable bulk artifact. full_only_count=0 @@ -259,8 +278,8 @@ done heltec_v4_r8_repeater_observer_mqtt >/dev/null [ "$standard_built" = heltec_v4_r8_repeater_lora_ota_no_external_sensors ] \ || fail "matrix changed the distinct reduced OTA build" - [ "$full_built" = 'heltec_v4_r8_repeater heltec_v4_r8_repeater_observer_mqtt' ] \ - || fail "matrix did not retain V4's exact and legacy Full identities" + [ "$full_built" = heltec_v4_r8_repeater ] \ + || fail "matrix emitted duplicate V4 Full identities" [ "$ESP32_LORA_OTA_APP_LIMIT" = 1310720 ] || fail "changed the 1.25 MiB portable limit" ) @@ -273,8 +292,53 @@ done run_logging_matrix_build_targets heltec_rc32_repeater \ heltec_rc32_repeater_bridge_espnow >/dev/null [ "${#calls[@]}" -eq 1 ] || fail "FULL-only targets emitted duplicate matrix artifacts" - [ "${calls[0]}" = 'full:1:full-logging:heltec_rc32_repeater heltec_rc32_repeater_bridge_espnow' ] \ - || fail "matrix did not build exact FULL-only identities" + [ "${calls[0]}" = 'full:1:full-logging:heltec_rc32_repeater' ] \ + || fail "matrix emitted duplicate RC32 Full identities" +) + +# A three-identity group keeps the audited plain target; a group without an +# audited plain Full uses the feature-complete observer. Different hardware +# variants must remain separate. +( + calls=() + run_logged_build_targets() { + [ "$ESP32_FULL_BUILD" = 1 ] && calls+=("$*") + } + run_logging_matrix_build_targets Heltec_v3_repeater \ + Heltec_v3_repeater_observer_mqtt Heltec_v3_repeater_bridge_espnow \ + Tbeam_SX1262_repeater_bridge_espnow Tbeam_SX1262_repeater_observer_mqtt \ + Heltec_v3_room_server >/dev/null + [ "${#calls[@]}" -eq 2 ] || fail "multi-role matrix emitted too many Full passes" + [ "${calls[0]}" = 'Heltec_v3_repeater Heltec_v3_room_server' ] \ + || fail "plain V3 Full identity or separate room role was lost" + [ "${calls[1]}" = Tbeam_SX1262_repeater_observer_mqtt ] \ + || fail "T-Beam observer Full identity was lost" +) + +( + mapfile -t matrix_targets < <(resolve_logging_matrix_firmwares) + mapfile -t full_targets < <(select_ordinary_full_targets "${matrix_targets[@]}") + declare -A seen=() + for target in "${full_targets[@]}"; do + key=$(get_ordinary_full_group_key "$target") || fail "invalid Full target $target" + [ -z "${seen[$key]+x}" ] || fail "duplicate ordinary Full group $key" + seen[$key]=1 + done + [ "${#full_targets[@]}" -gt 100 ] || fail "Full selection unexpectedly dropped board roles" +) + +( + calls=() + run_logged_build_targets() { calls+=("$*"); } + run_full_esp32_build_targets all heltec_v4_r8_repeater \ + heltec_v4_r8_repeater_observer_mqtt \ + Tbeam_SX1262_repeater_bridge_espnow \ + Tbeam_SX1262_repeater_observer_mqtt >/dev/null + [ "${#calls[@]}" -eq 2 ] || fail "Full-only bulk command emitted duplicate roles" + [ "${calls[0]}" = heltec_v4_r8_repeater ] \ + || fail "Full-only bulk command lost the audited plain identity" + [ "${calls[1]}" = Tbeam_SX1262_repeater_observer_mqtt ] \ + || fail "Full-only bulk command lost the T-Beam observer identity" ) ( diff --git a/test/test_local_release_full_selection.py b/test/test_local_release_full_selection.py new file mode 100644 index 000000000..0685c847c --- /dev/null +++ b/test/test_local_release_full_selection.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""The ordinary release has one ESP32 Full OTA identity per board and role.""" + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from build_local_release import select_ordinary_full_records + + +def record(target: str, profile: str = "full", platform: str = "ESP32_PLATFORM") -> dict: + return {"manifest": {"target": target, "build_profile": profile, "platform": platform}, + "files": []} + + +class ReleaseFullSelectionTest(unittest.TestCase): + def test_resume_filters_sibling_full_images_only(self): + inputs = [ + record("Heltec_v3_repeater_observer_mqtt"), + record("Heltec_v3_repeater_bridge_espnow"), + record("Heltec_v3_repeater"), + record("Heltec_v3_repeater", "standard"), + record("Heltec_v3_room_server"), + record("Station_G2_repeater"), + record("Station_G2_repeater_observer_mqtt"), + record("Tbeam_SX1262_repeater_bridge_espnow"), + record("Tbeam_SX1262_repeater_observer_mqtt"), + record("LilyGo_TLora_V2_1_1_6_repeater"), + record("LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_"), + record("Meshadventurer_sx1262_repeater"), + record("Meshadventurer_sx1262_repeater_bridge_espnow"), + record("RAK_4631_repeater", platform="NRF52_PLATFORM"), + ] + selected = select_ordinary_full_records(inputs) + self.assertEqual({item["manifest"]["target"] for item in selected}, { + "Heltec_v3_repeater", "Heltec_v3_room_server", + "Station_G2_repeater_observer_mqtt", + "Tbeam_SX1262_repeater_observer_mqtt", + "LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_", + "Meshadventurer_sx1262_repeater_bridge_espnow", + "RAK_4631_repeater", + }) + self.assertEqual(len(selected), 8) # standard V3 remains alongside Full V3 + + +if __name__ == "__main__": + unittest.main()