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