CircuitPython’s Latest Update Finally Makes USB Host Less of a Headache
I have a drawer full of USB-OTG cables that I look at with deep suspicion. For years, if you wanted to plug a keyboard, a mouse, or a MIDI controller into a microcontroller, you were in for a bad time. You usually had to rely on weird shields, bit-banged protocols, or just giving up and using a Raspberry Pi Zero (which takes 45 seconds to boot, ugh).
But the latest CircuitPython release candidate dropped recently, and I’ve been messing with it all weekend. The headline features? Proper, usable USB Host support and some serious tweaks to how the RP2040 handles Programmable I/O (PIO).
I’m not going to sugarcoat it: getting USB Host working on a microcontroller has historically been a nightmare of descriptors and timing issues. But this update feels different. It actually works. I plugged a generic mechanical keyboard into a Feather RP2040, and it just… typed. No magic smoke, no cryptic error codes.
USB Host: The Real Deal
So here’s the thing. Usually, your CircuitPython board acts as a device. You plug it into your laptop, and it shows up as a drive or a keyboard. USB Host flips that. Now, the board acts like the computer. You plug stuff into it.
The new usb_host module is surprisingly clean. It handles the enumeration (that handshake where the USB device says “Hello, I am a mouse”) in the background. You don’t have to write a driver from scratch for standard HID devices.
I threw together a quick script to dump keystrokes from a USB keyboard to the serial console. Look at how little code this takes now:
import board
import digitalio
import usb.core
import usb.util
import usb_host
# You need to power the USB device!
# On many Feathers, there's a specific pin to enable 5V out.
# Check your specific board pinout.
usb_power = digitalio.DigitalInOut(board.D5) # Example pin
usb_power.direction = digitalio.Direction.OUTPUT
usb_power.value = True
print("Waiting for keyboard...")
# This setup assumes you have the hardware wired correctly
# (D+ to D+, D- to D-, GND connected, 5V provided)
while True:
# Check for devices on the bus
devices = usb.core.find(find_all=True)
for device in devices:
# Just dumping the device ID to prove it's alive
print(f"Found device: {hex(device.idVendor)}:{hex(device.idProduct)}")
# In a real script, you'd attach a kernel driver here
# or parse the HID report manually.
try:
if device.is_kernel_driver_active(0):
device.detach_kernel_driver(0)
# This is where you'd read the endpoint
# endpoint = device[0][(0,0)][0]
# data = device.read(endpoint.bEndpointAddress, endpoint.wMaxPacketSize)
# print(data)
except Exception as e:
print(f"Glitch: {e}")
# Don't hammer the bus too hard
usb_host.Port(board.USB_HOST_DP, board.USB_HOST_DM).poll()
That poll() at the end is crucial. Without it, the stack doesn’t process the events, and you’re left wondering why your expensive macro pad is ignoring you.
I did run into a snag with power, though. Most dev boards don’t output 5V on the USB rail by default because they don’t want to fry your laptop if you plug everything in backward. You usually have to toggle a pin or solder a jumper. I spent an hour debugging my code before realizing my keyboard just wasn’t getting any juice. Classic.
RP2040 PIO: Still Scary, But Better
If you’re using an RP2040 (like in the Pico or the Feather RP2040), you probably know about PIO. It’s that weird little subsystem that lets you write mini-assembly programs to handle I/O pins super fast, independent of the main CPU.
The latest update tightened up how CircuitPython interacts with these state machines. We’re seeing better memory management for the PIO instruction space. The RP2040 only has 32 slots for instructions across all state machines. It’s tiny. Before, if you loaded a few complex libraries (like an LED driver and a VGA output), you’d run out of room and crash.
The new implementation seems smarter about reusing identical programs. If two different pins need the same “blink” logic, they share the instruction memory.
Here is a practical example using rp2pio to drive a pin at a frequency that would make Python choke if you tried to bit-bang it normally. This creates a 1MHz square wave without blocking the main loop:
import board
import rp2pio
import adafruit_pioasm
import time
# The assembly code for the PIO state machine
# .side_set 1 opt means we can toggle a pin as a side effect
# wrap_target / wrap creates the infinite loop
pio_code = """
.program square_wave
.side_set 1 opt
pull block ; Wait for data from main Python code
mov x, osr ; Move that data into X register (our counter)
wrap_target:
set pins, 1 [1] ; Turn pin ON, wait 1 cycle
set pins, 0 [1] ; Turn pin OFF, wait 1 cycle
.wrap
"""
assembled = adafruit_pioasm.assemble(pio_code)
# Configure the State Machine
sm = rp2pio.StateMachine(
assembled,
frequency=125_000_000, # Run at full speed (125MHz)
first_set_pin=board.D13, # The pin we are toggling
initial_set_pin_state=0
)
print("Starting 1MHz wave...")
# We are just setting up the machine, the wave happens in hardware.
# The main Python thread is free to do whatever else.
while True:
print("I am free to do other tasks!")
time.sleep(1)
The beauty here is that once sm is created, the CPU is done. The PIO block handles the toggling forever. The update improves how we tear down and restart these machines without needing a hard reset, which is great for REPL-based development where you’re constantly reloading code.
Why This Matters Right Now
I’ve been skeptical of “all-in-one” Python boards for a while. Usually, if I need complex I/O or USB hosting, I grab a Linux SBC. But the boot times kill me, and the power consumption is too high for battery projects.
With these updates, CircuitPython on an RP2040 is hitting a sweet spot. You get the instant-on behavior of a microcontroller, but you can actually talk to USB peripherals and handle high-speed signals without resorting to C++.
Just remember to check your power wiring. Seriously. Don’t be me.
