Skip to content

Commit

Permalink
feat(home-assistant): add support for MQTT device trackers
Browse files Browse the repository at this point in the history
No auto discovery sadly, but this is still easier than having to write
custom automations to have device tracker based on the room presence.

Closes #183
  • Loading branch information
mKeRix committed May 2, 2020
1 parent a8cffec commit 3e50cd3
Show file tree
Hide file tree
Showing 11 changed files with 198 additions and 17 deletions.
14 changes: 14 additions & 0 deletions docs/integrations/home-assistant.md
Expand Up @@ -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 |
Expand Down
5 changes: 5 additions & 0 deletions src/entities/device-tracker.ts
@@ -0,0 +1,5 @@
import { Entity } from './entity';

export class DeviceTracker extends Entity {
state: boolean;
}
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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
Expand Down
36 changes: 31 additions & 5 deletions src/integrations/bluetooth-classic/bluetooth-classic.service.ts
Expand Up @@ -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);

Expand Down Expand Up @@ -341,6 +343,11 @@ export class BluetoothClassicService extends KalmanFilterable(Object, 1.4, 1)
sensorId: string,
device: Device
): Promise<RoomPresenceDistanceSensor> {
const deviceTracker = this.createDeviceTracker(
makeId(`${sensorId}-tracker`),
device.name
);

const customizations: Array<EntityCustomization<any>> = [
{
for: SensorConfig,
Expand All @@ -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<RoomPresenceDistanceSensor>(
rawSensor,
new RoomPresenceDeviceTrackerProxyHandler(deviceTracker)
);

const sensor = this.entitiesService.add(
new RoomPresenceDistanceSensor(
sensorId,
`${device.name} Room Presence`,
0
),
sensorProxy,
customizations
) as RoomPresenceDistanceSensor;

Expand All @@ -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.
*
Expand Down
Expand Up @@ -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();

Expand Down Expand Up @@ -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);
});
Expand Down
Expand Up @@ -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';

Expand Down Expand Up @@ -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<EntityCustomization<any>> = [
{
Expand All @@ -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<RoomPresenceDistanceSensor>(
rawSensor,
new RoomPresenceDeviceTrackerProxyHandler(deviceTracker)
);
const sensor = this.entitiesService.add(
new RoomPresenceDistanceSensor(sensorId, sensorName, this.config.timeout),
proxiedSensor,
customizations
) as RoomPresenceDistanceSensor;

Expand All @@ -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.
*
Expand Down
10 changes: 10 additions & 0 deletions 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);
}
}
10 changes: 10 additions & 0 deletions src/integrations/home-assistant/home-assistant.service.spec.ts
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand Down
35 changes: 24 additions & 11 deletions src/integrations/home-assistant/home-assistant.service.ts
Expand Up @@ -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'];

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down
@@ -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<RoomPresenceDistanceSensor>(
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();
});
});
@@ -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<RoomPresenceDistanceSensor> {
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;
}
}

0 comments on commit 3e50cd3

Please sign in to comment.