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:
2026-10-11 10:54:59 -04:00
co-authored by Claude Opus 5.5
parent b468b42889
commit 98070f31c4
7 changed files with 27211 additions and 5 deletions
+160 -1
View File
@@ -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