/* 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), // The label already is the cue's name, and it is what the card is titled // with; repeating it on the status line only crowded the card. status: { text: state.playing ? 'Next cue' : 'Held', 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) }); } }, }; }, };