The Resi -> ProPresenter bridge, its installer and site kit, and the Companion module resi-studio-cues, as running at North Attleboro. Bridge (resi_to_propresenter.py) - Follows a Resi decoder's playhead and keeps ProPresenter's NextCue timer counting down to the next cue; optional cue name as a stage message, sent once per cue (ProPresenter flashes it on every send) and off by default. - Playhead estimate no longer learns a cadence from the partial first gap after a start, so a 30s Resi doesn't read as paused half the time. - Backs off on 429, holds 2 min before retrying a failed startup login, and logs Resi's actual reason (wrong password, USER_LOCKED, rate limit). - --setup asks for the login, decoder, ProPresenter and timer. install_bridge.sh installs it as a login service (outside ~/Documents, which launchd may not read); make_kit.sh builds the kit for another site. Companion module resi-studio-cues 0.1.4 (source/, package committed) - Same playhead fix; login and request hold-off (15s doubling to 5 min); a 401/403 on a fresh session is a permission, not a reason to sign in again; init never fails on a Resi problem, so Companion can't restart it into a login loop. Tests: test_playhead.py, test_stage_message.py, test_rate_limit.py and source/backoff_test.js run offline; test_end_to_end.py drives a real ProPresenter and refuses to run without an explicit flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
268 lines
10 KiB
Python
268 lines
10 KiB
Python
#!/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 run_short_gaps_then_slow(start_into_cycle, burst):
|
|
"""Seen live at North Attleboro: Resi publishing every ~30s, but the bridge started
|
|
a few seconds before a publish, so its first measured "gap" was 2s (and 9s after a
|
|
reboot). Once that was the learned cadence, every honest 30s gap was thrown out as a
|
|
pause gap, so playback was declared paused 15s into every update -- the timer
|
|
stopped and the next cue (and stage message) froze half the time."""
|
|
w = World(30000, 1000, 2000, 500)
|
|
R.now_ms = lambda: w.t
|
|
w.advance(start_into_cycle) # the bridge starts partway through Resi's cycle
|
|
head = R.Playhead()
|
|
|
|
def second():
|
|
w.advance(1000)
|
|
head.note(w.fetch())
|
|
return head.read()
|
|
|
|
startup_paused = sum(1 for _ in range(100) if not second()[1])
|
|
eq(startup_paused, 0, "not called paused while the cadence is still being learned")
|
|
if burst:
|
|
w.media += 15 * 60000 # a seek, published at once...
|
|
w.last_publish = w.t - w.next_cadence
|
|
second()
|
|
second()
|
|
w.last_publish = w.t - w.next_cadence # ...and again 2s later
|
|
second()
|
|
|
|
paused = 0
|
|
for _ in range(300):
|
|
_, playing, _ = second()
|
|
if not playing:
|
|
paused += 1
|
|
eq(paused, 0, "short early gaps do not make every later 30s gap look like a pause")
|
|
|
|
|
|
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 len(sys.argv) == 1:
|
|
for label, into, burst in (("started 2s before a 30s publish", 28000, False),
|
|
("started 9s before a 30s publish", 21000, False),
|
|
("2s burst after a seek, 30s cadence", 0, True)):
|
|
start = len(FAILURES)
|
|
run_short_gaps_then_slow(into, burst)
|
|
n = len(FAILURES) - start
|
|
bad += n
|
|
print(" %-41s %s" % (label, "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()
|