Resi Cues becomes a real module, and Companion keeps the stage timer

Yesterday's version of this was a Python script under `modules/` with a
LaunchAgent to keep it alive — which the guide rules out in as many words:
everything a module does runs inside its server entry, no companion programs
or daemons an operator starts by hand. It also could not put anything on a
dashboard, which is half of what it was wanted for. Both halves are now in
the right place.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 12:08:55 -04:00
co-authored by Claude Opus 5
parent 82c552955d
commit a8d1b8e159
14 changed files with 1388 additions and 1064 deletions
+91
View File
@@ -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
}
}
}