Combine firmware picker profiles and replace extra dropdowns

This commit is contained in:
mikecarper committed 2026-09-29 16:30:50 -07:00
1 parent 24458b0214
commit 9c9121c391
5 files changed
+272 -76

No files matched your search

+154 -32
View File
@@ -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,
+8 -1
View File
@@ -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;
+34 -41
View File
@@ -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.
</select>
</div>
<div class="firmware-picker-control firmware-picker-select-control" data-role="hardware-variant-control" hidden>
<label for="firmware-picker-hardware">Hardware variant</label>
<select id="firmware-picker-hardware" data-field="hardware" disabled>
<option value="">Choose hardware first</option>
</select>
</div>
<fieldset class="firmware-picker-control firmware-picker-radio-control firmware-picker-wide" data-radio-field="hardware" data-role="hardware-variant-control" disabled hidden>
<legend>Hardware variant</legend>
<div class="firmware-picker-radio-options" data-field="hardware">
Choose hardware first
</div>
</fieldset>
<fieldset class="firmware-picker-control firmware-picker-radio-control firmware-picker-wide" data-radio-field="install" disabled>
<legend>Install operation</legend>
@@ -94,34 +97,21 @@ from the published firmware assets.
</div>
</fieldset>
<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...
<fieldset class="firmware-picker-control firmware-picker-radio-control firmware-picker-wide" data-radio-field="firmwareProfile" aria-describedby="firmware-picker-profile-help" disabled>
<legend>Firmware profile</legend>
<p id="firmware-picker-profile-help" class="firmware-picker-control-help">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.</p>
<div class="firmware-picker-radio-options" data-field="firmwareProfile">
Loading firmware profiles...
</div>
</fieldset>
<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>
</div>
<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...
<fieldset class="firmware-picker-control firmware-picker-radio-control firmware-picker-wide" data-radio-field="mode" disabled>
<legend>Connection / bridge mode</legend>
<div class="firmware-picker-radio-options" data-field="mode">
Loading modes...
</div>
</fieldset>
<div class="firmware-picker-control firmware-picker-select-control">
<label for="firmware-picker-variant">Firmware variant</label>
<select id="firmware-picker-variant" data-field="variant" disabled>
<option value="">Loading variants...</option>
</select>
</div>
<div class="firmware-picker-form-actions firmware-picker-wide">
<button type="reset" data-action="clear" disabled>Clear all choices</button>
<button type="button" data-action="copy-link" disabled>Copy link to settings</button>
@@ -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
+2 -2
View File
@@ -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
+74
View File
@@ -1291,3 +1291,77 @@ const escapedShare = picker.selectionFromUrl('https://example.com/?role=%3Cscrip
assert.deepStrictEqual(escapedShare.filters, {});
assert.deepStrictEqual(escapedShare.unavailable, ['role=<script>']);
console.log('shareable picker URL tests passed');
// One choice carries OTA, feature set and sensor/storage tradeoffs together.
// Every existing profile remains reachable and resolves to its original files.
for (const candidateCatalog of [catalog, controlled, currentCatalog, chipCatalog, withMigrations]) {
for (const profile of candidateCatalog.profiles) {
const context = {
hardwareFamily: profile.hardwareFamily, hardware: profile.hardware,
role: profile.role, mode: profile.mode, install: profile.installKinds[0],
};
const choices = picker.firmwareProfileChoices(candidateCatalog.profiles, context);
const value = picker.firmwareProfileValue(profile);
const choice = choices.find(item => item.value === value);
assert(choice, profile.target);
assert.deepStrictEqual(choice.filters, {
ota: profile.ota, feature: profile.feature, variant: profile.variant,
});
assert.deepStrictEqual(picker.firmwareProfileFilters(value), choice.filters);
const chosen = Object.assign({}, context, choice.filters);
const matches = candidateCatalog.profiles.filter(item => picker.profileMatchesFacets(item, chosen));
assert(matches.includes(profile), profile.target);
assert(picker.resolveProfileAssets(matches, context.install)
.some(item => item.profile === profile), profile.target);
const linked = picker.selectionFromUrl(picker.selectionUrl(shareBase, chosen, true), candidateCatalog.profiles);
assert.deepStrictEqual(linked.unavailable, [], profile.target);
assert.strictEqual(picker.firmwareProfileValue(linked.filters), value, profile.target);
assert.strictEqual(new Set(choices.map(item => item.value)).size, choices.length);
}
}
const compactChoice = picker.firmwareProfileChoices(catalog.profiles, {
hardware: 'Station_G2', role: 'repeater',
}).find(item => item.filters.variant === 'no-external-sensors');
assert(compactChoice);
assert.strictEqual(compactChoice.label, 'Receives LoRa OTA - Reduced optional sensors');
assert.strictEqual(compactChoice.filters.ota, 'lora-receiver');
assert(!compactChoice.label.includes('No I2C'));
const fullChoice = picker.firmwareProfileChoices(catalog.profiles, {
hardware: 'Station_G2', role: 'companion',
})[0];
assert.strictEqual(fullChoice.label, 'Full - LoRa OTA source only');
assert.strictEqual(fullChoice.filters.ota, 'lora-source');
assert.strictEqual(fullChoice.filters.feature, 'full');
assert.strictEqual(picker.firmwareProfileLabel({ota: 'lora-receiver', feature: 'full', variant: 'default'}),
'Full - Receives LoRa OTA');
assert(picker.firmwareProfileLabel({ota: 'lora-receiver', feature: 'standard', variant: 'w25q16'})
.includes('External storage board (W25Q16)'));
// A combined profile can be replaced in one click even when its former
// OTA/feature/variant facets have narrowed the catalog to a different image.
const replacementChoices = picker.firmwareProfileChoices(catalog.profiles, {
hardware: 'Station_G2', role: 'repeater',
ota: 'none', feature: 'standard', variant: 'default',
});
assert(replacementChoices.some(item => item.value === compactChoice.value));
assert.deepStrictEqual(picker.firmwareProfileFilters(''), {ota: '', feature: '', variant: ''});
assert.strictEqual(picker.firmwareProfileValue({}), '');
for (const invalid of ['invalid', '{}', '["none"]', '["none",false,"default"]']) {
assert.strictEqual(picker.firmwareProfileFilters(invalid), null);
}
// Legacy partial links keep a visible selection without guessing a sensor
// recipe, Full layout or OTA capability that the link did not request.
for (const partial of [{ota: 'lora-receiver'}, {feature: 'standard'}, {variant: 'no-external-sensors'}]) {
const context = Object.assign({hardware: 'Station_G2', role: 'repeater'}, partial);
const value = picker.firmwareProfileValue(context);
const selected = picker.firmwareProfileChoices(catalog.profiles, context).find(item => item.value === value);
assert(selected);
assert(selected.label.includes('other profile choices open'));
for (const field of picker.PROFILE_FIELDS) assert.strictEqual(selected.filters[field], partial[field] || '');
}
assert(!picker.firmwareProfileChoices(catalog.profiles, {
hardware: 'Station_G2', role: 'repeater', variant: 'missing',
}).some(item => item.filters.variant === 'missing'));
console.log('combined firmware profile choices and legacy links passed');