Python CircuitPython for Hardware — Deep Dive

CircuitPython’s USB Architecture

CircuitPython’s USB drive workflow is not a convenience hack — it is a fundamental architectural decision. The firmware implements a composite USB device that simultaneously exposes:

  • Mass Storage (MSC) — The CIRCUITPY drive for code and libraries
  • CDC Serial — A serial console for REPL access and print output
  • HID — Optional Human Interface Device mode for keyboard/mouse emulation
  • MIDI — Optional MIDI device mode for music projects

This composite USB stack means a single CircuitPython board can appear as a flash drive, a serial port, and a keyboard at the same time. The usb_hid and usb_midi modules let you build devices that interact with a computer as standard peripherals:

import usb_hid
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keycode import Keycode

kbd = Keyboard(usb_hid.devices)

# Type a macro when a button is pressed
kbd.send(Keycode.CONTROL, Keycode.SHIFT, Keycode.T)

Customizing USB at Boot

The boot.py file runs once before code.py and configures USB behavior. You can disable the mass storage drive for security or rename the device:

# boot.py
import storage
import usb_cdc
import board
import digitalio

# Only enable USB drive if a button is held during boot
button = digitalio.DigitalInOut(board.BUTTON)
button.pull = digitalio.Pull.UP

if button.value:  # button not pressed
    storage.disable_usb_drive()
    usb_cdc.disable()

This pattern is critical for deployed projects where you do not want end users accidentally modifying code.

Display Framework: displayio

CircuitPython includes displayio, a compositing display framework that manages screens, sprites, and text through a layered group system:

import board
import displayio
import terminalio
from adafruit_display_text import label

display = board.DISPLAY  # built-in display on some boards

# Create a display group
splash = displayio.Group()
display.root_group = splash

# Background
bg_bitmap = displayio.Bitmap(display.width, display.height, 1)
bg_palette = displayio.Palette(1)
bg_palette[0] = 0x000033  # dark blue

bg_sprite = displayio.TileGrid(bg_bitmap, pixel_shader=bg_palette)
splash.append(bg_sprite)

# Text label
text = label.Label(terminalio.FONT, text="Temp: 23.5°C",
                   color=0xFFFFFF, x=10, y=20)
splash.append(text)

The framework handles display refresh automatically. You modify the group contents, and displayio composites them to the screen at the appropriate refresh rate. This design avoids manual framebuffer management.

Supported Displays

CircuitPython supports a wide range of display hardware through driver modules:

  • SSD1306 OLED (128x64, I2C/SPI)
  • ST7789 and ILI9341 TFT (color, SPI)
  • E-ink/E-paper displays (SSD1680, UC8151D)
  • HUB75 RGB LED matrices (via rgbmatrix)
  • NeoPixel strips treated as pixel arrays

Asynchronous Programming with asyncio

CircuitPython 7.1+ supports asyncio, enabling concurrent task patterns without threads:

import asyncio
import board
import digitalio
import neopixel

pixels = neopixel.NeoPixel(board.NEOPIXEL, 10)

async def blink_led():
    led = digitalio.DigitalInOut(board.LED)
    led.direction = digitalio.Direction.OUTPUT
    while True:
        led.value = not led.value
        await asyncio.sleep(0.5)

async def rainbow_cycle():
    import rainbowio
    offset = 0
    while True:
        for i in range(10):
            pixels[i] = rainbowio.colorwheel((i * 25 + offset) % 255)
        pixels.show()
        offset = (offset + 1) % 255
        await asyncio.sleep(0.02)

async def read_sensor():
    import analogio
    temp = analogio.AnalogIn(board.TEMPERATURE)
    while True:
        voltage = temp.value * 3.3 / 65535
        temp_c = (voltage - 0.5) * 100
        print(f"Temp: {temp_c:.1f}°C")
        await asyncio.sleep(5)

async def main():
    await asyncio.gather(
        blink_led(),
        rainbow_cycle(),
        read_sensor()
    )

asyncio.run(main())

This pattern lets you run multiple concurrent behaviors — animation, sensor reading, communication — without complex state machines.

Network Programming

CircuitPython on Wi-Fi-enabled boards (ESP32-S2, ESP32-S3, Pico W) provides networking through the wifi, socketpool, and adafruit_requests modules:

import wifi
import socketpool
import ssl
import adafruit_requests

wifi.radio.connect("MyNetwork", "MyPassword")
print("IP:", wifi.radio.ipv4_address)

pool = socketpool.SocketPool(wifi.radio)
session = adafruit_requests.Session(pool, ssl.create_default_context())

response = session.get("https://io.adafruit.com/api/v2/time/seconds")
print("Unix time:", response.text)
response.close()

Adafruit IO Integration

Adafruit IO is a cloud platform designed for IoT data. CircuitPython has first-class support:

from adafruit_io.adafruit_io import IO_HTTP

io = IO_HTTP("username", "aio_key", session)

# Send sensor data
io.send_data("temperature", 23.5)

# Receive commands
value = io.receive_data("led-control")

Memory Management Techniques

CircuitPython boards typically have 256 KB to 2 MB of RAM. Effective memory management is essential for complex projects:

import gc

# Check available memory
gc.collect()
print(f"Free: {gc.mem_free()} bytes")

# Pre-allocate buffers for repeated operations
sensor_buffer = bytearray(64)
display_buffer = bytearray(1024)

Reducing Library Memory Footprint

The library bundle offers both source (.py) and compiled (.mpy) versions. Always use .mpy files on the board — they use 30-50% less RAM than source files.

For very constrained boards, cherry-pick only the modules you need rather than copying entire library folders.

Building Custom Board Definitions

If you manufacture hardware or use an unsupported board, you can create a CircuitPython board definition:

  1. Fork the CircuitPython repository
  2. Create a board directory under ports/<chip_family>/boards/
  3. Define mpconfigboard.h (pin mappings, clock configuration)
  4. Define pins.c (board pin names exposed to Python)
  5. Build with make BOARD=your_board

The build system uses gcc-arm-none-eabi for ARM boards and xtensa-esp32s2-elf-gcc for ESP32 boards.

Testing and Development Workflow

Blinka: CircuitPython on Desktop

Adafruit’s Blinka library lets you run CircuitPython code on full Linux computers (Raspberry Pi, BeagleBone) using the same API:

pip install adafruit-blinka
# Same code works on CircuitPython board and Raspberry Pi
import board
import digitalio

led = digitalio.DigitalInOut(board.D18)
led.direction = digitalio.Direction.OUTPUT
led.value = True

This enables testing CircuitPython library code on a desktop before deploying to constrained hardware.

Continuous Integration

The CircuitPython project uses CI extensively. For your own libraries, Adafruit provides cookie-cutter templates that include pre-configured GitHub Actions for linting, building documentation, and running tests.

Production Deployment Considerations

For projects that ship to end users:

Lock down USB. Use boot.py to disable the drive and serial console. Provide a physical button combination to re-enable for updates.

Implement error recovery. CircuitPython shows errors on connected displays and falls back to the REPL. For headless deployments, add a try/except wrapper in code.py that logs errors to a file and restarts:

import supervisor
import time

while True:
    try:
        import main_app
        main_app.run()
    except Exception as e:
        with open("error.log", "a") as f:
            f.write(f"{time.monotonic()}: {e}\n")
        time.sleep(5)
        supervisor.reload()

Version your firmware. Track which CircuitPython version and library bundle version are deployed. Mismatched versions cause subtle bugs that are difficult to diagnose remotely.

One thing to remember: CircuitPython’s power is in its opinionated simplicity — USB-drive deployment, unified APIs, and a curated library ecosystem that trades flexibility for a development experience where hardware projects just work.

pythoncircuitpythonhardwareiot

See Also

  • Python Behavior Trees Robotics How robots make decisions using a tree-shaped rulebook that keeps them organized, like a flowchart that tells a robot what to do in every situation.
  • Python Bluetooth Ble How Python connects to fitness trackers, smart locks, and wireless sensors using the invisible radio signals all around you.
  • Python Computer Vision Autonomous How self-driving cars use cameras and Python to see the road, spot pedestrians, read signs, and understand traffic — like giving a car human eyes and a brain.
  • Python Home Assistant Automation How Python turns your home into a smart home that reacts to you automatically, like a helpful invisible butler.
  • Python Lidar Point Cloud Processing How self-driving cars use millions of laser dots to build a 3D picture of the world around them, and how Python helps make sense of it all.