'use strict'; /** * Companion Buttons — server part. * * Shows Bitfocus Companion pages on dashboard tiles, every button exactly as * Companion draws it: colours, text, icons and live feedback included. * ProdDash holds one connection to Companion (companion.js) however many * dashboards are open; browsers only ever talk to these routes. * * Each Companion page is an entry in the + Add-tile picker (tiles()), titled * with the page's name. A tile shows its whole page, or the buttons chosen on * it (the tile's own choosing mode). * * GET /page-options → the pages, for a tile added without one * GET /stream/[//…] * → SSE for one tile: `page` (what to lay out) on * connect and whenever it changes, `button` (one * picture) on connect and on every redraw * GET /state/[//…] * → the same as one snapshot, for a tile that joins * a stream already open on its dashboard * * is Companion's own page id, which stays with the page when pages are * inserted or moved; is a button's row and column on it. Buttons are * followed by place, so a page re-imported from a .companionconfig — every * button gets a new id — keeps showing. A tile holds one stream for all its * buttons: ProdDash carries at most 32 streams per page. * * Read-only by design: nothing here can press a button or change Companion. */ const { createLink, createPageBook, NAV_TYPES } = require('./companion'); const HEARTBEAT_MS = 15000; const LINGER_MS = 30000; // keep a button's subscription this long after its last viewer leaves const DESCRIBE_MS = 3000; // how long to wait for one button's setup const MAX_SPOTS = 64; const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/; const SPOT_RE = /^(-?\d{1,3})\.(-?\d{1,3})$/; let current = null; function endpointOf(v) { const ep = v && typeof v === 'object' ? v : {}; return { host: String(ep.host || '').trim(), port: Number(ep.port) || 8000 }; } function sendJson(res, code, body) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); } /** "/" → { pageId, keys: null } (the whole page); "//0.1/2.3" → { pageId, keys: ['0.1', '2.3'] }. */ function viewFrom(req) { const [pageId, ...rest] = String(req.wildcard || '').replace(/^\//, '').split('/'); if (!PAGE_RE.test(pageId || '') || rest.length > MAX_SPOTS) return null; if (!rest.length) return { pageId, keys: null }; if (!rest.every((k) => SPOT_RE.test(k))) return null; return { pageId, keys: [...new Set(rest)] }; } module.exports = { init({ config, log }) { const { host, port } = endpointOf(config.companion); const pages = createPageBook(); const buttons = new Map(); // controlId → { image, seen, refs, linger, off } const known = new Map(); // controlId → { type } const describing = new Map(); // controlId → Promise const viewers = new Set(); // { res, pageId, keys, spots: [{ key, row, column, target }], sig } let link = null; let heartbeat = null; let status = host ? { state: 'connecting', message: `Connecting to Companion at ${host}:${port}` } : { state: 'error', message: "Enter Companion's address in /admin → Companion Buttons" }; /* — what kind of button each is (read once per button; navigation is never shown) — */ function describe(controlId) { if (!link) return Promise.resolve(null); if (describing.has(controlId)) return describing.get(controlId); const p = new Promise((resolve) => { let off = null; const finish = (info) => { clearTimeout(timer); if (off) off(); off = null; describing.delete(controlId); if (info) known.set(controlId, info); resolve(info); }; const timer = setTimeout(() => finish(null), DESCRIBE_MS); off = link.subscribe( 'controls.watchControl', { controlId }, (data) => { if (!data || data.type !== 'init') return; const cfg = data.config || {}; finish({ type: String(cfg.type || '') }); }, () => finish(null) ); }); describing.set(controlId, p); return p; } /** Learn every button Companion has that is not known yet, a few at a time. */ let learning = false; async function learnAll() { if (learning) return; learning = true; try { for (;;) { const todo = []; for (const p of pages.list()) for (const b of p.buttons) if (!known.has(b.controlId)) todo.push(b.controlId); if (!todo.length || !link || status.state !== 'ok') break; for (let i = 0; i < todo.length; i += 16) { await Promise.all(todo.slice(i, i + 16).map(describe)); } sendAll(); // nav buttons now known: whole-page tiles drop them if (todo.every((id) => !known.has(id))) break; // Companion is not answering; try again on the next change } } finally { learning = false; } } const isNav = (controlId) => NAV_TYPES.has(known.get(controlId)?.type); /* — following a button's picture — */ function watch(controlId) { let b = buttons.get(controlId); if (!b) { b = { image: null, seen: false, refs: 0, linger: null, off: null }; buttons.set(controlId, b); if (link) { b.off = link.subscribe( 'preview.graphics.controlId', { controlId }, (data) => { const image = typeof data === 'string' && data.startsWith('data:image/') ? data : null; const changed = image !== b.image || !b.seen; b.image = image; b.seen = true; // Status buttons redraw every second with the same picture; only a real change goes out. if (changed) sendButton(controlId); }, (message) => log(`button ${controlId}: ${message}`) ); } } clearTimeout(b.linger); b.linger = null; return b; } function release(controlId) { const b = buttons.get(controlId); if (!b || b.refs > 0) return; clearTimeout(b.linger); b.linger = setTimeout(() => { if (b.refs > 0) return; if (b.off) b.off(); buttons.delete(controlId); }, LINGER_MS); } /* — one tile's view of one page — */ /** The buttons a viewer shows right now: its ticked places, or every non-navigation button on the page. */ function spotsFor(viewer, page) { if (!page) return []; const at = new Map(page.buttons.map((b) => [`${b.row}.${b.column}`, b])); const keys = viewer.keys || page.buttons.filter((b) => !isNav(b.controlId)).map((b) => `${b.row}.${b.column}`); return keys.map((key) => { const [row, column] = key.split('.').map(Number); return { key, row, column, target: at.get(key)?.controlId || null }; }); } function pageBody(spots, page) { return { status: status.state, message: status.message, found: Boolean(page) || !pages.loaded, name: page ? page.name : '', number: page ? page.number : 0, spots: spots.map(({ key, row, column }) => ({ key, row, column })), }; } function buttonBody(spot) { const b = spot.target ? buttons.get(spot.target) : null; return { key: spot.key, image: b ? b.image : null, // A place with nothing on it is as answered as it will get. seen: b ? b.seen : pages.loaded, }; } const pageFrame = (viewer, page) => `event: page\ndata: ${JSON.stringify(pageBody(viewer.spots, page))}\n\n`; const buttonFrame = (spot) => `event: button\ndata: ${JSON.stringify(buttonBody(spot))}\n\n`; /** * What a stream would send on connect, for a tile that joined a stream * already open on the page (a remount after a settings change): ProdDash * reuses the connection, so the tile asks for this on `open` instead. */ function snapshot(view) { const page = pages.page(view.pageId); const spots = spotsFor({ keys: view.keys }, page); return { page: pageBody(spots, page), buttons: spots.map(buttonBody) }; } function write(viewer, chunk) { try { viewer.res.write(chunk); } catch { drop(viewer); } } /** Bring a viewer up to date with the page book; send what changed. */ function refresh(viewer, force) { const page = pages.page(viewer.pageId); const next = spotsFor(viewer, page); const before = new Map(viewer.spots.map((s) => [s.key, s.target])); for (const s of next) if (s.target) watch(s.target).refs += 1; for (const s of viewer.spots) { if (!s.target) continue; const b = buttons.get(s.target); if (b) b.refs -= 1; release(s.target); } viewer.spots = next; const sig = JSON.stringify([status, page && page.name, page && page.number, next.map((s) => [s.key, s.target])]); if (!force && sig === viewer.sig) return; viewer.sig = sig; write(viewer, pageFrame(viewer, page)); for (const s of next) if (force || before.get(s.key) !== s.target || !before.has(s.key)) write(viewer, buttonFrame(s)); } function sendAll() { for (const v of viewers) refresh(v, false); } function sendButton(controlId) { for (const v of viewers) for (const s of v.spots) if (s.target === controlId) write(v, buttonFrame(s)); } function drop(viewer) { if (!viewers.delete(viewer)) return; for (const s of viewer.spots) { if (!s.target) continue; const b = buttons.get(s.target); if (b) b.refs -= 1; release(s.target); } } function addViewer(res, view) { const viewer = { res, pageId: view.pageId, keys: view.keys, spots: [], sig: '' }; viewers.add(viewer); refresh(viewer, true); return viewer; } /* — the connection — */ if (host) { link = createLink({ host, port, onStatus(next) { status = next.state === 'ok' && !pages.loaded ? { state: 'connecting', message: 'Connected — reading pages' } : next; if (next.state !== 'ok') log(next.message); sendAll(); }, }); link.subscribe('pages.watch', undefined, (change) => { const fresh = change && change.type === 'init'; pages.apply(change); if (fresh) { status = { state: 'ok', message: `Companion at ${host}:${port} · ${pages.count} pages` }; log(`connected to Companion at ${host}:${port} (${pages.count} pages)`); } // A page renamed, a button added or moved, or a page re-imported changes what tiles show. sendAll(); learnAll(); }); } heartbeat = setInterval(() => { for (const v of viewers) write(v, ': ping\n\n'); }, HEARTBEAT_MS); /** The + picker's entries: one per page that has a button worth showing. */ function tileList() { return pages .list() .filter((p) => p.buttons.some((b) => !isNav(b.controlId))) .map((p) => ({ id: p.id, name: p.name, description: `Companion page ${p.number}`, settings: { page: p.id }, instanceSchema: { layout: { type: 'switch', label: 'Page layout', default: true }, }, })); } current = { get status() { return status; }, pages, addViewer, drop, snapshot, tileList, }; const self = current; return { stop() { clearInterval(heartbeat); for (const v of viewers) { try { v.res.end(); } catch { /* gone */ } } viewers.clear(); for (const b of buttons.values()) clearTimeout(b.linger); buttons.clear(); if (link) link.close(); if (current === self) current = null; }, health() { const shown = new Set(); for (const v of viewers) for (const s of v.spots) if (s.target) shown.add(s.target); const suffix = status.state === 'ok' && shown.size ? ` · showing ${shown.size} button${shown.size === 1 ? '' : 's'}` : ''; return { status: status.state, message: status.message + suffix }; }, }; }, tiles() { return current ? current.tileList() : []; }, routes() { return { 'GET /page-options': (req, res) => { if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' }); return sendJson(res, 200, { options: current.pages.list().map((p) => ({ value: p.id, label: p.name })) }); }, 'GET /state/*': (req, res) => { const view = viewFrom(req); if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' }); if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' }); return sendJson(res, 200, current.snapshot(view)); }, 'GET /stream/*': (req, res) => { const view = viewFrom(req); if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' }); res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); res.write('retry: 3000\n\n'); if (!current) { const body = { status: 'error', message: 'Companion Buttons is not running', found: true, name: '', number: 0, spots: [] }; res.write(`event: page\ndata: ${JSON.stringify(body)}\n\n`); return void res.end(); } const app = current; const viewer = app.addViewer(res, view); try { req.socket.setKeepAlive(true, HEARTBEAT_MS); } catch { /* gone */ } req.on('close', () => app.drop(viewer)); }, }; }, };