Companion Buttons 1.1.0: pages in the + picker, buttons chosen on the tile

The picker row (page list, Clear, Done) is gone. Each Companion page is now
its own + entry, titled with the page's name, showing the whole page minus
page up/down. The grid icon switches the tile to choosing: click buttons to
take them out or put them back, click the icon again when done. The gear
holds one switch, Page layout (Companion's rows and columns, or packed).

- Tiles follow their page by Companion's page id and buttons by row/column,
  so inserted pages and re-imported pages keep showing.
- A tile remounted after a settings change asks GET /state for the snapshot
  the already-open stream sent, instead of waiting blank.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 11:48:24 -04:00
co-authored by Claude Opus 5.5
parent 415453ef5f
commit 0a6cec88aa
8 changed files with 642 additions and 804 deletions
+35 -34
View File
@@ -1,14 +1,11 @@
# Companion Buttons # Companion Buttons
A [ProdDash](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash) module A [ProdDash](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash) module
that shows any button from Bitfocus Companion — any page, any button — on a that shows Bitfocus Companion pages on dashboard tiles — a whole page, or just
dashboard tile, exactly as Companion draws it: colours, text, icons and live the buttons you pick from it — every button exactly as Companion draws it:
feedback. It is meant for status buttons and anything else that says what is colours, text, icons and live feedback. It is meant for status buttons and
happening right now. 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 - **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 tally lighting up), the tile shows it. Identical redraws — a status page that
repaints every second with the same picture — are not re-sent. repaints every second with the same picture — are not re-sent.
@@ -21,26 +18,27 @@ happening right now.
running Companion and its web interface port (8000 unless changed in 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 Companion's settings). Use `127.0.0.1` when Companion runs on the same
machine as ProdDash. machine as ProdDash.
2. **+ Add tile → Companion Buttons.** A new tile opens the picker: choose a 2. **+ Add tile:** every Companion page is listed under **Companion
page from the list and tap the buttons to show. The numbers on the picked Buttons**. Pick one — "19 Status", say — and the tile shows that page,
buttons are the order they appear in. **Done** shows them. titled with its name. Page up/down buttons are left out.
3. To change the buttons later, use the grid icon in the tile's title bar. 3. **To show only some buttons,** click the grid icon in the tile's title bar.
The gear has one switch, **Captions**, which writes the page name under The whole page appears; click a button to take it out (it fades) or put it
each button. 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.
Hover a button for its page and Companion location (`page/row/column`). Hover a button for its page and Companion location (`page/row/column`).
### What a tile follows ### What a tile follows
Each picked button is saved with its Companion id **and** its page, row and A tile follows its page by Companion's own page id, so inserting or moving
column. While Companion still has that button anywhere, the tile follows it, pages changes nothing. Buttons are followed by their row and column on that
so dragging it to another spot or inserting a page in front changes nothing. page, so a page re-imported from a `.companionconfig` (every button gets a new
If the id is gone — a page re-imported from a `.companionconfig` gives every internal id) keeps showing. A chosen place that is emptied in Companion shows
button a new id — the tile shows whatever is at the saved page, row and column a dashed outline. A page deleted in Companion leaves the tile empty; the
instead. Either way the tile saves back what it found, so it stays current. dashboard folds it away.
A button that is gone with nothing at its place shows **Not in Companion**,
and the status dot turns amber.
### When Companion is down ### When Companion is down
@@ -53,20 +51,23 @@ 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 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 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 interface port (`ws://<host>:<port>/trpc`). The module's server holds one such
connection for all dashboards and uses two read-only subscriptions: connection for all dashboards and uses three read-only subscriptions:
`pages.watch` (pages, names, which button is where) and `pages.watch` (pages, names, which button is where),
`preview.graphics.controlId` (a button's PNG, on every redraw). That is `preview.graphics.controlId` (a button's PNG, on every redraw) and
Companion's internal API rather than a documented one, so a future Companion `controls.watchControl` (read once per button, to leave out page up/down).
release could change it; it was built and tested against Companion 5.0.7. 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 | | Route | What it serves |
| --- | --- | | --- | --- |
| `GET /pages` | Every page and the buttons on it — the picker's list | | `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 /image/<id>` | One button's current picture as PNG — picker thumbnails | | `GET /state/<page>[/<row.col>/…]` | The same as one snapshot, for a tile that joins a stream the dashboard already has open |
| `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 /page-options` | The pages, for a tile added without one (the gear's Page) |
A tile holds one stream for all its buttons, because ProdDash carries at most The + entries come from the server's `tiles()`, one per page. A tile holds
32 streams per page and a status board can show more buttons than that. 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 ## Requirements
@@ -81,9 +82,9 @@ A tile holds one stream for all its buttons, because ProdDash carries at most
| File | | | File | |
| --- | --- | | --- | --- |
| `module.json` | Manifest | | `module.json` | Manifest |
| `server.js` | Routes, button subscriptions, the per-tile streams | | `server.js` | + entries, routes, button subscriptions, the per-tile streams |
| `companion.js` | The connection to Companion's tRPC websocket and the page list | | `companion.js` | The connection to Companion's tRPC websocket and the page list |
| `client.js`, `style.css` | The tile and its picker | | `client.js`, `style.css` | The tile and its choosing mode |
| `test/companion.test.js` | Tests: `node test/companion.test.js` | | `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` | | `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` |
+241 -353
View File
@@ -1,37 +1,24 @@
/* Companion Buttons — the tile. /* Companion Buttons — the tile.
Shows the Companion buttons this tile was given, as Companion draws them, Shows one Companion page, or the buttons of it chosen for this tile, every
as large as the tile allows. One button fills the tile; several share it in button as Companion draws it and as large as the tile allows. Every
whatever grid makes each one biggest. Every picture is live: the server picture is live: the server forwards each redraw (a status button turning
forwards each redraw (a status button turning red, a tally lighting up). red, a tally lighting up).
Buttons are chosen in the tile itself — the grid icon in the title bar The page comes from the + picker entry the tile was added as (or, for a
opens a picker that shows any Companion page as it is laid out, and a tap tile added without one, the gear's Page). The grid icon in the title bar
adds or removes a button. The choice is the tile's own (saved with the switches the tile to choosing: the whole page is shown, the buttons this
layout), so two tiles can show two different sets. Each choice is kept as tile leaves out faded, and a click puts a button in or takes it out. The
the button's id and its page/row/column; the server follows whichever icon again goes back. "Page layout" in the gear keeps Companion's own rows
still holds, and the tile saves back what it resolved to. */ and columns; off, the buttons are packed into whatever grid makes each
one biggest. */
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 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 GAP_PX = 6;
const CAPTION_PX = 18; const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/;
const PAGES_RETRY_MS = 5000; const SPOT_RE = /^-?\d{1,3}\.-?\d{1,3}$/;
const ID_RE = /^bank:[A-Za-z0-9_-]{1,64}$/; 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) && 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) { function el(tag, className, text) {
const node = document.createElement(tag); const node = document.createElement(tag);
@@ -41,13 +28,11 @@ function el(tag, className, text) {
} }
/** The column count that makes `n` square cells biggest inside w × h. */ /** The column count that makes `n` square cells biggest inside w × h. */
function bestGrid(n, w, h, captionPx) { function packedGrid(n, w, h) {
let best = { cols: 1, size: 0 }; let best = { cols: 1, size: 0 };
for (let cols = 1; cols <= n; cols += 1) { for (let cols = 1; cols <= n; cols += 1) {
const rows = Math.ceil(n / cols); const rows = Math.ceil(n / cols);
const byWidth = (w - GAP_PX * (cols - 1)) / cols; const size = Math.floor(Math.min((w - GAP_PX * (cols - 1)) / cols, (h - GAP_PX * (rows - 1)) / rows));
const byHeight = (h - GAP_PX * (rows - 1)) / rows - captionPx;
const size = Math.floor(Math.min(byWidth, byHeight));
if (size > best.size) best = { cols, size }; if (size > best.size) best = { cols, size };
} }
return best; return best;
@@ -55,56 +40,232 @@ function bestGrid(n, w, h, captionPx) {
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
const prefs = () => moduleApi.instanceSettings || {}; const prefs = () => moduleApi.instanceSettings || {};
const chosen = () => (Array.isArray(prefs().buttons) ? prefs().buttons.filter(validSpot).slice(0, MAX_BUTTONS) : []); const pageId = () => {
const save = (spots) => moduleApi.saveInstanceSettings({ const p = String(prefs().page || '');
buttons: spots.map(({ id, page, row, column }) => ({ id, page, row, column })), return PAGE_RE.test(p) ? p : '';
}); };
const imageUrl = (id) => `/api/modules/${encodeURIComponent(moduleApi.id)}/image/${urlId(id)}`; /** 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 data = new Map(); // spot key → the server's latest word on it const pictures = new Map(); // spot key → { image, seen }
let stream = null; // one stream for every button on the tile let page = null; // the server's latest `page` event
let stream = null;
let streamPath = ''; let streamPath = '';
let lost = false; let lost = false;
let cells = []; // one { cell, face, img, note, caption } per chosen button, in order let choosing = false;
let view = null;
let banner = null;
let pickerEl = null;
let pickerPage = 0;
let pickerPages = [];
let pickerTimer = null;
let pickerToken = 0;
let pickButton = null; let pickButton = null;
let wrap = null;
let banner = null;
let grid = null;
let note = null;
let cells = new Map(); // spot key → { face, img }
let observer = null; let observer = null;
let stopped = false;
/* ── live buttons ─────────────────────────────────────────────────── */ /* ── status ───────────────────────────────────────────────────────── */
function syncStream() { function applyStatus() {
const keys = chosen().map(spotKey); if (!pageId()) return moduleApi.setStatus('connecting', 'No page chosen');
const path = keys.length ? `/stream/${keys.join('/')}` : ''; 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);
}
applyStatus();
}
/** 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('/')}` : ''}`;
if (path === streamPath) return; if (path === streamPath) return;
if (stream) stream.close(); if (stream) stream.close();
stream = null; stream = null;
streamPath = path; streamPath = path;
lost = false;
for (const key of [...data.keys()]) if (!keys.includes(key)) data.delete(key);
if (!path) return; if (!path) return;
stream = moduleApi.sse(path, { 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.
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);
} catch {
/* the stream itself will catch up */
}
},
events: { events: {
button(e) { page(e) {
let msg;
try { try {
msg = JSON.parse(e.data); apply('page', JSON.parse(e.data));
} catch { } catch {
return; /* not JSON */
}
},
button(e) {
try {
apply('button', JSON.parse(e.data));
} catch {
/* not JSON */
} }
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() { error() {
@@ -114,307 +275,34 @@ 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 (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 ────────────────────────────────────────────────────── */ /* ── lifecycle ────────────────────────────────────────────────────── */
return { return {
start() { start() {
stopped = false; build();
moduleApi.setStatus('connecting', 'Connecting'); if (pageId()) {
pickButton = moduleApi.header.addButton({ pickButton = moduleApi.header.addButton({
icon: GRID_SVG, icon: GRID_SVG,
title: 'Choose buttons', title: 'Choose buttons',
onClick: () => (pickerEl ? closePicker() : openPicker()), onClick: () => setChoosing(!choosing),
}); });
observer = new ResizeObserver(() => layout());
observer.observe(root);
syncStream();
if (chosen().length) {
buildView();
applyStatus();
} else {
openPicker();
} }
observer = new ResizeObserver(() => layout());
observer.observe(grid);
drawPage();
applyStatus();
connect();
}, },
stop() { stop() {
stopped = true;
clearTimeout(pickerTimer);
pickerToken += 1;
if (stream) stream.close(); if (stream) stream.close();
stream = null; stream = null;
streamPath = ''; streamPath = '';
data.clear();
if (observer) observer.disconnect(); if (observer) observer.disconnect();
observer = null; observer = null;
pickerEl = null; pictures.clear();
view = null; cells = new Map();
cells = []; page = null;
root.textContent = ''; root.textContent = '';
}, },
}; };
+12 -9
View File
@@ -8,8 +8,9 @@
* *
* pages.watch → every page, its name, and which control * pages.watch → every page, its name, and which control
* sits at each row/column * sits at each row/column
* preview.graphics.controlId → a button's rendered image, following the * preview.graphics.controlId → a button's rendered image, on every redraw
* button if it is moved to another page * controls.watchControl → a button's setup, read once for its kind
* (page up/down buttons are left out)
* *
* Nothing here can press, edit or change anything in Companion. * Nothing here can press, edit or change anything in Companion.
* *
@@ -245,7 +246,7 @@ function createPageBook() {
} }
} }
buttons.sort((a, b) => a.row - b.row || a.column - b.column); buttons.sort((a, b) => a.row - b.row || a.column - b.column);
return { number: i + 1, name: pageName(page.name, i + 1), buttons }; return { id, number: i + 1, name: pageName(page.name, i + 1), buttons };
}) })
.filter(Boolean); .filter(Boolean);
} }
@@ -266,17 +267,16 @@ function createPageBook() {
return null; return null;
} }
/** The control at a page number, row and column right now, or null. */ /** One page by Companion's own page id (stable when pages are renumbered), or null. */
function at(number, row, column) { function page(id) {
const page = pages.get(order[number - 1]); return list().find((p) => p.id === id) || null;
return (page && page.controls[row] && page.controls[row][column]) || null;
} }
return { return {
apply, apply,
list, list,
locate, locate,
at, page,
get loaded() { get loaded() {
return order.length > 0; return order.length > 0;
}, },
@@ -303,4 +303,7 @@ function pageName(raw, number) {
return `${number} ${name}`; return `${number} ${name}`;
} }
module.exports = { createLink, createPageBook, pageName }; /** 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 };
+12 -6
View File
@@ -1,9 +1,9 @@
{ {
"id": "companion-buttons", "id": "companion-buttons",
"name": "Companion Buttons", "name": "Companion Buttons",
"version": "1.0.0", "version": "1.1.0",
"proddash": ">=1.10.0", "proddash": ">=1.10.0",
"description": "Any Companion button from any page, live as Companion draws it — for status and what's happening", "description": "Companion pages and buttons, live as Companion draws them — for status and what's happening",
"permissions": [ "permissions": [
{ {
"kind": "local-network", "kind": "local-network",
@@ -14,7 +14,7 @@
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 1, "h": 1 }, "minSize": { "w": 1, "h": 1 },
"defaultSize": { "w": 3, "h": 3 }, "defaultSize": { "w": 6, "h": 4 },
"configSchema": { "configSchema": {
"companion": { "companion": {
"type": "endpoint", "type": "endpoint",
@@ -24,10 +24,16 @@
} }
}, },
"instanceSchema": { "instanceSchema": {
"captions": { "layout": {
"type": "switch", "type": "switch",
"label": "Captions", "label": "Page layout",
"default": false "default": true
},
"page": {
"type": "select",
"label": "Page",
"default": "",
"optionsRoute": "/page-options"
} }
} }
} }
+209 -153
View File
@@ -3,40 +3,41 @@
/** /**
* Companion Buttons — server part. * Companion Buttons — server part.
* *
* Shows any button from Bitfocus Companion, on any page, exactly as Companion * Shows Bitfocus Companion pages on dashboard tiles, every button exactly as
* draws it: colours, text, icons and live feedback included. ProdDash holds * Companion draws it: colours, text, icons and live feedback included.
* one connection to Companion (companion.js) however many dashboards are open; * ProdDash holds one connection to Companion (companion.js) however many
* browsers only ever talk to these routes. * dashboards are open; browsers only ever talk to these routes.
* *
* GET /pages → every page and the buttons on it (the tile's picker) * Each Companion page is an entry in the + Add-tile picker (tiles()), titled
* GET /image/<id> → one button's current picture, as PNG (picker thumbnails) * with the page's name. A tile shows its whole page, or the buttons chosen on
* GET /stream/<b>/<b>/… → SSE: `button` for each of those buttons on connect, * it (the tile's own choosing mode).
* then again whenever one redraws or moves
* *
* <id> is Companion's control id without its "bank:" prefix, and each <b> is * GET /page-options → the pages, for a tile added without one
* page.row.column.id — "19.1.4.t4bp4ZhrQblkr3bPSEBbc" — in the characters * GET /stream/<page>[/<r.c>/…]
* ProdDash's page stream carries. A tile holds one stream for all its buttons: * → SSE for one tile: `page` (what to lay out) on
* a page carries at most 32 streams, and a status board can show more buttons * connect and whenever it changes, `button` (one
* than that. * 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
* *
* Which button a tile means: the control id while Companion still has it * <page> is Companion's own page id, which stays with the page when pages are
* anywhere, so a button that is dragged elsewhere or a page inserted in front * inserted or moved; <r.c> is a button's row and column on it. Buttons are
* keeps showing the same button; otherwise whatever is at the page, row and * followed by place, so a page re-imported from a .companionconfig — every
* column now, so a page that is re-imported (every button gets a new id) keeps * button gets a new id — keeps showing. A tile holds one stream for all its
* showing the same place. Every frame says which control and location it * buttons: ProdDash carries at most 32 streams per page.
* 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. * Read-only by design: nothing here can press a button or change Companion.
*/ */
const { createLink, createPageBook } = require('./companion'); const { createLink, createPageBook, NAV_TYPES } = require('./companion');
const HEARTBEAT_MS = 15000; const HEARTBEAT_MS = 15000;
const LINGER_MS = 30000; // keep a button's subscription this long after its last viewer leaves 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 DESCRIBE_MS = 3000; // how long to wait for one button's setup
const MAX_PER_STREAM = 64; const MAX_SPOTS = 64;
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/; const PAGE_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})$/; const SPOT_RE = /^(-?\d{1,3})\.(-?\d{1,3})$/;
let current = null; let current = null;
@@ -50,37 +51,89 @@ function sendJson(res, code, body) {
res.end(JSON.stringify(body)); res.end(JSON.stringify(body));
} }
/** "/19.1.4.t4bp4Z…/2.0.0.QOPCk…" → [{ key, page, row, column, id }], or null if malformed. */ /** "/<page>" → { pageId, keys: null } (the whole page); "/<page>/0.1/2.3" → { pageId, keys: ['0.1', '2.3'] }. */
function spotsFrom(req) { function viewFrom(req) {
const parts = String(req.wildcard || '').replace(/^\//, '').split('/'); const [pageId, ...rest] = String(req.wildcard || '').replace(/^\//, '').split('/');
if (!parts.length || parts.length > MAX_PER_STREAM) return null; if (!PAGE_RE.test(pageId || '') || rest.length > MAX_SPOTS) return null;
const spots = []; if (!rest.length) return { pageId, keys: null };
for (const key of new Set(parts)) { if (!rest.every((k) => SPOT_RE.test(k))) return null;
const m = SPOT_RE.exec(key); return { pageId, keys: [...new Set(rest)] };
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 = { module.exports = {
init({ config, log }) { init({ config, log }) {
const { host, port } = endpointOf(config.companion); const { host, port } = endpointOf(config.companion);
const pages = createPageBook(); const pages = createPageBook();
const buttons = new Map(); // controlId → { image, seen, refs, waiters, linger, off } const buttons = new Map(); // controlId → { image, seen, refs, linger, off }
const viewers = new Set(); // { res, spots: [{ key, page, row, column, id, target }] } 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 }
let link = null; let link = null;
let heartbeat = null; let heartbeat = null;
let status = host let status = host
? { state: 'connecting', message: `Connecting to Companion at ${host}:${port}` } ? { state: 'connecting', message: `Connecting to Companion at ${host}:${port}` }
: { state: 'error', message: "Enter Companion's address in /admin → Companion Buttons" }; : { state: 'error', message: "Enter Companion's address in /admin → Companion Buttons" };
/* — following a button in Companion — */ /* — 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 — */
function watch(controlId) { function watch(controlId) {
let b = buttons.get(controlId); let b = buttons.get(controlId);
if (!b) { if (!b) {
b = { image: null, seen: false, refs: 0, waiters: new Set(), linger: null, off: null }; b = { image: null, seen: false, refs: 0, linger: null, off: null };
buttons.set(controlId, b); buttons.set(controlId, b);
if (link) { if (link) {
b.off = link.subscribe( b.off = link.subscribe(
@@ -91,8 +144,6 @@ module.exports = {
const changed = image !== b.image || !b.seen; const changed = image !== b.image || !b.seen;
b.image = image; b.image = image;
b.seen = true; 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. // Status buttons redraw every second with the same picture; only a real change goes out.
if (changed) sendButton(controlId); if (changed) sendButton(controlId);
}, },
@@ -105,54 +156,63 @@ module.exports = {
return b; return b;
} }
/** Let go of a button once nothing has looked at it for a while. */
function release(controlId) { function release(controlId) {
const b = buttons.get(controlId); const b = buttons.get(controlId);
if (!b || b.refs > 0 || b.waiters.size) return; if (!b || b.refs > 0) return;
clearTimeout(b.linger); clearTimeout(b.linger);
b.linger = setTimeout(() => { b.linger = setTimeout(() => {
if (b.refs > 0 || b.waiters.size) return; if (b.refs > 0) return;
if (b.off) b.off(); if (b.off) b.off();
buttons.delete(controlId); buttons.delete(controlId);
}, LINGER_MS); }, LINGER_MS);
} }
/** The control a tile's spot means right now (see the header), or null. */ /* — one tile's view of one page — */
function resolve(spot) {
if (!pages.loaded) return spot.id; /** The buttons a viewer shows right now: its ticked places, or every non-navigation button on the page. */
if (pages.locate(spot.id)) return spot.id; function spotsFor(viewer, page) {
return pages.at(spot.page, spot.row, spot.column); 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 };
});
} }
function point(spot) { function pageBody(spots, page) {
const target = resolve(spot); return {
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, status: status.state,
message: status.message, message: status.message,
image: b ? b.image : null, found: Boolean(page) || !pages.loaded,
// A spot with nothing there is as answered as it will get. name: page ? page.name : '',
seen: b ? b.seen : pages.loaded, number: page ? page.number : 0,
location, spots: spots.map(({ key, row, column }) => ({ key, row, column })),
}; };
return `event: button\ndata: ${JSON.stringify(body)}\n\n`; }
function buttonBody(spot) {
const b = spot.target ? buttons.get(spot.target) : null;
return {
key: spot.key,
image: b ? b.image : null,
// A place with nothing on it is as answered as it will get.
seen: b ? b.seen : pages.loaded,
};
}
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`;
/**
* 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.
*/
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 write(viewer, chunk) { function write(viewer, chunk) {
@@ -163,59 +223,73 @@ module.exports = {
} }
} }
function sendButton(controlId) { /** Bring a viewer up to date with the page book; send what changed. */
for (const v of viewers) for (const spot of v.spots) if (spot.target === controlId) write(v, frame(spot)); 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 sendAll() { function sendAll() {
for (const v of viewers) { for (const v of viewers) refresh(v, false);
for (const spot of v.spots) { }
point(spot);
write(v, frame(spot)); function sendButton(controlId) {
} for (const v of viewers) for (const s of v.spots) if (s.target === controlId) write(v, buttonFrame(s));
}
} }
function drop(viewer) { function drop(viewer) {
if (!viewers.delete(viewer)) return; if (!viewers.delete(viewer)) return;
for (const spot of viewer.spots) { for (const s of viewer.spots) {
if (!spot.target) continue; if (!s.target) continue;
const b = buttons.get(spot.target); const b = buttons.get(s.target);
if (b) b.refs -= 1; if (b) b.refs -= 1;
release(spot.target); release(s.target);
} }
} }
function addViewer(res, spots) { function addViewer(res, view) {
const viewer = { res, spots: spots.map((s) => ({ ...s, target: null })) }; const viewer = { res, pageId: view.pageId, keys: view.keys, spots: [], sig: '' };
viewers.add(viewer); viewers.add(viewer);
for (const spot of viewer.spots) { refresh(viewer, true);
point(spot);
write(viewer, frame(spot));
}
return viewer; return viewer;
} }
/* — the connection — */
if (host) { if (host) {
link = createLink({ link = createLink({
host, host,
port, port,
onStatus(next) { onStatus(next) {
status = next.state === 'ok' && !pages.loaded status = next.state === 'ok' && !pages.loaded ? { state: 'connecting', message: 'Connected — reading pages' } : next;
? { state: 'connecting', message: 'Connected — reading pages' }
: next;
if (next.state !== 'ok') log(next.message); if (next.state !== 'ok') log(next.message);
sendAll(); sendAll();
}, },
}); });
link.subscribe('pages.watch', undefined, (change) => { link.subscribe('pages.watch', undefined, (change) => {
const fresh = change && change.type === 'init';
pages.apply(change); pages.apply(change);
if (change && change.type === 'init') { if (fresh) {
status = { state: 'ok', message: `Companion at ${host}:${port} · ${pages.count} pages` }; status = { state: 'ok', message: `Companion at ${host}:${port} · ${pages.count} pages` };
log(`connected to 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. // A page renamed, a button added or moved, or a page re-imported changes what tiles show.
sendAll(); sendAll();
learnAll();
}); });
} }
@@ -223,16 +297,31 @@ module.exports = {
for (const v of viewers) write(v, ': ping\n\n'); for (const v of viewers) write(v, ': ping\n\n');
}, HEARTBEAT_MS); }, 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 = { current = {
get status() { get status() {
return status; return status;
}, },
pages, pages,
watch,
release,
addViewer, addViewer,
drop, drop,
linked: Boolean(link), snapshot,
tileList,
}; };
const self = current; const self = current;
@@ -247,72 +336,41 @@ module.exports = {
} }
} }
viewers.clear(); viewers.clear();
for (const b of buttons.values()) { for (const b of buttons.values()) clearTimeout(b.linger);
clearTimeout(b.linger);
for (const done of b.waiters) done();
}
buttons.clear(); buttons.clear();
if (link) link.close(); if (link) link.close();
if (current === self) current = null; if (current === self) current = null;
}, },
health() { health() {
const shown = new Set(); const shown = new Set();
for (const v of viewers) for (const spot of v.spots) if (spot.target) shown.add(spot.target); for (const v of viewers) for (const s of v.spots) if (s.target) shown.add(s.target);
const suffix = status.state === 'ok' && shown.size ? ` · showing ${shown.size} button${shown.size === 1 ? '' : 's'}` : ''; const suffix = status.state === 'ok' && shown.size ? ` · showing ${shown.size} button${shown.size === 1 ? '' : 's'}` : '';
return { status: status.state, message: status.message + suffix }; return { status: status.state, message: status.message + suffix };
}, },
}; };
}, },
tiles() {
return current ? current.tileList() : [];
},
routes() { routes() {
return { return {
'GET /pages': (req, res) => { 'GET /page-options': (req, res) => {
if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' }); if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' });
const { state, message } = current.status; return sendJson(res, 200, { options: current.pages.list().map((p) => ({ value: p.id, label: p.name })) });
return sendJson(res, 200, { status: state, message, pages: current.pages.list() });
}, },
'GET /image/*': (req, res) => { 'GET /state/*': (req, res) => {
const raw = String(req.wildcard || '').replace(/^\//, ''); const view = viewFrom(req);
if (!ID_RE.test(raw)) return sendJson(res, 400, { error: 'Not a Companion button id' }); if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' });
if (!current || !current.linked) return sendJson(res, 503, { error: current ? current.status.message : 'Not running' }); if (!current) return sendJson(res, 503, { error: 'Companion Buttons is not running' });
const id = `bank:${raw}`; return sendJson(res, 200, current.snapshot(view));
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) => { 'GET /stream/*': (req, res) => {
const spots = spotsFrom(req); const view = viewFrom(req);
if (!spots) return sendJson(res, 400, { error: 'Not a list of Companion buttons' }); if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' });
res.writeHead(200, { res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8', 'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform', 'Cache-Control': 'no-cache, no-transform',
@@ -321,14 +379,12 @@ module.exports = {
}); });
res.write('retry: 3000\n\n'); res.write('retry: 3000\n\n');
if (!current) { if (!current) {
for (const s of spots) { const body = { status: 'error', message: 'Companion Buttons is not running', found: true, name: '', number: 0, 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: page\ndata: ${JSON.stringify(body)}\n\n`);
res.write(`event: button\ndata: ${JSON.stringify(body)}\n\n`);
}
return void res.end(); return void res.end();
} }
const app = current; const app = current;
const viewer = app.addViewer(res, spots); const viewer = app.addViewer(res, view);
try { try {
req.socket.setKeepAlive(true, HEARTBEAT_MS); req.socket.setKeepAlive(true, HEARTBEAT_MS);
} catch { } catch {
+13 -160
View File
@@ -11,25 +11,18 @@
overflow: hidden; overflow: hidden;
} }
/* ── the chosen buttons ─────────────────────────────────────────────── */
[data-module="companion-buttons"] .cb-grid { [data-module="companion-buttons"] .cb-grid {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
display: grid; display: grid;
grid-template-columns: repeat(var(--cb-cols, 1), var(--cb-size, 64px)); grid-template-columns: repeat(var(--cb-cols, 1), var(--cb-size, 64px));
grid-auto-rows: max-content; grid-auto-rows: var(--cb-size, 64px);
gap: 6px; gap: 6px;
align-content: center; align-content: center;
justify-content: center; justify-content: center;
} }
[data-module="companion-buttons"] .cb-cell { [data-module="companion-buttons"] .cb-grid[hidden] { display: none; }
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
[data-module="companion-buttons"] .cb-face { [data-module="companion-buttons"] .cb-face {
position: relative; position: relative;
@@ -57,45 +50,15 @@
to { opacity: 0.8; } to { opacity: 0.8; }
} }
[data-module="companion-buttons"] .cb-face.is-missing { /* A ticked place with no button on it any more. */
[data-module="companion-buttons"] .cb-face.is-empty {
background: transparent; background: transparent;
outline: 1px dashed var(--border); outline: 1px dashed var(--border);
outline-offset: -1px; 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. */ /* Companion gone: the last pictures stay, faded, so nobody reads them as live. */
[data-module="companion-buttons"] .cb-grid.is-offline .cb-face { [data-module="companion-buttons"] .cb-root.is-offline .cb-face {
opacity: 0.3; opacity: 0.3;
filter: grayscale(1); filter: grayscale(1);
} }
@@ -116,139 +79,29 @@
[data-module="companion-buttons"] .cb-banner[hidden] { display: none; } [data-module="companion-buttons"] .cb-banner[hidden] { display: none; }
/* ── nothing chosen ─────────────────────────────────────────────────── */
[data-module="companion-buttons"] .cb-empty { [data-module="companion-buttons"] .cb-empty {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px;
text-align: center;
}
[data-module="companion-buttons"] .cb-empty-line {
color: var(--muted); color: var(--muted);
font-size: 0.85rem; 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; text-align: center;
} }
[data-module="companion-buttons"] .cb-pick-grid { [data-module="companion-buttons"] .cb-empty[hidden] { display: none; }
display: grid;
grid-template-columns: repeat(var(--cb-pick-cols, 8), minmax(36px, 1fr));
gap: 4px;
}
[data-module="companion-buttons"] .cb-pick-key { /* Choosing: the whole page is shown and clickable; buttons this tile leaves out are faded. */
position: relative; [data-module="companion-buttons"] .cb-root.is-choosing .cb-face {
aspect-ratio: 1;
padding: 0;
border: 0;
border-radius: 8%;
overflow: hidden;
background: color-mix(in srgb, var(--text) 6%, transparent);
cursor: pointer; cursor: pointer;
} }
[data-module="companion-buttons"] .cb-pick-img { [data-module="companion-buttons"] .cb-root.is-choosing .cb-face:hover {
display: block; outline: 2px solid var(--accent);
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; outline-offset: -2px;
} }
[data-module="companion-buttons"] .cb-pick-key.is-picked { [data-module="companion-buttons"] .cb-root.is-choosing .cb-face.is-off {
outline: 3px solid var(--accent); opacity: 0.25;
outline-offset: -3px; filter: grayscale(1);
} }
[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; }
+28 -5
View File
@@ -8,9 +8,10 @@
* *
* then point Companion Buttons at host 127.0.0.1, port 18000 in /admin. * 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 * It serves the three subscriptions the module uses — pages.watch,
* preview.graphics.controlId — over a hand-rolled websocket (no deps), with * preview.graphics.controlId and controls.watchControl — over a hand-rolled
* three pages. Page 1's first button flips between green and red every two * 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
* seconds, like a status button; page 1's second button redraws the same * 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. * 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, * Every 30 s it sends PING and drops a client that does not answer PONG,
@@ -123,9 +124,22 @@ function readFrames(state, data, onText, onClose) {
function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} } = {}) { function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} } = {}) {
const pages = { const pages = {
p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' } } }, p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' }, 1: { 0: 'bank:up1' } } },
p2: { id: 'p2', name: 'Cameras', controls: { 0: { 0: 'bank:cam1' }, 1: { 3: 'bank:cam2' } } }, p2: { id: 'p2', name: 'Cameras', controls: { 0: { 0: 'bank:cam1' }, 1: { 3: 'bank:cam2' } } },
p3: { id: 'p3', name: 'PAGE', controls: {} }, 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: {} },
}; };
const order = ['p1', 'p2', 'p3']; const order = ['p1', 'p2', 'p3'];
const looks = { const looks = {
@@ -184,6 +198,14 @@ function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} }
} }
if (msg.method !== 'subscription') return send(client, { id: msg.id, error: { message: 'mock: only subscriptions' } }); if (msg.method !== 'subscription') return send(client, { id: msg.id, error: { message: 'mock: only subscriptions' } });
const { path, input } = msg.params || {}; 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') { if (path !== 'pages.watch' && path !== 'preview.graphics.controlId') {
return send(client, { id: msg.id, error: { message: `No procedure found on path "${path}"` } }); return send(client, { id: msg.id, error: { message: `No procedure found on path "${path}"` } });
} }
@@ -276,6 +298,7 @@ function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} }
redraw, redraw,
looks, looks,
pages, pages,
configs,
/** Drop every client without a close frame, as a crash or a cable pull would. */ /** Drop every client without a close frame, as a crash or a cable pull would. */
dropAll() { dropAll() {
for (const c of clients) c.socket.destroy(); for (const c of clients) c.socket.destroy();
+92 -84
View File
@@ -63,9 +63,10 @@ function get(port, path) {
}); });
} }
/** An SSE client that collects `button` events. */ /** An SSE client that collects `page` and `button` events. */
function listen(port, path) { function listen(port, path) {
const events = []; const pages = [];
const buttons = [];
let req; let req;
const ready = new Promise((resolve) => { const ready = new Promise((resolve) => {
req = http.get({ host: '127.0.0.1', port, path }, (res) => { req = http.get({ host: '127.0.0.1', port, path }, (res) => {
@@ -78,13 +79,23 @@ function listen(port, path) {
buf = buf.slice(at + 2); buf = buf.slice(at + 2);
const name = /^event: (.+)$/m.exec(block); const name = /^event: (.+)$/m.exec(block);
const data = /^data: (.+)$/m.exec(block); const data = /^data: (.+)$/m.exec(block);
if (name && data && name[1] === 'button') events.push(JSON.parse(data[1])); if (!name || !data) continue;
if (name[1] === 'page') pages.push(JSON.parse(data[1]));
if (name[1] === 'button') buttons.push(JSON.parse(data[1]));
} }
}); });
resolve(); resolve();
}); });
}); });
return { events, ready, close: () => req.destroy(), last: () => events[events.length - 1] }; 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),
};
} }
/* ── pure parts ────────────────────────────────────────────────────────── */ /* ── pure parts ────────────────────────────────────────────────────────── */
@@ -135,6 +146,8 @@ function testPageBook() {
book.apply({ type: 'update', updatedOrder: ['c', 'b'], added: [], changes: [] }); book.apply({ type: 'update', updatedOrder: ['c', 'b'], added: [], changes: [] });
assert.strictEqual(book.locate('bank:x'), null); assert.strictEqual(book.locate('bank:x'), null);
assert.strictEqual(book.count, 2); assert.strictEqual(book.count, 2);
assert.strictEqual(book.page('b').number, 2);
assert.strictEqual(book.page('a'), null);
} }
/* ── against the mock ─────────────────────────────────────────────────── */ /* ── against the mock ─────────────────────────────────────────────────── */
@@ -152,92 +165,91 @@ async function testLive() {
await until('Companion connected', () => handle.health().status === 'ok'); await until('Companion connected', () => handle.health().status === 'ok');
assert.match(handle.health().message, /3 pages/); assert.match(handle.health().message, /3 pages/);
// pages for the picker // the + picker: one entry per page with something to show — not the page that is only navigation
const pages = JSON.parse((await get(at, '/pages')).body); await until('nav buttons learned', () => serverEntry.tiles().length === 2);
assert.strictEqual(pages.status, 'ok'); const tiles = serverEntry.tiles();
assert.deepStrictEqual(pages.pages.map((p) => p.name), ['1 Status', '2 Cameras', 'Page 3']); assert.deepStrictEqual(tiles.map((t) => [t.id, t.name]), [['p1', '1 Status'], ['p2', '2 Cameras']]);
assert.deepStrictEqual(pages.pages[1].buttons, [ assert.deepStrictEqual(tiles[0].settings, { page: 'p1' });
{ row: 0, column: 0, controlId: 'bank:cam1' }, assert.deepStrictEqual(Object.keys(tiles[0].instanceSchema), ['layout']);
{ row: 1, column: 3, controlId: 'bank:cam2' },
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 },
]); ]);
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));
// thumbnails // the snapshot a remounted tile asks for matches what the stream sent
const img = await get(at, '/image/blue'); const snap = JSON.parse((await get(at, '/state/p1')).body);
assert.strictEqual(img.status, 200); assert.deepStrictEqual(snap.page.spots, whole.page().spots);
assert.strictEqual(img.type, 'image/png'); 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.deepStrictEqual([...img.body.subarray(1, 4)], [0x50, 0x4e, 0x47]); assert.strictEqual((await get(at, '/state/p1/x')).status, 400);
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 // a button that redraws the same picture every 300 ms is sent once, not five times
const steady = listen(at, '/stream/1.0.1.steady'); const steadyCount = whole.buttons.filter((b) => b.key === '0.1').length;
await steady.ready;
await until('steady first frame', () => steady.last() && steady.last().image);
const settled = steady.events.length;
await sleep(1600); await sleep(1600);
assert.strictEqual(steady.events.length, settled, 'identical redraws must not be re-sent'); assert.strictEqual(whole.buttons.filter((b) => b.key === '0.1').length, steadyCount, '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 // a status button that changes is forwarded
const flip = listen(at, '/stream/1.0.0.flip'); const firstLook = whole.pic('0.0').image;
await flip.ready; await until('flip redraw', () => whole.pic('0.0').image !== firstLook, 4000);
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 // chosen buttons only, in the order chosen
const pair = listen(at, '/stream/1.0.2.blue/2.1.3.cam2/1.0.2.blue'); const some = listen(at, '/stream/p2/1.3/0.0');
await pair.ready; await some.ready;
await until('both buttons', () => new Set(pair.events.filter((e) => e.image).map((e) => e.controlId)).size === 2); 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([...new Set(pair.events.map((e) => e.key))].sort(), ['1.0.2.blue', '2.1.3.cam2']); assert.deepStrictEqual(some.page().spots.map((s) => s.key), ['1.3', '0.0']);
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 // a chosen place with nothing on it is answered, not left waiting
const gone = listen(at, '/stream/3.0.0.gone'); const hole = listen(at, '/stream/p2/3.3');
await gone.ready; await hole.ready;
await until('missing button answered', () => gone.last() && gone.last().seen); await until('empty place answered', () => hole.pic('3.3') && hole.pic('3.3').seen);
assert.strictEqual(gone.last().image, null); assert.strictEqual(hole.pic('3.3').image, null);
assert.strictEqual(gone.last().controlId, null); hole.close();
// an unknown id falls back to what is at its page/row/column // a page that does not exist says so
const byPlace = listen(at, '/stream/2.0.0.oldid'); const missing = listen(at, '/stream/nope');
await byPlace.ready; await missing.ready;
await until('found by place', () => byPlace.last() && byPlace.last().image); await until('missing page', () => missing.page());
assert.strictEqual(byPlace.last().controlId, 'bank:cam1'); assert.strictEqual(missing.page().found, false);
byPlace.close(); missing.close();
// the button moves to another page in Companion: the tile follows it // 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'];
mock.pageUpdate({ mock.pageUpdate({
type: 'update', type: 'update',
updatedOrder: null, updatedOrder: null,
added: [], added: [],
changes: [ changes: [{ id: 'p2', name: 'Cameras Renamed', controls: [{ row: 1, column: 3, controlId: 'bank:cam2-new' }] }],
{ 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); await until('renamed', () => some.page().name === '2 Cameras Renamed');
assert.strictEqual(steady.last().controlId, 'bank:steady'); await until('re-imported button still shown', () => some.pic('1.3') && some.pic('1.3').image);
// the page is re-imported: every button gets a new id, the tile keeps its place // a page inserted in front: the tile follows its page, now page 3
mock.looks['bank:blue-new'] = mock.looks['bank:blue']; mock.pageUpdate({ type: 'update', updatedOrder: ['p0', 'p1', 'p2', 'p3'], added: [{ id: 'p0', name: 'New', controls: {} }], changes: [] });
const blue = listen(at, '/stream/1.0.2.blue'); await until('renumbered', () => some.page().number === 3 && some.page().name === '3 Cameras Renamed');
await blue.ready; some.close();
await until('blue', () => blue.last() && blue.last().image);
mock.pageUpdate({ // a button added to a whole-page tile appears in it
type: 'update', mock.looks['bank:new'] = mock.looks['bank:blue'];
updatedOrder: null, mock.configs['bank:new'] = mock.configs['bank:blue'];
added: [], mock.pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [{ id: 'p1', name: null, controls: [{ row: 2, column: 2, controlId: 'bank:new' }] }] });
changes: [{ id: 'p1', name: null, controls: [{ row: 0, column: 2, controlId: 'bank:blue-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 }) };
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 // Companion drops: streams say so, then recover by themselves
mock.dropAll(); mock.dropAll();
@@ -254,9 +266,7 @@ async function testLive() {
await until('stream live after restart', () => flip.last().status === 'ok' && flip.last().image, 4000); 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 // closing a viewer keeps the subscription a little while (remounts are cheap), stop() ends all
steady.close(); whole.close();
flip.close();
gone.close();
await sleep(100); await sleep(100);
assert.ok(mock.clients.size === 1, 'one connection to Companion, however many viewers'); assert.ok(mock.clients.size === 1, 'one connection to Companion, however many viewers');
} finally { } finally {
@@ -275,14 +285,12 @@ async function testUnconfigured() {
try { try {
assert.strictEqual(handle.health().status, 'error'); assert.strictEqual(handle.health().status, 'error');
assert.match(handle.health().message, /admin/); assert.match(handle.health().message, /admin/);
const pages = JSON.parse((await get(at, '/pages')).body); assert.deepStrictEqual(JSON.parse((await get(at, '/page-options')).body).options, []);
assert.strictEqual(pages.status, 'error'); assert.deepStrictEqual(serverEntry.tiles(), []);
assert.deepStrictEqual(pages.pages, []); const s = listen(at, '/stream/p1');
assert.strictEqual((await get(at, '/image/x')).status, 503);
const s = listen(at, '/stream/1.0.0.x');
await s.ready; await s.ready;
await until('stream answers', () => s.last()); await until('stream answers', () => s.page());
assert.strictEqual(s.last().status, 'error'); assert.strictEqual(s.page().status, 'error');
s.close(); s.close();
} finally { } finally {
handle.stop(); handle.stop();