# SPDX-FileCopyrightText: 2014-2025 Fredrik Ahlberg, Angus Gratton,
# Espressif Systems (Shanghai) CO LTD, other contributors as noted.
#
# SPDX-License-Identifier: GPL-2.0-or-later

import base64
import hashlib
import itertools
import json
import os
import re
import string
import struct
import sys
import time

from .config import load_config_file
from .logger import log
from .reset import (
    DEFAULT_RESET_DELAY,
    ClassicReset,
    CustomReset,
    HardReset,
    UnixTightReset,
    USBJTAGSerialReset,
)
from .util import (
    FatalError,
    NANDEraseFailed,
    NANDProgramFailed,
    NotImplementedInROMError,
    NotSupportedError,
    UnsupportedCommandError,
    byte,
    get_key_from_value,
    hexify,
    mask_to_shift,
    pad_to,
    strip_chip_name,
)

try:
    import serial
except ImportError:
    log.error(
        f"PySerial is not installed for {sys.executable}. "
        "Check the documentation for installation instructions."
    )
    raise

# check 'serial' is 'pyserial' and not 'serial'
# ref. https://github.com/espressif/esptool/issues/269
try:
    if "serialization" in serial.__doc__ and "deserialization" in serial.__doc__:
        raise ImportError(
            "esptool depends on pySerial, but there is a conflict with a currently "
            "installed package named 'serial'.\n"
            "You may work around this by 'pip uninstall serial; pip install pyserial' "
            "but this may break other installed Python software "
            "that depends on 'serial'.\n"
            "There is no good fix for this right now, "
            "apart from configuring virtualenvs. "
            "See https://github.com/espressif/esptool/issues/269#issuecomment-385298196"
            " for discussion of the underlying issue(s)."
        )
except TypeError:
    pass  # __doc__ returns None for pySerial

try:
    import serial.tools.list_ports as list_ports
    from serial.tools.list_ports_common import ListPortInfo  # noqa: F401
except ImportError:
    log.error(
        f"The installed version ({serial.VERSION}) of pySerial appears to be too old "
        f"for esptool (Python interpreter {sys.executable}). "
        "Check the documentation for installation instructions."
    )
    raise
except Exception:
    if sys.platform == "darwin":
        # swallow the exception, this is a known issue in pySerial+macOS Big Sur preview
        # ref https://github.com/espressif/esptool/issues/540
        list_ports = None
    else:
        raise


cfg, _ = load_config_file()
cfg = cfg["esptool"]

# Timeout for most flash operations
DEFAULT_TIMEOUT = cfg.getfloat("timeout", 3)
# Timeout for full chip erase
CHIP_ERASE_TIMEOUT = cfg.getfloat("chip_erase_timeout", 120)
# Longest any command can run
MAX_TIMEOUT = cfg.getfloat("max_timeout", CHIP_ERASE_TIMEOUT * 2)
# Timeout for syncing with bootloader
SYNC_TIMEOUT = cfg.getfloat("sync_timeout", 0.1)
# Timeout (per megabyte) for calculating md5sum
MD5_TIMEOUT_PER_MB = cfg.getfloat("md5_timeout_per_mb", 8)
# Timeout (per megabyte) for erasing a region
ERASE_REGION_TIMEOUT_PER_MB = cfg.getfloat("erase_region_timeout_per_mb", 30)
# Timeout (per megabyte) for erasing and writing data
ERASE_WRITE_TIMEOUT_PER_MB = cfg.getfloat("erase_write_timeout_per_mb", 40)
# Short timeout for MEM_END, as it may never respond
MEM_END_ROM_TIMEOUT = cfg.getfloat("mem_end_rom_timeout", 0.2)
# Timeout for serial port write
DEFAULT_SERIAL_WRITE_TIMEOUT = cfg.getfloat("serial_write_timeout", 10)
# Default number of times to try connection
DEFAULT_CONNECT_ATTEMPTS = cfg.getint("connect_attempts", 7)
# Number of times to try writing a data block
WRITE_BLOCK_ATTEMPTS = cfg.getint("write_block_attempts", 3)
# Number of times to try opening the serial port
DEFAULT_OPEN_PORT_ATTEMPTS = cfg.getint("open_port_attempts", 1)

# Pages per NAND flash block for the supported NAND chip (W25N01GV).
# Sent to the stub as part of the NAND read-flash parameter block.
NAND_PAGES_PER_BLOCK = 64
# Size of one NAND block in bytes (64 pages × 2 KB page = 128 KB).
NAND_BLOCK_SIZE = 0x20000

# Documentation page linked from connection/serial error messages.
TROUBLESHOOTING_GUIDE_URL = (
    "https://docs.espressif.com/projects/esptool/en/latest/troubleshooting.html"
)


def timeout_per_mb(seconds_per_mb, size_bytes):
    """Scales timeouts which are size-specific"""
    result = seconds_per_mb * (size_bytes / 1e6)
    if result < DEFAULT_TIMEOUT:
        return DEFAULT_TIMEOUT
    return result


def check_supported_function(func, check_func):
    """
    Decorator implementation that wraps a check around an ESPLoader
    bootloader function to check if it's supported.

    This is used to capture the multidimensional differences in
    functionality between the ESP8266 & ESP32 (and later chips) ROM loaders, and the
    software stub that runs on these. Not possible to do this cleanly
    via inheritance alone.
    """

    def inner(*args, **kwargs):
        obj = args[0]
        if check_func(obj):
            return func(*args, **kwargs)
        else:
            raise NotImplementedInROMError(obj, func)

    return inner


def stub_function_only(func):
    """Attribute for a function only supported in the software stub loader"""
    return check_supported_function(func, lambda o: o.IS_STUB)


def stub_and_esp32_function_only(func):
    """Attribute for a function only supported by stubs or ESP32 and later chips ROM"""
    return check_supported_function(
        func, lambda o: o.IS_STUB or o.CHIP_NAME not in ["ESP8266"]
    )


class StubFlasher:
    STUB_DIR = os.path.join(os.path.dirname(__file__), "targets", "stub_flasher")
    # directories will be searched in the order of STUB_SUBDIRS
    STUB_SUBDIRS = ["2", "1"]
    STUB_VERSION_EXPLICIT = False

    def __init__(self, target, plugins=None):
        json_name = target.STUB_CLASS.stub_json_name(target)

        with open(self._get_json_path(json_name, target.CHIP_NAME)) as json_file:
            stub = json.load(json_file)

        self.text = base64.b64decode(stub["text"])
        self.text_start = stub["text_start"]
        self.entry = stub["entry"]

        try:
            # bytearray: plugins patch the FPT in-place via struct.pack_into.
            # Therefore, it cannot be immutable bytes.
            self.data = bytearray(base64.b64decode(stub["data"]))
            self.data_start = stub["data_start"]
        except KeyError:
            self.data = None
            self.data_start = None

        self.bss_start = stub.get("bss_start")

        # Plugin support: list of (load_addr, bytes) segments to upload
        self.plugin_segments = []
        if plugins:
            chip_name = target.CHIP_NAME
            if stub.get("plugin_table_offset") is not None:
                self._apply_plugins(stub, plugins, chip_name)
            else:
                raise FatalError(f"{chip_name} stub does not support plugins.")

    def _apply_plugins(self, stub, plugins, chip_name):
        fpt_offset = stub["plugin_table_offset"]
        first_opcode = stub.get("plugin_first_opcode", 0xD5)
        plugin_text_kb = 0
        for name in plugins:
            pinfo = stub.get("plugins", {}).get(name)
            if pinfo is None:
                raise FatalError(f"Plugin '{name}' not found in {chip_name} stub.")
            ptext = base64.b64decode(pinfo["text"])
            self.plugin_segments.append((pinfo["text_start"], bytes(ptext)))
            plugin_text_kb += len(ptext) / 1024
            for opcode_str, handler_offset in pinfo["handlers"].items():
                opcode = int(opcode_str, 16)
                idx = opcode - first_opcode
                fpt_entry_addr = pinfo["text_start"] + handler_offset
                entry_off = fpt_offset + idx * 4
                struct.pack_into("<I", self.data, entry_off, fpt_entry_addr)
            bss_size = pinfo.get("bss_size", 0)
            if bss_size > 0:
                self.data += bytearray(bss_size)
        base_text_kb = len(self.text) / 1024
        log.print(
            f"Stub: {base_text_kb:.1f} KB (base)"
            + (
                f" + {plugin_text_kb:.1f} KB ({', '.join(plugins)})"
                if self.plugin_segments
                else ""
            )
        )

    def _get_json_path(self, json_name, chip_name):
        for i, subdir in enumerate(self.STUB_SUBDIRS):
            json_path = os.path.join(self.STUB_DIR, subdir, json_name)
            if os.path.exists(json_path):
                if i and self.STUB_VERSION_EXPLICIT:
                    log.warning(
                        f"{chip_name} stub version {self.STUB_SUBDIRS[0]} doesn't "
                        f"exist, using {subdir} instead."
                    )
                if subdir == "1":
                    log.note(
                        "Using the deprecated legacy stub flasher. "
                        "Support for this stub will be removed in a future release."
                    )
                return json_path
        else:
            raise FatalError(
                f"Flasher stub data is missing for {chip_name}. \n"
                "This means the esptool installation is incomplete or broken - "
                "stub JSON files were removed or a third-party distribution package "
                "didn't ship them. "
                "It is unlikely to be a defect in esptool itself.\n\n"
                "Try reinstalling esptool or restoring the stub files "
                "from the upstream source tree. As a workaround, "
                "you can pass --no-stub (slower operation, fewer features)."
            )

    @classmethod
    def set_stub_subdir(cls, subdir):
        cls.STUB_SUBDIRS = [subdir] + [x for x in cls.STUB_SUBDIRS if x != subdir]
        cls.STUB_VERSION_EXPLICIT = True


class ESPLoader:
    """Base class providing access to ESP ROM & software stub bootloaders.
    Subclasses provide ESP8266 & ESP32 Family specific functionality.

    Don't instantiate this base class directly, either instantiate a subclass or
    call cmds.detect_chip() which will interrogate the chip and return the
    appropriate subclass instance. You can also use a context manager as
    "with detect_chip() as esp:" to ensure the serial port is closed when done.

    """

    CHIP_NAME = "Espressif device"
    IS_STUB = False
    STUB_CLASS: type["ESPLoader"] | None = None
    BOOTLOADER_IMAGE: type | None = None
    IMAGE_CHIP_ID: int | None = None

    # Chip uses magic number for chip type autodetection
    USES_MAGIC_VALUE = True
    MAGIC_VALUE: int | None = None

    UF2_FAMILY_ID: int = 0x0

    DEFAULT_PORT = "/dev/ttyUSB0"

    USES_RFC2217 = False

    ESP_CMDS = {
        # Commands supported by ESP8266 ROM bootloader
        "FLASH_BEGIN": 0x02,
        "FLASH_DATA": 0x03,
        "FLASH_END": 0x04,
        "MEM_BEGIN": 0x05,
        "MEM_END": 0x06,
        "MEM_DATA": 0x07,
        "SYNC": 0x08,
        "WRITE_REG": 0x09,
        "READ_REG": 0x0A,
        # Commands supported by ESP32 and later chips ROM bootloader (or -8266 w/ stub)
        "SPI_SET_PARAMS": 0x0B,
        "SPI_ATTACH": 0x0D,
        "READ_FLASH_SLOW": 0x0E,  # ROM only, much slower than the stub flash read
        "CHANGE_BAUDRATE": 0x0F,
        "FLASH_DEFL_BEGIN": 0x10,
        "FLASH_DEFL_DATA": 0x11,
        "FLASH_DEFL_END": 0x12,
        "SPI_FLASH_MD5": 0x13,
        # Commands supported by ESP32-S2 and later chips ROM bootloader only
        "GET_SECURITY_INFO": 0x14,
        # Some commands supported by stub only
        "ERASE_FLASH": 0xD0,
        "ERASE_REGION": 0xD1,
        "READ_FLASH": 0xD2,
        "RUN_USER_CODE": 0xD3,
        # Flash encryption encrypted data command
        "FLASH_ENCRYPT_DATA": 0xD4,
        # NAND flash commands (stub only)
        "SPI_NAND_ATTACH": 0xD5,
        "SPI_NAND_READ_SPARE": 0xD6,
        "SPI_NAND_WRITE_SPARE": 0xD7,
        "SPI_NAND_READ_FLASH": 0xD8,
        "SPI_NAND_WRITE_FLASH_BEGIN": 0xD9,
        "SPI_NAND_WRITE_FLASH_DATA": 0xDA,
        "SPI_NAND_ERASE_FLASH": 0xDB,
        "SPI_NAND_ERASE_REGION": 0xDC,
        # Not used by esptool; defined here for completeness (stub-only command)
        "SPI_NAND_READ_PAGE_DEBUG": 0xDD,
        "SPI_NAND_WRITE_FLASH_END": 0xDE,
    }

    # Response code(s) sent by ROM
    ROM_INVALID_RECV_MSG = 0x05  # response if an invalid message is received

    # Maximum block sized for RAM and Flash writes, respectively.
    ESP_RAM_BLOCK = 0x1800

    FLASH_WRITE_SIZE = 0x400

    # Default baudrate. The ROM auto-bauds, so we can use more or less whatever we want.
    ESP_ROM_BAUD = 115200

    # First byte of the application image
    ESP_IMAGE_MAGIC = 0xE9

    # Initial state for the checksum routine
    ESP_CHECKSUM_MAGIC = 0xEF

    # Flash sector size, minimum unit of erase.
    FLASH_SECTOR_SIZE = 0x1000

    UART_DATE_REG_ADDR = 0x60000078

    # Whether the SPI peripheral sends from MSB of 32-bit register, or the MSB of valid
    # LSB bits.
    SPI_ADDR_REG_MSB = True

    # This ROM address has a different value on each chip model
    CHIP_DETECT_MAGIC_REG_ADDR = 0x40001000

    UART_CLKDIV_MASK = 0xFFFFF

    # Memory addresses
    IROM_MAP_START = 0x40200000
    IROM_MAP_END = 0x40300000

    # Bootloader flashing offset
    BOOTLOADER_FLASH_OFFSET = 0x0

    # Response to SYNC might indicate that flasher stub is running
    # instead of the ROM bootloader
    sync_stub_detected = False

    # Device VIDs, PIDs
    ESPRESSIF_VID = 0x303A

    USB_JTAG_SERIAL_PID = 0x1001
    HARDWARE_FLOW_CONTROL_VID_PIDS = [
        (0x10C4, 0xEA64),  # SiLabs CP2102C USB to UART Bridge Controller
    ]

    # Chip IDs that are no longer supported by esptool
    UNSUPPORTED_CHIPS = {
        4: "ESP32-S3(beta2)",
        6: "ESP32-S3(beta3)",
        7: "ESP32-C6(beta)",
        10: "ESP32-H2(beta1)",
        14: "ESP32-H2(beta2)",
        17: "ESP32-C5(beta3)",
    }

    # Number of attempts to write flash data
    WRITE_FLASH_ATTEMPTS = 2

    FLASH_ENCRYPTED_WRITE_ALIGN = 16
    KEY_PURPOSES: dict[int, str] = {}
    EFUSE_MAX_KEY = 5

    def __init__(self, port=DEFAULT_PORT, baud=ESP_ROM_BAUD, trace_enabled=False):
        """Base constructor for ESPLoader bootloader interaction

        Don't call this constructor, either instantiate a specific
        ROM class directly, or use cmds.detect_chip(). You can use the with
        statement to ensure the serial port is closed when done.

        This base class has all of the instance methods for bootloader
        functionality supported across various chips & stub
        loaders. Subclasses replace the functions they don't support
        with ones which throw NotImplementedInROMError().

        """
        # True if esptool detects the ROM is in Secure Download Mode
        self.secure_download_mode = False
        # True if esptool detects conditions which require the stub to be disabled
        self.stub_is_disabled = False

        # Device-and-runtime-specific cache
        self.cache = {
            "flash_id": None,
            "usb_vid": None,
            "usb_pid": None,
            "security_info": None,
        }

        if isinstance(port, str):
            try:
                self._port = serial.serial_for_url(
                    port, exclusive=True, do_not_open=True
                )
                if sys.platform == "win32":
                    # When opening a port on Windows,
                    # the RTS/DTR (active low) lines
                    # need to be set to False (pulled high)
                    # to avoid unwanted chip reset
                    self._port.rts = False
                    self._port.dtr = False
                self._port.open()
            except serial.serialutil.SerialException as e:
                port_issues = [
                    [  # does not exist error
                        re.compile(r"Errno 2|FileNotFoundError", re.IGNORECASE),
                        "Check if the port is correct and ESP connected",
                    ],
                    [  # busy port error
                        re.compile(r"Access is denied", re.IGNORECASE),
                        "Check if the port is not used by another task",
                    ],
                ]
                if sys.platform.startswith("linux"):
                    port_issues.append(
                        [  # permission denied error
                            re.compile(r"Permission denied", re.IGNORECASE),
                            ("Try to add user into dialout or uucp group."),
                        ],
                    )

                hint_msg = ""
                for port_issue in port_issues:
                    if port_issue[0].search(str(e)):
                        hint_msg = f"\nHint: {port_issue[1]}\n"
                        break

                raise FatalError(
                    f"Could not open {port}, the port is busy or doesn't exist."
                    f"\n({e})\n"
                    f"{hint_msg}"
                )
        else:
            self._port = port
        self._slip_reader = slip_reader(self._port, self.trace)
        # setting baud rate in a separate step is a workaround for
        # CH341 driver on some Linux versions (this opens at 9600 then
        # sets), shouldn't matter for other platforms/drivers. See
        # https://github.com/espressif/esptool/issues/44#issuecomment-107094446
        self._set_port_baudrate(baud)
        self._trace_enabled = trace_enabled
        # set write timeout, to prevent esptool blocked at write forever.
        try:
            self._port.write_timeout = DEFAULT_SERIAL_WRITE_TIMEOUT
        except NotImplementedError:
            # no write timeout for RFC2217 ports
            # need to set the property back to None or it will continue to fail
            self._port.write_timeout = None

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_value, traceback):
        self._port.close()

    @property
    def serial_port(self):
        return self._port.port

    def _set_port_baudrate(self, baud):
        try:
            self._port.baudrate = baud
        except OSError:
            raise FatalError(
                f"Failed to set baud rate {baud}. The driver may not support this rate."
            )

    def read(self):
        """Read a SLIP packet from the serial port"""
        try:
            return next(self._slip_reader)
        except StopIteration:
            # slip_reader only ever exits by raising (never returns), so a
            # StopIteration here means the generator was already exhausted by an
            # earlier serial-stream failure. Without this guard the bare
            # StopIteration becomes an opaque "RuntimeError: generator raised
            # StopIteration" (PEP 479) once it crosses a caller's generator,
            # hiding the real cause. Re-raise something actionable instead.
            raise FatalError(
                "No more data to read from the serial port. This can have "
                "many causes, for troubleshooting steps visit: "
                f"{TROUBLESHOOTING_GUIDE_URL}"
            ) from None

    def write(self, packet):
        """Write bytes to the serial port while performing SLIP escaping"""
        buf = (
            b"\xc0"
            + (packet.replace(b"\xdb", b"\xdb\xdd").replace(b"\xc0", b"\xdb\xdc"))
            + b"\xc0"
        )
        self.trace(f"{f'Write {len(buf)} bytes:':<21} {HexFormatter(buf)}")
        self._port.write(buf)

    def trace(self, message, newline=False):
        if self._trace_enabled:
            now = time.time()
            try:
                delta = now - self._last_trace
            except AttributeError:
                delta = 0.0
            self._last_trace = now
            prefix = f" TRACE +{delta:.3f}  "
            log.print("\n" if newline else "", f"{prefix} {message}")

    @staticmethod
    def checksum(data, state=ESP_CHECKSUM_MAGIC):
        """Calculate checksum of a blob, as it is defined by the ROM"""
        for b in data:
            state ^= b

        return state

    def command(
        self,
        op=None,
        data=b"",
        chk=0,
        wait_response=True,
        timeout=DEFAULT_TIMEOUT,
    ):
        """Send a request and read the response"""
        saved_timeout = self._port.timeout
        new_timeout = min(timeout, MAX_TIMEOUT)
        if new_timeout != saved_timeout:
            self._port.timeout = new_timeout

        try:
            if op is not None:
                self.trace(
                    f"--- Cmd {get_key_from_value(self.ESP_CMDS, op)} ({op:#04x}) | "
                    f"data_len {len(data)} | wait_response {1 if wait_response else 0}"
                    f" | timeout {timeout:.3f} | data {HexFormatter(data)} ---",
                    newline=True,
                )
                pkt = struct.pack(b"<BBHI", 0x00, op, len(data), chk) + data
                self.write(pkt)

            if not wait_response:
                return

            # tries to get a response until that response has the
            # same operation as the request or a retries limit has
            # exceeded. This is needed for some esp8266s that
            # reply with more sync responses than expected.
            for retry in range(100):
                p = self.read()
                if len(p) < 8:
                    continue
                (resp, op_ret, len_ret, val) = struct.unpack("<BBHI", p[:8])
                if resp != 1:
                    continue
                data = p[8:]

                if op is None or op_ret == op:
                    return val, data
                if byte(data, 0) != 0 and byte(data, 1) == self.ROM_INVALID_RECV_MSG:

                    def drain_input_buffer(buffering_time=0.2):
                        """
                        Actively drain the input buffer by reading data
                        for a specified time. Simple approach for some
                        drivers that have issues with the buffer flushing.

                        Args:
                            buffering_time: Time in seconds to wait for
                            the buffer to fill.
                        """
                        time.sleep(buffering_time)
                        original_timeout = self._port.timeout
                        # Set a very short timeout for draining
                        self._port.timeout = 0.001

                        # Unsupported command response is sent 8 times and has
                        # 14 bytes length including delimiter 0xC0 bytes.
                        # At least part of it is read as a command response,
                        # but to be safe, read it all.
                        self._port.read(14 * 8)

                        # Restore original timeout
                        self._port.timeout = original_timeout
                        self.flush_input()

                    # Unsupported command can result in
                    # more than one error response for some reason
                    drain_input_buffer(0.2)
                    raise UnsupportedCommandError(self, op)

        finally:
            if new_timeout != saved_timeout:
                self._port.timeout = saved_timeout

        raise FatalError("Response doesn't match request.")

    def check_command(
        self,
        op_description,
        op=None,
        data=b"",
        chk=0,
        resp_data_len=0,
        timeout=DEFAULT_TIMEOUT,
    ):
        """
        Execute a command with 'command', check the result code and throw an appropriate
        FatalError if it fails.

        Returns the "result" of a successful command.
        """

        # The status bytes are first two bytes after the expected response data,
        # ROM bootloaders of the chips (except ESP8266) return another 2 bytes,
        # which are reserved for future use, these are ignored here.
        STATUS_BYTES_LENGTH = 2

        # Execute the command and get the result
        val, data = self.command(op, data, chk, timeout=timeout)

        # Check if we have enough data,
        # including the expected response data and status bytes
        if len(data) < resp_data_len + STATUS_BYTES_LENGTH:
            # If we don't have enough data, check the first 2 bytes as status
            status_bytes = data[0:2]
            # Only care if the first byte is non-zero.
            # If it is, the second byte is a reason.
            if status_bytes[0] != 0:
                raise FatalError.WithResult(f"Failed to {op_description}", status_bytes)
            else:
                raise FatalError(
                    f"Failed to {op_description}. "
                    f"Only got {len(data)} byte status response."
                )
        # The status bytes are positioned after the expected response data
        # (first two bytes after resp_data_len are the status bytes)
        status_bytes = data[resp_data_len : resp_data_len + STATUS_BYTES_LENGTH]
        # Only care if the first byte is non-zero.
        # If it is, the second byte is a reason.
        if status_bytes[0] != 0:
            raise FatalError.WithResult(f"Failed to {op_description}", status_bytes)

        if resp_data_len > 0:
            return data[:resp_data_len]
        else:
            return val

    def flush_input(self):
        self._port.flushInput()
        self._slip_reader = slip_reader(self._port, self.trace)

    def sync(self):
        val, _ = self.command(
            self.ESP_CMDS["SYNC"],
            b"\x07\x07\x12\x20" + 32 * b"\x55",
            timeout=SYNC_TIMEOUT,
        )

        # ROM bootloaders send some non-zero "val" response. The flasher stub sends 0.
        # If we receive 0 then it probably indicates that the chip wasn't or couldn't be
        # reset properly and esptool is talking to the flasher stub.
        self.sync_stub_detected = val == 0

        for _ in range(7):
            val, _ = self.command()
            self.sync_stub_detected &= val == 0

    def get_usb_vid_pid(self):
        if self.cache["usb_vid"] is not None and self.cache["usb_pid"] is not None:
            return self.cache["usb_vid"], self.cache["usb_pid"]

        if list_ports is None:
            log.print(
                "\nListing all serial ports is currently not available. "
                "Can't get device VID/PID."
            )
            return None, None
        active_port = self._port.port

        # Pyserial only identifies regular ports, URL handlers are not supported
        if not active_port.lower().startswith("com") and not os.path.isabs(active_port):
            log.print(
                "\nDevice VID/PID identification is only supported on "
                "COM and absolute device paths."
            )
            return None, None
        active_ports = [active_port]

        # Prefer the real path if the active port is a symlink, but keep the
        # original name as a fallback for environments where pyserial lists it.
        if os.path.islink(active_port):
            resolved_port = os.path.realpath(active_port)
            if os.path.exists(resolved_port) and resolved_port != active_port:
                active_ports.insert(0, resolved_port)

        # The "cu" (call-up) device has to be used for outgoing communication on MacOS
        if sys.platform == "darwin":
            active_ports += [p.replace("tty", "cu") for p in active_ports if "tty" in p]
        ports = list_ports.comports()
        found_without_ids = False
        for lookup_port in active_ports:
            for p in ports:
                if p.device != lookup_port:
                    continue
                if p.vid is not None and p.pid is not None:
                    self.cache["usb_vid"] = p.vid
                    self.cache["usb_pid"] = p.pid
                    return p.vid, p.pid
                found_without_ids = True
        if found_without_ids:
            return None, None
        log.print(
            f"\nFailed to get VID/PID of a device on {active_port}, "
            "using standard reset sequence."
        )
        return None, None

    def _connect_attempt(self, reset_strategy, mode="default-reset"):
        """A single connection attempt"""
        last_error = None
        boot_log_detected = False
        download_mode = False

        # If we're doing no_sync, we're likely communicating as a pass through
        # with an intermediate device to the ESP32
        if mode == "no-reset-no-sync":
            return last_error

        if mode != "no-reset":
            if not self.USES_RFC2217:  # Might block on rfc2217 ports
                # Empty serial buffer to isolate boot log
                self._port.reset_input_buffer()

            reset_strategy()  # Reset the chip to bootloader (download mode)

            # Detect the ROM boot log and check actual boot mode (ESP32 and later only)
            waiting = self._port.inWaiting()
            read_bytes = self._port.read(waiting)
            data = re.search(
                b"boot:(0x[0-9a-fA-F]+)(.*waiting for download)?", read_bytes, re.DOTALL
            )
            if data is not None:
                boot_log_detected = True
                boot_mode = data.group(1)
                download_mode = data.group(2) is not None

        for _ in range(5):
            try:
                self.flush_input()
                self._port.flushOutput()
                self.sync()
                return None
            except FatalError as e:
                log.print(".", end="", flush=True)
                time.sleep(0.05)
                last_error = e

        if boot_log_detected:
            last_error = FatalError(
                "Wrong boot mode detected ({})! "
                "The chip needs to be in download mode.".format(
                    boot_mode.decode("utf-8")
                )
            )
            if download_mode:
                last_error = FatalError(
                    "Download mode successfully detected, but getting no sync reply: "
                    "The serial TX path seems to be down."
                )
        return last_error

    def get_memory_region(self, name):
        """
        Returns a tuple of (start, end) for the memory map entry with the given name,
        or None if it doesn't exist
        """
        try:
            return [(start, end) for (start, end, n) in self.MEMORY_MAP if n == name][0]
        except IndexError:
            return None

    def _construct_reset_strategy_sequence(self, mode):
        """
        Constructs a sequence of reset strategies based on the OS,
        used ESP chip, external settings, and environment variables.
        Returns a tuple of one or more reset strategies to be tried sequentially.
        """
        cfg_custom_reset_sequence = cfg.get("custom_reset_sequence")
        if cfg_custom_reset_sequence is not None:
            return (CustomReset(self._port, cfg_custom_reset_sequence),)

        cfg_reset_delay = cfg.getfloat("reset_delay")
        if cfg_reset_delay is not None:
            delay = extra_delay = cfg_reset_delay
        else:
            delay = DEFAULT_RESET_DELAY
            extra_delay = DEFAULT_RESET_DELAY + 0.5

        # This FPGA delay is for Espressif internal use
        if (
            self.CHIP_NAME == "ESP32"
            and os.environ.get("ESPTOOL_ENV_FPGA", "").strip() == "1"
        ):
            delay = extra_delay = 7

        # USB-JTAG/Serial mode
        if mode == "usb-reset" or self.get_usb_vid_pid()[1] == self.USB_JTAG_SERIAL_PID:
            return (USBJTAGSerialReset(self._port),)

        flow_control = self.uses_hardware_flow_control()

        # USB-to-Serial bridge
        if os.name != "nt" and not self._port.name.startswith("rfc2217:"):
            return (
                UnixTightReset(self._port, delay, flow_control),
                UnixTightReset(self._port, extra_delay, flow_control),
                ClassicReset(self._port, delay, flow_control),
                ClassicReset(self._port, extra_delay, flow_control),
            )

        return (
            ClassicReset(self._port, delay, flow_control),
            ClassicReset(self._port, extra_delay, flow_control),
        )

    def connect(
        self,
        mode="default-reset",
        attempts=DEFAULT_CONNECT_ATTEMPTS,
        detecting=False,
        warnings=True,
    ):
        """Try connecting repeatedly until successful, or giving up"""
        if warnings and mode in ["no-reset", "no-reset-no-sync"]:
            log.note(
                f'Pre-connection option "{mode}" was selected. '
                "Connection may fail if the chip is not in bootloader "
                "or flasher stub mode."
            )

        if self._port.name.startswith("socket:"):
            mode = "no-reset"  # not possible to toggle DTR/RTS over a TCP socket
            log.note(
                "It's not possible to reset the chip over a TCP socket. "
                "Automatic resetting to bootloader has been disabled, "
                "reset the chip manually."
            )

        log.print("Connecting...", end="", flush=True)
        last_error = None

        reset_sequence = self._construct_reset_strategy_sequence(mode)
        try:
            for _, reset_strategy in zip(
                range(attempts) if attempts > 0 else itertools.count(),
                itertools.cycle(reset_sequence),
            ):
                last_error = self._connect_attempt(reset_strategy, mode)
                if last_error is None:
                    break
        finally:
            log.print("")  # end 'Connecting...' line

        if last_error is not None:
            additional_msg = ""
            if self.CHIP_NAME == "ESP32-C2" and self._port.baudrate < 115200:
                additional_msg = (
                    "\nNote: Please set a higher baud rate"
                    " if ESP32-C2 doesn't connect"
                    " (at least 115200 Bd is recommended)."
                )
            self._port.close()
            raise FatalError(
                f"Failed to connect to {self.CHIP_NAME}: {last_error}"
                f"{additional_msg}"
                f"\nFor troubleshooting steps visit: {TROUBLESHOOTING_GUIDE_URL}"
            )

        if not detecting:
            from .targets import ROM_LIST

            # Check if chip supports reading chip ID from the get-security-info command
            try:
                # get_chip_id() raises FatalError if the chip does not have a chip ID
                # (ESP32-S2)
                chip_id = self.get_chip_id()
                si = self.get_security_info()
                self.secure_download_mode = si["parsed_flags"]["SECURE_DOWNLOAD_ENABLE"]
            except (UnsupportedCommandError, FatalError):
                chip_id = None
                # Try to read the chip magic value to verify the chip type
                # (ESP8266, ESP32, ESP32-S2)
                try:
                    chip_magic_value = self.read_reg(
                        ESPLoader.CHIP_DETECT_MAGIC_REG_ADDR
                    )
                except UnsupportedCommandError:
                    # If the chip does not support reading the chip magic value,
                    # it is ESP32-S2 in SDM
                    chip_magic_value = None
                    self.secure_download_mode = True

            detected = None
            chip_arg_wrong = False

            # If we can read chip ID (ESP32-S3 and later), verify the ID
            if chip_id and (self.USES_MAGIC_VALUE or chip_id != self.IMAGE_CHIP_ID):
                chip_arg_wrong = True
                for cls in ROM_LIST:
                    if not cls.USES_MAGIC_VALUE and chip_id == cls.IMAGE_CHIP_ID:
                        detected = cls
                        break
            # If we can't read chip ID (ESP8266, ESP32, ESP32-S2),
            # try to verify the chip by magic value
            elif (
                not chip_id
                and not self.secure_download_mode
                and (not self.USES_MAGIC_VALUE or chip_magic_value != self.MAGIC_VALUE)
            ):
                chip_arg_wrong = True
                for cls in ROM_LIST:
                    if cls.USES_MAGIC_VALUE and chip_magic_value == cls.MAGIC_VALUE:
                        detected = cls
                        break
            # If we can't read chip ID and the chip is in SDM, it is ESP32-S2
            elif (
                not chip_id
                and self.secure_download_mode
                and self.CHIP_NAME != "ESP32-S2"
            ):
                chip_arg_wrong = True
                detected = "ESP32-S2"

            if chip_arg_wrong:
                if warnings and detected is None:
                    specifier = (
                        f"(read chip ID {chip_id})"
                        if chip_id
                        else f"(read chip magic value {chip_magic_value:#08x})"
                    )
                    log.warning(
                        f"This chip doesn't appear to be an {self.CHIP_NAME} "
                        f"{specifier}. Probably it is unsupported by this version "
                        "of esptool. Will attempt to continue anyway."
                    )
                else:
                    chip_type = (
                        detected if isinstance(detected, str) else detected.CHIP_NAME
                    )
                    raise FatalError(
                        f"This chip is {chip_type}, not {self.CHIP_NAME}. "
                        "Wrong chip argument?"
                    )
            self._post_connect()

    def _post_connect(self):
        """
        Additional initialization hook, may be overridden by the chip-specific class.
        Gets called after connect, and after auto-detection.
        """
        pass

    def read_reg(self, addr, timeout=DEFAULT_TIMEOUT):
        """Read memory address in target"""
        command = struct.pack("<I", addr)
        return self.check_command(
            "read target memory", self.ESP_CMDS["READ_REG"], command, timeout=timeout
        )

    def write_reg(self, addr, value, mask=0xFFFFFFFF, delay_us=0, delay_after_us=0):
        """Write to memory address in target"""
        command = struct.pack("<IIII", addr, value, mask, delay_us)
        if delay_after_us > 0:
            # add a dummy write to a date register as an excuse to have a delay
            command += struct.pack(
                "<IIII", self.UART_DATE_REG_ADDR, 0, 0, delay_after_us
            )

        return self.check_command(
            "write target memory", self.ESP_CMDS["WRITE_REG"], command
        )

    def update_reg(self, addr, mask, new_val):
        """
        Update register at 'addr', replace the bits masked out by 'mask'
        with new_val. new_val is shifted left to match the LSB of 'mask'

        Returns just-written value of register.
        """
        shift = mask_to_shift(mask)
        val = self.read_reg(addr)
        val &= ~mask
        val |= (new_val << shift) & mask
        self.write_reg(addr, val)

        return val

    def mem_begin(self, size, blocks, blocksize, offset):
        """Start downloading an application image to RAM"""
        # check we're not going to overwrite a running stub with this data
        if self.IS_STUB:
            stub = StubFlasher(self)
            load_start = offset
            load_end = offset + size
            for stub_start, stub_end in [
                (
                    stub.bss_start or stub.data_start,
                    stub.data_start + len(stub.data),
                ),  # DRAM = bss+data
                (stub.text_start, stub.text_start + len(stub.text)),  # IRAM
            ]:
                if load_start < stub_end and load_end > stub_start:
                    raise FatalError(
                        "Stub flasher is resident at "
                        f"{stub_start:#010x}-{stub_end:#010x}. "
                        "Can't load binary at overlapping address range "
                        f"{load_start:#010x}-{load_end:#010x}. Either change binary "
                        "loading address, or disable the stub flasher."
                    )

        return self.check_command(
            "enter RAM download mode",
            self.ESP_CMDS["MEM_BEGIN"],
            struct.pack("<IIII", size, blocks, blocksize, offset),
        )

    def mem_block(self, data, seq):
        """Send a block of an image to RAM"""
        return self.check_command(
            "write to target RAM",
            self.ESP_CMDS["MEM_DATA"],
            struct.pack("<IIII", len(data), seq, 0, 0) + data,
            self.checksum(data),
        )

    def mem_finish(self, entrypoint=0):
        """Leave download mode and run the application"""
        # Sending MEM_END usually sends a correct response back, however sometimes
        # (with ROM loader) the executed code may reset the UART or change the baud rate
        # before the transmit FIFO is empty. So in these cases we set a short timeout
        # and ignore errors.
        timeout = DEFAULT_TIMEOUT if self.IS_STUB else MEM_END_ROM_TIMEOUT
        data = struct.pack("<II", int(entrypoint == 0), entrypoint)
        try:
            return self.check_command(
                "leave RAM download mode",
                self.ESP_CMDS["MEM_END"],
                data=data,
                timeout=timeout,
            )
        except FatalError:
            if self.IS_STUB:
                raise
            pass

    def flash_begin(self, size, offset, encrypted_write=False, logging=True):
        """
        Start downloading to Flash (performs an erase)

        Returns number of blocks (of size self.FLASH_WRITE_SIZE) to write.
        """
        num_blocks = (size + self.FLASH_WRITE_SIZE - 1) // self.FLASH_WRITE_SIZE
        erase_size = self.get_erase_size(offset, size)

        t = time.time()
        if self.IS_STUB:
            timeout = DEFAULT_TIMEOUT
        else:
            timeout = timeout_per_mb(
                ERASE_REGION_TIMEOUT_PER_MB, size
            )  # ROM performs the erase up front

        params = struct.pack(
            "<IIII", erase_size, num_blocks, self.FLASH_WRITE_SIZE, offset
        )

        # ESP32 and ESP8266 ROMs do not support extended parameter format
        if self.IS_STUB or self.CHIP_NAME not in ("ESP32", "ESP8266"):
            params += struct.pack("<I", 1 if encrypted_write else 0)

        self.check_command(
            "enter flash download mode",
            self.ESP_CMDS["FLASH_BEGIN"],
            params,
            timeout=timeout,
        )
        if size != 0 and not self.IS_STUB and logging:
            log.print(f"Took {time.time() - t:.2f}s to erase flash block.")
        return num_blocks

    def flash_block(self, data, seq, timeout=DEFAULT_TIMEOUT, encrypted=False):
        """Write block to flash, retry if fail"""

        operation = "encrypted " if encrypted else ""
        for attempts_left in range(WRITE_BLOCK_ATTEMPTS - 1, -1, -1):
            try:
                self.check_command(
                    f"write {operation}to target flash after seq {seq}",
                    self.ESP_CMDS["FLASH_DATA"],
                    struct.pack("<IIII", len(data), seq, 0, 0) + data,
                    self.checksum(data),
                    timeout=timeout,
                )
                break
            except FatalError:
                if attempts_left:
                    self.trace(
                        f"{operation}block write failed, "
                        f"retrying with {attempts_left} attempts left...".capitalize()
                    )
                else:
                    raise

    def flash_finish(self, reboot=False, timeout=DEFAULT_TIMEOUT):
        """Leave flash mode and run/reboot"""
        pkt = struct.pack("<I", int(not reboot))
        # stub sends a reply to this command
        self.check_command(
            "leave flash download mode",
            self.ESP_CMDS["FLASH_END"],
            pkt,
            timeout=timeout,
        )

    def run(self, reboot=False):
        """Run application code in flash"""
        # Fake flash begin immediately followed by flash end
        self.flash_begin(0, 0)
        self.flash_finish(reboot)

    def flash_id(self, cache=True):
        """Read SPI flash manufacturer and device ID"""
        if not cache or self.cache["flash_id"] is None:
            SPIFLASH_RDID = 0x9F
            self.cache["flash_id"] = self.run_spiflash_command(SPIFLASH_RDID, b"", 24)
        return self.cache["flash_id"]

    def flash_type(self):
        """Read flash type bit field from eFuse. Returns 0, 1, None (not present)"""
        return None  # not implemented for all chip targets

    def get_security_info(self, cache=True):
        """
        Get security information from the ESP device including flags,
        flash encryption count,
        key purposes, chip ID, and API version.

        The security info command response format according to the ESP32-S3
        documentation:
        - 32 bits flags
        - 1 byte flash_crypt_cnt
        - 7x1 byte key_purposes
        - 32-bit word chip_id (ESP32-S3 and later)
        - 32-bit word eco_version/api_version (ESP32-S3 and later)

        Returns a dictionary with parsed security information and individual
        flag status.
        """
        if cache and self.cache["security_info"] is not None:
            return self.cache["security_info"]

        # The following mapping was taken from the ROM code
        # This mapping is same across all targets in the ROM
        SECURITY_INFO_FLAG_MAP = {
            "SECURE_BOOT_EN": (1 << 0),
            "SECURE_BOOT_AGGRESSIVE_REVOKE": (1 << 1),
            "SECURE_DOWNLOAD_ENABLE": (1 << 2),
            "SECURE_BOOT_KEY_REVOKE0": (1 << 3),
            "SECURE_BOOT_KEY_REVOKE1": (1 << 4),
            "SECURE_BOOT_KEY_REVOKE2": (1 << 5),
            "SOFT_DIS_JTAG": (1 << 6),
            "HARD_DIS_JTAG": (1 << 7),
            "DIS_USB": (1 << 8),
            "DIS_DOWNLOAD_DCACHE": (1 << 9),
            "DIS_DOWNLOAD_ICACHE": (1 << 10),
        }

        def parse_security_flags(flags_value):
            """Parse security flags into individual boolean values"""
            parsed_flags = {}
            for flag_name, flag_mask in SECURITY_INFO_FLAG_MAP.items():
                parsed_flags[flag_name] = (flags_value & flag_mask) != 0
            return parsed_flags

        # Try with 20 bytes first (most chips), fall back to 12 bytes (ESP32-S2)
        try:
            res = self.check_command(
                "get security info",
                self.ESP_CMDS["GET_SECURITY_INFO"],
                b"",
                resp_data_len=20,
            )
            res = struct.unpack("<IBBBBBBBBII", res)
            esp32s2 = False
        except FatalError:
            res = self.check_command(
                "get security info",
                self.ESP_CMDS["GET_SECURITY_INFO"],
                b"",
                resp_data_len=12,
            )
            res = struct.unpack("<IBBBBBBBB", res)
            esp32s2 = True

        security_info = {
            "flags": res[0],
            "flash_crypt_cnt": res[1],
            "key_purposes": res[2:9],
            "chip_id": None if esp32s2 else res[9],
            "api_version": None if esp32s2 else res[10],
            "parsed_flags": parse_security_flags(res[0]),
        }

        self.cache["security_info"] = security_info
        return security_info

    def get_chip_id(self):
        chip_id = self.get_security_info()["chip_id"]
        if chip_id is None:
            raise FatalError(
                "Security info command does not contain chip ID. "
                "This is expected for ESP32-S2 which doesn't support chip ID "
                "in security info."
            )
        return chip_id

    def uses_usb_jtag_serial(self):
        """
        True if the host sees this port as Espressif USB Serial/JTAG (VID/PID match).
        """
        return self.get_usb_vid_pid() == (self.ESPRESSIF_VID, self.USB_JTAG_SERIAL_PID)

    def uses_usb_otg(self):
        """
        True if the host sees this port as Espressif USB-OTG (VID/PID match).
        """
        return self.get_usb_vid_pid() == (self.ESPRESSIF_VID, self.IMAGE_CHIP_ID)

    def uses_hardware_flow_control(self):
        """
        True if this port uses hardware flow control that esptool needs to know
        about for resetting. This is determined by checking if the USB PID matches
        known PIDs with hardware flow control.
        """
        return self.get_usb_vid_pid() in self.HARDWARE_FLOW_CONTROL_VID_PIDS

    def get_usb_mode(self):
        """
        Get the USB mode of the chip: USB-Serial/JTAG or USB-OTG.
        If the usb_mode is None, external USB-UART is used.
        """
        usb_jtag_serial = self.uses_usb_jtag_serial()
        usb_otg = self.uses_usb_otg()

        return "USB-Serial/JTAG" if usb_jtag_serial else "USB-OTG" if usb_otg else None

    def get_chip_revision(self):
        return self.get_major_chip_version() * 100 + self.get_minor_chip_version()

    def get_minor_chip_version(self):
        raise NotImplementedInROMError

    def get_major_chip_version(self):
        raise NotImplementedInROMError

    def read_mac(self, mac_type):
        raise NotImplementedInROMError

    def chip_id(self):
        raise NotSupportedError(self, "Function chip_id")

    def get_secure_boot_enabled(self):
        return False

    def get_secure_boot_v1_enabled(self):
        """
        Returns True if Secure Boot V1 is enabled.
        Only ESP32 supports V1; other chips return False.
        """
        return False

    def get_flash_encryption_enabled(self):
        return False

    def uses_key_manager_for_flash_encryption(self):
        return False

    def get_encrypted_download_disabled(self):
        return False

    def get_flash_crypt_config(self):
        raise NotImplementedInROMError

    def get_flash_voltage(self):
        raise NotSupportedError(self, "Reading flash voltage")

    def override_vddsdio(self, new_voltage):
        raise NotSupportedError(self, "Overriding VDDSDIO")

    def check_spi_connection(self, spi_connection):
        raise NotSupportedError(self, "Setting --spi-connection")

    def get_chip_spi_pads(self):
        raise NotSupportedError(self, "Reading chip SPI pad config")

    def is_flash_encryption_key_valid(self):
        raise NotSupportedError(self, "Flash encryption")

    @classmethod
    def parse_flash_size_arg(cls, arg):
        try:
            return cls.FLASH_SIZES[arg]
        except KeyError:
            raise FatalError(
                f"Flash size '{arg}' is not supported by this chip type. "
                f"Supported sizes: {', '.join(cls.FLASH_SIZES.keys())}"
            )

    @classmethod
    def parse_flash_freq_arg(cls, arg):
        if arg is None:
            # The encoding of the default flash frequency in FLASH_FREQUENCY is always 0
            return 0
        try:
            return cls.FLASH_FREQUENCY[arg]
        except KeyError:
            raise FatalError(
                f"Flash frequency '{arg}' is not supported by this chip type. "
                f"Supported frequencies: {', '.join(cls.FLASH_FREQUENCY.keys())}"
            )

    def run_stub(self, stub: StubFlasher | None = None) -> "ESPLoader":
        log.stage()
        if stub is None:
            stub = StubFlasher(self)

        if self.sync_stub_detected:
            log.stage(finish=True)
            log.print("Stub flasher is already running. No upload is necessary.")
            return self.STUB_CLASS(self) if self.STUB_CLASS is not None else self

        secure_boot_workflow = (
            self.CHIP_NAME == "ESP32-S3" and self.get_secure_boot_enabled()
        )

        # Upload
        log.print("Uploading stub flasher...")
        for field, offs in [
            (stub.text, stub.text_start),
            (stub.data, stub.data_start),
        ]:
            if field is not None:
                self._upload_segment(field, offs)
        for load_addr, segment_bytes in getattr(stub, "plugin_segments", []):
            self._upload_segment(segment_bytes, load_addr)

        log.print("Running stub flasher...")
        if not secure_boot_workflow:
            self.mem_finish(stub.entry)
        else:
            # Bug in ESP32-S3 ROM prevents stub execution if secure boot is enabled
            # Hijack the `read` function in ROM to point to the stub entrypoint
            # got with GDB - p &rom_spiflash_legacy_funcs.read
            rom_spiflash_legacy_funcs_read_ptr = 0x3FCEF688
            self.mem_finish(0)  # Finish uploading to RAM but don't run the stub yet
            stored_read_pointer = self.read_reg(rom_spiflash_legacy_funcs_read_ptr)
            self.write_reg(rom_spiflash_legacy_funcs_read_ptr, stub.entry)
            self.command(  # Trigger the `read` in ROM to jump to the stub entrypoint
                self.ESP_CMDS["READ_FLASH_SLOW"],
                struct.pack("<II", 0, 0),
                wait_response=False,
            )

        try:
            p = self.read()
        except FatalError as e:
            # A read failure here means the stub never sent its OHAI greeting
            # (a frequent occurrence during stub development). Surface the
            # stub-start context; the serial-level cause stays available via the
            # `from e` chain.
            raise FatalError(
                "Failed to start stub flasher. There was no response.\n"
                f"For troubleshooting steps visit: {TROUBLESHOOTING_GUIDE_URL}"
            ) from e

        if p != b"OHAI":
            raise FatalError(f"Failed to start stub flasher. Unexpected response: {p}")
        if secure_boot_workflow:
            # Restore the original `read` function pointer
            self.write_reg(rom_spiflash_legacy_funcs_read_ptr, stored_read_pointer)
        log.stage(finish=True)
        log.print("Stub flasher running.")
        return self.STUB_CLASS(self) if self.STUB_CLASS is not None else self

    def _upload_segment(self, data, offs):
        """Upload a binary segment to device RAM via mem_begin/mem_block."""
        length = len(data)
        blocks = (length + self.ESP_RAM_BLOCK - 1) // self.ESP_RAM_BLOCK
        self.mem_begin(length, blocks, self.ESP_RAM_BLOCK, offs)
        for seq in range(blocks):
            from_offs = seq * self.ESP_RAM_BLOCK
            to_offs = from_offs + self.ESP_RAM_BLOCK
            self.mem_block(data[from_offs:to_offs], seq)

    @stub_and_esp32_function_only
    def flash_defl_begin(self, size, compsize, offset, encrypted_write=False):
        """
        Start downloading compressed data to Flash (performs an erase)

        Returns number of blocks (size self.FLASH_WRITE_SIZE) to write.
        """
        num_blocks = (compsize + self.FLASH_WRITE_SIZE - 1) // self.FLASH_WRITE_SIZE
        erase_blocks = (size + self.FLASH_WRITE_SIZE - 1) // self.FLASH_WRITE_SIZE

        t = time.time()
        if self.IS_STUB:
            write_size = (
                size  # stub expects number of bytes here, manages erasing internally
            )
            timeout = DEFAULT_TIMEOUT
        else:
            write_size = (
                erase_blocks * self.FLASH_WRITE_SIZE
            )  # ROM expects rounded up to erase block size
            timeout = timeout_per_mb(
                ERASE_REGION_TIMEOUT_PER_MB, write_size
            )  # ROM performs the erase up front
        log.print(f"Compressed {size} bytes to {compsize}...")
        params = struct.pack(
            "<IIII", write_size, num_blocks, self.FLASH_WRITE_SIZE, offset
        )

        # ESP32 and ESP8266 ROMs do not support extended parameter format
        if self.IS_STUB or self.CHIP_NAME not in ("ESP32", "ESP8266"):
            params += struct.pack("<I", 1 if encrypted_write else 0)

        self.check_command(
            "enter compressed flash mode",
            self.ESP_CMDS["FLASH_DEFL_BEGIN"],
            params,
            timeout=timeout,
        )
        if size != 0 and not self.IS_STUB:
            # (stub erases as it writes, but ROM loaders erase on begin)
            log.print(f"Took {time.time() - t:.2f}s to erase flash block.")
        return num_blocks

    @stub_and_esp32_function_only
    def flash_defl_block(self, data, seq, timeout=DEFAULT_TIMEOUT):
        """Write block to flash, send compressed, retry if fail"""
        for attempts_left in range(WRITE_BLOCK_ATTEMPTS - 1, -1, -1):
            try:
                self.check_command(
                    f"write compressed data to flash after seq {seq}",
                    self.ESP_CMDS["FLASH_DEFL_DATA"],
                    struct.pack("<IIII", len(data), seq, 0, 0) + data,
                    self.checksum(data),
                    timeout=timeout,
                )
                break
            except FatalError:
                if attempts_left:
                    self.trace(
                        "Compressed block write failed, "
                        f"retrying with {attempts_left} attempts left"
                    )
                else:
                    raise

    @stub_and_esp32_function_only
    def flash_defl_finish(self, reboot=False, timeout=DEFAULT_TIMEOUT):
        """Leave compressed flash mode and run/reboot"""
        if not reboot and not self.IS_STUB:
            # skip sending flash_finish to ROM loader, as this
            # exits the bootloader. Stub doesn't do this.
            return
        pkt = struct.pack("<I", int(not reboot))
        self.check_command(
            "leave compressed flash mode",
            self.ESP_CMDS["FLASH_DEFL_END"],
            pkt,
            timeout=timeout,
        )
        self.in_bootloader = False

    @stub_and_esp32_function_only
    def flash_md5sum(self, addr, size):
        # the MD5 command returns additional bytes in the standard
        # command reply slot
        RESP_DATA_LEN = 32
        RESP_DATA_LEN_STUB = 16
        timeout = timeout_per_mb(MD5_TIMEOUT_PER_MB, size)
        res = self.check_command(
            "calculate md5sum",
            self.ESP_CMDS["SPI_FLASH_MD5"],
            struct.pack("<IIII", addr, size, 0, 0),
            resp_data_len=RESP_DATA_LEN_STUB if self.IS_STUB else RESP_DATA_LEN,
            timeout=timeout,
        )

        if not self.IS_STUB:
            return res.decode("utf-8")  # already hex formatted
        else:
            return hexify(res).lower()

    @stub_and_esp32_function_only
    def change_baud(self, baud):
        log.print(f"Changing baud rate to {baud}...")
        # stub takes the new baud rate and the old one
        second_arg = self._port.baudrate if self.IS_STUB else 0
        self.command(
            self.ESP_CMDS["CHANGE_BAUDRATE"], struct.pack("<II", baud, second_arg)
        )
        log.print("Changed.")
        self._set_port_baudrate(baud)
        time.sleep(0.05)  # get rid of crap sent during baud rate change
        self.flush_input()

    @stub_function_only
    def erase_flash(self):
        # depending on flash chip model the erase may take this long (maybe longer!)
        self.check_command(
            "erase flash", self.ESP_CMDS["ERASE_FLASH"], timeout=CHIP_ERASE_TIMEOUT
        )

    @stub_function_only
    def erase_region(self, offset, size):
        timeout = timeout_per_mb(ERASE_REGION_TIMEOUT_PER_MB, size)
        self.check_command(
            "erase region",
            self.ESP_CMDS["ERASE_REGION"],
            struct.pack("<II", offset, size),
            timeout=timeout,
        )

    @stub_function_only
    def erase_nand_flash(self):
        self.check_command(
            "erase NAND flash",
            self.ESP_CMDS["SPI_NAND_ERASE_FLASH"],
            timeout=CHIP_ERASE_TIMEOUT,
        )

    @stub_function_only
    def erase_nand_region(self, offset, size):
        timeout = timeout_per_mb(ERASE_REGION_TIMEOUT_PER_MB, size)
        self.check_command(
            "erase NAND region",
            self.ESP_CMDS["SPI_NAND_ERASE_REGION"],
            struct.pack("<II", offset, size),
            timeout=timeout,
        )

    def read_flash_slow(self, offset, length, progress_fn) -> bytes:
        raise NotImplementedInROMError(self, self.read_flash_slow)

    def read_flash(self, offset, length, progress_fn=None) -> bytes:
        if not self.IS_STUB:
            return self.read_flash_slow(offset, length, progress_fn)  # ROM-only routine

        # issue a standard bootloader command to trigger the read
        self.check_command(
            "read flash",
            self.ESP_CMDS["READ_FLASH"],
            struct.pack("<IIII", offset, length, self.FLASH_SECTOR_SIZE, 64),
        )
        # now we expect (length // block_size) SLIP frames with the data
        data = b""
        while len(data) < length:
            self._port.timeout = 3
            p = self.read()
            data += p
            data_len = len(data)
            if data_len < length and len(p) < self.FLASH_SECTOR_SIZE:
                raise FatalError(
                    f"Corrupt data, expected {self.FLASH_SECTOR_SIZE:#x} "
                    f"bytes but received {len(p):#x} bytes."
                )
            self.write(struct.pack("<I", data_len))
            if progress_fn and (data_len % 1024 == 0 or data_len == length):
                progress_fn(data_len, length, offset)
        if len(data) > length:
            raise FatalError("Read more than expected.")

        digest_frame = self.read()
        if len(digest_frame) != 16:
            raise FatalError(f"Expected digest, got: {hexify(digest_frame)}")
        expected_digest = hexify(digest_frame).upper()
        digest = hashlib.md5(data).hexdigest().upper()
        if digest != expected_digest:
            raise FatalError(
                f"Digest mismatch: expected {expected_digest}, got {digest}"
            )
        return data

    def read_flash_nand(self, offset, length, progress_fn=None) -> bytes:
        """Read NAND flash via stub command ESP_SPI_NAND_READ_FLASH (0xD8).

        Uses the same SLIP data-frame + ACK + MD5 wire protocol as NOR read_flash.
        """
        if not self.IS_STUB:
            raise FatalError("NAND read_flash is only supported via the stub loader.")

        self.check_command(
            "read NAND flash",
            self.ESP_CMDS["SPI_NAND_READ_FLASH"],
            struct.pack(
                "<IIII", offset, length, self.FLASH_SECTOR_SIZE, NAND_PAGES_PER_BLOCK
            ),
        )

        prev_timeout = self._port.timeout
        self._port.timeout = 10
        data = b""
        try:
            while len(data) < length:
                p = self.read()
                data += p
                data_len = len(data)
                if data_len < length and len(p) < self.FLASH_SECTOR_SIZE:
                    raise FatalError(
                        f"Corrupt data, expected {self.FLASH_SECTOR_SIZE:#x} "
                        f"bytes but received {len(p):#x} bytes."
                    )
                self.write(struct.pack("<I", data_len))
                if progress_fn and (data_len % 1024 == 0 or data_len == length):
                    progress_fn(data_len, length, offset)
            if len(data) > length:
                raise FatalError("Read more than expected.")

            digest_frame = self.read()
            if len(digest_frame) != 16:
                raise FatalError(f"Expected digest, got: {hexify(digest_frame)}")
            expected_digest = hexify(digest_frame).upper()
            digest = hashlib.md5(data).hexdigest().upper()
            if digest != expected_digest:
                raise FatalError(
                    f"Digest mismatch: expected {expected_digest}, got {digest}"
                )
        finally:
            self._port.timeout = prev_timeout
        return data

    def write_flash_nand_begin(self, size, offset):
        """Start NAND flash write (stub command). Same as flash_begin but for NAND."""
        params = struct.pack(
            "<IIII", offset, size, NAND_BLOCK_SIZE, self.FLASH_WRITE_SIZE
        )
        self.check_command(
            "enter NAND flash download mode",
            self.ESP_CMDS["SPI_NAND_WRITE_FLASH_BEGIN"],
            params,
        )

    def write_flash_nand_block(self, data, seq, timeout=DEFAULT_TIMEOUT):
        """Write one block to NAND flash (stub command). Same format as flash_block."""
        for attempts_left in range(WRITE_BLOCK_ATTEMPTS - 1, -1, -1):
            try:
                self.check_command(
                    f"write to NAND flash after seq {seq}",
                    self.ESP_CMDS["SPI_NAND_WRITE_FLASH_DATA"],
                    struct.pack("<IIII", len(data), seq, 0, 0) + data,
                    self.checksum(data),
                    timeout=timeout,
                )
                break
            except (NANDProgramFailed, NANDEraseFailed):
                # Chip-reported P_FAIL / E_FAIL — retrying is futile (the cell is
                # bad). Surface immediately so the caller can mark the block bad.
                raise
            except FatalError:
                if attempts_left:
                    self.trace(
                        f"block write failed, "
                        f"retrying with {attempts_left} attempts left...".capitalize()
                    )
                else:
                    raise

    def write_flash_nand_finish(self, reboot=False, timeout=DEFAULT_TIMEOUT):
        """End a NAND flash write session (stub plugin command)."""
        pkt = struct.pack("<I", int(not reboot))
        self.check_command(
            "leave NAND flash download mode",
            self.ESP_CMDS["SPI_NAND_WRITE_FLASH_END"],
            pkt,
            timeout=timeout,
        )

    def flash_spi_attach(self, hspi_arg):
        """Send SPI attach command to enable the SPI flash pins

        ESP8266 ROM does this when you send flash_begin, ESP32 ROM
        has it as a SPI command.
        """
        # last 3 bytes in SPI_ATTACH argument are reserved values
        arg = struct.pack("<I", hspi_arg)
        if not self.IS_STUB:
            # ESP32 ROM loader takes additional 'is legacy' arg, which is not
            # currently supported in the stub loader or esptool
            # (as it's not usually needed.)
            is_legacy = 0
            arg += struct.pack("BBBB", is_legacy, 0, 0, 0)
        self.check_command("configure SPI flash pins", self.ESP_CMDS["SPI_ATTACH"], arg)

    def flash_spi_nand_attach(self, hspi_arg):
        """Send SPI NAND attach command to enable the SPI NAND flash pins

        Similar to flash_spi_attach but for NAND flash.
        """
        arg = struct.pack("<I", hspi_arg)
        if not self.IS_STUB:
            is_legacy = 0
            arg += struct.pack("BBBB", is_legacy, 0, 0, 0)
        val, data = self.command(self.ESP_CMDS["SPI_NAND_ATTACH"], arg)
        # 2-byte response contract: data[0]=prot_reg, data[1]=status byte.
        # Gate on data[1] (status) to match the normal ≥3-byte path.
        if len(data) < 3:
            if len(data) >= 2 and data[1] != 0:
                raise FatalError.WithResult(
                    "Failed to configure SPI NAND flash pins", data[0:2]
                )
            raise FatalError(
                "Failed to configure SPI NAND flash pins. "
                f"Only got {len(data)} byte response."
            )
        status_bytes = data[1:3]
        if status_bytes[0] != 0:
            raise FatalError.WithResult(
                "Failed to configure SPI NAND flash pins", status_bytes
            )
        status_reg = (val >> 24) & 0xFF
        mfr_id = (val >> 16) & 0xFF
        dev_id = val & 0xFFFF
        prot_reg = data[0]
        # Known/tested NAND JEDEC IDs: (manufacturer, device) → description
        KNOWN_NAND_IDS = {
            (0xEF, 0xAA21): "Winbond W25N01GV (1Gbit)",
        }
        chip_desc = KNOWN_NAND_IDS.get((mfr_id, dev_id))
        if chip_desc:
            log.print(f"Detected NAND chip: {chip_desc}")
        else:
            raise FatalError(
                f"Unrecognized NAND JEDEC ID (mfr={mfr_id:#04x}, dev={dev_id:#06x}).\n"
                f"Only Winbond W25N01GV is supported."
            )
        self.trace(
            f"NAND debug: status={status_reg:#04x}, JEDEC ID: "
            f"mfr={mfr_id:#04x} dev={dev_id:#06x}, prot={prot_reg:#04x}"
        )
        if prot_reg != 0x00:
            log.warning(
                f"NAND protection register is {prot_reg:#04x} (expected 0x00); "
                "program/erase may not persist."
            )

    def read_nand_spare(self, page_number):
        """Read NAND flash spare area for a given page number.

        Returns:
            int: Only the first response word from the stub, which encodes the
            first spare bytes in little-endian order (LSB = byte 0, i.e. the
            bad-block marker).  Callers that want the bad-block marker should
            use ``result & 0xFF``.
        """
        data = self.check_command(
            "read NAND spare",
            self.ESP_CMDS["SPI_NAND_READ_SPARE"],
            struct.pack("<I", page_number),
        )
        return data

    def write_nand_spare(self, page_number, is_bad):
        """Write NAND flash spare area to mark bad blocks.

        Returns:
            int: Only the first response word echoed back by the stub (same
            encoding as :meth:`read_nand_spare`).
        """
        data = self.check_command(
            "write NAND spare",
            self.ESP_CMDS["SPI_NAND_WRITE_SPARE"],
            struct.pack("<IB", page_number, is_bad),
        )
        return data

    def flash_set_parameters(self, size):
        """Tell the ESP bootloader the parameters of the chip

        Corresponds to the "flashchip" data structure that the ROM
        has in RAM.

        'size' is in bytes.

        All other flash parameters are currently hardcoded (on ESP8266
        these are mostly ignored by ROM code, on ESP32 I'm not sure.)
        """
        fl_id = 0
        total_size = size
        block_size = 64 * 1024
        sector_size = 4 * 1024
        page_size = 256
        status_mask = 0xFFFF
        self.check_command(
            "set SPI params",
            self.ESP_CMDS["SPI_SET_PARAMS"],
            struct.pack(
                "<IIIIII",
                fl_id,
                total_size,
                block_size,
                sector_size,
                page_size,
                status_mask,
            ),
        )

    def run_spiflash_command(
        self,
        spiflash_command,
        data=b"",
        read_bits=0,
        addr=None,
        addr_len=0,
        dummy_len=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
        SPI_USR_COMMAND = 1 << 31
        SPI_USR_ADDR = 1 << 30
        SPI_USR_DUMMY = 1 << 29
        SPI_USR_MISO = 1 << 28
        SPI_USR_MOSI = 1 << 27

        # SPI registers, base address differs ESP32* vs 8266
        base = self.SPI_REG_BASE
        SPI_CMD_REG = base + 0x00
        SPI_ADDR_REG = base + 0x04
        SPI_USR_REG = base + self.SPI_USR_OFFS
        SPI_USR1_REG = base + self.SPI_USR1_OFFS
        SPI_USR2_REG = base + self.SPI_USR2_OFFS
        SPI_W0_REG = base + self.SPI_W0_OFFS

        # following two registers are ESP32 and later chips only
        if self.SPI_MOSI_DLEN_OFFS is not None:
            # ESP32 and later chips have a more sophisticated way
            # to set up "user" commands
            def set_data_lengths(mosi_bits, miso_bits):
                SPI_MOSI_DLEN_REG = base + self.SPI_MOSI_DLEN_OFFS
                SPI_MISO_DLEN_REG = base + self.SPI_MISO_DLEN_OFFS
                if mosi_bits > 0:
                    self.write_reg(SPI_MOSI_DLEN_REG, mosi_bits - 1)
                if miso_bits > 0:
                    self.write_reg(SPI_MISO_DLEN_REG, miso_bits - 1)
                flags = 0
                if dummy_len > 0:
                    flags |= dummy_len - 1
                if addr_len > 0:
                    flags |= (addr_len - 1) << SPI_USR_ADDR_LEN_SHIFT
                if flags:
                    self.write_reg(SPI_USR1_REG, flags)

        else:

            def set_data_lengths(mosi_bits, miso_bits):
                SPI_DATA_LEN_REG = SPI_USR1_REG
                SPI_MOSI_BITLEN_S = 17
                SPI_MISO_BITLEN_S = 8
                mosi_mask = 0 if (mosi_bits == 0) else (mosi_bits - 1)
                miso_mask = 0 if (miso_bits == 0) else (miso_bits - 1)
                flags = (miso_mask << SPI_MISO_BITLEN_S) | (
                    mosi_mask << SPI_MOSI_BITLEN_S
                )
                if dummy_len > 0:
                    flags |= dummy_len - 1
                if addr_len > 0:
                    flags |= (addr_len - 1) << SPI_USR_ADDR_LEN_SHIFT
                self.write_reg(SPI_DATA_LEN_REG, flags)

        # SPI peripheral "command" bitmasks for SPI_CMD_REG
        SPI_CMD_USR = 1 << 18

        # shift values
        SPI_USR2_COMMAND_LEN_SHIFT = 28
        SPI_USR_ADDR_LEN_SHIFT = 26

        if read_bits > 32:
            raise FatalError(
                "Reading more than 32 bits back from a SPI flash "
                "operation is unsupported"
            )
        if len(data) > 64:
            raise FatalError(
                "Writing more than 64 bytes of data with one SPI command is unsupported"
            )

        data_bits = len(data) * 8
        old_spi_usr = self.read_reg(SPI_USR_REG)
        old_spi_usr2 = self.read_reg(SPI_USR2_REG)
        flags = SPI_USR_COMMAND
        if read_bits > 0:
            flags |= SPI_USR_MISO
        if data_bits > 0:
            flags |= SPI_USR_MOSI
        if addr_len > 0:
            flags |= SPI_USR_ADDR
        if dummy_len > 0:
            flags |= SPI_USR_DUMMY
        set_data_lengths(data_bits, read_bits)
        self.write_reg(SPI_USR_REG, flags)
        self.write_reg(
            SPI_USR2_REG, (7 << SPI_USR2_COMMAND_LEN_SHIFT) | spiflash_command
        )
        if addr_len > 0:
            if self.SPI_ADDR_REG_MSB:
                addr = addr << (32 - addr_len)
            self.write_reg(SPI_ADDR_REG, addr)
        if data_bits == 0:
            self.write_reg(SPI_W0_REG, 0)  # clear data register before we read it
        else:
            data = pad_to(data, 4, b"\00")  # pad to 32-bit multiple
            words = struct.unpack("I" * (len(data) // 4), data)
            next_reg = SPI_W0_REG
            for word in words:
                self.write_reg(next_reg, word)
                next_reg += 4
        self.write_reg(SPI_CMD_REG, SPI_CMD_USR)

        def wait_done():
            for _ in range(10):
                if (self.read_reg(SPI_CMD_REG) & SPI_CMD_USR) == 0:
                    return
            raise FatalError("SPI command did not complete in time")

        wait_done()

        status = self.read_reg(SPI_W0_REG)
        # restore some SPI controller registers
        self.write_reg(SPI_USR_REG, old_spi_usr)
        self.write_reg(SPI_USR2_REG, old_spi_usr2)
        return status

    def read_spiflash_sfdp(self, addr, read_bits):
        CMD_RDSFDP = 0x5A
        return self.run_spiflash_command(
            CMD_RDSFDP, read_bits=read_bits, addr=addr, addr_len=24, dummy_len=8
        )

    def read_status(self, num_bytes=2):
        """Read up to 24 bits (num_bytes) of SPI flash status register contents
        via RDSR, RDSR2, RDSR3 commands

        Not all SPI flash supports all three commands. The upper 1 or 2
        bytes may be 0xFF.
        """
        SPIFLASH_RDSR = 0x05
        SPIFLASH_RDSR2 = 0x35
        SPIFLASH_RDSR3 = 0x15

        status = 0
        shift = 0
        for cmd in [SPIFLASH_RDSR, SPIFLASH_RDSR2, SPIFLASH_RDSR3][0:num_bytes]:
            status += self.run_spiflash_command(cmd, read_bits=8) << shift
            shift += 8
        return status

    def write_status(self, new_status, num_bytes=2, set_non_volatile=False):
        """Write up to 24 bits (num_bytes) of new status register

        num_bytes can be 1, 2 or 3.

        Not all flash supports the additional commands to write the
        second and third byte of the status register. When writing 2
        bytes, esptool also sends a 16-byte WRSR command (as some
        flash types use this instead of WRSR2.)

        If the set_non_volatile flag is set, non-volatile bits will
        be set as well as volatile ones (WREN used instead of WEVSR).

        """
        SPIFLASH_WRSR = 0x01
        SPIFLASH_WRSR2 = 0x31
        SPIFLASH_WRSR3 = 0x11
        SPIFLASH_WEVSR = 0x50
        SPIFLASH_WREN = 0x06
        SPIFLASH_WRDI = 0x04

        enable_cmd = SPIFLASH_WREN if set_non_volatile else SPIFLASH_WEVSR

        # try using a 16-bit WRSR (not supported by all chips)
        # this may be redundant, but shouldn't hurt
        if num_bytes == 2:
            self.run_spiflash_command(enable_cmd)
            self.run_spiflash_command(SPIFLASH_WRSR, struct.pack("<H", new_status))

        # also try using individual commands
        # (also not supported by all chips for num_bytes 2 & 3)
        for cmd in [SPIFLASH_WRSR, SPIFLASH_WRSR2, SPIFLASH_WRSR3][0:num_bytes]:
            self.run_spiflash_command(enable_cmd)
            self.run_spiflash_command(cmd, struct.pack("B", new_status & 0xFF))
            new_status >>= 8

        self.run_spiflash_command(SPIFLASH_WRDI)

    def get_crystal_freq(self):
        """
        Figure out the crystal frequency from the UART clock divider

        Returns a normalized value in integer MHz (only values 40 or 26 are supported)
        """
        # The logic here is:
        # - We know that our baud rate and the ESP UART baud rate are roughly the same,
        #   or we couldn't communicate
        # - We can read the UART clock divider register to know how the ESP derives this
        #   from the APB bus frequency
        # - Multiplying these two together gives us the bus frequency which is either
        #   the crystal frequency (ESP32) or double the crystal frequency (ESP8266).
        #   See the self.XTAL_CLK_DIVIDER parameter for this factor.
        uart_div = self.read_reg(self.UART_CLKDIV_REG) & self.UART_CLKDIV_MASK
        est_xtal = (self._port.baudrate * uart_div) / 1e6 / self.XTAL_CLK_DIVIDER
        if est_xtal > 45:
            norm_xtal = 48
        elif est_xtal > 33:
            norm_xtal = 40
        else:
            norm_xtal = 26
        if abs(norm_xtal - est_xtal) > 1:
            log.warning(
                f"Detected crystal freq {est_xtal:.2f} MHz is quite different to "
                f"normalized freq {norm_xtal} MHz. Unsupported crystal in use?"
            )

        return norm_xtal

    def hard_reset(self, uses_usb=False):
        log.print("Hard resetting via RTS pin...")
        cfg_custom_hard_reset_sequence = cfg.get("custom_hard_reset_sequence")
        if cfg_custom_hard_reset_sequence is not None:
            CustomReset(self._port, cfg_custom_hard_reset_sequence)()
        else:
            HardReset(
                self._port, uses_usb, flow_control=self.uses_hardware_flow_control()
            )()

    def soft_reset(self, stay_in_bootloader):
        if not self.IS_STUB:
            if stay_in_bootloader:
                return  # ROM bootloader is already in bootloader!
            else:
                # 'run user code' is as close to a soft reset as we can do
                self.flash_begin(0, 0)
                self.flash_finish(False)
        else:
            if stay_in_bootloader:
                # soft resetting from the stub loader
                # will re-load the ROM bootloader
                self.flash_begin(0, 0)
                self.flash_finish(True)
            elif self.CHIP_NAME != "ESP8266":
                raise FatalError(
                    "Soft resetting is currently only supported on ESP8266"
                )
            else:
                # running user code from stub loader requires some hacks
                # in the stub loader
                self.command(self.ESP_CMDS["RUN_USER_CODE"], wait_response=False)

    def watchdog_reset(self):
        log.note(
            f"Watchdog hard reset is not supported on {self.CHIP_NAME}, "
            "attempting classic hard reset instead."
        )
        self.hard_reset()


class StubMixin:
    """
    Mixin class bundling the stub loader-specific properties.
    Not intended for direct instantiation.
    A child class (e.g. ESP32StubLoader) uses multiple inheritance
    to combine this mixin class (StubMixin) with a parent class (e.g. ESP32ROM).
    """

    FLASH_WRITE_SIZE = 0x4000  # Default value, can be overridden
    IS_STUB = True

    def __init__(self, rom_loader):
        self.secure_download_mode = rom_loader.secure_download_mode
        self._port = rom_loader._port
        self._trace_enabled = rom_loader._trace_enabled
        self.cache = rom_loader.cache
        self.flush_input()  # resets _slip_reader

    def stub_json_name(self):
        chip_name = strip_chip_name(self.CHIP_NAME)
        return f"{chip_name}.json"


def slip_reader(port, trace_function):
    """Generator to read SLIP packets from a serial port.
    Yields one full SLIP packet at a time, raises exception on timeout or invalid data.

    Designed to avoid too many calls to serial.read(1), which can bog
    down on slow systems.
    """

    def detect_panic_handler(input):
        """
        Checks the input bytes for panic handler messages.
        Raises a FatalError if Guru Meditation or Fatal Exception is found, as both
        of these are used between different ROM versions.
        Tries to also parse the error cause (e.g. IllegalInstruction).
        """

        guru_meditation = (
            rb"G?uru Meditation Error: (?:Core \d panic'ed \(([a-zA-Z ]*)\))?"
        )
        fatal_exception = rb"F?atal exception \(\d+\): (?:([a-zA-Z ]*)?.*epc)?"

        # Search either for Guru Meditation or Fatal Exception
        data = re.search(
            rb"".join([rb"(?:", guru_meditation, rb"|", fatal_exception, rb")"]),
            input,
            re.DOTALL,
        )
        if data is not None:
            cause = [
                "({})".format(i.decode("utf-8"))
                for i in [data.group(1), data.group(2)]
                if i is not None
            ]
            cause = f" {cause[0]}" if len(cause) else ""
            msg = f"Guru Meditation Error detected{cause}."
            raise FatalError(msg)

    partial_packet = None
    in_escape = False
    successful_slip = False
    while True:
        waiting = port.inWaiting()
        read_bytes = port.read(1 if waiting == 0 else waiting)
        if read_bytes == b"":
            if partial_packet is None:  # fail due to no data
                msg = (
                    "Serial data stream stopped: Possible serial noise or corruption."
                    if successful_slip
                    else "No serial data received."
                )
            else:  # fail during packet transfer
                msg = "Packet content transfer stopped "
                f"(received {len(partial_packet)} bytes)."
            trace_function(msg)
            raise FatalError(msg)
        trace_function(
            f"{f'Read {len(read_bytes)} bytes:':<21} {HexFormatter(read_bytes)}"
        )
        for b in read_bytes:
            b = bytes([b])
            if partial_packet is None:  # waiting for packet header
                if b == b"\xc0":
                    partial_packet = b""
                else:
                    trace_function(f"Read invalid data: {HexFormatter(read_bytes)}")
                    remaining_data = port.read(port.inWaiting())
                    trace_function(
                        "Remaining data in serial buffer: "
                        f"{HexFormatter(remaining_data)}",
                    )
                    detect_panic_handler(read_bytes + remaining_data)
                    raise FatalError(
                        f"Invalid head of packet (0x{hexify(b)}): "
                        "Possible serial noise or corruption."
                    )
            elif in_escape:  # part-way through escape sequence
                in_escape = False
                if b == b"\xdc":
                    partial_packet += b"\xc0"
                elif b == b"\xdd":
                    partial_packet += b"\xdb"
                else:
                    trace_function(f"Read invalid data: {HexFormatter(read_bytes)}")
                    remaining_data = port.read(port.inWaiting())
                    trace_function(
                        "Remaining data in serial buffer: "
                        f"{HexFormatter(remaining_data)}"
                    )
                    detect_panic_handler(read_bytes + remaining_data)
                    raise FatalError(f"Invalid SLIP escape (0xdb, 0x{hexify(b)}).")
            elif b == b"\xdb":  # start of escape sequence
                in_escape = True
            elif b == b"\xc0":  # end of packet
                trace_function(f"Received full packet: {HexFormatter(partial_packet)}")
                yield partial_packet
                partial_packet = None
                successful_slip = True
            else:  # normal byte in packet
                partial_packet += b


class HexFormatter:
    """
    Wrapper class which takes binary data in its constructor
    and returns a hex string as it's __str__ method.

    This is intended for "lazy formatting" of trace() output
    in hex format. Avoids overhead (significant on slow computers)
    of generating long hex strings even if tracing is disabled.

    Note that this doesn't save any overhead if passed as an
    argument to "%", only when passed to trace()

    If auto_split is set (default), any long line (> 16 bytes) will be
    printed as separately indented lines, with ASCII decoding at the end
    of each line.
    """

    def __init__(self, binary_string, auto_split=True):
        self._s = binary_string
        self._auto_split = auto_split

    def __str__(self):
        if self._auto_split and len(self._s) > 16:
            result = ""
            s = self._s
            while len(s) > 0:
                line = s[:16]
                ascii_line = "".join(
                    (
                        c
                        if (
                            c == " "
                            or (c in string.printable and c not in string.whitespace)
                        )
                        else "."
                    )
                    for c in line.decode("ascii", "replace")
                )
                s = s[16:]
                result += (
                    f"\n    {hexify(line[:8], False):<16s} "
                    f"{hexify(line[8:], False):<16s} | {ascii_line}"
                )
            return result
        else:
            return hexify(self._s, False)
