Timers: LTC timecode via the stage-display websocket
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f80790e3a1
commit
444f03938d
@@ -91,8 +91,10 @@ are written by the app itself.
|
|||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
- 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:
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +18,11 @@
|
|||||||
"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": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,20 @@ 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,
|
||||||
|
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 +335,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 +345,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 {
|
||||||
|
|||||||
@@ -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":"<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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 };
|
||||||
+136
-1
@@ -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,8 @@ 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');
|
||||||
const FPS = 30;
|
const FPS = 30;
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
@@ -140,6 +153,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 ? [] : [{ 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)' : ''}`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user