/** * Bluetooth Terminal class. */ class BluetoothTerminal { /** * Create preconfigured Bluetooth Terminal instance. * * @param {!(number|string)} [serviceUuid=0xFFE0] - Service UUID. * @param {!(number|string)} [characteristicUuid=0xFFE1] - Characteristic UUID. * @param {string} [receiveSeparator='\n'] - Receive separator. * @param {string} [sendSeparator='\n'] - Send separator. * @param {Function|undefined} [onConnected=undefined] - Listener for connected event. * @param {Function|undefined} [onDisconnected=undefined] - Listener for disconnected event. */ constructor(serviceUuid = 0xFFE0, characteristicUuid = 0xFFE1, receiveSeparator = '\n', sendSeparator = '\n', onConnected = undefined, onDisconnected = undefined) { // Used private variables. this._receiveBuffer = ''; // Buffer containing not separated data. this._maxCharacteristicValueLength = 20; // Max characteristic value length. this._device = null; // Device object cache. this._characteristic = null; // Characteristic object cache. // Bound functions used to add and remove appropriate event handlers. this._boundHandleDisconnection = this._handleDisconnection.bind(this); this._boundHandleCharacteristicValueChanged = this._handleCharacteristicValueChanged.bind(this); // Configure with specified parameters. this.setServiceUuid(serviceUuid); this.setCharacteristicUuid(characteristicUuid); this.setReceiveSeparator(receiveSeparator); this.setSendSeparator(sendSeparator); this.setOnConnected(onConnected); this.setOnDisconnected(onDisconnected); } /** * Set number or string representing service UUID used. * * @param {!(number|string)} uuid - Service UUID. */ setServiceUuid(uuid) { if (!Number.isInteger(uuid) && !(typeof uuid === 'string' || uuid instanceof String)) { throw new Error('UUID type is neither a number nor a string'); } if (!uuid) { throw new Error('UUID cannot be a null'); } this._serviceUuid = uuid; } /** * Set number or string representing characteristic UUID used. * * @param {!(number|string)} uuid - Characteristic UUID. */ setCharacteristicUuid(uuid) { if (!Number.isInteger(uuid) && !(typeof uuid === 'string' || uuid instanceof String)) { throw new Error('UUID type is neither a number nor a string'); } if (!uuid) { throw new Error('UUID cannot be a null'); } this._characteristicUuid = uuid; } /** * Set character representing separator for data coming from the connected device, end of line for example. * * @param {string} separator - Receive separator with length equal to one character. */ setReceiveSeparator(separator) { if (!(typeof separator === 'string' || separator instanceof String)) { throw new Error('Separator type is not a string'); } if (separator.length !== 1) { throw new Error('Separator length must be equal to one character'); } this._receiveSeparator = separator; } /** * Set string representing separator for data coming to the connected device, end of line for example. * * @param {string} separator - Send separator. */ setSendSeparator(separator) { if (!(typeof separator === 'string' || separator instanceof String)) { throw new Error('Separator type is not a string'); } if (separator.length !== 1) { throw new Error('Separator length must be equal to one character'); } this._sendSeparator = separator; } /** * Set a listener to be called after a device is connected. * * @param {Function|undefined} listener - Listener for connected event. */ setOnConnected(listener) { this._onConnected = listener; } /** * Set a listener to be called after a device is disconnected. * * @param {Function|undefined} listener - Listener for disconnected event. */ setOnDisconnected(listener) { this._onDisconnected = listener; } /** * Launch Bluetooth device chooser and connect to the selected device. * * @returns {Promise} Promise which will be fulfilled when notifications will be started or rejected if something went * wrong. */ connect() { return this._connectToDevice(this._device). then(() => { if (this._onConnected) { this._onConnected(); } }); } /** * Disconnect from the connected device. */ disconnect() { this._disconnectFromDevice(this._device); if (this._characteristic) { this._characteristic.removeEventListener('characteristicvaluechanged', this._boundHandleCharacteristicValueChanged); this._characteristic = null; } this._device = null; if (this._onDisconnected) { this._onDisconnected(); } } /** * Data receiving handler which called whenever the new data comes from the connected device, override it to handle * incoming data. * * @param {string} data - Data. */ receive(data) { // Handle incoming data. } /** * Send data to the connected device. * * @param {string} data - Data. * @returns {Promise} Promise which will be fulfilled when data will be sent or rejected if something went wrong. */ send(data) { // Convert data to the string using global object. data = String(data || ''); // Return rejected promise immediately if data is empty. if (!data) { return Promise.reject(new Error('Data must be not empty')); } data += this._sendSeparator; // Split data to chunks by max characteristic value length. const chunks = this.constructor._splitByLength(data, this._maxCharacteristicValueLength); // Return rejected promise immediately if there is no connected device. if (!this._characteristic) { return Promise.reject(new Error('There is no connected device')); } // Write first chunk to the characteristic immediately. let promise = this._writeToCharacteristic(this._characteristic, chunks[0]); // Iterate over chunks if there are more than one of it. for (let i = 1; i < chunks.length; i++) { // Chain new promise. promise = promise.then(() => new Promise((resolve, reject) => { // Reject promise if the device has been disconnected. if (!this._characteristic) { reject(new Error('Device has been disconnected')); } // Write chunk to the characteristic and resolve the promise. this._writeToCharacteristic(this._characteristic, chunks[i]). then(resolve). catch(reject); })); } return promise; } /** * Get the connected device name. * * @returns {string} Device name or empty string if not connected. */ getDeviceName() { if (!this._device) { return ''; } return this._device.name; } /** * Connect to device. * * @param {object} device - Device. * @returns {Promise} Promise. * @private */ _connectToDevice(device) { return (device ? Promise.resolve(device) : this._requestBluetoothDevice()). then((device) => this._connectDeviceAndCacheCharacteristic(device)). then((characteristic) => this._startNotifications(characteristic)). catch((error) => { this._log(error); return Promise.reject(error); }); } /** * Disconnect from device. * * @param {object} device - Device. * @returns {undefined} Undefined. * @private */ _disconnectFromDevice(device) { if (!device) { return; } this._log('Disconnecting from "' + device.name + '" bluetooth device...'); device.removeEventListener('gattserverdisconnected', this._boundHandleDisconnection); if (!device.gatt.connected) { this._log('"' + device.name + '" bluetooth device is already disconnected'); return; } device.gatt.disconnect(); this._log('"' + device.name + '" bluetooth device disconnected'); } /** * Request bluetooth device. * * @returns {Promise} Promise. * @private */ _requestBluetoothDevice() { this._log('Requesting bluetooth device...'); return navigator.bluetooth.requestDevice({ filters: [{services: [this._serviceUuid]}], }). then((device) => { this._log('"' + device.name + '" bluetooth device selected'); this._device = device; // Remember device. this._device.addEventListener('gattserverdisconnected', this._boundHandleDisconnection); return this._device; }); } /** * Connect device and cache characteristic. * * @param {object} device - Device. * @returns {Promise} Promise. * @private */ _connectDeviceAndCacheCharacteristic(device) { // Check remembered characteristic. if (device.gatt.connected && this._characteristic) { return Promise.resolve(this._characteristic); } this._log('Connecting to GATT server...'); return device.gatt.connect(). then((server) => { this._log('GATT server connected', 'Getting service...'); return server.getPrimaryService(this._serviceUuid); }). then((service) => { this._log('Service found', 'Getting characteristic...'); return service.getCharacteristic(this._characteristicUuid); }). then((characteristic) => { this._log('Characteristic found'); this._characteristic = characteristic; // Remember characteristic. return this._characteristic; }); } /** * Start notifications. * * @param {object} characteristic - Characteristic. * @returns {Promise} Promise. * @private */ _startNotifications(characteristic) { this._log('Starting notifications...'); return characteristic.startNotifications(). then(() => { this._log('Notifications started'); characteristic.addEventListener('characteristicvaluechanged', this._boundHandleCharacteristicValueChanged); }); } /** * Stop notifications. * * @param {object} characteristic - Characteristic. * @returns {Promise} Promise. * @private */ _stopNotifications(characteristic) { this._log('Stopping notifications...'); return characteristic.stopNotifications(). then(() => { this._log('Notifications stopped'); characteristic.removeEventListener('characteristicvaluechanged', this._boundHandleCharacteristicValueChanged); }); } /** * Handle disconnection. * * @param {object} event - Event. * @returns {undefined} Undefined. * @private */ _handleDisconnection(event) { const device = event.target; this._log('"' + device.name + '" bluetooth device disconnected, trying to reconnect...'); if (this._onDisconnected) { this._onDisconnected(); } this._connectDeviceAndCacheCharacteristic(device). then((characteristic) => this._startNotifications(characteristic)). then(() => { if (this._onConnected) { this._onConnected(); } }). catch((error) => this._log(error)); } /** * Handle characteristic value changed. * * @param {object} event - Event. * @private */ _handleCharacteristicValueChanged(event) { const value = new TextDecoder().decode(event.target.value); for (const c of value) { if (c === this._receiveSeparator) { const data = this._receiveBuffer.trim(); this._receiveBuffer = ''; if (data) { this.receive(data); } } else { this._receiveBuffer += c; } } } /** * Write to characteristic. * * @param {object} characteristic - Characteristic. * @param {string} data - Data. * @returns {Promise} Promise. * @private */ _writeToCharacteristic(characteristic, data) { return characteristic.writeValue(new TextEncoder().encode(data)); } /** * Log. * * @param {Array} messages - Messages. * @private */ _log(...messages) { console.log(...messages); // eslint-disable-line no-console } /** * Split by length. * * @param {string} string - String. * @param {number} length - Length. * @returns {Array} Array. * @private */ static _splitByLength(string, length) { return string.match(new RegExp('(.|[\r\n]){1,' + length + '}', 'g')); } } // Export class as a module to support requiring. /* istanbul ignore next */ if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { module.exports = BluetoothTerminal; }