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'); /** * 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,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'; /** * A list of WeDo 2.0 BLE service UUIDs. * @enum */ const BLEService = { DEVICE_SERVICE: '00001523-1212-efde-1523-785feabcd123', IO_SERVICE: '00004f0e-1212-efde-1523-785feabcd123' }; /** * A list of WeDo 2.0 BLE characteristic UUIDs. * * Characteristics on DEVICE_SERVICE: * - ATTACHED_IO * * Characteristics on IO_SERVICE: * - INPUT_VALUES * - INPUT_COMMAND * - OUTPUT_COMMAND * * @enum */ const BLECharacteristic = { ATTACHED_IO: '00001527-1212-efde-1523-785feabcd123', LOW_VOLTAGE_ALERT: '00001528-1212-efde-1523-785feabcd123', INPUT_VALUES: '00001560-1212-efde-1523-785feabcd123', INPUT_COMMAND: '00001563-1212-efde-1523-785feabcd123', OUTPUT_COMMAND: '00001565-1212-efde-1523-785feabcd123' }; /** * A time interval to wait (in milliseconds) in between battery check calls. * @type {number} */ const BLEBatteryCheckInterval = 5000; /** * A time interval to wait (in milliseconds) while a block that sends a BLE message is running. * @type {number} */ const BLESendInterval = 100; /** * A maximum number of BLE message sends per second, to be enforced by the rate limiter. * @type {number} */ const BLESendRateMax = 20; /** * Enum for WeDo 2.0 sensor and output types. * @readonly * @enum {number} */ const WeDo2Device = { MOTOR: 1, PIEZO: 22, LED: 23, TILT: 34, DISTANCE: 35 }; /** * Enum for connection/port ids assigned to internal WeDo 2.0 output devices. * @readonly * @enum {number} */ // TODO: Check for these more accurately at startup? const WeDo2ConnectID = { LED: 6, PIEZO: 5 }; /** * Enum for ids for various output commands on the WeDo 2.0. * @readonly * @enum {number} */ const WeDo2Command = { MOTOR_POWER: 1, PLAY_TONE: 2, STOP_TONE: 3, WRITE_RGB: 4, SET_VOLUME: 255 }; /** * Enum for modes for input sensors on the WeDo 2.0. * @enum {number} */ const WeDo2Mode = { TILT: 0, // angle DISTANCE: 0, // detect LED: 1 // RGB }; /** * Enum for units for input sensors on the WeDo 2.0. * * 0 = raw * 1 = percent * * @enum {number} */ const WeDo2Unit = { TILT: 0, DISTANCE: 1, LED: 0 }; /** * Manage power, direction, and timers for one WeDo 2.0 motor. */ class WeDo2Motor { /** * Construct a WeDo 2.0 Motor instance. * @param {WeDo2} parent - the WeDo 2.0 peripheral which owns this motor. * @param {int} index - the zero-based index of this motor on its parent peripheral. */ constructor (parent, index) { /** * The WeDo 2.0 peripheral which owns this motor. * @type {WeDo2} * @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 = 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; /** * The starting time for the pending timeout. * @type {Object} * @private */ this._pendingTimeoutStartTime = null; /** * The delay/duration of the pending timeout. * @type {Object} * @private */ this._pendingTimeoutDelay = null; this.startBraking = this.startBraking.bind(this); this.turnOff = this.turnOff.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) { const p = Math.max(0, Math.min(value, 100)); // Lego Wedo 2.0 hub only turns motors at power range [30 - 100], so // map value from [0 - 100] to [30 - 100]. if (p === 0) { this._power = 0; } else { const delta = 100 / p; this._power = 30 + (70 / delta); } } /** * @return {boolean} - true if this motor is currently moving, false if this motor is off or braking. */ get isOn () { return this._isOn; } /** * @return {boolean} - time, in milliseconds, of when the pending timeout began. */ get pendingTimeoutStartTime () { return this._pendingTimeoutStartTime; } /** * @return {boolean} - delay, in milliseconds, of the pending timeout. */ get pendingTimeoutDelay () { return this._pendingTimeoutDelay; } /** * Turn this motor on indefinitely. */ turnOn () { if (this._power === 0) return; const cmd = this._parent.generateOutputCommand( this._index + 1, WeDo2Command.MOTOR_POWER, [this._power * this._direction] // power in range 0-100 ); this._parent.send(BLECharacteristic.OUTPUT_COMMAND, cmd); this._isOn = true; this._clearTimeout(); } /** * Turn this motor on for a specific duration. * @param {number} milliseconds - run the motor for this long. */ turnOnFor (milliseconds) { if (this._power === 0) return; milliseconds = Math.max(0, milliseconds); this.turnOn(); this._setNewTimeout(this.startBraking, milliseconds); } /** * Start active braking on this motor. After a short time, the motor will turn off. * // TODO: rename this to coastAfter? */ startBraking () { if (this._power === 0) return; const cmd = this._parent.generateOutputCommand( this._index + 1, WeDo2Command.MOTOR_POWER, [127] // 127 = break ); this._parent.send(BLECharacteristic.OUTPUT_COMMAND, cmd); this._isOn = false; this._setNewTimeout(this.turnOff, WeDo2Motor.BRAKE_TIME_MS); } /** * Turn this motor off. * @param {boolean} [useLimiter=true] - if true, use the rate limiter */ turnOff (useLimiter = true) { if (this._power === 0) return; const cmd = this._parent.generateOutputCommand( this._index + 1, WeDo2Command.MOTOR_POWER, [0] // 0 = stop ); this._parent.send(BLECharacteristic.OUTPUT_COMMAND, cmd, useLimiter); 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; this._pendingTimeoutStartTime = null; this._pendingTimeoutDelay = 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; this._pendingTimeoutStartTime = null; this._pendingTimeoutDelay = null; } callback(); }, delay); this._pendingTimeoutId = timeoutID; this._pendingTimeoutStartTime = Date.now(); this._pendingTimeoutDelay = delay; } } /** * Manage communication with a WeDo 2.0 peripheral over a Bluetooth Low Energy client socket. */ class WeDo2 { 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 motors or sensors in ports 1 and 2. * @type {string[]} * @private */ this._ports = ['none', 'none']; /** * The motors which this WeDo 2.0 could possibly have. * @type {WeDo2Motor[]} * @private */ this._motors = [null, null]; /** * The most recently received value for each sensor. * @type {Object.} * @private */ this._sensors = { tiltX: 0, tiltY: 0, distance: 0 }; /** * 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(BLESendRateMax); /** * An interval id for the battery check interval. * @type {number} * @private */ this._batteryLevelIntervalId = null; this.disconnect = this.disconnect.bind(this); this._onConnect = this._onConnect.bind(this); this._onMessage = this._onMessage.bind(this); this._checkBatteryLevel = this._checkBatteryLevel.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 value received from the distance sensor. */ get distance () { return this._sensors.distance; } /** * Access a particular motor on this peripheral. * @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]; } /** * 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 WeDo 2.0 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( WeDo2ConnectID.LED, WeDo2Command.WRITE_RGB, rgb ); return this.send(BLECharacteristic.OUTPUT_COMMAND, cmd); } /** * Sets the input mode of the LED to RGB. * @return {Promise} - a promise returned by the send operation. */ setLEDMode () { const cmd = this.generateInputCommand( WeDo2ConnectID.LED, WeDo2Device.LED, WeDo2Mode.LED, 0, WeDo2Unit.LED, false ); return this.send(BLECharacteristic.INPUT_COMMAND, cmd); } /** * Switch off the LED on the WeDo 2.0. * @return {Promise} - a promise of the completion of the stop led send operation. */ stopLED () { const cmd = this.generateOutputCommand( WeDo2ConnectID.LED, WeDo2Command.WRITE_RGB, [0, 0, 0] ); return this.send(BLECharacteristic.OUTPUT_COMMAND, cmd); } /** * Play a tone from the WeDo 2.0 peripheral 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. * @return {Promise} - a promise of the completion of the play tone send operation. */ playTone (tone, milliseconds) { const cmd = this.generateOutputCommand( WeDo2ConnectID.PIEZO, WeDo2Command.PLAY_TONE, [ tone, tone >> 8, milliseconds, milliseconds >> 8 ] ); return this.send(BLECharacteristic.OUTPUT_COMMAND, cmd); } /** * Stop the tone playing from the WeDo 2.0 peripheral, if any. * @return {Promise} - a promise that the command sent. */ stopTone () { const cmd = this.generateOutputCommand( WeDo2ConnectID.PIEZO, WeDo2Command.STOP_TONE ); // Send this command without using the rate limiter, because it is // only triggered by the stop button. return this.send(BLECharacteristic.OUTPUT_COMMAND, cmd, false); } /** * Stop the tone playing and motors on the WeDo 2.0 peripheral. */ stopAll () { if (!this.isConnected()) return; this.stopTone(); this.stopAllMotors(); } /** * Called by the runtime when user wants to scan for a WeDo 2.0 peripheral. */ scan () { if (this._ble) { this._ble.disconnect(); } this._ble = new BLE(this._runtime, this._extensionId, { filters: [{ services: [BLEService.DEVICE_SERVICE] }], optionalServices: [BLEService.IO_SERVICE] }, this._onConnect, this.disconnect); } /** * Called by the runtime when user wants to connect to a certain WeDo 2.0 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. */ disconnect () { this._ports = ['none', 'none']; this._motors = [null, null]; this._sensors = { tiltX: 0, tiltY: 0, distance: 0 }; if (this._ble) { this._ble.disconnect(); } if (this._batteryLevelIntervalId) { window.clearInterval(this._batteryLevelIntervalId); this._batteryLevelIntervalId = null; } } /** * Called by the runtime to detect whether the WeDo 2.0 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 WeDo 2.0 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( BLEService.IO_SERVICE, uuid, Base64Util.uint8ArrayToBase64(message), 'base64' ); } /** * Generate a WeDo 2.0 'Output Command' in the byte array format * (CONNECT ID, COMMAND ID, NUMBER OF BYTES, VALUES ...). * * This sends a command to the WeDo 2.0 to actuate the specified outputs. * * @param {number} connectID - the port (Connect ID) to send a command to. * @param {number} commandID - the id of the byte command. * @param {array} values - the list of values to write to the command. * @return {array} - a generated output command. */ generateOutputCommand (connectID, commandID, values = null) { let command = [connectID, commandID]; if (values) { command = command.concat( values.length ).concat( values ); } return command; } /** * Generate a WeDo 2.0 '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 WeDo 2.0 that sets that input format * of the specified inputs and sets value change notifications. * * @param {number} connectID - the port (Connect ID) to send a command to. * @param {number} type - the type of input sensor. * @param {number} mode - the mode of the input sensor. * @param {number} delta - the delta change needed to trigger notification. * @param {array} units - the unit of the input sensor value. * @param {boolean} enableNotifications - whether to enable notifications. * @return {array} - a generated input command. */ generateInputCommand (connectID, type, mode, delta, units, enableNotifications) { const command = [ 1, // Command ID = 1 = "Sensor Format" 2, // Command Type = 2 = "Write" connectID, type, mode, delta, 0, // Delta Interval Byte 2 0, // Delta Interval Byte 3 0, // Delta Interval Byte 4 units, enableNotifications ? 1 : 0 ]; return command; } /** * Sets LED mode and initial color and starts reading data from peripheral after BLE has connected. * @private */ _onConnect () { this.setLEDMode(); this.setLED(0x0000FF); this._ble.startNotifications( BLEService.DEVICE_SERVICE, BLECharacteristic.ATTACHED_IO, this._onMessage ); this._batteryLevelIntervalId = window.setInterval(this._checkBatteryLevel, BLEBatteryCheckInterval); } /** * Process the sensor data from the incoming BLE characteristic. * @param {object} base64 - the incoming BLE data. * @private */ _onMessage (base64) { const data = Base64Util.base64ToUint8Array(base64); // log.info(data); /** * If first byte of data is '1' or '2', then either clear the * sensor present in ports 1 or 2 or set their format. * * If first byte of data is anything else, read incoming sensor value. */ switch (data[0]) { case 1: case 2: { const connectID = data[0]; if (data[1] === 0) { // clear sensor or motor this._clearPort(connectID); } else { // register sensor or motor this._registerSensorOrMotor(connectID, data[3]); } break; } default: { // read incoming sensor value const connectID = data[1]; const type = this._ports[connectID - 1]; if (type === WeDo2Device.DISTANCE) { this._sensors.distance = data[2]; } if (type === WeDo2Device.TILT) { this._sensors.tiltX = data[2]; this._sensors.tiltY = data[3]; } break; } } } /** * Check the battery level on the WeDo 2.0. If the WeDo 2.0 has disconnected * for some reason, the BLE socket will get an error back and automatically * close the socket. */ _checkBatteryLevel () { this._ble.read( BLEService.DEVICE_SERVICE, BLECharacteristic.LOW_VOLTAGE_ALERT, 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} connectID - the port to register a sensor or motor on. * @param {number} type - the type ID of the sensor or motor * @private */ _registerSensorOrMotor (connectID, type) { // Record which port is connected to what type of device this._ports[connectID - 1] = type; // Record motor port if (type === WeDo2Device.MOTOR) { this._motors[connectID - 1] = new WeDo2Motor(this, connectID - 1); } else { // Set input format for tilt or distance sensor const typeString = type === WeDo2Device.DISTANCE ? 'DISTANCE' : 'TILT'; const cmd = this.generateInputCommand( connectID, type, WeDo2Mode[typeString], 1, WeDo2Unit[typeString], true ); this.send(BLECharacteristic.INPUT_COMMAND, cmd); this._ble.startNotifications( BLEService.IO_SERVICE, BLECharacteristic.INPUT_VALUES, this._onMessage ); } } /** * Clear the sensor or motor present at port 1 or 2. * @param {number} connectID - the port to clear. * @private */ _clearPort (connectID) { const type = this._ports[connectID - 1]; if (type === WeDo2Device.TILT) { this._sensors.tiltX = this._sensors.tiltY = 0; } if (type === WeDo2Device.DISTANCE) { this._sensors.distance = 0; } this._ports[connectID - 1] = 'none'; this._motors[connectID - 1] = null; } } /** * Enum for motor specification. * @readonly * @enum {string} */ const WeDo2MotorLabel = { DEFAULT: 'motor', A: 'motor A', B: 'motor B', ALL: 'all motors' }; /** * Enum for motor direction specification. * @readonly * @enum {string} */ const WeDo2MotorDirection = { FORWARD: 'this way', BACKWARD: 'that way', REVERSE: 'reverse' }; /** * Enum for tilt sensor direction. * @readonly * @enum {string} */ const WeDo2TiltDirection = { UP: 'up', DOWN: 'down', LEFT: 'left', RIGHT: 'right', ANY: 'any' }; /** * Scratch 3.0 blocks to interact with a LEGO WeDo 2.0 peripheral. */ class Scratch3WeDo2Blocks { /** * @return {string} - the ID of this extension. */ static get EXTENSION_ID () { return 'wedo2'; } /** * @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; // Create a new WeDo 2.0 peripheral instance this._peripheral = new WeDo2(this.runtime, Scratch3WeDo2Blocks.EXTENSION_ID); } /** * @returns {object} metadata for this extension and its blocks. */ getInfo () { return { id: Scratch3WeDo2Blocks.EXTENSION_ID, name: 'WeDo 2.0', blockIconURI: iconURI, showStatusButton: true, blocks: [ { opcode: 'motorOnFor', text: formatMessage({ id: 'wedo2.motorOnFor', default: 'turn [MOTOR_ID] on for [DURATION] seconds', description: 'turn a motor on for some time' }), blockType: BlockType.COMMAND, arguments: { MOTOR_ID: { type: ArgumentType.STRING, menu: 'MOTOR_ID', defaultValue: WeDo2MotorLabel.DEFAULT }, DURATION: { type: ArgumentType.NUMBER, defaultValue: 1 } } }, { opcode: 'motorOn', text: formatMessage({ id: 'wedo2.motorOn', default: 'turn [MOTOR_ID] on', description: 'turn a motor on indefinitely' }), blockType: BlockType.COMMAND, arguments: { MOTOR_ID: { type: ArgumentType.STRING, menu: 'MOTOR_ID', defaultValue: WeDo2MotorLabel.DEFAULT } } }, { opcode: 'motorOff', text: formatMessage({ id: 'wedo2.motorOff', default: 'turn [MOTOR_ID] off', description: 'turn a motor off' }), blockType: BlockType.COMMAND, arguments: { MOTOR_ID: { type: ArgumentType.STRING, menu: 'MOTOR_ID', defaultValue: WeDo2MotorLabel.DEFAULT } } }, { opcode: 'startMotorPower', text: formatMessage({ id: 'wedo2.startMotorPower', default: 'set [MOTOR_ID] power to [POWER]', description: 'set the motor\'s power and turn it on' }), blockType: BlockType.COMMAND, arguments: { MOTOR_ID: { type: ArgumentType.STRING, menu: 'MOTOR_ID', defaultValue: WeDo2MotorLabel.DEFAULT }, POWER: { type: ArgumentType.NUMBER, defaultValue: 100 } } }, { opcode: 'setMotorDirection', text: formatMessage({ id: 'wedo2.setMotorDirection', default: 'set [MOTOR_ID] direction to [MOTOR_DIRECTION]', description: 'set the motor\'s turn direction' }), blockType: BlockType.COMMAND, arguments: { MOTOR_ID: { type: ArgumentType.STRING, menu: 'MOTOR_ID', defaultValue: WeDo2MotorLabel.DEFAULT }, MOTOR_DIRECTION: { type: ArgumentType.STRING, menu: 'MOTOR_DIRECTION', defaultValue: WeDo2MotorDirection.FORWARD } } }, { opcode: 'setLightHue', text: formatMessage({ id: 'wedo2.setLightHue', default: 'set light color to [HUE]', description: 'set the LED color' }), blockType: BlockType.COMMAND, arguments: { HUE: { type: ArgumentType.NUMBER, defaultValue: 50 } } }, { opcode: 'playNoteFor', text: formatMessage({ id: 'wedo2.playNoteFor', default: 'play note [NOTE] for [DURATION] seconds', description: 'play a certain note for some time' }), blockType: BlockType.COMMAND, arguments: { NOTE: { type: ArgumentType.NUMBER, // TODO: ArgumentType.MIDI_NOTE? defaultValue: 60 }, DURATION: { type: ArgumentType.NUMBER, defaultValue: 0.5 } }, hideFromPalette: true }, { opcode: 'whenDistance', text: formatMessage({ id: 'wedo2.whenDistance', default: 'when distance [OP] [REFERENCE]', description: 'check for when distance is < or > than reference' }), blockType: BlockType.HAT, arguments: { OP: { type: ArgumentType.STRING, menu: 'OP', defaultValue: '<' }, REFERENCE: { type: ArgumentType.NUMBER, defaultValue: 50 } } }, { opcode: 'whenTilted', text: formatMessage({ id: 'wedo2.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: WeDo2TiltDirection.ANY } } }, { opcode: 'getDistance', text: formatMessage({ id: 'wedo2.getDistance', default: 'distance', description: 'the value returned by the distance sensor' }), blockType: BlockType.REPORTER }, { opcode: 'isTilted', text: formatMessage({ id: 'wedo2.isTilted', default: 'tilted [TILT_DIRECTION_ANY]?', description: 'whether the tilt sensor is tilted' }), blockType: BlockType.BOOLEAN, arguments: { TILT_DIRECTION_ANY: { type: ArgumentType.STRING, menu: 'TILT_DIRECTION_ANY', defaultValue: WeDo2TiltDirection.ANY } } }, { opcode: 'getTiltAngle', text: formatMessage({ id: 'wedo2.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: WeDo2TiltDirection.UP } } } ], menus: { MOTOR_ID: [ { text: formatMessage({ id: 'wedo2.motorId.default', default: 'motor', description: 'label for motor element in motor menu for LEGO WeDo 2 extension' }), value: WeDo2MotorLabel.DEFAULT }, { text: formatMessage({ id: 'wedo2.motorId.a', default: 'motor A', description: 'label for motor A element in motor menu for LEGO WeDo 2 extension' }), value: WeDo2MotorLabel.A }, { text: formatMessage({ id: 'wedo2.motorId.b', default: 'motor B', description: 'label for motor B element in motor menu for LEGO WeDo 2 extension' }), value: WeDo2MotorLabel.B }, { text: formatMessage({ id: 'wedo2.motorId.all', default: 'all motors', description: 'label for all motors element in motor menu for LEGO WeDo 2 extension' }), value: WeDo2MotorLabel.ALL } ], MOTOR_DIRECTION: [ { text: formatMessage({ id: 'wedo2.motorDirection.forward', default: 'this way', description: 'label for forward element in motor direction menu for LEGO WeDo 2 extension' }), value: WeDo2MotorDirection.FORWARD }, { text: formatMessage({ id: 'wedo2.motorDirection.backward', default: 'that way', description: 'label for backward element in motor direction menu for LEGO WeDo 2 extension' }), value: WeDo2MotorDirection.BACKWARD }, { text: formatMessage({ id: 'wedo2.motorDirection.reverse', default: 'reverse', description: 'label for reverse element in motor direction menu for LEGO WeDo 2 extension' }), value: WeDo2MotorDirection.REVERSE } ], TILT_DIRECTION: [ { text: formatMessage({ id: 'wedo2.tiltDirection.up', default: 'up', description: 'label for up element in tilt direction menu for LEGO WeDo 2 extension' }), value: WeDo2TiltDirection.UP }, { text: formatMessage({ id: 'wedo2.tiltDirection.down', default: 'down', description: 'label for down element in tilt direction menu for LEGO WeDo 2 extension' }), value: WeDo2TiltDirection.DOWN }, { text: formatMessage({ id: 'wedo2.tiltDirection.left', default: 'left', description: 'label for left element in tilt direction menu for LEGO WeDo 2 extension' }), value: WeDo2TiltDirection.LEFT }, { text: formatMessage({ id: 'wedo2.tiltDirection.right', default: 'right', description: 'label for right element in tilt direction menu for LEGO WeDo 2 extension' }), value: WeDo2TiltDirection.RIGHT } ], TILT_DIRECTION_ANY: [ { text: formatMessage({ id: 'wedo2.tiltDirection.up', default: 'up' }), value: WeDo2TiltDirection.UP }, { text: formatMessage({ id: 'wedo2.tiltDirection.down', default: 'down' }), value: WeDo2TiltDirection.DOWN }, { text: formatMessage({ id: 'wedo2.tiltDirection.left', default: 'left' }), value: WeDo2TiltDirection.LEFT }, { text: formatMessage({ id: 'wedo2.tiltDirection.right', default: 'right' }), value: WeDo2TiltDirection.RIGHT }, { text: formatMessage({ id: 'wedo2.tiltDirection.any', default: 'any', description: 'label for any element in tilt direction menu for LEGO WeDo 2 extension' }), value: WeDo2TiltDirection.ANY } ], OP: ['<', '>'] } }; } /** * 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 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.turnOn(); } }); return new Promise(resolve => { window.setTimeout(() => { resolve(); }, BLESendInterval); }); } /** * 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(); }, BLESendInterval); }); } /** * 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). * @return {Promise} - a Promise that resolves after some delay. */ startMotorPower (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); motor.turnOn(); } }); return new Promise(resolve => { window.setTimeout(() => { resolve(); }, BLESendInterval); }); } /** * 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} - a Promise that resolves after some delay. */ 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 WeDo2MotorDirection.FORWARD: motor.direction = 1; break; case WeDo2MotorDirection.BACKWARD: motor.direction = -1; break; case WeDo2MotorDirection.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.isOn) { if (motor.pendingTimeoutDelay) { motor.turnOnFor(motor.pendingTimeoutStartTime + motor.pendingTimeoutDelay - Date.now()); } else { motor.turnOn(); } } } }); return new Promise(resolve => { window.setTimeout(() => { resolve(); }, BLESendInterval); }); } /** * 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.setLED(rgbDecimal); return new Promise(resolve => { window.setTimeout(() => { resolve(); }, BLESendInterval); }); } /** * Make the WeDo 2.0 peripheral 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) { let durationMS = Cast.toNumber(args.DURATION) * 1000; durationMS = MathUtil.clamp(durationMS, 0, 3000); const note = MathUtil.clamp(Cast.toNumber(args.NOTE), 25, 125); // valid WeDo 2.0 sounds if (durationMS === 0) return; // WeDo 2.0 plays duration '0' forever return new Promise(resolve => { const tone = this._noteToTone(note); this._peripheral.playTone(tone, durationMS); // Run for some time even when no piezo 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._peripheral.distance < Cast.toNumber(args.REFERENCE); case '>': return this._peripheral.distance > Cast.toNumber(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} 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); } /** * @return {number} - the distance sensor's value, scaled to the [0,100] range. */ getDistance () { return this._peripheral.distance; } /** * 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 WeDo2TiltDirection.ANY: return (Math.abs(this._peripheral.tiltX) >= Scratch3WeDo2Blocks.TILT_THRESHOLD) || (Math.abs(this._peripheral.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 WeDo2TiltDirection.UP: return this._peripheral.tiltY > 45 ? 256 - this._peripheral.tiltY : -this._peripheral.tiltY; case WeDo2TiltDirection.DOWN: return this._peripheral.tiltY > 45 ? this._peripheral.tiltY - 256 : this._peripheral.tiltY; case WeDo2TiltDirection.LEFT: return this._peripheral.tiltX > 45 ? 256 - this._peripheral.tiltX : -this._peripheral.tiltX; case WeDo2TiltDirection.RIGHT: return this._peripheral.tiltX > 45 ? this._peripheral.tiltX - 256 : this._peripheral.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 WeDo2MotorLabel.A: motors = [0]; break; case WeDo2MotorLabel.B: motors = [1]; break; case WeDo2MotorLabel.ALL: case WeDo2MotorLabel.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;