Merge remote-tracking branch 'birdpic/feature/display-mode-today' into avian-visitors
This commit is contained in:
+79
-8
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user