Files
companion-buttons/server.js
T
christianandClaude Opus 5.5 0a6cec88aa Companion Buttons 1.1.0: pages in the + picker, buttons chosen on the tile
The picker row (page list, Clear, Done) is gone. Each Companion page is now
its own + entry, titled with the page's name, showing the whole page minus
page up/down. The grid icon switches the tile to choosing: click buttons to
take them out or put them back, click the icon again when done. The gear
holds one switch, Page layout (Companion's rows and columns, or packed).

- Tiles follow their page by Companion's page id and buttons by row/column,
  so inserted pages and re-imported pages keep showing.
- A tile remounted after a settings change asks GET /state for the snapshot
  the already-open stream sent, instead of waiting blank.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:48:24 -04:00

398 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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/<page>[/<r.c>/…]
* → 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/<page>[/<r.c>/…]
* → the same as one snapshot, for a tile that joins
* a stream already open on its dashboard
*
* <page> is Companion's own page id, which stays with the page when pages are
* inserted or moved; <r.c> 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));
}
/** "/<page>" → { pageId, keys: null } (the whole page); "/<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));
},
};
},
};