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:
2026-10-15 15:55:18 -04:00
co-authored by Claude Fable 5.1
commit 131cdfd81f
23 changed files with 10066 additions and 0 deletions
+938
View File
@@ -0,0 +1,938 @@
#!/usr/bin/env node
'use strict';
/**
* probe-catalog.js - build a catalog of what a DiGiCo S31 answers over its iPad
* OSC extension, using READ-ONLY queries only.
*
* SAFETY (live console):
* - Every message sent is an OSC address ending in "/?" with NO arguments.
* assertSafeQuery() refuses anything else, and the encoded packet is decoded
* again and checked to carry zero arguments before it is sent.
* - Lowercase S-Series addresses (/console/...) are refused.
* - One socket, sends at least 60 ms apart (INTERVAL_MS = 65), no retries.
* - Write-only action nodes from the 2014 list (Save session, Fire snapshot,
* Macro press) are NOT queried unless --include-actions is passed.
* - Every HEARTBEAT_EVERY queries a /Console/Name/? heartbeat is sent; three
* missed heartbeats in a row stop the run (results so far are still written).
*
* Network path: queries go to the Companion relay UDP 10.3.11.152:8000, which
* forwards to the console; replies are fanned out to this machine on UDP 9000.
*
* Usage:
* node tools/probe-catalog.js [--dry-run] [--include-actions] [--test-local]
* [--raw-log FILE] [--tail-seconds 15]
*
* --test-local sends to a mock-s31.js on 127.0.0.1:8000 instead (tool testing only).
*
* Outputs (relative to this file): catalog/s31-catalog.json, catalog/s31-catalog.md
*/
const dgram = require('node:dgram');
const fs = require('node:fs');
const path = require('node:path');
const { encode, decode } = require('./osc-lite');
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const INTERVAL_MS = 65; // spec: >= 60 ms between sends
const MIN_GAP_MS = 60;
const WINDOW_MS = 400; // reply must arrive within this long after its query
const CONNECT_TIMEOUT_MS = 2000;
const HEARTBEAT_EVERY = 120;
const HEARTBEAT_MAX_MISSES = 3;
const MAX_STORED_REPLIES = 300;
const HERE = __dirname;
const LIST_FILE = path.join(HERE, 'sd-osc-list-2014.txt');
const OUT_DIR = path.join(HERE, 'catalog');
const argv = process.argv.slice(2);
const flag = (name) => argv.includes(name);
const opt = (name, dflt) => {
const i = argv.indexOf(name);
return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : dflt;
};
// --test-local points the probe at a mock-s31.js on 127.0.0.1 (for testing the tool only).
const TEST_LOCAL = flag('--test-local');
const RELAY_HOST = TEST_LOCAL ? '127.0.0.1' : '10.3.11.152';
const RELAY_PORT = 8000;
const LISTEN_PORT = 9000;
const DRY_RUN = flag('--dry-run');
const INCLUDE_ACTIONS = flag('--include-actions');
const RAW_LOG = opt('--raw-log', null);
const TAIL_SECONDS = Number(opt('--tail-seconds', '15'));
/** Max index per entity on this console, and which instances to probe. */
const ENTITIES = {
Input_Channels: { max: 60, probe: [1, 2] },
Aux_Outputs: { max: 25, probe: [1, 2] },
Group_Outputs: { max: 25, probe: [1, 2] },
Matrix_Outputs: { max: 8, probe: [1, 2] },
Matrix_Inputs: { max: 10, probe: [1, 2] },
Control_Groups: { max: 10, probe: [1, 2] },
Graphic_EQ: { max: 16, probe: [1, 2] },
Multis: { max: 0, probe: [1] },
};
/** Sub-index probes for the second wildcard after these segments. */
const SUB_PROBE = { Aux_Send: [1, 24], Group_Send: [1, 24], Matrix_Send: [1] };
/** Write-only trigger nodes in the 2014 list: skipped unless --include-actions. */
const ACTION_NODES = new Set([
'/Filing/Save_current_Session',
'/Snapshots/Fire_Snapshot_number',
'/Snapshots/Fire_Prev_Snapshot',
'/Snapshots/Fire_Next_Snapshot',
'/Macros/Buttons/press',
]);
/** Candidate name addresses per entity, in preference order ('#' = index). */
const NAME_CANDIDATES = {
Input_Channels: ['/Input_Channels/#/Channel_Input/name'],
Aux_Outputs: ['/Aux_Outputs/#/Buss_Trim/name'],
Group_Outputs: ['/Group_Outputs/#/Buss_Trim/name'],
Matrix_Outputs: ['/Matrix_Outputs/#/Buss_Trim/name', '/Matrix_Outputs/#/name'],
Matrix_Inputs: ['/Matrix_Inputs/#/Buss_Trim/name', '/Matrix_Inputs/#/name'],
Control_Groups: ['/Control_Groups/#/name', '/Control_Groups/#/Buss_Trim/name'],
Graphic_EQ: ['/Graphic_EQ/#/name', '/Graphic_EQ/#/Buss_Trim/name'],
};
/** Extra candidates not in the 2014 list (base addresses; "/?" is appended). */
const EXTRA_ADDRESSES = [
'/Console/Name', '/Console/Channels', '/Console/Input_Channels', '/Console/Aux_Outputs',
'/Console/Group_Outputs', '/Console/Matrix_Outputs', '/Console/Matrix_Inputs',
'/Console/Control_Groups', '/Console/Graphic_EQ', '/Console/Multis', '/Console/Talkback_Outputs',
'/Console/Aux_Outputs/modes', '/Console/Group_Outputs/modes', '/Console/Matrix_Outputs/modes',
'/Console/Version', '/Console/Session', '/Console/Session/name', '/Console/Sample_Rate',
'/Console/Meters', '/Console/Meter', '/Console/Metering', '/Console/Solo', '/Console/Solo_Busses',
'/Console/Talkback', '/Console/Oscillator', '/Console/FX',
'/Snapshots/Current_Snapshot', '/Snapshots/names', '/Snapshots/name', '/Snapshots/count',
'/Snapshots/Total',
'/Macros/Buttons', '/Macros/names', '/Macros/count', '/Macros/1/name', '/Macros/Buttons/1/name',
'/Macros/Buttons/state',
'/Talkback_Outputs/1/name', '/Solo/1/level', '/Solo_Busses/1/fader',
'/Input_Channels/1/meter', '/Input_Channels/1/Meter', '/Input_Channels/1/meters',
'/Input_Channels/1/Channel_Input/meter', '/Input_Channels/1/Channel_Input/input_meter',
'/Input_Channels/1/Channel_Output/meter', '/Input_Channels/1/Channel_Input/signal',
'/Input_Channels/1/Channel_Input/source', '/Input_Channels/1/Channel_Input/route',
'/Input_Channels/1/Channel_Input/input_route', '/Input_Channels/1/Channel_Input/socket',
'/Input_Channels/1/Channel_Input/mode', '/Input_Channels/1/Channel_Input/stereo',
'/Input_Channels/1/stereo', '/Input_Channels/1/colour', '/Input_Channels/1/color',
'/Input_Channels/1/Channel_Input/colour', '/Input_Channels/1/Aux_Send/24/send_pre-post',
'/Input_Channels/1/Group_Send/24/group', '/Input_Channels/1/Direct_Output/send_level',
'/Input_Channels/1/Direct_Output/send_on', '/Input_Channels/1/Channel_Output/direct_out',
'/Aux_Outputs/1/Buss_Trim/name', '/Aux_Outputs/1/Buss_Trim/colour', '/Aux_Outputs/1/mode',
'/Aux_Outputs/1/stereo', '/Aux_Outputs/1/meter',
'/Group_Outputs/1/Buss_Trim/name', '/Matrix_Outputs/1/Buss_Trim/name', '/Matrix_Outputs/1/name',
'/Matrix_Inputs/1/name', '/Matrix_Inputs/1/Buss_Trim/name',
'/Control_Groups/1/name', '/Control_Groups/1/Buss_Trim/name', '/Control_Groups/1/fader',
'/Control_Groups/1/mute', '/Control_Groups/1/solo',
'/Graphic_EQ/1/name', '/Graphic_EQ/1/Buss_Trim/name', '/Graphic_EQ/1/on',
'/Graphic_EQ/1/Bands/1/gain', '/Graphic_EQ/1/gain/1',
'/FX/1/name', '/Multis/1/name',
'/Talkback/level', '/Talkback/on', '/Oscillator/on', '/Oscillator/level', '/Oscillator/freq',
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const T0 = process.hrtime.bigint();
const now = () => Number(process.hrtime.bigint() - T0) / 1e6;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/** Replace the first all-digit path segment with N and the second with M. */
function patternOf(address) {
let count = 0;
return address
.split('/')
.map((s) => (/^\d+$/.test(s) ? (count++ === 0 ? 'N' : 'M') : s))
.join('/');
}
/** Numeric path segments of an address as { n, m } (null when absent). */
function instanceOf(address) {
const nums = address.split('/').filter((s) => /^\d+$/.test(s)).map(Number);
return { n: nums.length > 0 ? nums[0] : null, m: nums.length > 1 ? nums[1] : null };
}
const treeOf = (address) => address.split('/')[1] || '';
const round6 = (v) => (typeof v === 'number' && Number.isFinite(v) ? Number(v.toPrecision(6)) : v);
/** Refuse anything that is not a plain read-only query. Throws on violation. */
function assertSafeQuery(address) {
if (typeof address !== 'string') throw new Error('query address must be a string');
if (!address.endsWith('/?')) throw new Error(`refusing non-query address (no trailing /?): ${address}`);
if (!/^\/[A-Z][A-Za-z0-9_\-/]*\/\?$/.test(address)) {
throw new Error(`refusing address (must start with /<Capital>, only [A-Za-z0-9_-/]): ${address}`);
}
if (address.includes('//') || address.includes('*')) throw new Error(`refusing malformed address: ${address}`);
if (address.startsWith('/console/')) throw new Error(`refusing S-Series address: ${address}`);
const base = address.slice(0, -2);
if (ACTION_NODES.has(base) && !INCLUDE_ACTIONS) throw new Error(`refusing action node: ${address}`);
// The encoded packet must decode back to the same address with zero arguments.
const buf = encode(address, []);
const back = decode(buf);
if (back.address !== address || !Array.isArray(back.args) || back.args.length !== 0 || back.types !== '') {
throw new Error(`encoded packet is not an argument-less query: ${address}`);
}
return buf;
}
// ---------------------------------------------------------------------------
// Parse the 2014 command list
// ---------------------------------------------------------------------------
function parseDocList(text) {
const rows = [];
const skipped = [];
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line.startsWith('/sd/')) continue;
// Example lines carry arguments ("/sd/Input_Channels/1/mute, i,1 - ..."): not table rows.
if (/^\/sd\/\S*,\s/.test(line)) {
skipped.push(line);
continue;
}
const tokens = line.split(/\s+/);
const ti = tokens.findIndex((t) => /^(Int|Float|String)$/i.test(t));
if (ti < 0) {
skipped.push(line);
continue;
}
const docPath = tokens.slice(0, ti).join('').replace(/\/+$/, ''); // "/sd/Entity/*/Sub/param"
const addr = docPath.replace(/^\/sd/, '');
const type = tokens[ti][0].toUpperCase() + tokens[ti].slice(1).toLowerCase();
const min = tokens[ti + 1] !== undefined ? Number(tokens[ti + 1]) : null;
const max = tokens[ti + 2] !== undefined ? Number(tokens[ti + 2]) : null;
rows.push({
template: addr,
documentedType: type,
documentedRange: min !== null && max !== null ? { min, max } : null,
});
}
return { rows, skipped };
}
/** Expand "*" wildcards of one row into concrete addresses (without "/?"). */
function expandRow(row) {
const segs = row.template.split('/'); // ['', 'Input_Channels', '*', ...]
const entity = segs[1];
const star = segs.reduce((acc, s, i) => (s === '*' ? [...acc, i] : acc), []);
if (star.length === 0) return [row.template];
const ent = ENTITIES[entity];
const entIdx = ent ? ent.probe : [1];
let out = [];
for (const n of entIdx) {
const segsN = segs.slice();
segsN[star[0]] = String(n);
if (star.length > 1) {
const subName = segs[star[1] - 1];
const subs = SUB_PROBE[subName] || [1];
for (const m of subs) {
const segsM = segsN.slice();
segsM[star[1]] = String(m);
out.push(segsM.join('/'));
}
} else {
out.push(segsN.join('/'));
}
}
return out;
}
// ---------------------------------------------------------------------------
// Build the query list
// ---------------------------------------------------------------------------
/** entries: Map base-address -> entry (insertion order = send order). */
function buildEntries() {
const entries = new Map();
const sourceCounts = { extra: 0, doc: 0, actionSkipped: [] };
const ensure = (base, source) => {
let e = entries.get(base);
if (!e) {
e = {
address: base,
query: `${base}/?`,
pattern: patternOf(base),
tree: treeOf(base),
inst: instanceOf(base),
sources: [],
documentedType: null,
documentedRange: null,
skipped: false,
};
entries.set(base, e);
}
if (!e.sources.includes(source)) e.sources.push(source);
return e;
};
for (const base of EXTRA_ADDRESSES) {
ensure(base, 'extra');
sourceCounts.extra++;
}
const text = fs.readFileSync(LIST_FILE, 'utf8');
const { rows, skipped } = parseDocList(text);
for (const row of rows) {
for (const base of expandRow(row)) {
const e = ensure(base, 'doc');
e.documentedType = row.documentedType;
e.documentedRange = row.documentedRange;
if (ACTION_NODES.has(base) && !INCLUDE_ACTIONS) {
e.skipped = true;
e.skipReason = 'write-only action node in the 2014 list (trigger); not queried by default';
sourceCounts.actionSkipped.push(base);
}
sourceCounts.doc++;
}
}
return { entries, rows, skippedLines: skipped, sourceCounts };
}
// ---------------------------------------------------------------------------
// Probe run
// ---------------------------------------------------------------------------
async function main() {
const { entries, rows, skippedLines, sourceCounts } = buildEntries();
const toSend = [...entries.values()].filter((e) => !e.skipped);
// Validate every query up front (throws before any packet leaves the machine).
for (const e of toSend) assertSafeQuery(e.query);
assertSafeQuery('/Console/Name/?');
const docPatterns = new Set(rows.map((r) => patternOf(r.template)));
console.log(
`list: ${rows.length} doc rows (${skippedLines.length} example lines ignored), ` +
`${docPatterns.size} distinct patterns, ${sourceCounts.extra} extra candidates`
);
console.log(`planned phase-1 queries: ${toSend.length} (+ names phase, + heartbeats)`);
if (sourceCounts.actionSkipped.length) {
console.log(`skipped action nodes: ${[...new Set(sourceCounts.actionSkipped)].join(', ')}`);
}
if (DRY_RUN) {
const perTree = {};
for (const e of toSend) perTree[e.tree] = (perTree[e.tree] || 0) + 1;
console.log('queries per tree:', JSON.stringify(perTree));
console.log('dry run only; nothing sent');
return;
}
const sock = dgram.createSocket({ type: 'udp4', reuseAddr: false });
const sentLog = []; // every query sent, in order
const packets = []; // every packet received
const unsolicited = [];
const sendErrors = [];
let lastSend = -1e9;
let minGap = Infinity;
let sentCount = 0;
let currentPhase = 'connect';
const sourcesSeen = new Map();
const parseFailures = [];
sock.on('error', (err) => {
console.error(`socket error: ${err.message}`);
process.exit(3);
});
sock.on('message', (msg, rinfo) => {
const t = now();
const key = `${rinfo.address}:${rinfo.port}`;
sourcesSeen.set(key, (sourcesSeen.get(key) || 0) + 1);
const d = decode(msg);
const pkt = { tMs: round6(t), from: key, bytes: msg.length, phase: currentPhase };
if (d.raw !== undefined) {
pkt.raw = d.raw;
packets.push(pkt);
parseFailures.push(pkt);
unsolicited.push({ ...pkt, reason: 'unparsed' });
return;
}
pkt.address = d.address;
pkt.types = d.types;
pkt.args = d.args.map(round6);
pkt.unpadded = d.unpadded;
// Match: newest query with the same reply address inside the window.
let match = null;
let kind = null;
for (let i = sentLog.length - 1; i >= 0; i--) {
const q = sentLog[i];
if (t - q.sentAt > WINDOW_MS) break;
if (q.replyAddress === d.address) {
match = q;
kind = 'exact';
break;
}
}
if (!match) {
for (let i = sentLog.length - 1; i >= 0; i--) {
const q = sentLog[i];
if (t - q.sentAt > WINDOW_MS) break;
if (q.replyAddress.startsWith(d.address) || d.address.startsWith(q.replyAddress)) {
match = q;
kind = 'related';
break;
}
}
}
if (match) {
pkt.matched = match.query;
pkt.matchKind = kind;
pkt.latencyMs = round6(t - match.sentAt);
const e = match.entry;
if (e.replies.length < MAX_STORED_REPLIES) {
e.replies.push({
address: d.address,
types: d.types,
args: d.args.map(round6),
latencyMs: pkt.latencyMs,
match: kind,
unpadded: d.unpadded,
});
}
e.replyCount++;
if (e.firstLatencyMs === null) e.firstLatencyMs = pkt.latencyMs;
match.replied = true;
} else {
// Unsolicited: tag how long after the newest same-address query it arrived (late reply?).
let after = null;
for (let i = sentLog.length - 1; i >= 0; i--) {
if (sentLog[i].replyAddress === d.address) {
after = round6(t - sentLog[i].sentAt);
break;
}
}
unsolicited.push({ ...pkt, reason: after === null ? 'no-query-for-address' : 'outside-window', afterQueryMs: after });
}
packets.push(pkt);
});
await new Promise((resolve, reject) => {
sock.once('error', reject);
sock.bind({ port: LISTEN_PORT, address: '0.0.0.0', exclusive: true }, () => {
sock.removeListener('error', reject);
resolve();
});
});
console.log(`listening on udp4 0.0.0.0:${LISTEN_PORT}; queries go to ${RELAY_HOST}:${RELAY_PORT}`);
/** Send one query (paced) and register it. */
async function send(entry, tag) {
const buf = assertSafeQuery(entry.query);
const wait = lastSend + INTERVAL_MS - now();
if (wait > 0) await sleep(wait);
const t = now();
if (sentCount > 0) minGap = Math.min(minGap, t - lastSend);
lastSend = t;
const rec = {
query: entry.query,
replyAddress: entry.address,
sentAt: t,
entry,
tag: tag || 'probe',
replied: false,
};
sentLog.push(rec);
entry.sentCount = (entry.sentCount || 0) + 1;
sentCount++;
sock.send(buf, RELAY_PORT, RELAY_HOST, (err) => {
if (err) sendErrors.push({ query: entry.query, error: err.message });
});
return rec;
}
const newEntryState = (e) => {
e.replies = [];
e.replyCount = 0;
e.firstLatencyMs = null;
return e;
};
for (const e of entries.values()) newEntryState(e);
// ---- connectivity check -------------------------------------------------
const nameEntry = entries.get('/Console/Name');
const connRec = await send(nameEntry, 'connectivity');
const connDeadline = connRec.sentAt + CONNECT_TIMEOUT_MS;
while (now() < connDeadline && !(nameEntry.replyCount > 0)) await sleep(10);
// The match window is 400 ms; a reply between 400 ms and 2 s is still accepted for this check.
const connUnsolicited = unsolicited.find((u) => u.address === '/Console/Name');
const connectivityOk = nameEntry.replyCount > 0 || !!connUnsolicited;
const connReply = nameEntry.replies[0] || connUnsolicited || null;
if (!connectivityOk) {
console.error('CONNECTIVITY CHECK FAILED: no /Console/Name reply within 2 s from the relay. Stopping (nothing else sent).');
console.error(`packets received during the wait: ${packets.length}`);
sock.close();
process.exitCode = 2;
return;
}
const connName = connReply && (connReply.args || [])[0];
console.log(`connectivity OK: /Console/Name -> ${JSON.stringify(connName)} (types ${connReply && connReply.types})`);
if (!nameEntry.replyCount && connUnsolicited) {
// Late (>400 ms) but within 2 s: keep as an unsolicited packet and record that it was slow.
console.log(`note: the first reply was slow (${connUnsolicited.afterQueryMs} ms), outside the 400 ms window`);
}
// ---- phase 1 + names ----------------------------------------------------
const heartbeats = [];
let misses = 0;
let aborted = null;
let sinceHeartbeat = 0;
async function maybeHeartbeat() {
if (sinceHeartbeat < HEARTBEAT_EVERY) return true;
sinceHeartbeat = 0;
const hbEntry = { address: '/Console/Name', query: '/Console/Name/?', replies: [], replyCount: 0, firstLatencyMs: null };
const rec = await send(hbEntry, 'heartbeat');
await sleep(WINDOW_MS + 20);
const ok = rec.replied;
heartbeats.push({ tMs: round6(rec.sentAt), ok, latencyMs: hbEntry.firstLatencyMs });
misses = ok ? 0 : misses + 1;
if (!ok) console.log(` heartbeat MISSED at ${Math.round(rec.sentAt)} ms (${misses}/${HEARTBEAT_MAX_MISSES})`);
if (misses >= HEARTBEAT_MAX_MISSES) {
aborted = `console stopped answering the heartbeat ${misses} times in a row at ${Math.round(rec.sentAt)} ms`;
return false;
}
return true;
}
currentPhase = 'probe';
const startProbe = now();
let done = 0;
const phase1 = toSend.filter((e) => e !== nameEntry);
for (const e of phase1) {
if (!(await maybeHeartbeat())) break;
await send(e);
sinceHeartbeat++;
done++;
if (done % 100 === 0) {
const answered = phase1.slice(0, done).filter((x) => x.replyCount > 0).length;
console.log(` ${done}/${phase1.length} sent, ${answered} answered so far (${Math.round((now() - startProbe) / 1000)} s)`);
}
}
// ---- names phase ----------------------------------------------------------
const nameTables = {};
if (!aborted) {
await sleep(WINDOW_MS + 20);
currentPhase = 'names';
const nameQueries = [];
for (const [entity, cands] of Object.entries(NAME_CANDIDATES)) {
let chosen = null;
for (const c of cands) {
const probes = ENTITIES[entity].probe.map((n) => entries.get(c.replace('#', String(n)))).filter(Boolean);
if (probes.some((p) => p.replyCount > 0)) {
chosen = c;
break;
}
}
nameTables[entity] = { addressPattern: chosen ? patternOf(chosen.replace('#', '1')) : null, max: ENTITIES[entity].max, values: {}, unanswered: [] };
if (!chosen) continue;
for (let n = 1; n <= ENTITIES[entity].max; n++) {
const base = chosen.replace('#', String(n));
let e = entries.get(base);
if (!e) {
e = newEntryState({
address: base,
query: `${base}/?`,
pattern: patternOf(base),
tree: treeOf(base),
inst: instanceOf(base),
sources: ['names'],
documentedType: null,
documentedRange: null,
skipped: false,
});
for (const doc of [...entries.values()]) {
if (doc.pattern === e.pattern && doc.documentedType) {
e.documentedType = doc.documentedType;
e.documentedRange = doc.documentedRange;
break;
}
}
entries.set(base, e);
nameQueries.push(e);
} else if (!e.sources.includes('names')) {
e.sources.push('names');
}
nameTables[entity].entryFor = nameTables[entity].entryFor || [];
nameTables[entity].entryFor.push(e);
}
}
console.log(`names phase: ${nameQueries.length} new queries`);
for (const e of nameQueries) {
if (!(await maybeHeartbeat())) break;
await send(e);
sinceHeartbeat++;
}
}
// Let the last window close, then listen for unsolicited traffic.
await sleep(WINDOW_MS + 100);
const probeEnd = now();
currentPhase = 'tail';
const tailStart = now();
console.log(`probe finished at ${Math.round(probeEnd / 1000)} s; listening ${TAIL_SECONDS} s for unsolicited traffic`);
await sleep(TAIL_SECONDS * 1000);
const end = now();
sock.close();
// ---- finalize name tables ---------------------------------------------------
for (const [entity, tbl] of Object.entries(nameTables)) {
for (const e of tbl.entryFor || []) {
const r = e.replies.find((x) => x.match === 'exact') || e.replies[0];
if (r && r.args.length > 0) tbl.values[e.inst.n] = r.args[0];
else tbl.unanswered.push(e.inst.n);
}
delete tbl.entryFor;
tbl.answered = Object.keys(tbl.values).length;
}
// ---- build catalog ------------------------------------------------------------
const unsolicitedAddresses = new Set(unsolicited.filter((u) => u.address).map((u) => u.address));
const catalog = [];
for (const e of entries.values()) {
const first = e.replies.find((r) => r.match === 'exact') || e.replies[0] || null;
catalog.push({
address: e.address,
query: e.query,
pattern: e.pattern,
tree: e.tree,
instance: e.inst,
sources: e.sources,
documentedType: e.documentedType,
documentedRange: e.documentedRange,
queried: !e.skipped && (e.sentCount || 0) > 0,
skipped: e.skipped || undefined,
skipReason: e.skipReason,
answered: e.replyCount > 0,
replyAddress: first ? first.address : null,
replyTypes: first ? first.types : null,
exampleValue: first ? (first.args.length === 1 ? first.args[0] : first.args) : null,
replyCount: e.replyCount,
replyMatch: first ? first.match : null,
latencyMs: e.firstLatencyMs,
unsolicited: unsolicitedAddresses.has(e.address),
replies: e.replyCount > 1 || (first && first.address !== e.address) ? e.replies : undefined,
});
}
const queriedAddresses = new Set(catalog.map((c) => c.address));
for (const addr of unsolicitedAddresses) {
if (queriedAddresses.has(addr)) continue;
const u = unsolicited.find((x) => x.address === addr);
catalog.push({
address: addr,
query: null,
pattern: patternOf(addr),
tree: treeOf(addr),
instance: instanceOf(addr),
sources: ['unsolicited'],
documentedType: null,
documentedRange: null,
queried: false,
answered: false,
replyAddress: addr,
replyTypes: u.types,
exampleValue: u.args.length === 1 ? u.args[0] : u.args,
unsolicited: true,
});
}
// ---- summary ----------------------------------------------------------------------
const perTree = {};
for (const c of catalog) {
if (!c.queried && !c.skipped) continue;
const t = (perTree[c.tree] = perTree[c.tree] || { queried: 0, answered: 0, unanswered: 0, skipped: 0 });
if (c.skipped) t.skipped++;
else {
t.queried++;
if (c.answered) t.answered++;
else t.unanswered++;
}
}
const answeredPatterns = new Set(catalog.filter((c) => c.answered).map((c) => c.pattern));
const queriedPatterns = new Set(catalog.filter((c) => c.queried).map((c) => c.pattern));
const summary = {
relay: `${RELAY_HOST}:${RELAY_PORT}`,
listen: `udp4 0.0.0.0:${LISTEN_PORT}`,
consoleName: connName,
startedAt: new Date(Date.now() - end).toISOString(),
durationSeconds: round6(end / 1000),
probeSeconds: round6((probeEnd - startProbe) / 1000),
tailSeconds: round6((end - tailStart) / 1000),
intervalMs: INTERVAL_MS,
minObservedSendGapMs: round6(minGap),
windowMs: WINDOW_MS,
queriesSent: sentCount,
distinctAddressesQueried: catalog.filter((c) => c.queried).length,
addressesAnswered: catalog.filter((c) => c.answered).length,
addressesUnanswered: catalog.filter((c) => c.queried && !c.answered).length,
patternsQueried: queriedPatterns.size,
patternsAnswered: answeredPatterns.size,
packetsReceived: packets.length,
packetsMatched: packets.filter((p) => p.matched).length,
packetsUnsolicited: unsolicited.length,
unsolicitedDuringProbe: unsolicited.filter((u) => u.phase !== 'tail').length,
unsolicitedInTail: unsolicited.filter((u) => u.phase === 'tail').length,
unparsedPackets: parseFailures.length,
unpaddedPackets: packets.filter((p) => p.unpadded).length,
replySources: Object.fromEntries(sourcesSeen),
heartbeats: { total: heartbeats.length, missed: heartbeats.filter((h) => !h.ok).length, detail: heartbeats },
aborted,
sendErrors,
skippedActionNodes: [...new Set(sourceCounts.actionSkipped)],
exampleLinesIgnored: skippedLines.length,
perTree,
};
const out = { summary, names: nameTables, catalog, unsolicited };
fs.mkdirSync(OUT_DIR, { recursive: true });
const jsonPath = path.join(OUT_DIR, 's31-catalog.json');
const oneLine = (list) => list.map((x) => ' ' + JSON.stringify(x)).join(',\n');
const jsonText =
'{\n' +
` "summary": ${JSON.stringify(out.summary, null, 2).replace(/\n/g, '\n ')},\n` +
` "names": ${JSON.stringify(out.names, null, 2).replace(/\n/g, '\n ')},\n` +
` "catalog": [\n${oneLine(out.catalog)}\n ],\n` +
` "unsolicited": [\n${oneLine(out.unsolicited)}\n ]\n}\n`;
JSON.parse(jsonText); // sanity: must be valid JSON
fs.writeFileSync(jsonPath, jsonText);
fs.writeFileSync(path.join(OUT_DIR, 's31-catalog.md'), renderMarkdown(out));
if (RAW_LOG) fs.writeFileSync(RAW_LOG, packets.map((p) => JSON.stringify(p)).join('\n') + '\n');
console.log(
`done in ${Math.round(end / 1000)} s: ${sentCount} queries, ${summary.addressesAnswered}/${summary.distinctAddressesQueried} addresses answered, ` +
`${packets.length} packets (${unsolicited.length} unsolicited), min send gap ${summary.minObservedSendGapMs} ms`
);
if (aborted) console.log(`ABORTED EARLY: ${aborted}`);
console.log(`wrote ${jsonPath}`);
}
// ---------------------------------------------------------------------------
// Markdown report
// ---------------------------------------------------------------------------
const EXPECT = { Int: 'i', Float: 'f', String: 's' };
function fmtValue(v) {
if (Array.isArray(v)) return `[${v.map(fmtValue).join(', ')}]`;
if (typeof v === 'string') return JSON.stringify(v);
return String(v);
}
const esc = (s) => String(s).replace(/\|/g, '\\|');
/** [1,2,3,5] -> "1-3,5" */
function compressList(nums) {
const a = [...new Set(nums)].sort((x, y) => x - y);
const parts = [];
for (let i = 0; i < a.length; ) {
let j = i;
while (j + 1 < a.length && a[j + 1] === a[j] + 1) j++;
parts.push(j - i >= 2 ? `${a[i]}-${a[j]}` : a.slice(i, j + 1).join(','));
i = j + 1;
}
return parts.join(',');
}
const fmtRange = (r) => (r ? `${r.min}..${r.max}` : '');
function renderMarkdown({ summary, names, catalog, unsolicited }) {
const L = [];
const queried = catalog.filter((c) => c.queried);
const answered = queried.filter((c) => c.answered);
// Group by pattern.
const byPattern = new Map();
for (const c of queried) {
let g = byPattern.get(c.pattern);
if (!g) {
g = { pattern: c.pattern, tree: c.tree, documentedType: null, documentedRange: null, items: [] };
byPattern.set(c.pattern, g);
}
if (c.documentedType) {
g.documentedType = c.documentedType;
g.documentedRange = c.documentedRange;
}
g.items.push(c);
}
const groups = [...byPattern.values()];
const answeredGroups = groups.filter((g) => g.items.some((i) => i.answered));
const unansweredGroups = groups.filter((g) => !g.items.some((i) => i.answered));
L.push('# DiGiCo S31 OSC catalog (iPad extension, read-only probe)');
L.push('');
L.push(`Generated by \`tools/probe-catalog.js\` on ${summary.startedAt}. Console: ${fmtValue(summary.consoleName)}. Queries sent to the relay ${summary.relay}; replies on ${summary.listen}.`);
L.push('');
L.push(`- ${summary.queriesSent} queries sent in ${summary.probeSeconds} s (interval ${summary.intervalMs} ms, minimum observed gap ${summary.minObservedSendGapMs} ms), then ${summary.tailSeconds} s of listening.`);
L.push(`- ${summary.addressesAnswered} of ${summary.distinctAddressesQueried} distinct addresses answered (${summary.patternsAnswered} of ${summary.patternsQueried} patterns).`);
L.push(`- ${summary.packetsReceived} packets received: ${summary.packetsMatched} matched to a query, ${summary.packetsUnsolicited} unsolicited (${summary.unsolicitedDuringProbe} during probing, ${summary.unsolicitedInTail} in the final listen).`);
L.push(`- Heartbeats (/Console/Name/? every ${HEARTBEAT_EVERY} queries): ${summary.heartbeats.total} sent, ${summary.heartbeats.missed} missed.${summary.aborted ? ` **Run aborted early: ${summary.aborted}.**` : ''}`);
if (summary.skippedActionNodes.length) {
L.push(`- Not queried (write-only trigger nodes in the 2014 list): ${summary.skippedActionNodes.map((a) => `\`${a}\``).join(', ')}.`);
}
L.push('');
L.push('Pattern notation: N = first numeric index (channel / buss / entity), M = second numeric index (send buss, band). "Documented" is the 2014 DiGiCo SD OSC list type and OSC min/max.');
L.push('');
// Per-tree counts.
L.push('## Counts per tree');
L.push('');
L.push('| Tree | Addresses queried | Answered | Unanswered | Skipped |');
L.push('| --- | ---: | ---: | ---: | ---: |');
for (const [tree, t] of Object.entries(summary.perTree)) {
L.push(`| ${tree} | ${t.queried} | ${t.answered} | ${t.unanswered} | ${t.skipped || ''} |`);
}
L.push('');
// Surprises.
L.push('## Surprising / noteworthy');
L.push('');
const surprises = [];
const matrix = {};
const mismatchByLeaf = {};
for (const c of answered) {
if (!c.documentedType || !c.replyTypes) continue;
const key = `${c.documentedType} -> ${c.replyTypes || '(none)'}`;
matrix[key] = (matrix[key] || 0) + 1;
if (EXPECT[c.documentedType] !== c.replyTypes) {
const leaf = c.pattern;
(mismatchByLeaf[key] = mismatchByLeaf[key] || new Set()).add(leaf);
}
}
if (Object.keys(matrix).length) {
surprises.push('Documented type -> type tag actually received (answered addresses with a documented type):');
for (const [k, n] of Object.entries(matrix).sort()) surprises.push(` - ${k}: ${n} addresses${k.split(' -> ')[0] && EXPECT[k.split(' -> ')[0]] !== k.split(' -> ')[1] ? ' (MISMATCH)' : ''}`);
}
for (const [key, set] of Object.entries(mismatchByLeaf)) {
const list = [...set].sort();
surprises.push(`Mismatch ${key}: ${list.length} pattern(s)${list.length <= 40 ? ': ' + list.map((p) => '`' + p + '`').join(', ') : ' (see JSON)'}`);
}
// Observed numeric values outside the documented range.
const outOfRange = [];
for (const g of answeredGroups) {
if (!g.documentedRange) continue;
const vals = g.items.filter((i) => i.answered && typeof i.exampleValue === 'number').map((i) => i.exampleValue);
if (vals.some((v) => v < g.documentedRange.min || v > g.documentedRange.max)) outOfRange.push(`\`${g.pattern}\` (documented ${fmtRange(g.documentedRange)}, saw ${[...new Set(vals)].join(', ')})`);
}
if (outOfRange.length) surprises.push(`Values outside the documented OSC range (${outOfRange.length}): ${outOfRange.slice(0, 40).join('; ')}${outOfRange.length > 40 ? '; ...' : ''}`);
// Partial answers.
const partial = answeredGroups.filter((g) => g.items.some((i) => !i.answered)).map((g) => `\`${g.pattern}\` (${g.items.filter((i) => i.answered).length}/${g.items.length})`);
if (partial.length) surprises.push(`Patterns answered for some instances but not all probed instances (${partial.length}): ${partial.slice(0, 60).join(', ')}${partial.length > 60 ? ', ...' : ''}`);
// Multi-reply / different reply address.
const multi = answered.filter((c) => c.replyCount > 1);
if (multi.length) surprises.push(`Queries that produced more than one reply message (${multi.length}): ${multi.slice(0, 20).map((c) => `\`${c.query}\` x${c.replyCount}`).join(', ')}`);
const diffAddr = answered.filter((c) => c.replyAddress !== c.address);
if (diffAddr.length) surprises.push(`Replies on a different address than the query (${diffAddr.length}): ${diffAddr.slice(0, 20).map((c) => `\`${c.query}\` -> \`${c.replyAddress}\``).join(', ')}`);
if (summary.unpaddedPackets) surprises.push(`${summary.unpaddedPackets} received packets were not 4-byte padded OSC (osc-lite decoded them leniently).`);
if (summary.unparsedPackets) surprises.push(`${summary.unparsedPackets} received packets could not be decoded (raw hex in JSON "unsolicited" entries).`);
const lateish = unsolicited.filter((u) => u.reason === 'outside-window' && u.afterQueryMs !== null && u.afterQueryMs < 2000);
if (lateish.length) surprises.push(`${lateish.length} unsolicited replies arrived 0.4-2 s after a query for the same address (possibly slow replies, not human input); see the unsolicited section.`);
if (summary.heartbeats.missed) surprises.push(`${summary.heartbeats.missed} of ${summary.heartbeats.total} heartbeats were missed: the console (or relay) went quiet during the run.`);
if (surprises.length === 0) surprises.push('Nothing unexpected detected by the automatic checks.');
for (const s of surprises) L.push(s.startsWith(' ') ? s : `- ${s}`);
L.push('');
// Answered table by tree.
L.push('## Answered addresses, by tree');
L.push('');
const trees = [...new Set(answeredGroups.map((g) => g.tree))];
for (const tree of trees) {
const gs = answeredGroups.filter((g) => g.tree === tree);
L.push(`### /${tree} (${gs.length} patterns answered)`);
L.push('');
L.push('| Pattern | Answered N / M | Reply tags | Example value(s) | Documented type, range |');
L.push('| --- | --- | --- | --- | --- |');
for (const g of gs) {
const ans = g.items.filter((i) => i.answered);
const ns = [...new Set(ans.map((i) => i.instance.n).filter((v) => v !== null))];
const ms = [...new Set(ans.map((i) => i.instance.m).filter((v) => v !== null))];
const inst = [ns.length ? `N=${compressList(ns)}` : '', ms.length ? `M=${compressList(ms)}` : ''].filter(Boolean).join(' ') || '-';
const partialMark = ans.length < g.items.length ? ` (${ans.length}/${g.items.length})` : '';
const tags = [...new Set(ans.map((i) => (i.replyAddress !== i.address ? `${i.replyTypes} @${i.replyAddress}` : i.replyTypes)))].join(', ');
const ex = [...new Set(ans.map((i) => fmtValue(i.exampleValue)))].slice(0, 4).join(' / ');
const doc = g.documentedType ? `${g.documentedType}${g.documentedRange ? ' ' + fmtRange(g.documentedRange) : ''}` : '(not in 2014 list)';
L.push(`| \`${g.pattern}\` | ${inst}${partialMark} | ${esc(tags)} | ${esc(ex)} | ${doc} |`);
}
L.push('');
}
if (trees.length === 0) {
L.push('_No address answered._');
L.push('');
}
// Unanswered.
const docUnanswered = unansweredGroups.filter((g) => g.items.some((i) => i.sources.includes('doc')));
const extraUnanswered = unansweredGroups.filter((g) => !g.items.some((i) => i.sources.includes('doc')));
L.push(`## Unanswered documented patterns (${docUnanswered.length})`);
L.push('');
L.push('Patterns from the 2014 list for which none of the probed instances answered.');
L.push('');
const unTrees = [...new Set(docUnanswered.map((g) => g.tree))];
for (const tree of unTrees) {
const gs = docUnanswered.filter((g) => g.tree === tree);
L.push(`### /${tree} (${gs.length})`);
L.push('');
for (const g of gs) L.push(`- \`${g.pattern}\``);
L.push('');
}
L.push(`## Unanswered extra candidates (${extraUnanswered.length})`);
L.push('');
for (const g of extraUnanswered) L.push(`- \`${g.items.map((i) => i.address).join('`, `')}\``);
L.push('');
if (summary.skippedActionNodes.length) {
L.push('## Not queried');
L.push('');
for (const a of summary.skippedActionNodes) L.push(`- \`${a}\` (write-only trigger node; skipped for safety, pass --include-actions to query it)`);
L.push('');
}
// Unsolicited.
L.push(`## Unsolicited messages (${unsolicited.length})`);
L.push('');
if (unsolicited.length === 0) {
L.push('None seen, including during the final listen.');
} else {
L.push('| t (s) | Phase | Address | Tags | Value | Note |');
L.push('| ---: | --- | --- | --- | --- | --- |');
for (const u of unsolicited.slice(0, 500)) {
const note = u.reason === 'outside-window' ? `${u.afterQueryMs} ms after a query for this address` : u.reason;
L.push(`| ${round6(u.tMs / 1000)} | ${u.phase} | \`${u.address || '(unparsed)'}\` | ${u.types ?? ''} | ${esc(u.args ? fmtValue(u.args) : u.raw || '')} | ${note} |`);
}
if (unsolicited.length > 500) L.push(`| | | ... ${unsolicited.length - 500} more (see JSON) | | | |`);
}
L.push('');
// Names.
L.push('## Live name tables');
L.push('');
for (const [entity, tbl] of Object.entries(names)) {
L.push(`### ${entity}`);
L.push('');
if (!tbl.addressPattern) {
L.push('_No name address answered for this entity._');
L.push('');
continue;
}
L.push(`Address: \`${tbl.addressPattern}\` (${tbl.answered} of ${tbl.max} answered${tbl.unanswered.length ? `, no reply for ${tbl.unanswered.join(', ')}` : ''}).`);
const sample = catalog.find((c) => c.pattern === tbl.addressPattern && c.answered);
if (sample) L.push(`Reply format: \`${sample.replyAddress} ,${sample.replyTypes} ${fmtValue(Array.isArray(sample.exampleValue) ? sample.exampleValue : [sample.exampleValue])}\`.`);
L.push('');
L.push('| # | Name |');
L.push('| ---: | --- |');
for (const [idx, nm] of Object.entries(tbl.values)) L.push(`| ${idx} | ${esc(fmtValue(nm))} |`);
L.push('');
}
return L.join('\n') + '\n';
}
main().catch((err) => {
console.error(`fatal: ${err.stack || err.message}`);
process.exit(1);
});