matterbridge-shelly
Version:
Matterbridge shelly plugin
223 lines (222 loc) • 11.7 kB
JavaScript
import { AnsiLogger, CYAN, db, er, hk, nf, rs, zb } from 'matterbridge/logger';
import WebSocket from 'ws';
import crypto from 'node:crypto';
import EventEmitter from 'node:events';
import { createDigestShellyAuth } from './auth.js';
import { ShellyDevice } from './shellyDevice.js';
export class WsClient extends EventEmitter {
log;
static logLevel = "info";
wsClient;
_isConnected = false;
_isConnecting = false;
id;
wsHost;
wsDeviceId;
wsUrl;
auth = false;
password;
requestId;
pingInterval;
pongTimeout;
requestFrame = {
id: 0,
src: 'Matterbridge',
method: 'Shelly.GetStatus',
params: {},
};
requestFrameWithAuth = {
id: 0,
src: 'Matterbridge',
method: 'Shelly.GetStatus',
params: {},
auth: { realm: '', username: 'admin', nonce: 0, cnonce: 0, response: '', algorithm: 'SHA-256' },
};
constructor(wsDeviceId, wsHost, password) {
super();
this.log = new AnsiLogger({ logName: 'ShellyWsClient', logTimestampFormat: 4, logLevel: WsClient.logLevel });
this.wsHost = wsHost;
this.wsDeviceId = wsDeviceId;
this.wsUrl = `ws://${this.wsHost}/rpc`;
this.password = password;
this.requestId = crypto.randomInt(0, 999999);
this.requestFrame.id = this.requestId;
this.requestFrame.src = 'Matterbridge' + this.requestId;
this.requestFrameWithAuth.id = this.requestId;
this.requestFrameWithAuth.src = 'Matterbridge' + this.requestId;
}
emit(eventName, ...args) {
return super.emit(eventName, ...args);
}
on(eventName, listener) {
return super.on(eventName, listener);
}
setHost(value) {
this.wsHost = value;
this.wsUrl = `ws://${this.wsHost}/rpc`;
}
get isConnected() {
return this._isConnected;
}
get isConnecting() {
return this._isConnecting;
}
async sendRequest(method = 'Shelly.GetStatus', params = {}) {
if (!this.wsClient || !this._isConnected) {
this.log.error(`SendRequest error: WebSocket client is not connected to device ${hk}${this.wsDeviceId}${er} host ${zb}${this.wsHost}${er}`);
return;
}
this.requestFrame.method = method;
this.requestFrame.params = params;
this.wsClient?.send(JSON.stringify(this.requestFrame));
}
startPingPong(pingTimeout = 30000) {
this.log.debug(`Start PingPong with device ${hk}${this.wsDeviceId}${db} host ${zb}${this.wsHost}${db}.`);
this.pingInterval = setInterval(() => {
if (this.wsClient?.readyState === WebSocket.OPEN) {
this.wsClient.ping();
this.pongTimeout = setTimeout(() => {
this.log.warn(`Pong not received from device ${hk}${this.wsDeviceId}${er} host ${zb}${this.wsHost}${er}, closing connection.`);
this.wsClient?.terminate();
}, pingTimeout);
}
}, pingTimeout);
this.wsClient?.on('pong', () => {
clearTimeout(this.pongTimeout);
this.pongTimeout = undefined;
this.log.debug(`Pong received from device ${hk}${this.wsDeviceId}${db} host ${zb}${this.wsHost}${db}, connection is alive.`);
});
}
stopPingPong() {
this.log.debug(`Stop PingPong with device ${hk}${this.wsDeviceId}${db} host ${zb}${this.wsHost}${db}.`);
if (this.pingInterval) {
clearInterval(this.pingInterval);
this.pingInterval = undefined;
}
if (this.pongTimeout) {
clearTimeout(this.pongTimeout);
this.pongTimeout = undefined;
}
this.wsClient?.removeAllListeners('pong');
}
async listenForStatusUpdates() {
if (this._isConnecting || this._isConnected) {
this.log.debug(`WebSocket client is already ${this._isConnecting ? 'connecting' : 'connected'} to device ${hk}${this.wsDeviceId}${db} host ${zb}${this.wsHost}${db}`);
return;
}
try {
this._isConnecting = true;
this.wsClient = new WebSocket(this.wsUrl);
}
catch (error) {
this.log.error(`Failed to create WebSocket connection to ${zb}${this.wsUrl}${er}: ${error}`);
return;
}
this.wsClient.on('open', () => {
this.log.info(`WebSocket connection opened with Shelly device ${hk}${this.wsDeviceId}${nf} host ${zb}${this.wsHost}${nf}`);
this._isConnecting = false;
this._isConnected = true;
if (this.wsClient?.readyState === WebSocket.OPEN) {
this.log.debug(`Sending request to Shelly device ${hk}${this.wsDeviceId}${db} host ${zb}${this.wsHost}${db}`, this.requestFrame);
this.wsClient?.send(JSON.stringify(this.requestFrame));
}
this.startPingPong();
});
this.wsClient.on('error', (error) => {
this.log.error(`WebSocket error with Shelly device ${hk}${this.wsDeviceId}${er} host ${zb}${this.wsHost}${er}: ${error instanceof Error ? error.message : error}`);
this._isConnecting = false;
});
this.wsClient.on('close', () => {
this.log.info(`WebSocket connection closed with Shelly device ${hk}${this.wsDeviceId}${nf} host ${zb}${this.wsHost}${nf}`);
this._isConnecting = false;
this._isConnected = false;
this.stopPingPong();
});
this.wsClient.on('message', (data, isBinary) => {
try {
const response = JSON.parse(data.toString());
this.id = ShellyDevice.normalizeId(response.src).id;
if (response.error && response.error.code === 401 && response.id === this.requestId && response.dst === 'Matterbridge' + this.requestId) {
this.auth = true;
if (!this.password) {
this.log.error(`Authentication required for ${response.src} but the password is not set. Exiting...`);
return;
}
this.requestFrameWithAuth.method = this.requestFrame.method;
this.requestFrameWithAuth.params = this.requestFrame.params;
const auth = JSON.parse(response.error.message);
this.log.debug(`Auth requested: ${response.error.message}`);
this.requestFrameWithAuth.auth = createDigestShellyAuth('admin', this.password, auth.nonce, crypto.randomInt(0, 999999999), auth.realm, auth.nc);
this.log.debug(`Sending auth request to Shelly device ${hk}${this.wsDeviceId}${db} host ${zb}${this.wsHost}${db}`, this.requestFrameWithAuth);
this.wsClient?.send(JSON.stringify(this.requestFrameWithAuth));
}
else if (response.result && response.id === this.requestId && response.dst === 'Matterbridge' + this.requestId) {
this.log.debug(`Received ${CYAN}Shelly.GetStatus${db} response from ${hk}${this.id}${db} host ${zb}${this.wsHost}${db}:${rs}\n`, response.result);
this.emit('response', response.result);
}
else if (response.method && (response.method === 'NotifyStatus' || response.method === 'NotifyFullStatus') && response.dst === 'Matterbridge' + this.requestId) {
this.log.debug(`Received ${CYAN}${response.method}${db} from ${hk}${this.id}${db} host ${zb}${this.wsHost}${db}:${rs}\n`, response.params);
this.emit('update', response.params);
}
else if (response.method &&
(response.method === 'NotifyStatus' || response.method === 'NotifyFullStatus') &&
response.dst === 'user_1' &&
this.wsDeviceId.startsWith('shellywalldisplay')) {
this.log.debug(`Received ${CYAN}${response.method}${db} from ${hk}${this.id}${db} host ${zb}${this.wsHost}${db}:${rs}\n`, response.params);
this.emit('update', response.params);
}
else if (response.method && response.method === 'NotifyEvent' && response.dst === 'Matterbridge' + this.requestId) {
this.log.debug(`Received ${CYAN}${response.method}${db} from ${hk}${this.id}${db} host ${zb}${this.wsHost}${db}:${rs}\n`, response.params.events);
this.emit('event', response.params.events);
}
else if (response.method && response.method === 'NotifyEvent' && response.dst === 'user_1' && this.wsDeviceId.startsWith('shellywalldisplay')) {
this.log.debug(`Received ${CYAN}${response.method}${db} from ${hk}${this.id}${db} host ${zb}${this.wsHost}${db}:${rs}\n`, response.params.events);
this.emit('event', response.params.events);
}
else if (response.error && response.id === this.requestId && response.dst === 'Matterbridge' + this.requestId) {
this.log.error(`Received ${CYAN}error response${er} from ${hk}${this.id}${er} host ${zb}${this.wsHost}${er}:${rs}\n`, response);
}
else {
this.log.debug(`Received ${CYAN}unknown response${db} from ${hk}${this.id}${db} host ${zb}${this.wsHost}${db}:${rs}\n`, response);
}
}
catch (error) {
this.log.error(`WebSocket client error parsing message from ${hk}${this.id}${er} host ${zb}${this.wsHost}${er}: ${error instanceof Error ? error.message : error}`);
}
});
}
start() {
this.log.debug(`Starting ws client for Shelly device ${hk}${this.wsDeviceId}${db} host ${zb}${this.wsHost}${db}`);
this.listenForStatusUpdates();
this.log.debug(`Started ws client for Shelly device ${hk}${this.wsDeviceId}${db} host ${zb}${this.wsHost}${db}`);
}
stop() {
this.log.debug(`Stopping ws client for Shelly device ${hk}${this.wsDeviceId}${db} host ${zb}${this.wsHost}${db} state ${this.wsClient?.readyState} connencting ${this._isConnecting} connected ${this._isConnected} `);
this.stopPingPong();
if (!this.wsClient)
return;
if (this.wsClient.readyState === WebSocket.OPEN) {
this.wsClient.close();
this.log.debug(`Closed ws client for Shelly device ${hk}${this.wsDeviceId}${db} host ${zb}${this.wsHost}${db}`);
}
else if (this.wsClient.readyState === WebSocket.CONNECTING || this.wsClient.readyState === WebSocket.CLOSING) {
const wsClient = this.wsClient;
const timeout = setTimeout(() => {
if (wsClient.readyState === WebSocket.OPEN)
wsClient.close();
if (wsClient.readyState === WebSocket.CONNECTING || wsClient.readyState === WebSocket.CLOSING)
wsClient.terminate();
}, 1000);
timeout.unref();
this.log.debug(`Terminated ws client for Shelly device ${hk}${this.wsDeviceId}${db} host ${zb}${this.wsHost}${db}`);
}
else if (this.wsClient.readyState === WebSocket.CLOSED) {
this.log.debug(`Ws client already closed for Shelly device ${hk}${this.wsDeviceId}${db} host ${zb}${this.wsHost}${db}`);
}
this._isConnecting = false;
this._isConnected = false;
this.wsClient.removeAllListeners();
this.wsClient = undefined;
this.log.debug(`Stopped ws client for Shelly device ${hk}${this.wsDeviceId}${db} host ${zb}${this.wsHost}${db}`);
}
}