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:
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user