Skip to content

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 uasyncio tasks
  • 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, and PWM
  • uasyncio
  • basic Python classes and functions

Development Environment Setup

Thonny Setup

  1. Open Thonny
  2. Select Run > Select Interpreter
  3. Choose MicroPython (Raspberry Pi Pico)
  4. Select the correct serial port for the Pico 2 W
  5. Ensure the board is running current MicroPython firmware
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

  1. Cooperative polling
    Poll hardware periodically with short sleeps.

  2. State-based drivers
    Split a long operation into steps and resume later.

  3. Async wrappers around synchronous hardware access
    Keep I/O calls short and use await around waiting periods.

  4. 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 of time.sleep()
  • return simple values or booleans
  • provide clear state methods
  • document timing assumptions
  • test drivers independently before integrating them

Do Not

  • block in while True loops without yielding
  • hide long delays inside read() or write()
  • 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_high or active_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

  1. Modify the LED driver to support a pulse() method
  2. Extend the button driver to detect long presses
  3. Adapt the sensor demo to read every 5 seconds
  4. Add a second background task that prints uptime every second
  5. 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



Back to Chapter | Back to Master Plan | Previous Session