Companion Buttons 1.0.0: any Companion button from any page, live on a ProdDash tile

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>
This commit is contained in:
2026-10-14 20:12:23 -04:00
co-authored by Claude Opus 5.5
commit 415453ef5f
9 changed files with 2085 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
.DS_Store
node_modules/
+104
View File
@@ -0,0 +1,104 @@
# Companion Buttons
A [ProdDash](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash) module
that shows any button from Bitfocus Companion — any page, any button — on a
dashboard tile, exactly as Companion draws it: colours, text, icons and live
feedback. It is meant for status buttons and anything else that says what is
happening right now.
- **One tile, one or many buttons.** A single button fills the tile; several
share it in whatever grid makes each one biggest, and resizing the tile
re-flows them.
- **Live.** Every time Companion redraws a button (a status turning red, a
tally lighting up), the tile shows it. Identical redraws — a status page that
repaints every second with the same picture — are not re-sent.
- **Read-only.** Nothing in the module can press a button or change anything
in Companion.
## Using it
1. **Admin → Companion Buttons:** enter Companion's address — the machine
running Companion and its web interface port (8000 unless changed in
Companion's settings). Use `127.0.0.1` when Companion runs on the same
machine as ProdDash.
2. **+ Add tile → Companion Buttons.** A new tile opens the picker: choose a
page from the list and tap the buttons to show. The numbers on the picked
buttons are the order they appear in. **Done** shows them.
3. To change the buttons later, use the grid icon in the tile's title bar.
The gear has one switch, **Captions**, which writes the page name under
each button.
Hover a button for its page and Companion location (`page/row/column`).
### What a tile follows
Each picked button is saved with its Companion id **and** its page, row and
column. While Companion still has that button anywhere, the tile follows it,
so dragging it to another spot or inserting a page in front changes nothing.
If the id is gone — a page re-imported from a `.companionconfig` gives every
button a new id — the tile shows whatever is at the saved page, row and column
instead. Either way the tile saves back what it found, so it stays current.
A button that is gone with nothing at its place shows **Not in Companion**,
and the status dot turns amber.
### When Companion is down
The status dot turns red, the tile says **Companion offline** and fades the
last pictures to grey, so nobody reads them as live. ProdDash keeps retrying
(1, 2, 5, then every 10 seconds) and the tile recovers by itself.
## How it works
Companion 4 and 5 have no HTTP route that returns a button's picture. Their
own web buttons and emulator get them from the tRPC websocket on the web
interface port (`ws://<host>:<port>/trpc`). The module's server holds one such
connection for all dashboards and uses two read-only subscriptions:
`pages.watch` (pages, names, which button is where) and
`preview.graphics.controlId` (a button's PNG, on every redraw). That is
Companion's internal API rather than a documented one, so a future Companion
release could change it; it was built and tested against Companion 5.0.7.
| Route | What it serves |
| --- | --- |
| `GET /pages` | Every page and the buttons on it — the picker's list |
| `GET /image/<id>` | One button's current picture as PNG — picker thumbnails |
| `GET /stream/<page.row.column.id>/…` | SSE `button` events for a tile's buttons: all of them on connect, then each redraw or move |
A tile holds one stream for all its buttons, because ProdDash carries at most
32 streams per page and a status board can show more buttons than that.
## Requirements
- ProdDash 1.10.0 or newer.
- Node.js 22 or newer for the built-in WebSocket. The packaged macOS app and
the Windows bundle ship Node 24; on an older Node the admin page says so.
- Companion 4 or newer, reachable from the ProdDash machine.
- No dependencies, nothing to install.
## Files
| File | |
| --- | --- |
| `module.json` | Manifest |
| `server.js` | Routes, button subscriptions, the per-tile streams |
| `companion.js` | The connection to Companion's tRPC websocket and the page list |
| `client.js`, `style.css` | The tile and its picker |
| `test/companion.test.js` | Tests: `node test/companion.test.js` |
| `test/companion-mock.js` | A stand-in Companion: `node test/companion-mock.js 18000`, then point the module at `127.0.0.1` port `18000` |
## Installing
This module lives in its own repository rather than in ProdDash's catalog, so
**Available modules** in the admin page does not list it. Install it the way
the module guide allows for any module: copy this folder (without `.git`) into
the ProdDash data directory as `modules/companion-buttons/` and restart
ProdDash.
| OS | Folder |
| --- | --- |
| macOS | `~/Library/Application Support/ProdDash/modules/companion-buttons/` |
| Windows | `%APPDATA%\ProdDash\modules\companion-buttons\` |
To update, replace the folder's files and restart. Bump `version` in
`module.json` with every change.
+421
View File
@@ -0,0 +1,421 @@
/* Companion Buttons — the tile.
Shows the Companion buttons this tile was given, as Companion draws them,
as large as the tile allows. One button fills the tile; several share it in
whatever grid makes each one biggest. Every picture is live: the server
forwards each redraw (a status button turning red, a tally lighting up).
Buttons are chosen in the tile itself — the grid icon in the title bar
opens a picker that shows any Companion page as it is laid out, and a tap
adds or removes a button. The choice is the tile's own (saved with the
layout), so two tiles can show two different sets. Each choice is kept as
the button's id and its page/row/column; the server follows whichever
still holds, and the tile saves back what it resolved to. */
const GRID_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="9" y="2" width="5" height="5" rx="1"/><rect x="2" y="9" width="5" height="5" rx="1"/><rect x="9" y="9" width="5" height="5" rx="1"/></svg>';
const GAP_PX = 6;
const CAPTION_PX = 18;
const PAGES_RETRY_MS = 5000;
const ID_RE = /^bank:[A-Za-z0-9_-]{1,64}$/;
const MAX_BUTTONS = 64;
/** "bank:QOPCkIuUp5U9" → "QOPCkIuUp5U9": what the server's URLs carry. */
const urlId = (id) => id.slice(5);
/** A chosen button as the server's stream path names it: page.row.column.id */
const spotKey = (s) => `${s.page}.${s.row}.${s.column}.${urlId(s.id)}`;
function validSpot(s) {
return Boolean(s) && ID_RE.test(String(s.id))
&& Number.isInteger(s.page) && s.page >= 1 && s.page <= 9999
&& Number.isInteger(s.row) && Math.abs(s.row) <= 999
&& Number.isInteger(s.column) && Math.abs(s.column) <= 999;
}
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
/** The column count that makes `n` square cells biggest inside w × h. */
function bestGrid(n, w, h, captionPx) {
let best = { cols: 1, size: 0 };
for (let cols = 1; cols <= n; cols += 1) {
const rows = Math.ceil(n / cols);
const byWidth = (w - GAP_PX * (cols - 1)) / cols;
const byHeight = (h - GAP_PX * (rows - 1)) / rows - captionPx;
const size = Math.floor(Math.min(byWidth, byHeight));
if (size > best.size) best = { cols, size };
}
return best;
}
export default function create({ root, moduleApi }) {
const prefs = () => moduleApi.instanceSettings || {};
const chosen = () => (Array.isArray(prefs().buttons) ? prefs().buttons.filter(validSpot).slice(0, MAX_BUTTONS) : []);
const save = (spots) => moduleApi.saveInstanceSettings({
buttons: spots.map(({ id, page, row, column }) => ({ id, page, row, column })),
});
const imageUrl = (id) => `/api/modules/${encodeURIComponent(moduleApi.id)}/image/${urlId(id)}`;
const data = new Map(); // spot key → the server's latest word on it
let stream = null; // one stream for every button on the tile
let streamPath = '';
let lost = false;
let cells = []; // one { cell, face, img, note, caption } per chosen button, in order
let view = null;
let banner = null;
let pickerEl = null;
let pickerPage = 0;
let pickerPages = [];
let pickerTimer = null;
let pickerToken = 0;
let pickButton = null;
let observer = null;
let stopped = false;
/* ── live buttons ─────────────────────────────────────────────────── */
function syncStream() {
const keys = chosen().map(spotKey);
const path = keys.length ? `/stream/${keys.join('/')}` : '';
if (path === streamPath) return;
if (stream) stream.close();
stream = null;
streamPath = path;
lost = false;
for (const key of [...data.keys()]) if (!keys.includes(key)) data.delete(key);
if (!path) return;
stream = moduleApi.sse(path, {
events: {
button(e) {
let msg;
try {
msg = JSON.parse(e.data);
} catch {
return;
}
const index = chosen().findIndex((s) => spotKey(s) === msg.key);
if (index < 0) return;
data.set(msg.key, msg);
lost = false;
paintCell(index);
applyStatus();
adopt(index, msg);
},
},
error() {
lost = true;
applyStatus();
},
});
}
/** Save what the server resolved a button to — a new id after a re-import, a new place after a move. */
function adopt(index, msg) {
const where = msg.location;
if (!msg.controlId || !where) return;
const spots = chosen();
const s = spots[index];
if (s.id === msg.controlId && s.page === where.page && s.row === where.row && s.column === where.column) return;
if (spots.some((o, i) => i !== index && o.id === msg.controlId)) return; // already shown here
spots[index] = { id: msg.controlId, page: where.page, row: where.row, column: where.column };
data.set(spotKey(spots[index]), msg); // keep the picture up while the stream is re-made
save(spots);
syncStream();
}
function applyStatus() {
const spots = chosen();
if (pickerEl && !spots.length) return; // the picker reports while it is the whole tile
if (!spots.length) return moduleApi.setStatus('ok', 'No buttons chosen');
if (lost) return showOffline('Lost the connection to ProdDash');
const list = spots.map((s) => data.get(spotKey(s)));
const bad = list.find((d) => d && d.status === 'error');
if (bad) return showOffline(bad.message || 'Companion is not reachable');
setBanner('');
if (list.some((d) => !d || d.status !== 'ok' || !d.seen)) {
const waiting = list.find((d) => d && d.status !== 'ok');
return moduleApi.setStatus('connecting', waiting ? waiting.message : 'Waiting for Companion');
}
const missing = list.filter((d) => !d.image).length;
if (missing) {
return moduleApi.setStatus('connecting', `${missing} of ${list.length} button${list.length === 1 ? ' is' : 's are'} no longer in Companion`);
}
const msg = list[0].message || 'Companion';
return moduleApi.setStatus('ok', list.length > 1 ? `${msg} · ${list.length} buttons` : msg);
}
function showOffline(message) {
setBanner('Companion offline');
moduleApi.setStatus('error', message);
}
function setBanner(text) {
if (!banner || banner.textContent === text) return;
banner.hidden = !text;
banner.textContent = text;
if (view) view.classList.toggle('is-offline', Boolean(text));
layout(); // the banner takes room from the buttons
}
/* ── drawing the chosen buttons ───────────────────────────────────── */
function buildView() {
root.textContent = '';
cells = [];
const spots = chosen();
const wrap = el('div', 'cb-root');
banner = el('div', 'cb-banner');
banner.hidden = true;
wrap.append(banner);
if (!spots.length) {
view = null;
const empty = el('div', 'cb-empty');
empty.append(el('div', 'cb-empty-line', 'No buttons chosen'));
const choose = el('button', 'cb-action', 'Choose buttons');
choose.type = 'button';
choose.addEventListener('click', openPicker);
empty.append(choose);
wrap.append(empty);
root.append(wrap);
return;
}
view = el('div', 'cb-grid');
view.classList.toggle('has-captions', prefs().captions === true);
spots.forEach((s, index) => {
const cell = el('div', 'cb-cell');
const face = el('div', 'cb-face is-waiting');
const img = el('img', 'cb-img');
img.alt = '';
img.draggable = false;
img.hidden = true;
const note = el('div', 'cb-note');
face.append(img, note);
const caption = el('div', 'cb-caption');
cell.append(face, caption);
view.append(cell);
cells.push({ cell, face, img, note, caption });
paintCell(index);
});
wrap.append(view);
root.append(wrap);
layout();
}
function paintCell(index) {
const c = cells[index];
const s = chosen()[index];
if (!c || !s) return;
const d = data.get(spotKey(s));
const where = d && d.location;
const image = d && d.image;
if (image) {
if (c.img.getAttribute('src') !== image) c.img.src = image;
c.img.hidden = false;
c.note.textContent = '';
} else {
c.img.hidden = true;
c.note.textContent = d && d.seen ? 'Not in Companion' : '';
}
c.face.classList.toggle('is-waiting', !d || !d.seen);
c.face.classList.toggle('is-missing', Boolean(d && d.seen && !image));
c.caption.textContent = where ? where.pageName : '';
const title = where ? `${where.pageName} · ${where.page}/${where.row}/${where.column}` : '';
c.cell.title = title;
c.img.alt = title;
}
function layout() {
if (!view || !cells.length) return;
const captionPx = prefs().captions === true ? CAPTION_PX : 0;
const { cols, size } = bestGrid(cells.length, view.clientWidth, view.clientHeight, captionPx);
view.style.setProperty('--cb-cols', String(cols));
view.style.setProperty('--cb-size', `${Math.max(16, size)}px`);
}
/* ── the picker ───────────────────────────────────────────────────── */
function openPicker() {
if (pickerEl) return;
pickButton?.classList.add('active');
pickerEl = el('div', 'cb-picker');
const head = el('div', 'cb-pick-head');
const select = el('select', 'cb-pick-page');
select.setAttribute('aria-label', 'Page');
const clear = el('button', 'cb-pick-btn', 'Clear');
clear.type = 'button';
const done = el('button', 'cb-pick-btn cb-pick-done', 'Done');
done.type = 'button';
head.append(select, clear, done);
const body = el('div', 'cb-pick-body');
pickerEl.append(head, body);
root.textContent = '';
root.append(pickerEl);
select.addEventListener('change', () => {
pickerPage = Number(select.value) || 0;
drawPage();
});
clear.addEventListener('click', () => {
save([]);
markPicked();
});
done.addEventListener('click', closePicker);
loadPages();
}
function closePicker() {
if (!pickerEl) return;
clearTimeout(pickerTimer);
pickerToken += 1;
pickerEl = null;
pickButton?.classList.remove('active');
syncStream();
buildView();
applyStatus();
}
async function loadPages() {
const token = ++pickerToken;
clearTimeout(pickerTimer);
let reply = null;
try {
const res = await moduleApi.fetch('/pages');
reply = await res.json();
if (!res.ok) throw new Error(reply.error || `ProdDash answered ${res.status}`);
} catch (err) {
reply = { status: 'error', message: err.message || 'ProdDash is not answering', pages: [] };
}
if (stopped || !pickerEl || token !== pickerToken) return;
pickerPages = Array.isArray(reply.pages) ? reply.pages : [];
if (!chosen().length) {
moduleApi.setStatus(reply.status === 'ok' ? 'ok' : reply.status === 'connecting' ? 'connecting' : 'error', reply.message || '');
}
if (!pickerPages.length) {
const body = pickerEl.querySelector('.cb-pick-body');
body.textContent = '';
body.append(el('div', 'cb-pick-note', reply.status === 'ok' ? 'Companion has no pages' : reply.message || 'Companion is not reachable'));
pickerTimer = setTimeout(loadPages, PAGES_RETRY_MS);
return;
}
if (!pickerPages.some((p) => p.number === pickerPage)) pickerPage = startPage();
const select = pickerEl.querySelector('.cb-pick-page');
select.textContent = '';
for (const p of pickerPages) {
const opt = el('option', '', p.name);
opt.value = String(p.number);
select.append(opt);
}
select.value = String(pickerPage);
drawPage();
}
/** The page of the first chosen button, else the first page that has buttons. */
function startPage() {
const first = chosen()[0];
if (first && pickerPages.some((p) => p.number === first.page)) return first.page;
return (pickerPages.find((p) => p.buttons.length) || pickerPages[0]).number;
}
function drawPage() {
if (!pickerEl) return;
const body = pickerEl.querySelector('.cb-pick-body');
body.textContent = '';
const page = pickerPages.find((p) => p.number === pickerPage);
if (!page || !page.buttons.length) {
body.append(el('div', 'cb-pick-note', 'No buttons on this page'));
return;
}
// Companion's grid may start below zero; lay the page out from its own corner.
const rows = page.buttons.map((b) => b.row);
const cols = page.buttons.map((b) => b.column);
const minRow = Math.min(0, ...rows);
const minCol = Math.min(0, ...cols);
const grid = el('div', 'cb-pick-grid');
grid.style.setProperty('--cb-pick-cols', String(Math.max(...cols) - minCol + 1));
for (const b of page.buttons) {
const key = el('button', 'cb-pick-key');
key.type = 'button';
key.dataset.id = b.controlId;
key.style.gridRow = String(b.row - minRow + 1);
key.style.gridColumn = String(b.column - minCol + 1);
key.title = `${page.name} · ${page.number}/${b.row}/${b.column}`;
const img = el('img', 'cb-pick-img');
img.alt = key.title;
img.loading = 'lazy';
img.draggable = false;
img.src = imageUrl(b.controlId);
img.addEventListener('error', () => img.classList.add('is-broken'));
key.append(img, el('span', 'cb-pick-badge'));
key.addEventListener('click', () => toggle({ id: b.controlId, page: page.number, row: b.row, column: b.column }));
grid.append(key);
}
body.append(grid);
markPicked();
}
function toggle(spot) {
const spots = chosen();
const at = spots.findIndex((s) => s.id === spot.id);
if (at >= 0) spots.splice(at, 1);
else if (spots.length < MAX_BUTTONS) spots.push(spot);
save(spots);
markPicked();
}
function markPicked() {
if (!pickerEl) return;
const ids = chosen().map((s) => s.id);
for (const key of pickerEl.querySelectorAll('.cb-pick-key')) {
const at = ids.indexOf(key.dataset.id);
key.classList.toggle('is-picked', at >= 0);
key.querySelector('.cb-pick-badge').textContent = at >= 0 ? String(at + 1) : '';
}
}
/* ── lifecycle ────────────────────────────────────────────────────── */
return {
start() {
stopped = false;
moduleApi.setStatus('connecting', 'Connecting');
pickButton = moduleApi.header.addButton({
icon: GRID_SVG,
title: 'Choose buttons',
onClick: () => (pickerEl ? closePicker() : openPicker()),
});
observer = new ResizeObserver(() => layout());
observer.observe(root);
syncStream();
if (chosen().length) {
buildView();
applyStatus();
} else {
openPicker();
}
},
stop() {
stopped = true;
clearTimeout(pickerTimer);
pickerToken += 1;
if (stream) stream.close();
stream = null;
streamPath = '';
data.clear();
if (observer) observer.disconnect();
observer = null;
pickerEl = null;
view = null;
cells = [];
root.textContent = '';
},
};
}
+306
View File
@@ -0,0 +1,306 @@
/* Companion Buttons — the connection to Companion.
*
* Companion 4 and 5 have no HTTP route that returns a button's picture. Its own
* web buttons and emulator get them from the tRPC API on the web interface port
* (`ws://<host>:<port>/trpc`), which streams a PNG every time a button redraws.
* This is a client for exactly the two read-only subscriptions this module
* needs, over Node's built-in WebSocket:
*
* pages.watch → every page, its name, and which control
* sits at each row/column
* preview.graphics.controlId → a button's rendered image, following the
* button if it is moved to another page
*
* Nothing here can press, edit or change anything in Companion.
*
* The wire format is tRPC 11's websocket protocol without a transformer:
* → { id, method: 'subscription', params: { path, input } }
* ← { id, result: { type: 'started' | 'data' | 'stopped', data } }
* ← { id, error: { message, … } }
* → { id, method: 'subscription.stop' }
* plus a bare 'PING' every 30 s that must be answered 'PONG' within 5 s, and
* `{ method: 'reconnect' }` when Companion is shutting down.
*/
'use strict';
const BACKOFF_MS = [1000, 2000, 5000, 10000];
const SILENCE_MS = 75000; // Companion pings every 30 s; two missed means the link is dead
const CONNECT_TIMEOUT_MS = 8000;
/**
* A long-lived connection that survives Companion restarting. Subscriptions
* are remembered and sent again after every reconnect, so callers subscribe
* once and simply keep receiving data.
*
* const link = createLink({ host, port, onStatus });
* const off = link.subscribe('pages.watch', undefined, (data) => …);
* off(); // stop that subscription
* link.close(); // stop everything
*/
function createLink({ host, port, onStatus = () => {}, WebSocketImpl = globalThis.WebSocket }) {
const url = `ws://${host}:${port}/trpc`;
const subs = new Map(); // key → { path, input, onData, wireId }
let ws = null;
let nextId = 1;
let nextKey = 1;
let attempt = 0;
let retryTimer = null;
let silenceTimer = null;
let connectTimer = null;
let closed = false;
let status = { state: 'connecting', message: `Connecting to Companion at ${host}:${port}` };
function setStatus(state, message) {
if (status.state === state && status.message === message) return;
status = { state, message };
onStatus(status);
}
function send(msg) {
if (!ws || ws.readyState !== 1) return;
try {
ws.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
} catch {
/* the close handler deals with it */
}
}
function start(sub) {
sub.wireId = nextId++;
send({ id: sub.wireId, method: 'subscription', params: { path: sub.path, input: sub.input } });
}
function heard() {
clearTimeout(silenceTimer);
silenceTimer = setTimeout(() => drop('Companion stopped answering'), SILENCE_MS);
}
function drop(reason) {
const old = ws;
ws = null;
clearTimeout(silenceTimer);
clearTimeout(connectTimer);
if (old) {
old.onopen = old.onmessage = old.onclose = old.onerror = null;
try {
old.close();
} catch {
/* already gone */
}
}
for (const sub of subs.values()) sub.wireId = 0;
if (closed) return;
setStatus('error', `${reason} — retrying`);
const wait = BACKOFF_MS[Math.min(attempt, BACKOFF_MS.length - 1)];
attempt += 1;
clearTimeout(retryTimer);
retryTimer = setTimeout(connect, wait);
}
function onMessage(raw) {
heard();
const text = typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf8');
if (text === 'PING') return send('PONG');
if (text === 'PONG') return;
let msg;
try {
msg = JSON.parse(text);
} catch {
return;
}
if (msg && msg.method === 'reconnect') return drop('Companion is restarting');
if (!msg || msg.id === undefined || msg.id === null) return;
const sub = [...subs.values()].find((s) => s.wireId === msg.id);
if (!sub) return;
if (msg.error) {
sub.onError(String(msg.error.message || 'Companion refused the request'));
return;
}
if (msg.result && msg.result.type === 'data') sub.onData(msg.result.data);
}
function connect() {
if (closed) return;
clearTimeout(retryTimer);
if (typeof WebSocketImpl !== 'function') {
setStatus('error', 'This ProdDash runs on a Node.js without WebSocket (Node 22 or newer is needed)');
return;
}
setStatus('connecting', `Connecting to Companion at ${host}:${port}`);
let socket;
try {
socket = new WebSocketImpl(url);
} catch (err) {
drop(`Cannot reach Companion at ${host}:${port} (${err.message})`);
return;
}
ws = socket;
connectTimer = setTimeout(() => {
if (ws === socket && socket.readyState !== 1) drop(`No answer from Companion at ${host}:${port}`);
}, CONNECT_TIMEOUT_MS);
socket.onopen = () => {
if (ws !== socket) return;
clearTimeout(connectTimer);
attempt = 0;
heard();
setStatus('ok', `Connected to Companion at ${host}:${port}`);
for (const sub of subs.values()) start(sub);
};
socket.onmessage = (e) => {
if (ws === socket) onMessage(e.data);
};
socket.onclose = () => {
if (ws === socket) drop(`Lost Companion at ${host}:${port}`);
};
socket.onerror = () => {
if (ws === socket && socket.readyState !== 1) drop(`Cannot reach Companion at ${host}:${port}`);
};
}
connect();
return {
get status() {
return status;
},
subscribe(path, input, onData, onError = () => {}) {
const key = nextKey++;
const sub = { path, input, onData, onError, wireId: 0 };
subs.set(key, sub);
if (ws && ws.readyState === 1) start(sub);
return () => {
if (!subs.delete(key)) return;
if (sub.wireId) send({ id: sub.wireId, method: 'subscription.stop' });
};
},
close() {
closed = true;
clearTimeout(retryTimer);
subs.clear();
drop('Closed');
},
};
}
/**
* Companion's page list, kept current from `pages.watch`. Page numbers are
* positions in `order` (1-based), so they follow Companion when pages are
* inserted or moved, exactly as its own UI numbers them.
*/
function createPageBook() {
let order = [];
const pages = new Map(); // page id → { id, name, controls: { row: { column: controlId } } }
function apply(change) {
if (!change || typeof change !== 'object') return;
if (change.type === 'init') {
order = Array.isArray(change.order) ? [...change.order] : [];
pages.clear();
for (const [id, page] of Object.entries(change.pages || {})) {
if (page) pages.set(id, clonePage(page));
}
return;
}
if (change.type !== 'update') return;
for (const page of change.added || []) pages.set(page.id, clonePage(page));
if (Array.isArray(change.updatedOrder)) {
order = [...change.updatedOrder];
for (const id of [...pages.keys()]) if (!order.includes(id)) pages.delete(id);
}
for (const item of change.changes || []) {
const page = pages.get(item.id);
if (!page) continue;
if (item.name !== null && item.name !== undefined) page.name = item.name;
for (const { row, column, controlId } of item.controls || []) {
if (controlId) {
if (!page.controls[row]) page.controls[row] = {};
page.controls[row][column] = controlId;
} else if (page.controls[row]) {
delete page.controls[row][column];
if (!Object.keys(page.controls[row]).length) delete page.controls[row];
}
}
}
}
function clonePage(page) {
const controls = {};
for (const [row, cols] of Object.entries(page.controls || {})) controls[row] = { ...cols };
return { id: page.id, name: String(page.name || ''), controls };
}
/** Every page in Companion's order, with the buttons it holds. */
function list() {
return order
.map((id, i) => {
const page = pages.get(id);
if (!page) return null;
const buttons = [];
for (const [row, cols] of Object.entries(page.controls)) {
for (const [column, controlId] of Object.entries(cols)) {
buttons.push({ row: Number(row), column: Number(column), controlId });
}
}
buttons.sort((a, b) => a.row - b.row || a.column - b.column);
return { number: i + 1, name: pageName(page.name, i + 1), buttons };
})
.filter(Boolean);
}
/** Where a control sits right now, or null when it is on no page. */
function locate(controlId) {
for (let i = 0; i < order.length; i += 1) {
const page = pages.get(order[i]);
if (!page) continue;
for (const [row, cols] of Object.entries(page.controls)) {
for (const [column, id] of Object.entries(cols)) {
if (id === controlId) {
return { page: i + 1, row: Number(row), column: Number(column), pageName: pageName(page.name, i + 1) };
}
}
}
}
return null;
}
/** The control at a page number, row and column right now, or null. */
function at(number, row, column) {
const page = pages.get(order[number - 1]);
return (page && page.controls[row] && page.controls[row][column]) || null;
}
return {
apply,
list,
locate,
at,
get loaded() {
return order.length > 0;
},
get count() {
return order.length;
},
};
}
/**
* A page as people read it, with its number in front. Companion page names
* often carry a literal "\n" so they wrap on a 72 px button ("19\nStatus");
* on one line that is "19 Status". A name that already starts with its page
* number is kept as it is, and "PAGE" — what Companion calls a page nobody
* named — becomes "Page 24".
*/
function pageName(raw, number) {
const name = String(raw || '')
.replace(/\\n|\n/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!name || name.toUpperCase() === 'PAGE') return `Page ${number}`;
if (name.startsWith(`${number} `) || name === String(number)) return name;
return `${number} ${name}`;
}
module.exports = { createLink, createPageBook, pageName };
+33
View File
@@ -0,0 +1,33 @@
{
"id": "companion-buttons",
"name": "Companion Buttons",
"version": "1.0.0",
"proddash": ">=1.10.0",
"description": "Any Companion button from any page, live as Companion draws it — for status and what's happening",
"permissions": [
{
"kind": "local-network",
"reason": "Reading button pictures from Companion on this network"
}
],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": { "w": 1, "h": 1 },
"defaultSize": { "w": 3, "h": 3 },
"configSchema": {
"companion": {
"type": "endpoint",
"label": "Companion",
"default": { "host": "", "port": 8000 },
"help": "The machine running Bitfocus Companion and its web interface port (8000 unless changed in Companion's settings). 127.0.0.1 when Companion runs on this machine. Companion 4 or newer."
}
},
"instanceSchema": {
"captions": {
"type": "switch",
"label": "Captions",
"default": false
}
}
}
+341
View File
@@ -0,0 +1,341 @@
'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));
},
};
},
};
+254
View File
@@ -0,0 +1,254 @@
/* Companion Buttons — scoped under its tile via [data-module="companion-buttons"],
styled with the shell's CSS variables only. The button pictures carry
Companion's own colours; everything around them follows the theme. */
[data-module="companion-buttons"] .cb-root {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
padding: 6px;
overflow: hidden;
}
/* ── the chosen buttons ─────────────────────────────────────────────── */
[data-module="companion-buttons"] .cb-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: repeat(var(--cb-cols, 1), var(--cb-size, 64px));
grid-auto-rows: max-content;
gap: 6px;
align-content: center;
justify-content: center;
}
[data-module="companion-buttons"] .cb-cell {
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
[data-module="companion-buttons"] .cb-face {
position: relative;
width: var(--cb-size, 64px);
height: var(--cb-size, 64px);
border-radius: 8%;
overflow: hidden;
background: color-mix(in srgb, var(--text) 6%, transparent);
}
[data-module="companion-buttons"] .cb-img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
user-select: none;
}
[data-module="companion-buttons"] .cb-face.is-waiting {
animation: cb-wait 1.4s ease-in-out infinite alternate;
}
@keyframes cb-wait {
from { opacity: 0.35; }
to { opacity: 0.8; }
}
[data-module="companion-buttons"] .cb-face.is-missing {
background: transparent;
outline: 1px dashed var(--border);
outline-offset: -1px;
}
[data-module="companion-buttons"] .cb-note {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 8%;
color: var(--muted);
font-size: clamp(0.6rem, calc(var(--cb-size, 64px) * 0.12), 0.9rem);
line-height: 1.15;
text-align: center;
}
[data-module="companion-buttons"] .cb-note:empty { display: none; }
[data-module="companion-buttons"] .cb-caption {
display: none;
width: var(--cb-size, 64px);
height: 18px;
padding-top: 3px;
color: var(--muted);
font-size: 0.72rem;
line-height: 15px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="companion-buttons"] .cb-grid.has-captions .cb-caption { display: block; }
/* Companion gone: the last pictures stay, faded, so nobody reads them as live. */
[data-module="companion-buttons"] .cb-grid.is-offline .cb-face {
opacity: 0.3;
filter: grayscale(1);
}
[data-module="companion-buttons"] .cb-banner {
flex: none;
margin-bottom: 6px;
padding: 3px 8px;
border-radius: 4px;
background: color-mix(in srgb, var(--danger) 18%, transparent);
color: var(--danger);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.06em;
text-align: center;
text-transform: uppercase;
}
[data-module="companion-buttons"] .cb-banner[hidden] { display: none; }
/* ── nothing chosen ─────────────────────────────────────────────────── */
[data-module="companion-buttons"] .cb-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
}
[data-module="companion-buttons"] .cb-empty-line {
color: var(--muted);
font-size: 0.85rem;
}
[data-module="companion-buttons"] .cb-action,
[data-module="companion-buttons"] .cb-pick-btn {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text);
font: inherit;
font-size: 0.8rem;
cursor: pointer;
}
[data-module="companion-buttons"] .cb-action,
[data-module="companion-buttons"] .cb-pick-done {
border-color: var(--accent);
background: var(--accent);
color: var(--accent-text);
font-weight: 600;
}
/* ── the picker ─────────────────────────────────────────────────────── */
[data-module="companion-buttons"] .cb-picker {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
background: var(--panel);
}
[data-module="companion-buttons"] .cb-pick-head {
flex: none;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 6px;
border-bottom: 1px solid var(--border);
}
[data-module="companion-buttons"] .cb-pick-page {
flex: 1 1 8em;
min-width: 0;
padding: 3px 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 0.8rem;
}
[data-module="companion-buttons"] .cb-pick-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 6px;
}
[data-module="companion-buttons"] .cb-pick-note {
padding: 16px 8px;
color: var(--muted);
font-size: 0.8rem;
text-align: center;
}
[data-module="companion-buttons"] .cb-pick-grid {
display: grid;
grid-template-columns: repeat(var(--cb-pick-cols, 8), minmax(36px, 1fr));
gap: 4px;
}
[data-module="companion-buttons"] .cb-pick-key {
position: relative;
aspect-ratio: 1;
padding: 0;
border: 0;
border-radius: 8%;
overflow: hidden;
background: color-mix(in srgb, var(--text) 6%, transparent);
cursor: pointer;
}
[data-module="companion-buttons"] .cb-pick-img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
[data-module="companion-buttons"] .cb-pick-img.is-broken { visibility: hidden; }
[data-module="companion-buttons"] .cb-pick-key:hover {
outline: 2px solid color-mix(in srgb, var(--accent) 50%, transparent);
outline-offset: -2px;
}
[data-module="companion-buttons"] .cb-pick-key.is-picked {
outline: 3px solid var(--accent);
outline-offset: -3px;
}
[data-module="companion-buttons"] .cb-pick-badge {
position: absolute;
top: 3px;
right: 3px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--accent);
color: var(--accent-text);
font-size: 0.65rem;
font-weight: 700;
line-height: 16px;
text-align: center;
}
[data-module="companion-buttons"] .cb-pick-badge:empty { display: none; }
+305
View File
@@ -0,0 +1,305 @@
'use strict';
/**
* A stand-in for Companion's tRPC websocket, so the module can be built and
* tested without the real thing:
*
* node test/companion-mock.js 18000
*
* then point Companion Buttons at host 127.0.0.1, port 18000 in /admin.
*
* It serves the two subscriptions the module uses — pages.watch and
* preview.graphics.controlId — over a hand-rolled websocket (no deps), with
* three pages. Page 1's first button flips between green and red every two
* seconds, like a status button; page 1's second button redraws the same
* picture every 300 ms, like a button fed a variable that has not changed.
* Every 30 s it sends PING and drops a client that does not answer PONG,
* the way Companion does.
*
* --rename-after <s> rename page 2 after s seconds (an `update` change)
* --move-after <s> move the flipping button to page 2 after s seconds
*/
const http = require('http');
const crypto = require('crypto');
const zlib = require('zlib');
/* ── a solid-colour PNG, with a lighter band so pictures differ ─────── */
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
let c = n;
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
return c >>> 0;
});
function crc32(buf) {
let c = 0xffffffff;
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length);
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(body));
return Buffer.concat([len, body, crc]);
}
function png([r, g, b], size = 72) {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(size, 0);
ihdr.writeUInt32BE(size, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 2; // RGB
const rows = [];
for (let y = 0; y < size; y += 1) {
const row = Buffer.alloc(1 + size * 3);
const band = y > size * 0.4 && y < size * 0.6;
for (let x = 0; x < size; x += 1) {
row[1 + x * 3] = band ? 255 : r;
row[2 + x * 3] = band ? 255 : g;
row[3 + x * 3] = band ? 255 : b;
}
rows.push(row);
}
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const out = Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(Buffer.concat(rows))), chunk('IEND', Buffer.alloc(0))]);
return `data:image/png;base64,${out.toString('base64')}`;
}
/* ── the minimal websocket server (RFC 6455, text frames only) ────────── */
function frame(text) {
const payload = Buffer.from(text, 'utf8');
let head;
if (payload.length < 126) {
head = Buffer.from([0x81, payload.length]);
} else if (payload.length < 65536) {
head = Buffer.alloc(4);
head[0] = 0x81;
head[1] = 126;
head.writeUInt16BE(payload.length, 2);
} else {
head = Buffer.alloc(10);
head[0] = 0x81;
head[1] = 127;
head.writeBigUInt64BE(BigInt(payload.length), 2);
}
return Buffer.concat([head, payload]);
}
function readFrames(state, data, onText, onClose) {
state.buf = Buffer.concat([state.buf, data]);
for (;;) {
const b = state.buf;
if (b.length < 2) return;
const op = b[0] & 0x0f;
let len = b[1] & 0x7f;
let off = 2;
if (len === 126) {
if (b.length < 4) return;
len = b.readUInt16BE(2);
off = 4;
} else if (len === 127) {
if (b.length < 10) return;
len = Number(b.readBigUInt64BE(2));
off = 10;
}
const masked = (b[1] & 0x80) !== 0;
const mask = masked ? b.subarray(off, off + 4) : null;
if (masked) off += 4;
if (b.length < off + len) return;
const payload = Buffer.from(b.subarray(off, off + len));
if (mask) for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
state.buf = b.subarray(off + len);
if (op === 0x8) return onClose();
if (op === 0x1) onText(payload.toString('utf8'));
}
}
/* ── Companion's side ─────────────────────────────────────────────────── */
function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} } = {}) {
const pages = {
p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' } } },
p2: { id: 'p2', name: 'Cameras', controls: { 0: { 0: 'bank:cam1' }, 1: { 3: 'bank:cam2' } } },
p3: { id: 'p3', name: 'PAGE', controls: {} },
};
const order = ['p1', 'p2', 'p3'];
const looks = {
'bank:steady': png([200, 140, 0]),
'bank:blue': png([20, 60, 200]),
'bank:cam1': png([90, 90, 90]),
'bank:cam2': png([120, 0, 120]),
};
const GREEN = png([0, 160, 60]);
const RED = png([200, 20, 20]);
let flipRed = false;
looks['bank:flip'] = GREEN;
const clients = new Set();
const stats = { connections: 0, pongs: 0, subscriptions: 0, stops: 0 };
function send(client, msg) {
if (client.closed) return;
try {
client.socket.write(frame(typeof msg === 'string' ? msg : JSON.stringify(msg)));
} catch {
/* gone */
}
}
function pageUpdate(change) {
for (const c of clients) for (const [id, sub] of c.subs) if (sub.path === 'pages.watch') send(c, { id, result: { type: 'data', data: change } });
}
function redraw(controlId) {
for (const c of clients) {
for (const [id, sub] of c.subs) {
if (sub.path === 'preview.graphics.controlId' && sub.input.controlId === controlId) {
send(c, { id, result: { type: 'data', data: looks[controlId] || null } });
}
}
}
}
function onMessage(client, text) {
if (text === 'PONG') {
client.awaitingPong = false;
stats.pongs += 1;
return;
}
let msg;
try {
msg = JSON.parse(text);
} catch {
return;
}
if (msg.method === 'subscription.stop') {
client.subs.delete(msg.id);
stats.stops += 1;
return send(client, { id: msg.id, result: { type: 'stopped' } });
}
if (msg.method !== 'subscription') return send(client, { id: msg.id, error: { message: 'mock: only subscriptions' } });
const { path, input } = msg.params || {};
if (path !== 'pages.watch' && path !== 'preview.graphics.controlId') {
return send(client, { id: msg.id, error: { message: `No procedure found on path "${path}"` } });
}
stats.subscriptions += 1;
client.subs.set(msg.id, { path, input });
send(client, { id: msg.id, result: { type: 'started' } });
if (path === 'pages.watch') {
send(client, { id: msg.id, result: { type: 'data', data: { type: 'init', order: [...order], pages: JSON.parse(JSON.stringify(pages)) } } });
} else {
send(client, { id: msg.id, result: { type: 'data', data: looks[input.controlId] || null } });
}
}
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('companion mock\n');
});
server.on('upgrade', (req, socket) => {
if (new URL(req.url, 'http://x').pathname !== '/trpc') {
socket.end('HTTP/1.1 404 Not Found\r\n\r\n');
return;
}
const accept = crypto.createHash('sha1').update(`${req.headers['sec-websocket-key']}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64');
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
const client = { socket, subs: new Map(), state: { buf: Buffer.alloc(0) }, closed: false, awaitingPong: false };
clients.add(client);
stats.connections += 1;
log(`client connected (${clients.size})`);
const close = () => {
if (client.closed) return;
client.closed = true;
clients.delete(client);
socket.destroy();
log(`client gone (${clients.size})`);
};
socket.on('data', (d) => readFrames(client.state, d, (t) => onMessage(client, t), close));
socket.on('close', close);
socket.on('error', close);
});
const timers = [
setInterval(() => {
flipRed = !flipRed;
looks['bank:flip'] = flipRed ? RED : GREEN;
redraw('bank:flip');
}, 2000),
setInterval(() => redraw('bank:steady'), 300),
setInterval(() => {
for (const c of clients) {
if (c.awaitingPong) {
log('no PONG — dropping client');
c.socket.destroy();
continue;
}
c.awaitingPong = true;
send(c, 'PING');
}
}, 30000),
];
if (renameAfter) {
timers.push(setTimeout(() => {
pages.p2.name = 'Cameras Renamed';
pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [{ id: 'p2', name: 'Cameras Renamed', controls: [] }] });
}, renameAfter * 1000));
}
if (moveAfter) {
timers.push(setTimeout(() => {
delete pages.p1.controls[0][0];
pages.p2.controls[1][0] = 'bank:flip';
pageUpdate({
type: 'update',
updatedOrder: null,
added: [],
changes: [
{ id: 'p1', name: null, controls: [{ row: 0, column: 0, controlId: null }] },
{ id: 'p2', name: null, controls: [{ row: 1, column: 0, controlId: 'bank:flip' }] },
],
});
}, moveAfter * 1000));
}
return new Promise((resolve) => {
server.listen(port, '127.0.0.1', () => {
resolve({
port: server.address().port,
stats,
clients,
pageUpdate,
redraw,
looks,
pages,
/** Drop every client without a close frame, as a crash or a cable pull would. */
dropAll() {
for (const c of clients) c.socket.destroy();
},
close() {
for (const t of timers) clearInterval(t);
for (const c of clients) c.socket.destroy();
return new Promise((r) => server.close(() => r()));
},
});
});
});
}
module.exports = { startMock, png };
if (require.main === module) {
const args = process.argv.slice(2);
const opt = (name) => {
const i = args.indexOf(name);
return i >= 0 ? Number(args[i + 1]) || 0 : 0;
};
const port = Number(args.find((a) => /^\d+$/.test(a))) || 18000;
startMock(port, { renameAfter: opt('--rename-after'), moveAfter: opt('--move-after'), log: (m) => console.log(`[companion-mock] ${m}`) }).then((m) => {
console.log(`[companion-mock] listening on 127.0.0.1:${m.port} — point Companion Buttons at 127.0.0.1 port ${m.port}`);
});
}
+319
View File
@@ -0,0 +1,319 @@
'use strict';
/**
* Tests for Companion Buttons.
*
* node test/companion.test.js
*
* Plain Node (22 or newer, for the built-in WebSocket), no dependencies;
* exits non-zero on the first failure. The server entry runs against
* test/companion-mock.js, mounted the way ProdDash mounts it, so the routes,
* the reconnect after Companion drops, and the de-duplication of identical
* redraws are all exercised over real sockets.
*/
const assert = require('assert');
const http = require('http');
const { createPageBook, pageName } = require('../companion');
const serverEntry = require('../server');
const { startMock } = require('./companion-mock');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function until(what, fn, ms = 5000) {
const end = Date.now() + ms;
for (;;) {
const v = await fn();
if (v) return v;
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
await sleep(50);
}
}
/* ── ProdDash's route mounting, reduced to what this module uses ──────── */
function mount(table) {
const routes = Object.entries(table).map(([key, fn]) => {
const [method, path] = key.split(' ');
return { method, prefix: path.endsWith('/*') ? path.slice(0, -2) : null, path, fn };
});
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
for (const r of routes) {
if (r.method !== '*' && r.method !== req.method) continue;
if (r.prefix !== null ? url.pathname === r.prefix || url.pathname.startsWith(`${r.prefix}/`) : url.pathname === r.path) {
req.wildcard = r.prefix !== null ? url.pathname.slice(r.prefix.length) : '';
req.search = url.search;
return r.fn(req, res);
}
}
res.writeHead(404);
res.end();
});
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server)));
}
function get(port, path) {
return new Promise((resolve, reject) => {
http.get({ host: '127.0.0.1', port, path }, (res) => {
const parts = [];
res.on('data', (d) => parts.push(d));
res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], body: Buffer.concat(parts) }));
}).on('error', reject);
});
}
/** An SSE client that collects `button` events. */
function listen(port, path) {
const events = [];
let req;
const ready = new Promise((resolve) => {
req = http.get({ host: '127.0.0.1', port, path }, (res) => {
let buf = '';
res.on('data', (d) => {
buf += d.toString('utf8');
let at;
while ((at = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, at);
buf = buf.slice(at + 2);
const name = /^event: (.+)$/m.exec(block);
const data = /^data: (.+)$/m.exec(block);
if (name && data && name[1] === 'button') events.push(JSON.parse(data[1]));
}
});
resolve();
});
});
return { events, ready, close: () => req.destroy(), last: () => events[events.length - 1] };
}
/* ── pure parts ────────────────────────────────────────────────────────── */
function testPageName() {
assert.strictEqual(pageName('19\\nStatus', 19), '19 Status');
assert.strictEqual(pageName('7\\nStudio\\nB', 7), '7 Studio B');
assert.strictEqual(pageName('PAGE', 24), 'Page 24');
assert.strictEqual(pageName('', 3), 'Page 3');
assert.strictEqual(pageName('Cameras', 2), '2 Cameras');
assert.strictEqual(pageName('2', 2), '2');
assert.strictEqual(pageName('20 Restart', 20), '20 Restart');
}
function testPageBook() {
const book = createPageBook();
assert.strictEqual(book.loaded, false);
book.apply({
type: 'init',
order: ['a', 'b'],
pages: {
a: { id: 'a', name: 'One', controls: { 0: { 0: 'bank:x', 1: 'bank:y' } } },
b: { id: 'b', name: 'PAGE', controls: {} },
},
});
assert.deepStrictEqual(book.locate('bank:y'), { page: 1, row: 0, column: 1, pageName: '1 One' });
assert.strictEqual(book.locate('bank:none'), null);
assert.deepStrictEqual(book.list().map((p) => [p.number, p.name, p.buttons.length]), [[1, '1 One', 2], [2, 'Page 2', 0]]);
// a button moves to page 2, page 2 is named
book.apply({
type: 'update',
updatedOrder: null,
added: [],
changes: [
{ id: 'a', name: null, controls: [{ row: 0, column: 1, controlId: null }] },
{ id: 'b', name: 'Two', controls: [{ row: 2, column: 3, controlId: 'bank:y' }] },
],
});
assert.deepStrictEqual(book.locate('bank:y'), { page: 2, row: 2, column: 3, pageName: '2 Two' });
// a page inserted in front renumbers the rest, as Companion does
book.apply({ type: 'update', updatedOrder: ['c', 'a', 'b'], added: [{ id: 'c', name: 'New', controls: {} }], changes: [] });
assert.strictEqual(book.locate('bank:y').page, 3);
assert.strictEqual(book.locate('bank:x').page, 2);
// a page removed
book.apply({ type: 'update', updatedOrder: ['c', 'b'], added: [], changes: [] });
assert.strictEqual(book.locate('bank:x'), null);
assert.strictEqual(book.count, 2);
}
/* ── against the mock ─────────────────────────────────────────────────── */
async function testLive() {
let mock = await startMock(0);
const port = mock.port;
const logs = [];
const config = { companion: { host: '127.0.0.1', port } };
const handle = serverEntry.init({ config, log: (...a) => logs.push(a.join(' ')) });
const app = await mount(serverEntry.routes({ config }));
const at = app.address().port;
try {
await until('Companion connected', () => handle.health().status === 'ok');
assert.match(handle.health().message, /3 pages/);
// pages for the picker
const pages = JSON.parse((await get(at, '/pages')).body);
assert.strictEqual(pages.status, 'ok');
assert.deepStrictEqual(pages.pages.map((p) => p.name), ['1 Status', '2 Cameras', 'Page 3']);
assert.deepStrictEqual(pages.pages[1].buttons, [
{ row: 0, column: 0, controlId: 'bank:cam1' },
{ row: 1, column: 3, controlId: 'bank:cam2' },
]);
// thumbnails
const img = await get(at, '/image/blue');
assert.strictEqual(img.status, 200);
assert.strictEqual(img.type, 'image/png');
assert.deepStrictEqual([...img.body.subarray(1, 4)], [0x50, 0x4e, 0x47]);
assert.strictEqual((await get(at, '/image/gone')).status, 404);
assert.strictEqual((await get(at, '/image/..%2Fetc')).status, 400);
assert.strictEqual((await get(at, `/image/${'x'.repeat(200)}`)).status, 400);
// a button that redraws the same picture every 300 ms sends one event, not five
const steady = listen(at, '/stream/1.0.1.steady');
await steady.ready;
await until('steady first frame', () => steady.last() && steady.last().image);
const settled = steady.events.length;
await sleep(1600);
assert.strictEqual(steady.events.length, settled, 'identical redraws must not be re-sent');
assert.strictEqual(steady.last().key, '1.0.1.steady');
assert.strictEqual(steady.last().controlId, 'bank:steady');
assert.deepStrictEqual(steady.last().location, { page: 1, row: 0, column: 1, pageName: '1 Status' });
// a status button that changes is forwarded
const flip = listen(at, '/stream/1.0.0.flip');
await flip.ready;
await until('flip first frame', () => flip.last() && flip.last().image);
const firstLook = flip.last().image;
await until('flip redraw', () => flip.last().image !== firstLook, 4000);
// one stream carries all of a tile's buttons: each is sent on connect
const pair = listen(at, '/stream/1.0.2.blue/2.1.3.cam2/1.0.2.blue');
await pair.ready;
await until('both buttons', () => new Set(pair.events.filter((e) => e.image).map((e) => e.controlId)).size === 2);
assert.deepStrictEqual([...new Set(pair.events.map((e) => e.key))].sort(), ['1.0.2.blue', '2.1.3.cam2']);
pair.close();
assert.strictEqual((await get(at, `/stream/${Array.from({ length: 65 }, (_, i) => `1.0.${i}.b${i}`).join('/')}`)).status, 400);
assert.strictEqual((await get(at, '/stream/flip')).status, 400);
assert.strictEqual((await get(at, '/stream/1.0.0.bank:flip')).status, 400);
// a button that is gone, with nothing at its place either, says so instead of hanging
const gone = listen(at, '/stream/3.0.0.gone');
await gone.ready;
await until('missing button answered', () => gone.last() && gone.last().seen);
assert.strictEqual(gone.last().image, null);
assert.strictEqual(gone.last().controlId, null);
// an unknown id falls back to what is at its page/row/column
const byPlace = listen(at, '/stream/2.0.0.oldid');
await byPlace.ready;
await until('found by place', () => byPlace.last() && byPlace.last().image);
assert.strictEqual(byPlace.last().controlId, 'bank:cam1');
byPlace.close();
// the button moves to another page in Companion: the tile follows it
mock.pageUpdate({
type: 'update',
updatedOrder: null,
added: [],
changes: [
{ id: 'p1', name: null, controls: [{ row: 0, column: 1, controlId: null }] },
{ id: 'p2', name: null, controls: [{ row: 1, column: 0, controlId: 'bank:steady' }] },
],
});
await until('moved location', () => steady.last().location && steady.last().location.page === 2);
assert.strictEqual(steady.last().controlId, 'bank:steady');
// the page is re-imported: every button gets a new id, the tile keeps its place
mock.looks['bank:blue-new'] = mock.looks['bank:blue'];
const blue = listen(at, '/stream/1.0.2.blue');
await blue.ready;
await until('blue', () => blue.last() && blue.last().image);
mock.pageUpdate({
type: 'update',
updatedOrder: null,
added: [],
changes: [{ id: 'p1', name: null, controls: [{ row: 0, column: 2, controlId: 'bank:blue-new' }] }],
});
await until('re-imported button found by place', () => blue.last().controlId === 'bank:blue-new' && blue.last().image);
blue.close();
// Companion drops: streams say so, then recover by themselves
mock.dropAll();
await until('error reported', () => flip.last().status === 'error');
assert.strictEqual(handle.health().status, 'error');
await until('reconnected', () => flip.last().status === 'ok' && handle.health().status === 'ok', 6000);
await until('pictures again', () => flip.last().image, 4000);
// Companion restarts on the same port (down for a moment, then back)
await mock.close();
await until('error while down', () => handle.health().status === 'error');
mock = await startMock(port);
await until('back after restart', () => handle.health().status === 'ok', 8000);
await until('stream live after restart', () => flip.last().status === 'ok' && flip.last().image, 4000);
// closing a viewer keeps the subscription a little while (remounts are cheap), stop() ends all
steady.close();
flip.close();
gone.close();
await sleep(100);
assert.ok(mock.clients.size === 1, 'one connection to Companion, however many viewers');
} finally {
handle.stop();
await until('Companion connection closed', () => mock.clients.size === 0, 2000).catch(() => {});
assert.strictEqual(mock.clients.size, 0, 'stop() must close the Companion connection');
await mock.close();
await new Promise((r) => app.close(r));
}
}
async function testUnconfigured() {
const handle = serverEntry.init({ config: { companion: { host: '', port: 8000 } }, log: () => {} });
const app = await mount(serverEntry.routes({}));
const at = app.address().port;
try {
assert.strictEqual(handle.health().status, 'error');
assert.match(handle.health().message, /admin/);
const pages = JSON.parse((await get(at, '/pages')).body);
assert.strictEqual(pages.status, 'error');
assert.deepStrictEqual(pages.pages, []);
assert.strictEqual((await get(at, '/image/x')).status, 503);
const s = listen(at, '/stream/1.0.0.x');
await s.ready;
await until('stream answers', () => s.last());
assert.strictEqual(s.last().status, 'error');
s.close();
} finally {
handle.stop();
await new Promise((r) => app.close(r));
}
}
async function testNothingThere() {
// A port nobody listens on: the module reports it, keeps retrying, and stops cleanly.
const handle = serverEntry.init({ config: { companion: { host: '127.0.0.1', port: 9 } }, log: () => {} });
try {
await until('unreachable reported', () => handle.health().status === 'error');
assert.match(handle.health().message, /127\.0\.0\.1:9/);
} finally {
handle.stop();
}
}
(async () => {
if (typeof WebSocket !== 'function') {
console.error('These tests need Node 22 or newer (built-in WebSocket).');
process.exit(1);
}
const tests = [testPageName, testPageBook, testUnconfigured, testNothingThere, testLive];
for (const t of tests) {
await t();
console.log(`ok ${t.name}`);
}
console.log(`all ${tests.length} passed`);
process.exit(0);
})().catch((err) => {
console.error(err);
process.exit(1);
});