diff --git a/src/entities/camera.ts b/src/entities/camera.ts new file mode 100644 index 0000000..3f31261 --- /dev/null +++ b/src/entities/camera.ts @@ -0,0 +1,5 @@ +import { Entity } from './entity'; + +export class Camera extends Entity { + state: Buffer; +} diff --git a/src/entities/entities.controller.spec.ts b/src/entities/entities.controller.spec.ts index 8e405e2..5f8b8e0 100644 --- a/src/entities/entities.controller.spec.ts +++ b/src/entities/entities.controller.spec.ts @@ -9,6 +9,8 @@ import { ClusterModule } from '../cluster/cluster.module'; import { EventEmitter } from 'events'; import { ClusterService } from '../cluster/cluster.service'; import { ConfigModule } from '../config/config.module'; +import { BinarySensor } from './binary-sensor'; +import { Camera } from './camera'; describe('Entities Controller', () => { let controller: EntitiesController; @@ -43,6 +45,21 @@ describe('Entities Controller', () => { return entities; }); - expect(controller.getAll()).toBe(entities); + expect(controller.getAll()).toStrictEqual(entities); + }); + + it('should filter camera entities from the list', () => { + const entities: Entity[] = [ + new BinarySensor('binary_sensor', 'Test Binary Sensor'), + new Camera('camera', 'Test Camera'), + ]; + jest.spyOn(service, 'getAll').mockImplementation(() => { + return entities; + }); + + const result = controller.getAll(); + expect(result).toHaveLength(1); + expect(result).toContain(entities[0]); + expect(result).not.toContain(entities[1]); }); }); diff --git a/src/entities/entities.controller.ts b/src/entities/entities.controller.ts index 34d6e51..d823a4c 100644 --- a/src/entities/entities.controller.ts +++ b/src/entities/entities.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get } from '@nestjs/common'; import { Entity } from './entity'; import { EntitiesService } from './entities.service'; +import { Camera } from './camera'; @Controller('entities') export class EntitiesController { @@ -8,6 +9,8 @@ export class EntitiesController { @Get() getAll(): Entity[] { - return this.entitiesService.getAll(); + return this.entitiesService + .getAll() + .filter((entity) => !(entity instanceof Camera)); } } diff --git a/src/entities/entities.events.ts b/src/entities/entities.events.ts index 2ddcbce..ec5cd39 100644 --- a/src/entities/entities.events.ts +++ b/src/entities/entities.events.ts @@ -11,7 +11,7 @@ interface EntitiesEvents { stateUpdate: ( id: string, - state: boolean | string | number, + state: boolean | string | number | Buffer, distributed?: boolean ) => void; diff --git a/src/entities/entity.ts b/src/entities/entity.ts index 5fa91e1..a35901f 100644 --- a/src/entities/entity.ts +++ b/src/entities/entity.ts @@ -7,7 +7,7 @@ export abstract class Entity { readonly id: string; name: string; - state: string | number | boolean; + state: string | number | boolean | Buffer; attributes: { [key: string]: any } = {}; readonly distributed: boolean; } diff --git a/src/integrations/home-assistant/camera-config.ts b/src/integrations/home-assistant/camera-config.ts new file mode 100644 index 0000000..a46344c --- /dev/null +++ b/src/integrations/home-assistant/camera-config.ts @@ -0,0 +1,10 @@ +import { EntityConfig } from './entity-config'; + +export class CameraConfig extends EntityConfig { + constructor(id: string, name: string) { + super('camera', id, name); + this.topic = this.stateTopic; + } + + readonly topic: string; +} diff --git a/src/integrations/home-assistant/home-assistant.service.spec.ts b/src/integrations/home-assistant/home-assistant.service.spec.ts index 5b8ca82..67992d4 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 { Sensor } from '../../entities/sensor'; import { BinarySensor } from '../../entities/binary-sensor'; import { Switch } from '../../entities/switch'; import { DeviceTracker } from '../../entities/device-tracker'; +import { Camera } from '../../entities/camera'; import { DISTRIBUTED_DEVICE_ID } from './home-assistant.const'; jest.mock('async-mqtt', () => { @@ -288,6 +289,37 @@ describe('HomeAssistantService', () => { expect(mockMqttClient.publish).toHaveBeenCalledTimes(1); }); + it('should publish discovery information for a new camera', async () => { + await service.onModuleInit(); + service.handleNewEntity(new Camera('test-camera', 'Test Camera')); + + expect(mockMqttClient.publish).toHaveBeenCalledWith( + 'homeassistant/camera/room-assistant/test-instance-test-camera/config', + expect.any(String), + { + qos: 0, + retain: true, + } + ); + expect(mockMqttClient.publish).toHaveBeenCalledWith( + 'room-assistant/camera/test-instance-test-camera/status', + 'online', + { + qos: 0, + retain: true, + } + ); + expect(JSON.parse(mockMqttClient.publish.mock.calls[0][1])).toMatchObject({ + unique_id: 'room-assistant-test-instance-test-camera', + name: 'Test Camera', + topic: 'room-assistant/camera/test-instance-test-camera/state', + json_attributes_topic: + 'room-assistant/camera/test-instance-test-camera/attributes', + availability_topic: + 'room-assistant/camera/test-instance-test-camera/status', + }); + }); + it('should include device information in the discovery message', async () => { mockSystem.mockResolvedValue({ serial: 'abcd', @@ -389,6 +421,23 @@ describe('HomeAssistantService', () => { ); }); + it('should publish Buffer states in binary form', async () => { + const imageData = new Buffer('abc'); + + await service.onModuleInit(); + service.handleNewEntity(new Camera('test', 'Test')); + service.handleNewState('test', imageData); + + expect(mockMqttClient.publish).toHaveBeenCalledWith( + 'room-assistant/camera/test-instance-test/state', + imageData, + { + qos: 0, + retain: true, + } + ); + }); + it('should ignore state updates if the entity is not registered with Home Assistant', () => { service.handleNewState('does-not-exist', 'not_home'); expect(mockMqttClient.publish).not.toHaveBeenCalled(); diff --git a/src/integrations/home-assistant/home-assistant.service.ts b/src/integrations/home-assistant/home-assistant.service.ts index 0ba19ed..0b3804e 100644 --- a/src/integrations/home-assistant/home-assistant.service.ts +++ b/src/integrations/home-assistant/home-assistant.service.ts @@ -26,6 +26,8 @@ import { Switch } from '../../entities/switch'; import { SwitchConfig } from './switch-config'; import { DeviceTracker } from '../../entities/device-tracker'; import { DeviceTrackerConfig } from './device-tracker-config'; +import { Camera } from '../../entities/camera'; +import { CameraConfig } from './camera-config'; const PROPERTY_BLACKLIST = ['component', 'configTopic', 'commandStore']; @@ -136,17 +138,18 @@ export class HomeAssistantService `Device tracker requires manual setup in Home Assistant with topic: ${config.stateTopic}` ); } else { + // camera entities do not support stateTopic + const message = this.formatMessage( + config instanceof CameraConfig ? _.omit(config, ['stateTopic']) : config + ); + 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.configTopic, JSON.stringify(message), { + qos: 0, + retain: true, + }); } this.mqttClient.publish(config.availabilityTopic, config.payloadAvailable, { @@ -164,7 +167,7 @@ export class HomeAssistantService */ handleNewState( id: string, - state: number | string | boolean, + state: number | string | boolean | Buffer, distributed = false ): void { const config = this.entityConfigs.get(this.getCombinedId(id, distributed)); @@ -173,10 +176,14 @@ export class HomeAssistantService } this.logger.debug(`Sending new state ${state} for ${config.uniqueId}`); - this.mqttClient.publish(config.stateTopic, String(state), { - qos: 0, - retain: true, - }); + this.mqttClient.publish( + config.stateTopic, + state instanceof Buffer ? state : String(state), + { + qos: 0, + retain: true, + } + ); } /** @@ -301,6 +308,8 @@ export class HomeAssistantService ); this.mqttClient.subscribe(config.commandTopic, { qos: 0 }); return config; + } else if (entity instanceof Camera) { + return new CameraConfig(combinedId, entity.name); } else if (entity instanceof DeviceTracker) { return new DeviceTrackerConfig(combinedId, entity.name); } else {