iobroker.zigbee2mqtt 3.1.1 → 3.2.0

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,16 +1,20 @@
1
+ 'use strict';
2
+
1
3
  const utils = require('./utils');
2
4
 
3
5
  /**
4
- *
6
+ * Verwaltet das Schreiben von Zigbee2MQTT-Gerätedaten in ioBroker-States.
7
+ * Puffert eingehende Nachrichten für noch nicht erstellte Geräte in einer Queue.
5
8
  */
6
9
  class StatesController {
7
10
  /**
11
+ * Erstellt eine neue StatesController-Instanz.
8
12
  *
9
- * @param adapter
10
- * @param deviceCache
11
- * @param groupCache
12
- * @param logCustomizations
13
- * @param createCache
13
+ * @param {object} adapter Die ioBroker-Adapter-Instanz
14
+ * @param {Array} deviceCache Gemeinsamer Cache aller bekannten Geräte
15
+ * @param {Array} groupCache Gemeinsamer Cache aller bekannten Gruppen
16
+ * @param {object} logCustomizations Debug/Filter-Einstellungen (debugDevices, logfilter)
17
+ * @param {object} createCache Cache bereits erstellter ioBroker-Objekte
14
18
  */
15
19
  constructor(adapter, deviceCache, groupCache, logCustomizations, createCache) {
16
20
  this.adapter = adapter;
@@ -20,11 +24,18 @@ class StatesController {
20
24
  this.createCache = createCache;
21
25
  this.incStatsQueue = [];
22
26
  this.timeOutCache = {};
27
+ // Einmalig berechnen – wird nur bei Konfigurationsänderung ungültig
28
+ this._debugDeviceList = logCustomizations.debugDevices
29
+ ? String(logCustomizations.debugDevices).split(',').map((s) => s.trim()).filter(Boolean)
30
+ : [];
23
31
  }
24
32
 
25
33
  /**
34
+ * Verarbeitet eine eingehende Gerätenachricht von Zigbee2MQTT.
35
+ * Ist das Gerät noch nicht im Cache bekannt, wird die Nachricht in der incStatsQueue
36
+ * gepuffert und später über processQueue() erneut versucht.
26
37
  *
27
- * @param messageObj
38
+ * @param {{ topic: string, payload: any }} messageObj Die zu verarbeitende Nachricht
28
39
  */
29
40
  async processDeviceMessage(messageObj) {
30
41
  if (!messageObj || typeof messageObj !== 'object') {
@@ -42,9 +53,21 @@ class StatesController {
42
53
  this.adapter.log.error(`setDeviceStateSafely error for ${messageObj.topic}: ${error}`);
43
54
  }
44
55
  } else {
45
- if (!this.incStatsQueue.some((x) => x && x.topic === messageObj.topic)) {
56
+ // Wenn das Gerät (noch) nicht bekannt ist: Message in Queue stellen.
57
+ // Existiert für dieses Topic bereits ein Eintrag, wird er mit den aktuellen
58
+ // Payload-Daten überschrieben, damit wir stets den neuesten Stand verarbeiten.
59
+ const existingIdx = this.incStatsQueue.findIndex((x) => x && x.topic === messageObj.topic);
60
+ if (existingIdx !== -1) {
61
+ const ttl = (this.incStatsQueue[existingIdx]._ttl || 0) + 1;
62
+ if (ttl > 10) {
63
+ this.adapter.log.warn(`incStatsQueue: dropping message for unknown device ${messageObj.topic} after ${ttl} retries`);
64
+ this.incStatsQueue.splice(existingIdx, 1);
65
+ return;
66
+ }
67
+ this.incStatsQueue[existingIdx] = { ...messageObj, _ttl: ttl };
68
+ } else {
46
69
  if (this.incStatsQueue.length < 500) {
47
- this.incStatsQueue.push(messageObj);
70
+ this.incStatsQueue.push({ ...messageObj, _ttl: 1 });
48
71
  } else {
49
72
  this.adapter.log.warn(`incStatsQueue is full (500), dropping message for ${messageObj.topic}`);
50
73
  }
@@ -54,12 +77,14 @@ class StatesController {
54
77
  }
55
78
 
56
79
  /**
80
+ * Schreibt alle State-Werte einer Nachricht in die zugehörigen ioBroker-States.
81
+ * Action-States werden gesammelt und am Ende gesondert behandelt.
57
82
  *
58
- * @param messageObj
59
- * @param device
83
+ * @param {{ topic: string, payload: object }} messageObj Die zu verarbeitende Nachricht
84
+ * @param {object} device Das zugehörige Geräteobjekt aus dem Cache
60
85
  */
61
86
  async setDeviceStateSafely(messageObj, device) {
62
- if (this.logCustomizations.debugDevices.includes(device.ieee_address)) {
87
+ if (this._debugDeviceList.includes(device.ieee_address)) {
63
88
  this.adapter.log.warn(`--->>> fromZ2M -> ${device.ieee_address} states: ${JSON.stringify(messageObj)}`);
64
89
  }
65
90
 
@@ -69,13 +94,28 @@ class StatesController {
69
94
  let queuedThisRound = false;
70
95
 
71
96
  const pushToQueue = (msg) => {
72
- if (!queuedThisRound && this.incStatsQueue.length < 500
73
- && !this.incStatsQueue.some((x) => x && x.topic === msg.topic)) {
74
- this.incStatsQueue.push(msg);
75
- queuedThisRound = true;
97
+ if (queuedThisRound) {return;}
98
+ const existingIdx = this.incStatsQueue.findIndex((x) => x && x.topic === msg.topic);
99
+ if (existingIdx !== -1) {
100
+ const ttl = (this.incStatsQueue[existingIdx]._ttl || 0) + 1;
101
+ if (ttl > 10) {
102
+ this.adapter.log.warn(`incStatsQueue: dropping message for ${msg.topic} after ${ttl} retries (state not yet created)`);
103
+ this.incStatsQueue.splice(existingIdx, 1);
104
+ return;
105
+ }
106
+ this.incStatsQueue[existingIdx] = { ...msg, _ttl: ttl };
107
+ } else if (this.incStatsQueue.length < 500) {
108
+ this.incStatsQueue.push({ ...msg, _ttl: 1 });
109
+ } else {
110
+ this.adapter.log.warn(`incStatsQueue is full, dropping message for ${msg.topic}`);
76
111
  }
112
+ queuedThisRound = true;
77
113
  };
78
114
 
115
+ if (!messageObj.payload || typeof messageObj.payload !== 'object' || Array.isArray(messageObj.payload)) {
116
+ return;
117
+ }
118
+
79
119
  for (let [key, value] of Object.entries(messageObj.payload)) {
80
120
  if (value === undefined || value === null) {
81
121
  continue;
@@ -154,13 +194,12 @@ class StatesController {
154
194
  }
155
195
  }
156
196
  } catch (err) {
157
- // Fix 2: Größenlimit auch hier
158
- pushToQueue(messageObj);
159
- this.adapter.log.debug(`Can not set ${stateName}, queue state in incStatsQueue!`);
197
+ this.adapter.log.warn(`Cannot set state ${stateName}: ${err}`);
160
198
  }
161
199
  }
162
200
  }
163
201
 
202
+
164
203
  for (const state of actionStates) {
165
204
  const stateName = `${device.ieee_address}.${state.id}`;
166
205
 
@@ -178,29 +217,31 @@ class StatesController {
178
217
  }
179
218
  }
180
219
  } catch (err) {
181
- // Fix 2: Größenlimit auch hier
182
- pushToQueue(messageObj);
183
- this.adapter.log.debug(`Can not set ${stateName}, queue state in incStatsQueue!`);
220
+ this.adapter.log.warn(`Cannot set action state ${stateName}: ${err}`);
184
221
  }
185
222
  }
186
223
  }
187
224
 
188
225
  /**
226
+ * Setzt einen ioBroker-State (immer, ohne Changed-Prüfung).
227
+ * Ignoriert null/undefined-Werte sicher.
189
228
  *
190
- * @param stateName
191
- * @param value
229
+ * @param {string} stateName Vollständiger State-Pfad (z.B. "0xAABB.state")
230
+ * @param {*} value Der zu setzende Wert
192
231
  */
193
232
  async setStateSafelyAsync(stateName, value) {
194
233
  if (value === undefined || value === null) {
195
234
  return;
196
235
  }
197
- await this.adapter.setStateAsync(stateName, value, true);
236
+ this.adapter.setState(stateName, value, true);
198
237
  }
199
238
 
200
239
  /**
240
+ * Setzt einen ioBroker-State nur wenn sich der Wert geändert hat.
241
+ * Ignoriert null/undefined-Werte sicher.
201
242
  *
202
- * @param stateName
203
- * @param value
243
+ * @param {string} stateName Vollständiger State-Pfad (z.B. "0xAABB.brightness")
244
+ * @param {*} value Der zu setzende Wert
204
245
  */
205
246
  async setStateChangedSafelyAsync(stateName, value) {
206
247
  if (value === undefined || value === null) {
@@ -210,29 +251,41 @@ class StatesController {
210
251
  }
211
252
 
212
253
  /**
254
+ * Setzt einen State sofort auf den angegebenen Wert und – nur bei value=true –
255
+ * nach Ablauf des Timeouts automatisch zurück auf false (Button/Event-Reset).
256
+ * Bei value=false wird kein Auto-Reset ausgelöst (z.B. brightness_stop-Signal).
213
257
  *
214
- * @param stateName
215
- * @param value
216
- * @param timeout
258
+ * @param {string} stateName Vollständiger State-Pfad
259
+ * @param {boolean} value Der sofort zu setzende Wert
260
+ * @param {number} timeout Millisekunden bis zum Auto-Reset (nur bei value=true)
217
261
  */
218
262
  async setStateWithTimeoutAsync(stateName, value, timeout) {
219
263
  if (value === undefined || value === null) {
220
264
  return;
221
265
  }
222
266
 
223
- await this.adapter.setStateAsync(stateName, value, true);
267
+ this.adapter.setState(stateName, value, true);
268
+
269
+ // Auto-Reset (false → nichts tun, true → nach timeout zurücksetzen)
270
+ // Wenn value=false (z.B. Stop-Aktion im simpleMoveStopState-Modus),
271
+ // soll der State dauerhaft false bleiben und NICHT nach timeout auf true springen.
224
272
  if (this.timeOutCache[stateName]) {
225
- clearTimeout(this.timeOutCache[stateName]);
273
+ this.adapter.clearTimeout(this.timeOutCache[stateName]);
274
+ delete this.timeOutCache[stateName];
275
+ }
276
+ if (value === true) {
277
+ this.timeOutCache[stateName] = this.adapter.setTimeout(() => {
278
+ delete this.timeOutCache[stateName];
279
+ this.adapter.setState(stateName, false, true).catch((err) => {
280
+ this.adapter.log.debug(`setStateWithTimeout reset error for ${stateName}: ${err}`);
281
+ });
282
+ }, timeout);
226
283
  }
227
- this.timeOutCache[stateName] = setTimeout(() => {
228
- this.adapter.setStateAsync(stateName, !value, true).catch((err) => {
229
- this.adapter.log.debug(`setStateWithTimeout reset error for ${stateName}: ${err}`);
230
- });
231
- }, timeout);
232
284
  }
233
285
 
234
286
  /**
235
- *
287
+ * Verarbeitet alle in der incStatsQueue gepufferten Nachrichten erneut.
288
+ * Wird nach dem Aufbau des Geräte-/Gruppen-Caches aufgerufen.
236
289
  */
237
290
  async processQueue() {
238
291
  const oldIncStatsQueue = [];
@@ -244,9 +297,10 @@ class StatesController {
244
297
  }
245
298
 
246
299
  /**
247
- *
300
+ * Meldet alle bisherigen State-Subscriptions ab und subscribt neu
301
+ * nur auf beschreibbare States aller bekannten Geräte und Gruppen.
248
302
  */
249
- async subscribeWritableStates() {
303
+ subscribeWritableStates() {
250
304
  // Alle bestehenden State-Subscriptions zuerst abmelden
251
305
  this.adapter.unsubscribeStates('*');
252
306
  for (const device of this.groupCache.concat(this.deviceCache)) {
@@ -265,22 +319,26 @@ class StatesController {
265
319
  }
266
320
 
267
321
  /**
268
- *
322
+ * Setzt alle "*.available"-States im Adapter auf false.
323
+ * Wird beim Verbindungsverlust zu Zigbee2MQTT aufgerufen.
269
324
  */
270
325
  async setAllAvailableToFalse() {
271
326
  const availableStates = await this.adapter.getStatesAsync('*.available');
272
- // Fix: Object.keys() statt for...in verhindert ungewollte Prototype-Properties
327
+ if (!availableStates) {
328
+ return;
329
+ }
273
330
  for (const availableState of Object.keys(availableStates)) {
274
331
  await this.adapter.setStateChangedAsync(availableState, false, true);
275
332
  }
276
333
  }
277
334
 
278
335
  /**
279
- *
336
+ * Bricht alle laufenden Auto-Reset-Timer ab und leert den Timer-Cache.
337
+ * Wird beim Adapter-Stop aufgerufen.
280
338
  */
281
- async allTimerClear() {
339
+ allTimerClear() {
282
340
  for (const timer of Object.keys(this.timeOutCache)) {
283
- clearTimeout(this.timeOutCache[timer]);
341
+ this.adapter.clearTimeout(this.timeOutCache[timer]);
284
342
  }
285
343
  this.timeOutCache = {};
286
344
  }
package/lib/utils.js CHANGED
@@ -1,57 +1,43 @@
1
1
  /**
2
- * Converts a bulb level of range [0...254] to an adapter level of range [0...100]
2
+ * Konvertiert einen Lampen-Helligkeitswert [0..254] in einen Adapter-Prozentwert [0..100].
3
3
  *
4
- * @param bulbLevel
4
+ * @param {number} bulbLevel Helligkeitswert der Lampe (0–254)
5
+ * @returns {number} Prozentwert (0–100)
5
6
  */
6
7
  function bulbLevelToAdapterLevel(bulbLevel) {
7
- // Convert from bulb levels [0...254] to adapter levels [0...100]:
8
- // - Bulb level 0 is a forbidden value according to the ZigBee spec "ZigBee Cluster Library
9
- // (for ZigBee 3.0) User Guide", but some bulbs (HUE) accept this value and interpret this
10
- // value as "switch the bulb off".
11
- // - A bulb level of "1" is the "minimum possible level" which should mean "bulb off",
12
- // but there are bulbs that do not switch off (they need "0", some IKEA bulbs are affected).
13
- // - No visible difference was seen between bulb level 1 and 2 on HUE LCT012 bulbs.
14
- //
15
- // Conclusion:
16
- // - We map adapter level "0" to the (forbidden) bulb level "0" that seems to switch all
17
- // known bulbs.
18
- // - Bulb level "1" is not used, but if received nevertheless, it is converted to
19
- // adapter level "0" (off).
20
- // - Bulb level range [2...254] is linearly mapped to adapter level range [1...100].
21
8
  if (bulbLevel >= 2) {
22
9
  // Perform linear mapping of range [2...254] to [1...100]
23
10
  return Math.round(((bulbLevel - 2) * 99) / 252) + 1;
24
- }
25
- // The bulb is considered off. Even a bulb level of "1" is considered as off.
26
- return 0;
27
- // else
11
+ }
12
+ // The bulb is considered off. Even a bulb level of "1" is considered as off.
13
+ return 0;
28
14
  }
29
15
 
30
16
  /**
31
- * Converts an adapter level of range [0...100] to a bulb level of range [0...254]
17
+ * Konvertiert einen Adapter-Prozentwert [0..100] in einen Lampen-Helligkeitswert [0..254].
32
18
  *
33
- * @param adapterLevel
19
+ * @param {number} adapterLevel Prozentwert (0–100)
20
+ * @returns {number} Helligkeitswert der Lampe (0–254)
34
21
  */
35
22
  function adapterLevelToBulbLevel(adapterLevel) {
36
- // Convert from adapter levels [0...100] to bulb levels [0...254].
37
- // This is the inverse of function bulbLevelToAdapterLevel().
38
- // Please read the comments there regarding the rules applied here for mapping the values.
39
- if (adapterLevel) {
23
+ if (adapterLevel != null && adapterLevel > 0) {
40
24
  // Perform linear mapping of range [1...100] to [2...254]
41
25
  return Math.round(((adapterLevel - 1) * 252) / 99) + 2;
42
- }
43
- // Switch the bulb off. Some bulbs need "0" (IKEA), others "1" (HUE), and according to the
44
- // ZigBee docs "1" is the "minimum possible level"... we choose "0" here which seems to work.
45
- return 0;
46
- // else
26
+ }
27
+ // Switch the bulb off. Some bulbs need "0" (IKEA), others "1" (HUE), and according to the
28
+ // ZigBee docs "1" is the "minimum possible level"... we choose "0" here which seems to work.
29
+ return 0;
47
30
  }
48
31
 
49
32
 
50
33
  // If the value is greater than 1000, kelvin is assumed.
51
34
  // If smaller, it is assumed to be mired.
52
35
  /**
36
+ * Konvertiert einen Kelvin- oder Mired-Wert immer in Mired.
37
+ * Werte > 1000 werden als Kelvin interpretiert und umgerechnet.
53
38
  *
54
- * @param t
39
+ * @param {number} t Farbtemperatur in Kelvin (>1000) oder Mired (≤1000)
40
+ * @returns {number} Farbtemperatur in Mired
55
41
  */
56
42
  function toMired(t) {
57
43
  let miredValue = t;
@@ -62,18 +48,21 @@ function toMired(t) {
62
48
  }
63
49
 
64
50
  /**
51
+ * Konvertiert zwischen Mired und Kelvin (die Formel ist in beide Richtungen gleich: 1.000.000 / t).
65
52
  *
66
- * @param t
53
+ * @param {number} t Farbtemperatur in Mired oder Kelvin
54
+ * @returns {number} Umgerechneter Wert (gerundet)
67
55
  */
68
56
  function miredKelvinConversion(t) {
69
57
  return Math.round(1000000 / t);
70
58
  }
71
59
 
72
60
  /**
73
- * Converts a decimal number to a hex string with zero-padding
61
+ * Konvertiert eine Dezimalzahl in einen hex-String mit führenden Nullen.
74
62
  *
75
- * @param decimal
76
- * @param padding
63
+ * @param {number} decimal Die umzuwandelnde Zahl
64
+ * @param {number} [padding] Mindestlänge des hex-Strings (wird mit '0' aufgefüllt)
65
+ * @returns {string} Hex-String in Kleinbuchstaben
77
66
  */
78
67
  function decimalToHex(decimal, padding) {
79
68
  let hex = Number(decimal).toString(16);
@@ -86,31 +75,31 @@ function decimalToHex(decimal, padding) {
86
75
  return hex;
87
76
  }
88
77
 
89
-
90
78
  /**
79
+ * Leert ein Array in-place (O(1)).
91
80
  *
92
- * @param array
81
+ * @param {Array} array Das zu leerende Array
93
82
  */
94
83
  function clearArray(array) {
95
- while (array.length > 0) {
96
- array.pop();
97
- }
84
+ array.length = 0;
98
85
  }
99
86
 
100
87
  /**
88
+ * Verschiebt alle Einträge von source nach target und leert source dabei.
101
89
  *
102
- * @param source
103
- * @param target
90
+ * @param {Array} source Quell-Array (wird nach dem Aufruf leer sein)
91
+ * @param {Array} target Ziel-Array (bekommt alle Einträge aus source angehängt)
104
92
  */
105
93
  function moveArray(source, target) {
106
- while (source.length > 0) {
107
- target.push(source.shift());
108
- }
94
+ target.push(...source);
95
+ source.length = 0;
109
96
  }
110
97
 
111
98
  /**
99
+ * Prüft ob ein Wert ein einfaches Objekt ist (kein Array, kein null).
112
100
  *
113
- * @param item
101
+ * @param {*} item Der zu prüfende Wert
102
+ * @returns {boolean} true wenn item ein Objekt ist, sonst false
114
103
  */
115
104
  function isObject(item) {
116
105
  return typeof item === 'object' && !Array.isArray(item) && item !== null;
@@ -3,13 +3,18 @@
3
3
  const WebSocket = require('ws');
4
4
  const wsHeartbeatIntervall = 5000;
5
5
  const restartTimeout = 1000;
6
+ /** Maximum reconnect delay in milliseconds (caps exponential backoff) */
7
+ const MAX_RESTART_TIMEOUT = 30000;
6
8
 
7
9
  /**
8
- *
10
+ * Verwaltet die WebSocket-Verbindung zu Zigbee2MQTT inklusive
11
+ * Heartbeat-Überwachung und exponentiellem Reconnect-Backoff.
9
12
  */
10
13
  class WebsocketController {
11
14
  /**
12
- * @param adapter
15
+ * Erstellt eine neue WebsocketController-Instanz.
16
+ *
17
+ * @param {object} adapter Die ioBroker-Adapter-Instanz
13
18
  */
14
19
  constructor(adapter) {
15
20
  this.adapter = adapter;
@@ -19,6 +24,8 @@ class WebsocketController {
19
24
  this.autoRestartTimeout = null;
20
25
  // Flag: wird bei closeConnection() gesetzt damit autoRestart() nicht feuert
21
26
  this._intentionalClose = false;
27
+ /** Aktueller Reconnect-Delay (exponentielles Backoff) */
28
+ this._reconnectDelay = restartTimeout;
22
29
  }
23
30
 
24
31
  /**
@@ -35,9 +42,9 @@ class WebsocketController {
35
42
  this._intentionalClose = false;
36
43
 
37
44
  // Vorherige Timer stoppen
38
- clearTimeout(this.ping);
39
- clearTimeout(this.pingTimeout);
40
- clearTimeout(this.autoRestartTimeout);
45
+ this.adapter.clearTimeout(this.ping);
46
+ this.adapter.clearTimeout(this.pingTimeout);
47
+ this.adapter.clearTimeout(this.autoRestartTimeout);
41
48
 
42
49
  // Vorherige Verbindung sicher schließen und null setzen
43
50
  if (this.wsClient) {
@@ -55,14 +62,18 @@ class WebsocketController {
55
62
 
56
63
  try {
57
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;
58
67
 
59
68
  if (this.adapter.config.wsTokenEnabled === true) {
60
69
  wsURL += `?token=${this.adapter.config.wsToken}`;
61
70
  }
62
71
 
72
+ this.adapter.log.debug(`WebSocket connecting to ${wsURLSafe}`);
63
73
  this.wsClient = new WebSocket(wsURL, { rejectUnauthorized: false });
64
74
 
65
75
  this.wsClient.on('open', () => {
76
+ this._reconnectDelay = restartTimeout; // Backoff zurücksetzen bei Erfolg
66
77
  this.adapter.log.info('Connect to Zigbee2MQTT over websocket connection.');
67
78
  this.sendPingToServer();
68
79
  this.wsHeartbeat();
@@ -87,32 +98,10 @@ class WebsocketController {
87
98
  });
88
99
  });
89
100
 
90
- this.wsClient.on('close', async (code, reason) => {
91
- clearTimeout(this.pingTimeout);
92
- clearTimeout(this.ping);
93
-
94
- this.adapter.log.debug(`WebSocket closed – code: ${code}, reason: ${reason ? reason.toString() : 'none'}`);
95
-
96
- try {
97
- if (this.adapter.statesController) {
98
- this.adapter.setStateChanged('info.connection', false, true);
99
- await this.adapter.statesController.setAllAvailableToFalse();
100
- }
101
- } catch (err) {
102
- this.adapter.log.error(`close handler setAllAvailableToFalse error: ${err}`);
103
- }
104
-
105
- // Caches leeren
106
- this.adapter.deviceCache.length = 0;
107
- this.adapter.groupCache.length = 0;
108
- for (const key of Object.keys(this.adapter.createCache)) {
109
- delete this.adapter.createCache[key];
110
- }
111
-
112
- // Nur reconnecten wenn kein intentionales Shutdown
113
- if (!this._intentionalClose) {
114
- this.autoRestart();
115
- }
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
+ });
116
105
  });
117
106
 
118
107
  this.wsClient.on('error', (err) => {
@@ -132,9 +121,43 @@ class WebsocketController {
132
121
  }
133
122
 
134
123
  /**
135
- * Sendet eine Nachricht an Z2M.
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
+ await this.adapter.setStateChangedAsync('info.connection', false, true);
137
+ if (this.adapter.statesController) {
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();
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Sendet eine serialisierte Nachricht an Zigbee2MQTT über den WebSocket.
136
159
  *
137
- * @param {string} message
160
+ * @param {string} message JSON-String der zu sendenden Nachricht
138
161
  */
139
162
  send(message) {
140
163
  if (!this.wsClient || this.wsClient.readyState !== WebSocket.OPEN) {
@@ -149,7 +172,8 @@ class WebsocketController {
149
172
  }
150
173
 
151
174
  /**
152
- * Sendet regelmäßig Pings an den Server.
175
+ * Sendet regelmäßig WebSocket-Pings an den Z2M-Server
176
+ * und plant den nächsten Ping nach wsHeartbeatIntervall Millisekunden.
153
177
  */
154
178
  sendPingToServer() {
155
179
  if (!this.wsClient || this.wsClient.readyState !== WebSocket.OPEN) {
@@ -161,17 +185,19 @@ class WebsocketController {
161
185
  this.adapter.log.debug(`WebSocket ping error: ${err && err.message ? err.message : String(err)}`);
162
186
  return;
163
187
  }
164
- this.ping = setTimeout(() => {
188
+ this.ping = this.adapter.setTimeout(() => {
165
189
  this.sendPingToServer();
166
190
  }, wsHeartbeatIntervall);
167
191
  }
168
192
 
169
193
  /**
170
- * Überwacht den Heartbeat. Wenn kein Pong innerhalb des Timeouts kommt, wird die Verbindung getrennt.
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.
171
197
  */
172
198
  wsHeartbeat() {
173
- clearTimeout(this.pingTimeout);
174
- this.pingTimeout = setTimeout(() => {
199
+ this.adapter.clearTimeout(this.pingTimeout);
200
+ this.pingTimeout = this.adapter.setTimeout(() => {
175
201
  this.adapter.log.warn('WebSocket connection timed out – terminating.');
176
202
  try {
177
203
  if (this.wsClient) {
@@ -185,28 +211,33 @@ class WebsocketController {
185
211
  }
186
212
 
187
213
  /**
188
- * Wartet kurz und baut dann die Verbindung neu auf.
214
+ * Plant einen Reconnect-Versuch mit exponentiellem Backoff.
215
+ * Der Delay verdoppelt sich mit jedem Versuch bis maximal MAX_RESTART_TIMEOUT ms.
189
216
  */
190
217
  autoRestart() {
191
- this.adapter.log.warn(`WebSocket disconnected reconnecting in ${restartTimeout / 1000} second(s)...`);
192
- clearTimeout(this.autoRestartTimeout);
193
- this.autoRestartTimeout = setTimeout(() => {
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(() => {
194
223
  try {
195
224
  this.initWsClient();
196
225
  } catch (err) {
197
226
  this.adapter.log.error(`autoRestart initWsClient error: ${err}`);
198
227
  }
199
- }, restartTimeout);
228
+ }, delay);
200
229
  }
201
230
 
202
231
  /**
203
- * Schließt die Verbindung intentional (kein autoRestart).
232
+ * Schließt die WebSocket-Verbindung intentional (kein autoRestart).
233
+ * Wird beim Adapter-Stop aufgerufen.
204
234
  */
205
235
  closeConnection() {
206
236
  this._intentionalClose = true;
207
- clearTimeout(this.ping);
208
- clearTimeout(this.pingTimeout);
209
- clearTimeout(this.autoRestartTimeout);
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);
210
241
  if (this.wsClient) {
211
242
  this.wsClient.removeAllListeners();
212
243
  try {
@@ -222,12 +253,13 @@ class WebsocketController {
222
253
  }
223
254
 
224
255
  /**
225
- * Stoppt alle Timer (z.B. beim Adapter-Stop).
256
+ * Stoppt alle laufenden Timer (ping, pingTimeout, autoRestartTimeout).
257
+ * Wird beim Adapter-Stop aufgerufen.
226
258
  */
227
- async allTimerClear() {
228
- clearTimeout(this.pingTimeout);
229
- clearTimeout(this.ping);
230
- clearTimeout(this.autoRestartTimeout);
259
+ allTimerClear() {
260
+ this.adapter.clearTimeout(this.pingTimeout);
261
+ this.adapter.clearTimeout(this.ping);
262
+ this.adapter.clearTimeout(this.autoRestartTimeout);
231
263
  }
232
264
  }
233
265