diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index f0de5c0df..f975c2ac2 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -1097,6 +1097,23 @@ profile.loggingModes = modes.slice(); } } + // T-LoRa has two distinct Full images because its UART bridge and MQTT + // client cannot fit together. USB/off logging must not collapse them into + // one firmware-profile button. Qualify the split against the exact files. + if (source && sourceMatches && source === profile.controlsSource && + controls.platform === "ESP32_PLATFORM" && controls.espnowBridge === true) { + const loggingKey = Array.isArray(modes) ? modes.join(",") : ""; + if (/^LilyGo_TLora_V2_1_1_6_repeater-full-(?:logging|usb-wifi)$/i.test(profile.target) && + controls.loggingControl === "usb.logging" && loggingKey === "none,usb" && controls.mqtt === false && + controls.rs232Merged === true && controls.rs232Source === source && + releaseBoundControl(profile, "rs232")) { + profile.variant = "rs232-bridge"; + } else if (/^LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_?-full-usb-wifi$/i.test(profile.target) && + controls.loggingControl === "logging.output" && loggingKey === "none,usb,wifi,both" && + controls.mqtt === true && controls.rs232 === false) { + profile.variant = "mqtt-observer"; + } + } if (typeof controls.dedicatedUsbLogging === "boolean") { profile.dedicatedUsbLogging = controls.dedicatedUsbLogging; } @@ -1794,6 +1811,8 @@ function humanizeVariant(value, hardware) { if (!value || value === "default") return "Default"; + if (value === "rs232-bridge") return "RS-232 bridge"; + if (value === "mqtt-observer") return "Wi-Fi MQTT observer"; const tokenLabels = { ps: "Power save", rx: "RX", diff --git a/mkdocs.yml b/mkdocs.yml index 808221690..235684bdd 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=20261024-embedded-rak-profiles + - _javascript/firmware_picker.js?v=20261024-board-profile-audit - _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 ae98734c0..86a42c165 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -2006,6 +2006,93 @@ assert(!tloraObserverDirections.some(section => /^RS232 bridge/.test(section.tit assert(!commands(tloraObserverDirections).some(command => command.startsWith('set rs232.'))); console.log('T-LoRa RAM exception preserves separate UART and MQTT Full picker choices'); +const tlora118Targets = ['LilyGo_TLora_V2_1_1_6_repeater-full-logging', tloraObserverTarget]; +const tlora118Profiles = tlora118Targets.map(target => publishedCatalog.profiles.find(profile => profile.target === target)); +assert(tlora118Profiles.every(Boolean), 'The current release must exercise both qualified T-LoRa images'); +assert.deepStrictEqual(tlora118Profiles.map(profile => profile.variant), ['rs232-bridge', 'mqtt-observer']); +assert(picker.firmwareProfileLabel(tlora118Profiles[0]).includes('RS-232 bridge')); +assert(picker.firmwareProfileLabel(tlora118Profiles[1]).includes('Wi-Fi MQTT observer')); +for (const logging of ['none', 'usb']) { + for (const mode of ['standard', 'espnow']) { + const filters = {hardware: 'LilyGo_TLora_V2_1_1_6', role: 'repeater', logging, mode}; + const choices = picker.firmwareProfileChoices(publishedCatalog.profiles, filters); + assert.strictEqual(choices.length, 2, 'Shared T-LoRa logging/transport choices must still select two real images'); + assert.strictEqual(new Set(choices.map(choice => choice.value)).size, 2); + assert.strictEqual(new Set(choices.map(choice => choice.label)).size, 2); + for (const profile of tlora118Profiles) { + assert(picker.profileMatchesFacets(profile, filters)); + const selected = {...filters, ota: profile.ota, feature: profile.feature, variant: profile.variant, install: 'bin'}; + const url = picker.shortSelectionUrl('https://mikecarper.github.io/MeshCore/firmware_picker/', selected, true); + const restored = picker.selectionFromUrl(url, publishedCatalog.profiles); + assert.deepStrictEqual(restored.unavailable, []); + assert.deepStrictEqual(publishedCatalog.profiles.filter(item => + picker.profileMatchesFacets(item, restored.filters)).map(item => item.target), [profile.target], + 'The new T-LoRa profile URL must identify exactly the chosen application'); + } + } +} +for (const logging of ['wifi', 'both']) { + assert.deepStrictEqual(tlora118Profiles.filter(profile => + picker.profileMatchesFacets(profile, {logging})).map(profile => profile.target), [tlora118Targets[1]], + 'Only the observer image supports direct Wi-Fi MQTT output'); +} +assert.deepStrictEqual(tlora118Profiles.filter(profile => + picker.profileMatchesFacets(profile, {mode: 'rs232'})).map(profile => profile.target), [tlora118Targets[0]], +'Only the normal T-LoRa image supports the UART bridge'); +for (const profile of tlora118Profiles) { + const original = publishedAssets.find(file => file.name.startsWith(profile.target + '-')); + assert(original && profile.files.some(file => file.name === original.name && file.url === original.browser_download_url)); + assert(publishedCatalog.rows.some(file => file.name === original.name && file.url === original.browser_download_url), + 'T-LoRa profile labels must preserve exact published filenames and URLs'); +} +for (const target of tlora118Targets) { + const normal = target === tlora118Targets[0]; + const faults = ['missing_logging_source', 'stale_logging_source', 'missing_logging_modes', + 'wrong_logging_control', 'missing_controls_source', 'wrong_hardware']; + if (normal) faults.push('missing_uart_source', 'stale_uart_source', 'unmerged_uart'); + for (const fault of faults) { + const info = JSON.parse(JSON.stringify(publishedControls.profiles[target])); + let changedTarget = target; + let source = publishedControls.source; + if (fault === 'missing_logging_source') delete info.loggingSource; + if (fault === 'stale_logging_source') info.loggingSource = 'b'.repeat(40); + if (fault === 'missing_logging_modes') delete info.loggingModes; + if (fault === 'wrong_logging_control') info.loggingControl = normal ? 'logging.output' : 'usb.logging'; + if (fault === 'missing_controls_source') source = undefined; + if (fault === 'wrong_hardware') changedTarget = target.replace('V2_1_1_6', 'V2_0'); + if (fault === 'missing_uart_source') delete info.rs232Source; + if (fault === 'stale_uart_source') info.rs232Source = 'b'.repeat(40); + if (fault === 'unmerged_uart') info.rs232Merged = false; + const partial = picker.buildCatalog([ + release(publishedControls.familyTag, '2026-10-24T00:00:00Z', + [asset(changedTarget + '-' + publishedControls.familyTag + '.bin')]), + ], {...publishedControls, source, profiles: {[changedTarget]: info}}); + assert.strictEqual(partial.profiles.length, 1); + assert(!['rs232-bridge', 'mqtt-observer'].includes(partial.profiles[0].variant), + 'T-LoRa transport variants require exact source-qualified capability proof: ' + target + ': ' + fault); + } +} + +// Distinct images can share a profile tuple only when another exposed selector +// actually separates them, as with the Tracker V2's different connection modes. +function profilesOverlap(left, right, field) { + const values = field === 'install' ? left.installKinds : picker.profileFieldValues(left, field); + const other = field === 'install' ? right.installKinds : picker.profileFieldValues(right, field); + return values.some(value => other.includes(value)); +} +for (let leftIndex = 0; leftIndex < publishedCatalog.profiles.length; leftIndex += 1) { + const left = publishedCatalog.profiles[leftIndex]; + for (const right of publishedCatalog.profiles.slice(leftIndex + 1)) { + if (left.hardware !== right.hardware || left.role !== right.role || + !['ota', 'feature', 'mode', 'logging', 'install'].every(field => profilesOverlap(left, right, field))) continue; + assert.notStrictEqual(picker.firmwareProfileValue(left), picker.firmwareProfileValue(right), + 'Overlapping selectors must not merge distinct firmware profile values: ' + left.target + ' / ' + right.target); + assert.notStrictEqual(picker.firmwareProfileLabel(left), picker.firmwareProfileLabel(right), + 'Overlapping selectors must not show indistinguishable firmware labels: ' + left.target + ' / ' + right.target); + } +} +console.log('current T-LoRa profiles remain distinct and every overlapping release choice has a unique value and label'); + // Partition expansion uses the packager's lookup and published utility assets, // not hardware labels or guessed slugs (several historical names differ). const migrationFamily = currentControls.familyTag;