Merge pull request #13 from Twarner491/eink-installer
Frame install flavors, fresh-clone collage fix, kit links, and hardware
This commit is contained in:
@@ -19,6 +19,13 @@ See it running at [bird.onethreenine.net](https://bird.onethreenine.net).
|
||||
|
||||
Optional: a [Gemini API key](https://aistudio.google.com/apikey) to restyle illustrations, an [eBird API key](https://ebird.org/api/keygen) to filter species by region.
|
||||
|
||||
### Kits
|
||||
|
||||
I offer the bird mic and the wall frame as separate electronics kits. I put up a store for some of my open-source projects and will soon be able to offer kits cheaper than buying all the components individually, once I start buying in bulk.
|
||||
|
||||
- [Bird mic kit](https://theodore.net/store/avian-mic/)
|
||||
- [Frame kit](https://theodore.net/store/avian-visitors/)
|
||||
|
||||
---
|
||||
|
||||
## 1. Flash the SD card
|
||||
|
||||
+20
-23
@@ -21,6 +21,13 @@ A [Pimoroni Inky Impression 13.3"](https://amzn.to/4xlAWr3) (Spectra 6) mirrorin
|
||||
|
||||
CAD + 3d print files can be found in [`hardware/`](hardware/).
|
||||
|
||||
### Kits
|
||||
|
||||
I offer the frame and the bird mic as separate electronics kits. I put up a store for some of my open-source projects and will soon be able to offer kits cheaper than buying all the components individually, once I start buying in bulk.
|
||||
|
||||
- [Frame kit](https://theodore.net/store/avian-visitors/)
|
||||
- [Bird mic kit](https://theodore.net/store/avian-mic/)
|
||||
|
||||
---
|
||||
|
||||
## 1. Flash the SD card
|
||||
@@ -39,32 +46,22 @@ Then install in Pi and power up.
|
||||
```bash
|
||||
ssh <your-username>@birdpic.local
|
||||
git clone https://github.com/Twarner491/AvianVisitors
|
||||
cd AvianVisitors/frame && ./install.sh
|
||||
cd AvianVisitors/frame
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## No bird mic? BirdWeather mode
|
||||
|
||||
To run the frame on its own, with no mic and no website, install with `--bird-weather` and your ZIP code instead of step 3:
|
||||
Pick how the frame gets its birds:
|
||||
|
||||
```bash
|
||||
cd AvianVisitors/frame && ./install.sh --bird-weather --zip 94107
|
||||
# Pair with your bird mic on the same network (birdnet.local). The default.
|
||||
./install.sh
|
||||
|
||||
# No microphone: draw the collage from BirdWeather for any ZIP code.
|
||||
./install.sh --bird-weather --zip 94107
|
||||
|
||||
# Bird mic hosted at a public URL: point the frame straight at it.
|
||||
./install.sh --image-url https://bird.onethreenine.net/frame.png?k=YOUR_FRAME_KEY
|
||||
```
|
||||
|
||||
It pulls the top recently-heard birds near that ZIP code from [BirdWeather](https://app.birdweather.com) and renders the collage on the Pi itself, refreshing every few hours. Cutouts load from this repo's illustrations on GitHub, so there is no image set to copy over; add illustrations for any local birds it is missing the same way you would for the site. ZIP codes with no station nearby fall back to the closest ones, and an optional `EBIRD_API_KEY` covers the most remote spots.
|
||||
Each one enables SPI + I2C, installs the deps and a systemd timer, writes `~/.birdframe/config.toml`, and reboots once to bring SPI up. Full options live in [`config.example.toml`](config.example.toml).
|
||||
|
||||
BirdWeather mode renders on the Pi from this repo's illustrations on GitHub, so there is no image set to copy over. ZIP codes with no station nearby fall back to the closest ones, and an optional `EBIRD_API_KEY` covers the most remote spots.
|
||||
|
||||
+37
-40
@@ -1,55 +1,52 @@
|
||||
# AvianVisitors e-ink frame. Copy to config.toml and edit.
|
||||
# AvianVisitors e-ink frame. install.sh writes this for you; edit to taste.
|
||||
# config.toml is gitignored. Nothing here is secret unless your whole site is
|
||||
# behind basic-auth (see the bottom).
|
||||
# behind basic-auth (see the bottom). This file is the full reference.
|
||||
|
||||
# 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
|
||||
# --- Where the collage comes from -------------------------------------------
|
||||
# Default: mirror the BirdNET-Pi on your network. This Pi screenshots it.
|
||||
base_url = "http://birdnet.local"
|
||||
shoot = true
|
||||
|
||||
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 fetch a ready-made PNG instead of rendering here (lighter, but needs
|
||||
# something that serves a frame image, e.g. a public Cloudflare Worker):
|
||||
# shoot = false
|
||||
# image_url = "https://bird.onethreenine.net/frame.png?k=YOUR_FRAME_KEY"
|
||||
# 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
|
||||
hours = 24 # window shown on the frame
|
||||
|
||||
# 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"
|
||||
# --- Titles and look (used when this Pi renders, i.e. shoot = true) ----------
|
||||
shoot_title = "Avian Visitors"
|
||||
shoot_subtitle = "Heard Today"
|
||||
# shoot_headline_px = 42
|
||||
# shoot_eyebrow_px = 18
|
||||
# shoot_lowercase = false
|
||||
# shoot_mat = 0.04
|
||||
# shoot_small_floor = 0.04
|
||||
# shoot_count_exp = 0.65 # tile size vs call count; higher = the loudest birds dominate more
|
||||
|
||||
# Only if your WHOLE site is behind basic-auth. The AvianVisitors default
|
||||
# leaves the collage and detection API public, so usually leave these out.
|
||||
# --- 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
|
||||
|
||||
# --- 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 = "..."
|
||||
|
||||
+45
-13
@@ -43,10 +43,10 @@ DEFAULTS = {
|
||||
"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": False, # or capture inline (needs a browser; the Zero 2 W handles it)
|
||||
"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,
|
||||
"shoot_mat": 0.04, "shoot_small_floor": 0.04, "shoot_count_exp": 0.65,
|
||||
"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,
|
||||
@@ -144,6 +144,11 @@ def _scale_w(img, target_w):
|
||||
return img.resize((max(1, round(img.width * s)), max(1, round(img.height * s))), Image.LANCZOS)
|
||||
|
||||
|
||||
def _scale_h(img, target_h):
|
||||
s = target_h / img.height
|
||||
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")
|
||||
@@ -155,10 +160,10 @@ def _centroid_x(img, paper):
|
||||
# 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
|
||||
TITLE_H_FRAC, COLLAGE_FRAC, GAP_FRAC = 0.065, 0.66, 0.1
|
||||
|
||||
|
||||
def mat_and_center(img, mat):
|
||||
def mat_and_center(img, mat, empty=False):
|
||||
"""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")
|
||||
@@ -174,7 +179,7 @@ def mat_and_center(img, mat):
|
||||
for y in range(top, bot):
|
||||
if levels[y] <= 2:
|
||||
run += 1
|
||||
if run >= 30: # first empty band of 30px splits title from collage
|
||||
if run >= 60: # split below the headline; a 60px band clears the ~30px eyebrow/headline gap so the title stays whole
|
||||
cy = y
|
||||
while cy < bot and levels[cy] <= 2:
|
||||
cy += 1
|
||||
@@ -185,20 +190,45 @@ def mat_and_center(img, mat):
|
||||
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)
|
||||
# No birds: the content under the title is just the one-line empty-state
|
||||
# note. Render a calm title card (a modest title with the small note
|
||||
# below) rather than blowing the lone title up to fill the opening.
|
||||
if empty and tb and cb:
|
||||
title = _scale_h(img.crop(tb), box_h * TITLE_H_FRAC)
|
||||
note = _scale_w(img.crop(cb), box_w * 0.30)
|
||||
gap = round(box_h * 0.05)
|
||||
cw = max(title.width, note.width)
|
||||
comp = Image.new("RGB", (cw, title.height + gap + note.height), paper)
|
||||
comp.paste(title, ((cw - title.width) // 2, 0))
|
||||
comp.paste(note, ((cw - note.width) // 2, title.height + gap))
|
||||
canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper)
|
||||
canvas.paste(comp, ((PANEL_W - comp.width) // 2, (PANEL_H - comp.height) // 2))
|
||||
return canvas
|
||||
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)
|
||||
title = _scale_h(img.crop(tb), box_h * TITLE_H_FRAC)
|
||||
gap = round(box_h * GAP_FRAC)
|
||||
# Size the collage to fill the room left under the fixed-size title,
|
||||
# binding on whichever of width or remaining height runs out first, so the
|
||||
# title stays a consistent size whether the collage is tall or compact
|
||||
# instead of ballooning when the collage happens to be short.
|
||||
coll = img.crop(cb)
|
||||
cs = min(box_w * COLLAGE_FRAC / coll.width, (box_h - title.height - gap) / coll.height)
|
||||
collage = coll.resize((max(1, round(coll.width * cs)), max(1, round(coll.height * cs))), Image.LANCZOS)
|
||||
ccx = _centroid_x(collage, paper) # centre the collage by ink weight, not bbox
|
||||
half = max(ccx, collage.width - ccx)
|
||||
# A wildly off-centre collage can push the centroid-mirrored width (2*half)
|
||||
# past the A5 opening; shrink only the collage, never the fixed-size title,
|
||||
# so nothing spills under the physical mat.
|
||||
if 2 * half > box_w:
|
||||
s = box_w / (2 * half)
|
||||
collage = collage.resize((max(1, round(collage.width * s)), max(1, round(collage.height * s))), Image.LANCZOS)
|
||||
ccx = round(ccx * s)
|
||||
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
|
||||
@@ -276,7 +306,7 @@ def obtain_image(cfg):
|
||||
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,
|
||||
small_floor=cfg["shoot_small_floor"], count_exp=cfg["shoot_count_exp"], 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"]
|
||||
@@ -289,9 +319,11 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
|
||||
now = time.time()
|
||||
state = load_state(cfg["state"])
|
||||
sig = None
|
||||
species = None
|
||||
if use_signature:
|
||||
try:
|
||||
sig = signature(fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], _auth(cfg)))
|
||||
species = fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], _auth(cfg))
|
||||
sig = signature(species)
|
||||
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
|
||||
@@ -310,7 +342,7 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
|
||||
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"])
|
||||
img = mat_and_center(img, cfg["mat"], empty=(species == []))
|
||||
if preview:
|
||||
out = quantize_spectra6(img)
|
||||
if mat_box:
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+412226
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+21776
-33526
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+117
-26
@@ -2,30 +2,69 @@
|
||||
# Install the AvianVisitors e-ink frame (display side) on a Raspberry Pi.
|
||||
# Enables SPI + I2C, installs deps, makes a venv, installs the systemd timer.
|
||||
#
|
||||
# Default mode mirrors a local bird mic (set the image source in the config).
|
||||
# Pass --bird-weather --zip <ZIP> to instead run the frame standalone from
|
||||
# BirdWeather data for that ZIP: it renders on the Pi and pulls cutouts from the
|
||||
# repo's raw GitHub URLs, so no mic and no local illustration folder are needed.
|
||||
# Three ways to feed the frame, pick one:
|
||||
# ./install.sh mirror the BirdNET-Pi on your network
|
||||
# (birdnet.local), rendered on this Pi
|
||||
# ./install.sh --image-url <URL> fetch a ready-made frame PNG instead
|
||||
# (e.g. a public Cloudflare Worker)
|
||||
# ./install.sh --bird-weather --zip <ZIP> standalone from BirdWeather, no mic
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
FRAME="$(pwd)"
|
||||
|
||||
BIRD_WEATHER=0
|
||||
MODE=local # local | image | birdweather
|
||||
ZIP=""
|
||||
IMAGE_URL=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--bird-weather) BIRD_WEATHER=1 ;;
|
||||
--zip) ZIP="${2:-}"; shift ;;
|
||||
--zip=*) ZIP="${1#*=}" ;;
|
||||
--bird-weather) MODE=birdweather; shift ;;
|
||||
--zip) [ $# -ge 2 ] || { echo "--zip needs a value, e.g. --zip 94107" >&2; exit 1; }
|
||||
ZIP="$2"; shift 2 ;;
|
||||
--zip=*) ZIP="${1#*=}"; shift ;;
|
||||
--image-url) [ $# -ge 2 ] || { echo "--image-url needs a URL, e.g. --image-url https://bird.example/frame.png" >&2; exit 1; }
|
||||
MODE=image; IMAGE_URL="$2"; shift 2 ;;
|
||||
--image-url=*) MODE=image; IMAGE_URL="${1#*=}"; shift ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
if [ "$BIRD_WEATHER" = 1 ] && [ -z "$ZIP" ]; then
|
||||
echo "--bird-weather needs --zip <ZIP code>, e.g. install.sh --bird-weather --zip 94107" >&2
|
||||
|
||||
if [ -n "$ZIP" ] && [ "$MODE" != birdweather ]; then
|
||||
echo "--zip only applies with --bird-weather" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate inputs up front: a bad value would otherwise land in a config file or
|
||||
# a systemd unit verbatim. These checks also reject a flag passed as a value
|
||||
# (e.g. "--zip --image-url"), which would fail the format below.
|
||||
if [ "$MODE" = birdweather ]; then
|
||||
if [ -z "$ZIP" ]; then
|
||||
echo "--bird-weather needs --zip <ZIP code>, e.g. install.sh --bird-weather --zip 94107" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! printf '%s' "$ZIP" | LC_ALL=C grep -qE '^[A-Za-z0-9][A-Za-z0-9 -]{1,9}$'; then
|
||||
echo "--zip should look like a postal code, e.g. 94107 or SW1A 1AA" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if [ "$MODE" = image ]; then
|
||||
if [ -z "$IMAGE_URL" ]; then
|
||||
echo "--image-url needs a URL, e.g. install.sh --image-url https://bird.example/frame.png" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$IMAGE_URL" in
|
||||
http://*|https://*) ;;
|
||||
*) echo "--image-url must start with http:// or https://" >&2; exit 1 ;;
|
||||
esac
|
||||
if printf '%s' "$IMAGE_URL" | LC_ALL=C grep -q '[^A-Za-z0-9._~:/?#@!$&()*+,;=%-]'; then
|
||||
echo "--image-url has characters that are not allowed in a URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# local + birdweather render on the Pi (need a browser); image only fetches.
|
||||
NEEDS_BROWSER=1
|
||||
if [ "$MODE" = image ]; then NEEDS_BROWSER=0; fi
|
||||
|
||||
CONFIG_TXT=/boot/firmware/config.txt
|
||||
[ -f "$CONFIG_TXT" ] || CONFIG_TXT=/boot/config.txt
|
||||
|
||||
@@ -42,19 +81,58 @@ 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
|
||||
if [ "$BIRD_WEATHER" = 1 ]; then
|
||||
echo " BirdWeather mode: installing Playwright + Chromium for on-Pi rendering (a few minutes)..."
|
||||
if [ "$NEEDS_BROWSER" = 1 ]; then
|
||||
echo " Installing Playwright + Chromium so the Pi can render the collage (a few minutes)..."
|
||||
.venv/bin/pip install -q playwright
|
||||
sudo .venv/bin/playwright install-deps chromium
|
||||
.venv/bin/playwright install chromium
|
||||
fi
|
||||
|
||||
echo "4/5 Setting up config..."
|
||||
echo "4/5 Writing config..."
|
||||
mkdir -p "$HOME/.birdframe"
|
||||
[ -f "$HOME/.birdframe/config.toml" ] || cp config.example.toml "$HOME/.birdframe/config.toml"
|
||||
CONFIG="$HOME/.birdframe/config.toml"
|
||||
if [ -f "$CONFIG" ]; then
|
||||
EXISTING="$(sed -n 's/^# birdframe-mode: //p' "$CONFIG" | head -1)"
|
||||
if [ -n "$EXISTING" ] && [ "$EXISTING" != "$MODE" ]; then
|
||||
echo " $CONFIG is set up for '$EXISTING' mode, not '$MODE'." >&2
|
||||
echo " To switch, remove it and re-run: rm $CONFIG" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo " $CONFIG already exists, leaving it untouched."
|
||||
elif [ "$MODE" = local ]; then
|
||||
cat > "$CONFIG" <<'CFG'
|
||||
# birdframe-mode: local
|
||||
# AvianVisitors frame, local mode: mirrors the BirdNET-Pi on your network.
|
||||
# This Pi screenshots birdnet.local itself, so there is nothing else to set up.
|
||||
base_url = "http://birdnet.local"
|
||||
shoot = true
|
||||
shoot_title = "Avian Visitors"
|
||||
shoot_subtitle = "Heard Today"
|
||||
rotate = 90 # flip to 270 if the frame hangs the other way up
|
||||
saturation = 0.6
|
||||
timeout = 45
|
||||
# If your BirdNET-Pi is behind basic-auth, uncomment and set these:
|
||||
# basic_user = "..."
|
||||
# basic_pass = "..."
|
||||
CFG
|
||||
elif [ "$MODE" = image ]; then
|
||||
BASE="$(printf '%s' "$IMAGE_URL" | sed -E 's#^(https?://[^/]+).*#\1#')"
|
||||
# printf, not a heredoc: the URL is written literally, never shell-expanded.
|
||||
{
|
||||
printf '%s\n' '# birdframe-mode: image'
|
||||
printf '%s\n' '# AvianVisitors frame, image mode: fetches a ready-made frame PNG.'
|
||||
printf 'base_url = "%s"\n' "$BASE"
|
||||
printf 'image_url = "%s"\n' "$IMAGE_URL"
|
||||
printf '%s\n' 'shoot = false'
|
||||
printf '%s\n' 'rotate = 90 # flip to 270 if the frame hangs the other way up'
|
||||
printf '%s\n' 'saturation = 0.6'
|
||||
} > "$CONFIG"
|
||||
else
|
||||
{ printf '%s\n' '# birdframe-mode: birdweather'; cat config.example.toml; } > "$CONFIG"
|
||||
fi
|
||||
|
||||
echo "5/5 Installing systemd service + timer..."
|
||||
if [ "$BIRD_WEATHER" = 1 ]; then
|
||||
if [ "$MODE" = birdweather ]; then
|
||||
PY="$FRAME/.venv/bin/python"
|
||||
PNG="$HOME/.birdframe/frame.png"
|
||||
sudo tee /etc/systemd/system/birdframe.service >/dev/null <<SERVICE
|
||||
@@ -77,6 +155,7 @@ SERVICE
|
||||
sed 's|OnUnitActiveSec=.*|OnUnitActiveSec=6h|' systemd/birdframe.timer \
|
||||
| sudo tee /etc/systemd/system/birdframe.timer >/dev/null
|
||||
else
|
||||
# local + image both run display.py against the config; only the config differs.
|
||||
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
|
||||
@@ -84,21 +163,33 @@ fi
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now birdframe.timer # --now starts it immediately, not only on the next boot
|
||||
|
||||
if [ "$BIRD_WEATHER" = 1 ]; then
|
||||
cat <<DONE
|
||||
case "$MODE" in
|
||||
local)
|
||||
cat <<DONE
|
||||
|
||||
Installed. The frame mirrors birdnet.local on your network and refreshes every
|
||||
15 min, only when the birds change. Until the mic has heard its first bird it
|
||||
shows a plain title card. If the panel hangs upside down, set rotate = 270 in
|
||||
~/.birdframe/config.toml.
|
||||
DONE
|
||||
;;
|
||||
image)
|
||||
cat <<DONE
|
||||
|
||||
Installed. The frame fetches its image from
|
||||
$IMAGE_URL
|
||||
and refreshes every 15 min, only when the birds change.
|
||||
DONE
|
||||
;;
|
||||
birdweather)
|
||||
cat <<DONE
|
||||
|
||||
Installed in BirdWeather mode for ZIP $ZIP. The frame renders the top birds
|
||||
near you on the Pi and refreshes every 6 hours. Cutouts come from the repo on
|
||||
GitHub, so add illustrations there for any local birds it is missing.
|
||||
DONE
|
||||
else
|
||||
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
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# 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.
|
||||
|
||||
+17
-8
@@ -184,14 +184,19 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
|
||||
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)
|
||||
# Wait for the collage, or for the empty-state element the page shows
|
||||
# when the mic has heard nothing yet, so a birdless frame renders a
|
||||
# clean title card fast instead of hanging until the timeout. A page
|
||||
# with neither still times out here and stays fatal (keep last frame).
|
||||
page.wait_for_selector(".gtile, .empty", state="attached", timeout=timeout_ms)
|
||||
if page.query_selector(".gtile") is not None:
|
||||
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")
|
||||
|
||||
@@ -199,6 +204,10 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
|
||||
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)
|
||||
# Soften the empty-state line for a fresh frame whose mic hasn't
|
||||
# heard a bird yet, and darken it so it survives the e-ink dither and
|
||||
# the matting step's ink detection (a no-op once there are birds).
|
||||
page.evaluate("() => { const e = document.querySelector('.empty'); if (e) { e.textContent = 'listening for birds…'; e.style.color = '#555'; } }")
|
||||
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})
|
||||
|
||||
@@ -238,6 +238,13 @@ EOF
|
||||
usermod -aG $USER caddy
|
||||
usermod -aG video caddy
|
||||
chmod g+r+x $HOME
|
||||
|
||||
# Serve the AvianVisitors collage at / rather than the stock BirdNET-Pi UI.
|
||||
# The Caddyfile written above is the stock one (hardcoded php-fpm.sock, no
|
||||
# index.html try_files override); re-apply both through update_caddyfile.sh,
|
||||
# the single source of truth, so / serves index.html not index.php. Run it
|
||||
# last so it wins.
|
||||
"$HOME/BirdNET-Pi/scripts/update_caddyfile.sh"
|
||||
}
|
||||
|
||||
install_avahi_aliases() {
|
||||
|
||||
@@ -84,4 +84,12 @@ EOF
|
||||
fi
|
||||
|
||||
sudo caddy fmt --overwrite /etc/caddy/Caddyfile
|
||||
sudo systemctl reload caddy
|
||||
# Fail loudly on a Caddyfile caddy can't parse rather than reloading a broken
|
||||
# config and reporting success.
|
||||
sudo caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile || {
|
||||
echo "generated Caddyfile failed validation; not reloading caddy" >&2
|
||||
exit 1
|
||||
}
|
||||
# reload-or-restart so this also works at install time, when caddy may not be
|
||||
# running yet (a plain reload would fail there); tolerate a not-yet-ready unit.
|
||||
sudo systemctl reload-or-restart caddy 2>/dev/null || true
|
||||
|
||||
Reference in New Issue
Block a user