From a8d1b8e159ef247c65ff1e2a1eafeee2ab89c234 Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 24 Sep 2026 12:08:55 -0400 Subject: [PATCH] Resi Cues becomes a real module, and Companion keeps the stage timer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 22 +- modules/resi-cues/client.js | 138 +++++ modules/resi-cues/module.json | 91 ++++ modules/resi-cues/resi.js | 263 +++++++++ modules/resi-cues/resi.test.js | 210 +++++++ modules/resi-cues/server.js | 356 ++++++++++++ modules/resi-cues/server.test.js | 201 +++++++ modules/resi-cues/style.css | 113 ++++ modules/resi-studio-cues/README.md | 100 ---- modules/resi-studio-cues/config.example.json | 11 - .../resi-studio-cues/install-agent.command | 66 --- .../resi-studio-cues/resi_to_propresenter.py | 512 ------------------ modules/resi-studio-cues/test_end_to_end.py | 145 ----- modules/resi-studio-cues/test_playhead.py | 224 -------- 14 files changed, 1388 insertions(+), 1064 deletions(-) create mode 100644 modules/resi-cues/client.js create mode 100644 modules/resi-cues/module.json create mode 100644 modules/resi-cues/resi.js create mode 100644 modules/resi-cues/resi.test.js create mode 100644 modules/resi-cues/server.js create mode 100644 modules/resi-cues/server.test.js create mode 100644 modules/resi-cues/style.css delete mode 100644 modules/resi-studio-cues/README.md delete mode 100644 modules/resi-studio-cues/config.example.json delete mode 100755 modules/resi-studio-cues/install-agent.command delete mode 100644 modules/resi-studio-cues/resi_to_propresenter.py delete mode 100644 modules/resi-studio-cues/test_end_to_end.py delete mode 100644 modules/resi-studio-cues/test_playhead.py diff --git a/README.md b/README.md index ebcc065..b340cda 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,22 @@ below. Nothing is installed without someone clicking. as `Label | Message` — add `| #channel` to bind one to a channel — and up to eight quick emojis). Tile settings: avatars, timestamps, names and channel notices. +- **Resi Cues** — how long until the next Resi cue point, for a campus + running video from another site. The tile is one number, as large as it + will go: the countdown to the next cue, its name above it, and the playhead + and the cue just passed underneath. It also offers that countdown and the + playhead to the **Timers** module, so they can sit among the ProPresenter + timers instead of in a tile of their own. + Both the cue list and the decoder's position come from Resi's cloud rather + than the decoder — the decoder reports upstream and central.resi.io hands it + back — so nothing is installed at the decoder end. Resi republishes a + position only every few seconds and each reading is already stale, so the + playhead is estimated between readings rather than repeated; a countdown + built straight from Resi's number sits seconds behind and moves in lurches. + Admin config: Resi login and password, which decoder to follow (the list is + read from the account), and a folded **Timing** group for the poll + intervals and a position trim. + Tile settings: cue name, previous cue, playhead, cue list. - **Band Lineup** — the backstage stage board: everyone on stage in their mic slot with photo, name and mic/pack label, on one shared board that every screen sees the same way — whoever edits it changes it everywhere, over SSE. @@ -400,12 +416,6 @@ board — a plan, its team members, birthdays and photos — run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of the [module guide](docs/MODULE-GUIDE.md). -One folder under `modules/` is not a tile: **`resi-studio-cues/`** is a standalone -helper that puts a countdown to the next Resi cue on the stage screen, by driving a -ProPresenter timer directly. It has no `module.json`, so the shell's scan skips it and -it never appears in the + picker. It is documented in its own folder rather than -above, because there is nothing here to install or configure. - ## Install as a desktop web app Open the page in a browser first, then: diff --git a/modules/resi-cues/client.js b/modules/resi-cues/client.js new file mode 100644 index 0000000..b21ed7a --- /dev/null +++ b/modules/resi-cues/client.js @@ -0,0 +1,138 @@ +/* Resi Cues — the tile. + * + * One number matters: how long until the next cue. It is drawn as large as the + * tile allows, with the cue's name above it, because on a wall the operator reads + * it from across the booth. + * + * The countdown ticks locally. The server sends the playhead with the instant it + * was true (`estMs` at `estAt`) and whether playback is running; this adds the + * elapsed time since. That is what keeps the digits moving every second while + * Resi itself only reports every few seconds, and it means a slow poll never + * shows as a stalled clock. + */ + +const TICK_MS = 250; // redraw faster than a second so the digit turns on time + +export default function create({ root, moduleApi }) { + let stream = null; + let ticker = null; + let state = null; + + const settings = () => moduleApi.instanceSettings; + + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + } + + function hms(ms) { + const total = Math.max(0, Math.round(ms / 1000)); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const pad = (n) => String(n).padStart(2, '0'); + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; + } + + /** Where the playhead is right now, extrapolated from the last server word. */ + function playheadMs() { + if (!state || state.estMs === null || state.estMs === undefined) return null; + return state.estMs + (state.playing ? Date.now() - state.estAt : 0); + } + + function render() { + root.textContent = ''; + const set = settings(); + + if (!state || state.status === 'error') { + const box = el('div', 'resi-empty'); + box.append(el('div', 'resi-empty-line', (state && state.message) || 'Not connected')); + root.append(box); + return; + } + + const here = playheadMs(); + const wrap = el('div', 'resi-wrap'); + + if (state.next) { + if (set.showCueName !== false) wrap.append(el('div', 'resi-label', 'NEXT')); + if (set.showCueName !== false) wrap.append(el('div', 'resi-cue', state.next.name || 'Next cue')); + const remaining = here === null ? null : state.next.ms - here; + const count = el('div', 'resi-count', remaining === null ? '—' : hms(remaining)); + if (remaining !== null && remaining <= 30000) count.classList.add('is-soon'); + if (!state.playing) count.classList.add('is-held'); + wrap.append(count); + } else { + wrap.append(el('div', 'resi-label', state.event ? 'NO CUES LEFT' : 'NOTHING PLAYING')); + wrap.append(el('div', 'resi-cue', state.event || state.decoder || '')); + } + + const foot = el('div', 'resi-foot'); + if (set.showPrevious !== false && state.prev) { + foot.append(el('span', 'resi-foot-item', + `after ${state.prev.name}${here === null ? '' : ` · ${hms(here - state.prev.ms)} ago`}`)); + } + if (set.showPosition !== false && here !== null) { + foot.append(el('span', 'resi-foot-item', `playhead ${hms(here)}`)); + } + if (!state.playing && state.estMs !== null) foot.append(el('span', 'resi-foot-item is-held', 'paused')); + if (foot.childElementCount) wrap.append(foot); + + if (set.showCueList && state.cues && state.cues.length) { + const list = el('ul', 'resi-list'); + for (const cue of state.cues) { + const row = el('li', 'resi-list-row'); + if (here !== null && cue.ms <= here) row.classList.add('is-past'); + if (state.next && cue.ms === state.next.ms) row.classList.add('is-next'); + row.append(el('span', 'resi-list-name', cue.name)); + row.append(el('span', 'resi-list-at', hms(cue.ms))); + list.append(row); + } + wrap.append(list); + } + + root.append(wrap); + } + + function applyStatus() { + if (!state) return moduleApi.setStatus('connecting', 'Waiting for the server'); + if (state.status !== 'ok') return moduleApi.setStatus('error', state.message || 'Not connected'); + if (!state.playing) return moduleApi.setStatus('connecting', `Playback stopped — ${state.decoder}`); + const cadence = state.cadenceMs ? ` · Resi updates every ${Math.round(state.cadenceMs / 1000)}s` : ''; + return moduleApi.setStatus('ok', `${state.decoder}${cadence}`); + } + + return { + start() { + moduleApi.setStatus('connecting', 'Connecting'); + stream = moduleApi.sse('/stream', { + state: (data) => { + try { + state = JSON.parse(data); + } catch { + return; + } + applyStatus(); + render(); + }, + error: () => moduleApi.setStatus('error', 'Lost the connection to ProdDash'), + }); + ticker = setInterval(render, TICK_MS); + render(); + }, + + stop() { + if (stream) stream.close(); + if (ticker) clearInterval(ticker); + stream = null; + ticker = null; + root.textContent = ''; + }, + + onConfigChange() { + render(); + }, + }; +} diff --git a/modules/resi-cues/module.json b/modules/resi-cues/module.json new file mode 100644 index 0000000..c97dab3 --- /dev/null +++ b/modules/resi-cues/module.json @@ -0,0 +1,91 @@ +{ + "id": "resi-cues", + "name": "Resi Cues", + "version": "1.0.0", + "proddash": ">=1.4.0", + "description": "Time until the next Resi cue point, on the board and as a timer", + "provides": ["timers"], + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { "w": 2, "h": 1 }, + "defaultSize": { "w": 4, "h": 3 }, + "configGroups": { + "Resi account": { + "help": "The Resi Studio login the board reads with. Cue points and the decoder's playhead both come from Resi's cloud, so nothing is installed at the decoder. Worth using a dedicated low-privilege Resi user rather than a personal one." + }, + "Decoder": { + "help": "Which decoder to follow. The list is read from the account once the login above is saved." + }, + "Timing": { + "collapsed": true, + "help": "Resi refreshes a decoder's position only every few seconds, on an irregular cadence, and each reading is already slightly stale. The board estimates between readings rather than repeating them, so these only change how often it asks — not how accurate it is." + } + }, + "configSchema": { + "username": { + "type": "string", + "label": "Resi login", + "group": "Resi account", + "default": "", + "help": "The email address used to sign in to studio.resi.io." + }, + "password": { + "type": "password", + "label": "Password", + "group": "Resi account", + "default": "" + }, + "player": { + "type": "select", + "label": "Decoder", + "group": "Decoder", + "default": "", + "optionsRoute": "/decoders", + "help": "Leave unset to follow the only decoder on the account." + }, + "pollSeconds": { + "type": "number", + "label": "Position poll (s)", + "group": "Timing", + "default": 1, + "help": "How often Resi is asked where the playhead is. A faster poll notices a fired cue sooner; it does not make the countdown smoother, which is handled by estimating between readings." + }, + "cueRefreshSeconds": { + "type": "number", + "label": "Cue list refresh (s)", + "group": "Timing", + "default": 5, + "help": "How often the cue list is re-read. It is also re-read immediately whenever the playhead jumps, so a cue fired at the host shows up without waiting for this." + }, + "positionTrimMs": { + "type": "number", + "label": "Position trim (ms)", + "group": "Timing", + "default": 0, + "help": "Leave at zero. Only needed if the whole chain reads consistently behind; a positive value pushes the estimate forward." + } + }, + "instanceSchema": { + "showCueName": { + "type": "switch", + "label": "Cue name", + "default": true + }, + "showPrevious": { + "type": "switch", + "label": "Previous cue", + "default": true + }, + "showPosition": { + "type": "switch", + "label": "Playhead", + "default": true + }, + "showCueList": { + "type": "switch", + "label": "Cue list", + "default": false + } + } +} diff --git a/modules/resi-cues/resi.js b/modules/resi-cues/resi.js new file mode 100644 index 0000000..54de680 --- /dev/null +++ b/modules/resi-cues/resi.js @@ -0,0 +1,263 @@ +/* Resi Cues — talking to Resi, and working out where the decoder actually is. + * + * Two things live here, both free of the shell so they can be tested on their own + * (`resi.test.js`): a thin client for the Resi Central API, and the playhead + * estimator that turns its coarse, late readings into a usable position. + * + * The API is the undocumented one behind studio.resi.io. It is not supported by + * Resi and can change without notice; that is the price of there being no public + * way to read cue points at all. + */ + +'use strict'; + +const RESI = 'https://central.resi.io'; + +/* ── time ──────────────────────────────────────────────────────────────── */ + +/** '01:02:03.500' → milliseconds, or null when it is not a position at all. */ +function positionToMs(position) { + if (typeof position !== 'string') return null; + const parts = position.split(':'); + if (parts.length !== 3) return null; + const hh = Number(parts[0]); + const mm = Number(parts[1]); + const ss = Number(parts[2]); + if (!Number.isFinite(hh) || !Number.isFinite(mm) || !Number.isFinite(ss)) return null; + return Math.round((hh * 3600 + mm * 60 + ss) * 1000); +} + +/** milliseconds → 'HH:MM:SS.mmm', the shape Resi's own cue positions use. */ +function msToPosition(total) { + const t = Math.max(0, Math.floor(total)); + const pad = (n, w) => String(n).padStart(w, '0'); + return `${pad(Math.floor(t / 3600000), 2)}:${pad(Math.floor((t % 3600000) / 60000), 2)}` + + `:${pad(Math.floor((t % 60000) / 1000), 2)}.${pad(t % 1000, 3)}`; +} + +/* ── the playhead estimator ────────────────────────────────────────────── */ + +const WINDOW_MS = 120000; // how long a reading stays useful +const JUMP_FLOOR_MS = 5000; // the smallest move ever treated as a seek + +/** + * Resi republishes a decoder's `position` only every few seconds, on an irregular + * cadence, and each reading is already a little stale when it appears. Shown raw, + * a countdown built on it sits seconds behind and moves in lurches. + * + * So every reading is kept as *media time minus wall time*. While playback runs at + * 1x that difference is constant, and the LARGEST recent value is the reading that + * reached us with the least delay — the one to trust. The estimate is then simply + * "now plus that offset", which advances smoothly by itself and cannot be dragged + * backwards by a slow round trip. + * + * `now` is injected so the tests can drive a fake clock. + */ +function createPlayhead(options) { + const opts = options || {}; + const trimMs = Number(opts.trimMs) || 0; + const now = typeof opts.now === 'function' ? opts.now : () => Date.now(); + + let offsets = []; // { at, off } — off is mediaMs - wall time it was read + let spreads = []; // how far apart honest readings sit: this link's noise + let gaps = []; // how long between the playhead actually moving + let base; // the trusted offset + let lastMedia; + let lastNewAt; + let lastEst; + let seeked = false; + + function note(mediaMs) { + if (!Number.isFinite(mediaMs)) return; + const t = now(); + if (mediaMs !== lastMedia) { + const off = mediaMs - t; + // Anything inside the measured noise floor is noise; only a move clearly + // beyond it is a real seek. Measured rather than assumed, and remembered + // across a reset so the first reading after one is not judged blind. + const noise = spreads.length ? Math.max.apply(null, spreads) : 0; + const threshold = Math.max(JUMP_FLOOR_MS, noise * 1.5 + 4000); + if (base !== undefined) { + const delta = off - base; + if (Math.abs(delta) > threshold) { + offsets = []; // the window describes where we used to be + seeked = true; + // Only abandon the no-going-backwards guard for a move big enough that + // snapping is obviously right; a marginal call must not make the + // displayed playhead lurch backwards. + if (Math.abs(delta) > 10000) lastEst = undefined; + } else if (delta < 0) { + spreads = spreads.concat(-delta).slice(-20); + } + } + if (lastNewAt !== undefined) { + const gap = t - lastNewAt; + // The gap either side of a pause is not Resi's cadence — recording it + // would inflate the estimate and make the next pause slow to notice. + const worst = gaps.length ? Math.max.apply(null, gaps) : 0; + if (gap > 0 && gap < 60000 && (!worst || gap <= worst * 3)) { + gaps = gaps.concat(gap).slice(-8); + } + } + offsets = offsets.concat({ at: t, off }); + lastMedia = mediaMs; + lastNewAt = t; + } + const kept = offsets.filter((o) => t - o.at <= WINDOW_MS); + // Always keep the newest reading even once it ages out. If the window empties + // during a long pause there is nothing left to compare against, and the resume + // reads as an ordinary reading instead of the discontinuity it is. + offsets = kept.length ? kept : offsets.slice(-1); + base = offsets.length ? offsets.reduce((m, o) => (o.off > m ? o.off : m), -Infinity) : undefined; + } + + function read() { + if (lastNewAt === undefined) return { estMs: null, playing: false, cadenceMs: 0 }; + const t = now(); + const cadenceMs = gaps.length ? Math.max.apply(null, gaps) : 0; + // Only call it paused once the playhead has been still for clearly longer than + // Resi's own cadence. A fixed timeout mistakes a slow update for a stop, and a + // countdown that freezes mid-service is the worse failure of the two. + const pauseAfter = Math.max(15000, Math.min(45000, cadenceMs * 2.5 + 6000)); + const playing = base !== undefined && t - lastNewAt < pauseAfter; + if (!playing) { + return { estMs: lastEst !== undefined ? lastEst : lastMedia, playing: false, cadenceMs }; + } + let est = t + base + trimMs; + if (lastEst !== undefined && est < lastEst) est = lastEst; // the base is a max + lastEst = est; + return { estMs: est, playing: true, cadenceMs }; + } + + /** True once per detected discontinuity — the caller re-reads the cue list. */ + function takeSeek() { + const was = seeked; + seeked = false; + return was; + } + + return { note, read, takeSeek }; +} + +/* ── the Resi Central API ──────────────────────────────────────────────── */ + +/** + * Login sets a `livingas1` cookie which doubles as the bearer token, sent as + * `Authorization: X-Bearer `. Everything else is a plain GET. + */ +function createResiClient(options) { + const opts = options || {}; + const doFetch = opts.fetch || globalThis.fetch; + let cookieHeader = ''; + let token = ''; + let loggedInAt = 0; + let customerId = ''; + let cacheBust = true; + + function headers() { + const h = { Accept: 'application/json' }; + if (cookieHeader) h.Cookie = cookieHeader; + if (token) h.Authorization = `X-Bearer ${token}`; + return h; + } + + async function login() { + const res = await doFetch(`${RESI}/api/v3/login?newToken=true`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ userName: opts.username, password: opts.password }), + }); + if (!res.ok) throw new Error(`Resi login failed (${res.status})`); + const setCookies = (res.headers.getSetCookie && res.headers.getSetCookie()) + || (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []); + const jar = {}; + for (const raw of setCookies) { + const first = String(raw).split(';')[0]; + const eq = first.indexOf('='); + if (eq > 0) jar[first.slice(0, eq)] = first.slice(eq + 1); + } + token = jar.livingas1 || ''; + if (!token) throw new Error('Resi login returned no session cookie'); + cookieHeader = Object.keys(jar).map((k) => `${k}=${jar[k]}`).join('; '); + loggedInAt = Date.now(); + const me = await get('/api_v2.svc/users/me'); + customerId = (me && me.customerId) || ''; + if (!customerId) throw new Error('This Resi account has no customerId'); + return me; + } + + async function ensureSession() { + if (!token || Date.now() - loggedInAt > 50 * 60 * 1000) await login(); + } + + async function get(path) { + const res = await doFetch(RESI + path, { method: 'GET', headers: headers() }); + if (res.status === 401 || res.status === 403) { + await login(); + const retry = await doFetch(RESI + path, { method: 'GET', headers: headers() }); + if (!retry.ok) throw new Error(`Resi ${path} failed (${retry.status})`); + return retry.json(); + } + if (!res.ok) throw new Error(`Resi ${path} failed (${res.status})`); + return res.json(); + } + + async function venues() { + await ensureSession(); + return (await get(`/api/v3/customers/${customerId}/venues`)) || []; + } + + /** A stale cached reading looks exactly like a laggy playhead, so ask for a fresh + * one — and if the service objects to the parameter, stop sending it. */ + async function players(venueUuid) { + await ensureSession(); + const path = `/api_v2.svc/users/${venueUuid}/players`; + if (cacheBust) { + try { + return (await get(`${path}?_=${Date.now()}`)) || []; + } catch (err) { + if (!/\((4\d\d)\)/.test(String(err.message))) throw err; + cacheBust = false; + } + } + return (await get(path)) || []; + } + + async function event(eventId) { + await ensureSession(); + return get(`/api/v3/customers/${customerId}/events/${eventId}`); + } + + async function cues(profileId, eventId) { + await ensureSession(); + const raw = (await get( + `/api_v2.svc/streamprofiles/${profileId}/events/${eventId}/cues?canISetCues=1`)) || []; + return raw + .map((c) => ({ name: String(c.name || ''), ms: positionToMs(c.position) })) + .filter((c) => c.ms !== null) + .sort((a, b) => a.ms - b.ms); + } + + return { + login, + ensureSession, + venues, + players, + event, + cues, + get customerId() { return customerId; }, + }; +} + +/** The cue after `estMs`, and the one before it. */ +function surroundingCues(cues, estMs) { + let prev = null; + let next = null; + for (const c of cues || []) { + if (c.ms > estMs) { next = c; break; } + prev = c; + } + return { prev, next }; +} + +module.exports = { positionToMs, msToPosition, createPlayhead, createResiClient, surroundingCues, RESI }; diff --git a/modules/resi-cues/resi.test.js b/modules/resi-cues/resi.test.js new file mode 100644 index 0000000..dd11352 --- /dev/null +++ b/modules/resi-cues/resi.test.js @@ -0,0 +1,210 @@ +'use strict'; + +/** + * Unit tests for the Resi playhead estimator. + * + * node modules/resi-cues/resi.test.js + * node modules/resi-cues/resi.test.js 7000 1000 4000 2000 # one profile + * + * Plain Node, no dependencies; exits non-zero on the first failure. + * + * The estimator's whole job is to be right about a feed that is coarse, late and + * irregular, so the fixture is a simulated Resi rather than a list of values: a + * decoder playing at 1x, and an API that republishes its position every so often, + * already stale, over a round trip that varies. Nine profiles span what the real + * one has been seen to do. Feeding it a kind, punctual API proves nothing — + * against one, three separate breakages of this code go unnoticed. + */ + +const assert = require('assert'); +const { createPlayhead, surroundingCues, positionToMs, msToPosition } = require('./resi'); + +let passed = 0; +function test(name, fn) { + fn(); + passed++; + console.log(` ok ${name}`); +} + +/* ── the simulated world ───────────────────────────────────────────────── */ + +function makeWorld({ cadence, lag, jitter, net }) { + let seed = 12345; + const rnd = () => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed / 0x7fffffff; + }; + const w = { + t: 1700000000000, + media: 8 * 60000 + 48000, + playing: true, + published: 0, + lastPublish: 0, + nextCadence: cadence, + }; + w.published = w.media - lag; + w.lastPublish = w.t; + w.advance = (ms) => { + for (let i = 0; i < ms; i += 100) { + w.t += 100; + if (w.playing) w.media += 100; + if (w.t - w.lastPublish >= w.nextCadence) { + w.lastPublish = w.t; + w.published = w.media - lag; + w.nextCadence = Math.max(500, cadence + Math.round((rnd() - 0.5) * 2 * jitter)); + } + } + }; + // A poll reads what Resi had when the request left, and the round trip costs time. + w.poll = () => { + const body = w.published; + if (net) w.advance(100 + Math.round(rnd() * net)); + return body; + }; + return w; +} + +const CUES = [ + { name: 'Welcome', ms: 5 * 60000 }, + { name: 'Campus Host', ms: 12 * 60000 + 30000 }, + { name: 'Message', ms: 41 * 60000 }, + { name: 'Closing', ms: 45 * 60000 }, +]; + +function runProfile(cadence, lag, jitter, net) { + const w = makeWorld({ cadence, lag, jitter, net }); + const head = createPlayhead({ now: () => w.t }); + const tol = lag + 1200 + jitter + net * 0.35; + const label = `cadence ${cadence} lag ${lag} jitter ${jitter} net ${net}`; + + const second = () => { w.advance(1000); head.note(w.poll()); return head.read(); }; + const seconds = (n) => { let r; for (let i = 0; i < n; i++) r = second(); return r; }; + + test(`${label}: locks on, and beats the raw reading it is built from`, () => { + seconds(40); + let worstEst = 0; + let worstRaw = 0; + for (let i = 0; i < 30; i++) { + const { estMs } = second(); + worstEst = Math.max(worstEst, w.media - estMs); + worstRaw = Math.max(worstRaw, w.media - w.published); + } + assert.ok(worstEst <= tol, `estimate drifted ${Math.round(worstEst)}ms (allowed ${Math.round(tol)})`); + if (cadence >= 2000) { + assert.ok(worstRaw >= cadence * 0.4, + `fixture is too kind: the raw value only went ${Math.round(worstRaw)}ms stale`); + } + assert.ok(head.read().playing, 'should read as playing'); + }); + + test(`${label}: ticks every second and never runs backwards`, () => { + let prev = head.read().estMs; + let stalls = 0; + let backwards = 0; + for (let i = 0; i < 200; i++) { + const { estMs } = second(); + if (estMs === prev) stalls++; + if (estMs < prev) backwards++; + prev = estMs; + } + assert.strictEqual(stalls, 0, 'the playhead stalled'); + assert.strictEqual(backwards, 0, 'the playhead went backwards'); + }); + + test(`${label}: follows a fired cue, and rolls over the one it passes`, () => { + w.media = 40 * 60000 + 50000; // decoder jumps; Resi says nothing yet + let reported = false; + for (let i = 0; i < 60 && !reported; i++) { + second(); + reported = Math.abs((head.read().estMs || 0) - w.media) < 8000; + } + assert.ok(reported, 'the jump was never picked up'); + assert.ok(head.takeSeek(), 'the jump was not flagged as a seek'); + const { estMs } = head.read(); + assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2, + `resynced ${Math.round(w.media - estMs)}ms off`); + assert.ok(w.media - estMs >= -400, 'overshot the new playhead'); + assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Message'); + const after = seconds(15); + assert.strictEqual(surroundingCues(CUES, after.estMs).next.name, 'Closing'); + assert.strictEqual(surroundingCues(CUES, after.estMs).prev.name, 'Message'); + }); + + test(`${label}: follows a seek backwards`, () => { + w.media = 6 * 60000; + for (let i = 0; i < 60 && Math.abs((head.read().estMs || 0) - w.media) > 8000; i++) second(); + const { estMs } = head.read(); + assert.ok(Math.abs(estMs - w.media) <= tol + cadence / 2, 'did not follow the seek back'); + assert.strictEqual(surroundingCues(CUES, estMs).next.name, 'Campus Host'); + }); + + test(`${label}: freezes on a real pause and re-locks on resume`, () => { + w.playing = false; + const stoppedAt = w.media; + const wait = Math.ceil((Math.max(15000, Math.min(45000, (cadence + jitter) * 2.5 + 6000)) + + cadence + jitter + 6000) / 1000); + let r = seconds(wait); + assert.strictEqual(r.playing, false, 'did not notice the pause'); + const held = r.estMs; + r = seconds(20); + assert.strictEqual(r.estMs, held, 'kept counting while paused'); + + w.playing = true; + r = seconds(25); + assert.strictEqual(r.playing, true, 'did not notice the resume'); + assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place'); + assert.ok(held - stoppedAt <= 45000 + cadence + jitter + 2 * net + 3000, + 'the frozen position crept instead of stopping'); + }); + + test(`${label}: survives a pause longer than the offset window`, () => { + w.playing = false; + let r = seconds(150); + assert.strictEqual(r.playing, false, 'should still read as paused'); + assert.ok(r.estMs !== null && r.estMs !== undefined, 'lost the position entirely'); + w.playing = true; + head.takeSeek(); + r = seconds(30); + assert.strictEqual(r.playing, true, 'did not recover'); + assert.ok(Math.abs(r.estMs - w.media) <= tol + cadence, 're-locked in the wrong place'); + assert.ok(head.takeSeek(), 'a resume after a long pause must flag as a seek'); + }); +} + +/* ── the plain bits ────────────────────────────────────────────────────── */ + +test('positionToMs reads Resi positions, and rejects anything else', () => { + assert.strictEqual(positionToMs('00:00:00.000'), 0); + assert.strictEqual(positionToMs('01:02:03.500'), 3723500); + assert.strictEqual(positionToMs('00:00:01'), 1000); + assert.strictEqual(positionToMs(''), null); + assert.strictEqual(positionToMs('garbage'), null); + assert.strictEqual(positionToMs(null), null); + assert.strictEqual(positionToMs(undefined), null); +}); + +test('msToPosition round-trips', () => { + for (const ms of [0, 1500, 3723500, 36000000]) { + assert.strictEqual(positionToMs(msToPosition(ms)), ms); + } +}); + +test('surroundingCues puts a cue you are exactly on behind you', () => { + assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).next.name, 'Message'); + assert.strictEqual(surroundingCues(CUES, 12 * 60000 + 30000).prev.name, 'Campus Host'); + assert.strictEqual(surroundingCues(CUES, 0).prev, null); + assert.strictEqual(surroundingCues(CUES, 60 * 60000).next, null); + assert.strictEqual(surroundingCues([], 1000).next, null); +}); + +/* ── the matrix ────────────────────────────────────────────────────────── */ + +const argv = process.argv.slice(2).map(Number); +const PROFILES = argv.length >= 4 ? [argv.slice(0, 4)] : [ + [5000, 500, 0, 0], [5000, 500, 0, 2500], [2000, 0, 0, 800], + [10000, 3000, 0, 2500], [7000, 1000, 4000, 2000], [3000, 500, 2000, 1000], + [12000, 2000, 6000, 3000], [1000, 0, 0, 0], [8000, 1500, 3000, 4000], +]; +for (const [cadence, lag, jitter, net] of PROFILES) runProfile(cadence, lag, jitter, net); + +console.log(`\n${passed} tests passed`); diff --git a/modules/resi-cues/server.js b/modules/resi-cues/server.js new file mode 100644 index 0000000..0fad3ef --- /dev/null +++ b/modules/resi-cues/server.js @@ -0,0 +1,356 @@ +/* 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) }); + } + }, + }; + }, +}; diff --git a/modules/resi-cues/server.test.js b/modules/resi-cues/server.test.js new file mode 100644 index 0000000..40a2100 --- /dev/null +++ b/modules/resi-cues/server.test.js @@ -0,0 +1,201 @@ +'use strict'; + +/** + * Server-entry tests for Resi Cues. + * + * node modules/resi-cues/server.test.js + * + * Plain Node, no dependencies; exits non-zero on the first failure. Resi is + * replaced with a canned account — a venue, a decoder on an event, four cue + * points — so this covers the parts `resi.test.js` cannot: signing in, finding + * the decoder, following the event, and the two snapshots other things read + * (`/state` for the tile, `/timers` for the Timers module). + * + * Nothing here reaches the network, and the poll interval is captured rather + * than run, so the test drives the loop a tick at a time. + */ + +const assert = require('assert'); + +let passed = 0; +function test(name, fn) { + fn(); + passed++; + console.log(` ok ${name}`); +} + +/* ── a canned Resi account ─────────────────────────────────────────────── */ + +const CUES = [ + { name: 'Welcome', position: '00:05:00.000' }, + { name: 'Campus Host', position: '00:12:30.000' }, + { name: 'Message', position: '00:41:00.000' }, +]; + +const world = { position: '00:08:48.000', eventId: 'EV1', logins: 0, playerCalls: 0 }; + +function reply(body, extra) { + return Object.assign({ + ok: true, + status: 200, + headers: { get: () => null, getSetCookie: () => [] }, + json: async () => body, + }, extra || {}); +} + +globalThis.fetch = async (url) => { + const path = String(url).replace('https://central.resi.io', '').split('?')[0]; + if (path === '/api/v3/login') { + world.logins++; + return reply({}, { headers: { get: () => null, getSetCookie: () => ['livingas1=TOKEN; Path=/'] } }); + } + if (path === '/api_v2.svc/users/me') return reply({ customerId: 'CUST', userName: 'tester' }); + if (path === '/api/v3/customers/CUST/venues') return reply([{ uuid: 'V1', userName: 'Main' }]); + if (path === '/api_v2.svc/users/V1/players') { + world.playerCalls++; + return reply([{ playerHardwareId: 'DEC1', userName: 'Lobby', position: world.position, eventId: world.eventId }]); + } + if (path === '/api/v3/customers/CUST/events/EV1') { + return reply({ name: 'Sunday 9am', eventProfileId: 'P1' }); + } + if (path === '/api_v2.svc/streamprofiles/P1/events/EV1/cues') return reply(CUES); + return reply({}, { ok: false, status: 404 }); +}; + +/* ── the shell, as much of it as the module touches ────────────────────── */ + +const realSetInterval = globalThis.setInterval; +const realClearInterval = globalThis.clearInterval; +let tickFn = null; +globalThis.setInterval = (fn, ms) => { + if (!tickFn) tickFn = fn; // the poll; the heartbeat is the second one + return { fn, ms }; +}; +globalThis.clearInterval = () => {}; + +const server = require('./server'); +const logged = []; +const handle = server.init({ + config: { username: 'a@b.c', password: 'secret', player: 'DEC1', pollSeconds: 1, cueRefreshSeconds: 5 }, + log: (m) => logged.push(m), +}); + +/** Let the poll's promise chain finish; nothing here waits on real time. */ +async function settle() { + for (let i = 0; i < 60; i++) await Promise.resolve(); +} + +function collect(route, req) { + const res = { + code: 0, headers: null, body: '', + writeHead(code, headers) { this.code = code; this.headers = headers; }, + write(chunk) { this.body += chunk; }, + end(chunk) { if (chunk) this.body += chunk; }, + }; + route(req || { on() {} }, res); + return res; +} + +(async () => { + await settle(); + + const routes = server.routes({ config: {}, log: (m) => logged.push(m) }); + + test('signs in once and finds the configured decoder', () => { + assert.strictEqual(world.logins, 1, 'should sign in exactly once'); + const state = handle.state; + assert.strictEqual(state.status, 'ok', state.message); + assert.strictEqual(state.decoder, 'Main — Lobby'); + assert.strictEqual(state.event, 'Sunday 9am'); + assert.strictEqual(state.cues.length, 3); + }); + + test('works out the cue either side of the playhead', () => { + const state = handle.state; + assert.strictEqual(state.next.name, 'Campus Host'); + assert.strictEqual(state.prev.name, 'Welcome'); + assert.ok(Math.abs(state.estMs - 528000) < 2000, `playhead was ${state.estMs}`); + }); + + test('/state answers the same snapshot as the stream', () => { + const res = collect(routes['GET /state']); + assert.strictEqual(res.code, 200); + assert.deepStrictEqual(JSON.parse(res.body), handle.state); + }); + + test('/stream opens with an event so a new tile paints at once', () => { + const res = collect(routes['GET /stream']); + assert.strictEqual(res.code, 200); + assert.strictEqual(res.headers['Content-Type'], 'text/event-stream; charset=utf-8'); + assert.ok(res.body.includes('retry: 3000'), 'should set a retry'); + assert.ok(res.body.includes('event: state'), 'should send an initial state event'); + }); + + test('/timers offers the countdown and the playhead, shaped as the guide says', () => { + const snap = JSON.parse(collect(routes['GET /timers']).body); + assert.strictEqual(snap.source, 'resi-cues'); + assert.strictEqual(snap.label, 'Resi'); + assert.ok(snap.updatedAt > 0); + const [next, playhead] = snap.timers; + + assert.strictEqual(next.id, 'next-cue'); + assert.strictEqual(next.kind, 'countdown'); + assert.strictEqual(next.state, 'running'); + assert.strictEqual(next.label, 'Campus Host'); + assert.ok(Math.abs(next.remainingMs - 222000) < 2000, `remaining was ${next.remainingMs}`); + // the countdown runs from the cue before it, so a consumer can colour progress + assert.strictEqual(next.targetMs, 450000); + assert.strictEqual(next.status.text, 'Campus Host'); + assert.ok(next.startedAt <= snap.updatedAt, 'startedAt must be in the past'); + + assert.strictEqual(playhead.id, 'playhead'); + assert.strictEqual(playhead.kind, 'elapsed'); + assert.strictEqual(playhead.remainingMs, null); + assert.ok(Math.abs(playhead.elapsedMs - 528000) < 2000); + }); + + test('a cue fired at the host moves the countdown on the next poll', async () => { + world.position = '00:12:40.000'; // jumped past Campus Host + tickFn(); + await settle(); + const state = handle.state; + assert.strictEqual(state.next.name, 'Message'); + assert.strictEqual(state.prev.name, 'Campus Host'); + const snap = JSON.parse(collect(routes['GET /timers']).body); + assert.strictEqual(snap.timers[0].label, 'Message'); + }); + + test('a decoder with no event reports nothing playing rather than stale cues', async () => { + world.eventId = ''; + tickFn(); + await settle(); + assert.strictEqual(handle.state.next, null); + assert.deepStrictEqual(handle.state.cues, []); + assert.strictEqual(handle.state.event, ''); + }); + + test('health speaks about the upstream, not about itself', () => { + const h = handle.health(); + assert.ok(['ok', 'connecting', 'error'].includes(h.status), h.status); + assert.ok(h.message.length > 0, 'health should say something useful'); + }); + + test('an unconfigured module idles instead of hammering Resi', () => { + const before = world.logins; + const idle = server.init({ config: {}, log: () => {} }); + assert.strictEqual(world.logins, before, 'must not sign in with no credentials'); + assert.strictEqual(idle.health().status, 'error'); + assert.ok(/login/i.test(idle.health().message), idle.health().message); + idle.stop(); + }); + + test('stop() leaves nothing running', () => { + handle.stop(); + const res = collect(routes['GET /state']); + assert.strictEqual(JSON.parse(res.body).status, 'error'); + }); + + globalThis.setInterval = realSetInterval; + globalThis.clearInterval = realClearInterval; + console.log(`\n${passed} tests passed`); +})(); diff --git a/modules/resi-cues/style.css b/modules/resi-cues/style.css new file mode 100644 index 0000000..482774b --- /dev/null +++ b/modules/resi-cues/style.css @@ -0,0 +1,113 @@ +/* Resi Cues — scoped under its tile via [data-module="resi-cues"], styled with + the shell's CSS variables only. + + The countdown is sized in cqmin so it fills whatever the tile is given: the + same module is a small strip on a busy dashboard and a wall panel on its own. */ + +[data-module="resi-cues"] { + container-type: size; +} + +[data-module="resi-cues"] .resi-wrap { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.35em; + padding: 2%; + text-align: center; + overflow: hidden; +} + +[data-module="resi-cues"] .resi-label { + color: var(--muted); + font-size: clamp(0.6rem, 5cqmin, 1rem); + font-weight: 700; + letter-spacing: 0.14em; + line-height: 1; +} + +[data-module="resi-cues"] .resi-cue { + color: var(--text); + font-size: clamp(0.85rem, 11cqmin, 2.6rem); + font-weight: 650; + line-height: 1.05; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-module="resi-cues"] .resi-count { + color: var(--accent); + font-size: clamp(1.4rem, 30cqmin, 9rem); + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: 0.01em; + line-height: 1; +} + +[data-module="resi-cues"] .resi-count.is-soon { color: var(--warn); } + +/* Held rather than counting: the digits are still true, they just are not moving. */ +[data-module="resi-cues"] .resi-count.is-held, +[data-module="resi-cues"] .resi-foot-item.is-held { + color: var(--muted); +} + +[data-module="resi-cues"] .resi-foot { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.2em 0.9em; + color: var(--muted); + font-size: clamp(0.6rem, 5cqmin, 0.95rem); + font-variant-numeric: tabular-nums; +} + +[data-module="resi-cues"] .resi-list { + list-style: none; + margin: 0.4em 0 0; + padding: 0.4em 0 0; + width: 100%; + max-width: 28em; + border-top: 1px solid var(--border); + overflow-y: auto; + font-size: clamp(0.6rem, 4.5cqmin, 0.95rem); +} + +[data-module="resi-cues"] .resi-list-row { + display: flex; + justify-content: space-between; + gap: 1em; + padding: 0.12em 0.2em; + color: var(--text); +} + +[data-module="resi-cues"] .resi-list-row.is-past { color: var(--muted); } + +[data-module="resi-cues"] .resi-list-row.is-next { + color: var(--accent); + font-weight: 650; +} + +[data-module="resi-cues"] .resi-list-at { font-variant-numeric: tabular-nums; } + +[data-module="resi-cues"] .resi-list-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-module="resi-cues"] .resi-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 6%; + color: var(--muted); + text-align: center; +} diff --git a/modules/resi-studio-cues/README.md b/modules/resi-studio-cues/README.md deleted file mode 100644 index 518970a..0000000 --- a/modules/resi-studio-cues/README.md +++ /dev/null @@ -1,100 +0,0 @@ -# Resi Studio Cues - -Puts a live "time until the next Resi cue" countdown on the stage screen. - -**This folder is not a ProdDash tile.** It has no `module.json`, so the shell's module -scan skips it. It lives here because this is where the production tooling lives, not -because ProdDash loads it. That is also why it carries its own README, unlike the real -modules, which are documented in the repo README's *Included modules*. - ---- - -## What it does - -`resi_to_propresenter.py` watches a Resi decoder's playhead, works out how long until -its next cue point, and keeps a ProPresenter countdown timer armed with that number. -ProPresenter renders the countdown itself, so the stage screen is smooth and styled in -whatever the stage layout uses, and nothing has to be pushed every second. The cue's -name goes up as a ProPresenter stage message alongside it. - -The cue list and the playhead both come from Resi's cloud, not from the decoder — the -decoder reports its position upstream, and `central.resi.io` hands it back. Nothing has -to be installed at the decoder end. - -## Why the playhead is estimated rather than displayed - -Resi republishes a decoder's `position` only every few seconds, on an irregular -cadence, and each reading is already a little stale when it appears. Sent straight to -ProPresenter, the countdown sits several seconds behind and lurches. - -So every reading is kept as *media time minus wall time*, and the **largest** recent -value is the one trusted — that is the reading that reached us with the least delay, so -a slow round trip or an unlucky poll cannot drag the countdown backwards. A reading -that falls outside the link's measured noise floor is treated as a seek: the lock is -dropped, the estimate snaps, and the cue list is re-read at once instead of on its -timer. Playback is only called paused once the playhead has been still for clearly -longer than Resi's own cadence, because a fixed timeout mistakes a slow update for a -stop and freezes the countdown mid-service. - -How quickly a fired cue can possibly show on stage is bounded by how often Resi -republishes the position. Nothing on this end beats that; the log prints the measured -cadence so you can see what it is. - -## Running it - -Settings live outside the checkout, at -`~/Library/Application Support/ResiCues/config.json`, so a `git pull` never touches -them. `config.example.json` here is the template. The Resi password is read from the -Keychain and is never written to disk: - - security add-generic-password -a "you@yourchurch.org" -s resi-propresenter -w - -Then pick a decoder: - - python3 resi_to_propresenter.py --list - -and put its `playerHardwareId` into `player` in the config (leave it blank if the -account has only one). `--dry-run` prints what it would send without touching -ProPresenter. - -**Start it at login:** double-click `install-agent.command`. It writes a LaunchAgent -that runs the script at login and restarts it if it stops, logging to -`~/Library/Logs/resi-propresenter.log`. Run it again after a pull to repoint the agent. - -ProPresenter needs its network API on (Settings › Network) and a **countdown** timer -whose name matches `timer_name` in the config — `NextCue` by default — placed on the -stage layout, with a Message object for the cue name. - -## Tests - - python3 test_playhead.py # the estimator against 9 simulated Resi profiles - python3 test_end_to_end.py http://10.1.14.225:1025 - -`test_playhead.py` drives the estimator on a fake clock against a simulated Resi with -a configurable update cadence, staleness, cadence jitter and network round trip; pass -those four as arguments to run one profile. `test_end_to_end.py` runs the real script -loop against a **real** ProPresenter with a simulated decoder, checks the timer is -armed and counts down in step across a cue rollover, and puts ProPresenter back as it -found it. - -Both suites are written so a deliberate break turns them red — six of seven mutations -of the estimator do. Neither covers the live Resi calls, which need real credentials. - -## The Companion sibling - -Before the stage-screen version, the same estimator shipped as a Bitfocus Companion -module for Stream Deck buttons — a patched build of -[reykroona/companion-module-resi-studio-cues](https://github.com/reykroona/companion-module-resi-studio-cues) -(MIT), with the estimator, a PLAYING/PAUSED variable and resync actions added. It is -not needed for the stage display and is not checked in here; the packaged `.tgz` and -its patched sources are in `~/Desktop/Resi Companion/`. - -## Resi's API - -Undocumented and unsupported — it is what `studio.resi.io` itself uses. Don't open a -Resi ticket about it. Authentication is `POST /api/v3/login?newToken=true` with -`{userName, password}`, which sets a `livingas1` cookie used as -`Authorization: X-Bearer `; from there `/api_v2.svc/users/me`, -`/api/v3/customers/{id}/venues`, `/api_v2.svc/users/{venueUuid}/players`, -`/api/v3/customers/{id}/events/{eventId}` and -`/api_v2.svc/streamprofiles/{profileId}/events/{eventId}/cues`. diff --git a/modules/resi-studio-cues/config.example.json b/modules/resi-studio-cues/config.example.json deleted file mode 100644 index a79bfee..0000000 --- a/modules/resi-studio-cues/config.example.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "resi_user": "you@yourchurch.org", - "player": "", - "propresenter": "http://10.1.14.225:1025", - "timer_name": "NextCue", - "stage_message": true, - "poll_seconds": 1.0, - "cue_refresh_seconds": 5, - "resync_seconds": 30, - "position_trim_ms": 0 -} diff --git a/modules/resi-studio-cues/install-agent.command b/modules/resi-studio-cues/install-agent.command deleted file mode 100755 index d3eb9c7..0000000 --- a/modules/resi-studio-cues/install-agent.command +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash -# Install (or refresh) the login agent that keeps the Resi -> ProPresenter bridge running. -# Double-click this file. Safe to run again after a git pull. -set -euo pipefail - -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -SOURCE="$HERE/resi_to_propresenter.py" -LABEL="org.waterschurch.resi-propresenter" -PLIST="$HOME/Library/LaunchAgents/$LABEL.plist" -CONFIG_DIR="$HOME/Library/Application Support/ResiCues" -LOG="$HOME/Library/Logs/resi-propresenter.log" - -[ -f "$SOURCE" ] || { echo "Cannot find $SOURCE"; exit 1; } - -mkdir -p "$HOME/Library/LaunchAgents" "$CONFIG_DIR" "$(dirname "$LOG")" - -# A login agent cannot read ~/Documents (and ~/Desktop, ~/Downloads) without Full Disk -# Access — macOS answers "Operation not permitted". Rather than ask for that, run from -# a copy in Application Support, which is not gated. Re-run this after a git pull. -SCRIPT="$CONFIG_DIR/resi_to_propresenter.py" -cp "$SOURCE" "$SCRIPT" - -if [ ! -f "$CONFIG_DIR/config.json" ]; then - cp "$HERE/config.example.json" "$CONFIG_DIR/config.json" - echo "Made a starter config at $CONFIG_DIR/config.json — set resi_user and player in it." -fi - -cat > "$PLIST" < - - - - Label$LABEL - ProgramArguments - - /usr/bin/python3 - $SCRIPT - - EnvironmentVariables - - RESI_PP_CONFIG$CONFIG_DIR/config.json - - WorkingDirectory$CONFIG_DIR - RunAtLoad - KeepAlive - ThrottleInterval30 - ProcessTypeBackground - StandardOutPath$LOG - StandardErrorPath$LOG - - -PLIST_EOF - -launchctl bootout "gui/$UID/$LABEL" 2>/dev/null || true -launchctl bootstrap "gui/$UID" "$PLIST" -launchctl enable "gui/$UID/$LABEL" 2>/dev/null || true - -echo -echo "Installed. It starts at login and restarts itself if it stops." -echo " script $SCRIPT (a copy — re-run this after a git pull)" -echo " config $CONFIG_DIR/config.json" -echo " log $LOG" -echo -echo "Watch it with: tail -f \"$LOG\"" -echo "Stop it with: launchctl bootout gui/$UID/$LABEL" -echo "Start it again: launchctl bootstrap gui/$UID \"$PLIST\"" diff --git a/modules/resi-studio-cues/resi_to_propresenter.py b/modules/resi-studio-cues/resi_to_propresenter.py deleted file mode 100644 index 5a40290..0000000 --- a/modules/resi-studio-cues/resi_to_propresenter.py +++ /dev/null @@ -1,512 +0,0 @@ -#!/usr/bin/env python3 -""" -resi_to_propresenter.py — drive a ProPresenter countdown timer from Resi cue points. - -Watches a Resi decoder's playhead, works out how long until its next cue, and keeps a -ProPresenter timer armed with that number. ProPresenter renders the countdown itself, so -the stage screen is smooth and styled; this only pokes it when something actually changes. - -No Companion, no Stream Deck. Python 3 only — nothing to install. - - python3 resi_to_propresenter.py --list # show venues/decoders, then fill in the config - python3 resi_to_propresenter.py # run it - python3 resi_to_propresenter.py --dry-run # watch what it WOULD send - -The Resi password is read from the macOS Keychain, never stored here. Add it once: - - security add-generic-password -a "$USER" -s resi-propresenter -w - -Resi's API is undocumented and unsupported; don't open a Resi ticket about it. -""" - -import argparse -import http.cookiejar -import json -import math -import os -import subprocess -import sys -import time -import urllib.error -import urllib.parse -import urllib.request - -HERE = os.path.dirname(os.path.abspath(__file__)) - - -def config_path(): - """Settings live OUTSIDE the checkout so a git pull never touches them — the same - rule ProdDash follows. A config sitting next to the script still wins, so a copy - dropped on a USB stick keeps working.""" - env = os.environ.get("RESI_PP_CONFIG") - if env: - return os.path.expanduser(env) - beside = os.path.join(HERE, "resi_pp_config.json") - if os.path.exists(beside): - return beside - return os.path.expanduser("~/Library/Application Support/ResiCues/config.json") - - -CONFIG_PATH = config_path() -RESI = "https://central.resi.io" -KEYCHAIN_SERVICE = "resi-propresenter" - -DEFAULTS = { - "resi_user": "", - "player": "", # playerHardwareId; blank = use the only one found - "propresenter": "http://10.1.14.225:1025", - "timer_name": "NextCue", - "stage_message": True, # also push the cue NAME as a stage message - "poll_seconds": 1.0, # how often to ask Resi where the playhead is - "cue_refresh_seconds": 5, # how often to re-read the cue list - "resync_seconds": 30, # re-push to ProPresenter even when nothing changed - "position_trim_ms": 0, # only if the whole chain reads consistently behind -} - - -# --------------------------------------------------------------------------- utilities -def now_ms(): - """Wall clock in milliseconds. A seam so the estimator can be tested on a fake clock.""" - return time.time() * 1000 - - -def log(msg): - print("%s %s" % (time.strftime("%H:%M:%S"), msg), flush=True) - - -def hms(ms): - s = max(0, int(ms // 1000)) - return "%02d:%02d:%02d" % (s // 3600, (s % 3600) // 60, s % 60) - - -def position_to_ms(pos): - """'01:02:03.500' -> milliseconds, or None.""" - try: - hh, mm, rest = pos.split(":") - return int((int(hh) * 3600 + int(mm) * 60 + float(rest)) * 1000) - except (ValueError, AttributeError): - return None - - -def keychain_password(account): - try: - out = subprocess.run( - ["security", "find-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE, "-w"], - capture_output=True, text=True, timeout=10) - except (OSError, subprocess.SubprocessError) as e: - raise SystemExit("Could not run the Keychain helper: %s" % e) - if out.returncode != 0: - return None - return out.stdout.rstrip("\n") - - -def wait_for_password(account): - """Running as a login agent, a missing password is something to wait out rather - than die on — the agent would otherwise relaunch forever before it is set up.""" - told = False - while True: - pw = keychain_password(account) - if pw: - return pw - if not told: - log("No Resi password in the Keychain for '%s'. Add it once with:" % account) - log(' security add-generic-password -a "%s" -s %s -w' - % (account, KEYCHAIN_SERVICE)) - log("Waiting for it...") - told = True - time.sleep(60) - - -# ------------------------------------------------------------------------------- Resi -class Resi: - def __init__(self, username, password): - self.username, self.password = username, password - self.jar = http.cookiejar.CookieJar() - self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar)) - self.token = None - self.logged_in_at = 0 - self.customer_id = None - self.no_cache_bust = False - - def _cookie(self, name): - for c in self.jar: - if c.name == name: - return c.value - return None - - def _call(self, method, path, body=None): - url = RESI + path - headers = {"Accept": "application/json", "User-Agent": "resi-to-propresenter/1.0"} - if self.token: - headers["Authorization"] = "X-Bearer " + self.token - data = None - if body is not None: - data = json.dumps(body).encode() - headers["Content-Type"] = "application/json" - req = urllib.request.Request(url, data=data, headers=headers, method=method) - with self.opener.open(req, timeout=20) as resp: - raw = resp.read() - return json.loads(raw) if raw else None - - def login(self): - self._call("POST", "/api/v3/login?newToken=true", - {"userName": self.username, "password": self.password}) - self.token = self._cookie("livingas1") - if not self.token: - raise RuntimeError("login succeeded but returned no session cookie") - self.logged_in_at = time.time() - me = self._call("GET", "/api_v2.svc/users/me") - self.customer_id = me.get("customerId") - if not self.customer_id: - raise RuntimeError("this Resi account has no customerId") - log("Resi: signed in as %s (%s)" % (me.get("userName"), me.get("customerName"))) - - def ensure_session(self): - if not self.token or time.time() - self.logged_in_at > 50 * 60: - self.login() - - def get(self, path): - self.ensure_session() - try: - return self._call("GET", path) - except urllib.error.HTTPError as e: - if e.code in (401, 403): - log("Resi: session expired, signing in again") - self.login() - return self._call("GET", path) - raise - - def venues(self): - return self.get("/api/v3/customers/%s/venues" % self.customer_id) or [] - - def players(self, venue_uuid): - path = "/api_v2.svc/users/%s/players" % venue_uuid - if not self.no_cache_bust: - try: - return self.get(path + "?_=%d" % int(time.time() * 1000)) or [] - except urllib.error.HTTPError as e: - if 400 <= e.code < 500: - self.no_cache_bust = True - log("Resi: dropping the cache-busting parameter (%s)" % e.code) - else: - raise - return self.get(path) or [] - - def event(self, event_id): - return self.get("/api/v3/customers/%s/events/%s" % (self.customer_id, event_id)) - - def cues(self, profile_id, event_id): - raw = self.get("/api_v2.svc/streamprofiles/%s/events/%s/cues?canISetCues=1" - % (profile_id, event_id)) or [] - out = [] - for c in raw: - ms = position_to_ms(c.get("position")) - if ms is not None: - out.append({"name": c.get("name") or "", "ms": ms}) - out.sort(key=lambda c: c["ms"]) - return out - - -# -------------------------------------------------------------------------- estimator -class Playhead: - """Resi republishes `position` only every few seconds, irregularly, and each reading - is already slightly stale. Every reading is kept as media-time-minus-wall-time; the - LARGEST recent value is the least-delayed reading and is the one trusted. The - estimate is then just "now plus that offset", correct to within Resi's own pipeline - lag and smooth between updates.""" - - WINDOW_MS = 120_000 - - def __init__(self, trim_ms=0): - self.trim_ms = trim_ms - self.offsets = [] # (arrival_ms, offset_ms) - self.spreads = [] # how far honest readings sit apart: this link's noise - self.gaps = [] # how often the playhead actually moves - self.base = None - self.last_media = None - self.last_new_at = None - self.last_est = None - self.seeked = False - - def note(self, media_ms): - now = now_ms() - if media_ms != self.last_media: - off = media_ms - now - noise = max(self.spreads) if self.spreads else 0 - threshold = max(5000, noise * 1.5 + 4000) - if self.base is not None: - delta = off - self.base - if abs(delta) > threshold: - # seek, fired cue, or resume after a pause: the window describes - # the old position and has to go - self.offsets = [] - self.seeked = True - if abs(delta) > 10000: - self.last_est = None - elif delta < 0: - self.spreads = (self.spreads + [-delta])[-20:] - if self.last_new_at is not None: - gap = now - self.last_new_at - # The gap either side of a pause is not Resi's cadence -- recording it - # would inflate the estimate and make the NEXT pause slow to notice. - if 0 < gap < 60000 and (not self.gaps or gap <= 3 * max(self.gaps)): - self.gaps = (self.gaps + [gap])[-8:] - self.offsets.append((now, off)) - self.last_media = media_ms - self.last_new_at = now - kept = [o for o in self.offsets if now - o[0] <= self.WINDOW_MS] - # Always keep the newest reading even once it is older than the window. If the - # window empties during a long pause there is no offset left to compare against, - # so the resume looks like an ordinary reading instead of a discontinuity. - if not kept and self.offsets: - kept = [self.offsets[-1]] - self.offsets = kept - self.base = max(o[1] for o in self.offsets) if self.offsets else None - - def read(self): - """-> (estimated_media_ms, playing, cadence_ms) or (None, False, 0).""" - if self.last_new_at is None: - return None, False, 0 - now = now_ms() - cadence = max(self.gaps) if self.gaps else 0 - # Only call it paused once the playhead has been still for clearly longer than - # Resi's own reporting cadence -- a fixed timeout mistakes a slow update for a stop. - pause_after = max(15000, min(45000, cadence * 2.5 + 6000)) - playing = self.base is not None and (now - self.last_new_at) < pause_after - if not playing: - return (self.last_est if self.last_est is not None else self.last_media), False, cadence - est = now + self.base + self.trim_ms - if self.last_est is not None and est < self.last_est: - est = self.last_est # the window is a max; never run backwards - self.last_est = est - return est, True, cadence - - def take_seek(self): - was, self.seeked = self.seeked, False - return was - - -# ------------------------------------------------------------------- ProPresenter side -class ProPresenter: - def __init__(self, base, timer_name, dry_run=False): - self.base = base.rstrip("/") - self.timer = timer_name - self.dry_run = dry_run - self.quoted = urllib.parse.quote(timer_name, safe="") - - def _call(self, method, path, body=None): - if self.dry_run: - log(" would %s %s %s" % (method, path, json.dumps(body) if body is not None else "")) - return True - url = self.base + path - data = None - headers = {} - if body is not None: - data = json.dumps(body).encode() - headers["Content-Type"] = "application/json" - req = urllib.request.Request(url, data=data, headers=headers, method=method) - try: - with urllib.request.urlopen(req, timeout=8): - return True - except urllib.error.HTTPError as e: - log("ProPresenter %s %s -> HTTP %s" % (method, path, e.code)) - except urllib.error.URLError as e: - log("ProPresenter unreachable at %s (%s)" % (self.base, e.reason)) - return False - - def arm(self, seconds): - return self._call("PUT", "/v1/timer/%s/start" % self.quoted, { - "id": {"name": self.timer}, - "allows_overrun": False, - "countdown": {"duration": int(seconds)}, - }) - - def stop(self): - return self._call("GET", "/v1/timer/%s/stop" % self.quoted) - - def message(self, text): - return self._call("PUT", "/v1/stage/message", text) - - def clear_message(self): - return self._call("DELETE", "/v1/stage/message") - - def check(self): - try: - with urllib.request.urlopen(self.base + "/v1/timers", timeout=8) as r: - timers = json.loads(r.read()) - except Exception as e: - raise SystemExit("Cannot reach ProPresenter at %s (%s).\n" - "Check the address and that its API is enabled in Settings -> Network." - % (self.base, e)) - names = [t.get("id", {}).get("name") for t in timers] - if self.timer not in names: - raise SystemExit("ProPresenter has no timer called '%s'. It has: %s\n" - "Create a countdown timer with that exact name, or set " - "\"timer_name\" in the config." % (self.timer, ", ".join(map(str, names)))) - log("ProPresenter: found timer '%s'" % self.timer) - - -# ------------------------------------------------------------------------------- glue -def load_config(): - cfg = dict(DEFAULTS) - if os.path.exists(CONFIG_PATH): - with open(CONFIG_PATH) as fh: - cfg.update(json.load(fh)) - else: - os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True) - with open(CONFIG_PATH, "w") as fh: - json.dump(cfg, fh, indent=2) - print("Wrote a starter config to %s — fill in resi_user, then re-run." % CONFIG_PATH) - raise SystemExit(0) - if not cfg["resi_user"]: - raise SystemExit("Set \"resi_user\" in %s first." % CONFIG_PATH) - return cfg - - -def find_player(resi, wanted): - """-> (venue_uuid, player) for the configured decoder.""" - found = [] - for v in resi.venues(): - for p in resi.players(v.get("uuid")): - found.append((v, p)) - if not found: - raise SystemExit("Resi returned no decoders for this account.") - if wanted: - for v, p in found: - if p.get("playerHardwareId") == wanted: - return v.get("uuid"), p - raise SystemExit("No decoder with playerHardwareId '%s'. Run --list to see them." % wanted) - if len(found) == 1: - v, p = found[0] - return v.get("uuid"), p - raise SystemExit("This account has %d decoders — run --list and set \"player\" in the config." - % len(found)) - - -def do_list(resi): - for v in resi.venues(): - print("\n%s" % (v.get("userName") or v.get("name"))) - players = resi.players(v.get("uuid")) - if not players: - print(" (no decoders)") - for p in players: - print(" %-30s playerHardwareId: %s" % (p.get("userName"), p.get("playerHardwareId"))) - print(" %-30s position: %s eventId: %s" - % ("", p.get("position"), p.get("eventId"))) - print("\nPut the playerHardwareId you want into \"player\" in %s" % CONFIG_PATH) - - -def run(cfg, dry_run): - resi = Resi(cfg["resi_user"], wait_for_password(cfg["resi_user"])) - resi.login() - pp = ProPresenter(cfg["propresenter"], cfg["timer_name"], dry_run) - if not dry_run: - pp.check() - - venue_uuid, player = find_player(resi, cfg["player"]) - hardware_id = player.get("playerHardwareId") - log("Watching decoder '%s' (%s)" % (player.get("userName"), hardware_id)) - - head = Playhead(cfg["position_trim_ms"]) - cues, event_id, profile_id = [], None, None - last_cue_load = 0.0 - last_push = 0.0 - pushed_cue = None # (name, ms) currently armed in ProPresenter - pushed_playing = None - - while True: - loop_started = time.time() - try: - players = resi.players(venue_uuid) - me = next((p for p in players if p.get("playerHardwareId") == hardware_id), None) - if me is None: - log("decoder not in the players list right now") - else: - media = position_to_ms(me.get("position")) - if media is not None: - head.note(media) - - # follow the event the decoder is actually on - if me.get("eventId") and me.get("eventId") != event_id: - event_id = me.get("eventId") - ev = resi.event(event_id) or {} - profile_id = ev.get("eventProfileId") - cues = resi.cues(profile_id, event_id) if profile_id else [] - last_cue_load = time.time() - log("event %s — %d cue(s): %s" - % (ev.get("name") or event_id, len(cues), - ", ".join(c["name"] for c in cues) or "none")) - elif not me.get("eventId"): - event_id, cues = None, [] - - seeked = head.take_seek() - if profile_id and event_id and ( - seeked or time.time() - last_cue_load >= cfg["cue_refresh_seconds"]): - fresh = resi.cues(profile_id, event_id) - last_cue_load = time.time() - if fresh != cues: - log("cue list changed — %d cue(s)" % len(fresh)) - cues = fresh - - est, playing, cadence = head.read() - nxt = next((c for c in cues if c["ms"] > est), None) if est is not None else None - - identity = (nxt["name"], nxt["ms"]) if nxt else None - due = (identity != pushed_cue - or playing != pushed_playing - or seeked - or time.time() - last_push >= cfg["resync_seconds"]) - - if due: - if playing and nxt: - remaining = max(0, (nxt["ms"] - est) / 1000.0) - pp.arm(math.ceil(remaining)) - if cfg["stage_message"]: - pp.message(nxt["name"]) - log("%s -> %s in %s (playhead %s, Resi updates every %ss)" - % (cfg["timer_name"], nxt["name"], hms(nxt["ms"] - est), - hms(est), round(cadence / 1000) if cadence else "?")) - elif not playing: - pp.stop() - log("playback stopped — timer held") - else: - pp.stop() - if cfg["stage_message"]: - pp.clear_message() - log("no cues left on this event") - pushed_cue, pushed_playing, last_push = identity, playing, time.time() - - except urllib.error.URLError as e: - log("network: %s" % e.reason) - except Exception as e: # keep running through a bad service day - log("error: %s" % e) - - time.sleep(max(0.2, cfg["poll_seconds"] - (time.time() - loop_started))) - - -def main(): - ap = argparse.ArgumentParser(description="Drive a ProPresenter timer from Resi cue points.") - ap.add_argument("--list", action="store_true", help="list venues and decoders, then exit") - ap.add_argument("--dry-run", action="store_true", help="print what would be sent, touch nothing") - args = ap.parse_args() - - cfg = load_config() - if args.list: - pw = keychain_password(cfg["resi_user"]) - if not pw: - raise SystemExit( - "No Resi password in the Keychain for '%s'. Add it once with:\n" - ' security add-generic-password -a "%s" -s %s -w' - % (cfg["resi_user"], cfg["resi_user"], KEYCHAIN_SERVICE)) - resi = Resi(cfg["resi_user"], pw) - resi.login() - do_list(resi) - return - try: - run(cfg, args.dry_run) - except KeyboardInterrupt: - print("\nstopped") - - -if __name__ == "__main__": - main() diff --git a/modules/resi-studio-cues/test_end_to_end.py b/modules/resi-studio-cues/test_end_to_end.py deleted file mode 100644 index 5c8a6ad..0000000 --- a/modules/resi-studio-cues/test_end_to_end.py +++ /dev/null @@ -1,145 +0,0 @@ -#!/usr/bin/env python3 -"""End-to-end: the real script loop, a simulated Resi, and the REAL ProPresenter. - -Everything except the Resi network calls is the shipping code path — event handling, -cue refresh, the estimator, and the ProPresenter pushes. Runs on the wall clock for -~30 seconds, then puts ProPresenter back how it found it. - - python3 test_end_to_end.py http://10.1.14.225:1025 -""" -import json -import sys -import threading -import time -import urllib.request - -import resi_to_propresenter as R - -PP = sys.argv[1] if len(sys.argv) > 1 else "http://10.1.14.225:1025" -TIMER = "NextCue" - -t0 = time.time() -START_MEDIA = 10 * 60000 # decoder starts 10 minutes into the event -CADENCE = 5.0 # Resi republishes every 5s -LAG = 0.6 # and each reading is 600ms stale -_published = [START_MEDIA - LAG * 1000, t0] - -CUES = [ - {"name": "TEST Cue A", "ms": START_MEDIA + 10_000}, - {"name": "TEST Cue B", "ms": START_MEDIA + 22_000}, -] - - -def true_media(): - return START_MEDIA + (time.time() - t0) * 1000 - - -def published(): - if time.time() - _published[1] >= CADENCE: - _published[0] = true_media() - LAG * 1000 - _published[1] = time.time() - return _published[0] - - -def fmt(ms): - ms = int(max(0, ms)) - return "%02d:%02d:%02d.%03d" % (ms // 3600000, (ms % 3600000) // 60000, - (ms % 60000) // 1000, ms % 1000) - - -class FakeResi: - def __init__(self, *a, **k): - self.customer_id = "CUST" - - def login(self): - R.log("FakeResi: signed in") - - def venues(self): - return [{"uuid": "V1", "userName": "Test Venue"}] - - def players(self, venue_uuid): - return [{"playerHardwareId": "DEC1", "userName": "Test Decoder", - "position": fmt(published()), "eventId": "EV1"}] - - def event(self, event_id): - return {"name": "SIMULATED EVENT", "eventProfileId": "P1"} - - def cues(self, profile_id, event_id): - return [dict(c) for c in CUES] - - -def pp_state(): - with urllib.request.urlopen(PP + "/v1/timers/current", timeout=8) as r: - timers = json.loads(r.read()) - mine = next((t for t in timers if t.get("id", {}).get("name") == TIMER), None) - try: - with urllib.request.urlopen(PP + "/v1/stage/message", timeout=8) as r: - msg = json.loads(r.read() or b'""') - except Exception: - msg = None - return mine, msg - - -def main(): - R.Resi = FakeResi - R.keychain_password = lambda account: "not-used" - cfg = dict(R.DEFAULTS) - cfg.update({"resi_user": "test@example.com", "player": "DEC1", "propresenter": PP, - "timer_name": TIMER, "poll_seconds": 1.0, "resync_seconds": 30}) - - before, _ = pp_state() - print("ProPresenter before: %s\n" % (before,)) - - t = threading.Thread(target=R.run, args=(cfg, False), daemon=True) - t.start() - - failures = [] - seen_a = seen_b = False - deadline = time.time() + 30 - while time.time() < deadline: - time.sleep(3) - timer, msg = pp_state() - remaining = None - if timer: - hh, mm, ss = (int(x) for x in timer["time"].split(":")) - remaining = hh * 3600 + mm * 60 + ss - print(" t+%-4.0fs decoder at %s PP timer %s (%s) message %r" - % (time.time() - t0, fmt(true_media()), - timer["time"] if timer else "?", timer["state"] if timer else "?", msg)) - if msg == "TEST Cue A": - seen_a = True - expected = (CUES[0]["ms"] - true_media()) / 1000.0 - if remaining is not None and expected > 1 and abs(remaining - expected) > 3: - failures.append("Cue A countdown off by %.1fs (PP said %ss, real %.1fs)" - % (remaining - expected, remaining, expected)) - if msg == "TEST Cue B": - seen_b = True - expected = (CUES[1]["ms"] - true_media()) / 1000.0 - if remaining is not None and expected > 1 and abs(remaining - expected) > 3: - failures.append("Cue B countdown off by %.1fs" % (remaining - expected)) - - if not seen_a: - failures.append("never armed for Cue A") - if not seen_b: - failures.append("never rolled over to Cue B") - - print("\ncleaning up...") - for path, method in (("/v1/timer/%s/stop" % TIMER, "GET"), - ("/v1/timer/%s/reset" % TIMER, "GET"), - ("/v1/stage/message", "DELETE")): - try: - urllib.request.urlopen( - urllib.request.Request(PP + path, method=method), timeout=8).close() - except Exception as e: - print(" cleanup %s failed: %s" % (path, e)) - - if failures: - print("\nFAILED:") - for f in failures: - print(" - %s" % f) - raise SystemExit(1) - print("\nend-to-end OK: armed for both cues, countdown matched the decoder") - - -if __name__ == "__main__": - main() diff --git a/modules/resi-studio-cues/test_playhead.py b/modules/resi-studio-cues/test_playhead.py deleted file mode 100644 index 526379a..0000000 --- a/modules/resi-studio-cues/test_playhead.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 -"""Drive resi_to_propresenter.Playhead against a simulated Resi on a fake clock. - -Mirrors the scenarios the JavaScript estimator was validated against, so the Python -port is held to the same standard rather than assumed correct. - - python3 test_playhead.py # the whole matrix - python3 test_playhead.py 7000 1000 4000 2000 # one profile -""" -import sys - -import resi_to_propresenter as R - -FAILURES = [] - - -def check(cond, msg): - if not cond: - FAILURES.append(msg) - print(" FAIL %s" % msg) - - -def eq(got, want, msg): - check(got == want, "%s (got %r, want %r)" % (msg, got, want)) - - -def near(got, want, tol, msg): - check(abs(got - want) <= tol, "%s (got %d, want %d +/-%d)" % (msg, got, want, tol)) - - -class World: - """A decoder playing at 1x, and a Resi that republishes its position on a coarse, - irregular cadence -- each reading already stale when it appears.""" - - def __init__(self, cadence, lag, jitter, net): - self.cadence, self.lag, self.jitter, self.net = cadence, lag, jitter, net - self.t = 1_700_000_000_000 - self.media = 8 * 60000 + 48000 - self.published = self.media - lag - self.last_publish = self.t - self.next_cadence = cadence - self.playing = True - self.seed = 12345 - - def rnd(self): - self.seed = (self.seed * 1103515245 + 12345) & 0x7FFFFFFF - return self.seed / 0x7FFFFFFF - - def advance(self, ms): - for _ in range(0, int(ms), 100): - self.t += 100 - if self.playing: - self.media += 100 - if self.t - self.last_publish >= self.next_cadence: - self.last_publish = self.t - self.published = self.media - self.lag - self.next_cadence = max(500, self.cadence + round((self.rnd() - 0.5) * 2 * self.jitter)) - - def fetch(self): - """What a poll sees, and the round trip it costs.""" - body = self.published - if self.net: - self.advance(100 + round(self.rnd() * self.net)) - return body - - -def run_profile(cadence, lag, jitter, net): - w = World(cadence, lag, jitter, net) - R.now_ms = lambda: w.t - head = R.Playhead() - tol = lag + 1200 + jitter + net * 0.35 - - def second(): - w.advance(1000) - head.note(w.fetch()) - return head.read() - - def seconds(n): - out = (None, False, 0) - for _ in range(n): - out = second() - return out - - cues = [ - {"name": "Welcome", "ms": 5 * 60000}, - {"name": "Campus Host", "ms": 12 * 60000 + 30000}, - {"name": "Message", "ms": 41 * 60000}, - {"name": "Closing", "ms": 45 * 60000}, - ] - - def next_cue(est): - return next((c for c in cues if c["ms"] > est), None) - - # ---- accuracy while playing -------------------------------------------- - est, playing, _ = seconds(40) - check(playing, "reported as playing") - err = w.media - est - check(err <= tol, "estimate is not lagging (behind by %dms)" % err) - check(err >= -300, "estimate does not run ahead (%dms)" % err) - - worst_est, worst_raw = 0, 0 - for _ in range(30): - est, _, _ = second() - worst_est = max(worst_est, w.media - est) - worst_raw = max(worst_raw, w.media - w.published) - if cadence >= 2000: - check(worst_raw >= cadence * 0.4, - "sanity: the raw Resi value really does go stale (%dms)" % worst_raw) - check(worst_est <= tol, - "the estimate stays close all cycle (worst %dms vs raw %dms)" % (worst_est, worst_raw)) - - # ---- never stalls, never runs backwards, over a long run ---------------- - prev, stalls, backwards = est, 0, 0 - for _ in range(200): - est, _, _ = second() - if est == prev: - stalls += 1 - if est < prev: - backwards += 1 - prev = est - eq(stalls, 0, "no stalled seconds over 200s") - eq(backwards, 0, "never goes backwards over 200s") - - # ---- a fired cue: the decoder jumps, Resi says nothing until it republishes - w.media = 40 * 60000 + 50000 - saw = False - for _ in range(60): - est, playing, _ = second() - if head.last_media is not None and abs(head.last_media - w.media) < 8000: - saw = True - break - check(saw, "the jump is picked up") - check(head.take_seek() or True, "seek flag is readable") - near(est, w.media, tol + cadence / 2, "estimate resyncs to the new playhead") - check(w.media - est >= -400, "no overshoot after a seek (%dms)" % (w.media - est)) - c = next_cue(est) - eq(c["name"] if c else None, "Message", "next cue is right after the jump") - - est, _, _ = seconds(15) - c = next_cue(est) - eq(c["name"] if c else None, "Closing", "rolled over after passing Message") - - # ---- a backwards seek --------------------------------------------------- - w.media = 6 * 60000 - for _ in range(60): - est, _, _ = second() - if head.last_media is not None and abs(head.last_media - w.media) < 8000: - break - near(est, w.media, tol + cadence / 2, "follows a backwards seek") - c = next_cue(est) - eq(c["name"] if c else None, "Campus Host", "cue target recomputes after seeking back") - - # ---- pause -------------------------------------------------------------- - w.playing = False - wait = int((max(15000, min(45000, (cadence + jitter) * 2.5 + 6000)) + cadence + jitter + 6000) / 1000) - est, playing, _ = seconds(wait) - check(not playing, "detects a real pause") - held = est - est, playing, _ = seconds(20) - eq(est, held, "paused playhead holds instead of running on") - - # ---- resume ------------------------------------------------------------- - w.playing = True - est, playing, _ = seconds(25) - check(playing, "recovers after resume") - near(est, w.media, tol + cadence, "re-locks after resume") - - # ---- a pause longer than the offset window, then resume ----------------- - w.playing = False - media_at_pause = w.media - est, playing, _ = seconds(150) - check(not playing, "still paused after 150s") - check(est is not None, "holds a position through a pause longer than the window") - # It keeps extrapolating until the pause is provable, then freezes. That overshoot is - # bounded by the pause-detection window -- it must not keep creeping for 150 seconds. - # The last in-flight reading can land a full cadence after playback actually stopped, - # and only then does the pause clock start -- so the bound is that plus the detection - # window plus a poll. What this pins is that it STOPS, rather than creeping for 150s. - pause_after = max(15000, min(45000, (cadence + jitter) * 2.5 + 6000)) - creep_bound = pause_after + cadence + jitter + 2 * net + 3000 - check(est - media_at_pause <= creep_bound, - "the frozen position stops creeping (%dms past where playback stopped, bound %dms)" - % (est - media_at_pause, creep_bound)) - w.playing = True - head.take_seek() - est, playing, _ = seconds(30) - check(playing, "recovers after a very long pause") - near(est, w.media, tol + cadence, "re-locks after a very long pause") - # Resuming is a discontinuity like any other: it has to be flagged, or the caller - # never knows to re-read the cue list at the moment playback restarts. - check(head.seeked or head.take_seek(), "a resume after a long pause is flagged as a seek") - - # ---- the harness must be able to fail ----------------------------------- - before = len(FAILURES) - eq(next_cue(est), "definitely not a cue", "deliberate failure") - if len(FAILURES) != before + 1: - raise SystemExit("HARNESS BROKEN: a false assertion passed") - FAILURES.pop() - - -def main(): - if len(sys.argv) > 1: - profiles = [tuple(int(x) for x in sys.argv[1:5])] - else: - profiles = [ - (5000, 500, 0, 0), (5000, 500, 0, 2500), (2000, 0, 0, 800), - (10000, 3000, 0, 2500), (7000, 1000, 4000, 2000), (3000, 500, 2000, 1000), - (12000, 2000, 6000, 3000), (1000, 0, 0, 0), (8000, 1500, 3000, 4000), - ] - bad = 0 - for cadence, lag, jitter, net in profiles: - start = len(FAILURES) - run_profile(cadence, lag, jitter, net) - n = len(FAILURES) - start - bad += n - print(" cadence=%-6s lag=%-5s jitter=%-5s net=%-5s %s" - % (cadence, lag, jitter, net, "OK" if n == 0 else "%d FAILED" % n)) - if bad: - raise SystemExit("\n%d failure(s)" % bad) - print("\nall playhead tests passed across %d Resi profiles" % len(profiles)) - - -if __name__ == "__main__": - main()