The Raspberry Pi Pico’s analog-to-digital converter (ADC) is a powerful but frequently misunderstood peripheral. Out of the box, the RP2040 (Pico 1) features a 12-bit SAR ADC, while the newer RP2350 (Pico 2) upgrades this to a highly linear 15-bit effective ADC with built-in chopping modes. However, simply calling read_u16() in MicroPython rarely yields bench-grade precision. To get accurate readings, you must manage source impedance, filter switching noise, and map the correct GPIO pins.

In this guide, we are building a 4S LiFePO4 battery pack voltage and temperature monitor. We will cover the exact hardware filtering required to tame ADC noise, provide a complete MicroPython script with error handling, and build a decision framework to help you choose the right Pico variant for your analog sensing needs.

Board Selection: Which Pico Variant for ADC Work?

Not all Pico boards are created equal when it comes to analog sensing. The original RP2040 has a known ±3°C offset on its internal temperature sensor and roughly 8.5 to 9 bits of effective number of bits (ENOB) due to internal digital noise. The RP2350 (Pico 2) drastically improves ADC linearity and noise performance. Use the decision table below to pick your board.

Requirement Board Variant ADC Spec Verdict
Budget projects, slow-moving DC voltage (e.g., solar battery) Raspberry Pi Pico (RP2040) 12-bit (9-bit ENOB) Choose if cost is the primary driver and software oversampling is acceptable.
IoT data logging, MQTT telemetry over WiFi Raspberry Pi Pico W 12-bit (9-bit ENOB) Choose when wireless is mandatory. Note: WiFi transmission causes periodic ADC noise spikes.
Precision sensing, audio, low-noise analog Raspberry Pi Pico 2 (RP2350) 15-bit effective, Chopping mode Default Pick. Choose for high-precision DC measurement without external ADC chips.

For the build below, the code and hardware target the Raspberry Pi Pico 2 (RP2350), but remain 100% backward-compatible with the original Pico and Pico W.

Parts List & Pin Mapping

To measure a 4S LiFePO4 pack (nominal 12.8V, max 14.6V), we cannot feed the voltage directly into the 3.3V-tolerant ADC pins. We need a precision voltage divider and a low-pass filter.

Bill of Materials

  • Microcontroller: Raspberry Pi Pico 2 (RP2350) with pre-soldered headers (~$5.00)
  • R1 Resistor: Vishay Dale 39kΩ 1% Metal Film (1/4W) (~$0.10)
  • R2 Resistor: Vishay Dale 10kΩ 1% Metal Film (1/4W) (~$0.10)
  • Filter Capacitor: KEMET 100nF (0.1µF) X7R Ceramic (50V) (~$0.05)
  • Wiring: 22 AWG silicone stranded wire for power, 24 AWG solid core for breadboard/perfboard.

Pin Mapping Table

Pico Pin GPIO / Function Connection Target Notes
31 GPIO26 (ADC0) Voltage Divider Midpoint (R1/R2 junction) Primary analog input for battery voltage.
32 GPIO27 (ADC1) Reserved / Current Sense Leave unconnected for this build.
33 AGND Battery Pack Negative / Circuit Ground Critical: Use AGND (Pin 33), not standard DGND, for analog references.
36 3V3(OUT) Reference Voltage Check Used to verify the internal 3.3V LDO output.
Internal ADC4 Internal Die Temperature Sensor No external pin; accessed via channel 4 in code.

Hardware Design: The Voltage Divider & RC Filter

The most common mistake hobbyists make with the RPi Pico ADC is ignoring source impedance. The RP2040/RP2350 ADC uses a sample-and-hold (S/H) capacitor internally. When the ADC mux switches to your pin, that internal capacitor must charge to the input voltage within a few microseconds. If your external circuit has high resistance, the capacitor won't fully charge, resulting in readings that are artificially low and highly dependent on the sampling rate.

Raspberry Pi's hardware guidelines recommend keeping the source impedance below 10kΩ. Let's calculate our Thevenin equivalent resistance for the divider:

Voltage Divider Math:
Target Max Voltage: 14.6V (4S LiFePO4 fully charged)
ADC Max Safe Voltage: 3.0V (leaving 0.3V headroom below the 3.3V rail)
Ratio needed: 3.0V / 14.6V = 0.205

Using standard 1% values: R1 = 39kΩ, R2 = 10kΩ
Actual Ratio: 10k / (39k + 10k) = 0.204
Max ADC Voltage: 14.6V * 0.204 = 2.98V (Safe)

Thevenin Impedance: (R1 * R2) / (R1 + R2) = (39k * 10k) / 49k = 7.96kΩ

At 7.96kΩ, we are safely under the 10kΩ limit. However, to eliminate high-frequency switching noise from the battery management system (BMS) and the Pico's own digital clock, we add a 100nF ceramic capacitor in parallel with R2. This creates a low-pass RC filter with a cutoff frequency of roughly 200Hz, effectively shorting out RF noise while letting the slow-moving DC battery voltage pass straight into GPIO26.

Complete MicroPython Code with Error Handling

Below is the complete, compilable MicroPython script. It reads the battery voltage via ADC0 and the internal die temperature via ADC4. It includes software oversampling (averaging 64 reads) to squeeze extra resolution out of the ADC, and robust error handling for pin initialization.

import machine
import time
import sys

# --- Pin & Hardware Definitions ---
ADC_VBAT_PIN = 26       # GPIO26 corresponds to ADC0
ADC_TEMP_CHANNEL = 4    # Internal temperature sensor channel

# --- Conversion Constants ---
VREF = 3.3              # Nominal 3.3V reference (measure with multimeter for exact calibration)
ADC_MAX = 65535         # MicroPython scales the 12-bit ADC to a 16-bit unsigned int
R1 = 39000.0            # 39k Ohm
R2 = 10000.0            # 10k Ohm
DIVIDER_RATIO = (R1 + R2) / R2

# RP2040/RP2350 Temp Sensor Formula Constants
TEMP_OFFSET = 27.0
TEMP_VOLTAGE_AT_27C = 0.706
TEMP_SLOPE = 0.001721

def read_oversampled(adc_obj, samples=64):
    """Reads ADC multiple times and averages to reduce noise floor."""
    total = 0
    for _ in range(samples):
        total += adc_obj.read_u16()
    return total / samples

def calculate_pack_voltage(raw_adc_val):
    voltage_at_pin = (raw_adc_val / ADC_MAX) * VREF
    return voltage_at_pin * DIVIDER_RATIO

def calculate_die_temp_c(raw_adc_val):
    voltage_at_sensor = (raw_adc_val / ADC_MAX) * VREF
    return TEMP_OFFSET - (voltage_at_sensor - TEMP_VOLTAGE_AT_27C) / TEMP_SLOPE

# --- Initialization with Error Handling ---
try:
    # Initialize ADC0 on GPIO26
    vbat_adc = machine.ADC(machine.Pin(ADC_VBAT_PIN))
    # Initialize Internal Temp Sensor (Channel 4)
    temp_adc = machine.ADC(ADC_TEMP_CHANNEL)
    print("ADC Initialization Successful.")
except ValueError as e:
    print(f"Fatal Init Error: {e}")
    print("Verify you are passing a valid ADC pin (GPIO26-28) or int 4 for temp.")
    sys.exit(1)
except Exception as e:
    print(f"Unexpected Hardware Error: {e}")
    sys.exit(1)

# --- Main Execution Loop ---
print("Starting LiFePO4 Monitor... (Ctrl+C to stop)")
try:
    while True:
        raw_vbat = read_oversampled(vbat_adc)
        raw_temp = read_oversampled(temp_adc)
        
        pack_v = calculate_pack_voltage(raw_vbat)
        die_c = calculate_die_temp_c(raw_temp)
        
        # State of Charge (SoC) rough estimate for LiFePO4
        if pack_v >= 13.4:
            soc_status = "100% (Full)"
        elif pack_v >= 12.8:
            soc_status = "~80% (Nominal)"
        elif pack_v >= 12.0:
            soc_status = "~20% (Low)"
        else:
            soc_status = "<10% (CRITICAL)"
            
        print(f"Pack: {pack_v:.2f}V [{soc_status}] | Die Temp: {die_c:.1f}C")
        time.sleep(2)
        
except KeyboardInterrupt:
    print("\nMonitor stopped by user.")
except Exception as e:
    print(f"\nRuntime Error during read: {e}")

Debugging: Exact Errors & The First 3 Things to Check

When working with the RPi Pico ADC, you will inevitably hit a wall of confusing errors or garbage data. Here is the decision path for the most common failures.

1. The Exact Error: ValueError: Pin(0) is not an ADC pin

Ranked Causes:

  1. GPIO vs ADC Channel Confusion (90% of cases): You passed machine.ADC(0) thinking it maps to ADC0. In MicroPython, passing an integer under 26 attempts to initialize a standard digital GPIO. ADC0 is physically wired to GPIO26. You must use machine.ADC(26) or machine.ADC(machine.Pin(26)).
  2. Using a Non-ADC Pin: You tried to use GPIO15 or GPIO22. The RP2040/RP2350 only supports analog input on GPIO26, GPIO27, and GPIO28. Move your wire.

2. The Exact Error: AttributeError: 'int' object has no attribute 'id'

Ranked Causes:

  1. Outdated MicroPython Firmware: Older builds of MicroPython (pre-1.19) required a Pin object instead of an integer. Update your Pico's firmware via Thonny to the latest stable release from the official MicroPython download page.

3. Symptom: Noisy Readings (Jumping ±0.15V randomly)

If your multimeter reads a steady 12.8V but the Pico prints values bouncing between 12.65V and 12.95V, check these three things immediately:

The First 3 Things to Check for ADC Noise:
  1. AGND vs DGND Bonding: Did you connect your battery's ground to Pin 33 (AGND) or Pin 3/8/13 (GND/DGND)? Digital ground carries switching noise from the CPU. Fix: Move your ground wire to Pin 33 (AGND).
  2. Missing Filter Capacitor: Is the 100nF capacitor physically present across R2? Fix: Solder the cap directly at the ADC pin junction, keeping leads under 5mm.
  3. USB VBUS Noise: Are you powering the Pico via a cheap PC USB port? The 5V to 3.3V LDO on the Pico will pass USB ripple directly into your VREF. Fix: Power the Pico via the VSYS pin with a clean bench supply, or measure the 3V3(OUT) pin with an oscilloscope to verify ripple.

Extending and Simplifying the Build

Depending on your final application, you may need to scale this circuit up or strip it down.

How to Simplify (Cost & Space Reduction)

If you are logging slow-moving data (like a solar tank battery) and only need 10-bit accuracy, drop the hardware RC filter and the 1% resistors. Use standard 5% carbon film resistors and rely entirely on the software oversampling function (read_oversampled) in the code above. Averaging 256 samples in software will mathematically filter out the high-frequency noise, saving you board space and BOM cost, at the expense of a slightly slower sampling rate (which doesn't matter for DC voltage).

How to Extend (Adding Current & Wireless)

Voltage only tells half the story for a battery pack. To measure current, do not use the Pico's ADC with a shunt resistor. The ADC lacks the differential input and gain required for millivolt shunt readings. Instead, add an INA219 I2C Current Sensor breakout (~$4.00). Wire its SDA/SCL to GPIO4/GPIO5, and use the ina219 MicroPython library.

If you need to push this data to Home Assistant, swap the Pico 2 for a Pico W. Add the network and umqtt.simple libraries to publish the pack_v and die_c variables to an MQTT broker every 60 seconds. Be aware that when the Pico W's WiFi radio transmits, it draws a 100mA+ spike that causes a momentary brownout on the 3.3V rail, which will corrupt a single ADC read. Pro-tip: Trigger your ADC read sequence, wait 50ms, then trigger the WiFi transmission to keep the analog and RF domains separated in time.

For deeper architectural details on the RP2350's improved ADC chopping modes and exact leakage current specifications, refer to the official RP2350 Datasheet (Section 10.4). For standard MicroPython ADC class methods, consult the MicroPython machine.ADC documentation.