iobroker.zigbee2mqtt
Version:
Zigbee2MQTT adapter for ioBroker
269 lines (241 loc) • 10.1 kB
JavaScript
'use strict';
const WebSocket = require('ws');
const wsHeartbeatIntervall = 5000;
const restartTimeout = 1000;
/** Maximum reconnect delay in milliseconds (caps exponential backoff) */
const MAX_RESTART_TIMEOUT = 30000;
/**
* Verwaltet die WebSocket-Verbindung zu Zigbee2MQTT inklusive
* Heartbeat-Überwachung und exponentiellem Reconnect-Backoff.
*/
class WebsocketController {
/**
* Erstellt eine neue WebsocketController-Instanz.
*
* @param {object} adapter Die ioBroker-Adapter-Instanz
*/
constructor(adapter) {
this.adapter = adapter;
this.wsClient = null;
this.ping = null;
this.pingTimeout = null;
this.autoRestartTimeout = null;
// Flag: wird bei closeConnection() gesetzt damit autoRestart() nicht feuert
this._intentionalClose = false;
/** Aktueller Reconnect-Delay (exponentielles Backoff) */
this._reconnectDelay = restartTimeout;
}
/**
* Baut eine neue WebSocket-Verbindung auf.
* Bestehende Verbindung und alle Timer werden zuerst sicher bereinigt.
*/
initWsClient() {
// Config-Guard: Pflichtfelder müssen vorhanden sein
if (!this.adapter.config.wsScheme || !this.adapter.config.wsServerIP || !this.adapter.config.wsServerPort) {
this.adapter.log.error('WebSocket config incomplete (wsScheme / wsServerIP / wsServerPort missing).');
return;
}
this._intentionalClose = false;
// Vorherige Timer stoppen
this.adapter.clearTimeout(this.ping);
this.adapter.clearTimeout(this.pingTimeout);
this.adapter.clearTimeout(this.autoRestartTimeout);
// Vorherige Verbindung sicher schließen und null setzen
if (this.wsClient) {
this.wsClient.removeAllListeners();
if (this.wsClient.readyState !== WebSocket.CLOSED &&
this.wsClient.readyState !== WebSocket.CLOSING) {
try {
this.wsClient.terminate();
} catch (e) {
this.adapter.log.debug(`initWsClient: old socket terminate error: ${e}`);
}
}
this.wsClient = null;
}
try {
let wsURL = `${this.adapter.config.wsScheme}://${this.adapter.config.wsServerIP}:${this.adapter.config.wsServerPort}/api`;
// Für Logging: URL ohne Token (Sicherheit)
const wsURLSafe = wsURL;
if (this.adapter.config.wsTokenEnabled === true) {
wsURL += `?token=${this.adapter.config.wsToken}`;
}
this.adapter.log.debug(`WebSocket connecting to ${wsURLSafe}`);
this.wsClient = new WebSocket(wsURL, { rejectUnauthorized: false });
this.wsClient.on('open', () => {
this._reconnectDelay = restartTimeout; // Backoff zurücksetzen bei Erfolg
this.adapter.log.info('Connect to Zigbee2MQTT over websocket connection.');
this.sendPingToServer();
this.wsHeartbeat();
});
this.wsClient.on('pong', () => {
this.wsHeartbeat();
});
// ws@8 liefert Buffer, nicht String → immer .toString() verwenden
this.wsClient.on('message', (message) => {
let messageObj;
try {
messageObj = JSON.parse(message.toString());
} catch {
this.adapter.log.debug(`Invalid WebSocket message: ${message.toString().slice(0, 200)}`);
return;
}
// messageParse ist async – Fehler mit .catch() abfangen
this.adapter.messageParse(messageObj).catch((err) => {
this.adapter.log.error(`messageParse error: ${err}`);
});
});
this.wsClient.on('close', (code, reason) => {
this._onWsClose(code, reason).catch((err) => {
this.adapter.log.error(`WebSocket close handler error: ${err}`);
});
});
this.wsClient.on('error', (err) => {
// err kann undefined sein oder kein Error-Objekt
const msg = err && err.message ? err.message : String(err || 'unknown');
this.adapter.log.debug(`WebSocket error: ${msg}`);
// Kein throw – der 'close'-Event folgt nach 'error' immer automatisch
});
} catch (err) {
this.adapter.log.error(`WebSocket init error: ${err}`);
// Retry nach restartTimeout
if (!this._intentionalClose) {
this.autoRestart();
}
}
}
/**
* Interner async Handler für den WebSocket-close-Event.
*
* @param {number} code WebSocket-Close-Code (z.B. 1000 = normal, 1006 = abnormal)
* @param {Buffer} reason Optionaler Grund-Text des Close-Events
*/
async _onWsClose(code, reason) {
this.adapter.clearTimeout(this.pingTimeout);
this.adapter.clearTimeout(this.ping);
this.adapter.log.debug(`WebSocket closed – code: ${code}, reason: ${reason ? reason.toString() : 'none'}`);
try {
await this.adapter.setStateChangedAsync('info.connection', false, true);
if (this.adapter.statesController) {
await this.adapter.statesController.setAllAvailableToFalse();
}
} catch (err) {
this.adapter.log.error(`close handler setAllAvailableToFalse error: ${err}`);
}
// Caches leeren
this.adapter.deviceCache.length = 0;
this.adapter.groupCache.length = 0;
for (const key of Object.keys(this.adapter.createCache)) {
delete this.adapter.createCache[key];
}
// Nur reconnecten wenn kein intentionales Shutdown
if (!this._intentionalClose) {
this.autoRestart();
}
}
/**
* Sendet eine serialisierte Nachricht an Zigbee2MQTT über den WebSocket.
*
* @param {string} message JSON-String der zu sendenden Nachricht
*/
send(message) {
if (!this.wsClient || this.wsClient.readyState !== WebSocket.OPEN) {
this.adapter.log.warn('Cannot set State, no websocket connection to Zigbee2MQTT!');
return;
}
try {
this.wsClient.send(message);
} catch (err) {
this.adapter.log.error(`WebSocket send error: ${err && err.message ? err.message : String(err)}`);
}
}
/**
* Sendet regelmäßig WebSocket-Pings an den Z2M-Server
* und plant den nächsten Ping nach wsHeartbeatIntervall Millisekunden.
*/
sendPingToServer() {
if (!this.wsClient || this.wsClient.readyState !== WebSocket.OPEN) {
return;
}
try {
this.wsClient.ping();
} catch (err) {
this.adapter.log.debug(`WebSocket ping error: ${err && err.message ? err.message : String(err)}`);
return;
}
this.ping = this.adapter.setTimeout(() => {
this.sendPingToServer();
}, wsHeartbeatIntervall);
}
/**
* Startet oder erneuert den Heartbeat-Timeout.
* Wenn innerhalb von wsHeartbeatIntervall + 3000 ms kein Pong eintrifft,
* wird die Verbindung terminiert und autoRestart() ausgelöst.
*/
wsHeartbeat() {
this.adapter.clearTimeout(this.pingTimeout);
this.pingTimeout = this.adapter.setTimeout(() => {
this.adapter.log.warn('WebSocket connection timed out – terminating.');
try {
if (this.wsClient) {
this.wsClient.terminate();
}
} catch (err) {
this.adapter.log.debug(`wsHeartbeat terminate error: ${err}`);
}
// terminate() feuert 'close' → autoRestart() wird dort aufgerufen
}, wsHeartbeatIntervall + 3000);
}
/**
* Plant einen Reconnect-Versuch mit exponentiellem Backoff.
* Der Delay verdoppelt sich mit jedem Versuch bis maximal MAX_RESTART_TIMEOUT ms.
*/
autoRestart() {
const delay = this._reconnectDelay;
this._reconnectDelay = Math.min(this._reconnectDelay * 2, MAX_RESTART_TIMEOUT);
this.adapter.log.warn(`WebSocket disconnected – reconnecting in ${delay / 1000} second(s)...`);
this.adapter.clearTimeout(this.autoRestartTimeout);
this.autoRestartTimeout = this.adapter.setTimeout(() => {
try {
this.initWsClient();
} catch (err) {
this.adapter.log.error(`autoRestart initWsClient error: ${err}`);
}
}, delay);
}
/**
* Schließt die WebSocket-Verbindung intentional (kein autoRestart).
* Wird beim Adapter-Stop aufgerufen.
*/
closeConnection() {
this._intentionalClose = true;
this._reconnectDelay = restartTimeout; // Backoff zurücksetzen
this.adapter.clearTimeout(this.ping);
this.adapter.clearTimeout(this.pingTimeout);
this.adapter.clearTimeout(this.autoRestartTimeout);
if (this.wsClient) {
this.wsClient.removeAllListeners();
try {
if (this.wsClient.readyState !== WebSocket.CLOSED &&
this.wsClient.readyState !== WebSocket.CLOSING) {
this.wsClient.close();
}
} catch (err) {
this.adapter.log.debug(`closeConnection error: ${err}`);
}
this.wsClient = null;
}
}
/**
* Stoppt alle laufenden Timer (ping, pingTimeout, autoRestartTimeout).
* Wird beim Adapter-Stop aufgerufen.
*/
allTimerClear() {
this.adapter.clearTimeout(this.pingTimeout);
this.adapter.clearTimeout(this.ping);
this.adapter.clearTimeout(this.autoRestartTimeout);
}
}
module.exports = {
WebsocketController,
};