Resi Cues: the countdown to the next cue, on the board and as a timer #22
@@ -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:
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user