diff --git a/README.md b/README.md index 7b60751..ebcc065 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,12 @@ board — a plan, its team members, birthdays and photos — run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of the [module guide](docs/MODULE-GUIDE.md). +One folder under `modules/` is not a tile: **`resi-studio-cues/`** is a standalone +helper that puts a countdown to the next Resi cue on the stage screen, by driving a +ProPresenter timer directly. It has no `module.json`, so the shell's scan skips it and +it never appears in the + picker. It is documented in its own folder rather than +above, because there is nothing here to install or configure. + ## Install as a desktop web app Open the page in a browser first, then: diff --git a/modules/resi-studio-cues/README.md b/modules/resi-studio-cues/README.md new file mode 100644 index 0000000..518970a --- /dev/null +++ b/modules/resi-studio-cues/README.md @@ -0,0 +1,100 @@ +# 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 `; 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`. diff --git a/modules/resi-studio-cues/config.example.json b/modules/resi-studio-cues/config.example.json new file mode 100644 index 0000000..a79bfee --- /dev/null +++ b/modules/resi-studio-cues/config.example.json @@ -0,0 +1,11 @@ +{ + "resi_user": "you@yourchurch.org", + "player": "", + "propresenter": "http://10.1.14.225:1025", + "timer_name": "NextCue", + "stage_message": true, + "poll_seconds": 1.0, + "cue_refresh_seconds": 5, + "resync_seconds": 30, + "position_trim_ms": 0 +} diff --git a/modules/resi-studio-cues/install-agent.command b/modules/resi-studio-cues/install-agent.command new file mode 100755 index 0000000..d3eb9c7 --- /dev/null +++ b/modules/resi-studio-cues/install-agent.command @@ -0,0 +1,66 @@ +#!/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" < + + + + Label$LABEL + ProgramArguments + + /usr/bin/python3 + $SCRIPT + + EnvironmentVariables + + RESI_PP_CONFIG$CONFIG_DIR/config.json + + WorkingDirectory$CONFIG_DIR + RunAtLoad + KeepAlive + ThrottleInterval30 + ProcessTypeBackground + StandardOutPath$LOG + StandardErrorPath$LOG + + +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\"" diff --git a/modules/resi-studio-cues/resi_to_propresenter.py b/modules/resi-studio-cues/resi_to_propresenter.py new file mode 100644 index 0000000..5a40290 --- /dev/null +++ b/modules/resi-studio-cues/resi_to_propresenter.py @@ -0,0 +1,512 @@ +#!/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() diff --git a/modules/resi-studio-cues/test_end_to_end.py b/modules/resi-studio-cues/test_end_to_end.py new file mode 100644 index 0000000..5c8a6ad --- /dev/null +++ b/modules/resi-studio-cues/test_end_to_end.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""End-to-end: the real script loop, a simulated Resi, and the REAL ProPresenter. + +Everything except the Resi network calls is the shipping code path — event handling, +cue refresh, the estimator, and the ProPresenter pushes. Runs on the wall clock for +~30 seconds, then puts ProPresenter back how it found it. + + python3 test_end_to_end.py http://10.1.14.225:1025 +""" +import json +import sys +import threading +import time +import urllib.request + +import resi_to_propresenter as R + +PP = sys.argv[1] if len(sys.argv) > 1 else "http://10.1.14.225:1025" +TIMER = "NextCue" + +t0 = time.time() +START_MEDIA = 10 * 60000 # decoder starts 10 minutes into the event +CADENCE = 5.0 # Resi republishes every 5s +LAG = 0.6 # and each reading is 600ms stale +_published = [START_MEDIA - LAG * 1000, t0] + +CUES = [ + {"name": "TEST Cue A", "ms": START_MEDIA + 10_000}, + {"name": "TEST Cue B", "ms": START_MEDIA + 22_000}, +] + + +def true_media(): + return START_MEDIA + (time.time() - t0) * 1000 + + +def published(): + if time.time() - _published[1] >= CADENCE: + _published[0] = true_media() - LAG * 1000 + _published[1] = time.time() + return _published[0] + + +def fmt(ms): + ms = int(max(0, ms)) + return "%02d:%02d:%02d.%03d" % (ms // 3600000, (ms % 3600000) // 60000, + (ms % 60000) // 1000, ms % 1000) + + +class FakeResi: + def __init__(self, *a, **k): + self.customer_id = "CUST" + + def login(self): + R.log("FakeResi: signed in") + + def venues(self): + return [{"uuid": "V1", "userName": "Test Venue"}] + + def players(self, venue_uuid): + return [{"playerHardwareId": "DEC1", "userName": "Test Decoder", + "position": fmt(published()), "eventId": "EV1"}] + + def event(self, event_id): + return {"name": "SIMULATED EVENT", "eventProfileId": "P1"} + + def cues(self, profile_id, event_id): + return [dict(c) for c in CUES] + + +def pp_state(): + with urllib.request.urlopen(PP + "/v1/timers/current", timeout=8) as r: + timers = json.loads(r.read()) + mine = next((t for t in timers if t.get("id", {}).get("name") == TIMER), None) + try: + with urllib.request.urlopen(PP + "/v1/stage/message", timeout=8) as r: + msg = json.loads(r.read() or b'""') + except Exception: + msg = None + return mine, msg + + +def main(): + R.Resi = FakeResi + R.keychain_password = lambda account: "not-used" + cfg = dict(R.DEFAULTS) + cfg.update({"resi_user": "test@example.com", "player": "DEC1", "propresenter": PP, + "timer_name": TIMER, "poll_seconds": 1.0, "resync_seconds": 30}) + + before, _ = pp_state() + print("ProPresenter before: %s\n" % (before,)) + + t = threading.Thread(target=R.run, args=(cfg, False), daemon=True) + t.start() + + failures = [] + seen_a = seen_b = False + deadline = time.time() + 30 + while time.time() < deadline: + time.sleep(3) + timer, msg = pp_state() + remaining = None + if timer: + hh, mm, ss = (int(x) for x in timer["time"].split(":")) + remaining = hh * 3600 + mm * 60 + ss + print(" t+%-4.0fs decoder at %s PP timer %s (%s) message %r" + % (time.time() - t0, fmt(true_media()), + timer["time"] if timer else "?", timer["state"] if timer else "?", msg)) + if msg == "TEST Cue A": + seen_a = True + expected = (CUES[0]["ms"] - true_media()) / 1000.0 + if remaining is not None and expected > 1 and abs(remaining - expected) > 3: + failures.append("Cue A countdown off by %.1fs (PP said %ss, real %.1fs)" + % (remaining - expected, remaining, expected)) + if msg == "TEST Cue B": + seen_b = True + expected = (CUES[1]["ms"] - true_media()) / 1000.0 + if remaining is not None and expected > 1 and abs(remaining - expected) > 3: + failures.append("Cue B countdown off by %.1fs" % (remaining - expected)) + + if not seen_a: + failures.append("never armed for Cue A") + if not seen_b: + failures.append("never rolled over to Cue B") + + print("\ncleaning up...") + for path, method in (("/v1/timer/%s/stop" % TIMER, "GET"), + ("/v1/timer/%s/reset" % TIMER, "GET"), + ("/v1/stage/message", "DELETE")): + try: + urllib.request.urlopen( + urllib.request.Request(PP + path, method=method), timeout=8).close() + except Exception as e: + print(" cleanup %s failed: %s" % (path, e)) + + if failures: + print("\nFAILED:") + for f in failures: + print(" - %s" % f) + raise SystemExit(1) + print("\nend-to-end OK: armed for both cues, countdown matched the decoder") + + +if __name__ == "__main__": + main() diff --git a/modules/resi-studio-cues/test_playhead.py b/modules/resi-studio-cues/test_playhead.py new file mode 100644 index 0000000..526379a --- /dev/null +++ b/modules/resi-studio-cues/test_playhead.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Drive resi_to_propresenter.Playhead against a simulated Resi on a fake clock. + +Mirrors the scenarios the JavaScript estimator was validated against, so the Python +port is held to the same standard rather than assumed correct. + + python3 test_playhead.py # the whole matrix + python3 test_playhead.py 7000 1000 4000 2000 # one profile +""" +import sys + +import resi_to_propresenter as R + +FAILURES = [] + + +def check(cond, msg): + if not cond: + FAILURES.append(msg) + print(" FAIL %s" % msg) + + +def eq(got, want, msg): + check(got == want, "%s (got %r, want %r)" % (msg, got, want)) + + +def near(got, want, tol, msg): + check(abs(got - want) <= tol, "%s (got %d, want %d +/-%d)" % (msg, got, want, tol)) + + +class World: + """A decoder playing at 1x, and a Resi that republishes its position on a coarse, + irregular cadence -- each reading already stale when it appears.""" + + def __init__(self, cadence, lag, jitter, net): + self.cadence, self.lag, self.jitter, self.net = cadence, lag, jitter, net + self.t = 1_700_000_000_000 + self.media = 8 * 60000 + 48000 + self.published = self.media - lag + self.last_publish = self.t + self.next_cadence = cadence + self.playing = True + self.seed = 12345 + + def rnd(self): + self.seed = (self.seed * 1103515245 + 12345) & 0x7FFFFFFF + return self.seed / 0x7FFFFFFF + + def advance(self, ms): + for _ in range(0, int(ms), 100): + self.t += 100 + if self.playing: + self.media += 100 + if self.t - self.last_publish >= self.next_cadence: + self.last_publish = self.t + self.published = self.media - self.lag + self.next_cadence = max(500, self.cadence + round((self.rnd() - 0.5) * 2 * self.jitter)) + + def fetch(self): + """What a poll sees, and the round trip it costs.""" + body = self.published + if self.net: + self.advance(100 + round(self.rnd() * self.net)) + return body + + +def run_profile(cadence, lag, jitter, net): + w = World(cadence, lag, jitter, net) + R.now_ms = lambda: w.t + head = R.Playhead() + tol = lag + 1200 + jitter + net * 0.35 + + def second(): + w.advance(1000) + head.note(w.fetch()) + return head.read() + + def seconds(n): + out = (None, False, 0) + for _ in range(n): + out = second() + return out + + cues = [ + {"name": "Welcome", "ms": 5 * 60000}, + {"name": "Campus Host", "ms": 12 * 60000 + 30000}, + {"name": "Message", "ms": 41 * 60000}, + {"name": "Closing", "ms": 45 * 60000}, + ] + + def next_cue(est): + return next((c for c in cues if c["ms"] > est), None) + + # ---- accuracy while playing -------------------------------------------- + est, playing, _ = seconds(40) + check(playing, "reported as playing") + err = w.media - est + check(err <= tol, "estimate is not lagging (behind by %dms)" % err) + check(err >= -300, "estimate does not run ahead (%dms)" % err) + + worst_est, worst_raw = 0, 0 + for _ in range(30): + est, _, _ = second() + worst_est = max(worst_est, w.media - est) + worst_raw = max(worst_raw, w.media - w.published) + if cadence >= 2000: + check(worst_raw >= cadence * 0.4, + "sanity: the raw Resi value really does go stale (%dms)" % worst_raw) + check(worst_est <= tol, + "the estimate stays close all cycle (worst %dms vs raw %dms)" % (worst_est, worst_raw)) + + # ---- never stalls, never runs backwards, over a long run ---------------- + prev, stalls, backwards = est, 0, 0 + for _ in range(200): + est, _, _ = second() + if est == prev: + stalls += 1 + if est < prev: + backwards += 1 + prev = est + eq(stalls, 0, "no stalled seconds over 200s") + eq(backwards, 0, "never goes backwards over 200s") + + # ---- a fired cue: the decoder jumps, Resi says nothing until it republishes + w.media = 40 * 60000 + 50000 + saw = False + for _ in range(60): + est, playing, _ = second() + if head.last_media is not None and abs(head.last_media - w.media) < 8000: + saw = True + break + check(saw, "the jump is picked up") + check(head.take_seek() or True, "seek flag is readable") + near(est, w.media, tol + cadence / 2, "estimate resyncs to the new playhead") + check(w.media - est >= -400, "no overshoot after a seek (%dms)" % (w.media - est)) + c = next_cue(est) + eq(c["name"] if c else None, "Message", "next cue is right after the jump") + + est, _, _ = seconds(15) + c = next_cue(est) + eq(c["name"] if c else None, "Closing", "rolled over after passing Message") + + # ---- a backwards seek --------------------------------------------------- + w.media = 6 * 60000 + for _ in range(60): + est, _, _ = second() + if head.last_media is not None and abs(head.last_media - w.media) < 8000: + break + near(est, w.media, tol + cadence / 2, "follows a backwards seek") + c = next_cue(est) + eq(c["name"] if c else None, "Campus Host", "cue target recomputes after seeking back") + + # ---- pause -------------------------------------------------------------- + w.playing = False + wait = int((max(15000, min(45000, (cadence + jitter) * 2.5 + 6000)) + cadence + jitter + 6000) / 1000) + est, playing, _ = seconds(wait) + check(not playing, "detects a real pause") + held = est + est, playing, _ = seconds(20) + eq(est, held, "paused playhead holds instead of running on") + + # ---- resume ------------------------------------------------------------- + w.playing = True + est, playing, _ = seconds(25) + check(playing, "recovers after resume") + near(est, w.media, tol + cadence, "re-locks after resume") + + # ---- a pause longer than the offset window, then resume ----------------- + w.playing = False + media_at_pause = w.media + est, playing, _ = seconds(150) + check(not playing, "still paused after 150s") + check(est is not None, "holds a position through a pause longer than the window") + # It keeps extrapolating until the pause is provable, then freezes. That overshoot is + # bounded by the pause-detection window -- it must not keep creeping for 150 seconds. + # The last in-flight reading can land a full cadence after playback actually stopped, + # and only then does the pause clock start -- so the bound is that plus the detection + # window plus a poll. What this pins is that it STOPS, rather than creeping for 150s. + pause_after = max(15000, min(45000, (cadence + jitter) * 2.5 + 6000)) + creep_bound = pause_after + cadence + jitter + 2 * net + 3000 + check(est - media_at_pause <= creep_bound, + "the frozen position stops creeping (%dms past where playback stopped, bound %dms)" + % (est - media_at_pause, creep_bound)) + w.playing = True + head.take_seek() + est, playing, _ = seconds(30) + check(playing, "recovers after a very long pause") + near(est, w.media, tol + cadence, "re-locks after a very long pause") + # Resuming is a discontinuity like any other: it has to be flagged, or the caller + # never knows to re-read the cue list at the moment playback restarts. + check(head.seeked or head.take_seek(), "a resume after a long pause is flagged as a seek") + + # ---- the harness must be able to fail ----------------------------------- + before = len(FAILURES) + eq(next_cue(est), "definitely not a cue", "deliberate failure") + if len(FAILURES) != before + 1: + raise SystemExit("HARNESS BROKEN: a false assertion passed") + FAILURES.pop() + + +def main(): + if len(sys.argv) > 1: + profiles = [tuple(int(x) for x in sys.argv[1:5])] + else: + profiles = [ + (5000, 500, 0, 0), (5000, 500, 0, 2500), (2000, 0, 0, 800), + (10000, 3000, 0, 2500), (7000, 1000, 4000, 2000), (3000, 500, 2000, 1000), + (12000, 2000, 6000, 3000), (1000, 0, 0, 0), (8000, 1500, 3000, 4000), + ] + bad = 0 + for cadence, lag, jitter, net in profiles: + start = len(FAILURES) + run_profile(cadence, lag, jitter, net) + n = len(FAILURES) - start + bad += n + print(" cadence=%-6s lag=%-5s jitter=%-5s net=%-5s %s" + % (cadence, lag, jitter, net, "OK" if n == 0 else "%d FAILED" % n)) + if bad: + raise SystemExit("\n%d failure(s)" % bad) + print("\nall playhead tests passed across %d Resi profiles" % len(profiles)) + + +if __name__ == "__main__": + main()