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
+26830
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
// Read-only snapshot of the ATEM switcher's audio (Fairlight) and camera control (CCU) state,
// printed as one JSON line. Uses the atem-connection library inside Companion's ATEM module,
// run with Companion's own node. Nothing is sent that changes the switcher.
//
// node atem_state.js <switcher ip> <path to atem-connection> [seconds to collect CCU values]
const [ip, lib, secs] = process.argv.slice(2)
const { Atem } = require(lib)
const ccu = {}
const atem = new Atem({ disableMultithreaded: true })
atem.on('receivedCommands', (cmds) => {
for (const c of cmds) {
if (c.constructor && c.constructor.rawName === 'CCdP') {
const p = c.properties
const v = p.numberData && p.numberData.length ? p.numberData
: p.boolData && p.boolData.length ? p.boolData
: p.bigintData && p.bigintData.length ? p.bigintData.map(Number) : p.stringData
ccu[`${c.source}/${c.category}/${c.parameter}`] = v
}
}
})
const done = (out, code) => {
process.stdout.write(JSON.stringify(out) + '\n')
atem.disconnect().finally(() => process.exit(code))
setTimeout(() => process.exit(code), 2000)
}
setTimeout(() => done({ error: 'no connection to the switcher' }, 2), 15000)
atem.on('connected', () => {
setTimeout(() => {
const inputs = {}
const fl = (atem.state.fairlight && atem.state.fairlight.inputs) || {}
for (const [id, input] of Object.entries(fl)) {
if (!input) continue
for (const [sid, src] of Object.entries(input.sources || {})) {
if (!src || !src.properties) continue
inputs[`${id}/${sid}`] = { mixOption: src.properties.mixOption, faderGain: src.properties.faderGain }
}
}
done({ model: atem.state.info && atem.state.info.productIdentifier, fairlight: inputs, ccu }, 0)
}, (Number(secs) || 3) * 1000)
})
atem.connect(ip)
+114
View File
@@ -0,0 +1,114 @@
// Status panel: keep a connection to the ATEM switcher and compare its audio (Fairlight) and camera
// control settings with the restore file, every second. Run by status_panel.py with Companion's node:
//
// node atem_watch.js <switcher ip> <path to atem-connection> <reference.json>
//
// reference.json (made by status_panel.py from the ATEM Software Control restore .xml):
// {"audio": {"16/-65280": {"mixOption": 2, "faderGain": 0}, ...},
// "camera": {"2/1/2": [4500, 0], ...}} // camera/category/parameter -> values
// Prints a line "ATEM {json}" when the differences change (and every 10 s):
// {"connected": true, "audio": ["16 Resi..: Off, should be On", ...], "camera": [...]}
// Reads lines on stdin: "fix audio" / "fix camera" sends back only the settings that differ.
// Nothing else is ever sent to the switcher.
const [ip, lib, refPath] = process.argv.slice(2)
const fs = require('fs')
const readline = require('readline')
const { Atem, Commands } = require(lib)
const ref = JSON.parse(fs.readFileSync(refPath, 'utf8'))
const MIX = { 1: 'Off', 2: 'On', 4: 'AFV' }
const ccu = {} // "camera/category/parameter" -> {type, values}
const atem = new Atem({ disableMultithreaded: true })
let connected = false
atem.on('receivedCommands', (cmds) => {
for (const c of cmds) {
if (c.constructor && c.constructor.rawName === 'CCdP') {
const p = c.properties
const values = p.numberData && p.numberData.length ? p.numberData
: p.bigintData && p.bigintData.length ? p.bigintData.map(Number)
: p.boolData && p.boolData.length ? p.boolData.map(Number) : []
ccu[`${c.source}/${c.category}/${c.parameter}`] = { type: p.type, values }
}
}
})
atem.on('connected', () => { connected = true })
atem.on('disconnected', () => { connected = false })
atem.on('error', () => {})
const close = (a, b) => Math.abs(a - b) < 0.002
function audioDiffs() {
const out = []
const fl = (atem.state && atem.state.fairlight && atem.state.fairlight.inputs) || {}
for (const [key, want] of Object.entries(ref.audio || {})) {
const [input, source] = key.split('/')
const src = fl[input] && fl[input].sources && fl[input].sources[source]
if (!src || !src.properties) continue
const have = src.properties
if (have.mixOption !== want.mixOption || (want.faderGain !== undefined && have.faderGain !== want.faderGain)) {
out.push({ key, input: Number(input), source, want, have: { mixOption: have.mixOption, faderGain: have.faderGain },
text: `input ${input}: ${MIX[have.mixOption] || have.mixOption}, should be ${MIX[want.mixOption] || want.mixOption}` +
(have.faderGain !== want.faderGain ? ` (fader ${have.faderGain / 100} dB, should be ${want.faderGain / 100})` : '') })
}
}
return out
}
function cameraDiffs() {
const out = []
for (const [key, want] of Object.entries(ref.camera || {})) {
const have = ccu[key]
if (!have || have.values.length !== want.length) continue
if (!want.every((w, i) => close(w, have.values[i]))) {
const [cam] = key.split('/')
out.push({ key, want, have: have.values, type: have.type, text: `camera ${cam} ${key}: ${have.values.join(',')} should be ${want.join(',')}` })
}
}
return out
}
let last = '', lastAt = 0
function report() {
const state = { connected, audio: connected ? audioDiffs().map((d) => d.text) : [], camera: connected ? cameraDiffs().map((d) => d.text) : [],
camerasSeen: Object.keys(ccu).length }
const s = JSON.stringify(state)
if (s !== last || Date.now() - lastAt > 10000) {
process.stdout.write('ATEM ' + s + '\n')
last = s
lastAt = Date.now()
}
}
async function fix(part) {
if (!connected) return process.stdout.write('FIXED ' + JSON.stringify({ part, sent: 0, error: 'not connected' }) + '\n')
let sent = 0
if (part === 'audio') {
for (const d of audioDiffs()) {
const props = { mixOption: d.want.mixOption }
if (d.want.faderGain !== undefined) props.faderGain = d.want.faderGain
await atem.setFairlightAudioMixerSourceProps(d.input, BigInt(d.source), props)
sent++
}
} else if (part === 'camera') {
for (const d of cameraDiffs()) {
const [source, category, parameter] = d.key.split('/').map(Number)
const isFloat = d.type === 128
const cmd = new Commands.CameraControlCommand(source, category, parameter, {
type: d.type, relative: false, boolData: [], stringData: '', bigintData: [],
numberData: d.want.map((v) => (isFloat ? v : Math.round(v))),
})
await atem.sendCommand(cmd)
sent++
}
}
process.stdout.write('FIXED ' + JSON.stringify({ part, sent }) + '\n')
}
readline.createInterface({ input: process.stdin }).on('line', (line) => {
const [verb, part] = line.trim().split(/\s+/)
if (verb === 'fix') fix(part).catch((e) => process.stdout.write('FIXED ' + JSON.stringify({ part, error: String(e) }) + '\n'))
}).on('close', () => { atem.disconnect().finally(() => process.exit(0)); setTimeout(() => process.exit(0), 2000) })
setInterval(report, 1000)
atem.connect(ip)
+17 -2
View File
@@ -67,6 +67,11 @@
"caption_screen": { "kind": "local", "short": "CAPTION SCREEN", "label": "restart the lobby caption screen (display app only)", "run": "launchctl kickstart -k gui/$(id -u)/org.waterschurch.lobby-captions-display || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.lobby-captions-display.plist" }, "caption_screen": { "kind": "local", "short": "CAPTION SCREEN", "label": "restart the lobby caption screen (display app only)", "run": "launchctl kickstart -k gui/$(id -u)/org.waterschurch.lobby-captions-display || launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/org.waterschurch.lobby-captions-display.plist" },
"solo_afl": { "host": "studiob", "short": "PT SOLO", "label": "set Pro Tools on the Studio B Mac Studio back to Solo Mode AFL + Latch (clicks Options > Solo Mode)", "solo_afl": { "host": "studiob", "short": "PT SOLO", "label": "set Pro Tools on the Studio B Mac Studio back to Solo Mode AFL + Latch (clicks Options > Solo Mode)",
"run": "touch \"$HOME/Library/Application Support/status-panel/solo-mode.fix\"; sleep 3; ! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\"" }, "run": "touch \"$HOME/Library/Application Support/status-panel/solo-mode.fix\"; sleep 3; ! grep -q '\"bad\":true' \"$HOME/Library/Application Support/status-panel/solo-mode.json\"" },
"atem_audio": { "kind": "atem", "part": "audio", "short": "ATEM AUDIO", "label": "set the switcher's audio inputs back to the restore file (Resi on input 16 on, the rest as saved)" },
"atem_camera": { "kind": "atem", "part": "camera", "short": "ATEM CAMERA", "label": "set the cameras' settings back to the restore file (only what differs)" },
"atem_midi": { "kind": "local", "short": "ATEM MIDI", "restart_ok": true,
"label": "turn off MIDI control in ATEM Software Control (quits and reopens the app; the switcher is not affected)",
"run": "was=$(pgrep -f \"ATEM Software Control.app/Contents/MacOS\"); pkill -TERM -f \"ATEM Software Control.app/Contents/MacOS\"; i=0; while pgrep -fq \"ATEM Software Control.app/Contents/MacOS\" && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; defaults write \"com.blackmagic-design.ATEM Software Control\" midiEnabled -bool false; [ -z \"$was\" ] || open -g -a \"ATEM Software Control\"; [ \"$(defaults read \"com.blackmagic-design.ATEM Software Control\" midiEnabled)\" = 0 ]" },
"proddash": { "kind": "local", "short": "PRODDASH", "label": "restart ProdDash (its launcher stops its server cleanly)", "proddash": { "kind": "local", "short": "PRODDASH", "label": "restart ProdDash (its launcher stops its server cleanly)",
"run": "pkill -TERM -x ProdDash; i=0; while pgrep -xq ProdDash && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; open -g /Applications/ProdDash.app" }, "run": "pkill -TERM -x ProdDash; i=0; while pgrep -xq ProdDash && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; open -g /Applications/ProdDash.app" },
"prodcom": { "kind": "local", "short": "PRODCOM", "label": "restart ProdCom (transcription; lobby captions blank for 5-15 s)", "prodcom": { "kind": "local", "short": "PRODCOM", "label": "restart ProdCom (transcription; lobby captions blank for 5-15 s)",
@@ -81,12 +86,20 @@
"label": "turn Companion's connection to that HyperDeck off and on (what gets a restarted deck green); never reboots the deck" } "label": "turn Companion's connection to that HyperDeck off and on (what gets a restarted deck green); never reboots the deck" }
}, },
"atem": {
"host": "10.1.10.2",
"node": "/Applications/Companion [0-9]*.app/Contents/Resources/node-runtimes/node22/bin/node",
"library": "~/Library/Application Support/companion/modules/bmd-atem-*/node_modules/atem-connection",
"reference": "atem-reference.xml"
},
"auto_fix": { "auto_fix": {
"windows": [{ "days": ["sun"], "start": "06:30", "end": "13:30" }], "windows": [{ "days": ["sun"], "start": "06:30", "end": "13:30" }],
"after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60, "after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60,
"restart_connections": true, "restart_connections": true,
"fixes": ["hyperdeck", "tascam_reconnect", "midi_reconnect", "rtpmidi_start", "vista", "arena", "obs", "fixes": ["hyperdeck", "tascam_reconnect", "midi_reconnect", "rtpmidi_start", "vista", "arena", "obs",
"ableton", "translate", "dvs_prop", "dvs_tracks", "dvs_translation", "solo_afl"] "ableton", "translate", "dvs_prop", "dvs_tracks", "dvs_translation", "solo_afl",
"atem_audio", "atem_camera", "atem_midi"]
}, },
"restart_page": { "restart_page": {
@@ -173,7 +186,9 @@
{ "id": "service", "label": "ALARMS", "pos": [3, 0], "kind": "service" }, { "id": "service", "label": "ALARMS", "pos": [3, 0], "kind": "service" },
{ "id": "atem", "label": "ATEM", "pos": [3, 1], "checks": [ { "id": "atem", "label": "ATEM", "pos": [3, 1], "checks": [
{ "type": "conn", "conn": "ATEM" }, { "type": "conn", "conn": "ATEM" },
{ "type": "pref", "domain": "com.blackmagic-design.ATEM Software Control", "key": "midiEnabled", "want": "0", "msg": "ATEM SW MIDI ON" } { "type": "atem", "part": "audio", "msg": "ATEM AUDIO WRONG", "fix": "atem_audio" },
{ "type": "atem", "part": "camera", "msg": "ATEM CAMERA WRONG", "fix": "atem_camera" },
{ "type": "pref", "domain": "com.blackmagic-design.ATEM Software Control", "key": "midiEnabled", "want": "0", "msg": "ATEM SW MIDI ON", "fix": "atem_midi" }
] }, ] },
{ "id": "obs", "label": "OBS", "pos": [3, 2], "checks": [{ "type": "conn", "conn": "OBS-Youtube", "service_only": true, "fix": "obs" }] }, { "id": "obs", "label": "OBS", "pos": [3, 2], "checks": [{ "type": "conn", "conn": "OBS-Youtube", "service_only": true, "fix": "obs" }] },
{ "id": "resi", "label": "RESI", "pos": [3, 3], "checks": [ { "id": "resi", "label": "RESI", "pos": [3, 3], "checks": [
+2 -1
View File
@@ -24,7 +24,8 @@ mkdir -p "$DEST"
if [[ -f "$DEST/config.json" ]] && ! cmp -s "$SRC/config.json" "$DEST/config.json"; then if [[ -f "$DEST/config.json" ]] && ! cmp -s "$SRC/config.json" "$DEST/config.json"; then
cp "$DEST/config.json" "$DEST/config.json.bak-$(date +%Y%m%d-%H%M%S)" cp "$DEST/config.json" "$DEST/config.json.bak-$(date +%Y%m%d-%H%M%S)"
fi fi
cp "$SRC/status_panel.py" "$SRC/coremidi.py" "$SRC/netgear.py" "$SRC/config.json" "$DEST/" cp "$SRC/status_panel.py" "$SRC/coremidi.py" "$SRC/netgear.py" "$SRC/config.json" "$SRC/atem_watch.js" "$DEST/"
[[ -f "$SRC/atem-reference.xml" ]] && cp "$SRC/atem-reference.xml" "$DEST/"
cat > "$PLIST" <<EOF cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
+160 -1
View File
@@ -19,6 +19,7 @@ import calendar
import http.client import http.client
import json import json
import logging import logging
import math
import logging.handlers import logging.handlers
import os import os
import re import re
@@ -150,6 +151,7 @@ class Snapshot:
self.peer_addr = {} # (host or "", session, peer) -> address it was last seen at self.peer_addr = {} # (host or "", session, peer) -> address it was last seen at
self.conn_hosts = {} # Companion connection label -> its "host" setting self.conn_hosts = {} # Companion connection label -> its "host" setting
self.prefs = {} # (preferences domain, key) -> value as "defaults read" prints it 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.midi_time = 0.0
self.mdns = set() # Bonjour _apple-midi._udp instance names self.mdns = set() # Bonjour _apple-midi._udp instance names
self.mdns_time = 0.0 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]) 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") log.info("FIX %s -> Companion connection %s restarted %s", label, conn, "ok" if ok else "FAILED")
return r.returncode == 0 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": if kind == "reconnect":
return reconnect_and_verify(cfg, fix, label) return reconnect_and_verify(cfg, fix, label)
if kind == "midi_connect": 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" 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): def start_artnet_listener(snap, stop, port=6454):
"""Note when each source last sent Art-Net. Shares the port (SO_REUSEPORT) so it never """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.""" 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", []): for f in (cfg.get("auto_fix") or {}).get("fixes", []):
if f not in cfg.get("fixes", {}): if f not in cfg.get("fixes", {}):
raise ValueError("auto_fix: unknown fix %s" % f) 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) raise ValueError("auto_fix: %s closes a running program - that stays a hold" % f)
for k in cfg.get("restart_page", {}).get("keys", []): for k in cfg.get("restart_page", {}).get("keys", []):
if k["id"] in ids: if k["id"] in ids:
@@ -2083,6 +2233,15 @@ def start_pollers(cfg, snap, stop, data_dir=None):
for t in threads: for t in threads:
t.start() t.start()
threads += start_remote_midi_streams(snap, cfg, stop) 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", [])): if any(c["type"] == "artnet" for t in cfg["tiles"] for c in t.get("checks", [])):
start_artnet_listener(snap, stop) start_artnet_listener(snap, stop)
return threads return threads
+47 -1
View File
@@ -283,6 +283,51 @@ class TestChecks(unittest.TestCase):
self.snap.prefs[key] = "1" self.snap.prefs[key] = "1"
self.assertEqual(run_steps(engine, self.snap, self.now + 5, 4)[0]["atem"]["x"], "ATEM\nATEM SW MIDI ON") self.assertEqual(run_steps(engine, self.snap, self.now + 5, 4)[0]["atem"]["x"], "ATEM\nATEM SW MIDI ON")
def test_atem_reference_from_the_restore_file(self):
d = tempfile.mkdtemp()
xml = os.path.join(d, "r.xml")
open(xml, "w").write("""<Profile product="ATEM 4 M/E Constellation 4K Plus"><FairlightAudioMixer>
<AudioInputs><AudioInput id="16"><AudioSources><AudioSource id="-65280" faderGain="0" mixOption="On"/></AudioSources></AudioInput>
<AudioInput id="1301"><AudioSources><AudioSource id="-65280" faderGain="-inf" mixOption="Off"/></AudioSources></AudioInput>
<AudioInput id="7"><AudioSources><AudioSource id="-65280" faderGain="-2.5" mixOption="AudioFollowVideo"/></AudioSources></AudioInput>
</AudioInputs></FairlightAudioMixer><CameraControl>
<Parameter device="2" category="Video" parameter="ManualWhiteBalance" temperature="4500" tint="0"/>
<Parameter device="6" category="ColorCorrection" parameter="LiftAdjust" red="0" green="0" blue="0" luma="0.18994140625"/>
<Parameter device="1" category="Lens" parameter="ApertureNormalised" value="0"/>
<Parameter device="1" property="ApertureCoarse" value="1"/></CameraControl></Profile>""")
ref = sp.atem_reference(xml)
self.assertEqual(ref["audio"], {"16/-65280": {"mixOption": 2, "faderGain": 0},
"1301/-65280": {"mixOption": 1}, # -inf: fader not compared
"7/-65280": {"mixOption": 4, "faderGain": -250}})
self.assertEqual(ref["camera"], {"2/1/2": [4500.0, 0.0], "6/8/0": [0.0, 0.0, 0.0, 0.18994140625]})
def test_atem_audio_and_camera_checks(self):
engine = sp.Engine(CONFIG)
self.snap.atem = {"connected": True, "audio": [], "camera": [], "time": self.now}
self.assertEqual(run_steps(engine, self.snap, self.now, 4)[0]["atem"]["x"], "ATEM\nOK")
self.snap.atem = {"connected": True, "audio": ["input 16: Off, should be On"], "camera": ["camera 2 ..."],
"time": self.now + 5}
tiles, _ = run_steps(engine, self.snap, self.now + 5, 4)
self.assertEqual(tiles["atem"]["x"], "ATEM\nATEM AUDIO WRONG\n+1")
self.assertEqual(sorted(w for _, k, w, _ in engine.fix_plan("atem", self.snap)), ["atem_audio", "atem_camera"])
self.snap.atem = None # watcher not reporting
self.assertEqual(run_steps(engine, self.snap, self.now + 10, 2)[0]["atem"]["x"], "ATEM\nOK")
for f in ("atem_audio", "atem_camera", "atem_midi"):
self.assertIn(f, CONFIG["auto_fix"]["fixes"])
self.assertIn("midiEnabled -bool false", CONFIG["fixes"]["atem_midi"]["run"])
def test_atem_fix_goes_through_the_watcher(self):
class W:
def __init__(self): self.parts = []
def fix(self, part): self.parts.append(part); return True
w, old = W(), sp.ATEM_WATCHER
sp.ATEM_WATCHER = w
try:
self.assertTrue(sp.execute_fix(CONFIG, "atem_camera", {}))
finally:
sp.ATEM_WATCHER = old
self.assertEqual(w.parts, ["camera"])
def test_propresenter_yellow_warning_is_ignored(self): def test_propresenter_yellow_warning_is_ignored(self):
engine = sp.Engine(CONFIG) engine = sp.Engine(CONFIG)
self.snap.conns["Propresenter-API"].update(category="warning", message="Socket closed") self.snap.conns["Propresenter-API"].update(category="warning", message="Socket closed")
@@ -1284,7 +1329,7 @@ class TestFixes(unittest.TestCase):
def test_every_fix_is_valid(self): def test_every_fix_is_valid(self):
for f in CONFIG["fixes"].values(): for f in CONFIG["fixes"].values():
self.assertIn(f.get("kind", "ssh"), ("ssh", "local", "hyperdeck", "midi_connect", "reconnect")) self.assertIn(f.get("kind", "ssh"), ("ssh", "local", "hyperdeck", "midi_connect", "reconnect", "atem"))
class FakeDeck: class FakeDeck:
@@ -1511,6 +1556,7 @@ class TestAgainstFakeCompanion(unittest.TestCase):
def test_end_to_end_loop(self): def test_end_to_end_loop(self):
"""The real run() loop: it writes state and heartbeat, and consumes button commands.""" """The real run() loop: it writes state and heartbeat, and consumes button commands."""
cfg = json.loads(json.dumps(CONFIG)) cfg = json.loads(json.dumps(CONFIG))
cfg.pop("atem", None) # never connect to the real switcher from a test
cfg["companion"]["port"] = self.port cfg["companion"]["port"] = self.port
# keep it offline: no pings, no web, no launchd, no bonjour lookups # keep it offline: no pings, no web, no launchd, no bonjour lookups
cfg["network"]["gateway"] = None cfg["network"]["gateway"] = None