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:
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user