---
myst:
  html_meta:
    description: "Build the openbricks MicroPython firmware from source with ESP-IDF, flash it to ESP32/ESP32-S3, and run the test suite against the unix MicroPython port."
---

# Building the firmware

openbricks is a custom MicroPython firmware. Users flash the resulting image to their MCU; the openbricks Python package is frozen into the image and the motor-control hot path (scheduler, trajectory, observer, servo, drivebase) lives in a compiled C extension.

See `native/README.md` for the directory layout.

## One-time setup

### 1. Initialise all submodules

```
git submodule update --init --recursive
```

The MicroPython source tracks a master commit under `native/micropython/`. Bumps should be deliberate and tested on real hardware — `motor_process.c` and the other native modules assume a specific MP ABI. The current pin is documented in `.gitmodules` / `native/micropython` HEAD.

### 2. Install the target toolchain

#### ESP32 / ESP32-S3 (current targets)

Install **ESP-IDF v5.5.4** (or newer in the 5.5 line). MicroPython master supports ESP-IDF 5.3 through 5.5; we recommend 5.5.4 because that's what CI builds against and what the maintainer tests on hardware.

```
mkdir -p ~/esp && cd ~/esp
git clone -b v5.5.4 --recursive https://github.com/espressif/esp-idf.git esp-idf-v5.5
cd esp-idf-v5.5 && ./install.sh esp32,esp32s3
source export.sh          # sets $IDF_PATH; needs to happen in every shell
```

Verify:

```
echo $IDF_PATH       # /Users/<you>/esp/esp-idf-v5.5
idf.py --version     # ESP-IDF v5.5.4
```

> **Note.** If you're running an older MicroPython pin (e.g. v1.28.0), you'll need ESP-IDF v5.2.x — v1.28 pre-dates the MP commits that add 5.4+ support. Check `git -C native/micropython log --oneline -1` first.

## Building

From the repo root:

```
./scripts/build_firmware.sh esp32      # original ESP32 (Xtensa LX6)
./scripts/build_firmware.sh esp32s3    # ESP32-S3  (Xtensa LX7, native USB)
```

The script checks `native/micropython` is populated and `$IDF_PATH` is set, then builds `mpy-cross` once and produces the image for the selected target with `BOARD=openbricks_<target>` and `USER_C_MODULES=$(pwd)/native/user_c_modules`.

Output tree: `native/micropython/ports/esp32/build-openbricks_<target>/`

| File | Purpose |
|---|---|
| `firmware.bin` | complete flash image (everything below combined) |
| `bootloader/bootloader.bin` | second-stage bootloader |
| `partition_table/partition-table.bin` | partition map |
| `micropython.bin` | application partition only |

## Flashing

Use `openbricks flash`. It drives `esptool.py` to write the image and then writes the hub's BLE advertising name into NVS. The name is **per-hub**, set at flash time (not build time) — one firmware image is reused across every hub, and each hub gets its own identity here. `--name` is mandatory: two hubs that answer to the same name can't be individually addressed over BLE.

```
pipx install openbricks             # one-time; or `pip install openbricks`

openbricks flash \
    --name RobotA \
    --firmware native/micropython/ports/esp32/build-openbricks_esp32s3/firmware.bin
```

`--port` is auto-detected when exactly one ESP device is connected;
pass it explicitly with several devices attached. (`--firmware` is
optional too — omitted, the newest **released** image for the
detected chip is downloaded — but when flashing a local build like
here, point it at your build output.)

The command erases flash, writes `firmware.bin` at the offset the image was built for — `0x0` on the S3, `0x1000` on the classic ESP32, detected from the partition-table position inside the image itself — waits for the device to boot, then pokes the name into `esp32.NVS("openbricks").hub_name` via `mpremote` and reads it back to verify. Cross-platform — works on macOS, Linux, Windows (use `COM5` etc. for `--port`).

If you'd rather use `esptool.py` directly (e.g. mass-flashing with a fixture, no name needed yet), the raw commands:

```
# Classic ESP32 — bootloader at 0x1000
esptool.py --chip esp32 --port /dev/tty.usbserial-XXXX erase_flash
esptool.py --chip esp32 --port /dev/tty.usbserial-XXXX --baud 460800 \
    write_flash -z 0x1000 openbricks-esp32-firmware-v0.9.0.bin

# ESP32-S3 — bootloader at 0x0
esptool.py --chip esp32s3 --port /dev/tty.usbmodemXXXX erase_flash
esptool.py --chip esp32s3 --port /dev/tty.usbmodemXXXX --baud 460800 \
    write_flash -z 0x0 openbricks-esp32s3-firmware-v0.9.0.bin
```

A hub flashed this way boots fine but has no name; `openbricks.bluetooth.set_enabled(True)` will raise `HubNameNotSetError` until you write one via `mpremote`:

```
mpremote connect PORT exec "
import esp32
nvs = esp32.NVS('openbricks')
nvs.set_blob('hub_name', b'RobotA')
nvs.commit()
"
```

> **Common footgun.** Flashing the S3 `firmware.bin` at `0x1000` (classic-ESP32 offset) leaves `0x0..0xFFF` untouched; the S3 ROM boots from `0x0` and fails with `Invalid image block, can't boot`. Always match the offset to the chip.

Or, if you built with `idf.py`:

```
cd native/micropython/ports/esp32/build-openbricks_esp32
idf.py -p /dev/tty.usbserial-XXXX flash monitor
```

## Verifying the image at the REPL

Connect over the USB UART (e.g. `mpremote connect /dev/tty.usbserial-XXXX`) and at the REPL:

```python
from openbricks._native import motor_process, Servo, TrapezoidalProfile, Observer, DriveBase
motor_process.is_running()    # False initially; True once a motor attaches
```

The openbricks Python package is frozen into the image, so `import openbricks.*` works without copying any files. The first time a closed-loop motor calls `run_speed`, it registers its control step with the scheduler and the 1 kHz timer ISR comes online.

## Running tests

Tests exercise the real C module under the unix MicroPython binary — the same build as firmware, minus the ESP32 port:

```
make -C native/micropython/mpy-cross -j
make -C native/micropython/ports/unix \
    VARIANT=standard \
    USER_C_MODULES=$(pwd)/native/user_c_modules -j
./native/micropython/ports/unix/build-standard/micropython tests/run.py
```

The runner spawns one MP subprocess per test module for state isolation (the module list lives in `tests/run.py::_TEST_MODULES`). Expected final line: `all modules passed.`

## CI

GitHub Actions runs several job groups on every push / PR (see `.github/workflows/ci.yaml`):

- **`test`** — builds the unix MP binary with the `_openbricks_native` user_c_module and runs `tests/run.py`. No ESP-IDF needed. This is the merge gate for firmware changes.
- **`cpython-tests`** / **`openbricks-py`** — the CPython-compatible subset of the firmware tests under stock CPython (catches MP/CPython interpreter divergence), the latter with coverage upload.
- **`openbricks-host`** — the host CLI + sim test suite (`tools/openbricks/tests`) with coverage, plus a `--help` smoke test of every subcommand and a headless preview of every shipped sim world.
- **`coverage`** — the firmware suite again on the gcov-instrumented unix MP build; uploads C-core coverage.
- **`firmware`** — a matrix job (targets: `esp32`, `esp32s3`) that builds each image inside the `espressif/idf:v5.5.4` container and uploads `firmware.bin` + bootloader + partition-table per target as a workflow artifact.
- **`qemu-smoke`** — boots the just-built ESP32-S3 image in Espressif's QEMU and asserts the bootloader reaches app entry with no panic markers.
- **`release`** / **`build-openbricks-sdist`** / **`build-openbricks-wheels`** / **`publish-openbricks`** — firmware GitHub Releases (rolling `latest` on main pushes, versioned on `v*` tags) and the PyPI sdist + cibuildwheel wheels published on `cli/v*` tags (releases up to 0.10.24 used `openbricks/v*`).

Successful PRs produce a flashable image downloadable from the Actions run.

## Troubleshooting

**"IDF_PATH is not set"** — source `export.sh` in the shell that runs the build script. Each new shell needs it.

**Build fails at `modbluetooth_nimble.c`** — BLE (NimBLE) is *enabled* on both boards via `boards/sdkconfig.ble` in each board's `mpconfigboard.cmake` `SDKCONFIG_DEFAULTS` list; it's what `openbricks run` / `upload` / `stop` ride on, so don't disable it. A failure here usually means an ESP-IDF / MicroPython version mismatch — see the `WIFI_AUTH_MAX` entry below. (WiFi, by contrast, *is* deliberately disabled in `sdkconfig.board` + `mpconfigboard.h`.)

**Build fails at `network_wlan.c` with a `_Static_assert` about `WIFI_AUTH_MAX`** — ESP-IDF / MP version mismatch. Either bump ESP-IDF into the supported range (5.3–5.5 for current MP master) or bump the MP submodule.

**Submodule is empty** — `git submodule update --init --recursive`.

**Link error "undefined reference to `mp_register_module__openbricks_native`"** — the user_c_module isn't being built into the image. Check `./scripts/build_firmware.sh` is passing `USER_C_MODULES=$(pwd)/native/user_c_modules` to `idf.py`.
