{"slug": "bird-away-raspberry-pi-powered-water-based-bird-deterrent", "title": "Bird-Away: Raspberry Pi-powered water-based bird deterrent", "summary": "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.", "body_md": "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.\n\nblogpost: [https://mattsahn.github.io/bird-away-blog/](https://mattsahn.github.io/bird-away-blog/)\n\n- Raspberry Pi 4 (or earlier) on Wi-Fi.\n- Relay module wired to one Pi GPIO pin and ground. Active-high or active-low is configurable.\n- RTSP-capable IP camera reachable on the same network.\n- Solenoid valve on its own power supply, switched by the relay. The Pi must not source current to the valve directly.\n- Sprinkler aimed to spray over and around the pool when the valve opens.\n- optional - temperature/humidity sensor for monitoring ambient conditions in the enclosure\n- Momentary switch (the contact pair on the same panel-mount\nbutton) for a manual sprinkler trigger. One contact → GPIO pin (default\n`23`\n\n), 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.\n\nOn the Pi:\n\n```\nsudo apt update\nsudo apt install -y python3-venv ffmpeg swig liblgpio-dev\n\ngit clone <this repo> /home/pi/git/bird-away\ncd /home/pi/git/bird-away\npython3 -m venv .venv\n.venv/bin/pip install -r requirements.txt\n\ncp .env.example .env          # then edit: OPENROUTER_API_KEY, RTSP_URL\ncp config.yaml.example config.yaml   # then edit GPIO pin, durations, etc.\n```\n\n`swig`\n\nand `liblgpio-dev`\n\nare needed so `pip`\n\ncan build the `lgpio`\n\nwheel,\nwhich gpiozero uses as its GPIO backend on Raspberry Pi OS Bookworm/Trixie.\nWithout it gpiozero falls back to an experimental native pin factory and\nprints `PinFactoryFallback`\n\nwarnings on startup.\n\nThe clone path above (`/home/pi/git/bird-away`\n\n) matches the paths baked into\n`systemd/bird-away.service`\n\n. If you install elsewhere, edit `WorkingDirectory`\n\n,\n`EnvironmentFile`\n\n, and `ExecStart`\n\nin that unit to match.\n\n`.env`\n\n(secrets, never committed):\n\n`OPENROUTER_API_KEY`\n\n— your OpenRouter API key ([https://openrouter.ai](https://openrouter.ai)). Routes to whichever model`detector_model`\n\nselects.`RTSP_URL`\n\n— full RTSP URL with credentials, e.g.`rtsp://user:pass@192.168.1.50:554/stream`\n\n.`R2_ACCESS_KEY_ID`\n\n/`R2_SECRET_ACCESS_KEY`\n\n— only required if`r2_enabled: true`\n\nin`config.yaml`\n\n. See[Remote publishing](#remote-publishing-cloudflare-r2).\n\n`config.yaml`\n\n(tunables):\n\n`interval_seconds`\n\n— how often to sample (default`60`\n\n).`spray_duration`\n\n— relay-on time in seconds when a bird is seen (default`3`\n\n).`pre_spray_seconds`\n\n— seconds of video recorded before the spray fires, so the clip captures the moment leading up to it (default`3`\n\n).`post_spray_seconds`\n\n— seconds of video recorded after the spray fires (default`4`\n\n). Total clip length is`pre_spray_seconds + spray_duration + post_spray_seconds`\n\n.`gpio_pin`\n\n— BCM pin number wired to the relay input (default`17`\n\n).`relay_active_high`\n\n—`true`\n\nif the relay closes on logic-high,`false`\n\nif active-low (most cheap relay modules are active-low — check yours).`capture_dir`\n\n— where images and clips are saved (default`./captures`\n\n).`detector_model`\n\n— OpenRouter model id; default`anthropic/claude-haiku-4.5`\n\n.`detector_base_url`\n\n— OpenAI-compatible base URL; default`https://openrouter.ai/api/v1`\n\n. Override to point at a different provider.`detector_prompt`\n\n— system prompt sent to the vision model. Use a YAML literal block (`|`\n\n) to write it across multiple lines. See[Tuning the prompt](#tuning-the-prompt)for what makes a good one.`daytime_only`\n\n— when`true`\n\n(default), only run detection between 07:00 and 19:00 local time. Set`false`\n\nto run 24/7 (e.g. for testing or with an IR camera).`motion_enabled`\n\n,`motion_threshold`\n\n,`motion_downscale`\n\n— 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`\n\nis a reasonable start.`status_led_enabled`\n\n/`status_led_pin`\n\n— drive a status LED on a separate GPIO pin (default`24`\n\n). 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. Set`status_led_enabled: false`\n\nif you don't have the LED wired.`trigger_button_enabled`\n\n/`trigger_button_pin`\n\n— manual sprinkler trigger via a momentary switch on a GPIO pin (default`23`\n\n). 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. Set`trigger_button_enabled: false`\n\nif no switch is wired.`retention_days`\n\n— delete local`detection-*.jpg`\n\n/`event-*.mp4`\n\nfiles older than this many days. Sweep runs at startup and hourly thereafter. Default`7`\n\n. Set to`0`\n\n(or negative) to keep everything. R2 objects are unaffected — manage their lifecycle in the bucket settings.`healthcheck_url`\n\n/`healthcheck_interval_seconds`\n\n— liveness ping to[healthchecks.io](https://healthchecks.io)(or any URL accepting a GET). Pinged after each successful loop iteration, rate-limited to once per interval (default`300s`\n\n). 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`\n\n—`INFO`\n\nor`DEBUG`\n\n.- R2 publishing keys (\n`r2_enabled`\n\n,`r2_account_id`\n\n,`r2_bucket`\n\n,`r2_public_base_url`\n\n,`r2_key_prefix`\n\n) — see[Remote publishing](#remote-publishing-cloudflare-r2). `delete_after_upload`\n\n— when`true`\n\n, skip the local JPEG write entirely and delete each MP4 right after R2 confirms the upload. Requires`r2_enabled: true`\n\n. Pair with`capture_dir: /dev/shm/bird-away`\n\nfor zero SD-card writes. See[Minimizing SD-card writes](#minimizing-sd-card-writes).\n\nOptional. When `r2_enabled: true`\n\n, each detection's snapshot JPEG and event\nMP4 are uploaded to a Cloudflare R2 bucket so you can view them from any\nbrowser. R2 is S3-compatible with no egress fees and a 10 GB free tier — for\ntypical usage this stays free indefinitely with a 30-day lifecycle rule.\n\n-\nCloudflare → R2 → create a bucket (e.g.\n\n`bird-away`\n\n). In the bucket's settings, enable the**R2.dev subdomain** and copy the`https://pub-<hash>.r2.dev`\n\nURL. -\nR2 → \"Manage R2 API tokens\" →\n\n**Create API token**, scope** Object Read & Write**to that bucket. Save the** Access Key ID**and** Secret Access Key**— they're shown once. -\nAppend to\n\n`.env`\n\n:\n\n```\nR2_ACCESS_KEY_ID=...\nR2_SECRET_ACCESS_KEY=...\n```\n\n-\nAdd to\n\n`config.yaml`\n\n:\n\n```\nr2_enabled: true\nr2_account_id: <hex string from R2 dashboard>\nr2_bucket: <bucket name>\nr2_public_base_url: https://pub-<hash>.r2.dev\n```\n\n-\nOptional: in the R2 dashboard, add a lifecycle rule \"delete objects older than 30 days\" to keep storage under the free tier.\n\nEach detection produces two objects under\n`<r2_key_prefix>/YYYY-MM-DD/`\n\n: `detection-<ts>.jpg`\n\nand `event-<ts>.mp4`\n\n.\nDefault `r2_key_prefix`\n\nis `events`\n\n. By default R2 is additive — local\ncopies in `captures/`\n\nare also written and pruned by `retention_days`\n\n. To\nmake R2 the only copy and stop writing to the SD card, see\n[Minimizing SD-card writes](#minimizing-sd-card-writes).\n\nWhen R2 publishing is enabled, the service also maintains a `manifest.json`\n\nin the bucket (at `<r2_key_prefix>/manifest.json`\n\n). The manifest is a JSON\nindex of the last 500 detection events with public URLs for each snapshot and\nvideo. `web/index.html`\n\nis a self-contained dashboard that reads this\nmanifest and renders an interactive event timeline.\n\nThe dashboard is a static site — deploy the `web/`\n\ndirectory to Vercel:\n\n```\ncd web\nnpx vercel --prod\n```\n\nSet the `MANIFEST_URL`\n\nenvironment variable on the Vercel project so\nvisitors see data immediately without any manual configuration:\n\n```\ncd web\nnpx vercel env add MANIFEST_URL production\n# paste: https://pub-<hash>.r2.dev/events/manifest.json\nnpx vercel --prod        # redeploy to pick up the new env var\n```\n\nOr set it in the Vercel dashboard under Project → Settings → Environment\nVariables. The build step writes this value into `dashboard-config.json`\n\n,\nwhich the dashboard loads automatically on startup.\n\nVisitors can still override the URL via the settings modal or the\n`?manifest=`\n\nquery parameter.\n\nThe dashboard is a single `index.html`\n\nwith no build step, so it works\nanywhere: open it locally as a file, deploy to GitHub Pages or Netlify,\nor upload it to the same R2 bucket as your events.\n\n**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 (see[Real-time monitoring](#real-time-monitoring)).\n\nIf 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:\n\n```\n[\n  {\n    \"AllowedOrigins\": [\"*\"],\n    \"AllowedMethods\": [\"GET\"],\n    \"AllowedHeaders\": [\"*\"]\n  }\n]\n```\n\nR2.dev public subdomains include permissive CORS headers by default, so this is usually not needed unless you've customized the bucket's CORS settings.\n\nIf you suspect the detector is missing birds, the **Live** tab gives you a\nrolling view of *every* frame the loop captured in the last 30 minutes — each\nlabeled **Bird** or **No bird** — so you can manually spot false negatives.\nThese frames are full-resolution (the detector only ever sees a 512px copy, so\nsmall birds can hide there) and carry no video.\n\nEnable it on the device (`config.yaml`\n\n):\n\n```\nrealtime_enabled: true        # requires r2_enabled: true\nrealtime_key_prefix: realtime # frames + manifest land under this prefix\nrealtime_window_minutes: 30   # how much the Live tab shows\nrealtime_max_image_dim: 0     # 0 = full-res; e.g. 1280 to shrink\n```\n\nFrames upload to `realtime/frames/<ts>.jpg`\n\nand are indexed in\n`realtime/manifest.json`\n\n— kept entirely separate from `events/`\n\n, which stays\nreal-detections-only. The dashboard derives the real-time manifest URL from the\nmain manifest URL automatically (swapping `/events/`\n\nfor `/realtime/`\n\n); override\nit in Settings if you use a custom prefix.\n\n**Set up cleanup so old frames don't accumulate.** R2 lifecycle rules expire\nobjects by whole days (1-day minimum), so a true 30-minute purge isn't possible\nserver-side — the Live tab provides the 30-minute *view*, and a lifecycle rule\ndeletes the underlying objects a day later. In the Cloudflare dashboard: R2 →\nyour bucket → Settings → Object lifecycle rules → **Add rule**, set the prefix\nto `realtime/frames/`\n\nand \"Delete objects\" **1 day** after creation. Scoping the\nrule to `realtime/frames/`\n\nleaves `realtime/manifest.json`\n\nuntouched (it's\nrewritten every cycle anyway). Storage stays well within R2's free tier — one\nday of frames is a few hundred MB at most, and R2 egress is free.\n\nWhen deploying the dashboard to Vercel you can optionally set a\n`REALTIME_MANIFEST_URL`\n\nenvironment variable (alongside `MANIFEST_URL`\n\n); the\nbuild bakes it into `dashboard-config.json`\n\n. It's optional — the derive\nfallback covers the default `events`\n\n/`realtime`\n\nprefixes.\n\n```\n.venv/bin/python -m src.main\n```\n\nLogs go to stdout. Output files appear under `captures/`\n\n.\n\nRun these one at a time to verify each piece of the chain.\n\n```\n.venv/bin/python scripts/test_camera.py        # writes /tmp/bird-away-test.jpg\n.venv/bin/python scripts/test_detector.py path/to/sample.jpg   # prints yes/no\n.venv/bin/python scripts/test_sprinkler.py 1   # clicks relay for 1 second\n.venv/bin/python scripts/test_status_led.py    # runs each LED pattern in sequence\n.venv/bin/python scripts/test_trigger_button.py # press to light LED, release to log\n```\n\n`scripts/test_models.py`\n\nruns a list of vision models against a single saved\nframe so you can see how each one handles the same input. Useful when picking\n`detector_model`\n\nor tuning `detector_prompt`\n\nagainst tricky frames (small\ndistant birds, glare on water, dawn light, etc.).\n\nFirst-time setup — copy the example config (the actual file is gitignored so your local tweaks stay out of the repo):\n\n```\ncp scripts/models_config.yaml.example scripts/models_config.yaml\n```\n\nThen run against any local image:\n\n```\n.venv/bin/python scripts/test_models.py captures/detection-20260426T131922Z.jpg\n```\n\nEach model gets two prompts: a yes/no classifier (the project's bird detector\nby default) and a freeform description (asks the model to describe the scene,\nanimals, people, actions). Both prompts and the model list live in\n`scripts/models_config.yaml`\n\nand can be overridden — pass `--config <path>`\n\nto use an alternate file. The script prints the raw response from each model\nalong with the input resolution, elapsed time, and token usage so you can\ncompare quality, cost, and latency at a glance.\n\n```\nsudo cp systemd/bird-away.service /etc/systemd/system/\nsudo systemctl daemon-reload\nsudo systemctl enable --now bird-away\njournalctl -u bird-away -f\n```\n\nThe unit runs as user `pi`\n\nin group `gpio`\n\n. Adjust `User=`\n\n, `Group=`\n\n, and the\npaths in `bird-away.service`\n\nif your install location differs.\n\nFor unattended deployments, SD-card wear is the most common cause of Pi\ndeath — every snapshot JPEG and event MP4 written to `captures/`\n\nconsumes\nwrite cycles. Cards typically tolerate 10k-100k writes per cell, and a busy\nday at the pool can produce dozens of MB; over months that adds up.\n\nTwo settings together eliminate ~all capture-related SD writes:\n\n```\nr2_enabled: true             # uploads still go to R2\ndelete_after_upload: true    # don't keep a local copy\ncapture_dir: /dev/shm/bird-away   # tmpfs (RAM); never touches the SD card\n```\n\nHow it works:\n\n**JPEG snapshot.** With`delete_after_upload: true`\n\n, the bytes are uploaded straight from memory via`put_object`\n\n—`write_bytes`\n\nis never called and`image_path`\n\nnever exists on disk.**Event MP4.** ffmpeg has to write to a path, so the file lives briefly in`capture_dir`\n\nwhile it records. With`capture_dir`\n\npointed at`/dev/shm`\n\n(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 so`retention_days`\n\ncan 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`\n\n).\n\nTradeoff to know: with `delete_after_upload: true`\n\n, an extended R2 outage\nloses captures (failed uploads stay on disk only until `retention_days`\n\nexpires, vs. the default behavior where they persist until you manually\ncopy them off). For a deterrent system this is usually the right trade —\nSD-card death is permanent, a missed video clip is not.\n\nIf you want to keep the local copies (for debugging, or because you don't\nhave R2 set up), leave `delete_after_upload: false`\n\n(the default) and let\n`retention_days`\n\nbound disk usage. You can still point `capture_dir`\n\nat\n`/dev/shm/bird-away`\n\nto get RAM-backed storage with retention-based\ncleanup — useful if you want a few days of local history without SD wear.\n\nFor setups that need to run for months without intervention, the unit ships\nwith two watchdogs and `Restart=always`\n\n. The defaults Just Work, but two\nextra one-time setup steps make recovery faster.\n\n**Software watchdog (already wired up).** The service uses `Type=notify`\n\nand\nthe main loop pings `WATCHDOG=1`\n\nat the top of each iteration and once per\nsecond while sleeping. If the loop hangs for more than `WatchdogSec=120`\n\n,\nsystemd kills and restarts the process. Combined with `Restart=always`\n\nthis\nalso catches clean exits, OOM kills, segfaults — anything short of a kernel\nhang.\n\n**Hardware watchdog (one-time system config).** The Pi's BCM watchdog will\nreset the SoC if the kernel itself hangs (rare but possible — bad SD card\nsector, GPU lockup). On Pi 4/5 with current Pi OS, `/dev/watchdog`\n\nis\nalready exposed; you just need to tell systemd to use it:\n\n```\nsudo sed -i 's/^#RuntimeWatchdogSec=off/RuntimeWatchdogSec=15/' /etc/systemd/system.conf\nsudo systemctl daemon-reexec\n```\n\nOn older Pi OS (Bullseye and earlier) you may also need\n`dtparam=watchdog=on`\n\nin `/boot/firmware/config.txt`\n\n(or `/boot/config.txt`\n\non pre-Bookworm) followed by a reboot. Check `ls /dev/watchdog`\n\nfirst — if\nit exists, you're already set.\n\n**Liveness alerting.** Set `healthcheck_url`\n\nin `config.yaml`\n\nto a free\n[healthchecks.io](https://healthchecks.io) URL — see the config docs above.\nThis catches the failure mode where the loop is alive and pinging the\nwatchdog but every iteration is throwing (e.g. RTSP camera unreachable).\n\n**False positives**(sprays when no birds): tighten`detector_prompt`\n\nin`config.yaml`\n\n(see[Tuning the prompt](#tuning-the-prompt)), or switch`detector_model`\n\nto a stronger vision model (e.g.`anthropic/claude-sonnet-4.5`\n\n).**Birds aren't fazed**: increase`spray_duration`\n\n, or check that the spray pattern actually covers where they land.**Storage filling up**: tune`retention_days`\n\n(default`7`\n\n). The service prunes`captures/`\n\nat startup and hourly. R2 lifecycle rules handle the cloud copy.\n\n`detector_prompt`\n\nis the system message sent to the vision model on every\nframe that passes the motion gate. The model receives the prompt plus a single\nstill image, and the parser in `src/detector.py`\n\ncalls it a bird if the reply\nstarts with `yes`\n\n(case-insensitive).\n\nA few rules of thumb:\n\n**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 stronger`detector_model`\n\ninstead.**Pin the output format.** End with something like \"Output only the single word.\" Models occasionally drift to \"Yes.\" or \"Yes, I see…\" — those still match`startswith(\"yes\")`\n\n, but rambling answers like \"I'm not sure…\" parse as`no`\n\n.**Iterate against saved frames.** Every detection writes a still to`captures/`\n\n. Point`scripts/test_detector.py captures/<file>.jpg`\n\nat known bird and non-bird frames to sanity-check a prompt change before restarting the service.**Restart after editing.**`config.yaml`\n\nis read once at startup —`sudo systemctl restart bird-away`\n\nto pick up changes.\n\n- 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.\n- Confirm the relay polarity (active-high vs active-low) before attaching the valve. A relay stuck closed will leave water flowing.\n- The\n`Sprinkler`\n\nclass drives the GPIO low on`__exit__`\n\nand 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.\n\n```\n                  ┌──────────────────────────────────────────────────────┐\n                  │             Raspberry Pi (bird-away.service)         │\n                  └──────────────────────────────────────────────────────┘\n\n   Configuration\n   ─────────────\n       .env (secrets)            config.yaml (tunables)\n       ─────────────             ──────────────────────\n       OPENROUTER_API_KEY        interval_seconds, gpio_pin, spray_duration,\n       RTSP_URL                  detector_model, detector_prompt, motion_*,\n       R2_ACCESS_KEY_ID  *       daytime_only, r2_*  (* if r2_enabled), …\n       R2_SECRET_ACCESS_KEY  *\n              │                          │\n              └─────────────┬────────────┘\n                            ▼\n                    src/config.py  ──►  Config dataclass (passed to all modules)\n\n   Per-iteration flow  (loops every cfg.interval_seconds in src/main.py)\n   ─────────────────────────────────────────────────────────────────────\n\n   src/camera.py runs a background thread (PyAV / libav) that holds one\n   long-lived RTSP/TCP session and continuously decodes frames. The main\n   loop pulls the most recent JPEG from RAM in O(1) — no per-iteration\n   handshake.\n\n       ┌──────────────┐   persistent\n       │ RTSP camera  │◄───────────────►  src/camera.py\n       │  (IP cam)    │   H.264 stream    Camera class:\n       └──────────────┘                     • bg thread: av.open + decode\n                                            • capture_frame() → latest JPEG\n                                              │\n                                              ▼\n                                  daytime_only gate (07:00-19:00 local)\n                                              │\n                                  outside ◄───┴───► inside\n                                     │                │\n                                     ▼                ▼\n                                   sleep      src/motion.py\n                                              MotionDetector.check()\n                                              (frame-diff gate, local)\n                                                      │\n                                  score < thresh ◄────┴────► score ≥ thresh\n                                        │                          │\n                                        ▼                          ▼\n                                      sleep             src/detector.py\n                                                        downscale → 512px\n                                                        Detector.is_bird_present()\n                                                                  │ HTTPS\n                                                                  ▼\n                                                    ┌──────────────────────┐\n                                                    │   OpenRouter API     │\n                                                    │ (Claude vision model │\n                                                    │  per detector_model) │\n                                                    └──────────┬───────────┘\n                                                               │ \"yes\" / \"no\"\n                                                               ▼\n                                                    ┌──────────┴──────────┐\n                                                    ▼  no                 ▼  yes\n                                                  sleep             _handle_event()\n                                                                          │\n                              ┌───────────────────────────────────────────┤\n                              ▼                                           ▼\n                      pause bg thread,                        save still + spawn\n                      spawn ffmpeg                            ffmpeg recorder\n                      (separate RTSP                          (captures/event-*.mp4)\n                       session, camera                                    │\n                       only allows 1-2)                          ┌────────┴────────┐\n                              │                                  ▼                 ▼\n                              ▼                          src/sprinkler.py    src/uploader.py\n                       resume bg thread                  GPIO via gpiozero   R2Uploader\n                       after ffmpeg                      + lgpio             (boto3 → S3 API)\n                                                                │                 │\n                                                                ▼                 ▼\n                                                          GPIO pin 17       Cloudflare R2\n                                                                            (public bucket)\n\n   Hardware chain (off the Pi)\n   ───────────────────────────\n\n       GPIO 17 ──► Relay module ──► 12V solenoid valve ──► sprinkler ──► pool surface\n                   (active high/                ▲\n                    low configurable)           │\n                                          separate PSU\n                                          (never from Pi)\n\n   Background pieces\n   ─────────────────\n       systemd/bird-away.service  →  runs `python -m src.main` as user pi (group gpio),\n                                     restarts on failure, logs to journal\n       captures/                  →  unbounded; prune via cron / tmpfiles.d\n       scripts/test_*.py          →  per-stage smoke tests (camera / detector / sprinkler)\n```\n\n", "url": "https://wpnews.pro/news/bird-away-raspberry-pi-powered-water-based-bird-deterrent", "canonical_source": "https://github.com/mattsahn/bird-away", "published_at": "2026-07-07 11:10:47+00:00", "updated_at": "2026-07-07 11:30:07.993680+00:00", "lang": "en", "topics": ["computer-vision", "artificial-intelligence", "developer-tools", "ai-products"], "entities": ["Raspberry Pi", "Claude", "OpenRouter", "Cloudflare R2", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/bird-away-raspberry-pi-powered-water-based-bird-deterrent", "markdown": "https://wpnews.pro/news/bird-away-raspberry-pi-powered-water-based-bird-deterrent.md", "text": "https://wpnews.pro/news/bird-away-raspberry-pi-powered-water-based-bird-deterrent.txt", "jsonld": "https://wpnews.pro/news/bird-away-raspberry-pi-powered-water-based-bird-deterrent.jsonld"}}