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.
- package/README.md +10 -67
- package/io-package.json +87 -61
- package/lib/check.js +6 -4
- package/lib/colors.js +9 -9
- package/lib/deviceController.js +90 -37
- package/lib/exposes.js +1362 -1350
- package/lib/imageController.js +56 -26
- package/lib/messages.js +15 -13
- package/lib/mqttServerController.js +36 -30
- package/lib/nonGenericDevicesExtension.js +2 -3
- package/lib/rgb.js +52 -63
- package/lib/states.js +142 -28
- package/lib/statesController.js +103 -45
- package/lib/utils.js +36 -47
- package/lib/websocketController.js +84 -52
- package/lib/z2mController.js +28 -16
- package/main.js +102 -23
- package/package.json +2 -2
package/lib/statesController.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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 (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
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
|
-
*
|
|
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',
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
this.
|
|
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
|
-
},
|
|
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
|
-
|
|
208
|
-
clearTimeout(this.
|
|
209
|
-
clearTimeout(this.
|
|
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 (
|
|
256
|
+
* Stoppt alle laufenden Timer (ping, pingTimeout, autoRestartTimeout).
|
|
257
|
+
* Wird beim Adapter-Stop aufgerufen.
|
|
226
258
|
*/
|
|
227
|
-
|
|
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
|
|