v2: generic S-Series module with live discovery, dynamic options and meters

Rewrite of the module around what the console itself reports:

- Settings cut to console address/ports, the iPad relay and debug logging;
  nothing site-specific remains. An upgrade script carries v1 configs and
  remaps v1 actions/feedbacks whose ids were reused.
- On connect the module reads every name, buss type (aux/group), stereo
  mode, fader/mute/solo/pan and the snapshot list; entities that never
  answer are hidden. Definitions rebuild (debounced, skipped when
  identical) when names, buss types or snapshots change.
- Every entity option is a searchable dropdown of live names. 70 actions
  cover inputs, aux sends, group assigns, busses, matrix outputs and sends,
  control groups, graphic EQs, channel-strip switches, trim/gain/filters and
  snapshots, each level with set and encoder-friendly adjust variants.
- Boolean, value and bar feedbacks for every state, input level and
  gain-reduction meters via the console's meter subscription protocol, and
  about 2800 presets including rotary knob presets with live level text.
- A write guard keeps fast encoder turns from being undone by delayed
  echoes; feedbacks are re-checked per instance.
- Relay: the client table survives config changes, idle iPads are kept for
  an hour by default (0 = forever), bind failures retry and show in status.

npm test: 326 checks pass on Node 25 and on Companion's Node 22.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 17:11:17 -04:00
co-authored by Claude Opus 5.5
parent 8693fe924a
commit dad8010239
20 changed files with 5102 additions and 1327 deletions
+324
View File
@@ -0,0 +1,324 @@
import { LEVEL, LIMITS, PARAMS } from '../protocol.js'
export const STATE_CHOICES = [
{ id: 'on', label: 'On' },
{ id: 'off', label: 'Off' },
{ id: 'toggle', label: 'Toggle' },
]
const firstId = (choices, fallback = 1) => (choices.length ? choices[0].id : fallback)
/** Entity option builders shared by actions and feedbacks. Choices are read from the live state when the definitions are built. */
export const opt = {
dropdown(id, label, choices, extra = {}) {
return { type: 'dropdown', id, label, choices, default: firstId(choices), minChoicesForSearch: 0, ...extra }
},
multi(id, label, choices) {
const def = choices.length ? [choices[0].id] : []
return { type: 'multidropdown', id, label, choices, default: def, minSelection: 1, minChoicesForSearch: 0 }
},
channel: (inst, id = 'channel', label = 'Input') => opt.dropdown(id, label, inst.state.choices.inputs()),
channels: (inst, id = 'channels', label = 'Inputs') => opt.multi(id, label, inst.state.choices.inputs()),
buss: (inst, kind = 'all', id = 'buss', label) => {
const choices = kind === 'aux' ? inst.state.choices.auxChoices() : kind === 'group' ? inst.state.choices.groupChoices() : inst.state.choices.busses()
return opt.dropdown(id, label || (kind === 'aux' ? 'Aux buss' : kind === 'group' ? 'Group buss' : 'Buss'), choices)
},
busses: (inst, id = 'busses', label = 'Busses') => opt.multi(id, label, inst.state.choices.busses()),
matrix: (inst, id = 'matrix', label = 'Matrix output') => opt.dropdown(id, label, inst.state.choices.matrix()),
matrixIn: (inst, id = 'matrixIn', label = 'Matrix input') => opt.dropdown(id, label, inst.state.choices.matrixInputs()),
cg: (inst, id = 'cg', label = 'Control group') => opt.dropdown(id, label, inst.state.choices.cgs()),
geq: (inst, id = 'geq', label = 'Graphic EQ') => opt.dropdown(id, label, inst.state.choices.geqs()),
snapshot: (inst, id = 'snapshot', label = 'Snapshot') => opt.dropdown(id, label, inst.state.choices.snapshots(), { allowCustom: true, default: firstId(inst.state.choices.snapshots(), 0) }),
state: (label = 'State') => ({ type: 'dropdown', id: 'state', label, choices: STATE_CHOICES, default: 'toggle' }),
level: (id = 'level', label = 'Level (dB)') => ({
type: 'number',
id,
label,
default: 0,
min: LEVEL.min,
max: LEVEL.max,
step: 0.5,
showMinAsNegativeInfinity: true,
}),
delta: (id = 'delta', label = 'Change (dB, for encoders)') => ({ type: 'number', id, label, default: 1, min: -30, max: 30, step: 0.5 }),
pan: (id = 'pan', label = 'Pan (0 = left, 0.5 = centre, 1 = right)') => ({ type: 'number', id, label, default: 0.5, min: 0, max: 1, step: 0.01 }),
pdelta: (id = 'pdelta', label = 'Pan change (for encoders)') => ({ type: 'number', id, label, default: 0.05, min: -1, max: 1, step: 0.01 }),
}
/** Normalise a multidropdown / dropdown value to an array of numbers. */
export const numbers = (v) => (Array.isArray(v) ? v : v === undefined || v === '' ? [] : [v]).map(Number).filter(Number.isFinite)
/** Which entity kinds get actions / feedbacks for a parameter: 'input', 'buss' and/or 'matrix'. */
export const paramEntities = (param) => Object.keys(PARAMS[param].paths)
/** Entity dropdown for a parameter's entity kind. */
export const paramEntityOption = (inst, ent) => (ent === 'input' ? opt.channel(inst) : ent === 'matrix' ? opt.matrix(inst) : opt.buss(inst, 'all'))
/** Switch toggles and value set/adjust actions for the lazily loaded per-channel parameters (PARAMS). */
function getParamActions(inst) {
const out = {}
for (const [param, p] of Object.entries(PARAMS)) {
for (const ent of paramEntities(param)) {
const entOpt = paramEntityOption(inst, ent)
const who = { input: 'Input', buss: 'Buss', matrix: 'Matrix output' }[ent]
const target = (options) => Number(options[entOpt.id])
if (p.type === 'switch') {
out[`${ent}_${param}`] = {
name: `${who}: ${p.label} (on/off)`,
description: 'The current state is read from the console the first time it is needed.',
options: [paramEntityOption(inst, ent), opt.state()],
callback: async ({ options }) => inst.setParamSwitch(ent, target(options), param, options.state),
}
continue
}
const unit = p.adjust === 'percent' ? '%' : p.unit
out[`${ent}_${param}_set`] = {
name: `${who}: set ${p.label.toLowerCase()}`,
description: `Value in ${p.unit}, as the console reports it (not verified on hardware: read the value back first).`,
options: [paramEntityOption(inst, ent), { type: 'number', id: 'value', label: `${p.label} (${p.unit})`, default: p.min < 0 ? 0 : p.min, min: p.min, max: p.max, step: p.step }],
callback: async ({ options }) => inst.setParamValue(ent, target(options), param, Number(options.value)),
}
out[`${ent}_${param}_adjust`] = {
name: `${who}: adjust ${p.label.toLowerCase()} (relative)`,
description: p.adjust === 'percent' ? 'For rotary encoders: each step changes the frequency by this percentage.' : 'For rotary encoders.',
options: [
paramEntityOption(inst, ent),
{ type: 'number', id: 'delta', label: `Change (${unit})`, default: p.delta, min: -(p.adjust === 'percent' ? 100 : (p.max - p.min) / 2), max: p.adjust === 'percent' ? 100 : (p.max - p.min) / 2, step: p.adjust === 'percent' ? 1 : p.step },
],
callback: async ({ options }) => inst.adjustParamValue(ent, target(options), param, Number(options.delta)),
}
}
}
return out
}
/** Action callbacks return nothing, and an error inside one is logged instead of thrown into Companion. */
function quiet(inst, defs) {
for (const def of Object.values(defs)) {
const run = def.callback
def.callback = async (event, context) => {
try {
await run(event, context)
} catch (err) {
inst.log('error', `${event?.actionId || 'action'}: ${err.message}`)
}
}
}
return defs
}
export function getActionDefinitions(inst) {
const each = (list, fn) => Promise.all(numbers(list).map(fn))
const n = (v) => Number(v)
return quiet(inst, {
...getParamActions(inst),
input_mute: {
name: 'Input: mute',
description: 'Mute, unmute or toggle one or more inputs.',
options: [opt.channels(inst), opt.state()],
callback: async ({ options }) => void (await each(options.channels, (ch) => inst.setSwitch('input', 'mute', ch, options.state))),
},
input_solo: {
name: 'Input: solo',
options: [opt.channels(inst), opt.state()],
callback: async ({ options }) => void (await each(options.channels, (ch) => inst.setSwitch('input', 'solo', ch, options.state))),
},
input_fader_set: {
name: 'Input: set fader',
options: [opt.channel(inst), opt.level()],
callback: async ({ options }) => inst.setFader('input', n(options.channel), n(options.level)),
},
input_fader_adjust: {
name: 'Input: adjust fader (relative)',
description: 'For rotary encoders. From -inf the first step up lands at -60 dB.',
options: [opt.channel(inst), opt.delta()],
callback: async ({ options }) => inst.adjustFader('input', n(options.channel), n(options.delta)),
},
input_pan_set: {
name: 'Input: set pan',
options: [opt.channel(inst), opt.pan()],
callback: async ({ options }) => inst.setPan(n(options.channel), n(options.pan)),
},
input_pan_adjust: {
name: 'Input: adjust pan (relative)',
options: [opt.channel(inst), opt.pdelta()],
callback: async ({ options }) => inst.adjustPan(n(options.channel), n(options.pdelta)),
},
input_name_set: {
name: 'Input: set name',
options: [opt.channel(inst), { type: 'textinput', id: 'name', label: 'Name', default: '', useVariables: true }],
callback: async ({ options }) => inst.setInputName(n(options.channel), String(options.name ?? '')),
},
send_on: {
name: 'Aux send: on/off',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.state()],
callback: async ({ options }) => inst.setSendOn(n(options.channel), n(options.buss), options.state),
},
send_level_set: {
name: 'Aux send: set level',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.level()],
callback: async ({ options }) => inst.setSendLevel(n(options.channel), n(options.buss), n(options.level)),
},
send_level_adjust: {
name: 'Aux send: adjust level (relative)',
description: 'For rotary encoders. From -inf the first step up lands at -60 dB.',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.delta()],
callback: async ({ options }) => inst.adjustSendLevel(n(options.channel), n(options.buss), n(options.delta)),
},
send_pan_set: {
name: 'Aux send: set pan',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.pan()],
callback: async ({ options }) => inst.setSendPan(n(options.channel), n(options.buss), n(options.pan)),
},
send_pan_adjust: {
name: 'Aux send: adjust pan (relative)',
options: [opt.channel(inst), opt.buss(inst, 'aux'), opt.pdelta()],
callback: async ({ options }) => inst.adjustSendPan(n(options.channel), n(options.buss), n(options.pdelta)),
},
group_assign: {
name: 'Group: assign input on/off',
options: [opt.channel(inst), opt.buss(inst, 'group'), opt.state()],
callback: async ({ options }) => inst.setGroup(n(options.channel), n(options.buss), options.state),
},
buss_mute: {
name: 'Buss: mute',
options: [opt.busses(inst), opt.state()],
callback: async ({ options }) => void (await each(options.busses, (b) => inst.setSwitch('buss', 'mute', b, options.state))),
},
buss_solo: {
name: 'Buss: solo',
options: [opt.busses(inst), opt.state()],
callback: async ({ options }) => void (await each(options.busses, (b) => inst.setSwitch('buss', 'solo', b, options.state))),
},
buss_fader_set: {
name: 'Buss: set fader',
options: [opt.buss(inst), opt.level()],
callback: async ({ options }) => inst.setFader('buss', n(options.buss), n(options.level)),
},
buss_fader_adjust: {
name: 'Buss: adjust fader (relative)',
options: [opt.buss(inst), opt.delta()],
callback: async ({ options }) => inst.adjustFader('buss', n(options.buss), n(options.delta)),
},
matrix_mute: {
name: 'Matrix output: mute',
options: [opt.matrix(inst), opt.state()],
callback: async ({ options }) => inst.setSwitch('matrix', 'mute', n(options.matrix), options.state),
},
matrix_fader_set: {
name: 'Matrix output: set fader',
options: [opt.matrix(inst), opt.level()],
callback: async ({ options }) => inst.setFader('matrix', n(options.matrix), n(options.level)),
},
matrix_fader_adjust: {
name: 'Matrix output: adjust fader (relative)',
options: [opt.matrix(inst), opt.delta()],
callback: async ({ options }) => inst.adjustFader('matrix', n(options.matrix), n(options.delta)),
},
matrix_send_on: {
name: 'Matrix send: on/off',
options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.state()],
callback: async ({ options }) => inst.setMatrixSendOn(n(options.matrixIn), n(options.matrixOut), options.state),
},
matrix_send_level_set: {
name: 'Matrix send: set level',
options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.level()],
callback: async ({ options }) => inst.setMatrixSendLevel(n(options.matrixIn), n(options.matrixOut), n(options.level)),
},
matrix_send_level_adjust: {
name: 'Matrix send: adjust level (relative)',
options: [opt.matrixIn(inst), opt.matrix(inst, 'matrixOut'), opt.delta()],
callback: async ({ options }) => inst.adjustMatrixSendLevel(n(options.matrixIn), n(options.matrixOut), n(options.delta)),
},
cg_mute: {
name: 'Control group: mute',
options: [opt.cg(inst), opt.state()],
callback: async ({ options }) => inst.setSwitch('cg', 'mute', n(options.cg), options.state),
},
cg_solo: {
name: 'Control group: solo',
options: [opt.cg(inst), opt.state()],
callback: async ({ options }) => inst.setSwitch('cg', 'solo', n(options.cg), options.state),
},
cg_fader_set: {
name: 'Control group: set fader',
options: [opt.cg(inst), opt.level()],
callback: async ({ options }) => inst.setFader('cg', n(options.cg), n(options.level)),
},
cg_fader_adjust: {
name: 'Control group: adjust fader (relative)',
options: [opt.cg(inst), opt.delta()],
callback: async ({ options }) => inst.adjustFader('cg', n(options.cg), n(options.delta)),
},
geq_on: {
name: 'Graphic EQ: in/out',
options: [opt.geq(inst), opt.state('State')],
callback: async ({ options }) => inst.setGeqOn(n(options.geq), options.state),
},
geq_band_set: {
name: 'Graphic EQ: set band gain',
description: 'Band 1-32, gain in dB.',
options: [
opt.geq(inst),
{ type: 'number', id: 'band', label: 'Band', default: 1, min: 1, max: LIMITS.geqBands, step: 1 },
{ type: 'number', id: 'gain', label: 'Gain (dB)', default: 0, min: -12, max: 12, step: 0.5 },
],
callback: async ({ options }) => inst.setGeqBand(n(options.geq), n(options.band), n(options.gain)),
},
geq_band_adjust: {
name: 'Graphic EQ: adjust band gain (relative)',
options: [
opt.geq(inst),
{ type: 'number', id: 'band', label: 'Band', default: 1, min: 1, max: LIMITS.geqBands, step: 1 },
{ type: 'number', id: 'delta', label: 'Change (dB, for encoders)', default: 1, min: -12, max: 12, step: 0.5 },
],
callback: async ({ options }) => inst.adjustGeqBand(n(options.geq), n(options.band), n(options.delta)),
},
snapshot_fire: {
name: 'Snapshot: fire',
description: 'Pick a snapshot, or type a snapshot index. Fires by snapshot list position (0 = first). Verify once on your console before using live.',
options: [opt.snapshot(inst)],
callback: async ({ options }) => inst.fireSnapshot(n(options.snapshot)),
},
snapshot_next: { name: 'Snapshot: next', options: [], callback: async () => inst.fireNextSnapshot() },
snapshot_prev: { name: 'Snapshot: previous', options: [], callback: async () => inst.firePrevSnapshot() },
reload: {
name: 'Console: reload names and levels',
options: [],
callback: async () => inst.reload(),
},
raw_osc: {
name: 'Send raw OSC message',
description: 'For experiments: any address, optional single argument. Append /? to an address to query it.',
options: [
{ type: 'textinput', id: 'address', label: 'Address', default: '/Console/Name/?', useVariables: true },
{
type: 'dropdown',
id: 'argType',
label: 'Argument type',
default: 'none',
choices: [
{ id: 'none', label: 'None' },
{ id: 'int', label: 'Integer' },
{ id: 'float', label: 'Float' },
{ id: 'string', label: 'String' },
{ id: 'bool', label: 'Boolean (T/F)' },
],
},
{ type: 'textinput', id: 'value', label: 'Value', default: '', useVariables: true },
],
callback: async ({ options }) => inst.sendRaw(options),
},
relay_forget: {
name: 'Relay: forget all iPad clients',
options: [],
callback: async () => inst.forgetRelayClients(),
},
})
}