commit 415453ef5f4418c4f1f9ed183d213d6eeb8e320a Author: christian Date: Wed Oct 14 20:12:23 2026 -0400 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 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); +});