NET blip flashes, clears itself after 5 minutes, hold NET clears it; Restart page is now page 20

- The remembered network blip is a flashing amber warning (new "notice"
  level, not counted as DOWN): press NET to stop the flashing, hold NET 2 s
  to clear it, otherwise it goes by itself after 5 minutes (was 3, solid).
- The user moved the Restart page to page 20: config, the RESTART PAGE key
  on page 19 and both pages re-pushed. A scan of all pages finds no other
  button pointing at the old page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 18:38:19 -04:00
co-authored by Claude Opus 5.5
parent 482a0a4f82
commit 6410525681
8 changed files with 61 additions and 22 deletions
+2 -2
View File
@@ -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`. 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.
## 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. 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. | | 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 (`<autoloadpath>` in `E:\media\Documents\Vista 3\.preferences`, set 2026-10-08). Resolume is the ARENA button. | | 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 (`<autoloadpath>` 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. | | 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 | | 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. | | 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. |
+2 -2
View File
@@ -20,7 +20,7 @@
"burst_connections": 4, "burst_connections": 4,
"burst_groups": 3, "burst_groups": 3,
"burst_window_seconds": 10, "burst_window_seconds": 10,
"remember_seconds": 180 "remember_seconds": 300
}, },
"alert_webhook": "", "alert_webhook": "",
@@ -78,7 +78,7 @@
}, },
"restart_page": { "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": [ "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" },
+4 -2
View File
@@ -166,8 +166,10 @@ def button(tid, tile, cfg):
press = "ack " + tid press = "ack " + tid
fixable = "members" in tile or any((c["type"] in RESTARTABLE and c.get("conn")) or c.get("fix") fixable = "members" in tile or any((c["type"] in RESTARTABLE and c.get("conn")) or c.get("fix")
for c in tile.get("checks", [])) for c in tile.get("checks", []))
hold = ("restart " + tid) if fixable or tile.get("hold_always") else None hold = ("restart " + tid) if fixable or tile.get("hold_always") or kind == "network" else None
if tile.get("hold_always"): 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"] note = "Press: acknowledge. Hold 2 s: %s." % cfg["fixes"][tile["hold_always"]]["label"]
else: else:
note = "Press: acknowledge." + (" Hold 2 s: fix what this button is failing on (nothing if it is green)." note = "Press: acknowledge." + (" Hold 2 s: fix what this button is failing on (nothing if it is green)."
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/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 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): same internal API the Companion web UI uses (tRPC over a WebSocket at /trpc):
Binary file not shown.
Binary file not shown.
+21 -8
View File
@@ -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 # 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. # 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)} RANK = {name: i for i, name in enumerate(ORDER)}
# (background, text) for the flash-on phase and the flash-off phase. # (background, text) for the flash-on phase and the flash-off phase.
COLORS = { COLORS = {
"ok": ((0x007A2E, 0xFFFFFF), (0x007A2E, 0xFFFFFF)), "ok": ((0x007A2E, 0xFFFFFF), (0x007A2E, 0xFFFFFF)),
"warn": ((0xFFA000, 0x000000), (0xFFA000, 0x000000)), "warn": ((0xFFA000, 0x000000), (0xFFA000, 0x000000)),
"notice": ((0xFFA000, 0x000000), (0x000000, 0xFFA000)), # flashing amber: something happened (NET BLIP)
"quiet": ((0x3A0000, 0xFF8080), (0x3A0000, 0xFF8080)), "quiet": ((0x3A0000, 0xFF8080), (0x3A0000, 0xFF8080)),
"ack": ((0x8B0000, 0xFFFFFF), (0x8B0000, 0xFFFFFF)), "ack": ((0x8B0000, 0xFFFFFF), (0x8B0000, 0xFFFFFF)),
"err": ((0xFF0000, 0xFFFFFF), (0x000000, 0xFF3030)), "err": ((0xFF0000, 0xFFFFFF), (0x000000, 0xFF3030)),
@@ -60,14 +61,15 @@ COLORS = {
class Result: class Result:
"""One check's verdict. `named` means msg already says which device it is.""" """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.level = level
self.msg = msg self.msg = msg
self.info = info self.info = info
self.service_only = service_only self.service_only = service_only
self.named = named self.named = named
self.flash = flash # a warning that flashes until acknowledged (NET BLIP)
def __repr__(self): def __repr__(self):
return "Result(%s, %r, info=%r)" % (self.level, self.msg, self.info) return "Result(%s, %r, info=%r)" % (self.level, self.msg, self.info)
@@ -715,9 +717,10 @@ class Engine:
else: else:
self.burst["cleared"] = now self.burst["cleared"] = now
if self.burst["cleared"] is not None: 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") 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: else:
self.burst = None self.burst = None
if not any(sub == "burst" for sub, _ in out): 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} self.notes[tile_id] = {"text": text, "level": level, "until": now + seconds}
def run_fixes(self, tile_id, snap, comp, now, cooldown=60): 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) plan = self.fix_plan(tile_id, snap)
always = self.tiles.get(tile_id, {}).get("hold_always") always = self.tiles.get(tile_id, {}).get("hold_always")
if not plan and always: if not plan and always:
@@ -988,7 +1001,7 @@ def display_level(res, acked, service_on):
if res.level == OK: if res.level == OK:
return "ok" return "ok"
if res.level == WARN: if res.level == WARN:
return "warn" return "notice" if res.flash and not acked else "warn"
if res.service_only and not service_on: if res.service_only and not service_on:
return "idle" return "idle"
if not service_on: if not service_on:
@@ -1003,7 +1016,7 @@ def worst(items):
level = max((lv for lv, _, _ in items), key=lambda lv: RANK[lv]) level = max((lv for lv, _, _ in items), key=lambda lv: RANK[lv])
if all(lv == "off" for lv, _, _ in items): if all(lv == "off" for lv, _, _ in items):
level = "off" 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])) faults.sort(key=lambda it: (-RANK[it[0]], it[2]))
return level, faults return level, faults
@@ -1075,7 +1088,7 @@ def render(engine, service_on):
text, level = "ALL OK", "ok" text, level = "ALL OK", "ok"
else: else:
first = next(fault_text(t, r) for lv, r, s, t in items if (lv, r, s) == faults[0]) 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 text = ("%d DOWN" % n_bad if n_bad else "%d WARN" % len(faults)) + "\n" + first
out[tid] = colored(text, level) out[tid] = colored(text, level)
continue continue
+31 -7
View File
@@ -85,8 +85,8 @@ def run_steps(engine, snap, start, seconds, step=1.0):
def level(tile): def level(tile):
for name, ((b, f), _) in sp.COLORS.items(): for name, ((b, f), (b2, f2)) in sp.COLORS.items():
if (tile["b"], tile["f"]) == (b, f): if (tile["b"], tile["f"], tile["b2"], tile["f2"]) == (b, f, b2, f2):
return name return name
return "?" return "?"
@@ -513,9 +513,32 @@ class TestBehaviour(unittest.TestCase):
self.snap.conns[label]["category"] = "good" self.snap.conns[label]["category"] = "good"
tiles, t = run_steps(engine, self.snap, t, 1) tiles, t = run_steps(engine, self.snap, t, 1)
self.assertTrue(tiles["net"]["x"].startswith("NET\nBLIP ")) 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) 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): def test_racks_powering_off_is_not_a_network_event(self):
engine = sp.Engine(CONFIG) 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") 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 self.assertEqual(n, len(positioned) + 2) # + the page number and RESTART keys
jump = self.controls["2"]["6"]["steps"]["0"]["action_sets"]["up"][0] 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. # The master is where the smallest Stream Deck can see it.
self.assertEqual(CONFIG["tiles"][0]["pos"], [0, 1]) self.assertEqual(CONFIG["tiles"][0]["pos"], [0, 1])
def test_restart_page(self): def test_restart_page(self):
export = make_page.build_restart(CONFIG) 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"] c = export["page"]["controls"]
holds = {} holds = {}
for r in c.values(): 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", ingest["action_sets"]) # restarts its services
self.assertIn("2000", self.controls["0"]["2"]["steps"]["0"]["action_sets"]) # REC group self.assertIn("2000", self.controls["0"]["2"]["steps"]["0"]["action_sets"]) # REC group
net = self.controls["3"]["4"]["steps"]["0"] 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() 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] 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))) self.assertEqual(len(ids), len(set(ids)))