From 415453ef5f4418c4f1f9ed183d213d6eeb8e320a Mon Sep 17 00:00:00 2001 From: christian Date: Wed, 14 Oct 2026 20:12:23 -0400 Subject: [PATCH] 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 --- .gitignore | 2 + README.md | 104 ++++++++++ client.js | 421 +++++++++++++++++++++++++++++++++++++++++ companion.js | 306 ++++++++++++++++++++++++++++++ module.json | 33 ++++ server.js | 341 +++++++++++++++++++++++++++++++++ style.css | 254 +++++++++++++++++++++++++ test/companion-mock.js | 305 +++++++++++++++++++++++++++++ test/companion.test.js | 319 +++++++++++++++++++++++++++++++ 9 files changed, 2085 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 client.js create mode 100644 companion.js create mode 100644 module.json create mode 100644 server.js create mode 100644 style.css create mode 100644 test/companion-mock.js create mode 100644 test/companion.test.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..646ac51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6170d8d --- /dev/null +++ b/README.md @@ -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://:/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/` | One button's current picture as PNG — picker thumbnails | +| `GET /stream//…` | 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. diff --git a/client.js b/client.js new file mode 100644 index 0000000..68e01a4 --- /dev/null +++ b/client.js @@ -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 = ''; + +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 = ''; + }, + }; +} diff --git a/companion.js b/companion.js new file mode 100644 index 0000000..d4afb47 --- /dev/null +++ b/companion.js @@ -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://:/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 }; diff --git a/module.json b/module.json new file mode 100644 index 0000000..9818ee4 --- /dev/null +++ b/module.json @@ -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 + } + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..d1c7ba9 --- /dev/null +++ b/server.js @@ -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/ → one button's current picture, as PNG (picker thumbnails) + * GET /stream///… → SSE: `button` for each of those buttons on connect, + * then again whenever one redraws or moves + * + * is Companion's control id without its "bank:" prefix, and each 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)); + }, + }; + }, +}; diff --git a/style.css b/style.css new file mode 100644 index 0000000..ff1ca4e --- /dev/null +++ b/style.css @@ -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; } diff --git a/test/companion-mock.js b/test/companion-mock.js new file mode 100644 index 0000000..5e49b12 --- /dev/null +++ b/test/companion-mock.js @@ -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 rename page 2 after s seconds (an `update` change) + * --move-after 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}`); + }); +} diff --git a/test/companion.test.js b/test/companion.test.js new file mode 100644 index 0000000..2573c1d --- /dev/null +++ b/test/companion.test.js @@ -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); +});