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.
package/main.js CHANGED
@@ -1,7 +1,4 @@
1
1
  'use strict';
2
- /*
3
- * Created with @iobroker/create-adapter v2.2.1
4
- */
5
2
 
6
3
  // The adapter-core module gives you access to the core ioBroker functions
7
4
  // you need to create an adapter
@@ -18,367 +15,636 @@ const StatesController = require('./lib/statesController').StatesController;
18
15
  const WebsocketController = require('./lib/websocketController').WebsocketController;
19
16
  const MqttServerController = require('./lib/mqttServerController').MqttServerController;
20
17
 
21
- let mqttClient;
22
-
23
- let deviceCache = [];
24
-
25
- let groupCache = [];
26
- const createCache = {};
27
- const logCustomizations = { debugDevices: '', logfilter: [] };
28
- let showInfo = true;
29
- let statesController;
30
- let deviceController;
31
- let z2mController;
32
- let websocketController;
33
- let mqttServerController;
34
-
35
- let messageParseMutex = Promise.resolve();
36
-
37
18
  class Zigbee2mqtt extends core.Adapter {
19
+ /**
20
+ * Erstellt eine neue Instanz des Zigbee2MQTT-Adapters.
21
+ *
22
+ * @param {object} [options] Optionale Adapter-Konfiguration (AdapterOptions)
23
+ */
38
24
  constructor(options) {
39
25
  super({
40
26
  ...options,
41
27
  name: 'zigbee2mqtt',
42
28
  });
43
- this.on('ready', this.onReady.bind(this));
44
- this.on('stateChange', this.onStateChange.bind(this));
29
+
30
+ // Instance-level state (kein Modul-globaler Zustand)
31
+ this.mqttClient = null;
32
+ this.deviceCache = [];
33
+ this.groupCache = [];
34
+ this.createCache = {};
35
+ this.logCustomizations = { debugDevices: '', logfilter: [] };
36
+ this.showInfo = true;
37
+ this.statesController = null;
38
+ this.deviceController = null;
39
+ this.z2mController = null;
40
+ this.websocketController = null;
41
+ this.mqttServerController = null;
42
+ this.messageParseMutex = Promise.resolve();
43
+
44
+ this.on('ready', () => {
45
+ this.onReady().catch((e) => this.log.error(`onReady error: ${e}`));
46
+ });
47
+ this.on('stateChange', (id, state) => {
48
+ this.onStateChange(id, state).catch((e) => this.log.error(`onStateChange error: ${e}`));
49
+ });
50
+ this.on('message', (obj) => {
51
+ this.onMessage(obj).catch((e) => this.log.error(`onMessage error: ${e}`));
52
+ });
45
53
  this.on('unload', this.onUnload.bind(this));
46
54
  }
47
55
 
56
+ /**
57
+ * Wird aufgerufen sobald der Adapter bereit ist (alle Objekte initialisiert).
58
+ * Initialisiert alle Controller und baut die Verbindung zu Zigbee2MQTT auf.
59
+ */
48
60
  async onReady() {
49
- statesController = new StatesController(this, deviceCache, groupCache, logCustomizations, createCache);
50
- deviceController = new DeviceController(
61
+ this.statesController = new StatesController(this, this.deviceCache, this.groupCache, this.logCustomizations, this.createCache);
62
+ this.deviceController = new DeviceController(
51
63
  this,
52
- deviceCache,
53
- groupCache,
64
+ this.deviceCache,
65
+ this.groupCache,
54
66
  this.config,
55
- logCustomizations,
56
- createCache
67
+ this.logCustomizations,
68
+ this.createCache
57
69
  );
58
- z2mController = new Z2mController(this, deviceCache, groupCache, logCustomizations);
70
+ this.z2mController = new Z2mController(this, this.deviceCache, this.groupCache, this.logCustomizations);
59
71
 
60
- // Initialize your adapter here
61
72
  adapterInfo(this.config, this.log);
62
73
 
63
- this.setState('info.connection', false, true);
74
+ await this.setStateAsync('info.connection', false, true);
64
75
 
65
76
  const debugDevicesState = await this.getStateAsync('info.debugmessages');
66
77
  if (debugDevicesState && debugDevicesState.val) {
67
- logCustomizations.debugDevices = String(debugDevicesState.val);
78
+ this.logCustomizations.debugDevices = String(debugDevicesState.val);
68
79
  }
69
80
 
70
81
  const logfilterState = await this.getStateAsync('info.logfilter');
71
- if (logfilterState && logfilterState.val) {
72
- logCustomizations.logfilter = String(logfilterState.val)
82
+ if (logfilterState && logfilterState.val) {
83
+ this.logCustomizations.logfilter = String(logfilterState.val)
73
84
  .split(';')
74
- .filter((x) => x); // filter removes empty strings here
85
+ .filter((x) => x);
75
86
  }
76
87
 
77
- if (this.config.coordinatorCheck == true) {
88
+ if (this.config.coordinatorCheck === true) {
78
89
  try {
79
- schedule.scheduleJob('coordinatorCheck', this.config.coordinatorCheckCron, () =>
80
- this.onStateChange('manual_trigger._.info.coordinator_check', { ack: false })
81
- );
90
+ schedule.scheduleJob('coordinatorCheck', this.config.coordinatorCheckCron, () => {
91
+ this.onStateChange('manual_trigger._.info.coordinator_check', { ack: false, val: null })
92
+ .catch((e) => this.log.error(`coordinatorCheck trigger error: ${e}`));
93
+ });
82
94
  } catch (e) {
83
95
  this.log.error(e);
84
96
  }
85
97
  }
98
+
86
99
  // MQTT
87
100
  if (['exmqtt', 'intmqtt'].includes(this.config.connectionType)) {
88
101
  // External MQTT-Server
89
- if (this.config.connectionType == 'exmqtt') {
90
- if (this.config.externalMqttServerIP == '') {
102
+ if (this.config.connectionType === 'exmqtt') {
103
+ if (!this.config.externalMqttServerIP) {
91
104
  this.log.warn('Please configure the External MQTT-Server connection!');
92
105
  return;
93
106
  }
94
107
 
95
- // MQTT connection settings
96
108
  const mqttClientOptions = {
97
109
  clientId: `ioBroker.zigbee2mqtt_${Math.random().toString(16).slice(2, 8)}`,
98
110
  clean: true,
99
111
  reconnectPeriod: 500,
100
112
  };
101
113
 
102
- // Set external mqtt credentials
103
- if (this.config.externalMqttServerCredentials == true) {
114
+ if (this.config.externalMqttServerCredentials === true) {
104
115
  mqttClientOptions.username = this.config.externalMqttServerUsername;
105
116
  mqttClientOptions.password = this.config.externalMqttServerPassword;
106
117
  }
107
118
 
108
- // Init connection
109
- mqttClient = mqtt.connect(
119
+ this.mqttClient = mqtt.connect(
110
120
  `mqtt://${this.config.externalMqttServerIP}:${this.config.externalMqttServerPort}`,
111
121
  mqttClientOptions
112
122
  );
113
123
  } else {
114
- // Internal MQTT-Server
115
- mqttServerController = new MqttServerController(this);
116
- await mqttServerController.createMQTTServer();
117
- await this.delay(1500);
118
- mqttClient = mqtt.connect(`mqtt://${this.config.mqttServerIPBind}:${this.config.mqttServerPort}`, {
124
+ // Internal MQTT-Server
125
+ this.mqttServerController = new MqttServerController(this);
126
+ await this.mqttServerController.createMQTTServer();
127
+ // Kurze Pause damit der OS-Socket tatsächlich bereit ist (createMQTTServer wartet bereits auf listen)
128
+ await this.delay(200);
129
+ this.mqttClient = mqtt.connect(`mqtt://${this.config.mqttServerIPBind}:${this.config.mqttServerPort}`, {
119
130
  clientId: `ioBroker.zigbee2mqtt_${Math.random().toString(16).slice(2, 8)}`,
120
131
  clean: true,
121
132
  reconnectPeriod: 500,
122
133
  });
123
134
  }
124
135
 
125
- // MQTT Client
126
- mqttClient.on('connect', () => {
136
+ // MQTT Client Events
137
+ this.mqttClient.on('connect', () => {
127
138
  this.log.info(
128
- `Connect to Zigbee2MQTT over ${this.config.connectionType == 'exmqtt' ? 'external mqtt' : 'internal mqtt'} connection.`
139
+ `Connect to Zigbee2MQTT over ${this.config.connectionType === 'exmqtt' ? 'external mqtt' : 'internal mqtt'} connection.`
129
140
  );
141
+ this.setStateChangedAsync('info.connection', true, true).catch((e) =>
142
+ this.log.error(`MQTT connect setStateChangedAsync error: ${e}`)
143
+ );
144
+ if (!this.config.baseTopic) {
145
+ this.log.error('baseTopic is not configured – cannot subscribe to MQTT topics!');
146
+ return;
147
+ }
148
+ this.mqttClient.subscribe(`${this.config.baseTopic}/#`, (err) => {
149
+ if (err) {
150
+ this.log.error(`MQTT subscribe error: ${err && err.message ? err.message : String(err)}`);
151
+ }
152
+ });
130
153
  });
131
154
 
132
- mqttClient.subscribe(`${this.config.baseTopic}/#`);
155
+ this.mqttClient.on('reconnect', () => {
156
+ this.log.info('MQTT client reconnecting to Zigbee2MQTT...');
157
+ });
158
+
159
+ this.mqttClient.on('offline', () => {
160
+ (async () => {
161
+ this.log.warn('MQTT client offline – connection to Zigbee2MQTT lost.');
162
+ await this.setStateChangedAsync('info.connection', false, true);
163
+ try {
164
+ if (this.statesController) {
165
+ await this.statesController.setAllAvailableToFalse();
166
+ }
167
+ } catch (e) {
168
+ this.log.error(`MQTT offline setAllAvailableToFalse error: ${e}`);
169
+ }
170
+ })().catch((e) => this.log.error(`MQTT offline handler error: ${e}`));
171
+ });
133
172
 
134
- mqttClient.on('message', (topic, payload) => {
135
- const newMessage = `{"payload":${payload.toString() == '' ? '"null"' : payload.toString()},"topic":"${topic.slice(topic.search('/') + 1)}"}`;
136
- this.messageParse(newMessage);
173
+ this.mqttClient.on('error', (err) => {
174
+ this.log.error(`MQTT client error: ${err && err.message ? err.message : String(err)}`);
175
+ });
176
+
177
+ this.mqttClient.on('message', (topic, payload) => {
178
+ // baseTopic-Prefix vollständig entfernen – funktioniert auch bei mehrstufigem baseTopic (z.B. home/zigbee2mqtt)
179
+ const basePrefix = this.config.baseTopic ? `${this.config.baseTopic}/` : null;
180
+ if (!basePrefix || !topic.startsWith(basePrefix)) {
181
+ this.log.debug(`MQTT message with unexpected topic format: ${topic}`);
182
+ return;
183
+ }
184
+ const payloadStr = payload.toString();
185
+ let parsedPayload;
186
+ try {
187
+ parsedPayload = payloadStr === '' ? null : JSON.parse(payloadStr);
188
+ } catch {
189
+ parsedPayload = payloadStr;
190
+ }
191
+ const messageObj = {
192
+ payload: parsedPayload,
193
+ topic: topic.slice(basePrefix.length),
194
+ };
195
+ this.messageParse(messageObj).catch((err) => {
196
+ this.log.error(`messageParse error: ${err}`);
197
+ });
137
198
  });
138
- } else if (this.config.connectionType == 'ws') {
139
- // Websocket
140
- if (this.config.wsServerIP == '') {
199
+ } else if (this.config.connectionType === 'ws') {
200
+ // Websocket
201
+ if (!this.config.wsServerIP) {
141
202
  this.log.warn('Please configure the Websoket connection!');
142
203
  return;
143
204
  }
144
205
 
145
- // Dummy MQTT-Server
146
- if (this.config.dummyMqtt == true) {
147
- mqttServerController = new MqttServerController(this);
148
- await mqttServerController.createDummyMQTTServer();
149
- await this.delay(1500);
206
+ if (this.config.dummyMqtt === true) {
207
+ this.mqttServerController = new MqttServerController(this);
208
+ await this.mqttServerController.createDummyMQTTServer();
209
+ await this.delay(200);
150
210
  }
151
211
 
152
212
  this.startWebsocket();
153
213
  }
154
214
  }
155
215
 
216
+ /**
217
+ * Erstellt (einmalig) den WebsocketController und initiiert die WS-Verbindung.
218
+ * Wird beim Start und nach einem Verbindungsabbruch aufgerufen.
219
+ */
156
220
  startWebsocket() {
157
- websocketController = new WebsocketController(this);
158
- const wsClient = websocketController.initWsClient();
159
-
160
- if (wsClient) {
161
- wsClient.on('open', () => {
162
- this.log.info('Connect to Zigbee2MQTT over websocket connection.');
163
- });
164
-
165
- wsClient.on('message', (message) => {
166
- this.messageParse(message);
167
- });
168
-
169
- wsClient.on('close', async () => {
170
- this.setStateChanged('info.connection', false, true);
171
- await statesController.setAllAvailableToFalse();
172
- deviceCache = [];
173
- groupCache = [];
174
- });
221
+ // Controller nur einmal erstellen – bei Reconnect wird derselbe wiederverwendet
222
+ if (!this.websocketController) {
223
+ this.websocketController = new WebsocketController(this);
175
224
  }
225
+ this.websocketController.initWsClient();
176
226
  }
177
227
 
178
- async messageParse(message) {
179
- // Mutex lock: queue up calls to messageParse
180
- let release;
228
+ /**
229
+ * Parst eine eingehende MQTT- oder WebSocket-Nachricht von Zigbee2MQTT
230
+ * und leitet sie an den zuständigen Controller weiter.
231
+ * Alle Aufrufe werden serialisiert (Mutex), um Race-Conditions zu vermeiden.
232
+ *
233
+ * @param {{ topic: string, payload: any }} messageObj Die zu verarbeitende Nachricht
234
+ */
235
+ async messageParse(messageObj) {
236
+ // Mutex: serialisiert alle messageParse-Aufrufe
237
+ // Wichtig: lock muss IMMER wieder freigegeben werden, auch bei early return / Fehler.
238
+ let release = () => {};
181
239
  const lock = new Promise((resolve) => (release = resolve));
182
- const prev = messageParseMutex;
183
- messageParseMutex = lock;
184
- await prev;
240
+ const prev = this.messageParseMutex;
241
+ this.messageParseMutex = lock;
242
+
243
+ // Wenn prev rejected hat, wollen wir trotzdem weiterarbeiten (sonst blockiert alles)
244
+ try {
245
+ await prev;
246
+ } catch {
247
+ // ignore
248
+ }
249
+
185
250
  try {
186
- // If the MQTT output type is set to attribute_and_json, the non-valid JSON must be checked here.
187
- if (utils.isJson(message) == false) {
251
+ if (!messageObj || typeof messageObj !== 'object') {
252
+ return;
253
+ }
254
+ if (!this.statesController || !this.deviceController || !this.z2mController) {
255
+ this.log.debug('messageParse: controllers not yet initialized, dropping message.');
188
256
  return;
189
257
  }
190
-
191
- const messageObj = JSON.parse(message);
192
258
 
193
259
  switch (messageObj.topic) {
194
- case 'Coordinator/availability':
195
- this.setStateChanged('info.coordinator_status', messageObj.payload.state, true);
260
+ case 'Coordinator/availability': {
261
+ const coordState = typeof messageObj.payload === 'object' && messageObj.payload !== null
262
+ ? messageObj.payload.state
263
+ : messageObj.payload;
264
+ await this.setStateChangedAsync('info.coordinator_status', coordState, true);
196
265
  break;
266
+ }
197
267
  case 'bridge/info':
198
- if (showInfo) {
199
- zigbee2mqttInfo(messageObj.payload, this.log);
200
- checkConfig(messageObj.payload.config, this.log, messageObj.payload.version);
201
- showInfo = false;
268
+ if (this.showInfo && messageObj.payload) {
269
+ await zigbee2mqttInfo(messageObj.payload, this.log);
270
+ if (messageObj.payload.config && messageObj.payload.version) {
271
+ checkConfig(messageObj.payload.config, this.log, messageObj.payload.version);
272
+ }
273
+ this.showInfo = false;
202
274
  }
203
275
  break;
204
- case 'bridge/state':
205
- if (messageObj.payload.state != 'online') {
206
- statesController.setAllAvailableToFalse();
276
+ case 'bridge/state': {
277
+ const bridgeState = typeof messageObj.payload === 'object' && messageObj.payload !== null
278
+ ? messageObj.payload.state
279
+ : messageObj.payload;
280
+ if (bridgeState !== 'online') {
281
+ await this.statesController.setAllAvailableToFalse();
282
+ this.showInfo = true;
207
283
  }
208
- this.setStateChanged('info.connection', messageObj.payload.state == 'online', true);
284
+ await this.setStateChangedAsync('info.connection', bridgeState === 'online', true);
209
285
  break;
286
+ }
210
287
  case 'bridge/devices':
211
- await deviceController.createDeviceDefinitions(messageObj.payload);
212
- await deviceController.createOrUpdateDevices();
213
- await deviceController.checkAndProgressDeviceRemove();
214
- await statesController.subscribeWritableStates();
215
- statesController.processQueue();
288
+ await this.deviceController.createDeviceDefinitions(messageObj.payload);
289
+ await this.deviceController.createOrUpdateDevices();
290
+ await this.deviceController.checkAndProgressDeviceRemove();
291
+ await this.statesController.subscribeWritableStates();
292
+ await this.statesController.processQueue();
216
293
  break;
217
294
  case 'bridge/groups':
218
- await deviceController.createGroupDefinitions(messageObj.payload);
219
- await deviceController.createOrUpdateDevices();
220
- await statesController.subscribeWritableStates();
221
- statesController.processQueue();
295
+ await this.deviceController.createGroupDefinitions(messageObj.payload);
296
+ await this.deviceController.createOrUpdateDevices();
297
+ await this.statesController.subscribeWritableStates();
298
+ await this.statesController.processQueue();
222
299
  break;
223
300
  case 'bridge/response/coordinator_check':
224
- deviceController.processCoordinatorCheck(messageObj.payload);
301
+ await this.deviceController.processCoordinatorCheck(messageObj.payload);
225
302
  break;
226
303
  case 'bridge/logging':
227
- if (this.config.proxyZ2MLogs == true) {
228
- z2mController.proxyZ2MLogs(messageObj);
304
+ if (this.config.proxyZ2MLogs === true) {
305
+ await this.z2mController.proxyZ2MLogs(messageObj);
229
306
  }
230
307
  break;
231
308
  case 'bridge/response/device/rename':
232
- await deviceController.renameDeviceInCache(messageObj);
233
- await deviceController.createOrUpdateDevices();
234
- statesController.processQueue();
309
+ await this.deviceController.renameDeviceInCache(messageObj);
310
+ await this.deviceController.createOrUpdateDevices();
311
+ await this.statesController.processQueue();
312
+ break;
313
+ case 'bridge/event': {
314
+ const evType = messageObj.payload && messageObj.payload.type;
315
+ const evData = messageObj.payload && messageObj.payload.data;
316
+ if (evType === 'device_announce' && evData && evData.friendly_name) {
317
+ const newMessage = { payload: { available: true }, topic: evData.friendly_name };
318
+ await this.statesController.processDeviceMessage(newMessage);
319
+ } else if (evType === 'device_leave' && evData && evData.friendly_name) {
320
+ const newMessage = { payload: { available: false }, topic: evData.friendly_name };
321
+ await this.statesController.processDeviceMessage(newMessage);
322
+ }
235
323
  break;
324
+ }
236
325
  case 'bridge/config':
237
326
  case 'bridge/health':
238
327
  case 'bridge/definitions':
239
- case 'bridge/event':
240
328
  case 'bridge/extensions':
241
329
  case 'bridge/response/device/configure':
242
330
  case 'bridge/response/device/remove':
243
331
  case 'bridge/response/device/options':
332
+ case 'bridge/response/device/interview':
244
333
  case 'bridge/response/permit_join':
245
334
  case 'bridge/response/networkmap':
335
+ case 'bridge/response/options':
336
+ case 'bridge/response/restart':
337
+ case 'bridge/response/backup':
338
+ case 'bridge/response/install_code/add':
339
+ case 'bridge/response/group/add':
340
+ case 'bridge/response/group/remove':
341
+ case 'bridge/response/group/members/add':
342
+ case 'bridge/response/group/members/remove':
246
343
  case 'bridge/response/touchlink/scan':
247
344
  case 'bridge/response/touchlink/identify':
248
345
  case 'bridge/response/touchlink/factory_reset':
249
346
  break;
250
- default:
251
- {
252
- // is the payload an availability status?
253
- if (messageObj.topic.endsWith('/availability')) {
254
- // If an availability message for an old device ID comes with a payload of NULL, this is the indicator that a device has been unnamed.
255
- if (messageObj.payload == 'null') {
256
- return;
257
- }
258
- // is it a viable payload?
259
- if (messageObj.payload && messageObj.payload.state) {
260
- // {"payload":{"state":"online"},"topic":"FL.Licht.Links/availability"} ----> {"payload":{"available":true},"topic":"FL.Licht.Links"}
261
- const newMessage = {
262
- payload: { available: messageObj.payload.state == 'online' },
263
- topic: messageObj.topic.replace('/availability', ''),
264
- };
265
-
266
- statesController.processDeviceMessage(newMessage);
267
- }
268
- // States
269
- } else {
270
- // With the MQTT output type attribute_and_json, primitive payloads arrive here that must be discarded.
271
- if (utils.isObject(messageObj.payload) == false) {
272
- return;
273
- }
274
- // If MQTT is used, I have to filter the self-sent 'set' commands.
275
- if (messageObj.topic.endsWith('/set')) {
276
- return;
277
- }
278
-
279
- statesController.processDeviceMessage(messageObj);
347
+ default: {
348
+ if (!messageObj.topic || typeof messageObj.topic !== 'string') {
349
+ break;
350
+ }
351
+ if (messageObj.topic.endsWith('/availability')) {
352
+ if (messageObj.payload === null || messageObj.payload === undefined) {
353
+ break;
354
+ }
355
+ const deviceTopic = messageObj.topic.replace('/availability', '');
356
+ if (!deviceTopic) {
357
+ break;
358
+ }
359
+ const availState = typeof messageObj.payload === 'object'
360
+ ? messageObj.payload.state
361
+ : messageObj.payload;
362
+ const newMessage = {
363
+ payload: { available: availState === 'online' },
364
+ topic: deviceTopic,
365
+ };
366
+ await this.statesController.processDeviceMessage(newMessage);
367
+ } else {
368
+ if (!utils.isObject(messageObj.payload)) {
369
+ break;
370
+ }
371
+ if (messageObj.topic.endsWith('/set')) {
372
+ break;
280
373
  }
374
+ await this.statesController.processDeviceMessage(messageObj);
281
375
  }
282
376
  break;
377
+ }
283
378
  }
284
379
  } finally {
380
+ // Mutex immer freigeben
285
381
  release();
286
382
  }
287
383
  }
288
384
 
385
+ /**
386
+ * Verarbeitet interne ioBroker-Nachrichten (z.B. Admin-UI-Kommandos).
387
+ *
388
+ * @param {ioBroker.Message} obj Das eingehende Nachrichtenobjekt
389
+ */
390
+ async onMessage(obj) {
391
+ if (!obj || !obj.command) {
392
+ return;
393
+ }
394
+
395
+ if (obj.command === 'deleteOldStates') {
396
+ const deletedList = [];
397
+ const errorList = [];
398
+ try {
399
+ // Guard: Caches müssen gefüllt sein – sonst würden ALLE States gelöscht
400
+ if (this.deviceCache.length === 0 && this.groupCache.length === 0) {
401
+ const warn = 'deleteOldStates: device list is empty – adapter not connected to Zigbee2MQTT yet. Aborting.';
402
+ this.log.warn(warn);
403
+ if (obj.callback) {
404
+ this.sendTo(obj.from, obj.command, { error: warn }, obj.callback);
405
+ }
406
+ return;
407
+ }
408
+
409
+ // Nur echte Geräte (kein group_*) – Gruppen werden beim Löschen komplett ausgelassen
410
+ const knownStateIDs = new Set();
411
+ for (const device of this.deviceCache) {
412
+ if (!device || !device.ieee_address || !Array.isArray(device.states)) {
413
+ continue;
414
+ }
415
+ const base = `${this.namespace}.${device.ieee_address}`;
416
+ for (const state of device.states) {
417
+ if (state && state.id) {
418
+ knownStateIDs.add(`${base}.${state.id}`);
419
+ }
420
+ }
421
+ // additional-Channel und Pflicht-States immer erlauben
422
+ knownStateIDs.add(`${base}.additional`);
423
+ knownStateIDs.add(`${base}.available`);
424
+ knownStateIDs.add(`${base}.last_seen`);
425
+ knownStateIDs.add(`${base}.send_payload`);
426
+ }
427
+
428
+ // Alle vorhandenen Adapter-Objekte laden
429
+ const allObjects = await this.getAdapterObjectsAsync();
430
+ if (!allObjects) {
431
+ const warn = 'deleteOldStates: getAdapterObjectsAsync returned null – aborting.';
432
+ this.log.error(warn);
433
+ if (obj.callback) {
434
+ this.sendTo(obj.from, obj.command, { error: warn }, obj.callback);
435
+ }
436
+ return;
437
+ }
438
+ for (const id of Object.keys(allObjects)) {
439
+ const iobObj = allObjects[id];
440
+
441
+ // Nur States löschen – keine channels, devices, folders
442
+ if (!iobObj || iobObj.type !== 'state') {
443
+ continue;
444
+ }
445
+
446
+ // info.* States niemals löschen
447
+ if (id.includes('.info.')) {
448
+ continue;
449
+ }
450
+
451
+ // Gruppen-States (group_*) niemals löschen
452
+ // Format: adapter.instance.ieee_address.stateid → parts[2] ist ieee_address
453
+ const parts = id.split('.');
454
+ if (parts.length >= 3 && parts[2].startsWith('group_')) {
455
+ continue;
456
+ }
457
+
458
+ // additional.* Sub-States erlauben
459
+ if (parts.length >= 5) {
460
+ const additionalBase = parts.slice(0, 4).join('.');
461
+ if (knownStateIDs.has(additionalBase)) {
462
+ continue;
463
+ }
464
+ }
465
+
466
+ // Wenn nicht im bekannten Set → löschen
467
+ if (!knownStateIDs.has(id)) {
468
+ try {
469
+ await this.delObjectAsync(id);
470
+ deletedList.push(id);
471
+ this.log.debug(`deleteOldStates: deleted ${id}`);
472
+ } catch (e) {
473
+ errorList.push(id);
474
+ this.log.warn(`deleteOldStates: could not delete ${id}: ${e}`);
475
+ }
476
+ }
477
+ }
478
+
479
+ // Liste aller gelöschten States als Warning ausgeben
480
+ if (deletedList.length > 0) {
481
+ this.log.warn(`deleteOldStates: deleted ${deletedList.length} state(s):`);
482
+ for (const deletedId of deletedList) {
483
+ this.log.warn(` - ${deletedId}`);
484
+ }
485
+ }
486
+ if (errorList.length > 0) {
487
+ this.log.warn(`deleteOldStates: failed to delete ${errorList.length} state(s):`);
488
+ for (const errId of errorList) {
489
+ this.log.warn(` - ${errId}`);
490
+ }
491
+ }
492
+
493
+ const msg = `Deleted ${deletedList.length} old state(s)${errorList.length > 0 ? `, ${errorList.length} error(s)` : ''}.`;
494
+ this.log.info(`deleteOldStates: ${msg}`);
495
+ if (obj.callback) {
496
+ this.sendTo(obj.from, obj.command, { result: msg }, obj.callback);
497
+ }
498
+ } catch (e) {
499
+ this.log.error(`deleteOldStates error: ${e}`);
500
+ if (obj.callback) {
501
+ this.sendTo(obj.from, obj.command, { error: String(e) }, obj.callback);
502
+ }
503
+ }
504
+ }
505
+ }
506
+
507
+ /**
508
+ * Wird beim Stoppen des Adapters aufgerufen.
509
+ * Trennt alle Verbindungen, stoppt Timer und ruft abschließend callback() auf.
510
+ *
511
+ * @param {() => void} callback Muss am Ende zwingend aufgerufen werden
512
+ */
289
513
  async onUnload(callback) {
290
- // Close MQTT connections
291
- if (['exmqtt', 'intmqtt'].includes(this.config.connectionType)) {
292
- if (mqttClient && !mqttClient.closed) {
514
+ try {
515
+ if (['exmqtt', 'intmqtt'].includes(this.config.connectionType)) {
516
+ if (this.mqttClient && !this.mqttClient.disconnected) {
517
+ try {
518
+ this.mqttClient.removeAllListeners();
519
+ this.mqttClient.end(true);
520
+ } catch (e) {
521
+ this.log.error(e);
522
+ }
523
+ }
524
+ }
525
+ if (this.config.connectionType === 'intmqtt' || this.config.dummyMqtt === true) {
526
+ try {
527
+ if (this.mqttServerController) {
528
+ this.mqttServerController.closeServer();
529
+ }
530
+ } catch (e) {
531
+ this.log.error(e);
532
+ }
533
+ } else if (this.config.connectionType === 'ws') {
293
534
  try {
294
- if (mqttClient) {
295
- mqttClient.end();
535
+ if (this.websocketController) {
536
+ this.websocketController.closeConnection();
296
537
  }
297
538
  } catch (e) {
298
539
  this.log.error(e);
299
540
  }
300
541
  }
301
- }
302
- // Internal or Dummy MQTT-Server
303
- if (this.config.connectionType == 'intmqtt' || this.config.dummyMqtt == true) {
304
542
  try {
305
- if (mqttServerController) {
306
- mqttServerController.closeServer();
543
+ if (this.statesController) {
544
+ await this.statesController.setAllAvailableToFalse();
307
545
  }
308
546
  } catch (e) {
309
547
  this.log.error(e);
310
548
  }
311
- } else if (this.config.connectionType == 'ws') {
312
- // Websocket
313
549
  try {
314
- if (websocketController) {
315
- websocketController.closeConnection();
550
+ if (this.websocketController) {
551
+ this.websocketController.allTimerClear();
316
552
  }
317
553
  } catch (e) {
318
554
  this.log.error(e);
319
555
  }
320
- }
321
- // Set all device available states of false
322
- try {
323
- if (statesController) {
324
- await statesController.setAllAvailableToFalse();
325
- }
326
- } catch (e) {
327
- this.log.error(e);
328
- }
329
- // Clear all websocket timers
330
- try {
331
- if (websocketController) {
332
- await websocketController.allTimerClear();
333
- }
334
- } catch (e) {
335
- this.log.error(e);
336
- }
337
- // Clear all state timers
338
- try {
339
- if (statesController) {
340
- await statesController.allTimerClear();
556
+ try {
557
+ if (this.statesController) {
558
+ this.statesController.allTimerClear();
559
+ }
560
+ } catch (e) {
561
+ this.log.error(e);
341
562
  }
342
- } catch (e) {
343
- this.log.error(e);
344
- }
345
563
 
346
- this.setState('info.connection', false, true);
564
+ await this.setStateAsync('info.connection', false, true);
347
565
 
348
- callback();
566
+ // Schedule-Job beenden
567
+ const job = schedule.scheduledJobs['coordinatorCheck'];
568
+ if (job) {
569
+ job.cancel();
570
+ }
571
+ } finally {
572
+ // callback() wird IMMER aufgerufen – auch wenn oben etwas wirft
573
+ // Ohne das hängt der Adapter-Stop dauerhaft
574
+ callback();
575
+ }
349
576
  }
350
577
 
578
+ /**
579
+ * Reagiert auf Änderungen von ioBroker-States.
580
+ * Wandelt den State-Change in eine Zigbee2MQTT-Nachricht um und sendet sie.
581
+ *
582
+ * @param {string} id Vollständige State-ID (z.B. zigbee2mqtt.0.0xAABB.state)
583
+ * @param {ioBroker.State | null | undefined} state Das neue State-Objekt
584
+ */
351
585
  async onStateChange(id, state) {
352
- if (state && state.ack == false) {
586
+ if (state && state.ack === false) {
353
587
  if (id.endsWith('info.debugmessages')) {
354
- logCustomizations.debugDevices = state.val;
355
- this.setState(id, state.val, true);
588
+ this.logCustomizations.debugDevices = state.val != null ? String(state.val) : '';
589
+ await this.setStateAsync(id, state.val, true);
356
590
  return;
357
591
  }
358
592
  if (id.endsWith('info.logfilter')) {
359
- logCustomizations.logfilter = state.val.split(';').filter((x) => x); // filter removes empty strings here
360
- this.setState(id, state.val, true);
593
+ this.logCustomizations.logfilter = state.val != null
594
+ ? String(state.val).split(';').filter((x) => x)
595
+ : [];
596
+ await this.setStateAsync(id, state.val, true);
597
+ return;
598
+ }
599
+
600
+ if (!this.z2mController) {
601
+ this.log.debug(`onStateChange: z2mController not yet initialized, dropping state change for ${id}.`);
361
602
  return;
362
603
  }
363
604
 
364
- const message = (await z2mController.createZ2MMessage(id, state)) || { topic: '', payload: '' };
605
+ const message = await this.z2mController.createZ2MMessage(id, state);
606
+ if (!message || !message.topic) {
607
+ return;
608
+ }
365
609
 
366
610
  if (['exmqtt', 'intmqtt'].includes(this.config.connectionType)) {
367
- mqttClient.publish(`${this.config.baseTopic}/${message.topic}`, JSON.stringify(message.payload));
368
- } else if (this.config.connectionType == 'ws') {
369
- websocketController.send(JSON.stringify(message));
611
+ if (!this.mqttClient || this.mqttClient.disconnected) {
612
+ this.log.warn(`Cannot publish state, MQTT client not connected. (${id})`);
613
+ return;
614
+ }
615
+ if (!this.config.baseTopic) {
616
+ this.log.error('baseTopic is not configured – cannot publish state!');
617
+ return;
618
+ }
619
+ try {
620
+ this.mqttClient.publish(
621
+ `${this.config.baseTopic}/${message.topic}`,
622
+ JSON.stringify(message.payload),
623
+ (err) => {
624
+ if (err) {
625
+ this.log.error(`MQTT publish error for ${id}: ${err && err.message ? err.message : String(err)}`);
626
+ }
627
+ }
628
+ );
629
+ } catch (e) {
630
+ this.log.error(`MQTT publish exception for ${id}: ${e}`);
631
+ }
632
+ } else if (this.config.connectionType === 'ws') {
633
+ if (!this.websocketController) {
634
+ this.log.warn(`Cannot send state, WebSocket not initialized. (${id})`);
635
+ return;
636
+ }
637
+ this.websocketController.send(JSON.stringify({ topic: message.topic, payload: message.payload }));
370
638
  }
371
639
  }
372
640
  }
373
641
  }
374
642
 
375
643
  if (require.main !== module) {
376
- // Export the constructor in compact mode
377
644
  /**
378
- * @param {Partial<core.AdapterOptions>} [options]
645
+ * @param {object} [options] Optionale Adapter-Konfiguration (AdapterOptions)
379
646
  */
380
647
  module.exports = (options) => new Zigbee2mqtt(options);
381
648
  } else {
382
- // otherwise start the instance directly
383
649
  new Zigbee2mqtt();
384
650
  }