Daily auto-fix log on NA Ingest's Desktop

~/Desktop/Auto-Fix Logs/<YYYY-MM-DD Weekday> Auto-Fix.log, written line by
line as it happens: auto-fix on/off, each problem and what was done (try n
of 2), the result, when the fault cleared, give-ups, and when it held back
(network problem, several Dante devices gone). Tests write to a temp
folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 07:10:06 -04:00
co-authored by Claude Opus 5.5
parent 560e0401a3
commit f7a98f4dec
3 changed files with 117 additions and 6 deletions
+1
View File
@@ -73,6 +73,7 @@ During that window, with ALARMS on, the panel fixes a problem by itself once it
- At most 2 tries per problem, 90 s apart; then it is yours to hold.
- It waits while the network is having a blip, and does nothing to Dante when several devices vanish at once (that is NA Ingest's view or the Dante network, not the Macs).
- Never automatic: restarting a running ProPresenter, Bible app, ProdCom (or DVS on NA Ingest, which restarts ProdCom), captions or the bridge, power-cycling a switch port, or turning on a connection someone switched off. `load_config` refuses an auto fix that closes a program.
- **Daily log:** everything auto-fix does is written as it happens to `~/Desktop/Auto-Fix Logs/<date> <weekday> Auto-Fix.log` on NA Ingest (e.g. `2026-10-11 Sunday Auto-Fix.log`): the problem, what it did, whether it worked, when the fault cleared, when it gave up or held back. Double-click it to open it in Console, which shows new lines live.
- Press ALARMS to mute the panel for maintenance: that also stops auto-fix. The window and the list are `"auto_fix"` in `config.json`.
## Restart page (page 20)
+92 -5
View File
@@ -580,6 +580,37 @@ class Tracker:
DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
class AutoLog:
"""One plain-text file per day of everything auto-fix does, written line by line as it happens,
e.g. "~/Desktop/Auto-Fix Logs/2026-10-11 Sunday Auto-Fix.log" (a .log opens in Console, which
shows new lines live)."""
def __init__(self, directory):
self.dir = os.path.expanduser(directory)
self.lock = threading.Lock()
def path(self, now):
return os.path.join(self.dir, datetime.fromtimestamp(now).strftime("%Y-%m-%d %A") + " Auto-Fix.log")
def write(self, now, button, text):
dt = datetime.fromtimestamp(now)
line = "%s %-8s %s\n" % (dt.strftime("%H:%M:%S"), button, text.replace("\n", " "))
try:
with self.lock:
os.makedirs(self.dir, exist_ok=True)
path = self.path(now)
new = not os.path.exists(path)
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"
" running program stays a hold. Newest lines at the bottom.)\n\n"
% dt.strftime("%A %-d %B %Y"))
f.write(line)
except OSError as e:
log.warning("auto-fix log %s: %s", self.dir, e)
def in_windows(windows, now):
"""Is `now` inside one of [{"days": [...], "start": "HH:MM", "end": "HH:MM"}]?"""
dt = datetime.fromtimestamp(now)
@@ -665,6 +696,10 @@ class Engine:
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
self.auto_watch = {} # "BUTTON FAULT" auto-fix went after -> when (to log when it clears)
self.auto_paused = False
self.auto_on = False # inside the window (for the "auto-fix on" line)
self.auto_last = 0.0
self.deck_ips = {} # HyperDeck connection -> last address Companion reported
self.now = 0.0
@@ -766,7 +801,12 @@ class Engine:
if event == "fault":
self.events.append(("FAULT", "%s %s" % (self.tiles[tile_id]["label"], res.msg)))
elif event == "clear":
self.events.append(("CLEAR", "%s %s" % (self.tiles[tile_id]["label"], tr.last_fault_msg)))
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)
if fixed_at is not None and self.autolog:
self.autolog.write(now, self.tiles[tile_id]["label"],
"OK again: %s (%d s after the auto-fix)" % (tr.last_fault_msg, now - fixed_at))
# A check may stop reporting a fault instead of reporting it OK: a Shure or PSM check says
# "conn" while its connection is down and per channel once it is back (2026-10-08). Such a
# fault is over - clear it, or it lingers unseen and a hold would still try to fix it.
@@ -947,7 +987,7 @@ 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):
def run_plan(self, tile_id, plan, snap, comp, now, cooldown=60, auto=False, on_done=None):
"""Carry out fix_plan() items for one button (a hold, or auto-fix during service)."""
started, names, waited, midi = [], [], False, []
for ident, kind, what, chk in plan:
@@ -993,6 +1033,8 @@ class Engine:
summary = names[0] if len(set(names)) == 1 else "%d FIXES" % len(names)
if not started: # only Companion reconnects: quick
self.note(tile_id, "CHECKING\n" + summary, "fixing", now, 15)
if on_done:
on_done("asked Companion to reconnect")
return plan
self.note(tile_id, ("AUTO-FIXING\n" if auto else "FIXING\n") + summary, "fixing", now, 180)
results = []
@@ -1008,12 +1050,18 @@ class Engine:
done = time.time()
if WAKING in results:
self.note(tile_id, "WAKING PC\n" + summary, "fixing", done, 120)
outcome = "the PC was off: sent Wake-on-LAN"
elif said: # the fix says what to do next
self.note(tile_id, said[0], "failed", done, 60)
outcome = "did not work: " + said[0]
elif all(r is not False for r in results):
self.note(tile_id, "CHECKING\n" + summary, "fixing", done, 20)
outcome = "done, checking it comes back"
else:
self.note(tile_id, "FIX FAILED\n" + summary, "failed", done, 20)
outcome = "the fix command failed"
if on_done:
on_done(outcome)
said = []
for fn, args, short in started:
threading.Thread(target=work, args=(fn, args, short), daemon=True).start()
@@ -1030,14 +1078,28 @@ class Engine:
return []
self.auto_last = now
if not in_windows(a.get("windows", []), now) or not self.service.on(now):
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.auto_on = False
self.auto_tries.clear()
self.auto_seen.clear()
return []
if not self.auto_on 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" % (" until " + ends[0] if ends else ""))
self.auto_on = True
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
or now - self.burst["cleared"] < a.get("pause_after_network_seconds", 60))):
if not self.auto_paused and self.autolog:
self.autolog.write(now, "NET", "network problem: no auto-fixes until it settles")
self.auto_paused = True
return [] # everything looks broken at once: wait it out
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", []))
done = []
for t in self.cfg["tiles"]:
@@ -1051,6 +1113,9 @@ class Engine:
if self.auto_seen.get(("dante-wide", t["id"]), 0) < now - 600:
log.info("AUTO %s: %d Dante devices gone at once - not touching them, hold DANTE if needed",
t["id"], len(gone))
if self.autolog:
self.autolog.write(now, t["label"], "left alone: %d Dante devices gone at once (NA Ingest's "
"view of Dante or the Dante network) - hold DANTE if needed" % len(gone))
self.auto_seen[("dante-wide", t["id"])] = now
continue
plan = []
@@ -1062,6 +1127,9 @@ class Engine:
if tries >= a.get("max_tries", 2):
if tries == a.get("max_tries", 2):
log.info("AUTO %s: gave up on %s %s after %d tries - hold it to try again", t["id"], kind, what, tries)
if self.autolog:
self.autolog.write(now, t["label"], "gave up after %d tries: %s - hold %s to try again"
% (tries, self.describe(kind, what), t["label"]))
self.auto_tries[ident] = (tries + 1, last)
continue
if now - last < a.get("retry_seconds", 90) or now - self.fix_last.get(ident, 0) < 60:
@@ -1069,9 +1137,20 @@ class Engine:
self.auto_tries[ident] = (tries + 1, now)
plan.append((ident, kind, what, chk))
if plan:
log.info("AUTO %s: fixing %s (try %d)", t["id"], ", ".join("%s %s" % (k, w) for _, k, w, _ in plan),
max(self.auto_tries[i][0] for i, _, _, _ in plan))
self.run_plan(t["id"], plan, snap, comp, now, auto=True)
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]
for f in faults:
self.auto_watch["%s %s" % (t["label"], f)] = now
label = t["label"]
if self.autolog:
self.autolog.write(now, label, "problem: %s - fixing: %s (try %d of %d)" % (
", ".join(faults) or "?", "; ".join(self.describe(k, w) for _, k, w, _ in plan),
try_no, a.get("max_tries", 2)))
self.run_plan(t["id"], plan, snap, comp, now, auto=True,
on_done=(lambda outcome, label=label: self.autolog.write(time.time(), label, "-> " + outcome))
if self.autolog else None)
done.append(t["id"])
forget = a.get("forget_seconds", 600)
for ident in list(self.auto_tries):
@@ -1081,6 +1160,14 @@ class Engine:
del self.auto_tries[ident]
return done
def describe(self, kind, what):
"""A plan item in words, for the auto-fix log."""
if kind == "restart":
return "reconnect Companion's %s connection" % what
if kind == "fix":
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):
"""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."""
+24 -1
View File
@@ -29,6 +29,7 @@ CONFIG["service_windows"] = [{"days": ["sun"], "start": "07:00", "end": "12:30"}
CONFIG["tiles"] = [dict(t, label="SERVICE") if t["id"] == "service" else t for t in CONFIG["tiles"]]
# A Sunday 08:00 (inside the service window) and a Wednesday 15:00 (outside it).
SUNDAY = datetime(2026, 10, 11, 8, 0).timestamp()
CONFIG["auto_fix"]["log_dir"] = tempfile.mkdtemp(prefix="sp-autolog-") # never the real Desktop
WEDNESDAY = datetime(2026, 10, 7, 15, 0).timestamp()
@@ -585,8 +586,9 @@ class TestFixes(unittest.TestCase):
def setUp(self):
self.snap = healthy_snapshot(SUNDAY)
self.engine = sp.Engine(CONFIG)
# Safety net: a hold in these tests must never reach a real machine.
# Safety net: a hold in these tests must never reach a real machine (or write a real log).
self.engine.executor = self.engine.poe_executor = lambda *a: True
self.engine.autolog = sp.AutoLog(tempfile.mkdtemp())
def plan(self, tile):
run_steps(self.engine, self.snap, SUNDAY, 4)
@@ -699,6 +701,27 @@ class TestFixes(unittest.TestCase):
self.assertEqual(self.engine.notes["apps"]["text"], "AUTO-FIXING\nABLETON")
gate.set()
def test_auto_fix_writes_a_daily_log(self):
ran, comp = self.auto_setup()
t = SUNDAY
self.snap.remote["tracks\0pgrep -xq Live"] = False
run_steps(self.engine, self.snap, t, 25)
self.engine.auto_fix(self.snap, comp, t + 25)
time.sleep(0.2)
self.snap.remote["tracks\0pgrep -xq Live"] = True # Ableton is back
run_steps(self.engine, self.snap, t + 40, 1)
path = self.engine.autolog.path(t)
self.assertTrue(path.endswith("2026-10-11 Sunday Auto-Fix.log"))
lines = open(path).read().splitlines()
self.assertTrue(lines[0].startswith("Status panel auto-fixes - Sunday 11 October 2026"))
body = [l for l in lines if l[:2].isdigit()]
self.assertIn("PANEL auto-fix on until 13:30", body.pop(0))
self.assertEqual(body[0][:8], "08:00:25")
self.assertIn("APPS problem: ABLETON NOT RUNNING - fixing: start Ableton", body[0])
self.assertIn("(try 1 of 2)", body[0])
self.assertIn("-> done, checking it comes back", body[1])
self.assertIn("OK again: ABLETON NOT RUNNING (15 s after the auto-fix)", body[2])
def test_auto_fix_tries_twice_then_leaves_it_to_you(self):
ran, comp = self.auto_setup()
self.snap.remote["tracks\0pgrep -xq Live"] = False