'use strict'; /** * Companion Buttons — server part. * * Shows any button from Bitfocus Companion, on any page, 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. * * GET /pages → every page and the buttons on it (the tile's picker) * GET /image/ → one button's current picture, as PNG (picker thumbnails) * GET /stream///… → SSE: `button` for each of those buttons on connect, * then again whenever one redraws or moves * * is Companion's control id without its "bank:" prefix, and each is * page.row.column.id — "19.1.4.t4bp4ZhrQblkr3bPSEBbc" — in the characters * ProdDash's page stream carries. A tile holds one stream for all its buttons: * a page carries at most 32 streams, and a status board can show more buttons * than that. * * Which button a tile means: the control id while Companion still has it * anywhere, so a button that is dragged elsewhere or a page inserted in front * keeps showing the same button; otherwise whatever is at the page, row and * column now, so a page that is re-imported (every button gets a new id) keeps * showing the same place. Every frame says which control and location it * resolved to, and the tile saves that back, so it stays current. * * Read-only by design: nothing here can press a button or change Companion. */ const { createLink, createPageBook } = require('./companion'); const HEARTBEAT_MS = 15000; const LINGER_MS = 30000; // keep a button's subscription this long after its last viewer leaves const FIRST_FRAME_MS = 4000; // how long /image waits for Companion's first picture const MAX_PER_STREAM = 64; const ID_RE = /^[A-Za-z0-9_-]{1,64}$/; const SPOT_RE = /^(\d{1,4})\.(-?\d{1,3})\.(-?\d{1,3})\.([A-Za-z0-9_-]{1,64})$/; 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)); } /** "/19.1.4.t4bp4Z…/2.0.0.QOPCk…" → [{ key, page, row, column, id }], or null if malformed. */ function spotsFrom(req) { const parts = String(req.wildcard || '').replace(/^\//, '').split('/'); if (!parts.length || parts.length > MAX_PER_STREAM) return null; const spots = []; for (const key of new Set(parts)) { const m = SPOT_RE.exec(key); if (!m) return null; spots.push({ key, page: Number(m[1]), row: Number(m[2]), column: Number(m[3]), id: `bank:${m[4]}` }); } return spots; } module.exports = { init({ config, log }) { const { host, port } = endpointOf(config.companion); const pages = createPageBook(); const buttons = new Map(); // controlId → { image, seen, refs, waiters, linger, off } const viewers = new Set(); // { res, spots: [{ key, page, row, column, id, target }] } 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" }; /* — following a button in Companion — */ function watch(controlId) { let b = buttons.get(controlId); if (!b) { b = { image: null, seen: false, refs: 0, waiters: new Set(), 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; for (const done of b.waiters) done(); b.waiters.clear(); // 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; } /** Let go of a button once nothing has looked at it for a while. */ function release(controlId) { const b = buttons.get(controlId); if (!b || b.refs > 0 || b.waiters.size) return; clearTimeout(b.linger); b.linger = setTimeout(() => { if (b.refs > 0 || b.waiters.size) return; if (b.off) b.off(); buttons.delete(controlId); }, LINGER_MS); } /** The control a tile's spot means right now (see the header), or null. */ function resolve(spot) { if (!pages.loaded) return spot.id; if (pages.locate(spot.id)) return spot.id; return pages.at(spot.page, spot.row, spot.column); } function point(spot) { const target = resolve(spot); if (target === spot.target) return false; if (spot.target) { const old = buttons.get(spot.target); if (old) old.refs -= 1; release(spot.target); } spot.target = target; if (target) watch(target).refs += 1; return true; } /* — what a tile is sent — */ function frame(spot) { const b = spot.target ? buttons.get(spot.target) : null; const location = spot.target ? pages.locate(spot.target) : null; const body = { key: spot.key, controlId: spot.target, status: status.state, message: status.message, image: b ? b.image : null, // A spot with nothing there is as answered as it will get. seen: b ? b.seen : pages.loaded, location, }; return `event: button\ndata: ${JSON.stringify(body)}\n\n`; } function write(viewer, chunk) { try { viewer.res.write(chunk); } catch { drop(viewer); } } function sendButton(controlId) { for (const v of viewers) for (const spot of v.spots) if (spot.target === controlId) write(v, frame(spot)); } function sendAll() { for (const v of viewers) { for (const spot of v.spots) { point(spot); write(v, frame(spot)); } } } function drop(viewer) { if (!viewers.delete(viewer)) return; for (const spot of viewer.spots) { if (!spot.target) continue; const b = buttons.get(spot.target); if (b) b.refs -= 1; release(spot.target); } } function addViewer(res, spots) { const viewer = { res, spots: spots.map((s) => ({ ...s, target: null })) }; viewers.add(viewer); for (const spot of viewer.spots) { point(spot); write(viewer, frame(spot)); } return viewer; } 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) => { pages.apply(change); if (change && change.type === 'init') { 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 moved or a page re-imported changes what tiles show. sendAll(); }); } heartbeat = setInterval(() => { for (const v of viewers) write(v, ': ping\n\n'); }, HEARTBEAT_MS); current = { get status() { return status; }, pages, watch, release, addViewer, drop, linked: Boolean(link), }; 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); for (const done of b.waiters) done(); } buttons.clear(); if (link) link.close(); if (current === self) current = null; }, health() { const shown = new Set(); for (const v of viewers) for (const spot of v.spots) if (spot.target) shown.add(spot.target); const suffix = status.state === 'ok' && shown.size ? ` · showing ${shown.size} button${shown.size === 1 ? '' : 's'}` : ''; return { status: status.state, message: status.message + suffix }; }, }; }, routes() { return { 'GET /pages': (req, res) => { if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' }); const { state, message } = current.status; return sendJson(res, 200, { status: state, message, pages: current.pages.list() }); }, 'GET /image/*': (req, res) => { const raw = String(req.wildcard || '').replace(/^\//, ''); if (!ID_RE.test(raw)) return sendJson(res, 400, { error: 'Not a Companion button id' }); if (!current || !current.linked) return sendJson(res, 503, { error: current ? current.status.message : 'Not running' }); const id = `bank:${raw}`; const app = current; const b = app.watch(id); const reply = () => { if (!b.image) { res.writeHead(b.seen ? 404 : 504, { 'Cache-Control': 'no-store' }); res.end(); } else { const png = Buffer.from(b.image.slice(b.image.indexOf(',') + 1), 'base64'); res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store', 'Content-Length': png.length }); res.end(png); } app.release(id); }; if (b.seen) return reply(); let done = false; const finish = () => { if (done) return; done = true; clearTimeout(timer); b.waiters.delete(finish); reply(); }; const timer = setTimeout(finish, FIRST_FRAME_MS); b.waiters.add(finish); req.on('close', () => { if (done) return; done = true; clearTimeout(timer); b.waiters.delete(finish); app.release(id); }); }, 'GET /stream/*': (req, res) => { const spots = spotsFrom(req); if (!spots) return sendJson(res, 400, { error: 'Not a list of Companion 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) { for (const s of spots) { const body = { key: s.key, controlId: null, status: 'error', message: 'Companion Buttons is not running', image: null, seen: false, location: null }; res.write(`event: button\ndata: ${JSON.stringify(body)}\n\n`); } return void res.end(); } const app = current; const viewer = app.addViewer(res, spots); try { req.socket.setKeepAlive(true, HEARTBEAT_MS); } catch { /* gone */ } req.on('close', () => app.drop(viewer)); }, }; }, };