commit 1a658875bc0d7618a1f01c0d682a3d9748340b2f Author: christian Date: Tue Oct 6 18:11:01 2026 -0400 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d830bfa --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b89259 --- /dev/null +++ b/README.md @@ -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 -> 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 +`
--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. diff --git a/install_bridge.sh b/install_bridge.sh new file mode 100755 index 0000000..a6fa3dc --- /dev/null +++ b/install_bridge.sh @@ -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" < + + + + Label + $LABEL + ProgramArguments + + /usr/bin/python3 + $DEST/resi_to_propresenter.py + + RunAtLoad + + + KeepAlive + + ThrottleInterval + 30 + StandardOutPath + $LOG + StandardErrorPath + $LOG + + +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 diff --git a/make_kit.sh b/make_kit.sh new file mode 100755 index 0000000..7a41d5c --- /dev/null +++ b/make_kit.sh @@ -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' diff --git a/resi-studio-cues-0.1.4.tgz b/resi-studio-cues-0.1.4.tgz new file mode 100644 index 0000000..3560714 Binary files /dev/null and b/resi-studio-cues-0.1.4.tgz differ diff --git a/resi_to_propresenter.py b/resi_to_propresenter.py new file mode 100644 index 0000000..e7a9b7d --- /dev/null +++ b/resi_to_propresenter.py @@ -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() diff --git a/source/HELP.md b/source/HELP.md new file mode 100644 index 0000000..e76cd3e --- /dev/null +++ b/source/HELP.md @@ -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 diff --git a/source/backoff_test.js b/source/backoff_test.js new file mode 100644 index 0000000..0d3df0f --- /dev/null +++ b/source/backoff_test.js @@ -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); }); diff --git a/source/check_resolve.py b/source/check_resolve.py new file mode 100644 index 0000000..e806702 --- /dev/null +++ b/source/check_resolve.py @@ -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") diff --git a/source/dist/actions.js b/source/dist/actions.js new file mode 100644 index 0000000..e83e248 --- /dev/null +++ b/source/dist/actions.js @@ -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 \ No newline at end of file diff --git a/source/dist/config.js b/source/dist/config.js new file mode 100644 index 0000000..b110c72 --- /dev/null +++ b/source/dist/config.js @@ -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 \ No newline at end of file diff --git a/source/dist/feedbacks.js b/source/dist/feedbacks.js new file mode 100644 index 0000000..49568db --- /dev/null +++ b/source/dist/feedbacks.js @@ -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 \ No newline at end of file diff --git a/source/dist/main.js b/source/dist/main.js new file mode 100644 index 0000000..fc5e7a3 --- /dev/null +++ b/source/dist/main.js @@ -0,0 +1,1076 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ModuleInstance = void 0; +const base_1 = require("@companion-module/base"); +const config_js_1 = require("./config.js"); +const variables_js_1 = require("./variables.js"); +const upgrades_js_1 = require("./upgrades.js"); +const actions_js_1 = require("./actions.js"); +const feedbacks_js_1 = require("./feedbacks.js"); +const presets_js_1 = require("./presets.js"); +class ModuleInstance extends base_1.InstanceBase { + config; + secrets; + cookieHeader = ''; + refreshToken; + livingas1; + pollTimer; + pollInFlight = false; + lastLoginMs = 0; + loginInFlight; + customerId; + venues = []; + playersByVenueUuid = {}; + cues = []; + selectedVenueUuid; + selectedPlayerHardwareId; + playerPollTimer; + playerPollInFlight = false; + currentEventId; + currentEventProfileId; + eventMediaStartEpochMs; + lastPlayerPosition; + lastCueRefreshMs = 0; + cueRefreshInFlight = false; + lastPositionSec; + constructor(internal) { + super(internal); + } + async init(config, _isFirstInit, secrets) { + this.config = config; + this.secrets = secrets; + this.updateStatus(base_1.InstanceStatus.Connecting, 'Initializing'); + this.updateActions(); + this.updateFeedbacks(); + this.updatePresets(); + this.updateVariableDefinitions(); + // A Resi problem must never fail init: Companion treats that as a crash and + // restarts the module every few seconds, each restart a fresh process with no + // hold-off -- a sign-in every few seconds, which is how an account gets locked. + // So start what can be started, and let the polls (and their hold-off) retry. + try { + await this.startResi(false); + } + catch (err) { + this.log('warn', `Not connected to Resi yet: ${err?.message ?? err} — will keep trying`); + } + finally { + this.startPolling(); + } + } + /** Sign in, load venues/decoders, and follow the configured decoder. */ + async startResi(forceLogin) { + // Auth + customerId + await this.refreshProfileSmart(forceLogin); + // Load venues/players + await this.refreshVenuesAndPlayers(); + // Auto-select default player if configured + if (this.config.defaultPlayerHardwareId) { + await this.selectPlayer(this.config.defaultPlayerHardwareId); + } + } + async configUpdated(config, secrets) { + this.config = config; + this.secrets = secrets; + // New credentials: start over, so the hold-off of the old ones doesn't apply. + this.clearHold(); + this.cookieHeader = ''; + try { + await this.startResi(true); + } + catch (err) { + this.log('warn', `Not connected to Resi yet: ${err?.message ?? err} — will keep trying`); + } + this.startPolling(); + } + // Return config fields for web config + getConfigFields() { + const fields = (0, config_js_1.GetConfigFields)(); + // Patch in current player choices + for (const f of fields) { + if (f.type === 'dropdown' && f.id === 'defaultPlayerHardwareId') { + f.choices = [{ id: '', label: '(none)' }, ...this.getPlayerChoices()]; + } + } + return fields; + } + updateActions() { + (0, actions_js_1.UpdateActions)(this); + } + updateFeedbacks() { + (0, feedbacks_js_1.UpdateFeedbacks)(this); + } + updatePresets() { + (0, presets_js_1.UpdatePresets)(this); + } + updateVariableDefinitions() { + (0, variables_js_1.UpdateVariableDefinitions)(this); + } + async refreshProfileSmart(forceLogin = false) { + try { + await this.ensureAuthenticated(forceLogin); + if (!this.cookieHeader) + return; // missing creds or login not possible yet + await this.fetchProfile(); + this.updateStatus(base_1.InstanceStatus.Ok); + this.setVariableValues({ auth_status: 'OK', last_error: '' }); + } + catch (err) { + const msg = err?.message ?? String(err); + this.setVariableValues({ auth_status: 'Auth/profile failed', last_error: msg }); + this.log('error', `Refresh failed: ${msg}`); + this.updateStatus(base_1.InstanceStatus.ConnectionFailure, 'Refresh failed'); + throw err; + } + } + async resiLogin(username, password) { + const res = await fetch('https://central.resi.io/api/v3/login?newToken=true', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ userName: username, password }), + }); + if (!res.ok) { + let detail = ''; + const j = (await res.json().catch(() => undefined)); + if (j) + detail = [j?.code, j?.message ?? j?.error].filter(Boolean).join(' ') || JSON.stringify(j); + else + detail = await res.text().catch(() => ''); + const e = new Error(`Login failed (${res.status}) ${detail}`.trim()); + e.status = res.status; + e.retryAfterMs = (Number(res.headers.get('retry-after')) || 0) * 1000; + e.code = j?.code; + throw e; + } + // Node 18+ (Companion) supports getSetCookie() + const setCookies = res.headers.getSetCookie?.() ?? + (res.headers.get('set-cookie') ? [res.headers.get('set-cookie')] : []); + const cookies = {}; + for (const sc of setCookies) { + const first = sc.split(';')[0]; + const idx = first.indexOf('='); + if (idx > 0) + cookies[first.slice(0, idx)] = first.slice(idx + 1); + } + this.refreshToken = cookies.refreshToken; + this.livingas1 = cookies.livingas1; + const parts = []; + if (this.refreshToken) + parts.push(`refreshToken=${this.refreshToken}`); + if (this.livingas1) + parts.push(`livingas1=${this.livingas1}`); + this.cookieHeader = parts.join('; '); + } + async getUsersMe() { + const res = await fetch('https://central.resi.io/api_v2.svc/users/me', { + method: 'GET', + headers: { + Accept: 'application/json', + Cookie: this.cookieHeader, + ...(this.livingas1 ? { authorization: `X-Bearer ${this.livingas1}` } : {}), + }, + }); + if (!res.ok) { + throw new Error(`/users/me failed (${res.status})`); + } + return res.json(); + } + /* A failed login used to be retried on the very next poll -- once a second, for + ever -- and a 401/403 forced a fresh login every time. Resi answers that with + 429s and, kept up, locks the account (it did, at North Attleboro). So any + failure now holds off every Resi call: 15s, doubling to 5min, or as long as + Resi's Retry-After asks. Calls in between fail at once with the reason. */ + holdOff(reason, askedMs = 0) { + this.__holdFailures = (this.__holdFailures ?? 0) + 1; + const backoff = Math.min(300000, 15000 * 2 ** (this.__holdFailures - 1)); + this.__holdUntil = Date.now() + Math.max(backoff, askedMs || 0); + this.__holdReason = reason; + this.updateStatus(base_1.InstanceStatus.ConnectionFailure, reason); + } + clearHold() { + this.__holdFailures = 0; + this.__holdUntil = 0; + this.__holdReason = ''; + } + checkHold() { + if (Date.now() < (this.__holdUntil ?? 0)) { + const wait = Math.ceil((this.__holdUntil - Date.now()) / 1000); + throw new Error(`${this.__holdReason} — retrying in ${wait}s`); + } + } + /** A refusal straight after signing in is a permission; only an older session is renewed. */ + sessionIsFresh() { + return Date.now() - (this.lastLoginMs ?? 0) < 60000; + } + /** 401/403/429 from any Resi request: renew an old session once, otherwise hold off. */ + noteRefusal(status, what, retryAfterMs = 0) { + if (status === 429) { + this.holdOff('Resi is rate-limiting this account (429)', retryAfterMs); + return; + } + if ((status === 401 || status === 403) && !this.sessionIsFresh()) { + this.cookieHeader = ''; // the next call signs in again, once + return; + } + if (status === 401 || status === 403) + this.holdOff(`This Resi login isn't allowed to see ${what} (${status}) — ask a Resi admin for access`); + } + async ensureAuthenticated(force = false) { + this.checkHold(); + if (force && this.sessionIsFresh() && this.cookieHeader) + force = false; // just signed in: signing in again won't change the answer + const username = this.config.username; + const password = this.secrets?.password; + if (!username || !password) { + this.updateStatus(base_1.InstanceStatus.BadConfig, 'Missing username/password'); + this.setVariableValues({ + auth_status: 'Missing credentials', + last_error: 'Enter username/password in config', + }); + return; + } + // If we already have cookies and it hasn’t been too long, skip login + const ageMs = Date.now() - this.lastLoginMs; + const shouldRelogin = force || !this.cookieHeader || ageMs > 50 * 60 * 1000; // 50 minutes + if (!shouldRelogin) + return; + // De-dupe concurrent logins + if (!this.loginInFlight) { + this.loginInFlight = (async () => { + try { + this.updateStatus(base_1.InstanceStatus.Connecting, 'Logging in'); + await this.resiLogin(username, password); + this.lastLoginMs = Date.now(); + this.clearHold(); + this.updateStatus(base_1.InstanceStatus.Ok, 'Connected'); + this.setVariableValues({ auth_status: 'OK', last_error: '' }); + } + catch (err) { + const msg = err?.message ?? String(err); + const reason = err?.code === 'USER_LOCKED' ? 'Resi has locked this account (USER_LOCKED)' + : err?.status === 429 ? 'Resi is rate-limiting this login (429)' + : err?.status === 400 || err?.status === 401 ? `Resi rejected the login (${err.status}) — check the password` + : err?.status ? `Resi login failed (${err.status})` : `Resi unreachable (${msg})`; + this.holdOff(reason, err?.retryAfterMs); + this.setVariableValues({ auth_status: 'Auth failed', last_error: `${reason} (${msg})` }); + // Same words as the held-off calls that follow, so the log says it once. + throw new Error(reason); + } + })().finally(() => { + this.loginInFlight = undefined; + }); + } + await this.loginInFlight; + } + async fetchProfile() { + this.updateStatus(base_1.InstanceStatus.Connecting, 'Fetching profile'); + const me = await this.getUsersMe(); + this.customerId = me.customerId; + this.setVariableValues({ + org_name: me.customerName ?? '', + first_name: me.firstName ?? '', + last_name: me.lastName ?? '', + email: me.userName ?? '', + }); + return me; + } + async refreshProfileNow() { + await this.refreshProfileSmart(true); // force login on manual refresh + this.setVariableValues({ last_poll: new Date().toLocaleString() }); + } + getPlayerChoices() { + const choices = []; + for (const v of this.venues) { + const players = this.playersByVenueUuid[v.uuid] ?? []; + for (const p of players) { + choices.push({ + id: p.playerHardwareId, + label: `${v.userName} → ${p.userName} (${p.playerHardwareId})`, + }); + } + } + return choices; + } + async refreshListsNow() { + await this.refreshProfileSmart(false); + await this.refreshVenuesAndPlayers(); + } + async selectPlayer(playerHardwareId) { + this.selectedPlayerHardwareId = playerHardwareId; + // find which venue it's in + this.selectedVenueUuid = undefined; + for (const v of this.venues) { + if ((this.playersByVenueUuid[v.uuid] ?? []).some((p) => p.playerHardwareId === playerHardwareId)) { + this.selectedVenueUuid = v.uuid; + break; + } + } + this.setVariableValues({ + auth_status: this.selectedVenueUuid ? 'Player selected' : 'Player selected (venue not found)', + }); + this.startPlayerPolling(); + this.startSmoothTick(); + } + // If the API hands us a timestamp for when this reading was taken, the sample's + // true age is known and the pipeline lag drops out. Name hint + a tight window + // so an event start time can never be mistaken for one. + detectSampleTime(p) { + const now = Date.now(); + for (const k of Object.keys(p || {})) { + if (!/updat|time|stamp|sampl|asof|as_of|checkin|heartbeat|report|seen/i.test(k)) + continue; + const v = p[k]; + let t; + if (typeof v === 'number' && v > 1e12 && v < 4e12) + t = v; + else if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(v)) { + const d = Date.parse(v); + if (!Number.isNaN(d)) + t = d; + } + if (t !== undefined && Math.abs(now - t) < 60000) + return t; + } + return undefined; + } + notePlayheadSample(mediaMs, player) { + if (!Number.isFinite(mediaMs)) + return; + const nowMs = Date.now(); + if (player) + this.setVariableValues({ player_fields: Object.keys(player).sort().join(',') }); + if (mediaMs !== this.__lastSampleMediaMs) { + const takenAt = (player ? this.detectSampleTime(player) : undefined) ?? nowMs; + // media time minus wall time. Constant while playing at 1x; the LARGEST + // recent value is the least-delayed reading, which is the one to trust. + const off = mediaMs - takenAt; + // How far apart honest readings sit: a slow round trip or a late poll + // makes a reading look older than it is, and that spread is this link's + // noise floor. Anything inside it is noise; only a change clearly beyond + // it is a real seek. Measured, not assumed, and kept across resets. + const spreads = this.__spreads ?? []; + const noise = spreads.length ? spreads.reduce((m, v) => (v > m ? v : m), 0) : 0; + const jumpThreshold = Math.max(5000, noise * 1.5 + 4000); + if (this.__baseOffset !== undefined) { + const delta = off - this.__baseOffset; + if (Math.abs(delta) > jumpThreshold) { + // A discontinuity: seek, fired cue, or resume after a pause. + // The window describes the old position and has to go. + this.__offsets = []; + this.__seeked = true; + // Only abandon the no-going-backwards guard for a move big enough + // that snapping is obviously right; a marginal call must not make + // the displayed playhead lurch backwards. + if (Math.abs(delta) > 10000) + this.__lastEstMs = undefined; + } + else if (delta < 0) { + this.__spreads = spreads.concat(-delta).slice(-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 (this.__lastNewSampleAt !== undefined && (this.__sampleChanges ?? 0) > 0) { + const gap = nowMs - this.__lastNewSampleAt; + // 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. + const worstGap = (this.__gaps ?? []).reduce((m, g) => (g > m ? g : m), 0); + if (gap > 0 && gap < 60000) { + if (!worstGap || gap <= worstGap * 3 || this.__gapRejected) { + this.__gaps = (this.__gaps ?? []).concat(gap).slice(-8); + this.__gapRejected = false; + } + else { + this.__gapRejected = true; + } + } + } + if (this.__lastNewSampleAt !== undefined) + this.__sampleChanges = (this.__sampleChanges ?? 0) + 1; + this.__offsets = (this.__offsets ?? []).concat({ t: nowMs, off }); + this.__lastSampleMediaMs = mediaMs; + this.__lastNewSampleAt = nowMs; + } + const kept = (this.__offsets ?? []).filter((o) => nowMs - o.t <= 120000); + // Always keep the newest reading even once it ages out. If the window empties + // during a long pause there is nothing left to compare against, so the resume + // reads as an ordinary reading instead of the discontinuity it is. + this.__offsets = kept.length ? kept : (this.__offsets ?? []).slice(-1); + this.__baseOffset = this.__offsets.length + ? this.__offsets.reduce((m, o) => (o.off > m ? o.off : m), -Infinity) + : undefined; + } + applySmoothVars() { + if (this.__lastNewSampleAt === undefined) + return; + const nowMs = Date.now(); + const gaps = this.__gaps ?? []; + const cadence = gaps.length ? gaps.reduce((m, g) => (g > m ? g : m), 0) : 0; + // Only call it paused once the playhead has been still for clearly longer + // than Resi's own (irregular) reporting cadence. + // 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. + const pauseAfter = cadence ? Math.max(15000, Math.min(45000, cadence * 2.5 + 6000)) : 45000; + const sinceNew = nowMs - this.__lastNewSampleAt; + const playing = this.__baseOffset !== undefined && sinceNew < pauseAfter; + this.setVariableValues({ + playback_state: playing ? 'PLAYING' : 'PAUSED', + position_update_interval_sec: cadence ? String(Math.round(cadence / 1000)) : '', + }); + let est; + if (playing) { + const trim = Number(this.config.positionTrimMs ?? 0) || 0; + est = nowMs + this.__baseOffset + trim; + // the offset window is a max, so it can only dip when the best sample ages + // out -- never show the playhead running backwards because of that + if (this.__lastEstMs !== undefined && est < this.__lastEstMs) + est = this.__lastEstMs; + this.__lastEstMs = est; + } + else { + est = this.__lastEstMs ?? this.__lastSampleMediaMs; + } + this.computeAndSetCueVars(est); + this.setVariableValues({ + current_position: this.msToHmsMs(est), + current_position_sec: String(Math.floor(est / 1000)), + }); + } + msToHmsMs(total) { + const t = Math.max(0, Math.floor(total)); + const hh = Math.floor(t / 3600000); + const mm = Math.floor((t % 3600000) / 60000); + const ss = Math.floor((t % 60000) / 1000); + const ms = t % 1000; + const pad = (n, w) => String(n).padStart(w, '0'); + return `${pad(hh, 2)}:${pad(mm, 2)}:${pad(ss, 2)}.${pad(ms, 3)}`; + } + // Resi only republishes `position` every few seconds, so a fired cue is invisible + // until it does. If the operator fires it from Companion they already know where + // the playhead landed -- this takes their word for it and lets the next real + // sample confirm or correct it. + resyncToCue(cueName) { + const want = String(cueName ?? '').trim().toLowerCase(); + const cue = (this.cues ?? []).find((c) => String(c.name ?? '').trim().toLowerCase() === want); + if (!cue) { + this.log('warn', `Resync: no cue named "${cueName}"`); + this.setVariableValues({ last_error: `Resync: no cue named "${cueName}"` }); + return false; + } + this.resyncToPosition(cue.positionMs); + return true; + } + resyncToNextCue() { + const here = this.__lastEstMs ?? this.__lastSampleMediaMs; + if (!Number.isFinite(here)) + return false; + const next = (this.cues ?? []).find((c) => c.positionMs > here); + if (!next) { + this.log('warn', 'Resync: there is no next cue to jump to'); + return false; + } + this.resyncToPosition(next.positionMs); + return true; + } + resyncToPosition(mediaMs) { + if (!Number.isFinite(mediaMs)) + return; + const nowMs = Date.now(); + this.__offsets = [{ t: nowMs, off: mediaMs - nowMs }]; + this.__baseOffset = mediaMs - nowMs; + this.__lastEstMs = undefined; + this.__lastSampleMediaMs = undefined; // the next poll counts as a fresh sample + this.__lastNewSampleAt = nowMs; + this.__sampleChanges = 0; // the gap from here to the next reading is partial too + this.lastCueRefreshMs = 0; + this.applySmoothVars(); + } + startSmoothTick() { + if (this.__tickTimer) + clearInterval(this.__tickTimer); + this.__tickTimer = setInterval(() => { + try { + this.applySmoothVars(); + } + catch (e) { + // a display tick must never kill the instance + } + }, 1000); + } + startPlayerPolling() { + if (this.playerPollTimer) + clearInterval(this.playerPollTimer); + const ms = Math.max(250, Number(this.config.playerPollMs ?? 1000) || 1000); + this.playerPollTimer = setInterval(() => { + void this.pollPlayer(); + }, ms); + } + async pollPlayer() { + if (this.playerPollInFlight) + return; + this.playerPollInFlight = true; + try { + if (!this.selectedVenueUuid || !this.selectedPlayerHardwareId) + return; + await this.ensureAuthenticated(false); + if (!this.cookieHeader) + return; + // A stale cached reading looks exactly like a laggy playhead, so ask for a + // fresh one. If the service objects to the cache-busting parameter, drop it + // and never send it again rather than failing every poll. + const playersUrl = `https://central.resi.io/api_v2.svc/users/${this.selectedVenueUuid}/players`; + const noCacheHeaders = { ...this.buildAuthHeaders(), 'Cache-Control': 'no-cache', Pragma: 'no-cache' }; + let res = await fetch(this.__noCacheBust ? playersUrl : `${playersUrl}?_=${Date.now()}`, { + method: 'GET', + headers: noCacheHeaders, + }); + if (!res.ok && !this.__noCacheBust && res.status >= 400 && res.status < 500 + && ![401, 403, 429].includes(res.status)) { + this.__noCacheBust = true; + this.log('info', 'players: retrying without the cache-busting parameter'); + res = await fetch(playersUrl, { method: 'GET', headers: noCacheHeaders }); + } + if (!res.ok) { + this.noteRefusal(res.status, "this venue's decoders", + (Number(res.headers.get('retry-after')) || 0) * 1000); + throw new Error(`players poll failed (${res.status})`); + } + const players = (await res.json()); + const p = players.find((x) => x.playerHardwareId === this.selectedPlayerHardwareId); + // Guard: player not found + if (!p) { + this.setVariableValues({ + auth_status: 'Selected player not found', + last_error: 'Selected playerHardwareId not present in /players response', + }); + return; + } + const posMs = this.parseTimeToMs(p.position ?? '00:00:00'); + const eventId = p.eventId ?? undefined; + const posSec = Math.floor(posMs / 1000); + if (this.lastPositionSec === undefined || this.lastPositionSec !== posSec) { + this.lastPositionSec = posSec; + this.setVariableValues({ + playback_last_change_epoch_ms: String(Date.now()), + }); + } + this.setVariableValues({ + current_position: p.position ?? '', + current_position_sec: String(posSec), + current_event_id: eventId ?? '', + last_error: '', + }); + this.lastPlayerPosition = p.position ?? ''; + // If event changed, refresh cues + capture event startTime -> eventMediaStartEpochMs + if (eventId && eventId !== this.currentEventId) { + this.currentEventId = eventId; + this.eventMediaStartEpochMs = undefined; + await this.refreshEventProfileAndCues(); + } + // --- playhead estimator: record this sample (patched) --- + this.notePlayheadSample(posMs, p); + this.applySmoothVars(); + if (this.__seeked) { + this.__seeked = false; + this.lastCueRefreshMs = 0; // make the cue list catch up now, not on the next tick + } + // --- end patch --- + this.__lastPollWarn = ''; // a later failure is news again + await this.refreshCuesIfDue(); + } + catch (err) { + const msg = err?.message ?? String(err); + // Logged when the reason changes, not on every 1s poll of a held-off account. + const gist = msg.replace(/ — retrying in \d+s$/, ''); + if (gist !== this.__lastPollWarn) + this.log('warn', `Player poll failed: ${msg}`); + this.__lastPollWarn = gist; + this.setVariableValues({ + auth_status: 'Player poll failed', + last_error: msg, + }); + } + finally { + this.playerPollInFlight = false; + } + } + async refreshEventProfileAndCues() { + if (!this.customerId || !this.currentEventId) + return; + await this.ensureAuthenticated(false); + if (!this.cookieHeader) + return; + const evRes = await fetch(`https://central.resi.io/api/v3/customers/${this.customerId}/events/${this.currentEventId}`, { method: 'GET', headers: this.buildAuthHeaders() }); + if (!evRes.ok) + throw new Error(`event detail failed (${evRes.status})`); + const ev = (await evRes.json()); + this.currentEventProfileId = ev.eventProfileId; + // anchor ToD off event startTime + if (ev.startTime) { + const t = Date.parse(ev.startTime); + if (!Number.isNaN(t)) + this.eventMediaStartEpochMs = t; + } + // cues + const cuesRes = await fetch(`https://central.resi.io/api_v2.svc/streamprofiles/${this.currentEventProfileId}/events/${this.currentEventId}/cues?canISetCues=1`, { method: 'GET', headers: this.buildAuthHeaders() }); + if (!cuesRes.ok) + throw new Error(`cues failed (${cuesRes.status})`); + const cuesJson = (await cuesRes.json()); + this.cues = cuesJson + .map((c) => ({ + uuid: c.uuid, + name: c.name, + user: c.user, + privateCue: c.privateCue, + positionMs: this.parseTimeToMs(c.position), + })) + .sort((a, b) => a.positionMs - b.positionMs); + this.log('info', `Loaded ${this.cues.length} cues for event ${this.currentEventId}`); + this.setVariableValues({ + event_id: this.currentEventId ?? '', + cue_count: String(this.cues.length), + cue_names: this.cues.map((c) => c.name).join(', '), + }); + } + startPolling() { + if (this.pollTimer) + clearInterval(this.pollTimer); + const ms = Math.max(10000, Number(this.config.listPollMs ?? 120000) || 120000); + this.pollTimer = setInterval(() => { + void this.pollOnce(false); + }, ms); + setTimeout(() => void this.pollOnce(false), 2000); + } + async pollOnce(isManual) { + if (this.pollInFlight) + return; + this.pollInFlight = true; + try { + await this.ensureAuthenticated(false); + if (!this.cookieHeader) + return; + try { + await this.fetchProfile(); + } + catch (err) { + const status = err?.status ?? err?.response?.status; + const msg = err?.message ?? String(err); + if (String(msg).includes('401') || String(msg).includes('403') || status === 401 || status === 403) { + // Renewed only if the session is old enough to have expired; a + // refusal of a fresh one is a permission and holds off instead. + if (this.sessionIsFresh()) { + this.noteRefusal(String(msg).includes('401') ? 401 : 403, 'its own profile'); + throw err; + } + await this.ensureAuthenticated(true); + await this.fetchProfile(); + } + else { + throw err; + } + } + // Optionally keep lists fresh on the same interval: + await this.refreshVenuesAndPlayers(); + // A start that failed (Resi down, account held off) left no decoder followed; + // pick the configured one up as soon as Resi answers. + if (this.config.defaultPlayerHardwareId && !this.selectedVenueUuid) + await this.selectPlayer(this.config.defaultPlayerHardwareId); + this.setVariableValues({ + last_poll: new Date().toLocaleString(), + }); + this.updateStatus(base_1.InstanceStatus.Ok); + } + catch (err) { + const msg = err?.message ?? String(err); + this.setVariableValues({ + auth_status: isManual ? 'Manual refresh failed' : 'Poll failed', + last_error: msg, + last_poll: new Date().toLocaleString(), + }); + this.updateStatus(base_1.InstanceStatus.ConnectionFailure, 'Poll failed'); + } + finally { + this.pollInFlight = false; + } + } + buildAuthHeaders() { + return { + Accept: 'application/json', + Cookie: this.cookieHeader, + ...(this.livingas1 ? { authorization: `X-Bearer ${this.livingas1}` } : {}), + }; + } + parseTimeToMs(s) { + // Accept: "HH:MM:SS", "HH:MM:SS.mmm", "MM:SS", "MM:SS.mmm", "SS", "SS.mmm" + if (!s || typeof s !== 'string') + return 0; + const [timePart, msPartRaw] = s.trim().split('.'); + const parts = timePart.split(':').map((p) => p.trim()).filter(Boolean); + const msPart = msPartRaw ? msPartRaw.padEnd(3, '0').slice(0, 3) : '0'; + const ms = Number(msPart); + let hh = 0, mm = 0, ss = 0; + if (parts.length === 3) { + hh = Number(parts[0]); + mm = Number(parts[1]); + ss = Number(parts[2]); + } + else if (parts.length === 2) { + mm = Number(parts[0]); + ss = Number(parts[1]); + } + else if (parts.length === 1) { + ss = Number(parts[0]); + } + else { + return 0; + } + if (![hh, mm, ss, ms].every((n) => Number.isFinite(n))) + return 0; + return ((hh * 60 + mm) * 60 + ss) * 1000 + ms; + } + msToHms(ms) { + const total = Math.max(0, Math.floor(ms)); + const hh = Math.floor(total / 3600000); + const mm = Math.floor((total % 3600000) / 60000); + const ss = Math.floor((total % 60000) / 1000); + const mmm = total % 1000; + return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}.${String(mmm).padStart(3, '0')}`; + } + formatTod(epochMs) { + // 24-hour time, Companion host timezone + return new Date(epochMs).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }); + } + secondsToHms(totalSeconds) { + const s = Math.max(0, Math.floor(totalSeconds)); + const hh = Math.floor(s / 3600); + const mm = Math.floor((s % 3600) / 60); + const ss = s % 60; + return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`; + } + computeAndSetCueVars(posMs) { + if (!Number.isFinite(posMs)) { + this.log('warn', `posMs is not finite. current_position parse failed.`); + this.setVariableValues({ + next_cue_name: '', + next_cue_time_in_seconds: '', + next_cue_time_in_hms: '', + next_cue_position: '', + next_cue_tod: '', + prev_cue_name: '', + prev_cue_time_ago_in_seconds: '', + prev_cue_time_ago_in_hms: '', + prev_cue_position: '', + prev_cue_tod: '', + }); + return; + } + if (!this.cues || this.cues.length === 0) { + this.setVariableValues({ + next_cue_name: '', + next_cue_time_in_seconds: '', + next_cue_time_in_hms: '', + next_cue_position: '', + next_cue_tod: '', + prev_cue_name: '', + prev_cue_time_ago_in_seconds: '', + prev_cue_time_ago_in_hms: '', + prev_cue_position: '', + prev_cue_tod: '', + }); + return; + } + // Find next cue (first > posMs) and prev cue (last <= posMs) + let next; + let prev; + for (const c of this.cues) { + if (c.positionMs > posMs) { + next = c; + break; + } + prev = c; + } + const vars = {}; + // --- NEXT --- + if (next) { + const secsUntil = Math.max(0, Math.ceil((next.positionMs - posMs) / 1000)); + vars.next_cue_name = next.name ?? ''; + vars.next_cue_time_in_seconds = String(secsUntil); + vars.next_cue_time_in_hms = this.secondsToHms(secsUntil); + vars.next_cue_position = this.msToHms(next.positionMs); + vars.next_cue_tod = this.eventMediaStartEpochMs + ? this.formatTod(this.eventMediaStartEpochMs + next.positionMs) + : ''; + } + else { + // Optional fallback if user wants a default countdown when there is no "next cue" + const fallback = Number(this.config.noNextCueSeconds ?? 0); + const secs = Number.isFinite(fallback) && fallback > 0 ? Math.floor(fallback) : NaN; + vars.next_cue_name = ''; + vars.next_cue_time_in_seconds = Number.isFinite(secs) ? String(secs) : ''; + vars.next_cue_time_in_hms = Number.isFinite(secs) ? this.secondsToHms(secs) : ''; + vars.next_cue_position = ''; + vars.next_cue_tod = ''; + } + // --- PREV --- + if (prev) { + const secsAgo = Math.max(0, Math.floor((posMs - prev.positionMs) / 1000)); + vars.prev_cue_name = prev.name ?? ''; + vars.prev_cue_time_ago_in_seconds = String(secsAgo); + vars.prev_cue_time_ago_in_hms = this.secondsToHms(secsAgo); + vars.prev_cue_position = this.msToHms(prev.positionMs); + vars.prev_cue_tod = this.eventMediaStartEpochMs + ? this.formatTod(this.eventMediaStartEpochMs + prev.positionMs) + : ''; + } + else { + vars.prev_cue_name = ''; + vars.prev_cue_time_ago_in_seconds = ''; + vars.prev_cue_time_ago_in_hms = ''; + vars.prev_cue_position = ''; + vars.prev_cue_tod = ''; + } + this.setVariableValues(vars); + } + async refreshVenuesAndPlayers() { + if (!this.customerId) + return; + await this.ensureAuthenticated(false); + if (!this.cookieHeader) + return; + // venues + const venuesRes = await fetch(`https://central.resi.io/api/v3/customers/${this.customerId}/venues`, { + method: 'GET', + headers: this.buildAuthHeaders(), + }); + if (!venuesRes.ok) { + this.noteRefusal(venuesRes.status, 'its venues', (Number(venuesRes.headers.get('retry-after')) || 0) * 1000); + throw new Error(`venues failed (${venuesRes.status})`); + } + const venuesJson = (await venuesRes.json()); + this.venues = venuesJson; + // players per venue + this.playersByVenueUuid = {}; + for (const v of this.venues) { + const playersRes = await fetch(`https://central.resi.io/api_v2.svc/users/${v.uuid}/players`, { + method: 'GET', + headers: this.buildAuthHeaders(), + }); + if (!playersRes.ok) + continue; + const playersJson = (await playersRes.json()); + this.playersByVenueUuid[v.uuid] = playersJson.map((p) => ({ + playerHardwareId: p.playerHardwareId, + userName: p.userName, + position: p.position, + eventId: p.eventId, + })); + } + // refresh action dropdown choices + this.updateActions(); + } + lookupCueByName(queryRaw, mode = 'exact') { + const query = (queryRaw ?? '').trim(); + this.setVariableValues({ lookup_cue_query: query }); + if (!query) { + this.setVariableValues({ + lookup_cue_found: 'false', + lookup_cue_name: '', + lookup_cue_uuid: '', + lookup_cue_position: '', + lookup_cue_position_sec: '', + lookup_cue_tod: '', + }); + return; + } + const norm = (s) => s.trim().toLowerCase(); + const match = mode === 'contains' + ? this.cues.find((c) => norm(c.name).includes(norm(query))) + : this.cues.find((c) => norm(c.name) === norm(query)); + if (!match) { + this.setVariableValues({ + lookup_cue_found: 'false', + lookup_cue_name: '', + lookup_cue_uuid: '', + lookup_cue_position: '', + lookup_cue_position_sec: '', + lookup_cue_tod: '', + }); + return; + } + this.setVariableValues({ + lookup_cue_found: 'true', + lookup_cue_name: match.name ?? '', + lookup_cue_uuid: match.uuid ?? '', + lookup_cue_position: this.msToHms(match.positionMs), + lookup_cue_position_sec: String(Math.floor(match.positionMs / 1000)), + lookup_cue_tod: this.eventMediaStartEpochMs ? this.formatTod(this.eventMediaStartEpochMs + match.positionMs) : '', + }); + } + isValidHmsMs(s) { + // Accept HH:MM:SS or HH:MM:SS.mmm (hours can be 1+ digits) + return /^\d+:[0-5]\d:[0-5]\d(\.\d{1,3})?$/.test(s.trim()); + } + normalizeHmsMs(s) { + // Ensure we always send HH:MM:SS.mmm + const trimmed = (s ?? '').trim(); + const [timePart, msRaw] = trimmed.split('.'); + const ms = (msRaw ?? '0').padEnd(3, '0').slice(0, 3); + // If they typed "4:00:00", keep it (hours can be 1 digit) but add .mmm + return `${timePart}.${ms}`; + } + async refreshCuesIfDue() { + // Must have an event loaded + if (!this.currentEventId || !this.currentEventProfileId) + return; + // Don’t overlap cue refreshes + if (this.cueRefreshInFlight) + return; + // How often to refresh cues while playing (ms) + // If you want “every player poll”, set this to your playerPollMs, + // but recommended: 10000–30000. + const intervalMs = Math.max(1000, Number(this.config.cuePollMs ?? 5000) || 5000); + const now = Date.now(); + if (now - this.lastCueRefreshMs < intervalMs) + return; + this.cueRefreshInFlight = true; + this.lastCueRefreshMs = now; + try { + await this.refreshEventProfileAndCues(); + } + catch (err) { + // Don’t hard-fail playback polling if cues refresh fails + this.log('warn', `Cue refresh failed: ${err?.message ?? err}`); + } + finally { + this.cueRefreshInFlight = false; + } + } + /** + * Adds a cue to the currently-selected event, at either current player position or a custom time. + */ + async addCueFromPosition(opts) { + const cueName = (opts.name ?? '').trim(); + if (!cueName) + throw new Error('Cue name is required'); + if (!this.currentEventId || !this.currentEventProfileId) { + throw new Error('No active event loaded yet. Select a player/event first.'); + } + const user = (this.config.username ?? '').trim(); + if (!user) + throw new Error('Missing username (needed for cue user field)'); + let position = ''; + if (opts.positionMode === 'player') { + // ✅ THIS is what “uses” lastPlayerPosition + const p = (this.lastPlayerPosition ?? '').trim(); + if (!p) + throw new Error('Current player position is not available yet'); + if (!this.isValidHmsMs(p)) + throw new Error(`Current player position is invalid: ${p}`); + position = this.normalizeHmsMs(p); + } + else { + const custom = (opts.customPosition ?? '').trim(); + if (!custom) + throw new Error('Custom position is required'); + if (!this.isValidHmsMs(custom)) + throw new Error('Custom position must be like HH:MM:SS or HH:MM:SS.mmm'); + position = this.normalizeHmsMs(custom); + } + await this.ensureAuthenticated(false); + if (!this.cookieHeader) + return; + const url = `https://central.resi.io/api_v2.svc/streamprofiles/${this.currentEventProfileId}/events/${this.currentEventId}/cues`; + const body = { position, name: cueName, privateCue: !!opts.privateCue, user }; + const res = await fetch(url, { + method: 'POST', + headers: { ...this.buildAuthHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Add cue failed (${res.status}) ${text}`.trim()); + } + this.log('info', `Added cue "${cueName}" at ${position}`); + await this.refreshEventProfileAndCues(); + } + async deleteCueByName(opts) { + const query = (opts.name ?? '').trim(); + if (!query) + throw new Error('Cue name is required'); + if (!this.currentEventId || !this.currentEventProfileId) { + throw new Error('No active event loaded yet. Select a player/event first.'); + } + // Make sure we have current cues loaded + if (!this.cues || this.cues.length === 0) { + await this.refreshEventProfileAndCues(); + } + const norm = (s) => s.trim().toLowerCase(); + const qn = norm(query); + // Note: your this.cues is { uuid, name, positionMs } (no user field), + // so if you want onlyMine filtering, we need to store "user" too when loading cues. + // For now, onlyMine will be best-effort if you extend cues to include user. + const myUser = (this.config.username ?? '').trim().toLowerCase(); + // If you haven't extended cue storage with user, this will just skip that filter. + const cuesAny = this.cues; + const matches = cuesAny.filter((c) => { + const nameOk = opts.matchMode === 'contains' ? norm(c.name ?? '').includes(qn) : norm(c.name ?? '') === qn; + if (!nameOk) + return false; + if (opts.onlyMine) { + // works only if c.user exists + if (typeof c.user !== 'string') + return false; + return c.user.trim().toLowerCase() === myUser; + } + return true; + }); + if (matches.length === 0) { + throw new Error(`No cue matched "${query}"`); + } + const toDelete = opts.deleteMode === 'all' ? matches : [matches[0]]; + await this.ensureAuthenticated(false); + let deleted = 0; + for (const c of toDelete) { + const uuid = String(c.uuid ?? '').trim(); + if (!uuid) + continue; + const url = `https://central.resi.io/api_v2.svc/streamprofiles/${this.currentEventProfileId}/events/${this.currentEventId}/cues/${uuid}`; + const res = await fetch(url, { + method: 'DELETE', + headers: this.buildAuthHeaders(), + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`Delete cue failed (${res.status}) ${text}`.trim()); + } + deleted++; + } + this.log('info', `Deleted ${deleted} cue(s) matching "${query}"`); + await this.refreshEventProfileAndCues(); + } + async destroy() { + if (this.pollTimer) + clearInterval(this.pollTimer); + if (this.playerPollTimer) + clearInterval(this.playerPollTimer); + this.playerPollTimer = undefined; + if (this.__tickTimer) + clearInterval(this.__tickTimer); + this.__tickTimer = undefined; + this.pollTimer = undefined; + this.log('debug', 'destroy'); + } +} +exports.ModuleInstance = ModuleInstance; +(0, base_1.runEntrypoint)(ModuleInstance, upgrades_js_1.UpgradeScripts); +//# sourceMappingURL=main.js.map \ No newline at end of file diff --git a/source/dist/presets.js b/source/dist/presets.js new file mode 100644 index 0000000..9ad9907 --- /dev/null +++ b/source/dist/presets.js @@ -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 \ No newline at end of file diff --git a/source/dist/upgrades.js b/source/dist/upgrades.js new file mode 100644 index 0000000..7f01d4f --- /dev/null +++ b/source/dist/upgrades.js @@ -0,0 +1,5 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.UpgradeScripts = void 0; +exports.UpgradeScripts = []; +//# sourceMappingURL=upgrades.js.map \ No newline at end of file diff --git a/source/dist/variables.js b/source/dist/variables.js new file mode 100644 index 0000000..88a056f --- /dev/null +++ b/source/dist/variables.js @@ -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 \ No newline at end of file diff --git a/source/manifest.json b/source/manifest.json new file mode 100644 index 0000000..590b6ed --- /dev/null +++ b/source/manifest.json @@ -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" + ] +} \ No newline at end of file diff --git a/source/npm_vendor.py b/source/npm_vendor.py new file mode 100644 index 0000000..c1b4a73 --- /dev/null +++ b/source/npm_vendor.py @@ -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)) diff --git a/source/resi_probe.py b/source/resi_probe.py new file mode 100644 index 0000000..60245fd --- /dev/null +++ b/source/resi_probe.py @@ -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") diff --git a/source/tick_test.js b/source/tick_test.js new file mode 100644 index 0000000..6f17f31 --- /dev/null +++ b/source/tick_test.js @@ -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]'); +})(); diff --git a/test_end_to_end.py b/test_end_to_end.py new file mode 100644 index 0000000..c6219a4 --- /dev/null +++ b/test_end_to_end.py @@ -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() diff --git a/test_playhead.py b/test_playhead.py new file mode 100644 index 0000000..8d24080 --- /dev/null +++ b/test_playhead.py @@ -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() diff --git a/test_rate_limit.py b/test_rate_limit.py new file mode 100644 index 0000000..fbd3da1 --- /dev/null +++ b/test_rate_limit.py @@ -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() diff --git a/test_stage_message.py b/test_stage_message.py new file mode 100644 index 0000000..709cf31 --- /dev/null +++ b/test_stage_message.py @@ -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()