diff --git a/frame/display.py b/frame/display.py index 4313144..e840f93 100644 --- a/frame/display.py +++ b/frame/display.py @@ -45,6 +45,9 @@ DEFAULTS = { "bw_days": 7, # BirdWeather lookback window, in days "bw_country": "us", # geocoder country for the ZIP "hours": 24, + "mode": "window", # "window" = rolling hours; "today" = daily reset + "start_time": "06:00", # "today" mode: collage resets at this time + "hold_overnight": True, # "today" mode: keep overnight collage until first bird "image": "", # local PNG written by the shooter "image_url": "", # or a published screenshot URL "shoot": False, # or capture inline (needs a browser; the 3 A+ and Zero 2 W both handle it) @@ -109,6 +112,55 @@ def fetch_species(cfg, auth=None): return fetch_recent(cfg["base_url"], cfg["hours"], cfg["timeout"], auth) +def _today_hours(cfg): + """Compute the hours window for 'today' mode. + + Returns hours since start_time today (or since yesterday's start_time + if we haven't reached it yet today). + """ + now = datetime.now() + parts = cfg["start_time"].split(":") + start_h, start_m = int(parts[0]), int(parts[1]) if len(parts) > 1 else 0 + start_today = now.replace(hour=start_h, minute=start_m, second=0, microsecond=0) + + if now < start_today: + from datetime import timedelta + yesterday_start = start_today - timedelta(days=1) + hours = (now - yesterday_start).total_seconds() / 3600 + return max(1, hours) + + hours = (now - start_today).total_seconds() / 3600 + return max(1, hours) + + +def fetch_remote_config(base, timeout, auth=None): + """Fetch frame config from the BirdNET server's frame_config endpoint.""" + url = f"{base.rstrip('/')}/avian/api/birdnet-api.php?action=frame_config" + req = urllib.request.Request(url, headers={"User-Agent": "AvianVisitors-frame/1.0"}) + if auth: + req.add_header("Authorization", auth) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read(100_000)) + except Exception as e: + print(f"remote config fetch failed: {e}", file=sys.stderr) + return None + + +DISPLAY_KEYS = ( + "mode", "hours", "start_time", "shoot_title", "shoot_subtitle", + "shoot_headline_px", "shoot_eyebrow_px", "shoot_lowercase", + "shoot_mat", "shoot_small_floor", "shoot_count_exp", + "mat", "mat_opening_w", "mat_opening_h", "rotate", "saturation", +) + + +def config_signature(cfg): + """Hash of display-affecting config values.""" + items = [(k, cfg.get(k)) for k in DISPLAY_KEYS] + return hashlib.sha256(json.dumps(items).encode()).hexdigest()[:16] + + # --- image ------------------------------------------------------------------ def get_image(src, timeout, auth=None): if re.match(r"^https?://", src): @@ -295,15 +347,15 @@ def load_state(path): with open(os.path.expanduser(path)) as f: return json.load(f) except Exception: - return {"signature": None, "last_refresh": 0} + return {"signature": None, "last_refresh": 0, "config_sig": None} -def save_state(path, sig, when): +def save_state(path, sig, when, cfg_sig=None): 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) + json.dump({"signature": sig, "last_refresh": when, "config_sig": cfg_sig}, f) f.flush() os.fsync(f.fileno()) os.replace(tmp, path) # atomic: a power cut can't leave a half-written file @@ -317,7 +369,7 @@ def in_quiet_hours(cfg, hour): # --- run -------------------------------------------------------------------- -def obtain_image(cfg, species=None): +def obtain_image(cfg, species=None, window_hours=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 @@ -335,6 +387,7 @@ def obtain_image(cfg, species=None): 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"], count_exp=cfg["shoot_count_exp"], timeout_ms=cfg["timeout"] * 1000, + window_hours=int(window_hours) if window_hours else None, user=cfg["basic_user"], password=cfg["basic_pass"]) return Image.open(out).convert("RGB") src = cfg["image_url"] or cfg["image"] @@ -345,17 +398,35 @@ def obtain_image(cfg, species=None): def run(cfg, preview=None, force=False, use_signature=True, mat_box=False): now = time.time() + remote = fetch_remote_config(cfg["base_url"], cfg["timeout"], _auth(cfg)) + if remote: + for k, v in remote.items(): + if k in cfg and k not in ("state", "cache", "base_url"): + cfg[k] = v state = load_state(cfg["state"]) sig = None species = None + window_hours = None + if cfg["mode"] == "today": + window_hours = _today_hours(cfg) if use_signature: try: - species = fetch_species(cfg, _auth(cfg)) + if cfg["mode"] == "today": + species = fetch_recent(cfg["base_url"], max(1, int(window_hours + 0.5)), cfg["timeout"], _auth(cfg)) + if not species and cfg["hold_overnight"]: + print("today mode: no birds yet, holding overnight collage") + return + else: + 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 + cfg_sig = config_signature(cfg) + config_changed = cfg_sig != state.get("config_sig") + if config_changed and not force and not preview: + print("config changed; forcing refresh") 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")) + changed = config_changed or (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") @@ -366,7 +437,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, species)) + img = fit_panel(obtain_image(cfg, species, window_hours)) except Exception as e: print(f"could not get image: {e}", file=sys.stderr) # keep last panel image return @@ -384,7 +455,7 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False): 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) + save_state(cfg["state"], sig if sig is not None else state.get("signature"), now, cfg_sig) print("panel updated") diff --git a/frame/trigger.py b/frame/trigger.py new file mode 100644 index 0000000..cf010ba --- /dev/null +++ b/frame/trigger.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Tiny HTTP server that triggers a birdframe refresh on demand.""" + +import http.server +import subprocess +import json + +PORT = 8080 + +class Handler(http.server.BaseHTTPRequestHandler): + def do_POST(self): + if self.path == "/refresh": + subprocess.Popen(["systemctl", "start", "birdframe.service"]) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(json.dumps({"status": "triggered"}).encode()) + else: + self.send_response(404) + self.end_headers() + + def do_OPTIONS(self): + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + + def log_message(self, fmt, *args): + pass + +if __name__ == "__main__": + server = http.server.HTTPServer(("0.0.0.0", PORT), Handler) + print(f"frame-trigger listening on :{PORT}") + server.serve_forever()