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:
Executable
+278
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* fake-ipad.js - stand-in for the DiGiCo iPad app, for testing the Companion
|
||||
* module (or an OSC relay) against the mock console or a real S31.
|
||||
*
|
||||
* Two OSC dialects (--dialect):
|
||||
*
|
||||
* sseries (default) sends /console/ping then /console/resend on start, then
|
||||
* /console/ping every 5 s; /console/pong counts as a pong.
|
||||
* sd the "DiGiCo Pad" dialect the real S31 speaks. On start it
|
||||
* queries /Console/Name/?, /Console/Input_Channels/?,
|
||||
* /Console/Aux_Outputs/?, /Console/Aux_Outputs/modes/?,
|
||||
* the names of inputs 1-8 and aux outputs 1-4, and
|
||||
* /Snapshots/names/?; then /Console/Name/? every 5 s as a
|
||||
* keepalive. A /Console/Name reply counts as a pong.
|
||||
*
|
||||
* In both dialects it binds --receive-port, prints everything it receives and a
|
||||
* packet-count summary every 10 s, and reads lines from stdin of the form
|
||||
* `ADDRESS [arg ...]` and sends them, e.g.
|
||||
* /channel/3/send/2/level -12.5 (sseries)
|
||||
* /Input_Channels/3/Aux_Send/2/send_on 1 (sd set)
|
||||
* /Input_Channels/3/Aux_Send/2/send_level/? (sd query)
|
||||
* /channel/3/name "Lead Vox"
|
||||
* true/false -> boolean, whole numbers -> int, decimals -> float, anything else
|
||||
* (or anything in double quotes) -> string. Type "quit" to exit.
|
||||
*
|
||||
* Usage:
|
||||
* node tools/fake-ipad.js --console IP --send-port 8000 --receive-port 9000
|
||||
* [--dialect sseries|sd] [--name A] [--types] [--quiet]
|
||||
*/
|
||||
|
||||
const dgram = require('node:dgram');
|
||||
const readline = require('node:readline');
|
||||
const { parseArgs } = require('node:util');
|
||||
const { encode, decode, formatArgs, typeTags, int, float } = require('./osc-lite');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command line
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const USAGE = `Usage: node tools/fake-ipad.js --console IP [options]
|
||||
|
||||
--console IP host to send requests to (console, relay or mock) (required)
|
||||
--send-port PORT UDP port on that host [8000]
|
||||
--receive-port PORT local UDP port to bind for feedback [9000]
|
||||
--dialect NAME sseries (default): lowercase S-Series OSC list
|
||||
sd: the dialect the real S31 iPad extension speaks
|
||||
--name NAME label used in the output lines [A]
|
||||
--types also print each message's OSC type tags, e.g. ",f"
|
||||
--quiet only print the periodic summary, not every packet
|
||||
-h, --help show this help
|
||||
|
||||
Type "ADDRESS [arg ...]" lines on stdin to send your own messages.`;
|
||||
|
||||
function die(message) {
|
||||
console.error(`fake-ipad: ${message}\n\n${USAGE}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function parseCli() {
|
||||
let values;
|
||||
try {
|
||||
({ values } = parseArgs({
|
||||
options: {
|
||||
console: { type: 'string' },
|
||||
'send-port': { type: 'string', default: '8000' },
|
||||
'receive-port': { type: 'string', default: '9000' },
|
||||
dialect: { type: 'string', default: 'sseries' },
|
||||
name: { type: 'string', default: 'A' },
|
||||
types: { type: 'boolean', default: false },
|
||||
quiet: { type: 'boolean', default: false },
|
||||
help: { type: 'boolean', short: 'h', default: false },
|
||||
},
|
||||
allowPositionals: false,
|
||||
strict: true,
|
||||
}));
|
||||
} catch (err) {
|
||||
die(err.message);
|
||||
}
|
||||
if (values.help) {
|
||||
console.log(USAGE);
|
||||
process.exit(0);
|
||||
}
|
||||
if (!values.console) die('--console IP is required');
|
||||
if (values.dialect !== 'sseries' && values.dialect !== 'sd') die(`--dialect must be "sseries" or "sd" (got "${values.dialect}")`);
|
||||
const port = (name) => {
|
||||
const text = values[name];
|
||||
if (!/^\d+$/.test(text) || Number(text) < 1 || Number(text) > 65535) die(`--${name} must be a port number 1-65535 (got "${text}")`);
|
||||
return Number(text);
|
||||
};
|
||||
return {
|
||||
host: values.console,
|
||||
sendPort: port('send-port'),
|
||||
receivePort: port('receive-port'),
|
||||
dialect: values.dialect,
|
||||
name: values.name,
|
||||
types: values.types,
|
||||
quiet: values.quiet,
|
||||
};
|
||||
}
|
||||
|
||||
const opts = parseCli();
|
||||
const isSd = opts.dialect === 'sd';
|
||||
const tag = `[${opts.name}]`;
|
||||
|
||||
/** What a "pong" is in this dialect, and the message that provokes one. */
|
||||
const PONG_ADDRESS = isSd ? '/Console/Name' : '/console/pong';
|
||||
const KEEPALIVE_ADDRESS = isSd ? '/Console/Name/?' : '/console/ping';
|
||||
const STARTUP_GAP_MS = isSd ? 20 : 0; // be gentle with a real desk: pace the SD burst
|
||||
|
||||
/** The opening requests, in order. */
|
||||
function startupAddresses() {
|
||||
if (!isSd) return ['/console/ping', '/console/resend'];
|
||||
const list = ['/Console/Name/?', '/Console/Input_Channels/?', '/Console/Aux_Outputs/?', '/Console/Aux_Outputs/modes/?'];
|
||||
for (let n = 1; n <= 8; n++) list.push(`/Input_Channels/${n}/Channel_Input/name/?`);
|
||||
for (let n = 1; n <= 4; n++) list.push(`/Aux_Outputs/${n}/Buss_Trim/name/?`);
|
||||
list.push('/Snapshots/names/?');
|
||||
return list;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transport and statistics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const socket = dgram.createSocket('udp4');
|
||||
|
||||
const stats = {
|
||||
received: 0, // total packets
|
||||
sent: 0,
|
||||
pongs: 0,
|
||||
undecodable: 0,
|
||||
unpadded: 0,
|
||||
windowReceived: 0, // packets since the last summary
|
||||
};
|
||||
const seenAddresses = new Set();
|
||||
let pingTimer = null;
|
||||
let summaryTimer = null;
|
||||
|
||||
/** "ADDRESS [args]" or, with --types, "ADDRESS ,tags [args]". */
|
||||
function describe(address, args, types) {
|
||||
const body = formatArgs(args, types);
|
||||
return opts.types ? `${address} ,${types} ${body}` : `${address} ${body}`;
|
||||
}
|
||||
|
||||
/** Encode and send one message to the console. */
|
||||
function sendMessage(address, args) {
|
||||
let buf;
|
||||
try {
|
||||
buf = encode(address, args);
|
||||
} catch (err) {
|
||||
console.log(`${tag} ! cannot encode ${address}: ${err.message}`);
|
||||
return;
|
||||
}
|
||||
socket.send(buf, opts.sendPort, opts.host, (err) => {
|
||||
if (err) console.log(`${tag} ! send error: ${err.message}`);
|
||||
});
|
||||
stats.sent++;
|
||||
if (!opts.quiet) console.log(`${tag} -> ${describe(address, args, typeTags(args))}`);
|
||||
}
|
||||
|
||||
function printSummary(label) {
|
||||
console.log(
|
||||
`${tag} ${label}: received ${stats.received} packets (+${stats.windowReceived} in the last interval), ` +
|
||||
`${seenAddresses.size} distinct addresses, ${stats.pongs} pong(s), sent ${stats.sent}` +
|
||||
(stats.undecodable ? `, ${stats.undecodable} undecodable` : '') +
|
||||
(stats.unpadded ? `, ${stats.unpadded} unpadded` : ''),
|
||||
);
|
||||
stats.windowReceived = 0;
|
||||
}
|
||||
|
||||
socket.on('message', (msg) => {
|
||||
stats.received++;
|
||||
stats.windowReceived++;
|
||||
const packet = decode(msg);
|
||||
if (packet.raw !== undefined) {
|
||||
stats.undecodable++;
|
||||
if (!opts.quiet) console.log(`${tag} <- (undecodable, ${msg.length} bytes) ${packet.raw.slice(0, 96)}`);
|
||||
return;
|
||||
}
|
||||
seenAddresses.add(packet.address);
|
||||
if (packet.unpadded) stats.unpadded++;
|
||||
if (packet.address === PONG_ADDRESS) stats.pongs++;
|
||||
if (!opts.quiet) console.log(`${tag} <- ${describe(packet.address, packet.args, packet.types)}`);
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
console.error(`${tag} socket error: ${err.code === 'EADDRINUSE' ? `UDP port ${opts.receivePort} is already in use` : err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
socket.on('listening', () => {
|
||||
const addr = socket.address();
|
||||
console.log(`${tag} listening on udp://${addr.address}:${addr.port}; talking to ${opts.host}:${opts.sendPort} (dialect ${opts.dialect})`);
|
||||
|
||||
// Same opening sequence as the iPad app, then a keepalive.
|
||||
startupAddresses().forEach((address, i) => {
|
||||
if (STARTUP_GAP_MS === 0) sendMessage(address, []);
|
||||
else setTimeout(() => sendMessage(address, []), i * STARTUP_GAP_MS);
|
||||
});
|
||||
|
||||
pingTimer = setInterval(() => sendMessage(KEEPALIVE_ADDRESS, []), 5000);
|
||||
summaryTimer = setInterval(() => printSummary('summary'), 10000);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Interactive stdin: "ADDRESS [arg ...]"
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Split a line into tokens; "double quoted" tokens are always strings. */
|
||||
function tokenize(line) {
|
||||
const tokens = [];
|
||||
const re = /"((?:[^"\\]|\\.)*)"|(\S+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(line)) !== null) {
|
||||
if (m[1] !== undefined) tokens.push({ text: m[1].replace(/\\(.)/g, '$1'), quoted: true });
|
||||
else tokens.push({ text: m[2], quoted: false });
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** true/false -> boolean, integers -> int, decimals -> float, else string. */
|
||||
function parseValue(token) {
|
||||
if (token.quoted) return token.text;
|
||||
const text = token.text;
|
||||
if (text === 'true') return true;
|
||||
if (text === 'false') return false;
|
||||
if (/^[+-]?\d+$/.test(text)) {
|
||||
const n = Number(text);
|
||||
return n >= -2147483648 && n <= 2147483647 ? int(n) : float(n);
|
||||
}
|
||||
if (/^[+-]?(\d+\.\d*|\.\d+|\d+(?=[eE]))([eE][+-]?\d+)?$/.test(text)) return float(Number(text));
|
||||
return text;
|
||||
}
|
||||
|
||||
function handleLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return;
|
||||
if (trimmed === 'quit' || trimmed === 'exit') {
|
||||
shutdown('quit');
|
||||
return;
|
||||
}
|
||||
const tokens = tokenize(trimmed);
|
||||
const address = tokens.shift().text;
|
||||
if (address[0] !== '/') {
|
||||
console.log(`${tag} ? expected "ADDRESS [arg ...]" starting with '/', e.g. ${isSd ? '/Input_Channels/3/Aux_Send/2/send_level -12.5' : '/channel/3/send/2/level -12.5'}`);
|
||||
return;
|
||||
}
|
||||
sendMessage(address, tokens.map(parseValue));
|
||||
}
|
||||
|
||||
// terminal:false keeps Ctrl-C as a normal SIGINT. If stdin is closed or not a
|
||||
// terminal (running in the background, piped input) we simply keep running.
|
||||
const rl = readline.createInterface({ input: process.stdin, terminal: false });
|
||||
rl.on('line', handleLine);
|
||||
rl.on('close', () => {});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function shutdown(reason) {
|
||||
clearInterval(pingTimer);
|
||||
clearInterval(summaryTimer);
|
||||
printSummary(`final (${reason})`);
|
||||
try {
|
||||
rl.close();
|
||||
socket.close();
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
|
||||
socket.bind(opts.receivePort);
|
||||
Reference in New Issue
Block a user