A Raspberry Pi service that watches a pool through an RTSP camera, asks Claude whether birds are present, and triggers a sprinkler (via relay + solenoid valve) to startle them. Each detection saves a still image and a short video clip, and can optionally publish both to a Cloudflare R2 bucket so they're viewable from anywhere on the internet.
blogpost: https://mattsahn.github.io/bird-away-blog/
- Raspberry Pi 4 (or earlier) on Wi-Fi.
- Relay module wired to one Pi GPIO pin and ground. Active-high or active-low is configurable.
- RTSP-capable IP camera reachable on the same network.
- Solenoid valve on its own power supply, switched by the relay. The Pi must not source current to the valve directly.
- Sprinkler aimed to spray over and around the pool when the valve opens.
- optional - temperature/humidity sensor for monitoring ambient conditions in the enclosure
- Momentary switch (the contact pair on the same panel-mount
button) for a manual sprinkler trigger. One contact β GPIO pin (default
23
), other β ground; the Pi's internal pull-up holds it HIGH at rest. The status LED stays solid while held, and releasing fires the same capture/spray/record/upload flow as a real bird detection.
On the Pi:
sudo apt update
sudo apt install -y python3-venv ffmpeg swig liblgpio-dev
git clone <this repo> /home/pi/git/bird-away
cd /home/pi/git/bird-away
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env # then edit: OPENROUTER_API_KEY, RTSP_URL
cp config.yaml.example config.yaml # then edit GPIO pin, durations, etc.
swig
and liblgpio-dev
are needed so pip
can build the lgpio
wheel,
which gpiozero uses as its GPIO backend on Raspberry Pi OS Bookworm/Trixie.
Without it gpiozero falls back to an experimental native pin factory and
prints PinFactoryFallback
warnings on startup.
The clone path above (/home/pi/git/bird-away
) matches the paths baked into
systemd/bird-away.service
. If you install elsewhere, edit WorkingDirectory
,
EnvironmentFile
, and ExecStart
in that unit to match.
.env
(secrets, never committed):
OPENROUTER_API_KEY
β your OpenRouter API key (https://openrouter.ai). Routes to whichever modeldetector_model
selects.RTSP_URL
β full RTSP URL with credentials, e.g.rtsp://user:pass@192.168.1.50:554/stream
.R2_ACCESS_KEY_ID
/R2_SECRET_ACCESS_KEY
β only required ifr2_enabled: true
inconfig.yaml
. SeeRemote publishing.
config.yaml
(tunables):
interval_seconds
β how often to sample (default60
).spray_duration
β relay-on time in seconds when a bird is seen (default3
).pre_spray_seconds
β seconds of video recorded before the spray fires, so the clip captures the moment leading up to it (default3
).post_spray_seconds
β seconds of video recorded after the spray fires (default4
). Total clip length ispre_spray_seconds + spray_duration + post_spray_seconds
.gpio_pin
β BCM pin number wired to the relay input (default17
).relay_active_high
βtrue
if the relay closes on logic-high,false
if active-low (most cheap relay modules are active-low β check yours).capture_dir
β where images and clips are saved (default./captures
).detector_model
β OpenRouter model id; defaultanthropic/claude-haiku-4.5
.detector_base_url
β OpenAI-compatible base URL; defaulthttps://openrouter.ai/api/v1
. Override to point at a different provider.detector_prompt
β system prompt sent to the vision model. Use a YAML literal block (|
) to write it across multiple lines. SeeTuning the promptfor what makes a good one.daytime_only
β whentrue
(default), only run detection between 07:00 and 19:00 local time. Setfalse
to run 24/7 (e.g. for testing or with an IR camera).motion_enabled
,motion_threshold
,motion_downscale
β local frame-diff gate; only call the vision API when consecutive frames differ enough. Threshold is on a 0-255 mean per-pixel scale;5.0
is a reasonable start.status_led_enabled
/status_led_pin
β drive a status LED on a separate GPIO pin (default24
). Heartbeat-blinks 0.5s every 10s while the service is running, blinks 1.5s on each frame capture, and rapid-blinks 5 times on a positive bird detection. Setstatus_led_enabled: false
if you don't have the LED wired.trigger_button_enabled
/trigger_button_pin
β manual sprinkler trigger via a momentary switch on a GPIO pin (default23
). Wired between the pin and ground; uses the Pi's internal pull-up. While held, the status LED stays solid; releasing runs the same flow as a real bird detection. Settrigger_button_enabled: false
if no switch is wired.retention_days
β delete localdetection-*.jpg
/event-*.mp4
files older than this many days. Sweep runs at startup and hourly thereafter. Default7
. Set to0
(or negative) to keep everything. R2 objects are unaffected β manage their lifecycle in the bucket settings.healthcheck_url
/healthcheck_interval_seconds
β liveness ping tohealthchecks.io(or any URL accepting a GET). Pinged after each successful loop iteration, rate-limited to once per interval (default300s
). Pings stop if the loop hangs or throws every iteration, so healthchecks.io alerts you by email/SMS. Leave blank to disable. Recommended for unattended deployments.log_level
βINFO
orDEBUG
.- R2 publishing keys (
r2_enabled
,r2_account_id
,r2_bucket
,r2_public_base_url
,r2_key_prefix
) β seeRemote publishing. delete_after_upload
β whentrue
, skip the local JPEG write entirely and delete each MP4 right after R2 confirms the upload. Requiresr2_enabled: true
. Pair withcapture_dir: /dev/shm/bird-away
for zero SD-card writes. SeeMinimizing SD-card writes.
Optional. When r2_enabled: true
, each detection's snapshot JPEG and event MP4 are uploaded to a Cloudflare R2 bucket so you can view them from any browser. R2 is S3-compatible with no egress fees and a 10 GB free tier β for typical usage this stays free indefinitely with a 30-day lifecycle rule.
Cloudflare β R2 β create a bucket (e.g.
bird-away
). In the bucket's settings, enable theR2.dev subdomain and copy thehttps://pub-<hash>.r2.dev
URL. - R2 β "Manage R2 API tokens" β
Create API token, scope** Object Read & Writeto that bucket. Save the Access Key IDand Secret Access Key**β they're shown once. - Append to
.env
:
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
Add to
config.yaml
:
r2_enabled: true
r2_account_id: <hex string from R2 dashboard>
r2_bucket: <bucket name>
r2_public_base_url: https://pub-<hash>.r2.dev
Optional: in the R2 dashboard, add a lifecycle rule "delete objects older than 30 days" to keep storage under the free tier.
Each detection produces two objects under
<r2_key_prefix>/YYYY-MM-DD/
: detection-<ts>.jpg
and event-<ts>.mp4
.
Default r2_key_prefix
is events
. By default R2 is additive β local
copies in captures/
are also written and pruned by retention_days
. To make R2 the only copy and stop writing to the SD card, see Minimizing SD-card writes.
When R2 publishing is enabled, the service also maintains a manifest.json
in the bucket (at <r2_key_prefix>/manifest.json
). The manifest is a JSON
index of the last 500 detection events with public URLs for each snapshot and
video. web/index.html
is a self-contained dashboard that reads this manifest and renders an interactive event timeline.
The dashboard is a static site β deploy the web/
directory to Vercel:
cd web
npx vercel --prod
Set the MANIFEST_URL
environment variable on the Vercel project so visitors see data immediately without any manual configuration:
cd web
npx vercel env add MANIFEST_URL production
npx vercel --prod # redeploy to pick up the new env var
Or set it in the Vercel dashboard under Project β Settings β Environment
Variables. The build step writes this value into dashboard-config.json
, which the dashboard loads automatically on startup.
Visitors can still override the URL via the settings modal or the
?manifest=
query parameter.
The dashboard is a single index.html
with no build step, so it works anywhere: open it locally as a file, deploy to GitHub Pages or Netlify, or upload it to the same R2 bucket as your events.
Live status: shows connection state, last event time, event count.** Event grid**: thumbnail cards for each detection with date, time, and trigger type (auto / manual).** Filtering**: filter by date or trigger type.** Lightbox**: click any card to see the full snapshot; links to open the image or play the video.** Auto-refresh**: polls the manifest at a configurable interval (default 60 seconds).** Live tab**: a rolling last-30-minutes view of every captured frame labeled bird / no-bird, for spotting missed detections (seeReal-time monitoring).
If the dashboard is served from a different origin than R2 (e.g. localhost or a Vercel deploy), the browser needs CORS headers on the manifest. In the Cloudflare dashboard, go to your R2 bucket β Settings β CORS policy and add:
[
{
"AllowedOrigins": ["*"],
"AllowedMethods": ["GET"],
"AllowedHeaders": ["*"]
}
]
R2.dev public subdomains include permissive CORS headers by default, so this is usually not needed unless you've customized the bucket's CORS settings.
If you suspect the detector is missing birds, the Live tab gives you a rolling view of every frame the loop captured in the last 30 minutes β each labeled Bird or No bird β so you can manually spot false negatives. These frames are full-resolution (the detector only ever sees a 512px copy, so small birds can hide there) and carry no video.
Enable it on the device (config.yaml
):
realtime_enabled: true # requires r2_enabled: true
realtime_key_prefix: realtime # frames + manifest land under this prefix
realtime_window_minutes: 30 # how much the Live tab shows
realtime_max_image_dim: 0 # 0 = full-res; e.g. 1280 to shrink
Frames upload to realtime/frames/<ts>.jpg
and are indexed in
realtime/manifest.json
β kept entirely separate from events/
, which stays
real-detections-only. The dashboard derives the real-time manifest URL from the
main manifest URL automatically (swapping /events/
for /realtime/
); override it in Settings if you use a custom prefix.
Set up cleanup so old frames don't accumulate. R2 lifecycle rules expire
objects by whole days (1-day minimum), so a true 30-minute purge isn't possible
server-side β the Live tab provides the 30-minute view, and a lifecycle rule
deletes the underlying objects a day later. In the Cloudflare dashboard: R2 β
your bucket β Settings β Object lifecycle rules β Add rule, set the prefix
to realtime/frames/
and "Delete objects" 1 day after creation. Scoping the
rule to realtime/frames/
leaves realtime/manifest.json
untouched (it's rewritten every cycle anyway). Storage stays well within R2's free tier β one day of frames is a few hundred MB at most, and R2 egress is free.
When deploying the dashboard to Vercel you can optionally set a
REALTIME_MANIFEST_URL
environment variable (alongside MANIFEST_URL
); the
build bakes it into dashboard-config.json
. It's optional β the derive
fallback covers the default events
/realtime
prefixes.
.venv/bin/python -m src.main
Logs go to stdout. Output files appear under captures/
.
Run these one at a time to verify each piece of the chain.
.venv/bin/python scripts/test_camera.py # writes /tmp/bird-away-test.jpg
.venv/bin/python scripts/test_detector.py path/to/sample.jpg # prints yes/no
.venv/bin/python scripts/test_sprinkler.py 1 # clicks relay for 1 second
.venv/bin/python scripts/test_status_led.py # runs each LED pattern in sequence
.venv/bin/python scripts/test_trigger_button.py # press to light LED, release to log
scripts/test_models.py
runs a list of vision models against a single saved
frame so you can see how each one handles the same input. Useful when picking
detector_model
or tuning detector_prompt
against tricky frames (small distant birds, glare on water, dawn light, etc.).
First-time setup β copy the example config (the actual file is gitignored so your local tweaks stay out of the repo):
cp scripts/models_config.yaml.example scripts/models_config.yaml
Then run against any local image:
.venv/bin/python scripts/test_models.py captures/detection-20260426T131922Z.jpg
Each model gets two prompts: a yes/no classifier (the project's bird detector
by default) and a freeform description (asks the model to describe the scene,
animals, people, actions). Both prompts and the model list live in
scripts/models_config.yaml
and can be overridden β pass --config <path>
to use an alternate file. The script prints the raw response from each model along with the input resolution, elapsed time, and token usage so you can compare quality, cost, and latency at a glance.
sudo cp systemd/bird-away.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now bird-away
journalctl -u bird-away -f
The unit runs as user pi
in group gpio
. Adjust User=
, Group=
, and the
paths in bird-away.service
if your install location differs.
For unattended deployments, SD-card wear is the most common cause of Pi
death β every snapshot JPEG and event MP4 written to captures/
consumes write cycles. Cards typically tolerate 10k-100k writes per cell, and a busy day at the pool can produce dozens of MB; over months that adds up.
Two settings together eliminate ~all capture-related SD writes:
r2_enabled: true # uploads still go to R2
delete_after_upload: true # don't keep a local copy
capture_dir: /dev/shm/bird-away # tmpfs (RAM); never touches the SD card
How it works:
JPEG snapshot. Withdelete_after_upload: true
, the bytes are uploaded straight from memory viaput_object
βwrite_bytes
is never called andimage_path
never exists on disk.Event MP4. ffmpeg has to write to a path, so the file lives briefly incapture_dir
while it records. Withcapture_dir
pointed at/dev/shm
(a kernel-managed tmpfs sized to half of RAM by default), that "file" lives in RAM. Once the R2 upload returns success, the file is deleted. If the upload fails, the file stays soretention_days
can clean it up later.Bonus ( Faster than the SD card and survives nothing β a reboot wipes it. That's exactly what you want for transient capture data once R2 has the durable copy./dev/shm
).
Tradeoff to know: with delete_after_upload: true
, an extended R2 outage
loses captures (failed uploads stay on disk only until retention_days
expires, vs. the default behavior where they persist until you manually copy them off). For a deterrent system this is usually the right trade β SD-card death is permanent, a missed video clip is not.
If you want to keep the local copies (for debugging, or because you don't
have R2 set up), leave delete_after_upload: false
(the default) and let
retention_days
bound disk usage. You can still point capture_dir
at
/dev/shm/bird-away
to get RAM-backed storage with retention-based cleanup β useful if you want a few days of local history without SD wear.
For setups that need to run for months without intervention, the unit ships
with two watchdogs and Restart=always
. The defaults Just Work, but two extra one-time setup steps make recovery faster.
Software watchdog (already wired up). The service uses Type=notify
and
the main loop pings WATCHDOG=1
at the top of each iteration and once per
second while sleeping. If the loop hangs for more than WatchdogSec=120
,
systemd kills and restarts the process. Combined with Restart=always
this also catches clean exits, OOM kills, segfaults β anything short of a kernel hang.
Hardware watchdog (one-time system config). The Pi's BCM watchdog will
reset the SoC if the kernel itself hangs (rare but possible β bad SD card
sector, GPU lockup). On Pi 4/5 with current Pi OS, /dev/watchdog
is already exposed; you just need to tell systemd to use it:
sudo sed -i 's/^#RuntimeWatchdogSec=off/RuntimeWatchdogSec=15/' /etc/systemd/system.conf
sudo systemctl daemon-reexec
On older Pi OS (Bullseye and earlier) you may also need
dtparam=watchdog=on
in /boot/firmware/config.txt
(or /boot/config.txt
on pre-Bookworm) followed by a reboot. Check ls /dev/watchdog
first β if it exists, you're already set.
Liveness alerting. Set healthcheck_url
in config.yaml
to a free healthchecks.io URL β see the config docs above. This catches the failure mode where the loop is alive and pinging the watchdog but every iteration is throwing (e.g. RTSP camera unreachable).
False positives(sprays when no birds): tightendetector_prompt
inconfig.yaml
(seeTuning the prompt), or switchdetector_model
to a stronger vision model (e.g.anthropic/claude-sonnet-4.5
).Birds aren't fazed: increasespray_duration
, or check that the spray pattern actually covers where they land.Storage filling up: tuneretention_days
(default7
). The service prunescaptures/
at startup and hourly. R2 lifecycle rules handle the cloud copy.
detector_prompt
is the system message sent to the vision model on every
frame that passes the motion gate. The model receives the prompt plus a single
still image, and the parser in src/detector.py
calls it a bird if the reply
starts with yes
(case-insensitive).
A few rules of thumb:
Be specific about what counts."A bird" is ambiguous β does a duck on the deck count? Birds in flight? Reflections in the water? Most false positives and false negatives come from leaving these unstated. The default prompt explicitly covers "in, on, or near the pool (including birds in flight directly above it)" for that reason.Keep it short. Long prompts cost more per call and rarely improve accuracy. If you find yourself writing a paragraph, switch to a strongerdetector_model
instead.Pin the output format. End with something like "Output only the single word." Models occasionally drift to "Yes." or "Yes, I seeβ¦" β those still matchstartswith("yes")
, but rambling answers like "I'm not sureβ¦" parse asno
.Iterate against saved frames. Every detection writes a still tocaptures/
. Pointscripts/test_detector.py captures/<file>.jpg
at known bird and non-bird frames to sanity-check a prompt change before restarting the service.Restart after editing.config.yaml
is read once at startup βsudo systemctl restart bird-away
to pick up changes.
- The Pi only switches the relay's input. The valve's power must come from a separate supply sized for the solenoid. Never wire mains AC to the Pi.
- Confirm the relay polarity (active-high vs active-low) before attaching the valve. A relay stuck closed will leave water flowing.
- The
Sprinkler
class drives the GPIO low on__exit__
and on close, so a clean exit (Ctrl-C, SIGTERM) will shut the valve. systemd will also restart the service on failure, which forces a fresh GPIO init.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Raspberry Pi (bird-away.service) β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Configuration
βββββββββββββ
.env (secrets) config.yaml (tunables)
βββββββββββββ ββββββββββββββββββββββ
OPENROUTER_API_KEY interval_seconds, gpio_pin, spray_duration,
RTSP_URL detector_model, detector_prompt, motion_*,
R2_ACCESS_KEY_ID * daytime_only, r2_* (* if r2_enabled), β¦
R2_SECRET_ACCESS_KEY *
β β
βββββββββββββββ¬βββββββββββββ
βΌ
src/config.py βββΊ Config dataclass (passed to all modules)
Per-iteration flow (loops every cfg.interval_seconds in src/main.py)
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
src/camera.py runs a background thread (PyAV / libav) that holds one
long-lived RTSP/TCP session and continuously decodes frames. The main
loop pulls the most recent JPEG from RAM in O(1) β no per-iteration
handshake.
ββββββββββββββββ persistent
β RTSP camera ββββββββββββββββββΊ src/camera.py
β (IP cam) β H.264 stream Camera class:
ββββββββββββββββ β’ bg thread: av.open + decode
β’ capture_frame() β latest JPEG
β
βΌ
daytime_only gate (07:00-19:00 local)
β
outside βββββ΄ββββΊ inside
β β
βΌ βΌ
sleep src/motion.py
MotionDetector.check()
(frame-diff gate, local)
β
score < thresh ββββββ΄βββββΊ score β₯ thresh
β β
βΌ βΌ
sleep src/detector.py
downscale β 512px
Detector.is_bird_present()
β HTTPS
βΌ
ββββββββββββββββββββββββ
β OpenRouter API β
β (Claude vision model β
β per detector_model) β
ββββββββββββ¬ββββββββββββ
β "yes" / "no"
βΌ
ββββββββββββ΄βββββββββββ
βΌ no βΌ yes
sleep _handle_event()
β
βββββββββββββββββββββββββββββββββββββββββββββ€
βΌ βΌ
bg thread, save still + spawn
spawn ffmpeg ffmpeg recorder
(separate RTSP (captures/event-*.mp4)
session, camera β
only allows 1-2) ββββββββββ΄βββββββββ
β βΌ βΌ
βΌ src/sprinkler.py src/up.py
resume bg thread GPIO via gpiozero R2Up
after ffmpeg + lgpio (boto3 β S3 API)
β β
βΌ βΌ
GPIO pin 17 Cloudflare R2
(public bucket)
Hardware chain (off the Pi)
βββββββββββββββββββββββββββ
GPIO 17 βββΊ Relay module βββΊ 12V solenoid valve βββΊ sprinkler βββΊ pool surface
(active high/ β²
low configurable) β
separate PSU
(never from Pi)
Background pieces
βββββββββββββββββ
systemd/bird-away.service β runs `python -m src.main` as user pi (group gpio),
restarts on failure, logs to journal
captures/ β unbounded; prune via cron / tmpfiles.d
scripts/test_*.py β per-stage smoke tests (camera / detector / sprinkler)