#!/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 --setup # pick the decoder, ProPresenter and timer python3 resi_to_propresenter.py # run it python3 resi_to_propresenter.py --dry-run # watch what it WOULD send python3 resi_to_propresenter.py --list # show venues/decoders The Resi password is read from the macOS Keychain, never stored here; --setup saves it (Keychain service "resi-propresenter", account = resi_user from the config). 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__)) CONFIG_PATH = os.path.join(HERE, "resi_pp_config.json") RESI = "https://central.resi.io" KEYCHAIN_SERVICE = "resi-propresenter" DEFAULTS = { "resi_user": "", "player": "", # playerHardwareId; blank = use the only one found "propresenter": "", # e.g. http://192.168.1.50:1025 -- set by --setup "timer_name": "NextCue", "stage_message": False, # also show the next cue's NAME as a ProPresenter stage message "poll_seconds": 3.0, # how often to ask Resi where the playhead is. Resi only # republishes every ~5-30s, and the estimate fills the gaps; # 1s polling from two tools on one account hit its rate limit. "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: raise SystemExit( "No Resi password in the Keychain for '%s'.\nAdd it once with:\n" ' security add-generic-password -a "%s" -s %s -w' % (account, account, KEYCHAIN_SERVICE)) return out.stdout.rstrip("\n") # ------------------------------------------------------------------------------- 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): try: self._call("POST", "/api/v3/login?newToken=true", {"userName": self.username, "password": self.password}) except urllib.error.HTTPError as e: body = e.read() if b"INVALID_CREDENTIALS" in body: raise SystemExit( "Resi rejected the login for '%s' (wrong password?).\nReplace it with:\n" ' security add-generic-password -U -a "%s" -s %s -w' % (self.username, self.username, KEYCHAIN_SERVICE)) if b"USER_LOCKED" in body: raise SystemExit("Resi has locked the account '%s' (USER_LOCKED) — unlock it " "at studio.resi.io or with a Resi admin" % self.username) # The body was read above, so say what it was here: it is the only clue. raise RuntimeError("HTTP %s %s" % (e.code, body[:200].decode("utf-8", "replace"))) 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: # 429 is Resi saying "slow down", not an objection to the parameter; # dropping it on a 429 lost the fresh readings for good. if 400 <= e.code < 500 and e.code != 429: 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.changes = 0 # readings that differed from the one before self.rejected = False # the last gap looked like a pause and was skipped 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:] # The first reading lands at an arbitrary point in Resi's cycle, so the gap # from it to the next one is only a fraction of the real cadence. Learning # from it locked in a 2s cadence that rejected every honest 30s gap. if self.last_new_at is not None and self.changes > 0: 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. # But a pause is one gap; twice in a row is the cadence having changed. if 0 < gap < 60000: if not self.gaps or gap <= 3 * max(self.gaps) or self.rejected: self.gaps = (self.gaps + [gap])[-8:] self.rejected = False else: self.rejected = True if self.last_new_at is not None: self.changes += 1 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. # Until a full gap has been measured, the cadence is unknown; assume the slowest # Resi plausibly is, or a 30s publisher looks paused 15s after every startup. pause_after = max(15000, min(45000, cadence * 2.5 + 6000)) if cadence else 45000 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 _UNKNOWN = object() 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="") # text last put on the stage; None = cleared. Unknown at startup, so the first # message or clear always goes through (a restart may have left one up). self.sent_message = _UNKNOWN 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) # ProPresenter re-shows (and flashes) the stage message on every PUT, even with the # same text, so it is only ever sent when the text actually changes -- never on the # timer's periodic resync or a pause/resume. def message(self, text): if text == self.sent_message: return True ok = self._call("PUT", "/v1/stage/message", text) if ok: self.sent_message = text return ok def clear_message(self): if self.sent_message is None: return True ok = self._call("DELETE", "/v1/stage/message") if ok: self.sent_message = None return ok 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: with open(CONFIG_PATH, "w") as fh: json.dump(cfg, fh, indent=2) print("Wrote a starter config to %s — run with --setup to fill it in." % CONFIG_PATH) raise SystemExit(0) if not cfg["resi_user"] or not cfg["propresenter"]: raise SystemExit("%s is incomplete — run with --setup 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) STARTUP_LOGIN_HOLD_S = 120 def run(cfg, dry_run): resi = Resi(cfg["resi_user"], keychain_password(cfg["resi_user"])) try: resi.login() except (Exception, SystemExit) as e: # launchd restarts the service 30s after it exits, so failing straight away # here meant a login attempt every 30s -- enough, with a wrong password or a # rate limit, to get the Resi account locked. Hold first. log("Resi login failed (%s) — waiting %ds before trying again" % (e, STARTUP_LOGIN_HOLD_S)) time.sleep(STARTUP_LOGIN_HOLD_S) raise SystemExit(1) 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 backoff = 0 # seconds waited after the last 429; 0 once Resi answers again 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() backoff = 0 except urllib.error.HTTPError as e: if e.code != 429: log("Resi: HTTP %s" % e.code) else: # Rate-limited. Asking again every poll is what keeps Resi refusing, # so wait -- 15s doubling to 2min, or as long as Resi asks -- and say # so once rather than logging a line a second. try: asked = int(e.headers.get("Retry-After") or 0) if e.headers else 0 except ValueError: # an HTTP date instead of seconds asked = 0 backoff = min(120, max(15, backoff * 2, asked)) log("Resi: too many requests — waiting %ds before asking again" % backoff) time.sleep(backoff) 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 ask(prompt, default=""): got = input("%s%s: " % (prompt, " [%s]" % default if default else "")).strip() return got or default def setup(): """Interactive first-run: everything site-specific, asked once, written to the config.""" cfg = dict(DEFAULTS) if os.path.exists(CONFIG_PATH): with open(CONFIG_PATH) as fh: cfg.update(json.load(fh)) print("\n-- Resi --") cfg["resi_user"] = ask("Resi login email", cfg["resi_user"]) while True: have = subprocess.run(["security", "find-generic-password", "-a", cfg["resi_user"], "-s", KEYCHAIN_SERVICE], capture_output=True).returncode == 0 if not have: print("Resi password for %s (saved in this Mac's Keychain; nothing shows as you type):" % cfg["resi_user"]) if subprocess.run(["security", "add-generic-password", "-U", "-a", cfg["resi_user"], "-s", KEYCHAIN_SERVICE, "-w"]).returncode != 0: raise SystemExit("Could not save the password to the Keychain.") try: resi = Resi(cfg["resi_user"], keychain_password(cfg["resi_user"])) resi.login() break except SystemExit as e: if "rejected" not in str(e): raise print("Resi rejected that password — try again.") subprocess.run(["security", "delete-generic-password", "-a", cfg["resi_user"], "-s", KEYCHAIN_SERVICE], capture_output=True) found = [] for v in resi.venues(): for p in resi.players(v.get("uuid")): found.append(p) if not found: raise SystemExit("This Resi account has no decoders.") print("\nDecoders on this account:") for i, p in enumerate(found, 1): print(" %d) %-28s now at %s" % (i, p.get("userName"), p.get("position"))) current = next((i for i, p in enumerate(found, 1) if p.get("playerHardwareId") == cfg["player"]), 1) while True: pick = ask("Which decoder feeds this campus", str(current)) if pick.isdigit() and 1 <= int(pick) <= len(found): break cfg["player"] = found[int(pick) - 1].get("playerHardwareId") print("\n-- ProPresenter --") print("Needs Settings -> Network -> Enable Network turned on; the port is shown there.") while True: addr = ask("ProPresenter address (IP:port)", cfg["propresenter"].replace("http://", "")) base = addr if addr.startswith("http") else "http://" + addr try: with urllib.request.urlopen(base.rstrip("/") + "/v1/timers", timeout=8) as r: names = [t.get("id", {}).get("name") for t in json.loads(r.read())] break except Exception as e: print("Can't reach ProPresenter at %s (%s)." % (base, e)) cfg["propresenter"] = base.rstrip("/") print("Timers in ProPresenter: %s" % ", ".join(map(str, names))) while True: cfg["timer_name"] = ask("Timer to drive", cfg["timer_name"]) if cfg["timer_name"] in names: break input("No timer called '%s'. In ProPresenter, add a Countdown timer with exactly that " "name, then press Return. " % cfg["timer_name"]) with urllib.request.urlopen(cfg["propresenter"] + "/v1/timers", timeout=8) as r: names = [t.get("id", {}).get("name") for t in json.loads(r.read())] print("\nThe countdown always runs. Optionally the next cue's name can also go up as a") print("ProPresenter stage message (shows wherever a stage layout has a Stage Message box).") while True: yn = ask("Show cue names as a stage message? (y/n)", "y" if cfg["stage_message"] else "n") if yn.lower() in ("y", "yes", "n", "no"): break cfg["stage_message"] = yn.lower().startswith("y") with open(CONFIG_PATH, "w") as fh: json.dump(cfg, fh, indent=2) print("\nSaved %s" % CONFIG_PATH) 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") ap.add_argument("--setup", action="store_true", help="ask for this site's settings and save them") args = ap.parse_args() if args.setup: try: setup() except (KeyboardInterrupt, EOFError): raise SystemExit("\nsetup cancelled") return cfg = load_config() if args.list: resi = Resi(cfg["resi_user"], keychain_password(cfg["resi_user"])) resi.login() do_list(resi) return try: run(cfg, args.dry_run) except KeyboardInterrupt: print("\nstopped") if __name__ == "__main__": main()