Resi cue countdowns on ProPresenter and in Companion

The Resi -> ProPresenter bridge, its installer and site kit, and the
Companion module resi-studio-cues, as running at North Attleboro.

Bridge (resi_to_propresenter.py)
- Follows a Resi decoder's playhead and keeps ProPresenter's NextCue timer
  counting down to the next cue; optional cue name as a stage message, sent
  once per cue (ProPresenter flashes it on every send) and off by default.
- Playhead estimate no longer learns a cadence from the partial first gap
  after a start, so a 30s Resi doesn't read as paused half the time.
- Backs off on 429, holds 2 min before retrying a failed startup login, and
  logs Resi's actual reason (wrong password, USER_LOCKED, rate limit).
- --setup asks for the login, decoder, ProPresenter and timer.

install_bridge.sh installs it as a login service (outside ~/Documents,
which launchd may not read); make_kit.sh builds the kit for another site.

Companion module resi-studio-cues 0.1.4 (source/, package committed)
- Same playhead fix; login and request hold-off (15s doubling to 5 min);
  a 401/403 on a fresh session is a permission, not a reason to sign in
  again; init never fails on a Resi problem, so Companion can't restart it
  into a login loop.

Tests: test_playhead.py, test_stage_message.py, test_rate_limit.py and
source/backoff_test.js run offline; test_end_to_end.py drives a real
ProPresenter and refuses to run without an explicit flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 18:11:01 -04:00
co-authored by Claude Opus 5.5
commit 1a658875bc
24 changed files with 4548 additions and 0 deletions
+243
View File
@@ -0,0 +1,243 @@
# Resi Studio Cues (Bitfocus Companion Module)
This module logs into **studio.resi.io / central.resi.io** with a username + password, reads decoder/player playback position, loads cue points for the current event, and exposes variables/actions for cue timing and cue management.
The main goal is to drive things like a **countdown clock in ProPresenter** (time until next cue, next cue name, etc).
---
## Features
### Authentication
- Logs in via `POST /api/v3/login?newToken=true`
- Stores auth cookies (`refreshToken`, `livingas1`) and uses them on subsequent requests
- Exposes status via:
- Instance status (green/red in Companion)
- Variables: `auth_status`, `last_error`
### Player polling (live)
- Polls decoder/player position from:
- `GET /api_v2.svc/users/{venueUuid}/players`
- Updates variables like:
- `current_position`
- `current_position_sec`
- `current_event_id`
### Cue loading (per event)
When the current `eventId` changes, the module loads:
- Event detail:
- `GET /api/v3/customers/{customerId}/events/{eventId}`
- Captures `startTime` for ToD (time-of-day) math
- Cues:
- `GET /api_v2.svc/streamprofiles/{eventProfileId}/events/{eventId}/cues?canISetCues=1`
Cues are stored locally and used to compute **next/prev** cue timing from the live playback position.
### Cue timing variables
- Next cue:
- `next_cue_name`
- `next_cue_time_in_seconds`
- `next_cue_time_in_hms`
- `next_cue_position`
- `next_cue_tod` (24-hour)
- Previous cue:
- `prev_cue_name`
- `prev_cue_time_ago_in_seconds`
- `prev_cue_time_ago_in_hms`
- `prev_cue_position`
- `prev_cue_tod` (24-hour)
### Cue management actions
- Add cue (player position or custom time)
- Lookup cue by name (stores into `lookup_*` variables)
- Delete cue by name (looks up UUID, deletes by UUID)
---
## Installation / Testing
### Best (recommended): Developer Module via Git
1. Push this repo to GitHub/GitLab (public or private).
2. On the test system, add it as a Developer Module in Companion.
3. Restart Companion.
### Offline / manual
1. `yarn install`
2. `yarn build`
3. Zip the module folder (without `node_modules`) and provide to tester.
4. Tester places it in Companion’s modules folder and restarts Companion.
(Exact folder paths vary by OS / Companion version.)
---
## Configuration
Typical config fields:
- **Username / Email**
- **Password** (secret)
- **Default Player** (optional)
- **Polling intervals**
- Playback position refresh (fast, e.g. 2000ms)
- Account/venue refresh (slow, e.g. 60000ms)
- Cue refresh while playing (optional, e.g. 15000ms)
- **Fallback when no next cue** (optional)
> Note: dynamic "read-only" status display inside config is not supported by Companion config fields.
> Use Instance Status + module variables for connection state.
---
## Actions
- **Refresh profile**: Reload `/users/me`
- **Refresh lists**: Reload venues + players list
- **Select decoder/player**: Choose which player to poll
- **Add cue at player position (or custom time)**
- Options:
- Cue name
- Private cue
- Position source: player vs custom
- Custom time string
- Text fields support Companion variables (e.g. `$(internal:...)`)
- **Lookup cue by name**
- Writes result into:
- `lookup_cue_found`, `lookup_cue_uuid`, `lookup_cue_position`, etc.
- **Delete cue by name**
- Finds matching cue(s), deletes by UUID via:
- `DELETE /.../cues/{uuid}`
---
## Feedbacks (for button coloring)
Recommended “production” feedback set:
1. **Connected (auth OK)** → Green
2. **Error (auth/API)** → Red
3. **Player active** (position > 0) → Blue
4. **Approaching next cue** (threshold seconds) → Yellow
5. **Playback frozen** (position not changing) → Red
Playback frozen requires:
- variable `playback_last_change_epoch_ms` to be updated when playback position changes.
---
## Presets Included
### Status
- Connected / Error indicator
- Player active indicator
- Playback frozen warning
### Cues
- Next cue display (with T- countdown)
- Previous cue display
### Maintenance
- Refresh profile
- Refresh venues/players list
### Cue actions
- Add cue @ player position
- Add cue @ custom time
- Lookup cue by name
- Delete cue by name
---
## Notes / Limitations
- The Resi API endpoints used here are not public/stable; breaking changes may occur.
- Avoid polling cues every 2 seconds. Recommended cue refresh while playing is 10–30 seconds unless you have a specific need.
- Companion config fields are not truly read-only, so status is exposed via variables + instance status.
---
## Roadmap Ideas
- Optional periodic cue refresh while playing (rate-limited)
- Extra feedback: "No player selected"
- Extra feedback: "No next cue"
- More presets for ProPresenter integration workflows
---
## Playhead accuracy (0.1.0)
Resi only republishes a decoder's `position` every few seconds, on an irregular
cadence, and each reading is already slightly stale when it appears. Showing that
number raw makes the countdown jump in steps and sit several seconds behind.
This build instead keeps a running estimate:
- every reading is stored as **media time minus wall time**; the largest recent value
is the least-delayed reading and is the one trusted, so a slow round trip or an
unlucky poll can no longer drag the countdown backwards
- the display is recomputed **every second** from that offset, so the countdown ticks
smoothly between Resi's updates instead of stepping
- a reading that differs from the locked offset by more than the link's measured
noise is treated as a **seek** - the lock is dropped, the estimate snaps, and the
cue list is re-fetched immediately rather than on its timer
- playback is only called **PAUSED** once the playhead has been still for clearly
longer than Resi's own reporting cadence, so an irregular gap no longer freezes the
countdown
`position_update_interval_sec` reports how often Resi is actually updating. That
number is the floor on how fast a jump can possibly be noticed.
### Beating that floor
If cues are fired from Companion, the module does not have to wait for Resi to
notice. Add one of these to the same button:
- **Resync playhead to cue** - give it the cue name; the estimate moves there at once
- **Resync playhead to the NEXT cue**
The next real reading from Resi confirms or corrects it.
### Settings that matter
- **Player position poll (ms)** - 1000 is recommended. Accuracy no longer depends on
this, but a faster poll notices a jump sooner.
- **Cue list refresh (ms)** - 5000. Also forced immediately whenever a seek is seen.
- **Position trim (ms)** - leave at 0. Only needed if the whole pipeline is
consistently behind; positive values push the estimate forward.
## Startup fix (0.1.2)
On a Resi that publishes every ~30s, the module could start a few seconds before a
publish and learn a 2s cadence from that partial first gap. It then threw out every
real 30s gap, so `playback_state` flipped to **PAUSED** 15s into each update - the
countdown froze and the next cue lagged, worst with cues close together.
- the partial gap after startup (or after a manual resync) is no longer learned
- a single outlier gap can no longer lock out the real cadence
- until the cadence is measured, PAUSED waits 45s instead of 15s
## Resi login back-off (0.1.3)
0.1.2 retried a failed Resi login on every player poll (once a second, for ever), and
answered every 401/403 by signing in again. With a wrong password, a rate limit or a
login that may not see decoders, that is thousands of sign-ins an hour: Resi replies
with 429 and then locks the account (it locked one at North Attleboro).
- after any failed login, or a 429 on any request, every Resi call holds off: 15s,
doubling to 5min, or as long as Resi's Retry-After asks
- a 401/403 renews the session only if it is over a minute old; a refusal straight
after signing in is a permission ("ask a Resi admin for access") and holds off
- status and `last_error` say which: locked (USER_LOCKED), rate-limited, password
rejected, no access, or unreachable
- the log says it once per problem, not once a second
- the cache-busting parameter is no longer dropped for good on a 401/403/429
## Start-up never crashes on a Resi problem (0.1.4)
In 0.1.3 a failed Resi login at start-up (here: a locked account) made the module's
init fail. Companion counts that as a crash and restarts the module every few
seconds, and each restart signed in again from scratch, outside the hold-off.
- init no longer fails when Resi refuses or is down: it logs "Not connected to Resi
yet … will keep trying", starts the account poll, and the hold-off paces retries
- once Resi answers, the configured decoder is picked up automatically
- saving new credentials clears the old hold-off and starts over
+158
View File
@@ -0,0 +1,158 @@
// A failed or refused Resi login must hold off, not retry on every poll.
//
// Seen at North Attleboro: with a bad password, a rate limit or a login without
// decoder access, 0.1.2 signed in again on every 1s player poll. Resi answered with
// 429s and then locked the account. Drives the real module code on a fake clock
// against a fake Resi; touches nothing real.
//
// node backoff_test.js (defaults to dist/main.js)
// node backoff_test.js path/to/main.js
'use strict';
const Module = require('module');
const path = require('path');
const assert = require('assert');
const target = path.resolve(process.argv[2] || path.join(__dirname, 'dist/main.js'));
const stubs = {
'@companion-module/base': {
InstanceBase: class { updateStatus() {} log() {} setVariableValues() {} },
InstanceStatus: { Ok: 'ok', Connecting: 'c', ConnectionFailure: 'f', BadConfig: 'b' },
runEntrypoint() {}, combineRgb() { return 0 }, Regex: {},
},
'./config.js': { GetConfigFields: () => [] },
'./variables.js': { UpdateVariableDefinitions() {} },
'./upgrades.js': { UpgradeScripts: [] },
'./actions.js': { UpdateActions() {} },
'./feedbacks.js': { UpdateFeedbacks() {} },
'./presets.js': { UpdatePresets() {} },
};
const realLoad = Module._load;
Module._load = function (req, parent, isMain) {
if (stubs[req]) return stubs[req];
return realLoad.apply(this, arguments);
};
let now = 1700000000000;
Date.now = () => now;
let mode = 'limited'; // what the fake Resi does with a login
let logins = 0;
let venueCalls = 0;
let playerCalls = 0;
const reply = (status, body, headers) => ({
ok: status >= 200 && status < 300,
status,
headers: { get: (k) => (headers || {})[k.toLowerCase()] ?? null, getSetCookie: () => (headers || {})['set-cookie'] || [] },
json: async () => body,
text: async () => JSON.stringify(body),
});
global.fetch = async (url, opts) => {
const p = String(url).replace('https://central.resi.io', '').split('?')[0];
if (p === '/api/v3/login') {
logins++;
if (mode === 'limited') return reply(429, { message: 'Rate limit exceeded' });
if (mode === 'locked') return reply(401, { code: 'USER_LOCKED', message: 'Unauthorized' });
return reply(200, {}, { 'set-cookie': ['livingas1=T; Path=/'] });
}
if (p === '/api_v2.svc/users/me') return reply(200, { customerId: 'C' });
if (p === '/api/v3/customers/C/venues') { venueCalls++; return reply(mode === 'noaccess' ? 403 : 200, [{ uuid: 'V1', name: 'NA' }]); }
if (p.endsWith('/players')) {
playerCalls++;
return reply(mode === 'noaccess' ? 403 : 200,
[{ playerHardwareId: 'DEC1', userName: 'NA', position: '00:10:00', eventId: null }]);
}
return reply(404, {});
};
const { ModuleInstance } = require(target);
function fresh() {
const inst = Object.create(ModuleInstance.prototype);
inst.config = { username: 'a@b.c' };
inst.secrets = { password: 'pw' };
inst.lastLoginMs = 0;
inst.selectedVenueUuid = 'V1';
inst.selectedPlayerHardwareId = 'DEC1';
inst.playerPollInFlight = false;
inst.updateStatus = () => {};
inst.setVariableValues = () => {};
inst.warns = [];
inst.log = (lvl, m) => { if (lvl === 'warn' || lvl === 'error') inst.warns.push(m); };
// no real timers: the test drives the polls itself
inst.startPolling = () => { inst.pollingStarted = true; };
inst.startPlayerPolling = () => {};
inst.startSmoothTick = () => {};
inst.refreshCuesIfDue = async () => {};
return inst;
}
async function pollFor(inst, seconds) {
for (let i = 0; i < seconds; i++) {
now += 1000;
await inst.pollPlayer();
}
}
let passed = 0;
async function test(name, fn) { await fn(); passed++; console.log(` ok ${name}`); }
(async () => {
await test('a rate-limited login is not retried every poll', async () => {
mode = 'limited'; logins = 0;
const inst = fresh();
await pollFor(inst, 300); // five minutes of 1s polls
assert.ok(logins <= 6, `signed in ${logins} times in five rate-limited minutes`);
assert.ok(inst.warns.length <= 6, `logged ${inst.warns.length} warnings (one per poll would be 300)`);
});
await test('a locked account says so and holds off', async () => {
mode = 'locked'; logins = 0;
const inst = fresh();
await pollFor(inst, 120);
assert.ok(logins <= 4, `signed in ${logins} times against a locked account`);
assert.ok(inst.warns.some((w) => /USER_LOCKED/.test(w)), 'the lock is named in the log');
});
await test('it recovers once Resi lets it in, and keeps one session', async () => {
mode = 'limited'; logins = 0;
const inst = fresh();
await pollFor(inst, 30);
mode = 'ok';
await pollFor(inst, 330); // past the longest hold-off
const after = logins;
playerCalls = 0;
await pollFor(inst, 30);
assert.strictEqual(logins, after, 'a good session is not signed in again');
assert.ok(playerCalls >= 29, `polls the decoder normally once in (${playerCalls} in 30s)`);
});
await test('a login without decoder access is not signed in every poll', async () => {
mode = 'noaccess'; logins = 0; playerCalls = 0;
const inst = fresh();
await pollFor(inst, 120);
assert.ok(logins <= 3, `signed in ${logins} times in two minutes for a permission problem`);
assert.ok(playerCalls <= 10, `asked for decoders ${playerCalls} times (a poll each would be 120)`);
assert.ok(inst.warns.some((w) => /ask a Resi admin/.test(w)), 'says what to do about it');
});
// Seen live with 0.1.3: a locked account made init() throw, Companion restarted the
// module every few seconds, and every restart signed in again from scratch.
await test('init survives a locked account, and recovers once it is unlocked', async () => {
mode = 'locked'; logins = 0;
const inst = fresh();
inst.config.defaultPlayerHardwareId = 'DEC1';
inst.venues = []; inst.playersByVenueUuid = {};
await inst.init(inst.config, true, inst.secrets); // must not throw
assert.ok(inst.pollingStarted, 'polling starts even though Resi refused');
for (let i = 0; i < 60; i++) { now += 10000; await inst.pollOnce(false); } // ten minutes of account polls
// 0s, 15s, 45s, 105s, 225s, 465s: six. The restart loop did about one every 6s.
assert.ok(logins <= 6, `signed in ${logins} times in ten locked minutes`);
mode = 'ok';
now += 300000;
await inst.pollOnce(false);
assert.strictEqual(inst.selectedVenueUuid, 'V1', 'picks up the configured decoder by itself');
});
console.log(`\n${passed} tests passed`);
})().catch((e) => { console.error(e); process.exit(1); });
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Walk every .js in a package and check that each require()/import target resolves,
following Node's resolution rules against the vendored node_modules tree."""
import json
import os
import re
import sys
ROOT = os.path.abspath(sys.argv[1])
BUILTIN = set("""assert async_hooks buffer child_process cluster console constants crypto dgram
diagnostics_channel dns domain events fs http http2 https inspector module net os path
perf_hooks process punycode querystring readline repl stream string_decoder sys timers tls
trace_events tty url util v8 vm wasi worker_threads zlib""".split())
SPEC = re.compile(r"""(?:require\(|(?:^|[\s;{}])(?:import|export)[^;\n]*?\bfrom\s*|import\()\s*['"]([^'"]+)['"]""", re.M)
DYNAMIC = re.compile(r"require\(\s*[^'\")\s]")
COMMENTS = re.compile(r"/\*.*?\*/|^\s*//.*$", re.S | re.M)
# Optional at runtime: loaded only under a config or platform this module never uses.
OPTIONAL = {"re2", "expo-random", "tinybench", "uri-js", "neostandard"}
SKIP_DIRS = ("test", "benchmark", "benchmarks", "example", "examples", ".github")
def load_json(p):
try:
with open(p) as fh:
return json.load(fh)
except Exception:
return {}
def resolve_file(base):
for cand in (base, base + ".js", base + ".json", base + ".cjs", base + ".mjs", base + ".node"):
if os.path.isfile(cand):
return cand
if os.path.isdir(base):
pj = load_json(os.path.join(base, "package.json"))
for key in ("main", "module"):
if isinstance(pj.get(key), str):
hit = resolve_file(os.path.join(base, pj[key]))
if hit:
return hit
if pj.get("exports"):
return base # exports map: trust it, too complex to model here
for idx in ("index.js", "index.json", "index.cjs", "index.node"):
if os.path.isfile(os.path.join(base, idx)):
return os.path.join(base, idx)
return None
def resolve_bare(spec, from_dir):
d = from_dir
while True:
hit = resolve_file(os.path.join(d, "node_modules", *spec.split("/")))
if hit:
return hit
# scoped or deep import into a package that exists
parts = spec.split("/")
pkg = "/".join(parts[:2]) if spec.startswith("@") else parts[0]
pkg_dir = os.path.join(d, "node_modules", *pkg.split("/"))
if os.path.isdir(pkg_dir) and len(parts) > (2 if spec.startswith("@") else 1):
sub = resolve_file(os.path.join(pkg_dir, *parts[(2 if spec.startswith("@") else 1):]))
if sub:
return sub
if load_json(os.path.join(pkg_dir, "package.json")).get("exports"):
return pkg_dir
if os.path.dirname(d) == d or len(d) <= len(ROOT) - 1:
return None
d = os.path.dirname(d)
missing, dynamic, files, optional_hits = [], [], 0, set()
for dirpath, dirnames, filenames in os.walk(ROOT):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
if dirpath.endswith((os.sep + "browser", os.sep + "types")):
continue
for fn in filenames:
if not fn.endswith((".js", ".cjs", ".mjs")):
continue
if fn.endswith((".native.js", ".browser.js")) or fn.startswith("eslint.config"):
continue
path = os.path.join(dirpath, fn)
files += 1
try:
src = COMMENTS.sub("", open(path, encoding="utf-8", errors="replace").read())
except OSError:
continue
if DYNAMIC.search(src):
dynamic.append(os.path.relpath(path, ROOT))
for spec in set(SPEC.findall(src)):
if spec.startswith("node:") or spec.split("/")[0] in BUILTIN:
continue
if spec in OPTIONAL:
optional_hits.add((os.path.relpath(path, ROOT), spec))
continue
if spec.startswith("."):
hit = resolve_file(os.path.normpath(os.path.join(dirpath, spec)))
else:
hit = resolve_bare(spec, dirpath)
if not hit:
missing.append((os.path.relpath(path, ROOT), spec))
print("scanned %d js files" % files)
if dynamic:
print("\n%d file(s) use a computed require() (not statically checkable):" % len(dynamic))
for d in sorted(dynamic)[:12]:
print(" ", d)
if optional_hits:
print("\n%d optional dep(s), not loaded on this platform/config:" % len(optional_hits))
for f, spec in sorted(optional_hits):
print(" %-64s -> %s" % (f, spec))
if missing:
print("\nUNRESOLVED (%d):" % len(missing))
for f, s in sorted(set(missing)):
print(" %-70s -> %s" % (f, s))
sys.exit(1)
print("\nevery static require/import resolves")
+184
View File
@@ -0,0 +1,184 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UpdateActions = UpdateActions;
function UpdateActions(self) {
self.setActionDefinitions({
refresh_profile: {
name: 'Refresh profile (users/me)',
options: [],
callback: async () => {
await self.refreshProfileNow();
},
},
refresh_lists: {
name: 'Refresh venues/players list',
options: [],
callback: async () => {
await self.refreshListsNow();
},
},
add_cue_at_position: {
name: 'Add cue at player position (or custom time)',
options: [
{
id: 'name',
type: 'textinput',
label: 'Cue name',
default: 'Web Cue',
useVariables: true,
},
{
id: 'privateCue',
type: 'checkbox',
label: 'Private cue',
default: false,
},
{
id: 'positionMode',
type: 'dropdown',
label: 'Position source',
default: 'player',
choices: [
{ id: 'player', label: 'Player position (current)' },
{ id: 'custom', label: 'Custom time' },
],
},
{
id: 'customPosition',
type: 'textinput',
label: 'Custom position (HH:MM:SS or HH:MM:SS.mmm)',
default: '00:00:00.000',
useVariables: true,
},
],
callback: async (evt) => {
const name = (await self.parseVariablesInString(String(evt.options.name ?? ''))).trim();
const privateCue = Boolean(evt.options.privateCue);
const positionMode = String(evt.options.positionMode ?? 'player');
const customPosition = (await self.parseVariablesInString(String(evt.options.customPosition ?? ''))).trim();
await self.addCueFromPosition({
name,
privateCue,
positionMode,
customPosition,
});
},
},
delete_cue_by_name: {
name: 'Delete cue by name',
options: [
{
id: 'cueName',
type: 'textinput',
label: 'Cue name',
default: '',
useVariables: true,
},
{
id: 'matchMode',
type: 'dropdown',
label: 'Match mode',
default: 'exact',
choices: [
{ id: 'exact', label: 'Exact (case-insensitive)' },
{ id: 'contains', label: 'Contains (case-insensitive)' },
],
},
{
id: 'deleteMode',
type: 'dropdown',
label: 'Delete',
default: 'first',
choices: [
{ id: 'first', label: 'First match only' },
{ id: 'all', label: 'All matches' },
],
},
{
id: 'onlyMine',
type: 'checkbox',
label: 'Only delete cues created by this user',
default: true,
},
],
callback: async (evt) => {
const cueName = await self.parseVariablesInString(String(evt.options.cueName ?? ''));
const matchMode = String(evt.options.matchMode ?? 'exact');
const deleteMode = String(evt.options.deleteMode ?? 'first');
const onlyMine = Boolean(evt.options.onlyMine);
await self.deleteCueByName({
name: cueName,
matchMode,
deleteMode,
onlyMine,
});
},
},
select_decoder: {
name: 'Select decoder/player',
options: [
{
id: 'playerId',
type: 'dropdown',
label: 'Decoder / Player',
default: '',
choices: self.getPlayerChoices(),
},
],
callback: async (evt) => {
await self.selectPlayer(String(evt.options.playerId || ''));
},
},
resync_to_cue: {
name: 'Resync playhead to cue (use on the button that fires it)',
options: [
{
id: 'cueName',
type: 'textinput',
label: 'Cue name',
default: '',
useVariables: true,
},
],
callback: async (evt) => {
const cueName = await self.parseVariablesInString(String(evt.options.cueName ?? ''));
self.resyncToCue(cueName);
},
},
resync_to_next_cue: {
name: 'Resync playhead to the NEXT cue',
options: [],
callback: async () => {
self.resyncToNextCue();
},
},
lookup_cue_by_name: {
name: 'Lookup cue by name (stores in lookup_* variables)',
options: [
{
id: 'cueName',
type: 'textinput',
label: 'Cue name',
default: '',
useVariables: true,
},
{
id: 'matchMode',
type: 'dropdown',
label: 'Match mode',
default: 'exact',
choices: [
{ id: 'exact', label: 'Exact (case-insensitive)' },
{ id: 'contains', label: 'Contains (case-insensitive)' },
],
},
],
callback: async (evt) => {
const cueName = await self.parseVariablesInString(String(evt.options.cueName ?? ''));
const mode = String(evt.options.matchMode ?? 'exact');
self.lookupCueByName(cueName, mode);
},
},
});
}
//# sourceMappingURL=actions.js.map
+65
View File
@@ -0,0 +1,65 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.GetConfigFields = GetConfigFields;
function GetConfigFields() {
return [
{
type: 'textinput',
id: 'username',
label: 'Username / Email',
width: 12,
},
{
type: 'dropdown',
id: 'defaultPlayerHardwareId',
label: 'Default decoder/player on startup',
width: 12,
default: '',
choices: [{ id: '', label: '(none)' }],
},
// NEW: polling intervals
{
type: 'number',
id: 'playerPollMs',
label: 'Player position poll (ms)',
width: 6,
min: 250,
max: 30000,
default: 1000,
},
{
type: 'number',
id: 'positionTrimMs',
label: 'Position trim (ms) - only if it still reads behind',
width: 6,
min: -30000,
max: 30000,
default: 0,
},
{
type: 'number',
id: 'cuePollMs',
label: 'Cue list refresh (ms)',
width: 6,
min: 1000,
max: 60000,
default: 5000,
},
{
type: 'number',
id: 'listPollMs',
label: 'User & decoder inf0 poll (ms)',
width: 6,
min: 10000,
max: 3600000,
default: 120000,
},
{
type: 'secret-text',
id: 'password',
label: 'Password',
width: 12,
}
];
}
//# sourceMappingURL=config.js.map
+113
View File
@@ -0,0 +1,113 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UpdateFeedbacks = UpdateFeedbacks;
const base_1 = require("@companion-module/base");
function UpdateFeedbacks(self) {
self.setFeedbackDefinitions({
resi_connected: {
name: 'Resi: Connected (auth OK)',
type: 'boolean',
defaultStyle: {
bgcolor: (0, base_1.combineRgb)(0, 200, 0),
color: (0, base_1.combineRgb)(0, 0, 0),
},
options: [],
callback: () => {
const v = self.getVariableValue('auth_status') ?? '';
return v === 'OK';
},
},
resi_error: {
name: 'Resi: Error (auth/API)',
type: 'boolean',
defaultStyle: {
bgcolor: (0, base_1.combineRgb)(200, 0, 0),
color: (0, base_1.combineRgb)(255, 255, 255),
},
options: [],
callback: () => {
const auth = (self.getVariableValue('auth_status') ?? '').trim();
const err = (self.getVariableValue('last_error') ?? '').trim();
// True when we have an error message OR auth isn't OK (but ignore "Missing credentials" if you prefer)
return err.length > 0 || (auth.length > 0 && auth !== 'OK');
},
},
player_active: {
name: 'Resi: Player active (position > 0s)',
type: 'boolean',
defaultStyle: {
bgcolor: (0, base_1.combineRgb)(0, 120, 255),
color: (0, base_1.combineRgb)(255, 255, 255),
},
options: [],
callback: () => {
const s = self.getVariableValue('current_position_sec') ?? '0';
const n = Number(s);
return Number.isFinite(n) && n > 0;
},
},
approaching_next_cue: {
name: 'Resi: Approaching next cue (<= threshold seconds)',
type: 'boolean',
defaultStyle: {
bgcolor: (0, base_1.combineRgb)(255, 180, 0),
color: (0, base_1.combineRgb)(0, 0, 0),
},
options: [
{
id: 'threshold',
type: 'number',
label: 'Threshold (seconds)',
default: 30,
min: 1,
max: 3600,
},
],
callback: (fb) => {
const nextName = (self.getVariableValue('next_cue_name') ?? '').trim();
if (!nextName)
return false;
const s = self.getVariableValue('next_cue_time_in_seconds') ?? '';
const n = Number(s);
if (!Number.isFinite(n))
return false;
const thr = Number(fb.options.threshold);
return Number.isFinite(thr) ? n <= thr : false;
},
},
playback_frozen: {
name: 'Resi: Playback frozen (position not changing)',
type: 'boolean',
defaultStyle: {
bgcolor: (0, base_1.combineRgb)(200, 0, 0),
color: (0, base_1.combineRgb)(255, 255, 255),
},
options: [
{
id: 'staleSeconds',
type: 'number',
label: 'Frozen if unchanged for (seconds)',
default: 6,
min: 2,
max: 120,
},
],
callback: (fb) => {
const staleSeconds = Number(fb.options.staleSeconds) || 6;
const curStr = self.getVariableValue('current_position_sec') ?? '';
const cur = Number(curStr);
if (!Number.isFinite(cur))
return false;
const now = Date.now();
const lastStr = self.getVariableValue('playback_last_change_epoch_ms') ?? '';
const last = Number(lastStr);
// If you haven't implemented playback_last_change_epoch_ms yet, we can't detect freezing reliably.
if (!Number.isFinite(last) || last <= 0)
return false;
const ageSec = (now - last) / 1000;
return ageSec >= staleSeconds;
},
},
});
}
//# sourceMappingURL=feedbacks.js.map
+1076
View File
File diff suppressed because it is too large Load Diff
+303
View File
@@ -0,0 +1,303 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UpdatePresets = UpdatePresets;
const base_1 = require("@companion-module/base");
function UpdatePresets(self) {
const presets = {};
// Helper to reference THIS instance’s variables in button text
// Companion variable syntax uses the instance label.
const L = self.label;
// ----------------------------
// STATUS / HEALTH
// ----------------------------
presets['status_connected'] = {
type: 'button',
category: 'Resi • Status',
name: 'Connected (green) / Error (red)',
style: {
text: `Resi\n$(${L}:auth_status)`,
size: '14',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(40, 40, 40),
show_topbar: true,
},
steps: [],
feedbacks: [
{
feedbackId: 'resi_connected',
options: {},
},
{
feedbackId: 'resi_error',
options: {},
},
],
};
presets['status_player_active'] = {
type: 'button',
category: 'Resi • Status',
name: 'Player Active',
style: {
text: `Player\n$(${L}:current_position)`,
size: '14',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(40, 40, 40),
show_topbar: true,
},
steps: [],
feedbacks: [
{
feedbackId: 'player_active',
options: {},
},
],
};
presets['status_frozen'] = {
type: 'button',
category: 'Resi • Status',
name: 'Playback Frozen Warning',
style: {
text: `FROZEN?\n$(${L}:current_position)`,
size: '14',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(40, 40, 40),
show_topbar: true,
},
steps: [],
feedbacks: [
{
feedbackId: 'playback_frozen',
options: {
staleSeconds: 6,
},
},
],
};
// ----------------------------
// CUE DISPLAY (NO ACTIONS)
// ----------------------------
presets['cue_next_display'] = {
type: 'button',
category: 'Resi • Cues',
name: 'Next Cue Display',
style: {
text: `NEXT:\n$(${L}:next_cue_name)\nT- $(${L}:next_cue_time_in_hms)`,
size: '14',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(0, 0, 0),
show_topbar: false,
},
steps: [],
feedbacks: [
{
feedbackId: 'approaching_next_cue',
options: { threshold: 30 },
},
],
};
presets['cue_prev_display'] = {
type: 'button',
category: 'Resi • Cues',
name: 'Previous Cue Display',
style: {
text: `PREV:\n$(${L}:prev_cue_name)\n+ $(${L}:prev_cue_time_ago_in_hms)`,
size: '14',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(0, 0, 0),
show_topbar: false,
},
steps: [],
feedbacks: [],
};
// ----------------------------
// MAINTENANCE / REFRESH
// ----------------------------
presets['refresh_profile'] = {
type: 'button',
category: 'Resi • Maintenance',
name: 'Refresh Profile',
style: {
text: 'Refresh\nProfile',
size: '18',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(60, 60, 60),
show_topbar: false,
},
steps: [
{
down: [
{
actionId: 'refresh_profile',
options: {},
},
],
up: [],
},
],
feedbacks: [
{ feedbackId: 'resi_connected', options: {} },
{ feedbackId: 'resi_error', options: {} },
],
};
presets['refresh_lists'] = {
type: 'button',
category: 'Resi • Maintenance',
name: 'Refresh Venues/Players',
style: {
text: 'Refresh\nLists',
size: '18',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(60, 60, 60),
show_topbar: false,
},
steps: [
{
down: [
{
actionId: 'refresh_lists',
options: {},
},
],
up: [],
},
],
feedbacks: [
{ feedbackId: 'resi_connected', options: {} },
{ feedbackId: 'resi_error', options: {} },
],
};
// ----------------------------
// CUE ACTIONS
// ----------------------------
presets['add_cue_player'] = {
type: 'button',
category: 'Resi • Cues',
name: 'Add Cue @ Player Position',
style: {
text: 'Add Cue\n@ Player',
size: '18',
color: (0, base_1.combineRgb)(0, 0, 0),
bgcolor: (0, base_1.combineRgb)(180, 180, 180),
show_topbar: false,
},
steps: [
{
down: [
{
actionId: 'add_cue_at_position',
options: {
name: 'Web Cue',
privateCue: false,
positionMode: 'player',
customPosition: '00:00:00.000',
},
},
],
up: [],
},
],
feedbacks: [
{ feedbackId: 'resi_connected', options: {} },
{ feedbackId: 'resi_error', options: {} },
],
};
presets['add_cue_custom'] = {
type: 'button',
category: 'Resi • Cues',
name: 'Add Cue @ Custom Time',
style: {
text: 'Add Cue\n@ Custom',
size: '18',
color: (0, base_1.combineRgb)(0, 0, 0),
bgcolor: (0, base_1.combineRgb)(180, 180, 180),
show_topbar: false,
},
steps: [
{
down: [
{
actionId: 'add_cue_at_position',
options: {
// user can edit this preset, OR replace with a variable
name: 'Custom Cue',
privateCue: false,
positionMode: 'custom',
customPosition: '00:10:00.000',
},
},
],
up: [],
},
],
feedbacks: [
{ feedbackId: 'resi_connected', options: {} },
{ feedbackId: 'resi_error', options: {} },
],
};
presets['lookup_cue'] = {
type: 'button',
category: 'Resi • Cues',
name: 'Lookup Cue (stores in lookup_* vars)',
style: {
text: 'Lookup\nCue',
size: '18',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(0, 110, 200),
show_topbar: false,
},
steps: [
{
down: [
{
actionId: 'lookup_cue_by_name',
options: {
// user edits this preset to their cue name or uses variables
cueName: 'sermon start',
matchMode: 'exact',
},
},
],
up: [],
},
],
feedbacks: [
{ feedbackId: 'resi_connected', options: {} },
{ feedbackId: 'resi_error', options: {} },
],
};
presets['delete_cue'] = {
type: 'button',
category: 'Resi • Cues',
name: 'Delete Cue by Name',
style: {
text: 'DELETE\nCue',
size: '18',
color: (0, base_1.combineRgb)(255, 255, 255),
bgcolor: (0, base_1.combineRgb)(140, 0, 0),
show_topbar: false,
},
steps: [
{
down: [
{
actionId: 'delete_cue_by_name',
options: {
// user edits this preset to the cue they want to delete
cueName: 'Companion Test',
matchMode: 'exact',
deleteMode: 'first',
onlyMine: true,
},
},
],
up: [],
},
],
feedbacks: [
{ feedbackId: 'resi_connected', options: {} },
{ feedbackId: 'resi_error', options: {} },
],
};
self.setPresetDefinitions(presets);
}
//# sourceMappingURL=presets.js.map
+5
View File
@@ -0,0 +1,5 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UpgradeScripts = void 0;
exports.UpgradeScripts = [];
//# sourceMappingURL=upgrades.js.map
+43
View File
@@ -0,0 +1,43 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.UpdateVariableDefinitions = UpdateVariableDefinitions;
function UpdateVariableDefinitions(instance) {
const vars = [
{ variableId: 'org_name', name: 'Organization name' },
{ variableId: 'first_name', name: 'First name' },
{ variableId: 'last_name', name: 'Last name' },
{ variableId: 'email', name: 'Email' },
{ variableId: 'auth_status', name: 'Auth status' },
{ variableId: 'last_error', name: 'Last error' },
{ variableId: 'last_poll', name: 'Last poll (local time)' },
{ variableId: 'current_position', name: 'Current position (HH:MM:SS.ms)' },
{ variableId: 'playback_state', name: 'Playback state (PLAYING/PAUSED)' },
{ variableId: 'position_update_interval_sec', name: 'Seconds between Resi position updates' },
{ variableId: 'player_fields', name: 'Fields Resi returns for this decoder (diagnostic)' },
{ variableId: 'current_position_sec', name: 'Current position (seconds)' },
{ variableId: 'current_event_id', name: 'Current event id (live from player)' },
{ variableId: 'cue_count', name: 'Cue count' },
{ variableId: 'cue_names', name: 'Cue names (comma separated)' },
{ variableId: 'event_id', name: 'Current event id (cues loaded for)' },
{ variableId: 'next_cue_name', name: 'Next cue name' },
{ variableId: 'next_cue_time_in_seconds', name: 'Seconds until next cue' },
{ variableId: 'next_cue_position', name: 'Next cue position (HH:MM:SS.ms)' },
{ variableId: 'next_cue_tod', name: 'Next cue time-of-day (24 hour)' },
{ variableId: 'prev_cue_name', name: 'Previous cue name' },
{ variableId: 'prev_cue_time_ago_in_seconds', name: 'Seconds since previous cue' },
{ variableId: 'prev_cue_position', name: 'Previous cue position (HH:MM:SS.ms)' },
{ variableId: 'prev_cue_tod', name: 'Previous cue time-of-day (24 hour)' },
{ variableId: 'next_cue_time_in_hms', name: 'Time until next cue (HH:MM:SS)' },
{ variableId: 'prev_cue_time_ago_in_hms', name: 'Time since previous cue (HH:MM:SS)' },
{ variableId: 'lookup_cue_query', name: 'Lookup cue query (name)' },
{ variableId: 'lookup_cue_found', name: 'Lookup cue found (true/false)' },
{ variableId: 'lookup_cue_name', name: 'Lookup cue name (matched)' },
{ variableId: 'lookup_cue_uuid', name: 'Lookup cue uuid' },
{ variableId: 'lookup_cue_position', name: 'Lookup cue position (HH:MM:SS.ms)' },
{ variableId: 'lookup_cue_position_sec', name: 'Lookup cue position (seconds)' },
{ variableId: 'lookup_cue_tod', name: 'Lookup cue time-of-day' },
{ variableId: 'playback_last_change_epoch_ms', name: 'Playback last position change (epoch ms)' },
];
instance.setVariableDefinitions(vars);
}
//# sourceMappingURL=variables.js.map
+35
View File
@@ -0,0 +1,35 @@
{
"id": "resi-studio-cues",
"name": "resi-studio-cues",
"shortname": "resi-studio-cues",
"description": "Live cue list, playhead and next-cue countdown from Resi Studio",
"version": "0.1.4",
"license": "MIT",
"repository": "git+https://github.com/reykroona/companion-module-resi-studio-cues.git",
"bugs": "https://github.com/reykroona/companion-module-resi-studio-cues/issues",
"maintainers": [
{
"name": "Rey Kroona",
"email": "reykroona@gmail.com"
}
],
"runtime": {
"type": "node18",
"api": "nodejs-ipc",
"apiVersion": "1.14.1",
"entrypoint": "../dist/main.js"
},
"legacyIds": [],
"manufacturer": "Rey Kroona",
"products": [
"Resi Studio Cues"
],
"keywords": [
"resi",
"living as one",
"multisite",
"cues",
"countdown",
"decoder"
]
}
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Assemble a fully-nested node_modules tree from the npm registry, without npm.
Fully nested (no hoisting) so Node's upward resolution can never pick a wrong
version: every package's deps live inside its own node_modules/.
"""
import json
import os
import shutil
import subprocess
import sys
import tarfile
import urllib.parse
import urllib.request
CACHE = os.path.abspath("_npmcache")
os.makedirs(CACHE, exist_ok=True)
_meta = {}
def meta(name):
if name not in _meta:
path = os.path.join(CACHE, name.replace("/", "_") + ".json")
if not os.path.exists(path):
url = "https://registry.npmjs.org/" + urllib.parse.quote(name, safe="")
urllib.request.urlretrieve(url, path)
with open(path) as fh:
_meta[name] = json.load(fh)
return _meta[name]
def parse(v):
core = v.split("-")[0].split("+")[0]
return tuple(int(x) for x in core.split("."))
def satisfies(version, rng):
if "-" in version: # never auto-pick a prerelease
return False
rng = rng.strip()
v = parse(version)
if rng in ("", "*", "latest", "x"):
return True
if rng.startswith("^"):
b = parse(rng[1:])
if v < b:
return False
if b[0] > 0:
return v[0] == b[0]
if b[1] > 0:
return v[0] == 0 and v[1] == b[1]
return v[0] == 0 and v[1] == 0 and v[2] == b[2]
if rng.startswith("~"):
b = parse(rng[1:])
return v >= b and v[0] == b[0] and v[1] == b[1]
if rng.startswith(">="):
return v >= parse(rng[2:])
if rng[0].isdigit():
return version == rng
raise ValueError("unsupported range %r" % rng)
def pick(name, rng):
m = meta(name)
ok = [v for v in m["versions"] if satisfies(v, rng)]
if not ok:
raise SystemExit("no version of %s satisfies %s" % (name, rng))
best = max(ok, key=parse)
return best, m["versions"][best]
def install(name, rng, target_nm, chain, log):
version, info = pick(name, rng)
if (name, version) in chain:
return # already provided by an ancestor
dest = os.path.join(target_nm, *name.split("/"))
if os.path.exists(dest):
return
os.makedirs(os.path.dirname(dest), exist_ok=True)
tgz = os.path.join(CACHE, "%s-%s.tgz" % (name.replace("/", "_"), version))
if not os.path.exists(tgz):
urllib.request.urlretrieve(info["dist"]["tarball"], tgz)
tmp = dest + ".tmp"
shutil.rmtree(tmp, ignore_errors=True)
with tarfile.open(tgz) as tf:
tf.extractall(tmp)
inner = os.path.join(tmp, "package")
shutil.move(inner if os.path.isdir(inner) else tmp, dest)
shutil.rmtree(tmp, ignore_errors=True)
log.append("%s%s@%s" % (" " * len(chain), name, version))
deps = info.get("dependencies") or {}
if deps:
child_nm = os.path.join(dest, "node_modules")
for dname, drng in sorted(deps.items()):
install(dname, drng, child_nm, chain | {(name, version)}, log)
if __name__ == "__main__":
root_nm, name, rng = sys.argv[1], sys.argv[2], sys.argv[3]
os.makedirs(root_nm, exist_ok=True)
log = []
install(name, rng, root_nm, frozenset(), log)
print("\n".join(log))
print("\n%d packages" % len(log))
+243
View File
@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""
resi_probe.py — does the Resi cloud hand us live cue + playhead data for OUR account?
One read-only pass over the endpoints a Companion "cue countdown" module would need:
login POST /api/v3/login?newToken=true
who am i GET /api_v2.svc/users/me
venues GET /api/v3/customers/{customerId}/venues
decoders GET /api_v2.svc/users/{venueUuid}/players <- playhead lives here
event GET /api/v3/customers/{customerId}/events/{eventId}
cues GET /api_v2.svc/streamprofiles/{eventProfileId}/events/{eventId}/cues
Nothing is created, changed or deleted. Your password is read with getpass (never
echoed), sent only to https://central.resi.io, and never written to disk.
This talks to Resi's INTERNAL API — the one studio.resi.io itself uses. It is
undocumented and unsupported; don't open a Resi ticket about it.
Usage:
python3 resi_probe.py --user you@yourchurch.org
python3 resi_probe.py --user you@yourchurch.org --watch 10 # 10s playhead watch
python3 resi_probe.py --user you@yourchurch.org --save ./resi-capture
"""
import argparse
import getpass
import http.cookiejar
import json
import os
import ssl
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE = "https://central.resi.io"
_jar = http.cookiejar.CookieJar()
_opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(_jar))
_token = None
_save_dir = None
def _cookie(name):
for c in _jar:
if c.name == name:
return c.value
return None
def _headers():
h = {"Accept": "application/json", "User-Agent": "resi-probe/1.0"}
if _token:
h["Authorization"] = "X-Bearer " + _token
return h
def _request(method, path, body=None):
url = path if path.startswith("http") else BASE + path
data = None
h = _headers()
if body is not None:
data = json.dumps(body).encode()
h["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=h, method=method)
try:
with _opener.open(req, timeout=20) as resp:
raw = resp.read()
except urllib.error.HTTPError as e:
raise RuntimeError("HTTP %s on %s %s — %s" % (e.code, method, path, e.read()[:300].decode("utf-8", "replace")))
except ssl.SSLError as e:
raise RuntimeError("TLS failure (%s). If this Mac's Python has no CA bundle, run the "
"'Install Certificates.command' that ships with your Python install." % e)
except urllib.error.URLError as e:
raise RuntimeError("Could not reach %s — %s" % (url, e.reason))
if not raw:
return None
try:
return json.loads(raw)
except ValueError:
return raw.decode("utf-8", "replace")
def get(path):
out = _request("GET", path)
if _save_dir is not None:
name = urllib.parse.quote(path.split("?")[0].strip("/"), safe="") + ".json"
with open(os.path.join(_save_dir, name), "w") as fh:
json.dump(out, fh, indent=2, sort_keys=True)
return out
def login(username, password):
global _token
_request("POST", "/api/v3/login?newToken=true", {"userName": username, "password": password})
_token = _cookie("livingas1")
if not _token:
raise RuntimeError("Login returned 2xx but set no 'livingas1' cookie — the auth shape has changed. "
"Cookies seen: %s" % ", ".join(c.name for c in _jar))
def ms(position):
"""'01:02:03.500' -> milliseconds. Returns None on anything unparseable."""
if not position:
return None
try:
hh, mm_, rest = position.split(":")
return int((int(hh) * 3600 + int(mm_) * 60 + float(rest)) * 1000)
except (ValueError, AttributeError):
return None
def hms(milliseconds):
s, sign = int(abs(milliseconds) // 1000), "-" if milliseconds < 0 else ""
return "%s%02d:%02d:%02d" % (sign, s // 3600, (s % 3600) // 60, s % 60)
def main():
global _save_dir
ap = argparse.ArgumentParser(description="Read-only probe of the Resi internal API.")
ap.add_argument("--user", help="Resi Studio login (email)")
ap.add_argument("--watch", type=int, default=0, metavar="SEC",
help="after the report, poll the playhead for SEC seconds to prove it advances")
ap.add_argument("--save", metavar="DIR",
help="write each raw JSON response to DIR (contains venue/decoder names and uuids)")
args = ap.parse_args()
if args.save:
os.makedirs(args.save, exist_ok=True)
_save_dir = args.save
username = args.user or input("Resi login (email): ").strip()
password = getpass.getpass("Password (not echoed, not stored): ")
print("\n== auth ==")
login(username, password)
print(" login OK, bearer token acquired")
me = get("/api_v2.svc/users/me")
customer_id = me.get("customerId")
print(" user : %s" % me.get("userName"))
print(" org : %s (customerId %s)" % (me.get("customerName"), customer_id))
if not customer_id:
sys.exit(" no customerId on this account — stop here, nothing else will resolve")
print("\n== venues ==")
venues = get("/api/v3/customers/%s/venues" % customer_id) or []
if not venues:
print(" none returned. Either this login has no venue access, or the plan has no Multisite.")
for v in venues:
print(" %-40s %s" % (v.get("userName") or v.get("name"), v.get("uuid")))
print("\n== decoders / players ==")
live = []
for v in venues:
players = []
try:
players = get("/api_v2.svc/users/%s/players" % v.get("uuid")) or []
except RuntimeError as e:
print(" [%s] players call failed: %s" % (v.get("userName"), e))
continue
if not players:
print(" [%s] no players" % v.get("userName"))
for p in players:
print(" [%s] %-28s pos=%-14s eventId=%s"
% (v.get("userName"), p.get("userName"), p.get("position"), p.get("eventId")))
extra = sorted(set(p) - {"playerHardwareId", "userName", "position", "eventId"})
if extra:
print(" other fields on this player: %s" % ", ".join(extra))
if p.get("eventId"):
live.append((v, p))
if not live:
print("\nNo decoder is on an event right now. Re-run this DURING a service —"
"\nposition and eventId are what the whole countdown hangs on.")
return
for v, p in live:
print("\n== event + cues for %s / %s ==" % (v.get("userName"), p.get("userName")))
event = get("/api/v3/customers/%s/events/%s" % (customer_id, p.get("eventId")))
profile_id = event.get("eventProfileId")
print(" event : %s" % event.get("name"))
print(" startTime : %s" % event.get("startTime"))
print(" profileId : %s" % profile_id)
if not profile_id:
print(" no eventProfileId — cannot fetch cues for this event")
continue
cues = get("/api_v2.svc/streamprofiles/%s/events/%s/cues?canISetCues=1"
% (profile_id, p.get("eventId"))) or []
cues = sorted(cues, key=lambda c: ms(c.get("position")) or 0)
print(" %d cue(s):" % len(cues))
here = ms(p.get("position"))
for c in cues:
at = ms(c.get("position"))
marker = " "
if here is not None and at is not None:
marker = "->" if at > here else " "
print(" %s %-14s %s" % (marker, c.get("position"), c.get("name")))
if here is None:
print("\n playhead unparseable (%r) — countdown not possible from this field"
% p.get("position"))
continue
nxt = next((c for c in cues if (ms(c.get("position")) or 0) > here), None)
print("\n THE BUTTON WOULD READ:")
print(" Playback position : %s" % hms(here))
if nxt:
print(" Next cue : %s" % nxt.get("name"))
print(" Next cue in : %s" % hms((ms(nxt.get("position")) or 0) - here))
else:
print(" Next cue : (none left on this event)")
if args.watch:
v, p = live[0]
print("\n== watching %s for %ds (does the playhead advance?) ==" % (p.get("userName"), args.watch))
first = last = None
deadline = time.time() + args.watch
while time.time() < deadline:
players = get("/api_v2.svc/users/%s/players" % v.get("uuid")) or []
now = next((x.get("position") for x in players
if x.get("playerHardwareId") == p.get("playerHardwareId")), None)
print(" %s %s" % (time.strftime("%H:%M:%S"), now))
if first is None:
first = ms(now)
last = ms(now)
time.sleep(2)
if first is not None and last is not None:
moved = last - first
print(" moved %s over ~%ds -> %s"
% (hms(moved), args.watch,
"PLAYING (a play/pause variable is derivable)" if moved > 0 else "frozen / paused"))
if __name__ == "__main__":
try:
main()
except RuntimeError as exc:
sys.exit("\nFAILED: %s" % exc)
except KeyboardInterrupt:
sys.exit("\ninterrupted")
+272
View File
@@ -0,0 +1,272 @@
// Drives the real pollPlayer() + estimator against a simulated Resi that
// publishes `position` on a coarse cadence, already slightly stale.
var console = {
log: function () { print(Array.prototype.join.call(arguments, ' ')) },
error: function () { print('ERR ' + Array.prototype.join.call(arguments, ' ')) },
warn: function () { print('WARN ' + Array.prototype.join.call(arguments, ' ')) },
info: function () { print(Array.prototype.join.call(arguments, ' ')) },
debug: function () {},
};
var failures = [];
function check(c, m) { if (!c) { failures.push(m); print(' FAIL ' + m) } }
function eq(g, w, m) { check(g === w, m + ' (got ' + g + ', want ' + w + ')') }
function near(g, w, tol, m) {
check(Math.abs(g - w) <= tol, m + ' (got ' + Math.round(g) + ', want ' + Math.round(w) + ' +/-' + tol + ')');
}
function StubInstanceBase() {}
StubInstanceBase.prototype.updateStatus = function () {}
StubInstanceBase.prototype.log = function () {}
StubInstanceBase.prototype.setVariableValues = function () {}
var stubs = {
'@companion-module/base': {
InstanceBase: StubInstanceBase,
InstanceStatus: { Ok: 'ok', Connecting: 'c', ConnectionFailure: 'f', BadConfig: 'b' },
runEntrypoint: function () {}, combineRgb: function () { return 0 }, Regex: {},
},
'./config.js': { GetConfigFields: function () { return [] } },
'./variables.js': { UpdateVariableDefinitions: function () {} },
'./upgrades.js': { UpgradeScripts: [] },
'./actions.js': { UpdateActions: function () {} },
'./feedbacks.js': { UpdateFeedbacks: function () {} },
'./presets.js': { UpdatePresets: function () {} },
};
// ---- the simulated world -------------------------------------------------
var fakeNow = 1700000000000;
Date.now = function () { return fakeNow };
// cadence / lag / jitter can be overridden: jsc tick_test.js -- 10000 3000 4000
var ARGS = (typeof arguments !== 'undefined' ? arguments : []).map(Number);
var DIAG = true;
var RESI_CADENCE = ARGS[0] || 5000; // how often Resi refreshes `position`
var RESI_LAG = ARGS.length > 1 ? ARGS[1] : 500; // how stale it already is when it appears
var RESI_JITTER = ARGS[2] || 0; // how irregular the cadence is
var NET_JITTER = ARGS[3] || 0; // how variable the request/response round trip is
var START_INTO = ARGS[4] || 0; // how far into Resi's cycle the module starts polling
var seed = 12345;
function rnd() { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff }
var nextCadence = RESI_CADENCE;
var playing = true;
var trueMedia = 8 * 60000 + 48000; // where the decoder REALLY is
var published = trueMedia - RESI_LAG; // what the API currently returns
var lastPublish = fakeNow - START_INTO;
var extraPlayerFields = {};
var TOL = RESI_LAG + 1200 + RESI_JITTER;
function pad(n, w) { return String(n).padStart(w, '0') }
function fmt(ms) {
var t = Math.max(0, Math.floor(ms));
return pad(Math.floor(t / 3600000), 2) + ':' + pad(Math.floor((t % 3600000) / 60000), 2) + ':' +
pad(Math.floor((t % 60000) / 1000), 2) + '.' + pad(t % 1000, 3);
}
function advanceWorld(ms) {
for (var i = 0; i < ms; i += 100) {
fakeNow += 100;
if (playing) trueMedia += 100;
if (fakeNow - lastPublish >= nextCadence) {
lastPublish = fakeNow;
published = trueMedia - RESI_LAG;
nextCadence = RESI_CADENCE + Math.round((rnd() - 0.5) * 2 * RESI_JITTER);
if (nextCadence < 500) nextCadence = 500;
}
}
}
var fetch = function () {
// the body is whatever Resi had at the moment the request left
var one = { playerHardwareId: 'DEC1', userName: 'Lobby', position: fmt(published), eventId: 'EV1' };
for (var k in extraPlayerFields) one[k] = extraPlayerFields[k];
// ...and the round trip takes a while, during which the world moves on
if (NET_JITTER) advanceWorld(100 + Math.round(rnd() * NET_JITTER));
return Promise.resolve({ ok: true, json: function () { return Promise.resolve([one]) } });
};
var tickFn = null, tickMs = null;
var setInterval = function (fn, ms) { tickFn = fn; tickMs = ms; return {} };
var clearInterval = function () {};
var src = readFile(typeof TARGET !== 'undefined' ? TARGET : 'build/pkg/resi-studio-cues/dist/main.js');
var mod = { exports: {} };
(new Function('module', 'exports', 'require', 'setInterval', 'clearInterval', 'console', 'fetch', src))(
mod, mod.exports,
function (id) { if (!stubs[id]) throw new Error('unstubbed require: ' + id); return stubs[id] },
setInterval, clearInterval, console, fetch);
var inst = Object.create(mod.exports.ModuleInstance.prototype);
var vars = {};
inst.setVariableValues = function (o) { for (var k in o) vars[k] = o[k] };
inst.log = function () {};
inst.config = {};
inst.selectedVenueUuid = 'V1';
inst.selectedPlayerHardwareId = 'DEC1';
inst.cookieHeader = 'stub';
inst.currentEventId = 'EV1';
inst.playerPollInFlight = false;
inst.ensureAuthenticated = function () { return Promise.resolve() };
inst.buildAuthHeaders = function () { return {} };
inst.refreshCuesIfDue = function () { return Promise.resolve() };
inst.cues = [
{ uuid: 'a', name: 'Welcome', positionMs: 5 * 60000 },
{ uuid: 'b', name: 'Campus Host', positionMs: 12 * 60000 + 30000 },
{ uuid: 'c', name: 'Message', positionMs: 41 * 60000 },
{ uuid: 'd', name: 'Closing', positionMs: 45 * 60000 },
];
// true once Resi has actually REPORTED a position near `target`
function reported_near(target) {
return Number.isFinite(inst.__lastSampleMediaMs) && Math.abs(inst.__lastSampleMediaMs - target) < 8000;
}
// one second of real operation: advance the world, poll, redraw
// any PAUSED while the decoder is really playing is a false pause: it freezes the countdown
var falsePauses = 0;
async function second() {
advanceWorld(1000); await inst.pollPlayer(); tickFn();
if (playing && vars.playback_state === 'PAUSED') falsePauses++;
}
async function seconds(n) { for (var i = 0; i < n; i++) await second() }
function timestampChecks() {
var now = Date.now();
eq(inst.detectSampleTime({ lastUpdated: new Date(now - 2000).toISOString() }), now - 2000,
'uses an ISO "lastUpdated" as the sample time');
eq(inst.detectSampleTime({ reportedAt: now - 1500 }), now - 1500,
'uses an epoch-ms "reportedAt" as the sample time');
eq(inst.detectSampleTime({ startTime: new Date(now - 120000).toISOString() }), undefined,
'ignores a timestamp too old to be this reading');
eq(inst.detectSampleTime({ userName: 'Lobby', position: '00:01:02.000' }), undefined,
'ignores fields that are not timestamps');
}
(async function () {
inst.startSmoothTick();
eq(tickMs, 1000, 'display ticks every second');
// ---- accuracy while playing -----------------------------------------
await seconds(40);
var err = trueMedia - inst.__lastEstMs;
check(err <= TOL + NET_JITTER * 0.35, 'estimate is not lagging (behind truth by ' + Math.round(err) + 'ms)');
check(err >= -300, 'estimate does not run ahead of the real playhead (' + Math.round(err) + 'ms)');
// the point of the whole exercise: compare against the raw value over a
// full publish cycle, not at one lucky instant
var maxEstErr = 0, maxRawErr = 0;
for (var w = 0; w < 30; w++) {
await second();
maxEstErr = Math.max(maxEstErr, trueMedia - inst.__lastEstMs);
maxRawErr = Math.max(maxRawErr, trueMedia - published);
}
if (RESI_CADENCE >= 2000)
check(maxRawErr >= RESI_CADENCE * 0.4,
'sanity: the raw Resi value goes badly stale within a cycle (' + Math.round(maxRawErr) + 'ms)');
check(maxEstErr <= TOL + NET_JITTER * 0.35,
'the estimate stays close all cycle (worst ' + Math.round(maxEstErr) + 'ms vs raw ' + Math.round(maxRawErr) + 'ms)');
// ---- ticks every second, never backwards ------------------------------
var prev = inst.__lastEstMs, stalls = 0, backwards = 0;
for (var i = 0; i < 60; i++) {
await second();
if (inst.__lastEstMs === prev) stalls++;
if (inst.__lastEstMs < prev) backwards++;
prev = inst.__lastEstMs;
}
eq(stalls, 0, 'no stalled seconds across a minute of playback');
eq(backwards, 0, 'the playhead never goes backwards');
eq(vars.playback_state, 'PLAYING', 'reported as playing');
check(trueMedia - inst.__lastEstMs <= TOL + NET_JITTER * 0.35, 'still accurate after a minute');
// ---- long run: the offset window is finite, so the best sample eventually
// ages out of it; that must not drag the displayed playhead backwards
var prev2 = inst.__lastEstMs, back2 = 0, worstBack = 0;
for (var L = 0; L < 200; L++) {
await second();
if (inst.__lastEstMs < prev2) {
back2++; worstBack = Math.max(worstBack, prev2 - inst.__lastEstMs);
if (typeof DIAG !== 'undefined')
print(' backward step ' + Math.round(prev2 - inst.__lastEstMs) + 'ms at L=' + L +
' state=' + vars.playback_state + ' offsets=' + (inst.__offsets || []).length +
' sinceNew=' + (Date.now() - inst.__lastNewSampleAt) + ' base=' + inst.__baseOffset);
}
prev2 = inst.__lastEstMs;
}
eq(back2, 0, 'no backward steps over 200s (worst was ' + Math.round(worstBack) + 'ms)');
eq(falsePauses, 0, 'never reported PAUSED while playing, startup included');
// ---- a fired cue / forward seek --------------------------------------
inst.lastCueRefreshMs = 999999;
trueMedia = 40 * 60000 + 50000; // decoder jumps; Resi says nothing yet
var jumpAt = fakeNow, sawIt = false;
for (var q = 0; q < 60 && !sawIt; q++) { await second(); sawIt = reported_near(trueMedia) }
check(sawIt, 'the jump is picked up at all');
var detectMs = fakeNow - jumpAt;
if (typeof DIAG !== 'undefined') print(' jump detected after ' + detectMs + 'ms');
check(detectMs <= RESI_CADENCE + RESI_JITTER + 2000,
'jump detected within one publish cycle (' + detectMs + 'ms)');
eq(inst.lastCueRefreshMs, 0, 'a jump forces the cue list to refresh immediately');
near(inst.__lastEstMs, trueMedia, TOL + RESI_CADENCE / 2, 'estimate resyncs to the new playhead at once');
if (typeof DIAG !== 'undefined') print(' post-seek error: ' + Math.round(trueMedia - inst.__lastEstMs) + 'ms');
check(trueMedia - inst.__lastEstMs >= -400,
'estimate does not overshoot after a seek (' + Math.round(trueMedia - inst.__lastEstMs) + 'ms)');
eq(vars.next_cue_name, 'Message', 'next cue is correct straight after the jump');
await seconds(15);
eq(vars.next_cue_name, 'Closing', 'rolled over after passing Message');
eq(vars.prev_cue_name, 'Message', 'passed cue became prev');
// ---- a backwards seek -------------------------------------------------
trueMedia = 6 * 60000;
for (var r = 0; r < 60 && !reported_near(trueMedia); r++) await second();
near(inst.__lastEstMs, trueMedia, TOL + RESI_CADENCE / 2, 'follows a backwards seek');
eq(vars.next_cue_name, 'Campus Host', 'cue targets recompute after seeking back');
// ---- pause ------------------------------------------------------------
playing = false;
// how long the module can legitimately take to call it a pause, given this feed
var pauseWait = Math.ceil((Math.max(15000, Math.min(45000, (RESI_CADENCE + RESI_JITTER) * 2.5 + 6000))
+ RESI_CADENCE + RESI_JITTER + 6000) / 1000);
await seconds(pauseWait);
eq(vars.playback_state, 'PAUSED', 'detects a real pause');
var held = inst.__lastEstMs;
await seconds(20);
eq(inst.__lastEstMs, held, 'paused playhead holds instead of running on');
// ---- resume -----------------------------------------------------------
playing = true;
await seconds(25);
eq(vars.playback_state, 'PLAYING', 'recovers after resume');
near(inst.__lastEstMs, trueMedia, TOL + 1500, 'estimate re-locks after resume');
// ---- operator-driven resync (beats waiting for Resi to republish) ----
var beforeEst = inst.__lastEstMs;
eq(inst.resyncToCue('No Such Cue'), false, 'resync to an unknown cue is refused');
eq(inst.__lastEstMs, beforeEst, 'a refused resync leaves the playhead alone');
inst.lastCueRefreshMs = 999999;
eq(inst.resyncToCue('Campus Host'), true, 'resync to a named cue succeeds');
eq(inst.__lastEstMs, 12 * 60000 + 30000, 'playhead snaps to that cue immediately');
eq(vars.next_cue_name, 'Message', 'next cue updates in the same instant');
eq(inst.lastCueRefreshMs, 0, 'resync also forces a cue-list refresh');
eq(inst.resyncToNextCue(), true, 'resync to the next cue succeeds');
eq(inst.__lastEstMs, 41 * 60000, 'playhead snaps to the following cue');
// a real sample after a resync must still be taken seriously
trueMedia = 30 * 60000; published = trueMedia - RESI_LAG; lastPublish = fakeNow;
await seconds(2);
for (var z = 0; z < 60 && !reported_near(trueMedia); z++) await second();
near(inst.__lastEstMs, trueMedia, TOL + RESI_CADENCE / 2, 'a real reading overrides a resync');
timestampChecks();
var b = failures.length;
eq(vars.next_cue_name, 'Not A Real Cue', 'deliberate failure');
if (failures.length !== b + 1) { print('HARNESS BROKEN'); throw new Error('harness') }
failures.pop();
if (failures.length) { print('\n' + failures.length + ' FAILED'); throw new Error('tests failed') }
print('\nall estimator tests passed [cadence ' + RESI_CADENCE + 'ms, lag ' + RESI_LAG + 'ms, jitter +/-' + RESI_JITTER + 'ms]');
})();