Files
ProdDash/modules/resi-studio-cues/resi_to_propresenter.py
T
christianandClaude Opus 5 82c552955d Resi cue countdowns reach the stage screen without Companion
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>
2026-09-24 11:35:40 -04:00

513 lines
20 KiB
Python

#!/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()