Vista Cues 1.1.0: a wired-network setting; a sender set by name or off it shows red #52

Merged
christian merged 1 commits from claude/vista-cues-wired-network into main 2026-10-20 13:27:46 -04:00
5 changed files with 118 additions and 3 deletions
Showing only changes of commit df2f0494f8 - Show all commits
+3 -1
View File
@@ -160,7 +160,7 @@ export default function create({ root, moduleApi }) {
el.sources.hidden = !on('showSources') || !s.sources.length;
if (!el.sources.hidden) {
el.sources.innerHTML = s.sources.map((x) => `
<span class="vc-src${x.connected ? ' is-on' : ''}" title="${esc(x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…'))}">
<span class="vc-src${x.connected ? ' is-on' : ''}${x.offNetwork ? ' is-off-network' : ''}" title="${esc((x.offNetwork ? `${x.offNetwork === 'by name' ? 'Set by name' : 'Not on the wired network'} (${s.wiredNetwork}) · ` : '') + (x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…')))}">
<span class="vc-dot"></span>${esc(x.label)}
</span>`).join('');
}
@@ -186,6 +186,8 @@ export default function create({ root, moduleApi }) {
if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI');
const connected = s.sources.filter((x) => x.connected);
const waiting = s.sources.filter((x) => !x.connected);
const off = s.sources.filter((x) => x.offNetwork);
if (off.length) return moduleApi.setStatus('error', `${off.map((x) => x.label).join(', ')} not on the wired network (${s.wiredNetwork})`);
if (!connected.length) {
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
return moduleApi.setStatus('error', why);
+8 -1
View File
@@ -1,7 +1,7 @@
{
"id": "vista-cues",
"name": "Vista Cues",
"version": "1.0.1",
"version": "1.1.0",
"proddash": ">=1.5.0",
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
"permissions": [
@@ -131,6 +131,13 @@
"default": true,
"group": "This machine",
"help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)."
},
"wiredNetwork": {
"type": "string",
"label": "Wired network",
"default": "",
"group": "This machine",
"help": "The network MIDI must come over, like 10.1.0.0/16 (several: comma between). A sender set by name or to an address outside it shows red here and on the tile; it still joins. Empty: any network."
}
},
"configGroups": {
+58 -1
View File
@@ -49,6 +49,44 @@ function emptyState(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 };
@@ -62,6 +100,8 @@ module.exports = {
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}`);
}
@@ -187,6 +227,9 @@ module.exports = {
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 || ''),
@@ -195,7 +238,7 @@ module.exports = {
}
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, configured: false, connected: true, error: '', lastHeard: p.lastHeard });
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;
}
@@ -219,6 +262,7 @@ module.exports = {
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.
@@ -304,6 +348,16 @@ module.exports = {
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';
@@ -323,6 +377,9 @@ module.exports = {
};
},
// 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
+2
View File
@@ -180,6 +180,8 @@
}
[data-module="vista-cues"] .vc-src.is-on { color: var(--text); }
[data-module="vista-cues"] .vc-src.is-on .vc-dot { background: var(--accent); }
[data-module="vista-cues"] .vc-src.is-off-network { color: var(--danger); }
[data-module="vista-cues"] .vc-src.is-off-network .vc-dot { background: var(--danger); }
@container (max-height: 140px) {
[data-module="vista-cues"] .vc-sources { display: none; }
}
+47
View File
@@ -363,6 +363,53 @@ async function freePair() {
await wait(80);
});
await test('wired network: addresses outside it, and names, are flagged', () => {
const mod = require('./server');
const off = mod._offNetwork;
assert.strictEqual(off('', '10.6.9.193'), ''); // no rule: any network
assert.strictEqual(off('10.1.0.0/16', '10.1.3.227'), '');
assert.strictEqual(off('10.1.0.0/16', '10.6.9.193'), 'not wired'); // Wi-Fi
assert.strictEqual(off('10.1.0.0/16', '169.254.12.3'), 'not wired'); // Dante
assert.strictEqual(off('10.1.0.0/16', 'NA-Tracks.local'), 'by name');
assert.strictEqual(off('10.1.0.0/16', '127.0.0.1'), ''); // Companion on this machine
assert.strictEqual(off('10.1.0.0/16, 10.6.0.0/20', '10.6.9.193'), ''); // several
assert.strictEqual(off('10.1.3.227', '10.1.3.227'), ''); // a single address
assert.throws(() => off('wifi', '10.1.3.227'), /not a network like 10\.1\.0\.0\/16/);
assert.throws(() => off('10.1.0.0/40', '10.1.3.227'), /not a network/);
});
await test('server entry: a sender off the wired network still joins, and says so', async () => {
const senderPort = await freePair();
const sender = await fakeSender(senderPort, 'Fake Tracks');
const port = await freePair();
const mod = require('./server');
assert.throws(() => mod.init({ config: { sessionPort: port, advertise: false, wiredNetwork: '10.1/16' }, log: () => {} }), /Wired network/);
const handle = mod.init({
config: {
sessionPort: port,
advertise: false,
wiredNetwork: '10.1.0.0/16',
source2Name: 'Tracks',
source2: { host: 'localhost', port: senderPort }, // a name: it may resolve to Wi-Fi next time
cueLists: '60 = Walk-in',
},
log: () => {},
});
await until(() => sender.peers.length > 0); // joined all the same
const h = handle.health();
assert.strictEqual(h.status, 'error');
assert.match(h.message, /^Tracks is set by name \(localhost\): use its address on 10\.1\.0\.0\/16$/);
const routes = mod.routes({});
let body = '';
await new Promise((r) => routes['GET /state']({}, { writeHead() {}, end(b) { body = b; r(); } }));
const st = JSON.parse(body).state;
assert.strictEqual(st.wiredNetwork, '10.1.0.0/16');
assert.strictEqual(st.sources[0].offNetwork, 'by name');
handle.stop();
sender.close();
await wait(80);
});
console.log(`\n${passed} passed`);
process.exit(0);
})().catch((e) => {