Step 0 of the settings page: registry, safe restart, remembered state, test verb, page markers

- schema.py: every setting with type, default, help, owner screen and the rules a config must
  pass; tests/test_schema.py keeps it in step with the code (every key the code reads is declared,
  every default matches, check types and fix kinds agree, the glossary covers every word a key can
  show, today's config still builds today's pages apart from the key notes)
- status_panel.py: VERSION + started.json; SIGTERM lets a running fix finish and starts nothing new
  (RESTARTING SOON); fixes-running.json and live-now.json for the settings page; state.json keeps
  the ALARMS override, acknowledgements, auto-fix tries and fix times across restarts, keyed by
  what a check is about rather than its position; 'test' and 'resurvey' commands; queued commands
  dropped at start; overnight windows; a HyperDeck switched off is always switched back on;
  the spotify fix kind (playlist set once, in config 'spotify'); Windows remote checks batched for
  cmd.exe with conditional echoes; every fix's kind and host validated; never_auto; wording and
  numbers from config (site block, timing, page_hold_ms, auto-fix keep_months); hyperdeck_probe
  removed; --check and --exit-timeout
- make_page.py: numbers from config, markers on every key, nav templates per Companion version,
  --out; push_page.py: a page is overwritten only when every key carries a marker (foreign keys
  named by position), secret-free fetch, host from the site; nav_keys.py: host from the site;
  netgear.py: switches and the shared-port limit from config
- install.sh: validates with the new code first, keeps prev/ for --rollback, --keep-config,
  derived ExitTimeOut, wider deploy list, waits for the start stamp
- config.json: site, timing, spotify sections; Windows hosts marked; deployed to NA Ingest and
  both pages re-imported once (--force) so every key carries its marker

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-13 16:00:41 -04:00
co-authored by Claude Fable 5.1
parent a3934b7c62
commit 5d66805687
13 changed files with 1822 additions and 307 deletions
+458 -109
View File
@@ -16,6 +16,7 @@ Stdlib only; runs on the macOS /usr/bin/python3 (3.9).
"""
import argparse
import calendar
import hashlib
import http.client
import json
import logging
@@ -24,6 +25,7 @@ import logging.handlers
import os
import re
import shutil
import signal
import socket
import ssl
import subprocess
@@ -38,6 +40,10 @@ from datetime import datetime
HERE = os.path.dirname(os.path.abspath(__file__))
log = logging.getLogger("status-panel")
VERSION = "2026.10.13" # logged at start ("status panel running (code ...)") and written to started.json
# Every kind of fix the watchdog can run (validated by load_config).
FIX_KINDS = ("ssh", "local", "atem", "reconnect", "midi_connect", "netgear_poe", "hyperdeck", "spotify")
OK, WARN, ERR, OFF = "ok", "warn", "err", "off"
# Display levels, least to most urgent. "idle" is a service-only fault while
@@ -629,13 +635,14 @@ class AutoLog:
NAME = re.compile(r"^(\d{4})-(\d{2})-(\d{2}) [A-Za-z]+ Auto-Fix\.log$")
def __init__(self, directory):
def __init__(self, directory, who="NA Ingest", when="on Sundays 06:30-13:30", keep_months=1):
self.dir = os.path.expanduser(directory)
self.lock = threading.Lock()
self.who, self.when, self.keep_months = who, when, max(1, int(keep_months))
def cleanup(self, now):
"""Delete day files once they are a calendar month old (the 11 Oct file goes on 11 Nov).
Only files named exactly like these logs are ever touched."""
"""Delete day files once they are keep_months calendar months old (the 11 Oct file goes on
11 Nov). Only files named exactly like these logs are ever touched."""
today = datetime.fromtimestamp(now).date()
removed = []
try:
@@ -647,7 +654,8 @@ class AutoLog:
if not m:
continue
y, mo, d = (int(x) for x in m.groups())
y2, mo2 = (y + 1, 1) if mo == 12 else (y, mo + 1)
total = mo - 1 + self.keep_months
y2, mo2 = y + total // 12, total % 12 + 1
try:
due = datetime(y2, mo2, min(d, calendar.monthrange(y2, mo2)[1])).date()
except ValueError:
@@ -678,41 +686,111 @@ class AutoLog:
with open(path, "a", encoding="utf-8") as f:
if new:
f.write("Status panel auto-fixes - %s\n"
"(NA Ingest fixes these by itself on Sundays 06:30-13:30; anything that closes a\n"
"(%s fixes these by itself %s; anything that closes a\n"
" running program stays a hold. Newest lines at the bottom.)\n\n"
% dt.strftime("%A %-d %B %Y"))
% (dt.strftime("%A %-d %B %Y"), self.who, self.when))
f.write(line)
except OSError as e:
log.warning("auto-fix log %s: %s", self.dir, e)
def window_holds(w, day, hm, prev_day):
"""One window at one moment. "end" <= "start" means the window runs past midnight: it starts on
one of its days and ends early the next morning (a Saturday night service: sat 18:00-00:30)."""
if w["start"] < w["end"]:
return day in w["days"] and w["start"] <= hm < w["end"]
return (day in w["days"] and hm >= w["start"]) or (prev_day in w["days"] and hm < w["end"])
def in_windows(windows, now):
"""Is `now` inside one of [{"days": [...], "start": "HH:MM", "end": "HH:MM"}]?"""
dt = datetime.fromtimestamp(now)
hm, day = dt.strftime("%H:%M"), DAYS[dt.weekday()]
return any(day in w["days"] and w["start"] <= hm < w["end"] for w in windows)
return any(window_holds(w, day, hm, DAYS[dt.weekday() - 1]) for w in windows)
def windows_phrase(windows):
"""Windows in words, for log headers and notes: "on Sundays 06:30-13:30"."""
parts = []
for w in windows:
days = [d for d in DAYS if d in w["days"]]
names = [datetime(2024, 1, 1 + DAYS.index(d)).strftime("%As") for d in days] # 2024-01-01 is a Monday
when = "every day" if len(days) == 7 else " and ".join(names)
parts.append("%s %s-%s" % (when, w["start"], w["end"]))
return ("on " + " and ".join(parts)) if parts else "never"
def check_identity(chk):
"""What a check is about, independent of its position in the list: the key that remembered
state (acknowledgements, auto-fix tries) is filed under, so editing the list keeps it."""
t = chk["type"]
if t == "midi_peer":
return "%s/%s/%s" % (chk.get("host", ""), chk["session"], chk["peer"])
if t == "remote":
return "%s/%s" % (chk["host"], hashlib.sha1(chk["cmd"].encode()).hexdigest()[:8])
if t == "ping":
return chk.get("conn") or chk.get("host", "")
if t == "pref":
return "%s/%s" % (chk["domain"], chk["key"])
single = {"http": "url", "mdns": "instance", "launchd": "label", "process": "match", "artnet": "source",
"atem": "part", "disk": "path", "midi_rx": "source"}
if t in single:
return str(chk.get(single[t], ""))
return chk.get("conn") or t
def write_json(path, obj):
"""Write a small JSON file in one go (a reader never sees half of it)."""
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(obj, f, indent=1, sort_keys=True)
os.replace(tmp, path)
class State:
"""One file (state.json) for everything the watchdog remembers across restarts: the ALARMS
override, acknowledged faults, auto-fix tries and when each fix last ran. One writer; saved whole."""
def __init__(self, path):
self.path, self.data, self.lock = path, {}, threading.Lock()
if not path:
return
try:
with open(path) as f:
self.data = json.load(f)
except (OSError, ValueError):
legacy = os.path.join(os.path.dirname(path), "service-mode.json") # the file before state.json
try:
with open(legacy) as f:
old = json.load(f)
self.data = {"service": {"override": old.get("override"), "last_sched": old.get("last_sched")}}
except (OSError, ValueError):
pass
def get(self, key, default=None):
return self.data.get(key, default)
def set(self, key, value):
with self.lock:
self.data[key] = value
if self.path:
try:
write_json(self.path, self.data)
except OSError as e:
log.warning("could not save %s: %s", self.path, e)
class ServiceMode:
"""On inside a scheduled window; a button press overrides until the next window edge."""
def __init__(self, windows, path):
self.windows, self.path = windows, path
self.override, self.last_sched = None, None
if not path:
return
try:
with open(path) as f:
saved = json.load(f)
self.override, self.last_sched = saved.get("override"), saved.get("last_sched")
except (OSError, ValueError):
pass
def __init__(self, windows, state):
self.windows = windows
self.state = state if isinstance(state, State) else State(state)
saved = self.state.get("service") or {}
self.override, self.last_sched = saved.get("override"), saved.get("last_sched")
def scheduled(self, now):
dt = datetime.fromtimestamp(now)
hm = dt.strftime("%H:%M")
day = DAYS[dt.weekday()]
return any(day in w["days"] and w["start"] <= hm < w["end"] for w in self.windows)
return in_windows(self.windows, now)
def on(self, now):
sched = self.scheduled(now)
@@ -728,28 +806,33 @@ class ServiceMode:
self._save()
def _save(self):
if not self.path:
return
try:
tmp = self.path + ".tmp"
with open(tmp, "w") as f:
json.dump({"override": self.override, "last_sched": self.last_sched}, f)
os.replace(tmp, self.path)
except OSError as e:
log.warning("could not save service mode: %s", e)
self.state.set("service", {"override": self.override, "last_sched": self.last_sched})
# --- Engine ------------------------------------------------------------------
class Engine:
def __init__(self, cfg, state_path=None):
def __init__(self, cfg, state_path=None, data_dir=None):
self.cfg = cfg
self.tiles = {t["id"]: t for t in cfg["tiles"]}
self.debounce = cfg.get("debounce_seconds", 3)
self.flap_count = cfg.get("flap", {}).get("count", 3)
self.flap_window = cfg.get("flap", {}).get("window_seconds", 600)
self.net = cfg.get("network", {})
self.service = ServiceMode(cfg.get("service_windows", []), state_path)
self.site = cfg.get("site", {})
timing = cfg.get("timing", {})
self.cooldown = timing.get("fix_cooldown_seconds", 60) # the same fix never re-runs within this
self.hold_ms = cfg.get("page_hold_ms", 2000) # how long a key is held to run its fix
self.state = State(state_path)
self.service = ServiceMode(cfg.get("service_windows", []), self.state)
self.data_dir = data_dir
self.stopping = False # SIGTERM arrived: finish what runs, start nothing new
self.running = {} # fix thread name -> {"key", "what", "since"} (published as fixes-running.json)
self.fix_threads = [] # the threads behind self.running, joined at exit
self.any_recording = False
self.live = None # (live?, reason) last written to live-now.json
self.live_written = 0.0
self.restore_acks = set(self.state.get("acks") or []) # acknowledged before the last restart
self.trackers = {}
self.group_of = {}
for t in cfg["tiles"]:
@@ -763,16 +846,19 @@ class Engine:
self.burst = None # {"start", "conns", "cleared"}
self.events = []
self.meta = {} # key -> (tile id, check, sub key)
self.fix_last = {} # fix identity -> last run time
self.fix_last = {tuple(k.split("\0")): v for k, v in (self.state.get("fix_last") or {}).items()}
self.executor = execute_fix
self.poe_executor = execute_poe
self.notes = {} # tile id -> hold feedback {"text", "level", "until"}
# The Restart page: one key per thing, hold 2 s = restart it even when it looks fine.
self.restart_keys = {k["id"]: k for k in cfg.get("restart_page", {}).get("keys", [])}
self.auto = cfg.get("auto_fix") or {}
self.auto_tries = {} # fix identity -> [tries, last try time] for the fault now going on
self.auto_seen = {} # fix identity -> last time it was in an auto plan
self.autolog = AutoLog(self.auto.get("log_dir", "~/Desktop/Auto-Fix Logs")) if self.auto else None
saved = self.state.get("auto") or {}
self.auto_tries = {tuple(k.split("\0")): v for k, v in saved.get("tries", {}).items()}
self.auto_seen = {tuple(k.split("\0")): v for k, v in saved.get("seen", {}).items()}
self.autolog = AutoLog(self.auto.get("log_dir", "~/Desktop/Auto-Fix Logs"),
self.site.get("ingest_name", "NA Ingest"), windows_phrase(self.auto.get("windows", [])),
self.auto.get("keep_months", 1)) if self.auto else None
if self.autolog:
self.autolog.cleanup(time.time())
self.auto_watch = {} # "BUTTON FAULT" auto-fix went after -> when (to log when it clears)
@@ -782,6 +868,81 @@ class Engine:
self.deck_ips = {} # HyperDeck connection -> last address Companion reported
self.now = 0.0
@property
def hold_text(self):
return "HOLD %ss" % ("%g" % (self.hold_ms / 1000.0))
@property
def wait_text(self):
c = int(self.cooldown)
return "WAIT %d MIN" % (c // 60) if c % 60 == 0 else "WAIT %d S" % c
def identity_key(self, key):
"""A tracker key ("tile:index:sub") as the position-free name its state is remembered by."""
tile_id, chk, sub = self.meta[key]
return "%s|%s|%s" % (tile_id, check_identity(chk), sub)
def remember(self):
"""Save what must survive a restart: acknowledgements, auto-fix tries, when fixes last ran."""
acks = sorted({self.identity_key(k) for k, tr in self.trackers.items()
if tr.confirmed and tr.acked and k in self.meta} | self.restore_acks)
self.state.set("acks", acks)
self.state.set("fix_last", {"\0".join(k): v for k, v in self.fix_last.items()})
self.state.set("auto", {"tries": {"\0".join(k): v for k, v in self.auto_tries.items()},
"seen": {"\0".join(k): v for k, v in self.auto_seen.items()}})
def live_now(self, now):
"""(live?, why): changes to the panel wait while this is true. A recording HyperDeck is
always live; a campus may add clock windows ("live_services" in "site")."""
if self.any_recording:
return True, "a HyperDeck is recording"
if in_windows(self.site.get("live_services", []), now):
return True, "a live service is scheduled"
return False, ""
def publish(self, now):
"""live-now.json and fixes-running.json in the data dir (the settings page reads them)."""
if not self.data_dir:
return
live = self.live_now(now)
if live != self.live or now - self.live_written > 5:
try:
write_json(os.path.join(self.data_dir, "live-now.json"),
{"live": live[0], "reason": live[1], "time": now})
except OSError as e:
log.warning("live-now.json: %s", e)
self.live, self.live_written = live, now
def fix_started(self, name, key_id, what):
self.running[name] = {"key": key_id, "what": what, "since": time.time()}
self.publish_running()
def fix_done(self, name):
self.running.pop(name, None)
self.publish_running()
def publish_running(self):
if self.data_dir:
try:
write_json(os.path.join(self.data_dir, "fixes-running.json"), self.running)
except OSError as e:
log.warning("fixes-running.json: %s", e)
def start_fix_thread(self, target, key_id, what):
"""Every fix runs in a thread the watchdog knows about: a restart waits for them."""
name = "fix-%s-%d" % (key_id, int(time.time() * 1000) % 100000)
def wrapped():
try:
target()
finally:
self.fix_done(name)
self.fix_started(name, key_id, what)
t = threading.Thread(target=wrapped, name=name, daemon=True)
self.fix_threads = [x for x in self.fix_threads if x.is_alive()] + [t]
t.start()
return t
# raw results for every check key
def evaluate(self, snap, now, service_on):
ctx = {"now": now, "service": service_on, "any_recording": False, "dante_all": {}}
@@ -794,6 +955,7 @@ class Engine:
if chk["type"] == "hyperdeck" and chk.get("rec_sync", True):
if conn_result(snap, chk["conn"], now).level == OK and hyperdeck_recording(snap, chk["conn"]):
ctx["any_recording"] = True
self.any_recording = ctx["any_recording"]
raw = {}
for t in self.cfg["tiles"]:
for i, chk in enumerate(t.get("checks", [])):
@@ -878,13 +1040,23 @@ class Engine:
deb = self.debounce if debounce is None else debounce
for key, (tile_id, res, chk) in raw.items():
tr = self.trackers.setdefault(key, Tracker())
was_acked = tr.acked
# A MIDI peer leaving a session is a clean event: show it after 1 s, not the general 3 s.
event = tr.update(res, now, 0 if chk["type"] == "network" else
chk.get("debounce_seconds", 1 if chk["type"] == "midi_peer" else deb),
self.flap_window)
if event == "fault":
self.events.append(("FAULT", "%s %s" % (self.tiles[tile_id]["label"], res.msg)))
ident = self.identity_key(key) if key in self.meta else None
if ident in self.restore_acks: # acknowledged before the restart: still acknowledged
self.restore_acks.discard(ident)
tr.acked = True
self.remember()
elif event == "clear":
ident = self.identity_key(key) if key in self.meta else None
if was_acked or ident in self.restore_acks:
self.restore_acks.discard(ident)
self.remember() # an acknowledgement is for one fault only
text = "%s %s" % (self.tiles[tile_id]["label"], tr.last_fault_msg)
self.events.append(("CLEAR", text))
fixed_at = self.auto_watch.pop(text, None)
@@ -896,15 +1068,19 @@ class Engine:
# fault is over - clear it, or it lingers unseen and a hold would still try to fix it.
for key, tr in self.trackers.items():
if key not in raw and (tr.confirmed or tr.bad_since is not None):
was_acked = tr.acked
if tr.update(Result(OK), now, 0, self.flap_window) == "clear" and key in self.meta:
tile_id = self.meta[key][0]
self.events.append(("CLEAR", "%s %s" % (self.tiles[tile_id]["label"], tr.last_fault_msg)))
if was_acked:
self.remember()
self.items = {}
for key, (tile_id, res, chk) in raw.items():
tr = self.trackers[key]
eff = tr.effective(self.flap_count)
self.items.setdefault(tile_id, []).append(
(display_level(eff, tr.acked, service_on), eff, tr.since or now))
self.publish(now)
return render(self, service_on)
def keys_for(self, tile_id):
@@ -925,6 +1101,8 @@ class Engine:
if tr.confirmed and not tr.acked:
tr.acked = True
n += 1
if n:
self.remember()
return n
def restart_targets(self, tile_id):
@@ -1038,7 +1216,11 @@ class Engine:
"""Show `text` on the button for a while (hold feedback), instead of its usual text."""
self.notes[tile_id] = {"text": text, "level": level, "until": now + seconds}
def run_fixes(self, tile_id, snap, comp, now, cooldown=60):
def run_fixes(self, tile_id, snap, comp, now, cooldown=None):
cooldown = self.cooldown if cooldown is None else cooldown
if self.stopping:
self.note(tile_id, "RESTARTING\nSOON", "idle", now, 4)
return []
if self.tiles.get(tile_id, {}).get("kind") == "network":
# NET: a hold clears a remembered network blip (link and gateway clear themselves).
if self.burst is None:
@@ -1071,8 +1253,9 @@ class Engine:
return plan
return self.run_plan(tile_id, plan, snap, comp, now, cooldown)
def run_plan(self, tile_id, plan, snap, comp, now, cooldown=60, auto=False, on_done=None):
def run_plan(self, tile_id, plan, snap, comp, now, cooldown=None, auto=False, on_done=None):
"""Carry out fix_plan() items for one button (a hold, or auto-fix during service)."""
cooldown = self.cooldown if cooldown is None else cooldown
started, names, waited, midi = [], [], False, []
for ident, kind, what, chk in plan:
if now - self.fix_last.get(ident, 0) < cooldown:
@@ -1080,6 +1263,7 @@ class Engine:
waited = True
continue
self.fix_last[ident] = now
self.remember()
if kind == "enable":
c = snap.conns.get(what)
if c:
@@ -1112,7 +1296,7 @@ class Engine:
started.append((midi_worker, (midi,), self.cfg["fixes"][midi[0][0]].get("short", "MIDI")))
if not names:
if not auto:
self.note(tile_id, "JUST TRIED\nWAIT 1 MIN" if waited else "NOTHING\nTO FIX", "idle", now, 4)
self.note(tile_id, ("JUST TRIED\n" + self.wait_text) if waited else "NOTHING\nTO FIX", "idle", now, 4)
return plan
summary = names[0] if len(set(names)) == 1 else "%d FIXES" % len(names)
if not started: # only Companion reconnects: quick
@@ -1148,7 +1332,7 @@ class Engine:
on_done(outcome)
said = []
for fn, args, short in started:
threading.Thread(target=work, args=(fn, args, short), daemon=True).start()
self.start_fix_thread(lambda fn=fn, args=args, short=short: work(fn, args, short), tile_id, short)
return plan
def auto_fix(self, snap, comp, now):
@@ -1158,7 +1342,7 @@ class Engine:
the network is having a blip, and never while ALARMS has muted the panel (maintenance).
Restarting a running program stays a hold."""
a = self.auto
if not a or now - self.auto_last < 5:
if not a or now - self.auto_last < 5 or self.stopping:
return []
self.auto_last = now
if not in_windows(a.get("windows", []), now) or not self.service.on(now):
@@ -1184,7 +1368,7 @@ class Engine:
if self.auto_paused and self.autolog:
self.autolog.write(now, "NET", "network settled: auto-fix is back on")
self.auto_paused = False
gentle = set(a.get("fixes", []))
gentle = {f for f in a.get("fixes", []) if not self.cfg["fixes"].get(f, {}).get("never_auto")}
done = []
for t in self.cfg["tiles"]:
if "checks" not in t:
@@ -1243,6 +1427,8 @@ class Engine:
# fault blink out for a moment (restarting, re-confirming), which must not reset the count.
if now - self.auto_seen.get(ident, 0) >= forget:
del self.auto_tries[ident]
if done or self.auto_tries:
self.remember()
return done
def describe(self, kind, what):
@@ -1253,9 +1439,13 @@ class Engine:
return self.cfg["fixes"][what].get("label", what)
return "%s %s" % (kind, what)
def run_force(self, key_id, snap, comp, now, cooldown=60, fix_id=None, chk=None):
def run_force(self, key_id, snap, comp, now, cooldown=None, fix_id=None, chk=None, verb="FORCE"):
"""Run one fix whatever the status says: a Restart page key, or a button's "hold_always".
Shares the 60 s guard with holds, so a hold and a restart key never double up."""
Shares the cooldown guard with holds, so a hold and a restart key never double up."""
cooldown = self.cooldown if cooldown is None else cooldown
if self.stopping:
self.note(key_id, "RESTARTING\nSOON", "idle", now, 4)
return None
if fix_id is None:
key = self.restart_keys.get(key_id)
if not key:
@@ -1266,19 +1456,20 @@ class Engine:
and now - self.fix_last.get(("fix", w, ""), 0) < cooldown]
if busy: # e.g. auto-fix is starting Ableton right now
log.info("FORCE %s: %s ran less than %ds ago, skipped", key_id, busy[0], cooldown)
self.note(key_id, "STARTING\nWAIT 1 MIN", "idle", now, 4)
self.note(key_id, "STARTING\n" + self.wait_text, "idle", now, 4)
return None
per_deck = self.cfg["fixes"][fix_id].get("kind") == "hyperdeck" and chk and chk.get("conn")
ident = ("fix", fix_id, chk["conn"] if per_deck else "") # the same guard a failing hold uses
if now - self.fix_last.get(ident, 0) < cooldown:
log.info("FORCE %s: %s ran less than %ds ago, skipped", key_id, fix_id, cooldown)
log.info("%s %s: %s ran less than %ds ago, skipped", verb, key_id, fix_id, cooldown)
cur = self.notes.get(key_id)
if not (cur and cur["level"] == "fixing" and now < cur["until"]): # still running: keep saying so
self.note(key_id, "JUST DONE\nWAIT 1 MIN", "idle", now, 4)
self.note(key_id, "JUST DONE\n" + self.wait_text, "idle", now, 4)
return None
self.fix_last[ident] = now
self.remember()
fix = self.cfg["fixes"][fix_id]
log.info("FORCE %s: %s", key_id, fix.get("label", fix_id))
log.info("%s %s: %s", verb, key_id, fix.get("label", fix_id))
self.note(key_id, "RESTARTING", "fixing", now, 180)
def work():
@@ -1292,9 +1483,51 @@ class Engine:
self.note(key_id, "FAILED", "failed", done, 30)
else:
self.note(key_id, "DONE", "ok", done, 20)
threading.Thread(target=work, daemon=True).start()
self.start_fix_thread(work, key_id, fix.get("short", fix_id.upper()))
return fix_id
def run_test(self, key_id, fix_id, which, snap, comp, now):
"""The settings page's "Run this fix now": one check's fix, run exactly as a hold on that key
would run it (deck address, MIDI peer address, recording guard, cooldown), logged as TEST.
`which` picks the check: its number on the key, or its identity. Refused for anything a hold
would not do from that key, and for fixes that act on the switcher, a PoE port or Dante."""
tile = self.tiles.get(key_id)
if not tile:
log.warning("TEST %s: no such key", key_id)
return None
fix = self.cfg["fixes"].get(fix_id)
if not fix or fix.get("kind") in ("atem", "netgear_poe") or fix_id in (RESTART_CONNECTION, DANTE_AUTO):
log.warning("TEST %s: %s is not a fix the page may run", key_id, fix_id)
return None
checks = tile.get("checks", [])
chk = None
for i, c in enumerate(checks):
if which in (str(i), check_identity(c), urllib.parse.quote(check_identity(c), safe="")):
chk = c
if chk is None and tile.get("hold_always") == fix_id:
chk = next((c for c in checks if c.get("conn")), {})
if chk is None:
log.warning("TEST %s: no check %r on it", key_id, which)
return None
own = chk.get("fix")
own = set(own.values()) if isinstance(own, dict) else {own}
if fix_id not in own and tile.get("hold_always") != fix_id:
log.warning("TEST %s: %s is not attached to that check", key_id, fix_id)
return None
if chk.get("type") == "hyperdeck":
if hyperdeck_recording(snap, chk["conn"]):
log.info("TEST %s: %s is recording, left alone", key_id, chk["conn"])
self.note(key_id, "RECORDING\nLEFT ALONE", "idle", now, 4)
return None
chk = dict(chk, ip=chk.get("ip") or snap.var(chk["conn"], "ip") or self.deck_ips.get(chk["conn"]))
if fix.get("kind") == "midi_connect":
address = snap.peer_addr.get((chk.get("host", ""), chk["session"], chk["peer"])) or chk.get("address")
if not address:
log.info("TEST %s: no address known for %s", key_id, chk["peer"])
return None
chk = dict(chk, address=address)
return self.run_force(key_id, snap, comp, now, fix_id=fix_id, chk=chk, verb="TEST")
def display_level(res, acked, service_on):
if res.level == OFF:
@@ -1373,7 +1606,7 @@ def render(engine, service_on):
elif down: # flashes: this is the one to hold
out[kid] = colored(key["label"] + "\nHOLD TO FIX", "err")
else:
out[kid] = colored(key["label"] + "\nHOLD 2s", "idle")
out[kid] = colored(key["label"] + "\n" + engine.hold_text, "idle")
for tile in engine.cfg["tiles"]:
tid, kind = tile["id"], tile.get("kind")
note = engine.notes.get(tid)
@@ -1616,13 +1849,18 @@ def playlist_tracks(playlist_uri):
return tracks, False
def spotify_playlist(cfg, chk=None):
"""The service playlist: "spotify" in config is the one place it is set; a check may override it."""
return (chk or {}).get("playlist") or (cfg.get("spotify") or {}).get("playlist")
def poll_spotify(snap, cfg):
checks = [c for t in cfg["tiles"] for c in t.get("checks", []) if c["type"] == "spotify"]
if not checks:
return
def refresh():
try:
snap.spotify_tracks, snap.spotify_complete = playlist_tracks(checks[0]["playlist"])
snap.spotify_tracks, snap.spotify_complete = playlist_tracks(spotify_playlist(cfg, checks[0]))
except (OSError, ValueError, KeyError, AttributeError, TypeError) as e:
log.warning("Spotify playlist list: %s", e)
snap.spotify_tracks_time = time.time()
@@ -1769,11 +2007,22 @@ def wake_on_lan(mac, broadcast=None, port=9):
return packet
def remote_script(cmds):
"""One shell line that runs every check and prints SP<n>=<exit code>."""
def remote_script(cmds, shell="sh"):
"""One line that runs every check and prints SP<n>=<0 or 1>. On a Mac the shell evaluates $?
after each command; cmd.exe expands %ERRORLEVEL% once before the line runs, so Windows gets a
conditional echo per command instead (never a bare %ERRORLEVEL%)."""
if shell == "cmd":
return " & ".join("((%s) >nul 2>&1 && echo SP%d=0 || echo SP%d=1)" % (cmd, i, i) for i, cmd in enumerate(cmds))
return "; ".join("(%s) >/dev/null 2>&1; echo SP%d=$?" % (cmd, i) for i, cmd in enumerate(cmds))
def host_shell(cfg, host, chk=None):
"""Which shell a remote check's command line is written for: "cmd" on a Windows PC, else "sh"."""
if chk and chk.get("shell"):
return chk["shell"]
return "cmd" if cfg.get("hosts", {}).get(host, {}).get("os") == "windows" else "sh"
def parse_remote(out, n):
codes = dict(re.findall(r"SP(\d+)=(\d+)", out or ""))
return [None if str(i) not in codes else codes[str(i)] == "0" for i in range(n)]
@@ -1790,7 +2039,7 @@ def poll_remote(snap, cfg):
result = dict(snap.remote)
for host, cmds in by_host.items():
try:
out = subprocess.run(ssh_cmd(cfg, host) + [remote_script(cmds)], capture_output=True,
out = subprocess.run(ssh_cmd(cfg, host) + [remote_script(cmds, host_shell(cfg, host))], capture_output=True,
text=True, timeout=20).stdout
except subprocess.TimeoutExpired:
out = ""
@@ -1799,41 +2048,6 @@ def poll_remote(snap, cfg):
snap.remote = result
def hyperdeck_probe(ip, timeout=3, port=9993):
"""Ask a HyperDeck directly what it is doing, on its control port 9993.
Returns its transport status ("record", "preview", "stopped", ...), "busy" if it
refused another client, "answered" if it greeted but did not report a status,
"hung" if it accepted the connection and said nothing at all, or "unreachable".
Only "hung" justifies a reboot.
"""
try:
s = socket.create_connection((ip, port), timeout=timeout)
except OSError:
return "unreachable"
buf = b""
with s:
s.settimeout(timeout)
try:
s.sendall(b"transport info\r\n")
end = time.time() + timeout
while time.time() < end:
chunk = s.recv(4096)
if not chunk:
break
buf += chunk
m = re.search(rb"208 transport info:.*?status: (\w+)", buf, re.S)
if m:
return m.group(1).decode().lower()
if b"120 connection rejected" in buf:
return "busy"
except socket.timeout:
pass
except OSError:
return "unreachable" if not buf else "answered"
return "answered" if buf else "hung"
# --- The self-updating Dante map: name -> IP -> MAC -> switch port -> PoE --------
def dante_map_path(data_dir):
@@ -1950,6 +2164,8 @@ def execute_fix(cfg, fix_id, chk):
ok = conn in ids and comp.restart_connection(ids[conn])
log.info("FIX %s -> Companion connection %s restarted %s", label, conn, "ok" if ok else "FAILED")
return r.returncode == 0
if kind == "spotify":
return play_spotify(cfg, fix, chk, label)
if kind == "atem":
ok = bool(ATEM_WATCHER) and ATEM_WATCHER.fix(fix["part"])
log.info("FIX %s -> %s", label, "switcher matches the restore file again" if ok else "FAILED")
@@ -1990,6 +2206,27 @@ def execute_fix(cfg, fix_id, chk):
return False
def play_spotify(cfg, fix, chk, label, run=None):
"""Play the service playlist in Spotify on this Mac, opening Spotify first if it is closed."""
run = run or subprocess.run
sp_ = cfg.get("spotify") or {}
playlist = spotify_playlist(cfg, chk) or fix.get("playlist")
if not playlist:
log.warning("FIX %s: no playlist set", label)
return False
if run(["/usr/bin/pgrep", "-xq", "Spotify"], timeout=5).returncode != 0:
run(["/usr/bin/open", "-g", "-a", "Spotify"], timeout=15)
time.sleep(sp_.get("open_wait", 8))
run(["/usr/bin/osascript", "-e", 'tell application "Spotify" to play track "%s"' % playlist],
capture_output=True, text=True, timeout=15)
time.sleep(sp_.get("play_wait", 3))
r = run(["/usr/bin/osascript", "-e", 'tell application "Spotify" to player state as text'],
capture_output=True, text=True, timeout=10)
ok = r.returncode == 0 and r.stdout.strip() == "playing"
log.info("FIX %s -> %s", label, "playing" if ok else "not playing (%s)" % (r.stdout.strip() or r.stderr.strip()[-80:]))
return ok
def reconnect_and_verify(cfg, fix, label, sleep=None, conn=None, host=None, toggle=False):
"""Restart one Companion connection (toggle=True: switch it off and on again), then look again
after "verify_seconds". Returns True if it came back; otherwise what to tell the operator:
@@ -2006,8 +2243,11 @@ def reconnect_and_verify(cfg, fix, label, sleep=None, conn=None, host=None, togg
if conn.get("enabled") and not comp.disable_connection(conn["id"]):
log.info("FIX %s -> Companion refused to switch %s off", label, fix["conn"])
return False
sleep(fix.get("off_seconds", 2))
if not comp.enable_connection(conn["id"]):
try:
sleep(fix.get("off_seconds", 2))
finally: # whatever happens, it is switched back on
on = comp.enable_connection(conn["id"])
if not on:
log.info("FIX %s -> Companion did not switch %s back on", label, fix["conn"])
return "STILL OFF IN\nCOMPANION"
log.info("FIX %s -> %s switched off and on", label, fix["conn"])
@@ -2058,10 +2298,14 @@ CCU_PARAMS = {
}
def atem_reference(xml_path):
def atem_reference(xml_path, compare=None):
"""The restore file (.xml from ATEM Software Control) as what atem_watch.js compares against:
every Fairlight source's mix option and fader, and every camera's settings above."""
every Fairlight source's mix option and fader, and every camera's settings above. `compare`
("compare" in config "atem") narrows it: {"audio": ["mixOption", "faderGain"], "camera": [...]}."""
import xml.etree.ElementTree as ET
compare = compare or {}
audio_fields = compare.get("audio", ["mixOption", "faderGain"])
camera_params = compare.get("camera") # parameter names, e.g. ["ApertureFstop", ...]
root = ET.parse(xml_path).getroot()
audio = {}
mixer = root.find("FairlightAudioMixer")
@@ -2074,13 +2318,16 @@ def atem_reference(xml_path):
gain = float(src.attrib.get("faderGain", "nan"))
except ValueError:
gain = float("nan")
if math.isfinite(gain): # the file saves dB; the switcher uses 1/100 dB
if math.isfinite(gain) and "faderGain" in audio_fields: # the file saves dB; the switcher 1/100 dB
want["faderGain"] = int(round(gain * 100))
audio["%s/%s" % (inp.attrib["id"], src.attrib["id"])] = want
if "mixOption" in audio_fields or "faderGain" in want:
audio["%s/%s" % (inp.attrib["id"], src.attrib["id"])] = want
camera = {}
cc = root.find("CameraControl")
for p in (cc if cc is not None else []):
spec = CCU_PARAMS.get((p.attrib.get("category"), p.attrib.get("parameter")))
if camera_params is not None and p.attrib.get("parameter") not in camera_params:
continue
if spec and all(f in p.attrib for f in spec[2]):
camera["%s/%d/%d" % (p.attrib["device"], spec[0], spec[1])] = [float(p.attrib[f]) for f in spec[2]]
return {"audio": audio, "camera": camera}
@@ -2100,7 +2347,7 @@ class AtemWatcher:
def __init__(self, cfg, snap, data_dir, stop):
a = cfg["atem"]
self.cfg, self.snap, self.stop = a, snap, stop
self.atem, self.snap, self.stop = a, snap, stop
self.proc, self.lock, self.fixed = None, threading.Lock(), {}
here = os.path.dirname(os.path.abspath(__file__))
self.script = os.path.join(here, "atem_watch.js")
@@ -2112,12 +2359,12 @@ class AtemWatcher:
if not os.path.isabs(xml):
xml = os.path.join(here, xml)
with open(self.ref_path, "w") as f:
json.dump(atem_reference(xml), f)
json.dump(atem_reference(xml, a.get("compare")), f)
def run(self):
while not self.stop.is_set():
try:
self.proc = subprocess.Popen([self.node, self.script, self.cfg["host"], self.library, self.ref_path],
self.proc = subprocess.Popen([self.node, self.script, self.atem["host"], self.library, self.ref_path],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1)
errors = deque(maxlen=5) # read all the time, so a chatty stderr never stalls it
@@ -2231,9 +2478,53 @@ class Periodic(threading.Thread):
# --- Main --------------------------------------------------------------------
def check_windows(windows, where):
for w in windows or []:
if not isinstance(w, dict) or not isinstance(w.get("days"), list) or not w["days"]:
raise ValueError("%s: a window needs days, start and end" % where)
for d in w["days"]:
if d not in DAYS:
raise ValueError("%s: unknown day %r" % (where, d))
for k in ("start", "end"):
if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", str(w.get(k, ""))):
raise ValueError("%s: %s must be HH:MM" % (where, k))
def check_host(cfg, host, where):
"""A machine the watchdog logs in to: listed in "hosts", with an ssh address, and not this Mac."""
h = cfg.get("hosts", {}).get(host)
if h is None:
raise ValueError("%s: unknown host %s" % (where, host))
if h.get("self"):
raise ValueError("%s: %s is this Mac - use a local fix or check" % (where, host))
if not h.get("ssh"):
raise ValueError("%s: host %s has no ssh address" % (where, host))
def exit_timeout(cfg):
"""How long launchd must let the watchdog finish a running fix before killing it: the longest
fix plus its verify time plus a margin, never under a minute (install.sh writes it into the plist)."""
longest = 0
for fix in cfg.get("fixes", {}).values():
longest = max(longest, fix.get("timeout", 60) + (fix.get("verify_seconds", 20)
if fix.get("kind") in ("reconnect", "hyperdeck") else 0))
return max(60, longest + 10)
def load_config(path):
with open(path) as f:
cfg = json.load(f)
for fid, fix in cfg.get("fixes", {}).items():
kind = fix.get("kind", "ssh")
if kind not in FIX_KINDS:
raise ValueError("fix %s: unknown kind %s" % (fid, kind))
if kind == "ssh":
check_host(cfg, fix.get("host"), "fix " + fid)
if kind in ("ssh", "local") and not fix.get("run"):
raise ValueError("fix %s: nothing to run" % fid)
check_windows(cfg.get("service_windows"), "service_windows")
check_windows((cfg.get("auto_fix") or {}).get("windows"), "auto_fix")
check_windows((cfg.get("site") or {}).get("live_services"), "site.live_services")
ids = set()
for t in cfg["tiles"]:
if t["id"] in ids:
@@ -2254,13 +2545,15 @@ def load_config(path):
if (f and f not in (RESTART_CONNECTION, DANTE_AUTO) and cfg["fixes"][f].get("kind", "ssh") == "ssh"
and cfg["fixes"][f]["host"] not in cfg.get("hosts", {})):
raise ValueError("fix %s: unknown host" % f)
if c["type"] == "remote" and c["host"] not in cfg.get("hosts", {}):
raise ValueError("tile %s: unknown host %s" % (t["id"], c["host"]))
if c["type"] == "remote" or (c["type"] == "midi_peer" and c.get("host")):
check_host(cfg, c["host"], "tile " + t["id"])
if t.get("hold_always") and t["hold_always"] not in cfg.get("fixes", {}):
raise ValueError("tile %s: unknown hold_always fix %s" % (t["id"], t["hold_always"]))
for f in (cfg.get("auto_fix") or {}).get("fixes", []):
if f not in cfg.get("fixes", {}):
raise ValueError("auto_fix: unknown fix %s" % f)
if cfg["fixes"][f].get("never_auto"):
raise ValueError("auto_fix: %s is marked never to run by itself" % f)
if re.search(r"pkill|taskkill|killall", cfg["fixes"][f].get("run", "")) and not cfg["fixes"][f].get("restart_ok"):
raise ValueError("auto_fix: %s closes a running program - that stays a hold" % f)
for k in cfg.get("restart_page", {}).get("keys", []):
@@ -2372,22 +2665,57 @@ def handle_commands(text, engine, comp, snap, now):
engine.run_fixes(arg, snap, comp, now)
else: # a Restart page key, held 2 seconds
engine.run_force(arg, snap, comp, now)
elif verb == "test":
# The settings page: "test <key> <fix> <check number or identity> <unix time>".
if len(words) < 5 or not words[4].isdigit() or now - int(words[4]) > HOLD_MAX_AGE:
log.info("TEST %s: no time on it, or too long ago - ignored", arg)
else:
engine.run_test(arg, words[2], words[3], snap, comp, now)
elif verb == "resurvey": # the settings page: look at the Dante switches now
snap.dante_map_time = 0.0
log.info("RESURVEY: Dante map survey requested")
else:
log.warning("unknown command %r", part)
def run(cfg, data_dir, dry_run=False, stop=None):
def run(cfg, data_dir, dry_run=False, stop=None, config_sha=None):
stop = stop or threading.Event()
names = cfg["variables"]
comp = make_companion(cfg)
snap = Snapshot()
engine = Engine(cfg, os.path.join(data_dir, "service-mode.json"))
engine = Engine(cfg, os.path.join(data_dir, "state.json"), data_dir)
snap.dante_map = load_dante_map(dante_map_path(data_dir))
site = cfg.get("site", {})
prefix = site.get("alert_prefix") or site.get("ingest_name") or "Status panel"
alert_after = cfg.get("timing", {}).get("alert_after_seconds", 30)
def on_term(signum, frame):
log.info("stop requested: %d fix(es) running will finish first", len(engine.running))
engine.stopping = True
stop.set()
try:
signal.signal(signal.SIGTERM, on_term)
except ValueError: # not the main thread (tests)
pass
threads = start_pollers(cfg, snap, stop, data_dir)
if not dry_run:
try:
# A hold pressed while the watchdog was down must not fire on a config it was not meant for.
queued = comp.get_custom(names["command"])
if queued and queued.strip():
comp.set_custom(names["command"], "")
log.info("dropped commands queued before the start: %s", queued.strip())
except (OSError, http.client.HTTPException) as e:
log.warning("could not read the command variable at start: %s", e)
stop.wait(2.5) # let the first polls land before judging anything
last_json, last_write, last_eval = None, 0.0, 0.0
down_since, alerted, warned_missing = None, False, set()
log.info("status panel running (%s)", "dry run" if dry_run else "live")
log.info("status panel running (code %s, %s)", VERSION, "dry run" if dry_run else "live")
try:
write_json(os.path.join(data_dir, "started.json"),
{"pid": os.getpid(), "started_at": time.time(), "config_sha": config_sha, "version": VERSION})
except OSError as e:
log.warning("started.json: %s", e)
while not stop.is_set():
now = time.time()
try:
@@ -2428,18 +2756,27 @@ def run(cfg, data_dir, dry_run=False, stop=None):
if down_since is not None:
log.info("Companion reachable again")
if alerted:
post_webhook(cfg.get("alert_webhook"), "NA Ingest: Companion is answering again.")
post_webhook(cfg.get("alert_webhook"), "%s: Companion is answering again." % prefix)
down_since, alerted = None, False
except (OSError, http.client.HTTPException) as e:
if down_since is None:
down_since = now
log.warning("Companion unreachable: %s", e)
elif not alerted and now - down_since > 30:
post_webhook(cfg.get("alert_webhook"), "NA Ingest: Companion has not answered for 30 seconds.")
elif not alerted and now - down_since > alert_after:
post_webhook(cfg.get("alert_webhook"), "%s: Companion has not answered for %d seconds."
% (prefix, alert_after))
alerted = True
stop.wait(0.25)
# A fix that is running finishes before the process goes (launchd waits exit_timeout for us).
budget = time.time() + max(5, exit_timeout(cfg) - 5)
for t in engine.fix_threads:
t.join(timeout=max(0.1, budget - time.time()))
if any(t.is_alive() for t in engine.fix_threads):
log.warning("stopping with a fix still running")
else:
log.info("stopped")
for t in threads:
t.join(timeout=10)
t.join(timeout=2)
def run_once(cfg):
@@ -2483,8 +2820,16 @@ def main(argv=None):
ap.add_argument("--once", action="store_true", help="poll once, print the tiles, write nothing")
ap.add_argument("--dry-run", action="store_true", help="run but never write to Companion")
ap.add_argument("--log", default=os.path.expanduser("~/Library/Logs/status-panel.log"))
ap.add_argument("--check", action="store_true", help="load and validate the config, then exit")
ap.add_argument("--exit-timeout", action="store_true", help="print the launchd exit timeout this config needs")
args = ap.parse_args(argv)
cfg = load_config(args.config)
if args.check:
print("config OK: %d keys, %d fixes" % (len(cfg["tiles"]), len(cfg.get("fixes", {}))))
return
if args.exit_timeout:
print(exit_timeout(cfg))
return
if args.once:
run_once(cfg)
return
@@ -2494,7 +2839,11 @@ def main(argv=None):
if sys.stderr.isatty():
log.addHandler(logging.StreamHandler())
log.setLevel(logging.INFO)
run(cfg, args.data_dir, dry_run=args.dry_run)
if not (3, 9) <= sys.version_info[:2] <= (3, 12):
log.warning("python %s: the Dante switch reader (telnetlib) needs 3.9-3.12", sys.version.split()[0])
with open(args.config, "rb") as f:
config_sha = hashlib.sha256(f.read()).hexdigest()
run(cfg, args.data_dir, dry_run=args.dry_run, config_sha=config_sha)
if __name__ == "__main__":