Merge pull request 'Vista Cues 1.1.0: a wired-network setting; a sender set by name or off it shows red' (#52) from claude/vista-cues-wired-network into main
This commit was merged in pull request #52.
This commit is contained in:
@@ -160,7 +160,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
el.sources.hidden = !on('showSources') || !s.sources.length;
|
el.sources.hidden = !on('showSources') || !s.sources.length;
|
||||||
if (!el.sources.hidden) {
|
if (!el.sources.hidden) {
|
||||||
el.sources.innerHTML = s.sources.map((x) => `
|
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 class="vc-dot"></span>${esc(x.label)}
|
||||||
</span>`).join('');
|
</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');
|
if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI');
|
||||||
const connected = s.sources.filter((x) => x.connected);
|
const connected = s.sources.filter((x) => x.connected);
|
||||||
const waiting = 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) {
|
if (!connected.length) {
|
||||||
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
|
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
|
||||||
return moduleApi.setStatus('error', why);
|
return moduleApi.setStatus('error', why);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "vista-cues",
|
"id": "vista-cues",
|
||||||
"name": "Vista Cues",
|
"name": "Vista Cues",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"proddash": ">=1.5.0",
|
"proddash": ">=1.5.0",
|
||||||
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
|
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@@ -131,6 +131,13 @@
|
|||||||
"default": true,
|
"default": true,
|
||||||
"group": "This machine",
|
"group": "This machine",
|
||||||
"help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)."
|
"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": {
|
"configGroups": {
|
||||||
|
|||||||
@@ -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) {
|
function endpointOf(v) {
|
||||||
const ep = v && typeof v === 'object' ? v : {};
|
const ep = v && typeof v === 'object' ? v : {};
|
||||||
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
|
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
|
||||||
@@ -62,6 +100,8 @@ module.exports = {
|
|||||||
const exclusive = config.exclusive === true;
|
const exclusive = config.exclusive === true;
|
||||||
const headTakesOver = config.headTakesOver !== false;
|
const headTakesOver = config.headTakesOver !== false;
|
||||||
const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC });
|
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) {
|
if (mappingErrors.length) {
|
||||||
for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`);
|
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,
|
label: s.label,
|
||||||
name: p ? p.name : '',
|
name: p ? p.name : '',
|
||||||
address: inv?.address || s.host,
|
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,
|
configured: true,
|
||||||
connected: Boolean(p),
|
connected: Boolean(p),
|
||||||
error: p ? '' : (inv?.error || ''),
|
error: p ? '' : (inv?.error || ''),
|
||||||
@@ -195,7 +238,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
for (const p of session.participants.values()) {
|
for (const p of session.participants.values()) {
|
||||||
if (seen.has(p.key) || !p.connected) continue;
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -219,6 +262,7 @@ module.exports = {
|
|||||||
sessionName,
|
sessionName,
|
||||||
port,
|
port,
|
||||||
advertised,
|
advertised,
|
||||||
|
wiredNetwork: netText,
|
||||||
sources: sources(),
|
sources: sources(),
|
||||||
current: cur || null,
|
current: cur || null,
|
||||||
// The rest in sheet order of their group, so a colour chip stays put as it changes.
|
// The rest in sheet order of their group, so a colour chip stays put as it changes.
|
||||||
@@ -304,6 +348,16 @@ module.exports = {
|
|||||||
health() {
|
health() {
|
||||||
if (session.error) return { status: 'error', message: session.error };
|
if (session.error) return { status: 'error', message: session.error };
|
||||||
const src = sources();
|
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 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 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';
|
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 —
|
* 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
|
* a module with no list sits headless under whichever module precedes it
|
||||||
|
|||||||
@@ -180,6 +180,8 @@
|
|||||||
}
|
}
|
||||||
[data-module="vista-cues"] .vc-src.is-on { color: var(--text); }
|
[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-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) {
|
@container (max-height: 140px) {
|
||||||
[data-module="vista-cues"] .vc-sources { display: none; }
|
[data-module="vista-cues"] .vc-sources { display: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,6 +363,53 @@ async function freePair() {
|
|||||||
await wait(80);
|
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`);
|
console.log(`\n${passed} passed`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})().catch((e) => {
|
})().catch((e) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user