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..aec3f4b20 --- /dev/null +++ b/src/extensions/scratch3_ev3/index.js @@ -0,0 +1,381 @@ +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'); + +/** + * 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 = ['1', '2', '3', '4']; + +class EV3 { + + constructor (runtime, extensionId) { + + /** + * The Scratch 3.0 runtime used to trigger the green flag button. + * @type {Runtime} + * @private + */ + this._runtime = runtime; + + this.connected = false; + this.speed = 50; + + /** + * The Bluetooth connection session for reading/writing device data. + * @type {BTSession} + * @private + */ + this._bt = null; + this._runtime.registerExtensionDevice(extensionId, this); + + // TODO: auto-connect temporary - until button is added + this.startDeviceScan(); + } + + // TODO: keep here? + /** + * Called by the runtime when user wants to scan for a device. + */ + startDeviceScan () { + log.info('making a new BT session'); + this._bt = new BTSession(this._runtime, { + majorDeviceClass: 8, + minorDeviceClass: 1 + }, this._onSessionConnect.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); + } + + 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); + }); + } + + _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 () { + log.info('bt device connected!'); + this.connected = true; + // start reading data? + } + +} + +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 MicroBit 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', + iconURI: null, + blocks: [ + { + opcode: 'motorTurnClockwise', + text: '[PORT] turn clockwise [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: '[PORT] turn counter [TIME] seconds', + blockType: BlockType.COMMAND, + arguments: { + PORT: { + type: ArgumentType.STRING, + menu: 'motorPorts', + defaultValue: MOTOR_PORTS[0].value + }, + TIME: { + type: ArgumentType.NUMBER, + defaultValue: 1 + } + } + }, + { + opcode: 'beep', + text: 'beep', + blockType: BlockType.COMMAND + } + ], + menus: { + motorPorts: this._buildMenu(MOTOR_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) { + // Validate arguments + const port = Cast.toNumber(args.PORT); + const time = Cast.toNumber(args.TIME) * 1000; + + this._device.motorTurnClockwise(port, time); + } + + motorTurnCounterClockwise (args) { + // Validate arguments + const port = Cast.toNumber(args.PORT); + const time = Cast.toNumber(args.TIME) * 1000; + + this._device.motorTurnCounterClockwise(port, time); + } + + 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 0cb09775b..22203ccb4 100644 --- a/src/extensions/scratch3_microbit/index.js +++ b/src/extensions/scratch3_microbit/index.js @@ -111,7 +111,7 @@ class MicroBit { * Called by the runtime when user wants to scan for a device. */ startDeviceScan () { - console.log('making a new BLE session'); + log.info('making a new BLE session'); this._ble = new BLESession(this._runtime, { filters: [ {services: [BLEUUID.service]} diff --git a/src/io/bleSession.js b/src/io/bleSession.js index 8cb95f78f..9a4cd1aab 100644 --- a/src/io/bleSession.js +++ b/src/io/bleSession.js @@ -1,4 +1,5 @@ const JSONRPCWebSocket = require('../util/jsonrpc-web-socket'); +const log = require('../util/log'); const ScratchLinkWebSocket = 'ws://localhost:20110/scratch/ble'; class BLESession extends JSONRPCWebSocket { @@ -34,7 +35,7 @@ class BLESession extends JSONRPCWebSocket { 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('error on discover')); // never reached? + .catch(e => this._sendError(e)); // never reached? } // TODO: else? } @@ -47,7 +48,7 @@ class BLESession extends JSONRPCWebSocket { connectDevice (id) { this.sendRemoteRequest('connect', {peripheralId: id}) .then(() => { - console.log('should have connected'); + log.info('should have connected'); this._runtime.emit(this._runtime.constructor.PERIPHERAL_CONNECTED); this._connectCallback(); }) @@ -118,8 +119,8 @@ class BLESession extends JSONRPCWebSocket { } _sendError (e) { - console.log(`BLESession error:`); - console.log(e); + log.error(`BLESession error:`); + log.error(e); this._runtime.emit(this._runtime.constructor.PERIPHERAL_ERROR); } } diff --git a/src/io/btSession.js b/src/io/btSession.js index 2a24f93af..3d52189cb 100644 --- a/src/io/btSession.js +++ b/src/io/btSession.js @@ -1,28 +1,84 @@ -const JSONRPCWebSocket = require('../util/jsonrpc'); +const JSONRPCWebSocket = require('../util/jsonrpc-web-socket'); +const log = require('../util/log'); const ScratchLinkWebSocket = 'ws://localhost:20110/scratch/bt'; class BTSession extends JSONRPCWebSocket { - constructor () { - super(new WebSocket(ScratchLinkWebSocket)); + + /** + * 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. + */ + 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; } - requestDevice (options) { - return this.sendRemoteRequest('discover', options); + /** + * 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? } - connectDevice (options) { - return this.sendRemoteRequest('connect', options); + /** + * 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._connectCallback(); + }) + .catch(e => { + this._sendError(e); + }); } sendMessage (options) { return this.sendRemoteRequest('send', options); } - didReceiveCall (method /* , params */) { + /** + * 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': - // TODO: do something on peripheral discovered + /* this._availablePeripherals[params.peripheralId] = params; + this._runtime.emit( + this._runtime.constructor.PERIPHERAL_LIST_UPDATE, + this._availablePeripherals + ); */ + // TODO: auto-connect temporary until button is added + this.connectDevice(params.peripheralId); + // TODO: cancel a discover timeout if one is active break; case 'didReceiveMessage': // TODO: do something on received message @@ -31,6 +87,12 @@ class BTSession extends JSONRPCWebSocket { return 'nah'; } } + + _sendError (e) { + log.error(`BLESession error:`); + log.error(e); + this._runtime.emit(this._runtime.constructor.PERIPHERAL_ERROR); + } } module.exports = BTSession; 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;