This board is an ESP32-S3 with USB-C (native USB Serial/JTAG). Connect it with a USB-C
data cable and flash directly with idf.py. Source the IDF env first
(. ~/esp/esp-idf/export.sh) and build (idf.py build from esp32-agui/).
Working inside the devcontainer? It can't see USB at all — use the RFC2217 bridge below instead.
idf.py -p <PORT> flash writes three regions for this app — it does not erase the whole
chip:
| Offset | Image | What it is |
|---|---|---|
0x0 |
bootloader/bootloader.bin |
2nd-stage bootloader |
0x8000 |
partition_table/partition-table.bin |
partition map (nvs / phy_init / 3 MB app / 256 KB alarmimg) |
0x10000 |
esp32_agui.bin |
the application |
The partition map (../esp32-agui/partitions.csv) places nvs at
0x9000 (right after the partition table). NVS is where the device stores your WiFi credentials,
Soniox key, AG-UI URL + token, time zone, voice, volume, and screen settings — so don't overwrite
it. The separate alarmimg partition (256 KB, at 0x310000) holds the user-uploaded alarm
graphic; it's written at runtime by the captive portal, never by the flasher.
cd esp32-agui
idf.py -p <PORT> flash monitor # exit monitor with Ctrl-]
# <PORT>: /dev/ttyACM0 (Linux) · /dev/cu.usbmodem* (macOS) · COMx (Windows)If Connecting... won't sync, force ROM download mode: hold BOOT, tap RESET, release BOOT,
then retry. Monitor baud is 115200.
⚠️ Flash the app alone at0x10000(build/esp32_agui.bin) on subsequent reflashes. Flashing a merged image at0x0(idf.py merge-bin→build/merged-binary.bin) re-pads thenvsregion with0xFFand wipes your saved WiFi/keys — you'll have to re-provision via theAMOLED-setupcaptive portal. App-only reflash keeps NVS intact:esptool.py --chip esp32s3 -p <PORT> write_flash 0x10000 build/esp32_agui.binWhen the partition table changes (e.g. the
alarmimgpartition was added), flash the table once too — a fullidf.py flashwrites bootloader +partition-table.bin@0x8000+ app and still leavesnvs@0x9000untouched. After that, app-only reflashes are enough again.
Verify the running build from the boot log's ELF file SHA256: line — the compile-time
timestamp is stale on incremental builds, so the ELF hash is the reliable identity check.
The devcontainer (Docker Desktop for Mac) can never see the board's USB port — the Docker VM
has no USB passthrough. Serial-over-TCP works instead: a tiny bridge on the Mac exposes the port,
and in-container esptool speaks rfc2217:// to it. Build, flash, and monitor all run in the
container; the Mac only runs the bridge. (Hardware-verified 2026-07-08.)
On the Mac (one-time setup: brew install esptool):
- Put the board in ROM download mode: unplug → hold BOOT → plug in → release. (esptool's auto-reset doesn't survive the bridge on this board — see gotchas below.)
ls /dev/cu.usbmodem*— the name can change on every re-plug; always re-check.esp_rfc2217_server -v -p 4000 /dev/cu.usbmodemXXXX— leave it running.
In the container (source /opt/esp/idf/export.sh, build, then from esp32-agui/build/):
esptool.py --chip esp32s3 \
-p 'rfc2217://host.docker.internal:4000?ign_set_control' -b 460800 \
--before no_reset --after no_reset write_flash @flash_argsThen unplug/replug the board (or tap reset) to boot the new firmware — --after no_reset leaves it
parked in the bootloader. The NVS rules above apply unchanged: @flash_args is the full
three-image flash; swap in 0x10000 esp32_agui.bin for an app-only reflash.
Monitor over the bridge: tools/monitor-bridge.sh
(Ctrl-] quits).
- Auto-reset fails over RFC2217. esptool can't detect that the remote port is USB-Serial/JTAG
("Device PID identification is only supported on COM and /dev/ serial ports"), so
--before default_resetclassic-resets the chip into a normal boot (Wrong boot mode detected (0x2b)). Hence the manual download-mode step and--before no_reset. - A physical re-plug kills the server (
[Errno 6] Device not configured) and may rename the port — restartesp_rfc2217_serverwith a re-checked/dev/cu.usbmodem*after every re-plug. - Attaching a client reboots a running board — the server re-applies port settings on connect
and the control-line transitions trip the S3 reset logic. Every monitor session therefore starts
with a fresh boot log. A board sitting in download mode is not reset by attaching, which is why
flashing is stable. For a non-disruptive live tail, monitor locally on the Mac instead:
idf.py monitor --no-reset -p /dev/cu.usbmodemXXXX. - One client at a time — the bridge serves a single connection; stop the monitor before flashing and vice versa.
The factory images are full 16 MB flash dumps in ../Firmware/ — write at 0x0
(this restores the entire stock experience, WiFi-setup UI included, and overwrites everything):
- V1 (SH8601 + FT3168) →
ESP32-S3-Touch-AMOLED-1.8-FactoryXiaozhi_250805.bin - V2 (CO5300 + CST816) →
ESP32-S3-Touch-AMOLED-1.8-V2-FactoryXiaozhi_260601.bin
esptool.py --chip esp32s3 -p <PORT> write_flash 0x0 \
Firmware/ESP32-S3-Touch-AMOLED-1.8-FactoryXiaozhi_250805.bin- Build:
idf.py buildinesp32-agui/. Show sizes:/idf-size. - Flash helper:
/idf-flash. Monitor:/idf-monitor(baud 115200). - Download mode: hold BOOT, tap RESET, release BOOT (or hold BOOT while plugging in).
- App-only reflash (keeps NVS):
write_flash 0x10000 build/esp32_agui.bin. - From the devcontainer:
esp_rfc2217_serveron the Mac + esptool-p 'rfc2217://host.docker.internal:4000?ign_set_control' --before no_reset --after no_reset; monitor viatools/monitor-bridge.sh.