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) => {