Retire internal-only SolarXiao Room release variants

This commit is contained in:
mikecarper committed 2026-10-16 23:24:36 -07:00
1 parent a16229a3eb
commit d5d84d325b
11 files changed
+373 -11

No files matched your search

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