diff --git a/docs/integrations/bluetooth-low-energy.md b/docs/integrations/bluetooth-low-energy.md index 2eefb3b..79b993d 100644 --- a/docs/integrations/bluetooth-low-energy.md +++ b/docs/integrations/bluetooth-low-energy.md @@ -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`. | @@ -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 @@ -82,6 +84,7 @@ bluetoothLowEnergy: 7750fb4dab70: name: Cool BLE Tag measuredPower: -61 + batteryMask: 0x0000FF00 ``` ::: diff --git a/src/integrations/bluetooth-classic/bluetooth-classic.service.ts b/src/integrations/bluetooth-classic/bluetooth-classic.service.ts index c19569d..7e69d5e 100644 --- a/src/integrations/bluetooth-classic/bluetooth-classic.service.ts +++ b/src/integrations/bluetooth-classic/bluetooth-classic.service.ts @@ -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); @@ -355,7 +355,7 @@ export class BluetoothClassicService ); const sensorProxy = new Proxy( rawSensor, - new RoomPresenceDeviceTrackerProxyHandler(deviceTracker) + new RoomPresenceProxyHandler(deviceTracker) ); const sensor = this.entitiesService.add( diff --git a/src/integrations/bluetooth-low-energy/bluetooth-low-energy-presence.sensor.ts b/src/integrations/bluetooth-low-energy/bluetooth-low-energy-presence.sensor.ts index 7c60823..30257bd 100644 --- a/src/integrations/bluetooth-low-energy/bluetooth-low-energy-presence.sensor.ts +++ b/src/integrations/bluetooth-low-energy/bluetooth-low-energy-presence.sensor.ts @@ -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; } } diff --git a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.config.ts b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.config.ts index c306901..ab77dce 100644 --- a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.config.ts +++ b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.config.ts @@ -8,6 +8,7 @@ export class BluetoothLowEnergyConfig { onlyIBeacon = false; majorMask = 0xffff; minorMask = 0xffff; + batteryMask = 0x00000000; tagOverrides: { [key: string]: TagOverride } = {}; timeout = 5; @@ -18,4 +19,5 @@ export class BluetoothLowEnergyConfig { class TagOverride { name?: string; measuredPower?: number; + batteryMask?: number; } diff --git a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.spec.ts b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.spec.ts index 6ff5ac1..186a360 100644 --- a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.spec.ts +++ b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.spec.ts @@ -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); @@ -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]) @@ -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); 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 091951d..2be34df 100644 --- a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts +++ b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts @@ -14,6 +14,7 @@ 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'; @@ -21,7 +22,8 @@ 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'; @@ -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)) { @@ -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 @@ -137,7 +142,8 @@ export class BluetoothLowEnergyService sensor = this.createRoomPresenceSensor( sensorId, event.tagId, - event.tagName + event.tagName, + hasBattery ); } @@ -146,7 +152,8 @@ export class BluetoothLowEnergyService event.rssi, event.measuredPower, event.distance, - event.outOfRange + event.outOfRange, + event.batteryLevel ); } @@ -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> = [ { for: SensorConfig, overrides: { icon: 'mdi:bluetooth', - device: { - identifiers: deviceId, - name: deviceName, - viaDevice: DISTRIBUTED_DEVICE_ID, - }, + device: deviceInfo, }, }, ]; @@ -268,7 +288,7 @@ export class BluetoothLowEnergyService ); const proxiedSensor = new Proxy( rawSensor, - new RoomPresenceDeviceTrackerProxyHandler(deviceTracker) + new RoomPresenceProxyHandler(deviceTracker, batterySensor) ); const sensor = this.entitiesService.add( proxiedSensor, @@ -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> = [ + { + 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. * @@ -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); @@ -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; diff --git a/src/integrations/bluetooth-low-energy/i-beacon.ts b/src/integrations/bluetooth-low-energy/i-beacon.ts index e345fc0..2a8f918 100644 --- a/src/integrations/bluetooth-low-energy/i-beacon.ts +++ b/src/integrations/bluetooth-low-energy/i-beacon.ts @@ -2,27 +2,49 @@ import { Tag } from './tag'; import { Peripheral } from '@abandonware/noble'; export class IBeacon extends Tag { - constructor(peripheral: Peripheral, majorMask = 0xffff, minorMask = 0xffff) { + constructor( + peripheral: Peripheral, + majorMask = 0xffff, + minorMask = 0xffff, + batteryMask = 0x00000000 + ) { super(peripheral); this.uuid = this.peripheral.advertisement.manufacturerData .slice(4, 20) .toString('hex'); - this.major = - this.peripheral.advertisement.manufacturerData.readUInt16BE(20) & - majorMask; - this.minor = - this.peripheral.advertisement.manufacturerData.readUInt16BE(22) & - minorMask; + const major = this.peripheral.advertisement.manufacturerData.readUInt16BE( + 20 + ); + const minor = this.peripheral.advertisement.manufacturerData.readUInt16BE( + 22 + ); this.measuredPower = this.peripheral.advertisement.manufacturerData.readInt8( 24 ); + + this._rawMajorMinor = (major << 16) + minor; + this.major = major & majorMask; + this.minor = minor & minorMask; + this.batteryMask = batteryMask; } uuid: string; major: number; minor: number; + batteryMask: number; + private _rawMajorMinor: number; get id(): string { return `${this.uuid}-${this.major}-${this.minor}`; } + + get batteryLevel(): number | undefined { + if (!this.batteryMask) { + return undefined; + } + + const battery = this._rawMajorMinor & this.batteryMask; + const offset = Math.log2(this.batteryMask & -this.batteryMask); + return battery >> offset; + } } diff --git a/src/integrations/bluetooth-low-energy/new-distance.event.ts b/src/integrations/bluetooth-low-energy/new-distance.event.ts index a98aaaf..c08e920 100644 --- a/src/integrations/bluetooth-low-energy/new-distance.event.ts +++ b/src/integrations/bluetooth-low-energy/new-distance.event.ts @@ -6,7 +6,8 @@ export class NewDistanceEvent { rssi: number, measuredPower: number, distance: number, - outOfRange = false + outOfRange = false, + batteryLevel?: number ) { this.instanceName = instanceName; this.tagId = tagId; @@ -15,6 +16,7 @@ export class NewDistanceEvent { this.measuredPower = measuredPower; this.distance = distance; this.outOfRange = outOfRange; + this.batteryLevel = batteryLevel; } instanceName: string; @@ -24,4 +26,5 @@ export class NewDistanceEvent { measuredPower: number; distance: number; outOfRange: boolean; + batteryLevel?: number; } diff --git a/src/integrations/room-presence/room-presence-device-tracker.proxy.spec.ts b/src/integrations/room-presence/room-presence.proxy.spec.ts similarity index 50% rename from src/integrations/room-presence/room-presence-device-tracker.proxy.spec.ts rename to src/integrations/room-presence/room-presence.proxy.spec.ts index e8980c1..806c96c 100644 --- a/src/integrations/room-presence/room-presence-device-tracker.proxy.spec.ts +++ b/src/integrations/room-presence/room-presence.proxy.spec.ts @@ -1,22 +1,22 @@ import { RoomPresenceDistanceSensor } from './room-presence-distance.sensor'; import { DeviceTracker } from '../../entities/device-tracker'; -import { RoomPresenceDeviceTrackerProxyHandler } from './room-presence-device-tracker.proxy'; +import { Sensor } from '../../entities/sensor'; +import { RoomPresenceProxyHandler } from './room-presence.proxy'; -describe('RoomPresenceDeviceTrackerProxyHandler', () => { +describe('RoomPresenceProxyHandler', () => { let proxy: RoomPresenceDistanceSensor; let deviceTracker: DeviceTracker; + let batterySensor: Sensor; + let sensor: RoomPresenceDistanceSensor; beforeEach(() => { deviceTracker = new DeviceTracker('test-tracker', 'Test Tracker'); + batterySensor = new Sensor('test-battery', 'Test Battery'); - const sensor = new RoomPresenceDistanceSensor( - 'test-sensor', - 'Test Sensor', - 0 - ); + sensor = new RoomPresenceDistanceSensor('test-sensor', 'Test Sensor', 0); proxy = new Proxy( sensor, - new RoomPresenceDeviceTrackerProxyHandler(deviceTracker) + new RoomPresenceProxyHandler(deviceTracker) ); }); @@ -31,4 +31,15 @@ describe('RoomPresenceDeviceTrackerProxyHandler', () => { expect(deviceTracker.state).toBeFalsy(); }); + + it('should set the battery level to value', () => { + const sensorProxy = new Proxy( + sensor, + new RoomPresenceProxyHandler(deviceTracker, batterySensor) + ); + + sensorProxy['batteryLevel'] = 99; + + expect(batterySensor.state).toBe(99); + }); }); diff --git a/src/integrations/room-presence/room-presence-device-tracker.proxy.ts b/src/integrations/room-presence/room-presence.proxy.ts similarity index 52% rename from src/integrations/room-presence/room-presence-device-tracker.proxy.ts rename to src/integrations/room-presence/room-presence.proxy.ts index 31e9808..8d1c06c 100644 --- a/src/integrations/room-presence/room-presence-device-tracker.proxy.ts +++ b/src/integrations/room-presence/room-presence.proxy.ts @@ -3,17 +3,26 @@ import { STATE_NOT_HOME, } from './room-presence-distance.sensor'; import { DeviceTracker } from '../../entities/device-tracker'; +import { Sensor } from '../../entities/sensor'; -export class RoomPresenceDeviceTrackerProxyHandler +export class RoomPresenceProxyHandler implements ProxyHandler { - constructor(private readonly deviceTracker: DeviceTracker) {} + constructor( + private readonly deviceTracker: DeviceTracker, + private readonly batterySensor?: Sensor + ) {} // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types set(target: RoomPresenceDistanceSensor, p: PropertyKey, value: any): boolean { target[p] = value; - if (p === 'state') { - this.deviceTracker.state = value != STATE_NOT_HOME; + switch (p) { + case 'state': + this.deviceTracker.state = value != STATE_NOT_HOME; + break; + case 'batteryLevel': + this.batterySensor.state = value; + break; } return true;