diff --git a/docs/integrations/bluetooth-classic.md b/docs/integrations/bluetooth-classic.md index 73893c7..9bd47b5 100644 --- a/docs/integrations/bluetooth-classic.md +++ b/docs/integrations/bluetooth-classic.md @@ -2,12 +2,6 @@ **Integration Key:** `bluetoothClassic` -::: warning - -Using this together with [Bluetooth Low Energy](./bluetooth-low-energy) requires multiple Bluetooth adapters. - -::: - The Bluetooth Classic integration can detect the location of any Bluetooth device within the home. It does this by sending out connection requests to the device addresses you configure on rotation and then checking the signal strength of the response. The configured Bluetooth devices do not need to be paired with your machines running room-assistant. The integration has been tested to work great with the Apple Watch. You may see a minor hit on the battery life of the Bluetooth devices when enabling this. @@ -131,25 +125,3 @@ bluetoothClassic: ``` ::: - -::: details Multiple Bluetooth Integrations Example Config - -`hciDeviceId` can be used to choose a different Bluetooth adapter than the default one. Use hciconfig from the command line to see all available Bluetooth adapters. When using Bluetooth Classic and Bluetooth Low Energy at the same time you need to specify different IDs for these integrations. - -```yaml -global: - integrations: - - bluetoothClassic - - bluetoothLowEnergy -bluetoothClassic: - hciDeviceId: 0 - addresses: - - '08:05:90:ed:3b:60' - - '77:50:fb:4d:ab:70' -bluetoothLowEnergy: - hciDeviceId: 1 - whitelist: - - 7750fb4dab70 - - 2f234454cf6d4a0fadf2f4911ba9ffa6-1-2 -``` -::: diff --git a/docs/integrations/bluetooth-low-energy.md b/docs/integrations/bluetooth-low-energy.md index fd1f030..2eefb3b 100644 --- a/docs/integrations/bluetooth-low-energy.md +++ b/docs/integrations/bluetooth-low-energy.md @@ -2,18 +2,18 @@ **Integration Key:** `bluetoothLowEnergy` -::: warning - -Using this together with [Bluetooth Classic](./bluetooth-classic) requires multiple Bluetooth adapters. - -::: - The Bluetooth Low Energy (BLE) integration scans for advertisement packets that other devices, like iBeacon or Bluetooth tags, emit. You can use any of the many different BLE tags or smart armbands out there, as long as they send out a constant ID. An example would be the [RadBeacon Chip](https://store.radiusnetworks.com/collections/all/products/radbeacon-chip) or the [iB001W](https://www.beaconzone.co.uk/iB001W?search=iB001W). You can use Google or your favorite tech hardware store to find many other products like them that would also work. The integration calculates an estimated distance in meters for all advertisements it receives and uses that to update the current location of the device. Since there are many factors at play these estimations are not exact measurements, especially once there are obstructions between the BLE device and room-assistant instance. The best accuracy can be achieved with properly configured iBeacons. The distance value is smoothed using a [Kalman filter](https://en.wikipedia.org/wiki/Kalman_filter) to limit the impact of measurement noise. ## Requirements +::: tip + +Using this together with [Bluetooth Classic](./bluetooth-classic) on the same adapter works, but will slightly degrade the performance. If you encounter issues you can try to run to run the integrations from different HCI devices. + +::: + This integration requires a BLE capable Bluetooth adapter. Most modern boards like the Raspberry Pi Zero W have an integrated adapter that is suitable. Any Bluetooth USB stick with BLE and Linux support should also work. ### Running with NodeJS @@ -87,7 +87,7 @@ bluetoothLowEnergy: ::: details Multiple Bluetooth Integrations Example Config -`hciDeviceId` can be used to choose a different Bluetooth adapter than the default one. Use hciconfig from the command line to see all available Bluetooth adapters. When using Bluetooth Classic and Bluetooth Low Energy at the same time you need to specify different IDs for these integrations. +`hciDeviceId` can be used to choose a different Bluetooth adapter than the default one. Use hciconfig from the command line to see all available Bluetooth adapters. This may be useful when using Bluetooth Classic and Bluetooth Low Energy at the same time, as you could then have BLE passive scanning enabled at all times instead of just when no Bluetooth Classic inquiry is running. ```yaml global: diff --git a/docs/integrations/xiaomi-mi.md b/docs/integrations/xiaomi-mi.md index 37a3ead..b9b1f90 100644 --- a/docs/integrations/xiaomi-mi.md +++ b/docs/integrations/xiaomi-mi.md @@ -4,9 +4,7 @@ ::: warning -Using this together with [Bluetooth Classic](./bluetooth-classic) requires multiple Bluetooth adapters. -Using this together with [Bluetooth Low Energy](./bluetooth-low-energy) -requires that the hciDeviceId settings of both integrations are the same value. +Using this together with [Bluetooth Low Energy](./bluetooth-low-energy) requires that the hciDeviceId settings of both integrations are the same value. ::: diff --git a/src/integrations/bluetooth-classic/bluetooth-classic.module.ts b/src/integrations/bluetooth-classic/bluetooth-classic.module.ts index 37b0ae3..9f97c10 100644 --- a/src/integrations/bluetooth-classic/bluetooth-classic.module.ts +++ b/src/integrations/bluetooth-classic/bluetooth-classic.module.ts @@ -3,16 +3,15 @@ import { BluetoothClassicService } from './bluetooth-classic.service'; import { ConfigModule } from '../../config/config.module'; import { EntitiesModule } from '../../entities/entities.module'; import { ClusterModule } from '../../cluster/cluster.module'; -import { BluetoothClassicHealthIndicator } from './bluetooth-classic.health'; -import { StatusModule } from '../../status/status.module'; +import BluetoothModule from '../bluetooth/bluetooth.module'; @Module({}) export default class BluetoothClassicModule { static forRoot(): DynamicModule { return { module: BluetoothClassicModule, - imports: [ConfigModule, EntitiesModule, ClusterModule, StatusModule], - providers: [BluetoothClassicService, BluetoothClassicHealthIndicator], + imports: [BluetoothModule, ConfigModule, EntitiesModule, ClusterModule], + providers: [BluetoothClassicService], }; } } diff --git a/src/integrations/bluetooth-classic/bluetooth-classic.service.spec.ts b/src/integrations/bluetooth-classic/bluetooth-classic.service.spec.ts index 5bbd60f..012e346 100644 --- a/src/integrations/bluetooth-classic/bluetooth-classic.service.spec.ts +++ b/src/integrations/bluetooth-classic/bluetooth-classic.service.spec.ts @@ -17,7 +17,8 @@ import { RoomPresenceDistanceSensor } from '../room-presence/room-presence-dista import KalmanFilter from 'kalmanjs'; import { Switch } from '../../entities/switch'; import { BluetoothClassicConfig } from './bluetooth-classic.config'; -import { BluetoothClassicHealthIndicator } from './bluetooth-classic.health'; +import { BluetoothModule } from '../bluetooth/bluetooth.module'; +import { BluetoothService } from '../bluetooth/bluetooth.service'; import c from 'config'; import { ConfigService } from '../../config/config.service'; import { Device } from './device'; @@ -41,6 +42,10 @@ jest.useFakeTimers(); describe('BluetoothClassicService', () => { let service: BluetoothClassicService; + const bluetoothService = { + inquireClassicRssi: jest.fn(), + inquireClassicDeviceInfo: jest.fn(), + }; const entitiesService = { add: jest.fn(), get: jest.fn(), @@ -59,10 +64,6 @@ describe('BluetoothClassicService', () => { error: jest.fn(), warn: jest.fn(), }; - const healthIndicator = { - reportError: jest.fn(), - reportSuccess: jest.fn(), - }; const config: Partial = { addresses: ['8d:ad:e3:e2:7a:01', 'f7:6c:e3:10:55:b5'], hciDeviceId: 0, @@ -82,21 +83,22 @@ describe('BluetoothClassicService', () => { const module: TestingModule = await Test.createTestingModule({ imports: [ + BluetoothModule, ConfigModule, EntitiesModule, ClusterModule, ScheduleModule.forRoot(), ], - providers: [BluetoothClassicService, BluetoothClassicHealthIndicator], + providers: [BluetoothClassicService], }) + .overrideProvider(BluetoothService) + .useValue(bluetoothService) .overrideProvider(EntitiesService) .useValue(entitiesService) .overrideProvider(ClusterService) .useValue(clusterService) .overrideProvider(ConfigService) .useValue(configService) - .overrideProvider(BluetoothClassicHealthIndicator) - .useValue(healthIndicator) .compile(); module.useLogger(loggerService); @@ -163,89 +165,15 @@ describe('BluetoothClassicService', () => { config.inquireFromStart = true; }); - it('should return measured RSSI value from command output', () => { - mockExec.mockResolvedValue({ stdout: 'RSSI return value: -4' }); - - const address = '77:50:fb:4d:ab:70'; - - expect(service.inquireRssi(address)).resolves.toBe(-4); - }); - - it('should return undefined if no RSSI could be determined', () => { - mockExec.mockResolvedValue({ - stdout: "Can't create connection: Input/output error", - stderr: 'Not connected.', - }); - - expect(service.inquireRssi('08:05:90:ed:3b:60')).resolves.toBeUndefined(); - }); - - it('should return undefined if the command failed', () => { - mockExec.mockRejectedValue({ message: 'Command failed' }); - - expect(service.inquireRssi('08:05:90:ed:3b:60')).resolves.toBeUndefined(); - }); - - it('should reset the HCI device if the query took too long', async () => { - mockExec.mockRejectedValue({ signal: 'SIGKILL' }); - - const result = await service.inquireRssi('08:05:90:ed:3b:60'); - expect(result).toBeUndefined(); - expect(mockExec).toHaveBeenCalledWith('hciconfig hci0 reset'); - }); - - it('should return device information based on parsed output', async () => { - mockExec.mockResolvedValue({ - stdout: ` -Requesting information ... -\tBD Address: F0:99:B6:12:34:AB -\tOUI Company: Apple, Inc. (F0-99-B6) -\tDevice Name: Test iPhone -\tLMP Version: 5.0 (0x9) LMP Subversion: 0x4307 -\tManufacturer: Broadcom Corporation (15) -\tFeatures page 0: -\tFeatures page 1: -\tFeatures page 2: - `, - }); - - expect(await service.inquireDeviceInfo('F0:99:B6:12:34:AB')).toStrictEqual({ - address: 'F0:99:B6:12:34:AB', - name: 'Test iPhone', - manufacturer: 'Apple, Inc.', - }); - }); - - it('should return the address as device name if none was found', async () => { - mockExec.mockResolvedValue({ - stdout: 'IO error', - }); - - expect(await service.inquireDeviceInfo('F0:99:B6:12:34:AB')).toStrictEqual({ - address: 'F0:99:B6:12:34:AB', - name: 'F0:99:B6:12:34:AB', - manufacturer: undefined, - }); - }); - - it('should return barebones information if request fails', async () => { - mockExec.mockRejectedValue({ stderr: 'I/O Error' }); - - expect(await service.inquireDeviceInfo('F0:99:B6:12:34:CD')).toStrictEqual({ - address: 'F0:99:B6:12:34:CD', - name: 'F0:99:B6:12:34:CD', - }); - }); - it('should publish the RSSI if found', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); - jest.spyOn(service, 'inquireRssi').mockResolvedValue(0); + bluetoothService.inquireClassicRssi.mockResolvedValue(0); const handleRssiMock = jest .spyOn(service, 'handleNewRssi') .mockImplementation(() => undefined); const address = '77:50:fb:4d:ab:70'; const device = new Device(address, 'Test Device'); - jest.spyOn(service, 'inquireDeviceInfo').mockResolvedValue(device); + bluetoothService.inquireClassicDeviceInfo.mockResolvedValue(device); const expectedEvent = new NewRssiEvent('test-instance', device, 0); @@ -259,7 +187,7 @@ Requesting information ... it('should not publish an RSSI value if none was found', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); - jest.spyOn(service, 'inquireRssi').mockResolvedValue(undefined); + bluetoothService.inquireClassicRssi.mockResolvedValue(undefined); const handleRssiMock = jest .spyOn(service, 'handleNewRssi') .mockImplementation(() => undefined); @@ -272,7 +200,7 @@ Requesting information ... it('should publish RSSI values that are bigger than the min RSSI', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); - jest.spyOn(service, 'inquireRssi').mockResolvedValue(-9); + bluetoothService.inquireClassicRssi.mockResolvedValue(-9); const handleRssiMock = jest .spyOn(service, 'handleNewRssi') .mockImplementation(() => undefined); @@ -285,7 +213,7 @@ Requesting information ... it('should publish RSSI values that are the same as the min RSSI', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); - jest.spyOn(service, 'inquireRssi').mockResolvedValue(-10); + bluetoothService.inquireClassicRssi.mockResolvedValue(-10); const handleRssiMock = jest .spyOn(service, 'handleNewRssi') .mockImplementation(() => undefined); @@ -298,7 +226,7 @@ Requesting information ... it('should mark RSSI values that are smaller than the min RSSI as out of range', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); - jest.spyOn(service, 'inquireRssi').mockResolvedValue(-11); + bluetoothService.inquireClassicRssi.mockResolvedValue(-11); const handleRssiMock = jest .spyOn(service, 'handleNewRssi') .mockImplementation(() => undefined); @@ -306,7 +234,7 @@ Requesting information ... const address = '77:50:fb:4d:ab:70'; const device = new Device(address, 'Test Device'); - jest.spyOn(service, 'inquireDeviceInfo').mockResolvedValue(device); + bluetoothService.inquireClassicDeviceInfo.mockResolvedValue(device); const expectedEvent = new NewRssiEvent('test-instance', device, -11, true); @@ -320,7 +248,7 @@ Requesting information ... it('should handle minRssi per device', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); - jest.spyOn(service, 'inquireRssi').mockResolvedValue(-11); + bluetoothService.inquireClassicRssi.mockResolvedValue(-11); const handleRssiMock = jest .spyOn(service, 'handleNewRssi') .mockImplementation(() => undefined); @@ -331,7 +259,7 @@ Requesting information ... const address = '77:50:fb:4d:ab:70'; const device = new Device(address, 'Test Device'); - jest.spyOn(service, 'inquireDeviceInfo').mockResolvedValue(device); + bluetoothService.inquireClassicDeviceInfo.mockResolvedValue(device); const expectedEvent = new NewRssiEvent('test-instance', device, -11, true); @@ -345,7 +273,7 @@ Requesting information ... it('should pick the default minRssi if no device-specific one is configured', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); - jest.spyOn(service, 'inquireRssi').mockResolvedValue(-11); + bluetoothService.inquireClassicRssi.mockResolvedValue(-11); const handleRssiMock = jest .spyOn(service, 'handleNewRssi') .mockImplementation(() => undefined); @@ -356,7 +284,7 @@ Requesting information ... const address = '50:50:50:50:50:50'; const device = new Device(address, 'Test Device'); - jest.spyOn(service, 'inquireDeviceInfo').mockResolvedValue(device); + bluetoothService.inquireClassicDeviceInfo.mockResolvedValue(device); const expectedEvent = new NewRssiEvent('test-instance', device, -11, false); @@ -370,7 +298,7 @@ Requesting information ... it('should consider everything in range when no default minRssi is configured', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); - jest.spyOn(service, 'inquireRssi').mockResolvedValue(-25); + bluetoothService.inquireClassicRssi.mockResolvedValue(-25); const handleRssiMock = jest .spyOn(service, 'handleNewRssi') .mockImplementation(() => undefined); @@ -380,7 +308,7 @@ Requesting information ... const address = '50:50:50:50:50:50'; const device = new Device(address, 'Test Device'); - jest.spyOn(service, 'inquireDeviceInfo').mockResolvedValue(device); + bluetoothService.inquireClassicDeviceInfo.mockResolvedValue(device); const expectedEvent = new NewRssiEvent('test-instance', device, -25, false); @@ -394,32 +322,32 @@ Requesting information ... it('should gather the device info for previously unkown addresses', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); - jest.spyOn(service, 'inquireRssi').mockResolvedValue(0); + bluetoothService.inquireClassicRssi.mockResolvedValue(0); jest.spyOn(service, 'handleNewRssi').mockImplementation(() => undefined); const address = '77:50:fb:4d:ab:70'; const device = new Device(address, 'Test Device'); - const infoSpy = jest - .spyOn(service, 'inquireDeviceInfo') - .mockResolvedValue(device); + const infoSpy = bluetoothService.inquireClassicDeviceInfo.mockResolvedValue( + device + ); await service.handleRssiRequest(address); - expect(infoSpy).toHaveBeenCalledWith('77:50:fb:4d:ab:70'); + expect(infoSpy).toHaveBeenCalledWith(0, '77:50:fb:4d:ab:70'); }); it('should re-use already gathered device information', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); - jest.spyOn(service, 'inquireRssi').mockResolvedValue(0); + bluetoothService.inquireClassicRssi.mockResolvedValue(0); const handleSpy = jest .spyOn(service, 'handleNewRssi') .mockImplementation(() => undefined); const address = '77:50:fb:4d:ab:70'; const device = new Device(address, 'Test Device'); - const infoSpy = jest - .spyOn(service, 'inquireDeviceInfo') - .mockResolvedValue(device); + const infoSpy = bluetoothService.inquireClassicDeviceInfo.mockResolvedValue( + device + ); await service.handleRssiRequest(address); await service.handleRssiRequest(address); @@ -430,7 +358,7 @@ Requesting information ... it('should not trigger Bluetooth commands for undefined addresses', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); - const inquireSpy = jest.spyOn(service, 'inquireRssi'); + const inquireSpy = bluetoothService.inquireClassicRssi; await service.handleRssiRequest(undefined); expect(inquireSpy).not.toHaveBeenCalled(); @@ -438,7 +366,7 @@ Requesting information ... it('should not trigger Bluetooth commands for empty addresses', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); - const inquireSpy = jest.spyOn(service, 'inquireRssi'); + const inquireSpy = bluetoothService.inquireClassicRssi; await service.handleRssiRequest(''); expect(inquireSpy).not.toHaveBeenCalled(); @@ -460,7 +388,7 @@ Requesting information ... clusterService.nodes.mockReturnValue({ abcd: { channels: [NEW_RSSI_CHANNEL] }, }); - jest.spyOn(service, 'inquireDeviceInfo').mockResolvedValue({ + bluetoothService.inquireClassicDeviceInfo.mockResolvedValue({ address: '10:36:cf:ca:9a:18', name: 'Test iPhone', }); @@ -582,7 +510,7 @@ Requesting information ... it('should not distribute inquiries if not the leader', () => { clusterService.isMajorityLeader.mockReturnValue(false); - const inquireSpy = jest.spyOn(service, 'inquireRssi'); + const inquireSpy = bluetoothService.inquireClassicRssi; service.distributeInquiries(); expect(clusterService.send).not.toHaveBeenCalled(); @@ -718,7 +646,7 @@ Requesting information ... it('should filter the RSSI of inquired devices before publishing', async () => { jest.spyOn(service, 'shouldInquire').mockReturnValue(true); - jest.spyOn(service, 'inquireRssi').mockResolvedValue(-3); + bluetoothService.inquireClassicRssi.mockResolvedValue(-3); const handleRssiMock = jest .spyOn(service, 'handleNewRssi') .mockImplementation(() => undefined); @@ -728,7 +656,7 @@ Requesting information ... const address = 'ab:cd:01:23:00:70'; const device = new Device(address, 'Test Device'); - jest.spyOn(service, 'inquireDeviceInfo').mockResolvedValue(device); + bluetoothService.inquireClassicDeviceInfo.mockResolvedValue(device); const expectedEvent = new NewRssiEvent('test-instance', device, -5.2); @@ -768,38 +696,4 @@ Requesting information ... expect(service.shouldInquire()).toBeTruthy(); }); - - it('should report success to the health indicator when queries are successful', async () => { - mockExec.mockResolvedValue({ stdout: 'RSSI return value: -4' }); - await service.inquireRssi(''); - - expect(healthIndicator.reportSuccess).toHaveBeenCalledTimes(1); - }); - - it('should report an error to the health indicator when queries are unsuccessful', async () => { - mockExec.mockRejectedValue({ message: 'critical error' }); - await service.inquireRssi(''); - - expect(healthIndicator.reportError).toHaveBeenCalledTimes(1); - }); - - it('should not report anything to the health indicator if the device was not reachable', async () => { - mockExec.mockRejectedValue({ - message: 'Could not connect: Input/output error', - }); - await service.inquireRssi(''); - - expect(healthIndicator.reportSuccess).not.toHaveBeenCalled(); - expect(healthIndicator.reportError).not.toHaveBeenCalled(); - }); - - it('should not report an error if the scan was stopped due to low time limits', async () => { - mockExec.mockRejectedValue({ - message: 'killed', - signal: 'SIGKILL', - }); - await service.inquireRssi(''); - - expect(healthIndicator.reportError).not.toHaveBeenCalled(); - }); }); diff --git a/src/integrations/bluetooth-classic/bluetooth-classic.service.ts b/src/integrations/bluetooth-classic/bluetooth-classic.service.ts index b5547fe..3961acd 100644 --- a/src/integrations/bluetooth-classic/bluetooth-classic.service.ts +++ b/src/integrations/bluetooth-classic/bluetooth-classic.service.ts @@ -29,7 +29,7 @@ import { Switch } from '../../entities/switch'; import { SwitchConfig } from '../home-assistant/switch-config'; import { DeviceTracker } from '../../entities/device-tracker'; import { RoomPresenceDeviceTrackerProxyHandler } from '../room-presence/room-presence-device-tracker.proxy'; -import { BluetoothClassicHealthIndicator } from './bluetooth-classic.health'; +import { BluetoothService } from '../bluetooth/bluetooth.service'; const execPromise = util.promisify(exec); @@ -44,11 +44,11 @@ export class BluetoothClassicService private logger: Logger; constructor( + private readonly bluetoothService: BluetoothService, private readonly configService: ConfigService, private readonly entitiesService: EntitiesService, private readonly clusterService: ClusterService, - private readonly schedulerRegistry: SchedulerRegistry, - private readonly healthIndicator: BluetoothClassicHealthIndicator + private readonly schedulerRegistry: SchedulerRegistry ) { super(); this.config = this.configService.get('bluetoothClassic'); @@ -101,7 +101,10 @@ export class BluetoothClassicService } if (this.shouldInquire()) { - let rssi = await this.inquireRssi(address); + let rssi = await this.bluetoothService.inquireClassicRssi( + this.config.hciDeviceId, + address + ); if (rssi !== undefined) { rssi = _.round(this.filterRssi(address, rssi), 1); @@ -110,7 +113,10 @@ export class BluetoothClassicService if (this.deviceMap.has(address)) { device = this.deviceMap.get(address); } else { - device = await this.inquireDeviceInfo(address); + device = await this.bluetoothService.inquireClassicDeviceInfo( + this.config.hciDeviceId, + address + ); this.deviceMap.set(address, device); } @@ -204,54 +210,6 @@ export class BluetoothClassicService } } - /** - * Queries for the RSSI of a Bluetooth device using the hcitool shell command. - * - * @param address - Bluetooth MAC address - * @returns RSSI value - */ - async inquireRssi(address: string): Promise { - const regex = new RegExp(/-?[0-9]+/); - - this.logger.debug(`Querying for RSSI of ${address} using hcitool`); - try { - const output = await execPromise( - `hcitool -i hci${this.config.hciDeviceId} cc "${address}" && hcitool -i hci${this.config.hciDeviceId} rssi "${address}"`, - { - timeout: this.config.scanTimeLimit * 1000, - killSignal: 'SIGKILL', - } - ); - const matches = output.stdout.match(regex); - - this.healthIndicator.reportSuccess(); - - return matches?.length > 0 ? parseInt(matches[0], 10) : undefined; - } catch (e) { - if (e.signal === 'SIGKILL') { - this.logger.debug( - `Query of ${address} reached scan time limit, resetting hci${this.config.hciDeviceId}` - ); - this.resetHciDevice(); - - // when not reachable a scan runs for 6s, so lower time limits might not be an error - if (this.config.scanTimeLimit >= 6) { - this.healthIndicator.reportError(); - } - } else if ( - e.message?.includes('Input/output') || - e.message?.includes('I/O') - ) { - this.logger.debug(e.message); - } else { - this.logger.error(e.message); - this.healthIndicator.reportError(); - } - - return undefined; - } - } - /** * Applies the Kalman filter based on the historic values with the same address. * @@ -263,37 +221,6 @@ export class BluetoothClassicService return this.kalmanFilter(rssi, address); } - /** - * Inquires device information of a Bluetooth peripheral. - * - * @param address - Bluetooth MAC address - * @returns Device information - */ - async inquireDeviceInfo(address: string): Promise { - try { - const output = await execPromise( - `hcitool -i hci${this.config.hciDeviceId} info "${address}"` - ); - - const nameMatches = /Device Name: (.+)/g.exec(output.stdout); - const manufacturerMatches = /OUI Company: (.+) \(.+\)/g.exec( - output.stdout - ); - - return { - address, - name: nameMatches ? nameMatches[1] : address, - manufacturer: manufacturerMatches ? manufacturerMatches[1] : undefined, - }; - } catch (e) { - this.logger.error(e.message, e.stack); - return { - address, - name: address, - }; - } - } - /** * Updates the underlying room presence sensor state. * Called regularly. @@ -351,17 +278,6 @@ export class BluetoothClassicService return this.inquiriesSwitch?.state; } - /** - * Reset the hci (Bluetooth) device used for inquiries. - */ - protected async resetHciDevice(): Promise { - try { - await execPromise(`hciconfig hci${this.config.hciDeviceId} reset`); - } catch (e) { - this.logger.error(e.message); - } - } - /** * Creates and registers a new switch as a setting for whether Bluetooth queries should be made from this device or not. * diff --git a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.module.ts b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.module.ts index f3e00e0..effbc12 100644 --- a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.module.ts +++ b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.module.ts @@ -5,6 +5,7 @@ import { EntitiesModule } from '../../entities/entities.module'; import { ConfigModule } from '../../config/config.module'; import { ClusterModule } from '../../cluster/cluster.module'; import { ScheduleModule } from '@nestjs/schedule'; +import { BluetoothModule } from '../bluetooth/bluetooth.module'; @Module({}) export default class BluetoothLowEnergyModule { @@ -12,6 +13,7 @@ export default class BluetoothLowEnergyModule { return { module: BluetoothLowEnergyModule, imports: [ + BluetoothModule, EntitiesModule, ConfigModule, ClusterModule, diff --git a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.spec.ts b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.spec.ts index 164c0c7..ceb11b6 100644 --- a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.spec.ts +++ b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.spec.ts @@ -1,13 +1,3 @@ -const mockNoble = { - on: jest.fn(), -}; -jest.mock( - '@abandonware/noble', - () => { - return mockNoble; - }, - { virtual: true } -); jest.mock('kalmanjs', () => { return jest.fn().mockImplementation(() => { return { @@ -37,11 +27,16 @@ import { BluetoothLowEnergyPresenceSensor } from './bluetooth-low-energy-presenc import KalmanFilter from 'kalmanjs'; import { DeviceTracker } from '../../entities/device-tracker'; import * as util from 'util'; +import { BluetoothService } from '../bluetooth/bluetooth.service'; +import { BluetoothModule } from '../bluetooth/bluetooth.module'; jest.useFakeTimers(); describe('BluetoothLowEnergyService', () => { let service: BluetoothLowEnergyService; + const bluetoothService = { + onLowEnergyDiscovery: jest.fn(), + }; const clusterService = { on: jest.fn(), subscribe: jest.fn(), @@ -100,6 +95,7 @@ describe('BluetoothLowEnergyService', () => { const module: TestingModule = await Test.createTestingModule({ imports: [ + BluetoothModule, ConfigModule, ClusterModule, EntitiesModule, @@ -107,6 +103,8 @@ describe('BluetoothLowEnergyService', () => { ], providers: [BluetoothLowEnergyService], }) + .overrideProvider(BluetoothService) + .useValue(bluetoothService) .overrideProvider(ClusterService) .useValue(clusterService) .overrideProvider(EntitiesService) @@ -119,13 +117,11 @@ describe('BluetoothLowEnergyService', () => { service = module.get(BluetoothLowEnergyService); }); - it('should setup noble listeners on bootstrap', () => { + it('should setup BLE listener on bootstrap', () => { service.onApplicationBootstrap(); - expect(mockNoble.on).toHaveBeenCalledWith( - 'stateChange', + expect(bluetoothService.onLowEnergyDiscovery).toHaveBeenCalledWith( expect.any(Function) ); - expect(mockNoble.on).toHaveBeenCalledWith('discover', expect.any(Function)); }); it('should warn if no whitelist has been configured', () => { diff --git a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts index ef25d6c..091951d 100644 --- a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts +++ b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts @@ -4,7 +4,7 @@ import { OnApplicationBootstrap, OnModuleInit, } from '@nestjs/common'; -import noble, { Peripheral } from '@abandonware/noble'; +import { Peripheral } from '@abandonware/noble'; import { EntitiesService } from '../../entities/entities.service'; import { IBeacon } from './i-beacon'; import { Tag } from './tag'; @@ -23,11 +23,13 @@ import * as _ from 'lodash'; import { DeviceTracker } from '../../entities/device-tracker'; import { RoomPresenceDeviceTrackerProxyHandler } from '../room-presence/room-presence-device-tracker.proxy'; import { BluetoothLowEnergyPresenceSensor } from './bluetooth-low-energy-presence.sensor'; +import { BluetoothService } from '../bluetooth/bluetooth.service'; export const NEW_DISTANCE_CHANNEL = 'bluetooth-low-energy.new-distance'; @Injectable() // parameters determined experimentally -export class BluetoothLowEnergyService extends KalmanFilterable(Object, 0.8, 15) +export class BluetoothLowEnergyService + extends KalmanFilterable(Object, 0.8, 15) implements OnModuleInit, OnApplicationBootstrap { private readonly config: BluetoothLowEnergyConfig; private readonly logger: Logger; @@ -37,6 +39,7 @@ export class BluetoothLowEnergyService extends KalmanFilterable(Object, 0.8, 15) } = {}; constructor( + private readonly bluetoothService: BluetoothService, private readonly entitiesService: EntitiesService, private readonly configService: ConfigService, private readonly clusterService: ClusterService, @@ -62,12 +65,7 @@ export class BluetoothLowEnergyService extends KalmanFilterable(Object, 0.8, 15) * Lifecycle hook, called once the application has started. */ onApplicationBootstrap(): void { - noble.on('stateChange', BluetoothLowEnergyService.handleStateChange); - noble.on('discover', this.handleDiscovery.bind(this)); - noble.on('warning', (message) => { - this.logger.warn(message); - }); - + this.bluetoothService.onLowEnergyDiscovery(this.handleDiscovery.bind(this)); this.clusterService.on( NEW_DISTANCE_CHANNEL, this.handleNewDistance.bind(this) @@ -339,17 +337,4 @@ export class BluetoothLowEnergyService extends KalmanFilterable(Object, 0.8, 15) return tag; } - - /** - * Stops or starts BLE scans based on the adapter state. - * - * @param state - Noble adapter state string - */ - private static handleStateChange(state: string): void { - if (state === 'poweredOn') { - noble.startScanning([], true); - } else { - noble.stopScanning(); - } - } } diff --git a/src/integrations/bluetooth-classic/bluetooth-classic.health.spec.ts b/src/integrations/bluetooth/bluetooth.health.spec.ts similarity index 78% rename from src/integrations/bluetooth-classic/bluetooth-classic.health.spec.ts rename to src/integrations/bluetooth/bluetooth.health.spec.ts index 8a59c9f..db490a2 100644 --- a/src/integrations/bluetooth-classic/bluetooth-classic.health.spec.ts +++ b/src/integrations/bluetooth/bluetooth.health.spec.ts @@ -1,11 +1,11 @@ -import { BluetoothClassicHealthIndicator } from './bluetooth-classic.health'; +import { BluetoothHealthIndicator } from './bluetooth.health'; import { HealthCheckError } from '@nestjs/terminus'; -describe('BluetoothClassicHealthIndicator', () => { - let healthIndicator: BluetoothClassicHealthIndicator; +describe('BluetoothHealthIndicator', () => { + let healthIndicator: BluetoothHealthIndicator; beforeEach(() => { - healthIndicator = new BluetoothClassicHealthIndicator(); + healthIndicator = new BluetoothHealthIndicator(); }); it('should report healthy by default', () => { diff --git a/src/integrations/bluetooth-classic/bluetooth-classic.health.ts b/src/integrations/bluetooth/bluetooth.health.ts similarity index 93% rename from src/integrations/bluetooth-classic/bluetooth-classic.health.ts rename to src/integrations/bluetooth/bluetooth.health.ts index 6d28122..6956e2e 100644 --- a/src/integrations/bluetooth-classic/bluetooth-classic.health.ts +++ b/src/integrations/bluetooth/bluetooth.health.ts @@ -7,7 +7,7 @@ import { Injectable, Optional } from '@nestjs/common'; import { HealthIndicatorService } from '../../status/health-indicator.service'; @Injectable() -export class BluetoothClassicHealthIndicator extends HealthIndicator { +export class BluetoothHealthIndicator extends HealthIndicator { private errorsOccurred = 0; constructor(@Optional() healthIndicatorService?: HealthIndicatorService) { diff --git a/src/integrations/bluetooth/bluetooth.module.ts b/src/integrations/bluetooth/bluetooth.module.ts new file mode 100644 index 0000000..340a2b6 --- /dev/null +++ b/src/integrations/bluetooth/bluetooth.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { BluetoothService } from './bluetooth.service'; +import { BluetoothHealthIndicator } from './bluetooth.health'; +import { ConfigModule } from '../../config/config.module'; +import { StatusModule } from '../../status/status.module'; + +@Module({ + imports: [ConfigModule, StatusModule], + providers: [BluetoothService, BluetoothHealthIndicator], + exports: [BluetoothService], +}) +export class BluetoothModule {} diff --git a/src/integrations/bluetooth/bluetooth.service.spec.ts b/src/integrations/bluetooth/bluetooth.service.spec.ts new file mode 100644 index 0000000..8fbfbf3 --- /dev/null +++ b/src/integrations/bluetooth/bluetooth.service.spec.ts @@ -0,0 +1,276 @@ +const mockExec = jest.fn(); +const mockNoble = { + state: 'poweredOn', + on: jest.fn(), + startScanning: jest.fn(), + stopScanning: jest.fn(), +}; +jest.mock( + '@abandonware/noble', + () => { + return mockNoble; + }, + { virtual: true } +); + +import { Test, TestingModule } from '@nestjs/testing'; +import { BluetoothService } from './bluetooth.service'; +import { ConfigModule } from '../../config/config.module'; +import { BluetoothHealthIndicator } from './bluetooth.health'; + +jest.mock('util', () => ({ + ...jest.requireActual('util'), + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + promisify: () => mockExec, +})); + +describe('BluetoothService', () => { + let service: BluetoothService; + const healthIndicator = { + reportError: jest.fn(), + reportSuccess: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule], + providers: [BluetoothService, BluetoothHealthIndicator], + }) + .overrideProvider(BluetoothHealthIndicator) + .useValue(healthIndicator) + .compile(); + service = module.get(BluetoothService); + }); + + describe('Bluetooth Classic', () => { + it('should return measured RSSI value from command output', () => { + mockExec.mockResolvedValue({ stdout: 'RSSI return value: -4' }); + + const address = '77:50:fb:4d:ab:70'; + + expect(service.inquireClassicRssi(1, address)).resolves.toBe(-4); + expect(mockExec).toHaveBeenCalledWith( + `hcitool -i hci1 cc \"${address}\" && hcitool -i hci1 rssi \"${address}\"`, + expect.anything() + ); + }); + + it('should return undefined if no RSSI could be determined', () => { + mockExec.mockResolvedValue({ + stdout: "Can't create connection: Input/output error", + stderr: 'Not connected.', + }); + + expect( + service.inquireClassicRssi(0, '08:05:90:ed:3b:60') + ).resolves.toBeUndefined(); + }); + + it('should return undefined if the command failed', () => { + mockExec.mockRejectedValue({ message: 'Command failed' }); + + expect( + service.inquireClassicRssi(0, '08:05:90:ed:3b:60') + ).resolves.toBeUndefined(); + }); + + it('should reset the HCI device if the query took too long', async () => { + mockExec.mockRejectedValue({ signal: 'SIGKILL' }); + + const result = await service.inquireClassicRssi(1, '08:05:90:ed:3b:60'); + expect(result).toBeUndefined(); + expect(mockExec).toHaveBeenCalledWith('hciconfig hci1 reset'); + }); + + it('should stop scanning on an adapter while performing an inquiry', async () => { + service.onLowEnergyDiscovery(() => undefined); + const stateChangeHandler = mockNoble.on.mock.calls[0][1]; + stateChangeHandler('poweredOn'); + + expect(mockNoble.startScanning).toHaveBeenCalledTimes(1); + + let execResolve; + const execPromise = new Promise((r) => (execResolve = r)); + mockExec.mockReturnValue(execPromise); + service.inquireClassicRssi(0, 'x'); + + expect(mockNoble.stopScanning).toHaveBeenCalledTimes(1); + + await execResolve(); + + expect(mockNoble.startScanning).toHaveBeenCalledTimes(2); + }); + + it('should start scanning again even after encountering an exception', async () => { + service.onLowEnergyDiscovery(() => undefined); + const stateChangeHandler = mockNoble.on.mock.calls[0][1]; + stateChangeHandler('poweredOn'); + + mockExec.mockRejectedValue({ stderr: 'error' }); + await service.inquireClassicRssi(0, 'x'); + + expect(mockNoble.startScanning).toHaveBeenCalledTimes(2); + }); + + it('should stop scanning on an adapter while getting Classic device info', async () => { + service.onLowEnergyDiscovery(() => undefined); + const stateChangeHandler = mockNoble.on.mock.calls[0][1]; + stateChangeHandler('poweredOn'); + + expect(mockNoble.startScanning).toHaveBeenCalledTimes(1); + + let execResolve; + const execPromise = new Promise((r) => (execResolve = r)); + mockExec.mockReturnValue(execPromise); + service.inquireClassicDeviceInfo(0, 'x'); + + expect(mockNoble.stopScanning).toHaveBeenCalledTimes(1); + + await execResolve({ stdout: '' }); + + expect(mockNoble.startScanning).toHaveBeenCalledTimes(2); + }); + + it('should return device information based on parsed output', async () => { + mockExec.mockResolvedValue({ + stdout: ` +Requesting information ... +\tBD Address: F0:99:B6:12:34:AB +\tOUI Company: Apple, Inc. (F0-99-B6) +\tDevice Name: Test iPhone +\tLMP Version: 5.0 (0x9) LMP Subversion: 0x4307 +\tManufacturer: Broadcom Corporation (15) +\tFeatures page 0: +\tFeatures page 1: +\tFeatures page 2: + `, + }); + + expect( + await service.inquireClassicDeviceInfo(0, 'F0:99:B6:12:34:AB') + ).toStrictEqual({ + address: 'F0:99:B6:12:34:AB', + name: 'Test iPhone', + manufacturer: 'Apple, Inc.', + }); + }); + + it('should return the address as device name if none was found', async () => { + mockExec.mockResolvedValue({ + stdout: 'IO error', + }); + + expect( + await service.inquireClassicDeviceInfo(0, 'F0:99:B6:12:34:AB') + ).toStrictEqual({ + address: 'F0:99:B6:12:34:AB', + name: 'F0:99:B6:12:34:AB', + manufacturer: undefined, + }); + }); + + it('should return barebones information if request fails', async () => { + mockExec.mockRejectedValue({ stderr: 'I/O Error' }); + + expect( + await service.inquireClassicDeviceInfo(0, 'F0:99:B6:12:34:CD') + ).toStrictEqual({ + address: 'F0:99:B6:12:34:CD', + name: 'F0:99:B6:12:34:CD', + }); + }); + + it('should report success to the health indicator when queries are successful', async () => { + mockExec.mockResolvedValue({ stdout: 'RSSI return value: -4' }); + await service.inquireClassicRssi(0, ''); + + expect(healthIndicator.reportSuccess).toHaveBeenCalledTimes(1); + }); + + it('should report an error to the health indicator when queries are unsuccessful', async () => { + mockExec.mockRejectedValue({ message: 'critical error' }); + await service.inquireClassicRssi(0, ''); + + expect(healthIndicator.reportError).toHaveBeenCalledTimes(1); + }); + + it('should not report anything to the health indicator if the device was not reachable', async () => { + mockExec.mockRejectedValue({ + message: 'Could not connect: Input/output error', + }); + await service.inquireClassicRssi(0, ''); + + expect(healthIndicator.reportSuccess).not.toHaveBeenCalled(); + expect(healthIndicator.reportError).not.toHaveBeenCalled(); + }); + + it('should not report an error if the scan was stopped due to low time limits', async () => { + mockExec.mockRejectedValue({ + message: 'killed', + signal: 'SIGKILL', + }); + await service.inquireClassicRssi(0, ''); + + expect(healthIndicator.reportError).not.toHaveBeenCalled(); + }); + }); + + describe('Bluetooth Low Energy', () => { + it('should setup noble listeners on the first subscriber', () => { + const callback = () => undefined; + service.onLowEnergyDiscovery(callback); + expect(mockNoble.on).toHaveBeenCalledWith( + 'stateChange', + expect.any(Function) + ); + expect(mockNoble.on).toHaveBeenCalledWith('discover', callback); + }); + + it('should only setup noble listeners once', () => { + service.onLowEnergyDiscovery(() => undefined); + service.onLowEnergyDiscovery(() => undefined); + expect(mockNoble.on).toHaveBeenCalledTimes(4); + }); + + it('should enable scanning when the adapter is inactive', () => { + service.onLowEnergyDiscovery(() => undefined); + const stateChangeHandler = mockNoble.on.mock.calls[0][1]; + + stateChangeHandler('poweredOn'); + + expect(mockNoble.startScanning).toHaveBeenCalledTimes(1); + }); + + it('should not enable scanning when the adapter is performing a Classic inquiry', async () => { + service.onLowEnergyDiscovery(() => undefined); + const stateChangeHandler = mockNoble.on.mock.calls[0][1]; + + let execResolve; + const execPromise = new Promise((r) => (execResolve = r)); + mockExec.mockReturnValue(execPromise); + service.inquireClassicRssi(0, 'x'); + + stateChangeHandler('poweredOn'); + + expect(mockNoble.startScanning).not.toHaveBeenCalled(); + + await execResolve({ stdout: '-1' }); + expect(mockNoble.startScanning).toHaveBeenCalledTimes(1); + }); + + it('should continue scanning if Classic inquiries are performed on another adapter', async () => { + service.onLowEnergyDiscovery(() => undefined); + const stateChangeHandler = mockNoble.on.mock.calls[0][1]; + stateChangeHandler('poweredOn'); + + const execPromise = Promise.resolve({ stdout: '-1' }); + mockExec.mockReturnValue(execPromise); + await service.inquireClassicRssi(1, 'x'); + + expect(mockNoble.startScanning).toHaveBeenCalledTimes(1); + expect(mockNoble.stopScanning).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/integrations/bluetooth/bluetooth.service.ts b/src/integrations/bluetooth/bluetooth.service.ts new file mode 100644 index 0000000..05d4bdb --- /dev/null +++ b/src/integrations/bluetooth/bluetooth.service.ts @@ -0,0 +1,201 @@ +import { Injectable, Logger } from '@nestjs/common'; +import noble, { Peripheral } from '@abandonware/noble'; +import util from 'util'; +import { exec } from 'child_process'; +import { BluetoothHealthIndicator } from './bluetooth.health'; +import { BluetoothClassicConfig } from '../bluetooth-classic/bluetooth-classic.config'; +import { ConfigService } from '../../config/config.service'; +import { Device } from '../bluetooth-classic/device'; + +type BluetoothAdapterState = 'inquiry' | 'scan' | 'inactive'; + +const execPromise = util.promisify(exec); +const rssiRegex = new RegExp(/-?[0-9]+/); + +@Injectable() +export class BluetoothService { + private readonly logger: Logger = new Logger(BluetoothService.name); + private readonly classicConfig: BluetoothClassicConfig; + private readonly adapterStates = new Map(); + private lowEnergyAdapterId: number; + + constructor( + private readonly configService: ConfigService, + private readonly healthIndicator: BluetoothHealthIndicator + ) { + this.classicConfig = this.configService.get('bluetoothClassic'); + } + + /** + * Registers a callback function that will be invoked when a + * Bluetooth Low Energy peripheral advertisement was received. + * + * @param callback - Callback function that receives a peripheral + */ + onLowEnergyDiscovery(callback: (peripheral: Peripheral) => void): void { + if (this.lowEnergyAdapterId == undefined) { + this.setupNoble(); + } + + noble.on('discover', callback); + } + + /** + * Queries for the RSSI of a Bluetooth device using the hcitool shell command. + * + * @param adapterId - HCI Adapter ID to use for queries + * @param address - Bluetooth MAC address + * @returns RSSI value + */ + async inquireClassicRssi( + adapterId: number, + address: string + ): Promise { + this.lockAdapter(adapterId); + + this.logger.debug(`Querying for RSSI of ${address} using hcitool`); + try { + const output = await execPromise( + `hcitool -i hci${adapterId} cc "${address}" && hcitool -i hci${adapterId} rssi "${address}"`, + { + timeout: this.classicConfig.scanTimeLimit * 1000, + killSignal: 'SIGKILL', + } + ); + const matches = output.stdout.match(rssiRegex); + + this.healthIndicator.reportSuccess(); + + return matches?.length > 0 ? parseInt(matches[0], 10) : undefined; + } catch (e) { + if (e.signal === 'SIGKILL') { + this.logger.debug( + `Query of ${address} reached scan time limit, resetting hci${this.classicConfig.hciDeviceId}` + ); + this.resetHciDevice(adapterId); + + // when not reachable a scan runs for 6s, so lower time limits might not be an error + if (this.classicConfig.scanTimeLimit >= 6) { + this.healthIndicator.reportError(); + } + } else if ( + e.message?.includes('Input/output') || + e.message?.includes('I/O') + ) { + this.logger.debug(e.message); + } else { + this.logger.error(e.message); + this.healthIndicator.reportError(); + } + + return undefined; + } finally { + this.unlockAdapter(adapterId); + } + } + + /** + * Inquires device information of a Bluetooth peripheral. + * + * @param adapterId - HCI Adapter ID to use for queries + * @param address - Bluetooth MAC address + * @returns Device information + */ + async inquireClassicDeviceInfo( + adapterId: number, + address: string + ): Promise { + this.lockAdapter(adapterId); + + try { + const output = await execPromise( + `hcitool -i hci${adapterId} info "${address}"` + ); + + const nameMatches = /Device Name: (.+)/g.exec(output.stdout); + const manufacturerMatches = /OUI Company: (.+) \(.+\)/g.exec( + output.stdout + ); + + return { + address, + name: nameMatches ? nameMatches[1] : address, + manufacturer: manufacturerMatches ? manufacturerMatches[1] : undefined, + }; + } catch (e) { + this.logger.error(e.message, e.stack); + return { + address, + name: address, + }; + } finally { + this.unlockAdapter(adapterId); + } + } + + /** + * Reset the hci (Bluetooth) device used for inquiries. + */ + protected async resetHciDevice(adapterId: number): Promise { + try { + await execPromise(`hciconfig hci${adapterId} reset`); + } catch (e) { + this.logger.error(e.message); + } + } + + /** + * Locks an adapter for an active inquiry. + * + * @param adapterId - HCI Device ID of the adapter to lock + */ + protected lockAdapter(adapterId: number): void { + if (this.adapterStates.get(adapterId) == 'scan') { + noble.stopScanning(); + } + + this.adapterStates.set(adapterId, 'inquiry'); + } + + /** + * Unlocks an adapter and returns it to scan or inactive state. + * + * @param adapterId - HCI Device ID of the adapter to unlock + */ + protected unlockAdapter(adapterId: number): void { + this.adapterStates.set(adapterId, 'inactive'); + + if (adapterId == this.lowEnergyAdapterId) { + this.handleAdapterStateChange(noble.state); + } + } + + /** + * Sets up Noble hooks. + */ + private setupNoble(): void { + this.lowEnergyAdapterId = parseInt(process.env.NOBLE_HCI_DEVICE_ID) || 0; + + noble.on('stateChange', this.handleAdapterStateChange.bind(this)); + noble.on('warning', (message) => { + this.logger.warn(message); + }); + } + + /** + * Handles state adapter changes as reported by Noble. + * + * @param state - State of the HCI adapter + */ + private handleAdapterStateChange(state: string): void { + if (this.adapterStates.get(this.lowEnergyAdapterId) != 'inquiry') { + if (state === 'poweredOn') { + noble.startScanning([], true); + this.adapterStates.set(this.lowEnergyAdapterId, 'scan'); + } else { + noble.stopScanning(); + this.adapterStates.set(this.lowEnergyAdapterId, 'inactive'); + } + } + } +} diff --git a/src/integrations/xiaomi-mi/xiaomi-mi.module.ts b/src/integrations/xiaomi-mi/xiaomi-mi.module.ts index c68b809..a4ff20e 100644 --- a/src/integrations/xiaomi-mi/xiaomi-mi.module.ts +++ b/src/integrations/xiaomi-mi/xiaomi-mi.module.ts @@ -3,13 +3,14 @@ import { DynamicModule, Module } from '@nestjs/common'; import { XiaomiMiService } from './xiaomi-mi.service'; import { EntitiesModule } from '../../entities/entities.module'; import { ConfigModule } from '../../config/config.module'; +import { BluetoothModule } from '../bluetooth/bluetooth.module'; @Module({}) export default class XiaomiMiModule { static forRoot(): DynamicModule { return { module: XiaomiMiModule, - imports: [EntitiesModule, ConfigModule], + imports: [BluetoothModule, EntitiesModule, ConfigModule], providers: [XiaomiMiService], }; } diff --git a/src/integrations/xiaomi-mi/xiaomi-mi.service.spec.ts b/src/integrations/xiaomi-mi/xiaomi-mi.service.spec.ts index 1623769..248ff0d 100644 --- a/src/integrations/xiaomi-mi/xiaomi-mi.service.spec.ts +++ b/src/integrations/xiaomi-mi/xiaomi-mi.service.spec.ts @@ -1,14 +1,3 @@ -const mockNoble = { - on: jest.fn(), -}; -jest.mock( - '@abandonware/noble', - () => { - return mockNoble; - }, - { virtual: true } -); - import { Peripheral } from '@abandonware/noble'; import { ConfigService } from '../../config/config.service'; import { Test, TestingModule } from '@nestjs/testing'; @@ -21,9 +10,14 @@ import { XiaomiMiConfig } from './xiaomi-mi.config'; import { Sensor } from '../../entities/sensor'; import { SensorConfig } from '../home-assistant/sensor-config'; import c from 'config'; +import { BluetoothService } from '../bluetooth/bluetooth.service'; +import { BluetoothModule } from '../bluetooth/bluetooth.module'; describe('XiaomiMiService', () => { let service: XiaomiMiService; + const bluetoothService = { + onLowEnergyDiscovery: jest.fn(), + }; const entitiesService = { get: jest.fn(), add: jest.fn(), @@ -74,9 +68,11 @@ describe('XiaomiMiService', () => { mockConfig.sensors = [{ name: 'test', address: testAddress }]; const module: TestingModule = await Test.createTestingModule({ - imports: [EntitiesModule, ConfigModule], + imports: [BluetoothModule, EntitiesModule, ConfigModule], providers: [XiaomiMiService], }) + .overrideProvider(BluetoothService) + .useValue(bluetoothService) .overrideProvider(EntitiesService) .useValue(entitiesService) .overrideProvider(ConfigService) @@ -90,14 +86,11 @@ describe('XiaomiMiService', () => { service.onModuleInit(); }); - it('should setup noble listeners on bootstrap', () => { + it('should setup BLE listener on bootstrap', () => { service.onApplicationBootstrap(); - expect(mockNoble.on).toHaveBeenCalledWith( - 'stateChange', + expect(bluetoothService.onLowEnergyDiscovery).toHaveBeenCalledWith( expect.any(Function) ); - expect(mockNoble.on).toHaveBeenCalledWith('discover', expect.any(Function)); - expect(mockNoble.on).toHaveBeenCalledWith('warning', expect.any(Function)); }); it('should warn if no sensors have been configured', () => { diff --git a/src/integrations/xiaomi-mi/xiaomi-mi.service.ts b/src/integrations/xiaomi-mi/xiaomi-mi.service.ts index 98734ce..fdd9855 100644 --- a/src/integrations/xiaomi-mi/xiaomi-mi.service.ts +++ b/src/integrations/xiaomi-mi/xiaomi-mi.service.ts @@ -4,7 +4,7 @@ import { OnApplicationBootstrap, OnModuleInit, } from '@nestjs/common'; -import noble, { Peripheral, Advertisement } from '@abandonware/noble'; +import { Peripheral, Advertisement } from '@abandonware/noble'; import { EntitiesService } from '../../entities/entities.service'; import { ConfigService } from '../../config/config.service'; import { XiaomiMiSensorOptions } from './xiaomi-mi.config'; @@ -13,6 +13,7 @@ import { SERVICE_DATA_UUID, ServiceData, Parser, EventTypes } from './parser'; import { Sensor } from '../../entities/sensor'; import { EntityCustomization } from '../../entities/entity-customization.interface'; import { SensorConfig } from '../home-assistant/sensor-config'; +import { BluetoothService } from '../bluetooth/bluetooth.service'; @Injectable() export class XiaomiMiService implements OnModuleInit, OnApplicationBootstrap { @@ -20,6 +21,7 @@ export class XiaomiMiService implements OnModuleInit, OnApplicationBootstrap { private readonly logger: Logger; constructor( + private readonly bluetoothService: BluetoothService, private readonly entitiesService: EntitiesService, private readonly configService: ConfigService ) { @@ -46,16 +48,7 @@ export class XiaomiMiService implements OnModuleInit, OnApplicationBootstrap { * Lifecycle hook, called once the application has started. */ onApplicationBootstrap(): void { - noble.on('stateChange', XiaomiMiService.handleStateChange); - noble.on('discover', this.handleDiscovery.bind(this)); - noble.on('warning', this.onWarning.bind(this)); - } - - /** - * Log warnings from noble. - */ - private onWarning(message: string): void { - this.logger.warn('Warning: ', message); + this.bluetoothService.onLowEnergyDiscovery(this.handleDiscovery.bind(this)); } /** @@ -233,17 +226,4 @@ export class XiaomiMiService implements OnModuleInit, OnApplicationBootstrap { ): ServiceData { return new Parser(buffer, bindKey).parse(); } - - /** - * Stops or starts BLE scans based on the adapter state. - * - * @param state - Noble adapter state string - */ - private static handleStateChange(state: string): void { - if (state === 'poweredOn') { - noble.startScanning([], true); - } else { - noble.stopScanning(); - } - } }