lifx-lan-client
Version:
Node.js implementation of the LIFX LAN protocol
718 lines (641 loc) • 24.1 kB
JavaScript
'use strict';
const util = require('util');
const dgram = require('dgram');
const EventEmitter = require('eventemitter3');
const {
defaults, isArray, isBoolean, isString, isNil, result, find, bind, forEach, keys, every, includes, filter
} = require('lodash');
const Packet = require('../lifx').packet;
const {Light, constants, utils} = require('../lifx');
/**
* Creates a lifx client
* @extends EventEmitter
*/
function Client() {
EventEmitter.call(this);
this.debug = false;
this.socket = dgram.createSocket('udp4');
this.isSocketBound = false;
this.devices = {};
this.port = null;
this.messageQueues = {};
this.sendTimers = {};
this.messageRateLimit = 50; // ms
this.discoveryTimer = null;
this.discoveryInterval = 5000; // ms
this.discoveryPacketSequence = 0;
this.messageHandlers = [{
type: 'stateService',
callback: this.processDiscoveryPacket.bind(this)
}, {
type: 'stateLabel',
callback: this.processLabelPacket.bind(this)
}, {
type: 'stateLight',
callback: this.processLabelPacket.bind(this)
}];
this.sequenceNumber = 0;
this.lightOfflineTolerance = 3;
this.messageHandlerTimeout = 45000; // 45 sec
this.resendPacketDelay = 150;
this.resendMaxTimes = 5;
this.source = utils.getRandomHexString(8);
this.broadcastAddress = '255.255.255.255';
this.lightAddresses = [];
this.stopAfterDiscovery = false;
this.discoveryCompleted = false;
}
util.inherits(Client, EventEmitter);
/**
* Creates a new socket and starts discovery
* @example
* init({debug: true}, function() {
* console.log('Client started');
* })
* @param {Object} [options] Configuration to use
* @param {String} [options.address] The IPv4 address to bind to
* @param {Number} [options.port] The port to bind to
* @param {Boolean} [options.debug] Show debug output
* @param {Number} [options.lightOfflineTolerance] If light hasn't answered for amount of discoveries it is set offline
* @param {Number} [options.messageHandlerTimeout] Message handlers not called will be removed after this delay in ms
* @param {String} [options.source] The source to send to light, must be 8 chars lowercase or digit
* @param {Boolean} [options.startDiscovery] Weather to start discovery after initialization or not
* @param {Array} [options.lights] Pre set list of ip addresses of known addressable lights
* @param {Boolean} [options.stopAfterDiscovery] Stop discovery after discovering known addressable lights defined with options.light
* @param {String} [options.broadcast] The broadcast address to use for light discovery
* @param {Number} [options.sendPort] The port to send messages to
* @param {Number} [options.messageRateLimit] The minimum delay (in ms) between sending any two packets to a single light
* @param {Number} [options.discoveryInterval] Interval (in ms) between discovery operations
* @param {Function} [callback] Called after initialation
*/
Client.prototype.init = function(options, callback) {
const defaultOpts = {
address: '0.0.0.0',
port: 0,
debug: false,
lightOfflineTolerance: 3,
messageHandlerTimeout: 45000,
source: '',
startDiscovery: true,
lights: [],
stopAfterDiscovery: false,
broadcast: '255.255.255.255',
sendPort: constants.LIFX_DEFAULT_PORT,
resendPacketDelay: 150,
resendMaxTimes: 3,
messageRateLimit: 50,
discoveryInterval: 5000
};
options = options || {};
const opts = defaults(options, defaultOpts);
if (typeof opts.port !== 'number') {
throw new TypeError('LIFX Client port option must be a number');
} else if (opts.port > 65535 || opts.port < 0) {
throw new RangeError('LIFX Client port option must be between 0 and 65535');
}
if (typeof opts.debug !== 'boolean') {
throw new TypeError('LIFX Client debug option must be a boolean');
}
this.debug = opts.debug;
if (typeof opts.lightOfflineTolerance !== 'number') {
throw new TypeError('LIFX Client lightOfflineTolerance option must be a number');
}
this.lightOfflineTolerance = opts.lightOfflineTolerance;
if (typeof opts.messageHandlerTimeout !== 'number') {
throw new TypeError('LIFX Client messageHandlerTimeout option must be a number');
}
this.messageHandlerTimeout = opts.messageHandlerTimeout;
if (typeof opts.resendPacketDelay !== 'number') {
throw new TypeError('LIFX Client resendPacketDelay option must be a number');
}
this.resendPacketDelay = opts.resendPacketDelay;
if (typeof opts.resendMaxTimes !== 'number') {
throw new TypeError('LIFX Client resendMaxTimes option must be a number');
}
this.resendMaxTimes = opts.resendMaxTimes;
if (typeof opts.broadcast !== 'string') {
throw new TypeError('LIFX Client broadcast option must be a string');
} else if (!utils.isIpv4Format(opts.broadcast)) {
throw new TypeError('LIFX Client broadcast option does only allow IPv4 address format');
}
this.broadcastAddress = opts.broadcast;
if (typeof opts.sendPort !== 'number') {
throw new TypeError('LIFX Client sendPort option must be a number');
} else if (opts.sendPort > 65535 || opts.sendPort < 1) {
throw new RangeError('LIFX Client sendPort option must be between 1 and 65535');
}
this.sendPort = opts.sendPort;
if (!isArray(opts.lights)) {
throw new TypeError('LIFX Client lights option must be an array');
} else {
opts.lights.forEach(function(light) {
if (!utils.isIpv4Format(light)) {
throw new TypeError('LIFX Client lights option array element \'' + light + '\' is not expected IPv4 format');
}
});
this.lightAddresses = opts.lights;
if (!isBoolean(opts.stopAfterDiscovery)) {
throw new TypeError('LIFX Client stopAfterDiscovery must be a boolean');
} else {
this.stopAfterDiscovery = opts.stopAfterDiscovery;
}
}
if (opts.source !== '') {
if (typeof opts.source === 'string') {
if (/^[0-9A-F]{8}$/.test(opts.source)) {
this.source = opts.source;
} else {
throw new RangeError('LIFX Client source option must be 8 hex chars');
}
} else {
throw new TypeError('LIFX Client source option must be given as string');
}
}
if (typeof opts.messageRateLimit !== 'number') {
throw new TypeError('LIFX Client messageRateLimit option must be a number');
} else if (opts.messageRateLimit <= 0) {
throw new RangeError('LIFX Client messageRateLimit option must greater than 0');
}
this.messageRateLimit = opts.messageRateLimit;
if (typeof opts.discoveryInterval !== 'number') {
throw new TypeError('LIFX Client discoveryInterval option must be a number');
} else if (opts.discoveryInterval <= 0) {
throw new RangeError('LIFX Client discoveryInterval option must greater than 0');
}
this.discoveryInterval = opts.discoveryInterval;
this.socket.on('error', function(err) {
this.isSocketBound = false;
console.error('LIFX Client UDP error');
console.trace(err);
this.socket.close();
this.emit('error', err);
}.bind(this));
this.socket.on('message', function(msg, rinfo) {
// Ignore own messages and false formats
if (utils.getHostIPs().indexOf(rinfo.address) >= 0 || !Buffer.isBuffer(msg)) {
return;
}
/* istanbul ignore if */
if (this.debug) {
console.log('DEBUG - ' + msg.toString('hex') + ' from ' + rinfo.address);
}
// Parse packet to object
const parsedMsg = Packet.toObject(msg);
// Check if packet is read successfully
if (parsedMsg instanceof Error) {
console.error('LIFX Client invalid packet header error');
console.error('Packet: ', msg.toString('hex'));
console.trace(parsedMsg);
} else {
// Convert type before emitting
const messageTypeName = result(find(Packet.typeList, {id: parsedMsg.type}), 'name');
if (messageTypeName !== undefined) {
parsedMsg.type = messageTypeName;
}
// Check for handlers of given message and rinfo
this.processMessageHandlers(parsedMsg, rinfo);
this.emit('message', parsedMsg, rinfo);
}
}.bind(this));
this.socket.bind(opts.port, opts.address, function() {
this.isSocketBound = true;
this.socket.setBroadcast(true);
this.emit('listening');
this.port = opts.port;
// Start scanning
if (opts.startDiscovery) {
this.startDiscovery(opts.lights);
}
if (typeof callback === 'function') {
return callback();
}
}.bind(this));
};
/**
* Destroy an instance
*/
Client.prototype.destroy = function() {
this.stopDiscovery();
forEach(keys(this.messageQueues), (queueAddress) => {
this.stopSendingProcess(queueAddress);
});
if (this.isSocketBound) {
this.socket.close();
}
};
/**
* Gets the message queue for the given address. If no address is defined,
* defaults to broadcast address.
* @param {String} queueAddress Message queue address
* @return {Array} Message queue for the address
*/
Client.prototype.getMessageQueue = function(queueAddress = this.broadcastAddress) {
if (isNil(this.messageQueues[queueAddress])) {
this.messageQueues[queueAddress] = [];
}
return this.messageQueues[queueAddress];
};
/**
* Sends a packet from the messages queue or stops the sending process
* if queue is empty
* @param {String} queueAddress Message queue id
* @return {Function} Sending process for the message queue
**/
Client.prototype.sendingProcess = function(queueAddress) {
const messageQueue = this.getMessageQueue(queueAddress);
return () => {
if (!this.isSocketBound) {
this.stopSendingProcess(queueAddress);
console.log('LIFX Client stopped sending due to unbound socket');
return;
}
if (messageQueue.length > 0) {
const msg = messageQueue.pop();
if (msg.address === undefined) {
msg.address = this.broadcastAddress;
}
if (msg.transactionType === constants.PACKET_TRANSACTION_TYPES.ONE_WAY) {
this.socket.send(msg.data, 0, msg.data.length, this.sendPort, msg.address);
/* istanbul ignore if */
if (this.debug) {
console.log('DEBUG - ' + msg.data.toString('hex') + ' to ' + msg.address);
}
} else if (msg.transactionType === constants.PACKET_TRANSACTION_TYPES.REQUEST_RESPONSE) {
if (msg.timesSent < this.resendMaxTimes) {
if (Date.now() > (msg.timeLastSent + this.resendPacketDelay)) {
this.socket.send(msg.data, 0, msg.data.length, this.sendPort, msg.address);
msg.timesSent += 1;
msg.timeLastSent = Date.now();
/* istanbul ignore if */
if (this.debug) {
console.log(
'DEBUG - ' + msg.data.toString('hex') + ' to ' + msg.address +
', send ' + msg.timesSent + ' time(s)'
);
}
}
// Add to the end of the queue again
messageQueue.unshift(msg);
} else {
this.messageHandlers.forEach(function(handler, hdlrIndex) {
if (handler.type === 'acknowledgement' && handler.sequenceNumber === msg.sequence) {
this.messageHandlers.splice(hdlrIndex, 1);
const err = new Error('No LIFX response after max resend limit of ' + this.resendMaxTimes);
return handler.callback(err, null, null);
}
}.bind(this));
}
}
} else {
this.stopSendingProcess(queueAddress);
}
};
};
/**
* Starts the sending of all packages in the queue
* @param {String} queueAddress Message queue id
*/
Client.prototype.startSendingProcess = function(queueAddress = this.broadcastAddress) {
if (isNil(this.sendTimers[queueAddress])) { // Already running?
const sendingProcess = this.sendingProcess(queueAddress);
this.sendTimers[queueAddress] = setInterval(sendingProcess, this.messageRateLimit);
}
};
/**
* Stops sending of all packages in the queue
* @param {String} queueAddress Message queue id
*/
Client.prototype.stopSendingProcess = function(queueAddress = this.broadcastAddress) {
if (!isNil(this.sendTimers[queueAddress])) {
clearInterval(this.sendTimers[queueAddress]);
delete this.sendTimers[queueAddress];
}
};
/**
* Start discovery of lights
* This will keep the list of lights updated, finds new lights and sets lights
* offline if no longer found
* @param {Array} [lights] Pre set list of ip addresses of known addressable lights to request directly
*/
Client.prototype.startDiscovery = function(lights) {
lights = lights || [];
const sendDiscoveryPacket = function() {
// Sign flag on inactive lights
forEach(this.devices, bind(function(info, deviceId) {
if (this.devices[deviceId].status !== 'off') {
const diff = this.discoveryPacketSequence - info.seenOnDiscovery;
if (diff >= this.lightOfflineTolerance) {
this.devices[deviceId].status = 'off';
this.emit('bulb-offline', info); // deprecated
this.emit('light-offline', info);
}
}
}, this));
// Send a discovery packet broadcast
this.send(Packet.create('getService', {}, this.source));
// Send a discovery packet to each light given directly
lights.forEach(function(lightAddress) {
const msg = Packet.create('getService', {}, this.source);
msg.address = lightAddress;
this.send(msg);
}, this);
// Keep track of a sequent number to find not answering lights
if (this.discoveryPacketSequence >= Number.MAX_VALUE) {
this.discoveryPacketSequence = 0;
} else {
this.discoveryPacketSequence += 1;
}
}.bind(this);
this.discoveryTimer = setInterval(
sendDiscoveryPacket,
this.discoveryInterval
);
sendDiscoveryPacket();
};
/**
* Checks if light discovery is in progress
* @return {Boolean} is discovery in progress
*/
Client.prototype.isDiscovering = function() {
return this.discoveryTimer !== null;
};
/**
* Checks all registered message handlers if they request the given message
* @param {Object} msg message to check handler for
* @param {Object} rinfo rinfo address info to check handler for
*/
Client.prototype.processMessageHandlers = function(msg, rinfo) {
const messageQueue = this.getMessageQueue(rinfo.address);
// Process only packages for us
if (msg.source.toLowerCase() !== this.source.toLowerCase()) {
return;
}
// We check our message handler if the answer received is requested
this.messageHandlers.forEach(function(handler, hdlrIndex) {
if (msg.type === handler.type) {
if (handler.sequenceNumber !== undefined) {
if (handler.sequenceNumber === msg.sequence) {
// Remove if specific packet was request, since it should only be called once
this.messageHandlers.splice(hdlrIndex, 1);
messageQueue.forEach(function(packet, packetIndex) {
if (packet.transactionType === constants.PACKET_TRANSACTION_TYPES.REQUEST_RESPONSE &&
packet.sequence === msg.sequence) {
messageQueue.splice(packetIndex, 1);
}
});
// Call the function requesting the packet
return handler.callback(null, msg, rinfo);
}
} else {
// Call the function requesting the packet
return handler.callback(null, msg, rinfo);
}
}
// We want to call expired request handlers for specific packages after the
// messageHandlerTimeout set in options, to specify an error
if (handler.sequenceNumber !== undefined) {
if (Date.now() > (handler.timestamp + this.messageHandlerTimeout)) {
this.messageHandlers.splice(hdlrIndex, 1);
const err = new Error('No LIFX response in time');
return handler.callback(err, null, null);
}
}
}, this);
};
/**
* Processes a discovery report packet to update internals
* @param {Object} err Error if existant
* @param {Object} msg The discovery report package
* @param {Object} rinfo Remote host details
*/
Client.prototype.processDiscoveryPacket = function(err, msg, rinfo) {
if (err) {
return;
}
if (msg.service === 'udp' && msg.port === constants.LIFX_DEFAULT_PORT) {
// Add / update the found gateway
if (!this.devices[msg.target]) {
const lightDevice = new Light({
client: this,
id: msg.target,
address: rinfo.address,
port: msg.port,
seenOnDiscovery: this.discoveryPacketSequence
});
this.devices[msg.target] = lightDevice;
// Request label
const labelRequest = Packet.create('getLabel', {}, this.source);
labelRequest.target = msg.target;
this.send(labelRequest);
this.emit('bulb-new', lightDevice); // deprecated
this.emit('light-new', lightDevice);
} else {
if (this.devices[msg.target].status === 'off') {
this.devices[msg.target].status = 'on';
this.emit('bulb-online', this.devices[msg.target]); // deprecated
this.emit('light-online', this.devices[msg.target]);
}
this.devices[msg.target].address = rinfo.address;
this.devices[msg.target].seenOnDiscovery = this.discoveryPacketSequence;
}
// Check if discovery should be stopped
if (this.stopAfterDiscovery && !this.discoveryCompleted) {
if (this.predefinedDiscoveredAndOnline()) {
this.emit('discovery-completed');
this.stopDiscovery();
this.discoveryCompleted = true;
}
}
}
};
/**
* Processes a state label packet to update internals
* @param {Object} err Error if existant
* @param {Object} msg The state label package
*/
Client.prototype.processLabelPacket = function(err, msg) {
if (err) {
return;
}
if (this.devices[msg.target] !== undefined) {
this.devices[msg.target].label = msg.label;
}
};
/**
* This stops the discovery process
* The client will be no longer updating the state of lights or find lights
*/
Client.prototype.stopDiscovery = function() {
clearInterval(this.discoveryTimer);
this.discoveryTimer = null;
};
/**
* Checks if all predefined lights are discovered and online
* @return {Boolean} are lights discovered and online
*/
Client.prototype.predefinedDiscoveredAndOnline = function() {
const predefinedDevices = filter(this.devices, (device) => includes(this.lightAddresses, device.address));
const numDiscovered = keys(this.devices).length;
const allDiscovered = numDiscovered >= this.lightAddresses.length;
const allOnline = every(predefinedDevices, (device) => device.status === 'on');
const labelsReceived = every(predefinedDevices, (device) => isString(device.label));
return allDiscovered && allOnline && labelsReceived;
};
/**
* Send a LIFX message objects over the network
* @param {Object} msg A message object or multiple with data to send
* @param {Function} [callback] Function to handle error and success after send
* @return {Number} The sequence number of the request
*/
Client.prototype.send = function(msg, callback) {
const packet = {
timeCreated: Date.now(),
timeLastSent: 0,
timesSent: 0,
transactionType: constants.PACKET_TRANSACTION_TYPES.ONE_WAY
};
// Add the target ip address if target given
if (msg.address !== undefined) {
packet.address = msg.address;
}
if (msg.target !== undefined) {
const targetBulb = this.light(msg.target);
if (targetBulb) {
packet.address = targetBulb.address;
// If we would exceed the max value for the int8 field start over again
if (this.sequenceNumber >= constants.PACKET_HEADER_SEQUENCE_MAX) {
this.sequenceNumber = 0;
} else {
this.sequenceNumber += 1;
}
}
}
msg.sequence = this.sequenceNumber;
packet.sequence = this.sequenceNumber;
if (typeof callback === 'function') {
msg.ackRequired = true;
this.addMessageHandler('acknowledgement', callback, msg.sequence);
packet.transactionType = constants.PACKET_TRANSACTION_TYPES.REQUEST_RESPONSE;
}
packet.data = Packet.toBuffer(msg);
const queueAddress = packet.address;
const messageQueue = this.getMessageQueue(packet.address);
messageQueue.unshift(packet);
this.startSendingProcess(queueAddress);
return this.sequenceNumber;
};
/**
* Get network address data from connection
* @return {Object} Network address data
*/
Client.prototype.address = function() {
let address = null;
try {
address = this.socket.address();
} catch (e) {
// ignored
}
return address;
};
/**
* Sets debug on or off at runtime
* @param {Boolean} debug debug messages on
*/
Client.prototype.setDebug = function(debug) {
if (typeof debug !== 'boolean') {
throw new TypeError('LIFX Client setDebug expects boolean as parameter');
}
this.debug = debug;
};
/**
* Adds a message handler that calls a function when the requested
* info was received
* @param {String} type A type of packet to listen for, like stateLight
* @param {Function} callback the function to call if the packet was received,
* this will be called with parameters msg and rinfo
* @param {Number} [sequenceNumber] Expects a specific sequenceNumber on which will
* be called, this will call it only once. If not
* given the callback handler is permanent
*/
Client.prototype.addMessageHandler = function(type, callback, sequenceNumber) {
if (typeof type !== 'string') {
throw new TypeError('LIFX Client addMessageHandler expects type parameter to be string');
}
if (typeof callback !== 'function') {
throw new TypeError('LIFX Client addMessageHandler expects callback parameter to be a function');
}
const typeName = find(Packet.typeList, {name: type});
if (typeName === undefined) {
throw new RangeError('LIFX Client addMessageHandler unknown packet type: ' + type);
}
const handler = {
type: type,
callback: callback.bind(this),
timestamp: Date.now()
};
if (sequenceNumber !== undefined) {
if (typeof sequenceNumber !== 'number') {
throw new TypeError('LIFX Client addMessageHandler expects sequenceNumber to be a integer');
} else {
handler.sequenceNumber = sequenceNumber;
}
}
this.messageHandlers.push(handler);
};
/**
* Returns the list of all known lights
* @example client.lights()
* @param {String} [status='on'] Status to filter for, empty string for all
* @return {Array} Lights
*/
Client.prototype.lights = function(status) {
if (status === undefined) {
status = 'on';
} else if (typeof status !== 'string') {
throw new TypeError('LIFX Client lights expects status to be a string');
}
if (status.length > 0) {
if (status !== 'on' && status !== 'off') {
throw new TypeError('Lifx Client lights expects status to be \'on\', \'off\' or \'\'');
}
const result = [];
forEach(this.devices, function(light) {
if (light.status === status) {
result.push(light);
}
});
return result;
}
return this.devices;
};
/**
* Find a light by label, id or ip
* @param {String} identifier label, id or ip to search for
* @return {Object|Boolean} the light object or false if not found
*/
Client.prototype.light = function(identifier) {
let result;
if (typeof identifier !== 'string') {
throw new TypeError('LIFX Client light expects identifier for LIFX light to be a string');
}
// There is no ip or id longer than 45 chars, no label longer than 32 bit
if (identifier.length > 45 && Buffer.byteLength(identifier, 'utf8') > 32) {
return false;
}
// Dots or colons is high likely an ip
if (identifier.indexOf('.') >= 0 || identifier.indexOf(':') >= 0) {
result = find(this.devices, {address: identifier}) || false;
if (result !== false) {
return result;
}
}
// Search id
result = find(this.devices, {id: identifier}) || false;
if (result !== false) {
return result;
}
// Search label
result = find(this.devices, {label: identifier}) || false;
return result;
};
exports.Client = Client;