Make firmware picker order independent

This commit is contained in:
mikecarper committed 2026-08-23 01:45:55 -07:00
1 parent c22da32964
commit 63ab4a1690
4 files changed
+354 -153

No files matched your search

+148 -95
View File
@@ -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,
+95 -4
View File
@@ -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;
+62 -54
View File
@@ -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.
<p>
For a new installation, choose the exact board and role, prefer a
<strong>FULL / complete profile</strong> when it is available, and select
<strong>USB first install / recovery</strong>. Narrower profiles remain
available when their reduced transport or feature set is intentional.
<strong>Erase &amp; fresh install (merged .bin)</strong>. Narrower profiles
remain available when their reduced transport or feature set is intentional.
</p>
</div>
<p class="firmware-picker-order-note">
Pick in any order. Use <strong>Any</strong> to clear one choice, or clear
everything with the button below.
</p>
<form class="firmware-picker-form" data-role="form">
<label>
1. Hardware
<select data-field="hardware" disabled>
<div class="firmware-picker-control firmware-picker-select-control">
<label for="firmware-picker-hardware">Hardware</label>
<select id="firmware-picker-hardware" data-field="hardware" disabled>
<option value="">Loading hardware...</option>
</select>
</label>
</div>
<label>
2. Firmware role
<select data-field="role" disabled>
<option value="">Choose hardware first</option>
</select>
</label>
<fieldset class="firmware-picker-control firmware-picker-radio-control firmware-picker-wide" data-radio-field="install" disabled>
<legend>Install operation</legend>
<div class="firmware-picker-radio-options" data-field="install">
Loading install choices...
</div>
</fieldset>
<label>
3. Logging / MQTT
<select data-field="logging" disabled>
<option value="">Choose role first</option>
</select>
</label>
<fieldset class="firmware-picker-control firmware-picker-radio-control" data-radio-field="role" disabled>
<legend>Firmware role</legend>
<div class="firmware-picker-radio-options" data-field="role">
Loading roles...
</div>
</fieldset>
<label>
4. OTA capability
<select data-field="ota" disabled>
<option value="">Choose earlier options first</option>
</select>
</label>
<fieldset class="firmware-picker-control firmware-picker-radio-control" data-radio-field="logging" disabled>
<legend>Logging / MQTT</legend>
<div class="firmware-picker-radio-options" data-field="logging">
Loading logging choices...
</div>
</fieldset>
<label>
5. Connection / bridge mode
<select data-field="mode" disabled>
<option value="">Choose earlier options first</option>
</select>
</label>
<fieldset class="firmware-picker-control firmware-picker-radio-control" data-radio-field="ota" disabled>
<legend>OTA capability</legend>
<div class="firmware-picker-radio-options" data-field="ota">
Loading OTA choices...
</div>
</fieldset>
<label>
6. Feature profile
<select data-field="feature" disabled>
<option value="">Choose earlier options first</option>
<div class="firmware-picker-control firmware-picker-select-control">
<label for="firmware-picker-mode">Connection / bridge mode</label>
<select id="firmware-picker-mode" data-field="mode" disabled>
<option value="">Loading modes...</option>
</select>
</label>
</div>
<label>
7. Hardware/profile variant
<select data-field="variant" disabled>
<option value="">Choose earlier options first</option>
</select>
</label>
<fieldset class="firmware-picker-control firmware-picker-radio-control" data-radio-field="feature" disabled>
<legend>Feature profile</legend>
<div class="firmware-picker-radio-options" data-field="feature">
Loading profiles...
</div>
</fieldset>
<label>
8. Installation method
<select data-field="install" disabled>
<option value="">Choose firmware options first</option>
<div class="firmware-picker-control firmware-picker-select-control">
<label for="firmware-picker-variant">Hardware/profile variant</label>
<select id="firmware-picker-variant" data-field="variant" disabled>
<option value="">Loading variants...</option>
</select>
</label>
</div>
<div class="firmware-picker-form-actions firmware-picker-wide">
<button type="reset" data-action="clear" disabled>Clear all choices</button>
</div>
</form>
<div class="firmware-picker-status" data-role="status" aria-live="polite">
@@ -145,11 +153,11 @@ and inactive partitions.
| File | Use |
| --- | --- |
| <code>-merged.bin</code> | ESP32 first install, recovery, role migration, or partition-profile change over USB |
| Non-merged <code>.bin</code> | Same-board, same-role, same-partition application update |
| <code>.zip</code> | Native nRF52 Serial DFU package; it is not an extra archive |
| <code>.uf2</code> | UF2 bootloader drag-and-drop installation |
| <code>.hex</code> | Full wired programmer or recovery flash |
| <code>-merged.bin</code> | Erase/fresh install, recovery, role migration, or partition-profile change on ESP32 over USB |
| Non-merged <code>.bin</code> | Update an existing same-board, same-role, same-partition installation |
| <code>.zip</code> | Native nRF52 Serial DFU update package; it is not an extra archive |
| <code>.uf2</code> | UF2 bootloader drag-and-drop install or update |
| <code>.hex</code> | 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.
+49
View File
@@ -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()