/* Companion Buttons — the connection to Companion. * * Companion 4 and 5 have no HTTP route that returns a button's picture. Its own * web buttons and emulator get them from the tRPC API on the web interface port * (`ws://:/trpc`), which streams a PNG every time a button redraws. * This is a client for exactly the two read-only subscriptions this module * needs, over Node's built-in WebSocket: * * pages.watch → every page, its name, and which control * sits at each row/column * preview.graphics.controlId → a button's rendered image, following the * button if it is moved to another page * * Nothing here can press, edit or change anything in Companion. * * The wire format is tRPC 11's websocket protocol without a transformer: * → { id, method: 'subscription', params: { path, input } } * ← { id, result: { type: 'started' | 'data' | 'stopped', data } } * ← { id, error: { message, … } } * → { id, method: 'subscription.stop' } * plus a bare 'PING' every 30 s that must be answered 'PONG' within 5 s, and * `{ method: 'reconnect' }` when Companion is shutting down. */ 'use strict'; const BACKOFF_MS = [1000, 2000, 5000, 10000]; const SILENCE_MS = 75000; // Companion pings every 30 s; two missed means the link is dead const CONNECT_TIMEOUT_MS = 8000; /** * A long-lived connection that survives Companion restarting. Subscriptions * are remembered and sent again after every reconnect, so callers subscribe * once and simply keep receiving data. * * const link = createLink({ host, port, onStatus }); * const off = link.subscribe('pages.watch', undefined, (data) => …); * off(); // stop that subscription * link.close(); // stop everything */ function createLink({ host, port, onStatus = () => {}, WebSocketImpl = globalThis.WebSocket }) { const url = `ws://${host}:${port}/trpc`; const subs = new Map(); // key → { path, input, onData, wireId } let ws = null; let nextId = 1; let nextKey = 1; let attempt = 0; let retryTimer = null; let silenceTimer = null; let connectTimer = null; let closed = false; let status = { state: 'connecting', message: `Connecting to Companion at ${host}:${port}` }; function setStatus(state, message) { if (status.state === state && status.message === message) return; status = { state, message }; onStatus(status); } function send(msg) { if (!ws || ws.readyState !== 1) return; try { ws.send(typeof msg === 'string' ? msg : JSON.stringify(msg)); } catch { /* the close handler deals with it */ } } function start(sub) { sub.wireId = nextId++; send({ id: sub.wireId, method: 'subscription', params: { path: sub.path, input: sub.input } }); } function heard() { clearTimeout(silenceTimer); silenceTimer = setTimeout(() => drop('Companion stopped answering'), SILENCE_MS); } function drop(reason) { const old = ws; ws = null; clearTimeout(silenceTimer); clearTimeout(connectTimer); if (old) { old.onopen = old.onmessage = old.onclose = old.onerror = null; try { old.close(); } catch { /* already gone */ } } for (const sub of subs.values()) sub.wireId = 0; if (closed) return; setStatus('error', `${reason} — retrying`); const wait = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)]; attempt += 1; clearTimeout(retryTimer); retryTimer = setTimeout(connect, wait); } function onMessage(raw) { heard(); const text = typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8'); if (text === 'PING') return send('PONG'); if (text === 'PONG') return; let msg; try { msg = JSON.parse(text); } catch { return; } if (msg && msg.method === 'reconnect') return drop('Companion is restarting'); if (!msg || msg.id === undefined || msg.id === null) return; const sub = [...subs.values()].find((s) => s.wireId === msg.id); if (!sub) return; if (msg.error) { sub.onError(String(msg.error.message || 'Companion refused the request')); return; } if (msg.result && msg.result.type === 'data') sub.onData(msg.result.data); } function connect() { if (closed) return; clearTimeout(retryTimer); if (typeof WebSocketImpl !== 'function') { setStatus('error', 'This ProdDash runs on a Node.js without WebSocket (Node 22 or newer is needed)'); return; } setStatus('connecting', `Connecting to Companion at ${host}:${port}`); let socket; try { socket = new WebSocketImpl(url); } catch (err) { drop(`Cannot reach Companion at ${host}:${port} (${err.message})`); return; } ws = socket; connectTimer = setTimeout(() => { if (ws === socket && socket.readyState !== 1) drop(`No answer from Companion at ${host}:${port}`); }, CONNECT_TIMEOUT_MS); socket.onopen = () => { if (ws !== socket) return; clearTimeout(connectTimer); attempt = 0; heard(); setStatus('ok', `Connected to Companion at ${host}:${port}`); for (const sub of subs.values()) start(sub); }; socket.onmessage = (e) => { if (ws === socket) onMessage(e.data); }; socket.onclose = () => { if (ws === socket) drop(`Lost Companion at ${host}:${port}`); }; socket.onerror = () => { if (ws === socket && socket.readyState !== 1) drop(`Cannot reach Companion at ${host}:${port}`); }; } connect(); return { get status() { return status; }, subscribe(path, input, onData, onError = () => {}) { const key = nextKey++; const sub = { path, input, onData, onError, wireId: 0 }; subs.set(key, sub); if (ws && ws.readyState === 1) start(sub); return () => { if (!subs.delete(key)) return; if (sub.wireId) send({ id: sub.wireId, method: 'subscription.stop' }); }; }, close() { closed = true; clearTimeout(retryTimer); subs.clear(); drop('Closed'); }, }; } /** * Companion's page list, kept current from `pages.watch`. Page numbers are * positions in `order` (1-based), so they follow Companion when pages are * inserted or moved, exactly as its own UI numbers them. */ function createPageBook() { let order = []; const pages = new Map(); // page id → { id, name, controls: { row: { column: controlId } } } function apply(change) { if (!change || typeof change !== 'object') return; if (change.type === 'init') { order = Array.isArray(change.order) ? [...change.order] : []; pages.clear(); for (const [id, page] of Object.entries(change.pages || {})) { if (page) pages.set(id, clonePage(page)); } return; } if (change.type !== 'update') return; for (const page of change.added || []) pages.set(page.id, clonePage(page)); if (Array.isArray(change.updatedOrder)) { order = [...change.updatedOrder]; for (const id of [...pages.keys()]) if (!order.includes(id)) pages.delete(id); } for (const item of change.changes || []) { const page = pages.get(item.id); if (!page) continue; if (item.name !== null && item.name !== undefined) page.name = item.name; for (const { row, column, controlId } of item.controls || []) { if (controlId) { if (!page.controls[row]) page.controls[row] = {}; page.controls[row][column] = controlId; } else if (page.controls[row]) { delete page.controls[row][column]; if (!Object.keys(page.controls[row]).length) delete page.controls[row]; } } } } function clonePage(page) { const controls = {}; for (const [row, cols] of Object.entries(page.controls || {})) controls[row] = { ...cols }; return { id: page.id, name: String(page.name || ''), controls }; } /** Every page in Companion's order, with the buttons it holds. */ function list() { return order .map((id, i) => { const page = pages.get(id); if (!page) return null; const buttons = []; for (const [row, cols] of Object.entries(page.controls)) { for (const [column, controlId] of Object.entries(cols)) { buttons.push({ row: Number(row), column: Number(column), controlId }); } } buttons.sort((a, b) => a.row - b.row || a.column - b.column); return { id, number: i + 1, name: pageName(page.name, i + 1), buttons }; }) .filter(Boolean); } /** Where a control sits right now, or null when it is on no page. */ function locate(controlId) { for (let i = 0; i < order.length; i += 1) { const page = pages.get(order[i]); if (!page) continue; for (const [row, cols] of Object.entries(page.controls)) { for (const [column, id] of Object.entries(cols)) { if (id === controlId) { return { page: i + 1, row: Number(row), column: Number(column), pageName: pageName(page.name, i + 1) }; } } } } return null; } /** The control at a page number, row and column right now, or null. */ function at(number, row, column) { const page = pages.get(order[number - 1]); return (page && page.controls[row] && page.controls[row][column]) || null; } return { apply, list, locate, at, get loaded() { return order.length > 0; }, get count() { return order.length; }, }; } /** * A page as people read it, with its number in front. Companion page names * often carry a literal "\n" so they wrap on a 72 px button ("19\nStatus"); * on one line that is "19 Status". A name that already starts with its page * number is kept as it is, and "PAGE" — what Companion calls a page nobody * named — becomes "Page 24". */ function pageName(raw, number) { const name = String(raw || '') .replace(/\\n|\n/g, ' ') .replace(/\s+/g, ' ') .trim(); if (!name || name.toUpperCase() === 'PAGE') return `Page ${number}`; if (name.startsWith(`${number} `) || name === String(number)) return name; return `${number} ${name}`; } module.exports = { createLink, createPageBook, pageName };