From c29498b3fa1e39136bcab274655bb8e705952c37 Mon Sep 17 00:00:00 2001 From: Chris Sader Date: Fri, 17 Jul 2026 17:59:54 -0500 Subject: [PATCH] Fetch remote config and detect config changes for auto-refresh - Pulls frame_config from BirdNET server API each timer cycle - Remote settings override local config.toml (except paths/base_url) - Display-affecting config changes (title, saturation, rotate, sizes) are tracked via a config signature in state.json - Config change forces a panel refresh on the next cycle Co-Authored-By: Claude Opus 4 --- frame/display.py | 47 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/frame/display.py b/frame/display.py index 4652e2a..e840f93 100644 --- a/frame/display.py +++ b/frame/display.py @@ -133,6 +133,34 @@ def _today_hours(cfg): 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): @@ -319,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 @@ -370,6 +398,11 @@ def obtain_image(cfg, species=None, window_hours=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 @@ -388,8 +421,12 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False): 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") @@ -418,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")