Adding Ev3 extension with three test blocks to latest device-connection work, auto-connecting to device for now.

This commit is contained in:
Evelyn Eastmond 2018-06-25 13:42:48 -04:00 committed by Ray Schamp
parent 4332725d33
commit 2d8ad05a78
6 changed files with 476 additions and 15 deletions

View file

@ -15,6 +15,7 @@ const Scratch3SpeakBlocks = require('../extensions/scratch3_speak');
const Scratch3TranslateBlocks = require('../extensions/scratch3_translate'); const Scratch3TranslateBlocks = require('../extensions/scratch3_translate');
const Scratch3VideoSensingBlocks = require('../extensions/scratch3_video_sensing'); const Scratch3VideoSensingBlocks = require('../extensions/scratch3_video_sensing');
const Scratch3SpeechBlocks = require('../extensions/scratch3_speech'); const Scratch3SpeechBlocks = require('../extensions/scratch3_speech');
const Scratch3Ev3Blocks = require('../extensions/scratch3_ev3');
const builtinExtensions = { const builtinExtensions = {
pen: Scratch3PenBlocks, pen: Scratch3PenBlocks,
@ -24,7 +25,8 @@ const builtinExtensions = {
speak: Scratch3SpeakBlocks, speak: Scratch3SpeakBlocks,
translate: Scratch3TranslateBlocks, translate: Scratch3TranslateBlocks,
videoSensing: Scratch3VideoSensingBlocks, videoSensing: Scratch3VideoSensingBlocks,
speech: Scratch3SpeechBlocks speech: Scratch3SpeechBlocks,
ev3: Scratch3Ev3Blocks
}; };
/** /**

View file

@ -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;

View file

@ -111,7 +111,7 @@ class MicroBit {
* Called by the runtime when user wants to scan for a device. * Called by the runtime when user wants to scan for a device.
*/ */
startDeviceScan () { startDeviceScan () {
console.log('making a new BLE session'); log.info('making a new BLE session');
this._ble = new BLESession(this._runtime, { this._ble = new BLESession(this._runtime, {
filters: [ filters: [
{services: [BLEUUID.service]} {services: [BLEUUID.service]}

View file

@ -1,4 +1,5 @@
const JSONRPCWebSocket = require('../util/jsonrpc-web-socket'); const JSONRPCWebSocket = require('../util/jsonrpc-web-socket');
const log = require('../util/log');
const ScratchLinkWebSocket = 'ws://localhost:20110/scratch/ble'; const ScratchLinkWebSocket = 'ws://localhost:20110/scratch/ble';
class BLESession extends JSONRPCWebSocket { 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? if (this._ws.readyState === 1) { // is this needed since it's only called on ws.onopen?
// TODO: start a 'discover' timeout // TODO: start a 'discover' timeout
this.sendRemoteRequest('discover', this._deviceOptions) this.sendRemoteRequest('discover', this._deviceOptions)
.catch(e => this._sendError('error on discover')); // never reached? .catch(e => this._sendError(e)); // never reached?
} }
// TODO: else? // TODO: else?
} }
@ -47,7 +48,7 @@ class BLESession extends JSONRPCWebSocket {
connectDevice (id) { connectDevice (id) {
this.sendRemoteRequest('connect', {peripheralId: id}) this.sendRemoteRequest('connect', {peripheralId: id})
.then(() => { .then(() => {
console.log('should have connected'); log.info('should have connected');
this._runtime.emit(this._runtime.constructor.PERIPHERAL_CONNECTED); this._runtime.emit(this._runtime.constructor.PERIPHERAL_CONNECTED);
this._connectCallback(); this._connectCallback();
}) })
@ -118,8 +119,8 @@ class BLESession extends JSONRPCWebSocket {
} }
_sendError (e) { _sendError (e) {
console.log(`BLESession error:`); log.error(`BLESession error:`);
console.log(e); log.error(e);
this._runtime.emit(this._runtime.constructor.PERIPHERAL_ERROR); this._runtime.emit(this._runtime.constructor.PERIPHERAL_ERROR);
} }
} }

View file

@ -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'; const ScratchLinkWebSocket = 'ws://localhost:20110/scratch/bt';
class BTSession extends JSONRPCWebSocket { 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) { sendMessage (options) {
return this.sendRemoteRequest('send', 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 // TODO: Add peripheral 'undiscover' handling
switch (method) { switch (method) {
case 'didDiscoverPeripheral': 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; break;
case 'didReceiveMessage': case 'didReceiveMessage':
// TODO: do something on received message // TODO: do something on received message
@ -31,6 +87,12 @@ class BTSession extends JSONRPCWebSocket {
return 'nah'; return 'nah';
} }
} }
_sendError (e) {
log.error(`BLESession error:`);
log.error(e);
this._runtime.emit(this._runtime.constructor.PERIPHERAL_ERROR);
}
} }
module.exports = BTSession; module.exports = BTSession;

View file

@ -28,6 +28,21 @@ class Base64Util {
return base64; 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; module.exports = Base64Util;