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>
This commit is contained in:
2026-10-06 18:11:01 -04:00
co-authored by Claude Opus 5.5
commit 1a658875bc
24 changed files with 4548 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# Site settings: `resi_to_propresenter.py --setup` writes this on the Mac that runs
# the bridge. The Resi password is never in it (it lives in that Mac's Keychain).
resi_pp_config.json
# Built by make_kit.sh
resi-propresenter-kit.zip
# Superseded Companion module packages, kept locally only
older versions/
__pycache__/
.DS_Store
+116
View File
@@ -0,0 +1,116 @@
# Resi → ProPresenter countdown
Shows a countdown to the next Resi cue on ProPresenter's stage display, and optionally
the cue's name as a stage message. ProPresenter does the counting, so it's smooth; a small
background service on a Mac keeps it pointed at the right cue.
```
Resi (cloud) ──► always-on Mac (this service) ──► ProPresenter (timer, optional stage message)
```
It talks to Resi directly. Companion isn't needed for the countdown; the Companion
module in this kit is optional, for buttons and Stream Deck displays.
## What you need
- **An always-on Mac** on the same network as ProPresenter, ideally one that logs in
automatically (an ingest or utility Mac). It can be the ProPresenter Mac itself.
- **A Resi login** that can see the campus's decoder.
- **ProPresenter 7.9 or newer.**
## 1. Set up ProPresenter (once per site)
1. **Settings → Network**: turn on **Enable Network**. Note the IP address and port.
2. **Timers**: add a **Countdown** timer named **`NextCue`** (any length; it gets overwritten).
3. **Stage layout**: in the layout your stage screen uses, add a **Timer** object set to
**NextCue**. Add a **Stage Message** object only if you'll turn cue-name messages on.
## 2. Install the service (on the always-on Mac)
Copy this folder onto that Mac, open **Terminal**, and run:
```bash
zsh ~/Desktop/resi-propresenter-kit/install_bridge.sh
```
(adjust the path to wherever you put the folder). It asks for:
1. **Resi login email and password.** The password goes in that Mac's Keychain, not a file.
2. **Which decoder** feeds this campus, picked from a numbered list.
3. **ProPresenter's address**, e.g. `192.168.1.50:1025`. It checks it can reach it.
4. **Which timer** to drive (`NextCue`). It checks the timer exists.
5. **Whether to show cue names as a stage message.** Default is **no**: just the countdown.
If yes, the name is sent once per cue (never repeated), so it won't keep flashing.
Then it starts the service and shows the first lines of its log. You should see
`NextCue -> <cue name> in 00:12:34`. From then on it starts at login, restarts itself if
it stops, and follows whatever event the decoder is playing. Nobody has to do anything on
service day.
If the installer warns that the Mac doesn't log in automatically, turn on **System
Settings → Users & Groups → Automatically log in as …** (FileVault must be off).
Otherwise the countdown won't come back after a reboot until someone logs in.
Run only **one copy per ProPresenter**: two copies fight over the same timer.
## 3. Optional: Companion module
In Companion: **Modules → Import module package** → `resi-studio-cues-0.1.2.tgz`, then add a
**Rey Kroona: Resi Studio Cues** connection. It provides variables such as
`next_cue_name`, `next_cue_time_in_hms` and `playback_state` for buttons.
## Day to day
| To… | Run (on that Mac) |
|---|---|
| Watch what it's doing | `tail -f ~/Library/Logs/resi-propresenter.log` |
| Change decoder / ProPresenter / password / stage message on-off | `zsh install_bridge.sh --setup` |
| Update to a newer script | run `zsh install_bridge.sh` from the new kit (keeps settings) |
| Remove it | `zsh install_bridge.sh --uninstall` |
## If something's wrong
- **`Resi rejected the login`**: the password changed. Run `--setup` again.
- **`Cannot reach ProPresenter`**: ProPresenter is closed, its IP changed, or Network is
off. Run `--setup` to enter the new address.
- **`playback stopped — timer held`**: the decoder isn't moving, or Resi isn't reporting.
It picks back up by itself when playback resumes.
- **Stage shows the cue name but no countdown**: the stage layout's timer object isn't set
to `NextCue`.
- **Over SSH**: the installer asks for that Mac's login password first, to unlock its
Keychain. Remote sessions keep it locked.
Resi's API is undocumented and unsupported, so don't open a Resi ticket about this tool.
## Working on it
| Path | What it is |
|---|---|
| `resi_to_propresenter.py` | The bridge. Python 3, standard library only. |
| `install_bridge.sh` | Installs it as a login service on the Mac it runs on, and runs `--setup` when needed. |
| `make_kit.sh` | Builds `resi-propresenter-kit.zip` for another site (no site settings inside). |
| `source/` | The Companion module `resi-studio-cues` (`dist/` is what ships, plus `manifest.json` and `HELP.md`). |
| `resi-studio-cues-0.1.4.tgz` | The current Companion package: import it in Companion → Modules. |
Tests. All run offline against fakes, except the end-to-end one:
```bash
python3 test_playhead.py # the playhead estimator, nine Resi profiles plus slow-start cases
python3 test_stage_message.py # stage message sent once per cue, never when switched off
python3 test_rate_limit.py # backs off when Resi says "too many requests"
node source/backoff_test.js # Companion module: login hold-off, no crash loop on a locked account
```
`source/tick_test.js` is the Companion module's estimator test (run with JavaScriptCore's
`jsc`, with `var TARGET='dist/main.js'` loaded first). `test_end_to_end.py` drives a **real**
ProPresenter's `NextCue` timer and stage message, so it refuses to run without
`<address> --touch-live-propresenter`. Stop the installed bridge before using it.
The Companion package is `source/dist`, `source/manifest.json` and `source/HELP.md` (under
`companion/`), plus a vendored `node_modules` with `@companion-module/base` 1.14.1
(`source/npm_vendor.py` assembles one). Bump the version in `manifest.json` and the
package's `package.json` on every change.
**Running at North Attleboro:** the bridge, Companion and ProdDash all run on the NA Ingest
Mac, and all three use the `media@waters.church` Resi login. Keep them polling no faster than
every 3 seconds: Resi rate-limits the account when tools share it.
+108
View File
@@ -0,0 +1,108 @@
#!/bin/zsh
# install_bridge.sh — install the Resi -> ProPresenter bridge as a login service on THIS Mac.
#
# Put this folder on the Mac that should run the bridge (one that stays on and logged in),
# then run: zsh install_bridge.sh
#
# First run asks for the Resi login, the decoder, ProPresenter's address and the timer.
# Safe to run again: it keeps those answers, replaces the script and restarts the service.
# zsh install_bridge.sh --setup ask the questions again
# zsh install_bridge.sh --uninstall stop it and remove it from this Mac
set -e
HERE="${0:A:h}"
DEST="$HOME/Library/Application Support/resi-propresenter"
LABEL="org.waterschurch.resi-propresenter"
PLIST="$HOME/Library/LaunchAgents/$LABEL.plist"
LOG="$HOME/Library/Logs/resi-propresenter.log"
if [[ "$1" == "--uninstall" ]]; then
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
rm -f "$PLIST"
echo "Stopped and removed the service. Settings are kept in $DEST (delete it to forget them)."
exit 0
fi
# python3 ships with the Command Line Tools; /usr/bin/python3 is only a stub without them
if ! /usr/bin/python3 -c 'import sys' 2>/dev/null; then
echo "python3 isn't usable yet. Run: xcode-select --install then run this again."
exit 1
fi
# Outside ~/Documents: macOS blocks background services from reading it.
mkdir -p "$DEST" "$HOME/Library/LaunchAgents" "$HOME/Library/Logs"
if [[ ! "$HERE/resi_to_propresenter.py" -ef "$DEST/resi_to_propresenter.py" ]]; then
cat "$HERE/resi_to_propresenter.py" > "$DEST/resi_to_propresenter.py.new"
mv "$DEST/resi_to_propresenter.py.new" "$DEST/resi_to_propresenter.py"
fi
# A config shipped alongside (re-installing a known site) seeds a fresh install only.
if [[ -f "$HERE/resi_pp_config.json" && ! -f "$DEST/resi_pp_config.json" ]]; then
cp "$HERE/resi_pp_config.json" "$DEST/resi_pp_config.json"
fi
complete=$(/usr/bin/python3 - "$DEST/resi_pp_config.json" <<'EOF'
import json, subprocess, sys
try:
c = json.load(open(sys.argv[1]))
except Exception:
print("no"); sys.exit()
ok = c.get("resi_user") and c.get("player") and c.get("propresenter") and c.get("timer_name")
ok = ok and subprocess.run(["security", "find-generic-password", "-a", c["resi_user"],
"-s", "resi-propresenter"], capture_output=True).returncode == 0
print("yes" if ok else "no")
EOF
)
if [[ "$complete" != "yes" || "$1" == "--setup" ]]; then
# Over SSH the login keychain is locked, and saving the Resi password into it fails
# with "User interaction is not allowed" until it is unlocked here.
if [[ -n "$SSH_CONNECTION" ]]; then
echo "Unlock $(scutil --get ComputerName)'s Keychain: enter the Mac login password for $(whoami)."
security unlock-keychain "$HOME/Library/Keychains/login.keychain-db"
fi
/usr/bin/python3 "$DEST/resi_to_propresenter.py" --setup
fi
cat > "$PLIST" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>$LABEL</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>$DEST/resi_to_propresenter.py</string>
</array>
<key>RunAtLoad</key>
<true/>
<!-- restart if it exits: no network yet at login, Resi/ProPresenter down, a crash -->
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>30</integer>
<key>StandardOutPath</key>
<string>$LOG</string>
<key>StandardErrorPath</key>
<string>$LOG</string>
</dict>
</plist>
EOF
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$PLIST"
echo "Installed. Waiting for the first lines of the log..."
sleep 10
tail -n 8 "$LOG"
echo
echo "Running on $(scutil --get ComputerName). Log: $LOG"
# After a reboot the service only starts once someone is logged in.
autologin=$(defaults read /Library/Preferences/com.apple.loginwindow autoLoginUser 2>/dev/null || true)
if [[ "$autologin" != "$(whoami)" ]]; then
echo
echo "NOTE: this Mac doesn't log in to '$(whoami)' automatically, so after a reboot the"
echo "countdown won't start until someone logs in. To make it fully hands-off, turn on"
echo "System Settings -> Users & Groups -> Automatically log in as $(whoami) (needs FileVault off)."
fi
Executable
+19
View File
@@ -0,0 +1,19 @@
#!/bin/zsh
# make_kit.sh — bundle everything another site needs into resi-propresenter-kit.zip.
# Site settings (resi_pp_config.json) are left out on purpose: the installer asks.
set -e
HERE="${0:A:h}"
cd "$HERE"
MODULE=$(ls resi-studio-cues-*.tgz | sort -V | tail -1)
STAGE=$(mktemp -d)
KIT="$STAGE/resi-propresenter-kit"
mkdir "$KIT"
cp install_bridge.sh resi_to_propresenter.py README.md "$MODULE" "$KIT/"
chmod +x "$KIT/install_bridge.sh"
rm -f resi-propresenter-kit.zip
(cd "$STAGE" && COPYFILE_DISABLE=1 zip -qrX "$HERE/resi-propresenter-kit.zip" resi-propresenter-kit)
rm -rf "$STAGE"
echo "Built resi-propresenter-kit.zip:"
unzip -l resi-propresenter-kit.zip | sed -n '4,$p' | sed '$d' | sed '$d'
Binary file not shown.
+647
View File
@@ -0,0 +1,647 @@
#!/usr/bin/env python3
"""
resi_to_propresenter.py — drive a ProPresenter countdown timer from Resi cue points.
Watches a Resi decoder's playhead, works out how long until its next cue, and keeps a
ProPresenter timer armed with that number. ProPresenter renders the countdown itself, so
the stage screen is smooth and styled; this only pokes it when something actually changes.
No Companion, no Stream Deck. Python 3 only — nothing to install.
python3 resi_to_propresenter.py --setup # pick the decoder, ProPresenter and timer
python3 resi_to_propresenter.py # run it
python3 resi_to_propresenter.py --dry-run # watch what it WOULD send
python3 resi_to_propresenter.py --list # show venues/decoders
The Resi password is read from the macOS Keychain, never stored here; --setup saves it
(Keychain service "resi-propresenter", account = resi_user from the config).
Resi's API is undocumented and unsupported; don't open a Resi ticket about it.
"""
import argparse
import http.cookiejar
import json
import math
import os
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
HERE = os.path.dirname(os.path.abspath(__file__))
CONFIG_PATH = os.path.join(HERE, "resi_pp_config.json")
RESI = "https://central.resi.io"
KEYCHAIN_SERVICE = "resi-propresenter"
DEFAULTS = {
"resi_user": "",
"player": "", # playerHardwareId; blank = use the only one found
"propresenter": "", # e.g. http://192.168.1.50:1025 -- set by --setup
"timer_name": "NextCue",
"stage_message": False, # also show the next cue's NAME as a ProPresenter stage message
"poll_seconds": 3.0, # how often to ask Resi where the playhead is. Resi only
# republishes every ~5-30s, and the estimate fills the gaps;
# 1s polling from two tools on one account hit its rate limit.
"cue_refresh_seconds": 5, # how often to re-read the cue list
"resync_seconds": 30, # re-push to ProPresenter even when nothing changed
"position_trim_ms": 0, # only if the whole chain reads consistently behind
}
# --------------------------------------------------------------------------- utilities
def now_ms():
"""Wall clock in milliseconds. A seam so the estimator can be tested on a fake clock."""
return time.time() * 1000
def log(msg):
print("%s %s" % (time.strftime("%H:%M:%S"), msg), flush=True)
def hms(ms):
s = max(0, int(ms // 1000))
return "%02d:%02d:%02d" % (s // 3600, (s % 3600) // 60, s % 60)
def position_to_ms(pos):
"""'01:02:03.500' -> milliseconds, or None."""
try:
hh, mm, rest = pos.split(":")
return int((int(hh) * 3600 + int(mm) * 60 + float(rest)) * 1000)
except (ValueError, AttributeError):
return None
def keychain_password(account):
try:
out = subprocess.run(
["security", "find-generic-password", "-a", account, "-s", KEYCHAIN_SERVICE, "-w"],
capture_output=True, text=True, timeout=10)
except (OSError, subprocess.SubprocessError) as e:
raise SystemExit("Could not run the Keychain helper: %s" % e)
if out.returncode != 0:
raise SystemExit(
"No Resi password in the Keychain for '%s'.\nAdd it once with:\n"
' security add-generic-password -a "%s" -s %s -w'
% (account, account, KEYCHAIN_SERVICE))
return out.stdout.rstrip("\n")
# ------------------------------------------------------------------------------- Resi
class Resi:
def __init__(self, username, password):
self.username, self.password = username, password
self.jar = http.cookiejar.CookieJar()
self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.jar))
self.token = None
self.logged_in_at = 0
self.customer_id = None
self.no_cache_bust = False
def _cookie(self, name):
for c in self.jar:
if c.name == name:
return c.value
return None
def _call(self, method, path, body=None):
url = RESI + path
headers = {"Accept": "application/json", "User-Agent": "resi-to-propresenter/1.0"}
if self.token:
headers["Authorization"] = "X-Bearer " + self.token
data = None
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
with self.opener.open(req, timeout=20) as resp:
raw = resp.read()
return json.loads(raw) if raw else None
def login(self):
try:
self._call("POST", "/api/v3/login?newToken=true",
{"userName": self.username, "password": self.password})
except urllib.error.HTTPError as e:
body = e.read()
if b"INVALID_CREDENTIALS" in body:
raise SystemExit(
"Resi rejected the login for '%s' (wrong password?).\nReplace it with:\n"
' security add-generic-password -U -a "%s" -s %s -w'
% (self.username, self.username, KEYCHAIN_SERVICE))
if b"USER_LOCKED" in body:
raise SystemExit("Resi has locked the account '%s' (USER_LOCKED) — unlock it "
"at studio.resi.io or with a Resi admin" % self.username)
# The body was read above, so say what it was here: it is the only clue.
raise RuntimeError("HTTP %s %s" % (e.code, body[:200].decode("utf-8", "replace")))
self.token = self._cookie("livingas1")
if not self.token:
raise RuntimeError("login succeeded but returned no session cookie")
self.logged_in_at = time.time()
me = self._call("GET", "/api_v2.svc/users/me")
self.customer_id = me.get("customerId")
if not self.customer_id:
raise RuntimeError("this Resi account has no customerId")
log("Resi: signed in as %s (%s)" % (me.get("userName"), me.get("customerName")))
def ensure_session(self):
if not self.token or time.time() - self.logged_in_at > 50 * 60:
self.login()
def get(self, path):
self.ensure_session()
try:
return self._call("GET", path)
except urllib.error.HTTPError as e:
if e.code in (401, 403):
log("Resi: session expired, signing in again")
self.login()
return self._call("GET", path)
raise
def venues(self):
return self.get("/api/v3/customers/%s/venues" % self.customer_id) or []
def players(self, venue_uuid):
path = "/api_v2.svc/users/%s/players" % venue_uuid
if not self.no_cache_bust:
try:
return self.get(path + "?_=%d" % int(time.time() * 1000)) or []
except urllib.error.HTTPError as e:
# 429 is Resi saying "slow down", not an objection to the parameter;
# dropping it on a 429 lost the fresh readings for good.
if 400 <= e.code < 500 and e.code != 429:
self.no_cache_bust = True
log("Resi: dropping the cache-busting parameter (%s)" % e.code)
else:
raise
return self.get(path) or []
def event(self, event_id):
return self.get("/api/v3/customers/%s/events/%s" % (self.customer_id, event_id))
def cues(self, profile_id, event_id):
raw = self.get("/api_v2.svc/streamprofiles/%s/events/%s/cues?canISetCues=1"
% (profile_id, event_id)) or []
out = []
for c in raw:
ms = position_to_ms(c.get("position"))
if ms is not None:
out.append({"name": c.get("name") or "", "ms": ms})
out.sort(key=lambda c: c["ms"])
return out
# -------------------------------------------------------------------------- estimator
class Playhead:
"""Resi republishes `position` only every few seconds, irregularly, and each reading
is already slightly stale. Every reading is kept as media-time-minus-wall-time; the
LARGEST recent value is the least-delayed reading and is the one trusted. The
estimate is then just "now plus that offset", correct to within Resi's own pipeline
lag and smooth between updates."""
WINDOW_MS = 120_000
def __init__(self, trim_ms=0):
self.trim_ms = trim_ms
self.offsets = [] # (arrival_ms, offset_ms)
self.spreads = [] # how far honest readings sit apart: this link's noise
self.gaps = [] # how often the playhead actually moves
self.changes = 0 # readings that differed from the one before
self.rejected = False # the last gap looked like a pause and was skipped
self.base = None
self.last_media = None
self.last_new_at = None
self.last_est = None
self.seeked = False
def note(self, media_ms):
now = now_ms()
if media_ms != self.last_media:
off = media_ms - now
noise = max(self.spreads) if self.spreads else 0
threshold = max(5000, noise * 1.5 + 4000)
if self.base is not None:
delta = off - self.base
if abs(delta) > threshold:
# seek, fired cue, or resume after a pause: the window describes
# the old position and has to go
self.offsets = []
self.seeked = True
if abs(delta) > 10000:
self.last_est = None
elif delta < 0:
self.spreads = (self.spreads + [-delta])[-20:]
# The first reading lands at an arbitrary point in Resi's cycle, so the gap
# from it to the next one is only a fraction of the real cadence. Learning
# from it locked in a 2s cadence that rejected every honest 30s gap.
if self.last_new_at is not None and self.changes > 0:
gap = now - self.last_new_at
# The gap either side of a pause is not Resi's cadence -- recording it
# would inflate the estimate and make the NEXT pause slow to notice.
# But a pause is one gap; twice in a row is the cadence having changed.
if 0 < gap < 60000:
if not self.gaps or gap <= 3 * max(self.gaps) or self.rejected:
self.gaps = (self.gaps + [gap])[-8:]
self.rejected = False
else:
self.rejected = True
if self.last_new_at is not None:
self.changes += 1
self.offsets.append((now, off))
self.last_media = media_ms
self.last_new_at = now
kept = [o for o in self.offsets if now - o[0] <= self.WINDOW_MS]
# Always keep the newest reading even once it is older than the window. If the
# window empties during a long pause there is no offset left to compare against,
# so the resume looks like an ordinary reading instead of a discontinuity.
if not kept and self.offsets:
kept = [self.offsets[-1]]
self.offsets = kept
self.base = max(o[1] for o in self.offsets) if self.offsets else None
def read(self):
"""-> (estimated_media_ms, playing, cadence_ms) or (None, False, 0)."""
if self.last_new_at is None:
return None, False, 0
now = now_ms()
cadence = max(self.gaps) if self.gaps else 0
# Only call it paused once the playhead has been still for clearly longer than
# Resi's own reporting cadence -- a fixed timeout mistakes a slow update for a stop.
# Until a full gap has been measured, the cadence is unknown; assume the slowest
# Resi plausibly is, or a 30s publisher looks paused 15s after every startup.
pause_after = max(15000, min(45000, cadence * 2.5 + 6000)) if cadence else 45000
playing = self.base is not None and (now - self.last_new_at) < pause_after
if not playing:
return (self.last_est if self.last_est is not None else self.last_media), False, cadence
est = now + self.base + self.trim_ms
if self.last_est is not None and est < self.last_est:
est = self.last_est # the window is a max; never run backwards
self.last_est = est
return est, True, cadence
def take_seek(self):
was, self.seeked = self.seeked, False
return was
# ------------------------------------------------------------------- ProPresenter side
_UNKNOWN = object()
class ProPresenter:
def __init__(self, base, timer_name, dry_run=False):
self.base = base.rstrip("/")
self.timer = timer_name
self.dry_run = dry_run
self.quoted = urllib.parse.quote(timer_name, safe="")
# text last put on the stage; None = cleared. Unknown at startup, so the first
# message or clear always goes through (a restart may have left one up).
self.sent_message = _UNKNOWN
def _call(self, method, path, body=None):
if self.dry_run:
log(" would %s %s %s" % (method, path, json.dumps(body) if body is not None else ""))
return True
url = self.base + path
data = None
headers = {}
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=8):
return True
except urllib.error.HTTPError as e:
log("ProPresenter %s %s -> HTTP %s" % (method, path, e.code))
except urllib.error.URLError as e:
log("ProPresenter unreachable at %s (%s)" % (self.base, e.reason))
return False
def arm(self, seconds):
return self._call("PUT", "/v1/timer/%s/start" % self.quoted, {
"id": {"name": self.timer},
"allows_overrun": False,
"countdown": {"duration": int(seconds)},
})
def stop(self):
return self._call("GET", "/v1/timer/%s/stop" % self.quoted)
# ProPresenter re-shows (and flashes) the stage message on every PUT, even with the
# same text, so it is only ever sent when the text actually changes -- never on the
# timer's periodic resync or a pause/resume.
def message(self, text):
if text == self.sent_message:
return True
ok = self._call("PUT", "/v1/stage/message", text)
if ok:
self.sent_message = text
return ok
def clear_message(self):
if self.sent_message is None:
return True
ok = self._call("DELETE", "/v1/stage/message")
if ok:
self.sent_message = None
return ok
def check(self):
try:
with urllib.request.urlopen(self.base + "/v1/timers", timeout=8) as r:
timers = json.loads(r.read())
except Exception as e:
raise SystemExit("Cannot reach ProPresenter at %s (%s).\n"
"Check the address and that its API is enabled in Settings -> Network."
% (self.base, e))
names = [t.get("id", {}).get("name") for t in timers]
if self.timer not in names:
raise SystemExit("ProPresenter has no timer called '%s'. It has: %s\n"
"Create a countdown timer with that exact name, or set "
"\"timer_name\" in the config." % (self.timer, ", ".join(map(str, names))))
log("ProPresenter: found timer '%s'" % self.timer)
# ------------------------------------------------------------------------------- glue
def load_config():
cfg = dict(DEFAULTS)
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH) as fh:
cfg.update(json.load(fh))
else:
with open(CONFIG_PATH, "w") as fh:
json.dump(cfg, fh, indent=2)
print("Wrote a starter config to %s — run with --setup to fill it in." % CONFIG_PATH)
raise SystemExit(0)
if not cfg["resi_user"] or not cfg["propresenter"]:
raise SystemExit("%s is incomplete — run with --setup first." % CONFIG_PATH)
return cfg
def find_player(resi, wanted):
"""-> (venue_uuid, player) for the configured decoder."""
found = []
for v in resi.venues():
for p in resi.players(v.get("uuid")):
found.append((v, p))
if not found:
raise SystemExit("Resi returned no decoders for this account.")
if wanted:
for v, p in found:
if p.get("playerHardwareId") == wanted:
return v.get("uuid"), p
raise SystemExit("No decoder with playerHardwareId '%s'. Run --list to see them." % wanted)
if len(found) == 1:
v, p = found[0]
return v.get("uuid"), p
raise SystemExit("This account has %d decoders — run --list and set \"player\" in the config."
% len(found))
def do_list(resi):
for v in resi.venues():
print("\n%s" % (v.get("userName") or v.get("name")))
players = resi.players(v.get("uuid"))
if not players:
print(" (no decoders)")
for p in players:
print(" %-30s playerHardwareId: %s" % (p.get("userName"), p.get("playerHardwareId")))
print(" %-30s position: %s eventId: %s"
% ("", p.get("position"), p.get("eventId")))
print("\nPut the playerHardwareId you want into \"player\" in %s" % CONFIG_PATH)
STARTUP_LOGIN_HOLD_S = 120
def run(cfg, dry_run):
resi = Resi(cfg["resi_user"], keychain_password(cfg["resi_user"]))
try:
resi.login()
except (Exception, SystemExit) as e:
# launchd restarts the service 30s after it exits, so failing straight away
# here meant a login attempt every 30s -- enough, with a wrong password or a
# rate limit, to get the Resi account locked. Hold first.
log("Resi login failed (%s) — waiting %ds before trying again"
% (e, STARTUP_LOGIN_HOLD_S))
time.sleep(STARTUP_LOGIN_HOLD_S)
raise SystemExit(1)
pp = ProPresenter(cfg["propresenter"], cfg["timer_name"], dry_run)
if not dry_run:
pp.check()
venue_uuid, player = find_player(resi, cfg["player"])
hardware_id = player.get("playerHardwareId")
log("Watching decoder '%s' (%s)" % (player.get("userName"), hardware_id))
head = Playhead(cfg["position_trim_ms"])
cues, event_id, profile_id = [], None, None
last_cue_load = 0.0
last_push = 0.0
pushed_cue = None # (name, ms) currently armed in ProPresenter
pushed_playing = None
backoff = 0 # seconds waited after the last 429; 0 once Resi answers again
while True:
loop_started = time.time()
try:
players = resi.players(venue_uuid)
me = next((p for p in players if p.get("playerHardwareId") == hardware_id), None)
if me is None:
log("decoder not in the players list right now")
else:
media = position_to_ms(me.get("position"))
if media is not None:
head.note(media)
# follow the event the decoder is actually on
if me.get("eventId") and me.get("eventId") != event_id:
event_id = me.get("eventId")
ev = resi.event(event_id) or {}
profile_id = ev.get("eventProfileId")
cues = resi.cues(profile_id, event_id) if profile_id else []
last_cue_load = time.time()
log("event %s — %d cue(s): %s"
% (ev.get("name") or event_id, len(cues),
", ".join(c["name"] for c in cues) or "none"))
elif not me.get("eventId"):
event_id, cues = None, []
seeked = head.take_seek()
if profile_id and event_id and (
seeked or time.time() - last_cue_load >= cfg["cue_refresh_seconds"]):
fresh = resi.cues(profile_id, event_id)
last_cue_load = time.time()
if fresh != cues:
log("cue list changed — %d cue(s)" % len(fresh))
cues = fresh
est, playing, cadence = head.read()
nxt = next((c for c in cues if c["ms"] > est), None) if est is not None else None
identity = (nxt["name"], nxt["ms"]) if nxt else None
due = (identity != pushed_cue
or playing != pushed_playing
or seeked
or time.time() - last_push >= cfg["resync_seconds"])
if due:
if playing and nxt:
remaining = max(0, (nxt["ms"] - est) / 1000.0)
pp.arm(math.ceil(remaining))
if cfg["stage_message"]:
pp.message(nxt["name"])
log("%s -> %s in %s (playhead %s, Resi updates every %ss)"
% (cfg["timer_name"], nxt["name"], hms(nxt["ms"] - est),
hms(est), round(cadence / 1000) if cadence else "?"))
elif not playing:
pp.stop()
log("playback stopped — timer held")
else:
pp.stop()
if cfg["stage_message"]:
pp.clear_message()
log("no cues left on this event")
pushed_cue, pushed_playing, last_push = identity, playing, time.time()
backoff = 0
except urllib.error.HTTPError as e:
if e.code != 429:
log("Resi: HTTP %s" % e.code)
else:
# Rate-limited. Asking again every poll is what keeps Resi refusing,
# so wait -- 15s doubling to 2min, or as long as Resi asks -- and say
# so once rather than logging a line a second.
try:
asked = int(e.headers.get("Retry-After") or 0) if e.headers else 0
except ValueError: # an HTTP date instead of seconds
asked = 0
backoff = min(120, max(15, backoff * 2, asked))
log("Resi: too many requests — waiting %ds before asking again" % backoff)
time.sleep(backoff)
except urllib.error.URLError as e:
log("network: %s" % e.reason)
except Exception as e: # keep running through a bad service day
log("error: %s" % e)
time.sleep(max(0.2, cfg["poll_seconds"] - (time.time() - loop_started)))
def ask(prompt, default=""):
got = input("%s%s: " % (prompt, " [%s]" % default if default else "")).strip()
return got or default
def setup():
"""Interactive first-run: everything site-specific, asked once, written to the config."""
cfg = dict(DEFAULTS)
if os.path.exists(CONFIG_PATH):
with open(CONFIG_PATH) as fh:
cfg.update(json.load(fh))
print("\n-- Resi --")
cfg["resi_user"] = ask("Resi login email", cfg["resi_user"])
while True:
have = subprocess.run(["security", "find-generic-password", "-a", cfg["resi_user"],
"-s", KEYCHAIN_SERVICE], capture_output=True).returncode == 0
if not have:
print("Resi password for %s (saved in this Mac's Keychain; nothing shows as you type):"
% cfg["resi_user"])
if subprocess.run(["security", "add-generic-password", "-U", "-a", cfg["resi_user"],
"-s", KEYCHAIN_SERVICE, "-w"]).returncode != 0:
raise SystemExit("Could not save the password to the Keychain.")
try:
resi = Resi(cfg["resi_user"], keychain_password(cfg["resi_user"]))
resi.login()
break
except SystemExit as e:
if "rejected" not in str(e):
raise
print("Resi rejected that password — try again.")
subprocess.run(["security", "delete-generic-password", "-a", cfg["resi_user"],
"-s", KEYCHAIN_SERVICE], capture_output=True)
found = []
for v in resi.venues():
for p in resi.players(v.get("uuid")):
found.append(p)
if not found:
raise SystemExit("This Resi account has no decoders.")
print("\nDecoders on this account:")
for i, p in enumerate(found, 1):
print(" %d) %-28s now at %s" % (i, p.get("userName"), p.get("position")))
current = next((i for i, p in enumerate(found, 1)
if p.get("playerHardwareId") == cfg["player"]), 1)
while True:
pick = ask("Which decoder feeds this campus", str(current))
if pick.isdigit() and 1 <= int(pick) <= len(found):
break
cfg["player"] = found[int(pick) - 1].get("playerHardwareId")
print("\n-- ProPresenter --")
print("Needs Settings -> Network -> Enable Network turned on; the port is shown there.")
while True:
addr = ask("ProPresenter address (IP:port)", cfg["propresenter"].replace("http://", ""))
base = addr if addr.startswith("http") else "http://" + addr
try:
with urllib.request.urlopen(base.rstrip("/") + "/v1/timers", timeout=8) as r:
names = [t.get("id", {}).get("name") for t in json.loads(r.read())]
break
except Exception as e:
print("Can't reach ProPresenter at %s (%s)." % (base, e))
cfg["propresenter"] = base.rstrip("/")
print("Timers in ProPresenter: %s" % ", ".join(map(str, names)))
while True:
cfg["timer_name"] = ask("Timer to drive", cfg["timer_name"])
if cfg["timer_name"] in names:
break
input("No timer called '%s'. In ProPresenter, add a Countdown timer with exactly that "
"name, then press Return. " % cfg["timer_name"])
with urllib.request.urlopen(cfg["propresenter"] + "/v1/timers", timeout=8) as r:
names = [t.get("id", {}).get("name") for t in json.loads(r.read())]
print("\nThe countdown always runs. Optionally the next cue's name can also go up as a")
print("ProPresenter stage message (shows wherever a stage layout has a Stage Message box).")
while True:
yn = ask("Show cue names as a stage message? (y/n)", "y" if cfg["stage_message"] else "n")
if yn.lower() in ("y", "yes", "n", "no"):
break
cfg["stage_message"] = yn.lower().startswith("y")
with open(CONFIG_PATH, "w") as fh:
json.dump(cfg, fh, indent=2)
print("\nSaved %s" % CONFIG_PATH)
def main():
ap = argparse.ArgumentParser(description="Drive a ProPresenter timer from Resi cue points.")
ap.add_argument("--list", action="store_true", help="list venues and decoders, then exit")
ap.add_argument("--dry-run", action="store_true", help="print what would be sent, touch nothing")
ap.add_argument("--setup", action="store_true", help="ask for this site's settings and save them")
args = ap.parse_args()
if args.setup:
try:
setup()
except (KeyboardInterrupt, EOFError):
raise SystemExit("\nsetup cancelled")
return
cfg = load_config()
if args.list:
resi = Resi(cfg["resi_user"], keychain_password(cfg["resi_user"]))
resi.login()
do_list(resi)
return
try:
run(cfg, args.dry_run)
except KeyboardInterrupt:
print("\nstopped")
if __name__ == "__main__":
main()
+243
View File
@@ -0,0 +1,243 @@
# Resi Studio Cues (Bitfocus Companion Module)
This module logs into **studio.resi.io / central.resi.io** with a username + password, reads decoder/player playback position, loads cue points for the current event, and exposes variables/actions for cue timing and cue management.
The main goal is to drive things like a **countdown clock in ProPresenter** (time until next cue, next cue name, etc).
---
## Features
### Authentication
- Logs in via `POST /api/v3/login?newToken=true`
- Stores auth cookies (`refreshToken`, `livingas1`) and uses them on subsequent requests
- Exposes status via:
- Instance status (green/red in Companion)
- Variables: `auth_status`, `last_error`
### Player polling (live)
- Polls decoder/player position from:
- `GET /api_v2.svc/users/{venueUuid}/players`
- Updates variables like:
- `current_position`
- `current_position_sec`
- `current_event_id`
### Cue loading (per event)
When the current `eventId` changes, the module loads:
- Event detail:
- `GET /api/v3/customers/{customerId}/events/{eventId}`
- Captures `startTime` for ToD (time-of-day) math
- Cues:
- `GET /api_v2.svc/streamprofiles/{eventProfileId}/events/{eventId}/cues?canISetCues=1`
Cues are stored locally and used to compute **next/prev** cue timing from the live playback position.
### Cue timing variables
- Next cue:
- `next_cue_name`
- `next_cue_time_in_seconds`
- `next_cue_time_in_hms`
- `next_cue_position`
- `next_cue_tod` (24-hour)
- Previous cue:
- `prev_cue_name`
- `prev_cue_time_ago_in_seconds`
- `prev_cue_time_ago_in_hms`
- `prev_cue_position`
- `prev_cue_tod` (24-hour)
### Cue management actions
- Add cue (player position or custom time)
- Lookup cue by name (stores into `lookup_*` variables)
- Delete cue by name (looks up UUID, deletes by UUID)
---
## Installation / Testing
### Best (recommended): Developer Module via Git
1. Push this repo to GitHub/GitLab (public or private).
2. On the test system, add it as a Developer Module in Companion.
3. Restart Companion.
### Offline / manual
1. `yarn install`
2. `yarn build`
3. Zip the module folder (without `node_modules`) and provide to tester.
4. Tester places it in Companion’s modules folder and restarts Companion.
(Exact folder paths vary by OS / Companion version.)
---
## Configuration
Typical config fields:
- **Username / Email**
- **Password** (secret)
- **Default Player** (optional)
- **Polling intervals**
- Playback position refresh (fast, e.g. 2000ms)
- Account/venue refresh (slow, e.g. 60000ms)
- Cue refresh while playing (optional, e.g. 15000ms)
- **Fallback when no next cue** (optional)
> Note: dynamic "read-only" status display inside config is not supported by Companion config fields.
> Use Instance Status + module variables for connection state.
---
## Actions
- **Refresh profile**: Reload `/users/me`
- **Refresh lists**: Reload venues + players list
- **Select decoder/player**: Choose which player to poll
- **Add cue at player position (or custom time)**
- Options:
- Cue name
- Private cue
- Position source: player vs custom
- Custom time string
- Text fields support Companion variables (e.g. `$(internal:...)`)
- **Lookup cue by name**
- Writes result into:
- `lookup_cue_found`, `lookup_cue_uuid`, `lookup_cue_position`, etc.
- **Delete cue by name**
- Finds matching cue(s), deletes by UUID via:
- `DELETE /.../cues/{uuid}`
---
## Feedbacks (for button coloring)
Recommended “production” feedback set:
1. **Connected (auth OK)** → Green
2. **Error (auth/API)** → Red
3. **Player active** (position > 0) → Blue
4. **Approaching next cue** (threshold seconds) → Yellow
5. **Playback frozen** (position not changing) → Red
Playback frozen requires:
- variable `playback_last_change_epoch_ms` to be updated when playback position changes.
---
## Presets Included
### Status
- Connected / Error indicator
- Player active indicator
- Playback frozen warning
### Cues
- Next cue display (with T- countdown)
- Previous cue display
### Maintenance
- Refresh profile
- Refresh venues/players list
### Cue actions
- Add cue @ player position
- Add cue @ custom time
- Lookup cue by name
- Delete cue by name
---
## Notes / Limitations
- The Resi API endpoints used here are not public/stable; breaking changes may occur.
- Avoid polling cues every 2 seconds. Recommended cue refresh while playing is 10–30 seconds unless you have a specific need.
- Companion config fields are not truly read-only, so status is exposed via variables + instance status.
---
## Roadmap Ideas
- Optional periodic cue refresh while playing (rate-limited)
- Extra feedback: "No player selected"
- Extra feedback: "No next cue"
- More presets for ProPresenter integration workflows
---
## Playhead accuracy (0.1.0)
Resi only republishes a decoder's `position` every few seconds, on an irregular
cadence, and each reading is already slightly stale when it appears. Showing that
number raw makes the countdown jump in steps and sit several seconds behind.
This build instead keeps a running estimate:
- every reading is stored as **media time minus wall time**; the largest recent value
is the least-delayed reading and is the one trusted, so a slow round trip or an
unlucky poll can no longer drag the countdown backwards
- the display is recomputed **every second** from that offset, so the countdown ticks
smoothly between Resi's updates instead of stepping
- a reading that differs from the locked offset by more than the link's measured
noise is treated as a **seek** - the lock is dropped, the estimate snaps, and the
cue list is re-fetched immediately rather than on its timer
- playback is only called **PAUSED** once the playhead has been still for clearly
longer than Resi's own reporting cadence, so an irregular gap no longer freezes the
countdown
`position_update_interval_sec` reports how often Resi is actually updating. That
number is the floor on how fast a jump can possibly be noticed.
### Beating that floor
If cues are fired from Companion, the module does not have to wait for Resi to
notice. Add one of these to the same button:
- **Resync playhead to cue** - give it the cue name; the estimate moves there at once
- **Resync playhead to the NEXT cue**
The next real reading from Resi confirms or corrects it.
### Settings that matter
- **Player position poll (ms)** - 1000 is recommended. Accuracy no longer depends on
this, but a faster poll notices a jump sooner.
- **Cue list refresh (ms)** - 5000. Also forced immediately whenever a seek is seen.
- **Position trim (ms)** - leave at 0. Only needed if the whole pipeline is
consistently behind; positive values push the estimate forward.
## Startup fix (0.1.2)
On a Resi that publishes every ~30s, the module could start a few seconds before a
publish and learn a 2s cadence from that partial first gap. It then threw out every
real 30s gap, so `playback_state` flipped to **PAUSED** 15s into each update - the
countdown froze and the next cue lagged, worst with cues close together.
- the partial gap after startup (or after a manual resync) is no longer learned
- a single outlier gap can no longer lock out the real cadence
- until the cadence is measured, PAUSED waits 45s instead of 15s
## Resi login back-off (0.1.3)
0.1.2 retried a failed Resi login on every player poll (once a second, for ever), and
answered every 401/403 by signing in again. With a wrong password, a rate limit or a
login that may not see decoders, that is thousands of sign-ins an hour: Resi replies
with 429 and then locks the account (it locked one at North Attleboro).
- after any failed login, or a 429 on any request, every Resi call holds off: 15s,
doubling to 5min, or as long as Resi's Retry-After asks
- a 401/403 renews the session only if it is over a minute old; a refusal straight
after signing in is a permission ("ask a Resi admin for access") and holds off
- status and `last_error` say which: locked (USER_LOCKED), rate-limited, password
rejected, no access, or unreachable
- the log says it once per problem, not once a second
- the cache-busting parameter is no longer dropped for good on a 401/403/429
## Start-up never crashes on a Resi problem (0.1.4)
In 0.1.3 a failed Resi login at start-up (here: a locked account) made the module's
init fail. Companion counts that as a crash and restarts the module every few
seconds, and each restart signed in again from scratch, outside the hold-off.
- init no longer fails when Resi refuses or is down: it logs "Not connected to Resi
yet … will keep trying", starts the account poll, and the hold-off paces retries
- once Resi answers, the configured decoder is picked up automatically
- saving new credentials clears the old hold-off and starts over
+158
View File
@@ -0,0 +1,158 @@
// A failed or refused Resi login must hold off, not retry on every poll.
//
// Seen at North Attleboro: with a bad password, a rate limit or a login without
// decoder access, 0.1.2 signed in again on every 1s player poll. Resi answered with
// 429s and then locked the account. Drives the real module code on a fake clock
// against a fake Resi; touches nothing real.
//
// node backoff_test.js (defaults to dist/main.js)
// node backoff_test.js path/to/main.js
'use strict';
const Module = require('module');
const path = require('path');
const assert = require('assert');
const target = path.resolve(process.argv[2] || path.join(__dirname, 'dist/main.js'));
const stubs = {
'@companion-module/base': {
InstanceBase: class { updateStatus() {} log() {} setVariableValues() {} },
InstanceStatus: { Ok: 'ok', Connecting: 'c', ConnectionFailure: 'f', BadConfig: 'b' },
runEntrypoint() {}, combineRgb() { return 0 }, Regex: {},
},
'./config.js': { GetConfigFields: () => [] },
'./variables.js': { UpdateVariableDefinitions() {} },
'./upgrades.js': { UpgradeScripts: [] },
'./actions.js': { UpdateActions() {} },
'./feedbacks.js': { UpdateFeedbacks() {} },
'./presets.js': { UpdatePresets() {} },
};
const realLoad = Module._load;
Module._load = function (req, parent, isMain) {
if (stubs[req]) return stubs[req];
return realLoad.apply(this, arguments);
};
let now = 1700000000000;
Date.now = () => now;
let mode = 'limited'; // what the fake Resi does with a login
let logins = 0;
let venueCalls = 0;
let playerCalls = 0;
const reply = (status, body, headers) => ({
ok: status >= 200 && status < 300,
status,
headers: { get: (k) => (headers || {})[k.toLowerCase()] ?? null, getSetCookie: () => (headers || {})['set-cookie'] || [] },
json: async () => body,
text: async () => JSON.stringify(body),
});
global.fetch = async (url, opts) => {
const p = String(url).replace('https://central.resi.io', '').split('?')[0];
if (p === '/api/v3/login') {
logins++;
if (mode === 'limited') return reply(429, { message: 'Rate limit exceeded' });
if (mode === 'locked') return reply(401, { code: 'USER_LOCKED', message: 'Unauthorized' });
return reply(200, {}, { 'set-cookie': ['livingas1=T; Path=/'] });
}
if (p === '/api_v2.svc/users/me') return reply(200, { customerId: 'C' });
if (p === '/api/v3/customers/C/venues') { venueCalls++; return reply(mode === 'noaccess' ? 403 : 200, [{ uuid: 'V1', name: 'NA' }]); }
if (p.endsWith('/players')) {
playerCalls++;
return reply(mode === 'noaccess' ? 403 : 200,
[{ playerHardwareId: 'DEC1', userName: 'NA', position: '00:10:00', eventId: null }]);
}
return reply(404, {});
};
const { ModuleInstance } = require(target);
function fresh() {
const inst = Object.create(ModuleInstance.prototype);
inst.config = { username: 'a@b.c' };
inst.secrets = { password: 'pw' };
inst.lastLoginMs = 0;
inst.selectedVenueUuid = 'V1';
inst.selectedPlayerHardwareId = 'DEC1';
inst.playerPollInFlight = false;
inst.updateStatus = () => {};
inst.setVariableValues = () => {};
inst.warns = [];
inst.log = (lvl, m) => { if (lvl === 'warn' || lvl === 'error') inst.warns.push(m); };
// no real timers: the test drives the polls itself
inst.startPolling = () => { inst.pollingStarted = true; };
inst.startPlayerPolling = () => {};
inst.startSmoothTick = () => {};
inst.refreshCuesIfDue = async () => {};
return inst;
}
async function pollFor(inst, seconds) {
for (let i = 0; i < seconds; i++) {
now += 1000;
await inst.pollPlayer();
}
}
let passed = 0;
async function test(name, fn) { await fn(); passed++; console.log(` ok ${name}`); }
(async () => {
await test('a rate-limited login is not retried every poll', async () => {
mode = 'limited'; logins = 0;
const inst = fresh();
await pollFor(inst, 300); // five minutes of 1s polls
assert.ok(logins <= 6, `signed in ${logins} times in five rate-limited minutes`);
assert.ok(inst.warns.length <= 6, `logged ${inst.warns.length} warnings (one per poll would be 300)`);
});
await test('a locked account says so and holds off', async () => {
mode = 'locked'; logins = 0;
const inst = fresh();
await pollFor(inst, 120);
assert.ok(logins <= 4, `signed in ${logins} times against a locked account`);
assert.ok(inst.warns.some((w) => /USER_LOCKED/.test(w)), 'the lock is named in the log');
});
await test('it recovers once Resi lets it in, and keeps one session', async () => {
mode = 'limited'; logins = 0;
const inst = fresh();
await pollFor(inst, 30);
mode = 'ok';
await pollFor(inst, 330); // past the longest hold-off
const after = logins;
playerCalls = 0;
await pollFor(inst, 30);
assert.strictEqual(logins, after, 'a good session is not signed in again');
assert.ok(playerCalls >= 29, `polls the decoder normally once in (${playerCalls} in 30s)`);
});
await test('a login without decoder access is not signed in every poll', async () => {
mode = 'noaccess'; logins = 0; playerCalls = 0;
const inst = fresh();
await pollFor(inst, 120);
assert.ok(logins <= 3, `signed in ${logins} times in two minutes for a permission problem`);
assert.ok(playerCalls <= 10, `asked for decoders ${playerCalls} times (a poll each would be 120)`);
assert.ok(inst.warns.some((w) => /ask a Resi admin/.test(w)), 'says what to do about it');
});
// Seen live with 0.1.3: a locked account made init() throw, Companion restarted the
// module every few seconds, and every restart signed in again from scratch.
await test('init survives a locked account, and recovers once it is unlocked', async () => {
mode = 'locked'; logins = 0;
const inst = fresh();
inst.config.defaultPlayerHardwareId = 'DEC1';
inst.venues = []; inst.playersByVenueUuid = {};
await inst.init(inst.config, true, inst.secrets); // must not throw
assert.ok(inst.pollingStarted, 'polling starts even though Resi refused');
for (let i = 0; i < 60; i++) { now += 10000; await inst.pollOnce(false); } // ten minutes of account polls
// 0s, 15s, 45s, 105s, 225s, 465s: six. The restart loop did about one every 6s.
assert.ok(logins <= 6, `signed in ${logins} times in ten locked minutes`);
mode = 'ok';
now += 300000;
await inst.pollOnce(false);
assert.strictEqual(inst.selectedVenueUuid, 'V1', 'picks up the configured decoder by itself');
});
console.log(`\n${passed} tests passed`);
})().catch((e) => { console.error(e); process.exit(1); });
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Walk every .js in a package and check that each require()/import target resolves,
following Node's resolution rules against the vendored node_modules tree."""
import json
import os
import re
import sys
ROOT = os.path.abspath(sys.argv[1])
BUILTIN = set("""assert async_hooks buffer child_process cluster console constants crypto dgram
diagnostics_channel dns domain events fs http http2 https inspector module net os path
perf_hooks process punycode querystring readline repl stream string_decoder sys timers tls
trace_events tty url util v8 vm wasi worker_threads zlib""".split())
SPEC = re.compile(r"""(?:require\(|(?:^|[\s;{}])(?:import|export)[^;\n]*?\bfrom\s*|import\()\s*['"]([^'"]+)['"]""", re.M)
DYNAMIC = re.compile(r"require\(\s*[^'\")\s]")
COMMENTS = re.compile(r"/\*.*?\*/|^\s*//.*$", re.S | re.M)
# Optional at runtime: loaded only under a config or platform this module never uses.
OPTIONAL = {"re2", "expo-random", "tinybench", "uri-js", "neostandard"}
SKIP_DIRS = ("test", "benchmark", "benchmarks", "example", "examples", ".github")
def load_json(p):
try:
with open(p) as fh:
return json.load(fh)
except Exception:
return {}
def resolve_file(base):
for cand in (base, base + ".js", base + ".json", base + ".cjs", base + ".mjs", base + ".node"):
if os.path.isfile(cand):
return cand
if os.path.isdir(base):
pj = load_json(os.path.join(base, "package.json"))
for key in ("main", "module"):
if isinstance(pj.get(key), str):
hit = resolve_file(os.path.join(base, pj[key]))
if hit:
return hit
if pj.get("exports"):
return base # exports map: trust it, too complex to model here
for idx in ("index.js", "index.json", "index.cjs", "index.node"):
if os.path.isfile(os.path.join(base, idx)):
return os.path.join(base, idx)
return None
def resolve_bare(spec, from_dir):
d = from_dir
while True:
hit = resolve_file(os.path.join(d, "node_modules", *spec.split("/")))
if hit:
return hit
# scoped or deep import into a package that exists
parts = spec.split("/")
pkg = "/".join(parts[:2]) if spec.startswith("@") else parts[0]
pkg_dir = os.path.join(d, "node_modules", *pkg.split("/"))
if os.path.isdir(pkg_dir) and len(parts) > (2 if spec.startswith("@") else 1):
sub = resolve_file(os.path.join(pkg_dir, *parts[(2 if spec.startswith("@") else 1):]))
if sub:
return sub
if load_json(os.path.join(pkg_dir, "package.json")).get("exports"):
return pkg_dir
if os.path.dirname(d) == d or len(d) <= len(ROOT) - 1:
return None
d = os.path.dirname(d)
missing, dynamic, files, optional_hits = [], [], 0, set()
for dirpath, dirnames, filenames in os.walk(ROOT):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
if dirpath.endswith((os.sep + "browser", os.sep + "types")):
continue
for fn in filenames:
if not fn.endswith((".js", ".cjs", ".mjs")):
continue
if fn.endswith((".native.js", ".browser.js")) or fn.startswith("eslint.config"):
continue
path = os.path.join(dirpath, fn)
files += 1
try:
src = COMMENTS.sub("", open(path, encoding="utf-8", errors="replace").read())
except OSError:
continue
if DYNAMIC.search(src):
dynamic.append(os.path.relpath(path, ROOT))
for spec in set(SPEC.findall(src)):
if spec.startswith("node:") or spec.split("/")[0] in BUILTIN:
continue
if spec in OPTIONAL:
optional_hits.add((os.path.relpath(path, ROOT), spec))
continue
if spec.startswith("."):
hit = resolve_file(os.path.normpath(os.path.join(dirpath, spec)))
else:
hit = resolve_bare(spec, dirpath)
if not hit:
missing.append((os.path.relpath(path, ROOT), spec))
print("scanned %d js files" % files)
if dynamic:
print("\n%d file(s) use a computed require() (not statically checkable):" % len(dynamic))
for d in sorted(dynamic)[:12]:
print(" ", d)
if optional_hits:
print("\n%d optional dep(s), not loaded on this platform/config:" % len(optional_hits))
for f, spec in sorted(optional_hits):
print(" %-64s -> %s" % (f, spec))
if missing:
print("\nUNRESOLVED (%d):" % len(missing))
for f, s in sorted(set(missing)):
print(" %-70s -> %s" % (f, s))
sys.exit(1)
print("\nevery static require/import resolves")
+184
View File
@@ -0,0 +1,184 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UpdateActions = UpdateActions;
function UpdateActions(self) {
self.setActionDefinitions({
refresh_profile: {
name: 'Refresh profile (users/me)',
options: [],
callback: async () => {
await self.refreshProfileNow();
},
},
refresh_lists: {
name: 'Refresh venues/players list',
options: [],
callback: async () => {
await self.refreshListsNow();
},
},
add_cue_at_position: {
name: 'Add cue at player position (or custom time)',
options: [
{
id: 'name',
type: 'textinput',
label: 'Cue name',
default: 'Web Cue',
useVariables: true,
},
{
id: 'privateCue',
type: 'checkbox',
label: 'Private cue',
default: false,
},
{
id: 'positionMode',
type: 'dropdown',
label: 'Position source',
default: 'player',
choices: [
{ id: 'player', label: 'Player position (current)' },
{ id: 'custom', label: 'Custom time' },
],
},
{
id: 'customPosition',
type: 'textinput',
label: 'Custom position (HH:MM:SS or HH:MM:SS.mmm)',
default: '00:00:00.000',
useVariables: true,
},
],
callback: async (evt) => {
const name = (await self.parseVariablesInString(String(evt.options.name ?? ''))).trim();
const privateCue = Boolean(evt.options.privateCue);
const positionMode = String(evt.options.positionMode ?? 'player');
const customPosition = (await self.parseVariablesInString(String(evt.options.customPosition ?? ''))).trim();
await self.addCueFromPosition({
name,
privateCue,
positionMode,
customPosition,
});
},
},
delete_cue_by_name: {
name: 'Delete cue by name',
options: [
{
id: 'cueName',
type: 'textinput',
label: 'Cue name',
default: '',
useVariables: true,
},
{
id: 'matchMode',
type: 'dropdown',
label: 'Match mode',
default: 'exact',
choices: [
{ id: 'exact', label: 'Exact (case-insensitive)' },
{ id: 'contains', label: 'Contains (case-insensitive)' },
],
},
{
id: 'deleteMode',
type: 'dropdown',
label: 'Delete',
default: 'first',
choices: [
{ id: 'first', label: 'First match only' },
{ id: 'all', label: 'All matches' },
],
},
{
id: 'onlyMine',
type: 'checkbox',
label: 'Only delete cues created by this user',
default: true,
},
],
callback: async (evt) => {
const cueName = await self.parseVariablesInString(String(evt.options.cueName ?? ''));
const matchMode = String(evt.options.matchMode ?? 'exact');
const deleteMode = String(evt.options.deleteMode ?? 'first');
const onlyMine = Boolean(evt.options.onlyMine);
await self.deleteCueByName({
name: cueName,
matchMode,
deleteMode,
onlyMine,
});
},
},
select_decoder: {
name: 'Select decoder/player',
options: [
{
id: 'playerId',
type: 'dropdown',
label: 'Decoder / Player',
default: '',
choices: self.getPlayerChoices(),
},
],
callback: async (evt) => {
await self.selectPlayer(String(evt.options.playerId || ''));
},
},
resync_to_cue: {
name: 'Resync playhead to cue (use on the button that fires it)',
options: [
{
id: 'cueName',
type: 'textinput',
label: 'Cue name',
default: '',
useVariables: true,
},
],
callback: async (evt) => {
const cueName = await self.parseVariablesInString(String(evt.options.cueName ?? ''));
self.resyncToCue(cueName);
},
},
resync_to_next_cue: {
name: 'Resync playhead to the NEXT cue',
options: [],
callback: async () => {
self.resyncToNextCue();
},
},
lookup_cue_by_name: {
name: 'Lookup cue by name (stores in lookup_* variables)',
options: [
{
id: 'cueName',
type: 'textinput',
label: 'Cue name',
default: '',
useVariables: true,
},
{
id: 'matchMode',
type: 'dropdown',
label: 'Match mode',
default: 'exact',
choices: [
{ id: 'exact', label: 'Exact (case-insensitive)' },
{ id: 'contains', label: 'Contains (case-insensitive)' },
],
},
],
callback: async (evt) => {
const cueName = await self.parseVariablesInString(String(evt.options.cueName ?? ''));
const mode = String(evt.options.matchMode ?? 'exact');
self.lookupCueByName(cueName, mode);
},
},
});
}
//# sourceMappingURL=actions.js.map
+65
View File
@@ -0,0 +1,65 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GetConfigFields = GetConfigFields;
function GetConfigFields() {
return [
{
type: 'textinput',
id: 'username',
label: 'Username / Email',
width: 12,
},
{
type: 'dropdown',
id: 'defaultPlayerHardwareId',
label: 'Default decoder/player on startup',
width: 12,
default: '',
choices: [{ id: '', label: '(none)' }],
},
// NEW: polling intervals
{
type: 'number',
id: 'playerPollMs',
label: 'Player position poll (ms)',
width: 6,
min: 250,
max: 30000,
default: 1000,
},
{
type: 'number',
id: 'positionTrimMs',
label: 'Position trim (ms) - only if it still reads behind',
width: 6,
min: -30000,
max: 30000,
default: 0,
},
{
type: 'number',
id: 'cuePollMs',
label: 'Cue list refresh (ms)',
width: 6,
min: 1000,
max: 60000,
default: 5000,
},
{
type: 'number',
id: 'listPollMs',
label: 'User & decoder inf0 poll (ms)',
width: 6,
min: 10000,
max: 3600000,
default: 120000,
},
{
type: 'secret-text',
id: 'password',
label: 'Password',
width: 12,
}
];
}
//# sourceMappingURL=config.js.map
+113
View File
@@ -0,0 +1,113 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UpdateFeedbacks = UpdateFeedbacks;
const base_1 = require("@companion-module/base");
function UpdateFeedbacks(self) {
self.setFeedbackDefinitions({
resi_connected: {
name: 'Resi: Connected (auth OK)',
type: 'boolean',
defaultStyle: {
bgcolor: (0, base_1.combineRgb)(0, 200, 0),
color: (0, base_1.combineRgb)(0, 0, 0),
},
options: [],
callback: () => {
const v = self.getVariableValue('auth_status') ?? '';
return v === 'OK';
},
},
resi_error: {
name: 'Resi: Error (auth/API)',
type: 'boolean',
defaultStyle: {
bgcolor: (0, base_1.combineRgb)(200, 0, 0),
color: (0, base_1.combineRgb)(255, 255, 255),
},
options: [],
callback: () => {
const auth = (self.getVariableValue('auth_status') ?? '').trim();
const err = (self.getVariableValue('last_error') ?? '').trim();
// True when we have an error message OR auth isn't OK (but ignore "Missing credentials" if you prefer)
return err.length > 0 || (auth.length > 0 && auth !== 'OK');
},
},
player_active: {
name: 'Resi: Player active (position > 0s)',
type: 'boolean',
defaultStyle: {
bgcolor: (0, base_1.combineRgb)(0, 120, 255),
color: (0, base_1.combineRgb)(255, 255, 255),
},
options: [],
callback: () => {
const s = self.getVariableValue('current_position_sec') ?? '0';
const n = Number(s);
return Number.isFinite(n) && n > 0;
},
},
approaching_next_cue: {
name: 'Resi: Approaching next cue (<= threshold seconds)',
type: 'boolean',
defaultStyle: {
bgcolor: (0, base_1.combineRgb)(255, 180, 0),
color: (0, base_1.combineRgb)(0, 0, 0),
},
options: [
{
id: 'threshold',
type: 'number',
label: 'Threshold (seconds)',
default: 30,
min: 1,
max: 3600,
},
],
callback: (fb) => {
const nextName = (self.getVariableValue('next_cue_name') ?? '').trim();
if (!nextName)
return false;
const s = self.getVariableValue('next_cue_time_in_seconds') ?? '';
const n = Number(s);
if (!Number.isFinite(n))
return false;
const thr = Number(fb.options.threshold);
return Number.isFinite(thr) ? n <= thr : false;
},
},
playback_frozen: {
name: 'Resi: Playback frozen (position not changing)',
type: 'boolean',
defaultStyle: {
bgcolor: (0, base_1.combineRgb)(200, 0, 0),
color: (0, base_1.combineRgb)(255, 255, 255),
},
options: [
{
id: 'staleSeconds',
type: 'number',
label: 'Frozen if unchanged for (seconds)',
default: 6,
min: 2,
max: 120,
},
],
callback: (fb) => {
const staleSeconds = Number(fb.options.staleSeconds) || 6;
const curStr = self.getVariableValue('current_position_sec') ?? '';
const cur = Number(curStr);
if (!Number.isFinite(cur))
return false;
const now = Date.now();
const lastStr = self.getVariableValue('playback_last_change_epoch_ms') ?? '';
const last = Number(lastStr);
// If you haven't implemented playback_last_change_epoch_ms yet, we can't detect freezing reliably.
if (!Number.isFinite(last) || last <= 0)
return false;
const ageSec = (now - last) / 1000;
return ageSec >= staleSeconds;
},
},
});
}
//# sourceMappingURL=feedbacks.js.map
+1076
View File
File diff suppressed because it is too large Load Diff
+303
View File
@@ -0,0 +1,303 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UpdatePresets = UpdatePresets;
const base_1 = require("@companion-module/base");
function UpdatePresets(self) {
const presets = {};
// Helper to reference THIS instance’s variables in button text
// Companion variable syntax uses the instance label.
const L = self.label;
// ----------------------------
// STATUS / HEALTH
// ----------------------------
presets['status_connected'] = {
type: 'button',
category: 'Resi • Status',
name: 'Connected (green) / Error (red)',
style: {
text: `Resi\n$(${L}:auth_status)`,
size: '14',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(40, 40, 40),
show_topbar: true,
},
steps: [],
feedbacks: [
{
feedbackId: 'resi_connected',
options: {},
},
{
feedbackId: 'resi_error',
options: {},
},
],
};
presets['status_player_active'] = {
type: 'button',
category: 'Resi • Status',
name: 'Player Active',
style: {
text: `Player\n$(${L}:current_position)`,
size: '14',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(40, 40, 40),
show_topbar: true,
},
steps: [],
feedbacks: [
{
feedbackId: 'player_active',
options: {},
},
],
};
presets['status_frozen'] = {
type: 'button',
category: 'Resi • Status',
name: 'Playback Frozen Warning',
style: {
text: `FROZEN?\n$(${L}:current_position)`,
size: '14',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(40, 40, 40),
show_topbar: true,
},
steps: [],
feedbacks: [
{
feedbackId: 'playback_frozen',
options: {
staleSeconds: 6,
},
},
],
};
// ----------------------------
// CUE DISPLAY (NO ACTIONS)
// ----------------------------
presets['cue_next_display'] = {
type: 'button',
category: 'Resi • Cues',
name: 'Next Cue Display',
style: {
text: `NEXT:\n$(${L}:next_cue_name)\nT- $(${L}:next_cue_time_in_hms)`,
size: '14',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(0, 0, 0),
show_topbar: false,
},
steps: [],
feedbacks: [
{
feedbackId: 'approaching_next_cue',
options: { threshold: 30 },
},
],
};
presets['cue_prev_display'] = {
type: 'button',
category: 'Resi • Cues',
name: 'Previous Cue Display',
style: {
text: `PREV:\n$(${L}:prev_cue_name)\n+ $(${L}:prev_cue_time_ago_in_hms)`,
size: '14',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(0, 0, 0),
show_topbar: false,
},
steps: [],
feedbacks: [],
};
// ----------------------------
// MAINTENANCE / REFRESH
// ----------------------------
presets['refresh_profile'] = {
type: 'button',
category: 'Resi • Maintenance',
name: 'Refresh Profile',
style: {
text: 'Refresh\nProfile',
size: '18',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(60, 60, 60),
show_topbar: false,
},
steps: [
{
down: [
{
actionId: 'refresh_profile',
options: {},
},
],
up: [],
},
],
feedbacks: [
{ feedbackId: 'resi_connected', options: {} },
{ feedbackId: 'resi_error', options: {} },
],
};
presets['refresh_lists'] = {
type: 'button',
category: 'Resi • Maintenance',
name: 'Refresh Venues/Players',
style: {
text: 'Refresh\nLists',
size: '18',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(60, 60, 60),
show_topbar: false,
},
steps: [
{
down: [
{
actionId: 'refresh_lists',
options: {},
},
],
up: [],
},
],
feedbacks: [
{ feedbackId: 'resi_connected', options: {} },
{ feedbackId: 'resi_error', options: {} },
],
};
// ----------------------------
// CUE ACTIONS
// ----------------------------
presets['add_cue_player'] = {
type: 'button',
category: 'Resi • Cues',
name: 'Add Cue @ Player Position',
style: {
text: 'Add Cue\n@ Player',
size: '18',
color: (0, base_1.combineRgb)(0, 0, 0),
bgcolor: (0, base_1.combineRgb)(180, 180, 180),
show_topbar: false,
},
steps: [
{
down: [
{
actionId: 'add_cue_at_position',
options: {
name: 'Web Cue',
privateCue: false,
positionMode: 'player',
customPosition: '00:00:00.000',
},
},
],
up: [],
},
],
feedbacks: [
{ feedbackId: 'resi_connected', options: {} },
{ feedbackId: 'resi_error', options: {} },
],
};
presets['add_cue_custom'] = {
type: 'button',
category: 'Resi • Cues',
name: 'Add Cue @ Custom Time',
style: {
text: 'Add Cue\n@ Custom',
size: '18',
color: (0, base_1.combineRgb)(0, 0, 0),
bgcolor: (0, base_1.combineRgb)(180, 180, 180),
show_topbar: false,
},
steps: [
{
down: [
{
actionId: 'add_cue_at_position',
options: {
// user can edit this preset, OR replace with a variable
name: 'Custom Cue',
privateCue: false,
positionMode: 'custom',
customPosition: '00:10:00.000',
},
},
],
up: [],
},
],
feedbacks: [
{ feedbackId: 'resi_connected', options: {} },
{ feedbackId: 'resi_error', options: {} },
],
};
presets['lookup_cue'] = {
type: 'button',
category: 'Resi • Cues',
name: 'Lookup Cue (stores in lookup_* vars)',
style: {
text: 'Lookup\nCue',
size: '18',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(0, 110, 200),
show_topbar: false,
},
steps: [
{
down: [
{
actionId: 'lookup_cue_by_name',
options: {
// user edits this preset to their cue name or uses variables
cueName: 'sermon start',
matchMode: 'exact',
},
},
],
up: [],
},
],
feedbacks: [
{ feedbackId: 'resi_connected', options: {} },
{ feedbackId: 'resi_error', options: {} },
],
};
presets['delete_cue'] = {
type: 'button',
category: 'Resi • Cues',
name: 'Delete Cue by Name',
style: {
text: 'DELETE\nCue',
size: '18',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(140, 0, 0),
show_topbar: false,
},
steps: [
{
down: [
{
actionId: 'delete_cue_by_name',
options: {
// user edits this preset to the cue they want to delete
cueName: 'Companion Test',
matchMode: 'exact',
deleteMode: 'first',
onlyMine: true,
},
},
],
up: [],
},
],
feedbacks: [
{ feedbackId: 'resi_connected', options: {} },
{ feedbackId: 'resi_error', options: {} },
],
};
self.setPresetDefinitions(presets);
}
//# sourceMappingURL=presets.js.map
+5
View File
@@ -0,0 +1,5 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UpgradeScripts = void 0;
exports.UpgradeScripts = [];
//# sourceMappingURL=upgrades.js.map
+43
View File
@@ -0,0 +1,43 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UpdateVariableDefinitions = UpdateVariableDefinitions;
function UpdateVariableDefinitions(instance) {
const vars = [
{ variableId: 'org_name', name: 'Organization name' },
{ variableId: 'first_name', name: 'First name' },
{ variableId: 'last_name', name: 'Last name' },
{ variableId: 'email', name: 'Email' },
{ variableId: 'auth_status', name: 'Auth status' },
{ variableId: 'last_error', name: 'Last error' },
{ variableId: 'last_poll', name: 'Last poll (local time)' },
{ variableId: 'current_position', name: 'Current position (HH:MM:SS.ms)' },
{ variableId: 'playback_state', name: 'Playback state (PLAYING/PAUSED)' },
{ variableId: 'position_update_interval_sec', name: 'Seconds between Resi position updates' },
{ variableId: 'player_fields', name: 'Fields Resi returns for this decoder (diagnostic)' },
{ variableId: 'current_position_sec', name: 'Current position (seconds)' },
{ variableId: 'current_event_id', name: 'Current event id (live from player)' },
{ variableId: 'cue_count', name: 'Cue count' },
{ variableId: 'cue_names', name: 'Cue names (comma separated)' },
{ variableId: 'event_id', name: 'Current event id (cues loaded for)' },
{ variableId: 'next_cue_name', name: 'Next cue name' },
{ variableId: 'next_cue_time_in_seconds', name: 'Seconds until next cue' },
{ variableId: 'next_cue_position', name: 'Next cue position (HH:MM:SS.ms)' },
{ variableId: 'next_cue_tod', name: 'Next cue time-of-day (24 hour)' },
{ variableId: 'prev_cue_name', name: 'Previous cue name' },
{ variableId: 'prev_cue_time_ago_in_seconds', name: 'Seconds since previous cue' },
{ variableId: 'prev_cue_position', name: 'Previous cue position (HH:MM:SS.ms)' },
{ variableId: 'prev_cue_tod', name: 'Previous cue time-of-day (24 hour)' },
{ variableId: 'next_cue_time_in_hms', name: 'Time until next cue (HH:MM:SS)' },
{ variableId: 'prev_cue_time_ago_in_hms', name: 'Time since previous cue (HH:MM:SS)' },
{ variableId: 'lookup_cue_query', name: 'Lookup cue query (name)' },
{ variableId: 'lookup_cue_found', name: 'Lookup cue found (true/false)' },
{ variableId: 'lookup_cue_name', name: 'Lookup cue name (matched)' },
{ variableId: 'lookup_cue_uuid', name: 'Lookup cue uuid' },
{ variableId: 'lookup_cue_position', name: 'Lookup cue position (HH:MM:SS.ms)' },
{ variableId: 'lookup_cue_position_sec', name: 'Lookup cue position (seconds)' },
{ variableId: 'lookup_cue_tod', name: 'Lookup cue time-of-day' },
{ variableId: 'playback_last_change_epoch_ms', name: 'Playback last position change (epoch ms)' },
];
instance.setVariableDefinitions(vars);
}
//# sourceMappingURL=variables.js.map
+35
View File
@@ -0,0 +1,35 @@
{
"id": "resi-studio-cues",
"name": "resi-studio-cues",
"shortname": "resi-studio-cues",
"description": "Live cue list, playhead and next-cue countdown from Resi Studio",
"version": "0.1.4",
"license": "MIT",
"repository": "git+https://github.com/reykroona/companion-module-resi-studio-cues.git",
"bugs": "https://github.com/reykroona/companion-module-resi-studio-cues/issues",
"maintainers": [
{
"name": "Rey Kroona",
"email": "reykroona@gmail.com"
}
],
"runtime": {
"type": "node18",
"api": "nodejs-ipc",
"apiVersion": "1.14.1",
"entrypoint": "../dist/main.js"
},
"legacyIds": [],
"manufacturer": "Rey Kroona",
"products": [
"Resi Studio Cues"
],
"keywords": [
"resi",
"living as one",
"multisite",
"cues",
"countdown",
"decoder"
]
}
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Assemble a fully-nested node_modules tree from the npm registry, without npm.
Fully nested (no hoisting) so Node's upward resolution can never pick a wrong
version: every package's deps live inside its own node_modules/.
"""
import json
import os
import shutil
import subprocess
import sys
import tarfile
import urllib.parse
import urllib.request
CACHE = os.path.abspath("_npmcache")
os.makedirs(CACHE, exist_ok=True)
_meta = {}
def meta(name):
if name not in _meta:
path = os.path.join(CACHE, name.replace("/", "_") + ".json")
if not os.path.exists(path):
url = "https://registry.npmjs.org/" + urllib.parse.quote(name, safe="")
urllib.request.urlretrieve(url, path)
with open(path) as fh:
_meta[name] = json.load(fh)
return _meta[name]
def parse(v):
core = v.split("-")[0].split("+")[0]
return tuple(int(x) for x in core.split("."))
def satisfies(version, rng):
if "-" in version: # never auto-pick a prerelease
return False
rng = rng.strip()
v = parse(version)
if rng in ("", "*", "latest", "x"):
return True
if rng.startswith("^"):
b = parse(rng[1:])
if v < b:
return False
if b[0] > 0:
return v[0] == b[0]
if b[1] > 0:
return v[0] == 0 and v[1] == b[1]
return v[0] == 0 and v[1] == 0 and v[2] == b[2]
if rng.startswith("~"):
b = parse(rng[1:])
return v >= b and v[0] == b[0] and v[1] == b[1]
if rng.startswith(">="):
return v >= parse(rng[2:])
if rng[0].isdigit():
return version == rng
raise ValueError("unsupported range %r" % rng)
def pick(name, rng):
m = meta(name)
ok = [v for v in m["versions"] if satisfies(v, rng)]
if not ok:
raise SystemExit("no version of %s satisfies %s" % (name, rng))
best = max(ok, key=parse)
return best, m["versions"][best]
def install(name, rng, target_nm, chain, log):
version, info = pick(name, rng)
if (name, version) in chain:
return # already provided by an ancestor
dest = os.path.join(target_nm, *name.split("/"))
if os.path.exists(dest):
return
os.makedirs(os.path.dirname(dest), exist_ok=True)
tgz = os.path.join(CACHE, "%s-%s.tgz" % (name.replace("/", "_"), version))
if not os.path.exists(tgz):
urllib.request.urlretrieve(info["dist"]["tarball"], tgz)
tmp = dest + ".tmp"
shutil.rmtree(tmp, ignore_errors=True)
with tarfile.open(tgz) as tf:
tf.extractall(tmp)
inner = os.path.join(tmp, "package")
shutil.move(inner if os.path.isdir(inner) else tmp, dest)
shutil.rmtree(tmp, ignore_errors=True)
log.append("%s%s@%s" % (" " * len(chain), name, version))
deps = info.get("dependencies") or {}
if deps:
child_nm = os.path.join(dest, "node_modules")
for dname, drng in sorted(deps.items()):
install(dname, drng, child_nm, chain | {(name, version)}, log)
if __name__ == "__main__":
root_nm, name, rng = sys.argv[1], sys.argv[2], sys.argv[3]
os.makedirs(root_nm, exist_ok=True)
log = []
install(name, rng, root_nm, frozenset(), log)
print("\n".join(log))
print("\n%d packages" % len(log))
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""
resi_probe.py — does the Resi cloud hand us live cue + playhead data for OUR account?
One read-only pass over the endpoints a Companion "cue countdown" module would need:
login POST /api/v3/login?newToken=true
who am i GET /api_v2.svc/users/me
venues GET /api/v3/customers/{customerId}/venues
decoders GET /api_v2.svc/users/{venueUuid}/players <- playhead lives here
event GET /api/v3/customers/{customerId}/events/{eventId}
cues GET /api_v2.svc/streamprofiles/{eventProfileId}/events/{eventId}/cues
Nothing is created, changed or deleted. Your password is read with getpass (never
echoed), sent only to https://central.resi.io, and never written to disk.
This talks to Resi's INTERNAL API — the one studio.resi.io itself uses. It is
undocumented and unsupported; don't open a Resi ticket about it.
Usage:
python3 resi_probe.py --user you@yourchurch.org
python3 resi_probe.py --user you@yourchurch.org --watch 10 # 10s playhead watch
python3 resi_probe.py --user you@yourchurch.org --save ./resi-capture
"""
import argparse
import getpass
import http.cookiejar
import json
import os
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE = "https://central.resi.io"
_jar = http.cookiejar.CookieJar()
_opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(_jar))
_token = None
_save_dir = None
def _cookie(name):
for c in _jar:
if c.name == name:
return c.value
return None
def _headers():
h = {"Accept": "application/json", "User-Agent": "resi-probe/1.0"}
if _token:
h["Authorization"] = "X-Bearer " + _token
return h
def _request(method, path, body=None):
url = path if path.startswith("http") else BASE + path
data = None
h = _headers()
if body is not None:
data = json.dumps(body).encode()
h["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=h, method=method)
try:
with _opener.open(req, timeout=20) as resp:
raw = resp.read()
except urllib.error.HTTPError as e:
raise RuntimeError("HTTP %s on %s %s — %s" % (e.code, method, path, e.read()[:300].decode("utf-8", "replace")))
except ssl.SSLError as e:
raise RuntimeError("TLS failure (%s). If this Mac's Python has no CA bundle, run the "
"'Install Certificates.command' that ships with your Python install." % e)
except urllib.error.URLError as e:
raise RuntimeError("Could not reach %s — %s" % (url, e.reason))
if not raw:
return None
try:
return json.loads(raw)
except ValueError:
return raw.decode("utf-8", "replace")
def get(path):
out = _request("GET", path)
if _save_dir is not None:
name = urllib.parse.quote(path.split("?")[0].strip("/"), safe="") + ".json"
with open(os.path.join(_save_dir, name), "w") as fh:
json.dump(out, fh, indent=2, sort_keys=True)
return out
def login(username, password):
global _token
_request("POST", "/api/v3/login?newToken=true", {"userName": username, "password": password})
_token = _cookie("livingas1")
if not _token:
raise RuntimeError("Login returned 2xx but set no 'livingas1' cookie — the auth shape has changed. "
"Cookies seen: %s" % ", ".join(c.name for c in _jar))
def ms(position):
"""'01:02:03.500' -> milliseconds. Returns None on anything unparseable."""
if not position:
return None
try:
hh, mm_, rest = position.split(":")
return int((int(hh) * 3600 + int(mm_) * 60 + float(rest)) * 1000)
except (ValueError, AttributeError):
return None
def hms(milliseconds):
s, sign = int(abs(milliseconds) // 1000), "-" if milliseconds < 0 else ""
return "%s%02d:%02d:%02d" % (sign, s // 3600, (s % 3600) // 60, s % 60)
def main():
global _save_dir
ap = argparse.ArgumentParser(description="Read-only probe of the Resi internal API.")
ap.add_argument("--user", help="Resi Studio login (email)")
ap.add_argument("--watch", type=int, default=0, metavar="SEC",
help="after the report, poll the playhead for SEC seconds to prove it advances")
ap.add_argument("--save", metavar="DIR",
help="write each raw JSON response to DIR (contains venue/decoder names and uuids)")
args = ap.parse_args()
if args.save:
os.makedirs(args.save, exist_ok=True)
_save_dir = args.save
username = args.user or input("Resi login (email): ").strip()
password = getpass.getpass("Password (not echoed, not stored): ")
print("\n== auth ==")
login(username, password)
print(" login OK, bearer token acquired")
me = get("/api_v2.svc/users/me")
customer_id = me.get("customerId")
print(" user : %s" % me.get("userName"))
print(" org : %s (customerId %s)" % (me.get("customerName"), customer_id))
if not customer_id:
sys.exit(" no customerId on this account — stop here, nothing else will resolve")
print("\n== venues ==")
venues = get("/api/v3/customers/%s/venues" % customer_id) or []
if not venues:
print(" none returned. Either this login has no venue access, or the plan has no Multisite.")
for v in venues:
print(" %-40s %s" % (v.get("userName") or v.get("name"), v.get("uuid")))
print("\n== decoders / players ==")
live = []
for v in venues:
players = []
try:
players = get("/api_v2.svc/users/%s/players" % v.get("uuid")) or []
except RuntimeError as e:
print(" [%s] players call failed: %s" % (v.get("userName"), e))
continue
if not players:
print(" [%s] no players" % v.get("userName"))
for p in players:
print(" [%s] %-28s pos=%-14s eventId=%s"
% (v.get("userName"), p.get("userName"), p.get("position"), p.get("eventId")))
extra = sorted(set(p) - {"playerHardwareId", "userName", "position", "eventId"})
if extra:
print(" other fields on this player: %s" % ", ".join(extra))
if p.get("eventId"):
live.append((v, p))
if not live:
print("\nNo decoder is on an event right now. Re-run this DURING a service —"
"\nposition and eventId are what the whole countdown hangs on.")
return
for v, p in live:
print("\n== event + cues for %s / %s ==" % (v.get("userName"), p.get("userName")))
event = get("/api/v3/customers/%s/events/%s" % (customer_id, p.get("eventId")))
profile_id = event.get("eventProfileId")
print(" event : %s" % event.get("name"))
print(" startTime : %s" % event.get("startTime"))
print(" profileId : %s" % profile_id)
if not profile_id:
print(" no eventProfileId — cannot fetch cues for this event")
continue
cues = get("/api_v2.svc/streamprofiles/%s/events/%s/cues?canISetCues=1"
% (profile_id, p.get("eventId"))) or []
cues = sorted(cues, key=lambda c: ms(c.get("position")) or 0)
print(" %d cue(s):" % len(cues))
here = ms(p.get("position"))
for c in cues:
at = ms(c.get("position"))
marker = " "
if here is not None and at is not None:
marker = "->" if at > here else " "
print(" %s %-14s %s" % (marker, c.get("position"), c.get("name")))
if here is None:
print("\n playhead unparseable (%r) — countdown not possible from this field"
% p.get("position"))
continue
nxt = next((c for c in cues if (ms(c.get("position")) or 0) > here), None)
print("\n THE BUTTON WOULD READ:")
print(" Playback position : %s" % hms(here))
if nxt:
print(" Next cue : %s" % nxt.get("name"))
print(" Next cue in : %s" % hms((ms(nxt.get("position")) or 0) - here))
else:
print(" Next cue : (none left on this event)")
if args.watch:
v, p = live[0]
print("\n== watching %s for %ds (does the playhead advance?) ==" % (p.get("userName"), args.watch))
first = last = None
deadline = time.time() + args.watch
while time.time() < deadline:
players = get("/api_v2.svc/users/%s/players" % v.get("uuid")) or []
now = next((x.get("position") for x in players
if x.get("playerHardwareId") == p.get("playerHardwareId")), None)
print(" %s %s" % (time.strftime("%H:%M:%S"), now))
if first is None:
first = ms(now)
last = ms(now)
time.sleep(2)
if first is not None and last is not None:
moved = last - first
print(" moved %s over ~%ds -> %s"
% (hms(moved), args.watch,
"PLAYING (a play/pause variable is derivable)" if moved > 0 else "frozen / paused"))
if __name__ == "__main__":
try:
main()
except RuntimeError as exc:
sys.exit("\nFAILED: %s" % exc)
except KeyboardInterrupt:
sys.exit("\ninterrupted")
+272
View File
@@ -0,0 +1,272 @@
// Drives the real pollPlayer() + estimator against a simulated Resi that
// publishes `position` on a coarse cadence, already slightly stale.
var console = {
log: function () { print(Array.prototype.join.call(arguments, ' ')) },
error: function () { print('ERR ' + Array.prototype.join.call(arguments, ' ')) },
warn: function () { print('WARN ' + Array.prototype.join.call(arguments, ' ')) },
info: function () { print(Array.prototype.join.call(arguments, ' ')) },
debug: function () {},
};
var failures = [];
function check(c, m) { if (!c) { failures.push(m); print(' FAIL ' + m) } }
function eq(g, w, m) { check(g === w, m + ' (got ' + g + ', want ' + w + ')') }
function near(g, w, tol, m) {
check(Math.abs(g - w) <= tol, m + ' (got ' + Math.round(g) + ', want ' + Math.round(w) + ' +/-' + tol + ')');
}
function StubInstanceBase() {}
StubInstanceBase.prototype.updateStatus = function () {}
StubInstanceBase.prototype.log = function () {}
StubInstanceBase.prototype.setVariableValues = function () {}
var stubs = {
'@companion-module/base': {
InstanceBase: StubInstanceBase,
InstanceStatus: { Ok: 'ok', Connecting: 'c', ConnectionFailure: 'f', BadConfig: 'b' },
runEntrypoint: function () {}, combineRgb: function () { return 0 }, Regex: {},
},
'./config.js': { GetConfigFields: function () { return [] } },
'./variables.js': { UpdateVariableDefinitions: function () {} },
'./upgrades.js': { UpgradeScripts: [] },
'./actions.js': { UpdateActions: function () {} },
'./feedbacks.js': { UpdateFeedbacks: function () {} },
'./presets.js': { UpdatePresets: function () {} },
};
// ---- the simulated world -------------------------------------------------
var fakeNow = 1700000000000;
Date.now = function () { return fakeNow };
// cadence / lag / jitter can be overridden: jsc tick_test.js -- 10000 3000 4000
var ARGS = (typeof arguments !== 'undefined' ? arguments : []).map(Number);
var DIAG = true;
var RESI_CADENCE = ARGS[0] || 5000; // how often Resi refreshes `position`
var RESI_LAG = ARGS.length > 1 ? ARGS[1] : 500; // how stale it already is when it appears
var RESI_JITTER = ARGS[2] || 0; // how irregular the cadence is
var NET_JITTER = ARGS[3] || 0; // how variable the request/response round trip is
var START_INTO = ARGS[4] || 0; // how far into Resi's cycle the module starts polling
var seed = 12345;
function rnd() { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff }
var nextCadence = RESI_CADENCE;
var playing = true;
var trueMedia = 8 * 60000 + 48000; // where the decoder REALLY is
var published = trueMedia - RESI_LAG; // what the API currently returns
var lastPublish = fakeNow - START_INTO;
var extraPlayerFields = {};
var TOL = RESI_LAG + 1200 + RESI_JITTER;
function pad(n, w) { return String(n).padStart(w, '0') }
function fmt(ms) {
var t = Math.max(0, Math.floor(ms));
return pad(Math.floor(t / 3600000), 2) + ':' + pad(Math.floor((t % 3600000) / 60000), 2) + ':' +
pad(Math.floor((t % 60000) / 1000), 2) + '.' + pad(t % 1000, 3);
}
function advanceWorld(ms) {
for (var i = 0; i < ms; i += 100) {
fakeNow += 100;
if (playing) trueMedia += 100;
if (fakeNow - lastPublish >= nextCadence) {
lastPublish = fakeNow;
published = trueMedia - RESI_LAG;
nextCadence = RESI_CADENCE + Math.round((rnd() - 0.5) * 2 * RESI_JITTER);
if (nextCadence < 500) nextCadence = 500;
}
}
}
var fetch = function () {
// the body is whatever Resi had at the moment the request left
var one = { playerHardwareId: 'DEC1', userName: 'Lobby', position: fmt(published), eventId: 'EV1' };
for (var k in extraPlayerFields) one[k] = extraPlayerFields[k];
// ...and the round trip takes a while, during which the world moves on
if (NET_JITTER) advanceWorld(100 + Math.round(rnd() * NET_JITTER));
return Promise.resolve({ ok: true, json: function () { return Promise.resolve([one]) } });
};
var tickFn = null, tickMs = null;
var setInterval = function (fn, ms) { tickFn = fn; tickMs = ms; return {} };
var clearInterval = function () {};
var src = readFile(typeof TARGET !== 'undefined' ? TARGET : 'build/pkg/resi-studio-cues/dist/main.js');
var mod = { exports: {} };
(new Function('module', 'exports', 'require', 'setInterval', 'clearInterval', 'console', 'fetch', src))(
mod, mod.exports,
function (id) { if (!stubs[id]) throw new Error('unstubbed require: ' + id); return stubs[id] },
setInterval, clearInterval, console, fetch);
var inst = Object.create(mod.exports.ModuleInstance.prototype);
var vars = {};
inst.setVariableValues = function (o) { for (var k in o) vars[k] = o[k] };
inst.log = function () {};
inst.config = {};
inst.selectedVenueUuid = 'V1';
inst.selectedPlayerHardwareId = 'DEC1';
inst.cookieHeader = 'stub';
inst.currentEventId = 'EV1';
inst.playerPollInFlight = false;
inst.ensureAuthenticated = function () { return Promise.resolve() };
inst.buildAuthHeaders = function () { return {} };
inst.refreshCuesIfDue = function () { return Promise.resolve() };
inst.cues = [
{ uuid: 'a', name: 'Welcome', positionMs: 5 * 60000 },
{ uuid: 'b', name: 'Campus Host', positionMs: 12 * 60000 + 30000 },
{ uuid: 'c', name: 'Message', positionMs: 41 * 60000 },
{ uuid: 'd', name: 'Closing', positionMs: 45 * 60000 },
];
// true once Resi has actually REPORTED a position near `target`
function reported_near(target) {
return Number.isFinite(inst.__lastSampleMediaMs) && Math.abs(inst.__lastSampleMediaMs - target) < 8000;
}
// one second of real operation: advance the world, poll, redraw
// any PAUSED while the decoder is really playing is a false pause: it freezes the countdown
var falsePauses = 0;
async function second() {
advanceWorld(1000); await inst.pollPlayer(); tickFn();
if (playing && vars.playback_state === 'PAUSED') falsePauses++;
}
async function seconds(n) { for (var i = 0; i < n; i++) await second() }
function timestampChecks() {
var now = Date.now();
eq(inst.detectSampleTime({ lastUpdated: new Date(now - 2000).toISOString() }), now - 2000,
'uses an ISO "lastUpdated" as the sample time');
eq(inst.detectSampleTime({ reportedAt: now - 1500 }), now - 1500,
'uses an epoch-ms "reportedAt" as the sample time');
eq(inst.detectSampleTime({ startTime: new Date(now - 120000).toISOString() }), undefined,
'ignores a timestamp too old to be this reading');
eq(inst.detectSampleTime({ userName: 'Lobby', position: '00:01:02.000' }), undefined,
'ignores fields that are not timestamps');
}
(async function () {
inst.startSmoothTick();
eq(tickMs, 1000, 'display ticks every second');
// ---- accuracy while playing -----------------------------------------
await seconds(40);
var err = trueMedia - inst.__lastEstMs;
check(err <= TOL + NET_JITTER * 0.35, 'estimate is not lagging (behind truth by ' + Math.round(err) + 'ms)');
check(err >= -300, 'estimate does not run ahead of the real playhead (' + Math.round(err) + 'ms)');
// the point of the whole exercise: compare against the raw value over a
// full publish cycle, not at one lucky instant
var maxEstErr = 0, maxRawErr = 0;
for (var w = 0; w < 30; w++) {
await second();
maxEstErr = Math.max(maxEstErr, trueMedia - inst.__lastEstMs);
maxRawErr = Math.max(maxRawErr, trueMedia - published);
}
if (RESI_CADENCE >= 2000)
check(maxRawErr >= RESI_CADENCE * 0.4,
'sanity: the raw Resi value goes badly stale within a cycle (' + Math.round(maxRawErr) + 'ms)');
check(maxEstErr <= TOL + NET_JITTER * 0.35,
'the estimate stays close all cycle (worst ' + Math.round(maxEstErr) + 'ms vs raw ' + Math.round(maxRawErr) + 'ms)');
// ---- ticks every second, never backwards ------------------------------
var prev = inst.__lastEstMs, stalls = 0, backwards = 0;
for (var i = 0; i < 60; i++) {
await second();
if (inst.__lastEstMs === prev) stalls++;
if (inst.__lastEstMs < prev) backwards++;
prev = inst.__lastEstMs;
}
eq(stalls, 0, 'no stalled seconds across a minute of playback');
eq(backwards, 0, 'the playhead never goes backwards');
eq(vars.playback_state, 'PLAYING', 'reported as playing');
check(trueMedia - inst.__lastEstMs <= TOL + NET_JITTER * 0.35, 'still accurate after a minute');
// ---- long run: the offset window is finite, so the best sample eventually
// ages out of it; that must not drag the displayed playhead backwards
var prev2 = inst.__lastEstMs, back2 = 0, worstBack = 0;
for (var L = 0; L < 200; L++) {
await second();
if (inst.__lastEstMs < prev2) {
back2++; worstBack = Math.max(worstBack, prev2 - inst.__lastEstMs);
if (typeof DIAG !== 'undefined')
print(' backward step ' + Math.round(prev2 - inst.__lastEstMs) + 'ms at L=' + L +
' state=' + vars.playback_state + ' offsets=' + (inst.__offsets || []).length +
' sinceNew=' + (Date.now() - inst.__lastNewSampleAt) + ' base=' + inst.__baseOffset);
}
prev2 = inst.__lastEstMs;
}
eq(back2, 0, 'no backward steps over 200s (worst was ' + Math.round(worstBack) + 'ms)');
eq(falsePauses, 0, 'never reported PAUSED while playing, startup included');
// ---- a fired cue / forward seek --------------------------------------
inst.lastCueRefreshMs = 999999;
trueMedia = 40 * 60000 + 50000; // decoder jumps; Resi says nothing yet
var jumpAt = fakeNow, sawIt = false;
for (var q = 0; q < 60 && !sawIt; q++) { await second(); sawIt = reported_near(trueMedia) }
check(sawIt, 'the jump is picked up at all');
var detectMs = fakeNow - jumpAt;
if (typeof DIAG !== 'undefined') print(' jump detected after ' + detectMs + 'ms');
check(detectMs <= RESI_CADENCE + RESI_JITTER + 2000,
'jump detected within one publish cycle (' + detectMs + 'ms)');
eq(inst.lastCueRefreshMs, 0, 'a jump forces the cue list to refresh immediately');
near(inst.__lastEstMs, trueMedia, TOL + RESI_CADENCE / 2, 'estimate resyncs to the new playhead at once');
if (typeof DIAG !== 'undefined') print(' post-seek error: ' + Math.round(trueMedia - inst.__lastEstMs) + 'ms');
check(trueMedia - inst.__lastEstMs >= -400,
'estimate does not overshoot after a seek (' + Math.round(trueMedia - inst.__lastEstMs) + 'ms)');
eq(vars.next_cue_name, 'Message', 'next cue is correct straight after the jump');
await seconds(15);
eq(vars.next_cue_name, 'Closing', 'rolled over after passing Message');
eq(vars.prev_cue_name, 'Message', 'passed cue became prev');
// ---- a backwards seek -------------------------------------------------
trueMedia = 6 * 60000;
for (var r = 0; r < 60 && !reported_near(trueMedia); r++) await second();
near(inst.__lastEstMs, trueMedia, TOL + RESI_CADENCE / 2, 'follows a backwards seek');
eq(vars.next_cue_name, 'Campus Host', 'cue targets recompute after seeking back');
// ---- pause ------------------------------------------------------------
playing = false;
// how long the module can legitimately take to call it a pause, given this feed
var pauseWait = Math.ceil((Math.max(15000, Math.min(45000, (RESI_CADENCE + RESI_JITTER) * 2.5 + 6000))
+ RESI_CADENCE + RESI_JITTER + 6000) / 1000);
await seconds(pauseWait);
eq(vars.playback_state, 'PAUSED', 'detects a real pause');
var held = inst.__lastEstMs;
await seconds(20);
eq(inst.__lastEstMs, held, 'paused playhead holds instead of running on');
// ---- resume -----------------------------------------------------------
playing = true;
await seconds(25);
eq(vars.playback_state, 'PLAYING', 'recovers after resume');
near(inst.__lastEstMs, trueMedia, TOL + 1500, 'estimate re-locks after resume');
// ---- operator-driven resync (beats waiting for Resi to republish) ----
var beforeEst = inst.__lastEstMs;
eq(inst.resyncToCue('No Such Cue'), false, 'resync to an unknown cue is refused');
eq(inst.__lastEstMs, beforeEst, 'a refused resync leaves the playhead alone');
inst.lastCueRefreshMs = 999999;
eq(inst.resyncToCue('Campus Host'), true, 'resync to a named cue succeeds');
eq(inst.__lastEstMs, 12 * 60000 + 30000, 'playhead snaps to that cue immediately');
eq(vars.next_cue_name, 'Message', 'next cue updates in the same instant');
eq(inst.lastCueRefreshMs, 0, 'resync also forces a cue-list refresh');
eq(inst.resyncToNextCue(), true, 'resync to the next cue succeeds');
eq(inst.__lastEstMs, 41 * 60000, 'playhead snaps to the following cue');
// a real sample after a resync must still be taken seriously
trueMedia = 30 * 60000; published = trueMedia - RESI_LAG; lastPublish = fakeNow;
await seconds(2);
for (var z = 0; z < 60 && !reported_near(trueMedia); z++) await second();
near(inst.__lastEstMs, trueMedia, TOL + RESI_CADENCE / 2, 'a real reading overrides a resync');
timestampChecks();
var b = failures.length;
eq(vars.next_cue_name, 'Not A Real Cue', 'deliberate failure');
if (failures.length !== b + 1) { print('HARNESS BROKEN'); throw new Error('harness') }
failures.pop();
if (failures.length) { print('\n' + failures.length + ' FAILED'); throw new Error('tests failed') }
print('\nall estimator tests passed [cadence ' + RESI_CADENCE + 'ms, lag ' + RESI_LAG + 'ms, jitter +/-' + RESI_JITTER + 'ms]');
})();
+151
View File
@@ -0,0 +1,151 @@
#!/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()
+267
View File
@@ -0,0 +1,267 @@
#!/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()
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""When Resi says "too many requests" (429), the bridge must wait, not ask again next poll.
Seen at North Attleboro: two tools polling one Resi account every second hit its rate
limit, and because neither backed off, Resi kept refusing and the ProPresenter
countdown stopped. Runs the real loop on a fake clock against a fake Resi; touches
nothing real. Takes a second.
python3 test_rate_limit.py
"""
import io
import urllib.error
import resi_to_propresenter as R
clock = [0.0]
asks = []
sleeps = []
class Done(BaseException):
pass
def fake_sleep(s):
sleeps.append(s)
clock[0] += s
if clock[0] > 600: # ten simulated minutes is plenty
raise Done()
def too_many():
return urllib.error.HTTPError("https://central.resi.io/x", 429, "Too Many Requests",
{"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}, io.BytesIO())
class LimitedResi:
limited_until = 300 # Resi refuses for the first five minutes
def __init__(self, *a, **k):
self.no_cache_bust = False
def login(self):
pass
def venues(self):
return [{"uuid": "V1"}]
def players(self, venue_uuid):
asks.append(clock[0])
if clock[0] < self.limited_until:
raise too_many()
return [{"playerHardwareId": "DEC1", "userName": "Test", "position": "00:10:00",
"eventId": None}]
class QuietPP:
def __init__(self, *a, **k):
pass
def check(self):
pass
def __getattr__(self, name):
return lambda *a, **k: True
def main():
R.time = type("T", (), {"time": staticmethod(lambda: clock[0]),
"sleep": staticmethod(fake_sleep),
"strftime": staticmethod(lambda f: "00:00:00")})
R.now_ms = lambda: clock[0] * 1000
R.Resi = LimitedResi
R.ProPresenter = QuietPP
R.keychain_password = lambda account: "x"
R.find_player = lambda resi, wanted: ("V1", {"playerHardwareId": "DEC1", "userName": "Test"})
R.log = lambda msg: None
cfg = dict(R.DEFAULTS)
cfg.update({"resi_user": "t@example.com", "player": "DEC1", "propresenter": "http://x"})
try:
R.run(cfg, False)
except Done:
pass
failures = []
limited = [t for t in asks if t < LimitedResi.limited_until]
print("asked Resi %d times in the 5 rate-limited minutes (every poll would be %d)"
% (len(limited), int(LimitedResi.limited_until / cfg["poll_seconds"])))
if len(limited) > 12:
failures.append("kept asking while rate-limited: %d times" % len(limited))
after = [t for t in asks if t >= LimitedResi.limited_until]
if not after:
failures.append("never asked again once Resi would answer")
elif after[0] - LimitedResi.limited_until > 125:
failures.append("waited %.0fs too long to recover" % (after[0] - LimitedResi.limited_until))
steady = [b - a for a, b in zip(after, after[1:])]
if steady and max(steady) > cfg["poll_seconds"] + 0.5:
failures.append("did not return to the normal poll once answered (gaps %s)" % steady[:5])
if failures:
raise SystemExit("FAILED:\n - " + "\n - ".join(failures))
print("rate limit tests passed")
if __name__ == "__main__":
main()
+162
View File
@@ -0,0 +1,162 @@
#!/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()