scratch-vm/src/blocks/scratch3_wedo2.js

700 lines
21 KiB
JavaScript
Raw Normal View History

2017-05-05 18:05:21 -04:00
const color = require('../util/color');
const log = require('../util/log');
/**
* Manage power, direction, and timers for one WeDo 2.0 motor.
*/
class WeDo2Motor {
/**
* Construct a WeDo2Motor instance.
* @param {WeDo2} parent - the WeDo 2.0 device which owns this motor.
* @param {int} index - the zero-based index of this motor on its parent device.
*/
constructor (parent, index) {
/**
* The WeDo 2.0 device which owns this motor.
* @type {WeDo2}
* @private
*/
this._parent = parent;
/**
* The zero-based index of this motor on its parent device.
* @type {int}
* @private
*/
this._index = index;
/**
* This motor's current direction: 1 for "this way" or -1 for "that way"
* @type {number}
* @private
*/
this._direction = 1;
/**
* This motor's current power level, in the range [0,100].
* @type {number}
* @private
*/
this._power = 100;
/**
* Is this motor currently moving?
* @type {boolean}
* @private
*/
this._isOn = false;
/**
* If the motor has been turned on or is actively braking for a specific duration, this is the timeout ID for
* the end-of-action handler. Cancel this when changing plans.
* @type {Object}
* @private
*/
this._pendingTimeoutId = null;
this.startBraking = this.startBraking.bind(this);
this.setMotorOff = this.setMotorOff.bind(this);
}
/**
* @return {number} - the duration of active braking after a call to startBraking(). Afterward, turn the motor off.
* @constructor
*/
static get BRAKE_TIME_MS () {
return 1000;
}
/**
* @return {int} - this motor's current direction: 1 for "this way" or -1 for "that way"
*/
get direction () {
return this._direction;
}
/**
* @param {int} value - this motor's new direction: 1 for "this way" or -1 for "that way"
*/
set direction (value) {
if (value < 0) {
this._direction = -1;
} else {
this._direction = 1;
}
}
/**
* @return {int} - this motor's current power level, in the range [0,100].
*/
get power () {
return this._power;
}
/**
* @param {int} value - this motor's new power level, in the range [0,100].
*/
set power (value) {
this._power = Math.max(0, Math.min(value, 100));
}
/**
* @return {boolean} - true if this motor is currently moving, false if this motor is off or braking.
*/
get isOn () {
return this._isOn;
}
/**
* Turn this motor on indefinitely.
*/
setMotorOn () {
this._parent._send('motorOn', {motorIndex: this._index, power: this._direction * this._power});
this._isOn = true;
this._clearTimeout();
}
/**
* Turn this motor on for a specific duration.
* @param {number} milliseconds - run the motor for this long.
*/
setMotorOnFor (milliseconds) {
milliseconds = Math.max(0, milliseconds);
this.setMotorOn();
this._setNewTimeout(this.startBraking, milliseconds);
}
/**
* Start active braking on this motor. After a short time, the motor will turn off.
*/
startBraking () {
this._parent._send('motorBrake', {motorIndex: this._index});
this._isOn = false;
this._setNewTimeout(this.setMotorOff, WeDo2Motor.BRAKE_TIME_MS);
}
/**
* Turn this motor off.
*/
setMotorOff () {
this._parent._send('motorOff', {motorIndex: this._index});
this._isOn = false;
}
/**
* Clear the motor action timeout, if any. Safe to call even when there is no pending timeout.
* @private
*/
_clearTimeout () {
if (this._pendingTimeoutId !== null) {
clearTimeout(this._pendingTimeoutId);
this._pendingTimeoutId = null;
}
}
/**
* Set a new motor action timeout, after clearing an existing one if necessary.
* @param {Function} callback - to be called at the end of the timeout.
* @param {int} delay - wait this many milliseconds before calling the callback.
* @private
*/
_setNewTimeout (callback, delay) {
this._clearTimeout();
const timeoutID = setTimeout(() => {
if (this._pendingTimeoutId === timeoutID) {
this._pendingTimeoutId = null;
}
callback();
}, delay);
this._pendingTimeoutId = timeoutID;
}
}
/**
* Manage communication with a WeDo 2.0 device over a Device Manager client socket.
*/
class WeDo2 {
/**
* @return {string} - the type of Device Manager device socket that this class will handle.
*/
static get DEVICE_TYPE () {
return 'wedo2';
}
/**
* Construct a WeDo2 communication object.
* @param {Socket} socket - the socket for a WeDo 2.0 device, as provided by a Device Manager client.
*/
constructor (socket) {
/**
* The socket-IO socket used to communicate with the Device Manager about this device.
* @type {Socket}
* @private
*/
this._socket = socket;
/**
* The motors which this WeDo 2.0 could possibly have.
* @type {[WeDo2Motor]}
* @private
*/
this._motors = [new WeDo2Motor(this, 0), new WeDo2Motor(this, 1)];
/**
* The most recently received value for each sensor.
* @type {Object.<string, number>}
* @private
*/
this._sensors = {
tiltX: 0,
tiltY: 0,
distance: 0
};
this._onSensorChanged = this._onSensorChanged.bind(this);
this._onDisconnect = this._onDisconnect.bind(this);
this._connectEvents();
}
/**
* Manually dispose of this object.
*/
dispose () {
this._disconnectEvents();
}
/**
* @return {number} - the latest value received for the tilt sensor's tilt about the X axis.
*/
get tiltX () {
return this._sensors.tiltX;
}
/**
* @return {number} - the latest value received for the tilt sensor's tilt about the Y axis.
*/
get tiltY () {
return this._sensors.tiltY;
}
/**
* @return {number} - the latest value received from the distance sensor.
*/
get distance () {
return this._sensors.distance;
}
/**
* Access a particular motor on this device.
* @param {int} index - the zero-based index of the desired motor.
* @return {WeDo2Motor} - the WeDo2Motor instance, if any, at that index.
*/
motor (index) {
return this._motors[index];
}
/**
* Set the WeDo 2.0 hub's LED to a specific color.
* @param {int} rgb - a 24-bit RGB color in 0xRRGGBB format.
*/
setLED (rgb) {
this._send('setLED', {rgb});
}
/**
* Play a tone from the WeDo 2.0 hub for a specific amount of time.
* @param {int} tone - the pitch of the tone, in Hz.
* @param {int} milliseconds - the duration of the note, in milliseconds.
*/
playTone (tone, milliseconds) {
this._send('playTone', {tone, ms: milliseconds});
}
/**
* Stop the tone playing from the WeDo 2.0 hub, if any.
*/
stopTone () {
this._send('stopTone');
}
/**
* Attach event handlers to the device socket.
* @private
*/
_connectEvents () {
this._socket.on('sensorChanged', this._onSensorChanged);
this._socket.on('deviceWasClosed', this._onDisconnect);
this._socket.on('disconnect', this._onDisconnect);
}
/**
* Detach event handlers from the device socket.
* @private
*/
_disconnectEvents () {
this._socket.off('sensorChanged', this._onSensorChanged);
this._socket.off('deviceWasClosed', this._onDisconnect);
this._socket.off('disconnect', this._onDisconnect);
}
/**
* Store the sensor value from an incoming 'sensorChanged' event.
* @param {object} event - the 'sensorChanged' event.
* @property {string} sensorName - the name of the sensor which changed.
* @property {number} sensorValue - the new value of the sensor.
* @private
*/
_onSensorChanged (event) {
this._sensors[event.sensorName] = event.sensorValue;
}
/**
* React to device disconnection. May be called more than once.
* @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);
}
}
2017-05-05 18:05:21 -04:00
/**
* Enum for motor specification.
* @readonly
* @enum {string}
*/
const MotorID = {
DEFAULT: 'motor',
A: 'motor A',
B: 'motor B',
ALL: 'all motors'
};
/**
* Enum for motor direction specification.
* @readonly
* @enum {string}
*/
const MotorDirection = {
FORWARD: 'this way',
BACKWARD: 'that way',
REVERSE: 'reverse'
};
/**
* Enum for tilt sensor direction.
* @readonly
* @enum {string}
*/
const TiltDirection = {
UP: 'up',
DOWN: 'down',
LEFT: 'left',
RIGHT: 'right',
ANY: 'any'
};
/**
* Scratch 3.0 blocks to interact with a LEGO WeDo 2.0 device.
*/
class Scratch3WeDo2Blocks {
/**
* @return {string} - the name of this extension.
*/
static get EXTENSION_NAME () {
return 'wedo2';
}
2017-05-05 18:05:21 -04:00
/**
* @return {number} - the tilt sensor counts as "tilted" if its tilt angle meets or exceeds this threshold.
*/
static get TILT_THRESHOLD () {
return 15;
}
/**
* Construct a set of WeDo 2.0 blocks.
* @param {Runtime} runtime - the Scratch 3.0 runtime.
*/
constructor (runtime) {
/**
* The Scratch 3.0 runtime.
* @type {Runtime}
*/
this.runtime = runtime;
this.runtime.HACK_WeDo2Blocks = this;
}
/**
* Use the Device Manager client to attempt to connect to a WeDo 2.0 device.
*/
connect () {
if (this._device || this._finder) {
return;
}
const deviceManager = this.runtime.ioDevices.deviceManager;
const finder = this._finder =
deviceManager.searchAndConnect(Scratch3WeDo2Blocks.EXTENSION_NAME, WeDo2.DEVICE_TYPE);
this._finder.promise.then(
socket => {
if (this._finder === finder) {
this._finder = null;
this._device = new WeDo2(socket);
} else {
log.warn('Ignoring success from stale WeDo 2.0 connection attempt');
}
},
reason => {
if (this._finder === finder) {
this._finder = null;
log.warn(`WeDo 2.0 connection failed: ${reason}`);
} else {
log.warn('Ignoring failure from stale WeDo 2.0 connection attempt');
}
});
}
2017-05-05 18:05:21 -04:00
/**
* Retrieve the block primitives implemented by this package.
* @return {object.<string, Function>} Mapping of opcode to Function.
*/
getPrimitives () {
return {
wedo2_motorOnFor: this.motorOnFor,
wedo2_motorOn: this.motorOn,
wedo2_motorOff: this.motorOff,
wedo2_startMotorPower: this.startMotorPower,
wedo2_setMotorDirection: this.setMotorDirection,
wedo2_setLightHue: this.setLightHue,
wedo2_playNoteFor: this.playNoteFor,
wedo2_whenDistance: this.whenDistance,
wedo2_whenTilted: this.whenTilted,
wedo2_getDistance: this.getDistance,
wedo2_isTilted: this.isTilted,
wedo2_getTiltAngle: this.getTiltAngle
};
}
/**
* Turn specified motor(s) on for a specified duration.
* @param {object} args - the block's arguments.
* @property {MotorID} MOTOR_ID - the motor(s) to activate.
* @property {int} DURATION - the amount of time to run the motors.
* @return {Promise} - a promise which will resolve at the end of the duration.
*/
motorOnFor (args) {
return new Promise(resolve => {
this._forEachMotor(args.MOTOR_ID, motorIndex => {
this._device.motor(motorIndex).setMotorOnFor(args.DURATION);
});
// Ensure this block runs for a fixed amount of time even when no device is connected.
setTimeout(resolve, args.DURATION);
});
}
/**
* Turn specified motor(s) on indefinitely.
* @param {object} args - the block's arguments.
* @property {MotorID} MOTOR_ID - the motor(s) to activate.
*/
motorOn (args) {
this._forEachMotor(args.MOTOR_ID, motorIndex => {
this._device.motor(motorIndex).setMotorOn();
});
}
/**
* Turn specified motor(s) off.
* @param {object} args - the block's arguments.
* @property {MotorID} MOTOR_ID - the motor(s) to deactivate.
*/
motorOff (args) {
this._forEachMotor(args.MOTOR_ID, motorIndex => {
this._device.motor(motorIndex).setMotorOff();
});
}
/**
* Turn specified motor(s) off.
* @param {object} args - the block's arguments.
* @property {MotorID} MOTOR_ID - the motor(s) to be affected.
* @property {int} POWER - the new power level for the motor(s).
*/
startMotorPower (args) {
this._forEachMotor(args.MOTOR_ID, motorIndex => {
const motor = this._device.motor(motorIndex);
motor.power = args.POWER;
motor.setMotorOn();
});
}
/**
* Set the direction of rotation for specified motor(s).
* If the direction is 'reverse' the motor(s) will be reversed individually.
* @param {object} args - the block's arguments.
* @property {MotorID} MOTOR_ID - the motor(s) to be affected.
* @property {MotorDirection} DIRECTION - the new direction for the motor(s).
*/
setMotorDirection (args) {
this._forEachMotor(args.MOTOR_ID, motorIndex => {
const motor = this._device.motor(motorIndex);
switch (args.DIRECTION) {
case MotorDirection.FORWARD:
motor.direction = 1;
break;
case MotorDirection.BACKWARD:
motor.direction = -1;
break;
case MotorDirection.REVERSE:
motor.direction = -motor.direction;
break;
default:
log.warn(`Unknown motor direction in setMotorDirection: ${args.DIRECTION}`);
break;
}
});
}
/**
* Set the LED's hue.
* @param {object} args - the block's arguments.
* @property {number} HUE - the hue to set, in the range [0,100].
*/
setLightHue (args) {
// Convert from [0,100] to [0,360]
const hue = args.HUE * 360 / 100;
const rgbObject = color.hsvToRgb({h: hue, s: 1, v: 1});
const rgbDecimal = color.rgbToDecimal(rgbObject);
this._device.setLED(rgbDecimal);
}
/**
* Make the WeDo 2.0 hub play a MIDI note for the specified duration.
* @param {object} args - the block's arguments.
* @property {number} NOTE - the MIDI note to play.
* @property {number} DURATION - the duration of the note, in seconds.
* @return {Promise} - a promise which will resolve at the end of the duration.
*/
playNoteFor (args) {
return new Promise(resolve => {
const durationMS = args.DURATION * 1000;
const tone = this._noteToTone(args.NOTE);
this._device.playTone(tone, durationMS);
// Ensure this block runs for a fixed amount of time even when no device is connected.
setTimeout(resolve, durationMS);
});
}
/**
* Compare the distance sensor's value to a reference.
* @param {object} args - the block's arguments.
* @property {string} OP - the comparison operation: '<' or '>'.
* @property {number} REFERENCE - the value to compare against.
* @return {boolean} - the result of the comparison, or false on error.
*/
whenDistance (args) {
switch (args.OP) {
case '<':
return this._device.distance < args.REFERENCE;
case '>':
return this._device.distance > args.REFERENCE;
default:
log.warn(`Unknown comparison operator in whenDistance: ${args.OP}`);
return false;
}
}
/**
* Test whether the tilt sensor is currently tilted.
* @param {object} args - the block's arguments.
* @property {TiltDirection} DIRECTION - the tilt direction to test (up, down, left, right, or any).
* @return {boolean} - true if the tilt sensor is tilted past a threshold in the specified direction.
*/
whenTilted (args) {
return this._isTilted(args.DIRECTION);
}
/**
* @return {number} - the distance sensor's value, scaled to the [0,100] range.
*/
getDistance () {
return this._device.distance * 10;
}
/**
* Test whether the tilt sensor is currently tilted.
* @param {object} args - the block's arguments.
* @property {TiltDirection} DIRECTION - the tilt direction to test (up, down, left, right, or any).
* @return {boolean} - true if the tilt sensor is tilted past a threshold in the specified direction.
*/
isTilted (args) {
return this._isTilted(args.DIRECTION);
}
/**
* @param {object} args - the block's arguments.
* @property {TiltDirection} DIRECTION - the direction (up, down, left, right) to check.
* @return {number} - the tilt sensor's angle in the specified direction.
* Note that getTiltAngle(up) = -getTiltAngle(down) and getTiltAngle(left) = -getTiltAngle(right).
*/
getTiltAngle (args) {
return this._getTiltAngle(args.DIRECTION);
}
/**
* Test whether the tilt sensor is currently tilted.
* @param {TiltDirection} direction - the tilt direction to test (up, down, left, right, or any).
* @return {boolean} - true if the tilt sensor is tilted past a threshold in the specified direction.
* @private
*/
_isTilted (direction) {
switch (direction) {
case TiltDirection.ANY:
return (Math.abs(this._device.tiltX) >= Scratch3WeDo2Blocks.TILT_THRESHOLD) ||
(Math.abs(this._device.tiltY) >= Scratch3WeDo2Blocks.TILT_THRESHOLD);
default:
return this._getTiltAngle(direction) >= Scratch3WeDo2Blocks.TILT_THRESHOLD;
}
}
/**
* @param {TiltDirection} direction - the direction (up, down, left, right) to check.
* @return {number} - the tilt sensor's angle in the specified direction.
* Note that getTiltAngle(up) = -getTiltAngle(down) and getTiltAngle(left) = -getTiltAngle(right).
* @private
*/
_getTiltAngle (direction) {
switch (direction) {
case TiltDirection.UP:
return -this._device.tiltY;
case TiltDirection.DOWN:
return this._device.tiltY;
case TiltDirection.LEFT:
return -this._device.tiltX;
case TiltDirection.RIGHT:
return this._device.tiltX;
default:
log.warn(`Unknown tilt direction in _getTiltAngle: ${direction}`);
}
}
/**
* Call a callback for each motor indexed by the provided motor ID.
* @param {MotorID} motorID - the ID specifier.
* @param {Function} callback - the function to call with the numeric motor index for each motor.
* @private
*/
_forEachMotor (motorID, callback) {
let motors;
switch (motorID) {
case MotorID.A:
motors = [0];
break;
case MotorID.B:
motors = [1];
break;
case MotorID.ALL:
case MotorID.DEFAULT:
motors = [0, 1];
break;
default:
log.warn(`Invalid motor ID: ${motorID}`);
motors = [];
break;
}
for (const index of motors) {
callback(index);
}
}
/**
* @param {number} midiNote - the MIDI note value to convert.
* @return {number} - the frequency, in Hz, corresponding to that MIDI note value.
* @private
*/
_noteToTone (midiNote) {
// Note that MIDI note 69 is A4, 440 Hz
return 440 * Math.pow(2, (midiNote - 69) / 12);
}
}
module.exports = Scratch3WeDo2Blocks;