Resi Cues: the countdown to the next cue, on the board and as a timer #22

Merged
christian merged 2 commits from claude/resi-stage-cues into main 2026-09-24 12:19:51 -04:00
7 changed files with 1064 additions and 0 deletions
Showing only changes of commit 82c552955d - Show all commits
+6
View File
@@ -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:
+100
View File
@@ -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 <cookie>`; 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`.
@@ -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
}
+66
View File
@@ -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" <<PLIST_EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>$SCRIPT</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>RESI_PP_CONFIG</key><string>$CONFIG_DIR/config.json</string>
</dict>
<key>WorkingDirectory</key><string>$CONFIG_DIR</string>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>30</integer>
<key>ProcessType</key><string>Background</string>
<key>StandardOutPath</key><string>$LOG</string>
<key>StandardErrorPath</key><string>$LOG</string>
</dict>
</plist>
PLIST_EOF
launchctl bootout "gui/$UID/$LABEL" 2>/dev/null || true
launchctl bootstrap "gui/$UID" "$PLIST"
launchctl enable "gui/$UID/$LABEL" 2>/dev/null || true
echo
echo "Installed. It starts at login and restarts itself if it stops."
echo " script $SCRIPT (a copy — re-run this after a git pull)"
echo " config $CONFIG_DIR/config.json"
echo " log $LOG"
echo
echo "Watch it with: tail -f \"$LOG\""
echo "Stop it with: launchctl bootout gui/$UID/$LABEL"
echo "Start it again: launchctl bootstrap gui/$UID \"$PLIST\""
@@ -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()
+145
View File
@@ -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()
+224
View File
@@ -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()