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>
163 lines
5.3 KiB
Python
163 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""The stage message must go up once per cue, not on every resync or pause/resume.
|
|
|
|
ProPresenter re-shows (flashes) the stage message on every PUT, even with the same text.
|
|
The bridge used to re-send it every resync_seconds and on each play/pause flip, so a
|
|
stalling Resi feed made the stage message flash over and over.
|
|
|
|
Runs the real loop against a FAKE ProPresenter on localhost -- the real one is never
|
|
touched. About 40 seconds.
|
|
|
|
python3 test_stage_message.py
|
|
"""
|
|
import http.server
|
|
import json
|
|
import threading
|
|
import time
|
|
|
|
import resi_to_propresenter as R
|
|
|
|
CALLS = []
|
|
|
|
|
|
class FakePP(http.server.BaseHTTPRequestHandler):
|
|
def _ok(self):
|
|
n = int(self.headers.get("Content-Length") or 0)
|
|
body = self.rfile.read(n) if n else b""
|
|
CALLS.append((time.time(), self.command, self.path, body.decode()))
|
|
reply = b""
|
|
if self.path == "/v1/timers": # the startup check looks for the timer by name
|
|
reply = json.dumps([{"id": {"name": "NextCue"}}]).encode()
|
|
self.send_response(200)
|
|
self.end_headers()
|
|
self.wfile.write(reply)
|
|
|
|
do_GET = do_PUT = do_DELETE = _ok
|
|
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
|
|
t0 = time.time()
|
|
|
|
|
|
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 = "C"
|
|
|
|
def login(self):
|
|
pass
|
|
|
|
def venues(self):
|
|
return [{"uuid": "V1"}]
|
|
|
|
def players(self, venue_uuid):
|
|
media = 10 * 60000 + int(time.time() - t0) * 1000 # moves every second
|
|
return [{"playerHardwareId": "DEC1", "userName": "Test", "position": fmt(media),
|
|
"eventId": "EV1"}]
|
|
|
|
def event(self, event_id):
|
|
return {"name": "SIM", "eventProfileId": "P1"}
|
|
|
|
def cues(self, profile_id, event_id):
|
|
return [{"name": "Message", "ms": 30 * 60000}] # one cue, far away
|
|
|
|
|
|
def run_for(seconds, stage_message, flap):
|
|
"""Run the real loop; with flap, playback reads as paused/playing every 3s --
|
|
what a stalling Resi feed looks like to the loop."""
|
|
del CALLS[:]
|
|
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), FakePP)
|
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
|
|
real_read = R.Playhead.read
|
|
|
|
def flapping(self):
|
|
est, playing, cadence = real_read(self)
|
|
if flap and int(time.time() - t0) // 3 % 2:
|
|
return est, False, cadence
|
|
return est, playing, cadence
|
|
|
|
R.Playhead.read = flapping
|
|
R.Resi = FakeResi
|
|
R.keychain_password = lambda account: "x"
|
|
cfg = dict(R.DEFAULTS)
|
|
cfg.update({"resi_user": "t@example.com", "player": "DEC1",
|
|
"propresenter": "http://127.0.0.1:%d" % server.server_address[1],
|
|
"timer_name": "NextCue", "poll_seconds": 0.5, "resync_seconds": 2,
|
|
"stage_message": stage_message})
|
|
# The loop's only exit is an exception from its sleep; BaseException gets past its
|
|
# catch-all, so the end of the scenario stops it cleanly.
|
|
stop = threading.Event()
|
|
|
|
class Done(BaseException):
|
|
pass
|
|
|
|
def sleep(s):
|
|
if stop.wait(s):
|
|
raise Done()
|
|
|
|
def worker():
|
|
try:
|
|
R.run(cfg, False)
|
|
except Done:
|
|
pass
|
|
|
|
R.time = type("T", (), {"time": staticmethod(time.time), "sleep": staticmethod(sleep),
|
|
"strftime": staticmethod(time.strftime)})
|
|
th = threading.Thread(target=worker, daemon=True)
|
|
th.start()
|
|
time.sleep(seconds)
|
|
stop.set()
|
|
th.join(5)
|
|
R.time = time
|
|
R.Playhead.read = real_read
|
|
server.shutdown()
|
|
return list(CALLS)
|
|
|
|
|
|
def count(calls, method, path):
|
|
return sum(1 for c in calls if c[1] == method and c[2] == path)
|
|
|
|
|
|
def main():
|
|
failures = []
|
|
|
|
calls = run_for(12, stage_message=True, flap=False)
|
|
arms = count(calls, "PUT", "/v1/timer/NextCue/start")
|
|
msgs = count(calls, "PUT", "/v1/stage/message")
|
|
print("steady play, resync every 2s: %d timer pushes, %d stage-message sends" % (arms, msgs))
|
|
if arms < 4:
|
|
failures.append("sanity: the timer should have been resynced several times (%d)" % arms)
|
|
if msgs != 1:
|
|
failures.append("steady play sent the stage message %d times, want 1" % msgs)
|
|
|
|
calls = run_for(16, stage_message=True, flap=True)
|
|
stops = count(calls, "GET", "/v1/timer/NextCue/stop")
|
|
msgs = count(calls, "PUT", "/v1/stage/message")
|
|
print("pause/resume every 3s: %d timer stops, %d stage-message sends" % (stops, msgs))
|
|
if stops < 2:
|
|
failures.append("sanity: the flapping should have stopped the timer repeatedly (%d)" % stops)
|
|
if msgs != 1:
|
|
failures.append("pause/resume sent the stage message %d times, want 1" % msgs)
|
|
|
|
calls = run_for(8, stage_message=False, flap=True)
|
|
touched = [c for c in calls if c[2] == "/v1/stage/message"]
|
|
print("stage_message off: %d stage-message calls" % len(touched))
|
|
if touched:
|
|
failures.append("stage_message off still touched the stage message: %s" % touched)
|
|
|
|
if failures:
|
|
raise SystemExit("\nFAILED:\n - " + "\n - ".join(failures))
|
|
print("\nstage message tests passed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|