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 <noreply@anthropic.com>
This commit is contained in:
Chris Sader
2026-07-17 17:59:54 -05:00
parent d586be0fac
commit c29498b3fa
+42 -5
View File
@@ -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")