A ProdDash module for status buttons and what's happening: pick buttons from any Companion page in the tile's own picker; each is shown exactly as Companion draws it and redraws live. One tile can hold one button or many. - One read-only connection to Companion's tRPC websocket (pages.watch and preview.graphics.controlId), shared by every dashboard; identical redraws are not re-sent. - A tile follows its button by id, and by page/row/column when the id is gone (a re-imported page), saving back what it resolved to. - One SSE stream per tile, within ProdDash's 32-streams-per-page limit. - Red dot and a faded "Companion offline" view when Companion is down; recovers by itself. - Tests against a mock Companion: node test/companion.test.js Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
342 lines
12 KiB
JavaScript
342 lines
12 KiB
JavaScript
'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/<id> → one button's current picture, as PNG (picker thumbnails)
|
|
* GET /stream/<b>/<b>/… → SSE: `button` for each of those buttons on connect,
|
|
* then again whenever one redraws or moves
|
|
*
|
|
* <id> is Companion's control id without its "bank:" prefix, and each <b> 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));
|
|
},
|
|
};
|
|
},
|
|
};
|