From 4cdeb24d0cf03ffa7f1e52dc595e915337ccd581 Mon Sep 17 00:00:00 2001 From: Heiko Rothe Date: Sun, 13 Dec 2020 20:32:49 +0100 Subject: [PATCH] feat(bluetooth): reset deadlocked Bluetooth adapters If an adapter is not unlocked quick enough it will be reset and force-unlocked. --- .../bluetooth/bluetooth.module.ts | 3 +- .../bluetooth/bluetooth.service.spec.ts | 15 +++++ .../bluetooth/bluetooth.service.ts | 58 +++++++++++++++---- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/integrations/bluetooth/bluetooth.module.ts b/src/integrations/bluetooth/bluetooth.module.ts index 340a2b6..6ed6ad6 100644 --- a/src/integrations/bluetooth/bluetooth.module.ts +++ b/src/integrations/bluetooth/bluetooth.module.ts @@ -3,9 +3,10 @@ import { BluetoothService } from './bluetooth.service'; import { BluetoothHealthIndicator } from './bluetooth.health'; import { ConfigModule } from '../../config/config.module'; import { StatusModule } from '../../status/status.module'; +import { ScheduleModule } from '@nestjs/schedule'; @Module({ - imports: [ConfigModule, StatusModule], + imports: [ConfigModule, StatusModule, ScheduleModule.forRoot()], providers: [BluetoothService, BluetoothHealthIndicator], exports: [BluetoothService], }) diff --git a/src/integrations/bluetooth/bluetooth.service.spec.ts b/src/integrations/bluetooth/bluetooth.service.spec.ts index ff171bc..02ba56b 100644 --- a/src/integrations/bluetooth/bluetooth.service.spec.ts +++ b/src/integrations/bluetooth/bluetooth.service.spec.ts @@ -413,4 +413,19 @@ Requesting information ... expect(peripheral.disconnectAsync).toHaveBeenCalled(); }); }); + + it('should reset adapters that have been locked for too long', () => { + jest.useFakeTimers('modern'); + + service.lockAdapter(0); // should time out + + service.unlockAdapter(1); // already unlocked + jest.setSystemTime(Date.now() + 31 * 1000); + service.lockAdapter(2); // should not time out + + service.resetDeadlockedAdapters(); + + expect(mockExec).toHaveBeenCalledTimes(1); + expect(mockExec).toHaveBeenCalledWith('hciconfig hci0 reset'); + }); }); diff --git a/src/integrations/bluetooth/bluetooth.service.ts b/src/integrations/bluetooth/bluetooth.service.ts index 7257371..c7da41c 100644 --- a/src/integrations/bluetooth/bluetooth.service.ts +++ b/src/integrations/bluetooth/bluetooth.service.ts @@ -7,18 +7,36 @@ import { BluetoothClassicConfig } from '../bluetooth-classic/bluetooth-classic.c import { ConfigService } from '../../config/config.service'; import { Device } from '../bluetooth-classic/device'; import { promiseWithTimeout } from '../../util/promises'; +import { Interval } from '@nestjs/schedule'; + +const RSSI_REGEX = new RegExp(/-?[0-9]+/); +const INQUIRY_LOCK_TIMEOUT = 30 * 1000; + +const execPromise = util.promisify(exec); type BluetoothAdapterState = 'inquiry' | 'scan' | 'inactive'; type ExecOutput = { stdout: string; stderr: string }; -const execPromise = util.promisify(exec); -const rssiRegex = new RegExp(/-?[0-9]+/); +class BluetoothAdapter { + state: BluetoothAdapterState; + startedAt: Date; +} + +class BluetoothAdapterMap extends Map { + getState(key: number): BluetoothAdapterState { + return this.get(key)?.state; + } + + setState(key: number, state: BluetoothAdapterState): this { + return this.set(key, { state, startedAt: new Date() }); + } +} @Injectable() export class BluetoothService { private readonly logger: Logger = new Logger(BluetoothService.name); private readonly classicConfig: BluetoothClassicConfig; - private readonly adapterStates = new Map(); + private readonly adapters = new BluetoothAdapterMap(); private lowEnergyAdapterId: number; constructor( @@ -130,7 +148,7 @@ export class BluetoothService { ), this.classicConfig.scanTimeLimit * 1000 * 1.1 ); - const matches = output.stdout.match(rssiRegex); + const matches = output.stdout.match(RSSI_REGEX); this.healthIndicator.reportSuccess(); @@ -221,7 +239,7 @@ export class BluetoothService { lockAdapter(adapterId: number): void { this.logger.debug(`Locking adapter ${adapterId}`); - switch (this.adapterStates.get(adapterId)) { + switch (this.adapters.getState(adapterId)) { case 'inquiry': throw new Error( `Trying to lock adapter ${adapterId} even though it is already locked` @@ -233,7 +251,7 @@ export class BluetoothService { noble.stopScanning(); } - this.adapterStates.set(adapterId, 'inquiry'); + this.adapters.setState(adapterId, 'inquiry'); } /** @@ -243,13 +261,33 @@ export class BluetoothService { */ async unlockAdapter(adapterId: number): Promise { this.logger.debug(`Unlocking adapter ${adapterId}`); - this.adapterStates.set(adapterId, 'inactive'); + this.adapters.setState(adapterId, 'inactive'); if (adapterId == this.lowEnergyAdapterId) { await this.handleAdapterStateChange(noble.state); } } + /** + * Checks if any adapters had a lock acquired on them for longer than + * INQUIRY_LOCK_TIMEOUT and resets them before unlocking them again. + */ + @Interval(10 * 1000) + resetDeadlockedAdapters(): void { + this.adapters.forEach(async (adapter, adapterId) => { + if ( + adapter.state === 'inquiry' && + adapter.startedAt.getTime() < Date.now() - INQUIRY_LOCK_TIMEOUT + ) { + this.logger.log( + `Detected unusually long lock on Bluetooth adapter ${adapterId}, resetting` + ); + await this.resetHciDevice(adapterId); + await this.unlockAdapter(adapterId); + } + }); + } + /** * Sets up Noble hooks. */ @@ -272,19 +310,19 @@ export class BluetoothService { * @param state - State of the HCI adapter */ private async handleAdapterStateChange(state: string): Promise { - if (this.adapterStates.get(this.lowEnergyAdapterId) != 'inquiry') { + if (this.adapters.getState(this.lowEnergyAdapterId) != 'inquiry') { if (state === 'poweredOn') { this.logger.debug( `Start scanning for BLE peripherals on adapter ${this.lowEnergyAdapterId}` ); await noble.startScanningAsync([], true); - this.adapterStates.set(this.lowEnergyAdapterId, 'scan'); + this.adapters.setState(this.lowEnergyAdapterId, 'scan'); } else { this.logger.debug( `Stop scanning for BLE peripherals on adapter ${this.lowEnergyAdapterId}` ); await noble.stopScanning(); - this.adapterStates.set(this.lowEnergyAdapterId, 'inactive'); + this.adapters.setState(this.lowEnergyAdapterId, 'inactive'); } } }