Feature: DPA N-Series (#372)

* Feature: DPA N-Series support

Connects to N-Series receivers (e.g. N-DR1) via OSC over TCP.
Displays audio level, RF strength (A/B), battery status and
warnings for both channels.

* Add Lato font (bundled, offline-safe) to DPA plugin

* Add DPA plugin icon

* Fix: TCP framing, correct meter scaling

- Fix osc-tcp connection type being caught by osc SLIP handler in device.js
- Add subscriptions for tx/active, tx/name, tx/batterystatus
- Fix audio meter formula direction (was inverted)
- Scale audiolevel and rfstrength by /10 (values are dB×10)

* Fix: CSS
This commit is contained in:
Linus Wileryd
2026-05-15 16:30:25 +02:00
committed by GitHub
parent c3ddbd8353
commit 84c0921943
6 changed files with 478 additions and 1 deletions
+155
View File
@@ -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');
};