Skip to content

Commit

Permalink
feat(entities): added a switch entity
Browse files Browse the repository at this point in the history
Allows integrations to register switches that can be turned on or off
from the home automation software.
  • Loading branch information
mKeRix committed Feb 10, 2020
1 parent e2c83bf commit 52eaef5
Show file tree
Hide file tree
Showing 5 changed files with 172 additions and 4 deletions.
13 changes: 13 additions & 0 deletions src/entities/switch.ts
@@ -0,0 +1,13 @@
import { Entity } from './entity';

export class Switch extends Entity {
state: boolean;

turnOn(): void {
this.state = true;
}

turnOff(): void {
this.state = false;
}
}
6 changes: 3 additions & 3 deletions src/integrations/home-assistant/binary-sensor-config.ts
@@ -1,9 +1,9 @@
import { EntityConfig } from './entity-config';

export class BinarySensorConfig extends EntityConfig {
payload_on = 'true';
payload_off = 'false';
device_class?: BinarySensorDeviceClass;
payloadOn = 'true';
payloadOff = 'false';
deviceClass?: BinarySensorDeviceClass;

constructor(id: string, name: string) {
super('binary_sensor', id, name);
Expand Down
98 changes: 98 additions & 0 deletions src/integrations/home-assistant/home-assistant.service.spec.ts
Expand Up @@ -2,6 +2,7 @@
const mockMqttClient = {
on: jest.fn(),
publish: jest.fn(),
subscribe: jest.fn(),
end: jest.fn()
};

Expand All @@ -18,6 +19,7 @@ import { SensorConfig } from './sensor-config';
import { Entity } from '../../entities/entity';
import { Sensor } from '../../entities/sensor';
import { BinarySensor } from '../../entities/binary-sensor';
import { Switch } from '../../entities/switch';
import { DISTRIBUTED_DEVICE_ID } from './home-assistant.const';

jest.mock('async-mqtt', () => {
Expand Down Expand Up @@ -80,6 +82,10 @@ describe('HomeAssistantService', () => {
expect.any(Object),
false
);
expect(mockMqttClient.on).toHaveBeenCalledWith(
'message',
expect.any(Function)
);
});

it('should get the device info on init', async () => {
Expand Down Expand Up @@ -225,6 +231,54 @@ describe('HomeAssistantService', () => {
});
});

it('should publish discovery information for a new switch', async () => {
await service.onModuleInit();
service.handleNewEntity(new Switch('test-switch', 'Test Switch'));

expect(mockMqttClient.publish).toHaveBeenCalledWith(
'homeassistant/switch/room-assistant/test-instance-test-switch/config',
expect.any(String),
{
qos: 0,
retain: true
}
);
expect(mockMqttClient.publish).toHaveBeenCalledWith(
'room-assistant/switch/test-instance-test-switch/status',
'online',
{
qos: 0,
retain: true
}
);
expect(JSON.parse(mockMqttClient.publish.mock.calls[0][1])).toMatchObject({
unique_id: 'room-assistant-test-instance-test-switch',
name: 'Test Switch',
state_topic: 'room-assistant/switch/test-instance-test-switch/state',
command_topic: 'room-assistant/switch/test-instance-test-switch/command',
json_attributes_topic:
'room-assistant/switch/test-instance-test-switch/attributes',
availability_topic:
'room-assistant/switch/test-instance-test-switch/status',
payload_on: 'on',
payload_off: 'off',
state_on: 'true',
state_off: 'false'
});
});

it('should subscribe to the command topic when registering a new switch', async () => {
await service.onModuleInit();
service.handleNewEntity(new Switch('test-switch', 'Test Switch'));

expect(
mockMqttClient.subscribe
).toHaveBeenCalledWith(
'room-assistant/switch/test-instance-test-switch/command',
{ qos: 0 }
);
});

it('should include device information in the discovery message', async () => {
mockSystem.mockResolvedValue({
serial: 'abcd',
Expand Down Expand Up @@ -348,4 +402,48 @@ describe('HomeAssistantService', () => {

expect(mockMqttClient.publish).not.toHaveBeenCalled();
});

it('should match incoming messages to the correct entity', async () => {
await service.onModuleInit();
const mockSwitch = new Switch('test-switch', 'Test Switch');
const turnOnSpy = jest.spyOn(mockSwitch, 'turnOn');
service.handleNewEntity(mockSwitch);
service.handleNewEntity(new Sensor('test-sensor', 'Test Sensor'));
service.handleNewEntity(new Switch('switch2', 'Second Switch'));

service.handleIncomingMessage(
'room-assistant/switch/test-instance-test-switch/command',
Buffer.from('on')
);
expect(turnOnSpy).toHaveBeenCalled();
});

it('should ignore incoming messages for unknown entities', async () => {
await service.onModuleInit();
service.handleNewEntity(new Switch('test-switch', 'Test Switch'));

expect(() => {
service.handleIncomingMessage(
'room-assistant/switch/123-test/command',
Buffer.from('on')
);
}).not.toThrowError();
});

it('should ignore incoming messages with invalid commands', async () => {
await service.onModuleInit();
const mockSwitch = new Switch('test-switch', 'Test Switch');
service.handleNewEntity(mockSwitch);
const turnOnSpy = jest.spyOn(mockSwitch, 'turnOn');
const turnOffSpy = jest.spyOn(mockSwitch, 'turnOff');

expect(() => {
service.handleIncomingMessage(
'room-assistant/switch/test-instance-test-switch/command',
Buffer.from('invalid')
);
}).not.toThrowError();
expect(turnOnSpy).not.toHaveBeenCalled();
expect(turnOffSpy).not.toHaveBeenCalled();
});
});
34 changes: 33 additions & 1 deletion src/integrations/home-assistant/home-assistant.service.ts
Expand Up @@ -21,8 +21,10 @@ import { makeId } from '../../util/id';
import { DISTRIBUTED_DEVICE_ID } from './home-assistant.const';
import { BinarySensor } from '../../entities/binary-sensor';
import { BinarySensorConfig } from './binary-sensor-config';
import { Switch } from '../../entities/switch';
import { SwitchConfig } from './switch-config';

const PROPERTY_BLACKLIST = ['component', 'configTopic'];
const PROPERTY_BLACKLIST = ['component', 'configTopic', 'commandStore'];

@Injectable()
export class HomeAssistantService
Expand Down Expand Up @@ -59,6 +61,7 @@ export class HomeAssistantService
this.config.mqttOptions,
false
);
this.mqttClient.on('message', this.handleIncomingMessage.bind(this));
this.logger.log(
`Successfully connected to MQTT broker at ${this.config.mqttUrl}`
);
Expand Down Expand Up @@ -193,6 +196,26 @@ export class HomeAssistantService
}
}

/**
* Executes a stored command based on the topic and content of a MQTT message.
*
* @param topic - Topic that the message was received on
* @param message - Buffer containing the received message as a string
*/
handleIncomingMessage(topic: string, message: Buffer): void {
const configs = Array.from(this.entityConfigs.values());
const config = configs.find(
config => config instanceof SwitchConfig && config.commandTopic == topic
);

if (config instanceof SwitchConfig) {
const command = message.toString();
if (config.commandStore[command]) {
config.commandStore[command]();
}
}
}

/**
* Retrieves information about the local device.
*
Expand Down Expand Up @@ -237,6 +260,15 @@ export class HomeAssistantService
return new SensorConfig(combinedId, entity.name);
} else if (entity instanceof BinarySensor) {
return new BinarySensorConfig(combinedId, entity.name);
} else if (entity instanceof Switch) {
const config = new SwitchConfig(
combinedId,
entity.name,
entity.turnOn.bind(entity),
entity.turnOff.bind(entity)
);
this.mqttClient.subscribe(config.commandTopic, { qos: 0 });
return config;
} else {
return;
}
Expand Down
25 changes: 25 additions & 0 deletions src/integrations/home-assistant/switch-config.ts
@@ -0,0 +1,25 @@
import { EntityConfig } from './entity-config';

export class SwitchConfig extends EntityConfig {
readonly payloadOn = 'on';
readonly payloadOff = 'off';
readonly stateOn = 'true';
readonly stateOff = 'false';
icon?: string;
commandTopic: string;
commandStore: { [key in 'on' | 'off']: () => undefined };

constructor(
id: string,
name: string,
onCallback?: () => undefined,
offCallback?: () => undefined
) {
super('switch', id, name);
this.commandTopic = `room-assistant/${this.component}/${id}/command`;
this.commandStore = {
on: onCallback,
off: offCallback
};
}
}

0 comments on commit 52eaef5

Please sign in to comment.