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:
2026-09-24 11:35:40 -04:00
co-authored by Claude Opus 5
parent 098562c088
commit 82c552955d
7 changed files with 1064 additions and 0 deletions
+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()