[FEAT] frame: BirdWeather refreshes every 15 min, only when the birds change
This commit is contained in:
+29
-3
@@ -23,6 +23,7 @@ EBIRD = "https://api.ebird.org/v2/data/obs/geo/recent"
|
||||
APT_JS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "avian", "frontend", "apt.js")
|
||||
|
||||
_drawable = None
|
||||
_GEO_CACHE = os.path.expanduser("~/.birdframe/geocode.json")
|
||||
|
||||
|
||||
def _graphql(query, timeout):
|
||||
@@ -39,12 +40,37 @@ def _graphql(query, timeout):
|
||||
|
||||
|
||||
def geocode(zip_code, country="us", timeout=20):
|
||||
"""ZIP / postal code to (lat, lon) via the keyless zippopotam.us gazetteer."""
|
||||
url = f"{GEOCODER}/{country}/{urllib.parse.quote(zip_code.strip())}"
|
||||
"""ZIP / postal code to (lat, lon) via the keyless zippopotam.us gazetteer.
|
||||
A ZIP's coordinates never move, so the result is cached on disk and the
|
||||
frame geocodes once rather than on every 15-minute refresh."""
|
||||
zip_code = zip_code.strip()
|
||||
key = f"{country}/{zip_code}".lower()
|
||||
store = {}
|
||||
try:
|
||||
with open(_GEO_CACHE) as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
store = loaded
|
||||
hit = store.get(key)
|
||||
if isinstance(hit, list) and len(hit) == 2: # shape-guard a hand-edited / stale entry
|
||||
return float(hit[0]), float(hit[1])
|
||||
except Exception:
|
||||
store = {}
|
||||
url = f"{GEOCODER}/{country}/{urllib.parse.quote(zip_code)}"
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "AvianVisitors-frame/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
place = json.loads(r.read(200_000))["places"][0]
|
||||
return float(place["latitude"]), float(place["longitude"])
|
||||
lat, lon = float(place["latitude"]), float(place["longitude"])
|
||||
store[key] = [lat, lon]
|
||||
try:
|
||||
os.makedirs(os.path.dirname(_GEO_CACHE), exist_ok=True)
|
||||
tmp = _GEO_CACHE + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(store, f)
|
||||
os.replace(tmp, _GEO_CACHE) # atomic, like display.py's state write
|
||||
except Exception:
|
||||
pass
|
||||
return lat, lon
|
||||
|
||||
|
||||
def bbox(lat, lon, miles):
|
||||
|
||||
@@ -14,6 +14,14 @@ shoot = true
|
||||
# Or read a PNG a sibling process drops next to us:
|
||||
# image = "~/.birdframe/frame.png"
|
||||
|
||||
# Or render from BirdWeather for any ZIP, no mic (what install.sh --bird-weather
|
||||
# writes). The panel still refreshes only when the local top birds change:
|
||||
# species_source = "birdweather"
|
||||
# zip = "94107"
|
||||
# bw_days = 7 # BirdWeather lookback window, in days
|
||||
# bw_country = "us" # geocoder country for the ZIP
|
||||
# shoot = true # this Pi renders the collage
|
||||
|
||||
hours = 24 # window shown on the frame
|
||||
|
||||
# --- Titles and look (used when this Pi renders, i.e. shoot = true) ----------
|
||||
|
||||
+34
-9
@@ -40,6 +40,10 @@ SPECTRA6 = [(236, 234, 223), (26, 26, 28), (165, 60, 56),
|
||||
|
||||
DEFAULTS = {
|
||||
"base_url": "http://birdnet.local",
|
||||
"species_source": "", # "" = the recent API at base_url; "birdweather" = BirdWeather near a ZIP
|
||||
"zip": "", # BirdWeather ZIP / postal code (with species_source = "birdweather")
|
||||
"bw_days": 7, # BirdWeather lookback window, in days
|
||||
"bw_country": "us", # geocoder country for the ZIP
|
||||
"hours": 24,
|
||||
"image": "", # local PNG written by the shooter
|
||||
"image_url": "", # or a published screenshot URL
|
||||
@@ -93,6 +97,16 @@ def signature(species):
|
||||
return hashlib.sha256(json.dumps(items).encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def fetch_species(cfg, auth=None):
|
||||
"""The species list the signature is built from: the BirdNET-Pi recent API
|
||||
by default, or BirdWeather's recent detections near a ZIP when
|
||||
species_source = "birdweather"."""
|
||||
if cfg.get("species_source") == "birdweather":
|
||||
import birdweather
|
||||
return birdweather.species_for_zip(cfg["zip"], country=cfg["bw_country"], days=cfg["bw_days"])
|
||||
return fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], auth)
|
||||
|
||||
|
||||
# --- image ------------------------------------------------------------------
|
||||
def get_image(src, timeout, auth=None):
|
||||
if re.match(r"^https?://", src):
|
||||
@@ -190,17 +204,19 @@ def mat_and_center(img, mat, empty=False):
|
||||
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.
|
||||
# No birds yet: hold the title where a full collage would put it and float
|
||||
# the one-line note where the birds would be, so a birdless frame reads like
|
||||
# a full one with the collage removed, not a title card drifted to the middle.
|
||||
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)
|
||||
gap = round(box_h * GAP_FRAC)
|
||||
region = box_w * COLLAGE_FRAC # stand in for a usual-size collage
|
||||
cw = max(title.width, note.width)
|
||||
comp = Image.new("RGB", (cw, title.height + gap + note.height), paper)
|
||||
comp = Image.new("RGB", (cw, round(title.height + gap + region)), paper)
|
||||
comp.paste(title, ((cw - title.width) // 2, 0))
|
||||
comp.paste(note, ((cw - note.width) // 2, title.height + gap))
|
||||
ny = title.height + gap + max(0, (round(region) - note.height) // 2)
|
||||
comp.paste(note, ((cw - note.width) // 2, ny))
|
||||
canvas = Image.new("RGB", (PANEL_W, PANEL_H), paper)
|
||||
canvas.paste(comp, ((PANEL_W - comp.width) // 2, (PANEL_H - comp.height) // 2))
|
||||
return canvas
|
||||
@@ -298,7 +314,16 @@ def in_quiet_hours(cfg, hour):
|
||||
|
||||
|
||||
# --- run --------------------------------------------------------------------
|
||||
def obtain_image(cfg):
|
||||
def obtain_image(cfg, species=None):
|
||||
if cfg.get("species_source") == "birdweather":
|
||||
from shoot import shoot_birdweather
|
||||
if species is None: # gate skipped (--no-signature): fetch the list to render
|
||||
species = fetch_species(cfg, _auth(cfg))
|
||||
out = os.path.join(os.path.expanduser(cfg["cache"]), "frame.png")
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
shoot_birdweather(out, species, title=cfg["shoot_title"], subtitle=cfg["shoot_subtitle"],
|
||||
timeout_ms=cfg["timeout"] * 1000)
|
||||
return Image.open(out).convert("RGB")
|
||||
if cfg["shoot"]:
|
||||
from shoot import shoot
|
||||
out = os.path.join(os.path.expanduser(cfg["cache"]), "shot.png")
|
||||
@@ -322,7 +347,7 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
|
||||
species = None
|
||||
if use_signature:
|
||||
try:
|
||||
species = fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], _auth(cfg))
|
||||
species = fetch_species(cfg, _auth(cfg))
|
||||
sig = signature(species)
|
||||
except Exception as e:
|
||||
print(f"signature fetch failed: {e}", file=sys.stderr) # treat as no change
|
||||
@@ -338,7 +363,7 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
|
||||
print("refresh:", "changed" if changed else "heal")
|
||||
|
||||
try:
|
||||
img = fit_panel(obtain_image(cfg))
|
||||
img = fit_panel(obtain_image(cfg, species))
|
||||
except Exception as e:
|
||||
print(f"could not get image: {e}", file=sys.stderr) # keep last panel image
|
||||
return
|
||||
|
||||
+25
-33
@@ -141,42 +141,34 @@ elif [ "$MODE" = image ]; then
|
||||
printf '%s\n' 'saturation = 0.6'
|
||||
} > "$CONFIG"
|
||||
else
|
||||
{ printf '%s\n' '# birdframe-mode: birdweather'; cat config.example.toml; } > "$CONFIG"
|
||||
# birdweather: this Pi renders from BirdWeather near $ZIP, gated on the same
|
||||
# signature as the other modes - it only redraws when the local top birds change.
|
||||
{
|
||||
printf '%s\n' '# birdframe-mode: birdweather'
|
||||
printf '%s\n' '# AvianVisitors frame, BirdWeather mode: renders the top birds near a ZIP.'
|
||||
printf '%s\n' 'species_source = "birdweather"'
|
||||
printf 'zip = "%s"\n' "$ZIP"
|
||||
printf '%s\n' 'bw_days = 7 # BirdWeather lookback window, in days'
|
||||
printf '%s\n' 'bw_country = "us" # geocoder country for the ZIP'
|
||||
printf '%s\n' 'shoot = true # this Pi renders the collage'
|
||||
printf '%s\n' 'shoot_title = "Avian Visitors"'
|
||||
printf '%s\n' 'shoot_subtitle = "Heard Today"'
|
||||
printf '%s\n' 'rotate = 90 # flip to 270 if the frame hangs the other way up'
|
||||
printf '%s\n' 'saturation = 0.6'
|
||||
} > "$CONFIG"
|
||||
fi
|
||||
|
||||
echo "5/5 Installing systemd service + timer..."
|
||||
if [ "$MODE" = birdweather ]; then
|
||||
PY="$FRAME/.venv/bin/python"
|
||||
PNG="$HOME/.birdframe/frame.png"
|
||||
sudo tee /etc/systemd/system/birdframe.service >/dev/null <<SERVICE
|
||||
[Unit]
|
||||
Description=AvianVisitors frame, BirdWeather mode (ZIP $ZIP)
|
||||
Documentation=https://github.com/Twarner491/AvianVisitors
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=$USER
|
||||
WorkingDirectory=$FRAME
|
||||
ExecStart=/bin/sh -c '$PY $FRAME/shoot.py --bird-weather --zip "$ZIP" --out $PNG && $PY $FRAME/display.py --config $HOME/.birdframe/config.toml --image-url $PNG --no-signature --force'
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Nice=10
|
||||
TimeoutStartSec=300
|
||||
SERVICE
|
||||
# Remote ZIPs with no nearby station fall back to eBird, which needs a key.
|
||||
if [ -n "$EBIRD_KEY" ]; then
|
||||
echo "Environment=EBIRD_API_KEY=$EBIRD_KEY" | sudo tee -a /etc/systemd/system/birdframe.service >/dev/null
|
||||
fi
|
||||
# BirdWeather's recent-species list drifts slowly, so refresh a few times a day.
|
||||
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
|
||||
# Every mode runs display.py against the config on the standard 15-minute timer;
|
||||
# only the config differs. display.py renders inline for local + birdweather and
|
||||
# pushes to the panel only when the birds change.
|
||||
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
|
||||
# BirdWeather's remote-ZIP eBird fallback reads its key from the unit environment.
|
||||
if [ "$MODE" = birdweather ] && [ -n "$EBIRD_KEY" ]; then
|
||||
echo "Environment=EBIRD_API_KEY=$EBIRD_KEY" | sudo tee -a /etc/systemd/system/birdframe.service >/dev/null
|
||||
fi
|
||||
sudo cp systemd/birdframe.timer /etc/systemd/system/birdframe.timer
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now birdframe.timer # --now starts it immediately, not only on the next boot
|
||||
|
||||
@@ -202,7 +194,7 @@ DONE
|
||||
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.
|
||||
you on the Pi and refreshes every 15 min, only when the local top birds change.
|
||||
DONE
|
||||
# The bundled illustrations center on the western U.S. If birds near this ZIP
|
||||
# aren't in the cloned set the frame quietly skips them, which has tripped
|
||||
|
||||
+46
-18
@@ -225,6 +225,30 @@ def shoot(url, out, *, title=None, subtitle=None, vw=600, vh=800, dsf=2,
|
||||
return out
|
||||
|
||||
|
||||
def shoot_birdweather(out, species, *, title=None, subtitle=None, timeout_ms=45000, **look):
|
||||
"""Render `species` ([{sci,com,n}]) as the BirdWeather collage into `out`.
|
||||
|
||||
The mic path screenshots a live site; this builds the same page from a
|
||||
species list instead. It serves the bundled frontend on localhost, feeds it
|
||||
the species, and routes cutouts to the local clone first then GitHub, so the
|
||||
--bird-weather CLI and display.py's inline render share one setup. An empty
|
||||
list renders the page's empty-state card, the same as the mic mode. `look`
|
||||
overrides any shoot() tunable (the CLI passes its flags through)."""
|
||||
if species is None:
|
||||
raise RuntimeError("shoot_birdweather needs a species list")
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
_httpd, port = _serve_frontend(os.path.join(here, "..", "avian", "frontend"))
|
||||
cutout_local = os.path.join(here, "..", "avian", "assets", "illustrations")
|
||||
# BirdWeather's flat 7-day counts need a steeper exponent for the same hero
|
||||
# hierarchy; the slightly smaller titles match the mic frame's optical weight.
|
||||
for k, v in (("count_exp", 1.0), ("headline_px", 39), ("eyebrow_px", 17)):
|
||||
look.setdefault(k, v)
|
||||
return shoot(f"http://127.0.0.1:{port}/", out,
|
||||
title=title or "Avian Visitors", subtitle=subtitle or "Heard Today",
|
||||
species=species, cutout_base=RAW_ILLUSTRATIONS, cutout_local=cutout_local,
|
||||
timeout_ms=timeout_ms, **look)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Screenshot the AvianVisitors collage for the e-ink frame.")
|
||||
ap.add_argument("--url", default="http://birdnet.local")
|
||||
@@ -257,7 +281,6 @@ def main():
|
||||
ap.add_argument("--password")
|
||||
ap.add_argument("--timeout", type=int, default=45000)
|
||||
a = ap.parse_args()
|
||||
url, title, subtitle, species, cutout_base, cutout_local = a.url, a.title, a.subtitle, None, None, None
|
||||
if a.bird_weather:
|
||||
if not a.zip:
|
||||
print("--bird-weather needs --zip", file=sys.stderr)
|
||||
@@ -268,28 +291,33 @@ def main():
|
||||
if not species:
|
||||
print(f"no drawable birds near {a.zip}; nothing to render", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
front = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "avian", "frontend")
|
||||
_httpd, port = _serve_frontend(front)
|
||||
url = f"http://127.0.0.1:{port}/"
|
||||
cutout_base = RAW_ILLUSTRATIONS
|
||||
# Locally generated cutouts may not be on GitHub yet; serve the clone first.
|
||||
cutout_local = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "avian", "assets", "illustrations")
|
||||
title = title or "Avian Visitors"
|
||||
subtitle = subtitle or "Heard Today"
|
||||
# BirdWeather's 7-day counts are flatter than a mic's, so they need a steeper
|
||||
# exponent to get the same hero-bird hierarchy and collage shape.
|
||||
count_exp = a.count_exp if a.count_exp is not None else (1.0 if a.bird_weather else 0.4)
|
||||
headline_px = a.headline_px if a.headline_px is not None else (39 if a.bird_weather else 42)
|
||||
eyebrow_px = a.eyebrow_px if a.eyebrow_px is not None else (17 if a.bird_weather else 18)
|
||||
# Pass the CLI's look flags through; shoot_birdweather fills the bird-weather
|
||||
# defaults (steeper count exponent, smaller titles) for anything left unset.
|
||||
look = {k: v for k, v in (("count_exp", a.count_exp), ("headline_px", a.headline_px),
|
||||
("eyebrow_px", a.eyebrow_px)) if v is not None}
|
||||
look.update(vw=a.width, vh=a.height, dsf=a.dsf, mat=a.mat, collage_vh=a.collage_vh,
|
||||
cluster_xbias=a.cluster_xbias, cluster_ybias=a.cluster_ybias,
|
||||
cluster_pad=a.cluster_pad, small_floor=a.small_floor, lowercase=a.lowercase,
|
||||
window_hours=a.window_hours, user=a.user, password=a.password)
|
||||
try:
|
||||
shoot_birdweather(a.out, species, title=a.title, subtitle=a.subtitle,
|
||||
timeout_ms=a.timeout, **look)
|
||||
except Exception as e:
|
||||
print(f"shoot failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"wrote {a.out}")
|
||||
return
|
||||
# Mic path: screenshot the live AvianVisitors site at --url.
|
||||
count_exp = a.count_exp if a.count_exp is not None else 0.4
|
||||
headline_px = a.headline_px if a.headline_px is not None else 42
|
||||
eyebrow_px = a.eyebrow_px if a.eyebrow_px is not None else 18
|
||||
try:
|
||||
shoot(url, a.out, title=title, subtitle=subtitle, vw=a.width, vh=a.height, dsf=a.dsf,
|
||||
shoot(a.url, a.out, title=a.title, subtitle=a.subtitle, vw=a.width, vh=a.height, dsf=a.dsf,
|
||||
headline_px=headline_px, eyebrow_px=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=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,
|
||||
species=species, cutout_base=cutout_base, cutout_local=cutout_local)
|
||||
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)
|
||||
|
||||
@@ -12,4 +12,4 @@ 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
|
||||
TimeoutStartSec=300
|
||||
|
||||
Reference in New Issue
Block a user