From Zero to Flight-Ready: Logging Data with CircuitPython on the Feather RP2040
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.
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.
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.