diff --git a/docs/integrations/home-assistant.md b/docs/integrations/home-assistant.md index c78fc30..d46c558 100644 --- a/docs/integrations/home-assistant.md +++ b/docs/integrations/home-assistant.md @@ -16,6 +16,20 @@ You will need to setup an MQTT broker that both your instance of Home Assistant room-assistant makes use of the [MQTT auto discovery](https://www.home-assistant.io/docs/mqtt/discovery/) features provided by Home Assistant to automatically create all entities for you. It is strongly recommended that you enable this feature when setting up the MQTT integration in Home Assistant. +## Device Trackers + +Some integrations, such as [Bluetooth Classic](bluetooth-classic.md) or [Bluetooth Low Energy](bluetooth-low-energy.md) support the [MQTT device tracker](https://www.home-assistant.io/integrations/device_tracker.mqtt/) in Home Assistant. This is for example useful if you want to integrate the room-assistant presence detection with others in the form of a [person](https://www.home-assistant.io/integrations/person/). Unfortunately, device trackers can not be auto discovered by Home Assistant. To enable this feature you will have to edit your Home Assistant configuration manually and add a few lines as shown in the example below. You can get all your available device tracker topics from the room-assistant logs. + +```yaml +device_tracker: +- platform: mqtt + devices: + apple_watch: 'room-assistant/device_tracker/bluetooth-classic-aa-aa-aa-aa-aa-aa-tracker/state' + payload_home: 'true' + payload_not_home: 'false' + source_type: bluetooth +``` + ## Settings | Name | Type | Default | Description | diff --git a/src/entities/device-tracker.ts b/src/entities/device-tracker.ts new file mode 100644 index 0000000..a5b011f --- /dev/null +++ b/src/entities/device-tracker.ts @@ -0,0 +1,5 @@ +import { Entity } from './entity'; + +export class DeviceTracker extends Entity { + state: boolean; +} diff --git a/src/integrations/bluetooth-classic/bluetooth-classic.service.spec.ts b/src/integrations/bluetooth-classic/bluetooth-classic.service.spec.ts index 8c4d65b..95bed9b 100644 --- a/src/integrations/bluetooth-classic/bluetooth-classic.service.spec.ts +++ b/src/integrations/bluetooth-classic/bluetooth-classic.service.spec.ts @@ -20,6 +20,8 @@ import { BluetoothClassicConfig } from './bluetooth-classic.config'; import c from 'config'; import { ConfigService } from '../../config/config.service'; import { Device } from './device'; +import { DeviceTracker } from '../../entities/device-tracker'; +import * as util from 'util'; jest.mock('../room-presence/room-presence-distance.sensor'); jest.mock('kalmanjs', () => { @@ -449,10 +451,20 @@ Requesting information ... ); await service.handleNewRssi(event); + expect(entitiesService.add).toHaveBeenCalledWith( + new DeviceTracker( + 'bluetooth-classic-10-36-cf-ca-9a-18-tracker', + 'Test Device', + true + ) + ); expect(entitiesService.add).toHaveBeenCalledWith( expect.any(RoomPresenceDistanceSensor), expect.any(Array) ); + expect( + util.types.isProxy(entitiesService.add.mock.calls[1][0]) + ).toBeTruthy(); expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 6000); const sensorInstance = (RoomPresenceDistanceSensor as jest.Mock).mock diff --git a/src/integrations/bluetooth-classic/bluetooth-classic.service.ts b/src/integrations/bluetooth-classic/bluetooth-classic.service.ts index 002e442..fbee33c 100644 --- a/src/integrations/bluetooth-classic/bluetooth-classic.service.ts +++ b/src/integrations/bluetooth-classic/bluetooth-classic.service.ts @@ -27,6 +27,8 @@ import { Device } from './device'; 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'; const execPromise = util.promisify(exec); @@ -341,6 +343,11 @@ export class BluetoothClassicService extends KalmanFilterable(Object, 1.4, 1) sensorId: string, device: Device ): Promise { + const deviceTracker = this.createDeviceTracker( + makeId(`${sensorId}-tracker`), + device.name + ); + const customizations: Array> = [ { for: SensorConfig, @@ -356,12 +363,18 @@ export class BluetoothClassicService extends KalmanFilterable(Object, 1.4, 1) } } ]; + const rawSensor = new RoomPresenceDistanceSensor( + sensorId, + `${device.name} Room Presence`, + 0 + ); + const sensorProxy = new Proxy( + rawSensor, + new RoomPresenceDeviceTrackerProxyHandler(deviceTracker) + ); + const sensor = this.entitiesService.add( - new RoomPresenceDistanceSensor( - sensorId, - `${device.name} Room Presence`, - 0 - ), + sensorProxy, customizations ) as RoomPresenceDistanceSensor; @@ -374,6 +387,19 @@ export class BluetoothClassicService extends KalmanFilterable(Object, 1.4, 1) return sensor; } + /** + * Creates and registers a new device tracker. + * + * @param id - Entity ID for the new device tracker + * @param name - Name for the new device tracker + * @returns Registered device tracker + */ + protected createDeviceTracker(id: string, name: string): DeviceTracker { + return this.entitiesService.add( + new DeviceTracker(id, name, true) + ) as DeviceTracker; + } + /** * Calculates the current timeout value based on the time it takes for a full rotation. * 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 7adda5a..e9d8b3a 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 @@ -35,6 +35,8 @@ import c from 'config'; import { NewDistanceEvent } from './new-distance.event'; import { RoomPresenceDistanceSensor } from '../room-presence/room-presence-distance.sensor'; import KalmanFilter from 'kalmanjs'; +import { DeviceTracker } from '../../entities/device-tracker'; +import * as util from 'util'; jest.useFakeTimers(); @@ -687,6 +689,12 @@ describe('BluetoothLowEnergyService', () => { }), expect.any(Array) ); + expect(entitiesService.add).toHaveBeenCalledWith( + new DeviceTracker('ble-new-tracker', 'New Tag', true) + ); + expect( + util.types.isProxy(entitiesService.add.mock.calls[1][0]) + ).toBeTruthy(); expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 20 * 1000); expect(sensorHandleSpy).toHaveBeenCalledWith('test-instance', 1.3, false); }); 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 f5a2a46..1b2b727 100644 --- a/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts +++ b/src/integrations/bluetooth-low-energy/bluetooth-low-energy.service.ts @@ -20,6 +20,8 @@ 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'; export const NEW_DISTANCE_CHANNEL = 'bluetooth-low-energy.new-distance'; @@ -232,6 +234,11 @@ export class BluetoothLowEnergyService extends KalmanFilterable(Object, 0.8, 15) deviceId: string, deviceName: string ): RoomPresenceDistanceSensor { + const deviceTracker = this.createDeviceTracker( + makeId(`${sensorId}-tracker`), + deviceName + ); + const sensorName = `${deviceName} Room Presence`; const customizations: Array> = [ { @@ -246,8 +253,17 @@ export class BluetoothLowEnergyService extends KalmanFilterable(Object, 0.8, 15) } } ]; + const rawSensor = new RoomPresenceDistanceSensor( + sensorId, + sensorName, + this.config.timeout + ); + const proxiedSensor = new Proxy( + rawSensor, + new RoomPresenceDeviceTrackerProxyHandler(deviceTracker) + ); const sensor = this.entitiesService.add( - new RoomPresenceDistanceSensor(sensorId, sensorName, this.config.timeout), + proxiedSensor, customizations ) as RoomPresenceDistanceSensor; @@ -260,6 +276,19 @@ export class BluetoothLowEnergyService extends KalmanFilterable(Object, 0.8, 15) return sensor; } + /** + * Creates and registers a new device tracker. + * + * @param id - Entity ID for the new device tracker + * @param name - Name for the new device tracker + * @returns Registered device tracker + */ + protected createDeviceTracker(id: string, name: string): DeviceTracker { + return this.entitiesService.add( + new DeviceTracker(id, name, true) + ) as DeviceTracker; + } + /** * Creates a tag based on a given BLE peripheral. * diff --git a/src/integrations/home-assistant/device-tracker-config.ts b/src/integrations/home-assistant/device-tracker-config.ts new file mode 100644 index 0000000..6e1a952 --- /dev/null +++ b/src/integrations/home-assistant/device-tracker-config.ts @@ -0,0 +1,10 @@ +import { EntityConfig } from './entity-config'; + +export class DeviceTrackerConfig extends EntityConfig { + readonly payloadHome = 'true'; + readonly payloadNotHome = 'false'; + + constructor(id: string, name: string) { + super('device_tracker', id, name); + } +} diff --git a/src/integrations/home-assistant/home-assistant.service.spec.ts b/src/integrations/home-assistant/home-assistant.service.spec.ts index 9ad5223..9b408ac 100644 --- a/src/integrations/home-assistant/home-assistant.service.spec.ts +++ b/src/integrations/home-assistant/home-assistant.service.spec.ts @@ -20,6 +20,7 @@ import { Entity } from '../../entities/entity'; import { Sensor } from '../../entities/sensor'; import { BinarySensor } from '../../entities/binary-sensor'; import { Switch } from '../../entities/switch'; +import { DeviceTracker } from '../../entities/device-tracker'; import { DISTRIBUTED_DEVICE_ID } from './home-assistant.const'; jest.mock('async-mqtt', () => { @@ -279,6 +280,15 @@ describe('HomeAssistantService', () => { ); }); + it('should not publish discovery information for a new device tracker', async () => { + await service.onModuleInit(); + service.handleNewEntity( + new DeviceTracker('test-tracker', 'Test Tracker', true) + ); + + expect(mockMqttClient.publish).toHaveBeenCalledTimes(1); + }); + it('should include device information in the discovery message', async () => { mockSystem.mockResolvedValue({ serial: 'abcd', diff --git a/src/integrations/home-assistant/home-assistant.service.ts b/src/integrations/home-assistant/home-assistant.service.ts index 4fb3cc8..8009910 100644 --- a/src/integrations/home-assistant/home-assistant.service.ts +++ b/src/integrations/home-assistant/home-assistant.service.ts @@ -23,6 +23,8 @@ import { BinarySensor } from '../../entities/binary-sensor'; import { BinarySensorConfig } from './binary-sensor-config'; import { Switch } from '../../entities/switch'; import { SwitchConfig } from './switch-config'; +import { DeviceTracker } from '../../entities/device-tracker'; +import { DeviceTrackerConfig } from './device-tracker-config'; const PROPERTY_BLACKLIST = ['component', 'configTopic', 'commandStore']; @@ -125,18 +127,27 @@ export class HomeAssistantService config = this.applyCustomizations(config, customizations); - this.logger.debug( - `Registering entity ${config.uniqueId} under ${config.configTopic}` - ); this.entityConfigs.set(combinedId, config); - this.mqttClient.publish( - config.configTopic, - JSON.stringify(this.formatMessage(config)), - { - qos: 0, - retain: true - } - ); + + if (config instanceof DeviceTrackerConfig) { + // auto discovery not supported by Home Assistant yet + this.logger.log( + `Device tracker requires manual setup in Home Assistant with topic: ${config.stateTopic}` + ); + } else { + this.logger.debug( + `Registering entity ${config.uniqueId} under ${config.configTopic}` + ); + this.mqttClient.publish( + config.configTopic, + JSON.stringify(this.formatMessage(config)), + { + qos: 0, + retain: true + } + ); + } + this.mqttClient.publish(config.availabilityTopic, config.payloadAvailable, { qos: 0, retain: true @@ -289,6 +300,8 @@ export class HomeAssistantService ); this.mqttClient.subscribe(config.commandTopic, { qos: 0 }); return config; + } else if (entity instanceof DeviceTracker) { + return new DeviceTrackerConfig(combinedId, entity.name); } else { return; } diff --git a/src/integrations/room-presence/room-presence-device-tracker.proxy.spec.ts b/src/integrations/room-presence/room-presence-device-tracker.proxy.spec.ts new file mode 100644 index 0000000..e8980c1 --- /dev/null +++ b/src/integrations/room-presence/room-presence-device-tracker.proxy.spec.ts @@ -0,0 +1,34 @@ +import { RoomPresenceDistanceSensor } from './room-presence-distance.sensor'; +import { DeviceTracker } from '../../entities/device-tracker'; +import { RoomPresenceDeviceTrackerProxyHandler } from './room-presence-device-tracker.proxy'; + +describe('RoomPresenceDeviceTrackerProxyHandler', () => { + let proxy: RoomPresenceDistanceSensor; + let deviceTracker: DeviceTracker; + + beforeEach(() => { + deviceTracker = new DeviceTracker('test-tracker', 'Test Tracker'); + + const sensor = new RoomPresenceDistanceSensor( + 'test-sensor', + 'Test Sensor', + 0 + ); + proxy = new Proxy( + sensor, + new RoomPresenceDeviceTrackerProxyHandler(deviceTracker) + ); + }); + + it('should set the device tracker to true when home', () => { + proxy.handleNewDistance('room1', 0.5); + + expect(deviceTracker.state).toBeTruthy(); + }); + + it('should set the device tracker to false when not home', () => { + proxy.updateState(); + + expect(deviceTracker.state).toBeFalsy(); + }); +}); diff --git a/src/integrations/room-presence/room-presence-device-tracker.proxy.ts b/src/integrations/room-presence/room-presence-device-tracker.proxy.ts new file mode 100644 index 0000000..9ff8dd0 --- /dev/null +++ b/src/integrations/room-presence/room-presence-device-tracker.proxy.ts @@ -0,0 +1,20 @@ +import { + RoomPresenceDistanceSensor, + STATE_NOT_HOME +} from './room-presence-distance.sensor'; +import { DeviceTracker } from '../../entities/device-tracker'; + +export class RoomPresenceDeviceTrackerProxyHandler + implements ProxyHandler { + constructor(private readonly deviceTracker: DeviceTracker) {} + + set(target: RoomPresenceDistanceSensor, p: PropertyKey, value: any): boolean { + target[p] = value; + + if (p === 'state') { + this.deviceTracker.state = value != STATE_NOT_HOME; + } + + return true; + } +}