Companion Buttons 1.2.0: back to picking any buttons from any pages, without the header row

1.1.0 made each tile one page, so buttons picked on a second page never
showed, choosing ended on every settings change, and a tile could go blank
after a remount. This goes back to 1.0's model and keeps only the changes
asked for:

- Pick buttons from any pages; each gets a ring and its order number.
- No Page / Clear / Done row: Page is in the gear, the grid icon starts and
  ends choosing, a second click on a button takes it out.
- Choosing survives the remount a gear change causes, so switching Page
  carries straight on.
- A tile joining a stream its dashboard already has open (another tile with
  the same buttons, or itself after a settings change) asks GET /state for
  the snapshot instead of waiting blank.
- Gear: Captions and Page. The per-page + entries are gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 12:09:31 -04:00
co-authored by Claude Opus 5.5
parent 0a6cec88aa
commit a3097481e7
8 changed files with 772 additions and 605 deletions
+165 -195
View File
@@ -3,41 +3,43 @@
/**
* 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.
* 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.
*
* 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 /pages → every page and the buttons on it (choosing)
* GET /page-options → the pages, for the gear's Page
* GET /image/<id> → one button's current picture, as PNG (choosing)
* GET /stream/<b>/<b>/… → SSE: `button` for each of those buttons on connect,
* then again whenever one redraws or moves
* GET /state/<b>/<b>/… → the same as one snapshot, for a tile that joins a
* stream its dashboard already has open
*
* 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
* <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.
*
* <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.
* 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, NAV_TYPES } = require('./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 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})$/;
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;
@@ -51,89 +53,37 @@ function sendJson(res, code, body) {
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)] };
/** "/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, 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 }
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" };
/* — 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 — */
/* — following a button in Companion — */
function watch(controlId) {
let b = buttons.get(controlId);
if (!b) {
b = { image: null, seen: false, refs: 0, linger: null, off: null };
b = { image: null, seen: false, refs: 0, waiters: new Set(), linger: null, off: null };
buttons.set(controlId, b);
if (link) {
b.off = link.subscribe(
@@ -144,6 +94,8 @@ module.exports = {
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);
},
@@ -156,63 +108,64 @@ module.exports = {
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) return;
if (!b || b.refs > 0 || b.waiters.size) return;
clearTimeout(b.linger);
b.linger = setTimeout(() => {
if (b.refs > 0) return;
if (b.refs > 0 || b.waiters.size) 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 };
});
/** 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 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 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;
}
function buttonBody(spot) {
/* — what a tile is sent — */
function body(spot) {
const b = spot.target ? buttons.get(spot.target) : null;
return {
key: spot.key,
controlId: spot.target,
status: status.state,
message: status.message,
image: b ? b.image : null,
// A place with nothing on it is as answered as it will get.
seen: b ? b.seen : pages.loaded,
// A spot with nothing there is as answered as it will get; a button whose
// picture has not arrived yet is not.
seen: b ? b.seen : !spot.target && pages.loaded,
location: spot.target ? pages.locate(spot.target) : null,
};
}
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`;
const frame = (spot) => `event: button\ndata: ${JSON.stringify(body(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.
* What a stream sends on connect, for a tile that joins a stream the
* dashboard already has open — ProdDash reuses the connection when a tile
* remounts after a settings change, so nothing new arrives on its own.
*/
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 snapshot(spots) {
return spots.map((s) => body({ ...s, target: resolve(s) }));
}
function write(viewer, chunk) {
@@ -223,73 +176,59 @@ module.exports = {
}
}
/** 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 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) 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));
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 s of viewer.spots) {
if (!s.target) continue;
const b = buttons.get(s.target);
for (const spot of viewer.spots) {
if (!spot.target) continue;
const b = buttons.get(spot.target);
if (b) b.refs -= 1;
release(s.target);
release(spot.target);
}
}
function addViewer(res, view) {
const viewer = { res, pageId: view.pageId, keys: view.keys, spots: [], sig: '' };
function addViewer(res, spots) {
const viewer = { res, spots: spots.map((s) => ({ ...s, target: null })) };
viewers.add(viewer);
refresh(viewer, true);
for (const spot of viewer.spots) {
point(spot);
write(viewer, frame(spot));
}
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;
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) {
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 added or moved, or a page re-imported changes what tiles show.
// A page renamed, a button moved or a page re-imported changes what tiles show.
sendAll();
learnAll();
});
}
@@ -297,31 +236,17 @@ module.exports = {
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,
watch,
release,
addViewer,
drop,
snapshot,
tileList,
linked: Boolean(link),
};
const self = current;
@@ -336,24 +261,23 @@ module.exports = {
}
}
viewers.clear();
for (const b of buttons.values()) clearTimeout(b.linger);
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 s of v.spots) if (s.target) shown.add(s.target);
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 };
},
};
},
tiles() {
return current ? current.tileList() : [];
},
routes() {
return {
'GET /page-options': (req, res) => {
@@ -362,15 +286,59 @@ module.exports = {
},
'GET /state/*': (req, res) => {
const view = viewFrom(req);
if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' });
const spots = spotsFrom(req);
if (!spots) return sendJson(res, 400, { error: 'Not a list of Companion buttons' });
if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' });
return sendJson(res, 200, current.snapshot(view));
return sendJson(res, 200, { buttons: current.snapshot(spots) });
},
'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 view = viewFrom(req);
if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' });
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',
@@ -379,12 +347,14 @@ module.exports = {
});
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`);
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, view);
const viewer = app.addViewer(res, spots);
try {
req.socket.setKeepAlive(true, HEARTBEAT_MS);
} catch {