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
+83
View File
@@ -0,0 +1,83 @@
import dgram from 'node:dgram'
import { EventEmitter } from 'node:events'
import { encode, decode } from './osc.js'
/**
* One UDP socket toward the console.
*
* The S-Series sends every reply and every change notification to the single
* controller IP:port configured on the console, never to the packet's source
* port, so we bind a fixed local port (the console's "Send Port") and send to
* the console's "Receive Port".
*
* Events: 'raw' (buf, rinfo) for every datagram from the console,
* 'message' (decoded, buf, rinfo), 'stray' (buf, rinfo) for packets from
* other hosts, 'error' (err), 'listening' (addressInfo).
*/
export class ConsoleLink extends EventEmitter {
constructor({ host, port, localPort, bindAddress = '0.0.0.0' }) {
super()
this.host = host
this.port = Number(port)
this.localPort = Number(localPort)
this.bindAddress = bindAddress
this.socket = null
this.lastRx = 0
this.rxCount = 0
this.txCount = 0
}
open() {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: false })
let settled = false
socket.on('error', (err) => {
if (!settled) {
settled = true
reject(err)
} else this.emit('error', err)
})
socket.on('message', (buf, rinfo) => {
if (this.host && rinfo.address !== this.host) {
this.emit('stray', buf, rinfo)
return
}
this.lastRx = Date.now()
this.rxCount++
this.emit('raw', buf, rinfo)
this.emit('message', decode(buf), buf, rinfo)
})
socket.bind(this.localPort, this.bindAddress, () => {
settled = true
this.socket = socket
this.emit('listening', socket.address())
resolve(socket.address())
})
})
}
send(address, args = []) {
return this.sendRaw(encode(address, args))
}
sendRaw(buf) {
if (!this.socket || !this.host) return false
this.txCount++
this.socket.send(buf, this.port, this.host, (err) => {
if (err) this.emit('error', err)
})
return true
}
close() {
const s = this.socket
this.socket = null
if (s) {
try {
s.close()
} catch (_e) {
/* already closed */
}
}
}
}
+345
View File
@@ -0,0 +1,345 @@
import { combineRgb } from '@companion-module/base'
export const MAX_INPUTS = 60
export const MAX_BUSSES = 25
export const BUS_FIRST = 70
export const BUS_LAST = 93
const GREEN = combineRgb(0, 160, 60)
const RED = combineRgb(190, 30, 30)
const AMBER = combineRgb(200, 140, 0)
const WHITE = combineRgb(255, 255, 255)
const BLACK = combineRgb(0, 0, 0)
const DARK = combineRgb(30, 30, 30)
const STATE_CHOICES = [
{ id: 'on', label: 'On' },
{ id: 'off', label: 'Off' },
{ id: 'toggle', label: 'Toggle' },
]
export const CHANNEL_HELP = 'inputs 1-60; with the S-Series OSC Control set busses are 70-93 and master 120'
function channelOption(id = 'channel', label = 'Input channel (1-60)') {
return { type: 'number', id, label, default: 1, min: 1, max: 120, step: 1 }
}
function bussOption(inst, id = 'send', label = 'Buss number (1-25)') {
return { type: 'number', id, label, default: inst.monSend(), min: 1, max: MAX_BUSSES, step: 1 }
}
function levelOption(inst, id = 'level', label) {
const d = inst.dialect
return {
type: 'number',
id,
label: label || `Level (dB, ${d.levelMin} = off)`,
default: 0,
min: d.levelMin,
max: d.levelMax,
step: 0.5,
required: true,
}
}
function deltaOption() {
return { type: 'number', id: 'delta', label: 'Change (dB)', default: 1, min: -30, max: 30, step: 0.5 }
}
function stateOption(label = 'State') {
return { type: 'dropdown', id: 'state', label, default: 'toggle', choices: STATE_CHOICES }
}
export function getActionDefinitions(inst) {
const mon = inst.monLabel()
const d = inst.dialect
return {
mon_on: {
name: `${mon}: channel on/off (aux send on, or group assign)`,
description: 'Uses the Studio Mon buss from the connection settings; works whether that buss is an aux (send on/off) or a group (assign).',
options: [channelOption(), stateOption()],
callback: async ({ options }) => inst.setMonOn(options.channel, options.state),
},
mon_level: {
name: `${mon}: set send level (aux mode only)`,
options: [channelOption(), levelOption(inst)],
callback: async ({ options }) => inst.setSendLevel(options.channel, inst.monSend(), options.level),
},
mon_nudge: {
name: `${mon}: nudge send level (aux mode only)`,
options: [channelOption(), deltaOption()],
callback: async ({ options }) => inst.nudgeSendLevel(options.channel, inst.monSend(), options.delta),
},
send_on: {
name: 'Aux send: on/off',
options: [channelOption(), bussOption(inst, 'send', 'Aux buss number (1-25)'), stateOption()],
callback: async ({ options }) => inst.setSendEnabled(options.channel, options.send, options.state),
},
send_level: {
name: 'Aux send: set level',
options: [channelOption(), bussOption(inst, 'send', 'Aux buss number (1-25)'), levelOption(inst)],
callback: async ({ options }) => inst.setSendLevel(options.channel, options.send, options.level),
},
send_nudge: {
name: 'Aux send: nudge level',
options: [channelOption(), bussOption(inst, 'send', 'Aux buss number (1-25)'), deltaOption()],
callback: async ({ options }) => inst.nudgeSendLevel(options.channel, options.send, options.delta),
},
send_pan: {
name: 'Aux send: set pan',
options: [
channelOption(),
bussOption(inst, 'send', 'Aux buss number (1-25)'),
{ type: 'number', id: 'pan', label: `Pan (${d.panMin} L … ${d.panMax} R, centre ${d.panCentre})`, default: d.panCentre, min: d.panMin, max: d.panMax, step: 0.05 },
],
callback: async ({ options }) => inst.sendDialect('sendPan', options.channel, options.send, options.pan),
},
group_assign: {
name: 'Group: assign channel on/off',
options: [channelOption(), bussOption(inst, 'group', 'Group buss number (1-25)'), stateOption()],
callback: async ({ options }) => inst.setGroupAssign(options.channel, options.group, options.state),
},
fader: {
name: 'Channel fader (dB)',
options: [channelOption('channel', `Channel number (${CHANNEL_HELP})`), { type: 'number', id: 'level', label: 'Level (dB)', default: 0, min: -150, max: 10, step: 0.5 }],
callback: async ({ options }) => inst.sendDialect('fader', options.channel, options.level),
},
mute: {
name: 'Channel mute',
options: [channelOption('channel', `Channel number (${CHANNEL_HELP})`), stateOption()],
callback: async ({ options }) => inst.setMute(options.channel, options.state),
},
buss_fader: {
name: 'Aux buss master fader (dB)',
options: [bussOption(inst, 'buss', 'Aux buss number (1-25)'), { type: 'number', id: 'level', label: 'Level (dB)', default: 0, min: -150, max: 10, step: 0.5 }],
callback: async ({ options }) => inst.sendDialect('auxFader', options.buss, options.level),
},
buss_mute: {
name: 'Aux buss master mute',
options: [bussOption(inst, 'buss', 'Aux buss number (1-25)'), stateOption()],
callback: async ({ options }) => inst.setBussMute(options.buss, options.state),
},
snapshot_fire: {
name: 'Snapshot: fire number',
options: [{ type: 'number', id: 'number', label: 'Snapshot number', default: 1, min: 0, max: 9999, step: 1 }],
callback: async ({ options }) => inst.sendDialect('snapshotFire', Math.trunc(options.number)),
},
snapshot_next: { name: 'Snapshot: next', options: [], callback: async () => inst.sendDialect('snapshotNext') },
snapshot_prev: { name: 'Snapshot: previous', options: [], callback: async () => inst.sendDialect('snapshotPrev') },
resend: {
name: 'Console: reload names, levels and sends',
options: [],
callback: async () => inst.requestResend(),
},
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.sendRawAction(options),
},
relay_forget: {
name: 'Relay: forget all iPad clients',
options: [],
callback: async () => inst.relay?.forgetClients(),
},
}
}
export function getFeedbackDefinitions(inst) {
const mon = inst.monLabel()
const d = inst.dialect
return {
mon_on: {
type: 'boolean',
name: `${mon}: channel is on (send on / assigned)`,
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [channelOption()],
callback: ({ options }) => inst.getMonOn(options.channel) === true,
},
send_on: {
type: 'boolean',
name: 'Aux send: is on',
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [channelOption(), bussOption(inst, 'send', 'Aux buss number (1-25)')],
callback: ({ options }) => inst.getSend(options.channel, options.send).enabled === true,
},
group_assigned: {
type: 'boolean',
name: 'Group: channel is assigned',
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [channelOption(), bussOption(inst, 'group', 'Group buss number (1-25)')],
callback: ({ options }) => inst.getGroup(options.channel, options.group) === true,
},
mon_level_above: {
type: 'boolean',
name: `${mon}: send level is above`,
defaultStyle: { bgcolor: AMBER, color: BLACK },
options: [channelOption(), { type: 'number', id: 'threshold', label: 'Threshold (dB)', default: -60, min: d.levelMin, max: d.levelMax, step: 0.5 }],
callback: ({ options }) => {
const v = inst.getSend(options.channel, inst.monSend()).level
return typeof v === 'number' && v > options.threshold
},
},
muted: {
type: 'boolean',
name: 'Channel is muted',
defaultStyle: { bgcolor: RED, color: WHITE },
options: [channelOption('channel', `Channel number (${CHANNEL_HELP})`)],
callback: ({ options }) => inst.state.mutes.get(Number(options.channel)) === true,
},
buss_muted: {
type: 'boolean',
name: 'Aux buss master is muted',
defaultStyle: { bgcolor: RED, color: WHITE },
options: [bussOption(inst, 'buss', 'Aux buss number (1-25)')],
callback: ({ options }) => inst.state.bussMutes.get(Number(options.buss)) === true,
},
connected: {
type: 'boolean',
name: 'Console link is alive',
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [],
callback: () => inst.isConnected(),
},
relay_clients: {
type: 'boolean',
name: 'Relay has at least one iPad connected',
defaultStyle: { bgcolor: GREEN, color: WHITE },
options: [],
callback: () => (inst.relay?.clientCount || 0) > 0,
},
}
}
export function getVariableDefinitions(inst) {
const mon = inst.monLabel()
const defs = {
connected: { name: 'Console link alive (Yes/No)' },
console_name: { name: 'Console name' },
console_host: { name: 'Console IP' },
relay_clients: { name: 'iPad apps connected through the relay' },
relay_client_ips: { name: 'iPad client IPs' },
last_rx: { name: 'Last message received from the console' },
last_send_touched: { name: 'Last send/assign that changed (ch/buss/field)' },
input_count: { name: 'Input channel count reported by the console' },
aux_count: { name: 'Buss count reported by the console' },
snapshot_current: { name: 'Current snapshot index' },
snapshot_name: { name: 'Current snapshot name' },
mon_send: { name: `${mon} buss number (from config)` },
mon_name: { name: `${mon} buss name (from the console)` },
mon_mode: { name: `${mon} buss mode (aux / group)` },
}
for (let n = 1; n <= MAX_INPUTS; n++) defs[`ch_${n}_name`] = { name: `Input ${n} name` }
for (let n = 1; n <= MAX_BUSSES; n++) {
defs[`aux_${n}_name`] = { name: `Buss ${n} name (aux mode)` }
defs[`group_${n}_name`] = { name: `Buss ${n} name (group mode)` }
defs[`buss_${n}_mode`] = { name: `Buss ${n} Stereo/Mono` }
}
for (let n = BUS_FIRST; n <= BUS_LAST; n++) defs[`bus_${n}_name`] = { name: `S-Series OSC channel ${n} name` }
for (let n = 1; n <= MAX_INPUTS; n++) {
defs[`mon_${n}_level`] = { name: `${mon} send level, input ${n} (dB)` }
defs[`mon_${n}_on`] = { name: `${mon} on/off (send on or assigned), input ${n}` }
}
return defs
}
export function getPresetDefinitions(inst) {
const label = inst.label
const mon = inst.monLabel()
const count = Math.min(MAX_INPUTS, Math.max(1, Number(inst.config.channels) || 48))
const groupMode = inst.monMode() === 'group'
const presets = {}
const onIds = []
const upIds = []
const downIds = []
for (let n = 1; n <= count; n++) {
presets[`mon_on_${n}`] = {
type: 'simple',
name: `${mon} ch ${n} on/off`,
style: {
text: groupMode ? `$(${label}:ch_${n}_name)\\n$(${label}:mon_${n}_on)` : `$(${label}:ch_${n}_name)\\n$(${label}:mon_${n}_level)dB`,
size: 'auto',
color: WHITE,
bgcolor: DARK,
},
steps: [{ down: [{ actionId: 'mon_on', options: { channel: n, state: 'toggle' } }], up: [] }],
feedbacks: [{ feedbackId: 'mon_on', options: { channel: n }, style: { bgcolor: GREEN, color: WHITE } }],
}
onIds.push(`mon_on_${n}`)
if (!groupMode) {
presets[`mon_up_${n}`] = {
type: 'simple',
name: `${mon} ch ${n} +1 dB`,
style: { text: `$(${label}:ch_${n}_name)\\n▲ +1`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'mon_nudge', options: { channel: n, delta: 1 } }], up: [] }],
feedbacks: [],
}
presets[`mon_down_${n}`] = {
type: 'simple',
name: `${mon} ch ${n} -1 dB`,
style: { text: `$(${label}:ch_${n}_name)\\n▼ -1`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'mon_nudge', options: { channel: n, delta: -1 } }], up: [] }],
feedbacks: [],
}
upIds.push(`mon_up_${n}`)
downIds.push(`mon_down_${n}`)
}
}
presets.resend = {
type: 'simple',
name: 'Reload from console',
style: { text: `$(${label}:console_name)\\nReload`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'resend', options: {} }], up: [] }],
feedbacks: [{ feedbackId: 'connected', options: {}, style: { bgcolor: GREEN, color: WHITE } }],
}
presets.snapshot_next = {
type: 'simple',
name: 'Snapshot next',
style: { text: `$(${label}:snapshot_name)\\nNEXT ▶`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'snapshot_next', options: {} }], up: [] }],
feedbacks: [],
}
presets.snapshot_prev = {
type: 'simple',
name: 'Snapshot previous',
style: { text: `◀ PREV\\n$(${label}:snapshot_name)`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [{ actionId: 'snapshot_prev', options: {} }], up: [] }],
feedbacks: [],
}
presets.relay_status = {
type: 'simple',
name: 'Relay status',
style: { text: `iPads\\n$(${label}:relay_clients)`, size: 'auto', color: WHITE, bgcolor: DARK },
steps: [{ down: [], up: [] }],
feedbacks: [{ feedbackId: 'relay_clients', options: {}, style: { bgcolor: GREEN, color: WHITE } }],
}
const monDefs = [{ id: 'mon_on', type: 'simple', name: `${mon} on/off per input`, presets: onIds }]
if (!groupMode) {
monDefs.push({ id: 'mon_up', type: 'simple', name: `${mon} +1 dB per input`, presets: upIds })
monDefs.push({ id: 'mon_down', type: 'simple', name: `${mon} -1 dB per input`, presets: downIds })
}
const structure = [
{ id: 'mon', name: `${mon} (buss ${inst.monSend()}, ${groupMode ? 'group assign' : 'aux send'})`, definitions: monDefs },
{
id: 'console',
name: 'Console',
definitions: [{ id: 'console', type: 'simple', name: 'Console', presets: ['resend', 'snapshot_next', 'snapshot_prev', 'relay_status'] }],
},
]
return { structure, presets }
}
+200
View File
@@ -0,0 +1,200 @@
/**
* Address dialects the console link can speak.
*
* sd The "DiGiCo Pad" tree (/Input_Channels/N/Aux_Send/M/send_level …). This is
* what the S31's *iPad* extension speaks — verified live on 2026-10-15 against
* console "S31 AB" (10.3.4.221): queries are the address + "/?", replies come
* back on the same address without "/?", and every reply goes to the controller
* IP:port configured on the console. Levels are dB floats (−150 = off, +10 max),
* send_on / mute are reported as float 0/1 (DiGiCo's SD list says write ints),
* pan is 0..1 with 0.5 centre. The SD iPad app and the stock companion
* digico-osc "IPAD" command set use this same tree.
*
* sseries The S21/S31 *OSC Control* extension (Extensions > OSC Control): lower-case,
* channel-number based (/channel/N/send/M/level, /console/resend …). Inputs 1-60,
* aux busses 70-79, groups 80-93, matrix 100-107, CGs 110-119, master 120. The
* S31's iPad extension does NOT answer these.
*
* raw No parsing, no keepalive: the module is only a transparent relay.
*
* Builders return [address, args] or null when the dialect has no equivalent.
* parse() turns an inbound message into a normalised event or null.
*/
import { toBool, toNumber } from './osc.js'
export const BOOL_STYLES = [
{ id: 'int', label: 'Integer 0/1 (DiGiCo Pad default)' },
{ id: 'float', label: 'Float 0.0/1.0 (as the S31 reports them)' },
{ id: 'tf', label: 'OSC True/False tags (S-Series OSC Control default)' },
]
function boolArg(on, style) {
if (style === 'tf') return !!on
if (style === 'float') return on ? 1 : 0 // plain numbers encode as float32
return { i: on ? 1 : 0 }
}
const sd = {
id: 'sd',
label: 'DiGiCo Pad / iPad extension (/Input_Channels/N/…)',
pongAddress: '/Console/Name',
levelMin: -150,
levelMax: 10,
panMin: 0,
panMax: 1,
panCentre: 0.5,
ping: () => ['/Console/Name/?', []],
/** Everything the module wants after (re)connecting; sent paced by the queue. */
resendQueries({ inputs, auxes, monSend }) {
const q = [
['/Console/Name/?', []],
['/Console/Input_Channels/?', []],
['/Console/Aux_Outputs/?', []],
['/Console/Aux_Outputs/modes/?', []],
['/Snapshots/Current_Snapshot/?', []],
['/Snapshots/names/?', []],
]
for (let a = 1; a <= auxes; a++) {
q.push([`/Aux_Outputs/${a}/Buss_Trim/name/?`, []])
q.push([`/Group_Outputs/${a}/Buss_Trim/name/?`, []])
}
for (let n = 1; n <= inputs; n++) {
q.push([`/Input_Channels/${n}/Channel_Input/name/?`, []])
q.push([`/Input_Channels/${n}/mute/?`, []])
q.push([`/Input_Channels/${n}/Aux_Send/${monSend}/send_level/?`, []])
q.push([`/Input_Channels/${n}/Aux_Send/${monSend}/send_on/?`, []])
q.push([`/Input_Channels/${n}/Group_Send/${monSend}/group/?`, []])
}
// The console has been seen to drop queries sent in the first few ms after a
// connection opens, so ask the cheap global ones again at the end.
q.push(['/Console/Input_Channels/?', []], ['/Console/Aux_Outputs/?', []], ['/Snapshots/Current_Snapshot/?', []])
return q
},
querySend: (ch, send) => [
[`/Input_Channels/${ch}/Aux_Send/${send}/send_level/?`, []],
[`/Input_Channels/${ch}/Aux_Send/${send}/send_on/?`, []],
],
queryGroup: (ch, group) => [[`/Input_Channels/${ch}/Group_Send/${group}/group/?`, []]],
sendLevel: (ch, send, dB) => [`/Input_Channels/${ch}/Aux_Send/${send}/send_level`, [Number(dB)]],
sendEnabled: (ch, send, on, style) => [`/Input_Channels/${ch}/Aux_Send/${send}/send_on`, [boolArg(on, style)]],
sendPan: (ch, send, pan) => [`/Input_Channels/${ch}/Aux_Send/${send}/send_pan`, [Number(pan)]],
groupAssign: (ch, group, on, style) => [`/Input_Channels/${ch}/Group_Send/${group}/group`, [boolArg(on, style)]],
fader: (ch, dB) => [`/Input_Channels/${ch}/fader`, [Number(dB)]],
mute: (ch, on, style) => [`/Input_Channels/${ch}/mute`, [boolArg(on, style)]],
auxFader: (aux, dB) => [`/Aux_Outputs/${aux}/fader`, [Number(dB)]],
auxMute: (aux, on, style) => [`/Aux_Outputs/${aux}/mute`, [boolArg(on, style)]],
snapshotFire: (n) => ['/Snapshots/Fire_Snapshot_number', [{ i: n }]],
snapshotNext: () => ['/Snapshots/Fire_Next_Snapshot', [{ i: 0 }]],
snapshotPrev: () => ['/Snapshots/Fire_Prev_Snapshot', [{ i: 0 }]],
parse(address, args) {
let m
if (address === '/Console/Name') return { kind: 'pong', name: String(args[0] ?? '') }
if (address === '/Console/Input_Channels') return { kind: 'count', field: 'inputs', value: toNumber(args[0]) }
if (address === '/Console/Aux_Outputs') return { kind: 'count', field: 'auxes', value: toNumber(args[0]) }
if (address === '/Console/Group_Outputs') return { kind: 'count', field: 'groups', value: toNumber(args[0]) }
if (address === '/Console/Aux_Outputs/modes') return { kind: 'auxModes', values: args.map(toNumber) }
if ((m = /^\/Input_Channels\/(\d+)\/Aux_Send\/(\d+)\/send_(level|on|pan)$/.exec(address))) {
const field = m[3] === 'on' ? 'enabled' : m[3]
const value = field === 'enabled' ? toBool(args[0]) : toNumber(args[0])
return { kind: 'send', ch: +m[1], send: +m[2], field, value }
}
if ((m = /^\/Input_Channels\/(\d+)\/Group_Send\/(\d+)\/group$/.exec(address))) return { kind: 'group', ch: +m[1], group: +m[2], value: toBool(args[0]) }
if ((m = /^\/Input_Channels\/(\d+)\/Channel_Input\/name$/.exec(address))) return { kind: 'name', ch: +m[1], value: String(args[0] ?? '') }
if ((m = /^\/Input_Channels\/(\d+)\/fader$/.exec(address))) return { kind: 'fader', ch: +m[1], value: toNumber(args[0]) }
if ((m = /^\/Input_Channels\/(\d+)\/mute$/.exec(address))) return { kind: 'mute', ch: +m[1], value: toBool(args[0]) }
if ((m = /^\/Aux_Outputs\/(\d+)\/Buss_Trim\/name$/.exec(address))) return { kind: 'auxName', aux: +m[1], value: String(args[0] ?? '') }
if ((m = /^\/Group_Outputs\/(\d+)\/Buss_Trim\/name$/.exec(address))) return { kind: 'groupName', group: +m[1], value: String(args[0] ?? '') }
if ((m = /^\/Aux_Outputs\/(\d+)\/fader$/.exec(address))) return { kind: 'auxFader', aux: +m[1], value: toNumber(args[0]) }
if ((m = /^\/Aux_Outputs\/(\d+)\/mute$/.exec(address))) return { kind: 'auxMute', aux: +m[1], value: toBool(args[0]) }
if (address === '/Snapshots/Current_Snapshot') return { kind: 'snapshot', index: toNumber(args[0]) }
if ((m = /^\/Snapshots\/Recall_Snapshot\/(\d+)$/.exec(address))) return { kind: 'snapshot', index: +m[1] }
if (address === '/Snapshots/name') {
// [index, cueNumber*100, ?, name]
return { kind: 'snapshotName', index: toNumber(args[0]), cue: toNumber(args[1]) / 100, name: String(args[args.length - 1] ?? '') }
}
return null
},
}
const sseries = {
id: 'sseries',
label: 'S-Series OSC Control extension (/channel/N/…)',
pongAddress: '/console/pong',
levelMin: -90,
levelMax: 10,
panMin: -1,
panMax: 1,
panCentre: 0,
ping: () => ['/console/ping', []],
resendQueries: () => [['/console/resend', []]],
querySend: (ch, send) => [
[`/channel/${ch}/send/${send}/level`, []],
[`/channel/${ch}/send/${send}/enabled`, []],
],
sendLevel: (ch, send, dB) => [`/channel/${ch}/send/${send}/level`, [Number(dB)]],
sendEnabled: (ch, send, on, style) => [`/channel/${ch}/send/${send}/enabled`, [boolArg(on, style === 'int' ? 'tf' : style)]],
sendPan: (ch, send, pan) => [`/channel/${ch}/send/${send}/pan`, [Number(pan)]],
queryGroup: () => [],
groupAssign: (ch, group, on, style) => [`/channel/${ch}/send/${group}/enabled`, [boolArg(on, style === 'int' ? 'tf' : style)]],
fader: (ch, dB) => [`/channel/${ch}/fader`, [Number(dB)]],
mute: (ch, on, style) => [`/channel/${ch}/mute`, [boolArg(on, style === 'int' ? 'tf' : style)]],
auxFader: (aux, dB) => [`/channel/${69 + aux}/fader`, [Number(dB)]],
auxMute: (aux, on, style) => [`/channel/${69 + aux}/mute`, [boolArg(on, style === 'int' ? 'tf' : style)]],
snapshotFire: (n) => ['/digico/snapshots/fire', [{ i: n }]],
snapshotNext: () => ['/digico/snapshots/fire/next', []],
snapshotPrev: () => ['/digico/snapshots/fire/previous', []],
parse(address, args) {
let m
if (address === '/console/pong') return { kind: 'pong' }
if (address === '/console/channel/counts') return { kind: 'count', field: 'inputs', value: toNumber(args[0]), extra: args.map(toNumber) }
if ((m = /^\/channel\/(\d+)\/send\/(\d+)\/(level|enabled|pan)$/.exec(address))) {
const field = m[3]
const value = field === 'enabled' ? toBool(args[0]) : toNumber(args[0])
return { kind: 'send', ch: +m[1], send: +m[2], field, value }
}
if ((m = /^\/channel\/(\d+)\/name$/.exec(address))) {
const ch = +m[1]
if (ch >= 70 && ch <= 79) return { kind: 'auxName', aux: ch - 69, value: String(args[0] ?? '') }
return { kind: 'name', ch, value: String(args[0] ?? '') }
}
if ((m = /^\/channel\/(\d+)\/fader$/.exec(address))) return { kind: 'fader', ch: +m[1], value: toNumber(args[0]) }
if ((m = /^\/channel\/(\d+)\/mute$/.exec(address))) return { kind: 'mute', ch: +m[1], value: toBool(args[0]) }
return null
},
}
const raw = {
id: 'raw',
label: 'Relay only (no parsing, no keepalive)',
pongAddress: null,
levelMin: -150,
levelMax: 10,
panMin: 0,
panMax: 1,
panCentre: 0.5,
ping: () => null,
resendQueries: () => [],
querySend: () => [],
queryGroup: () => [],
sendLevel: () => null,
sendEnabled: () => null,
sendPan: () => null,
groupAssign: () => null,
fader: () => null,
mute: () => null,
auxFader: () => null,
auxMute: () => null,
snapshotFire: () => null,
snapshotNext: () => null,
snapshotPrev: () => null,
parse: () => null,
}
export const DIALECTS = { sd, sseries, raw }
export function getDialect(id) {
return DIALECTS[id] || sd
}
export const DIALECT_CHOICES = Object.values(DIALECTS).map((d) => ({ id: d.id, label: d.label }))
+605
View File
@@ -0,0 +1,605 @@
import { InstanceBase, InstanceStatus, Regex } from '@companion-module/base'
import { ConsoleLink } from './console-link.js'
import { Relay } from './relay.js'
import { BOOL_STYLES, DIALECT_CHOICES, getDialect } from './dialects.js'
import { decode, describe, foldPattern } from './osc.js'
import {
getActionDefinitions,
getFeedbackDefinitions,
getPresetDefinitions,
getVariableDefinitions,
MAX_INPUTS,
MAX_BUSSES,
BUS_FIRST,
BUS_LAST,
} from './definitions.js'
const KEEPALIVE_MS = 5000
const SILENCE_MS = 20000
const TX_PACE_MS = 20
const LEARN_CAP = 400
function emptyState() {
return {
consoleName: '',
names: new Map(), // input ch -> name
auxNames: new Map(), // buss -> name when in aux mode
groupNames: new Map(), // buss -> name when in group mode
busNames: new Map(), // S-Series OSC channel 70-93 -> name
bussModes: [], // 1 mono / 2 stereo per buss
sends: new Map(), // "ch:buss" -> { level, enabled, pan }
groups: new Map(), // "ch:buss" -> assigned
mutes: new Map(),
faders: new Map(),
bussMutes: new Map(),
bussFaders: new Map(),
snapshot: { index: null, names: new Map() },
counts: {},
}
}
export default class DigicoS31Instance extends InstanceBase {
constructor(internal) {
super(internal)
this.link = null
this.relay = null
this.timer = null
this.txQueue = []
this.txTimer = null
this.seen = new Set()
this.state = emptyState()
this.connectedFlag = false
this.lastMonMode = null
}
async init(config) {
this.config = config
this.dialect = getDialect(config.dialect)
this.initDefinitions()
await this.connect()
}
async destroy() {
await this.disconnect()
}
async configUpdated(config) {
this.config = config
this.dialect = getDialect(config.dialect)
await this.disconnect()
this.initDefinitions()
await this.connect()
}
getConfigFields() {
return [
{
type: 'static-text',
id: 'info',
width: 12,
label: 'How this works',
value:
'Companion becomes the console\'s single remote (iPad) controller: on the console, Extensions → iPad, set the iPad IP to this computer, ' +
'Send Port = the local receive port below, Receive Port = the console receive port below. DiGiCo iPad apps then point at this computer ' +
'instead of the console and are relayed through, so several can be used at once.',
},
{ type: 'textinput', id: 'host', label: 'Console IP', width: 6, default: '10.3.4.221', regex: Regex.IP },
{ type: 'number', id: 'consolePort', label: 'Console receive port (we send here)', width: 3, default: 8000, min: 1, max: 65535 },
{ type: 'number', id: 'localPort', label: 'Local receive port (console sends here)', width: 3, default: 9000, min: 1, max: 65535 },
{ type: 'dropdown', id: 'dialect', label: 'Command set', width: 6, default: 'sd', choices: DIALECT_CHOICES },
{ type: 'dropdown', id: 'boolStyle', label: 'How to write on/off values', width: 6, default: 'int', choices: BOOL_STYLES },
{ type: 'checkbox', id: 'keepalive', label: 'Keepalive ping + reload on reconnect', width: 4, default: true },
{ type: 'number', id: 'channels', label: 'Input channels to load and build presets for', width: 4, default: 60, min: 1, max: MAX_INPUTS },
{ type: 'number', id: 'busses', label: 'Busses to load names for', width: 4, default: MAX_BUSSES, min: 1, max: MAX_BUSSES },
{
type: 'static-text',
id: 'monInfo',
width: 12,
label: 'Studio Mon buss',
value: 'Buss numbers follow the console\'s buss list (1-25). On this console buss 24 is the stereo group "Studio" and buss 17 is the aux "Studio TB". A group buss gives per-channel assign on/off; an aux buss gives send level + on/off.',
},
{ type: 'number', id: 'monSend', label: 'Studio Mon buss number', width: 4, default: 24, min: 1, max: MAX_BUSSES },
{
type: 'dropdown',
id: 'monMode',
label: 'Studio Mon buss type',
width: 4,
default: 'auto',
choices: [
{ id: 'auto', label: 'Auto (from the names the console reports)' },
{ id: 'aux', label: 'Aux (send level + on/off)' },
{ id: 'group', label: 'Group (assign on/off)' },
],
},
{ type: 'textinput', id: 'monLabel', label: 'Name for it in button labels', width: 4, default: 'Studio Mon' },
{
type: 'static-text',
id: 'relayInfo',
width: 12,
label: 'iPad relay',
value: 'In the DiGiCo S app enter this computer\'s IP as the console, Send Port = relay port, Receive Port = iPad receive port.',
},
{ type: 'checkbox', id: 'relayEnable', label: 'Enable iPad relay', width: 3, default: true },
{ type: 'number', id: 'relayPort', label: 'Relay port (iPad apps send here)', width: 3, default: 8000, min: 1, max: 65535 },
{ type: 'number', id: 'relayClientPort', label: 'iPad receive port (0 = reply to source port)', width: 3, default: 9000, min: 0, max: 65535 },
{ type: 'number', id: 'relayTimeout', label: 'Forget an iPad after (seconds idle)', width: 3, default: 180, min: 10, max: 3600 },
{ type: 'checkbox', id: 'learnLog', label: 'Learn mode: log each new address pattern once', width: 4, default: true },
{ type: 'checkbox', id: 'verboseLog', label: 'Log every message (debug level)', width: 4, default: false },
]
}
// ---------- helpers used by definitions ----------
monSend() {
return Math.min(MAX_BUSSES, Math.max(1, Math.trunc(Number(this.config?.monSend) || 1)))
}
monLabel() {
return String(this.config?.monLabel || 'Studio Mon').trim() || 'Studio Mon'
}
monMode() {
const cfg = this.config?.monMode
if (cfg === 'aux' || cfg === 'group') return cfg
const n = this.monSend()
const g = (this.state.groupNames.get(n) || '').trim()
const a = (this.state.auxNames.get(n) || '').trim()
if (g && !a) return 'group'
return 'aux'
}
getSend(ch, send) {
return this.state.sends.get(`${Number(ch)}:${Number(send)}`) || {}
}
getGroup(ch, group) {
return this.state.groups.get(`${Number(ch)}:${Number(group)}`)
}
getMonOn(ch) {
return this.monMode() === 'group' ? this.getGroup(ch, this.monSend()) : this.getSend(ch, this.monSend()).enabled
}
isConnected() {
return this.connectedFlag
}
initDefinitions() {
this.setActionDefinitions(getActionDefinitions(this))
this.setFeedbackDefinitions(getFeedbackDefinitions(this))
this.setVariableDefinitions(getVariableDefinitions(this))
this.refreshPresets(true)
this.setVariableValues({
connected: this.connectedFlag ? 'Yes' : 'No',
console_host: this.config.host || '',
relay_clients: 0,
relay_client_ips: '',
mon_send: this.monSend(),
mon_mode: this.monMode(),
last_rx: '',
last_send_touched: '',
})
this.publishState()
}
refreshPresets(force = false) {
const mode = this.monMode()
if (!force && mode === this.lastMonMode) return
this.lastMonMode = mode
const { structure, presets } = getPresetDefinitions(this)
this.setPresetDefinitions(structure, presets)
this.setVariableValues({ mon_mode: mode })
}
// ---------- lifecycle ----------
async connect() {
const cfg = this.config
if (!cfg.host) {
this.updateStatus(InstanceStatus.BadConfig, 'Console IP missing')
return
}
this.updateStatus(InstanceStatus.Connecting)
this.seen.clear()
this.state = emptyState()
this.link = new ConsoleLink({ host: cfg.host, port: cfg.consolePort, localPort: cfg.localPort })
this.link.on('error', (err) => this.log('error', `Console socket: ${err.message}`))
this.link.on('stray', (buf, rinfo) => this.learn('stray', decode(buf), rinfo))
this.link.on('raw', (buf) => {
if (this.relay) this.relay.fanout(buf)
})
this.link.on('message', (msg, _buf, rinfo) => this.onConsoleMessage(msg, rinfo))
try {
const addr = await this.link.open()
this.log('info', `Console link: listening on udp ${addr.port}, sending to ${cfg.host}:${cfg.consolePort} (${this.dialect.label})`)
} catch (err) {
this.updateStatus(InstanceStatus.ConnectionFailure, `Cannot bind local port ${cfg.localPort}: ${err.code || err.message}`)
this.log('error', `Cannot bind local port ${cfg.localPort}: ${err.message}`)
this.link = null
return
}
if (cfg.relayEnable) {
this.relay = new Relay({ port: cfg.relayPort, clientPort: cfg.relayClientPort, timeoutMs: (Number(cfg.relayTimeout) || 180) * 1000 })
this.relay.on('error', (err) => this.log('error', `Relay socket: ${err.message}`))
this.relay.on('client', (rinfo) => {
this.log('info', `Relay: new iPad client ${rinfo.address}:${rinfo.port}`)
this.publishRelay()
})
this.relay.on('clientGone', (ip) => {
this.log('info', `Relay: iPad client ${ip} idle, forgotten`)
this.publishRelay()
})
this.relay.on('fromClient', (buf, rinfo) => {
if (this.link) this.link.sendRaw(buf)
if (cfg.learnLog || cfg.verboseLog) {
const msg = decode(buf)
this.learn('ipad', msg, rinfo)
if (cfg.verboseLog) this.log('debug', `ipad ${rinfo.address} -> console: ${describe(msg)}`)
}
})
try {
const addr = await this.relay.open()
this.log('info', `Relay: iPad apps may send to udp ${addr.port}; console traffic fans out to port ${cfg.relayClientPort || '(source)'}`)
} catch (err) {
this.log('error', `Relay: cannot bind port ${cfg.relayPort}: ${err.message}`)
this.relay = null
}
}
if (this.dialect.id === 'raw') {
this.setConnected(true, 'relay only')
} else {
this.sendPing()
this.requestResend()
}
this.timer = setInterval(() => this.tick(), KEEPALIVE_MS)
this.publishRelay()
}
async disconnect() {
if (this.timer) clearInterval(this.timer)
this.timer = null
if (this.txTimer) clearInterval(this.txTimer)
this.txTimer = null
this.txQueue = []
if (this.link) this.link.close()
if (this.relay) this.relay.close()
this.link = null
this.relay = null
this.connectedFlag = false
}
tick() {
if (!this.link) return
if (this.dialect.id === 'raw') {
this.publishRelay()
return
}
if (this.config.keepalive) this.sendPing()
const silent = Date.now() - this.link.lastRx
if (this.connectedFlag && silent > SILENCE_MS) this.setConnected(false)
this.publishRelay()
}
setConnected(on, note) {
const was = this.connectedFlag
this.connectedFlag = !!on
if (on) this.updateStatus(InstanceStatus.Ok, note)
else this.updateStatus(InstanceStatus.Disconnected, 'No reply from console (check the iPad/controller IP and ports on the console)')
if (was !== this.connectedFlag) {
this.setVariableValues({ connected: on ? 'Yes' : 'No' })
this.checkFeedbacks('connected')
this.log('info', on ? 'Console link alive' : 'Console link silent')
if (on && was === false && this.config.keepalive && this.link && this.link.rxCount > 1) this.requestResend()
}
}
// ---------- outbound ----------
/** Immediate send of a dialect-built message (user actions). */
sendDialect(fn, ...args) {
if (!this.link) return false
const built = this.dialect[fn]?.(...args)
if (!built) {
this.log('warn', `${fn}: not available in the "${this.dialect.label}" command set`)
return false
}
if (this.config.verboseLog) this.log('debug', `-> ${built[0]} ${JSON.stringify(built[1])}`)
return this.link.send(built[0], built[1])
}
/** Paced send for bulk queries so the console is not flooded. */
enqueue(list) {
for (const item of list) if (item) this.txQueue.push(item)
if (this.txTimer || !this.txQueue.length) return
this.txTimer = setInterval(() => {
const next = this.txQueue.shift()
if (!next || !this.link) {
clearInterval(this.txTimer)
this.txTimer = null
this.txQueue = []
return
}
this.link.send(next[0], next[1])
}, TX_PACE_MS)
}
sendPing() {
const p = this.dialect.ping()
if (p && this.link) this.link.send(p[0], p[1])
}
requestResend() {
if (!this.link) return false
const inputs = Math.min(MAX_INPUTS, Math.max(1, Number(this.config.channels) || MAX_INPUTS))
const auxes = Math.min(MAX_BUSSES, Math.max(1, Number(this.config.busses) || MAX_BUSSES))
const list = this.dialect.resendQueries({ inputs, auxes, monSend: this.monSend() })
if (!list.length) return false
this.log('info', `Loading ${list.length} values from the console`)
this.enqueue(list)
return true
}
resolveState(current, state) {
if (state === 'toggle') return !(current === true)
return state === 'on'
}
setMonOn(ch, state) {
return this.monMode() === 'group' ? this.setGroupAssign(ch, this.monSend(), state) : this.setSendEnabled(ch, this.monSend(), state)
}
setSendEnabled(ch, send, state) {
const cur = this.getSend(ch, send).enabled
const on = this.resolveState(cur, state)
const ok = this.sendDialect('sendEnabled', Number(ch), Number(send), on, this.config.boolStyle)
if (ok) this.enqueue(this.dialect.querySend(Number(ch), Number(send)))
return ok
}
setGroupAssign(ch, group, state) {
const cur = this.getGroup(ch, group)
const on = this.resolveState(cur, state)
const ok = this.sendDialect('groupAssign', Number(ch), Number(group), on, this.config.boolStyle)
if (ok) this.enqueue(this.dialect.queryGroup(Number(ch), Number(group)))
return ok
}
setSendLevel(ch, send, dB) {
const d = this.dialect
const v = Math.min(d.levelMax, Math.max(d.levelMin, Number(dB)))
const ok = this.sendDialect('sendLevel', Number(ch), Number(send), v)
if (ok) this.enqueue(this.dialect.querySend(Number(ch), Number(send)))
return ok
}
nudgeSendLevel(ch, send, delta) {
const cur = this.getSend(ch, send).level
if (typeof cur !== 'number') {
this.log('warn', `Send level for ch ${ch} buss ${send} unknown yet; asking the console`)
this.enqueue(this.dialect.querySend(Number(ch), Number(send)))
return false
}
const floor = this.dialect.levelMin
const base = cur <= floor ? floor : cur
return this.setSendLevel(ch, send, base + Number(delta))
}
setMute(ch, state) {
const cur = this.state.mutes.get(Number(ch))
const on = this.resolveState(cur, state)
return this.sendDialect('mute', Number(ch), on, this.config.boolStyle)
}
setBussMute(buss, state) {
const cur = this.state.bussMutes.get(Number(buss))
const on = this.resolveState(cur, state)
return this.sendDialect('auxMute', Number(buss), on, this.config.boolStyle)
}
sendRawAction(options) {
if (!this.link) return false
const address = String(options.address || '').trim()
if (!address.startsWith('/')) {
this.log('warn', `raw OSC: address must start with "/": ${address}`)
return false
}
const v = String(options.value ?? '')
let args = []
switch (options.argType) {
case 'int':
args = [{ i: Math.trunc(Number(v)) || 0 }]
break
case 'float':
args = [Number(v) || 0]
break
case 'string':
args = [v]
break
case 'bool':
args = [/^(1|true|on|yes)$/i.test(v.trim())]
break
default:
args = []
}
this.log('info', `raw OSC -> ${address} ${JSON.stringify(args)}`)
return this.link.send(address, args)
}
// ---------- inbound ----------
onConsoleMessage(msg, rinfo) {
if (!this.connectedFlag) this.setConnected(true)
if (msg.bundle) {
for (const el of msg.elements) this.onConsoleMessage(el, rinfo)
return
}
this.learn('console', msg, rinfo)
if (this.config.verboseLog) this.log('debug', `console -> ${describe(msg)}`)
if (!msg.address) return
this.setVariableValues({ last_rx: describe(msg) })
const ev = this.dialect.parse(msg.address, msg.args)
if (ev) this.applyEvent(ev)
}
applyEvent(ev) {
const s = this.state
const values = {}
const mon = this.monSend()
switch (ev.kind) {
case 'pong':
if (ev.name && ev.name !== s.consoleName) {
s.consoleName = ev.name
values.console_name = ev.name
}
break
case 'count':
s.counts[ev.field] = ev.value
if (ev.field === 'inputs') values.input_count = ev.value
if (ev.field === 'auxes') values.aux_count = ev.value
break
case 'auxModes':
s.bussModes = ev.values
ev.values.forEach((m, i) => {
if (i < MAX_BUSSES) values[`buss_${i + 1}_mode`] = m === 2 ? 'Stereo' : 'Mono'
})
break
case 'name':
s.names.set(ev.ch, ev.value)
if (ev.ch >= 1 && ev.ch <= MAX_INPUTS) values[`ch_${ev.ch}_name`] = ev.value
else if (ev.ch >= BUS_FIRST && ev.ch <= BUS_LAST) {
s.busNames.set(ev.ch, ev.value)
values[`bus_${ev.ch}_name`] = ev.value
}
break
case 'auxName':
s.auxNames.set(ev.aux, ev.value)
if (ev.aux >= 1 && ev.aux <= MAX_BUSSES) values[`aux_${ev.aux}_name`] = ev.value
if (ev.aux === mon) this.afterMonNames(values)
break
case 'groupName':
s.groupNames.set(ev.group, ev.value)
if (ev.group >= 1 && ev.group <= MAX_BUSSES) values[`group_${ev.group}_name`] = ev.value
if (ev.group === mon) this.afterMonNames(values)
break
case 'send': {
const key = `${ev.ch}:${ev.send}`
const cur = s.sends.get(key) || {}
cur[ev.field] = ev.value
s.sends.set(key, cur)
values.last_send_touched = `ch ${ev.ch} buss ${ev.send} ${ev.field}`
if (ev.send === mon && ev.ch >= 1 && ev.ch <= MAX_INPUTS) {
if (ev.field === 'level') values[`mon_${ev.ch}_level`] = this.formatLevel(ev.value)
if (ev.field === 'enabled' && this.monMode() === 'aux') values[`mon_${ev.ch}_on`] = ev.value ? 'On' : 'Off'
}
this.checkFeedbacks('mon_on', 'send_on', 'mon_level_above')
break
}
case 'group': {
s.groups.set(`${ev.ch}:${ev.group}`, ev.value)
values.last_send_touched = `ch ${ev.ch} group ${ev.group} assign`
if (ev.group === mon && this.monMode() === 'group' && ev.ch >= 1 && ev.ch <= MAX_INPUTS) values[`mon_${ev.ch}_on`] = ev.value ? 'On' : 'Off'
this.checkFeedbacks('mon_on', 'group_assigned')
break
}
case 'fader':
s.faders.set(ev.ch, ev.value)
break
case 'mute':
s.mutes.set(ev.ch, ev.value)
this.checkFeedbacks('muted')
break
case 'auxFader':
s.bussFaders.set(ev.aux, ev.value)
break
case 'auxMute':
s.bussMutes.set(ev.aux, ev.value)
this.checkFeedbacks('buss_muted')
break
case 'snapshot':
s.snapshot.index = ev.index
values.snapshot_current = ev.index
values.snapshot_name = s.snapshot.names.get(ev.index) ?? ''
if (!s.snapshot.names.has(ev.index) && this.dialect.id === 'sd') this.enqueue([['/Snapshots/names/?', []]])
break
case 'snapshotName':
s.snapshot.names.set(ev.index, ev.name)
if (ev.index === s.snapshot.index) values.snapshot_name = ev.name
break
default:
break
}
if (Object.keys(values).length) this.setVariableValues(values)
}
/** Called when the Studio Mon buss name arrives (aux or group form). */
afterMonNames(values) {
const n = this.monSend()
const mode = this.monMode()
values.mon_name = mode === 'group' ? this.state.groupNames.get(n) || '' : this.state.auxNames.get(n) || ''
values.mon_mode = mode
if (mode !== this.lastMonMode) {
this.log('info', `Studio Mon buss ${n} is "${values.mon_name}" (${mode === 'group' ? 'group: assign on/off' : 'aux: send level + on/off'})`)
this.refreshPresets()
this.checkFeedbacks('mon_on')
}
}
formatLevel(v) {
if (!Number.isFinite(v)) return ''
if (v <= this.dialect.levelMin) return '-inf'
return Number(v.toFixed(1))
}
learn(source, msg, rinfo) {
if (!this.config.learnLog) return
if (!msg || !msg.address) {
const key = `${source}:${msg?.bundle ? '#bundle' : 'raw'}`
if (this.seen.has(key) || this.seen.size > LEARN_CAP) return
this.seen.add(key)
this.log('info', `learn ${source}: ${describe(msg)} from ${rinfo?.address}:${rinfo?.port}`)
return
}
const key = `${source}:${foldPattern(msg.address)} ${msg.tags || ''}${msg.unpadded ? ' (unpadded)' : ''}`
if (this.seen.has(key) || this.seen.size > LEARN_CAP) return
this.seen.add(key)
this.log('info', `learn ${source}: ${key} e.g. ${describe(msg)}`)
}
publishRelay() {
const list = this.relay ? this.relay.clientList() : []
const now = Date.now()
const live = list.filter((c) => now - c.lastSeen <= (this.relay?.timeoutMs || 0))
this.setVariableValues({ relay_clients: live.length, relay_client_ips: live.map((c) => c.ip).join(', ') })
this.checkFeedbacks('relay_clients')
}
/** Re-publish everything known (after definitions were rebuilt). */
publishState() {
const s = this.state
const values = {}
for (const [ch, name] of s.names) if (ch >= 1 && ch <= MAX_INPUTS) values[`ch_${ch}_name`] = name
for (const [ch, name] of s.busNames) values[`bus_${ch}_name`] = name
for (const [b, name] of s.auxNames) if (b >= 1 && b <= MAX_BUSSES) values[`aux_${b}_name`] = name
for (const [b, name] of s.groupNames) if (b >= 1 && b <= MAX_BUSSES) values[`group_${b}_name`] = name
s.bussModes.forEach((m, i) => {
if (i < MAX_BUSSES) values[`buss_${i + 1}_mode`] = m === 2 ? 'Stereo' : 'Mono'
})
const mon = this.monSend()
const mode = this.monMode()
for (const [key, v] of s.sends) {
const [ch, send] = key.split(':').map(Number)
if (send !== mon || ch < 1 || ch > MAX_INPUTS) continue
if (typeof v.level === 'number') values[`mon_${ch}_level`] = this.formatLevel(v.level)
if (typeof v.enabled === 'boolean' && mode === 'aux') values[`mon_${ch}_on`] = v.enabled ? 'On' : 'Off'
}
if (mode === 'group') {
for (const [key, on] of s.groups) {
const [ch, g] = key.split(':').map(Number)
if (g === mon && ch >= 1 && ch <= MAX_INPUTS) values[`mon_${ch}_on`] = on ? 'On' : 'Off'
}
}
values.console_name = s.consoleName
values.mon_name = mode === 'group' ? s.groupNames.get(mon) || '' : s.auxNames.get(mon) || ''
values.mon_mode = mode
if (s.snapshot.index !== null) {
values.snapshot_current = s.snapshot.index
values.snapshot_name = s.snapshot.names.get(s.snapshot.index) ?? ''
}
this.setVariableValues(values)
}
}
export const UpgradeScripts = []
+182
View File
@@ -0,0 +1,182 @@
/**
* Minimal, tolerant OSC 1.0 codec.
*
* The DiGiCo iPad app is known to emit datagrams that are not padded to a
* 4-byte multiple, so the decoder never throws: anything it cannot read comes
* back as `{ raw }` and the relay still forwards the original bytes verbatim.
*
* Argument encoding rules (encode):
* boolean -> T / F
* null -> N
* number -> f (float32) use { i: n } for an int32
* string -> s
* { i: n } -> i { f: n } -> f { s: x } -> s { b: Buffer } -> blob
*/
function pad4(len) {
return (4 - (len % 4)) % 4
}
function encodeString(value) {
const bytes = Buffer.from(String(value), 'utf8')
const out = Buffer.alloc(bytes.length + 1 + pad4(bytes.length + 1))
bytes.copy(out)
return out
}
function encodeBlob(buf) {
const out = Buffer.alloc(4 + buf.length + pad4(buf.length))
out.writeInt32BE(buf.length, 0)
buf.copy(out, 4)
return out
}
function int32(n) {
const b = Buffer.alloc(4)
b.writeInt32BE(Math.trunc(n), 0)
return b
}
function float32(n) {
const b = Buffer.alloc(4)
b.writeFloatBE(Number(n), 0)
return b
}
export function encode(address, args = []) {
let tags = ','
const parts = []
for (const arg of args) {
if (arg === true) tags += 'T'
else if (arg === false) tags += 'F'
else if (arg === null || arg === undefined) tags += 'N'
else if (typeof arg === 'number') {
tags += 'f'
parts.push(float32(arg))
} else if (typeof arg === 'string') {
tags += 's'
parts.push(encodeString(arg))
} else if (Buffer.isBuffer(arg)) {
tags += 'b'
parts.push(encodeBlob(arg))
} else if (typeof arg === 'object') {
if ('i' in arg) {
tags += 'i'
parts.push(int32(arg.i))
} else if ('f' in arg) {
tags += 'f'
parts.push(float32(arg.f))
} else if ('s' in arg) {
tags += 's'
parts.push(encodeString(arg.s))
} else if ('b' in arg) {
tags += 'b'
parts.push(encodeBlob(arg.b))
} else throw new Error('Unsupported OSC argument object')
} else throw new Error(`Unsupported OSC argument type ${typeof arg}`)
}
return Buffer.concat([encodeString(address), encodeString(tags), ...parts])
}
function readString(buf, offset) {
let end = buf.indexOf(0, offset)
if (end < 0) end = buf.length
const value = buf.toString('utf8', offset, end)
let next = end + 1
next += pad4(next)
return [value, next]
}
/**
* Decode one datagram. Returns
* { address, args, tags, unpadded } for a message
* { bundle: true, elements: [...] } for a bundle
* { raw: '<hex>' } for anything unreadable
*/
export function decode(buf) {
try {
if (!Buffer.isBuffer(buf) || buf.length === 0) return { raw: '' }
if (buf.length >= 8 && buf.toString('ascii', 0, 7) === '#bundle') {
const elements = []
let off = 16
while (off + 4 <= buf.length) {
const size = buf.readInt32BE(off)
off += 4
if (size <= 0 || off + size > buf.length) break
elements.push(decode(buf.subarray(off, off + size)))
off += size
}
return { bundle: true, elements }
}
if (buf[0] !== 0x2f) return { raw: buf.toString('hex') }
let [address, off] = readString(buf, 0)
let tags = ''
if (off < buf.length && buf[off] === 0x2c) [tags, off] = readString(buf, off)
const args = []
for (const t of tags.slice(1)) {
if (t === 'i') {
if (off + 4 > buf.length) break
args.push(buf.readInt32BE(off))
off += 4
} else if (t === 'f') {
if (off + 4 > buf.length) break
args.push(buf.readFloatBE(off))
off += 4
} else if (t === 'd') {
if (off + 8 > buf.length) break
args.push(buf.readDoubleBE(off))
off += 8
} else if (t === 'h') {
if (off + 8 > buf.length) break
args.push(Number(buf.readBigInt64BE(off)))
off += 8
} else if (t === 's' || t === 'S') {
let s
;[s, off] = readString(buf, off)
args.push(s)
} else if (t === 'b') {
if (off + 4 > buf.length) break
const size = buf.readInt32BE(off)
off += 4
args.push(buf.subarray(off, Math.min(buf.length, off + size)))
off += size + pad4(size)
} else if (t === 'T') args.push(true)
else if (t === 'F') args.push(false)
else if (t === 'N') args.push(null)
else if (t === 'I') args.push(Infinity)
else break
}
return { address, args, tags, unpadded: buf.length % 4 !== 0 }
} catch (e) {
return { raw: buf.toString('hex'), error: e.message }
}
}
/** "/channel/12/send/3/level" -> "/channel/N/send/N/level" */
export function foldPattern(address) {
return String(address).replace(/\/-?\d+(?:\.\d+)?(?=\/|$)/g, '/N')
}
/** Coerce an OSC argument to boolean (T/F, 0/1, 0.0/1.0, "true"). */
export function toBool(value) {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value > 0.5
if (typeof value === 'string') return /^(1|true|on|yes)$/i.test(value.trim())
return false
}
export function toNumber(value) {
if (typeof value === 'number') return value
if (typeof value === 'boolean') return value ? 1 : 0
const n = Number.parseFloat(value)
return Number.isFinite(n) ? n : NaN
}
/** Short human form of a decoded message for logs. */
export function describe(msg) {
if (!msg) return '(empty)'
if (msg.raw !== undefined) return `raw ${msg.raw.slice(0, 48)}${msg.raw.length > 48 ? '…' : ''}`
if (msg.bundle) return `#bundle(${msg.elements.length})`
const args = msg.args.map((a) => (typeof a === 'number' ? Number(a.toFixed(3)) : Buffer.isBuffer(a) ? `<blob ${a.length}>` : a))
return `${msg.address} ${JSON.stringify(args)}`
}
+110
View File
@@ -0,0 +1,110 @@
import dgram from 'node:dgram'
import { EventEmitter } from 'node:events'
/**
* iPad-app relay.
*
* The console accepts exactly one iPad / OSC controller. This module occupies
* that slot, and any number of DiGiCo iPad apps point at this machine instead
* of the console. Everything an app sends is forwarded to the console byte for
* byte (via ConsoleLink.sendRaw), and everything the console sends back is
* fanned out to every app seen recently. Nothing is rewritten, so the relay
* does not care which dialect the iPad extension speaks.
*
* Events: 'fromClient' (buf, rinfo), 'client' (rinfo) on first sight,
* 'clientGone' (ip), 'error' (err), 'listening' (addressInfo).
*/
export class Relay extends EventEmitter {
constructor({ port, clientPort = 0, timeoutMs = 180000, bindAddress = '0.0.0.0' }) {
super()
this.port = Number(port)
this.clientPort = Number(clientPort) || 0
this.timeoutMs = Number(timeoutMs) || 180000
this.bindAddress = bindAddress
this.socket = null
this.clients = new Map() // ip -> { port, lastSeen, rx, tx }
this.rxCount = 0
this.txCount = 0
}
open() {
return new Promise((resolve, reject) => {
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: false })
let settled = false
socket.on('error', (err) => {
if (!settled) {
settled = true
reject(err)
} else this.emit('error', err)
})
socket.on('message', (buf, rinfo) => {
this.rxCount++
let client = this.clients.get(rinfo.address)
if (!client) {
client = { port: rinfo.port, lastSeen: 0, rx: 0, tx: 0 }
this.clients.set(rinfo.address, client)
this.emit('client', rinfo)
}
client.port = rinfo.port
client.lastSeen = Date.now()
client.rx++
this.emit('fromClient', buf, rinfo)
})
socket.bind(this.port, this.bindAddress, () => {
settled = true
this.socket = socket
this.emit('listening', socket.address())
resolve(socket.address())
})
})
}
/** Send one datagram from the console to every live client. */
fanout(buf) {
if (!this.socket) return 0
const now = Date.now()
let n = 0
for (const [ip, client] of this.clients) {
if (now - client.lastSeen > this.timeoutMs) {
this.clients.delete(ip)
this.emit('clientGone', ip)
continue
}
const port = this.clientPort || client.port
this.socket.send(buf, port, ip, (err) => {
if (err) this.emit('error', err)
})
client.tx++
this.txCount++
n++
}
return n
}
get clientCount() {
const now = Date.now()
let n = 0
for (const c of this.clients.values()) if (now - c.lastSeen <= this.timeoutMs) n++
return n
}
clientList() {
return [...this.clients].map(([ip, c]) => ({ ip, ...c }))
}
forgetClients() {
this.clients.clear()
}
close() {
const s = this.socket
this.socket = null
if (s) {
try {
s.close()
} catch (_e) {
/* already closed */
}
}
}
}