///
import {
CHIP_FAMILY_ESP32,
CHIP_FAMILY_ESP32S2,
CHIP_FAMILY_ESP32S3,
CHIP_FAMILY_ESP32C2,
CHIP_FAMILY_ESP32C3,
CHIP_FAMILY_ESP32C5,
CHIP_FAMILY_ESP32C6,
CHIP_FAMILY_ESP32C61,
CHIP_FAMILY_ESP32H2,
CHIP_FAMILY_ESP32H4,
CHIP_FAMILY_ESP32H21,
CHIP_FAMILY_ESP32P4,
CHIP_FAMILY_ESP32S31,
CHIP_FAMILY_ESP8266,
MAX_TIMEOUT,
Logger,
DEFAULT_TIMEOUT,
ERASE_REGION_TIMEOUT_PER_MB,
ESP_CHANGE_BAUDRATE,
ESP_CHECKSUM_MAGIC,
ESP_FLASH_BEGIN,
ESP_FLASH_DATA,
ESP_FLASH_END,
ESP_MEM_BEGIN,
ESP_MEM_DATA,
ESP_MEM_END,
ESP_READ_REG,
ESP_WRITE_REG,
ESP_SPI_ATTACH,
ESP_SYNC,
ESP_GET_SECURITY_INFO,
FLASH_SECTOR_SIZE,
FLASH_WRITE_SIZE,
STUB_FLASH_WRITE_SIZE,
MEM_END_ROM_TIMEOUT,
ROM_INVALID_RECV_MSG,
SYNC_PACKET,
SYNC_TIMEOUT,
USB_RAM_BLOCK,
ChipFamily,
ESP_ERASE_FLASH,
ESP_ERASE_REGION,
ESP_READ_FLASH,
CHIP_ERASE_TIMEOUT,
FLASH_READ_TIMEOUT,
timeoutPerMb,
ESP_ROM_BAUD,
USB_JTAG_SERIAL_PID,
ESP_FLASH_DEFL_BEGIN,
ESP_FLASH_DEFL_DATA,
ESP_FLASH_DEFL_END,
getSpiFlashAddresses,
SpiFlashAddresses,
DETECTED_FLASH_SIZES,
CHIP_DETECT_MAGIC_REG_ADDR,
CHIP_DETECT_MAGIC_VALUES,
CHIP_ID_TO_INFO,
ESP32_BASEFUSEADDR,
ESP32_APB_CTL_DATE_ADDR,
ESP32S2_EFUSE_BLOCK1_ADDR,
ESP32S3_EFUSE_BLOCK1_ADDR,
ESP32C2_EFUSE_BLOCK2_ADDR,
ESP32C5_EFUSE_BLOCK1_ADDR,
ESP32C6_EFUSE_BLOCK1_ADDR,
ESP32C61_EFUSE_BLOCK1_ADDR,
ESP32H2_EFUSE_BLOCK1_ADDR,
ESP32P4_EFUSE_BLOCK1_ADDR,
ESP32S31_EFUSE_BLOCK1_ADDR,
ESP32S31_RTC_CNTL_WDTWPROTECT_REG,
ESP32S31_RTC_CNTL_WDTCONFIG0_REG,
ESP32S31_RTC_CNTL_WDTCONFIG1_REG,
ESP32S31_RTC_CNTL_WDT_WKEY,
SlipReadError,
ESP32S2_RTC_CNTL_WDTWPROTECT_REG,
ESP32S2_RTC_CNTL_WDTCONFIG0_REG,
ESP32S2_RTC_CNTL_WDTCONFIG1_REG,
ESP32S2_RTC_CNTL_WDT_WKEY,
ESP32S2_RTC_CNTL_OPTION1_REG,
ESP32S2_RTC_CNTL_FORCE_DOWNLOAD_BOOT_MASK,
ESP32S3_RTC_CNTL_WDTWPROTECT_REG,
ESP32S3_RTC_CNTL_WDTCONFIG0_REG,
ESP32S3_RTC_CNTL_WDTCONFIG1_REG,
ESP32S3_RTC_CNTL_WDT_WKEY,
ESP32S3_RTC_CNTL_OPTION1_REG,
ESP32S3_RTC_CNTL_FORCE_DOWNLOAD_BOOT_MASK,
ESP32C3_EFUSE_RD_MAC_SPI_SYS_3_REG,
ESP32C3_EFUSE_RD_MAC_SPI_SYS_5_REG,
ESP32C5_UART_CLKDIV_REG,
ESP32C5_PCR_SYSCLK_CONF_REG,
ESP32C5_PCR_SYSCLK_XTAL_FREQ_V,
ESP32C5_PCR_SYSCLK_XTAL_FREQ_S,
ESP32P4_RTC_CNTL_WDTWPROTECT_REG,
ESP32P4_RTC_CNTL_WDTCONFIG0_REG,
ESP32P4_RTC_CNTL_WDTCONFIG1_REG,
ESP32P4_RTC_CNTL_WDT_WKEY,
ESP32P4_RTC_CNTL_OPTION1_REG,
ESP32P4_RTC_CNTL_FORCE_DOWNLOAD_BOOT_MASK,
ESP32P4_LP_SYSTEM_REG_ANA_XPD_PAD_GROUP_REG,
ESP32P4_PMU_EXT_LDO_P0_0P1A_ANA_REG,
ESP32P4_PMU_ANA_0P1A_EN_CUR_LIM_0,
ESP32P4_PMU_EXT_LDO_P0_0P1A_REG,
ESP32P4_PMU_0P1A_TARGET0_0,
ESP32P4_PMU_0P1A_FORCE_TIEH_SEL_0,
ESP32P4_PMU_DATE_REG,
ESP32S2_UARTDEV_BUF_NO,
ESP32S2_UARTDEV_BUF_NO_USB_OTG,
ESP32S3_UARTDEV_BUF_NO,
ESP32S3_UARTDEV_BUF_NO_USB_OTG,
ESP32S3_UARTDEV_BUF_NO_USB_JTAG_SERIAL,
ESP32C3_UARTDEV_BUF_NO_USB_JTAG_SERIAL,
ESP32C3_BUF_UART_NO_OFFSET,
ESP32C5_UARTDEV_BUF_NO,
ESP32C5_UARTDEV_BUF_NO_USB_JTAG_SERIAL,
ESP32C6_UARTDEV_BUF_NO,
ESP32C6_UARTDEV_BUF_NO_USB_JTAG_SERIAL,
ESP32C61_UARTDEV_BUF_NO_REV_LE2,
ESP32C61_UARTDEV_BUF_NO_REV_GT2,
ESP32C61_UARTDEV_BUF_NO_USB_JTAG_SERIAL_REV_LE2,
ESP32C61_UARTDEV_BUF_NO_USB_JTAG_SERIAL_REV_GT2,
ESP32H2_UARTDEV_BUF_NO,
ESP32H2_UARTDEV_BUF_NO_USB_JTAG_SERIAL,
ESP32H4_UARTDEV_BUF_NO,
ESP32H4_UARTDEV_BUF_NO_USB_JTAG_SERIAL,
ESP32P4_UARTDEV_BUF_NO_REV0,
ESP32P4_UARTDEV_BUF_NO_REV300,
ESP32P4_UARTDEV_BUF_NO_USB_OTG,
ESP32P4_UARTDEV_BUF_NO_USB_JTAG_SERIAL,
} from "./const";
import { getStubCode } from "./stubs";
import { hexFormatter, padTo, sleep, slipEncode, toHex } from "./util";
import { deflate } from "pako";
import { pack, unpack } from "./struct";
// Interface for WebUSB Serial Port (extends SerialPort with WebUSB-specific methods)
interface WebUSBSerialPort extends SerialPort {
isWebUSB?: boolean;
maxTransferSize?: number;
setSignals(signals: {
dataTerminalReady?: boolean;
requestToSend?: boolean;
}): Promise;
setBaudRate(baudRate: number): Promise;
}
export class ESPLoader extends EventTarget {
__chipFamily?: ChipFamily;
__chipName: string | null = null;
__chipRevision: number | null = null;
__chipVariant: string | null = null;
_efuses = new Array(4).fill(0);
_flashsize = 4 * 1024 * 1024;
debug = false;
IS_STUB = false;
connected = true;
flashSize: string | null = null;
__inputBuffer?: number[];
__inputBufferReadIndex?: number;
__totalBytesRead?: number;
public currentBaudRate: number = ESP_ROM_BAUD;
private _maxUSBSerialBaudrate?: number;
public __reader?: ReadableStreamDefaultReader;
private SLIP_END = 0xc0;
private SLIP_ESC = 0xdb;
private SLIP_ESC_END = 0xdc;
private SLIP_ESC_ESC = 0xdd;
private _isESP32S2NativeUSB: boolean = false;
private _initializationSucceeded: boolean = false;
private __commandLock: Promise<[number, number[]]> = Promise.resolve([0, []]);
private __isReconfiguring: boolean = false;
private __abandonCurrentOperation: boolean = false;
private _suppressDisconnect: boolean = false;
private __consoleMode: boolean = false;
public _isUsbJtagOrOtg: boolean | undefined = undefined;
/**
* Check if device is using USB-JTAG or USB-OTG (not external serial chip)
* Returns undefined if not yet determined
*/
public get isUsbJtagOrOtg(): boolean | undefined {
return this._parent ? this._parent._isUsbJtagOrOtg : this._isUsbJtagOrOtg;
}
// Adaptive speed adjustment for flash read operations
private __adaptiveBlockMultiplier: number = 1;
private __adaptiveMaxInFlightMultiplier: number = 1;
private __consecutiveSuccessfulChunks: number = 0;
private __lastAdaptiveAdjustment: number = 0;
private __isCDCDevice: boolean = false;
constructor(
public port: SerialPort,
public logger: Logger,
private _parent?: ESPLoader,
) {
super();
}
// Chip properties with parent delegation
// chipFamily accessed before initialization as designed
get chipFamily(): ChipFamily {
return this._parent ? this._parent.chipFamily : this.__chipFamily!;
}
set chipFamily(value: ChipFamily) {
if (this._parent) {
this._parent.chipFamily = value;
} else {
this.__chipFamily = value;
}
}
get chipName(): string | null {
return this._parent ? this._parent.chipName : this.__chipName;
}
set chipName(value: string | null) {
if (this._parent) {
this._parent.chipName = value;
} else {
this.__chipName = value;
}
}
get chipRevision(): number | null {
return this._parent ? this._parent.chipRevision : this.__chipRevision;
}
set chipRevision(value: number | null) {
if (this._parent) {
this._parent.chipRevision = value;
} else {
this.__chipRevision = value;
}
}
get chipVariant(): string | null {
return this._parent ? this._parent.chipVariant : this.__chipVariant;
}
set chipVariant(value: string | null) {
if (this._parent) {
this._parent.chipVariant = value;
} else {
this.__chipVariant = value;
}
}
// Console mode with parent delegation
private get _consoleMode(): boolean {
return this._parent ? this._parent._consoleMode : this.__consoleMode;
}
private set _consoleMode(value: boolean) {
if (this._parent) {
this._parent._consoleMode = value;
} else {
this.__consoleMode = value;
}
}
// Public setter for console mode (used by script.js)
public setConsoleMode(value: boolean): void {
this._consoleMode = value;
}
private get _inputBuffer(): number[] {
if (this._parent) {
return this._parent._inputBuffer;
}
if (this.__inputBuffer === undefined) {
throw new Error("_inputBuffer accessed before initialization");
}
return this.__inputBuffer;
}
private get _inputBufferReadIndex(): number {
return this._parent
? this._parent._inputBufferReadIndex
: this.__inputBufferReadIndex || 0;
}
private set _inputBufferReadIndex(value: number) {
if (this._parent) {
this._parent._inputBufferReadIndex = value;
} else {
this.__inputBufferReadIndex = value;
}
}
// Get available bytes in buffer (from read index to end)
private get _inputBufferAvailable(): number {
return this._inputBuffer.length - this._inputBufferReadIndex;
}
// Read one byte from buffer (ring-buffer style with index pointer)
private _readByte(): number | undefined {
if (this._inputBufferReadIndex >= this._inputBuffer.length) {
return undefined;
}
return this._inputBuffer[this._inputBufferReadIndex++];
}
// Clear input buffer and reset read index
private _clearInputBuffer(): void {
this._inputBuffer.length = 0;
this._inputBufferReadIndex = 0;
}
// Compact buffer when read index gets too large (prevent memory growth)
private _compactInputBuffer(): void {
if (
this._inputBufferReadIndex > 1000 &&
this._inputBufferReadIndex > this._inputBuffer.length / 2
) {
// Remove already-read bytes and reset index
this._inputBuffer.splice(0, this._inputBufferReadIndex);
this._inputBufferReadIndex = 0;
}
}
private get _totalBytesRead(): number {
return this._parent
? this._parent._totalBytesRead
: this.__totalBytesRead || 0;
}
private set _totalBytesRead(value: number) {
if (this._parent) {
this._parent._totalBytesRead = value;
} else {
this.__totalBytesRead = value;
}
}
private get _commandLock(): Promise<[number, number[]]> {
return this._parent ? this._parent._commandLock : this.__commandLock;
}
private set _commandLock(value: Promise<[number, number[]]>) {
if (this._parent) {
this._parent._commandLock = value;
} else {
this.__commandLock = value;
}
}
private get _isReconfiguring(): boolean {
return this._parent
? this._parent._isReconfiguring
: this.__isReconfiguring;
}
private set _isReconfiguring(value: boolean) {
if (this._parent) {
this._parent._isReconfiguring = value;
} else {
this.__isReconfiguring = value;
}
}
private get _abandonCurrentOperation(): boolean {
return this._parent
? this._parent._abandonCurrentOperation
: this.__abandonCurrentOperation;
}
private set _abandonCurrentOperation(value: boolean) {
if (this._parent) {
this._parent._abandonCurrentOperation = value;
} else {
this.__abandonCurrentOperation = value;
}
}
private get _adaptiveBlockMultiplier(): number {
return this._parent
? this._parent._adaptiveBlockMultiplier
: this.__adaptiveBlockMultiplier;
}
private set _adaptiveBlockMultiplier(value: number) {
if (this._parent) {
this._parent._adaptiveBlockMultiplier = value;
} else {
this.__adaptiveBlockMultiplier = value;
}
}
private get _adaptiveMaxInFlightMultiplier(): number {
return this._parent
? this._parent._adaptiveMaxInFlightMultiplier
: this.__adaptiveMaxInFlightMultiplier;
}
private set _adaptiveMaxInFlightMultiplier(value: number) {
if (this._parent) {
this._parent._adaptiveMaxInFlightMultiplier = value;
} else {
this.__adaptiveMaxInFlightMultiplier = value;
}
}
private get _consecutiveSuccessfulChunks(): number {
return this._parent
? this._parent._consecutiveSuccessfulChunks
: this.__consecutiveSuccessfulChunks;
}
private set _consecutiveSuccessfulChunks(value: number) {
if (this._parent) {
this._parent._consecutiveSuccessfulChunks = value;
} else {
this.__consecutiveSuccessfulChunks = value;
}
}
private get _lastAdaptiveAdjustment(): number {
return this._parent
? this._parent._lastAdaptiveAdjustment
: this.__lastAdaptiveAdjustment;
}
private set _lastAdaptiveAdjustment(value: number) {
if (this._parent) {
this._parent._lastAdaptiveAdjustment = value;
} else {
this.__lastAdaptiveAdjustment = value;
}
}
private get _isCDCDevice(): boolean {
return this._parent ? this._parent._isCDCDevice : this.__isCDCDevice;
}
private set _isCDCDevice(value: boolean) {
if (this._parent) {
this._parent._isCDCDevice = value;
} else {
this.__isCDCDevice = value;
}
}
private detectUSBSerialChip(
vendorId: number,
productId: number,
): { name: string; maxBaudrate?: number } {
// Common USB-Serial chip vendors and their products
const chips: Record<
number,
Record
> = {
0x1a86: {
// QinHeng Electronics
0x7522: { name: "CH340", maxBaudrate: 460800 },
0x7523: { name: "CH340", maxBaudrate: 460800 },
0x7584: { name: "CH340", maxBaudrate: 460800 },
0x5523: { name: "CH341", maxBaudrate: 2000000 },
0x55d3: { name: "CH343", maxBaudrate: 6000000 },
0x55d4: { name: "CH9102", maxBaudrate: 6000000 },
0x55d8: { name: "CH9101", maxBaudrate: 3000000 },
},
0x10c4: {
// Silicon Labs
0xea60: { name: "CP2102(n)", maxBaudrate: 3000000 },
0xea70: { name: "CP2105", maxBaudrate: 2000000 },
0xea71: { name: "CP2108", maxBaudrate: 2000000 },
},
0x0403: {
// FTDI
0x6001: { name: "FT232R", maxBaudrate: 3000000 },
0x6010: { name: "FT2232", maxBaudrate: 3000000 },
0x6011: { name: "FT4232", maxBaudrate: 3000000 },
0x6014: { name: "FT232H", maxBaudrate: 12000000 },
0x6015: { name: "FT230X", maxBaudrate: 3000000 },
},
0x303a: {
// Espressif (native USB)
0x2: { name: "ESP32-S2 Native USB", maxBaudrate: 2000000 },
0x12: { name: "ESP32-P4 Native USB", maxBaudrate: 2000000 },
0x1001: { name: "ESP32 Native USB", maxBaudrate: 2000000 },
},
};
const vendor = chips[vendorId];
if (vendor && vendor[productId]) {
return vendor[productId];
}
return {
name: `Unknown (VID: 0x${vendorId.toString(16)}, PID: 0x${productId.toString(16)})`,
};
}
async initialize() {
if (!this._parent) {
this.__inputBuffer = [];
this.__inputBufferReadIndex = 0;
this.__totalBytesRead = 0;
// Detect and log USB-Serial chip info
const portInfo = this.port.getInfo();
if (portInfo.usbVendorId && portInfo.usbProductId) {
const chipInfo = this.detectUSBSerialChip(
portInfo.usbVendorId,
portInfo.usbProductId,
);
this.logger.log(
`USB-Serial: ${chipInfo.name} (VID: 0x${portInfo.usbVendorId.toString(16)}, PID: 0x${portInfo.usbProductId.toString(16)})`,
);
if (chipInfo.maxBaudrate) {
this._maxUSBSerialBaudrate = chipInfo.maxBaudrate;
this.logger.log(`Max baudrate: ${chipInfo.maxBaudrate}`);
}
// Detect ESP32-S2 Native USB
if (portInfo.usbVendorId === 0x303a && portInfo.usbProductId === 0x2) {
this._isESP32S2NativeUSB = true;
}
// Detect CDC devices for adaptive speed adjustment
// Espressif Native USB (VID: 0x303a) or CH343 (VID: 0x1a86, PID: 0x55d3)
if (
portInfo.usbVendorId === 0x303a ||
(portInfo.usbVendorId === 0x1a86 && portInfo.usbProductId === 0x55d3)
) {
this._isCDCDevice = true;
}
}
// Don't await this promise so it doesn't block rest of method.
this.readLoop();
}
// Try to connect with different reset strategies
await this.connectWithResetStrategies();
// Detect chip type
await this.detectChip();
// Power on flash for ESP32-P4 Rev 301 (must be done before loading stub)
if (this.chipFamily === CHIP_FAMILY_ESP32P4 && this.chipRevision === 301) {
await this.powerOnFlash();
}
// Detect if device is using USB-JTAG/Serial or USB-OTG (not external serial chip)
// This is needed to determine the correct reset strategy for console mode
try {
this._isUsbJtagOrOtg = await this.detectUsbConnectionType();
this.logger.debug(
`USB connection type: ${this._isUsbJtagOrOtg ? "USB-JTAG/OTG" : "External Serial Chip"}`,
);
} catch (err) {
this.logger.debug(`Could not detect USB connection type: ${err}`);
}
try {
const usbMode = await this.getUsbMode();
this.logger.debug(
`USB mode (register): ${usbMode.mode} (uartNo=${usbMode.uartNo})`,
);
} catch (err) {
this.logger.debug(`Could not detect USB mode: ${err}`);
}
// Read the OTP data for this chip and store into this.efuses array
const FlAddr = getSpiFlashAddresses(this.getChipFamily());
const AddrMAC = FlAddr.macFuse;
for (let i = 0; i < 4; i++) {
this._efuses[i] = await this.readRegister(AddrMAC + 4 * i);
}
const revisionInfo =
this.chipRevision !== null && this.chipRevision !== undefined
? ` (revision ${this.chipRevision})`
: "";
this.logger.log(`Connected to ${this.chipName}${revisionInfo}`);
this.logger.debug(
`Bootloader flash offset: 0x${FlAddr.flashOffs.toString(16)}`,
);
// Mark initialization as successful
this._initializationSucceeded = true;
}
/**
* Detect chip type using GET_SECURITY_INFO (for newer chips) or magic value (for older chips)
*/
async detectChip() {
try {
// Try GET_SECURITY_INFO command first (ESP32-C3 and later)
const securityInfo = await this.getSecurityInfo();
const chipId = securityInfo.chipId;
const chipInfo = CHIP_ID_TO_INFO[chipId];
if (chipInfo) {
this.chipName = chipInfo.name;
this.chipFamily = chipInfo.family;
this.chipRevision = await this.getChipRevision();
this.logger.debug(`${this.chipName} revision: ${this.chipRevision}`);
if (
this.chipFamily === CHIP_FAMILY_ESP32P4 &&
this.chipRevision >= 300
) {
this.chipVariant = "rev300";
} else if (this.chipFamily === CHIP_FAMILY_ESP32P4) {
this.chipVariant = "rev0";
}
this.logger.debug(
`Detected chip via IMAGE_CHIP_ID: ${chipId} (${this.chipName})`,
);
return;
}
this.logger.debug(
`Unknown IMAGE_CHIP_ID: ${chipId}, falling back to magic value detection`,
);
} catch (error) {
// GET_SECURITY_INFO not supported, fall back to magic value detection
this.logger.debug(
`GET_SECURITY_INFO failed, using magic value detection: ${error}`,
);
// Drain input buffer for CP210x compatibility on Windows
// This ensures all error responses are cleared before continuing
await this.drainInputBuffer(200);
// Clear input buffer and re-sync to recover from failed command
this._clearInputBuffer();
await sleep(SYNC_TIMEOUT);
// Re-sync with the chip to ensure clean communication
try {
await this.sync();
} catch (syncErr) {
this.logger.debug(
`Re-sync after GET_SECURITY_INFO failure: ${syncErr}`,
);
}
}
// Fallback: Use magic value detection for ESP8266, ESP32, ESP32-S2
const chipMagicValue = await this.readRegister(CHIP_DETECT_MAGIC_REG_ADDR);
const chip = CHIP_DETECT_MAGIC_VALUES[chipMagicValue >>> 0];
if (chip === undefined) {
throw new Error(
`Unknown Chip: Hex: ${toHex(
chipMagicValue >>> 0,
8,
).toLowerCase()} Number: ${chipMagicValue}`,
);
}
this.chipName = chip.name;
this.chipFamily = chip.family;
this.chipRevision = await this.getChipRevision();
this.logger.debug(`${this.chipName} revision: ${this.chipRevision}`);
if (this.chipFamily === CHIP_FAMILY_ESP32P4) {
this.chipVariant = this.chipRevision >= 300 ? "rev300" : "rev0";
this.logger.debug(`ESP32-P4 variant: ${this.chipVariant}`);
}
this.logger.debug(
`Detected chip via magic value: ${toHex(chipMagicValue >>> 0, 8)} (${this.chipName})`,
);
}
async getChipRevision(): Promise {
let minor = 0;
let major = 0;
switch (this.chipFamily) {
case CHIP_FAMILY_ESP32: {
const efuse3 = await this.readRegister(ESP32_BASEFUSEADDR + 4 * 3);
const efuse5 = await this.readRegister(ESP32_BASEFUSEADDR + 4 * 5);
minor = (efuse5 >> 24) & 0x3;
const revBit0 = (efuse3 >> 15) & 0x1;
const revBit1 = (efuse5 >> 20) & 0x1;
const apb = await this.readRegister(ESP32_APB_CTL_DATE_ADDR);
const revBit2 = (apb >> 31) & 0x1;
const combined = (revBit2 << 2) | (revBit1 << 1) | revBit0;
major =
({ 0: 0, 1: 1, 3: 2, 7: 3 } as Record)[combined] ?? 0;
break;
}
case CHIP_FAMILY_ESP32S2: {
const w3 = await this.readRegister(ESP32S2_EFUSE_BLOCK1_ADDR + 4 * 3);
const w4 = await this.readRegister(ESP32S2_EFUSE_BLOCK1_ADDR + 4 * 4);
const hi = (w3 >> 20) & 0x01;
const lo = (w4 >> 4) & 0x07;
minor = (hi << 3) + lo;
major = (w3 >> 18) & 0x03;
break;
}
case CHIP_FAMILY_ESP32S3: {
const w3 = await this.readRegister(ESP32S3_EFUSE_BLOCK1_ADDR + 4 * 3);
const w5 = await this.readRegister(ESP32S3_EFUSE_BLOCK1_ADDR + 4 * 5);
const hi = (w5 >> 23) & 0x01;
const lo = (w3 >> 18) & 0x07;
minor = (hi << 3) + lo;
major = (w5 >> 24) & 0x03;
break;
}
case CHIP_FAMILY_ESP32C2: {
const w1 = await this.readRegister(ESP32C2_EFUSE_BLOCK2_ADDR + 4 * 1);
minor = (w1 >> 16) & 0x0f;
major = (w1 >> 20) & 0x03;
break;
}
case CHIP_FAMILY_ESP32C3: {
const w3 = await this.readRegister(ESP32C3_EFUSE_RD_MAC_SPI_SYS_3_REG);
const w5 = await this.readRegister(ESP32C3_EFUSE_RD_MAC_SPI_SYS_5_REG);
const hi = (w5 >> 23) & 0x01;
const lo = (w3 >> 18) & 0x07;
minor = (hi << 3) + lo;
major = (w5 >> 24) & 0x03;
break;
}
case CHIP_FAMILY_ESP32C5: {
const w2 = await this.readRegister(ESP32C5_EFUSE_BLOCK1_ADDR + 4 * 2);
minor = w2 & 0x0f;
major = (w2 >> 4) & 0x03;
break;
}
case CHIP_FAMILY_ESP32C6: {
const w3 = await this.readRegister(ESP32C6_EFUSE_BLOCK1_ADDR + 4 * 3);
minor = (w3 >> 18) & 0x0f;
major = (w3 >> 22) & 0x03;
break;
}
case CHIP_FAMILY_ESP32C61: {
const w2 = await this.readRegister(ESP32C61_EFUSE_BLOCK1_ADDR + 4 * 2);
minor = w2 & 0x0f;
major = (w2 >> 4) & 0x03;
break;
}
case CHIP_FAMILY_ESP32H2: {
const w3 = await this.readRegister(ESP32H2_EFUSE_BLOCK1_ADDR + 4 * 3);
minor = (w3 >> 18) & 0x07;
major = (w3 >> 21) & 0x03;
break;
}
case CHIP_FAMILY_ESP32H4: {
break;
}
case CHIP_FAMILY_ESP32H21: {
break;
}
case CHIP_FAMILY_ESP32P4: {
const w2 = await this.readRegister(ESP32P4_EFUSE_BLOCK1_ADDR + 4 * 2);
minor = w2 & 0x0f;
major = (((w2 >> 23) & 1) << 2) | ((w2 >> 4) & 0x03);
break;
}
case CHIP_FAMILY_ESP32S31: {
const w2 = await this.readRegister(ESP32S31_EFUSE_BLOCK1_ADDR + 4 * 2);
minor = w2 & 0x0f;
major = (w2 >> 4) & 0x03;
break;
}
}
return major * 100 + minor;
}
/**
* Power on the flash chip for ESP32-P4 Rev 301 (ECO6)
* The flash chip is powered off by default on ECO6, when the default flash
* voltage changed from 1.8V to 3.3V. This is to prevent damage to 1.8V flash chips.
*/
async powerOnFlash(): Promise {
if (this.chipFamily !== CHIP_FAMILY_ESP32P4) {
return; // Only needed for ESP32-P4
}
if (this.chipRevision !== 301) {
return; // Only needed for Rev 301 (ECO6)
}
this.logger.debug("Powering on flash for ESP32-P4 Rev 301 (ECO6)");
// Power up pad group
await this.writeRegister(ESP32P4_LP_SYSTEM_REG_ANA_XPD_PAD_GROUP_REG, 1);
await sleep(10); // 0.01 seconds
// Flash power up sequence
const pmuAnaReg = await this.readRegister(
ESP32P4_PMU_EXT_LDO_P0_0P1A_ANA_REG,
);
await this.writeRegister(
ESP32P4_PMU_EXT_LDO_P0_0P1A_ANA_REG,
pmuAnaReg | ESP32P4_PMU_ANA_0P1A_EN_CUR_LIM_0,
);
const pmuReg = await this.readRegister(ESP32P4_PMU_EXT_LDO_P0_0P1A_REG);
await this.writeRegister(
ESP32P4_PMU_EXT_LDO_P0_0P1A_REG,
pmuReg | ESP32P4_PMU_0P1A_FORCE_TIEH_SEL_0,
);
const pmuDateReg = await this.readRegister(ESP32P4_PMU_DATE_REG);
await this.writeRegister(ESP32P4_PMU_DATE_REG, pmuDateReg | (3 << 0));
await sleep(0.05); // 0.00005 seconds = 0.05 ms
const pmuAnaReg2 = await this.readRegister(
ESP32P4_PMU_EXT_LDO_P0_0P1A_ANA_REG,
);
await this.writeRegister(
ESP32P4_PMU_EXT_LDO_P0_0P1A_ANA_REG,
pmuAnaReg2 & ~ESP32P4_PMU_ANA_0P1A_EN_CUR_LIM_0,
);
const pmuReg2 = await this.readRegister(ESP32P4_PMU_EXT_LDO_P0_0P1A_REG);
await this.writeRegister(
ESP32P4_PMU_EXT_LDO_P0_0P1A_REG,
pmuReg2 & ~ESP32P4_PMU_0P1A_TARGET0_0,
);
// Update eFuse voltage to PMU
const pmuReg3 = await this.readRegister(ESP32P4_PMU_EXT_LDO_P0_0P1A_REG);
await this.writeRegister(ESP32P4_PMU_EXT_LDO_P0_0P1A_REG, pmuReg3 | 0x80);
const pmuReg4 = await this.readRegister(ESP32P4_PMU_EXT_LDO_P0_0P1A_REG);
await this.writeRegister(
ESP32P4_PMU_EXT_LDO_P0_0P1A_REG,
pmuReg4 & ~ESP32P4_PMU_0P1A_FORCE_TIEH_SEL_0,
);
await sleep(2); // 0.0018 seconds = 1.8 ms, rounded to 2ms
this.logger.debug("Flash powered on successfully");
}
/**
* Get security info including chip ID (ESP32-C3 and later)
*/
async getSecurityInfo(): Promise<{
flags: number;
flashCryptCnt: number;
keyPurposes: number[];
chipId: number;
apiVersion: number;
}> {
const [, responseData] = await this.checkCommand(
ESP_GET_SECURITY_INFO,
[],
0,
);
// Some chips/ROM versions return empty response or don't support this command
if (responseData.length === 0) {
throw new Error(
`GET_SECURITY_INFO not supported or returned empty response`,
);
}
if (responseData.length < 12) {
throw new Error(
`Invalid security info response length: ${responseData.length} (expected at least 12 bytes)`,
);
}
const flags = unpack("= 16
? unpack("= 20
? unpack(" {
if (!this._initializationSucceeded) {
throw new Error(
"getMacAddress() requires initialize() to have completed successfully",
);
}
const macBytes = this.macAddr(); // chip-family-aware
return macBytes
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join(":");
}
/**
* @name readLoop
* Reads data from the input stream and places it in the inputBuffer
*/
async readLoop() {
if (this.debug) {
this.logger.debug("Starting read loop");
}
this._reader = this.port.readable!.getReader();
try {
let keepReading = true;
while (keepReading) {
const { value, done } = await this._reader.read();
if (done) {
this._reader.releaseLock();
keepReading = false;
break;
}
if (!value || value.length === 0) {
continue;
}
// Always read from browser's serial buffer immediately
// to prevent browser buffer overflow. Don't apply back-pressure here.
const chunk = Array.from(value as Uint8Array);
Array.prototype.push.apply(this._inputBuffer, chunk);
// Track total bytes read from serial port
this._totalBytesRead += value.length;
}
} catch {
// this.logger.error("Read loop got disconnected");
} finally {
// Always reset reconfiguring flag when read loop ends
// This prevents "Cannot write during port reconfiguration" errors
// when the read loop dies unexpectedly
this._isReconfiguring = false;
// Release reader if still locked
if (this._reader) {
try {
this._reader.releaseLock();
this.logger.debug("Reader released in readLoop cleanup");
} catch (err) {
this.logger.debug(`Reader release error in readLoop: ${err}`);
}
this._reader = undefined;
}
}
// Disconnected!
this.connected = false;
// Check if this is ESP32-S2 Native USB that needs port reselection
// Only trigger reconnect if initialization did NOT succeed (wrong port)
if (this._isESP32S2NativeUSB && !this._initializationSucceeded) {
this.logger.log(
"ESP32-S2 Native USB detected - requesting port reselection",
);
this.dispatchEvent(
new CustomEvent("esp32s2-usb-reconnect", {
detail: { message: "ESP32-S2 Native USB requires port reselection" },
}),
);
}
// Only dispatch disconnect event if not suppressed
if (!this._suppressDisconnect) {
this.dispatchEvent(new Event("disconnect"));
}
this._suppressDisconnect = false;
this.logger.debug("Finished read loop");
}
state_DTR = false;
state_RTS = false;
// ============================================================================
// Web Serial (Desktop) - DTR/RTS Signal Handling & Reset Strategies
// ============================================================================
async setRTS(state: boolean) {
await this.port.setSignals({ requestToSend: state });
// Work-around for adapters on Windows using the usbser.sys driver:
// generate a dummy change to DTR so that the set-control-line-state
// request is sent with the updated RTS state and the same DTR state
// Referenced to esptool.py
await this.setDTR(this.state_DTR);
}
async setDTR(state: boolean) {
this.state_DTR = state;
await this.port.setSignals({ dataTerminalReady: state });
}
async setDTRandRTS(dtr: boolean, rts: boolean) {
this.state_DTR = dtr;
this.state_RTS = rts;
await this.port.setSignals({
dataTerminalReady: dtr,
requestToSend: rts,
});
}
private async runSignalSequence(
steps: Array<{ dtr?: boolean; rts?: boolean; delayMs?: number }>,
): Promise {
const webusb =
(this.port as unknown as { isWebUSB?: boolean }).isWebUSB === true;
for (const step of steps) {
if (step.dtr !== undefined && step.rts !== undefined) {
if (webusb) {
await this.setDTRandRTSWebUSB(step.dtr, step.rts);
} else {
await this.setDTRandRTS(step.dtr, step.rts);
}
} else {
if (step.dtr !== undefined) {
if (webusb) {
await this.setDTRWebUSB(step.dtr);
} else {
await this.setDTR(step.dtr);
}
}
if (step.rts !== undefined) {
if (webusb) {
await this.setRTSWebUSB(step.rts);
} else {
await this.setRTS(step.rts);
}
}
}
if (step.delayMs) await sleep(step.delayMs);
}
}
/**
* @name hardResetUSBJTAGSerial
* USB-JTAG/Serial reset for Web Serial (Desktop)
*/
async hardResetUSBJTAGSerial() {
await this.runSignalSequence([
{ rts: false },
{ dtr: false, delayMs: 100 },
{ dtr: true, rts: false, delayMs: 100 },
{ rts: true },
{ dtr: false, rts: true, delayMs: 100 },
{ dtr: false, rts: false, delayMs: 200 },
]);
}
/**
* @name hardResetClassic
* Classic reset for Web Serial (Desktop) DTR = IO0, RTS = EN
*/
async hardResetClassic() {
await this.runSignalSequence([
{ dtr: false, rts: true, delayMs: 100 },
{ dtr: true, rts: false, delayMs: 50 },
{ dtr: false, delayMs: 200 },
]);
}
/**
* Reset to firmware mode (not bootloader) for Web Serial
* Keeps IO0=HIGH during reset so chip boots into firmware
*/
async hardResetToFirmware() {
await this.runSignalSequence([
{ dtr: false, rts: true, delayMs: 100 },
{ rts: false, delayMs: 50 },
{ delayMs: 200 },
]);
}
/**
* @name hardResetUnixTight
* Unix Tight reset for Web Serial (Desktop) - sets DTR and RTS simultaneously
*/
async hardResetUnixTight() {
await this.runSignalSequence([
{ dtr: true, rts: true },
{ dtr: false, rts: false },
{ dtr: false, rts: true, delayMs: 100 },
{ dtr: true, rts: false, delayMs: 50 },
{ dtr: false, rts: false },
{ dtr: false, delayMs: 200 },
]);
}
// ============================================================================
// WebUSB (Android) - DTR/RTS Signal Handling & Reset Strategies
// ============================================================================
async setRTSWebUSB(state: boolean) {
this.state_RTS = state;
// Always specify both signals to avoid flipping the other line
// The WebUSB setSignals() now preserves unspecified signals, but being explicit is safer
await (this.port as WebUSBSerialPort).setSignals({
requestToSend: state,
dataTerminalReady: this.state_DTR,
});
}
async setDTRWebUSB(state: boolean) {
this.state_DTR = state;
// Always specify both signals to avoid flipping the other line
await (this.port as WebUSBSerialPort).setSignals({
dataTerminalReady: state,
requestToSend: this.state_RTS, // Explicitly preserve current RTS state
});
}
async setDTRandRTSWebUSB(dtr: boolean, rts: boolean) {
this.state_DTR = dtr;
this.state_RTS = rts;
await (this.port as WebUSBSerialPort).setSignals({
dataTerminalReady: dtr,
requestToSend: rts,
});
}
/**
* @name hardResetUSBJTAGSerialInvertedDTRWebUSB
* USB-JTAG/Serial reset with inverted DTR for WebUSB (Android)
*/
async hardResetUSBJTAGSerialInvertedDTRWebUSB() {
await this.runSignalSequence([
{ rts: false, dtr: true, delayMs: 100 },
{ dtr: false, rts: false, delayMs: 100 },
{ rts: true, dtr: true, delayMs: 100 },
{ dtr: true, rts: false, delayMs: 200 },
]);
}
/**
* @name hardResetClassicLongDelayWebUSB
* Classic reset with longer delays for WebUSB (Android)
* Specifically for CP2102/CH340 which may need more time
*/
async hardResetClassicLongDelayWebUSB() {
await this.runSignalSequence([
{ dtr: false, rts: true, delayMs: 500 },
{ dtr: true, rts: false, delayMs: 200 },
{ dtr: false, delayMs: 500 },
]);
}
/**
* @name hardResetClassicShortDelayWebUSB
* Classic reset with shorter delays for WebUSB (Android)
*/
async hardResetClassicShortDelayWebUSB() {
await this.runSignalSequence([
{ dtr: false, rts: true, delayMs: 50 },
{ dtr: true, rts: false, delayMs: 25 },
{ dtr: false, delayMs: 100 },
]);
}
/**
* @name hardResetInvertedWebUSB
* Inverted reset sequence for WebUSB (Android) - both signals inverted
*/
async hardResetInvertedWebUSB() {
await this.runSignalSequence([
{ dtr: true, rts: false, delayMs: 100 },
{ dtr: false, rts: true, delayMs: 50 },
{ dtr: true, delayMs: 200 },
]);
}
/**
* @name hardResetInvertedDTRWebUSB
* Only DTR inverted for WebUSB (Android)
*/
async hardResetInvertedDTRWebUSB() {
await this.runSignalSequence([
{ dtr: true, rts: true, delayMs: 100 },
{ dtr: false, rts: false, delayMs: 50 },
{ dtr: true, delayMs: 200 },
]);
}
/**
* @name hardResetInvertedRTSWebUSB
* Only RTS inverted for WebUSB (Android)
*/
async hardResetInvertedRTSWebUSB() {
await this.runSignalSequence([
{ dtr: false, rts: false, delayMs: 100 },
{ dtr: true, rts: true, delayMs: 50 },
{ dtr: false, delayMs: 200 },
]);
}
/**
* Check if we're using WebUSB (Android) or Web Serial (Desktop)
*/
private isWebUSB(): boolean {
// WebUSBSerial class has isWebUSB flag - this is the most reliable check
return (this.port as WebUSBSerialPort).isWebUSB === true;
}
/**
* @name connectWithResetStrategies
* Try different reset strategies to enter bootloader mode
* Similar to esptool.py's connect() method with multiple reset strategies
*/
async connectWithResetStrategies() {
const portInfo = this.port.getInfo();
const isUSBJTAGSerial = portInfo.usbProductId === USB_JTAG_SERIAL_PID;
const isEspressifUSB = portInfo.usbVendorId === 0x303a;
// this.logger.log(
// `Detected USB: VID=0x${portInfo.usbVendorId?.toString(16) || "unknown"}, PID=0x${portInfo.usbProductId?.toString(16) || "unknown"}`,
// );
// Define reset strategies to try in order
const resetStrategies: Array<{ name: string; fn: () => Promise }> =
[];
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
// Detect if this is a USB-Serial chip (needs different sync approach)
const isUSBSerialChip = !isUSBJTAGSerial && !isEspressifUSB;
// WebUSB (Android) uses different reset methods than Web Serial (Desktop)
if (this.isWebUSB()) {
// For USB-Serial chips (CP2102, CH340, etc.), try inverted strategies first
// Detect specific chip types once
const isCP2102 = portInfo.usbVendorId === 0x10c4;
const isCH34x = portInfo.usbVendorId === 0x1a86;
// Check for ESP32-S2 Native USB (VID: 0x303a, PID: 0x0002)
const isESP32S2NativeUSB =
portInfo.usbVendorId === 0x303a && portInfo.usbProductId === 0x0002;
// WebUSB Strategy 1: USB-JTAG/Serial reset (for Native USB only)
if (isUSBJTAGSerial || isEspressifUSB) {
if (isESP32S2NativeUSB) {
// ESP32-S2 Native USB: Try multiple strategies
// The device might be in JTAG mode OR CDC mode
// Strategy 1: USB-JTAG/Serial (works in CDC mode on Desktop)
resetStrategies.push({
name: "USB-JTAG/Serial (WebUSB) - ESP32-S2",
fn: async () => {
return await self.hardResetUSBJTAGSerial();
},
});
// Strategy 2: USB-JTAG/Serial Inverted DTR (works in JTAG mode)
resetStrategies.push({
name: "USB-JTAG/Serial Inverted DTR (WebUSB) - ESP32-S2",
fn: async () => {
return await self.hardResetUSBJTAGSerialInvertedDTRWebUSB();
},
});
// Strategy 3: UnixTight (CDC fallback)
resetStrategies.push({
name: "UnixTight (WebUSB) - ESP32-S2 CDC",
fn: async () => {
return await self.hardResetUnixTight();
},
});
// Strategy 4: Classic reset (CDC fallback)
resetStrategies.push({
name: "Classic (WebUSB) - ESP32-S2 CDC",
fn: async () => {
return await self.hardResetClassic();
},
});
} else {
// Other USB-JTAG chips: Try Inverted DTR first - works best for ESP32-H2 and other JTAG chips
resetStrategies.push({
name: "USB-JTAG/Serial Inverted DTR (WebUSB)",
fn: async () => {
return await self.hardResetUSBJTAGSerialInvertedDTRWebUSB();
},
});
resetStrategies.push({
name: "USB-JTAG/Serial (WebUSB)",
fn: async () => {
return await self.hardResetUSBJTAGSerial();
},
});
resetStrategies.push({
name: "Inverted DTR Classic (WebUSB)",
fn: async () => {
return await self.hardResetInvertedDTRWebUSB();
},
});
}
}
// For USB-Serial chips, try inverted strategies first
if (isUSBSerialChip) {
if (isCH34x) {
// CH340/CH343: UnixTight works best (like CP2102)
resetStrategies.push({
name: "UnixTight (WebUSB) - CH34x",
fn: async () => {
return await self.hardResetUnixTight();
},
});
resetStrategies.push({
name: "Classic (WebUSB) - CH34x",
fn: async () => {
return await self.hardResetClassic();
},
});
resetStrategies.push({
name: "Inverted Both (WebUSB) - CH34x",
fn: async () => {
return await self.hardResetInvertedWebUSB();
},
});
resetStrategies.push({
name: "Inverted RTS (WebUSB) - CH34x",
fn: async () => {
return await self.hardResetInvertedRTSWebUSB();
},
});
resetStrategies.push({
name: "Inverted DTR (WebUSB) - CH34x",
fn: async () => {
return await self.hardResetInvertedDTRWebUSB();
},
});
} else if (isCP2102) {
// CP2102: UnixTight works best (tested and confirmed)
// Try it first, then fallback to other strategies
resetStrategies.push({
name: "UnixTight (WebUSB) - CP2102",
fn: async () => {
return await self.hardResetUnixTight();
},
});
resetStrategies.push({
name: "Classic (WebUSB) - CP2102",
fn: async () => {
return await self.hardResetClassic();
},
});
resetStrategies.push({
name: "Inverted Both (WebUSB) - CP2102",
fn: async () => {
return await self.hardResetInvertedWebUSB();
},
});
resetStrategies.push({
name: "Inverted RTS (WebUSB) - CP2102",
fn: async () => {
return await self.hardResetInvertedRTSWebUSB();
},
});
resetStrategies.push({
name: "Inverted DTR (WebUSB) - CP2102",
fn: async () => {
return await self.hardResetInvertedDTRWebUSB();
},
});
} else {
// For other USB-Serial chips, try UnixTight first, then multiple strategies
resetStrategies.push({
name: "UnixTight (WebUSB)",
fn: async () => {
return await self.hardResetUnixTight();
},
});
resetStrategies.push({
name: "Classic (WebUSB)",
fn: async function () {
return await self.hardResetClassic();
},
});
resetStrategies.push({
name: "Inverted Both (WebUSB)",
fn: async function () {
return await self.hardResetInvertedWebUSB();
},
});
resetStrategies.push({
name: "Inverted RTS (WebUSB)",
fn: async function () {
return await self.hardResetInvertedRTSWebUSB();
},
});
resetStrategies.push({
name: "Inverted DTR (WebUSB)",
fn: async function () {
return await self.hardResetInvertedDTRWebUSB();
},
});
}
}
// Add general fallback strategies only for Native USB chips (not USB-Serial)
// and only for chips not already handled by specific blocks above
if (
!isUSBSerialChip &&
!isCP2102 &&
!isESP32S2NativeUSB &&
!isUSBJTAGSerial
) {
// Classic reset (for chips not handled above)
if (portInfo.usbVendorId !== 0x1a86) {
resetStrategies.push({
name: "Classic (WebUSB)",
fn: async function () {
return await self.hardResetClassic();
},
});
}
// UnixTight reset (sets DTR/RTS simultaneously)
resetStrategies.push({
name: "UnixTight (WebUSB)",
fn: async function () {
return await self.hardResetUnixTight();
},
});
// WebUSB Strategy: Classic with long delays
resetStrategies.push({
name: "Classic Long Delay (WebUSB)",
fn: async function () {
return await self.hardResetClassicLongDelayWebUSB();
},
});
// WebUSB Strategy: Classic with short delays
resetStrategies.push({
name: "Classic Short Delay (WebUSB)",
fn: async function () {
return await self.hardResetClassicShortDelayWebUSB();
},
});
// WebUSB Strategy: USB-JTAG/Serial fallback
if (!isEspressifUSB) {
resetStrategies.push({
name: "USB-JTAG/Serial fallback (WebUSB)",
fn: async function () {
return await self.hardResetUSBJTAGSerial();
},
});
}
}
} else {
// Strategy: USB-JTAG/Serial reset
if (isUSBJTAGSerial || isEspressifUSB) {
resetStrategies.push({
name: "USB-JTAG/Serial",
fn: async function () {
return await self.hardResetUSBJTAGSerial();
},
});
}
// Strategy: UnixTight reset
resetStrategies.push({
name: "UnixTight",
fn: async function () {
return await self.hardResetUnixTight();
},
});
// Strategy: USB-JTAG/Serial fallback
if (!isUSBJTAGSerial && !isEspressifUSB) {
resetStrategies.push({
name: "USB-JTAG/Serial (fallback)",
fn: async function () {
return await self.hardResetUSBJTAGSerial();
},
});
}
}
let lastError: Error | null = null;
// Try each reset strategy with timeout
for (const strategy of resetStrategies) {
try {
// Check if port is still open, if not, skip this strategy
if (!this.connected || !this.port.writable) {
this.logger.debug(
`Port disconnected, skipping ${strategy.name} reset`,
);
continue;
}
// Clear abandon flag before starting new strategy
this._abandonCurrentOperation = false;
await strategy.fn();
// Try to sync after reset
// USB-Serial / native USB chips needs different sync approaches
if (isUSBSerialChip) {
// USB-Serial chips: Use timeout strategy (2 seconds)
// this.logger.log(`USB-Serial chip detected, using sync with timeout.`);
const syncSuccess = await this.syncWithTimeout(2000);
if (syncSuccess) {
// Sync succeeded
this.logger.log(
`Connected USB Serial successfully with ${strategy.name} reset.`,
);
return;
} else {
throw new Error("Sync timeout or abandoned");
}
} else {
// Native USB chips
// Note: We use Promise.race with sync() directly instead of syncWithTimeout()
// because syncWithTimeout causes CDC/JTAG devices to hang for unknown reasons.
// The abandon flag in readPacket() prevents overlapping I/O.
// this.logger.log(`Native USB chip detected, using CDC/JTAG sync.`);
const syncPromise = this.sync();
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error("Sync timeout")), 1000),
);
try {
await Promise.race([syncPromise, timeoutPromise]);
// Sync succeeded
this.logger.debug(
`Connected CDC/JTAG successfully with ${strategy.name} reset.`,
);
return;
} catch {
throw new Error("Sync timeout or abandoned");
}
}
} catch (error) {
lastError = error as Error;
// this.logger.debug(
// `${strategy.name} reset failed: ${(error as Error).message}`,
// );
// Set abandon flag to stop any in-flight operations
this._abandonCurrentOperation = true;
// Wait a bit for in-flight operations to abort
await sleep(100);
// If port got disconnected, we can't try more strategies
if (!this.connected || !this.port.writable) {
this.logger.log(`Port disconnected during reset attempt`);
break;
}
// Clear buffers before trying next strategy
this._clearInputBuffer();
await this.drainInputBuffer(200);
await this.flushSerialBuffers();
}
}
// All strategies failed - reset abandon flag before throwing
this._abandonCurrentOperation = false;
throw new Error(
`Couldn't sync to ESP. Try resetting manually. Last error: ${lastError?.message}`,
);
}
/**
* @name watchdogReset
* Watchdog reset for ESP32-S2/S3/P4 with USB-OTG or USB-JTAG/Serial
* Uses RTC watchdog timer to reset the chip - works when DTR/RTS signals are not available
* This is an alias for rtcWdtResetChipSpecific() for backwards compatibility
* Note: ESP32-C3, ESP32-C5, ESP32-C6 do NOT boot correctly after WDT reset
*/
async watchdogReset() {
await this.rtcWdtResetChipSpecific();
}
/**
* RTC watchdog timer reset for ESP32-S2, ESP32-S3, ESP32-P4, and ESP32-S31
* Uses specific registers for each chip family
* Note: ESP32-C3 does NOT boot correctly after WDT reset
* Note: ESP32-C5, ESP32-C6, ESP32-C61, ESP32-H2 do NOT support WDT reset (no usable RTC WDT path)
*/
public async rtcWdtResetChipSpecific(): Promise {
this.logger.debug("Hard resetting with watchdog timer...");
let WDTWPROTECT_REG: number;
let WDTCONFIG0_REG: number;
let WDTCONFIG1_REG: number;
let WDT_WKEY: number;
if (this.chipFamily === CHIP_FAMILY_ESP32S2) {
WDTWPROTECT_REG = ESP32S2_RTC_CNTL_WDTWPROTECT_REG;
WDTCONFIG0_REG = ESP32S2_RTC_CNTL_WDTCONFIG0_REG;
WDTCONFIG1_REG = ESP32S2_RTC_CNTL_WDTCONFIG1_REG;
WDT_WKEY = ESP32S2_RTC_CNTL_WDT_WKEY;
} else if (this.chipFamily === CHIP_FAMILY_ESP32S3) {
WDTWPROTECT_REG = ESP32S3_RTC_CNTL_WDTWPROTECT_REG;
WDTCONFIG0_REG = ESP32S3_RTC_CNTL_WDTCONFIG0_REG;
WDTCONFIG1_REG = ESP32S3_RTC_CNTL_WDTCONFIG1_REG;
WDT_WKEY = ESP32S3_RTC_CNTL_WDT_WKEY;
} else if (this.chipFamily === CHIP_FAMILY_ESP32P4) {
// P4 uses LP_WDT (Low Power Watchdog Timer)
WDTWPROTECT_REG = ESP32P4_RTC_CNTL_WDTWPROTECT_REG;
WDTCONFIG0_REG = ESP32P4_RTC_CNTL_WDTCONFIG0_REG;
WDTCONFIG1_REG = ESP32P4_RTC_CNTL_WDTCONFIG1_REG;
WDT_WKEY = ESP32P4_RTC_CNTL_WDT_WKEY;
} else if (this.chipFamily === CHIP_FAMILY_ESP32S31) {
// S31 uses LP_WDT (Low Power Watchdog Timer)
WDTWPROTECT_REG = ESP32S31_RTC_CNTL_WDTWPROTECT_REG;
WDTCONFIG0_REG = ESP32S31_RTC_CNTL_WDTCONFIG0_REG;
WDTCONFIG1_REG = ESP32S31_RTC_CNTL_WDTCONFIG1_REG;
WDT_WKEY = ESP32S31_RTC_CNTL_WDT_WKEY;
} else {
throw new Error(
`rtcWdtResetChipSpecific() is not supported for ${this.chipFamily}`,
);
}
// Unlock watchdog registers
await this.writeRegister(WDTWPROTECT_REG, WDT_WKEY, undefined, 0);
// Set WDT timeout to 2000ms (matches Python esptool)
await this.writeRegister(WDTCONFIG1_REG, 2000, undefined, 0);
// Enable WDT: bit 31 = enable, bits 28-30 = stage, bit 8 = sys reset, bits 0-2 = prescaler
const wdtConfig = (1 << 31) | (5 << 28) | (1 << 8) | 2;
await this.writeRegister(WDTCONFIG0_REG, wdtConfig, undefined, 0);
// Lock watchdog registers
await this.writeRegister(WDTWPROTECT_REG, 0, undefined, 0);
// Wait for reset to take effect
await sleep(500);
}
/**
* Reset device from bootloader mode to firmware mode
* Automatically selects the correct reset strategy based on USB connection type
* @param clearForceDownloadFlag - If true, clears the force download boot flag (USB-OTG only)
* @returns true if port will change (USB-OTG), false otherwise
*/
public async resetToFirmwareMode(
clearForceDownloadFlag = true,
): Promise {
this.logger.debug("Resetting from bootloader to firmware mode...");
try {
// Detect USB connection type
const isUsbJtagOrOtg = await this.detectUsbConnectionType();
if (isUsbJtagOrOtg) {
// USB-JTAG/OTG devices need special handling
this.logger.debug("USB-JTAG/OTG detected - checking WDT reset support");
// Get detailed USB mode information
let usbMode: {
mode: "uart" | "usb-jtag-serial" | "usb-otg";
uartNo: number;
};
try {
usbMode = await this.getUsbMode();
this.logger.debug(
`USB mode: ${usbMode.mode} (uartNo=${usbMode.uartNo})`,
);
} catch (err) {
this.logger.debug(`Could not get USB mode: ${err}`);
// Fall back to generic USB-JTAG/OTG handling
usbMode = { mode: "usb-jtag-serial", uartNo: 0 };
}
// WDT reset is not needed for ESP32-C3
// WDT reset is supported by: ESP32-S2, ESP32-S3, ESP32-P4
// WDT reset is NOT supported by: ESP32-C5, ESP32-C6, ESP32-C61, ESP32-H2
const supportsWdtReset =
this.chipFamily === CHIP_FAMILY_ESP32S2 ||
this.chipFamily === CHIP_FAMILY_ESP32S3 ||
this.chipFamily === CHIP_FAMILY_ESP32P4 ||
this.chipFamily === CHIP_FAMILY_ESP32S31;
if (!supportsWdtReset) {
this.logger.debug(
`${this.chipName} does not support WDT reset - using classic reset instead`,
);
// Use classic reset for chips without WDT support
await this.hardResetToFirmware();
this.logger.debug("Classic reset to firmware complete");
return false; // Port stays open
}
// WDT reset is supported - proceed with WDT reset logic
this.logger.debug(
`${this.chipName} supports WDT reset - using WDT reset strategy`,
);
// CRITICAL: WDT register writes require ROM (not stub) and baudrate 115200
// If on stub, need to return to ROM first
if (this.IS_STUB) {
this.logger.debug("On stub - returning to ROM before WDT reset");
// Change baudrate back to ROM baudrate if needed
if (this.currentBaudRate !== ESP_ROM_BAUD) {
this.logger.debug(
`Changing baudrate from ${this.currentBaudRate} to ${ESP_ROM_BAUD}`,
);
await this.reconfigurePort(ESP_ROM_BAUD);
this.currentBaudRate = ESP_ROM_BAUD;
this.logger.debug("Baudrate changed to 115200");
}
// CRITICAL: Temporarily clear console mode flag so hardReset(true) works
const wasInConsoleMode = this._consoleMode;
this._consoleMode = false;
// Reset to bootloader (ROM)
await this.hardReset(true);
await sleep(200);
// Restore console mode flag
this._consoleMode = wasInConsoleMode;
// Sync with ROM
await this.sync();
this.IS_STUB = false;
this.logger.debug("Now on ROM");
} else {
// Even if not on stub, ensure baudrate is 115200 for WDT register writes
if (this.currentBaudRate !== ESP_ROM_BAUD) {
this.logger.debug(
`Not on stub, but baudrate is ${this.currentBaudRate} - changing to ${ESP_ROM_BAUD} for WDT reset`,
);
await this.reconfigurePort(ESP_ROM_BAUD);
this.currentBaudRate = ESP_ROM_BAUD;
this.logger.debug("Baudrate changed to 115200");
}
}
// Clear force download boot flag if requested (USB-OTG only)
if (clearForceDownloadFlag && usbMode.mode === "usb-otg") {
const flagCleared = await this._clearForceDownloadBootIfNeeded();
if (flagCleared) {
this.logger.debug("Force download boot flag cleared");
}
}
// Perform WDT reset to boot into firmware
await this.rtcWdtResetChipSpecific();
this.logger.debug("WDT reset performed - device will boot to firmware");
// Check if port will change after WDT reset
// USB-OTG (ESP32-S2/P4): Port always changes
// USB-JTAG/Serial (ESP32-S3/C3/C5/C6/C61/H2/P4): Port may change depending on platform
const portWillChange =
usbMode.mode === "usb-otg" || usbMode.mode === "usb-jtag-serial";
if (portWillChange) {
this.logger.debug(
`Port will change after WDT reset (${usbMode.mode}) - port reselection needed`,
);
return true;
}
return false;
} else {
// External serial chip - use classic reset to firmware
this.logger.debug(
"External serial chip detected - using classic reset",
);
await this.hardResetToFirmware();
this.logger.debug("Classic reset to firmware complete");
return false;
}
} catch (err) {
this.logger.error(`Failed to reset to firmware mode: ${err}`);
throw err;
}
}
async hardReset(bootloader = false) {
// In console mode, only allow simple hardware reset (no bootloader entry)
if (this._consoleMode) {
if (bootloader) {
this.logger.debug(
"Skipping bootloader reset - device is in console mode",
);
return;
}
// Simple hardware reset to restart firmware (IO0=HIGH)
this.logger.debug("Performing hardware reset (console mode)...");
await this.resetInConsoleMode();
this.logger.debug("Hardware reset complete");
return;
}
if (bootloader) {
// Enter bootloader/flash mode
if (this.port.getInfo().usbProductId === USB_JTAG_SERIAL_PID) {
await this.hardResetUSBJTAGSerial();
this.logger.debug("USB-JTAG/Serial reset to bootloader.");
} else {
await this.hardResetClassic();
this.logger.debug("Classic reset to bootloader.");
}
} else {
// Reset to firmware mode (exit bootloader)
// Use intelligent reset strategy based on USB connection type
this.logger.debug("Resetting to firmware mode...");
// Detect USB connection type to choose correct reset method
const isUsbJtagOrOtg = await this.detectUsbConnectionType();
if (isUsbJtagOrOtg) {
// USB-JTAG/OTG devices: Check if chip supports WDT reset
// Only S2, S3, P4 support WDT reset correctly
// C3, C5, C6, C61, H2 do NOT boot correctly after WDT reset
const supportsWdtReset =
this.chipFamily === CHIP_FAMILY_ESP32S2 ||
this.chipFamily === CHIP_FAMILY_ESP32S3 ||
this.chipFamily === CHIP_FAMILY_ESP32P4 ||
this.chipFamily === CHIP_FAMILY_ESP32S31;
if (supportsWdtReset) {
this.logger.debug("USB-JTAG/OTG detected - using WDT reset");
// Get USB mode details
let usbMode: {
mode: "uart" | "usb-jtag-serial" | "usb-otg";
uartNo: number;
};
try {
usbMode = await this.getUsbMode();
this.logger.debug(
`USB mode: ${usbMode.mode} (uartNo=${usbMode.uartNo})`,
);
} catch (err) {
this.logger.debug(`Could not get USB mode: ${err}`);
usbMode = { mode: "usb-jtag-serial", uartNo: 0 };
}
// Clear force download flag for USB-OTG devices
if (usbMode.mode === "usb-otg") {
try {
const flagCleared = await this._clearForceDownloadBootIfNeeded();
if (flagCleared) {
this.logger.debug("Force download boot flag cleared");
}
} catch (err) {
this.logger.debug(`Could not clear force download flag: ${err}`);
}
}
// Perform WDT reset
await this.rtcWdtResetChipSpecific();
this.logger.debug(`${this.chipName}: WDT reset to firmware complete`);
return;
} else {
// C3, C5, C6, etc. - use classic reset (like external serial chips)
this.logger.debug(
`${this.chipName} does not support WDT reset - using classic reset instead`,
);
}
} else {
// External serial chip: Use classic reset
this.logger.debug(
"External serial chip detected - using classic reset",
);
}
// Classic reset: used for external serial chips and USB-JTAG chips that do not support WDT reset
if (this.isWebUSB()) {
// WebUSB: Use longer delays for better compatibility
await this.setRTSWebUSB(true); // EN->LOW
await sleep(200);
await this.setRTSWebUSB(false);
await sleep(200);
this.logger.debug("Hard reset to firmware (WebUSB).");
} else {
// Web Serial: Standard reset
await this.setRTS(true); // EN->LOW
await sleep(100);
await this.setRTS(false);
this.logger.debug("Hard reset to firmware.");
}
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
/**
* @name macAddr
* The MAC address burned into the OTP memory of the ESP chip
*/
macAddr() {
const macAddr = new Array(6).fill(0);
const mac0 = this._efuses[0];
const mac1 = this._efuses[1];
const mac2 = this._efuses[2];
const mac3 = this._efuses[3];
let oui;
if (this.chipFamily == CHIP_FAMILY_ESP8266) {
if (mac3 != 0) {
oui = [(mac3 >> 16) & 0xff, (mac3 >> 8) & 0xff, mac3 & 0xff];
} else if (((mac1 >> 16) & 0xff) == 0) {
oui = [0x18, 0xfe, 0x34];
} else if (((mac1 >> 16) & 0xff) == 1) {
oui = [0xac, 0xd0, 0x74];
} else {
throw new Error("Couldnt determine OUI");
}
macAddr[0] = oui[0];
macAddr[1] = oui[1];
macAddr[2] = oui[2];
macAddr[3] = (mac1 >> 8) & 0xff;
macAddr[4] = mac1 & 0xff;
macAddr[5] = (mac0 >> 24) & 0xff;
} else if (this.chipFamily == CHIP_FAMILY_ESP32) {
macAddr[0] = (mac2 >> 8) & 0xff;
macAddr[1] = mac2 & 0xff;
macAddr[2] = (mac1 >> 24) & 0xff;
macAddr[3] = (mac1 >> 16) & 0xff;
macAddr[4] = (mac1 >> 8) & 0xff;
macAddr[5] = mac1 & 0xff;
} else if (
this.chipFamily == CHIP_FAMILY_ESP32S2 ||
this.chipFamily == CHIP_FAMILY_ESP32S3 ||
this.chipFamily == CHIP_FAMILY_ESP32C2 ||
this.chipFamily == CHIP_FAMILY_ESP32C3 ||
this.chipFamily == CHIP_FAMILY_ESP32C5 ||
this.chipFamily == CHIP_FAMILY_ESP32C6 ||
this.chipFamily == CHIP_FAMILY_ESP32C61 ||
this.chipFamily == CHIP_FAMILY_ESP32H2 ||
this.chipFamily == CHIP_FAMILY_ESP32H4 ||
this.chipFamily == CHIP_FAMILY_ESP32H21 ||
this.chipFamily == CHIP_FAMILY_ESP32P4 ||
this.chipFamily == CHIP_FAMILY_ESP32S31
) {
macAddr[0] = (mac1 >> 8) & 0xff;
macAddr[1] = mac1 & 0xff;
macAddr[2] = (mac0 >> 24) & 0xff;
macAddr[3] = (mac0 >> 16) & 0xff;
macAddr[4] = (mac0 >> 8) & 0xff;
macAddr[5] = mac0 & 0xff;
} else {
throw new Error("Unknown chip family");
}
return macAddr;
}
async readRegister(reg: number) {
if (this.debug) {
this.logger.debug("Reading from Register " + toHex(reg, 8));
}
const packet = pack(" {
// Serialize command execution to prevent lock contention
const executeCommand = async (): Promise<[number, number[]]> => {
timeout = Math.min(timeout, MAX_TIMEOUT);
await this.sendCommand(opcode, buffer, checksum);
const [value, responseData] = await this.getResponse(opcode, timeout);
if (responseData === null) {
throw new Error("Didn't get enough status bytes");
}
let data = responseData;
let statusLen = 0;
if (this.IS_STUB || this.chipFamily == CHIP_FAMILY_ESP8266) {
statusLen = 2;
} else if (
[
CHIP_FAMILY_ESP32,
CHIP_FAMILY_ESP32S2,
CHIP_FAMILY_ESP32S3,
CHIP_FAMILY_ESP32C2,
CHIP_FAMILY_ESP32C3,
CHIP_FAMILY_ESP32C5,
CHIP_FAMILY_ESP32C6,
CHIP_FAMILY_ESP32C61,
CHIP_FAMILY_ESP32H2,
CHIP_FAMILY_ESP32H4,
CHIP_FAMILY_ESP32H21,
CHIP_FAMILY_ESP32P4,
CHIP_FAMILY_ESP32S31,
].includes(this.chipFamily)
) {
statusLen = 4;
} else {
// When chipFamily is not yet set (e.g., during GET_SECURITY_INFO in detectChip),
// assume modern chips use 4-byte status
if (opcode === ESP_GET_SECURITY_INFO) {
statusLen = 4;
} else if ([2, 4].includes(data.length)) {
statusLen = data.length;
} else {
// Default to 2-byte status if we can't determine
// This prevents silent data corruption when statusLen would be 0
statusLen = 2;
this.logger.debug(
`Unknown chip family, defaulting to 2-byte status (opcode: ${toHex(opcode)}, data.length: ${data.length})`,
);
}
}
if (data.length < statusLen) {
throw new Error("Didn't get enough status bytes");
}
const status = data.slice(-statusLen, data.length);
data = data.slice(0, -statusLen);
if (this.debug) {
this.logger.debug("status", status);
this.logger.debug("value", value);
this.logger.debug("data", data);
}
if (status[0] == 1) {
if (status[1] == ROM_INVALID_RECV_MSG) {
// Unsupported command can result in more than one error response
// Use drainInputBuffer for CP210x compatibility on Windows
await this.drainInputBuffer(200);
throw new Error("Invalid (unsupported) command " + toHex(opcode));
} else {
throw new Error("Command failure error code " + toHex(status[1]));
}
}
return [value, data];
};
// Chain command execution through the lock
// Use both .then() handlers to ensure lock continues even on error
this._commandLock = this._commandLock.then(executeCommand, executeCommand);
return this._commandLock;
}
/**
* @name sendCommand
* Send a slip-encoded, checksummed command over the UART,
* does not check response
*/
async sendCommand(opcode: number, buffer: number[], checksum = 0) {
const packet = slipEncode([
...pack(" {
let partialPacket: number[] | null = null;
let inEscape = false;
// CDC devices use burst processing, non-CDC use byte-by-byte
if (this._isCDCDevice) {
// Burst version: Process all available bytes in one pass for ultra-high-speed transfers
// Used for: CDC devices (all platforms) and CH343
const startTime = Date.now();
while (true) {
// Check abandon flag (for reset strategy timeout)
if (this._abandonCurrentOperation) {
throw new SlipReadError(
"Operation abandoned (reset strategy timeout)",
);
}
// Check timeout
if (Date.now() - startTime > timeout) {
const waitingFor = partialPacket === null ? "header" : "content";
throw new SlipReadError("Timed out waiting for packet " + waitingFor);
}
// If no data available, wait a bit
if (this._inputBufferAvailable === 0) {
await sleep(1);
continue;
}
// Process all available bytes without going back to outer loop
// This is critical for handling high-speed burst transfers
while (this._inputBufferAvailable > 0) {
// Periodic timeout check to prevent hang on slow data
if (Date.now() - startTime > timeout) {
const waitingFor = partialPacket === null ? "header" : "content";
throw new SlipReadError(
"Timed out waiting for packet " + waitingFor,
);
}
const byte = this._readByte()!;
if (partialPacket === null) {
// waiting for packet header
if (byte == this.SLIP_END) {
partialPacket = [];
} else {
if (this.debug) {
this.logger.debug("Read invalid data: " + toHex(byte));
this.logger.debug(
"Remaining data in serial buffer: " +
hexFormatter(this._inputBuffer),
);
}
throw new SlipReadError(
"Invalid head of packet (" + toHex(byte) + ")",
);
}
} else if (inEscape) {
// part-way through escape sequence
inEscape = false;
if (byte == this.SLIP_ESC_END) {
partialPacket.push(this.SLIP_END);
} else if (byte == this.SLIP_ESC_ESC) {
partialPacket.push(this.SLIP_ESC);
} else {
if (this.debug) {
this.logger.debug("Read invalid data: " + toHex(byte));
this.logger.debug(
"Remaining data in serial buffer: " +
hexFormatter(this._inputBuffer),
);
}
throw new SlipReadError(
"Invalid SLIP escape (0xdb, " + toHex(byte) + ")",
);
}
} else if (byte == this.SLIP_ESC) {
// start of escape sequence
inEscape = true;
} else if (byte == this.SLIP_END) {
// end of packet
if (this.debug)
this.logger.debug(
"Received full packet: " + hexFormatter(partialPacket),
);
// Compact buffer periodically to prevent memory growth
this._compactInputBuffer();
return partialPacket;
} else {
// normal byte in packet
partialPacket.push(byte);
}
}
}
} else {
// Byte-by-byte version: Stable for non CDC USB-Serial adapters (CH340, CP2102, etc.)
let readBytes: number[] = [];
while (true) {
// Check abandon flag (for reset strategy timeout)
if (this._abandonCurrentOperation) {
throw new SlipReadError(
"Operation abandoned (reset strategy timeout)",
);
}
const stamp = Date.now();
readBytes = [];
while (Date.now() - stamp < timeout) {
if (this._inputBufferAvailable > 0) {
readBytes.push(this._readByte()!);
break;
} else {
// Reduced sleep time for faster response during high-speed transfers
await sleep(1);
}
}
if (readBytes.length == 0) {
const waitingFor = partialPacket === null ? "header" : "content";
throw new SlipReadError("Timed out waiting for packet " + waitingFor);
}
if (this.debug)
this.logger.debug(
"Read " + readBytes.length + " bytes: " + hexFormatter(readBytes),
);
for (const byte of readBytes) {
if (partialPacket === null) {
// waiting for packet header
if (byte == this.SLIP_END) {
partialPacket = [];
} else {
if (this.debug) {
this.logger.debug("Read invalid data: " + toHex(byte));
this.logger.debug(
"Remaining data in serial buffer: " +
hexFormatter(this._inputBuffer),
);
}
throw new SlipReadError(
"Invalid head of packet (" + toHex(byte) + ")",
);
}
} else if (inEscape) {
// part-way through escape sequence
inEscape = false;
if (byte == this.SLIP_ESC_END) {
partialPacket.push(this.SLIP_END);
} else if (byte == this.SLIP_ESC_ESC) {
partialPacket.push(this.SLIP_ESC);
} else {
if (this.debug) {
this.logger.debug("Read invalid data: " + toHex(byte));
this.logger.debug(
"Remaining data in serial buffer: " +
hexFormatter(this._inputBuffer),
);
}
throw new SlipReadError(
"Invalid SLIP escape (0xdb, " + toHex(byte) + ")",
);
}
} else if (byte == this.SLIP_ESC) {
// start of escape sequence
inEscape = true;
} else if (byte == this.SLIP_END) {
// end of packet
if (this.debug)
this.logger.debug(
"Received full packet: " + hexFormatter(partialPacket),
);
// Compact buffer periodically to prevent memory growth
this._compactInputBuffer();
return partialPacket;
} else {
// normal byte in packet
partialPacket.push(byte);
}
}
}
}
}
/**
* @name getResponse
* Read response data and decodes the slip packet, then parses
* out the value/data and returns as a tuple of (value, data) where
* each is a list of bytes
*/
async getResponse(
opcode: number,
timeout = DEFAULT_TIMEOUT,
): Promise<[number, number[]]> {
for (let i = 0; i < 100; i++) {
const packet = await this.readPacket(timeout);
if (packet.length < 8) {
continue;
}
const [resp, opRet, , val] = unpack(" {
const reg = await this.readRegister(ESP32C5_PCR_SYSCLK_CONF_REG);
return (
(reg & ESP32C5_PCR_SYSCLK_XTAL_FREQ_V) >>> ESP32C5_PCR_SYSCLK_XTAL_FREQ_S
);
}
async getC5CrystalFreqDetected(): Promise {
const UART_CLKDIV_MASK = 0xfffff;
const uartDiv =
(await this.readRegister(ESP32C5_UART_CLKDIV_REG)) & UART_CLKDIV_MASK;
const estXtal = (ESP_ROM_BAUD * uartDiv) / 1e6;
if (estXtal > 45) return 48;
if (estXtal > 33) return 40;
return 26;
}
async setBaudrate(baud: number) {
const chipFamily = this._parent ? this._parent.chipFamily : this.chipFamily;
if (!this.IS_STUB && chipFamily === CHIP_FAMILY_ESP32C5) {
await this.setBaudrateC5Rom(baud);
} else {
try {
const buffer = pack(" maxBaud) {
this.logger.log(
`⚠️ WARNING: Baudrate ${baud} exceeds USB-Serial chip limit (${maxBaud})!`,
);
this.logger.log(
`⚠️ This may cause data corruption or connection failures!`,
);
}
this.logger.debug(`Changed baud rate to ${baud}`);
}
private async setBaudrateC5Rom(baud: number) {
const crystalFreqRomExpect = await this.getC5CrystalFreqRomExpect();
const crystalFreqDetect = await this.getC5CrystalFreqDetected();
this.logger.log(
`ROM expects crystal freq: ${crystalFreqRomExpect} MHz, detected ${crystalFreqDetect} MHz.`,
);
let baudRate = baud;
if (crystalFreqDetect === 48 && crystalFreqRomExpect === 40) {
baudRate = Math.trunc((baud * 40) / 48);
} else if (crystalFreqDetect === 40 && crystalFreqRomExpect === 48) {
baudRate = Math.trunc((baud * 48) / 40);
}
this.logger.log(`Changing baud rate to ${baudRate}...`);
try {
const buffer = pack(" {
const startTime = Date.now();
for (let i = 0; i < 5; i++) {
// Check if we've exceeded the timeout
if (Date.now() - startTime > timeoutMs) {
return false;
}
// Check abandon flag
if (this._abandonCurrentOperation) {
return false;
}
this._clearInputBuffer();
try {
const response = await this._sync();
if (response) {
await sleep(SYNC_TIMEOUT);
return true;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
// Check abandon flag after error
if (this._abandonCurrentOperation) {
return false;
}
}
await sleep(SYNC_TIMEOUT);
}
return false;
}
/**
* @name sync
* Put into ROM bootload mode & attempt to synchronize with the
* ESP ROM bootloader, we will retry a few times
*/
async sync() {
for (let i = 0; i < 5; i++) {
this._clearInputBuffer();
const response = await this._sync();
if (response) {
await sleep(SYNC_TIMEOUT);
return true;
}
await sleep(SYNC_TIMEOUT);
}
throw new Error("Couldn't sync to ESP. Try resetting.");
}
/**
* @name _sync
* Perform a soft-sync using AT sync packets, does not perform
* any hardware resetting
*/
async _sync() {
await this.sendCommand(ESP_SYNC, SYNC_PACKET);
for (let i = 0; i < 8; i++) {
try {
const [, data] = await this.getResponse(ESP_SYNC, SYNC_TIMEOUT);
if (data.length > 1 && data[0] == 0 && data[1] == 0) {
return true;
}
} catch (e) {
if (this.debug) {
this.logger.debug(`Sync attempt ${i + 1} failed: ${e}`);
}
}
}
return false;
}
/**
* @name getFlashWriteSize
* Get the Flash write size based on the chip
*/
getFlashWriteSize() {
if (this.IS_STUB) {
return STUB_FLASH_WRITE_SIZE;
}
return FLASH_WRITE_SIZE;
}
/**
* @name flashData
* Program a full, uncompressed binary file into SPI Flash at
* a given offset. If an ESP32 and md5 string is passed in, will also
* verify memory. ESP8266 does not have checksum memory verification in
* ROM
*/
async flashData(
binaryData: ArrayBuffer,
updateProgress: (bytesWritten: number, totalBytes: number) => void,
offset = 0,
compress = false,
) {
if (binaryData.byteLength >= 8) {
// unpack the (potential) image header
const header = Array.from(new Uint8Array(binaryData, 0, 4));
const headerMagic = header[0];
const headerFlashMode = header[2];
const headerFlashSizeFreq = header[3];
this.logger.log(
`Image header, Magic=${toHex(headerMagic)}, FlashMode=${toHex(
headerFlashMode,
)}, FlashSizeFreq=${toHex(headerFlashSizeFreq)}`,
);
}
const paddedData = padTo(new Uint8Array(binaryData), 4);
binaryData = paddedData.buffer as ArrayBuffer;
const uncompressedFilesize = binaryData.byteLength;
let compressedFilesize = 0;
let dataToFlash;
let timeout = DEFAULT_TIMEOUT;
if (compress) {
dataToFlash = deflate(new Uint8Array(binaryData), {
level: 9,
}).buffer;
compressedFilesize = dataToFlash.byteLength;
this.logger.log(
`Writing data with filesize: ${uncompressedFilesize}. Compressed Size: ${compressedFilesize}`,
);
timeout = await this.flashDeflBegin(
uncompressedFilesize,
compressedFilesize,
offset,
);
} else {
this.logger.log(`Writing data with filesize: ${uncompressedFilesize}`);
dataToFlash = binaryData;
await this.flashBegin(uncompressedFilesize, offset);
}
let block = [];
let seq = 0;
let written = 0;
let position = 0;
const stamp = Date.now();
const flashWriteSize = this.getFlashWriteSize();
const filesize = compress ? compressedFilesize : uncompressedFilesize;
while (filesize - position > 0) {
if (this.debug) {
this.logger.log(
`Writing at ${toHex(offset + seq * flashWriteSize, 8)} `,
);
}
if (filesize - position >= flashWriteSize) {
block = Array.from(
new Uint8Array(dataToFlash, position, flashWriteSize),
);
} else {
// Pad the last block only if we are sending uncompressed data.
block = Array.from(
new Uint8Array(dataToFlash, position, filesize - position),
);
if (!compress) {
block = block.concat(
new Array(flashWriteSize - block.length).fill(0xff),
);
}
}
if (compress) {
await this.flashDeflBlock(block, seq, timeout);
} else {
await this.flashBlock(block, seq);
}
seq += 1;
// If using compression we update the progress with the proportional size of the block taking into account the compression ratio.
// This way we report progress on the uncompressed size
written += compress
? Math.round((block.length * uncompressedFilesize) / compressedFilesize)
: block.length;
position += flashWriteSize;
updateProgress(
Math.min(written, uncompressedFilesize),
uncompressedFilesize,
);
}
this.logger.log(
"Took " + (Date.now() - stamp) + "ms to write " + filesize + " bytes",
);
// Only send flashF finish if running the stub because ir causes the ROM to exit and run user code
if (this.IS_STUB) {
await this.flashBegin(0, 0);
if (compress) {
await this.flashDeflFinish();
} else {
await this.flashFinish();
}
}
}
/**
* @name flashBlock
* Send one block of data to program into SPI Flash memory
*/
async flashBlock(data: number[], seq: number, timeout = DEFAULT_TIMEOUT) {
await this.checkCommand(
ESP_FLASH_DATA,
pack(" 0) {
// add a dummy write to a date register as an excuse to have a delay
buffer = buffer.concat(
pack(
" 0) {
await this.writeRegister(SPI_MOSI_DLEN_REG, mosiBits - 1);
}
if (misoBits > 0) {
await this.writeRegister(SPI_MISO_DLEN_REG, misoBits - 1);
}
} else {
const SPI_DATA_LEN_REG = spiAddresses.regBase + spiAddresses.usr1Offs;
const SPI_MOSI_BITLEN_S = 17;
const SPI_MISO_BITLEN_S = 8;
const mosiMask = mosiBits == 0 ? 0 : mosiBits - 1;
const misoMask = misoBits == 0 ? 0 : misoBits - 1;
const value =
(misoMask << SPI_MISO_BITLEN_S) | (mosiMask << SPI_MOSI_BITLEN_S);
await this.writeRegister(SPI_DATA_LEN_REG, value);
}
}
async waitDone(spiCmdReg: number, spiCmdUsr: number) {
for (let i = 0; i < 10; i++) {
const cmdValue = await this.readRegister(spiCmdReg);
if ((cmdValue & spiCmdUsr) == 0) {
return;
}
}
throw Error("SPI command did not complete in time");
}
async runSpiFlashCommand(
spiflashCommand: number,
data: number[],
readBits = 0,
) {
// Run an arbitrary SPI flash command.
// This function uses the "USR_COMMAND" functionality in the ESP
// SPI hardware, rather than the precanned commands supported by
// hardware. So the value of spiflash_command is an actual command
// byte, sent over the wire.
// After writing command byte, writes 'data' to MOSI and then
// reads back 'read_bits' of reply on MISO. Result is a number.
// SPI_USR register flags
const SPI_USR_COMMAND = 1 << 31;
const SPI_USR_MISO = 1 << 28;
const SPI_USR_MOSI = 1 << 27;
// SPI registers, base address differs
const spiAddresses = getSpiFlashAddresses(this.getChipFamily());
const base = spiAddresses.regBase;
const SPI_CMD_REG = base;
const SPI_USR_REG = base + spiAddresses.usrOffs;
const SPI_USR2_REG = base + spiAddresses.usr2Offs;
const SPI_W0_REG = base + spiAddresses.w0Offs;
// SPI peripheral "command" bitmasks for SPI_CMD_REG
const SPI_CMD_USR = 1 << 18;
// shift values
const SPI_USR2_COMMAND_LEN_SHIFT = 28;
if (readBits > 32) {
throw new Error(
"Reading more than 32 bits back from a SPI flash operation is unsupported",
);
}
if (data.length > 64) {
throw new Error(
"Writing more than 64 bytes of data with one SPI command is unsupported",
);
}
const dataBits = data.length * 8;
const oldSpiUsr = await this.readRegister(SPI_USR_REG);
const oldSpiUsr2 = await this.readRegister(SPI_USR2_REG);
let flags = SPI_USR_COMMAND;
if (readBits > 0) {
flags |= SPI_USR_MISO;
}
if (dataBits > 0) {
flags |= SPI_USR_MOSI;
}
await this.setDataLengths(spiAddresses, dataBits, readBits);
await this.writeRegister(SPI_USR_REG, flags);
await this.writeRegister(
SPI_USR2_REG,
(7 << SPI_USR2_COMMAND_LEN_SHIFT) | spiflashCommand,
);
if (dataBits == 0) {
await this.writeRegister(SPI_W0_REG, 0); // clear data register before we read it
} else {
const padLen = (4 - (data.length % 4)) % 4;
data = data.concat(new Array(padLen).fill(0x00)); // pad to 32-bit multiple
const words = unpack("I".repeat(Math.floor(data.length / 4)), data);
let nextReg = SPI_W0_REG;
this.logger.debug(`Words Length: ${words.length}`);
for (const word of words) {
this.logger.debug(
`Writing word ${toHex(word)} to register offset ${toHex(nextReg)}`,
);
await this.writeRegister(nextReg, word);
nextReg += 4;
}
}
await this.writeRegister(SPI_CMD_REG, SPI_CMD_USR);
await this.waitDone(SPI_CMD_REG, SPI_CMD_USR);
const status = await this.readRegister(SPI_W0_REG);
// restore some SPI controller registers
await this.writeRegister(SPI_USR_REG, oldSpiUsr);
await this.writeRegister(SPI_USR2_REG, oldSpiUsr2);
return status;
}
async detectFlashSize() {
this.logger.debug("Detecting Flash Size");
const flashId = await this.flashId();
const flashIdLowbyte = (flashId >> 16) & 0xff;
this.flashSize = DETECTED_FLASH_SIZES[flashIdLowbyte];
this.logger.log(`Auto-detected Flash size: ${this.flashSize}`);
}
/**
* @name getEraseSize
* Calculate an erase size given a specific size in bytes.
* Provides a workaround for the bootloader erase bug on ESP8266.
*/
getEraseSize(offset: number, size: number) {
const sectorsPerBlock = 16;
const sectorSize = FLASH_SECTOR_SIZE;
const numSectors = Math.floor((size + sectorSize - 1) / sectorSize);
const startSector = Math.floor(offset / sectorSize);
let headSectors = sectorsPerBlock - (startSector % sectorsPerBlock);
if (numSectors < headSectors) {
headSectors = numSectors;
}
if (numSectors < 2 * headSectors) {
return Math.floor(((numSectors + 1) / 2) * sectorSize);
}
return (numSectors - headSectors) * sectorSize;
}
/**
* @name memBegin (592)
* Start downloading an application image to RAM
*/
async memBegin(
size: number,
blocks: number,
blocksize: number,
offset: number,
) {
return await this.checkCommand(
ESP_MEM_BEGIN,
pack(" {
this.logger.debug(
`Loading stub for ${this.chipName}, revision: ${this.chipRevision}`,
);
const stub = await getStubCode(this.chipFamily, this.chipRevision);
// No stub available for this chip, return ROM loader
if (stub === null) {
this.logger.log(
`Stub flasher is not yet supported on ${this.chipName}, using ROM loader`,
);
return this as unknown as EspStubLoader;
}
// We're transferring over USB, right?
const ramBlock = USB_RAM_BLOCK;
// Upload
this.logger.debug("Uploading stub...");
for (const field of ["text", "data"] as const) {
const fieldData = stub[field];
const offset = stub[`${field}_start` as "text_start" | "data_start"];
const length = fieldData.length;
const blocks = Math.floor((length + ramBlock - 1) / ramBlock);
await this.memBegin(length, blocks, ramBlock, offset);
for (const seq of Array(blocks).keys()) {
const fromOffs = seq * ramBlock;
let toOffs = fromOffs + ramBlock;
if (toOffs > length) {
toOffs = length;
}
await this.memBlock(fieldData.slice(fromOffs, toOffs), seq);
}
}
await this.memFinish(stub.entry);
const p = await this.readPacket(500);
const pChar = String.fromCharCode(...p);
if (pChar != "OHAI") {
throw new Error("Failed to start stub. Unexpected response: " + pChar);
}
this.logger.debug("Stub is now running...");
const espStubLoader = new EspStubLoader(this.port, this.logger, this);
// Try to autodetect the flash size.
if (!skipFlashDetection) {
await espStubLoader.detectFlashSize();
}
return espStubLoader;
}
__writer?: WritableStreamDefaultWriter;
__writeChain: Promise = Promise.resolve();
private get _reader(): ReadableStreamDefaultReader | undefined {
return this._parent ? this._parent._reader : this.__reader;
}
private set _reader(
value: ReadableStreamDefaultReader | undefined,
) {
if (this._parent) {
this._parent._reader = value;
} else {
this.__reader = value;
}
}
private get _writer(): WritableStreamDefaultWriter | undefined {
return this._parent ? this._parent._writer : this.__writer;
}
private set _writer(
value: WritableStreamDefaultWriter | undefined,
) {
if (this._parent) {
this._parent._writer = value;
} else {
this.__writer = value;
}
}
private get _writeChain(): Promise {
return this._parent ? this._parent._writeChain : this.__writeChain;
}
private set _writeChain(value: Promise) {
if (this._parent) {
this._parent._writeChain = value;
} else {
this.__writeChain = value;
}
}
async writeToStream(data: number[]) {
if (!this.port.writable) {
this.logger.debug("Port writable stream not available, skipping write");
return;
}
if (this._isReconfiguring) {
throw new Error("Cannot write during port reconfiguration");
}
// Queue writes to prevent lock contention (critical for CP2102 on Windows)
this._writeChain = this._writeChain
.then(
async () => {
// Check if port is still writable before attempting write
if (!this.port.writable) {
throw new Error("Port became unavailable during write");
}
// Get or create persistent writer
if (!this._writer) {
try {
this._writer = this.port.writable.getWriter();
} catch (err) {
this.logger.error(`Failed to get writer: ${err}`);
throw err;
}
}
// Perform the write
await this._writer.write(new Uint8Array(data));
},
async () => {
// Previous write failed, but still attempt this write
this.logger.debug(
"Previous write failed, attempting recovery for current write",
);
if (!this.port.writable) {
throw new Error("Port became unavailable during write");
}
// Writer was likely cleaned up by previous error, create new one
if (!this._writer) {
try {
this._writer = this.port.writable.getWriter();
} catch (err) {
this.logger.debug(`Failed to get writer in recovery: ${err}`);
throw new Error("Cannot acquire writer lock");
}
}
await this._writer.write(new Uint8Array(data));
},
)
.catch((err) => {
this.logger.error(`Write error: ${err}`);
// Ensure writer is cleaned up on any error
if (this._writer) {
try {
this._writer.releaseLock();
} catch {
// Ignore release errors
}
this._writer = undefined;
}
// Re-throw to propagate error
throw err;
});
// Always await the write chain to ensure errors are caught
await this._writeChain;
}
async disconnect() {
if (this._parent) {
await this._parent.disconnect();
return;
}
if (!this.port.writable) {
// this.logger.debug("Port already closed, skipping disconnect");
return;
}
// Wait for pending writes to complete
try {
await this._writeChain;
} catch {
// this.logger.debug("Pending write error during disconnect");
}
// Release persistent writer before closing
if (this._writer) {
try {
await this._writer.close();
this._writer.releaseLock();
} catch {
// this.logger.debug("Writer close/release error");
}
this._writer = undefined;
} else {
// No persistent writer exists, close stream directly
// This path is taken when no writes have been queued
try {
const writer = this.port.writable.getWriter();
await writer.close();
writer.releaseLock();
} catch {
// this.logger.debug("Direct writer close error");
}
}
await new Promise((resolve) => {
if (!this._reader) {
resolve(undefined);
return;
}
// Set a timeout to prevent hanging (important for node-usb)
const timeout = setTimeout(() => {
this.logger.debug("Disconnect timeout - forcing resolution");
resolve(undefined);
}, 1000);
this.addEventListener(
"disconnect",
() => {
clearTimeout(timeout);
resolve(undefined);
},
{ once: true },
);
// Only cancel if reader is still active
try {
this._reader.cancel();
} catch {
// Reader already released, resolve immediately
clearTimeout(timeout);
resolve(undefined);
}
});
this.connected = false;
// Close the port (important for node-usb adapter)
try {
await this.port.close();
this.logger.debug("Port closed successfully");
} catch (err) {
this.logger.debug(`Port close error: ${err}`);
}
}
/**
* @name releaseReaderWriter
* Release reader and writer locks without closing the port
* Used when switching to console mode
*/
async releaseReaderWriter() {
if (this._parent) {
await this._parent.releaseReaderWriter();
return;
}
// Wait for pending writes to complete
try {
await this._writeChain;
} catch {
// this.logger.debug("Pending write error during release");
}
// Release writer
if (this._writer) {
try {
this._writer.releaseLock();
this.logger.debug("Writer released");
} catch (err) {
this.logger.debug(`Writer release error: ${err}`);
}
this._writer = undefined;
}
// Cancel reader - let readLoop's finally block handle releaseLock()
if (this._reader) {
try {
// Suppress disconnect event during console mode switching
this._suppressDisconnect = true;
// Cancel will cause readLoop to exit and call releaseLock() in its finally block
await this._reader.cancel();
this.logger.debug("Reader cancelled - waiting for readLoop to finish");
// CRITICAL: Wait a bit for readLoop's finally block to complete
// The finally block needs time to call releaseLock() and set _reader = undefined
// This is much faster than waiting for browser to unlock (just waiting for JS execution)
await sleep(50);
this.logger.debug("ReadLoop cleanup should be complete");
} catch (err) {
this.logger.debug(`Reader cancel error: ${err}`);
}
// Don't call releaseLock() or set _reader to undefined here
// Let readLoop's finally block handle it to avoid race conditions
}
}
/**
* @name resetToFirmware
* Public method to reset device from bootloader to firmware for console mode
* Automatically detects USB-JTAG/Serial and USB-OTG devices and performs appropriate reset
* @returns true if reset was performed, false if not needed
*/
public async resetToFirmware(): Promise {
return await this._resetToFirmwareIfNeeded();
}
/**
* @name detectUsbConnectionType
* Detect if device is using USB-JTAG/Serial or USB-OTG (not external serial chip)
* Uses USB PID (Product ID) for reliable detection - does NOT require chipFamily
* @returns true if USB-JTAG or USB-OTG, false if external serial chip
*/
public async detectUsbConnectionType(): Promise {
// Use PID-based detection
const portInfo = this.port.getInfo();
const pid = portInfo.usbProductId;
const vid = portInfo.usbVendorId;
// Check if this is an Espressif device
const isEspressif = vid === 0x303a;
if (!isEspressif) {
this.logger.debug("Not Espressif VID - external serial chip");
return false;
}
// ESP32-S2/S3/C3/C5/C6/C61/H2/P4 USB-JTAG/OTG PIDs
// According to official Espressif documentation:
// https://docs.espressif.com/projects/esp-iot-solution/en/latest/usb/usb_overview/usb_device_const_COM.html
// 0x0002 = ESP32-S2 USB-OTG, 0x0012 = ESP32-P4 USB-Serial-JTAG
// 0x1001 = ESP32-S3, C3, C5, C6, C61, H2 USB-Serial-JTAG
const usbJtagPids = [0x0002, 0x0012, 0x1001];
const isUsbJtag = usbJtagPids.includes(pid || 0);
this.logger.debug(
`USB-JTAG/OTG detection: ${isUsbJtag ? "YES" : "NO"} (PID=0x${pid?.toString(16)})`,
);
return isUsbJtag;
}
public async getUsbMode(): Promise<{
mode: "uart" | "usb-jtag-serial" | "usb-otg";
uartNo: number;
}> {
const family = this._parent ? this._parent.chipFamily : this.chipFamily;
const revision = this._parent
? (this._parent.chipRevision ?? 0)
: (this.chipRevision ?? 0);
let bufNoAddr: number | null = null;
let jtagSerialVal: number | null = null;
let otgVal: number | null = null;
switch (family) {
case CHIP_FAMILY_ESP32S2:
bufNoAddr = ESP32S2_UARTDEV_BUF_NO;
otgVal = ESP32S2_UARTDEV_BUF_NO_USB_OTG;
break;
case CHIP_FAMILY_ESP32S3:
bufNoAddr = ESP32S3_UARTDEV_BUF_NO;
jtagSerialVal = ESP32S3_UARTDEV_BUF_NO_USB_JTAG_SERIAL;
otgVal = ESP32S3_UARTDEV_BUF_NO_USB_OTG;
break;
case CHIP_FAMILY_ESP32C3: {
const bssAddr = revision < 101 ? 0x3fcdf064 : 0x3fcdf060;
bufNoAddr = bssAddr + ESP32C3_BUF_UART_NO_OFFSET;
jtagSerialVal = ESP32C3_UARTDEV_BUF_NO_USB_JTAG_SERIAL;
break;
}
case CHIP_FAMILY_ESP32C5:
bufNoAddr = ESP32C5_UARTDEV_BUF_NO;
jtagSerialVal = ESP32C5_UARTDEV_BUF_NO_USB_JTAG_SERIAL;
break;
case CHIP_FAMILY_ESP32C6:
bufNoAddr = ESP32C6_UARTDEV_BUF_NO;
jtagSerialVal = ESP32C6_UARTDEV_BUF_NO_USB_JTAG_SERIAL;
break;
case CHIP_FAMILY_ESP32C61:
bufNoAddr =
revision <= 200
? ESP32C61_UARTDEV_BUF_NO_REV_LE2
: ESP32C61_UARTDEV_BUF_NO_REV_GT2;
jtagSerialVal =
revision <= 200
? ESP32C61_UARTDEV_BUF_NO_USB_JTAG_SERIAL_REV_LE2
: ESP32C61_UARTDEV_BUF_NO_USB_JTAG_SERIAL_REV_GT2;
break;
case CHIP_FAMILY_ESP32H2:
bufNoAddr = ESP32H2_UARTDEV_BUF_NO;
jtagSerialVal = ESP32H2_UARTDEV_BUF_NO_USB_JTAG_SERIAL;
break;
case CHIP_FAMILY_ESP32H4:
bufNoAddr = ESP32H4_UARTDEV_BUF_NO;
jtagSerialVal = ESP32H4_UARTDEV_BUF_NO_USB_JTAG_SERIAL;
break;
case CHIP_FAMILY_ESP32P4:
bufNoAddr =
revision < 300
? ESP32P4_UARTDEV_BUF_NO_REV0
: ESP32P4_UARTDEV_BUF_NO_REV300;
jtagSerialVal = ESP32P4_UARTDEV_BUF_NO_USB_JTAG_SERIAL;
otgVal = ESP32P4_UARTDEV_BUF_NO_USB_OTG;
break;
}
if (bufNoAddr === null) {
return { mode: "uart", uartNo: 0 };
}
const uartNo = (await this.readRegister(bufNoAddr)) & 0xff;
if (otgVal !== null && uartNo === otgVal) {
this.logger.debug(`USB mode: USB-OTG (uartNo=${uartNo})`);
return { mode: "usb-otg", uartNo };
}
if (jtagSerialVal !== null && uartNo === jtagSerialVal) {
this.logger.debug(`USB mode: USB-JTAG/Serial (uartNo=${uartNo})`);
return { mode: "usb-jtag-serial", uartNo };
}
this.logger.debug(`USB mode: UART (uartNo=${uartNo})`);
return { mode: "uart", uartNo };
}
/**
* Check if the current chip supports USB-JTAG or USB-OTG
* @returns true if chip has native USB support (JTAG or OTG)
*/
public supportsNativeUsb(): boolean {
const family = this._parent ? this._parent.chipFamily : this.chipFamily;
// Chips with USB-JTAG/Serial or USB-OTG support
const usbChips = [
CHIP_FAMILY_ESP32S2, // USB-OTG
CHIP_FAMILY_ESP32S3, // USB-OTG + USB-JTAG/Serial
CHIP_FAMILY_ESP32C3, // USB-JTAG/Serial
CHIP_FAMILY_ESP32C5, // USB-JTAG/Serial
CHIP_FAMILY_ESP32C6, // USB-JTAG/Serial
CHIP_FAMILY_ESP32C61, // USB-JTAG/Serial
CHIP_FAMILY_ESP32H2, // USB-JTAG/Serial
CHIP_FAMILY_ESP32H4, // USB-JTAG/Serial
CHIP_FAMILY_ESP32P4, // USB-OTG + USB-JTAG/Serial
];
return usbChips.includes(family);
}
/**
* @name _ensureStreamsReady
* After a hardware reset, ensure port streams are available.
* On WebUSB, recreates streams since they break after reset.
* On Web Serial, waits for streams to become available.
*/
private async _ensureStreamsReady(): Promise {
if (this.isWebUSB()) {
try {
await (
this.port as unknown as { recreateStreams(): Promise }
).recreateStreams();
this.logger.debug("WebUSB streams recreated");
let retries = 30;
while (retries > 0 && !this.port.readable) {
await sleep(100);
retries--;
}
if (!this.port.readable) {
throw new Error(
"Readable stream not available after recreating streams",
);
}
this.logger.debug("WebUSB streams are ready");
} catch (err) {
this.logger.error(`Failed to recreate WebUSB streams: ${err}`);
this._consoleMode = false;
throw err;
}
} else {
let retries = 20;
while (retries > 0 && !this.port.readable) {
await sleep(100);
retries--;
}
if (!this.port.readable) {
this._consoleMode = false;
throw new Error("Readable stream not available after reset");
}
this.logger.debug("Port streams are ready");
}
}
/**
* @name enterConsoleMode
* Prepare device for console mode by resetting to firmware
* Handles both USB-JTAG/OTG devices (closes port) and external serial chips (keeps port open)
* @returns true if port was closed (USB-JTAG), false if port stays open (serial chip)
*/
public async enterConsoleMode(): Promise {
// Check if port is open - if not, we need a new port selection
if (!this.port.writable || !this.port.readable) {
this.logger.debug("Port is not open - port selection needed");
// Return true to signal that port selection is needed
// The caller should handle port selection and try again
return true;
}
// Re-detect USB connection type to ensure we have a definitive value
let isUsbJtag: boolean;
try {
isUsbJtag = await this.detectUsbConnectionType();
this.logger.debug(
`USB connection type detected: ${isUsbJtag ? "USB-JTAG/OTG" : "External Serial Chip"}`,
);
// CRITICAL: Set the cached value so _resetToFirmwareIfNeeded() can use it
this._isUsbJtagOrOtg = isUsbJtag;
} catch (err) {
// If detection fails, fall back to cached value or fail-fast
if (this.isUsbJtagOrOtg === undefined) {
throw new Error(
`Cannot enter console mode: USB connection type unknown and detection failed: ${err}`,
);
}
this.logger.debug(
`USB detection failed, using cached value: ${this.isUsbJtagOrOtg}`,
);
isUsbJtag = this.isUsbJtagOrOtg;
}
// Set console mode flag BEFORE any operations
this._consoleMode = true;
if (isUsbJtag) {
// USB-JTAG/OTG devices: Use reset which may close port
const wasReset = await this._resetToFirmwareIfNeeded();
if (wasReset) {
return true; // port closed, caller must reopen
}
// Port stayed open (e.g. C3/C5/C6/H2 classic reset)
await this._ensureStreamsReady();
return false;
} else {
// External serial chip devices: Release locks and do simple reset
try {
await this.releaseReaderWriter();
await sleep(100);
} catch (err) {
this.logger.debug(`Failed to release locks: ${err}`);
}
try {
await this.hardResetToFirmware();
this.logger.debug("Device reset to firmware mode");
} catch (err) {
this.logger.debug(`Could not reset device: ${err}`);
}
await this._ensureStreamsReady();
return false;
}
}
/**
* @name _clearForceDownloadBootIfNeeded
* Read and clear the force download boot flag if it is set
* This should ONLY be called when on ROM (not stub) and before WDT reset
* Clearing it on every connect causes issues with flash operations
* Returns true if the flag was cleared, false if it was already clear
*/
private async _clearForceDownloadBootIfNeeded(): Promise {
try {
let regAddr: number;
let mask: number;
let chipName: string;
// Get register address and mask for this chip
if (this.chipFamily === CHIP_FAMILY_ESP32S2) {
regAddr = ESP32S2_RTC_CNTL_OPTION1_REG;
mask = ESP32S2_RTC_CNTL_FORCE_DOWNLOAD_BOOT_MASK;
chipName = "ESP32-S2";
} else if (this.chipFamily === CHIP_FAMILY_ESP32S3) {
regAddr = ESP32S3_RTC_CNTL_OPTION1_REG;
mask = ESP32S3_RTC_CNTL_FORCE_DOWNLOAD_BOOT_MASK;
chipName = "ESP32-S3";
} else if (this.chipFamily === CHIP_FAMILY_ESP32P4) {
regAddr = ESP32P4_RTC_CNTL_OPTION1_REG;
mask = ESP32P4_RTC_CNTL_FORCE_DOWNLOAD_BOOT_MASK;
chipName = "ESP32-P4";
} else {
// Not a chip that needs this
return false;
}
// Read current register value
const currentValue = await this.readRegister(regAddr);
this.logger.debug(
`${chipName} force download boot register: 0x${currentValue.toString(16)} (mask: 0x${mask.toString(16)})`,
);
// Check if the flag is set
const isFlagSet = (currentValue & mask) !== 0;
if (isFlagSet) {
this.logger.debug(
`${chipName} force download boot flag is SET - clearing it`,
);
// Clear the flag by writing 0 to the masked bits
await this.writeRegister(regAddr, 0, mask, 0);
this.logger.debug(`${chipName} force download boot flag cleared`);
return true;
} else {
this.logger.debug(
`${chipName} force download boot flag is already CLEAR - no action needed`,
);
return false;
}
} catch (err) {
this.logger.debug(`Error checking/clearing force download flag: ${err}`);
return false;
}
}
/**
* @name _resetToFirmwareIfNeeded
* Reset device from bootloader to firmware when switching to console mode
* Detects USB-JTAG/Serial and USB-OTG devices and performs appropriate reset
* @returns true if reconnect was performed, false otherwise
*/
private async _resetToFirmwareIfNeeded(): Promise {
// Detect if we need WDT reset (USB-JTAG/OTG) or classic reset
const isUsbJtagOrOtg = await this.detectUsbConnectionType();
try {
// Check if port is open - if not, assume device is already in firmware mode
if (!this.port.writable || !this.port.readable) {
this.logger.debug(
"Port is not open - assuming device is already in firmware mode",
);
return false;
}
if (isUsbJtagOrOtg) {
// USB-JTAG/OTG: DON'T release reader/writer before WDT reset
// The WDT reset needs active communication to send register write commands
// The port will close automatically after the WDT reset anyway
this.logger.debug(
"USB-JTAG/OTG: Keeping reader/writer active for WDT reset",
);
} else {
// External serial chip: Release reader/writer before classic reset
await this.releaseReaderWriter();
this.logger.debug(
"External serial: Reader/writer released before reset",
);
}
// Use the new resetToFirmwareMode method which handles all the logic
const portWillChange = await this.resetToFirmwareMode(true);
if (portWillChange) {
this.logger.debug(
`${this.chipName}: Port will change after WDT reset - user must reselect port`,
);
// Dispatch event to signal port change
this.dispatchEvent(
new CustomEvent("usb-otg-port-change", {
detail: {
chipName: this.chipName,
message: `${this.chipName} USB port changed after reset. Please select the new port.`,
reason: "wdt-reset-to-firmware",
},
}),
);
return true;
} else {
// Port stays the same - release reader/writer now if not already done
if (isUsbJtagOrOtg) {
await this.releaseReaderWriter();
this.logger.debug("Reader/writer released after reset");
}
return false;
}
} catch (err) {
this.logger.error(`Reset to firmware mode failed: ${err}`);
// For USB-JTAG/OTG, the port is likely dead after a failed reset
// For external serial, the port is usually still fine
if (isUsbJtagOrOtg) {
this.logger.debug(
"Forcing port reselection due to USB-JTAG/OTG reset failure",
);
return true;
}
this.logger.debug(
"External serial reset failed, but port should still be usable",
);
return false;
}
}
/**
* @name reconnectAndResume
* Reconnect the serial port to flush browser buffers and reload stub
*/
async reconnect(): Promise {
if (this._parent) {
await this._parent.reconnect();
return;
}
try {
this.logger.log("Reconnecting serial port...");
const savedBaudRate = this.currentBaudRate;
this.connected = false;
this.__inputBuffer = [];
this.__inputBufferReadIndex = 0;
// Wait for pending writes to complete
try {
await this._writeChain;
} catch (err) {
this.logger.debug(`Pending write error during reconnect: ${err}`);
}
// Block new writes during port close/open
this._isReconfiguring = true;
// Release persistent writer
if (this._writer) {
try {
this._writer.releaseLock();
} catch (err) {
this.logger.debug(`Writer release error during reconnect: ${err}`);
}
this._writer = undefined;
}
// Cancel reader
if (this._reader) {
try {
await this._reader.cancel();
} catch (err) {
this.logger.debug(`Reader cancel error: ${err}`);
}
this._reader = undefined;
}
// Close port
try {
await this.port.close();
this.logger.debug("Port closed");
} catch (err) {
this.logger.debug(`Port close error: ${err}`);
}
// Open the port
this.logger.debug("Opening port...");
try {
await this.port.open({ baudRate: ESP_ROM_BAUD });
this.connected = true;
this.currentBaudRate = ESP_ROM_BAUD;
} catch (err) {
throw new Error(`Failed to open port: ${err}`);
}
// Verify port streams are available
if (!this.port.readable || !this.port.writable) {
throw new Error(
`Port streams not available after open (readable: ${!!this.port.readable}, writable: ${!!this.port.writable})`,
);
}
// Port is now open and ready - allow writes for initialization
this._isReconfiguring = false;
// Save chip info and flash size (no need to detect again)
const savedChipFamily = this.chipFamily;
const savedChipName = this.chipName;
const savedChipRevision = this.chipRevision;
const savedChipVariant = this.chipVariant;
const savedFlashSize = this.flashSize;
// Reinitialize
await this.hardReset(true);
if (!this._parent) {
this.__inputBuffer = [];
this.__inputBufferReadIndex = 0;
this.__totalBytesRead = 0;
this.readLoop();
}
await this.flushSerialBuffers();
await this.sync();
// Restore chip info
this.chipFamily = savedChipFamily;
this.chipName = savedChipName;
this.chipRevision = savedChipRevision;
this.chipVariant = savedChipVariant;
this.flashSize = savedFlashSize;
this.logger.debug(`Reconnect complete (chip: ${this.chipName})`);
// Verify port is ready
if (!this.port.writable || !this.port.readable) {
throw new Error("Port not ready after reconnect");
}
// Power on flash for ESP32-P4 Rev 301 (must be done before loading stub)
if (
this.chipFamily === CHIP_FAMILY_ESP32P4 &&
this.chipRevision === 301
) {
await this.powerOnFlash();
}
// Load stub
const stubLoader = await this.runStub(true);
this.logger.debug("Stub loaded");
// Restore baudrate if it was changed
if (savedBaudRate !== ESP_ROM_BAUD) {
await stubLoader.setBaudrate(savedBaudRate);
// Verify port is still ready after baudrate change
if (!this.port.writable || !this.port.readable) {
throw new Error(
`Port not ready after baudrate change (readable: ${!!this.port.readable}, writable: ${!!this.port.writable})`,
);
}
}
// The stub is now running on the chip
// stubLoader has this instance as _parent, so all operations go through this
// We just need to mark this instance as running stub code
this.IS_STUB = true;
this.logger.debug("Reconnection successful");
} catch (err) {
// Ensure flag is reset on error
this._isReconfiguring = false;
throw err;
}
}
/**
* @name reconnectToBootloader
* Close and reopen the port, then reset ESP to bootloader mode
* This is needed after Improv or other operations that leave ESP in firmware mode
*/
async reconnectToBootloader(): Promise {
if (this._parent) {
await this._parent.reconnectToBootloader();
return;
}
try {
this.logger.log("Reconnecting to bootloader mode...");
// Clear console mode flag when reconnecting to bootloader
this._consoleMode = false;
this.connected = false;
this.__inputBuffer = [];
this.__inputBufferReadIndex = 0;
// Wait for pending writes to complete
try {
await this._writeChain;
} catch (err) {
this.logger.debug(`Pending write error during reconnect: ${err}`);
}
// Block new writes during port close/open
this._isReconfiguring = true;
// Release persistent writer
if (this._writer) {
try {
this._writer.releaseLock();
} catch (err) {
this.logger.debug(`Writer release error during reconnect: ${err}`);
}
this._writer = undefined;
}
// Cancel reader
if (this._reader) {
try {
await this._reader.cancel();
} catch (err) {
this.logger.debug(`Reader cancel error: ${err}`);
}
this._reader = undefined;
}
// Close port
try {
await this.port.close();
this.logger.debug("Port closed");
} catch (err) {
this.logger.debug(`Port close error: ${err}`);
}
// Open the port
this.logger.debug("Opening port...");
try {
await this.port.open({ baudRate: ESP_ROM_BAUD });
this.connected = true;
this.currentBaudRate = ESP_ROM_BAUD;
} catch (err) {
throw new Error(`Failed to open port: ${err}`);
}
// Verify port streams are available
if (!this.port.readable || !this.port.writable) {
throw new Error(
`Port streams not available after open (readable: ${!!this.port.readable}, writable: ${!!this.port.writable})`,
);
}
// Port is now open and ready - allow writes for initialization
this._isReconfiguring = false;
// Reset chip info and stub state
this.__chipFamily = undefined;
this.chipName = "Unknown Chip";
this.chipRevision = null;
this.chipVariant = null;
this.IS_STUB = false;
// Start read loop
if (!this._parent) {
this.__inputBuffer = [];
this.__inputBufferReadIndex = 0;
this.__totalBytesRead = 0;
this.readLoop();
}
// Wait for readLoop to start
await sleep(100);
// Reset to bootloader mode using multiple strategies
await this.connectWithResetStrategies();
// Detect chip type
await this.detectChip();
this.logger.debug(`Reconnected to bootloader: ${this.chipName}`);
} catch (err) {
// Ensure flag is reset on error
this._isReconfiguring = false;
throw err;
}
}
/**
* @name exitConsoleMode
* Exit console mode and return to bootloader
* For ESP32-S2, uses reconnectToBootloader which will trigger port change
* @returns true if manual reconnection is needed (ESP32-S2), false otherwise
*/
async exitConsoleMode(): Promise {
if (this._parent) {
return await this._parent.exitConsoleMode();
}
// Clear console mode flag
this._consoleMode = false;
// Check if this is a USB-OTG device (ESP32-S2 or ESP32-P4)
const isUsbOtgChip =
this.chipFamily === CHIP_FAMILY_ESP32S2 ||
this.chipFamily === CHIP_FAMILY_ESP32P4;
// For USB-OTG chips: if _isUsbJtagOrOtg is undefined, try to detect it
// If detection fails or is undefined, assume USB-JTAG/OTG (conservative/safe path)
let isUsbJtagOrOtg = this._isUsbJtagOrOtg;
if (isUsbOtgChip && isUsbJtagOrOtg === undefined) {
try {
isUsbJtagOrOtg = await this.detectUsbConnectionType();
} catch (err) {
this.logger.debug(
`USB detection failed, assuming USB-JTAG/OTG for ${this.chipName}: ${err}`,
);
isUsbJtagOrOtg = true; // Conservative fallback
}
}
if (isUsbOtgChip && isUsbJtagOrOtg) {
// USB-OTG devices: Need to reset to bootloader, which will cause port change
this.logger.debug(`${this.chipName} USB: Resetting to bootloader mode`);
// Perform hardware reset to bootloader (GPIO0=LOW)
// This will cause the port to change from CDC (firmware) to JTAG (bootloader)
try {
await this.hardResetClassic();
this.logger.debug("Reset to bootloader initiated");
} catch (err) {
this.logger.debug(`Reset error: ${err}`);
}
// Wait for reset to complete and port to change
await sleep(500);
this.logger.debug(
`${this.chipName}: Port changed. Please select the bootloader port.`,
);
// Dispatch event to signal port change
this.dispatchEvent(
new CustomEvent("usb-otg-port-change", {
detail: {
chipName: this.chipName,
message: `${this.chipName}: Port changed. Please select the bootloader port.`,
reason: "exit-console-to-bootloader",
},
}),
);
// Port will change, so return true to indicate manual reconnection needed
return true;
}
// For other devices, use standard reconnectToBootloader
await this.reconnectToBootloader();
return false; // No manual reconnection needed
}
/**
* @name isConsoleResetSupported
* Check if console reset is supported for this device
* ESP32-S2 USB-JTAG/CDC does not support reset in console mode
* because any reset causes USB port to be lost (hardware limitation)
*/
isConsoleResetSupported(): boolean {
if (this._parent) {
return this._parent.isConsoleResetSupported();
}
// For ESP32-S2: if _isUsbJtagOrOtg is undefined, assume USB-JTAG/OTG (conservative)
// This means console reset is NOT supported (safer default)
const isS2UsbJtag =
this.chipFamily === CHIP_FAMILY_ESP32S2 &&
(this._isUsbJtagOrOtg === true || this._isUsbJtagOrOtg === undefined);
return !isS2UsbJtag; // Not supported for ESP32-S2 USB-JTAG/CDC
}
/**
* @name resetInConsoleMode
* Reset device while in console mode (firmware mode)
*
* NOTE: For ESP32-S2 USB-JTAG/CDC, ANY reset (hardware or software) causes
* the USB port to be lost because the device switches USB modes during reset.
* This is a hardware limitation - use isConsoleResetSupported() to check first.
*/
async resetInConsoleMode(): Promise {
if (this._parent) {
return await this._parent.resetInConsoleMode();
}
if (!this.isConsoleResetSupported()) {
this.logger.debug(
"Simple Console reset not supported for ESP32-S2 USB-JTAG/CDC - using exitConsoleMode to enter bootloader",
);
await this.exitConsoleMode();
this.logger.debug(
"S2 now in bootloader mode - caller must do syncAndWdtReset on new port, then reconnect console",
);
return;
}
// For other devices: Use standard firmware reset
try {
this.logger.debug("Resetting device in console mode");
await this.hardResetToFirmware();
this.logger.debug("Device reset complete");
} catch (err) {
this.logger.error(`Reset failed: ${err}`);
throw err;
}
}
/**
* @name syncAndWdtReset
* Open a new bootloader port, sync with ROM (no stub, no reset strategies), and fire WDT reset.
* This is used for ESP32-S2 USB-OTG devices which require WDT reset to switch modes.
* After WDT reset the port will re-enumerate again.
* The user must select the new port after this method is called.
* @param newPort - The bootloader port selected by the user
*/
async syncAndWdtReset(newPort: SerialPort): Promise {
if (this._parent) {
await this._parent.syncAndWdtReset(newPort);
return;
}
this.port = newPort;
this.connected = false;
this.IS_STUB = false;
this.__inputBuffer = [];
this.__inputBufferReadIndex = 0;
this.__totalBytesRead = 0;
this.logger.debug("Opening bootloader port at 115200...");
await this.port.open({ baudRate: ESP_ROM_BAUD });
this.connected = true;
this.currentBaudRate = ESP_ROM_BAUD;
// Start read loop
this.readLoop();
await sleep(100);
// Sync with ROM only - no reset strategies, device is already in bootloader
this.logger.debug("Syncing with bootloader ROM...");
await this.sync();
this.logger.debug("Bootloader sync OK, no stub");
// Fire WDT reset → device boots into firmware
this.logger.debug("Firing WDT reset...");
await this.rtcWdtResetChipSpecific();
this.logger.debug("WDT reset fired - device will boot to firmware");
}
/**
* @name drainInputBuffer
* Actively drain the input buffer by reading data for a specified time.
* Simple approach for some drivers (especially CP210x on Windows) that have
* issues with buffer flushing.
*
* Based on esptool.py fix: https://github.com/espressif/esptool/commit/5338ea054e5099ac7be235c54034802ac8a43162
*
* @param bufferingTime - Time in milliseconds to wait for the buffer to fill
*/
async drainInputBuffer(bufferingTime = 200): Promise {
// Wait for the buffer to fill
await sleep(bufferingTime);
// Unsupported command response is sent 8 times and has
// 14 bytes length including delimiter SLIP_END (0xC0) bytes.
// At least part of it is read as a command response,
// but to be safe, read it all.
const bytesToDrain = 14 * 8;
let drained = 0;
// Drain the buffer by reading available data
const drainStart = Date.now();
const drainTimeout = 100; // Short timeout for draining
while (drained < bytesToDrain && Date.now() - drainStart < drainTimeout) {
if (this._inputBufferAvailable > 0) {
const byte = this._readByte();
if (byte !== undefined) {
drained++;
}
} else {
// Small sleep to avoid busy waiting
await sleep(1);
}
}
if (drained > 0) {
this.logger.debug(`Drained ${drained} bytes from input buffer`);
}
// Final clear of application buffer
if (!this._parent) {
this.__inputBuffer = [];
this.__inputBufferReadIndex = 0;
}
}
/**
* @name flushSerialBuffers
* Flush any pending data in the TX and RX serial port buffers
* This clears both the application RX buffer and waits for hardware buffers to drain
*/
async flushSerialBuffers(): Promise {
// Clear application buffer
if (!this._parent) {
this.__inputBuffer = [];
this.__inputBufferReadIndex = 0;
}
// Wait for any pending data
await sleep(SYNC_TIMEOUT);
// Final clear
if (!this._parent) {
this.__inputBuffer = [];
this.__inputBufferReadIndex = 0;
}
this.logger.debug("Serial buffers flushed");
}
/**
* @name readFlash
* Read flash memory from the chip (only works with stub loader)
* @param addr - Address to read from
* @param size - Number of bytes to read
* @param onPacketReceived - Optional callback function called when packet is received
* @param options - Optional parameters for advanced control
* - chunkSize: Amount of data to request from ESP in one command (bytes)
* - blockSize: Size of each data block sent by ESP (bytes)
* - maxInFlight: Maximum unacknowledged bytes (bytes)
* @returns Uint8Array containing the flash data
*/
async readFlash(
addr: number,
size: number,
onPacketReceived?: (
packet: Uint8Array,
progress: number,
totalSize: number,
) => void,
options?: {
chunkSize?: number;
blockSize?: number;
maxInFlight?: number;
},
): Promise {
if (!this.IS_STUB) {
throw new Error(
"Reading flash is only supported in stub mode. Please run runStub() first.",
);
}
// Flush serial buffers before flash read operation
await this.flushSerialBuffers();
this.logger.log(
`Reading ${size} bytes from flash at address 0x${addr.toString(16)}...`,
);
// Initialize adaptive speed multipliers for WebUSB devices
if (this.isWebUSB()) {
if (this._isCDCDevice) {
// CDC devices (CH343): Start with maximum, adaptive adjustment enabled
this._adaptiveBlockMultiplier = 8; // blockSize = 248 bytes
this._adaptiveMaxInFlightMultiplier = 8; // maxInFlight = 248 bytes
this._consecutiveSuccessfulChunks = 0;
this.logger.debug(
`CDC device - Initialized: blockMultiplier=${this._adaptiveBlockMultiplier}, maxInFlightMultiplier=${this._adaptiveMaxInFlightMultiplier}`,
);
} else {
// Non-CDC devices (CH340, CP2102): Fixed values, no adaptive adjustment
this._adaptiveBlockMultiplier = 1; // blockSize = 31 bytes (fixed)
this._adaptiveMaxInFlightMultiplier = 1; // maxInFlight = 31 bytes (fixed)
this._consecutiveSuccessfulChunks = 0;
this.logger.debug(
`Non-CDC device - Fixed values: blockSize=31, maxInFlight=31`,
);
}
}
// Chunk size: Amount of data to request from ESP in one command
// For WebUSB (Android), use smaller chunks to avoid timeouts and buffer issues
// For Web Serial (Desktop), use larger chunks for better performance
let CHUNK_SIZE: number;
if (options?.chunkSize !== undefined) {
// Use user-provided chunkSize if in advanced mode
CHUNK_SIZE = options.chunkSize;
this.logger.log(
`Using custom chunk size: 0x${CHUNK_SIZE.toString(16)} bytes`,
);
} else if (this.isWebUSB()) {
// WebUSB: Use smaller chunks to avoid SLIP timeout issues
CHUNK_SIZE = 0x4 * 0x1000; // 4KB = 16384 bytes
} else {
// Web Serial: Use larger chunks for better performance
CHUNK_SIZE = 0x40 * 0x1000;
}
let allData = new Uint8Array(0);
let currentAddr = addr;
let remainingSize = size;
while (remainingSize > 0) {
const chunkSize = Math.min(CHUNK_SIZE, remainingSize);
let chunkSuccess = false;
let retryCount = 0;
const MAX_RETRIES = 5;
let deepRecoveryAttempted = false;
// Retry loop for this chunk
while (!chunkSuccess && retryCount <= MAX_RETRIES) {
let resp = new Uint8Array(0);
let lastAckedLength = 0; // Track last acknowledged length
try {
// Only log on first attempt or retries
if (retryCount === 0) {
this.logger.debug(
`Reading chunk at 0x${currentAddr.toString(16)}, size: 0x${chunkSize.toString(16)}`,
);
}
let blockSize: number;
let maxInFlight: number;
if (
options?.blockSize !== undefined &&
options?.maxInFlight !== undefined
) {
// Use user-provided values if in advanced mode
blockSize = options.blockSize;
maxInFlight = options.maxInFlight;
if (retryCount === 0) {
this.logger.debug(
`Using custom parameters: blockSize=${blockSize}, maxInFlight=${maxInFlight}`,
);
}
} else if (this.isWebUSB()) {
// WebUSB (Android): All devices use adaptive speed
// All have maxTransferSize=64, baseBlockSize=31
const maxTransferSize =
(this.port as WebUSBSerialPort).maxTransferSize || 64;
const baseBlockSize = Math.floor((maxTransferSize - 2) / 2); // 31 bytes
// Use current adaptive multipliers (initialized at start of readFlash)
blockSize = baseBlockSize * this._adaptiveBlockMultiplier;
maxInFlight = baseBlockSize * this._adaptiveMaxInFlightMultiplier;
} else {
// Web Serial (Desktop): Use multiples of 63 for consistency
const base = 63;
blockSize = base * 65; // 63 * 65 = 4095 (close to 0x1000)
maxInFlight = base * 130; // 63 * 130 = 8190 (close to blockSize * 2)
}
const pkt = pack(
"= chunkSize) {
break;
}
}
throw err;
}
if (packet && packet.length > 0) {
const packetData = new Uint8Array(packet);
// Append to response
const newResp = new Uint8Array(resp.length + packetData.length);
newResp.set(resp);
newResp.set(packetData, resp.length);
resp = newResp;
// Send acknowledgment when we've received maxInFlight bytes
// The stub sends packets until (num_sent - num_acked) >= max_in_flight
// We MUST wait for all packets before sending ACK
const shouldAck =
resp.length >= chunkSize || // End of chunk
resp.length >= lastAckedLength + maxInFlight; // Received all packets
if (shouldAck) {
const ackData = pack("= 2) {
const maxTransferSize =
(this.port as WebUSBSerialPort).maxTransferSize || 64;
const baseBlockSize = Math.floor((maxTransferSize - 2) / 2); // 31 bytes
// Maximum: blockSize=248 (8 * 31), maxInFlight=248 (8 * 31)
const MAX_BLOCK_MULTIPLIER = 8; // 248 bytes - tested stable
const MAX_INFLIGHT_MULTIPLIER = 8; // 248 bytes - tested stable
let adjusted = false;
// Increase blockSize first (up to 248), then maxInFlight
if (this._adaptiveBlockMultiplier < MAX_BLOCK_MULTIPLIER) {
this._adaptiveBlockMultiplier = Math.min(
this._adaptiveBlockMultiplier * 2,
MAX_BLOCK_MULTIPLIER,
);
adjusted = true;
}
// Once blockSize is at maximum, increase maxInFlight
else if (
this._adaptiveMaxInFlightMultiplier < MAX_INFLIGHT_MULTIPLIER
) {
this._adaptiveMaxInFlightMultiplier = Math.min(
this._adaptiveMaxInFlightMultiplier * 2,
MAX_INFLIGHT_MULTIPLIER,
);
adjusted = true;
}
if (adjusted) {
const newBlockSize =
baseBlockSize * this._adaptiveBlockMultiplier;
const newMaxInFlight =
baseBlockSize * this._adaptiveMaxInFlightMultiplier;
this.logger.debug(
`Speed increased: blockSize=${newBlockSize}, maxInFlight=${newMaxInFlight}`,
);
this._lastAdaptiveAdjustment = Date.now();
}
// Reset counter
this._consecutiveSuccessfulChunks = 0;
}
}
} catch (err) {
retryCount++;
// ADAPTIVE SPEED ADJUSTMENT: Only for CDC devices
// Non-CDC devices stay at fixed values
if (this.isWebUSB() && this._isCDCDevice && retryCount === 1) {
// Only reduce if we're above minimum
if (
this._adaptiveBlockMultiplier > 1 ||
this._adaptiveMaxInFlightMultiplier > 1
) {
// Reduce to minimum on error
this._adaptiveBlockMultiplier = 1; // 31 bytes (for CH343)
this._adaptiveMaxInFlightMultiplier = 1; // 31 bytes
this._consecutiveSuccessfulChunks = 0; // Reset success counter
const maxTransferSize =
(this.port as WebUSBSerialPort).maxTransferSize || 64;
const baseBlockSize = Math.floor((maxTransferSize - 2) / 2);
const newBlockSize =
baseBlockSize * this._adaptiveBlockMultiplier;
const newMaxInFlight =
baseBlockSize * this._adaptiveMaxInFlightMultiplier;
this.logger.debug(
`Error at higher speed - reduced to minimum: blockSize=${newBlockSize}, maxInFlight=${newMaxInFlight}`,
);
} else {
// Already at minimum and still failing - this is a real error
this.logger.debug(
`Error at minimum speed (blockSize=31, maxInFlight=31) - not a speed issue`,
);
}
}
// Check if it's a timeout error or SLIP error
if (err instanceof SlipReadError) {
if (retryCount <= MAX_RETRIES) {
this.logger.debug(
`Cleared buffer and retrying (attempt ${retryCount}/${MAX_RETRIES})...`,
);
// Continue to retry the same chunk (will send NEW read command)
} else {
// All retries exhausted - attempt recovery by reloading stub
// IMPORTANT: Do NOT close port to keep ESP32 in bootloader mode
if (!deepRecoveryAttempted) {
deepRecoveryAttempted = true;
this.logger.log(
`All retries exhausted at 0x${currentAddr.toString(16)}. Attempting recovery (close and reopen port)...`,
);
try {
// Reconnect will close port, reopen, and reload stub
await this.reconnect();
this.logger.log(
"Deep recovery successful. Resuming read from current position...",
);
// Reset retry counter to give it another chance after recovery
retryCount = 0;
continue;
} catch (recoveryErr) {
throw new Error(
`Failed to read chunk at 0x${currentAddr.toString(16)} after ${MAX_RETRIES} retries and recovery failed: ${recoveryErr}`,
);
}
} else {
// Recovery already attempted, give up
throw new Error(
`Failed to read chunk at 0x${currentAddr.toString(16)} after ${MAX_RETRIES} retries and recovery attempt`,
);
}
}
} else {
// Non-SLIP error, don't retry
throw err;
}
}
}
// Update progress (use empty array since we already appended to allData)
if (onPacketReceived) {
onPacketReceived(new Uint8Array(chunkSize), allData.length, size);
}
currentAddr += chunkSize;
remainingSize -= chunkSize;
this.logger.debug(
`Total progress: 0x${allData.length.toString(16)} from 0x${size.toString(16)} bytes`,
);
}
return allData;
}
}
class EspStubLoader extends ESPLoader {
/*
The Stubloader has commands that run on the uploaded Stub Code in RAM
rather than built in commands.
*/
IS_STUB = true;
/**
* @name memBegin (592)
* Start downloading an application image to RAM
*/
async memBegin(
size: number,
_blocks: number,
_blocksize: number,
offset: number,
): Promise<[number, number[]]> {
const stub = await getStubCode(this.chipFamily, this.chipRevision);
// Stub may be null for chips without stub support
if (stub === null) {
return [0, []];
}
const load_start = offset;
const load_end = offset + size;
this.logger.debug(
`Load range: ${toHex(load_start, 8)}-${toHex(load_end, 8)}`,
);
this.logger.debug(
`Stub data: ${toHex(stub.data_start, 8)}, len: ${stub.data.length}, text: ${toHex(stub.text_start, 8)}, len: ${stub.text.length}`,
);
for (const [start, end] of [
[stub.data_start, stub.data_start + stub.data.length],
[stub.text_start, stub.text_start + stub.text.length],
]) {
if (load_start < end && load_end > start) {
throw new Error(
"Software loader is resident at " +
toHex(start, 8) +
"-" +
toHex(end, 8) +
". " +
"Can't load binary at overlapping address range " +
toHex(load_start, 8) +
"-" +
toHex(load_end, 8) +
". " +
"Try changing the binary loading address.",
);
}
}
return [0, []];
}
/**
* @name eraseFlash
* Erase entire flash chip
*/
async eraseFlash() {
await this.checkCommand(ESP_ERASE_FLASH, [], 0, CHIP_ERASE_TIMEOUT);
}
/**
* @name eraseRegion
* Erase a specific region of flash
*/
async eraseRegion(offset: number, size: number) {
// Validate inputs
if (offset < 0) {
throw new Error(`Invalid offset: ${offset} (must be non-negative)`);
}
if (size < 0) {
throw new Error(`Invalid size: ${size} (must be non-negative)`);
}
// No-op for zero size
if (size === 0) {
this.logger.log("eraseRegion: size is 0, skipping erase");
return;
}
// Check for sector alignment
if (offset % FLASH_SECTOR_SIZE !== 0) {
throw new Error(
`Offset ${offset} (0x${offset.toString(16)}) is not aligned to flash sector size ${FLASH_SECTOR_SIZE} (0x${FLASH_SECTOR_SIZE.toString(16)})`,
);
}
if (size % FLASH_SECTOR_SIZE !== 0) {
throw new Error(
`Size ${size} (0x${size.toString(16)}) is not aligned to flash sector size ${FLASH_SECTOR_SIZE} (0x${FLASH_SECTOR_SIZE.toString(16)})`,
);
}
// Check for reasonable bounds (prevent wrapping in pack)
const maxValue = 0xffffffff; // 32-bit unsigned max
if (offset > maxValue) {
throw new Error(`Offset ${offset} exceeds maximum value ${maxValue}`);
}
if (size > maxValue) {
throw new Error(`Size ${size} exceeds maximum value ${maxValue}`);
}
// Check for wrap-around
if (offset + size > maxValue) {
throw new Error(
`Region end (offset + size = ${offset + size}) exceeds maximum addressable range ${maxValue}`,
);
}
const timeout = timeoutPerMb(ERASE_REGION_TIMEOUT_PER_MB, size);
const buffer = pack("