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:
+42
-5
@@ -133,6 +133,34 @@ def _today_hours(cfg):
|
|||||||
return max(1, hours)
|
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):
|
||||||
@@ -319,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
|
||||||
@@ -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):
|
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
|
||||||
@@ -388,8 +421,12 @@ def run(cfg, preview=None, force=False, use_signature=True, mat_box=False):
|
|||||||
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")
|
||||||
@@ -418,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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user