Settings page: ON/OFF switches everywhere, the Auto switch screen, a Companion button picker

- ON/OFF on every key, check, fix, Restart and READY key, computer, MIDI link, HyperDeck,
  Dante device, watched Companion connection, switcher and Spotify check, outlet fix, and one
  main switch each for auto-fix, the auto switch and the READY page. Switched off = kept in
  the settings, grey OFF on the deck, never checked or run (effective_config in the watchdog).
- Settings › Auto switch: a plain summary, what counts as down (each trigger editable in place:
  address, program and computer, Dante device, the button it presses, timings), the hours
  folded away, the buttons; + Button, + What counts as down, Remove.
- Pick a Companion button by clicking it on its page, drawn like Companion (read-only API:
  /api/companion/pages and /api/companion/page).
- STREAM STB / STREAM FOH Restart keys removed (the Studio B page has its own); the alert names
  the switch-back button instead.
- Dante screen: shows the DANTE key's device list again (it picked STB AUDIO's one device).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 14:30:41 -04:00
co-authored by Claude Opus 5.5
parent 14fce2cf9a
commit 5adab6c518
11 changed files with 663 additions and 82 deletions
+107 -11
View File
@@ -1536,6 +1536,108 @@ def run_failover(engine, snap, start, seconds, step=0.5):
STB_PING, STB_PT = "10.1.13.175", "studiob\0pgrep -xq 'Pro Tools'"
class TestSwitchedOff(unittest.TestCase):
"""Anything can be switched off in the settings and stay there, like a connection in Companion:
a key goes grey OFF and checks nothing, a check is skipped, a fix never runs."""
def cfg(self, **changes):
cfg = json.loads(json.dumps(CONFIG))
for path, value in changes.items():
node = cfg
*parts, last = path.split("/")
for p in parts:
node = node[int(p)] if isinstance(node, list) else node[p]
node[last] = value
return cfg
def test_a_key_switched_off_is_grey_and_checks_nothing(self):
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "obs")
engine = sp.Engine(self.cfg(**{"tiles/%d/disabled" % i: True}))
snap = healthy_snapshot(SUNDAY)
snap.conns.pop("OBS-Youtube", None) # OBS gone: would be red
tiles, _ = run_steps(engine, snap, SUNDAY, 5)
self.assertEqual(tiles["obs"]["x"], "OBS\nOFF")
self.assertEqual(level(tiles["obs"]), "off")
self.assertEqual(tiles["all"]["x"], "ALL OK") # not part of ALL any more
self.assertEqual(tiles["stream"]["x"], "STREAM\nOK") # nor of its group
def test_a_check_switched_off_is_skipped(self):
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "stb_audio")
engine = sp.Engine(self.cfg(**{"tiles/%d/checks/0/disabled" % i: True}))
snap = healthy_snapshot(SUNDAY)
snap.ping[STB_PING] = 5 # the ping check would fail
tiles, _ = run_steps(engine, snap, SUNDAY, 5)
self.assertEqual(tiles["stb_audio"]["x"], "STB AUDIO\nOK")
self.assertEqual(len(engine.tiles["stb_audio"]["checks"]), 2)
self.assertEqual(len(CONFIG["tiles"][i]["checks"]), 3) # the settings keep all three
def test_a_fix_switched_off_never_runs(self):
cfg = self.cfg(**{"fixes/obs/disabled": True, "fixes/stream_foh_backup/disabled": True})
self.assertEqual(sp.execute_fix(cfg, "obs", {}), "TURNED\nOFF")
engine = sp.Engine(cfg)
engine.executor = lambda c, fix_id, chk: self.fail("ran " + fix_id)
engine.notify = lambda url, text: None
snap = healthy_snapshot(SUNDAY)
snap.ping[STB_PING] = 5 # Studio B dies on a Sunday
snap.conns.pop("OBS-Youtube", None)
run_failover(engine, snap, SUNDAY, 40)
self.assertEqual(engine.failed, {}) # no auto switch
self.assertEqual(engine.auto_fix(snap, mock_comp(), SUNDAY + 41), []) # no auto-fix
def test_a_restart_key_switched_off(self):
rp = self.cfg()["restart_page"]
j = next(n for n, k in enumerate(rp["keys"]) if k["id"] == "rs_tascam")
engine = sp.Engine(self.cfg(**{"restart_page/keys/%d/disabled" % j: True}))
tiles = engine.step(healthy_snapshot(SUNDAY), SUNDAY)
self.assertEqual(tiles["rs_tascam"]["x"], "TASCAM\nOFF")
self.assertIsNone(engine.run_force("rs_tascam", healthy_snapshot(SUNDAY), None, SUNDAY))
self.assertEqual(engine.notes["rs_tascam"]["text"], "TURNED\nOFF")
def test_a_computer_switched_off(self):
cfg = self.cfg(**{"hosts/studiob/disabled": True})
engine = sp.Engine(cfg)
stb = engine.tiles["stb_audio"]["checks"]
self.assertEqual([c["type"] for c in stb], ["dante"]) # its ping and Pro Tools checks are gone
self.assertTrue(engine.cfg["fixes"]["solo_afl"]["disabled"]) # and a fix that logs in to it
self.assertFalse(any(c.get("host") == "studiob" for t in sp.all_tiles(engine.cfg) for c in t.get("checks", [])))
self.assertNotIn("studiob", sp.remote_midi_hosts(engine.cfg))
engine = sp.Engine(self.cfg(**{"hosts/bibletv/disabled": True})) # its web page is not fetched either
self.assertNotIn("http://10.6.7.13:8080/", [u for u, _ in sp.http_checks(engine.cfg)])
self.assertTrue(engine.cfg["fixes"]["bibleapp"]["disabled"])
def test_a_dante_device_switched_off(self):
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "dante")
engine = sp.Engine(self.cfg(**{"tiles/%d/checks/0/devices_off" % i: ["STUDIO-B-Tascam"]}))
snap = healthy_snapshot(SUNDAY)
devices = json.loads(snap.vars[("audinate-dantecontroller", "devices")])
snap.vars[("audinate-dantecontroller", "devices")] = json.dumps([d for d in devices if d != "STUDIO-B-Tascam"])
tiles, _ = run_steps(engine, snap, SUNDAY, 5)
self.assertNotIn("GONE", tiles["dante"]["x"]) # gone, but switched off: no alarm
self.assertIn("STUDIO-B-Tascam", CONFIG["tiles"][i]["checks"][0]["devices"]) # still in the list
def test_whole_features_switched_off(self):
cfg = self.cfg(**{"auto_fix/disabled": True, "failover/disabled": True, "ready/disabled": True})
engine = sp.Engine(cfg)
self.assertEqual(engine.cfg["auto_fix"]["windows"], [])
self.assertEqual(engine.failover_windows, [])
self.assertTrue(all(k.get("disabled") for k in sp.ready_keys(engine.cfg)))
snap = healthy_snapshot(SUNDAY)
snap.ping[STB_PING] = 5
engine.executor = lambda c, fix_id, chk: self.fail("ran " + fix_id)
engine.notify = lambda url, text: None
tiles, _ = run_failover(engine, snap, SUNDAY, 20)
self.assertEqual(engine.failed, {})
self.assertEqual(tiles["rd_tracks"]["x"], "TRACKS\nOFF")
def test_the_rules_accept_switched_off_things(self):
import schema
i = next(n for n, t in enumerate(CONFIG["tiles"]) if t["id"] == "obs")
cfg = self.cfg(**{"tiles/%d/disabled" % i: True, "tiles/%d/checks/0/disabled" % i: True, "fixes/obs/disabled": True})
errors, warnings = schema.validate(cfg)
self.assertEqual(errors, [])
self.assertFalse(any("disabled" in w for w in warnings), warnings)
class TestFailover(unittest.TestCase):
"""Studio B dies on a Sunday: the FOH Backup button is pressed by itself, once, and the keys say so.
Nothing here presses a real button, posts a real alert or writes a real log."""
@@ -1567,8 +1669,6 @@ class TestFailover(unittest.TestCase):
self.assertEqual(fix["undo"], "stream_studio_b")
self.assertFalse(CONFIG["fixes"]["stream_studio_b"].get("failover"))
self.assertNotIn("stream_foh_backup", CONFIG["auto_fix"]["fixes"])
keys = {k["id"]: k["fix"] for k in CONFIG["restart_page"]["keys"]}
self.assertEqual((keys["rs_stream_stb"], keys["rs_stream_foh"]), ("stream_studio_b", "stream_foh_backup"))
self.assertEqual(CONFIG["failover"]["after_seconds"], 2)
self.assertEqual(self.engine.failover_windows, [{"days": ["sun"], "start": "06:30", "end": "13:30"}])
@@ -1600,11 +1700,9 @@ class TestFailover(unittest.TestCase):
self.assertTrue(any("-> done, checking it comes back" in l for l in body), body)
self.assertEqual(len(self.sent), 1)
self.assertTrue(self.sent[0].startswith("NA Ingest: STB AUDIO STUDIO B NO PING - switched over by itself: press the FOH Backup"))
self.assertIn("hold STREAM STB on the Restart page", self.sent[0])
self.assertIn("Switch back by hand (press the Studio B button in Companion", self.sent[0])
self.engine.run_fixes("audio", self.snap, None, at + 10)
self.assertEqual(self.engine.notes["audio"]["text"], "JUST TRIED\nWAIT 1 MIN")
self.assertIsNone(self.engine.run_force("rs_stream_foh", self.snap, None, at + 11))
self.assertEqual(self.engine.notes["rs_stream_foh"]["text"], "JUST DONE\nWAIT 1 MIN")
out = sp.snapshot_summary(self.snap, self.engine, t)
json.dumps(out)
self.assertEqual(out["failover"]["stream_foh_backup"]["note"], "ON FOH BACKUP")
@@ -1664,9 +1762,6 @@ class TestFailover(unittest.TestCase):
self.snap.remote[STB_PT] = False # the answer is back: still gone
tiles, t = run_failover(self.engine, self.snap, t, 10)
self.assertEqual(self.ran, ["stream_foh_backup"]) # no second press
self.assertEqual(tiles["rs_stream_foh"]["x"], "STREAM FOH\nON FOH BACKUP") # pressed already: no HOLD TO FIX
self.assertEqual(level(tiles["rs_stream_foh"]), "ok")
self.assertEqual(tiles["rs_stream_stb"]["x"], "STREAM STB\nHOLD 2s")
def test_studio_b_rednet_is_never_power_cycled_by_itself(self):
"""Only STUDIO-B-RedNet-TNX vanishes: the failover presses FOH Backup; Sunday auto-fix on the DANTE
@@ -1697,11 +1792,12 @@ class TestFailover(unittest.TestCase):
self.snap.ping[STB_PING] = 2
tiles, t = run_failover(self.engine, self.snap, t0, 6)
self.assertIn("ON FOH BACKUP", tiles["audio"]["x"])
self.assertEqual(self.engine.run_force("rs_stream_stb", self.snap, None, t), "stream_studio_b")
self.assertEqual(self.engine.notes["rs_stream_stb"]["text"], "PRESSING")
# the undo, run through the watchdog (a Restart key or the settings page's Run the fix)
self.assertEqual(self.engine.run_force("stb_audio", self.snap, None, t, fix_id="stream_studio_b", chk={}), "stream_studio_b")
self.assertEqual(self.engine.notes["stb_audio"]["text"], "PRESSING")
gate.set()
time.sleep(0.2)
self.assertEqual(self.engine.notes["rs_stream_stb"]["text"], "DONE")
self.assertEqual(self.engine.notes["stb_audio"]["text"], "DONE")
self.assertEqual(self.engine.failed, {})
tiles = self.engine.step(self.snap, t + 0.5)
self.assertEqual(tiles["audio"]["x"], "AUDIO\nSTUDIO B NO PING")