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:
@@ -0,0 +1,2 @@
|
|||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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; }
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user