technique
CircuitPython on the ESP32-S3
Adafruit's Python for microcontrollers on the ESP32-S3: the CIRCUITPY drive or web workflow, code.py, the board module, and how it differs from MicroPython.
Before this
This page assumes you are comfortable with:
Why you need this
CircuitPython gives you the shortest loop between an idea and a running gadget: the board shows up as a USB drive, you save a file, and it runs. On an ESP32-S3 it can also act as a USB host and read a keyboard, which the author's keyboard project did in about 120 lines of Python. Knowing where it differs from MicroPython saves you from copying code that cannot run.
The idea
CircuitPython is Adafruit's fork of MicroPython: the same core interpreter, a different hardware library, and a different way of getting code onto the board. You flash the CircuitPython firmware once (see Flashing and the ROM bootloader); after that you never flash again to change your program.
The drive and code.py
On a board whose chip has a full USB peripheral, CircuitPython shows up on your computer as a drive named CIRCUITPY. It holds your program, code.py, and a lib/ folder for libraries. When you save a file to the drive, CircuitPython notices and reruns code.py from the top; this is autoreload. A file named boot.py, if present, runs once before code.py on every boot, and is the place for settings that must be fixed before USB starts. The same USB connection also carries a serial console with the REPL, the interactive prompt.
The ESP32-S3 has that full USB peripheral, so S3 boards get the drive. The classic ESP32 has no USB at all, and the C6 DevKitM clone's port is USB-Serial-JTAG only, which a computer sees as a serial port, never a drive. For boards like those, CircuitPython offers the web workflow: put WiFi details in a file named settings.toml (the keys are CIRCUITPY_WIFI_SSID, CIRCUITPY_WIFI_PASSWORD, and CIRCUITPY_WEB_API_PASSWORD), and the board serves its files and a console over HTTP on your local network.
Which chips
CircuitPython's official downloads page lists boards in the ESP32, ESP32-S2, ESP32-S3, ESP32-C3, ESP32-C6, and ESP32-P4 families, each build named for one specific board. The YD-ESP32-S3 has its own entries there (N16R8 and N8R8 variants). The author runs CircuitPython 10.x on the YD-ESP32-S3; the header of the project's code.py records a build for Espressif's DevKitC-1 N8R2 board, while the author's notes record the unit's module as N16R8, so check that a build names your module's flash and PSRAM sizes before you pick one.
The hardware library
| Job | MicroPython | CircuitPython |
|---|---|---|
| Name a pin | Pin(21), by GPIO number |
board.IO21, by the name the board's build gives it |
| Digital in and out | machine.Pin |
digitalio.DigitalInOut |
| I2C, SPI, UART | machine.I2C, machine.SPI, machine.UART |
busio.I2C, busio.SPI, busio.UART |
| USB serial | sys.stdin, sys.stdout |
usb_cdc.console, plus sys.stdin |
| Libraries | .py or .mpy files you copy |
.mpy files from Adafruit's bundle, copied into lib/ |
| Program file | main.py |
code.py |
An .mpy file is a library precompiled to bytecode, which loads faster and uses less RAM than the .py source. Adafruit publishes its libraries as a bundle matched to each major CircuitPython version.
Worked example
The deep example: a USB keyboard reader
This is excerpted from the author's ESP_32_Keyboard project, firmware/yd_host/code.py, which ran on the YD-ESP32-S3. The left USB-C port is the S3's native USB, wired to GPIO 19 (D-) and GPIO 20 (D+), and here it runs in host mode: the board powers and reads a keyboard, the way a computer would. The header comment records a hard-won fact.
# Reads USB keyboard on native USB port (left, GPIO 19/20) in Host mode.
# Debug output via print() → USB CDC (left port, COM10).
#
# Note: busio.UART on GPIO 43/44 does NOT reach CH343P (COM6) because
# CircuitPython assigns a different UART peripheral than UART0.
# ROM bootloader uses UART0 directly, but CP's busio.UART does not.
#
# CircuitPython 10.x on espressif_esp32s3_devkitc_1_n8r2
import board
import usb_host
import usb.core
import time
# --- USB Host Setup ---
# D+ = GPIO 20, D- = GPIO 19 (native USB on left port)
usb_host.Port(board.IO20, board.IO19)
...
A USB keyboard sends 8-byte reports: byte 0 holds the modifier keys as bits, byte 1 is reserved, and bytes 2 to 7 hold up to six key codes. The project turns one report into names:
def decode_report(buf):
"""Decode an 8-byte HID boot keyboard report."""
mod_byte = buf[0]
keys = [buf[i] for i in range(2, 8) if buf[i] != 0]
mod_names = [name for bit, name in MODIFIERS.items() if mod_byte & bit]
key_names = [KEYCODES.get(k, f"0x{k:02X}") for k in keys]
return mod_names, key_names
Pressing left Shift and A gives byte 0 = 0x02 (the L_SHIFT bit in the project's table) and byte 2 = 0x04 (A in its key-code table), so the program prints L_SHIFT and A.
A USB port cannot be a host and a drive at once, so the project's boot.py, from the same folder, switches the drive off:
import storage
import usb_hid
import usb_midi
storage.disable_usb_drive()
usb_hid.disable()
usb_midi.disable()
Its comment gives the way back in: double-tap RESET to enter safe mode, which skips boot.py, and the drive returns.
The uppercase echo
This is the CircuitPython echo from Choosing a language, written for this page and not hardware-tested. It targets the YD-ESP32-S3's left port or any native-USB S3 board such as a lx7-usbjtag one, with the console left enabled (the default).
import usb_cdc
port = usb_cdc.console # the USB serial port the REPL uses
while True:
b = port.read(1) # a bytes object; b"" if nothing arrived
if not b:
continue
c = b[0] # the byte as a number, 0 to 255
if 0x61 <= c <= 0x7A: # 'a' to 'z'
c -= 0x20 # 'a' (0x61) becomes 'A' (0x41)
port.write(bytes([c]))
How it differs from the MicroPython echo on MicroPython on the ESP32:
| MicroPython echo | CircuitPython echo | |
|---|---|---|
| Where bytes come from | sys.stdin, which is UART0 on the CYD |
usb_cdc.console, the native USB serial port |
| What a read returns | a one-character string | a bytes object, possibly empty |
| Uppercase | compare characters, chr(ord(ch) - 0x20) |
compare numbers, c -= 0x20 |
| Waiting | the read waits for a character | an empty result means try again |
| File name on the board | main.py |
code.py |
Trace q: port.read(1) returns b"q", b[0] is 0x71, which is between 0x61 and 0x7A, so c becomes 0x51 and bytes([0x51]) is b"Q". CircuitPython's documentation notes that usb_cdc.console is None when the console is disabled in boot.py, so this echo needs the console left on. The author's keyboard boot.py keeps it on.
In an ESP32 project
This is stage 2 of the pipeline on the hub, Write the program, for S3 boards. Stage 3 shrinks to one firmware flash at the start. Stage 4 work goes through board, digitalio, and busio; GPIO: buttons and LEDs shows the pin side. When a project outgrows it, as the keyboard host did once it needed ESP-NOW and a configuration web page alongside USB host, the next stop is C with ESP-IDF.
Common mistakes
- Expecting
busio.UARTon GPIO 43 and 44 to reach the YD's CH343 port. The author found that CircuitPython assigns a different UART peripheral than the UART0 the bridge is wired to. Symptom: your output never appears on the right-hand COM port. - Locking yourself out with
boot.py. Afterstorage.disable_usb_drive()the drive is gone. Symptom: no CIRCUITPY drive appears; double-tap RESET for safe mode. - A library bundle for the wrong major version. Symptom: an import error naming an incompatible
.mpyfile. - Copying MicroPython code across. Symptom:
ImportError: no module named 'machine'. - Saving half a file. Autoreload restarts on every write, so an editor that saves in pieces can start a broken program. Symptom: a syntax error that vanishes on the next save.
- USB host without power. On the YD-ESP32-S3, the USB-OTG solder jumper must be closed for the left port to power a keyboard. Symptom: the keyboard's lights stay off and the scan loop prints "No USB device found".
Cost
Edit-to-run is the cheapest of any option here: save, and the program reruns. The price is paid elsewhere. The interpreter takes a large part of flash before your code, and your objects live in a garbage-collected heap, so large programs and big buffers run out of RAM sooner than in C. It is interpreted, so a tight loop runs far slower than compiled C. And on the S3 the native USB port does one job at a time: drive and console, or USB host, chosen in boot.py.
Going further
- MicroPython on the ESP32, the parent project and the CYD's language
- Choosing a language, for the echo in all four languages
- Adafruit's CircuitPython documentation for the
usb_cdc,usb_host, andstoragemodules - Adafruit's guide to the Feather ESP32-S3, a
lx7-usbjtagboard with CircuitPython in mind - The CircuitPython downloads page filtered to ESP32-S3 boards
Back to ESP32 development: assembly, C, MicroPython, and CircuitPython