diff --git a/plugins/dpa/icon.png b/plugins/dpa/icon.png new file mode 100644 index 0000000..bb828bb Binary files /dev/null and b/plugins/dpa/icon.png differ diff --git a/plugins/dpa/info.html b/plugins/dpa/info.html new file mode 100644 index 0000000..ed6abea --- /dev/null +++ b/plugins/dpa/info.html @@ -0,0 +1,8 @@ +

DPA N-Series Digital Wireless

+

Connects to a DPA N-Series wireless receiver (e.g. N-DR1) via OSC over TCP.

+ +

Enable TCP control on the device under Advanced → LAN.

diff --git a/plugins/dpa/main.js b/plugins/dpa/main.js new file mode 100644 index 0000000..1f27405 --- /dev/null +++ b/plugins/dpa/main.js @@ -0,0 +1,155 @@ +const osc = require('osc'); + +exports.config = { + defaultName: 'DPA N-Series', + connectionType: 'osc-tcp', + defaultPort: 1993, + mayChangePorts: true, + heartbeatInterval: 5000, + heartbeatTimeout: 10000, + searchOptions: { + type: 'TCPport', + // OSC /model message with SLIP framing: END + "/model\0\0" + ",\0\0\0" + END + searchBuffer: Buffer.from([ + 0xc0, 0x2f, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0xc0, + ]), + testPort: 1993, + validateResponse(msg) { + const str = msg.toString(); + return str.includes('/model') || str.includes('N-DR'); + }, + }, +}; + +function makeChannel() { + return { + name: '–', + audioLevel: -80, + txActive: false, + txName: '', + battCapacity: null, + battTimeMin: null, + battHealth: null, + rfA: -130, + rfB: -130, + warnings: {}, + }; +} + +function subscribeToAddress(device, address, intervalMs) { + const msgBlob = osc.writePacket({ address, args: [] }); + device.send('/subscribe', [ + { type: 'i', value: intervalMs }, + { type: 'i', value: 0 }, + { type: 'b', value: msgBlob }, + ]); +} + +exports.ready = function ready(_device) { + const device = _device; + device.data.model = ''; + device.data.channels = [null, makeChannel(), makeChannel()]; + device.data.warnings = {}; + + device.send('/settings/retrieve'); + device.send('/model'); + + subscribeToAddress(device, '/ch/1/audiolevel', 100); + subscribeToAddress(device, '/ch/2/audiolevel', 100); + subscribeToAddress(device, '/advanced/1/antenna/rfstrength', 200); + subscribeToAddress(device, '/advanced/2/antenna/rfstrength', 200); + subscribeToAddress(device, '/ch/1/tx/active', 500); + subscribeToAddress(device, '/ch/2/tx/active', 500); + subscribeToAddress(device, '/ch/1/tx/name', 5000); + subscribeToAddress(device, '/ch/2/tx/name', 5000); + subscribeToAddress(device, '/ch/1/tx/batterystatus', 1000); + subscribeToAddress(device, '/ch/2/tx/batterystatus', 1000); +}; + +exports.data = function data(_device, message) { + const device = _device; + this.deviceInfoUpdate(device, 'status', 'ok'); + + const { address, args } = message; + if (!address) return; + + const parts = address.split('/').filter(Boolean); + + if (address === '/model') { + if (args && args[0]) { + device.data.model = args[0]; + this.deviceInfoUpdate(device, 'defaultName', args[0]); + } + } else if (address === '/heartbeat') { + device.draw(); + } else if (parts[0] === 'ch' && parts.length >= 3) { + const chNum = parseInt(parts[1], 10); + if (chNum < 1 || chNum > 2) return; + const ch = device.data.channels[chNum]; + const sub = parts.slice(2).join('/'); + + if (sub === 'name') { + ch.name = (args && args[0]) ? args[0] : '–'; + device.draw(); + } else if (sub === 'audiolevel') { + ch.audioLevel = (args && args[0] !== undefined) ? args[0] / 10 : -80; + device.update('meterUpdate', { channels: device.data.channels }); + } else if (sub === 'tx/active') { + ch.txActive = args && args[0] === 1; + device.draw(); + } else if (sub === 'tx/name') { + ch.txName = (args && args[0]) ? args[0] : ''; + device.draw(); + } else if (sub === 'tx/batterystatus') { + if (args) { + ch.battCapacity = args[0] !== undefined ? args[0] : null; + ch.battTimeMin = args[1] !== undefined ? args[1] : null; + ch.battHealth = args[2] !== undefined ? args[2] : null; + } + device.draw(); + } + } else if (parts[0] === 'advanced' && parts[2] === 'antenna' && parts[3] === 'rfstrength') { + const chNum = parseInt(parts[1], 10); + if (chNum < 1 || chNum > 2) return; + const ch = device.data.channels[chNum]; + ch.rfA = (args && args[1] !== undefined) ? args[1] / 10 : -130; + ch.rfB = (args && args[2] !== undefined) ? args[2] / 10 : -130; + device.update('meterUpdate', { channels: device.data.channels }); + } else if (parts[0] === 'warning') { + if (parts.length === 2) { + device.data.warnings[parts[1]] = args && args[0] === 1; + device.draw(); + } else if (parts.length === 3) { + const chNum = parseInt(parts[1], 10); + if (chNum >= 1 && chNum <= 2) { + device.data.channels[chNum].warnings[parts[2]] = args && args[0] === 1; + device.draw(); + } + } + } +}; + +exports.update = function update(device, doc, updateType, data) { + for (let i = 1; i <= 2; i++) { + const ch = data.channels[i]; + + // Audio level bar: -126 dBFS = empty, 0 dBFS = full + const audioOverlay = Math.max(0, Math.min(90, (1 - (ch.audioLevel + 126) / 126) * 90)); + const $audio = doc.getElementById(`ch-${i}-audio`); + if ($audio) $audio.style.height = `${audioOverlay}px`; + const $audioText = doc.getElementById(`ch-${i}-audio-text`); + if ($audioText) $audioText.textContent = `${ch.audioLevel}`; + + // RF bars: -130 to -50 dBm range (80 dBm span) + const rfANorm = Math.max(0, Math.min(1, (ch.rfA + 130) / 80)); + const rfBNorm = Math.max(0, Math.min(1, (ch.rfB + 130) / 80)); + const $rfA = doc.getElementById(`ch-${i}-rfa`); + if ($rfA) $rfA.style.height = `${(1 - rfANorm) * 90}px`; + const $rfB = doc.getElementById(`ch-${i}-rfb`); + if ($rfB) $rfB.style.height = `${(1 - rfBNorm) * 90}px`; + } +}; + +exports.heartbeat = function heartbeat(device) { + device.send('/settings/retrieve'); +}; diff --git a/plugins/dpa/styles.css b/plugins/dpa/styles.css new file mode 100644 index 0000000..c20ca3e --- /dev/null +++ b/plugins/dpa/styles.css @@ -0,0 +1,180 @@ +/* DPA color palette: #202020 bg, #333 surface, #5b5b5b secondary, + #a0a0a0 border/muted text, #ddd primary text, #cedc00 brand accent */ + +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 300; + src: url(../../src/assets/font/Lato-Light.ttf) format('truetype'); +} +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 400; + src: url(../../src/assets/font/Lato-Regular.ttf) format('truetype'); +} +@font-face { + font-family: 'Lato'; + font-style: normal; + font-weight: 700; + src: url(../../src/assets/font/Lato-Bold.ttf) format('truetype'); +} + +table.channel { + width: 140px; + table-layout: fixed; + height: 340px; + background-color: #333; + border: 1px solid #a0a0a0; + border-collapse: collapse; + color: #ddd; + font-family: Lato, Roboto, sans-serif; + text-align: center; + float: left; + margin: 2px; + margin-bottom: 20px; +} + +table td { + border: 1px solid #5b5b5b; + vertical-align: top; +} + +.chan-name { + color: #cedc00; + font-size: 18px; + font-weight: bold; + padding: 2px 4px; +} + +.tx-row { + font-size: 11px; + padding: 2px 4px; + color: #a0a0a0; + white-space: nowrap; + overflow: hidden; +} + +.tx-dot { + color: #5b5b5b; +} + +.tx-dot.active { + color: #cedc00; +} + +.bar-wrapper { + width: 20px; + height: 90px; + margin: 4px auto; + border-radius: 4px; + outline: #202020 2px solid; + outline-offset: -1px; + overflow: hidden; +} + +.bar-value { + font-variant-numeric: tabular-nums; + display: block; +} + +/* RF bars: DPA blue */ +.bar-wrapper.rf-color { + background: linear-gradient(0deg, #1a2a80, #485CE5 40%, #485CE5 80%, #6b7fff); +} + +/* Audio bars: subtle green to accent */ +.bar-wrapper.audio-color { + background: linear-gradient(0deg, #6b7a00, #a8b400 30%, #cedc00 100%); +} + +.bar { + width: 100%; + background-color: #202020; + outline: rgba(255, 255, 255, 0.05) 1px solid; +} + +.batt-wrapper { + width: 104px; + height: 35px; + margin: 8px auto; + border: 2px solid #5b5b5b; + border-radius: 4px; + padding: 2px; + position: relative; + box-sizing: content-box; +} + +.batt-wrapper.low { + border-color: red; +} + +.batt-knob { + background-color: #5b5b5b; + position: absolute; + right: -5px; + top: 10px; + width: 5px; + height: 16px; + border-radius: 2px; +} + +.batt-knob.low { + background-color: red; +} + +.batt-bar { + height: 100%; + background: linear-gradient(90deg, #a8b400, #cedc00); + border-radius: 2px; + font-size: 10px; + color: #202020; + font-weight: bold; + white-space: nowrap; + overflow: hidden; + display: flex; + align-items: center; + padding-left: 3px; + box-sizing: border-box; +} + +.batt-wrapper.low .batt-bar { + background: red; + color: #fff; +} + +.warnings { + padding: 2px 3px; + height: 32px; +} + +.warn { + display: inline-block; + font-size: 12px; + font-weight: bold; + border-radius: 2px; + padding: 1px 3px; + margin: 1px; +} + +.warn.alert { + background-color: red; + color: #fff; +} + +.warn.warning { + background-color: #c07000; + color: #fff; +} + +.warn.notify { + background-color: #5b5b5b; + color: #ddd; +} + +.device-warnings { + clear: both; + padding: 4px; + margin-top: 4px; + font-family: Lato, Roboto, sans-serif; +} diff --git a/plugins/dpa/template.ejs b/plugins/dpa/template.ejs new file mode 100644 index 0000000..1404cdd --- /dev/null +++ b/plugins/dpa/template.ejs @@ -0,0 +1,85 @@ +
+

<%= listName %>

+

<%= data.model %>

+
+ +<% for (var i = 1; i <= 2; i++) { %> +<% var ch = data.channels[i]; %> + + + + + + + + + + + + + + + + + + + + + +
CH <%= i %>
<%= ch.name %>
+ <% if (ch.txActive) { %> + <%= ch.txName || 'TX' %> + <% } else { %> + No TX + <% } %> +
+ A +
+
+
+ <%= Math.round(ch.rfA) %> +
+ B +
+
+
+ <%= Math.round(ch.rfB) %> +
+ audio +
+
+
+ <%= ch.audioLevel %>
dBFS
+
+ <% if (ch.battCapacity !== null) { %> +
+
+ <% if (ch.battTimeMin !== null && ch.battTimeMin < 65535) { %> + <%= Math.floor(ch.battTimeMin / 60) %>:<%= String(ch.battTimeMin % 60).padStart(2, '0') %> + <% } else { %> + <%= ch.battCapacity %>% + <% } %> +
+
+
+ <% } else { %> +
+ <% } %> +
+ <% if (ch.warnings.txdropout) { %>DROPOUT<% } %> + <% if (ch.warnings.lowbattery) { %>LOW BATT<% } %> + <% if (ch.warnings.interference) { %>INTERF<% } %> + <% if (ch.warnings.afoverload) { %>OVERLOAD<% } %> + <% if (ch.warnings.antennashorted) { %>ANT SHORT<% } %> + <% if (ch.warnings.txsyncmismatch) { %>SYNC MISM<% } %> +
+<% } %> + +<% if (data.warnings.hightemp || data.warnings.criticaltemp || data.warnings.rfoverload || data.warnings.fan) { %> +
+ <% if (data.warnings.criticaltemp) { %>CRIT TEMP<% } %> + <% if (data.warnings.hightemp) { %>HIGH TEMP<% } %> + <% if (data.warnings.rfoverload) { %>RF OVERLOAD<% } %> + <% if (data.warnings.fan) { %>FAN<% } %> +
+<% } %> diff --git a/src/device.js b/src/device.js index 50101ee..b935fba 100644 --- a/src/device.js +++ b/src/device.js @@ -110,7 +110,55 @@ function initDeviceConnection(id) { const { type } = devices[id]; const plugins = PLUGINS.all; - if (plugins[type].config.connectionType.includes('osc')) { + if (plugins[type].config.connectionType === 'osc-tcp') { + // OSC over TCP with 4-byte big-endian size prefix + device.connection = new net.Socket(); + let recvBuf = Buffer.alloc(0); + device.connection.connect({ port: device.remotePort, host: device.addresses[0] }); + device.connection.on('error', () => { + device.connection.destroy(); + }); + device.connection.on('connect', () => { + plugins[type].ready(device); + if (Object.keys(devices).length === 1) { + VIEW.switchDevice(device.id); + } + }); + device.connection.on('data', (chunk) => { + recvBuf = Buffer.concat([recvBuf, chunk]); + while (recvBuf.length >= 4) { + const msgLen = recvBuf.readUInt32BE(0); + if (recvBuf.length < 4 + msgLen) break; + const msgBytes = recvBuf.slice(4, 4 + msgLen); + recvBuf = recvBuf.slice(4 + msgLen); + try { + const message = osc.readPacket(msgBytes, {}); + plugins[type].data(device, message); + } catch (err) { + console.error(err); + } + device.trafficSignal(device); + device.lastMessage = Date.now(); + } + }); + device.connection.on('close', () => { + infoUpdate(device, 'status', 'broken'); + }); + device.send = (address, args) => { + device.sendQueue.push({ address, args }); + }; + device.sendNow = (data) => { + try { + const bytes = osc.writePacket(data, {}); + const buf = Buffer.allocUnsafe(4 + bytes.length); + buf.writeUInt32BE(bytes.length, 0); + Buffer.from(bytes).copy(buf, 4); + device.connection.write(buf); + } catch (err) { + console.error(err); + } + }; + } else if (plugins[type].config.connectionType.includes('osc')) { if (plugins[type].config.connectionType.includes('udp')) { device.connection = new osc.UDPPort({ localAddress: '0.0.0.0', @@ -122,6 +170,7 @@ function initDeviceConnection(id) { device.connection = new osc.TCPSocketPort({ address: device.addresses[0], port: device.remotePort, + useSLIP: true, }); } device.connection.open();