diff --git a/README.md b/README.md index 5458a01..904cb48 100644 --- a/README.md +++ b/README.md @@ -177,8 +177,10 @@ below. Nothing is installed without someone clicking. 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" and pick a selection via the tile's "Timers ▾" menu; overruns are - unmistakably red. Read-only — no start/stop controls. Admin config: - ProPresenter endpoint (host : port), password. + unmistakably red. LTC is read from a stage-display layout field labeled + "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 reference for module authors. diff --git a/docs/MODULE-GUIDE.md b/docs/MODULE-GUIDE.md index d03474c..0820c1d 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -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, overrunning, stopped and elapsed timers plus an LTC timecode that 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: diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index 02f728d..0a6f826 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -210,7 +210,9 @@ export default function create({ root, moduleApi }) { if (ltc.supported === false) { mode = 'unavailable'; 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) { mode = 'waiting'; card.timeEl.textContent = displayTime(ltc.time); diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 2bddace..1fc1019 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -1,24 +1,38 @@ { "id": "propresenter-timers", "name": "Timers", - "version": "1.0.0", + "version": "1.1.0", "proddash": ">=1.1.0", "description": "Live ProPresenter timers and LTC timecode", "client": "client.js", "server": "server.js", "style": "style.css", - "minSize": { "w": 2, "h": 1 }, - "defaultSize": { "w": 4, "h": 2 }, + "minSize": { + "w": 2, + "h": 1 + }, + "defaultSize": { + "w": 4, + "h": 2 + }, "configSchema": { "propresenter": { "type": "endpoint", "label": "ProPresenter", - "default": { "host": "", "port": 1025 } + "default": { + "host": "", + "port": 1025 + } }, "password": { "type": "password", "label": "API password (only if set in ProPresenter)", "default": "" + }, + "stagePassword": { + "type": "password", + "label": "Stage display password (for LTC — ProPresenter Settings → Network)", + "default": "" } } } diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index ae6fd26..f90aeaa 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -23,10 +23,15 @@ * "overr" as overrun. The API exposes NO per-timer color anywhere. * GET /v1/timecode/status → NOT in the published spec. The spec (fetched * 2026-08, 0 mentions of "timecode", not in the /v1/status/updates - * allowlist either) documents no timecode/LTC route at all. We probe this - * candidate route once per connection anyway so a future ProPresenter - * that adds it lights up without a module update; a 404 marks LTC - * "not available on this ProPresenter" (ltc.supported = false). + * allowlist either) documents no timecode/LTC route at all — verified + * live: ProPresenter 21.4 returns 404. We probe this candidate route + * once per connection anyway so a future ProPresenter that adds it + * 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 * 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. */ const HEARTBEAT_MS = 15000; +const { createStageLtcClient } = require('./stage-ws'); + /** Set by init(), read by the routes — both are rebuilt together on remount. */ let current = null; @@ -56,9 +63,10 @@ function clearedState(enabled) { reachable: false, lastError: '', timers: [], - // supported: null = not probed yet (or ProPresenter unreachable), - // true/false once a reachable ProPresenter answered the probe. - ltc: { supported: null, time: '', receiving: false }, + // supported: null = no verdict yet (probing, or ProPresenter + // unreachable); true once a timecode source is live; false when both + // 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. */ let ltcSupported = null; 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) { const built = new URL(`http://${host}:${port}${target}`); @@ -197,7 +222,10 @@ module.exports = { } 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 { const body = await requestJson('/v1/timecode/status'); if (ltcSupported === null) log('timecode endpoint answered — LTC card is live'); @@ -221,7 +249,7 @@ module.exports = { reachable: latest.reachable, lastError: latest.lastError, timers: latest.timers, - ltc: { supported: ltcSupported, time: ltcLast.time, receiving: ltcLast.receiving }, + ltc: composeLtc(), }; if (!streams.size) return; let frame; @@ -271,9 +299,23 @@ module.exports = { pollTimer = setTimeout(pollTick, latest.reachable ? POLL_MS : RETRY_MS); } + let stageClient = null; if (enabled) { pollTick(); 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 { log('no ProPresenter host configured — set one in /admin'); } @@ -296,6 +338,7 @@ module.exports = { stopped = true; clearTimeout(pollTimer); clearInterval(heartbeat); + stageClient?.stop(); for (const res of streams) { try { res.end(); } catch { /* already gone */ } } @@ -305,7 +348,10 @@ module.exports = { health() { if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' }; 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 { diff --git a/modules/propresenter-timers/stage-ws.js b/modules/propresenter-timers/stage-ws.js new file mode 100644 index 0000000..bebcdca --- /dev/null +++ b/modules/propresenter-timers/stage-ws.js @@ -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":"","ptl":610,"acn":"ath"} + * ← {"acn":"ath","ath":true|false,"err":"…"} + * → {"acn":"psl"} ← {"acn":"psl","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 }; diff --git a/tools/pp-timers-mock.js b/tools/pp-timers-mock.js index c62887f..c687e56 100644 --- a/tools/pp-timers-mock.js +++ b/tools/pp-timers-mock.js @@ -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=] + * [--stage-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= 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)' : ''}`); });