matterbridge-hass
Version:
Matterbridge hass plugin
417 lines (416 loc) • 19 kB
JavaScript
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);
});
}
}