The picker row (page list, Clear, Done) is gone. Each Companion page is now its own + entry, titled with the page's name, showing the whole page minus page up/down. The grid icon switches the tile to choosing: click buttons to take them out or put them back, click the icon again when done. The gear holds one switch, Page layout (Companion's rows and columns, or packed). - Tiles follow their page by Companion's page id and buttons by row/column, so inserted pages and re-imported pages keep showing. - A tile remounted after a settings change asks GET /state for the snapshot the already-open stream sent, instead of waiting blank. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
329 lines
11 KiB
JavaScript
329 lines
11 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* A stand-in for Companion's tRPC websocket, so the module can be built and
|
|
* tested without the real thing:
|
|
*
|
|
* node test/companion-mock.js 18000
|
|
*
|
|
* then point Companion Buttons at host 127.0.0.1, port 18000 in /admin.
|
|
*
|
|
* It serves the 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
|
|
* seconds, like a status button; page 1's second button redraws the same
|
|
* picture every 300 ms, like a button fed a variable that has not changed.
|
|
* Every 30 s it sends PING and drops a client that does not answer PONG,
|
|
* the way Companion does.
|
|
*
|
|
* --rename-after <s> rename page 2 after s seconds (an `update` change)
|
|
* --move-after <s> move the flipping button to page 2 after s seconds
|
|
*/
|
|
|
|
const http = require('http');
|
|
const crypto = require('crypto');
|
|
const zlib = require('zlib');
|
|
|
|
/* ── a solid-colour PNG, with a lighter band so pictures differ ─────── */
|
|
|
|
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
let c = n;
|
|
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
return c >>> 0;
|
|
});
|
|
|
|
function crc32(buf) {
|
|
let c = 0xffffffff;
|
|
for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8);
|
|
return (c ^ 0xffffffff) >>> 0;
|
|
}
|
|
|
|
function chunk(type, data) {
|
|
const len = Buffer.alloc(4);
|
|
len.writeUInt32BE(data.length);
|
|
const body = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
|
const crc = Buffer.alloc(4);
|
|
crc.writeUInt32BE(crc32(body));
|
|
return Buffer.concat([len, body, crc]);
|
|
}
|
|
|
|
function png([r, g, b], size = 72) {
|
|
const ihdr = Buffer.alloc(13);
|
|
ihdr.writeUInt32BE(size, 0);
|
|
ihdr.writeUInt32BE(size, 4);
|
|
ihdr[8] = 8; // bit depth
|
|
ihdr[9] = 2; // RGB
|
|
const rows = [];
|
|
for (let y = 0; y < size; y += 1) {
|
|
const row = Buffer.alloc(1 + size * 3);
|
|
const band = y > size * 0.4 && y < size * 0.6;
|
|
for (let x = 0; x < size; x += 1) {
|
|
row[1 + x * 3] = band ? 255 : r;
|
|
row[2 + x * 3] = band ? 255 : g;
|
|
row[3 + x * 3] = band ? 255 : b;
|
|
}
|
|
rows.push(row);
|
|
}
|
|
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
const out = Buffer.concat([sig, chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(Buffer.concat(rows))), chunk('IEND', Buffer.alloc(0))]);
|
|
return `data:image/png;base64,${out.toString('base64')}`;
|
|
}
|
|
|
|
/* ── the minimal websocket server (RFC 6455, text frames only) ────────── */
|
|
|
|
function frame(text) {
|
|
const payload = Buffer.from(text, 'utf8');
|
|
let head;
|
|
if (payload.length < 126) {
|
|
head = Buffer.from([0x81, payload.length]);
|
|
} else if (payload.length < 65536) {
|
|
head = Buffer.alloc(4);
|
|
head[0] = 0x81;
|
|
head[1] = 126;
|
|
head.writeUInt16BE(payload.length, 2);
|
|
} else {
|
|
head = Buffer.alloc(10);
|
|
head[0] = 0x81;
|
|
head[1] = 127;
|
|
head.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
}
|
|
return Buffer.concat([head, payload]);
|
|
}
|
|
|
|
function readFrames(state, data, onText, onClose) {
|
|
state.buf = Buffer.concat([state.buf, data]);
|
|
for (;;) {
|
|
const b = state.buf;
|
|
if (b.length < 2) return;
|
|
const op = b[0] & 0x0f;
|
|
let len = b[1] & 0x7f;
|
|
let off = 2;
|
|
if (len === 126) {
|
|
if (b.length < 4) return;
|
|
len = b.readUInt16BE(2);
|
|
off = 4;
|
|
} else if (len === 127) {
|
|
if (b.length < 10) return;
|
|
len = Number(b.readBigUInt64BE(2));
|
|
off = 10;
|
|
}
|
|
const masked = (b[1] & 0x80) !== 0;
|
|
const mask = masked ? b.subarray(off, off + 4) : null;
|
|
if (masked) off += 4;
|
|
if (b.length < off + len) return;
|
|
const payload = Buffer.from(b.subarray(off, off + len));
|
|
if (mask) for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
|
|
state.buf = b.subarray(off + len);
|
|
if (op === 0x8) return onClose();
|
|
if (op === 0x1) onText(payload.toString('utf8'));
|
|
}
|
|
}
|
|
|
|
/* ── Companion's side ─────────────────────────────────────────────────── */
|
|
|
|
function startMock(port = 0, { renameAfter = 0, moveAfter = 0, log = () => {} } = {}) {
|
|
const pages = {
|
|
p1: { id: 'p1', name: '1\\nStatus', controls: { 0: { 0: 'bank:flip', 1: 'bank:steady', 2: 'bank:blue' }, 1: { 0: 'bank:up1' } } },
|
|
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: {} },
|
|
};
|
|
const order = ['p1', 'p2', 'p3'];
|
|
const looks = {
|
|
'bank:steady': png([200, 140, 0]),
|
|
'bank:blue': png([20, 60, 200]),
|
|
'bank:cam1': png([90, 90, 90]),
|
|
'bank:cam2': png([120, 0, 120]),
|
|
};
|
|
const GREEN = png([0, 160, 60]);
|
|
const RED = png([200, 20, 20]);
|
|
let flipRed = false;
|
|
looks['bank:flip'] = GREEN;
|
|
|
|
const clients = new Set();
|
|
const stats = { connections: 0, pongs: 0, subscriptions: 0, stops: 0 };
|
|
|
|
function send(client, msg) {
|
|
if (client.closed) return;
|
|
try {
|
|
client.socket.write(frame(typeof msg === 'string' ? msg : JSON.stringify(msg)));
|
|
} catch {
|
|
/* gone */
|
|
}
|
|
}
|
|
|
|
function pageUpdate(change) {
|
|
for (const c of clients) for (const [id, sub] of c.subs) if (sub.path === 'pages.watch') send(c, { id, result: { type: 'data', data: change } });
|
|
}
|
|
|
|
function redraw(controlId) {
|
|
for (const c of clients) {
|
|
for (const [id, sub] of c.subs) {
|
|
if (sub.path === 'preview.graphics.controlId' && sub.input.controlId === controlId) {
|
|
send(c, { id, result: { type: 'data', data: looks[controlId] || null } });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function onMessage(client, text) {
|
|
if (text === 'PONG') {
|
|
client.awaitingPong = false;
|
|
stats.pongs += 1;
|
|
return;
|
|
}
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(text);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (msg.method === 'subscription.stop') {
|
|
client.subs.delete(msg.id);
|
|
stats.stops += 1;
|
|
return send(client, { id: msg.id, result: { type: 'stopped' } });
|
|
}
|
|
if (msg.method !== 'subscription') return send(client, { id: msg.id, error: { message: 'mock: only subscriptions' } });
|
|
const { path, input } = msg.params || {};
|
|
if (path === '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}"` } });
|
|
}
|
|
stats.subscriptions += 1;
|
|
client.subs.set(msg.id, { path, input });
|
|
send(client, { id: msg.id, result: { type: 'started' } });
|
|
if (path === 'pages.watch') {
|
|
send(client, { id: msg.id, result: { type: 'data', data: { type: 'init', order: [...order], pages: JSON.parse(JSON.stringify(pages)) } } });
|
|
} else {
|
|
send(client, { id: msg.id, result: { type: 'data', data: looks[input.controlId] || null } });
|
|
}
|
|
}
|
|
|
|
const server = http.createServer((req, res) => {
|
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
res.end('companion mock\n');
|
|
});
|
|
|
|
server.on('upgrade', (req, socket) => {
|
|
if (new URL(req.url, 'http://x').pathname !== '/trpc') {
|
|
socket.end('HTTP/1.1 404 Not Found\r\n\r\n');
|
|
return;
|
|
}
|
|
const accept = crypto.createHash('sha1').update(`${req.headers['sec-websocket-key']}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64');
|
|
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
|
const client = { socket, subs: new Map(), state: { buf: Buffer.alloc(0) }, closed: false, awaitingPong: false };
|
|
clients.add(client);
|
|
stats.connections += 1;
|
|
log(`client connected (${clients.size})`);
|
|
const close = () => {
|
|
if (client.closed) return;
|
|
client.closed = true;
|
|
clients.delete(client);
|
|
socket.destroy();
|
|
log(`client gone (${clients.size})`);
|
|
};
|
|
socket.on('data', (d) => readFrames(client.state, d, (t) => onMessage(client, t), close));
|
|
socket.on('close', close);
|
|
socket.on('error', close);
|
|
});
|
|
|
|
const timers = [
|
|
setInterval(() => {
|
|
flipRed = !flipRed;
|
|
looks['bank:flip'] = flipRed ? RED : GREEN;
|
|
redraw('bank:flip');
|
|
}, 2000),
|
|
setInterval(() => redraw('bank:steady'), 300),
|
|
setInterval(() => {
|
|
for (const c of clients) {
|
|
if (c.awaitingPong) {
|
|
log('no PONG — dropping client');
|
|
c.socket.destroy();
|
|
continue;
|
|
}
|
|
c.awaitingPong = true;
|
|
send(c, 'PING');
|
|
}
|
|
}, 30000),
|
|
];
|
|
if (renameAfter) {
|
|
timers.push(setTimeout(() => {
|
|
pages.p2.name = 'Cameras Renamed';
|
|
pageUpdate({ type: 'update', updatedOrder: null, added: [], changes: [{ id: 'p2', name: 'Cameras Renamed', controls: [] }] });
|
|
}, renameAfter * 1000));
|
|
}
|
|
if (moveAfter) {
|
|
timers.push(setTimeout(() => {
|
|
delete pages.p1.controls[0][0];
|
|
pages.p2.controls[1][0] = 'bank:flip';
|
|
pageUpdate({
|
|
type: 'update',
|
|
updatedOrder: null,
|
|
added: [],
|
|
changes: [
|
|
{ id: 'p1', name: null, controls: [{ row: 0, column: 0, controlId: null }] },
|
|
{ id: 'p2', name: null, controls: [{ row: 1, column: 0, controlId: 'bank:flip' }] },
|
|
],
|
|
});
|
|
}, moveAfter * 1000));
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
server.listen(port, '127.0.0.1', () => {
|
|
resolve({
|
|
port: server.address().port,
|
|
stats,
|
|
clients,
|
|
pageUpdate,
|
|
redraw,
|
|
looks,
|
|
pages,
|
|
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();
|
|
},
|
|
close() {
|
|
for (const t of timers) clearInterval(t);
|
|
for (const c of clients) c.socket.destroy();
|
|
return new Promise((r) => server.close(() => r()));
|
|
},
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
module.exports = { startMock, png };
|
|
|
|
if (require.main === module) {
|
|
const args = process.argv.slice(2);
|
|
const opt = (name) => {
|
|
const i = args.indexOf(name);
|
|
return i >= 0 ? Number(args[i + 1]) || 0 : 0;
|
|
};
|
|
const port = Number(args.find((a) => /^\d+$/.test(a))) || 18000;
|
|
startMock(port, { renameAfter: opt('--rename-after'), moveAfter: opt('--move-after'), log: (m) => console.log(`[companion-mock] ${m}`) }).then((m) => {
|
|
console.log(`[companion-mock] listening on 127.0.0.1:${m.port} — point Companion Buttons at 127.0.0.1 port ${m.port}`);
|
|
});
|
|
}
|