From e0028d9630b17c1e857e5d88fe1bcb2f26f59856 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Sat, 24 Oct 2026 15:23:09 -0700 Subject: [PATCH] Fix RAK firmware picker bootloader variants --- docs/_javascript/firmware_picker.js | 129 +++++++++++++++--- mkdocs.yml | 2 +- test/test_firmware_picker.js | 199 +++++++++++++++++++++++++++- 3 files changed, 307 insertions(+), 23 deletions(-) diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 74d4dd655..f0de5c0df 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -494,8 +494,24 @@ .replace(/^-+|-+$/g, "") || "default"; } + function targetPublicationParts(target) { + let base = String(target || ""); + const sensorSuffix = (base.match(/-(?:full|reduced)(?:-ota)?$/i) || [""])[0]; + if (sensorSuffix) base = base.slice(0, -sensorSuffix.length); + const bootloaderSuffix = (base.match(/-with-bootloader-\d+(?:\.\d+){2}$/i) || [""])[0]; + if (bootloaderSuffix) base = base.slice(0, -bootloaderSuffix.length); + return { base: base, sensorSuffix: sensorSuffix, bootloaderSuffix: bootloaderSuffix }; + } + + function targetWithoutBootloader(target) { + const publication = targetPublicationParts(target); + return publication.base + publication.sensorSuffix; + } + function parseTargetProfile(target) { - const parts = roleParts(target); + const publication = targetPublicationParts(target); + const parentTarget = publication.base + publication.sensorSuffix; + const parts = roleParts(parentTarget); const sourceHardware = parts.hardware || target; const lowerTarget = target.toLowerCase(); const lowerTail = parts.tail.toLowerCase(); @@ -530,10 +546,10 @@ ? "lora-source" : ""; let variant = variantForProfile(parts.role, parts.tail); - if (/^RAK_(?:3401|4631)_repeater_unified_lora_ota(?:-(?:full|reduced)(?:-ota)?)?$/i.test(target)) { + if (/^RAK_(?:3401|4631)_repeater_unified_lora_ota(?:-(?:full|reduced)(?:-ota)?)?$/i.test(parentTarget)) { variant = "default"; } - if (/^RAK_4631_repeater_ethernet(?:-(?:full|reduced)(?:-ota)?)?$/i.test(target)) { + if (/^RAK_4631_repeater_ethernet(?:-(?:full|reduced)(?:-ota)?)?$/i.test(parentTarget)) { // The RAK13800 Ethernet image is a real hardware choice, separate from // adaptive storage. Label it consistently for both sensor policies. variant = "ethernet"; @@ -543,6 +559,10 @@ ? "rx-boosted" : variant + "-rx-boosted"; } + if (publication.bootloaderSuffix) { + variant = (variant === "default" ? "" : variant + "-") + + publication.bootloaderSuffix.slice(1).replace(/\./g, "-").toLowerCase(); + } return { target: target, @@ -608,7 +628,44 @@ return hardware + "\n" + String(profile && profile.variant || "default"); } - function omitTransportsReplacedByFull(profiles) { + function matchingPublicationReplacement(profile, candidate) { + if (!candidate) return false; + const original = targetPublicationParts(profile.target); + const successor = targetPublicationParts(candidate.target); + if (original.sensorSuffix.toLowerCase() !== successor.sensorSuffix.toLowerCase() || + original.bootloaderSuffix.toLowerCase() !== successor.bootloaderSuffix.toLowerCase()) return false; + if (!original.bootloaderSuffix) return true; + + // An ordinary image or a different bootloader cannot retire an embedded + // download. Wait until both exact assets have release-bound controls and + // prove the same bootloader image, hardware and sensor policy. + const embedded = profile.embeddedBootloader; + const replacement = candidate.embeddedBootloader; + const controls = profile.controls; + const replacementControls = candidate.controls; + return Boolean(embedded && replacement && controls && replacementControls && + profile.hardware === candidate.hardware && profile.role === candidate.role && + profile.releaseFamily === candidate.releaseFamily && + profile.sensorProfile === candidate.sensorProfile && + /^[0-9a-f]{40}$/i.test(controls.sensorProfileSource || "") && + controls.sensorProfileSource === replacementControls.sensorProfileSource && + controls.embeddedBootloaderSource === controls.sensorProfileSource && + replacementControls.embeddedBootloaderSource === replacementControls.sensorProfileSource && + ["release", "profile", "candidate_sha256", "asset_sha256", "manifest_sha256"].every(function (key) { + return typeof embedded[key] === "string" && embedded[key].length > 0 && + (!key.endsWith("_sha256") || /^[0-9a-f]{64}$/i.test(embedded[key])) && + embedded[key] === replacement[key]; + })); + } + + function adaptiveRakStorageProfile(profile) { + const controls = profile && profile.controls; + const lora = controls && controls.updateRequirements && controls.updateRequirements.lora; + return Boolean(controls && controls.platform === "NRF52_PLATFORM" && lora && + lora.storage === "adaptive_internal_or_external_qspi"); + } + + function omitTransportsReplacedByFull(profiles, embeddedOnly) { const fullKeys = new Set((profiles || []).filter(function (profile) { return isFullCompanion(profile); }).map(replacementKey)); @@ -624,8 +681,8 @@ profile.mode === "usb-ble"); } ).map(replacementKey)); - const targets = new Set((profiles || []).map(function (profile) { - return String(profile.target || "").toLowerCase(); + const targets = new Map((profiles || []).map(function (profile) { + return [String(profile.target || "").toLowerCase(), profile]; })); const mergedRs232Targets = { heltec_t096_repeater_bridge_rs232: "heltec_t096_repeater", @@ -656,6 +713,9 @@ }; return (profiles || []).filter(function (profile) { + const target = String(profile.target || "").toLowerCase(); + const publication = targetPublicationParts(target); + if (embeddedOnly && !publication.bootloaderSuffix) return true; const key = replacementKey(profile); const replacedAttachedTransport = profile.role === "companion" && (profile.mode === "usb" || profile.mode === "ble" || @@ -666,10 +726,8 @@ combinedUsbBleKeys.has(key); const replacedTerminal = profile.role === "terminal" && terminalReplacementKeys.has(key); - const target = String(profile.target || "").toLowerCase(); - const sensorSuffix = (target.match(/-(?:full|reduced)$/) || [""])[0]; - const baseTarget = sensorSuffix ? target.slice(0, -sensorSuffix.length) : target; - const rs232Replacement = mergedRs232Targets[baseTarget]; + const suffix = publication.bootloaderSuffix + publication.sensorSuffix; + const rs232Replacement = mergedRs232Targets[publication.base]; // Recommendations prefer the combined UART2 image on RAK4631. Exact // Serial1 files remain in raw rows for existing GPS-free installations; // this preference does not migrate their wiring or OTA identity. @@ -677,7 +735,12 @@ // missing Reduced image, or an older unsuffixed compatibility image. const replacedRs232 = Boolean(rs232Replacement && [].concat(rs232Replacement).some(function (replacement) { - return targets.has(replacement + sensorSuffix); + const candidate = targets.get(replacement + suffix); + if (!matchingPublicationReplacement(profile, candidate)) return false; + return !publication.bootloaderSuffix || + /^rak_4631_repeater_/.test(target) && candidate.controls.rs232Merged === true && + candidate.controls.rs232Source === candidate.controls.sensorProfileSource && + releaseBoundControl(candidate, "rs232") && adaptiveRakStorageProfile(candidate); })); return !(replacedAttachedTransport && fullKeys.has(key)) && !replacedByUsbBle && !replacedTerminal && !replacedRs232; @@ -877,14 +940,21 @@ }); } - function omitRakStorageProfilesWhenUnified(profiles) { - const targets = new Set((profiles || []).map(function (profile) { - return String(profile && profile.target || "").toLowerCase(); + function omitRakStorageProfilesWhenUnified(profiles, embeddedOnly) { + const targets = new Map((profiles || []).map(function (profile) { + return [String(profile && profile.target || "").toLowerCase(), profile]; })); return (profiles || []).filter(function (profile) { const target = String(profile && profile.target || "").toLowerCase(); - const match = /^rak_(3401|4631)_repeater_(?:lora_ota_no_external_sensors|w25q16_lora_ota|rak15001_slot_c_lora_ota|rak13302_w25q16_lora_ota)(-(?:full|reduced))?$/.exec(target); - return !match || !targets.has("rak_" + match[1] + "_repeater_unified_lora_ota" + (match[2] || "")); + const publication = targetPublicationParts(target); + if (embeddedOnly && !publication.bootloaderSuffix) return true; + const match = /^rak_(3401|4631)_repeater_(?:lora_ota_no_external_sensors|w25q16_lora_ota|rak15001_slot_c_lora_ota|rak13302_w25q16_lora_ota)$/.exec(publication.base); + if (!match) return true; + const candidate = targets.get("rak_" + match[1] + "_repeater_unified_lora_ota" + + publication.bootloaderSuffix + publication.sensorSuffix); + if (!matchingPublicationReplacement(profile, candidate)) return true; + return Boolean(publication.bootloaderSuffix && + !adaptiveRakStorageProfile(candidate)); }); } @@ -1280,6 +1350,11 @@ } }); + // Embedded compatibility images can be consolidated only after their + // metadata proves the exact replacement; raw rows retain every download. + const qualifiedProfiles = omitRakStorageProfilesWhenUnified( + omitTransportsReplacedByFull(profiles, true), true + ); return { releaseSet: releaseSet, rows: firmwareRows.filter(function (file) { @@ -1289,7 +1364,7 @@ }), // Recommendation filters retain original files and target identities in // the raw catalog, including when a qualified successor replaces them. - profiles: omitConsolidatedProfiles(omitT114DisplayDuplicates(omitReplacedQspiProfiles(omitStationG3SlotDuplicates(profiles)))), + profiles: omitConsolidatedProfiles(omitT114DisplayDuplicates(omitReplacedQspiProfiles(omitStationG3SlotDuplicates(qualifiedProfiles)))), }; } @@ -1652,6 +1727,19 @@ if (requested.hardware === "PicoW" && requested.variant === "radio" && profiles.some(combinedPicoCompanion)) { requested.variant = "default"; } + // Preserve links to the same embedded image after correcting its label. + // A former generic Ethernet link is unambiguous only with Ethernet mode. + if (/^RAK_(?:3401|4631)$/.test(requested.hardware || "") && requested.role === "repeater") { + const variant = /^unified-with-bootloader-\d+-\d+-\d+$/.test(requested.variant || "") + ? requested.variant.replace(/^unified-/, "") + : requested.hardware === "RAK_4631" && requested.mode === "ethernet" && + /^with-bootloader-\d+-\d+-\d+$/.test(requested.variant || "") + ? "ethernet-" + requested.variant : ""; + if (variant && profiles.some(function (profile) { + return profile.embeddedBootloader && + profileMatchesFacets(profile, Object.assign({}, requested, { variant: variant })); + })) requested.variant = variant; + } if (!profiles.some(function (profile) { return String(profile.hardware).toLowerCase() === "heltec_t114_without_display" && (!requested.role || profile.role === requested.role); @@ -1898,7 +1986,7 @@ // Match the exact result, not its broad hardware-family filter. A TFT, // legacy UART or different role must not inherit another image's claims. if (identity.hardware !== profile.hardware || identity.role !== profile.role) return []; - const target = profile.target; + const target = targetWithoutBootloader(profile.target); const info = profile.controls || {}; const notes = []; if (combinedPicoCompanion(profile)) { @@ -2218,9 +2306,10 @@ if (profile.role === "repeater") toggle("Repeat mesh traffic", "set repeat", "get repeat"); if (info.rs232 && infrastructure) { const mode = chosen.mode; - const uartNote = /^RAK_4631_repeater_unified_lora_ota-(?:full|reduced)$/i.test(profile.target) && profile.sensorProfile + const parentTarget = targetWithoutBootloader(profile.target); + const uartNote = /^RAK_4631_repeater_unified_lora_ota-(?:full|reduced)$/i.test(parentTarget) && profile.sensorProfile ? "Unified RAK4631 uses UART2 for the RS-232 bridge; select it with set bridge.uart 2 while the bridge is stopped. GPS safety guards reserve UART1 even when no GPS was detected. Use UART2 for new installations. Legacy Serial1 downloads remain available by exact filename for existing GPS-free wiring." - : /^RAK_4631_repeater_bridge_rs232_serial1(?:_lora_ota_no_external_sensors)?-(?:full|reduced)$/i.test(profile.target) + : /^RAK_4631_repeater_bridge_rs232_serial1(?:_lora_ota_no_external_sensors)?-(?:full|reduced)$/i.test(parentTarget) ? "This GPS-free Serial1 image uses UART1. Do not fit a powered UART GPS or header-connected W25 flash on the same UART1 pins. Use the unified UART2 image when those pins are occupied." : "Use the UART and pin map for this exact board. Canonical GPS-enabled RAK4631 uses UART2; select it with set bridge.uart 2 while the bridge is stopped. UART1 needs a compatible GPS-free image."; // MQTT images keep the historical bridge.enabled ESP-NOW alias. diff --git a/mkdocs.yml b/mkdocs.yml index e3a02a7c5..808221690 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -135,7 +135,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=20261023-reduced-sensors-default + - _javascript/firmware_picker.js?v=20261024-embedded-rak-profiles - _javascript/telemetry_decoder.js?v=20261022-noise-history - _javascript/management_decoder.js - _javascript/filter_tool.js?v=20261002-hashbytes-2plus diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index 3dde461d4..ae98734c0 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -1183,11 +1183,41 @@ const publishedReleases = [release(publishedControls.familyTag, '2026-10-24T00:0 const publishedCatalog = picker.buildCatalog(publishedReleases, publishedControls); assert.strictEqual(publishedCatalog.rows.length, publishedAssets.length); assert(publishedCatalog.profiles.every(profile => profile.controls && profile.chipFamily !== 'unknown')); +for (const file of publishedAssets) { + assert(publishedCatalog.rows.some(row => row.name === file.name && row.url === file.browser_download_url), + 'Recommendation consolidation must preserve every exact filename and download URL: ' + file.name); +} +const embeddedRakCompatibilitySuccessors = new Map([ + ['RAK_3401_repeater_lora_ota_no_external_sensors', 'RAK_3401_repeater_unified_lora_ota'], + ['RAK_3401_repeater_rak13302_w25q16_lora_ota', 'RAK_3401_repeater_unified_lora_ota'], + ['RAK_4631_repeater_lora_ota_no_external_sensors', 'RAK_4631_repeater_unified_lora_ota'], + ['RAK_4631_repeater_w25q16_lora_ota', 'RAK_4631_repeater_unified_lora_ota'], + ['RAK_4631_repeater_rak15001_slot_c_lora_ota', 'RAK_4631_repeater_unified_lora_ota'], + ['RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors', 'RAK_4631_repeater_unified_lora_ota'], + ['RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors', 'RAK_4631_repeater_unified_lora_ota'], +]); const embeddedControls = Object.entries(publishedControls.profiles).filter(([, info]) => info.embeddedBootloader); assert.strictEqual(embeddedControls.length, 154); +let consolidatedEmbeddedProfiles = 0; for (const [target, info] of embeddedControls) { - const profile = publishedCatalog.profiles.find(item => item.target === target); - assert(profile, target + ' must remain selectable'); + let profile = publishedCatalog.profiles.find(item => item.target === target); + const base = target.replace(/-with-bootloader-\d+(?:\.\d+){2}-(?:full|reduced)$/, ''); + const successorBase = embeddedRakCompatibilitySuccessors.get(base); + if (successorBase) { + assert.strictEqual(profile, undefined, target + ' must use its exact combined recommendation'); + const successor = successorBase + '-with-bootloader-' + info.embeddedBootloader.release + '-' + info.sensorProfile; + assert(publishedCatalog.profiles.some(item => item.target === successor), + target + ' can be consolidated only with the same bootloader release and sensor policy'); + const file = publishedAssets.find(item => item.name.startsWith(target + '-')); + assert(file && publishedCatalog.rows.some(row => row.target === target && + row.name === file.name && row.url === file.browser_download_url), + target + ' must retain its exact compatibility download'); + profile = picker.buildCatalog([ + release(publishedControls.familyTag, '2026-10-24T00:00:00Z', [file]), + ], publishedControls).profiles.find(item => item.target === target); + consolidatedEmbeddedProfiles += 1; + } + assert(profile, target + ' must remain selectable when its exact successor is unavailable'); assert.deepStrictEqual(profile.embeddedBootloader, info.embeddedBootloader); const ordinaryTarget = target.replace('-with-bootloader-' + info.embeddedBootloader.release, ''); const ordinaryAsset = publishedAssets.find(file => file.name.startsWith(ordinaryTarget + '-')); @@ -1205,6 +1235,171 @@ for (const [target, info] of embeddedControls) { assert(instructions.includes('Recovery and unrecognized bootloaders')); assert(instructions.includes('get bootloader.ver')); } +assert.strictEqual(consolidatedEmbeddedProfiles, 14); + +const rak118Context = {hardware: 'RAK_4631', role: 'repeater', ota: 'lora-receiver'}; +const rak118Bases = ['RAK_4631_repeater_unified_lora_ota', 'RAK_4631_repeater_ethernet']; +const rak118Targets = rak118Bases.flatMap(base => ['', '-with-bootloader-2.4.12'].flatMap(marker => + ['full', 'reduced'].map(policy => base + marker + '-' + policy))); +const rak118Profiles = publishedCatalog.profiles.filter(profile => picker.profileMatchesFacets(profile, rak118Context)); +assert.deepStrictEqual(rak118Profiles.map(profile => profile.target).sort(), rak118Targets.slice().sort(), + 'RAK4631 must recommend exactly adaptive/Ethernet x Full/Reduced x ordinary/embedded'); +const rak118Choices = picker.firmwareProfileChoices(publishedCatalog.profiles, + {...rak118Context, feature: 'full', variant: 'default'}); +assert.strictEqual(rak118Choices.length, 8, 'Each real RAK4631 firmware combination needs its own choice'); +assert.strictEqual(new Set(rak118Choices.map(choice => choice.label)).size, 8, + 'Ordinary, Ethernet and embedded bootloader choices must have distinct labels'); +assert.strictEqual(rak118Choices.filter(choice => choice.label.includes('bootloader included')).length, 4); +assert.strictEqual(rak118Choices.filter(choice => choice.label.includes('Ethernet')).length, 4); +assert.strictEqual(new Set(rak118Profiles.map(picker.firmwareProfileValue)).size, 8, + 'Embedded Ethernet must never collide with the adaptive bootloader-included choice'); +for (const profile of rak118Profiles) { + const label = picker.firmwareProfileLabel(profile); + assert.strictEqual(label.includes('Ethernet'), profile.target.startsWith('RAK_4631_repeater_ethernet-')); + assert.strictEqual(label.includes('bootloader included'), Boolean(profile.embeddedBootloader)); + if (profile.target.startsWith('RAK_4631_repeater_unified_lora_ota-')) { + assert.deepStrictEqual(picker.profileFieldValues(profile, 'mode'), ['standard', 'rs232'], + 'Ordinary and embedded adaptive images must retain runtime UART2 bridge selection'); + assert(!label.includes('Unified'), 'The embedded marker must not invent a new physical Unified variant'); + } +} +const rak118LinkedSelection = picker.selectionFromUrl( + 'https://mikecarper.github.io/MeshCore/firmware_picker/?s=1.hRAK_4631.rrepeater.olora-receiver.ffull.vdefault', + publishedCatalog.profiles); +assert.deepStrictEqual(rak118LinkedSelection.unavailable, []); +assert.deepStrictEqual(publishedCatalog.profiles.filter(profile => + picker.profileMatchesFacets(profile, rak118LinkedSelection.filters)).map(profile => profile.target), +['RAK_4631_repeater_unified_lora_ota-full'], 'Existing default links must retain the ordinary adaptive Full image'); + +function rak118PartialCatalog(targets, extraControls = {}) { + const metadata = {...publishedControls, profiles: {...publishedControls.profiles, ...extraControls}}; + const files = targets.map(target => asset(target + '-' + publishedControls.familyTag + '.uf2')); + return picker.buildCatalog([ + release(publishedControls.familyTag, '2026-10-24T00:00:00Z', files), + ], metadata); +} +for (const hardware of ['RAK_3401', 'RAK_4631']) { + for (const policy of ['full', 'reduced']) { + const target = hardware + '_repeater_unified_lora_ota-with-bootloader-2.4.12-' + policy; + const oldFilters = {hardware, role: 'repeater', ota: 'lora-receiver', + feature: policy === 'full' ? 'full' : 'standard', variant: 'unified-with-bootloader-2-4-12'}; + const oldUrl = picker.shortSelectionUrl('https://mikecarper.github.io/MeshCore/firmware_picker/', oldFilters, true); + const restored = picker.selectionFromUrl(oldUrl, publishedCatalog.profiles); + assert.deepStrictEqual(restored.unavailable, [], 'Saved embedded unified links must retain their exact image'); + assert.strictEqual(restored.filters.variant, 'with-bootloader-2-4-12'); + assert.deepStrictEqual(publishedCatalog.profiles.filter(profile => + picker.profileMatchesFacets(profile, restored.filters)).map(profile => profile.target), [target]); + const canonicalUrl = picker.shortSelectionUrl(oldUrl, restored.filters, restored.automaticChipFamily); + assert(!canonicalUrl.includes('unified-with-bootloader')); + assert.deepStrictEqual(picker.selectionFromUrl(canonicalUrl, publishedCatalog.profiles), restored, + 'New canonical embedded links must round-trip without changing any selected facet'); + for (const fault of ['missing', 'invalid']) { + const changed = JSON.parse(JSON.stringify(publishedControls.profiles[target])); + if (fault === 'missing') delete changed.embeddedBootloader; + else changed.embeddedBootloader.candidate_sha256 = 'invalid'; + const partial = rak118PartialCatalog([target], {[target]: changed}); + const rejected = picker.selectionFromUrl(oldUrl, partial.profiles); + assert(rejected.unavailable.includes('variant=' + oldFilters.variant), + 'Saved unified aliases require validated embedded metadata: ' + fault); + assert.notStrictEqual(rejected.filters.variant, 'with-bootloader-2-4-12'); + } + } +} +for (const policy of ['full', 'reduced']) { + const target = 'RAK_4631_repeater_ethernet-with-bootloader-2.4.12-' + policy; + const oldFilters = {hardware: 'RAK_4631', role: 'repeater', mode: 'ethernet', ota: 'lora-receiver', + feature: policy === 'full' ? 'full' : 'standard', variant: 'with-bootloader-2-4-12'}; + const oldUrl = picker.selectionUrl('https://mikecarper.github.io/MeshCore/firmware_picker/', oldFilters, true); + const restored = picker.selectionFromUrl(oldUrl, publishedCatalog.profiles); + assert.deepStrictEqual(restored.unavailable, []); + assert.strictEqual(restored.filters.variant, 'ethernet-with-bootloader-2-4-12'); + assert.deepStrictEqual(publishedCatalog.profiles.filter(profile => + picker.profileMatchesFacets(profile, restored.filters)).map(profile => profile.target), [target]); + assert.deepStrictEqual(picker.selectionFromUrl(picker.shortSelectionUrl(oldUrl, restored.filters, true), + publishedCatalog.profiles), restored, 'Canonical Ethernet embedded links must retain the exact image'); + const noMode = picker.selectionFromUrl(picker.selectionUrl(oldUrl, {...oldFilters, mode: ''}, true), + publishedCatalog.profiles); + assert.strictEqual(noMode.filters.variant, 'with-bootloader-2-4-12', + 'An ambiguous old generic variant must not guess an Ethernet hardware selection'); + for (const fault of ['missing', 'invalid']) { + const changed = JSON.parse(JSON.stringify(publishedControls.profiles[target])); + if (fault === 'missing') delete changed.embeddedBootloader; + else changed.embeddedBootloader.candidate_sha256 = 'invalid'; + const partial = rak118PartialCatalog([target], {[target]: changed}); + const rejected = picker.selectionFromUrl(oldUrl, partial.profiles); + assert(rejected.unavailable.includes('variant=' + oldFilters.variant)); + assert.notStrictEqual(rejected.filters.variant, 'ethernet-with-bootloader-2-4-12', + 'An Ethernet saved-link alias requires validated embedded metadata'); + } +} +console.log('saved embedded RAK unified and explicit Ethernet links retain exact validated identities'); +for (const [legacyBase, successorBase] of embeddedRakCompatibilitySuccessors) { + for (const policy of ['full', 'reduced']) { + const otherPolicy = policy === 'full' ? 'reduced' : 'full'; + const legacy = legacyBase + '-with-bootloader-2.4.12-' + policy; + const successor = successorBase + '-with-bootloader-2.4.12-' + policy; + const ordinarySuccessor = successorBase + '-' + policy; + const anotherVersion = successorBase + '-with-bootloader-2.4.11-' + policy; + const anotherVersionControls = { + ...publishedControls.profiles[successor], + embeddedBootloader: {...publishedControls.profiles[successor].embeddedBootloader, release: '2.4.11'}, + }; + for (const [fault, targets, metadata] of [ + ['other sensor policy', [legacy, successorBase + '-with-bootloader-2.4.12-' + otherPolicy], {}], + ['ordinary application only', [legacy, ordinarySuccessor], {}], + ['other embedded version', [legacy, anotherVersion], {[anotherVersion]: anotherVersionControls}], + ]) { + const partial = rak118PartialCatalog(targets, metadata); + assert(partial.profiles.some(profile => profile.target === legacy), + legacy + ' must remain selectable with only a successor for ' + fault); + assert(partial.rows.some(row => row.target === legacy && row.name === legacy + '-' + publishedControls.familyTag + '.uf2'), + 'Missing-successor checks must retain the exact legacy file'); + } + const exact = rak118PartialCatalog([legacy, successor]); + assert(!exact.profiles.some(profile => profile.target === legacy), + 'The exact same-version, same-policy combined image must replace the legacy recommendation'); + assert(exact.rows.some(row => row.target === legacy)); + const ordinaryLegacy = legacyBase + '-' + policy; + const embeddedOnly = rak118PartialCatalog([ordinaryLegacy, successor]); + assert(embeddedOnly.profiles.some(profile => profile.target === ordinaryLegacy), + 'An embedded successor must not hide an ordinary compatibility application'); + const missingMarker = rak118PartialCatalog([legacy, ordinarySuccessor], { + [ordinarySuccessor]: publishedControls.profiles[successor], + }); + assert(missingMarker.profiles.some(profile => profile.target === legacy), + 'Embedded metadata alone must not turn an ordinary filename into a matching embedded successor'); + const metadataFaults = ['missing_embedded', 'stale_embedded_source', 'stale_sensor_source', + 'invalid_candidate', 'different_candidate', 'different_profile', 'invalid_delay', + 'different_asset', 'different_manifest', 'invalid_asset', 'invalid_manifest', 'nonadaptive_storage']; + if (legacyBase.includes('_bridge_rs232_')) metadataFaults.push('unmerged_rs232', 'stale_rs232_source'); + for (const fault of metadataFaults) { + const changed = JSON.parse(JSON.stringify(publishedControls.profiles[successor])); + if (fault === 'missing_embedded') delete changed.embeddedBootloader; + if (fault === 'stale_embedded_source') changed.embeddedBootloaderSource = 'b'.repeat(40); + if (fault === 'stale_sensor_source') changed.sensorProfileSource = 'b'.repeat(40); + if (fault === 'invalid_candidate') changed.embeddedBootloader.candidate_sha256 = 'invalid'; + if (fault === 'different_candidate') changed.embeddedBootloader.candidate_sha256 = '0'.repeat(64); + if (fault === 'different_profile') changed.embeddedBootloader.profile = 'another_board_profile'; + if (fault === 'invalid_delay') changed.embeddedBootloader.automatic_delay_seconds = 1; + if (fault === 'different_asset') changed.embeddedBootloader.asset_sha256 = '0'.repeat(64); + if (fault === 'different_manifest') changed.embeddedBootloader.manifest_sha256 = '0'.repeat(64); + if (fault === 'invalid_asset') changed.embeddedBootloader.asset_sha256 = 'invalid'; + if (fault === 'invalid_manifest') changed.embeddedBootloader.manifest_sha256 = 'invalid'; + if (fault === 'unmerged_rs232') changed.rs232Merged = false; + if (fault === 'stale_rs232_source') changed.rs232Source = 'b'.repeat(40); + if (fault === 'nonadaptive_storage') changed.updateRequirements.lora.storage = 'internal_flash'; + const partial = rak118PartialCatalog([legacy, successor], {[successor]: changed}); + assert(partial.profiles.some(profile => profile.target === legacy), + legacy + ' must remain selectable when its proposed successor has ' + fault); + } + const unqualifiedLegacy = JSON.parse(JSON.stringify(publishedControls.profiles[legacy])); + unqualifiedLegacy.embeddedBootloader.candidate_sha256 = 'invalid'; + const invalidLegacy = rak118PartialCatalog([legacy, successor], {[legacy]: unqualifiedLegacy}); + assert(invalidLegacy.profiles.some(profile => profile.target === legacy), + 'A matching successor must not conceal an unqualified legacy embedded claim'); + } +} +console.log('RAK 1.18 recommendations keep eight distinct choices and exact missing-successor safety'); const [embeddedTarget] = embeddedControls[0]; for (const fault of ['stale_source', 'invalid_candidate', 'wrong_delay', 'wrong_version', 'ordinary_target']) { const metadata = JSON.parse(JSON.stringify(publishedControls));