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:
@@ -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); });
|
||||
Reference in New Issue
Block a user