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:
+5
-28
@@ -8,10 +8,9 @@
|
||||
*
|
||||
* then point Companion Buttons at host 127.0.0.1, port 18000 in /admin.
|
||||
*
|
||||
* It serves the three subscriptions the module uses — pages.watch,
|
||||
* preview.graphics.controlId and controls.watchControl — over a hand-rolled
|
||||
* websocket (no deps), with three pages: a status page with a page-up
|
||||
* button, a camera page, and an unnamed page holding only navigation. Page 1's first button flips between green and red every two
|
||||
* It serves the two subscriptions the module uses — pages.watch and
|
||||
* preview.graphics.controlId — over a hand-rolled websocket (no deps), with
|
||||
* three pages. Page 1's first button flips between green and red every two
|
||||
* seconds, like a status button; page 1's second button redraws the same
|
||||
* picture every 300 ms, like a button fed a variable that has not changed.
|
||||
* Every 30 s it sends PING and drops a client that does not answer PONG,
|
||||
@@ -124,22 +123,9 @@ function readFrames(state, data, onText, onClose) {
|
||||
|
||||
function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} } = {}) {
|
||||
const pages = {
|
||||
p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' }, 1: { 0: 'bank:up1' } } },
|
||||
p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' } } },
|
||||
p2: { id: 'p2', name: 'Cameras', controls: { 0: { 0: 'bank:cam1' }, 1: { 3: 'bank:cam2' } } },
|
||||
p3: { id: 'p3', name: 'PAGE', controls: { 0: { 0: 'bank:up3' } } },
|
||||
};
|
||||
const layered = (value, isExpression = false) => ({
|
||||
type: 'button-layered',
|
||||
style: { layers: [{ id: 'canvas', type: 'canvas' }, { id: 'text0', type: 'text', text: { value, isExpression } }] },
|
||||
});
|
||||
const configs = {
|
||||
'bank:flip': layered('FLIP'),
|
||||
'bank:steady': layered('Steady'),
|
||||
'bank:blue': layered('Blue'),
|
||||
'bank:cam1': { type: 'button', style: { text: 'CAM 1' } },
|
||||
'bank:cam2': layered('CAM 2'),
|
||||
'bank:up1': { type: 'pageup', style: {} },
|
||||
'bank:up3': { type: 'pageup', style: {} },
|
||||
p3: { id: 'p3', name: 'PAGE', controls: {} },
|
||||
};
|
||||
const order = ['p1', 'p2', 'p3'];
|
||||
const looks = {
|
||||
@@ -198,14 +184,6 @@ function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} }
|
||||
}
|
||||
if (msg.method !== 'subscription') return send(client, { id: msg.id, error: { message: 'mock: only subscriptions' } });
|
||||
const { path, input } = msg.params || {};
|
||||
if (path === 'controls.watchControl') {
|
||||
const cfg = configs[input && input.controlId];
|
||||
if (!cfg) return send(client, { id: msg.id, error: { message: `Control ${input && input.controlId} not found` } });
|
||||
stats.subscriptions += 1;
|
||||
client.subs.set(msg.id, { path, input });
|
||||
send(client, { id: msg.id, result: { type: 'started' } });
|
||||
return send(client, { id: msg.id, result: { type: 'data', data: { type: 'init', config: cfg, runtime: {} } } });
|
||||
}
|
||||
if (path !== 'pages.watch' && path !== 'preview.graphics.controlId') {
|
||||
return send(client, { id: msg.id, error: { message: `No procedure found on path "${path}"` } });
|
||||
}
|
||||
@@ -298,7 +276,6 @@ function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} }
|
||||
redraw,
|
||||
looks,
|
||||
pages,
|
||||
configs,
|
||||
/** Drop every client without a close frame, as a crash or a cable pull would. */
|
||||
dropAll() {
|
||||
for (const c of clients) c.socket.destroy();
|
||||
|
||||
+107
-92
@@ -63,10 +63,9 @@ function get(port, path) {
|
||||
});
|
||||
}
|
||||
|
||||
/** An SSE client that collects `page` and `button` events. */
|
||||
/** An SSE client that collects `button` events. */
|
||||
function listen(port, path) {
|
||||
const pages = [];
|
||||
const buttons = [];
|
||||
const events = [];
|
||||
let req;
|
||||
const ready = new Promise((resolve) => {
|
||||
req = http.get({ host: '127.0.0.1', port, path }, (res) => {
|
||||
@@ -79,23 +78,13 @@ function listen(port, path) {
|
||||
buf = buf.slice(at + 2);
|
||||
const name = /^event: (.+)$/m.exec(block);
|
||||
const data = /^data: (.+)$/m.exec(block);
|
||||
if (!name || !data) continue;
|
||||
if (name[1] === 'page') pages.push(JSON.parse(data[1]));
|
||||
if (name[1] === 'button') buttons.push(JSON.parse(data[1]));
|
||||
if (name && data && name[1] === 'button') events.push(JSON.parse(data[1]));
|
||||
}
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
return {
|
||||
pages,
|
||||
buttons,
|
||||
ready,
|
||||
close: () => req.destroy(),
|
||||
page: () => pages[pages.length - 1],
|
||||
/** The latest picture for one row.column, or undefined. */
|
||||
pic: (key) => [...buttons].reverse().find((b) => b.key === key),
|
||||
};
|
||||
return { events, ready, close: () => req.destroy(), last: () => events[events.length - 1] };
|
||||
}
|
||||
|
||||
/* ── pure parts ────────────────────────────────────────────────────────── */
|
||||
@@ -146,8 +135,6 @@ function testPageBook() {
|
||||
book.apply({ type: 'update', updatedOrder: ['c', 'b'], added: [], changes: [] });
|
||||
assert.strictEqual(book.locate('bank:x'), null);
|
||||
assert.strictEqual(book.count, 2);
|
||||
assert.strictEqual(book.page('b').number, 2);
|
||||
assert.strictEqual(book.page('a'), null);
|
||||
}
|
||||
|
||||
/* ── against the mock ─────────────────────────────────────────────────── */
|
||||
@@ -165,91 +152,115 @@ async function testLive() {
|
||||
await until('Companion connected', () => handle.health().status === 'ok');
|
||||
assert.match(handle.health().message, /3 pages/);
|
||||
|
||||
// the + picker: one entry per page with something to show — not the page that is only navigation
|
||||
await until('nav buttons learned', () => serverEntry.tiles().length === 2);
|
||||
const tiles = serverEntry.tiles();
|
||||
assert.deepStrictEqual(tiles.map((t) => [t.id, t.name]), [['p1', '1 Status'], ['p2', '2 Cameras']]);
|
||||
assert.deepStrictEqual(tiles[0].settings, { page: 'p1' });
|
||||
assert.deepStrictEqual(Object.keys(tiles[0].instanceSchema), ['layout']);
|
||||
|
||||
const pageOptions = JSON.parse((await get(at, '/page-options')).body).options;
|
||||
assert.deepStrictEqual(pageOptions.map((o) => o.label), ['1 Status', '2 Cameras', 'Page 3']);
|
||||
|
||||
// a whole page: every button but the page-up one, laid out where Companion has them
|
||||
const whole = listen(at, '/stream/p1');
|
||||
await whole.ready;
|
||||
await until('whole page', () => whole.page() && whole.page().status === 'ok' && whole.page().spots.length === 3);
|
||||
assert.deepStrictEqual(whole.page().spots, [
|
||||
{ key: '0.0', row: 0, column: 0 },
|
||||
{ key: '0.1', row: 0, column: 1 },
|
||||
{ key: '0.2', row: 0, column: 2 },
|
||||
// pages for the picker
|
||||
const pages = JSON.parse((await get(at, '/pages')).body);
|
||||
assert.strictEqual(pages.status, 'ok');
|
||||
assert.deepStrictEqual(pages.pages.map((p) => p.name), ['1 Status', '2 Cameras', 'Page 3']);
|
||||
assert.deepStrictEqual(pages.pages[1].buttons, [
|
||||
{ row: 0, column: 0, controlId: 'bank:cam1' },
|
||||
{ row: 1, column: 3, controlId: 'bank:cam2' },
|
||||
]);
|
||||
assert.strictEqual(whole.page().name, '1 Status');
|
||||
await until('three pictures', () => ['0.0', '0.1', '0.2'].every((k) => whole.pic(k) && whole.pic(k).image));
|
||||
|
||||
// the snapshot a remounted tile asks for matches what the stream sent
|
||||
const snap = JSON.parse((await get(at, '/state/p1')).body);
|
||||
assert.deepStrictEqual(snap.page.spots, whole.page().spots);
|
||||
assert.deepStrictEqual(snap.buttons.map((b) => [b.key, Boolean(b.image), b.seen]), [['0.0', true, true], ['0.1', true, true], ['0.2', true, true]]);
|
||||
assert.strictEqual((await get(at, '/state/p1/x')).status, 400);
|
||||
// thumbnails
|
||||
const img = await get(at, '/image/blue');
|
||||
assert.strictEqual(img.status, 200);
|
||||
assert.strictEqual(img.type, 'image/png');
|
||||
assert.deepStrictEqual([...img.body.subarray(1, 4)], [0x50, 0x4e, 0x47]);
|
||||
assert.strictEqual((await get(at, '/image/gone')).status, 404);
|
||||
assert.strictEqual((await get(at, '/image/..%2Fetc')).status, 400);
|
||||
assert.strictEqual((await get(at, `/image/${'x'.repeat(200)}`)).status, 400);
|
||||
|
||||
// a button that redraws the same picture every 300 ms is sent once, not five times
|
||||
const steadyCount = whole.buttons.filter((b) => b.key === '0.1').length;
|
||||
// the gear's Page list, by Companion's page id
|
||||
const pageOptions = JSON.parse((await get(at, '/page-options')).body).options;
|
||||
assert.deepStrictEqual(pageOptions, [
|
||||
{ value: 'p1', label: '1 Status' },
|
||||
{ value: 'p2', label: '2 Cameras' },
|
||||
{ value: 'p3', label: 'Page 3' },
|
||||
]);
|
||||
|
||||
// a tile that joins a stream its dashboard already has open asks for the snapshot instead
|
||||
const warm = listen(at, '/stream/1.0.2.blue');
|
||||
await warm.ready;
|
||||
await until('blue streamed', () => warm.last() && warm.last().image);
|
||||
const snap = JSON.parse((await get(at, '/state/1.0.2.blue/2.0.0.oldid/3.0.0.gone')).body).buttons;
|
||||
assert.deepStrictEqual(snap.map((b) => [b.key, b.controlId, Boolean(b.image), b.seen]), [
|
||||
['1.0.2.blue', 'bank:blue', true, true],
|
||||
['2.0.0.oldid', 'bank:cam1', false, false],
|
||||
['3.0.0.gone', null, false, true],
|
||||
]);
|
||||
assert.strictEqual(snap[0].image, warm.last().image);
|
||||
assert.deepStrictEqual(snap[0].location, { page: 1, row: 0, column: 2, pageName: '1 Status' });
|
||||
assert.strictEqual((await get(at, '/state/blue')).status, 400);
|
||||
warm.close();
|
||||
|
||||
// a button that redraws the same picture every 300 ms sends one event, not five
|
||||
const steady = listen(at, '/stream/1.0.1.steady');
|
||||
await steady.ready;
|
||||
await until('steady first frame', () => steady.last() && steady.last().image);
|
||||
const settled = steady.events.length;
|
||||
await sleep(1600);
|
||||
assert.strictEqual(whole.buttons.filter((b) => b.key === '0.1').length, steadyCount, 'identical redraws must not be re-sent');
|
||||
assert.strictEqual(steady.events.length, settled, 'identical redraws must not be re-sent');
|
||||
assert.strictEqual(steady.last().key, '1.0.1.steady');
|
||||
assert.strictEqual(steady.last().controlId, 'bank:steady');
|
||||
assert.deepStrictEqual(steady.last().location, { page: 1, row: 0, column: 1, pageName: '1 Status' });
|
||||
|
||||
// a status button that changes is forwarded
|
||||
const firstLook = whole.pic('0.0').image;
|
||||
await until('flip redraw', () => whole.pic('0.0').image !== firstLook, 4000);
|
||||
const flip = listen(at, '/stream/1.0.0.flip');
|
||||
await flip.ready;
|
||||
await until('flip first frame', () => flip.last() && flip.last().image);
|
||||
const firstLook = flip.last().image;
|
||||
await until('flip redraw', () => flip.last().image !== firstLook, 4000);
|
||||
|
||||
// chosen buttons only, in the order chosen
|
||||
const some = listen(at, '/stream/p2/1.3/0.0');
|
||||
await some.ready;
|
||||
await until('two ticked', () => some.page() && some.page().spots.length === 2 && some.pic('1.3') && some.pic('1.3').image && some.pic('0.0'));
|
||||
assert.deepStrictEqual(some.page().spots.map((s) => s.key), ['1.3', '0.0']);
|
||||
// one stream carries all of a tile's buttons: each is sent on connect
|
||||
const pair = listen(at, '/stream/1.0.2.blue/2.1.3.cam2/1.0.2.blue');
|
||||
await pair.ready;
|
||||
await until('both buttons', () => new Set(pair.events.filter((e) => e.image).map((e) => e.controlId)).size === 2);
|
||||
assert.deepStrictEqual([...new Set(pair.events.map((e) => e.key))].sort(), ['1.0.2.blue', '2.1.3.cam2']);
|
||||
pair.close();
|
||||
assert.strictEqual((await get(at, `/stream/${Array.from({ length: 65 }, (_, i) => `1.0.${i}.b${i}`).join('/')}`)).status, 400);
|
||||
assert.strictEqual((await get(at, '/stream/flip')).status, 400);
|
||||
assert.strictEqual((await get(at, '/stream/1.0.0.bank:flip')).status, 400);
|
||||
|
||||
// a chosen place with nothing on it is answered, not left waiting
|
||||
const hole = listen(at, '/stream/p2/3.3');
|
||||
await hole.ready;
|
||||
await until('empty place answered', () => hole.pic('3.3') && hole.pic('3.3').seen);
|
||||
assert.strictEqual(hole.pic('3.3').image, null);
|
||||
hole.close();
|
||||
// a button that is gone, with nothing at its place either, says so instead of hanging
|
||||
const gone = listen(at, '/stream/3.0.0.gone');
|
||||
await gone.ready;
|
||||
await until('missing button answered', () => gone.last() && gone.last().seen);
|
||||
assert.strictEqual(gone.last().image, null);
|
||||
assert.strictEqual(gone.last().controlId, null);
|
||||
|
||||
// a page that does not exist says so
|
||||
const missing = listen(at, '/stream/nope');
|
||||
await missing.ready;
|
||||
await until('missing page', () => missing.page());
|
||||
assert.strictEqual(missing.page().found, false);
|
||||
missing.close();
|
||||
// an unknown id falls back to what is at its page/row/column
|
||||
const byPlace = listen(at, '/stream/2.0.0.oldid');
|
||||
await byPlace.ready;
|
||||
await until('found by place', () => byPlace.last() && byPlace.last().image);
|
||||
assert.strictEqual(byPlace.last().controlId, 'bank:cam1');
|
||||
byPlace.close();
|
||||
|
||||
// bad paths
|
||||
assert.strictEqual((await get(at, '/stream/p1/abc')).status, 400);
|
||||
assert.strictEqual((await get(at, '/stream/p:1')).status, 400);
|
||||
assert.strictEqual((await get(at, `/stream/p1/${Array.from({ length: 65 }, (_, i) => `0.${i}`).join('/')}`)).status, 400);
|
||||
|
||||
// the page is re-imported: every button gets a new id, the tile keeps showing the same places
|
||||
mock.looks['bank:cam2-new'] = mock.looks['bank:cam2'];
|
||||
mock.configs['bank:cam2-new'] = mock.configs['bank:cam2'];
|
||||
// the button moves to another page in Companion: the tile follows it
|
||||
mock.pageUpdate({
|
||||
type: 'update',
|
||||
updatedOrder: null,
|
||||
added: [],
|
||||
changes: [{ id: 'p2', name: 'Cameras Renamed', controls: [{ row: 1, column: 3, controlId: 'bank:cam2-new' }] }],
|
||||
changes: [
|
||||
{ id: 'p1', name: null, controls: [{ row: 0, column: 1, controlId: null }] },
|
||||
{ id: 'p2', name: null, controls: [{ row: 1, column: 0, controlId: 'bank:steady' }] },
|
||||
],
|
||||
});
|
||||
await until('renamed', () => some.page().name === '2 Cameras Renamed');
|
||||
await until('re-imported button still shown', () => some.pic('1.3') && some.pic('1.3').image);
|
||||
await until('moved location', () => steady.last().location && steady.last().location.page === 2);
|
||||
assert.strictEqual(steady.last().controlId, 'bank:steady');
|
||||
|
||||
// a page inserted in front: the tile follows its page, now page 3
|
||||
mock.pageUpdate({ type: 'update', updatedOrder: ['p0', 'p1', 'p2', 'p3'], added: [{ id: 'p0', name: 'New', controls: {} }], changes: [] });
|
||||
await until('renumbered', () => some.page().number === 3 && some.page().name === '3 Cameras Renamed');
|
||||
some.close();
|
||||
|
||||
// a button added to a whole-page tile appears in it
|
||||
mock.looks['bank:new'] = mock.looks['bank:blue'];
|
||||
mock.configs['bank:new'] = mock.configs['bank:blue'];
|
||||
mock.pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [{ id: 'p1', name: null, controls: [{ row: 2, column: 2, controlId: 'bank:new' }] }] });
|
||||
await until('new button shown', () => whole.page().spots.some((s) => s.key === '2.2') && whole.pic('2.2') && whole.pic('2.2').image);
|
||||
const flip = { last: () => ({ status: whole.page().status, image: whole.pic('0.0') && whole.pic('0.0').image }) };
|
||||
// the page is re-imported: every button gets a new id, the tile keeps its place
|
||||
mock.looks['bank:blue-new'] = mock.looks['bank:blue'];
|
||||
const blue = listen(at, '/stream/1.0.2.blue');
|
||||
await blue.ready;
|
||||
await until('blue', () => blue.last() && blue.last().image);
|
||||
mock.pageUpdate({
|
||||
type: 'update',
|
||||
updatedOrder: null,
|
||||
added: [],
|
||||
changes: [{ id: 'p1', name: null, controls: [{ row: 0, column: 2, controlId: 'bank:blue-new' }] }],
|
||||
});
|
||||
await until('re-imported button found by place', () => blue.last().controlId === 'bank:blue-new' && blue.last().image);
|
||||
blue.close();
|
||||
|
||||
// Companion drops: streams say so, then recover by themselves
|
||||
mock.dropAll();
|
||||
@@ -266,7 +277,9 @@ async function testLive() {
|
||||
await until('stream live after restart', () => flip.last().status === 'ok' && flip.last().image, 4000);
|
||||
|
||||
// closing a viewer keeps the subscription a little while (remounts are cheap), stop() ends all
|
||||
whole.close();
|
||||
steady.close();
|
||||
flip.close();
|
||||
gone.close();
|
||||
await sleep(100);
|
||||
assert.ok(mock.clients.size === 1, 'one connection to Companion, however many viewers');
|
||||
} finally {
|
||||
@@ -285,12 +298,14 @@ async function testUnconfigured() {
|
||||
try {
|
||||
assert.strictEqual(handle.health().status, 'error');
|
||||
assert.match(handle.health().message, /admin/);
|
||||
assert.deepStrictEqual(JSON.parse((await get(at, '/page-options')).body).options, []);
|
||||
assert.deepStrictEqual(serverEntry.tiles(), []);
|
||||
const s = listen(at, '/stream/p1');
|
||||
const pages = JSON.parse((await get(at, '/pages')).body);
|
||||
assert.strictEqual(pages.status, 'error');
|
||||
assert.deepStrictEqual(pages.pages, []);
|
||||
assert.strictEqual((await get(at, '/image/x')).status, 503);
|
||||
const s = listen(at, '/stream/1.0.0.x');
|
||||
await s.ready;
|
||||
await until('stream answers', () => s.page());
|
||||
assert.strictEqual(s.page().status, 'error');
|
||||
await until('stream answers', () => s.last());
|
||||
assert.strictEqual(s.last().status, 'error');
|
||||
s.close();
|
||||
} finally {
|
||||
handle.stop();
|
||||
|
||||
Reference in New Issue
Block a user