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:
2026-10-13 16:00:41 -04:00
co-authored by Claude Fable 5.1
parent a3934b7c62
commit 5d66805687
13 changed files with 1822 additions and 307 deletions
+148 -90
View File
@@ -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"]: