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 uid = require('../../util/uid'); const BT = require('../../io/bt'); 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 blockIconURI = 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyB3aWR0aD0iNDBweCIgaGVpZ2h0PSI0MHB4IiB2aWV3Qm94PSIwIDAgNDAgNDAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDUwLjIgKDU1MDQ3KSAtIGh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaCAtLT4KICAgIDx0aXRsZT5ldjMtYmxvY2staWNvbjwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxkZWZzPjwvZGVmcz4KICAgIDxnIGlkPSJldjMtYmxvY2staWNvbiIgc3Ryb2tlPSJub25lIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+CiAgICAgICAgPGcgaWQ9ImV2MyIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoNS41MDAwMDAsIDMuNTAwMDAwKSIgZmlsbC1ydWxlPSJub256ZXJvIj4KICAgICAgICAgICAgPHJlY3QgaWQ9IlJlY3RhbmdsZS1wYXRoIiBzdHJva2U9IiM3Qzg3QTUiIGZpbGw9IiNGRkZGRkYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgeD0iMC41IiB5PSIzLjU5IiB3aWR0aD0iMjgiIGhlaWdodD0iMjUuODEiIHJ4PSIxIj48L3JlY3Q+CiAgICAgICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUtcGF0aCIgc3Ryb2tlPSIjN0M4N0E1IiBmaWxsPSIjRTZFN0U4IiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHg9IjIuNSIgeT0iMC41IiB3aWR0aD0iMjQiIGhlaWdodD0iMzIiIHJ4PSIxIj48L3JlY3Q+CiAgICAgICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUtcGF0aCIgc3Ryb2tlPSIjN0M4N0E1IiBmaWxsPSIjRkZGRkZGIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIHg9IjIuNSIgeT0iMTQuNSIgd2lkdGg9IjI0IiBoZWlnaHQ9IjEzIj48L3JlY3Q+CiAgICAgICAgICAgIDxwYXRoIGQ9Ik0xNC41LDEwLjUgTDE0LjUsMTQuNSIgaWQ9IlNoYXBlIiBzdHJva2U9IiM3Qzg3QTUiIGZpbGw9IiNFNkU3RTgiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PC9wYXRoPgogICAgICAgICAgICA8cmVjdCBpZD0iUmVjdGFuZ2xlLXBhdGgiIGZpbGw9IiM0MTQ3NTciIHg9IjQuNSIgeT0iMi41IiB3aWR0aD0iMjAiIGhlaWdodD0iMTAiIHJ4PSIxIj48L3JlY3Q+CiAgICAgICAgICAgIDxyZWN0IGlkPSJSZWN0YW5nbGUtcGF0aCIgZmlsbD0iIzdDODdBNSIgb3BhY2l0eT0iMC41IiB4PSIxMy41IiB5PSIyMC4xMyIgd2lkdGg9IjIiIGhlaWdodD0iMiIgcng9IjAuNSI+PC9yZWN0PgogICAgICAgICAgICA8cGF0aCBkPSJNOS4wNiwyMC4xMyBMMTAuNTYsMjAuMTMgQzEwLjgzNjE0MjQsMjAuMTMgMTEuMDYsMjAuMzUzODU3NiAxMS4wNiwyMC42MyBMMTEuMDYsMjEuNjMgQzExLjA2LDIxLjkwNjE0MjQgMTAuODM2MTQyNCwyMi4xMyAxMC41NiwyMi4xMyBMOS4wNiwyMi4xMyBDOC41MDc3MTUyNSwyMi4xMyA4LjA2LDIxLjY4MjI4NDcgOC4wNiwyMS4xMyBDOC4wNiwyMC41Nzc3MTUzIDguNTA3NzE1MjUsMjAuMTMgOS4wNiwyMC4xMyBaIiBpZD0iU2hhcGUiIGZpbGw9IiM3Qzg3QTUiIG9wYWNpdHk9IjAuNSI+PC9wYXRoPgogICAgICAgICAgICA8cGF0aCBkPSJNMTguOTEsMjAuMTMgTDIwLjQyLDIwLjEzIEMyMC42OTYxNDI0LDIwLjEzIDIwLjkyLDIwLjM1Mzg1NzYgMjAuOTIsMjAuNjMgTDIwLjkyLDIxLjYzIEMyMC45MiwyMS45MDYxNDI0IDIwLjY5NjE0MjQsMjIuMTMgMjAuNDIsMjIuMTMgTDE4LjkyLDIyLjEzIEMxOC4zNjc3MTUzLDIyLjEzIDE3LjkyLDIxLjY4MjI4NDcgMTcuOTIsMjEuMTMgQzE3LjkxOTk3MjYsMjAuNTgxNTk3IDE4LjM2MTYyNDUsMjAuMTM1NDg0IDE4LjkxLDIwLjEzIFoiIGlkPSJTaGFwZSIgZmlsbD0iIzdDODdBNSIgb3BhY2l0eT0iMC41IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxOS40MjAwMDAsIDIxLjEzMDAwMCkgcm90YXRlKC0xODAuMDAwMDAwKSB0cmFuc2xhdGUoLTE5LjQyMDAwMCwgLTIxLjEzMDAwMCkgIj48L3BhdGg+CiAgICAgICAgICAgIDxwYXRoIGQ9Ik04LjIzLDE3LjUgTDUsMTcuNSBDNC43MjM4NTc2MywxNy41IDQuNSwxNy4yNzYxNDI0IDQuNSwxNyBMNC41LDE0LjUgTDEwLjUsMTQuNSBMOC42NSwxNy4yOCBDOC41NTQ2Njk2MSwxNy40MTc5MDgyIDguMzk3NjUwMDYsMTcuNTAwMTU2NiA4LjIzLDE3LjUgWiIgaWQ9IlNoYXBlIiBmaWxsPSIjN0M4N0E1IiBvcGFjaXR5PSIwLjUiPjwvcGF0aD4KICAgICAgICAgICAgPHBhdGggZD0iTTE4LjE1LDE4Ljg1IEwxNy42NSwxOS4zNSBDMTcuNTUyMzQxNiwxOS40NDQwNzU2IDE3LjQ5ODAzMzksMTkuNTc0NDE0MiAxNy41LDE5LjcxIEwxNy41LDIwIEMxNy41LDIwLjI3NjE0MjQgMTcuMjc2MTQyNCwyMC41IDE3LDIwLjUgTDE2LjUsMjAuNSBDMTYuMjIzODU3NiwyMC41IDE2LDIwLjI3NjE0MjQgMTYsMjAgQzE2LDE5LjcyMzg1NzYgMTUuNzc2MTQyNCwxOS41IDE1LjUsMTkuNSBMMTMuNSwxOS41IEMxMy4yMjM4NTc2LDE5LjUgMTMsMTkuNzIzODU3NiAxMywyMCBDMTMsMjAuMjc2MTQyNCAxMi43NzYxNDI0LDIwLjUgMTIuNSwyMC41IEwxMiwyMC41IEMxMS43MjM4NTc2LDIwLjUgMTEuNSwyMC4yNzYxNDI0IDExLjUsMjAgTDExLjUsMTkuNzEgQzExLjUwMTk2NjEsMTkuNTc0NDE0MiAxMS40NDc2NTg0LDE5LjQ0NDA3NTYgMTEuMzUsMTkuMzUgTDEwLjg1LDE4Ljg1IEMxMC42NTgyMTY3LDE4LjY1MjE4NjMgMTAuNjU4MjE2NywxOC4zMzc4MTM3IDEwLjg1LDE4LjE0IEwxMi4zNiwxNi42NSBDMTIuNDUwMjgwMywxNi41NTI4NjE3IDEyLjU3NzM5NjEsMTYuNDk4MzgzNSAxMi43MSwxNi41IEwxNi4yOSwxNi41IEMxNi40MjI2MDM5LDE2LjQ5ODM4MzUgMTYuNTQ5NzE5NywxNi41NTI4NjE3IDE2LjY0LDE2LjY1IEwxOC4xNSwxOC4xNCBDMTguMzQxNzgzMywxOC4zMzc4MTM3IDE4LjM0MTc4MzMsMTguNjUyMTg2MyAxOC4xNSwxOC44NSBaIiBpZD0iU2hhcGUiIGZpbGw9IiM3Qzg3QTUiIG9wYWNpdHk9IjAuNSI+PC9wYXRoPgogICAgICAgICAgICA8cGF0aCBkPSJNMTAuODUsMjMuNDUgTDExLjM1LDIyLjk1IEMxMS40NDc2NTg0LDIyLjg1NTkyNDQgMTEuNTAxOTY2MSwyMi43MjU1ODU4IDExLjUsMjIuNTkgTDExLjUsMjIuMyBDMTEuNSwyMi4wMjM4NTc2IDExLjcyMzg1NzYsMjEuOCAxMiwyMS44IEwxMi41LDIxLjggQzEyLjc3NjE0MjQsMjEuOCAxMywyMi4wMjM4NTc2IDEzLDIyLjMgQzEzLDIyLjU3NjE0MjQgMTMuMjIzODU3NiwyMi44IDEzLjUsMjIuOCBMMTUuNSwyMi44IEMxNS43NzYxNDI0LDIyLjggMTYsMjIuNTc2MTQyNCAxNiwyMi4zIEMxNiwyMi4wMjM4NTc2IDE2LjIyMzg1NzYsMjEuOCAxNi41LDIxLjggTDE3LDIxLjggQzE3LjI3NjE0MjQsMjEuOCAxNy41LDIyLjAyMzg1NzYgMTcuNSwyMi4zIEwxNy41LDIyLjU5IEMxNy40OTgwMzM5LDIyLjcyNTU4NTggMTcuNTUyMzQxNiwyMi44NTU5MjQ0IDE3LjY1LDIyLjk1IEwxOC4xNSwyMy40NSBDMTguMzQwNTcxNCwyMy42NDQ0MjE4IDE4LjM0MDU3MTQsMjMuOTU1NTc4MiAxOC4xNSwyNC4xNSBMMTYuNjQsMjUuNjUgQzE2LjU0OTcxOTcsMjUuNzQ3MTM4MyAxNi40MjI2MDM5LDI1LjgwMTYxNjUgMTYuMjksMjUuOCBMMTIuNzEsMjUuOCBDMTIuNTc3Mzk2MSwyNS44MDE2MTY1IDEyLjQ1MDI4MDMsMjUuNzQ3MTM4MyAxMi4zNiwyNS42NSBMMTAuODUsMjQuMTUgQzEwLjY1OTQyODYsMjMuOTU1NTc4MiAxMC42NTk0Mjg2LDIzLjY0NDQyMTggMTAuODUsMjMuNDUgWiIgaWQ9IlNoYXBlIiBmaWxsPSIjN0M4N0E1IiBvcGFjaXR5PSIwLjUiPjwvcGF0aD4KICAgICAgICAgICAgPHBhdGggZD0iTTIxLjUsMjcuNSBMMjYuNSwyNy41IEwyNi41LDMxLjUgQzI2LjUsMzIuMDUyMjg0NyAyNi4wNTIyODQ3LDMyLjUgMjUuNSwzMi41IEwyMS41LDMyLjUgTDIxLjUsMjcuNSBaIiBpZD0iU2hhcGUiIHN0cm9rZT0iI0NDNEMyMyIgZmlsbD0iI0YxNUEyOSIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48L3BhdGg+CiAgICAgICAgPC9nPgogICAgPC9nPgo8L3N2Zz4='; /** * String with Ev3 expected pairing pin. * @readonly */ const Ev3PairingPin = '1234'; /** * A maximum number of BT message sends per second, to be enforced by the rate limiter. * @type {number} */ const BTSendRateMax = 40; /** * Enum for Ev3 direct command types. * Found in the 'EV3 Communication Developer Kit', section 4, page 24, at * https://education.lego.com/en-us/support/mindstorms-ev3/developer-kits. * @readonly * @enum {number} */ const Ev3Command = { DIRECT_COMMAND_REPLY: 0x00, DIRECT_COMMAND_NO_REPLY: 0x80, DIRECT_REPLY: 0x02 }; /** * Enum for Ev3 commands opcodes. * Found in the 'EV3 Firmware Developer Kit', section 4, page 10, at * https://education.lego.com/en-us/support/mindstorms-ev3/developer-kits. * @readonly * @enum {number} */ const Ev3Opcode = { OPOUTPUT_STEP_SPEED: 0xAE, OPOUTPUT_TIME_SPEED: 0xAF, OPOUTPUT_STOP: 0xA3, OPOUTPUT_RESET: 0xA2, OPOUTPUT_STEP_SYNC: 0xB0, OPOUTPUT_TIME_SYNC: 0xB1, OPOUTPUT_GET_COUNT: 0xB3, OPSOUND: 0x94, OPSOUND_CMD_TONE: 1, OPSOUND_CMD_STOP: 0, OPINPUT_DEVICE_LIST: 0x98, OPINPUT_READSI: 0x9D }; /** * Enum for Ev3 values used as arguments to various opcodes. * Found in the 'EV3 Firmware Developer Kit', section4, page 10, at * https://education.lego.com/en-us/support/mindstorms-ev3/developer-kits. * @readonly * @enum {string} */ const Ev3Value = { LAYER: 0x00, // always 0, chained EV3s not supported NUM8: 0x81, // "1 byte to follow" NUM16: 0x82, // "2 bytes to follow" NUM32: 0x83, // "4 bytes to follow" COAST: 0x00, BRAKE: 0x01, LONG_RAMP: 50, DO_NOT_CHANGE_TYPE: 0 }; /** * Enum for Ev3 device type numbers. * Found in the 'EV3 Firmware Developer Kit', section 5, page 100, at * https://education.lego.com/en-us/support/mindstorms-ev3/developer-kits. * @readonly * @enum {string} */ const Ev3Device = { 29: 'color', 30: 'ultrasonic', 32: 'gyro', 16: 'touch', 8: 'mediumMotor', 7: 'largeMotor', 126: 'none', 125: 'none' }; /** * Enum for Ev3 device modes. * Found in the 'EV3 Firmware Developer Kit', section 5, page 100, at * https://education.lego.com/en-us/support/mindstorms-ev3/developer-kits. * @readonly * @enum {number} */ const Ev3Mode = { touch: 0, // touch color: 1, // ambient ultrasonic: 1, // inch none: 0 }; /** * Enum for Ev3 device labels used in the Scratch blocks/UI. * @readonly * @enum {string} */ const Ev3Label = { // TODO: rename? touch: 'button', color: 'brightness', ultrasonic: 'distance' }; /** * Manage power, direction, and timers for one EV3 motor. */ class EV3Motor { /** * Construct a EV3 Motor instance, which could be of type 'largeMotor' or * 'mediumMotor'. * * @param {EV3} parent - the EV3 peripheral which owns this motor. * @param {int} index - the zero-based index of this motor on its parent peripheral. * @param {string} type - the type of motor (i.e. 'largeMotor' or 'mediumMotor'). */ constructor (parent, index, type) { /** * The EV3 peripheral which owns this motor. * @type {EV3} * @private */ this._parent = parent; /** * The zero-based index of this motor on its parent peripheral. * @type {int} * @private */ this._index = index; /** * The type of EV3 motor this could be: 'largeMotor' or 'mediumMotor'. * @type {string} * @private */ this._type = type; /** * This motor's current direction: 1 for "clockwise" or -1 for "counterclockwise" * @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 position, in the range [0,360]. * @type {number} * @private */ this._position = 0; /** * An ID for the current coast command, to help override multiple coast * commands sent in succession. * @type {number} * @private */ this._commandID = null; /** * A delay, in milliseconds, to add to coasting, to make sure that a brake * first takes effect if one was sent. * @type {number} * @private */ this._coastDelay = 1000; } /** * @return {string} - this motor's type: 'largeMotor' or 'mediumMotor' */ get type () { return this._type; } /** * @param {string} value - this motor's new type: 'largeMotor' or 'mediumMotor' */ set type (value) { this._type = value; } /** * @return {int} - this motor's current direction: 1 for "clockwise" or -1 for "counterclockwise" */ get direction () { return this._direction; } /** * @param {int} value - this motor's new direction: 1 for "clockwise" or -1 for "counterclockwise" */ 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 = value; } /** * @return {int} - this motor's current position, in the range [-inf,inf]. */ get position () { return this._position; } /** * @param {int} array - this motor's new position, in the range [0,360]. */ set position (array) { // tachoValue from Paula let value = array[0] + (array[1] * 256) + (array[2] * 256 * 256) + (array[3] * 256 * 256 * 256); if (value > 0x7fffffff) { value = value - 0x100000000; } this._position = value; } /** * Turn this motor on for a specific duration. * Found in the 'EV3 Firmware Developer Kit', page 56, at * https://education.lego.com/en-us/support/mindstorms-ev3/developer-kits. * * Opcode arguments: * (Data8) LAYER – Specify chain layer number [0 - 3] * (Data8) NOS – Output bit field [0x00 – 0x0F] * (Data8) SPEED – Power level, [-100 – 100] * (Data32) STEP1 – Time in milliseconds for ramp up * (Data32) STEP2 – Time in milliseconds for continues run * (Data32) STEP3 – Time in milliseconds for ramp down * (Data8) BRAKE - Specify break level [0: Float, 1: Break] * * @param {number} milliseconds - run the motor for this long. */ turnOnFor (milliseconds) { if (this._power === 0) return; const port = this._portMask(this._index); let n = milliseconds; let speed = this._power * this._direction; const ramp = Ev3Value.LONG_RAMP; let byteCommand = []; byteCommand[0] = Ev3Opcode.OPOUTPUT_TIME_SPEED; // If speed is less than zero, make it positive and multiply the input // value by -1 if (speed < 0) { speed = -1 * speed; n = -1 * n; } // If the input value is less than 0 const dir = (n < 0) ? 0x100 - speed : speed; // step negative or positive n = Math.abs(n); // Setup motor run duration and ramping behavior let rampup = ramp; let rampdown = ramp; let run = n - (ramp * 2); if (run < 0) { rampup = Math.floor(n / 2); run = 0; rampdown = n - rampup; } // Generate motor command values const runcmd = this._runValues(run); byteCommand = byteCommand.concat([ Ev3Value.LAYER, port, Ev3Value.NUM8, dir & 0xff, Ev3Value.NUM8, rampup ]).concat(runcmd.concat([ Ev3Value.NUM8, rampdown, Ev3Value.BRAKE ])); const cmd = this._parent.generateCommand( Ev3Command.DIRECT_COMMAND_NO_REPLY, byteCommand ); this._parent.send(cmd); this.coastAfter(milliseconds); } /** * Set the motor to coast after a specified amount of time. * TODO: rename this startBraking? * @param {number} time - the time in milliseconds. */ coastAfter (time) { if (this._power === 0) return; // Set the motor command id to check before starting coast const commandId = uid(); this._commandID = commandId; // Send coast message setTimeout(() => { // Do not send coast if another motor command changed the command id. if (this._commandID === commandId) { this.coast(); this._commandID = null; } }, time + this._coastDelay); // add a delay so the brake takes effect } /** * Set the motor to coast. */ coast () { if (this._power === 0) return; const cmd = this._parent.generateCommand( Ev3Command.DIRECT_COMMAND_NO_REPLY, [ Ev3Opcode.OPOUTPUT_STOP, Ev3Value.LAYER, this._portMask(this._index), // port output bit field Ev3Value.COAST ] ); this._parent.send(cmd, false); // don't use rate limiter to ensure motor stops } /** * Generate motor run values for a given input. * @param {number} run - run input. * @return {array} - run values as a byte array. */ _runValues (run) { // If run duration is less than max 16-bit integer if (run < 0x7fff) { return [ Ev3Value.NUM16, run & 0xff, (run >> 8) & 0xff ]; } // Run forever return [ Ev3Value.NUM32, run & 0xff, (run >> 8) & 0xff, (run >> 16) & 0xff, (run >> 24) & 0xff ]; } /** * Return a port value for the EV3 that is in the format for 'output bit field' * as 1/2/4/8, generally needed for motor ports, instead of the typical 0/1/2/3. * The documentation in the 'EV3 Firmware Developer Kit' for motor port arguments * is sometimes mistaken, but we believe motor ports are mostly addressed this way. * @param {number} port - the port number to convert to an 'output bit field'. * @return {number} - the converted port number. */ _portMask (port) { return Math.pow(2, port); } } class EV3 { 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 names of the sensors connected in ports 1,2,3,4. * @type {string[]} * @private */ this._sensorPorts = []; /** * A list of the names of the motors connected in ports A,B,C,D. * @type {string[]} * @private */ this._motorPorts = []; /** * The state of all sensor values. * @type {string[]} * @private */ this._sensors = { distance: 0, brightness: 0, buttons: [0, 0, 0, 0] }; /** * The motors which this EV3 could possibly have connected. * @type {string[]} * @private */ this._motors = [null, null, null, null]; /** * The polling interval, in milliseconds. * @type {number} * @private */ this._pollingInterval = 150; /** * The polling interval ID. * @type {number} * @private */ this._pollingIntervalID = null; /** * The counter keeping track of polling cycles. * @type {string[]} * @private */ this._pollingCounter = 0; /** * The Bluetooth socket connection for reading/writing peripheral data. * @type {BT} * @private */ this._bt = null; this._runtime.registerPeripheralExtension(extensionId, this); /** * A rate limiter utility, to help limit the rate at which we send BT messages * over the socket to Scratch Link to a maximum number of sends per second. * @type {RateLimiter} * @private */ this._rateLimiter = new RateLimiter(BTSendRateMax); this.reset = this.reset.bind(this); this._onConnect = this._onConnect.bind(this); this._onMessage = this._onMessage.bind(this); this._pollValues = this._pollValues.bind(this); } get distance () { let value = this._sensors.distance > 100 ? 100 : this._sensors.distance; value = value < 0 ? 0 : value; value = Math.round(100 * value) / 100; return value; } get brightness () { return this._sensors.brightness; } /** * Access a particular motor on this peripheral. * @param {int} index - the zero-based index of the desired motor. * @return {EV3Motor} - the EV3Motor instance, if any, at that index. */ motor (index) { return this._motors[index]; } isButtonPressed (port) { return this._sensors.buttons[port] === 1; } beep (freq, time) { const cmd = this.generateCommand( Ev3Command.DIRECT_COMMAND_NO_REPLY, [ Ev3Opcode.OPSOUND, Ev3Opcode.OPSOUND_CMD_TONE, Ev3Value.NUM8, 2, Ev3Value.NUM16, freq, freq >> 8, Ev3Value.NUM16, time, time >> 8 ] ); this.send(cmd); } stopAll () { this.stopAllMotors(); this.stopSound(); } stopSound () { const cmd = this.generateCommand( Ev3Command.DIRECT_COMMAND_NO_REPLY, [ Ev3Opcode.OPSOUND, Ev3Opcode.OPSOUND_CMD_STOP ] ); this.send(cmd, false); // don't use rate limiter to ensure sound stops } stopAllMotors () { this._motors.forEach(motor => { if (motor) { motor.coast(); } }); } /** * Called by the runtime when user wants to scan for an EV3 peripheral. */ scan () { if (this._bt) { this._bt.disconnect(); } this._bt = new BT(this._runtime, this._extensionId, { majorDeviceClass: 8, minorDeviceClass: 1 }, this._onConnect, this.reset, this._onMessage); } /** * Called by the runtime when user wants to connect to a certain EV3 peripheral. * @param {number} id - the id of the peripheral to connect to. */ connect (id) { if (this._bt) { this._bt.connectPeripheral(id, Ev3PairingPin); } } /** * Called by the runtime when user wants to disconnect from the EV3 peripheral. */ disconnect() { console.log('EV3 DISCONNECT CALLED'); if (this._bt) { this._bt.disconnect(); } this.reset(); } /** * Reset all the state and timeout/interval ids. */ reset() { console.log('EV3 RESET CALLED'); this._sensorPorts = []; this._motorPorts = []; this._sensors = { distance: 0, brightness: 0, buttons: [0, 0, 0, 0] }; this._motors = [null, null, null, null]; if (this._pollingIntervalID) { window.clearInterval(this._pollingIntervalID); this._pollingIntervalID = null; } } /** * Called by the runtime to detect whether the EV3 peripheral is connected. * @return {boolean} - the connected state. */ isConnected () { let connected = false; if (this._bt) { connected = this._bt.isConnected(); } return connected; } /** * Send a message to the peripheral BT socket. * @param {Uint8Array} message - the message to send. * @param {boolean} [useLimiter=true] - if true, use the rate limiter * @return {Promise} - a promise result of the send operation. */ send (message, useLimiter = true) { if (!this.isConnected()) return Promise.resolve(); if (useLimiter) { if (!this._rateLimiter.okayToSend()) return Promise.resolve(); } return this._bt.sendMessage({ message: Base64Util.uint8ArrayToBase64(message), encoding: 'base64' }); } /** * Genrates direct commands that are sent to the EV3 as a single or compounded byte arrays. * See 'EV3 Communication Developer Kit', section 4, page 24 at * https://education.lego.com/en-us/support/mindstorms-ev3/developer-kits. * * Direct commands are one of two types: * DIRECT_COMMAND_NO_REPLY = a direct command where no reply is expected * DIRECT_COMMAND_REPLY = a direct command where a reply is expected, and the * number and length of returned values needs to be specified. * * The direct command byte array sent takes the following format: * Byte 0 - 1: Command size, Little Endian. Command size not including these 2 bytes * Byte 2 - 3: Message counter, Little Endian. Forth running counter * Byte 4: Command type. Either DIRECT_COMMAND_REPLY or DIRECT_COMMAND_NO_REPLY * Byte 5 - 6: Reservation (allocation) of global and local variables using a compressed format * (globals reserved in byte 5 and the 2 lsb of byte 6, locals reserved in the upper * 6 bits of byte 6) – see documentation for more details. * Byte 7 - n: Byte codes as a single command or compound commands (I.e. more commands composed * as a small program) * * @param {number} type - the direct command type. * @param {string} byteCommands - a compound array of EV3 Opcode + arguments. * @param {number} allocation - the allocation of global and local vars needed for replies. * @return {array} - generated complete command byte array, with header and compounded commands. */ generateCommand (type, byteCommands, allocation = 0) { // Header (Bytes 0 - 6) let command = []; command[2] = 0; // Message counter unused for now command[3] = 0; // Message counter unused for now command[4] = type; command[5] = allocation & 0xFF; command[6] = allocation >> 8 && 0xFF; // Bytecodes (Bytes 7 - n) command = command.concat(byteCommands); // Calculate command length minus first two header bytes const len = command.length - 2; command[0] = len & 0xFF; command[1] = len >> 8 && 0xFF; return command; } /** * When the EV3 peripheral connects, start polling for sensor and motor values. * @private */ _onConnect () { this._pollingIntervalID = window.setInterval(this._pollValues, this._pollingInterval); } /** * Poll the EV3 for sensor and motor input values, based on the list of * known connected sensors and motors. This is sent as many compound commands * in a direct command, with a reply expected. * * See 'EV3 Firmware Developer Kit', section 4.8, page 46, at * https://education.lego.com/en-us/support/mindstorms-ev3/developer-kits * for a list of polling/input device commands and their arguments. * * @private */ _pollValues () { if (!this.isConnected()) { window.clearInterval(this._pollingIntervalID); return; } const byteCommands = []; // a compound command let allocation = 0; let sensorCount = 0; // For the command to send, either request device list or request sensor data // based on the polling counter value. (i.e., reset the list of devices every // 20 counts). if (this._pollingCounter % 20 === 0) { // GET DEVICE LIST byteCommands[0] = Ev3Opcode.OPINPUT_DEVICE_LIST; byteCommands[1] = Ev3Value.NUM8; // 1 byte to follow byteCommands[2] = 33; // 0x21 ARRAY // TODO: document byteCommands[3] = 96; // 0x60 CHANGED // TODO: document byteCommands[4] = 225; // 0xE1 size of global var - 1 byte to follow // TODO: document byteCommands[5] = 32; // 0x20 global var index "0" 0b00100000 // TODO: document // Command and payload lengths allocation = 33; this._updateDevices = true; // TODO: need to clar sensor data? } else { // GET SENSOR VALUES FOR CONNECTED SENSORS let index = 0; // eslint-disable-next-line no-undefined if (!this._sensorPorts.includes(undefined)) { // TODO: why is this needed? for (let i = 0; i < 4; i++) { if (this._sensorPorts[i] !== 'none') { byteCommands[index + 0] = Ev3Opcode.OPINPUT_READSI; byteCommands[index + 1] = Ev3Value.LAYER; byteCommands[index + 2] = i; // PORT byteCommands[index + 3] = Ev3Value.DO_NOT_CHANGE_TYPE; byteCommands[index + 4] = Ev3Mode[this._sensorPorts[i]]; byteCommands[index + 5] = 225; // 0xE1 one byte to follow // TODO: document byteCommands[index + 6] = sensorCount * 4; // global index // TODO: document index += 7; } sensorCount++; } } // GET MOTOR POSITION VALUES, EVEN IF NO MOTOR PRESENT // eslint-disable-next-line no-undefined if (!this._motorPorts.includes(undefined)) { for (let i = 0; i < 4; i++) { byteCommands[index + 0] = Ev3Opcode.OPOUTPUT_GET_COUNT; byteCommands[index + 1] = Ev3Value.LAYER; byteCommands[index + 2] = i; // PORT TODO: explain incorrect documentation as 'Output bit field' byteCommands[index + 3] = 225; // 0xE1 byte following TODO: document byteCommands[index + 4] = sensorCount * 4; // global index TODO: document index += 5; sensorCount++; } } // Command and payload lengths allocation = sensorCount * 4; } const cmd = this.generateCommand( Ev3Command.DIRECT_COMMAND_REPLY, byteCommands, allocation ); this.send(cmd); this._pollingCounter++; } /** * Message handler for incoming EV3 reply messages, either a list of connected * devices (sensors and motors) or the values of the connected sensors and motors. * * See 'EV3 Communication Developer Kit', section 4.1, page 24 at * https://education.lego.com/en-us/support/mindstorms-ev3/developer-kits * for more details on direct reply formats. * * The direct reply byte array sent takes the following format: * Byte 0 – 1: Reply size, Little Endian. Reply size not including these 2 bytes * Byte 2 – 3: Message counter, Little Endian. Equals the Direct Command * Byte 4: Reply type. Either DIRECT_REPLY or DIRECT_REPLY_ERROR * Byte 5 - n: Resonse buffer. I.e. the content of the by the Command reserved global variables. * I.e. if the command reserved 64 bytes, these bytes will be placed in the reply * packet as the bytes 5 to 68. * * See 'EV3 Firmware Developer Kit', section 4.8, page 56 at * https://education.lego.com/en-us/support/mindstorms-ev3/developer-kits * for direct response buffer formats for various commands. * * @param {object} params - incoming message parameters * @private */ _onMessage (params) { const message = params.message; const data = Base64Util.base64ToUint8Array(message); // log.info(`received array: ${array}`); // TODO: Is this the correct check? if (data[4] !== Ev3Command.DIRECT_REPLY) { return; } if (this._updateDevices) { // ***************** // PARSE DEVICE LIST // ***************** // TODO: put these in for loop? this._sensorPorts[0] = Ev3Device[data[5]] ? Ev3Device[data[5]] : 'none'; this._sensorPorts[1] = Ev3Device[data[6]] ? Ev3Device[data[6]] : 'none'; this._sensorPorts[2] = Ev3Device[data[7]] ? Ev3Device[data[7]] : 'none'; this._sensorPorts[3] = Ev3Device[data[8]] ? Ev3Device[data[8]] : 'none'; this._motorPorts[0] = Ev3Device[data[21]] ? Ev3Device[data[21]] : 'none'; this._motorPorts[1] = Ev3Device[data[22]] ? Ev3Device[data[22]] : 'none'; this._motorPorts[2] = Ev3Device[data[23]] ? Ev3Device[data[23]] : 'none'; this._motorPorts[3] = Ev3Device[data[24]] ? Ev3Device[data[24]] : 'none'; for (let m = 0; m < 4; m++) { const type = this._motorPorts[m]; if (type !== 'none' && !this._motors[m]) { // add new motor if don't already have one this._motors[m] = new EV3Motor(this, m, type); } if (type === 'none' && this._motors[m]) { // clear old motor this._motors[m] = null; } } this._updateDevices = false; // eslint-disable-next-line no-undefined } else if (!this._sensorPorts.includes(undefined) && !this._motorPorts.includes(undefined)) { // ******************* // PARSE SENSOR VALUES // ******************* let offset = 5; // start reading sensor values at byte 5 for (let i = 0; i < 4; i++) { // array 2 float const buffer = new Uint8Array([ data[offset], data[offset + 1], data[offset + 2], data[offset + 3] ]).buffer; const view = new DataView(buffer); const value = view.getFloat32(0, true); if (Ev3Label[this._sensorPorts[i]] === 'button') { // Read a button value per port this._sensors.buttons[i] = value ? value : 0; } else if (Ev3Label[this._sensorPorts[i]]) { // if valid // Read brightness / distance values and set to 0 if null this._sensors[Ev3Label[this._sensorPorts[i]]] = value ? value : 0; } offset += 4; } // ***************************************************** // PARSE MOTOR POSITION VALUES, EVEN IF NO MOTOR PRESENT // ***************************************************** for (let i = 0; i < 4; i++) { const positionArray = [ data[offset], data[offset + 1], data[offset + 2], data[offset + 3] ]; if (this._motors[i]) { this._motors[i].position = positionArray; } offset += 4; } } } } /** * Enum for motor port names. * Note: if changed, will break compatibility with previously saved projects. * @readonly * @enum {string} */ const Ev3MotorMenu = ['A', 'B', 'C', 'D']; /** * Enum for sensor port names. * Note: if changed, will break compatibility with previously saved projects. * @readonly * @enum {string} */ const Ev3SensorMenu = ['1', '2', '3', '4']; class Scratch3Ev3Blocks { /** * The ID of the extension. * @return {string} the id */ static get EXTENSION_ID () { return 'ev3'; } /** * Creates a new instance of the EV3 extension. * @param {object} runtime VM runtime * @constructor */ constructor (runtime) { /** * The Scratch 3.0 runtime. * @type {Runtime} */ this.runtime = runtime; // Create a new EV3 peripheral instance this._peripheral = new EV3(this.runtime, Scratch3Ev3Blocks.EXTENSION_ID); this._playNoteForPicker = this._playNoteForPicker.bind(this); this.runtime.on('PLAY_NOTE', this._playNoteForPicker); } /** * Define the EV3 extension. * @return {object} Extension description. */ getInfo () { return { id: Scratch3Ev3Blocks.EXTENSION_ID, name: 'LEGO EV3', blockIconURI: blockIconURI, showStatusButton: true, blocks: [ { opcode: 'motorTurnClockwise', text: formatMessage({ id: 'ev3.motorTurnClockwise', default: 'motor [PORT] turn this way for [TIME] seconds', description: 'turn a motor clockwise for some time' }), blockType: BlockType.COMMAND, arguments: { PORT: { type: ArgumentType.STRING, menu: 'motorPorts', defaultValue: 0 }, TIME: { type: ArgumentType.NUMBER, defaultValue: 1 } } }, { opcode: 'motorTurnCounterClockwise', text: formatMessage({ id: 'ev3.motorTurnCounterClockwise', default: 'motor [PORT] turn that way for [TIME] seconds', description: 'turn a motor counter-clockwise for some time' }), blockType: BlockType.COMMAND, arguments: { PORT: { type: ArgumentType.STRING, menu: 'motorPorts', defaultValue: 0 }, TIME: { type: ArgumentType.NUMBER, defaultValue: 1 } } }, { opcode: 'motorSetPower', text: formatMessage({ id: 'ev3.motorSetPower', default: 'motor [PORT] set power [POWER] %', description: 'set a motor\'s power to some value' }), blockType: BlockType.COMMAND, arguments: { PORT: { type: ArgumentType.STRING, menu: 'motorPorts', defaultValue: 0 }, POWER: { type: ArgumentType.NUMBER, defaultValue: 100 } } }, { opcode: 'getMotorPosition', text: formatMessage({ id: 'ev3.getMotorPosition', default: 'motor [PORT] position', description: 'get the measured degrees a motor has turned' }), blockType: BlockType.REPORTER, arguments: { PORT: { type: ArgumentType.STRING, menu: 'motorPorts', defaultValue: 0 } } }, { opcode: 'whenButtonPressed', text: formatMessage({ id: 'ev3.whenButtonPressed', default: 'when button [PORT] pressed', description: 'when a button connected to a port is pressed' }), blockType: BlockType.HAT, arguments: { PORT: { type: ArgumentType.STRING, menu: 'sensorPorts', defaultValue: 0 } } }, { opcode: 'whenDistanceLessThan', text: formatMessage({ id: 'ev3.whenDistanceLessThan', default: 'when distance < [DISTANCE]', description: 'when the value measured by the distance sensor is less than some value' }), blockType: BlockType.HAT, arguments: { DISTANCE: { type: ArgumentType.NUMBER, defaultValue: 5 } } }, { opcode: 'whenBrightnessLessThan', text: formatMessage({ id: 'ev3.whenBrightnessLessThan', default: 'when brightness < [DISTANCE]', description: 'when value measured by brightness sensor is less than some value' }), blockType: BlockType.HAT, arguments: { DISTANCE: { type: ArgumentType.NUMBER, defaultValue: 50 } } }, { opcode: 'buttonPressed', text: formatMessage({ id: 'ev3.buttonPressed', default: 'button [PORT] pressed?', description: 'is a button on some port pressed?' }), blockType: BlockType.BOOLEAN, arguments: { PORT: { type: ArgumentType.STRING, menu: 'sensorPorts', defaultValue: 0 } } }, { opcode: 'getDistance', text: formatMessage({ id: 'ev3.getDistance', default: 'distance', description: 'gets measured distance' }), blockType: BlockType.REPORTER }, { opcode: 'getBrightness', text: formatMessage({ id: 'ev3.getBrightness', default: 'brightness', description: 'gets measured brightness' }), blockType: BlockType.REPORTER }, { opcode: 'beep', text: formatMessage({ id: 'ev3.beepNote', default: 'beep note [NOTE] for [TIME] secs', description: 'play some note on EV3 for some time' }), blockType: BlockType.COMMAND, arguments: { NOTE: { type: ArgumentType.NOTE, defaultValue: 60 }, TIME: { type: ArgumentType.NUMBER, defaultValue: 0.5 } } } ], menus: { motorPorts: this._formatMenu(Ev3MotorMenu), sensorPorts: this._formatMenu(Ev3SensorMenu) } }; } motorTurnClockwise (args) { const port = Cast.toNumber(args.PORT); let time = Cast.toNumber(args.TIME) * 1000; time = MathUtil.clamp(time, 0, 15000); return new Promise(resolve => { this._forEachMotor(port, motorIndex => { const motor = this._peripheral.motor(motorIndex); if (motor) { motor.direction = 1; motor.turnOnFor(time); } }); // Run for some time even when no motor is connected setTimeout(resolve, time); }); } motorTurnCounterClockwise (args) { const port = Cast.toNumber(args.PORT); let time = Cast.toNumber(args.TIME) * 1000; time = MathUtil.clamp(time, 0, 15000); return new Promise(resolve => { this._forEachMotor(port, motorIndex => { const motor = this._peripheral.motor(motorIndex); if (motor) { motor.direction = -1; motor.turnOnFor(time); } }); // Run for some time even when no motor is connected setTimeout(resolve, time); }); } motorSetPower (args) { const port = Cast.toNumber(args.PORT); const power = MathUtil.clamp(Cast.toNumber(args.POWER), 0, 100); this._forEachMotor(port, motorIndex => { const motor = this._peripheral.motor(motorIndex); if (motor) { motor.power = power; } }); } getMotorPosition (args) { const port = Cast.toNumber(args.PORT); if (![0, 1, 2, 3].includes(port)) { return; } const motor = this._peripheral.motor(port); let position = 0; if (motor) { position = MathUtil.wrapClamp(motor.position, 0, 360); } return position; } whenButtonPressed (args) { const port = Cast.toNumber(args.PORT); if (![0, 1, 2, 3].includes(port)) { return; } return this._peripheral.isButtonPressed(port); } whenDistanceLessThan (args) { const distance = MathUtil.clamp(Cast.toNumber(args.DISTANCE), 0, 100); return this._peripheral.distance < distance; } whenBrightnessLessThan (args) { const brightness = MathUtil.clamp(Cast.toNumber(args.DISTANCE), 0, 100); return this._peripheral.brightness < brightness; } buttonPressed (args) { const port = Cast.toNumber(args.PORT); if (![0, 1, 2, 3].includes(port)) { return; } return this._peripheral.isButtonPressed(port); } getDistance () { return this._peripheral.distance; } getBrightness () { return this._peripheral.brightness; } _playNoteForPicker (note, category) { if (category !== this.getInfo().name) return; this.beep({ NOTE: note, TIME: 0.25 }); } beep (args) { const note = MathUtil.clamp(Cast.toNumber(args.NOTE), 47, 99); // valid EV3 sounds let time = Cast.toNumber(args.TIME) * 1000; time = MathUtil.clamp(time, 0, 3000); if (time === 0) { return; // don't send a beep time of 0 } return new Promise(resolve => { // https://en.wikipedia.org/wiki/MIDI_tuning_standard#Frequency_values const freq = Math.pow(2, ((note - 69 + 12) / 12)) * 440; this._peripheral.beep(freq, time); // Run for some time even when no piezo is connected. setTimeout(resolve, time); }); } /** * 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 */ // TODO: unnecessary, but could be useful if 'all motors' is added (see WeDo2 extension) _forEachMotor (motorID, callback) { let motors; switch (motorID) { case 0: motors = [0]; break; case 1: motors = [1]; break; case 2: motors = [2]; break; case 3: motors = [3]; break; default: log.warn(`Invalid motor ID: ${motorID}`); motors = []; break; } for (const index of motors) { callback(index); } } /** * Formats menus into a format suitable for block menus, and loading previously * saved projects: * [ * { * text: label, * value: index * }, * { * text: label, * value: index * }, * etc... * ] * * @param {array} menu - a menu to format. * @return {object} - a formatted menu as an object. * @private */ _formatMenu (menu) { const m = []; for (let i = 0; i < menu.length; i++) { const obj = {}; obj.text = menu[i]; obj.value = i.toString(); m.push(obj); } return m; } } module.exports = Scratch3Ev3Blocks;