const JSONRPC = require('../util/jsonrpc'); class BLE extends JSONRPC { /** * A BLE peripheral socket object. It handles connecting, over web sockets, to * BLE peripherals, and reading and writing data to them. * @param {Runtime} runtime - the Runtime for sending/receiving GUI update events. * @param {string} extensionId - the id of the extension using this socket. * @param {object} peripheralOptions - the list of options for peripheral discovery. * @param {object} connectCallback - a callback for connection. * @param {object} resetCallback - a callback for resetting extension state. */ constructor (runtime, extensionId, peripheralOptions, connectCallback, resetCallback = null) { super(); this._socket = runtime.getScratchLinkSocket('BLE'); this._socket.setOnOpen(this.requestPeripheral.bind(this)); this._socket.setOnClose(this.handleDisconnectError.bind(this)); this._socket.setOnError(this._handleRequestError.bind(this)); this._socket.setHandleMessage(this._handleMessage.bind(this)); this._sendMessage = this._socket.sendMessage.bind(this._socket); this._availablePeripherals = {}; this._connectCallback = connectCallback; this._connected = false; this._characteristicDidChangeCallback = null; this._resetCallback = resetCallback; this._discoverTimeoutID = null; this._extensionId = extensionId; this._peripheralOptions = peripheralOptions; this._runtime = runtime; this._socket.open(); } /** * Request connection to the peripheral. * If the web socket is not yet open, request when the socket promise resolves. */ requestPeripheral () { this._availablePeripherals = {}; if (this._discoverTimeoutID) { window.clearTimeout(this._discoverTimeoutID); } this._discoverTimeoutID = window.setTimeout(this._handleDiscoverTimeout.bind(this), 15000); this.sendRemoteRequest('discover', this._peripheralOptions) .catch(e => { this._handleRequestError(e); }); } /** * Try connecting to the input peripheral id, and then call the connect * callback if connection is successful. * @param {number} id - the id of the peripheral to connect to */ connectPeripheral (id) { this.sendRemoteRequest('connect', {peripheralId: id}) .then(() => { this._connected = true; this._runtime.emit(this._runtime.constructor.PERIPHERAL_CONNECTED); this._connectCallback(); }) .catch(e => { this._handleRequestError(e); }); } /** * Close the websocket. */ disconnect () { if (this._connected) { this._connected = false; } if (this._socket.isOpen()) { this._socket.close(); } if (this._discoverTimeoutID) { window.clearTimeout(this._discoverTimeoutID); } // Sets connection status icon to orange this._runtime.emit(this._runtime.constructor.PERIPHERAL_DISCONNECTED); } /** * @return {bool} whether the peripheral is connected. */ isConnected () { return this._connected; } /** * Start receiving notifications from the specified ble service. * @param {number} serviceId - the ble service to read. * @param {number} characteristicId - the ble characteristic to get notifications from. * @param {object} onCharacteristicChanged - callback for characteristic change notifications. * @return {Promise} - a promise from the remote startNotifications request. */ startNotifications (serviceId, characteristicId, onCharacteristicChanged = null) { const params = { serviceId, characteristicId }; this._characteristicDidChangeCallback = onCharacteristicChanged; return this.sendRemoteRequest('startNotifications', params) .catch(e => { this.handleDisconnectError(e); }); } /** * Read from the specified ble service. * @param {number} serviceId - the ble service to read. * @param {number} characteristicId - the ble characteristic to read. * @param {boolean} optStartNotifications - whether to start receiving characteristic change notifications. * @param {object} onCharacteristicChanged - callback for characteristic change notifications. * @return {Promise} - a promise from the remote read request. */ read (serviceId, characteristicId, optStartNotifications = false, onCharacteristicChanged = null) { const params = { serviceId, characteristicId }; if (optStartNotifications) { params.startNotifications = true; } if (onCharacteristicChanged) { this._characteristicDidChangeCallback = onCharacteristicChanged; } return this.sendRemoteRequest('read', params) .catch(e => { this.handleDisconnectError(e); }); } /** * Write data to the specified ble service. * @param {number} serviceId - the ble service to write. * @param {number} characteristicId - the ble characteristic to write. * @param {string} message - the message to send. * @param {string} encoding - the message encoding type. * @param {boolean} withResponse - if true, resolve after peripheral's response. * @return {Promise} - a promise from the remote send request. */ write (serviceId, characteristicId, message, encoding = null, withResponse = null) { const params = {serviceId, characteristicId, message}; if (encoding) { params.encoding = encoding; } if (withResponse !== null) { params.withResponse = withResponse; } return this.sendRemoteRequest('write', params) .catch(e => { this.handleDisconnectError(e); }); } /** * Handle a received call from the socket. * @param {string} method - a received method label. * @param {object} params - a received list of parameters. * @return {object} - optional return value. */ didReceiveCall (method, params) { switch (method) { case 'didDiscoverPeripheral': this._availablePeripherals[params.peripheralId] = params; this._runtime.emit( this._runtime.constructor.PERIPHERAL_LIST_UPDATE, this._availablePeripherals ); if (this._discoverTimeoutID) { window.clearTimeout(this._discoverTimeoutID); } break; case 'userDidPickPeripheral': this._availablePeripherals[params.peripheralId] = params; this._runtime.emit( this._runtime.constructor.USER_PICKED_PERIPHERAL, this._availablePeripherals ); if (this._discoverTimeoutID) { window.clearTimeout(this._discoverTimeoutID); } break; case 'userDidNotPickPeripheral': this._runtime.emit( this._runtime.constructor.PERIPHERAL_SCAN_TIMEOUT ); if (this._discoverTimeoutID) { window.clearTimeout(this._discoverTimeoutID); } break; case 'characteristicDidChange': if (this._characteristicDidChangeCallback) { this._characteristicDidChangeCallback(params.message); } break; case 'ping': return 42; } } /** * Handle an error resulting from losing connection to a peripheral. * * This could be due to: * - battery depletion * - going out of bluetooth range * - being powered down * * Disconnect the socket, and if the extension using this socket has a * reset callback, call it. Finally, emit an error to the runtime. */ handleDisconnectError (/* e */) { // log.error(`BLE error: ${JSON.stringify(e)}`); if (!this._connected) return; this.disconnect(); if (this._resetCallback) { this._resetCallback(); } this._runtime.emit(this._runtime.constructor.PERIPHERAL_CONNECTION_LOST_ERROR, { message: `Scratch lost connection to`, extensionId: this._extensionId }); } _handleRequestError (/* e */) { // log.error(`BLE error: ${JSON.stringify(e)}`); this._runtime.emit(this._runtime.constructor.PERIPHERAL_REQUEST_ERROR, { message: `Scratch lost connection to`, extensionId: this._extensionId }); } _handleDiscoverTimeout () { if (this._discoverTimeoutID) { window.clearTimeout(this._discoverTimeoutID); } this._runtime.emit(this._runtime.constructor.PERIPHERAL_SCAN_TIMEOUT); } } module.exports = BLE;