Files
ProdDash/modules/propresenter-timers/server.js
Waters ChurchandClaude Fable 5 2f4c357bf5 Timers 1.4.2: ProPresenter digits no longer flick back a second
ProPresenter reports running timers as truncated whole seconds, and the
poller re-anchored the tiles' local count at the bottom of that second on
every 500 ms poll — so the displayed digit ran up to a second ahead and
got yanked back a step whenever a fresh poll sampled the next flip later
than the last one did. Tiles flickered ...12:33 → 12:34 → 12:33... about
once a second.

phase.js turns the quantized reports into one stable anchor per timer:
the only information in a poll is a FLIP (the report stepping one second
in the direction of travel), which dates the true boundary to the
midpoint of the two polls that straddle it. While reports stay consistent
with the anchor (±450 ms) the anchor holds, so tiles count along one
steady clock; a report the anchor can't explain (reset, seek, resume,
accumulated drift) re-anchors. Timers carry the anchor as a per-timer
asOf the client extrapolates from (optional in the provider contract,
documented in the guide); the client additionally refuses to tick a
running card backwards over sub-1.5 s jitter.

phase.selftest.js (66 checks) drives a simulated ProPresenter — truncated
reports, jittered polls — through the tracker and the client's render
math: digits never step backwards, dwells hold 1 s (the ~2 s zero-crossing
dwell is trunc's honest behavior), anchors stay within tolerance of the
true value, resets snap, pause/resume re-locks, 0.2% clock drift is
absorbed. Verified live against tools/pp-timers-mock.js: 78 s of 100 ms
DOM sampling across four running cards — every step ±1 in the card's own
direction, dwells 998–1002 ms, zero regressions; upstream kill/restart
still recovers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-27 08:46:50 -04:00

1220 lines
47 KiB
JavaScript

'use strict';
/**
* Timers — server part.
*
* One tile for every timer and clock in the room, from any source. Each
* SOURCE turns its own world into the module guide's standard timer objects
* ({ id, label, kind, state, startedAt, elapsedMs, remainingMs, targetMs,
* status, detail } — see "Offering timers to the Timers module") and reports
* its health. This file merges the sources, fans the merged state out to the
* tiles over SSE, and offers the whole set to the rest of ProdDash as a
* timers provider itself. Browsers never talk to ProPresenter, the audio
* hardware or other modules directly.
*
* Sources (one factory each, below):
* propresenter the ProPresenter openAPI timer list + live values, polled
* exactly as before (admin switch: ppEnabled)
* ltc SMPTE LTC decoded from an audio input by the built-in
* listener (ltc-listener.js / ltc.js — untouched), shown as a
* kind:"clock" timer whose status.text is the timecode and
* whose `ltc` extension carries fps/df/ageMs so the tile can
* count frames between reports (admin switch: ltcEnabled)
* clock time of day. Trivial here — the tile paints its own wall
* clock — it exists so the clock is one more entry in
* /timers and in the tile's list, like everything else
* modules every other module whose manifest says
* "provides": ["timers"], discovered from the shell's
* /api/modules over loopback (shell.port, never guessed) and
* read through its /timers/stream when that answers as SSE,
* else its /timers polled every 2 s. Re-discovered every 30 s
* so a newly installed provider appears without a restart.
* PCO Plan sits behind the admin pcoEnabled switch; any other
* provider is consumed as soon as it is found (give it a
* switch of its own in module.json the day one needs it).
*
* ADDING A SOURCE (another timer platform) is a dozen lines. Write a factory
* that returns
*
* { id, label, kind, start(emit), stop(), health() }
*
* id short, stable ('propresenter', 'ltc', …) — the tile keys its
* show/hide settings on `${id}:${timer.id}`
* label the heading the tile puts over this source's timers
* kind which tile switch governs it: 'propresenter' | 'ltc' | 'clock'
* | 'module' (a new platform would add its own switch and kind)
* start(emit) begins following the upstream and calls
* emit({ status, message, timers, updatedAt }) whenever the view
* changes — status 'ok' | 'connecting' | 'error', message one
* human line (shown under the heading while not ok), timers an
* array of standard timer objects as of updatedAt
* stop() tears everything down (a re-init follows every admin save)
* health() → { status, message } for the admin health line
*
* then, in init(), push it onto `fixed` behind its own admin `switch` field
* (named as that group's `toggle` in module.json). Merging, change
* detection, both SSE feeds, /timers and the tile are generic — nothing
* else changes.
*
* Routes:
* GET /state → { state } tile snapshot (instant paint)
* GET /stream → SSE `state` events tile feed, change-detected
* GET /timers → provider snapshot the module guide's contract
* GET /timers/stream → SSE `timers` events same, for other consumers
* GET /ltc/devices → this machine's audio inputs, for the admin page's
* device picker (select + optionsRoute)
*
* ProPresenter endpoints, verified against openapi.propresenter.com (v1):
* GET /v1/timers → [ { id:{uuid,name,index}, allows_overrun,
* countdown:{duration} | count_down_to_time:{time_of_day,period}
* | elapsed:{start_time,end_time} } ]
* GET /v1/timers/current → [ { id:{uuid,name,index}, time:"HH:MM:SS", state } ]
* `time` may be negative ("-00:00:02") while overrunning, and some builds
* append hundredths. `state` enum is stopped|running|complete|overrunning|
* overran — but the spec's own example says "overrun", so anything
* containing "overr" is overrun. The API exposes NO per-timer color.
* ProPresenter's HTTP server is easily overwhelmed, so requests run strictly
* sequentially, live values are the only every-tick call, and the timer
* list rides a slow lane.
*/
const http = require('http');
const path = require('path');
const { spawn } = require('child_process');
const { createLtcListener } = require('./ltc-listener');
const { createTimerPhase } = require('./phase');
const manifest = require('./module.json');
/** This module's id — left out of provider discovery (we don't consume ourselves). */
const SELF_ID = 'propresenter-timers';
/** The provider the admin "PCO Plan" switch governs; every other provider is always on. */
const PCO_ID = 'pco-plan';
/* ProPresenter cadence. */
const POLL_MS = 500;
const RETRY_MS = 2500;
const SLOW_EVERY = 4;
const REQUEST_TIMEOUT_MS = 4000;
/* Timers offered by other modules. */
const DISCOVER_MS = 30000; // re-read /api/modules: a newly installed provider shows up without a restart
const DISCOVER_RETRY_MS = 5000; // …sooner while the shell isn't answering yet (boot)
const DISCOVER_FIRST_MS = 1500; // init() runs before the shell listens
const MODULE_POLL_MS = 2000; // GET /timers cadence for a provider with no stream
const BACKOFF_MIN_MS = 3000;
const BACKOFF_MAX_MS = 15000;
const GRACE_MS = 10000; // a provider's timers survive this long without a fresh snapshot
const STREAM_SILENCE_MS = 45000; // a stream with no bytes for this long (heartbeats are 15 s) is dead
const MAX_TIMERS_PER_PROVIDER = 50;
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
const HEARTBEAT_MS = 15000;
const KINDS = new Set(['elapsed', 'countdown', 'clock']);
const STATES = new Set(['running', 'paused', 'stopped', 'idle']);
const TONES = new Set(['ok', 'warn', 'danger', 'muted']);
/** Set by init(), read by routes() and tiles() — all rebuilt together on remount. */
let current = null;
/* ── small helpers ────────────────────────────────────────────────────── */
function sendJson(res, code, body) {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
}
/** Open an SSE response, send the first frame, and register it in `set`. */
function openSse(req, res, set, firstFrame) {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
res.write(firstFrame);
if (!set) return void res.end();
// A viewer that stops reading (locked tablet, sleeping laptop) must not
// queue frames forever; keepalive probes make the OS notice.
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
set.add(res);
req.on('close', () => set.delete(res));
}
function writeAll(set, payload) {
for (const res of set) {
try {
res.write(payload);
} catch {
set.delete(res);
}
}
}
/** "HH:MM:SS", "-HH:MM:SS", "MM:SS", optional ".hh" → signed milliseconds, or null. */
function hmsToMs(str) {
const m = /^\s*(-)?(?:(\d+):)?(\d{1,2}):(\d{2})(?:\.(\d+))?\s*$/.exec(String(str || ''));
if (!m) return null;
const h = m[2] ? Number(m[2]) : 0;
let ms = (h * 3600 + Number(m[3]) * 60 + Number(m[4])) * 1000;
if (m[5]) ms += Math.round(Number('0.' + m[5]) * 1000);
return m[1] ? -ms : ms;
}
/** ms → "M:SS" / "H:MM:SS" (for detail lines). */
function fmtMs(ms) {
const s = Math.max(0, Math.round(ms / 1000));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const pad = (v) => String(v).padStart(2, '0');
return h ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
}
/** ProPresenter's count_down_to_time target, short: "9:30a", "12:05p", or "21:30" for a 24-hour clock. */
function timeOfDayText(seconds, period) {
const s = Number(seconds);
if (!Number.isFinite(s)) return '';
const h = Math.floor(s / 3600) % 24;
const m = Math.floor((s % 3600) / 60);
const p = String(period || '').toLowerCase();
if (p === 'am' || p === 'pm') return `${h % 12 || 12}:${String(m).padStart(2, '0')}${p[0]}`;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
function errorText(err) {
return String(err?.cause?.code || err?.code || err?.message || err || 'failed');
}
/* ── validation of what other modules hand us ─────────────────────────── */
function numOrNull(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
/** One timer object from a provider, coerced to the contract (or null to drop it). */
function sanitizeTimer(t) {
if (!t || typeof t !== 'object') return null;
const id = t.id === null || t.id === undefined ? '' : String(t.id);
if (!id) return null;
let status = null;
if (t.status && typeof t.status === 'object' && t.status.text !== undefined && t.status.text !== null) {
status = { text: String(t.status.text).slice(0, 200) };
if (TONES.has(t.status.tone)) status.tone = t.status.tone;
}
return {
id,
label: String(t.label || id).slice(0, 120),
kind: KINDS.has(t.kind) ? t.kind : 'elapsed',
state: STATES.has(t.state) ? t.state : 'stopped',
asOf: numOrNull(t.asOf),
startedAt: numOrNull(t.startedAt),
elapsedMs: numOrNull(t.elapsedMs),
remainingMs: numOrNull(t.remainingMs),
targetMs: numOrNull(t.targetMs),
status,
detail: t.detail === null || t.detail === undefined ? '' : String(t.detail).slice(0, 200),
};
}
/** A provider's /timers document → { label, timers, updatedAt }, or null when it isn't one. */
function sanitizeSnapshot(raw) {
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.timers)) return null;
return {
label: raw.label ? String(raw.label).slice(0, 60) : '',
timers: raw.timers.map(sanitizeTimer).filter(Boolean).slice(0, MAX_TIMERS_PER_PROVIDER),
updatedAt: Number(raw.updatedAt) || Date.now(),
};
}
/* ── source: ProPresenter ─────────────────────────────────────────────── */
function createProPresenterSource({ config, log }) {
// Endpoint config. Legacy flat host/port keys (saved before the endpoint
// field existed) can only still be present until the admin form is
// re-saved, so when they exist they win over the endpoint's default.
const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {};
const host = String(config.host ?? ep.host ?? '');
const port = Number(config.port ?? ep.port) || 1025;
const password = String(config.password || '');
const configured = Boolean(host && port);
let emit = () => {};
let stopped = false;
let pollTimer = null;
let tick = 0;
let reachable = false;
let lastError = '';
let timers = [];
let updatedAt = 0;
/** uuid → { name, index, type, allowsOverrun, targetMs, detail } from /v1/timers, in list order. */
let timerConfigs = new Map();
/** uuid → phase tracker: whole-second reports → one stable anchor (see phase.js). */
const phases = new Map();
function url(target) {
const built = new URL(`http://${host}:${port}${target}`);
if (password) built.searchParams.set('password', password);
return built;
}
async function requestJson(target) {
const response = await fetch(url(target), {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
const err = new Error(`ProPresenter ${response.status} ${response.statusText}`);
err.status = response.status;
throw err;
}
const text = await response.text();
return text ? JSON.parse(text) : null;
}
function describe(entry) {
if (entry.countdown) {
const targetMs = Number(entry.countdown.duration) > 0 ? Number(entry.countdown.duration) * 1000 : null;
return { type: 'countdown', targetMs, detail: targetMs !== null ? `Countdown ${fmtMs(targetMs)}` : 'Countdown' };
}
if (entry.count_down_to_time) {
const at = timeOfDayText(entry.count_down_to_time.time_of_day, entry.count_down_to_time.period);
return { type: 'countdown_to_time', targetMs: null, detail: at ? `to ${at}` : 'to a time of day' };
}
if (entry.elapsed) {
const end = Number(entry.elapsed.end_time);
const targetMs = Number.isFinite(end) && end > 0 ? end * 1000 : null;
return { type: 'elapsed', targetMs, detail: targetMs !== null ? `Elapsed · to ${fmtMs(targetMs)}` : 'Elapsed' };
}
return { type: 'unknown', targetMs: null, detail: '' };
}
function applyTimerList(list) {
if (!Array.isArray(list)) return;
const next = new Map();
for (const entry of list) {
const uuid = entry?.id?.uuid;
if (!uuid) continue;
next.set(uuid, {
name: String(entry.id.name || 'Timer'),
index: Number(entry.id.index) || 0,
allowsOverrun: Boolean(entry.allows_overrun),
...describe(entry),
});
}
timerConfigs = next;
}
/** One ProPresenter timer (configured entry + live value) → a standard timer object. */
function toTimer(uuid, cfg, live, now) {
const rawState = String(live?.state || '').toLowerCase();
const overrun = rawState.includes('overr');
const value = live ? hmsToMs(live.time) : null;
const type = cfg ? cfg.type : 'unknown';
// A timer reported live but missing from the (stale) config list has an
// unknown type until the slow lane catches up (~2 s); treat it as elapsed
// meanwhile — the whole-second display can't drift wrong in that time.
const kind = type === 'countdown' || type === 'countdown_to_time' ? 'countdown' : 'elapsed';
const targetMs = cfg ? cfg.targetMs : null;
let state;
if (!live) state = 'idle';
else if (rawState === 'running' || overrun) state = 'running';
else if (rawState === 'complete') state = 'stopped';
else {
// ProPresenter's "stopped" covers both a timer that never started
// (still showing its full length) and one paused mid-way.
const atStart = value === null || (kind === 'countdown' ? targetMs !== null && value === targetMs : value === 0);
state = atStart ? 'idle' : 'paused';
}
// ProPresenter reports whole (truncated) seconds. Re-anchoring the tiles'
// local count at the bottom of that second every poll made the digits run
// ahead and flick back a step about once a second — phase.js turns the
// reports into one stable anchor { valueMs, asOf } the tiles count from.
let phase = phases.get(uuid);
if (!phase) {
phase = createTimerPhase();
phases.set(uuid, phase);
}
const anchored = phase.observe({ kind, running: state === 'running', wholeMs: value, pollAt: now });
const v = anchored.valueMs;
const asOf = anchored.asOf;
const elapsedMs = kind === 'elapsed' ? v : targetMs !== null && v !== null ? targetMs - v : null;
const remainingMs = kind === 'countdown' ? v : targetMs !== null && v !== null ? targetMs - v : null;
const startedAt = state === 'running' && elapsedMs !== null ? asOf - elapsedMs : null;
const text = !live ? '—' : overrun ? 'Overrun' : rawState === 'running' ? 'Running' : rawState === 'complete' ? 'Complete' : 'Stopped';
const tone = overrun ? 'danger' : rawState === 'running' ? 'ok' : rawState === 'complete' ? 'warn' : 'muted';
return {
id: uuid,
label: (live && live.name) || (cfg && cfg.name) || 'Timer',
kind,
state,
asOf,
startedAt,
elapsedMs,
remainingMs,
targetMs,
status: { text, tone },
detail: cfg ? cfg.detail : '',
};
}
/** Merge configured timers (order, type) with live values into one list. */
function mergeTimers(currentTimes, now) {
const live = new Map();
if (Array.isArray(currentTimes)) {
for (const entry of currentTimes) {
const uuid = entry?.id?.uuid;
if (!uuid) continue;
live.set(uuid, {
name: String(entry.id.name || ''),
time: typeof entry.time === 'string' ? entry.time : '',
state: typeof entry.state === 'string' ? entry.state : '',
});
}
}
const out = [];
for (const [uuid, cfg] of timerConfigs) {
const now_ = live.get(uuid) || null;
live.delete(uuid);
out.push(toTimer(uuid, cfg, now_, now));
}
// Timers reported live but missing from the (possibly stale) config
// list still deserve a card — type unknown until the slow lane catches up.
for (const [uuid, now_] of live) out.push(toTimer(uuid, null, now_, now));
for (const uuid of phases.keys()) {
if (!out.some((t) => t.id === uuid)) phases.delete(uuid); // timer deleted in ProPresenter
}
return out;
}
function status() {
if (!configured) return 'error';
if (reachable) return 'ok';
return lastError ? 'error' : 'connecting';
}
function message() {
if (!configured) return 'No ProPresenter host configured — set one in Admin, or switch ProPresenter timers off';
if (reachable) return `Polling ${host}:${port}, ${timers.length} timer${timers.length === 1 ? '' : 's'}`;
return lastError ? `Unreachable at ${host}:${port} — ${lastError}` : `Reaching ${host}:${port}…`;
}
function publish() {
if (stopped) return;
emit({ status: status(), message: message(), timers, updatedAt });
}
async function pollTick() {
if (stopped) return;
try {
tick += 1;
// Slow lane first on the very first tick so cards get names/types
// immediately; afterwards every SLOW_EVERY ticks.
if (tick % SLOW_EVERY === 1 || !timerConfigs.size) {
applyTimerList(await requestJson('/v1/timers'));
}
// THE HOT PATH — live values every tick.
const currentTimes = await requestJson('/v1/timers/current');
updatedAt = Date.now();
timers = mergeTimers(currentTimes, updatedAt);
reachable = true;
lastError = '';
} catch (err) {
reachable = false;
lastError = errorText(err); // "ECONNREFUSED" beats fetch's "fetch failed"
}
publish();
if (stopped) return;
pollTimer = setTimeout(pollTick, reachable ? POLL_MS : RETRY_MS);
}
return {
id: 'propresenter',
label: 'ProPresenter',
kind: 'propresenter',
start(e) {
emit = e;
if (!configured) {
log('ProPresenter timers on, but no host configured — set one in /admin');
publish();
return;
}
log(`polling timers on ${host}:${port}`);
pollTick();
},
stop() {
stopped = true;
clearTimeout(pollTimer);
},
health() {
return { status: status(), message: message() };
},
};
}
/* ── source: LTC timecode (built-in listener) ─────────────────────────── */
function createLtcSource({ config, log, moduleDir }) {
const device = String(config.ltcDevice || '');
const channel = Math.max(1, Math.trunc(Number(config.ltcChannel)) || 1);
let emit = () => {};
let listener = null;
let stopped = false;
/** The listener's latest word: { state, time, fps, df, ageMs } | { error } | null while starting. */
let word = null;
function rateText() {
return word?.df ? '29.97 DF' : word?.fps ? `${word.fps} fps` : '';
}
/** The LTC timecode as one standard timer object (kind clock, timecode in status.text). */
function timer() {
const base = { id: 'ltc', label: 'Timecode', kind: 'clock', startedAt: null, elapsedMs: null, remainingMs: null, targetMs: null };
if (!word || word.error) {
const note = word ? word.error : 'Starting LTC listener…';
return { ...base, state: 'idle', status: { text: '', tone: 'muted' }, detail: note, ltc: { time: '', fps: 0, df: false, status: '', note } };
}
const running = word.state === 'running';
const rate = rateText();
const ltc = { time: word.time || '', fps: word.fps || 0, df: Boolean(word.df), status: word.state, note: '' };
// While running: how old this timecode is, so the tile can pin the
// anchor to its own clock and count frames between reports.
if (running && Number.isFinite(word.ageMs)) ltc.ageMs = Math.max(0, word.ageMs);
return {
...base,
state: running ? 'running' : word.state === 'stopped' ? 'stopped' : 'idle',
status: { text: word.time || '', tone: running ? 'ok' : word.state === 'stopped' ? 'muted' : 'warn' },
detail: running ? (rate ? `Running · ${rate}` : 'Running') : word.state === 'stopped' ? 'Stopped' : 'No signal',
ltc,
};
}
function status() {
if (!word) return 'connecting';
return word.error ? 'error' : 'ok';
}
function message() {
if (!word) return 'Starting LTC listener…';
if (word.error) return word.error;
const rate = rateText();
return word.state === 'running' ? `LTC running${rate ? ` · ${rate}` : ''}` : word.state === 'stopped' ? 'LTC stopped' : 'LTC no signal';
}
function publish() {
if (stopped) return;
emit({ status: status(), message: message(), timers: [timer()], updatedAt: Date.now() });
}
return {
id: 'ltc',
label: 'LTC',
kind: 'ltc',
start(e) {
emit = e;
if (!device) {
word = { error: 'LTC timecode is on but no audio device is selected — pick one in Admin' };
publish();
return;
}
publish();
listener = createLtcListener({
device,
channel,
moduleDir,
log,
onUpdate(update) {
word = update;
publish();
},
});
},
stop() {
stopped = true;
listener?.stop();
listener = null;
},
health() {
return { status: status(), message: message() };
},
};
}
/* ── source: time of day ──────────────────────────────────────────────── */
function createClockSource() {
return {
id: 'clock',
label: 'Clock',
kind: 'clock',
start(emit) {
// status.text (the ISO time) is stamped when a snapshot is served —
// see stampClock() — so this never has to re-emit.
emit({
status: 'ok',
message: 'ticking',
updatedAt: Date.now(),
timers: [{ id: 'local', label: 'Clock', kind: 'clock', state: 'running', startedAt: null, elapsedMs: null, remainingMs: null, targetMs: null, status: { text: '', tone: 'ok' }, detail: '' }],
});
},
stop() {},
health() {
return { status: 'ok', message: '' };
},
};
}
/* ── source: another module's timers (over loopback) ─────────────────── */
function createModuleSource({ moduleId, name, shellPort, log }) {
const base = `/api/modules/${encodeURIComponent(moduleId)}`;
let emit = () => {};
let stopped = false;
let label = String(name || moduleId);
let timers = [];
let updatedAt = 0;
let lastOkAt = 0;
let lastError = '';
let state = 'connecting'; // 'ok' | 'connecting' | 'error'
let via = ''; // 'stream' | 'poll' once we know which the provider offers
let stream = null; // the open http.ClientRequest of /timers/stream
let pollTimer = null;
let pollInflight = false;
let retryTimer = null;
let silenceTimer = null;
let backoff = BACKOFF_MIN_MS;
function message() {
if (state === 'ok') return `${timers.length} timer${timers.length === 1 ? '' : 's'}${via ? ` (${via})` : ''}`;
if (state === 'connecting') return lastError ? `reconnecting — ${lastError}` : 'connecting…';
return `unreachable — ${lastError}`;
}
function publish() {
if (stopped) return;
emit({ label, status: state, message: message(), timers, updatedAt });
}
function accept(raw) {
const snap = sanitizeSnapshot(raw);
if (!snap) {
fail('malformed /timers snapshot');
return;
}
if (snap.label) label = snap.label;
timers = snap.timers;
updatedAt = snap.updatedAt;
lastOkAt = Date.now();
lastError = '';
state = 'ok';
backoff = BACKOFF_MIN_MS;
publish();
}
/** A provider restarting mid-service drops its stream for a moment; its
timers stay up through the grace period and only then disappear. */
function fail(why) {
lastError = why;
if (Date.now() - lastOkAt > GRACE_MS) {
timers = [];
state = 'error';
} else {
state = 'connecting';
}
publish();
}
function later(fn, ms) {
const t = setTimeout(() => { if (!stopped) fn(); }, ms);
t.unref?.();
return t;
}
function backoffLater(fn) {
clearTimeout(retryTimer);
retryTimer = later(fn, backoff);
backoff = Math.min(BACKOFF_MAX_MS, backoff * 2);
}
function closeStream() {
clearTimeout(silenceTimer);
silenceTimer = null;
if (stream) {
const s = stream;
stream = null;
try { s.destroy(); } catch { /* already gone */ }
}
}
function connectStream() {
if (stopped || stream) return;
let done = false;
const drop = (why) => {
if (done || stopped) return;
done = true;
closeStream();
fail(why);
backoffLater(connectStream);
};
const req = http.get({ host: '127.0.0.1', port: shellPort, path: `${base}/timers/stream`, headers: { Accept: 'text/event-stream' } }, (res) => {
const type = String(res.headers['content-type'] || '');
if (res.statusCode !== 200 || !/text\/event-stream/i.test(type)) {
// No stream here (404: the provider only serves /timers; 502: it
// failed to start) — poll instead, and try the stream again at the
// next discovery pass.
done = true;
res.resume();
stream = null;
via = 'poll';
poll();
return;
}
via = 'stream';
clearTimeout(pollTimer);
pollTimer = null;
res.setEncoding('utf8');
let buf = '';
const armSilence = () => {
clearTimeout(silenceTimer);
silenceTimer = later(() => drop('stream went silent'), STREAM_SILENCE_MS);
};
armSilence();
res.on('data', (chunk) => {
armSilence();
buf += chunk.replace(/\r\n/g, '\n');
let at;
while ((at = buf.indexOf('\n\n')) >= 0) {
const block = buf.slice(0, at);
buf = buf.slice(at + 2);
let event = 'message';
const data = [];
for (const line of block.split('\n')) {
if (!line || line.startsWith(':')) continue;
const colon = line.indexOf(':');
const field = colon < 0 ? line : line.slice(0, colon);
let value = colon < 0 ? '' : line.slice(colon + 1);
if (value.startsWith(' ')) value = value.slice(1);
if (field === 'event') event = value;
else if (field === 'data') data.push(value);
}
if (!data.length || (event !== 'timers' && event !== 'message')) continue;
try {
accept(JSON.parse(data.join('\n')));
} catch { /* not for us */ }
}
if (buf.length > 1e6) buf = ''; // runaway guard: never an SSE frame this size
});
res.on('end', () => drop('stream ended'));
res.on('close', () => drop('stream closed'));
res.on('error', (err) => drop(errorText(err)));
});
stream = req;
req.on('error', (err) => drop(errorText(err)));
}
async function pollOnce() {
pollTimer = null;
if (stopped || pollInflight || via === 'stream') return;
pollInflight = true;
let ok = false;
try {
const res = await fetch(`http://127.0.0.1:${shellPort}${base}/timers`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (via === 'stream' || stopped) return; // the stream came up meanwhile
accept(body);
ok = true;
} catch (err) {
if (!stopped && via !== 'stream') fail(errorText(err));
} finally {
pollInflight = false;
}
if (stopped || via === 'stream') return;
if (ok) {
pollTimer = later(pollOnce, MODULE_POLL_MS);
} else {
pollTimer = later(pollOnce, backoff);
backoff = Math.min(BACKOFF_MAX_MS, backoff * 2);
}
}
function poll() {
if (stopped || pollTimer || pollInflight) return;
pollTimer = later(pollOnce, 0);
}
return {
id: moduleId,
kind: 'module',
get label() {
return label;
},
start(e) {
emit = e;
publish();
connectStream();
},
stop() {
stopped = true;
closeStream();
clearTimeout(retryTimer);
clearTimeout(pollTimer);
retryTimer = null;
pollTimer = null;
},
/** Called on every discovery pass: a provider that had no stream may have one now. */
retryStream() {
if (!stopped && !stream && via === 'poll') connectStream();
},
health() {
return { status: state, message: message() };
},
};
}
/**
* Finds timer providers — every active module whose manifest says
* "provides": ["timers"] — by reading the shell's own /api/modules over
* loopback, and keeps one module source per provider alive.
*/
function createModuleDiscovery({ shellPort, log, flags, onAdd, onRemove }) {
const sources = new Map(); // module id → source
let timer = null;
let stopped = false;
let listed = null; // providers named by the last successful read, [{ id, name }]
let listError = '';
function wanted(id) {
return id === PCO_ID ? flags.pco : true;
}
async function discover() {
timer = null;
if (stopped) return;
try {
if (!shellPort) throw new Error('shell port unknown (needs ProdDash 1.4.0)');
const res = await fetch(`http://127.0.0.1:${shellPort}/api/modules`, {
headers: { Accept: 'application/json' },
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json();
if (stopped) return;
const mods = Array.isArray(body?.modules) ? body.modules : [];
listed = mods
.filter((m) => m && typeof m.id === 'string' && m.id !== SELF_ID && Array.isArray(m.provides) && m.provides.includes('timers'))
.map((m) => ({ id: m.id, name: String(m.name || m.id) }));
listError = '';
const keep = new Set();
for (const m of listed) {
if (!wanted(m.id)) continue;
keep.add(m.id);
const have = sources.get(m.id);
if (have) {
have.retryStream();
} else {
const source = createModuleSource({ moduleId: m.id, name: m.name, shellPort, log });
sources.set(m.id, source);
log(`following timers offered by module "${m.id}"`);
onAdd(source);
}
}
// Disabled, uninstalled, or no longer offering timers: the shell is
// authoritative, so its timers go now rather than after the grace.
for (const [id, source] of sources) {
if (keep.has(id)) continue;
source.stop();
sources.delete(id);
log(`module "${id}" no longer offers timers`);
onRemove(id);
}
} catch (err) {
listError = errorText(err);
}
if (!stopped) {
timer = setTimeout(discover, listError ? DISCOVER_RETRY_MS : DISCOVER_MS);
timer.unref?.();
}
}
return {
start() {
timer = setTimeout(discover, DISCOVER_FIRST_MS);
timer.unref?.();
},
stop() {
stopped = true;
clearTimeout(timer);
timer = null;
for (const source of sources.values()) source.stop();
sources.clear();
},
/** Health lines for the admin page: one for PCO Plan, one per other provider. */
health() {
const parts = [];
let status = 'ok';
const bump = (s) => {
if (s === 'error') status = 'error';
else if (s === 'connecting' && status === 'ok') status = 'connecting';
};
if (!flags.pco) parts.push('PCO Plan: off');
if (listed === null) {
parts.push(listError ? `Modules: waiting for the shell (${listError})` : 'Modules: discovering…');
return { status: 'connecting', parts };
}
if (listError) {
parts.push(`Modules: cannot re-read /api/modules (${listError})`);
bump('error');
}
if (flags.pco) {
const pco = sources.get(PCO_ID);
if (pco) {
const h = pco.health();
parts.push(`PCO Plan: ${h.message}`);
bump(h.status);
} else {
parts.push('PCO Plan: not installed, or not offering timers yet');
}
}
// Every other provider found is listed under its own name.
for (const s of [...sources.values()].filter((x) => x.id !== PCO_ID)) {
const h = s.health();
parts.push(`${s.label}: ${h.message}`);
bump(h.status);
}
return { status, parts };
},
};
}
/* ── what each tile's gear offers ─────────────────────────────────────
module.json's instanceSchema is the full set — what an "All timers" tile
offers, and what the shell falls back to for a tile with no picker entry
(one added before the picker listed timers). A solo tile shows one card,
so its picker entry carries a schema of just what that card can use, and
the shell renders that instead (module guide, "Presenting multiple
tiles"): a Message Timer has no use for the clock's format switches.
Every field is drawn from the manifest so it is defined once; the client
falls back to its own defaults for keys a slimmer schema leaves out. */
const FULL_SCHEMA = manifest.instanceSchema || {};
const FULL_GROUPS = manifest.instanceGroups || {};
/**
* A gear schema of just `keys` (manifest order kept) with the groups they
* use. `loose` names keys shown outside their group; `groups` overrides a
* group's meta. A group toggle whose switch isn't among the keys is dropped.
*/
function gearOf(keys, { loose = [], groups = {} } = {}) {
const instanceSchema = {};
for (const key of Object.keys(FULL_SCHEMA)) {
if (!keys.includes(key)) continue;
const spec = { ...FULL_SCHEMA[key] };
if (loose.includes(key)) delete spec.group;
instanceSchema[key] = spec;
}
const instanceGroups = {};
for (const spec of Object.values(instanceSchema)) {
if (!spec.group || instanceGroups[spec.group]) continue;
const meta = { ...(FULL_GROUPS[spec.group] || {}), ...(groups[spec.group] || {}) };
if (meta.toggle && !(meta.toggle in instanceSchema)) delete meta.toggle;
instanceGroups[spec.group] = meta;
}
return { instanceSchema, instanceGroups };
}
const COLOURS = ['runningColor', 'warningColor', 'overrunColor', 'idleColor'];
/** What a timer card can show or hide of itself (which timers appear is the header menu's job). */
const TIMER_ELEMENTS = ['showNames', 'showStatus', 'showDetail', 'showSeconds', 'leadingZeros'];
const CLOCK_ELEMENTS = ['clock24h', 'clockSeconds', 'clockDate', 'dateShortDay', 'dateShortMonth'];
/** With one card there is little else to see: the colours start unfolded. */
const OPEN_COLOURS = { groups: { Colours: { collapsed: false } } };
const GEAR = {
/** Every card's elements, the headings, how the clock reads, the tones. */
all: { instanceSchema: FULL_SCHEMA, instanceGroups: FULL_GROUPS },
/** One ProPresenter or module timer: its elements, and the tone its digits take per state. */
timer: gearOf([...TIMER_ELEMENTS, ...COLOURS], OPEN_COLOURS),
/** The timecode: running, no signal (warning) and stopped (idle) — it never overruns, and frames are frames. */
ltc: gearOf(['showNames', 'showStatus', 'runningColor', 'warningColor', 'idleColor'], OPEN_COLOURS),
/** The time of day: plain digits, so only how they read. */
clock: gearOf(CLOCK_ELEMENTS),
/** A clock another module offers: read like the clock, muted while it isn't running. */
providerClock: gearOf(['showNames', 'showStatus', 'clock24h', 'clockSeconds', 'idleColor'], OPEN_COLOURS),
};
/** The gear a solo card of this timer gets — by what the card can show. */
function gearFor(source, timer) {
if (source.kind === 'ltc') return GEAR.ltc;
if (source.kind === 'clock') return GEAR.clock;
if (timer.kind === 'clock') return GEAR.providerClock;
return GEAR.timer;
}
/* ── the module ───────────────────────────────────────────────────────── */
/** Sort order of the sources in the tile: fixed ones first, then PCO Plan, then other modules by name. */
const ORDER = { propresenter: 0, ltc: 1, clock: 2, [PCO_ID]: 3 };
function emptyState(timerFont = 'default') {
return { serverNow: Date.now(), timerFont, sources: [] };
}
function emptyProvider() {
return { source: SELF_ID, label: 'Timers', updatedAt: Date.now(), timers: [] };
}
module.exports = {
init({ config, log, shell }) {
const shellPort = Number(shell && shell.port) || 0;
const timerFont = config.timerFont === 'monospace' ? 'monospace' : 'default';
const flags = {
pp: config.ppEnabled !== false,
ltc: Boolean(config.ltcEnabled),
pco: config.pcoEnabled !== false,
};
const tileStreams = new Set();
const providerStreams = new Set();
/** source id → { id, kind, label, status, message, timers, updatedAt } */
const views = new Map();
let latest = emptyState(timerFont);
let lastKey = '';
let stopped = false;
const orderedViews = () => [...views.values()].sort((a, b) => ((ORDER[a.id] ?? 10) - (ORDER[b.id] ?? 10)) || a.label.localeCompare(b.label));
/** The tile state: every source, its health, its timers as of updatedAt. */
function buildState() {
return {
serverNow: Date.now(),
timerFont,
sources: orderedViews().map((v) => ({ id: v.id, label: v.label, kind: v.kind, status: v.status, message: v.message, updatedAt: v.updatedAt, timers: v.timers })),
};
}
/**
* The provider snapshot (the module guide's contract): all enabled
* sources merged, ids prefixed with their source so they stay unique,
* every duration re-anchored to this snapshot's own instant.
*/
function buildProviderSnapshot() {
const now = Date.now();
const timers = [];
for (const v of orderedViews()) {
for (const t of v.timers) {
const out = { ...t, id: `${v.id}:${t.id}`, group: v.label };
if (v.id === 'clock') out.status = { text: new Date(now).toISOString(), tone: 'ok' };
// A timer measured at its own instant (asOf — a phase-locked
// ProPresenter value) re-anchors from there; the rest from the
// source snapshot. Consumers see plain contract values as of `now`.
const at = Number(out.asOf) || v.updatedAt;
if (t.state === 'running' && at > 0) {
const dt = Math.max(0, now - at);
if (out.elapsedMs !== null && out.elapsedMs !== undefined) out.elapsedMs += dt;
if (out.remainingMs !== null && out.remainingMs !== undefined) out.remainingMs -= dt;
if (out.ltc && Number.isFinite(out.ltc.ageMs)) out.ltc = { ...out.ltc, ageMs: out.ltc.ageMs + dt };
}
delete out.asOf;
timers.push(out);
}
}
return { source: SELF_ID, label: 'Timers', updatedAt: now, timers };
}
/** Recompute the merged state; broadcast only when something other than the clocks moved. */
function rebuild() {
if (stopped) return;
latest = buildState();
// updatedAt / serverNow move on every poll, and a startedAt derived
// from a whole-second value jitters with them; the tiles extrapolate
// from the values but don't need a frame for that alone.
let key;
try {
key = JSON.stringify(latest.sources.map((v) => [v.id, v.label, v.status, v.message, v.timers.map(({ startedAt, ...rest }) => rest)]));
} catch {
return;
}
if (key === lastKey) return;
lastKey = key;
if (tileStreams.size) writeAll(tileStreams, `event: state\ndata: ${JSON.stringify(latest)}\n\n`);
if (providerStreams.size) writeAll(providerStreams, `event: timers\ndata: ${JSON.stringify(buildProviderSnapshot())}\n\n`);
}
const emitFor = (source) => (view) => {
if (stopped) return;
views.set(source.id, {
id: source.id,
kind: source.kind,
label: String(view.label || source.label),
status: view.status === 'ok' || view.status === 'connecting' || view.status === 'error' ? view.status : 'ok',
message: String(view.message || ''),
timers: Array.isArray(view.timers) ? view.timers : [],
updatedAt: Number(view.updatedAt) || Date.now(),
});
rebuild();
};
// The fixed sources, each behind its admin switch. Add a platform here.
const fixed = [];
if (flags.pp) fixed.push(createProPresenterSource({ config, log }));
else log('ProPresenter timers are off (admin switch)');
if (flags.ltc) fixed.push(createLtcSource({ config, log, moduleDir: __dirname }));
fixed.push(createClockSource());
const discovery = createModuleDiscovery({
shellPort,
log,
flags,
onAdd(source) {
source.start(emitFor(source));
},
onRemove(id) {
views.delete(id);
rebuild();
},
});
for (const source of fixed) {
try {
source.start(emitFor(source));
} catch (err) {
log(`${source.id} failed to start: ${err.message}`);
}
}
discovery.start();
if (!flags.pco) log('PCO Plan timers are off (admin switch)');
const heartbeat = setInterval(() => {
writeAll(tileStreams, ': ping\n\n');
writeAll(providerStreams, ': ping\n\n');
}, HEARTBEAT_MS);
heartbeat.unref?.();
current = {
tileStreams,
providerStreams,
getState: () => ({ ...latest, serverNow: Date.now() }),
getProviderSnapshot: buildProviderSnapshot,
};
return {
stop() {
stopped = true;
clearInterval(heartbeat);
discovery.stop();
for (const source of fixed) {
try { source.stop(); } catch { /* already down */ }
}
for (const set of [tileStreams, providerStreams]) {
for (const res of set) {
try { res.end(); } catch { /* already gone */ }
}
set.clear();
}
if (current && current.tileStreams === tileStreams) current = null;
},
health() {
const parts = [];
let status = 'ok';
const bump = (s) => {
if (s === 'error') status = 'error';
else if (s === 'connecting' && status === 'ok') status = 'connecting';
};
for (const source of fixed) {
if (source.id === 'clock') continue;
const h = source.health();
parts.push(`${source.label}: ${h.message}`);
bump(h.status);
}
if (!flags.pp) parts.push('ProPresenter: off');
if (!flags.ltc) parts.push('LTC: off');
const d = discovery.health();
parts.push(...d.parts);
bump(d.status);
return { status, message: parts.join(' · ') };
},
};
},
/**
* The Add-tile picker's entries: "All timers" plus one solo card per known
* timer from any source (moduleApi.variant carries the entry id — 'all',
* or 'solo:<source>:<timer id>'; the older 'timer:<uuid>' / 'ltc' ids keep
* working). Each entry carries the gear settings that fit its tile (see
* GEAR above); descriptions are the picker's tooltips, one sentence each.
* Answered from the merged state, never from upstream — the picker must
* stay instant and a dead upstream must not stall it.
*/
tiles() {
const state = current ? current.getState() : null;
if (!state) return [];
const KIND_WORD = { countdown: 'countdown', elapsed: 'elapsed timer', clock: 'clock' };
const oneCard = { defaultSize: { w: 3, h: 2 }, minSize: { w: 2, h: 1 } };
const out = [{ id: 'all', name: 'All timers', description: 'Every timer and clock, grouped by source', ...GEAR.all }];
for (const src of state.sources) {
for (const t of src.timers) {
const description = src.kind === 'ltc' ? 'The incoming LTC timecode on its own'
: src.kind === 'clock' ? 'The time of day on its own'
: `This ${src.label} ${KIND_WORD[t.kind] || 'timer'} on its own`;
// Layouts from before 1.3.0 hold 'timer:<uuid>' / 'ltc' — the same tiles.
const aliases = src.id === 'propresenter' ? [`timer:${t.id}`] : src.kind === 'ltc' ? ['ltc'] : [];
out.push({ id: `solo:${src.id}:${t.id}`, name: t.label, description, ...oneCard, ...gearFor(src, t), ...(aliases.length ? { aliases } : {}) });
}
}
return out;
},
routes() {
return {
'GET /state': (req, res) => {
sendJson(res, 200, { state: current ? current.getState() : emptyState() });
},
'GET /stream': (req, res) => {
const state = current ? current.getState() : emptyState();
openSse(req, res, current ? current.tileStreams : null, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
},
// ProdDash itself as a timers provider — the module guide's contract.
'GET /timers': (req, res) => {
sendJson(res, 200, current ? current.getProviderSnapshot() : emptyProvider());
},
'GET /timers/stream': (req, res) => {
const snap = current ? current.getProviderSnapshot() : emptyProvider();
openSse(req, res, current ? current.providerStreams : null, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
},
// The admin device picker asks this machine what it can hear. The
// options shape matches the admin select's optionsRoute contract.
'GET /ltc/devices': (req, res) => {
const bin = path.join(__dirname, 'ltc-capture');
const child = spawn(bin, ['--list'], { stdio: ['ignore', 'ignore', 'pipe'], timeout: 4000 });
let err = '';
child.stderr.setEncoding('utf8');
child.stderr.on('data', (t) => { err += t; });
child.on('error', () => {
sendJson(res, 200, { options: [], error: 'LTC capture tool missing — see modules/propresenter-timers/ltc-capture.swift' });
});
child.on('exit', () => {
if (res.writableEnded) return;
const options = [];
for (const line of err.split('\n')) {
const m = line.match(/^ltc-capture:\s+(.*?)\s+(\d+) ch\s+(\d+) Hz\s+uid=/);
if (m) options.push({ value: m[1], label: `${m[1]} — ${m[2]} ch @ ${(Number(m[3]) / 1000)} kHz` });
}
sendJson(res, 200, { options });
});
},
};
},
};