After weeks of SD card frustrations on STM32, I finally switched to CircuitPython and the Adafruit Feather RP2040 — and within days had a working telemetry logger running. In this post, I’ll walk you through the setup, show some code, and share what I learned along the way.

Build, Create & Learn — A Maker’s Journey Episode 5 you’ll know that in the last Episode I decided to pivot my drone telemetry project to CircuitPython on the Adafruit Feather RP2040.



First Steps with the Feather RP2040

Setting up the RP2040 with CircuitPython was almost shockingly easy. Instead of compiling and flashing firmware like on STM32, you just drag-and-drop the UF2 file onto the board. After reboot, it shows up as a USB drive called CIRCUITPY.

From there, you simply edit code.py — hit save — and the board runs it immediately. No toolchains, no flashing steps. It felt almost like cheating compared to my earlier struggles.

The Adafruit documentation deserves a big shoutout here. It’s incredibly beginner-friendly: wiring diagrams, example code, photos — everything laid out step by step. Honestly, it felt like working with LEGO. That makes it not only great for me but also the perfect entry point if you’re new to electronics.

Blinky, NeoPixel & First Impressions

Naturally, I started with a blinky script — and then discovered the big RGB NeoPixel in the middle of the board. One example later, and I had rainbow colors cycling on my desk. My daughter loved it — and so did I. Sometimes the smallest things give you the motivation to push forward.

Adafruit Feature with rainbow blinking LED

Connecting the BME280 Sensor

Next up was the BME280 environmental sensor. Thanks to the Adafruit library, it was as easy as dropping the .mpy file into the lib folder and running the example.

import board, busio
i2c = busio.I2C(board.SCL, board.SDA)
while not i2c.try_lock():
    pass
print([hex(x) for x in i2c.scan()])
i2c.unlock()

At first, I hit a pin assignment error, but with a helper function to explicitly define the SPI pins, I quickly got temperature, humidity, and pressure streaming into the serial console.

Finally: SD Card Logging that Works

The biggest relief came with the microSD breakout board. After endless failures on STM32, the Feather RP2040 + CircuitPython combo worked almost immediately once I switched to the sdcardio library.

Not only could I write test files, I was soon logging BME280 data to CSV. At first, everything went into one file, but I later adapted the script to use daily log files — much better for organizing drone flight data.

Here’s the full working code I ended up with (+GPS data):

import time, os, board, storage, sdcardio, busio, supervisor, neopixel, digitalio, rtc
from adafruit_bme280 import basic as adafruit_bme280
import adafruit_gps

# ---------------- Settings ----------------
SEA_LEVEL_HPA = 1016.0
SD_CS = board.D5
LOG_DIR = "/sd"
FLUSH_EVERY = 10
GPS_BAUD = 9600

# Avoid restarts while logging if you save files over USB
supervisor.runtime.autoreload = False

# --------------- NeoPixel -----------------
try:
    np_power = digitalio.DigitalInOut(getattr(board, "NEOPIXEL_POWER"))
    np_power.switch_to_output(True)
except Exception:
    pass

PIX = neopixel.NeoPixel(board.NEOPIXEL, 1, brightness=0.2, auto_write=True)
OFF     = (0, 0, 0)
RED     = (255, 0, 0)
GREEN   = (0, 255, 0)
BLUE    = (0, 0, 255)
YELLOW  = (255, 180, 0)
TEAL    = (0, 170, 120)
PURPLE  = (160, 0, 255)
ORANGE  = (255, 80, 0)

def set_led(c): PIX[0] = c
flash_until = 0
def flash(color, duration=0.08):
    global flash_until
    set_led(color)
    flash_until = time.monotonic() + duration

def apply_base_color(has_fix):
    set_led(TEAL if has_fix else YELLOW)

# ---------------- SD init -----------------
set_led(PURPLE)
spi = board.SPI()
sd = sdcardio.SDCard(spi, SD_CS, baudrate=1_000_000)
vfs = storage.VfsFat(sd)
storage.mount(vfs, LOG_DIR)
for _ in range(2):
    set_led(BLUE); time.sleep(0.07)
    set_led(OFF);  time.sleep(0.07)

def path_exists(p):
    try: os.stat(p); return True
    except OSError: return False

def datestr_from_rtc():
    y, m, d = rtc.RTC().datetime[0:3]
    return f"{y:04d}{m:02d}{d:02d}"

def log_path_for_today():
    return f"{LOG_DIR}/log_{datestr_from_rtc()}.csv"

def open_log_for_today():
    path = log_path_for_today()
    new = not path_exists(path)
    f = open(path, "a")
    if new:
        header = "t_s,temp_c,rel_hum_pct,press_hpa,alt_bme_m,lat,lon,spd_kmh,fix_q,sats,hdop,alt_gps_m\n"
        f.write(header); f.flush(); os.sync()
    return f, path

log_f, current_path = open_log_for_today()
lines_since_sync = 0

# ------------- BME280 (I2C) --------------
i2c = busio.I2C(board.SCL, board.SDA)
bme = adafruit_bme280.Adafruit_BME280_I2C(i2c, address=0x76)
bme.sea_level_pressure = SEA_LEVEL_HPA

# --------------- GPS (UART) --------------
uart = busio.UART(board.TX, board.RX, baudrate=GPS_BAUD, timeout=10)
gps = adafruit_gps.GPS(uart, debug=False)
gps.send_command(b"PMTK314,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0")  # RMC + GGA
gps.send_command(b"PMTK220,1000")  # 1 Hz

last_sample = time.monotonic()
flash_until = 0.0
apply_base_color(False)  # start "no fix"

def fmt(x, prec=6):
    if x is None: return ""
    if isinstance(x, float): return f"{x:.{prec}f}"
    return str(x)

def rtc_is_reasonable():
    y = rtc.RTC().datetime[0]
    return y >= 2020

def try_set_rtc_from_gps():
    """If GPS has a timestamp, set the RTC so filenames reflect real date."""
    if getattr(gps, "timestamp_utc", None):
        t = gps.timestamp_utc
        try:
            rtc.RTC().datetime = (t.tm_year, t.tm_mon, t.tm_mday, t.tm_wday,
                                  t.tm_hour, t.tm_min, t.tm_sec, 0)
            return True
        except Exception:
            pass
    return False

try:
    while True:
        gps.update()

        # handle LED flash timeout / base color
        now = time.monotonic()
        if flash_until and now >= flash_until:
            flash_until = 0
            apply_base_color(gps.has_fix)

        # if first valid GPS time, set RTC and rotate file once
        if gps.has_fix and not rtc_is_reasonable():
            if try_set_rtc_from_gps():
                # Rotate to correct-dated file
                try:
                    log_f.flush(); os.sync(); log_f.close()
                except Exception:
                    pass
                log_f, current_path = open_log_for_today()
                lines_since_sync = 0
                apply_base_color(True)

        # sample & log each second
        if now - last_sample >= 1.0:
            last_sample = now

            # rotate if date changed
            wanted_path = log_path_for_today()
            if wanted_path != current_path:
                try:
                    log_f.flush(); os.sync(); log_f.close()
                except Exception:
                    pass
                log_f, current_path = open_log_for_today()
                lines_since_sync = 0

            # BME read
            try:
                t_c  = bme.temperature
                rh   = bme.relative_humidity
                p_hpa= bme.pressure
                altb = bme.altitude
            except Exception as e:
                t_c, rh, p_hpa, altb = None, None, None, None
                flash(ORANGE, 0.12)
                print("BME read err:", repr(e))

            # GPS fields (may be None until fix)
            lat = gps.latitude
            lon = gps.longitude
            spd_kmh = getattr(gps, "speed_kmh", None)
            if spd_kmh is None and gps.speed_knots is not None:
                spd_kmh = gps.speed_knots * 1.852
            fix_q = gps.fix_quality
            sats  = gps.satellites
            hdop  = gps.horizontal_dilution
            altg  = gps.altitude_m

            if not flash_until:
                apply_base_color(gps.has_fix)

            row = ",".join([
                f"{now:.1f}",
                fmt(t_c, 2),
                fmt(rh, 2),
                fmt(p_hpa, 2),
                fmt(altb, 2),
                fmt(lat, 6),
                fmt(lon, 6),
                fmt(spd_kmh, 2),
                fmt(fix_q, 0) if fix_q is not None else "",
                fmt(sats, 0)  if sats  is not None else "",
                fmt(hdop, 2),
                fmt(altg, 2),
            ]) + "\n"

            try:
                log_f.write(row)
                lines_since_sync += 1
                flash(GREEN, 0.05)
                if lines_since_sync >= FLUSH_EVERY:
                    log_f.flush(); os.sync()
                    lines_since_sync = 0
            except Exception as e:
                flash(RED, 0.2)
                print("SD write err:", repr(e))

        time.sleep(0.05)

except Exception as e:
    set_led(RED)
    print("FATAL:", repr(e))

finally:
    try:
        log_f.flush(); os.sync(); log_f.close()
    except Exception:
        pass
    try:
        storage.umount(LOG_DIR)
    except Exception:
        pass
    set_led(OFF)

Adding GPS and Next Steps

With the SD card and BME280 running smoothly, I wired up the GPS breakout. At first, nothing — until I noticed a missing wire. Once fixed, the NMEA strings came in cleanly, and the Adafruit GPS library parsed them into usable data immediately.

Adding that to the logger means I now have environmental data + GPS coordinates saved to microSD. A full working telemetry prototype at last.

Thinking About the Enclosure

Electronics done, my attention turned to mechanics. Breadboards are great for testing, but not for strapping to a drone.

Instead of soldering everything to a perfboard, I made the deliberate maker’s choice to design an enclosure first. Since I’m using breakout boards with mounting holes, this approach is faster and more practical for early test flights.

3d printed threaded inserts

I started sketching, then 3D-printed simple standoffs with threaded inserts and a rough case to play with layouts. It feels good to lean on my mechanical engineering background here — and it’s something I see many electronics-first makers struggle with. Enclosures are just as important as wiring, and I enjoy the crossover.

Stay tuned — in the next episode, the goal is clear: a first real test flight.

Wrap-Up

So here’s what I achieved in this phase:

  • Feather RP2040 running CircuitPython
  • BME280 sensor data logging to CSV
  • microSD breakout working reliably with sdcardio
  • GPS coordinates integrated into the logger
  • Early CAD and 3D printed standoffs for the enclosure

It feels amazing to see this project finally coming together.

🎧 If you’d like the full backstory with all the ups and downs, check out Podcast Episode 5 here.

📬 And if you want a weekly peek behind my workbench — projects and experiments that don’t always make it into the podcast — subscribe to my Maker’s Logbook newsletter.




Let’s keep building, creating, and learning — together.