diff --git a/companion/HELP.md b/companion/HELP.md index 5c5af4d..b74e9fe 100644 --- a/companion/HELP.md +++ b/companion/HELP.md @@ -166,8 +166,9 @@ different banks both receive the mixed stream. The module never sends `/Meters/c ## Protocol (condensed) A query is the address plus `/?` with no argument; the reply is the same address without `/?`. All 0/1 -switches are **reported as floats** but written as integers 0/1; levels, sends and pans are floats. Numbers -are 1-based, snapshot indexes 0-based. +switches are **reported and written as floats** (0.0 / 1.0), verified on an S31 on 2026-10-15: integer and +OSC `T`/`F` writes to a switch are silently ignored (no echo, no change), unlike the 2014 DiGiCo list. +Levels, sends and pans are floats too. Numbers are 1-based, snapshot indexes 0-based. | Address | Reply | Notes | | --- | --- | --- | diff --git a/docs/design-v2.md b/docs/design-v2.md index 808671d..8bb2a52 100644 --- a/docs/design-v2.md +++ b/docs/design-v2.md @@ -29,8 +29,10 @@ design note is updated. Two `static-text` blocks explain the console-side iPad extension settings and the iPad-app settings (generic wording: "this computer", no IPs). Nothing else. Keepalive is always on. Dialect is fixed to -the DiGiCo Pad / SD tree (the only one the iPad extension speaks). On/off values are written as -int 0/1, levels and pans as floats (DiGiCo SD OSC list). +the DiGiCo Pad / SD tree (the only one the iPad extension speaks). Every 0/1 switch is reported AND +written as a float 0.0 / 1.0, as are levels and pans. Verified on an S31 (2026-10-15): int and T/F writes to a +switch are silently ignored (no echo, no change), so the 2014 DiGiCo list (which says Int) is wrong for the S31. +Snapshot triggers and meter subscriptions keep integer arguments. ## Topology discovery diff --git a/src/main.js b/src/main.js index 0dd670a..8ec36ca 100644 --- a/src/main.js +++ b/src/main.js @@ -754,7 +754,8 @@ export default class DigicoS31Instance extends InstanceBase { } on = !cur } - return this.write(address, [{ i: on ? 1 : 0 }], on ? 1 : 0) + // Verified on an S31: switches are written as float 0.0 / 1.0; int and T/F writes are silently ignored. + return this.write(address, [on ? 1 : 0], on ? 1 : 0) } writeLevel(address, dB) { diff --git a/test/selftest.js b/test/selftest.js index 4e522ec..c5cffd6 100644 --- a/test/selftest.js +++ b/test/selftest.js @@ -483,12 +483,12 @@ function expectWrite(list, address, tags, value, label) { expectWrite(r, '/Input_Channels/8/fader', ',f', -60, 'input_fader_adjust from -inf lands at -60 dB') const mute9 = S.getMute('input', 9) r = await run('input_mute', { channels: [9], state: 'toggle' }) - expectWrite(r, '/Input_Channels/9/mute', ',i', mute9 ? 0 : 1, 'input_mute toggle (known state)') + expectWrite(r, '/Input_Channels/9/mute', ',f', mute9 ? 0 : 1, 'input_mute toggle (known state)') r = await run('input_mute', { channels: [10, 11], state: 'on' }) - expectWrite(r, '/Input_Channels/10/mute', ',i', 1, 'input_mute on, first of two inputs') - expectWrite(r, '/Input_Channels/11/mute', ',i', 1, 'input_mute on, second of two inputs') + expectWrite(r, '/Input_Channels/10/mute', ',f', 1, 'input_mute on, first of two inputs') + expectWrite(r, '/Input_Channels/11/mute', ',f', 1, 'input_mute on, second of two inputs') r = await run('input_solo', { channels: [12], state: 'on' }) - expectWrite(r, '/Input_Channels/12/solo', ',i', 1, 'input_solo on') + expectWrite(r, '/Input_Channels/12/solo', ',f', 1, 'input_solo on') r = await run('input_pan_set', { channel: 13, pan: 0.25 }) expectWrite(r, '/Input_Channels/13/Panner/pan', ',f', 0.25, 'input_pan_set') r = await run('input_pan_adjust', { channel: 13, pdelta: 0.5 }) @@ -498,7 +498,7 @@ function expectWrite(list, address, tags, value, label) { // sends r = await run('send_on', { channel: 2, buss: 3, state: 'on' }) - expectWrite(r, '/Input_Channels/2/Aux_Send/3/send_on', ',i', 1, 'send_on on') + expectWrite(r, '/Input_Channels/2/Aux_Send/3/send_on', ',f', 1, 'send_on on') r = await run('send_level_set', { channel: 2, buss: 3, level: -10 }) expectWrite(r, '/Input_Channels/2/Aux_Send/3/send_level', ',f', -10, 'send_level_set') r = await run('send_level_adjust', { channel: 2, buss: 3, delta: 3 }) @@ -514,25 +514,25 @@ function expectWrite(list, address, tags, value, label) { const q = r.findIndex((m) => m.address === '/Input_Channels/4/Aux_Send/5/send_on/?') const w = r.findIndex((m) => m.address === '/Input_Channels/4/Aux_Send/5/send_on') const replied = r[w]?.args?.[0] - check(q === 0 && w > q && (replied === 0 || replied === 1) && r[w].tags === ',i', `toggle on unknown state queries first (query #${q}), then writes (${JSON.stringify(r[w]?.args)})`) + check(q === 0 && w > q && (replied === 0 || replied === 1) && r[w].tags === ',f', `toggle on unknown state queries first (query #${q}), then writes (${JSON.stringify(r[w]?.args)})`) await sleep(60) check(S.getSend(4, 5).on === (replied === 1), 'state follows the toggle (echo or optimistic)') // groups check(S.getGroup(1, 24) === undefined, 'group assign is not preloaded') r = await run('group_assign', { channel: 3, buss: 24, state: 'off' }) - expectWrite(r, '/Input_Channels/3/Group_Send/24/group', ',i', 0, 'group_assign off') + expectWrite(r, '/Input_Channels/3/Group_Send/24/group', ',f', 0, 'group_assign off') r = await run('group_assign', { channel: 3, buss: 24, state: 'on' }) - expectWrite(r, '/Input_Channels/3/Group_Send/24/group', ',i', 1, 'group_assign on') + expectWrite(r, '/Input_Channels/3/Group_Send/24/group', ',f', 1, 'group_assign on') // busses: aux side vs group side r = await run('buss_mute', { busses: [3], state: 'on' }) - expectWrite(r, '/Aux_Outputs/3/mute', ',i', 1, 'buss_mute on an aux buss uses Aux_Outputs') + expectWrite(r, '/Aux_Outputs/3/mute', ',f', 1, 'buss_mute on an aux buss uses Aux_Outputs') r = await run('buss_mute', { busses: [9, 24], state: 'on' }) - expectWrite(r, '/Group_Outputs/9/mute', ',i', 1, 'buss_mute on a group buss uses Group_Outputs') - expectWrite(r, '/Group_Outputs/24/mute', ',i', 1, 'buss_mute second group buss') + expectWrite(r, '/Group_Outputs/9/mute', ',f', 1, 'buss_mute on a group buss uses Group_Outputs') + expectWrite(r, '/Group_Outputs/24/mute', ',f', 1, 'buss_mute second group buss') r = await run('buss_solo', { busses: [24], state: 'on' }) - expectWrite(r, '/Group_Outputs/24/solo', ',i', 1, 'buss_solo') + expectWrite(r, '/Group_Outputs/24/solo', ',f', 1, 'buss_solo') r = await run('buss_fader_set', { buss: 24, level: -3 }) expectWrite(r, '/Group_Outputs/24/fader', ',f', -3, 'buss_fader_set on a group buss') const auxFader = S.getFader('buss', 1) @@ -541,13 +541,13 @@ function expectWrite(list, address, tags, value, label) { // matrix r = await run('matrix_mute', { matrix: 2, state: 'toggle' }) - check(writes(r).some((m) => m.address === '/Matrix_Outputs/2/mute' && m.tags === ',i'), 'matrix_mute toggle') + check(writes(r).some((m) => m.address === '/Matrix_Outputs/2/mute' && m.tags === ',f'), 'matrix_mute toggle') r = await run('matrix_fader_set', { matrix: 2, level: -20 }) expectWrite(r, '/Matrix_Outputs/2/fader', ',f', -20, 'matrix_fader_set') r = await run('matrix_fader_adjust', { matrix: 2, delta: 4 }) expectWrite(r, '/Matrix_Outputs/2/fader', ',f', -16, 'matrix_fader_adjust') r = await run('matrix_send_on', { matrixIn: 2, matrixOut: 3, state: 'on' }) - expectWrite(r, '/Matrix_Inputs/2/Matrix_Send/3/send_on', ',i', 1, 'matrix_send_on') + expectWrite(r, '/Matrix_Inputs/2/Matrix_Send/3/send_on', ',f', 1, 'matrix_send_on') r = await run('matrix_send_level_set', { matrixIn: 2, matrixOut: 3, level: -30 }) expectWrite(r, '/Matrix_Inputs/2/Matrix_Send/3/send_level', ',f', -30, 'matrix_send_level_set') r = await run('matrix_send_level_adjust', { matrixIn: 3, matrixOut: 2, delta: 1 }) @@ -556,9 +556,9 @@ function expectWrite(list, address, tags, value, label) { // control groups (1-based) r = await run('cg_mute', { cg: 1, state: 'on' }) - expectWrite(r, '/Control_Groups/1/mute', ',i', 1, 'cg_mute') + expectWrite(r, '/Control_Groups/1/mute', ',f', 1, 'cg_mute') r = await run('cg_solo', { cg: 10, state: 'on' }) - expectWrite(r, '/Control_Groups/10/solo', ',i', 1, 'cg_solo') + expectWrite(r, '/Control_Groups/10/solo', ',f', 1, 'cg_solo') r = await run('cg_fader_set', { cg: 2, level: -6 }) expectWrite(r, '/Control_Groups/2/fader', ',f', -6, 'cg_fader_set') r = await run('cg_fader_adjust', { cg: 2, delta: -2 }) @@ -566,7 +566,7 @@ function expectWrite(list, address, tags, value, label) { // graphic EQ r = await run('geq_on', { geq: 1, state: 'off' }) - expectWrite(r, '/Graphic_EQ/1/geq_in', ',i', 0, 'geq_on off') + expectWrite(r, '/Graphic_EQ/1/geq_in', ',f', 0, 'geq_on off') r = await run('geq_band_set', { geq: 1, band: 5, gain: 3 }) expectWrite(r, '/Graphic_EQ/1/geq_gain_5', ',f', 3, 'geq_band_set') r = await run('geq_band_adjust', { geq: 1, band: 5, delta: 1 }) @@ -580,18 +580,18 @@ function expectWrite(list, address, tags, value, label) { r = await run('input_phantom', { channel: 14, state: 'toggle' }) const pq = r.findIndex((m) => m.address === '/Input_Channels/14/Channel_Input/phantom/?') const pw = r.findIndex((m) => m.address === '/Input_Channels/14/Channel_Input/phantom') - check(pq === 0 && pw > pq && r[pw].tags === ',i' && r[pw].args[0] === 1, `input_phantom toggle on an unloaded switch queries first, then writes the opposite of the replied 0 (${JSON.stringify(r[pw]?.args)})`) + check(pq === 0 && pw > pq && r[pw].tags === ',f' && r[pw].args[0] === 1, `input_phantom toggle on an unloaded switch queries first, then writes the opposite of the replied 0 (${JSON.stringify(r[pw]?.args)})`) r = await run('input_lpf_in', { channel: 14, state: 'on' }) - expectWrite(r, '/Input_Channels/14/Filters/hi_filter_in', ',i', 1, 'input_lpf_in on (hi_filter_in is the low-pass filter)') + expectWrite(r, '/Input_Channels/14/Filters/hi_filter_in', ',f', 1, 'input_lpf_in on (hi_filter_in is the low-pass filter)') r = await run('input_hpf_in', { channel: 14, state: 'on' }) - expectWrite(r, '/Input_Channels/14/Filters/lo_filter_in', ',i', 1, 'input_hpf_in on (lo_filter_in is the high-pass filter)') + expectWrite(r, '/Input_Channels/14/Filters/lo_filter_in', ',f', 1, 'input_hpf_in on (lo_filter_in is the high-pass filter)') r = await run('input_pad', { channel: 14, state: 'off' }) - expectWrite(r, '/Input_Channels/14/Channel_Input/input_pad', ',i', 0, 'input_pad off') + expectWrite(r, '/Input_Channels/14/Channel_Input/input_pad', ',f', 0, 'input_pad off') r = await run('input_phase', { channel: 14, state: 'on' }) - expectWrite(r, '/Input_Channels/14/Channel_Input/phase', ',i', 1, 'input_phase on') + expectWrite(r, '/Input_Channels/14/Channel_Input/phase', ',f', 1, 'input_phase on') for (const [id, path] of [['eq_in', 'EQ/eq_in'], ['comp_in', 'Dynamics/comp_in'], ['gate_in', 'Dynamics/gate_in'], ['delay_on', 'Channel_Delay/delay_on'], ['insert_a', 'Insert/insert_A_in'], ['insert_b', 'Insert/insert_B_in']]) { r = await run(`input_${id}`, { channel: 14, state: 'on' }) - expectWrite(r, `/Input_Channels/14/${path}`, ',i', 1, `input_${id} on`) + expectWrite(r, `/Input_Channels/14/${path}`, ',f', 1, `input_${id} on`) } r = await run('input_hpf_freq_set', { channel: 14, value: 80 }) expectWrite(r, '/Input_Channels/14/Filters/lo_filter_freq', ',f', 80, 'input_hpf_freq_set') @@ -614,11 +614,11 @@ function expectWrite(list, address, tags, value, label) { r = await run('matrix_trim_set', { matrix: 2, value: 1.5 }) expectWrite(r, '/Matrix_Outputs/2/Buss_Trim/trim', ',f', 1.5, 'matrix_trim_set') r = await run('buss_eq_in', { buss: 3, state: 'toggle' }) - check(r[0]?.address === '/Aux_Outputs/3/EQ/eq_in/?' && writes(r).some((m) => m.address === '/Aux_Outputs/3/EQ/eq_in' && m.tags === ',i'), 'buss_eq_in toggle on an unloaded aux buss queries first') + check(r[0]?.address === '/Aux_Outputs/3/EQ/eq_in/?' && writes(r).some((m) => m.address === '/Aux_Outputs/3/EQ/eq_in' && m.tags === ',f'), 'buss_eq_in toggle on an unloaded aux buss queries first') r = await run('buss_insert_a', { buss: 9, state: 'on' }) - expectWrite(r, '/Group_Outputs/9/Insert/insert_A_in', ',i', 1, 'buss_insert_a on a group buss') + expectWrite(r, '/Group_Outputs/9/Insert/insert_A_in', ',f', 1, 'buss_insert_a on a group buss') r = await run('buss_phase', { buss: 3, state: 'on' }) - expectWrite(r, '/Aux_Outputs/3/Buss_Trim/phase', ',i', 1, 'buss_phase on') + expectWrite(r, '/Aux_Outputs/3/Buss_Trim/phase', ',f', 1, 'buss_phase on') // names, snapshots, raw, relay r = await run('snapshot_fire', { snapshot: 5 }) @@ -647,6 +647,43 @@ function expectWrite(list, address, tags, value, label) { check(seq.filter((a) => a === '/Control_Groups/3/fader/?').length === 1 && seq.indexOf('/Control_Groups/3/fader/?') > seq.indexOf('/Control_Groups/3/fader'), 'one confirm query of the same address follows when the write guard ends') } +// A 0/1 switch takes a FLOAT write only (verified on a real S31): the mock ignores int and T/F writes like the console does +{ + const address = '/Input_Channels/30/mute' + const before = S.getMute('input', 30) + const want = before ? 0 : 1 + const seen = [] + const original = inst.onConsoleMessage.bind(inst) + inst.onConsoleMessage = (msg, rinfo) => { + if (msg.address === address) seen.push(msg) + return original(msg, rinfo) + } + const raw = dgram.createSocket('udp4') + const sendRaw = (args) => new Promise((resolve) => raw.send(encode(address, args), P.mock, '127.0.0.1', resolve)) + await sendRaw([{ i: want }]) + await sendRaw([true]) + await sendRaw([false]) + await sendRaw([{ i: before ? 0 : 1 }]) + await sleep(500) + check(seen.length === 0 && S.getMute('input', 30) === before, 'int and T/F writes to a switch are ignored by the mock: no echo and no state change') + inst.queryNow(address) + check(await waitFor(() => seen.length === 1, 2000) && seen[0].tags === ',f' && seen[0].args[0] === (before ? 1 : 0), 'reading it back still gives the old value, as a float') + await sendRaw([want]) + check(await waitFor(() => seen.length === 2, 2000) && seen[1].tags === ',f' && seen[1].args[0] === want && S.getMute('input', 30) === !before, 'a float write is accepted and echoed as a float') + await sendRaw([before ? 1 : 0]) // restore + await sleep(300) + inst.onConsoleMessage = original + raw.close() + + // and the module's own switch writes are floats, so the console keeps them (the confirm query after the guard agrees) + const mark = sent.length + const was31 = S.getMute('input', 31) + await run('input_mute', { channels: [31], state: 'toggle' }) + await sleep(inst.timing.writeGuardMs + 500) + const w = sent.slice(mark).find((m) => m.address === '/Input_Channels/31/mute' && !m.address.endsWith('/?')) + check(!!w && decodeArgs(w).tags === ',f' && S.getMute('input', 31) === !was31, 'a module switch write goes out as a float and the console accepted it (the confirm query returns the written value)') +} + // Names: renames rebuild the definitions once (debounced) and the dropdowns pick them up { const before = ctx.rebuilds diff --git a/tools/README.md b/tools/README.md index fec2ded..a05f4f3 100644 --- a/tools/README.md +++ b/tools/README.md @@ -70,7 +70,7 @@ inputs, `--auxes N` the busses (default 25), and the matrix (8 outputs, 10 input | `/Console/{Aux_Outputs,Group_Outputs}/modes/?`, `/Console/Input_Channels/modes/?` | one `i` per buss / input (2 = stereo, 1 = mono, captured) | | `/Input_Channels/N/Channel_Input/name/?`, `/Aux_Outputs/N/Buss_Trim/name/?`, `/Group_Outputs/N/Buss_Trim/name/?`, `/Matrix_Outputs/N/Buss_Trim/name/?`, `/Control_Groups/N/name/?`, `/Graphic_EQ/N/name/?` | `s`. Only input names accept a write here (not verified on the real console); the others are read-only. The 25 busses appear under both Aux_Outputs and Group_Outputs; a buss reports its name on the side matching its mode and `""` on the other. Matrix inputs have no name address | | `/{Input_Channels,Aux_Outputs,Group_Outputs,Matrix_Outputs,Control_Groups}/N/fader/?` | `f`, dB, -150 = -inf, max +10 | -| `/{...}/N/mute/?`, `/{...}/N/solo/?` | `f` 0/1 (a float, not an int) | +| `/{...}/N/mute/?`, `/{...}/N/solo/?` | `f` 0/1 (a float, not an int); a SET must be a float too, see below | | `/Input_Channels/N/Panner/pan/?` | `f`, 0..1, 0.5 = centre | | `/Input_Channels/N/Aux_Send/M/{send_level,send_on,send_pan}/?` | `f` (level dB -150..10, on 0/1, pan 0..1) | | `/Input_Channels/N/Aux_Send/M/send_pre-post/?` | `i` (captured as an int, default 0; settable 0..1, which value means "pre" is an assumption; the Companion module does not use it) | @@ -90,7 +90,10 @@ chooses `id`. Every subscribed id then gets a random value 0..127 about five tim `/Meters/values ,ii... [id, v, id, v, ...]` (only ids whose value changed). `/Meters/clear` drops every subscription, like the real console. -Sets accept int or float for the 0/1 switches (any non-zero = on) and clamp levels, pans and values to +A 0/1 switch (mute, solo, send on, group assign, matrix send on, `geq_in` and every channel-strip switch) is both +reported **and written as a float** 0.0 / 1.0, verified on a real S31 on 2026-10-15 (the 2014 DiGiCo list says +int). Exactly like the console, the mock accepts only a float write (stored, echoed as `,f`) and silently +ignores an int `i` or `T`/`F` write: no echo, no state change. Levels, pans and values are clamped to their ranges. Like the real console the mock stays **silent** on everything else: all lowercase S-Series addresses, `/Console/Resend`, `/Console/Channels/?`, whole-send queries, out-of-range entity numbers, sets of read-only names. (`--bool-int` has no effect in `sd`.) diff --git a/tools/mock-s31.js b/tools/mock-s31.js index 531fa3a..47a7a16 100755 --- a/tools/mock-s31.js +++ b/tools/mock-s31.js @@ -23,7 +23,8 @@ * A QUERY is the address plus a trailing "/?" and no args; the reply is the * same address WITHOUT "/?" plus the value. A SET is the address without * "/?" and one argument; it is stored and echoed using the console's reply - * types (mute, solo and send_on come back as floats 0/1). It also streams + * types (mute, solo and send_on come back as floats 0/1, and a 0/1 + * switch only accepts a SET as a float: int and T/F writes are ignored). It also streams * meters to subscribed ids and implements the write-only snapshot triggers. * Anything the real console does not answer (lowercase S-Series addresses, * /Console/Resend, /Console/Channels/?, whole-send queries, ...) gets no @@ -165,8 +166,8 @@ function mulberry32(seed) { * s string * f float32 with min/max clamp * b boolean (sseries; sent as T/F, or int 0/1 with --bool-int) - * flag 0/1 number (sd; accepted as int, float or boolean; reported as a float, - * or as an int when the spec says wire: 'i') + * flag 0/1 number (sd; a SET is accepted only as a float 0.0 / 1.0, exactly like the S31: int and + * T/F writes are silently ignored; reported as a float, or as an int when the spec says wire: 'i') * int integer with min/max clamp (sd) */ @@ -803,12 +804,12 @@ function sdResolve(address) { return null; } -function handleSd(address, args) { +function handleSd(address, args, types) { if (address.endsWith('/?')) { if (args.length !== 0) return ignore(`query ${address} carries arguments ${formatArgs(args)}`); return sdQuery(address.slice(0, -2), address); } - return sdSet(address, args); + return sdSet(address, args, types); } /** The nine counts the console pushes once, shortly after first contact (catalog: unsolicited, order as captured). */ @@ -859,7 +860,7 @@ function sdFireSnapshot(index) { send('/Snapshots/Current_Snapshot', [int(sdSnapshot)]); } -function sdSet(address, args) { +function sdSet(address, args, types) { // Write-only trigger nodes if (address === '/Snapshots/Fire_Snapshot_number') { if (typeof args[0] !== 'number' || args[0] < 0 || args[0] >= SD_SNAPSHOTS.length) return ignore(`snapshot fire with ${formatArgs(args)}`); @@ -895,6 +896,8 @@ function sdSet(address, args) { if (!r) return ignore(`no reply for ${address} (unknown or unsupported address)`); const spec = r.spec; if (spec.readOnly) return ignore(`${address} is read-only`); + // Verified on a real S31 (2026-10-15): a 0/1 switch only takes a float write; ints and T/F are ignored without any reply. + if (spec.kind === 'flag' && types !== 'f') return ignore(`${address} is a switch: only a float 0.0 / 1.0 is accepted, not ,${types} (no reply, no change)`); if (args.length !== 1) return ignore(`${address} needs exactly one argument (got ${formatArgs(args)})`); const value = coerce(spec, args[0]); if (value === undefined) return ignore(`unusable value ${formatArgs(args)} for ${address}`); @@ -963,7 +966,7 @@ socket.on('message', (msg, rinfo) => { setTimeout(() => startPaced('count burst', sdCountBurst(), { append: true }), SD_BURST_DELAY_MS); } - if (packet.raw === undefined) handle(packet.address, packet.args); + if (packet.raw === undefined) handle(packet.address, packet.args, packet.types); }); socket.on('error', (err) => {