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
+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); });