Files

279 lines
10 KiB
JavaScript
Raw Permalink Normal View History

#!/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);