Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Companion OSC Module Tests

on:
push:
branches: ["**"]
pull_request:
branches: ["**"]

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v5

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22.18.0"
cache: "yarn"

- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Run unit tests
run: yarn mocha tests.js
11 changes: 10 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
package.json
pkg
*.tgz
*.tgz
.github/
*.yaml
*.yml
*.json
node_modules/
dist/
*.lock
*.DS_Store
*.md
2 changes: 2 additions & 0 deletions companion/HELP.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ In instaces tab specify the ip and port you want to send. In button actions tab
- Send message with multiple arguments
- Send boolean (this is not part of OSC standard and may only work with some receivers)
- Send blob (Base64 & Hex)
- Send MIDI message (OSC MIDI)

**Available feedback for OSC Generic:**

Expand All @@ -20,6 +21,7 @@ In instaces tab specify the ip and port you want to send. In button actions tab
- Listen for OSC messages (Multiple Arguments)
- Listen for OSC messages (Specific Arguments)
- Listen for OSC messages (No Arguments)
- Listen for OSC messages (OSC MIDI)

**Available variables for OSC Generic:**

Expand Down
57 changes: 56 additions & 1 deletion helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ function setupOSC(instance) {
instance.client = new OSCUDPClient(
instance,
instance.targetHost,
instance.config.targetPort,
instance.config.feedbackPort,
instance.config.listen,
);
Expand All @@ -96,4 +97,58 @@ function setupOSC(instance) {
}
}

module.exports = { resolveHostname, isValidIPAddress, parseArguments, evaluateComparison, setupOSC };
const clampInt = (v, min, max) => {
const n = Number(v);
if (!Number.isFinite(n)) return null;
const i = Math.trunc(n);
if (i < min || i > max) return null;
return i;
};

const parseHexByte = (s) => {
const cleaned = String(s || '')
.trim()
.replace(/^0x/i, '');
if (!/^[0-9a-fA-F]{1,2}$/.test(cleaned)) return null;
return parseInt(cleaned, 16);
};

const parseHexBytes = (s, expectedLen) => {
const cleaned = String(s || '')
.trim()
.replace(/,/g, ' ')
.replace(/\s+/g, ' ')
.split(' ')
.filter(Boolean);

if (cleaned.length !== expectedLen) return null;

const bytes = cleaned.map((b) => parseHexByte(b));
if (bytes.some((b) => b === null)) return null;

return Buffer.from(bytes);
};

const midiTypeFromStatus = (status) => {
const hi = status & 0xf0;
if (hi === 0x80) return 'noteoff';
if (hi === 0x90) return 'noteon';
if (hi === 0xa0) return 'polyaftertouch';
if (hi === 0xb0) return 'cc';
if (hi === 0xc0) return 'program';
if (hi === 0xd0) return 'channelpressure';
if (hi === 0xe0) return 'pitchbend';
return 'unknown';
};

module.exports = {
resolveHostname,
isValidIPAddress,
parseArguments,
evaluateComparison,
setupOSC,
clampInt,
parseHexByte,
parseHexBytes,
midiTypeFromStatus,
};
89 changes: 64 additions & 25 deletions osc-udp.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ const dgram = require('dgram');
const { onDataHandler } = require('./osc-feedback.js');

class OSCUDPClient {
constructor(root, host, port, listen) {
constructor(root, host, remotePort, localPort, listen) {
this.root = root;
this.host = host;
this.port = port;
this.remotePort = remotePort;
this.localPort = localPort;
this.listen = listen;
this.udpPort = null;
this.connected = false;
Expand All @@ -16,7 +17,7 @@ class OSCUDPClient {
openConnection() {
if (this.connected) {
this.root.log('info', 'UDP connection is already open');
return;
return Promise.resolve();
}

return new Promise((resolve, reject) => {
Expand All @@ -25,7 +26,11 @@ class OSCUDPClient {

this.socket.on('error', (err) => {
this.root.log('warn', `Socket error: ${err.message}`);
this.socket.close();
try {
this.socket.close();
} catch (_) {
// ignore
}
this.connected = false;
this.root.updateStatus('connection_failure');
reject(new Error(`Socket error: ${err.message}`));
Expand All @@ -44,10 +49,30 @@ class OSCUDPClient {
}
});

this.socket.bind({ address: '0.0.0.0', port: this.port }, () => {
this.root.log('info', `Listening for OSC messages on port ${this.port} with SO_REUSEPORT`);
// If not listening, do not bind => ephemeral source port.
// This is valid for sending-only use cases.
if (!this.listen) {
this.connected = true;
this.root.updateStatus('ok');
this.root.log('info', 'UDP socket opened (ephemeral source port; not bound)');
resolve();
return;
}

// If listening, bind to localPort => fixed source port for replies.
// (Devices that "reply to sender" will send back to this port.)
const lp = Number(this.localPort);
if (!Number.isInteger(lp) || lp <= 0 || lp > 65535) {
this.connected = false;
this.root.updateStatus('connection_failure');
reject(new Error('localPort must be a valid UDP port (1-65535) when listen=true'));
return;
}

this.socket.bind({ address: '0.0.0.0', port: lp }, () => {
this.connected = true;
this.root.updateStatus('ok');
this.root.log('info', `Listening for OSC messages on 0.0.0.0:${lp} (fixed source port)`);
resolve();
});
});
Expand All @@ -56,11 +81,15 @@ class OSCUDPClient {
closeConnection() {
if (!this.socket || !this.connected) {
this.root.log('debug', 'No UDP connection to close');
return;
return Promise.resolve();
}

return new Promise((resolve, reject) => {
this.socket.close();
return new Promise((resolve) => {
try {
this.socket.close();
} catch (_) {
// ignore
}
this.connected = false;
this.root.log('info', 'UDP connection closed manually');

Expand All @@ -72,8 +101,7 @@ class OSCUDPClient {
});
}

//Even though it is defined, this code is not used - as Companion's internal OSC sender is still used.
async sendCommand(command, args) {
async sendCommand(command, args = []) {
if (!this.connected) {
this.root.log('info', 'No open UDP connection. Opening connection now...');
await this.openConnection();
Expand All @@ -84,30 +112,41 @@ class OSCUDPClient {
const message = osc.writePacket(
{
address: command,
args: args, // Ensure args have correct type and value fields
args: args,
},
{ metadata: true },
);

this.socket.send(message, 0, message.byteLength, this.port, this.host, (err) => {
// Send to REMOTE destination port (remotePort), NOT the bound localPort
this.socket.send(message, 0, message.byteLength, this.remotePort, this.host, (err) => {
if (err) {
this.root.log('warn', `Error sending OSC message: ${err.message}`);
reject(new Error(err.message));
} else {
this.root.log('debug', `Sent command: ${command} with args: ${JSON.stringify(args)}`);

//Update Variables
const args_string = args.map((item) => item.value).join(' ');
return;
}

this.root.setVariableValues({
latest_sent_raw: `${command} ${args_string}`,
latest_sent_path: command,
latest_sent_args: args.length ? args.map((arg) => arg.value) : undefined,
latest_sent_timestamp: Date.now(),
});
this.root.log('debug', `Sent command: ${command} with args: ${JSON.stringify(args)}`);

resolve();
// If we did not bind (listen=false), we can still report what ephemeral port got chosen
// once the socket has been used.
let localRememberedPort;
try {
localRememberedPort = this.socket?.address?.()?.port;
} catch (_) {
localRememberedPort = undefined;
}

// Update Variables (keep same variable keys as your previous version)
const args_string = (args || []).map((item) => item?.value).join(' ');

this.root.setVariableValues({
latest_sent_raw: `${command} ${args_string}`.trim(),
latest_sent_path: command,
latest_sent_args: args?.length ? args.map((arg) => arg.value) : undefined,
latest_sent_timestamp: Date.now(),
});

resolve();
});
} catch (err) {
this.root.log('warn', `Error sending OSC message: ${err.message}`);
Expand Down
Loading
Loading