Simplify firmware selection and canonical build menus

This commit is contained in:
mikecarper committed 2026-10-06 10:46:11 -07:00
1 parent 08bf174ea3
commit 9202fc2a96
9 files changed
+869 -58

No files matched your search

+46 -12
View File
@@ -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=""
+137 -21
View File
@@ -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,
+17 -11
View File
@@ -59,17 +59,23 @@ as explicit alternatives with different routing capacities. Build one with
it over LoRa with `ota pull <MID8> 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
+19 -13
View File
@@ -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.
<strong>Current release set</strong>
<p data-role="release-set">Loading release information...</p>
<p>
For a new installation, choose the exact board and role, prefer a
<strong>FULL / complete profile</strong> when it is available, and select
For a new installation, choose the exact board and role, and select
<strong>Full install / layout migration (merged .bin)</strong>. 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
<strong>FULL / complete</strong> when it meets your update and hardware
requirements. Other chip families retain their qualified profiles.
</p>
<p>
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
@@ -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.
@@ -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": []
}
}
+1 -1
View File
@@ -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
+45
View File
@@ -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.
+215
View File
@@ -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');