From ff78f8df7ec028f77e306229d74af3b97248cffb Mon Sep 17 00:00:00 2001 From: mikecarper Date: Tue, 13 Oct 2026 22:05:00 -0700 Subject: [PATCH] Put firmware selections first and show contextual board notes --- docs/_javascript/firmware_picker.js | 75 +++++++++++ docs/_stylesheets/firmware_picker.css | 9 ++ docs/firmware_picker.md | 186 ++++++++++++-------------- mkdocs.yml | 4 +- test/test_firmware_picker.js | 106 +++++++++++++++ test/test_firmware_picker_package.py | 49 +++++++ 6 files changed, 324 insertions(+), 105 deletions(-) diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index a82e1b698..6dfb9afc2 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -1502,6 +1502,68 @@ : { url: fallback, label: "Check partition-expansion availability" }; } + function boardNotes(profile) { + if (!profile || typeof profile.target !== "string") return []; + const identity = parseTargetProfile(profile.target); + // Match the exact result, not its broad hardware-family filter. A TFT, + // legacy UART or different role must not inherit another image's claims. + if (identity.hardware !== profile.hardware || identity.role !== profile.role) return []; + const target = profile.target; + const info = profile.controls || {}; + const notes = []; + const rak = /^RAK_(3401|4631)_repeater_unified_lora_ota(?:-(?:full|reduced)(?:-ota)?)?$/i.exec(target); + const lora = info.updateRequirements && info.updateRequirements.lora; + if (rak && info.platform === "NRF52_PLATFORM" && profile.ota === "lora-receiver" && + ["full", "reduced"].includes(profile.sensorProfile) && lora && + lora.storage === "adaptive_internal_or_external_qspi") { + notes.push("This unified repeater chooses supported internal or external OTA storage automatically. Full and Reduced select optional sensor drivers, not a storage module. Exact external-flash wiring and the matching OTAFIX bootloader are still required."); + notes.push("Internal flash accepts in-place deltas only; supported external QSPI storage accepts full images and in-place deltas. The update package must match the installed target and storage layout."); + if (rak[1] === "4631" && info.rs232 === true && info.gps === true) { + notes.push("Use UART2 for new RS-232 installations. UART1 remains reserved for potentially powered GPS hardware, and header-connected W25 flash also occupies its pins. Legacy GPS-free Serial1 images remain exact-filename compatibility downloads for existing wiring; selecting the unified image does not migrate wiring or OTA identity."); + notes.push("RAK4631 Ethernet requires its separate exact hardware image; it is not a storage choice in this unified repeater."); + } + } + if (/^RAK_4631_repeater_bridge_rs232_serial1(?:_lora_ota_no_external_sensors)?(?:-(?:full|reduced))?$/i.test(target) && + info.platform === "NRF52_PLATFORM" && info.rs232 === true && info.gps === false) { + notes.push("This legacy GPS-free Serial1 bridge uses UART1. Do not connect powered UART GPS or header-connected W25 flash to the same pins. Prefer the unified UART2 image for new wiring, but changing images does not migrate an existing installation's wiring or OTA identity."); + } + if (/^RAK_4631_repeater_ethernet(?:-(?:full|reduced)(?:-ota)?)?$/i.test(target) && + info.platform === "NRF52_PLATFORM" && profile.mode === "ethernet") { + notes.push("This is the separate RAK4631 Ethernet hardware image, not the unified internal/external-storage repeater. Use its exact Ethernet hardware and update target."); + } + if (/^LilyGo_TLora_V2_1_1_6_repeater(?:_observer_mqtt_?)?-full-(?:usb-wifi|logging)$/i.test(target) && + info.platform === "ESP32_PLATFORM" && profile.feature === "full" && info.espnowBridge === true) { + const observer = /_observer_mqtt/i.test(target); + if (!observer && info.rs232 === true && info.mqtt === false) { + notes.push("T-LoRa V2.1 retains two RAM-safe Full images: UART plus ESP-NOW, or MQTT plus ESP-NOW. This image is the UART/RS-232 choice; it does not include direct Wi-Fi MQTT. Select the separate MQTT observer image if that is needed."); + } else if (observer && info.mqtt === true && info.rs232 === false) { + notes.push("T-LoRa V2.1 retains two RAM-safe Full images: UART plus ESP-NOW, or MQTT plus ESP-NOW. This image is the MQTT observer choice; it does not include the UART/RS-232 bridge. Select the separate UART image if that is needed."); + } + } + if (/^(?:Heltec_v3|Heltec_WSL3|RAK_3112)_repeater(?:_observer_mqtt_?)?-full-usb-wifi$/i.test(target) && + info.platform === "ESP32_PLATFORM" && profile.feature === "full" && + info.rs232 === true && info.mqtt === true && info.espnowBridge === true) { + notes.push("This Full MQTT repeater also retains independent RS-232 and ESP-NOW bridges. Use the separate restore-settings controls for each transport; the UART is not MQTT's historical bridge.enabled alias."); + } + const v4 = /^heltec_v4(?:_3)?(?:_expansionkit)?(?:_r8)?(?:_tft)?$/i.test(profile.hardware); + if (v4 && info.platform === "ESP32_PLATFORM" && info.primaryEspnow === false) { + if (isFullCompanion(profile) && /^heltec_v4(?:_[a-z0-9]+)*_companion_radio_full(?:_fem(?:on|off))?$/i.test(target)) { + notes.push("This Full Companion is LoRa-primary, not the LoRa/ESP-NOW repeater gateway role. For a gateway, choose this exact board's Full repeater when its ESP-NOW bridge is offered."); + } else if (profile.role === "repeater" && profile.feature === "full" && info.espnowBridge === true && + /^heltec_v4(?:_[a-z0-9]+)*_repeater(?:_observer_mqtt_?)?-full-(?:usb-wifi|logging)$/i.test(target)) { + notes.push("This Full repeater keeps LoRa as its primary mesh radio and can bridge to ESP-NOW when that runtime bridge is enabled. Use the matching restore-settings controls below; the Full Companion is a different role, not the repeater gateway."); + } + } + if (profile.hardware === "wio-e5" && profile.role === "repeater" && + (!info.platform || info.platform === "STM32_PLATFORM") && + /^wio-e5(?:_|-)repeater(?:_bridge_rs232)?$/i.test(target)) { + // These exact historical recipes are intentionally separate; do not + // infer the RS-232 capability for an ordinary or Mini Companion image. + notes.push("Wio-E5 is a flash-capacity exception with separate ordinary repeater and RS-232 bridge images. For RS-232 wiring, choose the exact bridge image rather than assuming the ordinary repeater includes that bridge."); + } + return notes; + } + function installSteps(profile, kind) { const common = [ "Verify that the hardware name and every displayed variant match the physical board.", @@ -1962,6 +2024,17 @@ } card.appendChild(actions); + const notes = boardNotes(profile); + if (notes.length) { + const details = createElement("details"); + details.className = "firmware-picker-board-notes"; + details.appendChild(createElement("summary", "Board notes")); + notes.forEach(function (text) { + details.appendChild(createElement("p", text)); + }); + card.appendChild(details); + } + const steps = createElement("div"); steps.className = "firmware-picker-steps"; steps.appendChild(createElement("strong", "What to do")); @@ -2582,6 +2655,8 @@ runtimeDirections: runtimeDirections, runtimePanelDirections: runtimePanelDirections, renderRuntimeDirections: renderRuntimeDirections, + renderProfileCard: renderProfileCard, + boardNotes: boardNotes, installSteps: installSteps, migrationReleaseUrl: migrationReleaseUrl, migrationPackageForProfile: migrationPackageForProfile, diff --git a/docs/_stylesheets/firmware_picker.css b/docs/_stylesheets/firmware_picker.css index 4ab0e86ed..fbbd83413 100644 --- a/docs/_stylesheets/firmware_picker.css +++ b/docs/_stylesheets/firmware_picker.css @@ -8,6 +8,8 @@ .firmware-picker-intro, .firmware-picker-result, .firmware-picker-missing, +.firmware-picker-help, +.firmware-picker-board-notes, .firmware-asset-browser { border: 1px solid var(--picker-border); border-radius: 0.7rem; @@ -338,6 +340,13 @@ margin-top: 1.2rem; } +.firmware-picker-help, +.firmware-picker-board-notes { + margin-top: 1.2rem; +} + +.firmware-picker-help summary, +.firmware-picker-board-notes summary, .firmware-asset-browser summary { cursor: pointer; font-weight: 700; diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 9ede3b423..31ce558f2 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -4,75 +4,7 @@ # Firmware picker -Each result now includes **Restore your settings after flashing**. Its commands -follow the selected logging mode and the exact image's verified role/hardware -controls. Click On, Off, or Check to view and copy the commands; Companion -MQTT and GPS show their app/WebConfig steps instead. See -[feature switches by role](role_feature_switches.md) for the full reference. -The [USB web console](https://flasher.meshcore.io/console) works with the -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. -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 when a choice between different images is needed. - -RAK3401 and RAK4631 repeaters use one unified image per sensor policy for -supported internal and external OTA storage. **Full** and **Reduced** choose -optional sensor drivers; they do not select a storage module. The unified -RAK4631 image also includes RS-232 on UART2, which is recommended for new -installations. The unified image reserves UART1 for potentially powered GPS -hardware; header-connected W25 flash also occupies UART1 pins. Legacy GPS-free -Serial1 downloads remain available through exact-filename search for existing -wiring, but are omitted from normal choices when the matching unified sensor -profile is available. Ethernet remains a separate RAK4631 hardware image. -LoRa OTA still requires the matching -OTAFIX bootloader and a package compatible with the installed target. - -The picker reads public release metadata from GitHub. It does not upload device -information. Hardware names, target names, and download links come directly -from the published firmware assets. -
-
- Current release set -

Loading release information...

-

- 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. 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 - layout? The selected Full result links directly to its exact board and - role partition-expansion ZIP when published. Follow its included README - to check the installed OTA target and source layout for a supported Wi-Fi - or LoRa route. If no unique matching ZIP is available, the picker links - to the utility release to check availability. USB installation uses the matching - merged image instead. An app-only .bin cannot change the partition table. - If the installed firmware supports it, run get storage.layout - to see the live flash size and OTA slots. Firmware version alone does not - prove the installed layout. -

-
- -

- Pick in any order. Use Any to clear one choice, or clear - everything with the button below. The address bar updates with your choices; - use Copy link to settings for a shorter link to the same selection. -

-
-
- - -
- - -
Install operation
@@ -114,13 +53,6 @@ from the published firmware assets.
-
- Firmware role -
- Loading roles... -
-
-
Logging / MQTT
@@ -151,6 +83,11 @@ from the published firmware assets.
+
+ Current release set +

Loading release information...

+
+ +
+ Picker help +

+ Pick in any order. Use Any to clear one choice, or + Clear all choices to start over. Use + Copy link to settings to share your selection. +

+

+ Choices stay limited to compatible builds. Picking hardware selects its + chip family automatically. Filters disappear when they select the same + file; runtime options appear under + Restore your settings after flashing instead. + Installation instructions and board-specific guidance follow your download. +

+

+ The picker reads public release metadata from GitHub. It does not upload + device information. Hardware names, targets, and download links come from + the published firmware assets. +

+
+
Advanced: search current release filenames

@@ -189,6 +147,35 @@ from the published firmware assets. ## What the choices mean +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. +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 when a choice between different images is needed. + +Use **Any** to clear one choice, or **Clear all choices** to start over. +The address bar updates with your choices; use **Copy link to settings** for a +shorter link to the same selection. + +Each result includes **Restore your settings after flashing**. Its commands +follow the selected logging mode and the exact image's verified role/hardware +controls. Click On, Off, or Check to view and copy the commands; Companion +MQTT and GPS show their app/WebConfig steps instead. See +[feature switches by role](role_feature_switches.md) for the full reference. +The [USB web console](https://flasher.meshcore.io/console) works with the +default ASCII terminal on USB Companion and infrastructure roles. + +The picker reads public release metadata from GitHub. It does not upload device +information. Hardware names, target names, and download links come directly +from the published firmware assets. + | Choice | Use | | --- | --- | | Companion | A phone, computer, or host application controls the radio | @@ -210,9 +197,8 @@ Connection and bridge choices depend on the selected role. Companion firmware may offer Full, combined USB + Bluetooth, Bluetooth, USB, Wi-Fi, serial, or 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 in the result's saved logging settings when +board has room; use the selected image's runtime directions to configure it. +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 @@ -229,11 +215,9 @@ supported, `set mqtt.enabled on|off` for MQTT. Newly combined ordinary repeaters start with ESP-NOW off until explicitly enabled, including the first upgrade from unmarked legacy preferences. Later reboots preserve that setting. On combined RS-232 boards without MQTT, `bridge.enabled` controls the UART. -Full Heltec V3, WSL3 and RAK3112 MQTT images also retain RS-232; -use `set rs232.enabled on|off` and `get rs232.running` for their independent -UART. T-LoRa V2.1 keeps two Full choices due to its internal RAM limit: -the normal repeater has UART plus ESP-NOW, and the observer has MQTT plus -ESP-NOW. MQTT and ESP-NOW use one 2.4 GHz radio, so when both are enabled the ESP-NOW bridge channel must +Use the selected download's board notes and runtime controls for images with +independent UART, MQTT, and ESP-NOW services. +MQTT and ESP-NOW use one 2.4 GHz radio, so when both are enabled the ESP-NOW bridge channel must match the connected WiFi access point's fixed channel. ESP-NOW-only mode does not require WiFi credentials. Its runtime `bridge.format` setting chooses the peer protocol: `wrapped` (the @@ -242,10 +226,6 @@ backward-compatible bridge-to-bridge default using `bridge.secret`) or `raw` `SenseCapIndicator-ESPNow`, and other primary-ESP-NOW nodes). This is one firmware choice, not two board images. Match `bridge.channel` to the primary nodes' `espnow.channel` before selecting `set bridge.format raw`. -For Heltec V4 specifically, `companion_radio_full` is still a LoRa-primary -Companion; choose the combined `heltec_v4_repeater` Full firmware and enable -ESP-NOW to make that board the LoRa/ESP-NOW gateway. Use its exact merged artifact when -changing roles or partition layouts. ## Share a selection @@ -288,8 +268,8 @@ Current ESP32 ordinary releases use one Full / complete image per exact board and role. Full keeps the supported feature set and CLI; runtime switches select USB, Bluetooth, Wi-Fi, logging, and other qualified features. Separate portable, minimal, and single-transport ESP32 images are installation/recovery recipes, -not additional ordinary release choices. The documented T-LoRa UART/MQTT RAM -split remains two Full images. Other chip families retain their qualified +not additional ordinary release choices. Board-specific capacity exceptions +are shown with the selected download. Other chip families retain their qualified capacity and sensor-profile choices. Moving an older ESP32 installation to an expanded Full layout requires a diff --git a/mkdocs.yml b/mkdocs.yml index 37cff25f7..b6de631ab 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,7 +120,7 @@ extra_css: - _stylesheets/notification_builder.css?v=20261006-channel9 - https://unpkg.com/leaflet@1.9.4/dist/leaflet.css - _stylesheets/extra.css - - _stylesheets/firmware_picker.css?v=20261001-feedback-1 + - _stylesheets/firmware_picker.css?v=20261013-pick-first-1 - _stylesheets/telemetry_decoder.css - _stylesheets/management_decoder.css - _stylesheets/filter_tool.css @@ -131,7 +131,7 @@ extra_javascript: - _javascript/hosted_doc_links.js?v=20261007-1 - _javascript/notification_builder.js?v=20261006-channel9 - https://unpkg.com/leaflet@1.9.4/dist/leaflet.js - - _javascript/firmware_picker.js?v=20261012-short-picker-1 + - _javascript/firmware_picker.js?v=20261013-board-notes-1 - _javascript/telemetry_decoder.js?v=20261007-hosted-form - _javascript/management_decoder.js - _javascript/filter_tool.js?v=20261002-hashbytes-2plus diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index 2c7e50657..6b503062d 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -2127,6 +2127,94 @@ 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'); +// Board-specific help belongs to exact qualified results, never to a broad +// family filter or an unrelated role which happens to share the board name. +const boardText = profile => picker.boardNotes(profile).join('\n'); +const boardProfile = target => currentCatalog.profiles.find(profile => profile.target === target); +assert.deepStrictEqual(picker.boardNotes(null), []); +assert.deepStrictEqual(picker.boardNotes({}), []); +for (const board of ['RAK_3401', 'RAK_4631']) { + for (const policy of ['full', 'reduced']) { + const profile = boardProfile(board + '_repeater_unified_lora_ota-' + policy); + assert(profile); + const before = JSON.stringify(profile); + const notes = boardText(profile); + assert(notes.includes('storage automatically')); + assert(notes.includes('sensor drivers, not a storage module')); + assert(notes.includes('Internal flash accepts in-place deltas only')); + assert(notes.includes('matching OTAFIX bootloader')); + if (board === 'RAK_4631') { + assert(notes.includes('UART2') && notes.includes('UART1') && notes.includes('W25')); + assert(notes.includes('Legacy GPS-free Serial1') && notes.includes('Ethernet')); + } else { + assert(!notes.includes('UART') && !notes.includes('Ethernet')); + } + assert.strictEqual(JSON.stringify(profile), before, 'boardNotes must not mutate release metadata'); + for (const change of [ + {controls: undefined}, {ota: 'none'}, {sensorProfile: undefined}, + {hardware: 'RAK_4631_ethernet'}, {role: 'companion'}, + {controls: {...profile.controls, platform: 'ESP32_PLATFORM'}}, + {controls: {...profile.controls, updateRequirements: undefined}}, + {controls: {...profile.controls, updateRequirements: {lora: {storage: 'external_only'}}}}, + ]) { + assert.deepStrictEqual(picker.boardNotes({...profile, ...change}), [], + 'Unqualified adaptive-storage metadata must not create a storage promise'); + } + } +} +const boardRak = boardProfile('RAK_4631_repeater_unified_lora_ota-full'); +assert(!boardText({...boardRak, controls: {...boardRak.controls, rs232: false}}).includes('UART2')); +assert(!boardText({...boardRak, controls: {...boardRak.controls, gps: false}}).includes('UART2')); +const boardLegacy = rakPartialCatalog.profiles.find(profile => profile.target === rakBridgeBase + '-reduced'); +assert(boardText(boardLegacy).includes('legacy GPS-free Serial1')); +assert(!boardText(boardLegacy).includes('storage automatically')); +assert.deepStrictEqual(picker.boardNotes({...boardLegacy, + controls: {...boardLegacy.controls, gps: true}}), []); +const boardEthernet = boardProfile('RAK_4631_repeater_ethernet-full'); +assert(boardText(boardEthernet).includes('separate RAK4631 Ethernet hardware image')); +assert(!boardText(boardEthernet).includes('storage automatically')); +assert(!boardText(boardEthernet).includes('Use UART2')); +for (const [profile, selected] of [[tloraNormalProfile, 'UART/RS-232 choice'], + [tloraObserverProfile, 'MQTT observer choice']]) { + assert(boardText(profile).includes('two RAM-safe Full images')); + assert(boardText(profile).includes(selected)); + for (const change of [{controls: undefined}, {feature: 'standard'}, + {controls: {...profile.controls, espnowBridge: false}}, + {controls: {...profile.controls, rs232: true, mqtt: true}}, + {target: profile.target.replace('V2_1_1_6', 'V2_0'), hardware: 'LilyGo_TLora_V2_0'}]) { + assert.deepStrictEqual(picker.boardNotes({...profile, ...change}), []); + } +} +assert(boardText(boardProfile('LilyGo_TLora_V2_1_1_6_repeater-full-logging')) + .includes('UART/RS-232 choice'), 'Current UART-only logging recipe must retain the RAM warning'); +for (const profile of tripleCatalog.profiles) { + assert(boardText(profile).includes('independent RS-232 and ESP-NOW')); + assert.deepStrictEqual(picker.boardNotes({...profile, + controls: {...profile.controls, rs232: false}}), []); +} +for (const profile of currentCatalog.profiles.filter(profile => + /^heltec_v4(?:_[a-z0-9]+)*_companion_radio_full/i.test(profile.target))) { + assert(boardText(profile).includes('Full Companion is LoRa-primary'), profile.target); + assert(!boardText(profile).includes('can bridge to ESP-NOW')); + assert.deepStrictEqual(picker.boardNotes({...profile, controls: undefined}), []); + assert.deepStrictEqual(picker.boardNotes({...profile, + controls: {...profile.controls, primaryEspnow: true}}), []); +} +const boardV4Gateway = boardProfile('heltec_v4_repeater-full-usb-wifi'); +assert(boardText(boardV4Gateway).includes('can bridge to ESP-NOW')); +assert.deepStrictEqual(picker.boardNotes({...boardV4Gateway, + controls: {...boardV4Gateway.controls, espnowBridge: false}}), []); +assert.deepStrictEqual(picker.boardNotes({...boardV4Gateway, role: 'companion'}), []); +assert(boardText(wio).includes('flash-capacity exception')); +assert(boardText(picker.parseTargetProfile('wio-e5_repeater')).includes('separate ordinary repeater')); +for (const target of ['wio-e5_companion_radio_usb', 'wio-e5-mini_companion_radio_usb', + 'wio-e5-mini_repeater', 'wio-e5-dev_repeater', 'RAK_4631_companion_radio_full', + 'RAK_3401_sensor', 't1000e_companion_radio_full', 'heltec_v4_companion_radio_usb']) { + assert.deepStrictEqual(picker.boardNotes(picker.parseTargetProfile(target)), [], target); +} +assert.deepStrictEqual(picker.boardNotes({...wio, controls: {platform: 'ESP32_PLATFORM'}}), []); +console.log('exact-board notes qualify adaptive storage, UART wiring, RAM splits and gateway roles'); + // 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']) { @@ -2178,6 +2266,24 @@ global.document = { createTextNode: text => new RuntimeElement('#text', text), }; try { + const boardContainer = new RuntimeElement('section'); + picker.renderProfileCard(boardContainer, boardRak, boardRak.files[0], 'uf2', {}, {}); + const boardCard = boardContainer.children[0]; + const boardDetails = boardCard.children.find(node => node.className === 'firmware-picker-board-notes'); + assert(boardDetails && boardDetails.tagName === 'DETAILS'); + assert.strictEqual(!!boardDetails.open, false, 'Board notes start collapsed'); + assert.strictEqual(boardDetails.children[0].tagName, 'SUMMARY'); + assert.strictEqual(boardDetails.children[0].textContent, 'Board notes'); + assert(boardCard.children.indexOf(boardDetails) > boardCard.children.findIndex(node => + node.className === 'firmware-picker-actions'), 'Exact download actions stay ahead of board notes'); + assert(boardCard.children.indexOf(boardDetails) < boardCard.children.findIndex(node => + node.className === 'firmware-picker-steps'), 'Board notes precede installation steps'); + assert.strictEqual(boardDetails.children.filter(node => node.tagName === 'P').length, + picker.boardNotes(boardRak).length); + const noNotesContainer = new RuntimeElement('section'); + picker.renderProfileCard(noNotesContainer, companionEsp32, companionEsp32.files[0], 'bin', {}, {}); + assert(!noNotesContainer.children[0].children.some(node => + node.className === 'firmware-picker-board-notes'), 'Unaffected boards must not get an empty notes panel'); const card = new RuntimeElement('article'); const changes = []; picker.renderRuntimeDirections(card, observer, {logging: 'wifi'}, { diff --git a/test/test_firmware_picker_package.py b/test/test_firmware_picker_package.py index 10b8adfa4..d54649953 100644 --- a/test/test_firmware_picker_package.py +++ b/test/test_firmware_picker_package.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Offline picker packaging preserves qualified full/reduced OTA choices.""" import importlib.util +from html.parser import HTMLParser import json from pathlib import Path import re @@ -12,7 +13,54 @@ PACKAGE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(PACKAGE) +class PickerMarkup(HTMLParser): + def __init__(self, text): + super().__init__() + self.tags = [] + self.feed(text) + + def matching(self, attribute, value): + return [(index, tag, attrs) for index, (tag, attrs) in enumerate(self.tags) + if attrs.get(attribute) == value] + + def handle_starttag(self, tag, attrs): + self.tags.append((tag, dict(attrs))) + + class OfflinePickerPackageTests(unittest.TestCase): + def assert_picker_first(self, text): + markup = PickerMarkup(text) + form = markup.matching('data-role', 'form') + release = markup.matching('data-role', 'release-set') + help_panels = markup.matching('class', 'firmware-picker-help') + self.assertEqual(len(form), 1) + self.assertEqual(len(release), 1) + self.assertEqual(len(help_panels), 1) + help_index, help_tag, help_attrs = help_panels[0] + self.assertEqual(help_tag, 'details') + self.assertNotIn('open', help_attrs) + self.assertLess(form[0][0], release[0][0]) + self.assertLess(markup.matching('data-role', 'result')[0][0], help_index) + fields = [(attrs.get('data-field'), index) for index, (_, attrs) + in enumerate(markup.tags) if 'data-field' in attrs] + self.assertEqual(fields[0][0], 'hardwareFamily') + field_positions = dict(fields) + self.assertLess(field_positions['role'], field_positions['install']) + self.assertTrue({'chipFamily', 'hardwareFamily', 'hardware', 'install', + 'role', 'logging', 'firmwareProfile', 'mode'}.issubset( + {field for field, _ in fields})) + for _, attrs in markup.tags: + for described_by in attrs.get('aria-describedby', '').split(): + self.assertEqual(len(markup.matching('id', described_by)), 1) + + def test_hosted_page_starts_with_choices_not_instructions(self): + source = (ROOT / 'docs/firmware_picker.md').read_text(encoding='utf-8') + title_end = source.index('# Firmware picker') + len('# Firmware picker') + picker_start = source.index('

(.*?)', packaged) embedded = json.loads(match[1]) self.assertEqual(embedded['controls'], controls)