diff --git a/docs/src/flash-tool/flash-page.ts b/docs/src/flash-tool/flash-page.ts index 29c85bf27..3cf7601db 100644 --- a/docs/src/flash-tool/flash-page.ts +++ b/docs/src/flash-tool/flash-page.ts @@ -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; @@ -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; @@ -187,6 +195,7 @@ const els = { brandSelect: must("brand-select"), boardSelect: must("board-select"), consoleOutputSelect: must("console-output-select"), + preserveConfigSelect: must("preserve-config-select"), selectedBoardName: must("selected-board-name"), selectedBoardDesc: must("selected-board-desc"), selectedBoardMeta: must("selected-board-meta"), @@ -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, @@ -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; @@ -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(); @@ -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; @@ -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 { @@ -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."); @@ -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(); diff --git a/docs/src/flash-tool/i18n.ts b/docs/src/flash-tool/i18n.ts index a699f9758..6817a4cfc 100644 --- a/docs/src/flash-tool/i18n.ts +++ b/docs/src/flash-tool/i18n.ts @@ -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; @@ -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", @@ -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", diff --git a/docs/src/pages/[lang]/flash.astro b/docs/src/pages/[lang]/flash.astro index ea1db9112..5cd33b526 100644 --- a/docs/src/pages/[lang]/flash.astro +++ b/docs/src/pages/[lang]/flash.astro @@ -1145,6 +1145,14 @@ const flashBootJson = JSON.stringify({ {s.chooseConsoleOutputLabel} + + +

{s.preserveConfigHint}