[FEAT] frame: e-ink wall display mirroring the live collage
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
config.toml
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.png
|
||||
.birdframe/
|
||||
@@ -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).
|
||||
@@ -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 = "..."
|
||||
@@ -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
Executable
+51
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user