Skip to content

Session 4: Structuring Small Async Hardware Applications

Synopsis

Introduces simple architectural patterns for separating device logic, task startup, and application coordination in maintainable embedded code.

Session Content

Session 4: Structuring Small Async Hardware Applications

Session Overview

  • Duration: ~45 minutes
  • Audience: Python developers with basic programming knowledge
  • Platform: Raspberry Pi Pico 2 W
  • Language/Runtime: MicroPython
  • IDE: Thonny

Session Goals

By the end of this session, learners will be able to: - Organize a small hardware project into reusable modules - Separate configuration, hardware drivers, and application logic - Build a simple asynchronous application loop with multiple tasks - Use uasyncio to coordinate sensor reads, LED control, and network readiness - Apply basic best practices for maintainable Pico firmware structure


Prerequisites

  • Raspberry Pi Pico 2 W flashed with MicroPython
  • Thonny installed and connected to the board
  • Basic familiarity with:
  • Variables, functions, if statements, loops
  • Reading and writing simple MicroPython scripts
  • Hardware:
  • Pico 2 W
  • Breadboard and jumper wires
  • 1 LED + 220Ω resistor
  • 1 pushbutton
  • Optional: DHT11/DHT22, PIR, or another simple digital sensor

Development Environment Setup

Thonny Setup

  1. Install Thonny.
  2. Connect Pico 2 W to your computer via USB.
  3. In Thonny:
  4. Go to Tools > Options > Interpreter
  5. Select MicroPython (Raspberry Pi Pico)
  6. Choose the correct port
  7. Open the Files pane to access the board filesystem.
  8. Save files directly to the Pico as:
  9. main.py
  10. config.py
  11. hardware.py
  12. app.py
/
├── main.py
├── config.py
├── hardware.py
└── app.py

Session Structure

1) Theory: Why Structure Matters in Small Async Projects (10 minutes)

Common Problems in Small Scripts

A first Pico project often starts as a single file with: - GPIO setup - Sensor reads - LED control - Wi-Fi connection - Timing logic - Debug prints

This becomes hard to: - Read - Reuse - Debug - Extend

Better Structure

A small async hardware app is easier to manage when split into parts:

  • config.py
    Holds pin numbers, intervals, Wi-Fi settings, and constants.

  • hardware.py
    Contains helper functions for LEDs, buttons, or sensors.

  • app.py
    Contains asynchronous tasks and application behavior.

  • main.py
    Minimal startup file that launches the app.

Benefits

  • Cleaner code organization
  • Easier hardware changes
  • Reusable modules
  • Better async task separation
  • Easier debugging

2) Theory: Async Application Design Pattern (8 minutes)

A small async Pico app usually has: - One task for periodic sensor reading - One task for actuator control - One task for connectivity or status reporting - One main function that creates and runs tasks

Example Task Responsibilities

  • read_button_task() → checks button state regularly
  • blink_led_task() → blinks LED based on state
  • network_task() → connects to Wi-Fi or reports status
  • main() → starts tasks and keeps event loop alive

Key Rule

Each task should: - Do a small job - Yield control often using await asyncio.sleep(...) - Avoid long blocking operations


3) Hands-on Exercise 1: Build a Modular Blinking LED App (15 minutes)

Goal

Create a small async app with: - A reusable LED helper - A configurable blink interval - A main async loop that blinks the LED without blocking

Wiring

  • LED anode → GPIO 15 through 220Ω resistor
  • LED cathode → GND

File: config.py

# config.py
# Project-wide settings and constants.

LED_PIN = 15
BLINK_INTERVAL_MS = 500

File: hardware.py

# hardware.py
# Hardware helper functions for GPIO devices.

from machine import Pin

def create_led(pin_number):
    """
    Create and return an output pin configured for an LED.
    """
    led = Pin(pin_number, Pin.OUT)
    led.off()
    return led

File: app.py

# app.py
# Application logic using uasyncio.

import uasyncio as asyncio
from config import LED_PIN, BLINK_INTERVAL_MS
from hardware import create_led

async def blink_led_task(led, interval_ms):
    """
    Continuously blink the LED with the given interval.
    """
    while True:
        led.toggle()
        print("LED state:", led.value())
        await asyncio.sleep_ms(interval_ms)

async def main():
    """
    Set up hardware and run tasks.
    """
    led = create_led(LED_PIN)
    await blink_led_task(led, BLINK_INTERVAL_MS)

# Run the application
asyncio.run(main())

File: main.py

# main.py
# Minimal startup file.

import app

Expected Output

In Thonny Shell:

LED state: 1
LED state: 0
LED state: 1
LED state: 0

Task

  • Change BLINK_INTERVAL_MS in config.py to 200 and observe the faster blinking.

4) Hands-on Exercise 2: Add a Button Task and Shared State (10 minutes)

Goal

Expand the app so a button changes the blinking behavior.

Wiring

  • Button leg 1 → GPIO 14
  • Button leg 2 → GND

Use the Pico internal pull-up resistor in software.


Updated hardware.py

# hardware.py
# Hardware helper functions for GPIO devices.

from machine import Pin

def create_led(pin_number):
    """
    Create and return an output pin configured for an LED.
    """
    led = Pin(pin_number, Pin.OUT)
    led.off()
    return led

def create_button(pin_number):
    """
    Create and return an input pin configured for a button.
    Uses the internal pull-up resistor.
    """
    return Pin(pin_number, Pin.IN, Pin.PULL_UP)

Updated config.py

# config.py
# Project-wide settings and constants.

LED_PIN = 15
BUTTON_PIN = 14
BLINK_INTERVAL_MS = 500
FAST_BLINK_INTERVAL_MS = 150

File: app.py

# app.py
# Application logic using uasyncio.

import uasyncio as asyncio
from config import LED_PIN, BUTTON_PIN, BLINK_INTERVAL_MS, FAST_BLINK_INTERVAL_MS
from hardware import create_led, create_button

button_pressed = False

async def blink_led_task(led):
    """
    Blink the LED using the current shared interval.
    """
    global button_pressed

    while True:
        interval = FAST_BLINK_INTERVAL_MS if button_pressed else BLINK_INTERVAL_MS
        led.toggle()
        print("LED:", led.value(), "Fast mode:", button_pressed)
        await asyncio.sleep_ms(interval)

async def button_task(button):
    """
    Poll the button and update shared state.
    """
    global button_pressed

    while True:
        # Button is active low because of pull-up resistor.
        button_pressed = (button.value() == 0)
        await asyncio.sleep_ms(50)

async def main():
    """
    Initialize hardware and start tasks.
    """
    led = create_led(LED_PIN)
    button = create_button(BUTTON_PIN)

    await asyncio.gather(
        blink_led_task(led),
        button_task(button),
    )

asyncio.run(main())

Expected Behavior

  • LED blinks slowly by default
  • Holding the button makes it blink faster
  • Releasing the button returns it to normal speed

Expected Shell Output

LED: 1 Fast mode: False
LED: 0 Fast mode: False
LED: 1 Fast mode: True
LED: 0 Fast mode: True
LED: 1 Fast mode: False

Task

  • Press and hold the button for 3 seconds.
  • Observe how the blink rate changes immediately.

5) Hands-on Exercise 3: Add a Network Readiness Task (7 minutes)

Goal

Create a simple async task that simulates Wi-Fi readiness and status reporting.

This exercise focuses on project structure, not full networking.

Updated config.py

# config.py

LED_PIN = 15
BUTTON_PIN = 14
BLINK_INTERVAL_MS = 500
FAST_BLINK_INTERVAL_MS = 150
STATUS_INTERVAL_MS = 2000

Updated app.py

# app.py

import uasyncio as asyncio
from config import (
    LED_PIN,
    BUTTON_PIN,
    BLINK_INTERVAL_MS,
    FAST_BLINK_INTERVAL_MS,
    STATUS_INTERVAL_MS,
)
from hardware import create_led, create_button

button_pressed = False
network_ready = False

async def blink_led_task(led):
    """
    Blink the LED at a speed based on button state.
    """
    while True:
        interval = FAST_BLINK_INTERVAL_MS if button_pressed else BLINK_INTERVAL_MS
        led.toggle()
        print("LED:", led.value(), "Fast mode:", button_pressed)
        await asyncio.sleep_ms(interval)

async def button_task(button):
    """
    Update shared button state.
    """
    global button_pressed

    while True:
        button_pressed = (button.value() == 0)
        await asyncio.sleep_ms(50)

async def network_task():
    """
    Simulate a network startup process.
    """
    global network_ready

    print("Connecting to Wi-Fi...")
    await asyncio.sleep(3)
    network_ready = True
    print("Wi-Fi connected")

async def status_task():
    """
    Periodically report overall app status.
    """
    while True:
        print("Status: button_pressed =", button_pressed, "| network_ready =", network_ready)
        await asyncio.sleep_ms(STATUS_INTERVAL_MS)

async def main():
    """
    Initialize hardware and start all tasks.
    """
    led = create_led(LED_PIN)
    button = create_button(BUTTON_PIN)

    await asyncio.gather(
        blink_led_task(led),
        button_task(button),
        network_task(),
        status_task(),
    )

asyncio.run(main())

Expected Output

Connecting to Wi-Fi...
LED: 1 Fast mode: False
LED: 0 Fast mode: False
Status: button_pressed = False | network_ready = False
Wi-Fi connected
Status: button_pressed = False | network_ready = True

Discussion Points

Good Practices Demonstrated

  • Constants in a separate config module
  • Reusable hardware setup functions
  • Small task responsibilities
  • Shared state kept simple
  • No blocking sleep() calls inside async tasks

Common Mistakes to Avoid

  • Putting all logic in main.py
  • Using time.sleep() in async code
  • Reading inputs too slowly
  • Forgetting await asyncio.sleep(...)
  • Mixing hardware setup with business logic

Mini-Challenge

Extend the app so that: - The LED turns on solid when network_ready becomes True - The button still changes blink speed before connection - Status messages continue every 2 seconds

Hint

Use a new task or update the LED task to check network_ready.


Review Questions

  1. Why is it helpful to split a Pico project into multiple files?
  2. What should each async task do in a small application?
  3. Why should async tasks yield control often?
  4. What is the purpose of a config module?
  5. How does shared state simplify small async coordination?

Summary

In this session, learners built a small asynchronous Pico application using: - Modular project structure - Reusable hardware helpers - Config-driven design - Multiple uasyncio tasks - Shared application state

This approach creates a solid foundation for larger Pico 2 W IoT and hardware projects.


Suggested Next Session

  • Async Wi-Fi connection and reconnection handling
  • Reading real sensors in background tasks
  • Publishing data to a cloud service or local web server

Back to Chapter | Back to Master Plan | Previous Session