diff --git a/package.json b/package.json index 960d64274..07a5467b1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,8 @@ }, "dependencies": { "arraybuffer-loader": "^1.0.3", + "atob": "2.1.1", + "btoa": "1.2.1", "canvas-toBlob": "1.0.0", "decode-html": "2.0.0", "diff-match-patch": "1.0.0", diff --git a/src/extensions/scratch3_microbit/index.js b/src/extensions/scratch3_microbit/index.js index 8bf56b2a8..a121b43dc 100644 --- a/src/extensions/scratch3_microbit/index.js +++ b/src/extensions/scratch3_microbit/index.js @@ -1,6 +1,8 @@ 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 Base64Util = require('../../util/base64-util'); /** * Icon svg to be displayed at the left edge of each extension block, encoded as a data URI. @@ -17,38 +19,54 @@ const blockIconURI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNv const menuIconURI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxOS4xLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4KCjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgeG1sbnM6c29kaXBvZGk9Imh0dHA6Ly9zb2RpcG9kaS5zb3VyY2Vmb3JnZS5uZXQvRFREL3NvZGlwb2RpLTAuZHRkIgogICB4bWxuczppbmtzY2FwZT0iaHR0cDovL3d3dy5pbmtzY2FwZS5vcmcvbmFtZXNwYWNlcy9pbmtzY2FwZSIKICAgdmVyc2lvbj0iMS4xIgogICBpZD0ibWljcm9iaXQtbG9nbyIKICAgeD0iMHB4IgogICB5PSIwcHgiCiAgIHZpZXdCb3g9IjAgMCA0MC43MDUwMDIgNDAuNzA1MDAxIgogICBlbmFibGUtYmFja2dyb3VuZD0ibmV3IDAgMCAyMTMgNTUiCiAgIHhtbDpzcGFjZT0icHJlc2VydmUiCiAgIGlua3NjYXBlOnZlcnNpb249IjAuOTEgcjEzNzI1IgogICBzb2RpcG9kaTpkb2NuYW1lPSJiYmMtbWljcm9iaXQtYmxhY2sgKDEpLnN2ZyIKICAgd2lkdGg9IjQwLjcwNTAwMiIKICAgaGVpZ2h0PSI0MC43MDUwMDIiPjxtZXRhZGF0YQogICAgIGlkPSJtZXRhZGF0YTQ5Ij48cmRmOlJERj48Y2M6V29yawogICAgICAgICByZGY6YWJvdXQ9IiI+PGRjOmZvcm1hdD5pbWFnZS9zdmcreG1sPC9kYzpmb3JtYXQ+PGRjOnR5cGUKICAgICAgICAgICByZGY6cmVzb3VyY2U9Imh0dHA6Ly9wdXJsLm9yZy9kYy9kY21pdHlwZS9TdGlsbEltYWdlIiAvPjxkYzp0aXRsZT48L2RjOnRpdGxlPjwvY2M6V29yaz48L3JkZjpSREY+PC9tZXRhZGF0YT48ZGVmcwogICAgIGlkPSJkZWZzNDciIC8+PHNvZGlwb2RpOm5hbWVkdmlldwogICAgIHBhZ2Vjb2xvcj0iI2ZmZmZmZiIKICAgICBib3JkZXJjb2xvcj0iIzY2NjY2NiIKICAgICBib3JkZXJvcGFjaXR5PSIxIgogICAgIG9iamVjdHRvbGVyYW5jZT0iMTAiCiAgICAgZ3JpZHRvbGVyYW5jZT0iMTAiCiAgICAgZ3VpZGV0b2xlcmFuY2U9IjEwIgogICAgIGlua3NjYXBlOnBhZ2VvcGFjaXR5PSIwIgogICAgIGlua3NjYXBlOnBhZ2VzaGFkb3c9IjIiCiAgICAgaW5rc2NhcGU6d2luZG93LXdpZHRoPSIxMjUzIgogICAgIGlua3NjYXBlOndpbmRvdy1oZWlnaHQ9IjEwNzYiCiAgICAgaWQ9Im5hbWVkdmlldzQ1IgogICAgIHNob3dncmlkPSJmYWxzZSIKICAgICBmaXQtbWFyZ2luLXRvcD0iMCIKICAgICBmaXQtbWFyZ2luLWxlZnQ9IjAiCiAgICAgZml0LW1hcmdpbi1yaWdodD0iMCIKICAgICBmaXQtbWFyZ2luLWJvdHRvbT0iMCIKICAgICBpbmtzY2FwZTp6b29tPSIxLjU0OTI5NTgiCiAgICAgaW5rc2NhcGU6Y3g9IjQyLjIzNyIKICAgICBpbmtzY2FwZTpjeT0iMTIuNjI4IgogICAgIGlua3NjYXBlOndpbmRvdy14PSIxNDYwIgogICAgIGlua3NjYXBlOndpbmRvdy15PSI0MyIKICAgICBpbmtzY2FwZTp3aW5kb3ctbWF4aW1pemVkPSIwIgogICAgIGlua3NjYXBlOmN1cnJlbnQtbGF5ZXI9Im1pY3JvYml0LWxvZ28iIC8+PHBhdGgKICAgICBzdHlsZT0iZmlsbDojMDAwMDAwIgogICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiCiAgICAgaWQ9InBhdGgzOSIKICAgICBkPSJtIDI4Ljg3NCwyMi43MDEwMDEgYyAxLjI5OCwwIDIuMzQ3LC0xLjA1MyAyLjM0NywtMi4zNDkgMCwtMS4yOTYgLTEuMDQ4LC0yLjM0ODAwMSAtMi4zNDcsLTIuMzQ4MDAxIC0xLjI5NywwIC0yLjM0OCwxLjA1MjAwMSAtMi4zNDgsMi4zNDgwMDEgMC4wMDEsMS4yOTYgMS4wNTEsMi4zNDkgMi4zNDgsMi4zNDkiIC8+PHBhdGgKICAgICBzdHlsZT0iZmlsbDojMDAwMDAwIgogICAgIGlua3NjYXBlOmNvbm5lY3Rvci1jdXJ2YXR1cmU9IjAiCiAgICAgaWQ9InBhdGg0MSIKICAgICBkPSJtIDExLjYzLDE4LjAwNCBjIC0xLjI5NywwIC0yLjM0OSwxLjA1MjAwMSAtMi4zNDksMi4zNDgwMDEgMCwxLjI5NiAxLjA1MiwyLjM0OSAyLjM0OSwyLjM0OSAxLjI5NiwwIDIuMzQ3LC0xLjA1MyAyLjM0NywtMi4zNDkgMCwtMS4yOTYgLTEuMDUxLC0yLjM0ODAwMSAtMi4zNDcsLTIuMzQ4MDAxIiAvPjxwYXRoCiAgICAgc3R5bGU9ImZpbGw6IzAwMDAwMCIKICAgICBpbmtzY2FwZTpjb25uZWN0b3ItY3VydmF0dXJlPSIwIgogICAgIGlkPSJwYXRoNDMiCiAgICAgZD0ibSAxMS42MywxMy4zNzQ1IGMgLTMuODQ4LDAgLTYuOTc4LDMuMTI5IC02Ljk3OCw2Ljk3ODAwMSAwLDMuODQ4IDMuMTMsNi45NzggNi45NzgsNi45NzggbCAxNy40NDUsMCBjIDMuODQ4LDAgNi45NzcsLTMuMTMgNi45NzcsLTYuOTc4IDAsLTMuODQ5MDAxIC0zLjEyOSwtNi45NzgwMDEgLTYuOTc3LC02Ljk3ODAwMSBsIC0xNy40NDUsMCBtIDE3LjQ0NSwxOC42MDgwMDEgLTE3LjQ0NSwwIGMgLTYuNDEzLDAgLTExLjYzLC01LjIxNyAtMTEuNjMsLTExLjYzIEMgMCwxMy45Mzk1IDUuMjE3LDguNzIyNTAwNCAxMS42Myw4LjcyMjUwMDQgbCAxNy40NDUsMCBjIDYuNDEzLDAgMTEuNjMsNS4yMTY5OTk2IDExLjYzLDExLjYzMDAwMDYgLTEwZS00LDYuNDEzIC01LjIxNywxMS42MyAtMTEuNjMsMTEuNjMiIC8+PC9zdmc+'; /** - * Manage communication with a MicroBit device over a Device Manager client socket. + * Enum for micro:bit BLE command protocol. + * https://github.com/LLK/scratch-microbit-firmware/blob/master/protocol.md + * @readonly + * @enum {number} + */ +const BLECommand = { + CMD_PIN_CONFIG: 0x80, + CMD_DISPLAY_TEXT: 0x81, + CMD_DISPLAY_LED: 0x82 +}; + +/** + * Enum for micro:bit protocol. + * https://github.com/LLK/scratch-microbit-firmware/blob/master/protocol.md + * @readonly + * @enum {string} + */ +const BLEUUID = { + service: 0xf005, + rxChar: '5261da01-fa7e-42ab-850b-7c80220097cc', + txChar: '5261da02-fa7e-42ab-850b-7c80220097cc' +}; + +/** + * Manage communication with a MicroBit device over a Scrath Link client socket. */ class MicroBit { - /** - * @return {string} - the type of Device Manager device socket that this class will handle. - */ - static get DEVICE_TYPE () { - return 'ble'; - } - /** * Construct a MicroBit communication object. - * @param {Socket} socket - the socket for a MicroBit device, as provided by a Device Manager client. * @param {Runtime} runtime - the Scratch 3.0 runtime */ - constructor (socket, runtime) { - /** - * The socket-IO socket used to communicate with the Device Manager about this device. - * @type {Socket} - * @private - */ - this._socket = socket; + constructor (runtime) { /** - * The Scratch 3.0 runtime used to trigger the green flag button - * + * The Scratch 3.0 runtime used to trigger the green flag button. * @type {Runtime} * @private */ this._runtime = runtime; + /** + * The ScratchBLE connection session for reading/writing device data. + * @type {ScratchBLE} + * @private + */ + this._ble = new ScratchBLE(); + /** * The most recently received value for each sensor. * @type {Object.} @@ -64,6 +82,11 @@ class MicroBit { ledMatrixState: new Uint8Array(5) }; + /** + * The most recently received value for each gesture. + * @type {Object.} + * @private + */ this._gestures = { moving: false, move: { @@ -80,17 +103,32 @@ class MicroBit { } }; - // this._onRxChar = this._onRxChar.bind(this); - // this._onDisconnect = this._onDisconnect.bind(this); + // 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: Add ScratchBLE 'disconnect' handling - this._connectEvents(); } /** - * Manually dispose of this object. + * @param {string} text - the text to display. */ - dispose () { - this._disconnectEvents(); + 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); + } + + /** + * @param {Uint8Array} matrix - the matrix to display. + */ + displayMatrix (matrix) { + this._writeBLE(BLECommand.CMD_DISPLAY_LED, matrix); } /** @@ -144,31 +182,38 @@ class MicroBit { } /** - * Attach event handlers to the device socket. - * @private + * Requests connection to a device when BLE session is ready. */ - _connectEvents () { - // this._socket.on(BLE_UUIDs.rx, this._onRxChar); - // this._socket.on('deviceWasClosed', this._onDisconnect); - // this._socket.on('disconnect', this._onDisconnect); + _onBLEReady () { + this._ble.requestDevice({ + filters: [ + {services: [BLEUUID.service]} + ] + }, this._onBLEConnect.bind(this), this._onBLEError); } /** - * Detach event handlers from the device socket. - * @private + * Starts reading data from device after BLE has connected to it. */ - _disconnectEvents () { - // this._socket.off(BLE_UUIDs.rx, this._onRxChar); - // this._socket.off('deviceWasClosed', this._onDisconnect); - // this._socket.off('disconnect', this._onDisconnect); + _onBLEConnect () { + const callback = this._processBLEData.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} data - the incoming BLE data. + * @param {object} base64 - the incoming BLE data. * @private */ - _processData (data) { + _processBLEData (base64) { + const data = Base64Util.base64ToUint8Array(base64); this._sensors.tiltX = data[1] | (data[0] << 8); if (this._sensors.tiltX > (1 << 15)) this._sensors.tiltX -= (1 << 16); @@ -186,45 +231,22 @@ class MicroBit { } /** - * React to device disconnection. May be called more than once. + * Write a message to the device BLE session. + * @param {number} command - the BLE command hex. + * @param {Uint8Array} message - the message to write. * @private */ - _onDisconnect () { - this._disconnectEvents(); - } - - /** - * Send a message to the device socket. - * @param {string} message - the name of the message, such as 'playTone'. - * @param {object} [details] - optional additional details for the message, such as tone duration and pitch. - * @private - */ - _send (message, details) { - this._socket.emit(message, details); + _writeBLE (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 BLE_UUIDs = { - * uuid: '4cdbbd87d6e646c29d0bdf87551e159a', - * rx: '4cdb8702d6e646c29d0bdf87551e159a' - * }; - */ - -/* - * const DEV_SPEC = { - * info: { - * uuid: [BLE_UUIDs.uuid], - * read_characteristics: { - * '4cdb8702d6e646c29d0bdf87551e159a': { - * notify: true - * } - * } - * }, - * type: 'ble' - * }; - */ - /** * Enum for tilt sensor direction. * @readonly @@ -258,17 +280,6 @@ const symbols2hex = { '?': 0xC91004 }; -/** - * Enum for micro:bit BLE command protocol. - * @readonly - * @enum {number} - */ -const BLECommand = { - CMD_PIN_CONFIG: 0x80, - CMD_DISPLAY_TEXT: 0x81, - CMD_DISPLAY_LED: 0x82 -}; - /** * Scratch 3.0 blocks to interact with a MicroBit device. */ @@ -306,7 +317,8 @@ class Scratch3MicroBitBlocks { */ this.runtime = runtime; - this.connect(); + // Create a new MicroBit device instance + this._device = new MicroBit(this.runtime); } /** @@ -461,44 +473,6 @@ class Scratch3MicroBitBlocks { }; } - /** - * Use the Device Manager client to attempt to connect to a MicroBit device. - */ - connect () { - this._device = new MicroBit(null, this.runtime); - window.addEventListener('message', event => { - if (event.data.type === 'data') { - this._device._processData(new Uint8Array(event.data.buffer)); - } - }, false); - /* - * if (this._device || this._finder) { - * return; - * } - * const deviceManager = this.runtime.ioDevices.deviceManager; - * const finder = this._finder = - * deviceManager.searchAndConnect(Scratch3MicroBitBlocks.EXTENSION_NAME, MicroBit.DEVICE_TYPE, DEV_SPEC); - * - * this._finder.promise.then( - * socket => { - * if (this._finder === finder) { - * this._finder = null; - * this._device = new MicroBit(socket, this.runtime); - * } else { - * log.warn('Ignoring success from stale MicroBit connection attempt'); - * } - * }, - * reason => { - * if (this._finder === finder) { - * this._finder = null; - * log.warn(`MicroBit connection failed: ${reason}`); - * } else { - * log.warn('Ignoring failure from stale MicroBit connection attempt'); - * } - * }); - */ - } - /** * Test whether the A or B button is pressed * @param {object} args - the block's arguments. @@ -546,12 +520,7 @@ class Scratch3MicroBitBlocks { */ displayText (args) { const text = String(args.TEXT).substring(0, 19); - const output = new Uint8Array(text.length + 1); - output[0] = BLECommand.CMD_DISPLAY_TEXT; - for (let i = 0; i < text.length; i++) { - output[i + 1] = text.charCodeAt(i); - } - window.postMessage({type: 'command', buffer: output}, '*'); + this._device.displayText(text); return; } @@ -562,14 +531,12 @@ class Scratch3MicroBitBlocks { displaySymbol (args) { const hex = symbols2hex[args.SYMBOL]; if (!hex) return; - const output = new Uint8Array(6); - output[0] = BLECommand.CMD_DISPLAY_LED; - output[1] = (hex >> 20) & 0x1F; - output[2] = (hex >> 15) & 0x1F; - output[3] = (hex >> 10) & 0x1F; - output[4] = (hex >> 5) & 0x1F; - output[5] = hex & 0x1F; - window.postMessage({type: 'command', buffer: output}, '*'); + this._device.ledMatrixState[0] = (hex >> 20) & 0x1F; + this._device.ledMatrixState[1] = (hex >> 15) & 0x1F; + 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; } @@ -583,7 +550,7 @@ class Scratch3MicroBitBlocks { } else if (args.STATE === 'off') { this._device.ledMatrixState[args.Y - 1] &= ~(1 << 5 - args.X); } else return; - this._displayLEDs(this._device.ledMatrixState); + this._device.displayMatrix(this._device.ledMatrixState); return; } @@ -594,23 +561,10 @@ class Scratch3MicroBitBlocks { for (let i = 0; i < 5; i++) { this._device.ledMatrixState[i] = 0; } - this._displayLEDs(this._device.ledMatrixState); + this._device.displayMatrix(this._device.ledMatrixState); return; } - /** - * Send value to the micro:bit LED matrix - * @param {Uin8array} matrix - the value to send to the matrix. - */ - _displayLEDs (matrix) { - const output = new Uint8Array(matrix.length + 1); - output[0] = BLECommand.CMD_DISPLAY_LED; - for (let i = 0; i < matrix.length; i++) { - output[i + 1] = matrix[i]; - } - window.postMessage({type: 'command', buffer: output}, '*'); - } - /** * Test whether the tilt sensor is currently tilted. * @param {object} args - the block's arguments. diff --git a/src/io/peripheralChooser.js b/src/io/peripheralChooser.js new file mode 100644 index 000000000..53d0a50bc --- /dev/null +++ b/src/io/peripheralChooser.js @@ -0,0 +1,38 @@ +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 new file mode 100644 index 000000000..36bd9a7da --- /dev/null +++ b/src/io/scratchBLE.js @@ -0,0 +1,104 @@ +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 new file mode 100644 index 000000000..2890b525d --- /dev/null +++ b/src/io/scratchBT.js @@ -0,0 +1,37 @@ +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 new file mode 100644 index 000000000..60680e851 --- /dev/null +++ b/src/util/base64-util.js @@ -0,0 +1,33 @@ +const atob = require('atob'); +const btoa = require('btoa'); + +class Base64Util { + + /** + * Convert a base64 encoded string to a Uint8Array. + * @param {string} base64 - a base64 encoded string. + * @return {Uint8Array} - a decoded Uint8Array. + */ + static base64ToUint8Array (base64) { + const binaryString = atob(base64); + const len = binaryString.length; + const array = new Uint8Array(len); + for (let i = 0; i < len; i++) { + array[i] = binaryString.charCodeAt(i); + } + return array; + } + + /** + * Convert a Uint8Array to a base64 encoded string. + * @param {Uint8Array} array - the array to convert. + * @return {string} - the base64 encoded string. + */ + static uint8ArrayToBase64 (array) { + const base64 = btoa(String.fromCharCode.apply(null, array)); + return base64; + } + +} + +module.exports = Base64Util; diff --git a/src/util/jsonrpc-web-socket.js b/src/util/jsonrpc-web-socket.js new file mode 100644 index 000000000..22310d337 --- /dev/null +++ b/src/util/jsonrpc-web-socket.js @@ -0,0 +1,39 @@ +const JSONRPC = require('./jsonrpc'); + +class JSONRPCWebSocket extends JSONRPC { + constructor (webSocket) { + super(); + + this._ws = webSocket; + this._ws.onmessage = e => this._onSocketMessage(e); + this._ws.onopen = e => this._onSocketOpen(e); + this._ws.onclose = e => this._onSocketClose(e); + this._ws.onerror = e => this._onSocketError(e); + } + + dispose () { + this._ws.close(); + this._ws = null; + } + + _onSocketOpen () { + } + + _onSocketClose () { + } + + _onSocketError () { + } + + _onSocketMessage (e) { + const json = JSON.parse(e.data); + this._handleMessage(json); + } + + _sendMessage (message) { + const messageText = JSON.stringify(message); + this._ws.send(messageText); + } +} + +module.exports = JSONRPCWebSocket; diff --git a/src/util/jsonrpc.js b/src/util/jsonrpc.js new file mode 100644 index 000000000..472bdb77c --- /dev/null +++ b/src/util/jsonrpc.js @@ -0,0 +1,112 @@ +class JSONRPC { + constructor () { + this._requestID = 0; + this._openRequests = {}; + } + + /** + * Make an RPC request and retrieve the result. + * @param {string} method - the remote method to call. + * @param {object} params - the parameters to pass to the remote method. + * @returns {Promise} - a promise for the result of the call. + */ + sendRemoteRequest (method, params) { + const requestID = this._requestID++; + + const promise = new Promise((resolve, reject) => { + this._openRequests[requestID] = {resolve, reject}; + }); + + this._sendRequest(method, params, requestID); + + return promise; + } + + /** + * Make an RPC notification with no expectation of a result or callback. + * @param {string} method - the remote method to call. + * @param {object} params - the parameters to pass to the remote method. + */ + sendRemoteNotification (method, params) { + this._sendRequest(method, params); + } + + /** + * Handle an RPC request from remote, should return a result or Promise for result, if appropriate. + * @param {string} method - the method requested by the remote caller. + * @param {object} params - the parameters sent with the remote caller's request. + */ + didReceiveCall (/* method , params */) { + throw new Error('Must override didReceiveCall'); + } + + _sendMessage (/* jsonMessageObject */) { + throw new Error('Must override _sendMessage'); + } + + _sendRequest (method, params, id) { + const request = { + jsonrpc: '2.0', + method, + params + }; + + if (id !== null) { + request.id = id; + } + + this._sendMessage(request); + } + + _handleMessage (json) { + if (json.jsonrpc !== '2.0') { + throw new Error(`Bad or missing JSON-RPC version in message: ${json}`); + } + if (json.hasOwnProperty('method')) { + this._handleRequest(json); + } else { + this._handleResponse(json); + } + } + + _sendResponse (id, result, error) { + const response = { + jsonrpc: '2.0', + id + }; + if (error) { + response.error = error; + } else { + response.result = result || null; + } + this._sendMessage(response); + } + + _handleResponse (json) { + const {result, error, id} = json; + const openRequest = this._openRequests[id]; + delete this._openRequests[id]; + if (error) { + openRequest.reject(error); + } else { + openRequest.resolve(result); + } + } + + _handleRequest (json) { + const {method, params, id} = json; + const rawResult = this.didReceiveCall(method, params); + if (id) { + Promise.resolve(rawResult).then( + result => { + this._sendResponse(id, result); + }, + error => { + this._sendResponse(id, null, error); + } + ); + } + } +} + +module.exports = JSONRPC; diff --git a/test/unit/extension_microbit.js b/test/unit/extension_microbit.js new file mode 100644 index 000000000..4c68eb3fa --- /dev/null +++ b/test/unit/extension_microbit.js @@ -0,0 +1,12 @@ +const test = require('tap').test; +// const MicroBit = require('../../src/extensions/scratch3_microbit/index.js'); + +test('displayText', t => { + t.end(); +}); + +test('displayMatrix', t => { + t.end(); +}); + +// etc... diff --git a/test/unit/io_scratchBLE.js b/test/unit/io_scratchBLE.js new file mode 100644 index 000000000..00336fd3d --- /dev/null +++ b/test/unit/io_scratchBLE.js @@ -0,0 +1,26 @@ +const test = require('tap').test; +// const ScratchBLE = require('../../src/io/scratchBLE'); + +test('constructor', t => { + t.end(); +}); + +test('waitForSocket', t => { + t.end(); +}); + +test('requestDevice', t => { + t.end(); +}); + +test('didReceiveCall', t => { + t.end(); +}); + +test('read', t => { + t.end(); +}); + +test('write', t => { + t.end(); +}); diff --git a/test/unit/io_scratchBT.js b/test/unit/io_scratchBT.js new file mode 100644 index 000000000..deb51025a --- /dev/null +++ b/test/unit/io_scratchBT.js @@ -0,0 +1,22 @@ +const test = require('tap').test; +// const ScratchBT = require('../../src/io/scratchBT'); + +test('constructor', t => { + t.end(); +}); + +test('requestDevice', t => { + t.end(); +}); + +test('connectDevice', t => { + t.end(); +}); + +test('sendMessage', t => { + t.end(); +}); + +test('didReceiveCall', t => { + t.end(); +}); diff --git a/test/unit/util_base64.js b/test/unit/util_base64.js new file mode 100644 index 000000000..99692675a --- /dev/null +++ b/test/unit/util_base64.js @@ -0,0 +1,10 @@ +const test = require('tap').test; +// const Base64Util = require('../../src/util/base64-util'); + +test('base64ToUint8Array', t => { + t.end(); +}); + +test('uint8ArrayToBase64', t => { + t.end(); +}); diff --git a/test/unit/util_jsonrpc-web-socket.js b/test/unit/util_jsonrpc-web-socket.js new file mode 100644 index 000000000..ba03e83ab --- /dev/null +++ b/test/unit/util_jsonrpc-web-socket.js @@ -0,0 +1,10 @@ +const test = require('tap').test; +// const JSONRPCWebSocket = require('../../src/util/jsonrpc-web-socket'); + +test('constructor', t => { + t.end(); +}); + +test('dispose', t => { + t.end(); +}); diff --git a/test/unit/util_jsonrpc.js b/test/unit/util_jsonrpc.js new file mode 100644 index 000000000..33c103b8a --- /dev/null +++ b/test/unit/util_jsonrpc.js @@ -0,0 +1,18 @@ +const test = require('tap').test; +// const JSONRPC = require('../../src/util/jsonrpc'); + +test('constructor', t => { + t.end(); +}); + +test('sendRemoteRequest', t => { + t.end(); +}); + +test('sendRemoteNotification', t => { + t.end(); +}); + +test('didReceiveCall', t => { + t.end(); +});