Files
Nextcue/test_end_to_end.py
christianandClaude Opus 5.5 1a658875bc Resi cue countdowns on ProPresenter and in Companion
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>
2026-10-06 18:11:01 -04:00

152 lines
5.1 KiB
Python

#!/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 --touch-live-propresenter
It TAKES OVER that ProPresenter's NextCue timer and stage message for ~30s (showing
"TEST Cue A/B"), so only run it when nothing is on stage, and stop the installed bridge
first -- two writers on one timer make this test fail and the stage flicker.
"""
import json
import sys
import threading
import time
import urllib.request
import resi_to_propresenter as R
if len(sys.argv) < 3 or sys.argv[2] != "--touch-live-propresenter":
raise SystemExit(__doc__)
PP = sys.argv[1]
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()