Retire internal-only SolarXiao Room release variants
This commit is contained in:
1 parent
a16229a3eb
commit
d5d84d325b
11 files changed
+373
-11
No files matched your search
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user