diff --git a/README.md b/README.md index 1c35faf..a792dfb 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,7 @@ The **page number key** on every page follows the ALL button. It's green when ev | LED | VX1000 and UHD Jr: Companion connection **and** each one answers ping (the NovaStar module keeps saying "Connected" after a processor is switched off). "UHDJR NO PING" = processor off or unreachable; "UHDJR OFFLINE" = Companion's connection failed; "UHDJR DISABLED" (amber) = connection turned off in Companion. | | DANTE | All 19 Dante devices ("19/19"), named when one drops: "WING GONE", "TRACKS GONE", "PROP MAC GONE". | | MIDI | All six network MIDI links on the FOH / Studio B checklist, each named when it drops: **LIGHT PC** (Ingest → Lighting), **SUPERRACK** (Ingest → Livebox), **FX LIGHTS** (Tracks → Lighting, the FX lighting cues), **TRK LIVEBOX** (Tracks → Livebox), **TRK STUDIO B** (Tracks → Studio B), **PROP LIGHTS** (ProPresenter → Lighting). NA Ingest's own session is read locally; NA Tracks' and NA ProP's over SSH every 5 s. Also: the Lighting PC and NA Tracks MIDI sessions still announced on the network. A clean disconnect shows within about 10 s; a crash or pulled cable takes about a minute (macOS's own timeout, measured). Hold 2 s reconnects whichever links dropped. | -| 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; **AbleSet** running on the Tracks Mac (ProdDash shows it): **ABLESET NOT RUNNING**, and the hold (and auto-fix) opens it in the background and minimizes its window (`mac_start` with *Minimized*). Messages name the app ("PROPRES OFFLINE", "BIBLE APP DOWN"). Hold 2 s restarts the ProPresenter, Ableton and Bible TV Companion connections.; **Pro Tools on the Studio B Mac Studio** set to Solo Mode **AFL + Latch** ("PT SOLO NOT AFL"; hold, or Sunday auto-fix, ticks them back). A helper on that Mac (`remote-access/solo-mode-agent.swift`, installed with `install-solo-mode.sh`) reads the menu; it needs Accessibility allowed once, then a restart of the helper. "PT SOLO CHECK OFF" = that helper is not running or not allowed. | +| 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; **AbleSet** running on the Tracks Mac (ProdDash shows it): **ABLESET NOT RUNNING**, and the hold (and auto-fix) opens it in the background and minimizes its window (`mac_start` with *Minimized*). While **ABLESET** on the Restart page (row 1 col 6) says **AUTO-OPEN OFF**, AbleSet is neither checked nor opened (hold it to switch; it stays as left, through restarts: a `bypass` key with `fixes`, shown by a Restart page key with `toggle`). Messages name the app ("PROPRES OFFLINE", "BIBLE APP DOWN"). Hold 2 s restarts the ProPresenter, Ableton and Bible TV Companion connections.; **Pro Tools on the Studio B Mac Studio** set to Solo Mode **AFL + Latch** ("PT SOLO NOT AFL"; hold, or Sunday auto-fix, ticks them back). A helper on that Mac (`remote-access/solo-mode-agent.swift`, installed with `install-solo-mode.sh`) reads the menu; it needs Accessibility allowed once, then a restart of the helper. "PT SOLO CHECK OFF" = that helper is not running or not allowed. | | 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). 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. | diff --git a/config.json b/config.json index e558d90..e75e1dd 100644 --- a/config.json +++ b/config.json @@ -1697,6 +1697,16 @@ "watch": [ "conn:arena" ] + }, + { + "id": "rs_ableset", + "label": "ABLESET", + "fix": "ableset", + "toggle": "ableset", + "pos": [ + 1, + 6 + ] } ], "page": 20, @@ -2657,6 +2667,18 @@ 3, 7 ] + }, + { + "id": "ableset", + "label": "ABLESET", + "kind": "bypass", + "bypass": { + "fixes": [ + "ableset" + ], + "on_text": "AUTO-OPEN\nOFF", + "off_text": "AUTO-OPEN\nON" + } } ], "timing": { diff --git a/make_page.py b/make_page.py index 9d77517..8f858ed 100644 --- a/make_page.py +++ b/make_page.py @@ -150,17 +150,22 @@ def restart_key(key, cfg): names, opts = cfg["variables"], settings(cfg) hold_ms, secs = opts["hold_ms"], "%g" % (opts["hold_ms"] / 1000.0) stale_text = "'%s\\n?'" % key["label"] + if key.get("toggle"): # a switch kept on this page (ABLESET): its hold flips it + verb, note = "restart %s $(internal:time_unix)" % key["toggle"], ( + "SWITCH key '%s' - hold %s s: switch %s off or back on (status_panel.py on %s)." % (key["id"], secs, key["label"], opts["ingest"])) + else: + verb, note = "force %s $(internal:time_unix)" % key["id"], ( + "RESTART key '%s' - hold %s s: restart it even when the status page says it is fine " + "(status_panel.py on %s runs fix '%s')." % (key["id"], secs, opts["ingest"], key["fix"])) return { "type": "button-layered", "style": {"layers": layers(key["id"], key, names, stale_text, opts)}, "options": {"stepProgression": "auto", "stepExpression": "", "rotaryActions": False, "canModifyStyleInApis": True, - "notes": "RESTART key '%s' - hold %s s: restart it even when the status page says it is fine " - "(status_panel.py on %s runs fix '%s')." % (key["id"], secs, opts["ingest"], key["fix"])}, + "notes": note}, "feedbacks": [], "steps": {"0": {"action_sets": {"down": [], "up": [], - str(hold_ms): [command_action(key["id"], "hold", names["command"], - "force %s $(internal:time_unix)" % key["id"])]}, + str(hold_ms): [command_action(key["id"], "hold", names["command"], verb)]}, "options": {"runWhileHeld": [hold_ms]}}}, "localVariables": [], } @@ -214,7 +219,9 @@ def button(tid, tile, cfg): stale_text = "'%s\\n?'" % label press, hold = "ack " + tid, "restart " + tid b = tile.get("bypass") or {} - note = ("Hold %s s: hand %s to an operator - its MIDI links (%s) are taken out%s and the panel stops putting them " + held = ", ".join((cfg.get("fixes", {}).get(f) or {}).get("short", f) for f in b.get("fixes") or []) + note = ("Hold %s s: switch off %s - its checks stay green and nothing opens or fixes it. Hold again: back on." % (secs, held) + if held and not b.get("peers") else None) or ("Hold %s s: hand %s to an operator - its MIDI links (%s) are taken out%s and the panel stops putting them " "back. Hold again: everything is put back." % (secs, label, ", ".join(b.get("peers") or []), " and Companion's %s switched off" % ", ".join(b["conns"]) if b.get("conns") else "")) else: diff --git a/restart-page.companionconfig b/restart-page.companionconfig index c659c0c..fb83666 100644 Binary files a/restart-page.companionconfig and b/restart-page.companionconfig differ diff --git a/schema.py b/schema.py index db77144..5a1340e 100644 --- a/schema.py +++ b/schema.py @@ -274,7 +274,9 @@ field("tiles[].kind", "enum", None, "A special key: master (ALL), service (ALARM "silences), bypass (hold: hand a device to an operator - its MIDI links out, its Companion connections off).", "Keys", advanced=True, options=["master", "service", "network", "ready", "autofix", "bypass"]) field("tiles[].bypass", "dict", None, "A bypass key: peers (MIDI peer or session names to take out, e.g. Lighting), " - "hosts (the Macs whose sessions to look in; \"\" is this Mac) and conns (Companion connections to switch off).", + "hosts (the Macs whose sessions to look in; \"\" is this Mac) and conns (Companion connections to switch off); or " + "fixes (fix ids it holds off: their checks stay green and nothing runs them, e.g. ableset), with on_text / off_text " + "for the key's face.", "Keys", advanced=True) field("tiles[].members", "list", None, "A group: the keys it sums up.", "Keys") field("tiles[].checks", "list", [], "What the key watches.", "Keys") @@ -350,6 +352,8 @@ field("restart_page.keys[].id", "id", None, "", "Keys", advanced=True, required= field("restart_page.keys[].label", "str", None, "", "Keys", required=True, apply="pages") field("restart_page.keys[].pos", "list", None, "[row, column].", "Keys", required=True, apply="pages") field("restart_page.keys[].fix", "str", None, "What holding it runs.", "Keys", required=True) +field("restart_page.keys[].toggle", "str", None, "A switch kept on this page: the bypass key (no position of its own) " + "it shows and a hold flips, e.g. ableset.", "Keys", apply="pages") field("restart_page.keys[].disabled", "bool", False, "Switched off: the key stays on the page (OFF) but a hold does " "nothing.", "Keys", apply="pages") field("restart_page.keys[].watch", "list", [], "Keep flashing HOLD TO FIX while these fixes' checks or " @@ -565,7 +569,7 @@ GLOSSARY = { "ON FOH BACKUP", "BUSY", "TAKING MIDI OUT", "PUTTING MIDI BACK", "TAKING", "PUTTING", "MIDI OUT", "MIDI BACK", ], "a switch key (AUTO-FIX, or one like LIGHTS that hands a device to an operator)": [ - "OPERATOR", "MIDI OFF", "OFF IN SETTINGS", "SETTINGS", "TIL", "PAUSED", + "OPERATOR", "MIDI OFF", "AUTO-OPEN", "OFF IN SETTINGS", "SETTINGS", "TIL", "PAUSED", ], } @@ -732,7 +736,9 @@ def validate(cfg, live=None): fixes_of = {c.get("fix") for c in t.get("checks", []) if isinstance(c.get("fix"), str)} | {"conn:" + c["conn"] for c in t.get("checks", []) if c.get("conn")} if fixes_of & on_restart: continue - if "pos" not in t and "members" not in t and t.get("id") not in in_group and t.get("kind") != "master": + toggled = {k.get("toggle") for k in (cfg.get("restart_page") or {}).get("keys", [])} + if "pos" not in t and "members" not in t and t.get("id") not in in_group and t.get("kind") != "master" \ + and t.get("id") not in toggled: warnings.append("key %s has no place on the deck and is in no group: nobody will see it" % t.get("id")) if kinds["master"] != 1: errors.append("there must be exactly one ALL key (kind master); found %d" % kinds["master"]) @@ -745,8 +751,11 @@ def validate(cfg, live=None): for t in cfg["tiles"]: if t.get("kind") == "bypass": b = t.get("bypass") if isinstance(t.get("bypass"), dict) else {} - if not b.get("peers"): - errors.append("key %s: a bypass key needs bypass.peers (the MIDI peers it takes out)" % t.get("id")) + if not b.get("peers") and not b.get("fixes"): + errors.append("key %s: a bypass key needs bypass.peers (the MIDI peers it takes out) or bypass.fixes" % t.get("id")) + for f in b.get("fixes") or []: + if f not in cfg.get("fixes", {}): + errors.append("key %s: bypass fix %s is not a fix in the settings" % (t.get("id"), f)) for h in b.get("hosts") or []: if h and h not in cfg.get("hosts", {}): errors.append("key %s: bypass host %s is not a computer in the settings" % (t.get("id"), h)) @@ -857,6 +866,8 @@ def validate(cfg, live=None): errors.append("%s: a key on the Status page has that id" % where) if k.get("fix") not in fixes: errors.append("%s: unknown fix %s" % (where, k.get("fix"))) + if k.get("toggle") and not any(t.get("id") == k["toggle"] and t.get("kind") == "bypass" for t in cfg.get("tiles", [])): + errors.append("%s: toggle %s is not a bypass key" % (where, k["toggle"])) for w in k.get("watch", []): if not w.startswith("conn:") and w not in fixes: errors.append("%s: unknown fix %s in its watch list" % (where, w)) diff --git a/status_panel.py b/status_panel.py index 955b2c6..285e7f9 100644 --- a/status_panel.py +++ b/status_panel.py @@ -592,6 +592,8 @@ def bypass_apply(cfg, tile, on, entry, comp, names_ips, lock=None): taken out ({host, session, peer, address}), so taking it back puts exactly those back; the MIDI checks naming it are reconnected too. Its Companion connections are switched off, then on again.""" b = tile.get("bypass") or {} + if not b.get("peers") and not b.get("conns"): + return # a key that only holds off fixes: nothing to hand over names, ips = set(b.get("peers") or []), set(names_ips[1]) if on else set() hosts = b.get("hosts") or [""] + sorted({c["host"] for t in cfg["tiles"] for c in t.get("checks", []) if c.get("type") == "midi_peer" and c.get("peer") in names and c.get("host")}) @@ -1410,7 +1412,7 @@ class Engine: def evaluate(self, snap, now, service_on): ctx = {"now": now, "service": service_on, "any_recording": False, "dante_all": {}, "ready_due": in_windows(self.ready_cfg.get("due", []), now), "ready_done": self.ready_done(now), - "bypassed": self.bypass_names()[0], "autofix_windows": (self.cfg.get("auto_fix") or {}).get("windows", [])} + "bypassed": self.bypass_names()[0], "bypassed_fixes": self.bypass_fixes(), "autofix_windows": (self.cfg.get("auto_fix") or {}).get("windows", [])} for t in all_tiles(self.cfg): for chk in t.get("checks", []): if chk["type"] == "dante": @@ -1426,6 +1428,8 @@ class Engine: for i, chk in enumerate(t.get("checks", [])): try: results = CHECKS[chk["type"]](chk, snap, ctx) + if isinstance(chk.get("fix"), str) and chk["fix"] in ctx["bypassed_fixes"]: + results = [(sub, Result(OK)) for sub, _ in results] # switched off by its bypass key except Exception as e: # a bad check must not take the panel down log.exception("check %s/%s failed", t["id"], chk["type"]) results = [("error", Result(WARN, "CHECK BUG"))] @@ -1748,6 +1752,10 @@ class Engine: ips.add(str(ip)) return names, ips + def bypass_fixes(self): + """Fixes a bypass key holds off (an ABLESET key: AbleSet not watched nor opened while it is on).""" + return {f for tid in self.bypass for f in (((self.tiles.get(tid) or {}).get("bypass") or {}).get("fixes") or [])} + def toggle_bypass(self, tile_id, comp, now): """A hold on a bypass key: hand it to an operator (take its MIDI links out, switch its Companion connections off), or take it back (put everything back, as it was).""" @@ -1763,8 +1771,13 @@ class Engine: entry = self.bypass[tile_id] = {"since": now, "dropped": []} on = True self.state.set("bypass", self.bypass) - log.info("BYPASS %s: %s", tile["label"], "handed to an operator" if on else "back to the panel") - self.note(tile_id, "TAKING\nMIDI OUT" if on else "PUTTING\nMIDI BACK", "fixing", now, 6) + b = tile.get("bypass") or {} + if b.get("peers") or b.get("conns"): + log.info("BYPASS %s: %s", tile["label"], "handed to an operator" if on else "back to the panel") + self.note(tile_id, "TAKING\nMIDI OUT" if on else "PUTTING\nMIDI BACK", "fixing", now, 6) + else: # a switch that only holds off fixes: its face says it all + log.info("BYPASS %s: %s", tile["label"], "switched off (%s not watched or run)" % ", ".join(b.get("fixes") or []) + if on else "back on") self.bypass_last = now self.start_bypass_job(tile_id, on, entry, comp) @@ -2409,7 +2422,9 @@ def render(engine, service_on): if kind == "bypass": # Handed to an operator: steady blue, says so; otherwise the panel runs it. on = tid in engine.bypass - out[tid] = colored("%s\n%s" % (tile["label"], "OPERATOR\nMIDI OFF" if on else "AUTO"), "service" if on else "ok") + b = tile.get("bypass") or {} + face = (b.get("on_text") or "OPERATOR\nMIDI OFF") if on else (b.get("off_text") or "AUTO") + out[tid] = colored("%s\n%s" % (tile["label"], face), "service" if on else "ok") continue if kind == "master": # Idle faults are expected outside service; they do not stop "ALL OK". @@ -2461,6 +2476,9 @@ def render(engine, service_on): else: text = tile["label"] + "\n" + (infos[0] if infos else "OK") out[tid] = colored(text + failover_line(engine, [tid]), level) + for kid, key in engine.restart_keys.items(): # a Restart page key that is a switch shows the switch + if key.get("toggle") in out: + out[kid] = out[key["toggle"]] return out diff --git a/tests/test_status_panel.py b/tests/test_status_panel.py index f2057b0..e6558fb 100644 --- a/tests/test_status_panel.py +++ b/tests/test_status_panel.py @@ -2634,6 +2634,26 @@ class TestSwitches(unittest.TestCase): self.assertIsNone(res) self.assertIn("calls in", why) + def test_ableset_key_holds_off_its_fix(self): + """ABLESET (2026-10-25, the user had an AbleSet bug): held, AbleSet is neither watched nor opened.""" + engine = sp.Engine(CONFIG) + snap = sp.Snapshot() + snap.remote["tracks\0pgrep -xq AbleSet"] = False + tiles, _ = run_steps(engine, snap, SUNDAY, 4) + msgs = lambda: [res.msg for _, res, _ in engine.items.get("apps", [])] + self.assertIn("ABLESET NOT RUNNING", msgs()) + self.assertEqual(tiles["ableset"]["x"], "ABLESET\nAUTO-OPEN\nON") + self.assertIn("ableset", [w for _, k, w, _ in engine.fix_plan("apps", snap)]) + engine.bypass["ableset"] = {"since": SUNDAY, "dropped": []} + tiles, _ = run_steps(engine, snap, SUNDAY + 10, 4) + self.assertNotIn("ABLESET NOT RUNNING", msgs()) + self.assertEqual(tiles["ableset"]["x"], "ABLESET\nAUTO-OPEN\nOFF") + self.assertEqual(tiles["rs_ableset"]["x"], "ABLESET\nAUTO-OPEN\nOFF") # its key on the Restart page + key = make_page.restart_key(next(k for k in CONFIG["restart_page"]["keys"] if k["id"] == "rs_ableset"), CONFIG) + self.assertIn("restart ableset", json.dumps(key)) # the hold flips the switch + self.assertNotIn("ableset", [w for _, k, w, _ in engine.fix_plan("apps", snap)]) + sp.bypass_apply(CONFIG, engine.tiles["ableset"], True, engine.bypass["ableset"], None, (set(), set())) # nothing to hand over + def test_a_bypass_key_needs_its_peers(self): import schema bad = json.loads(json.dumps(self.cfg))