scratch-vm/src/extensions/scratch3_boost/index.js
2019-06-27 15:48:30 -04:00

2031 lines
66 KiB
JavaScript

const ArgumentType = require('../../extension-support/argument-type');
const BlockType = require('../../extension-support/block-type');
const Cast = require('../../util/cast');
const formatMessage = require('format-message');
const color = require('../../util/color');
const BLE = require('../../io/ble');
const Base64Util = require('../../util/base64-util');
const MathUtil = require('../../util/math-util');
const RateLimiter = require('../../util/rateLimiter.js');
const log = require('../../util/log');
/**
* The LEGO Wireless Protocol documentation used to create this extension can be found at:
* https://lego.github.io/lego-ble-wireless-protocol-docs/index.html
*/
/**
* 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 iconURI = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABQCAMAAAC5zwKfAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAACpQTFRF////fIel5ufolZ62/2YavsPS+YZOkJmy9/j53+Hk6+zs6N/b6dfO////tDhMHAAAAA50Uk5T/////////////////wBFwNzIAAAA6ElEQVR42uzX2w6DIBAEUGDVtlr//3dLaLwgiwUd2z7MJPJg5EQWiGhGcAxBggQJEiT436CIfqXJPTn3MKNYYMSDFpoAmp24OaYgvwKnFgL2zvVTCwHrMoMi+nUQLFthaNCCa0iwclLkDgYVsQp0mzxuqXgK1MRzoCLWgkPXNN2wI/q6Kvt7u/cX0HtejN8x2sXpnpb8J8D3b0Keuhh3X975M+i0xNVbg3s1TIasgK21bQyGO+s2PykaGMYbge8KrNrssvkOWDXkErB8UuBHETjoYLkKBA8ZfuDkbwVBggQJEiR4MC8BBgDTtMZLx2nFCQAAAABJRU5ErkJggg==';
/**
* Boost BLE UUIDs.
* @enum {string}
*/
const BoostBLE = {
service: '00001623-1212-efde-1623-785feabcd123',
characteristic: '00001624-1212-efde-1623-785feabcd123',
sendInterval: 100,
sendRateMax: 20
};
/**
* Boost Motor Max Power Add. Defines how much more power than the target speed
* the motors may supply to reach the target speed faster.
* Lower number == softer, slower reached target speed.
* Higher number == harder, faster reached target speed.
* @constant {number}
*/
const BoostMotorMaxPowerAdd = 10;
/**
* A time interval to wait (in milliseconds) in between battery check calls.
* @type {number}
*/
const BoostPingInterval = 5000;
/**
* The number of continuous samples the color-sensor will evaluate color from.
* @type {number}
*/
const BoostColorSampleSize = 5;
/**
* Enum for Boost sensor and actuator types.
* @readonly
* @enum {number}
*/
const BoostIO = {
MOTOR_WEDO: 0x01,
MOTOR_SYSTEM: 0x02,
BUTTON: 0x05,
LIGHT: 0x08,
VOLTAGE: 0x14,
CURRENT: 0x15,
PIEZO: 0x16,
LED: 0x17,
TILT_EXTERNAL: 0x22,
MOTION_SENSOR: 0x23,
COLOR: 0x25,
MOTOREXT: 0x26,
MOTORINT: 0x27,
TILT: 0x28
};
/**
* Enum for ids for various output command feedback types on the Boost.
* @readonly
* @enum {number}
*/
const BoostPortFeedback = {
IN_PROGRESS: 0x01,
COMPLETED: 0x02,
DISCARDED: 0x04,
IDLE: 0x08,
BUSY_OR_FULL: 0x10
};
/**
* Enum for physical Boost Ports
* @readonly
* @enum {number}
*/
const BoostPort = {
A: 55,
B: 56,
C: 1,
D: 2
};
/**
* Ids for each color sensor value used by the extension.
* @readonly
* @enum {string}
*/
const BoostColor = {
ANY: 'any',
NONE: 'none',
RED: 'red',
BLUE: 'blue',
GREEN: 'green',
YELLOW: 'yellow',
WHITE: 'white',
BLACK: 'black'
};
/**
* Enum for indices for each color sensed by the Boost vision sensor.
* @readonly
* @enum {number}
*/
const BoostColorIndex = {
[BoostColor.NONE]: 255,
[BoostColor.RED]: 9,
[BoostColor.BLUE]: 3,
[BoostColor.GREEN]: 5,
[BoostColor.YELLOW]: 7,
[BoostColor.WHITE]: 10,
[BoostColor.BLACK]: 0
};
/**
* Enum for Message Types
* @readonly
* @enum {number}
*/
const BoostMessage = {
HUB_PROPERTIES: 0x01,
HUB_ACTIONS: 0x02,
HUB_ALERTS: 0x03,
HUB_ATTACHED_IO: 0x04,
ERROR: 0x05,
PORT_INPUT_FORMAT_SETUP_SINGLE: 0x41,
PORT_INPUT_FORMAT_SETUP_COMBINED: 0x42,
PORT_INFORMATION: 0x43,
PORT_MODEINFORMATION: 0x44,
PORT_VALUE: 0x45,
PORT_VALUE_COMBINED: 0x46,
PORT_INPUT_FORMAT: 0x47,
PORT_INPUT_FORMAT_COMBINED: 0x48,
OUTPUT: 0x81,
PORT_FEEDBACK: 0x82
};
/**
* Enum for Motor Subcommands (for 0x81)
* @readonly
* @enum {number}
*/
const BoostOutputSubCommand = {
START_POWER_PAIR: 0x02,
SET_ACC_TIME: 0x05,
SET_DEC_TIME: 0x06,
START_SPEED: 0x07,
START_SPEED_PAIR: 0x08,
START_SPEED_FOR_TIME: 0x09,
START_SPEED_FOR_TIME_PAIR: 0x0A,
START_SPEED_FOR_DEGREES: 0x0B,
START_SPEED_FOR_DEGREES_PAIR: 0x0C,
GO_TO_ABS_POSITION: 0x0D,
GO_TO_ABS_POSITION_PAIR: 0x0E,
PRESET_ENCODER: 0x14,
WRITE_DIRECT_MODE_DATA: 0x51
};
/**
* Enum for Startup/Completion information for an output command.
* Startup and completion bytes must be OR'ed to be combined to a single byte.
* @readonly
* @enum {number}
*/
const BoostOutputExecution = {
// Startup information
BUFFER_IF_NECESSARY: 0x00,
EXECUTE_IMMEDIATELY: 0x10,
// Completion information
NO_ACTION: 0x00,
COMMAND_FEEDBACK: 0x01
};
/**
* Enum for Boost Motor end states
* @readonly
* @enum {number}
*/
const BoostMotorEndState = {
FLOAT: 0,
HOLD: 126,
BRAKE: 127
};
/**
* Enum for Boost Motor acceleration/deceleration profiles
* @readyonly
* @enum {number}
*/
const BoostMotorProfile = {
DO_NOT_USE: 0x00,
ACCELERATION: 0x01,
DECELERATION: 0x02
};
/**
* Enum for when Boost IO's are attached/detached
* @readonly
* @enum {number}
*/
const BoostIOEvent = {
ATTACHED: 0x01,
DETACHED: 0x00,
ATTACHED_VIRTUAL: 0x02
};
/**
* Enum for selected sensor modes.
* @enum {number}
*/
const BoostMode = {
TILT: 0, // angle (pitch/yaw)
LED: 1, // Set LED to accept RGB values
COLOR: 0, // Read indexed colors from Vision Sensor
MOTOR_SENSOR: 2, // Set motors to report their position
UNKNOWN: 0 // Anything else will use the default mode (mode 0)
};
/**
* Enum for Boost motor states.
* @param {number}
*/
const BoostMotorState = {
OFF: 0,
ON_FOREVER: 1,
ON_FOR_TIME: 2,
ON_FOR_ROTATION: 3
};
/**
* Helper function for converting a JavaScript number to an INT32-number
* @param {number} number - a number
* @return {array} - a 4-byte array of Int8-values representing an INT32-number
*/
const numberToInt32Array = function (number) {
const buffer = new ArrayBuffer(4);
const dataview = new DataView(buffer);
dataview.setInt32(0, number);
return [
dataview.getInt8(3),
dataview.getInt8(2),
dataview.getInt8(1),
dataview.getInt8(0)
];
};
/**
* Helper function for converting a regular array to a Little Endian INT32-value
* @param {Array} array - an array containing UInt8-values
* @return {number} - a number
*/
const int32ArrayToNumber = function (array) {
const i = Uint8Array.from(array);
const d = new DataView(i.buffer);
return d.getInt32(0, true);
};
/**
* Manage power, direction, position, and timers for one Boost motor.
*/
class BoostMotor {
/**
* Construct a Boost Motor instance.
* @param {Boost} parent - the Boost peripheral which owns this motor.
* @param {int} index - the zero-based index of this motor on its parent peripheral.
*/
constructor (parent, index) {
/**
* The Boost peripheral which owns this motor.
* @type {Boost}
* @private
*/
this._parent = parent;
/**
* The zero-based index of this motor on its parent peripheral.
* @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 = 50;
/**
* This motor's current relative position
* @type {number}
* @private
*/
this._position = 0;
/**
* Is this motor currently moving?
* @type {boolean}
* @private
*/
this._status = BoostMotorState.OFF;
/**
* 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._pendingDurationTimeoutId = null;
/**
* The starting time for the pending duration timeout.
* @type {number}
* @private
*/
this._pendingDurationTimeoutStartTime = null;
/**
* The delay/duration of the pending duration timeout.
* @type {number}
* @private
*/
this._pendingDurationTimeoutDelay = null;
/**
* The target position of a turn-based command.
* @type {number}
* @private
*/
this._pendingRotationDestination = null;
/**
* If the motor has been turned on run for a specific rotation, this is the function
* that will be called once Scratch VM gets a notification from the Move Hub.
* @type {Object}
* @private
*/
this._pendingRotationPromise = null;
this.turnOff = this.turnOff.bind(this);
}
/**
* @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 [10,100].
*/
set power (value) {
/**
* Scale the motor power to a range between 10 and 100,
* to make sure the motors will run with something built onto them.
*/
if (value === 0) {
this._power = 0;
} else {
this._power = MathUtil.scale(value, 1, 100, 10, 100);
}
}
/**
* @return {int} - this motor's current position, in the range of [-MIN_INT32,MAX_INT32]
*/
get position () {
return this._position;
}
/**
* @param {int} value - set this motor's current position.
*/
set position (value) {
this._position = value;
}
/**
* @return {BoostMotorState} - the motor's current state.
*/
get status () {
return this._status;
}
/**
* @param {BoostMotorState} value - set this motor's state.
*/
set status (value) {
this._clearRotationState();
this._clearDurationTimeout();
this._status = value;
}
/**
* @return {number} - time, in milliseconds, of when the pending duration timeout began.
*/
get pendingDurationTimeoutStartTime () {
return this._pendingDurationTimeoutStartTime;
}
/**
* @return {number} - delay, in milliseconds, of the pending duration timeout.
*/
get pendingDurationTimeoutDelay () {
return this._pendingDurationTimeoutDelay;
}
/**
* @return {number} - target position, in degrees, of the pending rotation.
*/
get pendingRotationDestination () {
return this._pendingRotationDestination;
}
/**
* @return {Promise} - the Promise function for the pending rotation.
*/
get pendingRotationPromise () {
return this._pendingRotationPromise;
}
/**
* @param {function} func - function to resolve pending rotation Promise
*/
set pendingRotationPromise (func) {
this._pendingRotationPromise = func;
}
/**
* Turn this motor on indefinitely
* @private
*/
_turnOn () {
const cmd = this._parent.generateOutputCommand(
this._index,
BoostOutputExecution.EXECUTE_IMMEDIATELY,
BoostOutputSubCommand.START_SPEED,
[
this.power * this.direction,
MathUtil.clamp(this.power + BoostMotorMaxPowerAdd, 0, 100),
BoostMotorProfile.DO_NOT_USE
]);
this._parent.send(BoostBLE.characteristic, cmd);
}
/**
* Turn this motor on indefinitely
*/
turnOnForever () {
this.status = BoostMotorState.ON_FOREVER;
this._turnOn();
}
/**
* Turn this motor on for a specific duration.
* @param {number} milliseconds - run the motor for this long.
*/
turnOnFor (milliseconds) {
milliseconds = Math.max(0, milliseconds);
this.status = BoostMotorState.ON_FOR_TIME;
this._turnOn();
this._setNewDurationTimeout(this.turnOff, milliseconds);
}
/**
* Turn this motor on for a specific rotation in degrees.
* @param {number} degrees - run the motor for this amount of degrees.
* @param {number} direction - rotate in this direction
*/
turnOnForDegrees (degrees, direction) {
degrees = Math.max(0, degrees);
const cmd = this._parent.generateOutputCommand(
this._index,
(BoostOutputExecution.EXECUTE_IMMEDIATELY ^ BoostOutputExecution.COMMAND_FEEDBACK),
BoostOutputSubCommand.START_SPEED_FOR_DEGREES,
[
...numberToInt32Array(degrees),
this.power * this.direction * direction,
MathUtil.clamp(this.power + BoostMotorMaxPowerAdd, 0, 100),
BoostMotorEndState.BRAKE,
BoostMotorProfile.DO_NOT_USE
]
);
this.status = BoostMotorState.ON_FOR_ROTATION;
this._pendingRotationDestination = this.position + (degrees * this.direction * direction);
this._parent.send(BoostBLE.characteristic, cmd);
}
/**
* Turn this motor off.
* @param {boolean} [useLimiter=true] - if true, use the rate limiter
*/
turnOff (useLimiter = true) {
const cmd = this._parent.generateOutputCommand(
this._index,
BoostOutputExecution.EXECUTE_IMMEDIATELY ^ BoostOutputExecution.COMMAND_FEEDBACK,
BoostOutputSubCommand.START_SPEED,
[
BoostMotorEndState.FLOAT,
BoostMotorEndState.FLOAT,
BoostMotorProfile.DO_NOT_USE
]
);
this.status = BoostMotorState.OFF;
this._parent.send(BoostBLE.characteristic, cmd, useLimiter);
}
/**
* Clear the motor action timeout, if any. Safe to call even when there is no pending timeout.
* @private
*/
_clearDurationTimeout () {
if (this._pendingDurationTimeoutId !== null) {
clearTimeout(this._pendingDurationTimeoutId);
this._pendingDurationTimeoutId = null;
this._pendingDurationTimeoutStartTime = null;
this._pendingDurationTimeoutDelay = 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
*/
_setNewDurationTimeout (callback, delay) {
this._clearDurationTimeout();
const timeoutID = setTimeout(() => {
if (this._pendingDurationTimeoutId === timeoutID) {
this._pendingDurationTimeoutId = null;
this._pendingDurationTimeoutStartTime = null;
this._pendingDurationTimeoutDelay = null;
}
callback();
}, delay);
this._pendingDurationTimeoutId = timeoutID;
this._pendingDurationTimeoutStartTime = Date.now();
this._pendingDurationTimeoutDelay = delay;
}
/**
* Clear the motor states related to rotation-based commands, if any.
* Safe to call even when there is no pending promise function.
* @private
*/
_clearRotationState () {
if (this._pendingRotationPromise !== null) {
this._pendingRotationPromise();
this._pendingRotationPromise = null;
}
this._pendingRotationDestination = null;
}
}
/**
* Manage communication with a Boost peripheral over a Bluetooth Low Energy client socket.
*/
class Boost {
constructor (runtime, extensionId) {
/**
* The Scratch 3.0 runtime used to trigger the green flag button.
* @type {Runtime}
* @private
*/
this._runtime = runtime;
this._runtime.on('PROJECT_STOP_ALL', this.stopAll.bind(this));
/**
* The id of the extension this peripheral belongs to.
*/
this._extensionId = extensionId;
/**
* A list of the ids of the physical or virtual sensors.
* @type {string[]}
* @private
*/
this._ports = [];
/**
* A list of motors registered by the Boost hardware.
* @type {BoostMotor[]}
* @private
*/
this._motors = [];
/**
* The most recently received value for each sensor.
* @type {Object.<string, number>}
* @private
*/
this._sensors = {
tiltX: 0,
tiltY: 0,
color: BoostColor.NONE,
previousColor: BoostColor.NONE
};
/**
* An array of values from the Boost Vision Sensor.
* @type {Array}
* @private
*/
this._colorSamples = [];
/**
* The Bluetooth connection socket for reading/writing peripheral data.
* @type {BLE}
* @private
*/
this._ble = null;
this._runtime.registerPeripheralExtension(extensionId, this);
/**
* A rate limiter utility, to help limit the rate at which we send BLE messages
* over the socket to Scratch Link to a maximum number of sends per second.
* @type {RateLimiter}
* @private
*/
this._rateLimiter = new RateLimiter(BoostBLE.sendRateMax);
/**
* An interval id for the battery check interval.
* @type {number}
* @private
*/
this._pingDeviceId = null;
this.reset = this.reset.bind(this);
this._onConnect = this._onConnect.bind(this);
this._onMessage = this._onMessage.bind(this);
this._pingDevice = this._pingDevice.bind(this);
}
/**
* @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 color value received from the vision sensor.
*/
get color () {
return this._sensors.color;
}
/**
* @return {number} - the previous color value received from the vision sensor.
*/
get previousColor () {
return this._sensors.previousColor;
}
/**
* Look up the color id for an index received from the vision sensor.
* @param {number} index - the color index to look up.
* @return {BoostColor} the color id for this index.
*/
boostColorForIndex (index) {
const colorForIndex = Object.keys(BoostColorIndex).find(key => BoostColorIndex[key] === index);
return colorForIndex || BoostColor.NONE;
}
/**
* Access a particular motor on this peripheral.
* @param {int} index - the index of the desired motor.
* @return {BoostMotor} - the BoostMotor instance, if any, at that index.
*/
motor (index) {
return this._motors[index];
}
/**
* Stop all the motors that are currently running.
*/
stopAllMotors () {
this._motors.forEach(motor => {
if (motor) {
// Send the motor off command without using the rate limiter.
// This allows the stop button to stop motors even if we are
// otherwise flooded with commands.
motor.turnOff(false);
}
});
}
/**
* Set the Boost peripheral's LED to a specific color.
* @param {int} inputRGB - a 24-bit RGB color in 0xRRGGBB format.
* @return {Promise} - a promise of the completion of the set led send operation.
*/
setLED (inputRGB) {
const rgb = [
(inputRGB >> 16) & 0x000000FF,
(inputRGB >> 8) & 0x000000FF,
(inputRGB) & 0x000000FF
];
const cmd = this.generateOutputCommand(
this._ports.indexOf(BoostIO.LED),
BoostOutputExecution.EXECUTE_IMMEDIATELY ^ BoostOutputExecution.COMMAND_FEEDBACK,
BoostOutputSubCommand.WRITE_DIRECT_MODE_DATA,
[BoostMode.LED,
...rgb]
);
return this.send(BoostBLE.characteristic, cmd);
}
/**
* Sets the input mode of the LED to RGB.
* @return {Promise} - a promise returned by the send operation.
*/
setLEDMode () {
const cmd = this.generateInputCommand(
this._ports.indexOf(BoostIO.LED),
BoostMode.LED,
0,
false
);
return this.send(BoostBLE.characteristic, cmd);
}
/**
* Stop the motors on the Boost peripheral.
*/
stopAll () {
if (!this.isConnected()) return;
this.stopAllMotors();
}
/**
* Called by the runtime when user wants to scan for a Boost peripheral.
*/
scan () {
if (this._ble) {
this._ble.disconnect();
}
this._ble = new BLE(this._runtime, this._extensionId, {
filters: [{
services: [BoostBLE.service]/* ,
manufacturerData: {
0: {
dataPrefix: [0x97, 0x03, 0x00, 0x40],
mask: [0xFF, 0xFF, 0, 0xFF]
}
} commented out until feature is enabled in scratch-link */
}],
optionalServices: []
}, this._onConnect, this.reset);
}
/**
* Called by the runtime when user wants to connect to a certain Boost peripheral.
* @param {number} id - the id of the peripheral to connect to.
*/
connect (id) {
if (this._ble) {
this._ble.connectPeripheral(id);
}
}
/**
* Disconnects from the current BLE socket and resets state.
*/
disconnect () {
if (this._ble) {
this._ble.disconnect();
}
this.reset();
}
/**
* Reset all the state and timeout/interval ids.
*/
reset () {
this._ports = [];
this._motors = [];
this._sensors = {
tiltX: 0,
tiltY: 0,
color: BoostColor.NONE,
previousColor: BoostColor.NONE
};
if (this._pingDeviceId) {
window.clearInterval(this._pingDeviceId);
this._pingDeviceId = null;
}
}
/**
* Called by the runtime to detect whether the Boost peripheral is connected.
* @return {boolean} - the connected state.
*/
isConnected () {
let connected = false;
if (this._ble) {
connected = this._ble.isConnected();
}
return connected;
}
/**
* Write a message to the Boost peripheral BLE socket.
* @param {number} uuid - the UUID of the characteristic to write to
* @param {Array} message - the message to write.
* @param {boolean} [useLimiter=true] - if true, use the rate limiter
* @return {Promise} - a promise result of the write operation
*/
send (uuid, message, useLimiter = true) {
if (!this.isConnected()) return Promise.resolve();
if (useLimiter) {
if (!this._rateLimiter.okayToSend()) return Promise.resolve();
}
return this._ble.write(
BoostBLE.service,
uuid,
Base64Util.uint8ArrayToBase64(message),
'base64'
);
}
/**
* Generate a Boost 'Output Command' in the byte array format
* (COMMON HEADER, PORT ID, EXECUTION BYTE, SUBCOMMAND ID, PAYLOAD).
*
* Payload is accepted as an array since these vary across different subcommands.
*
* @param {number} portID - the port (Connect ID) to send a command to.
* @param {number} execution - Byte containing startup/completion information
* @param {number} subCommand - the id of the subcommand byte.
* @param {array} payload - the list of bytes to send as subcommand payload
* @return {array} - a generated output command.
*/
generateOutputCommand (portID, execution, subCommand, payload) {
const hubID = 0x00;
const command = [hubID, BoostMessage.OUTPUT, portID, execution, subCommand, ...payload];
command.unshift(command.length + 1); // Prepend payload with length byte;
return command;
}
/**
* Generate a Boost 'Input Command' in the byte array format
* (COMMAND ID, COMMAND TYPE, CONNECT ID, TYPE ID, MODE, DELTA INTERVAL (4 BYTES),
* UNIT, NOTIFICATIONS ENABLED).
*
* This sends a command to the Boost that sets that input format
* of the specified inputs and sets value change notifications.
*
* @param {number} portID - the port (Connect ID) to send a command to.
* @param {number} mode - the mode of the input sensor.
* @param {number} delta - the delta change needed to trigger notification.
* @param {boolean} enableNotifications - whether to enable notifications.
* @return {array} - a generated input command.
*/
generateInputCommand (portID, mode, delta, enableNotifications) {
const command = [
0x00, // Hub ID
BoostMessage.PORT_INPUT_FORMAT_SETUP_SINGLE,
portID,
mode
].concat(numberToInt32Array(delta)).concat([
enableNotifications
]);
command.unshift(command.length + 1); // Prepend payload with length byte;
return command;
}
/**
* Starts reading data from peripheral after BLE has connected.
* @private
*/
_onConnect () {
this._ble.startNotifications(
BoostBLE.service,
BoostBLE.characteristic,
this._onMessage
);
this._pingDeviceId = window.setInterval(this._pingDevice, BoostPingInterval);
}
/**
* Process the sensor data from the incoming BLE characteristic.
* @param {object} base64 - the incoming BLE data.
* @private
*/
_onMessage (base64) {
const data = Base64Util.base64ToUint8Array(base64);
/**
* First three bytes are the common header:
* 0: Length of message
* 1: Hub ID (always 0x00 at the moment, unused)
* 2: Message Type
* 3: Port ID
* We base our switch-case on Message Type
*/
const messageType = data[2];
const portID = data[3];
switch (messageType) {
case BoostMessage.HUB_ATTACHED_IO: { // IO Attach/Detach events
const event = data[4];
const typeId = data[5];
switch (event) {
case BoostIOEvent.ATTACHED:
this._registerSensorOrMotor(portID, typeId);
break;
case BoostIOEvent.DETACHED:
this._clearPort(portID);
break;
case BoostIOEvent.ATTACHED_VIRTUAL:
default:
}
break;
}
case BoostMessage.PORT_VALUE: {
const type = this._ports[portID];
switch (type) {
case BoostIO.TILT:
this._sensors.tiltX = data[4];
this._sensors.tiltY = data[5];
break;
case BoostIO.COLOR:
this._colorSamples.unshift(data[4]);
if (this._colorSamples.length > BoostColorSampleSize) {
this._colorSamples.pop();
if (this._colorSamples.every((v, i, arr) => v === arr[0])) {
this._sensors.previousColor = this._sensors.color;
this._sensors.color = this.boostColorForIndex(this._colorSamples[0]);
} else {
this._sensors.color = BoostColor.NONE;
}
} else {
this._sensors.color = BoostColor.NONE;
}
break;
case BoostIO.MOTOREXT:
case BoostIO.MOTORINT:
this.motor(portID).position = int32ArrayToNumber(data.slice(4, 8));
break;
case BoostIO.CURRENT:
case BoostIO.VOLTAGE:
break;
default:
log.warn(`Unknown sensor value! Type: ${type}`);
}
break;
}
case BoostMessage.PORT_FEEDBACK: {
const feedback = data[4];
const motor = this.motor(portID);
if (motor) {
// Makes sure that commands resolve both when they actually complete and when they fail
const isBusy = feedback & BoostPortFeedback.IN_PROGRESS;
const commandCompleted = feedback & (BoostPortFeedback.COMPLETED ^ BoostPortFeedback.DISCARDED);
if (!isBusy && commandCompleted) {
if (motor.status === BoostMotorState.ON_FOR_ROTATION) {
motor.status = BoostMotorState.OFF;
}
}
}
break;
}
case BoostMessage.ERROR:
log.warn(`Error reported by hub: ${data}`);
break;
}
}
/**
* Ping the Boost hub. If the Boost hub has disconnected
* for some reason, the BLE socket will get an error back and automatically
* close the socket.
* @private
*/
_pingDevice () {
this._ble.read(
BoostBLE.service,
BoostBLE.characteristic,
false
);
}
/**
* Register a new sensor or motor connected at a port. Store the type of
* sensor or motor internally, and then register for notifications on input
* values if it is a sensor.
* @param {number} portID - the port to register a sensor or motor on.
* @param {number} type - the type ID of the sensor or motor
* @private
*/
_registerSensorOrMotor (portID, type) {
// Record which port is connected to what type of device
this._ports[portID] = type;
// Record motor port
if (type === BoostIO.MOTORINT || type === BoostIO.MOTOREXT) {
this._motors[portID] = new BoostMotor(this, portID);
}
// Set input format for tilt or distance sensor
let mode = null;
let delta = 1;
switch (type) {
case BoostIO.MOTORINT:
case BoostIO.MOTOREXT:
mode = BoostMode.MOTOR_SENSOR;
break;
case BoostIO.COLOR:
mode = BoostMode.COLOR;
delta = 0;
break;
case BoostIO.LED:
mode = BoostMode.LED;
/**
* Sets the LED to blue to give an indication on the hub
* that it has connected successfully.
*/
this.setLEDMode();
this.setLED(0x0000FF);
break;
case BoostIO.TILT:
mode = BoostMode.TILT;
break;
default:
mode = BoostMode.UNKNOWN;
}
const cmd = this.generateInputCommand(
portID,
mode,
delta,
true // Receive feedback
);
this.send(BoostBLE.characteristic, cmd);
}
/**
* Clear the sensors or motors present on the ports.
* @param {number} portID - the port to clear.
* @private
*/
_clearPort (portID) {
const type = this._ports[portID];
if (type === BoostIO.TILT) {
this._sensors.tiltX = this._sensors.tiltY = 0;
}
if (type === BoostIO.COLOR) {
this._sensors.color = BoostColor.NONE;
}
this._ports[portID] = 'none';
this._motors[portID] = null;
}
}
/**
* Enum for motor specification.
* @readonly
* @enum {string}
*/
const BoostMotorLabel = {
A: 'A',
B: 'B',
C: 'C',
D: 'D',
AB: 'AB',
ALL: 'ABCD'
};
/**
* Enum for motor direction specification.
* @readonly
* @enum {string}
*/
const BoostMotorDirection = {
FORWARD: 'this way',
BACKWARD: 'that way',
REVERSE: 'reverse'
};
/**
* Enum for tilt sensor direction.
* @readonly
* @enum {string}
*/
const BoostTiltDirection = {
UP: 'up',
DOWN: 'down',
LEFT: 'left',
RIGHT: 'right',
ANY: 'any'
};
/**
* Scratch 3.0 blocks to interact with a LEGO Boost peripheral.
*/
class Scratch3BoostBlocks {
/**
* @return {string} - the ID of this extension.
*/
static get EXTENSION_ID () {
return 'boost';
}
/**
* @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 Boost blocks.
* @param {Runtime} runtime - the Scratch 3.0 runtime.
*/
constructor (runtime) {
/**
* The Scratch 3.0 runtime.
* @type {Runtime}
*/
this.runtime = runtime;
// Create a new Boost peripheral instance
this._peripheral = new Boost(this.runtime, Scratch3BoostBlocks.EXTENSION_ID);
}
/**
* @returns {object} metadata for this extension and its blocks.
*/
getInfo () {
return {
id: Scratch3BoostBlocks.EXTENSION_ID,
name: 'BOOST',
blockIconURI: iconURI,
showStatusButton: true,
blocks: [
{
opcode: 'motorOnFor',
text: formatMessage({
id: 'boost.motorOnFor',
default: 'turn motor [MOTOR_ID] for [DURATION] seconds',
description: 'turn a motor on for some time'
}),
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'MOTOR_ID',
defaultValue: BoostMotorLabel.A
},
DURATION: {
type: ArgumentType.NUMBER,
defaultValue: 1
}
}
},
{
opcode: 'motorOnForRotation',
text: formatMessage({
id: 'boost.motorOnForRotation',
default: 'turn motor [MOTOR_ID] for [ROTATION] rotations',
description: 'turn a motor on for rotation'
}),
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'MOTOR_ID',
defaultValue: BoostMotorLabel.A
},
ROTATION: {
type: ArgumentType.NUMBER,
defaultValue: 1
}
}
},
{
opcode: 'motorOn',
text: formatMessage({
id: 'boost.motorOn',
default: 'turn motor [MOTOR_ID] on',
description: 'turn a motor on indefinitely'
}),
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'MOTOR_ID',
defaultValue: BoostMotorLabel.A
}
}
},
{
opcode: 'motorOff',
text: formatMessage({
id: 'boost.motorOff',
default: 'turn motor [MOTOR_ID] off',
description: 'turn a motor off'
}),
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'MOTOR_ID',
defaultValue: BoostMotorLabel.A
}
}
},
{
opcode: 'setMotorPower',
text: formatMessage({
id: 'boost.setMotorPower',
default: 'set motor [MOTOR_ID] speed to [POWER] %',
description: 'set the motor\'s speed without turning it on'
}),
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'MOTOR_ID',
defaultValue: BoostMotorLabel.ALL
},
POWER: {
type: ArgumentType.NUMBER,
defaultValue: 100
}
}
},
{
opcode: 'setMotorDirection',
text: formatMessage({
id: 'boost.setMotorDirection',
default: 'set motor [MOTOR_ID] direction [MOTOR_DIRECTION]',
description: 'set the motor\'s turn direction without turning it on'
}),
blockType: BlockType.COMMAND,
arguments: {
MOTOR_ID: {
type: ArgumentType.STRING,
menu: 'MOTOR_ID',
defaultValue: BoostMotorLabel.A
},
MOTOR_DIRECTION: {
type: ArgumentType.STRING,
menu: 'MOTOR_DIRECTION',
defaultValue: BoostMotorDirection.FORWARD
}
}
},
{
opcode: 'getMotorPosition',
text: formatMessage({
id: 'boost.getMotorPosition',
default: 'motor [MOTOR_REPORTER_ID] position',
description: 'the position returned by the motor'
}),
blockType: BlockType.REPORTER,
arguments: {
MOTOR_REPORTER_ID: {
type: ArgumentType.STRING,
menu: 'MOTOR_REPORTER_ID',
defaultValue: BoostMotorLabel.A
}
}
},
{
opcode: 'whenColor',
text: formatMessage({
id: 'boost.whenColor',
default: 'when [COLOR] brick seen',
description: 'check for when color'
}),
blockType: BlockType.HAT,
arguments: {
COLOR: {
type: ArgumentType.STRING,
menu: 'COLOR',
defaultValue: BoostColor.ANY
}
}
},
{
opcode: 'seeingColor',
text: formatMessage({
id: 'boost.seeingColor',
default: 'seeing [COLOR] brick?',
description: 'is the color sensor seeing a certain color?'
}),
blockType: BlockType.BOOLEAN,
arguments: {
COLOR: {
type: ArgumentType.STRING,
menu: 'COLOR',
defaultValue: BoostColor.ANY
}
}
},
{
opcode: 'whenTilted',
text: formatMessage({
id: 'boost.whenTilted',
default: 'when tilted [TILT_DIRECTION_ANY]',
description: 'check when tilted in a certain direction'
}),
func: 'isTilted',
blockType: BlockType.HAT,
arguments: {
TILT_DIRECTION_ANY: {
type: ArgumentType.STRING,
menu: 'TILT_DIRECTION_ANY',
defaultValue: BoostTiltDirection.ANY
}
}
},
{
opcode: 'getTiltAngle',
text: formatMessage({
id: 'boost.getTiltAngle',
default: 'tilt angle [TILT_DIRECTION]',
description: 'the angle returned by the tilt sensor'
}),
blockType: BlockType.REPORTER,
arguments: {
TILT_DIRECTION: {
type: ArgumentType.STRING,
menu: 'TILT_DIRECTION',
defaultValue: BoostTiltDirection.UP
}
}
},
{
opcode: 'setLightHue',
text: formatMessage({
id: 'boost.setLightHue',
default: 'set light color to [HUE]',
description: 'set the LED color'
}),
blockType: BlockType.COMMAND,
arguments: {
HUE: {
type: ArgumentType.NUMBER,
defaultValue: 50
}
}
}
],
menus: {
MOTOR_ID: {
acceptReporters: true,
items: [
{
text: 'A',
value: BoostMotorLabel.A
},
{
text: 'B',
value: BoostMotorLabel.B
},
{
text: 'C',
value: BoostMotorLabel.C
},
{
text: 'D',
value: BoostMotorLabel.D
},
{
text: 'AB',
value: BoostMotorLabel.AB
},
{
text: 'ABCD',
value: BoostMotorLabel.ALL
}
]
},
MOTOR_REPORTER_ID: {
acceptReporters: true,
items: [
{
text: 'A',
value: BoostMotorLabel.A
},
{
text: 'B',
value: BoostMotorLabel.B
},
{
text: 'C',
value: BoostMotorLabel.C
},
{
text: 'D',
value: BoostMotorLabel.D
}
]
},
MOTOR_DIRECTION: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'boost.motorDirection.forward',
default: 'this way',
description:
'label for forward element in motor direction menu for LEGO Boost extension'
}),
value: BoostMotorDirection.FORWARD
},
{
text: formatMessage({
id: 'boost.motorDirection.backward',
default: 'that way',
description:
'label for backward element in motor direction menu for LEGO Boost extension'
}),
value: BoostMotorDirection.BACKWARD
},
{
text: formatMessage({
id: 'boost.motorDirection.reverse',
default: 'reverse',
description:
'label for reverse element in motor direction menu for LEGO Boost extension'
}),
value: BoostMotorDirection.REVERSE
}
]
},
TILT_DIRECTION: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'boost.tiltDirection.up',
default: 'up',
description: 'label for up element in tilt direction menu for LEGO Boost extension'
}),
value: BoostTiltDirection.UP
},
{
text: formatMessage({
id: 'boost.tiltDirection.down',
default: 'down',
description: 'label for down element in tilt direction menu for LEGO Boost extension'
}),
value: BoostTiltDirection.DOWN
},
{
text: formatMessage({
id: 'boost.tiltDirection.left',
default: 'left',
description: 'label for left element in tilt direction menu for LEGO Boost extension'
}),
value: BoostTiltDirection.LEFT
},
{
text: formatMessage({
id: 'boost.tiltDirection.right',
default: 'right',
description: 'label for right element in tilt direction menu for LEGO Boost extension'
}),
value: BoostTiltDirection.RIGHT
}
]
},
TILT_DIRECTION_ANY: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'boost.tiltDirection.up',
default: 'up'
}),
value: BoostTiltDirection.UP
},
{
text: formatMessage({
id: 'boost.tiltDirection.down',
default: 'down'
}),
value: BoostTiltDirection.DOWN
},
{
text: formatMessage({
id: 'boost.tiltDirection.left',
default: 'left'
}),
value: BoostTiltDirection.LEFT
},
{
text: formatMessage({
id: 'boost.tiltDirection.right',
default: 'right'
}),
value: BoostTiltDirection.RIGHT
},
{
text: formatMessage({
id: 'boost.tiltDirection.any',
default: 'any',
description: 'label for any element in tilt direction menu for LEGO Boost extension'
}),
value: BoostTiltDirection.ANY
}
]
},
COLOR: {
acceptReporters: true,
items: [
{
text: formatMessage({
id: 'boost.color.red',
default: 'red',
description: 'the color red'
}),
value: BoostColor.RED
},
{
text: formatMessage({
id: 'boost.color.blue',
default: 'blue',
description: 'the color blue'
}),
value: BoostColor.BLUE
},
{
text: formatMessage({
id: 'boost.color.green',
default: 'green',
description: 'the color green'
}),
value: BoostColor.GREEN
},
{
text: formatMessage({
id: 'boost.color.yellow',
default: 'yellow',
description: 'the color yellow'
}),
value: BoostColor.YELLOW
},
{
text: formatMessage({
id: 'boost.color.white',
default: 'white',
desription: 'the color white'
}),
value: BoostColor.WHITE
},
{
text: formatMessage({
id: 'boost.color.black',
default: 'black',
description: 'the color black'
}),
value: BoostColor.BLACK
},
{
text: formatMessage({
id: 'boost.color.any',
default: 'any color',
description: 'any color'
}),
value: BoostColor.ANY
}
]
}
}
};
}
/**
* 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) {
// TODO: cast args.MOTOR_ID?
let durationMS = Cast.toNumber(args.DURATION) * 1000;
durationMS = MathUtil.clamp(durationMS, 0, 15000);
return new Promise(resolve => {
this._forEachMotor(args.MOTOR_ID, motorIndex => {
const motor = this._peripheral.motor(motorIndex);
if (motor) motor.turnOnFor(durationMS);
});
// Run for some time even when no motor is connected
setTimeout(resolve, durationMS);
});
}
/**
* Turn specified motor(s) on for a specified rotation in full rotations.
* @param {object} args - the block's arguments.
* @property {MotorID} MOTOR_ID - the motor(s) to activate.
* @property {int} ROTATION - the amount of full rotations to turn the motors.
* @return {Promise} - a promise which will resolve at the end of the duration.
*/
motorOnForRotation (args) {
// TODO: cast args.MOTOR_ID?
let degrees = Cast.toNumber(args.ROTATION) * 360;
// TODO: Clamps to 100 rotations. Consider changing.
const sign = Math.sign(degrees);
degrees = Math.abs(MathUtil.clamp(degrees, -360000, 360000));
const motors = [];
this._forEachMotor(args.MOTOR_ID, motorIndex => {
motors.push(motorIndex);
});
/**
* Checks that the motors given in args.MOTOR_ID exist,
* and maps a promise for each of the motor-commands to an array.
*/
const promises = motors.map(portID => {
const motor = this._peripheral.motor(portID);
if (motor) {
// to avoid a hanging block if power is 0, return an immediately resolving promise.
if (motor.power === 0) return Promise.resolve();
return new Promise(resolve => {
motor.turnOnForDegrees(degrees, sign);
motor.pendingRotationPromise = resolve;
});
}
return null;
});
/**
* Make sure all promises are resolved, i.e. all motor-commands have completed.
* To prevent the block from returning a value, an empty function is added to the .then
*/
return Promise.all(promises).then(() => {});
}
/**
* Turn specified motor(s) on indefinitely.
* @param {object} args - the block's arguments.
* @property {MotorID} MOTOR_ID - the motor(s) to activate.
* @return {Promise} - a Promise that resolves after some delay.
*/
motorOn (args) {
// TODO: cast args.MOTOR_ID?
this._forEachMotor(args.MOTOR_ID, motorIndex => {
const motor = this._peripheral.motor(motorIndex);
if (motor) motor.turnOnForever();
});
return new Promise(resolve => {
window.setTimeout(() => {
resolve();
}, BoostBLE.sendInterval);
});
}
/**
* Turn specified motor(s) off.
* @param {object} args - the block's arguments.
* @property {MotorID} MOTOR_ID - the motor(s) to deactivate.
* @return {Promise} - a Promise that resolves after some delay.
*/
motorOff (args) {
// TODO: cast args.MOTOR_ID?
this._forEachMotor(args.MOTOR_ID, motorIndex => {
const motor = this._peripheral.motor(motorIndex);
if (motor) motor.turnOff();
});
return new Promise(resolve => {
window.setTimeout(() => {
resolve();
}, BoostBLE.sendInterval);
});
}
/**
* Set the power level of the specified motor(s).
* @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).
* @return {Promise} - returns a promise to make sure the block yields.
*/
setMotorPower (args) {
// TODO: cast args.MOTOR_ID?
this._forEachMotor(args.MOTOR_ID, motorIndex => {
const motor = this._peripheral.motor(motorIndex);
if (motor) {
motor.power = MathUtil.clamp(Cast.toNumber(args.POWER), 0, 100);
switch (motor.status) {
case BoostMotorState.ON_FOREVER:
motor.turnOnForever();
break;
case BoostMotorState.ON_FOR_TIME:
motor.turnOnFor(motor.pendingDurationTimeoutStartTime +
motor.pendingDurationTimeoutDelay - Date.now());
break;
}
}
});
return new Promise(resolve => {
window.setTimeout(() => {
resolve();
}, BoostBLE.sendInterval);
});
}
/**
* 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} MOTOR_DIRECTION - the new direction for the motor(s).
* @return {Promise} - returns a promise to make sure the block yields.
*/
setMotorDirection (args) {
// TODO: cast args.MOTOR_ID?
this._forEachMotor(args.MOTOR_ID, motorIndex => {
const motor = this._peripheral.motor(motorIndex);
if (motor) {
switch (args.MOTOR_DIRECTION) {
case BoostMotorDirection.FORWARD:
motor.direction = 1;
break;
case BoostMotorDirection.BACKWARD:
motor.direction = -1;
break;
case BoostMotorDirection.REVERSE:
motor.direction = -motor.direction;
break;
default:
log.warn(`Unknown motor direction in setMotorDirection: ${args.DIRECTION}`);
break;
}
// keep the motor on if it's running, and update the pending timeout if needed
if (motor) {
switch (motor.status) {
case BoostMotorState.ON_FOREVER:
motor.turnOnForever();
break;
case BoostMotorState.ON_FOR_TIME:
motor.turnOnFor(motor.pendingDurationTimeoutStartTime +
motor.pendingDurationTimeoutDelay - Date.now());
break;
}
}
}
});
return new Promise(resolve => {
window.setTimeout(() => {
resolve();
}, BoostBLE.sendInterval);
});
}
/**
* @param {object} args - the block's arguments.
* @return {number} - returns the motor's position.
*/
getMotorPosition (args) {
let portID = null;
switch (args.MOTOR_REPORTER_ID) {
case BoostMotorLabel.A:
portID = BoostPort.A;
break;
case BoostMotorLabel.B:
portID = BoostPort.B;
break;
case BoostMotorLabel.C:
portID = BoostPort.C;
break;
case BoostMotorLabel.D:
portID = BoostPort.D;
break;
default:
log.warn('Asked for a motor position that doesnt exist!');
return false;
}
if (portID && this._peripheral.motor(portID)) {
let val = this._peripheral.motor(portID).position;
// Boost motor A position direction is reversed by design
// so we have to reverse the position here
if (portID === BoostPort.A) {
val *= -1;
}
return MathUtil.wrapClamp(val, 0, 360);
}
return 0;
}
/**
* 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 BoostMotorLabel.A:
motors = [BoostPort.A];
break;
case BoostMotorLabel.B:
motors = [BoostPort.B];
break;
case BoostMotorLabel.C:
motors = [BoostPort.C];
break;
case BoostMotorLabel.D:
motors = [BoostPort.D];
break;
case BoostMotorLabel.AB:
motors = [BoostPort.A, BoostPort.B];
break;
case BoostMotorLabel.ALL:
motors = [BoostPort.A, BoostPort.B, BoostPort.C, BoostPort.D];
break;
default:
log.warn(`Invalid motor ID: ${motorID}`);
motors = [];
break;
}
for (const index of motors) {
callback(index);
}
}
/**
* Test whether the tilt sensor is currently tilted.
* @param {object} args - the block's arguments.
* @property {TiltDirection} TILT_DIRECTION_ANY - 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.TILT_DIRECTION_ANY);
}
/**
* Test whether the tilt sensor is currently tilted.
* @param {object} args - the block's arguments.
* @property {TiltDirection} TILT_DIRECTION_ANY - 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.TILT_DIRECTION_ANY);
}
/**
* @param {object} args - the block's arguments.
* @property {TiltDirection} TILT_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.TILT_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 BoostTiltDirection.ANY:
return (Math.abs(this._peripheral.tiltX) >= Scratch3BoostBlocks.TILT_THRESHOLD) ||
(Math.abs(this._peripheral.tiltY) >= Scratch3BoostBlocks.TILT_THRESHOLD);
default:
return this._getTiltAngle(direction) >= Scratch3BoostBlocks.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 BoostTiltDirection.UP:
return this._peripheral.tiltY > 90 ? 256 - this._peripheral.tiltY : -this._peripheral.tiltY;
case BoostTiltDirection.DOWN:
return this._peripheral.tiltY > 90 ? this._peripheral.tiltY - 256 : this._peripheral.tiltY;
case BoostTiltDirection.LEFT:
return this._peripheral.tiltX > 90 ? this._peripheral.tiltX - 256 : this._peripheral.tiltX;
case BoostTiltDirection.RIGHT:
return this._peripheral.tiltX > 90 ? 256 - this._peripheral.tiltX : -this._peripheral.tiltX;
default:
log.warn(`Unknown tilt direction in _getTiltAngle: ${direction}`);
}
}
/**
* Edge-triggering hat function, for when the vision sensor is detecting
* a certain color.
* @param {object} args - the block's arguments.
* @return {boolean} - true when the color sensor senses the specified color.
*/
whenColor (args) {
if (args.COLOR === BoostColor.ANY) {
// For "any" color, return true if the color is not "none", and
// the color is different from the previous color detected. This
// allows the hat to trigger when the color changes from one color
// to another.
return this._peripheral.color !== BoostColor.NONE &&
this._peripheral.color !== this._peripheral.previousColor;
}
return args.COLOR === this._peripheral.color;
}
/**
* A boolean reporter function, for whether the vision sensor is detecting
* a certain color.
* @param {object} args - the block's arguments.
* @return {boolean} - true when the color sensor senses the specified color.
*/
seeingColor (args) {
if (args.COLOR === BoostColor.ANY) {
return this._peripheral.color !== BoostColor.NONE;
}
return args.COLOR === this._peripheral.color;
}
/**
* Set the LED's hue.
* @param {object} args - the block's arguments.
* @property {number} HUE - the hue to set, in the range [0,100].
* @return {Promise} - a Promise that resolves after some delay.
*/
setLightHue (args) {
// Convert from [0,100] to [0,360]
let inputHue = Cast.toNumber(args.HUE);
inputHue = MathUtil.wrapClamp(inputHue, 0, 100);
const hue = inputHue * 360 / 100;
const rgbObject = color.hsvToRgb({h: hue, s: 1, v: 1});
const rgbDecimal = color.rgbToDecimal(rgbObject);
this._peripheral._led = inputHue;
this._peripheral.setLED(rgbDecimal);
return new Promise(resolve => {
window.setTimeout(() => {
resolve();
}, BoostBLE.sendInterval);
});
}
}
module.exports = Scratch3BoostBlocks;