diff --git a/README.md b/README.md index 95420aa..ae89c92 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ *A live bird collage from your apartment window.* -A frontend overlay for [BirdNET-Pi](https://github.com/Nachtzuster/BirdNET-Pi). Listens to your balcony, identifies every passing bird with Cornell's [BirdNET](https://birdnet.cornell.edu/), and renders the detections as a tile-packed kachō-e print at `http://birdnet.local/collage/`. Full project writeup at [teddywarner.org/Projects/AvianVisitors](https://teddywarner.org/Projects/AvianVisitors/). +A fork of [BirdNET-Pi](https://github.com/Nachtzuster/BirdNET-Pi) that adds a tile-packed kachō-e collage UI. Listens to your balcony with Cornell's [BirdNET](https://birdnet.cornell.edu/), illustrates every detected species in the style of Edo-period Japanese woodblock prints, and renders them sized by call count. See it running at [bird.onethreenine.net](https://bird.onethreenine.net). avianvisitors collage @@ -10,101 +10,85 @@ A frontend overlay for [BirdNET-Pi](https://github.com/Nachtzuster/BirdNET-Pi). ## BOM -| Qty | Description | Price | Link | Notes | -|-----|-------------|-------|------|-------| -| 1 | Raspberry Pi (4B / 5 / Zero 2W) | ~$35–80 | [Raspberry Pi](https://www.raspberrypi.com/products/) | 4B+ recommended | -| 1 | Micro SD Card | ~$10 | [Amazon](https://a.co/d/08aiL8c) | ≥32 GB | -| 1 | USB Lavalier Microphone | $14.99 | [Amazon](https://www.amazon.com/dp/B0176NRE1G) | The one I used. Any USB mic with a half-decent capsule works. | -| 1 | Pi Power Supply | ~$10 | — | Matched to your Pi model | +| Qty | Description | Price | Link | +|-----|-------------|-------|------| +| 1 | Raspberry Pi (4B / 5 / Zero 2W) | ~$35–80 | [Raspberry Pi](https://www.raspberrypi.com/products/) | +| 1 | Micro SD Card (≥32 GB) | ~$10 | [Amazon](https://a.co/d/08aiL8c) | +| 1 | USB lavalier microphone | $14.99 | [Amazon](https://www.amazon.com/dp/B0176NRE1G) | +| 1 | Pi power supply | ~$10 | — | -You'll also need a [Gemini API key](https://aistudio.google.com/apikey) (free tier is enough for an apartment-scale lifelist) and, optionally, an [eBird API key](https://ebird.org/api/keygen) for region-filtering the species pre-gen. +Optional: a [Gemini API key](https://aistudio.google.com/apikey) (free tier covers a regional regen) for restyling or adding species, and an [eBird API key](https://ebird.org/api/keygen) for filtering the species list to your area. --- -## 1. Install BirdNET-Pi +## 1. Flash the SD card -Follow the [BirdNET-Pi installation guide](https://github.com/mcguirepr89/BirdNET-Pi/wiki/Installation-Guide) on your Pi. Confirm the stock BirdNET-Pi UI is reachable at `http://birdnet.local/` before continuing. +Use [Raspberry Pi Imager](https://www.raspberrypi.com/software/). Pick **Raspberry Pi OS Lite (64-bit)**. In the customisation dialog, set: + +- Username (your choice — the installer uses whatever you pick) +- WiFi SSID + password +- Hostname: `birdnet` (so it's reachable at `birdnet.local`) +- Enable SSH with password auth + +Boot the Pi. --- -## 2. Clone AvianVisitors +## 2. Run the installer + +SSH in and run one command: ```bash -ssh monalisa@birdnet.local -git clone https://github.com/Twarner491/AvianVisitors.git ~/AvianVisitors -cd ~/AvianVisitors +ssh @birdnet.local +curl -s https://raw.githubusercontent.com/Twarner491/AvianVisitors/avian-visitors/newinstaller.sh | bash ``` +The installer clones this fork, sets up BirdNET-Pi (audio capture, model, web UI), and symlinks the AvianVisitors overlay into the Caddy web root. Takes 20–40 minutes. The Pi reboots when done. + +Open `http://birdnet.local/avian/` from any device on your network. The BirdNET-Pi stock UI sits at `http://birdnet.local/`. + --- -## 3. Pre-generate illustrations for your region (optional but recommended) +## 3. (Optional) Restyle the illustrations -The repo ships with ~450 bundled illustrations. To restyle, regenerate, or add region-specific species, run the pregen script with your Gemini key. +The repo ships with 450 bundled illustrations covering most North-American species. To change the visual style or add region-specific birds: ```bash export GEMINI_API_KEY='your-gemini-key' -# Generate every species BirdNET-Pi knows: -python3 avian/scripts/pregen.py --labels ~/BirdNET-Pi/model/labels.txt +# Re-render every species in BirdNET-Pi's model: +python3 ~/BirdNET-Pi/avian/scripts/pregen.py --labels ~/BirdNET-Pi/model/labels.txt --force -# Or filter to species observed in your state/county via eBird: +# Or only species eBird has observed in your region: export EBIRD_API_KEY='your-ebird-key' -python3 avian/scripts/pregen.py \ +python3 ~/BirdNET-Pi/avian/scripts/pregen.py \ --labels ~/BirdNET-Pi/model/labels.txt \ - --ebird-region US-CA # state, or US-CA-085 for a county + --ebird-region US-CA # state, or US-CA-085 for a county ``` -To change the art style, edit [`avian/scripts/prompt.template.md`](avian/scripts/prompt.template.md) and re-run with `--force`. The template has `{sci_name}`, `{com_name}`, and `{pose}` placeholders; swap the body for whatever style you want (woodblock, ink wash, scientific plate, etc.). +Style is a single editable file at [`avian/scripts/prompt.template.md`](avian/scripts/prompt.template.md). Replace the body, re-run pregen with `--force`, done. --- -## 4. Install the frontend +## 4. (Optional) Forward off your LAN -One-shot installer — drops the static collage UI into `/var/www/avian`, the JSON shims into BirdNET-Pi's PHP root, and a Caddy snippet that mounts everything at `http://birdnet.local/collage/`. +See [`avian/forwarding/`](avian/forwarding/) for three independent recipes: -```bash -bash avian/scripts/install.sh -``` - -That's it. Open `http://birdnet.local/collage/` from any device on your network. As BirdNET-Pi accumulates detections, they fade into the collage — sized by how often each species has been heard. - ---- - -## 5. Loading the Gemini key on the Pi (for live JIT generation) - -The bundled illustrations cover most North-American species. For new birds the Pi hasn't seen before, AvianVisitors can render them on the fly. Drop the key into a systemd environment file so the JIT generator picks it up: - -```bash -sudo mkdir -p /etc/avian -echo "GEMINI_API_KEY=your-gemini-key" | sudo tee /etc/avian/env >/dev/null -sudo chmod 600 /etc/avian/env -sudo systemctl restart php8.2-fpm -``` - -The PHP shim at `avian/api/cutout.php` reads `/etc/avian/env` and falls through to a Wikipedia photo if the key is missing — so you can run completely Gemini-free if you'd rather. - ---- - -## 6. Optional: forward off your local network - -For public access, Home Assistant integration, or MQTT fan-out, see [`avian/forwarding/`](avian/forwarding/). Each recipe is independent: - -- **Cloudflare Tunnel** — public HTTPS URL, no port forwarding, optional Cloudflare Access for password protection. -- **Home Assistant REST sensor** — surfaces the latest detection as a sensor.value for automations. -- **MQTT bridge** — publishes every new detection to a broker as JSON. +- **Cloudflare Tunnel** — public HTTPS URL, no port forwarding, optional Cloudflare Access password gate. +- **Home Assistant REST sensor** — surfaces the latest detection for automations. +- **MQTT bridge** — publishes every new detection as JSON. --- ## Repo layout ``` -avian/ -├── frontend/ # static HTML/JS/CSS for the collage -├── assets/ # bundled illustrations + cutouts + masks -├── api/ # PHP shims served by BirdNET-Pi's existing PHP-FPM -├── scripts/ # installer + Gemini pregen + editable prompt -├── caddy/ # snippet mounting /collage on existing Caddy -└── forwarding/ # optional HA / MQTT / Cloudflare configs +avian/ # everything we add to BirdNET-Pi +├── frontend/ # static HTML/JS/CSS for the collage +├── assets/ # 450 bundled illustrations + cutouts + masks +├── api/ # PHP shims served by BirdNET-Pi's PHP-FPM +├── scripts/ # pregen.py + editable prompt template +└── forwarding/ # optional HA / MQTT / Cloudflare configs ``` Everything outside `avian/` is upstream BirdNET-Pi. @@ -113,7 +97,7 @@ Everything outside `avian/` is upstream BirdNET-Pi. ## License -CC-BY-NC-SA-4.0 (inherited from BirdNET-Pi). Non-commercial use only; share-alike on derivatives. See [`LICENSE`](LICENSE) and [`README.upstream.md`](README.upstream.md). +CC-BY-NC-SA-4.0, inherited from [BirdNET-Pi's upstream license](https://github.com/Nachtzuster/BirdNET-Pi/blob/main/LICENSE). Non-commercial use only; share-alike on derivatives. See the [BirdNET-Pi README](https://github.com/Nachtzuster/BirdNET-Pi/blob/main/README.md) for the full Cornell attribution. --- diff --git a/avian/api/birdnet-api.php b/avian/api/birdnet-api.php index 8dcc80a..bb0848a 100644 --- a/avian/api/birdnet-api.php +++ b/avian/api/birdnet-api.php @@ -1,26 +1,35 @@ : detail page for one species +// recent — &hours=N (default 24): species heard in the window +// species — &sci=: per-species detail page +// timeseries — &days=N: daily detection counts per species +// firstseen — every species' earliest detection +// +// Default LAN deploy ships without auth. If you've exposed the Pi via +// Cloudflare or a tunnel, add a Caddy `basic_auth` matcher around the +// /avian/api/* path — see avian/forwarding/. declare(strict_types=1); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: public, max-age=30'); -$DB_PATH = '/home/monalisa/BirdNET-Pi/scripts/birds.db'; +// SCRIPT_FILENAME on the Pi resolves through the symlink to +// $HOME/BirdNET-Pi/avian/api/birdnet-api.php — walk three dirs up to +// reach the install root, then point at scripts/birds.db. Lets a Pi +// installed under any username (the BirdNET-Pi installer uses $USER, +// not a fixed name) work without editing this file. +$DB_PATH = dirname(__DIR__, 3) . '/scripts/birds.db'; +// Fallback if the symlink layout ever changes — keeps the most common +// install path working even if SCRIPT_FILENAME oddities trip __DIR__. +if (!file_exists($DB_PATH)) { + $alt = getenv('HOME') . '/BirdNET-Pi/scripts/birds.db'; + if (file_exists($alt)) $DB_PATH = $alt; +} if (!file_exists($DB_PATH)) { http_response_code(503); diff --git a/avian/api/cutout.php b/avian/api/cutout.php index 6dbd6fe..145142a 100644 --- a/avian/api/cutout.php +++ b/avian/api/cutout.php @@ -1,20 +1,17 @@ ). +// AvianVisitors — bird image resolver. // -// Lives in the Caddy file-server root so the BirdNET-Pi auto-update doesn't -// touch it. Each new species hits this once: download Wikipedia/Macaulay -// image → rembg via /usr/local/bin/rembg-cli → cache transparent PNG to -// disk under cutouts/. Subsequent requests are instant readfile() from cache. +// Lookup chain for /avian/api/cutout.php?sci=Calypte+anna: +// 1. ../assets/illustrations/.png (450+ bundled kachō-e renders) +// 2. ../assets/cutouts/.png (background-removed photo) +// 3. cached rembg of a Wikipedia photo at $HOME/BirdSongs/Extracted/cutouts/ +// 4. fresh Wikipedia → rembg → cache (skipped gracefully if rembg unset) // -// Auth: the Caddy site block 403s anything missing X-BirdNET-Proxy-Token at -// the top, so this script inherits the Worker-only access guarantee. +// The frontend's points here for every species — bundled +// hits return instantly; cold misses fall through to the dynamic path. // -// Pre-reqs (run once via /install/rembg-setup.sh): -// - /home/monalisa/rembg-env/bin/rembg (Python venv) -// - /usr/local/bin/rembg-cli (wrapper) -// - /home/monalisa/BirdSongs/Extracted/cutouts/ (cache dir, monalisa-owned) +// Default LAN deploy ships without auth. To expose publicly, gate +// /avian/api/* with basic_auth in your Caddyfile — see avian/forwarding/. declare(strict_types=1); @@ -24,36 +21,63 @@ if ($sci === '') { echo 'sci required'; exit; } +// Binomial / trinomial pattern. Rejects path-traversal payloads and +// junk before any filesystem or upstream lookup. +if (!preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci)) { + http_response_code(400); + echo 'invalid sci'; + exit; +} -// Slugify scientific name for the cache filename. +// Slugify scientific name for filename + cache key. $slug = preg_replace('/[^a-z0-9]+/', '-', strtolower($sci)); $slug = trim((string)$slug, '-'); -$cacheDir = '/home/monalisa/BirdSongs/Extracted/cutouts'; -$cachePath = "$cacheDir/$slug.png"; function serve_png(string $path): void { header('Content-Type: image/png'); - header('Cache-Control: public, max-age=2592000'); - header('Content-Length: ' . filesize($path)); + header('Cache-Control: public, max-age=86400'); + header('Content-Length: ' . (string)filesize($path)); readfile($path); exit; } -// Cache hit — short-circuit. +// 1. Bundled illustration (the kachō-e PNG the repo ships with). +$bundled = dirname(__DIR__) . "/assets/illustrations/$slug.png"; +if (is_file($bundled) && filesize($bundled) > 1024) { + serve_png($bundled); +} +// 2. Bundled cutout (background-removed photo, fallback for species +// without an illustration). +$cutout = dirname(__DIR__) . "/assets/cutouts/$slug.png"; +if (is_file($cutout) && filesize($cutout) > 1024) { + serve_png($cutout); +} + +// 3. Dynamic cache from a previous Wikipedia + rembg run. +$cacheDir = getenv('HOME') . '/BirdSongs/Extracted/cutouts'; +$cachePath = "$cacheDir/$slug.png"; if (is_file($cachePath) && filesize($cachePath) > 1024) { serve_png($cachePath); } -// Make sure the cache dir exists (first run). +// 4. Fresh Wikipedia fetch + rembg. Skipped if rembg-cli isn't on +// PATH — the resolver simply returns a 404 in that case rather +// than burning a Wikipedia request we can't use. +$rembg = '/usr/local/bin/rembg-cli'; +if (!is_executable($rembg)) { + http_response_code(404); + echo 'no illustration bundled for ' . htmlspecialchars($sci) . ' (install rembg-cli to enable Wikipedia fallback)'; + exit; +} + if (!is_dir($cacheDir)) @mkdir($cacheDir, 0755, true); -// Resolve a source image URL. Wikipedia summary first (free, fast, -// no auth, has a clean originalimage for most birds). +// Wikipedia's REST API asks for a contact-able identifier. Override +// via the AV_USER_AGENT env var (set in /etc/php/*/fpm/pool.d/www.conf +// or your shell) if your install hammers their endpoint at scale. +$ua = getenv('AV_USER_AGENT') ?: 'AvianVisitors/1.0 (+https://github.com/Twarner491/AvianVisitors)'; $ctx = stream_context_create([ - 'http' => [ - 'header' => "User-Agent: apartment-birds/1.0 (twarner491@gmail.com)\r\n", - 'timeout' => 12, - ], + 'http' => ['header' => "User-Agent: $ua\r\n", 'timeout' => 12], ]); $wpUrl = 'https://en.wikipedia.org/api/rest_v1/page/summary/' . rawurlencode($sci); $wpJson = @file_get_contents($wpUrl, false, $ctx); @@ -62,13 +86,20 @@ if ($wpJson !== false) { $j = json_decode($wpJson, true); $srcUrl = $j['originalimage']['source'] ?? $j['thumbnail']['source'] ?? null; } +// Defensive: only follow URLs on Wikimedia / Wikipedia hosts so a +// poisoned summary endpoint can't redirect us to arbitrary servers. +if ($srcUrl !== null) { + $host = parse_url((string)$srcUrl, PHP_URL_HOST) ?: ''; + if (!preg_match('/(?:^|\.)(?:wikimedia\.org|wikipedia\.org)$/i', $host)) { + $srcUrl = null; + } +} if (!$srcUrl) { http_response_code(404); - echo 'no upstream image for ' . htmlspecialchars($sci); + echo 'no Wikipedia photo for ' . htmlspecialchars($sci); exit; } -// Download the source image. $imgBytes = @file_get_contents($srcUrl, false, $ctx); if (!$imgBytes || strlen($imgBytes) < 1024) { http_response_code(503); @@ -76,16 +107,19 @@ if (!$imgBytes || strlen($imgBytes) < 1024) { exit; } -// Run rembg via the wrapper. We use temp files because rembg's CLI prefers -// real paths. u2netp is the lightweight model (~50MB peak RAM) — important -// on the Pi 3B+ (1GB total RAM). Call with --post-process-mask to clean -// up edges. -$tmpIn = tempnam('/tmp', 'rembg-in-') . '.jpg'; -$tmpOut = tempnam('/tmp', 'rembg-out-') . '.png'; +// rembg via the wrapper. u2netp = lightweight model (~50MB peak RAM — +// matters on the Pi 3B+). Temp files because rembg's CLI prefers +// real paths. +$tmpInBase = tempnam(sys_get_temp_dir(), 'rembg-in-'); +$tmpOutBase = tempnam(sys_get_temp_dir(), 'rembg-out-'); +@unlink($tmpInBase); @unlink($tmpOutBase); +$tmpIn = $tmpInBase . '.jpg'; +$tmpOut = $tmpOutBase . '.png'; file_put_contents($tmpIn, $imgBytes); $cmd = sprintf( - '/usr/local/bin/rembg-cli i -m u2netp -ppm %s %s 2>&1', + '%s i -m u2netp -ppm %s %s 2>&1', + escapeshellarg($rembg), escapeshellarg($tmpIn), escapeshellarg($tmpOut) ); @@ -96,13 +130,13 @@ if (!is_file($tmpOut) || filesize($tmpOut) < 1024) { @unlink($tmpOut); http_response_code(500); header('Content-Type: text/plain'); - echo "rembg failed:\n" . ($out ?? '(no output)'); + echo "rembg failed (see your Pi's logs for details)"; + error_log("rembg failed for $sci: " . ($out ?? '(no output)')); exit; } -// 1. Alpha-crop to bounding box so each PNG == the bird's actual shape -// (no transparent padding around it). Lets the layout pack tiles tight. -// 2. Resize down to a max edge of 800px so the cache stays small. +// Tight-crop to the bird's bounding box + downscale to 800px max edge +// so cache stays small. $im = @imagecreatefrompng($tmpOut); if ($im !== false) { $cropped = @imagecropauto($im, IMG_CROP_TRANSPARENT); @@ -128,7 +162,8 @@ if ($im !== false) { imagedestroy($im); } -// Cache + serve. -copy($tmpOut, $cachePath); -@unlink($tmpOut); +// Atomic install: rename is atomic on the same filesystem, so any +// concurrent reader either sees the old cached file or the new one, +// never a half-written PNG. +@rename($tmpOut, $cachePath); serve_png($cachePath); diff --git a/avian/api/recording.php b/avian/api/recording.php index 1f69969..2e5ee97 100644 --- a/avian/api/recording.php +++ b/avian/api/recording.php @@ -1,16 +1,15 @@ . +// AvianVisitors — serves the most-recent detection mp3 for a given +// scientific name. Called by the collage detail modal at +// /avian/api/recording.php?sci=. // // BirdNET-Pi writes audio + spectrograms to -// ~/BirdSongs/Extracted/By_Date/YYYY-MM-DD//.mp3 -// (with a matching .png next to it). Common_Name is the SPACE-stripped -// English common name (e.g. "Anna's_Hummingbird"), NOT the scientific name. -// We resolve sci → common via the same lookup the Pi's web UI uses -// (birds.json under /scripts/) and then walk the directory tree newest-first. +// $HOME/BirdSongs/Extracted/By_Date/YYYY-MM-DD//.mp3 +// (with a matching .png next to it). Common_Name is the SPACE-stripped +// English common name (e.g. "Anna's_Hummingbird"), NOT the scientific +// name. We resolve sci → common via birds.json under BirdNET-Pi/scripts/ +// and walk the directory tree newest-first. // -// Auth: the Caddy site block 403s anything missing X-BirdNET-Proxy-Token. declare(strict_types=1); @@ -33,7 +32,7 @@ if ($sci !== '' && !preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci exit; } -$BY_DATE = '/home/monalisa/BirdSongs/Extracted/By_Date'; +$BY_DATE = getenv('HOME') . '/BirdSongs/Extracted/By_Date'; // ---- Direct-by-file lookup ---- // Used by the atlas detail modal to play any past recording. @@ -95,14 +94,14 @@ if ($file !== '') { exit; } $BIRDS_JSON_CANDIDATES = [ - '/home/monalisa/BirdNET-Pi/scripts/birds.json', - '/home/monalisa/BirdNET-Pi/model/labels.txt', + getenv('HOME') . '/BirdNET-Pi/scripts/birds.json', + getenv('HOME') . '/BirdNET-Pi/model/labels.txt', ]; // ---- Resolve scientific name → common name (with underscores) ---- function resolve_common(string $sci): ?string { // Try birds.json first (preferred — has clean sci/com pairs). - foreach (['/home/monalisa/BirdNET-Pi/scripts/birds.json'] as $f) { + foreach ([getenv('HOME') . '/BirdNET-Pi/scripts/birds.json'] as $f) { if (is_readable($f)) { $list = json_decode((string)file_get_contents($f), true); if (is_array($list)) { @@ -118,7 +117,7 @@ function resolve_common(string $sci): ?string { } } // Fallback: labels.txt has "_" or ", " per line. - $labels = '/home/monalisa/BirdNET-Pi/model/labels.txt'; + $labels = getenv('HOME') . '/BirdNET-Pi/model/labels.txt'; if (is_readable($labels)) { foreach (file($labels, FILE_IGNORE_NEW_LINES) as $line) { if (strpos($line, '_') !== false) { diff --git a/avian/api/spectrogram.php b/avian/api/spectrogram.php index d5f8669..9526b1e 100644 --- a/avian/api/spectrogram.php +++ b/avian/api/spectrogram.php @@ -1,8 +1,8 @@ //) — just .png instead of .mp3. +// AvianVisitors — serves the spectrogram PNG that BirdNET-Pi generates +// alongside each detection mp3. Same lookup logic as recording.php (find +// the matching file under By_Date///) — just .png +// instead of .mp3. // // Endpoints: // ?sci= → newest spectrogram for that species @@ -31,7 +31,7 @@ if ($sci !== '' && !preg_match('/^[A-Za-z]{2,40}(?:[ ][a-z]{2,40}){1,3}$/', $sci exit; } -$BY_DATE = '/home/monalisa/BirdSongs/Extracted/By_Date'; +$BY_DATE = getenv('HOME') . '/BirdSongs/Extracted/By_Date'; // ---- Direct-by-file lookup ---- // BirdNET-Pi writes .mp3 and .png next to each other under @@ -93,7 +93,7 @@ if ($file !== '') { } function resolve_common(string $sci): ?string { - $f = '/home/monalisa/BirdNET-Pi/scripts/birds.json'; + $f = getenv('HOME') . '/BirdNET-Pi/scripts/birds.json'; if (is_readable($f)) { $list = json_decode((string)file_get_contents($f), true); if (is_array($list)) { @@ -107,7 +107,7 @@ function resolve_common(string $sci): ?string { } } } - $labels = '/home/monalisa/BirdNET-Pi/model/labels.txt'; + $labels = getenv('HOME') . '/BirdNET-Pi/model/labels.txt'; if (is_readable($labels)) { foreach (file($labels, FILE_IGNORE_NEW_LINES) as $line) { if (strpos($line, '_') !== false) { diff --git a/avian/caddy/avian.caddy b/avian/caddy/avian.caddy deleted file mode 100644 index 6ec6330..0000000 --- a/avian/caddy/avian.caddy +++ /dev/null @@ -1,37 +0,0 @@ -# AvianVisitors — Caddy snippet. -# Drop into /etc/caddy/conf.d/ and add `import /etc/caddy/conf.d/*.caddy` -# to the main Caddyfile (the installer does this for you). -# -# Default: serves the collage locally at http://birdnet.local/collage/ -# with no auth. For the optional Cloudflare/HA forwarded deployment, -# see ../forwarding/. - -(avian_common) { - # CORS — collage is a single-origin static app; loose for the JSON - # endpoints so HA dashboards etc. can read them. - @json path /api/*.json - header @json { - Access-Control-Allow-Origin "*" - Cache-Control "no-cache" - } -} - -# Frontend lives at http://birdnet.local/collage/ -http://birdnet.local { - import avian_common - - # Static collage UI - handle_path /collage/* { - root * /var/www/avian - file_server - } - redir /collage /collage/ - - # PHP shims for recent.json / lifelist.json / firstseen.json / - # timeseries.json — served from BirdNET-Pi's existing PHP root. - handle_path /api/* { - root * /home/monalisa/BirdSongs/Extracted - php_fastcgi unix//run/php/php8.2-fpm.sock - file_server - } -} diff --git a/avian/forwarding/README.md b/avian/forwarding/README.md index 183c078..f6b2b78 100644 --- a/avian/forwarding/README.md +++ b/avian/forwarding/README.md @@ -1,6 +1,6 @@ # Optional: forwarding the collage off your local network -Default install hosts the collage at `http://birdnet.local/collage/` on +Default install hosts the collage at `http://birdnet.local/avian/` on your LAN with no auth. If you want it accessible from anywhere — or piped into Home Assistant / MQTT — pick one of the recipes below. @@ -8,18 +8,17 @@ Each recipe is independent. Skip what you don't need. --- -## 1. Cloudflare Tunnel (recommended for public access) +## 1. Cloudflare Tunnel — public HTTPS, no port forwarding -Gives you a public HTTPS URL with no port forwarding and no exposed -home IP. Free Cloudflare account required. +Free Cloudflare account required. -Install `cloudflared` on the Pi: +Install `cloudflared`: ```bash -sudo mkdir -p /usr/share/keyrings +sudo apt install -y lsb-release curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \ | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null -echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main' \ +echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" \ | sudo tee /etc/apt/sources.list.d/cloudflared.list sudo apt update && sudo apt install -y cloudflared ``` @@ -29,28 +28,21 @@ Authenticate + create the tunnel: ```bash cloudflared tunnel login cloudflared tunnel create birds -``` - -Add a public route — pick a hostname on a zone you own: - -```bash cloudflared tunnel route dns birds birds.your-domain.com ``` -Configure the tunnel to point at the local Caddy: +Configure + start the tunnel: ```bash -sudo cp avian/forwarding/cloudflared.yml /etc/cloudflared/config.yml -# edit /etc/cloudflared/config.yml — set `tunnel:` to your tunnel UUID +sudo cp ~/BirdNET-Pi/avian/forwarding/cloudflared.yml /etc/cloudflared/config.yml +# Edit /etc/cloudflared/config.yml — set `tunnel:` to your tunnel UUID sudo cloudflared service install sudo systemctl restart cloudflared ``` -**Adding password protection on the public URL.** With Cloudflare in -front, gate the public endpoint via Cloudflare Access (zero-trust -free tier supports up to 50 users) — see Cloudflare docs. The local -LAN URL remains unprotected. If you'd rather use HTTP Basic auth on -Caddy itself, see [forwarding/caddy-auth.caddy](caddy-auth.caddy). +To password-protect the public URL, set up Cloudflare Access (free +tier: up to 50 users). The LAN URL stays open. If you'd rather use HTTP +Basic auth, see [`caddy-auth.caddy`](caddy-auth.caddy). --- @@ -60,13 +52,11 @@ Add to `configuration.yaml`: ```yaml rest: - - resource: http://birdnet.local/api/recent.json?hours=1 + - resource: http://birdnet.local/avian/api/birdnet-api.php?action=recent&hours=1 scan_interval: 60 sensor: - name: "Latest Bird" - value_template: > - {% set top = value_json.species | sort(attribute='last_seen', reverse=true) | first %} - {{ top.com if top else 'none' }} + value_template: "{{ value_json.species[0].com if value_json.species else 'none' }}" json_attributes_path: "$.species[0]" json_attributes: - sci @@ -75,25 +65,25 @@ rest: - best_conf ``` -Use the sensor in automations — flash a light when a new species is -heard, etc. +`birdnet-api.php?action=recent` already returns `species` ordered by +count desc; if you want most-recent first, replace the value_template +with a sort filter. --- ## 3. MQTT — fan out detections to other services -If you already run an MQTT broker, publish every new detection. -Install `paho-mqtt` and run the bridge: - ```bash sudo pip3 install paho-mqtt --break-system-packages -cp avian/forwarding/mqtt-bridge.py ~/avian-mqtt.py -# edit ~/avian-mqtt.py — broker host, topic prefix -sudo cp avian/forwarding/avian-mqtt.service /etc/systemd/system/ +cp ~/BirdNET-Pi/avian/forwarding/mqtt-bridge.py ~/avian-mqtt.py +# Edit ~/avian-mqtt.py — broker host, topic prefix, credentials +sudo cp ~/BirdNET-Pi/avian/forwarding/avian-mqtt.service /etc/systemd/system/ +# Edit /etc/systemd/system/avian-mqtt.service — set User= to your username sudo systemctl daemon-reload sudo systemctl enable --now avian-mqtt ``` -The bridge polls `/api/recent.json?hours=1` once a minute and -publishes new species under `birdnet/` with the full record as -JSON payload. +The bridge polls `/avian/api/birdnet-api.php?action=recent&hours=1` +once a minute and publishes new species under `birdnet/` with the +full record as a JSON payload. Dedup is in-memory only — downstream +consumers should be idempotent. diff --git a/avian/forwarding/avian-mqtt.service b/avian/forwarding/avian-mqtt.service index 2b59be0..263270a 100644 --- a/avian/forwarding/avian-mqtt.service +++ b/avian/forwarding/avian-mqtt.service @@ -4,7 +4,10 @@ After=network-online.target [Service] Type=simple -ExecStart=/usr/bin/python3 /home/%i/avian-mqtt.py +# Edit User= and the ExecStart path to match your Pi's username if +# you didn't install BirdNET-Pi as the `birdnet` user. +User=birdnet +ExecStart=/usr/bin/python3 %h/avian-mqtt.py Restart=on-failure RestartSec=10 diff --git a/avian/forwarding/mqtt-bridge.py b/avian/forwarding/mqtt-bridge.py index 75d74bf..507de34 100755 --- a/avian/forwarding/mqtt-bridge.py +++ b/avian/forwarding/mqtt-bridge.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -"""Poll /api/recent.json on the Pi once a minute and publish each new -detection to MQTT. Edit BROKER, TOPIC_PREFIX, and PI_URL below.""" +"""Poll AvianVisitors' recent-detections endpoint once a minute and publish +each new species to MQTT. Edit BROKER, TOPIC_PREFIX, and PI_URL below.""" import json import time import urllib.request @@ -11,7 +11,7 @@ PORT = 1883 USER = "" PASSWORD = "" TOPIC_PREFIX = "birdnet" -PI_URL = "http://birdnet.local/api/recent.json?hours=1" +PI_URL = "http://birdnet.local/avian/api/birdnet-api.php?action=recent&hours=1" seen_keys: set[str] = set() @@ -36,7 +36,13 @@ def loop(client: mqtt.Client) -> None: time.sleep(60) def main() -> None: - client = mqtt.Client() + # paho-mqtt 2.x requires CallbackAPIVersion; the constructor below + # also works on 1.x (the kwarg is just ignored). Pin to VERSION2 so + # we get the modern callback signatures going forward. + try: + client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION2) + except AttributeError: # paho-mqtt 1.x + client = mqtt.Client() if USER: client.username_pw_set(USER, PASSWORD) client.connect(BROKER, PORT, keepalive=60) diff --git a/avian/frontend/apt.js b/avian/frontend/apt.js index 3af0ad0..ac98103 100644 --- a/avian/frontend/apt.js +++ b/avian/frontend/apt.js @@ -1,67 +1,79 @@ /* AvianVisitors — bird collage frontend. * - * Renders three views over BirdNET-Pi detections: + * Three views over BirdNET-Pi detections: * collage — mask-packed cluster of species illustrations, sized by - * count. Layout normalises so all birds always fit on + * count. Layout normalises so every bird always fits on * every viewport. - * stats — per-species histogram across the selected time window. - * atlas — alphabet of all detected species with detail modal. + * stats — per-species mark on a time × count plot. + * atlas — grid of every species ever detected, with detail modal. * - * Reads four JSON endpoints exposed by the Pi (PHP shims in ../api/): - * GET /api/recent.json?hours=N — species + counts in window - * GET /api/lifelist.json — all species ever detected - * GET /api/firstseen.json — earliest detection per species - * GET /api/timeseries.json?days=D — daily counts per species + * Lives at $HOME/BirdNET-Pi/avian/frontend/ on the Pi; the install + * symlinks $HOME/BirdNET-Pi/avian → $EXTRACTED/avian, so this file + * loads from http://birdnet.local/avian/frontend/apt.js with the + * collage at http://birdnet.local/avian/frontend/. * - * Plus two media proxies (also via PHP): - * GET /api/img?sci=X[&com=Y] — bird illustration - * GET /api/recording?sci=X — most-recent mp3 - * GET /api/spectrogram?sci=X — matching spectrogram png - * - * Local-network deploys ship without auth. The PHP shims accept the - * X-AvianVisitors-Token header for the optional Cloudflare-forwarded - * deployment — see ../forwarding/. + * All API calls are relative — they target the PHP shims in ../api/ + * served by BirdNET-Pi's existing Caddy + PHP-FPM stack. No frontend + * configuration needed; works out of the box on any BirdNET-Pi host. */ (function () { 'use strict'; - // ---- Config ---- - // API_BASE: where the JSON endpoints live. Default '/' works when - // Caddy mounts the frontend at the same host as the API. Override - // with window.AV_API_BASE if you split origins. - var API_BASE = (window.AV_API_BASE || '').replace(/\/$/, ''); - var IMG_VERSION = '1'; // bump after running scripts/pregen.py + // Relative API helpers. Frontend lives at /avian/frontend/; the PHP + // shims live at /avian/api/. The router PHP (birdnet-api.php) + // dispatches on ?action= to the recent/lifelist/firstseen/timeseries + // queries. + function api(action, qs) { + return '../api/birdnet-api.php?action=' + action + (qs ? '&' + qs : ''); + } + function media(file, qs) { + return '../api/' + file + (qs ? '?' + qs : ''); + } + + // Cache-bust query for image URLs. Bump after running pregen.py with + // --force so browsers + CDNs drop their cached copies of every bird. + var IMG_VERSION = '1'; - function api(p) { return API_BASE + p; } function readLS(k, d) { try { return localStorage.getItem(k) || d; } catch (e) { return d; } } function writeLS(k, v) { try { localStorage.setItem(k, v); } catch (e) {} } - function fetchJson(u) { return fetch(u, { cache: 'no-store' }).then(function (r) { return r.json(); }); } + function fetchJson(u) { + return fetch(u, { cache: 'no-store' }).then(function (r) { + if (!r.ok) throw new Error('HTTP ' + r.status + ' for ' + u); + return r.json(); + }); + } + // HTML-escape user-supplied strings before they land in innerHTML. + // Species names come from BirdNET-Pi's labels file, which is + // user-editable (custom species lists, l18n) — so they're untrusted. + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } // ---- State ---- - var DATA = { - recent: null, // /api/recent.json?hours=N (refetched on picker change) - lifelist: null, // /api/lifelist.json - firstseen: null, // /api/firstseen.json - timeseries: null, // /api/timeseries.json - }; + var DATA = { recent: null, lifelist: null, firstseen: null, timeseries: null }; var MASKS = null, DIMS = null; var currentHours = +readLS('av:window', '24') || 24; - // ---- Boot: load mask/dim registries, then first data pull ---- + // ---- Boot ---- Promise.all([ - fetchJson(api('/avian/masks.json')).then(function (j) { MASKS = j; }), - fetchJson(api('/avian/dims.json')).then(function (j) { DIMS = j; }), + fetchJson('./masks.json').then(function (j) { MASKS = j; }), + fetchJson('./dims.json').then(function (j) { DIMS = j; }), ]).then(function () { bindUI(); refreshAll(); - setInterval(refreshRecent, 60000); // poll for new detections every min - }).catch(function (e) { console.error('boot failed', e); }); + setInterval(refreshRecent, 60000); + }).catch(function (e) { + console.error('boot failed', e); + var c = document.getElementById('collage'); + if (c) c.innerHTML = '

collage failed to load: ' + esc(e.message) + '

'; + }); - // ---- UI bindings ---- - var slider = document.getElementById('slider'); - var winPick = document.getElementById('winPick'); - var views = document.querySelectorAll('.view'); + // ---- UI ---- + function $(id) { return document.getElementById(id); } function syncPill(container) { + if (!container) return; var pill = container.querySelector('.seg-pill'); var active = container.querySelector('button[aria-current="true"]'); if (!pill || !active) return; @@ -69,11 +81,15 @@ pill.style.transform = 'translateX(' + active.offsetLeft + 'px)'; } function syncAllPills() { - [slider, winPick, document.getElementById('atlasSort')].forEach(function (c) { if (c) syncPill(c); }); + [$('slider'), $('winPick'), $('atlasSort')].forEach(syncPill); } function bindUI() { - // View slider - [].slice.call(slider.querySelectorAll('button')).forEach(function (b) { + var slider = $('slider'); + var winPick = $('winPick'); + var atlasEl = $('atlasSort'); + var views = document.querySelectorAll('.view'); + + if (slider) [].slice.call(slider.querySelectorAll('button')).forEach(function (b) { b.addEventListener('click', function () { [].slice.call(slider.querySelectorAll('button')).forEach(function (x) { x.setAttribute('aria-current', x === b ? 'true' : 'false'); @@ -86,8 +102,8 @@ if (i === 2) renderAtlas(); }); }); - // Window picker - [].slice.call(winPick.querySelectorAll('button')).forEach(function (b) { + + if (winPick) [].slice.call(winPick.querySelectorAll('button')).forEach(function (b) { b.setAttribute('aria-current', (+b.dataset.h === currentHours) ? 'true' : 'false'); b.addEventListener('click', function () { [].slice.call(winPick.querySelectorAll('button')).forEach(function (x) { @@ -99,9 +115,8 @@ refreshRecent(); }); }); - // Atlas sort - var atlasEl = document.getElementById('atlasSort'); - [].slice.call(atlasEl.querySelectorAll('button')).forEach(function (b) { + + if (atlasEl) [].slice.call(atlasEl.querySelectorAll('button')).forEach(function (b) { b.addEventListener('click', function () { [].slice.call(atlasEl.querySelectorAll('button')).forEach(function (x) { x.setAttribute('aria-current', x === b ? 'true' : 'false'); @@ -111,16 +126,16 @@ renderAtlas(); }); }); - // About modal - document.getElementById('aboutLink').addEventListener('click', function () { - document.getElementById('about-modal').setAttribute('aria-hidden', 'false'); + + var aboutLink = $('aboutLink'); + var aboutModal = $('about-modal'); + if (aboutLink && aboutModal) { + aboutLink.addEventListener('click', function () { aboutModal.setAttribute('aria-hidden', 'false'); }); + } + document.querySelectorAll('#about-modal [data-close]').forEach(function (el) { + el.addEventListener('click', function () { aboutModal && aboutModal.setAttribute('aria-hidden', 'true'); }); }); - document.querySelectorAll('[data-close]').forEach(function (el) { - el.addEventListener('click', function () { - document.getElementById('about-modal').setAttribute('aria-hidden', 'true'); - }); - }); - // Resize: re-pack collage and re-pill + var rT; window.addEventListener('resize', function () { clearTimeout(rT); @@ -137,10 +152,10 @@ function refreshAll() { var h = currentHours; return Promise.all([ - fetchJson(api('/api/lifelist.json')).catch(function () { return null; }), - fetchJson(api('/api/firstseen.json')).catch(function () { return null; }), - fetchJson(api('/api/timeseries.json?days=30')).catch(function () { return null; }), - fetchJson(api('/api/recent.json?hours=' + h)).catch(function () { return null; }), + fetchJson(api('lifelist')).catch(function () { return null; }), + fetchJson(api('firstseen')).catch(function () { return null; }), + fetchJson(api('timeseries', 'days=30')).catch(function () { return null; }), + fetchJson(api('recent', 'hours=' + h)).catch(function () { return null; }), ]).then(function (parts) { DATA.lifelist = parts[0]; DATA.firstseen = parts[1]; @@ -152,7 +167,7 @@ } function refreshRecent() { var h = currentHours; - return fetchJson(api('/api/recent.json?hours=' + h)).then(function (j) { + return fetchJson(api('recent', 'hours=' + h)).then(function (j) { if (h !== currentHours) return; DATA.recent = j; renderCollageFromData(); @@ -160,9 +175,10 @@ }).catch(function () {}); } - // ---- Mask + slug helpers ---- + // ---- Slug + mask helpers ---- var maskCache = {}; function loadMask(slug) { + if (!MASKS) return null; if (maskCache[slug]) return maskCache[slug]; var rec = MASKS[slug]; if (!rec) return null; @@ -182,17 +198,16 @@ return sci.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } function aspect(sci) { - var d = DIMS[slugify(sci)]; + var d = DIMS && DIMS[slugify(sci)]; return d ? d[0] / d[1] : 1.4; } + function imgUrl(sci, com) { + return media('cutout.php', 'sci=' + encodeURIComponent(sci) + + (com ? '&com=' + encodeURIComponent(com) : '') + + '&v=' + IMG_VERSION); + } // ---- Collage layout (mask-aware, viewport-budget) ---- - // - // Each species ships a low-res alpha mask. We maintain an occupancy - // grid at viewport resolution; for each tile we spiral outward from - // the cluster center and pick the closest non-overlapping placement. - // Total area is normalised so the entire cluster fits any viewport, - // and we iterate shrink+repack until every bird lands on-screen. function tuning(n) { return { packingBudgetFrac: n <= 4 ? 0.46 : n <= 12 ? 0.40 : n <= 24 ? 0.34 : 0.28, @@ -278,14 +293,17 @@ } function renderCollageFromData() { - var items = (DATA.recent && DATA.recent.species) || []; - renderCollage(items); + renderCollage((DATA.recent && DATA.recent.species) || []); } function renderCollage(items) { - var collage = document.getElementById('collage'); + var collage = $('collage'); + if (!collage) return; collage.innerHTML = ''; if (!items.length) { - collage.innerHTML = '

no birds heard in this window.

'; + var p = document.createElement('p'); + p.className = 'empty'; + p.textContent = 'no birds heard in this window.'; + collage.appendChild(p); return; } var W = collage.clientWidth, H = collage.clientHeight; @@ -297,13 +315,15 @@ var minArea = vpArea * T.minTileAreaFrac; var tiles = items.map(function (s) { - var slug = slugify(s.sci); + var slug = slugify(s.sci || ''); var mask = loadMask(slug); if (!mask) return null; var n = +s.n; if (!n || isNaN(n)) n = 1; return { mask: mask, data: s, ar: aspect(s.sci), score: Math.pow(Math.max(1, n), T.countExp) }; }).filter(Boolean); + if (!tiles.length) { collage.innerHTML = ''; return; } + var sumScore = tiles.reduce(function (a, t) { return a + t.score; }, 0) || 1; tiles.forEach(function (t) { t.area = Math.max(minArea, budget * t.score / sumScore); }); var sumA = tiles.reduce(function (a, t) { return a + t.area; }, 0); @@ -353,30 +373,35 @@ placed.forEach(function (t) { if (t.x > -1000) { t.x += dx; t.y += dy; } }); } + // Build tiles via createElement / textContent / setAttribute so user- + // editable species names from labels.txt can't smuggle markup. placed.forEach(function (t) { var s = t.data; - var img = api('/api/img?sci=' + encodeURIComponent(s.sci) + - (s.com ? '&com=' + encodeURIComponent(s.com) : '') + - '&v=' + IMG_VERSION); + var label = s.com || s.sci; var btn = document.createElement('button'); btn.className = 'gtile'; btn.type = 'button'; - btn.setAttribute('data-sci', s.sci); - btn.setAttribute('aria-label', s.com || s.sci); - btn.title = (s.com || s.sci) + ' · ' + (+s.n || 0) + ' calls'; + btn.dataset.sci = s.sci; + btn.setAttribute('aria-label', label); + btn.title = label + ' · ' + (+s.n || 0) + ' calls'; btn.style.left = t.x + 'px'; btn.style.top = t.y + 'px'; btn.style.width = t.fullW + 'px'; btn.style.height = t.fullH + 'px'; - btn.innerHTML = '' + (s.com || s.sci) + ''; + var img = document.createElement('img'); + img.loading = 'lazy'; + img.decoding = 'async'; + img.alt = label; + img.src = imgUrl(s.sci, s.com); + btn.appendChild(img); btn.addEventListener('click', function () { openDetail(s); }); collage.appendChild(btn); }); } - // ---- Stats: per-species histogram with last-seen positioning ---- + // ---- Stats ---- function drawHistograms() { - var tl = document.getElementById('statsTimeline'); + var tl = $('statsTimeline'); if (!tl) return; var sp = ((DATA.recent && DATA.recent.species) || []).slice(); if (!sp.length) { tl.innerHTML = '
no detections in this window
'; return; } @@ -388,24 +413,38 @@ var cap = Math.max(4, Math.floor(W / 28)); if (sp.length > cap) sp = sp.slice(0, cap); var maxN = sp.reduce(function (m, s) { return Math.max(m, +s.n || 0); }, 1); - var html = '
'; + tl.innerHTML = ''; + var plot = document.createElement('div'); + plot.className = 'stats-tl-plot'; sp.forEach(function (s) { var ts = Date.parse((s.last_seen || '').replace(' ', 'T')); var leftPct = isNaN(ts) ? 50 : ((Math.max(windowStart, Math.min(now, ts)) - windowStart) / windowSpan) * 100; var n = +s.n || 0; var bottomPct = (n / maxN) * 50; - html += '
'; + var mark = document.createElement('div'); + mark.className = 'stats-tl-mark'; + mark.style.left = leftPct.toFixed(1) + '%'; + mark.style.bottom = bottomPct.toFixed(1) + '%'; + mark.dataset.sci = s.sci; + mark.title = (s.com || s.sci) + ' · ' + n + ' calls'; + plot.appendChild(mark); }); - html += '
'; - tl.innerHTML = html; + tl.appendChild(plot); } - // ---- Atlas: gridded alphabet of all species ---- + // ---- Atlas ---- function renderAtlas() { - var list = document.getElementById('atlasList'); + var list = $('atlasList'); if (!list) return; var sp = ((DATA.lifelist && DATA.lifelist.species) || []).slice(); - if (!sp.length) { list.innerHTML = '

no species yet — atlas fills in as the Pi detects birds.

'; return; } + if (!sp.length) { + var p = document.createElement('p'); + p.className = 'empty'; + p.textContent = 'no species yet — atlas fills in as the Pi detects birds.'; + list.innerHTML = ''; + list.appendChild(p); + return; + } var sort = readLS('av:atlasSort', 'count'); if (sort === 'count') sp.sort(function (a, b) { return (+b.n || 0) - (+a.n || 0); }); else if (sort === 'recent') sp.sort(function (a, b) { @@ -414,49 +453,66 @@ else sp.sort(function (a, b) { return Date.parse((a.first_seen || '').replace(' ', 'T')) - Date.parse((b.first_seen || '').replace(' ', 'T')); }); - list.innerHTML = sp.map(function (s) { - var img = api('/api/img?sci=' + encodeURIComponent(s.sci) + - (s.com ? '&com=' + encodeURIComponent(s.com) : '') + - '&v=' + IMG_VERSION); - return ''; - }).join(''); - [].slice.call(list.querySelectorAll('.atlas-card')).forEach(function (b) { - b.addEventListener('click', function () { openDetail({ sci: b.dataset.sci }); }); + list.innerHTML = ''; + sp.forEach(function (s) { + var btn = document.createElement('button'); + btn.className = 'atlas-card'; + btn.type = 'button'; + btn.dataset.sci = s.sci; + var img = document.createElement('img'); + img.loading = 'lazy'; + img.decoding = 'async'; + img.alt = s.com || s.sci; + img.src = imgUrl(s.sci, s.com); + var name = document.createElement('span'); + name.className = 'atlas-name'; + name.textContent = s.com || s.sci; + var count = document.createElement('span'); + count.className = 'atlas-count'; + count.textContent = String(+s.n || 0); + btn.appendChild(img); + btn.appendChild(name); + btn.appendChild(count); + btn.addEventListener('click', function () { openDetail(s); }); + list.appendChild(btn); }); } - // ---- Detail modal (basic — opens recording + spectrogram for a species) ---- + // ---- Detail modal ---- function openDetail(s) { var sci = s.sci || s; - // Lookup full record from lifelist if available var rec = ((DATA.lifelist && DATA.lifelist.species) || []).find(function (x) { return x.sci === sci; }) || s; - var img = api('/api/img?sci=' + encodeURIComponent(sci) + - (rec.com ? '&com=' + encodeURIComponent(rec.com) : '') + - '&v=' + IMG_VERSION); - var rec_url = api('/api/recording?sci=' + encodeURIComponent(sci)); - var spec_url = api('/api/spectrogram?sci=' + encodeURIComponent(sci)); - var modal = document.getElementById('detail-modal'); + var label = rec.com || sci; + + var modal = $('detail-modal'); if (!modal) { modal = document.createElement('div'); modal.id = 'detail-modal'; modal.setAttribute('role', 'dialog'); document.body.appendChild(modal); } + // Single innerHTML for the chrome (no user content), then build the + // user-content elements via createElement so labels are safe. modal.innerHTML = '' + '
' + ' ' + - ' ' + (rec.com || sci) + '' + - '

' + (rec.com || sci) + '

' + - '

' + sci + '

' + - '

' + (+rec.n || 0) + ' calls · last heard ' + (rec.last_seen || '—') + '

' + - ' ' + - ' spectrogram' + + ' ' + + '

' + + '

' + + '

' + + ' ' + + ' spectrogram' + '
'; + var card = modal.querySelector('.detail-card'); + card.querySelector('.detail-img').src = imgUrl(sci, rec.com); + card.querySelector('.detail-img').alt = label; + card.querySelector('.detail-title').textContent = label; + card.querySelector('.detail-sci em').textContent = sci; + card.querySelector('.detail-stats').textContent = + (+rec.n || 0) + ' calls · last heard ' + (rec.last_seen || '—'); + card.querySelector('.detail-audio').src = media('recording.php', 'sci=' + encodeURIComponent(sci)); + card.querySelector('.detail-spec').src = media('spectrogram.php', 'sci=' + encodeURIComponent(sci)); modal.setAttribute('aria-hidden', 'false'); modal.querySelectorAll('[data-close]').forEach(function (el) { el.addEventListener('click', function () { modal.setAttribute('aria-hidden', 'true'); }); diff --git a/avian/frontend/index.html b/avian/frontend/index.html index 10e9fae..94a10ac 100644 --- a/avian/frontend/index.html +++ b/avian/frontend/index.html @@ -5,7 +5,6 @@ apartment birds - diff --git a/avian/scripts/install.sh b/avian/scripts/install.sh deleted file mode 100755 index 579e743..0000000 --- a/avian/scripts/install.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -# AvianVisitors — install the collage frontend on a BirdNET-Pi host. -# -# Assumes you already ran the BirdNET-Pi installer. Drops the frontend -# into /var/www/avian, mounts it under Caddy at http://birdnet.local/collage, -# and adds the JSON shims that the frontend reads from. -# -# Re-run safely — every step is idempotent. -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -WEB_ROOT="${WEB_ROOT:-/var/www/avian}" -CADDY_SNIPPET="${CADDY_SNIPPET:-/etc/caddy/conf.d/avian.caddy}" -USER_NAME="${USER_NAME:-$USER}" - -echo "[avian] repo: $REPO_ROOT" -echo "[avian] web: $WEB_ROOT" -echo "[avian] caddy: $CADDY_SNIPPET" - -# 1. Web root with frontend + assets + masks -sudo mkdir -p "$WEB_ROOT" -sudo cp -R "$REPO_ROOT/frontend/." "$WEB_ROOT/" -sudo mkdir -p "$WEB_ROOT/assets" -sudo cp -R "$REPO_ROOT/assets/." "$WEB_ROOT/assets/" -sudo chown -R "$USER_NAME":"$USER_NAME" "$WEB_ROOT" -echo "[avian] frontend deployed → $WEB_ROOT" - -# 2. PHP shims (under BirdNET-Pi's web root so PHP-FPM picks them up) -PHP_ROOT="${PHP_ROOT:-/home/$USER_NAME/BirdSongs/Extracted}" -if [ -d "$PHP_ROOT" ]; then - for f in birdnet-api.php recording.php spectrogram.php cutout.php; do - if [ -f "$REPO_ROOT/api/$f" ]; then - sudo cp "$REPO_ROOT/api/$f" "$PHP_ROOT/$f" - sudo chown "$USER_NAME":"$USER_NAME" "$PHP_ROOT/$f" - echo "[avian] api/$f → $PHP_ROOT/$f" - fi - done -else - echo "[avian] WARN: $PHP_ROOT not found — copy avian/api/*.php to your BirdNET-Pi PHP root manually." -fi - -# 3. Caddy snippet — mounts /collage and proxies /api to PHP -sudo mkdir -p "$(dirname "$CADDY_SNIPPET")" -sudo cp "$REPO_ROOT/caddy/avian.caddy" "$CADDY_SNIPPET" -echo "[avian] caddy snippet → $CADDY_SNIPPET" - -# Make sure the main Caddyfile imports the snippet directory -if ! sudo grep -q "import /etc/caddy/conf.d/\*.caddy" /etc/caddy/Caddyfile 2>/dev/null; then - echo "[avian] adding import to /etc/caddy/Caddyfile" - echo -e "\nimport /etc/caddy/conf.d/*.caddy" | sudo tee -a /etc/caddy/Caddyfile >/dev/null -fi - -# 4. Optional: install Gemini pregen Python deps (none — uses stdlib) -# Just leave a note so users find the regen flow. -echo "[avian] (optional) regenerate illustrations:" -echo " export GEMINI_API_KEY=your-key" -echo " python3 $REPO_ROOT/scripts/pregen.py --labels /home/$USER_NAME/BirdNET-Pi/model/labels.txt" - -# 5. Reload Caddy -sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile -sudo systemctl reload caddy -echo "[avian] caddy reloaded — collage live at http://birdnet.local/collage/" diff --git a/avian/scripts/pregen.py b/avian/scripts/pregen.py index f91544d..e6468f0 100755 --- a/avian/scripts/pregen.py +++ b/avian/scripts/pregen.py @@ -1,28 +1,30 @@ #!/usr/bin/env python3 """AvianVisitors — pre-generate kachō-e illustrations for a region. -Reads a species list (from BirdNET-Pi's labels.txt, eBird, or stdin), +Reads a species list (BirdNET-Pi's labels.txt, eBird, or stdin), generates an illustration for each via the Gemini 2.5 Flash Image API, and saves PNGs into avian/assets/illustrations/. Each species gets two poses: .png (perched) and -2.png -(flight). The prompt template lives at avian/scripts/prompt.template.md -— edit it to change the visual style. +(flight). Edit avian/scripts/prompt.template.md to change the visual +style — the prompt body is re-sent verbatim per request with +{sci_name}, {com_name}, and {pose} substituted. Usage: - # Generate the full BirdNET-Pi label set: - python3 pregen.py --labels /home/$USER/BirdNET-Pi/model/labels.txt + # Every species BirdNET-Pi knows: + python3 pregen.py --labels ~/BirdNET-Pi/model/labels.txt - # Generate only species observed in eBird region US-CA: - python3 pregen.py --labels labels.txt --ebird-region US-CA --ebird-key YOUR_KEY + # Only species observed in an eBird region: + python3 pregen.py --labels ~/BirdNET-Pi/model/labels.txt \\ + --ebird-region US-CA --ebird-key YOUR_KEY - # Re-render a single species (useful when you tweak the prompt): + # Re-render a single species (useful after editing the prompt): python3 pregen.py --species "Calypte anna|Anna's Hummingbird" --force - # Re-render everything (after a prompt change you actually want applied): - python3 pregen.py --labels labels.txt --force + # Re-render everything after a prompt change: + python3 pregen.py --labels ~/BirdNET-Pi/model/labels.txt --force -Set GEMINI_API_KEY in the environment or pass --gemini-key. +Set GEMINI_API_KEY in the environment (preferred) or pass --gemini-key. """ from __future__ import annotations import argparse @@ -37,108 +39,149 @@ import urllib.parse import urllib.request from pathlib import Path +# Gemini's image-out model. The endpoint changes occasionally; if you +# get a 404 here, check Google's model catalog and bump this. GEMINI_URL = ( "https://generativelanguage.googleapis.com/v1beta/models/" - "gemini-2.5-flash-image:generateContent?key={key}" + "gemini-2.5-flash-image-preview:generateContent" ) POSES = {1: "perched", 2: "in flight with wings spread"} def slugify(sci: str) -> str: + """Match avian/frontend/apt.js slugify() exactly.""" return re.sub(r"[^a-z0-9]+", "-", sci.lower()).strip("-") +def parse_species_line(line: str) -> tuple[str, str] | None: + """Accept any of: 'Sci|Com', 'Sci_Com', 'Sci,Com'. Skip blanks + #.""" + line = line.strip() + if not line or line.startswith("#"): + return None + for sep in ("|", "_", ","): + if sep in line: + sci, com = line.split(sep, 1) + sci, com = sci.strip(), com.strip() + if sci and com: + return (sci, com) + return None + + +def parse_species_list(lines: list[str]) -> tuple[list[tuple[str, str]], int]: + """Returns (parsed, skipped_count).""" + out, skipped = [], 0 + for line in lines: + parsed = parse_species_line(line) + if parsed: + out.append(parsed) + elif line.strip() and not line.lstrip().startswith("#"): + skipped += 1 + return out, skipped + + def load_prompt(path: Path) -> str: - """Pull the prompt body from the markdown template — everything - after the `## Prompt` heading, stripped.""" + """Return everything after the `## Prompt` heading, stripped to the + next `##` heading (so doc preamble or trailing sections don't bleed + into the API call).""" text = path.read_text() - m = re.search(r"##\s*Prompt\s*\n(.+)$", text, flags=re.DOTALL) + m = re.search(r"##\s*Prompt\s*\n(.+?)(?=\n##\s|\Z)", text, flags=re.DOTALL) return (m.group(1) if m else text).strip() -def parse_labels(p: Path) -> list[tuple[str, str]]: - """BirdNET-Pi labels.txt format: `Sci name_Common Name` per line.""" - out = [] - for line in p.read_text().splitlines(): - line = line.strip() - if not line or line.startswith("#"): - continue - # Split on first underscore (BirdNET format) - if "_" in line: - sci, com = line.split("_", 1) - elif "," in line: - sci, com = (s.strip() for s in line.split(",", 1)) - else: - continue - sci = sci.strip() - com = com.strip() - if sci and com: - out.append((sci, com)) - return out - - -def ebird_filter(species: list[tuple[str, str]], region: str, key: str) -> list[tuple[str, str]]: - """Intersect a label set with the eBird observed-species list for a - region. Region codes look like US-CA (state) or US-CA-085 (county). - See https://documenter.getpostman.com/view/664302/S1ENwy59""" +def ebird_filter(species, region: str, key: str): + """Intersect a label set with the eBird species list for a region. + Region codes: US-CA (state), US-CA-085 (county).""" url = f"https://api.ebird.org/v2/product/spplist/{region}" req = urllib.request.Request(url, headers={"X-eBirdApiToken": key}) with urllib.request.urlopen(req, timeout=30) as r: ebird_codes = set(json.loads(r.read())) - # eBird returns 6-letter species codes — we need sci names. Use the - # taxonomy endpoint to map. - tax_url = f"https://api.ebird.org/v2/ref/taxonomy/ebird?fmt=json" + tax_url = "https://api.ebird.org/v2/ref/taxonomy/ebird?fmt=json" req2 = urllib.request.Request(tax_url, headers={"X-eBirdApiToken": key}) with urllib.request.urlopen(req2, timeout=60) as r: taxonomy = json.loads(r.read()) code_to_sci = {t["speciesCode"]: t["sciName"] for t in taxonomy} - allowed_sci = {code_to_sci[c] for c in ebird_codes if c in code_to_sci} - return [(s, c) for s, c in species if s in allowed_sci] + allowed = {code_to_sci[c] for c in ebird_codes if c in code_to_sci} + return [(s, c) for s, c in species if s in allowed] def gen_one(api_key: str, prompt: str, sci: str, com: str, pose: int) -> bytes: - """Single Gemini call. Returns raw PNG bytes.""" - body = prompt.replace("{sci_name}", sci).replace("{com_name}", com).replace( - "{pose}", POSES[pose] - ) + """Single Gemini call with bounded retry on 429 + transient 5xx. + Returns raw PNG bytes.""" + body = (prompt + .replace("{sci_name}", sci) + .replace("{com_name}", com) + .replace("{pose}", POSES[pose])) payload = { "contents": [{"parts": [{"text": body}]}], - "generationConfig": {"responseModalities": ["IMAGE"]}, + # TEXT included so Gemini can surface safety messaging without + # rejecting the request shape (image-only sometimes errors). + "generationConfig": {"responseModalities": ["TEXT", "IMAGE"]}, } + # API key as header, NOT URL — keeps the key out of Google's + # request logs, proxy logs, and shell history. req = urllib.request.Request( - GEMINI_URL.format(key=urllib.parse.quote(api_key)), + GEMINI_URL, data=json.dumps(payload).encode(), - headers={"Content-Type": "application/json"}, + headers={"Content-Type": "application/json", "x-goog-api-key": api_key}, method="POST", ) - with urllib.request.urlopen(req, timeout=120) as r: - resp = json.loads(r.read()) + + backoff = 4.0 + for attempt in range(4): + try: + with urllib.request.urlopen(req, timeout=120) as r: + resp = json.loads(r.read()) + break + except urllib.error.HTTPError as e: + if e.code in (429, 500, 502, 503, 504) and attempt < 3: + retry_after = float(e.headers.get("Retry-After") or backoff) + time.sleep(retry_after) + backoff *= 2 + continue + raise + except urllib.error.URLError: + if attempt < 3: + time.sleep(backoff) + backoff *= 2 + continue + raise + for cand in resp.get("candidates", []): for part in cand.get("content", {}).get("parts", []): inline = part.get("inlineData") or part.get("inline_data") if inline and inline.get("data"): return base64.b64decode(inline["data"]) - raise RuntimeError(f"no image in response: {json.dumps(resp)[:300]}") + # No image — surface the blocking reason so users know what to fix. + finish = (resp.get("candidates", [{}])[0]).get("finishReason", "?") + block = resp.get("promptFeedback", {}).get("blockReason", "") + raise RuntimeError(f"no image (finish={finish} block={block})") def main() -> int: - ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) src = ap.add_mutually_exclusive_group(required=True) - src.add_argument("--labels", type=Path, help="BirdNET-Pi labels.txt path") + src.add_argument("--labels", type=Path, help="Path to BirdNET-Pi labels.txt (or any file of Sci|Com lines)") src.add_argument("--species", action="append", default=[], - help="Manual species in format 'Sci name|Common Name' (repeatable)") - src.add_argument("--stdin", action="store_true", help="Read species from stdin (one per line, same format)") + help="Manual 'Sci|Com' (repeatable)") + src.add_argument("--stdin", action="store_true", help="Read Sci|Com lines from stdin") ap.add_argument("--ebird-region", help="eBird region code (e.g. US-CA, US-CA-085) to filter labels") - ap.add_argument("--ebird-key", help="eBird API key (or set EBIRD_API_KEY)") - ap.add_argument("--gemini-key", help="Gemini API key (or set GEMINI_API_KEY)") - ap.add_argument("--out", type=Path, default=Path(__file__).resolve().parents[1] / "assets" / "illustrations", + ap.add_argument("--ebird-key", help="eBird API key (or EBIRD_API_KEY env)") + ap.add_argument("--gemini-key", help="Gemini API key (or GEMINI_API_KEY env)") + ap.add_argument("--out", type=Path, + default=Path(__file__).resolve().parents[1] / "assets" / "illustrations", help="Output directory (default: avian/assets/illustrations/)") - ap.add_argument("--prompt", type=Path, default=Path(__file__).resolve().parent / "prompt.template.md", + ap.add_argument("--prompt", type=Path, + default=Path(__file__).resolve().parent / "prompt.template.md", help="Prompt template path") ap.add_argument("--poses", nargs="+", type=int, default=[1, 2], - help="Which poses to render (1=perched, 2=flight). Default: both.") + choices=list(POSES.keys()), + help="Which poses to render. 1=perched, 2=flight. Default: both.") ap.add_argument("--force", action="store_true", help="Re-render even if file exists") - ap.add_argument("--sleep", type=float, default=1.0, help="Seconds between API calls (rate limit)") + ap.add_argument("--sleep", type=float, default=4.0, + help="Seconds between API calls (default 4 = under free-tier RPM cap)") ap.add_argument("--limit", type=int, default=0, help="Cap species count for testing") args = ap.parse_args() @@ -149,15 +192,13 @@ def main() -> int: # Build species list if args.labels: - species = parse_labels(args.labels) + species, skipped = parse_species_list(args.labels.read_text().splitlines()) elif args.stdin: - species = parse_labels_lines(sys.stdin.read().splitlines()) + species, skipped = parse_species_list(sys.stdin.read().splitlines()) else: - species = [] - for s in args.species: - if "|" in s: - sci, com = s.split("|", 1) - species.append((sci.strip(), com.strip())) + species, skipped = parse_species_list(args.species) + if skipped: + print(f"[parse] skipped {skipped} malformed line(s)", file=sys.stderr) if not species: print("error: no species resolved", file=sys.stderr) return 2 @@ -167,7 +208,7 @@ def main() -> int: if not ek: print("error: --ebird-region requires --ebird-key or EBIRD_API_KEY", file=sys.stderr) return 2 - print(f"[ebird] filtering {len(species)} species against region {args.ebird_region}…") + print(f"[ebird] filtering {len(species)} species against {args.ebird_region}…") species = ebird_filter(species, args.ebird_region, ek) if args.limit: @@ -177,48 +218,36 @@ def main() -> int: args.out.mkdir(parents=True, exist_ok=True) total = len(species) * len(args.poses) - print(f"generating {total} illustrations into {args.out}/") + print(f"generating up to {total} illustrations into {args.out}/") - done = skipped = failed = 0 - for sci, com in species: + done = skipped_existing = failed = 0 + first_fail = None + for idx, (sci, com) in enumerate(species): slug = slugify(sci) for pose in args.poses: fname = f"{slug}.png" if pose == 1 else f"{slug}-{pose}.png" path = args.out / fname if path.exists() and not args.force: - skipped += 1 + skipped_existing += 1 continue try: data = gen_one(gemini_key, prompt, sci, com, pose) path.write_bytes(data) done += 1 - print(f" ✓ {fname} ({len(data)//1024} KB)") + print(f" [ok] {fname} ({len(data)//1024} KB)") except (urllib.error.HTTPError, urllib.error.URLError, RuntimeError) as e: failed += 1 - print(f" ✗ {fname}: {e}", file=sys.stderr) - time.sleep(args.sleep) + first_fail = first_fail or fname + print(f" [fail] {fname}: {e}", file=sys.stderr) + # Don't sleep after the last species' last pose. + if not (idx == len(species) - 1 and pose == args.poses[-1]): + time.sleep(args.sleep) - print(f"\ngenerated {done} · skipped {skipped} · failed {failed}") + print(f"\ngenerated {done} · skipped {skipped_existing} · failed {failed}") + if first_fail: + print(f"first failure: {first_fail} (re-run without --force to retry only the misses)", file=sys.stderr) return 0 if failed == 0 else 1 -def parse_labels_lines(lines: list[str]) -> list[tuple[str, str]]: - out = [] - for line in lines: - line = line.strip() - if not line: - continue - if "|" in line: - sci, com = line.split("|", 1) - elif "_" in line: - sci, com = line.split("_", 1) - elif "," in line: - sci, com = line.split(",", 1) - else: - continue - out.append((sci.strip(), com.strip())) - return out - - if __name__ == "__main__": sys.exit(main()) diff --git a/avian/scripts/prompt.template.md b/avian/scripts/prompt.template.md index 6b04040..85e62d5 100644 --- a/avian/scripts/prompt.template.md +++ b/avian/scripts/prompt.template.md @@ -4,16 +4,16 @@ This is the prompt sent to Gemini for every bird illustration. Edit it to change the style — the entire visual feel of the collage flows through this template. -Two placeholders get replaced per request: +Three placeholders get replaced per request: - `{sci_name}` — the binomial Latin name, e.g. `Calypte anna` - `{com_name}` — the English common name, e.g. `Anna's Hummingbird` - `{pose}` — either `perched` (pose 1) or `in flight with wings spread` (pose 2) The default style below is **kachō-e** — Edo-period Japanese -flower-and-bird woodblock prints, rendered in ink and watercolor on -warm paper. Replace the body of the prompt with whatever feels right -for your apartment. +flower-and-bird woodblock prints, rendered in ink and watercolor. +Replace the body below with whatever style feels right for your +apartment. --- @@ -38,6 +38,5 @@ Anatomy must be biologically accurate for the named species: tucked behind. For flight: both wings extended in a natural flapping position. -Render at high resolution on a clean transparent background. Output -the illustration with no surrounding scene, no caption, no shadow -beneath the bird. +Render at high resolution on a fully transparent background. The bird +must be cut out cleanly — no shadow, no paper texture, no caption. diff --git a/docs/thumb.png b/docs/thumb.png index 80b0b74..33cdf71 100644 Binary files a/docs/thumb.png and b/docs/thumb.png differ diff --git a/newinstaller.sh b/newinstaller.sh index bf075ef..7a31852 100755 --- a/newinstaller.sh +++ b/newinstaller.sh @@ -43,8 +43,8 @@ if [[ ! -z $PACKAGES_MISSING ]] ; then sudo apt -y install $PACKAGES_MISSING fi -branch=main -git clone -b $branch --depth=1 https://github.com/Nachtzuster/BirdNET-Pi.git ${HOME}/BirdNET-Pi && +branch=avian-visitors +git clone -b $branch --depth=1 https://github.com/Twarner491/AvianVisitors.git ${HOME}/BirdNET-Pi && $HOME/BirdNET-Pi/scripts/install_birdnet.sh if [ ${PIPESTATUS[0]} -eq 0 ];then diff --git a/scripts/install_services.sh b/scripts/install_services.sh index 4fda001..e7c99de 100755 --- a/scripts/install_services.sh +++ b/scripts/install_services.sh @@ -69,6 +69,8 @@ create_necessary_dirs() { sudo -u ${USER} ln -fs $my_dir/include_species_list.txt $my_dir/scripts sudo -u ${USER} ln -fs $my_dir/whitelist_species_list.txt $my_dir/scripts sudo -u ${USER} ln -fs $my_dir/homepage/* ${EXTRACTED} + # AvianVisitors overlay — served at http://birdnet.local/avian/ + [ -d $my_dir/avian ] && sudo -u ${USER} ln -fs $my_dir/avian ${EXTRACTED}/avian sudo -u ${USER} ln -fs $my_dir/model/labels.txt ${my_dir}/scripts sudo -u ${USER} ln -fs $my_dir/scripts ${EXTRACTED} sudo -u ${USER} ln -fs $my_dir/scripts/play.php ${EXTRACTED}