From f3c6be28814c5ef34a3be114d53581dde00dff6b Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford Date: Fri, 28 Apr 2017 13:40:13 -0700 Subject: [PATCH 1/6] Add an I/O device to represent the Device Manager --- package.json | 2 + src/engine/runtime.js | 2 + src/io/deviceManager.js | 261 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+) create mode 100644 src/io/deviceManager.js diff --git a/package.json b/package.json index 3e359b393..e009cb037 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "eslint-config-scratch": "^3.1.0", "expose-loader": "0.7.3", "gh-pages": "^0.12.0", + "got": "^5.7.1", "highlightjs": "^9.8.0", "htmlparser2": "3.9.2", "json": "^9.0.4", @@ -45,6 +46,7 @@ "scratch-render": "latest", "scratch-storage": "latest", "script-loader": "0.7.0", + "socket.io-client": "^1.7.3", "stats.js": "^0.17.0", "tap": "^10.2.0", "travis-after-all": "^1.4.4", diff --git a/src/engine/runtime.js b/src/engine/runtime.js index f57a0e9f9..443d4b9f8 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -5,6 +5,7 @@ const Thread = require('./thread'); // Virtual I/O devices. const Clock = require('../io/clock'); +const DeviceManager = require('../io/deviceManager'); const Keyboard = require('../io/keyboard'); const Mouse = require('../io/mouse'); @@ -141,6 +142,7 @@ class Runtime extends EventEmitter { /** @type {Object.} */ this.ioDevices = { clock: new Clock(), + deviceManager: new DeviceManager(), keyboard: new Keyboard(this), mouse: new Mouse(this) }; diff --git a/src/io/deviceManager.js b/src/io/deviceManager.js new file mode 100644 index 000000000..c603ccc9f --- /dev/null +++ b/src/io/deviceManager.js @@ -0,0 +1,261 @@ +const got = require('got'); +const io = require('socket.io-client'); +const querystring = require('querystring'); + +/** + * Internal class used by the Device Manager client to manage making a connection to a particular device. + */ +class DeviceOpener { + /** + * @return {number} - The number of milliseconds to allow before deciding a connection attempt has timed out. + */ + static get CONNECTION_TIMEOUT_MS () { + return 10 * 1000; + } + + /** + * Construct a DeviceOpener to help connect to a particular device. + * @param {DeviceManager} deviceManager - the Device Manager client which instigated this action. + * @param {function} resolve - callback to be called if the device is successfully found, connected, and opened. + * @param {function} reject - callback to be called if an error or timeout is encountered. + */ + constructor (deviceManager, resolve, reject) { + this._deviceManager = deviceManager; + this._resolve = resolve; + this._reject = reject; + } + + /** + * Attempt to open a particular device. This will cause `resolve` or `reject` to be called. + * Note that in some cases it's possible that both `resolve` and `reject` will be called. In that event, ignore all + * calls after the first. If `resolve` and `reject` are from a Promise, then the Promise will do this for you. + * @param {string} extensionName - human-readable name of the extension requesting the device + * @param {string} deviceType - the type of device to open, such as 'wedo2' + * @param {string} deviceId - the ID of the particular device to open, usually from list results + */ + open (extensionName, deviceType, deviceId) { + this._socket = /** @type {Socket} */ io(`${this._deviceManager._serverURL}/${deviceType}`); + this._deviceManager._sockets.push(this._socket); + + this._socket.on('deviceWasOpened', () => this.onDeviceWasOpened()); + this._socket.on('disconnect', () => this.onDisconnect()); + this._connectionTimeout = setTimeout(() => this.onTimeout(), DeviceOpener.CONNECTION_TIMEOUT_MS); + + this._socket.emit('open', {deviceId: deviceId, name: extensionName}); + } + + /** + * React to a 'deviceWasOpened' message from the Device Manager application. + */ + onDeviceWasOpened () { + this.clearConnectionTimeout(); + this._resolve(this._socket); + } + + /** + * React to the socket becoming disconnected. + */ + onDisconnect () { + this.removeSocket(); + this.clearConnectionTimeout(); + this._reject('device disconnected'); + } + + /** + * React to the connection timeout expiring. This could mean that the socket itself timed out, or that the Device + * Manager took too long to send a 'deviceWasOpened' message back. + */ + onTimeout () { + this.clearConnectionTimeout(); + + // `socket.disconnect()` triggers `onDisconnect` only for connected sockets + if (this._socket.connected) { + this._socket.disconnect(); + } else { + this.removeSocket(); + this._reject('connection attempt timed out'); + } + } + + /** + * Cancel the connection timeout. + */ + clearConnectionTimeout () { + if (this._connectionTimeout !== null) { + clearTimeout(this._connectionTimeout); + this._connectionTimeout = null; + } + } + + /** + * Remove the socket we were using for a now-failed connection attempt. + */ + removeSocket () { + const socketIndex = this._deviceManager._sockets.indexOf(this._socket); + if (socketIndex >= 0) { + this._deviceManager._sockets.splice(socketIndex, 1); + } + } +} + +/** + * A DeviceFinder implements the Device Manager client's `searchAndConnect` functionality. + * Use the `promise` property to access a promise for a device socket. + * Call `cancel()` to cancel the search. Once the search finds a device it cannot be canceled. + */ +class DeviceFinder { + /** + * @return {number} - the number of milliseconds to wait between search attempts (calls to 'list') + */ + static get SEARCH_RETRY_MS () { + return 1000; + } + + /** + * Construct a DeviceFinder to help find and connect to a device satisfying specific conditions. + * @param {DeviceManager} deviceManager - the Device Manager client which instigated this action. + * @param {string} extensionName - human-readable name of the extension requesting the search + * @param {string} deviceType - the type of device to list, such as 'wedo2' + * @param {object} [deviceSpec] - optional additional information about the specific devices to list + */ + constructor (deviceManager, extensionName, deviceType, deviceSpec) { + this._deviceManager = deviceManager; + this._extensionName = extensionName; + this._deviceType = deviceType; + this._deviceSpec = deviceSpec; + this._cancel = false; + this._promise = null; + this._fulfill = null; + } + + /** + * @return {Promise} - A promise for a device socket. + */ + get promise () { + return this._promise; + } + + /** + * Start searching for a device. + */ + start () { + this._promise = new Promise((fulfill, reject) => { + this._fulfill = fulfill; + this._reject = reject; + this._getList(); + }); + } + + /** + * Cancel the search for a device. Effective only before the promise resolves. + */ + cancel () { + this._cancel = true; + this._reject('canceled'); + } + + /** + * Fetch the list of devices matching the parameters provided in the constructor. + * @private + */ + _getList () { + this._deviceManager + .list(this._extensionName, this._deviceType, this._deviceSpec) + .then(listResult => this._listResultHandler(listResult)); + } + + /** + * Handle the list of devices returned by the Device Manager. + * @param {Array} listResult - an array of device information objects. + * @private + */ + _listResultHandler (listResult) { + if (this._cancel) { + return; + } + + if (listResult && listResult.length > 0) { + for (const deviceInfo of listResult) { + if (!deviceInfo.connected) { + this._fulfill(this._deviceManager.open(this._extensionName, this._deviceType, deviceInfo.id)); + return; + } + } + } + + setTimeout(() => this._getList(), DeviceFinder.SEARCH_RETRY_MS); + } +} + +/** + * A Scratch 3.0 "I/O Device" representing a client for the Scratch Device Manager. + */ +class DeviceManager { + /** + * @return {string} - The default Scratch Device Manager connection URL. + */ + static get DEFAULT_SERVER_URL () { + return 'https://device-manager.scratch.mit.edu:3030'; + } + + constructor () { + this._serverURL = DeviceManager.DEFAULT_SERVER_URL; + this._isConnected = true; + this._sockets = []; + } + + /** + * @return {boolean} - True if there is no known problem connecting to the Scratch Device Manager, false otherwise. + */ + get isConnected () { + return this._isConnected; + } + + /** + * High-level request to find and connect to a device satisfying the specified characteristics. + * This function will repeatedly call list() until the list is non-empty, then it will open() the first suitable + * item in the list and provide the socket for that device. + * @todo Offer a way to filter results. See the Scratch 2.0 PicoBoard extension for details on why that's important. + * @param {string} extensionName - human-readable name of the extension requesting the search + * @param {string} deviceType - the type of device to list, such as 'wedo2' + * @param {object} [deviceSpec] - optional additional information about the specific devices to list + * @return {DeviceFinder} - An object providing a Promise for an opened device and a way to cancel the search. + */ + searchAndConnect (extensionName, deviceType, deviceSpec) { + const finder = new DeviceFinder(this, extensionName, deviceType, deviceSpec); + finder.start(); + return finder; + } + + /** + * Request a list of available devices. + * @param {string} extensionName - human-readable name of the extension requesting the list + * @param {string} deviceType - the type of device to list, such as 'wedo2' + * @param {object} [deviceSpec] - optional additional information about the specific devices to list + * @return {Promise} - A Promise for an Array of available devices. + */ + list (extensionName, deviceType, deviceSpec) { + const queryObject = { + name: extensionName + }; + if (deviceSpec) queryObject.spec = deviceSpec; + const url = `${this._serverURL}/${encodeURIComponent(deviceType)}/list?${querystring.stringify(queryObject)}`; + return got(url).then(response => JSON.parse(response.body)); + } + + /** + * Attempt to open a particular device. + * @param {string} extensionName - human-readable name of the extension requesting the device + * @param {string} deviceType - the type of device to open, such as 'wedo2' + * @param {string} deviceId - the ID of the particular device to open, usually from list results + * @return {Promise} - A Promise for a Socket which can be used to communicate with the device + */ + open (extensionName, deviceType, deviceId) { + return new Promise((resolve, reject) => { + const opener = new DeviceOpener(this, resolve, reject); + opener.open(extensionName, deviceType, deviceId); + }); + } +} + +module.exports = DeviceManager; From 9a9c509e7657e3d24a3c2f53bab95f4a4cdff143 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford Date: Fri, 28 Apr 2017 14:50:57 -0700 Subject: [PATCH 2/6] Implement a few tests for io/deviceManager --- src/io/deviceManager.js | 5 +++- test/unit/io_deviceManager.js | 46 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 test/unit/io_deviceManager.js diff --git a/src/io/deviceManager.js b/src/io/deviceManager.js index c603ccc9f..3895af341 100644 --- a/src/io/deviceManager.js +++ b/src/io/deviceManager.js @@ -161,7 +161,10 @@ class DeviceFinder { _getList () { this._deviceManager .list(this._extensionName, this._deviceType, this._deviceSpec) - .then(listResult => this._listResultHandler(listResult)); + .then( + listResult => this._listResultHandler(listResult), + () => this._listResultHandler(null) + ); } /** diff --git a/test/unit/io_deviceManager.js b/test/unit/io_deviceManager.js new file mode 100644 index 000000000..89127e31d --- /dev/null +++ b/test/unit/io_deviceManager.js @@ -0,0 +1,46 @@ +const test = require('tap').test; +const DeviceManager = require('../../src/io/deviceManager'); + +test('spec', t => { + const deviceManager = new DeviceManager(); + + t.type(DeviceManager, 'function'); + t.type(deviceManager, 'object'); + t.type(deviceManager.list, 'function'); + t.type(deviceManager.open, 'function'); + t.type(deviceManager.searchAndConnect, 'function'); + t.type(deviceManager.isConnected, 'boolean'); + t.end(); +}); + +test('default connected', t => { + const deviceManager = new DeviceManager(); + + t.strictEqual(deviceManager.isConnected, true); + t.end(); +}); + +test('cancel searchAndConnect', t => { + const deviceManager = new DeviceManager(); + + const finder = deviceManager.searchAndConnect('test extension', 'test device'); + + let resolved = false; + let rejected = false; + const testPromise = finder.promise + .then( + () => { + resolved = true; + }, + () => { + rejected = true; + } + ) + .then(() => { + t.strictEqual(resolved, false); + t.strictEqual(rejected, true); + }); + finder.cancel(); + + return testPromise; +}); From 96c74867b50eeda3459d0a8c6c2df695bed73709 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford Date: Fri, 28 Apr 2017 15:06:42 -0700 Subject: [PATCH 3/6] Remove test for canceling a search When no Device Manager instance is present this test takes a long time and can cause timeouts. We'll need to fake the HTTP requests if we want to do this sort of test reliably. --- test/unit/io_deviceManager.js | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/test/unit/io_deviceManager.js b/test/unit/io_deviceManager.js index 89127e31d..f013ef5dd 100644 --- a/test/unit/io_deviceManager.js +++ b/test/unit/io_deviceManager.js @@ -19,28 +19,3 @@ test('default connected', t => { t.strictEqual(deviceManager.isConnected, true); t.end(); }); - -test('cancel searchAndConnect', t => { - const deviceManager = new DeviceManager(); - - const finder = deviceManager.searchAndConnect('test extension', 'test device'); - - let resolved = false; - let rejected = false; - const testPromise = finder.promise - .then( - () => { - resolved = true; - }, - () => { - rejected = true; - } - ) - .then(() => { - t.strictEqual(resolved, false); - t.strictEqual(rejected, true); - }); - finder.cancel(); - - return testPromise; -}); From e096f4348763cdf6bc31ef84db238ff3d58a42b3 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford Date: Wed, 3 May 2017 16:26:31 -0700 Subject: [PATCH 4/6] DeviceManager: add more jsdoc, remove `_sockets` It turns out we don't need the Device Manager to centrally track each socket, so this change removes the Device Manager's `_sockets` property and related code. --- src/io/deviceManager.js | 99 +++++++++++++++++++++++++++++++++++------ 1 file changed, 85 insertions(+), 14 deletions(-) diff --git a/src/io/deviceManager.js b/src/io/deviceManager.js index 3895af341..90b1fd23d 100644 --- a/src/io/deviceManager.js +++ b/src/io/deviceManager.js @@ -20,9 +20,40 @@ class DeviceOpener { * @param {function} reject - callback to be called if an error or timeout is encountered. */ constructor (deviceManager, resolve, reject) { + /** + * The DeviceManager client which wants to open a device. + * @type {DeviceManager} + * @private + */ this._deviceManager = deviceManager; + + /** + * Callback to be called if the device is successfully found, connected, and opened. + * @type {Function} + * @private + */ this._resolve = resolve; + + /** + * Callback to be called if an error or timeout is encountered. + * @type {Function} + * @private + */ this._reject = reject; + + /** + * The socket for the device being opened. + * @type {Socket} + * @private + */ + this._socket = null; + + /** + * If this timeout expires before a successful connection, the connection attempt will be canceled. + * @type {Object} + * @private + */ + this._connectionTimeout = null; } /** @@ -34,8 +65,7 @@ class DeviceOpener { * @param {string} deviceId - the ID of the particular device to open, usually from list results */ open (extensionName, deviceType, deviceId) { - this._socket = /** @type {Socket} */ io(`${this._deviceManager._serverURL}/${deviceType}`); - this._deviceManager._sockets.push(this._socket); + this._socket = io(`${this._deviceManager._serverURL}/${deviceType}`); this._socket.on('deviceWasOpened', () => this.onDeviceWasOpened()); this._socket.on('disconnect', () => this.onDisconnect()); @@ -86,16 +116,6 @@ class DeviceOpener { this._connectionTimeout = null; } } - - /** - * Remove the socket we were using for a now-failed connection attempt. - */ - removeSocket () { - const socketIndex = this._deviceManager._sockets.indexOf(this._socket); - if (socketIndex >= 0) { - this._deviceManager._sockets.splice(socketIndex, 1); - } - } } /** @@ -115,16 +135,57 @@ class DeviceFinder { * Construct a DeviceFinder to help find and connect to a device satisfying specific conditions. * @param {DeviceManager} deviceManager - the Device Manager client which instigated this action. * @param {string} extensionName - human-readable name of the extension requesting the search - * @param {string} deviceType - the type of device to list, such as 'wedo2' + * @param {string} deviceType - the type of device to find, such as 'wedo2'. * @param {object} [deviceSpec] - optional additional information about the specific devices to list */ constructor (deviceManager, extensionName, deviceType, deviceSpec) { + /** + * The Device Manager client which wants to find a device. + * @type {DeviceManager} + * @private + */ this._deviceManager = deviceManager; + + /** + * The human-readable name of the extension requesting the search. + * @type {string} + * @private + */ this._extensionName = extensionName; + + /** + * The type of device to find, such as 'wedo2'. + * @type {string} + * @private + */ this._deviceType = deviceType; + + /** + * Optional additional information about the specific devices to list. + * @type {Object} + * @private + */ this._deviceSpec = deviceSpec; + + /** + * Flag indicating that the search should be canceled. + * @type {boolean} + * @private + */ this._cancel = false; + + /** + * The promise representing this search's results. + * @type {Promise} + * @private + */ this._promise = null; + + /** + * The fulfillment function for `this._promise`. + * @type {Function} + * @private + */ this._fulfill = null; } @@ -202,9 +263,19 @@ class DeviceManager { } constructor () { + /** + * The URL this client will use for Device Manager communication both HTTP(S) and WS(S). + * @type {string} + * @private + */ this._serverURL = DeviceManager.DEFAULT_SERVER_URL; + + /** + * True if there is no known problem connecting to the Scratch Device Manager, false otherwise. + * @type {boolean} + * @private + */ this._isConnected = true; - this._sockets = []; } /** From 35d0544ce050bfcb642fa40e0deb52166993d54a Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford Date: Fri, 5 May 2017 10:37:29 -0700 Subject: [PATCH 5/6] Respond to review comments - Pin `got` and `socket-io.client` - Finish removing `removeSocket`-related code --- package.json | 4 ++-- src/io/deviceManager.js | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index e009cb037..b32cfb6a0 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "eslint-config-scratch": "^3.1.0", "expose-loader": "0.7.3", "gh-pages": "^0.12.0", - "got": "^5.7.1", + "got": "5.7.1", "highlightjs": "^9.8.0", "htmlparser2": "3.9.2", "json": "^9.0.4", @@ -46,7 +46,7 @@ "scratch-render": "latest", "scratch-storage": "latest", "script-loader": "0.7.0", - "socket.io-client": "^1.7.3", + "socket.io-client": "1.7.3", "stats.js": "^0.17.0", "tap": "^10.2.0", "travis-after-all": "^1.4.4", diff --git a/src/io/deviceManager.js b/src/io/deviceManager.js index 90b1fd23d..18e6de024 100644 --- a/src/io/deviceManager.js +++ b/src/io/deviceManager.js @@ -86,7 +86,6 @@ class DeviceOpener { * React to the socket becoming disconnected. */ onDisconnect () { - this.removeSocket(); this.clearConnectionTimeout(); this._reject('device disconnected'); } @@ -102,7 +101,6 @@ class DeviceOpener { if (this._socket.connected) { this._socket.disconnect(); } else { - this.removeSocket(); this._reject('connection attempt timed out'); } } From 9d306962bdda1e9ed8a298cbe449d28fc2eb9b08 Mon Sep 17 00:00:00 2001 From: Christopher Willis-Ford Date: Thu, 11 May 2017 12:07:46 -0700 Subject: [PATCH 6/6] Fix socket.io-client dependency problems re: build The Socket.io client library pulls in some extra dependencies in a way that isn't fully compatible with Webpack. Switching to their build output file, instead of the file specified in their `package.json`, makes those errors go away. --- src/io/deviceManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/io/deviceManager.js b/src/io/deviceManager.js index 18e6de024..d2d0046fb 100644 --- a/src/io/deviceManager.js +++ b/src/io/deviceManager.js @@ -1,5 +1,5 @@ const got = require('got'); -const io = require('socket.io-client'); +const io = require('socket.io-client/dist/socket.io'); const querystring = require('querystring'); /**