2026-10-25 the Resi AJA passed no Dante audio and the panel stayed green (DANTE only sees the card on the network). atem_watch.js now asks the switcher for its Fairlight meters and reports a metered input stuck at the floor (-100 dB); the ATEM key turns red in auto-fix's hours or while a deck records. Differences (audio, camera, silence) are logged when they change, so a fault says what it was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
6.4 KiB
JavaScript
143 lines
6.4 KiB
JavaScript
// 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
|
|
// "levels": {"16": "Resi - RX1"}, "silence_seconds": 30} // inputs whose audio must never go silent
|
|
// Prints a line "ATEM {json}" when the differences change (and every 10 s):
|
|
// {"connected": true, "audio": ["16 Resi..: Off, should be On", ...], "camera": [...],
|
|
// "silence": ["input 16 Resi - RX1: no audio for 30 s"]}
|
|
// Reads lines on stdin: "fix audio" / "fix camera" sends back only the settings that differ.
|
|
// Besides those fixes, the only thing ever sent is the request for the audio meters (as ATEM Software
|
|
// Control's mixer does); nothing on the switcher changes.
|
|
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}
|
|
// Audio meters: the switcher reads -100.00 dB (-10000) on an input with nothing on it at all; a quiet room, a
|
|
// pause or an open mic is far above that. So an input stuck at the floor means its audio is not arriving
|
|
// (2026-10-25: the Resi AJA stopped passing Dante audio and nothing said so).
|
|
const LEVELS = ref.levels || {}
|
|
const FLOOR = -9000
|
|
const heard = {} // input -> when it last had any audio
|
|
let metersAt = 0 // when the switcher last sent meters (none: no verdict)
|
|
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('levelChanged', (l) => {
|
|
if (l.system !== 'fairlight' || l.type !== 'source') return
|
|
metersAt = Date.now()
|
|
const k = String(l.index)
|
|
if (k in LEVELS && Math.max(l.levels.inputLeftLevel, l.levels.inputRightLevel) > FLOOR) heard[k] = Date.now()
|
|
})
|
|
atem.on('connected', () => {
|
|
connected = true
|
|
for (const k of Object.keys(LEVELS)) heard[k] = Date.now() // a fresh start gets the full grace time
|
|
if (Object.keys(LEVELS).length) atem.startFairlightMixerSendLevels().catch(() => {})
|
|
})
|
|
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
|
|
}
|
|
|
|
function silence() {
|
|
const now = Date.now()
|
|
if (!connected || now - metersAt > 5000) return []
|
|
const secs = ref.silence_seconds || 30
|
|
return Object.entries(LEVELS).filter(([k]) => now - (heard[k] || now) >= secs * 1000)
|
|
.map(([k, name]) => `input ${k} ${name}: no audio for ${secs} s or more`)
|
|
}
|
|
|
|
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, silence: silence() }
|
|
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)
|