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:
+12
@@ -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
|
||||
@@ -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.
|
||||
Executable
+108
@@ -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
@@ -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.
@@ -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
@@ -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
|
||||
@@ -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); });
|
||||
@@ -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")
|
||||
Vendored
+184
@@ -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
|
||||
Vendored
+65
@@ -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
|
||||
Vendored
+113
@@ -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
|
||||
Vendored
+1076
File diff suppressed because it is too large
Load Diff
Vendored
+303
@@ -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
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.UpgradeScripts = void 0;
|
||||
exports.UpgradeScripts = [];
|
||||
//# sourceMappingURL=upgrades.js.map
|
||||
Vendored
+43
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
@@ -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]');
|
||||
})();
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user