Skip to content

Commit

Permalink
feat(bluetooth-low-energy): add health indicator for advertisement ti…
Browse files Browse the repository at this point in the history
…meouts
  • Loading branch information
mKeRix committed May 24, 2021
1 parent 3c97c0c commit f0cea61
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 69 deletions.
51 changes: 32 additions & 19 deletions src/integration-support/bluetooth/bluetooth.health.spec.ts
@@ -1,35 +1,48 @@
import { BluetoothHealthIndicator } from './bluetooth.health';
import { HealthCheckError } from '@nestjs/terminus';
import { BluetoothService } from './bluetooth.service';

describe('BluetoothHealthIndicator', () => {
let healthIndicator: BluetoothHealthIndicator;
let mockBluetoothService: any;

beforeEach(() => {
healthIndicator = new BluetoothHealthIndicator();
mockBluetoothService = {
successiveErrorsOccurred: 0,
timeSinceLastDiscovery: null,
} as BluetoothService;
healthIndicator = new BluetoothHealthIndicator(mockBluetoothService);
});

it('should report healthy by default', () => {
const result = healthIndicator.successiveErrorCheck(3);
expect(result['bt_successive_errors'].status).toEqual('up');
});
describe('ble_advertisement_timeout', () => {
it('should report healthy by default', () => {
const result = healthIndicator.lowEnergyAdvertisementTimeoutCheck(
2 * 60 * 1000
);
expect(result['ble_advertisement_timeout'].status).toEqual('up');
});

it('should report unhealthy after meeting the threshold', () => {
healthIndicator.reportError();
healthIndicator.reportError();
healthIndicator.reportError();
it('should report unhealthy after meeting the threshold', () => {
mockBluetoothService.timeSinceLastDiscovery = 120000;

expect(() => healthIndicator.successiveErrorCheck(3)).toThrow(
HealthCheckError
);
expect(() =>
healthIndicator.lowEnergyAdvertisementTimeoutCheck(2 * 60 * 1000)
).toThrow(HealthCheckError);
});
});

it('should reset the error count on success', () => {
healthIndicator.reportError();
healthIndicator.reportError();
healthIndicator.reportError();
healthIndicator.reportSuccess();
describe('bt_successive_errors', () => {
it('should report healthy by default', () => {
const result = healthIndicator.successiveErrorCheck(3);
expect(result['bt_successive_errors'].status).toEqual('up');
});

it('should report unhealthy after meeting the threshold', () => {
mockBluetoothService.successiveErrorsOccurred = 3;

const result = healthIndicator.successiveErrorCheck(3);
expect(result['bt_successive_errors'].status).toEqual('up');
expect(() => healthIndicator.successiveErrorCheck(3)).toThrow(
HealthCheckError
);
});
});
});
35 changes: 23 additions & 12 deletions src/integration-support/bluetooth/bluetooth.health.ts
Expand Up @@ -5,36 +5,47 @@ import {
} from '@nestjs/terminus';
import { Injectable, Optional } from '@nestjs/common';
import { HealthIndicatorService } from '../../status/health-indicator.service';
import { BluetoothService } from './bluetooth.service';

@Injectable()
export class BluetoothHealthIndicator extends HealthIndicator {
private errorsOccurred = 0;

constructor(@Optional() healthIndicatorService?: HealthIndicatorService) {
constructor(
private bluetoothService: BluetoothService,
@Optional() healthIndicatorService?: HealthIndicatorService
) {
super();
healthIndicatorService?.registerHealthIndicator(async () =>
this.lowEnergyAdvertisementTimeoutCheck(2 * 60 * 1000)
);
healthIndicatorService?.registerHealthIndicator(async () =>
this.successiveErrorCheck(3)
);
}

successiveErrorCheck(threshold: number): HealthIndicatorResult {
const isHealthy = this.errorsOccurred < threshold;
const result = this.getStatus(`bt_successive_errors`, isHealthy);
lowEnergyAdvertisementTimeoutCheck(threshold: number): HealthIndicatorResult {
const isHealthy = this.bluetoothService.timeSinceLastDiscovery < threshold;
const result = this.getStatus('ble_advertisement_timeout', isHealthy);

if (isHealthy) {
return result;
}
throw new HealthCheckError(
'BT Classic successive error check failed',
`No BLE advertisements recorded in over ${threshold}ms`,
result
);
}

reportError(): void {
this.errorsOccurred++;
}
successiveErrorCheck(threshold: number): HealthIndicatorResult {
const isHealthy =
this.bluetoothService.successiveErrorsOccurred < threshold;
const result = this.getStatus(`bt_successive_errors`, isHealthy);

reportSuccess(): void {
this.errorsOccurred = 0;
if (isHealthy) {
return result;
}
throw new HealthCheckError(
'BT Classic successive error check failed',
result
);
}
}
40 changes: 19 additions & 21 deletions src/integration-support/bluetooth/bluetooth.service.spec.ts
Expand Up @@ -22,10 +22,6 @@ const mockBleno = mocked(bleno);

describe('BluetoothService', () => {
let service: BluetoothService;
const healthIndicator = {
reportError: jest.fn(),
reportSuccess: jest.fn(),
};

beforeEach(async () => {
jest.clearAllMocks();
Expand All @@ -40,10 +36,7 @@ describe('BluetoothService', () => {
help: 'help',
}),
],
})
.overrideProvider(BluetoothHealthIndicator)
.useValue(healthIndicator)
.compile();
}).compile();
service = module.get<BluetoothService>(BluetoothService);
});

Expand Down Expand Up @@ -210,31 +203,36 @@ Requesting information ...
});
});

it('should report success to the health indicator when queries are successful', async () => {
mockExec.mockResolvedValue({ stdout: 'RSSI return value: -4' });
it('should reset occurred error count when queries are successful', async () => {
mockExec
.mockRejectedValueOnce({ message: 'critical error' })
.mockResolvedValue({ stdout: 'RSSI return value: -4' });
await service.inquireClassicRssi(0, '');
await service.inquireClassicRssi(0, '');

expect(healthIndicator.reportSuccess).toHaveBeenCalledTimes(1);
expect(service.successiveErrorsOccurred).toBe(0);
});

it('should report an error to the health indicator when queries are unsuccessful', async () => {
it('should add to occurred error count when queries are unsuccessful', async () => {
mockExec.mockRejectedValue({ message: 'critical error' });
await service.inquireClassicRssi(0, '');

expect(healthIndicator.reportError).toHaveBeenCalledTimes(1);
expect(service.successiveErrorsOccurred).toBe(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',
});
it('should not change the occurred error count if the device was not reachable', async () => {
mockExec
.mockRejectedValueOnce({ message: 'critical error' })
.mockRejectedValue({
message: 'Could not connect: Input/output error',
});
await service.inquireClassicRssi(0, '');
await service.inquireClassicRssi(0, '');

expect(healthIndicator.reportSuccess).not.toHaveBeenCalled();
expect(healthIndicator.reportError).not.toHaveBeenCalled();
expect(service.successiveErrorsOccurred).toBe(1);
});

it('should not report an error if the scan was stopped due to low time limits', async () => {
it('should not add to the occurred error count if the scan was stopped due to low time limits', async () => {
mockExec
.mockRejectedValueOnce({
message: 'killed',
Expand All @@ -243,7 +241,7 @@ Requesting information ...
.mockResolvedValue({});
await service.inquireClassicRssi(0, '');

expect(healthIndicator.reportError).not.toHaveBeenCalled();
expect(service.successiveErrorsOccurred).toBe(0);
});
});

Expand Down
54 changes: 37 additions & 17 deletions src/integration-support/bluetooth/bluetooth.service.ts
@@ -1,8 +1,7 @@
import { Injectable, Logger, OnApplicationShutdown } from "@nestjs/common";
import { Injectable, Logger, OnApplicationShutdown } from '@nestjs/common';
import noble, { Peripheral } from '@mkerix/noble';
import util from 'util';
import { exec } from 'child_process';
import { BluetoothHealthIndicator } from './bluetooth.health';
import { BluetoothClassicConfig } from '../../integrations/bluetooth-classic/bluetooth-classic.config';
import { ConfigService } from '../../config/config.service';
import { Device } from '../../integrations/bluetooth-classic/device';
Expand All @@ -11,12 +10,12 @@ import { Interval } from '@nestjs/schedule';
import _ from 'lodash';
import { Counter } from 'prom-client';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import bleno from "bleno";
import bleno from 'bleno';

const RSSI_REGEX = new RegExp(/-?[0-9]+/);
const INQUIRY_LOCK_TIMEOUT = 30 * 1000;
const SCAN_NO_PERIPHERAL_TIMEOUT = 30 * 1000;
const IBEACON_UUID = 'D1338ACE-002D-44AF-88D1-E57C12484966'
const IBEACON_UUID = 'D1338ACE-002D-44AF-88D1-E57C12484966';

const execPromise = util.promisify(exec);

Expand Down Expand Up @@ -44,12 +43,12 @@ export class BluetoothService implements OnApplicationShutdown {
private readonly classicConfig: BluetoothClassicConfig;
private readonly adapters = new BluetoothAdapterMap();
private _lowEnergyAdapterId: number;
private _successiveErrorsOccurred = 0;
private lastLowEnergyDiscovery: Date;
private scanStartedAt?: Date;

constructor(
private readonly configService: ConfigService,
private readonly healthIndicator: BluetoothHealthIndicator,
@InjectMetric('bluetooth_le_advertisements_received_count')
private readonly advertisementReceivedCounter: Counter<string>
) {
Expand All @@ -61,7 +60,7 @@ export class BluetoothService implements OnApplicationShutdown {
* scan state started if no discovery was made yet.
* Returns null if the time is unknown.
*/
private get timeSinceLastDiscovery(): number | null {
get timeSinceLastDiscovery(): number | null {
if (
this._lowEnergyAdapterId == null ||
!['inactive', 'scan'].includes(
Expand All @@ -86,12 +85,22 @@ export class BluetoothService implements OnApplicationShutdown {
return this._lowEnergyAdapterId;
}

/**
* Returns the current number of successive BT Classic errors.
*/
get successiveErrorsOccurred(): number {
return this._successiveErrorsOccurred;
}

/**
* Application hook, called when the application shuts down.
*/
onApplicationShutdown(): void {
if (this._lowEnergyAdapterId != null && this.adapters.getState(this._lowEnergyAdapterId) != 'inquiry') {
noble.stopScanning()
if (
this._lowEnergyAdapterId != null &&
this.adapters.getState(this._lowEnergyAdapterId) != 'inquiry'
) {
noble.stopScanning();
bleno.stopAdvertising();
}
}
Expand Down Expand Up @@ -199,7 +208,7 @@ export class BluetoothService implements OnApplicationShutdown {
);
const matches = output.stdout.match(RSSI_REGEX);

this.healthIndicator.reportSuccess();
this._successiveErrorsOccurred = 0;

return matches?.length > 0 ? parseInt(matches[0], 10) : undefined;
} catch (e) {
Expand All @@ -215,7 +224,7 @@ export class BluetoothService implements OnApplicationShutdown {
this.logger.debug(e.message);
} else {
this.logger.error(`Inquiring RSSI via BT Classic failed: ${e.message}`);
this.healthIndicator.reportError();
this._successiveErrorsOccurred++;
}

return undefined;
Expand Down Expand Up @@ -428,9 +437,12 @@ export class BluetoothService implements OnApplicationShutdown {
this.logger.warn(message);
});

bleno.on('stateChange', this.handleAdvertisingAdapterStateChange.bind(this))
bleno.on('advertisingStart', this.handleAdvertisingStart.bind(this))
bleno.on('advertisingStop', this.handleAdvertisingStop.bind(this))
bleno.on(
'stateChange',
this.handleAdvertisingAdapterStateChange.bind(this)
);
bleno.on('advertisingStart', this.handleAdvertisingStart.bind(this));
bleno.on('advertisingStop', this.handleAdvertisingStop.bind(this));
}

/**
Expand Down Expand Up @@ -466,17 +478,20 @@ export class BluetoothService implements OnApplicationShutdown {
*/
private handleAdvertisingStart(e?: Error): void {
if (e) {
this.logger.error(`Failed to start advertising instance via BLE: ${e.message}`, e.stack)
this.logger.error(
`Failed to start advertising instance via BLE: ${e.message}`,
e.stack
);
} else {
this.logger.debug('Started advertising instance via BLE')
this.logger.debug('Started advertising instance via BLE');
}
}

/**
* Callback that is executed when BLE advertising is stopped.
*/
private handleAdvertisingStop(): void {
this.logger.debug('Stopped advertising instance via BLE')
this.logger.debug('Stopped advertising instance via BLE');
}

/**
Expand Down Expand Up @@ -545,7 +560,12 @@ export class BluetoothService implements OnApplicationShutdown {
const config = this.configService.get('bluetoothLowEnergy');

if (config.instanceBeaconEnabled && bleno.state === 'poweredOn') {
bleno.startAdvertisingIBeacon(IBEACON_UUID, config.instanceBeaconMajor, config.instanceBeaconMinor, -59)
bleno.startAdvertisingIBeacon(
IBEACON_UUID,
config.instanceBeaconMajor,
config.instanceBeaconMinor,
-59
);
}
}

Expand Down

0 comments on commit f0cea61

Please sign in to comment.