From ce53567a4c501350ab394f416530ee410e0982da Mon Sep 17 00:00:00 2001 From: mikecarper Date: Fri, 16 Oct 2026 23:42:10 -0700 Subject: [PATCH] Consolidate onboard QSPI infrastructure build profiles --- .github/workflows/run-unit-tests.yml | 1 + build_legacy.sh | 73 ++++- docs/_javascript/firmware_picker.js | 66 +++- docs/firmware_picker.md | 29 +- mkdocs.yml | 2 +- scripts/build_local_release.py | 6 +- scripts/generate_picker_controls.py | 15 + scripts/package_cascade_release.py | 66 +++- test/test_build_profiles.sh | 83 +++++ test/test_cascade_release_package.py | 82 ++++- test/test_firmware_picker.js | 163 ++++++++++ test/test_local_release_full_selection.py | 118 +++++++- test/test_onboard_qspi_profiles.py | 333 +++++++++++++++++++++ test/test_picker_controls_generator.py | 36 +++ variants/ikoka_handheld_nrf/platformio.ini | 11 + variants/ikoka_nano_nrf/platformio.ini | 33 ++ variants/ikoka_stick_nrf/platformio.ini | 33 ++ variants/lilygo_techo/platformio.ini | 13 + variants/lilygo_techo_card/platformio.ini | 26 ++ variants/lilygo_techo_lite/platformio.ini | 26 ++ variants/muzi_base/platformio.ini | 75 +++++ variants/nano_g2_ultra/platformio.ini | 29 ++ variants/sensecap_solar/platformio.ini | 16 + variants/thinknode_m1/platformio.ini | 13 + variants/thinknode_m6/platformio.ini | 13 + variants/wio-tracker-l1/platformio.ini | 13 + variants/xiao_nrf52/platformio.ini | 19 ++ 27 files changed, 1351 insertions(+), 42 deletions(-) create mode 100644 test/test_onboard_qspi_profiles.py diff --git a/.github/workflows/run-unit-tests.yml b/.github/workflows/run-unit-tests.yml index c7d0c1b8f..3506bd44a 100644 --- a/.github/workflows/run-unit-tests.yml +++ b/.github/workflows/run-unit-tests.yml @@ -712,6 +712,7 @@ jobs: python3 -B test/test_waveshare_partition_migration.py -v python3 -B test/test_usb_partition_bridge_set.py -v bash test/test_build_profiles.sh + python3 -B test/test_onboard_qspi_profiles.py -v bash test/test_full_infrastructure_selection.sh bash test/test_nrf52_ota_profiles.sh bash test/test_xiao_qspi_combined.sh diff --git a/build_legacy.sh b/build_legacy.sh index a76d0d9d5..a0774142f 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -81,7 +81,7 @@ BUILD_BACKGROUND_HANDOFF_STATE="" BUILD_SCRIPT_LOCK_FD="" INTERACTIVE_BUILD_SELECTION=0 -ENV_VARIANT_SUFFIX_PATTERN='companion_radio_(wifi_mqtt|serial|wifi|usb|ble|full)(_ps)?(_fem(on|off))?|companion_radio_ethernet|comp_radio_usb|companion_usb|companion_ble|repeater_bridge_rs232_serial1_lora_ota_no_external_sensors|repeater_bridge_rs232_serial2_lora_ota_no_external_sensors|repeater_bridge_rs232_lora_ota_no_external_sensors|repeater_rak13302_w25q16_lora_ota|repeater_rak15001_slot_c_lora_ota|repeater_w25q16_lora_ota|repeater_lora_ota_no_external_sensors|repeater_bridge_rs232_serial1|repeater_bridge_rs232_serial2|repeater_bridge_rs232|repeater_bridge_espnow|repeater_observer_mqtt|repeater_ethernet|room_server_ethernet_lora_ota_no_external_sensors|room_server_lora_ota_no_external_sensors|room_svr_lora_ota_no_external_sensors|room_server_lora_ota|room_server_observer_mqtt|room_server_ethernet|terminal_chat|room_server|room_svr|kiss_modem|sensor_lora_ota_no_external_sensors|sensor|repeatr|repeater' +ENV_VARIANT_SUFFIX_PATTERN='companion_radio_(wifi_mqtt|serial|wifi|usb|ble|full)(_ps)?(_fem(on|off))?|companion_radio_ethernet|comp_radio_usb|companion_usb|companion_ble|repeater_bridge_rs232_serial1_lora_ota_no_external_sensors|repeater_bridge_rs232_serial2_lora_ota_no_external_sensors|repeater_bridge_rs232_lora_ota_no_external_sensors|repeater_rak13302_w25q16_lora_ota|repeater_rak15001_slot_c_lora_ota|repeater_w25q16_lora_ota|repeater_lora_ota_no_external_sensors|repeater_lora_ota|repeater_bridge_rs232_serial1|repeater_bridge_rs232_serial2|repeater_bridge_rs232|repeater_bridge_espnow|repeater_observer_mqtt|repeater_ethernet|room_server_ethernet_lora_ota_no_external_sensors|room_server_lora_ota_no_external_sensors|room_svr_lora_ota_no_external_sensors|room_server_lora_ota|room_server_observer_mqtt|room_server_ethernet|terminal_chat|room_server|room_svr|kiss_modem|sensor_lora_ota_no_external_sensors|sensor|repeatr|repeater' BOARD_MODIFIER_WITHOUT_DISPLAY="_without_display" BOARD_MODIFIER_LOGGING="_logging" BOARD_MODIFIER_TFT="_tft" @@ -1980,6 +1980,13 @@ print_release_firmware_targets() { ;; get-repeater-firmwares-to-build) get_pio_envs_ending_with_string "_repeater" + local qspi_target + for qspi_target in "${SUPPORTED_PIO_ENVS[@]}"; do + if [[ "$qspi_target" == *_repeater_lora_ota ]] \ + && ! is_redundant_bulk_build_target "$qspi_target"; then + printf '%s\n' "$qspi_target" + fi + done # MeshTower keeps its deployed SD target identity as the one canonical # repeater recipe, including when SD staging falls back to internal staging. if is_supported_build_env "Heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors"; then @@ -2021,8 +2028,9 @@ print_release_firmware_targets() { get_pio_envs_ending_with_string "_room_server" for env_name in "${SUPPORTED_PIO_ENVS[@]}"; do if is_room_server_role_target "$env_name" \ - && { is_lora_ota_no_external_sensors_target "$env_name" \ - || [[ "$env_name" == solarxiao_*_room_server_lora_ota ]]; }; then + && { is_lora_ota_no_external_sensors_target "$env_name" \ + || [[ "$env_name" == *_room_server_lora_ota ]]; } \ + && ! is_redundant_bulk_build_target "$env_name"; then printf '%s\n' "$env_name" fi done @@ -3445,6 +3453,9 @@ declare_build_capability_contract() { declare_full_logging_application_contract "$env_name" record_build_capability "profile.${BUILD_PROFILE_FOR_TARGET}" pio_env_name=$(get_pio_build_env "$env_name") + if is_qspi_hardware_qualification_target "$env_name"; then + record_build_capability "hardware.qualification.pending" + fi if [ "$BUILD_PROFILE_FOR_TARGET" = "full" ] \ && is_esp32_canonical_full_release_target "$env_name"; then pio_env_name=$(get_exact_identity_full_pio_env "$env_name") @@ -3456,6 +3467,10 @@ declare_build_capability_contract() { # Check both the logical and compiled recipes so substituting sources # cannot silently remove RS-232 while its legacy image is retired. record_build_expectation "bridge.rs232" "_ZN11RS232Bridge5beginEv" + if pio_env_option_contains "$pio_env_name" build_flags "RS232_BRIDGE_MERGED=1"; then + record_application_expectation "bridge.rs232.merged" \ + "Error: use set rs232.enabled on|off" + fi fi if [ "$BUILD_PROFILE_FOR_TARGET" = "full" ] \ && supports_esp32_full_shared_espnow "$pio_env_name"; then @@ -5666,6 +5681,9 @@ get_merged_rs232_repeater_replacement() { solarxiao_33s_repeater_bridge_rs232) printf '%s\n' solarxiao_33S_repeater ;; + xiao_nrf52_repeater_bridge_rs232|xiao_nrf52_repeater_bridge_rs232_lora_ota_no_external_sensors) + printf '%s\n' Xiao_nrf52_repeater + ;; heltec_v3_repeater_bridge_rs232) printf '%s\n' Heltec_v3_repeater ;; @@ -5779,11 +5797,52 @@ is_solarxiao_internal_room_compatibility_target() { esac } +get_onboard_qspi_normal_replacement() { + local target=$1 base + base=${target%_lora_ota_no_external_sensors} + case "${base,,}" in + solarxiao_30s_room_server|solarxiao_33s_room_server|xiao_nrf52_room_server|\ + ikoka_nano_nrf_22dbm_room_server|ikoka_nano_nrf_30dbm_room_server|ikoka_nano_nrf_33dbm_room_server|\ + ikoka_stick_nrf_22dbm_room_server|ikoka_stick_nrf_30dbm_room_server|ikoka_stick_nrf_33dbm_room_server|\ + ikoka_handheld_nrf_e22_30dbm_room_server|lilygo_t-echo_room_server|\ + sensecap_solar_room_server|thinknode_m1_room_server|thinknode_m6_room_server|\ + wiotrackerl1_room_server|lilygo_t-echo-lite_room_server|lilygo_t-echo-lite_repeater) + printf '%s_lora_ota\n' "$base" + return 0 + ;; + lilygo_t-echo_repeater|sensecap_solar_repeater|thinknode_m1_repeater|\ + thinknode_m6_repeater|wiotrackerl1_repeater) + if [ "$base" != "$target" ]; then + printf '%s\n' "$base" + return 0 + fi + ;; + xiao_nrf52_repeater_bridge_rs232) + printf '%s\n' Xiao_nrf52_repeater + return 0 + ;; + esac + return 1 +} + +is_qspi_hardware_qualification_target() { + case "${1,,}" in + muzi_base_duo_repeater_lora_ota|muzi_base_duo_room_server_lora_ota|\ + muzi_base_uno_repeater_lora_ota|muzi_base_uno_room_server_lora_ota|\ + lilygo_t-echo_card_repeater_lora_ota|lilygo_t-echo_card_room_server_lora_ota|\ + nano_g2_ultra_repeater_lora_ota|nano_g2_ultra_room_server_lora_ota) + 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 + # Known onboard-QSPI normal recipes replace old internal/lean choices. + # Retain exact legacy OTA identities for explicit compatibility/recovery; + # do not remap them to the QSPI identity. Hardware-pending external ports + # are explicit qualification builds, never ordinary release choices. + if get_onboard_qspi_normal_replacement "$1" >/dev/null \ + || is_qspi_hardware_qualification_target "$1"; then return 0 fi # Bench fixtures, migration utilities and the unqualified optional SD diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index a5412f5ca..9a48efc66 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -219,6 +219,17 @@ "ikoka_handheld_nrf_e22_30dbm_096_rotated_companion_radio_full", ]); + const QSPI_REPEATER_ALIAS_HARDWARE = Object.freeze([ + "lilygo_t-echo", "sensecap_solar", "thinknode_m1", "thinknode_m6", "wiotrackerl1", + ]); + + const QSPI_ROOM_HARDWARE = Object.freeze([ + "xiao_nrf52", "ikoka_handheld_nrf_e22_30dbm", + "ikoka_nano_nrf_22dbm", "ikoka_nano_nrf_30dbm", "ikoka_nano_nrf_33dbm", + "ikoka_stick_nrf_22dbm", "ikoka_stick_nrf_30dbm", "ikoka_stick_nrf_33dbm", + "solarxiao_30s", "solarxiao_33s", "lilygo_t-echo-lite", + ].concat(QSPI_REPEATER_ALIAS_HARDWARE)); + let pickerInstanceCount = 0; let runtimeGroupCount = 0; @@ -522,10 +533,14 @@ 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. + // These room installations use their 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) || + (QSPI_ROOM_HARDWARE.includes(String(profile.sourceHardware || "").toLowerCase()) && + /^_room_server(?:_lora_ota_no_external_sensors)?(?:-(?:full|reduced)(?:-ota)?)?$/ + .test(lowerTarget.slice(profile.sourceHardware.length))) || + /^lilygo_t-echo-lite_repeater(?:_lora_ota_no_external_sensors)?(?:-(?:full|reduced)(?:-ota)?)?$/ + .test(lowerTarget) || (lowerTarget.includes("companion_radio_") && /(?:^|[_-])(?:ps|femoff)(?=$|[_-])/.test(lowerTarget)); } @@ -709,6 +724,47 @@ }); } + function omitReplacedQspiProfiles(profiles) { + const canonicalPairs = new Map(); + (profiles || []).forEach(function (profile) { + const controls = profile.controls; + const hardware = String(profile.hardware || "").toLowerCase(); + const target = String(profile.target || "").toLowerCase(); + if (!controls || controls.platform !== "NRF52_PLATFORM" || + profile.ota !== "lora-receiver" || !profile.sensorProfile || + !controls.updateRequirements || !controls.updateRequirements.lora || + controls.updateRequirements.lora.storage !== "external_qspi" || + target !== hardware + "_repeater-" + profile.sensorProfile) return; + if (!canonicalPairs.has(hardware)) canonicalPairs.set(hardware, new Map()); + canonicalPairs.get(hardware).set(profile.sensorProfile, profile); + }); + return (profiles || []).filter(function (profile) { + const hardware = String(profile.hardware || "").toLowerCase(); + const pair = canonicalPairs.get(hardware); + if (!pair || !pair.has("full") || !pair.has("reduced")) return true; + if (pair.get("full").controls.sensorProfileSource.toLowerCase() !== + pair.get("reduced").controls.sensorProfileSource.toLowerCase()) return true; + const target = String(profile.target || "").toLowerCase(); + if (QSPI_REPEATER_ALIAS_HARDWARE.includes(hardware) && + /^_repeater_lora_ota_no_external_sensors(?:-(?:full|reduced)(?:-ota)?)?$/ + .test(target.slice(hardware.length))) return false; + if (hardware === "xiao_nrf52" && + /^xiao_nrf52_repeater_bridge_rs232(?:_lora_ota_no_external_sensors)?(?:-(?:full|reduced)(?:-ota)?)?$/.test(target)) { + // A filename or static UART flag cannot prove the merged image's + // runtime bridge control. Each qualified policy needs release-bound + // packaged control evidence for the same source as its sensor proof. + return !["full", "reduced"].every(function (policy) { + const controls = pair.get(policy).controls; + return controls.rs232 === true && controls.rs232Merged === true && + typeof controls.rs232Source === "string" && + /^[0-9a-f]{40}$/i.test(controls.rs232Source) && + controls.rs232Source.toLowerCase() === controls.sensorProfileSource.toLowerCase(); + }); + } + return true; + }); + } + function applyFullCompanionCapabilities(profiles) { return (profiles || []).map(function (profile) { if (!isFullCompanion(profile)) return profile; @@ -1012,9 +1068,9 @@ return { releaseSet: releaseSet, rows: firmwareRows, - // Old releases have no shared-slot proof and retain both choices. Raw - // assets remain searchable even when a new exact successor replaces R2. - profiles: omitStationG3SlotDuplicates(profiles), + // Recommendation filters retain original files and target identities in + // the raw catalog, including when a qualified successor replaces them. + profiles: omitReplacedQspiProfiles(omitStationG3SlotDuplicates(profiles)), }; } diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index f7c79c7dc..fc1794bb4 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -482,9 +482,13 @@ 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. +Normal SolarXiao, XIAO nRF52, Ikoka Nano/Stick/Handheld, T-Echo, T-Echo Lite, +ThinkNode M1/M6, Wio Tracker L1 and SenseCAP Solar Room Server builds use +onboard QSPI storage through their exact `*_room_server_lora_ota` targets, +with both sensor profiles retained. T-Echo Lite Repeater similarly uses its +exact `LilyGo_T-Echo-Lite_repeater_lora_ota` target. Each requires the +board-matched QSPI-capable OTAFIX bootloader; a shared board-family name is +not enough to choose a bootloader. 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 @@ -493,6 +497,25 @@ 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. +T-Echo, SenseCAP Solar, ThinkNode M1/M6 and Wio Tracker L1 already use QSPI +in their ordinary Repeater targets. Their older +`*_repeater_lora_ota_no_external_sensors` identities remain explicit +compatibility builds, not extra normal Full/Reduced choices. The picker hides +these aliases only when a source-matched, qualified ordinary QSPI pair exists. +XIAO nRF52 Repeater now also includes its runtime RS232 bridge, disabled by +default (`set rs232.enabled on|off`). The separate bridge recommendation is +hidden only after both ordinary sensor profiles prove the linked driver and +packaged runtime controls. Existing bridge OTA identities remain unchanged. + +Muzi Base Uno/Duo, T-Echo Card and Nano G2 Ultra have explicit +`*_repeater_lora_ota` and `*_room_server_lora_ota` qualification recipes. +Their dedicated bootloader ports still require physical qualification and +publication. These experimental recipes reserve raw QSPI for OTA (no shared +QSPI filesystem), are excluded from ordinary menus and bulk builds, and carry +`hardware.qualification.pending` in their capability manifests so release +packaging refuses them. Their current normal builds and picker choices remain +available; do not install another board's bootloader to bypass this gate. + 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 44e41af02..8171d201d 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=20261016-solar-room-qspi-1 + - _javascript/firmware_picker.js?v=20261016-onboard-qspi-2 - _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 e705a99b8..c46672f9c 100644 --- a/scripts/build_local_release.py +++ b/scripts/build_local_release.py @@ -18,7 +18,7 @@ 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, - omit_internal_solar_room_records) + omit_retired_onboard_qspi_records) from station_g3_profiles import omit_redundant_slot_records @@ -251,7 +251,7 @@ def select_ordinary_full_records(records: list[dict]) -> list[dict]: This also removes stale sibling artifacts when a matrix build is resumed. 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 + SD and onboard-QSPI primaries replace retired internal-only or lean artifacts on resume; those files must never be relabeled with a primary's different OTA ID. """ @@ -291,7 +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) + records = omit_retired_onboard_qspi_records(records) tower_primary = "heltec_tower_v2_sdcard_repeater_lora_ota_no_external_sensors" tower_legacy = { diff --git a/scripts/generate_picker_controls.py b/scripts/generate_picker_controls.py index 869d62491..a4115790d 100644 --- a/scripts/generate_picker_controls.py +++ b/scripts/generate_picker_controls.py @@ -95,6 +95,21 @@ def runtime_metadata(manifest, mqtt, release_source=None): result['loggingSource'] = manifest.get('source_commit', release_source) if 'companion.dedicated_usb_logging' in verified: result['dedicatedUsbLogging'] = True + linked = {check['capability'] for check in manifest.get('verification', []) + if check.get('present') is True and check.get('source') == 'linked image' + and check.get('evidence')} + merged_runtime = any(check.get('capability') == 'bridge.rs232.merged' + and check.get('present') is True + and check.get('source') == 'packaged application' + and check.get('evidence') + for check in manifest.get('verification', [])) + if (manifest.get('verified') is True + and {'bridge.rs232', 'bridge.rs232.merged'} <= capabilities + and 'bridge.rs232' in linked and merged_runtime): + source = manifest.get('source_commit', release_source) + if not isinstance(source, str) or not re.fullmatch(r'[0-9a-f]{40}', source): + raise ValueError('Merged RS232 requires an exact source commit: ' + manifest['target']) + result.update(rs232Merged=True, rs232Source=source) return result diff --git a/scripts/package_cascade_release.py b/scripts/package_cascade_release.py index f568c4bb7..eb1b4a21d 100644 --- a/scripts/package_cascade_release.py +++ b/scripts/package_cascade_release.py @@ -55,42 +55,82 @@ 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. +def omit_retired_onboard_qspi_records(records): + """Omit retired onboard-QSPI alternatives 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", - } + room_bases = ( + "solarxiao_30s_room_server", "solarxiao_33s_room_server", + "xiao_nrf52_room_server", + "ikoka_nano_nrf_22dbm_room_server", "ikoka_nano_nrf_30dbm_room_server", + "ikoka_nano_nrf_33dbm_room_server", "ikoka_stick_nrf_22dbm_room_server", + "ikoka_stick_nrf_30dbm_room_server", "ikoka_stick_nrf_33dbm_room_server", + "ikoka_handheld_nrf_e22_30dbm_room_server", + "lilygo_t-echo_room_server", "sensecap_solar_room_server", + "thinknode_m1_room_server", "thinknode_m6_room_server", "wiotrackerl1_room_server", + "lilygo_t-echo-lite_room_server", + ) + legacy_targets = {} + for base in room_bases + ("lilygo_t-echo-lite_repeater",): + for legacy in (base, base + "_lora_ota_no_external_sensors"): + legacy_targets[legacy] = base + "_lora_ota" + for primary in ("lilygo_t-echo_repeater", "sensecap_solar_repeater", "thinknode_m1_repeater", + "thinknode_m6_repeater", "wiotrackerl1_repeater"): + legacy_targets[primary + "_lora_ota_no_external_sensors"] = primary + bridge_legacy = {"xiao_nrf52_repeater_bridge_rs232", + "xiao_nrf52_repeater_bridge_rs232_lora_ota_no_external_sensors"} + legacy_targets.update({legacy: "xiao_nrf52_repeater" for legacy in bridge_legacy}) 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 + # and layout-matched sensor pair. Do not let retired 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 " + error = (legacy + ": ordinary onboard-QSPI release requires the qualified " "external-QSPI Full/Reduced pair for " + primary - + "; internal-only artifacts cannot substitute for its distinct OTA identity") + + "; retired 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 {}) + or any("hardware.qualification.pending" in manifest.get("capabilities", []) + or (manifest.get("ota_update_requirements") or {}) .get("lora", {}).get("storage") != "external_qspi" - for manifest in replacements)): + for manifest in replacements) + or replacements[0].get("source_commit") != replacements[1].get("source_commit") + or replacements[0].get("ota_update_requirements") != + replacements[1].get("ota_update_requirements")): raise ValueError(error) + if legacy in bridge_legacy: + # The ordinary QSPI repeater may retire the separate UART role + # only when both sensor profiles retain the linked driver and + # prove its runtime controls in the packaged application. + required = {"bridge.rs232": "linked image", + "bridge.rs232.merged": "packaged application"} + for manifest in replacements: + proven = {check.get("capability") for check in manifest.get("verification", []) + if check.get("present") is True + and required.get(check.get("capability")) == check.get("source") + and isinstance(check.get("evidence"), str) and check["evidence"].strip()} + if (not required.keys() <= set(manifest.get("capabilities", [])) + or not required.keys() <= proven): + raise ValueError(error + "; replacement requires a verified runtime RS232 bridge") return [record for record in records if record["manifest"]["target"].lower() not in legacy_targets] +def omit_internal_solar_room_records(records): + """Compatibility entry point for the shared onboard-QSPI retirement policy.""" + return omit_retired_onboard_qspi_records(records) + + def release_profile_label(manifest): sensor = nrf52_sensor_profile(manifest) if sensor: @@ -153,6 +193,8 @@ def validate_nrf52_sensor_pairs(records): def validate_manifest(manifest): target = manifest["target"] + if "hardware.qualification.pending" in manifest.get("capabilities", []): + raise ValueError(f"{target}: hardware qualification pending; experimental firmware cannot be published") if manifest.get("schema_version", 0) < 2 or not manifest.get("verified"): raise ValueError(f"{target}: firmware capability qualification failed") if not is_companion(target) and not manifest.get("ota_update_verified"): @@ -414,7 +456,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) + records = omit_retired_onboard_qspi_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 6fb18e579..d9a4b28ec 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -765,6 +765,89 @@ if is_solarxiao_internal_room_compatibility_target Xiao_nrf52_room_server \ fail "SolarXiao internal Room policy retired an unrelated recipe" fi +# Every supported onboard-QSPI Room has a normal exact external identity, +# while old internal identities stay directly buildable without being remapped. +for qspi_board in Xiao_nrf52 \ + ikoka_nano_nrf_22dbm ikoka_nano_nrf_30dbm ikoka_nano_nrf_33dbm \ + ikoka_stick_nrf_22dbm ikoka_stick_nrf_30dbm ikoka_stick_nrf_33dbm \ + ikoka_handheld_nrf_e22_30dbm LilyGo_T-Echo SenseCap_Solar \ + ThinkNode_M1 ThinkNode_M6 WioTrackerL1 LilyGo_T-Echo-Lite; do + legacy_room=${qspi_board}_room_server + legacy_alias=${legacy_room}_lora_ota_no_external_sensors + qspi_room=${legacy_room}_lora_ota + [ "$(get_onboard_qspi_normal_replacement "$legacy_room")" = "$qspi_room" ] \ + || fail "$legacy_room lacks its normal QSPI replacement" + for old in "$legacy_room" "$legacy_alias"; do + is_supported_build_env "$old" || fail "$old lost explicit compatibility builds" + is_redundant_bulk_build_target "$old" || fail "$old remains an ordinary Room choice" + [ "$(get_pio_build_env "$old")" = "$legacy_room" ] \ + || fail "$old changed its original internal recipe" + done + for selection in "$room_release_targets" "$solar_room_targets" \ + "$solar_all_targets" "$solar_logging_targets" "$solar_menu_targets"; do + grep -Fxq "$qspi_room" <<<"$selection" || fail "$qspi_room missing from ordinary selection" + if grep -Fxq -e "$legacy_room" -e "$legacy_alias" <<<"$selection"; then + fail "$legacy_room internal identity leaked into ordinary selection" + fi + done + ( + REQUIRE_OTA_UPDATES=0 + resolve_command_targets build-firmware "$legacy_room" + [ "${#RESOLVED_BUILD_TARGETS[@]}" -eq 1 ] \ + && [ "${RESOLVED_BUILD_TARGETS[0]}" = "$legacy_alias" ] \ + || fail "$legacy_room direct build lost its deployed internal pair identity" + ) +done +repeater_release_targets=$(print_release_firmware_targets get-repeater-firmwares-to-build) +for board in LilyGo_T-Echo SenseCap_Solar ThinkNode_M1 ThinkNode_M6 WioTrackerL1; do + canonical=${board}_repeater + alias=${canonical}_lora_ota_no_external_sensors + is_supported_build_env "$alias" || fail "$alias lost its exact deployed recipe" + for selection in "$repeater_release_targets" "$solar_all_targets" \ + "$solar_logging_targets" "$solar_menu_targets"; do + grep -Fxq "$canonical" <<<"$selection" || fail "$canonical missing its normal QSPI pair" + if grep -Fxq "$alias" <<<"$selection"; then fail "$alias duplicates its normal QSPI pair"; fi + done +done +for selection in "$repeater_release_targets" "$solar_all_targets" \ + "$solar_logging_targets" "$solar_menu_targets"; do + grep -Fxq LilyGo_T-Echo-Lite_repeater_lora_ota <<<"$selection" \ + || fail "Lite QSPI repeater missing from ordinary selection" + if grep -Fxq -e LilyGo_T-Echo-Lite_repeater \ + -e LilyGo_T-Echo-Lite_repeater_lora_ota_no_external_sensors \ + -e Xiao_nrf52_repeater_bridge_rs232 \ + -e Xiao_nrf52_repeater_bridge_rs232_lora_ota_no_external_sensors <<<"$selection"; then + fail "internal Lite/bridge identity leaked into ordinary selection" + fi +done +for pending in muzi_base_duo_repeater_lora_ota muzi_base_duo_room_server_lora_ota \ + muzi_base_uno_repeater_lora_ota muzi_base_uno_room_server_lora_ota \ + LilyGo_T-Echo_Card_repeater_lora_ota LilyGo_T-Echo_Card_room_server_lora_ota \ + Nano_G2_Ultra_repeater_lora_ota Nano_G2_Ultra_room_server_lora_ota; do + is_supported_build_env "$pending" || fail "$pending qualification recipe missing" + for selection in "$room_release_targets" "$repeater_release_targets" "$solar_all_targets" \ + "$solar_logging_targets" "$solar_menu_targets"; do + if grep -Fxq "$pending" <<<"$selection"; then fail "$pending escaped qualification quarantine"; fi + done + ( + BUILD_PROFILE_FOR_TARGET=standard + ESP32_FULL_BUILD=0 + BUILD_CAPABILITIES=() BUILD_EXPECTATIONS=() BUILD_APPLICATION_EXPECTATIONS=() BUILD_REDUCTIONS=() + declare_build_capability_contract "$pending" NRF52_PLATFORM + [[ " ${BUILD_CAPABILITIES[*]} " == *" hardware.qualification.pending "* ]] \ + || fail "$pending lacks its package publication prohibition" + ) +done +( + BUILD_PROFILE_FOR_TARGET=standard + ESP32_FULL_BUILD=0 + BUILD_CAPABILITIES=() BUILD_EXPECTATIONS=() BUILD_APPLICATION_EXPECTATIONS=() BUILD_REDUCTIONS=() + declare_build_capability_contract Xiao_nrf52_repeater NRF52_PLATFORM + [[ " ${BUILD_EXPECTATIONS[*]} " == *"bridge.rs232="* \ + && " ${BUILD_APPLICATION_EXPECTATIONS[*]} " == *"bridge.rs232.merged="* ]] \ + || fail "merged XIAO bridge lacks linked driver and packaged runtime proof" +) + 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 e7c0cc854..65d8b6a57 100644 --- a/test/test_cascade_release_package.py +++ b/test/test_cascade_release_package.py @@ -63,6 +63,12 @@ class ReleaseQualificationTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, "wireless updater"): package.validate_manifest(self.manifest(ota_update_verified=False)) + def test_pending_hardware_qualification_cannot_be_published(self): + for platform in ('NRF52_PLATFORM', 'ESP32_PLATFORM'): + manifest = self.manifest(platform=platform, capabilities=['hardware.qualification.pending']) + with self.subTest(platform=platform), self.assertRaisesRegex(ValueError, 'hardware qualification pending'): + package.validate_manifest(manifest) + def test_usb_companion_is_accepted(self): package.validate_manifest(self.manifest("test_companion_radio_usb", ota_update_verified=False)) @@ -99,9 +105,11 @@ class ReleaseQualificationTest(unittest.TestCase): def sensor_record(self, directory, sensor, *, target='test_repeater', target_id=None, hw_id='TEST', app_base=0x26000, flags=0x10, fw_version=0x01110107, - version='v1.17.1.7-dev-deadbeef', storage='internal_flash_and_retained_ram'): + version='v1.17.1.7-dev-deadbeef', storage='internal_flash_and_retained_ram', + extra_application=b''): stem = target + '-' + sensor + '-ota-' + version - application, _ = ensure_endf(ensure_nrf52_layout(b'OTA: status\0invalid in-place patch geometry\0', + application, _ = ensure_endf(ensure_nrf52_layout(b'OTA: status\0invalid in-place patch geometry\0' + + extra_application, Nrf52Layout(app_base, 0xED000, 0xED000, flags)), FwIdent(fw_version, target_id_for_env(target) if target_id is None else target_id, hw_id)) path = directory / (stem + '.zip') @@ -117,12 +125,23 @@ class ReleaseQualificationTest(unittest.TestCase): ota_update_requirements={'lora': {'storage': storage}}) return {'manifest': manifest, 'files': [path]} - def sensor_package_fixture(self, directory, target, sensor, *, external=False): + def sensor_package_fixture(self, directory, target, sensor, *, external=False, + merged_bridge=False, extra_capabilities=()): """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') + storage='external_qspi' if external else 'internal_flash_and_retained_ram', + extra_application=b'meshcore.capability.rs232.merged.v1\0' if merged_bridge else b'') + manifest = record['manifest'] + manifest['capabilities'].extend(extra_capabilities) + if merged_bridge: + for capability, source, evidence in ( + ('bridge.rs232', 'linked image', '_ZN11RS232Bridge5beginEv'), + ('bridge.rs232.merged', 'packaged application', 'meshcore.capability.rs232.merged.v1')): + manifest['capabilities'].append(capability) + manifest['verification'].append(dict(capability=capability, source=source, + present=True, evidence=evidence)) path = record['files'][0] with zipfile.ZipFile(path) as archive: application = archive.read('application.bin') @@ -153,20 +172,30 @@ class ReleaseQualificationTest(unittest.TestCase): '--input', str(directory / 'input'), '--output', str(directory / 'stage'), '--build-status', str(status), '--commit', commit] - def test_direct_matrix_packaging_omits_resumed_solar_internal_room_packages(self): + def test_direct_matrix_packaging_omits_resumed_onboard_qspi_alternatives(self): with tempfile.TemporaryDirectory() as temp: directory = Path(temp) inputs = directory / 'input' inputs.mkdir() retired = [] expected = [] - for board in ('30S', '33S'): - target = 'solarxiao_' + board + '_room_server' + cases = [(target, target + '_lora_ota') for target in ( + 'solarxiao_30S_room_server', 'solarxiao_33S_room_server', 'Xiao_nrf52_room_server', + 'ikoka_nano_nrf_33dbm_room_server', 'ikoka_stick_nrf_22dbm_room_server', + 'ikoka_handheld_nrf_e22_30dbm_room_server', 'LilyGo_T-Echo_room_server', + 'SenseCap_Solar_room_server', 'ThinkNode_M1_room_server', 'ThinkNode_M6_room_server', + 'WioTrackerL1_room_server', 'LilyGo_T-Echo-Lite_room_server', 'LilyGo_T-Echo-Lite_repeater')] + cases += [(target + '_lora_ota_no_external_sensors', target) for target in ( + 'LilyGo_T-Echo_repeater', 'SenseCap_Solar_repeater', 'ThinkNode_M1_repeater', + 'ThinkNode_M6_repeater', 'WioTrackerL1_repeater')] + cases.append(('Xiao_nrf52_repeater_bridge_rs232', 'Xiao_nrf52_repeater')) + for target, primary in cases: for sensor in ('full', 'reduced'): retired.append(self.sensor_package_fixture(inputs, target, sensor)) - expected.append(self.sensor_package_fixture(inputs, target + '_lora_ota', sensor, - external=True)) - for target in ('Xiao_nrf52_room_server', 'RAK_3401_room_server'): + expected.append(self.sensor_package_fixture(inputs, primary, sensor, external=True, + merged_bridge=primary == 'Xiao_nrf52_repeater')) + for target in ('muzi_base_uno_room_server', 'RAK_3401_room_server', + 'Nano_G2_Ultra_room_server', 'LilyGo_T-Echo_Card_room_server'): for sensor in ('full', 'reduced'): expected.append(self.sensor_package_fixture(inputs, target, sensor)) before = {p.name: p.read_bytes() for p in inputs.iterdir()} @@ -184,6 +213,7 @@ class ReleaseQualificationTest(unittest.TestCase): staged = by_artifact[manifest['artifact_target']] self.assertEqual(staged['target'], manifest['target']) self.assertEqual(staged['ota_update_requirements'], manifest['ota_update_requirements']) + self.assertEqual(staged['capabilities'], manifest['capabilities']) for name in staged['files']: matches = list(stage.glob('*/' + name)) self.assertEqual(len(matches), 1) @@ -209,6 +239,38 @@ class ReleaseQualificationTest(unittest.TestCase): self.assertFalse((directory / 'stage').exists()) self.assertEqual({p.name: p.read_bytes() for p in inputs.iterdir()}, before) + def test_direct_matrix_packaging_requires_xiao_runtime_bridge_in_each_profile(self): + for missing_sensor in ('full', 'reduced'): + with self.subTest(profile=missing_sensor), tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + inputs = directory / 'input' + inputs.mkdir() + for sensor in ('full', 'reduced'): + self.sensor_package_fixture(inputs, 'Xiao_nrf52_repeater_bridge_rs232', sensor) + self.sensor_package_fixture(inputs, 'Xiao_nrf52_repeater', sensor, external=True, + merged_bridge=sensor != missing_sensor) + before = {p.name: p.read_bytes() for p in inputs.iterdir()} + result = subprocess.run(self.matrix_package_command(directory), capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('verified runtime RS232 bridge', result.stderr) + self.assertFalse((directory / 'stage').exists()) + self.assertEqual({p.name: p.read_bytes() for p in inputs.iterdir()}, before) + + def test_direct_matrix_packaging_rejects_experimental_qspi_even_with_complete_pair(self): + with tempfile.TemporaryDirectory() as temp: + directory = Path(temp) + inputs = directory / 'input' + inputs.mkdir() + for sensor in ('full', 'reduced'): + self.sensor_package_fixture(inputs, 'experimental_repeater_lora_ota', sensor, external=True, + extra_capabilities=('hardware.qualification.pending',)) + before = {p.name: p.read_bytes() for p in inputs.iterdir()} + result = subprocess.run(self.matrix_package_command(directory), capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('hardware qualification pending', result.stderr) + self.assertFalse((directory / 'stage').exists()) + self.assertEqual({p.name: p.read_bytes() for p in inputs.iterdir()}, before) + def test_sensor_pair_requires_one_of_each_with_matching_packaged_identity_and_layout(self): with tempfile.TemporaryDirectory() as temp: directory = Path(temp) diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index 3a79414a3..c536924d8 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -1228,6 +1228,169 @@ 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'); +const fixedQspiHardware = ['LilyGo_T-Echo', 'SenseCap_Solar', 'ThinkNode_M1', + 'ThinkNode_M6', 'WioTrackerL1']; +const newQspiRoomHardware = ['Xiao_nrf52', 'ikoka_handheld_nrf_e22_30dbm', + 'ikoka_nano_nrf_22dbm', 'ikoka_nano_nrf_30dbm', 'ikoka_nano_nrf_33dbm', + 'ikoka_stick_nrf_22dbm', 'ikoka_stick_nrf_30dbm', 'ikoka_stick_nrf_33dbm', + 'LilyGo_T-Echo-Lite', ...fixedQspiHardware]; +for (const hardware of newQspiRoomHardware) { + assert(!currentCatalog.profiles.some(profile => profile.hardware === hardware && profile.role === 'room'), + 'An older release without the new QSPI room recipe must not recommend internal storage'); + for (const policy of ['full', 'reduced']) { + const target = hardware + '_room_server_lora_ota_no_external_sensors-' + policy; + assert(currentCatalog.rows.some(row => row.target === target), + 'Retired room images must remain available for exact compatibility search'); + } +} +const qspiFixtureSource = 'deadbeef' + '0'.repeat(32); +const qspiFixtureFamily = 'v1.17.1.10-halo-keymind-cascade-dev-deadbeef'; +const qspiRoomControls = {familyTag: qspiFixtureFamily, profiles: {}}; +const qspiRoomTargets = []; +for (const hardware of [...newQspiRoomHardware, 'solarxiao_30S', 'solarxiao_33S']) { + for (const base of [hardware + '_room_server', + hardware + '_room_server_lora_ota_no_external_sensors', hardware + '_room_server_lora_ota']) { + for (const policy of ['full', 'reduced']) { + const target = base + '-' + policy; + qspiRoomTargets.push(target); + qspiRoomControls.profiles[target] = { + platform: 'NRF52_PLATFORM', otaRole: 'lora-receiver', updateMethods: ['lora'], + sensorProfile: policy, sensorProfileSource: qspiFixtureSource, + updateRequirements: {lora: {storage: base.endsWith('_lora_ota') ? 'external_qspi' : 'internal_flash'}}, + }; + } + } +} +const qspiRoomAssets = qspiRoomTargets.map(target => asset(target + '-ota-' + qspiFixtureFamily + '.uf2')); +const qspiRoomCatalog = picker.buildCatalog([ + release(qspiFixtureFamily, '2026-10-16T00:00:00Z', qspiRoomAssets), +], qspiRoomControls); +assert.deepStrictEqual(qspiRoomCatalog.rows.map(row => row.target), qspiRoomTargets); +for (const hardware of [...newQspiRoomHardware, 'solarxiao_30S', 'solarxiao_33S']) { + const visible = qspiRoomCatalog.profiles.filter(profile => profile.hardware === hardware); + assert.deepStrictEqual(visible.map(profile => profile.target).sort(), + ['full', 'reduced'].map(policy => hardware + '_room_server_lora_ota-' + policy)); + assert(visible.every(profile => profile.sensorProfile && + profile.files.every(file => file.target === profile.target)), + 'New QSPI room recommendations must retain qualified sensor policies and exact identities'); +} +const retiredRoomOnly = picker.buildCatalog([ + release(qspiFixtureFamily, '2026-10-16T00:00:00Z', qspiRoomAssets.filter(file => + !file.name.includes('_room_server_lora_ota-full-') && !file.name.includes('_room_server_lora_ota-reduced-'))), +], qspiRoomControls); +assert.deepStrictEqual(retiredRoomOnly.profiles, [], + 'No retired board may fall back to its internal room recipe without a QSPI replacement'); +const liteRepeaterTargets = ['', '_lora_ota_no_external_sensors', '_lora_ota'].flatMap(suffix => + ['full', 'reduced'].map(policy => 'LilyGo_T-Echo-Lite_repeater' + suffix + '-' + policy)); +const liteRepeaterAssets = liteRepeaterTargets.map(target => + asset(target + '-ota-' + qspiFixtureFamily + '.uf2')); +const liteRepeaterCatalog = picker.buildCatalog([ + release(qspiFixtureFamily, '2026-10-16T00:00:00Z', liteRepeaterAssets), +]); +assert.deepStrictEqual(liteRepeaterCatalog.profiles.map(profile => profile.target).sort(), + ['full', 'reduced'].map(policy => 'LilyGo_T-Echo-Lite_repeater_lora_ota-' + policy)); +assert.deepStrictEqual(liteRepeaterCatalog.rows.map(row => row.target), liteRepeaterTargets); +assert(!currentCatalog.profiles.some(profile => + profile.hardware === 'LilyGo_T-Echo-Lite' && profile.role === 'repeater'), + 'The old internal Lite repeater must not be recommended before its QSPI release exists'); +for (const hardware of ['LilyGo_T-Echo_Card', 'muzi_base_uno', 'muzi_base_duo', 'Nano_G2_Ultra']) { + const original = Object.keys(currentControls.profiles).filter(target => + target.startsWith(hardware + '_repeater_') || target.startsWith(hardware + '_room_server_')); + assert(original.length > 0, 'The fixture must exercise each hardware-pending board'); + for (const target of original) { + assert(currentCatalog.profiles.some(profile => profile.target === target), + 'Hardware-pending QSPI boards must retain their supported current firmware'); + } +} +for (const hardware of fixedQspiHardware) { + const base = hardware + '_repeater'; + const visible = currentCatalog.profiles.filter(profile => profile.hardware === hardware && profile.role === 'repeater'); + assert.deepStrictEqual(visible.map(profile => profile.target).sort(), + ['full', 'reduced'].map(policy => base + '-' + policy), + 'Qualified QSPI canonical pairs must replace duplicate repeater recommendations'); + for (const policy of ['full', 'reduced']) { + assert(currentCatalog.rows.some(row => row.target === base + '_lora_ota_no_external_sensors-' + policy)); + } + for (const fault of ['missing_full', 'missing_reduced', 'stale_source', 'mixed_source', + 'internal_storage', 'missing_controls']) { + const changedControls = JSON.parse(JSON.stringify(currentControls)); + let files = currentAssets.filter(file => file.name.startsWith(base + '-full-') || + file.name.startsWith(base + '-reduced-') || file.name.startsWith(base + '_lora_ota_no_external_sensors-')); + if (fault.startsWith('missing_') && fault !== 'missing_controls') { + files = files.filter(file => !file.name.startsWith(base + '-' + fault.slice('missing_'.length) + '-')); + } else if (fault === 'stale_source') { + changedControls.profiles[base + '-full'].sensorProfileSource = 'f'.repeat(40); + } else if (fault === 'mixed_source') { + changedControls.profiles[base + '-full'].sensorProfileSource = 'a'.repeat(40); + files = files.map(file => file.name.startsWith(base + '-full-') + ? asset(file.name.replace(/-[0-9a-f]{8}\.uf2$/, '-aaaaaaaa.uf2')) : file); + } else if (fault === 'internal_storage') { + changedControls.profiles[base + '-full'].updateRequirements.lora.storage = 'internal_flash'; + } + const partial = picker.buildCatalog([ + release(currentControls.familyTag, '2026-10-16T00:00:00Z', files), + ], fault === 'missing_controls' ? undefined : changedControls); + for (const policy of ['full', 'reduced']) { + assert(partial.profiles.some(profile => profile.target === base + '_lora_ota_no_external_sensors-' + policy), + hardware + ': retain exact aliases when canonical QSPI qualification is incomplete (' + fault + ')'); + } + } +} +const xiaoBridgeBase = 'Xiao_nrf52_repeater_bridge_rs232'; +const xiaoBridgeBases = [xiaoBridgeBase, xiaoBridgeBase + '_lora_ota_no_external_sensors']; +assert(['full', 'reduced'].every(policy => currentCatalog.profiles.some(profile => + profile.target === xiaoBridgeBase + '-' + policy)), + 'The old canonical image has no merged bridge proof and must retain both bridge downloads'); +const mergedXiaoControls = JSON.parse(JSON.stringify(currentControls)); +for (const policy of ['full', 'reduced']) { + Object.assign(mergedXiaoControls.profiles['Xiao_nrf52_repeater-' + policy], { + rs232: true, rs232Merged: true, + rs232Source: currentControls.profiles['Xiao_nrf52_repeater-' + policy].sensorProfileSource, + }); + mergedXiaoControls.profiles[xiaoBridgeBases[1] + '-' + policy] = { + ...currentControls.profiles[xiaoBridgeBase + '-' + policy], + }; +} +const xiaoRepeaterAssets = currentAssets.filter(file => + file.name.startsWith('Xiao_nrf52_repeater-full-') || + file.name.startsWith('Xiao_nrf52_repeater-reduced-') || file.name.startsWith(xiaoBridgeBase + '-')) + .concat(['full', 'reduced'].map(policy => + asset(xiaoBridgeBases[1] + '-' + policy + '-ota-' + currentControls.familyTag + '.uf2'))); +const mergedXiaoCatalog = picker.buildCatalog([ + release(currentControls.familyTag, '2026-10-16T00:00:00Z', xiaoRepeaterAssets), +], mergedXiaoControls); +assert.deepStrictEqual(mergedXiaoCatalog.profiles.map(profile => profile.target).sort(), + ['full', 'reduced'].map(policy => 'Xiao_nrf52_repeater-' + policy)); +for (const profile of mergedXiaoCatalog.profiles) { + assert.deepStrictEqual(picker.profileFieldValues(profile, 'mode'), ['standard', 'rs232']); + assert(profile.files.every(file => file.target === profile.target)); +} +assert(xiaoBridgeBases.every(base => ['full', 'reduced'].every(policy => + mergedXiaoCatalog.rows.some(row => row.target === base + '-' + policy))), + 'Retired bridge identities must remain exact searchable compatibility files'); +for (const fault of ['missing_full', 'missing_reduced', 'unmerged', 'missing_source', 'stale_source', 'no_rs232']) { + const changedControls = JSON.parse(JSON.stringify(mergedXiaoControls)); + let files = xiaoRepeaterAssets; + const info = changedControls.profiles['Xiao_nrf52_repeater-full']; + if (fault === 'missing_full' || fault === 'missing_reduced') { + files = files.filter(file => !file.name.startsWith('Xiao_nrf52_repeater-' + fault.slice(8) + '-')); + } else if (fault === 'unmerged') { + delete info.rs232Merged; + } else if (fault === 'missing_source') { + delete info.rs232Source; + } else if (fault === 'stale_source') { + info.rs232Source = 'f'.repeat(40); + } else { + info.rs232 = false; + } + const partial = picker.buildCatalog([ + release(currentControls.familyTag, '2026-10-16T00:00:00Z', files), + ], changedControls); + assert(xiaoBridgeBases.every(base => ['full', 'reduced'].every(policy => + partial.profiles.some(profile => profile.target === base + '-' + policy))), + 'Keep bridge functionality until both canonical policies prove merged control: ' + fault); +} +console.log('onboard-QSPI room retirement and qualified repeater alias replacements 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 a160d5429..177ecfabf 100644 --- a/test/test_local_release_full_selection.py +++ b/test/test_local_release_full_selection.py @@ -35,6 +35,37 @@ def sensor_pair(target: str, storage: str = "external_qspi") -> list[dict]: return variants +def onboard_qspi_retirements() -> list[tuple[str, str]]: + rooms = ("solarxiao_30S_room_server", "solarxiao_33S_room_server", "Xiao_nrf52_room_server", + "ikoka_nano_nrf_22dbm_room_server", "ikoka_nano_nrf_30dbm_room_server", + "ikoka_nano_nrf_33dbm_room_server", "ikoka_stick_nrf_22dbm_room_server", + "ikoka_stick_nrf_30dbm_room_server", "ikoka_stick_nrf_33dbm_room_server", + "ikoka_handheld_nrf_e22_30dbm_room_server", "LilyGo_T-Echo_room_server", + "SenseCap_Solar_room_server", "ThinkNode_M1_room_server", "ThinkNode_M6_room_server", + "WioTrackerL1_room_server", "LilyGo_T-Echo-Lite_room_server") + cases = [(legacy, base + "_lora_ota") + for base in rooms + ("LilyGo_T-Echo-Lite_repeater",) + for legacy in (base, base + "_lora_ota_no_external_sensors")] + cases += [(target + "_lora_ota_no_external_sensors", target) for target in ( + "LilyGo_T-Echo_repeater", "SenseCap_Solar_repeater", "ThinkNode_M1_repeater", + "ThinkNode_M6_repeater", "WioTrackerL1_repeater")] + cases += [(target, "Xiao_nrf52_repeater") for target in ( + "Xiao_nrf52_repeater_bridge_rs232", + "Xiao_nrf52_repeater_bridge_rs232_lora_ota_no_external_sensors")] + return cases + + +def prove_runtime_rs232(variants: list[dict]) -> list[dict]: + for item in variants: + manifest = item["manifest"] + for capability, source in (("bridge.rs232", "linked image"), + ("bridge.rs232.merged", "packaged application")): + manifest["capabilities"].append(capability) + manifest["verification"].append(dict(capability=capability, source=source, + present=True, evidence=capability)) + 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", @@ -282,7 +313,8 @@ class ReleaseFullSelectionTest(unittest.TestCase): 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", + "muzi_base_uno_room_server", "Nano_G2_Ultra_room_server", + "LilyGo_T-Echo_Card_room_server", "R1Neo_sensor", "RAK_3401_room_server", "RAK_4631_room_server_lora_ota_no_external_sensors", "solarxiao_30S_repeater", "solarxiao_33S_sensor", )] @@ -346,6 +378,90 @@ class ReleaseFullSelectionTest(unittest.TestCase): select_ordinary_full_records(inputs) self.assertEqual(inputs, before) + def test_onboard_qspi_retirement_preserves_pairs_files_sources_and_original_identities(self): + legacy = [] + primaries = {} + for old, primary in onboard_qspi_retirements(): + legacy.extend(sensor_pair(old.upper(), "internal_flash")) + if primary not in primaries: + pair = sensor_pair(primary) + if primary == "Xiao_nrf52_repeater": + prove_runtime_rs232(pair) + primaries[primary] = pair + for item in legacy: + item["manifest"].update(source_commit="original-recovery-source", target_id=1234) + current = [item for pair in primaries.values() for item in pair] + for item in current: + item["manifest"].update(source_commit="qualified-normal-source", target_id=5678) + unrelated = [record(target, "auto", "NRF52_PLATFORM") for target in ( + "muzi_base_duo_repeater", "muzi_base_uno_room_server", "Nano_G2_Ultra_room_server", + "LilyGo_T-Echo_Card_room_server", "RAK_3401_room_server", "R1Neo_sensor")] + for inputs in (legacy + current + unrelated, unrelated + current + legacy, + list(reversed(legacy + current + 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.assertTrue(all(actual is original for actual, original in zip(selected, expected))) + self.assertEqual(inputs, before) + + def test_all_onboard_qspi_retirements_require_the_exact_complete_matching_pair(self): + for old, primary in onboard_qspi_retirements(): + for fault in ("missing", "wrong_board", "full_only", "reduced_only", "duplicate", + "source_mismatch", "pending_hardware"): + pair = sensor_pair(primary) + if primary == "Xiao_nrf52_repeater": + prove_runtime_rs232(pair) + if fault == "missing": + pair = [] + elif fault == "wrong_board": + pair = sensor_pair("unrelated_room_server_lora_ota") + elif fault == "full_only": + pair = pair[:1] + elif fault == "reduced_only": + pair = pair[1:] + elif fault == "duplicate": + pair.append(deepcopy(pair[0])) + elif fault == "source_mismatch": + pair[0]["manifest"]["source_commit"] = "different-source" + else: + pair[0]["manifest"]["capabilities"].append("hardware.qualification.pending") + stale = sensor_pair(old, "internal_flash") + inputs = stale + pair + before = deepcopy(inputs) + with self.subTest(old=old, fault=fault): + with self.assertRaisesRegex(ValueError, "qualified external-QSPI Full/Reduced pair"): + select_ordinary_full_records(inputs) + self.assertEqual(inputs, before) + + def test_xiao_bridge_retirement_requires_packaged_runtime_and_linked_driver_proof_in_both_profiles(self): + for old in ("Xiao_nrf52_repeater_bridge_rs232", + "Xiao_nrf52_repeater_bridge_rs232_lora_ota_no_external_sensors"): + for index in (0, 1): + for capability in ("bridge.rs232", "bridge.rs232.merged"): + for fault in ("missing_capability", "missing_proof", "false_proof", + "wrong_source", "empty_evidence"): + pair = prove_runtime_rs232(sensor_pair("Xiao_nrf52_repeater")) + manifest = pair[index]["manifest"] + check = next(item for item in manifest["verification"] + if item["capability"] == capability) + if fault == "missing_capability": + manifest["capabilities"].remove(capability) + elif fault == "missing_proof": + manifest["verification"].remove(check) + elif fault == "false_proof": + check["present"] = False + elif fault == "wrong_source": + check["source"] = "filename" + else: + check["evidence"] = "" + inputs = sensor_pair(old, "internal_flash") + pair + before = deepcopy(inputs) + with self.subTest(old=old, profile=index, capability=capability, fault=fault): + with self.assertRaisesRegex(ValueError, "verified runtime RS232 bridge"): + 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/test/test_onboard_qspi_profiles.py b/test/test_onboard_qspi_profiles.py new file mode 100644 index 000000000..a04ba6d71 --- /dev/null +++ b/test/test_onboard_qspi_profiles.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""Resolved PlatformIO contracts for onboard-NOR Repeater and Room targets. + +Set MESHCORE_TEST_PIO_CONFIG to a previously generated PlatformIO JSON file +when sharing a checkout with a build. Otherwise this suite resolves the project +once; it must follow the repository's single-PlatformIO-process rule. +""" + +from dataclasses import dataclass +from functools import lru_cache +import glob +import json +import os +from pathlib import Path +import re +import shlex +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +V6 = "boards/nrf52840_s140_v6.ld" +V7 = "boards/nrf52840_s140_v7.ld" +XIAO = "boards/nrf52840_s140_v7_xiao_bootloader_ota.ld" +XIAO_BOARD = "seeed-xiao-afruitnrf52-nrf52840" +PENDING = "OTA_QSPI_HARDWARE_QUALIFICATION_PENDING" +INTERNAL_ONLY = ("OTA_HYBRID_RAM_STORE", "OTA_INTERNAL_BOOTLOADER_UPDATE") +STORAGE_CHANGES = { + "ENABLE_OTA", "DISABLE_LORA_OTA", "QSPIFLASH", "OTA_FLASH_STORE", + "OTA_QSPI_STORE", "OTA_FOLDER_SERIAL", "OTA_QSPI_BOOTLOADER_UPDATE", PENDING, +} + + +@dataclass(frozen=True) +class Profile: + parent: str + board: str + variant: str + linker: str = V6 + maximum_size: int = 815104 + + @property + def target(self): + return self.parent + "_lora_ota" + + +SUPPORTED = ( + Profile("Xiao_nrf52_room_server", XIAO_BOARD, "xiao_nrf52", XIAO, 757760), + Profile("ikoka_handheld_nrf_e22_30dbm_room_server", XIAO_BOARD, + "ikoka_handheld_nrf", XIAO, 757760), + *(Profile(f"ikoka_nano_nrf_{power}dbm_room_server", XIAO_BOARD, + "ikoka_nano_nrf", XIAO, 757760) for power in (22, 30, 33)), + *(Profile(f"ikoka_stick_nrf_{power}dbm_room_server", XIAO_BOARD, + "ikoka_stick_nrf", XIAO, 757760) for power in (22, 30, 33)), + Profile("LilyGo_T-Echo_room_server", "t-echo", "lilygo_techo"), + Profile("ThinkNode_M1_room_server", "thinknode_m1", "thinknode_m1"), + Profile("ThinkNode_M6_room_server", "thinknode_m6", "thinknode_m6"), + Profile("WioTrackerL1_room_server", "seeed-wio-tracker-l1", + "wio-tracker-l1", V7, 811008), + Profile("SenseCap_Solar_room_server", "seeed_sensecap_solar", + "sensecap_solar", V7, 811008), + Profile("LilyGo_T-Echo-Lite_repeater", "t-echo", "lilygo_techo_lite"), + Profile("LilyGo_T-Echo-Lite_room_server", "t-echo", "lilygo_techo_lite"), +) +PREEXISTING_SOLAR_ROOMS = ( + Profile("solarxiao_30S_room_server", XIAO_BOARD, "xiao_nrf52", XIAO, 757760), + Profile("solarxiao_33S_room_server", XIAO_BOARD, "xiao_nrf52", XIAO, 757760), +) +QUALIFICATION = ( + *(Profile(f"muzi_base_{radio}_{role}", "muzi_base", "muzi_base") + for radio in ("uno", "duo") for role in ("repeater", "room_server")), + *(Profile(f"LilyGo_T-Echo_Card_{role}", "t-echo", "lilygo_techo_card") + for role in ("repeater", "room_server")), + *(Profile(f"Nano_G2_Ultra_{role}", "nano-g2-ultra", "nano_g2_ultra") + for role in ("repeater", "room_server")), +) + + +def text_option(value): + if isinstance(value, list): + return "\n".join(str(item) for item in value) + return str(value) + + +def macro_operations(value): + """Parse both '-D NAME=1' and '-UNAME', preserving command-line order.""" + tokens = iter(shlex.split(text_option(value))) + operations = [] + for token in tokens: + if token in ("-D", "-U"): + kind, argument = token[1], next(tokens) + elif token.startswith(("-D", "-U")): + kind, argument = token[1], token[2:] + else: + continue + name, separator, value = argument.partition("=") + operations.append((kind, name, value if separator else None)) + return operations + + +def effective_defines(options): + # ProcessFlags emits CPPDEFINES first and appends every -U afterwards in + # _CPPDEFFLAGS, regardless of the order in build_flags. ProcessUnFlags + # removes matching CPPDEFINES; a bare -D name also removes valued defines. + # A defined zero still violates raw-QSPI/QSPIFLASH exclusivity. + removed = set(macro_operations(options.get("build_unflags", ""))) + operations = macro_operations(options.get("build_flags", "")) + defines = {} + for operation in operations: + kind, name, value = operation + if kind == "D" and operation not in removed and (kind, name, None) not in removed: + defines[name] = value + for kind, name, _ in operations: + if kind == "U": + defines.pop(name, None) + return defines + + +def role_defines(options): + return { + name: value for name, value in effective_defines(options).items() + if name not in STORAGE_CHANGES + } + + +def variant_includes(options): + tokens = iter(shlex.split(text_option(options.get("build_flags", "")))) + includes = set() + for token in tokens: + if token == "-I": + path = next(tokens) + elif token.startswith("-I"): + path = token[2:] + else: + continue + if path.startswith("variants/"): + includes.add(path) + return includes + + +@lru_cache(maxsize=None) +def source_matches(pattern, path): + # MatchSourceFiles expands glob patterns under PROJECT_SRC_DIR, then + # recursively includes matched directories. fnmatch('*.cpp') would also + # match helpers/ota/foo.cpp or ../examples/foo.cpp, which PIO does not. + source = (ROOT / "src" / path).resolve() + for candidate in glob.iglob(str(ROOT / "src" / pattern), recursive=True): + candidate = Path(candidate).resolve() + if candidate == source or candidate.is_dir() and candidate in source.parents: + return True + return False + + +def source_selected(options, path): + selected = False + for action, pattern in re.findall( + r"([+-])<([^>]+)>", text_option(options.get("build_src_filter", "")) + ): + if source_matches(pattern, path): + selected = action == "+" + return selected + + +class OnboardQspiProfilesTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cached = os.environ.get("MESHCORE_TEST_PIO_CONFIG") + if cached: + payload = Path(cached).read_text(encoding="utf-8") + else: + payload = subprocess.check_output( + ["pio", "project", "config", "--json-output"], cwd=ROOT, text=True + ) + cls.config = {section: dict(options) for section, options in json.loads(payload)} + + def options(self, target): + self.assertIn("env:" + target, self.config) + return self.config["env:" + target] + + def assert_external_contract(self, profile, pending=False): + options = self.options(profile.target) + parent = self.options(profile.parent) + defines = effective_defines(options) + parents = re.split(r"[,\s]+", text_option(options.get("extends", "")).strip()) + self.assertEqual(parents, ["env:" + profile.parent]) + self.assertEqual(options.get("board"), profile.board) + self.assertEqual(options.get("board"), parent.get("board")) + self.assertEqual(role_defines(options), role_defines(parent)) + self.assertEqual(variant_includes(options), {"variants/" + profile.variant}) + self.assertEqual(variant_includes(options), variant_includes(parent)) + self.assertEqual(options.get("board_build.ldscript"), profile.linker) + self.assertEqual(int(options.get("board_upload.maximum_size", 0)), profile.maximum_size) + for name in ("ENABLE_OTA", "OTA_FLASH_STORE", "OTA_QSPI_STORE"): + self.assertEqual(defines.get(name), "1", name) + for name in ("QSPIFLASH", "DISABLE_LORA_OTA", "OTA_SD_STORE", *INTERNAL_ONLY): + self.assertNotIn(name, defines) + if profile.linker == XIAO: + self.assertEqual(defines.get("OTA_QSPI_BOOTLOADER_UPDATE"), "1") + else: + self.assertNotIn("OTA_QSPI_BOOTLOADER_UPDATE", defines) + if pending: + self.assertEqual(defines.get(PENDING), "1") + else: + self.assertNotIn(PENDING, defines) + self.assertIn("post:tools/mota/pio_endf.py", text_option(options.get("extra_scripts", ""))) + for source in ("helpers/ota/OtaStoreQspiNrf52.cpp", "helpers/ota/OtaManager.cpp"): + self.assertTrue(source_selected(options, source), source) + role = "simple_room_server" if "_room_server" in profile.parent else "simple_repeater" + self.assertTrue(source_selected(options, "../examples/" + role + "/main.cpp")) + + def test_supported_external_profiles_keep_exact_legacy_hardware(self): + self.assertEqual(len(SUPPORTED), 15) + for profile in (*SUPPORTED, *PREEXISTING_SOLAR_ROOMS): + with self.subTest(target=profile.target): + self.assert_external_contract(profile) + + def test_original_internal_recipes_remain_distinct(self): + for profile in (*SUPPORTED, *PREEXISTING_SOLAR_ROOMS): + with self.subTest(target=profile.parent): + options = self.options(profile.parent) + defines = effective_defines(options) + self.assertEqual(options.get("board"), profile.board) + self.assertEqual(options.get("board_build.ldscript"), V7 if profile.linker == XIAO else profile.linker) + for name in ("OTA_QSPI_STORE", "OTA_QSPI_BOOTLOADER_UPDATE", PENDING, *INTERNAL_ONLY): + self.assertNotIn(name, defines) + self.assertNotEqual(profile.target, profile.parent) + + def test_linker_files_keep_the_checked_application_geometry(self): + for linker, start, end, maximum_size in ( + (V6, 0x26000, 0xED000, 815104), + (V7, 0x27000, 0xED000, 811008), + (XIAO, 0x27000, 0xE0000, 757760), + ): + with self.subTest(linker=linker): + source = (ROOT / linker).read_text(encoding="ascii") + flash = re.search( + r"FLASH\s*\(rx\)\s*:\s*ORIGIN\s*=\s*(0x[0-9A-Fa-f]+)," + r"\s*LENGTH\s*=\s*(0x[0-9A-Fa-f]+)\s*-\s*(0x[0-9A-Fa-f]+)", + source, + ) + self.assertIsNotNone(flash) + self.assertEqual(tuple(int(value, 16) for value in flash.groups()), (start, end, start)) + self.assertEqual(end - start, maximum_size) + + def test_unqualified_profiles_are_explicit_and_do_not_claim_xiao_layout(self): + marked = { + section[4:] for section, options in self.config.items() + if section.startswith("env:") and PENDING in effective_defines(options) + } + self.assertEqual(len(QUALIFICATION), 8) + self.assertEqual(marked, {profile.target for profile in QUALIFICATION}) + for profile in QUALIFICATION: + with self.subTest(target=profile.target): + self.assert_external_contract(profile, pending=True) + parent = self.options(profile.parent) + self.assertNotIn(PENDING, effective_defines(parent)) + self.assertNotIn("OTA_QSPI_STORE", effective_defines(parent)) + if profile.board == "muzi_base": + self.assertEqual(effective_defines(parent).get("QSPIFLASH"), "1") + self.assertIn( + ("D", "QSPIFLASH", "1"), + macro_operations(self.options(profile.target).get("build_unflags", "")), + ) + + def test_pending_profiles_preserve_base_optimization_unflags(self): + baseline = set(shlex.split(text_option(self.config["nrf52_base"]["build_unflags"]))) + self.assertIn("-Ofast", baseline) + for profile in QUALIFICATION: + with self.subTest(target=profile.target): + options = self.options(profile.target) + unflags = set(shlex.split(text_option(options.get("build_unflags", "")))) + self.assertTrue(baseline <= unflags, "missing inherited build_unflags: " + repr(baseline - unflags)) + self.assertIn("-Oz", shlex.split(text_option(options.get("build_flags", "")))) + + def test_xiao_normal_repeaters_have_a_disabled_merged_uart_driver(self): + for target in ("Xiao_nrf52_repeater", "solarxiao_30S_repeater", "solarxiao_33S_repeater"): + with self.subTest(target=target): + options = self.options(target) + defines = effective_defines(options) + expected = { + "WITH_RS232_BRIDGE": "Serial1", + "WITH_RS232_BRIDGE_UART": "1", + "WITH_RS232_BRIDGE_RX": "PIN_SERIAL1_RX", + "WITH_RS232_BRIDGE_TX": "PIN_SERIAL1_TX", + "BRIDGE_MAX_BAUD": "500000", + "RS232_BRIDGE_MERGED": "1", + } + for name, value in expected.items(): + self.assertEqual(defines.get(name), value, name) + self.assertNotIn("RS232_BRIDGE_DEFAULT_ON", defines) + self.assertNotIn("WITH_MQTT_BRIDGE", defines) + self.assertTrue(source_selected(options, "helpers/bridges/RS232Bridge.cpp")) + legacy = self.options("Xiao_nrf52_repeater_bridge_rs232") + defines = effective_defines(legacy) + self.assertEqual(defines.get("WITH_RS232_BRIDGE"), "Serial1") + self.assertNotIn("RS232_BRIDGE_MERGED", defines) + self.assertNotIn("OTA_QSPI_STORE", defines) + self.assertEqual(legacy.get("board_build.ldscript"), V7) + self.assertTrue(source_selected(legacy, "helpers/bridges/RS232Bridge.cpp")) + + def test_effective_defines_honor_undefines_and_removed_definitions(self): + options = { + "build_flags": ["-D ENABLE_OTA=0 -UENABLE_OTA -DENABLE_OTA=1", + "-D QSPIFLASH=1 -D ZERO_IS_DEFINED=0 -UABSENT"], + "build_unflags": "-D QSPIFLASH=1", + } + self.assertEqual(effective_defines(options), {"ZERO_IS_DEFINED": "0"}) + self.assertEqual(effective_defines({ + "build_flags": "-D QSPIFLASH=1 -D KEEP=2", + "build_unflags": "-DQSPIFLASH", + }), {"KEEP": "2"}) + self.assertEqual(effective_defines({ + "build_flags": "-D LEVEL=1 -D LEVEL=2", + "build_unflags": "-D LEVEL=1", + }), {"LEVEL": "2"}) + + def test_source_filters_keep_globs_inside_the_selected_directory(self): + options = {"build_src_filter": "+<*.cpp> +"} + self.assertFalse(source_selected(options, "../examples/simple_repeater/main.cpp")) + self.assertFalse(source_selected(options, "helpers/ota/OtaStoreQspiNrf52.cpp")) + options["build_src_filter"] = "+<*>" + self.assertFalse(source_selected(options, "../examples/simple_repeater/main.cpp")) + self.assertTrue(source_selected(options, "helpers/ota/OtaStoreQspiNrf52.cpp")) + options["build_src_filter"] = "+<../examples/simple_repeater>" + self.assertTrue(source_selected(options, "../examples/simple_repeater/main.cpp")) + options["build_src_filter"] = "+ -" + self.assertFalse(source_selected(options, "helpers/ota/OtaStoreQspiNrf52.cpp")) + options["build_src_filter"] += " +" + self.assertTrue(source_selected(options, "helpers/ota/OtaStoreQspiNrf52.cpp")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_picker_controls_generator.py b/test/test_picker_controls_generator.py index 860c4f804..0e64301b4 100644 --- a/test/test_picker_controls_generator.py +++ b/test/test_picker_controls_generator.py @@ -79,6 +79,42 @@ class PickerControlsTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, 'ambiguous'): GENERATOR.runtime_metadata(manifest, False, source) + def test_merged_rs232_requires_qualified_linked_and_packaged_proof(self): + source = 'a' * 40 + manifest = dict(target='Xiao_nrf52_repeater', source_commit=source, verified=True, + capabilities=['bridge.rs232', 'bridge.rs232.merged'], + verification=[dict(capability='bridge.rs232', present=True, + source='linked image', evidence='RS232Bridge::begin'), + dict(capability='bridge.rs232.merged', present=True, + source='packaged application', evidence='rs232.enabled')]) + self.assertEqual(GENERATOR.runtime_metadata(manifest, False), + dict(otaRole='none', rs232Merged=True, rs232Source=source)) + for fault in ('unverified', 'no_driver', 'no_runtime', 'no_capability', + 'wrong_driver_source', 'wrong_runtime_source', 'no_evidence', 'not_present'): + changed = json.loads(json.dumps(manifest)) + if fault == 'unverified': + changed['verified'] = False + elif fault == 'no_driver': + changed['verification'].pop(0) + elif fault == 'no_runtime': + changed['verification'].pop() + elif fault == 'no_capability': + changed['capabilities'].remove('bridge.rs232.merged') + elif fault == 'wrong_driver_source': + changed['verification'][0]['source'] = 'filename' + elif fault == 'wrong_runtime_source': + changed['verification'][1]['source'] = 'linked image' + elif fault == 'no_evidence': + changed['verification'][1]['evidence'] = '' + else: + changed['verification'][1]['present'] = False + with self.subTest(fault=fault): + self.assertNotIn('rs232Merged', GENERATOR.runtime_metadata(changed, False)) + self.assertNotIn('rs232Source', GENERATOR.runtime_metadata(changed, False)) + manifest['source_commit'] = 'aaaaaaaa' + with self.assertRaisesRegex(ValueError, 'exact source commit'): + GENERATOR.runtime_metadata(manifest, False) + def test_generates_independent_controls_for_same_identity_sensor_pair(self): source = 'a' * 40 family = 'v1.17.1.7-dev-aaaaaaaa' diff --git a/variants/ikoka_handheld_nrf/platformio.ini b/variants/ikoka_handheld_nrf/platformio.ini index 31150b465..cec1a016a 100644 --- a/variants/ikoka_handheld_nrf/platformio.ini +++ b/variants/ikoka_handheld_nrf/platformio.ini @@ -114,6 +114,17 @@ build_flags = ${ikoka_handheld_nrf.build_flags} build_src_filter = ${ikoka_handheld_nrf.build_src_filter} +<../examples/simple_room_server/*.cpp> +; Keep the original Room recipe for its distinct deployed internal OTA ID. +[env:ikoka_handheld_nrf_e22_30dbm_room_server_lora_ota] +extends = env:ikoka_handheld_nrf_e22_30dbm_room_server +board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld +board_upload.maximum_size = 757760 +build_flags = + ${env:ikoka_handheld_nrf_e22_30dbm_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} + -D OTA_QSPI_BOOTLOADER_UPDATE=1 +build_src_filter = ${env:ikoka_handheld_nrf_e22_30dbm_room_server.build_src_filter} + [env:ikoka_handheld_nrf_kiss_modem] extends = ikoka_handheld_nrf build_flags = ${ikoka_handheld_nrf.build_flags} diff --git a/variants/ikoka_nano_nrf/platformio.ini b/variants/ikoka_nano_nrf/platformio.ini index 53c94e4f4..acb80ca7a 100644 --- a/variants/ikoka_nano_nrf/platformio.ini +++ b/variants/ikoka_nano_nrf/platformio.ini @@ -193,6 +193,17 @@ build_src_filter = ${ikoka_nano_nrf_e22_22dbm.build_src_filter} +; Raw-QSPI Room OTA keeps this exact radio variant and bootloader layout. +[env:ikoka_nano_nrf_22dbm_room_server_lora_ota] +extends = env:ikoka_nano_nrf_22dbm_room_server +board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld +board_upload.maximum_size = 757760 +build_flags = + ${env:ikoka_nano_nrf_22dbm_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} + -D OTA_QSPI_BOOTLOADER_UPDATE=1 +build_src_filter = ${env:ikoka_nano_nrf_22dbm_room_server.build_src_filter} + ;;; 30dBm EBYTE E22-900M30 variants [env:ikoka_nano_nrf_30dbm_companion_radio_usb] extends = @@ -239,6 +250,17 @@ build_src_filter = ${ikoka_nano_nrf_e22_30dbm.build_src_filter} +; Raw-QSPI Room OTA keeps this exact radio variant and bootloader layout. +[env:ikoka_nano_nrf_30dbm_room_server_lora_ota] +extends = env:ikoka_nano_nrf_30dbm_room_server +board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld +board_upload.maximum_size = 757760 +build_flags = + ${env:ikoka_nano_nrf_30dbm_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} + -D OTA_QSPI_BOOTLOADER_UPDATE=1 +build_src_filter = ${env:ikoka_nano_nrf_30dbm_room_server.build_src_filter} + ;;; 33dBm EBYTE E22-900M33 variants [env:ikoka_nano_nrf_33dbm_companion_radio_usb] extends = @@ -284,6 +306,17 @@ build_src_filter = ${ikoka_nano_nrf_room_server.build_src_filter} ${ikoka_nano_nrf_e22_33dbm.build_src_filter} +; Original Room targets remain available with their deployed internal OTA IDs. +[env:ikoka_nano_nrf_33dbm_room_server_lora_ota] +extends = env:ikoka_nano_nrf_33dbm_room_server +board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld +board_upload.maximum_size = 757760 +build_flags = + ${env:ikoka_nano_nrf_33dbm_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} + -D OTA_QSPI_BOOTLOADER_UPDATE=1 +build_src_filter = ${env:ikoka_nano_nrf_33dbm_room_server.build_src_filter} + [env:ikoka_nano_nrf_22dbm_kiss_modem] extends = ikoka_nano_nrf_e22_22dbm build_src_filter = ${ikoka_nano_nrf_e22_22dbm.build_src_filter} diff --git a/variants/ikoka_stick_nrf/platformio.ini b/variants/ikoka_stick_nrf/platformio.ini index 2a96ab60f..2522c263e 100644 --- a/variants/ikoka_stick_nrf/platformio.ini +++ b/variants/ikoka_stick_nrf/platformio.ini @@ -203,6 +203,17 @@ build_src_filter = ${ikoka_stick_nrf_e22_22dbm.build_src_filter} +; Raw-QSPI Room OTA keeps this exact radio variant and bootloader layout. +[env:ikoka_stick_nrf_22dbm_room_server_lora_ota] +extends = env:ikoka_stick_nrf_22dbm_room_server +board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld +board_upload.maximum_size = 757760 +build_flags = + ${env:ikoka_stick_nrf_22dbm_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} + -D OTA_QSPI_BOOTLOADER_UPDATE=1 +build_src_filter = ${env:ikoka_stick_nrf_22dbm_room_server.build_src_filter} + ;;; 30dBm EBYTE E22-900M30 variants [env:ikoka_stick_nrf_30dbm_companion_radio_usb] @@ -250,6 +261,17 @@ build_src_filter = ${ikoka_stick_nrf_e22_30dbm.build_src_filter} +; Raw-QSPI Room OTA keeps this exact radio variant and bootloader layout. +[env:ikoka_stick_nrf_30dbm_room_server_lora_ota] +extends = env:ikoka_stick_nrf_30dbm_room_server +board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld +board_upload.maximum_size = 757760 +build_flags = + ${env:ikoka_stick_nrf_30dbm_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} + -D OTA_QSPI_BOOTLOADER_UPDATE=1 +build_src_filter = ${env:ikoka_stick_nrf_30dbm_room_server.build_src_filter} + ;;; 33dBm EBYTE E22-900M33 variants [env:ikoka_stick_nrf_33dbm_companion_radio_usb] @@ -296,6 +318,17 @@ build_src_filter = ${ikoka_stick_nrf_room_server.build_src_filter} ${ikoka_stick_nrf_e22_33dbm.build_src_filter} +; Original Room targets remain available with their deployed internal OTA IDs. +[env:ikoka_stick_nrf_33dbm_room_server_lora_ota] +extends = env:ikoka_stick_nrf_33dbm_room_server +board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld +board_upload.maximum_size = 757760 +build_flags = + ${env:ikoka_stick_nrf_33dbm_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} + -D OTA_QSPI_BOOTLOADER_UPDATE=1 +build_src_filter = ${env:ikoka_stick_nrf_33dbm_room_server.build_src_filter} + [env:ikoka_stick_nrf_22dbm_kiss_modem] extends = ikoka_stick_nrf_e22_22dbm build_src_filter = ${ikoka_stick_nrf_e22_22dbm.build_src_filter} diff --git a/variants/lilygo_techo/platformio.ini b/variants/lilygo_techo/platformio.ini index bdab9a065..2b678fcbd 100644 --- a/variants/lilygo_techo/platformio.ini +++ b/variants/lilygo_techo/platformio.ini @@ -78,6 +78,19 @@ build_flags = ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 +; Preserve the internal-staging Room target above for compatibility/recovery. +; Normal OTA uses the same dedicated QSPI store and app layout as Repeater. +[env:LilyGo_T-Echo_room_server_lora_ota] +extends = env:LilyGo_T-Echo_room_server +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 815104 +extra_scripts = ${nrf52_lora_ota.extra_scripts} +build_flags = + ${env:LilyGo_T-Echo_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} +build_src_filter = ${env:LilyGo_T-Echo_room_server.build_src_filter} + + + [env:LilyGo_T-Echo_companion_radio_ble] extends = LilyGo_T-Echo board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld diff --git a/variants/lilygo_techo_card/platformio.ini b/variants/lilygo_techo_card/platformio.ini index 7d5861af7..03ee0db34 100644 --- a/variants/lilygo_techo_card/platformio.ini +++ b/variants/lilygo_techo_card/platformio.ini @@ -62,6 +62,32 @@ build_flags = ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 +; Qualification-only: requires the Card-specific OTAFIX port (NFC retained, +; no ordinary LEDs, and the correct flash/radio rail), not the T-Echo loader. +[env:LilyGo_T-Echo_Card_repeater_lora_ota] +extends = env:LilyGo_T-Echo_Card_repeater +extra_scripts = ${nrf52_lora_ota.extra_scripts} +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 815104 +build_flags = + ${env:LilyGo_T-Echo_Card_repeater.build_flags} + ${nrf52_qspi_ota.build_flags} + -D OTA_QSPI_HARDWARE_QUALIFICATION_PENDING=1 +build_src_filter = ${env:LilyGo_T-Echo_Card_repeater.build_src_filter} + + + +[env:LilyGo_T-Echo_Card_room_server_lora_ota] +extends = env:LilyGo_T-Echo_Card_room_server +extra_scripts = ${nrf52_lora_ota.extra_scripts} +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 815104 +build_flags = + ${env:LilyGo_T-Echo_Card_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} + -D OTA_QSPI_HARDWARE_QUALIFICATION_PENDING=1 +build_src_filter = ${env:LilyGo_T-Echo_Card_room_server.build_src_filter} + + + [env:LilyGo_T-Echo_Card_companion_radio_ble] extends = LilyGo_T-Echo_Card board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld diff --git a/variants/lilygo_techo_lite/platformio.ini b/variants/lilygo_techo_lite/platformio.ini index b6b9678e9..67e2f6ada 100644 --- a/variants/lilygo_techo_lite/platformio.ini +++ b/variants/lilygo_techo_lite/platformio.ini @@ -67,6 +67,32 @@ build_flags = ; -D MESH_PACKET_LOGGING=1 ; -D MESH_DEBUG=1 +; Keep the internal-staging targets above available for compatibility/recovery. +; These targets require the Lite-specific QSPI OTAFIX bootloader. Its ordinary +; application layout needs no reserved XIAO bootloader-update scratch bank. +; TechoBoard::begin keeps the shared radio/flash RT9080 rail at P0.30 enabled. +[env:LilyGo_T-Echo-Lite_repeater_lora_ota] +extends = env:LilyGo_T-Echo-Lite_repeater +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 815104 +extra_scripts = ${nrf52_lora_ota.extra_scripts} +build_flags = + ${env:LilyGo_T-Echo-Lite_repeater.build_flags} + ${nrf52_qspi_ota.build_flags} +build_src_filter = ${env:LilyGo_T-Echo-Lite_repeater.build_src_filter} + + + +[env:LilyGo_T-Echo-Lite_room_server_lora_ota] +extends = env:LilyGo_T-Echo-Lite_room_server +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 815104 +extra_scripts = ${nrf52_lora_ota.extra_scripts} +build_flags = + ${env:LilyGo_T-Echo-Lite_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} +build_src_filter = ${env:LilyGo_T-Echo-Lite_room_server.build_src_filter} + + + [env:LilyGo_T-Echo-Lite_companion_radio_ble] extends = LilyGo_T-Echo-Lite board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld diff --git a/variants/muzi_base/platformio.ini b/variants/muzi_base/platformio.ini index a253c4b99..525102cbe 100644 --- a/variants/muzi_base/platformio.ini +++ b/variants/muzi_base/platformio.ini @@ -100,6 +100,45 @@ build_src_filter = ${muzi_base_duo.build_src_filter} +<../examples/simple_room_server> lib_deps = ${muzi_base_duo.lib_deps} +; Explicit qualification builds only. The source-verified Muzi OTAFIX port +; still needs physical qualification and a published board-matched loader. +; Raw staging must not share the Companion's QSPI filesystem configuration. +[env:muzi_base_duo_repeater_lora_ota] +extends = env:muzi_base_duo_repeater +extra_scripts = ${nrf52_lora_ota.extra_scripts} +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 815104 +build_unflags = + ${nrf52_base.build_unflags} + -D QSPIFLASH=1 +build_flags = + ${env:muzi_base_duo_repeater.build_flags} + ${nrf52_qspi_ota.build_flags} + -D ENABLE_OTA=1 + -D OTA_FLASH_STORE=1 + -D OTA_FOLDER_SERIAL + -D OTA_QSPI_HARDWARE_QUALIFICATION_PENDING=1 +build_src_filter = ${env:muzi_base_duo_repeater.build_src_filter} + + + +[env:muzi_base_duo_room_server_lora_ota] +extends = env:muzi_base_duo_room_server +extra_scripts = ${nrf52_lora_ota.extra_scripts} +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 815104 +build_unflags = + ${nrf52_base.build_unflags} + -D QSPIFLASH=1 +build_flags = + ${env:muzi_base_duo_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} + -D ENABLE_OTA=1 + -D OTA_FLASH_STORE=1 + -D OTA_FOLDER_SERIAL + -D OTA_QSPI_HARDWARE_QUALIFICATION_PENDING=1 +build_src_filter = ${env:muzi_base_duo_room_server.build_src_filter} + + + [env:muzi_base_duo_companion_radio_usb] extends = muzi_base_duo board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld @@ -227,6 +266,42 @@ build_src_filter = ${muzi_base_uno.build_src_filter} +<../examples/simple_room_server> lib_deps = ${muzi_base_uno.lib_deps} +[env:muzi_base_uno_repeater_lora_ota] +extends = env:muzi_base_uno_repeater +extra_scripts = ${nrf52_lora_ota.extra_scripts} +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 815104 +build_unflags = + ${nrf52_base.build_unflags} + -D QSPIFLASH=1 +build_flags = + ${env:muzi_base_uno_repeater.build_flags} + ${nrf52_qspi_ota.build_flags} + -D ENABLE_OTA=1 + -D OTA_FLASH_STORE=1 + -D OTA_FOLDER_SERIAL + -D OTA_QSPI_HARDWARE_QUALIFICATION_PENDING=1 +build_src_filter = ${env:muzi_base_uno_repeater.build_src_filter} + + + +[env:muzi_base_uno_room_server_lora_ota] +extends = env:muzi_base_uno_room_server +extra_scripts = ${nrf52_lora_ota.extra_scripts} +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 815104 +build_unflags = + ${nrf52_base.build_unflags} + -D QSPIFLASH=1 +build_flags = + ${env:muzi_base_uno_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} + -D ENABLE_OTA=1 + -D OTA_FLASH_STORE=1 + -D OTA_FOLDER_SERIAL + -D OTA_QSPI_HARDWARE_QUALIFICATION_PENDING=1 +build_src_filter = ${env:muzi_base_uno_room_server.build_src_filter} + + + [env:muzi_base_uno_companion_radio_usb] extends = muzi_base_uno board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld diff --git a/variants/nano_g2_ultra/platformio.ini b/variants/nano_g2_ultra/platformio.ini index 159aa03bf..d5186c5df 100644 --- a/variants/nano_g2_ultra/platformio.ini +++ b/variants/nano_g2_ultra/platformio.ini @@ -62,6 +62,35 @@ build_src_filter = ${Nano_G2_Ultra.build_src_filter} + +<../examples/simple_room_server> +; Qualification-only until the Nano-specific OTAFIX port is physically +; verified and published. Never substitute a generic application USB identity. +[env:Nano_G2_Ultra_repeater_lora_ota] +extends = env:Nano_G2_Ultra_repeater +extra_scripts = ${nrf52_lora_ota.extra_scripts} +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 815104 +build_flags = + ${env:Nano_G2_Ultra_repeater.build_flags} + ${nrf52_qspi_ota.build_flags} + -D OTA_QSPI_HARDWARE_QUALIFICATION_PENDING=1 +build_src_filter = ${env:Nano_G2_Ultra_repeater.build_src_filter} + + + +[env:Nano_G2_Ultra_room_server_lora_ota] +extends = env:Nano_G2_Ultra_room_server +extra_scripts = ${nrf52_lora_ota.extra_scripts} +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 815104 +build_flags = + ${env:Nano_G2_Ultra_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} + -D ENABLE_OTA=1 + -D OTA_FLASH_STORE=1 + -D OTA_FOLDER_SERIAL + -D OTA_QSPI_HARDWARE_QUALIFICATION_PENDING=1 +build_src_filter = ${env:Nano_G2_Ultra_room_server.build_src_filter} + + + [env:Nano_G2_Ultra_companion_radio_ble] extends = Nano_G2_Ultra board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld diff --git a/variants/sensecap_solar/platformio.ini b/variants/sensecap_solar/platformio.ini index bae88a157..852114ee0 100644 --- a/variants/sensecap_solar/platformio.ini +++ b/variants/sensecap_solar/platformio.ini @@ -72,6 +72,22 @@ build_flags = build_src_filter = ${SenseCap_Solar.build_src_filter} +<../examples/simple_room_server/*.cpp> +; Preserve the non-OTA Room target above for compatibility/recovery. +; QSPI staging owns the same onboard NOR as the existing Repeater target. +[env:SenseCap_Solar_room_server_lora_ota] +extends = env:SenseCap_Solar_room_server +board_build.ldscript = boards/nrf52840_s140_v7.ld +board_upload.maximum_size = 811008 +extra_scripts = ${nrf52_lora_ota.extra_scripts} +build_flags = + ${env:SenseCap_Solar_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} + -D ENABLE_OTA=1 + -D OTA_FLASH_STORE=1 + -D OTA_FOLDER_SERIAL +build_src_filter = ${env:SenseCap_Solar_room_server.build_src_filter} + + + [env:SenseCap_Solar_companion_radio_ble] extends = SenseCap_Solar board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld diff --git a/variants/thinknode_m1/platformio.ini b/variants/thinknode_m1/platformio.ini index be3006aa7..c5c2bbc80 100644 --- a/variants/thinknode_m1/platformio.ini +++ b/variants/thinknode_m1/platformio.ini @@ -68,6 +68,19 @@ build_src_filter = ${ThinkNode_M1.build_src_filter} lib_deps = ${ThinkNode_M1.lib_deps} +; Preserve the internal-staging Room target above for compatibility/recovery. +; Normal OTA uses the same dedicated QSPI store and app layout as Repeater. +[env:ThinkNode_M1_room_server_lora_ota] +extends = env:ThinkNode_M1_room_server +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 815104 +extra_scripts = ${nrf52_lora_ota.extra_scripts} +build_flags = + ${env:ThinkNode_M1_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} +build_src_filter = ${env:ThinkNode_M1_room_server.build_src_filter} + + + [env:ThinkNode_M1_companion_radio_ble] extends = ThinkNode_M1 board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld diff --git a/variants/thinknode_m6/platformio.ini b/variants/thinknode_m6/platformio.ini index bd54226a9..57e6e017b 100644 --- a/variants/thinknode_m6/platformio.ini +++ b/variants/thinknode_m6/platformio.ini @@ -72,6 +72,19 @@ build_src_filter = ${ThinkNode_M6.build_src_filter} lib_deps = ${ThinkNode_M6.lib_deps} +; Preserve the internal-staging Room target above for compatibility/recovery. +; Normal OTA uses the same dedicated QSPI store and app layout as Repeater. +[env:ThinkNode_M6_room_server_lora_ota] +extends = env:ThinkNode_M6_room_server +board_build.ldscript = boards/nrf52840_s140_v6.ld +board_upload.maximum_size = 815104 +extra_scripts = ${nrf52_lora_ota.extra_scripts} +build_flags = + ${env:ThinkNode_M6_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} +build_src_filter = ${env:ThinkNode_M6_room_server.build_src_filter} + + + [env:ThinkNode_M6_companion_radio_ble] extends = ThinkNode_M6 board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld diff --git a/variants/wio-tracker-l1/platformio.ini b/variants/wio-tracker-l1/platformio.ini index ff2b69540..f67b22834 100644 --- a/variants/wio-tracker-l1/platformio.ini +++ b/variants/wio-tracker-l1/platformio.ini @@ -83,6 +83,19 @@ build_flags = ${WioTrackerL1.build_flags} lib_deps = ${WioTrackerL1.lib_deps} adafruit/RTClib @ ^2.1.3 +; Preserve the internal-staging Room target above for compatibility/recovery. +; Normal OTA uses the same dedicated QSPI store and app layout as Repeater. +[env:WioTrackerL1_room_server_lora_ota] +extends = env:WioTrackerL1_room_server +board_build.ldscript = boards/nrf52840_s140_v7.ld +board_upload.maximum_size = 811008 +extra_scripts = ${nrf52_lora_ota.extra_scripts} +build_flags = + ${env:WioTrackerL1_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} +build_src_filter = ${env:WioTrackerL1_room_server.build_src_filter} + + + [env:WioTrackerL1_companion_radio_usb] extends = WioTrackerL1 board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld diff --git a/variants/xiao_nrf52/platformio.ini b/variants/xiao_nrf52/platformio.ini index 133af44c2..553326708 100644 --- a/variants/xiao_nrf52/platformio.ini +++ b/variants/xiao_nrf52/platformio.ini @@ -102,8 +102,15 @@ build_flags = -D ADMIN_PASSWORD='"password"' -D MAX_NEIGHBOURS=50 -D MESH_PACKET_LOGGING=1 + -D WITH_RS232_BRIDGE=Serial1 + -D WITH_RS232_BRIDGE_UART=1 + -D WITH_RS232_BRIDGE_RX=PIN_SERIAL1_RX + -D WITH_RS232_BRIDGE_TX=PIN_SERIAL1_TX + -D BRIDGE_MAX_BAUD=500000 + -D RS232_BRIDGE_MERGED=1 ; -D MESH_DEBUG=1 build_src_filter = ${Xiao_nrf52.build_src_filter} + + +<../examples/simple_repeater/*.cpp> [env:Xiao_nrf52_room_server] @@ -119,6 +126,18 @@ build_flags = build_src_filter = ${Xiao_nrf52.build_src_filter} +<../examples/simple_room_server/*.cpp> +; Raw-QSPI Room OTA uses the matching repeater's bootloader scratch layout. +; Keep the original Room target available under its deployed internal OTA ID. +[env:Xiao_nrf52_room_server_lora_ota] +extends = env:Xiao_nrf52_room_server +board_build.ldscript = boards/nrf52840_s140_v7_xiao_bootloader_ota.ld +board_upload.maximum_size = 757760 +build_flags = + ${env:Xiao_nrf52_room_server.build_flags} + ${nrf52_qspi_ota.build_flags} + -D OTA_QSPI_BOOTLOADER_UPDATE=1 +build_src_filter = ${env:Xiao_nrf52_room_server.build_src_filter} + [env:Xiao_nrf52_repeater_bridge_rs232] extends = Xiao_nrf52 build_flags =