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:
2026-10-07 17:27:34 -04:00
co-authored by Claude Opus 5.5
parent 1a658875bc
commit 0327d6a9f8
9 changed files with 218 additions and 19 deletions
+38 -14
View File
@@ -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;