UNPKG

matterbridge-hass

Version:
417 lines (416 loc) 19 kB
import { EventEmitter } from 'node:events'; import { AnsiLogger, CYAN, db, debugStringify } from 'matterbridge/logger'; import WebSocket from 'ws'; export class HomeAssistant extends EventEmitter { hassDevices = new Map(); hassEntities = new Map(); hassStates = new Map(); hassServices = null; hassConfig = null; pingInterval = null; pingTimeout = null; reconnectTimeout = null; pingIntervalTime = 30000; pingTimeoutTime = 35000; reconnectTimeoutTime = 0; configFetchId = 1; servicesFetchId = 2; devicesFetchId = 3; entitiesFetchId = 4; statesFetchId = 5; eventsSubscribeId = 6; asyncFetchId = 0; asyncCallServiceId = 0; nextId = 7; connected = false; devicesReceived = false; entitiesReceived = false; statesReceived = false; subscribed = false; ws = null; wsUrl; wsAccessToken; log; emit(eventName, ...args) { return super.emit(eventName, ...args); } on(eventName, listener) { return super.on(eventName, listener); } constructor(url, accessToken, reconnectTimeoutTime = 0) { super(); this.wsUrl = url; this.wsAccessToken = accessToken; this.reconnectTimeoutTime = reconnectTimeoutTime * 1000; this.log = new AnsiLogger({ logName: 'HomeAssistant', logTimestampFormat: 4, logLevel: "debug" }); } connect() { if (this.connected) { this.log.info('Already connected to Home Assistant'); return; } try { this.log.info(`Connecting to Home Assistant on ${this.wsUrl}...`); this.ws = new WebSocket(this.wsUrl + '/api/websocket'); this.ws.onopen = () => { this.log.debug('WebSocket connection established'); }; this.ws.onmessage = async (event) => { let response; try { response = JSON.parse(event.data.toString()); } catch (error) { this.log.error('Error parsing WebSocket.MessageEvent:', error); return; } if (response.type === 'auth_required') { 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 with Home Assistant v. ${response.ha_version}`); this.connected = true; this.emit('connected', response.ha_version); this.fetch('get_config', this.configFetchId); this.fetch('get_services', this.servicesFetchId); this.fetch('config/device_registry/list', this.devicesFetchId); this.fetch('config/entity_registry/list', this.entitiesFetchId); this.fetch('get_states', this.statesFetchId); this.fetch('subscribe_events', this.eventsSubscribeId); this.startPing(); } else if (response.type === 'result' && response.success !== true) { this.log.error('Error result received:', response); this.emit('error', response.error); } else if (response.type === 'result' && response.success) { if (response.id === this.devicesFetchId && response.result) { this.devicesReceived = true; const devices = response.result; this.log.debug(`Received ${devices.length} devices.`); this.emit('devices', devices); devices.forEach((device) => { this.hassDevices.set(device.id, device); }); } else if (response.id === this.entitiesFetchId && response.result) { this.entitiesReceived = true; const entities = response.result; this.log.debug(`Received ${entities.length} entities.`); this.emit('entities', entities); entities.forEach((entity) => { this.hassEntities.set(entity.entity_id, entity); }); } else if (response.id === this.statesFetchId) { this.statesReceived = true; const states = response.result; this.log.debug(`Received ${states.length} states.`); this.emit('states', states); states.forEach((state) => { this.hassStates.set(state.entity_id, state); }); } else if (response.id === this.eventsSubscribeId) { this.subscribed = true; this.emit('subscribed'); this.log.debug('Subscribed to events:', response); } else if (response.id === this.configFetchId) { this.hassConfig = response.result; this.emit('config', this.hassConfig); } else if (response.id === this.servicesFetchId) { this.hassServices = response.result; this.emit('services', this.hassServices); } else if (response.id === this.asyncFetchId) { this.log.debug(`Received fectch async result id ${response.id}`); } else if (response.id === this.asyncCallServiceId) { this.log.debug(`Received callService async result id ${response.id}`); } else { this.log.debug(`Unknown result received id ${response.id}:`); } } else 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.emit('pong'); } else if (response.type === 'event') { if (!response.event) { this.log.error('Event response missing event data'); return; } if (response.id === this.eventsSubscribeId && response.event && 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.id === this.eventsSubscribeId && response.event && 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.id === this.eventsSubscribeId && response.event && response.event.event_type === 'device_registry_updated') { this.log.debug(`Event ${CYAN}${response.event.event_type}${db} received id ${CYAN}${response.id}${db}`); const devices = (await this.fetchAsync('config/device_registry/list')); this.log.debug(`Received ${devices.length} devices.`); devices.forEach((device) => { this.hassDevices.set(device.id, device); }); this.emit('devices', devices); } else if (response.id === this.eventsSubscribeId && response.event && response.event.event_type === 'entity_registry_updated') { this.log.debug(`Event ${CYAN}${response.event.event_type}${db} received id ${CYAN}${response.id}${db}`); const entities = (await this.fetchAsync('config/entity_registry/list')); this.log.debug(`Received ${entities.length} entities.`); entities.forEach((entity) => { this.hassEntities.set(entity.entity_id, entity); }); this.emit('entities', entities); } else { this.log.debug(`*Unknown event type ${CYAN}${response.event.event_type}${db} received id ${CYAN}${response.id}${db}`); } } }; this.ws.on('pong', () => { this.log.debug('WebSocket pong received'); if (this.pingTimeout) clearTimeout(this.pingTimeout); this.pingTimeout = null; }); this.ws.onerror = (event) => { this.log.error(`WebSocket error: ${event.message} type: ${event.type}`); this.emit('error', event); }; this.ws.onclose = (event) => { this.log.debug('WebSocket connection closed. Reason:', event.reason, 'Code:', event.code, 'Clean:', event.wasClean, 'Type:', event.type); this.connected = false; this.stopPing(); this.emit('disconnected', event); this.startReconnect(); }; } catch (error) { this.log.error('WebSocket error connecting to Home Assistant:', error); } } startPing() { if (this.pingInterval) { this.log.error('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.nextId}...`); this.ws.send(JSON.stringify({ id: this.nextId++, type: 'ping', })); this.pingTimeout = setTimeout(() => { this.log.error('Ping timeout. Closing connection...'); this.close(); this.startReconnect(); }, this.pingTimeoutTime); }, this.pingIntervalTime); } startReconnect() { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } if (this.reconnectTimeoutTime) { this.log.notice(`Reconnecting in ${this.reconnectTimeoutTime / 1000} seconds...`); this.reconnectTimeout = setTimeout(() => { this.connect(); }, this.reconnectTimeoutTime); } } stopPing() { this.log.debug('Stopping ping interval...'); if (this.pingInterval) { clearInterval(this.pingInterval); this.pingInterval = null; } if (this.pingTimeout) { clearTimeout(this.pingTimeout); this.pingTimeout = null; } } close() { this.log.info('Closing Home Assistance connection...'); this.stopPing(); if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.close(0x1000, 'Normal closure'); } this.ws?.removeAllListeners(); this.ws = null; this.connected = false; this.emit('disconnected'); } fetch(type, id) { if (!this.connected) { this.log.error('Fetch error: not connected to Home Assistant'); return; } if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { this.log.error('Fetch error: WebSocket not open'); return; } if (!id) id = this.nextId++; this.log.debug(`Fetching ${CYAN}${type}${db} id ${CYAN}${id}${db}...`); this.ws.send(JSON.stringify({ id, type })); } fetchAsync(type, timeout = 5000) { return new Promise((resolve, reject) => { if (!this.connected) { this.log.error('FetchAsync error: not connected to Home Assistant'); reject('FetchAsync error: not connected to Home Assistant'); return; } if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { this.log.error('FetchAsync error: WebSocket not open'); reject('FetchAsync error: WebSocket not open'); return; } const asyncFetchId = (this.asyncFetchId = this.nextId++); this.log.debug(`Fetching async ${CYAN}${type}${db} with id ${CYAN}${asyncFetchId}${db} and timeout ${CYAN}${timeout}${db} ms ...`); const message = JSON.stringify({ id: asyncFetchId, type }); this.ws.send(message); const timer = setTimeout(() => { reject('FetchAsync did not complete before the timeout'); }, timeout); const handleMessage = (event) => { let response; try { response = JSON.parse(event.data.toString()); } catch (error) { this.log.error('FetchAsync error parsing WebSocket.MessageEvent:', error); } if (!response) { clearTimeout(timer); this.ws?.removeEventListener('message', handleMessage); reject('FetchAsync error parsing WebSocket.MessageEvent'); return; } if (response.type === 'result' && response.id === asyncFetchId) { clearTimeout(timer); this.ws?.removeEventListener('message', handleMessage); if (response.success) { resolve(response.result); } else { reject(response.error); } } }; this.ws.addEventListener('message', handleMessage); }); } callService(domain, service, entityId, serviceData = {}, id) { if (!this.connected) { this.log.error('CallService error: not connected to Home Assistant'); return; } if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { this.log.error('CallService error: WebSocket not open'); return; } if (!id) id = this.nextId++; this.log.debug(`Calling service ${CYAN}${domain}.${service}${db} for entity ${CYAN}${entityId}${db} with ${debugStringify(serviceData)}${db} id ${CYAN}${id}${db}`); this.ws.send(JSON.stringify({ id, type: 'call_service', domain, service, service_data: { entity_id: entityId, ...serviceData, }, })); } callServiceAsync(domain, service, entityId, serviceData = {}, timeout = 5000) { return new Promise((resolve, reject) => { if (!this.connected) { this.log.error('CallServiceAsync error: not connected to Home Assistant'); reject('CallServiceAsync error: not connected to Home Assistant'); return; } if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { this.log.error('CallServiceAsync error: WebSocket not open'); reject('CallServiceAsync error: WebSocket not open'); return; } const asyncCallServiceId = (this.asyncCallServiceId = this.nextId++); this.log.debug(`Calling service async ${CYAN}${domain}.${service}${db} for entity ${CYAN}${entityId}${db} with ${debugStringify(serviceData)}${db} id ${CYAN}${asyncCallServiceId}${db} and timeout ${CYAN}${timeout}${db} ms ...`); this.ws.send(JSON.stringify({ id: asyncCallServiceId, type: 'call_service', domain, service, service_data: { entity_id: entityId, ...serviceData, }, })); const timer = setTimeout(() => { reject('CallServiceAsync did not complete before the timeout'); }, timeout); const handleMessage = (event) => { let response; try { response = JSON.parse(event.data.toString()); } catch (error) { this.log.error('CallServiceAsync error parsing WebSocket.MessageEvent:', error); } if (!response) { clearTimeout(timer); this.ws?.removeEventListener('message', handleMessage); reject('CallServiceAsync error parsing WebSocket.MessageEvent'); return; } if (response.type === 'result' && response.id === asyncCallServiceId) { clearTimeout(timer); this.ws?.removeEventListener('message', handleMessage); if (response.success) { resolve(response.result); } else { reject(response.error); } } }; this.ws.addEventListener('message', handleMessage); }); } }