Metadata-Version: 2.4
Name: esp32-mock-bootloader
Version: 0.3.0
Summary: Mock ESP32 ROM bootloader (SLIP) for CI and local upload testing without hardware
Project-URL: Homepage, https://github.com/lucasssvaz/esp32-mock-bootloader
Project-URL: Documentation, https://github.com/lucasssvaz/esp32-mock-bootloader#readme
Project-URL: Repository, https://github.com/lucasssvaz/esp32-mock-bootloader
Project-URL: Issues, https://github.com/lucasssvaz/esp32-mock-bootloader/issues
Author: Lucas Saavedra Vaz
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: bootloader,ci,embedded,esp32,esptool,slip
Classifier: Development Status :: 3 - Alpha
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Embedded Systems
Classifier: Topic :: System :: Emulators
Requires-Python: >=3.9
Requires-Dist: esptool
Provides-Extra: dev
Requires-Dist: coverage>=7.10.6; extra == 'dev'
Requires-Dist: hatch; extra == 'dev'
Requires-Dist: hatch-vcs; extra == 'dev'
Requires-Dist: pyserial>=3.5; extra == 'dev'
Requires-Dist: pytest-cov>=7; extra == 'dev'
Requires-Dist: pytest-xdist>=3; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Provides-Extra: test
Requires-Dist: coverage>=7.10.6; extra == 'test'
Requires-Dist: pyserial>=3.5; extra == 'test'
Requires-Dist: pytest-cov>=7; extra == 'test'
Requires-Dist: pytest-xdist>=3; extra == 'test'
Requires-Dist: pytest>=8; extra == 'test'
Description-Content-Type: text/markdown

# esp32-mock-bootloader

A mock Espressif ROM bootloader for testing firmware uploads **without a board**.

Run it on your machine or in CI. Point **esptool**, **arduino-cli**, or any ROM-compatible flasher at a TCP port or serial path, and flash as if a chip were connected.

[![CI](https://github.com/lucasssvaz/esp32-mock-bootloader/actions/workflows/ci.yml/badge.svg)](https://github.com/lucasssvaz/esp32-mock-bootloader/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/esp32-mock-bootloader)](https://pypi.org/project/esp32-mock-bootloader/)
[![Python](https://img.shields.io/pypi/pyversions/esp32-mock-bootloader)](https://pypi.org/project/esp32-mock-bootloader/)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)

> **Stability:** version `0.x` is alpha. CLI flags and protocol details may change before `1.0.0`.

## Table of contents

- [Overview](#overview)
- [Features](#features)
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick start](#quick-start)
- [Usage](#usage)
  - [CLI commands](#cli-commands)
  - [Daemon lifecycle](#daemon-lifecycle)
  - [Chip selection](#chip-selection)
  - [Supported chips](#supported-chips)
- [Transports](#transports)
  - [TCP (default)](#tcp-default)
  - [PTY / serial path](#pty--serial-path)
  - [Windows with com0com](#windows-with-com0com)
- [GitHub Actions](#github-actions)
  - [Client examples](#client-examples)
  - [Python API](#python-api)
  - [How it works](#how-it-works)
- [Protocol references](#protocol-references)
- [Limitations](#limitations)
- [Development](#development)
- [Project layout](#project-layout)
- [AI disclosure](#ai-disclosure)
- [License](#license)

## Overview

Real Espressif chips expose a **ROM bootloader** over serial. Upload tools speak a SLIP-framed binary protocol: sync, detect the SoC, write flash blocks, verify MD5, and so on.

**esp32-mock-bootloader** implements enough of that protocol for upload clients to complete a full flash cycle. It does **not** run your firmware or emulate peripherals — it only answers the bootloader conversation.

```
  esptool / arduino-cli / …
           │
           ▼
  socket://127.0.0.1:PORT   or   /dev/tty* / COM*
           │
           ▼
  esp32-mock-bootloader  (SLIP server)
           │
           ▼
  ACK + chip metadata + in-memory flash image
```

Chip metadata comes from the installed **[esptool](https://github.com/espressif/esptool)** package. When Espressif adds a new SoC to esptool, this mock can support it without a code change here.

For protocol details and authoritative behavior, see [Protocol references](#protocol-references).

## Features

- **No hardware** — run upload tests locally and in CI.
- **All esptool SoCs** — profiles are built from `esptool.targets.CHIP_DEFS`.
- **Auto chip detection** — `--chip auto` learns the SoC from client traffic.
- **TCP daemon** — background `start` / `stop` with stable `socket://` URLs.
- **PTY and COM paths** — Unix PTY, Windows com0com pairs, or socket fallback.
- **GitHub Action** — one step to start the mock; teardown runs automatically.
- **CI-ready** — tested on Ubuntu, Windows, and macOS with esptool integration tests.

## Requirements

| Component | Version |
|-----------|---------|
| Python | 3.9 or newer |
| esptool | Installed automatically with this package (runtime dependency) |
| Upload client | e.g. pip `esptool`, or arduino-cli with a client that supports your transport |

Optional:

- **com0com** on Windows — for real `COMx` ports in local testing ([setup guide](#windows-with-com0com)).

## Installation

From PyPI:

```bash
pip install esp32-mock-bootloader
```

Pin a release:

```bash
pip install esp32-mock-bootloader==0.1.0
```

From source (development):

```bash
git clone https://github.com/lucasssvaz/esp32-mock-bootloader.git
cd esp32-mock-bootloader
pip install -e ".[dev]"
```

## Quick start

```bash
# 1. Start the mock (background daemon on port 9876)
esp32-mock-bootloader start

# 2. Flash through it
esptool --chip esp32 \
  --port "$(esp32-mock-bootloader url)" \
  write-flash 0x10000 firmware.bin

# 3. Stop when done
esp32-mock-bootloader stop
```

The daemon keeps running between steps 1 and 3. Use `status` to inspect it, `url` for the full `socket://` address, or `port` for the port number alone.

## Usage

### CLI commands

| Command | Description |
|---------|-------------|
| `start` | Start the background daemon and exit once the port is ready |
| `stop` | Stops the only running instance by default; `--port PORT` for one; `--port all` for every instance |
| `status` | Auto-picks the only running instance; lists all when several are running; `--port PORT` or `--port all` |
| `url` | Prints one `socket://` URL (auto-pick); tab-separated list when several; `--port PORT` or `--port all` |
| `port` | Prints one TCP port (auto-pick); one per line when several; `--port PORT` or `--port all` |
| `erase-flash` | Erases the only running instance by default; `--port PORT` or `--port all` |
| `chips` | List SoCs supported by the installed esptool |
| `run` | Run the server in the foreground (used internally; prefer `start`) |

Common flags for `start` and `run`:

| Flag | Default | Description |
|------|---------|-------------|
| `--chip` | `auto` | Chip profile, or `auto` to detect from client traffic |
| `--port` | `9876` | TCP listen port (`run` / `start`); with `run --pty` in null-modem mode, the **upload client** serial port (e.g. `COM19`) — same port you pass to esptool. Falls back to OS-assigned port if taken. |
| `--serial-bind` | — | `run --pty` only: mock-side port in a null-modem pair; auto-detected from com0com when only `--port` is set |
| `--pty` | off | `run` only: serial path mode (Unix PTY, null-modem COM, or Windows socket fallback) |
| `--bind` | `127.0.0.1` | Bind address |
| `--startup-timeout` | `30` | Seconds `start` waits for the port (`start` only) |
| `--force` | off | Stop an existing daemon on the same port first (`start` only) |
| `--exit-on-disconnect` | off | Exit after the first client disconnects on any transport (`run`; test TCP helpers enable this) |
| `--timeout` | none | Exit after N seconds (`run` only) |

`status --json` adds machine-readable output. With several running instances (or `--port all`), JSON is `{"instances": [...]}`; otherwise a single status object.

`status`, `url`, `port`, `erase-flash`, and `stop` share `--port` semantics: omit it to auto-pick (one instance behaves like before; several are listed or all stopped/erased), pass a number for one instance, or pass `all` to always target every running instance.

When no daemon is running and you omit `--port`, query commands fall back to port `9876` (the `start` default) so `$(esp32-mock-bootloader url)` keeps working in single-daemon CI scripts.

### Daemon lifecycle

Runtime files live under the OS temp directory (`$TMPDIR/esp32-mock-bootloader/` on macOS/Linux, `%TEMP%\esp32-mock-bootloader\` on Windows). A single `registry.json` tracks every running daemon and foreground `run` process:

```json
{
  "version": 1,
  "instances": {
    "9876": {
      "pid": 12345,
      "port": 9876,
      "chip": "esp32",
      "bind": "127.0.0.1",
      "url": "socket://127.0.0.1:9876",
      "log_file": "/tmp/esp32-mock-bootloader/port-9876.log",
      "detected_chip": "esp32",
      "mode": "daemon"
    }
  }
}
```

Per-port logs are written alongside the registry. Stale entries are pruned automatically when a process is no longer running. `detected_chip` is filled after a client identifies the SoC (in `auto` mode).

### Chip selection

esptool’s `--chip` flag is **client-side only** — it is never sent over the serial link. The mock cannot read it. Choose the mock profile to match how your upload client selects the SoC:

| Mock `--chip` | Client | Connect fidelity |
|---------------|--------|------------------|
| `esp32`, `esp32c3`, … | `esptool --chip <same>` | Full ROM profile (MAC, crystal, security-info) — **recommended for CI** |
| `esp32`, `esp32c3`, … | `esptool --chip auto` | esptool autodetects from ROM probes against the fixed mock profile |
| `auto` | `esptool --chip auto` | Mock learns the SoC from esptool’s standard detection probes |
| `auto` | `esptool --chip <explicit>` | Upload usually works; connect may warn until chip-specific registers are read |

**Recommended CI pattern** — one virtual board per job, matching chips:

```bash
esp32-mock-bootloader start --chip esp32c3
esptool --chip esp32c3 --port "$(esp32-mock-bootloader url)" flash-id
```

Or let esptool autodetect against a fixed mock profile:

```bash
esp32-mock-bootloader start --chip esp32c3
esptool --chip auto --port "$(esp32-mock-bootloader url)" flash-id
```

Use mock `--chip auto` when the client also uses `--chip auto`, or when you need the mock to learn the SoC from chip-specific register traffic (for example multi-SoC protocol tests).

In `auto` mode the mock:

1. Returns a ROM-style error on `GET_SECURITY_INFO` until a SoC is known.
2. Returns `0` for the legacy probe at `0x40001000` until chip-specific registers identify the SoC.
3. Sets `detected_chip` from unique detect registers or efuse windows (addresses from esptool ROM classes).

**ROM profile:** For explicit chip modes (and after auto detection), `READ_REG` returns a sparse set of register values derived from esptool ROM classes — synthetic MAC, crystal calibration (`UART_CLKDIV`, ESP32 `RTCCALICFG1`), and security-off defaults. This is not a full efuse block emulator; see [espefuse](#espefuse).

#### Synthetic MAC

esptool's `flash-id` prints a MAC decoded from efuse/OTP registers. The mock fills those registers with a **synthetic BASE_MAC** so the line is non-zero and passes each chip family's `read_mac()` logic. No particular address is required for protocol correctness.

| Property | Value |
|----------|-------|
| OUI | `24:0A:C4` (Espressif; not a real burned address on the mock) |
| Host suffix | First 3 bytes of `SHA256("<chip>")`, e.g. `esp32` → `e2:95:26` |
| Stability | Same `--chip` always yields the same MAC across runs |
| Uniqueness | Different SoC names get different suffixes (helps multi-chip CI logs) |

Examples: `esp32` → `24:0a:c4:e2:95:26`, `esp32c3` → `24:0a:c4:1f:67:7d`, `esp8266` → `24:0a:c4:ce:2b:c1`.

To compute the expected MAC in a test: `registers.mac_bytes_for_chip("esp32c3")` or the formula above. A single fixed MAC for all chips would also work with esptool; the per-chip suffix is a readability choice, not a hardware requirement.

### Supported chips

Every target in the installed esptool `CHIP_DEFS` is available:

```bash
esp32-mock-bootloader chips
esp32-mock-bootloader chips --json   # detect registers and chip_id metadata
```

New esptool releases can add SoCs without updating this package.

## Transports

### TCP (default)

The daemon listens on `127.0.0.1:PORT`. Pip-installed esptool accepts `socket://` URLs via pyserial.

```bash
esp32-mock-bootloader start --port 9876
esptool --chip esp32c6 --port socket://127.0.0.1:9876 write-flash 0x10000 app.bin
```

### VID/PID messages

esptool reads USB vendor/product IDs only from real USB serial devices (Espressif VID `0x303A`). Virtual transports cannot provide those descriptors:

| Transport | Typical esptool message | Fixable by mock? |
|-----------|-------------------------|------------------|
| `socket://` | `Device VID/PID identification is only supported on COM and /dev/ serial ports.` | No — document only |
| Unix PTY (`/dev/ttys…`) | `Failed to get VID/PID of a device on /dev/ttys…` | No — PTY is not a USB device |
| Windows com0com (`COMx`) | Same `Failed to get VID/PID` — virtual pairs have no USB descriptors | No |

These messages are harmless for upload tests. Only a physical Espressif USB-Serial/JTAG adapter silences them.

### PTY / serial path

Use `--pty` with `run` when a tool expects a **device path** instead of a URL (common with arduino-cli):

```bash
esp32-mock-bootloader run --pty --chip esp32
# The PTY path is printed to stdout; use --port-file to write it to a file:
esp32-mock-bootloader run --pty --port-file /tmp/mock-pty --chip esp32
esptool --chip esp32 --port "$(cat /tmp/mock-pty)" write-flash 0x10000 firmware.bin
```

| Platform | `--pty` provides |
|----------|------------------|
| Linux / macOS | Real PTY device (e.g. `/dev/ttys003`) |
| Windows (local) | com0com virtual COM pair |
| Windows (CI) | `socket://127.0.0.1:PORT` fallback when no COM pair is configured |

#### PTY vs COM

Both modes expose a **serial device path** to the client, but they create that path differently:

| | **PTY** (Unix default) | **Null-modem serial** (`--port` / `--serial-bind`) |
|--|------------------------|---------------------------------------|
| **What it is** | Kernel pseudo-terminal pair created by the mock | Two ends of an existing serial device (virtual or physical) |
| **Server I/O** | Master side of the PTY (`PtyMasterTransport`) | pyserial on `--serial-bind` |
| **Client path** | Slave device (e.g. `/dev/ttys003`) | `--port` (e.g. `COM19`); written to `--port-file` |
| **Typical platform** | Linux / macOS | Windows (com0com); Linux with `socat` loopback |
| **Needs extra software** | No | Yes on Windows (com0com); paired ports on Linux |

**Null-modem mode is not Windows-only.** Any path pyserial can open works on any OS. Unix CI defaults to PTY because the kernel provides a free pair with no setup.

On Windows with com0com you usually pass only **`--port COM19`** (the upload port). The mock looks up the paired port via `setupc` and binds there automatically. Pass **`--serial-bind COM18`** as well when you want to name both ends explicitly.

Legacy env vars `ESP32_MOCK_COM_PORT` / `ESP32_MOCK_COM_PEER` still work; prefer `ESP32_MOCK_SERIAL_BIND` and `ESP32_MOCK_PORT`.

### Windows with com0com

Install [com0com](https://sourceforge.net/projects/com0com/). The mock binds the paired port; **`--port`** is what esptool uses (written to the path file):

```bat
esp32-mock-bootloader run --pty --port-file mock.port ^
  --port COM19 --chip esp32
esptool --chip esp32 --port COM19 write-flash 0x10000 firmware.bin
```

To name both ends explicitly:

```bat
esp32-mock-bootloader run --pty --port-file mock.port ^
  --serial-bind COM18 --port COM19 --chip esp32
```

Expect `Failed to get VID/PID` on com0com ports (see [VID/PID messages](#vidpid-messages)).

Environment variables `ESP32_MOCK_SERIAL_BIND` and `ESP32_MOCK_PORT` work the same as `--serial-bind` / `--port`. Legacy `ESP32_MOCK_COM_PORT` / `ESP32_MOCK_COM_PEER` are still read.

**Helper script** (elevated prompt; creates a pair, runs esptool, removes the pair):

```bat
python scripts\test_windows_com.py
```

Set `ESP32_MOCK_KEEP_COM_PAIR=1` to leave the pair installed after the script exits.


## GitHub Actions

The action starts the daemon in the main step and **stops it in a post step** when the job ends — including on failure. No manual `stop` required.

```yaml
jobs:
  upload-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Mock bootloader
        uses: lucasssvaz/esp32-mock-bootloader@v0.1.0
        id: mock

      - name: Flash test firmware
        run: |
          pip install esptool
          esptool --chip esp32 \
            --port "${{ steps.mock.outputs.url }}" \
            --no-stub --before no-reset --after no-reset \
            write-flash 0x10000 firmware.bin
```

**Outputs:** `url` (`socket://…`), `port`.

**Inputs** (all optional):

| Input | Default | Description |
|-------|---------|-------------|
| `chip` | `auto` | Chip profile |
| `port` | `9876` | TCP port |
| `startup-timeout` | `30` | Startup wait in seconds |
| `python-version` | `3.x` | Python for `setup-python` |
| `version` | *(empty)* | PyPI pin (e.g. `0.1.0`); omit to install from the action checkout |

**Manual CLI in a workflow** (without the action):

```yaml
- run: pip install esp32-mock-bootloader esptool
- run: esp32-mock-bootloader start
- run: |
    esptool --chip esp32 --port "$(esp32-mock-bootloader url)" \
      write-flash 0x10000 firmware.bin
- run: esp32-mock-bootloader stop
  if: always()
```

## Client examples

**esptool** (TCP, no stub — typical for CI):

```bash
esptool --chip esp32c6 \
  --port socket://127.0.0.1:9876 \
  --no-stub --before no-reset --after no-reset \
  write-flash 0x10000 app.bin
```

**arduino-cli:**

```bash
arduino-cli compile -b esp32:esp32:esp32 \
  -p socket://127.0.0.1:9876 --upload sketch.ino
```

**Shell CI script:**

```bash
esp32-mock-bootloader start
PORT="$(esp32-mock-bootloader url)"
# run your upload tests against "$PORT"
esp32-mock-bootloader stop
```

## Python API

The public surface is intentionally small: start a mock, read its endpoint, optionally query or stop running instances. Upload clients (esptool, arduino-cli) connect to `handle.url()` — the mock never runs uploads for you.

**Exports:** `mock_bootloader`, `MockHandle`, `instances`, `__version__`

### Style A — context manager (single instance, auto cleanup)

```python
import subprocess
from esp32_mock_bootloader import mock_bootloader

with mock_bootloader(chip="esp32") as mock:
    subprocess.run(["esptool", "--chip", "esp32", "--port", mock.url(), "write-flash", ...])
```

### Style B — imperative (multiple instances)

```python
from esp32_mock_bootloader import mock_bootloader

server_a = mock_bootloader(chip="esp32")
server_b = mock_bootloader(chip="esp32c3")
try:
    # point esptool / arduino-cli at server_a.url() and server_b.url()
    ...
finally:
    server_b.stop()
    server_a.stop()
```

### `instances` — CLI-parity operations

Same verbs as `esp32-mock-bootloader status|url|port|stop|erase-flash`:

```python
from esp32_mock_bootloader import mock_bootloader, instances

server_a = mock_bootloader(chip="esp32")
server_b = mock_bootloader(chip="esp32c3")

print(instances.status(format="text"))   # table of all running mocks
rows = instances.status()              # list[dict] when multiple
urls = instances.url(port="all")
instances.erase_flash(port=server_a.port())
instances.stop(port="all")
```

Each `MockHandle` exposes the same verbs scoped to that server: `server_a.url()`, `server_a.status()`, `server_a.erase_flash()`, `server_a.stop()`, etc.

Default mode for ``mock_bootloader()`` is **foreground** (subprocess server; stops when the handle is destroyed or the ``with`` block ends). Use ``mode="daemon"`` for a background daemon like ``esp32-mock-bootloader start``.

### Advanced (opt-in)

Protocol testing and raw transports live under `esp32_mock_bootloader.advanced`:

```python
from esp32_mock_bootloader import mock_bootloader
from esp32_mock_bootloader.advanced import protocol

server = mock_bootloader(chip="esp32")
try:
    client = protocol.connect(server)
    client.send_command(cmd, data)
finally:
    server.stop()
```

Also exported: `transport`, `process`, `protocol_client`, `constants`. Chip metadata: `from esp32_mock_bootloader import chips`.

Runnable scripts with comments live under [`examples/`](examples/README.md) (basic upload patterns and `esp32_mock_bootloader.advanced` protocol examples).

Reference constants (`FLASH_APP_OFFSET`, `SYNC_PAYLOAD`, …) live in `esp32_mock_bootloader.constants`.

## How it works

The mock speaks SLIP-framed ROM commands. Implemented handlers include:

`SYNC`, `FLASH_BEGIN` / `DATA` / `END`, `FLASH_DEFL_*`, `MEM_*` (+ OHAI after `MEM_END`), `READ_REG`, `WRITE_REG` (with mask, readable via `READ_REG`), `GET_SECURITY_INFO`, `SPI_SET_PARAMS`, `SPI_ATTACH`, `CHANGE_BAUDRATE`, `SPI_FLASH_MD5`, `READ_FLASH_SLOW` (ROM), and stub-only `ERASE_FLASH`, `ERASE_REGION`, `READ_FLASH` (streaming + MD5), `RUN_USER_CODE`. `FLASH_DATA`, `MEM_DATA`, and `FLASH_DEFL_DATA` validate the 0xEF XOR checksum (ROM error `0x07`, stub error `0xC1`). Unknown commands return ROM error `0x05` or stub error `0xFF`.

Flash data is stored in an in-memory image (erased bytes default to `0xFF`). After a stub upload (`MEM_END` with entrypoint + `OHAI`), `SPI_FLASH_MD5` returns a **16-byte binary** digest; in ROM mode it returns **32-byte lowercase hex ASCII** — matching esptool’s `flash_md5sum()` expectations. Unknown commands receive a generic ACK.

The mock validates **protocol behavior**, not silicon accuracy. It does not model Wi-Fi, sleep, brownout, or real flash timing.

## Protocol references

Implementation follows Espressif’s published bootloader protocol and the **esptool** reference client. Primary sources:

| Topic | Reference |
|-------|-----------|
| Serial protocol overview | [esptool serial protocol (ESP32)](https://docs.espressif.com/projects/esptool/en/latest/esp32/advanced-topics/serial-protocol.html) — same command set is documented per chip under `esptool/en/latest/<chip>/advanced-topics/serial-protocol.html` |
| Flash upload & MD5 verify | [Verifying uploaded data](https://docs.espressif.com/projects/esptool/en/latest/esp32/advanced-topics/serial-protocol.html#verifying-uploaded-data) — ROM returns 32 hex ASCII bytes; stub returns 16 raw MD5 bytes before the 2 status bytes |
| `SPI_FLASH_MD5` (`0x13`) | [Commands table](https://docs.espressif.com/projects/esptool/en/latest/esp32/advanced-topics/serial-protocol.html#supported-by-stub-loader-and-rom-loader) |
| Stub upload & `OHAI` | [Functional description — initialization](https://docs.espressif.com/projects/esptool/en/latest/esp32/advanced-topics/serial-protocol.html#functional-description) — `MEM_END` entrypoint, unsolicited `OHAI` SLIP packet |
| Client MD5 handling | [esptool `loader.py` — `flash_md5sum`](https://github.com/espressif/esptool/blob/master/esptool/loader.py) — `RESP_DATA_LEN` 32 (ROM) vs `RESP_DATA_LEN_STUB` 16 (stub); status bytes follow the digest |
| Stub lifecycle | [esptool `loader.py` — `run_stub`](https://github.com/espressif/esptool/blob/master/esptool/loader.py) — RAM download via `MEM_*`, then `mem_finish(entry)` |
| Chip profiles & detection | [esptool `targets` / `CHIP_DEFS`](https://github.com/espressif/esptool/tree/master/esptool/targets) — register addresses, magic values, security info |

**Integration tests** in downstream projects (e.g. [arduino-esp32 upload tests](https://github.com/espressif/arduino-esp32/blob/master/.github/workflows/upload-tests.yml)) exercise the default **stub** path (`flasher.py` / esptool without `--no-stub`). CI examples in this repo that pass `--no-stub` target the ROM MD5 format explicitly.

## Limitations

- **Protocol emulator only** — no application code runs on the mock.
- **Client packaging matters** — some bundled esptool builds lack `socket://`; use PTY/COM or pip esptool.
- **com0com is local** — GitHub-hosted Windows runners use the socket fallback automatically.
- **VID/PID noise on virtual ports** — socket, PTY, and com0com cannot expose Espressif USB descriptors; esptool prints informational VID/PID messages (see [Transports](#transports)).
- **Alpha API** — expect changes before `1.0.0`.

### espefuse

This mock targets **esptool** ROM upload clients (`write-flash`, `flash-id`, stub upload). It is **not** an efuse programmer.

| Tool / mode | Use with mock? |
|-------------|----------------|
| `espefuse --virt` | **Yes** — in-process efuse emulation for host-side tests (no serial port) |
| `espefuse --port …` read/burn commands | **No** — requires on-chip efuse controller `WRITE_REG` sequences and persistent burned state |

After connect, the mock exposes a **sparse ROM profile** (MAC, crystal registers, security-off defaults) so esptool connect paths behave plausibly. That is not espefuse field parity:

| espefuse command | Mock support |
|------------------|--------------|
| `summary` | Partial / best-effort only — most named fields stay at defaults |
| `dump`, `adc-info`, `check-error` | No — unmapped addresses read as `0` |
| `burn-*`, `read-protect-efuse`, `write-protect-efuse` | No — permanently out of scope unless a full efuse controller emulator is added |

Supported chips follow installed esptool `CHIP_DEFS`. [espefuse supported chips](https://github.com/espressif/esptool/tree/master/espefuse) are a subset (no ESP8266).

## Development

See [CONTRIBUTING.md](CONTRIBUTING.md) for pull request guidelines and AI disclosure expectations.

```bash
git clone https://github.com/lucasssvaz/esp32-mock-bootloader.git
cd esp32-mock-bootloader
pip install -e ".[dev]"
```

**Run tests:**

```bash
pytest                                      # parallel by default (pytest-xdist)
pytest -n0                                  # single process (debugging)
pytest -m "not esptool"                     # protocol unit tests only
pytest -m "not transport"                     # skip TCP/PTY integration
pytest tests/test_protocol.py -m transport  # TCP transport smoke only
pytest tests/test_process.py                # process.py + transport.py (advanced)
pytest tests/test_com0com.py                # com0com unit tests
```

CI runs the full suite on Ubuntu, Windows, and macOS (parallel via pytest-xdist), enforces coverage baselines on Ubuntu, and verifies parallel subprocess coverage with `scripts/verify_parallel_coverage.py`.

**com0com testing tiers:**

1. **CI (all OS)** — `tests/test_com0com.py` uses `tests/fixtures/fake_setupc.py` (no driver install).
2. **Windows CI** — `test_windows_com0com_esptool` skips when setupc is missing or not elevated.
3. **Local Windows** — install com0com, run elevated: `pytest -m com0com` or `scripts/test_windows_com.py`.

```bash
pytest -m com0com    # optional real com0com integration (Windows + admin)
```

**Coverage** (parallel + subprocess children; see [`reports/README.md`](reports/README.md)):

```bash
pytest -n auto --cov-config=pyproject.toml \
  --cov=esp32_mock_bootloader \
  --cov-report=term-missing \
  --cov-report=html:reports/htmlcov \
  --cov-report=xml:reports/coverage.xml
python scripts/verify_parallel_coverage.py
python scripts/check_coverage.py
```

**Build a release wheel:**

```bash
hatch build
```

## Project layout

```
esp32-mock-bootloader/
├── CONTRIBUTING.md              # PR guidelines and AI policy
├── src/esp32_mock_bootloader/   # Python package (api, CLI, daemon, SLIP server)
│   ├── registry.py              # Registry (multi-instance coordination)
│   ├── session.py               # Internal Session / SessionGroup lifecycle
│   ├── client.py                # Protocol Client (bound to Session)
│   ├── api.py                   # mock_bootloader() and MockHandle
│   ├── instances.py             # CLI-parity status/url/port/stop/erase_flash
│   ├── advanced/                # Opt-in protocol.connect, transport, process, constants
│   ├── constants.py             # Protocol/layout reference values
│   ├── transport.py             # TCP / PTY / serial client connections
│   ├── process.py               # Subprocess spawn and teardown helpers
│   ├── protocol_client.py       # SLIP client helpers
│   ├── chips.py                 # Chip profiles from esptool
│   ├── registers.py             # Sparse ROM register profile for esptool fidelity
│   ├── protocol.py              # Protocol constants
│   └── server.py                # SLIP server (advanced)
├── examples/                    # Runnable basic + advanced usage examples
├── action/                      # Node.js steps for the GitHub Action
├── action.yml                   # Composite action entry point
├── tests/                       # pytest suite (protocol, esptool, transports)
├── scripts/                     # Coverage checker, Windows COM helper
├── reports/                     # Coverage config and baselines
└── .github/workflows/           # CI and release pipelines
```

## AI disclosure

This repository was developed with help from AI coding assistants. Every change is reviewed and tested by a human maintainer before merge or release.

Contributor expectations (disclosure, review, commit trailers) are in [CONTRIBUTING.md](CONTRIBUTING.md#ai-assisted-contributions).

## License

Copyright 2026 Lucas Saavedra Vaz. Released under the [Apache-2.0](LICENSE) license.
