diff --git a/README.md b/README.md index 5e7aef0..c3b4732 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ 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`. - **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. -## Restart page (page 23) +## 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. 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. @@ -106,7 +106,7 @@ The **page number key** on every page follows the ALL button. It's green when ev | APPS | ProPresenter API (offline or error only: its harmless yellow warning, every 15–20 min, is ignored with `"ignore_warning": true`); Ableton OSC; tracks Mac pings; Bible TV app answering on :8080 (every 5 s); Bible TV OSC. Messages name the app ("PROPRES OFFLINE", "BIBLE APP DOWN"). Hold 2 s restarts the ProPresenter, Ableton and Bible TV Companion connections. | | VISTA | Vista on the Lighting PC: its Art-Net poll (sent every 0.5 s) must be heard within 5 s, otherwise **SILENT** (Vista closed or crashed, or the Lighting PC is off the network). Hold 2 s starts Vista if it is not running; Vista then opens **Prop Takeover** from its show folder (`E:\media\Documents\Vista 3\Shows`) by itself, through its own autoload preference (`` in `E:\media\Documents\Vista 3\.preferences`, set 2026-10-08). Resolume is the ARENA button. | | RESI | decoder (RossTalk connection and ping, 10.1.10.203); **encoder** (ping and its web page, 10.1.10.202, LAOEncoder); resi-studio-cues; Resi cloud reachable. Only shows they are on the network, not that the encoder is streaming. | -| NET | NA Ingest's link; gateway 10.1.0.1 pings; **4+ connections from 3+ groups dropping within 10 s** (what happened Sunday 10/5 at 10:21) | +| NET | NA Ingest's link; gateway 10.1.0.1 pings; **4+ connections from 3+ groups dropping within 10 s** (what happened Sunday 10/5 at 10:21). A blip **flashes amber** for 5 minutes, then goes by itself; press NET to stop the flashing, hold NET 2 s to clear it now. | | INGEST | the bridge (makes ProPresenter's stage screen count down to the next Resi cue), lobby captions server and caption screen, **ProdCom** (transcription, answering on :24480), ProdDash (running and its page on :24500); free disk | | RF (group) | Shure connections; battery ≤1 bar red, 2 bars amber; RF interference (only while that transmitter is on); PSM RF muted; each receiver pinged every 3 s ("AXIENT 1 NO PING" in about 6–9 s; Companion's Shure modules take 18–27 s to notice). The address is the one set on its Companion connection, read from Companion's settings every minute, so changing it there is enough; `"host"` in `config.json` is only the fallback. | diff --git a/config.json b/config.json index 51fec2e..5387384 100644 --- a/config.json +++ b/config.json @@ -20,7 +20,7 @@ "burst_connections": 4, "burst_groups": 3, "burst_window_seconds": 10, - "remember_seconds": 180 + "remember_seconds": 300 }, "alert_webhook": "", @@ -78,7 +78,7 @@ }, "restart_page": { - "page": 23, "page_name": "23\\nRestart", "jump_pos": [2, 6], "back_pos": [3, 0], + "page": 20, "page_name": "20\\nRestart", "jump_pos": [2, 6], "back_pos": [3, 0], "keys": [ { "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" }, diff --git a/make_page.py b/make_page.py index 02e0772..4f3691f 100644 --- a/make_page.py +++ b/make_page.py @@ -166,8 +166,10 @@ def button(tid, tile, cfg): press = "ack " + tid fixable = "members" in tile or any((c["type"] in RESTARTABLE and c.get("conn")) or c.get("fix") for c in tile.get("checks", [])) - hold = ("restart " + tid) if fixable or tile.get("hold_always") else None - if tile.get("hold_always"): + hold = ("restart " + tid) if fixable or tile.get("hold_always") or kind == "network" else None + if kind == "network": + note = "Press: stop the flashing. Hold 2 s: clear a network blip (it also clears itself after 5 minutes)." + elif tile.get("hold_always"): note = "Press: acknowledge. Hold 2 s: %s." % cfg["fixes"][tile["hold_always"]]["label"] else: note = "Press: acknowledge." + (" Hold 2 s: fix what this button is failing on (nothing if it is green)." diff --git a/push_page.py b/push_page.py index 6df100a..e8e256b 100644 --- a/push_page.py +++ b/push_page.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Import the status page (19) or the Restart page (23) into Companion without clicking through the UI. +"""Import the status page (19) or the Restart page (20; both numbers come from config.json) into Companion without clicking through the UI. Does exactly what Import/Export -> Import -> "import into page N" does, over the same internal API the Companion web UI uses (tRPC over a WebSocket at /trpc): diff --git a/restart-page.companionconfig b/restart-page.companionconfig index 6cb2fed..747487b 100644 Binary files a/restart-page.companionconfig and b/restart-page.companionconfig differ diff --git a/status-page.companionconfig b/status-page.companionconfig index 0829ad1..d91ab47 100644 Binary files a/status-page.companionconfig and b/status-page.companionconfig differ diff --git a/status_panel.py b/status_panel.py index adf95a8..856265c 100644 --- a/status_panel.py +++ b/status_panel.py @@ -40,13 +40,14 @@ OK, WARN, ERR, OFF = "ok", "warn", "err", "off" # Display levels, least to most urgent. "idle" is a service-only fault while # service mode is off (OBS PC off midweek); "quiet" is any other fault then. -ORDER = ["off", "ok", "idle", "warn", "quiet", "ack", "err"] +ORDER = ["off", "ok", "idle", "warn", "notice", "quiet", "ack", "err"] RANK = {name: i for i, name in enumerate(ORDER)} # (background, text) for the flash-on phase and the flash-off phase. COLORS = { "ok": ((0x007A2E, 0xFFFFFF), (0x007A2E, 0xFFFFFF)), "warn": ((0xFFA000, 0x000000), (0xFFA000, 0x000000)), + "notice": ((0xFFA000, 0x000000), (0x000000, 0xFFA000)), # flashing amber: something happened (NET BLIP) "quiet": ((0x3A0000, 0xFF8080), (0x3A0000, 0xFF8080)), "ack": ((0x8B0000, 0xFFFFFF), (0x8B0000, 0xFFFFFF)), "err": ((0xFF0000, 0xFFFFFF), (0x000000, 0xFF3030)), @@ -60,14 +61,15 @@ COLORS = { class Result: """One check's verdict. `named` means msg already says which device it is.""" - __slots__ = ("level", "msg", "info", "service_only", "named") + __slots__ = ("level", "msg", "info", "service_only", "named", "flash") - def __init__(self, level, msg="", info="", service_only=False, named=False): + def __init__(self, level, msg="", info="", service_only=False, named=False, flash=False): self.level = level self.msg = msg self.info = info self.service_only = service_only self.named = named + self.flash = flash # a warning that flashes until acknowledged (NET BLIP) def __repr__(self): return "Result(%s, %r, info=%r)" % (self.level, self.msg, self.info) @@ -715,9 +717,10 @@ class Engine: else: self.burst["cleared"] = now if self.burst["cleared"] is not None: - if now - self.burst["cleared"] < self.net.get("remember_seconds", 180): + if now - self.burst["cleared"] < self.net.get("remember_seconds", 300): stamp = datetime.fromtimestamp(self.burst["start"]).strftime("%H:%M") - out.append(("burst", Result(WARN, "BLIP " + stamp))) + # Flashes (to be noticed) until acknowledged, then goes by itself; a hold clears it. + out.append(("burst", Result(WARN, "BLIP " + stamp, flash=True))) else: self.burst = None if not any(sub == "burst" for sub, _ in out): @@ -873,6 +876,16 @@ class Engine: self.notes[tile_id] = {"text": text, "level": level, "until": now + seconds} def run_fixes(self, tile_id, snap, comp, now, cooldown=60): + if self.tiles.get(tile_id, {}).get("kind") == "network": + # NET: a hold clears a remembered network blip (link and gateway clear themselves). + if self.burst is None: + self.note(tile_id, "NOTHING\nTO CLEAR", "idle", now, 4) + else: + log.info("HOLD net: network blip cleared by hand") + self.burst = None + self.conn_drops.clear() + self.note(tile_id, "CLEARED", "ok", now, 3) + return [] plan = self.fix_plan(tile_id, snap) always = self.tiles.get(tile_id, {}).get("hold_always") if not plan and always: @@ -988,7 +1001,7 @@ def display_level(res, acked, service_on): if res.level == OK: return "ok" if res.level == WARN: - return "warn" + return "notice" if res.flash and not acked else "warn" if res.service_only and not service_on: return "idle" if not service_on: @@ -1003,7 +1016,7 @@ def worst(items): level = max((lv for lv, _, _ in items), key=lambda lv: RANK[lv]) if all(lv == "off" for lv, _, _ in items): level = "off" - faults = [it for it in items if it[0] in ("idle", "warn", "quiet", "ack", "err")] + faults = [it for it in items if it[0] in ("idle", "warn", "notice", "quiet", "ack", "err")] faults.sort(key=lambda it: (-RANK[it[0]], it[2])) return level, faults @@ -1075,7 +1088,7 @@ def render(engine, service_on): text, level = "ALL OK", "ok" else: first = next(fault_text(t, r) for lv, r, s, t in items if (lv, r, s) == faults[0]) - n_bad = sum(1 for f in faults if f[0] != "warn") + n_bad = sum(1 for f in faults if f[0] not in ("warn", "notice")) text = ("%d DOWN" % n_bad if n_bad else "%d WARN" % len(faults)) + "\n" + first out[tid] = colored(text, level) continue diff --git a/tests/test_status_panel.py b/tests/test_status_panel.py index c5978f8..11bab45 100644 --- a/tests/test_status_panel.py +++ b/tests/test_status_panel.py @@ -85,8 +85,8 @@ def run_steps(engine, snap, start, seconds, step=1.0): def level(tile): - for name, ((b, f), _) in sp.COLORS.items(): - if (tile["b"], tile["f"]) == (b, f): + for name, ((b, f), (b2, f2)) in sp.COLORS.items(): + if (tile["b"], tile["f"], tile["b2"], tile["f2"]) == (b, f, b2, f2): return name return "?" @@ -513,9 +513,32 @@ class TestBehaviour(unittest.TestCase): self.snap.conns[label]["category"] = "good" tiles, t = run_steps(engine, self.snap, t, 1) self.assertTrue(tiles["net"]["x"].startswith("NET\nBLIP ")) - self.assertEqual(level(tiles["net"]), "warn") + self.assertEqual(level(tiles["net"]), "notice") # flashes amber + self.assertTrue(tiles["all"]["x"].startswith("1 WARN\nNET BLIP ")) # a warning, not a DOWN tiles, _ = run_steps(engine, self.snap, t + 200, 1) - self.assertEqual(tiles["net"]["x"], "NET\nOK") + self.assertTrue(tiles["net"]["x"].startswith("NET\nBLIP ")) # still up after 3+ minutes + tiles, _ = run_steps(engine, self.snap, t + 320, 1) + self.assertEqual(tiles["net"]["x"], "NET\nOK") # gone by itself after 5 + + def test_network_blip_press_and_hold(self): + engine = sp.Engine(CONFIG) + t = SUNDAY + run_steps(engine, self.snap, t, 2) + drop = ("x32", "VX1000", "UHDJr", "RESI_Decoder", "tascam") + for label in drop: + self.snap.conns[label]["category"] = "error" + _, t = run_steps(engine, self.snap, t + 3, 4) + for label in drop: + self.snap.conns[label]["category"] = "good" + tiles, t = run_steps(engine, self.snap, t, 1) + engine.ack("net") # press: stop the flashing + tiles, t = run_steps(engine, self.snap, t, 1) + self.assertEqual((tiles["net"]["x"].split("\n")[1][:5], level(tiles["net"])), ("BLIP ", "warn")) + engine.run_fixes("net", self.snap, None, t) # hold: gone now + tiles, t = run_steps(engine, self.snap, t + 4, 1) + self.assertEqual((tiles["net"]["x"], tiles["all"]["x"]), ("NET\nOK", "ALL OK")) + engine.run_fixes("net", self.snap, None, t) + self.assertEqual(engine.notes["net"]["text"], "NOTHING\nTO CLEAR") def test_racks_powering_off_is_not_a_network_event(self): engine = sp.Engine(CONFIG) @@ -1253,13 +1276,13 @@ class TestPage(unittest.TestCase): n = sum(1 for r in self.controls.values() for c in r.values() if c["type"] == "button-layered") self.assertEqual(n, len(positioned) + 2) # + the page number and RESTART keys jump = self.controls["2"]["6"]["steps"]["0"]["action_sets"]["up"][0] - self.assertEqual((jump["definitionId"], jump["options"]["page"]["value"]), ("set_page", 23)) + self.assertEqual((jump["definitionId"], jump["options"]["page"]["value"]), ("set_page", 20)) # The master is where the smallest Stream Deck can see it. self.assertEqual(CONFIG["tiles"][0]["pos"], [0, 1]) def test_restart_page(self): export = make_page.build_restart(CONFIG) - self.assertEqual((export["oldPageNumber"], export["page"]["name"]), (23, "23\\nRestart")) + self.assertEqual((export["oldPageNumber"], export["page"]["name"]), (20, "20\\nRestart")) c = export["page"]["controls"] holds = {} for r in c.values(): @@ -1310,7 +1333,8 @@ class TestPage(unittest.TestCase): self.assertIn("2000", ingest["action_sets"]) # restarts its services self.assertIn("2000", self.controls["0"]["2"]["steps"]["0"]["action_sets"]) # REC group net = self.controls["3"]["4"]["steps"]["0"] - self.assertNotIn("2000", net["action_sets"]) # nothing to fix there + self.assertEqual(net["action_sets"]["2000"][0]["options"]["value"]["value"], # hold: clear a blip + "$(custom:sp_cmd);restart net $(internal:time_unix)") ids = [a["id"] for r in self.controls.values() for c in r.values() for s in c.get("steps", {}).values() for al in s["action_sets"].values() for a in al] self.assertEqual(len(ids), len(set(ids)))