diff --git a/.github/workflows/github-pages.yml b/.github/workflows/github-pages.yml index 1a27cebcb..7511497ca 100644 --- a/.github/workflows/github-pages.yml +++ b/.github/workflows/github-pages.yml @@ -32,8 +32,11 @@ jobs: MKDOCS_EDIT_URI: edit/${{ github.ref_name }}/docs/ run: | pip install mkdocs-material + node test/test_firmware_picker.js + node test/test_partition_catalog_viewer.js python scripts/sync_bootloader_manifest.py mkdocs build + cmp firmware/esp32_partition_catalog.json site/_data/esp32_partition_catalog.json - name: Deploy to GitHub Pages uses: peaceiris/actions-gh-pages@v4.1.0 diff --git a/docs/_javascript/esp32_partition_catalog.js b/docs/_javascript/esp32_partition_catalog.js new file mode 100644 index 000000000..7901daf8a --- /dev/null +++ b/docs/_javascript/esp32_partition_catalog.js @@ -0,0 +1,169 @@ +(function (global) { + "use strict"; + + function validateCatalog(catalog) { + if (!catalog || catalog.schema !== 1 || !Array.isArray(catalog.repositories) || + !Array.isArray(catalog.releases) || !Array.isArray(catalog.builds) || + !Array.isArray(catalog.unresolved) || !catalog.layouts || + typeof catalog.layouts !== "object" || Array.isArray(catalog.layouts)) { + throw new Error("Unsupported partition catalog format"); + } + return catalog; + } + + function snapshot(catalog) { + return { + schema: catalog.schema, + generatedAt: catalog.generatedAt, + repositories: catalog.repositories, + counts: { + releases: catalog.releases.length, + builds: catalog.builds.length, + layouts: Object.keys(catalog.layouts).length, + unresolved: catalog.unresolved.length, + }, + }; + } + + function jsonView(catalog, view) { + validateCatalog(catalog); + if (view === "all") return JSON.stringify(catalog, null, 2); + const result = snapshot(catalog); + if (view === "layouts") result.layouts = catalog.layouts; + else if (view === "releases") result.releases = catalog.releases; + else throw new Error("Unknown catalog view"); + return JSON.stringify(result, null, 2); + } + + function boardMinimums(catalog) { + validateCatalog(catalog); + const boards = new Map(); + catalog.builds.forEach(function (build) { + const key = build.board.toLowerCase(); + if (!boards.has(key)) { + boards.set(key, { + board: build.board, smallestBytes: null, + hasDualOta: false, hasNonDualOta: false, hasUnknownLayout: false, + }); + } + const row = boards.get(key); + const layout = Object.prototype.hasOwnProperty.call(catalog.layouts, build.layout) + ? catalog.layouts[build.layout] : null; + if (!layout || !Array.isArray(layout.partitions)) { + row.hasUnknownLayout = true; + return; + } + const apps = layout.partitions.filter(function (entry) { + return Array.isArray(entry) && entry[0] === 0 && + Number.isSafeInteger(entry[3]) && entry[3] > 0; + }); + if (!apps.length) { + row.hasUnknownLayout = true; + return; + } + const smallest = Math.min.apply(null, apps.map((entry) => entry[3])); + row.smallestBytes = row.smallestBytes === null + ? smallest : Math.min(row.smallestBytes, smallest); + if (layout.dualOta === true) row.hasDualOta = true; + else if (layout.dualOta === false) row.hasNonDualOta = true; + else row.hasUnknownLayout = true; + }); + return Array.from(boards.values()).sort(function (left, right) { + return left.board.localeCompare(right.board, "en", { sensitivity: "base" }); + }); + } + + function formatSize(bytes) { + return bytes === null ? "Unknown" : (bytes / 1048576).toLocaleString( + "en-US", { maximumFractionDigits: 4 } + ) + " MiB"; + } + + function otaLayouts(row) { + if (row.hasUnknownLayout) return "Unknown layouts present"; + if (row.hasDualOta && row.hasNonDualOta) return "Mixed (some lack dual OTA)"; + return row.hasDualOta ? "Dual-slot OTA" : "No dual-slot OTA"; + } + + async function initializeViewer() { + const root = document.getElementById("partition-catalog"); + if (!root || root.dataset.initialized) return; + root.dataset.initialized = "true"; + const selector = document.getElementById("partition-catalog-view"); + const status = document.getElementById("partition-catalog-status"); + const code = document.getElementById("partition-catalog-json"); + const link = document.getElementById("partition-catalog-open"); + const raw = document.getElementById("partition-catalog-raw"); + const search = document.getElementById("partition-catalog-search"); + const tbody = document.getElementById("partition-catalog-boards"); + const count = document.getElementById("partition-catalog-count"); + try { + const response = await global.fetch(link.href, { cache: "no-cache" }); + if (!response.ok) throw new Error("HTTP " + response.status); + const catalog = validateCatalog(await response.json()); + const render = function () { + // Catalog text is data, never HTML. Only format the large builds array + // when the reader explicitly requests the complete catalog. + code.textContent = jsonView(catalog, selector.value); + }; + raw.addEventListener("toggle", function () { if (raw.open) render(); }); + if (raw.open) render(); + selector.disabled = false; + selector.addEventListener("change", render); + const rows = boardMinimums(catalog).map(function (board) { + const tr = document.createElement("tr"); + const name = document.createElement("th"); + name.scope = "row"; + name.textContent = board.board; + const size = document.createElement("td"); + size.textContent = formatSize(board.smallestBytes); + if (board.smallestBytes !== null) { + const bytes = document.createElement("small"); + bytes.textContent = board.smallestBytes.toLocaleString("en-US") + " bytes"; + size.appendChild(bytes); + } + const layout = document.createElement("td"); + layout.textContent = otaLayouts(board); + tr.appendChild(name); + tr.appendChild(size); + tr.appendChild(layout); + tbody.appendChild(tr); + return { name: board.board.toLowerCase(), element: tr }; + }); + const filter = function () { + const query = search.value.trim().toLowerCase(); + let visible = 0; + rows.forEach(function (row) { + row.element.hidden = !row.name.includes(query); + if (!row.element.hidden) visible++; + }); + count.textContent = "Showing " + visible + " of " + rows.length + " boards."; + }; + filter(); + search.disabled = false; + search.addEventListener("input", filter); + document.getElementById("partition-catalog-table").hidden = false; + const counts = snapshot(catalog).counts; + status.textContent = "Snapshot " + catalog.generatedAt + ": " + + counts.builds.toLocaleString("en-US") + " builds, " + + counts.releases.toLocaleString("en-US") + " releases, " + + counts.layouts + " layouts, " + counts.unresolved + " unresolved assets."; + } catch (error) { + status.textContent = "Could not load the catalog: " + error.message + + ". Use Open JSON or Download JSON to access the file directly."; + code.textContent = "Catalog preview unavailable."; + } + } + + const api = Object.freeze({ + validateCatalog, jsonView, boardMinimums, formatSize, otaLayouts, initializeViewer, + }); + if (typeof module === "object" && module.exports) module.exports = api; + if (typeof document !== "undefined") { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initializeViewer, { once: true }); + } else { + initializeViewer(); + } + } +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/docs/_javascript/firmware_picker.js b/docs/_javascript/firmware_picker.js index 04263bfa7..44d5f13ca 100644 --- a/docs/_javascript/firmware_picker.js +++ b/docs/_javascript/firmware_picker.js @@ -1079,6 +1079,49 @@ }); } + // Count compiled builds, not files: merged and app-only downloads can belong + // to the same build, but still require different installation instructions. + function selectionProgress(profiles, filters) { + const count = (profiles || []).filter(function (profile) { + return profileMatchesFacets(profile, filters); + }).length; + return { + count: count, + state: count === 1 ? "single" : count ? "multiple" : "empty", + title: count === 1 ? "1 firmware build left" : count ? count + " firmware builds remain" + : "No matching firmware builds", + note: count === 1 + ? filters && filters.install ? "Confirm your board and install choice." + : "Choose an install operation." + : count ? "Choose options to narrow the results." + : "Change or clear choices to see available builds.", + }; + } + + function choicesUseSameFirmware(profiles, filters, field, options) { + // Never equate install operations, even when they belong to one build. + if (!["hardware", "role", "logging", "mode", "firmwareProfile"].includes(field)) { + return false; + } + const ignored = field === "firmwareProfile" ? PROFILE_FIELDS : field; + const compatible = (profiles || []).filter(function (profile) { + return profileMatchesFacets(profile, filters, ignored); + }); + const choices = (options || []).filter(function (option) { return option.value; }); + if (!compatible.length || !choices.length) return false; + // Ignore the current choice before comparing alternatives. Having one + // result now does not imply that another button would keep the same build. + return choices.every(function (option) { + const choice = field === "firmwareProfile" ? firmwareProfileFilters(option.value) + : { [field]: option.value }; + if (!choice) return false; + const selection = Object.assign({}, filters, choice); + return compatible.every(function (profile) { + return profileMatchesFacets(profile, selection); + }); + }); + } + function selectionUrl(url, filters, automaticChipFamily) { const result = new URL(url); FACET_FIELDS.forEach(function (field) { @@ -1751,6 +1794,8 @@ const shareLink = root.querySelector('[data-role="share-link"]'); const copyLinkButton = root.querySelector('[data-action="copy-link"]'); const linkStatus = root.querySelector('[data-role="link-status"]'); + const feedback = root.querySelector('[data-role="filter-feedback"]'); + const resultLink = root.querySelector('[data-role="view-results"]'); const controls = {}; FACET_FIELDS.forEach(function (field) { controls[field] = form.querySelector('[data-field="' + field + '"]'); @@ -1762,6 +1807,10 @@ const filters = {}; let automaticChipFamily = false; const groupPrefix = "firmware-picker-" + (++pickerInstanceCount); + if (resultLink) { + result.id = result.id || groupPrefix + "-results"; + resultLink.href = "#" + result.id; + } function updateSelectionUrl() { const url = selectionUrl(global.location.href, filters, automaticChipFamily); @@ -1855,6 +1904,35 @@ renderRadioChoices(container, options, selected, groupPrefix + "-" + field, field); const fieldset = container.closest("fieldset"); if (fieldset) fieldset.disabled = values.length === 0; + setRefinementHint(container, field, options); + } + + function setRefinementHint(container, field, options) { + const fieldset = container.closest("fieldset"); + if (!fieldset) return; + let badge = fieldset.querySelector('[data-role="same-firmware"]'); + let hint = fieldset.querySelector('[data-role="refinement-hint"]'); + if (!badge) { + badge = createElement("span", "Same firmware"); + badge.className = "firmware-picker-same-firmware"; + badge.dataset.role = "same-firmware"; + fieldset.querySelector("legend").appendChild(badge); + hint = createElement("p"); + hint.className = "firmware-picker-control-help firmware-picker-refinement-hint"; + hint.dataset.role = "refinement-hint"; + hint.id = groupPrefix + "-" + field + "-hint"; + fieldset.insertBefore(hint, container); + } + const same = choicesUseSameFirmware(catalog.profiles, filters, field, options); + badge.hidden = !same; + hint.hidden = !same; + hint.textContent = same ? "These choices do not narrow the firmware builds." + + (["logging", "mode"].includes(field) ? " They can change setup instructions." : "") : ""; + const describedBy = (fieldset.getAttribute("aria-describedby") || "") + .split(/\s+/).filter(function (id) { return id && id !== hint.id; }); + if (same) describedBy.push(hint.id); + if (describedBy.length) fieldset.setAttribute("aria-describedby", describedBy.join(" ")); + else fieldset.removeAttribute("aria-describedby"); } function setControlOptions(field, values) { @@ -1931,6 +2009,7 @@ renderRadioChoices(profileControl, [{ value: "", label: "Any" }].concat(profileChoices), firmwareProfileValue(filters), groupPrefix + "-firmwareProfile", "firmwareProfile"); profileControl.closest("fieldset").disabled = profileChoices.length === 0; + setRefinementHint(profileControl, "firmwareProfile", profileChoices); const hardwareVariants = valuesForField("hardware"); const hardwareVariantControl = root.querySelector( '[data-role="hardware-variant-control"]' @@ -1963,9 +2042,20 @@ return field !== "chipFamily" && !filters[field]; }); const matches = matchingProfiles(); + if (feedback) { + const progress = selectionProgress(catalog.profiles, filters); + 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; + } + if (resultLink) resultLink.hidden = true; if (missingFields.length) { - if (missingFields.length === CHOICE_FIELDS.length - 1 && !filters.chipFamily) { + 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."; + } else if (missingFields.length === CHOICE_FIELDS.length - 1 && !filters.chipFamily) { status.textContent = "Pick options in any order. " + matches.length + " compatible configurations are available."; } else { @@ -1993,6 +2083,7 @@ ); }); result.hidden = false; + if (resultLink) resultLink.hidden = false; } } return; @@ -2024,6 +2115,7 @@ ); }); result.hidden = false; + if (resultLink) resultLink.hidden = false; } function renderBootloader() { @@ -2265,6 +2357,8 @@ firmwareProfileFilters: firmwareProfileFilters, firmwareProfileLabel: firmwareProfileLabel, firmwareProfileChoices: firmwareProfileChoices, + selectionProgress: selectionProgress, + choicesUseSameFirmware: choicesUseSameFirmware, selectionUrl: selectionUrl, selectionFromUrl: selectionFromUrl, canonicalAsset: canonicalAsset, diff --git a/docs/_stylesheets/esp32_partition_catalog.css b/docs/_stylesheets/esp32_partition_catalog.css new file mode 100644 index 000000000..2147ef91c --- /dev/null +++ b/docs/_stylesheets/esp32_partition_catalog.css @@ -0,0 +1,101 @@ +.partition-catalog { + min-width: 0; + margin: 1rem 0; +} + +.partition-catalog-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem 1rem; +} + +.partition-catalog-controls label { + font-weight: 700; +} + +.partition-catalog-controls select, +.partition-catalog-controls input { + max-width: 100%; + padding: 0.5rem; + border: 1px solid var(--md-default-fg-color--light); + border-radius: 0.25rem; + background: var(--md-default-bg-color); + color: var(--md-default-fg-color); + font: inherit; +} + +.partition-catalog-controls select:focus-visible, +.partition-catalog-controls input:focus-visible, +.partition-catalog-table:focus-visible, +.partition-catalog-json:focus-visible { + outline: 2px solid var(--md-accent-fg-color); + outline-offset: 2px; +} + +.partition-catalog-table { + max-height: 32rem; + overflow: auto; + overscroll-behavior: contain; +} + +.partition-catalog [hidden] { + display: none !important; +} + +.md-typeset .partition-catalog-table table { + display: table; + width: 100%; + margin: 0; +} + +.partition-catalog-table thead th { + position: sticky; + top: 0; + background: var(--md-default-bg-color); +} + +.partition-catalog-table tbody th { + overflow-wrap: anywhere; +} + +.partition-catalog-table td small { + display: block; + color: var(--md-default-fg-color--light); + white-space: nowrap; +} + +.partition-catalog-raw { + margin-top: 1rem; +} + +.partition-catalog-raw summary { + cursor: pointer; +} + +.partition-catalog-raw .partition-catalog-controls { + margin-top: 1rem; +} + +@media (max-width: 600px) { + .md-typeset .partition-catalog-table table th, + .md-typeset .partition-catalog-table table td { + padding: 0.5rem; + } +} + +.md-typeset .partition-catalog-json { + max-height: 32rem; + overflow: auto; + overscroll-behavior: contain; + border: 1px solid var(--md-default-fg-color--lightest); + border-radius: 0.25rem; +} + +.md-typeset .partition-catalog-json > code { + display: block; + padding: 1rem; + white-space: pre; + overflow-wrap: normal; + word-break: normal; +} diff --git a/docs/_stylesheets/firmware_picker.css b/docs/_stylesheets/firmware_picker.css index a23f3421e..4ab0e86ed 100644 --- a/docs/_stylesheets/firmware_picker.css +++ b/docs/_stylesheets/firmware_picker.css @@ -38,6 +38,46 @@ margin: 1.2rem 0; } +.firmware-picker [hidden] { + display: none !important; +} + +.firmware-picker-feedback { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; + padding: 0.65rem 0.8rem; + border: 1px solid var(--picker-border); + border-left: 0.3rem solid var(--picker-accent); + border-radius: 0.4rem; + background: var(--md-default-bg-color, white); +} + +.firmware-picker-feedback[data-state="single"] { + border-color: var(--picker-accent); +} + +.firmware-picker-feedback p { + margin: 0.2rem 0 0; + font-size: 0.75rem; + line-height: 1.35; +} + +.firmware-picker-feedback a { + flex-shrink: 0; + padding: 0.55rem 0.65rem; + border: 1px solid var(--picker-accent); + border-radius: 0.35rem; + font-size: 0.8rem; + font-weight: 700; +} + +.firmware-picker-feedback a:focus-visible { + outline: 0.16rem solid var(--picker-accent); + outline-offset: 0.12rem; +} + .firmware-picker-order-note { margin: 1rem 0 0; color: var(--md-default-fg-color--light); @@ -76,10 +116,26 @@ } .firmware-picker-radio-control > legend { + display: flex; + flex-wrap: wrap; + align-items: center; margin-bottom: 0.35rem; padding: 0; } +.firmware-picker-same-firmware { + padding: 0.15rem 0.45rem; + border: 1px solid var(--picker-border); + border-radius: 999px; + font-size: 0.65rem; + font-weight: 600; + background: color-mix(in srgb, var(--picker-accent) 10%, transparent); +} + +.firmware-picker-refinement-hint { + font-size: 0.75rem; +} + .firmware-picker-select-control select, .firmware-asset-browser input { width: 100%; @@ -190,6 +246,7 @@ .firmware-picker-result { border-top: 0.3rem solid var(--picker-accent); + scroll-margin-top: 4rem; } .firmware-picker-eyebrow { @@ -309,6 +366,17 @@ grid-template-columns: 1fr; } + .firmware-picker-feedback { + position: sticky; + top: 0.5rem; + z-index: 2; + box-shadow: 0 0.2rem 0.5rem rgb(0 0 0 / 10%); + } + + .md-typeset .firmware-picker-feedback { + top: calc(var(--md-header-height, 2.4rem) + 0.4rem); + } + .firmware-picker-facts { grid-template-columns: 1fr; } diff --git a/docs/cli_commands.md b/docs/cli_commands.md index 228dc58a4..58a32ac75 100644 --- a/docs/cli_commands.md +++ b/docs/cli_commands.md @@ -914,7 +914,18 @@ All display-equipped Companion builds show the screen at boot in the default selected profile's timeout (15 seconds by default); button input restarts that timer. This also applies to previously saved button modes. Saved `off` and pairing-only modes still suppress the boot screen. Repeaters, room servers, -and sensors do not gain this Companion-only boot wake. +and sensors do not gain this Companion-only normal-UI boot wake. + +On display-equipped Companions, repeaters, room servers, and sensors, a small +`Starting...` screen appears as soon as the board, primary filesystem, and +display are initialized, before radio initialization and serial-settle delays. +If a new identity is needed, it changes to `Generating key` with an animated +spinner during radio entropy collection. OLED/TFT updates are paced at 150 ms; +e-paper uses a slower one-second cadence. Saved `off` and pairing-only modes +still suppress this startup screen. The normal UI's boot timeout starts only +when that UI is ready, so key generation does not consume it. Filesystem +recovery and the display driver's own initialization can still delay the first +visible frame; the firmware cannot draw before those operations finish. Startup shows `Loading identity` while reading a saved identity. `Generating key` and its spinner are used only when creating a replacement/new identity; diff --git a/docs/esp32_partition_catalog.md b/docs/esp32_partition_catalog.md index 25966d6ed..08dc41ef3 100644 --- a/docs/esp32_partition_catalog.md +++ b/docs/esp32_partition_catalog.md @@ -5,6 +5,46 @@ The canonical LUT is `firmware/esp32_partition_catalog.json` in this fork. assets. MeshCore Open bundles an identical copy for offline phone updates. There is no need to run a command on the phone or download old firmware there. +## Smallest partition by board + +The table shows each board's **smallest known application partition** across +all cataloged releases, roles, and profiles. It does not count smaller NVS or +filesystem partitions. Sizes use MiB (1,048,576 bytes), with exact bytes below. +Mixed layouts means some releases have dual-slot OTA and others do not. +These are historical factory-image minimums, not measurements of your device; +use `get storage.layout` to check its actual installed layout. + +
Loading catalog JSON...
+ +| Board | Smallest app partition | OTA layouts |
|---|
Expand this section to view the JSON.
+