Merge pull request #5 from michaelmcgary2008/claude/timers-ltc-stage-display

Timers: LTC timecode via the stage-display websocket
This commit is contained in:
Michael McGary
2026-09-10 16:39:35 -04:00
committed by GitHub
7 changed files with 602 additions and 19 deletions
+4 -2
View File
@@ -177,8 +177,10 @@ below. Nothing is installed without someone clicking.
elapsed) and LTC timecode as auto-sized cards. The + picker lists every elapsed) and LTC timecode as auto-sized cards. The + picker lists every
discovered timer (and the LTC card) as its own tile, or add "All timers" discovered timer (and the LTC card) as its own tile, or add "All timers"
and pick a selection via the tile's "Timers ▾" menu; overruns are and pick a selection via the tile's "Timers ▾" menu; overruns are
unmistakably red. Read-only — no start/stop controls. Admin config: unmistakably red. LTC is read from a stage-display layout field labeled
ProPresenter endpoint (host : port), password. "LTC" over ProPresenter's stage websocket (its HTTP API has no timecode
route). Read-only — no start/stop controls. Admin config: ProPresenter
endpoint (host : port), API password, stage display password.
- **Clock** — a big booth clock; also the smallest possible module and the - **Clock** — a big booth clock; also the smallest possible module and the
reference for module authors. reference for module authors.
+3 -1
View File
@@ -496,7 +496,9 @@ client-side failures (a tile that fails to start says so in the tile).
- ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running, - ProPresenter timers: `node tools/pp-timers-mock.js 1600` — running,
overrunning, stopped and elapsed timers plus an LTC timecode that overrunning, stopped and elapsed timers plus an LTC timecode that
periodically drops to no-signal (`--no-ltc` serves a ProPresenter without periodically drops to no-signal (`--no-ltc` serves a ProPresenter without
a timecode API). Point the Timers module at host `127.0.0.1`, port `1600`. a timecode API). Also serves the stage-display websocket with an
LTC-labeled layout field (`--stage-pwd`, `--no-ltc-field` exercise its
failure paths). Point the Timers module at host `127.0.0.1`, port `1600`.
**The resilience checklist** — every module must pass this before it's done: **The resilience checklist** — every module must pass this before it's done:
+3 -1
View File
@@ -210,7 +210,9 @@ export default function create({ root, moduleApi }) {
if (ltc.supported === false) { if (ltc.supported === false) {
mode = 'unavailable'; mode = 'unavailable';
card.timeEl.textContent = '—'; card.timeEl.textContent = '—';
card.stateEl.textContent = 'Timecode not available on this ProPresenter'; // The server says why when it knows (stage password rejected, no
// stage field labeled LTC, …) — surface that over the generic line.
card.stateEl.textContent = ltc.note || 'Timecode not available on this ProPresenter';
} else if (ltc.supported === null) { } else if (ltc.supported === null) {
mode = 'waiting'; mode = 'waiting';
card.timeEl.textContent = displayTime(ltc.time); card.timeEl.textContent = displayTime(ltc.time);
+18 -4
View File
@@ -1,24 +1,38 @@
{ {
"id": "propresenter-timers", "id": "propresenter-timers",
"name": "Timers", "name": "Timers",
"version": "1.0.0", "version": "1.1.0",
"proddash": ">=1.1.0", "proddash": ">=1.1.0",
"description": "Live ProPresenter timers and LTC timecode", "description": "Live ProPresenter timers and LTC timecode",
"client": "client.js", "client": "client.js",
"server": "server.js", "server": "server.js",
"style": "style.css", "style": "style.css",
"minSize": { "w": 2, "h": 1 }, "minSize": {
"defaultSize": { "w": 4, "h": 2 }, "w": 2,
"h": 1
},
"defaultSize": {
"w": 4,
"h": 2
},
"configSchema": { "configSchema": {
"propresenter": { "propresenter": {
"type": "endpoint", "type": "endpoint",
"label": "ProPresenter", "label": "ProPresenter",
"default": { "host": "", "port": 1025 } "default": {
"host": "",
"port": 1025
}
}, },
"password": { "password": {
"type": "password", "type": "password",
"label": "API password (only if set in ProPresenter)", "label": "API password (only if set in ProPresenter)",
"default": "" "default": ""
},
"stagePassword": {
"type": "password",
"label": "Stage display password (for LTC — ProPresenter Settings → Network)",
"default": ""
} }
} }
} }
+56 -10
View File
@@ -23,10 +23,15 @@
* "overr" as overrun. The API exposes NO per-timer color anywhere. * "overr" as overrun. The API exposes NO per-timer color anywhere.
* GET /v1/timecode/status → NOT in the published spec. The spec (fetched * GET /v1/timecode/status → NOT in the published spec. The spec (fetched
* 2026-08, 0 mentions of "timecode", not in the /v1/status/updates * 2026-08, 0 mentions of "timecode", not in the /v1/status/updates
* allowlist either) documents no timecode/LTC route at all. We probe this * allowlist either) documents no timecode/LTC route at all — verified
* candidate route once per connection anyway so a future ProPresenter * live: ProPresenter 21.4 returns 404. We probe this candidate route
* that adds it lights up without a module update; a 404 marks LTC * once per connection anyway so a future ProPresenter that adds it
* "not available on this ProPresenter" (ltc.supported = false). * lights up without a module update.
*
* Because the HTTP API has no timecode, LTC actually comes from the classic
* stage-display websocket (see stage-ws.js): a stage layout field labeled
* "LTC" streams its text to us. Source precedence: the HTTP route if it ever
* answers (authoritative), else the stage field, else "not available".
* *
* ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path * ProPresenter's built-in HTTP server is easily overwhelmed (see the hot-path
* notes in ../propresenter-now-next/propresenter-core). So: requests run * notes in ../propresenter-now-next/propresenter-core). So: requests run
@@ -47,6 +52,8 @@ const REQUEST_TIMEOUT_MS = 4000;
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ /** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000; const HEARTBEAT_MS = 15000;
const { createStageLtcClient } = require('./stage-ws');
/** Set by init(), read by the routes — both are rebuilt together on remount. */ /** Set by init(), read by the routes — both are rebuilt together on remount. */
let current = null; let current = null;
@@ -56,9 +63,10 @@ function clearedState(enabled) {
reachable: false, reachable: false,
lastError: '', lastError: '',
timers: [], timers: [],
// supported: null = not probed yet (or ProPresenter unreachable), // supported: null = no verdict yet (probing, or ProPresenter
// true/false once a reachable ProPresenter answered the probe. // unreachable); true once a timecode source is live; false when both
ltc: { supported: null, time: '', receiving: false }, // sources came up empty (note says why, when we know).
ltc: { supported: null, time: '', receiving: false, source: '', note: '' },
}; };
} }
@@ -85,6 +93,23 @@ module.exports = {
/** null = probe /v1/timecode/status on the next reachable tick. */ /** null = probe /v1/timecode/status on the next reachable tick. */
let ltcSupported = null; let ltcSupported = null;
let ltcLast = { time: '', receiving: false }; let ltcLast = { time: '', receiving: false };
/** Live view of the stage-display websocket's LTC field (stage-ws.js). */
let stageLtc = { bound: false, time: '', receiving: false, note: '' };
/** One LTC verdict from both sources: HTTP route wins if it ever
answers (authoritative), else the stage-display field. */
function composeLtc() {
if (ltcSupported === true) {
return { supported: true, source: 'api', time: ltcLast.time, receiving: ltcLast.receiving, note: '' };
}
if (stageLtc.bound) {
return { supported: true, source: 'stage', time: stageLtc.time, receiving: stageLtc.receiving, note: '' };
}
if (ltcSupported === null && !stageLtc.note) {
return { supported: null, source: '', time: '', receiving: false, note: '' };
}
return { supported: false, source: '', time: '', receiving: false, note: stageLtc.note || '' };
}
function url(target) { function url(target) {
const built = new URL(`http://${host}:${port}${target}`); const built = new URL(`http://${host}:${port}${target}`);
@@ -197,7 +222,10 @@ module.exports = {
} }
async function pollLtc() { async function pollLtc() {
if (ltcSupported === false) return; // A 404 verdict is retried once a minute — an endpoint appearing
// without an unreachable spell (ProPresenter hot-swapped in testing,
// or some future in-place upgrade) must not stay invisible forever.
if (ltcSupported === false && tick % 120 !== 0) return;
try { try {
const body = await requestJson('/v1/timecode/status'); const body = await requestJson('/v1/timecode/status');
if (ltcSupported === null) log('timecode endpoint answered — LTC card is live'); if (ltcSupported === null) log('timecode endpoint answered — LTC card is live');
@@ -221,7 +249,7 @@ module.exports = {
reachable: latest.reachable, reachable: latest.reachable,
lastError: latest.lastError, lastError: latest.lastError,
timers: latest.timers, timers: latest.timers,
ltc: { supported: ltcSupported, time: ltcLast.time, receiving: ltcLast.receiving }, ltc: composeLtc(),
}; };
if (!streams.size) return; if (!streams.size) return;
let frame; let frame;
@@ -271,9 +299,23 @@ module.exports = {
pollTimer = setTimeout(pollTick, latest.reachable ? POLL_MS : RETRY_MS); pollTimer = setTimeout(pollTick, latest.reachable ? POLL_MS : RETRY_MS);
} }
let stageClient = null;
if (enabled) { if (enabled) {
pollTick(); pollTick();
log(`polling timers on ${host}:${port}`); log(`polling timers on ${host}:${port}`);
stageClient = createStageLtcClient({
host,
port,
password: String(config.stagePassword || ''),
log,
// The HTTP poller knows every configured timer's uuid — anything
// else streaming timecode-shaped text can be heuristically bound.
isTimerUid: (uid) => timerConfigs.has(uid),
onUpdate(update) {
stageLtc = update;
broadcast();
},
});
} else { } else {
log('no ProPresenter host configured — set one in /admin'); log('no ProPresenter host configured — set one in /admin');
} }
@@ -296,6 +338,7 @@ module.exports = {
stopped = true; stopped = true;
clearTimeout(pollTimer); clearTimeout(pollTimer);
clearInterval(heartbeat); clearInterval(heartbeat);
stageClient?.stop();
for (const res of streams) { for (const res of streams) {
try { res.end(); } catch { /* already gone */ } try { res.end(); } catch { /* already gone */ }
} }
@@ -305,7 +348,10 @@ module.exports = {
health() { health() {
if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' }; if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' };
if (latest.reachable) { if (latest.reachable) {
const ltcNote = latest.ltc.supported ? ' (with timecode)' : ''; const ltc = latest.ltc;
const ltcNote = ltc.supported
? (ltc.source === 'stage' ? ', LTC via stage display' : ', LTC via API')
: (ltc.note ? ` (${ltc.note})` : '');
return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` }; return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` };
} }
return { return {
+379
View File
@@ -0,0 +1,379 @@
'use strict';
/**
* ProPresenter stage-display websocket client — the LTC timecode source.
*
* ProPresenter's HTTP openAPI exposes no timecode route (see server.js), but
* its classic stage-display websocket (ws://host:port/stagedisplay — the
* Pro6-era protocol, still answered by ProPresenter 21; verified live: it
* responds `{"acn":"ath","ath":…,"majorVersion":7,"minorVersion":21}`) streams
* the text of stage-layout fields. So: the operator puts a field labeled
* "LTC" on a stage layout, and this client reads that field's live text.
*
* Protocol (community-documented, github.com/jeffmikels/ProPresenter-API):
* → {"pwd":"<stage password>","ptl":610,"acn":"ath"}
* ← {"acn":"ath","ath":true|false,"err":"…"}
* → {"acn":"psl"} ← {"acn":"psl","uid":"<layout uid>"}
* → {"acn":"asl"} ← {"acn":"asl","ary":[{acn:"sl",uid,nme,fme:[…]},…]}
* ← {"acn":"sys","txt":" 11:17 AM"} every second — our liveness signal
* ← {"acn":"tmr","uid":"…","txt":"…"} live text for a layout field's uid
*
* A layout's `fme` frames carry `nme` (the label) and, for linked fields, a
* `uid`. We bind to the first frame labeled "LTC" (case-insensitive), then
* treat any message carrying that uid as the timecode text — matching by uid
* rather than acn on purpose, since how ProPresenter 21 tags timecode
* updates is undocumented. "Receiving" is a staleness heuristic: LTC text
* that hasn't advanced for STALE_MS is a frozen/no-signal feed.
*
* Zero-dependency by design (like the rest of ProdDash): a minimal RFC6455
* client — masked client frames, text + ping/pong + close, fragmented text
* reassembly. TLS is not needed (ProPresenter's server is plain ws://).
*/
const net = require('net');
const crypto = require('crypto');
const RECONNECT_MS = 3000;
/** Auth rejections retry slowly — a wrong password never fixes itself fast. */
const BAD_AUTH_RETRY_MS = 30000;
/** ProPresenter sends a clock tick every second; silence this long = dead link. */
const LIVENESS_MS = 10000;
/** LTC text that has not advanced for this long counts as no-signal. */
const STALE_MS = 2500;
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
const ZERO_UID = '00000000-0000-0000-0000-000000000000';
/** Timecode-shaped text: a fourth (frames) group separates it from timers. */
const TC_SHAPE = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/;
/**
* @param {{host:string, port:number, password:string, log:Function,
* isTimerUid?:(uid:string)=>boolean,
* onUpdate:(ltc:{bound:boolean,time:string,receiving:boolean,note:string})=>void}} opts
* @returns {{stop:()=>void}}
*/
function createStageLtcClient({ host, port, password, log, isTimerUid, onUpdate }) {
let sock = null;
let stopped = false;
let reconnectTimer = null;
let livenessTimer = null;
let staleTimer = null;
let ltcUid = ''; // uid of the frame labeled LTC, once bound
let boundBy = ''; // 'label' (layout frame) or 'heuristic' (sniffed)
let candidateUid = ''; // heuristic candidate awaiting a second, advancing sample
let candidateText = '';
let lastText = '';
let lastAdvance = 0;
// Known-but-unused message types; anything else is logged once so a new
// ProPresenter's timecode tag shows up in the server log for discovery.
const loggedUnknownAcn = new Set(['fv', 'msg', 'vid', 'tmr', 'cs', 'ns', 'csn', 'nsn']);
function emit(partial) {
onUpdate({
bound: Boolean(ltcUid),
time: lastText,
receiving: false,
note: '',
...partial,
});
}
/* ── outgoing frames (client → server must be masked) ───────────── */
function sendFrame(opcode, payload) {
if (!sock || sock.destroyed) return;
const mask = crypto.randomBytes(4);
const len = payload.length;
let header;
if (len < 126) {
header = Buffer.from([0x80 | opcode, 0x80 | len]);
} else if (len < 65536) {
header = Buffer.alloc(4);
header[0] = 0x80 | opcode;
header[1] = 0x80 | 126;
header.writeUInt16BE(len, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x80 | opcode;
header[1] = 0x80 | 127;
header.writeBigUInt64BE(BigInt(len), 2);
}
const masked = Buffer.from(payload);
for (let i = 0; i < masked.length; i += 1) masked[i] ^= mask[i % 4];
try {
sock.write(Buffer.concat([header, mask, masked]));
} catch { /* socket died mid-write; liveness/close handling reconnects */ }
}
function sendJson(obj) {
sendFrame(0x1, Buffer.from(JSON.stringify(obj)));
}
/* ── protocol: layouts → find the LTC-labeled frame ─────────────── */
let assignedLayoutUid = '';
function bindFromLayouts(layouts) {
const isLtcFrame = (f) => String(f?.nme || '').trim().toLowerCase() === 'ltc';
const pick = (layout) => (Array.isArray(layout?.fme) ? layout.fme.find(isLtcFrame) : null);
// Prefer the layout ProPresenter assigned to this client; fall back to
// any layout with an LTC-labeled frame.
const ordered = layouts.slice().sort((a, b) =>
(b.uid === assignedLayoutUid ? 1 : 0) - (a.uid === assignedLayoutUid ? 1 : 0));
for (const layout of ordered) {
const frame = pick(layout);
if (frame && frame.uid) {
if (ltcUid !== frame.uid) {
ltcUid = String(frame.uid);
boundBy = 'label';
log(`stage display: bound LTC to field "${frame.nme}" (${ltcUid}) in layout "${layout.nme || layout.uid}"`);
emit({ receiving: false });
}
return;
}
if (frame && !frame.uid) {
log(`stage display: layout "${layout.nme}" has an LTC field with no uid — it never streams; link it to a timecode/timer source`);
}
}
// Verified on ProPresenter 21.4: the legacy protocol (the only version
// it accepts, ptl 610) omits stage objects it predates — a timecode
// field never appears in any layout's frame list. So a heuristic
// binding sniffed from the live stream must survive layout refreshes.
if (boundBy === 'heuristic' && ltcUid) return;
ltcUid = '';
boundBy = '';
emit({ bound: false, time: '', note: 'No LTC field visible on the stage-display feed' });
}
function handleMessage(obj) {
const acn = String(obj.acn || '');
switch (acn) {
case 'ath':
if (obj.ath === true) {
log('stage display: authenticated');
sendJson({ acn: 'psl' }); // which layout are we assigned?
sendJson({ acn: 'asl' }); // all layouts, with their frames
} else {
const err = String(obj.err || 'authentication rejected');
log(`stage display: auth failed — ${err}`);
emit({ bound: false, time: '', note: 'Stage display password rejected — set it in Admin' });
scheduleReconnect(BAD_AUTH_RETRY_MS);
destroySocket();
}
return;
case 'psl':
assignedLayoutUid = String(obj.uid || '');
return;
case 'asl':
if (Array.isArray(obj.ary)) bindFromLayouts(obj.ary);
return;
case 'sl':
// A layout definition was pushed (assignment or edit) — it may not
// be ours, so re-resolve from the authoritative full list instead
// of rebinding blindly.
sendJson({ acn: 'psl' });
sendJson({ acn: 'asl' });
return;
case 'sys': // per-second clock — liveness only
return;
default:
break;
}
// Heuristic fallback: with no LTC-labeled frame to bind to, an unknown
// uid streaming timecode-shaped text (a fourth :FF group — timers never
// have one) that is NOT a configured timer can only be the timecode
// field. Require two advancing samples before trusting it.
if (!ltcUid && obj.uid && obj.uid !== ZERO_UID && typeof obj.txt === 'string'
&& TC_SHAPE.test(obj.txt.trim()) && !(isTimerUid && isTimerUid(obj.uid))) {
if (candidateUid === obj.uid && candidateText !== obj.txt) {
ltcUid = String(obj.uid);
boundBy = 'heuristic';
log(`stage display: no LTC-labeled field in any layout, but ${obj.uid} streams timecode-shaped text (acn "${acn}") — binding it as LTC`);
// fall through to the normal update path below
} else {
candidateUid = String(obj.uid);
candidateText = obj.txt;
return;
}
}
// Anything carrying our bound uid is the LTC text, whatever its acn —
// ProPresenter 21's tag for timecode fields is undocumented.
if (ltcUid && obj.uid === ltcUid && typeof obj.txt === 'string') {
const txt = obj.txt.trim();
const placeholder = !txt || /^[-–—:;.\s]*$/.test(txt);
if (placeholder) {
lastText = '';
emit({ receiving: false });
return;
}
if (txt !== lastText) {
lastText = txt;
lastAdvance = Date.now();
emit({ receiving: true });
clearTimeout(staleTimer);
staleTimer = setTimeout(() => {
// Frozen text = LTC stopped arriving at ProPresenter.
emit({ receiving: false });
}, STALE_MS);
staleTimer.unref?.();
}
return;
}
if (acn && !loggedUnknownAcn.has(acn) && loggedUnknownAcn.size < 30) {
loggedUnknownAcn.add(acn);
log(`stage display: unhandled message type "${acn}" — ${JSON.stringify(obj).slice(0, 160)}`);
}
}
/* ── incoming frames ────────────────────────────────────────────── */
function connect() {
if (stopped) return;
let buf = Buffer.alloc(0);
let upgraded = false;
let fragments = null;
const key = crypto.randomBytes(16).toString('base64');
sock = net.connect(port, host);
sock.setNoDelay(true);
const bumpLiveness = () => {
clearTimeout(livenessTimer);
livenessTimer = setTimeout(() => {
log('stage display: silent too long — reconnecting');
destroySocket();
scheduleReconnect(RECONNECT_MS);
}, LIVENESS_MS);
livenessTimer.unref?.();
};
sock.on('connect', () => {
sock.write(
`GET /stagedisplay HTTP/1.1\r\n` +
`Host: ${host}:${port}\r\n` +
`Upgrade: websocket\r\nConnection: Upgrade\r\n` +
`Sec-WebSocket-Key: ${key}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
bumpLiveness();
});
sock.on('data', (chunk) => {
bumpLiveness();
buf = Buffer.concat([buf, chunk]);
if (!upgraded) {
const idx = buf.indexOf('\r\n\r\n');
if (idx === -1) return;
const head = buf.slice(0, idx).toString();
buf = buf.slice(idx + 4);
const statusLine = head.split('\r\n')[0] || '';
if (!/ 101 /.test(statusLine + ' ')) {
log(`stage display: no websocket upgrade (${statusLine.trim()}) — this ProPresenter may not serve /stagedisplay`);
emit({ bound: false, time: '', note: 'No stage display websocket on this ProPresenter' });
destroySocket();
scheduleReconnect(BAD_AUTH_RETRY_MS);
return;
}
const accept = crypto.createHash('sha1').update(key + WS_MAGIC).digest('base64');
const m = head.match(/sec-websocket-accept:\s*(\S+)/i);
if (m && m[1] !== accept) log('stage display: Sec-WebSocket-Accept mismatch (continuing)');
upgraded = true;
sendJson({ pwd: String(password || ''), ptl: 610, acn: 'ath' });
}
// decode server frames (unmasked; tolerate masked anyway)
while (buf.length >= 2) {
const fin = (buf[0] & 0x80) !== 0;
const opcode = buf[0] & 0x0f;
const maskBit = (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 = maskBit ? 4 : 0;
if (buf.length < off + maskLen + len) return;
let payload = buf.slice(off + maskLen, off + maskLen + len);
if (maskBit) {
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 === 0x9) { // ping → pong
sendFrame(0xA, payload);
continue;
}
if (opcode === 0x8) { // close
destroySocket();
scheduleReconnect(RECONNECT_MS);
return;
}
if (opcode === 0x1 || (opcode === 0x0 && fragments)) {
if (!fin || opcode === 0x0) { // fragmented text
fragments = fragments ? Buffer.concat([fragments, payload]) : payload;
if (!fin) continue;
payload = fragments;
fragments = null;
}
let obj;
try {
obj = JSON.parse(payload.toString());
} catch {
continue;
}
if (obj && typeof obj === 'object') handleMessage(obj);
}
// binary frames (slide images) are ignored
}
});
sock.on('error', () => { /* close handler reconnects */ });
sock.on('close', () => {
clearTimeout(livenessTimer);
if (stopped) return;
if (!reconnectTimer) {
emit({ receiving: false });
scheduleReconnect(RECONNECT_MS);
}
});
}
function destroySocket() {
if (sock) {
try { sock.destroy(); } catch { /* already gone */ }
sock = null;
}
}
function scheduleReconnect(ms) {
if (stopped || reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, ms);
reconnectTimer.unref?.();
}
connect();
return {
stop() {
stopped = true;
clearTimeout(reconnectTimer);
clearTimeout(livenessTimer);
clearTimeout(staleTimer);
destroySocket();
},
};
}
module.exports = { createStageLtcClient };
+139 -1
View File
@@ -4,6 +4,7 @@
* band-lineup-display/tmp/pp-mock.js simulates slides, not timers). * band-lineup-display/tmp/pp-mock.js simulates slides, not timers).
* *
* node tools/pp-timers-mock.js [port] [--no-ltc] [--drop=<uuid,...>] * 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: * Serves the endpoint shapes verified against openapi.propresenter.com:
* GET /version (reachability probe) * GET /version (reachability probe)
@@ -23,6 +24,16 @@
* the mock with e.g. --drop=T2-WALKIN to simulate a timer being deleted in * 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). * 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. * Every incoming request is logged so we can confirm what the module fetches.
*/ */
'use strict'; '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 PORT = Number.parseInt(args.find((a) => /^\d+$/.test(a)) || '1600', 10);
const NO_LTC = args.includes('--no-ltc'); const NO_LTC = args.includes('--no-ltc');
const DROP = new Set(args.flatMap((a) => (a.startsWith('--drop=') ? a.slice(7).split(',') : []))); 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 FPS = 30;
const startedAt = Date.now(); const startedAt = Date.now();
@@ -140,6 +156,128 @@ const server = http.createServer((req, res) => {
sendJson(res, { error: 'not found' }, 404); 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, () => { 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)' : ''}`);
}); });