From 9202fc2a96802d08dfddcf8716e0ac38f0ab8606 Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 6 Oct 2026 10:46:11 -0700 Subject: [PATCH] Simplify firmware selection and canonical build menus --- build_legacy.sh | 58 ++++- docs/_javascript/firmware_picker.js | 158 +++++++++++-- docs/cli_build_matrix.md | 28 ++- docs/firmware_picker.md | 32 +-- .../firmware_choice_followup_2026-10-06.md | 209 +++++++++++++++++ ..._choice_followup_selection_2026-10-06.json | 180 +++++++++++++++ mkdocs.yml | 2 +- test/test_build_profiles.sh | 45 ++++ test/test_firmware_picker.js | 215 ++++++++++++++++++ 9 files changed, 869 insertions(+), 58 deletions(-) create mode 100644 docs/research/firmware_choice_followup_2026-10-06.md create mode 100644 docs/research/firmware_choice_followup_selection_2026-10-06.json diff --git a/build_legacy.sh b/build_legacy.sh index cdfcab42a..47767a2e9 100755 --- a/build_legacy.sh +++ b/build_legacy.sh @@ -1473,15 +1473,11 @@ get_variants_for_board() { local board_family=$1 local env - for env in "${SUPPORTED_PIO_ENVS[@]}"; do - if ! is_supported_build_env "$env"; then - continue - fi - + while IFS= read -r env; do if [ "$(get_board_family_for_env "$env")" == "$board_family" ]; then echo "$env" fi - done | sort_lines_case_insensitive + done < <(get_interactive_build_targets) | sort_lines_case_insensitive } prompt_for_variant_for_board() { @@ -1555,17 +1551,13 @@ prompt_for_board_target() { exit 1 fi - for env in "${SUPPORTED_PIO_ENVS[@]}"; do - if ! is_supported_build_env "$env"; then - continue - fi - + while IFS= read -r env; do board=$(get_board_family_for_env "$env") if [ -z "${seen_boards[$board]}" ]; then seen_boards["$board"]=1 boards+=("$board") fi - done + done < <(get_interactive_build_targets) mapfile -t boards < <(printf '%s\n' "${boards[@]}" | sort_lines_case_insensitive) @@ -6548,6 +6540,48 @@ select_ordinary_full_targets() { done } +# The board menu is for ordinary installations, so use the same Full choices +# as the release matrix instead of asking users to select a transport/default +# alias or an older partition recipe. Exact names remain accepted by direct +# build commands and migration tooling. Preserve other platforms' qualified +# hardware/storage contracts and KISS, which is a separate host-modem role. +get_interactive_build_targets() { + local target key priority pair_target + local -a full_targets=() other_keys=() + local -A other_choice=() other_priority=() + for target in "${SUPPORTED_PIO_ENVS[@]}"; do + is_supported_build_env "$target" || continue + is_redundant_bulk_build_target "$target" && continue + if [ "${PIO_ENV_PLATFORM_BY_NAME[$target]:-}" = ESP32_PLATFORM ] \ + && supports_esp32_full_build "$target"; then + full_targets+=("$target") + else + key=$target + priority=0 + # A normal nRF52 target and its generated reduced alias can both build + # the exact same Full/Reduced pair. Offer that operation once, preferring + # the ordinary board/role name. The existing resolver preserves distinct + # deployed IDs and external-storage contracts, so those remain choices. + if pair_target=$(get_nrf52_sensor_ota_pair_target "$target"); then + key=$pair_target + if ! is_lora_ota_only_target "$target"; then priority=1; fi + fi + if [ -z "${other_choice[$key]+x}" ]; then + other_keys+=("$key") + other_choice[$key]=$target + other_priority[$key]=$priority + elif [ "$priority" -gt "${other_priority[$key]}" ]; then + other_choice[$key]=$target + other_priority[$key]=$priority + fi + fi + done + for key in "${other_keys[@]}"; do + printf '%s\n' "${other_choice[$key]}" + done + select_ordinary_full_targets "${full_targets[@]}" +} + has_esp32_full_profile() { local target local candidate="" diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 33ec970c9..c49ff32fc 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -1132,6 +1132,67 @@ }); } + function selectionRequirements(profiles, filters) { + const chosen = filters || {}; + const hiddenFields = []; + let installKind = chosen.install || ""; + // Board and role are deliberate choices. Once known, do not ask for + // settings that leave every compatible build unchanged. + if (chosen.hardware && chosen.role) { + const context = Object.assign({}, chosen); + // Classify against board/role, not mutually narrowing settings. A real + // alternative must stay visible so a selected filter can be cleared. + ["logging", "mode"].concat(PROFILE_FIELDS).forEach(function (field) { + delete context[field]; + }); + const boardContext = Object.assign({}, context); + delete boardContext.install; + const boardProfiles = (profiles || []).filter(function (profile) { + return profileMatchesFacets(profile, boardContext); + }); + function supportsSelection(field) { + const selection = field === "firmwareProfile" + ? PROFILE_FIELDS.reduce(function (parts, part) { + if (chosen[part]) parts[part] = chosen[part]; + return parts; + }, {}) : chosen[field] ? { [field]: chosen[field] } : {}; + return boardProfiles.some(function (profile) { + return profileMatchesFacets(profile, selection); + }); + } + // Invalid old links need a visible Any control. Do not conceal their + // requested value or replace it with another install format. + if (!supportsSelection("install")) delete context.install; + ["logging", "firmwareProfile", "mode"].forEach(function (field) { + const options = field === "firmwareProfile" + ? firmwareProfileChoices(profiles, context) + : facetValues(profiles, context, field).map(function (value) { + return { value: value }; + }); + if (supportsSelection(field) && + choicesUseSameFirmware(profiles, context, field, options)) { + hiddenFields.push(field); + } + }); + const installs = facetValues(profiles, context, "install"); + if (installs.length === 1 && supportsSelection("install")) { + hiddenFields.push("install"); + installKind = installs[0]; + } + } + return { + hiddenFields: hiddenFields, + installKind: installKind, + missingFields: CHOICE_FIELDS.filter(function (field) { + if (field === "chipFamily" || hiddenFields.includes(field)) return false; + if (field === "firmwareProfile") return !PROFILE_FIELDS.every(function (part) { + return chosen[part]; + }); + return !chosen[field]; + }), + }; + } + function selectionUrl(url, filters, automaticChipFamily) { const result = new URL(url); FACET_FIELDS.forEach(function (field) { @@ -1516,6 +1577,7 @@ } return { label: LOGGING_LABELS[mode], commands: commands, + selection: { logging: mode }, text: full && info && info.mqtt ? (mode === "wifi" || mode === "both" ? "In WebConfig, enable the desired MQTT broker presets/settings and save." @@ -1655,7 +1717,40 @@ return sections; } - function renderRuntimeDirections(card, profile, selection) { + function runtimePanelDirections(profile, selection, runtimeFields) { + const chosen = Object.assign({}, selection); + const moved = runtimeFields || []; + if (moved.includes("logging")) delete chosen.logging; + if (moved.includes("mode") && !chosen.mode && + profileFieldValues(profile, "mode").includes("standard")) { + chosen.mode = "standard"; + } + const sections = runtimeDirections(profile, chosen); + sections.forEach(function (item) { + if (moved.includes("logging") && item.actions.some(function (action) { + return action.selection && action.selection.logging; + })) { + const mode = selection && selection.logging || "none"; + const selected = item.actions.findIndex(function (action) { + return action.selection && action.selection.logging === mode; + }); + item.selectedIndex = selected < 0 ? 0 : selected; + item.title = "Logging / MQTT output"; + } + if (moved.includes("mode") && (/^RS232 bridge/.test(item.title) || + item.title === "ESP-NOW bridge")) { + const on = /^RS232 bridge/.test(item.title) + ? chosen.mode === "rs232" : chosen.mode === "espnow"; + item.selectedIndex = item.actions.findIndex(function (action) { + return action.label === (on ? "On" : "Off"); + }); + } + }); + return sections; + } + + function renderRuntimeDirections(card, profile, selection, options) { + const settings = options || {}; const panel = createElement("section"); panel.className = "firmware-picker-runtime"; panel.appendChild(createElement("h4", "Restore your settings after flashing")); @@ -1675,7 +1770,7 @@ : "Use board and ver to identify the node."))); } if (!profile.controls) panel.appendChild(createElement("p", "Additional hardware controls have not been verified for this exact release image. Use the role guide for those settings.")); - runtimeDirections(profile, selection).forEach(function (item, index) { + runtimePanelDirections(profile, selection, settings.runtimeFields).forEach(function (item, index) { const details = createElement("details"); details.open = index === 0; details.appendChild(createElement("summary", item.title)); @@ -1685,13 +1780,13 @@ fieldset.appendChild(createElement("legend", "Show steps for " + item.title)); const choices = createElement("div"); choices.className = "firmware-picker-radio-options"; + let actionIndex = item.selectedIndex >= 0 ? item.selectedIndex : 0; renderRadioChoices(choices, item.actions.map(function (action, i) { return { value: String(i), label: action.label }; - }), "0", "firmware-picker-runtime-" + (++runtimeGroupCount)); + }), String(actionIndex), "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[actionIndex]; @@ -1717,6 +1812,11 @@ choices.addEventListener("change", function (event) { actionIndex = Number(event.target.value) || 0; show(); + const action = item.actions[actionIndex]; + if (action.selection && settings.onSelectionChange && + (settings.runtimeFields || []).includes("logging")) { + settings.onSelectionChange(action.selection); + } }); show(); details.appendChild(output); @@ -1733,7 +1833,7 @@ }); } - function renderProfileCard(container, profile, asset, installKind, selection) { + function renderProfileCard(container, profile, asset, installKind, selection, options) { const card = createElement("article"); card.className = "firmware-picker-card"; card.appendChild(createElement( @@ -1752,7 +1852,8 @@ return labelFor("mode", mode); }).join(" / "), ], - ["Logging", labelFor("logging", selection && selection.logging || profile.logging)], + ["Logging", labelFor("logging", options && (options.runtimeFields || []).includes("logging") + ? profile.logging : selection && selection.logging || profile.logging)], ["Firmware profile", firmwareProfileLabel(profile)], ["Install operation", labelFor("install", installKind)], ["File", asset.name], @@ -1794,7 +1895,7 @@ }); steps.appendChild(list); card.appendChild(steps); - renderRuntimeDirections(card, profile, selection); + renderRuntimeDirections(card, profile, selection, options); container.appendChild(card); } @@ -2031,6 +2132,11 @@ firmwareProfileValue(filters), groupPrefix + "-firmwareProfile", "firmwareProfile"); profileControl.closest("fieldset").disabled = profileChoices.length === 0; setRefinementHint(profileControl, "firmwareProfile", profileChoices); + const requirements = selectionRequirements(catalog.profiles, filters); + ["logging", "firmwareProfile", "mode", "install"].forEach(function (field) { + const control = controls[field]; + if (control) control.closest("fieldset").hidden = requirements.hiddenFields.includes(field); + }); const hardwareVariants = valuesForField("hardware"); const hardwareVariantControl = root.querySelector( '[data-role="hardware-variant-control"]' @@ -2056,26 +2162,32 @@ result.hidden = true; missing.hidden = true; resultList.replaceChildren(); - 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 requirements = selectionRequirements(catalog.profiles, filters); + const missingFields = requirements.missingFields; + const cardOptions = { + runtimeFields: requirements.hiddenFields, + onSelectionChange: function (selection) { + Object.keys(selection).forEach(function (field) { + filters[field] = selection[field]; + }); + updateSelectionUrl(); + }, + }; const matches = matchingProfiles(); if (feedback) { - const progress = selectionProgress(catalog.profiles, filters); + const progress = selectionProgress(catalog.profiles, + Object.assign({}, filters, { install: requirements.installKind })); feedback.hidden = false; feedback.dataset.state = progress.state; feedback.querySelector('[data-role="filter-count"]').textContent = progress.title; - feedback.querySelector('[data-role="filter-note"]').textContent = progress.note; + feedback.querySelector('[data-role="filter-note"]').textContent = missingFields.length + ? progress.note : "Matching files and install instructions are shown below."; } if (resultLink) resultLink.hidden = true; if (missingFields.length) { if (matches.length === 1) { - status.textContent = "One firmware build remains. Confirm the hardware and install operation. " + - "Choices marked 'Same firmware' do not narrow the result further."; + status.textContent = "One firmware build remains. Confirm the hardware, role and any remaining install choice."; } else if (missingFields.length === CHOICE_FIELDS.length - 1 && !filters.chipFamily) { status.textContent = "Pick options in any order. " + matches.length + " compatible configurations are available."; @@ -2086,7 +2198,7 @@ (missingFields.length === 1 ? "" : "s") + " in any order."; } if (shouldShowCandidateResults(matches)) { - const candidates = resolveProfileAssets(matches, filters.install); + const candidates = resolveProfileAssets(matches, requirements.installKind); if (candidates.length) { status.textContent += " Possible files are shown below."; resultEyebrow.textContent = "Narrowed firmware candidates"; @@ -2100,7 +2212,8 @@ entry.profile, entry.asset, entry.installKind, - filters + filters, + cardOptions ); }); result.hidden = false; @@ -2110,7 +2223,7 @@ return; } - const installKind = filters.install; + const installKind = requirements.installKind; const resolved = resolveProfileAssets(matches, installKind); if (!resolved.length) { missing.hidden = false; @@ -2132,7 +2245,8 @@ entry.profile, entry.asset, entry.installKind, - filters + filters, + cardOptions ); }); result.hidden = false; @@ -2380,12 +2494,14 @@ firmwareProfileChoices: firmwareProfileChoices, selectionProgress: selectionProgress, choicesUseSameFirmware: choicesUseSameFirmware, + selectionRequirements: selectionRequirements, selectionUrl: selectionUrl, selectionFromUrl: selectionFromUrl, canonicalAsset: canonicalAsset, resolveProfileAssets: resolveProfileAssets, shouldShowCandidateResults: shouldShowCandidateResults, runtimeDirections: runtimeDirections, + runtimePanelDirections: runtimePanelDirections, renderRuntimeDirections: renderRuntimeDirections, installSteps: installSteps, migrationReleaseUrl: migrationReleaseUrl, diff --git a/docs/cli_build_matrix.md b/docs/cli_build_matrix.md index b5d5433d3..91e50de49 100644 --- a/docs/cli_build_matrix.md +++ b/docs/cli_build_matrix.md @@ -59,17 +59,23 @@ as explicit alternatives with different routing capacities. Build one with it over LoRa with `ota pull flash` and `ota install` on compatible hardware and partition layouts. -Standard and reduced OTA images, including images that fit the 1.25 MiB -portable application slot, remain available and remain in the standard matrix -pass. Select `--build-profile standard` to retain the original target and -partition contract. Moving from a plain target to its MQTT sibling changes the -LoRa OTA target identity; a partition layout change needs its matching -migration step. -For the normal-role partition migrations, the matrix also publishes a -canonical Full image under the normal target identity. Install its matching -merged image over USB once; mOTA rejects the incompatible partition signature -until that migration is complete, then later Full updates use the normal -target ID. The portable image remains available while deployed nodes move. +The ordinary ESP32 release publishes Full images. Standard and reduced OTA +recipes, including those that fit a 1.25 MiB portable application slot, remain +available through explicit builds for recovery and migration. Select +`--build-profile standard` to retain an exact target's original partition +contract. They are omitted from the ordinary release and interactive board +menu. That menu also omits transport/default aliases covered by Full and +development utilities; it retains distinct hardware, roles, storage, and +measured capacity exceptions. Exact legacy names remain accepted by +`build-firmware` and `build-matching-firmwares`. + +For the normal-role partition migrations, the release publishes a canonical +Full image under the normal target identity. Use its qualified wireless +expansion package when one exists, or install its matching merged image over +USB. mOTA rejects an incompatible partition signature until that migration +is complete, then later Full updates use the normal target ID. Moving from a +plain target to a differently named sibling can also change the LoRa OTA +identity; partition expansion does not by itself authorize that transition. Legacy supported ESP32 Wi-Fi repeaters can use the [Wi-Fi partition migration](esp32_wifi_partition_migration.md) bridge to diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 8f0d7329c..8a6c6a774 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -11,13 +11,15 @@ default ASCII terminal on USB Companion and infrastructure roles. Pick the choices in any order. The controls stay limited to compatible firmware combinations that were actually built in the current release set. The result count stays visible while you scroll through the filters on a phone. -**Same firmware** marks choices that do not narrow the builds any further; -they remain usable because they can change the setup instructions. +Once the exact board and role are selected, the picker skips logging, +connection, and profile choices that all select the same firmware. Logging +settings move into **Restore your settings after flashing** beneath the files. +Choices that select different images remain visible. 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. +or storage differences when a choice between different images is needed. The picker reads public release metadata from GitHub. It does not upload device information. Hardware names, target names, and download links come directly @@ -28,11 +30,12 @@ from the published firmware assets. Current release set

Loading release information...

- For a new installation, choose the exact board and role, prefer a - FULL / complete profile when it is available, and select + For a new installation, choose the exact board and role, and select Full install / layout migration (merged .bin). Current ESP32 releases combine the supported features in Full; use runtime settings to - choose the active transports. Other chip families retain their qualified profiles. + choose the active transports. Where separate profiles remain, prefer + FULL / complete when it meets your update and hardware + requirements. Other chip families retain their qualified profiles.

Upgrading an existing ESP32 infrastructure node from a smaller partition @@ -180,7 +183,7 @@ from the published firmware assets. | USB logging / USB-connected MQTT | Node remains attached to a computer over a data-capable USB cable | | 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 | +| No logging | Normal standalone operation with external logging output disabled | | 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 | | Reduced sensors + LoRa OTA | Qualified nRF52 repeater, room server, or sensor build with the reduced optional sensor recipe and LoRa OTA retained | @@ -193,8 +196,9 @@ Ethernet transports. Normal repeater firmware includes runtime-controlled RS-232 support where the board has room; use `set bridge.enabled on` after configuring `bridge.uart` and `bridge.baud`. The Wio-E5 remains the capacity exception and offers a separate -RS-232 image. Choose Wi-Fi MQTT under **Logging / MQTT**; it is an output mode, -not a second connection choice. Full ESP32 LoRa repeaters include runtime +RS-232 image. Choose Wi-Fi MQTT in the result's saved logging settings when +it shares the Full image; separate MQTT images retain a **Logging / MQTT** +filter. Full ESP32 LoRa repeaters include runtime ESP-NOW when capacity permits; dedicated ESP-NOW images remain for measured capacity exceptions. Ethernet still needs its exact hardware recipe. @@ -278,11 +282,11 @@ guess cannot confirm the installed layout. See the that command. Current `full-usb-wifi` profiles use one binary for no external output, USB -packet logging/USB-connected MQTT, direct WiFi MQTT, or both. The picker shows -that same exact binary for each compatible logging choice; select the saved +packet logging/USB-connected MQTT, direct WiFi MQTT, or both. The picker skips +the logging filter when these modes use the same file; select the saved runtime mode with `set logging.output off|usb|wifi|both`. A FULL logging-fallback -profile is listed only when no WiFi MQTT sibling exists; it appears for both -the no-output and USB choices because `set usb.logging off|on` is persistent. +profile is listed only when no WiFi MQTT sibling exists; it supports both +the no-output and USB settings because `set usb.logging off|on` is persistent. On a fresh unified FULL install with no saved SSID, the setup AP and WiFi radio remain available for 30 minutes per boot, then turn off automatically until the next reboot or power cycle. An explicit administrator `start webconfig` remains @@ -450,6 +454,8 @@ 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 picker skips this step when all available profile choices lead to the +same firmware file. Future nRF52 repeater, room-server and sensor releases publish two separately qualified choices: **Reduced sensors + LoRa OTA** and **Full supported sensors + LoRa OTA**, with `-reduced-ota` and `-full-ota` artifact suffixes. Each keeps diff --git a/docs/research/firmware_choice_followup_2026-10-06.md b/docs/research/firmware_choice_followup_2026-10-06.md new file mode 100644 index 000000000..e742e14f6 --- /dev/null +++ b/docs/research/firmware_choice_followup_2026-10-06.md @@ -0,0 +1,209 @@ +# Further firmware choice consolidation audit — 6 October 2026 + +Baseline: pulled commit `08bf174ea30e2e075cdd9fc74111580662b461bb`. +The user requested fewer choices after the ESP32 Full-only release change. +This follow-up combines menu/picker changes with a source, +resolved-configuration, and retained-artifact audit. It did not build a matched +nRF52 Full/Reduced pair or run hardware tests. Image-removal candidates below +remain unqualified; the implemented changes simplify selection without changing +firmware sources, partition layouts, or deployed update identities. + +## Implemented selection changes + +The interactive board menu now uses canonical Full ESP32 targets and omits +transport/default aliases, compact recovery recipes, and development utilities. +On nRF52, normal names and aliases that already build the exact same qualified +Full/Reduced pair appear once. Distinct external-storage and deployed OTA IDs +remain selectable. Exact hidden names remain directly buildable. + +| Platform | Supported exact names before | Ordinary menu choices after | +| --- | ---: | ---: | +| ESP32 | 638 | 217 | +| nRF52 | 403 | 209 | +| RP2040 | 25 | 19 | +| STM32 | 16 | 16 | +| Total | 1,082 | 461 | + +These are menu targets, including KISS; they are not firmware-file counts. The +current ordinary ESP32 release still has **174 Full node images** (52 Companion, +58 Repeater, 47 Room Server, 17 Sensor). The pull added two Station G3 Sensor +recipes to the previous 172-image plan. No Full image was retired in this +follow-up. The [selector evidence](firmware_choice_followup_selection_2026-10-06.json) +records exact menu/release agreement, zero duplicate menu names, and the +remaining OTA identity constraints. + +After selecting an exact board and role, the web picker skips logging, +connection, and profile filters when they do not distinguish firmware builds. +Logging and independent bridge controls remain in the result's setup directions; +linked logging/bridge settings still select their corresponding commands. +A sole install format is inferred; multiple install operations remain explicit. +Real alternatives stay visible even after mutually narrowing filters are selected. +The measured T-LoRa split and nRF52 sensor/storage alternatives remain available. + +The complete build-profile suite, 28 ordinary release-selection tests, and two +picker-package tests passed. Picker tests cover skipped requirements, real +alternatives, install formats, existing share links, and runtime command rendering. +The complete form was also exercised in the in-app browser using local replayed +release metadata: a sole app-only Full download, explicit merged versus app-only +installation, nRF52 Full/Reduced UF2 versus ZIP choices, runtime logging changes, +and a partial share link with WiFi logging and ESP-NOW enabled. The correct +files and commands remained selected. This used fixture metadata and did not +download or install firmware, contact a device, or publish the website. + +## nRF52 sensor pairs: policy versus demonstrated need + +At this baseline, `is_nrf52_sensor_ota_pair_target()` in +[`build_legacy.sh`](../../build_legacy.sh) includes every nRF52 repeater, +room-server, and sensor recipe, including external-QSPI and SD recipes. +`run_nrf52_sensor_ota_pair()` requires Full to pass, then builds Reduced; a +successful Reduced build cannot replace a failed Full build. Thus the pair is +a blanket publication policy, not evidence that each board needs two images. + +| Classification | Evidence | Defensible action | +| --- | --- | --- | +| Same declared sensor functionality | The 31 concrete recipes below enable none of the drivers removed by Reduced. GPS is outside the omitted set. | Present Full as the ordinary choice. Treat image deduplication as a candidate until packaged applications are compared. | +| Actual optional-driver difference | Other recipes can enable environmental/ranging drivers; non-RAK recipes can also lose INA monitors. RAK3401/4631 Reduced keeps the four INA drivers and compatible GPS. | Full for normal installs; Reduced can remain an advanced/recovery choice with its missing drivers stated. Disabling a sensor at runtime does not remove its compiled flash cost. | +| Demonstrated internal-OTA headroom constraint | The historical RAK3401 compact chain has several exact transitions with zero staging margin. Current staging capacity depends on the installed image and exact delta, not just the board's nominal flash. | Preserve qualified reduced endpoints and exact-base update routes. This is not proof that every current Full/Reduced pair is necessary. | +| Current measured mandatory pair | No current matched Full/Reduced application and delta-package comparison was found in the retained local evidence. | Do not claim a current pair is required by measured capacity, or claim Reduced can safely be deleted everywhere. | + +The baseline [`CLI matrix`](../cli_build_matrix.md) explicitly acknowledges +that boards without optional sensor drivers still receive two named choices, +with small or zero savings. This audit found no qualified artifact pair proving +byte-identical firmware for those boards. The retained RAK3401 1.17.1.8 Reduced +[RAM report](../test-results/preview-1.17.1.8/RAK_3401_repeater_lora_ota_no_external_sensors-ota-v1.17.1.8-halo-keymind-cascade-dev-d5853b9c.memory.json) +passes with 67,660 available bytes versus 66,904 required, a 756-byte margin; +it is historical, not a current matched-pair comparison. The retained T1000-E +reports concern Full Companion, outside this infrastructure pair policy. + +Full already includes targeted RAM capacity tuning: T096/T1 repeaters use four +flood rules, and RAK3401 unified uses 47. The baseline script records these as +measured internal-RAM reductions while retaining the Full sensor support. +They justify tuning within Full, not a second sensor profile by themselves. + +## 31 concrete recipes with no enabled removable sensor driver + +Source: `out/esp32-full-audit-2026-10-06/platformio-current-resolved.json`, the +current resolved configuration retained for the baseline audit. Definitions +and undefinitions were processed in order, and definitions equal to zero were +excluded. The tested omitted set is the 12 environmental/ranging macros in +`apply_lora_ota_no_external_sensors_profile()`, plus four INA macros except on +the RAK recipes which retain them. This is a recipe-level result, not an image +hash result or a count of canonical published outputs. + +```text +Heltec_mesh_solar_repeater +Heltec_mesh_solar_room_server +Heltec_tower_v2_repeater +Heltec_tower_v2_room_server +KeepteenLT1_repeater +KeepteenLT1_room_server +LilyGo_T-Echo_Card_repeater +LilyGo_T-Echo_Card_room_server +LilyGo_T-Echo-Lite_repeater +LilyGo_T-Echo-Lite_room_server +MeshTracker_X1_repeater +MeshTracker_X1_room_server +Mesh_pocket_repeater +Mesh_pocket_room_server +muzi_base_duo_repeater +muzi_base_duo_room_server +muzi_base_uno_repeater +muzi_base_uno_room_server +Nano_G2_Ultra_repeater +Nano_G2_Ultra_room_server +t1000e_repeater +t1000e_room_server +t1000e_sensor +ThinkNode_M1_repeater +ThinkNode_M1_room_server +ThinkNode_M3_repeater +ThinkNode_M3_room_server +ThinkNode_M4_repeater +ThinkNode_M4_room_server +ThinkNode_M8_repeater +ThinkNode_M8_room_server +``` + +`ThinkNode_M1_repeater` already declares external QSPI OTA. The plain MeshTower +repeater name is an alias for the SD primary, so this list must not be blindly +converted into 31 release removals. Aliases, full source options, board headers, +storage layout, and deployed target identities must be resolved first. + +## Qualification before removing an image + +Start by showing Full for ordinary installation and moving Reduced to advanced +or recovery selection. Preserve existing artifacts and update identities while +qualifying actual duplicate removal. For each candidate: + +1. Build both profiles sequentially from the same source/toolchain, with the + existing flash, RAM, capability, packaged-application, and OTA checks. +2. Extract the DFU application's `bin_file` from its ZIP manifest and compare + its complete bytes, EndF identity/body hash, image length, and layout. Outer + UF2/ZIP hashes alone can differ because of packaging and are insufficient. +3. If applications differ, measure the driver/capacity difference. For internal + OTA, create exact-base deltas from representative installed Full and Reduced + images and verify staging geometry, reconstruction, and the matched deployed + bootloader. A passing local-install image does not prove a delta fits. +4. Change publication validation and fixtures deliberately before emitting one + artifact. [`validate_nrf52_sensor_pairs()`](../../scripts/package_cascade_release.py) + currently requires exactly one qualified Full and one Reduced with matching + target, hardware, version, source, and layout; it does not prove either member + saves space or is needed. Preserve legacy endpoints needed by qualified chains. + +Focused existing checks include `test/test_build_profiles.sh`, +`test/test_nrf52_ota_profiles.sh`, `test/test_ota_store_flash_nrf52_hybrid.py`, +`test/test_rak_storage_policy.py`, and the release-packaging tests. These check +policy and boundary behavior; they do not substitute for matched image and +exact-delta evidence. No PlatformIO process was started for this audit. + +## Storage and bootloader choices that need real distinctions + +[`OtaFlashLayout_nrf52.h`](../../src/helpers/ota/OtaFlashLayout_nrf52.h) derives +internal staging space from the trusted live EndF application extent, rounded +to a 4 KiB page, and the effective bootloader ceiling. +[`OtaStoreFlashNrf52.cpp`](../../src/helpers/ota/OtaStoreFlashNrf52.cpp) adds +64 KiB of retained RAM only with the matching `MOTARAMA` capability and at least +one available flash page. Its hybrid application path accepts deltas, not full +application containers. A **Full sensor firmware** can still be the destination +of a delta; the two uses of “Full” must not be confused. + +The historical [RAK3401 chain](rak3401_mota_chain.md) proves tight exact-package +geometry under an older bootloader: multiple transitions have zero margin; +steps 1–9 were physically passed, while the replacement final step has offline +reconstruction and exact-bootloader simulation only. It does not compare the +current sensor pair and must not be generalized to all nRF52 boards. + +External NOR/SD removes the internal container-staging bottleneck, but does not +increase executable internal flash or RAM. Existing runtime consolidation is +already available for qualified [RAK unified storage](../ota_nrf52_qspi.md) and +[MeshTower SD/internal fallback](../ota_meshtower_v2_sdcard.md). Keep the exact +NOR wiring, bootloader device/storage records, target IDs, and per-transfer +store selection. MeshTower's old internal-only identity needs local migration; +an alias does not authorize same-target LoRa replacement. Its internal fallback +can reject a delta that fits on SD. + +The source explicitly rejects raw OTA QSPI sharing a chip with the QSPIFLASH +filesystem, and WisBlock OTA SPI sharing incompatible Ethernet/SD wiring. +RAK3401 flash and the 1 W radio need distinct chip selects and the explicit bus +handoff contract. Combining those recipes requires hardware/layout evidence, +not just runtime sensor switches. Extra-filesystem and bootloader-scratch +layouts likewise cannot be relabeled as the same OTA target. + +## Other possible menu simplifications + +The current ESP32 replay reports 174 Full choices, including the two newly +pulled Station G3 sensor recipes. Its evidence file +`out/esp32-full-audit-2026-10-06/wifi_review_current_consolidation.json` finds two +equivalent source-option pairs: Heltec V4 base/Expansion Kit OLED repeaters, and +their room-server recipes. Repeated identical I2C pin definitions are the only +semantic option difference, but their mOTA target IDs differ. Simplify selection +first; retain deployed update artifacts until an explicit migration is reviewed. +The Expansion Kit TFT Companion has different display/sensor wiring. + +Pico W already has a combined USB/WiFi/BLE Companion recipe; it is a candidate +to replace transport-specific ordinary choices after fresh capacity and +transport qualification. This audit has no current qualifying build result. +STM32WL's retained flash reports support capacity tuning of its existing +256 KiB applications; they do not show a removable duplicate role. Repeater, +room-server, sensor, Companion/Terminal Chat, and KISS remain different +applications unless a separate combined application is designed and tested. diff --git a/docs/research/firmware_choice_followup_selection_2026-10-06.json b/docs/research/firmware_choice_followup_selection_2026-10-06.json new file mode 100644 index 000000000..d2608c905 --- /dev/null +++ b/docs/research/firmware_choice_followup_selection_2026-10-06.json @@ -0,0 +1,180 @@ +{ + "source_commit_current": "08bf174ea30e2e075cdd9fc74111580662b461bb", + "cache": "platformio-current-resolved.json", + "primary_cache_method": "Exact current resolved cache; remove11 local HIL env sections absent from primary tracked platformio.ini/variants recipes. Local file adds no ordinary overrides.", + "planned_current_slots": 174, + "current_profiles": { + "full": 174 + }, + "passes": { + "run_logging_matrix_build_targets": 52, + "run_full_only_esp32_profile": 122 + }, + "duplicate_target_identities": [], + "semantically_distinct_resolved_base_recipes": 172, + "duplicate_groups": [ + [ + { + "target": "heltec_v4_repeater", + "compiled_env": "heltec_v4_repeater_observer_mqtt" + }, + { + "target": "heltec_v4_expansionkit_repeater", + "compiled_env": "heltec_v4_expansionkit_repeater_observer_mqtt" + } + ], + [ + { + "target": "heltec_v4_room_server", + "compiled_env": "heltec_v4_room_server_observer_mqtt" + }, + { + "target": "heltec_v4_expansionkit_room_server_observer_mqtt", + "compiled_env": "heltec_v4_expansionkit_room_server_observer_mqtt" + } + ] + ], + "caveat": "Two semantically equivalent recipe pairs have distinct mOTA target IDs. This does not authorize relabeling artifacts or app-only OTA across IDs/layouts.", + "menu_counts": { + "supported_ESP32_logical_targets": 638, + "interactive_ESP32_targets_including_KISS": 217, + "interactive_ESP32_node_Full_choices": 174, + "interactive_KISS_choices": 43, + "supported_roles": { + "repeater": 196, + "Terminal_Chat": 23, + "room_server": 121, + "legacy_Companion": 162, + "KISS": 43, + "development_or_auxiliary": 21, + "sensor": 17, + "Full_Companion": 55 + }, + "interactive_roles": { + "KISS": 43, + "Full_Companion": 52, + "repeater": 58, + "room_server": 47, + "sensor": 17 + }, + "ordinary_menu_missing_from_matrix": [], + "matrix_missing_from_ordinary_menu": [], + "duplicate_menu_targets": [], + "ambiguous_board_variant_labels": [], + "primary_ESP32_raw_environments": 502, + "primary_ESP32_generated_aliases": 136 + }, + "source_equivalence_notice": "Software source/configuration equivalence is not binary equivalence: logical target names generate distinct mOTA IDs, image identity records, recipe digests, artifact names, and signed/hashed package contents.", + "identity_and_layout": [ + { + "targets": { + "heltec_v4_repeater": "0xe792a051", + "heltec_v4_expansionkit_repeater": "0xd522a14f" + }, + "storage_equivalence": "Both same board heltec_v4, 16MiB flash, infrastructure Full overlay default_16MB.csv expanded dual OTA. All cached board/storage/sourcefilter options equal.", + "migration_gate": "Current get_exact_identity_full_migration_target allows observer suffix to matching plain prefix. It does not map Expansion Kit prefix to base V4. Existing deployed firmware may lack any successor alias.", + "safe_next_step": "Consolidate fresh install selection while retaining exact legacy IDs/artifacts and direct links. Retire legacy update feeds only after explicitly reviewed same-layout successor migration. Never relabel an artifact or bypass its EndF target check." + }, + { + "targets": { + "heltec_v4_room_server": "0xeed78ce4", + "heltec_v4_expansionkit_room_server_observer_mqtt": "0x21ee03bd" + }, + "storage_equivalence": "Both same board heltec_v4, 16MiB flash, infrastructure Full overlay default_16MB.csv expanded dual OTA. All cached board/storage/sourcefilter options equal.", + "migration_gate": "Current get_exact_identity_full_migration_target allows observer suffix to matching plain prefix. It does not map Expansion Kit prefix to base V4. Existing deployed firmware may lack any successor alias.", + "safe_next_step": "Consolidate fresh install selection while retaining exact legacy IDs/artifacts and direct links. Retire legacy update feeds only after explicitly reviewed same-layout successor migration. Never relabel an artifact or bypass its EndF target check." + } + ], + "source_refs": { + "equivalent_V4_OLED_hardware": "variants/heltec_v4/platformio.ini:55", + "duplicate_repeater": "variants/heltec_v4/platformio.ini:223", + "duplicate_room_server": "variants/heltec_v4/platformio.ini:366", + "current_interactive_helper": "build_legacy.sh:6548", + "Full_source_substitution": "build_legacy.sh:2492", + "same_partition_alias_gate": "build_legacy.sh:2516", + "Full_partition_layout": "scripts/esp32_full_partition.py:94", + "automatic_target_choice": "src/helpers/ota/OtaManager.cpp:1149", + "received_manifest_target_check": "src/helpers/ota/OtaManager.cpp:1514", + "observer_layout_capacity_split": "build_legacy.sh:6500", + "RC32_optional_display_requires_review": "variants/heltec_rc32/platformio.ini:68", + "R8_portrait_compile_time_layout": "variants/heltec_v4_r8/platformio.ini:373", + "portrait_geometry_driver": "src/helpers/ui/ST7789LCDDisplay.h:25", + "portrait_dashboard_layout": "src/helpers/ui/ObserverDashboard.h:113", + "fresh_vs_resume_filter_tests": "test/test_local_release_full_selection.py:96" + }, + "further_options": [ + { + "proposal": "HeltecV4 OLED Expansion Kit software-source alias consolidation", + "remove_ordinary_choices": 2, + "readiness": "UI/fresh-install recommendation safe while exact legacy identities remain available; release artifact retirement awaits explicit reviewed identity migration", + "fresh_count_if_only_this_change": 172, + "keep_distinct": [ + "heltec_v4_expansionkit_tft_companion_radio_full_femon (different display/sensor wiring)" + ] + }, + { + "proposal": "R8 TFT portrait/landscape runtime layout", + "remove_ordinary_choices": 2, + "readiness": "Requires actual UI/driver changes, qualification and display testing. Existing display.flip rotates180 and preserves geometry; it does not choose portrait vs landscape." + }, + { + "proposal": "RC32 display/headless runtime optional peripheral", + "remove_ordinary_choices": 4, + "readiness": "Requires runtime display-presence/power controls and hardware/heap checks. Existing source filters/driver pin behavior differ; not currently software-source equivalent." + } + ], + "role_distinctions": "52 Full Companion,58 repeater,47 room-server,17 sensor ordinary choices select genuinely different applications/source filters and preference/storage/transport behavior. No existing runtime role switch permits consolidating those application roles without new firmware architecture.", + "peer_review": { + "interactive_helper": "Uses same canonical release selectors; preserves KISS host-modem role and every174 ordinary Full identity. No duplicate board/variant labels and no source/partition/recipe modifications. All old exact targets remain supported by direct-build APIs.", + "tests": "New real-inheritance block tests TLora UART/MQTT split, nRF52 external-storage variants, hiding legacy transports/reduced/utility names while preserving exact-build support, and submenu integration. Existing host suite passed per root; this audit additionally compares complete current menu with174 planned release choices.", + "fresh_resume_publication": "Current release filter drops standard/reduced/transport/default/fixture artifacts on resume and requires verified Full. Same-role observer/plain siblings are selected by capability/identity priorities; do not infer that source-equivalent V4 alias pairs are binary-equivalent.", + "nRF_pair_dedup": "Groups only through get_nrf52_sensor_ota_pair_target output identity. Alias-first ordering prefers non-LoRa-only ordinary name; tests retain external-storage and distinct deployed IDs. No suffix-only or cross-hardware grouping." + }, + "final_menu_counts_all_platforms": { + "supported_target_names": 1082, + "final_interactive_choices": 461, + "removed_redundant_menu_names": 621, + "supported_by_platform": { + "ESP32_PLATFORM": 638, + "NRF52_PLATFORM": 403, + "RP2040_PLATFORM": 25, + "STM32_PLATFORM": 16 + }, + "menu_by_platform": { + "ESP32_PLATFORM": 217, + "NRF52_PLATFORM": 209, + "RP2040_PLATFORM": 19, + "STM32_PLATFORM": 16 + }, + "menu_roles_by_platform": { + "ESP32_PLATFORM": { + "KISS": 43, + "Full_Companion": 52, + "repeater": 58, + "room_server": 47, + "sensor": 17 + }, + "NRF52_PLATFORM": { + "repeater": 59, + "room_server": 47, + "KISS": 45, + "sensor": 8, + "legacy_Companion": 3, + "Full_Companion": 47 + }, + "RP2040_PLATFORM": { + "repeater": 4, + "room_server": 4, + "legacy_Companion": 7, + "KISS": 4 + }, + "STM32_PLATFORM": { + "repeater": 5, + "sensor": 3, + "legacy_Companion": 4, + "KISS": 4 + } + }, + "duplicate_menu_targets": [] + } +} diff --git a/mkdocs.yml b/mkdocs.yml index 05b26ec09..d8fa050c4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -124,7 +124,7 @@ extra_css: extra_javascript: - _javascript/notification_builder.js?v=20261006-channel9 - https://unpkg.com/leaflet@1.9.4/dist/leaflet.js - - _javascript/firmware_picker.js?v=20261001-feedback-1 + - _javascript/firmware_picker.js?v=20261006-simplify-1 - _javascript/telemetry_decoder.js - _javascript/management_decoder.js - _javascript/filter_tool.js?v=20261002-hashbytes-2plus diff --git a/test/test_build_profiles.sh b/test/test_build_profiles.sh index 14f75c3c0..02cda4f2a 100644 --- a/test/test_build_profiles.sh +++ b/test/test_build_profiles.sh @@ -438,6 +438,51 @@ require(rak_usb, "build_flags", "FORCE_GPS_ALIVE") # replace each with that exact board's Full target. init_project_context >/dev/null +# The interactive board menu must offer the same ordinary Full choices as the +# release, while exact compatibility/development recipes stay directly usable. +# Test real board inheritance: dropping aliases by suffix alone would also +# discard the measured TLora transport split or special nRF52 storage wiring. +( + mapfile -t menu_targets < <(get_interactive_build_targets) + menu_text=$(printf '%s\n' "${menu_targets[@]}") + for expected in Heltec_v3_companion_radio_full Heltec_v3_repeater \ + LilyGo_TLora_V2_1_1_6_repeater \ + LilyGo_TLora_V2_1_1_6_repeater_observer_mqtt_ \ + RAK_3401_repeater \ + RAK_4631_repeater_rak15001_slot_c_lora_ota \ + RAK_4631_repeater_w25q16_lora_ota \ + RAK_4631_repeater_bridge_rs232_serial1_lora_ota_no_external_sensors \ + RAK_4631_repeater_bridge_rs232_serial2_lora_ota_no_external_sensors; do + grep -Fxq "$expected" <<<"$menu_text" \ + || fail "ordinary board menu omitted needed choice $expected" + done + for hidden in Heltec_v3_companion_radio_usb Heltec_v3_companion_radio_ble \ + Heltec_v3_repeater_observer_mqtt \ + Heltec_v3_repeater_lora_ota_no_external_sensors \ + RAK_3401_repeater_lora_ota_no_external_sensors \ + heltec_v4_partition_migrator; do + if grep -Fxq "$hidden" <<<"$menu_text"; then + fail "ordinary board menu still offers redundant/recovery choice $hidden" + fi + is_supported_build_env "$hidden" \ + || fail "hiding $hidden from the board menu removed its exact build" + done + board=$(get_board_family_for_env Heltec_v3_repeater) + variants=$(get_variants_for_board "$board") + grep -Fxq Heltec_v3_companion_radio_full <<<"$variants" \ + || fail "board submenu lost its Full Companion" + if grep -Eq 'companion_radio_(usb|ble|wifi)|_observer_mqtt|no_external_sensors' <<<"$variants"; then + fail "board submenu reintroduced transport/profile choices" + fi + [ "$(get_nrf52_sensor_ota_pair_target RAK_3401_repeater)" \ + = "$(get_nrf52_sensor_ota_pair_target RAK_3401_repeater_lora_ota_no_external_sensors)" ] \ + || fail "menu folded nRF52 targets with different output identities" + # Prefer the ordinary name even when a caller supplies the alias first. + SUPPORTED_PIO_ENVS=(RAK_3401_repeater_lora_ota_no_external_sensors RAK_3401_repeater) + [ "$(get_interactive_build_targets)" = RAK_3401_repeater ] \ + || fail "nRF52 menu depends on alias input order" +) + # Expanded partitions make Full the ordinary ESP32 node release. Legacy lean # names still address installed firmware and must remain directly buildable, # but cannot reappear in either the all-role or a role-specific release list. diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index 1b2125bf0..ef863ca1d 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -1743,3 +1743,218 @@ assert(!picker.choicesUseSameFirmware([], {}, 'role', feedbackOptions(['companio assert.strictEqual(JSON.stringify(feedbackCatalog), feedbackSnapshot); assert.deepStrictEqual(feedbackFilters, {hardware: 'Heltec_v3', role: 'companion'}); console.log('build counts, non-refining choices, install safety and partial-link feedback passed'); + +// Exact board and role are deliberate intent. Runtime settings and a sole +// available install format must not keep an already identified download locked. +const simpleFilters = {...feedbackFilters, hardwareFamily: 'Heltec_v3'}; +const simpleRequirements = picker.selectionRequirements(feedbackCatalog, simpleFilters); +assert.deepStrictEqual(simpleRequirements.hiddenFields, ['logging', 'firmwareProfile', 'mode']); +assert.deepStrictEqual(simpleRequirements.missingFields, ['install']); +for (const install of ['bin', 'merged-bin']) { + const requirements = picker.selectionRequirements(feedbackCatalog, {...simpleFilters, install}); + assert.deepStrictEqual(requirements.missingFields, []); + assert.strictEqual(requirements.installKind, install); +} +const soleInstallCatalog = [{...feedbackCatalog[0], installKinds: ['bin']}]; +const soleInstall = picker.selectionRequirements(soleInstallCatalog, simpleFilters); +assert.deepStrictEqual(soleInstall.hiddenFields, ['logging', 'firmwareProfile', 'mode', 'install']); +assert.deepStrictEqual(soleInstall.missingFields, []); +assert.strictEqual(soleInstall.installKind, 'bin'); +assert.deepStrictEqual(picker.resolveProfileAssets(soleInstallCatalog, soleInstall.installKind) + .map(entry => entry.asset.name), [picker.canonicalAsset(soleInstallCatalog[0].files, 'bin').name]); +const soleUf2 = picker.selectionRequirements([{...nrf, installKinds: ['uf2']}], { + hardwareFamily: nrf.hardwareFamily, hardware: nrf.hardware, role: nrf.role, +}); +assert.strictEqual(soleUf2.installKind, 'uf2'); +assert(soleUf2.hiddenFields.includes('install')); +assert(picker.installSteps(nrf, soleUf2.installKind).some(step => step.includes('UF2'))); +for (const filters of [{}, {hardware: 'Heltec_v3'}, {role: 'companion'}]) { + const requirements = picker.selectionRequirements(soleInstallCatalog, filters); + assert.deepStrictEqual(requirements.hiddenFields, []); + if (!filters.hardware) assert(requirements.missingFields.includes('hardware')); + if (!filters.role) assert(requirements.missingFields.includes('role')); +} +assert(picker.selectionRequirements([], simpleFilters).missingFields.includes('install')); + +// Unsupported choices in an old/manual URL must remain visible to clear. +// Inferring the sole BIN for an explicitly requested ZIP would conceal the +// invalid constraint while actual matching still returned no files. +const usbOnlyCatalog = [{...soleInstallCatalog[0], + loggingModes: ['none', 'usb'], connectionModes: ['usb']}]; +for (const [field, value] of [['install', 'zip'], ['logging', 'wifi'], ['mode', 'wifi'], + ['feature', 'missing'], ['ota', 'missing'], ['variant', 'missing']]) { + const selection = {...simpleFilters, [field]: value}; + const before = JSON.stringify(selection); + const requirements = picker.selectionRequirements(usbOnlyCatalog, selection); + const control = picker.PROFILE_FIELDS.includes(field) ? 'firmwareProfile' : field; + assert(!requirements.hiddenFields.includes(control), control + ' must stay clearable'); + assert.strictEqual(JSON.stringify(selection), before); + assert.strictEqual(new URL(picker.selectionUrl('https://example.test/picker/', selection, false)) + .searchParams.get(field), value); + if (field === 'install') assert.strictEqual(requirements.installKind, 'zip'); +} + +// A selected setting that currently narrows to one image must remain visible +// if another choice would select a different image. +const distinctRequirements = picker.selectionRequirements(feedbackAlternatives, { + ...simpleFilters, logging: 'usb', mode: 'usb', + feature: 'full', ota: 'lora-source', variant: 'default', +}); +for (const field of ['logging', 'mode', 'firmwareProfile']) { + assert(!distinctRequirements.hiddenFields.includes(field), + field + ' must remain clearable even when other selected facets narrow to one image'); +} +// Test each facet independently of other already narrowing choices. +assert(!picker.selectionRequirements(feedbackAlternatives, {...simpleFilters, logging: 'usb'}) + .hiddenFields.includes('logging')); +assert(!picker.selectionRequirements(feedbackAlternatives, {...simpleFilters, mode: 'usb'}) + .hiddenFields.includes('mode')); +assert(!picker.selectionRequirements(feedbackAlternatives, {...simpleFilters, + feature: 'full', ota: 'lora-source', variant: 'default'}) + .hiddenFields.includes('firmwareProfile')); +assert.strictEqual(distinctRequirements.installKind, ''); +const tloraRequirements = picker.selectionRequirements(tloraCatalog.profiles, { + hardwareFamily: tloraNormalProfile.hardwareFamily, + hardware: tloraNormalProfile.hardware, role: 'repeater', +}); +assert(!tloraRequirements.hiddenFields.includes('logging')); +assert(!tloraRequirements.hiddenFields.includes('mode')); +// Both capacity-safe images share the Full/OTA tuple. Logging and transport +// still distinguish their different files; the identical tuple adds no choice. +assert(tloraRequirements.hiddenFields.includes('firmwareProfile')); +assert(tloraRequirements.missingFields.includes('logging')); +assert(tloraRequirements.missingFields.includes('mode')); +const selectedTlora = picker.selectionRequirements(tloraCatalog.profiles, { + hardwareFamily: tloraNormalProfile.hardwareFamily, + hardware: tloraNormalProfile.hardware, role: 'repeater', + logging: 'wifi', mode: 'espnow', feature: 'full', ota: tloraObserverProfile.ota, + variant: tloraObserverProfile.variant, +}); +assert(!selectedTlora.hiddenFields.includes('logging')); +assert(!selectedTlora.hiddenFields.includes('mode')); +for (const item of sensorCatalog.profiles) { + const requirements = picker.selectionRequirements(sensorCatalog.profiles, { + hardwareFamily: item.hardwareFamily, hardware: item.hardware, role: item.role, + }); + assert(!requirements.hiddenFields.includes('firmwareProfile'), item.target); + assert(!requirements.hiddenFields.includes('install'), 'ZIP and UF2 stay distinct'); + const selected = picker.selectionRequirements(sensorCatalog.profiles, { + hardwareFamily: item.hardwareFamily, hardware: item.hardware, role: item.role, + logging: item.loggingModes[0], mode: item.mode, install: 'zip', + ota: item.ota, feature: item.feature, variant: item.variant, + }); + assert(!selected.hiddenFields.includes('firmwareProfile'), + 'Selected sensor profile must remain clearable: ' + item.target); +} +for (const partial of [{feature: 'full'}, {ota: 'lora-source'}]) { + const selection = {...simpleFilters, ...partial}; + const before = JSON.stringify(selection); + const requirements = picker.selectionRequirements(soleInstallCatalog, selection); + assert.deepStrictEqual(requirements.missingFields, []); + assert.strictEqual(JSON.stringify(selection), before); + const link = picker.selectionUrl('https://example.test/picker/', selection, false); + assert(!new URL(link).searchParams.has('install')); + for (const field of picker.PROFILE_FIELDS) { + if (!partial[field]) assert(!new URL(link).searchParams.has(field)); + } +} +assert.strictEqual(JSON.stringify(feedbackCatalog), feedbackSnapshot); +assert.deepStrictEqual(simpleFilters, {...feedbackFilters, hardwareFamily: 'Heltec_v3'}); +console.log('same-image selectors skip redundant steps while distinct profiles and install routes stay explicit'); + +// Logging choices remain available beneath the download. Linked choices +// preselect their exact commands; an unselected logging mode shows off steps. +for (const logging of ['none', 'usb', 'wifi', 'both']) { + const sections = picker.runtimePanelDirections(observer, {logging}, ['logging', 'mode']); + const section = sections.find(item => item.title === 'Logging / MQTT output'); + assert.deepStrictEqual(section.actions.map(action => action.selection.logging), + ['none', 'usb', 'wifi', 'both']); + assert.strictEqual(section.actions[section.selectedIndex].selection.logging, logging); + assert.deepStrictEqual(section.actions[section.selectedIndex].commands, + picker.runtimeDirections(observer, {logging})[0].actions[0].commands); +} +const defaultLogging = picker.runtimePanelDirections(observer, {}, ['logging'])[0]; +assert.strictEqual(defaultLogging.actions[defaultLogging.selectedIndex].selection.logging, 'none'); +const nrfLogging = picker.runtimePanelDirections(nrf, {logging: 'usb'}, ['logging'])[0]; +assert.deepStrictEqual(nrfLogging.actions[nrfLogging.selectedIndex].commands, ['set usb.logging on reboot']); +assert.strictEqual(picker.runtimePanelDirections(observer, {logging: 'usb'}, [])[0].actions.length, 1); +for (const mode of ['', 'standard', 'rs232', 'espnow']) { + const profile = tripleCatalog.profiles[0]; + const sections = picker.runtimePanelDirections(profile, {mode}, ['mode']); + const uart = sections.find(section => /^RS232 bridge/.test(section.title)); + const wireless = sections.find(section => section.title === 'ESP-NOW bridge'); + assert.strictEqual(uart.actions[uart.selectedIndex].label, mode === 'rs232' ? 'On' : 'Off'); + assert.strictEqual(wireless.actions[wireless.selectedIndex].label, mode === 'espnow' ? 'On' : 'Off'); +} + +// Exercise the actual command renderer, including the preselected radio and +// later logging change, without sending anything to a device or the network. +class RuntimeElement { + constructor(tag, text = '') { + this.tagName = tag.toUpperCase(); + this.children = []; + this.dataset = {}; + this.listeners = {}; + this.ownText = text; + } + appendChild(child) { this.children.push(child); return child; } + replaceChildren(...children) { this.children = children; this.ownText = ''; } + addEventListener(name, handler) { this.listeners[name] = handler; } + get textContent() { return this.ownText + this.children.map(child => child.textContent).join(''); } + set textContent(text) { this.ownText = text; this.children = []; } +} +function descendants(element, predicate) { + return element.children.flatMap(child => + (predicate(child) ? [child] : []).concat(descendants(child, predicate))); +} +const previousDocument = global.document; +global.document = { + createElement: tag => new RuntimeElement(tag), + createTextNode: text => new RuntimeElement('#text', text), +}; +try { + const card = new RuntimeElement('article'); + const changes = []; + picker.renderRuntimeDirections(card, observer, {logging: 'wifi'}, { + runtimeFields: ['logging'], onSelectionChange: selection => changes.push(selection), + }); + const loggingDetails = descendants(card, node => node.tagName === 'DETAILS') + .find(node => node.children[0].textContent === 'Logging / MQTT output'); + const inputs = descendants(loggingDetails, node => node.tagName === 'INPUT'); + assert.strictEqual(inputs.length, 4); + assert.deepStrictEqual(inputs.map(input => !!input.checked), [false, false, true, false]); + assert.strictEqual(descendants(loggingDetails, node => node.tagName === 'CODE')[0].textContent, + 'set logging.output wifi\nget logging.output'); + const choices = descendants(loggingDetails, node => node.className === 'firmware-picker-radio-options')[0]; + choices.listeners.change({target: inputs[1]}); + assert.deepStrictEqual(changes, [{logging: 'usb'}]); + assert.strictEqual(descendants(loggingDetails, node => node.tagName === 'CODE')[0].textContent, + 'set powersaving off\nset logging.output usb\nget logging.output'); + const url = picker.selectionUrl('https://example.test/picker/', {...simpleFilters, ...changes[0]}, false); + assert.strictEqual(new URL(url).searchParams.get('logging'), 'usb'); + const resolveLogging = logging => picker.resolveProfileAssets([observer].filter(profile => + picker.profileMatchesFacets(profile, {logging})), 'bin').map(entry => entry.asset.name); + assert.deepStrictEqual(resolveLogging(changes[0].logging), resolveLogging('wifi')); + assert.strictEqual(resolveLogging(changes[0].logging).length, 1); + + // When logging still distinguishes images, candidate command controls are + // directions only. Their preview cannot silently change filters or URLs. + const refiningCard = new RuntimeElement('article'); + const refiningChanges = []; + picker.renderRuntimeDirections(refiningCard, observer, {}, { + runtimeFields: [], onSelectionChange: selection => refiningChanges.push(selection), + }); + const refiningDetails = descendants(refiningCard, node => node.tagName === 'DETAILS') + .find(node => node.children[0].textContent === 'Restore the selected logging mode'); + const refiningInputs = descendants(refiningDetails, node => node.tagName === 'INPUT'); + const refiningChoices = descendants(refiningDetails, + node => node.className === 'firmware-picker-radio-options')[0]; + refiningChoices.listeners.change({target: refiningInputs[2]}); + assert.strictEqual(descendants(refiningDetails, node => node.tagName === 'CODE')[0].textContent, + 'set logging.output wifi\nget logging.output'); + assert.deepStrictEqual(refiningChanges, []); +} finally { + if (previousDocument === undefined) delete global.document; + else global.document = previousDocument; +} +console.log('result logging and bridge controls preserve linked modes and show deliberate default commands');