UNPKG

emberplus-connection

Version:

Javascript implementation of the Ember+ automation protocol

615 lines 26.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.EmberClient = exports.ConnectionStatus = exports.ExpectResponse = void 0; const types_1 = require("../../types/types"); const EmberElement_1 = require("../../model/EmberElement"); const Command_1 = require("../../model/Command"); const Connection_1 = require("../../model/Connection"); const eventemitter3_1 = require("eventemitter3"); const Socket_1 = require("../Socket"); const util_1 = require("../Lib/util"); const ber_1 = require("../../encodings/ber"); const Tree_1 = require("../../model/Tree"); const StreamManager_1 = require("./StreamManager"); var ExpectResponse; (function (ExpectResponse) { ExpectResponse["None"] = "none"; ExpectResponse["Any"] = "any"; ExpectResponse["HasChildren"] = "has-children"; })(ExpectResponse = exports.ExpectResponse || (exports.ExpectResponse = {})); var ConnectionStatus; (function (ConnectionStatus) { ConnectionStatus[ConnectionStatus["Error"] = 0] = "Error"; ConnectionStatus[ConnectionStatus["Disconnected"] = 1] = "Disconnected"; ConnectionStatus[ConnectionStatus["Connecting"] = 2] = "Connecting"; ConnectionStatus[ConnectionStatus["Connected"] = 3] = "Connected"; })(ConnectionStatus = exports.ConnectionStatus || (exports.ConnectionStatus = {})); class EmberClient extends eventemitter3_1.EventEmitter { constructor(host, port = 9000, timeout = 3000, enableResends = false, resendTimeout = 1000) { super(); this.tree = []; this._requests = new Map(); this._lastInvocation = 0; this._subscriptions = []; this._timeout = 3000; this._resendTimeout = 1000; this._resends = false; this.host = host; this.port = port; this._timeout = timeout; this._resendTimeout = resendTimeout; this._resends = enableResends; this._streamManager = new StreamManager_1.StreamManager(); // Forward stream events from StreamManager this._streamManager.on('streamUpdate', (path, value) => { this.emit('streamUpdate', path, value); }); // resend timer runs at greatest common divisor of timeouts and resends const findGcd = (a, b) => { // assuming a and b are greater than 0 while (b) { const t = b; b = a % b; a = t; } return a; }; this._timer = setInterval(() => this._resendTimer(), findGcd(this._timeout, this._resendTimeout)); this._client = new Socket_1.S101Client(this.host, this.port); this._client.on('emberTree', (tree) => { // Regular ember tree this._handleIncoming(tree); }); this._client.on('emberStreamTree', (tree) => { // Ember Tree with Stream const entries = tree.value; this._streamManager.updateStreamValues(entries); }); this._client.on('error', (e) => this.emit('error', e)); this._client.on('connected', () => this.emit('connected')); this._client.on('disconnected', () => { this._requests.forEach((req) => { req.reject(new Error('Socket was disconnected')); this._requests.delete(req.reqId); }); this.emit('disconnected'); }); } /** * Opens an s101 socket to the provider. * @param host The host of the emberplus provider * @param port Port of the provider */ async connect(host, port) { if (host) this.host = host; if (port) this.port = port; if (!this.host) return Promise.reject('No host specified'); this._client.address = this.host; this._client.port = this.port; return this._client.connect(); } /** * Closes the s101 socket to the provider */ async disconnect() { return this._client.disconnect(); } /** * Discards any outgoing connections, removes all requests and clears any timing loops * * This is destructive, using this class after discarding will cause errors. */ discard() { this.disconnect().catch(() => null); // we're not worried about errors after this this._client.removeAllListeners(); // @ts-expect-error: after using this method, properties are no longer expected to always exist delete this._client; this._requests.forEach((req) => { req.reject(new Error('Socket was disconnected')); this._requests.delete(req.reqId); }); clearInterval(this._timer); } get connected() { return this._client.status === ConnectionStatus.Connected; } /** Ember+ commands: */ async getDirectory(node, dirFieldMask, cb) { if (!node) { throw new Error('No node specified'); } const command = new Command_1.GetDirectoryImpl(dirFieldMask); if (!('number' in node || 'path' in node)) { if (cb) this._subscriptions.push({ path: undefined, cb, }); return this._sendRequest(new Tree_1.NumberedTreeNodeImpl(0, command), ExpectResponse.Any); } if (cb) this._subscriptions.push({ path: (0, util_1.getPath)(node), cb, }); return this._sendCommand(node, command, ExpectResponse.HasChildren); } async subscribe(node, cb) { if (!node) { throw new Error('No node specified'); } const command = new Command_1.SubscribeImpl(); if (Array.isArray(node)) { if (cb) this._subscriptions.push({ path: undefined, cb, }); return this._sendRequest(new Tree_1.NumberedTreeNodeImpl(0, command), ExpectResponse.Any); } // Check if this is a Parameter with streamIdentifier if (node.contents.type === EmberElement_1.ElementType.Parameter) { const parameter = node.contents; if (parameter.streamIdentifier !== undefined) { this._streamManager.registerParameter(parameter, (0, util_1.getPath)(node)); } } if (cb) this._subscriptions.push({ path: (0, util_1.getPath)(node), cb, }); return this._sendCommand(node, command, ExpectResponse.None); } async unsubscribe(node) { if (!node) { throw new Error('No node specified'); } const command = new Command_1.UnsubscribeImpl(); const path = Array.isArray(node) ? '' : (0, util_1.getPath)(node); // Clean up subscriptions for (const i in this._subscriptions) { if (this._subscriptions[i].path === path) { this._subscriptions.splice(Number(i), 1); } } // Deregister from StreamManager if this was a Parameter with streamIdentifier if (!Array.isArray(node) && node.contents.type === EmberElement_1.ElementType.Parameter) { const parameter = node.contents; if (parameter.streamIdentifier !== undefined) { this._streamManager.unregisterParameter(path); } } if (Array.isArray(node)) { return this._sendRequest(new Tree_1.NumberedTreeNodeImpl(0, command), ExpectResponse.Any); } return this._sendCommand(node, command, ExpectResponse.None); } async invoke(node, ...args) { if (!node) { throw new Error('No node specified'); } // TODO - validate arguments const command = { type: EmberElement_1.ElementType.Command, number: Command_1.CommandType.Invoke, invocation: { id: ++this._lastInvocation, args, }, }; return this._sendCommand(node, command, ExpectResponse.Any); } /** Sending ember+ values */ async setValue(node, value, awaitResponse = true) { if (!node) { throw new Error('No node specified'); } const qualifiedParam = (0, util_1.assertQualifiedEmberNode)(node); // TODO - validate value // TODO - should other properties be scrapped qualifiedParam.contents.value = value; return this._sendRequest(qualifiedParam, awaitResponse ? ExpectResponse.Any : ExpectResponse.None); } async matrixConnect(matrix, target, sources) { return this._matrixMutation(matrix, target, sources, Connection_1.ConnectionOperation.Connect); } async matrixDisconnect(matrix, target, sources) { return this._matrixMutation(matrix, target, sources, Connection_1.ConnectionOperation.Disconnect); } async matrixSetConnection(matrix, target, sources) { return this._matrixMutation(matrix, target, sources, Connection_1.ConnectionOperation.Absolute); } /** Getting the tree: */ async expand(node) { if (!node) { throw new Error('No node specified'); } if (!('number' in node)) { await (await this.getDirectory(node)).response; for (const root of Object.values(this.tree)) await this.expand(root); return; } const emberNodes = [node]; const canBeExpanded = (node) => { if (node.contents.type === EmberElement_1.ElementType.Node) { return node.contents.isOnline !== false; } else { return node.contents.type !== EmberElement_1.ElementType.Parameter && node.contents.type !== EmberElement_1.ElementType.Function; } }; let curEmberNode; while ((curEmberNode = emberNodes.shift())) { if (curEmberNode.children) { emberNodes.push(...Object.values(curEmberNode.children).filter(canBeExpanded)); } else { const req = await this.getDirectory(curEmberNode); if (!req.response) continue; const res = (await req.response); if (res.children) { Object.values(res.children).forEach((c) => canBeExpanded(c) && emberNodes.push(c)); } } } } async getElementByPath(path, cb, delimiter = '.') { const getNodeInCollection = (elements, identifier) => Object.values(elements || {}).find((r) => r.number === Number(identifier) || r.contents.identifier === identifier || r.contents.description === identifier); const getNextChild = (node, identifier) => node.children && getNodeInCollection(node.children, identifier); const numberedPath = []; const pathArr = path.split(delimiter); const firstIdentifier = pathArr.shift(); if (!firstIdentifier) throw new Error('Expected at least one segment in the path'); let tree = getNodeInCollection(this.tree, firstIdentifier); if (tree?.number !== undefined) numberedPath.push(tree.number); while (pathArr.length) { const i = pathArr.shift(); if (i === undefined) break; // TODO - this will break the loop if the path was `1..0` if (!tree) break; let next = getNextChild(tree, i); if (!next) { const req = await this.getDirectory(tree); tree = (await req.response); next = getNextChild(tree, i); } tree = next; if (!tree) throw new Error(`Could not find node ${i} on given path ${numberedPath.join()}`); if (tree?.number !== undefined) numberedPath.push(tree.number); } if (tree?.contents.type === EmberElement_1.ElementType.Parameter) { // do an additional getDirectory because Providers do not _have_ to send updates without that (should vs shall) const req = await this.getDirectory(tree); await req.response; } if (cb && numberedPath) { this._subscriptions.push({ path: numberedPath.join('.'), cb, }); } return tree; } // This function handles the fact that the path in the Ember+ tree is not always the same as the path in requested from the provider getInternalNodePath(node) { if ('path' in node && typeof node.path === 'string') { // QualifiedElement case return node.path; } else if ('number' in node) { // NumberedTreeNode case const numbers = []; let current = node; while (current) { numbers.unshift(current.number); if (current.parent && 'number' in current.parent) { current = current.parent; } else { current = undefined; } } return numbers.join('.'); } return undefined; } async _matrixMutation(matrix, target, sources, operation) { if (!matrix) { throw new Error('No matrix specified'); } const qualifiedMatrix = (0, util_1.assertQualifiedEmberNode)(matrix); const connection = { operation, target, sources, }; qualifiedMatrix.contents.connections = [connection]; return this._sendRequest(qualifiedMatrix, ExpectResponse.Any); } async _sendCommand(node, command, expectResponse) { // assert a qualified EmberNode const qualifiedEmberNode = (0, util_1.assertQualifiedEmberNode)(node); // insert command const commandEmberNode = (0, util_1.insertCommand)(qualifiedEmberNode, command); // send request return this._sendRequest(commandEmberNode, expectResponse); } async _sendRequest(node, expectResponse) { const reqId = Math.random().toString(24).substr(-4); const requestPromise = { reqId, sentOk: false, }; const message = (0, ber_1.berEncode)([node], types_1.RootType.Elements); if (expectResponse !== ExpectResponse.None) { const p = new Promise((resolve, reject) => { const request = { reqId, node, nodeResponse: expectResponse, resolve, reject, message, firstSent: Date.now(), lastSent: Date.now(), }; this._requests.set(reqId, request); requestPromise.cancel = () => { reject(new Error('Request cancelled')); this._requests.delete(reqId); }; }); requestPromise.response = p; } const sentOk = this._client.sendBER(message); // TODO - if sending multiple values to same path, should we do synchronous requests? if (!sentOk && requestPromise.cancel) { this._requests.get(reqId)?.reject(new Error('Request was not sent correctly')); this._requests.delete(reqId); } return { ...requestPromise, sentOk, }; } _handleIncoming(incoming) { const node = incoming.value; // update tree: const changes = this._applyRootToTree(node); // check for subscriptiions: for (const change of changes) { const subscription = this._subscriptions.find((s) => s.path === change.path); if (subscription && change.node) subscription.cb(change.node); } // check for any outstanding requests and resolve them // iterate over requests, check path, if Invocation check id // resolve requests for (const change of changes) { const reqs = Array.from(this._requests.values()).filter((s) => (!('path' in s.node) && !change.path) || ('path' in s.node && s.node.path === change.path)); for (const req of reqs) { // Don't complete the response, if the call was expecting the children to be loaded if (req.nodeResponse === ExpectResponse.HasChildren && !change.node.children) { if (change.node.contents.type === EmberElement_1.ElementType.Parameter) { // can't have children, therefore don't continue } else if (change.emptyNode) { // update comes from an empty node, so we can't continue anyway } else { continue; } } if (req.cb) req.cb(change.node); if (req.resolve) { req.resolve(change.node); this._requests.delete(req.reqId); } } } // at last, emit the errors for logging purposes incoming.errors?.forEach((e) => this.emit('warn', e)); } _applyRootToTree(node) { const changes = []; if ('id' in node) { // node is an InvocationResult this._requests.forEach((req) => { if (req.node.contents.type === EmberElement_1.ElementType.Function) { if (req.node.children && req.node.children[0]) { if ('invocation' in req.node.children[0].contents) { if (req.node.children[0].contents.invocation?.id && req.node.children[0].contents.invocation?.id === node.id) { req.resolve(node); this._requests.delete(req.reqId); } } } } }); } else { // EmberNode is not an InvocationResult // walk tree for (const rootElement of Object.values(node)) { if ('identifier' in rootElement) { // rootElement is a StreamEntry continue; } else if ('path' in rootElement) { // element is qualified const path = rootElement.path.split('.'); let tree = this.tree[Number(path.shift())]; let inserted = false; if (!tree) { if (path.length) { // Assuming this means that no get directory was done on the root of the tree. changes.push({ path: rootElement.path, node: rootElement }); continue; } else { const number = Number(rootElement.path); // Insert node into root this.tree[number] = new Tree_1.NumberedTreeNodeImpl(number, rootElement.contents, rootElement.children); changes.push({ path: undefined, node: this.tree[number] }); continue; } } for (const number of path) { if (!tree.children) tree.children = {}; if (!tree.children[Number(number)]) { tree.children[Number(number)] = { ...rootElement, number: Number(number), parent: tree, }; changes.push({ path: rootElement.path.split('.').slice(0, -1).join('.'), node: tree, }); inserted = true; break; } tree = tree.children[Number(number)]; } if (inserted) continue; changes.push(...this._updateTree(rootElement, tree)); } else { if (rootElement.children) { if (this.tree[rootElement.number]) { changes.push(...this._updateTree(rootElement, this.tree[rootElement.number])); } else { this.tree[rootElement.number] = rootElement; changes.push({ path: undefined, node: rootElement }); } } else if ((0, util_1.isEmptyNode)(rootElement)) { // empty node on the root of the tree must mean we have done a getDir on that specific node changes.push({ path: rootElement.number + '', node: rootElement, emptyNode: true }); } else { // this must have been something on the root of the tree (like GetDirectory) this.tree[rootElement.number] = rootElement; changes.push({ path: undefined, node: rootElement }); } } } } return changes; } _updateTree(update, tree) { const changes = []; if (update.contents.type === tree.contents.type) { changes.push({ path: (0, util_1.getPath)(tree), node: tree, emptyNode: (0, util_1.isEmptyNode)(update) }); // changes.push({ path: getPath(tree), node: tree }) switch (tree.contents.type) { case EmberElement_1.ElementType.Node: this._updateEmberNode(update.contents, tree.contents); break; case EmberElement_1.ElementType.Parameter: this._updateParameter(update.contents, tree.contents); break; case EmberElement_1.ElementType.Matrix: this._updateMatrix(update.contents, tree.contents); break; } } if (update.children && tree.children) { // Update children for (const child of Object.values(update.children)) { const i = child.number; const oldChild = tree.children[i]; // as NumberedTreeNode<EmberElement> | undefined // TODO changes.push(...this._updateTree(child, oldChild)); } } else if (update.children) { changes.push({ path: (0, util_1.getPath)(tree), node: tree }); tree.children = update.children; for (const c of Object.values(update.children)) { c.parent = tree; } } return changes; } _updateEmberNode(update, EmberNode) { (0, util_1.updateProps)(EmberNode, update, ['isOnline']); } _updateParameter(update, parameter) { (0, util_1.updateProps)(parameter, update, ['value', 'isOnline', 'access']); } _updateMatrix(update, matrix) { (0, util_1.updateProps)(matrix, update, ['targets', 'targetCount', 'sources', 'sourceCount', 'connections']); // update connections if (update.connections) { if (matrix.connections) { // matrix already has connections for (const connection of Object.values(update.connections)) { if (!connection.disposition || !(connection.disposition === Connection_1.ConnectionDisposition.Locked || connection.disposition === Connection_1.ConnectionDisposition.Pending)) { // update is either generic, tally or modification let exists = false; for (const i in matrix.connections) { if (matrix.connections[i].target === connection.target) { // found connection to update exists = true; matrix.connections[i].sources = connection.sources; } } if (!exists) { // connection to target does not exist yet matrix.connections[connection.target] = { target: connection.target, sources: connection.sources, }; } } } } else { // connections have not been set yet matrix.connections = update.connections; } } } _resendTimer() { if (this.connected) { this._requests.forEach((req) => { const sinceSent = Date.now() - req.lastSent; const sinceFirstSent = Date.now() - req.firstSent; if (this._resends && sinceSent >= this._resendTimeout) { const sent = this._client.sendBER(req.message); if (sent) { req.lastSent = Date.now(); } else { req.reject(new Error('Request was not sent correctly')); } } if (sinceFirstSent >= this._timeout) { req.reject(new Error('Request timed out')); this._requests.delete(req.reqId); } }); } } } exports.EmberClient = EmberClient; //# sourceMappingURL=index.js.map