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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 12:09:31 -04:00
co-authored by Claude Opus 5.5
parent 0a6cec88aa
commit a3097481e7
8 changed files with 772 additions and 605 deletions
+40 -36
View File
@@ -1,11 +1,14 @@
# Companion Buttons
A [ProdDash](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash) module
that shows Bitfocus Companion pages on dashboard tiles — a whole page, or just
the buttons you pick from it — every button 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.
that shows any Companion buttons — from any pages — on a dashboard tile,
exactly as Companion draws them: 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, from any pages.** 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.
@@ -18,27 +21,29 @@ anything else that says what is happening right now.
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:** every Companion page is listed under **Companion
Buttons**. Pick one — "19 Status", say — and the tile shows that page,
titled with its name. Page up/down buttons are left out.
3. **To show only some buttons,** click the grid icon in the tile's title bar.
The whole page appears; click a button to take it out (it fades) or put it
back. Click the icon again when done. Putting every button back means the
whole page again, including buttons added to it later.
4. **The gear** has one switch, **Page layout**: on, the buttons keep
Companion's rows and columns; off, they are packed into whatever grid makes
each one biggest.
2. **+ Add tile → Companion Buttons.** The new tile opens on a Companion page,
laid out as it is in Companion. Click the buttons to show: each gets a ring
and a number, the order it will appear in. Click again to take it out.
3. **Another page:** open the gear and change **Page**. The tile stays in
choosing and shows that page; what you already picked is kept.
4. **Done:** click the grid icon in the title bar. The tile shows your
buttons. Click the icon again any time to add or remove some.
Hover a button for its page and Companion location (`page/row/column`).
The gear has two settings: **Captions** (the page name under each button) and
**Page** (which page choosing shows). Hover a button for its page and Companion
location (`page/row/column`).
### What a tile follows
A tile follows its page by Companion's own page id, so inserting or moving
pages changes nothing. Buttons are followed by their row and column on that
page, so a page re-imported from a `.companionconfig` (every button gets a new
internal id) keeps showing. A chosen place that is emptied in Companion shows
a dashed outline. A page deleted in Companion leaves the tile empty; the
dashboard folds it away.
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
@@ -51,23 +56,22 @@ last pictures to grey, so nobody reads them as live. ProdDash keeps retrying
Companion 4 and 5 have no HTTP route that returns a button's picture. Their
own web buttons and emulator get them from the tRPC websocket on the web
interface port (`ws://<host>:<port>/trpc`). The module's server holds one such
connection for all dashboards and uses three read-only subscriptions:
`pages.watch` (pages, names, which button is where),
`preview.graphics.controlId` (a button's PNG, on every redraw) and
`controls.watchControl` (read once per button, to leave out page up/down).
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.
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 /stream/<page>[/<row.col>/…]` | SSE for one tile: `page` (what to lay out) and `button` (one picture) on connect, then on every change |
| `GET /state/<page>[/<row.col>/…]` | The same as one snapshot, for a tile that joins a stream the dashboard already has open |
| `GET /page-options` | The pages, for a tile added without one (the gear's Page) |
| `GET /stream/<page.row.column.id>/…` | SSE `button` events for a tile's buttons: all of them on connect, then each redraw or move |
| `GET /state/<page.row.column.id>/…` | The same as one snapshot — for a tile that joins a stream its dashboard already has open, as after a settings change |
| `GET /pages` | Every page and the buttons on it — choosing |
| `GET /image/<id>` | One button's current picture as PNG — choosing |
| `GET /page-options` | The pages — the gear's Page |
The + entries come from the server's `tiles()`, one per page. 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.
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
@@ -82,9 +86,9 @@ per page and a status board can show more buttons than that.
| File | |
| --- | --- |
| `module.json` | Manifest |
| `server.js` | + entries, routes, button subscriptions, the per-tile streams |
| `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 choosing mode |
| `client.js`, `style.css` | The tile and its choosing |
| `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` |
+341 -223
View File
@@ -1,24 +1,42 @@
/* Companion Buttons — the tile.
Shows one Companion page, or the buttons of it chosen for this tile, every
button as Companion draws it and as large as the tile allows. Every
Shows the Companion buttons this tile was given, from any pages, as
Companion draws them and 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).
The page comes from the + picker entry the tile was added as (or, for a
tile added without one, the gear's Page). The grid icon in the title bar
switches the tile to choosing: the whole page is shown, the buttons this
tile leaves out faded, and a click puts a button in or takes it out. The
icon again goes back. "Page layout" in the gear keeps Companion's own rows
and columns; off, the buttons are packed into whatever grid makes each
one biggest. */
Choosing: the grid icon in the title bar turns the tile into the Companion
page named by the gear's Page, laid out as it is in Companion. A click on
a button adds it to the tile (it gets a numbered ring, the order it will
show in) or takes it out. Change the gear's Page to pick from another page
— what is already chosen stays. The icon again shows the chosen buttons.
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, so a moved button or a re-imported page keeps showing. */
const GRID_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><rect x="2" y="2" width="5" height="5" rx="1"/><rect x="9" y="2" width="5" height="5" rx="1"/><rect x="2" y="9" width="5" height="5" rx="1"/><rect x="9" y="9" width="5" height="5" rx="1"/></svg>';
const GAP_PX = 6;
const CAPTION_PX = 18;
const PAGES_RETRY_MS = 5000;
const ID_RE = /^bank:[A-Za-z0-9_-]{1,64}$/;
const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/;
const SPOT_RE = /^-?\d{1,3}\.-?\d{1,3}$/;
const MAX_SPOTS = 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) && typeof s === 'object' && 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);
@@ -28,11 +46,13 @@ function el(tag, className, text) {
}
/** The column count that makes `n` square cells biggest inside w × h. */
function packedGrid(n, 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 size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows));
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;
@@ -40,229 +60,88 @@ function packedGrid(n, w, h) {
export default function create({ root, moduleApi }) {
const prefs = () => moduleApi.instanceSettings || {};
const pageId = () => {
const p = String(prefs().page || '');
return PAGE_RE.test(p) ? p : '';
};
/** The chosen buttons as "row.column", or null for the whole page (never chosen). */
const chosen = () => (Array.isArray(prefs().buttons) ? prefs().buttons.map(String).filter((k) => SPOT_RE.test(k)).slice(0, MAX_SPOTS) : null);
const keepLayout = () => choosing || prefs().layout !== false;
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 pictures = new Map(); // spot key → { image, seen }
let page = null; // the server's latest `page` event
let stream = null;
// Choosing survives the remount a gear change causes (picking from another
// Page), so it is kept per tile for this browser tab, not in the closure.
const choosingKey = `companion-buttons:choosing:${moduleApi.instanceId}`;
const readChoosing = () => {
try {
return sessionStorage.getItem(choosingKey) === '1';
} catch {
return false;
}
};
const writeChoosing = (on) => {
try {
if (on) sessionStorage.setItem(choosingKey, '1');
else sessionStorage.removeItem(choosingKey);
} catch {
/* private window: choosing just ends on the next remount */
}
};
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 choosing = false;
let pickButton = null;
let wrap = null;
let cells = []; // one { cell, face, img, note, caption } per chosen button, in order
let view = null;
let banner = null;
let grid = null;
let note = null;
let cells = new Map(); // spot key → { face, img }
let chooser = null; // the page being chosen from, while choosing
let pages = [];
let pagesTimer = null;
let pagesToken = 0;
let pickButton = null;
let observer = null;
let stopped = false;
/* ── status ───────────────────────────────────────────────────────── */
/* ── live buttons ─────────────────────────────────────────────────── */
function applyStatus() {
if (!pageId()) return moduleApi.setStatus('connecting', 'No page chosen');
if (lost) return offline('Lost the connection to ProdDash');
if (!page) return moduleApi.setStatus('connecting', 'Connecting');
if (page.status === 'error') return offline(page.message || 'Companion is not reachable');
setOffline(false);
if (page.status !== 'ok') return moduleApi.setStatus('connecting', page.message || 'Connecting to Companion');
if (!page.found) return moduleApi.setStatus('connecting', 'This page is no longer in Companion');
if (page.spots.some((s) => !pictures.get(s.key)?.seen)) return moduleApi.setStatus('connecting', 'Waiting for Companion');
const where = `${page.message} · page ${page.number}`;
return moduleApi.setStatus('ok', page.spots.length === 1 ? where : `${where} · ${page.spots.length} buttons`);
}
function offline(message) {
setOffline(true);
moduleApi.setStatus('error', message);
}
function setOffline(on) {
if (!banner || banner.hidden === !on) return;
banner.hidden = !on;
wrap.classList.toggle('is-offline', on);
layout(); // the banner takes room from the buttons
}
/* ── drawing ──────────────────────────────────────────────────────── */
function build() {
root.textContent = '';
wrap = el('div', 'cb-root');
banner = el('div', 'cb-banner', 'Companion offline');
banner.hidden = true;
grid = el('div', 'cb-grid');
note = el('div', 'cb-empty');
note.hidden = true;
wrap.append(banner, grid, note);
root.append(wrap);
}
function showNote(text) {
note.textContent = text;
note.hidden = !text;
grid.hidden = Boolean(text);
}
/** Lay out the cells for the spots in the latest `page` event. */
function drawPage() {
const spots = page ? page.spots : [];
wrap.classList.toggle('is-choosing', choosing);
if (!pageId()) return showNote('No page chosen');
if (!choosing && Array.isArray(chosen()) && !chosen().length) return showNote('No buttons chosen');
if (page && !page.found) return showNote(''); // the page is gone: draw nothing, the shell folds the tile away
if (page && page.status === 'ok' && !spots.length) return showNote('No buttons on this page');
showNote('');
const keys = new Set(spots.map((s) => s.key));
for (const [key, c] of cells) {
if (!keys.has(key)) {
c.face.remove();
cells.delete(key);
}
}
const picked = chosen();
for (const s of spots) {
let c = cells.get(s.key);
if (!c) {
const face = el('div', 'cb-face is-waiting');
const img = el('img', 'cb-img');
img.alt = '';
img.draggable = false;
img.hidden = true;
face.append(img);
face.addEventListener('click', () => toggle(s.key));
c = { face, img };
cells.set(s.key, c);
}
c.face.title = `${page.name} · ${page.number}/${s.row}/${s.column}`;
c.face.classList.toggle('is-off', choosing && picked !== null && !picked.includes(s.key));
grid.append(c.face); // re-appending keeps them in the server's order
paint(s.key);
}
layout();
}
function paint(key) {
const c = cells.get(key);
if (!c) return;
const p = pictures.get(key);
if (p && p.image) {
if (c.img.getAttribute('src') !== p.image) c.img.src = p.image;
c.img.hidden = false;
} else {
c.img.hidden = true;
}
c.face.classList.toggle('is-waiting', !p || !p.seen);
c.face.classList.toggle('is-empty', Boolean(p && p.seen && !p.image));
}
function layout() {
if (!grid || !page || !cells.size) return;
const spots = page.spots.filter((s) => cells.has(s.key));
const w = grid.clientWidth;
const h = grid.clientHeight;
if (keepLayout()) {
// Companion's own rows and columns, cropped to the buttons shown.
const minRow = Math.min(...spots.map((s) => s.row));
const minCol = Math.min(...spots.map((s) => s.column));
const rows = Math.max(...spots.map((s) => s.row)) - minRow + 1;
const cols = Math.max(...spots.map((s) => s.column)) - minCol + 1;
const size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows));
grid.style.setProperty('--cb-cols', String(cols));
grid.style.setProperty('--cb-size', `${Math.max(12, size)}px`);
for (const s of spots) {
const face = cells.get(s.key).face;
face.style.gridRow = String(s.row - minRow + 1);
face.style.gridColumn = String(s.column - minCol + 1);
}
} else {
const { cols, size } = packedGrid(spots.length, w, h);
grid.style.setProperty('--cb-cols', String(cols));
grid.style.setProperty('--cb-size', `${Math.max(12, size)}px`);
for (const s of spots) {
const face = cells.get(s.key).face;
face.style.gridRow = '';
face.style.gridColumn = '';
}
}
}
/* ── choosing ─────────────────────────────────────────────────────── */
function setChoosing(on) {
choosing = on;
pickButton?.classList.toggle('active', on);
connect();
drawPage();
}
/** Put a button in or take it out. Every button in again means the whole page, new buttons included. */
function toggle(key) {
if (!choosing || !page) return;
const all = page.spots.map((s) => s.key);
const now = chosen() || all;
const next = now.includes(key) ? now.filter((k) => k !== key) : [...now, key];
const whole = all.every((k) => next.includes(k));
moduleApi.saveInstanceSettings({ buttons: whole ? null : next.slice(0, MAX_SPOTS) });
drawPage();
}
/* ── the stream ───────────────────────────────────────────────────── */
function apply(kind, body) {
if (kind === 'page') {
page = body;
lost = false;
drawPage();
} else {
pictures.set(body.key, { image: body.image || null, seen: Boolean(body.seen) });
paint(body.key);
}
function onButton(msg) {
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);
}
/** Open the stream for what the tile shows now: the whole page while choosing, else the chosen buttons. */
function connect() {
const id = pageId();
const keys = choosing ? null : chosen();
const path = !id || (keys && !keys.length) ? '' : `${id}${keys ? `/${keys.join('/')}` : ''}`;
function syncStream() {
const keys = chosen().map(spotKey);
const path = keys.length ? 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(`/stream/${path}`, {
// The page's connection may already carry this stream (another tile, or
// this one before a remount): ask for what it sent on connect.
// The dashboard may already carry this stream (another tile showing the
// same buttons, or this tile before a remount); then nothing is re-sent
// on its own, so ask for what it sent on connect.
async open() {
try {
const res = await moduleApi.fetch(`/state/${path}`);
if (!res.ok || path !== streamPath) return;
const snap = await res.json();
if (path !== streamPath) return;
apply('page', snap.page);
for (const b of snap.buttons) apply('button', b);
if (path !== streamPath || stopped) return;
for (const msg of snap.buttons || []) if (msg.seen || !data.has(msg.key)) onButton(msg);
} catch {
/* the stream itself will catch up */
/* the stream itself catches up on the next redraw */
}
},
events: {
page(e) {
try {
apply('page', JSON.parse(e.data));
} catch {
/* not JSON */
}
},
button(e) {
try {
apply('button', JSON.parse(e.data));
onButton(JSON.parse(e.data));
} catch {
/* not JSON */
}
@@ -275,34 +154,273 @@ export default function create({ root, moduleApi }) {
});
}
/** 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 (choosing && !spots.length) return; // choosing reports Companion's state itself
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
}
/* ── the chosen buttons ───────────────────────────────────────────── */
function buildView() {
root.textContent = '';
cells = [];
chooser = null;
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;
wrap.append(el('div', 'cb-empty', 'No buttons chosen'));
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`);
}
/* ── choosing ─────────────────────────────────────────────────────── */
function setChoosing(on) {
choosing = on;
writeChoosing(on);
pickButton?.classList.toggle('active', on);
if (on) {
openChooser();
} else {
clearTimeout(pagesTimer);
pagesToken += 1;
syncStream();
buildView();
applyStatus();
}
}
function openChooser() {
root.textContent = '';
cells = [];
view = null;
banner = null;
chooser = el('div', 'cb-chooser');
root.append(chooser);
loadPages();
}
async function loadPages() {
const token = ++pagesToken;
clearTimeout(pagesTimer);
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 || !chooser || token !== pagesToken) return;
pages = Array.isArray(reply.pages) ? reply.pages : [];
moduleApi.setStatus(reply.status === 'ok' ? 'ok' : reply.status === 'connecting' ? 'connecting' : 'error', reply.message || '');
if (!pages.length) {
chooser.textContent = '';
chooser.append(el('div', 'cb-empty', reply.status === 'ok' ? 'Companion has no pages' : reply.message || 'Companion is not reachable'));
pagesTimer = setTimeout(loadPages, PAGES_RETRY_MS);
return;
}
drawChooser();
}
/** The page the gear names; else the page of the first chosen button, or the first page with buttons — saved, so the gear shows it. */
function chooserPage() {
const wanted = String(prefs().page || '');
const named = PAGE_RE.test(wanted) && pages.find((p) => p.id === wanted);
if (named) return named;
const first = chosen()[0];
const fallback = (first && pages.find((p) => p.number === first.page))
|| pages.find((p) => p.buttons.length)
|| pages[0];
moduleApi.saveInstanceSettings({ page: fallback.id });
return fallback;
}
function drawChooser() {
const page = chooserPage();
chooser.textContent = '';
if (!page.buttons.length) {
chooser.append(el('div', 'cb-empty', `${page.name} has no buttons`));
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.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);
}
chooser.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 (!chooser) return;
const ids = chosen().map((s) => s.id);
for (const key of chooser.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() {
build();
if (pageId()) {
pickButton = moduleApi.header.addButton({
icon: GRID_SVG,
title: 'Choose buttons',
onClick: () => setChoosing(!choosing),
});
}
stopped = false;
moduleApi.setStatus('connecting', 'Connecting');
pickButton = moduleApi.header.addButton({
icon: GRID_SVG,
title: 'Choose buttons',
onClick: () => setChoosing(!choosing),
});
observer = new ResizeObserver(() => layout());
observer.observe(grid);
drawPage();
applyStatus();
connect();
observer.observe(root);
syncStream();
// A new tile, or one that was choosing before a gear change, goes (back) to choosing.
setChoosing(readChoosing() || !chosen().length);
},
stop() {
stopped = true;
clearTimeout(pagesTimer);
pagesToken += 1;
if (stream) stream.close();
stream = null;
streamPath = '';
data.clear();
if (observer) observer.disconnect();
observer = null;
pictures.clear();
cells = new Map();
page = null;
chooser = null;
view = null;
cells = [];
root.textContent = '';
},
};
+8 -11
View File
@@ -8,9 +8,8 @@
*
* pages.watch → every page, its name, and which control
* sits at each row/column
* preview.graphics.controlId → a button's rendered image, on every redraw
* controls.watchControl → a button's setup, read once for its kind
* (page up/down buttons are left out)
* 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.
*
@@ -267,16 +266,17 @@ function createPageBook() {
return null;
}
/** One page by Companion's own page id (stable when pages are renumbered), or null. */
function page(id) {
return list().find((p) => p.id === id) || 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,
page,
at,
get loaded() {
return order.length > 0;
},
@@ -303,7 +303,4 @@ function pageName(raw, number) {
return `${number} ${name}`;
}
/** Companion's navigation buttons: page up, page down, the page number. Never shown. */
const NAV_TYPES = new Set(['pageup', 'pagedown', 'pagenum']);
module.exports = { createLink, createPageBook, pageName, NAV_TYPES };
module.exports = { createLink, createPageBook, pageName };
+6 -6
View File
@@ -1,9 +1,9 @@
{
"id": "companion-buttons",
"name": "Companion Buttons",
"version": "1.1.0",
"version": "1.2.0",
"proddash": ">=1.10.0",
"description": "Companion pages and buttons, live as Companion draws them — for status and what's happening",
"description": "Any Companion buttons from any pages, live as Companion draws them — for status and what's happening",
"permissions": [
{
"kind": "local-network",
@@ -14,7 +14,7 @@
"server": "server.js",
"style": "style.css",
"minSize": { "w": 1, "h": 1 },
"defaultSize": { "w": 6, "h": 4 },
"defaultSize": { "w": 4, "h": 4 },
"configSchema": {
"companion": {
"type": "endpoint",
@@ -24,10 +24,10 @@
}
},
"instanceSchema": {
"layout": {
"captions": {
"type": "switch",
"label": "Page layout",
"default": true
"label": "Captions",
"default": false
},
"page": {
"type": "select",
+165 -195
View File
@@ -3,41 +3,43 @@
/**
* Companion Buttons — server part.
*
* Shows Bitfocus Companion pages on dashboard tiles, every button exactly as
* Companion draws it: colours, text, icons and live feedback included.
* ProdDash holds one connection to Companion (companion.js) however many
* dashboards are open; browsers only ever talk to these routes.
* Shows any button from Bitfocus Companion, on any page, exactly as Companion
* draws it: colours, text, icons and live feedback included. ProdDash holds
* one connection to Companion (companion.js) however many dashboards are open;
* browsers only ever talk to these routes.
*
* Each Companion page is an entry in the + Add-tile picker (tiles()), titled
* with the page's name. A tile shows its whole page, or the buttons chosen on
* it (the tile's own choosing mode).
* GET /pages → every page and the buttons on it (choosing)
* GET /page-options → the pages, for the gear's Page
* GET /image/<id> → one button's current picture, as PNG (choosing)
* GET /stream/<b>/<b>/… → SSE: `button` for each of those buttons on connect,
* then again whenever one redraws or moves
* GET /state/<b>/<b>/… → the same as one snapshot, for a tile that joins a
* stream its dashboard already has open
*
* GET /page-options → the pages, for a tile added without one
* GET /stream/<page>[/<r.c>/…]
* → SSE for one tile: `page` (what to lay out) on
* connect and whenever it changes, `button` (one
* picture) on connect and on every redraw
* GET /state/<page>[/<r.c>/…]
* → the same as one snapshot, for a tile that joins
* a stream already open on its dashboard
* <id> is Companion's control id without its "bank:" prefix, and each <b> is
* page.row.column.id — "19.1.4.t4bp4ZhrQblkr3bPSEBbc" — in the characters
* ProdDash's page stream carries. A tile holds one stream for all its buttons:
* a page carries at most 32 streams, and a status board can show more buttons
* than that.
*
* <page> is Companion's own page id, which stays with the page when pages are
* inserted or moved; <r.c> is a button's row and column on it. Buttons are
* followed by place, so a page re-imported from a .companionconfig — every
* button gets a new id — keeps showing. A tile holds one stream for all its
* buttons: ProdDash carries at most 32 streams per page.
* Which button a tile means: the control id while Companion still has it
* anywhere, so a button that is dragged elsewhere or a page inserted in front
* keeps showing the same button; otherwise whatever is at the page, row and
* column now, so a page that is re-imported (every button gets a new id) keeps
* showing the same place. Every frame says which control and location it
* resolved to, and the tile saves that back, so it stays current.
*
* Read-only by design: nothing here can press a button or change Companion.
*/
const { createLink, createPageBook, NAV_TYPES } = require('./companion');
const { createLink, createPageBook } = require('./companion');
const HEARTBEAT_MS = 15000;
const LINGER_MS = 30000; // keep a button's subscription this long after its last viewer leaves
const DESCRIBE_MS = 3000; // how long to wait for one button's setup
const MAX_SPOTS = 64;
const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/;
const SPOT_RE = /^(-?\d{1,3})\.(-?\d{1,3})$/;
const LINGER_MS = 30000; // keep a button's subscription this long after its last viewer leaves
const FIRST_FRAME_MS = 4000; // how long /image waits for Companion's first picture
const MAX_PER_STREAM = 64;
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
const SPOT_RE = /^(\d{1,4})\.(-?\d{1,3})\.(-?\d{1,3})\.([A-Za-z0-9_-]{1,64})$/;
let current = null;
@@ -51,89 +53,37 @@ function sendJson(res, code, body) {
res.end(JSON.stringify(body));
}
/** "/<page>" → { pageId, keys: null } (the whole page); "/<page>/0.1/2.3" → { pageId, keys: ['0.1', '2.3'] }. */
function viewFrom(req) {
const [pageId, ...rest] = String(req.wildcard || '').replace(/^\//, '').split('/');
if (!PAGE_RE.test(pageId || '') || rest.length > MAX_SPOTS) return null;
if (!rest.length) return { pageId, keys: null };
if (!rest.every((k) => SPOT_RE.test(k))) return null;
return { pageId, keys: [...new Set(rest)] };
/** "/19.1.4.t4bp4Z…/2.0.0.QOPCk…" → [{ key, page, row, column, id }], or null if malformed. */
function spotsFrom(req) {
const parts = String(req.wildcard || '').replace(/^\//, '').split('/');
if (!parts.length || parts.length > MAX_PER_STREAM) return null;
const spots = [];
for (const key of new Set(parts)) {
const m = SPOT_RE.exec(key);
if (!m) return null;
spots.push({ key, page: Number(m[1]), row: Number(m[2]), column: Number(m[3]), id: `bank:${m[4]}` });
}
return spots;
}
module.exports = {
init({ config, log }) {
const { host, port } = endpointOf(config.companion);
const pages = createPageBook();
const buttons = new Map(); // controlId → { image, seen, refs, linger, off }
const known = new Map(); // controlId → { type }
const describing = new Map(); // controlId → Promise
const viewers = new Set(); // { res, pageId, keys, spots: [{ key, row, column, target }], sig }
const buttons = new Map(); // controlId → { image, seen, refs, waiters, linger, off }
const viewers = new Set(); // { res, spots: [{ key, page, row, column, id, target }] }
let link = null;
let heartbeat = null;
let status = host
? { state: 'connecting', message: `Connecting to Companion at ${host}:${port}` }
: { state: 'error', message: "Enter Companion's address in /admin → Companion Buttons" };
/* — what kind of button each is (read once per button; navigation is never shown) — */
function describe(controlId) {
if (!link) return Promise.resolve(null);
if (describing.has(controlId)) return describing.get(controlId);
const p = new Promise((resolve) => {
let off = null;
const finish = (info) => {
clearTimeout(timer);
if (off) off();
off = null;
describing.delete(controlId);
if (info) known.set(controlId, info);
resolve(info);
};
const timer = setTimeout(() => finish(null), DESCRIBE_MS);
off = link.subscribe(
'controls.watchControl',
{ controlId },
(data) => {
if (!data || data.type !== 'init') return;
const cfg = data.config || {};
finish({ type: String(cfg.type || '') });
},
() => finish(null)
);
});
describing.set(controlId, p);
return p;
}
/** Learn every button Companion has that is not known yet, a few at a time. */
let learning = false;
async function learnAll() {
if (learning) return;
learning = true;
try {
for (;;) {
const todo = [];
for (const p of pages.list()) for (const b of p.buttons) if (!known.has(b.controlId)) todo.push(b.controlId);
if (!todo.length || !link || status.state !== 'ok') break;
for (let i = 0; i < todo.length; i += 16) {
await Promise.all(todo.slice(i, i + 16).map(describe));
}
sendAll(); // nav buttons now known: whole-page tiles drop them
if (todo.every((id) => !known.has(id))) break; // Companion is not answering; try again on the next change
}
} finally {
learning = false;
}
}
const isNav = (controlId) => NAV_TYPES.has(known.get(controlId)?.type);
/* — following a button's picture — */
/* — following a button in Companion — */
function watch(controlId) {
let b = buttons.get(controlId);
if (!b) {
b = { image: null, seen: false, refs: 0, linger: null, off: null };
b = { image: null, seen: false, refs: 0, waiters: new Set(), linger: null, off: null };
buttons.set(controlId, b);
if (link) {
b.off = link.subscribe(
@@ -144,6 +94,8 @@ module.exports = {
const changed = image !== b.image || !b.seen;
b.image = image;
b.seen = true;
for (const done of b.waiters) done();
b.waiters.clear();
// Status buttons redraw every second with the same picture; only a real change goes out.
if (changed) sendButton(controlId);
},
@@ -156,63 +108,64 @@ module.exports = {
return b;
}
/** Let go of a button once nothing has looked at it for a while. */
function release(controlId) {
const b = buttons.get(controlId);
if (!b || b.refs > 0) return;
if (!b || b.refs > 0 || b.waiters.size) return;
clearTimeout(b.linger);
b.linger = setTimeout(() => {
if (b.refs > 0) return;
if (b.refs > 0 || b.waiters.size) return;
if (b.off) b.off();
buttons.delete(controlId);
}, LINGER_MS);
}
/* — one tile's view of one page — */
/** The buttons a viewer shows right now: its ticked places, or every non-navigation button on the page. */
function spotsFor(viewer, page) {
if (!page) return [];
const at = new Map(page.buttons.map((b) => [`${b.row}.${b.column}`, b]));
const keys = viewer.keys || page.buttons.filter((b) => !isNav(b.controlId)).map((b) => `${b.row}.${b.column}`);
return keys.map((key) => {
const [row, column] = key.split('.').map(Number);
return { key, row, column, target: at.get(key)?.controlId || null };
});
/** The control a tile's spot means right now (see the header), or null. */
function resolve(spot) {
if (!pages.loaded) return spot.id;
if (pages.locate(spot.id)) return spot.id;
return pages.at(spot.page, spot.row, spot.column);
}
function pageBody(spots, page) {
return {
status: status.state,
message: status.message,
found: Boolean(page) || !pages.loaded,
name: page ? page.name : '',
number: page ? page.number : 0,
spots: spots.map(({ key, row, column }) => ({ key, row, column })),
};
function point(spot) {
const target = resolve(spot);
if (target === spot.target) return false;
if (spot.target) {
const old = buttons.get(spot.target);
if (old) old.refs -= 1;
release(spot.target);
}
spot.target = target;
if (target) watch(target).refs += 1;
return true;
}
function buttonBody(spot) {
/* — what a tile is sent — */
function body(spot) {
const b = spot.target ? buttons.get(spot.target) : null;
return {
key: spot.key,
controlId: spot.target,
status: status.state,
message: status.message,
image: b ? b.image : null,
// A place with nothing on it is as answered as it will get.
seen: b ? b.seen : pages.loaded,
// A spot with nothing there is as answered as it will get; a button whose
// picture has not arrived yet is not.
seen: b ? b.seen : !spot.target && pages.loaded,
location: spot.target ? pages.locate(spot.target) : null,
};
}
const pageFrame = (viewer, page) => `event: page\ndata: ${JSON.stringify(pageBody(viewer.spots, page))}\n\n`;
const buttonFrame = (spot) => `event: button\ndata: ${JSON.stringify(buttonBody(spot))}\n\n`;
const frame = (spot) => `event: button\ndata: ${JSON.stringify(body(spot))}\n\n`;
/**
* What a stream would send on connect, for a tile that joined a stream
* already open on the page (a remount after a settings change): ProdDash
* reuses the connection, so the tile asks for this on `open` instead.
* What a stream sends on connect, for a tile that joins a stream the
* dashboard already has open — ProdDash reuses the connection when a tile
* remounts after a settings change, so nothing new arrives on its own.
*/
function snapshot(view) {
const page = pages.page(view.pageId);
const spots = spotsFor({ keys: view.keys }, page);
return { page: pageBody(spots, page), buttons: spots.map(buttonBody) };
function snapshot(spots) {
return spots.map((s) => body({ ...s, target: resolve(s) }));
}
function write(viewer, chunk) {
@@ -223,73 +176,59 @@ module.exports = {
}
}
/** Bring a viewer up to date with the page book; send what changed. */
function refresh(viewer, force) {
const page = pages.page(viewer.pageId);
const next = spotsFor(viewer, page);
const before = new Map(viewer.spots.map((s) => [s.key, s.target]));
for (const s of next) if (s.target) watch(s.target).refs += 1;
for (const s of viewer.spots) {
if (!s.target) continue;
const b = buttons.get(s.target);
if (b) b.refs -= 1;
release(s.target);
}
viewer.spots = next;
const sig = JSON.stringify([status, page && page.name, page && page.number, next.map((s) => [s.key, s.target])]);
if (!force && sig === viewer.sig) return;
viewer.sig = sig;
write(viewer, pageFrame(viewer, page));
for (const s of next) if (force || before.get(s.key) !== s.target || !before.has(s.key)) write(viewer, buttonFrame(s));
function sendButton(controlId) {
for (const v of viewers) for (const spot of v.spots) if (spot.target === controlId) write(v, frame(spot));
}
function sendAll() {
for (const v of viewers) refresh(v, false);
}
function sendButton(controlId) {
for (const v of viewers) for (const s of v.spots) if (s.target === controlId) write(v, buttonFrame(s));
for (const v of viewers) {
for (const spot of v.spots) {
point(spot);
write(v, frame(spot));
}
}
}
function drop(viewer) {
if (!viewers.delete(viewer)) return;
for (const s of viewer.spots) {
if (!s.target) continue;
const b = buttons.get(s.target);
for (const spot of viewer.spots) {
if (!spot.target) continue;
const b = buttons.get(spot.target);
if (b) b.refs -= 1;
release(s.target);
release(spot.target);
}
}
function addViewer(res, view) {
const viewer = { res, pageId: view.pageId, keys: view.keys, spots: [], sig: '' };
function addViewer(res, spots) {
const viewer = { res, spots: spots.map((s) => ({ ...s, target: null })) };
viewers.add(viewer);
refresh(viewer, true);
for (const spot of viewer.spots) {
point(spot);
write(viewer, frame(spot));
}
return viewer;
}
/* — the connection — */
if (host) {
link = createLink({
host,
port,
onStatus(next) {
status = next.state === 'ok' && !pages.loaded ? { state: 'connecting', message: 'Connected — reading pages' } : next;
status = next.state === 'ok' && !pages.loaded
? { state: 'connecting', message: 'Connected — reading pages' }
: next;
if (next.state !== 'ok') log(next.message);
sendAll();
},
});
link.subscribe('pages.watch', undefined, (change) => {
const fresh = change && change.type === 'init';
pages.apply(change);
if (fresh) {
if (change && change.type === 'init') {
status = { state: 'ok', message: `Companion at ${host}:${port} · ${pages.count} pages` };
log(`connected to Companion at ${host}:${port} (${pages.count} pages)`);
}
// A page renamed, a button added or moved, or a page re-imported changes what tiles show.
// A page renamed, a button moved or a page re-imported changes what tiles show.
sendAll();
learnAll();
});
}
@@ -297,31 +236,17 @@ module.exports = {
for (const v of viewers) write(v, ': ping\n\n');
}, HEARTBEAT_MS);
/** The + picker's entries: one per page that has a button worth showing. */
function tileList() {
return pages
.list()
.filter((p) => p.buttons.some((b) => !isNav(b.controlId)))
.map((p) => ({
id: p.id,
name: p.name,
description: `Companion page ${p.number}`,
settings: { page: p.id },
instanceSchema: {
layout: { type: 'switch', label: 'Page layout', default: true },
},
}));
}
current = {
get status() {
return status;
},
pages,
watch,
release,
addViewer,
drop,
snapshot,
tileList,
linked: Boolean(link),
};
const self = current;
@@ -336,24 +261,23 @@ module.exports = {
}
}
viewers.clear();
for (const b of buttons.values()) clearTimeout(b.linger);
for (const b of buttons.values()) {
clearTimeout(b.linger);
for (const done of b.waiters) done();
}
buttons.clear();
if (link) link.close();
if (current === self) current = null;
},
health() {
const shown = new Set();
for (const v of viewers) for (const s of v.spots) if (s.target) shown.add(s.target);
for (const v of viewers) for (const spot of v.spots) if (spot.target) shown.add(spot.target);
const suffix = status.state === 'ok' && shown.size ? ` · showing ${shown.size} button${shown.size === 1 ? '' : 's'}` : '';
return { status: status.state, message: status.message + suffix };
},
};
},
tiles() {
return current ? current.tileList() : [];
},
routes() {
return {
'GET /page-options': (req, res) => {
@@ -362,15 +286,59 @@ module.exports = {
},
'GET /state/*': (req, res) => {
const view = viewFrom(req);
if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' });
const spots = spotsFrom(req);
if (!spots) return sendJson(res, 400, { error: 'Not a list of Companion buttons' });
if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' });
return sendJson(res, 200, current.snapshot(view));
return sendJson(res, 200, { buttons: current.snapshot(spots) });
},
'GET /pages': (req, res) => {
if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' });
const { state, message } = current.status;
return sendJson(res, 200, { status: state, message, pages: current.pages.list() });
},
'GET /image/*': (req, res) => {
const raw = String(req.wildcard || '').replace(/^\//, '');
if (!ID_RE.test(raw)) return sendJson(res, 400, { error: 'Not a Companion button id' });
if (!current || !current.linked) return sendJson(res, 503, { error: current ? current.status.message : 'Not running' });
const id = `bank:${raw}`;
const app = current;
const b = app.watch(id);
const reply = () => {
if (!b.image) {
res.writeHead(b.seen ? 404 : 504, { 'Cache-Control': 'no-store' });
res.end();
} else {
const png = Buffer.from(b.image.slice(b.image.indexOf(',') + 1), 'base64');
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store', 'Content-Length': png.length });
res.end(png);
}
app.release(id);
};
if (b.seen) return reply();
let done = false;
const finish = () => {
if (done) return;
done = true;
clearTimeout(timer);
b.waiters.delete(finish);
reply();
};
const timer = setTimeout(finish, FIRST_FRAME_MS);
b.waiters.add(finish);
req.on('close', () => {
if (done) return;
done = true;
clearTimeout(timer);
b.waiters.delete(finish);
app.release(id);
});
},
'GET /stream/*': (req, res) => {
const view = viewFrom(req);
if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' });
const spots = spotsFrom(req);
if (!spots) return sendJson(res, 400, { error: 'Not a list of Companion buttons' });
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
@@ -379,12 +347,14 @@ module.exports = {
});
res.write('retry: 3000\n\n');
if (!current) {
const body = { status: 'error', message: 'Companion Buttons is not running', found: true, name: '', number: 0, spots: [] };
res.write(`event: page\ndata: ${JSON.stringify(body)}\n\n`);
for (const s of spots) {
const body = { key: s.key, controlId: null, status: 'error', message: 'Companion Buttons is not running', image: null, seen: false, location: null };
res.write(`event: button\ndata: ${JSON.stringify(body)}\n\n`);
}
return void res.end();
}
const app = current;
const viewer = app.addViewer(res, view);
const viewer = app.addViewer(res, spots);
try {
req.socket.setKeepAlive(true, HEARTBEAT_MS);
} catch {
+100 -14
View File
@@ -11,18 +11,25 @@
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: var(--cb-size, 64px);
grid-auto-rows: max-content;
gap: 6px;
align-content: center;
justify-content: center;
}
[data-module="companion-buttons"] .cb-grid[hidden] { display: none; }
[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;
@@ -50,15 +57,45 @@
to { opacity: 0.8; }
}
/* A ticked place with no button on it any more. */
[data-module="companion-buttons"] .cb-face.is-empty {
[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-root.is-offline .cb-face {
[data-module="companion-buttons"] .cb-grid.is-offline .cb-face {
opacity: 0.3;
filter: grayscale(1);
}
@@ -79,29 +116,78 @@
[data-module="companion-buttons"] .cb-banner[hidden] { display: none; }
/* ── nothing chosen ─────────────────────────────────────────────────── */
[data-module="companion-buttons"] .cb-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
color: var(--muted);
font-size: 0.85rem;
text-align: center;
}
[data-module="companion-buttons"] .cb-empty[hidden] { display: none; }
/* ── choosing: one Companion page, laid out as Companion has it ─────── */
/* Choosing: the whole page is shown and clickable; buttons this tile leaves out are faded. */
[data-module="companion-buttons"] .cb-root.is-choosing .cb-face {
[data-module="companion-buttons"] .cb-chooser {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
overflow: auto;
padding: 6px;
}
[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;
-webkit-tap-highlight-color: transparent;
}
[data-module="companion-buttons"] .cb-root.is-choosing .cb-face:hover {
outline: 2px solid var(--accent);
outline-offset: -2px;
[data-module="companion-buttons"] .cb-pick-img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
[data-module="companion-buttons"] .cb-root.is-choosing .cb-face.is-off {
opacity: 0.25;
filter: grayscale(1);
[data-module="companion-buttons"] .cb-pick-img.is-broken { visibility: hidden; }
/* Chosen: a ring and the place it shows in. Nothing else is ever marked. */
[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; }
+5 -28
View File
@@ -8,10 +8,9 @@
*
* then point Companion Buttons at host 127.0.0.1, port 18000 in /admin.
*
* It serves the three subscriptions the module uses — pages.watch,
* preview.graphics.controlId and controls.watchControl — over a hand-rolled
* websocket (no deps), with three pages: a status page with a page-up
* button, a camera page, and an unnamed page holding only navigation. Page 1's first button flips between green and red every two
* 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,
@@ -124,22 +123,9 @@ function readFrames(state, data, onText, onClose) {
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' }, 1: { 0: 'bank:up1' } } },
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: { 0: { 0: 'bank:up3' } } },
};
const layered = (value, isExpression = false) => ({
type: 'button-layered',
style: { layers: [{ id: 'canvas', type: 'canvas' }, { id: 'text0', type: 'text', text: { value, isExpression } }] },
});
const configs = {
'bank:flip': layered('FLIP'),
'bank:steady': layered('Steady'),
'bank:blue': layered('Blue'),
'bank:cam1': { type: 'button', style: { text: 'CAM 1' } },
'bank:cam2': layered('CAM 2'),
'bank:up1': { type: 'pageup', style: {} },
'bank:up3': { type: 'pageup', style: {} },
p3: { id: 'p3', name: 'PAGE', controls: {} },
};
const order = ['p1', 'p2', 'p3'];
const looks = {
@@ -198,14 +184,6 @@ function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} }
}
if (msg.method !== 'subscription') return send(client, { id: msg.id, error: { message: 'mock: only subscriptions' } });
const { path, input } = msg.params || {};
if (path === 'controls.watchControl') {
const cfg = configs[input && input.controlId];
if (!cfg) return send(client, { id: msg.id, error: { message: `Control ${input && input.controlId} not found` } });
stats.subscriptions += 1;
client.subs.set(msg.id, { path, input });
send(client, { id: msg.id, result: { type: 'started' } });
return send(client, { id: msg.id, result: { type: 'data', data: { type: 'init', config: cfg, runtime: {} } } });
}
if (path !== 'pages.watch' && path !== 'preview.graphics.controlId') {
return send(client, { id: msg.id, error: { message: `No procedure found on path "${path}"` } });
}
@@ -298,7 +276,6 @@ function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} }
redraw,
looks,
pages,
configs,
/** Drop every client without a close frame, as a crash or a cable pull would. */
dropAll() {
for (const c of clients) c.socket.destroy();
+107 -92
View File
@@ -63,10 +63,9 @@ function get(port, path) {
});
}
/** An SSE client that collects `page` and `button` events. */
/** An SSE client that collects `button` events. */
function listen(port, path) {
const pages = [];
const buttons = [];
const events = [];
let req;
const ready = new Promise((resolve) => {
req = http.get({ host: '127.0.0.1', port, path }, (res) => {
@@ -79,23 +78,13 @@ function listen(port, path) {
buf = buf.slice(at + 2);
const name = /^event: (.+)$/m.exec(block);
const data = /^data: (.+)$/m.exec(block);
if (!name || !data) continue;
if (name[1] === 'page') pages.push(JSON.parse(data[1]));
if (name[1] === 'button') buttons.push(JSON.parse(data[1]));
if (name && data && name[1] === 'button') events.push(JSON.parse(data[1]));
}
});
resolve();
});
});
return {
pages,
buttons,
ready,
close: () => req.destroy(),
page: () => pages[pages.length - 1],
/** The latest picture for one row.column, or undefined. */
pic: (key) => [...buttons].reverse().find((b) => b.key === key),
};
return { events, ready, close: () => req.destroy(), last: () => events[events.length - 1] };
}
/* ── pure parts ────────────────────────────────────────────────────────── */
@@ -146,8 +135,6 @@ function testPageBook() {
book.apply({ type: 'update', updatedOrder: ['c', 'b'], added: [], changes: [] });
assert.strictEqual(book.locate('bank:x'), null);
assert.strictEqual(book.count, 2);
assert.strictEqual(book.page('b').number, 2);
assert.strictEqual(book.page('a'), null);
}
/* ── against the mock ─────────────────────────────────────────────────── */
@@ -165,91 +152,115 @@ async function testLive() {
await until('Companion connected', () => handle.health().status === 'ok');
assert.match(handle.health().message, /3 pages/);
// the + picker: one entry per page with something to show — not the page that is only navigation
await until('nav buttons learned', () => serverEntry.tiles().length === 2);
const tiles = serverEntry.tiles();
assert.deepStrictEqual(tiles.map((t) => [t.id, t.name]), [['p1', '1 Status'], ['p2', '2 Cameras']]);
assert.deepStrictEqual(tiles[0].settings, { page: 'p1' });
assert.deepStrictEqual(Object.keys(tiles[0].instanceSchema), ['layout']);
const pageOptions = JSON.parse((await get(at, '/page-options')).body).options;
assert.deepStrictEqual(pageOptions.map((o) => o.label), ['1 Status', '2 Cameras', 'Page 3']);
// a whole page: every button but the page-up one, laid out where Companion has them
const whole = listen(at, '/stream/p1');
await whole.ready;
await until('whole page', () => whole.page() && whole.page().status === 'ok' && whole.page().spots.length === 3);
assert.deepStrictEqual(whole.page().spots, [
{ key: '0.0', row: 0, column: 0 },
{ key: '0.1', row: 0, column: 1 },
{ key: '0.2', row: 0, column: 2 },
// 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' },
]);
assert.strictEqual(whole.page().name, '1 Status');
await until('three pictures', () => ['0.0', '0.1', '0.2'].every((k) => whole.pic(k) && whole.pic(k).image));
// the snapshot a remounted tile asks for matches what the stream sent
const snap = JSON.parse((await get(at, '/state/p1')).body);
assert.deepStrictEqual(snap.page.spots, whole.page().spots);
assert.deepStrictEqual(snap.buttons.map((b) => [b.key, Boolean(b.image), b.seen]), [['0.0', true, true], ['0.1', true, true], ['0.2', true, true]]);
assert.strictEqual((await get(at, '/state/p1/x')).status, 400);
// 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 is sent once, not five times
const steadyCount = whole.buttons.filter((b) => b.key === '0.1').length;
// the gear's Page list, by Companion's page id
const pageOptions = JSON.parse((await get(at, '/page-options')).body).options;
assert.deepStrictEqual(pageOptions, [
{ value: 'p1', label: '1 Status' },
{ value: 'p2', label: '2 Cameras' },
{ value: 'p3', label: 'Page 3' },
]);
// a tile that joins a stream its dashboard already has open asks for the snapshot instead
const warm = listen(at, '/stream/1.0.2.blue');
await warm.ready;
await until('blue streamed', () => warm.last() && warm.last().image);
const snap = JSON.parse((await get(at, '/state/1.0.2.blue/2.0.0.oldid/3.0.0.gone')).body).buttons;
assert.deepStrictEqual(snap.map((b) => [b.key, b.controlId, Boolean(b.image), b.seen]), [
['1.0.2.blue', 'bank:blue', true, true],
['2.0.0.oldid', 'bank:cam1', false, false],
['3.0.0.gone', null, false, true],
]);
assert.strictEqual(snap[0].image, warm.last().image);
assert.deepStrictEqual(snap[0].location, { page: 1, row: 0, column: 2, pageName: '1 Status' });
assert.strictEqual((await get(at, '/state/blue')).status, 400);
warm.close();
// 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(whole.buttons.filter((b) => b.key === '0.1').length, steadyCount, 'identical redraws must not be re-sent');
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 firstLook = whole.pic('0.0').image;
await until('flip redraw', () => whole.pic('0.0').image !== firstLook, 4000);
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);
// chosen buttons only, in the order chosen
const some = listen(at, '/stream/p2/1.3/0.0');
await some.ready;
await until('two ticked', () => some.page() && some.page().spots.length === 2 && some.pic('1.3') && some.pic('1.3').image && some.pic('0.0'));
assert.deepStrictEqual(some.page().spots.map((s) => s.key), ['1.3', '0.0']);
// 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 chosen place with nothing on it is answered, not left waiting
const hole = listen(at, '/stream/p2/3.3');
await hole.ready;
await until('empty place answered', () => hole.pic('3.3') && hole.pic('3.3').seen);
assert.strictEqual(hole.pic('3.3').image, null);
hole.close();
// 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);
// a page that does not exist says so
const missing = listen(at, '/stream/nope');
await missing.ready;
await until('missing page', () => missing.page());
assert.strictEqual(missing.page().found, false);
missing.close();
// 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();
// bad paths
assert.strictEqual((await get(at, '/stream/p1/abc')).status, 400);
assert.strictEqual((await get(at, '/stream/p:1')).status, 400);
assert.strictEqual((await get(at, `/stream/p1/${Array.from({ length: 65 }, (_, i) => `0.${i}`).join('/')}`)).status, 400);
// the page is re-imported: every button gets a new id, the tile keeps showing the same places
mock.looks['bank:cam2-new'] = mock.looks['bank:cam2'];
mock.configs['bank:cam2-new'] = mock.configs['bank:cam2'];
// the button moves to another page in Companion: the tile follows it
mock.pageUpdate({
type: 'update',
updatedOrder: null,
added: [],
changes: [{ id: 'p2', name: 'Cameras Renamed', controls: [{ row: 1, column: 3, controlId: 'bank:cam2-new' }] }],
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('renamed', () => some.page().name === '2 Cameras Renamed');
await until('re-imported button still shown', () => some.pic('1.3') && some.pic('1.3').image);
await until('moved location', () => steady.last().location && steady.last().location.page === 2);
assert.strictEqual(steady.last().controlId, 'bank:steady');
// a page inserted in front: the tile follows its page, now page 3
mock.pageUpdate({ type: 'update', updatedOrder: ['p0', 'p1', 'p2', 'p3'], added: [{ id: 'p0', name: 'New', controls: {} }], changes: [] });
await until('renumbered', () => some.page().number === 3 && some.page().name === '3 Cameras Renamed');
some.close();
// a button added to a whole-page tile appears in it
mock.looks['bank:new'] = mock.looks['bank:blue'];
mock.configs['bank:new'] = mock.configs['bank:blue'];
mock.pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [{ id: 'p1', name: null, controls: [{ row: 2, column: 2, controlId: 'bank:new' }] }] });
await until('new button shown', () => whole.page().spots.some((s) => s.key === '2.2') && whole.pic('2.2') && whole.pic('2.2').image);
const flip = { last: () => ({ status: whole.page().status, image: whole.pic('0.0') && whole.pic('0.0').image }) };
// 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();
@@ -266,7 +277,9 @@ async function testLive() {
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
whole.close();
steady.close();
flip.close();
gone.close();
await sleep(100);
assert.ok(mock.clients.size === 1, 'one connection to Companion, however many viewers');
} finally {
@@ -285,12 +298,14 @@ async function testUnconfigured() {
try {
assert.strictEqual(handle.health().status, 'error');
assert.match(handle.health().message, /admin/);
assert.deepStrictEqual(JSON.parse((await get(at, '/page-options')).body).options, []);
assert.deepStrictEqual(serverEntry.tiles(), []);
const s = listen(at, '/stream/p1');
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.page());
assert.strictEqual(s.page().status, 'error');
await until('stream answers', () => s.last());
assert.strictEqual(s.last().status, 'error');
s.close();
} finally {
handle.stop();