ATEM: audio, camera settings and MIDI control watched and fixed against the restore file
atem_watch.js (run with Companion's node + its atem-connection) keeps one connection to the switcher and compares, every second, every Fairlight source's mix option and fader and every camera's settings with atem-reference.xml (the 2026-10-07 14:59:55 restore file). ATEM shows ATEM AUDIO WRONG / ATEM CAMERA WRONG; the hold and Sunday auto-fix send back only what differs. ATEM SW MIDI ON is fixed by quitting ATEM Software Control, switching midiEnabled off and reopening it. Live check: switcher matches the file (0 differences, 1440 camera values). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+160
-1
@@ -19,6 +19,7 @@ import calendar
|
||||
import http.client
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import logging.handlers
|
||||
import os
|
||||
import re
|
||||
@@ -150,6 +151,7 @@ class Snapshot:
|
||||
self.peer_addr = {} # (host or "", session, peer) -> address it was last seen at
|
||||
self.conn_hosts = {} # Companion connection label -> its "host" setting
|
||||
self.prefs = {} # (preferences domain, key) -> value as "defaults read" prints it
|
||||
self.atem = None # atem_watch.js's latest report
|
||||
self.midi_time = 0.0
|
||||
self.mdns = set() # Bonjour _apple-midi._udp instance names
|
||||
self.mdns_time = 0.0
|
||||
@@ -1865,6 +1867,10 @@ def execute_fix(cfg, fix_id, chk):
|
||||
ok = conn in ids and comp.restart_connection(ids[conn])
|
||||
log.info("FIX %s -> Companion connection %s restarted %s", label, conn, "ok" if ok else "FAILED")
|
||||
return r.returncode == 0
|
||||
if kind == "atem":
|
||||
ok = bool(ATEM_WATCHER) and ATEM_WATCHER.fix(fix["part"])
|
||||
log.info("FIX %s -> %s", label, "switcher matches the restore file again" if ok else "FAILED")
|
||||
return ok
|
||||
if kind == "reconnect":
|
||||
return reconnect_and_verify(cfg, fix, label)
|
||||
if kind == "midi_connect":
|
||||
@@ -1948,6 +1954,150 @@ def reconnect_and_verify(cfg, fix, label, sleep=None, conn=None, host=None, togg
|
||||
return "NO NETWORK\nCHECK POWER"
|
||||
|
||||
|
||||
# --- ATEM switcher: audio and camera settings against the restore file -------
|
||||
|
||||
MIX_OPTIONS = {"Off": 1, "On": 2, "AudioFollowVideo": 4}
|
||||
# ATEM Software Control's camera setting names -> camera control (category, parameter) and value order
|
||||
CCU_PARAMS = {
|
||||
("Lens", "ApertureFstop"): (0, 2, ["value"]),
|
||||
("Video", "ManualWhiteBalance"): (1, 2, ["temperature", "tint"]),
|
||||
("Video", "Exposure"): (1, 5, ["value"]),
|
||||
("Video", "DetailLevel"): (1, 8, ["value"]),
|
||||
("Video", "SensorGainInDecibels"): (1, 13, ["value"]),
|
||||
("Video", "SensorNDFilter"): (1, 16, ["value"]),
|
||||
("ColorCorrection", "LiftAdjust"): (8, 0, ["red", "green", "blue", "luma"]),
|
||||
("ColorCorrection", "GammaAdjust"): (8, 1, ["red", "green", "blue", "luma"]),
|
||||
("ColorCorrection", "GainAdjust"): (8, 2, ["red", "green", "blue", "luma"]),
|
||||
("ColorCorrection", "OffsetAdjust"): (8, 3, ["red", "green", "blue", "luma"]),
|
||||
("ColorCorrection", "ContrastAdjust"): (8, 4, ["pivot", "adjust"]),
|
||||
("ColorCorrection", "LumaMix"): (8, 5, ["value"]),
|
||||
("ColorCorrection", "ColorAdjust"): (8, 6, ["hue", "saturation"]),
|
||||
}
|
||||
|
||||
|
||||
def atem_reference(xml_path):
|
||||
"""The restore file (.xml from ATEM Software Control) as what atem_watch.js compares against:
|
||||
every Fairlight source's mix option and fader, and every camera's settings above."""
|
||||
import xml.etree.ElementTree as ET
|
||||
root = ET.parse(xml_path).getroot()
|
||||
audio = {}
|
||||
mixer = root.find("FairlightAudioMixer")
|
||||
for inp in (mixer.iter("AudioInput") if mixer is not None else []):
|
||||
for src in inp.iter("AudioSource"):
|
||||
mix = MIX_OPTIONS.get(src.attrib.get("mixOption"))
|
||||
if mix is not None:
|
||||
want = {"mixOption": mix}
|
||||
try:
|
||||
gain = float(src.attrib.get("faderGain", "nan"))
|
||||
except ValueError:
|
||||
gain = float("nan")
|
||||
if math.isfinite(gain): # the file saves dB; the switcher uses 1/100 dB
|
||||
want["faderGain"] = int(round(gain * 100))
|
||||
audio["%s/%s" % (inp.attrib["id"], src.attrib["id"])] = want
|
||||
camera = {}
|
||||
cc = root.find("CameraControl")
|
||||
for p in (cc if cc is not None else []):
|
||||
spec = CCU_PARAMS.get((p.attrib.get("category"), p.attrib.get("parameter")))
|
||||
if spec and all(f in p.attrib for f in spec[2]):
|
||||
camera["%s/%d/%d" % (p.attrib["device"], spec[0], spec[1])] = [float(p.attrib[f]) for f in spec[2]]
|
||||
return {"audio": audio, "camera": camera}
|
||||
|
||||
|
||||
def newest(pattern):
|
||||
"""The path matching a pattern like ".../bmd-atem-*/..." with the highest version number."""
|
||||
import glob
|
||||
def version(p):
|
||||
return [int(n) for n in re.findall(r"\d+", p)]
|
||||
found = sorted(glob.glob(os.path.expanduser(pattern)), key=version)
|
||||
return found[-1] if found else None
|
||||
|
||||
|
||||
class AtemWatcher:
|
||||
"""Runs atem_watch.js (Companion's node + its atem-connection library) and keeps its latest report."""
|
||||
|
||||
def __init__(self, cfg, snap, data_dir, stop):
|
||||
a = cfg["atem"]
|
||||
self.cfg, self.snap, self.stop = a, snap, stop
|
||||
self.proc, self.lock, self.fixed = None, threading.Lock(), {}
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
self.script = os.path.join(here, "atem_watch.js")
|
||||
self.ref_path = os.path.join(data_dir or here, "atem-reference.json")
|
||||
self.node, self.library = newest(a["node"]), newest(a["library"])
|
||||
if not self.node or not self.library:
|
||||
raise OSError("Companion's node or its ATEM library not found")
|
||||
xml = os.path.expanduser(a["reference"])
|
||||
if not os.path.isabs(xml):
|
||||
xml = os.path.join(here, xml)
|
||||
with open(self.ref_path, "w") as f:
|
||||
json.dump(atem_reference(xml), f)
|
||||
|
||||
def run(self):
|
||||
while not self.stop.is_set():
|
||||
try:
|
||||
self.proc = subprocess.Popen([self.node, self.script, self.cfg["host"], self.library, self.ref_path],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, bufsize=1)
|
||||
errors = deque(maxlen=5) # read all the time, so a chatty stderr never stalls it
|
||||
threading.Thread(target=lambda p=self.proc, e=errors: [e.append(l) for l in p.stderr],
|
||||
daemon=True).start()
|
||||
for line in self.proc.stdout:
|
||||
if line.startswith("ATEM "):
|
||||
data = json.loads(line[5:])
|
||||
data["time"] = time.time()
|
||||
self.snap.atem = data
|
||||
elif line.startswith("FIXED "):
|
||||
r = json.loads(line[6:])
|
||||
self.fixed[r.get("part")] = r
|
||||
if self.stop.is_set():
|
||||
break
|
||||
except (OSError, ValueError) as e:
|
||||
log.warning("ATEM watcher: %s", e)
|
||||
finally:
|
||||
if self.proc and self.proc.poll() is None:
|
||||
self.proc.terminate()
|
||||
if self.proc and not self.stop.is_set():
|
||||
err = [l.strip() for l in list(locals().get("errors") or []) if l.strip() and "ThreadedClass" not in l]
|
||||
log.warning("ATEM watcher stopped (exit %s)%s", self.proc.poll(), (": " + err[-1][:200]) if err else "")
|
||||
self.snap.atem = None
|
||||
self.stop.wait(5)
|
||||
|
||||
def fix(self, part, timeout=8):
|
||||
"""Send back what differs for "audio" or "camera"; True once the switcher matches again."""
|
||||
with self.lock:
|
||||
if not self.proc or self.proc.poll() is not None:
|
||||
return False
|
||||
self.fixed.pop(part, None)
|
||||
self.proc.stdin.write("fix %s\n" % part)
|
||||
self.proc.stdin.flush()
|
||||
end = time.time() + timeout
|
||||
while time.time() < end:
|
||||
time.sleep(0.5)
|
||||
report = self.snap.atem or {}
|
||||
if part in self.fixed and report.get("connected") and not report.get(part) \
|
||||
and report.get("time", 0) > time.time() - 3:
|
||||
return True
|
||||
r = self.fixed.get(part) or {}
|
||||
log.info("ATEM fix %s: %s", part, r.get("error") or "%s still different" % len((self.snap.atem or {}).get(part, [])))
|
||||
return False
|
||||
|
||||
|
||||
ATEM_WATCHER = None
|
||||
|
||||
|
||||
def check_atem(chk, snap, ctx):
|
||||
"""chk["part"] "audio" or "camera": the switcher must match the restore file."""
|
||||
a = snap.atem
|
||||
if not a or not a.get("connected") or ctx["now"] - a.get("time", 0) > 30:
|
||||
return [(chk["part"], Result(OK))] # watcher not reporting: no verdict
|
||||
diffs = a.get(chk["part"]) or []
|
||||
if diffs:
|
||||
return [(chk["part"], Result(ERR, chk["msg"], info="; ".join(diffs[:3]), named=True))]
|
||||
return [(chk["part"], Result(OK))]
|
||||
|
||||
|
||||
CHECKS["atem"] = check_atem
|
||||
|
||||
|
||||
def start_artnet_listener(snap, stop, port=6454):
|
||||
"""Note when each source last sent Art-Net. Shares the port (SO_REUSEPORT) so it never
|
||||
keeps another program from listening too. Passive: nothing is ever sent."""
|
||||
@@ -2028,7 +2178,7 @@ def load_config(path):
|
||||
for f in (cfg.get("auto_fix") or {}).get("fixes", []):
|
||||
if f not in cfg.get("fixes", {}):
|
||||
raise ValueError("auto_fix: unknown fix %s" % f)
|
||||
if re.search(r"pkill|taskkill|killall", cfg["fixes"][f].get("run", "")):
|
||||
if re.search(r"pkill|taskkill|killall", cfg["fixes"][f].get("run", "")) and not cfg["fixes"][f].get("restart_ok"):
|
||||
raise ValueError("auto_fix: %s closes a running program - that stays a hold" % f)
|
||||
for k in cfg.get("restart_page", {}).get("keys", []):
|
||||
if k["id"] in ids:
|
||||
@@ -2083,6 +2233,15 @@ def start_pollers(cfg, snap, stop, data_dir=None):
|
||||
for t in threads:
|
||||
t.start()
|
||||
threads += start_remote_midi_streams(snap, cfg, stop)
|
||||
if cfg.get("atem") and any(c["type"] == "atem" for t in cfg["tiles"] for c in t.get("checks", [])):
|
||||
global ATEM_WATCHER
|
||||
try:
|
||||
ATEM_WATCHER = AtemWatcher(cfg, snap, data_dir, stop)
|
||||
t = threading.Thread(target=ATEM_WATCHER.run, name="atem", daemon=True)
|
||||
t.start()
|
||||
threads.append(t)
|
||||
except (OSError, ValueError) as e:
|
||||
log.warning("ATEM checks off: %s", e)
|
||||
if any(c["type"] == "artnet" for t in cfg["tiles"] for c in t.get("checks", [])):
|
||||
start_artnet_listener(snap, stop)
|
||||
return threads
|
||||
|
||||
Reference in New Issue
Block a user