Resi cue countdowns reach the stage screen without Companion
The ask was a Stream Deck button showing the time until the next Resi cue. What it turned into is simpler: ProPresenter already renders countdowns, and it already has an HTTP API, so nothing has to sit in between except something that knows when the next cue is. `resi_to_propresenter.py` watches a decoder's playhead and keeps a ProPresenter countdown timer armed with the seconds until its next cue, with the cue's name as a stage message beside it. Both halves of that come from Resi's cloud rather than the decoder — the decoder reports its position upstream and `central.resi.io` hands it back — so there is nothing to install at the decoder end and nothing polling it every second. The playhead is estimated rather than repeated. Resi republishes `position` only every few seconds, irregularly, and each reading is already stale when it appears; sent straight through, the countdown sat three seconds behind and lurched. So each reading is kept as media time minus wall time and the largest recent value is trusted — the reading that arrived with the least delay — which a slow round trip can no longer drag backwards. A reading outside the link's measured noise floor counts as a seek: drop the lock, snap, and re-read the cue list immediately instead of on its timer. Playback is only called paused once the playhead has been still for longer than Resi's own cadence, because a fixed timeout mistakes a slow update for a stop and freezes the countdown mid-service. This is not a tile. There is no `module.json`, so `readManifests` skips the folder and it never reaches the + picker; it lives under `modules/` because that is where the production tooling lives. That is also why it carries a README when no real module does — there is no *Included modules* entry it could have instead — and the repo README says so where the module list ends. `install-agent.command` sets up a LaunchAgent so it runs from login and restarts if it stops. It deliberately runs a copy in Application Support: a login agent cannot read `~/Documents` without Full Disk Access, and macOS answers "Operation not permitted" rather than asking. Settings live there too, outside the checkout, the same way module config does. The Resi password is read from the Keychain and never written to disk; with none set the agent says so once and waits rather than relaunching in a loop. `test_playhead.py` drives the estimator on a fake clock against nine simulated Resi profiles — update cadences of one to twelve seconds, staleness to three, irregular timing, variable round trips — and six of seven deliberate breakages of the estimator turn it red. `test_end_to_end.py` runs the real loop against a real ProPresenter with a simulated decoder, and has: it armed for the first cue, counted down in step, rolled over to the second and stopped when the cues ran out. Neither covers the live Resi calls, which need real credentials. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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