Merge remote-tracking branch 'birdpic/feature/display-mode-today' into avian-visitors

This commit is contained in:
Chris
2026-07-17 18:25:08 -05:00
2 changed files with 115 additions and 8 deletions
+79 -8
View File
@@ -45,6 +45,9 @@ DEFAULTS = {
"bw_days": 7, # BirdWeather lookback window, in days "bw_days": 7, # BirdWeather lookback window, in days
"bw_country": "us", # geocoder country for the ZIP "bw_country": "us", # geocoder country for the ZIP
"hours": 24, "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": "", # local PNG written by the shooter
"image_url": "", # or a published screenshot URL "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) "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) 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 ------------------------------------------------------------------ # --- image ------------------------------------------------------------------
def get_image(src, timeout, auth=None): def get_image(src, timeout, auth=None):
if re.match(r"^https?://", src): if re.match(r"^https?://", src):
@@ -295,15 +347,15 @@ def load_state(path):
with open(os.path.expanduser(path)) as f: with open(os.path.expanduser(path)) as f:
return json.load(f) return json.load(f)
except Exception: 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) path = os.path.expanduser(path)
os.makedirs(os.path.dirname(path) or ".", exist_ok=True) os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = path + ".tmp" tmp = path + ".tmp"
with open(tmp, "w") as f: 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() f.flush()
os.fsync(f.fileno()) os.fsync(f.fileno())
os.replace(tmp, path) # atomic: a power cut can't leave a half-written file 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 -------------------------------------------------------------------- # --- run --------------------------------------------------------------------
def obtain_image(cfg, species=None): def obtain_image(cfg, species=None, window_hours=None):
if cfg.get("species_source") == "birdweather": if cfg.get("species_source") == "birdweather":
from shoot import shoot_birdweather from shoot import shoot_birdweather
if species is None: # gate skipped (--no-signature): fetch the list to render 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"], headline_px=cfg["shoot_headline_px"], eyebrow_px=cfg["shoot_eyebrow_px"],
lowercase=cfg["shoot_lowercase"], mat=cfg["shoot_mat"], 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, 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"]) user=cfg["basic_user"], password=cfg["basic_pass"])
return Image.open(out).convert("RGB") return Image.open(out).convert("RGB")
src = cfg["image_url"] or cfg["image"] 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): def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
now = time.time() 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"]) state = load_state(cfg["state"])
sig = None sig = None
species = None species = None
window_hours = None
if cfg["mode"] == "today":
window_hours = _today_hours(cfg)
if use_signature: if use_signature:
try: 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) sig = signature(species)
except Exception as e: except Exception as e:
print(f"signature fetch failed: {e}", file=sys.stderr) # treat as no change 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 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 not force and not preview:
if in_quiet_hours(cfg, datetime.now().hour): if in_quiet_hours(cfg, datetime.now().hour):
print("quiet hours; skip") 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") print("refresh:", "changed" if changed else "heal")
try: try:
img = fit_panel(obtain_image(cfg, species)) img = fit_panel(obtain_image(cfg, species, window_hours))
except Exception as e: except Exception as e:
print(f"could not get image: {e}", file=sys.stderr) # keep last panel image print(f"could not get image: {e}", file=sys.stderr) # keep last panel image
return return
@@ -384,7 +455,7 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
except Exception as e: except Exception as e:
print(f"panel push failed: {e}", file=sys.stderr) print(f"panel push failed: {e}", file=sys.stderr)
return 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") print("panel updated")
+36
View File
@@ -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()