Skip to content

Commit

Permalink
fix(bluetooth-low-energy): retry on immediate disconnects
Browse files Browse the repository at this point in the history
Closes #508
  • Loading branch information
mKeRix committed Feb 13, 2021
1 parent ce64847 commit 37000b8
Show file tree
Hide file tree
Showing 3 changed files with 104 additions and 20 deletions.
Expand Up @@ -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);
}
}

Expand Down
57 changes: 55 additions & 2 deletions src/integrations/bluetooth/bluetooth.service.spec.ts
Expand Up @@ -7,6 +7,7 @@ const mockNoble = {
startScanning: jest.fn(),
stopScanning: jest.fn(),
reset: jest.fn(),
resetBindings: jest.fn(),
};
jest.mock(
'@mkerix/noble',
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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',
Expand Down
63 changes: 47 additions & 16 deletions src/integrations/bluetooth/bluetooth.service.ts
Expand Up @@ -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(
Expand Down Expand Up @@ -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<Peripheral> {
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;
}
}
}

0 comments on commit 37000b8

Please sign in to comment.