Session 4: Building Peripheral Drivers for Async-Friendly Use
Synopsis
Shows how to wrap lower-level hardware access in reusable abstractions that cooperate cleanly with application-level tasks.
Session Content
Session 4: Building Peripheral Drivers for Async-Friendly Use
Session Overview
Duration: ~45 minutes
Audience: Python developers with basic programming knowledge learning Raspberry Pi Pico 2 W development
Topic: Designing MicroPython peripheral drivers that work well with asynchronous applications
Learning Objectives
By the end of this session, learners will be able to:
- Explain why peripheral drivers need to be async-friendly in embedded applications
- Identify blocking patterns that interfere with
uasynciotasks - Design lightweight, non-blocking driver APIs for sensors and actuators
- Create a simple MicroPython driver wrapper that cooperates with the event loop
- Use timer-based or cooperative polling patterns to avoid long delays
- Integrate a peripheral driver into an async application on the Pico 2 W
Prerequisites
- Raspberry Pi Pico 2 W
- MicroPython firmware installed
- Thonny IDE installed and connected to the board
- Basic familiarity with:
Pin,I2C,SPI,ADC, andPWMuasyncio- basic Python classes and functions
Development Environment Setup
Thonny Setup
- Open Thonny
- Select Run > Select Interpreter
- Choose MicroPython (Raspberry Pi Pico)
- Select the correct serial port for the Pico 2 W
- Ensure the board is running current MicroPython firmware
Recommended Project Structure
main.py
drivers/
__init__.py
led.py
button.py
sensor_demo.py
Theory: Why Async-Friendly Drivers Matter
The Problem with Blocking Drivers
In embedded systems, a driver may:
- wait in a long sleep()
- poll a device in a tight loop
- perform repeated retries without yielding control
- read a sensor with long conversion delays
These patterns can block the event loop, causing: - missed button presses - delayed LED updates - slow Wi-Fi responsiveness - poor multitasking behavior
Async-Friendly Design Goals
An async-friendly driver should:
- complete quickly
- return control to the event loop often
- use await asyncio.sleep_ms(...) instead of blocking delays
- separate hardware access from scheduling logic
- expose small, composable methods
Common Approaches
-
Cooperative polling
Poll hardware periodically with short sleeps. -
State-based drivers
Split a long operation into steps and resume later. -
Async wrappers around synchronous hardware access
Keep I/O calls short and useawaitaround waiting periods. -
Event-driven patterns
Use interrupts carefully, then process events in async tasks.
Hands-On Exercise 1: Async-Friendly LED Driver
In this exercise, you will build a small LED driver that can blink without blocking other tasks.
Hardware
- 1 onboard LED on Pico 2 W
drivers/led.py
from machine import Pin
import uasyncio as asyncio
class LEDDriver:
"""Async-friendly LED driver for the Pico onboard LED."""
def __init__(self, pin_num=25, active_high=True):
"""
Initialize the LED driver.
Args:
pin_num: GPIO pin number for the LED.
active_high: True if HIGH turns LED on.
"""
self._pin = Pin(pin_num, Pin.OUT)
self._active_high = active_high
self.off()
def on(self):
"""Turn the LED on."""
self._pin.value(1 if self._active_high else 0)
def off(self):
"""Turn the LED off."""
self._pin.value(0 if self._active_high else 1)
def toggle(self):
"""Toggle LED state."""
self._pin.toggle()
async def blink(self, times=3, on_ms=200, off_ms=200):
"""
Blink the LED without blocking other async tasks.
Args:
times: Number of blink cycles.
on_ms: Time LED stays on.
off_ms: Time LED stays off.
"""
for _ in range(times):
self.on()
await asyncio.sleep_ms(on_ms)
self.off()
await asyncio.sleep_ms(off_ms)
main.py
import uasyncio as asyncio
from drivers.led import LEDDriver
async def heartbeat(led):
"""Simple heartbeat task."""
while True:
led.toggle()
await asyncio.sleep_ms(1000)
async def demo():
led = LEDDriver()
# Run a short blink sequence
await led.blink(times=5, on_ms=150, off_ms=150)
async def main():
led = LEDDriver()
# Run heartbeat and blink together
asyncio.create_task(heartbeat(led))
await demo()
try:
asyncio.run(main())
finally:
asyncio.new_event_loop()
Expected Behavior
- LED blinks 5 times quickly
- then continues to toggle once per second in the background
Example Output
No console output expected.
LED flashes on the Pico board.
Theory: Driver API Design for Async Use
Good Driver API Characteristics
- Small methods:
read(),write(),start(),stop() - Non-blocking wait operations:
await sleep_ms(...) - No hidden delays: avoid long waits inside basic methods
- Predictable behavior: clear return values and errors
- Separation of concerns:
- driver handles hardware details
- application handles timing and orchestration
Example: Not Ideal
def read_sensor():
# Blocking wait buried inside the driver
while not ready:
pass
Better
async def wait_ready():
while not ready:
await asyncio.sleep_ms(20)
Hands-On Exercise 2: Async Button Driver with Debounce
This exercise shows how to build a cooperative button handler that can be used in async programs.
Hardware
- 1 pushbutton
- 1 jumper wire
- Optional: internal pull-up resistor
Wiring
- Button leg 1 → GND
- Button leg 2 → GPIO 14
drivers/button.py
from machine import Pin
import uasyncio as asyncio
class ButtonDriver:
"""Async-friendly pushbutton driver with debounce."""
def __init__(self, pin_num=14, pull=Pin.PULL_UP, active_low=True):
"""
Initialize button input.
Args:
pin_num: GPIO pin connected to the button.
pull: Internal pull resistor configuration.
active_low: True if pressed means logic 0.
"""
self._pin = Pin(pin_num, Pin.IN, pull)
self._active_low = active_low
def is_pressed(self):
"""Return True if the button is currently pressed."""
value = self._pin.value()
return value == 0 if self._active_low else value == 1
async def wait_for_press(self, debounce_ms=50):
"""
Wait for a stable button press.
Args:
debounce_ms: Debounce time in milliseconds.
"""
while True:
if self.is_pressed():
await asyncio.sleep_ms(debounce_ms)
if self.is_pressed():
while self.is_pressed():
await asyncio.sleep_ms(10)
return
await asyncio.sleep_ms(10)
main.py
import uasyncio as asyncio
from drivers.button import ButtonDriver
from drivers.led import LEDDriver
async def button_task(button, led):
"""Toggle LED whenever button is pressed."""
while True:
await button.wait_for_press()
led.toggle()
print("Button pressed: LED toggled")
async def main():
led = LEDDriver()
button = ButtonDriver()
await button_task(button, led)
try:
asyncio.run(main())
finally:
asyncio.new_event_loop()
Expected Behavior
- Pressing the button toggles the LED
- The event loop remains responsive
- Button bounce is filtered using async-friendly debounce logic
Example Output
Button pressed: LED toggled
Button pressed: LED toggled
Button pressed: LED toggled
Theory: Wrapping Slow Peripherals
Some peripherals, such as temperature sensors or displays, require: - conversion time - bus transactions - periodic refresh - retries if data is not ready
To keep these drivers async-friendly:
- trigger the conversion
- wait with await asyncio.sleep_ms(...)
- read the result later
- avoid busy-wait loops
Hands-On Exercise 3: Async Sensor Polling Pattern
This exercise demonstrates a reusable pattern for polling a sensor without blocking.
Example Concept
We will simulate a sensor that requires a short delay before a new reading is available.
drivers/sensor_demo.py
import uasyncio as asyncio
import time
class SimulatedSensor:
"""Simulates a peripheral with conversion delay."""
def __init__(self):
self._last_value = 0
def start_conversion(self):
"""Begin a fake sensor conversion."""
self._last_value = (self._last_value + 7) % 100
def read_value(self):
"""Return the latest sensor value."""
return self._last_value
async def read_async(self, conversion_ms=200):
"""
Read sensor data asynchronously.
Args:
conversion_ms: Simulated conversion time.
"""
self.start_conversion()
await asyncio.sleep_ms(conversion_ms)
return self.read_value()
main.py
import uasyncio as asyncio
from drivers.sensor_demo import SimulatedSensor
async def monitor_sensor(sensor):
"""Periodically read sensor data."""
while True:
value = await sensor.read_async(250)
print("Sensor value:", value)
await asyncio.sleep_ms(1000)
async def blink_status_led():
"""Background task to show the system is alive."""
from drivers.led import LEDDriver
led = LEDDriver()
while True:
led.toggle()
await asyncio.sleep_ms(500)
async def main():
sensor = SimulatedSensor()
asyncio.create_task(blink_status_led())
await monitor_sensor(sensor)
try:
asyncio.run(main())
finally:
asyncio.new_event_loop()
Example Output
Sensor value: 7
Sensor value: 14
Sensor value: 21
Best Practices for Async-Friendly Driver Design
Do
- keep hardware access short
- use
await asyncio.sleep_ms(...)instead oftime.sleep() - return simple values or booleans
- provide clear state methods
- document timing assumptions
- test drivers independently before integrating them
Do Not
- block in
while Trueloops without yielding - hide long delays inside
read()orwrite() - mix UI logic and low-level I/O
- assume the caller can tolerate blocking behavior
Quick Reference: Async Patterns in Drivers
Polling Loop
while True:
if device_ready():
break
await asyncio.sleep_ms(20)
Delayed Read
start_conversion()
await asyncio.sleep_ms(200)
data = read_data()
Cooperative Repeat Task
async def task():
while True:
do_work()
await asyncio.sleep_ms(100)
Practical Integration Example: Driver + Network Task
main.py
import uasyncio as asyncio
from drivers.led import LEDDriver
from drivers.button import ButtonDriver
async def network_heartbeat():
"""Placeholder for Wi-Fi or MQTT activity."""
while True:
print("Network task running")
await asyncio.sleep_ms(3000)
async def control_task():
led = LEDDriver()
button = ButtonDriver()
while True:
await button.wait_for_press()
led.toggle()
print("Control action triggered")
async def main():
asyncio.create_task(network_heartbeat())
await control_task()
try:
asyncio.run(main())
finally:
asyncio.new_event_loop()
Example Output
Network task running
Control action triggered
Network task running
Network task running
Control action triggered
Hands-On Lab: Build a Reusable Async Driver Wrapper
Task
Create a class for a peripheral that:
- initializes in __init__
- exposes a quick synchronous method for immediate checks
- provides one async method for waiting or polling
- avoids any long blocking delay
Suggested Implementation Template
class MyDriver:
def __init__(self, ...):
pass
def available(self):
"""Return current state quickly."""
return True
async def wait_ready(self):
"""Wait until the device is ready without blocking the event loop."""
while not self.available():
await asyncio.sleep_ms(20)
async def read_async(self):
"""Perform a non-blocking read workflow."""
await self.wait_ready()
return 123
Success Criteria
- Other tasks continue running while the driver waits
- No
time.sleep()used in async paths - The driver can be imported into a larger application
Troubleshooting
Problem: Tasks freeze after calling a driver method
- Check for
time.sleep() - Replace blocking loops with
await asyncio.sleep_ms(...)
Problem: Button triggers multiple times
- Increase debounce time
- Add press-release logic
Problem: LED logic seems inverted
- Verify whether the hardware is active-low
- Adjust
active_highoractive_low
Problem: uasyncio import error
- Confirm MicroPython firmware version
- Use
import uasyncio as asyncio
Session Summary
In this session, you learned how to build peripheral drivers that work smoothly in async applications on the Pico 2 W. You explored: - why blocking code is harmful in cooperative multitasking - how to structure drivers for event-loop compatibility - how to create async-friendly LED, button, and sensor patterns - how to integrate drivers into a larger async system
Practice Tasks
- Modify the LED driver to support a
pulse()method - Extend the button driver to detect long presses
- Adapt the sensor demo to read every 5 seconds
- Add a second background task that prints uptime every second
- Refactor one blocking function in your own code into an async-friendly version
Example Home Assignment
Create a small Pico 2 W application with: - one button input - one LED output - one async task for periodic status printing - one reusable driver class of your own design
Requirements:
- no time.sleep() in the main application
- use uasyncio
- keep driver methods small and non-blocking
Reference Links
- MicroPython Wiki
- MicroPython Quick Reference for RP2
- Raspberry Pi MicroPython Documentation
- Raspberry Pi Pico 2 Series Documentation