← Back to Dashboard

Project History

From garden sensors to bird songs

Original garden monitoring setup

Where it started: garden monitoring

Long before there was a bird station, there was a flower box. This Raspberry Pi setup started as a garden monitor — a Qwiic soil moisture sensor, a BME280 for temperature and humidity (connected via a Qwiic Shim), an ADS1115 analog-to-digital converter, and a DS3231 real-time clock (honestly overkill for this project), all feeding a Grafana dashboard so I could keep an eye on my plants without walking outside. The whole thing ran off a Jackery 300 portable power station.

Raspberry Pi camera module

The camera module

That garden project taught the basics: I2C wiring, sensor addressing, and how much can go wrong with something as "simple" as an outdoor camera cable. When it was time to expand into acoustic bird detection with BirdNET-Pi, the same camera module became the eyes of the new station — small, cheap, and surprisingly capable once exposure and focus are dialed in.

Bird station enclosure, first angle

Building the enclosure

From there it's been a process of small refinements — better cable routing and sturdier mounting — but I'll be honest: it's still fragile and not weatherproof. I bring it in overnight if there's any chance of rain. The next planned upgrade is an Ecowitt WittBoy Weather Station (GW2001) for proper outdoor-rated environmental monitoring, which should let the whole setup live outside full-time.

Bird station enclosure, second angle

Where it stands today

What started as a way to keep tabs on a flower box has grown into a full acoustic monitoring station — tracking species activity, weather, and a live camera feed, all running quietly in the background year-round.

Ecowitt WittBoy 7-in-1 weather sensor

Upgrading the weather sensors

The planned WittBoy upgrade arrived — every environmental sensor except the mic and camera was swapped for this single outdoor-rated 7-in-1 unit, adding wind, rain, solar radiation, and UV to what the BME280 could ever measure.

WittBoy weather station installed in the yard

The WittBoy, mounted

Getting it up on a pole wasn't quite plug-and-play — I had to track down a 1-inch diameter dowel rod just to mount it properly in the yard.

DIY marketing poster for the bird station

Old-fashioned marketing

QR codes aren't just for restaurant menus — I printed a few posters at UPS and put them up on community boards near Old Salem, hoping a few passersby would scan their way into a good bird rabbit hole.

USB mic wedged in kitchen window

Chasing better audio: the USB mic

To get the mic closer to the birds, I ran a USB extension cable out through the kitchen window, wedged between the screen and the glass. It works, but it's exposed to the weather every time it rains — not exactly a permanent solution.

XIAO ESP32-C3 and INMP441 microphone breadboard build

Going wireless: XIAO ESP32-C3 + INMP441

Looking to ditch the window cable, I built a wireless mic using a Seeed XIAO ESP32-C3 (not the C6) paired with an INMP441 omnidirectional microphone.

Close-up of INMP441 microphone wiring

The overheating setback

It worked great for about two hours, then started overheating and auto-shutting down at 80°C. After five hours of troubleshooting and three more shutdowns, I reverted to the USB mic. I've since ordered the ESP32-C6 (the C3's bigger sibling) and still think a wireless solution is possible.

A peek at the code

People often ask how the live camera feed works. Here's a sanitized excerpt of the capture loop — it rotates through a few camera positions, writes each shot to a temp file, then atomically swaps it into place so the site never serves a half-written image.

def run_camera_loop():
    views = [
        "",  # default wide view
        "",  # default wide view
        "--roi X,Y,W,H",   # centered zoom
        "--roi X,Y,W,H",   # panned left
        "--roi X,Y,W,H",   # panned right
    ]
    i = 0
    while True:
        roi = views[i % len(views)]
        tmp_path = IMAGE_PATH + ".tmp"
        cmd = f"rpicam-still -n --width 1280 --height 720 -q 95 {roi} -o " + tmp_path
        result = subprocess.run(cmd, shell=True, capture_output=True,
                                 text=True, timeout=25)
        if result.returncode == 0 and os.path.getsize(tmp_path) > 0:
            os.replace(tmp_path, IMAGE_PATH)  # atomic swap, no partial reads
        i += 1
        time.sleep(30)

And the rolling 24-hour activity query behind the species chart:

SELECT Date || ' ' || substr(Time,1,2) || ':' ||
       printf('%02d', (CAST(substr(Time,4,2) AS INTEGER)/15)*15) AS block,
       COUNT(DISTINCT Com_Name)
FROM detections
WHERE datetime(Date || ' ' || Time) >= datetime('now', 'localtime', '-24 hours')
GROUP BY block
ORDER BY block ASC;