Files
ProdDash/modules/resi-cues/server.js
christianandClaude Opus 5 a8d1b8e159 Resi Cues becomes a real module, and Companion keeps the stage timer
Yesterday's version of this was a Python script under `modules/` with a
LaunchAgent to keep it alive — which the guide rules out in as many words:
everything a module does runs inside its server entry, no companion programs
or daemons an operator starts by hand. It also could not put anything on a
dashboard, which is half of what it was wanted for. Both halves are now in
the right place.

`modules/resi-cues/` is an ordinary module. Its tile is one number as large as
the tile allows — the countdown to the next Resi cue, the cue's name above it,
the playhead and the cue just passed underneath, and the whole cue list behind
a tile switch. It also declares `"provides": ["timers"]` and serves `/timers`
and `/timers/stream`, so the countdown and the playhead turn up among the
ProPresenter timers in the Timers module without either side knowing about
the other.

The countdown ticks in the browser. The server sends the playhead with the
instant it was true and whether playback is running, and the tile adds the
elapsed time since; the Timers module extrapolates from `startedAt` the same
way. So a snapshot only has to be sent when something structural changes — a
new cue, a pause, a seek — rather than at 1 Hz to every open dashboard.

`resi.js` holds the part worth reading twice. Resi republishes a decoder's
position only every few seconds, on an irregular cadence, and each reading is
already stale when it appears; a countdown built straight from it sits seconds
behind and lurches. So every reading is kept as media time minus wall time and
the largest recent value is trusted — the reading that arrived with the least
delay — which a slow round trip cannot drag backwards. A reading outside the
link's measured noise floor counts as a seek: drop the lock, snap, and re-read
the cue list at once instead of on its timer. Playback is only called paused
once the playhead has been still for longer than Resi's own cadence, because a
fixed timeout mistakes a slow update for a stop and freezes the countdown
mid-service.

Driving the ProPresenter timer on the stage screen is Companion's job now. It
is already a service on that machine, so nothing new has to be kept alive, and
this module no longer has to reach ProPresenter at all — it only has to be
right about Resi, which is the hard part.

`resi.test.js` drives the estimator on a fake clock against nine simulated
Resi profiles — cadences of one to twelve seconds, staleness to three,
irregular timing, variable round trips — covering lock-on, a fired cue, a seek
backwards, a pause, and a pause longer than the offset window. A punctual
fixture proves nothing here: against one, three separate breakages of the
estimator go unnoticed. `server.test.js` runs the real server entry against a
canned Resi account and checks what the two consumers actually read, including
the `/timers` shape field by field. Every mutation tried against either turns
them red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 12:08:55 -04:00

357 lines
12 KiB
JavaScript

/* Resi Cues — server entry.
*
* Follows one Resi decoder and works out how long until its next cue point, then
* offers that two ways:
*
* GET /state → snapshot for this module's tile
* GET /stream → SSE, `state` events on every change
* GET /timers → snapshot for the Timers module (guide: "Offering
* GET /timers/stream → SSE, `timers` events timers to the Timers module")
* GET /decoders → optionsRoute for the admin page's decoder picker
*
* Both the cue list and the playhead come from Resi's cloud rather than the
* decoder: the decoder reports its position upstream and central.resi.io hands it
* back. Nothing is installed at the decoder end, and nothing here talks to the
* decoder directly.
*
* Why the playhead is estimated rather than repeated is explained in resi.js.
*/
'use strict';
const { createResiClient, createPlayhead, surroundingCues, positionToMs } = require('./resi');
const HEARTBEAT_MS = 15000;
function json(res, code, body) {
const text = JSON.stringify(body);
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(text);
}
function sseHead(res) {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
});
res.write('retry: 3000\n\n');
}
function writeAll(set, chunk) {
for (const res of set) {
try {
res.write(chunk);
} catch {
set.delete(res);
}
}
}
/** What the tile and the Timers module see before anything has been configured. */
function emptyState(message) {
return {
status: 'error',
message: message || 'Not configured',
decoder: '',
event: '',
playing: false,
estMs: null,
estAt: Date.now(),
cadenceMs: 0,
next: null,
prev: null,
cues: [],
};
}
function emptyTimers(now) {
return { source: 'resi-cues', label: 'Resi', updatedAt: now, timers: [] };
}
let current = null; // the live handle, shared with routes()
module.exports = {
init({ config, log }) {
const username = String(config.username || '').trim();
const password = String(config.password || '');
const wanted = String(config.player || '').trim();
const pollMs = Math.max(250, Number(config.pollSeconds || 1) * 1000);
const cueRefreshMs = Math.max(1000, Number(config.cueRefreshSeconds || 5) * 1000);
const stateStreams = new Set();
const timerStreams = new Set();
let state = emptyState(username && password ? 'Connecting' : 'Enter the Resi login in admin');
let timer = null;
let heartbeat = null;
let stopped = false;
const resi = createResiClient({ username, password });
const head = createPlayhead({ trimMs: Number(config.positionTrimMs || 0) });
let venueUuid = '';
let hardwareId = '';
let decoderName = '';
let eventId = '';
let profileId = '';
let eventName = '';
let cues = [];
let lastCueLoad = 0;
let inFlight = false;
/* — what the two consumers see — */
function publishState(next) {
const before = JSON.stringify(state);
state = next;
if (JSON.stringify(state) === before) return;
writeAll(stateStreams, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
}
/**
* The Timers module extrapolates from `startedAt` and its own clock, so this
* only has to change when a timer changes *structurally* — a new cue, a pause,
* a seek. Broadcasting every poll would make every dashboard repaint at 1 Hz
* for nothing.
*/
function timersSignature(snap) {
return JSON.stringify(snap.timers.map(
(t) => [t.id, t.label, t.state, t.startedAt, t.targetMs, t.status && t.status.text]));
}
let lastTimerSig = '';
function publishTimers() {
const snap = timersSnapshot(Date.now());
const sig = timersSignature(snap);
if (sig === lastTimerSig) return;
lastTimerSig = sig;
writeAll(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
}
function timersSnapshot(now) {
const timers = [];
if (state.estMs !== null && state.estMs !== undefined) {
const est = state.estMs + (state.playing ? now - state.estAt : 0);
if (state.next) {
const remainingMs = Math.max(0, state.next.ms - est);
// The countdown "runs" from the cue before it, which is what gives the
// consumer something to colour progress against.
const from = state.prev ? state.prev.ms : 0;
timers.push({
id: 'next-cue',
label: state.next.name || 'Next cue',
kind: 'countdown',
state: state.playing ? 'running' : 'paused',
startedAt: now - Math.max(0, est - from),
elapsedMs: null,
remainingMs,
targetMs: Math.max(1000, state.next.ms - from),
status: { text: state.next.name || 'Next cue', tone: state.playing ? 'ok' : 'warn' },
detail: state.event || '',
});
}
timers.push({
id: 'playhead',
label: 'Playhead',
kind: 'elapsed',
state: state.playing ? 'running' : 'paused',
startedAt: now - Math.max(0, est),
elapsedMs: Math.max(0, est),
remainingMs: null,
targetMs: null,
status: { text: state.decoder || '', tone: state.playing ? 'ok' : 'muted' },
detail: state.prev ? `After ${state.prev.name}` : '',
});
}
return { source: 'resi-cues', label: 'Resi', updatedAt: now, timers };
}
/* — finding the decoder — */
async function locate() {
const venues = await resi.venues();
for (const v of venues) {
const players = await resi.players(v.uuid);
for (const p of players) {
if (wanted ? p.playerHardwareId === wanted : true) {
venueUuid = v.uuid;
hardwareId = p.playerHardwareId;
decoderName = `${v.userName || v.name || 'Venue'} — ${p.userName || hardwareId}`;
return true;
}
}
}
return false;
}
/* — the loop — */
async function tick() {
if (inFlight || stopped) return;
inFlight = true;
try {
if (!hardwareId && !(await locate())) {
publishState(emptyState(wanted ? 'That decoder is not on this account' : 'No decoders found'));
return;
}
const players = await resi.players(venueUuid);
const me = players.find((p) => p.playerHardwareId === hardwareId);
if (!me) {
publishState({ ...emptyState('Decoder not listed'), decoder: decoderName });
return;
}
head.note(positionToMs(me.position));
if (me.eventId && me.eventId !== eventId) {
eventId = me.eventId;
const ev = (await resi.event(eventId)) || {};
profileId = ev.eventProfileId || '';
eventName = ev.name || '';
cues = profileId ? await resi.cues(profileId, eventId) : [];
lastCueLoad = Date.now();
log(`event ${eventName || eventId} — ${cues.length} cue(s)`);
} else if (!me.eventId) {
eventId = '';
profileId = '';
eventName = '';
cues = [];
}
const seeked = head.takeSeek();
if (profileId && eventId && (seeked || Date.now() - lastCueLoad >= cueRefreshMs)) {
lastCueLoad = Date.now();
const fresh = await resi.cues(profileId, eventId);
if (JSON.stringify(fresh) !== JSON.stringify(cues)) {
log(`cue list changed — ${fresh.length} cue(s)`);
cues = fresh;
}
}
const { estMs, playing, cadenceMs } = head.read();
const { prev, next } = surroundingCues(cues, estMs === null ? -1 : estMs);
publishState({
status: 'ok',
message: '',
decoder: decoderName,
event: eventName,
playing,
estMs,
estAt: Date.now(),
cadenceMs,
next,
prev,
cues,
});
} catch (err) {
const message = (err && err.message) || String(err);
log(`poll failed: ${message}`);
publishState({ ...state, status: 'error', message });
} finally {
inFlight = false;
publishTimers();
}
}
if (username && password) {
timer = setInterval(() => { void tick(); }, pollMs);
void tick();
} else {
log('no Resi login configured — idle');
}
heartbeat = setInterval(() => {
writeAll(stateStreams, ': ping\n\n');
writeAll(timerStreams, ': ping\n\n');
}, HEARTBEAT_MS);
current = {
get state() { return state; },
timersSnapshot,
stateStreams,
timerStreams,
resi,
configured: Boolean(username && password),
stop() {
stopped = true;
if (timer) clearInterval(timer);
if (heartbeat) clearInterval(heartbeat);
for (const res of [...stateStreams, ...timerStreams]) {
try { res.end(); } catch { /* already gone */ }
}
stateStreams.clear();
timerStreams.clear();
if (current && current.stop === this.stop) current = null;
},
health() {
if (!username || !password) return { status: 'error', message: 'Enter the Resi login in admin' };
if (state.status === 'ok') {
return { status: 'ok', message: state.playing ? `Playing — ${state.decoder}` : `Paused — ${state.decoder}` };
}
if (state.status === 'error') return { status: 'error', message: state.message };
return { status: 'connecting', message: state.message };
},
};
return current;
},
routes({ config, log }) {
return {
'GET /state': (req, res) => {
json(res, 200, current ? current.state : emptyState('Module restarting'));
},
'GET /stream': (req, res) => {
sseHead(res);
const snap = current ? current.state : emptyState('Module restarting');
res.write(`event: state\ndata: ${JSON.stringify(snap)}\n\n`);
if (!current) return res.end();
current.stateStreams.add(res);
req.on('close', () => current && current.stateStreams.delete(res));
},
'GET /timers': (req, res) => {
const now = Date.now();
json(res, 200, current ? current.timersSnapshot(now) : emptyTimers(now));
},
'GET /timers/stream': (req, res) => {
sseHead(res);
const now = Date.now();
const snap = current ? current.timersSnapshot(now) : emptyTimers(now);
res.write(`event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
if (!current) return res.end();
current.timerStreams.add(res);
req.on('close', () => current && current.timerStreams.delete(res));
},
// The admin page's decoder picker. A live call, because it is how the decoder
// gets chosen in the first place — there is nothing cached to answer from.
'GET /decoders': async (req, res) => {
if (!config.username || !config.password) {
return json(res, 200, { options: [], note: 'Enter the Resi login first' });
}
try {
const client = createResiClient({
username: String(config.username), password: String(config.password),
});
const options = [];
for (const v of await client.venues()) {
for (const p of await client.players(v.uuid)) {
options.push({
value: p.playerHardwareId,
label: `${p.userName || p.playerHardwareId}`,
group: v.userName || v.name || 'Venue',
});
}
}
json(res, 200, { options });
} catch (err) {
log(`decoder list failed: ${(err && err.message) || err}`);
json(res, 502, { options: [], error: (err && err.message) || String(err) });
}
},
};
},
};