From bd55369aa9bcb60345b7022f8973d02d7da5296c Mon Sep 17 00:00:00 2001
From: mikecarper
Date: Sat, 3 Oct 2026 03:11:59 -0700
Subject: [PATCH] Improve firmware tooling docs and OTA seeder startup checks
---
.github/workflows/github-pages.yml | 3 +
docs/_javascript/esp32_partition_catalog.js | 169 ++++++++++
docs/_javascript/firmware_picker.js | 96 +++++-
docs/_stylesheets/esp32_partition_catalog.css | 101 ++++++
docs/_stylesheets/firmware_picker.css | 68 ++++
docs/cli_commands.md | 13 +-
docs/esp32_partition_catalog.md | 40 +++
docs/firmware_picker.md | 15 +-
docs/lora_ota_automation.md | 17 +-
docs/test-results/heltec_v3_preview3_ram.md | 92 ++++++
mkdocs.yml | 9 +-
platformio.heltec-v3-preview3.ini | 81 +++++
scripts/docs_partition_catalog.py | 19 ++
test/test_firmware_picker.js | 71 +++++
test/test_heltec_v3_preview_profile.py | 61 ++++
test/test_partition_catalog_viewer.js | 164 ++++++++++
tools/lora_ota/lora_ota.py | 87 +++++-
tools/lora_ota/rak3401_mota_chain.py | 14 +-
tools/lora_ota/test_lora_ota.py | 2 +
.../lora_ota/test_lora_ota_seeder_startup.py | 290 ++++++++++++++++++
20 files changed, 1396 insertions(+), 16 deletions(-)
create mode 100644 docs/_javascript/esp32_partition_catalog.js
create mode 100644 docs/_stylesheets/esp32_partition_catalog.css
create mode 100644 docs/test-results/heltec_v3_preview3_ram.md
create mode 100644 platformio.heltec-v3-preview3.ini
create mode 100644 scripts/docs_partition_catalog.py
create mode 100644 test/test_heltec_v3_preview_profile.py
create mode 100644 test/test_partition_catalog_viewer.js
create mode 100644 tools/lora_ota/test_lora_ota_seeder_startup.py
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
+
+
+
+
+ Show JSON details
+
+ JSON view
+
+ Snapshot and partition layouts
+ Release inventory
+ Complete catalog (large)
+
+
+ Expand this section to view the JSON.
+
+
+
+With JavaScript disabled, use [the complete JSON file](_data/esp32_partition_catalog.json).
+
## Coverage
The initial snapshot enumerates every published release (including prereleases,
diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md
index 471680dae..41ea381cb 100644
--- a/docs/firmware_picker.md
+++ b/docs/firmware_picker.md
@@ -8,8 +8,11 @@ MQTT and GPS show their app/WebConfig steps instead. See
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. Every selection narrows all the other controls
-to firmware combinations that were actually built in the current release set.
+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.
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
@@ -51,6 +54,14 @@ from the published firmware assets.