matterbridge-hass
Version:
Matterbridge hass plugin
616 lines (615 loc) • 28.8 kB
JavaScript
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,
},
}));
});
}
}