cd /news/computer-vision/bird-away-raspberry-pi-powered-water… Β· home β€Ί topics β€Ί computer-vision β€Ί article
[ARTICLE Β· art-49306] src=github.com β†— pub= topic=computer-vision verified=true sentiment=Β· neutral

Bird-Away: Raspberry Pi-powered water-based bird deterrent

A developer built Bird-Away, a Raspberry Pi-powered system that uses an RTSP camera and Claude AI to detect birds in a pool, then triggers a sprinkler to deter them. The system saves images and video clips, optionally publishing them to Cloudflare R2 for remote viewing. The project is open-source and designed for easy setup on a Raspberry Pi 4.

read16 min views3 publishedJul 7, 2026
Bird-Away: Raspberry Pi-powered water-based bird deterrent
Image: source

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)
── more in #computer-vision 4 stories Β· sorted by recency
── more on @raspberry pi 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/bird-away-raspberry-…] indexed:0 read:16min 2026-07-07 Β· β€”