diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index c64181093..f139b0486 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -20,6 +20,8 @@ "variant", ]); + const FACET_FIELDS = Object.freeze(FILTER_FIELDS.concat(["install"])); + const ROLE_LABELS = Object.freeze({ companion: "Companion", repeater: "Repeater", @@ -62,11 +64,11 @@ }); const INSTALL_LABELS = Object.freeze({ - "merged-bin": "USB first install / recovery (.bin)", - bin: "Same-profile application update (.bin)", - zip: "nRF52 Serial DFU (.zip)", - uf2: "UF2 drag-and-drop (.uf2)", - hex: "Full wired flash (.hex)", + "merged-bin": "Erase & fresh install (merged .bin)", + bin: "Update existing install (.bin)", + zip: "nRF52 update / Serial DFU (.zip)", + uf2: "UF2 install / update (.uf2)", + hex: "Erase / recovery flash (.hex)", }); const INSTALL_ORDER = Object.freeze([ @@ -77,6 +79,8 @@ "hex", ]); + let pickerInstanceCount = 0; + function isSafeGithubUrl(value) { try { const url = new URL(value); @@ -369,6 +373,32 @@ }); } + function profileMatchesFacets(profile, filters, ignoredField) { + return FACET_FIELDS.every(function (field) { + if (field === ignoredField) return true; + const value = filters && filters[field]; + if (!value) return true; + if (field === "install") { + return profile.installKinds.includes(value); + } + return profile[field] === value; + }); + } + + function facetValues(profiles, filters, field) { + const compatible = (profiles || []).filter(function (profile) { + return profileMatchesFacets(profile, filters, field); + }); + const values = field === "install" + ? compatible.flatMap(function (profile) { + return profile.installKinds; + }) + : compatible.map(function (profile) { + return profile[field]; + }); + return Array.from(new Set(values.filter(Boolean))); + } + function uniqueValues(profiles, field) { return Array.from(new Set((profiles || []).map(function (profile) { return profile[field]; @@ -436,6 +466,7 @@ ota: otaOrder, feature: featureOrder, mode: modeOrder, + install: INSTALL_ORDER, }; const order = orders[field]; if (order) { @@ -457,7 +488,7 @@ const byKind = { "merged-bin": [ "Flash the merged image over a data-capable USB connection.", - "Use this path for a first install, recovery, or partition/profile migration.", + "Use this erase/fresh-install path for a first install, recovery, or partition/profile migration.", ], bin: [ "Use this app-only image only when the installed board, role, and partition layout already match.", @@ -515,7 +546,7 @@ ["OTA", labelFor("ota", profile.ota)], ["Feature profile", labelFor("feature", profile.feature)], ["Variant", labelFor("variant", profile.variant)], - ["Install path", labelFor("install", installKind)], + ["Install operation", labelFor("install", installKind)], ["File", asset.name], ["Size", formatBytes(asset.size)], ["Release", asset.releaseName], @@ -547,30 +578,37 @@ function initPicker(root) { const repo = root.getAttribute("data-release-repo") || "mikecarper/MeshCore"; + const form = root.querySelector('[data-role="form"]'); const status = root.querySelector('[data-role="status"]'); const releaseSetStatus = root.querySelector('[data-role="release-set"]'); const result = root.querySelector('[data-role="result"]'); const resultList = root.querySelector('[data-role="result-list"]'); const missing = root.querySelector('[data-role="missing"]'); - const installSelect = root.querySelector('[data-field="install"]'); const search = root.querySelector('[data-field="asset-search"]'); const assetResults = root.querySelector('[data-role="asset-results"]'); - const selects = {}; - FILTER_FIELDS.forEach(function (field) { - selects[field] = root.querySelector('[data-field="' + field + '"]'); + const clearButton = form.querySelector('[data-action="clear"]'); + const controls = {}; + FACET_FIELDS.forEach(function (field) { + controls[field] = form.querySelector('[data-field="' + field + '"]'); }); let catalog = { releaseSet: null, rows: [], profiles: [] }; const filters = {}; + const groupPrefix = "firmware-picker-" + (++pickerInstanceCount); - function matchingProfiles(fields) { + function matchingProfiles(ignoredField) { return catalog.profiles.filter(function (profile) { - return profileMatches(profile, filters, fields); + return profileMatchesFacets(profile, filters, ignoredField); }); } - function setOptions(select, field, values, placeholder, selected) { + function setSelectOptions(select, field, values, selected) { select.replaceChildren(); - const blank = createElement("option", placeholder); + const placeholders = { + hardware: "Any hardware", + mode: "Any connection / mode", + variant: "Any hardware/profile variant", + }; + const blank = createElement("option", placeholders[field] || "Any"); blank.value = ""; select.appendChild(blank); values.slice().sort(function (a, b) { @@ -581,93 +619,98 @@ select.appendChild(option); }); select.disabled = values.length === 0; - if (selected && values.includes(selected)) select.value = selected; - if (values.length === 1) select.value = values[0]; + select.value = selected && values.includes(selected) ? selected : ""; } - function rebuildCascade(changedIndex) { - if (changedIndex != null && changedIndex >= 0) { - FILTER_FIELDS.slice(changedIndex + 1).forEach(function (field) { - filters[field] = ""; - selects[field].value = ""; - }); - installSelect.value = ""; - } - - let priorComplete = true; - FILTER_FIELDS.forEach(function (field, index) { - const select = selects[field]; - if (!priorComplete) { - setOptions(select, field, [], "Choose earlier options first", ""); - filters[field] = ""; - return; - } - const priorFields = FILTER_FIELDS.slice(0, index); - const candidates = matchingProfiles(priorFields); - const values = uniqueValues(candidates, field); - const previous = filters[field] || select.value; - const placeholder = field === "hardware" - ? "Choose hardware" - : "Choose " + field; - setOptions(select, field, values, placeholder, previous); - filters[field] = select.value; - if (!filters[field]) priorComplete = false; - }); - rebuildInstall(priorComplete); - render(); - } - - function rebuildInstall(profileComplete) { - if (!profileComplete) { - setOptions( - installSelect, - "install", - [], - "Choose firmware options first", - "" - ); - return; - } - const matches = matchingProfiles(FILTER_FIELDS); - const kinds = Array.from(new Set(matches.flatMap(function (profile) { - return profile.installKinds; - }))).sort(function (a, b) { - return INSTALL_ORDER.indexOf(a) - INSTALL_ORDER.indexOf(b); - }); - const previous = installSelect.value; - setOptions( - installSelect, - "install", - kinds, - "Choose installation method", - previous + 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) }; + }) ); + 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); + }); + const fieldset = container.closest("fieldset"); + if (fieldset) fieldset.disabled = values.length === 0; } - function nextMissingField() { - return FILTER_FIELDS.find(function (field) { - return !filters[field]; - }) || (!installSelect.value ? "installation method" : ""); + function setControlOptions(field, values) { + const control = controls[field]; + if (!control) return; + if (control.tagName === "SELECT") { + setSelectOptions(control, field, values, filters[field] || ""); + } else { + setRadioOptions(control, field, values, filters[field] || ""); + } + } + + function stabilizeSingleChoices() { + let changed = true; + let passes = 0; + while (changed && passes <= FACET_FIELDS.length) { + changed = false; + passes += 1; + FACET_FIELDS.forEach(function (field) { + const values = facetValues(catalog.profiles, filters, field); + if (filters[field] && !values.includes(filters[field])) { + filters[field] = ""; + changed = true; + } else if (!filters[field] && values.length === 1) { + filters[field] = values[0]; + changed = true; + } + }); + } + } + + function refreshFacets() { + stabilizeSingleChoices(); + FACET_FIELDS.forEach(function (field) { + setControlOptions( + field, + facetValues(catalog.profiles, filters, field) + ); + }); + render(); } function render() { result.hidden = true; missing.hidden = true; resultList.replaceChildren(); - const missingField = nextMissingField(); - const activeFields = FILTER_FIELDS.filter(function (field) { - return Boolean(filters[field]); + const missingFields = FACET_FIELDS.filter(function (field) { + return !filters[field]; }); - const matches = matchingProfiles(activeFields); + const matches = matchingProfiles(); - if (missingField) { - status.textContent = "Choose " + missingField + ". " + - matches.length + " compatible configuration" + - (matches.length === 1 ? " remains." : "s remain."); + if (missingFields.length) { + if (missingFields.length === FACET_FIELDS.length) { + status.textContent = "Pick options in any order. " + + matches.length + " compatible configurations are available."; + } else { + status.textContent = matches.length + " compatible configuration" + + (matches.length === 1 ? " remains. " : "s remain. ") + + "Choose " + missingFields.length + " more option" + + (missingFields.length === 1 ? "" : "s") + " in any order."; + } return; } - const installKind = installSelect.value; + const installKind = filters.install; const resolved = matches.map(function (profile) { return { profile: profile, @@ -731,13 +774,21 @@ assetResults.appendChild(list); } - FILTER_FIELDS.forEach(function (field, index) { - selects[field].addEventListener("change", function () { - filters[field] = selects[field].value; - rebuildCascade(index); - }); + form.addEventListener("change", function (event) { + const target = event.target; + const field = target.dataset.choiceField || target.dataset.field; + if (!FACET_FIELDS.includes(field)) return; + filters[field] = target.value; + refreshFacets(); + }); + form.addEventListener("reset", function (event) { + event.preventDefault(); + if (!catalog.profiles.length) return; + FACET_FIELDS.forEach(function (field) { + filters[field] = ""; + }); + refreshFacets(); }); - installSelect.addEventListener("change", render); search.addEventListener("input", renderSearch); const endpoint = "https://api.github.com/repos/" + @@ -775,9 +826,8 @@ link.href = set.url; releaseSetStatus.appendChild(link); } - status.textContent = - "Catalog loaded. Start by choosing the exact hardware."; - rebuildCascade(-1); + clearButton.disabled = false; + refreshFacets(); }) .catch(function (error) { status.textContent = @@ -790,6 +840,7 @@ const api = Object.freeze({ FILTER_FIELDS: FILTER_FIELDS, + FACET_FIELDS: FACET_FIELDS, ROLE_LABELS: ROLE_LABELS, LOGGING_LABELS: LOGGING_LABELS, OTA_LABELS: OTA_LABELS, @@ -802,6 +853,8 @@ parseTargetProfile: parseTargetProfile, buildCatalog: buildCatalog, profileMatches: profileMatches, + profileMatchesFacets: profileMatchesFacets, + facetValues: facetValues, uniqueValues: uniqueValues, canonicalAsset: canonicalAsset, humanizeHardware: humanizeHardware, diff --git a/docs/_stylesheets/firmware_picker.css b/docs/_stylesheets/firmware_picker.css index 9ec794731..615a6e408 100644 --- a/docs/_stylesheets/firmware_picker.css +++ b/docs/_stylesheets/firmware_picker.css @@ -32,19 +32,41 @@ .firmware-picker-form { display: grid; - grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); - gap: 0.9rem; + grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); + align-items: start; + gap: 1rem; margin: 1.2rem 0; } -.firmware-picker-form label, +.firmware-picker-order-note { + margin: 1rem 0 0; + color: var(--md-default-fg-color--light); +} + +.firmware-picker-control { + min-width: 0; + margin: 0; +} + +.firmware-picker-select-control > label, +.firmware-picker-radio-control > legend, .firmware-asset-browser label { display: grid; gap: 0.35rem; font-weight: 650; } -.firmware-picker-form select, +.firmware-picker-radio-control { + padding: 0; + border: 0; +} + +.firmware-picker-radio-control > legend { + margin-bottom: 0.35rem; + padding: 0; +} + +.firmware-picker-select-control select, .firmware-asset-browser input { width: 100%; min-height: 2.7rem; @@ -56,6 +78,75 @@ font: inherit; } +.firmware-picker-radio-options { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.firmware-picker-radio-option { + position: relative; + display: inline-flex; + min-width: 0; + cursor: pointer; +} + +.firmware-picker-radio-option input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + pointer-events: none; +} + +.firmware-picker-radio-option span { + display: inline-flex; + align-items: center; + min-height: 2.45rem; + padding: 0.45rem 0.72rem; + border: 1px solid var(--picker-border); + border-radius: 999px; + background: var(--md-default-bg-color); + font-weight: 600; + line-height: 1.2; +} + +.firmware-picker-radio-option input:checked + span { + border-color: var(--picker-accent); + color: white; + background: var(--picker-accent); +} + +.firmware-picker-radio-option input:focus-visible + span { + outline: 0.16rem solid var(--picker-accent); + outline-offset: 0.12rem; +} + +.firmware-picker-radio-control:disabled { + opacity: 0.62; +} + +.firmware-picker-wide { + grid-column: 1 / -1; +} + +.firmware-picker-form-actions button { + min-height: 2.35rem; + padding: 0.4rem 0.75rem; + border: 1px solid var(--picker-border); + border-radius: 0.35rem; + color: inherit; + background: var(--md-default-bg-color); + font: inherit; + font-weight: 650; + cursor: pointer; +} + +.firmware-picker-form-actions button:disabled { + opacity: 0.55; + cursor: wait; +} + .firmware-picker-status { min-height: 1.5rem; margin: 0.75rem 0; diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 2c46e7f65..5fb98990f 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -1,8 +1,7 @@ # Firmware picker -Choose the exact hardware first. The remaining menus then show only firmware -roles and features that were actually built for that hardware in the current -release set. +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 picker reads public release metadata from GitHub. It does not upload device information. Hardware names, target names, and download links come directly @@ -15,67 +14,76 @@ from the published firmware assets.

For a new installation, choose the exact board and role, prefer a FULL / complete profile when it is available, and select - USB first install / recovery. Narrower profiles remain - available when their reduced transport or feature set is intentional. + Erase & fresh install (merged .bin). Narrower profiles + remain available when their reduced transport or feature set is intentional.

+

+ Pick in any order. Use Any to clear one choice, or clear + everything with the button below. +

+
- + - +
+ Install operation +
+ Loading install choices... +
+
- +
+ Firmware role +
+ Loading roles... +
+
- +
+ Logging / MQTT +
+ Loading logging choices... +
+
- +
+ OTA capability +
+ Loading OTA choices... +
+
- + - +
+ Feature profile +
+ Loading profiles... +
+
- + + +
+ +
@@ -145,11 +153,11 @@ and inactive partitions. | File | Use | | --- | --- | -| -merged.bin | ESP32 first install, recovery, role migration, or partition-profile change over USB | -| Non-merged .bin | Same-board, same-role, same-partition application update | -| .zip | Native nRF52 Serial DFU package; it is not an extra archive | -| .uf2 | UF2 bootloader drag-and-drop installation | -| .hex | Full wired programmer or recovery flash | +| -merged.bin | Erase/fresh install, recovery, role migration, or partition-profile change on ESP32 over USB | +| Non-merged .bin | Update an existing same-board, same-role, same-partition installation | +| .zip | Native nRF52 Serial DFU update package; it is not an extra archive | +| .uf2 | UF2 bootloader drag-and-drop install or update | +| .hex | Erase/recovery flash with a supported wired programmer | Never send a merged ESP32 image through browser OTA or LoRa OTA. Back up the node configuration and verify every filename suffix before flashing. diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index c7a40ecaf..ffdcb4f32 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -176,6 +176,55 @@ const matches = catalog.profiles.filter(function (item) { assert.strictEqual(matches.length, 1); assert.strictEqual(matches[0].target, "Station_G2_repeater-full-logging"); +assert.deepStrictEqual( + picker.FACET_FIELDS, + [ + "hardware", + "role", + "logging", + "ota", + "mode", + "feature", + "variant", + "install", + ] +); +assert.strictEqual( + picker.INSTALL_LABELS["merged-bin"], + "Erase & fresh install (merged .bin)" +); +assert.strictEqual( + picker.INSTALL_LABELS.bin, + "Update existing install (.bin)" +); +assert(picker.profileMatchesFacets(wio, { install: "hex" })); +assert(!picker.profileMatchesFacets(wio, { install: "merged-bin" })); +assert.deepStrictEqual( + picker.facetValues(catalog.profiles, { install: "hex" }, "hardware"), + ["wio-e5"] +); +assert.deepStrictEqual( + picker.facetValues(catalog.profiles, { hardware: "wio-e5" }, "install"), + ["hex"] +); +assert.deepStrictEqual( + picker.facetValues(catalog.profiles, { logging: "wifi" }, "hardware"), + ["Station_G2"] +); +assert.deepStrictEqual( + picker.facetValues( + catalog.profiles, + { + install: "merged-bin", + feature: "full", + logging: "usb", + role: "repeater", + }, + "hardware" + ), + ["Station_G2"] +); + assert.deepStrictEqual( picker.uniqueValues(catalog.profiles, "hardware").sort(), ["Heltec_t096", "ProMicro", "RAK_4631", "Station_G2", "wio-e5"].sort()