[FEAT] frame: BirdWeather refreshes every 15 min, only when the birds change

This commit is contained in:
Twarner491
2026-06-30 17:00:42 -07:00
parent 268b682c9d
commit 9915e5c86a
6 changed files with 143 additions and 64 deletions
+34 -9
View File
@@ -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