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 # 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 Bitfocus Companion pages on dashboard tiles — a whole page, or just that shows any Companion buttons — from any pages — on a dashboard tile,
the buttons you pick from it — every button exactly as Companion draws it: exactly as Companion draws them: colours, text, icons and live feedback. It is
colours, text, icons and live feedback. It is meant for status buttons and meant for status buttons and anything else that says what is happening right
anything else that says what is happening right now. 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 - **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.
@@ -18,27 +21,29 @@ anything else that says what is 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:** every Companion page is listed under **Companion 2. **+ Add tile → Companion Buttons.** The new tile opens on a Companion page,
Buttons**. Pick one — "19 Status", say — and the tile shows that page, laid out as it is in Companion. Click the buttons to show: each gets a ring
titled with its name. Page up/down buttons are left out. and a number, the order it will appear in. Click again to take it out.
3. **To show only some buttons,** click the grid icon in the tile's title bar. 3. **Another page:** open the gear and change **Page**. The tile stays in
The whole page appears; click a button to take it out (it fades) or put it choosing and shows that page; what you already picked is kept.
back. Click the icon again when done. Putting every button back means the 4. **Done:** click the grid icon in the title bar. The tile shows your
whole page again, including buttons added to it later. buttons. Click the icon again any time to add or remove some.
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`). 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 ### What a tile follows
A tile follows its page by Companion's own page id, so inserting or moving Each picked button is saved with its Companion id **and** its page, row and
pages changes nothing. Buttons are followed by their row and column on that column. While Companion still has that button anywhere, the tile follows it,
page, so a page re-imported from a `.companionconfig` (every button gets a new so dragging it to another spot or inserting a page in front changes nothing.
internal id) keeps showing. A chosen place that is emptied in Companion shows If the id is gone — a page re-imported from a `.companionconfig` gives every
a dashed outline. A page deleted in Companion leaves the tile empty; the button a new id — the tile shows whatever is at the saved page, row and column
dashboard folds it away. 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 ### 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 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 three read-only subscriptions: connection for all dashboards and uses two read-only subscriptions:
`pages.watch` (pages, names, which button is where), `pages.watch` (pages, names, which button is where) and
`preview.graphics.controlId` (a button's PNG, on every redraw) and `preview.graphics.controlId` (a button's PNG, on every redraw). That is
`controls.watchControl` (read once per button, to leave out page up/down). Companion's internal API rather than a documented one, so a future Companion
That is Companion's internal API rather than a documented one, so a future release could change it; it was built and tested against Companion 5.0.7.
Companion release could change it; it was built and tested against Companion
5.0.7.
| Route | What it serves | | 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 /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.col>/…]` | The same as one snapshot, for a tile that joins a stream the dashboard already has open | | `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 /page-options` | The pages, for a tile added without one (the gear's Page) | | `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 A tile holds one stream for all its buttons, because ProdDash carries at most
one stream for all its buttons, because ProdDash carries at most 32 streams 32 streams per page and a status board can show more buttons than that.
per page and a status board can show more buttons than that.
## Requirements ## Requirements
@@ -82,9 +86,9 @@ per page and a status board can show more buttons than that.
| File | | | File | |
| --- | --- | | --- | --- |
| `module.json` | Manifest | | `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 | | `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.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` |
+341 -223
View File
@@ -1,24 +1,42 @@
/* Companion Buttons — the tile. /* Companion Buttons — the tile.
Shows one Companion page, or the buttons of it chosen for this tile, every Shows the Companion buttons this tile was given, from any pages, as
button as Companion draws it and as large as the tile allows. Every 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 picture is live: the server forwards each redraw (a status button turning
red, a tally lighting up). red, a tally lighting up).
The page comes from the + picker entry the tile was added as (or, for a Choosing: the grid icon in the title bar turns the tile into the Companion
tile added without one, the gear's Page). The grid icon in the title bar page named by the gear's Page, laid out as it is in Companion. A click on
switches the tile to choosing: the whole page is shown, the buttons this a button adds it to the tile (it gets a numbered ring, the order it will
tile leaves out faded, and a click puts a button in or takes it out. The show in) or takes it out. Change the gear's Page to pick from another page
icon again goes back. "Page layout" in the gear keeps Companion's own rows — what is already chosen stays. The icon again shows the chosen buttons.
and columns; off, the buttons are packed into whatever grid makes each
one biggest. */ 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 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 PAGES_RETRY_MS = 5000;
const ID_RE = /^bank:[A-Za-z0-9_-]{1,64}$/;
const PAGE_RE = /^[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_BUTTONS = 64;
const MAX_SPOTS = 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) { function el(tag, className, text) {
const node = document.createElement(tag); 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. */ /** 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 }; 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 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 }; if (size > best.size) best = { cols, size };
} }
return best; return best;
@@ -40,229 +60,88 @@ function packedGrid(n, w, h) {
export default function create({ root, moduleApi }) { export default function create({ root, moduleApi }) {
const prefs = () => moduleApi.instanceSettings || {}; const prefs = () => moduleApi.instanceSettings || {};
const pageId = () => { const chosen = () => (Array.isArray(prefs().buttons) ? prefs().buttons.filter(validSpot).slice(0, MAX_BUTTONS) : []);
const p = String(prefs().page || ''); const save = (spots) => moduleApi.saveInstanceSettings({
return PAGE_RE.test(p) ? p : ''; buttons: spots.map(({ id, page, row, column }) => ({ id, page, row, column })),
}; });
/** The chosen buttons as "row.column", or null for the whole page (never chosen). */ const imageUrl = (id) => `/api/modules/${encodeURIComponent(moduleApi.id)}/image/${urlId(id)}`;
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 pictures = new Map(); // spot key → { image, seen } // Choosing survives the remount a gear change causes (picking from another
let page = null; // the server's latest `page` event // Page), so it is kept per tile for this browser tab, not in the closure.
let stream = null; 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 streamPath = '';
let lost = false; let lost = false;
let choosing = false; let choosing = false;
let pickButton = null; let cells = []; // one { cell, face, img, note, caption } per chosen button, in order
let wrap = null; let view = null;
let banner = null; let banner = null;
let grid = null; let chooser = null; // the page being chosen from, while choosing
let note = null; let pages = [];
let cells = new Map(); // spot key → { face, img } let pagesTimer = null;
let pagesToken = 0;
let pickButton = null;
let observer = null; let observer = null;
let stopped = false;
/* ── status ───────────────────────────────────────────────────────── */ /* ── live buttons ─────────────────────────────────────────────────── */
function applyStatus() { function onButton(msg) {
if (!pageId()) return moduleApi.setStatus('connecting', 'No page chosen'); const index = chosen().findIndex((s) => spotKey(s) === msg.key);
if (lost) return offline('Lost the connection to ProdDash'); if (index < 0) return;
if (!page) return moduleApi.setStatus('connecting', 'Connecting'); data.set(msg.key, msg);
if (page.status === 'error') return offline(page.message || 'Companion is not reachable'); lost = false;
setOffline(false); paintCell(index);
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(); applyStatus();
adopt(index, msg);
} }
/** Open the stream for what the tile shows now: the whole page while choosing, else the chosen buttons. */ function syncStream() {
function connect() { const keys = chosen().map(spotKey);
const id = pageId(); const path = keys.length ? keys.join('/') : '';
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(`/stream/${path}`, { stream = moduleApi.sse(`/stream/${path}`, {
// The page's connection may already carry this stream (another tile, or // The dashboard may already carry this stream (another tile showing the
// this one before a remount): ask for what it sent on connect. // 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() { async open() {
try { try {
const res = await moduleApi.fetch(`/state/${path}`); const res = await moduleApi.fetch(`/state/${path}`);
if (!res.ok || path !== streamPath) return; if (!res.ok || path !== streamPath) return;
const snap = await res.json(); const snap = await res.json();
if (path !== streamPath) return; if (path !== streamPath || stopped) return;
apply('page', snap.page); for (const msg of snap.buttons || []) if (msg.seen || !data.has(msg.key)) onButton(msg);
for (const b of snap.buttons) apply('button', b);
} catch { } catch {
/* the stream itself will catch up */ /* the stream itself catches up on the next redraw */
} }
}, },
events: { events: {
page(e) {
try {
apply('page', JSON.parse(e.data));
} catch {
/* not JSON */
}
},
button(e) { button(e) {
try { try {
apply('button', JSON.parse(e.data)); onButton(JSON.parse(e.data));
} catch { } catch {
/* not JSON */ /* 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 ────────────────────────────────────────────────────── */ /* ── lifecycle ────────────────────────────────────────────────────── */
return { return {
start() { start() {
build(); stopped = false;
if (pageId()) { moduleApi.setStatus('connecting', 'Connecting');
pickButton = moduleApi.header.addButton({ pickButton = moduleApi.header.addButton({
icon: GRID_SVG, icon: GRID_SVG,
title: 'Choose buttons', title: 'Choose buttons',
onClick: () => setChoosing(!choosing), onClick: () => setChoosing(!choosing),
}); });
}
observer = new ResizeObserver(() => layout()); observer = new ResizeObserver(() => layout());
observer.observe(grid); observer.observe(root);
drawPage(); syncStream();
applyStatus(); // A new tile, or one that was choosing before a gear change, goes (back) to choosing.
connect(); setChoosing(readChoosing() || !chosen().length);
}, },
stop() { stop() {
stopped = true;
clearTimeout(pagesTimer);
pagesToken += 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;
pictures.clear(); chooser = null;
cells = new Map(); view = null;
page = null; cells = [];
root.textContent = ''; root.textContent = '';
}, },
}; };
+8 -11
View File
@@ -8,9 +8,8 @@
* *
* 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, on every redraw * preview.graphics.controlId → a button's rendered image, following the
* controls.watchControl → a button's setup, read once for its kind * button if it is moved to another page
* (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.
* *
@@ -267,16 +266,17 @@ function createPageBook() {
return null; return null;
} }
/** One page by Companion's own page id (stable when pages are renumbered), or null. */ /** The control at a page number, row and column right now, or null. */
function page(id) { function at(number, row, column) {
return list().find((p) => p.id === id) || null; const page = pages.get(order[number - 1]);
return (page && page.controls[row] && page.controls[row][column]) || null;
} }
return { return {
apply, apply,
list, list,
locate, locate,
page, at,
get loaded() { get loaded() {
return order.length > 0; return order.length > 0;
}, },
@@ -303,7 +303,4 @@ function pageName(raw, number) {
return `${number} ${name}`; return `${number} ${name}`;
} }
/** Companion's navigation buttons: page up, page down, the page number. Never shown. */ module.exports = { createLink, createPageBook, pageName };
const NAV_TYPES = new Set(['pageup', 'pagedown', 'pagenum']);
module.exports = { createLink, createPageBook, pageName, NAV_TYPES };
+6 -6
View File
@@ -1,9 +1,9 @@
{ {
"id": "companion-buttons", "id": "companion-buttons",
"name": "Companion Buttons", "name": "Companion Buttons",
"version": "1.1.0", "version": "1.2.0",
"proddash": ">=1.10.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": [ "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": 6, "h": 4 }, "defaultSize": { "w": 4, "h": 4 },
"configSchema": { "configSchema": {
"companion": { "companion": {
"type": "endpoint", "type": "endpoint",
@@ -24,10 +24,10 @@
} }
}, },
"instanceSchema": { "instanceSchema": {
"layout": { "captions": {
"type": "switch", "type": "switch",
"label": "Page layout", "label": "Captions",
"default": true "default": false
}, },
"page": { "page": {
"type": "select", "type": "select",
+165 -195
View File
@@ -3,41 +3,43 @@
/** /**
* Companion Buttons — server part. * Companion Buttons — server part.
* *
* Shows Bitfocus Companion pages on dashboard tiles, every button exactly as * Shows any button from Bitfocus Companion, on any page, exactly as Companion
* Companion draws it: colours, text, icons and live feedback included. * draws it: colours, text, icons and live feedback included. ProdDash holds
* ProdDash holds one connection to Companion (companion.js) however many * one connection to Companion (companion.js) however many dashboards are open;
* dashboards are open; browsers only ever talk to these routes. * browsers only ever talk to these routes.
* *
* Each Companion page is an entry in the + Add-tile picker (tiles()), titled * GET /pages → every page and the buttons on it (choosing)
* with the page's name. A tile shows its whole page, or the buttons chosen on * GET /page-options → the pages, for the gear's Page
* it (the tile's own choosing mode). * 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 * <id> is Companion's control id without its "bank:" prefix, and each <b> is
* GET /stream/<page>[/<r.c>/…] * page.row.column.id — "19.1.4.t4bp4ZhrQblkr3bPSEBbc" — in the characters
* → SSE for one tile: `page` (what to lay out) on * ProdDash's page stream carries. A tile holds one stream for all its buttons:
* connect and whenever it changes, `button` (one * a page carries at most 32 streams, and a status board can show more buttons
* picture) on connect and on every redraw * than that.
* GET /state/<page>[/<r.c>/…]
* → the same as one snapshot, for a tile that joins
* a stream already open on its dashboard
* *
* <page> is Companion's own page id, which stays with the page when pages are * Which button a tile means: the control id while Companion still has it
* inserted or moved; <r.c> is a button's row and column on it. Buttons are * anywhere, so a button that is dragged elsewhere or a page inserted in front
* followed by place, so a page re-imported from a .companionconfig — every * keeps showing the same button; otherwise whatever is at the page, row and
* button gets a new id — keeps showing. A tile holds one stream for all its * column now, so a page that is re-imported (every button gets a new id) keeps
* buttons: ProdDash carries at most 32 streams per page. * 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. * 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 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 DESCRIBE_MS = 3000; // how long to wait for one button's setup const FIRST_FRAME_MS = 4000; // how long /image waits for Companion's first picture
const MAX_SPOTS = 64; const MAX_PER_STREAM = 64;
const PAGE_RE = /^[A-Za-z0-9_-]{1,64}$/; const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
const SPOT_RE = /^(-?\d{1,3})\.(-?\d{1,3})$/; const SPOT_RE = /^(\d{1,4})\.(-?\d{1,3})\.(-?\d{1,3})\.([A-Za-z0-9_-]{1,64})$/;
let current = null; let current = null;
@@ -51,89 +53,37 @@ function sendJson(res, code, body) {
res.end(JSON.stringify(body)); res.end(JSON.stringify(body));
} }
/** "/<page>" → { pageId, keys: null } (the whole page); "/<page>/0.1/2.3" → { pageId, keys: ['0.1', '2.3'] }. */ /** "/19.1.4.t4bp4Z…/2.0.0.QOPCk…" → [{ key, page, row, column, id }], or null if malformed. */
function viewFrom(req) { function spotsFrom(req) {
const [pageId, ...rest] = String(req.wildcard || '').replace(/^\//, '').split('/'); const parts = String(req.wildcard || '').replace(/^\//, '').split('/');
if (!PAGE_RE.test(pageId || '') || rest.length > MAX_SPOTS) return null; if (!parts.length || parts.length > MAX_PER_STREAM) return null;
if (!rest.length) return { pageId, keys: null }; const spots = [];
if (!rest.every((k) => SPOT_RE.test(k))) return null; for (const key of new Set(parts)) {
return { pageId, keys: [...new Set(rest)] }; 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 = { 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, linger, off } const buttons = new Map(); // controlId → { image, seen, refs, waiters, linger, off }
const known = new Map(); // controlId → { type } const viewers = new Set(); // { res, spots: [{ key, page, row, column, id, target }] }
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" };
/* — what kind of button each is (read once per button; navigation is never shown) — */ /* — following a button in Companion — */
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, linger: null, off: null }; b = { image: null, seen: false, refs: 0, waiters: new Set(), linger: null, off: null };
buttons.set(controlId, b); buttons.set(controlId, b);
if (link) { if (link) {
b.off = link.subscribe( b.off = link.subscribe(
@@ -144,6 +94,8 @@ 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);
}, },
@@ -156,63 +108,64 @@ 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) return; if (!b || b.refs > 0 || b.waiters.size) return;
clearTimeout(b.linger); clearTimeout(b.linger);
b.linger = setTimeout(() => { b.linger = setTimeout(() => {
if (b.refs > 0) return; if (b.refs > 0 || b.waiters.size) return;
if (b.off) b.off(); if (b.off) b.off();
buttons.delete(controlId); buttons.delete(controlId);
}, LINGER_MS); }, LINGER_MS);
} }
/* — one tile's view of one page — */ /** The control a tile's spot means right now (see the header), or null. */
function resolve(spot) {
/** The buttons a viewer shows right now: its ticked places, or every non-navigation button on the page. */ if (!pages.loaded) return spot.id;
function spotsFor(viewer, page) { if (pages.locate(spot.id)) return spot.id;
if (!page) return []; return pages.at(spot.page, spot.row, spot.column);
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 pageBody(spots, page) { function point(spot) {
return { const target = resolve(spot);
status: status.state, if (target === spot.target) return false;
message: status.message, if (spot.target) {
found: Boolean(page) || !pages.loaded, const old = buttons.get(spot.target);
name: page ? page.name : '', if (old) old.refs -= 1;
number: page ? page.number : 0, release(spot.target);
spots: spots.map(({ key, row, column }) => ({ key, row, column })), }
}; 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; const b = spot.target ? buttons.get(spot.target) : null;
return { return {
key: spot.key, key: spot.key,
controlId: spot.target,
status: status.state,
message: status.message,
image: b ? b.image : null, image: b ? b.image : null,
// A place with nothing on it is as answered as it will get. // A spot with nothing there is as answered as it will get; a button whose
seen: b ? b.seen : pages.loaded, // 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 frame = (spot) => `event: button\ndata: ${JSON.stringify(body(spot))}\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 * What a stream sends on connect, for a tile that joins a stream the
* already open on the page (a remount after a settings change): ProdDash * dashboard already has open — ProdDash reuses the connection when a tile
* reuses the connection, so the tile asks for this on `open` instead. * remounts after a settings change, so nothing new arrives on its own.
*/ */
function snapshot(view) { function snapshot(spots) {
const page = pages.page(view.pageId); return spots.map((s) => body({ ...s, target: resolve(s) }));
const spots = spotsFor({ keys: view.keys }, page);
return { page: pageBody(spots, page), buttons: spots.map(buttonBody) };
} }
function write(viewer, chunk) { function write(viewer, chunk) {
@@ -223,73 +176,59 @@ module.exports = {
} }
} }
/** Bring a viewer up to date with the page book; send what changed. */ function sendButton(controlId) {
function refresh(viewer, force) { for (const v of viewers) for (const spot of v.spots) if (spot.target === controlId) write(v, frame(spot));
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) refresh(v, false); for (const v of viewers) {
} for (const spot of v.spots) {
point(spot);
function sendButton(controlId) { write(v, frame(spot));
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 s of viewer.spots) { for (const spot of viewer.spots) {
if (!s.target) continue; if (!spot.target) continue;
const b = buttons.get(s.target); const b = buttons.get(spot.target);
if (b) b.refs -= 1; if (b) b.refs -= 1;
release(s.target); release(spot.target);
} }
} }
function addViewer(res, view) { function addViewer(res, spots) {
const viewer = { res, pageId: view.pageId, keys: view.keys, spots: [], sig: '' }; const viewer = { res, spots: spots.map((s) => ({ ...s, target: null })) };
viewers.add(viewer); viewers.add(viewer);
refresh(viewer, true); for (const spot of viewer.spots) {
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 ? { 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); 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 (fresh) { if (change && change.type === 'init') {
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 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(); sendAll();
learnAll();
}); });
} }
@@ -297,31 +236,17 @@ 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,
snapshot, snapshot,
tileList, linked: Boolean(link),
}; };
const self = current; const self = current;
@@ -336,24 +261,23 @@ module.exports = {
} }
} }
viewers.clear(); 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(); 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 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'}` : ''; 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 /page-options': (req, res) => { 'GET /page-options': (req, res) => {
@@ -362,15 +286,59 @@ module.exports = {
}, },
'GET /state/*': (req, res) => { 'GET /state/*': (req, res) => {
const view = viewFrom(req); const spots = spotsFrom(req);
if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' }); 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' }); 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) => { 'GET /stream/*': (req, res) => {
const view = viewFrom(req); const spots = spotsFrom(req);
if (!view) return sendJson(res, 400, { error: 'Not a Companion page and buttons' }); if (!spots) return sendJson(res, 400, { error: 'Not a list of Companion 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',
@@ -379,12 +347,14 @@ module.exports = {
}); });
res.write('retry: 3000\n\n'); res.write('retry: 3000\n\n');
if (!current) { if (!current) {
const body = { status: 'error', message: 'Companion Buttons is not running', found: true, name: '', number: 0, spots: [] }; for (const s of spots) {
res.write(`event: page\ndata: ${JSON.stringify(body)}\n\n`); 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(); return void res.end();
} }
const app = current; const app = current;
const viewer = app.addViewer(res, view); const viewer = app.addViewer(res, spots);
try { try {
req.socket.setKeepAlive(true, HEARTBEAT_MS); req.socket.setKeepAlive(true, HEARTBEAT_MS);
} catch { } catch {
+100 -14
View File
@@ -11,18 +11,25 @@
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: var(--cb-size, 64px); grid-auto-rows: max-content;
gap: 6px; gap: 6px;
align-content: center; align-content: center;
justify-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 { [data-module="companion-buttons"] .cb-face {
position: relative; position: relative;
@@ -50,15 +57,45 @@
to { opacity: 0.8; } to { opacity: 0.8; }
} }
/* A ticked place with no button on it any more. */ [data-module="companion-buttons"] .cb-face.is-missing {
[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-root.is-offline .cb-face { [data-module="companion-buttons"] .cb-grid.is-offline .cb-face {
opacity: 0.3; opacity: 0.3;
filter: grayscale(1); filter: grayscale(1);
} }
@@ -79,29 +116,78 @@
[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;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 12px;
color: var(--muted); color: var(--muted);
font-size: 0.85rem; font-size: 0.85rem;
text-align: center; 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-chooser {
[data-module="companion-buttons"] .cb-root.is-choosing .cb-face { 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; cursor: pointer;
-webkit-tap-highlight-color: transparent;
} }
[data-module="companion-buttons"] .cb-root.is-choosing .cb-face:hover { [data-module="companion-buttons"] .cb-pick-img {
outline: 2px solid var(--accent); display: block;
outline-offset: -2px; width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
} }
[data-module="companion-buttons"] .cb-root.is-choosing .cb-face.is-off { [data-module="companion-buttons"] .cb-pick-img.is-broken { visibility: hidden; }
opacity: 0.25;
filter: grayscale(1); /* 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. * then point Companion Buttons at host 127.0.0.1, port 18000 in /admin.
* *
* It serves the three subscriptions the module uses — pages.watch, * It serves the two subscriptions the module uses — pages.watch and
* preview.graphics.controlId and controls.watchControl — over a hand-rolled * preview.graphics.controlId — over a hand-rolled websocket (no deps), with
* websocket (no deps), with three pages: a status page with a page-up * three pages. Page 1's first button flips between green and red every two
* 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,
@@ -124,22 +123,9 @@ 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' }, 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' } } }, p2: { id: 'p2', name: 'Cameras', controls: { 0: { 0: 'bank:cam1' }, 1: { 3: 'bank:cam2' } } },
p3: { id: 'p3', name: 'PAGE', controls: { 0: { 0: 'bank:up3' } } }, p3: { id: 'p3', name: 'PAGE', controls: {} },
};
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 = {
@@ -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' } }); 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}"` } });
} }
@@ -298,7 +276,6 @@ 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();
+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) { function listen(port, path) {
const pages = []; const events = [];
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) => {
@@ -79,23 +78,13 @@ 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) continue; if (name && data && name[1] === 'button') events.push(JSON.parse(data[1]));
if (name[1] === 'page') pages.push(JSON.parse(data[1]));
if (name[1] === 'button') buttons.push(JSON.parse(data[1]));
} }
}); });
resolve(); resolve();
}); });
}); });
return { return { events, ready, close: () => req.destroy(), last: () => events[events.length - 1] };
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 ────────────────────────────────────────────────────────── */
@@ -146,8 +135,6 @@ 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 ─────────────────────────────────────────────────── */
@@ -165,91 +152,115 @@ 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/);
// the + picker: one entry per page with something to show — not the page that is only navigation // pages for the picker
await until('nav buttons learned', () => serverEntry.tiles().length === 2); const pages = JSON.parse((await get(at, '/pages')).body);
const tiles = serverEntry.tiles(); assert.strictEqual(pages.status, 'ok');
assert.deepStrictEqual(tiles.map((t) => [t.id, t.name]), [['p1', '1 Status'], ['p2', '2 Cameras']]); assert.deepStrictEqual(pages.pages.map((p) => p.name), ['1 Status', '2 Cameras', 'Page 3']);
assert.deepStrictEqual(tiles[0].settings, { page: 'p1' }); assert.deepStrictEqual(pages.pages[1].buttons, [
assert.deepStrictEqual(Object.keys(tiles[0].instanceSchema), ['layout']); { row: 0, column: 0, controlId: 'bank:cam1' },
{ 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));
// the snapshot a remounted tile asks for matches what the stream sent // thumbnails
const snap = JSON.parse((await get(at, '/state/p1')).body); const img = await get(at, '/image/blue');
assert.deepStrictEqual(snap.page.spots, whole.page().spots); assert.strictEqual(img.status, 200);
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(img.type, 'image/png');
assert.strictEqual((await get(at, '/state/p1/x')).status, 400); 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 // the gear's Page list, by Companion's page id
const steadyCount = whole.buttons.filter((b) => b.key === '0.1').length; 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); 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 // a status button that changes is forwarded
const firstLook = whole.pic('0.0').image; const flip = listen(at, '/stream/1.0.0.flip');
await until('flip redraw', () => whole.pic('0.0').image !== firstLook, 4000); 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 // one stream carries all of a tile's buttons: each is sent on connect
const some = listen(at, '/stream/p2/1.3/0.0'); const pair = listen(at, '/stream/1.0.2.blue/2.1.3.cam2/1.0.2.blue');
await some.ready; await pair.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')); await until('both buttons', () => new Set(pair.events.filter((e) => e.image).map((e) => e.controlId)).size === 2);
assert.deepStrictEqual(some.page().spots.map((s) => s.key), ['1.3', '0.0']); 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 // a button that is gone, with nothing at its place either, says so instead of hanging
const hole = listen(at, '/stream/p2/3.3'); const gone = listen(at, '/stream/3.0.0.gone');
await hole.ready; await gone.ready;
await until('empty place answered', () => hole.pic('3.3') && hole.pic('3.3').seen); await until('missing button answered', () => gone.last() && gone.last().seen);
assert.strictEqual(hole.pic('3.3').image, null); assert.strictEqual(gone.last().image, null);
hole.close(); assert.strictEqual(gone.last().controlId, null);
// a page that does not exist says so // an unknown id falls back to what is at its page/row/column
const missing = listen(at, '/stream/nope'); const byPlace = listen(at, '/stream/2.0.0.oldid');
await missing.ready; await byPlace.ready;
await until('missing page', () => missing.page()); await until('found by place', () => byPlace.last() && byPlace.last().image);
assert.strictEqual(missing.page().found, false); assert.strictEqual(byPlace.last().controlId, 'bank:cam1');
missing.close(); byPlace.close();
// bad paths // the button moves to another page in Companion: the tile follows it
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: [{ 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('moved location', () => steady.last().location && steady.last().location.page === 2);
await until('re-imported button still shown', () => some.pic('1.3') && some.pic('1.3').image); assert.strictEqual(steady.last().controlId, 'bank:steady');
// a page inserted in front: the tile follows its page, now page 3 // the page is re-imported: every button gets a new id, the tile keeps its place
mock.pageUpdate({ type: 'update', updatedOrder: ['p0', 'p1', 'p2', 'p3'], added: [{ id: 'p0', name: 'New', controls: {} }], changes: [] }); mock.looks['bank:blue-new'] = mock.looks['bank:blue'];
await until('renumbered', () => some.page().number === 3 && some.page().name === '3 Cameras Renamed'); const blue = listen(at, '/stream/1.0.2.blue');
some.close(); await blue.ready;
await until('blue', () => blue.last() && blue.last().image);
// a button added to a whole-page tile appears in it mock.pageUpdate({
mock.looks['bank:new'] = mock.looks['bank:blue']; type: 'update',
mock.configs['bank:new'] = mock.configs['bank:blue']; updatedOrder: null,
mock.pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [{ id: 'p1', name: null, controls: [{ row: 2, column: 2, controlId: 'bank:new' }] }] }); added: [],
await until('new button shown', () => whole.page().spots.some((s) => s.key === '2.2') && whole.pic('2.2') && whole.pic('2.2').image); changes: [{ id: 'p1', name: null, controls: [{ row: 0, column: 2, controlId: 'bank:blue-new' }] }],
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();
@@ -266,7 +277,9 @@ 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
whole.close(); steady.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 {
@@ -285,12 +298,14 @@ 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/);
assert.deepStrictEqual(JSON.parse((await get(at, '/page-options')).body).options, []); const pages = JSON.parse((await get(at, '/pages')).body);
assert.deepStrictEqual(serverEntry.tiles(), []); assert.strictEqual(pages.status, 'error');
const s = listen(at, '/stream/p1'); 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 s.ready;
await until('stream answers', () => s.page()); await until('stream answers', () => s.last());
assert.strictEqual(s.page().status, 'error'); assert.strictEqual(s.last().status, 'error');
s.close(); s.close();
} finally { } finally {
handle.stop(); handle.stop();