diff --git a/package-lock.json b/package-lock.json index c887d5869..824406d2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3799,9 +3799,9 @@ } }, "eslint": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-5.0.1.tgz", - "integrity": "sha512-D5nG2rErquLUstgUaxJlWB5+gu+U/3VDY0fk/Iuq8y9CUFy/7Y6oF4N2cR1tV8knzQvciIbfqfohd359xTLIKQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-5.1.0.tgz", + "integrity": "sha512-DyH6JsoA1KzA5+OSWFjg56DFJT+sDLO0yokaPZ9qY0UEmYrPA1gEX/G1MnVkmRDsksG4H1foIVz2ZXXM3hHYvw==", "dev": true, "requires": { "ajv": "^6.5.0", @@ -3811,6 +3811,7 @@ "debug": "^3.1.0", "doctrine": "^2.1.0", "eslint-scope": "^4.0.0", + "eslint-utils": "^1.3.1", "eslint-visitor-keys": "^1.0.0", "espree": "^4.0.0", "esquery": "^1.0.1", @@ -3818,7 +3819,7 @@ "file-entry-cache": "^2.0.0", "functional-red-black-tree": "^1.0.1", "glob": "^7.1.2", - "globals": "^11.5.0", + "globals": "^11.7.0", "ignore": "^3.3.3", "imurmurhash": "^0.1.4", "inquirer": "^5.2.0", @@ -4138,6 +4139,12 @@ "estraverse": "^4.1.1" } }, + "eslint-utils": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-1.3.1.tgz", + "integrity": "sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q==", + "dev": true + }, "eslint-visitor-keys": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", @@ -13129,9 +13136,9 @@ } }, "scratch-blocks": { - "version": "0.1.0-prerelease.1530135682", - "resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.1530135682.tgz", - "integrity": "sha512-JadXtaqDLmebmitbk5s5RVtuApvWWaj+ECSIfXY01dY9X3JDZAopMDWZuP/8uNWg7S1xMBrIaD8Rv0r83lGdmA==", + "version": "0.1.0-prerelease.1531144787", + "resolved": "https://registry.npmjs.org/scratch-blocks/-/scratch-blocks-0.1.0-prerelease.1531144787.tgz", + "integrity": "sha512-fSS/C6pBh5kNkimFfHDO+XG8Ny6lzHePb5eeqxDkel2u5agJgkAbIgBgJnsqwJ+iJmKhmJgW29K/iM442MDYXA==", "dev": true, "requires": { "exports-loader": "0.6.3", @@ -16054,6 +16061,11 @@ "untildify": "^3.0.2" }, "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=" + }, "ansi-styles": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", @@ -16108,6 +16120,14 @@ "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", "dev": true }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "requires": { + "ansi-regex": "^3.0.0" + } + }, "supports-color": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.4.0.tgz", diff --git a/src/engine/runtime.js b/src/engine/runtime.js index 1ba19570b..530db3b40 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -254,6 +254,8 @@ class Runtime extends EventEmitter { video: new Video(this) }; + this.extensionDevices = {}; + /** * A runtime profiler that records timed events for later playback to * diagnose Scratch performance. @@ -394,6 +396,30 @@ class Runtime extends EventEmitter { return 'EXTENSION_ADDED'; } + /** + * Event name for updating the available set of peripheral devices. + * @const {string} + */ + static get PERIPHERAL_LIST_UPDATE () { + return 'PERIPHERAL_LIST_UPDATE'; + } + + /** + * Event name for reporting that a peripheral has connected. + * @const {string} + */ + static get PERIPHERAL_CONNECTED () { + return 'PERIPHERAL_CONNECTED'; + } + + /** + * Event name for reporting that a peripheral has encountered an error. + * @const {string} + */ + static get PERIPHERAL_ERROR () { + return 'PERIPHERAL_ERROR'; + } + /** * Event name for reporting that blocksInfo was updated. * @const {string} @@ -867,6 +893,36 @@ class Runtime extends EventEmitter { (result, categoryInfo) => result.concat(categoryInfo.blocks.map(blockInfo => blockInfo.json)), []); } + registerExtensionDevice (extensionId, device) { + this.extensionDevices[extensionId] = device; + } + + startDeviceScan (extensionId) { + if (this.extensionDevices[extensionId]) { + this.extensionDevices[extensionId].startDeviceScan(); + } + } + + connectToPeripheral (extensionId, peripheralId) { + if (this.extensionDevices[extensionId]) { + this.extensionDevices[extensionId].connectDevice(peripheralId); + } + } + + disconnectExtensionSession (extensionId) { + if (this.extensionDevices[extensionId]) { + this.extensionDevices[extensionId].disconnectSession(); + } + } + + getPeripheralIsConnected (extensionId) { + let isConnected = false; + if (this.extensionDevices[extensionId]) { + isConnected = this.extensionDevices[extensionId].getPeripheralIsConnected(); + } + return isConnected; + } + /** * Retrieve the function associated with the given opcode. * @param {!string} opcode The opcode to look up. diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index cff9f4f0e..7a004104f 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -15,6 +15,7 @@ const Scratch3SpeakBlocks = require('../extensions/scratch3_speak'); const Scratch3TranslateBlocks = require('../extensions/scratch3_translate'); const Scratch3VideoSensingBlocks = require('../extensions/scratch3_video_sensing'); const Scratch3SpeechBlocks = require('../extensions/scratch3_speech'); +const Scratch3Ev3Blocks = require('../extensions/scratch3_ev3'); const builtinExtensions = { pen: Scratch3PenBlocks, @@ -24,7 +25,8 @@ const builtinExtensions = { speak: Scratch3SpeakBlocks, translate: Scratch3TranslateBlocks, videoSensing: Scratch3VideoSensingBlocks, - speech: Scratch3SpeechBlocks + speech: Scratch3SpeechBlocks, + ev3: Scratch3Ev3Blocks }; /** diff --git a/src/extensions/scratch3_ev3/index.js b/src/extensions/scratch3_ev3/index.js new file mode 100644 index 000000000..e5ea01660 --- /dev/null +++ b/src/extensions/scratch3_ev3/index.js @@ -0,0 +1,919 @@ +const ArgumentType = require('../../extension-support/argument-type'); +const BlockType = require('../../extension-support/block-type'); +const Cast = require('../../util/cast'); +const log = require('../../util/log'); +const Base64Util = require('../../util/base64-util'); +const BTSession = require('../../io/btSession'); + +// TODO: Refactor/rename all these high level primitives to be clearer/match + +/** + * Icon svg to be displayed at the left edge of each extension block, encoded as a data URI. + * @type {string} + */ +// eslint-disable-next-line max-len +const blockIconURI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iNDBweCIgaGVpZ2h0PSI0MHB4IiB2aWV3Qm94PSIwIDAgNDAgNDAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDUwLjIgKDU1MDQ3KSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5ldjMtYmxvY2staWNvbjwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxkZWZzPjwvZGVmcz4KICAgIDxnIGlkPSJldjMtYmxvY2staWNvbiIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgaWQ9ImV2MyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNS41MDAwMDAsIDMuNTAwMDAwKSIgZmlsbC1ydWxlPSJub256ZXJvIj4KICAgICAgICAgICAgPHJlY3QgaWQ9IlJlY3RhbmdsZS1wYXRoIiBzdHJva2U9IiM3Qzg3QTUiIGZpbGw9IiNGRkZGRkYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgeD0iMC41IiB5PSIzLjU5IiB3aWR0aD0iMjgiIGhlaWdodD0iMjUuODEiIHJ4PSIxIj48L3JlY3Q+CiAgICAgICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUtcGF0aCIgc3Ryb2tlPSIjN0M4N0E1IiBmaWxsPSIjRTZFN0U4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHg9IjIuNSIgeT0iMC41IiB3aWR0aD0iMjQiIGhlaWdodD0iMzIiIHJ4PSIxIj48L3JlY3Q+CiAgICAgICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUtcGF0aCIgc3Ryb2tlPSIjN0M4N0E1IiBmaWxsPSIjRkZGRkZGIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHg9IjIuNSIgeT0iMTQuNSIgd2lkdGg9IjI0IiBoZWlnaHQ9IjEzIj48L3JlY3Q+CiAgICAgICAgICAgIDxwYXRoIGQ9Ik0xNC41LDEwLjUgTDE0LjUsMTQuNSIgaWQ9IlNoYXBlIiBzdHJva2U9IiM3Qzg3QTUiIGZpbGw9IiNFNkU3RTgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PC9wYXRoPgogICAgICAgICAgICA8cmVjdCBpZD0iUmVjdGFuZ2xlLXBhdGgiIGZpbGw9IiM0MTQ3NTciIHg9IjQuNSIgeT0iMi41IiB3aWR0aD0iMjAiIGhlaWdodD0iMTAiIHJ4PSIxIj48L3JlY3Q+CiAgICAgICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUtcGF0aCIgZmlsbD0iIzdDODdBNSIgb3BhY2l0eT0iMC41IiB4PSIxMy41IiB5PSIyMC4xMyIgd2lkdGg9IjIiIGhlaWdodD0iMiIgcng9IjAuNSI+PC9yZWN0PgogICAgICAgICAgICA8cGF0aCBkPSJNOS4wNiwyMC4xMyBMMTAuNTYsMjAuMTMgQzEwLjgzNjE0MjQsMjAuMTMgMTEuMDYsMjAuMzUzODU3NiAxMS4wNiwyMC42MyBMMTEuMDYsMjEuNjMgQzExLjA2LDIxLjkwNjE0MjQgMTAuODM2MTQyNCwyMi4xMyAxMC41NiwyMi4xMyBMOS4wNiwyMi4xMyBDOC41MDc3MTUyNSwyMi4xMyA4LjA2LDIxLjY4MjI4NDcgOC4wNiwyMS4xMyBDOC4wNiwyMC41Nzc3MTUzIDguNTA3NzE1MjUsMjAuMTMgOS4wNiwyMC4xMyBaIiBpZD0iU2hhcGUiIGZpbGw9IiM3Qzg3QTUiIG9wYWNpdHk9IjAuNSI+PC9wYXRoPgogICAgICAgICAgICA8cGF0aCBkPSJNMTguOTEsMjAuMTMgTDIwLjQyLDIwLjEzIEMyMC42OTYxNDI0LDIwLjEzIDIwLjkyLDIwLjM1Mzg1NzYgMjAuOTIsMjAuNjMgTDIwLjkyLDIxLjYzIEMyMC45MiwyMS45MDYxNDI0IDIwLjY5NjE0MjQsMjIuMTMgMjAuNDIsMjIuMTMgTDE4LjkyLDIyLjEzIEMxOC4zNjc3MTUzLDIyLjEzIDE3LjkyLDIxLjY4MjI4NDcgMTcuOTIsMjEuMTMgQzE3LjkxOTk3MjYsMjAuNTgxNTk3IDE4LjM2MTYyNDUsMjAuMTM1NDg0IDE4LjkxLDIwLjEzIFoiIGlkPSJTaGFwZSIgZmlsbD0iIzdDODdBNSIgb3BhY2l0eT0iMC41IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxOS40MjAwMDAsIDIxLjEzMDAwMCkgcm90YXRlKC0xODAuMDAwMDAwKSB0cmFuc2xhdGUoLTE5LjQyMDAwMCwgLTIxLjEzMDAwMCkgIj48L3BhdGg+CiAgICAgICAgICAgIDxwYXRoIGQ9Ik04LjIzLDE3LjUgTDUsMTcuNSBDNC43MjM4NTc2MywxNy41IDQuNSwxNy4yNzYxNDI0IDQuNSwxNyBMNC41LDE0LjUgTDEwLjUsMTQuNSBMOC42NSwxNy4yOCBDOC41NTQ2Njk2MSwxNy40MTc5MDgyIDguMzk3NjUwMDYsMTcuNTAwMTU2NiA4LjIzLDE3LjUgWiIgaWQ9IlNoYXBlIiBmaWxsPSIjN0M4N0E1IiBvcGFjaXR5PSIwLjUiPjwvcGF0aD4KICAgICAgICAgICAgPHBhdGggZD0iTTE4LjE1LDE4Ljg1IEwxNy42NSwxOS4zNSBDMTcuNTUyMzQxNiwxOS40NDQwNzU2IDE3LjQ5ODAzMzksMTkuNTc0NDE0MiAxNy41LDE5LjcxIEwxNy41LDIwIEMxNy41LDIwLjI3NjE0MjQgMTcuMjc2MTQyNCwyMC41IDE3LDIwLjUgTDE2LjUsMjAuNSBDMTYuMjIzODU3NiwyMC41IDE2LDIwLjI3NjE0MjQgMTYsMjAgQzE2LDE5LjcyMzg1NzYgMTUuNzc2MTQyNCwxOS41IDE1LjUsMTkuNSBMMTMuNSwxOS41IEMxMy4yMjM4NTc2LDE5LjUgMTMsMTkuNzIzODU3NiAxMywyMCBDMTMsMjAuMjc2MTQyNCAxMi43NzYxNDI0LDIwLjUgMTIuNSwyMC41IEwxMiwyMC41IEMxMS43MjM4NTc2LDIwLjUgMTEuNSwyMC4yNzYxNDI0IDExLjUsMjAgTDExLjUsMTkuNzEgQzExLjUwMTk2NjEsMTkuNTc0NDE0MiAxMS40NDc2NTg0LDE5LjQ0NDA3NTYgMTEuMzUsMTkuMzUgTDEwLjg1LDE4Ljg1IEMxMC42NTgyMTY3LDE4LjY1MjE4NjMgMTAuNjU4MjE2NywxOC4zMzc4MTM3IDEwLjg1LDE4LjE0IEwxMi4zNiwxNi42NSBDMTIuNDUwMjgwMywxNi41NTI4NjE3IDEyLjU3NzM5NjEsMTYuNDk4MzgzNSAxMi43MSwxNi41IEwxNi4yOSwxNi41IEMxNi40MjI2MDM5LDE2LjQ5ODM4MzUgMTYuNTQ5NzE5NywxNi41NTI4NjE3IDE2LjY0LDE2LjY1IEwxOC4xNSwxOC4xNCBDMTguMzQxNzgzMywxOC4zMzc4MTM3IDE4LjM0MTc4MzMsMTguNjUyMTg2MyAxOC4xNSwxOC44NSBaIiBpZD0iU2hhcGUiIGZpbGw9IiM3Qzg3QTUiIG9wYWNpdHk9IjAuNSI+PC9wYXRoPgogICAgICAgICAgICA8cGF0aCBkPSJNMTAuODUsMjMuNDUgTDExLjM1LDIyLjk1IEMxMS40NDc2NTg0LDIyLjg1NTkyNDQgMTEuNTAxOTY2MSwyMi43MjU1ODU4IDExLjUsMjIuNTkgTDExLjUsMjIuMyBDMTEuNSwyMi4wMjM4NTc2IDExLjcyMzg1NzYsMjEuOCAxMiwyMS44IEwxMi41LDIxLjggQzEyLjc3NjE0MjQsMjEuOCAxMywyMi4wMjM4NTc2IDEzLDIyLjMgQzEzLDIyLjU3NjE0MjQgMTMuMjIzODU3NiwyMi44IDEzLjUsMjIuOCBMMTUuNSwyMi44IEMxNS43NzYxNDI0LDIyLjggMTYsMjIuNTc2MTQyNCAxNiwyMi4zIEMxNiwyMi4wMjM4NTc2IDE2LjIyMzg1NzYsMjEuOCAxNi41LDIxLjggTDE3LDIxLjggQzE3LjI3NjE0MjQsMjEuOCAxNy41LDIyLjAyMzg1NzYgMTcuNSwyMi4zIEwxNy41LDIyLjU5IEMxNy40OTgwMzM5LDIyLjcyNTU4NTggMTcuNTUyMzQxNiwyMi44NTU5MjQ0IDE3LjY1LDIyLjk1IEwxOC4xNSwyMy40NSBDMTguMzQwNTcxNCwyMy42NDQ0MjE4IDE4LjM0MDU3MTQsMjMuOTU1NTc4MiAxOC4xNSwyNC4xNSBMMTYuNjQsMjUuNjUgQzE2LjU0OTcxOTcsMjUuNzQ3MTM4MyAxNi40MjI2MDM5LDI1LjgwMTYxNjUgMTYuMjksMjUuOCBMMTIuNzEsMjUuOCBDMTIuNTc3Mzk2MSwyNS44MDE2MTY1IDEyLjQ1MDI4MDMsMjUuNzQ3MTM4MyAxMi4zNiwyNS42NSBMMTAuODUsMjQuMTUgQzEwLjY1OTQyODYsMjMuOTU1NTc4MiAxMC42NTk0Mjg2LDIzLjY0NDQyMTggMTAuODUsMjMuNDUgWiIgaWQ9IlNoYXBlIiBmaWxsPSIjN0M4N0E1IiBvcGFjaXR5PSIwLjUiPjwvcGF0aD4KICAgICAgICAgICAgPHBhdGggZD0iTTIxLjUsMjcuNSBMMjYuNSwyNy41IEwyNi41LDMxLjUgQzI2LjUsMzIuMDUyMjg0NyAyNi4wNTIyODQ3LDMyLjUgMjUuNSwzMi41IEwyMS41LDMyLjUgTDIxLjUsMjcuNSBaIiBpZD0iU2hhcGUiIHN0cm9rZT0iI0NDNEMyMyIgZmlsbD0iI0YxNUEyOSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48L3BhdGg+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4='; + +/** + * High-level primitives / constants used by the extension. + * @type {object} + */ +const BTCommand = { + LAYER: 0x00, + NUM8: 0x81, + NUM16: 0x82, + NUM32: 0x83, + COAST: 0x0, + BRAKE: 0x1, + LONGRAMP: 50, + STEPSPEED: 0xAE, + TIMESPEED: 0xAF, + OUTPUTSTOP: 0xA3, + OUTPUTRESET: 0xA2, + STEPSPEEDSYNC: 0xB0, + TIMESPEEDSYNC: 0xB1 +}; + +/** + * Array of accepted motor ports. + * @note These should not be translated as they correspond to labels on + * the EV3 hub. + * @type {array} + */ +const MOTOR_PORTS = [ + { + name: 'A', + value: 1 + }, + { + name: 'B', + value: 2 + }, + { + name: 'C', + value: 4 + }, + { + name: 'D', + value: 8 + } +]; + +/** + * Array of accepted sensor ports. + * @note These should not be translated as they correspond to labels on + * the EV3 hub. + * @type {array} + */ +const SENSOR_PORTS = [ + { + name: '1', + value: 1 + }, + { + name: '2', + value: 2 + }, + { + name: '3', + value: 3 + }, + { + name: '4', + value: 4 + } +]; + +// firmware pdf page 100 +const EV_DEVICE_TYPES = { + 29: 'color', + 30: 'ultrasonic', + 32: 'gyro', + 16: 'touch', + 8: 'mediumMotor', + 7: 'largeMotor', + 126: 'none' +}; + +// firmware pdf page 100? +const EV_DEVICE_MODES = { + touch: 0, + color: 1, + ultrasonic: 1 +}; + +const EV_DEVICE_LABELS = { + touch: 'button', + color: 'brightness', + ultrasonic: 'distance' +}; + + +class EV3 { + + constructor (runtime, extensionId) { + + /** + * The Scratch 3.0 runtime used to trigger the green flag button. + * @type {Runtime} + * @private + */ + this._runtime = runtime; + + /** + * EV3 State + */ + this.connected = false; + this.speed = 50; + this._sensors = { + distance: 0, + brightness: 0 + }; + this._motorPositions = { + 1: 0, + 2: 0, + 4: 0, + 8: 0 + }; + this._sensorPorts = []; + this._motorPorts = []; + this._sensorPortsWaiting = [false, false, false, false]; + this._motorPortsWaiting = [false, false, false, false]; + this._pollingIntervalID = null; + + /** + * The Bluetooth connection session for reading/writing device data. + * @type {BTSession} + * @private + */ + this._bt = null; + this._runtime.registerExtensionDevice(extensionId, this); + } + + // TODO: keep here? + /** + * Called by the runtime when user wants to scan for a device. + */ + startDeviceScan () { + this._bt = new BTSession(this._runtime, { + majorDeviceClass: 8, + minorDeviceClass: 1 + }, this._onSessionConnect.bind(this), this._onSessionMessage.bind(this)); + } + + // TODO: keep here? + /** + * Called by the runtime when user wants to connect to a certain device. + * @param {number} id - the id of the device to connect to. + */ + connectDevice (id) { + this._bt.connectDevice(id); + } + + // TODO: keep here? + /** + * Called by the runtime when user wants to disconnect from the device. + */ + disconnectSession () { + this._bt.disconnectSession(); + window.clearInterval(this._pollingIntervalID); // TODO: window? + this._sensorPorts = []; + this._motorPorts = []; + } + + /** + * Called by the runtime to detect whether the device is connected. + * @return {boolean} - the connected state. + */ + getPeripheralIsConnected () { + let connected = false; + if (this._bt) { + connected = this._bt.getPeripheralIsConnected(); + } + return connected; + } + + get distance () { + if (!this.connected) return 0; + + // https://shop.lego.com/en-US/EV3-Ultrasonic-Sensor-45504 + // Measures distances between one and 250 cm (one to 100 in.) + // Accurate to +/- 1 cm (+/- .394 in.) + let value = this._sensors.distance > 100 ? 100 : this._sensors.distance; + value = value < 0 ? 0 : value; + + return Math.round(value); + } + + get brightness () { + if (!this.connected) return 0; + + return this._sensors.brightness; + } + + getMotorPosition (port) { + if (!this.connected) return; + + return this._motorPositions[port]; + } + + isButtonPressed (/* args */) { + if (!this.connected) return; + + return this._sensors.button; + } + + beep () { + if (!this.connected) return; + + this._bt.sendMessage({ + message: 'DwAAAIAAAJQBgQKC6AOC6AM=', + encoding: 'base64' + }); + } + + motorTurnClockwise (port, time) { + if (!this.connected) return; + + // Build up motor command + const cmd = this._applyPrefix(0, this._motorCommand( + BTCommand.TIMESPEED, + port, + time, + this.speed, + BTCommand.LONGRAMP + )); + + // Send message + this._bt.sendMessage({ + message: Base64Util.arrayBufferToBase64(cmd), + encoding: 'base64' + }); + + // Yield for time + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, time); + }); + } + + motorTurnCounterClockwise (port, time) { + if (!this.connected) return; + + // Build up motor command + const cmd = this._applyPrefix(0, this._motorCommand( + BTCommand.TIMESPEED, + port, + time, + this.speed * -1, + BTCommand.LONGRAMP + )); + + // Send message + this._bt.sendMessage({ + message: Base64Util.arrayBufferToBase64(cmd), + encoding: 'base64' + }); + + // Yield for time + return new Promise(resolve => { + setTimeout(() => { + resolve(); + }, time); + }); + } + + motorRotate (port, degrees) { + if (!this.connected) return; + + // TODO: Build up motor command + log.info(`motor rotate port: ${port} and degrees: ${degrees}`); + } + + motorSetPosition (port, degrees) { + if (!this.connected) return; + + // TODO: Build up motor command + log.info(`motor set position port: ${port} and degrees: ${degrees}`); + } + + motorSetPower (port, power) { + if (!this.connected) return; + + // TODO: Build up motor command + log.info(`motor set power port: ${port} and degrees: ${power}`); + } + + _applyPrefix (n, cmd) { + const len = cmd.length + 5; + return [].concat( + len & 0xFF, + (len >> 8) & 0xFF, + 0x1, + 0x0, + 0x0, + n, + 0x0, + cmd + ); + } + + /** + * Generate a motor command in EV3 byte array format (CMD, LAYER, PORT, + * SPEED, RAMP UP, RUN, RAMP DOWN, BREAKING TYPE) + * @param {string} command Motor command primitive (i.e. "prefix") + * @param {string} port Port to address + * @param {number} n Value to be passed to motor command + * @param {number} speed Speed value + * @param {number} ramp Ramp value + * @return {array} Byte array + */ + _motorCommand (command, port, n, speed, ramp) { + /** + * Generate run values for a given input. + * @param {number} run Run input + * @return {array} Run values (byte array) + */ + const getRunValues = function (run) { + // If run duration is less than max 16-bit integer + if (run < 0x7fff) { + return [ + BTCommand.NUM16, + run & 0xff, + (run >> 8) & 0xff + ]; + } + + // Run forever + return [ + BTCommand.NUM32, + run & 0xff, + (run >> 8) & 0xff, + (run >> 16) & 0xff, + (run >> 24) & 0xff + ]; + }; + + // If speed is less than zero, make it positive and multiply the input + // value by -1 + if (speed < 0) { + speed = -1 * speed; + n = -1 * n; + } + + // If the input value is less than 0 + const dir = (n < 0) ? 0x100 - speed : speed; // step negative or possitive + n = Math.abs(n); + + // Setup motor run duration and ramping behavior + let rampup = ramp; + let rampdown = ramp; + let run = n - (ramp * 2); + if (run < 0) { + rampup = Math.floor(n / 2); + run = 0; + rampdown = n - rampup; + } + + // Generate motor command + const runcmd = getRunValues(run); + return [ + command, + BTCommand.LAYER, + port, + BTCommand.NUM8, + dir & 0xff, + BTCommand.NUM8, + rampup + ].concat(runcmd.concat([ + BTCommand.NUM8, + rampdown, + BTCommand.BRAKE + ])); + } + + _onSessionConnect () { + this.connected = true; + + // GET EV3 SENSOR LIST + /* + 0B [ 11] + 00 [ 0] + 01 [ 1] + 00 [ 0] + 00 [ 0] + 21 [ 33] + 00 [ 0] + 98 [152] opInput_Device_List + 81 [129] LENGTH + 21 [ 33] ARRAY + 60 [ 96] CHANGED + E1 [225] size of global var? + 20 [ 32] global var index + */ + this._bt.sendMessage({ + message: 'CwABAAAhAJiBIWDhIA==', // [11, 0, 1, 0, 0, 33, 0, 152, 129, 33, 96, 225, 32] + encoding: 'base64' + }).then( + x => { + log.info(`get device list resolved: ${x}`); + }, + e => { + log.info(`get device list rejected: ${e}`); + } + ); + } + + _getSessionData () { + if (!this.connected) { + window.clearInterval(this._pollingIntervalID); + return; + } + + // GET EV3 DISTANCE PORT 0 + /* + 99 [153] input device + 1D [ 29] ready si + 00 [ 0] layer (this brick) + 00 [ 0] sensor port 0 + 00 [ 0] do not change type + 01 [ 1] mode 1 = EV3-Ultrasonic-Inch + 01 [ 1] one data set + 60 [ 96] global var index + */ + /* + this._bt.sendMessage({ + message: 'DQAAAAAEAJkdAAAAAQFg', // [13, 0, 0, 0, 0, 4, 0, 153, 29, 0, 0, 0, 1, 1, 96] + encoding: 'base64' + }); + */ + + // GET EV3 BRIGHTNESS PORT 1 + /* + 0x99 [153] input device + 0x1D [ 29] ready si + 0x00 [ 0] layer (this brick) + 0x01 [ 1] sensor port 1 + 0x00 [ 0] do not change type + 0x01 [ 1] mode 1 = EV3-Color-Ambient + 0x01 [ 1] one data set + 0x60 [ 96] global var index + */ + /* + this._bt.sendMessage({ + message: 'DQAAAAAEAJkdAAEAAQFg', // [13, 0, 0, 0, 0, 4, 0, 153, 29, 0, 1, 0, 1, 1, 96] + encoding: 'base64' + }); + */ + + + // COMPOUND COMMAND FOR READING sensors0x27 command size + // 0x?? [ ] command size + // 0x00 [ 0] command size + // 0x01 [ 1] message counter + // 0x00 [ 0] message counter + // 0x00 [ 0] command type + // 0x?? [ ] result payload size of global/local vars + // 0x00 [ 0] result payload size of global/local vars + const compoundCommand = []; + compoundCommand[0] = 0; // calculate length later + compoundCommand[1] = 0; // command size + compoundCommand[2] = 1; // message counter // TODO: ????? + compoundCommand[3] = 0; // message counter + compoundCommand[4] = 0; // command type: direct command + compoundCommand[5] = 0; // global/local vars + compoundCommand[6] = 0; // global/local vars + let compoundCommandIndex = 7; + let sensorCount = -1; + + // Read from available sensors + for (let i = 0; i < this._sensorPorts.length; i++) { + if (this._sensorPorts[i] !== 'none') { + sensorCount++; + // make up sensor command array + // 0x9D [ 157] op: get sensor value + // 0x00 [ 0] layer + // 0x02 [ ] port + // 0x00 [ 0] do not change type + // 0x00 [ ] mode + // 0xE1 [ 225] + // 0x0C [ ] global index + compoundCommand[compoundCommandIndex + 0] = 157; + compoundCommand[compoundCommandIndex + 1] = 0; + compoundCommand[compoundCommandIndex + 2] = i; + compoundCommand[compoundCommandIndex + 3] = 0; + compoundCommand[compoundCommandIndex + 4] = EV_DEVICE_MODES[this._sensorPorts[i]]; + compoundCommand[compoundCommandIndex + 5] = 225; + compoundCommand[compoundCommandIndex + 6] = sensorCount * 4; + compoundCommandIndex += 7; + } + } + // Read from available motors + // let motorCount = 0; + for (let i = 0; i < this._motorPorts.length; i++) { + if (this._motorPorts[i] !== 'none') { + sensorCount++; + // make up sensor command array + // 0xB3 [ 179] op: get motor position value + // 0x00 [ 0] layer + // 0x02 [ ] output bit fields ?? + // 0xE1 [ 225] + // 0x?? [ 0] global index + compoundCommand[compoundCommandIndex + 0] = 179; + compoundCommand[compoundCommandIndex + 1] = 0; + compoundCommand[compoundCommandIndex + 2] = i; + compoundCommand[compoundCommandIndex + 3] = 225; + compoundCommand[compoundCommandIndex + 4] = sensorCount * 4; + compoundCommandIndex += 5; + // motorCount++; + } + } + + + // Calculate compound command length + compoundCommand[0] = compoundCommand.length - 2; + // Calculate global var payload length needed + compoundCommand[5] = (sensorCount + 1) * 4; + // console.log('compound command to send: ' + compoundCommand); + this._bt.sendMessage({ + message: Base64Util.uint8ArrayToBase64(compoundCommand), + encoding: 'base64' + }); + + // TODO: Read from available motor ports + } + + _onSessionMessage (params) { + const message = params.message; + const array = Base64Util.base64ToUint8Array(message); + + if (this._sensorPorts.length === 0) { + // SENSOR LIST + // JAABAAIefn5+fn5+fn5+fn5+fn5+Bwd+fn5+fn5+fn5+fn5+fgA= + log.info(`device array: ${array}`); + this._sensorPorts[0] = EV_DEVICE_TYPES[array[5]]; + this._sensorPorts[1] = EV_DEVICE_TYPES[array[6]]; + this._sensorPorts[2] = EV_DEVICE_TYPES[array[7]]; + this._sensorPorts[3] = EV_DEVICE_TYPES[array[8]]; + this._motorPorts[0] = EV_DEVICE_TYPES[array[21]]; + this._motorPorts[1] = EV_DEVICE_TYPES[array[22]]; + this._motorPorts[2] = EV_DEVICE_TYPES[array[23]]; + this._motorPorts[3] = EV_DEVICE_TYPES[array[24]]; + log.info(`sensor ports: ${this._sensorPorts}`); + log.info(`motor ports: ${this._motorPorts}`); + + // Now ready to read from assigned _sensors + // Start reading sensor data + // TODO: window? + this._pollingIntervalID = window.setInterval(this._getSessionData.bind(this), 100); + } else { + // log.info(`received compound command result: ${array}`); + let offset = 5; + for (let i = 0; i < this._sensorPorts.length; i++) { + if (this._sensorPorts[i] !== 'none') { + const value = this._array2float([ + array[offset], + array[offset + 1], + array[offset + 2], + array[offset + 3] + ]); + log.info(`sensor at port ${i} ${this._sensorPorts[i]} value: ${value}`); + this._sensors[EV_DEVICE_LABELS[this._sensorPorts[i]]] = value; + offset += 4; + } + } + for (let i = 0; i < this._motorPorts.length; i++) { + if (this._motorPorts[i] !== 'none') { + let value = this._tachoValue([ + array[offset], + array[offset + 1], + array[offset + 2], + array[offset + 3] + ]); + if (value > 0x7fffffff) { + value = value - 0x100000000; + } + log.info(`motor at port ${i} ${this._motorPorts[i]} value: ${value}`); + this._motorPositions[MOTOR_PORTS[i].value] = value; + offset += 4; + } + } + // const sensorValue = this._array2float([array[5], array[6], array[7], array[8]]); + // log.info('receiving port array?: ' + array); + // log.info('receiving port sensorValue?: ' + sensorValue); + // this._sensors.distance = distance; + } + + } + + _tachoValue (list) { + const value = list[0] + (list[1] * 256) + (list[2] * 256 * 256) + (list[3] * 256 * 256 * 256); + return value; + } + + // TODO: put elsewhere + _array2float (list) { + const buffer = new Uint8Array(list).buffer; + const view = new DataView(buffer); + return view.getFloat32(0, true); + } + +} + +class Scratch3Ev3Blocks { + + /** + * The ID of the extension. + * @return {string} the id + */ + static get EXTENSION_ID () { + return 'ev3'; + } + + /** + * Creates a new instance of the EV3 extension. + * @param {object} runtime VM runtime + * @constructor + */ + constructor (runtime) { + /** + * The Scratch 3.0 runtime. + * @type {Runtime} + */ + this.runtime = runtime; + + // Create a new EV3 device instance + this._device = new EV3(this.runtime, Scratch3Ev3Blocks.EXTENSION_ID); + } + + /** + * Define the EV3 extension. + * @return {object} Extension description. + */ + getInfo () { + return { + id: Scratch3Ev3Blocks.EXTENSION_ID, + name: 'LEGO MINDSTORMS EV3', + blockIconURI: blockIconURI, + showStatusButton: true, + blocks: [ + { + opcode: 'motorTurnClockwise', + text: 'motor [PORT] turn clockwise for [TIME] seconds', + blockType: BlockType.COMMAND, + arguments: { + PORT: { + type: ArgumentType.STRING, + menu: 'motorPorts', + defaultValue: MOTOR_PORTS[0].value + }, + TIME: { + type: ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'motorTurnCounterClockwise', + text: 'motor [PORT] turn counter for [TIME] seconds', + blockType: BlockType.COMMAND, + arguments: { + PORT: { + type: ArgumentType.STRING, + menu: 'motorPorts', + defaultValue: MOTOR_PORTS[0].value + }, + TIME: { + type: ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'motorRotate', + text: 'motor [PORT] rotate [DEGREES] degrees', + blockType: BlockType.COMMAND, + arguments: { + PORT: { + type: ArgumentType.STRING, + menu: 'motorPorts', + defaultValue: MOTOR_PORTS[0].value + }, + DEGREES: { + type: ArgumentType.NUMBER, + defaultValue: 90 + } + } + }, + { + opcode: 'motorSetPosition', + text: 'motor [PORT] set position [DEGREES] degrees', + blockType: BlockType.COMMAND, + arguments: { + PORT: { + type: ArgumentType.STRING, + menu: 'motorPorts', + defaultValue: MOTOR_PORTS[0].value + }, + DEGREES: { + type: ArgumentType.NUMBER, + defaultValue: 90 + } + } + }, + { + opcode: 'motorSetPower', + text: 'motor [PORT] set power [POWER] %', + blockType: BlockType.COMMAND, + arguments: { + PORT: { + type: ArgumentType.STRING, + menu: 'motorPorts', + defaultValue: MOTOR_PORTS[0].value + }, + POWER: { + type: ArgumentType.NUMBER, + defaultValue: 50 + } + } + }, + { + opcode: 'getMotorPosition', + text: 'motor [PORT] position', + blockType: BlockType.REPORTER, + arguments: { + PORT: { + type: ArgumentType.STRING, + menu: 'motorPorts', + defaultValue: MOTOR_PORTS[0].value + } + } + }, + { + opcode: 'whenButtonPressed', + text: 'when button [PORT] pressed', + blockType: BlockType.HAT, + arguments: { + PORT: { + type: ArgumentType.STRING, + menu: 'sensorPorts', + defaultValue: SENSOR_PORTS[0].value + } + } + }, + { + opcode: 'whenDistanceLessThan', + text: 'when distance < [DISTANCE]', + blockType: BlockType.HAT, + arguments: { + DISTANCE: { + type: ArgumentType.NUMBER, + defaultValue: 5 + } + } + }, + { + opcode: 'whenBrightnessLessThan', + text: 'when brightness < [DISTANCE]', + blockType: BlockType.HAT, + arguments: { + DISTANCE: { + type: ArgumentType.NUMBER, + defaultValue: 50 + } + } + }, + { + opcode: 'buttonPressed', + text: 'button [PORT] pressed?', + blockType: BlockType.BOOLEAN, + arguments: { + PORT: { + type: ArgumentType.STRING, + menu: 'sensorPorts', + defaultValue: SENSOR_PORTS[0].value + } + } + }, + { + opcode: 'getDistance', + text: 'distance', + blockType: BlockType.REPORTER + }, + { + opcode: 'getBrightness', + text: 'brightness', + blockType: BlockType.REPORTER + }, + { + opcode: 'beep', + text: 'beep', + blockType: BlockType.COMMAND + } + ], + menus: { + motorPorts: this._buildMenu(MOTOR_PORTS), + sensorPorts: this._buildMenu(SENSOR_PORTS) + } + }; + } + + /** + * Create data for a menu in scratch-blocks format, consisting of an array of objects with text and + * value properties. The text is a translated string, and the value is one-indexed. + * @param {object[]} info - An array of info objects each having a name property. + * @return {array} - An array of objects with text and value properties. + * @private + */ + _buildMenu (info) { + return info.map((entry, index) => { + const obj = {}; + obj.text = entry.name; + obj.value = String(index + 1); + return obj; + }); + } + + motorTurnClockwise (args) { + const port = Cast.toNumber(args.PORT); + const time = Cast.toNumber(args.TIME) * 1000; + + return this._device.motorTurnClockwise(port, time); + } + + motorTurnCounterClockwise (args) { + const port = Cast.toNumber(args.PORT); + const time = Cast.toNumber(args.TIME) * 1000; + + return this._device.motorTurnCounterClockwise(port, time); + } + + motorRotate (args) { + const port = Cast.toNumber(args.PORT); + const degrees = Cast.toNumber(args.DEGREES); + + this._device.motorRotate(port, degrees); + return; + } + + motorSetPosition (args) { + const port = Cast.toNumber(args.PORT); + const degrees = Cast.toNumber(args.DEGREES); + + this._device.motorSetPosition(port, degrees); + return; + } + + motorSetPower (args) { + const port = Cast.toNumber(args.PORT); + const power = Cast.toNumber(args.POWER); + + this._device.motorSetPower(port, power); + return; + } + + getMotorPosition (args) { + const port = Cast.toNumber(args.PORT); + + return this._device.getMotorPosition(port); + } + + whenButtonPressed (args) { + const port = Cast.toNumber(args.PORT); + + return this._device.isButtonPressed(port); + } + + whenDistanceLessThan (args) { + const distance = Cast.toNumber(args.DISTANCE); + + return this._device.distance < distance; + } + + whenBrightnessLessThan (args) { + const brightness = Cast.toNumber(args.DISTANCE); + + return this._device.brightness < brightness; + } + + buttonPressed (args) { + const port = Cast.toNumber(args.PORT); + + return this._device.isButtonPressed(port); + } + + getDistance () { + return this._device.distance; + } + + getBrightness () { + return this._device.brightness; + } + + beep () { + return this._device.beep(); + } +} + +module.exports = Scratch3Ev3Blocks; diff --git a/src/extensions/scratch3_microbit/index.js b/src/extensions/scratch3_microbit/index.js index a121b43dc..e9ad6bab7 100644 --- a/src/extensions/scratch3_microbit/index.js +++ b/src/extensions/scratch3_microbit/index.js @@ -1,7 +1,7 @@ const ArgumentType = require('../../extension-support/argument-type'); const BlockType = require('../../extension-support/block-type'); const log = require('../../util/log'); -const ScratchBLE = require('../../io/scratchBLE'); +const BLESession = require('../../io/bleSession'); const Base64Util = require('../../util/base64-util'); /** @@ -50,8 +50,9 @@ class MicroBit { /** * Construct a MicroBit communication object. * @param {Runtime} runtime - the Scratch 3.0 runtime + * @param {string} extensionId - the id of the extension */ - constructor (runtime) { + constructor (runtime, extensionId) { /** * The Scratch 3.0 runtime used to trigger the green flag button. @@ -61,11 +62,12 @@ class MicroBit { this._runtime = runtime; /** - * The ScratchBLE connection session for reading/writing device data. - * @type {ScratchBLE} + * The BluetoothLowEnergy connection session for reading/writing device data. + * @type {BLESession} * @private */ - this._ble = new ScratchBLE(); + this._ble = null; + this._runtime.registerExtensionDevice(extensionId, this); /** * The most recently received value for each sensor. @@ -102,33 +104,60 @@ class MicroBit { timeout: false } }; + } - // TODO: Temporary until the gui requests a device connection - this._ble.waitForSocket() - // TODO: remove pinging once no longer needed - .then(() => this._ble.sendRemoteRequest('pingMe')) - .then(() => this._onBLEReady()); + // TODO: keep here? + /** + * Called by the runtime when user wants to scan for a device. + */ + startDeviceScan () { + log.info('making a new BLE session'); + this._ble = new BLESession(this._runtime, { + filters: [ + {services: [BLEUUID.service]} + ] + }, this._onSessionConnect.bind(this)); + } - // TODO: Add ScratchBLE 'disconnect' handling + // TODO: keep here? + /** + * Called by the runtime when user wants to connect to a certain device. + * @param {number} id - the id of the device to connect to. + */ + connectDevice (id) { + this._ble.connectDevice(id); + } + disconnectSession () { + this._ble.disconnectSession(); + } + + getPeripheralIsConnected () { + let connected = false; + if (this._ble) { + connected = this._ble.getPeripheralIsConnected(); + } + return connected; } /** * @param {string} text - the text to display. + * @return {Promise} - a Promise that resolves when writing to device. */ displayText (text) { const output = new Uint8Array(text.length); for (let i = 0; i < text.length; i++) { output[i] = text.charCodeAt(i); } - this._writeBLE(BLECommand.CMD_DISPLAY_TEXT, output); + return this._writeSessionData(BLECommand.CMD_DISPLAY_TEXT, output); } /** * @param {Uint8Array} matrix - the matrix to display. + * @return {Promise} - a Promise that resolves when writing to device. */ displayMatrix (matrix) { - this._writeBLE(BLECommand.CMD_DISPLAY_LED, matrix); + return this._writeSessionData(BLECommand.CMD_DISPLAY_LED, matrix); } /** @@ -181,38 +210,20 @@ class MicroBit { return this._sensors.touchPins[pin]; } - /** - * Requests connection to a device when BLE session is ready. - */ - _onBLEReady () { - this._ble.requestDevice({ - filters: [ - {services: [BLEUUID.service]} - ] - }, this._onBLEConnect.bind(this), this._onBLEError); - } - /** * Starts reading data from device after BLE has connected to it. */ - _onBLEConnect () { - const callback = this._processBLEData.bind(this); + _onSessionConnect () { + const callback = this._processSessionData.bind(this); this._ble.read(BLEUUID.service, BLEUUID.rxChar, true, callback); } - /** - * @param {string} e - Error from BLE session. - */ - _onBLEError (e) { - log.error(`BLE error: ${e}`); - } - /** * Process the sensor data from the incoming BLE characteristic. * @param {object} base64 - the incoming BLE data. * @private */ - _processBLEData (base64) { + _processSessionData (base64) { const data = Base64Util.base64ToUint8Array(base64); this._sensors.tiltX = data[1] | (data[0] << 8); @@ -234,16 +245,17 @@ class MicroBit { * Write a message to the device BLE session. * @param {number} command - the BLE command hex. * @param {Uint8Array} message - the message to write. + * @return {Promise} - a Promise that resolves when writing to device. * @private */ - _writeBLE (command, message) { + _writeSessionData (command, message) { const output = new Uint8Array(message.length + 1); output[0] = command; // attach command to beginning of message for (let i = 0; i < message.length; i++) { output[i + 1] = message[i]; } - const b64enc = Base64Util.uint8ArrayToBase64(output); - this._ble.write(BLEUUID.service, BLEUUID.txChar, b64enc, 'base64'); + const data = Base64Util.uint8ArrayToBase64(output); + return this._ble.write(BLEUUID.service, BLEUUID.txChar, data, 'base64'); } } @@ -318,7 +330,7 @@ class Scratch3MicroBitBlocks { this.runtime = runtime; // Create a new MicroBit device instance - this._device = new MicroBit(this.runtime); + this._device = new MicroBit(this.runtime, Scratch3MicroBitBlocks.EXTENSION_ID); } /** @@ -516,17 +528,18 @@ class Scratch3MicroBitBlocks { /** * Display text on the 5x5 LED matrix. * @param {object} args - the block's arguments. + * @return {Promise} - a Promise that resolves when writing to device. * Note the limit is 19 characters */ displayText (args) { const text = String(args.TEXT).substring(0, 19); - this._device.displayText(text); - return; + return this._device.displayText(text); } /** * Display a predefined symbol on the 5x5 LED matrix. * @param {object} args - the block's arguments. + * @return {Promise} - a Promise that resolves when writing to device. */ displaySymbol (args) { const hex = symbols2hex[args.SYMBOL]; @@ -536,8 +549,7 @@ class Scratch3MicroBitBlocks { this._device.ledMatrixState[2] = (hex >> 10) & 0x1F; this._device.ledMatrixState[3] = (hex >> 5) & 0x1F; this._device.ledMatrixState[4] = hex & 0x1F; - this._device.displayMatrix(this._device.ledMatrixState); - return; + return this._device.displayMatrix(this._device.ledMatrixState); } /** diff --git a/src/io/bleSession.js b/src/io/bleSession.js new file mode 100644 index 000000000..1596c721a --- /dev/null +++ b/src/io/bleSession.js @@ -0,0 +1,146 @@ +const JSONRPCWebSocket = require('../util/jsonrpc-web-socket'); +const log = require('../util/log'); +const ScratchLinkWebSocket = 'ws://localhost:20110/scratch/ble'; + +class BLESession extends JSONRPCWebSocket { + + /** + * A BLE device session object. It handles connecting, over web sockets, to + * BLE devices, and reading and writing data to them. + * @param {Runtime} runtime - the Runtime for sending/receiving GUI update events. + * @param {object} deviceOptions - the list of options for device discovery. + * @param {object} connectCallback - a callback for connection. + */ + constructor (runtime, deviceOptions, connectCallback) { + const ws = new WebSocket(ScratchLinkWebSocket); + super(ws); + + this._ws = ws; + this._ws.onopen = this.requestDevice.bind(this); // only call request device after socket opens + this._ws.onerror = this._sendError.bind(this, 'ws onerror'); + this._ws.onclose = this._sendError.bind(this, 'ws onclose'); + + this._availablePeripherals = {}; + this._connectCallback = connectCallback; + this._characteristicDidChangeCallback = null; + this._deviceOptions = deviceOptions; + this._runtime = runtime; + + this._connected = false; + } + + /** + * Request connection to the device. + * If the web socket is not yet open, request when the socket promise resolves. + */ + requestDevice () { + if (this._ws.readyState === 1) { // is this needed since it's only called on ws.onopen? + // TODO: start a 'discover' timeout + this.sendRemoteRequest('discover', this._deviceOptions) + .catch(e => this._sendError(e)); // never reached? + } + // TODO: else? + } + + /** + * Try connecting to the input peripheral id, and then call the connect + * callback if connection is successful. + * @param {number} id - the id of the peripheral to connect to + */ + connectDevice (id) { + this.sendRemoteRequest('connect', {peripheralId: id}) + .then(() => { + log.info('should have connected'); + this._runtime.emit(this._runtime.constructor.PERIPHERAL_CONNECTED); + this._connected = true; + this._connectCallback(); + }) + .catch(e => { + this._sendError(e); + }); + } + + /** + * Close the websocket. + */ + disconnectSession () { + this._ws.close(); + this._connected = false; + } + + /** + * @return {bool} whether the peripheral is connected. + */ + getPeripheralIsConnected () { + return this._connected; + } + + /** + * Handle a received call from the socket. + * @param {string} method - a received method label. + * @param {object} params - a received list of parameters. + * @return {object} - optional return value. + */ + didReceiveCall (method, params) { + switch (method) { + case 'didDiscoverPeripheral': + this._availablePeripherals[params.peripheralId] = params; + this._runtime.emit( + this._runtime.constructor.PERIPHERAL_LIST_UPDATE, + this._availablePeripherals + ); + // TODO: cancel a discover timeout if one is active + break; + case 'characteristicDidChange': + this._characteristicDidChangeCallback(params.message); + break; + case 'ping': + return 42; + } + } + + /** + * Start reading from the specified ble service. + * @param {number} serviceId - the ble service to read. + * @param {number} characteristicId - the ble characteristic to read. + * @param {boolean} optStartNotifications - whether to start receiving characteristic change notifications. + * @param {object} onCharacteristicChanged - callback for characteristic change notifications. + * @return {Promise} - a promise from the remote read request. + */ + read (serviceId, characteristicId, optStartNotifications = false, onCharacteristicChanged) { + const params = { + serviceId, + characteristicId + }; + if (optStartNotifications) { + params.startNotifications = true; + } + this._characteristicDidChangeCallback = onCharacteristicChanged; + return this.sendRemoteRequest('read', params); + // TODO: handle error here + } + + /** + * Write data to the specified ble service. + * @param {number} serviceId - the ble service to write. + * @param {number} characteristicId - the ble characteristic to write. + * @param {string} message - the message to send. + * @param {string} encoding - the message encoding type. + * @return {Promise} - a promise from the remote send request. + */ + write (serviceId, characteristicId, message, encoding = null) { + const params = {serviceId, characteristicId, message}; + if (encoding) { + params.encoding = encoding; + } + return this.sendRemoteRequest('write', params); + } + + _sendError (e) { + this._connected = false; + log.error(`BLESession error: ${JSON.stringify(e)}`); + this._runtime.emit(this._runtime.constructor.PERIPHERAL_ERROR); + } +} + +module.exports = BLESession; diff --git a/src/io/btSession.js b/src/io/btSession.js new file mode 100644 index 000000000..bf847296e --- /dev/null +++ b/src/io/btSession.js @@ -0,0 +1,116 @@ +const JSONRPCWebSocket = require('../util/jsonrpc-web-socket'); +const log = require('../util/log'); +const ScratchLinkWebSocket = 'ws://localhost:20110/scratch/bt'; + +class BTSession extends JSONRPCWebSocket { + + /** + * A BT device session object. It handles connecting, over web sockets, to + * BT devices, and reading and writing data to them. + * @param {Runtime} runtime - the Runtime for sending/receiving GUI update events. + * @param {object} deviceOptions - the list of options for device discovery. + * @param {object} connectCallback - a callback for connection. + * @param {object} messageCallback - a callback for message sending. + */ + constructor (runtime, deviceOptions, connectCallback, messageCallback) { + const ws = new WebSocket(ScratchLinkWebSocket); + super(ws); + + this._ws = ws; + this._ws.onopen = this.requestDevice.bind(this); // only call request device after socket opens + this._ws.onerror = this._sendError.bind(this, 'ws onerror'); + this._ws.onclose = this._sendError.bind(this, 'ws onclose'); + + this._availablePeripherals = {}; + this._connectCallback = connectCallback; + this._characteristicDidChangeCallback = null; + this._deviceOptions = deviceOptions; + this._messageCallback = messageCallback; + this._runtime = runtime; + + this._connected = false; + } + + /** + * Request connection to the device. + * If the web socket is not yet open, request when the socket promise resolves. + */ + requestDevice () { + if (this._ws.readyState === 1) { // is this needed since it's only called on ws.onopen? + // TODO: start a 'discover' timeout + this.sendRemoteRequest('discover', this._deviceOptions) + .catch(e => this._sendError(e)); // never reached? + } + // TODO: else? + } + + /** + * Try connecting to the input peripheral id, and then call the connect + * callback if connection is successful. + * @param {number} id - the id of the peripheral to connect to + */ + connectDevice (id) { + this.sendRemoteRequest('connect', {peripheralId: id}) + .then(() => { + log.info('should have connected'); + this._runtime.emit(this._runtime.constructor.PERIPHERAL_CONNECTED); + this._connected = true; + this._connectCallback(); + }) + .catch(e => { + this._sendError(e); + }); + } + + /** + * Close the websocket. + */ + disconnectSession () { + this._ws.close(); + this._connected = false; + } + + /** + * @return {bool} whether the peripheral is connected. + */ + getPeripheralIsConnected () { + return this._connected; + } + + + sendMessage (options) { + return this.sendRemoteRequest('send', options); + } + + /** + * Handle a received call from the socket. + * @param {string} method - a received method label. + * @param {object} params - a received list of parameters. + * @return {object} - optional return value. + */ + didReceiveCall (method, params) { + // TODO: Add peripheral 'undiscover' handling + switch (method) { + case 'didDiscoverPeripheral': + this._availablePeripherals[params.peripheralId] = params; + this._runtime.emit( + this._runtime.constructor.PERIPHERAL_LIST_UPDATE, + this._availablePeripherals + ); + // TODO: cancel a discover timeout if one is active + break; + case 'didReceiveMessage': + this._messageCallback(params); // TODO: refine? + break; + default: + return 'nah'; + } + } + + _sendError (e) { + log.error(`BTSession error: ${JSON.stringify(e)}`); + this._runtime.emit(this._runtime.constructor.PERIPHERAL_ERROR); + } +} + +module.exports = BTSession; diff --git a/src/io/peripheralChooser.js b/src/io/peripheralChooser.js deleted file mode 100644 index 53d0a50bc..000000000 --- a/src/io/peripheralChooser.js +++ /dev/null @@ -1,38 +0,0 @@ -class PeripheralChooser { - - get chosenPeripheralId () { - return this._chosenPeripheralId; - } - - constructor () { - this._availablePeripherals = []; // TODO for use in gui? - this._chosenPeripheralId = null; - } - - /** - * Launches a GUI menu to choose a peripheral. - * @return {Promise} - chosen peripheral promise. - */ - choosePeripheral () { - return new Promise((resolve, reject) => { - // TODO: Temporary: should launch gui instead. - this._tempPeripheralChosenCallback = resolve; - this._tempPeripheralChosenReject = reject; - }); - } - - /** - * Adds the peripheral ID to list of available peripherals. - * @param {number} peripheralId - the id to add. - */ - addPeripheral (peripheralId) { - this._availablePeripherals.push(peripheralId); - - // TODO: Temporary: calls chosen callback on whatever peripherals are added. - this._chosenPeripheralId = this._availablePeripherals[0]; - this._tempPeripheralChosenCallback(this._chosenPeripheralId); - } - -} - -module.exports = PeripheralChooser; diff --git a/src/io/scratchBLE.js b/src/io/scratchBLE.js deleted file mode 100644 index 36bd9a7da..000000000 --- a/src/io/scratchBLE.js +++ /dev/null @@ -1,104 +0,0 @@ -const JSONRPCWebSocket = require('../util/jsonrpc-web-socket'); -const PeripheralChooser = require('./peripheralChooser'); - -const ScratchLinkWebSocket = 'ws://localhost:20110/scratch/ble'; - -class ScratchBLE extends JSONRPCWebSocket { - constructor () { - const ws = new WebSocket(ScratchLinkWebSocket); - - super(ws); - - this._ws = ws; - this.peripheralChooser = new PeripheralChooser(); // TODO: finalize gui connection - this._characteristicDidChange = null; - } - - /** - * Returns a promise for when the web socket opens. - * @return {Promise} - a promise when BLE socket is open. - */ - waitForSocket () { - return new Promise((resolve, reject) => { - this._ws.onopen = resolve; - this._ws.onerror = reject; - }); - } - - /** - * Request a device with the device options and optional gui options. - * @param {object} deviceOptions - list of device guiOptions. - * @param {object} onConnect - on connect callback. - * @param {object} onError - on error callbackk. - */ - requestDevice (deviceOptions, onConnect, onError) { - this.sendRemoteRequest('discover', deviceOptions) - .then(() => this.peripheralChooser.choosePeripheral()) // TODO: use gui options? - .then(id => this.sendRemoteRequest( - 'connect', - {peripheralId: id} - )) - .then( - onConnect, - onError - ); - } - - /** - * Handle a received call from the socket. - * @param {string} method - a received method label. - * @param {object} params - a received list of parameters. - * @return {object} - optional return value. - */ - didReceiveCall (method, params) { - // TODO: Add peripheral 'undiscover' handling - switch (method) { - case 'didDiscoverPeripheral': - this.peripheralChooser.addPeripheral(params.peripheralId); - break; - case 'characteristicDidChange': - this._characteristicDidChange(params.message); - break; - case 'ping': - return 42; - } - } - - /** - * Start reading from the specified ble service. - * @param {number} serviceId - the ble service to read. - * @param {number} characteristicId - the ble characteristic to read. - * @param {boolean} optStartNotifications - whether to start receiving characteristic change notifications. - * @param {object} onCharacteristicChanged - callback for characteristic change notifications. - * @return {Promise} - a promise from the remote read request. - */ - read (serviceId, characteristicId, optStartNotifications = false, onCharacteristicChanged) { - const params = { - serviceId, - characteristicId - }; - if (optStartNotifications) { - params.startNotifications = true; - } - this._characteristicDidChange = onCharacteristicChanged; - return this.sendRemoteRequest('read', params); - } - - /** - * Write data to the specified ble service. - * @param {number} serviceId - the ble service to write. - * @param {number} characteristicId - the ble characteristic to write. - * @param {string} message - the message to send. - * @param {string} encoding - the message encoding type. - * @return {Promise} - a promise from the remote send request. - */ - write (serviceId, characteristicId, message, encoding = null) { - const params = {serviceId, characteristicId, message}; - if (encoding) { - params.encoding = encoding; - } - return this.sendRemoteRequest('write', params); - } -} - -module.exports = ScratchBLE; diff --git a/src/io/scratchBT.js b/src/io/scratchBT.js deleted file mode 100644 index 2890b525d..000000000 --- a/src/io/scratchBT.js +++ /dev/null @@ -1,37 +0,0 @@ -const JSONRPCWebSocket = require('../util/jsonrpc'); - -const ScratchLinkWebSocket = 'ws://localhost:20110/scratch/bt'; - -class ScratchBT extends JSONRPCWebSocket { - constructor () { - super(new WebSocket(ScratchLinkWebSocket)); - } - - requestDevice (options) { - return this.sendRemoteRequest('discover', options); - } - - connectDevice (options) { - return this.sendRemoteRequest('connect', options); - } - - sendMessage (options) { - return this.sendRemoteRequest('send', options); - } - - didReceiveCall (method /* , params */) { - // TODO: Add peripheral 'undiscover' handling - switch (method) { - case 'didDiscoverPeripheral': - // TODO: do something on peripheral discovered - break; - case 'didReceiveMessage': - // TODO: do something on received message - break; - default: - return 'nah'; - } - } -} - -module.exports = ScratchBT; diff --git a/src/util/base64-util.js b/src/util/base64-util.js index 60680e851..c2bd7f743 100644 --- a/src/util/base64-util.js +++ b/src/util/base64-util.js @@ -28,6 +28,21 @@ class Base64Util { return base64; } + /** + * Convert an array buffer to a base64 encoded string. + * @param {array} buffer - an array buffer to convert. + * @return {string} - the base64 encoded string. + */ + static arrayBufferToBase64 (buffer) { + let binary = ''; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[ i ]); + } + return btoa(binary); + } + } module.exports = Base64Util; diff --git a/src/util/jsonrpc-web-socket.js b/src/util/jsonrpc-web-socket.js index 22310d337..af5af27e1 100644 --- a/src/util/jsonrpc-web-socket.js +++ b/src/util/jsonrpc-web-socket.js @@ -1,4 +1,5 @@ const JSONRPC = require('./jsonrpc'); +// const log = require('../util/log'); class JSONRPCWebSocket extends JSONRPC { constructor (webSocket) { diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 922c2fc20..e95258fb4 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -106,6 +106,16 @@ class VirtualMachine extends EventEmitter { this.emit(Runtime.BLOCKSINFO_UPDATE, blocksInfo); }); + this.runtime.on(Runtime.PERIPHERAL_LIST_UPDATE, info => { + this.emit(Runtime.PERIPHERAL_LIST_UPDATE, info); + }); + this.runtime.on(Runtime.PERIPHERAL_CONNECTED, () => + this.emit(Runtime.PERIPHERAL_CONNECTED) + ); + this.runtime.on(Runtime.PERIPHERAL_ERROR, () => + this.emit(Runtime.PERIPHERAL_ERROR) + ); + this.extensionManager = new ExtensionManager(this.runtime); this.blockListener = this.blockListener.bind(this); @@ -195,6 +205,22 @@ class VirtualMachine extends EventEmitter { this.runtime.ioDevices.video.setProvider(videoProvider); } + startDeviceScan (extensionId) { + this.runtime.startDeviceScan(extensionId); + } + + connectToPeripheral (extensionId, peripheralId) { + this.runtime.connectToPeripheral(extensionId, peripheralId); + } + + disconnectExtensionSession (extensionId) { + this.runtime.disconnectExtensionSession(extensionId); + } + + getPeripheralIsConnected (extensionId) { + return this.runtime.getPeripheralIsConnected(extensionId); + } + /** * Load a Scratch project from a .sb, .sb2, .sb3 or json string. * @param {string | object} input A json string, object, or ArrayBuffer representing the project to load.