diff --git a/src/entities/switch.ts b/src/entities/switch.ts new file mode 100644 index 0000000..fcafa65 --- /dev/null +++ b/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; + } +} diff --git a/src/integrations/home-assistant/binary-sensor-config.ts b/src/integrations/home-assistant/binary-sensor-config.ts index 546e660..f70a0a5 100644 --- a/src/integrations/home-assistant/binary-sensor-config.ts +++ b/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); diff --git a/src/integrations/home-assistant/home-assistant.service.spec.ts b/src/integrations/home-assistant/home-assistant.service.spec.ts index 1ff6bbb..9fab782 100644 --- a/src/integrations/home-assistant/home-assistant.service.spec.ts +++ b/src/integrations/home-assistant/home-assistant.service.spec.ts @@ -2,6 +2,7 @@ const mockMqttClient = { on: jest.fn(), publish: jest.fn(), + subscribe: jest.fn(), end: jest.fn() }; @@ -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', () => { @@ -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 () => { @@ -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', @@ -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(); + }); }); diff --git a/src/integrations/home-assistant/home-assistant.service.ts b/src/integrations/home-assistant/home-assistant.service.ts index 527fc87..5cd41f2 100644 --- a/src/integrations/home-assistant/home-assistant.service.ts +++ b/src/integrations/home-assistant/home-assistant.service.ts @@ -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 @@ -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}` ); @@ -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. * @@ -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; } diff --git a/src/integrations/home-assistant/switch-config.ts b/src/integrations/home-assistant/switch-config.ts new file mode 100644 index 0000000..8a99a7f --- /dev/null +++ b/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 + }; + } +}