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

Loading catalog JSON...

+

+ +
+ Show JSON details +
+ + +
+
Expand this section to view the JSON.
+
+
+ +With JavaScript disabled, use [the complete JSON file](_data/esp32_partition_catalog.json). + ## Coverage The initial snapshot enumerates every published release (including prereleases, diff --git a/docs/firmware_picker.md b/docs/firmware_picker.md index 471680dae..41ea381cb 100644 --- a/docs/firmware_picker.md +++ b/docs/firmware_picker.md @@ -8,8 +8,11 @@ MQTT and GPS show their app/WebConfig steps instead. See The [USB web console](https://flasher.meshcore.io/console) works with the default ASCII terminal on USB Companion and infrastructure roles. -Pick the choices in any order. Every selection narrows all the other controls -to firmware combinations that were actually built in the current release set. +Pick the choices in any order. The controls stay limited to compatible +firmware combinations that were actually built in the current release set. +The result count stays visible while you scroll through the filters on a phone. +**Same firmware** marks choices that do not narrow the builds any further; +they remain usable because they can change the setup instructions. The optional chip-family filter (ESP32, nRF52, RP2040, or STM32) can narrow the hardware list first. You can skip it: picking hardware fills it in automatically. Chip family and hardware are the only dropdowns. All remaining choices use @@ -51,6 +54,14 @@ from the published firmware assets.

+ +
Optional: chip family

Skip this if you know your board. Picking hardware selects its chip family automatically. Choose Any to clear this filter.

diff --git a/docs/lora_ota_automation.md b/docs/lora_ota_automation.md index 9c0bb18fd..954180e7b 100644 --- a/docs/lora_ota_automation.md +++ b/docs/lora_ota_automation.md @@ -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 flash`, and poll until that same ID reports ready. A seeder diff --git a/docs/test-results/heltec_v3_preview3_ram.md b/docs/test-results/heltec_v3_preview3_ram.md new file mode 100644 index 000000000..caf3fa0ef --- /dev/null +++ b/docs/test-results/heltec_v3_preview3_ram.md @@ -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 +``` diff --git a/mkdocs.yml b/mkdocs.yml index 796a1eb6f..75df14432 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/platformio.heltec-v3-preview3.ini b/platformio.heltec-v3-preview3.ini new file mode 100644 index 000000000..00a2097f6 --- /dev/null +++ b/platformio.heltec-v3-preview3.ini @@ -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} + + + + +lib_deps = + ${env:Heltec_v3_companion_radio_full.lib_deps} + h2zero/NimBLE-Arduino @ 2.5.1 +lib_ignore = ESP32 BLE Arduino diff --git a/scripts/docs_partition_catalog.py b/scripts/docs_partition_catalog.py new file mode 100644 index 000000000..a1fa1b3fa --- /dev/null +++ b/scripts/docs_partition_catalog.py @@ -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 diff --git a/test/test_firmware_picker.js b/test/test_firmware_picker.js index e9a85ab74..f23f6d94a 100644 --- a/test/test_firmware_picker.js +++ b/test/test_firmware_picker.js @@ -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'); diff --git a/test/test_heltec_v3_preview_profile.py b/test/test_heltec_v3_preview_profile.py new file mode 100644 index 000000000..15e6b5f11 --- /dev/null +++ b/test/test_heltec_v3_preview_profile.py @@ -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() diff --git a/test/test_partition_catalog_viewer.js b/test/test_partition_catalog_viewer.js new file mode 100644 index 000000000..7bfdff462 --- /dev/null +++ b/test/test_partition_catalog_viewer.js @@ -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; +}); diff --git a/tools/lora_ota/lora_ota.py b/tools/lora_ota/lora_ota.py index a9d6ce4de..6da1acc75 100755 --- a/tools/lora_ota/lora_ota.py +++ b/tools/lora_ota/lora_ota.py @@ -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") diff --git a/tools/lora_ota/rak3401_mota_chain.py b/tools/lora_ota/rak3401_mota_chain.py index ac63bee78..7f8b15430 100755 --- a/tools/lora_ota/rak3401_mota_chain.py +++ b/tools/lora_ota/rak3401_mota_chain.py @@ -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") diff --git a/tools/lora_ota/test_lora_ota.py b/tools/lora_ota/test_lora_ota.py index 77cccfb53..49698e15a 100644 --- a/tools/lora_ota/test_lora_ota.py +++ b/tools/lora_ota/test_lora_ota.py @@ -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") diff --git a/tools/lora_ota/test_lora_ota_seeder_startup.py b/tools/lora_ota/test_lora_ota_seeder_startup.py new file mode 100644 index 000000000..4033b5043 --- /dev/null +++ b/tools/lora_ota/test_lora_ota_seeder_startup.py @@ -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()