Improve firmware tooling docs and OTA seeder startup checks

This commit is contained in:
mikecarper committed 2026-10-03 03:12:24 -07:00
1 parent 4086222d85
commit bd55369aa9
20 files changed
+1396 -16

No files matched your search

+3
View File
@@ -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
+169
View File
@@ -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);
+95 -1
View File
@@ -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,
@@ -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;
}
+68
View File
@@ -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;
}
+12 -1
View File
@@ -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;
+40
View File
@@ -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.
<div class="partition-catalog" id="partition-catalog">
<div class="partition-catalog-controls">
<label for="partition-catalog-search">Board</label>
<input id="partition-catalog-search" type="search" placeholder="Search board name" autocomplete="off" disabled>
<a id="partition-catalog-open" href="../_data/esp32_partition_catalog.json">Open JSON</a>
<a href="../_data/esp32_partition_catalog.json" download="esp32_partition_catalog.json">Download JSON</a>
</div>
<p id="partition-catalog-status" role="status" aria-live="polite">Loading catalog JSON...</p>
<p id="partition-catalog-count" role="status" aria-live="polite"></p>
<div class="partition-catalog-table" id="partition-catalog-table" tabindex="0" aria-label="Smallest application partition by board" hidden>
<table>
<thead><tr><th scope="col">Board</th><th scope="col">Smallest app partition</th><th scope="col">OTA layouts</th></tr></thead>
<tbody id="partition-catalog-boards"></tbody>
</table>
</div>
<details class="partition-catalog-raw" id="partition-catalog-raw">
<summary>Show JSON details</summary>
<div class="partition-catalog-controls">
<label for="partition-catalog-view">JSON view</label>
<select id="partition-catalog-view" disabled>
<option value="layouts">Snapshot and partition layouts</option>
<option value="releases">Release inventory</option>
<option value="all">Complete catalog (large)</option>
</select>
</div>
<pre class="partition-catalog-json" tabindex="0" aria-label="Partition catalog JSON"><code id="partition-catalog-json">Expand this section to view the JSON.</code></pre>
</details>
</div>
With JavaScript disabled, use [the complete JSON file](_data/esp32_partition_catalog.json).
## Coverage
The initial snapshot enumerates every published release (including prereleases,
+13 -2
View File
@@ -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.
</p>
<form class="firmware-picker-form" data-role="form">
<div class="firmware-picker-feedback firmware-picker-wide" data-role="filter-feedback" hidden>
<div aria-live="polite" aria-atomic="true">
<strong data-role="filter-count"></strong>
<p data-role="filter-note"></p>
</div>
<a data-role="view-results" hidden>View files</a>
</div>
<details class="firmware-picker-chip-family firmware-picker-wide">
<summary data-role="chip-family-summary">Optional: chip family</summary>
<p id="firmware-picker-chip-help">Skip this if you know your board. Picking hardware selects its chip family automatically. Choose Any to clear this filter.</p>
+16 -1
View File
@@ -774,6 +774,20 @@ verbose device log contains its `COUNT` acknowledgement; an immediate device
`ERR` or a missing acknowledgement fails during startup instead of surfacing
later as a catalog timeout.
Newer `motatool` builds prepare raw-DEFLATE blocks with Zopfli before opening
the source link. This host-only work has a separate 30-minute preparation
limit (`--seeder-prepare-wait SECONDS`) and reports progress while waiting.
The five-second device acknowledgement timer (`--seeder-start-wait SECONDS`)
starts after the final `compressed N/N blocks` message, not when compression
starts. Neither compression progress nor a host "serving" message proves
device readiness: the runner still requires the device's `COUNT` reply.
Older motatool builds without a preparation phase keep the original device
startup timeout. Process exits and device errors still fail immediately.
Preparation happens after TempRadio is armed, so leave enough lease time for
both compression and transfer, especially on a slow host. The suggested
TempRadio budget includes the preparation limit; it remains a warning, not a
hard requirement to select that entire worst-case budget.
```bash
./tools/lora_ota/lora_ota.sh ./release.mota "Remote Target" \
--controller-serial /dev/ttyACM0 \
@@ -905,7 +919,8 @@ the destination.
target, far-to-near relays, and source. A separate controller is moved and
read back through Binary; a shared Full Companion instead schedules its
bounded local override while Binary remains the transport. The runner
rejects a TempRadio window that cannot cover setup, seeder startup,
rejects TempRadio windows under 20 minutes and warns when the selected
window does not cover worst-case setup, seeder preparation and startup,
discovery, the transfer timeout, final polling, and install checks.
7. Start `motatool serve`, discover the exact eight-hex manifest ID, request
`ota pull <id> flash`, and poll until that same ID reports ready. A seeder
@@ -0,0 +1,92 @@
# Full Companion RAM audit and Heltec V3 preview 3
Date: 2026-10-02. Baseline: `0ccac453` plus the early startup-screen changes
included in this revision. All 13 selected ordinary Full builds passed the
existing linked runtime RAM guard. This is a targeted audit, not a claim that
every Companion has been rebuilt or hardware-qualified.
## Builds needing the most attention
| Hardware | Available bytes | Reserved bytes | Margin bytes |
| --- | ---: | ---: | ---: |
| Generic ESP-NOW | 143,872 | 142,336 | 1,536 |
| Tracker V2 (FEM on) | 180,000 | 177,746 | 2,254 |
| Heltec V3 | 185,032 | 182,016 | 3,016 |
| XIAO ESP32-C3 | 146,048 | 142,336 | 3,712 |
| Heltec CT62 | 146,240 | 142,336 | 3,904 |
| Wireless Tracker | 184,304 | 178,946 | 5,358 |
| Heltec T096 (FEM on) | 84,152 | 75,298 | 8,854 |
| Heltec T1 | 84,488 | 75,298 | 9,190 |
| LilyGo TLora V2.1.1.6 | 185,304 | 173,840 | 11,464 |
| Heltec V2 | 187,176 | 173,840 | 13,336 |
| TBeam SX1276 | 194,160 | 169,744 | 24,416 |
| TBeam SX1262 | 196,112 | 169,744 | 26,368 |
| Heltec T114 | 84,336 | 46,880 | 37,456 |
The six smallest margins belong to non-PSRAM ESP32 Full Companions. They pass
their existing release capacities but have little room for further growth.
Heltec T096 and T1 are the next constrained screen-equipped nRF52 Full builds.
T114 Full passes; the failed older T114 BLE-only recipe is not a Full failure.
Values come from the firmware RAM guard in the retained local
`out/companion-ram-*-before.log` build logs. Available bytes exclude statically
reserved memory and unavailable internal regions; the reservation covers
startup allocations and a transient allowance. The margin is not measured
live free heap or a prediction of time to failure. No safety budget was lowered.
## V3 solution under test
The opt-in `platformio.heltec-v3-preview3.ini` profile uses the existing
NimBLE-Arduino adapter, pinned to version 2.5.1, instead of Bluedroid. It retains
the current ordinary V3 Full capacities: 100 contacts, 40 channels, and 256
offline frames, including during mOTA. It adds no new queue-sharing or storage
changes and does not enable the larger 350-contact capacity trial.
| V3 image | Available internal bytes | Reserved bytes | Margin bytes |
| --- | ---: | ---: | ---: |
| Published preview 2 | 185,040 | 182,016 | 3,024 |
| Preview 3 | 200,152 | 182,016 | 18,136 |
Compared with published preview 2, preview 3 recovers 15,112 bytes of internal
RAM without removing Full features. Its bootloader and partition-table bytes
match preview 2. The normal release matrix is unchanged.
Bluetooth still provides the Companion server, encrypted PIN pairing,
bonding, MAC policy and stealth controls. Existing Bluedroid bonds are not
migrated: forget the old device in the phone's Bluetooth settings and pair
again after installation. This does not require erasing contacts or identity.
The preview also contains the early `Starting...` screen and animated key
generation display. The saved Off and pairing-only display modes remain
respected. Normal UI timeout starts after startup. Fresh installs still
default to Bluetooth on and infrastructure WiFi off; saved WiFi-on settings
remain on. Use `set wifi.enabled 1` to enable WiFi.
## Verification and remaining work
The real embedded build passes the unchanged RAM guard and fits both existing
WiFi OTA slots. Capability checks inspect the linked ELF, packaged application
and WebConfig gzip asset. The package includes ELF-bound RAM proof and hashes.
Adapter regressions exercise actual NimBLE transport code under sanitizers,
including identity/bond failures, encrypted PIN authentication, stealth,
notifications, MTU gating and frame retry. Startup-screen, display policy,
Companion preferences, USB defaults, identity and frame-queue regressions are
also checked. Embedded compilation uses the actual pinned NimBLE library.
This is still a hardware-test preview. V3 first-boot screen, phone pairing and
reconnection, contact sync, WiFi/BLE coexistence, mOTA sending, and sustained
load need physical verification. A build passing RAM checks does not establish
that all reported V3 boot symptoms are fixed. Do not make this stack the default
on every board before those checks.
Reproduce with one PlatformIO process at a time:
```sh
pio run -c platformio.heltec-v3-preview3.ini \
-e Heltec_v3_companion_radio_full_preview3 -t mergebin
python3 -B test/test_heltec_v3_preview_profile.py
python3 -B test/test_nimble_companion.py
python3 -B test/test_startup_screen.py
python3 -B test/test_firmware_ram.py
```
+7 -2
View File
@@ -6,6 +6,9 @@ repo_name: !ENV [MKDOCS_REPO_NAME, 'meshcore-dev/meshcore']
repo_url: !ENV [MKDOCS_REPO_URL, 'https://github.com/meshcore-dev/meshcore/']
edit_uri: !ENV [MKDOCS_EDIT_URI, 'edit/main/docs/']
hooks:
- scripts/docs_partition_catalog.py
# Keep historical URLs for bookmarks while showing only the organized copies.
not_in_nav: |
/*_validation.md
@@ -110,17 +113,19 @@ extra_css:
- _stylesheets/notification_builder.css
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.css
- _stylesheets/extra.css
- _stylesheets/firmware_picker.css?v=20260929-1
- _stylesheets/firmware_picker.css?v=20261001-feedback-1
- _stylesheets/telemetry_decoder.css
- _stylesheets/management_decoder.css
- _stylesheets/filter_tool.css
- _stylesheets/preset_test.css?v=20260923-2
- _stylesheets/esp32_partition_catalog.css?v=20261001-1
extra_javascript:
- _javascript/notification_builder.js?v=20260930-audio-start
- https://unpkg.com/leaflet@1.9.4/dist/leaflet.js
- _javascript/firmware_picker.js?v=20260929-4
- _javascript/firmware_picker.js?v=20261001-feedback-1
- _javascript/telemetry_decoder.js
- _javascript/management_decoder.js
- _javascript/filter_tool.js?v=20261002-hashbytes-2plus
- _javascript/preset_test.js?v=20260923-2
- _javascript/esp32_partition_catalog.js?v=20261001-1
+81
View File
@@ -0,0 +1,81 @@
; Heltec V3 Full Companion 1.17.1.8 preview3. See README.txt for source and pairing.
[platformio]
extra_configs =
platformio.ini
variants/*/platformio.ini
[env:Heltec_v3_companion_radio_full_preview3]
extends = env:Heltec_v3_companion_radio_full
board_build.flash_mode = dio
board_build.partitions = default_8MB.csv
build_unflags =
-DMAX_CONTACTS=350
-DLORA_FREQ=869.618
-DLORA_SF=8
-DDISABLE_WIFI_OTA=1
-fexceptions
build_flags =
${env:Heltec_v3_companion_radio_full.build_flags}
-D MAX_CONTACTS=100
-D COMPANION_RADIO_FULL=1
-D ENABLE_USB_INTERFACE=1
-D BLE_PIN_CODE=123456
-D ENABLE_OTA=1
-UOTA_FLASH_STORE
-UOTA_SD_STORE
-UDISABLE_LORA_OTA
-UWEBCONFIG_DISABLED
-UDISABLE_WIFI_OTA
-D OTA_SEEDER_ONLY=1
-D MOTA_TARGET_ID=0
-D WIFI_OTA_SEEDER=1
-D COMPANION_FEATURE_TEMP_RADIO=1
-D COMPANION_FEATURE_OTA_CLI=1
-D COMPANION_FEATURE_USB_MOTA_SOURCE=1
-D COMPANION_FEATURE_NETWORK_TERMINAL=1
-D COMPANION_FEATURE_MEMORY_DIAGNOSTICS=1
-D MESHCORE_EXPANDED_PARTITION_PROFILE=1
-D LIGHTWEIGHT_WIFI_OTA=1
-D MESH_DEBUG=1
-D MESH_PACKET_LOGGING=1
-D MESH_USE_NIMBLE_ARDUINO=1
-D CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
-D CONFIG_BT_NIMBLE_ROLE_CENTRAL_DISABLED=1
-D CONFIG_BT_NIMBLE_ROLE_OBSERVER_DISABLED=1
-D LORA_FREQ=910.525
-D LORA_BW=62.5
-D LORA_SF=7
-D LORA_CR=5
-D MESHCORE_USA_RADIO_PRESET=1
-D CASCADE_PROFILE=1
-D DEFAULT_PATH_HASH_MODE=2
-D DEFAULT_LOOP_DETECT=1
-D DEFAULT_CAD_ENABLED=0
-D DEFAULT_RX_DELAY_BASE=2.0f
-D DEFAULT_AGC_RESET_INTERVAL_SECONDS=8
-D DEFAULT_ADVERT_INTERVAL_MINUTES=0
-D DEFAULT_FLOOD_ADVERT_INTERVAL_HOURS=83
-D DEFAULT_MULTI_ACKS=1
-D DEFAULT_MANUAL_ADD_CONTACTS=1
-D DEFAULT_AUTOADD_CONFIG=0
-D DEFAULT_POWERSAVING_ENABLED=1
-D DEFAULT_RXPS_ENABLED=1
-D DEFAULT_RXPS_LEVEL=8
-D DEFAULT_RXPS_PREAMBLE=16
-D RXPS_FIXED_ENABLED=1
-D RXPS_FIXED_LEVEL=8
-D RXPS_FIXED_PREAMBLE=16
-D DEFAULT_WIFI_POWER_SAVE_MODE=0
-D FIRMWARE_VERSION='"v1.17.1.8-preview3"'
-D FIRMWARE_BUILD_DATE='"02-Oct-2026"'
-D FIRMWARE_BUILD_EPOCH=1790928488
-D OTA_VARIANT='"Heltec_v3_companion_radio_full"'
-fno-exceptions
build_src_filter =
${env:Heltec_v3_companion_radio_full.build_src_filter}
+<helpers/ota/*.cpp>
+<helpers/ota/detools/detools.c>
lib_deps =
${env:Heltec_v3_companion_radio_full.lib_deps}
h2zero/NimBLE-Arduino @ 2.5.1
lib_ignore = ESP32 BLE Arduino
+19
View File
@@ -0,0 +1,19 @@
"""Publish the canonical partition catalog without keeping a second source copy."""
from pathlib import Path
from mkdocs.structure.files import File
CATALOG_URI = "_data/esp32_partition_catalog.json"
def on_files(files, config):
source = (
Path(config.config_file_path).resolve().parent
/ "firmware" / "esp32_partition_catalog.json"
)
if not source.is_file():
raise FileNotFoundError(f"Canonical partition catalog is missing: {source}")
files.append(File.generated(config, CATALOG_URI, abs_src_path=str(source)))
return files
+71
View File
@@ -1513,3 +1513,74 @@ assert(!picker.firmwareProfileChoices(catalog.profiles, {
hardware: 'Station_G2', role: 'repeater', variant: 'missing',
}).some(item => item.filters.variant === 'missing'));
console.log('combined firmware profile choices and legacy links passed');
// Mobile progress counts builds, not the installation files for each build.
const feedbackFilters = {hardware: 'Heltec_v3', role: 'companion'};
const v3Feedback = controlled.profiles.find(p => p.target === 'Heltec_v3_companion_radio_full');
const feedbackCatalog = [Object.assign({}, v3Feedback, {
installKinds: ['merged-bin', 'bin'],
})];
const feedbackSnapshot = JSON.stringify(feedbackCatalog);
assert.deepStrictEqual(picker.selectionProgress(feedbackCatalog, feedbackFilters), {
count: 1, state: 'single', title: '1 firmware build left',
note: 'Choose an install operation.',
});
for (const install of ['merged-bin', 'bin']) {
const progress = picker.selectionProgress(feedbackCatalog, {...feedbackFilters, install});
assert.strictEqual(progress.count, 1);
assert(progress.note.includes('Confirm your board and install choice'));
}
assert.strictEqual(picker.selectionProgress(controlled.profiles, {}).state, 'multiple');
assert.strictEqual(picker.selectionProgress(controlled.profiles, {}).count, controlled.profiles.length);
assert.strictEqual(picker.selectionProgress(feedbackCatalog, {hardware: 'Missing'}).state, 'empty');
assert.strictEqual(picker.selectionProgress([], {}).count, 0);
assert.strictEqual(picker.selectionProgress([], {}).title, 'No matching firmware builds');
assert.strictEqual(picker.selectionProgress(feedbackCatalog).count, 1);
function feedbackOptions(values) {
return [{value: ''}].concat(values.map(value => ({value})));
}
assert(picker.choicesUseSameFirmware(feedbackCatalog, feedbackFilters, 'logging',
feedbackOptions(v3Feedback.loggingModes)));
assert(picker.choicesUseSameFirmware(feedbackCatalog, {...feedbackFilters, logging: 'usb'}, 'logging',
feedbackOptions(v3Feedback.loggingModes)));
assert(picker.choicesUseSameFirmware(feedbackCatalog, feedbackFilters, 'mode',
feedbackOptions(picker.profileFieldValues(v3Feedback, 'mode'))));
assert(picker.choicesUseSameFirmware(feedbackCatalog, feedbackFilters, 'firmwareProfile',
picker.firmwareProfileChoices(feedbackCatalog, feedbackFilters)));
// A sole available option can be marked without disabling it or selecting it.
assert(picker.choicesUseSameFirmware(feedbackCatalog, feedbackFilters, 'role',
feedbackOptions(['companion'])));
assert(!picker.choicesUseSameFirmware(feedbackCatalog, feedbackFilters, 'install',
feedbackOptions(['merged-bin', 'bin'])));
// One currently selected build is not enough: alternatives may change builds.
const feedbackAlternatives = [
{...feedbackCatalog[0], loggingModes: ['none', 'usb'], mode: 'usb', connectionModes: ['usb']},
{...feedbackCatalog[0], target: 'second-build', loggingModes: ['wifi'],
mode: 'wifi', connectionModes: ['wifi'], feature: 'standard', ota: 'none'},
];
const selectedFeedback = {...feedbackFilters, logging: 'usb'};
assert.strictEqual(picker.selectionProgress(feedbackAlternatives, selectedFeedback).count, 1);
assert(!picker.choicesUseSameFirmware(feedbackAlternatives, selectedFeedback, 'logging',
feedbackOptions(['none', 'usb', 'wifi'])));
assert(!picker.choicesUseSameFirmware(feedbackAlternatives, {...feedbackFilters, mode: 'usb'}, 'mode',
feedbackOptions(['usb', 'wifi'])));
const selectedProfileFeedback = {...feedbackFilters, feature: 'full', ota: 'lora-source', variant: 'default'};
assert(!picker.choicesUseSameFirmware(feedbackAlternatives, selectedProfileFeedback, 'firmwareProfile',
picker.firmwareProfileChoices(feedbackAlternatives, selectedProfileFeedback)));
// Multiple builds can all share a runtime choice; report that accurately too.
assert(picker.choicesUseSameFirmware(feedbackAlternatives.map(p => ({...p, loggingModes: ['none', 'usb']})),
feedbackFilters, 'logging', feedbackOptions(['none', 'usb'])));
for (const partial of [{feature: 'full'}, {ota: 'lora-source'}]) {
const selection = {...feedbackFilters, ...partial};
assert(picker.choicesUseSameFirmware(feedbackCatalog, selection, 'firmwareProfile',
picker.firmwareProfileChoices(feedbackCatalog, selection)));
}
assert(!picker.choicesUseSameFirmware(feedbackCatalog, feedbackFilters, 'firmwareProfile',
[{value: 'invalid'}]));
assert(!picker.choicesUseSameFirmware(feedbackCatalog, feedbackFilters, 'role', [{value: ''}]));
assert(!picker.choicesUseSameFirmware([], {}, 'role', feedbackOptions(['companion'])));
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');
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Keep the opt-in V3 preview Full, capacity-preserving, and reproducible."""
import configparser
from pathlib import Path
import re
import unittest
ROOT = Path(__file__).resolve().parents[1]
class HeltecV3PreviewProfileTest(unittest.TestCase):
def setUp(self):
self.config = configparser.ConfigParser(interpolation=None)
self.config.read(ROOT / "platformio.heltec-v3-preview3.ini")
self.env = self.config["env:Heltec_v3_companion_radio_full_preview3"]
def test_full_features_and_existing_capacities(self):
flags = self.env["build_flags"]
for name in (
"COMPANION_RADIO_FULL", "ENABLE_USB_INTERFACE", "ENABLE_OTA",
"OTA_SEEDER_ONLY", "WIFI_OTA_SEEDER",
"COMPANION_FEATURE_TEMP_RADIO", "COMPANION_FEATURE_OTA_CLI",
"COMPANION_FEATURE_USB_MOTA_SOURCE",
"COMPANION_FEATURE_NETWORK_TERMINAL",
"COMPANION_FEATURE_MEMORY_DIAGNOSTICS"):
self.assertIn("-D " + name + "=1", flags)
self.assertIn("-D BLE_PIN_CODE=123456", flags)
self.assertIn("-D MAX_CONTACTS=100", flags)
self.assertNotIn("-D MAX_GROUP_CHANNELS", flags)
self.assertNotIn("-D OFFLINE_QUEUE_SIZE", flags)
self.assertNotIn("-D OTA_SHARED_COMPANION_QUEUE", flags)
self.assertNotIn("-D ONE_KEY_DM_SHARED_OFFLINE_QUEUE", flags)
self.assertNotIn("-D MESH_MIN_RUNTIME_HEAP", flags)
self.assertEqual(self.env["extends"], "env:Heltec_v3_companion_radio_full")
self.assertIn("${env:Heltec_v3_companion_radio_full.build_flags}", flags)
def test_nimble_is_pinned_and_not_in_the_ordinary_release_matrix(self):
self.assertIn("-D MESH_USE_NIMBLE_ARDUINO=1", self.env["build_flags"])
self.assertIn("h2zero/NimBLE-Arduino @ 2.5.1", self.env["lib_deps"])
self.assertIn("${env:Heltec_v3_companion_radio_full.lib_deps}",
self.env["lib_deps"])
self.assertEqual(self.env["lib_ignore"], "ESP32 BLE Arduino")
base = configparser.ConfigParser(interpolation=None, strict=False)
base.read(ROOT / "platformio.ini")
self.assertNotIn("platformio.heltec-v3-preview3.ini",
base["platformio"]["extra_configs"])
def test_version_and_partition_contract(self):
self.assertEqual(self.env["board_build.flash_mode"], "dio")
self.assertEqual(self.env["board_build.partitions"], "default_8MB.csv")
self.assertIn("v1.17.1.8-preview3", self.env["build_flags"])
self.assertNotIn("DISABLE_LORA_OTA=1", self.env["build_flags"])
# Never set a flag that removes the WiFi/BLE/sensor/display features.
self.assertNotRegex(self.env["build_flags"], re.compile(
r"-D\s*(WEBCONFIG_DISABLED|DISABLE_WIFI_OTA|UI_NO_\w+|"
r"ENV_EXCLUDE_\w+)(?:=|\s|$)"))
if __name__ == "__main__":
unittest.main()
+164
View File
@@ -0,0 +1,164 @@
"use strict";
const assert = require("assert");
const viewer = require("../docs/_javascript/esp32_partition_catalog.js");
const catalog = require("../firmware/esp32_partition_catalog.json");
assert.strictEqual(viewer.validateCatalog(catalog), catalog);
const layouts = JSON.parse(viewer.jsonView(catalog, "layouts"));
assert.deepStrictEqual(layouts.layouts, catalog.layouts);
assert.strictEqual(layouts.counts.builds, catalog.builds.length);
assert.strictEqual(layouts.counts.releases, catalog.releases.length);
assert.strictEqual(layouts.counts.unresolved, catalog.unresolved.length);
assert.strictEqual(layouts.builds, undefined);
assert.strictEqual(layouts.releases, undefined);
assert(viewer.jsonView(catalog, "layouts").length < 50000);
const releases = JSON.parse(viewer.jsonView(catalog, "releases"));
assert.deepStrictEqual(releases.releases, catalog.releases);
assert.strictEqual(releases.builds, undefined);
assert.deepStrictEqual(JSON.parse(viewer.jsonView(catalog, "all")), catalog);
assert.throws(() => viewer.jsonView(catalog, "unknown"), /Unknown catalog view/);
assert.throws(() => viewer.validateCatalog({ schema: 2 }), /Unsupported/);
assert.throws(() => viewer.validateCatalog(Object.assign({}, catalog, { layouts: [] })), /Unsupported/);
const minimums = viewer.boardMinimums(catalog);
assert.strictEqual(minimums.length, new Set(catalog.builds.map((build) => build.board.toLowerCase())).size);
const v3 = minimums.find((row) => row.board.toLowerCase() === "heltec_v3");
assert.strictEqual(v3.smallestBytes, 3342336);
assert.strictEqual(viewer.formatSize(v3.smallestBytes), "3.1875 MiB");
assert.strictEqual(viewer.otaLayouts(v3), "Dual-slot OTA");
assert.strictEqual(viewer.formatSize(null), "Unknown");
const sample = {
schema: 1, repositories: [], releases: [], unresolved: [],
layouts: {
large: { dualOta: true, partitions: [[1, 2, 0x9000, 4096, "nvs", 0],
[0, 16, 0x10000, 2097152, "app0", 0], [0, 17, 0x210000, 2097152, "app1", 0]] },
small: { dualOta: true, partitions: [[0, 16, 0x10000, 2097152, "app0", 0],
[0, 17, 0x210000, 1310720, "app1", 0]] },
single: { dualOta: false, slotBytes: null,
partitions: [[0, 0, 0x10000, 3145728, "factory", 0]] },
noApps: { dualOta: false, partitions: [[1, 2, 0x9000, 4096, "nvs", 0]] },
},
builds: [
{ board: "Board_A", layout: "large" },
{ board: "board_a", layout: "small" },
{ board: "Board_B", layout: "single" },
{ board: "Board_C", layout: "single" },
{ board: "Board_C", layout: "large" },
{ board: "Board_D", layout: "missing" },
{ board: "Board_E", layout: "noApps" },
{ board: "Board_F", layout: "large" },
{ board: "Board_F", layout: "missing" },
],
};
const expectedSample = JSON.stringify(sample);
const sampleRows = viewer.boardMinimums(sample);
assert.strictEqual(JSON.stringify(sample), expectedSample);
assert.strictEqual(sampleRows.length, 6);
assert.strictEqual(sampleRows[0].smallestBytes, 1310720); // Smaller inactive slot, not NVS.
assert.strictEqual(sampleRows[1].smallestBytes, 3145728); // A single-app layout still has an app size.
assert.strictEqual(viewer.otaLayouts(sampleRows[1]), "No dual-slot OTA");
assert.strictEqual(viewer.otaLayouts(sampleRows[2]), "Mixed (some lack dual OTA)");
assert.strictEqual(sampleRows[3].smallestBytes, null);
assert.strictEqual(sampleRows[4].smallestBytes, null);
assert.strictEqual(sampleRows[5].smallestBytes, 2097152);
assert.strictEqual(viewer.otaLayouts(sampleRows[5]), "Unknown layouts present");
function element() {
return {
dataset: {}, children: [], hidden: false, textContent: "",
addEventListener: function (event, handler) { this[event] = handler; },
appendChild: function (child) { this.children.push(child); child.parentElement = this; },
};
}
function fakePage() {
const elements = {
"partition-catalog": { dataset: {} },
"partition-catalog-view": {
value: "layouts", disabled: true,
addEventListener: function (event, handler) { this[event] = handler; },
},
"partition-catalog-status": { textContent: "Loading..." },
"partition-catalog-json": { textContent: "" },
"partition-catalog-open": {
href: "https://example.test/MeshCore/_data/esp32_partition_catalog.json",
},
"partition-catalog-raw": Object.assign(element(), { open: false }),
"partition-catalog-search": Object.assign(element(), { value: "", disabled: true }),
"partition-catalog-boards": element(),
"partition-catalog-count": element(),
};
elements.table = Object.assign(element(), { hidden: true });
elements["partition-catalog-table"] = elements.table;
global.document = { getElementById: (id) => elements[id], createElement: element };
return elements;
}
async function testBrowser() {
const oldFetch = global.fetch;
const oldDocument = global.document;
try {
let calls = 0;
global.fetch = async function (url) {
calls++;
assert.strictEqual(url, "https://example.test/MeshCore/_data/esp32_partition_catalog.json");
return { ok: true, json: async () => catalog };
};
const page = fakePage();
await viewer.initializeViewer();
assert.strictEqual(page["partition-catalog-view"].disabled, false);
assert.strictEqual(page["partition-catalog-json"].textContent, ""); // Details are lazy.
assert.strictEqual(page.table.hidden, false);
assert.strictEqual(page["partition-catalog-boards"].children.length, minimums.length);
assert.strictEqual(page["partition-catalog-search"].disabled, false);
page["partition-catalog-search"].value = "HELTEC_V3";
page["partition-catalog-search"].input();
assert.strictEqual(page["partition-catalog-count"].textContent, "Showing 1 of " + minimums.length + " boards.");
const visible = page["partition-catalog-boards"].children.filter((row) => !row.hidden);
assert.strictEqual(visible[0].children[0].textContent, v3.board);
assert.strictEqual(visible[0].children[1].textContent, "3.1875 MiB");
assert.strictEqual(visible[0].children[1].children[0].textContent, "3,342,336 bytes");
page["partition-catalog-search"].value = "no matching board";
page["partition-catalog-search"].input();
assert.strictEqual(page["partition-catalog-count"].textContent, "Showing 0 of " + minimums.length + " boards.");
page["partition-catalog-raw"].open = true;
page["partition-catalog-raw"].toggle();
assert.deepStrictEqual(JSON.parse(page["partition-catalog-json"].textContent).layouts, catalog.layouts);
assert(page["partition-catalog-status"].textContent.includes(
catalog.builds.length.toLocaleString("en-US") + " builds"));
page["partition-catalog-view"].value = "all";
page["partition-catalog-view"].change();
assert.deepStrictEqual(JSON.parse(page["partition-catalog-json"].textContent), catalog);
await viewer.initializeViewer();
assert.strictEqual(calls, 1);
global.fetch = async () => ({ ok: false, status: 404 });
const missing = fakePage();
await viewer.initializeViewer();
assert(missing["partition-catalog-status"].textContent.includes("HTTP 404"));
assert(missing["partition-catalog-status"].textContent.includes("Open JSON"));
assert.strictEqual(missing["partition-catalog-view"].disabled, true);
assert.strictEqual(missing.table.hidden, true);
global.fetch = async () => ({ ok: true, json: async () => ({ schema: 2 }) });
const invalid = fakePage();
await viewer.initializeViewer();
assert(invalid["partition-catalog-status"].textContent.includes("Unsupported"));
global.document = { getElementById: () => null };
global.fetch = async () => { throw new Error("Other pages must not fetch the catalog"); };
await viewer.initializeViewer();
} finally {
global.fetch = oldFetch;
global.document = oldDocument;
}
}
testBrowser().then(() => {
console.log("board minima, OTA warnings, search, JSON views, loading and error states passed");
}).catch((error) => {
console.error(error);
process.exitCode = 1;
});
+80 -7
View File
@@ -136,6 +136,9 @@ POST_INSTALL_READY_PROBE_INTERVAL_SECONDS = 10
# or build VM. Keep the explicit preparation workflow bounded; Pi-class radio
# hosts should serve an already-built, hash-verified package instead.
DEFAULT_PACKAGE_BUILD_TIMEOUT_SECONDS = 60 * 60
# Newer motatool versions precompress every payload block before opening the
# device link. CPU-bound preparation must not consume the COUNT attach timer.
DEFAULT_SEEDER_PREPARE_WAIT_SECONDS = 30 * 60
COMPANION_TERMINAL_START = "+++MESHCORE-TERM-START"
COMPANION_TERMINAL_STOP = "+++MESHCORE-TERM-STOP"
# Firmware may hold the apply reboot for up to 15 seconds while its reply
@@ -3040,6 +3043,7 @@ def recommended_temp_radio_minutes(
remote_setup_seconds
+ source_setup_seconds
+ TEMP_RADIO_SWITCH_DELAY_SECONDS
+ getattr(args, "seeder_prepare_wait", DEFAULT_SEEDER_PREPARE_WAIT_SECONDS)
+ args.seeder_start_wait
+ args.discovery_timeout
+ args.transfer_timeout_minutes * 60
@@ -3059,7 +3063,8 @@ def warn_short_temp_radio_window(
if selected < recommended:
print(
f"warning: TempRadio window is {selected} minutes; {recommended} minutes "
"covers the combined worst-case setup, discovery, transfer, and "
"covers the combined worst-case setup, seeder preparation, discovery, "
"transfer, and "
"final checks. Continuing; the lease may expire before OTA completes.",
file=sys.stderr,
)
@@ -3794,6 +3799,12 @@ def verify_shared_source_identity(
class SeederProcess:
READY_PATTERN = re.compile(r"(?mi)^\s*\[dev\]\s+COUNT\s*->\s*\d+\b")
PREPARE_PATTERN = re.compile(
r"(?mi)^[ \t]*preparing raw-DEFLATE transport\b.*\bbefore opening the link\b"
)
PREPARE_PROGRESS_PATTERN = re.compile(
r"(?mi)^[ \t]*compressed (\d+)/(\d+) blocks[ \t\r]*$"
)
ATTACH_ERROR_PATTERN = re.compile(
r"(?mi)^\s*\[dev\].*\b(?:ERR|ERROR)\b|"
r"folder\s+(?:is\s+)?already\s+(?:owned|attached)|"
@@ -3856,7 +3867,16 @@ class SeederProcess:
)
def _wait_until_attached(self) -> None:
deadline = time.monotonic() + self.args.seeder_start_wait
started = time.monotonic()
attach_deadline = started + self.args.seeder_start_wait
prepare_wait = getattr(
self.args, "seeder_prepare_wait", DEFAULT_SEEDER_PREPARE_WAIT_SECONDS
)
prepare_deadline = started + prepare_wait
preparing = False
prepared = False
last_progress = None
next_notice = started + 30
while True:
self.ensure_running("during startup")
detail = self._log_tail()
@@ -3867,11 +3887,57 @@ class SeederProcess:
)
if self.READY_PATTERN.search(detail):
return
remaining = deadline - time.monotonic()
now = time.monotonic()
if not prepared:
progress = self.PREPARE_PROGRESS_PATTERN.findall(detail)
latest_progress = tuple(map(int, progress[-1])) if progress else None
if not preparing and (
self.PREPARE_PATTERN.search(detail) or latest_progress is not None
):
preparing = True
print(
"[seeder] preparing compressed blocks on the host; "
f"allowing up to {prepare_wait:g}s before the device "
"COUNT timer starts"
)
if preparing:
if now >= prepare_deadline:
raise OtaError(
"motatool seeder host compression did not complete "
f"within {prepare_wait:g}s; the device COUNT timer has "
"not started. Increase --seeder-prepare-wait on a slow "
"host and leave enough TempRadio time for the transfer.\n"
f"{detail}"
)
if latest_progress is not None:
done, total = latest_progress
if done == total and total > 0:
prepared = True
preparing = False
attach_deadline = now + self.args.seeder_start_wait
print(
"[seeder] host compression complete; "
"waiting for the device COUNT acknowledgement"
)
if preparing and (
latest_progress != last_progress or now >= next_notice
):
counts = (
f" ({latest_progress[0]}/{latest_progress[1]} blocks)"
if latest_progress is not None else ""
)
print(
f"[seeder] still compressing{counts}; "
f"{now - started:.0f}s elapsed"
)
last_progress = latest_progress
next_notice = now + 30
remaining = (prepare_deadline if preparing else attach_deadline) - now
if remaining <= 0:
phase = " after host compression completed" if prepared else ""
raise OtaError(
"motatool seeder did not receive the device COUNT "
f"acknowledgement within {self.args.seeder_start_wait:g}s:\n"
f"acknowledgement within {self.args.seeder_start_wait:g}s{phase}:\n"
f"{detail}"
)
time.sleep(min(0.1, remaining))
@@ -7889,7 +7955,14 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--discovery-interval", type=int, default=8)
parser.add_argument("--poll-seconds", type=int, default=30)
parser.add_argument("--transfer-timeout-minutes", type=int, default=110)
parser.add_argument("--seeder-start-wait", type=int, default=5)
parser.add_argument(
"--seeder-prepare-wait", type=int, default=DEFAULT_SEEDER_PREPARE_WAIT_SECONDS,
help="maximum seconds for motatool host compression before opening the device link",
)
parser.add_argument(
"--seeder-start-wait", type=int, default=5,
help="seconds to wait for the device COUNT acknowledgement after host compression",
)
parser.add_argument(
"--reboot-wait",
type=int,
@@ -8124,8 +8197,8 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
parser.error(f"{label} contains an unsupported control character")
for name in (
"reply_timeout", "discovery_timeout", "discovery_interval", "poll_seconds",
"transfer_timeout_minutes", "seeder_start_wait", "reboot_wait",
"package_build_timeout",
"transfer_timeout_minutes", "seeder_prepare_wait", "seeder_start_wait",
"reboot_wait", "package_build_timeout",
):
if getattr(args, name) <= 0:
parser.error(f"--{name.replace('_', '-')} must be positive")
+12 -2
View File
@@ -2388,6 +2388,9 @@ def run_step(
"--discovery-interval", str(args.discovery_interval),
"--poll-seconds", str(args.poll_seconds),
"--transfer-timeout-minutes", str(args.transfer_timeout_minutes),
"--seeder-prepare-wait", str(getattr(
args, "seeder_prepare_wait", ota.DEFAULT_SEEDER_PREPARE_WAIT_SECONDS
)),
"--seeder-start-wait", str(args.seeder_start_wait),
"--reboot-wait", str(args.reboot_wait),
"--source-rxps-recovery-file",
@@ -2521,7 +2524,14 @@ def build_parser() -> argparse.ArgumentParser:
help="seconds between transfer status checks; sparse checks leave airtime for OTA",
)
parser.add_argument("--transfer-timeout-minutes", type=int, default=90)
parser.add_argument("--seeder-start-wait", type=int, default=5)
parser.add_argument(
"--seeder-prepare-wait", type=int, default=ota.DEFAULT_SEEDER_PREPARE_WAIT_SECONDS,
help="maximum seconds for motatool host compression before opening the device link",
)
parser.add_argument(
"--seeder-start-wait", type=int, default=5,
help="seconds to wait for the device COUNT acknowledgement after host compression",
)
parser.add_argument(
"--reboot-wait",
type=int,
@@ -2589,7 +2599,7 @@ def validate_args(args: argparse.Namespace, parser: argparse.ArgumentParser) ->
for name in (
"controller_baud", "source_baud", "reply_timeout", "discovery_timeout",
"discovery_interval", "poll_seconds", "transfer_timeout_minutes",
"seeder_start_wait", "reboot_wait",
"seeder_prepare_wait", "seeder_start_wait", "reboot_wait",
):
if getattr(args, name) <= 0:
parser.error(f"--{name.replace('_', '-')} must be positive")
+2
View File
@@ -7446,6 +7446,7 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
discovery_interval=8,
poll_seconds=60,
transfer_timeout_minutes=90,
seeder_prepare_wait=123,
seeder_start_wait=5,
reboot_wait=90,
relay=[],
@@ -7477,6 +7478,7 @@ class Rak3401KnownUnsafeReleaseTests(unittest.TestCase):
)
command = nested.call_args.args[0]
self.assertEqual(command.count("--debug"), int(enabled))
self.assertEqual(command[command.index("--seeder-prepare-wait") + 1], "123")
self.assertEqual(command[command.index("--ota-hops") + 1], "3")
self.assertEqual(command[1], target_key)
self.assertEqual(command[command.index("--relay") + 1], f"{relay_key}=relay-password")
@@ -0,0 +1,290 @@
"""Seeder startup timing tests; no radios or transmissions."""
import argparse
import contextlib
import io
from pathlib import Path
import sys
import tempfile
import unittest
from unittest import mock
import lora_ota as ota
import rak3401_mota_chain as rak_chain
PREPARING = (
"motatool serve: 1 valid .mota in /served (recursive)\n"
"update.mota : blocks=264 size=541358\n"
"preparing raw-DEFLATE transport with Zopfli --i1000 before opening the link\n"
)
class Clock:
def __init__(self):
self.now = 0.0
def monotonic(self):
return self.now
def sleep(self, seconds):
# Advance quickly without waiting in real time. Deadline decisions
# remain based on the same clock used to select the visible log lines.
self.now += max(seconds, 1.0)
class SeederStartupTests(unittest.TestCase):
def setUp(self):
self.args = argparse.Namespace(
motatool="motatool", source_serial="/dev/source",
source_tcp=None, source_baud=115200,
seeder_start_wait=5, seeder_prepare_wait=1800,
)
self.clock = Clock()
self.output = io.StringIO()
self.addCleanup(mock.patch.stopall)
mock.patch.object(ota.time, "monotonic", self.clock.monotonic).start()
mock.patch.object(ota.time, "sleep", self.clock.sleep).start()
self.redirect = contextlib.redirect_stdout(self.output)
self.redirect.__enter__()
self.addCleanup(self.redirect.__exit__, None, None, None)
def seeder(self, events):
seeder = ota.SeederProcess(self.args, Path("/served"), Path("/work"))
seeder.process = mock.Mock()
seeder.process.poll.return_value = None
def log_tail():
return next(
(text for when, text in reversed(events) if when <= self.clock.now),
"no seeder log output",
)
seeder._log_tail = mock.Mock(side_effect=log_tail)
return seeder
def test_slow_compression_gets_fresh_count_timer(self):
seeder = self.seeder([
(0, PREPARING),
(12, PREPARING + " compressed 64/264 blocks\n"),
(30, PREPARING + " compressed 256/264 blocks\n"),
(40, PREPARING + " compressed 264/264 blocks\n"),
(44, " compressed 264/264 blocks\n [dev] COUNT -> 1\n"),
])
seeder._wait_until_attached()
self.assertEqual(self.clock.now, 44)
self.assertIn("64/264 blocks", self.output.getvalue())
self.assertIn("host compression complete", self.output.getvalue())
def test_preparation_remains_recognized_after_header_leaves_log_tail(self):
seeder = self.seeder([
(0, PREPARING),
(10, " compressed 64/264 blocks\n"),
(20, " compressed 264/264 blocks\n"),
(24, " [dev] COUNT -> 1\n"),
])
seeder._wait_until_attached()
self.assertEqual(self.clock.now, 24)
def test_progress_can_identify_phase_without_header(self):
seeder = self.seeder([
(0, " compressed 64/264 blocks\n"),
(20, " compressed 264/264 blocks\n"),
(22, " [dev] COUNT -> 1\n"),
])
seeder._wait_until_attached()
self.assertEqual(self.clock.now, 22)
def test_crlf_progress_starts_count_timer(self):
seeder = self.seeder([
(0, PREPARING.replace("\n", "\r\n")),
(20, " compressed 264/264 blocks\r\n"),
])
with self.assertRaisesRegex(ota.OtaError, "after host compression completed"):
seeder._wait_until_attached()
self.assertEqual(self.clock.now, 25)
def test_invalid_completion_counts_do_not_start_count_timer(self):
self.args.seeder_prepare_wait = 10
for counts in ("0/0", "265/264"):
with self.subTest(counts=counts):
self.clock.now = 0
seeder = self.seeder([(0, PREPARING + f" compressed {counts} blocks\n")])
with self.assertRaisesRegex(ota.OtaError, "host compression did not complete"):
seeder._wait_until_attached()
self.assertEqual(self.clock.now, 10)
def test_final_progress_does_not_prove_device_readiness_or_reset_timer(self):
seeder = self.seeder([
(0, PREPARING),
(40, PREPARING + " compressed 264/264 blocks\n"),
])
with self.assertRaisesRegex(
ota.OtaError, "COUNT acknowledgement within 5s after host compression completed"
):
seeder._wait_until_attached()
self.assertEqual(self.clock.now, 45)
def test_stalled_preparation_has_its_own_bounded_timeout(self):
self.args.seeder_prepare_wait = 60
seeder = self.seeder([(0, PREPARING)])
with self.assertRaisesRegex(ota.OtaError, "host compression did not complete within 60s"):
seeder._wait_until_attached()
self.assertEqual(self.clock.now, 60)
self.assertIn("still compressing", self.output.getvalue())
self.assertIn("30s elapsed", self.output.getvalue())
def test_progress_does_not_extend_preparation_deadline(self):
self.args.seeder_prepare_wait = 60
seeder = self.seeder([
(0, PREPARING),
(20, PREPARING + " compressed 64/264 blocks\n"),
(40, PREPARING + " compressed 128/264 blocks\n"),
(59, PREPARING + " compressed 256/264 blocks\n"),
])
with self.assertRaisesRegex(ota.OtaError, "host compression did not complete within 60s"):
seeder._wait_until_attached()
self.assertEqual(self.clock.now, 60)
def test_custom_preparation_timeout_is_honored(self):
self.args.seeder_prepare_wait = 12
seeder = self.seeder([(0, PREPARING)])
with self.assertRaisesRegex(ota.OtaError, "within 12s"):
seeder._wait_until_attached()
self.assertEqual(self.clock.now, 12)
def test_namespace_without_new_option_uses_default_preparation_limit(self):
del self.args.seeder_prepare_wait
seeder = self.seeder([(0, PREPARING)])
with self.assertRaisesRegex(ota.OtaError, "host compression did not complete within 1800s"):
seeder._wait_until_attached()
self.assertEqual(self.clock.now, ota.DEFAULT_SEEDER_PREPARE_WAIT_SECONDS)
def test_device_error_stops_both_startup_phases_immediately(self):
for phase, elapsed in (("preparing", 10), ("attaching", 41)):
with self.subTest(phase=phase):
self.clock.now = 0
seeder = self.seeder([
(0, PREPARING),
(40, " compressed 264/264 blocks\n"),
(elapsed, " [dev] ERR folder source unavailable\n"),
])
with self.assertRaisesRegex(ota.OtaError, "rejected by the device"):
seeder._wait_until_attached()
self.assertEqual(self.clock.now, elapsed)
def test_process_exit_stops_both_startup_phases_immediately(self):
for elapsed in (10, 41):
with self.subTest(elapsed=elapsed):
self.clock.now = 0
seeder = self.seeder([
(0, PREPARING),
(40, " compressed 264/264 blocks\n"),
])
seeder.process.poll.side_effect = lambda: 2 if self.clock.now >= elapsed else None
with self.assertRaisesRegex(ota.OtaError, "exited with status 2 during startup"):
seeder._wait_until_attached()
self.assertEqual(self.clock.now, elapsed)
def test_legacy_tool_keeps_original_startup_timeout(self):
seeder = self.seeder([(0, "serving on /dev/source ... Ctrl-C to stop\n")])
with self.assertRaisesRegex(ota.OtaError, "COUNT acknowledgement within 5s:"):
seeder._wait_until_attached()
self.assertEqual(self.clock.now, 5)
self.assertNotIn("preparing compressed", self.output.getvalue())
def test_legacy_tool_is_ready_only_with_count(self):
seeder = self.seeder([
(0, "serving on /dev/source ... Ctrl-C to stop\n"),
(4, " [dev] COUNT -> 1\n"),
])
seeder._wait_until_attached()
self.assertEqual(self.clock.now, 4)
def test_genuine_count_can_confirm_readiness_without_final_progress(self):
seeder = self.seeder([
(0, PREPARING),
(10, PREPARING + " [dev] COUNT -> 1\n"),
])
seeder._wait_until_attached()
self.assertEqual(self.clock.now, 10)
class SeederOptionTests(unittest.TestCase):
def arguments(self, chain=False, *options):
parser = rak_chain.build_parser() if chain else ota.build_parser()
values = [] if chain else ["release.mota", "remote"]
values.extend([
"--controller-serial", "/dev/controller",
"--source-serial", "/dev/source", *options,
])
return parser, parser.parse_args(values)
def test_generic_and_chain_have_matching_defaults_and_overrides(self):
for chain in (False, True):
with self.subTest(chain=chain):
_, args = self.arguments(chain)
self.assertEqual(args.seeder_prepare_wait, 1800)
self.assertEqual(args.seeder_start_wait, 5)
_, args = self.arguments(chain, "--seeder-prepare-wait", "2400")
self.assertEqual(args.seeder_prepare_wait, 2400)
def test_both_runners_reject_nonpositive_preparation_timeouts(self):
for chain in (False, True):
for value in ("0", "-1"):
with self.subTest(chain=chain, value=value):
parser, args = self.arguments(chain, "--seeder-prepare-wait", value)
error = io.StringIO()
validate = rak_chain.validate_args if chain else ota.validate_args
with contextlib.redirect_stderr(error), self.assertRaises(SystemExit):
validate(args, parser)
self.assertIn("--seeder-prepare-wait must be positive", error.getvalue())
def test_preparation_budget_remains_advisory_with_twenty_minute_lease(self):
for chain in (False, True):
with self.subTest(chain=chain):
parser, args = self.arguments(chain, "--temp-radio-minutes", "20")
validate = rak_chain.validate_args if chain else ota.validate_args
with contextlib.redirect_stderr(io.StringIO()):
validate(args, parser)
self.assertEqual(args.temp_values[-1], 20)
original = ota.recommended_temp_radio_minutes(args)
args.seeder_prepare_wait += 60
self.assertEqual(ota.recommended_temp_radio_minutes(args), original + 1)
class SeederSubprocessTests(unittest.TestCase):
def test_real_subprocess_prepares_longer_than_attach_timeout(self):
# Exercise actual redirected output and lifecycle, not only mocked
# log snapshots. The child is a host-only stand-in, never motatool.
with tempfile.TemporaryDirectory() as directory:
args = argparse.Namespace(
motatool="unused", source_serial="/dev/unused",
source_tcp=None, source_baud=115200,
seeder_start_wait=1.0, seeder_prepare_wait=10,
)
seeder = ota.SeederProcess(args, Path(directory), Path(directory))
seeder.command = [
sys.executable, "-u", "-c",
"import time\n"
f"print({PREPARING!r}, flush=True)\n"
"time.sleep(1.25)\n"
"print(' compressed 264/264 blocks', flush=True)\n"
"time.sleep(0.2)\n"
"print(' [dev] COUNT -> 1', flush=True)\n"
"time.sleep(30)\n",
]
output = io.StringIO()
with contextlib.redirect_stdout(output):
try:
seeder.start()
self.assertIsNone(seeder.process.poll())
finally:
seeder.stop()
self.assertIn("running (device COUNT confirmed)", output.getvalue())
self.assertIn("host compression complete", output.getvalue())
self.assertIsNone(seeder.process)
if __name__ == "__main__":
unittest.main()