iobroker.zigbee2mqtt 3.2.2 → 3.2.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.
@@ -28,6 +28,76 @@ class StatesController {
28
28
  this._debugDeviceList = logCustomizations.debugDevices
29
29
  ? String(logCustomizations.debugDevices).split(',').map((s) => s.trim()).filter(Boolean)
30
30
  : [];
31
+
32
+ // PERFORMANCE: Map-Cache für Topic->Device Lookup (vermeidet concat().find() pro Nachricht)
33
+ this.deviceMap = new Map();
34
+
35
+ // PERFORMANCE: Session-Cache für bereits per setObjectNotExistsAsync erstellte IDs
36
+ // Vermeidet wiederholte js-controller-Roundtrips
37
+ this.ensuredObjects = new Set();
38
+ }
39
+
40
+ /**
41
+ * Invalidiert den Topic->Device Map-Cache sowie den Session-Cache der
42
+ * bereits sichergestellten Objekte. Muss aufgerufen werden, wenn
43
+ * deviceCache/groupCache zur Laufzeit neu aufgebaut werden.
44
+ */
45
+ clearDeviceMap() {
46
+ this.deviceMap.clear();
47
+ this.ensuredObjects.clear();
48
+ }
49
+
50
+ /**
51
+ * Findet ein Gerät oder eine Gruppe anhand des Topics.
52
+ * Nutzt einen Map-Cache und füllt diesen bei Bedarf über eine einmalige lineare Suche.
53
+ *
54
+ * @param {string} topic Das Topic (entspricht device.id)
55
+ * @returns {object|null} Das gefundene Geräte-/Gruppenobjekt oder null
56
+ */
57
+ findDeviceByTopic(topic) { if (!topic) {
58
+ return null;
59
+ }
60
+ const t = String(topic);
61
+ if (this.deviceMap.has(t)) {
62
+ return this.deviceMap.get(t);
63
+ }
64
+
65
+ // Fallback: lineare Suche (einmalig) und Cache-Eintrag
66
+ for (const grp of this.groupCache || []) {
67
+ if (grp && grp.id === t) {
68
+ this.deviceMap.set(t, grp);
69
+ return grp;
70
+ }
71
+ }
72
+ for (const dev of this.deviceCache || []) {
73
+ if (dev && dev.id === t) {
74
+ this.deviceMap.set(t, dev);
75
+ return dev;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Legt ein ioBroker-Objekt nur einmal pro Session an (reduziert setObjectNotExistsAsync-Roundtrips).
83
+ *
84
+ * @param {string} id Vollständige Objekt-ID
85
+ * @param {object} obj Objekt-Definition für setObjectNotExistsAsync
86
+ */
87
+ async ensureObjectOnce(id, obj) {
88
+ if (!id) {
89
+ return;
90
+ }
91
+ if (this.ensuredObjects.has(id)) {
92
+ return;
93
+ }
94
+ try {
95
+ await this.adapter.setObjectNotExistsAsync(id, obj);
96
+ this.ensuredObjects.add(id);
97
+ } catch (e) {
98
+ // Loggen, aber nicht wiederholend blockieren
99
+ this.adapter.log.warn(`ensureObjectOnce: setObjectNotExistsAsync failed for ${id}: ${e}`);
100
+ }
31
101
  }
32
102
 
33
103
  /**
@@ -45,7 +115,8 @@ class StatesController {
45
115
  return;
46
116
  }
47
117
 
48
- const device = this.groupCache.concat(this.deviceCache).find((x) => x.id === messageObj.topic);
118
+ // PERFORMANCE: Map-basiertes Lookup statt concat().find()
119
+ const device = this.findDeviceByTopic(messageObj.topic);
49
120
  if (device) {
50
121
  try {
51
122
  await this.setDeviceStateSafely(messageObj, device);
@@ -116,6 +187,35 @@ class StatesController {
116
187
  return;
117
188
  }
118
189
 
190
+ // Fix 3: Vorab prüfen, ob überhaupt ein State des Devices existiert.
191
+ // Nur wenn KEIN EINZIGER State existiert → requeue.
192
+ // Wenn mindestens einer existiert → verarbeite vorhandene, ignoriere fehlende.
193
+ let hasAnyExistingState = false;
194
+ for (const key of Object.keys(messageObj.payload)) {
195
+ if (hasAnyExistingState) { break; }
196
+ let states = device.states.filter(state => state.prop && state.prop === key);
197
+ if (states.length === 0) { states = device.states.filter(x => x.id === key); }
198
+ for (const state of states) {
199
+ if (this.createCache[device.ieee_address]
200
+ && this.createCache[device.ieee_address][state.id]
201
+ && this.createCache[device.ieee_address][state.id].created === true) {
202
+ hasAnyExistingState = true;
203
+ break;
204
+ }
205
+ }
206
+ }
207
+
208
+ if (!hasAnyExistingState) {
209
+ // Kein einziger State existiert → Nachricht in Queue stellen
210
+ pushToQueue(messageObj);
211
+ return;
212
+ }
213
+
214
+ // COLLECTION: Alle non-action state write-Promises sammeln und dann parallel ausführen
215
+ const stateWrites = [];
216
+ // Flag für available-Update (wenn last_seen vorhanden und config aktiv)
217
+ let needSetAvailableTrue = false;
218
+
119
219
  for (let [key, value] of Object.entries(messageObj.payload)) {
120
220
  if (value === undefined || value === null) {
121
221
  continue;
@@ -136,14 +236,15 @@ class StatesController {
136
236
  // some devices has addition information in payload
137
237
  const fullPath = `${device.ieee_address}.additional`;
138
238
 
139
- await this.adapter.setObjectNotExistsAsync(fullPath, {
239
+ // PERFORMANCE: ensure object only once per session
240
+ await this.ensureObjectOnce(fullPath, {
140
241
  type: 'channel',
141
242
  common: {
142
243
  name: 'hidden channelstate',
143
244
  },
144
245
  native: {},
145
246
  });
146
- await this.adapter.setObjectNotExistsAsync(`${fullPath}.${key}`, {
247
+ await this.ensureObjectOnce(`${fullPath}.${key}`, {
147
248
  type: 'state',
148
249
  common: {
149
250
  name: key,
@@ -157,7 +258,14 @@ class StatesController {
157
258
  if (typeof value === 'object') {
158
259
  value = JSON.stringify(value);
159
260
  }
160
- this.adapter.setState(`${fullPath}.${key}`, value, true);
261
+ // write additional state without awaiting
262
+ stateWrites.push((async () => {
263
+ try {
264
+ await this.adapter.setStateChangedAsync(`${fullPath}.${key}`, value, true);
265
+ } catch (e) {
266
+ this.adapter.log.warn(`Cannot set additional state ${fullPath}.${key}: ${e}`);
267
+ }
268
+ })());
161
269
  }
162
270
  continue;
163
271
  }
@@ -166,15 +274,17 @@ class StatesController {
166
274
  const stateName = `${device.ieee_address}.${state.id}`;
167
275
 
168
276
  // set available status if last_seen is set
169
- if (state.id === 'last_seen' && this.adapter.config.allwaysUpdateAvailableState === true) {
170
- await this.setStateSafelyAsync(`${device.ieee_address}.available`, true);
277
+ if (state.id === 'last_seen' && this.adapter.config.alwaysUpdateAvailableState === true) {
278
+ // mark flag to set available before other writes
279
+ needSetAvailableTrue = true;
171
280
  }
172
281
 
173
- // State noch nicht erstellt? → einmal in Queue legen und weiter
282
+ // State noch nicht erstellt? → überspringen (Requeue erfolgt nur,
283
+ // wenn GAR KEIN State des Devices existiert – siehe Pre-Check oben)
174
284
  if (!this.createCache[device.ieee_address]
175
285
  || !this.createCache[device.ieee_address][state.id]
176
286
  || this.createCache[device.ieee_address][state.id].created !== true) {
177
- pushToQueue(messageObj);
287
+ // If none created we already pushed to queue earlier
178
288
  continue;
179
289
  }
180
290
 
@@ -183,14 +293,34 @@ class StatesController {
183
293
  if (state.prop && state.prop === 'action') {
184
294
  actionStates.push(state);
185
295
  }
186
- else if (this.adapter.config.allwaysUpdateOccupancyState === true && state.id === 'occupancy' && value === true) {
187
- await this.setStateSafelyAsync(stateName, value);
296
+ else if (this.adapter.config.alwaysUpdateOccupancyState === true && state.id === 'occupancy' && value === true) {
297
+ // occupancy true: schedule write
298
+ stateWrites.push((async () => {
299
+ try {
300
+ await this.setStateSafelyAsync(stateName, value);
301
+ } catch (e) {
302
+ this.adapter.log.warn(`Cannot set occupancy ${stateName}: ${e}`);
303
+ }
304
+ })());
188
305
  }
189
306
  else {
190
307
  if (state.getter) {
191
- await this.setStateChangedSafelyAsync(stateName, state.getter(messageObj.payload));
308
+ // schedule changed-safe write
309
+ stateWrites.push((async () => {
310
+ try {
311
+ await this.setStateChangedSafelyAsync(stateName, state.getter(messageObj.payload));
312
+ } catch (e) {
313
+ this.adapter.log.warn(`Cannot set state ${stateName}: ${e}`);
314
+ }
315
+ })());
192
316
  } else {
193
- await this.setStateChangedSafelyAsync(stateName, value);
317
+ stateWrites.push((async () => {
318
+ try {
319
+ await this.setStateChangedSafelyAsync(stateName, value);
320
+ } catch (e) {
321
+ this.adapter.log.warn(`Cannot set state ${stateName}: ${e}`);
322
+ }
323
+ })());
194
324
  }
195
325
  }
196
326
  } catch (err) {
@@ -199,25 +329,52 @@ class StatesController {
199
329
  }
200
330
  }
201
331
 
332
+ // Wenn last_seen vorhanden: setze available zuerst (nicht parallelisiert)
333
+ if (needSetAvailableTrue) {
334
+ try {
335
+ await this.setStateSafelyAsync(`${device.ieee_address}.available`, true);
336
+ } catch (e) {
337
+ this.adapter.log.warn(`Cannot set available for ${device.ieee_address}: ${e}`);
338
+ }
339
+ }
202
340
 
341
+ // Parallel ausführen: non-action states
342
+ try {
343
+ await Promise.all(stateWrites);
344
+ } catch (e) {
345
+ this.adapter.log.warn(`processDeviceMessage: parallel writes encountered error: ${e}`);
346
+ }
347
+
348
+ // ACTION states: auch parallel ausführen, berücksichtige setStateWithTimeoutAsync
349
+ const actionPromises = [];
203
350
  for (const state of actionStates) {
204
351
  const stateName = `${device.ieee_address}.${state.id}`;
205
-
206
- try {
207
- const getterPayload = state.getter(messageObj.payload);
208
- if (getterPayload !== undefined) {
209
- if (state.isEvent && state.isEvent === true) {
210
- if (state.type === 'boolean') {
211
- await this.setStateWithTimeoutAsync(stateName, getterPayload, 250);
352
+ actionPromises.push((async () => {
353
+ try {
354
+ const getterPayload = state.getter(messageObj.payload);
355
+ if (getterPayload !== undefined) {
356
+ if (state.isEvent && state.isEvent === true) {
357
+ if (state.type === 'boolean') {
358
+ // setStateWithTimeoutAsync kümmert sich intern um Timer/Optimierung
359
+ await this.setStateWithTimeoutAsync(stateName, getterPayload, 250);
360
+ } else {
361
+ await this.setStateSafelyAsync(stateName, getterPayload);
362
+ }
212
363
  } else {
213
- await this.setStateSafelyAsync(stateName, getterPayload);
364
+ await this.setStateChangedSafelyAsync(stateName, getterPayload);
214
365
  }
215
- } else {
216
- await this.setStateChangedSafelyAsync(stateName, getterPayload);
217
366
  }
367
+ } catch (err) {
368
+ this.adapter.log.warn(`Cannot set action state ${stateName}: ${err}`);
218
369
  }
219
- } catch (err) {
220
- this.adapter.log.warn(`Cannot set action state ${stateName}: ${err}`);
370
+ })());
371
+ }
372
+
373
+ if (actionPromises.length > 0) {
374
+ try {
375
+ await Promise.all(actionPromises);
376
+ } catch (e) {
377
+ this.adapter.log.warn(`processDeviceMessage: action state parallel writes error: ${e}`);
221
378
  }
222
379
  }
223
380
  }
@@ -264,22 +421,33 @@ class StatesController {
264
421
  return;
265
422
  }
266
423
 
267
- this.adapter.setState(stateName, value, true);
424
+ // Prüfen, ob bereits ein Timer läuft (State ist dann schon auf true)
425
+ const wasAlreadyTrue = !!this.timeOutCache[stateName];
268
426
 
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.
427
+ // Timer immer clearen, wenn vorhanden
272
428
  if (this.timeOutCache[stateName]) {
273
429
  this.adapter.clearTimeout(this.timeOutCache[stateName]);
274
430
  delete this.timeOutCache[stateName];
275
431
  }
432
+
276
433
  if (value === true) {
434
+ // Nur setState aufrufen, wenn der State NICHT bereits auf true war.
435
+ // Bei Dauerfeuer (value=true→true schneller als timeout) spart das
436
+ // den unnötigen setState-Call und verhindert, dass der Timer
437
+ // nie ausgelöst wird (Timer-Leak).
438
+ if (!wasAlreadyTrue) {
439
+ await this.setStateSafelyAsync(stateName, true);
440
+ }
441
+ // Neuen Timer für Auto-Reset setzen
277
442
  this.timeOutCache[stateName] = this.adapter.setTimeout(() => {
278
443
  delete this.timeOutCache[stateName];
279
444
  this.adapter.setState(stateName, false, true).catch((err) => {
280
445
  this.adapter.log.debug(`setStateWithTimeout reset error for ${stateName}: ${err}`);
281
446
  });
282
447
  }, timeout);
448
+ } else {
449
+ // value === false: State setzen, Timer bereits gelöscht
450
+ await this.setStateSafelyAsync(stateName, false);
283
451
  }
284
452
  }
285
453
 
@@ -147,6 +147,11 @@ class WebsocketController {
147
147
  for (const key of Object.keys(this.adapter.createCache)) {
148
148
  delete this.adapter.createCache[key];
149
149
  }
150
+ // Topic->Device Map-Cache des StatesController invalidieren, da deviceCache/groupCache
151
+ // neu aufgebaut werden (verhindert veraltete Referenzen auf alte Geräteobjekte)
152
+ if (this.adapter.statesController && typeof this.adapter.statesController.clearDeviceMap === 'function') {
153
+ this.adapter.statesController.clearDeviceMap();
154
+ }
150
155
 
151
156
  // Nur reconnecten wenn kein intentionales Shutdown
152
157
  if (!this._intentionalClose) {
@@ -105,7 +105,15 @@ class Z2mController {
105
105
  for (const option of deviceState.options) {
106
106
  // Fix: "in"-Prüfung statt === undefined, damit null als gültiger gecachter Wert behandelt wird
107
107
  if (!(option in device.optionsValues)) {
108
- const optState = await this.adapter.getStateAsync(`${splitedID[0]}.${splitedID[1]}.${splitedID[2]}.${option}`);
108
+ let optState;
109
+ try {
110
+ optState = await this.adapter.getStateAsync(`${splitedID[0]}.${splitedID[1]}.${splitedID[2]}.${option}`);
111
+ } catch (e) {
112
+ this.adapter.log.warn(
113
+ `Cannot read state for option "${option}": ${e.message}`
114
+ );
115
+ optState = null;
116
+ }
109
117
  device.optionsValues[option] = optState ? optState.val : null;
110
118
  }
111
119
 
package/main.js CHANGED
@@ -40,6 +40,7 @@ class Zigbee2mqtt extends core.Adapter {
40
40
  this.websocketController = null;
41
41
  this.mqttServerController = null;
42
42
  this.messageParseMutex = Promise.resolve();
43
+ this.mqttReconnectAttempts = 0;
43
44
 
44
45
  this.on('ready', () => {
45
46
  this.onReady().catch((e) => this.log.error(`onReady error: ${e}`));
@@ -124,7 +125,12 @@ class Zigbee2mqtt extends core.Adapter {
124
125
  } else {
125
126
  // Internal MQTT-Server
126
127
  this.mqttServerController = new MqttServerController(this);
127
- await this.mqttServerController.createMQTTServer();
128
+ const serverStarted = await this.mqttServerController.createMQTTServer();
129
+ if (!serverStarted) {
130
+ this.log.error('Internal MQTT server could not be started. The adapter cannot connect to Zigbee2MQTT.');
131
+ this.setState('info.connection', false, true);
132
+ return;
133
+ }
128
134
  // Kurze Pause damit der OS-Socket tatsächlich bereit ist (createMQTTServer wartet bereits auf listen)
129
135
  await this.delay(200);
130
136
  this.mqttClient = mqtt.connect(`mqtt://${this.config.mqttServerIPBind}:${this.config.mqttServerPort}`, {
@@ -137,6 +143,7 @@ class Zigbee2mqtt extends core.Adapter {
137
143
 
138
144
  // MQTT Client Events
139
145
  this.mqttClient.on('connect', () => {
146
+ this.mqttReconnectAttempts = 0;
140
147
  this.log.info(
141
148
  `Connect to Zigbee2MQTT over ${this.config.connectionType === 'exmqtt' ? 'external mqtt' : 'internal mqtt'} connection.`
142
149
  );
@@ -155,7 +162,20 @@ class Zigbee2mqtt extends core.Adapter {
155
162
  });
156
163
 
157
164
  this.mqttClient.on('reconnect', () => {
158
- this.log.info('MQTT client reconnecting to Zigbee2MQTT...');
165
+ this.mqttReconnectAttempts++;
166
+ if (this.mqttReconnectAttempts > 20) {
167
+ this.log.error(
168
+ `MQTT client gave up after ${this.mqttReconnectAttempts} reconnect attempts (10 seconds). ` +
169
+ `Please check whether Zigbee2MQTT is running and the MQTT connection settings are correct.`
170
+ );
171
+ if (this.mqttClient) {
172
+ this.mqttClient.end(true);
173
+ }
174
+ return;
175
+ }
176
+ this.log.info(
177
+ `MQTT client reconnecting to Zigbee2MQTT... (attempt ${this.mqttReconnectAttempts}/20)`
178
+ );
159
179
  });
160
180
 
161
181
  this.mqttClient.on('offline', async () => {
@@ -214,7 +234,12 @@ class Zigbee2mqtt extends core.Adapter {
214
234
 
215
235
  if (this.config.dummyMqtt === true) {
216
236
  this.mqttServerController = new MqttServerController(this);
217
- await this.mqttServerController.createDummyMQTTServer();
237
+ const serverStarted = await this.mqttServerController.createDummyMQTTServer();
238
+ if (!serverStarted) {
239
+ this.log.error('Dummy MQTT server could not be started – WebSocket mode cannot continue.');
240
+ this.setState('info.connection', false, true);
241
+ return;
242
+ }
218
243
  await this.delay(200);
219
244
  }
220
245
 
@@ -527,7 +552,7 @@ class Zigbee2mqtt extends core.Adapter {
527
552
  this.mqttClient.removeAllListeners();
528
553
  this.mqttClient.end(true);
529
554
  } catch (e) {
530
- this.log.error(e);
555
+ this.log.error(`[onUnload] Fehler beim Schließen des MQTT-Clients: ${e.message}`);
531
556
  }
532
557
  }
533
558
  }
@@ -537,15 +562,16 @@ class Zigbee2mqtt extends core.Adapter {
537
562
  this.mqttServerController.closeServer();
538
563
  }
539
564
  } catch (e) {
540
- this.log.error(e);
565
+ this.log.error(`[onUnload] Fehler beim Schließen des MQTT-Servers: ${e.message}`);
541
566
  }
542
- } else if (this.config.connectionType === 'ws') {
567
+ }
568
+ if (this.config.connectionType === 'ws') {
543
569
  try {
544
570
  if (this.websocketController) {
545
571
  this.websocketController.closeConnection();
546
572
  }
547
573
  } catch (e) {
548
- this.log.error(e);
574
+ this.log.error(`[onUnload] Fehler beim Schließen der WebSocket-Verbindung: ${e.message}`);
549
575
  }
550
576
  }
551
577
  try {
@@ -553,21 +579,21 @@ class Zigbee2mqtt extends core.Adapter {
553
579
  await this.statesController.setAllAvailableToFalse();
554
580
  }
555
581
  } catch (e) {
556
- this.log.error(e);
582
+ this.log.error(`[onUnload] Fehler beim Setzen aller States auf false: ${e.message}`);
557
583
  }
558
584
  try {
559
585
  if (this.websocketController) {
560
586
  this.websocketController.allTimerClear();
561
587
  }
562
588
  } catch (e) {
563
- this.log.error(e);
589
+ this.log.error(`[onUnload] Fehler beim Zurücksetzen der WebSocket-Timer: ${e.message}`);
564
590
  }
565
591
  try {
566
592
  if (this.statesController) {
567
593
  this.statesController.allTimerClear();
568
594
  }
569
595
  } catch (e) {
570
- this.log.error(e);
596
+ this.log.error(`[onUnload] Fehler beim Zurücksetzen der States-Timer: ${e.message}`);
571
597
  }
572
598
 
573
599
  this.setState('info.connection', false, true);
@@ -595,14 +621,14 @@ class Zigbee2mqtt extends core.Adapter {
595
621
  if (state && state.ack === false) {
596
622
  if (id.endsWith('info.debugmessages')) {
597
623
  this.logCustomizations.debugDevices = state.val != null ? String(state.val) : '';
598
- this.setState(id, state.val, true);
624
+ await this.setState(id, state.val, true).catch(e => this.log.error(`[stateChange] Fehler beim Setzen von ${id}: ${e.message}`));
599
625
  return;
600
626
  }
601
627
  if (id.endsWith('info.logfilter')) {
602
628
  this.logCustomizations.logfilter = state.val != null
603
629
  ? String(state.val).split(';').filter((x) => x)
604
630
  : [];
605
- this.setState(id, state.val, true);
631
+ await this.setState(id, state.val, true).catch(e => this.log.error(`[stateChange] Fehler beim Setzen von ${id}: ${e.message}`));
606
632
  return;
607
633
  }
608
634
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.zigbee2mqtt",
3
- "version": "3.2.2",
3
+ "version": "3.2.5",
4
4
  "description": "Zigbee2MQTT adapter for ioBroker",
5
5
  "author": {
6
6
  "name": "Dennis Rathjen and Arthur Rupp",
@@ -24,27 +24,27 @@
24
24
  "node": ">= 22"
25
25
  },
26
26
  "dependencies": {
27
- "@iobroker/adapter-core": "^3.3.2",
28
- "@iobroker/dm-utils": "^3.0.17",
27
+ "@iobroker/adapter-core": "^3.4.1",
28
+ "@iobroker/dm-utils": "^3.1.3",
29
29
  "aedes": "^0.51.3",
30
30
  "aedes-persistence": "^9.1.2",
31
- "axios": "^1.16.0",
32
- "mqtt": "^5.15.1",
31
+ "axios": "^1.18.1",
32
+ "mqtt": "^5.15.2",
33
33
  "net": "^1.0.2",
34
34
  "node-schedule": "^2.1.1",
35
35
  "sharp": "^0.34.5",
36
- "ws": "^8.20.0"
36
+ "ws": "^8.21.0"
37
37
  },
38
38
  "devDependencies": {
39
- "@alcalzone/release-script": "^5.2.0",
39
+ "@alcalzone/release-script": "^5.2.1",
40
40
  "@alcalzone/release-script-plugin-iobroker": "^5.2.0",
41
41
  "@alcalzone/release-script-plugin-license": "^5.2.0",
42
42
  "@alcalzone/release-script-plugin-manual-review": "^5.2.0",
43
43
  "@iobroker/adapter-dev": "^1.5.0",
44
44
  "@iobroker/eslint-config": "^2.3.4",
45
- "@iobroker/testing": "^5.1.1",
46
- "@tsconfig/node14": "^14.1.8",
47
- "@types/node": "^25.6.2",
45
+ "@iobroker/testing": "^5.2.2",
46
+ "@tsconfig/node22": "^22.0.5",
47
+ "@types/node": "^25.9.5",
48
48
  "@types/node-schedule": "^2.1.8",
49
49
  "typescript": "~6.0.3"
50
50
  },