artnet-proto
Version:
A TypeScript library implementing the Art-Net protocol
445 lines (444 loc) • 15.6 kB
JavaScript
import { createSocket } from "node:dgram";
import { EventEmitter } from "node:events";
/**
* Implements the Art-Net v4 protocol for DMX-over-IP communication.
* Reference: https://art-net.org.uk/downloads/art-net.pdf
*/
export class Artnet extends EventEmitter {
host;
port;
refresh;
sendAll;
socket;
data = [];
interval = [];
sendThrottle = [];
sendDelayed = [];
dataChanged = [];
discoveredNodes = [];
constructor(config = {}) {
super();
this.host = config.host ?? "255.255.255.255";
this.port = config.port ?? 6454;
this.refresh = config.refresh ?? 4000;
this.sendAll = config.sendAll ?? false;
this.socket = createSocket({ type: "udp4", reuseAddr: true });
this.socket.on("error", (err) => {
this.emit("error", err);
});
if (config.interface && this.host === "255.255.255.255") {
this.socket.bind(this.port, config.interface, () => {
this.socket.setBroadcast(true);
});
}
else if (this.host.endsWith(".255")) {
this.socket.bind(this.port, () => {
this.socket.setBroadcast(true);
});
}
}
/**
* Send an Art-Net trigger packet.
* Reference: Art-Net 4 Spec, p40
*/
sendTrigger(oem, key, subKey, callback) {
const buf = this.triggerPackage(oem, key, subKey);
this.socket.send(buf, 0, buf.length, this.port, this.host, callback);
}
triggerPackage(oem, key, subkey) {
// See Art-Net 4, p40 for structure
const hOem = (oem >> 8) & 0xff;
const lOem = oem & 0xff;
const header = [
65,
114,
116,
45,
78,
101,
116,
0,
0,
153,
0,
14,
0,
0,
hOem,
lOem,
key,
subkey,
];
const payload = Array(512).fill(0);
return Buffer.from(header.concat(payload));
}
startRefresh(universe) {
this.interval[universe] = setInterval(() => {
this.send(universe, 512);
}, this.refresh);
}
/**
* Send DMX data as ArtDmx packet.
* Reference: Art-Net 4 Spec, p45 (ArtDmx Packet Definition)
*/
send(universe, refresh = false, callback) {
// Handle overload: send(universe, callback)
if (typeof refresh === "function") {
callback = refresh;
refresh = false;
}
// Global override to always send all channels
if (this.sendAll) {
refresh = true;
}
// Start refresh timer for this universe if needed
if (!this.interval[universe]) {
this.startRefresh(universe);
}
// Handle throttling
if (this.sendThrottle[universe]) {
this.sendDelayed[universe] = true;
return;
}
clearTimeout(this.sendThrottle[universe]);
// Capture args in closure for the throttle timeout
const sendRefresh = refresh;
const sendCallback = callback;
this.sendThrottle[universe] = setTimeout(() => {
this.sendThrottle[universe] = undefined;
if (this.sendDelayed[universe]) {
this.sendDelayed[universe] = false;
this.send(universe, sendRefresh, sendCallback);
}
}, 25);
// Enforce minimum length
let length;
if (refresh) {
length = 512;
}
else {
// If no changes recorded, still send full 512 for safety
length =
this.dataChanged[universe] && this.dataChanged[universe] > 0
? this.dataChanged[universe]
: 512;
}
// Build packet and reset change counter
const buf = this.artdmxPackage(universe, length);
this.dataChanged[universe] = 0;
// Send the packet
this.socket.send(buf, 0, buf.length, this.port, this.host, (err, bytes) => {
if (typeof callback === "function") {
callback(err, typeof bytes === "number" ? bytes : buf.length);
}
});
}
artdmxPackage(universe, length = 2) {
// Length must be even per Art-Net spec
if (length % 2) {
length++;
}
const hUni = (universe >> 8) & 0xff;
const lUni = universe & 0xff;
const hLen = (length >> 8) & 0xff;
const lLen = length & 0xff;
// Art-Net 4, p45 (ArtDmx Packet)
const header = [
65,
114,
116,
45,
78,
101,
116,
0, // "Art-Net" + null terminator
0,
80, // OpCode = OpOutput / OpDmx (0x5000 little endian)
0,
14, // Protocol Version (14 decimal)
0,
0, // Sequence + Physical (set to zero for auto)
lUni,
hUni, // Universe (little endian)
hLen,
lLen, // Length (DMX length)
];
if (!this.data[universe]) {
this.data[universe] = Array(512).fill(0);
}
const lengthBytes = hLen * 256 + lLen;
// Ensure we only send numbers 0–255 (replace null with 0)
const cleanDmx = this.data[universe]
.slice(0, lengthBytes)
.map((value) => value ?? 0);
return Buffer.from(header.concat(cleanDmx));
}
/**
* Set DMX channel(s) to value(s). Can be single value or array.
* @example set(1, 1, 255) // universe 1, channel 1 = 255
*/
set(arg1, arg2, arg3, arg4) {
let universe = 0;
let channel = 1;
let value;
let callback;
if (typeof arg1 === "number" &&
typeof arg2 === "number" &&
(typeof arg3 === "number" || Array.isArray(arg3))) {
// set(universe, channel, value/arr, [callback])
universe = arg1;
channel = arg2;
value = arg3;
callback = arg4;
}
else if (typeof arg1 === "number" &&
(typeof arg2 === "number" || Array.isArray(arg2))) {
// set(channel, value/arr, [callback])
channel = arg1;
value = arg2;
callback = arg3;
}
else {
// set(value/arr, [callback])
channel = 1;
value = arg1;
callback = arg2;
}
// Ensure universe exists
if (!this.data[universe]) {
this.data[universe] = Array(512).fill(0);
}
this.dataChanged[universe] = this.dataChanged[universe] || 0;
// Update data
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const index = channel + i - 1;
if (typeof value[i] === "number" &&
this.data[universe][index] !== value[i]) {
this.data[universe][index] = value[i];
if (index + 1 > this.dataChanged[universe]) {
this.dataChanged[universe] = index + 1;
}
}
}
}
else if (typeof value === "number" &&
this.data[universe][channel - 1] !== value) {
this.data[universe][channel - 1] = value;
if (channel > this.dataChanged[universe]) {
this.dataChanged[universe] = channel;
}
}
// Send if changed
if (this.dataChanged[universe]) {
this.send(universe, false, callback);
}
else if (typeof callback === "function") {
callback(null, undefined);
}
return true;
}
/**
* Send a trigger event.
*/
trigger(arg1, arg2, arg3, arg4) {
let oem;
let subKey;
let key;
let callback;
if (typeof arg2 === "number" && typeof arg3 === "number") {
// trigger(oem, subKey, key, [callback])
oem = arg1;
subKey = arg2;
key = arg3;
callback = arg4;
}
else if (typeof arg2 === "number" && typeof arg3 === "function") {
// trigger(subKey, key, callback)
oem = 0xffff;
subKey = arg1;
key = arg2;
callback = arg3;
}
else if (typeof arg2 === "function") {
// trigger(key, callback)
oem = 0xffff;
subKey = 0;
key = arg1;
callback = arg2;
}
else if (typeof arg2 === "number") {
// trigger(subKey, key)
oem = 0xffff;
subKey = arg1;
key = arg2;
}
else {
// trigger(key)
oem = 0xffff;
subKey = 0;
key = arg1;
}
// Defaults per Art-Net spec (p40)
oem = oem ?? 0xffff;
key = key ?? 255;
this.sendTrigger(oem, key, subKey, callback);
return true;
}
/**
* Discovers all Art-Net nodes (devices) present on the local network.
*
* Broadcasts an ArtPoll message and collects all ArtPollReply responses for the timeout period.
* Each discovered node provides IP address, short name, universes, and port info.
*
* @param timeout How long to wait for replies, in milliseconds (default: 2000ms).
* @returns Promise resolving with an array of discovered nodes.
*/
discoverNodes(timeout = 2000) {
return new Promise((resolve) => {
const socket = createSocket({ type: "udp4", reuseAddr: true });
this.discoveredNodes = [];
// Handle incoming UDP messages (possible ArtPollReply packets)
socket.on("message", (msg, rinfo) => {
if (msg.length < 12) {
return;
}
// Check "Art-Net\0"
const isArtNet = msg[0] === 0x41 &&
msg[1] === 0x72 &&
msg[2] === 0x74 &&
msg[3] === 0x2d &&
msg[4] === 0x4e &&
msg[5] === 0x65 &&
msg[6] === 0x74 &&
msg[7] === 0x00;
// ArtPollReply opcode 0x2100 (little-endian: 0x00, 0x21 at 8,9)
const isArtPollReply = isArtNet && msg[8] === 0x00 && msg[9] === 0x21;
if (!isArtPollReply) {
return;
}
const info = this.parseArtPollReply(msg);
if (!this.discoveredNodes.some((n) => n.ip === rinfo.address)) {
this.discoveredNodes.push({
ip: rinfo.address,
port: rinfo.port,
info,
});
}
});
// After timeout, stop listening and return discovered nodes
setTimeout(() => {
socket.close();
resolve(this.discoveredNodes);
}, timeout);
// Bind socket to standard Art-Net port and broadcast ArtPoll
socket.bind(6454, () => {
socket.setBroadcast(true);
const pollPacket = this.createArtPollPacket();
socket.send(pollPacket, 0, pollPacket.length, 6454, "255.255.255.255");
});
});
}
/**
* Generates a valid ArtPoll (discovery) UDP packet per Art-Net specification.
*
* @returns Buffer containing the ArtPoll packet.
*/
createArtPollPacket() {
const packet = Buffer.from("Art-Net\x000 \x0e\x00\x00", "ascii"); // OpPoll, ver 14
packet.writeUInt16LE(0x2000, 8); // OpPoll = 0x2000 (little-endian in packet)
return packet;
}
/**
* Extracts important information from an ArtPollReply UDP packet.
*
* Offsets (bytes):
* - 0..7 : "Art-Net\0"
* - 8..9 : OpCode (reply == 0x2100, little-endian)
* - 10..13 : Node IP address
* - 18 : NetSwitch (7 bits used, 0..127)
* - 19 : SubSwitch (low nibble used, 0..15)
* - 26..43 : ShortName (18 bytes, null-terminated ASCII)
* - 44..107 : LongName (64 bytes, null-terminated ASCII)
* - 172..173: NumPorts (Hi, Lo)
* - 186..189: SwIn[0..3] (low nibble == input universe/channel 0..15)
* - 190..193: SwOut[0..3] (low nibble == output universe/channel 0..15)
*
* Universe number (15-bit) is composed as:
* universe = (NetSwitch << 8) | (SubSwitch << 4) | (PortNibble)
* Where PortNibble is low-nibble of SwIn[x] or SwOut[x].
*
* @param msg Buffer containing the ArtPollReply packet.
* @returns ArtNetNodeInfo object with extracted node/device details.
*/
parseArtPollReply(msg) {
// Clean null-terminated ASCII strings and strip garbage (common on cheap nodes)
function getString(start, length) {
const end = msg.indexOf(0, start);
const raw = msg
.subarray(start, end === -1 ? start + length : end)
.toString("ascii");
// eslint-disable-next-line no-control-regex
// biome-ignore lint/suspicious/noControlCharactersInRegex: __ignore
return raw.replace(/[\x00-\x1F\x7F-\xFF]/g, "").trim();
}
const nodeIp = msg.length >= 14
? `${msg[10]}.${msg[11]}.${msg[12]}.${msg[13]}`
: "0.0.0.0";
const net = msg.length > 18 ? msg[18] & 0x7f : 0; // 0..127
const sub = msg.length > 19 ? msg[19] & 0x0f : 0; // 0..15
// Port count: NumPortsHi/Lo at 172/173
const portCount = msg.length > 173 ? (msg[172] << 8) | msg[173] : 0;
// SwIn[0..3] and SwOut[0..3] (guard against short packets)
const swInBytes = msg.length >= 190 ? [...msg.subarray(186, 190)] : [];
const swOutBytes = msg.length >= 194 ? [...msg.subarray(190, 194)] : [];
// Compose full universes for each available port
const compose = (nibble) => (net << 8) | (sub << 4) | (nibble & 0x0f);
const universesIn = swInBytes.map((b) => compose(b & 0x0f));
const universesOut = swOutBytes.map((b) => compose(b & 0x0f));
// Keep the “first input port” universe for backward compatibility
const universe = universesIn[0] ?? compose(msg.length > 186 ? msg[186] & 0x0f : 0);
return {
shortName: getString(26, 18),
longName: getString(44, 64),
nodeIp,
portCount,
universe,
universesIn,
universesOut,
};
}
/**
* Stop all polling/refresh and close the socket.
*/
close() {
for (const interval of this.interval) {
if (interval) {
clearInterval(interval);
}
}
for (const throttle of this.sendThrottle) {
if (throttle) {
clearTimeout(throttle);
}
}
this.socket.close();
}
/**
* Change the Art-Net output host.
*/
setHost(host) {
this.host = host;
}
/**
* Set a new UDP port. (Only allowed when not using broadcast)
*/
setPort(port) {
if (this.host === "255.255.255.255") {
throw new Error("Can't change port when using broadcast address 255.255.255.255");
}
else {
this.port = port;
}
}
}