diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index c49ff32fc..0b66da375 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -482,6 +482,11 @@ if (/^RAK_(?:3401|4631)_repeater_unified_lora_ota(?:-(?:full|reduced)(?:-ota)?)?$/i.test(target)) { variant = "default"; } + if (/^RAK_4631_repeater_ethernet(?:-(?:full|reduced)(?:-ota)?)?$/i.test(target)) { + // The RAK13800 Ethernet image is a real hardware choice, separate from + // adaptive storage. Label it consistently for both sensor policies. + variant = "ethernet"; + } if (sourceHardware === "Station_G2_logging") { variant = variant === "default" ? "rx-boosted" @@ -571,11 +576,13 @@ heltec_t096_repeater_bridge_rs232_lora_ota_no_external_sensors: "heltec_t096_repeater_lora_ota_no_external_sensors", rak_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors: - "rak_4631_repeater_lora_ota_no_external_sensors", + ["rak_4631_repeater_unified_lora_ota", "rak_4631_repeater_lora_ota_no_external_sensors"], rak_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors: - "rak_4631_repeater_lora_ota_no_external_sensors", - rak_4631_repeater_bridge_rs232_serial1: "rak_4631_repeater", - rak_4631_repeater_bridge_rs232_serial2: "rak_4631_repeater", + ["rak_4631_repeater_unified_lora_ota", "rak_4631_repeater_lora_ota_no_external_sensors"], + rak_4631_repeater_bridge_rs232_serial1: + ["rak_4631_repeater_unified_lora_ota", "rak_4631_repeater"], + rak_4631_repeater_bridge_rs232_serial2: + ["rak_4631_repeater_unified_lora_ota", "rak_4631_repeater"], promicro_repeater_bridge_rs232_serial1: "promicro_repeater", heltec_t114_without_display_repeater_bridge_rs232: "heltec_t114_without_display_repeater", @@ -603,11 +610,16 @@ combinedUsbBleKeys.has(key); const replacedTerminal = profile.role === "terminal" && terminalReplacementKeys.has(key); - const rs232Replacement = mergedRs232Targets[ - String(profile.target || "").toLowerCase() - ]; + 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]; + // Preserve the exact sensor policy: a Full image does not replace a + // missing Reduced image, or an older unsuffixed compatibility image. const replacedRs232 = Boolean(rs232Replacement && - targets.has(rs232Replacement)); + [].concat(rs232Replacement).some(function (replacement) { + return targets.has(replacement + sensorSuffix); + })); return !(replacedAttachedTransport && fullKeys.has(key)) && !replacedByUsbBle && !replacedTerminal && !replacedRs232; }); @@ -644,8 +656,8 @@ })); 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)$/.exec(target); - return !match || !targets.has("rak_" + match[1] + "_repeater_unified_lora_ota"); + 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] || "")); }); } @@ -668,10 +680,7 @@ function applyMergedRak4631RepeaterCapabilities(profiles) { return (profiles || []).map(function (profile) { const target = String(profile && profile.target || "").toLowerCase(); - if ( - target !== "rak_4631_repeater" && - target !== "rak_4631_repeater_lora_ota_no_external_sensors" - ) { + if (!/^rak_4631_repeater(?:_lora_ota_no_external_sensors|_unified_lora_ota)?(?:-(?:full|reduced))?$/.test(target)) { return profile; } @@ -1667,6 +1676,9 @@ 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 + ? "Use the UART and pin map for this exact board. Unified RAK4631 supports set bridge.uart 1 or set bridge.uart 2 while the bridge is stopped. UART1 pauses UART GPS while the bridge runs and restores it when stopped." + : "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. // Their independently saved UART uses the transport-specific controls. const uartSetting = info.mqtt ? "rs232.enabled" : "bridge.enabled"; @@ -1677,7 +1689,7 @@ { label: "Set baud", commands: ["set " + uartSetting + " off", "set bridge.baud 115200", "set " + uartSetting + " on"] }, ].sort(function (a, b) { return mode === "standard" ? Number(b.label === "Off") - Number(a.label === "Off") : 0; - }), "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."); + }), uartNote); } if (info.espnowBridge && infrastructure) { // Combined images need the transport-specific switch: bridge.enabled diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index b622e5058..085b5339d 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -25,6 +25,13 @@ Chip family and hardware are the only dropdowns. All remaining choices use buttons. **Firmware profile** combines OTA support, Full/standard, and sensor or storage differences when a choice between different images is needed. +RAK3401 and RAK4631 repeaters use one unified image per sensor policy for +supported internal and external OTA storage. **Full** and **Reduced** choose +optional sensor drivers; they do not select a storage module. The unified +RAK4631 image also includes runtime RS-232 UART selection. Ethernet remains +a separate RAK4631 hardware image. LoRa OTA still requires the matching +OTAFIX bootloader and a package compatible with the installed target. + The picker reads public release metadata from GitHub. It does not upload device information. Hardware names, target names, and download links come directly from the published firmware assets. diff --git a/mkdocs.yml b/mkdocs.yml index afa153933..a149186f4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -127,7 +127,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=20261006-simplify-1 + - _javascript/firmware_picker.js?v=20261008-rak-combined-1 - _javascript/telemetry_decoder.js?v=20261007-hosted-form - _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 b10091b02..a3e00272f 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -1014,6 +1014,27 @@ const rakLegacyCatalog = picker.buildCatalog([ release(family, "2026-09-25T00:00:00Z", rakStorageAssets.slice(1, 4).concat(rakStorageAssets.slice(5))), ]); assert.strictEqual(rakLegacyCatalog.profiles.length, 5); +// Sensor-policy suffixes must not resurrect the storage-specific compatibility +// images. A Full successor cannot replace a missing Reduced successor. +const rakPolicyTargets = rakStorageTargets.flatMap(target => + ['full', 'reduced'].map(policy => target + '-' + policy)); +const rakPolicyAssets = rakPolicyTargets.map(target => + asset(target + '-ota-' + family + '.uf2')); +const rakPolicyCatalog = picker.buildCatalog([ + release(family, '2026-10-08T00:00:00Z', rakPolicyAssets), +]); +assert.deepStrictEqual(rakPolicyCatalog.profiles.map(item => item.target).sort(), + ['RAK_3401', 'RAK_4631'].flatMap(board => + ['full', 'reduced'].map(policy => board + '_repeater_unified_lora_ota-' + policy)).sort()); +assert.deepStrictEqual(rakPolicyCatalog.rows.map(item => item.target).sort(), rakPolicyTargets.sort(), + 'All exact legacy OTA identities must remain in the raw catalog'); +const rakMissingReducedCatalog = picker.buildCatalog([ + release(family, '2026-10-08T00:00:00Z', rakPolicyAssets.filter(file => + !file.name.includes('_unified_lora_ota-reduced-'))), +]); +assert.deepStrictEqual(rakMissingReducedCatalog.profiles.map(item => item.target).sort(), + rakPolicyTargets.filter(target => target.endsWith('_unified_lora_ota-full') || + (target.endsWith('-reduced') && !target.includes('_unified_lora_ota-'))).sort()); assert.strictEqual(picker.formatBytes(2097152), "2.00 MiB"); assert.strictEqual( picker.parseFirmwareAsset( @@ -1155,6 +1176,40 @@ 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, bases] of [ + ['RAK_4631', ['RAK_4631_repeater_unified_lora_ota', 'RAK_4631_repeater_ethernet']], + ['RAK_3401', ['RAK_3401_repeater_unified_lora_ota']], +]) { + const context = {hardware, role: 'repeater'}; + const expected = bases.flatMap(base => ['full', 'reduced'].map(policy => base + '-' + policy)); + const visible = currentCatalog.profiles.filter(profile => picker.profileMatchesFacets(profile, context)); + assert.deepStrictEqual(visible.map(profile => profile.target).sort(), expected.sort(), + hardware + ' must recommend only combined storage/bridge images and genuine Ethernet variants'); + const choices = picker.firmwareProfileChoices(currentCatalog.profiles, context); + assert.strictEqual(choices.length, expected.length, hardware + ' must have one choice per real image'); + assert.strictEqual(new Set(choices.map(choice => choice.label)).size, choices.length, + hardware + ' must not show duplicate profile labels'); + for (const profile of visible.filter(item => item.target.includes('_unified_lora_ota-'))) { + assert.deepStrictEqual(picker.profileFieldValues(profile, 'mode'), + hardware === 'RAK_4631' ? ['standard', 'rs232'] : ['standard']); + if (hardware === 'RAK_4631') { + const bridge = picker.runtimeDirections(profile, {mode: 'rs232'}) + .find(section => section.title.startsWith('RS232 bridge')); + assert(bridge && bridge.note.includes('set bridge.uart 1') && bridge.note.includes('pauses UART GPS'), + 'Both combined sensor policies must explain runtime UART selection and GPS sharing'); + assert(!bridge.note.includes('GPS-free image')); + } + } +} +const rakBridgeBase = 'RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors'; +const rakPartialAssets = currentAssets.filter(file => + file.name.startsWith('RAK_4631_repeater_unified_lora_ota-full-') || + file.name.startsWith(rakBridgeBase + '-reduced-')); +const rakPartialCatalog = picker.buildCatalog([ + release(currentControls.familyTag, '2026-10-08T00:00:00Z', rakPartialAssets), +], currentControls); +assert(rakPartialCatalog.profiles.some(profile => profile.target === rakBridgeBase + '-reduced'), + 'A Full combined image must not hide the only available Reduced bridge image'); const expandedEsp32 = currentCatalog.profiles.find(profile => profile.target === 'Ebyte_EoRa-S3_Repeater-full-usb-wifi'); assert(expandedEsp32);