Companion module for the DiGiCo S31 with an iPad relay (v1)

Bitfocus Companion 5 connection module (ESM, @companion-module/base ~2.1.3,
runtime node22) for the DiGiCo S21/S31. It occupies the console's single
iPad slot, controls per-channel sends/assigns to one buss, aux send
level/on/pan, mutes, faders and snapshots, and relays any number of DiGiCo
iPad apps through to the console byte-for-byte.

The S31's iPad extension was probed live: it speaks the SD/DiGiCo Pad OSC
tree (console receives on 8000, replies only to the configured iPad port
9000). tools/mock-s31.js emulates the console and test/selftest.js runs the
link + relay end to end against it. docs/design-v2.md is the brief for the
generic v2 rewrite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 15:55:18 -04:00
co-authored by Claude Fable 5.1
commit 131cdfd81f
23 changed files with 10066 additions and 0 deletions
+146
View File
@@ -0,0 +1,146 @@
/**
* End-to-end self test without Companion: mock console <-> module link/relay <-> fake iPad.
*
* node test/selftest.js
*
* Ports (all on 127.0.0.1, chosen to avoid anything real):
* mock console listens 18000, replies to 127.0.0.1:19000 (console "receive" / "send")
* module link binds 19000, sends to 18000
* module relay binds 18001, fans out to iPad port 19001
* fake iPad binds 19001, sends to 18001
*/
import { spawn } from 'node:child_process'
import dgram from 'node:dgram'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { ConsoleLink } from '../src/console-link.js'
import { Relay } from '../src/relay.js'
import { getDialect } from '../src/dialects.js'
import { encode, decode, describe } from '../src/osc.js'
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
let failures = 0
function check(ok, what) {
console.log(`${ok ? 'PASS' : 'FAIL'} ${what}`)
if (!ok) failures++
}
// 0. The instance class must at least import cleanly with the real base library.
const mainMod = await import('../src/main.js')
check(typeof mainMod.default === 'function' && Array.isArray(mainMod.UpgradeScripts), 'src/main.js imports and exports default class + UpgradeScripts')
const inst = Object.create(mainMod.default.prototype)
inst.config = { monSend: 24, monLabel: 'Studio Mon', channels: 4, monMode: 'auto' }
Object.defineProperty(inst, 'label', { value: 'digico' })
inst.dialect = getDialect('sd')
inst.state = { auxNames: new Map([[24, '']]), groupNames: new Map([[24, 'Studio']]), sends: new Map(), groups: new Map(), mutes: new Map(), bussMutes: new Map() }
check(inst.monMode() === 'group', 'monMode() auto-detects a group buss from the names')
const defs = await import('../src/definitions.js')
const actions = defs.getActionDefinitions(inst)
const feedbacks = defs.getFeedbackDefinitions(inst)
const variables = defs.getVariableDefinitions(inst)
const { structure, presets } = defs.getPresetDefinitions(inst)
check(Object.keys(actions).length >= 15 && Object.keys(feedbacks).length >= 8, `definitions build (${Object.keys(actions).length} actions, ${Object.keys(feedbacks).length} feedbacks, ${Object.keys(variables).length} variables, ${Object.keys(presets).length} presets, ${structure.length} preset groups)`)
// 1. Codec round trips, including the unpadded-datagram case the iPad app produces.
{
const buf = encode('/Input_Channels/3/Aux_Send/2/send_on', [{ i: 1 }])
const m = decode(buf)
check(m.address === '/Input_Channels/3/Aux_Send/2/send_on' && m.tags === ',i' && m.args[0] === 1, 'OSC encode/decode int')
const f = decode(encode('/x', [-12.5, 'hi', true, false, null]))
check(Math.abs(f.args[0] + 12.5) < 1e-6 && f.args[1] === 'hi' && f.args[2] === true && f.args[3] === false && f.args[4] === null, 'OSC encode/decode float/string/T/F/N')
const short = decode(buf.subarray(0, buf.length - 2))
check(short.unpadded === true && short.address === m.address, 'decoder tolerates an unpadded datagram')
check(decode(Buffer.from('garbage')).raw !== undefined, 'decoder returns raw for non-OSC bytes')
}
// 2. Start the mock console (SD dialect) and a fake iPad.
const node = process.execPath
const mock = spawn(node, [path.join(root, 'tools/mock-s31.js'), '--dialect', 'sd', '--listen', '18000', '--reply-to', '127.0.0.1:19000', '--quiet'], { stdio: ['ignore', 'pipe', 'pipe'] })
let mockOut = ''
mock.stdout.on('data', (d) => (mockOut += d))
mock.stderr.on('data', (d) => (mockOut += d))
await sleep(400)
const link = new ConsoleLink({ host: '127.0.0.1', port: 18000, localPort: 19000 })
const relay = new Relay({ port: 18001, clientPort: 19001, timeoutMs: 60000 })
const dialect = getDialect('sd')
const state = { names: new Map(), auxNames: new Map(), groupNames: new Map(), sends: new Map(), groups: new Map(), pongs: 0, counts: {} }
link.on('raw', (buf) => relay.fanout(buf))
link.on('message', (msg) => {
const ev = dialect.parse(msg.address, msg.args || [])
if (!ev) return
if (ev.kind === 'pong') state.pongs++
if (ev.kind === 'name') state.names.set(ev.ch, ev.value)
if (ev.kind === 'auxName') state.auxNames.set(ev.aux, ev.value)
if (ev.kind === 'groupName') state.groupNames.set(ev.group, ev.value)
if (ev.kind === 'count') state.counts[ev.field] = ev.value
if (ev.kind === 'send') {
const k = `${ev.ch}:${ev.send}`
state.sends.set(k, { ...(state.sends.get(k) || {}), [ev.field]: ev.value })
}
if (ev.kind === 'group') state.groups.set(`${ev.ch}:${ev.group}`, ev.value)
})
relay.on('fromClient', (buf) => link.sendRaw(buf))
await link.open()
await relay.open()
// 3. Module-style load: paced queries.
const queries = dialect.resendQueries({ inputs: 8, auxes: 25, monSend: 24 })
for (const [a, args] of queries) {
link.send(a, args)
await sleep(4)
}
await sleep(800)
check(state.pongs >= 1, 'console answered /Console/Name/? (keepalive works)')
check(state.counts.inputs === 60 && state.counts.auxes === 25, `counts loaded (inputs=${state.counts.inputs}, auxes=${state.counts.auxes})`)
check(state.names.get(1) === 'KICK In', `input names loaded (1 = ${JSON.stringify(state.names.get(1))})`)
check(state.auxNames.get(1) === 'VOCAL VERB', `aux names loaded (1 = ${JSON.stringify(state.auxNames.get(1))})`)
const s = state.sends.get('1:24')
check(s && typeof s.level === 'number' && typeof s.enabled === 'boolean', `send state for ch1 buss24 loaded (${JSON.stringify(s)})`)
check(state.groupNames.get(24) === 'Studio' && state.auxNames.get(24) === '', `group names loaded (24 = ${JSON.stringify(state.groupNames.get(24))}, aux side ${JSON.stringify(state.auxNames.get(24))})`)
check(state.groups.get('1:24') === true && state.groups.size >= 8, `group assigns loaded for buss 24 (${state.groups.size} known, ch1 = ${state.groups.get('1:24')})`)
link.send(...dialect.groupAssign(5, 24, false, 'int'))
await sleep(200)
check(state.groups.get('5:24') === false, 'group assign write (int) echoed by console and parsed as boolean')
// 4. A write from the module leg is echoed back by the console.
link.send(...dialect.sendLevel(2, 24, -12.5))
await sleep(200)
check(Math.abs((state.sends.get('2:24')?.level ?? 999) + 12.5) < 0.01, 'send_level write echoed by console and parsed')
link.send(...dialect.sendEnabled(2, 24, true, 'int'))
await sleep(200)
check(state.sends.get('2:24')?.enabled === true, 'send_on write (int) echoed by console and parsed as boolean')
// 5. Fake iPad through the relay: it must get console replies and its writes must reach the console.
const ipadRx = []
const ipad = dgram.createSocket('udp4')
ipad.on('message', (buf) => ipadRx.push(decode(buf)))
await new Promise((r) => ipad.bind(19001, '127.0.0.1', r))
ipad.send(encode('/Console/Name/?', []), 18001, '127.0.0.1')
await sleep(300)
check(ipadRx.some((m) => m.address === '/Console/Name'), `iPad got /Console/Name via relay (${ipadRx.length} msgs)`)
check(relay.clientCount === 1, 'relay tracks one iPad client')
ipad.send(encode('/Input_Channels/3/Aux_Send/24/send_level', [-20]), 18001, '127.0.0.1')
await sleep(300)
check(Math.abs((state.sends.get('3:24')?.level ?? 999) + 20) < 0.01, 'iPad write relayed to console; echo parsed by module')
check(ipadRx.some((m) => m.address === '/Input_Channels/3/Aux_Send/24/send_level'), 'console echo of the iPad write fanned back to the iPad')
// 6. Unpadded datagram from an "iPad" is forwarded verbatim and still answered.
const unpadded = encode('/Console/Name/?', []).subarray(0, encode('/Console/Name/?', []).length - 0) // (already padded; mimic by trimming trailing zero bytes)
const trimmed = unpadded.subarray(0, unpadded.length - 2)
const before = ipadRx.length
ipad.send(trimmed, 18001, '127.0.0.1')
await sleep(300)
console.log(`info unpadded query forwarded; console replies after it: ${ipadRx.length - before} (mock may or may not accept unpadded input)`)
ipad.close()
relay.close()
link.close()
mock.kill('SIGTERM')
await sleep(200)
if (failures) {
console.log(`\n${failures} check(s) failed. Mock output:\n${mockOut.slice(-2000)}`)
process.exit(1)
}
console.log('\nAll checks passed.')