UNPKG

dmxnet

Version:

ArtNet-DMX-sender and receiver for nodejs

686 lines (653 loc) 21.9 kB
'use strict'; /* eslint-env node, mocha */ var dgram = require('dgram'); var EventEmitter = require('events'); var jspack = require('jspack').jspack; const os = require('os'); const Netmask = require('netmask').Netmask; const winston = require('winston'); const swap16 = (val) => { return ((val & 0xFF) << 8) | ((val >> 8) & 0xFF); }; // ArtDMX Header for jspack var ArtDmxHeaderFormat = '!7sBHHBBBBH'; // ArtDMX Payload for jspack var ArtDmxPayloadFormat = '512B'; /** Class representing the core dmxnet structure */ class dmxnet { /** * Creates a new dmxnet instance * * @param {object} options - Options for the whole instance */ constructor(options = {}) { // Parse all options and set defaults this.oem = options.oem || 0x2908; // OEM code hex this.esta = options.esta || 0x0000; // ESTA code hex this.port = options.listen || 6454; // Port listening for incoming data this.sName = options.sName || 'dmxnet'; // Shortname this.lName = options.lName || 'dmxnet - OpenSource ArtNet Transceiver'; // Longname // Init Logger this.logOptions = Object.assign( { level: 'info', format: winston.format.combine( winston.format.splat(), winston.format.timestamp(), winston.format.label({ label: 'dmxnet' }), winston.format.printf(({ level, message, label, timestamp }) => { return `${timestamp} [${label}] ${level}: ${message}`; }) ), transports: [new winston.transports.Console()] }, options.log ); this.logger = new winston.createLogger(this.logOptions); this.hosts = options.hosts || []; // Log started information this.logger.info('started with options: %o', options); // Get all network interfaces this.interfaces = os.networkInterfaces(); this.ip4 = []; this.ip6 = []; // Iterate over interfaces and insert sorted IPs Object.keys(this.interfaces).forEach((key) => { this.interfaces[key].forEach((val) => { if (val.family === 'IPv4') { var netmask = new Netmask(val.cidr); if (this.hosts.length === 0 || (this.hosts.indexOf(val.address) !== -1)) { this.ip4.push({ ip: val.address, netmask: val.netmask, mac: val.mac, broadcast: netmask.broadcast, }); } } }); }); this.logger.verbose('Interfaces: %o', this.ip4); // init artPollReplyCount this.artPollReplyCount = 0; // Array containing reference to foreign controllers this.controllers = []; // Array containing reference to foreign node's this.nodes = []; // Array containing reference to senders this.senders = []; // Array containing reference to receiver objects this.receivers = []; // Object containing reference to receivers by SubnetUniverseNet this.receiversSubUni = {}; // Timestamp of last Art-Poll send this.last_poll; // Create listener for incoming data if (!Number.isInteger(this.port)) throw new Error('Invalid Port'); this.listener4 = dgram.createSocket({ type: 'udp4', reuseAddr: true, }); // ToDo: IPv6 // ToDo: Multicast // Catch Socket errors this.listener4.on('error', function (err) { throw new Error('Socket error: ', err); }); // Register listening object this.listener4.on('message', (msg, rinfo) => { dataParser(msg, rinfo, this); }); // Start listening this.listener4.bind(this.port); this.logger.info('Listening on port ' + this.port); // Open Socket for sending broadcast data this.socket = dgram.createSocket('udp4'); this.socket.bind(() => { this.socket.setBroadcast(true); this.socket_ready = true; }); // Periodically check Controllers setInterval(() => { if (this.controllers) { this.logger.verbose('Check controller alive, count ' + this.controllers.length); for (var index = 0; index < this.controllers.length; index++) { if ((new Date().getTime() - new Date(this.controllers[index].last_poll).getTime()) > 60000) { this.controllers[index].alive = false; } } } }, 30000); return this; } /** * Returns a new sender instance * * @param {object} options - Options for the new sender * @returns {sender} - Instance of Sender */ newSender(options) { var s = new sender(options, this); this.senders.push(s); this.ArtPollReply(); return s; } /** * Returns a new receiver instance * * @param {object} options - Options for the new receiver * @returns {receiver} - Instance of Receiver */ newReceiver(options) { var r = new receiver(options, this); this.receivers.push(r); this.ArtPollReply(); return r; } /** * Builds and sends an ArtPollReply-Packet */ ArtPollReply() { this.logger.silly('Send ArtPollReply'); this.ip4.forEach((ip) => { // BindIndex handles all the different "instance". var bindIndex = 1; var ArtPollReplyFormat = '!7sBHBBBBHHBBHBBH18s64s64sH4B4B4B4B4B3HB6B4BBB'; var netSwitch = 0x01; var subSwitch = 0x01; var status = 0b11010000; var stateString = '#0001 [' + ('000' + this.artPollReplyCount).slice(-4) + '] dmxnet ArtNet-Transceiver running'; var sourceip = ip.ip; var broadcastip = ip.broadcast; // one packet for each sender this.senders.forEach((s) => { var portType = 0b01000000; var udppacket = Buffer.from(jspack.Pack( ArtPollReplyFormat, ['Art-Net', 0, 0x0021, // 4 bytes source ip + 2 bytes port sourceip.split('.')[0], sourceip.split('.')[1], sourceip.split('.')[2], sourceip.split('.')[3], this.port, // 2 bytes Firmware version, netSwitch, subSwitch, OEM-Code 0x0001, s.net, s.subnet, this.oem, // Ubea, status1, 2 bytes ESTA 0, status, swap16(this.esta), // short name (18), long name (63), stateString (63) this.sName.substring(0, 16), this.lName.substring(0, 63), stateString, // 2 bytes num ports, 4*portTypes 1, portType, 0, 0, 0, // 4*goodInput, 4*goodOutput 0b10000000, 0, 0, 0, 0, 0, 0, 0, // 4*SW IN, 4*SW OUT s.universe, 0, 0, 0, 0, 0, 0, 0, // 5* deprecated/spare, style 0, 0, 0, 0x01, // MAC address parseInt(ip.mac.split(':')[0], 16), parseInt(ip.mac.split(':')[1], 16), parseInt(ip.mac.split(':')[2], 16), parseInt(ip.mac.split(':')[3], 16), parseInt(ip.mac.split(':')[4], 16), parseInt(ip.mac.split(':')[5], 16), // BindIP sourceip.split('.')[0], sourceip.split('.')[1], sourceip.split('.')[2], sourceip.split('.')[3], // BindIndex, Status2 bindIndex, 0b00001110, ])); // Increase bindIndex bindIndex++; if (bindIndex > 255) { bindIndex = 1; } // Send UDP var client = this.socket; client.send(udppacket, 0, udppacket.length, 6454, broadcastip, (err) => { if (err) throw err; this.logger.debug('ArtPollReply frame sent'); }); }); // Send one package for every receiver this.receivers.forEach((r) => { var portType = 0b10000000; var udppacket = Buffer.from(jspack.Pack( ArtPollReplyFormat, ['Art-Net', 0, 0x0021, // 4 bytes source ip + 2 bytes port sourceip.split('.')[0], sourceip.split('.')[1], sourceip.split('.')[2], sourceip.split('.')[3], this.port, // 2 bytes Firmware version, netSwitch, subSwitch, OEM-Code 0x0001, r.net, r.subnet, this.oem, // Ubea, status1, 2 bytes ESTA 0, status, swap16(this.esta), // short name (18), long name (63), stateString (63) this.sName.substring(0, 16), this.lName.substring(0, 63), stateString, // 2 bytes num ports, 4*portTypes 1, portType, 0, 0, 0, // 4*goodInput, 4*goodOutput 0, 0, 0, 0, 0b10000000, 0, 0, 0, // 4*SW IN, 4*SW OUT 0, 0, 0, 0, r.universe, 0, 0, 0, // 5* deprecated/spare, style 0, 0, 0, 0x01, // MAC address parseInt(ip.mac.split(':')[0], 16), parseInt(ip.mac.split(':')[1], 16), parseInt(ip.mac.split(':')[2], 16), parseInt(ip.mac.split(':')[3], 16), parseInt(ip.mac.split(':')[4], 16), parseInt(ip.mac.split(':')[5], 16), // BindIP sourceip.split('.')[0], sourceip.split('.')[1], sourceip.split('.')[2], sourceip.split('.')[3], // BindIndex, Status2 bindIndex, 0b00001110, ])); // Increase bindIndex bindIndex++; if (bindIndex > 255) { bindIndex = 1; } // Send UDP var client = this.socket; client.send(udppacket, 0, udppacket.length, 6454, broadcastip, (err) => { if (err) throw err; this.logger.debug('ArtPollReply frame sent'); }); }); if ((this.senders.length + this.receivers.length) < 1) { // No senders and receivers available, propagate as "empty" var udppacket = Buffer.from(jspack.Pack( ArtPollReplyFormat, ['Art-Net', 0, 0x0021, // 4 bytes source ip + 2 bytes port sourceip.split('.')[0], sourceip.split('.')[1], sourceip.split('.')[2], sourceip.split('.')[3], this.port, // 2 bytes Firmware version, netSwitch, subSwitch, OEM-Code 0x0001, netSwitch, subSwitch, this.oem, // Ubea, status1, 2 bytes ESTA 0, status, swap16(this.esta), // short name (18), long name (63), stateString (63) this.sName.substring(0, 16), this.lName.substring(0, 63), stateString, // 2 bytes num ports, 4*portTypes 0, 0, 0, 0, 0, // 4*goodInput, 4*goodOutput 0, 0, 0, 0, 0, 0, 0, 0, // 4*SW IN, 4*SW OUT 0, 0, 0, 0, 0, 0, 0, 0, // 5* deprecated/spare, style 0, 0, 0, 0x01, // MAC address parseInt(ip.mac.split(':')[0], 16), parseInt(ip.mac.split(':')[1], 16), parseInt(ip.mac.split(':')[2], 16), parseInt(ip.mac.split(':')[3], 16), parseInt(ip.mac.split(':')[4], 16), parseInt(ip.mac.split(':')[5], 16), // BindIP sourceip.split('.')[0], sourceip.split('.')[1], sourceip.split('.')[2], sourceip.split('.')[3], // BindIndex, Status2 1, 0b00001110, ])); this.logger.debug('Packet content: ' + udppacket.toString('hex')); // Send UDP var client = this.socket; client.send(udppacket, 0, udppacket.length, 6454, broadcastip, (err) => { if (err) throw err; this.logger.debug('ArtPollReply frame sent'); }); } }); this.artPollReplyCount++; if (this.artPollReplyCount > 9999) { this.artPollReplyCount = 0; } } } /** * Class representing a sender */ class sender { /** * Creates a new sender, usually called trough factory in dmxnet * * @param {object} opt - Options for the sender * @param {dmxnet} parent - Instance of the dmxnet parent */ constructor(opt, parent) { // save parent object this.parent = parent; this.socket_ready = false; // set options var options = opt || {}; this.net = options.net || 0; this.subnet = options.subnet || 0; this.universe = options.universe || 0; this.subuni = options.subuni; this.ip = options.ip || '255.255.255.255'; this.port = options.port || 6454; this.base_refresh_interval = options.base_refresh_interval || 1000; // Validate Input if (this.net > 127) { throw new Error('Invalid Net, must be smaller than 128'); } if (this.universe > 15) { throw new Error('Invalid Universe, must be smaller than 16'); } if (this.subnet > 15) { throw new Error('Invalid subnet, must be smaller than 16'); } if ((this.net < 0) || (this.subnet < 0) || (this.universe < 0)) { throw new Error('Subnet, Net or Universe must be 0 or bigger!'); } this.parent.logger.info('new dmxnet sender started with params: %o', options); // init dmx-value array this.values = []; // fill all 512 channels for (var i = 0; i < 512; i++) { this.values[i] = 0; } // Build Subnet/Universe/Net Int16 if (!this.subuni) { this.subuni = (this.subnet << 4) | (this.universe); } // ArtDmxSeq this.ArtDmxSeq = 1; // Create Socket this.socket = dgram.createSocket('udp4'); // Check IP and Broadcast if (isBroadcast(this.ip)) { this.socket.bind(() => { this.socket.setBroadcast(true); this.socket_ready = true; }); } else { this.socket_ready = true; } // Transmit first Frame this.transmit(); // Send Frame every base_refresh_interval ms - even if no channel was changed this.interval = setInterval(() => { this.transmit(); }, this.base_refresh_interval); } /** * Transmits the current values */ transmit() { // Only transmit if socket is ready if (this.socket_ready) { if (this.ArtDmxSeq > 255) { this.ArtDmxSeq = 1; } // Build packet: ID Int8[8], OpCode Int16 0x5000 (conv. to 0x0050), // ProtVer Int16, Sequence Int8, PhysicalPort Int8, // SubnetUniverseNet Int16, Length Int16 var udppacket = Buffer.from(jspack.Pack(ArtDmxHeaderFormat + ArtDmxPayloadFormat, ['Art-Net', 0, 0x0050, 14, this.ArtDmxSeq, 0, this.subuni, this.net, 512, ].concat(this.values))); // Increase Sequence Counter this.ArtDmxSeq++; this.parent.logger.debug('Packet content: ' + udppacket.toString('hex')); // Send UDP var client = this.socket; client.send(udppacket, 0, udppacket.length, this.port, this.ip, (err) => { if (err) throw err; this.parent.logger.silly('ArtDMX frame sent to ' + this.ip + ':' + this.port); }); } } /** * Sets a single channel to a value and transmits the change * * @param {number} channel - channel (0-511) * @param {number} value - value (0-255) */ setChannel(channel, value) { if ((channel > 511) || (channel < 0)) { throw new Error('Channel must be between 0 and 512'); } if ((value > 255) || (value < 0)) { throw new Error('Value must be between 0 and 255'); } this.values[channel] = value; this.transmit(); } /** * Prepares a single channel (without transmitting) * * @param {number} channel - channel (0-511) * @param {number} value - value (0-255) */ prepChannel(channel, value) { if ((channel > 511) || (channel < 0)) { throw new Error('Channel must be between 0 and 512'); } if ((value > 255) || (value < 0)) { throw new Error('Value must be between 0 and 255'); } this.values[channel] = value; } /** * Fills channel block with a value and transmits the change * * @param {number} start - start of the block * @param {number} stop - end of the block (inclusive) * @param {number} value - value */ fillChannels(start, stop, value) { if ((start > 511) || (start < 0)) { throw new Error('Channel must be between 0 and 512'); } if ((stop > 511) || (stop < 0)) { throw new Error('Channel must be between 0 and 512'); } if ((value > 255) || (value < 0)) { throw new Error('Value must be between 0 and 255'); } for (var i = start; i <= stop; i++) { this.values[i] = value; } this.transmit(); } /** * Resets all channels to zero and Transmits */ reset() { // Reset all 512 channels of the sender to zero for (var i = 0; i < 512; i++) { this.values[i] = 0; } this.transmit(); } /** * Stops the sender and destroys it */ stop() { clearInterval(this.interval); this.parent.senders = this.parent.senders.filter(function (value) { if (value === this) { return false; } return true; }); this.socket.close(); } } // ToDo: Improve method /** * Checks if IPv4 address given is a broadcast address - only used internally * * @param {string} ipaddress - IP address to check * @returns {boolean} - result, true: broadcast */ function isBroadcast(ipaddress) { var oct = ipaddress.split('.'); if (oct.length !== 4) { throw new Error('Wrong IPv4 lenght'); } for (var i = 0; i < 4; i++) { if ((parseInt(oct[i], 10) > 255) || (parseInt(oct[i], 10) < 0)) { throw new Error('Invalid IP (Octet ' + (i + 1) + ')'); } } if (oct[3] === '255') { return true; } return false; } /** * Object representing a receiver-instance */ class receiver extends EventEmitter { /** * Creates a new receiver, usually called trough factory in dmxnet * * @param {object} opt - Options for the receiver * @param {dmxnet} parent - Instance of the dmxnet parent */ constructor(opt, parent) { super(); // save parent object this.parent = parent; // set options var options = opt || {}; this.net = options.net || 0; this.subnet = options.subnet || 0; this.universe = options.universe || 0; this.subuni = options.subuni; // Validate Input if (this.net > 127) { throw new Error('Invalid Net, must be smaller than 128'); } if (this.universe > 15) { throw new Error('Invalid Universe, must be smaller than 16'); } if (this.subnet > 15) { throw new Error('Invalid subnet, must be smaller than 16'); } if ((this.net < 0) || (this.subnet < 0) || (this.universe < 0)) { throw new Error('Subnet, Net or Universe must be 0 or bigger!'); } this.parent.logger.info('new dmxnet sender started with params %o', options); // init dmx-value array this.values = []; // fill all 512 channels for (var i = 0; i < 512; i++) { this.values[i] = 0; } // Build Subnet/Universe/Net Int16 if (!this.subuni) { this.subuni = (this.subnet << 4) | (this.universe); } this.subuninet = (this.subuni << 8) | this.net; // Insert this object into the map parent.receiversSubUni[this.subuninet] = this; } /** * Handles received data * * @param {Array} data - Data from received ArtDMX */ receive(data) { this.values = data; this.emit('data', data); } } // Parser & receiver /** * @param {Buffer} msg - Message buffer to parse * @param {dgram.RemoteInfo} rinfo - Remote info * @param {dmxnet} parent - Instance of the dmxnet parent */ var dataParser = function (msg, rinfo, parent) { parent.logger.silly(`got UDP from ${rinfo.address}:${rinfo.port}`); if (rinfo.size < 10) { parent.logger.silly('Payload to short'); return; } // Check first 8 bytes for the "Art-Net" - String if (String(jspack.Unpack('!8s', msg)) !== 'Art-Net\u0000') { parent.logger.silly('Invalid header'); return; } var opcode = parseInt(jspack.Unpack('B', msg, 8), 10); opcode += parseInt(jspack.Unpack('B', msg, 9), 10) * 256; if (!opcode || opcode === 0) { parent.logger.silly('Invalid OpCode'); return; } switch (opcode) { case 0x5000: parent.logger.debug('detected ArtDMX'); var universe = parseInt(jspack.Unpack('H', msg, 14), 10); var data = []; for (var ch = 1; ch <= msg.length - 18; ch++) { data.push(msg.readUInt8(ch + 17, true)); } parent.logger.debug('Received frame for SubUniNet 0x' + universe.toString(16)); if (parent.receiversSubUni[universe]) { parent.receiversSubUni[universe].receive(data); } break; case 0x2000: if (rinfo.size < 14) { parent.logger.silly('ArtPoll to small'); return; } parent.logger.debug('detected ArtPoll'); // Parse Protocol version var proto = parseInt(jspack.Unpack('B', msg, 10), 10); proto += parseInt(jspack.Unpack('B', msg, 11), 10) * 256; if (!proto || proto < 14) { parent.logger.silly('Invalid OpCode'); return; } // Parse TalkToMe var ctrl = { ip: rinfo.address, family: rinfo.family, last_poll: Date(), alive: true, }; var ttm_raw = parseInt(jspack.Unpack('B', msg, 12), 10); ctrl.diagnostic_unicast = ((ttm_raw & 0b00001000) > 0); ctrl.diagnostic_enable = ((ttm_raw & 0b00000100) > 0); ctrl.unilateral = ((ttm_raw & 0b00000010) > 0); // Priority ctrl.priority = parseInt(jspack.Unpack('B', msg, 13), 10); // Insert into controller's reference var done = false; for (var index = 0; index < parent.controllers.length; ++index) { if (parent.controllers[index].ip === rinfo.address) { done = true; parent.controllers[index] = ctrl; } } if (done !== true) { parent.controllers.push(ctrl); } parent.ArtPollReply(); parent.logger.debug('Controllers: %o', parent.controllers); break; case 0x2100: // ToDo parent.logger.debug('detected ArtPollReply'); break; default: parent.logger.silly('OpCode not implemented'); } }; // Export dmxnet module.exports = { dmxnet, };