From 444f03938d78f6286b3c62ebad5ce80f6cc92367 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Mon, 31 Aug 2026 15:21:12 -0400 Subject: [PATCH 01/15] Timers: LTC timecode via the stage-display websocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProPresenter's HTTP API has no timecode route (verified: 21.4 returns 404), but its classic stage-display websocket still answers — so LTC now comes from a stage layout field labeled "LTC": a zero-dependency RFC6455 client (stage-ws.js) authenticates (new stagePassword admin config), resolves the LTC-labeled frame's uid from the layout list, and treats any message carrying that uid as the timecode text. "Receiving" is a 2.5s staleness heuristic; frozen or placeholder text reads as no-signal. The HTTP probe stays authoritative when it ever answers (a 404 verdict is retried once a minute), and the LTC card surfaces the reason when timecode is unavailable (bad stage password, no LTC field). pp-timers-mock grows a /stagedisplay websocket with --stage-pwd and --no-ltc-field to exercise every path. Co-Authored-By: Claude Fable 5 --- README.md | 6 +- docs/MODULE-GUIDE.md | 4 +- modules/propresenter-timers/client.js | 4 +- modules/propresenter-timers/module.json | 5 + modules/propresenter-timers/server.js | 63 ++++- modules/propresenter-timers/stage-ws.js | 347 ++++++++++++++++++++++++ tools/pp-timers-mock.js | 137 +++++++++- 7 files changed, 551 insertions(+), 15 deletions(-) create mode 100644 modules/propresenter-timers/stage-ws.js diff --git a/README.md b/README.md index c2b5201..f3473f6 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,10 @@ are written by the app itself. 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 87786db..b84ddec 100644 --- a/docs/MODULE-GUIDE.md +++ b/docs/MODULE-GUIDE.md @@ -460,7 +460,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 4818d69..34f94c0 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -18,6 +18,11 @@ "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..e8947f5 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,20 @@ 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, + onUpdate(update) { + stageLtc = update; + broadcast(); + }, + }); } else { log('no ProPresenter host configured — set one in /admin'); } @@ -296,6 +335,7 @@ module.exports = { stopped = true; clearTimeout(pollTimer); clearInterval(heartbeat); + stageClient?.stop(); for (const res of streams) { try { res.end(); } catch { /* already gone */ } } @@ -305,7 +345,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..da29b1d --- /dev/null +++ b/modules/propresenter-timers/stage-ws.js @@ -0,0 +1,347 @@ +'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'; + +/** + * @param {{host:string, port:number, password:string, log:Function, + * onUpdate:(ltc:{bound:boolean,time:string,receiving:boolean,note:string})=>void}} opts + * @returns {{stop:()=>void}} + */ +function createStageLtcClient({ host, port, password, log, 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 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); + 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`); + } + } + ltcUid = ''; + emit({ bound: false, time: '', note: 'No stage layout field labeled "LTC"' }); + } + + 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; + } + // 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..42fa30a 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,8 @@ 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'); const FPS = 30; const startedAt = Date.now(); @@ -140,6 +153,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 ? [] : [{ 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)' : ''}`); }); From d5cb6b6cd9b5265cb1abc65760ef3bd91075899c Mon Sep 17 00:00:00 2001 From: Waters Church Date: Mon, 31 Aug 2026 15:32:43 -0400 Subject: [PATCH 02/15] Timers: heuristically bind LTC when the stage layout omits the field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against ProPresenter 21.4: the legacy stage-display protocol (ptl 610 — the only version PP21 accepts; newer values get 'out of date') omits stage objects it predates, so a timecode field labeled LTC never appears in any layout's frame list, and no timecode messages were observed while idle. Fallback: an unknown uid streaming timecode-shaped text (a fourth :FF group; not any configured timer's uuid) is bound as LTC after two advancing samples, and that binding survives layout refreshes. pp-timers-mock gains --ltc-unlisted to exercise the path. Co-Authored-By: Claude Fable 5 --- modules/propresenter-timers/server.js | 3 +++ modules/propresenter-timers/stage-ws.js | 36 +++++++++++++++++++++++-- tools/pp-timers-mock.js | 5 +++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index e8947f5..f90aeaa 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -308,6 +308,9 @@ module.exports = { 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(); diff --git a/modules/propresenter-timers/stage-ws.js b/modules/propresenter-timers/stage-ws.js index da29b1d..bebcdca 100644 --- a/modules/propresenter-timers/stage-ws.js +++ b/modules/propresenter-timers/stage-ws.js @@ -43,12 +43,17 @@ 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, onUpdate }) { +function createStageLtcClient({ host, port, password, log, isTimerUid, onUpdate }) { let sock = null; let stopped = false; let reconnectTimer = null; @@ -56,6 +61,9 @@ function createStageLtcClient({ host, port, password, log, onUpdate }) { 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 @@ -119,6 +127,7 @@ function createStageLtcClient({ host, port, password, log, onUpdate }) { 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 }); } @@ -128,8 +137,14 @@ function createStageLtcClient({ host, port, password, log, onUpdate }) { 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 = ''; - emit({ bound: false, time: '', note: 'No stage layout field labeled "LTC"' }); + boundBy = ''; + emit({ bound: false, time: '', note: 'No LTC field visible on the stage-display feed' }); } function handleMessage(obj) { @@ -166,6 +181,23 @@ function createStageLtcClient({ host, port, password, log, onUpdate }) { 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') { diff --git a/tools/pp-timers-mock.js b/tools/pp-timers-mock.js index 42fa30a..c687e56 100644 --- a/tools/pp-timers-mock.js +++ b/tools/pp-timers-mock.js @@ -46,6 +46,9 @@ 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(); @@ -164,7 +167,7 @@ const STAGE_LAYOUT = { nme: 'Timers', fme: [ { nme: 'Current Slide', typ: 1 }, - ...(NO_LTC_FIELD ? [] : [{ nme: 'LTC', typ: 7, uid: 'LTC-FIELD' }]), + ...(NO_LTC_FIELD || LTC_UNLISTED ? [] : [{ nme: 'LTC', typ: 7, uid: 'LTC-FIELD' }]), { nme: 'Sermon Countdown', typ: 7, uid: 'T1-SERMON' }, ], }; From cb77030eec5cb43b759d74f34504335dbcb600f6 Mon Sep 17 00:00:00 2001 From: Waters Church Date: Tue, 1 Sep 2026 14:52:20 -0400 Subject: [PATCH 03/15] Timers: LTC from the audio signal itself via an external reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProPresenter 21.4 exposes no timecode through any API, so read the LTC where it actually lives — the audio feed: - tools/ltc-reader.js: zero-dependency SMPTE LTC decoder (biphase-mark, adaptive bit clock, envelope-scaled comparator) fed s16le PCM on stdin from ffmpeg/sox; POSTs running/stopped/nosignal + timecode + frame rate (incl. 29.97 drop-frame) to the module. --selftest runs an encoder→ decoder suite (24/25/30 fps, DF minute counting, low level + noise + DC, dropout recovery); --demo synthesizes live LTC through the real decoder and ingest path with no audio hardware. - Module: POST /ltc ingest route (optional bearer token, validated body, 6 s staleness with automatic fallback to the API probe / stage source), richer LTC state (status, fps, df, reader source), health messages. - Client: LTC card shows Running · / Stopped (held frame) / No signal; works LTC-only with no ProPresenter host configured; the reader-fed card no longer grays out when only ProPresenter is down. Co-Authored-By: Claude Fable 5 --- README.md | 18 +- modules/propresenter-timers/client.js | 30 +- modules/propresenter-timers/module.json | 7 +- modules/propresenter-timers/server.js | 143 +++++- modules/propresenter-timers/style.css | 14 +- tools/ltc-reader.js | 566 ++++++++++++++++++++++++ 6 files changed, 744 insertions(+), 34 deletions(-) create mode 100644 tools/ltc-reader.js diff --git a/README.md b/README.md index f3473f6..c207401 100644 --- a/README.md +++ b/README.md @@ -91,10 +91,20 @@ are written by the app itself. 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. 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. + unmistakably red. Read-only — no start/stop controls. Admin config: + ProPresenter endpoint (host : port), API password, stage display + password, LTC reader token. + + LTC sources, best first (ProPresenter's HTTP API has no timecode route): + 1. **The LTC audio feed itself** — run `tools/ltc-reader.js` on any + machine that can hear the LTC signal; it decodes the SMPTE frames from + a PCM pipe (ffmpeg/sox one-liners in its header) and POSTs + running/stopped/no-signal, the timecode, and the frame rate (incl. + 29.97 drop-frame) to the module's `/ltc` ingest route. `--selftest` + checks the decoder; `--demo` feeds synthetic LTC end-to-end with no + audio hardware. + 2. A stage-display layout field labeled "LTC", read over ProPresenter's + stage websocket — value and freshness only, no frame rate. - **Clock** — a big booth clock; also the smallest possible module and the reference for module authors. diff --git a/modules/propresenter-timers/client.js b/modules/propresenter-timers/client.js index 0a6f826..c239214 100644 --- a/modules/propresenter-timers/client.js +++ b/modules/propresenter-timers/client.js @@ -207,11 +207,15 @@ export default function create({ root, moduleApi }) { function updateLtcCard(card, ltc) { let mode; + // Only the reader source knows the frame rate (decoded from the LTC + // bits); a set drop-frame flag implies 29.97 whatever fps rounds to. + const rate = ltc.df ? '29.97 DF' : ltc.fps ? `${ltc.fps} fps` : ''; if (ltc.supported === false) { mode = 'unavailable'; card.timeEl.textContent = '—'; // The server says why when it knows (stage password rejected, no - // stage field labeled LTC, …) — surface that over the generic line. + // stage field labeled LTC, reader offline, …) — surface that over + // the generic line. card.stateEl.textContent = ltc.note || 'Timecode not available on this ProPresenter'; } else if (ltc.supported === null) { mode = 'waiting'; @@ -220,19 +224,30 @@ export default function create({ root, moduleApi }) { } else if (ltc.receiving) { mode = 'receiving'; card.timeEl.textContent = displayTime(ltc.time); - card.stateEl.textContent = 'Receiving'; + card.stateEl.textContent = rate ? `Running · ${rate}` : 'Running'; + } else if (ltc.status === 'stopped' || ltc.time) { + // LTC ceased but we know where it stopped — hold the last frame. + mode = 'stopped'; + card.timeEl.textContent = displayTime(ltc.time); + card.stateEl.textContent = 'Stopped'; } else { mode = 'nosignal'; - card.timeEl.textContent = displayTime(ltc.time); + card.timeEl.textContent = '—'; card.stateEl.textContent = 'No signal'; } - card.el.className = `tm-card tm-card-ltc is-ltc-${mode}`; + // The reader posts independently of ProPresenter, so its card must not + // gray out with the rest when only ProPresenter is unreachable. + const live = ltc.source === 'reader' ? ' src-reader' : ''; + card.el.className = `tm-card tm-card-ltc is-ltc-${mode}${live}`; } /* ── status dot + degraded banners ──────────────────────────────── */ function renderStatus() { wrap.classList.toggle('is-stale', feedOffline || (state ? !state.reachable && state.enabled : false)); + // The stale gray-out spares the reader-fed LTC card — unless the feed + // from the ProdDash server itself is down, when everything is stale. + wrap.classList.toggle('is-feed-down', feedOffline); if (feedOffline) { bannerEl.hidden = false; bannerEl.textContent = 'Not receiving updates from the ProdDash server.'; @@ -246,7 +261,10 @@ export default function create({ root, moduleApi }) { } if (!state.enabled) { bannerEl.hidden = true; - moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin'); + // The LTC reader feeds us with or without ProPresenter — an LTC-only + // setup is working, not broken. + if (state.ltc?.supported) moduleApi.setStatus('ok', 'LTC via reader — no ProPresenter host configured'); + else moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin'); return; } if (!state.reachable) { @@ -266,7 +284,7 @@ export default function create({ root, moduleApi }) { rebuildMenu(); let hint = ''; - if (state && !state.enabled) { + if (state && !state.enabled && !state.ltc?.supported) { hint = 'No ProPresenter connection — set the host and port in Admin.'; } const items = hint ? [] : visibleItems(); diff --git a/modules/propresenter-timers/module.json b/modules/propresenter-timers/module.json index 34f94c0..00153b4 100644 --- a/modules/propresenter-timers/module.json +++ b/modules/propresenter-timers/module.json @@ -1,7 +1,7 @@ { "id": "propresenter-timers", "name": "Timers", - "version": "1.0.0", + "version": "1.1.0", "description": "Live ProPresenter timers and LTC timecode", "client": "client.js", "server": "server.js", @@ -23,6 +23,11 @@ "type": "password", "label": "Stage display password (for LTC — ProPresenter Settings → Network)", "default": "" + }, + "ingestToken": { + "type": "password", + "label": "LTC reader token (optional — must match tools/ltc-reader.js --token)", + "default": "" } } } diff --git a/modules/propresenter-timers/server.js b/modules/propresenter-timers/server.js index f90aeaa..685b71a 100644 --- a/modules/propresenter-timers/server.js +++ b/modules/propresenter-timers/server.js @@ -10,6 +10,8 @@ * * GET /state → { state: } (instant paint for new tiles) * GET /stream → SSE, `state` events (change-detected, heartbeated) + * POST /ltc → LTC ingest: tools/ltc-reader.js decodes the actual LTC + * audio signal and reports { state, time, fps, df } here. * * Endpoints used, verified against openapi.propresenter.com (API v1): * GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun, @@ -28,10 +30,13 @@ * 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". + * Because the HTTP API has no timecode, LTC comes from two real sources: + * the external reader (POST /ltc — tools/ltc-reader.js decoding the LTC + * audio feed itself, ground truth while its reports are fresh) and the + * classic stage-display websocket (see stage-ws.js): a stage layout field + * labeled "LTC" streams its text to us. Source precedence: fresh reader + * ingest, else the HTTP route if it ever answers, 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 @@ -51,6 +56,9 @@ const SLOW_EVERY = 4; const REQUEST_TIMEOUT_MS = 4000; /** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */ const HEARTBEAT_MS = 15000; +/** A reader report older than this no longer speaks for the LTC feed — + the reader posts at least 1×/s, so 6s of silence means it died. */ +const INGEST_STALE_MS = 6000; const { createStageLtcClient } = require('./stage-ws'); @@ -64,9 +72,11 @@ function clearedState(enabled) { lastError: '', timers: [], // 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: '' }, + // unreachable); true once a timecode source is live; false when every + // source came up empty (note says why, when we know). status is the + // richer verdict (running|stopped|nosignal); fps/df only when the + // source knows them (the reader decodes both from the LTC bits). + ltc: { supported: null, time: '', receiving: false, status: '', fps: 0, df: false, source: '', note: '' }, }; } @@ -95,20 +105,72 @@ module.exports = { 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: '' }; + /** Latest POST /ltc report from the external reader, null until one lands. */ + let ingest = null; // { state, time, fps, df, source, at } + let ingestExpiry = null; - /** One LTC verdict from both sources: HTTP route wins if it ever - answers (authoritative), else the stage-display field. */ + /** One LTC verdict from all three sources. The external reader decodes + the LTC audio itself, so a fresh report from it is ground truth; + then the HTTP route if it ever answers; then the stage-display + field. Text sources don't know fps — receiving/frozen/empty maps + onto the same status vocabulary the reader uses. */ function composeLtc() { + if (ingest && Date.now() - ingest.at < INGEST_STALE_MS) { + return { + supported: true, + source: 'reader', + time: ingest.time, + receiving: ingest.state === 'running', + status: ingest.state, + fps: ingest.fps, + df: ingest.df, + note: '', + }; + } + const textStatus = (src) => (src.receiving ? 'running' : src.time ? 'stopped' : 'nosignal'); if (ltcSupported === true) { - return { supported: true, source: 'api', time: ltcLast.time, receiving: ltcLast.receiving, note: '' }; + return { supported: true, source: 'api', time: ltcLast.time, receiving: ltcLast.receiving, status: textStatus(ltcLast), fps: 0, df: false, note: '' }; } if (stageLtc.bound) { - return { supported: true, source: 'stage', time: stageLtc.time, receiving: stageLtc.receiving, note: '' }; + return { supported: true, source: 'stage', time: stageLtc.time, receiving: stageLtc.receiving, status: textStatus(stageLtc), fps: 0, df: false, note: '' }; } - if (ltcSupported === null && !stageLtc.note) { - return { supported: null, source: '', time: '', receiving: false, note: '' }; + if (ingest) { + // A reader reported once but went silent — likelier to be the + // story than anything the stage feed has to say. + return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: 'LTC reader offline' }; } - return { supported: false, source: '', time: '', receiving: false, note: stageLtc.note || '' }; + if (ltcSupported === null && !stageLtc.note && enabled) { + return { supported: null, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: '' }; + } + return { supported: false, source: '', time: '', receiving: false, status: '', fps: 0, df: false, note: stageLtc.note || '' }; + } + + /** Timecode-shaped text — same shape rule as the other sources. */ + const TC_SHAPE = /^-?\d{1,2}:\d{2}:\d{2}[:;.]\d{1,3}$/; + const INGEST_STATES = new Set(['running', 'stopped', 'nosignal']); + + /** Validated apply of one POST /ltc body. Returns '' or a client error. */ + function applyIngest(body) { + if (!body || typeof body !== 'object') return 'body must be a JSON object'; + const state = INGEST_STATES.has(body.state) ? body.state : ''; + if (!state) return 'state must be one of running|stopped|nosignal'; + const time = typeof body.time === 'string' && TC_SHAPE.test(body.time.trim()) ? body.time.trim() : ''; + if (state === 'running' && !time) return 'a running report needs a timecode-shaped time'; + ingest = { + state, + time, + fps: Number.isFinite(body.fps) && body.fps > 0 && body.fps <= 120 ? Number(body.fps) : 0, + df: body.df === true, + source: String(body.source || '').slice(0, 60), + at: Date.now(), + }; + // When the reader dies mid-run the card must not stay green forever — + // re-broadcast once this report goes stale so the fallbacks take over. + clearTimeout(ingestExpiry); + ingestExpiry = setTimeout(broadcast, INGEST_STALE_MS + 100); + ingestExpiry.unref?.(); + broadcast(); + return ''; } function url(target) { @@ -331,12 +393,13 @@ module.exports = { }, HEARTBEAT_MS); heartbeat.unref?.(); - current = { streams, getLatest: () => latest }; + current = { streams, getLatest: () => latest, ingest: applyIngest }; return { stop() { stopped = true; clearTimeout(pollTimer); + clearTimeout(ingestExpiry); clearInterval(heartbeat); stageClient?.stop(); for (const res of streams) { @@ -346,11 +409,17 @@ module.exports = { if (current && current.streams === streams) current = null; }, health() { - if (!enabled) return { status: 'error', message: 'No ProPresenter host configured' }; + const ltcSource = { reader: ', LTC via reader', api: ', LTC via API', stage: ', LTC via stage display' }; + if (!enabled) { + // The LTC reader posts to us regardless of ProPresenter — an + // LTC-only setup is half-configured but working. + if (latest.ltc.source === 'reader') return { status: 'ok', message: 'No ProPresenter host configured — LTC via reader only' }; + return { status: 'error', message: 'No ProPresenter host configured' }; + } if (latest.reachable) { const ltc = latest.ltc; const ltcNote = ltc.supported - ? (ltc.source === 'stage' ? ', LTC via stage display' : ', LTC via API') + ? (ltcSource[ltc.source] || '') : (ltc.note ? ` (${ltc.note})` : ''); return { status: 'ok', message: `Polling ${host}:${port}, ${latest.timers.length} timer(s)${ltcNote}` }; } @@ -393,11 +462,45 @@ module.exports = { ]; }, - routes() { + routes({ config }) { + const sendJson = (res, code, body) => { + res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(body)); + }; return { 'GET /state': (req, res) => { - res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify({ state: current ? current.getLatest() : clearedState(false) })); + sendJson(res, 200, { state: current ? current.getLatest() : clearedState(false) }); + }, + + // The external LTC reader (tools/ltc-reader.js) reports here — the + // one route on this module that accepts writes, so it optionally + // carries a shared token (Admin → "LTC reader token"). + 'POST /ltc': (req, res) => { + const token = String(config.ingestToken || ''); + if (token) { + const given = String(req.headers.authorization || '').replace(/^Bearer\s+/i, ''); + if (given !== token) return void sendJson(res, 401, { error: 'bad or missing token' }); + } + let body = ''; + req.on('data', (chunk) => { + body += chunk; + if (body.length > 4096) { + sendJson(res, 413, { error: 'body too large' }); + req.destroy(); + } + }); + req.on('end', () => { + if (!current) return void sendJson(res, 503, { error: 'module not mounted' }); + let parsed; + try { + parsed = JSON.parse(body); + } catch { + return void sendJson(res, 400, { error: 'invalid JSON' }); + } + const problem = current.ingest(parsed); + if (problem) return void sendJson(res, 400, { error: problem }); + sendJson(res, 200, { ok: true }); + }); }, 'GET /stream': (req, res) => { diff --git a/modules/propresenter-timers/style.css b/modules/propresenter-timers/style.css index 7ef4f61..d9092b2 100644 --- a/modules/propresenter-timers/style.css +++ b/modules/propresenter-timers/style.css @@ -30,8 +30,12 @@ } [data-module="propresenter-timers"] .tm-banner[hidden] { display: none; } -/* Values still on screen while the feed is down are stale — say so visually. */ -[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-grid { opacity: 0.45; } +/* Values still on screen while the feed is down are stale — say so visually. + Per-card, because the reader-fed LTC card is not stale when only + ProPresenter is unreachable; it dims only when the ProdDash feed itself + is down (is-feed-down) and everything on screen really is old. */ +[data-module="propresenter-timers"] .tm-wrap.is-stale .tm-card { opacity: 0.45; } +[data-module="propresenter-timers"] .tm-wrap.is-stale:not(.is-feed-down) .tm-card-ltc.src-reader { opacity: 1; } [data-module="propresenter-timers"] .tm-hint { flex: 1; @@ -54,7 +58,6 @@ grid-auto-rows: 1fr; gap: var(--tm-gap, 8px); padding: var(--tm-pad, 10px); - transition: opacity 0.3s ease; } [data-module="propresenter-timers"] .tm-grid[hidden] { display: none; } @@ -80,6 +83,7 @@ border-radius: 10px; background: color-mix(in srgb, var(--bg) 60%, transparent); overflow: hidden; + transition: opacity 0.3s ease; } [data-module="propresenter-timers"] .tm-name { @@ -198,6 +202,10 @@ @keyframes tm-nosignal-blink { 50% { opacity: 0.35; } } +/* LTC ceased mid-show: hold the last frame, quietly — no alarm colors, + stopping the clock is usually somebody doing their job. */ +[data-module="propresenter-timers"] .tm-card-ltc.is-ltc-stopped .tm-time { color: var(--muted); } + /* No timecode API on this ProPresenter: say so, quietly. */ [data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-time { color: var(--muted); } [data-module="propresenter-timers"] .tm-card-ltc.is-ltc-unavailable .tm-state { diff --git a/tools/ltc-reader.js b/tools/ltc-reader.js new file mode 100644 index 0000000..b1bf21f --- /dev/null +++ b/tools/ltc-reader.js @@ -0,0 +1,566 @@ +#!/usr/bin/env node +'use strict'; + +/** + * ltc-reader — decode SMPTE LTC from an audio feed and push it to ProdDash. + * + * ProPresenter's APIs expose no timecode (see modules/propresenter-timers), + * so this tool reads LTC where it actually lives: the audio signal. Pipe + * mono PCM (signed 16-bit little-endian) into stdin from any capture + * front-end; the decoded state is POSTed to the Timers module's /ltc + * ingest route — ~4×/s while running, 1×/s as a keepalive otherwise, and + * immediately on every state change. + * + * macOS (ffmpeg; list input devices with + * `ffmpeg -f avfoundation -list_devices true -i ""`): + * ffmpeg -loglevel error -f avfoundation -i ":0" -ac 1 -ar 48000 \ + * -f s16le - | node tools/ltc-reader.js --url http://127.0.0.1:24500 + * sox (default input device): + * sox -q -d -t raw -b 16 -e signed -c 1 -r 48000 - | node tools/ltc-reader.js + * Windows (ffmpeg/dshow): + * ffmpeg -loglevel error -f dshow -i audio="" -ac 1 \ + * -ar 48000 -f s16le - | node tools/ltc-reader.js + * + * Flags: + * --url ProdDash base URL (default http://127.0.0.1:24500) + * --module target module id (default propresenter-timers) + * --token matches the module's "LTC reader token" admin setting + * --sr stdin sample rate (default 48000) + * --source