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>
101 lines
4.9 KiB
Markdown
101 lines
4.9 KiB
Markdown
# 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 <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`.
|