The Core Decision: Which Raspberry Pi Pico Board for Your Lessons?

When structuring embedded pico lessons for a classroom, makerspace, or your own bench learning, the first hurdle is hardware selection. The Raspberry Pi Pico ecosystem has fractured into several variants, and picking the wrong one leads to mid-lesson interruptions over missing wireless stacks or incompatible pinouts.

Use this decision matrix to select the exact board for your curriculum. Follow the logic down to the default recommendation.

Condition / Requirement Board Variant Approx. Cost (2026)
Needs WiFi/BLE telemetry (MQTT, HTTP) Raspberry Pi Pico W (RP2040) $6.00
Requires 5V logic tolerance or higher clock speeds Pimoroni Pico Plus 2 (RP2350) $12.00
Offline sensor logging, maximum GPIO count, standard 3.3V logic Raspberry Pi Pico 2 (RP2350) $5.00
Default Recommendation: For standard embedded lessons focusing on I2C, SPI, and ADC fundamentals without the distraction of network stack debugging, buy the Raspberry Pi Pico 2 (RP2350). It offers the latest MicroPython support, dual-core ARM Cortex-M33 processors, and eliminates the RF shielding complications that confuse beginners on the Pico W.

Project Build: I2C Environmental Datalogger (The Ultimate Pico Lesson)

This project is designed to teach three critical embedded concepts in one build: I2C bus communication, SPI peripheral mounting, and non-blocking sensor polling. We are building a self-contained datalogger that reads temperature and humidity, displays it on an OLED, and logs it to a microSD card.

Difficulty & Time Rating

  • Difficulty: Intermediate (2/5) - Requires basic I2C addressing and SPI filesystem mounting.
  • Bench Time: 45 minutes for wiring, 15 minutes for code deployment and debugging.

Exact Parts List

  • MCU: Raspberry Pi Pico 2 (RP2350) with pre-soldered headers ($5.00)
  • Sensor: Adafruit AHT20 Temperature & Humidity Sensor Breakout (Product ID: 4566) ($5.95)
  • Display: Adafruit Monochrome 0.96" 128x64 OLED Graphic Display (I2C, Product ID: 326) ($9.95)
  • Storage: Adafruit MicroSD Card Breakout Board (Product ID: 254) ($7.50)
  • Consumables: Half-size breadboard, 22 AWG solid core jumper wires, 8GB microSD card (FAT32 formatted).

Note: The code provided below specifically targets the Raspberry Pi Pico 2 (RP2350) running MicroPython firmware v1.23.0 or newer. If you are using an original RP2040 Pico, the pin mappings remain identical, but ensure you flash the RP2040-specific .uf2 file.

Wiring and Pin Mapping

Mixing I2C and SPI on the same board requires strict adherence to the Pico's GPIO multiplexing rules. The RP2350 allows I2C and SPI on multiple pins, but sticking to the default hardware blocks (I2C0 and SPI0) prevents DMA routing conflicts in more advanced lessons.

Pico 2 Pin (GPIO) Function Target Component Wire Color
GP4 (Pin 6)I2C0 SDAAHT20 & OLED SDABlue
GP5 (Pin 7)I2C0 SCLAHT20 & OLED SCLYellow
GP16 (Pin 21)SPI0 RX (MISO)MicroSD DOOrange
GP17 (Pin 22)SPI0 CSnMicroSD CSGreen
GP18 (Pin 24)SPI0 SCKMicroSD CLKPurple
GP19 (Pin 25)SPI0 TX (MOSI)MicroSD DIGray
3V3 (Pin 36)VCC / PowerAll Sensor VCC pinsRed
GND (Pin 38)GroundAll Sensor GND pinsBlack
Wiring Tip: The AHT20 and SSD1306 OLED both include onboard 10kΩ pull-up resistors. When wiring them in parallel on I2C0, the combined pull-up is ~5kΩ, which is perfectly within the I2C specification for 400kHz Fast Mode. Do not add external pull-ups to the breadboard, or you will pull the SDA line too low and cause logic errors.

Complete MicroPython Code with Error Handling

This script is entirely self-contained. It includes a minimal I2C driver for the AHT20 to avoid external library dependencies, ensuring it compiles and runs the moment you hit 'Run' in Thonny. Save this as main.py on your Pico.

# pico_lessons_datalogger.py
# Target: Raspberry Pi Pico 2 (RP2350) / MicroPython v1.23+
import machine
import ssd1306
import sdcard
import os
import time
import framebuf

# --- PIN DEFINITIONS ---
I2C_SDA = machine.Pin(4)
I2C_SCL = machine.Pin(5)
SPI_SCK = machine.Pin(18)
SPI_MOSI = machine.Pin(19)
SPI_MISO = machine.Pin(16)
SPI_CS = machine.Pin(17)

# --- HARDWARE INITIALIZATION ---
# Initialize I2C0 at 400kHz
i2c = machine.I2C(0, sda=I2C_SDA, scl=I2C_SCL, freq=400000)

# Initialize SPI0 for SD Card
spi = machine.SPI(0, baudrate=1000000, polarity=0, phase=0,
                  sck=SPI_SCK, mosi=SPI_MOSI, miso=SPI_MISO)

# --- AHT20 SENSOR CLASS (Self-Contained) ---
class AHT20:
    def __init__(self, i2c, address=0x38):
        self.i2c = i2c
        self.addr = address
        # Initialize sensor
        self.i2c.writeto(self.addr, b'\xBE\x08\x00')
        time.sleep(0.04)

    def read_data(self):
        # Trigger measurement
        self.i2c.writeto(self.addr, b'\xAC\x33\x00')
        time.sleep(0.08)
        data = self.i2c.readfrom(self.addr, 6)
        
        # Parse raw bytes
        raw_hum = ((data[1] << 12) | (data[2] << 4) | (data[3] >> 4))
        raw_temp = ((data[3] & 0x0F) << 16) | (data[4] << 8) | data[5]
        
        humidity = (raw_hum / 0x100000) * 100
        temperature = ((raw_temp / 0x100000) * 200) - 50
        return round(temperature, 2), round(humidity, 2)

# --- MAIN EXECUTION BLOCK ---
try:
    # 1. Setup OLED (Assumes standard ssd1306.py is in /lib)
    oled = ssd1306.SSD1306_I2C(128, 64, i2c)
    oled.fill(0)
    oled.text('System Boot...', 0, 0)
    oled.show()

    # 2. Setup Sensor
    sensor = AHT20(i2c)
    
    # 3. Mount SD Card
    sd = sdcard.SDCard(spi, SPI_CS)
    vfs = os.VfsFat(sd)
    os.mount(vfs, '/sd')
    oled.text('SD Mounted OK', 0, 10)
    oled.show()

    # 4. Datalogging Loop
    log_file = '/sd/pico_log.csv'
    with open(log_file, 'a') as f:
        if f.tell() == 0:
            f.write('Timestamp,Temp_C,Humidity_pct\n')

    cycle = 0
    while True:
        temp, hum = sensor.read_data()
        timestamp = time.localtime()
        time_str = '{:02d}:{:02d}:{:02d}'.format(timestamp[3], timestamp[4], timestamp[5])
        
        # Update OLED
        oled.fill(0)
        oled.text(f'Time: {time_str}', 0, 0)
        oled.text(f'Temp: {temp} C', 0, 20)
        oled.text(f'Hum:  {hum} %', 0, 35)
        oled.show()
        
        # Write to SD
        with open(log_file, 'a') as f:
            f.write(f'{time_str},{temp},{hum}\n')
        
        cycle += 1
        time.sleep(5)

except OSError as e:
    # Catch I2C or SPI hardware faults
    oled.fill(0)
    oled.text('HARDWARE FAULT', 0, 0)
    oled.text(str(e), 0, 20)
    oled.show()
    print(f'Critical Hardware Error: {e}')
except Exception as e:
    print(f'Unexpected Error: {e}')

Debugging: Fixing the "OSError: [Errno 121] EIO" I2C Fault

If you run the script and immediately hit a wall, you will likely see this exact error string in the Thonny REPL:

OSError: [Errno 121] EIO

This is the universal MicroPython error for an I2C bus communication failure. It means the Pico sent a clock pulse but received no acknowledgment (NACK) from the target device, or the SDA line is stuck low.

The First Three Things to Check When It Fails

  1. Verify the Common Ground: The most frequent cause of EIO on a breadboard is a floating ground. Ensure the GND pin on the Pico is physically wired to the GND rails of both the AHT20 and the OLED. A missing ground reference causes the I2C logic high to float above the Pico's 3.3V tolerance, triggering internal protection diodes and halting communication.
  2. Run an I2C Bus Scan: Comment out the SD card code and run print(i2c.scan()). You should see [56, 60] (the decimal addresses for 0x38 and 0x3C). If the list is empty, your SDA/SCL wires are swapped, or a wire is broken internally.
  3. Check Breadboard Power Rail Continuity: Many half-size breadboards have a split in the middle of the red/blue power rails. If your Pico is plugged into row 1, but your sensor is plugged into row 40, the VCC might not be crossing the gap.

Ranked Causes for Persistent I2C Errors

Rank Cause Fix / Measurement
1 SDA and SCL swapped Swap blue/yellow wires. I2C0 SDA is strictly GP4, SCL is GP5.
2 Bus capacitance too high Keep I2C jumper wires under 30cm. Measure SDA rise time with a scope; if >300ns, drop I2C freq to 100kHz.
3 Address collision Ensure no other device on the bus defaults to 0x38 or 0x3C.
4 3.3V vs 5V logic mismatch If using a 5V Arduino-style sensor, you MUST use a logic level shifter (e.g., Adafruit 757).

Extending or Simplifying the Build

Not every classroom or weekend session has time for a full SPI filesystem mount. Here is how to adapt this lesson plan on the fly.

How to Simplify (The 15-Minute Version)

If students are struggling with SPI wiring or SD card formatting issues, drop the storage requirement entirely.

  • Remove the SD card breakout and all SPI pin definitions from the code.
  • Replace the file-writing block with a simple print(f'{time_str} | {temp}C | {hum}%') statement.
  • Use the Thonny plotter feature to visualize the serial output in real-time. This keeps the focus strictly on I2C sensor polling and OLED rendering.

How to Extend (The Advanced Telemetry Version)

For students who master the baseline build quickly, introduce asynchronous network telemetry.

  • Hardware Swap: Upgrade to the Raspberry Pi Pico W.
  • Software Addition: Import the network and umqtt.simple libraries.
  • Architecture Shift: Move the sensor reading loop into a secondary core using _thread.start_new_thread(). Use the main core exclusively to handle the WiFi stack and MQTT publishing to a local Mosquitto broker or Home Assistant instance. This teaches critical RTOS concepts regarding thread safety and I2C bus locking.

For deeper reading on the RP2350 architecture and MicroPython I2C implementations, consult the official Raspberry Pi Pico Python SDK documentation and the MicroPython machine.I2C class reference. For hardware-specific wiring and pull-up resistor calculations, the Adafruit AHT20 Learning Guide provides excellent schematic breakdowns.