From 9c9121c3914f33f0fceea2dc37a607164d03bdf2 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 29 Sep 2026 16:30:50 -0700 Subject: [PATCH] Combine firmware picker profiles and replace extra dropdowns --- docs/_javascript/firmware_picker.js | 186 +++++++++++++++++++++----- docs/_stylesheets/firmware_picker.css | 9 +- docs/firmware_picker.md | 75 +++++------ mkdocs.yml | 4 +- test/test_firmware_picker.js | 74 ++++++++++ 5 files changed, 272 insertions(+), 76 deletions(-) diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index c2a3492e6..8774fc011 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -23,6 +23,11 @@ ]); const FACET_FIELDS = Object.freeze(FILTER_FIELDS.concat(["install"])); + const PROFILE_FIELDS = Object.freeze(["ota", "feature", "variant"]); + const CHOICE_FIELDS = Object.freeze([ + "chipFamily", "hardwareFamily", "hardware", "role", "logging", + "firmwareProfile", "mode", "install", + ]); const CHIP_FAMILY_LABELS = Object.freeze({ esp32: "ESP32", @@ -125,6 +130,7 @@ ]); let pickerInstanceCount = 0; + let runtimeGroupCount = 0; function isSafeGithubUrl(value) { try { @@ -883,6 +889,77 @@ }).filter(Boolean))); } + // Keep the original URL facets, but select the correlated firmware + // capabilities together so OTA and its sensor/storage tradeoff take one click. + function firmwareProfileValue(filters) { + if (!PROFILE_FIELDS.some(function (field) { return filters[field]; })) return ""; + return JSON.stringify(PROFILE_FIELDS.map(function (field) { + return filters[field] || ""; + })); + } + + function firmwareProfileFilters(value) { + let values; + try { + values = value ? JSON.parse(value) : ["", "", ""]; + } catch (error) { + return null; + } + if (!Array.isArray(values) || values.length !== PROFILE_FIELDS.length || + !values.every(function (entry) { return typeof entry === "string"; })) return null; + const filters = {}; + PROFILE_FIELDS.forEach(function (field, index) { filters[field] = values[index]; }); + return filters; + } + + function firmwareProfileLabel(profile) { + const parts = []; + if (profile.feature === "full") parts.push("Full"); + else if (profile.feature === "standard" && profile.ota !== "lora-receiver") { + parts.push("Standard"); + } + if (profile.ota) parts.push(profile.ota === "lora-source" + ? "LoRa OTA source only" : labelFor("ota", profile.ota)); + if (profile.variant && profile.variant !== "default") { + parts.push(humanizeVariant(profile.variant).replace( + "Reduced optional environmental/ranging drivers", "Reduced optional sensors" + )); + } + if (!PROFILE_FIELDS.every(function (field) { return profile[field]; })) { + parts.push("other profile choices open"); + } + return parts.join(" - "); + } + + function firmwareProfileChoices(profiles, filters) { + const compatible = (profiles || []).filter(function (profile) { + return profileMatchesFacets(profile, filters, PROFILE_FIELDS); + }); + const choices = new Map(); + compatible.forEach(function (profile) { + const value = firmwareProfileValue(profile); + if (!choices.has(value)) choices.set(value, { + value: value, + label: firmwareProfileLabel(profile), + filters: firmwareProfileFilters(value), + }); + }); + // Older share links can specify just OTA, feature or variant. Reflect + // that constraint visibly until the operator chooses a complete profile. + const selected = firmwareProfileValue(filters); + if (selected && !choices.has(selected) && compatible.some(function (profile) { + return profileMatches(profile, filters, PROFILE_FIELDS); + })) choices.set(selected, { + value: selected, + label: firmwareProfileLabel(filters), + filters: firmwareProfileFilters(selected), + }); + return Array.from(choices.values()).sort(function (a, b) { + const byFull = Number(b.filters.feature === "full") - Number(a.filters.feature === "full"); + return byFull || a.label.localeCompare(b.label, undefined, { numeric: true }); + }); + } + function selectionUrl(url, filters, automaticChipFamily) { const result = new URL(url); FACET_FIELDS.forEach(function (field) { @@ -1004,6 +1081,23 @@ return element; } + function renderRadioChoices(container, options, selected, name, field) { + container.replaceChildren(); + options.forEach(function (option) { + const label = createElement("label"); + label.className = "firmware-picker-radio-option"; + const input = createElement("input"); + input.type = "radio"; + input.name = name; + input.value = option.value; + if (field) input.dataset.choiceField = field; + input.checked = selected === option.value; + label.appendChild(input); + label.appendChild(createElement("span", option.label)); + container.appendChild(label); + }); + } + function optionSort(field, a, b) { const roleOrder = ["companion", "repeater", "room", "sensor", "partition-expander", "terminal", "kiss", "other"]; @@ -1398,20 +1492,21 @@ details.open = index === 0; details.appendChild(createElement("summary", item.title)); if (item.note) details.appendChild(createElement("p", item.note)); - const label = createElement("label", "Show steps for "); - const select = createElement("select"); - select.setAttribute("aria-label", item.title); - item.actions.forEach(function (action, i) { - const option = createElement("option", action.label); - option.value = String(i); - select.appendChild(option); - }); - label.appendChild(select); - details.appendChild(label); + const fieldset = createElement("fieldset"); + fieldset.className = "firmware-picker-radio-control"; + fieldset.appendChild(createElement("legend", "Show steps for " + item.title)); + const choices = createElement("div"); + choices.className = "firmware-picker-radio-options"; + renderRadioChoices(choices, item.actions.map(function (action, i) { + return { value: String(i), label: action.label }; + }), "0", "firmware-picker-runtime-" + (++runtimeGroupCount)); + fieldset.appendChild(choices); + details.appendChild(fieldset); const output = createElement("div"); + let actionIndex = 0; function show() { output.replaceChildren(); - const action = item.actions[Number(select.value) || 0]; + const action = item.actions[actionIndex]; if (action.text) output.appendChild(createElement("p", action.text)); if (action.commands) { const pre = createElement("pre"); @@ -1431,7 +1526,10 @@ output.appendChild(button); } } - select.addEventListener("change", show); + choices.addEventListener("change", function (event) { + actionIndex = Number(event.target.value) || 0; + show(); + }); show(); details.appendChild(output); panel.appendChild(details); @@ -1467,9 +1565,7 @@ }).join(" / "), ], ["Logging", labelFor("logging", selection && selection.logging || profile.logging)], - ["OTA", labelFor("ota", profile.ota)], - ["Feature profile", labelFor("feature", profile.feature)], - ["Variant", labelFor("variant", profile.variant, profile.hardware)], + ["Firmware profile", firmwareProfileLabel(profile)], ["Install operation", labelFor("install", installKind)], ["File", asset.name], ["Size", formatBytes(asset.size)], @@ -1535,6 +1631,7 @@ FACET_FIELDS.forEach(function (field) { controls[field] = form.querySelector('[data-field="' + field + '"]'); }); + controls.firmwareProfile = form.querySelector('[data-field="firmwareProfile"]'); let catalog = { releaseSet: null, rows: [], profiles: [] }; const filters = {}; let automaticChipFamily = false; @@ -1620,27 +1717,16 @@ } function setRadioOptions(container, field, values, selected) { - container.replaceChildren(); const options = [{ value: "", label: "Any" }].concat( values.slice().sort(function (a, b) { return optionSort(field, a, b); }).map(function (value) { - return { value: value, label: labelFor(field, value) }; + return { value: value, label: field === "hardware" + ? humanizeHardwareVariant(value, filters.hardwareFamily) + : labelFor(field, value, filters.hardware || filters.hardwareFamily) }; }) ); - options.forEach(function (option) { - const label = createElement("label"); - label.className = "firmware-picker-radio-option"; - const input = createElement("input"); - input.type = "radio"; - input.name = groupPrefix + "-" + field; - input.value = option.value; - input.dataset.choiceField = field; - input.checked = selected === option.value; - label.appendChild(input); - label.appendChild(createElement("span", option.label)); - container.appendChild(label); - }); + renderRadioChoices(container, options, selected, groupPrefix + "-" + field, field); const fieldset = container.closest("fieldset"); if (fieldset) fieldset.disabled = values.length === 0; } @@ -1676,6 +1762,7 @@ changed = false; passes += 1; FACET_FIELDS.forEach(function (field) { + if (PROFILE_FIELDS.includes(field)) return; if (field === "hardware" && !filters.hardwareFamily) { if (filters.hardware) { filters.hardware = ""; @@ -1693,10 +1780,17 @@ changed = true; } }); + if (firmwareProfileValue(filters) && !matchingProfiles().length) { + PROFILE_FIELDS.forEach(function (field) { filters[field] = ""; }); + changed = true; + } } } function refreshFacets() { + const active = document.activeElement; + const focusedChoice = active && form.contains(active) && active.type === "radio" + ? { field: active.dataset.choiceField, value: active.value } : null; if (automaticChipFamily) filters.chipFamily = ""; stabilizeFilters(); if (automaticChipFamily && filters.hardwareFamily) { @@ -1706,6 +1800,11 @@ FACET_FIELDS.forEach(function (field) { setControlOptions(field, valuesForField(field)); }); + const profileChoices = firmwareProfileChoices(catalog.profiles, filters); + const profileControl = controls.firmwareProfile; + renderRadioChoices(profileControl, [{ value: "", label: "Any" }].concat(profileChoices), + firmwareProfileValue(filters), groupPrefix + "-firmwareProfile", "firmwareProfile"); + profileControl.closest("fieldset").disabled = profileChoices.length === 0; const hardwareVariants = valuesForField("hardware"); const hardwareVariantControl = root.querySelector( '[data-role="hardware-variant-control"]' @@ -1717,19 +1816,29 @@ (filters.chipFamily ? " - " + labelFor("chipFamily", filters.chipFamily) : ""); render(); updateSelectionUrl(); + // Rebuilding the options must not interrupt native radio keyboard navigation. + if (focusedChoice) { + const control = controls[focusedChoice.field]; + const replacement = control && Array.from(control.querySelectorAll('input[type="radio"]')) + .find(function (input) { return input.value === focusedChoice.value; }); + if (replacement) replacement.focus({ preventScroll: true }); + } } function render() { result.hidden = true; missing.hidden = true; resultList.replaceChildren(); - const missingFields = FACET_FIELDS.filter(function (field) { + const missingFields = CHOICE_FIELDS.filter(function (field) { + if (field === "firmwareProfile") return !PROFILE_FIELDS.every(function (part) { + return filters[part]; + }); return field !== "chipFamily" && !filters[field]; }); const matches = matchingProfiles(); if (missingFields.length) { - if (missingFields.length === FACET_FIELDS.length - 1 && !filters.chipFamily) { + if (missingFields.length === CHOICE_FIELDS.length - 1 && !filters.chipFamily) { status.textContent = "Pick options in any order. " + matches.length + " compatible configurations are available."; } else { @@ -1827,6 +1936,14 @@ form.addEventListener("change", function (event) { const target = event.target; const field = target.dataset.choiceField || target.dataset.field; + if (field === "firmwareProfile") { + const chosen = firmwareProfileFilters(target.value); + if (!chosen) return; + if (linkStatus) linkStatus.textContent = ""; + PROFILE_FIELDS.forEach(function (part) { filters[part] = chosen[part]; }); + refreshFacets(); + return; + } if (!FACET_FIELDS.includes(field)) return; if (linkStatus) linkStatus.textContent = ""; if (field === "chipFamily") { @@ -1918,6 +2035,7 @@ const api = Object.freeze({ FILTER_FIELDS: FILTER_FIELDS, FACET_FIELDS: FACET_FIELDS, + PROFILE_FIELDS: PROFILE_FIELDS, ROLE_LABELS: ROLE_LABELS, LOGGING_LABELS: LOGGING_LABELS, OTA_LABELS: OTA_LABELS, @@ -1953,6 +2071,10 @@ profileMatchesFacets: profileMatchesFacets, facetValues: facetValues, uniqueValues: uniqueValues, + firmwareProfileValue: firmwareProfileValue, + firmwareProfileFilters: firmwareProfileFilters, + firmwareProfileLabel: firmwareProfileLabel, + firmwareProfileChoices: firmwareProfileChoices, selectionUrl: selectionUrl, selectionFromUrl: selectionFromUrl, canonicalAsset: canonicalAsset, diff --git a/docs/_stylesheets/firmware_picker.css b/docs/_stylesheets/firmware_picker.css index 732b24655..a23f3421e 100644 --- a/docs/_stylesheets/firmware_picker.css +++ b/docs/_stylesheets/firmware_picker.css @@ -98,10 +98,17 @@ gap: 0.45rem; } +.firmware-picker-control-help { + margin: 0 0 0.65rem; + color: var(--md-default-fg-color--light); + font-size: 0.85rem; +} + .firmware-picker-radio-option { position: relative; display: inline-flex; min-width: 0; + max-width: 100%; cursor: pointer; } @@ -123,6 +130,7 @@ background: var(--md-default-bg-color); font-weight: 600; line-height: 1.2; + overflow-wrap: anywhere; } .firmware-picker-radio-option input:checked + span { @@ -328,7 +336,6 @@ font-weight: 650; } -.firmware-picker-runtime select, .firmware-picker-runtime button { padding: 0.5rem; margin: 0.4rem 0; diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index af5577b8f..543ea2e9b 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -2,7 +2,7 @@ Each result now includes **Restore your settings after flashing**. Its commands follow the selected logging mode and the exact image's verified role/hardware -controls. Select On, Off, or Check to view and copy the commands; Companion +controls. Click On, Off, or Check to view and copy the commands; Companion MQTT and GPS show their app/WebConfig steps instead. See [feature switches by role](role_feature_switches.md) for the full reference. The [USB web console](https://flasher.meshcore.io/console) works with the @@ -12,6 +12,9 @@ Pick the choices in any order. Every selection narrows all the other controls to firmware combinations that were actually built in the current release set. The optional chip-family filter (ESP32, nRF52, RP2040, or STM32) can narrow the hardware list first. You can skip it: picking hardware fills it in automatically. +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 in one choice. The picker reads public release metadata from GitHub. It does not upload device information. Hardware names, target names, and download links come directly @@ -66,12 +69,12 @@ from the published firmware assets. - +
Install operation @@ -94,34 +97,21 @@ from the published firmware assets.
-
- OTA capability -
- Loading OTA choices... +
+ Firmware profile +

Each button combines OTA support with the feature, sensor, and storage choices for that image. Reduced optional sensors means fewer environmental/ranging drivers; I2C and supported board peripherals remain available.

+
+ Loading firmware profiles...
-
- - -
- -
- Feature profile -
- Loading profiles... +
+ Connection / bridge mode +
+ Loading modes...
-
- - -
-
@@ -174,7 +164,8 @@ from the published firmware assets. | Wi-Fi MQTT observer | Firmware connects directly to MQTT over Wi-Fi; this is not USB logging | | USB logging + Wi-Fi MQTT | Unified FULL image sends to both paths; avoid two publishers aimed at the same broker unless messages are deduplicated | | No logging | Normal standalone operation without the dedicated logging/MQTT profile | -| Receives LoRa OTA | Repeater, room server, or sensor image that can stage an exact matching update received over LoRa | +| Receives LoRa OTA | Repeater, room server, or sensor image that can stage an exact matching update received over LoRa; any sensor/storage tradeoff appears on the same Firmware profile button | +| Receives LoRa OTA - Reduced optional sensors | Compact OTA image that omits selected optional environmental/ranging drivers while retaining generic I2C and supported board peripherals | | LoRa OTA source only | Full Companion serving a host-supplied update to another node without self-installing it | Connection and bridge choices depend on the selected role. Companion firmware @@ -223,7 +214,10 @@ preselects that board, role, OTA capability, and storage profile. The query parameters are `chipFamily`, `hardwareFamily`, `hardware`, `role`, `logging`, `ota`, `mode`, `feature`, `variant`, and `install`. Values use the -picker's internal identifiers rather than the displayed labels. The generated +picker's internal identifiers rather than the displayed labels. One Firmware +profile button sets `ota`, `feature`, and `variant` together. Existing links +with only some of those choices still work and show their partial selection. +The generated link also records `chipAuto` so automatic chip-family selection or an explicit **Any** choice behaves the same after reopening. Existing section anchors and unrelated query parameters are preserved. @@ -397,16 +391,15 @@ bootloader-update UF2 for the application image being installed. ## Hardware and variant names -Hardware families with multiple released targets get a second hardware-variant -menu. It separates revisions, display type, expansion kit, radio/PA layout, -pin map, and other physical differences without crowding the first menu. The -firmware-variant menu separately exposes choices that still require different -code or wiring, such as serial port or the legacy `no_external_sensors` target -suffix. For RAK3401 and RAK4631, the picker labels that compact LoRa OTA option -**Internal storage (no external storage board)**. Its result details explain -the omitted optional environmental/ranging sensor drivers; the reduction does -not disable generic I2C or unrelated board-integrated peripherals. Other boards -retain the **Reduced optional environmental/ranging drivers** label. +Hardware families with multiple released targets get **Hardware variant** +buttons. They separate revisions, display type, expansion kit, radio/PA layout, +pin map, and other physical differences without crowding the Hardware dropdown. +**Firmware profile** buttons combine OTA support, Full/standard, and choices +that need a different image or wiring, such as serial port or external storage. +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 +reduction does not disable generic I2C or unrelated board-integrated peripherals. Reduced RAK3401 and RAK4631 targets retain INA219/INA226/INA260/INA3221 as voltage/current entries in the optional sensor table. They are not the only I2C users: the SSD1306 OLED, supported autodiscovered RTCs, and RAK12500 GPS remain diff --git a/mkdocs.yml b/mkdocs.yml index d89934d14..379a265b7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,7 +18,7 @@ theme: extra_css: - https://unpkg.com/leaflet@1.9.4/dist/leaflet.css - _stylesheets/extra.css - - _stylesheets/firmware_picker.css + - _stylesheets/firmware_picker.css?v=20260929-1 - _stylesheets/telemetry_decoder.css - _stylesheets/management_decoder.css - _stylesheets/filter_tool.css @@ -26,7 +26,7 @@ extra_css: extra_javascript: - https://unpkg.com/leaflet@1.9.4/dist/leaflet.js - - _javascript/firmware_picker.js?v=20260927-4 + - _javascript/firmware_picker.js?v=20260929-1 - _javascript/telemetry_decoder.js - _javascript/management_decoder.js - _javascript/filter_tool.js diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index a541a3820..521dd4722 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -1291,3 +1291,77 @@ const escapedShare = picker.selectionFromUrl('https://example.com/?role=%3Cscrip assert.deepStrictEqual(escapedShare.filters, {}); assert.deepStrictEqual(escapedShare.unavailable, ['role=