Publish one ordinary ESP32 Full image per board role

This commit is contained in:
mikecarper committed 2026-09-26 09:29:18 -07:00
1 parent b0b9e53dd7
commit df19760c85
6 files changed
+311 -44

No files matched your search

+126 -28
View File
@@ -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
+11 -4
View File
@@ -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 <target> --full-exact`, then deliberately install
it over LoRa with `ota pull <MID8> 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
+13 -1
View File
@@ -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 <MID8> 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 <that.mota>` and add **`validate`**: the node fetches just the target's block fingerprints, keeps
+42 -4
View File
@@ -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")
+71 -7
View File
@@ -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"
)
(
+48
View File
@@ -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()