Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 136 additions & 21 deletions docs/src/flash-tool/flash-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ type FirmwareRecord = {
merged_binary: FirmwareBinaryLinks;
min_flash_size?: number;
min_psram_size?: number;
nvs_info?: {
start_addr?: string;
size?: string;
};
};

type FirmwareBoards = Record<string, FirmwareRecord>;
Expand All @@ -47,6 +51,10 @@ type Strings = {
chooseBrandLabel: string;
chooseBoardLabel: string;
chooseConsoleOutputLabel: string;
preserveConfigLabel: string;
preserveConfigHint: string;
preserveConfigKeep: string;
preserveConfigOverwrite: string;
chooseChipPlaceholder: string;
chooseBrandPlaceholder: string;
chooseConsoleOutputPlaceholder: string;
Expand Down Expand Up @@ -187,6 +195,7 @@ const els = {
brandSelect: must<HTMLSelectElement>("brand-select"),
boardSelect: must<HTMLSelectElement>("board-select"),
consoleOutputSelect: must<HTMLSelectElement>("console-output-select"),
preserveConfigSelect: must<HTMLSelectElement>("preserve-config-select"),
selectedBoardName: must("selected-board-name"),
selectedBoardDesc: must("selected-board-desc"),
selectedBoardMeta: must("selected-board-meta"),
Expand Down Expand Up @@ -269,6 +278,7 @@ const state = {
selectedBrand: null as string | null,
selectedBoardId: null as string | null,
selectedConsoleOutput: null as string | null,
preserveConfig: false,
visibleBoards: [] as VisibleBoard[],
detectedConsoleOutput: null as DetectedConsoleOutput,
readyIp: null as string | null,
Expand Down Expand Up @@ -313,6 +323,7 @@ async function init() {
refreshBoards();
renderActionState();
renderConsole();
els.preserveConfigSelect.value = state.preserveConfig ? "keep" : "overwrite";

els.chipSelect.addEventListener("change", () => {
state.selectedChip = els.chipSelect.value || null;
Expand All @@ -337,6 +348,9 @@ async function init() {
renderSelectedBoard();
renderActionState();
});
els.preserveConfigSelect.addEventListener("change", () => {
state.preserveConfig = els.preserveConfigSelect.value !== "overwrite";
});

els.connectBtn.addEventListener("click", () => {
void connectDevice();
Expand Down Expand Up @@ -1036,7 +1050,11 @@ async function flashSelectedFirmware() {

state.flash = "flashing";
updateModalProgress(s.writingFlash, 0);
addProgressLine(`Flashing ${selected.boardKey} from 0x0`);
addProgressLine(
state.preserveConfig
? `Flashing ${selected.boardKey} with config retention`
: `Flashing ${selected.boardKey} from 0x0`,
);

const loader = state.loader;
let fastBaudForFlash = false;
Expand All @@ -1049,28 +1067,10 @@ async function flashSelectedFirmware() {
}
}

let lastWritePct = 0;
try {
await loader.flashData(binary, (written, total) => {
const pct = total > 0 ? Math.round((written / total) * 100) : 0;
lastWritePct = pct;
await flashBinaryWithConfigPolicy(loader, selected.firmware, binary, (pct) => {
updateModalProgress(s.writingFlash, pct);
}, 0x0, true);
} catch (flashError) {
// On ESP32-P4 (and some other chips) via UART, the stub does not respond
// to the ESP_FLASH_BEGIN(0,0) finalization command after a large compressed
// write, causing a "Timed out waiting for packet header" error even though
// all data blocks were written successfully. The post-flash reset is done
// via hardware DTR/RTS signals and does not rely on flashDeflFinish, so it
// is safe to continue the post-flash flow when this specific condition is met.
const isFinalizeTimeout =
lastWritePct >= 100 &&
getErrorMessage(flashError).includes("Timed out waiting for packet");
if (!isFinalizeTimeout) {
throw flashError;
}
addProgressLine("Note: stub finalization timed out after write; continuing with hardware reset.");
updateModalProgress(s.writingFlash, 100);
});
} finally {
if (fastBaudForFlash && loader.IS_STUB) {
try {
Expand Down Expand Up @@ -1581,6 +1581,104 @@ async function downloadResponseBuffer(
return merged.buffer;
}

async function flashBinaryWithConfigPolicy(
loader: ESPLoader,
firmware: FirmwareRecord,
binary: ArrayBuffer,
onProgress: (pct: number) => void,
) {
if (!state.preserveConfig) {
await flashBinaryChunk(loader, binary, 0x0, onProgress);
return;
}

const segments = getFlashSegmentsPreservingNvs(binary, firmware);
if (segments.length === 0) {
throw new Error("No writable firmware region found outside NVS.");
}

addProgressLine("Keeping existing NVS configuration partition.");
const totalBytes = segments.reduce((sum, segment) => sum + segment.size, 0);
let writtenBefore = 0;

for (const segment of segments) {
if (segment.offset + segment.size > binary.byteLength) {
throw new Error(
`Flash segment exceeds binary size: end=${segment.offset + segment.size}, size=${binary.byteLength}.`,
);
}
const segmentData = binary.slice(segment.offset, segment.offset + segment.size);
await flashBinaryChunk(loader, segmentData, segment.offset, (segmentPct) => {
const writtenNow = Math.round((segment.size * segmentPct) / 100);
const totalPct = Math.round(((writtenBefore + writtenNow) / totalBytes) * 100);
onProgress(totalPct);
});
writtenBefore += segment.size;
onProgress(Math.round((writtenBefore / totalBytes) * 100));
}
}

async function flashBinaryChunk(
loader: ESPLoader,
binary: ArrayBuffer,
flashOffset: number,
onProgress: (pct: number) => void,
) {
let lastWritePct = 0;
try {
await loader.flashData(
binary,
(written, total) => {
const pct = total > 0 ? Math.round((written / total) * 100) : 0;
lastWritePct = pct;
onProgress(pct);
},
flashOffset,
true,
);
} catch (flashError) {
// On ESP32-P4 (and some other chips) via UART, the stub does not respond
// to the ESP_FLASH_BEGIN(0,0) finalization command after a large compressed
// write, causing a "Timed out waiting for packet header" error even though
// all data blocks were written successfully. The post-flash reset is done
// via hardware DTR/RTS signals and does not rely on flashDeflFinish, so it
// is safe to continue the post-flash flow when this specific condition is met.
const isFinalizeTimeout =
lastWritePct >= 100 &&
getErrorMessage(flashError).includes("Timed out waiting for packet");
if (!isFinalizeTimeout) {
throw flashError;
}
addProgressLine("Note: stub finalization timed out after write; continuing with hardware reset.");
onProgress(100);
}
}

function getFlashSegmentsPreservingNvs(binary: ArrayBuffer, firmware: FirmwareRecord) {
const parsedNvsStart = parseHexAddress(firmware.nvs_info?.start_addr);
const parsedNvsSize = parseHexAddress(firmware.nvs_info?.size);
const binarySize = binary.byteLength;
if (parsedNvsStart === null || parsedNvsSize === null || parsedNvsSize <= 0) {
throw new Error(
'This firmware does not support configuration retention. Please select "Erase and reset settings" to continue.',
);
}

const nvsEnd = parsedNvsStart + parsedNvsSize;
if (parsedNvsStart < 0 || parsedNvsStart >= binarySize || nvsEnd > binarySize) {
throw new Error("Invalid NVS region in firmware metadata. Disable configuration retention and retry.");
}

const segments: Array<{ offset: number; size: number }> = [];
if (parsedNvsStart > 0) {
segments.push({ offset: 0, size: parsedNvsStart });
}
if (nvsEnd < binarySize) {
segments.push({ offset: nvsEnd, size: binarySize - nvsEnd });
}
return segments;
}

async function gunzipArrayBuffer(buffer: ArrayBuffer) {
if (typeof DecompressionStream === "undefined") {
throw new Error("This browser does not support gzip firmware flashing.");
Expand Down Expand Up @@ -1737,6 +1835,23 @@ function makeBoardId(chipKey: string, brandKey: string, boardKey: string) {
return `${chipKey}:${brandKey}:${boardKey}`;
}

function parseHexAddress(value: string | undefined) {
if (!value || !value.trim()) {
return null;
}
const trimmed = value.trim();
const isHex = /^0x[0-9a-f]+$/i.test(trimmed);
const isDecimal = /^[0-9]+$/.test(trimmed);
if (!isHex && !isDecimal) {
return null;
}
const parsed = Number.parseInt(trimmed, isHex ? 16 : 10);
if (!Number.isFinite(parsed) || !Number.isSafeInteger(parsed) || parsed < 0) {
return null;
}
return parsed;
}

function chipLabel(chipKey: string) {
const parsed = parseChipKey(chipKey);
const upper = parsed.baseChipKey.toUpperCase();
Expand Down
12 changes: 12 additions & 0 deletions docs/src/flash-tool/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export interface Strings {
chooseBrandLabel: string;
chooseBoardLabel: string;
chooseConsoleOutputLabel: string;
preserveConfigLabel: string;
preserveConfigHint: string;
preserveConfigKeep: string;
preserveConfigOverwrite: string;
chooseChipPlaceholder: string;
chooseBrandPlaceholder: string;
chooseConsoleOutputPlaceholder: string;
Expand Down Expand Up @@ -121,6 +125,10 @@ const en: Strings = {
chooseBrandLabel: "Choose Brand/Manufacturer/Series",
chooseBoardLabel: "Choose Board",
chooseConsoleOutputLabel: "Console Output",
preserveConfigLabel: "Configuration Retention",
preserveConfigHint: "Choose whether to keep existing device Wi-Fi/LLM and other NVS settings after flashing.",
preserveConfigKeep: "Keep existing settings",
preserveConfigOverwrite: "Erase and reset settings",
chooseChipPlaceholder: "Choose a chip",
chooseBrandPlaceholder: "Choose a brand/manufacturer/series",
chooseConsoleOutputPlaceholder: "Choose a console output",
Expand Down Expand Up @@ -229,6 +237,10 @@ const zhCn: Strings = {
chooseBrandLabel: "选择品牌/生产商/开发版系列",
chooseBoardLabel: "选择开发板",
chooseConsoleOutputLabel: "Console Output",
preserveConfigLabel: "配置保留",
preserveConfigHint: "可选择烧录后是否保留设备中已有的 Wi-Fi、LLM 等 NVS 配置。",
preserveConfigKeep: "保留已有配置",
preserveConfigOverwrite: "清空并重置配置",
chooseChipPlaceholder: "请选择芯片",
chooseBrandPlaceholder: "请选择品牌/生产商/开发版系列",
chooseConsoleOutputPlaceholder: "请选择 Console Output",
Expand Down
8 changes: 8 additions & 0 deletions docs/src/pages/[lang]/flash.astro
Original file line number Diff line number Diff line change
Expand Up @@ -1145,6 +1145,14 @@ const flashBootJson = JSON.stringify({
{s.chooseConsoleOutputLabel}
</label>
<select id="console-output-select"></select>
<label class="section-label" for="preserve-config-select" style="margin-top: 1rem;">
{s.preserveConfigLabel}
</label>
<select id="preserve-config-select">
<option value="keep">{s.preserveConfigKeep}</option>
<option value="overwrite">{s.preserveConfigOverwrite}</option>
</select>
<p class="section-note">{s.preserveConfigHint}</p>
<div class="selected-board" id="selected-board-summary" hidden>
<div class="selected-board-top">
<div class="board-meta-list" id="selected-board-meta"></div>
Expand Down