The Macs at NA are also on Wi-Fi and the Dante networks, and Bonjour lists each MIDI session once per network. On 2026-10-20 the Tracks and Companion senders were set to Wi-Fi addresses, so the lighting MIDI ProdDash hears rode Wi-Fi. With "Wired network" set (This machine; e.g. 10.1.0.0/16, several with commas), a sender typed as a name or as an address outside it, and a session that joins us from outside it, turn the admin status line and the tile's sender chip red, naming the sender. It still joins, so the cues keep showing. Empty (the default) changes nothing; a typo in the setting fails the module in /admin with a plain message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
429 lines
17 KiB
JavaScript
429 lines
17 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Vista Cues — server part.
|
||
*
|
||
* Vista 3 cannot say what it is playing: its OSC is input-only and it sends
|
||
* no feedback. What it *does* have is a network MIDI session that
|
||
* ProPresenter and the tracks rig fire cue lists through. A session sends
|
||
* every message to every participant, so this module joins those sessions
|
||
* as one more participant (rtpmidi.js) and hears exactly what Vista hears.
|
||
* The admin's "Cue lists" sheet (mapping.js) turns each note into
|
||
* "Go on Walk-in", "Release Worship 1"…, and from those the module keeps
|
||
* which lists are live.
|
||
*
|
||
* GET /state → { state } (instant paint)
|
||
* GET /stream → SSE: `state` on every change
|
||
* POST /clear → forget which lists are live (when Vista was reset by hand)
|
||
*
|
||
* What the tile shows is what Vista was told, not what Vista confirmed: if
|
||
* Vista is closed or someone works the desk by hand, the two can differ.
|
||
*/
|
||
|
||
const fs = require('fs');
|
||
const { spawn } = require('child_process');
|
||
const { Session } = require('./rtpmidi');
|
||
const { parseMapping, match, triggerLabel } = require('./mapping');
|
||
|
||
const HEARTBEAT_MS = 15000;
|
||
const LOG_MAX = 40;
|
||
const DNS_SD = '/usr/bin/dns-sd';
|
||
|
||
let current = null;
|
||
|
||
function emptyState(extra = {}) {
|
||
return {
|
||
listening: false,
|
||
error: '',
|
||
sessionName: '',
|
||
port: null,
|
||
advertised: false,
|
||
sources: [],
|
||
current: null,
|
||
active: [],
|
||
log: [],
|
||
mappingErrors: [],
|
||
rules: 0,
|
||
now: Date.now(),
|
||
...extra,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The wired network(s) MIDI must ride ("10.1.0.0/16, 10.2.0.0/16"). The Macs
|
||
* here are also on Wi-Fi and the Dante networks, and Bonjour lists each
|
||
* session once per network, so a sender typed or picked from the wrong copy
|
||
* works — until Wi-Fi hiccups mid-service. Returns [{ base, bits, text }];
|
||
* throws on a typo so the admin page says so at once.
|
||
*/
|
||
function parseNets(text) {
|
||
return String(text || '').split(/[\s,]+/).filter(Boolean).map((n) => {
|
||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/.exec(n);
|
||
const parts = m ? m.slice(1, 5).map(Number) : [];
|
||
const bits = m && m[5] !== undefined ? Number(m[5]) : 32;
|
||
if (!m || parts.some((x) => x > 255) || bits > 32) {
|
||
throw new Error(`Wired network: "${n}" is not a network like 10.1.0.0/16`);
|
||
}
|
||
return { base: ipNumber(parts.join('.')), bits, text: n };
|
||
});
|
||
}
|
||
|
||
function ipNumber(a) {
|
||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(String(a || '').trim());
|
||
return m ? m.slice(1, 5).reduce((n, x) => n * 256 + Number(x), 0) : null;
|
||
}
|
||
|
||
/**
|
||
* Why an address is not on the wired network: '' when it is (or there is no
|
||
* rule), 'by name' for a host name (it resolves to whichever network answers
|
||
* first), 'not wired' for an address outside every wired network.
|
||
*/
|
||
function offNetwork(nets, address) {
|
||
if (!nets.length || !address) return '';
|
||
const v = ipNumber(address);
|
||
if (v === null) return 'by name';
|
||
if (address.startsWith('127.')) return '';
|
||
const span = (bits) => 2 ** (32 - bits);
|
||
return nets.some((n) => Math.floor(v / span(n.bits)) === Math.floor(n.base / span(n.bits))) ? '' : 'not wired';
|
||
}
|
||
|
||
function endpointOf(v) {
|
||
const ep = v && typeof v === 'object' ? v : {};
|
||
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
|
||
}
|
||
|
||
module.exports = {
|
||
init({ config, log }) {
|
||
const sessionName = String(config.sessionName || '').trim() || 'ProdDash Lighting';
|
||
const port = Math.round(Number(config.sessionPort)) || 5040;
|
||
const middleC = config.middleC === 'C4' ? 'C4' : 'C3';
|
||
const exclusive = config.exclusive === true;
|
||
const headTakesOver = config.headTakesOver !== false;
|
||
const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC });
|
||
const nets = parseNets(config.wiredNetwork);
|
||
const netText = nets.map((n) => n.text).join(', ');
|
||
if (mappingErrors.length) {
|
||
for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`);
|
||
}
|
||
|
||
const senders = [
|
||
{ label: String(config.source1Name || '').trim() || 'ProPresenter', ...endpointOf(config.source1) },
|
||
{ label: String(config.source2Name || '').trim() || 'Tracks', ...endpointOf(config.source2) },
|
||
{ label: String(config.source3Name || '').trim() || 'Companion', ...endpointOf(config.source3) },
|
||
].filter((s) => s.host);
|
||
|
||
const streams = new Set();
|
||
const lists = new Map(); // lower-cased name → { name, group, state, firedAt, lastAt, lastAction, source, trigger, goes }
|
||
const history = []; // newest first
|
||
let currentKey = '';
|
||
let lastFrame = '';
|
||
let advertiser = null;
|
||
let advertised = false;
|
||
let stopped = false;
|
||
|
||
if (port < 1024 || port > 65534) throw new Error('Session port must be between 1024 and 65534');
|
||
|
||
const session = new Session({ name: sessionName, port, log });
|
||
const invites = senders.map((s) => ({ ...s, inv: null }));
|
||
|
||
/* ── what Vista was told ─────────────────────────────────────── */
|
||
|
||
function sourceLabel(p) {
|
||
return p.label || p.name || p.address;
|
||
}
|
||
|
||
function record(entry) {
|
||
history.unshift({ at: Date.now(), ...entry });
|
||
if (history.length > LOG_MAX) history.length = LOG_MAX;
|
||
}
|
||
|
||
function apply(rule, source, trigger) {
|
||
const now = Date.now();
|
||
const key = rule.list.toLowerCase();
|
||
record({ action: rule.action, list: rule.list, group: rule.group, source, trigger, mapped: true });
|
||
if (rule.action === 'flash') return; // a momentary hit: in the log, never live
|
||
if (rule.action === 'releaseAll') {
|
||
lists.clear();
|
||
currentKey = '';
|
||
return;
|
||
}
|
||
if (rule.action === 'release') {
|
||
lists.delete(key);
|
||
if (currentKey === key) currentKey = newestKey();
|
||
return;
|
||
}
|
||
let l = lists.get(key);
|
||
if (rule.action === 'go') {
|
||
// A Go replaces the other lists of its group. A Go in the first group (the
|
||
// main scene) takes over everything, as Vista's last-takes-precedence does;
|
||
// and with "Go releases others" on, every Go does.
|
||
const takeover = exclusive || (headTakesOver && rule.group && rule.group === groups[0]);
|
||
for (const [k, other] of [...lists]) {
|
||
if (k !== key && (takeover || (rule.group && other.group === rule.group))) lists.delete(k);
|
||
}
|
||
if (!l) {
|
||
l = { name: rule.list, group: rule.group, state: 'active', firedAt: now, goes: 0 };
|
||
lists.set(key, l);
|
||
}
|
||
l.state = 'active';
|
||
l.goes++;
|
||
l.lastAt = now;
|
||
l.lastAction = 'go';
|
||
l.source = source;
|
||
l.trigger = trigger;
|
||
currentKey = key;
|
||
return;
|
||
}
|
||
// pause / back act on a list that is live; on one that isn't, they only go in the log.
|
||
if (!l) return;
|
||
l.lastAt = now;
|
||
l.lastAction = rule.action;
|
||
l.source = source;
|
||
l.trigger = trigger;
|
||
if (rule.action === 'pause') l.state = 'paused'; // the next Go resumes it
|
||
if (rule.action === 'back') { l.state = 'active'; l.goes = Math.max(1, l.goes - 1); }
|
||
}
|
||
|
||
function newestKey() {
|
||
let best = '';
|
||
let at = -1;
|
||
for (const [k, l] of lists) if (l.lastAt > at) { best = k; at = l.lastAt; }
|
||
return best;
|
||
}
|
||
|
||
session.on('midi', ({ participant, event }) => {
|
||
if (event.type !== 'noteon' && event.type !== 'program') return;
|
||
const source = sourceLabel(participant);
|
||
const trigger = triggerLabel(event, middleC);
|
||
const rule = match(rules, event);
|
||
if (rule) {
|
||
apply(rule, source, trigger);
|
||
log(`${source}: ${trigger} → ${rule.action}${rule.list ? ` ${rule.list}` : ''}`);
|
||
} else {
|
||
record({ action: '', list: '', source, trigger, mapped: false });
|
||
}
|
||
broadcast();
|
||
});
|
||
session.on('change', () => broadcast());
|
||
// Senders are joined once our ports are ours — straight away normally,
|
||
// or a moment later when a busy port frees up.
|
||
session.on('listening', () => {
|
||
if (stopped) return;
|
||
log(`session "${sessionName}" listening on UDP ${port}/${port + 1}`);
|
||
for (const s of invites) s.inv = session.invite(s.host, s.port, s.label);
|
||
if (!advertiser) advertise();
|
||
});
|
||
|
||
/* ── the view-model ──────────────────────────────────────────── */
|
||
|
||
function sources() {
|
||
const out = [];
|
||
const seen = new Set();
|
||
for (const s of invites) {
|
||
const inv = s.inv;
|
||
const p = inv && [...session.participants.values()].find((q) => q.connected && q.address === inv.address && q.controlPort === inv.port);
|
||
if (p) seen.add(p.key);
|
||
out.push({
|
||
label: s.label,
|
||
name: p ? p.name : '',
|
||
address: inv?.address || s.host,
|
||
// A name is judged as typed: it may resolve to Wi-Fi next time even if it is wired now.
|
||
offNetwork: offNetwork(nets, s.host) || offNetwork(nets, inv?.address || ''),
|
||
host: s.host,
|
||
configured: true,
|
||
connected: Boolean(p),
|
||
error: p ? '' : (inv?.error || ''),
|
||
lastHeard: p ? p.lastHeard : null,
|
||
});
|
||
}
|
||
for (const p of session.participants.values()) {
|
||
if (seen.has(p.key) || !p.connected) continue;
|
||
out.push({ label: p.label || p.name, name: p.name, address: p.address, offNetwork: offNetwork(nets, p.address), configured: false, connected: true, error: '', lastHeard: p.lastHeard });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function groupRank(l) {
|
||
const i = groups.indexOf(l.group);
|
||
return i < 0 ? groups.length : i;
|
||
}
|
||
|
||
function buildState() {
|
||
const live = [...lists.values()].sort((a, b) => b.lastAt - a.lastAt);
|
||
// The headline: the live list of the sheet's first group (the scene, say,
|
||
// rather than whichever colour changed last); without groups, the last Go.
|
||
const head = groups[0];
|
||
const cur = (head && live.find((l) => l.group === head))
|
||
|| (currentKey ? lists.get(currentKey) : null)
|
||
|| null;
|
||
return {
|
||
listening: Boolean(session.control),
|
||
error: session.error,
|
||
sessionName,
|
||
port,
|
||
advertised,
|
||
wiredNetwork: netText,
|
||
sources: sources(),
|
||
current: cur || null,
|
||
// The rest in sheet order of their group, so a colour chip stays put as it changes.
|
||
active: live.filter((l) => l !== cur).sort((a, b) => groupRank(a) - groupRank(b) || b.lastAt - a.lastAt),
|
||
headline: head || '',
|
||
log: history,
|
||
mappingErrors,
|
||
rules: rules.length,
|
||
now: Date.now(),
|
||
};
|
||
}
|
||
|
||
function send(payload) {
|
||
for (const res of streams) {
|
||
try { res.write(payload); } catch { streams.delete(res); }
|
||
}
|
||
}
|
||
|
||
function broadcast() {
|
||
if (stopped) return;
|
||
const state = buildState();
|
||
const { now, sources: src, ...rest } = state;
|
||
// lastHeard moves with every packet; leave it out of change detection.
|
||
const frame = JSON.stringify({ ...rest, src: src.map(({ lastHeard, ...s }) => s) });
|
||
if (frame === lastFrame) return;
|
||
lastFrame = frame;
|
||
send(`event: state\ndata: ${JSON.stringify(state)}\n\n`);
|
||
}
|
||
|
||
const keepalive = setInterval(() => send(': ping\n\n'), HEARTBEAT_MS);
|
||
keepalive.unref?.();
|
||
|
||
/* ── being found: Bonjour on macOS ───────────────────────────── */
|
||
|
||
// Advertising _apple-midi._udp puts us in Audio MIDI Setup's Directory on
|
||
// every Mac here, so a sender can add us with a click. macOS ships the
|
||
// dns-sd tool; we run it as a child and stop it with the module. Elsewhere
|
||
// nothing is advertised and senders are joined by address alone.
|
||
function advertise() {
|
||
if (stopped || config.advertise === false || process.platform !== 'darwin' || !fs.existsSync(DNS_SD)) return;
|
||
advertiser = spawn(DNS_SD, ['-R', sessionName, '_apple-midi._udp', 'local', String(port)], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||
advertiser.stdout.on('data', (d) => {
|
||
if (/Name now registered and active/i.test(String(d)) && !advertised) {
|
||
advertised = true;
|
||
log(`advertised as "${sessionName}" on port ${port}`);
|
||
broadcast();
|
||
}
|
||
});
|
||
advertiser.stderr.on('data', () => {});
|
||
advertiser.on('error', (e) => { log(`dns-sd: ${e.message}`); advertiser = null; });
|
||
advertiser.on('exit', () => {
|
||
advertiser = null;
|
||
advertised = false;
|
||
if (!stopped) setTimeout(advertise, 5000).unref?.();
|
||
});
|
||
}
|
||
|
||
/* ── start ───────────────────────────────────────────────────── */
|
||
|
||
session.start().then(() => broadcast());
|
||
|
||
current = {
|
||
streams,
|
||
getState: buildState,
|
||
clear() {
|
||
lists.clear();
|
||
currentKey = '';
|
||
record({ action: 'cleared', list: '', source: 'Dashboard', trigger: '', mapped: true });
|
||
broadcast();
|
||
},
|
||
};
|
||
|
||
return {
|
||
stop() {
|
||
stopped = true;
|
||
clearInterval(keepalive);
|
||
session.stop();
|
||
if (advertiser) { try { advertiser.kill(); } catch { /* gone */ } advertiser = null; }
|
||
for (const res of streams) { try { res.end(); } catch { /* gone */ } }
|
||
streams.clear();
|
||
if (current && current.streams === streams) current = null;
|
||
},
|
||
health() {
|
||
if (session.error) return { status: 'error', message: session.error };
|
||
const src = sources();
|
||
// Off the wired network: still joined (the cues keep working), but said first and in red.
|
||
const off = src.filter((s) => s.offNetwork);
|
||
if (off.length) {
|
||
return {
|
||
status: 'error',
|
||
message: `${off.map((s) => (s.offNetwork === 'by name'
|
||
? `${s.label} is set by name (${s.host})`
|
||
: `${s.label} ${s.configured ? `(${s.address})` : `at ${s.address}`} is not on the wired network`)).join(' · ')}: use its address on ${netText}`,
|
||
};
|
||
}
|
||
const on = src.filter((s) => s.connected);
|
||
const lines = mappingErrors.length ? ` · ${mappingErrors.length} cue-list line${mappingErrors.length === 1 ? '' : 's'} not understood (first: line ${mappingErrors[0].line})` : '';
|
||
const map = rules.length ? '' : ' · no cue lists mapped yet';
|
||
if (on.length) {
|
||
const off = src.filter((s) => !s.connected).map((s) => `${s.label} (${s.error || 'joining…'})`);
|
||
return {
|
||
status: off.length ? 'connecting' : 'ok',
|
||
message: `In session with ${on.map((s) => s.label).join(', ')}${off.length ? ` · waiting for ${off.join(', ')}` : ''}${map}${lines}`,
|
||
};
|
||
}
|
||
const where = advertised ? `advertised as "${sessionName}"` : `port ${port}`;
|
||
if (src.length) {
|
||
return { status: 'connecting', message: `Joining ${src.map((s) => `${s.label}${s.error ? ` (${s.error})` : ''}`).join(', ')} · ${where}${map}${lines}` };
|
||
}
|
||
return { status: 'connecting', message: `Waiting for a MIDI session to connect · ${where}${map}${lines}` };
|
||
},
|
||
};
|
||
},
|
||
|
||
// For the tests.
|
||
_offNetwork: (text, address) => offNetwork(parseNets(text), address),
|
||
|
||
/**
|
||
* One picker entry, so the + menu gives Vista Cues a heading of its own —
|
||
* a module with no list sits headless under whichever module precedes it
|
||
* (Slack, alphabetically). Tiles added before this keep working: they
|
||
* have no variant and use the module's schema.
|
||
*/
|
||
tiles() {
|
||
return [{
|
||
id: 'live',
|
||
name: 'Live cue lists',
|
||
description: 'The main scene Vista is running, the FX on top, and what fired them',
|
||
defaultSize: { w: 5, h: 4 },
|
||
minSize: { w: 2, h: 1 },
|
||
}];
|
||
},
|
||
|
||
routes() {
|
||
const sendJson = (res, code, body) => {
|
||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||
res.end(JSON.stringify(body));
|
||
};
|
||
return {
|
||
'GET /state': (req, res) => sendJson(res, 200, { state: current ? current.getState() : emptyState() }),
|
||
'GET /stream': (req, res) => {
|
||
res.writeHead(200, {
|
||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||
'Cache-Control': 'no-cache, no-transform',
|
||
Connection: 'keep-alive',
|
||
'X-Accel-Buffering': 'no',
|
||
});
|
||
res.write('retry: 3000\n\n');
|
||
res.write(`event: state\ndata: ${JSON.stringify(current ? current.getState() : emptyState())}\n\n`);
|
||
if (!current) return void res.end();
|
||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||
const set = current.streams;
|
||
set.add(res);
|
||
req.on('close', () => set.delete(res));
|
||
},
|
||
'POST /clear': (req, res) => {
|
||
if (!current) return sendJson(res, 503, { error: 'Vista Cues is not running' });
|
||
current.clear();
|
||
return sendJson(res, 200, { ok: true });
|
||
},
|
||
};
|
||
},
|
||
};
|