4 Commits

Author SHA1 Message Date
Chris fe9379c826 Merge remote-tracking branch 'birdpic/feature/display-mode-today' into avian-visitors 2026-07-17 18:25:08 -05:00
Chris Sader e6496a821f Add HTTP trigger endpoint for on-demand frame refresh
Lightweight server on port 8080 that accepts POST /refresh and
starts birdframe.service. Runs as a systemd service so the web
UI's Refresh Now button can trigger immediate display updates.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-07-17 18:16:03 -05:00
Chris Sader c29498b3fa 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>
2026-07-17 17:59:54 -05:00
Chris Sader d586be0fac Add configurable display mode: rolling window or daily reset
New config options:
- mode = "window" (rolling hours, default) or "today" (daily reset)
- start_time = "06:00" (when today mode resets)
- hold_overnight = true (keep overnight collage until first bird)

In "today" mode the collage only shows birds since start_time,
growing through the day. With hold_overnight=true, the display
keeps the previous full collage until the first bird is detected
after start_time (no blank screen at dawn).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-07-17 14:48:09 -05:00
2 changed files with 115 additions and 8 deletions
+78 -7
View File
@@ -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:
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")
+36
View File
@@ -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()