nitrogen
Version:
Nitrogen is a platform for building connected devices. Nitrogen provides the authentication, authorization, and real time message passing framework so that you can focus on your device and application. All with a consistent development platform that lev
350 lines (304 loc) • 13.3 kB
JavaScript
var Message = require('./message');
/**
* The CommandManager object provides infrastructure for command processing within Nitrogen. Commands are messages that are issued
* with the intent of changing the state of a principal in the system. The CommandManager's role is to watch that principal's message
* stream and effect state changes on the device it is managing based on these commands and their context in the stream. The CommandManager
* is always subclassed into a class that handles a specific type of command. This subclass is responsible for providing a set of functions
* that define how the message stream should be interpreted and acted on: executeActiveCommands, isRelevant, isCommand, obsoletes. See each
* function in this class for more information on what an implementation of these functions should provide.
*
* An example of how a SwitchManager subclass is used to control a light in a device application looks like this:
*
* service.connect(light, function(err, session, light) {
* if (err) return console.log('failed to connect light: ' + err);
*
* var switchManager = new nitrogen.SwitchManager(light);
* switchManager.start(session, { $or: [ { to: light.id }, { from: light.id } ] }, function(err) {
* if (err) return console.log('switchManager failed to start: ' + err);
* });
* });
*
* @class CommandManager
* @namespace nitrogen
*/
function CommandManager(device) {
this.device = device;
this.executing = false;
this.messageQueue = [];
}
/**
* Returns the array of commands that are currently active for this manager. This is typically used to execute them during executeQueue.
*
* @method activeCommands
* @returns {Array of Messages} Array of active command messages.
**/
CommandManager.prototype.activeCommands = function() {
var filtered = [];
this.messageQueue.forEach(function(message) {
if (message.millisToTimestamp() <= 0) {
filtered.push(message);
}
});
return filtered;
};
CommandManager.prototype.commandFilter = function() {
return {
tags: CommandManager.commandTag(this.device.id)
};
};
CommandManager.commandTag = function(principalId) {
return "command:" + principalId;
};
/**
* Reduce the current message queue to the set of active commands by obsoleting completed and
* expired commands.
*
* @method collapse
**/
CommandManager.prototype.collapse = function() {
var collapsedMessages = [];
var upstreamMessage;
var self = this;
this.sortQueue();
// first, strip all non relevant messages.
var relevantMessages = [];
this.messageQueue.forEach(function(message) {
if (self.isRelevant(message)) {
relevantMessages.push(message);
}
});
// dequeue the first message in the message queue leaving only the downstream messages...
while ((upstreamMessage = relevantMessages.shift())) {
if (self.isCommand(upstreamMessage)) {
var idx;
var obsoleted = false;
for (idx=0; idx < relevantMessages.length && !obsoleted; idx++) {
var downstreamMessage = relevantMessages[idx];
obsoleted = this.obsoletes(downstreamMessage, upstreamMessage);
}
if (!obsoleted) {
collapsedMessages.push(upstreamMessage);
// console.log('collapse: message: ' + upstreamMessage.from + '->' + upstreamMessage.to + ' not obsolete, ');
} else {
// console.log('collapse: message: ' + upstreamMessage.from + '->' + upstreamMessage.to + ' has been obsoleted, removing.');
}
} else {
// console.log('collapse: message: ' + upstreamMessage.from + '->' + upstreamMessage.to + ' is type: ' + upstreamMessage.type + ': removing.');
}
}
this.messageQueue = collapsedMessages;
};
/**
* Executes the currently active command queue. If the next command's timestamp occurs in the future,
* setup a timeout to retry then.
*
* @method execute
* @private
**/
CommandManager.prototype.execute = function() {
var self = this;
if (this.device.id !== this.session.principal.id) return self.session.log.debug('CommandManager::execute: not in session of device, skipping execution.');
if (this.executing) return; // self.session.log.debug('CommandManager::execute: already executing command, skipping execute. current queue: ' + JSON.stringify(this.messageQueue));
if (!this.messageQueue || this.messageQueue.length === 0) return self.session.log.debug('CommandManager::execute: empty command queue.');
var msToExecute = this.messageQueue[0].millisToTimestamp();
if (msToExecute > 0) {
self.session.log.debug('CommandManager::execute: top message occurs in the future, setting timeout for ' + msToExecute + 'ms.');
return setTimeout(function() { self.execute(); }, msToExecute);
}
this.executing = true;
this.executeQueue(function(err) {
self.executing = false;
if (err) self.session.log.error('CommandManager::execute: execution error: ' + err);
// set up a new execution pass on the command queue.
setTimeout(function() { self.execute(); }, 0);
});
};
/**
* Executes the active commands in the message queue. Should be implemented by subclasses of CommandManager.
*
* Commands that have reached this point have been passed the start filter (see the CommandManager.start() method details),
* had a "true" result out of the CommandManager.isRelevant and CommandManager.isCommand methods and finally has recieved a "false"
* to the CommandManager.obsoletes method.
*
* An example use of this from the Simple Device guide:
*
* SimpleLEDManager.prototype.executeQueue = function(callback) {
* var self = this;
*
* // If you don't have a device, throw an error.
* if (!this.device) return callback(new Error('no device attached to control manager.'));
*
* var activeCommands = this.activeCommands();
* if (activeCommands.length === 0) {
* this.session.log.warn('SimpleLEDManager::executeQueue: no active commands to execute.');
* return callback();
* }
*
* // We will take all of the activeCommand's ids and use pass them to the "response_to".
* // This is important for the "obsoletes" method.
* var commandIds = [];
* activeCommands.forEach(function(activeCommand) {
* commandIds.push(activeCommand.id);
* });
*
* // Create the status response
* var message = new nitrogen.Message({
* type: '_myStatusResponse',
* tags: nitrogen.CommandManager.commandTag(self.device.id),
* body: {
* command: {
* message: "My status is good"
* }
* },
* response_to: commandIds
* });
*
* message.send(_session, function(err, message) {
* if (err) return callback(err);
*
* // Let the command manager know we processed this _lightOn message by passing it the _isOn message.
* self.process(new nitrogen.Message(message));
*
* // Need to callback if there aren't any issues so commandManager can proceed.
* return callback();
* });
* }
*
* @method executeQueue
**/
/**
* Return true if this message is relevant to the CommandManager. Should be implemented by subclasses of CommandManager.
*
* This works in concert with the filter passed into the server in the start method. It enables more intricate processing and
* filtering than the simple filter. It is also possible that you could just return true on the method if your filter is correct and you
* don't want to filter out additional messages. For example:
*
* MyCommandManager.prototype.isRelevant = function(message) {
* return (message.body.property === "value I'm looking for");
* };
*
* @method isRelevant
* @param {Object} message The message to test for relevance.
**/
/**
* Return true if this message is a command that this CommandManager can process. Should be implemented by subclasses of CommandManager.
*
* For example:
*
* MyCommandManager.prototype.isCommand = function(message) {
* return message.is('_myCommandName');
* };
*
* @method isCommand
* @param {Object} message The message to test to see if it is a relevant command.
**/
/**
* Return the last active command in the message queue as ordered by timestamp.
*
* @method lastActiveCommand
**/
CommandManager.prototype.lastActiveCommand = function() {
var activeCommands = this.activeCommands();
return activeCommands.length > 0 ? activeCommands[activeCommands.length - 1] : null;
};
/**
* Returns true if the given message upstream in time of the given downstream message is obsoleted by
* the downstream message. Should be overridden by subclasses to provide command type specific
* obsoletion logic. Overrides should start their implementation by calling this function for base
* functionality.
*
* For example:
*
* MyCommandManager.prototype.obsoletes = function(downstreamMsg, upstreamMsg) {
* if (nitrogen.CommandManager.obsoletes(downstreamMsg, upstreamMsg))
* return true;
*
* var value = downstreamMsg.is("_myStatusResponse") &&
* downstreamMsg.isResponseTo(upstreamMsg) &&
* upstreamMsg.is("_myStatusRequest");
*
* return value;
* };
*
* @method obsoletes
* @param {Object} downstreamMsg The downstream message that potentially obsoletes the upstream message.
* @param {Object} upstreamMsg The upstream message that is potentially obsoleted by the downstream message.
**/
CommandManager.obsoletes = function(downstreamMsg, upstreamMsg) {
if (downstreamMsg.ts < upstreamMsg.ts) return false;
if (downstreamMsg.expired()) return false;
if (upstreamMsg.expired()) return true;
return false;
};
/**
* Process new message in this principal's message stream: adds it to the messageQueue, collapses the
* current message stream, and sets up timeout to handle expiration of this message and the subsequent
* collapse that should occur.
*
* @method process
* @param {Object} message The new message to process.
* @private
**/
CommandManager.prototype.process = function(message) {
if (!this.isRelevant(message)) return;
this.messageQueue.push(message);
this.collapse();
var self = this;
if (message.expires) {
var nextExpiration = Math.max(message.millisToExpiration(), 0);
setTimeout(function() { self.collapse(); }, nextExpiration);
}
};
CommandManager.prototype.sortQueue = function() {
this.messageQueue.sort(function(a,b) {
return a.ts - b.ts;
});
};
/**
* Starts command processing on the message stream using the principal's session. It fetches all the
* current messages, processes them, and then starts execution. It also establishes a subscription to
* handle new messages and automatically executes them as they are received.
*
* The filter specified is passed to the service as both a query and subscription and should specify a bound on the messages you'd like
* to receive. By default, it uses a commandFilter (as defined in commandFilter) which is a tag applied to messages that are "command relevant"
* for this device. and is an opt in model for messages that you want to recieve. You can further filter out messages if you like but this can handle
* nearly all cases. In the case that you can't specify a perfect filter, you can further filter the message stream by subclassing the
* "isRelevant" function.
*
* In the most common case, your implementation of start in your CommandManager subclass should look like this:
*
* MyCommandManager.prototype.start = function(session, callback) {
* return nitrogen.CommandManager.prototype.start.call(this, session, null, callback);
* };
*
* @method start
* @param {Object} session Session this CommandManager should operate under.
* @param {Object} filter Filter for relevant messages for this CommandManager. This is usually specified by the CommandManager subclass.
* @param {Object} callback Callback of the form f(err, message). Called after each new message received.
**/
CommandManager.prototype.start = function(session, filter, callback) {
this.session = session;
// if no custom filter is passed, use a command query.
if (!filter) {
filter = this.commandFilter();
}
var self = this;
Message.find(session, filter, { sort: { ts: -1 }, limit: 1000 }, function(err, reversedMessages) {
if (err) return callback(err);
var messages = reversedMessages.reverse();
// collapse the message stream down to the just the active commands.
messages.forEach(function(message) { self.process(message); });
self.execute();
session.onMessage(filter, function(message) {
self.process(message);
callback(null, message);
// if we aren't currently executing anything, kickstart execution.
if (!self.executing) {
self.execute();
}
});
session.log.info('commandManager: started.');
callback();
});
};
module.exports = CommandManager;