The Raspberry Pi Pico 1 (RP2040) features a dual-core 133MHz Cortex-M0+, 264KB SRAM, and 26 usable GPIOs, while the newer Pico 2 (RP2350) upgrades to 150MHz Cortex-M33 cores, 520KB SRAM, and a significantly cleaner ADC. If you are building a precision battery logger or sensor node today, buy the original Pico 1 (RP2040) with pre-soldered headers (approx. $4.00). The Pico 2's 5V-tolerant GPIOs and improved ADC are excellent, but the RP2040's mature MicroPython ecosystem and lower price make it the definitive default for I2C sensor projects. Below, we break down the exact specifications, build a LiFePO4 capacity logger, and debug the most common I2C bus failures.

The Decision Matrix: Which Pico Variant Should You Buy?

Do not default to the newest board. Use this decision path to select the exact hardware for your bench:

If your project requires...Then choose this board variantWhy (Spec Constraint)
Strict budget (<$5) + mature MicroPython libsPico 1 (RP2040)Lowest cost, 100% library compatibility, 264KB RAM is enough for most I2C polling.
WiFi/BLE + MQTT telemetryPico W / Pico 2 WIntegrated Infineon CYW43439 radio. Note: Pico W uses 3 GPIOs internally for the radio.
Direct 5V sensor interfacing + precision ADCPico 2 (RP2350)RP2350 fixes the RP2040's noisy ADC and adds a secondary VREG for 5V-tolerant GPIO banks.
High-speed PIO state machines (e.g., RGB LEDs)Pico 1 or Pico 2Both have 8 PIO state machines. Pico 2 adds HSTX for DVI output.
The Concrete Pick: For the LiFePO4 logger built in this guide, we are using the Raspberry Pi Pico 1 (RP2040) with pre-soldered headers. We bypass the RP2040's internal ADC flaws by using an external I2C shunt monitor, making the Pico 1's lower price and massive community support the winning combination.

Spec-Sheet Deep Dive: RP2040 vs. RP2350 Hardware Limits

Understanding the Raspberry Pi Pico specifications at the silicon level prevents hardware damage and software bugs. The most critical difference lies in the Analog-to-Digital Converter (ADC) and GPIO voltage tolerance.

SpecificationPico 1 (RP2040)Pico 2 (RP2350)
ProcessorDual-core ARM Cortex-M0+ @ 133MHzDual-core ARM Cortex-M33 @ 150MHz (or RISC-V)
Memory (SRAM)264 KB520 KB
Flash2 MB (QSPI)4 MB (QSPI)
ADC Performance12-bit, but ~50mV noise floor, non-linear bottom 4 bits12-bit, vastly improved linearity and lower noise floor
GPIO Voltage3.3V strictly (5V fries the chip)3.3V default, select banks are 5V tolerant
UART/I2C/SPI2x UART, 2x I2C, 2x SPI2x UART, 2x I2C, 2x SPI

The ADC Trap: The RP2040's internal ADC is notoriously noisy. If you try to measure a LiFePO4 cell's voltage (3.0V - 3.6V) directly on GPIO26 (ADC0), the bottom 4 bits will fluctuate wildly, giving you a ±50mV error margin. For battery capacity testing, this is unacceptable. This specification flaw is exactly why we use an external INA219 I2C sensor in the build below.

Project Build: LiFePO4 Capacity Logger

This build monitors a LiFePO4 cell's voltage and current draw over time, calculating remaining capacity. It targets the Pico 1 (RP2040) running MicroPython v1.22+.

Parts List

  • MCU: Raspberry Pi Pico 1 (RP2040) with headers
  • Sensor: INA219 I2C High-Side Current/Power Sensor (Adafruit 904 or generic equivalent)
  • Display: 128x64 SSD1306 I2C OLED (0.96 inch)
  • Power: 1S LiFePO4 battery (3.2V nominal) + JST connector
  • Wiring: 22 AWG silicone wire, breadboard or perfboard

Pin Mapping Table

ComponentPin LabelPico GPIO / PinNotes
SSD1306 OLEDSDAGP0 (Pin 1)I2C0 SDA
SSD1306 OLEDSCLGP1 (Pin 2)I2C0 SCL
INA219 SensorSDAGP4 (Pin 6)I2C0 SDA (Shared bus)
INA219 SensorSCLGP5 (Pin 7)I2C0 SCL (Shared bus)
Both ModulesVCC3V3 OUT (Pin 36)Do NOT use VBUS (5V)
Both ModulesGNDGND (Pin 38)Common ground required

Wiring Steps

  1. De-energize: Ensure the Pico is unplugged from USB and the LiFePO4 cell is disconnected.
  2. Bus the I2C lines: Connect the SDA pins of both the OLED and INA219 together, then route to Pico GP0. Connect the SCL pins together and route to Pico GP1.
  3. Power the modules: Route Pico Pin 36 (3V3 OUT) to the VCC rails of both sensors. Route Pico Pin 38 (GND) to the GND rails.
  4. Connect the load: Wire the LiFePO4 positive terminal to the INA219 VIN+ screw terminal. Wire the INA219 VIN- terminal to your load (e.g., a resistor or motor). Wire the load's ground back to the battery ground.
  5. Verify: Use a multimeter to check continuity between the Pico GND pin and the INA219 GND pin. Read < 1 ohm.

Complete MicroPython Code with Error Handling

This code includes a lightweight, inline INA219 driver so you do not need to hunt for third-party .mpy libraries. It assumes you have installed the standard micropython-ssd1306 package via Thonny's package manager (Tools -> Manage Packages).


import machine
import time
import ssd1306
from framebuf import FrameBuffer, MONO_HLSB

# --- PIN DEFINITIONS ---
I2C_SDA = machine.Pin(0)
I2C_SCL = machine.Pin(1)
I2C_FREQ = 400000

# --- HARDWARE INITIALIZATION ---
try:
    i2c = machine.I2C(0, sda=I2C_SDA, scl=I2C_SCL, freq=I2C_FREQ)
    oled = ssd1306.SSD1306_I2C(128, 64, i2c, addr=0x3C)
except OSError as e:
    print(f'FATAL: I2C Init failed. {e}')
    machine.reset()

# --- INLINE INA219 DRIVER ---
class INA219:
    def __init__(self, i2c_bus, addr=0x40):
        self.i2c = i2c_bus
        self.addr = addr
        # Config: 16V Bus, 40mV Shunt, 12-bit resolution, continuous mode
        self._write_reg(0x00, 0x199F)
        self.calibrate()

    def calibrate(self):
        # Calibrate for 0.1 ohm shunt, max 3.2A
        self._write_reg(0x05, 4096)

    def _write_reg(self, reg, val):
        self.i2c.writeto(self.addr, bytes([reg, (val >> 8) & 0xFF, val & 0xFF]))

    def _read_reg(self, reg):
        self.i2c.writeto(self.addr, bytes([reg]))
        data = self.i2c.readfrom(self.addr, 2)
        return (data[0] << 8) | data[1]

    def get_bus_voltage(self):
        raw = self._read_reg(0x02)
        return (raw >> 3) * 0.004  # 4mV per bit

    def get_current_ma(self):
        raw = self._read_reg(0x04)
        if raw > 32767:
            raw -= 65536
        return raw * 1.0  # 1mA per bit with 4096 cal

sensor = INA219(i2c)
total_mah = 0.0
last_time = time.ticks_ms()

# --- MAIN LOOP ---
while True:
    try:
        v = sensor.get_bus_voltage()
        ma = sensor.get_current_ma()
        
        # Integrate current over time to get mAh
        now = time.ticks_ms()
        dt_hours = time.ticks_diff(now, last_time) / 3600000.0
        total_mah += (ma * dt_hours)
        last_time = now

        # Update OLED
        oled.fill(0)
        oled.text(f'V: {v:.2f}V', 0, 0)
        oled.text(f'I: {ma:.0f}mA', 0, 15)
        oled.text(f'Cap: {total_mah:.1f}mAh', 0, 30)
        oled.show()
        
        time.sleep(1.0)
        
    except OSError as e:
        oled.fill(0)
        oled.text('I2C BUS ERROR', 0, 0)
        oled.text(str(e), 0, 15)
        oled.show()
        time.sleep(5)

Debugging: Fixing OSError: [Errno 110] ETIMEDOUT

When working with Pico I2C specifications, the most common failure mode during sensor polling is the bus hanging. If your Thonny REPL throws this exact string:

OSError: [Errno 110] ETIMEDOUT

This means the Pico sent a clock pulse on the SCL line, but the SDA line remained stuck high or low, and the slave device failed to acknowledge (ACK) within the hardware timeout window.

The First Three Things to Check When It Fails

  1. Verify Pull-Up Resistors: The RP2040 I2C peripheral requires external pull-up resistors to 3.3V. While the Pico has internal pull-ups, they are ~50kΩ, which is too weak for 400kHz I2C. Check your OLED and INA219 breakout boards; 95% of them include 4.7kΩ surface-mount pull-ups. If you are using bare modules, add 4.7kΩ resistors from SDA to 3.3V and SCL to 3.3V.
  2. Check Wire Length and Capacitance: The I2C spec limits bus capacitance to 400pF. If your jumper wires exceed 30cm (12 inches), the capacitance will round off the square wave edges, causing the ETIMEDOUT error. Keep I2C traces under 15cm.
  3. Drop the Clock Frequency: If your wiring is messy or long, 400kHz (Fast Mode) will fail. Change the initialization in the code from freq=400000 to freq=100000 (Standard Mode). This gives the signal more time to rise and fall.

Ranked Causes for Intermittent Timeouts

RankCauseFix
1Loose breadboard contact on SDA/SCLSolder headers or use a screw-terminal breakout.
2Slave device brownout (INA219 resetting)Add a 100µF decoupling capacitor across the sensor's VCC/GND.
3Address collisionRun i2c.scan() in REPL. Ensure OLED is 0x3C and INA219 is 0x40.

Extending and Simplifying the Build

Depending on your deployment environment, you may need to alter the hardware footprint.

How to Simplify (Headless Data Logging)

If you are deploying this inside a battery pack where a screen is useless, drop the SSD1306 OLED entirely. This removes the heaviest I2C dependency and frees up 1KB of RAM. Instead, log the data to the Pico's internal flash using the uos module:


with open('log.csv', 'a') as f:
    f.write(f'{time.time()},{v},{ma}\n')

Note: Flash memory has a limited write cycle life (~100k cycles). Only write to the file once every 60 seconds, not every second.

How to Extend (Wireless Telemetry)

If you need to monitor the battery from your phone, swap the Pico 1 for a Pico W. Add the network and umqtt.simple libraries to push the voltage and capacity data to an MQTT broker (like Mosquitto or Home Assistant) every 10 seconds. The Pico W's CYW43439 radio draws ~120mA during WiFi transmission, so factor that parasitic draw into your capacity calculations by measuring the Pico's own current consumption on the INA219's load side.