Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe9379c826 | |||
| e6496a821f | |||
| c29498b3fa | |||
| d586be0fac |
+79
-8
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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