diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index f139b048..21183edd 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -11,6 +11,7 @@ ]); const FILTER_FIELDS = Object.freeze([ + "hardwareFamily", "hardware", "role", "logging", @@ -308,6 +309,57 @@ }; } + function hardwareFamilyFor(hardware, hardwareNames) { + const value = String(hardware || ""); + const lowerValue = value.toLowerCase(); + const parents = (hardwareNames || []).filter(function (candidate) { + const parent = String(candidate || ""); + if (!parent || parent === value) return false; + const lowerParent = parent.toLowerCase(); + return lowerValue.startsWith(lowerParent + "_") || + lowerValue.startsWith(lowerParent + "-"); + }).sort(function (a, b) { + return a.length - b.length || a.localeCompare(b); + }); + return parents[0] || value; + } + + function humanizeHardwareVariant(hardware, family) { + const value = String(hardware || ""); + const parent = String(family || ""); + if (!parent || value === parent) return "Base / standard"; + + const knownLabels = { + "heltec_v4_2_v4_3": "V4.2 / V4.3", + "heltec_v4_3": "V4.3", + "heltec_v4_3_expansionkit_tft": "V4.3 + expansion kit + TFT", + "heltec_v4_3_tft": "V4.3 + TFT", + "heltec_v4_expansionkit": "Expansion kit", + "heltec_v4_expansionkit_tft": "Expansion kit + TFT", + "heltec_v4_r8": "R8", + "heltec_v4_r8_tft": "R8 + TFT", + "heltec_v4_tft": "TFT", + "Station_G2_logging": "RX-boosted radio profile", + "Station_G3_ESP32_logging": "Alternate radio profile", + "wio-e5-mini": "Mini", + }; + if (knownLabels[value]) return knownLabels[value]; + + let suffix = value.slice(parent.length).replace(/^[_-]+/, ""); + const suffixLabels = { + logging: "Logging hardware profile", + without_display: "Without display", + sdcard: "SD card", + "096": "0.96-inch display", + "096_rotated": "0.96-inch rotated display", + Lite_non_shell: "Lite (no shell)", + }; + if (suffixLabels[suffix]) return suffixLabels[suffix]; + suffix = humanizeHardware(suffix).replace(/\bTft\b/gi, "TFT") + .replace(/\bWio\b/gi, "WIO"); + return suffix || humanizeHardware(value); + } + function canonicalAsset(files, installKind) { return (Array.isArray(files) ? files : []).filter(function (file) { return file.installKind === installKind; @@ -358,6 +410,16 @@ }); }); + const hardwareNames = Array.from(new Set(profiles.map(function (profile) { + return profile.hardware; + }))); + profiles.forEach(function (profile) { + profile.hardwareFamily = hardwareFamilyFor( + profile.hardware, + hardwareNames + ); + }); + return { releaseSet: releaseSet, rows: firmwareRows, @@ -374,8 +436,11 @@ } function profileMatchesFacets(profile, filters, ignoredField) { + const ignoredFields = new Set( + Array.isArray(ignoredField) ? ignoredField : [ignoredField] + ); return FACET_FIELDS.every(function (field) { - if (field === ignoredField) return true; + if (ignoredFields.has(field)) return true; const value = filters && filters[field]; if (!value) return true; if (field === "install") { @@ -385,9 +450,10 @@ }); } - function facetValues(profiles, filters, field) { + function facetValues(profiles, filters, field, additionallyIgnoredFields) { + const ignoredFields = [field].concat(additionallyIgnoredFields || []); const compatible = (profiles || []).filter(function (profile) { - return profileMatchesFacets(profile, filters, field); + return profileMatchesFacets(profile, filters, ignoredFields); }); const values = field === "install" ? compatible.flatMap(function (profile) { @@ -428,6 +494,7 @@ } function labelFor(field, value) { + if (field === "hardwareFamily") return humanizeHardware(value); if (field === "hardware") return humanizeHardware(value); if (field === "role") return ROLE_LABELS[value] || value; if (field === "logging") return LOGGING_LABELS[value] || value; @@ -604,7 +671,8 @@ function setSelectOptions(select, field, values, selected) { select.replaceChildren(); const placeholders = { - hardware: "Any hardware", + hardwareFamily: "Any hardware", + hardware: "Choose a hardware variant", mode: "Any connection / mode", variant: "Any hardware/profile variant", }; @@ -614,7 +682,10 @@ values.slice().sort(function (a, b) { return optionSort(field, a, b); }).forEach(function (value) { - const option = createElement("option", labelFor(field, value)); + const label = field === "hardware" + ? humanizeHardwareVariant(value, filters.hardwareFamily) + : labelFor(field, value); + const option = createElement("option", label); option.value = value; select.appendChild(option); }); @@ -658,18 +729,35 @@ } } - function stabilizeSingleChoices() { + function valuesForField(field) { + return facetValues( + catalog.profiles, + filters, + field, + field === "hardwareFamily" ? ["hardware"] : [] + ); + } + + function stabilizeFilters() { 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 (field === "hardware" && !filters.hardwareFamily) { + if (filters.hardware) { + filters.hardware = ""; + changed = true; + } + return; + } + const values = valuesForField(field); if (filters[field] && !values.includes(filters[field])) { filters[field] = ""; changed = true; - } else if (!filters[field] && values.length === 1) { + } else if (field === "hardware" && filters.hardwareFamily && + !filters[field] && values.length === 1) { filters[field] = values[0]; changed = true; } @@ -678,13 +766,16 @@ } function refreshFacets() { - stabilizeSingleChoices(); + stabilizeFilters(); FACET_FIELDS.forEach(function (field) { - setControlOptions( - field, - facetValues(catalog.profiles, filters, field) - ); + setControlOptions(field, valuesForField(field)); }); + const hardwareVariants = valuesForField("hardware"); + const hardwareVariantControl = root.querySelector( + '[data-role="hardware-variant-control"]' + ); + hardwareVariantControl.hidden = !filters.hardwareFamily || + hardwareVariants.length <= 1; render(); } @@ -778,6 +869,7 @@ const target = event.target; const field = target.dataset.choiceField || target.dataset.field; if (!FACET_FIELDS.includes(field)) return; + if (field === "hardwareFamily") filters.hardware = ""; filters[field] = target.value; refreshFacets(); }); @@ -851,6 +943,8 @@ flattenReleaseAssets: flattenReleaseAssets, parseFirmwareAsset: parseFirmwareAsset, parseTargetProfile: parseTargetProfile, + hardwareFamilyFor: hardwareFamilyFor, + humanizeHardwareVariant: humanizeHardwareVariant, buildCatalog: buildCatalog, profileMatches: profileMatches, profileMatchesFacets: profileMatchesFacets, diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 5fb98990..18e08b72 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -26,12 +26,19 @@ from the published firmware assets.
- -
+ +
Install operation
@@ -75,7 +82,7 @@ from the published firmware assets.
- + @@ -175,8 +182,9 @@ and select the hardware-matched HEX, Serial DFU ZIP, or bootloader-update UF2. ## Hardware and variant names -The hardware menu preserves meaningful board suffixes such as display type, -radio chip, PA/FEM layout, pin map, and external-flash variant. Later menus -expose build variants such as FEM on/off, power saving, serial port, or -no-external-sensors when those choices exist. Do not substitute a similarly -named target. +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 build choices such as FEM on/off, +power saving, serial port, or no-external-sensors. Do not substitute a +similarly named target. diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index ffdcb4f3..fab4da6d 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -134,6 +134,7 @@ assert.strictEqual(fullUsbLogging.mode, "usb"); assert.strictEqual(fullUsbLogging.variant, "default"); const standardRepeater = profile("Station_G2_repeater"); +assert.strictEqual(standardRepeater.hardwareFamily, "Station_G2"); assert.strictEqual(standardRepeater.role, "repeater"); assert.strictEqual(standardRepeater.logging, "none"); assert.strictEqual(standardRepeater.ota, "none"); @@ -179,6 +180,7 @@ assert.strictEqual(matches[0].target, "Station_G2_repeater-full-logging"); assert.deepStrictEqual( picker.FACET_FIELDS, [ + "hardwareFamily", "hardware", "role", "logging", @@ -189,6 +191,68 @@ assert.deepStrictEqual( "install", ] ); +const hardwareNames = [ + "heltec_v4", + "heltec_v4_3", + "heltec_v4_3_tft", + "heltec_v4_r8", + "wio-e5", + "wio-e5-mini", +]; +assert.strictEqual( + picker.hardwareFamilyFor("heltec_v4_3_tft", hardwareNames), + "heltec_v4" +); +assert.strictEqual( + picker.hardwareFamilyFor("wio-e5-mini", hardwareNames), + "wio-e5" +); +assert.strictEqual( + picker.humanizeHardwareVariant("heltec_v4", "heltec_v4"), + "Base / standard" +); +assert.strictEqual( + picker.humanizeHardwareVariant("heltec_v4_3_tft", "heltec_v4"), + "V4.3 + TFT" +); +assert.strictEqual( + picker.humanizeHardwareVariant("Station_G2_logging", "Station_G2"), + "RX-boosted radio profile" +); +const groupedHardwareProfiles = [ + { + hardwareFamily: "heltec_v4", + hardware: "heltec_v4", + installKinds: ["bin"], + }, + { + hardwareFamily: "heltec_v4", + hardware: "heltec_v4_3_tft", + installKinds: ["bin"], + }, + { + hardwareFamily: "wio-e5", + hardware: "wio-e5", + installKinds: ["hex"], + }, +]; +assert.deepStrictEqual( + picker.facetValues( + groupedHardwareProfiles, + { hardwareFamily: "heltec_v4", hardware: "heltec_v4_3_tft" }, + "hardwareFamily", + ["hardware"] + ).sort(), + ["heltec_v4", "wio-e5"] +); +assert.deepStrictEqual( + picker.facetValues( + groupedHardwareProfiles, + { hardwareFamily: "heltec_v4" }, + "hardware" + ).sort(), + ["heltec_v4", "heltec_v4_3_tft"] +); assert.strictEqual( picker.INSTALL_LABELS["merged-bin"], "Erase & fresh install (merged .bin)"