diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 9a48efc66..f0b5bd0e0 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -533,13 +533,10 @@ const lowerTarget = target.toLowerCase(); return HIDDEN_PROFILE_HARDWARE.includes(profile.sourceHardware) || HIDDEN_PROFILE_TARGETS.includes(target) || - // These room installations use their onboard external-QSPI recipe. + // SolarXiao 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. - (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)?)?$/ + /^solarxiao_(?:30|33)s_room_server(?:_lora_ota_no_external_sensors)?(?:-(?:full|reduced)(?:-ota)?)?$/ .test(lowerTarget) || (lowerTarget.includes("companion_radio_") && /(?:^|[_-])(?:ps|femoff)(?=$|[_-])/.test(lowerTarget)); @@ -725,26 +722,40 @@ } function omitReplacedQspiProfiles(profiles) { - const canonicalPairs = new Map(); + const qspiPairs = 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); + !target.endsWith("-" + profile.sensorProfile)) return; + const base = target.slice(0, -profile.sensorProfile.length - 1); + if (!qspiPairs.has(base)) qspiPairs.set(base, new Map()); + qspiPairs.get(base).set(profile.sensorProfile, profile); }); + function qualifiedPair(base) { + const pair = qspiPairs.get(base); + if (!pair || !pair.has("full") || !pair.has("reduced")) return null; + return pair.get("full").controls.sensorProfileSource.toLowerCase() === + pair.get("reduced").controls.sensorProfileSource.toLowerCase() ? pair : null; + } 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(); + // Keep supported roles in older releases until their exact QSPI pair + // is downloadable. A source-only recipe does not replace a download. + if (QSPI_ROOM_HARDWARE.includes(hardware) && + /^_room_server(?:_lora_ota_no_external_sensors)?(?:-(?:full|reduced)(?:-ota)?)?$/ + .test(target.slice(hardware.length)) && + qualifiedPair(hardware + "_room_server_lora_ota")) return false; + if (hardware === "lilygo_t-echo-lite" && + /^_repeater(?:_lora_ota_no_external_sensors)?(?:-(?:full|reduced)(?:-ota)?)?$/ + .test(target.slice(hardware.length)) && + qualifiedPair(hardware + "_repeater_lora_ota")) return false; + const pair = qualifiedPair(hardware + "_repeater"); + if (!pair) return true; if (QSPI_REPEATER_ALIAS_HARDWARE.includes(hardware) && /^_repeater_lora_ota_no_external_sensors(?:-(?:full|reduced)(?:-ota)?)?$/ .test(target.slice(hardware.length))) return false; diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index fc1794bb4..884183e72 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -490,9 +490,13 @@ 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 -available through exact filename searches. The legacy and QSPI images have +compatibility/recovery builds, but are excluded from ordinary build menus +and future releases. For newly converted boards, the picker keeps an existing +release's internal Room choice until that same release provides its qualified, +source-matched QSPI Full/Reduced pair. This preserves working Room links during +rollout; SolarXiao's already-retired internal choices stay hidden. 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. diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index c536924d8..e90616b34 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -1235,8 +1235,18 @@ const newQspiRoomHardware = ['Xiao_nrf52', 'ikoka_handheld_nrf_e22_30dbm', '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'); + const oldRoomLink = picker.selectionFromUrl( + 'https://example.com/p/?s=1.h' + hardware + '.rroom.olora-receiver.ffull.vdefault', + currentCatalog.profiles); + assert.deepStrictEqual(oldRoomLink.unavailable, [], + 'Saved Room links must keep their role until a qualified QSPI replacement is published'); + assert.strictEqual(oldRoomLink.filters.role, 'room'); + assert.strictEqual(oldRoomLink.filters.ota, 'lora-receiver'); + const oldRoomMatches = currentCatalog.profiles.filter(profile => + picker.profileMatchesFacets(profile, oldRoomLink.filters)); + assert.strictEqual(oldRoomMatches.length, 1); + assert.strictEqual(oldRoomMatches[0].target, + hardware + '_room_server_lora_ota_no_external_sensors-full'); 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), @@ -1278,21 +1288,85 @@ 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'); +for (const hardware of newQspiRoomHardware) { + assert.strictEqual(retiredRoomOnly.profiles.filter(profile => profile.hardware === hardware).length, 4, + 'Keep current internal Room identities when the release has no replacement downloads'); +} +assert(!retiredRoomOnly.profiles.some(profile => profile.hardware.startsWith('solarxiao_')), + 'The previously established SolarXiao retirement policy remains unconditional'); +for (const hardware of newQspiRoomHardware) { + const base = hardware + '_room_server_lora_ota'; + for (const fault of ['missing_full', 'missing_reduced', 'stale_source', 'mixed_source', + 'internal_storage', 'missing_controls']) { + const changedControls = JSON.parse(JSON.stringify(qspiRoomControls)); + let files = qspiRoomAssets.filter(file => file.name.startsWith(hardware + '_room_server')); + if (fault === 'missing_full' || fault === 'missing_reduced') { + files = files.filter(file => !file.name.startsWith(base + '-' + fault.slice(8) + '-')); + } 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(qspiFixtureFamily, '2026-10-16T00:00:00Z', files), + ], fault === 'missing_controls' ? undefined : changedControls); + for (const policy of ['full', 'reduced']) { + assert(partial.profiles.some(profile => + profile.target === hardware + '_room_server_lora_ota_no_external_sensors-' + policy), + hardware + ': keep the existing Room download during incomplete QSPI rollout (' + fault + ')'); + } + } +} 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')); +for (const target of liteRepeaterTargets) { + const policy = target.endsWith('-full') ? 'full' : 'reduced'; + qspiRoomControls.profiles[target] = { + platform: 'NRF52_PLATFORM', otaRole: 'lora-receiver', updateMethods: ['lora'], + sensorProfile: policy, sensorProfileSource: qspiFixtureSource, + updateRequirements: {lora: {storage: /_lora_ota-(?:full|reduced)$/.test(target) + ? 'external_qspi' : 'internal_flash'}}, + }; +} const liteRepeaterCatalog = picker.buildCatalog([ release(qspiFixtureFamily, '2026-10-16T00:00:00Z', liteRepeaterAssets), -]); +], qspiRoomControls); 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'); +const oldLiteRepeaterLink = picker.selectionFromUrl( + 'https://example.com/p/?s=1.hLilyGo_T-Echo-Lite.rrepeater.olora-receiver.ffull.vdefault', + currentCatalog.profiles); +assert.deepStrictEqual(oldLiteRepeaterLink.unavailable, []); +assert.strictEqual(oldLiteRepeaterLink.filters.role, 'repeater'); +assert.strictEqual(oldLiteRepeaterLink.filters.ota, 'lora-receiver'); +assert.deepStrictEqual(currentCatalog.profiles.filter(profile => + picker.profileMatchesFacets(profile, oldLiteRepeaterLink.filters)).map(profile => profile.target), + ['LilyGo_T-Echo-Lite_repeater_lora_ota_no_external_sensors-full']); +for (const fault of ['missing_full', 'missing_reduced', 'stale_source', 'missing_controls']) { + const changedControls = JSON.parse(JSON.stringify(qspiRoomControls)); + let files = liteRepeaterAssets; + const base = 'LilyGo_T-Echo-Lite_repeater_lora_ota'; + if (fault === 'missing_full' || fault === 'missing_reduced') { + files = files.filter(file => !file.name.startsWith(base + '-' + fault.slice(8) + '-')); + } else if (fault === 'stale_source') { + changedControls.profiles[base + '-full'].sensorProfileSource = 'f'.repeat(40); + } + const partial = picker.buildCatalog([ + release(qspiFixtureFamily, '2026-10-16T00:00:00Z', files), + ], fault === 'missing_controls' ? undefined : changedControls); + for (const policy of ['full', 'reduced']) { + assert(partial.profiles.some(profile => + profile.target === 'LilyGo_T-Echo-Lite_repeater_lora_ota_no_external_sensors-' + policy), + 'Keep existing Lite repeater functionality during incomplete QSPI rollout: ' + fault); + } +} 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_'));