Improve firmware tooling docs and OTA seeder startup checks

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

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
(function (global) {
"use strict";
function validateCatalog(catalog) {
if (!catalog || catalog.schema !== 1 || !Array.isArray(catalog.repositories) ||
!Array.isArray(catalog.releases) || !Array.isArray(catalog.builds) ||
!Array.isArray(catalog.unresolved) || !catalog.layouts ||
typeof catalog.layouts !== "object" || Array.isArray(catalog.layouts)) {
throw new Error("Unsupported partition catalog format");
}
return catalog;
}
function snapshot(catalog) {
return {
schema: catalog.schema,
generatedAt: catalog.generatedAt,
repositories: catalog.repositories,
counts: {
releases: catalog.releases.length,
builds: catalog.builds.length,
layouts: Object.keys(catalog.layouts).length,
unresolved: catalog.unresolved.length,
},
};
}
function jsonView(catalog, view) {
validateCatalog(catalog);
if (view === "all") return JSON.stringify(catalog, null, 2);
const result = snapshot(catalog);
if (view === "layouts") result.layouts = catalog.layouts;
else if (view === "releases") result.releases = catalog.releases;
else throw new Error("Unknown catalog view");
return JSON.stringify(result, null, 2);
}
function boardMinimums(catalog) {
validateCatalog(catalog);
const boards = new Map();
catalog.builds.forEach(function (build) {
const key = build.board.toLowerCase();
if (!boards.has(key)) {
boards.set(key, {
board: build.board, smallestBytes: null,
hasDualOta: false, hasNonDualOta: false, hasUnknownLayout: false,
});
}
const row = boards.get(key);
const layout = Object.prototype.hasOwnProperty.call(catalog.layouts, build.layout)
? catalog.layouts[build.layout] : null;
if (!layout || !Array.isArray(layout.partitions)) {
row.hasUnknownLayout = true;
return;
}
const apps = layout.partitions.filter(function (entry) {
return Array.isArray(entry) && entry[0] === 0 &&
Number.isSafeInteger(entry[3]) && entry[3] > 0;
});
if (!apps.length) {
row.hasUnknownLayout = true;
return;
}
const smallest = Math.min.apply(null, apps.map((entry) => entry[3]));
row.smallestBytes = row.smallestBytes === null
? smallest : Math.min(row.smallestBytes, smallest);
if (layout.dualOta === true) row.hasDualOta = true;
else if (layout.dualOta === false) row.hasNonDualOta = true;
else row.hasUnknownLayout = true;
});
return Array.from(boards.values()).sort(function (left, right) {
return left.board.localeCompare(right.board, "en", { sensitivity: "base" });
});
}
function formatSize(bytes) {
return bytes === null ? "Unknown" : (bytes / 1048576).toLocaleString(
"en-US", { maximumFractionDigits: 4 }
) + " MiB";
}
function otaLayouts(row) {
if (row.hasUnknownLayout) return "Unknown layouts present";
if (row.hasDualOta && row.hasNonDualOta) return "Mixed (some lack dual OTA)";
return row.hasDualOta ? "Dual-slot OTA" : "No dual-slot OTA";
}
async function initializeViewer() {
const root = document.getElementById("partition-catalog");
if (!root || root.dataset.initialized) return;
root.dataset.initialized = "true";
const selector = document.getElementById("partition-catalog-view");
const status = document.getElementById("partition-catalog-status");
const code = document.getElementById("partition-catalog-json");
const link = document.getElementById("partition-catalog-open");
const raw = document.getElementById("partition-catalog-raw");
const search = document.getElementById("partition-catalog-search");
const tbody = document.getElementById("partition-catalog-boards");
const count = document.getElementById("partition-catalog-count");
try {
const response = await global.fetch(link.href, { cache: "no-cache" });
if (!response.ok) throw new Error("HTTP " + response.status);
const catalog = validateCatalog(await response.json());
const render = function () {
// Catalog text is data, never HTML. Only format the large builds array
// when the reader explicitly requests the complete catalog.
code.textContent = jsonView(catalog, selector.value);
};
raw.addEventListener("toggle", function () { if (raw.open) render(); });
if (raw.open) render();
selector.disabled = false;
selector.addEventListener("change", render);
const rows = boardMinimums(catalog).map(function (board) {
const tr = document.createElement("tr");
const name = document.createElement("th");
name.scope = "row";
name.textContent = board.board;
const size = document.createElement("td");
size.textContent = formatSize(board.smallestBytes);
if (board.smallestBytes !== null) {
const bytes = document.createElement("small");
bytes.textContent = board.smallestBytes.toLocaleString("en-US") + " bytes";
size.appendChild(bytes);
}
const layout = document.createElement("td");
layout.textContent = otaLayouts(board);
tr.appendChild(name);
tr.appendChild(size);
tr.appendChild(layout);
tbody.appendChild(tr);
return { name: board.board.toLowerCase(), element: tr };
});
const filter = function () {
const query = search.value.trim().toLowerCase();
let visible = 0;
rows.forEach(function (row) {
row.element.hidden = !row.name.includes(query);
if (!row.element.hidden) visible++;
});
count.textContent = "Showing " + visible + " of " + rows.length + " boards.";
};
filter();
search.disabled = false;
search.addEventListener("input", filter);
document.getElementById("partition-catalog-table").hidden = false;
const counts = snapshot(catalog).counts;
status.textContent = "Snapshot " + catalog.generatedAt + ": " +
counts.builds.toLocaleString("en-US") + " builds, " +
counts.releases.toLocaleString("en-US") + " releases, " +
counts.layouts + " layouts, " + counts.unresolved + " unresolved assets.";
} catch (error) {
status.textContent = "Could not load the catalog: " + error.message +
". Use Open JSON or Download JSON to access the file directly.";
code.textContent = "Catalog preview unavailable.";
}
}
const api = Object.freeze({
validateCatalog, jsonView, boardMinimums, formatSize, otaLayouts, initializeViewer,
});
if (typeof module === "object" && module.exports) module.exports = api;
if (typeof document !== "undefined") {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initializeViewer, { once: true });
} else {
initializeViewer();
}
}
})(typeof globalThis !== "undefined" ? globalThis : this);
+95 -1
View File
@@ -1079,6 +1079,49 @@
});
}
// Count compiled builds, not files: merged and app-only downloads can belong
// to the same build, but still require different installation instructions.
function selectionProgress(profiles, filters) {
const count = (profiles || []).filter(function (profile) {
return profileMatchesFacets(profile, filters);
}).length;
return {
count: count,
state: count === 1 ? "single" : count ? "multiple" : "empty",
title: count === 1 ? "1 firmware build left" : count ? count + " firmware builds remain"
: "No matching firmware builds",
note: count === 1
? filters && filters.install ? "Confirm your board and install choice."
: "Choose an install operation."
: count ? "Choose options to narrow the results."
: "Change or clear choices to see available builds.",
};
}
function choicesUseSameFirmware(profiles, filters, field, options) {
// Never equate install operations, even when they belong to one build.
if (!["hardware", "role", "logging", "mode", "firmwareProfile"].includes(field)) {
return false;
}
const ignored = field === "firmwareProfile" ? PROFILE_FIELDS : field;
const compatible = (profiles || []).filter(function (profile) {
return profileMatchesFacets(profile, filters, ignored);
});
const choices = (options || []).filter(function (option) { return option.value; });
if (!compatible.length || !choices.length) return false;
// Ignore the current choice before comparing alternatives. Having one
// result now does not imply that another button would keep the same build.
return choices.every(function (option) {
const choice = field === "firmwareProfile" ? firmwareProfileFilters(option.value)
: { [field]: option.value };
if (!choice) return false;
const selection = Object.assign({}, filters, choice);
return compatible.every(function (profile) {
return profileMatchesFacets(profile, selection);
});
});
}
function selectionUrl(url, filters, automaticChipFamily) {
const result = new URL(url);
FACET_FIELDS.forEach(function (field) {
@@ -1751,6 +1794,8 @@
const shareLink = root.querySelector('[data-role="share-link"]');
const copyLinkButton = root.querySelector('[data-action="copy-link"]');
const linkStatus = root.querySelector('[data-role="link-status"]');
const feedback = root.querySelector('[data-role="filter-feedback"]');
const resultLink = root.querySelector('[data-role="view-results"]');
const controls = {};
FACET_FIELDS.forEach(function (field) {
controls[field] = form.querySelector('[data-field="' + field + '"]');
@@ -1762,6 +1807,10 @@
const filters = {};
let automaticChipFamily = false;
const groupPrefix = "firmware-picker-" + (++pickerInstanceCount);
if (resultLink) {
result.id = result.id || groupPrefix + "-results";
resultLink.href = "#" + result.id;
}
function updateSelectionUrl() {
const url = selectionUrl(global.location.href, filters, automaticChipFamily);
@@ -1855,6 +1904,35 @@
renderRadioChoices(container, options, selected, groupPrefix + "-" + field, field);
const fieldset = container.closest("fieldset");
if (fieldset) fieldset.disabled = values.length === 0;
setRefinementHint(container, field, options);
}
function setRefinementHint(container, field, options) {
const fieldset = container.closest("fieldset");
if (!fieldset) return;
let badge = fieldset.querySelector('[data-role="same-firmware"]');
let hint = fieldset.querySelector('[data-role="refinement-hint"]');
if (!badge) {
badge = createElement("span", "Same firmware");
badge.className = "firmware-picker-same-firmware";
badge.dataset.role = "same-firmware";
fieldset.querySelector("legend").appendChild(badge);
hint = createElement("p");
hint.className = "firmware-picker-control-help firmware-picker-refinement-hint";
hint.dataset.role = "refinement-hint";
hint.id = groupPrefix + "-" + field + "-hint";
fieldset.insertBefore(hint, container);
}
const same = choicesUseSameFirmware(catalog.profiles, filters, field, options);
badge.hidden = !same;
hint.hidden = !same;
hint.textContent = same ? "These choices do not narrow the firmware builds." +
(["logging", "mode"].includes(field) ? " They can change setup instructions." : "") : "";
const describedBy = (fieldset.getAttribute("aria-describedby") || "")
.split(/\s+/).filter(function (id) { return id && id !== hint.id; });
if (same) describedBy.push(hint.id);
if (describedBy.length) fieldset.setAttribute("aria-describedby", describedBy.join(" "));
else fieldset.removeAttribute("aria-describedby");
}
function setControlOptions(field, values) {
@@ -1931,6 +2009,7 @@
renderRadioChoices(profileControl, [{ value: "", label: "Any" }].concat(profileChoices),
firmwareProfileValue(filters), groupPrefix + "-firmwareProfile", "firmwareProfile");
profileControl.closest("fieldset").disabled = profileChoices.length === 0;
setRefinementHint(profileControl, "firmwareProfile", profileChoices);
const hardwareVariants = valuesForField("hardware");
const hardwareVariantControl = root.querySelector(
'[data-role="hardware-variant-control"]'
@@ -1963,9 +2042,20 @@
return field !== "chipFamily" && !filters[field];
});
const matches = matchingProfiles();
if (feedback) {
const progress = selectionProgress(catalog.profiles, filters);
feedback.hidden = false;
feedback.dataset.state = progress.state;
feedback.querySelector('[data-role="filter-count"]').textContent = progress.title;
feedback.querySelector('[data-role="filter-note"]').textContent = progress.note;
}
if (resultLink) resultLink.hidden = true;
if (missingFields.length) {
if (missingFields.length === CHOICE_FIELDS.length - 1 && !filters.chipFamily) {
if (matches.length === 1) {
status.textContent = "One firmware build remains. Confirm the hardware and install operation. " +
"Choices marked 'Same firmware' do not narrow the result further.";
} else if (missingFields.length === CHOICE_FIELDS.length - 1 && !filters.chipFamily) {
status.textContent = "Pick options in any order. " +
matches.length + " compatible configurations are available.";
} else {
@@ -1993,6 +2083,7 @@
);
});
result.hidden = false;
if (resultLink) resultLink.hidden = false;
}
}
return;
@@ -2024,6 +2115,7 @@
);
});
result.hidden = false;
if (resultLink) resultLink.hidden = false;
}
function renderBootloader() {
@@ -2265,6 +2357,8 @@
firmwareProfileFilters: firmwareProfileFilters,
firmwareProfileLabel: firmwareProfileLabel,
firmwareProfileChoices: firmwareProfileChoices,
selectionProgress: selectionProgress,
choicesUseSameFirmware: choicesUseSameFirmware,
selectionUrl: selectionUrl,
selectionFromUrl: selectionFromUrl,
canonicalAsset: canonicalAsset,
@@ -0,0 +1,101 @@
.partition-catalog {
min-width: 0;
margin: 1rem 0;
}
.partition-catalog-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 1rem;
}
.partition-catalog-controls label {
font-weight: 700;
}
.partition-catalog-controls select,
.partition-catalog-controls input {
max-width: 100%;
padding: 0.5rem;
border: 1px solid var(--md-default-fg-color--light);
border-radius: 0.25rem;
background: var(--md-default-bg-color);
color: var(--md-default-fg-color);
font: inherit;
}
.partition-catalog-controls select:focus-visible,
.partition-catalog-controls input:focus-visible,
.partition-catalog-table:focus-visible,
.partition-catalog-json:focus-visible {
outline: 2px solid var(--md-accent-fg-color);
outline-offset: 2px;
}
.partition-catalog-table {
max-height: 32rem;
overflow: auto;
overscroll-behavior: contain;
}
.partition-catalog [hidden] {
display: none !important;
}
.md-typeset .partition-catalog-table table {
display: table;
width: 100%;
margin: 0;
}
.partition-catalog-table thead th {
position: sticky;
top: 0;
background: var(--md-default-bg-color);
}
.partition-catalog-table tbody th {
overflow-wrap: anywhere;
}
.partition-catalog-table td small {
display: block;
color: var(--md-default-fg-color--light);
white-space: nowrap;
}
.partition-catalog-raw {
margin-top: 1rem;
}
.partition-catalog-raw summary {
cursor: pointer;
}
.partition-catalog-raw .partition-catalog-controls {
margin-top: 1rem;
}
@media (max-width: 600px) {
.md-typeset .partition-catalog-table table th,
.md-typeset .partition-catalog-table table td {
padding: 0.5rem;
}
}
.md-typeset .partition-catalog-json {
max-height: 32rem;
overflow: auto;
overscroll-behavior: contain;
border: 1px solid var(--md-default-fg-color--lightest);
border-radius: 0.25rem;
}
.md-typeset .partition-catalog-json > code {
display: block;
padding: 1rem;
white-space: pre;
overflow-wrap: normal;
word-break: normal;
}
+68
View File
@@ -38,6 +38,46 @@
margin: 1.2rem 0;
}
.firmware-picker [hidden] {
display: none !important;
}
.firmware-picker-feedback {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
padding: 0.65rem 0.8rem;
border: 1px solid var(--picker-border);
border-left: 0.3rem solid var(--picker-accent);
border-radius: 0.4rem;
background: var(--md-default-bg-color, white);
}
.firmware-picker-feedback[data-state="single"] {
border-color: var(--picker-accent);
}
.firmware-picker-feedback p {
margin: 0.2rem 0 0;
font-size: 0.75rem;
line-height: 1.35;
}
.firmware-picker-feedback a {
flex-shrink: 0;
padding: 0.55rem 0.65rem;
border: 1px solid var(--picker-accent);
border-radius: 0.35rem;
font-size: 0.8rem;
font-weight: 700;
}
.firmware-picker-feedback a:focus-visible {
outline: 0.16rem solid var(--picker-accent);
outline-offset: 0.12rem;
}
.firmware-picker-order-note {
margin: 1rem 0 0;
color: var(--md-default-fg-color--light);
@@ -76,10 +116,26 @@
}
.firmware-picker-radio-control > legend {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 0.35rem;
padding: 0;
}
.firmware-picker-same-firmware {
padding: 0.15rem 0.45rem;
border: 1px solid var(--picker-border);
border-radius: 999px;
font-size: 0.65rem;
font-weight: 600;
background: color-mix(in srgb, var(--picker-accent) 10%, transparent);
}
.firmware-picker-refinement-hint {
font-size: 0.75rem;
}
.firmware-picker-select-control select,
.firmware-asset-browser input {
width: 100%;
@@ -190,6 +246,7 @@
.firmware-picker-result {
border-top: 0.3rem solid var(--picker-accent);
scroll-margin-top: 4rem;
}
.firmware-picker-eyebrow {
@@ -309,6 +366,17 @@
grid-template-columns: 1fr;
}
.firmware-picker-feedback {
position: sticky;
top: 0.5rem;
z-index: 2;
box-shadow: 0 0.2rem 0.5rem rgb(0 0 0 / 10%);
}
.md-typeset .firmware-picker-feedback {
top: calc(var(--md-header-height, 2.4rem) + 0.4rem);
}
.firmware-picker-facts {
grid-template-columns: 1fr;
}
+12 -1
View File
@@ -914,7 +914,18 @@ All display-equipped Companion builds show the screen at boot in the default
selected profile's timeout (15 seconds by default); button input restarts that
timer. This also applies to previously saved button modes. Saved `off` and
pairing-only modes still suppress the boot screen. Repeaters, room servers,
and sensors do not gain this Companion-only boot wake.
and sensors do not gain this Companion-only normal-UI boot wake.
On display-equipped Companions, repeaters, room servers, and sensors, a small
`Starting...` screen appears as soon as the board, primary filesystem, and
display are initialized, before radio initialization and serial-settle delays.
If a new identity is needed, it changes to `Generating key` with an animated
spinner during radio entropy collection. OLED/TFT updates are paced at 150 ms;
e-paper uses a slower one-second cadence. Saved `off` and pairing-only modes
still suppress this startup screen. The normal UI's boot timeout starts only
when that UI is ready, so key generation does not consume it. Filesystem
recovery and the display driver's own initialization can still delay the first
visible frame; the firmware cannot draw before those operations finish.
Startup shows `Loading identity` while reading a saved identity. `Generating key`
and its spinner are used only when creating a replacement/new identity;
+40
View File
@@ -5,6 +5,46 @@ The canonical LUT is `firmware/esp32_partition_catalog.json` in this fork.
assets. MeshCore Open bundles an identical copy for offline phone updates.
There is no need to run a command on the phone or download old firmware there.
## Smallest partition by board
The table shows each board's **smallest known application partition** across
all cataloged releases, roles, and profiles. It does not count smaller NVS or
filesystem partitions. Sizes use MiB (1,048,576 bytes), with exact bytes below.
Mixed layouts means some releases have dual-slot OTA and others do not.
These are historical factory-image minimums, not measurements of your device;
use `get storage.layout` to check its actual installed layout.
<div class="partition-catalog" id="partition-catalog">
<div class="partition-catalog-controls">
<label for="partition-catalog-search">Board</label>
<input id="partition-catalog-search" type="search" placeholder="Search board name" autocomplete="off" disabled>
<a id="partition-catalog-open" href="../_data/esp32_partition_catalog.json">Open JSON</a>
<a href="../_data/esp32_partition_catalog.json" download="esp32_partition_catalog.json">Download JSON</a>
</div>
<p id="partition-catalog-status" role="status" aria-live="polite">Loading catalog JSON...</p>
<p id="partition-catalog-count" role="status" aria-live="polite"></p>
<div class="partition-catalog-table" id="partition-catalog-table" tabindex="0" aria-label="Smallest application partition by board" hidden>
<table>
<thead><tr><th scope="col">Board</th><th scope="col">Smallest app partition</th><th scope="col">OTA layouts</th></tr></thead>
<tbody id="partition-catalog-boards"></tbody>
</table>
</div>
<details class="partition-catalog-raw" id="partition-catalog-raw">
<summary>Show JSON details</summary>
<div class="partition-catalog-controls">
<label for="partition-catalog-view">JSON view</label>
<select id="partition-catalog-view" disabled>
<option value="layouts">Snapshot and partition layouts</option>
<option value="releases">Release inventory</option>
<option value="all">Complete catalog (large)</option>
</select>
</div>
<pre class="partition-catalog-json" tabindex="0" aria-label="Partition catalog JSON"><code id="partition-catalog-json">Expand this section to view the JSON.</code></pre>
</details>
</div>
With JavaScript disabled, use [the complete JSON file](_data/esp32_partition_catalog.json).
## Coverage
The initial snapshot enumerates every published release (including prereleases,
+13 -2
View File
@@ -8,8 +8,11 @@ MQTT and GPS show their app/WebConfig steps instead. See
The [USB web console](https://flasher.meshcore.io/console) works with the
default ASCII terminal on USB Companion and infrastructure roles.
Pick the choices in any order. Every selection narrows all the other controls
to firmware combinations that were actually built in the current release set.
Pick the choices in any order. The controls stay limited to compatible
firmware combinations that were actually built in the current release set.
The result count stays visible while you scroll through the filters on a phone.
**Same firmware** marks choices that do not narrow the builds any further;
they remain usable because they can change the setup instructions.
The optional chip-family filter (ESP32, nRF52, RP2040, or STM32) can narrow the
hardware list first. You can skip it: picking hardware fills it in automatically.
Chip family and hardware are the only dropdowns. All remaining choices use
@@ -51,6 +54,14 @@ from the published firmware assets.
</p>
<form class="firmware-picker-form" data-role="form">
<div class="firmware-picker-feedback firmware-picker-wide" data-role="filter-feedback" hidden>
<div aria-live="polite" aria-atomic="true">
<strong data-role="filter-count"></strong>
<p data-role="filter-note"></p>
</div>
<a data-role="view-results" hidden>View files</a>
</div>
<details class="firmware-picker-chip-family firmware-picker-wide">
<summary data-role="chip-family-summary">Optional: chip family</summary>
<p id="firmware-picker-chip-help">Skip this if you know your board. Picking hardware selects its chip family automatically. Choose Any to clear this filter.</p>
+16 -1
View File
@@ -774,6 +774,20 @@ verbose device log contains its `COUNT` acknowledgement; an immediate device
`ERR` or a missing acknowledgement fails during startup instead of surfacing
later as a catalog timeout.
Newer `motatool` builds prepare raw-DEFLATE blocks with Zopfli before opening
the source link. This host-only work has a separate 30-minute preparation
limit (`--seeder-prepare-wait SECONDS`) and reports progress while waiting.
The five-second device acknowledgement timer (`--seeder-start-wait SECONDS`)
starts after the final `compressed N/N blocks` message, not when compression
starts. Neither compression progress nor a host "serving" message proves
device readiness: the runner still requires the device's `COUNT` reply.
Older motatool builds without a preparation phase keep the original device
startup timeout. Process exits and device errors still fail immediately.
Preparation happens after TempRadio is armed, so leave enough lease time for
both compression and transfer, especially on a slow host. The suggested
TempRadio budget includes the preparation limit; it remains a warning, not a
hard requirement to select that entire worst-case budget.
```bash
./tools/lora_ota/lora_ota.sh ./release.mota "Remote Target" \
--controller-serial /dev/ttyACM0 \
@@ -905,7 +919,8 @@ the destination.
target, far-to-near relays, and source. A separate controller is moved and
read back through Binary; a shared Full Companion instead schedules its
bounded local override while Binary remains the transport. The runner
rejects a TempRadio window that cannot cover setup, seeder startup,
rejects TempRadio windows under 20 minutes and warns when the selected
window does not cover worst-case setup, seeder preparation and startup,
discovery, the transfer timeout, final polling, and install checks.
7. Start `motatool serve`, discover the exact eight-hex manifest ID, request
`ota pull <id> flash`, and poll until that same ID reports ready. A seeder
@@ -0,0 +1,92 @@
# Full Companion RAM audit and Heltec V3 preview 3
Date: 2026-10-02. Baseline: `0ccac453` plus the early startup-screen changes
included in this revision. All 13 selected ordinary Full builds passed the
existing linked runtime RAM guard. This is a targeted audit, not a claim that
every Companion has been rebuilt or hardware-qualified.
## Builds needing the most attention
| Hardware | Available bytes | Reserved bytes | Margin bytes |
| --- | ---: | ---: | ---: |
| Generic ESP-NOW | 143,872 | 142,336 | 1,536 |
| Tracker V2 (FEM on) | 180,000 | 177,746 | 2,254 |
| Heltec V3 | 185,032 | 182,016 | 3,016 |
| XIAO ESP32-C3 | 146,048 | 142,336 | 3,712 |
| Heltec CT62 | 146,240 | 142,336 | 3,904 |
| Wireless Tracker | 184,304 | 178,946 | 5,358 |
| Heltec T096 (FEM on) | 84,152 | 75,298 | 8,854 |
| Heltec T1 | 84,488 | 75,298 | 9,190 |
| LilyGo TLora V2.1.1.6 | 185,304 | 173,840 | 11,464 |
| Heltec V2 | 187,176 | 173,840 | 13,336 |
| TBeam SX1276 | 194,160 | 169,744 | 24,416 |
| TBeam SX1262 | 196,112 | 169,744 | 26,368 |
| Heltec T114 | 84,336 | 46,880 | 37,456 |
The six smallest margins belong to non-PSRAM ESP32 Full Companions. They pass
their existing release capacities but have little room for further growth.
Heltec T096 and T1 are the next constrained screen-equipped nRF52 Full builds.
T114 Full passes; the failed older T114 BLE-only recipe is not a Full failure.
Values come from the firmware RAM guard in the retained local
`out/companion-ram-*-before.log` build logs. Available bytes exclude statically
reserved memory and unavailable internal regions; the reservation covers
startup allocations and a transient allowance. The margin is not measured
live free heap or a prediction of time to failure. No safety budget was lowered.
## V3 solution under test
The opt-in `platformio.heltec-v3-preview3.ini` profile uses the existing
NimBLE-Arduino adapter, pinned to version 2.5.1, instead of Bluedroid. It retains
the current ordinary V3 Full capacities: 100 contacts, 40 channels, and 256
offline frames, including during mOTA. It adds no new queue-sharing or storage
changes and does not enable the larger 350-contact capacity trial.
| V3 image | Available internal bytes | Reserved bytes | Margin bytes |
| --- | ---: | ---: | ---: |
| Published preview 2 | 185,040 | 182,016 | 3,024 |
| Preview 3 | 200,152 | 182,016 | 18,136 |
Compared with published preview 2, preview 3 recovers 15,112 bytes of internal
RAM without removing Full features. Its bootloader and partition-table bytes
match preview 2. The normal release matrix is unchanged.
Bluetooth still provides the Companion server, encrypted PIN pairing,
bonding, MAC policy and stealth controls. Existing Bluedroid bonds are not
migrated: forget the old device in the phone's Bluetooth settings and pair
again after installation. This does not require erasing contacts or identity.
The preview also contains the early `Starting...` screen and animated key
generation display. The saved Off and pairing-only display modes remain
respected. Normal UI timeout starts after startup. Fresh installs still
default to Bluetooth on and infrastructure WiFi off; saved WiFi-on settings
remain on. Use `set wifi.enabled 1` to enable WiFi.
## Verification and remaining work
The real embedded build passes the unchanged RAM guard and fits both existing
WiFi OTA slots. Capability checks inspect the linked ELF, packaged application
and WebConfig gzip asset. The package includes ELF-bound RAM proof and hashes.
Adapter regressions exercise actual NimBLE transport code under sanitizers,
including identity/bond failures, encrypted PIN authentication, stealth,
notifications, MTU gating and frame retry. Startup-screen, display policy,
Companion preferences, USB defaults, identity and frame-queue regressions are
also checked. Embedded compilation uses the actual pinned NimBLE library.
This is still a hardware-test preview. V3 first-boot screen, phone pairing and
reconnection, contact sync, WiFi/BLE coexistence, mOTA sending, and sustained
load need physical verification. A build passing RAM checks does not establish
that all reported V3 boot symptoms are fixed. Do not make this stack the default
on every board before those checks.
Reproduce with one PlatformIO process at a time:
```sh
pio run -c platformio.heltec-v3-preview3.ini \
-e Heltec_v3_companion_radio_full_preview3 -t mergebin
python3 -B test/test_heltec_v3_preview_profile.py
python3 -B test/test_nimble_companion.py
python3 -B test/test_startup_screen.py
python3 -B test/test_firmware_ram.py
```