UNPKG

matterbridge-shelly

Version:
224 lines (223 loc) 11.9 kB
import crypto from 'node:crypto'; import EventEmitter from 'node:events'; import { AnsiLogger, CYAN, db, er, hk, nf, rs, wr, zb } from 'matterbridge/logger'; import WebSocket from 'ws'; 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; wsPort = 80; 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, nc: '00000001', response: '', algorithm: 'SHA-256' }, }; constructor(wsDeviceId, wsHost, wsPort = 80, password) { super(); this.log = new AnsiLogger({ logName: 'ShellyWsClient', logTimestampFormat: 4, logLevel: WsClient.logLevel }); this.wsHost = wsHost; this.wsPort = wsPort; this.wsDeviceId = wsDeviceId; this.wsUrl = `ws://${this.wsHost}:${this.wsPort}/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; } 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}${wr} host ${zb}${this.wsHost}${wr}, 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._isConnecting = false; 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.emit('open'); }); 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.emit('error', error.message); }); this.wsClient.on('close', (code, reason) => { this.log.info(`WebSocket connection closed with Shelly device ${hk}${this.wsDeviceId}${nf} host ${zb}${this.wsHost}${nf}: code ${code} ${reason.toString('utf-8') === '' ? '' : 'reason ' + reason.toString('utf-8')}`); this._isConnecting = false; this._isConnected = false; this.stopPingPong(); this.emit('close', code, reason); }); 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}`); } }); this.emit('started'); } 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; setTimeout(() => { if (wsClient.readyState === WebSocket.OPEN) wsClient.close(); if (wsClient.readyState === WebSocket.CONNECTING || wsClient.readyState === WebSocket.CLOSING) wsClient.terminate(); }, 1000).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}`); this.emit('stopped'); } }