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 c22d9b9..fb3c600 100644 --- a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts +++ b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts @@ -522,9 +522,9 @@ export class BluetoothLowEnergyService this.handleAppDiscovery(tag.id, appId); } else { this.logger.debug( - `Tag ${tag.id} should have the companion app, retrying in a minute` + `Tag ${tag.id} should have the companion app, retrying in 15s` ); - this.banDeviceFromDiscovery(tag.id, 60 * 1000); + this.banDeviceFromDiscovery(tag.id, 15 * 1000); } } diff --git a/src/integrations/bluetooth/bluetooth.service.spec.ts b/src/integrations/bluetooth/bluetooth.service.spec.ts index fd68a6a..b5b831f 100644 --- a/src/integrations/bluetooth/bluetooth.service.spec.ts +++ b/src/integrations/bluetooth/bluetooth.service.spec.ts @@ -7,6 +7,7 @@ const mockNoble = { startScanning: jest.fn(), stopScanning: jest.fn(), reset: jest.fn(), + resetBindings: jest.fn(), }; jest.mock( '@mkerix/noble', @@ -354,6 +355,7 @@ Requesting information ... connectable: true, connectAsync: jest.fn().mockReturnValue(connectPromise), once: jest.fn(), + state: 'disconnected', }; service.connectLowEnergyDevice((peripheral as unknown) as Peripheral); @@ -362,14 +364,20 @@ Requesting information ... service.connectLowEnergyDevice((peripheral as unknown) as Peripheral) ).rejects.toThrow(); + peripheral.state = 'connected'; connectResolve(); }); it('should unlock the adapter on disconnect', async () => { + jest.spyOn(Promises, 'sleep').mockResolvedValue(); const peripheral = { connectable: true, - connectAsync: jest.fn().mockResolvedValue(undefined), + connectAsync: jest.fn().mockImplementation(() => { + peripheral.state = 'connected'; + return Promise.resolve(); + }), once: jest.fn(), + state: 'disconnected', }; await service.connectLowEnergyDevice( @@ -431,10 +439,15 @@ Requesting information ... }); it('should return the peripheral after connecting', async () => { + jest.spyOn(Promises, 'sleep').mockResolvedValue(); const peripheral = { connectable: true, - connectAsync: jest.fn().mockResolvedValue(undefined), + connectAsync: jest.fn().mockImplementation(() => { + peripheral.state = 'connected'; + return Promise.resolve(); + }), once: jest.fn(), + state: 'disconnected', }; const actual = await service.connectLowEnergyDevice( @@ -444,6 +457,46 @@ Requesting information ... expect(actual).toBe(peripheral); }); + it('should retry connection attempts after immediate disconnects', async () => { + const peripheral = { + connectable: true, + connectAsync: jest + .fn() + .mockResolvedValueOnce(undefined) + .mockImplementation(() => { + peripheral.state = 'connected'; + return Promise.resolve(); + }), + once: jest.fn(), + state: 'disconnected', + }; + + const actual = await service.connectLowEnergyDevice( + (peripheral as unknown) as Peripheral + ); + expect(actual).toBe(peripheral); + expect(peripheral.connectAsync).toHaveBeenCalledTimes(2); + }); + + it('should throw after multiple connection attempts', async () => { + jest.spyOn(Promises, 'sleep').mockResolvedValue(); + const peripheral = { + connectable: true, + connectAsync: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + once: jest.fn(), + removeAllListeners: jest.fn(), + state: 'disconnected', + }; + + await expect(async () => { + await service.connectLowEnergyDevice( + (peripheral as unknown) as Peripheral + ); + }).rejects.toThrow(); + expect(peripheral.connectAsync).toHaveBeenCalledTimes(5); + }); + it('should disconnect from a peripheral', async () => { const peripheral = { state: 'connected', diff --git a/src/integrations/bluetooth/bluetooth.service.ts b/src/integrations/bluetooth/bluetooth.service.ts index c707430..46558c5 100644 --- a/src/integrations/bluetooth/bluetooth.service.ts +++ b/src/integrations/bluetooth/bluetooth.service.ts @@ -117,25 +117,13 @@ export class BluetoothService { ); } - this.logger.debug( - `Connecting to BLE device at address ${peripheral.address}` - ); this.lockAdapter(this._lowEnergyAdapterId); - peripheral.once('disconnect', (e) => { - if (e) { - this.logger.error(e); - } else { - this.logger.debug( - `Disconnected from BLE device at address ${peripheral.address}` - ); - } - - this.unlockAdapter(this._lowEnergyAdapterId); - }); - try { - await promiseWithTimeout(peripheral.connectAsync(), 10 * 1000); + await promiseWithTimeout( + this.connectLowEnergyDeviceWithRetry(peripheral, 5), + 10 * 1000 + ); return peripheral; } catch (e) { this.logger.error( @@ -493,4 +481,47 @@ export class BluetoothService { this.adapters.setState(this._lowEnergyAdapterId, 'inactive'); } } + + /** + * Connect to a peripheral and retry if it immediately disconnects. + * + * @param peripheral - BLE peripheral to connect to + * @param tries - Amount of connection attempts before failing + */ + private async connectLowEnergyDeviceWithRetry( + peripheral: Peripheral, + tries: number + ): Promise { + if (tries <= 0) { + this.unlockAdapter(this._lowEnergyAdapterId); + throw new Error( + `Maximum retries reached while connecting to ${peripheral.address}` + ); + } + + this.logger.debug( + `Connecting to BLE device at address ${peripheral.address}` + ); + + await peripheral.connectAsync(); + await sleep(500); // https://github.com/mKeRix/room-assistant/issues/508 + + if (peripheral.state != 'connected') { + return this.connectLowEnergyDeviceWithRetry(peripheral, tries - 1); + } else { + peripheral.once('disconnect', (e) => { + if (e) { + this.logger.error(e); + } else { + this.logger.debug( + `Disconnected from BLE device at address ${peripheral.address}` + ); + } + + this.unlockAdapter(this._lowEnergyAdapterId); + }); + + return peripheral; + } + } }