357 lines
12 KiB
JavaScript
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) });
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
},
|
||
|
|
};
|