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:
+26830
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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" },
|
||||
"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\"" },
|
||||
"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)",
|
||||
"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)",
|
||||
@@ -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" }
|
||||
},
|
||||
|
||||
"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": {
|
||||
"windows": [{ "days": ["sun"], "start": "06:30", "end": "13:30" }],
|
||||
"after_seconds": 20, "max_tries": 2, "retry_seconds": 90, "pause_after_network_seconds": 60,
|
||||
"restart_connections": true,
|
||||
"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": {
|
||||
@@ -173,7 +186,9 @@
|
||||
{ "id": "service", "label": "ALARMS", "pos": [3, 0], "kind": "service" },
|
||||
{ "id": "atem", "label": "ATEM", "pos": [3, 1], "checks": [
|
||||
{ "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": "resi", "label": "RESI", "pos": [3, 3], "checks": [
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@ mkdir -p "$DEST"
|
||||
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)"
|
||||
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
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
+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
|
||||
|
||||
@@ -283,6 +283,51 @@ class TestChecks(unittest.TestCase):
|
||||
self.snap.prefs[key] = "1"
|
||||
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):
|
||||
engine = sp.Engine(CONFIG)
|
||||
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):
|
||||
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:
|
||||
@@ -1511,6 +1556,7 @@ class TestAgainstFakeCompanion(unittest.TestCase):
|
||||
def test_end_to_end_loop(self):
|
||||
"""The real run() loop: it writes state and heartbeat, and consumes button commands."""
|
||||
cfg = json.loads(json.dumps(CONFIG))
|
||||
cfg.pop("atem", None) # never connect to the real switcher from a test
|
||||
cfg["companion"]["port"] = self.port
|
||||
# keep it offline: no pings, no web, no launchd, no bonjour lookups
|
||||
cfg["network"]["gateway"] = None
|
||||
|
||||
Reference in New Issue
Block a user