Files
companion-module-digico-s31/src/main.js
T

606 lines
21 KiB
JavaScript
Raw Normal View History

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 = []