Resi cue countdowns on ProPresenter and in Companion
The Resi -> ProPresenter bridge, its installer and site kit, and the Companion module resi-studio-cues, as running at North Attleboro. Bridge (resi_to_propresenter.py) - Follows a Resi decoder's playhead and keeps ProPresenter's NextCue timer counting down to the next cue; optional cue name as a stage message, sent once per cue (ProPresenter flashes it on every send) and off by default. - Playhead estimate no longer learns a cadence from the partial first gap after a start, so a 30s Resi doesn't read as paused half the time. - Backs off on 429, holds 2 min before retrying a failed startup login, and logs Resi's actual reason (wrong password, USER_LOCKED, rate limit). - --setup asks for the login, decoder, ProPresenter and timer. install_bridge.sh installs it as a login service (outside ~/Documents, which launchd may not read); make_kit.sh builds the kit for another site. Companion module resi-studio-cues 0.1.4 (source/, package committed) - Same playhead fix; login and request hold-off (15s doubling to 5 min); a 401/403 on a fresh session is a permission, not a reason to sign in again; init never fails on a Resi problem, so Companion can't restart it into a login loop. Tests: test_playhead.py, test_stage_message.py, test_rate_limit.py and source/backoff_test.js run offline; test_end_to_end.py drives a real ProPresenter and refuses to run without an explicit flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,647 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user