From df2f0494f8f95a2cf1ffd8ec083054952934bad9 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 20 Oct 2026 13:14:44 -0400 Subject: [PATCH] Vista Cues 1.1.0: a wired-network setting; a sender set by name or off it shows red 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 --- modules/vista-cues/client.js | 4 ++- modules/vista-cues/module.json | 9 ++++- modules/vista-cues/server.js | 59 +++++++++++++++++++++++++++++++- modules/vista-cues/style.css | 2 ++ modules/vista-cues/vista.test.js | 47 +++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 3 deletions(-) diff --git a/modules/vista-cues/client.js b/modules/vista-cues/client.js index 83861de..4a12066 100644 --- a/modules/vista-cues/client.js +++ b/modules/vista-cues/client.js @@ -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) => ` - + ${esc(x.label)} `).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); diff --git a/modules/vista-cues/module.json b/modules/vista-cues/module.json index 7021f7c..5ecd390 100644 --- a/modules/vista-cues/module.json +++ b/modules/vista-cues/module.json @@ -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": { diff --git a/modules/vista-cues/server.js b/modules/vista-cues/server.js index 9c04c10..dab01d1 100644 --- a/modules/vista-cues/server.js +++ b/modules/vista-cues/server.js @@ -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 diff --git a/modules/vista-cues/style.css b/modules/vista-cues/style.css index b6eae02..2593188 100644 --- a/modules/vista-cues/style.css +++ b/modules/vista-cues/style.css @@ -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; } } diff --git a/modules/vista-cues/vista.test.js b/modules/vista-cues/vista.test.js index 3b3f300..3093bc4 100644 --- a/modules/vista-cues/vista.test.js +++ b/modules/vista-cues/vista.test.js @@ -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) => {