Building reliable Raspberry Pico projects requires moving past simple blinky LEDs and tackling hardware bus protocols. The I2C bus is the backbone of modern sensor integration, but it is also the most common point of failure for hobbyists and students. A loose wire, a missing pull-up resistor, or an incorrect clock speed will silently lock the bus or throw cryptic OS errors.

In this guide, we are building a self-contained environmental data logger using the Raspberry Pi Pico W and the AHT20 temperature and humidity sensor. Unlike generic tutorials that rely on third-party libraries with hidden dependencies, this project uses a fully self-contained MicroPython script that directly manipulates the I2C registers. You will learn exactly how to wire the hardware, deploy the firmware, and systematically debug the bus when things go wrong.

Project Spec Sheet and Parts List

Before breadboarding, verify your components against this spec sheet. The AHT20 is chosen over the DHT11/DHT22 because it uses a digital I2C interface rather than a timing-sensitive single-wire protocol, making it vastly more reliable for embedded logging.

Component Exact Model / Variant Key Specification Est. Price (2026)
Microcontroller Raspberry Pi Pico W (RP2040) Dual-core 133MHz, 2MB Flash, CYW43439 WiFi $6.00
Sensor Module AHT20 Breakout (3.3V compatible) I2C Interface, ±0.3°C / ±2% RH accuracy $4.50
Pull-up Resistors 2.2kΩ 1/4W Carbon Film (x2) Required for 400kHz I2C Fast-mode $0.10
Wiring & Prototyping 830-point Breadboard, 22 AWG Solid Core Standard tie-point, pre-cut jumper kit $8.00
Callout: Board Variant Target
The code and pin mappings in this article specifically target the Raspberry Pi Pico W running MicroPython v1.23 or newer. If you are using the standard Pico (without WiFi), the code remains 100% identical, as the RP2040 silicon and GPIO mappings for I2C0 are unchanged.

Hardware Wiring and Pin Mapping

The RP2040 microcontroller features flexible I/O, meaning I2C can be mapped to multiple pin pairs. We are using the default I2C0 block on GPIO 4 (SDA) and GPIO 5 (SCL).

Pico W Pin GPIO / Function AHT20 Pin Wire Color
Pin 6 GP4 (I2C0 SDA) SDA Blue
Pin 7 GP5 (I2C0 SCL) SCL Yellow
Pin 36 3V3(OUT) VCC / VIN Red
Pin 38 GND GND Black

Wiring Steps and Pull-Up Resistor Rule

  1. Power the Bus: Connect the Pico W Pin 36 (3V3) to the breadboard positive rail, and Pin 38 (GND) to the negative rail.
  2. Wire the Sensor: Connect the AHT20 VCC to 3V3, GND to GND, SDA to GP4, and SCL to GP5.
  3. Install Pull-Up Resistors: Connect one 2.2kΩ resistor between the 3V3 rail and the SDA line. Connect the second 2.2kΩ resistor between the 3V3 rail and the SCL line.
Warning: The Internal Pull-Up Trap
The RP2040 has internal pull-up resistors enabled via software, but they are roughly 50kΩ to 60kΩ. According to the NXP I2C-bus specification (UM10204), Fast-mode (400kHz) requires pull-ups between 1kΩ and 2.2kΩ to overcome bus capacitance and achieve sharp rising edges. Relying on internal pull-ups at 400kHz will result in data corruption. Always use external physical resistors.

Complete MicroPython Firmware

Below is the complete, self-contained MicroPython script. It does not require you to download external .py driver libraries. It initializes the I2C bus, scans for the device, triggers the AHT20 measurement sequence via raw register writes, and parses the returned bytes into human-readable Celsius and Relative Humidity values.


import machine
import time
import sys

# --- Pin Definitions ---
I2C_SDA = 4
I2C_SCL = 5
AHT20_ADDR = 0x38

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

def scan_bus():
    """Scans the I2C bus and verifies the AHT20 is present."""
    devices = i2c.scan()
    if not devices:
        raise RuntimeError("I2C scan found no devices. Check wiring, power, and pull-ups.")
    if AHT20_ADDR not in devices:
        raise RuntimeError(f"AHT20 not found at 0x{AHT20_ADDR:02X}. Found: {[hex(d) for d in devices]}")
    print(f"[OK] AHT20 found at 0x{AHT20_ADDR:02X}")

def read_aht20():
    """Triggers measurement and reads raw bytes, returning Temp (C) and Humidity (%)."""
    # 1. Initialize sensor (required after power-on)
    i2c.writeto(AHT20_ADDR, b'\xBE\x08\x00')
    time.sleep_ms(10)
    
    # 2. Trigger measurement command
    i2c.writeto(AHT20_ADDR, b'\xAC\x33\x00')
    time.sleep_ms(80) # Wait for measurement to complete
    
    # 3. Read 7 bytes of data
    data = i2c.readfrom(AHT20_ADDR, 7)
    
    # 4. Check status bit (Bit 7 of first byte indicates busy)
    if data[0] & 0x80:
        raise RuntimeError("AHT20 busy timeout: Sensor did not complete measurement.")
        
    # 5. Parse raw 20-bit humidity and temperature values
    raw_h = (data[1] << 12) | (data[2] << 4) | (data[3] >> 4)
    raw_t = ((data[3] & 0x0F) << 16) | (data[4] << 8) | data[5]
    
    # 6. Apply conversion formulas from AHT20 datasheet
    humidity = (raw_h / 1048576.0) * 100.0
    temp = (raw_t / 1048576.0) * 200.0 - 50.0
    
    return temp, humidity

# --- Main Execution Loop ---
try:
    scan_bus()
except RuntimeError as e:
    print(f"[FATAL] {e}")
    sys.exit()

print("Starting environmental logging...")
while True:
    try:
        t, h = read_aht20()
        print(f"Temp: {t:.2f} C | Humidity: {h:.2f} %")
    except OSError as e:
        # Catches I2C bus lockups or physical disconnects
        print(f"[WARN] I2C Bus Error: {e}. Resetting bus...")
        i2c = machine.I2C(0, sda=machine.Pin(I2C_SDA), scl=machine.Pin(I2C_SCL), freq=400000)
    except Exception as e:
        print(f"[ERROR] Unexpected failure: {e}")
    
    time.sleep(2)

Debugging I2C Failures: The First Three Things to Check

When working on Raspberry Pico projects involving I2C, you will inevitably encounter bus failures. The two most common exact error strings you will see in the Thonny IDE or serial console are:

  • OSError: [Errno 121] Remote I/O error (or ENODEV on some builds)
  • RuntimeError: I2C scan found no devices. Check wiring, power, and pull-ups.

When the code fails, do not immediately rewrite the software. Hardware bus issues account for 95% of these errors. Here are the first three things to check, ranked by probability:

1. Verify Pull-Up Resistors and Voltage Levels

Disconnect the Pico from USB. Set your multimeter to continuity/resistance mode. Measure between the 3V3 rail and the SDA line; you should read exactly 2.2kΩ (or whatever pull-up value you installed). Repeat for SCL. If you read infinite resistance (OL), your pull-ups are missing or the breadboard contacts are dead. Furthermore, ensure your sensor breakout is rated for 3.3V. Feeding a 5V-only sensor module with 3.3V will result in the internal logic failing to recognize the I2C clock edges.

2. Run a Raw I2C Scan to Check Address Mismatches

Some AHT20 breakouts share a PCB with other sensors (like the BMP280) and might have address selection pads. While the AHT20 is hardcoded to 0x38, if you are adapting this code for a BME280, the address might be 0x76 or 0x77 depending on the SDO pad state. Open the Thonny shell and run a quick two-line scan:


import machine
i2c = machine.I2C(0, sda=machine.Pin(4), scl=machine.Pin(5))
print([hex(x) for x in i2c.scan()])

If the list is empty, your wiring or pull-ups are wrong. If it returns an address different from 0x38, update the AHT20_ADDR variable in the main script.

3. Check for SDA/SCL Swaps and Bus Capacitance

The RP2040 is somewhat forgiving, but swapping SDA and SCL will cause the Remote I/O error because the master will try to drive the clock line as a data line, resulting in a collision. Double-check your pinout against the official Pico W datasheet. Additionally, if your jumper wires exceed 30cm (12 inches), the parasitic capacitance of the wire will round off the I2C square waves, causing data corruption. If you must run long wires, drop the freq parameter in the code from 400000 to 100000 (Standard-mode) and increase pull-ups to 4.7kΩ.

Extending and Simplifying the Build

Once the baseline logger is stable, you can adapt the hardware to fit your specific project constraints.

How to Simplify: Deep Sleep and Power Reduction

If you are building a remote, battery-powered node, drop the Pico W and use the standard Raspberry Pi Pico (saving $2 and eliminating the WiFi chip's quiescent current draw). Modify the main loop to take a single reading, write it to an onboard flash file or external EEPROM, and then invoke machine.deepsleep(600000) to sleep for 10 minutes. The RP2040's deep sleep drops current consumption to roughly 1.3mA, allowing a standard 2000mAh 18650 Li-ion cell to run the node for over a month.

How to Extend: Wireless MQTT Telemetry

To turn this into an IoT node, leverage the Pico W's CYW43439 WiFi module. Import the network and umqtt.simple modules. Connect to your local router, instantiate an MQTT client, and publish the t and h variables to a broker like Mosquitto or Home Assistant.

Pro-Tip for WiFi Extensions: The WiFi radio introduces significant current spikes (up to 150mA) during transmission. If you are powering the Pico W from a breadboard power supply or a weak USB hub, the voltage droop can cause the RP2040 to brownout and reset. Always place a 100µF to 470µF electrolytic capacitor across the 3V3 and GND rails near the Pico W to buffer these transient RF loads.

For more details on MicroPython I2C memory management and bus recovery, refer to the official MicroPython machine.I2C documentation. By mastering the raw I2C registers and understanding the physical layer requirements of the bus, your Raspberry Pico projects will transition from fragile prototypes to robust, deployable hardware.