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 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 13:03:44 -04:00
co-authored by Claude Opus 5.5
parent 12a9e506e1
commit 6c78ed3221
5 changed files with 205 additions and 9 deletions
+1 -1
View File
@@ -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 |
+16 -6
View File
@@ -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
}
}
+7 -2
View File
@@ -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", "<device name>"], "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",
+99
View File
@@ -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: <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: <rec_name>-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:
+82
View File
@@ -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"