Companion module 0.1.5: settings save even before Resi answers
While the connection wasn't connected, nothing in its settings could be saved. The saved decoder showed as "??" because the list only fills once Resi answers, and a cue refresh that had never been saved showed as 0, below its minimum. Either blocks Save, including the login change that would have fixed the connection. That's how North Attleboro's connection stayed on a locked login. - The saved decoder is always a choice, "(not connected to Resi yet)" until Resi answers. - An upgrade script fills settings an older connection never saved with their defaults (and leaves a complete one alone). - Event details are read once per event instead of on every cue refresh: one Resi request fewer every few seconds on a shared account. - Cue requests share the login/rate-limit hold-off; "Loaded N cues" and repeated cue-refresh failures are logged once, not every few seconds. - "inf0" in the decoder info poll label. source/config_test.js covers all of it (6 tests; the first fails on 0.1.4). backoff_test.js 5/5 and the estimator matrix still pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -90,7 +90,7 @@ Resi's API is undocumented and unsupported, so don't open a Resi ticket about th
|
||||
| `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. |
|
||||
| `resi-studio-cues-0.1.5.tgz` | The current Companion package: import it in Companion → Modules. |
|
||||
|
||||
Tests. All run offline against fakes, except the end-to-end one:
|
||||
|
||||
@@ -99,6 +99,7 @@ python3 test_playhead.py # the playhead estimator, nine Resi profiles plu
|
||||
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
|
||||
node source/config_test.js # Companion module: settings always save, upgrade fills defaults, quieter cue refresh
|
||||
```
|
||||
|
||||
`source/tick_test.js` is the Companion module's estimator test (run with JavaScriptCore's
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -241,3 +241,17 @@ seconds, and each restart signed in again from scratch, outside the hold-off.
|
||||
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
|
||||
|
||||
## Settings that always save (0.1.5)
|
||||
|
||||
While the connection wasn't connected to Resi, its settings couldn't be saved at all:
|
||||
the saved decoder showed as "??" because the list only fills once Resi answers, and a
|
||||
cue refresh that had never been saved showed as 0, below its minimum. Either one blocks
|
||||
Save in Companion, including the login change that would have fixed the connection.
|
||||
|
||||
- the saved decoder is always offered, as "(not connected to Resi yet)" until Resi answers
|
||||
- an upgrade step fills any setting an older connection never saved with its default
|
||||
- event details are read once per event instead of on every cue refresh, which is one
|
||||
Resi request fewer every few seconds
|
||||
- cue requests share the login/rate-limit hold-off, and "Loaded N cues" and repeated
|
||||
cue-refresh failures are logged once, not every few seconds
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// The settings form, the upgrade script and the cue refresh (0.1.5).
|
||||
//
|
||||
// Seen at North Attleboro: the connection's settings couldn't be saved at all while
|
||||
// it wasn't connected -- the saved decoder showed as "??" (not in the list) and a
|
||||
// cue refresh that had never been saved showed as 0 (below its minimum), and either
|
||||
// one blocks Save, so the login that would have fixed it couldn't be changed.
|
||||
// Runs the real module code against a fake Resi; touches nothing real.
|
||||
//
|
||||
// node config_test.js (defaults to dist/)
|
||||
// node config_test.js path/to/dist
|
||||
|
||||
'use strict';
|
||||
const Module = require('module');
|
||||
const path = require('path');
|
||||
const assert = require('assert');
|
||||
|
||||
const dist = path.resolve(process.argv[2] || path.join(__dirname, 'dist'));
|
||||
const stubs = {
|
||||
'@companion-module/base': {
|
||||
InstanceBase: class { updateStatus() {} log() {} setVariableValues() {} },
|
||||
InstanceStatus: { Ok: 'ok', Connecting: 'c', ConnectionFailure: 'f', BadConfig: 'b' },
|
||||
runEntrypoint() {}, combineRgb() { return 0 }, Regex: {},
|
||||
},
|
||||
'./variables.js': { UpdateVariableDefinitions() {} },
|
||||
'./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 fetches = [];
|
||||
let cues = [{ uuid: 'a', name: 'Welcome', position: '00:05:00' }, { uuid: 'b', name: 'Message', position: '00:30:00' }];
|
||||
let cueStatus = 200;
|
||||
const reply = (status, body) => ({
|
||||
ok: status >= 200 && status < 300, status,
|
||||
headers: { get: () => null, getSetCookie: () => [] },
|
||||
json: async () => body, text: async () => JSON.stringify(body),
|
||||
});
|
||||
global.fetch = async (url) => {
|
||||
const p = String(url).replace('https://central.resi.io', '').split('?')[0];
|
||||
fetches.push(p);
|
||||
if (/\/events\/[^/]+$/.test(p)) return reply(200, { eventProfileId: 'P1', startTime: '2026-10-07T13:30:00Z' });
|
||||
if (p.endsWith('/cues')) return reply(cueStatus, cues);
|
||||
return reply(404, {});
|
||||
};
|
||||
|
||||
const { ModuleInstance } = require(path.join(dist, 'main.js'));
|
||||
const { UpgradeScripts } = require(path.join(dist, 'upgrades.js'));
|
||||
|
||||
function fresh() {
|
||||
const inst = Object.create(ModuleInstance.prototype);
|
||||
inst.config = {};
|
||||
inst.secrets = { password: 'pw' };
|
||||
inst.venues = [];
|
||||
inst.playersByVenueUuid = {};
|
||||
inst.updateStatus = () => {};
|
||||
inst.setVariableValues = () => {};
|
||||
inst.infos = [];
|
||||
inst.warns = [];
|
||||
inst.log = (lvl, m) => { (lvl === 'info' ? inst.infos : inst.warns).push(m); };
|
||||
return inst;
|
||||
}
|
||||
|
||||
let passed = 0;
|
||||
async function test(name, fn) { await fn(); passed++; console.log(` ok ${name}`); }
|
||||
|
||||
(async () => {
|
||||
await test('the saved decoder is a valid choice even before Resi answers', () => {
|
||||
const inst = fresh();
|
||||
inst.config = { defaultPlayerHardwareId: 'DEC1' };
|
||||
const field = inst.getConfigFields().find((f) => f.id === 'defaultPlayerHardwareId');
|
||||
const ids = field.choices.map((c) => c.id);
|
||||
assert.ok(ids.includes('DEC1'), `choices were ${JSON.stringify(ids)}`);
|
||||
assert.match(field.choices.find((c) => c.id === 'DEC1').label, /not connected/);
|
||||
});
|
||||
|
||||
await test('once connected, the decoder is listed once, under its real name', () => {
|
||||
const inst = fresh();
|
||||
inst.config = { defaultPlayerHardwareId: 'DEC1' };
|
||||
inst.venues = [{ uuid: 'V1', userName: 'North Attleboro' }];
|
||||
inst.playersByVenueUuid = { V1: [{ playerHardwareId: 'DEC1', userName: 'North Attleboro' }] };
|
||||
const field = inst.getConfigFields().find((f) => f.id === 'defaultPlayerHardwareId');
|
||||
const dec = field.choices.filter((c) => c.id === 'DEC1');
|
||||
assert.strictEqual(dec.length, 1);
|
||||
assert.match(dec[0].label, /North Attleboro/);
|
||||
});
|
||||
|
||||
await test('the upgrade fills settings an older connection never saved', () => {
|
||||
const r = UpgradeScripts[0]({}, { config: { username: 'x', playerPollMs: 3000 }, actions: [], feedbacks: [] });
|
||||
assert.ok(r.updatedConfig, 'config should be updated');
|
||||
assert.strictEqual(r.updatedConfig.cuePollMs, 5000);
|
||||
assert.strictEqual(r.updatedConfig.listPollMs, 120000);
|
||||
assert.strictEqual(r.updatedConfig.positionTrimMs, 0);
|
||||
assert.strictEqual(r.updatedConfig.playerPollMs, 3000, 'a saved value is kept');
|
||||
});
|
||||
|
||||
await test('the upgrade leaves a complete connection alone', () => {
|
||||
const r = UpgradeScripts[0]({}, {
|
||||
config: { playerPollMs: 3000, positionTrimMs: 0, cuePollMs: 5000, listPollMs: 120000 }, actions: [], feedbacks: [],
|
||||
});
|
||||
assert.strictEqual(r.updatedConfig, null);
|
||||
});
|
||||
|
||||
await test('event details are read once per event, and the cue log only speaks on a change', async () => {
|
||||
const inst = fresh();
|
||||
inst.customerId = 'C';
|
||||
inst.currentEventId = 'EV1';
|
||||
inst.cookieHeader = 'livingas1=T';
|
||||
inst.lastLoginMs = Date.now();
|
||||
fetches = [];
|
||||
for (let i = 0; i < 5; i++) await inst.refreshEventProfileAndCues();
|
||||
assert.strictEqual(fetches.filter((p) => p.endsWith('/events/EV1')).length, 1, 'event detail once');
|
||||
assert.strictEqual(fetches.filter((p) => p.endsWith('/cues')).length, 5, 'cues every refresh');
|
||||
assert.strictEqual(inst.infos.filter((m) => /Loaded/.test(m)).length, 1, `logged ${inst.infos.length} times`);
|
||||
|
||||
cues = cues.concat({ uuid: 'c', name: 'Closing', position: '00:45:00' });
|
||||
await inst.refreshEventProfileAndCues();
|
||||
assert.strictEqual(inst.infos.filter((m) => /Loaded/.test(m)).length, 2, 'a changed list is logged');
|
||||
|
||||
inst.currentEventId = 'EV2';
|
||||
await inst.refreshEventProfileAndCues();
|
||||
assert.strictEqual(fetches.filter((p) => p.endsWith('/events/EV2')).length, 1, 'a new event is read');
|
||||
});
|
||||
|
||||
await test('a rate-limited cue request holds off every Resi call', async () => {
|
||||
const inst = fresh();
|
||||
inst.customerId = 'C';
|
||||
inst.currentEventId = 'EV1';
|
||||
inst.cookieHeader = 'livingas1=T';
|
||||
inst.lastLoginMs = Date.now();
|
||||
inst.config = { username: 'a@b.c' };
|
||||
cueStatus = 429;
|
||||
await assert.rejects(inst.refreshEventProfileAndCues(), /cues failed \(429\)/);
|
||||
await assert.rejects(inst.ensureAuthenticated(false), /rate-limiting.*retrying in \d+s/);
|
||||
cueStatus = 200;
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
})().catch((e) => { console.error(e); process.exit(1); });
|
||||
Vendored
+1
-1
@@ -48,7 +48,7 @@ function GetConfigFields() {
|
||||
{
|
||||
type: 'number',
|
||||
id: 'listPollMs',
|
||||
label: 'User & decoder inf0 poll (ms)',
|
||||
label: 'User & decoder info poll (ms)',
|
||||
width: 6,
|
||||
min: 10000,
|
||||
max: 3600000,
|
||||
|
||||
Vendored
+38
-14
@@ -89,7 +89,14 @@ class ModuleInstance extends base_1.InstanceBase {
|
||||
// Patch in current player choices
|
||||
for (const f of fields) {
|
||||
if (f.type === 'dropdown' && f.id === 'defaultPlayerHardwareId') {
|
||||
f.choices = [{ id: '', label: '(none)' }, ...this.getPlayerChoices()];
|
||||
const choices = [{ id: '', label: '(none)' }, ...this.getPlayerChoices()];
|
||||
// The list only fills once Resi answers. Without the saved decoder in it,
|
||||
// Companion marked the form invalid ("??") and refused to save anything --
|
||||
// including the login that would have fixed the connection.
|
||||
const saved = this.config?.defaultPlayerHardwareId;
|
||||
if (saved && !choices.some((c) => c.id === saved))
|
||||
choices.push({ id: saved, label: `${saved} (not connected to Resi yet)` });
|
||||
f.choices = choices;
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
@@ -605,21 +612,30 @@ class ModuleInstance extends base_1.InstanceBase {
|
||||
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;
|
||||
// The event's details (profile, start time) don't change while it plays, so
|
||||
// they're read once per event rather than on every cue refresh.
|
||||
if (this.__eventDetailFor !== this.currentEventId || !this.currentEventProfileId) {
|
||||
const evRes = await fetch(`https://central.resi.io/api/v3/customers/${this.customerId}/events/${this.currentEventId}`, { method: 'GET', headers: this.buildAuthHeaders() });
|
||||
if (!evRes.ok) {
|
||||
this.noteRefusal(evRes.status, 'this event', (Number(evRes.headers.get('retry-after')) || 0) * 1000);
|
||||
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;
|
||||
}
|
||||
this.__eventDetailFor = this.currentEventId;
|
||||
}
|
||||
// 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)
|
||||
if (!cuesRes.ok) {
|
||||
this.noteRefusal(cuesRes.status, "this event's cues", (Number(cuesRes.headers.get('retry-after')) || 0) * 1000);
|
||||
throw new Error(`cues failed (${cuesRes.status})`);
|
||||
}
|
||||
const cuesJson = (await cuesRes.json());
|
||||
this.cues = cuesJson
|
||||
.map((c) => ({
|
||||
@@ -630,7 +646,11 @@ class ModuleInstance extends base_1.InstanceBase {
|
||||
positionMs: this.parseTimeToMs(c.position),
|
||||
}))
|
||||
.sort((a, b) => a.positionMs - b.positionMs);
|
||||
this.log('info', `Loaded ${this.cues.length} cues for event ${this.currentEventId}`);
|
||||
// Said when the list changes, not on every refresh (every few seconds).
|
||||
const cueSig = `${this.currentEventId}|${JSON.stringify(this.cues.map((c) => [c.name, c.positionMs]))}`;
|
||||
if (cueSig !== this.__cueSig)
|
||||
this.log('info', `Loaded ${this.cues.length} cues for event ${this.currentEventId}`);
|
||||
this.__cueSig = cueSig;
|
||||
this.setVariableValues({
|
||||
event_id: this.currentEventId ?? '',
|
||||
cue_count: String(this.cues.length),
|
||||
@@ -949,7 +969,11 @@ class ModuleInstance extends base_1.InstanceBase {
|
||||
}
|
||||
catch (err) {
|
||||
// Don’t hard-fail playback polling if cues refresh fails
|
||||
this.log('warn', `Cue refresh failed: ${err?.message ?? err}`);
|
||||
const msg = String(err?.message ?? err);
|
||||
const gist = msg.replace(/ — retrying in \d+s$/, '');
|
||||
if (gist !== this.__lastCueWarn)
|
||||
this.log('warn', `Cue refresh failed: ${msg}`);
|
||||
this.__lastCueWarn = gist;
|
||||
}
|
||||
finally {
|
||||
this.cueRefreshInFlight = false;
|
||||
|
||||
Vendored
+19
-2
@@ -1,5 +1,22 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.UpgradeScripts = void 0;
|
||||
exports.UpgradeScripts = [];
|
||||
//# sourceMappingURL=upgrades.js.map
|
||||
/* 0.1.5: connections created before a setting existed have no value for it. Companion's
|
||||
form shows such a number as 0 -- below the field's minimum -- and then refuses to save
|
||||
any change to the connection. Fill each missing setting with its default, once. */
|
||||
const DEFAULTS = { playerPollMs: 1000, positionTrimMs: 0, cuePollMs: 5000, listPollMs: 120000 };
|
||||
function fillMissingDefaults(_context, props) {
|
||||
const config = props.config;
|
||||
let changed = false;
|
||||
if (config) {
|
||||
for (const [key, value] of Object.entries(DEFAULTS)) {
|
||||
if (config[key] === undefined || config[key] === null || config[key] === '') {
|
||||
config[key] = value;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { updatedConfig: changed ? config : null, updatedActions: [], updatedFeedbacks: [] };
|
||||
}
|
||||
exports.UpgradeScripts = [fillMissingDefaults];
|
||||
//# sourceMappingURL=upgrades.js.map
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "resi-studio-cues",
|
||||
"shortname": "resi-studio-cues",
|
||||
"description": "Live cue list, playhead and next-cue countdown from Resi Studio",
|
||||
"version": "0.1.4",
|
||||
"version": "0.1.5",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user