From 6c78ed3221c54195d2c8a8b0c25b7c01f1d5d6c0 Mon Sep 17 00:00:00 2001 From: christian Date: Tue, 27 Oct 2026 13:03:44 -0400 Subject: [PATCH] RESI LIVE NOT REC: every deck with a disk must record while Resi plays; a hold starts the idle decks (never touches a recording one) Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- config.json | 22 ++++++--- schema.py | 9 +++- status_panel.py | 99 ++++++++++++++++++++++++++++++++++++++ tests/test_status_panel.py | 82 +++++++++++++++++++++++++++++++ 5 files changed, 205 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 0355ada..fe656fe 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ The **page number key** on every page follows the ALL button. It's green when ev | Button | Checks | |---|---| -| HD1–HD6 | Companion connection; **NO DISK**; record time **LOW** (<30 min, amber) / **FULL** (<5 min); **NOT REC** when another deck is recording and this one isn't | +| HD1–HD6 | Companion connection; **NO DISK**; record time **LOW** (<30 min, amber) / **FULL** (<5 min); **NOT REC** when another deck is recording and this one isn't; **RESI LIVE NOT REC** when Resi has been playing for a minute (`rec_watch`: resi-studio-cues `playback_state` PLAYING) and no deck records. Holding HD1–6 or REC then **starts recording** on every deck with a disk that is not recording (over the deck's own protocol, port 9993; a recording deck is sent nothing), named like the Director page's REC key: `CAM3-YYYYMMDD_HHMMSS-` (`rec_name` per deck) | | | HD2 and HD4 are `"optional": true` in `config.json`: with no disk they sit grey with no alarm, and once a disk is in they are watched like the others. Add or remove `"optional": true` on any deck to change that. | | TASCAM | connection; the recorder's own Error / Caution. Use the **1.3.1-quiet** Tascam module (see below) so Companion's log isn't flooded.; pinged at the address on its Companion connection. | | X32, ARENA, OBS | Companion connection | diff --git a/config.json b/config.json index 6e5016a..9fc4426 100644 --- a/config.json +++ b/config.json @@ -1958,7 +1958,8 @@ "fix": { "conn": "hyperdeck" }, - "type": "hyperdeck" + "type": "hyperdeck", + "rec_name": "CAM3" } ], "hold_always": "hyperdeck", @@ -1977,7 +1978,8 @@ "conn": "hyperdeck" }, "optional": true, - "type": "hyperdeck" + "type": "hyperdeck", + "rec_name": "CAM5" } ], "hold_always": "hyperdeck", @@ -1995,7 +1997,8 @@ "fix": { "conn": "hyperdeck" }, - "type": "hyperdeck" + "type": "hyperdeck", + "rec_name": "CAM2" } ], "hold_always": "hyperdeck", @@ -2014,7 +2017,8 @@ "conn": "hyperdeck" }, "optional": true, - "type": "hyperdeck" + "type": "hyperdeck", + "rec_name": "CAM8" } ], "hold_always": "hyperdeck", @@ -2032,7 +2036,8 @@ "fix": { "conn": "hyperdeck" }, - "type": "hyperdeck" + "type": "hyperdeck", + "rec_name": "PGM2" } ], "hold_always": "hyperdeck", @@ -2050,7 +2055,8 @@ "fix": { "conn": "hyperdeck" }, - "type": "hyperdeck" + "type": "hyperdeck", + "rec_name": "PGM4" } ], "hold_always": "hyperdeck", @@ -2811,5 +2817,9 @@ "command": "sp_cmd", "heartbeat": "sp_hb", "state": "sp_state" + }, + "rec_watch": { + "conn": "resi-studio-cues", + "after_seconds": 60 } } diff --git a/schema.py b/schema.py index dd3d850..8e2a915 100644 --- a/schema.py +++ b/schema.py @@ -104,6 +104,9 @@ field("service_windows[].end", "hhmm", None, "", "Schedules", required=True) field("resi_events", "dict", None, "Resi's scheduled events (studio.resi.io), so the checks marked quiet \"resi_events\" keep quiet " "while an event starts or stops: {\"weekly\": [{\"days\": [\"sun\"], \"start\": \"07:30\", \"end\": \"09:15\"}, ...], " "\"margin_minutes\": 10, \"pco_specials\": true (special services' times from Planning Center)}.", "Site") +field("rec_watch", "dict", None, "Every deck with a disk must record while Resi plays: {\"conn\": \"resi-studio-cues\", " + "\"after_seconds\": 60} (its playback_state PLAYING that long). A deck that does not shows RESI LIVE NOT REC; " + "a hold starts recording on the idle decks only.", "Site") field("debounce_seconds", "int", 3, "A fault must last this long before it shows (one bad reading is not an " "alarm).", "Schedules", advanced=True, lo=0, hi=60) field("flap", "dict", {}, "Repeated faults show amber 'xN'.", "Schedules", advanced=True) @@ -308,6 +311,8 @@ field("tiles[].checks[].disk_service_only", "bool", True, "NO DISK only during a field("tiles[].checks[].full_minutes", "int", 5, "FULL below this many minutes left.", "Keys", advanced=True) field("tiles[].checks[].low_minutes", "int", 30, "LOW below this many minutes left.", "Keys", advanced=True) field("tiles[].checks[].rec_sync", "bool", True, "Must record whenever another deck records.", "Keys") +field("tiles[].checks[].rec_name", "str", None, "Clip name a hold gives this deck when it starts recording (as the " + "Director REC key: CAM3, PGM2...).", "Keys", advanced=True) field("tiles[].checks[].ip", "ip", None, "The deck's address, instead of what Companion reports.", "Keys", advanced=True) field("tiles[].checks[].devices", "dict", None, "Dante devices: name -> key text.", "Keys") field("tiles[].checks[].count_all", "bool", False, "Count every watched Dante device on this key.", "Keys") @@ -497,7 +502,7 @@ field("ready.mic_positions", "dict", {}, "Other PCO positions and the mic they n CHECKS = { "conn": {"fields": ["conn", "service_only", "ignore_warning", "amber"], "subs": ["conn"], "identity": ["conn"], "module": None, "question": "Is a device answering?", "words": "A Companion connection is good."}, - "hyperdeck": {"fields": ["conn", "optional", "disk_service_only", "full_minutes", "low_minutes", "rec_sync", "ip"], + "hyperdeck": {"fields": ["conn", "optional", "disk_service_only", "full_minutes", "low_minutes", "rec_sync", "ip", "rec_name"], "subs": ["conn", "disk", "rec"], "identity": ["conn"], "module": "bmd-hyperdeck", "question": "Is a recorder OK?", "words": "A HyperDeck is connected, has a disk, and records with the others."}, "dante": {"fields": ["conn", "devices", "count_all", "service_only", "devices_off"], "subs": ["conn", ""], "identity": ["conn"], @@ -560,7 +565,7 @@ GLOSSARY = { "DISABLED", "OFFLINE", "NO SESSION", "NOT RUNNING", "DOWN", "NOT REC", "SILENT", "WARNING", "STARTING", "ATEM AUDIO WRONG", "ATEM CAMERA WRONG", "ATEM SW MIDI ON", "PT SOLO NOT AFL", "PROPRES NOT RUNNING", "ABLETON NOT RUNNING", "ABLESET NOT RUNNING", "TRANSLATE NOT RUNNING", "PROP TV DOWN", "BIBLE APP DOWN", "PRODCOM DOWN", "PRODDASH DOWN", - "ENCODER WEB DOWN", "PT SOLO CHECK OFF", "UNREACH", "DROPPED", + "ENCODER WEB DOWN", "PT SOLO CHECK OFF", "UNREACH", "DROPPED", "RESI LIVE NOT REC", ], "check the device or the cable": [ "NO PING", "NO ANSWER", "UNREACHABLE", "LINK DOWN", "NO GATEWAY", "NO DISK", "FULL", "LOW", "BATT", "INTERF", diff --git a/status_panel.py b/status_panel.py index 0082da3..57b0d9b 100644 --- a/status_panel.py +++ b/status_panel.py @@ -285,6 +285,47 @@ def hyperdeck_recording(snap, label): return (snap.var(label, "status") or "").strip().lower() == "record" +def rec_name(chk): + """The clip name a deck gets when a hold starts it (the Director REC key's names: CAM3, PGM2...).""" + return chk.get("rec_name") or chk["conn"] + + +def hyperdeck_record(ip, name, port=9993, timeout=4): + """Start recording on a HyperDeck over its own protocol (beside Companion's connection): sends + "record: name: " only if the deck is not recording already. True when it then reports record.""" + import socket + + def ask(sock, cmd): + sock.sendall(cmd.encode() + b"\r\n") + data = b"" + while True: # the answer, until the deck goes quiet + try: + chunk = sock.recv(4096) + except socket.timeout: + return data.decode("utf-8", "replace") + if not chunk: + return data.decode("utf-8", "replace") + data += chunk + + def status(sock): + m = re.search(r"status: (\w+)", ask(sock, "transport info")) + return m.group(1) if m else "" + try: + with socket.create_connection((ip, port), timeout=timeout) as sock: + sock.settimeout(1.0) + try: + sock.recv(4096) # the greeting (500 connection info) + except socket.timeout: + pass + if status(sock) == "record": + return True + ask(sock, "record: name: %s" % name) + time.sleep(1.0) + return status(sock) == "record" + except OSError: + return False + + def check_hyperdeck(chk, snap, ctx): label = chk["conn"] base = conn_result(snap, label, ctx["now"]) @@ -313,6 +354,9 @@ def check_hyperdeck(chk, snap, ctx): if chk.get("rec_sync", True): if ctx["any_recording"] and not recording: out.append(("rec", Result(ERR, "NOT REC"))) + elif ctx.get("resi_live") and not recording: + # Resi is playing and nothing records (2026-10-27: "i dont want anyone to forget these recordings") + out.append(("rec", Result(ERR, "RESI LIVE NOT REC"))) else: out.append(("rec", Result(OK))) out.insert(0, ("conn", Result(OK, info=info))) @@ -993,6 +1037,9 @@ def needed_variables(cfg): elif t == "psm": for ch in range(1, chk.get("channels", 1) + 1): slow += [(label, "ch_%d_name" % ch), (label, "ch_%d_rf_mute" % ch)] + rw = cfg.get("rec_watch") or {} + if rw.get("conn"): + fast.append((rw["conn"], rw.get("var", "playback_state"))) dedupe = lambda xs: list(dict.fromkeys(xs)) return dedupe(fast), dedupe(slow) @@ -1413,6 +1460,9 @@ class Engine: self.auto_muted = False # ALARMS muted last round (nothing fixes itself then) self.auto_last = 0.0 self.deck_ips = {} # HyperDeck connection -> last address Companion reported + self.resi_since = None # rec_watch: when Resi's playback was first seen PLAYING (None: not playing) + self.resi_live = False + self.record_executor = hyperdeck_record # tests swap it self.now = 0.0 @property @@ -1507,6 +1557,7 @@ class Engine: if conn_result(snap, chk["conn"], now).level == OK and hyperdeck_recording(snap, chk["conn"]): ctx["any_recording"] = True self.any_recording = ctx["any_recording"] + ctx["resi_live"] = self.resi_live = self.resi_playing(snap, now) raw = {} for t in all_tiles(self.cfg): for i, chk in enumerate(t.get("checks", [])): @@ -1905,6 +1956,50 @@ class Engine: log.info("BYPASS %s: auto-fix hours %s", t["label"], "began" if want else "ended") self.toggle_bypass(tid, comp, now) + def resi_playing(self, snap, now): + """config "rec_watch": Resi's playback (resi-studio-cues "playback_state") has read PLAYING for + after_seconds, with the module itself connected. The decks must record then.""" + rw = self.cfg.get("rec_watch") or {} + if not rw.get("conn"): + return False + value = (snap.var(rw["conn"], rw.get("var", "playback_state")) or "").strip().upper() + if value != rw.get("playing", "PLAYING").upper() or conn_result(snap, rw["conn"], now).level != OK: + self.resi_since = None + return False + self.resi_since = self.resi_since or now + return now - self.resi_since >= rw.get("after_seconds", 60) + + def idle_decks(self, tile_id, snap, now): + """HyperDeck checks on this key (or its members) that could record but do not: connected, a disk in.""" + tile = self.tiles.get(tile_id, {}) + out = [] + for tid in tile.get("members") or [tile_id]: + for chk in self.tiles.get(tid, {}).get("checks", []): + if chk["type"] != "hyperdeck" or not chk.get("rec_sync", True): + continue + if conn_result(snap, chk["conn"], now).level != OK or hyperdeck_recording(snap, chk["conn"]): + continue + if (snap.var(chk["conn"], "slotId") or "").strip() in ("", "-"): + continue + ip = chk.get("ip") or snap.var(chk["conn"], "ip") or self.deck_ips.get(chk["conn"]) + if ip: + out.append(dict(chk, ip=ip)) + return out + + def start_recording(self, tile_id, decks, now): + """Hold while Resi is live: start recording on the idle decks only (a recording deck is never sent + anything), named like the Director page's REC key: -YYYYMMDD_HHMMSS-.""" + log.info("HOLD %s: Resi is live - start recording on %s", tile_id, ", ".join(d["conn"] for d in decks)) + self.note(tile_id, "STARTING\nREC", "busy", now, 6) + stamp = time.strftime("%Y%m%d_%H%M%S", time.localtime(now)) + + def job(): + for chk in decks: + ok = self.record_executor(chk["ip"], "%s-%s-" % (rec_name(chk), stamp)) + log.info("REC %s (%s) -> %s", chk["conn"], chk["ip"], "recording" if ok else "FAILED") + threading.Thread(target=job, daemon=True).start() + return [] + def run_fixes(self, tile_id, snap, comp, now, cooldown=None): cooldown = self.cooldown if cooldown is None else cooldown if self.stopping: @@ -1932,6 +2027,10 @@ class Engine: self.conn_drops.clear() self.note(tile_id, "CLEARED", "ok", now, 3) return [] + if self.resi_live: + decks = self.idle_decks(tile_id, snap, now) + if decks: + return self.start_recording(tile_id, decks, now) plan = self.fix_plan(tile_id, snap) always = self.tiles.get(tile_id, {}).get("hold_always") if not plan and always: diff --git a/tests/test_status_panel.py b/tests/test_status_panel.py index cc6032b..9174126 100644 --- a/tests/test_status_panel.py +++ b/tests/test_status_panel.py @@ -630,6 +630,50 @@ class TestChecks(unittest.TestCase): self.assertEqual(tiles["ingest"]["x"], "INGEST\nBRIDGE NOT LOADED\n+2") +class TestHyperDeckRecord(unittest.TestCase): + """hyperdeck_record() against a fake deck speaking the HyperDeck protocol (port 9993).""" + def deck(self, recording=False): + import socketserver + state = {"status": "record" if recording else "preview", "got": []} + + class H(socketserver.BaseRequestHandler): + def handle(self): + self.request.sendall(b"500 connection info:\r\nprotocol version: 1.19\r\n\r\n") + buf = b"" + while True: + data = self.request.recv(1024) + if not data: + return + buf += data + while b"\r\n" in buf: + line, buf = buf.split(b"\r\n", 1) + cmd = line.decode() + state["got"].append(cmd) + if cmd == "transport info": + self.request.sendall(("208 transport info:\r\nstatus: %s\r\n\r\n" % state["status"]).encode()) + elif cmd.startswith("record"): + state["status"] = "record" + self.request.sendall(b"200 ok\r\n") + srv = socketserver.ThreadingTCPServer(("127.0.0.1", 0), H) + srv.daemon_threads = True + threading.Thread(target=srv.serve_forever, daemon=True).start() + self.addCleanup(srv.shutdown) + return srv.server_address[1], state + + def test_an_idle_deck_is_started_with_its_name(self): + port, state = self.deck() + self.assertTrue(sp.hyperdeck_record("127.0.0.1", "CAM3-20261027_090000-", port=port)) + self.assertIn("record: name: CAM3-20261027_090000-", state["got"]) + + def test_a_recording_deck_is_sent_no_record(self): + port, state = self.deck(recording=True) + self.assertTrue(sp.hyperdeck_record("127.0.0.1", "CAM3-x-", port=port)) + self.assertFalse([c for c in state["got"] if c.startswith("record")]) + + def test_no_deck_is_false(self): + self.assertFalse(sp.hyperdeck_record("127.0.0.1", "x", port=1, timeout=1)) + + class TestTextSize(unittest.TestCase): def test_no_word_is_split(self): for text in ("TASCAM\nOK", "SERVICE\nON\nmanual", "OBS\nOFFLINE", "INGEST\n97GB free", @@ -1473,6 +1517,44 @@ class TestFixes(unittest.TestCase): self.assertEqual(self.plan("rec"), [("enable", "HyperDeck_1")]) self.assertEqual(self.plan("x32"), [("enable", "x32")]) + def test_resi_live_and_no_deck_recording_is_red_and_a_hold_starts_them(self): + # 2026-10-27: "notify me if we are not recording on the hyperdecks, but resi is playing" + self.snap.vars[("resi-studio-cues", "playback_state")] = "PLAYING" + self.snap.vars[("HyperDeck_2", "slotId")] = "-" # optional deck, no disk: not in use + self.snap.vars[("HyperDeck_2", "recordingTime")] = "--:--:--" + tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 30) + self.assertEqual(level(tiles["hd1"]), "ok") # not yet: Resi just started + tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 30, 40) + self.assertEqual(tiles["hd1"]["x"], "HD1\nRESI LIVE NOT REC") + self.assertEqual(level(tiles["rec"]), "err") + self.assertEqual(level(tiles["hd2"]), "off") + self.snap.vars[("HyperDeck_3", "status")] = "Record" # once one records, the others are NOT REC + tiles, _ = run_steps(self.engine, self.snap, SUNDAY + 70, 4) + self.assertEqual(tiles["hd1"]["x"], "HD1\nNOT REC") + started = [] + self.engine.record_executor = lambda ip, name: started.append((ip, name)) or True + self.engine.run_fixes("rec", self.snap, None, SUNDAY + 80) + time.sleep(0.2) + self.assertEqual([ip for ip, _ in started], ["10.1.10.11", "10.1.10.14", "10.1.10.15", "10.1.10.16"]) + self.assertTrue(started[0][1].startswith("CAM3-") and started[0][1].endswith("-")) + self.assertTrue(started[3][1].startswith("PGM4-")) + self.assertEqual(self.engine.notes["rec"]["text"], "STARTING\nREC") + + def test_resi_paused_or_its_module_down_is_not_live(self): + self.snap.vars[("resi-studio-cues", "playback_state")] = "PAUSED" + tiles, _ = run_steps(self.engine, self.snap, SUNDAY, 70) + self.assertEqual(level(tiles["hd1"]), "ok") + self.snap.vars[("resi-studio-cues", "playback_state")] = "PLAYING" + self.snap.conns["resi-studio-cues"]["category"] = "error" + run_steps(self.engine, self.snap, SUNDAY + 70, 70) + self.assertFalse(self.engine.resi_live) + ran = [] + self.engine.executor = lambda cfg, fix_id, chk: ran.append(chk.get("conn")) or True + self.engine.record_executor = lambda ip, name: self.fail("must not record when Resi is not live") + self.engine.run_fixes("hd1", self.snap, None, SUNDAY + 150) # the usual hold: reconnect + time.sleep(0.2) + self.assertEqual(ran, ["HyperDeck_1"]) + def test_not_recording_is_not_fixed_by_a_reboot(self): for i in (1, 3): self.snap.vars[("HyperDeck_%d" % i, "status")] = "Record"