The LTC write happens by itself: READY fixes ticked for auto-fix act on the checklist, any hour for ready_ltc

Auto-fix now looks at the READY page's keys too, for the READY fixes ticked under Settings > Auto-fix,
and acts on a checklist gap while it is still amber. A fix marked anytime (only a READY fix may be)
runs outside the auto-fix hours as well, never while ALARMS is muted; the window's own fixes keep
their hours and start fresh each window. ready_ltc is ticked and marked anytime at North Attleboro:
once the weekend's set is on Tracks, every song's timecode goes into all four campuses' plans.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 14:08:33 -04:00
co-authored by Claude Fable 5.1
parent f1cd42a437
commit b7af31ea6f
6 changed files with 82 additions and 22 deletions
+32 -19
View File
@@ -645,7 +645,8 @@ def effective_config(cfg):
def all_tiles(cfg):
"""Every key the watchdog judges: the Status page's tiles and the READY page's keys. The READY
page's keys are not in ALL (nor in auto-fix): a weekday gap in the checklist is not an alarm."""
page's keys are not in ALL: a weekday gap in the checklist is not an alarm. Auto-fix does look
at them, for the READY fixes ticked under Settings > Auto-fix."""
return cfg["tiles"] + ready_keys(cfg)
# Checks whose connection a long press may restart (Dante is shared, so not that one).
@@ -1233,12 +1234,13 @@ class Engine:
tile = self.tiles.get(tile_id, {})
return [c["conn"] for c in tile.get("checks", []) if c["type"] in RESTARTABLE and c.get("conn")]
def fix_plan(self, tile_id, snap, now=None, min_age=0, errors_only=False):
def fix_plan(self, tile_id, snap, now=None, min_age=0, errors_only=False, levels=(ERR,)):
"""What a hold on this button would do: fix only what is failing right now.
[(identity, "fix", fix id, check) or (identity, "restart", connection label, check)]
A check's own "fix" wins; otherwise a failing Companion connection is restarted.
Nothing is done for a healthy button, and a recording HyperDeck is never touched.
With errors_only, only faults of these `levels` that have lasted min_age count (auto-fix).
"""
tile = self.tiles.get(tile_id, {})
ids = self.ready_ids if tile.get("kind") == "ready" else (tile.get("members") or [tile_id])
@@ -1248,7 +1250,7 @@ class Engine:
tr = self.trackers.get(key)
if tid not in ids or not tr or not tr.confirmed or tr.result.level not in (WARN, ERR):
continue
if errors_only and (tr.result.level != ERR or now - (tr.since or now) < min_age):
if errors_only and (tr.result.level not in levels or now - (tr.since or now) < min_age):
continue # auto-fix: only real faults that have lasted
# A connection switched off in Companion is fixed by switching it on - never by
# acting on the device (2026-10-08: a disabled HyperDeck connection got a deck reboot).
@@ -1500,19 +1502,26 @@ class Engine:
# (playing the Spotify playlist): those keep running inside the window.
muted = not self.service.on(now)
keep = set(a.get("while_muted", [])) if muted else None
if not in_windows(a.get("windows", []), now) or (muted and not keep):
hours = in_windows(a.get("windows", []), now)
# A fix marked "anytime" (writing the timecode into Planning Center) runs by itself at any
# hour, not only in the auto-fix hours - still never while ALARMS is muted.
anytime = {f for f in a.get("fixes", []) if self.cfg["fixes"].get(f, {}).get("anytime")
and not self.cfg["fixes"].get(f, {}).get("never_auto") and not self.cfg["fixes"].get(f, {}).get("disabled")}
if not hours or (muted and not keep):
if self.auto_on and self.autolog:
self.autolog.write(now, "PANEL", "auto-fix off (%s)" % ("ALARMS muted" if in_windows(
a.get("windows", []), now) else "end of the window"))
self.autolog.write(now, "PANEL", "auto-fix off (%s)" % ("ALARMS muted" if hours else "end of the window"))
self.auto_on = False
self.auto_tries.clear()
self.auto_seen.clear()
return []
if (not self.auto_on or self.auto_muted != muted) and self.autolog: # opens today's file, so it can be watched from the start
ends = [w["end"] for w in a.get("windows", []) if in_windows([w], now)]
self.autolog.write(now, "PANEL", "auto-fix on%s%s" % (" until " + ends[0] if ends else "",
" (ALARMS muted: only %s)" % ", ".join(sorted(keep)) if muted else ""))
self.auto_on, self.auto_muted = True, muted
for ident in [i for i in self.auto_tries if not (i[0] == "fix" and i[1] in anytime)]:
del self.auto_tries[ident] # the window's fixes start fresh next time
self.auto_seen.pop(ident, None)
if muted or not anytime:
return []
else:
if (not self.auto_on or self.auto_muted != muted) and self.autolog: # opens today's file, so it can be watched from the start
ends = [w["end"] for w in a.get("windows", []) if in_windows([w], now)]
self.autolog.write(now, "PANEL", "auto-fix on%s%s" % (" until " + ends[0] if ends else "",
" (ALARMS muted: only %s)" % ", ".join(sorted(keep)) if muted else ""))
self.auto_on, self.auto_muted = True, muted
net_down = any(self.trackers.get("net:" + k) and self.trackers["net:" + k].confirmed
and self.trackers["net:" + k].result.level == ERR for k in ("link", "gateway"))
if net_down or (self.burst and (self.burst["cleared"] is None
@@ -1525,9 +1534,11 @@ class Engine:
self.autolog.write(now, "NET", "network settled: auto-fix is back on")
self.auto_paused = False
gentle = {f for f in a.get("fixes", []) if not self.cfg["fixes"].get(f, {}).get("never_auto")
and not self.cfg["fixes"].get(f, {}).get("disabled")}
and not self.cfg["fixes"].get(f, {}).get("disabled")} if hours else anytime
restarts = bool(hours and a.get("restart_connections"))
ready_ids = {k["id"] for k in ready_keys(self.cfg)}
done = []
for t in self.cfg["tiles"]:
for t in all_tiles(self.cfg):
if "checks" not in t:
continue # groups repeat their members
gone = [k for k in self.keys_for(t["id"]) if self.meta.get(k) and self.meta[k][1]["type"] == "dante"
@@ -1544,8 +1555,10 @@ class Engine:
self.auto_seen[("dante-wide", t["id"])] = now
continue
plan = []
for ident, kind, what, chk in self.fix_plan(t["id"], snap, now, a.get("after_seconds", 20), True):
if not ((kind == "fix" and what in gentle) or (kind == "restart" and a.get("restart_connections"))):
# A READY key's gap is amber until the checklist is due: a ticked READY fix acts on it anyway.
levels = (WARN, ERR) if t["id"] in ready_ids else (ERR,)
for ident, kind, what, chk in self.fix_plan(t["id"], snap, now, a.get("after_seconds", 20), True, levels):
if not ((kind == "fix" and what in gentle) or (kind == "restart" and restarts)):
continue # not gentle: stays a hold
if keep is not None and not (kind == "fix" and what in keep):
continue # ALARMS muted: only the "while_muted" fixes
@@ -1567,7 +1580,7 @@ class Engine:
try_no = max(self.auto_tries[i][0] for i, _, _, _ in plan)
log.info("AUTO %s: fixing %s (try %d)", t["id"], ", ".join("%s %s" % (k, w) for _, k, w, _ in plan), try_no)
faults = [r.msg for k in self.keys_for(t["id"]) for r in [self.trackers[k].result]
if self.trackers[k].confirmed and r.level == ERR]
if self.trackers[k].confirmed and r.level in levels]
for f in faults:
self.auto_watch["%s %s" % (t["label"], f)] = now
label = t["label"]