From d5d84d325b20b5ac8b1155829dcce60531d7a3ad Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 16 Oct 2026 23:24:36 -0700 Subject: [PATCH] Retire internal-only SolarXiao Room release variants --- build_legacy.sh | 13 +++ docs/_javascript/firmware_picker.js | 4 + docs/firmware_picker.md | 12 +++ mkdocs.yml | 2 +- scripts/build_local_release.py | 13 +-- scripts/package_cascade_release.py | 37 ++++++++ test/test_build_profiles.sh | 47 ++++++++++ test/test_cascade_release_package.py | 100 ++++++++++++++++++++- test/test_firmware_picker.js | 49 +++++++++++ test/test_local_release_full_selection.py | 102 ++++++++++++++++++++++ variants/xiao_nrf52/platformio.ini | 5 +- 11 files changed, 373 insertions(+), 11 deletions(-) diff --git a/build_legacy.sh b/build_legacy.sh index 956c60ddc..a76d0d9d5 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -5772,7 +5772,20 @@ for target in sys.argv[1:]: printf '%s\n' "$candidate" } +is_solarxiao_internal_room_compatibility_target() { + case "${1,,}" in + solarxiao_30s_room_server|solarxiao_33s_room_server) return 0 ;; + *) return 1 ;; + esac +} + is_redundant_bulk_build_target() { + # SolarXiao has onboard QSPI. Its internal-only Room recipes retain their + # exact legacy OTA identities for explicit compatibility/recovery builds, + # but must not be offered as ordinary builds or mapped to the QSPI identity. + if is_solarxiao_internal_room_compatibility_target "$1"; then + return 0 + fi # Bench fixtures, migration utilities and the unqualified optional SD # source use exact recipes, not the ordinary release/OTA receiver matrix. case "${1,,}" in diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index f59b0c923..a5412f5ca 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -522,6 +522,10 @@ const lowerTarget = target.toLowerCase(); return HIDDEN_PROFILE_HARDWARE.includes(profile.sourceHardware) || HIDDEN_PROFILE_TARGETS.includes(target) || + // SolarXiao room installations use the onboard external-QSPI recipe. + // Retain internal-only downloads for exact compatibility searches, + // including when a release has no matching QSPI image. + /^solarxiao_(?:30|33)s_room_server(?:-(?:full|reduced)(?:-ota)?)?$/.test(lowerTarget) || (lowerTarget.includes("companion_radio_") && /(?:^|[_-])(?:ps|femoff)(?=$|[_-])/.test(lowerTarget)); } diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 31ce558f2..f7c79c7dc 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -481,6 +481,18 @@ enough that the images have little or no size difference. Packaging requires both profiles with matching packaged OTA identity and flash geometry, even when a matrix is resumed or partial publication is requested. A failed build is reported, never presented as a supported download. Companion is unchanged. + +Normal SolarXiao 30S and 33S Room Server builds use the module's onboard QSPI +storage (`solarxiao_30S_room_server_lora_ota` and +`solarxiao_33S_room_server_lora_ota`), with both sensor profiles retained. +Internal-only Room recipes remain available by exact name for explicit +compatibility/recovery builds, but are excluded from ordinary build menus, +future releases, and picker recommendations. Existing downloads remain +available through exact filename searches. The legacy and QSPI images have +different OTA identities and layouts: a QSPI package is not a cross-target +LoRa update for an internal-only installation. Use the matching bootloader +and documented local migration path when changing layouts. + The legacy `no_external_sensors` profile is labeled **Receives LoRa OTA - Reduced optional sensors** on the same button. On RAK3401 and RAK4631 this compact profile uses internal storage without an external storage board. The diff --git a/mkdocs.yml b/mkdocs.yml index b6de631ab..44e41af02 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -131,7 +131,7 @@ extra_javascript: - _javascript/hosted_doc_links.js?v=20261007-1 - _javascript/notification_builder.js?v=20261006-channel9 - https://unpkg.com/leaflet@1.9.4/dist/leaflet.js - - _javascript/firmware_picker.js?v=20261013-board-notes-1 + - _javascript/firmware_picker.js?v=20261016-solar-room-qspi-1 - _javascript/telemetry_decoder.js?v=20261007-hosted-form - _javascript/management_decoder.js - _javascript/filter_tool.js?v=20261002-hashbytes-2plus diff --git a/scripts/build_local_release.py b/scripts/build_local_release.py index 9fc1429ee..e705a99b8 100644 --- a/scripts/build_local_release.py +++ b/scripts/build_local_release.py @@ -17,7 +17,8 @@ import tempfile from build_esp32_partition_migration import bundle_release, verify_archive from package_esp32_partition_migration import BOARDS, resolve_pio_build_dir -from package_cascade_release import category, collect_artifacts, nrf52_sensor_profile +from package_cascade_release import (category, collect_artifacts, nrf52_sensor_profile, + omit_internal_solar_room_records) from station_g3_profiles import omit_redundant_slot_records @@ -248,10 +249,11 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]: 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. - nRF52 Full/reduced sensor OTA records always pass through independently, - even when their logical target ID and storage layout are the same. The - MeshTower SD primary replaces stale internal-only artifacts on resume; - those files must never be relabeled with the primary's different OTA ID. + nRF52 Full/reduced sensor OTA records pass through independently, even + when their logical target ID and storage layout are the same. MeshTower + SD and SolarXiao external-QSPI primaries replace stale internal-only + artifacts on resume; those files must never be relabeled with a primary's + different OTA ID. """ # Resumed directories can contain portable or transport-specific images # from older release policies. They are recovery inputs, not ordinary @@ -289,6 +291,7 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]: records = omit_redundant_slot_records( [record for record in records if ordinary_record(record)]) + records = omit_internal_solar_room_records(records) tower_primary = "heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors" tower_legacy = { diff --git a/scripts/package_cascade_release.py b/scripts/package_cascade_release.py index 446430c58..f568c4bb7 100644 --- a/scripts/package_cascade_release.py +++ b/scripts/package_cascade_release.py @@ -55,6 +55,42 @@ def nrf52_sensor_profile(manifest): return next(iter(choices)) +def omit_internal_solar_room_records(records): + """Omit resumed internal-only Solar Room packages from ordinary releases. + + Call after package qualification. Keep the original files and OTA + identities intact, and require each retired board's matching qualified + external-QSPI Full/Reduced pair before excluding its recovery packages. + """ + legacy_targets = { + "solarxiao_30s_room_server": "solarxiao_30s_room_server_lora_ota", + "solarxiao_33s_room_server": "solarxiao_33s_room_server_lora_ota", + } + targets = {record["manifest"]["target"].lower() for record in records} + for legacy, primary in legacy_targets.items(): + if legacy not in targets: + continue + # collect_artifacts() has already checked the packaged OTA identity + # and layout-matched sensor pair. Do not let internal-only leftovers + # conceal a missing normal release pair for this exact hardware. + replacements = [record["manifest"] for record in records + if record["manifest"]["target"].lower() == primary] + error = (legacy + ": ordinary SolarXiao Room release requires the qualified " + "external-QSPI Full/Reduced pair for " + primary + + "; internal-only artifacts cannot substitute for its distinct OTA identity") + try: + profiles = [nrf52_sensor_profile(manifest) for manifest in replacements] + except ValueError as cause: + raise ValueError(error) from cause + if (sorted(profiles) != ["full", "reduced"] + or any((manifest.get("ota_update_requirements") or {}) + .get("lora", {}).get("storage") != "external_qspi" + for manifest in replacements)): + raise ValueError(error) + return [record for record in records + if record["manifest"]["target"].lower() not in legacy_targets] + + def release_profile_label(manifest): sensor = nrf52_sensor_profile(manifest) if sensor: @@ -378,6 +414,7 @@ def main(): raise ValueError("matrix did not use Cascade runtime defaults") radio = {key: status["radio_" + key] for key in ("frequency", "bandwidth", "sf", "cr")} records = collect_artifacts(args.input, version) + records = omit_internal_solar_room_records(records) if args.output.exists() and any(args.output.iterdir()): raise ValueError("staging directory must be empty; existing releases are never overwritten") base_tag = version diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index 66ad0456d..6fb18e579 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -718,6 +718,53 @@ for room_ota_env in solarxiao_33S_room_server_lora_ota \ printf '%s\n' "$room_release_targets" | grep -Fx "$room_ota_env" >/dev/null \ || fail "room-server release resolution omitted $room_ota_env" done + +# SolarXiao Room normally uses the module's onboard QSPI, not an additional +# internal-only choice. Keep direct recovery recipes and their OTA identities +# intact while excluding them from every ordinary selection path. +solar_room_targets=$(resolve_room_server_firmwares) +solar_all_targets=$(resolve_all_firmwares) +solar_logging_targets=$(resolve_logging_matrix_firmwares) +solar_menu_targets=$(get_interactive_build_targets) +for solar_board in solarxiao_30S solarxiao_33S; do + legacy_room=${solar_board}_room_server + qspi_room=${legacy_room}_lora_ota + is_supported_build_env "$legacy_room" \ + || fail "$legacy_room explicit compatibility recipe disappeared" + validate_build_target "$legacy_room" \ + || fail "$legacy_room explicit compatibility recipe is invalid" + is_redundant_bulk_build_target "$legacy_room" \ + || fail "$legacy_room internal-only recipe remains an ordinary build" + is_redundant_bulk_build_target "${legacy_room^^}" \ + || fail "$legacy_room retirement policy is not case insensitive" + [ "$(get_pio_build_env "$legacy_room")" = "$legacy_room" ] \ + || fail "$legacy_room silently changed its PlatformIO recipe" + [ "$(get_nrf52_sensor_ota_pair_target "$legacy_room")" = "$legacy_room" ] \ + || fail "$legacy_room silently changed its OTA identity" + [ "$(get_nrf52_sensor_ota_pair_target "$qspi_room")" = "$qspi_room" ] \ + || fail "$qspi_room lost its distinct QSPI OTA identity" + for solar_selection in "$room_release_targets" "$solar_room_targets" \ + "$solar_all_targets" "$solar_logging_targets" "$solar_menu_targets"; do + if printf '%s\n' "$solar_selection" | grep -Fx "$legacy_room" >/dev/null; then + fail "$legacy_room leaked into an ordinary build selection" + fi + printf '%s\n' "$solar_selection" | grep -Fx "$qspi_room" >/dev/null \ + || fail "$qspi_room missing from an ordinary build selection" + done + ( + REQUIRE_OTA_UPDATES=0 + resolve_command_targets build-firmware "$legacy_room" + [ "${#RESOLVED_BUILD_TARGETS[@]}" -eq 1 ] \ + && [ "${RESOLVED_BUILD_TARGETS[0]}" = "$legacy_room" ] \ + || fail "$legacy_room direct recovery build changed its exact target" + ) +done +if is_solarxiao_internal_room_compatibility_target Xiao_nrf52_room_server \ + || is_solarxiao_internal_room_compatibility_target solarxiao_30S_repeater \ + || is_solarxiao_internal_room_compatibility_target solarxiao_33S_room_server_lora_ota; then + fail "SolarXiao internal Room policy retired an unrelated recipe" +fi + sensor_release_targets=$(resolve_sensor_firmwares) while IFS='|' read -r sensor_env sensor_ota_env; do is_supported_build_env "$sensor_ota_env" \ diff --git a/test/test_cascade_release_package.py b/test/test_cascade_release_package.py index 2aed18db7..e7c0cc854 100644 --- a/test/test_cascade_release_package.py +++ b/test/test_cascade_release_package.py @@ -5,6 +5,7 @@ from pathlib import Path import tempfile import unittest import subprocess +import struct import sys import hashlib from unittest.mock import patch @@ -97,8 +98,9 @@ class ReleaseQualificationTest(unittest.TestCase): package.validate_manifest(manifest) def sensor_record(self, directory, sensor, *, target='test_repeater', target_id=None, - hw_id='TEST', app_base=0x26000, flags=0x10, fw_version=0x01110107): - stem = target + '-' + sensor + '-ota-v1.17.1.7-dev-deadbeef' + hw_id='TEST', app_base=0x26000, flags=0x10, fw_version=0x01110107, + version='v1.17.1.7-dev-deadbeef', storage='internal_flash_and_retained_ram'): + stem = target + '-' + sensor + '-ota-' + version application, _ = ensure_endf(ensure_nrf52_layout(b'OTA: status\0invalid in-place patch geometry\0', Nrf52Layout(app_base, 0xED000, 0xED000, flags)), FwIdent(fw_version, target_id_for_env(target) if target_id is None else target_id, hw_id)) @@ -112,9 +114,101 @@ class ReleaseQualificationTest(unittest.TestCase): platform='NRF52_PLATFORM', build_profile='auto', capabilities=['sensor.profile.' + sensor, 'ota.update.lora'], ota_update_methods=['lora'], verification=[dict(capability='ota.update.lora', present=True, source='linked image')], - ota_update_requirements={'lora': {'storage': 'internal_flash_and_retained_ram'}}) + ota_update_requirements={'lora': {'storage': storage}}) return {'manifest': manifest, 'files': [path]} + def sensor_package_fixture(self, directory, target, sensor, *, external=False): + """Write fictional, structurally valid packages for the real CLI path.""" + record = self.sensor_record(directory, sensor, target=target, + version='v1.17.1.5-halo-keymind-cascade-dev-deadbeef', + flags=0x04 if external else 0x10, + storage='external_qspi' if external else 'internal_flash_and_retained_ram') + path = record['files'][0] + with zipfile.ZipFile(path) as archive: + application = archive.read('application.bin') + self.assertLessEqual(len(application), 256) + uf2 = (struct.pack('<8I', 0x0A324655, 0x9E5D5157, 0x2000, 0x26000, + 256, 0, 1, 0xADA52840) + + application.ljust(256, b'\xff') + b'\0' * 220 + + struct.pack(' profile.controls && profile.chipFamily !== 'unknown')); +for (const hardware of ['solarxiao_30S', 'solarxiao_33S']) { + const roomBase = hardware + '_room_server'; + const roomProfiles = currentCatalog.profiles.filter(profile => + picker.profileMatchesFacets(profile, {hardware, role: 'room'})); + assert.deepStrictEqual(roomProfiles.map(profile => profile.target).sort(), + ['full', 'reduced'].map(policy => roomBase + '_lora_ota-' + policy), + hardware + ' must recommend only QSPI room builds while retaining both sensor policies'); + for (const profile of roomProfiles) { + assert.strictEqual(profile.controls.updateRequirements.lora.storage, 'external_qspi'); + assert.strictEqual(profile.sensorProfile, profile.target.endsWith('-full') ? 'full' : 'reduced'); + assert(profile.files.every(file => file.target === profile.target), + 'QSPI recommendations must keep their exact files and OTA target identities'); + } + for (const policy of ['full', 'reduced']) { + const target = roomBase + '-' + policy; + const original = currentAssets.find(file => file.name.startsWith(target + '-')); + assert(original, 'The fixture must exercise an internal-only compatibility file'); + assert(currentCatalog.rows.some(row => row.target === target && + row.name === original.name && row.url === original.browser_download_url), + 'Internal-only files must retain their exact names, downloads and target identities'); + } +} +const solarFullLink = picker.selectionFromUrl( + 'https://mikecarper.github.io/MeshCore/firmware_picker/?s=1.hsolarxiao_33S.olora-receiver.ffull.vdefault#firmware-picker-1-results', + currentCatalog.profiles); +assert.deepStrictEqual(solarFullLink.unavailable, []); +const solarFullMatches = currentCatalog.profiles.filter(profile => + picker.profileMatchesFacets(profile, solarFullLink.filters)); +assert.deepStrictEqual(solarFullMatches.map(profile => profile.target).sort(), [ + 'solarxiao_33S_repeater-full', 'solarxiao_33S_room_server_lora_ota-full', +]); +assert.deepStrictEqual(solarFullMatches.filter(profile => + picker.profileMatchesFacets(profile, {...solarFullLink.filters, role: 'room'})) + .map(profile => profile.target), ['solarxiao_33S_room_server_lora_ota-full']); +const internalSolarTargets = ['solarxiao_30S', 'solarxiao_33S'].flatMap(hardware => + ['', '-full', '-reduced', '-full-ota', '-reduced-ota'].map(suffix => + hardware + '_room_server' + suffix)); +const internalSolarAssets = internalSolarTargets.map(target => + asset(target + '-ota-' + family + '.uf2')); +const internalSolarCatalog = picker.buildCatalog([ + release(family, '2026-10-16T00:00:00Z', internalSolarAssets), +]); +assert.deepStrictEqual(internalSolarCatalog.profiles, [], + 'A missing QSPI image must never restore internal-only room recommendations'); +assert.deepStrictEqual(internalSolarCatalog.rows.map(row => row.target), internalSolarTargets); +assert.deepStrictEqual(internalSolarCatalog.rows.map(row => row.name), + internalSolarAssets.map(file => file.name), + 'Every legacy publication form must remain available to exact filename search'); +console.log('SolarXiao QSPI-only room recommendations and exact compatibility downloads passed'); for (const [hardware, bases] of [ ['RAK_4631', ['RAK_4631_repeater_unified_lora_ota', 'RAK_4631_repeater_ethernet']], ['RAK_3401', ['RAK_3401_repeater_unified_lora_ota']], diff --git a/test/test_local_release_full_selection.py b/test/test_local_release_full_selection.py index 83c3c348e..a160d5429 100644 --- a/test/test_local_release_full_selection.py +++ b/test/test_local_release_full_selection.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Full selection preserves qualified transports and board capacity exceptions.""" +from copy import deepcopy import sys import unittest from pathlib import Path @@ -19,6 +20,21 @@ def record(target: str, profile: str = "full", platform: str = "ESP32_PLATFORM", "files": []} +def sensor_pair(target: str, storage: str = "external_qspi") -> list[dict]: + variants = [] + for sensor in ("full", "reduced"): + item = record(target, "auto", "NRF52_PLATFORM", + ("sensor.profile." + sensor, "ota.update.lora")) + item["manifest"].update( + artifact_target=target + "-" + sensor + "-ota", + ota_update_verified=True, ota_update_methods=["lora"], + ota_update_requirements={"lora": {"storage": storage}}) + item["files"] = [Path(item["manifest"]["artifact_target"] + ".uf2"), + Path(item["manifest"]["artifact_target"] + ".zip")] + variants.append(item) + return variants + + class ReleaseFullSelectionTest(unittest.TestCase): def test_resume_discards_everyday_esp32_standard_profiles_with_or_without_full(self): for target in ("heltec_v4_repeater", "heltec_v4_room_server", "heltec_v4_sensor", @@ -244,6 +260,92 @@ class ReleaseFullSelectionTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, "requires the SD primary"): select_ordinary_full_records([record(target, "auto", "NRF52_PLATFORM")]) + def test_solar_qspi_room_pairs_keep_both_profiles_and_original_identities(self): + variants = [] + for board in ("30S", "33S"): + variants.extend(sensor_pair("solarxiao_" + board + "_room_server_lora_ota")) + for inputs in (variants, list(reversed(variants))): + before = deepcopy(inputs) + selected = select_ordinary_full_records(inputs) + self.assertEqual(selected, inputs) + self.assertEqual(inputs, before) + self.assertTrue(all(actual is original for actual, original in zip(selected, inputs))) + + def test_solar_resume_filters_exact_internal_room_targets_without_mutating_files(self): + legacy = [] + primaries = [] + for board in ("30S", "33S"): + # Target matching is case-insensitive; publication identities stay + # exactly as they appeared in the qualified manifest. + target = "SOLARXIAO_" + board + "_ROOM_SERVER" + legacy.extend(sensor_pair(target, "internal_flash_and_retained_ram")) + legacy.append(record(target, "auto", "NRF52_PLATFORM")) + primaries.extend(sensor_pair(target + "_LORA_OTA")) + unrelated = [record(target, "auto", "NRF52_PLATFORM") for target in ( + "Xiao_nrf52_room_server", "Xiao_nrf52_room_server_lora_ota_no_external_sensors", + "RAK_3401_room_server", "RAK_4631_room_server_lora_ota_no_external_sensors", + "solarxiao_30S_repeater", "solarxiao_33S_sensor", + )] + for inputs in (legacy + primaries + unrelated, unrelated + primaries + legacy, + list(reversed(legacy + primaries + unrelated))): + before = deepcopy(inputs) + expected = [item for item in inputs if all(item is not old for old in legacy)] + selected = select_ordinary_full_records(inputs) + self.assertEqual(selected, expected) + self.assertEqual(inputs, before) + self.assertTrue(all(actual is original for actual, original in zip(selected, expected))) + + def test_solar_internal_rooms_cannot_replace_missing_matching_qspi_pair(self): + for board, other_board in (("30S", "33S"), ("33S", "30S")): + target = "solarxiao_" + board + "_room_server" + other_pair = sensor_pair("solarxiao_" + other_board + "_room_server_lora_ota") + for stale in ([record(target, "auto", "NRF52_PLATFORM")], + sensor_pair(target, "internal_flash_and_retained_ram")): + for inputs in (stale, stale + other_pair, other_pair + stale): + before = deepcopy(inputs) + with self.subTest(board=board, count=len(inputs)): + with self.assertRaisesRegex(ValueError, "qualified external-QSPI Full/Reduced pair"): + select_ordinary_full_records(inputs) + self.assertEqual(inputs, before) + + def test_solar_resume_requires_complete_qualified_external_qspi_pair(self): + for board in ("30S", "33S"): + target = "solarxiao_" + board + "_room_server" + for fault in ("missing_full", "missing_reduced", "duplicate_profile", "unverified", + "no_ota", "no_lora", "unproven_lora", "wrong_platform", + "internal_storage", "missing_storage", "missing_sensor_profile"): + pair = sensor_pair(target + "_lora_ota") + manifest = pair[0]["manifest"] + if fault == "missing_full": + pair.pop(0) + elif fault == "missing_reduced": + pair.pop() + elif fault == "duplicate_profile": + pair.append(deepcopy(pair[0])) + elif fault == "unverified": + manifest["verified"] = False + elif fault == "no_ota": + manifest["ota_update_verified"] = False + elif fault == "no_lora": + manifest["ota_update_methods"] = [] + elif fault == "unproven_lora": + manifest["verification"] = [] + elif fault == "wrong_platform": + manifest["platform"] = "ESP32_PLATFORM" + elif fault == "internal_storage": + manifest["ota_update_requirements"]["lora"]["storage"] = "internal_flash" + elif fault == "missing_storage": + del manifest["ota_update_requirements"] + else: + manifest["capabilities"].remove("sensor.profile.full") + stale = sensor_pair(target, "internal_flash_and_retained_ram") + for inputs in (stale + pair, pair + stale): + before = deepcopy(inputs) + with self.subTest(board=board, fault=fault): + with self.assertRaisesRegex(ValueError, "qualified external-QSPI Full/Reduced pair"): + select_ordinary_full_records(inputs) + self.assertEqual(inputs, before) + def test_nrf52_full_and_reduced_ota_pair_is_never_collapsed(self): for role in ('repeater', 'room_server', 'sensor'): target = 'RAK_3401_' + role diff --git a/variants/xiao_nrf52/platformio.ini b/variants/xiao_nrf52/platformio.ini index 659606842..133af44c2 100644 --- a/variants/xiao_nrf52/platformio.ini +++ b/variants/xiao_nrf52/platformio.ini @@ -307,8 +307,9 @@ build_flags = -D MANUFACTURER_STRING='"SolarXiao 33S"' ; Room-server OTA uses the same external-QSPI staging and OTAFIX linker -; contract as the corresponding SolarXiao repeater. The ordinary room image -; remains available for older bootloaders and USB/BLE recovery. +; contract as the corresponding SolarXiao repeater. Internal-only room images +; remain explicit compatibility/recovery builds for older bootloaders, not +; ordinary release or interactive menu choices. [env:solarxiao_30S_room_server_lora_ota] extends = env:solarxiao_30S_room_server board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld