Skip to content

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

  1. Connect the Pico 2 W to your computer via USB.
  2. Open Thonny.
  3. Go to Tools > Options > Interpreter.
  4. Select MicroPython (Raspberry Pi Pico).
  5. Choose the correct port for the Pico.
  6. Verify the REPL works by running: python print("Hello Pico 2 W")
  7. Save scripts directly to the Pico as main.py when 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 addresses
  • i2c.readfrom(addr, nbytes) — reads bytes
  • i2c.writeto(addr, data) — writes bytes
  • i2c.readfrom_mem(addr, memaddr, nbytes) — reads register memory
  • i2c.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 blocking time.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

  1. Wire the I2C peripheral to the Pico 2 W.
  2. Run an I2C scan.
  3. Record the addresses found.
  4. Identify the peripheral using its datasheet.

Expected Result

I2C devices found: [60]

Exercise 2: Create a Non-Blocking UART Logger

Steps

  1. Configure UART0 with 115200 baud.
  2. Create an async task that prints a counter every second.
  3. Add a second task that sends status messages over UART every 5 seconds.
  4. 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

  1. Wire an SPI device to SPI0 pins.
  2. Initialize SPI at 1 MHz.
  3. Toggle CS correctly.
  4. 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

  1. What is the main difference between I2C and SPI?
  2. Why is UART considered asynchronous?
  3. What happens if an async task never calls await?
  4. When would you choose SPI instead of I2C?
  5. How does uasyncio improve 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