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>
67 lines
2.4 KiB
Bash
Executable File
67 lines
2.4 KiB
Bash
Executable File
#!/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" <<PLIST_EOF
|
|
<?xml version="1.0" encoding="UTF-8"?>
|
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
<plist version="1.0">
|
|
<dict>
|
|
<key>Label</key><string>$LABEL</string>
|
|
<key>ProgramArguments</key>
|
|
<array>
|
|
<string>/usr/bin/python3</string>
|
|
<string>$SCRIPT</string>
|
|
</array>
|
|
<key>EnvironmentVariables</key>
|
|
<dict>
|
|
<key>RESI_PP_CONFIG</key><string>$CONFIG_DIR/config.json</string>
|
|
</dict>
|
|
<key>WorkingDirectory</key><string>$CONFIG_DIR</string>
|
|
<key>RunAtLoad</key><true/>
|
|
<key>KeepAlive</key><true/>
|
|
<key>ThrottleInterval</key><integer>30</integer>
|
|
<key>ProcessType</key><string>Background</string>
|
|
<key>StandardOutPath</key><string>$LOG</string>
|
|
<key>StandardErrorPath</key><string>$LOG</string>
|
|
</dict>
|
|
</plist>
|
|
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\""
|