Improve firmware tooling docs and OTA seeder startup checks
This commit is contained in:
1 parent
4086222d85
commit
bd55369aa9
20 files changed
+1396
-16
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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');
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user