import { DEFAULT_PARAMS, OBJECTS, MANDATORY_OBJECTS, OBJECT_PARAMETERS } from "./lib/objectList.js";
import { ResourceController } from "./lib/resource.js";
import { EventEmitter } from "events";
import pick from "lodash.pick";
import uniq from "lodash.uniq";
import XebraCommunicator from "xebra-communicator";
import { getInstanceForObjectType, ObjectNode, ParamNode } from "./nodes/index.js";
function isString(v) {
return typeof v === "string" || v instanceof String;
}
const RESOURCE_REQUEST_DOMAIN = Object.freeze({
INFO: "info",
DATA: "data"
});
/**
* List of objects available for synchronization in Xebra. Use this or a subset of this when setting the
* supported_objects option in Xebra.State.
*
* @static
* @constant
* @memberof Xebra
* @type {string[]}
*/
const SUPPORTED_OBJECTS = Object.freeze(Array.from(Object.values(OBJECTS)));
/**
* @namespace Xebra
*/
// /////////////////////
// Type Definitions //
// /////////////////////
/**
* A string or number based id
* @typedef {number|string} NodeId
* @memberof Xebra
*/
/**
* @typedef {number[]} PatchingRect
* @memberof Xebra
* @desc Patching Rectangle attribute consisting of 4 Numbers (x, y, width, height)
*/
/**
* @typedef {number|string|string[]|number[]|object} ParamValueType
* @memberof Xebra
* @desc Generic parameter value type
*/
/**
* @typedef {number[]} Color
* @memberof Xebra
* @desc Color attribute consisting of 4 Numbers in the format of [r, g, b, a]
*/
// /////////////////////
/**
* @desc State instances wrap the state sync and connection with the Max backend.
* @class
*/
class State extends EventEmitter {
/**
* @param {Object} options
* @param {Boolean} options.auto_connect=true - Whether to autoconnect on startup
* @param {String} options.hostname - The host of the Xebra backend
* @param {Number} options.port - The port of the Xebra backend
* @param {Boolean} options.secure=false - Whether to use a secure WS connection or not (ws vs wss)
* @param {Boolean} options.reconnect=true - Whether to try auto-reconnecting after the connection closed
* @param {Number} options.reconnect_attempts=5 - The amount of retries before considering it a failure
* @param {Number} options.reconnect_timeout=1000 - Timeout between reconnects in ms
* @param {string[]} options.supported_objects - List of objects to include in the state
*/
constructor(options) {
super();
const commOptions = pick(options, ["auto_connect", "hostname", "port", "secure", "reconnect", "reconnect_attempts", "reconnect_timeout"]);
if (!options.supported_objects) options.supported_objects = SUPPORTED_OBJECTS;
commOptions.supported_objects = Object.assign({}, MANDATORY_OBJECTS);
options.supported_objects.forEach((objDetails) => {
if (isString(objDetails)) {
const params = OBJECT_PARAMETERS[objDetails];
if (params) {
commOptions.supported_objects[objDetails] = params;
return;
}
if (!MANDATORY_OBJECTS.hasOwnProperty(objDetails)) {
console.log(`WARN: Unsupported or unknown object ${objDetails}. Please use the { name : ""<obj_name>", parameters: ["param_1", "param_2"] } syntax for non built-in objects.`);
return;
}
} else if (typeof objDetails === "object") {
if (!objDetails.name || !isString(objDetails.name) || !objDetails.parameters || !Array.isArray(objDetails.parameters)) {
console.log(`WARN: Skipping object defintion '${JSON.stringify(objDetails)}' Please declare objects using their name or the { name : ""<obj_name>", parameters: ["param_1", "param_2"] } syntax.`);
return;
}
// make sure that all required parameters are in place
let params = DEFAULT_PARAMS.concat(objDetails.parameters);
params = uniq(params);
commOptions.supported_objects[objDetails.name] = params;
return;
}
console.log(`WARN: Skipping object defintion '${objDetails}' Please declare objects using their name or the { name : ""<obj_name>", parameters: ["param_1", "param_2"] } syntax.`);
return;
});
this._communicator = new XebraCommunicator(commOptions);
this._communicator.on(XebraCommunicator.XEBRA_MESSAGES.CONNECTION_CHANGE, this._onConnectionChange);
this._communicator.on(XebraCommunicator.XEBRA_MESSAGES.ADD_NODE, this._addNode);
this._communicator.on(XebraCommunicator.XEBRA_MESSAGES.ADD_PARAM, this._addParam);
this._communicator.on(XebraCommunicator.XEBRA_MESSAGES.CHANNEL_MESSAGE, this._channelMessage);
this._communicator.on(XebraCommunicator.XEBRA_MESSAGES.DELETE_NODE, this._deleteNode);
this._communicator.on(XebraCommunicator.XEBRA_MESSAGES.HANDLE_RESOURCE_DATA, this._handleResourceData);
this._communicator.on(XebraCommunicator.XEBRA_MESSAGES.HANDLE_RESOURCE_INFO, this._handleResourceInfo);
this._communicator.on(XebraCommunicator.XEBRA_MESSAGES.INIT_NODE, this._modifyNode);
this._communicator.on(XebraCommunicator.XEBRA_MESSAGES.MODIFY_NODE, this._modifyNode);
this._communicator.on(XebraCommunicator.XEBRA_MESSAGES.STATEDUMP, this._statedump);
this._communicator.on(XebraCommunicator.XEBRA_MESSAGES.CLIENT_param_changed, this._clientParamChange);
this._resourceRequests = {
info: {
sequence: 0,
resourceToSequence: {},
sequenceToResource: {}
},
data: {
sequence: 0,
resourceToSequence: {},
sequenceToResource: {}
}
};
this._isStateLoaded = false;
this._rootNode = null;
this._state = new Map();
this._patchers = new Map();
this._motionNodes = new Map();
this._resourceController = new ResourceController();
this._resourceController.on("get_resource_info", this._onGetResourceInfo);
}
/**
* Returns whether motion tracking is currently enabled/disabled.
* @type {boolean}
* @readonly
*/
get isMotionEnabled() {
return this._motionNodes.size > 0;
}
/**
* Returns the current connection state.
* @type {number}
* @readonly
* @see {Xebra.CONNECTION_STATES}
*/
get connectionState() {
return this._communicator.connectionState;
}
/**
* Name of the current xebra connection. For some Max objects, like mira.motion and mira.multitouch, multiple xebra
* clients (connected via Xebra.js or the Mira iOS app) can send events to the same object. This name property will
* be appended to these events, so that the events can be routed in Max.
* @type {string}
*/
get name() {
return this._communicator.name;
}
set name(name) {
this._communicator.name = name;
}
/**
* Hostname of the Max WebSocket.
* @type {string}
* @readonly
*/
get hostname() {
return this._communicator.host;
}
/**
* Returns whether the initial state has been received from Max and loaded.
* @type {boolean}
* @readonly
*/
get isStateLoaded() {
return this._isStateLoaded;
}
/**
* Returns the port number of the Max WebSocket.
* @type {number}
* @readonly
*/
get port() {
return this._communicator.port;
}
/**
* WebSocket connection URL.
* @type {string}
* @readonly
*/
get wsUrl() {
return this._communicator.wsUrl;
}
/**
* UUID associated with this state.
* @type {string}
* @readonly
*/
get uuid() {
return this._communicator.uuid;
}
/**
* UID assigned to this state by Max, after connection.
* @private
* @readonly
*/
get xebraUuid() {
return this._communicator.xebraUuid;
}
/* Connection related events */
/**
* @private
* @throws throws an Error when the RESOURCE_REQUEST_DOMAIN is invalid
*/
_makeResourceRequest(context, resource, domain) {
if (domain !== RESOURCE_REQUEST_DOMAIN.INFO && domain !== RESOURCE_REQUEST_DOMAIN.DATA) {
throw new Error(`Resource request domain must be one of: ${RESOURCE_REQUEST_DOMAIN.DATA}, ${RESOURCE_REQUEST_DOMAIN.INFO}`);
}
// If there was a previous request from this resource, remove it
if (this._resourceRequests[domain].resourceToSequence.hasOwnProperty(resource.id)) {
const oldSequence = this._resourceRequests[domain].resourceToSequence[resource.id];
delete this._resourceRequests[domain].sequenceToResource[oldSequence];
}
const sequence = ++this._resourceRequests[domain].sequence;
this._resourceRequests[domain].resourceToSequence[resource.id] = sequence;
this._resourceRequests[domain].sequenceToResource[sequence] = resource;
const payload = {
context: context,
name: resource.filename,
width: resource.dimensions.width,
height: resource.dimensions.height,
sequence: sequence,
as_png: 1 // This asks Max to render SVG surfaces to PNG instead of raw bytes
};
if (domain === RESOURCE_REQUEST_DOMAIN.INFO) {
this._communicator.getResourceInfo(payload);
} else if (domain === RESOURCE_REQUEST_DOMAIN.DATA) {
this._communicator.getResourceData(payload);
}
}
/**
* @private
*/
_onConnectionChange = (status) => {
/**
* This event is emitted when the state of the web socket connection to the Max patch (ConnectionState) changes.
* @event State.connection_changed
*/
this.emit("connection_changed", status);
}
/**
* @private
*/
_onGetResourceInfo = (resource) => {
this._makeResourceRequest(resource.objectContext, resource, "info");
}
/**
* @private
* @fires State.frame_changed
* @fires State.object_changed
* @fires State.patcher_changed
*/
_onNodeChange = (object, param) => {
if (object.type === OBJECTS.MIRA_FRAME) {
/**
* This event is emitted when a parameter of a frame is changed. This change can come from Max or when the value
* of the parameter is set directly.
* @event State.frame_changed
* @param {FrameNode} frame The changed frame
* @param {ParamNode} param The parameter node
*/
if (object.isReady) this.emit("frame_changed", object, param);
}
if (object.type === OBJECTS.PATCHER) {
/**
* This event is emitted when a parameter of a patcher is changed. This change can come from Max or when the
* value of the parameter is set directly.
* @event State.patcher_changed
* @param {PatcherNode} patcher The changed patcher
* @param {ParamNode} param The parameter node
*/
if (object.isReady) this.emit("patcher_changed", object, param);
}
/**
* This event is emitted when a parameter of an object is changed. This change can come from Max or when the value
* of the parameter is set directly.
* @event State.object_changed
* @param {ObjectNode} object The changed object
* @param {ParamNode} param The parameter node
*/
if (object.isReady) this.emit("object_changed", object, param);
}
/**
* @private
*/
_onNodeInitialized = (object) => {
this.emit("object_added", object);
}
/**
* @private
*/
_onFrameInitialized = (frame) => {
this.emit("frame_added", frame);
}
/**
* @private
*/
_onPatcherInitialized = (patcher) => {
this.emit("patcher_added", patcher);
}
/**
* @private
*/
_onModifiyNodeChange = (object, param) => {
let val = param.value;
if (!Array.isArray(val)) val = [val];
this._communicator.sendModifyMessage({
id: param.id,
sequence: param.sequence,
creation_sequence: param.creationSequence,
values: val,
types: param.types
});
}
/**
* @private
*/
_addMotion(node) {
this._motionNodes.set(node.id, node);
if (this._motionNodes.size === 1) {
/**
* This event is emitted when there is at least one mira.motion object in Max.
* @event State.motion_enabled
*/
this.emit("motion_enabled");
}
}
/**
* @private
*/
_getMotion(id) {
return this._motionNodes.get(id) || null;
}
/**
* @private
*/
_removeMotion(node) {
this._motionNodes.delete(node.id);
if (this._motionNodes.size === 0) {
/**
* This event is emitted when the last mira.motion object is removed from Max. This event is not emitted when
* xebra first connects to Max, and there are no mira.motion objects in Max.
* @event State.motion_disabled
*/
this.emit("motion_disabled");
}
}
/**
* @private
*/
_addPatcher(node) {
this._patchers.set(node.id, node);
}
/**
* @private
*/
_getPatcher(id) {
return this._patchers.get(id) || null;
}
/**
* @private
*/
_removePatcher(node) {
this._patchers.delete(node.id);
}
/**
* @private
*
* @listens ObjectNode.param_changed
* @listens ObjectNode#param_set
* @fires State.frame_added
* @fires State.object_added
* @fires State.patcher_added
*/
_addNode = (data) => {
const node = getInstanceForObjectType(data.id, data.type, data.sequence, data.parent_id);
// patchers and frames are handled differently as we have to put them into the correct
// list other than just adding them to the statetree.
if (node.type === OBJECTS.PATCHER) {
this._addPatcher(node);
/**
* This event is emitted when a patcher is added in Max.
* @event State.patcher_added
* @param {PatcherNode} object The added patcher
*/
if (node.isReady) {
this.emit("patcher_added", node);
} else {
node.once("initialized", this._onPatcherInitialized);
}
} else if (data.type === OBJECTS.MIRA_FRAME) {
const parentPatcher = this._getPatcher(data.parent_id);
parentPatcher.addFrame(node);
/**
* This event is emitted when a frame is added in Max.
* @event State.frame_added
* @param {FrameNode} object The added frame
*/
if (node.isReady) {
this.emit("frame_added", node);
} else {
node.once("initialized", this._onFrameInitialized);
}
} else { // object node
const parentPatcher = this._getPatcher(data.parent_id);
parentPatcher.addObject(node);
if (node.type === OBJECTS.MIRA_MOTION) this._addMotion(node);
}
this._doInsertNode(node);
/**
* This event is emitted when an object is added in Max.
* @event State.object_added
* @param {ObjectNode} object The added object
*/
if (node.isReady) {
this.emit("object_added", node);
} else {
node.once("initialized", this._onNodeInitialized);
}
}
/**
* @private
*/
_doInsertNode(node) {
this._state.set(node.id, node);
node.on("param_changed", this._onNodeChange);
node.on("param_set", this._onModifiyNodeChange);
if (node.resourceController) node.resourceController.on("get_resource_info", this._onGetResourceInfo);
}
/**
* @private
*/
_addParam = (data) => {
const parent = this._state.get(data.parent_id);
const param = new ParamNode(data.id, data.type, data.sequence);
this._state.set(param.id, param);
parent.addParam(param);
}
/**
* @private
*/
_channelMessage = (channel, message) => {
/**
* This event is emitted when a message is sent to a mira.channel object
* @event State.channel_message_received
* @param {String} channel The name of the channel where the message was received
* @param {Number|String|Array<Number|String>|Object} message The message received from Max
*/
this.emit("channel_message_received", channel, message);
}
/**
* @private
*/
_clientParamChange = (key, value) => {
/**
* Client param change event
* @private
* @event State#client_param_changed
* @param {String} key
* @param {String} value
*/
this.emit("client_param_changed", key, value);
}
/**
* @private
* @fires State.frame_removed
* @fires State.object_removed
* @fires State.patcher_removed
*/
_deleteNode = (data) => {
const node = this._state.get(data.id);
if (!node) return;
const parentPatcher = this._getPatcher(node.patcherId);
// remove frame from parent patcher
if (node.type === OBJECTS.MIRA_FRAME) {
if (parentPatcher) parentPatcher.removeFrame(node.id);
/**
* This event is emitted when a frame is removed from Max.
* @event State.frame_removed
* @param {FrameNode} object The removed frame
*/
if (node.isReady) this.emit("frame_removed", node);
} else if (node.type === OBJECTS.PATCHER) {
/**
* This event is emitted when a patcher is removed from Max.
* @event State.patcher_removed
* @param {PatcherNode} object The removed patcher
*/
if (node.isReady) this.emit("patcher_removed", node);
} else {
if (parentPatcher) parentPatcher.removeObject(node.id);
}
if (node.type === OBJECTS.MIRA_MOTION) this._removeMotion(node);
this._destroyNode(node);
/**
* This event is emitted when an object is removed from Max.
* @event State.object_removed
* @param {ObjectNode} object The removed object
*/
if (node.isReady) this.emit("object_removed", node);
}
_destroyNode(node) {
node.destroy();
node.forEachChild((child) => {
if (child instanceof ParamNode) this._destroyNode(child);
}, this);
this._state.delete(node.id);
}
/**
* private
*/
_handleResourceData = (data) => {
if (data.request) {
const sequence = data.request.sequence;
const resource = this._resourceRequests.data.sequenceToResource[sequence];
if (resource) {
resource.handleData(data);
delete this._resourceRequests.data.resourceToSequence[resource.id];
delete this._resourceRequests.data.sequenceToResource[sequence];
}
}
}
_handleResourceInfo = (data) => {
if (data.request) {
const sequence = data.request.sequence;
const resource = this._resourceRequests.info.sequenceToResource[sequence];
if (resource) {
this._makeResourceRequest(data.request.context, resource, "data");
delete this._resourceRequests.info.resourceToSequence[resource.id];
delete this._resourceRequests.info.sequenceToResource[sequence];
}
} else {
console.log("Could not handle badly formatted resource info response", data);
}
}
/**
* @private
*/
_modifyNode = (data) => {
const node = this._state.get(data.id);
if (node) node.modify(data.values, data.types, data.sequence);
}
/**
* @private
*/
_statedump = (data) => {
/**
* This event is emitted when the web socket connection is persistently interrupted to the point that the xebra
* state and Max state fall out of sync. In this case, xebra will attempt to reset and rebuild the state, which
* fires this event. This should happen very infrequently. A state#loaded event will fire when this event fires.
* @event State.reset
*/
if (this._state) {
this.emit("reset");
}
this._resetState();
for (let i = 0, il = data.messages.length; i < il; i++) {
const msg = data.messages[i];
if (msg.message === XebraCommunicator.XEBRA_MESSAGES.ADD_NODE) {
this._addNode(msg.payload);
} else if (msg.message === XebraCommunicator.XEBRA_MESSAGES.ADD_PARAM) {
this._addParam(msg.payload);
} else if (msg.message === XebraCommunicator.XEBRA_MESSAGES.MODIFY_NODE) {
this._modifyNode(msg.payload, true);
}
}
/**
* This event is emitted when the entire Max state has been loaded. At this point, all `frame_added`,
* `object_added`, and `patcher_added` events will have fired, and all of their parameters will have been
* loaded. This is analogous to $(document).ready() in jQuery.
* @event State.loaded
*/
this._isStateLoaded = true;
this.emit("loaded");
}
/**
* @private
*/
_resetState() {
// destroy all old nodes
if (this._state) {
this._state.forEach((node) => {
node.destroy();
});
this._state.clear();
}
this._isStateLoaded = false;
this._state = new Map();
this._patchers = new Map();
// reset motion
this._motionNodes = new Map();
this.emit("motion_disabled");
this._rootNode = new ObjectNode(0, "root");
this._doInsertNode(this._rootNode);
}
/**
* Closes the Xebra connection and resets the state.
*/
close() {
this._communicator.close();
this._resetState();
}
/**
* Connects to the Xebra server. If `auto_connect : true` is passed to State on.
*/
connect() {
this._communicator.connect();
}
/**
* Create a {@link Resource}, which can be used to retrieve image data from the Max search path.
* @return {Resource}
*/
createResource() {
return this._resourceController.createResource();
}
/**
* Send an arbitrary message to the named channel. The type of the message will be coerced to
* a Max type in the Max application by mira.channel
* @param {String} channel - The name of the mira.channel objects that should receive this message
* @param {Number|String|Array<Number|String>|Object} message - the message to send
*/
sendMessageToChannel(channel, message) {
this._communicator.sendChannelMessage(channel, message);
}
/**
* Send mira.motion updates to parameters on the root node.
* @see Xebra.MOTION_TYPES
* @param {string} motionType - The type of motion
* @param {number} motionX
* @param {number} motionY
* @param {number} motionZ
* @param {number} timestamp
* @throws Will throw an error when motion is currently disabled on the instance of State.
*/
sendMotionData(motionType, motionX, motionY, motionZ, timestamp) {
const xuuid = this.xebraUuid;
if (!xuuid) return;
if (!this.isMotionEnabled) throw new Error("Can't send motion data when motion is disabled");
this._rootNode.setParamValue(motionType, [
xuuid,
xuuid,
motionX,
motionY,
motionZ,
timestamp
]);
}
/**
* Returns a list of the names of all mira.channel objects in all patchers
* @return {string[]}
*/
getChannelNames() {
const names = new Set();
this._patchers.forEach((patcher) => {
patcher.getChannelNames().forEach((name) => {
names.add(name);
});
});
return Array.from(names);
}
/**
* Returns a list of available patchers.
* @return {PatcherNode[]}
*/
getPatchers() {
return Array.from(this._patchers.values());
}
/**
* Returns a list of node objects with the given scripting name (the Max attribute 'varname').
* @return {ObjectNode[]}
*/
getObjectsByScriptingName(scriptingName) {
const retVal = [];
this._patchers.forEach((patcher, id) => {
const obj = patcher.getObjectByScriptingName(scriptingName);
if (obj) retVal.push(obj);
});
return retVal;
}
/**
* Returns the object speficied by the given id.
* @param {Xebra.NodeId} id - The id of the object
* @return {ObjectNode|null} the object or null if not known
*/
getObjectById(id) {
const node = this._state.get(id);
if (!node || !(node instanceof ObjectNode)) return null;
return node;
}
/**
* Returns the patcher speficied by the given id.
* @param {Xebra.NodeId} id - The id of the patcher
* @return {PatcherNode|null} the patcher or null if not known
*/
getPatcherById(id) {
return this._patchers.get(id) || null;
}
}
export { State };
// constants
/**
* Connection States
* @static
* @constant
* @memberof Xebra
* @type {object}
* @property {number} INIT - The connection hasn't been set up yet, it's still waiting for a call to connect (unless
* auto_connect is set to true)
* @property {number} CONNECTING - The connection is being established
* @property {number} CONNECTED - The connection is established and alive
* @property {number} CONNECTION_FAIL - The connection could NEVER be established
* @property {number} RECONNECTING - The connection was lost and attempts to reconnect are made (based on reconnect,
* reconnect_attempts and reconnect_timeout options)
* @property {number} DISCONNECTED - The connection was lost and all attempts to reconnect failed
*/
const CONNECTION_STATES = XebraCommunicator.CONNECTION_STATES;
const VERSION = XebraCommunicator.XEBRA_VERSION;
export * from "./lib/constants.js";
export {
CONNECTION_STATES,
SUPPORTED_OBJECTS,
VERSION
};