The ask was a Stream Deck button showing the time until the next Resi cue. What it turned into is simpler: ProPresenter already renders countdowns, and it already has an HTTP API, so nothing has to sit in between except something that knows when the next cue is. `resi_to_propresenter.py` watches a decoder's playhead and keeps a ProPresenter countdown timer armed with the seconds until its next cue, with the cue's name as a stage message beside it. Both halves of that come from Resi's cloud rather than the decoder — the decoder reports its position upstream and `central.resi.io` hands it back — so there is nothing to install at the decoder end and nothing polling it every second. The playhead is estimated rather than repeated. Resi republishes `position` only every few seconds, irregularly, and each reading is already stale when it appears; sent straight through, the countdown sat three seconds behind and lurched. So each 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 can no longer 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 immediately 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. This is not a tile. There is no `module.json`, so `readManifests` skips the folder and it never reaches the + picker; it lives under `modules/` because that is where the production tooling lives. That is also why it carries a README when no real module does — there is no *Included modules* entry it could have instead — and the repo README says so where the module list ends. `install-agent.command` sets up a LaunchAgent so it runs from login and restarts if it stops. It deliberately runs a copy in Application Support: a login agent cannot read `~/Documents` without Full Disk Access, and macOS answers "Operation not permitted" rather than asking. Settings live there too, outside the checkout, the same way module config does. The Resi password is read from the Keychain and never written to disk; with none set the agent says so once and waits rather than relaunching in a loop. `test_playhead.py` drives the estimator on a fake clock against nine simulated Resi profiles — update cadences of one to twelve seconds, staleness to three, irregular timing, variable round trips — and six of seven deliberate breakages of the estimator turn it red. `test_end_to_end.py` runs the real loop against a real ProPresenter with a simulated decoder, and has: it armed for the first cue, counted down in step, rolled over to the second and stopped when the cues ran out. Neither covers the live Resi calls, which need real credentials. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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
(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 <cookie>; 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.