Sunday auto-fix (gentle fixes only), reviewed; PROPRESENTER key label

Sundays 06:30-13:30, with ALARMS on, a fault that has lasted 20 s is fixed
by itself if the fix is gentle (reconnects, HyperDeck off/on, Tascam, MIDI,
rtpMIDI, starting a closed Vista/Resolume/OBS/Ableton/Translate, DVS on one
Mac). At most 2 tries 90 s apart; key shows AUTO-FIXING. Anything that
closes a running program stays a hold (load_config refuses it).

A review before going live (2 lenses, each finding verified) confirmed and
this fixes: a Dante-wide "gone" (Ingest's view) would restart DVS on every
Mac every minute - several devices gone at once now means hands off; the
try limit reset whenever a fix made its fault blink - tries are kept until
the fault has been gone 10 min; DVS on Ingest kills ProdCom - removed from
the auto list; restarting dead connections looked like a network burst -
only a connection good in the last 30 s counts; a restart key could kill
an app auto-fix was starting; concurrent MIDI reconnects overwrote each
other - now one worker. Auto-fix errors are caught so the panel keeps
running. 105 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 07:05:05 -04:00
co-authored by Claude Opus 5.5
parent 3417f00f1b
commit 560e0401a3
4 changed files with 274 additions and 10 deletions
+10 -1
View File
@@ -66,6 +66,15 @@ Row 0 holds the group buttons. Each one shows the first problem in its group, pl
It checks over SSH every 10 s that ProPresenter, Ableton and Church Translate are running, using NA Ingest's key `~/.ssh/id_status_panel`. That key was authorized on the Macs with `remote-access/mac-authorize.sh`. It checks over SSH every 10 s that ProPresenter, Ableton and Church Translate are running, using NA Ingest's key `~/.ssh/id_status_panel`. That key was authorized on the Macs with `remote-access/mac-authorize.sh`.
- **ALARMS:** alarms are armed all the time (`service_windows` in `config.json`: every day, 00:00–23:59). Press ALARMS to mute the blinking, for example during maintenance. It re-arms itself at midnight. - **ALARMS:** alarms are armed all the time (`service_windows` in `config.json`: every day, 00:00–23:59). Press ALARMS to mute the blinking, for example during maintenance. It re-arms itself at midnight.
## Auto-fix on Sundays (06:30–13:30)
During that window, with ALARMS on, the panel fixes a problem by itself once it has lasted 20 s, but only with **gentle fixes**: Companion reconnects, HyperDeck off/on, the Tascam reconnect, MIDI reconnects, starting rtpMIDI, and starting Vista, Resolume, OBS, Ableton or Church Translate if they are closed, plus DVS on one Mac that dropped out of Dante. The key shows **AUTO-FIXING**.
- 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.
- 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) ## Restart page (page 20)
Tap **RESTART PAGE** on the status page (row 2). Each key restarts one thing **even when the status page says it is fine**; hold it 2 s. A key **flashes red (HOLD TO FIX)** while the thing it restarts is down, so you can see which one to hold. It shows RESTARTING, then DONE (or FAILED / what to do). The same thing will not run twice within a minute. **STATUS PAGE** (bottom left) goes back. Tap **RESTART PAGE** on the status page (row 2). Each key restarts one thing **even when the status page says it is fine**; hold it 2 s. A key **flashes red (HOLD TO FIX)** while the thing it restarts is down, so you can see which one to hold. It shows RESTARTING, then DONE (or FAILED / what to do). The same thing will not run twice within a minute. **STATUS PAGE** (bottom left) goes back.
@@ -73,7 +82,7 @@ Tap **RESTART PAGE** on the status page (row 2). Each key restarts one thing **e
| Key | Restarts | | Key | Restarts |
|---|---| |---|---|
| DVS PROP / TRACKS / TRANSL / INGEST | Dante Virtual Soundcard's engine on that Mac (audio to and from it drops 5–10 s). DVS INGEST also restarts ProdCom afterwards, which otherwise keeps a dead input. | | DVS PROP / TRACKS / TRANSL / INGEST | Dante Virtual Soundcard's engine on that Mac (audio to and from it drops 5–10 s). DVS INGEST also restarts ProdCom afterwards, which otherwise keeps a dead input. |
| PROPRES | ProPresenter (quits and reopens the newest version) | | PROPRESENTER | ProPresenter (quits and reopens the newest version) |
| ABLETON | Ableton on the Tracks Mac, reopening the set it had open (from Live's own log; templates skipped). Unsaved changes are lost, and Live may ask to recover - click it on the Tracks Mac. APPS's hold, which starts a closed Ableton, opens that set too. | | ABLETON | Ableton on the Tracks Mac, reopening the set it had open (from Live's own log; templates skipped). Unsaved changes are lost, and Live may ask to recover - click it on the Tracks Mac. APPS's hold, which starts a closed Ableton, opens that set too. |
| TRANSLATE | Church Translate on NA Translation | | TRANSLATE | Church Translate on NA Translation |
| PRODCOM | ProdCom on NA Ingest (lobby captions blank for 5–15 s) | | PRODCOM | ProdCom on NA Ingest (lobby captions blank for 5–15 s) |
+10 -2
View File
@@ -78,14 +78,22 @@
"label": "turn Companion's connection to that HyperDeck off and on (what gets a restarted deck green); never reboots the deck" } "label": "turn Companion's connection to that HyperDeck off and on (what gets a restarted deck green); never reboots the deck" }
}, },
"auto_fix": {
"windows": [{ "days": ["sun"], "start": "06:30", "end": "13:30" }],
"after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60,
"restart_connections": true,
"fixes": ["hyperdeck", "tascam_reconnect", "midi_reconnect", "rtpmidi_start", "vista", "arena", "obs",
"ableton", "translate", "dvs_prop", "dvs_tracks", "dvs_translation"]
},
"restart_page": { "restart_page": {
"page": 20, "page_name": "20\\nRestart", "jump_pos": [2, 6], "back_pos": [3, 0], "page": 20, "page_name": "20\\nRestart", "jump_pos": [2, 6], "back_pos": [2, 6],
"keys": [ "keys": [
{ "id": "rs_dvs_prop", "label": "DVS PROP", "pos": [0, 1], "fix": "dvs_prop" }, { "id": "rs_dvs_prop", "label": "DVS PROP", "pos": [0, 1], "fix": "dvs_prop" },
{ "id": "rs_dvs_tracks", "label": "DVS TRACKS", "pos": [0, 2], "fix": "dvs_tracks" }, { "id": "rs_dvs_tracks", "label": "DVS TRACKS", "pos": [0, 2], "fix": "dvs_tracks" },
{ "id": "rs_dvs_transl", "label": "DVS TRANSL", "pos": [0, 3], "fix": "dvs_translation" }, { "id": "rs_dvs_transl", "label": "DVS TRANSL", "pos": [0, 3], "fix": "dvs_translation" },
{ "id": "rs_dvs_ingest", "label": "DVS INGEST", "pos": [0, 4], "fix": "dvs_ingest" }, { "id": "rs_dvs_ingest", "label": "DVS INGEST", "pos": [0, 4], "fix": "dvs_ingest" },
{ "id": "rs_propres", "label": "PROPRES", "pos": [1, 1], "fix": "propresenter" }, { "id": "rs_propres", "label": "PROPRESENTER", "pos": [1, 1], "fix": "propresenter" },
{ "id": "rs_ableton", "label": "ABLETON", "pos": [1, 2], "fix": "ableton_restart", "watch": ["ableton", "conn:ableton"] }, { "id": "rs_ableton", "label": "ABLETON", "pos": [1, 2], "fix": "ableton_restart", "watch": ["ableton", "conn:ableton"] },
{ "id": "rs_translate", "label": "TRANSLATE", "pos": [1, 3], "fix": "translate_restart", "watch": ["translate"] }, { "id": "rs_translate", "label": "TRANSLATE", "pos": [1, 3], "fix": "translate_restart", "watch": ["translate"] },
{ "id": "rs_prodcom", "label": "PRODCOM", "pos": [2, 1], "fix": "prodcom" }, { "id": "rs_prodcom", "label": "PRODCOM", "pos": [2, 1], "fix": "prodcom" },
+116 -6
View File
@@ -580,6 +580,13 @@ class Tracker:
DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
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)
class ServiceMode: class ServiceMode:
"""On inside a scheduled window; a button press overrides until the next window edge.""" """On inside a scheduled window; a button press overrides until the next window edge."""
@@ -644,6 +651,7 @@ class Engine:
self.group_of[m] = t["id"] self.group_of[m] = t["id"]
self.detail_ids = [t["id"] for t in cfg["tiles"] if "checks" in t or t.get("kind") == "network"] self.detail_ids = [t["id"] for t in cfg["tiles"] if "checks" in t or t.get("kind") == "network"]
self.prev_conn_bad = {} self.prev_conn_bad = {}
self.conn_good_at = {} # connection -> last time it was "good"
self.conn_drops = deque() # (time, conn label, group) self.conn_drops = deque() # (time, conn label, group)
self.burst = None # {"start", "conns", "cleared"} self.burst = None # {"start", "conns", "cleared"}
self.events = [] self.events = []
@@ -654,6 +662,10 @@ class Engine:
self.notes = {} # tile id -> hold feedback {"text", "level", "until"} 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. # 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.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.auto_last = 0.0
self.deck_ips = {} # HyperDeck connection -> last address Companion reported self.deck_ips = {} # HyperDeck connection -> last address Companion reported
self.now = 0.0 self.now = 0.0
@@ -700,7 +712,11 @@ class Engine:
bad = bool(c.get("enabled")) and c.get("category") == "error" bad = bool(c.get("enabled")) and c.get("category") == "error"
if bad: if bad:
bad_now.add(label) bad_now.add(label)
if bad and not self.prev_conn_bad.get(label): if c.get("category") == "good":
self.conn_good_at[label] = now
# Only a connection that was fine moments ago counts as dropping: one that a fix just
# restarted goes error -> starting -> error, and is not the network (2026-10-11 review).
if bad and not self.prev_conn_bad.get(label) and now - self.conn_good_at.get(label, -1e9) <= 30:
self.conn_drops.append((now, label, self.group_for_conn(label))) self.conn_drops.append((now, label, self.group_for_conn(label)))
self.prev_conn_bad[label] = bad self.prev_conn_bad[label] = bad
window = self.net.get("burst_window_seconds", 10) window = self.net.get("burst_window_seconds", 10)
@@ -791,7 +807,7 @@ class Engine:
tile = self.tiles.get(tile_id, {}) tile = self.tiles.get(tile_id, {})
return [c["conn"] for c in tile.get("checks", []) if c["type"] in RESTARTABLE and c.get("conn")] 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): def fix_plan(self, tile_id, snap, now=None, min_age=0, errors_only=False):
"""What a hold on this button would do: fix only what is failing right now. """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)] [(identity, "fix", fix id, check) or (identity, "restart", connection label, check)]
@@ -806,6 +822,8 @@ class Engine:
tr = self.trackers.get(key) 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): if tid not in ids or not tr or not tr.confirmed or tr.result.level not in (WARN, ERR):
continue continue
if errors_only and (tr.result.level != ERR 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 # 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). # acting on the device (2026-10-08: a disabled HyperDeck connection got a deck reboot).
label = chk.get("conn") label = chk.get("conn")
@@ -927,7 +945,11 @@ class Engine:
log.info("HOLD %s: nothing failing, nothing to fix", tile_id) log.info("HOLD %s: nothing failing, nothing to fix", tile_id)
self.note(tile_id, "NOTHING\nTO FIX", "idle", now, 4) self.note(tile_id, "NOTHING\nTO FIX", "idle", now, 4)
return plan return plan
started, names, waited = [], [], False 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):
"""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: for ident, kind, what, chk in plan:
if now - self.fix_last.get(ident, 0) < cooldown: if now - self.fix_last.get(ident, 0) < cooldown:
log.info("HOLD %s: %s %s ran less than %ds ago, skipped", tile_id, kind, what, cooldown) log.info("HOLD %s: %s %s ran less than %ds ago, skipped", tile_id, kind, what, cooldown)
@@ -954,15 +976,25 @@ class Engine:
fix = self.cfg["fixes"][what] fix = self.cfg["fixes"][what]
log.info("FIX %s: %s", tile_id, fix.get("label", what)) log.info("FIX %s: %s", tile_id, fix.get("label", what))
names.append(fix.get("short", what.upper())) names.append(fix.get("short", what.upper()))
started.append((self.executor, (self.cfg, what, chk), fix.get("short", what.upper()))) if fix.get("kind") == "midi_connect":
midi.append((what, chk)) # one worker for all of them (see below)
else:
started.append((self.executor, (self.cfg, what, chk), fix.get("short", what.upper())))
if midi:
# One after another: two reconnects writing the same session at once overwrite each other.
def midi_worker(batch):
rs = [self.executor(self.cfg, f, c) for f, c in batch]
return next((r for r in rs if isinstance(r, str)), False if False in rs else True)
started.append((midi_worker, (midi,), self.cfg["fixes"][midi[0][0]].get("short", "MIDI")))
if not names: if not names:
self.note(tile_id, "JUST TRIED\nWAIT 1 MIN" if waited else "NOTHING\nTO FIX", "idle", now, 4) if not auto:
self.note(tile_id, "JUST TRIED\nWAIT 1 MIN" if waited else "NOTHING\nTO FIX", "idle", now, 4)
return plan return plan
summary = names[0] if len(set(names)) == 1 else "%d FIXES" % len(names) summary = names[0] if len(set(names)) == 1 else "%d FIXES" % len(names)
if not started: # only Companion reconnects: quick if not started: # only Companion reconnects: quick
self.note(tile_id, "CHECKING\n" + summary, "fixing", now, 15) self.note(tile_id, "CHECKING\n" + summary, "fixing", now, 15)
return plan return plan
self.note(tile_id, "FIXING\n" + summary, "fixing", now, 180) self.note(tile_id, ("AUTO-FIXING\n" if auto else "FIXING\n") + summary, "fixing", now, 180)
results = [] results = []
group = "members" in self.tiles.get(tile_id, {}) or len(started) > 1 group = "members" in self.tiles.get(tile_id, {}) or len(started) > 1
@@ -987,6 +1019,68 @@ class Engine:
threading.Thread(target=work, args=(fn, args, short), daemon=True).start() threading.Thread(target=work, args=(fn, args, short), daemon=True).start()
return plan return plan
def auto_fix(self, snap, comp, now):
"""During the auto-fix windows (Sunday service), fix by itself what a hold would fix - but
only gentle fixes (reconnects, starting a closed app, DVS for a Mac that dropped), only for
real faults that have lasted "after_seconds", at most "max_tries" times each, never while
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:
return []
self.auto_last = now
if not in_windows(a.get("windows", []), now) or not self.service.on(now):
self.auto_tries.clear()
self.auto_seen.clear()
return []
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))):
return [] # everything looks broken at once: wait it out
gentle = set(a.get("fixes", []))
done = []
for t in self.cfg["tiles"]:
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"
and self.meta[k][2] != "conn" and self.trackers[k].confirmed and self.trackers[k].result.level == ERR]
if len(gone) > 1:
# Several Dante devices "gone" at once is NA Ingest's view of Dante or the Dante network,
# not those Macs: restarting their DVS would cut audio that is working. Leave it to a hold.
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))
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"))):
continue # not gentle: stays a hold
self.auto_seen[ident] = now
tries, last = self.auto_tries.get(ident, (0, 0.0))
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)
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:
continue
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)
done.append(t["id"])
forget = a.get("forget_seconds", 600)
for ident in list(self.auto_tries):
# Forget the tries only once the fault has been gone a long while: a fix makes its own
# 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]
return done
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=60, fix_id=None, chk=None):
"""Run one fix whatever the status says: a Restart page key, or a button's "hold_always". """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 60 s guard with holds, so a hold and a restart key never double up."""
@@ -996,6 +1090,12 @@ class Engine:
log.warning("FORCE %s: no such restart key", key_id) log.warning("FORCE %s: no such restart key", key_id)
return None return None
fix_id = key["fix"] fix_id = key["fix"]
busy = [w for w in key.get("watch", []) if not w.startswith("conn:")
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)
return None
per_deck = self.cfg["fixes"][fix_id].get("kind") == "hyperdeck" and chk and chk.get("conn") 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 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: if now - self.fix_last.get(ident, 0) < cooldown:
@@ -1728,6 +1828,11 @@ def load_config(path):
raise ValueError("tile %s: unknown host %s" % (t["id"], c["host"])) raise ValueError("tile %s: unknown host %s" % (t["id"], c["host"]))
if t.get("hold_always") and t["hold_always"] not in cfg.get("fixes", {}): 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"])) 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 re.search(r"pkill|taskkill|killall", cfg["fixes"][f].get("run", "")):
raise ValueError("auto_fix: %s closes a running program - that stays a hold" % f)
for k in cfg.get("restart_page", {}).get("keys", []): for k in cfg.get("restart_page", {}).get("keys", []):
if k["id"] in ids: if k["id"] in ids:
raise ValueError("restart key %s has a tile's id" % k["id"]) raise ValueError("restart key %s has a tile's id" % k["id"])
@@ -1855,6 +1960,11 @@ def run(cfg, data_dir, dry_run=False, stop=None):
if now - last_eval >= 1.0: if now - last_eval >= 1.0:
last_eval = now last_eval = now
tiles = engine.step(snap, now) tiles = engine.step(snap, now)
if not dry_run:
try:
engine.auto_fix(snap, comp, now)
except Exception: # a bug here must never take the panel down
log.exception("auto-fix failed; the panel carries on without it this round")
for kind, text in engine.events: for kind, text in engine.events:
log.info("%s %s", kind, text) log.info("%s %s", kind, text)
engine.events = [] engine.events = []
+138 -1
View File
@@ -566,6 +566,19 @@ class TestBehaviour(unittest.TestCase):
self.assertEqual(engine.restart_targets("ingest"), []) self.assertEqual(engine.restart_targets("ingest"), [])
def mock_comp():
class Comp:
def __init__(self):
self.restarted, self.enabled = [], []
def restart_connection(self, cid):
self.restarted.append(cid)
return True
def enable_connection(self, cid):
self.enabled.append(cid)
return True
return Comp()
class TestFixes(unittest.TestCase): class TestFixes(unittest.TestCase):
"""Hold = fix only what that button is failing on. Nothing here runs a real fix.""" """Hold = fix only what that button is failing on. Nothing here runs a real fix."""
@@ -660,6 +673,130 @@ class TestFixes(unittest.TestCase):
open(os.path.join(os.path.dirname(os.path.abspath(sp.__file__)), "remote-access", "mac-dvs-helper.sh")).read()) open(os.path.join(os.path.dirname(os.path.abspath(sp.__file__)), "remote-access", "mac-dvs-helper.sh")).read())
self.assertIn("open -g /Applications/ProdCom.app", CONFIG["fixes"]["dvs_ingest"]["run"]) # ProdCom after DVS self.assertIn("open -g /Applications/ProdCom.app", CONFIG["fixes"]["dvs_ingest"]["run"]) # ProdCom after DVS
def auto_setup(self):
ran = []
self.engine.executor = lambda cfg, fix_id, chk: ran.append(fix_id) or True
comp = mock_comp()
return ran, comp
def test_auto_fix_sunday_service_gentle_fixes_only(self):
ran, comp = self.auto_setup()
self.snap.remote["tracks\0pgrep -xq Live"] = False # Ableton closed: gentle
self.snap.http["http://10.6.7.13:8080/"] = False # Bible app: restart = a hold
self.snap.conns["x32"]["category"] = "error" # a reconnect: gentle
t = SUNDAY # Sunday 10:00, alarms on
run_steps(self.engine, self.snap, t, 10)
self.assertEqual(self.engine.auto_fix(self.snap, comp, t + 10), []) # not 20 s yet
done = self.engine.auto_fix(self.snap, comp, t + 30)
time.sleep(0.2)
self.assertEqual(sorted(done), ["apps", "x32"])
self.assertEqual(ran, ["ableton"]) # never "bibleapp"
self.assertEqual(comp.restarted, ["id-x32"])
# while it works, the key says it is fixing itself
gate = threading.Event()
self.engine.executor = lambda cfg, fix_id, chk: gate.wait(2) or True
self.engine.auto_fix(self.snap, comp, t + 130) # second try, 90 s later
self.assertEqual(self.engine.notes["apps"]["text"], "AUTO-FIXING\nABLETON")
gate.set()
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
t = SUNDAY
run_steps(self.engine, self.snap, t, 25)
for dt in (25, 60, 120, 230, 400): # 90 s apart, at most twice
self.engine.auto_fix(self.snap, comp, t + dt)
time.sleep(0.2)
self.assertEqual(ran, ["ableton", "ableton"])
self.snap.remote["tracks\0pgrep -xq Live"] = True # fixed (by hand)...
run_steps(self.engine, self.snap, t + 401, 2)
self.engine.auto_fix(self.snap, comp, t + 410)
self.assertIn(("fix", "ableton", ""), self.engine.auto_tries) # a blink does not reset it
self.engine.auto_fix(self.snap, comp, t + 1100)
self.assertEqual(self.engine.auto_tries, {}) # gone 10 min: fresh tries
def test_auto_fix_count_survives_its_own_fault_blinking_out(self):
ran, comp = self.auto_setup()
t = SUNDAY
self.snap.conns["x32"]["category"] = "error"
run_steps(self.engine, self.snap, t, 25)
for dt in range(25, 900, 30): # each reconnect: error -> starting -> error
self.snap.conns["x32"]["category"] = None if dt % 60 == 55 else "error"
run_steps(self.engine, self.snap, t + dt, 1)
self.engine.auto_fix(self.snap, comp, t + dt + 1)
self.assertEqual(comp.restarted, ["id-x32", "id-x32"]) # still only twice
def test_auto_fix_leaves_dante_alone_when_many_devices_vanish(self):
ran, comp = self.auto_setup()
t = SUNDAY
devices = json.loads(self.snap.vars[("audinate-dantecontroller", "devices")])
self.snap.vars[("audinate-dantecontroller", "devices")] = json.dumps(
[d for d in devices if d not in ("NA-Tracks", "NA-ProPresenter-Mac", "BOH-WING")])
run_steps(self.engine, self.snap, t, 30)
self.engine.auto_fix(self.snap, comp, t + 30)
self.assertEqual((ran, comp.restarted), ([], [])) # Ingest's view of Dante: hands off
self.snap.vars[("audinate-dantecontroller", "devices")] = json.dumps(
[d for d in devices if d != "NA-Tracks"]) # just the Tracks Mac's DVS
run_steps(self.engine, self.snap, t + 31, 1)
run_steps(self.engine, self.snap, t + 600, 30)
self.engine.auto_fix(self.snap, comp, t + 630)
time.sleep(0.2)
self.assertEqual(ran, ["dvs_tracks"])
self.assertNotIn("dvs_ingest", CONFIG["auto_fix"]["fixes"]) # it closes ProdCom
def test_midi_reconnects_run_one_after_another(self):
calls = []
def slow(cfg, fix_id, chk):
calls.append((threading.get_ident(), chk["peer"]))
time.sleep(0.05)
return True
self.engine.executor = slow
self.snap.midi["NA Ingest"]["peers"] = [] # Lighting PC and SuperRack gone
_, t = run_steps(self.engine, self.snap, SUNDAY, 4)
self.engine.run_fixes("midi", self.snap, None, t)
time.sleep(0.4)
self.assertEqual(sorted(p for _, p in calls), ["Lighting PC", "SuperRack-LiveBox"])
self.assertEqual(len({tid for tid, _ in calls}), 1) # one worker, not two at once
def test_restart_key_waits_while_auto_fix_starts_that_app(self):
ran, comp = self.auto_setup()
self.snap.remote["tracks\0pgrep -xq Live"] = False
run_steps(self.engine, self.snap, SUNDAY, 25)
self.engine.auto_fix(self.snap, comp, SUNDAY + 25) # auto-fix starts Ableton
self.assertIsNone(self.engine.run_force("rs_ableton", self.snap, None, SUNDAY + 30))
self.assertEqual(self.engine.notes["rs_ableton"]["text"], "STARTING\nWAIT 1 MIN")
time.sleep(0.2)
self.assertEqual(ran, ["ableton"]) # not killed while starting
def test_a_restarted_dead_connection_is_not_a_network_drop(self):
t = SUNDAY
run_steps(self.engine, self.snap, t, 2)
for label in ("x32", "VX1000", "UHDJr", "RESI_Decoder", "tascam"): # dead for a long time
self.snap.conns[label]["category"] = "error"
run_steps(self.engine, self.snap, t + 100, 2)
self.engine.burst, self.engine.conn_drops = None, sp.deque()
for cat in (None, "error"): # auto restarts: starting, error
for label in ("x32", "VX1000", "UHDJr", "RESI_Decoder", "tascam"):
self.snap.conns[label]["category"] = cat
tiles, t2 = run_steps(self.engine, self.snap, t + 200, 2)
self.assertEqual(tiles["net"]["x"], "NET\nOK")
def test_auto_fix_waits_out_the_network_and_respects_alarms_off(self):
ran, comp = self.auto_setup()
self.snap.remote["tracks\0pgrep -xq Live"] = False
t = SUNDAY
run_steps(self.engine, self.snap, t, 25)
self.engine.burst = {"start": t, "conns": set(), "cleared": None} # a network blip going on
self.assertEqual(self.engine.auto_fix(self.snap, comp, t + 30), [])
self.engine.burst = None
self.engine.service.set(False, t + 31) # ALARMS pressed: maintenance
self.assertEqual(self.engine.auto_fix(self.snap, comp, t + 40), [])
self.engine.service.set(True, t + 41)
weekday = SUNDAY - 3 * 86400 # Thursday: holds only
run_steps(self.engine, self.snap, weekday, 25)
self.assertEqual(self.engine.auto_fix(self.snap, comp, weekday + 30), [])
self.assertEqual(ran, [])
def test_restart_keys_flash_when_their_thing_is_down(self): def test_restart_keys_flash_when_their_thing_is_down(self):
devices = json.loads(self.snap.vars[("audinate-dantecontroller", "devices")]) devices = json.loads(self.snap.vars[("audinate-dantecontroller", "devices")])
devices.remove("NA-Tracks") # DVS on the Tracks Mac gone devices.remove("NA-Tracks") # DVS on the Tracks Mac gone
@@ -1371,7 +1508,7 @@ class TestPage(unittest.TestCase):
self.assertEqual(len(holds), len(CONFIG["restart_page"]["keys"])) self.assertEqual(len(holds), len(CONFIG["restart_page"]["keys"]))
self.assertIn("$(custom:sp_cmd);force rs_tascam $(internal:time_unix)", holds) self.assertIn("$(custom:sp_cmd);force rs_tascam $(internal:time_unix)", holds)
self.assertTrue(all(up == [] for up in holds.values())) # a tap does nothing self.assertTrue(all(up == [] for up in holds.values())) # a tap does nothing
back = c["3"]["0"]["steps"]["0"]["action_sets"]["up"][0] back = c["2"]["6"]["steps"]["0"]["action_sets"]["up"][0] # where the user put it
self.assertEqual((back["definitionId"], back["options"]["page"]["value"]), ("set_page", 19)) self.assertEqual((back["definitionId"], back["options"]["page"]["value"]), ("set_page", 19))
def test_push_page_looks_at_what_is_on_the_page(self): def test_push_page_looks_at_what_is_on_the_page(self):