Skip to content

Commit

Permalink
feat(home-assistant): add support for camera entities
Browse files Browse the repository at this point in the history
The images are sent via MQTT in binary JPEG format. Auto-discovery is
also supported.
  • Loading branch information
mKeRix committed May 31, 2020
1 parent 122f557 commit bc941ad
Show file tree
Hide file tree
Showing 8 changed files with 110 additions and 17 deletions.
5 changes: 5 additions & 0 deletions src/entities/camera.ts
@@ -0,0 +1,5 @@
import { Entity } from './entity';

export class Camera extends Entity {
state: Buffer;
}
19 changes: 18 additions & 1 deletion src/entities/entities.controller.spec.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -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]);
});
});
5 changes: 4 additions & 1 deletion src/entities/entities.controller.ts
@@ -1,13 +1,16 @@
import { Controller, Get } from '@nestjs/common';
import { Entity } from './entity';
import { EntitiesService } from './entities.service';
import { Camera } from './camera';

@Controller('entities')
export class EntitiesController {
constructor(private readonly entitiesService: EntitiesService) {}

@Get()
getAll(): Entity[] {
return this.entitiesService.getAll();
return this.entitiesService
.getAll()
.filter((entity) => !(entity instanceof Camera));
}
}
2 changes: 1 addition & 1 deletion src/entities/entities.events.ts
Expand Up @@ -11,7 +11,7 @@ interface EntitiesEvents {

stateUpdate: (
id: string,
state: boolean | string | number,
state: boolean | string | number | Buffer,
distributed?: boolean
) => void;

Expand Down
2 changes: 1 addition & 1 deletion src/entities/entity.ts
Expand Up @@ -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;
}
10 changes: 10 additions & 0 deletions 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;
}
49 changes: 49 additions & 0 deletions src/integrations/home-assistant/home-assistant.service.spec.ts
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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();
Expand Down
35 changes: 22 additions & 13 deletions src/integrations/home-assistant/home-assistant.service.ts
Expand Up @@ -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'];

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

/**
Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit bc941ad

Please sign in to comment.