-
-
Notifications
You must be signed in to change notification settings - Fork 0
/
home-assistant.service.ts
389 lines (359 loc) · 11.9 KB
/
home-assistant.service.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
/* eslint-disable @typescript-eslint/ban-types */
import {
Injectable,
Logger,
OnApplicationShutdown,
OnModuleInit,
} from '@nestjs/common';
import { Entity } from '../../entities/entity.dto';
import { Sensor } from '../../entities/sensor';
import { EntityConfig } from './entity-config';
import { SensorConfig } from './sensor-config';
import * as _ from 'lodash';
import { ConfigService } from '../../config/config.service';
import mqtt, { AsyncMqttClient } from 'async-mqtt';
import { HomeAssistantConfig } from './home-assistant.config';
import { Device } from './device';
import { system } from 'systeminformation';
import { InjectEventEmitter } from 'nest-emitter';
import { EntitiesEventEmitter } from '../../entities/entities.events';
import { EntityCustomization } from '../../entities/entity-customization.interface';
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';
import { DeviceTracker } from '../../entities/device-tracker';
import { DeviceTrackerConfig } from './device-tracker-config';
import { Camera } from '../../entities/camera';
import { CameraConfig } from './camera-config';
import { EntitiesService } from '../../entities/entities.service';
const PROPERTY_DENYLIST = ['component', 'configTopic', 'commandStore'];
@Injectable()
export class HomeAssistantService
implements OnModuleInit, OnApplicationShutdown {
private config: HomeAssistantConfig;
private device: Device;
private entityConfigs: Map<string, EntityConfig> = new Map<
string,
EntityConfig
>();
private debounceFunctions = new Map<
string,
(attributes: { [key: string]: any }) => void
>();
private mqttClient: AsyncMqttClient;
private readonly logger: Logger = new Logger(HomeAssistantService.name);
constructor(
private readonly configService: ConfigService,
private readonly entitiesService: EntitiesService,
@InjectEventEmitter() private readonly emitter: EntitiesEventEmitter
) {
this.config = this.configService.get('homeAssistant');
}
/**
* Lifecycle hook, called once the host module has been initialized.
*/
async onModuleInit(): Promise<void> {
this.device = await this.getDeviceInfo();
try {
this.mqttClient = await mqtt.connectAsync(
this.config.mqttUrl,
{ ...this.config.mqttOptions },
false
);
this.mqttClient.on('message', this.handleIncomingMessage.bind(this));
this.mqttClient.on('connect', this.handleReconnect.bind(this));
this.logger.log(
`Successfully connected to MQTT broker at ${this.config.mqttUrl}`
);
this.emitter.on('newEntity', this.handleNewEntity.bind(this));
this.emitter.on('stateUpdate', this.handleNewState.bind(this));
this.emitter.on('attributesUpdate', this.handleNewAttributes.bind(this));
} catch (e) {
this.logger.error(e, e.stack);
}
}
/**
* Lifecycle hook, called once the application is shutting down.
*/
async onApplicationShutdown(): Promise<void> {
this.entityConfigs.forEach((config) => {
if (
config.device?.identifiers !== DISTRIBUTED_DEVICE_ID &&
config.device?.viaDevice !== DISTRIBUTED_DEVICE_ID
) {
this.logger.debug(`Marking ${config.uniqueId} as unavailable`);
this.mqttClient.publish(
config.availabilityTopic,
config.payloadNotAvailable,
{
qos: 0,
retain: true,
}
);
}
});
return this.mqttClient?.end();
}
/**
* Sends information about a new entity to Home Assistant.
*
* @param entity - Entity to register
* @param customizations - Customizations for the discovery objects
*/
handleNewEntity(
entity: Entity,
customizations: Array<EntityCustomization<any>> = []
): void {
const combinedId = this.getCombinedId(entity.id, entity.distributed);
let config = this.generateEntityConfig(combinedId, entity);
if (config === undefined) {
this.logger.warn(
`${combinedId} cannot be matched to a Home Assistant type and will not be transferred`
);
return;
}
if (entity.distributed) {
config.device = new Device(DISTRIBUTED_DEVICE_ID);
config.device.name = 'room-assistant hub';
} else {
config.device = this.device;
}
config = this.applyCustomizations(config, customizations);
this.entityConfigs.set(combinedId, config);
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 {
// 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(message), {
qos: 0,
retain: true,
});
}
this.mqttClient.publish(config.availabilityTopic, config.payloadAvailable, {
qos: 0,
retain: true,
});
}
/**
* Sends information about entity state changes to Home Assistant.
*
* @param id - ID of the entity that had its state updated
* @param state - New state of the entity
* @param distributed - Whether the entity is a distributed one or not
*/
handleNewState(
id: string,
state: number | string | boolean | Buffer,
distributed = false
): void {
const config = this.entityConfigs.get(this.getCombinedId(id, distributed));
if (config === undefined) {
return;
}
const logState = state instanceof Buffer ? '<binary>' : state;
this.logger.debug(`Sending new state ${logState} for ${config.uniqueId}`);
this.mqttClient.publish(
config.stateTopic,
state instanceof Buffer ? state : String(state),
{
qos: 0,
retain: true,
}
);
}
/**
* Sends information about attribute state changes to Home Assistant.
* Updates are debounced and will only be sent on the next tick.
*
* @param entityId - ID of the entity that had its attributes updated
* @param attributes - All current attributes of the entity
* @param distributed - Whether the entity is a distributed one or not
*/
handleNewAttributes(
entityId: string,
attributes: { [key: string]: any },
distributed = false
): void {
const config = this.entityConfigs.get(
this.getCombinedId(entityId, distributed)
);
if (config === undefined || !this.config.sendAttributes) {
return;
}
if (this.debounceFunctions.has(entityId)) {
this.debounceFunctions.get(entityId)(attributes);
} else {
const debouncedFunc = _.debounce((attributes) => {
this.logger.debug(
`Sending new attributes ${JSON.stringify(attributes)} for ${
config.uniqueId
}`
);
this.mqttClient.publish(
config.jsonAttributesTopic,
JSON.stringify(this.formatMessage(attributes))
);
});
this.debounceFunctions.set(entityId, debouncedFunc);
debouncedFunc(attributes);
}
}
/**
* 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
);
this.logger.debug(
`Received message ${message.toString()} on ${topic} for ${
config?.uniqueId
}`
);
if (config instanceof SwitchConfig) {
const command = message.toString();
if (config.commandStore[command]) {
config.commandStore[command]();
}
}
}
/**
* Checks if room-assistant is connected to the MQTT broker.
*/
isConnected(): boolean {
return this.mqttClient?.connected;
}
/**
* Handles broker re-connection events.
*/
protected handleReconnect(): void {
this.logger.log('Re-connected to broker');
this.entitiesService.refreshStates();
}
/**
* Retrieves information about the local device.
*
* @returns Device information
*/
protected async getDeviceInfo(): Promise<Device> {
const instanceName = this.configService.get('global').instanceName;
const systemInfo = await system();
const serial =
systemInfo.serial && systemInfo.serial !== '-'
? systemInfo.serial
: makeId(instanceName);
const device = new Device(serial);
device.name = instanceName;
device.model = systemInfo.model;
device.manufacturer = systemInfo.manufacturer;
return device;
}
/**
* Generates a Home Assistant ID.
*
* @param entityId - ID of the entity
* @param distributed - Whether the entity is distributed or not
* @returns ID to be used for the entity in Home Assistant
*/
protected getCombinedId(entityId: string, distributed = false): string {
return makeId(
distributed
? entityId
: [this.configService.get('global').instanceName, entityId].join('-')
);
}
/**
* Generates a Home Assistant config for a local entity.
*
* @param combinedId - Home Assistant ID
* @param entity - Entity that the configuration should be generated for
* @returns Entity configuration for Home Assistant
*/
protected generateEntityConfig(
combinedId: string,
entity: Entity
): EntityConfig {
if (entity instanceof Sensor) {
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 if (entity instanceof Camera) {
return new CameraConfig(combinedId, entity.name);
} else if (entity instanceof DeviceTracker) {
return new DeviceTrackerConfig(combinedId, entity.name);
} else {
return;
}
}
/**
* Picks and applies relevant customizations to a Home Assistant entity configuration.
*
* @param config - Existing entity configuration
* @param customizations - Customizations for the configuration
* @returns Customized configuration
*/
protected applyCustomizations(
config: EntityConfig,
customizations: Array<EntityCustomization<any>>
): EntityConfig {
const customization = customizations.find(
(value) => value.for.prototype instanceof EntityConfig
);
if (customization !== undefined) {
Object.assign(config, customization.overrides);
}
return config;
}
/**
* Formats a message to be in the Home Assistant format.
*
* @param message - Message to be sent
* @returns Formatted message
*/
protected formatMessage(message: object): object {
const filteredMessage = _.omit(message, PROPERTY_DENYLIST);
return this.deepMap(filteredMessage, (obj) => {
return _.mapKeys(obj, (v, k) => {
return _.snakeCase(k);
});
});
}
/**
* Maps values of an object in a recursive manner.
*
* @param obj - Object to map
* @param mapper - Function to apply to all items
*/
private deepMap(obj: object, mapper: (v: object) => object): object {
return mapper(
_.mapValues(obj, (v) => {
return _.isObject(v) && !_.isArray(v) ? this.deepMap(v, mapper) : v;
})
);
}
}