diff --git a/frame/birdweather.py b/frame/birdweather.py index 625aa36..21df495 100644 --- a/frame/birdweather.py +++ b/frame/birdweather.py @@ -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): diff --git a/frame/config.example.toml b/frame/config.example.toml index 1c0ed04..69545d9 100644 --- a/frame/config.example.toml +++ b/frame/config.example.toml @@ -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) ---------- diff --git a/frame/display.py b/frame/display.py index 9357efc..9a4cd99 100644 --- a/frame/display.py +++ b/frame/display.py @@ -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 diff --git a/frame/install.sh b/frame/install.sh index cf09bda..c421467 100755 --- a/frame/install.sh +++ b/frame/install.sh @@ -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 </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 <