Consolidate onboard QSPI infrastructure build profiles

This commit is contained in:
mikecarper committed 2026-10-16 23:42:10 -07:00
1 parent d5d84d325b
commit ce53567a4c
27 files changed
+1351 -42

No files matched your search

+1
View File
@@ -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
+66 -7
View File
@@ -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
+61 -5
View File
@@ -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)),
};
}
+26 -3
View File
@@ -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
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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 = {
+15
View File
@@ -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
+54 -12
View File
@@ -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
+83
View File
@@ -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" \
+72 -10
View File
@@ -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)
+163
View File
@@ -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']],
+117 -1
View File
@@ -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
+333
View File
@@ -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> +<helpers/*.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"] = "+<helpers/ota> -<helpers/ota/OtaStoreQspiNrf52.cpp>"
self.assertFalse(source_selected(options, "helpers/ota/OtaStoreQspiNrf52.cpp"))
options["build_src_filter"] += " +<helpers/ota/OtaStoreQspiNrf52.cpp>"
self.assertTrue(source_selected(options, "helpers/ota/OtaStoreQspiNrf52.cpp"))
if __name__ == "__main__":
unittest.main()
+36
View File
@@ -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'
@@ -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}
+33
View File
@@ -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}
+33
View File
@@ -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}
+13
View File
@@ -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}
+<helpers/ota/*.cpp>
[env:LilyGo_T-Echo_companion_radio_ble]
extends = LilyGo_T-Echo
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
+26
View File
@@ -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}
+<helpers/ota/*.cpp>
[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}
+<helpers/ota/*.cpp>
[env:LilyGo_T-Echo_Card_companion_radio_ble]
extends = LilyGo_T-Echo_Card
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
+26
View File
@@ -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}
+<helpers/ota/*.cpp>
[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}
+<helpers/ota/*.cpp>
[env:LilyGo_T-Echo-Lite_companion_radio_ble]
extends = LilyGo_T-Echo-Lite
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
+75
View File
@@ -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}
+<helpers/ota/*.cpp>
[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}
+<helpers/ota/*.cpp>
[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}
+<helpers/ota/*.cpp>
[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}
+<helpers/ota/*.cpp>
[env:muzi_base_uno_companion_radio_usb]
extends = muzi_base_uno
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
+29
View File
@@ -62,6 +62,35 @@ build_src_filter = ${Nano_G2_Ultra.build_src_filter}
+<helpers/ui/SH1106Display.cpp>
+<../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}
+<helpers/ota/*.cpp>
[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}
+<helpers/ota/*.cpp>
[env:Nano_G2_Ultra_companion_radio_ble]
extends = Nano_G2_Ultra
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
+16
View File
@@ -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}
+<helpers/ota/*.cpp>
[env:SenseCap_Solar_companion_radio_ble]
extends = SenseCap_Solar
board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld
+13
View File
@@ -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}
+<helpers/ota/*.cpp>
[env:ThinkNode_M1_companion_radio_ble]
extends = ThinkNode_M1
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
+13
View File
@@ -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}
+<helpers/ota/*.cpp>
[env:ThinkNode_M6_companion_radio_ble]
extends = ThinkNode_M6
board_build.ldscript = boards/nrf52840_s140_v6_extrafs.ld
+13
View File
@@ -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}
+<helpers/ota/*.cpp>
[env:WioTrackerL1_companion_radio_usb]
extends = WioTrackerL1
board_build.ldscript = boards/nrf52840_s140_v7_extrafs.ld
+19
View File
@@ -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}
+<helpers/bridges/RS232Bridge.cpp>
+<../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 =