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:
+148
-90
@@ -130,7 +130,8 @@ class TestChecks(unittest.TestCase):
|
||||
s.spotify_recent.append("spotify:track:q")
|
||||
self.assertEqual(msg().msg, "WRONG PLAYLIST") # full list: one unknown is enough
|
||||
fix = CONFIG["fixes"][chk["fix"]]
|
||||
self.assertIn(chk["playlist"], fix["run"])
|
||||
self.assertEqual(fix["kind"], "spotify")
|
||||
self.assertEqual(sp.spotify_playlist(CONFIG, chk), CONFIG["spotify"]["playlist"])
|
||||
self.assertIn(chk["fix"], CONFIG["auto_fix"]["fixes"])
|
||||
|
||||
def test_connection_states(self):
|
||||
@@ -1352,93 +1353,145 @@ class TestFixes(unittest.TestCase):
|
||||
self.assertEqual(sp.parse_remote("SP0=0\nSP1=1\n", 2), [True, False])
|
||||
self.assertEqual(sp.parse_remote("", 2), [None, None])
|
||||
|
||||
def test_windows_remote_checks_use_conditional_echoes(self):
|
||||
line = sp.remote_script(["tasklist | find /I \"Vista.exe\"", "sc query rtpMIDIService | find \"RUNNING\""], "cmd")
|
||||
self.assertNotIn("%ERRORLEVEL%", line)
|
||||
self.assertIn("&& echo SP0=0 || echo SP0=1", line)
|
||||
self.assertIn("&& echo SP1=0 || echo SP1=1", line)
|
||||
self.assertEqual(sp.parse_remote("SP0=0\r\nSP1=1\r\n", 2), [True, False])
|
||||
self.assertEqual(sp.host_shell(CONFIG, "lighting"), "cmd")
|
||||
self.assertEqual(sp.host_shell(CONFIG, "tracks"), "sh")
|
||||
|
||||
def test_overnight_windows(self):
|
||||
sat_2350 = datetime(2026, 10, 10, 23, 50).timestamp()
|
||||
sun_0020 = datetime(2026, 10, 11, 0, 20).timestamp()
|
||||
sun_0100 = datetime(2026, 10, 11, 1, 0).timestamp()
|
||||
w = [{"days": ["sat"], "start": "18:00", "end": "00:30"}]
|
||||
self.assertTrue(sp.in_windows(w, sat_2350))
|
||||
self.assertTrue(sp.in_windows(w, sun_0020)) # past midnight, still Saturday's service
|
||||
self.assertFalse(sp.in_windows(w, sun_0100))
|
||||
self.assertFalse(sp.in_windows([{"days": ["sun"], "start": "18:00", "end": "00:30"}], sat_2350))
|
||||
|
||||
def test_check_identity_is_about_the_thing_not_the_position(self):
|
||||
midi = [c for c in CONFIG["tiles"][9]["checks"] if c["type"] == "midi_peer"]
|
||||
self.assertEqual(len({sp.check_identity(c) for c in midi}), len(midi)) # six peers, six identities
|
||||
self.assertEqual(sp.check_identity({"type": "midi_peer", "host": "tracks", "session": "NA Tracks", "peer": "Lighting PC"}),
|
||||
"tracks/NA Tracks/Lighting PC")
|
||||
self.assertEqual(sp.check_identity({"type": "ping", "conn": "Axient_1", "host": "10.1.1.101"}), "Axient_1")
|
||||
self.assertEqual(sp.check_identity({"type": "conn", "conn": "ATEM"}), "ATEM")
|
||||
|
||||
def test_acknowledgements_survive_a_restart(self):
|
||||
state = os.path.join(tempfile.mkdtemp(prefix="sp-state-"), "state.json")
|
||||
engine = sp.Engine(CONFIG, state)
|
||||
engine.executor = engine.poe_executor = lambda *a: True
|
||||
snap = healthy_snapshot(SUNDAY)
|
||||
snap.conns["ATEM"]["category"] = "error"
|
||||
run_steps(engine, snap, SUNDAY, 5)
|
||||
engine.ack("atem")
|
||||
self.assertEqual(level(run_steps(engine, snap, SUNDAY + 6, 1)[0]["atem"]), "ack")
|
||||
saved = json.load(open(state))
|
||||
self.assertIn("atem|ATEM|conn", saved["acks"])
|
||||
again = sp.Engine(CONFIG, state) # the watchdog restarts
|
||||
again.executor = again.poe_executor = lambda *a: True
|
||||
tiles, _ = run_steps(again, snap, SUNDAY + 10, 5)
|
||||
self.assertEqual(level(tiles["atem"]), "ack") # still acknowledged, no new flashing
|
||||
snap.conns["ATEM"]["category"] = "good"
|
||||
run_steps(again, snap, SUNDAY + 20, 2)
|
||||
self.assertEqual(json.load(open(state))["acks"], []) # cleared: forgotten
|
||||
|
||||
def test_run_this_fix_now_goes_through_the_hold_path(self):
|
||||
ran = []
|
||||
self.engine.executor = lambda cfg, fix_id, chk: ran.append((fix_id, chk.get("conn"), chk.get("ip"), chk.get("peer"))) or True
|
||||
snap = self.snap
|
||||
run_steps(self.engine, snap, SUNDAY, 2)
|
||||
comp = mock_comp()
|
||||
now = SUNDAY + 3
|
||||
self.engine.run_test("hd2", "hyperdeck", "0", snap, comp, now) # the check's number...
|
||||
self.engine.run_test("midi", "midi_reconnect", "tracks/NA Tracks/Studio B Mac Studio", snap, comp, now) # ...or its identity
|
||||
self.engine.run_test("midi", "midi_reconnect", "9", snap, comp, now) # no such check
|
||||
self.engine.run_test("atem", "atem_audio", "1", snap, comp, now) # never from the page
|
||||
self.engine.run_test("hd2", "propresenter", "0", snap, comp, now) # not attached to that key
|
||||
for t in self.engine.fix_threads:
|
||||
t.join(2)
|
||||
self.assertEqual(ran, [("hyperdeck", "HyperDeck_2", "10.1.10.12", None),
|
||||
("midi_reconnect", None, None, "Studio B Mac Studio")])
|
||||
self.assertEqual(self.engine.notes["hd2"]["text"], "DONE")
|
||||
|
||||
def test_a_restart_lets_the_running_fix_finish_and_starts_nothing_new(self):
|
||||
gate = threading.Event()
|
||||
self.engine.executor = lambda cfg, fix_id, chk: gate.wait(3) or True
|
||||
run_steps(self.engine, self.snap, SUNDAY, 2)
|
||||
self.engine.run_force("rs_bridge", self.snap, mock_comp(), SUNDAY + 3)
|
||||
self.assertEqual(len(self.engine.running), 1) # published for the settings page
|
||||
self.engine.stopping = True # SIGTERM arrived
|
||||
self.engine.run_force("rs_captions", self.snap, mock_comp(), SUNDAY + 4)
|
||||
self.assertEqual(self.engine.notes["rs_captions"]["text"], "RESTARTING\nSOON")
|
||||
self.assertEqual(self.engine.run_fixes("ingest", self.snap, mock_comp(), SUNDAY + 4), [])
|
||||
gate.set()
|
||||
for t in self.engine.fix_threads:
|
||||
t.join(2)
|
||||
self.assertEqual(self.engine.running, {})
|
||||
self.assertEqual(sp.exit_timeout(CONFIG), 90) # the longest fix + its verify + 10
|
||||
|
||||
def test_a_hyperdeck_switched_off_is_always_switched_back_on(self):
|
||||
calls = []
|
||||
|
||||
class Comp:
|
||||
def connections(self):
|
||||
return [{"label": "HyperDeck_2", "id": "x", "enabled": True, "status": {"category": "good"}}]
|
||||
|
||||
def disable_connection(self, cid):
|
||||
calls.append("off")
|
||||
return True
|
||||
|
||||
def enable_connection(self, cid):
|
||||
calls.append("on")
|
||||
return True
|
||||
import unittest.mock as mock
|
||||
|
||||
def boom(seconds):
|
||||
raise RuntimeError("killed mid-way")
|
||||
with mock.patch.object(sp, "Companion", lambda *a, **k: Comp()):
|
||||
with self.assertRaises(RuntimeError):
|
||||
sp.reconnect_and_verify(CONFIG, CONFIG["fixes"]["hyperdeck"], "x", sleep=boom, conn="HyperDeck_2", toggle=True)
|
||||
self.assertEqual(calls, ["off", "on"])
|
||||
|
||||
def test_spotify_fix_opens_and_plays(self):
|
||||
calls = []
|
||||
|
||||
class R:
|
||||
def __init__(self, code, out=""):
|
||||
self.returncode, self.stdout, self.stderr = code, out, ""
|
||||
|
||||
def run(argv, **kw):
|
||||
calls.append(argv)
|
||||
if argv[1] == "-xq":
|
||||
return R(1) # Spotify is closed
|
||||
if "player state" in argv[-1]:
|
||||
return R(0, "playing\n")
|
||||
return R(0)
|
||||
import unittest.mock as mock
|
||||
with mock.patch.object(sp.time, "sleep", lambda s: None):
|
||||
ok = sp.play_spotify(CONFIG, CONFIG["fixes"]["spotify_play"], {}, "spotify", run=run)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(calls[1][:3], ["/usr/bin/open", "-g", "-a"])
|
||||
self.assertIn(CONFIG["spotify"]["playlist"], calls[2][-1])
|
||||
|
||||
def test_never_auto_fixes_stay_a_hold(self):
|
||||
cfg = json.loads(json.dumps(CONFIG))
|
||||
cfg["fixes"]["ableton"]["never_auto"] = True
|
||||
cfg["auto_fix"]["fixes"].remove("ableton")
|
||||
engine = sp.Engine(cfg, None)
|
||||
self.assertNotIn("ableton", {f for f in engine.auto.get("fixes", []) if not engine.cfg["fixes"][f].get("never_auto")})
|
||||
cfg["auto_fix"]["fixes"].append("ableton")
|
||||
path = os.path.join(tempfile.mkdtemp(prefix="sp-cfg-"), "config.json")
|
||||
json.dump(cfg, open(path, "w"))
|
||||
with self.assertRaises(ValueError):
|
||||
sp.load_config(path)
|
||||
|
||||
def test_every_fix_is_valid(self):
|
||||
for f in CONFIG["fixes"].values():
|
||||
self.assertIn(f.get("kind", "ssh"), ("ssh", "local", "hyperdeck", "midi_connect", "reconnect", "atem"))
|
||||
|
||||
|
||||
class FakeDeck:
|
||||
"""A pretend HyperDeck on a local port: answers like a real one, stays silent, or greets only."""
|
||||
|
||||
def __init__(self, mode):
|
||||
import socket as _s
|
||||
self.mode, self.received = mode, b""
|
||||
self.srv = _s.socket()
|
||||
self.srv.bind(("127.0.0.1", 0))
|
||||
self.srv.listen(5)
|
||||
self.port = self.srv.getsockname()[1]
|
||||
threading.Thread(target=self.serve, daemon=True).start()
|
||||
|
||||
def serve(self):
|
||||
while True:
|
||||
try:
|
||||
conn, _ = self.srv.accept()
|
||||
except OSError:
|
||||
return
|
||||
with conn:
|
||||
conn.settimeout(4)
|
||||
if self.mode != "silent":
|
||||
conn.sendall(b"500 connection info:\r\nprotocol version: 1.11\r\nmodel: HyperDeck Studio 4K Pro\r\n\r\n")
|
||||
try:
|
||||
data = conn.recv(1024)
|
||||
self.received += data
|
||||
if self.mode in ("record", "preview") and b"transport info" in data:
|
||||
conn.sendall(b"208 transport info:\r\nstatus: %s\r\nspeed: 100\r\n\r\n" % self.mode.encode())
|
||||
more = conn.recv(1024)
|
||||
self.received += more
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
self.srv.close()
|
||||
|
||||
|
||||
class TestHyperDeckProbe(unittest.TestCase):
|
||||
def test_probe_answers(self):
|
||||
for mode, expected in (("record", "record"), ("preview", "preview"), ("greet", "answered"), ("silent", "hung")):
|
||||
deck = FakeDeck(mode)
|
||||
try:
|
||||
self.assertEqual(sp.hyperdeck_probe("127.0.0.1", timeout=1, port=deck.port), expected, mode)
|
||||
finally:
|
||||
deck.close()
|
||||
deck = FakeDeck("preview")
|
||||
port = deck.port
|
||||
deck.close()
|
||||
self.assertEqual(sp.hyperdeck_probe("127.0.0.1", timeout=1, port=port), "unreachable")
|
||||
|
||||
def test_the_hold_switches_the_connection_off_and_on_and_never_reboots(self):
|
||||
from unittest import mock
|
||||
deck = FakeDeck("silent") # even a deck that would once have been rebooted
|
||||
try:
|
||||
comp = mock.Mock()
|
||||
comp.connections.side_effect = [
|
||||
[{"label": "HyperDeck_1", "id": "h1", "enabled": True, "status": {"category": "error"}}],
|
||||
[{"label": "HyperDeck_1", "id": "h1", "enabled": True, "status": {"category": "good"}}]]
|
||||
comp.disable_connection.return_value = comp.enable_connection.return_value = True
|
||||
chk = {"conn": "HyperDeck_1", "ip": "127.0.0.1", "port": deck.port}
|
||||
with mock.patch.object(sp, "Companion", return_value=comp), mock.patch.object(sp.time, "sleep"):
|
||||
self.assertTrue(sp.execute_fix(CONFIG, "hyperdeck", chk))
|
||||
self.assertEqual([c[0] for c in comp.method_calls if c[0].endswith("_connection")],
|
||||
["disable_connection", "enable_connection"])
|
||||
comp.restart_connection.assert_not_called()
|
||||
time.sleep(0.3)
|
||||
self.assertNotIn(b"reboot", deck.received)
|
||||
finally:
|
||||
deck.close()
|
||||
|
||||
def test_a_deck_that_stays_offline_says_so(self):
|
||||
from unittest import mock
|
||||
fix = CONFIG["fixes"]["hyperdeck"]
|
||||
for pings, expect in ((True, "STILL OFFLINE\nHOLD AGAIN"), (False, "NO NETWORK\nCHECK POWER")):
|
||||
comp = mock.Mock()
|
||||
comp.connections.side_effect = [
|
||||
[{"label": "HyperDeck_2", "id": "h2", "enabled": True, "status": {"category": "error"}}],
|
||||
[{"label": "HyperDeck_2", "id": "h2", "enabled": True, "status": {"category": "error"}}]]
|
||||
with mock.patch.object(sp, "Companion", return_value=comp), \
|
||||
mock.patch.object(sp, "ping_once", return_value=pings) as ping:
|
||||
self.assertEqual(sp.reconnect_and_verify(CONFIG, fix, "x", sleep=lambda s: None,
|
||||
conn="HyperDeck_2", host="10.1.10.12", toggle=True), expect)
|
||||
ping.assert_called_once_with("10.1.10.12") # the deck's own address, from Companion
|
||||
self.assertIn(f.get("kind", "ssh"), sp.FIX_KINDS)
|
||||
|
||||
|
||||
class TestNetgearParsing(unittest.TestCase):
|
||||
@@ -1679,10 +1732,15 @@ class TestPage(unittest.TestCase):
|
||||
nav = {"0": {"0": {"type": "pageup"}}, "1": {"0": {"type": "button-layered",
|
||||
"options": {"notes": "STATUS nav key (pagenum): x"}}}}
|
||||
self.assertTrue(push_page.safe_target({"page": {"name": "PAGE", "controls": nav}}, "23\\nRestart")[0])
|
||||
busy = {"page": {"name": "PAGE", "controls": dict(nav, **{"2": {"3": {"type": "button"}}})}}
|
||||
self.assertFalse(push_page.safe_target(busy, "23\\nRestart")[0]) # pages 22 and 97
|
||||
busy["page"]["name"] = "23\\nRestart"
|
||||
self.assertTrue(push_page.safe_target(busy, "23\\nRestart")[0]) # our own page again
|
||||
ours = {"page": {"name": "whatever", "controls": dict(nav, **{"3": {"7": {"type": "button-layered",
|
||||
"options": {"notes": "STATUS tile 'led' - drawn by status_panel.py on NA Ingest."}},
|
||||
"6": {"type": "button-layered", "options": {"notes": "STATUS jump key - Tap: open the Restart page."}}}})}}
|
||||
self.assertTrue(push_page.safe_target(ours, "23\\nRestart")[0]) # our keys, whatever the page is called
|
||||
busy = {"page": {"name": "23\\nRestart", "controls": dict(nav, **{"2": {"3": {"type": "button"}}})}}
|
||||
ok, why = push_page.safe_target(busy, "23\\nRestart")
|
||||
self.assertFalse(ok) # a key someone made by hand: refused
|
||||
self.assertIn("row 2 col 3", why) # ...and named by its place
|
||||
self.assertIn("move or delete them first", why)
|
||||
|
||||
def test_each_button_reads_its_own_tile(self):
|
||||
for t in CONFIG["tiles"]:
|
||||
|
||||
Reference in New Issue
Block a user