UNPKG

matterbridge-hass

Version:
616 lines (615 loc) 28.8 kB
import { EventEmitter } from 'node:events'; import { readFileSync } from 'node:fs'; import { AnsiLogger, CYAN, db, debugStringify, er } from 'matterbridge/logger'; import WebSocket from 'ws'; export class HomeAssistant extends EventEmitter { connected = false; ws = null; wsUrl; wsAccessToken; log; hassDevices = new Map(); hassEntities = new Map(); hassStates = new Map(); hassAreas = new Map(); hassLabels = new Map(); hassServices = null; hassConfig = null; static hassConfig = null; pingInterval = null; pingTimeout = null; reconnectTimeout = null; pingIntervalTime = 30000; pingTimeoutTime = 35000; reconnectTimeoutTime = 60000; reconnectRetries = 10; _responseTimeout = 5000; certificatePath = undefined; rejectUnauthorized = undefined; reconnectRetry = 1; requestId = 1; fetchTimeout = null; fetchQueue = new Set(); emit(eventName, ...args) { return super.emit(eventName, ...args); } on(eventName, listener) { return super.on(eventName, listener); } get responseTimeout() { return this._responseTimeout; } set responseTimeout(value) { this._responseTimeout = value; } constructor(url, accessToken, reconnectTimeoutTime = 60, reconnectRetries = 10, certificatePath = undefined, rejectUnauthorized = undefined) { super(); this.wsUrl = url; this.wsAccessToken = accessToken; this.reconnectTimeoutTime = reconnectTimeoutTime * 1000; this.reconnectRetries = reconnectRetries; this.certificatePath = certificatePath; this.rejectUnauthorized = rejectUnauthorized; this.log = new AnsiLogger({ logName: 'HomeAssistant', logTimestampFormat: 4, logLevel: "debug", }); } onOpen = () => { this.log.debug('WebSocket connection established'); this.emit('socket_opened'); }; onPing(data) { this.log.debug('WebSocket ping received'); if (this.pingTimeout) { clearTimeout(this.pingTimeout); this.pingTimeout = null; this.log.debug('Stopped ping timeout'); } this.emit('ping', data); } onPong(data) { this.log.debug('WebSocket pong received'); if (this.pingTimeout) { clearTimeout(this.pingTimeout); this.pingTimeout = null; this.log.debug('Stopped ping timeout'); } this.emit('pong', data); } onError(error) { const errorMessage = `WebSocket error: ${error}`; this.log.debug(errorMessage); this.emit('error', errorMessage); } onMessage(data, isBinary) { let response; try { response = JSON.parse(isBinary ? data.toString() : data); } catch (error) { this.log.error(`Error parsing WebSocket message: ${error}`); return; } if (response.type === 'pong') { this.log.debug(`Home Assistant pong received with id ${response.id}`); if (this.pingTimeout) { clearTimeout(this.pingTimeout); this.pingTimeout = null; this.log.debug('Stopped ping timeout'); } this.emit('pong', Buffer.from('Home Assistant pong received')); } else if (response.type === 'event') { if (!response.event) { const errorMessage = `WebSocket event response missing event data for id ${response.id}`; this.log.error(errorMessage); this.emit('error', errorMessage); return; } if (response.event.event_type === 'state_changed') { const entity = this.hassEntities.get(response.event.data.entity_id); if (!entity) { this.log.debug(`Entity id ${CYAN}${response.event.data.entity_id}${db} not found processing event`); return; } if (response.event.data.old_state && response.event.data.new_state) { this.hassStates.set(response.event.data.new_state.entity_id, response.event.data.new_state); this.emit('event', entity.device_id, entity.entity_id, response.event.data.old_state, response.event.data.new_state); } } else if (response.event.event_type === 'call_service') { this.log.debug(`Event ${CYAN}${response.event.event_type}${db} received id ${CYAN}${response.id}${db}`); this.emit('call_service'); } else if (response.event.event_type === 'core_config_updated') { this.log.debug(`Event ${CYAN}${response.event.event_type}${db} received id ${CYAN}${response.id}${db}`); if (this.fetchTimeout) clearTimeout(this.fetchTimeout); this.fetchTimeout = setTimeout(this.onFetchTimeout.bind(this), 5000).unref(); this.fetchQueue.add('get_config'); } else if (response.event.event_type === 'device_registry_updated') { this.log.debug(`Event ${CYAN}${response.event.event_type}${db} received id ${CYAN}${response.id}${db}`); if (this.fetchTimeout) clearTimeout(this.fetchTimeout); this.fetchTimeout = setTimeout(this.onFetchTimeout.bind(this), 5000).unref(); this.fetchQueue.add('config/device_registry/list'); } else if (response.event.event_type === 'entity_registry_updated') { this.log.debug(`Event ${CYAN}${response.event.event_type}${db} received id ${CYAN}${response.id}${db}`); if (this.fetchTimeout) clearTimeout(this.fetchTimeout); this.fetchTimeout = setTimeout(this.onFetchTimeout.bind(this), 5000).unref(); this.fetchQueue.add('config/entity_registry/list'); } else if (response.event.event_type === 'area_registry_updated') { this.log.debug(`Event ${CYAN}${response.event.event_type}${db} received id ${CYAN}${response.id}${db}`); if (this.fetchTimeout) clearTimeout(this.fetchTimeout); this.fetchTimeout = setTimeout(this.onFetchTimeout.bind(this), 5000).unref(); this.fetchQueue.add('config/area_registry/list'); } else if (response.event.event_type === 'label_registry_updated') { this.log.debug(`Event ${CYAN}${response.event.event_type}${db} received id ${CYAN}${response.id}${db}`); if (this.fetchTimeout) clearTimeout(this.fetchTimeout); this.fetchTimeout = setTimeout(this.onFetchTimeout.bind(this), 5000).unref(); this.fetchQueue.add('config/label_registry/list'); } else { this.log.debug(`*Unknown event type ${CYAN}${response.event.event_type}${db} received id ${CYAN}${response.id}${db}`); } } } onClose(code, reason) { const closeMessage = `WebSocket connection closed. Code: ${code} Reason: ${reason.toString()}`; this.log.debug(closeMessage); this.connected = false; this.stopPing(); this.emit('socket_closed', code, reason); this.emit('disconnected', `Code: ${code} Reason: ${reason.toString()}`); this.startReconnect(); } async onFetchTimeout() { this.fetchTimeout = null; this.log.debug(`Fetch timeout reached, processing fetch queue of ${this.fetchQueue.size} fetch id(s)...`); for (const fetchId of this.fetchQueue) { this.log.debug(`Fetching ${CYAN}${fetchId}${db}...`); try { const data = await this.fetch(fetchId); this.log.debug(`Received data for ${CYAN}${fetchId}${db}`); if (fetchId === 'get_config') { const config = data; this.log.debug(`Received config.`); this.hassConfig = config; HomeAssistant.hassConfig = this.hassConfig; this.emit('config', config); } else if (fetchId === 'config/device_registry/list') { const devices = data; this.log.debug(`Received ${devices.length} devices.`); devices.forEach((device) => this.hassDevices.set(device.id, device)); this.emit('devices', devices); } else if (fetchId === 'config/entity_registry/list') { const entities = data; this.log.debug(`Received ${entities.length} entities.`); entities.forEach((entity) => this.hassEntities.set(entity.entity_id, entity)); this.emit('entities', entities); } else if (fetchId === 'config/area_registry/list') { const areas = data; this.log.debug(`Received ${areas.length} areas.`); areas.forEach((area) => this.hassAreas.set(area.area_id, area)); this.emit('areas', areas); } else if (fetchId === 'config/label_registry/list') { const labels = data; this.log.debug(`Received ${labels.length} labels.`); labels.forEach((label) => this.hassLabels.set(label.label_id, label)); this.emit('labels', labels); } } catch (error) { this.log.error(`Error fetching ${CYAN}${fetchId}${er}: ${error}`); } this.fetchQueue.delete(fetchId); } } connect() { return new Promise((resolve, reject) => { if (this.connected) { return reject(new Error('Already connected to Home Assistant')); } try { this.log.info(`Connecting to Home Assistant on ${this.wsUrl}...`); if (this.wsUrl.startsWith('ws://')) { this.ws = new WebSocket(this.wsUrl + '/api/websocket'); } else if (this.wsUrl.startsWith('wss://')) { let ca; if (this.certificatePath) { this.log.debug(`Loading CA certificate from ${this.certificatePath}...`); ca = readFileSync(this.certificatePath); this.log.debug(`CA certificate loaded successfully`); } this.ws = new WebSocket(this.wsUrl + '/api/websocket', { ca, rejectUnauthorized: this.rejectUnauthorized, }); } else { return reject(new Error(`Invalid WebSocket URL: ${this.wsUrl}. It must start with ws:// or wss://`)); } this.ws.on('open', this.onOpen.bind(this)); this.ws.on('ping', this.onPing.bind(this)); this.ws.on('pong', this.onPong.bind(this)); this.ws.on('close', this.onClose.bind(this)); this.ws.onerror = (event) => { this.log.error(`WebSocket error: ${event.message}`); this.emit('error', `WebSocket error: ${event.message}`); return reject(new Error(`WebSocket error: ${event.message}`)); }; this.ws.onmessage = async (event) => { let response; try { response = JSON.parse(event.data.toString()); } catch (error) { return reject(new Error(`Error parsing WebSocket message: ${error}`)); } if (response.type === 'auth_required') { this.log.debug(`Authentication required: ${debugStringify(response)}`); this.log.debug('Authentication required. Sending auth message...'); this.ws?.send(JSON.stringify({ type: 'auth', access_token: this.wsAccessToken, })); } else if (response.type === 'auth_ok') { this.log.debug(`Authenticated successfully: ${debugStringify(response)}`); this.log.debug(`Authenticated successfully with Home Assistant v. ${response.ha_version}`); this.connected = true; this.reconnectRetry = 1; if (this.ws) this.ws.onmessage = null; this.ws?.on('message', this.onMessage.bind(this)); if (this.ws) this.ws.onerror = null; this.ws?.on('error', this.onError.bind(this)); this.startPing(); this.emit('connected', response.ha_version); return resolve(response.ha_version); } }; } catch (error) { const errorMessage = `WebSocket error connecting to Home Assistant: ${error}`; this.log.debug(errorMessage); return reject(new Error(errorMessage)); } }); } startPing() { if (this.pingInterval) { this.log.debug('Ping interval already started'); return; } this.log.debug('Starting ping interval...'); this.pingInterval = setInterval(() => { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { this.log.error('WebSocket not open sending ping. Closing connection...'); this.close(); return; } this.log.debug(`Sending WebSocket ping...`); this.ws.ping(); this.log.debug(`Sending Home Assistant ping id ${this.requestId}...`); this.ws.send(JSON.stringify({ id: this.requestId++, type: 'ping', })); this.log.debug('Starting ping timeout...'); this.pingTimeout = setTimeout(() => { this.log.error('Ping timeout. Closing connection...'); this.close(); this.startReconnect(); }, this.pingTimeoutTime).unref(); this.log.debug('Started ping timeout'); }, this.pingIntervalTime).unref(); this.log.debug('Started ping interval'); } stopPing() { this.log.debug('Stopping ping interval...'); if (this.pingInterval) { clearInterval(this.pingInterval); this.pingInterval = null; } this.log.debug('Stopped ping interval'); this.log.debug('Stopping ping timeout...'); if (this.pingTimeout) { clearTimeout(this.pingTimeout); this.pingTimeout = null; } this.log.debug('Stopped ping timeout'); } startReconnect() { if (this.reconnectTimeout) { this.log.debug(`Reconnecting already in progress.`); return; } if (this.reconnectTimeoutTime && this.reconnectRetry <= this.reconnectRetries) { this.log.notice(`Reconnecting in ${this.reconnectTimeoutTime / 1000} seconds...`); this.reconnectTimeout = setTimeout(() => { this.log.notice(`Reconnecting attempt ${this.reconnectRetry} of ${this.reconnectRetries}...`); this.connect(); this.reconnectRetry++; this.reconnectTimeout = null; }, this.reconnectTimeoutTime).unref(); } else { this.log.error('Restart the plugin to reconnect.'); } } close(code = 1000, reason = 'Normal closure') { return new Promise((resolve, reject) => { this.log.info('Closing Home Assistant connection...'); this.stopPing(); if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } const cleanup = () => { clearTimeout(timer); this.ws?.removeAllListeners(); this.ws = null; this.connected = false; this.emit('disconnected', 'WebSocket connection closed'); this.log.info('Home Assistant connection closed'); }; const timer = setTimeout(() => { const message = `Close did not complete before the timeout of ${this._responseTimeout} ms`; this.log.debug(message); cleanup(); return reject(new Error(message)); }, this._responseTimeout).unref(); const onClose = () => { this.log.debug('Close received closed event from Home Assistant'); this.emit('socket_closed', code, Buffer.from(reason)); cleanup(); return resolve(); }; const onError = () => { const message = 'Close received error event while closing connection to Home Assistant'; this.log.debug(message); cleanup(); return reject(new Error(message)); }; if (this.ws) { this.ws.removeAllListeners(); this.ws.onclose = onClose; this.ws.onerror = onError; } if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.log.debug('Close websocket is open, closing...'); this.ws.close(code, reason); } else { this.log.debug('Close websocket is not open, resolving...'); cleanup(); return resolve(); } }); } async fetchData() { try { this.log.debug('Fetching initial data from Home Assistant...'); this.hassConfig = (await this.fetch('get_config')); HomeAssistant.hassConfig = this.hassConfig; this.log.debug('Received config.'); this.emit('config', this.hassConfig); this.hassServices = (await this.fetch('get_services')); this.log.debug('Received services.'); this.emit('services', this.hassServices); const devices = (await this.fetch('config/device_registry/list')); devices.forEach((device) => this.hassDevices.set(device.id, device)); this.log.debug(`Received ${devices.length} devices.`); this.emit('devices', devices); const entities = (await this.fetch('config/entity_registry/list')); entities.forEach((entity) => this.hassEntities.set(entity.entity_id, entity)); this.log.debug(`Received ${entities.length} entities.`); this.emit('entities', entities); const states = (await this.fetch('get_states')); states.forEach((state) => this.hassStates.set(state.entity_id, state)); this.log.debug(`Received ${states.length} states.`); this.emit('states', states); const areas = (await this.fetch('config/area_registry/list')); areas.forEach((area) => this.hassAreas.set(area.area_id, area)); this.log.debug(`Received ${areas.length} areas.`); this.emit('areas', areas); const labels = (await this.fetch('config/label_registry/list')); labels.forEach((label) => this.hassLabels.set(label.label_id, label)); this.log.debug(`Received ${labels.length} labels.`); this.emit('labels', labels); this.log.debug('Initial data fetched successfully.'); } catch (error) { this.log.error(`Error fetching initial data: ${error}`); } } fetch(type) { return new Promise((resolve, reject) => { if (!this.connected) { return reject(new Error('Fetch error: not connected to Home Assistant')); } if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return reject(new Error('Fetch error: WebSocket not open')); } const requestId = this.requestId++; const timer = setTimeout(() => { this.ws?.removeEventListener('message', handleMessage); return reject(new Error(`Fetch type ${type} id ${requestId} did not complete before the timeout`)); }, this._responseTimeout).unref(); const handleMessage = (event) => { try { const response = JSON.parse(event.data.toString()); if (response.type === 'result' && response.id === requestId) { clearTimeout(timer); this.ws?.removeEventListener('message', handleMessage); if (response.success) { return resolve(response.result); } else { return reject(new Error(response.error?.message)); } } } catch (error) { clearTimeout(timer); this.ws?.removeEventListener('message', handleMessage); reject(error); } }; this.ws.addEventListener('message', handleMessage); this.log.debug(`Fetching ${CYAN}${type}${db} with id ${CYAN}${requestId}${db} and timeout ${CYAN}${this._responseTimeout}${db} ms ...`); this.ws.send(JSON.stringify({ id: requestId, type })); }); } subscribe(event) { return new Promise((resolve, reject) => { if (!this.connected) { return reject(new Error('Subscribe error: not connected to Home Assistant')); } if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return reject(new Error('Subscribe error: WebSocket not open')); } const requestId = this.requestId++; const timer = setTimeout(() => { this.ws?.removeEventListener('message', handleMessage); return reject(new Error(`Subscribe event ${event} id ${requestId} did not complete before the timeout`)); }, this._responseTimeout).unref(); const handleMessage = (event) => { try { const response = JSON.parse(event.data.toString()); if (response.type === 'result' && response.id === requestId) { clearTimeout(timer); this.ws?.removeEventListener('message', handleMessage); if (response.success) { this.log.debug(`Subscribed successfully with id ${CYAN}${requestId}${db}`); return resolve(response.id); } else { return reject(new Error(response.error?.message)); } } } catch (error) { clearTimeout(timer); this.ws?.removeEventListener('message', handleMessage); reject(error); } }; this.ws.addEventListener('message', handleMessage); this.log.debug(`Subscribing to ${CYAN}${event ?? 'all events'}${db} with id ${CYAN}${requestId}${db} and timeout ${CYAN}${this._responseTimeout}${db} ms ...`); this.ws.send(JSON.stringify({ id: requestId, type: 'subscribe_events', event_type: event, })); }); } unsubscribe(subscriptionId) { return new Promise((resolve, reject) => { if (!this.connected) { return reject(new Error('Unsubscribe error: not connected to Home Assistant')); } if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return reject(new Error('Unsubscribe error: WebSocket not open')); } const requestId = this.requestId++; const timer = setTimeout(() => { this.ws?.removeEventListener('message', handleMessage); return reject(new Error(`Unsubscribe subscription ${subscriptionId} id ${requestId} did not complete before the timeout`)); }, this._responseTimeout).unref(); const handleMessage = (event) => { try { const response = JSON.parse(event.data.toString()); if (response.type === 'result' && response.id === requestId) { clearTimeout(timer); this.ws?.removeEventListener('message', handleMessage); if (response.success) { this.log.debug(`Unsubscribed successfully with id ${CYAN}${requestId}${db}`); return resolve(); } else { return reject(new Error(response.error?.message)); } } } catch (error) { clearTimeout(timer); this.ws?.removeEventListener('message', handleMessage); reject(error); } }; this.ws.addEventListener('message', handleMessage); this.log.debug(`Unsubscribing from subscription ${CYAN}${subscriptionId}${db} with id ${CYAN}${requestId}${db} and timeout ${CYAN}${this._responseTimeout}${db} ms ...`); this.ws.send(JSON.stringify({ id: requestId, type: 'unsubscribe_events', subscription: subscriptionId, })); }); } callService(domain, service, entityId, serviceData = {}) { return new Promise((resolve, reject) => { if (!this.connected) { return reject(new Error('CallService error: not connected to Home Assistant')); } if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { return reject(new Error('CallService error: WebSocket not open')); } const requestId = this.requestId++; const timer = setTimeout(() => { this.ws?.removeEventListener('message', handleMessage); return reject(new Error(`CallService service ${domain}.${service} entity ${entityId} id ${requestId} did not complete before the timeout`)); }, this._responseTimeout).unref(); const handleMessage = (event) => { try { const response = JSON.parse(event.data.toString()); if (response.type === 'result' && response.id === requestId) { clearTimeout(timer); this.ws?.removeEventListener('message', handleMessage); if (response.success) { return resolve(response.result); } else { return reject(new Error(response.error?.message)); } } } catch (error) { clearTimeout(timer); this.ws?.removeEventListener('message', handleMessage); reject(error); } }; this.ws.addEventListener('message', handleMessage); this.log.debug(`Calling service ${CYAN}${domain}.${service}${db} for entity ${CYAN}${entityId}${db} with ${debugStringify(serviceData)}${db} id ${CYAN}${requestId}${db} and timeout ${CYAN}${this._responseTimeout}${db} ms ...`); this.ws.send(JSON.stringify({ id: requestId, type: 'call_service', domain, service, service_data: { ...serviceData, }, target: { entity_id: entityId, }, })); }); } }