iobroker.zigbee2mqtt 3.0.21 → 3.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,128 +1,265 @@
1
+ 'use strict';
2
+
1
3
  const WebSocket = require('ws');
2
- let wsClient;
3
4
  const wsHeartbeatIntervall = 5000;
4
5
  const restartTimeout = 1000;
5
- let ping;
6
- let pingTimeout;
7
- let autoRestartTimeout;
6
+ /** Maximum reconnect delay in milliseconds (caps exponential backoff) */
7
+ const MAX_RESTART_TIMEOUT = 30000;
8
8
 
9
9
  /**
10
- *
10
+ * Verwaltet die WebSocket-Verbindung zu Zigbee2MQTT inklusive
11
+ * Heartbeat-Überwachung und exponentiellem Reconnect-Backoff.
11
12
  */
12
13
  class WebsocketController {
13
14
  /**
15
+ * Erstellt eine neue WebsocketController-Instanz.
14
16
  *
15
- * @param adapter
17
+ * @param {object} adapter Die ioBroker-Adapter-Instanz
16
18
  */
17
19
  constructor(adapter) {
18
20
  this.adapter = adapter;
21
+ this.wsClient = null;
22
+ this.ping = null;
23
+ this.pingTimeout = null;
24
+ this.autoRestartTimeout = null;
25
+ // Flag: wird bei closeConnection() gesetzt damit autoRestart() nicht feuert
26
+ this._intentionalClose = false;
27
+ /** Aktueller Reconnect-Delay (exponentielles Backoff) */
28
+ this._reconnectDelay = restartTimeout;
19
29
  }
20
30
 
21
31
  /**
22
- *
32
+ * Baut eine neue WebSocket-Verbindung auf.
33
+ * Bestehende Verbindung und alle Timer werden zuerst sicher bereinigt.
23
34
  */
24
35
  initWsClient() {
36
+ // Config-Guard: Pflichtfelder müssen vorhanden sein
37
+ if (!this.adapter.config.wsScheme || !this.adapter.config.wsServerIP || !this.adapter.config.wsServerPort) {
38
+ this.adapter.log.error('WebSocket config incomplete (wsScheme / wsServerIP / wsServerPort missing).');
39
+ return;
40
+ }
41
+
42
+ this._intentionalClose = false;
43
+
44
+ // Vorherige Timer stoppen
45
+ this.adapter.clearTimeout(this.ping);
46
+ this.adapter.clearTimeout(this.pingTimeout);
47
+ this.adapter.clearTimeout(this.autoRestartTimeout);
48
+
49
+ // Vorherige Verbindung sicher schließen und null setzen
50
+ if (this.wsClient) {
51
+ this.wsClient.removeAllListeners();
52
+ if (this.wsClient.readyState !== WebSocket.CLOSED &&
53
+ this.wsClient.readyState !== WebSocket.CLOSING) {
54
+ try {
55
+ this.wsClient.terminate();
56
+ } catch (e) {
57
+ this.adapter.log.debug(`initWsClient: old socket terminate error: ${e}`);
58
+ }
59
+ }
60
+ this.wsClient = null;
61
+ }
62
+
25
63
  try {
26
64
  let wsURL = `${this.adapter.config.wsScheme}://${this.adapter.config.wsServerIP}:${this.adapter.config.wsServerPort}/api`;
65
+ // Für Logging: URL ohne Token (Sicherheit)
66
+ const wsURLSafe = wsURL;
27
67
 
28
- if (this.adapter.config.wsTokenEnabled == true) {
68
+ if (this.adapter.config.wsTokenEnabled === true) {
29
69
  wsURL += `?token=${this.adapter.config.wsToken}`;
30
70
  }
31
71
 
32
- wsClient = new WebSocket(wsURL, { rejectUnauthorized: false });
72
+ this.adapter.log.debug(`WebSocket connecting to ${wsURLSafe}`);
73
+ this.wsClient = new WebSocket(wsURL, { rejectUnauthorized: false });
33
74
 
34
- wsClient.on('open', () => {
35
- // Send ping to server
75
+ this.wsClient.on('open', () => {
76
+ this._reconnectDelay = restartTimeout; // Backoff zurücksetzen bei Erfolg
77
+ this.adapter.log.info('Connect to Zigbee2MQTT over websocket connection.');
36
78
  this.sendPingToServer();
37
- // Start Heartbeat
38
79
  this.wsHeartbeat();
39
80
  });
40
81
 
41
- wsClient.on('pong', () => {
82
+ this.wsClient.on('pong', () => {
42
83
  this.wsHeartbeat();
43
84
  });
44
85
 
45
- wsClient.on('close', async () => {
46
- clearTimeout(pingTimeout);
47
- clearTimeout(ping);
48
-
49
- if (wsClient.readyState === WebSocket.CLOSED) {
50
- this.autoRestart();
86
+ // ws@8 liefert Buffer, nicht String → immer .toString() verwenden
87
+ this.wsClient.on('message', (message) => {
88
+ let messageObj;
89
+ try {
90
+ messageObj = JSON.parse(message.toString());
91
+ } catch {
92
+ this.adapter.log.debug(`Invalid WebSocket message: ${message.toString().slice(0, 200)}`);
93
+ return;
51
94
  }
95
+ // messageParse ist async – Fehler mit .catch() abfangen
96
+ this.adapter.messageParse(messageObj).catch((err) => {
97
+ this.adapter.log.error(`messageParse error: ${err}`);
98
+ });
52
99
  });
53
100
 
54
- wsClient.on('message', () => {});
101
+ this.wsClient.on('close', (code, reason) => {
102
+ this._onWsClose(code, reason).catch((err) => {
103
+ this.adapter.log.error(`WebSocket close handler error: ${err}`);
104
+ });
105
+ });
55
106
 
56
- wsClient.on('error', (err) => {
57
- this.adapter.log.debug(err);
107
+ this.wsClient.on('error', (err) => {
108
+ // err kann undefined sein oder kein Error-Objekt
109
+ const msg = err && err.message ? err.message : String(err || 'unknown');
110
+ this.adapter.log.debug(`WebSocket error: ${msg}`);
111
+ // Kein throw – der 'close'-Event folgt nach 'error' immer automatisch
58
112
  });
59
113
 
60
- return wsClient;
61
114
  } catch (err) {
62
- this.adapter.log.error(err);
115
+ this.adapter.log.error(`WebSocket init error: ${err}`);
116
+ // Retry nach restartTimeout
117
+ if (!this._intentionalClose) {
118
+ this.autoRestart();
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Interner async Handler für den WebSocket-close-Event.
125
+ *
126
+ * @param {number} code WebSocket-Close-Code (z.B. 1000 = normal, 1006 = abnormal)
127
+ * @param {Buffer} reason Optionaler Grund-Text des Close-Events
128
+ */
129
+ async _onWsClose(code, reason) {
130
+ this.adapter.clearTimeout(this.pingTimeout);
131
+ this.adapter.clearTimeout(this.ping);
132
+
133
+ this.adapter.log.debug(`WebSocket closed – code: ${code}, reason: ${reason ? reason.toString() : 'none'}`);
134
+
135
+ try {
136
+ if (this.adapter.statesController) {
137
+ this.adapter.setStateChanged('info.connection', false, true);
138
+ await this.adapter.statesController.setAllAvailableToFalse();
139
+ }
140
+ } catch (err) {
141
+ this.adapter.log.error(`close handler setAllAvailableToFalse error: ${err}`);
142
+ }
143
+
144
+ // Caches leeren
145
+ this.adapter.deviceCache.length = 0;
146
+ this.adapter.groupCache.length = 0;
147
+ for (const key of Object.keys(this.adapter.createCache)) {
148
+ delete this.adapter.createCache[key];
149
+ }
150
+
151
+ // Nur reconnecten wenn kein intentionales Shutdown
152
+ if (!this._intentionalClose) {
153
+ this.autoRestart();
63
154
  }
64
155
  }
65
156
 
66
157
  /**
158
+ * Sendet eine serialisierte Nachricht an Zigbee2MQTT über den WebSocket.
67
159
  *
68
- * @param message
160
+ * @param {string} message JSON-String der zu sendenden Nachricht
69
161
  */
70
162
  send(message) {
71
- if (wsClient.readyState !== WebSocket.OPEN) {
163
+ if (!this.wsClient || this.wsClient.readyState !== WebSocket.OPEN) {
72
164
  this.adapter.log.warn('Cannot set State, no websocket connection to Zigbee2MQTT!');
73
165
  return;
74
166
  }
75
- wsClient.send(message);
167
+ try {
168
+ this.wsClient.send(message);
169
+ } catch (err) {
170
+ this.adapter.log.error(`WebSocket send error: ${err && err.message ? err.message : String(err)}`);
171
+ }
76
172
  }
77
173
 
78
174
  /**
79
- *
175
+ * Sendet regelmäßig WebSocket-Pings an den Z2M-Server
176
+ * und plant den nächsten Ping nach wsHeartbeatIntervall Millisekunden.
80
177
  */
81
178
  sendPingToServer() {
82
- //this.logDebug('Send ping to server');
83
- wsClient.ping();
84
- ping = setTimeout(() => {
179
+ if (!this.wsClient || this.wsClient.readyState !== WebSocket.OPEN) {
180
+ return;
181
+ }
182
+ try {
183
+ this.wsClient.ping();
184
+ } catch (err) {
185
+ this.adapter.log.debug(`WebSocket ping error: ${err && err.message ? err.message : String(err)}`);
186
+ return;
187
+ }
188
+ this.ping = this.adapter.setTimeout(() => {
85
189
  this.sendPingToServer();
86
190
  }, wsHeartbeatIntervall);
87
191
  }
88
192
 
89
193
  /**
90
- *
194
+ * Startet oder erneuert den Heartbeat-Timeout.
195
+ * Wenn innerhalb von wsHeartbeatIntervall + 3000 ms kein Pong eintrifft,
196
+ * wird die Verbindung terminiert und autoRestart() ausgelöst.
91
197
  */
92
198
  wsHeartbeat() {
93
- clearTimeout(pingTimeout);
94
- pingTimeout = setTimeout(() => {
95
- this.adapter.log.warn('Websocked connection timed out');
96
- wsClient.terminate();
199
+ this.adapter.clearTimeout(this.pingTimeout);
200
+ this.pingTimeout = this.adapter.setTimeout(() => {
201
+ this.adapter.log.warn('WebSocket connection timed out – terminating.');
202
+ try {
203
+ if (this.wsClient) {
204
+ this.wsClient.terminate();
205
+ }
206
+ } catch (err) {
207
+ this.adapter.log.debug(`wsHeartbeat terminate error: ${err}`);
208
+ }
209
+ // terminate() feuert 'close' → autoRestart() wird dort aufgerufen
97
210
  }, wsHeartbeatIntervall + 3000);
98
211
  }
99
212
 
100
213
  /**
101
- *
214
+ * Plant einen Reconnect-Versuch mit exponentiellem Backoff.
215
+ * Der Delay verdoppelt sich mit jedem Versuch bis maximal MAX_RESTART_TIMEOUT ms.
102
216
  */
103
- async autoRestart() {
104
- this.adapter.log.warn(`Start try again in ${restartTimeout / 1000} seconds...`);
105
- autoRestartTimeout = setTimeout(() => {
106
- this.adapter.startWebsocket();
107
- }, restartTimeout);
217
+ autoRestart() {
218
+ const delay = this._reconnectDelay;
219
+ this._reconnectDelay = Math.min(this._reconnectDelay * 2, MAX_RESTART_TIMEOUT);
220
+ this.adapter.log.warn(`WebSocket disconnected – reconnecting in ${delay / 1000} second(s)...`);
221
+ this.adapter.clearTimeout(this.autoRestartTimeout);
222
+ this.autoRestartTimeout = this.adapter.setTimeout(() => {
223
+ try {
224
+ this.initWsClient();
225
+ } catch (err) {
226
+ this.adapter.log.error(`autoRestart initWsClient error: ${err}`);
227
+ }
228
+ }, delay);
108
229
  }
109
230
 
110
231
  /**
111
- *
232
+ * Schließt die WebSocket-Verbindung intentional (kein autoRestart).
233
+ * Wird beim Adapter-Stop aufgerufen.
112
234
  */
113
235
  closeConnection() {
114
- if (wsClient && wsClient.readyState !== WebSocket.CLOSED) {
115
- wsClient.close();
236
+ this._intentionalClose = true;
237
+ this._reconnectDelay = restartTimeout; // Backoff zurücksetzen
238
+ this.adapter.clearTimeout(this.ping);
239
+ this.adapter.clearTimeout(this.pingTimeout);
240
+ this.adapter.clearTimeout(this.autoRestartTimeout);
241
+ if (this.wsClient) {
242
+ this.wsClient.removeAllListeners();
243
+ try {
244
+ if (this.wsClient.readyState !== WebSocket.CLOSED &&
245
+ this.wsClient.readyState !== WebSocket.CLOSING) {
246
+ this.wsClient.close();
247
+ }
248
+ } catch (err) {
249
+ this.adapter.log.debug(`closeConnection error: ${err}`);
250
+ }
251
+ this.wsClient = null;
116
252
  }
117
253
  }
118
254
 
119
255
  /**
120
- *
256
+ * Stoppt alle laufenden Timer (ping, pingTimeout, autoRestartTimeout).
257
+ * Wird beim Adapter-Stop aufgerufen.
121
258
  */
122
- async allTimerClear() {
123
- clearTimeout(pingTimeout);
124
- clearTimeout(ping);
125
- clearTimeout(autoRestartTimeout);
259
+ allTimerClear() {
260
+ this.adapter.clearTimeout(this.pingTimeout);
261
+ this.adapter.clearTimeout(this.ping);
262
+ this.adapter.clearTimeout(this.autoRestartTimeout);
126
263
  }
127
264
  }
128
265
 
@@ -1,13 +1,15 @@
1
1
  /**
2
- *
2
+ * Übersetzt ioBroker-State-Änderungen in Zigbee2MQTT-MQTT-Nachrichten
3
+ * und leitet Z2M-Log-Nachrichten in den ioBroker-Logger weiter.
3
4
  */
4
5
  class Z2mController {
5
6
  /**
7
+ * Erstellt eine neue Z2mController-Instanz.
6
8
  *
7
- * @param adapter
8
- * @param deviceCache
9
- * @param groupCache
10
- * @param logCustomizations
9
+ * @param {object} adapter Die ioBroker-Adapter-Instanz
10
+ * @param {Array} deviceCache Gemeinsamer Cache aller bekannten Geräte
11
+ * @param {Array} groupCache Gemeinsamer Cache aller bekannten Gruppen
12
+ * @param {object} logCustomizations Debug/Filter-Einstellungen (debugDevices, logfilter)
11
13
  */
12
14
  constructor(adapter, deviceCache, groupCache, logCustomizations) {
13
15
  this.adapter = adapter;
@@ -17,9 +19,12 @@ class Z2mController {
17
19
  }
18
20
 
19
21
  /**
22
+ * Erzeugt aus einer ioBroker-State-Änderung eine Zigbee2MQTT-Nachricht.
23
+ * Gibt null zurück wenn kein Gerät/State gefunden wurde oder der Setter undefined liefert.
20
24
  *
21
- * @param id
22
- * @param state
25
+ * @param {string} id Vollständige State-ID (z.B. "zigbee2mqtt.0.0xAABB.state")
26
+ * @param {ioBroker.State} state Das neue State-Objekt mit val und ack
27
+ * @returns {Promise<{topic: string, payload: object}|undefined>} Die Z2M-Nachricht oder undefined
23
28
  */
24
29
  async createZ2MMessage(id, state) {
25
30
  const splitedID = id.split('.');
@@ -29,25 +34,38 @@ class Z2mController {
29
34
  }
30
35
 
31
36
  if (id.endsWith('info.coordinator_check')) {
32
- return { topic: 'bridge/request/coordinator_check', payload: '' };
37
+ // Fix 1: Z2M erwartet JSON-Objekt {} nicht leeren String ''
38
+ return { topic: 'bridge/request/coordinator_check', payload: {} };
33
39
  }
34
40
 
35
41
  const ieee_address = splitedID[2];
36
42
  const stateName = splitedID[3];
37
43
 
38
- const device = this.groupCache.concat(this.deviceCache).find((d) => d.ieee_address == ieee_address);
44
+ const device = this.groupCache.concat(this.deviceCache).find((d) => d.ieee_address === ieee_address);
39
45
  if (!device) {
40
46
  return;
41
47
  }
42
48
 
43
- const deviceState = device.states.find((s) => s.id == stateName);
49
+ const deviceState = device.states.find((s) => s.id === stateName);
44
50
  if (!deviceState) {
45
51
  return;
46
52
  }
47
53
 
48
54
  let stateVal = state.val;
49
55
  if (deviceState.setter) {
50
- stateVal = deviceState.setter(state.val);
56
+ // Fix 3: setter kann crashen (z.B. ungültiger Farbwert) → try/catch
57
+ try {
58
+ stateVal = deviceState.setter(state.val);
59
+ } catch (err) {
60
+ this.adapter.log.warn(`${device.ieee_address} state: ${stateName} setter error: ${err.message || err}`);
61
+ return;
62
+ }
63
+ }
64
+
65
+ // Fix 5: wenn setter undefined zurückgibt (z.B. Toggle-Release) → nichts senden
66
+ if (stateVal === undefined) {
67
+ this.adapter.log.debug(`${device.ieee_address} state: ${stateName} setter returned undefined, skipping`);
68
+ return;
51
69
  }
52
70
 
53
71
  let stateID = deviceState.id;
@@ -66,16 +84,18 @@ class Z2mController {
66
84
  topic: `${device.id}/set`,
67
85
  };
68
86
 
69
- if (stateID == 'send_payload') {
87
+ if (stateID === 'send_payload') {
70
88
  try {
71
89
  controlObj.payload = JSON.parse(stateVal);
72
- this.adapter.setState(id, state, true);
90
+ await this.adapter.setStateAsync(id, state.val, true);
73
91
  } catch (error) {
74
- controlObj.payload = stateVal.replaceAll(' ', '').replaceAll('\n', '');
92
+ // Fix 9: rawStr als String direkt als payload wird in main.js via
93
+ // JSON.stringify() nochmals gewrappt → würde doppelt quoten.
94
+ // Stattdessen: Fehler loggen und NICHT senden (ungültiges JSON bleibt ungültig)
75
95
  this.adapter.log.warn(
76
- `${device.ieee_address} state: ${stateID} error: value passed is not a valid JSON`
96
+ `${device.ieee_address} state: ${stateID} error: value passed is not a valid JSON – not sent`
77
97
  );
78
- this.adapter.log.debug(`${device.ieee_address} states: ${JSON.stringify(controlObj)} error: ${error}`);
98
+ this.adapter.log.debug(`${device.ieee_address} raw value: ${stateVal}`);
79
99
  return;
80
100
  }
81
101
  }
@@ -83,17 +103,13 @@ class Z2mController {
83
103
  // if available read option and set payload
84
104
  if (deviceState.options) {
85
105
  for (const option of deviceState.options) {
86
- // if optionsValues not set, set it!
87
- if (!device.optionsValues[option]) {
88
- const optionValue = (
89
- await this.adapter.getStateAsync(`${splitedID[0]}.${splitedID[1]}.${splitedID[2]}.${option}`)
90
- ).val;
91
- // optionsValues Cache
92
- device.optionsValues[option] = optionValue;
106
+ // Fix: "in"-Prüfung statt === undefined, damit null als gültiger gecachter Wert behandelt wird
107
+ if (!(option in device.optionsValues)) {
108
+ const optState = await this.adapter.getStateAsync(`${splitedID[0]}.${splitedID[1]}.${splitedID[2]}.${option}`);
109
+ device.optionsValues[option] = optState ? optState.val : null;
93
110
  }
94
111
 
95
- // if transition value == -1 it will be ignored. -1 stands for no overwrite!
96
- if (option == 'transition' && device.optionsValues[option] == -1) {
112
+ if (option === 'transition' && device.optionsValues[option] === -1) {
97
113
  continue;
98
114
  }
99
115
 
@@ -103,38 +119,40 @@ class Z2mController {
103
119
 
104
120
  // If an option datapoint has been set, it does not have to be sent.
105
121
  // This is confirmed directly by the adapter (ack = true)
106
- if (deviceState.isOption && deviceState.isOption == true) {
122
+ if (deviceState.isOption) {
107
123
  // set optionsValues 'Cache'
108
124
  device.optionsValues[stateName] = state.val;
109
- this.adapter.setState(id, state, true);
125
+ await this.adapter.setStateAsync(id, state.val, true);
110
126
  return;
111
127
  }
112
128
 
113
- // set stats with the mentioned roles or always immediately to ack = true, because these are not reported back by Zigbee2MQTT
114
- if (['button'].includes(deviceState.role)) {
115
- this.adapter.setState(id, state, true);
116
- }
117
- // set stats with the mentioned ids always immediately to ack = true, because these are not reported back by Zigbee2MQTT
118
- if (
119
- ['brightness_move', 'colortemp_move', 'brightness_move', 'brightness_step', 'effect'].includes(
120
- deviceState.id
121
- )
122
- ) {
123
- this.adapter.setState(id, state, true);
129
+ // States die nicht von Z2M zurückgemeldet werden sofort ack=true setzen
130
+ const immediateAckRole = ['button'].includes(deviceState.role);
131
+ const immediateAckId = ['brightness_move', 'colortemp_move', 'brightness_step', 'effect'].includes(deviceState.id);
132
+ if (immediateAckRole || immediateAckId) {
133
+ await this.adapter.setStateAsync(id, state.val, true);
124
134
  }
125
135
 
126
- if (this.logCustomizations.debugDevices.includes(device.ieee_address)) {
127
- this.adapter.log.warn(`<<<--- toZ2M -> ${device.ieee_address} states: ${JSON.stringify(controlObj)}`);
136
+ if (this.logCustomizations.debugDevices) {
137
+ const debugList = String(this.logCustomizations.debugDevices).split(',').map((s) => s.trim());
138
+ if (debugList.includes(device.ieee_address)) {
139
+ this.adapter.log.warn(`<<<--- toZ2M -> ${device.ieee_address} states: ${JSON.stringify(controlObj)}`);
140
+ }
128
141
  }
129
142
  return controlObj;
130
143
  }
131
144
 
132
145
  /**
146
+ * Leitet eine Z2M-Log-Nachricht (bridge/logging) an den ioBroker-Logger weiter.
147
+ * Nachrichten die dem konfigurierten Logfilter entsprechen werden unterdrückt.
133
148
  *
134
- * @param messageObj
149
+ * @param {{ payload: { message: string, level: string } }} messageObj Die Log-Nachricht
135
150
  */
136
151
  async proxyZ2MLogs(messageObj) {
137
- const logMessage = messageObj.payload.message;
152
+ const logMessage = messageObj.payload && messageObj.payload.message;
153
+ if (!logMessage) {
154
+ return;
155
+ }
138
156
  if (this.logCustomizations.logfilter.some((x) => logMessage.includes(x))) {
139
157
  return;
140
158
  }
@@ -149,6 +167,9 @@ class Z2mController {
149
167
  case 'warning':
150
168
  this.adapter.log.warn(logMessage);
151
169
  break;
170
+ default:
171
+ this.adapter.log.debug(`Z2M [${logLevel}]: ${logMessage}`);
172
+ break;
152
173
  }
153
174
  }
154
175
  }