From b0b1ca336dce0f4e0dd0b1221b5c64cc0fb95daa Mon Sep 17 00:00:00 2001 From: Daniel Dangond Date: Mon, 26 Jun 2023 13:42:22 -0400 Subject: [PATCH 01/16] OAuth functionality --- libraries/LocalUIApp.js | 4 +- libraries/objectDefaultFiles/object.js | 29 +++++++++- .../serverHelpers/oauthRequestHandlers.js | 54 +++++++++++++++++++ .../serverHelpers/proxyRequestHandler.js | 30 +++++++++++ .../toolboxEdgeProxyRequestHandler.js | 15 ------ server.js | 15 ++++-- 6 files changed, 124 insertions(+), 23 deletions(-) create mode 100644 libraries/serverHelpers/oauthRequestHandlers.js create mode 100644 libraries/serverHelpers/proxyRequestHandler.js delete mode 100644 libraries/serverHelpers/toolboxEdgeProxyRequestHandler.js diff --git a/libraries/LocalUIApp.js b/libraries/LocalUIApp.js index 631ab3ca8..0aed9b526 100644 --- a/libraries/LocalUIApp.js +++ b/libraries/LocalUIApp.js @@ -2,7 +2,7 @@ const cors = require('cors'); const express = require('express'); const fs = require('fs'); const path = require('path'); -const toolboxEdgeProxyRequestHandler = require('./serverHelpers/toolboxEdgeProxyRequestHandler.js'); +const proxyRequestHandler = require('./serverHelpers/proxyRequestHandler.js'); const contentScriptDir = 'content_scripts'; const contentStyleDir = 'content_styles'; @@ -63,7 +63,7 @@ class LocalUIApp { } res.status(403).send('access prohibited to non-script non-style file'); }); - this.app.get('/proxy/*', toolboxEdgeProxyRequestHandler); + this.app.get('/proxy/*', proxyRequestHandler); if (this.userinterfacePath && fs.existsSync(this.userinterfacePath)) { this.app.use(express.static(this.userinterfacePath)); } else { diff --git a/libraries/objectDefaultFiles/object.js b/libraries/objectDefaultFiles/object.js index 65052c251..b3ecd5ee2 100755 --- a/libraries/objectDefaultFiles/object.js +++ b/libraries/objectDefaultFiles/object.js @@ -28,7 +28,7 @@ object: '', publicData: {}, modelViewMatrix: [], - serverIp: '127.0.0.1', + serverIp: 'localhost', serverPort: '8080', matrices: { modelView: [], @@ -747,6 +747,8 @@ this.analyticsSetSpaghettiVisible = makeSendStub('analyticsSetSpaghettiVisible'); this.analyticsSetAllClonesVisible = makeSendStub('analyticsSetAllClonesVisible'); + this.getOAuthToken = makeSendStub('getOAuthToken'); + // deprecated methods this.sendToBackground = makeSendStub('sendToBackground'); } @@ -1767,6 +1769,31 @@ }); }; + /** + * Makes an OAuth request at `authorizationUrl`, requires the OAuth flow to redirect to navigate:// + * Will not call `callback` on initial OAuth flow, as the whole app gets reloaded + * TODO: Write correct redirect URIs above + * @param {object} urls - OAuth Authorization and Access Token URL + * @param {string} clientId - OAuth client ID + * @param {string} clientSecret - OAuth client secret + * @param {function} callback - Callback function executed once OAuth flow completes + */ + this.getOAuthToken = function(urls, clientId, clientSecret, callback) { + postDataToParent({ + getOAuthToken: { + frame: spatialObject.frame, + clientId: clientId, + clientSecret: clientSecret, + urls + } + }); + spatialObject.messageCallBacks.onOAuthToken = function (msgContent) { + if (typeof msgContent.onOAuthToken !== 'undefined') { + callback(msgContent.onOAuthToken.token, msgContent.onOAuthToken.error); + } + }; + }; + this.getScreenshotBase64 = function(callback) { spatialObject.messageCallBacks.screenshotBase64 = function (msgContent) { if (typeof msgContent.screenshotBase64 !== 'undefined') { diff --git a/libraries/serverHelpers/oauthRequestHandlers.js b/libraries/serverHelpers/oauthRequestHandlers.js new file mode 100644 index 000000000..1ba6a524a --- /dev/null +++ b/libraries/serverHelpers/oauthRequestHandlers.js @@ -0,0 +1,54 @@ +const fetch = require('node-fetch'); +const querystring = require('querystring'); + +const oauthRefreshRequestHandler = (req, res) => { + const refreshUrl = req.params[0]; // TODO: get this from the tool somehow to prevent leaking secret to any supplied url + const data = { + 'grant_type': 'refresh_token', + 'refresh_token': req.body.refresh_token, + 'client_id': req.body.client_id, + 'client_secret': req.body.client_secret, + }; + fetch(refreshUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: querystring.stringify(data) + }).then(response => { + return response.json(); + }).then(data => { + res.send(data); + }).catch(error => { + res.send(error); + }); +}; + +const oauthAcquireRequestHandler = (req, res) => { + const acquireUrl = req.params[0]; // TODO: get this from the addon somehow to prevent leaking secret to any client-supplied url (e.g. via postman) + const data = { + 'grant_type': 'authorization_code', + 'code': req.body.code, + 'redirect_uri': req.body.redirect_uri, + 'client_id': req.body.client_id, + 'client_secret': req.body.client_secret, + }; + fetch(acquireUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: querystring.stringify(data) + }).then(response => { + return response.json(); + }).then(data => { + res.send(data); + }).catch(error => { + res.send(error); + }); +}; + +module.exports = { + oauthRefreshRequestHandler, + oauthAcquireRequestHandler +}; diff --git a/libraries/serverHelpers/proxyRequestHandler.js b/libraries/serverHelpers/proxyRequestHandler.js new file mode 100644 index 000000000..afadc0f9c --- /dev/null +++ b/libraries/serverHelpers/proxyRequestHandler.js @@ -0,0 +1,30 @@ +const https = require('https'); + +const proxyRequestHandler = (req, res) => { + const input = req.params[0]; + if (!input.includes('://')) { + const proxyURL = `https://toolboxedge.net/${req.params[0]}`; + const headers = req.headers; + headers.Host = "toolboxedge.net"; + https.get(proxyURL, {headers}, proxyRes => { + for (let header in proxyRes.headers) { + res.setHeader(header, proxyRes.headers[header]); + } + proxyRes.pipe(res); + }); + } else { + const proxyURL = req.params[0]; + const headers = req.headers; + headers.Host = new URL(proxyURL).host; + const queryParams = new URLSearchParams(req.query); + const url = `${proxyURL}?${queryParams.toString()}`; + https.get(url, {headers}, proxyRes => { + for (let header in proxyRes.headers) { + res.setHeader(header, proxyRes.headers[header]); + } + proxyRes.pipe(res); + }); + } +}; + +module.exports = proxyRequestHandler; diff --git a/libraries/serverHelpers/toolboxEdgeProxyRequestHandler.js b/libraries/serverHelpers/toolboxEdgeProxyRequestHandler.js deleted file mode 100644 index 6cee82fe2..000000000 --- a/libraries/serverHelpers/toolboxEdgeProxyRequestHandler.js +++ /dev/null @@ -1,15 +0,0 @@ -const https = require('https'); - -const toolboxEdgeProxyRequestHandler = (req, res) => { - const proxyURL = `https://toolboxedge.net/${req.params[0]}`; - const headers = req.headers; - headers.Host = "toolboxedge.net"; - https.get(proxyURL, {headers}, proxyRes => { - for (let header in proxyRes.headers) { - res.setHeader(header, proxyRes.headers[header]); - } - proxyRes.pipe(res); - }); -}; - -module.exports = toolboxEdgeProxyRequestHandler; diff --git a/server.js b/server.js index ecba44b5a..665d636ba 100644 --- a/server.js +++ b/server.js @@ -194,9 +194,9 @@ services.getIP = function () { this.ips.interfaces = {}; // if this is mobile, only allow local interfaces if (isLightweightMobile || isStandaloneMobile) { - this.ips.interfaces['mobile'] = '127.0.0.1'; + this.ips.interfaces['mobile'] = 'localhost'; this.ips.activeInterface = 'mobile'; - return '127.0.0.1'; + return 'localhost'; } // Get All available interfaces @@ -216,7 +216,7 @@ services.getIP = function () { for (let key in interfaceNames) { let tempIps = this.networkInterface.toIps(interfaceNames[key], {ipVersion: 4}); - for (let key2 in tempIps) if (tempIps[key2] === '127.0.0.1') tempIps.splice(key2, 1); + for (let key2 in tempIps) if (tempIps[key2] === '127.0.0.1' || tempIps[key2] === 'localhost') tempIps.splice(key2, 1); this.ips.interfaces[interfaceNames[key]] = tempIps[0]; } @@ -587,6 +587,7 @@ const worldGraph = new WorldGraph(sceneGraph); const tempUuid = utilities.uuidTime().slice(1); // UUID of current run of the server (removed initial underscore) const HumanPoseFuser = require('./libraries/HumanPoseFuser'); +const {oauthRefreshRequestHandler} = require('./libraries/serverHelpers/oauthRequestHandlers.js'); const humanPoseFuser = new HumanPoseFuser(objects, sceneGraph, objectLookup, services.ip, version, protocol, beatPort, tempUuid); /********************************************************************************************************************** @@ -2059,8 +2060,12 @@ function objectWebServer() { }); // Proxies requests to toolboxedge.net, for CORS video playback - const toolboxEdgeProxyRequestHandler = require('./libraries/serverHelpers/toolboxEdgeProxyRequestHandler.js'); - webServer.get('/proxy/*', toolboxEdgeProxyRequestHandler); + const proxyRequestHandler = require('./libraries/serverHelpers/proxyRequestHandler.js'); + webServer.get('/proxy/*', proxyRequestHandler); + + const {oauthRefreshRequestHandler, oauthAcquireRequestHandler} = require('./libraries/serverHelpers/oauthRequestHandlers.js'); + webServer.post('/oauthRefresh/*', oauthRefreshRequestHandler); + webServer.post('/oauthAcquire/*', oauthAcquireRequestHandler); // restart the server from the web frontend to load webServer.get('/restartServer/', function () { From 1aeab42301259e2b36b69930851a7c8d8f0cfb05 Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Tue, 19 Sep 2023 16:24:26 -0400 Subject: [PATCH 02/16] adds availableDataStreams and bindNodeToDataStream APIs, for new IoT tools --- libraries/hardwareInterfaces.js | 40 +++++++++++++++++++++++++++++++++ server.js | 13 +++++++++++ 2 files changed, 53 insertions(+) diff --git a/libraries/hardwareInterfaces.js b/libraries/hardwareInterfaces.js index 7d24b7bdc..4fdd7748d 100644 --- a/libraries/hardwareInterfaces.js +++ b/libraries/hardwareInterfaces.js @@ -56,6 +56,8 @@ var screenPortMap = {}; var _this = this; var sceneGraphReference; var worldGraphReference; +let availableDataStreamGetters = []; +let bindNodeToDataStreamCallbacks = {}; //data structures to manage the IO points generated by the API user function ObjectCallbacks() { @@ -1118,6 +1120,44 @@ exports.advertiseConnection = function (object, tool, node, logic) { actionCallback(message); }; +exports.registerAvailableDataStreams = function (callback) { + availableDataStreamGetters.push(callback); +} + +exports.getAllAvailableDataStreams = function() { + let results = []; + availableDataStreamGetters.forEach(callback => { + let theseResults = callback(); + // console.log('result:', theseResults); + theseResults.dataStreams.forEach(dataStream => { + results.push(dataStream); + }); + }); + console.log('all results:', results); + return results; +} + +exports.registerBindNodeEndpoint = function(interfaceName, callback) { + console.log('bindNodeToDataStreamCallbacks 1', bindNodeToDataStreamCallbacks); + if (typeof bindNodeToDataStreamCallbacks[interfaceName] === 'undefined') { + bindNodeToDataStreamCallbacks[interfaceName] = []; + } + console.log('bindNodeToDataStreamCallbacks 2', bindNodeToDataStreamCallbacks); + bindNodeToDataStreamCallbacks[interfaceName].push(callback); + console.log('bindNodeToDataStreamCallbacks 3', bindNodeToDataStreamCallbacks); +} + +exports.bindNodeToDataStream = function({ objectId, frameId, nodeName, nodeType, frameType, hardwareInterface, streamId}) { + console.log('hardwareAPI received data', objectId, frameId, nodeName, nodeType, frameType, hardwareInterface); + + console.log('bindNodeToDataStreamCallbacks 4', bindNodeToDataStreamCallbacks); + + let callbacks = bindNodeToDataStreamCallbacks[hardwareInterface]; + callbacks.forEach(callback => { + callback(objectId, frameId, nodeName, nodeType, frameType, streamId); + }); +} + /** * Used by the server to emit a socket message when settings change * @param {string} interfaceName - exact name of the hardware interface diff --git a/server.js b/server.js index b5a943b88..7bb1f7702 100644 --- a/server.js +++ b/server.js @@ -1808,6 +1808,19 @@ function objectWebServer() { res.json(blockController.getLogicBlockList()); }); + webServer.get('/availableDataStreams/', function(req, res) { + let allAvailableDataStreams = hardwareAPI.getAllAvailableDataStreams(); + res.json({ + dataStreams: allAvailableDataStreams + }); + }); + + webServer.post('/bindNodeToDataStream/', function(req, res) { + console.log('bindNodeToDataStream', req.body); + hardwareAPI.bindNodeToDataStream(req.body); + res.status(200).json({ success: true, error: null }); + }); + // TODO: is the developer flag ever not true anymore? is it still useful to have? if (globalVariables.developer === true) { // // TODO: ask Valentin what this route was used for? From f956d7aa6250a8038da8b9a305a48b6857dc7a0f Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Tue, 26 Sep 2023 13:15:44 -0400 Subject: [PATCH 03/16] cleanup log messages for node binding APIs --- libraries/hardwareInterfaces.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/libraries/hardwareInterfaces.js b/libraries/hardwareInterfaces.js index 4fdd7748d..79b57c350 100644 --- a/libraries/hardwareInterfaces.js +++ b/libraries/hardwareInterfaces.js @@ -1133,25 +1133,18 @@ exports.getAllAvailableDataStreams = function() { results.push(dataStream); }); }); - console.log('all results:', results); return results; } exports.registerBindNodeEndpoint = function(interfaceName, callback) { - console.log('bindNodeToDataStreamCallbacks 1', bindNodeToDataStreamCallbacks); if (typeof bindNodeToDataStreamCallbacks[interfaceName] === 'undefined') { bindNodeToDataStreamCallbacks[interfaceName] = []; } - console.log('bindNodeToDataStreamCallbacks 2', bindNodeToDataStreamCallbacks); bindNodeToDataStreamCallbacks[interfaceName].push(callback); - console.log('bindNodeToDataStreamCallbacks 3', bindNodeToDataStreamCallbacks); } exports.bindNodeToDataStream = function({ objectId, frameId, nodeName, nodeType, frameType, hardwareInterface, streamId}) { console.log('hardwareAPI received data', objectId, frameId, nodeName, nodeType, frameType, hardwareInterface); - - console.log('bindNodeToDataStreamCallbacks 4', bindNodeToDataStreamCallbacks); - let callbacks = bindNodeToDataStreamCallbacks[hardwareInterface]; callbacks.forEach(callback => { callback(objectId, frameId, nodeName, nodeType, frameType, streamId); From 6831774ebb0da7d186aa5fd0e9bcc05e1aaddcfb Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Wed, 27 Sep 2023 08:55:20 -0400 Subject: [PATCH 04/16] adds new addDataSourceToInterface APIs --- libraries/hardwareInterfaces.js | 17 +++++++++++++++++ server.js | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/libraries/hardwareInterfaces.js b/libraries/hardwareInterfaces.js index 79b57c350..46637a44b 100644 --- a/libraries/hardwareInterfaces.js +++ b/libraries/hardwareInterfaces.js @@ -58,6 +58,7 @@ var sceneGraphReference; var worldGraphReference; let availableDataStreamGetters = []; let bindNodeToDataStreamCallbacks = {}; +let addDataSourceCallbacks = {}; //data structures to manage the IO points generated by the API user function ObjectCallbacks() { @@ -1151,6 +1152,22 @@ exports.bindNodeToDataStream = function({ objectId, frameId, nodeName, nodeType, }); } +exports.registerAddDataSourceEndpoint = function(interfaceName, callback) { + if (typeof addDataSourceCallbacks[interfaceName] === 'undefined') { + addDataSourceCallbacks[interfaceName] = []; + } + addDataSourceCallbacks[interfaceName].push(callback); +} + +exports.addDataSourceToInterface = function(interfaceName, dataStream = {}) { + if (!interfaceName) return; + // console.log('hardwareAPI received data', objectId, frameId, nodeName, nodeType, frameType, hardwareInterface); + let callbacks = addDataSourceCallbacks[interfaceName]; + callbacks.forEach(callback => { + callback(dataStream); + }); +} + /** * Used by the server to emit a socket message when settings change * @param {string} interfaceName - exact name of the hardware interface diff --git a/server.js b/server.js index 7bb1f7702..733ef5e28 100644 --- a/server.js +++ b/server.js @@ -1820,6 +1820,12 @@ function objectWebServer() { hardwareAPI.bindNodeToDataStream(req.body); res.status(200).json({ success: true, error: null }); }); + + webServer.post('/addDataSourceToInterface/', function(req, res) { + console.log('addDataSourceToInterface', req.body); + hardwareAPI.addDataSourceToInterface(req.body.interfaceName, req.body.dataStream); + res.status(200).json({ success: true, error: null }); + }); // TODO: is the developer flag ever not true anymore? is it still useful to have? if (globalVariables.developer === true) { From 73acfaa9b8568ee91aa9438de247e0d731b55482 Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Wed, 27 Sep 2023 11:13:16 -0400 Subject: [PATCH 05/16] updates to addDataSourceToInterface APIs --- libraries/hardwareInterfaces.js | 4 ++-- server.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libraries/hardwareInterfaces.js b/libraries/hardwareInterfaces.js index 46637a44b..ff0affcea 100644 --- a/libraries/hardwareInterfaces.js +++ b/libraries/hardwareInterfaces.js @@ -1159,12 +1159,12 @@ exports.registerAddDataSourceEndpoint = function(interfaceName, callback) { addDataSourceCallbacks[interfaceName].push(callback); } -exports.addDataSourceToInterface = function(interfaceName, dataStream = {}) { +exports.addDataSourceToInterface = function(interfaceName, dataSource = {}) { if (!interfaceName) return; // console.log('hardwareAPI received data', objectId, frameId, nodeName, nodeType, frameType, hardwareInterface); let callbacks = addDataSourceCallbacks[interfaceName]; callbacks.forEach(callback => { - callback(dataStream); + callback(dataSource); }); } diff --git a/server.js b/server.js index 733ef5e28..e41d7c1ff 100644 --- a/server.js +++ b/server.js @@ -1823,7 +1823,7 @@ function objectWebServer() { webServer.post('/addDataSourceToInterface/', function(req, res) { console.log('addDataSourceToInterface', req.body); - hardwareAPI.addDataSourceToInterface(req.body.interfaceName, req.body.dataStream); + hardwareAPI.addDataSourceToInterface(req.body.interfaceName, req.body.dataSource); res.status(200).json({ success: true, error: null }); }); From 5a111516fc9eb5504f2fb6712cf6a885be5c070b Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Wed, 8 Nov 2023 17:13:42 -0500 Subject: [PATCH 06/16] add APIs to get list of data sources, and to delete data sources --- libraries/hardwareInterfaces.js | 35 ++++++++++++++++++++++++++++++++- server.js | 13 ++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/libraries/hardwareInterfaces.js b/libraries/hardwareInterfaces.js index 51ec7b448..bf94ff690 100644 --- a/libraries/hardwareInterfaces.js +++ b/libraries/hardwareInterfaces.js @@ -57,8 +57,10 @@ var _this = this; var sceneGraphReference; var worldGraphReference; let availableDataStreamGetters = []; +let availableDataSourceGetters = []; let bindNodeToDataStreamCallbacks = {}; let addDataSourceCallbacks = {}; +let deleteDataSourceCallbacks = {}; //data structures to manage the IO points generated by the API user function ObjectCallbacks() { @@ -1221,6 +1223,22 @@ exports.getAllAvailableDataStreams = function() { return results; } +exports.registerAvailableDataSources = function (callback) { + availableDataSourceGetters.push(callback); +} + +exports.getAllAvailableDataSources = function() { + let results = []; + availableDataSourceGetters.forEach(callback => { + let theseResults = callback(); + // console.log('result:', theseResults); + theseResults.dataSources.forEach(dataSource => { + results.push(dataSource); + }); + }); + return results; +} + exports.registerBindNodeEndpoint = function(interfaceName, callback) { if (typeof bindNodeToDataStreamCallbacks[interfaceName] === 'undefined') { bindNodeToDataStreamCallbacks[interfaceName] = []; @@ -1244,7 +1262,7 @@ exports.registerAddDataSourceEndpoint = function(interfaceName, callback) { } exports.addDataSourceToInterface = function(interfaceName, dataSource = {}) { - if (!interfaceName) return; + if (!interfaceName) return; // TODO: the API response should change status if error adding // console.log('hardwareAPI received data', objectId, frameId, nodeName, nodeType, frameType, hardwareInterface); let callbacks = addDataSourceCallbacks[interfaceName]; callbacks.forEach(callback => { @@ -1252,6 +1270,21 @@ exports.addDataSourceToInterface = function(interfaceName, dataSource = {}) { }); } +exports.registerDeleteDataSourceEndpoint = function(interfaceName, callback) { + if (typeof deleteDataSourceCallbacks[interfaceName] === 'undefined') { + deleteDataSourceCallbacks[interfaceName] = []; + } + deleteDataSourceCallbacks[interfaceName].push(callback); +} + +exports.deleteDataSourceFromInterface = function(interfaceName, dataSource) { + if (!interfaceName || !dataSource || !dataSource.id || !dataSource.url) return; // TODO: the API response should change status if error adding + let callbacks = deleteDataSourceCallbacks[interfaceName]; + callbacks.forEach(callback => { + callback(dataSource); + }); +} + /** * Used by the server to emit a socket message when settings change * @param {string} interfaceName - exact name of the hardware interface diff --git a/server.js b/server.js index 831a32dc8..4af1d1952 100644 --- a/server.js +++ b/server.js @@ -1873,6 +1873,13 @@ function objectWebServer() { webServer.get('/availableLogicBlocks/', function (req, res) { res.json(blockController.getLogicBlockList()); }); + + webServer.get('/availableDataSources/', function (req, res) { + let allAvailableDataSources = hardwareAPI.getAllAvailableDataSources(); + res.json({ + dataSources: allAvailableDataSources + }); + }); webServer.get('/availableDataStreams/', function(req, res) { let allAvailableDataStreams = hardwareAPI.getAllAvailableDataStreams(); @@ -1892,6 +1899,12 @@ function objectWebServer() { hardwareAPI.addDataSourceToInterface(req.body.interfaceName, req.body.dataSource); res.status(200).json({ success: true, error: null }); }); + + webServer.delete('/deleteDataSourceFromInterface/', function(req, res) { + console.log('deleteDataSourceFromInterface', req.body); + hardwareAPI.deleteDataSourceFromInterface(req.body.interfaceName, req.body.dataSource); + res.status(200).json({ success: true, error: null }); + }); // TODO: is the developer flag ever not true anymore? is it still useful to have? if (globalVariables.developer === true) { From b233551f8042a458814ef850d5ab902d380ba5e4 Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Thu, 9 Nov 2023 14:21:10 -0500 Subject: [PATCH 07/16] adds showWindowTitleBar option to full2D API, to ask toolbox to create a menubar above the tool --- libraries/hardwareInterfaces.js | 2 +- libraries/objectDefaultFiles/object.js | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/libraries/hardwareInterfaces.js b/libraries/hardwareInterfaces.js index bf94ff690..cf5c48647 100644 --- a/libraries/hardwareInterfaces.js +++ b/libraries/hardwareInterfaces.js @@ -1278,7 +1278,7 @@ exports.registerDeleteDataSourceEndpoint = function(interfaceName, callback) { } exports.deleteDataSourceFromInterface = function(interfaceName, dataSource) { - if (!interfaceName || !dataSource || !dataSource.id || !dataSource.url) return; // TODO: the API response should change status if error adding + if (!interfaceName || !dataSource) return; // TODO: the API response should change status if error adding let callbacks = deleteDataSourceCallbacks[interfaceName]; callbacks.forEach(callback => { callback(dataSource); diff --git a/libraries/objectDefaultFiles/object.js b/libraries/objectDefaultFiles/object.js index 4c42e33b1..8695480bc 100755 --- a/libraries/objectDefaultFiles/object.js +++ b/libraries/objectDefaultFiles/object.js @@ -1463,10 +1463,12 @@ /** * Removes or adds the touch overlay div from the tool, without affecting fullscreen status * @param {boolean} enabled + * @param {*} options */ - this.setFull2D = function (enabled) { + this.setFull2D = function (enabled, options = {showWindowTitleBar: false }) { postDataToParent({ - full2D: enabled + full2D: enabled, + showWindowTitleBar: options.showWindowTitleBar }); }; From a81847501ff5195d2f8fbb8a63fe2984b8545e0c Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Fri, 10 Nov 2023 11:48:07 -0500 Subject: [PATCH 08/16] adds spatialObject.getURL helper function, and adds disableAutomaticResizing field to envelope to fix auto resize issue --- libraries/objectDefaultFiles/envelope.js | 16 ++++++++++++++-- libraries/objectDefaultFiles/object.js | 13 +++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/libraries/objectDefaultFiles/envelope.js b/libraries/objectDefaultFiles/envelope.js index 82bf54f93..35b8e36c9 100644 --- a/libraries/objectDefaultFiles/envelope.js +++ b/libraries/objectDefaultFiles/envelope.js @@ -155,6 +155,14 @@ */ this.moveDelayBeforeOpen = 400; + /** + * By default, the touch area for the tool changes every time you open or close the envelope. + * When you open it, it expands to fill the screen. When you close it, it shrinks to the icon. + * If you disable this, you should handle the spatialInterface.changeFrameSize in your onOpen and onClose events. + * @type {boolean} + */ + this.disableAutomaticResizing = false; + // finish setting up the envelope by adding default callbacks and listeners for certain events // listen to post messages from the editor to trigger certain envelope events @@ -560,7 +568,9 @@ this.rootElementWhenClosed.style.display = 'none'; this.rootElementWhenOpen.style.display = ''; // change the iframe and touch overlay size (including visual feedback corners) when the frame changes size - this.realityInterface.changeFrameSize(parseInt(this.rootElementWhenOpen.clientWidth), parseInt(this.rootElementWhenOpen.clientHeight)); + if (!this.disableAutomaticResizing){ + this.realityInterface.changeFrameSize(parseInt(this.rootElementWhenOpen.clientWidth), parseInt(this.rootElementWhenOpen.clientHeight)); + } this.moveDelayBeforeOpen = this.realityInterface.getMoveDelay() || 400; this.realityInterface.setMoveDelay(-1); // can't move it while fullscreen }; @@ -572,7 +582,9 @@ this.rootElementWhenClosed.style.display = ''; this.rootElementWhenOpen.style.display = 'none'; // change the iframe and touch overlay size (including visual feedback corners) when the frame changes size - this.realityInterface.changeFrameSize(parseInt(this.rootElementWhenClosed.clientWidth), parseInt(this.rootElementWhenClosed.clientHeight)); + if (!this.disableAutomaticResizing) { + this.realityInterface.changeFrameSize(parseInt(this.rootElementWhenClosed.clientWidth), parseInt(this.rootElementWhenClosed.clientHeight)); + } this.realityInterface.setMoveDelay(this.moveDelayBeforeOpen); // restore to previous value }; diff --git a/libraries/objectDefaultFiles/object.js b/libraries/objectDefaultFiles/object.js index 8695480bc..8f28486d4 100755 --- a/libraries/objectDefaultFiles/object.js +++ b/libraries/objectDefaultFiles/object.js @@ -293,6 +293,18 @@ } } + /** + * Helper function that tools can use to convert a path on the server to the correct server's URL, + * @example input: '/object/test/uploadMediaFile' + * output: 'https://toolboxedge.net/n/id/s/id/object/test/uploadMediaFile' on cloud server + * output: 'http://192.168.0.25:8080/object/test/uploadMediaFile' on local server + * @param {string} path + * @returns {string} + */ + spatialObject.getURL = function (path) { + return `${spatialObject.socketIoUrl}${path}`; + } + /** * receives POST messages from parent to change spatialObject state * @param {object} msgContent - JSON contents received by the iframe's contentWindow.postMessage listener @@ -2085,6 +2097,7 @@ * @param {number} newHeight */ this.changeFrameSize = function(newWidth, newHeight) { + console.log(`changeFrameSize of ${spatialObject.frame} to ${newWidth} x ${newHeight}`); if (spatialObject.width === newWidth && spatialObject.height === newHeight) { return; } From 4c65afb91f7539129ec23ac9fc71c16de8a93099 Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Mon, 13 Nov 2023 12:05:51 -0500 Subject: [PATCH 09/16] refactor dataStream APIs into routers/logic.js, and libraries/dataStreamInterfaces.js --- libraries/dataStreamInterfaces.js | 85 ++++++++++++++++++++++++++ libraries/hardwareInterfaces.js | 83 ------------------------- libraries/objectDefaultFiles/object.js | 1 - routers/logic.js | 42 +++++++++++++ server.js | 32 ---------- 5 files changed, 127 insertions(+), 116 deletions(-) create mode 100644 libraries/dataStreamInterfaces.js diff --git a/libraries/dataStreamInterfaces.js b/libraries/dataStreamInterfaces.js new file mode 100644 index 000000000..d44466fe4 --- /dev/null +++ b/libraries/dataStreamInterfaces.js @@ -0,0 +1,85 @@ +// Provides additional methods for hardwareInterfaces to use to bind nodes to data streams + +let availableDataStreamGetters = []; +let availableDataSourceGetters = []; +let bindNodeToDataStreamCallbacks = {}; +let addDataSourceCallbacks = {}; +let deleteDataSourceCallbacks = {}; + +exports.registerAvailableDataStreams = function (callback) { + availableDataStreamGetters.push(callback); +} + +exports.getAllAvailableDataStreams = function() { + let results = []; + availableDataStreamGetters.forEach(callback => { + let theseResults = callback(); + // console.log('result:', theseResults); + theseResults.dataStreams.forEach(dataStream => { + results.push(dataStream); + }); + }); + return results; +} + +exports.registerAvailableDataSources = function (callback) { + availableDataSourceGetters.push(callback); +} + +exports.getAllAvailableDataSources = function() { + let results = []; + availableDataSourceGetters.forEach(callback => { + let theseResults = callback(); + // console.log('result:', theseResults); + theseResults.dataSources.forEach(dataSource => { + results.push(dataSource); + }); + }); + return results; +} + +exports.registerBindNodeEndpoint = function(interfaceName, callback) { + if (typeof bindNodeToDataStreamCallbacks[interfaceName] === 'undefined') { + bindNodeToDataStreamCallbacks[interfaceName] = []; + } + bindNodeToDataStreamCallbacks[interfaceName].push(callback); +} + +exports.bindNodeToDataStream = function({ objectId, frameId, nodeName, nodeType, frameType, hardwareInterface, streamId}) { + console.log('hardwareAPI received data', objectId, frameId, nodeName, nodeType, frameType, hardwareInterface); + let callbacks = bindNodeToDataStreamCallbacks[hardwareInterface]; + callbacks.forEach(callback => { + callback(objectId, frameId, nodeName, nodeType, frameType, streamId); + }); +} + +exports.registerAddDataSourceEndpoint = function(interfaceName, callback) { + if (typeof addDataSourceCallbacks[interfaceName] === 'undefined') { + addDataSourceCallbacks[interfaceName] = []; + } + addDataSourceCallbacks[interfaceName].push(callback); +} + +exports.addDataSourceToInterface = function(interfaceName, dataSource = {}) { + if (!interfaceName) return; // TODO: the API response should change status if error adding + // console.log('hardwareAPI received data', objectId, frameId, nodeName, nodeType, frameType, hardwareInterface); + let callbacks = addDataSourceCallbacks[interfaceName]; + callbacks.forEach(callback => { + callback(dataSource); + }); +} + +exports.registerDeleteDataSourceEndpoint = function(interfaceName, callback) { + if (typeof deleteDataSourceCallbacks[interfaceName] === 'undefined') { + deleteDataSourceCallbacks[interfaceName] = []; + } + deleteDataSourceCallbacks[interfaceName].push(callback); +} + +exports.deleteDataSourceFromInterface = function(interfaceName, dataSource) { + if (!interfaceName || !dataSource) return; // TODO: the API response should change status if error adding + let callbacks = deleteDataSourceCallbacks[interfaceName]; + callbacks.forEach(callback => { + callback(dataSource); + }); +} diff --git a/libraries/hardwareInterfaces.js b/libraries/hardwareInterfaces.js index cf5c48647..25103962f 100644 --- a/libraries/hardwareInterfaces.js +++ b/libraries/hardwareInterfaces.js @@ -56,11 +56,6 @@ var screenPortMap = {}; var _this = this; var sceneGraphReference; var worldGraphReference; -let availableDataStreamGetters = []; -let availableDataSourceGetters = []; -let bindNodeToDataStreamCallbacks = {}; -let addDataSourceCallbacks = {}; -let deleteDataSourceCallbacks = {}; //data structures to manage the IO points generated by the API user function ObjectCallbacks() { @@ -1207,84 +1202,6 @@ exports.advertiseConnection = function (object, tool, node, logic) { actionCallback(message); }; -exports.registerAvailableDataStreams = function (callback) { - availableDataStreamGetters.push(callback); -} - -exports.getAllAvailableDataStreams = function() { - let results = []; - availableDataStreamGetters.forEach(callback => { - let theseResults = callback(); - // console.log('result:', theseResults); - theseResults.dataStreams.forEach(dataStream => { - results.push(dataStream); - }); - }); - return results; -} - -exports.registerAvailableDataSources = function (callback) { - availableDataSourceGetters.push(callback); -} - -exports.getAllAvailableDataSources = function() { - let results = []; - availableDataSourceGetters.forEach(callback => { - let theseResults = callback(); - // console.log('result:', theseResults); - theseResults.dataSources.forEach(dataSource => { - results.push(dataSource); - }); - }); - return results; -} - -exports.registerBindNodeEndpoint = function(interfaceName, callback) { - if (typeof bindNodeToDataStreamCallbacks[interfaceName] === 'undefined') { - bindNodeToDataStreamCallbacks[interfaceName] = []; - } - bindNodeToDataStreamCallbacks[interfaceName].push(callback); -} - -exports.bindNodeToDataStream = function({ objectId, frameId, nodeName, nodeType, frameType, hardwareInterface, streamId}) { - console.log('hardwareAPI received data', objectId, frameId, nodeName, nodeType, frameType, hardwareInterface); - let callbacks = bindNodeToDataStreamCallbacks[hardwareInterface]; - callbacks.forEach(callback => { - callback(objectId, frameId, nodeName, nodeType, frameType, streamId); - }); -} - -exports.registerAddDataSourceEndpoint = function(interfaceName, callback) { - if (typeof addDataSourceCallbacks[interfaceName] === 'undefined') { - addDataSourceCallbacks[interfaceName] = []; - } - addDataSourceCallbacks[interfaceName].push(callback); -} - -exports.addDataSourceToInterface = function(interfaceName, dataSource = {}) { - if (!interfaceName) return; // TODO: the API response should change status if error adding - // console.log('hardwareAPI received data', objectId, frameId, nodeName, nodeType, frameType, hardwareInterface); - let callbacks = addDataSourceCallbacks[interfaceName]; - callbacks.forEach(callback => { - callback(dataSource); - }); -} - -exports.registerDeleteDataSourceEndpoint = function(interfaceName, callback) { - if (typeof deleteDataSourceCallbacks[interfaceName] === 'undefined') { - deleteDataSourceCallbacks[interfaceName] = []; - } - deleteDataSourceCallbacks[interfaceName].push(callback); -} - -exports.deleteDataSourceFromInterface = function(interfaceName, dataSource) { - if (!interfaceName || !dataSource) return; // TODO: the API response should change status if error adding - let callbacks = deleteDataSourceCallbacks[interfaceName]; - callbacks.forEach(callback => { - callback(dataSource); - }); -} - /** * Used by the server to emit a socket message when settings change * @param {string} interfaceName - exact name of the hardware interface diff --git a/libraries/objectDefaultFiles/object.js b/libraries/objectDefaultFiles/object.js index 8f28486d4..579e86d20 100755 --- a/libraries/objectDefaultFiles/object.js +++ b/libraries/objectDefaultFiles/object.js @@ -2097,7 +2097,6 @@ * @param {number} newHeight */ this.changeFrameSize = function(newWidth, newHeight) { - console.log(`changeFrameSize of ${spatialObject.frame} to ${newWidth} x ${newHeight}`); if (spatialObject.width === newWidth && spatialObject.height === newHeight) { return; } diff --git a/routers/logic.js b/routers/logic.js index 6c8a6d3cd..81d369890 100644 --- a/routers/logic.js +++ b/routers/logic.js @@ -5,6 +5,7 @@ const blockController = require('../controllers/block.js'); const blockLinkController = require('../controllers/blockLink.js'); const logicNodeController = require('../controllers/logicNode.js'); const utilities = require('../libraries/utilities'); +const dataStreamAPI = require("../libraries/dataStreamInterfaces"); // logic links router.delete('/:objectName/:nodeName/link/:linkName/lastEditor/:lastEditor/', function (req, res) { @@ -66,6 +67,47 @@ router.post('/:objectName/:nodeName/nodeSize/', function (req, res) { }); }); +// APIs for programmatically connecting nodes of the system to external data streams, at runtime +// This enables functionality like: searching a ThingWorx server for sensor values, and adding new tools to visualize those values +// Much of the heavy-lifting of these APIs must be taken care of in the corresponding hardwareInterface implementation + +// gets the list of dataSources (from hardware interfaces) which are URL endpoints containing many dataStreams, e.g. a ThingWorx Thing REST API +router.get('/availableDataSources/', function (req, res) { + let allAvailableDataSources = dataStreamAPI.getAllAvailableDataSources(); + res.json({ + dataSources: allAvailableDataSources + }); +}); + +// gets the list of individual data streams that can be bound to nodes (e.g. a sensor value, signal emitter, or weather data stream) +router.get('/availableDataStreams/', function(req, res) { + let allAvailableDataStreams = dataStreamAPI.getAllAvailableDataStreams(); + res.json({ + dataStreams: allAvailableDataStreams + }); +}); + +// tells the specified hardwareInterface to stream values from the specified dataStream to the specified node +router.post('/bindNodeToDataStream/', function(req, res) { + console.log('bindNodeToDataStream', req.body); + dataStreamAPI.bindNodeToDataStream(req.body); + res.status(200).json({ success: true, error: null }); +}); + +// adds and stores the provided URL + authentication as a dataSource on the specified hardwareInterface +router.post('/addDataSourceToInterface/', function(req, res) { + console.log('addDataSourceToInterface', req.body); + dataStreamAPI.addDataSourceToInterface(req.body.interfaceName, req.body.dataSource); + res.status(200).json({ success: true, error: null }); +}); + +// removes the specified dataSource from the specified hardwareInterface +router.delete('/deleteDataSourceFromInterface/', function(req, res) { + console.log('deleteDataSourceFromInterface', req.body); + dataStreamAPI.deleteDataSourceFromInterface(req.body.interfaceName, req.body.dataSource); + res.status(200).json({ success: true, error: null }); +}); + const setup = function() { }; diff --git a/server.js b/server.js index 4af1d1952..13c53c9ad 100644 --- a/server.js +++ b/server.js @@ -1873,38 +1873,6 @@ function objectWebServer() { webServer.get('/availableLogicBlocks/', function (req, res) { res.json(blockController.getLogicBlockList()); }); - - webServer.get('/availableDataSources/', function (req, res) { - let allAvailableDataSources = hardwareAPI.getAllAvailableDataSources(); - res.json({ - dataSources: allAvailableDataSources - }); - }); - - webServer.get('/availableDataStreams/', function(req, res) { - let allAvailableDataStreams = hardwareAPI.getAllAvailableDataStreams(); - res.json({ - dataStreams: allAvailableDataStreams - }); - }); - - webServer.post('/bindNodeToDataStream/', function(req, res) { - console.log('bindNodeToDataStream', req.body); - hardwareAPI.bindNodeToDataStream(req.body); - res.status(200).json({ success: true, error: null }); - }); - - webServer.post('/addDataSourceToInterface/', function(req, res) { - console.log('addDataSourceToInterface', req.body); - hardwareAPI.addDataSourceToInterface(req.body.interfaceName, req.body.dataSource); - res.status(200).json({ success: true, error: null }); - }); - - webServer.delete('/deleteDataSourceFromInterface/', function(req, res) { - console.log('deleteDataSourceFromInterface', req.body); - hardwareAPI.deleteDataSourceFromInterface(req.body.interfaceName, req.body.dataSource); - res.status(200).json({ success: true, error: null }); - }); // TODO: is the developer flag ever not true anymore? is it still useful to have? if (globalVariables.developer === true) { From ce09bf528707ef0adcfef42a8637b524450f4666 Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Tue, 23 Jan 2024 16:18:02 -0500 Subject: [PATCH 10/16] add documentation to dataStreamInterfaces and related functions --- libraries/dataStreamInterfaces.js | 168 ++++++++++++++++++++----- libraries/objectDefaultFiles/object.js | 27 ---- routers/logic.js | 11 +- 3 files changed, 145 insertions(+), 61 deletions(-) diff --git a/libraries/dataStreamInterfaces.js b/libraries/dataStreamInterfaces.js index d44466fe4..72b032601 100644 --- a/libraries/dataStreamInterfaces.js +++ b/libraries/dataStreamInterfaces.js @@ -1,20 +1,136 @@ // Provides additional methods for hardwareInterfaces to use to bind nodes to data streams +/** + * A Data Source is an endpoint that can be queried at a specified frequency to get a list of data streams + * @typedef {Object} DataSource + * @property {string} id - unique identifier + * @property {string} displayName - human-readable name of this source + * @property {DataSourceDetails} source + */ + +/** + * @typedef {Object} DataSourceDetails + * @property {string} url - the endpoint to make the request to, e.g. ptc.io/Thingworx/Things/xyz/Properties + * @property {string} type - type of request to perform or protocol to use, e.g. 'REST/GET' + * @property {Object} headers - any headers to add to the request, e.g. { Accept: 'application/json', appKey: 'xyz' } + * @property {number} pollingFrequency - how many milliseconds between each fetch + * @property {string} dataFormat - a label identifying the data format of the data streams, e.g. 'thingworxProperty' + */ + +/** + * A Data Stream is an individual stream of updating data that can be bound to one or more nodes + * @typedef {Object} DataStream + * @property {string} id - unique identifier + * @property {string} displayName - human-readable name to show up in potential UIs + * @property {string} nodeType - most likely 'node' - which type of node this data stream should expect to write to + * @property {number} currentValue - the value which gets written into the node + * @property {string} interfaceName - the name of the hardware interface providing this data stream + */ + +/** + * @typedef {function} BindNodeFunction + * @param {number} objectId - The ID of the object. + * @param {number} frameId - The ID of the frame. + * @param {string} nodeName - The name of the node. + * @param {string} nodeType - The type of the node. + * @param {string} frameType - The type of the frame. + * @param {number} streamId - The ID of the DataStream. + */ + +/** + * @type {function[]} + */ let availableDataStreamGetters = []; + +/** + * @type {function[]} + */ let availableDataSourceGetters = []; + +/** + * @type {Object.} + */ let bindNodeToDataStreamCallbacks = {}; + +/** + * @type {Object.} + */ let addDataSourceCallbacks = {}; + +/** + * @type {Object.} + */ let deleteDataSourceCallbacks = {}; +/** + * Hardware interfaces can register a hook that they can use to inform the system of which DataStreams they know about + * @param {function} callback + */ exports.registerAvailableDataStreams = function (callback) { availableDataStreamGetters.push(callback); } +/** + * Hardware interfaces can register a hook that they can use to inform the system of which DataSources they know about + * @param callback + */ +exports.registerAvailableDataSources = function (callback) { + availableDataSourceGetters.push(callback); +} + +/** + * Hardware interfaces can register a callback, categorized by interfaceName, that will be triggered if a REST API + * client calls bindNodeToDataStream with the same interfaceName. The hardware interface can assume that the node + * already exists, and just implement this in a way that it will write any incoming data to that node from the + * DataStream with the provided streamId. The hardware interface should also persist the mapping, so it can be restored + * if the server is restarted. + * @param {string} interfaceName + * @param {BindNodeFunction} callback + */ +exports.registerBindNodeEndpoint = function(interfaceName, callback) { + if (typeof bindNodeToDataStreamCallbacks[interfaceName] === 'undefined') { + bindNodeToDataStreamCallbacks[interfaceName] = []; + } + bindNodeToDataStreamCallbacks[interfaceName].push(callback); +} + +/** + * Hardware interfaces can register a callback, categorized by interfaceName, that will be triggered if a client + * attempts to reconfigure the hardware interface by adding a new Data Source endpoint to it at runtime. + * For example, in the ThingWorx tool you can use a UI to add a new REST endpoint to the interface. + * The hardware interface should persist which Data Sources it has, and use those to fetch its Data Streams. + * @param {string} interfaceName + * @param {function(DataSource)} callback + */ +exports.registerAddDataSourceEndpoint = function(interfaceName, callback) { + if (typeof addDataSourceCallbacks[interfaceName] === 'undefined') { + addDataSourceCallbacks[interfaceName] = []; + } + addDataSourceCallbacks[interfaceName].push(callback); +} + +/** + * Hardware interfaces can register a callback, categorized by interfaceName, that will trigger if a client attempts + * to reconfigure the hardware interface by deleting one of its existing Data Sources. The hardware interface should + * remove the Data Source from its persistent storage, and remove any Data Streams provided by that Data Source. + * @param {string} interfaceName + * @param callback + */ +exports.registerDeleteDataSourceEndpoint = function(interfaceName, callback) { + if (typeof deleteDataSourceCallbacks[interfaceName] === 'undefined') { + deleteDataSourceCallbacks[interfaceName] = []; + } + deleteDataSourceCallbacks[interfaceName].push(callback); +} + +/** + * REST API clients can invoke this to get a list of all DataStreams known to the system + * @returns {DataStream[]} + */ exports.getAllAvailableDataStreams = function() { let results = []; availableDataStreamGetters.forEach(callback => { let theseResults = callback(); - // console.log('result:', theseResults); theseResults.dataStreams.forEach(dataStream => { results.push(dataStream); }); @@ -22,15 +138,14 @@ exports.getAllAvailableDataStreams = function() { return results; } -exports.registerAvailableDataSources = function (callback) { - availableDataSourceGetters.push(callback); -} - +/** + * REST API clients can invoke this to get a list of all DataSources known to the system + * @returns {DataSource[]} + */ exports.getAllAvailableDataSources = function() { let results = []; availableDataSourceGetters.forEach(callback => { let theseResults = callback(); - // console.log('result:', theseResults); theseResults.dataSources.forEach(dataSource => { results.push(dataSource); }); @@ -38,13 +153,18 @@ exports.getAllAvailableDataSources = function() { return results; } -exports.registerBindNodeEndpoint = function(interfaceName, callback) { - if (typeof bindNodeToDataStreamCallbacks[interfaceName] === 'undefined') { - bindNodeToDataStreamCallbacks[interfaceName] = []; - } - bindNodeToDataStreamCallbacks[interfaceName].push(callback); -} - +/** + * Triggers any BindNodeFunctions that hardware interfaces registered using registerBindNodeEndpoint, filtered down to + * those whose hardwareInterface name matches the provided hardwareInterface parameter + * @param {string} objectId + * @param {string} frameId + * @param {string} nodeName + * @param {string} nodeType + * @param {string} frameType + * @param {string} hardwareInterface + * @param {string} streamId + * @todo - pull out hardwareInterface into its own parameter, first + */ exports.bindNodeToDataStream = function({ objectId, frameId, nodeName, nodeType, frameType, hardwareInterface, streamId}) { console.log('hardwareAPI received data', objectId, frameId, nodeName, nodeType, frameType, hardwareInterface); let callbacks = bindNodeToDataStreamCallbacks[hardwareInterface]; @@ -53,29 +173,21 @@ exports.bindNodeToDataStream = function({ objectId, frameId, nodeName, nodeType, }); } -exports.registerAddDataSourceEndpoint = function(interfaceName, callback) { - if (typeof addDataSourceCallbacks[interfaceName] === 'undefined') { - addDataSourceCallbacks[interfaceName] = []; - } - addDataSourceCallbacks[interfaceName].push(callback); -} - +/** + * Triggers any callback functions registered using registerAddDataSourceEndpoint, filtered down to those whose + * hardwareInterface name matches the provided interfaceName parameter + * @param {string} interfaceName + * @param {DataSource} dataSource + * @todo - trigger status message in API if error adding + */ exports.addDataSourceToInterface = function(interfaceName, dataSource = {}) { if (!interfaceName) return; // TODO: the API response should change status if error adding - // console.log('hardwareAPI received data', objectId, frameId, nodeName, nodeType, frameType, hardwareInterface); let callbacks = addDataSourceCallbacks[interfaceName]; callbacks.forEach(callback => { callback(dataSource); }); } -exports.registerDeleteDataSourceEndpoint = function(interfaceName, callback) { - if (typeof deleteDataSourceCallbacks[interfaceName] === 'undefined') { - deleteDataSourceCallbacks[interfaceName] = []; - } - deleteDataSourceCallbacks[interfaceName].push(callback); -} - exports.deleteDataSourceFromInterface = function(interfaceName, dataSource) { if (!interfaceName || !dataSource) return; // TODO: the API response should change status if error adding let callbacks = deleteDataSourceCallbacks[interfaceName]; diff --git a/libraries/objectDefaultFiles/object.js b/libraries/objectDefaultFiles/object.js index 81cc66ca3..497361690 100755 --- a/libraries/objectDefaultFiles/object.js +++ b/libraries/objectDefaultFiles/object.js @@ -793,8 +793,6 @@ this.patchHydrate = makeSendStub('patchHydrate'); this.patchSetShaderMode = makeSendStub('patchSetShaderMode'); - this.getOAuthToken = makeSendStub('getOAuthToken'); - // deprecated methods this.sendToBackground = makeSendStub('sendToBackground'); } @@ -1841,31 +1839,6 @@ }); }; - /** - * Makes an OAuth request at `authorizationUrl`, requires the OAuth flow to redirect to navigate:// - * Will not call `callback` on initial OAuth flow, as the whole app gets reloaded - * TODO: Write correct redirect URIs above - * @param {object} urls - OAuth Authorization and Access Token URL - * @param {string} clientId - OAuth client ID - * @param {string} clientSecret - OAuth client secret - * @param {function} callback - Callback function executed once OAuth flow completes - */ - this.getOAuthToken = function(urls, clientId, clientSecret, callback) { - postDataToParent({ - getOAuthToken: { - frame: spatialObject.frame, - clientId: clientId, - clientSecret: clientSecret, - urls - } - }); - spatialObject.messageCallBacks.onOAuthToken = function (msgContent) { - if (typeof msgContent.onOAuthToken !== 'undefined') { - callback(msgContent.onOAuthToken.token, msgContent.onOAuthToken.error); - } - }; - }; - this.getScreenshotBase64 = function(callback) { spatialObject.messageCallBacks.screenshotBase64 = function (msgContent) { if (typeof msgContent.screenshotBase64 !== 'undefined') { diff --git a/routers/logic.js b/routers/logic.js index 81d369890..5dbf48221 100644 --- a/routers/logic.js +++ b/routers/logic.js @@ -67,9 +67,11 @@ router.post('/:objectName/:nodeName/nodeSize/', function (req, res) { }); }); -// APIs for programmatically connecting nodes of the system to external data streams, at runtime -// This enables functionality like: searching a ThingWorx server for sensor values, and adding new tools to visualize those values -// Much of the heavy-lifting of these APIs must be taken care of in the corresponding hardwareInterface implementation +/** + * APIs for programmatically connecting nodes of the system to external data streams, at runtime. This enables + * functionality like: searching a ThingWorx server for sensor values, and adding new tools to visualize those values. + * Much of the heavy-lifting of these APIs must be taken care of in the corresponding hardwareInterface implementation + */ // gets the list of dataSources (from hardware interfaces) which are URL endpoints containing many dataStreams, e.g. a ThingWorx Thing REST API router.get('/availableDataSources/', function (req, res) { @@ -89,21 +91,18 @@ router.get('/availableDataStreams/', function(req, res) { // tells the specified hardwareInterface to stream values from the specified dataStream to the specified node router.post('/bindNodeToDataStream/', function(req, res) { - console.log('bindNodeToDataStream', req.body); dataStreamAPI.bindNodeToDataStream(req.body); res.status(200).json({ success: true, error: null }); }); // adds and stores the provided URL + authentication as a dataSource on the specified hardwareInterface router.post('/addDataSourceToInterface/', function(req, res) { - console.log('addDataSourceToInterface', req.body); dataStreamAPI.addDataSourceToInterface(req.body.interfaceName, req.body.dataSource); res.status(200).json({ success: true, error: null }); }); // removes the specified dataSource from the specified hardwareInterface router.delete('/deleteDataSourceFromInterface/', function(req, res) { - console.log('deleteDataSourceFromInterface', req.body); dataStreamAPI.deleteDataSourceFromInterface(req.body.interfaceName, req.body.dataSource); res.status(200).json({ success: true, error: null }); }); From b78fa6059859f472477496c6b4e7bcb862d777ff Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Wed, 24 Jan 2024 16:02:39 -0500 Subject: [PATCH 11/16] cleanup and better document data stream code --- libraries/dataStreamInterfaces.js | 294 +++++++++++++++--------------- routers/logic.js | 12 +- 2 files changed, 156 insertions(+), 150 deletions(-) diff --git a/libraries/dataStreamInterfaces.js b/libraries/dataStreamInterfaces.js index 72b032601..e758432c9 100644 --- a/libraries/dataStreamInterfaces.js +++ b/libraries/dataStreamInterfaces.js @@ -1,4 +1,11 @@ -// Provides additional methods for hardwareInterfaces to use to bind nodes to data streams +/** + * @fileOverview + * Provides additional methods for hardwareInterfaces to use to bind nodes to data streams + * General structure: + * Hardware interfaces use the DataStreamHardwareInterfaceAPI to opt in to providing their data sources/streams. + * Clients can call the DataStreamClientAPI functions to perform tasks like getting the list of available data streams, + * and to "bind" a specific node to a data stream (meaning that stream will write data to the node from now on). + */ /** * A Data Source is an endpoint that can be queried at a specified frequency to get a list of data streams @@ -27,171 +34,170 @@ * @property {string} interfaceName - the name of the hardware interface providing this data stream */ -/** - * @typedef {function} BindNodeFunction - * @param {number} objectId - The ID of the object. - * @param {number} frameId - The ID of the frame. - * @param {string} nodeName - The name of the node. - * @param {string} nodeType - The type of the node. - * @param {string} frameType - The type of the frame. - * @param {number} streamId - The ID of the DataStream. - */ - /** * @type {function[]} */ let availableDataStreamGetters = []; - /** * @type {function[]} */ let availableDataSourceGetters = []; - /** - * @type {Object.} + * Callback functions categorized by interfaceName + * @type {Object.} */ let bindNodeToDataStreamCallbacks = {}; - /** - * @type {Object.} + * Callback functions categorized by interfaceName + * @type {Object.} */ let addDataSourceCallbacks = {}; - /** - * @type {Object.} + * Callback functions categorized by interfaceName + * @type {Object.} */ let deleteDataSourceCallbacks = {}; /** - * Hardware interfaces can register a hook that they can use to inform the system of which DataStreams they know about - * @param {function} callback - */ -exports.registerAvailableDataStreams = function (callback) { - availableDataStreamGetters.push(callback); -} - -/** - * Hardware interfaces can register a hook that they can use to inform the system of which DataSources they know about - * @param callback - */ -exports.registerAvailableDataSources = function (callback) { - availableDataSourceGetters.push(callback); -} - -/** - * Hardware interfaces can register a callback, categorized by interfaceName, that will be triggered if a REST API - * client calls bindNodeToDataStream with the same interfaceName. The hardware interface can assume that the node - * already exists, and just implement this in a way that it will write any incoming data to that node from the - * DataStream with the provided streamId. The hardware interface should also persist the mapping, so it can be restored - * if the server is restarted. - * @param {string} interfaceName - * @param {BindNodeFunction} callback - */ -exports.registerBindNodeEndpoint = function(interfaceName, callback) { - if (typeof bindNodeToDataStreamCallbacks[interfaceName] === 'undefined') { - bindNodeToDataStreamCallbacks[interfaceName] = []; - } - bindNodeToDataStreamCallbacks[interfaceName].push(callback); -} - -/** - * Hardware interfaces can register a callback, categorized by interfaceName, that will be triggered if a client - * attempts to reconfigure the hardware interface by adding a new Data Source endpoint to it at runtime. - * For example, in the ThingWorx tool you can use a UI to add a new REST endpoint to the interface. - * The hardware interface should persist which Data Sources it has, and use those to fetch its Data Streams. - * @param {string} interfaceName - * @param {function(DataSource)} callback - */ -exports.registerAddDataSourceEndpoint = function(interfaceName, callback) { - if (typeof addDataSourceCallbacks[interfaceName] === 'undefined') { - addDataSourceCallbacks[interfaceName] = []; - } - addDataSourceCallbacks[interfaceName].push(callback); -} - -/** - * Hardware interfaces can register a callback, categorized by interfaceName, that will trigger if a client attempts - * to reconfigure the hardware interface by deleting one of its existing Data Sources. The hardware interface should - * remove the Data Source from its persistent storage, and remove any Data Streams provided by that Data Source. - * @param {string} interfaceName - * @param callback - */ -exports.registerDeleteDataSourceEndpoint = function(interfaceName, callback) { - if (typeof deleteDataSourceCallbacks[interfaceName] === 'undefined') { - deleteDataSourceCallbacks[interfaceName] = []; + * Hardware interfaces can use these functions to register hooks/callbacks to notify the system of data streams/sources, + * and to respond to requests from the client in a modular way behind a level of indirection + */ +const DataStreamHardwareInterfaceAPI = { + /** + * Hardware interfaces can register a hook that they can use to inform the system of which DataStreams they know about + * @param {function} callback + */ + registerAvailableDataStreams(callback) { + availableDataStreamGetters.push(callback); + }, + /** + * Hardware interfaces can register a hook that they can use to inform the system of which DataSources they know about + * @param callback + */ + registerAvailableDataSources(callback) { + availableDataSourceGetters.push(callback); + }, + /** + * Hardware interfaces can register a callback, categorized by interfaceName, that will be triggered if a REST API + * client calls bindNodeToDataStream with the same interfaceName. The hardware interface can assume that the node + * already exists, and just implement this in a way that it will write any incoming data to that node from the + * DataStream with the provided streamId. The hardware interface should also persist the mapping, so it can be restored + * if the server is restarted. + * @param {string} interfaceName + * @param {function(string, string, string, string, string, string)} callback + */ + registerBindNodeEndpoint(interfaceName, callback) { + if (typeof bindNodeToDataStreamCallbacks[interfaceName] === 'undefined') { + bindNodeToDataStreamCallbacks[interfaceName] = []; + } + bindNodeToDataStreamCallbacks[interfaceName].push(callback); + }, + /** + * Hardware interfaces can register a callback, categorized by interfaceName, that will be triggered if a client + * attempts to reconfigure the hardware interface by adding a new Data Source endpoint to it at runtime. + * For example, in the ThingWorx tool you can use a UI to add a new REST endpoint to the interface. + * The hardware interface should persist which Data Sources it has, and use those to fetch its Data Streams. + * @param {string} interfaceName + * @param {function(DataSource)} callback + */ + registerAddDataSourceEndpoint(interfaceName, callback) { + if (typeof addDataSourceCallbacks[interfaceName] === 'undefined') { + addDataSourceCallbacks[interfaceName] = []; + } + addDataSourceCallbacks[interfaceName].push(callback); + }, + /** + * Hardware interfaces can register a callback, categorized by interfaceName, that will trigger if a client attempts + * to reconfigure the hardware interface by deleting one of its existing Data Sources. The hardware interface should + * remove the Data Source from its persistent storage, and remove any Data Streams provided by that Data Source. + * @param {string} interfaceName + * @param callback + */ + registerDeleteDataSourceEndpoint(interfaceName, callback) { + if (typeof deleteDataSourceCallbacks[interfaceName] === 'undefined') { + deleteDataSourceCallbacks[interfaceName] = []; + } + deleteDataSourceCallbacks[interfaceName].push(callback); } - deleteDataSourceCallbacks[interfaceName].push(callback); -} - -/** - * REST API clients can invoke this to get a list of all DataStreams known to the system - * @returns {DataStream[]} - */ -exports.getAllAvailableDataStreams = function() { - let results = []; - availableDataStreamGetters.forEach(callback => { - let theseResults = callback(); - theseResults.dataStreams.forEach(dataStream => { - results.push(dataStream); +}; + +const DataStreamClientAPI = { + /** + * REST API clients can invoke this to get a list of all DataStreams known to the system + * @returns {DataStream[]} + */ + getAllAvailableDataStreams() { + let results = []; + availableDataStreamGetters.forEach(callback => { + let theseResults = callback(); + theseResults.dataStreams.forEach(dataStream => { + results.push(dataStream); + }); }); - }); - return results; -} - -/** - * REST API clients can invoke this to get a list of all DataSources known to the system - * @returns {DataSource[]} - */ -exports.getAllAvailableDataSources = function() { - let results = []; - availableDataSourceGetters.forEach(callback => { - let theseResults = callback(); - theseResults.dataSources.forEach(dataSource => { - results.push(dataSource); + return results; + }, + /** + * REST API clients can invoke this to get a list of all DataSources known to the system + * @returns {DataSource[]} + */ + getAllAvailableDataSources() { + let results = []; + availableDataSourceGetters.forEach(callback => { + let theseResults = callback(); + theseResults.dataSources.forEach(dataSource => { + results.push(dataSource); + }); }); - }); - return results; -} - -/** - * Triggers any BindNodeFunctions that hardware interfaces registered using registerBindNodeEndpoint, filtered down to - * those whose hardwareInterface name matches the provided hardwareInterface parameter - * @param {string} objectId - * @param {string} frameId - * @param {string} nodeName - * @param {string} nodeType - * @param {string} frameType - * @param {string} hardwareInterface - * @param {string} streamId - * @todo - pull out hardwareInterface into its own parameter, first - */ -exports.bindNodeToDataStream = function({ objectId, frameId, nodeName, nodeType, frameType, hardwareInterface, streamId}) { - console.log('hardwareAPI received data', objectId, frameId, nodeName, nodeType, frameType, hardwareInterface); - let callbacks = bindNodeToDataStreamCallbacks[hardwareInterface]; - callbacks.forEach(callback => { - callback(objectId, frameId, nodeName, nodeType, frameType, streamId); - }); -} - -/** - * Triggers any callback functions registered using registerAddDataSourceEndpoint, filtered down to those whose - * hardwareInterface name matches the provided interfaceName parameter - * @param {string} interfaceName - * @param {DataSource} dataSource - * @todo - trigger status message in API if error adding - */ -exports.addDataSourceToInterface = function(interfaceName, dataSource = {}) { - if (!interfaceName) return; // TODO: the API response should change status if error adding - let callbacks = addDataSourceCallbacks[interfaceName]; - callbacks.forEach(callback => { - callback(dataSource); - }); + return results; + }, + /** + * Triggers any callback function that hardware interfaces registered using registerBindNodeEndpoint, filtered down to + * those whose hardwareInterface name matches the provided hardwareInterface parameter + * @param {string} interfaceName + * @param {string} objectId + * @param {string} frameId + * @param {string} nodeName + * @param {string} nodeType + * @param {string} frameType + * @param {string} streamId + */ + bindNodeToDataStream(interfaceName, { objectId, frameId, nodeName, nodeType, frameType, streamId}) { + let callbacks = bindNodeToDataStreamCallbacks[interfaceName]; + callbacks.forEach(callback => { + callback(objectId, frameId, nodeName, nodeType, frameType, streamId); + }); + }, + /** + * Triggers any callback functions registered using registerAddDataSourceEndpoint, filtered down to those whose + * hardwareInterface name matches the provided interfaceName parameter. + * @param {string} interfaceName + * @param {DataSource} dataSource + * @todo - trigger status message in API if error adding + */ + addDataSourceToInterface(interfaceName, dataSource = {}) { + if (!interfaceName) return; // TODO: the API response should change status if error adding + let callbacks = addDataSourceCallbacks[interfaceName]; + callbacks.forEach(callback => { + callback(dataSource); + }); + }, + /** + * Triggers any callback functions registered using registerDeleteDataSourceEndpoint, filtered down to those whose + * hardwareInterface name matches the provided interfaceName parameter. + * @param {string} interfaceName + * @param {DataSource} dataSource + * @todo - trigger status message in API if error adding + */ + deleteDataSourceFromInterface(interfaceName, dataSource) { + if (!interfaceName || !dataSource) return; // TODO: the API response should change status if error adding + let callbacks = deleteDataSourceCallbacks[interfaceName]; + callbacks.forEach(callback => { + callback(dataSource); + }); + } } -exports.deleteDataSourceFromInterface = function(interfaceName, dataSource) { - if (!interfaceName || !dataSource) return; // TODO: the API response should change status if error adding - let callbacks = deleteDataSourceCallbacks[interfaceName]; - callbacks.forEach(callback => { - callback(dataSource); - }); +module.exports = { + DataStreamHardwareInterfaceAPI, + DataStreamClientAPI } diff --git a/routers/logic.js b/routers/logic.js index 5dbf48221..d4d6464d7 100644 --- a/routers/logic.js +++ b/routers/logic.js @@ -5,7 +5,7 @@ const blockController = require('../controllers/block.js'); const blockLinkController = require('../controllers/blockLink.js'); const logicNodeController = require('../controllers/logicNode.js'); const utilities = require('../libraries/utilities'); -const dataStreamAPI = require("../libraries/dataStreamInterfaces"); +const { DataStreamClientAPI } = require("../libraries/dataStreamInterfaces"); // logic links router.delete('/:objectName/:nodeName/link/:linkName/lastEditor/:lastEditor/', function (req, res) { @@ -75,7 +75,7 @@ router.post('/:objectName/:nodeName/nodeSize/', function (req, res) { // gets the list of dataSources (from hardware interfaces) which are URL endpoints containing many dataStreams, e.g. a ThingWorx Thing REST API router.get('/availableDataSources/', function (req, res) { - let allAvailableDataSources = dataStreamAPI.getAllAvailableDataSources(); + let allAvailableDataSources = DataStreamClientAPI.getAllAvailableDataSources(); res.json({ dataSources: allAvailableDataSources }); @@ -83,7 +83,7 @@ router.get('/availableDataSources/', function (req, res) { // gets the list of individual data streams that can be bound to nodes (e.g. a sensor value, signal emitter, or weather data stream) router.get('/availableDataStreams/', function(req, res) { - let allAvailableDataStreams = dataStreamAPI.getAllAvailableDataStreams(); + let allAvailableDataStreams = DataStreamClientAPI.getAllAvailableDataStreams(); res.json({ dataStreams: allAvailableDataStreams }); @@ -91,19 +91,19 @@ router.get('/availableDataStreams/', function(req, res) { // tells the specified hardwareInterface to stream values from the specified dataStream to the specified node router.post('/bindNodeToDataStream/', function(req, res) { - dataStreamAPI.bindNodeToDataStream(req.body); + DataStreamClientAPI.bindNodeToDataStream(req.body.interfaceName, req.body.nodeBinding); res.status(200).json({ success: true, error: null }); }); // adds and stores the provided URL + authentication as a dataSource on the specified hardwareInterface router.post('/addDataSourceToInterface/', function(req, res) { - dataStreamAPI.addDataSourceToInterface(req.body.interfaceName, req.body.dataSource); + DataStreamClientAPI.addDataSourceToInterface(req.body.interfaceName, req.body.dataSource); res.status(200).json({ success: true, error: null }); }); // removes the specified dataSource from the specified hardwareInterface router.delete('/deleteDataSourceFromInterface/', function(req, res) { - dataStreamAPI.deleteDataSourceFromInterface(req.body.interfaceName, req.body.dataSource); + DataStreamClientAPI.deleteDataSourceFromInterface(req.body.interfaceName, req.body.dataSource); res.status(200).json({ success: true, error: null }); }); From 1fd10ea437fa0dd4a099e45cf0a357ef22c959b1 Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Thu, 25 Jan 2024 13:25:27 -0500 Subject: [PATCH 12/16] refactor a lot of the logic from the thingworx interface into a DataStreamInterface class that will be reusable for any other interfaces that want to provide searchable data streams that can bind to nodes --- libraries/DataStreamInterface.js | 196 ++++++++++++++++++++++++++++++ libraries/dataStreamInterfaces.js | 88 ++++++++++---- 2 files changed, 262 insertions(+), 22 deletions(-) create mode 100644 libraries/DataStreamInterface.js diff --git a/libraries/DataStreamInterface.js b/libraries/DataStreamInterface.js new file mode 100644 index 000000000..12293740a --- /dev/null +++ b/libraries/DataStreamInterface.js @@ -0,0 +1,196 @@ +const { DataStreamServerAPI } = require('@libraries/dataStreamInterfaces'); +const server = require('./hardwareInterfaces'); + +/** + * @classdesc DataStreamInterface + * An interface that can be created by a hardware interface that wishes to provide data streams to the system. + * See the thingworx interface for an example on how to use it. + * Creating a DataStreamInterface will automatically subscribe to the relevant functions in dataStreamInterfaces.js. + */ +class DataStreamInterface { + /** + * @param {string} interfaceName + * @param {function} queryDataSourcesCallback + * @param {function} processStreamsFromDataSourceResultsCallback + * @param {DataSource[]} initialDataSources + * @param {NodeBinding[]} initialNodeBindings + */ + constructor(interfaceName, queryDataSourcesCallback, processStreamsFromDataSourceResultsCallback, initialDataSources, initialNodeBindings) { + this.interfaceName = interfaceName; // this must be the exact string of the hardware interface directory name, e.g. 'thingworx' or 'kepware' + this.queryDataSources = queryDataSourcesCallback; + this.processStreamsFromDataSourceResults = processStreamsFromDataSourceResultsCallback; + this.dataSources = initialDataSources; // read these from settings + this.nodeBindings = initialNodeBindings; // read these from settings + this.dataStreams = []; // starts empty, populates when updateData is called + + if ((typeof interfaceName !== 'string') || !initialDataSources || !initialNodeBindings || + !queryDataSourcesCallback || !processStreamsFromDataSourceResultsCallback) { + console.warn('Constructed a DataStreamInterface with invalid parameters'); + } + + setTimeout(this.update.bind(this), 3000); // if you do it immediately it may interfere with server start-up process, so wait a few seconds + setInterval(this.update.bind(this), 6000 /* * 10 */); // fetch all data streams from data sources every 60 seconds + + DataStreamServerAPI.registerAvailableDataStreams(this.getAvailableDataStreams.bind(this)); + DataStreamServerAPI.registerAvailableDataSources(this.getAvailableDataSources.bind(this)); + DataStreamServerAPI.registerBindNodeEndpoint(this.interfaceName, this.bindNodeToDataStream.bind(this)); + DataStreamServerAPI.registerAddDataSourceEndpoint(this.interfaceName, this.addDataSource.bind(this)); + DataStreamServerAPI.registerDeleteDataSourceEndpoint(this.interfaceName, this.deleteDataSource.bind(this)); + + console.log('>>> initialized DataStreamInterface on server'); + } + + getAvailableDataStreams() { + return { + interfaceName: this.interfaceName, + dataStreams: this.dataStreams + }; + + console.log('>>> getAvailableDataStreams'); + } + + getAvailableDataSources() { + return { + interfaceName: this.interfaceName, + dataSources: this.dataSources + }; + + console.log('>>> getAvailableDataSources'); + } + + bindNodeToDataStream(objectId, frameId, nodeName, nodeType, frameType, streamId) { + // search for a node of type on the frame, or create the node of that type if it needs one + let objectName = server.getObjectNameFromObjectId(objectId); + let frameName = server.getToolNameFromToolId(objectId, frameId); + let existingNodes = server.getAllNodes(objectName, frameName); + + // TODO: how to make sure the name matches the name that the tool will use for its primary node? for now the client just guesses it's named "value" + let matchingNode = Object.values(existingNodes).find(node => { return node.type === nodeType && node.name === nodeName }); + if (!matchingNode) { + server.addNode(objectName, frameName, nodeName, nodeType); + matchingNode = Object.values(existingNodes).find(node => { return node.type === nodeType && node.name === nodeName }); + } + + // TODO: skip if a duplicate record is already in nodeBindings + this.nodeBindings.push(new NodeBinding( + objectId, objectName, + frameId, frameName, + matchingNode.uuid, nodeName, + streamId + )); + + this.writeNodeBindingsToSettings(this.nodeBindings); // write this to the json settings file, so it can be restored upon restarting the server + this.update(); // update one time immediately so the node gets a value without waiting for the interval + + console.log('>>> bindNodeToDataStream'); + } + + addDataSource(dataSource) { + this.dataSources.push(dataSource); + this.writeDataSourcesToSettings(this.dataSources); + this.update(); + + console.log('>>> addDataSource'); + } + + deleteDataSource(dataSourceToDelete) { + if (!dataSourceToDelete.id || !dataSourceToDelete.url || !dataSourceToDelete.displayName) return; + + let matchingDataSource = this.dataSources.find(dataSource => { + return dataSource.id === dataSourceToDelete.id && + dataSource.source.url === dataSourceToDelete.url && + dataSource.displayName === dataSourceToDelete.displayName; + }); + + if (matchingDataSource) { + let index = this.dataSources.indexOf(matchingDataSource); + this.dataSources.splice(index, 1); + } + + this.writeDataSourcesToSettings(this.dataSources); + this.update(); + + console.log('>>> deleteDataSource'); + } + + update() { + this.queryDataSources(this.dataSources).then((resultsArray) => { + this.dataStreams = this.processStreamsFromDataSourceResults(resultsArray); + + // process each of the node bindings and write it to the node + this.nodeBindings.forEach(nodeBinding => { + this.processNodeBinding(nodeBinding); + }); + + }).catch(err => { + console.warn('error in queryAllDataSources', err); + }); + + console.log('>>> update'); + } + + processNodeBinding(nodeBinding) { + let dataStream = this.dataStreams.find(stream => { return stream.id === nodeBinding.streamId}); + if (!dataStream) return; + // TODO: optionally add [mode, unit, unitMin, unitMax] to server.write arguments + let mode = 'f'; + let unit = undefined; //UNIT_DEGREES_C; // TODO: allow dataStream to provide this, e.g. 'degrees C' + let unitMin = typeof dataStream.minValue === 'number' ? dataStream.minValue : dataStream.currentValue - 0.5;// TODO: allow dataStream to fetch or calculate min/max based on observed values + let unitMax = typeof dataStream.maxValue === 'number' ? dataStream.maxValue : dataStream.currentValue + 0.5; + let valueMapped = (dataStream.currentValue - unitMin) / (unitMax - unitMin); + server.write(nodeBinding.objectName, nodeBinding.frameName, nodeBinding.nodeName, valueMapped, mode, unit, unitMin, unitMax); + + // console.log('>>> processNodeBinding'); + } + + /** + * @param {DataSource[]} dataSources + */ + writeDataSourcesToSettings(dataSources) { + // TODO: don't allow one hardware interface to accidentally write to the wrong settings file (eliminate the 'thingworx' parameter) + server.setHardwareInterfaceSettings(this.interfaceName, { dataSources: dataSources }, ['dataSources'], (successful, error) => { + if (error) { + console.log(`${this.interfaceName}: error persisting dataSources to settings`, error); + } else { + console.log(`${this.interfaceName}: success persisting dataSources to settings`, successful); + } + }); + + console.log('>>> writeDataSourcesToSettings'); + } + + /** + * @param {NodeBinding[]} nodeBindings + */ + writeNodeBindingsToSettings(nodeBindings) { + // TODO: don't allow one hardware interface to accidentally write to the wrong settings file (eliminate the 'thingworx' parameter) + server.setHardwareInterfaceSettings(this.interfaceName, { nodeBindings: nodeBindings }, ['nodeBindings'], (successful, error) => { + if (error) { + console.log(`${this.interfaceName}: error persisting nodeBindings to settings`, error); + } else { + console.log(`${this.interfaceName}: success persisting nodeBindings to settings`, successful); + } + }); + + console.log('>>> writeNodeBindingsToSettings'); + } +} + +/** + * @classdesc NodeBinding + * Maps a streamId to the address of a node (objectId, frameId, nodeId) to imply that + * this data stream should write to that node whenever the data stream updates + */ +class NodeBinding { + constructor(objectId, objectName, frameId, frameName, nodeId, nodeName, streamId) { + this.objectId = objectId; + this.objectName = objectName; + this.frameId = frameId; + this.frameName = frameName; + this.nodeId = nodeId; + this.nodeName = nodeName; + this.streamId = streamId; + } +} + +module.exports = DataStreamInterface; diff --git a/libraries/dataStreamInterfaces.js b/libraries/dataStreamInterfaces.js index e758432c9..734487c85 100644 --- a/libraries/dataStreamInterfaces.js +++ b/libraries/dataStreamInterfaces.js @@ -2,37 +2,80 @@ * @fileOverview * Provides additional methods for hardwareInterfaces to use to bind nodes to data streams * General structure: - * Hardware interfaces use the DataStreamHardwareInterfaceAPI to opt in to providing their data sources/streams. + * Hardware interfaces use the DataStreamServerAPI to opt in to providing their data sources/streams. * Clients can call the DataStreamClientAPI functions to perform tasks like getting the list of available data streams, * and to "bind" a specific node to a data stream (meaning that stream will write data to the node from now on). */ /** - * A Data Source is an endpoint that can be queried at a specified frequency to get a list of data streams - * @typedef {Object} DataSource - * @property {string} id - unique identifier - * @property {string} displayName - human-readable name of this source - * @property {DataSourceDetails} source + * @classdesc + * An individual stream of updating data that can be "bound" to one or more nodes, + * to write data into those nodes at a certain interval in perpetuity + * @example A data stream might point to a specific property of a Thing on ThingWorx: + * https://pp-2302201433iy.portal.ptc.io/Thingworx/Things/SE.CXC.HarpakUlma.Asset.Monitoring.TFS500.PTC01/Properties/EnergyConsumption_Watt + * This URL isn't directly stored in the DataStream, but the combination of its id and its DataSource can yield this url */ +class DataStream { + /** + * @param {string} id - unique identifier + * @param {string} displayName - human-readable name to show up in potential UIs + * @param {string} nodeType - which type of node this data stream should expect to write to + * @param {number} currentValue - the value which gets written into the node + * @param {number?} minValue - optional minimum value for the range + * @param {number?} maxValue - optional maximum value for the range + * @param {string} interfaceName - the name of the hardware interface providing this data stream + */ + constructor(id, displayName, nodeType = 'node', currentValue = 0, minValue, maxValue, interfaceName) { + this.id = id; + this.displayName = displayName; + this.nodeType = nodeType; + this.currentValue = currentValue; + this.minValue = minValue; + this.maxValue = maxValue; + this.interfaceName = interfaceName; + } +} /** - * @typedef {Object} DataSourceDetails - * @property {string} url - the endpoint to make the request to, e.g. ptc.io/Thingworx/Things/xyz/Properties - * @property {string} type - type of request to perform or protocol to use, e.g. 'REST/GET' - * @property {Object} headers - any headers to add to the request, e.g. { Accept: 'application/json', appKey: 'xyz' } - * @property {number} pollingFrequency - how many milliseconds between each fetch - * @property {string} dataFormat - a label identifying the data format of the data streams, e.g. 'thingworxProperty' + * @classdesc + * A Data Source is an endpoint that can be queried at a specified frequency to get a list of data streams + * @example A data source might point to the properties list of a Thing on ThingWorx: + * https://pp-2302201433iy.portal.ptc.io/Thingworx/Things/SE.CXC.HarpakUlma.Asset.Monitoring.TFS500.PTC01/Properties/ + * This URL is stored in its DataSourceDetails */ +class DataSource { + /** + * @param {string} id - unique identifier + * @param {string} displayName - human-readable name of this source + * @param {DataSourceDetails} source + */ + constructor(id, displayName, source) { + this.id = id; + this.displayName = displayName; + this.source = source; + } +} /** - * A Data Stream is an individual stream of updating data that can be bound to one or more nodes - * @typedef {Object} DataStream - * @property {string} id - unique identifier - * @property {string} displayName - human-readable name to show up in potential UIs - * @property {string} nodeType - most likely 'node' - which type of node this data stream should expect to write to - * @property {number} currentValue - the value which gets written into the node - * @property {string} interfaceName - the name of the hardware interface providing this data stream + * @classdesc + * Struct containing the specific location of where and how to fetch data for a DataSource */ +class DataSourceDetails { + /** + * @param {string} url - the endpoint to make the request to, e.g. ptc.io/Thingworx/Things/xyz/Properties + * @param {string} type - type of request to perform or protocol to use, e.g. 'REST/GET' + * @param {Object} headers - any headers to add to the request, e.g. { Accept: 'application/json', appKey: 'xyz' } + * @param {number} pollingFrequency - how many milliseconds between each fetch + * @param {string} dataFormat - a label identifying the data format of the data streams, e.g. 'thingworxProperty' + */ + constructor(url, type, headers, pollingFrequency, dataFormat) { + this.url = url; + this.type = type; + this.headers = headers; + this.pollingFrequency = pollingFrequency; + this.dataFormat = dataFormat; + } +} /** * @type {function[]} @@ -62,7 +105,7 @@ let deleteDataSourceCallbacks = {}; * Hardware interfaces can use these functions to register hooks/callbacks to notify the system of data streams/sources, * and to respond to requests from the client in a modular way behind a level of indirection */ -const DataStreamHardwareInterfaceAPI = { +const DataStreamServerAPI = { /** * Hardware interfaces can register a hook that they can use to inform the system of which DataStreams they know about * @param {function} callback @@ -198,6 +241,7 @@ const DataStreamClientAPI = { } module.exports = { - DataStreamHardwareInterfaceAPI, - DataStreamClientAPI + DataStreamClientAPI, + DataStreamServerAPI, + DataStream } From 324bf0e5638323e2e1c8758936e188530654a987 Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Fri, 26 Jan 2024 12:37:09 -0500 Subject: [PATCH 13/16] cleanup DataStreamInterface for PR --- libraries/DataStreamInterface.js | 59 +++++++++++----------- libraries/dataStreamInterfaces.js | 81 +++++++++++++++++++------------ 2 files changed, 77 insertions(+), 63 deletions(-) diff --git a/libraries/DataStreamInterface.js b/libraries/DataStreamInterface.js index 12293740a..cb87357a8 100644 --- a/libraries/DataStreamInterface.js +++ b/libraries/DataStreamInterface.js @@ -1,4 +1,4 @@ -const { DataStreamServerAPI } = require('@libraries/dataStreamInterfaces'); +const { DataStreamServerAPI, NodeBinding } = require('@libraries/dataStreamInterfaces'); const server = require('./hardwareInterfaces'); /** @@ -10,26 +10,25 @@ const server = require('./hardwareInterfaces'); class DataStreamInterface { /** * @param {string} interfaceName - * @param {function} queryDataSourcesCallback - * @param {function} processStreamsFromDataSourceResultsCallback * @param {DataSource[]} initialDataSources * @param {NodeBinding[]} initialNodeBindings + * @param {function} fetchDataStreamsImplementation */ - constructor(interfaceName, queryDataSourcesCallback, processStreamsFromDataSourceResultsCallback, initialDataSources, initialNodeBindings) { + constructor(interfaceName, initialDataSources, initialNodeBindings, fetchDataStreamsImplementation) { this.interfaceName = interfaceName; // this must be the exact string of the hardware interface directory name, e.g. 'thingworx' or 'kepware' - this.queryDataSources = queryDataSourcesCallback; - this.processStreamsFromDataSourceResults = processStreamsFromDataSourceResultsCallback; + this.fetchDataStreamsFromSource = fetchDataStreamsImplementation; this.dataSources = initialDataSources; // read these from settings this.nodeBindings = initialNodeBindings; // read these from settings this.dataStreams = []; // starts empty, populates when updateData is called - if ((typeof interfaceName !== 'string') || !initialDataSources || !initialNodeBindings || - !queryDataSourcesCallback || !processStreamsFromDataSourceResultsCallback) { + if ((typeof interfaceName !== 'string') || !initialDataSources || + !initialNodeBindings || !fetchDataStreamsImplementation) { console.warn('Constructed a DataStreamInterface with invalid parameters'); } - setTimeout(this.update.bind(this), 3000); // if you do it immediately it may interfere with server start-up process, so wait a few seconds - setInterval(this.update.bind(this), 6000 /* * 10 */); // fetch all data streams from data sources every 60 seconds + setTimeout(this.update.bind(this), 1000 * 5); // if you do it immediately it may interfere with server start-up process, so wait a few seconds + setInterval(this.update.bind(this), 1000 * 60); // fetch all data streams from data sources every 60 seconds + // TODO: in future use the pollingFrequency of each data stream. currently just fetches all data updates 1 time per minute. DataStreamServerAPI.registerAvailableDataStreams(this.getAvailableDataStreams.bind(this)); DataStreamServerAPI.registerAvailableDataSources(this.getAvailableDataSources.bind(this)); @@ -58,7 +57,7 @@ class DataStreamInterface { console.log('>>> getAvailableDataSources'); } - bindNodeToDataStream(objectId, frameId, nodeName, nodeType, frameType, streamId) { + bindNodeToDataStream(objectId, frameId, nodeName, nodeType, streamId) { // search for a node of type on the frame, or create the node of that type if it needs one let objectName = server.getObjectNameFromObjectId(objectId); let frameName = server.getToolNameFromToolId(objectId, frameId); @@ -114,14 +113,13 @@ class DataStreamInterface { } update() { - this.queryDataSources(this.dataSources).then((resultsArray) => { - this.dataStreams = this.processStreamsFromDataSourceResults(resultsArray); + this.queryAllDataSources(this.dataSources).then((dataStreamsArray) => { + this.dataStreams = dataStreamsArray.flat(); // process each of the node bindings and write it to the node this.nodeBindings.forEach(nodeBinding => { this.processNodeBinding(nodeBinding); }); - }).catch(err => { console.warn('error in queryAllDataSources', err); }); @@ -129,6 +127,22 @@ class DataStreamInterface { console.log('>>> update'); } + /** + * @param {DataSource[]} dataSources + * @returns {Promise[]>} + */ + queryAllDataSources(dataSources) { + const fetchPromises = dataSources.map(dataSource => { + return this.fetchDataStreamsFromSource(dataSource) + .catch(error => { + console.warn(`Error fetching data from source ${error.message}`, dataSource); + return []; + }); + }); + + return Promise.all(fetchPromises); + } + processNodeBinding(nodeBinding) { let dataStream = this.dataStreams.find(stream => { return stream.id === nodeBinding.streamId}); if (!dataStream) return; @@ -176,21 +190,4 @@ class DataStreamInterface { } } -/** - * @classdesc NodeBinding - * Maps a streamId to the address of a node (objectId, frameId, nodeId) to imply that - * this data stream should write to that node whenever the data stream updates - */ -class NodeBinding { - constructor(objectId, objectName, frameId, frameName, nodeId, nodeName, streamId) { - this.objectId = objectId; - this.objectName = objectName; - this.frameId = frameId; - this.frameName = frameName; - this.nodeId = nodeId; - this.nodeName = nodeName; - this.streamId = streamId; - } -} - module.exports = DataStreamInterface; diff --git a/libraries/dataStreamInterfaces.js b/libraries/dataStreamInterfaces.js index 734487c85..04c40348f 100644 --- a/libraries/dataStreamInterfaces.js +++ b/libraries/dataStreamInterfaces.js @@ -1,9 +1,9 @@ /** * @fileOverview - * Provides additional methods for hardwareInterfaces to use to bind nodes to data streams - * General structure: - * Hardware interfaces use the DataStreamServerAPI to opt in to providing their data sources/streams. - * Clients can call the DataStreamClientAPI functions to perform tasks like getting the list of available data streams, + * Provides methods to bind nodes to data streams. + * Hardware interfaces can instantiate a DataStreamInterface to opt in to providing their data sources/streams. + * Hardware interface developers should interact with the DataStreamInterface class, rather than this file's functions. + * REST APIs can call the DataStreamClientAPI functions to perform tasks like getting the list of available data streams, * and to "bind" a specific node to a data stream (meaning that stream will write data to the node from now on). */ @@ -58,7 +58,7 @@ class DataSource { /** * @classdesc - * Struct containing the specific location of where and how to fetch data for a DataSource + * Simple class containing the specific location of where and how to fetch data for a DataSource */ class DataSourceDetails { /** @@ -72,11 +72,28 @@ class DataSourceDetails { this.url = url; this.type = type; this.headers = headers; - this.pollingFrequency = pollingFrequency; + this.pollingFrequency = pollingFrequency; // TODO: use this in future for more control this.dataFormat = dataFormat; } } +/** + * @classdesc + * Maps a streamId to the address of a node (objectId, frameId, nodeId) to mark that + * this data stream should write to that node whenever the data stream updates + */ +class NodeBinding { + constructor(objectId, objectName, frameId, frameName, nodeId, nodeName, streamId) { + this.objectId = objectId; + this.objectName = objectName; + this.frameId = frameId; + this.frameName = frameName; + this.nodeId = nodeId; + this.nodeName = nodeName; + this.streamId = streamId; + } +} + /** * @type {function[]} */ @@ -102,30 +119,29 @@ let addDataSourceCallbacks = {}; let deleteDataSourceCallbacks = {}; /** - * Hardware interfaces can use these functions to register hooks/callbacks to notify the system of data streams/sources, - * and to respond to requests from the client in a modular way behind a level of indirection + * The DataStreamInterface class uses these functions to register callbacks to notify the system of data streams/sources, + * and to respond to requests from the client in a modular way behind a level of indirection. */ const DataStreamServerAPI = { /** - * Hardware interfaces can register a hook that they can use to inform the system of which DataStreams they know about + * Register a hook that the DataStreamInterface can use to inform the system of which DataStreams it knows about * @param {function} callback */ registerAvailableDataStreams(callback) { availableDataStreamGetters.push(callback); }, /** - * Hardware interfaces can register a hook that they can use to inform the system of which DataSources they know about - * @param callback + * Register a hook that the DataStreamInterface can use to inform the system of which DataSources it knows about + * @param {function} callback */ registerAvailableDataSources(callback) { availableDataSourceGetters.push(callback); }, /** - * Hardware interfaces can register a callback, categorized by interfaceName, that will be triggered if a REST API - * client calls bindNodeToDataStream with the same interfaceName. The hardware interface can assume that the node - * already exists, and just implement this in a way that it will write any incoming data to that node from the - * DataStream with the provided streamId. The hardware interface should also persist the mapping, so it can be restored - * if the server is restarted. + * Register a callback, categorized by interfaceName, that will be triggered if a REST API client calls + * bindNodeToDataStream with the same interfaceName. Assumes that the node already exists, and sets it up to write + * any incoming data from the DataStream with the provided streamId to write to that node. This mapping will be + * persisted, so it can be restored if the server is restarted. * @param {string} interfaceName * @param {function(string, string, string, string, string, string)} callback */ @@ -136,10 +152,10 @@ const DataStreamServerAPI = { bindNodeToDataStreamCallbacks[interfaceName].push(callback); }, /** - * Hardware interfaces can register a callback, categorized by interfaceName, that will be triggered if a client - * attempts to reconfigure the hardware interface by adding a new Data Source endpoint to it at runtime. - * For example, in the ThingWorx tool you can use a UI to add a new REST endpoint to the interface. - * The hardware interface should persist which Data Sources it has, and use those to fetch its Data Streams. + * Register a callback, categorized by interfaceName, that will be triggered if a client attempts to reconfigure + * the hardware interface by adding a new Data Source endpoint to it at runtime. For example, in the ThingWorx tool + * you can use a UI to add a new REST endpoint to the interface. The hardware interface will persist which Data + * Sources it has, and use those to fetch its Data Streams, which can then be bound to nodes. * @param {string} interfaceName * @param {function(DataSource)} callback */ @@ -150,11 +166,10 @@ const DataStreamServerAPI = { addDataSourceCallbacks[interfaceName].push(callback); }, /** - * Hardware interfaces can register a callback, categorized by interfaceName, that will trigger if a client attempts - * to reconfigure the hardware interface by deleting one of its existing Data Sources. The hardware interface should - * remove the Data Source from its persistent storage, and remove any Data Streams provided by that Data Source. + * Removes a DataSource that was added to a particular hardware interface using registerAddDataSourceEndpoint. + * Removes the DataSource from its persistent storage, and removes any Data Streams provided by that Data Source. * @param {string} interfaceName - * @param callback + * @param {function} callback */ registerDeleteDataSourceEndpoint(interfaceName, callback) { if (typeof deleteDataSourceCallbacks[interfaceName] === 'undefined') { @@ -194,20 +209,20 @@ const DataStreamClientAPI = { return results; }, /** - * Triggers any callback function that hardware interfaces registered using registerBindNodeEndpoint, filtered down to - * those whose hardwareInterface name matches the provided hardwareInterface parameter + * Triggers any callback functions that DataStreamInterfaces registered using registerBindNodeEndpoint, filtered + * down to those whose hardwareInterface name matches the provided hardwareInterface parameter + * Rather than using nodeId, we use a combination of nodeName and nodeType to help identify the node * @param {string} interfaceName * @param {string} objectId * @param {string} frameId * @param {string} nodeName * @param {string} nodeType - * @param {string} frameType * @param {string} streamId */ - bindNodeToDataStream(interfaceName, { objectId, frameId, nodeName, nodeType, frameType, streamId}) { + bindNodeToDataStream(interfaceName, { objectId, frameId, nodeName, nodeType, streamId}) { let callbacks = bindNodeToDataStreamCallbacks[interfaceName]; callbacks.forEach(callback => { - callback(objectId, frameId, nodeName, nodeType, frameType, streamId); + callback(objectId, frameId, nodeName, nodeType, streamId); }); }, /** @@ -241,7 +256,9 @@ const DataStreamClientAPI = { } module.exports = { - DataStreamClientAPI, - DataStreamServerAPI, - DataStream + DataStreamClientAPI, // intended to be used in response to clients using the server's REST APIs + DataStreamServerAPI, // intended to be used by the DataStreamInterface class, not by individual hardware interfaces + DataStream, + DataSource, + NodeBinding } From 2a90d4bb504ce007341ed8d121ec486e5e69d5be Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Fri, 26 Jan 2024 14:39:15 -0500 Subject: [PATCH 14/16] fully document DataStreamInterface, and fix linting --- libraries/DataStreamInterface.js | 180 +++++++++++++++++++---- libraries/dataStreamInterfaces.js | 5 +- libraries/objectDefaultFiles/envelope.js | 2 +- libraries/objectDefaultFiles/object.js | 2 +- 4 files changed, 154 insertions(+), 35 deletions(-) diff --git a/libraries/DataStreamInterface.js b/libraries/DataStreamInterface.js index cb87357a8..dc14359e2 100644 --- a/libraries/DataStreamInterface.js +++ b/libraries/DataStreamInterface.js @@ -1,4 +1,4 @@ -const { DataStreamServerAPI, NodeBinding } = require('@libraries/dataStreamInterfaces'); +const { DataStreamServerAPI, NodeBinding, DataSource, DataSourceDetails } = require('./dataStreamInterfaces'); const server = require('./hardwareInterfaces'); /** @@ -17,8 +17,9 @@ class DataStreamInterface { constructor(interfaceName, initialDataSources, initialNodeBindings, fetchDataStreamsImplementation) { this.interfaceName = interfaceName; // this must be the exact string of the hardware interface directory name, e.g. 'thingworx' or 'kepware' this.fetchDataStreamsFromSource = fetchDataStreamsImplementation; - this.dataSources = initialDataSources; // read these from settings - this.nodeBindings = initialNodeBindings; // read these from settings + + // read DataSources and NodeBindings from settings + this.loadInitialData(initialDataSources, initialNodeBindings); this.dataStreams = []; // starts empty, populates when updateData is called if ((typeof interfaceName !== 'string') || !initialDataSources || @@ -35,39 +36,135 @@ class DataStreamInterface { DataStreamServerAPI.registerBindNodeEndpoint(this.interfaceName, this.bindNodeToDataStream.bind(this)); DataStreamServerAPI.registerAddDataSourceEndpoint(this.interfaceName, this.addDataSource.bind(this)); DataStreamServerAPI.registerDeleteDataSourceEndpoint(this.interfaceName, this.deleteDataSource.bind(this)); + } + + /** + * Loads saved data and discards any items that can't properly be constructed into the class definition + * @param {DataSource[]} initialDataSources + * @param {NodeBinding[]} initialNodeBindings + */ + loadInitialData(initialDataSources, initialNodeBindings) { + this.dataSources = []; + this.nodeBindings = []; + + initialDataSources.forEach(dataSource => { + let castSource = null; + try { + castSource = this.castToDataSource(dataSource); + } catch (e) { + console.warn(`error parsing source while loading DataStreamInterface for ${this.interfaceName}`, e); + } + if (castSource) { + this.dataSources.push(castSource); + } + }); + + initialNodeBindings.forEach(nodeBinding => { + let castBinding = null; + try { + castBinding = this.castToNodeBinding(nodeBinding); + } catch (e) { + console.warn(`error parsing binding while loading DataStreamInterface for ${this.interfaceName}`, e); + } + if (castBinding) { + this.nodeBindings.push(castBinding); + } + }); + } - console.log('>>> initialized DataStreamInterface on server'); + /** + * @param {Object} data + * @returns {boolean} + */ + isValidDataSource(data) { + if (!data || !data.id || !data.displayName || !data.source) return false; + return (data.source.url && data.source.type && data.source.headers && + data.source.pollingFrequency && data.source.dataFormat); + } + + /** + * @param {Object} data + * @returns {boolean} + */ + isValidNodeBinding(data) { + if (!data) return false; + return (data.objectId && data.objectName && data.frameId && + data.frameName && data.nodeId && data.nodeName && data.streamId); + } + + /** + * @param {Object} dataSource + * @returns {DataSource} + * @throws {Error} if input is not a valid data source + */ + castToDataSource(dataSource) { + if (this.isValidDataSource(dataSource)) { + let sourceDetails = new DataSourceDetails(dataSource.source.url, dataSource.source.type, dataSource.source.headers, + dataSource.source.pollingFrequency, dataSource.source.dataFormat); + return new DataSource(dataSource.id, dataSource.displayName, sourceDetails); + } else { + throw new Error(`Invalid DataSource structure ${dataSource}`); + } + } + + /** + * @param {Object} nodeBinding + * @returns {NodeBinding} + * @throws {Error} if input is not a valid node binding + */ + castToNodeBinding(nodeBinding) { + if (this.isValidNodeBinding(nodeBinding)) { + return new NodeBinding(nodeBinding.objectId, nodeBinding.objectName, nodeBinding.frameId, + nodeBinding.frameName, nodeBinding.nodeId, nodeBinding.nodeName, nodeBinding.streamId); + } else { + throw new Error(`Invalid NodeBinding structure ${nodeBinding}`); + } } + /** + * @returns {{ interfaceName: string, dataStreams: DataStream[] }} + */ getAvailableDataStreams() { return { interfaceName: this.interfaceName, dataStreams: this.dataStreams }; - - console.log('>>> getAvailableDataStreams'); } + /** + * @returns {{ interfaceName: string, dataStreams: DataSource[] }} + */ getAvailableDataSources() { return { interfaceName: this.interfaceName, dataSources: this.dataSources }; - - console.log('>>> getAvailableDataSources'); } + /** + * Creates and saves a NodeBinding between the specified node and the specified streamId. + * If the node doesn't exist, it will create it on the specified tool, first. + * @param {string} objectId + * @param {string} frameId + * @param {string} nodeName + * @param {string} nodeType + * @param {string} streamId + */ bindNodeToDataStream(objectId, frameId, nodeName, nodeType, streamId) { + if (!objectId || !frameId || !nodeName || !nodeType || !streamId) { + console.warn('improper arguments for bindNodeToStream -> skipping'); + return; + } + // search for a node of type on the frame, or create the node of that type if it needs one let objectName = server.getObjectNameFromObjectId(objectId); let frameName = server.getToolNameFromToolId(objectId, frameId); let existingNodes = server.getAllNodes(objectName, frameName); - // TODO: how to make sure the name matches the name that the tool will use for its primary node? for now the client just guesses it's named "value" - let matchingNode = Object.values(existingNodes).find(node => { return node.type === nodeType && node.name === nodeName }); + let matchingNode = Object.values(existingNodes).find(node => { return node.type === nodeType && node.name === nodeName; }); if (!matchingNode) { server.addNode(objectName, frameName, nodeName, nodeType); - matchingNode = Object.values(existingNodes).find(node => { return node.type === nodeType && node.name === nodeName }); + matchingNode = Object.values(existingNodes).find(node => { return node.type === nodeType && node.name === nodeName; }); } // TODO: skip if a duplicate record is already in nodeBindings @@ -80,18 +177,31 @@ class DataStreamInterface { this.writeNodeBindingsToSettings(this.nodeBindings); // write this to the json settings file, so it can be restored upon restarting the server this.update(); // update one time immediately so the node gets a value without waiting for the interval - - console.log('>>> bindNodeToDataStream'); } + /** + * Adds a data source to the interface, saves it persistently, and fetches new data from it immediately + * @param {DataSource} dataSource + */ addDataSource(dataSource) { - this.dataSources.push(dataSource); + // verify that it is a correctly structured DataSource + let dataSourceInstance; + try { + dataSourceInstance = this.castToDataSource(dataSource); + } catch (e) { + console.warn('trying to add improper data as a dataSource', dataSource); + } + if (!dataSourceInstance) return; + + this.dataSources.push(dataSourceInstance); this.writeDataSourcesToSettings(this.dataSources); this.update(); - - console.log('>>> addDataSource'); } + /** + * Deletes the specified data source from the interface (if it exists), removes from persistent data, and refreshes + * @param {DataSource} dataSourceToDelete + */ deleteDataSource(dataSourceToDelete) { if (!dataSourceToDelete.id || !dataSourceToDelete.url || !dataSourceToDelete.displayName) return; @@ -104,14 +214,16 @@ class DataStreamInterface { if (matchingDataSource) { let index = this.dataSources.indexOf(matchingDataSource); this.dataSources.splice(index, 1); + this.writeDataSourcesToSettings(this.dataSources); + this.update(); } - - this.writeDataSourcesToSettings(this.dataSources); - this.update(); - - console.log('>>> deleteDataSource'); } + /** + * When update is called, we refresh our list of all data streams from all data sources. + * In the current implementation, this should also update the `currentValue` of each dataStream. + * We then look at the nodeBindings, and push the `currentValue` from any data streams into nodes bound to them. + */ update() { this.queryAllDataSources(this.dataSources).then((dataStreamsArray) => { this.dataStreams = dataStreamsArray.flat(); @@ -123,11 +235,14 @@ class DataStreamInterface { }).catch(err => { console.warn('error in queryAllDataSources', err); }); - - console.log('>>> update'); } /** + * Iterates over all data sources, and outsources the task of fetching the list of data streams and updating the + * `currentValue` of each data stream -> this task is outsourced via the fetchDataStreamsFromSource function, which + * must be implemented by the hardware interface that instantiates this DataStreamInterface – since the + * DataStreamInterface itself has no way to know how to interpret JSON structure of the fetch results. + * The hardware interface can optionally add a `minValue` and `maxValue` to the data stream to help map it to (0,1) * @param {DataSource[]} dataSources * @returns {Promise[]>} */ @@ -143,21 +258,27 @@ class DataStreamInterface { return Promise.all(fetchPromises); } + /** + * Finds the data stream identified in this nodeBinding, and writes the `currentValue` to the specified node. + * Attempts to map the `currentValue` to the range of (0,1), using the `minValue` and `maxValue` of the data stream, + * but if there is no range specified then it defaults to mapping it to 0.5. It can be remapped to true value by + * tools that look at the unitMin and unitMax in addition to the value. + * @param {NodeBinding} nodeBinding + */ processNodeBinding(nodeBinding) { - let dataStream = this.dataStreams.find(stream => { return stream.id === nodeBinding.streamId}); + let dataStream = this.dataStreams.find(stream => { return stream.id === nodeBinding.streamId; }); if (!dataStream) return; // TODO: optionally add [mode, unit, unitMin, unitMax] to server.write arguments let mode = 'f'; let unit = undefined; //UNIT_DEGREES_C; // TODO: allow dataStream to provide this, e.g. 'degrees C' - let unitMin = typeof dataStream.minValue === 'number' ? dataStream.minValue : dataStream.currentValue - 0.5;// TODO: allow dataStream to fetch or calculate min/max based on observed values + let unitMin = typeof dataStream.minValue === 'number' ? dataStream.minValue : dataStream.currentValue - 0.5; let unitMax = typeof dataStream.maxValue === 'number' ? dataStream.maxValue : dataStream.currentValue + 0.5; let valueMapped = (dataStream.currentValue - unitMin) / (unitMax - unitMin); server.write(nodeBinding.objectName, nodeBinding.frameName, nodeBinding.nodeName, valueMapped, mode, unit, unitMin, unitMax); - - // console.log('>>> processNodeBinding'); } /** + * Persists the list of Data Sources to persistent storage. * @param {DataSource[]} dataSources */ writeDataSourcesToSettings(dataSources) { @@ -169,11 +290,10 @@ class DataStreamInterface { console.log(`${this.interfaceName}: success persisting dataSources to settings`, successful); } }); - - console.log('>>> writeDataSourcesToSettings'); } /** + * Persists the list of Node Bindings to persistent storage. * @param {NodeBinding[]} nodeBindings */ writeNodeBindingsToSettings(nodeBindings) { @@ -185,8 +305,6 @@ class DataStreamInterface { console.log(`${this.interfaceName}: success persisting nodeBindings to settings`, successful); } }); - - console.log('>>> writeNodeBindingsToSettings'); } } diff --git a/libraries/dataStreamInterfaces.js b/libraries/dataStreamInterfaces.js index 04c40348f..edef88b95 100644 --- a/libraries/dataStreamInterfaces.js +++ b/libraries/dataStreamInterfaces.js @@ -253,12 +253,13 @@ const DataStreamClientAPI = { callback(dataSource); }); } -} +}; module.exports = { DataStreamClientAPI, // intended to be used in response to clients using the server's REST APIs DataStreamServerAPI, // intended to be used by the DataStreamInterface class, not by individual hardware interfaces DataStream, DataSource, + DataSourceDetails, NodeBinding -} +}; diff --git a/libraries/objectDefaultFiles/envelope.js b/libraries/objectDefaultFiles/envelope.js index 35b8e36c9..6532b545d 100644 --- a/libraries/objectDefaultFiles/envelope.js +++ b/libraries/objectDefaultFiles/envelope.js @@ -568,7 +568,7 @@ this.rootElementWhenClosed.style.display = 'none'; this.rootElementWhenOpen.style.display = ''; // change the iframe and touch overlay size (including visual feedback corners) when the frame changes size - if (!this.disableAutomaticResizing){ + if (!this.disableAutomaticResizing) { this.realityInterface.changeFrameSize(parseInt(this.rootElementWhenOpen.clientWidth), parseInt(this.rootElementWhenOpen.clientHeight)); } this.moveDelayBeforeOpen = this.realityInterface.getMoveDelay() || 400; diff --git a/libraries/objectDefaultFiles/object.js b/libraries/objectDefaultFiles/object.js index 497361690..8e011f32a 100755 --- a/libraries/objectDefaultFiles/object.js +++ b/libraries/objectDefaultFiles/object.js @@ -303,7 +303,7 @@ */ spatialObject.getURL = function (path) { return `${spatialObject.socketIoUrl}${path}`; - } + }; /** * receives POST messages from parent to change spatialObject state From c9d91de1df95e6e34cc59b885492a1c8c9bba339 Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Fri, 26 Jan 2024 16:44:18 -0500 Subject: [PATCH 15/16] adds data-streams.test.js to test programmatically adding a DataSource, DataStream, and NodeBinding to an interface --- tests/data-streams.test.js | 121 +++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/data-streams.test.js diff --git a/tests/data-streams.test.js b/tests/data-streams.test.js new file mode 100644 index 000000000..942743452 --- /dev/null +++ b/tests/data-streams.test.js @@ -0,0 +1,121 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +/* global test, beforeAll, afterAll, expect */ +const fetch = require('node-fetch'); +const { DataStream } = require('../libraries/dataStreamInterfaces'); +const DataStreamInterface = require('../libraries/DataStreamInterface'); +const { objectsPath } = require('../config'); +const { fileExists, mkdirIfNotExists } = require('../libraries/utilities'); +const { sleep } = require('./helpers.js'); +const path = require('path'); +const fsProm = require('fs/promises'); + +let server; +beforeAll(async () => { + server = require('../server.js'); + await sleep(1000); +}); + +afterAll(async () => { + await server.exit(); + await sleep(1000); +}); + +test('add data source and node binding', async () => { + expect.assertions(9); // don't finish test until we count this many assertions + + // create a settings.json file for the testInterface + const INTERFACE_NAME = 'testInterface'; + let interfaceIdentityDir = path.join(objectsPath, '.identity', INTERFACE_NAME); + await mkdirIfNotExists(interfaceIdentityDir, {recursive: true, mode: '0766'}); + let settingsFile = path.join(interfaceIdentityDir, 'settings.json'); + if (!await fileExists(settingsFile)) { + try { + await fsProm.writeFile(settingsFile, '{}'); + } catch (err) { + console.log('Error writing file', err); + } + } + + // create some mock data + const stream1 = new DataStream('streamId_001', 'Test Stream 1', 'node', 0.7, 0, 1, INTERFACE_NAME); + const stream2 = new DataStream('streamId_002', 'Test Stream 2', 'node', 10, 0, 10, INTERFACE_NAME); + + // this function mocks the process of "fetching" the array of data streams from a data source endpoint + const fetchMockDataStreams = async (dataSource) => { + if (!dataSource) return []; + return [ + stream1, + stream2 + ]; + }; + let dsInterface = new DataStreamInterface(INTERFACE_NAME, [], [], fetchMockDataStreams); + + // check that data source, streams, and node bindings begin as empty + expect(dsInterface.dataSources).toEqual([]); + expect(dsInterface.dataStreams).toEqual([]); + expect(dsInterface.nodeBindings).toEqual([]); + + let addDataSourceBody = { + interfaceName: INTERFACE_NAME, + dataSource: { + id: 'testDataSource123', + displayName: 'Test Data Source 123', + source: { + type: 'REST/GET', + url: 'http://www.example.com/', + headers: { + Accept: 'application/json', + appKey: 'testAppKey' + }, + pollingFrequency: 60 * 1000, + dataFormat: 'testFormat' + } + } + }; + + await fetch('http://localhost:8080/logic/addDataSourceToInterface', { + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(addDataSourceBody), + method: 'POST', + mode: 'cors' + }); + + // after posting to /addDataSourceToInterface, the interface will have one dataSource and two dataStreams + expect(dsInterface.dataSources).toEqual([dsInterface.castToDataSource(addDataSourceBody.dataSource)]); + expect(dsInterface.dataStreams).toEqual([stream1, stream2]); + + // send a message to the correct hardware interface on the correct server to subscribe this node + let bindNodeBody = { + interfaceName: INTERFACE_NAME, + nodeBinding: { + objectId: '_WORLD_instantScanPJ1cgyrm_T6ijgnpsk1c', + frameId: '_WORLD_instantScanPJ1cgyrm_T6ijgnpsk1cspatialDraw1mJx458y5jn9a', + nodeName: 'value', + nodeType: 'node', + frameType: 'spatialDraw', + streamId: stream1.id + } + }; + + await fetch('http://localhost:8080/logic/bindNodeToDataStream', { + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(bindNodeBody), + method: 'POST', + mode: 'cors' + }); + + // after posting to /bindNodeToDataStream, the interface will have one node binding with a nodeId and streamId + expect(dsInterface.nodeBindings[0].objectId).toBe('_WORLD_instantScanPJ1cgyrm_T6ijgnpsk1c'); + expect(dsInterface.nodeBindings[0].frameId).toBe('_WORLD_instantScanPJ1cgyrm_T6ijgnpsk1cspatialDraw1mJx458y5jn9a'); + expect(dsInterface.nodeBindings[0].nodeId).toMatch(/^_WORLD_instantScanPJ1cgyrm_T6ijgnpsk1cspatialDraw1mJx458y5jn9a/); + expect(dsInterface.nodeBindings[0].streamId).toBe(stream1.id); +}); From 027a3450c50ddc5c39e8dfc6159ebe2ce1978576 Mon Sep 17 00:00:00 2001 From: Ben Reynolds Date: Mon, 29 Jan 2024 13:45:06 -0500 Subject: [PATCH 16/16] prevent error if client tries to access a dataStreamInterface that doesnt exist --- libraries/dataStreamInterfaces.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libraries/dataStreamInterfaces.js b/libraries/dataStreamInterfaces.js index edef88b95..8d86325ae 100644 --- a/libraries/dataStreamInterfaces.js +++ b/libraries/dataStreamInterfaces.js @@ -221,6 +221,7 @@ const DataStreamClientAPI = { */ bindNodeToDataStream(interfaceName, { objectId, frameId, nodeName, nodeType, streamId}) { let callbacks = bindNodeToDataStreamCallbacks[interfaceName]; + if (!callbacks) return; callbacks.forEach(callback => { callback(objectId, frameId, nodeName, nodeType, streamId); }); @@ -235,6 +236,7 @@ const DataStreamClientAPI = { addDataSourceToInterface(interfaceName, dataSource = {}) { if (!interfaceName) return; // TODO: the API response should change status if error adding let callbacks = addDataSourceCallbacks[interfaceName]; + if (!callbacks) return; callbacks.forEach(callback => { callback(dataSource); }); @@ -249,6 +251,7 @@ const DataStreamClientAPI = { deleteDataSourceFromInterface(interfaceName, dataSource) { if (!interfaceName || !dataSource) return; // TODO: the API response should change status if error adding let callbacks = deleteDataSourceCallbacks[interfaceName]; + if (!callbacks) return; callbacks.forEach(callback => { callback(dataSource); });