Skip to content

Commit

Permalink
feat(bluetooth-low-energy): add batteryMask for iBeacons (#337)
Browse files Browse the repository at this point in the history
*  initial commit

* review comments
  • Loading branch information
PeteBa committed Nov 9, 2020
1 parent ee8310c commit ac9717f
Show file tree
Hide file tree
Showing 10 changed files with 215 additions and 39 deletions.
3 changes: 3 additions & 0 deletions docs/integrations/bluetooth-low-energy.md
Expand Up @@ -56,6 +56,7 @@ If you are unsure what ID your device has you can start room-assistant with the
| `maxDistance` | Number | | Limits the distance at which a received BLE advertisement is still reported if configured. Value is in meters. |
| `majorMask` | Number | `0xffff` | Filter out bits of the major ID to make dynamic tag IDs with encoded information consistent for filtering. |
| `minorMask` | Number | `0xffff` | Filter out bits of the minor ID to make dynamic tag IDs with encoded information consistent for filtering. |
| `batteryMask` | Number | `0x00000000` | If non-zero, extract the beacon's battery level from the major/minor fields. The mask operates on a 32bit value with major as the high two bytes and minor as the low two bytes. |
| `tagOverrides` | [Tag Overrides](#tag-overrides) | | Allows you to override some properties of the tracked devices. |
| `hciDeviceId` | Number | `0` | ID of the Bluetooth device to use for the inquiries, e.g. `0` to use `hci0`. |

Expand All @@ -67,6 +68,7 @@ The tag overrides object can be considered as a map with the BLE tag ID as key a
| --------------- | ------ | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | String | | Sets a friendly name for the device, which is sent to the home automation software for easier identification. |
| `measuredPower` | Number | | Overrides the [measured power](https://community.estimote.com/hc/en-us/articles/201636913-What-are-Broadcasting-Power-RSSI-and-other-characteristics-of-a-beacon-s-signal-) of a BLE tag, which is used for distance calculation. Should be the expected RSSI when the beacon is exactly 1 meter away from the room-assistant instance. |
| `batteryMask` | Number | `0x00000000` | If non-zero, extract the beacon's battery level from the major/minor fields. The mask operates on a 32bit value with major as the high two bytes and minor as the low two bytes. |

::: details Example Config
```yaml
Expand All @@ -82,6 +84,7 @@ bluetoothLowEnergy:
7750fb4dab70:
name: Cool BLE Tag
measuredPower: -61
batteryMask: 0x0000FF00
```
:::

Expand Down
Expand Up @@ -28,7 +28,7 @@ import { DISTRIBUTED_DEVICE_ID } from '../home-assistant/home-assistant.const';
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 { RoomPresenceProxyHandler } from '../room-presence/room-presence.proxy';
import { BluetoothService } from '../bluetooth/bluetooth.service';

const execPromise = util.promisify(exec);
Expand Down Expand Up @@ -355,7 +355,7 @@ export class BluetoothClassicService
);
const sensorProxy = new Proxy<RoomPresenceDistanceSensor>(
rawSensor,
new RoomPresenceDeviceTrackerProxyHandler(deviceTracker)
new RoomPresenceProxyHandler(deviceTracker)
);

const sensor = this.entitiesService.add(
Expand Down
Expand Up @@ -12,18 +12,21 @@ class BluetoothLowEnergyMeasurement {

export class BluetoothLowEnergyPresenceSensor extends RoomPresenceDistanceSensor {
measuredValues: { [instance: string]: BluetoothLowEnergyMeasurement } = {};
batteryLevel?: number;

handleNewMeasurement(
instanceName: string,
rssi: number,
measuredPower: number,
distance: number,
outOfRange: boolean
outOfRange: boolean,
batteryLevel?: number
): void {
this.measuredValues[instanceName] = new BluetoothLowEnergyMeasurement(
rssi,
measuredPower
);
this.handleNewDistance(instanceName, distance, outOfRange);
if (batteryLevel !== undefined) this.batteryLevel = batteryLevel;
}
}
Expand Up @@ -8,6 +8,7 @@ export class BluetoothLowEnergyConfig {
onlyIBeacon = false;
majorMask = 0xffff;
minorMask = 0xffff;
batteryMask = 0x00000000;
tagOverrides: { [key: string]: TagOverride } = {};

timeout = 5;
Expand All @@ -18,4 +19,5 @@ export class BluetoothLowEnergyConfig {
class TagOverride {
name?: string;
measuredPower?: number;
batteryMask?: number;
}
Expand Up @@ -423,6 +423,41 @@ describe('BluetoothLowEnergyService', () => {
);
});

it('should apply a tag batteryMask override for iBeacon if it exists', () => {
const handleDistanceSpy = jest
.spyOn(service, 'handleNewDistance')
.mockImplementation(() => undefined);
jest.spyOn(service, 'isWhitelistEnabled').mockReturnValue(true);
jest.spyOn(service, 'isOnWhitelist').mockReturnValue(true);

mockConfig.onlyIBeacon = true;
mockConfig.processIBeacon = true;

mockConfig.tagOverrides = {
'2f234454cf6d4a0fadf2f4911ba9ffa6-1-25346': {
batteryMask: 0x0000ff00,
},
};

const iBeaconDataWith99Battery = Buffer.from(iBeaconData);
iBeaconDataWith99Battery[22] = 99;

service.handleDiscovery({
id: 'test-ibeacon',
rssi: -50,
advertisement: {
localName: 'Test Beacon',
manufacturerData: iBeaconDataWith99Battery,
},
} as Peripheral);

expect(handleDistanceSpy).toHaveBeenCalledWith(
expect.objectContaining({
batteryLevel: 99,
})
);
});

it('should not publish state changes for devices that are not on the whitelist', () => {
jest.spyOn(service, 'isOnWhitelist').mockReturnValue(false);

Expand Down Expand Up @@ -702,7 +737,7 @@ describe('BluetoothLowEnergyService', () => {
expect.any(Array)
);
expect(entitiesService.add).toHaveBeenCalledWith(
new DeviceTracker('ble-new-tracker', 'New Tag', true)
new DeviceTracker('ble-new-tracker', 'New Tag Tracker', true)
);
expect(
util.types.isProxy(entitiesService.add.mock.calls[1][0])
Expand All @@ -711,6 +746,41 @@ describe('BluetoothLowEnergyService', () => {
expect(sensorHandleSpy).toHaveBeenCalledWith('test-instance', 1.3, false);
});

it('should add a new battery sensor and set the battery level', () => {
const sensor = new BluetoothLowEnergyPresenceSensor('test', 'Test', 0);
entitiesService.has.mockReturnValue(false);
entitiesService.add.mockReturnValue(sensor);

service.handleNewDistance(
new NewDistanceEvent(
'test-instance',
'new',
'New Tag',
-80,
-50,
1.3,
false,
99
)
);

expect(entitiesService.add).toHaveBeenCalledWith(
expect.objectContaining({
id: 'ble-new',
name: 'New Tag Room Presence',
}),
expect.any(Array)
);
expect(entitiesService.add).toHaveBeenCalledWith(
new DeviceTracker('ble-new-tracker', 'New Tag Tracker', true)
);
expect(entitiesService.add).toHaveBeenCalledWith(
new Sensor('ble-new-battery', 'New Tag Battery', true),
expect.any(Array)
);
expect(sensor.batteryLevel).toBe(99);
});

it('should update the sensor RSSI and measuredPower information', () => {
const sensor = new BluetoothLowEnergyPresenceSensor('test', 'Test', 0);
entitiesService.has.mockReturnValue(true);
Expand Down
Expand Up @@ -14,14 +14,16 @@ import { ClusterService } from '../../cluster/cluster.service';
import { NewDistanceEvent } from './new-distance.event';
import { EntityCustomization } from '../../entities/entity-customization.interface';
import { SensorConfig } from '../home-assistant/sensor-config';
import { Device } from '../home-assistant/device';
import { RoomPresenceDistanceSensor } from '../room-presence/room-presence-distance.sensor';
import { SchedulerRegistry } from '@nestjs/schedule';
import { KalmanFilterable } from '../../util/filters';
import { makeId } from '../../util/id';
import { DISTRIBUTED_DEVICE_ID } from '../home-assistant/home-assistant.const';
import * as _ from 'lodash';
import { DeviceTracker } from '../../entities/device-tracker';
import { RoomPresenceDeviceTrackerProxyHandler } from '../room-presence/room-presence-device-tracker.proxy';
import { Sensor } from '../../entities/sensor';
import { RoomPresenceProxyHandler } from '../room-presence/room-presence.proxy';
import { BluetoothLowEnergyPresenceSensor } from './bluetooth-low-energy-presence.sensor';
import { BluetoothService } from '../bluetooth/bluetooth.service';

Expand Down Expand Up @@ -107,7 +109,8 @@ export class BluetoothLowEnergyService
tag.rssi,
tag.measuredPower,
tag.distance,
tag.distance > this.config.maxDistance
tag.distance > this.config.maxDistance,
tag instanceof IBeacon ? tag.batteryLevel : undefined
);

if (!this.tagUpdaters.hasOwnProperty(tag.id)) {
Expand All @@ -122,13 +125,15 @@ export class BluetoothLowEnergyService
}

/**
* Passes newly found distance information to aggregated room presence sensors.
* Passes newly found discovery information to aggregated room presence sensors.
*
* @param event - Event with new distance data
* @param event - Event with new distance/battery data
*/
handleNewDistance(event: NewDistanceEvent): void {
const sensorId = makeId(`ble ${event.tagId}`);
let sensor: BluetoothLowEnergyPresenceSensor;
const hasBattery = event.batteryLevel !== undefined;

if (this.entitiesService.has(sensorId)) {
sensor = this.entitiesService.get(
sensorId
Expand All @@ -137,7 +142,8 @@ export class BluetoothLowEnergyService
sensor = this.createRoomPresenceSensor(
sensorId,
event.tagId,
event.tagName
event.tagName,
hasBattery
);
}

Expand All @@ -146,7 +152,8 @@ export class BluetoothLowEnergyService
event.rssi,
event.measuredPower,
event.distance,
event.outOfRange
event.outOfRange,
event.batteryLevel
);
}

Expand Down Expand Up @@ -235,29 +242,42 @@ export class BluetoothLowEnergyService
* @param sensorId - Id that the sensor should receive
* @param deviceId - Id of the BLE peripheral
* @param deviceName - Name of the BLE peripheral
* @param hasBattery - Ability to report battery
* @returns Registered room presence sensor
*/
protected createRoomPresenceSensor(
sensorId: string,
deviceId: string,
deviceName: string
deviceName: string,
hasBattery: boolean
): BluetoothLowEnergyPresenceSensor {
const deviceInfo: Device = {
identifiers: deviceId,
name: deviceName,
viaDevice: DISTRIBUTED_DEVICE_ID,
};

const deviceTracker = this.createDeviceTracker(
makeId(`${sensorId}-tracker`),
deviceName
`${deviceName} Tracker`
);

let batterySensor: Sensor;
if (hasBattery) {
batterySensor = this.createBatterySensor(
makeId(`${sensorId}-battery`),
`${deviceName} Battery`,
deviceInfo
);
}

const sensorName = `${deviceName} Room Presence`;
const customizations: Array<EntityCustomization<any>> = [
{
for: SensorConfig,
overrides: {
icon: 'mdi:bluetooth',
device: {
identifiers: deviceId,
name: deviceName,
viaDevice: DISTRIBUTED_DEVICE_ID,
},
device: deviceInfo,
},
},
];
Expand All @@ -268,7 +288,7 @@ export class BluetoothLowEnergyService
);
const proxiedSensor = new Proxy<RoomPresenceDistanceSensor>(
rawSensor,
new RoomPresenceDeviceTrackerProxyHandler(deviceTracker)
new RoomPresenceProxyHandler(deviceTracker, batterySensor)
);
const sensor = this.entitiesService.add(
proxiedSensor,
Expand Down Expand Up @@ -297,6 +317,35 @@ export class BluetoothLowEnergyService
) as DeviceTracker;
}

/**
* Creates and registers a new battery sensor.
*
* @param id - Entity ID for the new battery sensor
* @param name - Name for the new battery sensor
* @param deviceInfo - Reference information about the BLE device
* @returns Registered battery sensor
*/
protected createBatterySensor(
id: string,
name: string,
deviceInfo: Device
): Sensor {
const batteryCustomizations: Array<EntityCustomization<any>> = [
{
for: SensorConfig,
overrides: {
deviceClass: 'battery',
unitOfMeasurement: '%',
device: deviceInfo,
},
},
];
return this.entitiesService.add(
new Sensor(id, name, true),
batteryCustomizations
) as Sensor;
}

/**
* Creates a tag based on a given BLE peripheral.
*
Expand All @@ -311,7 +360,8 @@ export class BluetoothLowEnergyService
return new IBeacon(
peripheral,
this.config.majorMask,
this.config.minorMask
this.config.minorMask,
this.config.batteryMask
);
} else {
return new Tag(peripheral);
Expand All @@ -333,6 +383,9 @@ export class BluetoothLowEnergyService
if (overrides.measuredPower !== undefined) {
tag.measuredPower = overrides.measuredPower;
}
if (overrides.batteryMask !== undefined && tag instanceof IBeacon) {
tag.batteryMask = overrides.batteryMask;
}
}

return tag;
Expand Down

0 comments on commit ac9717f

Please sign in to comment.