[FEAT] frame: e-ink wall display mirroring the live collage

This commit is contained in:
Teddy Warner
2026-06-12 10:00:20 -07:00
committed by GitHub
parent 02dd83e516
commit cc9469d695
14 changed files with 34912 additions and 1 deletions
+8 -1
View File
@@ -86,9 +86,16 @@ avian/ # everything we add to BirdNET-Pi
├── api/ # PHP shims served by BirdNET-Pi's PHP-FPM ├── api/ # PHP shims served by BirdNET-Pi's PHP-FPM
├── scripts/ # generate -> cutout -> masks pipeline + prompt ├── scripts/ # generate -> cutout -> masks pipeline + prompt
└── forwarding/ # optional HA / MQTT / Cloudflare configs └── forwarding/ # optional HA / MQTT / Cloudflare configs
frame/ # optional e-ink wall display
``` ```
Everything outside `avian/` is upstream BirdNET-Pi. Everything outside `avian/` and `frame/` is upstream BirdNET-Pi.
---
## Wall frame
An optional e-ink frame mirrors the last 24h of birds onto a panel by your window. Build it from [`frame/`](frame/README.md).
--- ---
+6
View File
@@ -0,0 +1,6 @@
config.toml
.venv/
__pycache__/
*.pyc
*.png
.birdframe/
+58
View File
@@ -0,0 +1,58 @@
# AvianVisitors e-ink frame
*The last 24h of birds, framed on the wall by your window.*
A [Pimoroni Inky Impression 13.3"](https://shop.pimoroni.com/products/inky-impression-13-3) (Spectra 6) mirroring the live collage. A Pi screenshots the site, mats it onto an A5 opening, and pushes to the panel, refreshing only when the birds change. Build one of your own at [theodore.net/projects/AvianVisitors#frame-ous](https://theodore.net/projects/AvianVisitors/#frame-ous).
<img alt="avianvisitors frame" src="https://theodore.net/assets/images/AvianVisitors/final.jpg" />
---
### BOM
| Qty | Description | Price | Link |
|-----|-------------|-------|------|
| 1 | Raspberry Pi Zero (2) W | ~$35 | [Raspberry Pi](https://www.raspberrypi.com/products/) |
| 1 | 13.3" E Ink Display | $299.99 | [Amazon](https://a.co/d/0eGzAzpD) |
| 1 | A4 Wood Photo Frame | $21.99 | [Amazon](https://a.co/d/03lpjhgH) |
| 1 | Long, Flat Micro USB Cable | $7.99 | [Amazon](https://a.co/d/0a59rKSk) |
| 1 | Flat USB Brick | $7.59 | [Amazon](https://a.co/d/05OLRpvT) |
| | **Total** | **~$372** | | |
CAD + 3d print files can be found in [`hardware/`](hardware/).
---
## 1. Flash the SD card
Flash an sd card with Raspberry Pi OS Lite (64-bit) via [Raspberry Pi Imager](https://www.raspberrypi.com/software/). In the customisation dialog set:
- Username
- WiFi SSID + password
- Hostname: `birdpic`
- Enable SSH with password auth
Then install in Pi and power up.
## 2. Run the installer
```bash
ssh <your-username>@birdpic.local
git clone https://github.com/Twarner491/AvianVisitors
cd AvianVisitors/frame && ./install.sh
```
Enables SPI + I2C, installs the deps and a 15-minute systemd timer, writes `~/.birdframe/config.toml`, and reboots once to bring SPI up.
---
## 3. Point it at the collage
After it reboots and comes back, set your image source in `~/.birdframe/config.toml`. The Pi is too small to run a browser, so it fetches a ready-made PNG that the aggregator Worker renders at `/frame.png`, gated by a key:
```toml
base_url = "https://bird.onethreenine.net"
image_url = "https://bird.onethreenine.net/frame.png?k=YOUR_FRAME_KEY"
```
No Worker? Set `shoot = true` to screenshot on any capable host instead. Full options are in [`config.example.toml`](config.example.toml).
+55
View File
@@ -0,0 +1,55 @@
# AvianVisitors e-ink frame. Copy to config.toml and edit.
# config.toml is gitignored. Nothing here is secret unless your whole site is
# behind basic-auth (see the bottom).
# The collage to mirror. The Pi reads the recent API here to decide when a
# refresh is worth it, so point it at the public site (a Pi Zero W cannot reach
# a LAN BirdNET-Pi by default anyway).
base_url = "https://bird.onethreenine.net"
# base_url = "http://birdnet.local" # same-LAN BirdNET-Pi instead
hours = 24 # window shown on the frame
# Where the screenshot comes from. birdpic (a Pi Zero W) has no browser, so it
# fetches a ready-made PNG: the aggregator Worker renders the collage via
# Cloudflare Browser Rendering at /frame.png, gated by its FRAME_KEY secret.
# Put that key after k=.
shoot = false
image_url = "https://bird.onethreenine.net/frame.png?k=YOUR_FRAME_KEY"
# On a capable host (Pi 4/5, laptop) shoot inline instead of fetching:
# shoot = true
# Or read a PNG a sibling process drops next to us:
# image = "~/.birdframe/frame.png"
# Panel.
rotate = 90 # 90 or 270 if the frame hangs the other way up
saturation = 0.6 # Inky colour saturation, 0 to 1
mat = 0.0 # extra shrink of the content inside the A5 opening (0 = none)
# panel = "el133uf1" # force the 13.3" driver if auto-detect ever fails
# Refresh cadence. The panel refreshes only when the species set or a count
# bracket changes, plus a daily heal. Quiet hours are off so nocturnal
# detections still show; set quiet_start/quiet_end to a window to mute overnight.
quiet_start = 0 # 0/0 = always refresh on change; e.g. 22 and 6 mutes 22:00-06:00
quiet_end = 0
heal_hours = 24 # force a refresh at least this often
# Paths.
state = "~/.birdframe/state.json"
cache = "~/.birdframe"
timeout = 45
# Titles and look. Used only when shooting inline (shoot = true). With the
# Worker endpoint these are baked in, so leave them out.
# shoot_title = "onethreenine birds"
# shoot_subtitle = "heard today"
# shoot_headline_px = 42
# shoot_eyebrow_px = 18
# shoot_lowercase = false
# shoot_mat = 0.04
# shoot_small_floor = 0.04
# Only if your WHOLE site is behind basic-auth. The AvianVisitors default
# leaves the collage and detection API public, so usually leave these out.
# basic_user = "monalisa"
# basic_pass = "..."
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env python3
"""Frame-Pi client: turn a collage screenshot into Inky panel pixels.
Runs on the Pi Zero W on a systemd timer. Each run it decides whether a
refresh is worth it (the species set or call-count brackets changed, and it
is not quiet hours), then crops the title and collage from the screenshot,
centres and mats them, and pushes the result to the Inky Impression 13.3".
``--preview out.png`` writes an approximate 6-ink dither instead, so the
look can be checked on any machine without the panel.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import inspect
import io
import json
import os
import re
import statistics
import sys
import time
import urllib.request
from datetime import datetime
from PIL import Image, ImageChops, ImageDraw
try:
import tomllib
except ModuleNotFoundError: # Python < 3.11
import tomli as tomllib
PANEL_W, PANEL_H = 1200, 1600 # portrait; the panel itself is 1600x1200
# Approximate Spectra-6 inks, used only for --preview. On hardware the Inky
# library maps to the panel's real palette.
SPECTRA6 = [(236, 234, 223), (26, 26, 28), (165, 60, 56),
(198, 176, 74), (49, 71, 130), (58, 110, 72)]
DEFAULTS = {
"base_url": "http://birdnet.local",
"hours": 24,
"image": "", # local PNG written by the shooter
"image_url": "", # or a published screenshot URL
"shoot": False, # or capture inline (needs a browser; not a Zero W)
"shoot_title": None, "shoot_subtitle": None,
"shoot_headline_px": 42, "shoot_eyebrow_px": 18, "shoot_lowercase": False,
"shoot_mat": 0.04, "shoot_small_floor": 0.04,
"mat": 0.0, # extra global shrink of the content inside the A5 opening
"rotate": 90, # 90 or 270 if the frame hangs the other way up
"saturation": 0.6,
"panel": "", # "el133uf1" forces the 13.3" driver if auto() fails
"quiet_start": 0, "quiet_end": 0, # 0/0 = no quiet hours
"heal_hours": 24,
"state": "~/.birdframe/state.json",
"cache": "~/.birdframe",
"timeout": 45,
"basic_user": None, "basic_pass": None,
}
def _auth(cfg):
if not cfg.get("basic_user"):
return None
raw = f"{cfg['basic_user']}:{cfg.get('basic_pass') or ''}".encode()
return "Basic " + base64.b64encode(raw).decode()
# --- change detection -------------------------------------------------------
def slugify(sci):
return re.sub(r"[^a-z0-9]+", "-", sci.lower()).strip("-")
def _bucket(n):
for i, edge in enumerate((1, 2, 5, 15, 40, 100, 300, 1000)):
if n <= edge:
return i
return 8
def fetch_recent(base, hours, timeout, auth=None):
url = f"{base.rstrip('/')}/avian/api/birdnet-api.php?action=recent&hours={hours}"
req = urllib.request.Request(url, headers={"User-Agent": "AvianVisitors-frame/1.0"})
if auth:
req.add_header("Authorization", auth)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read(2_000_000)).get("species", [])
def signature(species):
items = sorted((slugify(s["sci"]), _bucket(int(s.get("n") or 1))) for s in species)
return hashlib.sha256(json.dumps(items).encode()).hexdigest()[:16]
# --- image ------------------------------------------------------------------
def get_image(src, timeout, auth=None):
if re.match(r"^https?://", src):
req = urllib.request.Request(src, headers={"User-Agent": "AvianVisitors-frame/1.0"})
if auth:
req.add_header("Authorization", auth)
with urllib.request.urlopen(req, timeout=timeout) as r:
return Image.open(io.BytesIO(r.read(20_000_000))).convert("RGB")
return Image.open(os.path.expanduser(src)).convert("RGB")
def fit_panel(img):
if img.size != (PANEL_W, PANEL_H):
img = img.resize((PANEL_W, PANEL_H), Image.LANCZOS)
return img
def _paper(img):
"""Median of the four corners, robust to a stray inked corner."""
w, h = img.size
px = (img.getpixel(p) for p in ((4, 4), (w - 5, 4), (4, h - 5), (w - 5, h - 5)))
return tuple(int(statistics.median(c)) for c in zip(*px))
# The mat opening is an A5 rectangle (1 : sqrt(2)) centred in the panel; the
# content floats inside it with `mat` of inner whitespace.
A5_H = PANEL_H * 0.7071 # A5 is 1/sqrt(2) of the panel height
A5_W = A5_H / 1.41421 # A5 aspect 1 : sqrt(2)
def _place(content, paper, mat):
s = min(A5_W * (1 - mat) / content.width, A5_H * (1 - mat) / content.height)
nw, nh = max(1, round(content.width * s)), max(1, round(content.height * s))
content = content.resize((nw, nh), Image.LANCZOS)
canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper)
canvas.paste(content, ((PANEL_W - nw) // 2, (PANEL_H - nh) // 2))
return canvas
def _region_bbox(img, paper, y0, y1):
region = img.crop((0, y0, img.width, y1))
diff = ImageChops.difference(region, Image.new("RGB", region.size, paper))
bb = diff.convert("L").point(lambda p: 255 if p > 34 else 0).getbbox()
return None if not bb else (bb[0], y0 + bb[1], bb[2], y0 + bb[3])
def _scale_w(img, target_w):
s = target_w / img.width
return img.resize((max(1, round(img.width * s)), max(1, round(img.height * s))), Image.LANCZOS)
def _centroid_x(img, paper):
"""Horizontal centre of ink weight (what the eye reads as centred)."""
m = ImageChops.difference(img, Image.new("RGB", img.size, paper)).convert("L")
cols = list(m.resize((img.width, 1), Image.BOX).tobytes())
total = sum(cols) or 1
return sum(x * v for x, v in enumerate(cols)) / total
# Content layout inside the A5 opening: the title and collage are sized
# independently (as fractions of the opening width), so tuning one leaves the
# other untouched. gap is a fraction of the opening height.
TITLE_FRAC, COLLAGE_FRAC, GAP_FRAC = 0.55, 0.66, 0.1
def mat_and_center(img, mat):
"""Crop the title and collage, size each to a fraction of the A5 opening,
stack with a gap, and centre on the panel."""
img = img.convert("RGB")
paper = _paper(img)
mask = ImageChops.difference(img, Image.new("RGB", img.size, paper))
mask = mask.convert("L").point(lambda p: 255 if p > 34 else 0)
full = mask.getbbox()
if not full:
return img
levels = list(mask.resize((1, img.height), Image.BOX).tobytes()) # per-row content
top, bot = full[1], full[3]
split, run = None, 0
for y in range(top, bot):
if levels[y] <= 2:
run += 1
if run >= 30: # first empty band of 30px splits title from collage
cy = y
while cy < bot and levels[cy] <= 2:
cy += 1
split = (y - run + 1, cy)
break
else:
run = 0
tb = _region_bbox(img, paper, top, split[0]) if split else None
cb = _region_bbox(img, paper, split[1], bot + 1) if split else None
box_w, box_h = A5_W * (1 - mat), A5_H * (1 - mat)
if not (tb and cb):
return _place(img.crop(full), paper, mat)
title = _scale_w(img.crop(tb), box_w * TITLE_FRAC)
collage = _scale_w(img.crop(cb), box_w * COLLAGE_FRAC)
gap = round(box_h * GAP_FRAC)
ccx = _centroid_x(collage, paper) # centre the collage by ink weight, not bbox
half = max(ccx, collage.width - ccx)
cw = round(max(title.width, 2 * half))
comp = Image.new("RGB", (cw, title.height + gap + collage.height), paper)
comp.paste(title, ((cw - title.width) // 2, 0))
comp.paste(collage, (round(cw / 2 - ccx), title.height + gap))
fit = min(box_w / comp.width, box_h / comp.height, 1.0) # shrink to the opening, never enlarge
if fit < 1.0:
comp = comp.resize((max(1, round(comp.width * fit)), max(1, round(comp.height * fit))), Image.LANCZOS)
canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper)
canvas.paste(comp, ((PANEL_W - comp.width) // 2, (PANEL_H - comp.height) // 2))
return canvas
def quantize_spectra6(img):
pal = Image.new("P", (1, 1))
flat = [c for ink in SPECTRA6 for c in ink]
flat += list(SPECTRA6[0]) * ((768 - len(flat)) // 3) # pad the 256-entry palette with paper
pal.putpalette(flat[:768])
return img.convert("RGB").quantize(palette=pal, dither=Image.Dither.FLOYDSTEINBERG).convert("RGB")
def _draw_mat_box(img):
"""Dev aid: outline the A5 mat opening so the matte and centring show."""
x0, y0 = round((PANEL_W - A5_W) / 2), round((PANEL_H - A5_H) / 2)
ImageDraw.Draw(img).rectangle((x0, y0, PANEL_W - x0 - 1, PANEL_H - y0 - 1),
outline=(170, 60, 56), width=2)
# --- hardware ---------------------------------------------------------------
def push_panel(img, rotate, saturation, panel=""):
"""Rotate to the panel's landscape buffer and push. Lazy import so this
module still loads on a machine without the Inky library."""
if rotate not in (90, 270):
print(f"rotate must be 90 or 270, not {rotate}; using 90", file=sys.stderr)
rotate = 90
if panel == "el133uf1":
from inky.inky_el133uf1 import Inky
dev = Inky(resolution=(1600, 1200))
else:
from inky.auto import auto
dev = auto()
buf = img.rotate(rotate, expand=True)
if buf.size != (dev.width, dev.height):
buf = buf.resize((dev.width, dev.height), Image.LANCZOS)
kw = {"saturation": saturation} if "saturation" in inspect.signature(dev.set_image).parameters else {}
dev.set_image(buf, **kw)
dev.show()
# --- state ------------------------------------------------------------------
def load_state(path):
try:
with open(os.path.expanduser(path)) as f:
return json.load(f)
except Exception:
return {"signature": None, "last_refresh": 0}
def save_state(path, sig, when):
path = os.path.expanduser(path)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump({"signature": sig, "last_refresh": when}, f)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path) # atomic: a power cut can't leave a half-written file
def in_quiet_hours(cfg, hour):
s, e = cfg["quiet_start"], cfg["quiet_end"]
if s == e:
return False
return s <= hour < e if s < e else hour >= s or hour < e
# --- run --------------------------------------------------------------------
def obtain_image(cfg):
if cfg["shoot"]:
from shoot import shoot
out = os.path.join(os.path.expanduser(cfg["cache"]), "shot.png")
os.makedirs(os.path.dirname(out), exist_ok=True)
shoot(cfg["base_url"], out, title=cfg["shoot_title"], subtitle=cfg["shoot_subtitle"],
headline_px=cfg["shoot_headline_px"], eyebrow_px=cfg["shoot_eyebrow_px"],
lowercase=cfg["shoot_lowercase"], mat=cfg["shoot_mat"],
small_floor=cfg["shoot_small_floor"], timeout_ms=cfg["timeout"] * 1000,
user=cfg["basic_user"], password=cfg["basic_pass"])
return Image.open(out).convert("RGB")
src = cfg["image_url"] or cfg["image"]
if not src:
raise ValueError("set image, image_url, or shoot in config")
return get_image(src, cfg["timeout"], _auth(cfg))
def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
now = time.time()
state = load_state(cfg["state"])
sig = None
if use_signature:
try:
sig = signature(fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], _auth(cfg)))
except Exception as e:
print(f"signature fetch failed: {e}", file=sys.stderr) # treat as no change
heal_due = now - state.get("last_refresh", 0) >= cfg["heal_hours"] * 3600
changed = (not use_signature) or (sig is not None and sig != state.get("signature"))
if not force and not preview:
if in_quiet_hours(cfg, datetime.now().hour):
print("quiet hours; skip")
return
if not changed and not heal_due:
print("no change; skip")
return
print("refresh:", "changed" if changed else "heal")
try:
img = fit_panel(obtain_image(cfg))
except Exception as e:
print(f"could not get image: {e}", file=sys.stderr) # keep last panel image
return
img = mat_and_center(img, cfg["mat"])
if preview:
out = quantize_spectra6(img)
if mat_box:
_draw_mat_box(out)
out.save(preview)
print(f"wrote preview {preview}")
return
try:
push_panel(img, cfg["rotate"], cfg["saturation"], cfg.get("panel", ""))
except Exception as e:
print(f"panel push failed: {e}", file=sys.stderr)
return
save_state(cfg["state"], sig if sig is not None else state.get("signature"), now)
print("panel updated")
def load_config(path):
cfg = dict(DEFAULTS)
if path:
with open(os.path.expanduser(path), "rb") as f:
cfg.update(tomllib.load(f))
return cfg
def main():
ap = argparse.ArgumentParser(description="Push the collage screenshot to the Inky panel.")
ap.add_argument("--config")
ap.add_argument("--base-url")
ap.add_argument("--image")
ap.add_argument("--image-url")
ap.add_argument("--preview", help="write a 6-ink preview PNG instead of pushing")
ap.add_argument("--rotate", type=int)
ap.add_argument("--force", action="store_true", help="refresh even if unchanged")
ap.add_argument("--no-signature", action="store_true", help="skip change detection")
ap.add_argument("--mat-box", action="store_true", help="dev: outline the mat window on the preview")
args = ap.parse_args()
cfg = load_config(args.config)
for key in ("base_url", "image", "image_url"):
val = getattr(args, key)
if val:
cfg[key] = val
if args.rotate is not None:
cfg["rotate"] = args.rotate
run(cfg, preview=args.preview, force=args.force, use_signature=not args.no_signature, mat_box=args.mat_box)
if __name__ == "__main__":
main()
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Install the AvianVisitors e-ink frame (display side) on a Raspberry Pi.
# Enables SPI + I2C, installs deps, makes a venv, installs the systemd timer.
set -euo pipefail
cd "$(dirname "$0")"
FRAME="$(pwd)"
CONFIG_TXT=/boot/firmware/config.txt
[ -f "$CONFIG_TXT" ] || CONFIG_TXT=/boot/config.txt
echo "1/5 Enabling SPI + I2C (Inky needs both; SPI with no chip-select)..."
sudo raspi-config nonint do_spi 0
sudo raspi-config nonint do_i2c 0
grep -q "^dtoverlay=spi0-0cs" "$CONFIG_TXT" || echo "dtoverlay=spi0-0cs" | sudo tee -a "$CONFIG_TXT" >/dev/null
echo "2/5 Installing system packages (build tools to compile spidev, libatlas3-base for numpy)..."
sudo apt-get update -qq
sudo apt-get install -y python3-venv python3-dev build-essential libatlas3-base
echo "3/5 Creating venv and installing Python deps..."
python3 -m venv .venv
.venv/bin/pip install -q --upgrade pip
.venv/bin/pip install -q -r requirements-frame.txt
echo "4/5 Setting up config..."
mkdir -p "$HOME/.birdframe"
[ -f "$HOME/.birdframe/config.toml" ] || cp config.example.toml "$HOME/.birdframe/config.toml"
echo "5/5 Installing systemd timer..."
sed "s|/home/monalisa/AvianVisitors/frame|$FRAME|g; s|/home/monalisa|$HOME|g; s|User=monalisa|User=$USER|" \
systemd/birdframe.service | sudo tee /etc/systemd/system/birdframe.service >/dev/null
sudo cp systemd/birdframe.timer /etc/systemd/system/birdframe.timer
sudo systemctl daemon-reload
sudo systemctl enable birdframe.timer
cat <<DONE
Installed. Set your image source in ~/.birdframe/config.toml (your /frame.png
key, or shoot = true); the panel fills itself in and refreshes every 15 min,
only when the birds change.
DONE
# SPI only takes effect on a reboot, so do it for the user. Skip if SPI is
# already up (e.g. a re-run) so we don't bounce a working frame.
if [ -e /dev/spidev0.0 ]; then
echo "SPI already active, no reboot needed."
else
echo "Rebooting to bring SPI up (back on its own in ~1 min)..."
sleep 4
sudo reboot
fi
+7
View File
@@ -0,0 +1,7 @@
# Frame Pi: display.py + the Inky panel.
# On a Pi Zero W (ARMv6) numpy needs libatlas3-base, and the panel needs SPI +
# I2C enabled. install.sh handles all of that, so prefer running install.sh on
# a fresh Pi rather than installing this file by hand.
Pillow>=10,<12
inky>=2.1,<3
tomli; python_version < "3.11"
+4
View File
@@ -0,0 +1,4 @@
# Screenshot host: shoot.py. Any 64-bit machine that can run headless Chromium
# (a Pi 4/5, a laptop, a small server). NOT a Pi Zero W (no ARMv6 browser).
# pip install -r requirements-shoot.txt && playwright install chromium
playwright>=1.40
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""Screenshot the live AvianVisitors collage for the e-ink frame.
Loads the real site (the LAN default http://birdnet.local, or a forwarded
public URL) at a portrait viewport, hides the controls, sets the frame
titles, and rewrites a few of the page's own apt.js tunables at capture time
(cluster bias, count-to-size exponent, a rare-bird floor). The result is the
actual website, framed for the wall, with no changes to AvianVisitors.
Needs a real headless browser, so it runs on any 64-bit capable machine, NOT
the Pi Zero W driving the panel. Writes a 1200x1600 PNG; display.py turns it
into panel pixels.
pip install playwright && playwright install chromium
python3 shoot.py --url https://bird.onethreenine.net \
--title "onethreenine birds" --subtitle "heard today" --out frame.png
"""
from __future__ import annotations
import argparse
import base64
import json
import re
import sys
from playwright.sync_api import TimeoutError as PWTimeout
from playwright.sync_api import sync_playwright
# Hide the controls and the other views, freeze animations. Titles + collage
# stay. Injected before first paint.
HIDE_CSS = """
.top, .slider, .return-to-atlas, #menu-dd, #detail-modal, #about-modal,
.admin-screen, #collageTip, .modal-backdrop, #v1, #v2 { display: none !important; }
.views { transform: none !important; }
*, *::before, *::after { animation: none !important; transition: none !important; }
html, body { background: var(--paper, #efece0) !important; }
"""
def _frame_css(headline_px, eyebrow_px, lowercase, pad_top, pad_side, pad_bottom, collage_vh):
css = (
f".stage {{ padding: {pad_top}px {pad_side}px {pad_bottom}px !important;"
f" box-sizing: border-box !important; justify-content: center !important; }}"
f".views {{ flex: 0 0 auto !important; height: {collage_vh}vh !important; }}"
f".view#v0 {{ height: 100% !important; flex: 1 1 100% !important; padding: 6px 0 !important; }}"
f".gcollage {{ max-width: none !important; }}"
f".static-head {{ padding: 0 8px 14px !important; }}"
f".static-head .pre {{ font-size: {eyebrow_px}px !important; }}"
f".static-head h1 {{ font-size: {headline_px}px !important; }}"
)
if lowercase:
css += ".static-head h1 { text-transform: none !important; }"
return css
def _safe_continue(route):
try:
route.continue_()
except Exception:
pass
def _make_api_handler(floor_frac, window_hours, auth):
"""Re-window action=recent (to preview busy days) and floor the rarest
counts so the packer draws them a little larger."""
def handler(route):
req = route.request
if "action=recent" not in req.url:
return route.continue_()
try:
url = re.sub(r"hours=\d+", f"hours={int(window_hours)}", req.url) if window_hours else req.url
kw = {"url": url}
if auth:
kw["headers"] = {**req.headers, "authorization": auth}
data = route.fetch(**kw).json()
sp = data.get("species", [])
if sp and floor_frac > 0:
floor = max((s.get("n") or 1) for s in sp) * floor_frac
for s in sp:
if (s.get("n") or 1) < floor:
s["n"] = max(1, round(floor))
route.fulfill(status=200, content_type="application/json", body=json.dumps(data))
except Exception as e:
print(f"recent-API rewrite skipped: {e}", file=sys.stderr)
_safe_continue(route)
return handler
def _make_js_handler(xbias, ybias, count_exp, pad, auth, misses):
"""Rewrite the collage tunables inside the page's apt.js at capture time."""
def handler(route):
try:
kw = {"headers": {**route.request.headers, "authorization": auth}} if auth else {}
js = route.fetch(**kw).text()
for pat, repl in ((r"var xBias = narrow \? 1 : T\.ellipseAspectBias;", f"var xBias = {xbias};"),
(r"var yBias = narrow \? 1\.7 : 1;", f"var yBias = {ybias};"),
(r"countExp:\s*[\d.]+,", f"countExp: {count_exp},"),
(r"var pad = narrow \? Math\.max\(1, COLLAGE_PAD - 1\) : COLLAGE_PAD;", f"var pad = {pad};")):
js, n = re.subn(pat, repl, js)
if not n:
misses.append(pat)
route.fulfill(status=200, content_type="application/javascript; charset=utf-8", body=js)
except Exception as e:
print(f"apt.js rewrite skipped: {e}", file=sys.stderr)
_safe_continue(route)
return handler
def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
headline_px=42, eyebrow_px=18, lowercase=False,
mat=0.04, collage_vh=52, cluster_xbias=1.0, cluster_ybias=1.2,
count_exp=0.4, cluster_pad=1, small_floor=0.04, window_hours=None,
timeout_ms=45000, user=None, password=None):
pad_side, pad_top, pad_bottom = int(vw * mat), int(vh * mat * 0.92), int(vh * mat)
auth = "Basic " + base64.b64encode(f"{user}:{password or ''}".encode()).decode() if user else None
with sync_playwright() as p:
browser = p.chromium.launch(args=["--force-color-profile=srgb"])
try:
ctx_kw = {"viewport": {"width": vw, "height": vh}, "device_scale_factor": dsf}
if user:
ctx_kw["http_credentials"] = {"username": user, "password": password or ""}
page = browser.new_context(**ctx_kw).new_page()
misses = []
page.route("**/birdnet-api.php**", _make_api_handler(small_floor, window_hours, auth))
page.route("**/apt.js*", _make_js_handler(cluster_xbias, cluster_ybias, count_exp, cluster_pad, auth, misses))
css = HIDE_CSS + _frame_css(headline_px, eyebrow_px, lowercase, pad_top, pad_side, pad_bottom, collage_vh)
page.add_init_script(
"document.addEventListener('DOMContentLoaded',function(){"
"var s=document.createElement('style');s.textContent=" + json.dumps(css) +
";document.head.appendChild(s);});")
resp = page.goto(url, wait_until="domcontentloaded", timeout=timeout_ms)
if resp is None or not resp.ok:
raise RuntimeError(f"site returned {resp.status if resp else 'no response'}")
page.wait_for_selector(".gtile", timeout=timeout_ms) # no collage -> fatal, keep the last frame
try:
page.wait_for_function(
"() => { const t=[...document.querySelectorAll('.gtile img')];"
" return t.length>0 && t.every(i=>i.complete && i.naturalWidth>0); }",
timeout=timeout_ms)
except PWTimeout:
print("some illustrations did not finish loading; capturing anyway", file=sys.stderr)
if misses:
raise RuntimeError(f"apt.js tunables not found ({len(misses)}); refusing to ship a half-tuned frame")
if title is not None:
page.evaluate("t=>{const e=document.querySelector('.static-head .pre'); if(e)e.textContent=t;}", title)
if subtitle is not None:
page.evaluate("s=>{const e=document.querySelector('.static-head h1'); if(e)e.textContent=s;}", subtitle)
page.wait_for_timeout(250)
# clip is CSS px; device_scale_factor scales the PNG to vw*dsf by vh*dsf = 1200x1600
page.screenshot(path=out, clip={"x": 0, "y": 0, "width": vw, "height": vh})
finally:
browser.close()
return out
def main():
ap = argparse.ArgumentParser(description="Screenshot the AvianVisitors collage for the e-ink frame.")
ap.add_argument("--url", default="http://birdnet.local")
ap.add_argument("--out", default="frame.png")
ap.add_argument("--title")
ap.add_argument("--subtitle")
ap.add_argument("--lowercase", action="store_true")
ap.add_argument("--headline-px", type=int, default=42)
ap.add_argument("--eyebrow-px", type=int, default=18)
ap.add_argument("--mat", type=float, default=0.04)
ap.add_argument("--collage-vh", type=float, default=52)
ap.add_argument("--cluster-xbias", type=float, default=1.0)
ap.add_argument("--cluster-ybias", type=float, default=1.2)
ap.add_argument("--count-exp", type=float, default=0.4)
ap.add_argument("--cluster-pad", type=int, default=1)
ap.add_argument("--small-floor", type=float, default=0.04)
ap.add_argument("--window-hours", type=int)
ap.add_argument("--width", type=int, default=600)
ap.add_argument("--height", type=int, default=800)
ap.add_argument("--dsf", type=int, default=2)
ap.add_argument("--user")
ap.add_argument("--password")
ap.add_argument("--timeout", type=int, default=45000)
a = ap.parse_args()
try:
shoot(a.url, a.out, title=a.title, subtitle=a.subtitle, vw=a.width, vh=a.height, dsf=a.dsf,
headline_px=a.headline_px, eyebrow_px=a.eyebrow_px, lowercase=a.lowercase,
mat=a.mat, collage_vh=a.collage_vh, cluster_xbias=a.cluster_xbias,
cluster_ybias=a.cluster_ybias, count_exp=a.count_exp, cluster_pad=a.cluster_pad,
small_floor=a.small_floor,
window_hours=a.window_hours, timeout_ms=a.timeout, user=a.user, password=a.password)
except Exception as e:
print(f"shoot failed: {e}", file=sys.stderr)
sys.exit(1)
print(f"wrote {a.out}")
if __name__ == "__main__":
main()
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=AvianVisitors e-ink frame update
Documentation=https://github.com/Twarner491/AvianVisitors
Wants=network-online.target
After=network-online.target
[Service]
# install.sh rewrites User= and the paths to match your install.
Type=oneshot
User=monalisa
WorkingDirectory=/home/monalisa/AvianVisitors/frame
ExecStart=/home/monalisa/AvianVisitors/frame/.venv/bin/python /home/monalisa/AvianVisitors/frame/display.py --config /home/monalisa/.birdframe/config.toml
Environment=PYTHONUNBUFFERED=1
Nice=10
TimeoutStartSec=180
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Update the e-ink bird frame on a schedule
[Timer]
OnBootSec=2min
OnUnitActiveSec=15min
Persistent=true
[Install]
WantedBy=timers.target