'Lumend' is a lighting control daemon that forms the core 'engine' of my custom lightweight architectural lighting system.

The original version of lumen was written in perl, then python, and to take advantage of socket.io to provide a better experience to mobile web devices (e.g. iPhone client) re-written (badly?) for node.js

This is, literally, the first thing I ever built in node. It works (quite well actually) but is probably very badly written in many ways, any suggestions for improvement are welcomed.

You are free to use this for your own private non-commercial use, but I would appreciate if you would please credit me with a link to this page in anything you produce.

/*
 *  
 * lumend.js - Lumend v2 (written for node.js) 
 * 
 * This is an attempt to unify DMX and Hue in a single protocol
 * the JSON is compatible with both my old Hue controller and 
 * the original Lumen.
 *
 */

// Configure Art-Net node
SERVER_HOST = "10.0.243.200";
SERVER_PORT = 6454;
// Configure Hue bridge
HUE_HOST = "10.0.243.65";
HUE_KEY = "2a83a86024ba804f2c95f4b0b94c03f";


// Initialise Variables
HEADER = [65, 114, 116, 45, 78, 101, 116, 0, 0, 80, 0, 14, 0, 0];
UNIVERSE = [0, 0];

/*
 * There must be an easier way to keep track of all this;
 *
 * dmx[ch] - Always the current DMX state in rounded integer values
 * dmxEnd[ch] - Represents the end state after all current sequences run
 * dmxFloat[ch] - The current DMX state (as a float value)
 * dmxDelta[ch] - The current rate of change
 *                 e.g. 10 will increase the level by 10 each iteration
 *                     -20 will increase the level by 20 each iteration
 * threadChans[guid] - Each entry contains an array of the channels currently under the control of that 'loop'
 * threadCounter[guid] - Counts how many time the thread (or 'loop') with the id specified has iterated.
 * threadUuidToId[guid] - Stores a cross-reference of thread guid to the node.js internal interval counter id
 * channelControlThread[ch] - Stores the associated counter/thread guid with a channel.  
 *                            The presence of a channel in this list indicates it is changing.
 */
var dmx = [];
var dmxEnd = [];
var dmxFloat = [];
var dmxDelta = [];
var threadChans = [];
var threadCounter = [];
var threadUuidToId = [];
var channelControlThread = [];

/*
 * Populate the initial DMX levels (as 0)
 */
for (var i = 0; i < 512; i++) {
   dmx[i] = 0;
   dmxFloat[i] = -0.00;
}

/*
 * artnetSend()
 *   - Sends a single packet of Art-Net data.
 */
function artnetSend(data) {
   // Calcualte the length
   var length_upper = Math.floor(data.length / 256);
   var length_lower = data.length % 256;
   var data = HEADER.concat(UNIVERSE).concat([length_upper, length_lower]).concat(data);
   var buf = new Buffer(data);
   socket.send(buf, 0, buf.length, SERVER_PORT, SERVER_HOST, function (err, bytes) {});
}

/*
 * fadeLevels
 *   - Main 'fading' thread, called on a setInterval to smoothly change DMX 
 *     levels and send art-net ddata packets.
 */
function fadeLevels(maxsteps, threadId) {
   threadCounter[threadId]++;

   for (ch in threadChans[threadId]) {
      if (threadCounter[threadId] == maxsteps) {
         dmxFloat[ch] = dmxEnd[ch];
         channelControlThread[ch] = null;
      } else if (dmxEnd[ch] > dmx[ch] && dmxFloat[ch] + dmxDelta[ch] > dmxEnd[ch]) {
         dmxFloat[ch] = dmxEnd[ch];
         channelControlThread[ch] = null;
      } else if (dmxEnd[ch] < dmx[ch] && dmxFloat[ch] + dmxDelta[ch] < dmxEnd[ch]) {
         dmxFloat[ch] = dmxEnd[ch];
         channelControlThread[ch] = null;
      } else {
         dmxFloat[ch] = (dmxFloat[ch] + dmxDelta[ch]);
      }
      dmx[ch] = Math.ceil(dmxFloat[ch]);
   }

   artnetSend(dmx);

   if (threadCounter[threadId] == maxsteps || !(threadChans[threadId])) {
      // end cleanly
      clearInterval(threadUuidToId[threadId]);
      delete threadUuidToId[threadId];
      delete threadCounter[threadId];
      delete threadChans[threadId];
   }
}

/*
 * getUuid()
 *   - A simple function to generate a GUID type ID for referring to threads,
 *     needed because we need to pass the ID *to* the thread and we don't get
 *     the "IntervalTimer" ID back until after it's set... 
 */
function getUuid() {
   return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
      var r = Math.random() * 16 | 0,
         v = c == 'x' ? r : (r & 0x3 | 0x8);
      return v.toString(16);
   });
}

/*
 * parseInput()
 *   - This is the universal function called to set the intervalTimers that
 *     cause the DMX values to fade, it also triggers requests to the Hue
 *     bridge.  This same function is called irrespective of what method 
 *     is used to trigger the request.
 */
function parseInput(inp) {
   // mainly for debugging purposes, we log what is received to the console.
   console.log('RCVD: ' + inp);

   // check whether we received a valid JSON command
   try {
      a = JSON.parse(inp);
   } catch (e) {
      res = {}
      res.success = false;
      res.reason = 'Not JSON';
      return res;
   }

   // if we have received some form of lighting data, act on it...
   if (a['data'] || a['hue']) {
  
      if (a['time'] && a['time'] > 0) {
         ftime = a['time'];
         fsteps = a['time'] * 30;
         tinterval = Math.round(a['time'] / fsteps * 1000);
      } else {
         ftime = 0;
         fsteps = 1;
         tinterval = 0;
      }

      /// Get a unique ID for this thread 
      threadId = getUuid();
      threadChans[threadId] = [];

      if (a['data']) {
         // DMX Data
         for (channel in a['data']) {
            dmxchannel = channel - 1;
            level = Number(a['data'][channel]);

            if (dmx[dmxchannel] != level) {
               dmxEnd[dmxchannel] = level;
               dmxDelta[dmxchannel] = Number((level - dmx[dmxchannel]) / fsteps);
               threadChans[threadId][dmxchannel] = dmxchannel;
            }
         }

         // Start a 'thread' (interval timer loop) to control the level of these channels
         threadCounter[threadId] = 0;
         threadUuidToId[threadId] = setInterval(fadeLevels, tinterval, fsteps, threadId)

         // Very primitive LTP merge, seize control of this
         // channel from any other thread (intervalTimer) that
         // has it.

         for (dmxchannel in a['data']) {
            if (channelControlThread[dmxchannel]) {
               delete threadChans[channelControlThread[dmxchannel]];
            }
            channelControlThread[dmxchannel] = threadId;
         }
      }

      // process hues
      if (a['hue']) {
         for (lamp in a['hue']) {
            a['hue'][lamp]['transitiontime'] = ftime * 10;
            var huereq = http.request({
               host: HUE_HOST,
               port: 80,
               path: '/api/' + HUE_KEY + '/lights/' + lamp + '/state',
               method: 'PUT'
            }, function (res) {
               res.setEncoding('utf8')
            });
            huereq.write(JSON.stringify(a['hue'][lamp]));
            huereq.end();
         }
      }

      res = {};
      res.success = true;
      res.thread = threadId;
      return res
   }
}

//
//  Interfaces 
//   (node.js lets this listen on stdin, telnet socket, web, anything..)
//

var dgram = require('dgram');
var socket = dgram.createSocket("udp4");
socketio = require('socket.io');
http = require('http');

// STDIN
var stdin = process.openStdin();
stdin.on('data', function (input) {
   console.log(JSON.stringify(parseInput(input)));
});

// TELNET (4295)
var net = require('net');
var server = net.createServer(function (socket) {
   socket.on('data', function (input) {
      socket.write(JSON.stringify(parseInput(input)));
   });
});
server.listen(4295);

// SOCKET.IO 
var httpserver = http.createServer(function (req, res) {
   res.writeHead(200, {
      'Content-type': 'text/html'
   });
   res.end(fs.readFileSync(__dirname + '/index.html'));
}).listen(8576, function () {
   console.log('Listening at: http://localhost:8576');
});

socketio.listen(httpserver).on('connection', function (socket) {
   socket.on('message', function (msg) {
      socket.broadcast.emit('message', JSON.stringify(parseInput(msg)));
   });
});


// general catch-all for exceptions
process.on('uncaughtException', function (err) {
   console.log('Caught exception: ' + err);
});

JSON Request Format

This daemon is designed to receive commands in JSON format (which are compatible with another system, hence the rather bodged implementation) as follows;


{ 
  "time": 20,             // fade over 20s
  "data": {
            "2" : 255,    // set DMX channel 2 to full
            "9" : 50      // set DMX channel 9 to 20%
          }
   "hue": {
            "1" : {..}    // send JSON '..' to hue lamp #1
          }
}

The JSON command sent to the hue is the same as would be sent by HTTP PUT to the bridge (e.g. 'bri', 'hue', 'ct' etc) as lumend simply act as a proxy allowing scenes to be built which control both DMX fixtures and Hue lamps with the same fade time and fired by one command.

Response

The response currently contains the thread id, which is only for debugging purposes, but could be used in the future if the interface was extended to allow control of fades already running.

Performance

Very un-scientific testing suggests that this little script will happily manage 40fps of Art-Net data on a basic Raspberry Pi. It could probably be optimised so there was a 'main' loop which constantly sent the content of the dmx variable as Art-Net and have the interval timer loops just update this variable - although this resulted in slightly more steppy fades, and there doesn't seem to be any adverse effect in normal use to outputting as it is just now.

The running node.js process uses less CPU and RAM than the Python scripts it replaced.

Disclaimer & Caveats

This is provided AS-IS and without any warranty as to fitness for any purpose whatsoever for educational and private non-commercial use only. You would be mad to try and use this code in a commercial environment for other reasons - but either way, you can't.

As mentioned above, this is my first attempt at writing anything in node.js - there's probably a lot wrong with it but constructive criticism is very welcome!

 

LinkedIn Twitter

Get in touch