>_ ctrlaltbrian
Tutorials ESP32 Arduino Raspberry Pi Pico About Queue

ctrlaltbrian

Pico and MicroPython: from setup to PIO

The fastest path from a Pico in a box to projects that use MicroPython, PIO, Wi-Fi, and sensors.

18 chapters · ~10 hours · last updated 2026-09-24

$22

Chapter 01

Pico: get started with MicroPython on the Raspberry Pi Pico

pico · 15 min

The Raspberry Pi Pico is the microcontroller I reach for when I want something cheap, fast, and easy to deploy. The W version adds Wi-Fi, which turns it into a $6 ESP32 alternative for projects where you do not need Bluetooth.

This tutorial gets MicroPython installed and runs a blink sketch in about 15 minutes.

What you need

  • Raspberry Pi Pico or Pico W
  • USB cable (micro-USB for the original Pico, USB-C for the Pico 2)
  • A computer

Step 1: download MicroPython

Go to https://micropython.org/download/RPI_PICO/ (or RPI_PICO_W for the W version). Download the latest .uf2 file.

There is no installer. The .uf2 file is the firmware image.

Step 2: flash the firmware

  1. Hold down the BOOTSEL button on the Pico.
  2. While holding the button, plug in the USB cable.
  3. Release the button.

The Pico appears as a USB drive called RPI-RP2.

  1. Drag the .uf2 file to that drive.

The Pico will reboot and the drive will disappear. The Pico is now running MicroPython.

Step 3: install Thonny

Thonny is the IDE I use for Pico + MicroPython. Download from https://thonny.org/.

Open Thonny. Configure the interpreter:

  • Tools >> Options >> Interpreter
  • Select MicroPython (Raspberry Pi Pico)

You should see a >>> prompt in the Shell pane at the bottom. This is the MicroPython REPL.

Step 4: blink the onboard LED

MicroPython (Pico)

Type this at the >>> prompt:

from machine import Pin
import time

led = Pin("LED", Pin.OUT)

while True:
    led.toggle()
    time.sleep(0.5)

The onboard LED on the Pico should blink at 1 Hz. Press Ctrl+C in the Shell to stop the script.

Arduino (Pico)

The Pico's Arduino core is maintained by Earle Philhower. It works through the regular Arduino IDE with a board package add-on.

  1. In the Arduino IDE: File >> Preferences >> Additional boards manager URLs. Paste: https://github.com/earlephilhower/arduino-pico/releases/download/global/package_rp2040_index.json
  2. Tools >> Board >> Boards Manager >> search pico >> install Raspberry Pi Pico/RP2040 by Earle Philhower.
  3. Tools >> Board >> Raspberry Pi Pico (select the one matching your board: Pico, Pico W, or Pico 2).
  4. The Pico does not auto-reset for upload on every host. Hold the BOOTSEL button, plug in the USB, release. The IDE will then upload.
  5. Open the IDE's Blink example (File >> Examples >> 01.Basics

    Blink) and upload.

The sketch that gets uploaded:

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);
  delay(1000);
  digitalWrite(LED_BUILTIN, LOW);
  delay(1000);
}

On the Pico W, the onboard LED is on the Wi-Fi chip's GPIO, accessed via LED_BUILTIN. On the original Pico, LED_BUILTIN maps to GPIO 25. Either way the Blink example works without changes.

On the Pico W, Pin("LED") is correct. On the original Pico (no W), you need to use the pin number: Pin(25, Pin.OUT) for the onboard LED.

Step 5: save a script to the Pico

In Thonny, write a file in the editor:

# main.py
from machine import Pin
import time

led = Pin("LED", Pin.OUT)

while True:
    led.toggle()
    time.sleep(0.5)

Save it to the Pico as main.py. This runs automatically when the Pico boots.

What you learned

  • The Pico has a BOOTSEL button for flashing firmware.
  • MicroPython is a Python 3 interpreter for microcontrollers.
  • The onboard LED is on pin 25 on the original Pico, on the Wi-Fi chip's GPIO on the Pico W (accessed via Pin("LED")).
  • main.py runs automatically on boot.

Saving files to the Pico

You can save files directly to the Pico's flash via Thonny:

  • File >> Save as >> MicroPython device

These files persist across reboots. The Pico has about 2 MB of usable flash after MicroPython.

Common file structure:

main.py       # runs on boot
boot.py       # runs before main.py (use for setup)
lib/          # additional modules (e.g. umqtt, network)

Working with the REPL

The REPL (Read-Eval-Print Loop) is your friend. You can:

  • Test individual commands before putting them in a script.
  • Inspect the state of variables.
  • Recover from errors without rebooting.

Useful REPL shortcuts:

  • Ctrl+C: interrupt the running script.
  • Ctrl+D: soft-reboot (re-runs main.py).
  • Ctrl+E: paste mode for multi-line code.

To see all available modules:

help('modules')

To see what's available on a specific module:

from machine import Pin
help(Pin)

Why MicroPython vs. CircuitPython

Both are Python for microcontrollers. Differences:

  • MicroPython: original, smaller footprint, official Pico port, more optimized. Default pick.
  • CircuitPython: Adafruit's fork, better peripheral support (more Adafruit boards), easier to use, includes a USB drive for editing.

For most projects, MicroPython on the Pico is fine. If you are using an Adafruit board (e.g. Feather, Qt Py), CircuitPython is usually easier.

When the Pico does not show up as a drive

  • You did not hold BOOTSEL before plugging in. Try again.
  • The USB cable is charge-only. Some cables have data lines missing. Use a different cable.
  • The USB port is weak. Try a different port, especially on a desktop.

When Thonny cannot connect

  • Wrong interpreter. Re-select MicroPython (Raspberry Pi Pico).
  • The Pico crashed. Hold BOOTSEL, plug in, reflash MicroPython.
  • The serial port is in use by another program. Close any other REPL connections.

What to build next

  • A button that toggles the LED.
  • A sensor reader (DHT22, DS18B20).
  • A web server on the Pico W.

The Pico W web server is in the book Pico Wi-Fi Projects. The sensor reader is one of the next tutorials on this site.


Chapter 02

Pico W: serve a web page from the chip over Wi-Fi

pico · 30 min

The Pico W has Wi-Fi. Combined with MicroPython's socket library, you can serve a web page from the chip. No Arduino IDE, no ESP-IDF setup, just a small Python script and a USB cable.

This tutorial gets you from a fresh Pico W to "I have a web page running on my microcontroller" in about 30 minutes.

What you need

  • Raspberry Pi Pico W (the W version is required for Wi-Fi)
  • MicroPython firmware installed (covered in the previous tutorial)
  • A known Wi-Fi network

Step 1: connect to Wi-Fi

Save this as main.py:

import network
import time

ssid = "your-wifi-ssid"
password = "your-wifi-password"

wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(ssid, password)

print("Connecting to Wi-Fi...")
while not wlan.isconnected():
    print(".", end="")
    time.sleep(1)

print()
print("Connected:", wlan.ifconfig())

The ifconfig() shows the IP address, subnet, gateway, and DNS. Save the IP address; you will need it.

Step 2: serve a web page

MicroPython (Pico)

Replace main.py with:

import network
import socket
import time
from machine import Pin

ssid = "your-wifi-ssid"
password = "your-wifi-password"

led = Pin("LED", Pin.OUT)

wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(ssid, password)

while not wlan.isconnected():
    print("Connecting...")
    time.sleep(1)

ip = wlan.ifconfig()[0]
print(f"Web server running on http://{ip}")

addr = socket.getaddrinfo(ip, 80)[0][-1]
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(5)

while True:
    try:
        cl, addr = s.accept()
        print("Client connected from", addr)
        request = cl.recv(1024).decode("utf-8")
        print("Request:", request.split("\r\n")[0])

        if "/led/on" in request:
            led.value(1)
            response = "LED is now ON"
        elif "/led/off" in request:
            led.value(0)
            response = "LED is now OFF"
        else:
            response = """
                <html>
                <head><title>Pico W</title></head>
                <body>
                <h1>Pico W Web Server</h1>
                <p><a href="/led/on">Turn LED on</a></p>
                <p><a href="/led/off">Turn LED off</a></p>
                </body>
                </html>
            """

        cl.send("HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\n")
        cl.send(response)
        cl.close()

    except OSError as e:
        print("Error:", e)
        cl.close()

Save and reboot the Pico. Watch the REPL for the IP address. Open that in a browser. Click the links to toggle the onboard LED.

Arduino (Pico)

The Pico W Arduino core supports the WiFi and WebServer libraries the same way the ESP32 does (Earle Philhower's arduino-pico core ships with both).

#include <WiFi.h>
#include <WebServer.h>

const char* ssid     = "your-wifi-ssid";
const char* password = "your-wifi-password";

WebServer server(80);

void handleRoot() {
  server.send(200, "text/html",
    "<h1>Pico W Web Server</h1>"
    "<p><a href=\"/led/on\">Turn LED on</a></p>"
    "<p><a href=\"/led/off\">Turn LED off</a></p>");
}

void handleLedOn() {
  digitalWrite(LED_BUILTIN, HIGH);
  server.send(200, "text/plain", "LED is now ON");
}

void handleLedOff() {
  digitalWrite(LED_BUILTIN, LOW);
  server.send(200, "text/plain", "LED is now OFF");
}

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
  Serial.begin(115200);

  WiFi.begin(ssid, password);
  Serial.print("Connecting");
  while (WiFi.status() != WL_CONNECTED) {
    Serial.print(".");
    delay(500);
  }
  Serial.println();
  Serial.print("IP: ");
  Serial.println(WiFi.localIP());

  server.on("/", handleRoot);
  server.on("/led/on", handleLedOn);
  server.on("/led/off", handleLedOff);
  server.begin();
}

void loop() {
  server.handleClient();
}

Same wiring (no wiring changes needed for the onboard LED). The WiFi and WebServer libraries are the same API as the ESP32, so most tutorials that use the ESP32 Wi-Fi libraries port over with no changes.

What you learned

  • The network module handles Wi-Fi on the Pico W.
  • The socket module is the standard Python socket library.
  • A basic HTTP server is just accept() -> recv() -> send() -> close().
  • You can use the request path (/led/on) to trigger different actions.

Common pitfalls

  • The HTML response does not include the right headers. The browser expects Content-Type: text/html for HTML. Without it, the browser shows the raw HTML or downloads it.
  • The request is too long. recv(1024) reads up to 1024 bytes. For larger requests, loop until you have it all (most browser requests fit in 1024 bytes).
  • Multiple connections at once. The single-threaded server above handles one connection at a time. For a real site, use socket.settimeout() and a pool, or move to asyncio (covered in the book Pico Wi-Fi Projects).

Reading a sensor over Wi-Fi

Combine with the DHT22 or DS18B20 tutorial:

import dht
import machine

sensor = dht.DHT22(machine.Pin(4))

# in the request handler:
sensor.measure()
temp = sensor.temperature()
hum = sensor.humidity()

response = f"<h1>{temp:.1f} C, {hum:.1f} %</h1>"

Now the page shows the current temperature and humidity.

The asyncio version

For projects with multiple things happening at once (e.g. reading a sensor every 5 seconds and serving a web page at the same time), MicroPython supports asyncio:

import asyncio
import network
from machine import Pin

async def blink():
    led = Pin("LED", Pin.OUT)
    while True:
        led.toggle()
        await asyncio.sleep(1)

async def main():
    asyncio.create_task(blink())
    # ... other tasks ...

asyncio.run(main())

The asyncio version is in the book Pico Wi-Fi Projects.

When the Wi-Fi keeps dropping

The Pico W's Wi-Fi is not as stable as the ESP32's. If your connection is flaky:

  • Add a reconnect loop:
def ensure_wifi():
    if not wlan.isconnected():
        print("Reconnecting...")
        wlan.disconnect()
        time.sleep(1)
        wlan.connect(ssid, password)
        while not wlan.isconnected():
            time.sleep(1)
        print("Reconnected")
  • Call ensure_wifi() periodically from your main loop.
  • Use a fixed Wi-Fi channel (set in the router). Auto-channel selection can confuse the Pico W.

When to use Pico W vs. ESP32

  • Pico W: $6, MicroPython or C, very low power, fewer peripherals. Great for "sensor reading + simple web interface" projects.
  • ESP32: $4-8, Arduino or MicroPython, more RAM, more peripherals, BLE. Better for complex projects, MQTT-heavy stuff, anything with Bluetooth.

The rule of thumb: if you are doing Wi-Fi + a sensor or two, the Pico W is often the better pick (cheaper, easier to deploy, MicroPython). If you need BLE, MQTT with persistent sessions, or anything with multiple concurrent connections, use the ESP32.

What to build next

  • A weather station with multiple sensors.
  • A MQTT client (publish readings to a broker).
  • A simple web-based UI with sliders and buttons.

The MQTT client is in the book Pico Wi-Fi Projects. The weather station is one of the next tutorials on this site.


Chapter 03

Pico: read a DHT22 with MicroPython

pico · 20 min

The Pico + a DHT22 is the cheapest weather station you can build. About $10 in parts, and you get temperature and humidity readings on the REPL.

This tutorial covers the wiring, the MicroPython driver, and the part where the DHT22 occasionally fails to read (and how to handle that).

What you need

  • Raspberry Pi Pico (any version)
  • DHT22 breakout (the 3-pin version with onboard pull-up)
  • Three jumper wires

Wiring

DHT22 pin Pico pin
VCC VBUS (5V, pin 40) or 3V3 (pin 36)
DATA GPIO 4 (pin 6)
GND GND (pin 8 or 38)

Power the DHT22 from VBUS (5V) for longer cable runs. For short runs (under 30 cm), 3V3 works fine. The data line is the same either way.

Get the DHT driver

The MicroPython firmware for the Pico does not include the DHT driver by default. Two options:

Option 1: install via mip (MicroPython's package manager):

import mip
mip.install("dht")

Option 2: copy dht.py from the MicroPython repository into your project. The file is at https://github.com/micropython/micropython/blob/master/drivers/dht/dht.py.

Save it to the Pico as /lib/dht.py.

The code

MicroPython (Pico)

import dht
import machine
import time

sensor = dht.DHT22(machine.Pin(4))

while True:
    try:
        sensor.measure()
        temp = sensor.temperature()
        hum = sensor.humidity()
        print(f"Temperature: {temp:.1f} C  Humidity: {hum:.1f} %")
    except OSError as e:
        print("Read failed:", e)
    time.sleep(2)

Run it. You should see temperature and humidity printing every 2 seconds.

Arduino (Pico)

Install the DHT sensor library by Adafruit through the Arduino IDE (Sketch >> Include Library >> Manage Libraries). This is the same library the ESP32 and Uno versions use. Board package: Raspberry Pi Pico/RP2040 by Earle Philhower (this gives you the Pico's Arduino core).

#include "DHT.h"

#define DHT_PIN 4
#define DHT_TYPE DHT22

DHT dht(DHT_PIN, DHT_TYPE);

void setup() {
  Serial.begin(115200);
  dht.begin();
}

void loop() {
  float t = dht.readTemperature();
  float h = dht.readHumidity();

  if (isnan(t) || isnan(h)) {
    Serial.println("Failed to read from DHT22");
  } else {
    Serial.print("Temperature: ");
    Serial.print(t);
    Serial.print(" C  Humidity: ");
    Serial.print(h);
    Serial.println(" %");
  }
  delay(2000);
}

Same wiring as the MicroPython version. Pin(4) maps to GPIO 4 on the Pico, which is what DHT_PIN 4 uses. The Pico's Arduino core treats plain pin numbers as the GPIO number (not the physical pin), so this matches the MicroPython example exactly.

Why the DHT22 fails sometimes

The DHT22 uses a custom one-wire protocol with tight timing requirements. The Pico is more reliable than the Raspberry Pi (no operating system getting in the way), but you will still get occasional OSError: [Errno 110] ETIMEDOUT errors. Just ignore them.

If every read fails, the wiring is wrong. The most common mistake:

  • Pull-up resistor missing. Some DHT22 breakouts have it built in; others do not. If yours does not, add a 10k resistor between DATA and VCC.
  • Wrong GPIO. Make sure Pin(4) matches the actual physical pin you used.

Logging to a file

To save readings for later analysis:

import dht
import machine
import time

sensor = dht.DHT22(machine.Pin(4))

def log_line(line):
    try:
        with open("readings.log", "a") as f:
            f.write(line + "\n")
    except OSError:
        print("Could not write to log")

while True:
    try:
        sensor.measure()
        temp = sensor.temperature()
        hum = sensor.humidity()
        line = f"{time.time():.0f},{temp:.1f},{hum:.1f}"
        print(line)
        log_line(line)
    except OSError as e:
        print("Read failed:", e)
    time.sleep(60)

Each line is unix_timestamp,temperature,humidity. You can graph it later with gnuplot or pandas.

The Pico's flash has limited write cycles (about 10,000 to 100,000 depending on the chip). Logging every 60 seconds means about 525,000 writes per year, which is over the limit. For long-term logging, use an SD card or send the data over Wi-Fi to a Raspberry Pi.

Why use a DHT22 vs. other sensors

  • DHT22: temperature and humidity, cheap, slow, finicky. Good for hobbyist indoor projects.
  • BME280: temperature, humidity, pressure. I2C, accurate. Good for weather stations.
  • SHT31: temperature and humidity, I2C, very accurate. More expensive than DHT22 but more reliable.
  • DS18B20: temperature only, one-wire, multiple sensors on one pin. Best for multi-zone temperature monitoring.

For "indoor temperature and humidity," the DHT22 is fine. For "outdoor weather station with barometric pressure," use a BME280. For "long-term deployment where I do not want to debug it," use an SHT31.

Reading the DHT22 with interrupts

The default DHT driver busy-waits during the read, which blocks other code from running. For a Pico doing multiple things, you can use the rp2 module's PIO (Programmable I/O) to read the DHT22 in the background:

from rp2 import PIO, asm_pio
from machine import Pin
import time

@asm_pio()
def dht22_read():
    pass   # PIO program here

# (full implementation in the book *Pico Sensors*)

The PIO version is more advanced and lets the Pico do other work during the read. Most projects do not need this; the basic version is fine.

What to build next

  • A weather station that logs to a Raspberry Pi over MQTT.
  • An OLED display showing the current readings.
  • A battery-powered version with deep sleep between readings.

The deep sleep version is in the book Pico Low Power. The OLED version is one of the next tutorials on this site.


Chapter 04

Pico: drive a servo motor with MicroPython

pico · 20 min

A servo is the motor I default to for "I need to move something to a specific angle." The Pico + an SG90 is a $8 robot arm. The Pico + an MG996R is a $12 robot arm with more torque.

This tutorial covers the PWM basics, the wiring, and a clean servo library that hides the angle-to-PWM math.

What you need

  • Raspberry Pi Pico
  • SG90 (small, weak) or MG996R (bigger, stronger) servo
  • Three jumper wires

Wiring

Servo red (VCC) -- Pico VBUS (5V, pin 40)
Servo brown or black (GND) -- Pico GND (pin 38)
Servo orange or yellow (signal) -- Pico GPIO 0 (pin 1)

GPIO 0 through GPIO 28 all work for servos. Avoid GPIO 26-28 if you are also using the ADC (those pins are ADC-capable and you lose analog reads). GPIO 15 is the only PWM-capable pin that overlaps with a boot pin; avoid it on the Pico W.

The code

MicroPython (Pico)

from machine import Pin, PWM

pwm = PWM(Pin(0))
pwm.freq(50)   # 50 Hz

# 0 degrees (1ms pulse = 5% duty cycle at 50Hz)
pwm.duty_u16(int(65535 * 0.05))

# 90 degrees (1.5ms pulse = 7.5% duty cycle)
pwm.duty_u16(int(65535 * 0.075))

# 180 degrees (2ms pulse = 10% duty cycle)
pwm.duty_u16(int(65535 * 0.10))

duty_u16 takes a value from 0 to 65535. The fraction times 65535 is the duty cycle.

Arduino (Pico)

The Servo.h library that ships with the Arduino IDE works on the Pico's Arduino core. Same wiring, same code as the Uno.

#include <Servo.h>

Servo myServo;

void setup() {
  myServo.attach(0);   // GPIO 0 on the Pico
}

void loop() {
  for (int angle = 0; angle <= 180; angle++) {
    myServo.write(angle);
    delay(15);
  }
  for (int angle = 180; angle >= 0; angle--) {
    myServo.write(angle);
    delay(15);
  }
}

Servo.h on the Pico handles the 50 Hz PWM timing internally, the same way it does on the Uno. The Pico has 8 PWM slices; Servo.h allocates one per attached servo, so up to 8 servos on a Pico with this library before you need an external driver like the PCA9685.

A cleaner servo library

The math above is fiddly. Wrap it in a helper:

from machine import Pin, PWM

class Servo:
    def __init__(self, pin):
        self.pwm = PWM(Pin(pin))
        self.pwm.freq(50)

    def write(self, angle):
        # Clamp to 0-180
        angle = max(0, min(180, angle))
        # Map 0-180 to 5-10% duty cycle
        duty = int(65535 * (0.05 + (angle / 180.0) * 0.05))
        self.pwm.duty_u16(duty)

servo = Servo(0)
servo.write(0)
time.sleep(1)
servo.write(90)
time.sleep(1)
servo.write(180)

This is the pattern I use in every Pico servo project.

A knob-controlled servo

Combine with an ADC reading:

from machine import Pin, PWM, ADC
import time

class Servo:
    def __init__(self, pin):
        self.pwm = PWM(Pin(pin))
        self.pwm.freq(50)

    def write(self, angle):
        angle = max(0, min(180, angle))
        duty = int(65535 * (0.05 + (angle / 180.0) * 0.05))
        self.pwm.duty_u16(duty)

servo = Servo(0)
pot = ADC(Pin(26))   # GP26 is ADC0

while True:
    raw = pot.read_u16()   # 0 to 65535
    angle = raw / 65535.0 * 180.0
    servo.write(angle)
    time.sleep(0.02)

Turn the pot, the servo follows. This is the foundation for "knob- controlled camera" and "knob-controlled valve."

When the servo jitters

The three usual causes:

  1. Insufficient power. The SG90 draws 100-200 mA when moving. The Pico's VBUS can supply about 500 mA. For multiple servos or larger servos, use an external 5V supply.
  2. Shared ground missing. If you are using an external supply, the Pico's GND must be connected to the supply's GND.
  3. Electrical noise. Add a 100uF capacitor across the servo's power pins, close to the servo.

When the servo makes a clicking sound but does not move

  • The signal wire is on the wrong pin. Double-check.
  • The duty cycle is wrong. Verify with pwm.duty_u16(...) print debug.
  • The servo is being asked to move past its physical range (e.g. 200 degrees). The library clamps to 180; without it, the servo can sit there buzzing.

Multiple servos

servos = [Servo(0), Servo(1), Servo(2), Servo(3)]

# Wave them back and forth
while True:
    for angle in range(0, 180, 5):
        for s in servos:
            s.write(angle)
        time.sleep_ms(20)
    for angle in range(180, 0, -5):
        for s in servos:
            s.write(angle)
        time.sleep_ms(20)

The Pico has 8 PWM-capable slices (16 channels). Each PWM slice can drive two outputs at the same frequency. For four servos, you can use two slices. For 16 servos, you need a PCA9685 driver over I2C.

Continuous rotation servos

A "continuous rotation" servo treats the duty cycle as speed and direction:

  • 7.5% duty cycle: stop
  • 10% duty cycle: full speed one direction
  • 5% duty cycle: full speed the other direction
  • 8.75% duty cycle: slow one direction

These are great for small wheeled robots. They have no position feedback, so you cannot tell where they are pointing, only how fast they are spinning.

What to build next

  • A pan/tilt camera mount (two servos).
  • A walking robot with 4-8 servos.
  • A robotic arm with 4 servos and a gripper.

The robotic arm version is in the book Pico Robotics. The pan/tilt mount is one of the next tutorials on this site.


Chapter 05

Pico: blink the onboard LED with MicroPython (the 5-minute start)

pico · 10 min

If you have a Pico and you want to see it do something right now, this is the tutorial. It assumes you already have MicroPython installed (covered in the previous tutorial). If not, go read that one first.

The code

MicroPython (Pico)

from machine import Pin
import time

led = Pin("LED", Pin.OUT)

while True:
    led.toggle()
    time.sleep(0.5)

Click the green "Run" button. The onboard LED blinks at 1 Hz. Click the red "Stop" button to stop.

Arduino (Pico)

Board package: Raspberry Pi Pico/RP2040 by Earle Philhower. Pin numbers in the Pico Arduino core map directly to the GPIO number, so LED_BUILTIN works on the Pico the same way it works on the Uno.

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);
  delay(500);
  digitalWrite(LED_BUILTIN, LOW);
  delay(500);
}

For an external LED on a GPIO pin, the wiring and code are the same as the Arduino blink tutorial, just with a different pin number:

#define LED_PIN 0

void setup() {
  pinMode(LED_PIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_PIN, HIGH);
  delay(500);
  digitalWrite(LED_PIN, LOW);
  delay(500);
}

Saving to flash

The script above only runs while Thonny is connected. To save it to the Pico's flash:

  1. File >> Save as
  2. Save to "MicroPython device" as main.py

Now the script runs whenever the Pico boots, with no computer attached.

Controlling the LED from the REPL

While the script is running, you cannot use the REPL. Press Ctrl+C in the Shell pane to interrupt. Now you can:

from machine import Pin
led = Pin("LED", Pin.OUT)
led.on()
led.off()
led.toggle()

The Pin("LED") constant works on both the Pico and the Pico W. On the original Pico, the LED is on pin 25; on the Pico W, it is on the Wi-Fi chip's GPIO (accessed via Pin("LED")).

Making it fade with PWM

The Pico has 8 PWM slices, each with two channels (16 PWM outputs total). Pin 25 (the onboard LED on the original Pico) is on PWM slice 0, channel 0 (it's PWM capable). On the Pico W, the onboard LED is not on a PWM-capable pin directly; you need to use PWM("LED") or the PWM(Pin("LED")) form.

from machine import Pin, PWM
import time

pwm = PWM(Pin("LED"))
pwm.freq(1000)

while True:
    for duty in range(0, 65536, 1000):
        pwm.duty_u16(duty)
        time.sleep_ms(5)
    for duty in range(65535, -1, -1000):
        pwm.duty_u16(duty)
        time.sleep_ms(5)

The LED fades up and down smoothly.

The differences between Pico and Pico W onboard LEDs

This trips up a lot of people.

  • Original Pico: onboard LED on GPIO 25 (PWM-capable).
  • Pico W: onboard LED on the Wi-Fi chip, accessed via Pin("LED") or PWM(Pin("LED")). PWM works but requires a special form because the LED is not on the main GPIO.

If your script works on the original Pico but not the Pico W, this is usually why.

Multiple LEDs

Wire external LEDs to any GPIO pin:

from machine import Pin
import time

red = Pin(0, Pin.OUT)
green = Pin(1, Pin.OUT)
blue = Pin(2, Pin.OUT)

while True:
    red.toggle()
    time.sleep_ms(500)
    green.toggle()
    time.sleep_ms(500)
    blue.toggle()
    time.sleep_ms(500)

Add a 220 ohm resistor in series with each LED to limit current.

Why Pin.toggle() and not Pin.value(not led.value())

toggle() is one operation; it is faster and atomic. value(not led.value()) reads, negates, and writes, which has a tiny window where another interrupt could change the pin.

For LED blinking, neither matters. For GPIO-banging a custom protocol, toggle is the right call.

What to build next

  • A button-controlled LED (combines with the button tutorial).
  • A "police light" pattern (alternating red and blue).
  • A "breathing" LED using PWM (the fade above, in a more polished form).

The "breathing" pattern is in the book Pico Fun Projects. The button version is one of the next tutorials on this site.


Chapter 06

Pico: use PIO to drive WS2812B LEDs (NeoPixels)

pico · 45 min

The Pico has Programmable I/O (PIO), which is the killer feature for LED projects. PIO is a tiny co-processor that handles bit-banged protocols in hardware, so your MicroPython script does not get blocked while the LEDs are updating.

This tutorial covers the PIO program for WS2812B, how to wire it up, and how to drive a strip from MicroPython.

What you need

  • Raspberry Pi Pico
  • WS2812B strip (any length)
  • 330 ohm resistor on the data line
  • 470uF capacitor across the strip's power pads
  • 5V power supply rated for the strip

Wiring

Pico VBUS (5V)  ---- 5V+ on power supply
                ---- 5V on WS2812B strip
Pico GND        ---- GND- on power supply
                ---- GND on WS2812B strip
Pico GPIO 0 --[330R]-- DIN on WS2812B strip

The resistor and capacitor are not optional for reliability.

The code

MicroPython (Pico)

Save this as ws2812b.py on the Pico:

import rp2
from machine import Pin
import time

@rp2.asm_pio(sideset_init=rp2.PIO.OUT_LOW, out_shiftdir=rp2.PIO.SHIFT_LEFT,
             autopull=True, pull_thresh=24)
def ws2812():
    T1 = 2
    T2 = 5
    T3 = 3
    wrap_target()
    label("bitloop")
    out(x, 1)               .side(0)    [T3 - 1]
    jmp(x_not_y, "do_zero") .side(1)    [T1 - 1]
    jmp("do_one")           .side(1)    [T2 - 1]
    label("do_zero")
    nop()                   .side(0)    [T2 - 1]
    label("do_one")
    wrap()

class WS2812B:
    def __init__(self, num_leds, pin):
        self.num_leds = num_leds
        self.sm = rp2.StateMachine(0, ws2812, freq=8_000_000,
                                    sideset_base=Pin(pin))
        self.sm.active(1)
        self.buf = bytearray(num_leds * 3)

    def __setitem__(self, index, color):
        offset = index * 3
        self.buf[offset] = (color >> 16) & 0xff   # red
        self.buf[offset + 1] = (color >> 8) & 0xff  # green
        self.buf[offset + 2] = color & 0xff         # blue

    def __getitem__(self, index):
        offset = index * 3
        return (self.buf[offset] << 16) | (self.buf[offset + 1] << 8) | self.buf[offset + 2]

    def fill(self, color):
        for i in range(self.num_leds):
            self[i] = color

    def write(self):
        self.sm.put(self.buf, 8)

# Usage:
NUM_LEDS = 60
strip = WS2812B(NUM_LEDS, 0)

def wheel(pos):
    if pos < 85:
        return (255 - pos * 3, pos * 3, 0)
    elif pos < 170:
        pos -= 85
        return (0, 255 - pos * 3, pos * 3)
    else:
        pos -= 170
        return (pos * 3, 0, 255 - pos * 3)

while True:
    for j in range(255):
        for i in range(NUM_LEDS):
            strip[i] = wheel((i * 256 // NUM_LEDS + j) & 255)
        strip.write()
        time.sleep_ms(10)

Run it. The strip should show a rainbow cycle.

Arduino (Pico)

The Pico's Arduino core has its own PIO API. Most projects do not need PIO directly: the Adafruit NeoPixel library handles the WS2812B protocol with regular GPIO bit-banging, and it works on the Pico out of the box. Install it through the Arduino IDE (Sketch >> Include Library >> Manage Libraries >> search Adafruit NeoPixel).

The Arduino code is the same as the ESP32 and Uno versions. Pin numbers in the Pico Arduino core map to the GPIO number directly, so PIN 0 is GPIO 0.

#include <Adafruit_NeoPixel.h>

#define LED_PIN    0
#define NUM_LEDS   60

Adafruit_NeoPixel strip(NUM_LEDS, LED_PIN, NEO_GRB + NEO_KHZ800);

uint32_t wheel(byte pos) {
  if (pos < 85) {
    return strip.Color(255 - pos * 3, pos * 3, 0);
  } else if (pos < 170) {
    pos -= 85;
    return strip.Color(0, 255 - pos * 3, pos * 3);
  } else {
    pos -= 170;
    return strip.Color(pos * 3, 0, 255 - pos * 3);
  }
}

void setup() {
  strip.begin();
  strip.show();
}

void loop() {
  for (long j = 0; j < 256; j++) {
    for (int i = 0; i < NUM_LEDS; i++) {
      strip.setPixelColor(i, wheel((i * 256 / NUM_LEDS + j) & 255));
    }
    strip.show();
    delay(10);
  }
}

The Adafruit NeoPixel library uses bit-banging, not PIO. It is accurate enough for short to medium strips (about 500 LEDs). For larger strips or for freeing the CPU during updates, you would write a PIO program directly via rp2040.pio headers in the Pico Arduino core. That is more advanced; the library version is fine for most projects.

How PIO works

PIO is a tiny state machine that runs on the chip separately from the main CPU. You write a small assembly program (the @rp2.asm_pio() function) that describes what to do with one or two GPIO pins. The Pico's hardware runs this program in parallel with your MicroPython code.

For the WS2812B protocol:

  • Each LED needs 24 bits (8 bits per color).
  • Each bit is encoded by the duration of the high signal: 0.4 us high for a "0" bit, 0.8 us high for a "1" bit.
  • The total period for one bit is 1.25 us.

The PIO program above runs at 8 MHz, with timing values tuned to produce the right pulse widths. The out instruction pulls a bit from the FIFO queue, and the side-set pin toggles to produce the protocol.

Why PIO is better than bit-banging

Without PIO, you would have to toggle the GPIO pin at exactly the right microseconds in MicroPython. With PIO, the hardware does it, freeing the CPU for your application code.

Practical differences:

  • CPU usage: 0% with PIO, 80%+ with bit-banging on a 60-LED update.
  • Timing accuracy: PIO is exact. Bit-banging has jitter that causes occasional glitches.
  • Multi-tasking: with PIO, you can run other code while the LEDs update. With bit-banging, your code is blocked.

For a 60-LED strip, the difference is huge. For 8 LEDs, either works.

Color order

WS2812Bs come in two color orders: GRB (most common) and RGB. The library above assumes GRB. If your colors are wrong (e.g. you ask for red and get green), swap the byte order:

def __setitem__(self, index, color):
    offset = index * 3
    self.buf[offset] = (color >> 8) & 0xff    # green first
    self.buf[offset + 1] = (color >> 16) & 0xff  # then red
    self.buf[offset + 2] = color & 0xff        # then blue

The WS2812B datasheet says GRB, but some strips are labeled RGB or BGR. When in doubt, test with a single color and check what comes out.

Brightness control

The strip uses 8 bits per color (0-255). For indoor eye-candy, drop the max to 60 or so to save power and avoid blinding yourself:

def brightness(color, factor):
    r = (color >> 16) & 0xff
    g = (color >> 8) & 0xff
    b = color & 0xff
    factor = factor / 255.0
    return (int(r * factor) << 16) | (int(g * factor) << 8) | int(b * factor)

# Usage:
strip[i] = brightness(wheel(...), 80)   # ~30% brightness

What to build next

  • A music-reactive LED strip (add a microphone and FFT).
  • An 8x8 or 16x16 LED matrix and run text across it.
  • A "fire" effect with random red/orange flickering.

The matrix version is in the book Pico LED Projects. The music-reactive strip is one of the next tutorials on this site.


Chapter 07

Pico W: publish MQTT messages from MicroPython over Wi-Fi

pico · 30 min

The Pico W has Wi-Fi, and MicroPython on the Pico W has an MQTT client (umqtt.simple). That is enough to publish sensor readings to a broker every few seconds, just like the ESP32 does. The pattern is the same: connect to Wi-Fi, connect to the broker, publish a JSON message, sleep, repeat.

The trade vs. the ESP32: the Pico W's Wi-Fi is less stable (auto- channel roaming can confuse it), and the MQTT client is more basic (no built-in QoS 2, no automatic reconnect). For a battery-powered sensor that publishes a JSON blob every 5 minutes, the Pico W is fine. For a real-time control system, the ESP32 is the better pick.

This tutorial uses the same broker (Mosquitto) and topic structure as the ESP32 MQTT tutorial, so you can mix the two boards on one broker and one dashboard.

What you need

  • Raspberry Pi Pico W (the W version is required for Wi-Fi)
  • MicroPython firmware installed (the v1.20+ builds have Wi-Fi and umqtt built in)
  • A computer running an MQTT broker. Mosquitto is a one-line install on most systems.
  • The Pico W and broker on the same network

Install the broker

On a Raspberry Pi, Linux, or macOS box:

sudo apt install mosquitto      # Debian / Ubuntu
brew install mosquitto          # macOS

Start it:

mosquitto -v

The -v prints every message to the terminal, which is useful for debugging.

If you do not have a broker yet and just want to test, you can use a public broker like test.mosquitto.org, but do not publish anything you would not want the whole internet to see. It is unauthenticated.

Install umqtt on the Pico W

Starting with MicroPython v1.20 for the Pico W, umqtt.simple is included in the firmware. If you are on an older build, install it via mip:

import mip
mip.install("umqtt.simple")

The code

Save this as main.py on the Pico W:

import network
import time
from machine import Pin
from umqtt.simple import MQTTClient

SSID = "your-wifi-ssid"
PASSWORD = "your-wifi-password"
BROKER = "192.168.1.50"   # your broker IP
TOPIC = "ctrlaltbrian/pico/sensor/temperature"
PUBLISH_INTERVAL_MS = 30000

def connect_wifi():
    wlan = network.WLAN(network.STA_IF)
    wlan.active(True)
    wlan.connect(SSID, PASSWORD)

    print("Connecting to Wi-Fi", end="")
    max_wait = 20
    while max_wait > 0:
        if wlan.isconnected():
            break
        max_wait -= 1
        print(".", end="")
        time.sleep(1)

    if not wlan.isconnected():
        raise RuntimeError("Wi-Fi failed to connect")

    print()
    print("Connected:", wlan.ifconfig())
    return wlan

def fake_temperature_reading():
    # Replace this with a real sensor read (DHT22, BME280, etc.)
    return 22.5 + (time.ticks_ms() % 100) / 50.0

def main():
    wlan = connect_wifi()
    client = MQTTClient(
        client_id="pico-w-publisher",
        server=BROKER,
        port=1883,
    )
    client.connect()
    print("MQTT connected")

    last_publish = 0
    while True:
        now = time.ticks_ms()
        if time.ticks_diff(now, last_publish) >= PUBLISH_INTERVAL_MS:
            temp = fake_temperature_reading()
            msg = '{{"temp":{:.1f}}}'.format(temp)
            client.publish(TOPIC, msg)
            print("Published:", msg)
            last_publish = now

        client.check_msg()   # process any incoming QoS 0 messages
        time.sleep_ms(100)

main()

The time.ticks_diff function is the MicroPython way to do non- blocking elapsed-time math. It is safe across the time.ticks_ms() overflow (every ~12 days for ms ticks).

Subscribing from another device

Subscribe from any device on the same network to see the messages:

mosquitto_sub -h 192.168.1.50 -t "ctrlaltbrian/#" -v

The -v flag prints the topic too. This is what I use for debugging more than half the time.

Adding a real sensor

Replace fake_temperature_reading() with a DHT22 read. The umqtt.simple pattern does not change:

import dht

DHT_PIN = 4
sensor = dht.DHT22(Pin(DHT_PIN))

def read_temperature_and_humidity():
    sensor.measure()
    return sensor.temperature(), sensor.humidity()

# in main():
temp, hum = read_temperature_and_humidity()
msg = '{{"temp":{:.1f},"hum":{:.1f}}}'.format(temp, hum)
client.publish(TOPIC, msg)

For a BME280 over I2C, the read is the same shape. The BME280 driver is mip.install("bme280").

Sleep between publishes

For battery-powered projects, the Pico W should sleep between publishes. MicroPython's machine.deepsleep() works on the Pico W:

import machine

PUBLISH_INTERVAL_SEC = 300   # 5 minutes

def main():
    connect_wifi()
    client = MQTTClient("pico-w-sleep", BROKER, port=1883)
    client.connect()
    temp, hum = read_temperature_and_humidity()
    msg = '{{"temp":{:.1f},"hum":{:.1f}}}'.format(temp, hum)
    client.publish(TOPIC, msg)
    client.disconnect()
    wlan = network.WLAN(network.STA_IF)
    wlan.disconnect()
    machine.deepsleep(PUBLISH_INTERVAL_SEC * 1000)

main()

On wake, MicroPython runs main.py from the top. The Pico W reconnects to Wi-Fi, publishes one reading, and goes back to sleep.

The deep sleep current on the Pico W is about 1.3 mA (the Wi-Fi chip's idle draw). For lower power, you can power down the Wi-Fi chip between cycles, but that adds 200 ms of wake-up time.

The Pico W does not have a true deep sleep like the ESP32. The Wi-Fi chip stays powered in deepsleep() mode. For very low power, use a hardware timer (e.g. the TPL5110) to cut the whole board's power between cycles.

Will (the QoS option)

umqtt.simple supports QoS 0 and QoS 1:

  • QoS 0 (default): fire and forget. The broker may or may not receive the message. Use for things that refresh anyway.
  • QoS 1: at-least-once. The broker ACKs the message; the client resends if no ACK. May get duplicates.

To publish with QoS 1:

client.publish(TOPIC, msg, qos=1)

For sensor publishing, QoS 0 is fine. The next reading is in 30 seconds anyway. For commands, use QoS 1.

When the Wi-Fi keeps dropping

The Pico W's Wi-Fi is less stable than the ESP32's. The most common failure mode is the connection dropping after a few hours of operation. The fix is a reconnect loop:

def ensure_wifi(wlan):
    if not wlan.isconnected():
        print("Reconnecting...")
        wlan.disconnect()
        time.sleep(1)
        wlan.connect(SSID, PASSWORD)
        for _ in range(20):
            if wlan.isconnected():
                return
            time.sleep(1)
        raise RuntimeError("Wi-Fi reconnect failed")

Call ensure_wifi(wlan) before each publish. If the connection is flaky enough that this fires often, switch to the ESP32.

Topics, the same convention as the ESP32

The convention I use across all my boards is:

ctrlaltbrian/<room>/<device>/<measurement>

For the Pico W:

  • ctrlaltbrian/garage/pico-w-1/temperature
  • ctrlaltbrian/garden/pico-w-2/soil_moisture

This lets a single Mosquitto broker receive from any combination of ESP32s and Pico Ws, and any dashboard can subscribe to all of one room or all of one measurement across the house.

What you learned

  • The Pico W has Wi-Fi and umqtt in MicroPython v1.20+.
  • The pattern is the same as ESP32: connect Wi-Fi, connect broker, publish JSON, sleep, repeat.
  • The Pico W's Wi-Fi is less stable than the ESP32's; add a reconnect loop.
  • For battery projects, use machine.deepsleep() between cycles.

When something breaks

  • ImportError: no module named 'umqtt'. The MicroPython firmware is older than v1.20. Either flash a new firmware (UF2 file from micropython.org) or install manually: import mip; mip.install("umqtt.simple").
  • OSError: [Errno 113] EHOSTUNREACH. Broker IP is wrong, or the Pico W is on a different network/VLAN. Try ping 192.168.1.50 from a laptop on the same Wi-Fi.
  • MQTTException: 5. Broker rejected the connection. The broker may not allow anonymous connections, or the client_id is already in use. Add a unique client_id (use the chip's unique ID: binascii.hexlify(machine.unique_id()).decode()).
  • Messages publish but never arrive at the subscriber. The topic is wrong, or the broker is on a different network. Verify with mosquitto_sub -h 192.168.1.50 -t "ctrlaltbrian/#" -v from a laptop on the same network.

What to build next

  • The ESP32 MQTT publish/subscribe tutorial shows the same pattern on the ESP32. Both boards can publish to one broker.
  • The Pico W sensor web server tutorial turns the Pico W into a tiny dashboard, no broker needed.
  • The book Pico Wi-Fi Projects covers the asyncio version of this, which runs Wi-Fi, MQTT, and a sensor read in parallel.

Chapter 08

Pico W: serve a live sensor dashboard over Wi-Fi

pico · 30 min

The Pico W plus a DHT22 is the smallest useful sensor dashboard you can build. The chip is $6, the sensor is $2, and you get a live web page that shows the temperature and humidity on any phone or laptop on the same Wi-Fi. No cloud, no app, no MQTT broker.

The pattern is the same as the ESP32 web server tutorial: read the sensor, build a small HTML page with the values, serve it on port 80. The Pico W runs MicroPython, so the code is shorter than the ESP32 version and easier to read.

What you need

  • Raspberry Pi Pico W (the W version is required for Wi-Fi)
  • DHT22 or BME280 sensor
  • 4.7 kohm pull-up resistor (for DHT22, between the data pin and 3.3V)
  • MicroPython firmware installed (v1.20+ has Wi-Fi)
  • A known Wi-Fi network

Wiring

DHT22

DHT22 VCC -- Pico 3.3V (pin 36)
DHT22 GND -- Pico GND  (pin 38)
DHT22 DAT -- Pico GPIO 4 (pin 6)

Add a 4.7 kohm pull-up between DAT and 3.3V. The DHT22's open-drain output needs it.

BME280 (over I2C)

BME280 VCC -- Pico 3.3V (pin 36)
BME280 GND -- Pico GND  (pin 38)
BME280 SDA -- Pico GPIO 0 (pin 1, default I2C0 SDA)
BME280 SCL -- Pico GPIO 1 (pin 2, default I2C0 SCL)

The Pico's default I2C pins are GPIO 0 and 1. If you have other devices on the I2C bus, the BME280 is address 0x76 (most boards) or 0x77 (if SDO is tied to VCC).

The code (DHT22 version)

Save this as main.py on the Pico W:

import network
import socket
import time
from machine import Pin
import dht

SSID = "your-wifi-ssid"
PASSWORD = "your-wifi-password"
DHT_PIN = 4

sensor = dht.DHT22(Pin(DHT_PIN))
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(SSID, PASSWORD)

print("Connecting to Wi-Fi", end="")
for _ in range(20):
    if wlan.isconnected():
        break
    print(".", end="")
    time.sleep(1)

if not wlan.isconnected():
    raise RuntimeError("Wi-Fi failed to connect")

ip = wlan.ifconfig()[0]
print()
print(f"Web server running on http://{ip}")

addr = socket.getaddrinfo(ip, 80)[0][-1]
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(addr)
s.listen(2)
s.settimeout(0.1)

last_read_ms = 0
temp = 0.0
hum = 0.0

def read_sensor():
    global temp, hum
    try:
        sensor.measure()
        temp = sensor.temperature()
        hum = sensor.humidity()
    except OSError as e:
        print("Sensor read failed:", e)

def build_page():
    return f"""<!DOCTYPE html>
<html><head>
<meta charset='utf-8'>
<meta http-equiv='refresh' content='5'>
<title>Pico W sensor dashboard</title>
<style>
body{{font-family:sans-serif;background:#0f1115;color:#e6e9ef;
display:flex;flex-direction:column;align-items:center;padding:2rem;}}
.card{{background:#161a22;padding:1.5rem 2rem;margin:0.5rem;border-radius:6px;
min-width:220px;text-align:center;}}
.value{{font-size:2.5rem;color:#4ea1ff;}}
</style></head><body>
<h1>Pico W sensor dashboard</h1>
<div class='card'><div class='value'>{temp:.1f} &deg;C</div><div>temperature</div></div>
<div class='card'><div class='value'>{hum:.1f} %</div><div>humidity</div></div>
</body></html>"""

while True:
    # Update sensor reading every 2 seconds
    now = time.ticks_ms()
    if time.ticks_diff(now, last_read_ms) >= 2000:
        read_sensor()
        last_read_ms = now

    # Accept any pending client
    try:
        cl, client_addr = s.accept()
    except OSError:
        continue

    try:
        req = cl.recv(1024).decode("utf-8")
        # Ignore the request body; we always return the same page
        cl.send("HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\n")
        cl.send(build_page())
    except OSError as e:
        print("Request error:", e)
    finally:
        cl.close()

The pattern is the same as the basic Pico W web server, with two additions: a non-blocking sensor read every 2 seconds, and a <meta http-equiv='refresh' content='5'> tag in the HTML that reloads the page every 5 seconds.

The code (BME280 version)

If you have a BME280 instead, the only changes are the import and the read function:

from machine import I2C, Pin
import bme280

i2c = I2C(0, scl=Pin(1), sda=Pin(0), freq=100_000)
sensor = bme280.BME280(i2c=i2c)

def read_sensor():
    global temp, hum, press
    t, p, h = sensor.read_compensated_data()
    temp = t / 100.0
    press = p / 25600.0
    hum = h / 1024.0

The BME280 needs the bme280 library installed first. Save bme280.py from https://github.com/micropython-IMUFUSION/micropython-bme280 to the Pico W's filesystem, or install via mip:

import mip
mip.install("bme280")

Why the meta refresh, not WebSockets

The HTML page uses <meta http-equiv='refresh' content='5'> to reload every 5 seconds. That works on every browser, with no JavaScript. The cost is a full page reload every 5 seconds, which uses ~3 KB of data per reload.

For smoother updates, the WebSocket version sends just the new sensor values every 5 seconds. That is a separate tutorial. The meta-refresh version is what I ship to clients who need "is the sensor up" without learning WebSockets.

What the page looks like

Open http://<pico-ip>/ in a browser. You see a dark page with two big cards: "22.5 C" and "45.2 %". Every 5 seconds the page reloads and the numbers update. On a phone, it looks the same. On a laptop, it looks the same. No app, no login, no cloud account.

The IP address prints to the REPL on boot. Save that to a sticky note, or set a static DHCP lease for the Pico W's MAC address in your router (this is the right move for a permanent install).

The request handling detail

cl.recv(1024) reads up to 1024 bytes of the HTTP request. For the page reload that the browser sends, that is enough. The full HTTP request line for a browser reload looks like:

GET / HTTP/1.1
Host: 192.168.1.42
...

We do not parse it. We just send the same HTML back. The browser ignores the URL and renders the page.

This is fine for a read-only dashboard. If you want to add controls (buttons, sliders, form submission), the request URL and method matter. See the Pico W web server tutorial for the URL-parsing version.

Avoiding the "client already connected" error

A common bug: the Pico W serves one page, then a few seconds later the browser tries to reconnect and the socket is still in CLOSE_WAIT. The fix is setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) and a finally: cl.close() in the request handler.

If you still see the error, lower the listen backlog:

s.listen(2)   # accept at most 2 queued connections

For a dashboard that one person views, listen(2) is plenty.

Power consumption

The Pico W draws about 30 mA active (Wi-Fi connected, no sensor read) and 1.3 mA in deep sleep. The DHT22 draws about 1.5 mA when sampling. For a battery-powered sensor that refreshes every 5 seconds, average current is about 35 mA active for 200 ms every 5 seconds, plus the deep sleep baseline. That is:

0.04 * 35 mA + 0.96 * 1.3 mA = 2.65 mA average

A 2000 mAh 18650 cell gives about 2000/2.65 = 750 hours = 31 days. For longer life, refresh less often, or use a hardware timer to cut the whole board's power.

What you learned

  • The Pico W can read a sensor and serve a web page from the same chip.
  • <meta http-equiv='refresh'> is the no-JavaScript way to auto- reload.
  • The HTTP request handling is "accept, recv, send, close" in a loop, with a non-blocking sensor read on the side.
  • A 2000 mAh battery gives about a month of life for a 5-second refresh.

When something breaks

  • The page loads but shows 0.0 for everything. Sensor read is failing. Check the wiring. For DHT22, the most common bug is missing pull-up resistor. For BME280, the most common bug is wrong I2C address (run i2c.scan() and update).
  • Browser says "site took too long to respond". The Pico W's Wi-Fi is not connected. Watch the REPL for the IP address; if you see "Wi-Fi failed to connect" on boot, fix the SSID and password.
  • Page loads but is missing styles. The browser cached the old page. Hard reload (Ctrl+Shift+R or Cmd+Shift+R).
  • OSError: [Errno 98] EADDRINUSE on boot. The Pico W did not release the socket from the last boot. Add s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) and the listen line. Or pull the power for 10 seconds.
  • Numbers update slowly. The DHT22 has a 2-second minimum between reads. Faster than that and the sensor returns the previous value. Use the BME280 if you need 10 Hz updates.

What to build next

  • The Pico W MQTT publish tutorial pushes the same readings to a broker instead of serving a web page.
  • The Pico W web server tutorial is the simpler version (LED on/off, no sensor).
  • The book Pico Wi-Fi Projects covers the WebSocket version of this, which sends just the new values every 5 seconds without a full page reload.

Chapter 09

Pico: I2C in depth with MicroPython, scanners and bus recovery

pico · 30 min

I2C is the protocol I reach for when I need to wire up a sensor and a microcontroller. Two wires, lots of devices on the same bus, simple addressing. It is also the protocol that produces the most confused emails I get, because I2C has three or four distinct failure modes that all look like "the sensor is not responding."

This tutorial is the one I wish I had read the first five times I tried to use I2C. The patterns here (scan, recover, address gotcha, pull-ups) come up in every I2C project.

What you need

  • Raspberry Pi Pico (or Pico W)
  • An I2C device (a BME280, an MPU6050, an OLED display, an ADS1115, anything with SDA and SCL pins)
  • 2 jumper wires (or 4, if the device has VCC and GND to wire too)
  • USB cable
  • MicroPython firmware installed (see the UART tutorial for the install steps)

For a "first I2C device," I recommend the BME280 or the SSD1306 OLED. Both are well-documented, both work at 3.3V, and both have libraries in MicroPython.

What I2C is (SDA, SCL, addresses)

I2C is a two-wire protocol:

  • SDA is the data line.
  • SCL is the clock line.

Both lines need pull-up resistors to VCC. The Pico has internal pull-ups that are usually strong enough for one or two devices on a short bus. For more devices or longer wires, you need external pull-ups (typically 4.7k resistors from SDA and SCL to 3.3V).

Each device on the bus has a 7-bit address (0x00 to 0x7F). The master (the Pico) initiates every transaction. The slave (device) responds when its address is called. Multiple devices can share the bus as long as they have different addresses.

The bus speed is 100 kHz in standard mode, 400 kHz in fast mode, 1 MHz in fast mode plus, and 3.4 MHz in high speed mode. The Pico supports all of these. Most sensors only do 100 kHz or 400 kHz.

The code: scanning the bus

The first thing I run on any new I2C device is a bus scan. It tells you whether the device is responding and at what address:

from machine import I2C, Pin

i2c = I2C(0, scl=Pin(1), sda=Pin(0), freq=100_000)

devices = i2c.scan()
print('Found devices at:', [hex(d) for d in devices])

If you have a BME280 wired correctly, this prints something like:

Found devices at: ['0x76']

If it prints an empty list, the wiring is wrong, the device has no power, or the device has a different default address. Most sensors have an address pin that lets you choose between two addresses (e.g. the BME280 is 0x76 by default, 0x77 if you tie the SDO pin to VCC). Try the other one.

The 7-bit vs 8-bit address gotcha

This is the part that has bitten me the most times. The I2C address is 7 bits. The 8th bit is the read/write bit.

When a datasheet says "the device address is 0x76," that is the 7-bit address. When a datasheet says "the device address is 0x76 / 0x77 for write, 0x77 / 0x78 for read," that is already shifted to include the R/W bit, and you have to right- shift by 1 to get the 7-bit address (0x76 / 0x77).

The rule: if the address in the datasheet is even, the author has already shifted. Divide by 2. If it is odd, the author is giving you the 7-bit address as-is. Use it directly. (This rule is approximate, and you should always check the actual datasheet.)

The MicroPython I2C.scan() returns the 7-bit address. Most libraries (bme280, ssd1306, etc.) also take the 7-bit address. If you are getting OSError: [Errno 5] EIO on every transaction, you almost certainly have the wrong address.

Reading a sensor

Once the scan finds the device, reading data is straightforward:

from machine import I2C, Pin

i2c = I2C(0, scl=Pin(1), sda=Pin(0), freq=100_000)

# raw read: 2 bytes from register 0x00
data = i2c.readfrom_mem(0x76, 0x00, 2)
print('Raw bytes:', data)

# write a byte to a register
i2c.writeto_mem(0x76, 0xF4, b'\x27')

For a real sensor, use a library. MicroPython has a BME280 library in the bme280_float module (one of the community-maintained ones). The driver handles the register reads and the math:

import bme280_float as bme280
bme = bme280.BME280(i2c=i2c)
print(bme.values)
# ('23.45C', '45.67%', '1013.25hPa')

The MicroPython library ecosystem for I2C sensors is smaller than the Arduino one, but the common ones (BME280, MPU6050, SSD1306, ADS1115, AHT20) all have working drivers.

Pull-up resistors: internal vs external

I2C requires pull-up resistors on SDA and SCL. The Pico's internal pull-ups are about 50k, which is too weak for a bus with more than one device or any meaningful wire length.

For one device on a short bus (under 10 cm), internal pull-ups usually work. For more devices or longer wires, add external pull-ups:

Pico 3.3V --[ 4.7k ]-- SDA
Pico 3.3V --[ 4.7k ]-- SCL

Smaller resistor values (2.2k, 1k) give stronger pull-ups and support faster speeds or longer buses. The trade-off is more current draw when the bus is low.

You can also enable internal pull-ups in code, but those are too weak for most cases. The I2C bus constructor accepts sda=Pin(0, Pin.IN, Pin.PULL_UP) if you want to be explicit.

The bus lockup problem (SDA stuck LOW) and how to recover

This is the failure mode that confuses everyone. The Pico is the master, the sensor is the slave, the sensor is in the middle of a transaction, and then... silence. The Pico says "SDA is stuck low." Every subsequent read times out.

The cause: the slave device was interrupted (power loss, glitch on the line, software bug in the slave) while it was holding SDA low. The slave is now waiting for the master to clock out the rest of the transaction, but the master has already given up. Deadlock.

The fix: manually clock out the rest of the transaction. The master toggles SCL up and down 9 times while watching SDA. The slave releases SDA on the next clock pulse.

from machine import Pin

def i2c_recover(scl_pin_num, sda_pin_num):
    scl = Pin(scl_pin_num, Pin.OPEN_DRAIN, value=1)
    sda = Pin(sda_pin_num, Pin.OPEN_DRAIN, value=1)

    # Toggle SCL up to 9 times to release a stuck slave
    for _ in range(9):
        scl.value(0)
        scl.value(1)
        if sda.value() == 1:
            break

    # Send a STOP (SDA low while SCL high, then SDA high)
    sda.value(0)
    scl.value(1)
    sda.value(1)

# usage:
i2c_recover(scl_pin_num=1, sda_pin_num=0)

This is the I2C bus recovery procedure from the I2C spec. Every embedded engineer eventually writes this function. Keep it in your toolbox.

After recovery, re-run i2c.scan() to confirm the device is back.

The multi-master I2C gotcha

I2C supports multiple masters. The Pico can be one master, a Raspberry Pi can be another, and they share the bus.

This sounds convenient. It is a recipe for hangs.

The problem: two masters both think the bus is free, both start a transaction at the same time, and the bus state becomes incoherent. The I2C spec has arbitration for this, but in practice it is fragile.

The fix: pick one master. The Pico is the master, the Raspberry Pi is the master, not both. Use a logic level shifter or a different bus for the second master.

For most projects, you have one master (the microcontroller) and many slaves (sensors). That works. Multi-master is for specific cases (e.g. a hot-swap controller board) and is not worth the complexity otherwise.

When to use I2C vs SPI

Both are serial protocols for inter-chip communication. The short version:

  • I2C: 2 wires, many devices, 400 kHz typical, address conflicts are possible. Use for: sensors, GPIO expanders, low-bandwidth peripherals.
  • SPI: 4+ wires, one device per CS pin, 10+ MHz, no addressing. Use for: displays with high refresh, SD cards, sensors that need fast reads.

The decision: if you need speed and have CS pins to spare, SPI. If you need to share a bus and can live with 400 kHz, I2C.

The I2C bus on the Pico can do 1 MHz if the sensors support it, but most stop at 400 kHz. For a sensor that needs 1 MHz or more, SPI is the right choice.

When something breaks

  • OSError: [Errno 5] EIO on every read. The address is wrong, the device is unpowered, or SDA/SCL are swapped. Re-run the scanner. Check the wiring.
  • OSError: [Errno 110] ETIMEDOUT. The bus is locked up. Run the recovery procedure above.
  • The scanner finds the device but reads return garbage. The baud rate is too high for the wire length. Drop to 100 kHz. Or the pull-ups are too weak. Add 4.7k external pull-ups.
  • Works on the breadboard, fails on the soldered board. Cold solder joint, usually on SDA or SCL. Reflow.
  • Two devices with the same address. I2C addresses are 7 bits, so 128 possible. With more than a few devices on the bus, address conflicts are common. Most sensor breakouts have an address pin to choose between two addresses. For three or more devices, use a TCA9548A I2C multiplexer ($3) to split the bus into 8 sub-buses.

What to build next

  • A multi-sensor weather station (BME280 + BH1750 light sensor
    • soil moisture on the same I2C bus).
  • An OLED display that shows live sensor readings.
  • A TCA9548A multiplexer to break an address conflict.
  • An I2C slave: configure a second Pico as a slave and communicate master-to-master.

The OLED display is the natural next project. The book Pico MicroPython has a chapter on building a sensor hub that reads a dozen I2C devices and exposes the data over a simple UART stream, including the bus recovery function as a defensive measure.


Chapter 10

Pico: PWM in depth with MicroPython, frequencies and duty cycles

pico · 30 min

PWM is the trick that turns a digital pin into something that acts analog. You flash the pin high and low so fast that the load (an LED, a motor, a servo) only ever sees the average. The fraction of the time the pin is high is the duty cycle. How often the pin goes through a full cycle is the frequency.

I use PWM for almost every output that is not a simple on/off. LED dimming, servo angles, motor speed, audio tones, even crude DAC output with a capacitor. The Pico is good at this because it has dedicated PWM hardware called slices, and MicroPython exposes them through machine.PWM.

What you need

  • Raspberry Pi Pico (or Pico W, same code)
  • An LED + 220 ohm resistor (for the fading example)
  • A hobby servo, e.g. SG90 (for the servo example)
  • A passive buzzer, e.g. the 3-pin "KY-006" type (for the tone example)
  • A 10k ohm resistor and a 1uF capacitor (for the low-pass filter example)

What PWM actually is

Two knobs, that's it:

  • Frequency: how many full on/off cycles per second, in Hz.
  • Duty cycle: what fraction of each cycle the pin is high, in percent.

So a 50 Hz signal with 10% duty is high 2 ms, low 18 ms, repeating 50 times a second. A 1 kHz signal with 50% duty is high 0.5 ms, low 0.5 ms, repeating 1000 times a second.

The Pico's PWM slices run independently of the CPU. You set them up once and they keep toggling the pin until you change the duty cycle. Your code is not in the loop. That is why you can drive 8 outputs with no flicker and almost no CPU.

The machine.PWM API

from machine import Pin, PWM

pwm = PWM(Pin(0))
pwm.freq(50)                   # 50 Hz, the standard for servos
pwm.duty_u16(32768)            # 50% duty, 16-bit range 0-65535

The 16-bit range matters. A hobby servo wants 1-2 ms pulses out of a 20 ms cycle, so the resolution at 50 Hz is about 0.3 microseconds per step. That is plenty for any servo I have ever used.

Three methods you will use constantly:

  • pwm.freq(hz) sets the frequency.
  • pwm.duty_u16(value) sets the duty as 0-65535.
  • pwm.duty_ns(ns) sets the duty as a number of nanoseconds. This is the one to use for servos if you want the math to be obvious (e.g. pwm.duty_ns(1_500_000) is a 1.5 ms pulse).

There is also pwm.duty_u8(0-255) for when you want the 8-bit Arduino mental model. I almost never use it.

Choosing a frequency

Different loads want different frequencies. The wrong frequency is the most common PWM bug I see.

Use case Frequency Why
Hobby servo 50 Hz The servo protocol. Anything else makes the servo unhappy.
LED dimming 1 kHz+ Below 100 Hz the eye sees flicker. 1 kHz looks smooth.
Buzzer tone 100-4000 Hz The frequency is the pitch.
DC motor speed 25 kHz+ Above audible range. Otherwise the motor whines.
Audio output (with LPF) 44.1 kHz+ Matches audio sample rates.

Pick the frequency for the load, not the load for the frequency. The 25 kHz motor rule exists because a motor coil is a speaker. PWM it at 1 kHz and you have a 1 kHz speaker.

Servo: the 1-2 ms pulse rule

A hobby servo wants a pulse every 20 ms (50 Hz). The pulse width tells it where to go:

  • 1.0 ms = one extreme (0 degrees on most servos)
  • 1.5 ms = center (90 degrees)
  • 2.0 ms = other extreme (180 degrees)
from machine import Pin, PWM
import time

servo = PWM(Pin(0))
servo.freq(50)

def angle(a):
    # Map 0-180 degrees to 1.0-2.0 ms pulse widths
    pulse_us = 1000 + (a / 180) * 1000
    servo.duty_ns(pulse_us * 1000)

angle(0)
time.sleep(1)
angle(90)
time.sleep(1)
angle(180)

I use duty_ns for servos because the math is honest. 1.5 ms is 1,500,000 ns. The PWM slice does the rest.

Fading an LED

from machine import Pin, PWM
import time

led = PWM(Pin(15))
led.freq(1000)

while True:
    for duty in range(0, 65536, 256):
        led.duty_u16(duty)
        time.sleep_ms(5)
    for duty in range(65535, -1, -256):
        led.duty_u16(duty)
        time.sleep_ms(5)

The range(0, 65536, 256) step size of 256 is a trade-off: smoother fades use a smaller step but more CPU. For a status LED I use 1024. For a mood lamp I use 64.

The LED can flicker at low duty cycles even with a 1 kHz PWM. The eye sees the discrete steps. If the flicker bothers you, drop to 8-bit resolution (duty_u8 instead of duty_u16).

Generating tones

A passive buzzer is a small speaker. Apply a square wave at the right frequency and you get a tone. Change the frequency and you get a different tone.

from machine import Pin, PWM
import time

buzzer = PWM(Pin(14))

def tone(hz, duration_ms):
    buzzer.freq(hz)
    buzzer.duty_u16(32768)   # 50% duty, the loudest
    time.sleep_ms(duration_ms)
    buzzer.duty_u16(0)       # silence

# Middle C, E, G, C
for note in [262, 330, 392, 523]:
    tone(note, 400)
    time.sleep_ms(50)

You can also play melodies. The time.sleep_ms between notes is what makes a melody sound like a melody and not a chord.

Active buzzers (the kind with a built-in oscillator) ignore the frequency and just beep. Make sure your buzzer is the passive kind if you want tones.

The 8-slices trick

The Pico has 8 PWM slices, each with two channels (A and B) for a total of 16 PWM outputs. Every GPIO from 0 to 28 is wired to one of these slices, and each slice drives two specific pins. Both pins on a slice share the same frequency, but you can set different duty cycles on A and B.

The trick: you can run PWM on 8 different frequencies, but if you want PWM on more than 8 pins at different frequencies, you run out of slices. The solution is to either accept that pins on the same slice share a frequency, or use the PIO peripheral (a different beast) for the odd ones out.

from machine import Pin, PWM

# Two pins on the same slice share a frequency
# GP0 and GP16 are both on slice 0
pwm_a = PWM(Pin(0), freq=50)
pwm_b = PWM(Pin(16), freq=50)

# GP1 and GP17 are on slice 1, independent of slice 0
pwm_c = PWM(Pin(1), freq=1000)
pwm_d = PWM(Pin(17), freq=1000)

For most projects you never hit the limit. I mention it because it surprises people the first time two pins "fight" over the frequency.

PWM as a cheap DAC

A capacitor and a resistor turn PWM into a DC voltage. The capacitor averages the high/low transitions. The resistor sets how fast the capacitor charges (this is the "low-pass filter").

Pico GPIO ----[1k]----+---- LED or ADC input
                      |
                    [1uF]
                      |
                     GND

With 1 kHz PWM and a 1k/1uF low-pass filter, you get a stable DC voltage from about 0 V to 3.3 V. The output is not perfect (it has a small ripple), but for "set a brightness level" or "control a slow analog input" it is plenty.

from machine import Pin, PWM
import time

dac = PWM(Pin(0))
dac.freq(1000)

# Set "voltage" to 50% of 3.3V = 1.65V
dac.duty_u16(32768)

The output is not a real DAC. It has ripple, and it cannot change instantaneously. If you need a real analog output, use the Pico's ADC input range or an external DAC like the MCP4725.

What you learned

  • PWM is duty cycle + frequency. Pick the frequency for the load.
  • The Pico has 8 PWM slices driving up to 16 pins. Pins on the same slice share a frequency.
  • duty_ns is the honest way to set servo pulse widths.
  • A 1k/1uF RC low-pass filter turns PWM into a slow analog signal.

When something breaks

The servo jitters. Three usual causes: insufficient power (the SG90 draws 200 mA, use a separate 5V supply for multiple servos), shared ground missing, or electrical noise (add a 100uF cap across the servo power pins).

The LED is dim even at 100% duty. The PWM pin is 3.3V. Your LED is rated for a higher forward voltage. Check the LED's Vf.

The buzzer is silent. Check whether it is active or passive. Active buzzers ignore the frequency.

Two pins on the same slice ignore independent frequencies. They are on the same slice. Move one of them to a different slice's pins, or accept the shared frequency.

ValueError: bad PWM freq. MicroPython's PWM has a minimum frequency (usually around 1 Hz) and the math needs to work out. If you ask for 1 Hz at 16-bit, the slice can take a long time to count up; MicroPython may reject it. Lower the resolution or use a higher frequency.

What to build next

  • A pan/tilt camera mount with two servos.
  • A music box with the buzzer, with a list of note frequencies in a tuple.
  • A "fake DAC" that drives a voltage-controlled oscillator (e.g. a PWM input on a motor controller).

The pan/tilt mount is one of the next tutorials on this site. The music box is in the Pico Audio Projects book.


Chapter 11

Pico: UART serial with MicroPython, talking to other chips

pico · 30 min

UART is the oldest serial protocol still in use, and it is the one you will reach for most often when you need a microcontroller to talk to another chip. GPS modules, RFID readers, Nextion displays, ESP8266 modules, even another Pico, all use UART. Two wires, a common ground, and a baud rate.

This is the tutorial I wish I had read before wiring a GPS module up wrong twice. The thing I got wrong: the TX of one device goes to the RX of the other, not to the TX of the other. Every beginner gets this once.

What you need

  • Raspberry Pi Pico (or Pico W)
  • Another UART device (a GPS module, an Arduino, a second Pico, a Nextion display, an ESP8266)
  • 3 jumper wires (TX, RX, GND)
  • USB cable
  • MicroPython firmware installed (see below)

For the GPS example I use the common NEO-6M module. For a Pico talking to a Pico, you need two Picos. For a Nextion, any of the basic 2.4" or 3.2" displays.

Install MicroPython

Hold the BOOTSEL button on the Pico while plugging in the USB cable. The Pico appears as a USB drive. Drag the RPI-PICO-2024-...uf2 file from https://micropython.org/download/RPI_PICO/ onto the drive. The Pico reboots as a MicroPython device.

To talk to the REPL, use Thonny (pip3 install thonny) or mpremote (pip3 install mpremote). Both work. Thonny is more beginner-friendly; mpremote is the CLI option I use.

Wiring

The Pico has two UART peripherals: UART0 and UART1. The default pin mapping is:

UART0 TX = GP0  (physical pin 1)
UART0 RX = GP1  (physical pin 2)
UART1 TX = GP4  (physical pin 6)
UART1 RX = GP5  (physical pin 7)

For a GPS module:

GPS VCC -- Pico 3.3V (the NEO-6M is 3.3V)
GPS GND -- Pico GND
GPS TX  -- Pico GP1 (UART0 RX)
GPS RX  -- Pico GP0 (UART0 TX)

Notice the cross: the GPS's TX goes to the Pico's RX (GP1), and the GPS's RX goes to the Pico's TX (GP0). The rule is always "my TX to your RX." If both devices try to drive the same wire, nothing works.

The "common ground" requirement is the third wire. Both devices need to share a ground reference, or the voltage levels do not mean anything. With separate power supplies (e.g. a Pico on USB and a GPS on a battery), the ground wire is what ties them together.

The code

from machine import UART, Pin
import time

uart = UART(0, baudrate=9600, tx=Pin(0), rx=Pin(1))

while True:
    if uart.any():
        data = uart.read()
        if data:
            print(data)
    time.sleep(0.1)

That is the entire pattern for receiving. UART(0, ...) is the UART0 peripheral. baudrate=9600 is the speed in bits per second. The tx and rx arguments set the GPIO pins.

uart.any() returns the number of bytes waiting in the receive buffer. If it is greater than zero, there is data to read. uart.read() reads whatever is in the buffer and returns it as a bytes object.

The GPS at 9600 baud emits one sentence per second, so the loop above catches them.

Baud rates: 9600, 115200, 921600

Baud rate is the speed of the serial link in bits per second. The two devices have to agree, or the data is garbage.

Common rates and what uses them:

  • 9600: the default for most GPS modules, RFID readers, and older serial devices. Slow but universal.
  • 115200: the default for ESP8266 AT firmware, Nextion displays, and most modern dev boards. 12x faster than 9600.
  • 921600: the high-speed option for short-range links. Some sensors go this fast; some go to 1 Mbaud or higher.

Higher baud rates need shorter wires. At 9600, a 1-meter wire is fine. At 921600, keep it under 30 cm or you get signal integrity issues.

Pick the lowest baud rate the device supports. Faster is not better if the device cannot keep up.

Reading GPS data

A GPS module at 9600 baud sends NMEA sentences, one per second:

$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47

The MicroPython code to parse this is straightforward:

import time

def parse_gga(sentence):
    if not sentence.startswith('$GPGGA'):
        return None
    parts = sentence.split(',')
    if len(parts) < 10 or not parts[2] or not parts[4]:
        return None
    return {
        'time': parts[1],
        'lat': float(parts[2][:2]) + float(parts[2][2:]) / 60,
        'lat_dir': parts[3],
        'lon': float(parts[4][:3]) + float(parts[4][3:]) / 60,
        'lon_dir': parts[5],
        'fix': int(parts[6]),
        'sats': int(parts[7]),
    }

uart = UART(0, baudrate=9600, tx=Pin(0), rx=Pin(1))
buf = b''
while True:
    if uart.any():
        buf += uart.read()
        if b'\n' in buf:
            line, _, buf = buf.partition(b'\n')
            sentence = line.decode('utf-8', 'ignore').strip()
            fix = parse_gga(sentence)
            if fix and fix['fix']:
                print(f"Lat {fix['lat']:.4f} {fix['lat_dir']}, "
                      f"Lon {fix['lon']:.4f} {fix['lon_dir']}, "
                      f"{fix['sats']} sats")
    time.sleep(0.05)

The buffer accumulates bytes until a newline arrives, then the sentence is parsed. NMEA sentences end with \r\n. The checksum (star *47 at the end) is optional to verify.

Hardware flow control (RTS/CTS)

Most UART links do not need flow control. The receiver is fast enough, the buffer is big enough, and data does not back up.

For high-speed or high-volume links, you can add RTS (Request To Send) and CTS (Clear To Send) pins. The receiver drops RTS when its buffer is full, the sender sees CTS go high and stops sending. This prevents data loss.

In practice, the only UART devices I have used that need hardware flow control are: Bluetooth modules at high baud rates, some industrial sensors, and ESP8266 AT firmware when streaming. For a GPS, RFID, Nextion, or Pico-to-Pico link, software flow control (XON/XOFF) or no flow control works.

To enable flow control on the Pico:

uart = UART(0, baudrate=115200,
            tx=Pin(0), rx=Pin(1),
            rts=Pin(2), cts=Pin(3))

You also need to wire the RTS/CTS pins between the two devices.

The UART1 / UART0 distinction on Pico (GPIO mapping)

The Pico has two UART peripherals. UART0 is on GP0/GP1 by default. UART1 is on GP4/GP5. You can move them to other pins with the constructor:

# UART1 on GP8 (TX) and GP9 (RX)
uart = UART(1, baudrate=115200, tx=Pin(8), rx=Pin(9))

This is useful when you want to use the REPL on UART0 and a sensor on UART1. The REPL uses UART0 by default, and you do not want to share that with a GPS spamming sentences every second.

You can have both UART0 and UART1 running at the same time:

import sys
uart0 = UART(0, baudrate=115200, tx=Pin(0), rx=Pin(1))
uart1 = UART(1, baudrate=9600, tx=Pin(8), rx=Pin(9))

The REPL keeps using UART0 (USB serial), and you can use the physical pins for your own purposes. Or you can move UART0 to different pins if you need GP0/GP1 for something else (e.g. I2C).

When to use UART vs I2C vs SPI

The three serial protocols for inter-chip communication, and when to pick each:

  • UART: 2 wires (TX, RX), one device per UART peripheral, no addressing, simple. Use for: GPS, RFID, Bluetooth modules, Nextion displays, anything that streams data.
  • I2C: 2 wires (SDA, SCL), many devices on the same bus with addresses, 400 kHz fast mode, 3.4 MHz on Pico. Use for: sensors (BME280, MPU6050, OLED), GPIO expanders, anything with a register map.
  • SPI: 4+ wires (MOSI, MISO, SCK, CS), one device per CS pin, very fast (tens of MHz). Use for: SD cards, displays with high refresh, sensors that need fast reads (e.g. thermal cameras).

The decision tree: if the device streams a lot of data and you need speed, SPI. If the device has many registers and a few control lines, I2C. If the device is a module that speaks a simple protocol (GPS, RFID, Bluetooth), UART.

When something breaks

  • Garbage characters on the serial monitor. Baud rate mismatch. The two devices are speaking at different speeds. Check the datasheet, set both to the same rate.
  • No data at all. TX/RX are swapped. Swap them.
  • Intermittent garbage. Bad ground. Make sure both devices share a ground wire, especially if they have separate power supplies.
  • Pico crashes when GPS is plugged in. Voltage mismatch. The NEO-6M is 3.3V, but if you accidentally power a 5V device from the Pico's 3.3V, or vice versa, the chip can latch up. Add a level shifter if needed.
  • uart.any() always returns 0. The device is not transmitting, or you are on the wrong UART. Try the other UART. Check that the device is actually powered (its LED should be on, or its output pin should be high).

What to build next

  • A GPS logger that writes coordinates to an SD card every second.
  • A Nextion display that shows the value of a sensor (covered in the display tutorial).
  • A Pico-to-Pico link that sends a message every second (the basic serial chat pattern).
  • A Bluetooth bridge: pair an HC-05 module to a phone, forward UART data over Bluetooth.

The Nextion display tutorial is the natural next project. The Pico-to-Pico chat is a quick 10-minute build you can use as a test bed for the rest. The book Pico MicroPython has a chapter on building a sensor hub that reads a dozen sensors over UART and I2C, then streams the combined data over a single UART link to a Raspberry Pi.


Chapter 12

Pico W: async web server with uasyncio, the modern pattern

pico · 40 min

The synchronous web server tutorial gets you 80% of the way. You bind a socket, accept a connection, send a response, close. It works for one request at a time. The moment you have a second request, the first one stalls, and the moment you want to read a sensor while you are serving, the sensor read blocks the response.

This tutorial is the upgrade: uasyncio, async def handlers, background tasks, and the gc.collect() pattern that keeps the Pico W from running out of RAM.

What you need

  • Raspberry Pi Pico W
  • MicroPython firmware 1.20+ (the uasyncio module is standard)
  • A sensor (the BME280 is a good all-in-one) if you want to do the background-task example

What uasyncio is

uasyncio is MicroPython's version of asyncio, the Python standard library's cooperative multitasking library. Instead of threads, you have coroutines. Instead of locks, you have await. A coroutine runs until it hits an await, then yields control, then resumes when the awaited thing is done.

This is the right model for microcontrollers. You do not have the memory for threads. You have one CPU. Cooperative multitasking on a single CPU is enough.

Sync vs async, the difference that matters

Synchronous server (the old pattern):

import socket

addr = socket.getaddrinfo("0.0.0.0", 80)[0][-1]
s = socket.socket()
s.bind(addr)
s.listen(5)

while True:
    cl, addr = s.accept()
    req = cl.recv(1024)            # blocks here
    cl.send(response)              # blocks here
    cl.close()

The recv and send block. If the client is slow, the server is slow. You cannot read a sensor while you are serving. The server is busy waiting on a network call.

Async server (the new pattern):

import asyncio
import uasyncio as asyncio

async def handle(reader, writer):
    req = await reader.read(1024)   # does not block the event loop
    writer.write(response)
    await writer.drain()
    writer.close()

async def main():
    server = await asyncio.start_server(handle, "0.0.0.0", 80)
    await server.serve_forever()

asyncio.run(main())

While the await reader.read() is in flight, the event loop is free to run other coroutines. A second client can connect. A background sensor task can publish.

The request handler structure

Every async request handler is a function that takes a reader and a writer. You read the request, write the response, close the writer.

async def handle(reader, writer):
    try:
        request_line = await reader.readline()
        # The request line is "GET /path HTTP/1.1\r\n"
        # Parse it
        method, path, _ = request_line.decode().split(" ", 2)

        # Read headers until empty line
        while True:
            line = await reader.readline()
            if line == b"\r\n":
                break

        # Build a response
        if path == "/":
            body = "Hello from the Pico W"
        elif path == "/temp":
            body = "23.5 C"
        else:
            body = "Not found"

        response = (
            "HTTP/1.1 200 OK\r\n"
            "Content-Type: text/plain\r\n"
            f"Content-Length: {len(body)}\r\n"
            "Connection: close\r\n"
            "\r\n"
            + body
        )
        writer.write(response.encode())
        await writer.drain()
    finally:
        writer.close()
        await writer.wait_closed()

The try / finally makes sure the connection closes even if the request raises an exception. A slow client that disconnects mid-request will not leak the writer.

Handling multiple connections

asyncio.start_server accepts connections in a loop. For each new connection, the library creates a task running your handle function. Multiple clients can be connected at the same time. The total number is limited by the Pico W's RAM (about 20 KB free after MicroPython starts). Plan for 3-4 concurrent connections.

The gc.collect() gotcha

MicroPython's garbage collector does not run as often as CPython's. On a Pico W, after a few hundred requests, you can see MemoryError even though the heap has plenty of free space. The memory is fragmented.

The fix: call gc.collect() periodically, especially between requests in a long-running server.

import gc

async def handle(reader, writer):
    try:
        # ... request handling ...
        pass
    finally:
        writer.close()
        await writer.wait_closed()
        gc.collect()      # run after every request

I put gc.collect() in the finally of every handler. It is not free (it pauses the server for a few milliseconds), but the alternative is a crash at request 500.

Running sensors in background tasks

The pattern I use in every Pico W web project: spawn a background task with asyncio.create_task(), and have it update a shared state object that the request handlers read.

import uasyncio as asyncio
import machine
import gc

# Shared state, updated by the sensor task
state = {"temp": 0.0, "humidity": 0.0, "updated": False}

async def sensor_task():
    while True:
        # Read your sensor here
        # For demo, simulate
        await asyncio.sleep(2)
        state["temp"] = 23.5
        state["humidity"] = 50.0
        state["updated"] = True
        gc.collect()

async def handle(reader, writer):
    try:
        request_line = await reader.readline()
        # ... read rest of headers ...
        if b"GET /temp" in request_line:
            body = f'{state["temp"]} C'
        else:
            body = "OK"

        response = (
            "HTTP/1.1 200 OK\r\n"
            "Content-Type: text/plain\r\n"
            f"Content-Length: {len(body)}\r\n"
            "Connection: close\r\n"
            "\r\n"
            + body
        )
        writer.write(response.encode())
        await writer.drain()
    finally:
        writer.close()
        await writer.wait_closed()
        gc.collect()

async def main():
    asyncio.create_task(sensor_task())
    server = await asyncio.start_server(handle, "0.0.0.0", 80)
    await server.serve_forever()

asyncio.run(main())

The sensor runs in the background, updates state, and the request handler reads from state. The two never block each other.

server.serve_forever() vs server.start()

serve_forever() is a coroutine you await to block forever. The event loop runs in the same task as the server. Use this when you have nothing else to do at the top level.

start() schedules the server to run in the background and returns immediately. Use this when you have other work to do at the top level (e.g. a status LED blink):

async def main():
    server = await asyncio.start_server(handle, "0.0.0.0", 80)
    asyncio.create_task(server.serve_forever())
    asyncio.create_task(blink_task())   # your other task
    await asyncio.sleep_forever()

asyncio.sleep_forever() is a coroutine that never returns. It exists so the event loop has something to do at the top level.

Comparing to ESP32 AsyncWebServer

The ESP32 has a similar pattern but with two key differences:

  • The ESP32 has a lot more RAM (300+ KB free after Wi-Fi connects), so the gc.collect() gotcha is less common.
  • The ESP32 Arduino core has an AsyncWebServer library that uses callbacks instead of coroutines. It is more performant but harder to read.

The Pico W aysncio pattern is easier to write and reason about. The ESP32 pattern scales better. For "Pico W serving a few clients," go with MicroPython.

When blocking is fine

A blocking server is the right choice when:

  • The server handles one request at a time (e.g. a configuration endpoint you hit once a day).
  • The server is on a private network with trusted clients.
  • You need it working in 20 minutes, not 40.

The async pattern is the right choice when:

  • Multiple clients can connect at once.
  • The handler has to do work that might block (sensor reads, file I/O).
  • You are building a real API, not a config page.

For a "set the Wi-Fi SSID" endpoint, sync is fine. For a "stream sensor data" endpoint, async is the right tool.

What you learned

  • uasyncio is MicroPython's cooperative multitasking library. One CPU, no threads, coroutines all the way down.
  • The async server can handle multiple connections at once without one blocking another.
  • gc.collect() between requests is the price you pay for the Pico W's small heap.
  • Background tasks update shared state, request handlers read from it.

When something breaks

MemoryError after a few hundred requests. Garbage collection. Add gc.collect() in the handler's finally block.

The server stops responding. Watchdog timer. Add a soft reset after N requests, or move the watchdog feeding into a background task.

The sensor reads always return 0. The sensor task never ran. Check that asyncio.create_task(sensor_task()) is in main(), before the await on the server.

Two requests at the same time corrupt the state. Add a lock: asyncio.Lock() around the read-and-write of the shared state object.

OSError: [Errno 12] ENOMEM. The heap is exhausted. Reduce the response size, or reduce the number of concurrent connections, or restart the Pico W.

What to build next

  • A weather station: BME280 in a background task, a /weather endpoint serving JSON.
  • A motor controller: a /move?speed=50 endpoint, background task reading encoders.
  • A real-time dashboard: WebSocket endpoint (covered in a separate tutorial on this site).

The weather station is the natural first project. The dashboard is the "this is actually useful" project.


Chapter 13

Pico W: Bluetooth Low Energy peripheral with MicroPython and aioble

pico · 45 min

I needed a phone to talk to a Pico without setting up Wi-Fi. The use case was a one-button remote in a workshop where there was no router. BLE was the right tool, even though BLE is more annoying than Wi-Fi for almost every other project.

This tutorial covers the whole pattern: advertising, a GATT service with read/write/notify characteristics, the connection handler, and the iPhone gotcha that I lost two hours to.

What you need

  • Raspberry Pi Pico W (the W, not the plain Pico, because only the W has Bluetooth)
  • MicroPython firmware 1.20 or newer (the aioble module is built in on recent builds)
  • A phone (iOS or Android) with a BLE scanner app (I use nRF Connect)

What BLE actually is

Bluetooth Low Energy is a separate protocol from "Bluetooth Classic" (the one that streams audio to your headphones). They share a brand name and almost nothing else. BLE is short-burst, low-power, and optimized for "send a small message every now and then." Bluetooth Classic is a continuous audio stream.

A BLE link has two sides:

  • The peripheral advertises its presence. It is the smaller, lower-power device. Your Pico is the peripheral.
  • The central scans for advertisements and connects. Your phone is the central.

Once connected, the peripheral exposes services and characteristics (the GATT table). The central reads, writes, and subscribes to them. That is the whole protocol. Everything you do in BLE is one of those three operations.

The aioble library

aioble is the asynchronous BLE library that ships with recent MicroPython firmware for the Pico W. It is built on uasyncio so you write the code with async/await.

import aioble
import asyncio

The library handles the radio, the GATT table, and the connection state machine. You write the GATT schema and the handler functions. The library takes care of the timing-sensitive bit-banging on the radio.

If import aioble gives you a ModuleNotFoundError, you are on an old firmware. Flash a fresh one from https://micropython.org/download/RPI_PICO_W/.

Advertising as a peripheral

import aioble
import bluetooth

# The UUID is just a 128-bit identifier. Generate your own for real projects.
_SERVICE_UUID = bluetooth.UUID("12345678-1234-5678-1234-56789abcdef0")

async def advertise():
    # Connectable, undirected, general-discoverable
    async with aioble.advertise(
        500_000,                       # interval in microseconds
        name="pico-ble-demo",
        services=[_SERVICE_UUID],
    ) as connection:
        print("Connected:", connection.device)
        await connection.disconnected()
        print("Disconnected")

The 500,000 microseconds is the advertising interval. Lower = more discoverable but more power draw. The phone will find you within a few seconds at this rate.

The GATT service pattern

A GATT service is a collection of characteristics. Each characteristic has a UUID, a value, and a set of allowed operations (read, write, notify).

import aioble
import bluetooth

_SERVICE_UUID = bluetooth.UUID("12345678-1234-5678-1234-56789abcdef0")
_TEMP_CHAR_UUID = bluetooth.UUID("12345678-1234-5678-1234-56789abcdef1")
_LED_CHAR_UUID  = bluetooth.UUID("12345678-1234-5678-1234-56789abcdef2")

# Build the service descriptor at import time
temp_char = aioble.Characteristic(
    _TEMP_CHAR_UUID,
    read=True,
    notify=True,
)
led_char = aioble.Characteristic(
    _LED_CHAR_UUID,
    read=True,
    write=True,
)
service = aioble.Service(_SERVICE_UUID)
service.add_characteristic(temp_char)
service.add_characteristic(led_char)
# Register the service with the stack
aioble.register_services(service)

The service has to be registered before you start advertising. The library will give you an error otherwise.

Read, write, and notify

The three operations on a characteristic are:

  • Read: the central asks "what is the current value?" and gets bytes back. The peripheral's read handler returns the value.
  • Write: the central pushes bytes to the peripheral. The peripheral's write handler is called with the bytes.
  • Notify: the peripheral pushes bytes to the central when the value changes. The central "subscribes" to notifications first.
from machine import Pin

led = Pin(15, Pin.OUT)

async def temp_read():
    # Pretend we read a temperature
    return b"23.5\n"

async def temp_notify_task():
    while True:
        # Push a new value to subscribed centrals
        temp_char.notify(b"23.5\n")
        await asyncio.sleep(1)

async def led_write(value):
    # value is bytes
    if value == b"on\n":
        led.value(1)
    elif value == b"off\n":
        led.value(0)

Notify does not require the central to read; the peripheral pushes the value on its own schedule. It is the closest thing BLE has to a server push.

The connection event handler

The pattern I use in every Pico W BLE project:

import asyncio
import aioble

async def main():
    # Kick off the sensor-publishing task
    asyncio.create_task(temp_notify_task())

    # Run the advertisement loop
    while True:
        async with aioble.advertise(
            500_000,
            name="pico-ble-demo",
            services=[_SERVICE_UUID],
        ) as connection:
            print("Connected")
            # You can do per-connection setup here
            await connection.disconnected()
            print("Disconnected")

asyncio.run(main())

When a phone connects, the async with block runs. When the phone disconnects, disconnected() completes and the while True loop re-advertises. The sensor task runs in the background the whole time.

The MTU limit

BLE has a maximum packet size called the MTU (Maximum Transmission Unit). The default is 23 bytes. After the connection is established, the central can request a larger MTU (up to 517 bytes in BLE 5, often 244 in practice). Until that negotiation happens, do not try to send more than 20 bytes per characteristic read/write.

async def main():
    async with aioble.advertise(...) as connection:
        # Wait for the MTU to be negotiated
        await connection.exchange_mtu()
        # Now you can send up to the negotiated MTU

If you send too much, the phone just gets a smaller truncated value or a connection error. The fix is either to negotiate a larger MTU or to chunk your data into 20-byte pieces.

The iPhone pairing gotcha

iOS will not show a Pico W in the Bluetooth settings unless the advertising name is one it recognizes. The name has to be:

  • 8 characters or fewer, OR
  • The exact prefix "BLE" or "Blue" or a few other recognized ones, OR
  • A name the user has already paired with before.

If you name your peripheral "pico-ble-demo" (15 characters, no recognized prefix), iOS will see the advertisement, fail to parse the name properly, and silently ignore the device. Android works fine. nRF Connect on iOS sees it. The Settings app does not.

The fix: use a short name. "PicoLED" works. "Workshop" works. "pico-ble-demo" does not.

I lost about two hours to this one.

ESP32 NimBLE vs Pico W aioble

The ESP32 has a more mature BLE stack (NimBLE, also known as the "Arduino BLE" library). The Pico W aioble stack is newer and has fewer features.

Feature ESP32 NimBLE Pico W aioble
BLE 5 long range Yes No (BLE 5.0 only)
Custom services Yes Yes
Multiple centrals Yes One at a time
BLE central role Yes Limited
Mature / documented Yes No

For "phone connects to my microcontroller," both work. For more sophisticated BLE topologies, the ESP32 is the more capable board.

When to use BLE vs Wi-Fi vs MQTT

  • BLE: no Wi-Fi infrastructure needed, range is short (10 m), one central at a time, low power. Right for: phone-as-remote, beacon applications, sensor data to a phone.
  • Wi-Fi: needs a router, range is good (50 m+), many clients, more power. Right for: home automation, dashboards, anything that talks to the internet.
  • MQTT: a protocol that runs over Wi-Fi (or any IP network). Right for: many devices publishing to a broker, persistent message queues.

BLE is the right choice when the phone is the only client and there is no network.

What you learned

  • BLE has peripheral (advertiser) and central (scanner) roles. The Pico W is the peripheral.
  • aioble is the async BLE library that ships with recent MicroPython for the Pico W.
  • GATT services expose characteristics with read, write, and notify operations.
  • iOS silently ignores peripherals with long, unrecognized names.

When something breaks

The phone does not see the Pico. Check the advertising name length (iOS gotcha). Try a different scanner app. Make sure the firmware is recent enough to have aioble.

The connection drops after a few seconds. The Pico W is overheating, or the radio is being reset. Power-cycle and try again. If it persists, re-flash the firmware.

ValueError: unknown characteristic. You forgot to register the service before advertising. The library raises this when the GATT table is empty.

Notifications never arrive. The central never subscribed. nRF Connect has a "subscribe" button in the characteristic view; you have to click it.

RuntimeError: out of memory. BLE on the Pico W is tight. Cut the number of services, or remove any background tasks you do not need.

What to build next

  • A BLE remote: one button on the Pico, one notification to the phone.
  • A BLE temperature beacon: Pico reads a sensor, broadcasts the value every second.
  • A bridge: Pico receives sensor data over BLE and republishes over MQTT over Wi-Fi.

The remote is the simplest end-to-end test. The bridge is the project I shipped for the workshop.


Chapter 14

Pico 2: getting started with the RP2350 and MicroPython

pico · 20 min

The Pico 2 is the new Raspberry Pi microcontroller, and it landed in 2024 with the usual promise: same price, same form factor, way more headroom. The chip inside (the RP2350) is what changed. Dual-core, a Cortex-M33, a RISC-V option, 520 KB of SRAM, and a hardware video output the original Pico never had.

If you already set up the original Pico (the RP2040), most of this is the same 20 minutes. The part that is different is the chip and the firmware file you flash. Get that wrong and the Pico 2 will sit there doing nothing, which is the trap I want to save you.

This tutorial gets MicroPython installed on the Pico 2 and runs a blink sketch in about 20 minutes. I will cover what is new on the RP2350, the dual-core decision (you do not have to make it for this tutorial), the firmware filename you need, and the one Thonny setting that catches people.

What is new on the RP2350

The original Pico ran a pair of Cortex-M0+ cores at 133 MHz. The Pico 2 swaps that for two Cortex-M33 cores at 150 MHz, and the M33 cores can also run as RISC-V Hazard3 cores if you ask for it at boot (e.g. by setting a flag in the image header). For almost everyone, the Cortex-M33 is the default and the thing you want. RISC-V on the same chip is a "nice to have" for people who care about ISA diversity, not a reason to buy a Pico 2.

The other big jumps:

  • 520 KB of SRAM (vs 264 KB on the RP2040). You stop hitting the RAM ceiling on anything that uses a framebuffer or a TLS stack.
  • HSTX, which is a high-speed serial transmitter. The Pico 2 can drive a DVI display at 640x480p60. The original Pico could not do video at all without bitbanging. I have a separate tutorial on this.
  • TrustZone, which is ARM's hardware isolation feature. The chip can run a secure world and a non-secure world at the same time, with the secure world holding the boot keys. This matters for production firmware and not at all for blinking an LED.

The pinout is identical to the original Pico. Same labels, same GPIO numbers, same I2C, same SPI, same UART. If you wired a sensor to a Pico, you can move the wires one pin over to a Pico 2 and the code does not change.

The dual-core decision

The RP2350 has two cores, and the original Pico had two cores. You have been able to use both cores on the Pico since 2021. The Pico 2 just gives you stronger cores to run on them.

For this tutorial we ignore the second core. machine.Pin and the REPL all run on core 0 by default. When you need both cores (e.g. one core running a DVI display, one core running your game logic), you reach for the _thread module. The dual-core model on the Pico 2 is the same as on the Pico. Do not overthink this until you need it.

What you need

  • A Raspberry Pi Pico 2 (or Pico 2 W if you want Wi-Fi)
  • A USB cable (USB-C, not micro-USB; the Pico 2 dropped micro-USB)
  • A computer (Windows, macOS, or Linux)
  • Thonny 4.x or newer (older Thonny versions do not know about the Pico 2)

Step 1: download the right MicroPython firmware

Go to https://micropython.org/download/RPI_PICO2/ (or RPI_PICO2_W if you have the wireless version). Download the latest .uf2 file.

The trap here is the filename. The Pico used RPI_PICO or RPI_PICO_W. The Pico 2 uses RPI_PICO2 or RPI_PICO2_W. The number is on purpose. If you flash a RPI_PICO firmware to a Pico 2, the Pico 2 will not boot, because the firmware is built for the M0+ cores and the Pico 2 has M33 cores. The reverse also fails: RPI_PICO2 firmware on an original Pico does nothing.

Step 2: flash the firmware

  1. Hold down the BOOTSEL button on the Pico 2.
  2. While holding the button, plug in the USB cable.
  3. Release the button.

The Pico 2 appears as a USB drive called RPI-RP2. Same name as the original Pico, because the boot ROM is the same protocol.

  1. Drag the .uf2 file to that drive.

The Pico 2 will reboot and the drive will disappear. The Pico 2 is now running MicroPython.

Step 3: install Thonny

Thonny is the IDE I use for Pico + MicroPython. Download from https://thonny.org/. Make sure you have Thonny 4.x or newer. The earlier 3.x line did not recognize the Pico 2 as a target and would silently connect to nothing.

Open Thonny. Configure the interpreter:

  • Tools >> Options >> Interpreter
  • Select MicroPython (Raspberry Pi Pico)

You should see a >>> prompt in the Shell pane at the bottom. This is the MicroPython REPL, running on the Pico 2.

If Thonny does not show the REPL, you either have the wrong firmware (the Pico 2 firmware filename issue above) or an old Thonny. Update Thonny first; the firmware is easier to fix than the IDE.

Step 4: blink the onboard LED

Type this at the >>> prompt:

from machine import Pin
import time

led = Pin("LED", Pin.OUT)

while True:
    led.toggle()
    time.sleep(0.5)

The onboard LED on the Pico 2 should blink at 1 Hz. Press Ctrl+C in the Shell to stop the script.

The "LED" string is the same on the Pico 2 as on the original Pico. The MicroPython port for the RP2350 maps the onboard LED to the same string name. Most of the machine module looks identical to the RP2040 port. A few module names differ (HSTX, TrustZone, the second core), and those are the ones we cover in the other Pico 2 tutorials.

What you learned

The Pico 2 is a drop-in upgrade for the original Pico. Same pinout, same MicroPython REPL, same Thonny workflow, same LED name. The differences are:

  • The chip is faster (150 MHz Cortex-M33 vs 133 MHz Cortex-M0+)
  • The chip has more RAM (520 KB vs 264 KB)
  • The firmware filename ends in RPI_PICO2 not RPI_PICO
  • The board uses USB-C, not micro-USB

For 95% of Pico projects, you move the wires, reflash, and keep going. The new RP2350 features (HSTX, TrustZone, dual-core, RISC-V) are available when you need them, and invisible when you do not.

When something breaks

  • The Pico 2 does not appear as RPI-RP2. Your USB cable is probably a power-only cable. Try a different cable.
  • The drive appears but the flash does nothing. You dragged the wrong firmware file. Make sure the filename ends in RPI_PICO2 or RPI_PICO2_W.
  • Thonny connects but the REPL is empty. You are on Thonny 3.x. Update to 4.x or newer.
  • The LED does not blink but there is no error. Make sure you typed the Pin("LED", Pin.OUT) line and pressed Enter. Pin.OUT is uppercase.
  • You flashed the firmware but the Pico 2 still shows up as a drive. The flash failed. Hold BOOTSEL, unplug, replug, and try again.

What to build next

  • The Pico 2 HSTX tutorial: drive a DVI display at 640x480p60 from the same board you just blinked an LED on. The HSTX peripheral is the headline feature of the RP2350.
  • The Pico 2 PIO improvements tutorial: same PIO language, new debugging features and larger FIFOs. If you have written PIO before, most of your code just works.
  • The Pico 2 TrustZone tutorial: signed boot, secure vs non-secure worlds. Only relevant if you are shipping a product and need the boot key story.
  • The Pico MicroPython SDK book (companion) bundles these four tutorials with the original Pico content in one place.

(these are sample tutorials written for Brian to review on his return. They will not be promoted to "ready" status without his approval.)


Chapter 15

Pico 2: drive a DVI display over HSTX, the new thing on RP2350

pico · 60 min

The Pico 2 has a peripheral the original Pico did not: HSTX, the high-speed serial transmitter. It is four data lanes that can each run up to 250 MHz, and the protocol on top is whatever you want it to be. In practice, everyone uses it for DVI, which is a fancy way of saying "HDMI without the audio or the HDCP." You can drive a 640x480p60 monitor from a $5 microcontroller with no extra chips.

I built this last weekend on a spare DVI plug I had from an old HDMI-to-DVI adapter. The picture came up on the first try, which is rare for me. The hard part was not the wiring. The hard part was understanding that one core is now permanently busy pushing pixels and the other core runs your code, and that is just the deal.

This tutorial walks through wiring, flashing the HSTX-DVI library, and running a test pattern on a 640x480p60 monitor.

What HSTX actually is

HSTX is a peripheral on the RP2350 that did not exist on the RP2040. It is four differential data lanes (eight GPIO pads if you count both polarities) plus a clock lane, and it can shift out bits at up to 250 MHz per lane. That is enough bandwidth for DVI 640x480p60, which runs at 25 MHz pixel clock with 1 bit per channel per pixel.

The peripheral is on dedicated pads, not on regular GPIO. On the Pico 2 the HSTX pads are GPIO 12 through GPIO 19 (eight pads total). You cannot move HSTX to other pins; the routing is fixed in silicon.

The supported mode that everyone uses is DVI 640x480p60. You can do higher resolutions in theory (the bandwidth is there), but the Adafruit HSTX-DVI library only ships the 640x480p60 timing and a few lower ones. Trying to push 1080p through HSTX on a Pico 2 will not work without writing your own TMDS encoder and your own timing generator, which is a project, not a tutorial.

The "limited resolution" gotcha

This is the part I want to be honest about up front. The Pico 2 with HSTX can drive a 640x480 monitor. It cannot drive a 1920x1080 monitor at 60 Hz. If you plug it into a modern 1080p or 4K display, the display will pick the closest mode it knows about and scale 640x480 up, which looks soft. That is fine for a test pattern or a status display. It is not fine if you wanted 1080p.

If you need 1080p output from a microcontroller, you are looking at a different chip family (e.g. ESP32-S3 with the LCD peripheral and an external HDMI bridge). The Pico 2 is not the answer there.

What you need

  • A Raspberry Pi Pico 2 (the Pico W and original Pico do not have HSTX)
  • A DVI or HDMI monitor (an old HDMI monitor with a DVI adapter works; a pure DVI monitor works even better because you skip the HDMI handshake)
  • A way to get DVI signals out of the Pico 2. The Adafruit HSTX-to-DVI FeatherWing is the easiest path. It plugs into the Pico 2 with header pins and gives you an HDMI connector.
  • USB-C cable
  • Thonny 4.x or newer
  • A 5V power source. HSTX displays draw more power than a Pico 2 normally pulls, and you want to feed the Pico 2 from a powered USB hub or a beefier supply. The Adafruit FeatherWing has a 5V pin you can use.

Wiring

If you are using the Adafruit HSTX-to-DVI FeatherWing, the wiring is: plug the FeatherWing onto the Pico 2. The eight HSTX pads (GPIO 12-19) align with the FeatherWing's header. There is no soldering if you have header pins on your Pico 2.

Pico 2 HSTX pin FeatherWing signal
GPIO 12 (HSTX0) DVI Data 0+
GPIO 13 (HSTX0) DVI Data 0-
GPIO 14 (HSTX1) DVI Data 1+
GPIO 15 (HSTX1) DVI Data 1-
GPIO 16 (HSTX2) DVI Data 2+
GPIO 17 (HSTX2) DVI Data 2-
GPIO 18 (HSTX3) DVI Clock+
GPIO 19 (HSTX3) DVI Clock-
5V (VBUS) DVI 5V (pin 18 on HDMI)
GND DVI Ground

HDMI carries 5V on pin 18 from the source. The Pico 2 is the source here. The display expects to find 5V on pin 18 to know a source is connected. If you do not wire 5V, some displays will refuse to sync.

If you are wiring to a raw HDMI connector instead of using the FeatherWing, the same mapping applies but you need to twist the +/- pairs and add the 5V and ground. There are DVI connector breakout boards that make this easier; the FeatherWing is the cheapest path.

Install

The HSTX-DVI library lives in the Adafruit MicroPython bundle. In Thonny:

  • Tools >> Manage Packages
  • Search for adafruit_hstx_dvi
  • Click Install

If adafruit_hstx_dvi is not in the Thonny package index, you can grab the .mpy files from the Adafruit MicroPython bundle release on GitHub and copy them to the Pico 2 filesystem manually (e.g. via the Thonny Files view, drag and drop to the Pico 2).

The code

# DVI test pattern on a Pico 2 over HSTX
# MicroPython only. Tested with Adafruit HSTX-DVI bundle.

import _thread
import time
from machine import Pin
import HSTX_DVI as dvi

# 640x480p60 is the supported mode.
WIDTH = 640
HEIGHT = 480

# Create the DVI output. The library handles the TMDS encoding and
# the HSTX peripheral setup.
display = dvi.Display(WIDTH, HEIGHT)

# Allocate a framebuffer. The library hands you a 1-bit-per-pixel
# buffer (each byte holds 8 pixels).
framebuffer = display.framebuffer()

# Pin a status LED so we know the script is alive.
led = Pin("LED", Pin.OUT)


def push_pixels():
    """Run on core 1. Just keep the display fed."""
    while True:
        display.show()


def draw_pattern():
    """Run on core 0. Draw a moving test pattern."""
    x = 0
    direction = 1
    while True:
        # Clear the framebuffer.
        for i in range(len(framebuffer)):
            framebuffer[i] = 0

        # Draw a moving vertical bar (8 pixels wide).
        for col in range(x, x + 8):
            for row in range(0, HEIGHT, 2):
                byte_index = (row * WIDTH + col) // 8
                bit_index = 7 - (col % 8)
                framebuffer[byte_index] |= (1 << bit_index)

        x += direction
        if x <= 0 or x >= WIDTH - 8:
            direction = -direction

        led.toggle()
        time.sleep_ms(20)


# Start the display feeder on the second core.
_thread.start_new_thread(push_pixels, ())

# Draw patterns on the main core.
draw_pattern()

The pattern shows up on the monitor as a white vertical bar bouncing left and right. The onboard LED blinks at about 25 Hz so you can see the script is alive.

The CPU overhead you should expect

The video output has to happen every frame, every 16.7 ms, no matter what. That is one core gone for as long as the display is on. If you are running this script and also trying to read sensors, do the sensor reads on the same core as the drawing (core 0). Do not put anything else on core 1. The display feeder on core 1 is not a suggestion; if it falls behind, the display tears or goes black.

The total CPU budget you have left on core 0 is about 30% of one core at 150 MHz, because the HSTX + TMDS encoding eats the rest. That is enough for a game, a UI, or a sensor logger. It is not enough for anything that needs the full M33 core, like heavy DSP.

What you learned

The HSTX peripheral on the RP2350 is the new headline feature. It turns the Pico 2 from a microcontroller into something that can also be a video source, which the original Pico could not do at all. The wiring is simple if you use a FeatherWing, the code is a framebuffer push from one core and your application from the other, and the supported mode is 640x480p60.

The gotcha is that 640x480 is the ceiling, not the floor. If you wanted 1080p, this is not the chip for you. If you wanted a status display, a simple game console, or a debugging readout on a spare monitor, HSTX on the Pico 2 is the cheapest path I have found.

When something breaks

  • The monitor says "no signal." Check the 5V wire. Most HDMI monitors refuse to sync without 5V on pin 18, even for a DVI signal.
  • The monitor lights up but the image is corrupt. You are missing one of the data pairs (GPIO 12 through 17). Re-seat the FeatherWing or check your wiring.
  • The image tears or stutters. Core 1 is starving. Check that nothing else is on core 1 (no extra _thread.start_new_thread calls, no interrupt handlers running there).
  • Thonny says ImportError: no module named 'HSTX_DVI'. The Adafruit HSTX-DVI .mpy files are not on the Pico 2 filesystem. Use the Thonny Files view to copy them across.
  • The Pico 2 reboots when the display comes up. Power supply is too weak. Use a powered USB hub or a 5V supply that can deliver at least 500 mA.

What to build next

  • A simple game (e.g. Pong, Snake). 640x480 is enough for both, and the Pico 2 has the headroom for input + drawing + display.
  • A status display. Show Wi-Fi signal strength, MQTT message rate, or a sensor readout on a wall monitor.
  • The HSTX + PIO pattern, where PIO drives HSTX directly for lower jitter. Covered in the Pico 2 PIO improvements tutorial.
  • The Pico MicroPython SDK book (companion) bundles this with the rest of the Pico 2 series.

(these are sample tutorials written for Brian to review on his return. They will not be promoted to "ready" status without his approval.)


Chapter 16

Pico: measure wind speed with an anemometer

pico · 35 min

The cup anemometer is the sensor that makes wind real: three cups on a rotor, spinning faster as the wind picks up. Inside the hub, a magnet sweeps past a reed switch once per rotation, and every pass closes a contact for a few milliseconds. Your job is to count closures and divide by time. No ADC, no protocol, no library. It is the cleanest interrupt lesson on any microcontroller, because the pulses arrive slowly enough to see and fast enough to matter.

The trap is polling. I read the reed switch in a while True loop with a sleep, and missed gusts all afternoon, because a single time.sleep(1) between reads will swallow two or three pulses at a decent breeze. A pulse arriving while you sleep does not queue up. It just never happened, as far as your program is concerned. The fix is a hardware interrupt on the GPIO pin, which counts edges even while the main loop is doing something else (e.g. writing to the SD card in the datalogger version at the end).

The second trap is contact bounce. A reed switch is a mechanical contact, and a mechanical contact does not close once. It closes, bounces open and shut for a millisecond or two, then settles. Without debouncing, one rotation counts as three. The MicroPython fix is a time check inside the interrupt handler: ignore any pulse that arrives within 10 ms of the last one. At 10 ms, a rotor spinning at 600 rpm (one pulse every 100 ms minimum, faster than any wind this switch survives) still counts every pulse honestly.

What you need

Needed

  • Raspberry Pi Pico (or Pico W), about $4-6.
  • Cup anemometer with reed-switch output. Two common picks:
    • The "SPG-30" style plastic cup anemometer with 2-core cable (about $15), which is what most DIY weather kits ship.
    • A Davis Instruments 6410 or a spare Vantage Pro anemometer (about $40), if you want the thing that survives actual storms.
  • 2-core outdoor cable or two jumper wires, if the sensor does not already have a lead.

Nice to have

  • Pole mount (a 1 inch PVC pipe section plus a hose clamp works).
  • Weatherproof junction box or cable gland, for a permanent install.
  • Soldering iron + solder, if the sensor's cable needs attaching to the terminal block.
  • Soldering iron stand, for parking the hot iron.
  • Helping hands, for holding the cable while tinning.
  • Anti-static wristband, for handling the bare Pico.
  • Magnifying goggles, for reading the tiny terminal labels.
  • Soldering mat, to keep resin off the bench.
  • Wire stripper, for the 2-core cable ends.
  • Multimeter, to watch the switch close by hand before coding.

Wiring

The reed switch is just a switch: two terminals, no polarity. One side to a GPIO pin, the other to ground, and the internal pull-up does the rest.

Anemometer wire Connect to
Switch terminal 1 Pico GP14 (physical pin 19)
Switch terminal 2 Pico GND (physical pin 18, any GND)

That is the entire circuit. No resistor, no external pull-up: the Pin.PULL_UP in the code holds the input high, and each switch closure pulls it to ground.

Keep the signal wire away from long runs next to motors or mains cable. A reed switch contact is a plain open circuit when idle; it can pick up noise on a 10-meter unshielded run. Twisted pair or a shielded cable fixes it if your pole is tall.

Install

No libraries. Everything is in the standard MicroPython firmware (machine.Pin, machine.Timer). If you have not flashed MicroPython yet, the Pico MicroPython setup tutorial covers it: hold BOOTSEL while plugging in USB, drag the UF2 file onto the drive that appears.

The code

The pulse counter runs in an interrupt; the main loop reads the count once per second and applies the conversion factor.

from machine import Pin, Timer
import time

# ---- config --------------------------------------------------------
ANEM_PIN = 14          # GP14, physical pin 19
# pulses per second -> wind speed. Every manufacturer publishes one.
#   1 pulse/sec = 1.492 mph is the classic Inspeed/Vantage constant.
#   Check your datasheet. Wrong constant = confidently wrong data.
PULSE_TO_MPH = 1.492
DEBOUNCE_MS = 10       # ignore pulses closer than this

# ---- state (touched by the IRQ handler only) -----------------------
pulse_count = 0
last_pulse_ticks = 0

def wind_isr(pin):
    # Runs with interrupts disabled; keep it tiny
    global pulse_count, last_pulse_ticks
    now = time.ticks_ms()
    if time.ticks_diff(now, last_pulse_ticks) < DEBOUNCE_MS:
        return            # bounce: not a real rotation
    last_pulse_ticks = now
    pulse_count += 1

# Reed switch to GND, pull-up holds it high, falling edge = a rotation
anem = Pin(ANEM_PIN, Pin.IN, Pin.PULL_UP)
anem.irq(trigger=Pin.IRQ_FALLING, handler=wind_isr)

# ---- main loop: report once per second ----------------------------
report = Timer()

def tick(timer):
    global pulse_count
    # Critical section: swap the count out so the ISR keeps counting
    state = machine.disable_irq()
    pulses = pulse_count
    pulse_count = 0
    machine.enable_irq()

    mph = pulses * PULSE_TO_MPH
    # 1 pulse per reporting window is the resolution floor.
    # Longer windows smooth the gusts (e.g. 6 s windows = 0.25 mph steps).
    print(f"{pulses} pulse(s) this window -> {mph:.1f} mph")

report.init(period=1000, mode=Timer.PERIODIC, callback=tick)

while True:
    time.sleep(1)

Three things carry the design:

  • The interrupt handler does one thing: bump a counter. No printing, no floating-point math, no sleep inside an IRQ. A handler that lingers freezes everything else (e.g. the Timer callback and any other IRQ on the chip).
  • disable_irq() around the read-and-reset makes the read-modify-write atomic. Without it, a pulse landing between pulses = pulse_count and pulse_count = 0 gets lost.
  • The conversion constant is per-model. The 1.492 mph constant is common but not universal; the datasheet number wins every time.

Converting pulses to speed

One pulse per rotation. Speed comes from the manufacturer's pulses-per- mph constant, because cup anemometers are calibrated instruments, not just switches:

  • Inspeed and most Davis clones: 1 pulse/s = 1.492 mph (2.4 km/h).
  • Some generic units: 1 pulse/s = 1.0 m/s; check the sheet.

Resolution is limited by your reporting window. At one reading per second, the smallest nonzero reading is 1.492 mph. Averaging over a longer window divides that floor (e.g. six seconds per window gives quarter- mph steps and steadier gust numbers).

Gusts versus averages: keep both. Track the max over a rolling minute for the gust value and the mean over the same minute for the sustained value. A weather station that only reports the mean makes every storm look boring.

The calibration check

You do not need a wind tunnel. You need a known speed and a known count:

  1. Hold the anemometer out of a car window at a steady 20 mph (passenger seat, cup height above the roofline, closed street).
  2. Watch the pulse counter for exactly 60 seconds.
  3. Expected: about 20 / 1.492 = 13.4 pulses per second, so roughly 800 pulses per minute. Within 10 percent is a healthy sensor.

If you get half of that, the sensor's constant differs from the assumed one; compute your own constant as known mph / measured pulses-per-second and put that in the sketch.

What you learned

  • Mechanical-contact sensors want interrupts, not polling. The poll-and-sleep loop silently drops pulses.
  • Debounce in the ISR with a time check: 10 ms absorbs reed bounce without hiding real rotations.
  • disable_irq() makes counter handoffs between ISR and main loop atomic; the race without it eats one pulse now and then.
  • Speed is pulses times a per-model constant, printed on the datasheet, verified once with a car or a fan.
  • Reporting window sets resolution: longer windows, smaller steps.

When something breaks

  • Counts stay at zero even in a gale. The switch terminals are on the wrong pins, or the sensor is a 5 V powered Hall-effect type needing its own supply. Spin the cups by hand with the sketch running; if the count never moves, probe both terminals with a multimeter in continuity mode and find the pair that clicks.
  • Wildly high counts in bursts. Debounce is off or too short. At 10 ms a rotor doing 10 pulses per second passes cleanly; if your ISR is firing hundreds of times per rotation, the debounce window is being bypassed (e.g. ticks wrap or another handler reset last_pulse_ticks).
  • Counts drift upward in rain. The reed switch and terminals are getting wet, and water bridges the contact. Seal the terminal block, drip-loop the cable, and point the cable exit downward.
  • No pulses below a light breeze. Real behavior: cup rotors have a starting threshold around 0.4 to 0.6 m/s and stick below it. Stiction, not a bug. Tap-test by hand to confirm the wiring still works.
  • Pico reboots when the sensor is connected. You wired the switch to a 3V3 pin instead of GND, shorting the rail through the closed contact. The signal side belongs on GP14 and GND, nothing on 3V3.

What to build next

  • The Pico GPIO tutorial covers the pull-up and edge-detection basics this build leans on, with buttons instead of cups.
  • The microSD datalogger tutorial turns this into a weather station: timestamp the pulses per minute and log to a card (e.g. one CSV line per minute: gusts, mean, and direction from a vane on GP15).
  • The Pico W MQTT publish tutorial streams the wind readings to your broker, next to the temperature from the sensor dashboard.
  • The asyncio tutorial runs the counter, the logger, and a web page as three tasks on one Pico.

Chapter 17

Pico: program the Pico with the Arduino core (when C++ is the right tool)

pico · 30 min

Most Pico tutorials (including the ones on this site) are MicroPython, and for good reason. But the Pico also runs the Arduino core, which means the Arduino IDE, C++, and the entire Arduino library ecosystem run on a $4 board. Some projects want that, and it is worth knowing which ones before you are halfway into one.

This tutorial installs Earle Philhower's arduino-pico core (the community core, not the older Mbed one), uploads a first sketch, and lays out the honest decision rule for MicroPython versus C++ on this chip.

The trap is treating this as an either/or decision made once. Both toolchains live on the same machine, flash the same board over the same USB cable, and you can switch between them any afternoon. The real trap inside the trap: uploading a MicroPython UF2 wipes the flash, and then the Arduino sketch appears to have "broken the Pico" when it was just replaced (e.g. people re-flash MicroPython, see the old script gone, and conclude the Arduino IDE deleted their work). One filesystem, one program at a time. Back up your scripts like you back up anything else.

What you need

Needed

  • Raspberry Pi Pico or Pico W (about $4-6).
  • A micro-USB cable that carries data (the charging-only cables in the junk drawer have wasted more hours than any software bug).
  • A computer running Windows, macOS, or Linux.

For the LED demo: nothing else. The Pico has a built-in LED you can blink.

Nice to have

  • Breadboard, jumpers, an LED, and a 220 ohm resistor, if you want to blink an external LED on GP15 instead of the onboard one.
  • USB hub with individual power switches, so you can cut power for BOOTSEL without unplugging.
  • Soldering iron + solder, if you bought a bare Pico with unsoldered headers.
  • Soldering iron stand, for parking the iron.
  • Helping hands, to hold the header strip square while the joints cool.
  • Anti-static wristband, for handling the bare board.
  • Magnifying goggles, for inspecting cold solder joints on GP0-GP15.
  • Soldering mat, to catch solder balls.
  • Wire stripper, if you are making a custom sensor lead.

Wiring

No wiring needed for the first sketch. The onboard LED is on GP25 (Pico) or the "LED" pin (Pico W). For the external-LED variant:

Component Connect to
LED anode (long leg) 220 ohm resistor -> Pico GP15 (physical pin 20)
LED cathode (short leg) Pico GND

Install

The core installs from inside the Arduino IDE, no downloads hunted on GitHub:

  1. Open the Arduino IDE.
  2. File >> Preferences >> Additional Board Manager URLs, paste: https://github.com/earlephilhower/arduino-pico/releases/package_rp2040_index.json
  3. OK, then Tools >> Board >> Boards Manager, search "pico", install "Raspberry Pi Pico/RP2040" (Earle Philhower's core).
  4. Tools >> Board >> Raspberry Pi RP2040 Boards >> select your exact board (e.g. "Raspberry Pi Pico" or "Pico W").
  5. Tools >> Port >> pick the Pico's serial port.

The first upload is the only awkward one. The IDE puts the board in bootloader mode automatically and asks where to save the sketch; after that, uploads happen over the serial port directly.

If the upload fails with "permission denied" or a silent port vanish, another program is holding the serial port. Close any MicroPython REPL (Thonny, mpremote) before uploading. One program at a time owns the port.

The code

Blink, the smoke test

// Works on Pico and Pico W: LED_BUILTIN maps to the right pin
void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);
  delay(500);
  digitalWrite(LED_BUILTIN, LOW);
  delay(500);
}

Upload (Sketch >> Upload, or the right-arrow button). The LED starts blinking. That is the whole hello-world, and it proves the toolchain, the core, and the USB link all work.

Something MicroPython cannot do well: precise pulses

The real reason to reach for C++ on the Pico is timing. This sketch produces a 1 microsecond pulse train with stable width, the kind of thing you would drive a camera flash trigger or an LED driver with:

const int OUT_PIN = 15;

void setup() {
  pinMode(OUT_PIN, OUTPUT);
  Serial.begin(115200);
}

void loop() {
  digitalWrite(OUT_PIN, HIGH);
  delayMicroseconds(1);      // one microsecond, reliably
  digitalWrite(OUT_PIN, LOW);
  delay(99);                 // 100 Hz total rate
  Serial.print("heap: ");
  Serial.println(RP2040.firmwareVersion());   // just proving the core API exists
}

MicroPython sleeps in milliseconds; sub-microsecond jitter is outside its deal. If a project needs that kind of timing, C++ is the right tool, and on the extreme end PIO (the Pico's programmable I/O blocks) handles it without the CPU at all.

The library ecosystem argument

The second reason: Arduino libraries. Every sensor with a begin()/read() API has one (e.g. Adafruit's BME280, the TFT display stacks, CAN bus, all of it). The MicroPython versions are often single-file ports of varying quality. When a project leans on several mature libraries, the C++ side saves real time.

When MicroPython is still the right answer

The honest decision table, from a few years of building both ways:

Situation Pick
Scripting, tweaking, REPL-driven experiments MicroPython
Sub-microsecond timing, heavy interrupts Arduino (C++)
Lean on mature Arduino libraries Arduino (C++)
Rapid iteration on a hobby project MicroPython
Very small flash/RAM budget, tight control Arduino (C++)
Web servers and MQTT on a Pico W Either works; MicroPython code is shorter

The two share more than people expect: the GPIO, PWM, UART, and I2C concepts are identical, so moving between them costs an evening, not a semester.

Flash layout and the back-and-forth

One more mental model and you are done: the Pico's flash holds one program. Flashing a UF2 (MicroPython, CircuitPython, or a UF2 built from the Arduino IDE) replaces whatever was there. That is why the first Arduino upload after MicroPython feels like an event; it is the same mechanism both toolchains use.

If you settle on C++ but keep a rescue REPL, flash MicroPython back by holding BOOTSEL and dropping the UF2 again. Nothing about the board ages or wears out in this cycle; it is all just files in flash.

What you learned

  • The arduino-pico core installs from the Arduino IDE Board Manager with one URL in Preferences.
  • The same board, cable, and BOOTSEL mechanism serve both toolchains; flash replacement is the expected behavior, not data loss.
  • C++ earns its keep on precise timing (delayMicroseconds-class work) and mature Arduino libraries.
  • MicroPython earns its keep on iteration speed and readable scripts.
  • Knowing both means picking per project, not per career.

When something breaks

  • The board does not appear as a port. The USB cable is charge-only, or the driver is missing on Windows. Try a known-good data cable first; it is the most common failure by a wide margin.
  • Upload fails with "boot mode" errors. The IDE could not flip the board into bootloader mode. Hold BOOTSEL while plugging in USB, then upload; the manual path always works.
  • Sketch uploads but nothing runs. You selected the wrong board variant (e.g. "Pico W" code on a plain Pico). The onboard LED pin differs between variants; check Tools >> Board matches the board on the desk.
  • The IDE cannot find the core after install. The Board Manager URL in Preferences is mistyped or your network blocks GitHub. The URL must be exactly the one in the install steps above.
  • Port busy on every upload. A REPL session is holding the serial device. Close Thonny or kill mpremote, then upload (e.g. on Linux, check lsof /dev/ttyACM0 to find the holder).
  • My scripts vanished after an Arduino upload. Expected: the flash now holds the compiled sketch. Re-flash MicroPython and copy your .py files back from your backup.

What to build next

  • The Pico MicroPython setup tutorial is the other half of this decision, with the REPL workflow this one replaces for C++ projects.
  • The Pico W web server tutorial exists in both toolchains; compare the same project in Python and C++ in one sitting.
  • The servo tutorial in MicroPython ports to C++ with the Servo.h library in about ten lines (e.g. attach(pin) and write(degrees) replace the whole duty-cycle dance).
  • The microSD datalogger tutorial is the classic C++ win: the SD library and sensor libraries are more mature on the Arduino side.

Chapter 18

Pico: drive a 28BYJ-48 stepper motor with a ULN2003 driver

pico · 30 min

The 28BYJ-48 is the stepper I reach for when the job is "move this thing to a specific angle" and the thing is light. It costs about $2 with the driver board included, it has a gear reduction inside that gives you 2048 steps per revolution at the output shaft, and it is nearly impossible to damage from a wiring mistake. A servo tells you nothing about where it actually is. This motor counts its steps, so you always know.

The trap I hit: my first version turned smoothly but stopped about 15 degrees short of where I asked every single time. Not a wiring problem, a speed problem. I was stepping too fast for the motor to keep up, it was losing steps, and because a stepper is an open-loop device it had no idea. Slow the step interval down and the error disappears. That is the whole secret of this motor: it is geared and slow on purpose.

What you need

Needed

  • Raspberry Pi Pico with MicroPython installed (see the Pico setup tutorial)
  • 28BYJ-48 stepper motor (5V version, not the 12V variant)
  • ULN2003 driver board (the little blue board with the white 4-pin connector, sold bundled with the motor)
  • 6 female-to-male jumper wires
  • 5V power for the motor (e.g. the Pico's VBUS pin is fine for bench testing, a separate 5V supply is better for anything that runs hours)

Nice to have

  • Soldering iron and solder (only if you solder headers, not needed here)
  • Helping hands or a third hand clip for holding wires while you probe
  • Multimeter (for the "is this actually 5V" check before you blame code)
  • Anti-static wristband

Wiring

The ULN2003 board has four inputs (IN1-IN4), a power pair, and the white plug the motor connects to. The motor plug only fits one way.

ULN2003 pin Pico pin
IN1 GP0 (physical pin 1)
IN2 GP1 (physical pin 2)
IN3 GP2 (physical pin 4)
IN4 GP3 (physical pin 5)
VCC (or +) VBUS (physical pin 40, 5V from USB)
GND GND (physical pin 38)

Two things worth knowing:

The 28BYJ-48 draws about 240 mA while stepping and stalls around 320 mA. The Pico's VBUS passes USB power through, and a 500 mA USB port handles one motor fine. If the motor gets warm and the board resets when it starts, your USB source is weak: power the ULN2003 board from a separate 5V supply and tie the grounds together.

The coil order matters. The motor's blue and pink wires are one coil, yellow and orange are the other. The stepping sequence in the code below assumes IN1-IN4 match the board's silk-screen labels. If your motor runs hot and just vibrates, the sequence order is your first suspect, not the wiring.

Install

Nothing to install. machine.Pin and time are built into MicroPython. If you are running this from Thonny: Thonny >> Tools >> Manage packages only matters if you later want micropython-async, which is optional here.

The code

This is a complete program. Save it as main.py on the Pico and it runs on power-up:

from machine import Pin
import time

IN1 = Pin(0, Pin.OUT)   # GP0
IN2 = Pin(1, Pin.OUT)   # GP1
IN3 = Pin(2, Pin.OUT)   # GP2
IN4 = Pin(3, Pin.OUT)   # GP3

coils = [IN1, IN2, IN3, IN4]

# Half-step sequence: one coil, two coils, next coil, two coils...
# This is the sequence that gives 4096 steps per output revolution.
SEQ = [
    (1, 0, 0, 0),
    (1, 1, 0, 0),
    (0, 1, 0, 0),
    (0, 1, 1, 0),
    (0, 0, 1, 0),
    (0, 0, 1, 1),
    (0, 0, 0, 1),
    (1, 0, 0, 1),
]

STEPS_PER_REV = 4096      # half-stepping, 32 coil steps x 64:1 gearbox
STEP_DELAY_MS = 2         # 2 ms/step = ~500 steps/s = ~7 RPM (theoretical)

def step_forward(n):
    """Advance the sequence n half-steps clockwise."""
    global _phase
    for _ in range(n):
        _phase = (_phase + 1) % 8
        for coil, val in zip(coils, SEQ[_phase]):
            coil.value(val)
        time.sleep_ms(STEP_DELAY_MS)

def step_backward(n):
    global _phase
    for _ in range(n):
        _phase = (_phase - 1) % 8
        for coil, val in zip(coils, SEQ[_phase]):
            coil.value(val)
        time.sleep_ms(STEP_DELAY_MS)

_phase = 0

def release():
    """De-energize all coils (the motor holds nothing when idle)."""
    for coil in coils:
        coil.value(0)

# Demo: one full turn clockwise, pause, one full turn back
step_forward(STEPS_PER_REV)
time.sleep(1)
step_backward(STEPS_PER_REV)
release()

Run it and the shaft turns one slow full revolution, pauses, and comes back. That is 8192 half-steps, and you can watch the shaft move in tiny distinct nudges if you look closely. That is normal: 2048 steps per revolution means each step is 0.088 degrees at the shaft.

Now the version you will actually use: absolute position control. A stepper only knows relative motion, so the program tracks where it is:

class Stepper28BYJ:
    def __init__(self, pins, step_delay_ms=2):
        self.coils = [Pin(p, Pin.OUT) for p in pins]
        self.seq = [
            (1, 0, 0, 0), (1, 1, 0, 0), (0, 1, 0, 0), (0, 1, 1, 0),
            (0, 0, 1, 0), (0, 0, 1, 1), (0, 0, 0, 1), (1, 0, 0, 1),
        ]
        self.delay = step_delay_ms
        self.phase = 0
        self.pos = 0            # current position in half-steps

    def _write(self):
        for coil, val in zip(self.coils, self.seq[self.phase]):
            coil.value(val)

    def move(self, target):
        """Move to an absolute position in half-steps (0-4095)."""
        target %= 4096
        delta = target - self.pos
        direction = 1 if delta >= 0 else -1
        for _ in range(abs(delta)):
            self.phase = (self.phase + direction) % 8
            self._write()
            time.sleep_ms(self.delay)
        self.pos = target

    def release(self):
        for coil in self.coils:
            coil.value(0)

stepper = Stepper28BYJ((0, 1, 2, 3))
while True:
    stepper.move(1024)    # 90 degrees
    time.sleep(1)
    stepper.move(2048)    # 180 degrees
    time.sleep(1)
    stepper.move(0)       # home
    time.sleep(3)

One practical note on speed: 2 ms per half-step works out to about 7 RPM in theory, and MicroPython's loop overhead lands it closer to 5. This motor tops out around 500 steps per second before it starts missing steps and losing position. If you need fast, you do not want this motor, you want a NEMA 17 with a proper driver (the Arduino stepper tutorial on this site covers that comparison). Slow and precise is the 28BYJ-48's whole personality.

Half-step vs full-step, and the 2048/4096 math

The motor inside has 32 steps per revolution. The gearbox is 64:1 (about 63.683:1 in reality, which is why 4096 is not perfectly exact over long runs). Half-stepping doubles the resolution, so you get 4096 half-steps per revolution, and that is what the code above uses. Full-stepping (energizing one coil at a time) gives 2048 steps and slightly more torque per step.

If you only ever run for a few hundred revolutions, the 0.5 percent gearbox error never matters (e.g. a camera slider that travels back and forth all day is fine). If you are tracking long-term accumulated position, re-home at a known stop end and the error resets to zero.

What you learned

  • A stepper moves in countable steps; position control is just bookkeeping.
  • The 28BYJ-48 is 32 motor steps, 64:1 gearbox, 4096 half-steps per shaft revolution.
  • Speed costs torque and accuracy on this motor. Slow is correct.
  • Half-step sequences alternate one coil and two coils to divide each full step in two.

When something breaks

  • The motor vibrates and heats up but never turns. The coil sequence is out of order for your wiring. Swap IN2 and IN3 in the pins tuple (that mirrors the most common 28BYJ-48 variant). Also confirm you are using the 5V motor, the 12V variant barely moves on 5V.
  • It turns but lands short of the target. You are stepping too fast and losing steps. Raise step_delay_ms from 2 to 4 and retest. If the error persists at low speed, something is mechanically jamming the shaft.
  • The Pico resets or browns out when the motor starts. The motor's inrush is more than the USB port gives. Power the ULN2003 from its own 5V supply and connect grounds, and add a 100-470 uF capacitor across the driver board's power pins.
  • Position drifts after hours of running. That is the 63.683:1 gearbox, not a bug. Re-home at a known end stop periodically (e.g. a microswitch at position zero) and the drift resets.

What to build next

  • The Pico microSD datalogger tutorial pairs with this one: log the stepper's commanded position and the move duration alongside sensor data.
  • The Pico servo tutorial is the faster, cheaper motor for "point at a thing" jobs; the stepper is the pick when you need full rotation and counted steps.
  • A plant-camera turntable: Pico W serves the web page (see the Pico W web server tutorial), the stepper rotates the pot 30 degrees a day.

© ctrlaltbrian.com

Published 2026-09-24 · Source: ctrlaltbrian.com

Built in the spirit of measure twice, flash once. Brian writes these so you can actually finish the project, not so you give up halfway and buy a pre-made one.

© 2026 ctrlaltbrian. Code samples are MIT. Tutorials are CC BY-NC-SA 4.0 (use them, share them, don't resell them).