Session 2: Working with I2C, SPI, and UART in Async Applications
Synopsis
Explains how common peripheral buses fit into cooperative systems, including transaction timing, serialization of access, and device polling patterns.
Session Content
Session 2: Working with I2C, SPI, and UART in Async Applications
Session Overview
In this session, learners will explore how to use the Raspberry Pi Pico 2 W with MicroPython to communicate with external devices over I2C, SPI, and UART while keeping applications responsive using asynchronous programming. The focus is on building non-blocking hardware interaction patterns suitable for sensor polling, displays, and serial communication in embedded IoT projects.
Session Duration
~45 minutes
Learning Objectives
By the end of this session, learners will be able to:
- Explain the purpose and typical use cases for I2C, SPI, and UART.
- Initialize and use I2C, SPI, and UART in MicroPython on the Pico 2 W.
- Structure hardware communication tasks using
uasyncio. - Avoid common blocking patterns in embedded applications.
- Build a simple asynchronous application that reads a sensor and reports data over UART.
Prerequisites
- Raspberry Pi Pico 2 W
- Micro-USB cable
- Thonny IDE installed
- MicroPython firmware flashed on Pico 2 W
- Basic understanding of Python functions, loops, and classes
- Breadboard and jumper wires
- Optional hardware for exercises:
- I2C device such as SSD1306 OLED display or BME280 sensor
- SPI device such as an MCP3008 ADC or SPI display
- USB-to-TTL UART device or another serial device
Required Setup in Thonny
- Connect the Pico 2 W to your computer via USB.
- Open Thonny.
- Go to Tools > Options > Interpreter.
- Select MicroPython (Raspberry Pi Pico).
- Choose the correct port for the Pico.
- Verify the REPL works by running:
python print("Hello Pico 2 W") - Save scripts directly to the Pico as
main.pywhen testing on-device.
Session Agenda
1. Theory: Serial Bus Fundamentals
2. Using I2C in MicroPython
3. Using SPI in MicroPython
4. Using UART in MicroPython
5. Asynchronous Patterns with uasyncio
6. Hands-On Exercise: Async Sensor Read + UART Logging
7. Wrap-Up and Troubleshooting
1. Theory: Serial Bus Fundamentals
I2C
- Two-wire protocol: SCL and SDA
- Multiple devices can share the same bus
- Devices have unique addresses
- Best for short-distance communication with sensors and low-speed peripherals
- Common use cases:
- Temperature and humidity sensors
- OLED displays
- RTC modules
SPI
- Four main wires:
- SCK: clock
- MOSI: master out, slave in
- MISO: master in, slave out
- CS: chip select
- Faster than I2C
- Supports higher throughput for displays, ADCs, flash memory
- Common use cases:
- TFT displays
- ADCs like MCP3008
- SD cards
UART
- Asynchronous serial communication
- Uses TX and RX
- No shared clock line
- Often used for device-to-device communication, debug consoles, GPS modules, and RS-232/TTL bridges
- Simple and reliable for text-based data exchange
2. Using I2C in MicroPython
Default Pico 2 W I2C Pins
Common pin mapping:
- I2C0: GP0 (SDA), GP1 (SCL)
- I2C1: GP2 (SDA), GP3 (SCL)
I2C Initialization Example
from machine import Pin, I2C
# Create I2C bus on GP0 (SDA) and GP1 (SCL)
i2c = I2C(0, scl=Pin(1), sda=Pin(0), freq=400000)
print("I2C devices found:", i2c.scan())
Example Output
I2C devices found: [60]
Common I2C Methods
i2c.scan()— lists device addressesi2c.readfrom(addr, nbytes)— reads bytesi2c.writeto(addr, data)— writes bytesi2c.readfrom_mem(addr, memaddr, nbytes)— reads register memoryi2c.writeto_mem(addr, memaddr, data)— writes register memory
I2C Sensor Read Example
from machine import Pin, I2C
import time
# Initialize I2C on GP0/GP1
i2c = I2C(0, scl=Pin(1), sda=Pin(0), freq=400000)
# Replace with your device address
DEVICE_ADDR = 0x3C
# Scan for connected devices
devices = i2c.scan()
print("Found devices:", [hex(d) for d in devices])
# Example read from a device register
# This is a generic pattern; actual register depends on the sensor
try:
data = i2c.readfrom_mem(DEVICE_ADDR, 0x00, 2)
print("Read bytes:", data)
except OSError as e:
print("I2C read error:", e)
3. Using SPI in MicroPython
Default Pico 2 W SPI Pins
Common mapping:
- SPI0: SCK GP18, MOSI GP19, MISO GP16, CS GP17
- SPI1: SCK GP10, MOSI GP11, MISO GP8, CS GP9
SPI Initialization Example
from machine import Pin, SPI
# Initialize SPI0
spi = SPI(
0,
baudrate=10_000_000,
polarity=0,
phase=0,
sck=Pin(18),
mosi=Pin(19),
miso=Pin(16)
)
cs = Pin(17, Pin.OUT)
cs.value(1)
print("SPI initialized")
SPI Write/Read Example
from machine import Pin, SPI
spi = SPI(
0,
baudrate=1_000_000,
polarity=0,
phase=0,
sck=Pin(18),
mosi=Pin(19),
miso=Pin(16)
)
cs = Pin(17, Pin.OUT, value=1)
# Write a command byte, then read a response
cs.value(0)
spi.write(b'\x9F') # Common "read ID" command for some SPI devices
response = spi.read(3, 0x00)
cs.value(1)
print("SPI response:", response)
Example Output
SPI response: b'\x00\x00\x00'
Notes
- Many SPI devices require command sequences specific to the chip
- Always check the datasheet for:
- clock polarity and phase
- maximum baudrate
- command format
- chip select timing
4. Using UART in MicroPython
Default UART Pins
Common UART mapping on Pico:
- UART0: TX GP0, RX GP1
- UART1: TX GP4, RX GP5
UART Initialization Example
from machine import Pin, UART
uart = UART(0, baudrate=115200, tx=Pin(0), rx=Pin(1))
uart.write("Hello UART!\r\n")
print("UART configured")
Reading UART Data
from machine import Pin, UART
import time
uart = UART(0, baudrate=115200, tx=Pin(0), rx=Pin(1))
while True:
if uart.any():
data = uart.read()
print("Received:", data)
time.sleep(0.1)
Example Output
Received: b'OK\r\n'
5. Asynchronous Patterns with uasyncio
Why Use Async?
In embedded applications, polling devices or waiting for serial data can block the main loop. uasyncio allows you to:
- read sensors periodically
- process incoming UART data
- update displays
- keep the system responsive
Basic Async Structure
import uasyncio as asyncio
async def task1():
while True:
print("Task 1 running")
await asyncio.sleep(1)
async def task2():
while True:
print("Task 2 running")
await asyncio.sleep(2)
async def main():
asyncio.create_task(task1())
asyncio.create_task(task2())
while True:
await asyncio.sleep(5)
asyncio.run(main())
Example Output
Task 1 running
Task 2 running
Task 1 running
Task 1 running
Task 2 running
Best Practices
- Use
await asyncio.sleep()instead of blockingtime.sleep() - Keep hardware transactions short
- Separate sensor polling, communication, and control logic into tasks
- Use shared state carefully
6. Hands-On Exercise: Async Sensor Read + UART Logging
Exercise Goal
Create an asynchronous application that:
- Initializes I2C to communicate with a sensor
- Periodically reads data from the sensor
- Sends readings over UART
- Keeps the application responsive using uasyncio
Option A: Generic I2C Device Polling Demo
If a specific sensor is unavailable, use I2C scanning and periodic status logging.
Wiring
- Connect your I2C device:
- SDA → GP0
- SCL → GP1
- VCC → 3V3
- GND → GND
Code
# main.py
from machine import Pin, I2C, UART
import uasyncio as asyncio
import time
# ----------------------------
# Hardware initialization
# ----------------------------
# I2C bus on GP0/GP1
i2c = I2C(0, scl=Pin(1), sda=Pin(0), freq=400000)
# UART for logging/debugging
uart = UART(0, baudrate=115200, tx=Pin(0), rx=Pin(1))
# Shared state
last_scan = []
counter = 0
# ----------------------------
# Async tasks
# ----------------------------
async def scan_i2c_task():
global last_scan
while True:
last_scan = i2c.scan()
print("I2C devices:", [hex(addr) for addr in last_scan])
uart.write("I2C devices: {}\r\n".format([hex(addr) for addr in last_scan]))
await asyncio.sleep(5)
async def heartbeat_task():
global counter
while True:
counter += 1
print("Heartbeat:", counter)
await asyncio.sleep(1)
async def main():
asyncio.create_task(scan_i2c_task())
asyncio.create_task(heartbeat_task())
while True:
await asyncio.sleep(10)
# ----------------------------
# Run application
# ----------------------------
try:
asyncio.run(main())
finally:
asyncio.new_event_loop()
Example Output
I2C devices: ['0x3c']
Heartbeat: 1
Heartbeat: 2
Heartbeat: 3
I2C devices: ['0x3c']
Heartbeat: 4
Option B: Async BME280 Reading Pattern
Use this if you have a BME280 sensor available. The code below demonstrates a common async structure; actual register handling requires a BME280 driver.
Code
from machine import Pin, I2C
import uasyncio as asyncio
# I2C on GP0/GP1
i2c = I2C(0, scl=Pin(1), sda=Pin(0), freq=400000)
BME280_ADDR = 0x76
async def read_sensor_task():
while True:
try:
# Example placeholder: read 8 bytes from a register block
raw = i2c.readfrom_mem(BME280_ADDR, 0xF7, 8)
print("Raw sensor bytes:", raw)
except OSError as e:
print("Sensor read error:", e)
await asyncio.sleep(2)
async def main():
asyncio.create_task(read_sensor_task())
while True:
await asyncio.sleep(10)
asyncio.run(main())
Suggested Extension
- Add a proper BME280 driver
- Convert raw values to temperature, humidity, and pressure
- Send readings to a display or over Wi-Fi in a later session
7. Guided Practical Exercise
Exercise 1: Discover Devices on the I2C Bus
Steps
- Wire the I2C peripheral to the Pico 2 W.
- Run an I2C scan.
- Record the addresses found.
- Identify the peripheral using its datasheet.
Expected Result
I2C devices found: [60]
Exercise 2: Create a Non-Blocking UART Logger
Steps
- Configure UART0 with 115200 baud.
- Create an async task that prints a counter every second.
- Add a second task that sends status messages over UART every 5 seconds.
- Observe both tasks running concurrently.
Starter Code
from machine import Pin, UART
import uasyncio as asyncio
uart = UART(0, baudrate=115200, tx=Pin(0), rx=Pin(1))
async def counter_task():
n = 0
while True:
n += 1
print("Counter:", n)
await asyncio.sleep(1)
async def uart_task():
while True:
uart.write("Status: system alive\r\n")
await asyncio.sleep(5)
async def main():
asyncio.create_task(counter_task())
asyncio.create_task(uart_task())
while True:
await asyncio.sleep(20)
asyncio.run(main())
Expected Output
Counter: 1
Counter: 2
Counter: 3
Status: system alive
Counter: 4
Counter: 5
Exercise 3: SPI Device Initialization Drill
Steps
- Wire an SPI device to SPI0 pins.
- Initialize SPI at 1 MHz.
- Toggle CS correctly.
- Send a command byte and read a response.
Starter Code
from machine import Pin, SPI
spi = SPI(
0,
baudrate=1_000_000,
polarity=0,
phase=0,
sck=Pin(18),
mosi=Pin(19),
miso=Pin(16)
)
cs = Pin(17, Pin.OUT, value=1)
def spi_transaction(command):
cs.value(0)
spi.write(bytes([command]))
response = spi.read(1, 0x00)
cs.value(1)
return response
result = spi_transaction(0x9F)
print("SPI result:", result)
Common Troubleshooting Tips
I2C
- Check SDA/SCL wiring
- Verify pull-up resistors are present if required by the device
- Confirm voltage is 3.3V, not 5V
- Use
i2c.scan()to verify device presence - Lower the I2C clock if communication is unstable
SPI
- Verify correct SCK/MOSI/MISO/CS pins
- Confirm CPOL and CPHA settings
- Ensure CS is toggled properly
- Check the device datasheet for command format
UART
- TX on one device must connect to RX on the other
- RX on one device must connect to TX on the other
- Make sure baud rates match
- Confirm common ground between devices
Async
- Avoid
time.sleep()in async tasks - Ensure tasks regularly
await - Keep long operations short or split them into steps
- Use
asyncio.sleep(0)to yield control if needed
Review Questions
- What is the main difference between I2C and SPI?
- Why is UART considered asynchronous?
- What happens if an async task never calls
await? - When would you choose SPI instead of I2C?
- How does
uasyncioimprove responsiveness in embedded applications?
Summary
In this session, you learned how to:
- Connect to peripherals using I2C, SPI, and UART
- Initialize each bus in MicroPython
- Structure embedded code with uasyncio
- Build non-blocking applications that can scale to more complex IoT systems
Suggested Next Steps
- Add a real sensor driver for BME280 or MPU6050
- Display sensor values on an SSD1306 OLED over I2C
- Use SPI to drive a graphical display or ADC
- Forward UART data to a host PC for logging
- Combine bus communication with Wi-Fi in upcoming IoT sessions
Back to Chapter | Back to Master Plan | Previous Session | Next Session