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,
ifstatements, 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
- Install Thonny.
- Connect Pico 2 W to your computer via USB.
- In Thonny:
- Go to Tools > Options > Interpreter
- Select MicroPython (Raspberry Pi Pico)
- Choose the correct port
- Open the Files pane to access the board filesystem.
- Save files directly to the Pico as:
main.pyconfig.pyhardware.pyapp.py
Recommended Project Layout
/
├── 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 regularlyblink_led_task()→ blinks LED based on statenetwork_task()→ connects to Wi-Fi or reports statusmain()→ 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
15through 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_MSinconfig.pyto200and 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
- Why is it helpful to split a Pico project into multiple files?
- What should each async task do in a small application?
- Why should async tasks yield control often?
- What is the purpose of a config module?
- 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