Merge main (PRs #5, #7, #9, #10) into the PCO Plan branch

Resolves the admin form conflicts in favour of this branch's grouped /
collapsible / dependent-select additions (the base hunks were the timers
lane's, ported verbatim), keeps both sides of the README and module-guide
edits, and adopts main's versioning rules: the shell moves to 1.3.0 for the
new schema features (configGroups / instanceGroups, help, color fields,
grouped and dependent selects) and pco-plan 1.3.0 declares
"proddash": ">=1.3.0".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-10 16:47:06 -04:00
co-authored by Claude Fable 5.1
26 changed files with 2485 additions and 165 deletions
+139 -1
View File
@@ -4,6 +4,7 @@
* band-lineup-display/tmp/pp-mock.js simulates slides, not timers).
*
* node tools/pp-timers-mock.js [port] [--no-ltc] [--drop=<uuid,...>]
* [--stage-pwd=<pwd>] [--no-ltc-field]
*
* Serves the endpoint shapes verified against openapi.propresenter.com:
* GET /version (reachability probe)
@@ -23,6 +24,16 @@
* the mock with e.g. --drop=T2-WALKIN to simulate a timer being deleted in
* ProPresenter (a solo tile for it must say so, not error).
*
* Also serves the classic stage-display websocket at /stagedisplay (the
* Pro6-era protocol ProPresenter still answers): ath / psl / asl / sl, a
* per-second "sys" clock, and "tmr" text updates — including a stage layout
* with a field labeled "LTC" whose text advances at 30 fps and freezes
* during the same 15 s/60 s no-signal window as the HTTP endpoint. Combine
* with --no-ltc to make the stage feed the only timecode source (the module
* prefers the HTTP route when it answers). --stage-pwd=<pwd> rejects other
* stage passwords ("Your Password is incorrect", as real ProPresenter says);
* --no-ltc-field serves the stage layout without the LTC field.
*
* Every incoming request is logged so we can confirm what the module fetches.
*/
'use strict';
@@ -33,6 +44,11 @@ const args = process.argv.slice(2);
const PORT = Number.parseInt(args.find((a) => /^\d+$/.test(a)) || '1600', 10);
const NO_LTC = args.includes('--no-ltc');
const DROP = new Set(args.flatMap((a) => (a.startsWith('--drop=') ? a.slice(7).split(',') : [])));
const STAGE_PWD = (args.find((a) => a.startsWith('--stage-pwd=')) || '').slice(12);
const NO_LTC_FIELD = args.includes('--no-ltc-field');
// Like real ProPresenter 21: the layout list omits the timecode field, but
// its updates still stream — exercises the module's heuristic binding.
const LTC_UNLISTED = args.includes('--ltc-unlisted');
const FPS = 30;
const startedAt = Date.now();
@@ -140,6 +156,128 @@ const server = http.createServer((req, res) => {
sendJson(res, { error: 'not found' }, 404);
});
/* ── stage-display websocket (/stagedisplay) ─────────────────────── */
const cryptoMod = require('crypto');
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const STAGE_LAYOUT = {
acn: 'sl',
uid: 'MOCK-LAYOUT-1',
nme: 'Timers',
fme: [
{ nme: 'Current Slide', typ: 1 },
...(NO_LTC_FIELD || LTC_UNLISTED ? [] : [{ nme: 'LTC', typ: 7, uid: 'LTC-FIELD' }]),
{ nme: 'Sermon Countdown', typ: 7, uid: 'T1-SERMON' },
],
};
/** Server→client frames are unmasked. Text only — plenty for this protocol. */
function wsEncode(str) {
const p = Buffer.from(str);
let h;
if (p.length < 126) {
h = Buffer.from([0x81, p.length]);
} else {
h = Buffer.alloc(4);
h[0] = 0x81;
h[1] = 126;
h.writeUInt16BE(p.length, 2);
}
return Buffer.concat([h, p]);
}
server.on('upgrade', (req, socket) => {
if (new URL(req.url, 'http://x').pathname !== '/stagedisplay') return void socket.destroy();
const key = req.headers['sec-websocket-key'];
if (!key) return void socket.destroy();
console.log('[pp-timers-mock] stage display client connected');
const accept = cryptoMod.createHash('sha1').update(key + WS_MAGIC).digest('base64');
socket.write(
'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`);
const send = (obj) => {
try { socket.write(wsEncode(JSON.stringify(obj))); } catch { /* gone */ }
};
let authed = false;
let buf = Buffer.alloc(0);
let lastLtcText = '';
const sysTimer = setInterval(() => {
if (authed) send({ acn: 'sys', txt: ' ' + new Date().toLocaleTimeString() });
}, 1000);
const tmrTimer = setInterval(() => {
if (!authed) return;
// noise a client must filter out: an ordinary timer's text
send({ acn: 'tmr', uid: 'T1-SERMON', txt: hms(Math.max(0, 25 * 60 - elapsedSec())) });
}, 1000);
const ltcTimer = setInterval(() => {
if (!authed || NO_LTC_FIELD) return;
const tc = timecodeStatus();
// During no-signal the on-stage field is frozen: no updates at all —
// exactly what the module's staleness heuristic must catch.
if (!tc.receiving || tc.time === lastLtcText) return;
lastLtcText = tc.time;
send({ acn: 'tmr', uid: 'LTC-FIELD', txt: tc.time });
}, 250);
socket.on('data', (chunk) => {
buf = Buffer.concat([buf, chunk]);
while (buf.length >= 2) {
const opcode = buf[0] & 0x0f;
const masked = (buf[1] & 0x80) !== 0;
let len = buf[1] & 0x7f;
let off = 2;
if (len === 126) {
if (buf.length < 4) return;
len = buf.readUInt16BE(2);
off = 4;
} else if (len === 127) {
if (buf.length < 10) return;
len = Number(buf.readBigUInt64BE(2));
off = 10;
}
const maskLen = masked ? 4 : 0;
if (buf.length < off + maskLen + len) return;
let payload = buf.slice(off + maskLen, off + maskLen + len);
if (masked) {
const mask = buf.slice(off, off + 4);
payload = Buffer.from(payload);
for (let i = 0; i < payload.length; i += 1) payload[i] ^= mask[i % 4];
}
buf = buf.slice(off + maskLen + len);
if (opcode === 0x8) return void socket.destroy();
if (opcode === 0x9) { socket.write(Buffer.concat([Buffer.from([0x8a, payload.length]), payload])); continue; }
if (opcode !== 0x1) continue;
let msg;
try { msg = JSON.parse(payload.toString()); } catch { continue; }
console.log('[pp-timers-mock] stage <-', JSON.stringify(msg).slice(0, 120));
if (msg.acn === 'ath') {
if (STAGE_PWD && String(msg.pwd || '') !== STAGE_PWD) {
send({ acn: 'ath', ath: false, majorVersion: 7, minorVersion: 21, err: 'Your Password is incorrect' });
} else {
authed = true;
send({ acn: 'ath', ath: true, err: '' });
}
} else if (msg.acn === 'psl') {
send({ acn: 'psl', uid: 'MOCK-LAYOUT-1' });
} else if (msg.acn === 'asl') {
send({ acn: 'asl', ary: [STAGE_LAYOUT] });
}
}
});
const cleanup = () => {
clearInterval(sysTimer);
clearInterval(tmrTimer);
clearInterval(ltcTimer);
};
socket.on('close', cleanup);
socket.on('error', () => { cleanup(); socket.destroy(); });
});
server.listen(PORT, () => {
console.log(`[pp-timers-mock] listening on http://127.0.0.1:${PORT}${NO_LTC ? ' (timecode endpoint disabled)' : ''}`);
console.log(`[pp-timers-mock] listening on http://127.0.0.1:${PORT}${NO_LTC ? ' (timecode endpoint disabled)' : ''}${NO_LTC_FIELD ? ' (stage layout has no LTC field)' : ''}${STAGE_PWD ? ' (stage password required)' : ''}`);
});