From 6f92880f92bf0e616f425c5209a10de2844df801 Mon Sep 17 00:00:00 2001 From: Alexandre Trovato <1839717+atrovato@users.noreply.github.com> Date: Thu, 3 Dec 2020 11:21:28 +0100 Subject: [PATCH] Bluetooth fixes & improvements (#921) * Fix read and logs + add getCharacteristics * Bluetooth device creation/deletion * Fix BLE load devices Co-authored-by: Pierre-Gilles Leymarie --- .../lib/commands/bluetooth.connectDevices.js | 29 +---- .../bluetooth.getCharacteristic.js | 4 +- .../bluetooth/lib/commands/bluetooth.poll.js | 46 +++++++ .../lib/commands/bluetooth.postCreate.js | 47 ++++++++ .../lib/commands/bluetooth.postDelete.js | 37 ++++++ .../lib/commands/bluetooth.readDevice.js | 45 ++----- .../lib/commands/bluetooth.scanDevice.js | 3 +- .../lib/commands/bluetooth.subscribeDevice.js | 24 ++++ .../commands/bluetooth.subscribePeripheral.js | 35 ------ .../commands/bluetooth.unsubscribeDevice.js | 22 ++++ .../lib/commands/bluetooth.writeDevice.js | 10 +- server/services/bluetooth/lib/index.js | 16 ++- .../utils/characteristic/bluetooth.read.js | 6 +- .../characteristic/bluetooth.subscribe.js | 6 +- .../characteristic/bluetooth.unsubscribe.js | 37 ++++++ .../utils/characteristic/bluetooth.write.js | 15 ++- .../lib/utils/peripheral/bluetooth.connect.js | 4 +- .../peripheral/bluetooth.discoverServices.js | 15 ++- .../bluetooth.discoverCharacteristics.js | 15 ++- .../lib/commands/bluetooth.poll.test.js | 26 ++++ .../lib/commands/bluetooth.postCreate.test.js | 113 ++++++++++++++++++ .../lib/commands/bluetooth.postDelete.test.js | 98 +++++++++++++++ .../lib/commands/bluetooth.readDevice.test.js | 71 ++++------- .../lib/commands/bluetooth.scanDevice.test.js | 9 ++ .../bluetooth.discoverCharacteristics.test.js | 2 +- .../utils/bluetooth.discoverServices.test.js | 2 +- .../lib/utils/bluetooth.unsubscribe.test.js | 72 +++++++++++ 27 files changed, 627 insertions(+), 182 deletions(-) rename server/services/bluetooth/lib/{utils => commands}/bluetooth.getCharacteristic.js (82%) create mode 100644 server/services/bluetooth/lib/commands/bluetooth.poll.js create mode 100644 server/services/bluetooth/lib/commands/bluetooth.postCreate.js create mode 100644 server/services/bluetooth/lib/commands/bluetooth.postDelete.js create mode 100644 server/services/bluetooth/lib/commands/bluetooth.subscribeDevice.js delete mode 100644 server/services/bluetooth/lib/commands/bluetooth.subscribePeripheral.js create mode 100644 server/services/bluetooth/lib/commands/bluetooth.unsubscribeDevice.js create mode 100644 server/services/bluetooth/lib/utils/characteristic/bluetooth.unsubscribe.js create mode 100644 server/test/services/bluetooth/lib/commands/bluetooth.postCreate.test.js create mode 100644 server/test/services/bluetooth/lib/commands/bluetooth.postDelete.test.js create mode 100644 server/test/services/bluetooth/lib/utils/bluetooth.unsubscribe.test.js diff --git a/server/services/bluetooth/lib/commands/bluetooth.connectDevices.js b/server/services/bluetooth/lib/commands/bluetooth.connectDevices.js index f9df43df50..4cb0d98ca1 100644 --- a/server/services/bluetooth/lib/commands/bluetooth.connectDevices.js +++ b/server/services/bluetooth/lib/commands/bluetooth.connectDevices.js @@ -11,35 +11,10 @@ const logger = require('../../../../utils/logger'); async function connectDevices() { logger.debug(`Bluetooth: subscribing to existing devices...`); const devices = await this.gladys.device.get({ - service_id: this.serviceId, + service: 'bluetooth', }); - return Promise.map( - devices, - (device) => { - const [, peripheralUuid] = device.external_id.split(':'); - - const subscribe = (peripheral) => { - return Promise.map( - device.features, - (feature) => { - const [, , serviceUuid, characteristicUuid] = feature.external_id.split(':'); - return this.subscribePeripheral(peripheral, serviceUuid, characteristicUuid, feature); - }, - { concurrency: 1 }, - ).catch((e) => { - logger.error(e.message); - return Promise.resolve(); - }); - }; - - return this.applyOnPeripheral(peripheralUuid, subscribe, true).catch((e) => { - logger.error(e.message); - return Promise.resolve(); - }); - }, - { concurrency: 1 }, - ); + return Promise.map(devices, (device) => this.postCreate(device), { concurrency: 1 }); } module.exports = { diff --git a/server/services/bluetooth/lib/utils/bluetooth.getCharacteristic.js b/server/services/bluetooth/lib/commands/bluetooth.getCharacteristic.js similarity index 82% rename from server/services/bluetooth/lib/utils/bluetooth.getCharacteristic.js rename to server/services/bluetooth/lib/commands/bluetooth.getCharacteristic.js index 8f79d53fe7..ff771489a1 100644 --- a/server/services/bluetooth/lib/utils/bluetooth.getCharacteristic.js +++ b/server/services/bluetooth/lib/commands/bluetooth.getCharacteristic.js @@ -1,7 +1,7 @@ const Promise = require('bluebird'); -const { discoverServices } = require('./peripheral/bluetooth.discoverServices'); -const { discoverCharacteristics } = require('./service/bluetooth.discoverCharacteristics'); +const { discoverServices } = require('../utils/peripheral/bluetooth.discoverServices'); +const { discoverCharacteristics } = require('../utils/service/bluetooth.discoverCharacteristics'); /** * @description Connects to peripheral, discovers all needed, to applu action. diff --git a/server/services/bluetooth/lib/commands/bluetooth.poll.js b/server/services/bluetooth/lib/commands/bluetooth.poll.js new file mode 100644 index 0000000000..eacf992fd1 --- /dev/null +++ b/server/services/bluetooth/lib/commands/bluetooth.poll.js @@ -0,0 +1,46 @@ +const Promise = require('bluebird'); + +const logger = require('../../../../utils/logger'); +const { EVENTS } = require('../../../../utils/constants'); + +const { decodeValue } = require('../device/bluetooth.information'); + +/** + * @description Poll value of a Bluetooth device + * @param {Object} device - The device to control. + * @returns {Promise} Promise of all read values. + * @example + * await bluetooth.poll({ external_id: 'bluetooth:uuid'}); + */ +async function poll(device) { + const [, peripheralUuid] = device.external_id.split(':'); + + const readFeature = (feature, peripheral) => { + const featureExternalId = feature.external_id; + const [, , serviceUuid, characteristicUuid] = featureExternalId.split(':'); + + return this.readDevice(peripheral, serviceUuid, characteristicUuid) + .then((value) => { + const state = decodeValue(serviceUuid, characteristicUuid, feature, value); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: featureExternalId, + state, + }); + return state; + }) + .catch((e) => { + logger.warn(e.message); + return Promise.resolve(); + }); + }; + + const readFeatures = (peripheral) => { + return Promise.map(device.features, (feature) => readFeature(feature, peripheral), { concurrency: 1 }); + }; + + return this.applyOnPeripheral(peripheralUuid, readFeatures); +} + +module.exports = { + poll, +}; diff --git a/server/services/bluetooth/lib/commands/bluetooth.postCreate.js b/server/services/bluetooth/lib/commands/bluetooth.postCreate.js new file mode 100644 index 0000000000..a410bcd119 --- /dev/null +++ b/server/services/bluetooth/lib/commands/bluetooth.postCreate.js @@ -0,0 +1,47 @@ +const Promise = require('bluebird'); + +const logger = require('../../../../utils/logger'); +const { EVENTS } = require('../../../../utils/constants'); +const { decodeValue } = require('../device/bluetooth.information'); + +/** + * @description Subscribe to peripheral notification on device creation. + * @param {Object} device - Newly created Gladys device. + * @returns {Promise} All subscription promises. + * @example + * await bluetooth.postCreate(device); + */ +async function postCreate(device) { + const [, peripheralUuid] = device.external_id.split(':'); + + const subscribe = (peripheral) => { + return Promise.map( + device.features, + (feature) => { + const [, , serviceUuid, characteristicUuid] = feature.external_id.split(':'); + + const onNotify = (value) => { + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: feature.external_id, + state: decodeValue(serviceUuid, characteristicUuid, feature, value), + }); + }; + + return this.subscribeDevice(peripheral, serviceUuid, characteristicUuid, onNotify); + }, + { concurrency: 1 }, + ).catch((e) => { + logger.error(e.message); + return Promise.resolve(); + }); + }; + + return this.applyOnPeripheral(peripheralUuid, subscribe, true).catch((e) => { + logger.error(e.message); + return Promise.resolve(); + }); +} + +module.exports = { + postCreate, +}; diff --git a/server/services/bluetooth/lib/commands/bluetooth.postDelete.js b/server/services/bluetooth/lib/commands/bluetooth.postDelete.js new file mode 100644 index 0000000000..015ac15917 --- /dev/null +++ b/server/services/bluetooth/lib/commands/bluetooth.postDelete.js @@ -0,0 +1,37 @@ +const Promise = require('bluebird'); + +const logger = require('../../../../utils/logger'); + +/** + * @description Unsubscribe to peripheral notification on device delete. + * @param {Object} device - Newly created Gladys device. + * @returns {Promise} All subscription promises. + * @example + * await bluetooth.postDelete(device); + */ +async function postDelete(device) { + const [, peripheralUuid] = device.external_id.split(':'); + + const unsubscribe = (peripheral) => { + return Promise.map( + device.features, + (feature) => { + const [, , serviceUuid, characteristicUuid] = feature.external_id.split(':'); + return this.unsubscribeDevice(peripheral, serviceUuid, characteristicUuid); + }, + { concurrency: 1 }, + ).catch((e) => { + logger.error(e.message); + return Promise.resolve(); + }); + }; + + return this.applyOnPeripheral(peripheralUuid, unsubscribe, true).catch((e) => { + logger.error(e.message); + return Promise.resolve(); + }); +} + +module.exports = { + postDelete, +}; diff --git a/server/services/bluetooth/lib/commands/bluetooth.readDevice.js b/server/services/bluetooth/lib/commands/bluetooth.readDevice.js index e134c011b5..4f837c9c1e 100644 --- a/server/services/bluetooth/lib/commands/bluetooth.readDevice.js +++ b/server/services/bluetooth/lib/commands/bluetooth.readDevice.js @@ -1,47 +1,18 @@ -const Promise = require('bluebird'); - -const logger = require('../../../../utils/logger'); -const { EVENTS } = require('../../../../utils/constants'); - -const { decodeValue } = require('../device/bluetooth.information'); const { read } = require('../utils/characteristic/bluetooth.read'); -const { getCharacteristic } = require('../utils/bluetooth.getCharacteristic'); /** - * @description Poll value of a Bluetooth device - * @param {Object} device - The device to control. + * @description Read value of a Bluetooth device + * @param {string} peripheral - Connected Noble peripheral. + * @param {string} serviceUuid - Service UUID. + * @param {string} characteristicUuid - Characteristic UUID. * @returns {Promise} Promise of all read values. * @example * await bluetooth.readDevice({ external_id: 'bluetooth:uuid'}); */ -async function readDevice(device) { - const [, peripheralUuid] = device.external_id.split(':'); - - const readFeature = (feature, peripheral) => { - const featureExternalId = feature.external_id; - const [, , serviceUuid, characteristicUuid] = featureExternalId.split(':'); - - return getCharacteristic(peripheral, serviceUuid, characteristicUuid) - .then((characteristic) => read(characteristic)) - .then((value) => { - const state = decodeValue(serviceUuid, characteristicUuid, feature, value); - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: featureExternalId, - state, - }); - return state; - }) - .catch((e) => { - logger.warn(e.message); - return Promise.resolve(); - }); - }; - - const readFeatures = (peripheral) => { - return Promise.map(device.features, (feature) => readFeature(feature, peripheral), { concurrency: 1 }); - }; - - return this.applyOnPeripheral(peripheralUuid, readFeatures); +async function readDevice(peripheral, serviceUuid, characteristicUuid) { + return this.getCharacteristic(peripheral, serviceUuid, characteristicUuid).then((characteristic) => + read(characteristic), + ); } module.exports = { diff --git a/server/services/bluetooth/lib/commands/bluetooth.scanDevice.js b/server/services/bluetooth/lib/commands/bluetooth.scanDevice.js index 48c28ea1a3..aafe5f5d12 100644 --- a/server/services/bluetooth/lib/commands/bluetooth.scanDevice.js +++ b/server/services/bluetooth/lib/commands/bluetooth.scanDevice.js @@ -7,7 +7,6 @@ const { setDeviceParam } = require('../../../../utils/setDeviceParam'); const { INFORMATION_SERVICES } = require('../device/bluetooth.information'); const { PARAMS } = require('../utils/bluetooth.constants'); const { read } = require('../utils/characteristic/bluetooth.read'); -const { getCharacteristic } = require('../utils/bluetooth.getCharacteristic'); /** * @description Look for peripheral details. @@ -34,7 +33,7 @@ async function scanDevice(peripheralUuid) { Promise.map( Object.keys(INFORMATION_SERVICES[serviceUuid]), (characteristicUuid) => { - return getCharacteristic(peripheral, serviceUuid, characteristicUuid) + return this.getCharacteristic(peripheral, serviceUuid, characteristicUuid) .then((characteristic) => { const actionMapper = INFORMATION_SERVICES[serviceUuid][characteristicUuid]; diff --git a/server/services/bluetooth/lib/commands/bluetooth.subscribeDevice.js b/server/services/bluetooth/lib/commands/bluetooth.subscribeDevice.js new file mode 100644 index 0000000000..72f747373d --- /dev/null +++ b/server/services/bluetooth/lib/commands/bluetooth.subscribeDevice.js @@ -0,0 +1,24 @@ +const Promise = require('bluebird'); + +const { subscribe } = require('../utils/characteristic/bluetooth.subscribe'); +const { read } = require('../utils/characteristic/bluetooth.read'); + +/** + * @description Subscribes to peripheral characteristic. + * @param {Object} peripheral - Connected Noble peripheral. + * @param {string} serviceUuid - Service UUID. + * @param {string} characteristicUuid - Characteristic UUID. + * @param {Object} onNotify - Value callback. + * @returns {Promise} The write value. + * @example + * await subscribeDevice({ uuid: 'peripheral' }, 'service1', 'char1', () => console.log('done')) + */ +async function subscribeDevice(peripheral, serviceUuid, characteristicUuid, onNotify) { + return this.getCharacteristic(peripheral, serviceUuid, characteristicUuid).then((characteristic) => + subscribe(characteristic, onNotify).then(() => read(characteristic).then((value) => onNotify(value))), + ); +} + +module.exports = { + subscribeDevice, +}; diff --git a/server/services/bluetooth/lib/commands/bluetooth.subscribePeripheral.js b/server/services/bluetooth/lib/commands/bluetooth.subscribePeripheral.js deleted file mode 100644 index 089f4601b1..0000000000 --- a/server/services/bluetooth/lib/commands/bluetooth.subscribePeripheral.js +++ /dev/null @@ -1,35 +0,0 @@ -const Promise = require('bluebird'); - -const { EVENTS } = require('../../../../utils/constants'); - -const { decodeValue } = require('../device/bluetooth.information'); -const { subscribe } = require('../utils/characteristic/bluetooth.subscribe'); -const { read } = require('../utils/characteristic/bluetooth.read'); -const { getCharacteristic } = require('../utils/bluetooth.getCharacteristic'); - -/** - * @description Subscribes to peripheral characteristic. - * @param {Object} peripheral - Connected Noble peripheral. - * @param {string} serviceUuid - Service UUID. - * @param {string} characteristicUuid - Characteristic UUID. - * @param {string} feature - Type of data to read. - * @returns {Promise} The write value. - * @example - * await subscribePeripheral({ uuid: 'peripheral' }, 'service1', 'char1', {}) - */ -async function subscribePeripheral(peripheral, serviceUuid, characteristicUuid, feature) { - const onNotify = (value) => { - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `bluetooth:${peripheral.uuid}:${serviceUuid}:${characteristicUuid}`, - state: decodeValue(serviceUuid, characteristicUuid, feature, value), - }); - }; - - return getCharacteristic(peripheral, serviceUuid, characteristicUuid).then((characteristic) => - subscribe(characteristic, onNotify).then(() => read(characteristic).then((value) => onNotify(value))), - ); -} - -module.exports = { - subscribePeripheral, -}; diff --git a/server/services/bluetooth/lib/commands/bluetooth.unsubscribeDevice.js b/server/services/bluetooth/lib/commands/bluetooth.unsubscribeDevice.js new file mode 100644 index 0000000000..e16b93f441 --- /dev/null +++ b/server/services/bluetooth/lib/commands/bluetooth.unsubscribeDevice.js @@ -0,0 +1,22 @@ +const Promise = require('bluebird'); + +const { unsubscribe } = require('../utils/characteristic/bluetooth.unsubscribe'); + +/** + * @description Unsubscribes to peripheral characteristic. + * @param {Object} peripheral - Connected Noble peripheral. + * @param {string} serviceUuid - Service UUID. + * @param {string} characteristicUuid - Characteristic UUID. + * @returns {Promise} Unscription status. + * @example + * await subscribeDevice({ uuid: 'peripheral' }, 'service1', 'char1') + */ +async function unsubscribeDevice(peripheral, serviceUuid, characteristicUuid) { + return this.getCharacteristic(peripheral, serviceUuid, characteristicUuid).then((characteristic) => + unsubscribe(characteristic), + ); +} + +module.exports = { + unsubscribeDevice, +}; diff --git a/server/services/bluetooth/lib/commands/bluetooth.writeDevice.js b/server/services/bluetooth/lib/commands/bluetooth.writeDevice.js index b7913f53d3..b09adc2764 100644 --- a/server/services/bluetooth/lib/commands/bluetooth.writeDevice.js +++ b/server/services/bluetooth/lib/commands/bluetooth.writeDevice.js @@ -1,7 +1,4 @@ -const Promise = require('bluebird'); - const { write } = require('../utils/characteristic/bluetooth.write'); -const { getCharacteristic } = require('../utils/bluetooth.getCharacteristic'); /** * @description Write specific value to requested characteristic. @@ -9,13 +6,14 @@ const { getCharacteristic } = require('../utils/bluetooth.getCharacteristic'); * @param {string} serviceUuid - Service UUID. * @param {string} characteristicUuid - Characteristic UUID. * @param {Array | Buffer} value - Value to send to peripheral. + * @param {boolean} withoutResponse - Use "write without response" property (default false). * @returns {Promise} The write value. * @example * await writeDevice({ uuid: 'peripheral' }, 'service1', 'char1') */ -async function writeDevice(peripheral, serviceUuid, characteristicUuid, value) { - return getCharacteristic(peripheral, serviceUuid, characteristicUuid).then((characteristic) => - write(characteristic, value), +async function writeDevice(peripheral, serviceUuid, characteristicUuid, value, withoutResponse = false) { + return this.getCharacteristic(peripheral, serviceUuid, characteristicUuid).then((characteristic) => + write(characteristic, value, withoutResponse), ); } diff --git a/server/services/bluetooth/lib/index.js b/server/services/bluetooth/lib/index.js index 0b436a9a17..e5b44f43ac 100644 --- a/server/services/bluetooth/lib/index.js +++ b/server/services/bluetooth/lib/index.js @@ -14,12 +14,17 @@ const { getDiscoveredDevice } = require('./commands/bluetooth.getDiscoveredDevic const { getDiscoveredDevices } = require('./commands/bluetooth.getDiscoveredDevices'); const { getStatus } = require('./commands/bluetooth.getStatus'); const { readDevice } = require('./commands/bluetooth.readDevice'); +const { poll } = require('./commands/bluetooth.poll'); const { setValue } = require('./commands/bluetooth.setValue'); const { writeDevice } = require('./commands/bluetooth.writeDevice'); const { scanDevice } = require('./commands/bluetooth.scanDevice'); const { connectDevices } = require('./commands/bluetooth.connectDevices'); -const { subscribePeripheral } = require('./commands/bluetooth.subscribePeripheral'); +const { subscribeDevice } = require('./commands/bluetooth.subscribeDevice'); +const { unsubscribeDevice } = require('./commands/bluetooth.unsubscribeDevice'); const { applyOnPeripheral } = require('./commands/bluetooth.applyOnPeripheral'); +const { getCharacteristic } = require('./commands/bluetooth.getCharacteristic'); +const { postCreate } = require('./commands/bluetooth.postCreate'); +const { postDelete } = require('./commands/bluetooth.postDelete'); const BluetoothManager = function BluetoothManager(gladys, serviceId) { this.bluetooth = undefined; @@ -55,10 +60,15 @@ BluetoothManager.prototype.readDevice = readDevice; BluetoothManager.prototype.writeDevice = writeDevice; BluetoothManager.prototype.scanDevice = scanDevice; BluetoothManager.prototype.connectDevices = connectDevices; -BluetoothManager.prototype.subscribePeripheral = subscribePeripheral; +BluetoothManager.prototype.subscribeDevice = subscribeDevice; +BluetoothManager.prototype.unsubscribeDevice = unsubscribeDevice; BluetoothManager.prototype.applyOnPeripheral = applyOnPeripheral; +BluetoothManager.prototype.getCharacteristic = getCharacteristic; + // Gladys commands BluetoothManager.prototype.setValue = setValue; -BluetoothManager.prototype.poll = readDevice; +BluetoothManager.prototype.poll = poll; +BluetoothManager.prototype.postCreate = postCreate; +BluetoothManager.prototype.postDelete = postDelete; module.exports = BluetoothManager; diff --git a/server/services/bluetooth/lib/utils/characteristic/bluetooth.read.js b/server/services/bluetooth/lib/utils/characteristic/bluetooth.read.js index ec2eeae131..cd8420f49e 100644 --- a/server/services/bluetooth/lib/utils/characteristic/bluetooth.read.js +++ b/server/services/bluetooth/lib/utils/characteristic/bluetooth.read.js @@ -22,11 +22,11 @@ async function read(characteristic) { return new Promise((resolve, reject) => { characteristic.read((error, data) => { if (error) { - reject(new Error(`Bluetooth: failed to read characteristic ${characteristic.uuid} - ${error}`)); + return reject(new Error(`Bluetooth: failed to read characteristic ${characteristic.uuid} - ${error}`)); } - logger.debug(`Bluetooth: read ${data} on characteristic ${characteristic.uuid}`); - resolve(data); + logger.debug(`Bluetooth: read ${data.toString('hex')} on characteristic ${characteristic.uuid}`); + return resolve(data); }); }).timeout(TIMERS.READ); } diff --git a/server/services/bluetooth/lib/utils/characteristic/bluetooth.subscribe.js b/server/services/bluetooth/lib/utils/characteristic/bluetooth.subscribe.js index c836e90859..3fdab690dc 100644 --- a/server/services/bluetooth/lib/utils/characteristic/bluetooth.subscribe.js +++ b/server/services/bluetooth/lib/utils/characteristic/bluetooth.subscribe.js @@ -9,7 +9,7 @@ const { TIMERS } = require('../bluetooth.constants'); * @description Try to subscribe to Noble characteristic. * @param {Object} characteristic - Noble characteristic. * @param {Object} onNotify - Value callback. - * @returns {Promise} Read value. + * @returns {Promise} Subscrption status. * @example * await subscribe(characteristic, (value) => console.log(value)); */ @@ -24,13 +24,13 @@ async function subscribe(characteristic, onNotify) { return new Promise((resolve, reject) => { characteristic.subscribe((error) => { if (error) { - reject(new Error(`Bluetooth: failed to subscribe characteristic ${characteristic.uuid} - ${error}`)); + return reject(new Error(`Bluetooth: failed to subscribe characteristic ${characteristic.uuid} - ${error}`)); } characteristic.on('notify', (value) => onNotify(value)); logger.debug(`Bluetooth: subscribed to characteristic ${characteristic.uuid}`); - resolve(); + return resolve(); }); }).timeout(TIMERS.READ); } diff --git a/server/services/bluetooth/lib/utils/characteristic/bluetooth.unsubscribe.js b/server/services/bluetooth/lib/utils/characteristic/bluetooth.unsubscribe.js new file mode 100644 index 0000000000..a87c13daff --- /dev/null +++ b/server/services/bluetooth/lib/utils/characteristic/bluetooth.unsubscribe.js @@ -0,0 +1,37 @@ +const Promise = require('bluebird'); + +const logger = require('../../../../../utils/logger'); +const { BadParameters } = require('../../../../../utils/coreErrors'); + +const { TIMERS } = require('../bluetooth.constants'); + +/** + * @description Try to unsubscribe to Noble characteristic. + * @param {Object} characteristic - Noble characteristic. + * @returns {Promise} Unscribsciption status. + * @example + * await subscribe(characteristic); + */ +async function unsubscribe(characteristic) { + const properties = characteristic.properties || []; + if (!properties.includes('notify') && !properties.includes('indicate')) { + throw new BadParameters(`Bluetooth: not notify characteristic ${characteristic.uuid}`); + } + + logger.trace(`Bluetooth: unsubscribing characteristic ${characteristic.uuid}`); + + return new Promise((resolve, reject) => { + characteristic.unsubscribe((error) => { + if (error) { + return reject(new Error(`Bluetooth: failed to unsubscribe characteristic ${characteristic.uuid} - ${error}`)); + } + + logger.debug(`Bluetooth: unsubscribed to characteristic ${characteristic.uuid}`); + return resolve(); + }); + }).timeout(TIMERS.READ); +} + +module.exports = { + unsubscribe, +}; diff --git a/server/services/bluetooth/lib/utils/characteristic/bluetooth.write.js b/server/services/bluetooth/lib/utils/characteristic/bluetooth.write.js index b2c314a002..091d887234 100644 --- a/server/services/bluetooth/lib/utils/characteristic/bluetooth.write.js +++ b/server/services/bluetooth/lib/utils/characteristic/bluetooth.write.js @@ -9,11 +9,12 @@ const { TIMERS } = require('../bluetooth.constants'); * @description Try to write Noble characteristic. * @param {Object} characteristic - Noble characteristic. * @param {Array | Buffer} value - Value to send to peripheral. + * @param {boolean} withoutResponse - Use "write without response" property (default false). * @returns {Promise} Write value. * @example * await write(characteristic, [0x01]); */ -async function write(characteristic, value) { +async function write(characteristic, value, withoutResponse = false) { if (!(characteristic.properties || []).includes('write')) { throw new BadParameters(`Bluetooth: not writable characteristic ${characteristic.uuid}`); } @@ -22,13 +23,17 @@ async function write(characteristic, value) { const commandBuffer = Buffer.isBuffer(value) ? value : Buffer.from(value); return new Promise((resolve, reject) => { - characteristic.write(commandBuffer, false, (error) => { + characteristic.write(commandBuffer, withoutResponse, (error) => { if (error) { - reject(new Error(`Bluetooth: failed to write ${value} on characteristic ${characteristic.uuid} - ${error}`)); + return reject( + new Error( + `Bluetooth: failed to write ${value.toString('hex')} on characteristic ${characteristic.uuid} - ${error}`, + ), + ); } - logger.debug(`Bluetooth: write ${value} on characteristic ${characteristic.uuid}`); - resolve(value); + logger.debug(`Bluetooth: write ${value.toString('hex')} on characteristic ${characteristic.uuid}`); + return resolve(value); }); }).timeout(TIMERS.WRITE); } diff --git a/server/services/bluetooth/lib/utils/peripheral/bluetooth.connect.js b/server/services/bluetooth/lib/utils/peripheral/bluetooth.connect.js index f1f2afa4d9..5ce536cce4 100644 --- a/server/services/bluetooth/lib/utils/peripheral/bluetooth.connect.js +++ b/server/services/bluetooth/lib/utils/peripheral/bluetooth.connect.js @@ -26,11 +26,11 @@ async function connect(peripheral) { peripheral.connect((error) => { if (error) { logger.error(`Bluetooth: connection to peripheral ${peripheral.uuid} failed`); - reject(error); + return reject(error); } logger.debug(`Bluetooth: connected to peripheral ${peripheral.uuid}`); - resolve(peripheral); + return resolve(peripheral); }); }).timeout(TIMERS.CONNECT); } diff --git a/server/services/bluetooth/lib/utils/peripheral/bluetooth.discoverServices.js b/server/services/bluetooth/lib/utils/peripheral/bluetooth.discoverServices.js index 035c155bd0..f3b4ccccbd 100644 --- a/server/services/bluetooth/lib/utils/peripheral/bluetooth.discoverServices.js +++ b/server/services/bluetooth/lib/utils/peripheral/bluetooth.discoverServices.js @@ -20,19 +20,28 @@ async function discoverServices(peripheral, serviceUuids) { return new Promise((resolve, reject) => { peripheral.discoverServices(serviceUuids, (error, services) => { if (error) { - reject(new Error(`Bluetooth: error discovering services on ${peripheral.uuid} - ${error}`)); + return reject(new Error(`Bluetooth: error discovering services on ${peripheral.uuid} - ${error}`)); } if (services.length === 0) { - reject(new NotFoundError(`Bluetooth: no services found for ${peripheral.uuid}`)); + return reject(new NotFoundError(`Bluetooth: no services found for ${peripheral.uuid}`)); } services.forEach((service) => { serviceMap[service.uuid] = service; }); + if (serviceUuids) { + const serviceKeys = Object.keys(serviceMap); + if (!serviceUuids.every((s) => serviceKeys.includes(s))) { + return reject( + new NotFoundError(`Bluetooth: requested ${serviceUuids} services not found for ${peripheral.uuid}`), + ); + } + } + logger.debug(`Bluetooth: all services found for ${peripheral.uuid}`); - resolve(serviceMap); + return resolve(serviceMap); }); }).timeout(TIMERS.DISCOVER); } diff --git a/server/services/bluetooth/lib/utils/service/bluetooth.discoverCharacteristics.js b/server/services/bluetooth/lib/utils/service/bluetooth.discoverCharacteristics.js index 1c93e36a8e..5f7ad1af28 100644 --- a/server/services/bluetooth/lib/utils/service/bluetooth.discoverCharacteristics.js +++ b/server/services/bluetooth/lib/utils/service/bluetooth.discoverCharacteristics.js @@ -20,19 +20,28 @@ async function discoverCharacteristics(service, characteristicUuids) { return new Promise((resolve, reject) => { service.discoverCharacteristics(characteristicUuids, (error, characteristics) => { if (error) { - reject(new Error(`Bluetooth: error discovering characteristics for service ${service.uuid} - ${error}`)); + return reject(new Error(`Bluetooth: error discovering characteristics for service ${service.uuid} - ${error}`)); } if (characteristics.length === 0) { - reject(new NotFoundError(`Bluetooth: no characteristics found for service ${service.uuid}`)); + return reject(new NotFoundError(`Bluetooth: no characteristics found for service ${service.uuid}`)); } characteristics.forEach((characteristic) => { characteristicMap[characteristic.uuid] = characteristic; }); + if (characteristicUuids) { + const characteristicKeys = Object.keys(characteristicMap); + if (!characteristicUuids.every((s) => characteristicKeys.includes(s))) { + return reject( + new NotFoundError(`Bluetooth: requested ${characteristicUuids} services not found for ${service.uuid}`), + ); + } + } + logger.debug(`Bluetooth: all characteristics already found for service ${service.uuid}`); - resolve(characteristicMap); + return resolve(characteristicMap); }); }).timeout(TIMERS.DISCOVER); } diff --git a/server/test/services/bluetooth/lib/commands/bluetooth.poll.test.js b/server/test/services/bluetooth/lib/commands/bluetooth.poll.test.js index 5cea078bcc..3d504e1bc6 100644 --- a/server/test/services/bluetooth/lib/commands/bluetooth.poll.test.js +++ b/server/test/services/bluetooth/lib/commands/bluetooth.poll.test.js @@ -86,4 +86,30 @@ describe('bluetooth.poll command', () => { state: 13, }); }); + + it('error on getCharacteristic', async () => { + service.discoverCharacteristics = fake.yields(new Error('error')); + + const device = { + external_id: 'bluetooth:uuid', + features: [ + { + external_id: 'bluetooth:uuid:1809:2a6e', + }, + ], + }; + + await bluetoothManager.poll(device); + + assert.calledOnce(peripheral.connect); + assert.calledOnce(peripheral.discoverServices); + assert.calledOnce(peripheral.disconnect); + assert.calledOnce(service.discoverCharacteristics); + assert.notCalled(characteristic.read); + assert.calledOnce(gladys.event.emit); + assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { + type: WEBSOCKET_MESSAGE_TYPES.BLUETOOTH.STATE, + payload: { peripheralLookup: false, ready: false, scanning: false }, + }); + }); }); diff --git a/server/test/services/bluetooth/lib/commands/bluetooth.postCreate.test.js b/server/test/services/bluetooth/lib/commands/bluetooth.postCreate.test.js new file mode 100644 index 0000000000..a260a6d0c8 --- /dev/null +++ b/server/test/services/bluetooth/lib/commands/bluetooth.postCreate.test.js @@ -0,0 +1,113 @@ +const sinon = require('sinon'); +const EventEmitter = require('events'); + +const { fake, assert } = sinon; + +const BluetoothManager = require('../../../../../services/bluetooth/lib'); +const BluetoothMock = require('../../BluetoothMock.test'); + +const device = { + external_id: 'bluetooth:uuid', + features: [ + { + external_id: 'bluetooth:uuid:1809:2a6e', + }, + ], +}; +const gladys = { + event: { + emit: fake.returns(null), + }, +}; +const serviceId = 'de051f90-f34a-4fd5-be2e-e502339ec9bc'; + +describe('bluetooth.postCreate command', () => { + let bluetooth; + let bluetoothManager; + + let peripheral; + let service; + let characteristic; + + beforeEach(() => { + characteristic = new EventEmitter(); + characteristic.uuid = '2a6e'; + characteristic.properties = ['notify']; + characteristic.subscribe = fake.yields(null); + + service = { + uuid: '1809', + discoverCharacteristics: fake.yields(null, [characteristic]), + }; + + peripheral = { + uuid: 'uuid', + connectable: true, + connect: fake.yields(null), + discoverServices: fake.yields(null, [service]), + }; + + bluetooth = new BluetoothMock(); + + bluetoothManager = new BluetoothManager(gladys, serviceId); + bluetoothManager.bluetooth = bluetooth; + bluetooth.startScanning = () => { + bluetooth.emit('discover', peripheral); + bluetooth.emit('scanStop'); + }; + }); + + afterEach(() => { + if (bluetoothManager.scanPromise && bluetoothManager.scanPromise.isPending()) { + bluetoothManager.scanPromise.cancel(); + } + + sinon.reset(); + }); + + it('subscribe to peripheral', async () => { + await bluetoothManager.postCreate(device); + + assert.calledOnce(peripheral.connect); + assert.calledOnce(peripheral.discoverServices); + assert.calledOnce(service.discoverCharacteristics); + assert.calledOnce(characteristic.subscribe); + assert.calledOnce(gladys.event.emit); + + characteristic.emit('notify', 'value'); + + assert.calledTwice(gladys.event.emit); + }); + + it('subscribe to peripheral with error', async () => { + peripheral.discoverServices = fake.yields('error'); + + await bluetoothManager.postCreate(device); + + assert.calledOnce(peripheral.connect); + assert.calledOnce(peripheral.discoverServices); + assert.notCalled(service.discoverCharacteristics); + assert.notCalled(characteristic.subscribe); + assert.calledOnce(gladys.event.emit); + + characteristic.emit('notify', 'value'); + + assert.calledOnce(gladys.event.emit); + }); + + it('subscribe to peripheral with error (on connect)', async () => { + peripheral.connect = fake.yields(new Error('error')); + + await bluetoothManager.postCreate(device); + + assert.calledOnce(peripheral.connect); + assert.notCalled(peripheral.discoverServices); + assert.notCalled(service.discoverCharacteristics); + assert.notCalled(characteristic.subscribe); + assert.calledOnce(gladys.event.emit); + + characteristic.emit('notify', 'value'); + + assert.calledOnce(gladys.event.emit); + }); +}); diff --git a/server/test/services/bluetooth/lib/commands/bluetooth.postDelete.test.js b/server/test/services/bluetooth/lib/commands/bluetooth.postDelete.test.js new file mode 100644 index 0000000000..080dd45d89 --- /dev/null +++ b/server/test/services/bluetooth/lib/commands/bluetooth.postDelete.test.js @@ -0,0 +1,98 @@ +const sinon = require('sinon'); + +const { fake, assert } = sinon; + +const BluetoothManager = require('../../../../../services/bluetooth/lib'); +const BluetoothMock = require('../../BluetoothMock.test'); + +const device = { + external_id: 'bluetooth:uuid', + features: [ + { + external_id: 'bluetooth:uuid:1809:2a6e', + }, + ], +}; +const gladys = { + event: { + emit: fake.returns(null), + }, +}; +const serviceId = 'de051f90-f34a-4fd5-be2e-e502339ec9bc'; + +describe('bluetooth.postDelete command', () => { + let bluetooth; + let bluetoothManager; + + let peripheral; + let service; + let characteristic; + + beforeEach(() => { + characteristic = { + uuid: '2a6e', + properties: ['notify'], + unsubscribe: fake.yields(null), + }; + + service = { + uuid: '1809', + discoverCharacteristics: fake.yields(null, [characteristic]), + }; + + peripheral = { + uuid: 'uuid', + connectable: true, + connect: fake.yields(null), + discoverServices: fake.yields(null, [service]), + }; + + bluetooth = new BluetoothMock(); + + bluetoothManager = new BluetoothManager(gladys, serviceId); + bluetoothManager.bluetooth = bluetooth; + bluetooth.startScanning = () => { + bluetooth.emit('discover', peripheral); + bluetooth.emit('scanStop'); + }; + }); + + afterEach(() => { + if (bluetoothManager.scanPromise && bluetoothManager.scanPromise.isPending()) { + bluetoothManager.scanPromise.cancel(); + } + + sinon.reset(); + }); + + it('unsubscribe to peripheral', async () => { + await bluetoothManager.postDelete(device); + + assert.calledOnce(peripheral.connect); + assert.calledOnce(peripheral.discoverServices); + assert.calledOnce(service.discoverCharacteristics); + assert.calledOnce(characteristic.unsubscribe); + }); + + it('unsubscribe to peripheral with error', async () => { + peripheral.discoverServices = fake.yields('error'); + + await bluetoothManager.postDelete(device); + + assert.calledOnce(peripheral.connect); + assert.calledOnce(peripheral.discoverServices); + assert.notCalled(service.discoverCharacteristics); + assert.notCalled(characteristic.unsubscribe); + }); + + it('unsubscribe to peripheral with error (on connect)', async () => { + peripheral.connect = fake.yields(new Error('error')); + + await bluetoothManager.postDelete(device); + + assert.calledOnce(peripheral.connect); + assert.notCalled(peripheral.discoverServices); + assert.notCalled(service.discoverCharacteristics); + assert.notCalled(characteristic.unsubscribe); + }); +}); diff --git a/server/test/services/bluetooth/lib/commands/bluetooth.readDevice.test.js b/server/test/services/bluetooth/lib/commands/bluetooth.readDevice.test.js index e71a9b836c..b0c596916b 100644 --- a/server/test/services/bluetooth/lib/commands/bluetooth.readDevice.test.js +++ b/server/test/services/bluetooth/lib/commands/bluetooth.readDevice.test.js @@ -6,13 +6,9 @@ const { assert, fake } = sinon; const BluetoothManager = require('../../../../../services/bluetooth/lib'); const BluetoothMock = require('../../BluetoothMock.test'); -const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../../utils/constants'); +const { BadParameters } = require('../../../../../utils/coreErrors'); -const gladys = { - event: { - emit: fake.returns(null), - }, -}; +const gladys = {}; const serviceId = 'de051f90-f34a-4fd5-be2e-e502339ec9bc'; describe('bluetooth.readDevice', () => { @@ -21,7 +17,6 @@ describe('bluetooth.readDevice', () => { let characteristic; let service; let peripheral; - let device; let bluetooth; let bluetoothManager; @@ -55,21 +50,9 @@ describe('bluetooth.readDevice', () => { localName: 'P1', }, lastSeen: 'D1', - connectable: true, - connect: fake.yields(null), - disconnect: fake.resolves(null), discoverServices: fake.yields(null, [service]), }; - device = { - external_id: `bluetooth:${peripheral.uuid}`, - features: [ - { - external_id: `bluetooth:${peripheral.uuid}:${service.uuid}:${characteristic.uuid}`, - }, - ], - }; - bluetooth = new BluetoothMock(); bluetoothManager = new BluetoothManager(gladys, serviceId); bluetoothManager.bluetooth = bluetooth; @@ -89,63 +72,53 @@ describe('bluetooth.readDevice', () => { }); it('bluetooth.readDevice success', async () => { - const readValue = await bluetoothManager.readDevice(device); + const readValue = await bluetoothManager.readDevice(peripheral, service.uuid, characteristic.uuid); - const expectedResult = [13]; + const expectedResult = 'd'; expect(readValue).deep.eq(expectedResult); - assert.calledOnce(peripheral.connect); - assert.calledOnce(peripheral.disconnect); assert.calledOnce(peripheral.discoverServices); assert.calledOnce(service.discoverCharacteristics); - assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { - type: WEBSOCKET_MESSAGE_TYPES.BLUETOOTH.STATE, - payload: { peripheralLookup: false, ready: false, scanning: false }, - }); }); it('bluetooth.readDevice error on read', async () => { throwError = true; - await bluetoothManager.readDevice(device); + try { + await bluetoothManager.readDevice(peripheral, service.uuid, characteristic.uuid); + assert.fail(); + } catch (e) { + expect(e).instanceOf(Error); + } - assert.calledOnce(peripheral.connect); - assert.calledOnce(peripheral.disconnect); assert.calledOnce(peripheral.discoverServices); assert.calledOnce(service.discoverCharacteristics); - assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { - type: WEBSOCKET_MESSAGE_TYPES.BLUETOOTH.STATE, - payload: { peripheralLookup: false, ready: false, scanning: false }, - }); }); it('bluetooth.readDevice not readable (no props)', async () => { characteristic.properties = undefined; - await bluetoothManager.readDevice(device); - - assert.calledOnce(peripheral.connect); - assert.calledOnce(peripheral.disconnect); + try { + await bluetoothManager.readDevice(peripheral, service.uuid, characteristic.uuid); + assert.fail(); + } catch (e) { + expect(e).instanceOf(BadParameters); + } assert.calledOnce(peripheral.discoverServices); assert.calledOnce(service.discoverCharacteristics); - assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { - type: WEBSOCKET_MESSAGE_TYPES.BLUETOOTH.STATE, - payload: { peripheralLookup: false, ready: false, scanning: false }, - }); }); it('bluetooth.readDevice not readable prop', async () => { characteristic.properties = ['write']; - await bluetoothManager.readDevice(device); + try { + await bluetoothManager.readDevice(peripheral, service.uuid, characteristic.uuid); + assert.fail(); + } catch (e) { + expect(e).instanceOf(BadParameters); + } - assert.calledOnce(peripheral.connect); - assert.calledOnce(peripheral.disconnect); assert.calledOnce(peripheral.discoverServices); assert.calledOnce(service.discoverCharacteristics); - assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, { - type: WEBSOCKET_MESSAGE_TYPES.BLUETOOTH.STATE, - payload: { peripheralLookup: false, ready: false, scanning: false }, - }); }); }); diff --git a/server/test/services/bluetooth/lib/commands/bluetooth.scanDevice.test.js b/server/test/services/bluetooth/lib/commands/bluetooth.scanDevice.test.js index 129d3c74ba..00c1121bf6 100644 --- a/server/test/services/bluetooth/lib/commands/bluetooth.scanDevice.test.js +++ b/server/test/services/bluetooth/lib/commands/bluetooth.scanDevice.test.js @@ -464,4 +464,13 @@ describe('bluetooth.scanDevice', () => { assert.callCount(service.discoverCharacteristics, Object.keys(INFORMATION_SERVICES[service.uuid]).length); }); + + it('bluetooth.scanDevice with error (on connect)', async () => { + peripheral.connect = fake.yields(new Error('error')); + + await bluetoothManager.scanDevice(peripheral.uuid); + + assert.calledOnce(peripheral.connect); + assert.notCalled(peripheral.discoverServices); + }); }); diff --git a/server/test/services/bluetooth/lib/utils/bluetooth.discoverCharacteristics.test.js b/server/test/services/bluetooth/lib/utils/bluetooth.discoverCharacteristics.test.js index 0810e27a4b..2639d961ed 100644 --- a/server/test/services/bluetooth/lib/utils/bluetooth.discoverCharacteristics.test.js +++ b/server/test/services/bluetooth/lib/utils/bluetooth.discoverCharacteristics.test.js @@ -18,7 +18,7 @@ describe('Discover bluetooth characteristics', () => { beforeEach(() => { throwError = false; - characteristic = { uuid: 'uuid' }; + characteristic = { uuid: 'fff1' }; characteristics = [characteristic]; discovered = false; diff --git a/server/test/services/bluetooth/lib/utils/bluetooth.discoverServices.test.js b/server/test/services/bluetooth/lib/utils/bluetooth.discoverServices.test.js index 58c2a9224c..ef307555b0 100644 --- a/server/test/services/bluetooth/lib/utils/bluetooth.discoverServices.test.js +++ b/server/test/services/bluetooth/lib/utils/bluetooth.discoverServices.test.js @@ -18,7 +18,7 @@ describe('Discover bluetooth services', () => { beforeEach(() => { throwError = false; - service = { uuid: 'service1' }; + service = { uuid: 'fff0' }; services = [service]; discovered = false; diff --git a/server/test/services/bluetooth/lib/utils/bluetooth.unsubscribe.test.js b/server/test/services/bluetooth/lib/utils/bluetooth.unsubscribe.test.js new file mode 100644 index 0000000000..634b20b9ae --- /dev/null +++ b/server/test/services/bluetooth/lib/utils/bluetooth.unsubscribe.test.js @@ -0,0 +1,72 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); + +const { fake, assert } = sinon; + +const { unsubscribe } = require('../../../../../services/bluetooth/lib/utils/characteristic/bluetooth.unsubscribe'); +const { BadParameters } = require('../../../../../utils/coreErrors'); + +describe('Unsubscribe bluetooth peripheral', () => { + afterEach(() => { + sinon.reset(); + }); + + it('Characteristic no properties', async () => { + const characteristic = {}; + + try { + await unsubscribe(characteristic); + assert.fail(); + } catch (e) { + expect(e).is.instanceOf(BadParameters); + } + }); + + it('Characteristic not "notifiable"', async () => { + const characteristic = { + properties: [], + }; + + try { + await unsubscribe(characteristic); + assert.fail(); + } catch (e) { + expect(e).is.instanceOf(BadParameters); + } + }); + + it('Characteristic with "notify"', async () => { + const characteristic = { + properties: ['notify'], + unsubscribe: fake.yields(null), + }; + + await unsubscribe(characteristic); + assert.calledOnce(characteristic.unsubscribe); + }); + + it('Characteristic with "indicate"', async () => { + const characteristic = { + properties: ['indicate'], + unsubscribe: fake.yields(null), + }; + + await unsubscribe(characteristic); + assert.calledOnce(characteristic.unsubscribe); + }); + + it('Characteristic subscribe error', async () => { + const characteristic = { + properties: ['indicate'], + unsubscribe: fake.yields('error'), + }; + + try { + await unsubscribe(characteristic); + assert.fail(); + } catch (e) { + expect(e).is.instanceOf(Error); + assert.calledOnce(characteristic.unsubscribe); + } + }); +});