iobroker.devices 2.0.3 → 2.0.12

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.
Files changed (47) hide show
  1. package/README.md +23 -2
  2. package/admin/assets/hostInit-LzITLWDa.js +1 -0
  3. package/admin/assets/index-By05vlt_.js +2783 -0
  4. package/admin/assets/index-D3eYsCtw.js +1 -0
  5. package/admin/assets/localSharedImportMap-CoMzwmdv.js +1 -0
  6. package/admin/assets/preload-helper-BDBacUwf.js +1 -0
  7. package/admin/assets/virtualExposes-BIHI7g3E.js +1 -0
  8. package/admin/assets/virtual_mf-REMOTE_ENTRY_ID_iobroker_devices__remoteEntry_js-D5_qLLYC.js +8 -0
  9. package/admin/index_m.html +5 -1
  10. package/admin/manifest.json +13 -13
  11. package/admin/mf-manifest.json +1 -0
  12. package/admin/remoteEntry.js +1 -0
  13. package/admin/tab.html +5 -1
  14. package/admin/vendor/socket.io.js +3446 -4
  15. package/build/lib/WidgetsManagement.js +159 -26
  16. package/build/lib/WidgetsManagement.js.map +1 -1
  17. package/build/packages/dm-widgets/src/StateContext.js +64 -0
  18. package/build/packages/dm-widgets/src/StateContext.js.map +1 -0
  19. package/build/packages/dm-widgets/src/WidgetGeneric.js +223 -0
  20. package/build/packages/dm-widgets/src/WidgetGeneric.js.map +1 -0
  21. package/build/packages/dm-widgets/src/index.js +99 -0
  22. package/build/packages/dm-widgets/src/index.js.map +1 -0
  23. package/build/packages/dm-widgets/src/types.js +3 -0
  24. package/build/packages/dm-widgets/src/types.js.map +1 -0
  25. package/build/src/lib/WidgetsManagement.js +760 -0
  26. package/build/src/lib/WidgetsManagement.js.map +1 -0
  27. package/build/src/main.js +53 -0
  28. package/build/src/main.js.map +1 -0
  29. package/build/src/widget-utils/WidgetsManagement.js +119 -0
  30. package/build/src/widget-utils/WidgetsManagement.js.map +1 -0
  31. package/build/src/widget-utils/index.js +18 -0
  32. package/build/src/widget-utils/index.js.map +1 -0
  33. package/build/src/widget-utils/types/api.js +3 -0
  34. package/build/src/widget-utils/types/api.js.map +1 -0
  35. package/build/src/widget-utils/types/base.js +3 -0
  36. package/build/src/widget-utils/types/base.js.map +1 -0
  37. package/build/src/widget-utils/types/index.js +3 -0
  38. package/build/src/widget-utils/types/index.js.map +1 -0
  39. package/build/widget-utils/WidgetsManagement.js +0 -2
  40. package/build/widget-utils/WidgetsManagement.js.map +1 -1
  41. package/io-package.json +25 -90
  42. package/package.json +9 -6
  43. package/www/assets/index-7xkEJSKC.js +1 -0
  44. package/www/assets/index-BoZ6w1gP.js +2815 -0
  45. package/www/index.html +2 -1
  46. package/admin/assets/index-BaeInhLO.js +0 -845
  47. package/www/assets/index-lrSafi0L.js +0 -748
@@ -0,0 +1,760 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const type_detector_1 = __importDefault(require("@iobroker/type-detector"));
7
+ const widget_utils_1 = require("../widget-utils");
8
+ const ROOT_CATEGORY = '__root__';
9
+ const ALIAS = 'alias.';
10
+ const ALIAS_MAX = 'alias.0.\u9999';
11
+ const LINKEDDEVICES = 'linkeddevices.';
12
+ function findMainStateId(device) {
13
+ const state = device.states.find(s => s.id && s.required);
14
+ return state?.id;
15
+ }
16
+ function getParentId(id) {
17
+ const pos = id.lastIndexOf('.');
18
+ return pos !== -1 ? id.substring(0, pos) : '';
19
+ }
20
+ class DevicesWidgetsManagement extends widget_utils_1.WidgetsManagement {
21
+ detector = new type_detector_1.default();
22
+ objects = {};
23
+ enumIds = [];
24
+ /** IDs collected from enum members only */
25
+ enumMemberIds = [];
26
+ idsInEnums = [];
27
+ /** All detected devices */
28
+ allDevices = [];
29
+ /** Only devices with `common.custom[namespace].enabled === true` */
30
+ enabledDevices = [];
31
+ loaded = null;
32
+ notifyTimeout = null;
33
+ invalidatedIds = [];
34
+ /** Lazily rebuilt sorted keys cache */
35
+ _sortedKeys = null;
36
+ /** Maps alias.0.X folder IDs to a same-named enum.rooms.* category that should subsume them. */
37
+ aliasFolderRedirects = new Map();
38
+ // ── Sorted keys cache ──────────────────────────────────────────────
39
+ getSortedKeys() {
40
+ this._sortedKeys ||= Object.keys(this.objects).sort();
41
+ return this._sortedKeys;
42
+ }
43
+ invalidateKeys() {
44
+ this._sortedKeys = null;
45
+ }
46
+ // ── Helpers ────────────────────────────────────────────────────────
47
+ /**
48
+ * Resolve the channelId for a detected device (same logic as updateEnumsForOneDevice).
49
+ */
50
+ resolveChannelId(device) {
51
+ const mainStateId = findMainStateId(device);
52
+ if (!mainStateId) {
53
+ device.storeId = '';
54
+ return;
55
+ }
56
+ const statesCount = device.states.filter(state => state.id).length;
57
+ let storeId = mainStateId;
58
+ if (mainStateId.includes('.') &&
59
+ (statesCount > 1 || storeId.startsWith(ALIAS) || storeId.startsWith(LINKEDDEVICES))) {
60
+ storeId = getParentId(mainStateId);
61
+ if (!this.objects[storeId]?.common ||
62
+ (this.objects[storeId].type !== 'channel' &&
63
+ this.objects[storeId].type !== 'device' &&
64
+ this.objects[storeId].type !== 'folder')) {
65
+ storeId = mainStateId;
66
+ }
67
+ }
68
+ device.storeId = storeId;
69
+ const channelId = getParentId(storeId);
70
+ if (this.objects[channelId]?.common &&
71
+ (this.objects[channelId].type === 'device' || this.objects[channelId].type === 'channel')) {
72
+ device.channelId = channelId;
73
+ if (this.objects[channelId].type === 'channel') {
74
+ const deviceId = getParentId(channelId);
75
+ if (this.objects[deviceId]?.type === 'device') {
76
+ device.deviceId = deviceId;
77
+ }
78
+ }
79
+ }
80
+ else {
81
+ device.channelId = storeId;
82
+ }
83
+ }
84
+ /**
85
+ * Rebuild enumIds and enumMemberIds by scanning all objects for enums.
86
+ * This is the expensive part — only call when enums are actually changed.
87
+ */
88
+ rebuildEnumMemberIds() {
89
+ this.enumIds = [];
90
+ const ids = [];
91
+ for (const [id, obj] of Object.entries(this.objects)) {
92
+ if (obj.type === 'enum') {
93
+ this.enumIds.push(id);
94
+ const members = obj.common?.members;
95
+ if (members) {
96
+ for (const m of members) {
97
+ if (!ids.includes(m)) {
98
+ ids.push(m);
99
+ }
100
+ }
101
+ }
102
+ }
103
+ }
104
+ this.enumIds.sort();
105
+ this.enumMemberIds = ids;
106
+ }
107
+ rebuildCategories() {
108
+ const structure = {
109
+ [ROOT_CATEGORY]: [],
110
+ };
111
+ const keys = this.getSortedKeys();
112
+ for (const key of keys) {
113
+ if (key < ALIAS) {
114
+ continue;
115
+ }
116
+ if (key > ALIAS_MAX) {
117
+ break;
118
+ }
119
+ if (!this.objects[key]) {
120
+ return;
121
+ }
122
+ if (this.objects[key].type === 'folder') {
123
+ structure[key] ||= [];
124
+ const parent = getParentId(key);
125
+ if (this.objects[parent]?.type === 'folder' || parent === 'alias.0') {
126
+ if (parent === 'alias.0') {
127
+ structure[ROOT_CATEGORY].push(key);
128
+ }
129
+ else {
130
+ structure[parent] ||= [];
131
+ structure[parent].push(key);
132
+ }
133
+ }
134
+ }
135
+ else if (this.objects[key].type === 'device' || this.objects[key].type === 'channel') {
136
+ // get parent
137
+ const parent = getParentId(key);
138
+ if (this.objects[parent]?.type === 'folder' || parent === 'alias.0') {
139
+ if (parent === 'alias.0') {
140
+ structure[ROOT_CATEGORY].push(key);
141
+ }
142
+ else {
143
+ structure[parent] ||= [];
144
+ structure[parent].push(key);
145
+ }
146
+ }
147
+ }
148
+ }
149
+ this.categories = new Map();
150
+ const categories = Object.keys(structure).filter(key => {
151
+ // Always keep ROOT_CATEGORY: room-based categories (enum.rooms.*) are added
152
+ // later in rebuildEnabledDevices() with parent=ROOT_CATEGORY, so without it
153
+ // the tree has no root and the GUI falls back to the first room.
154
+ if (key === ROOT_CATEGORY) {
155
+ return true;
156
+ }
157
+ if (structure[key].length) {
158
+ return true;
159
+ }
160
+ // Include empty folders marked with showEmpty, or that hold plugin/custom widgets
161
+ if (this.objects[key]) {
162
+ const custom = this.objects[key].common?.custom;
163
+ if (custom &&
164
+ Object.values(custom).some(c => c?.showEmpty || (Array.isArray(c?.customWidgets) && c.customWidgets.length > 0))) {
165
+ return true;
166
+ }
167
+ }
168
+ return false;
169
+ });
170
+ for (const category of categories) {
171
+ const parentId = category === ROOT_CATEGORY ? '' : getParentId(category);
172
+ this.categories.set(category, {
173
+ type: 'category',
174
+ id: category,
175
+ name: this.objects[category]
176
+ ? this.objects[category].common.name || category.split('.').pop() || ''
177
+ : '',
178
+ icon: this.objects[category] ? this.objects[category].common.icon : undefined,
179
+ color: this.objects[category] ? this.objects[category].common.color : undefined,
180
+ parent: category === ROOT_CATEGORY ? undefined : parentId !== 'alias.0' ? parentId : ROOT_CATEGORY,
181
+ custom: category !== ROOT_CATEGORY && this.objects[category]
182
+ ? this.objects[category].common.custom?.[this.adapter.namespace]
183
+ : undefined,
184
+ });
185
+ }
186
+ // Remove all orphan categories
187
+ for (const [id, category] of this.categories) {
188
+ if (category.parent && !this.categories.has(category.parent)) {
189
+ this.categories.delete(id);
190
+ }
191
+ }
192
+ // Merge alias.0.X folder categories with same-named enum.rooms.* categories
193
+ // so devices structured under an alias folder and devices assigned to the same-named
194
+ // room enum appear together as a single room in the GUI.
195
+ this.mergeAliasFoldersWithRoomEnums();
196
+ }
197
+ getObjectDisplayName(obj) {
198
+ const raw = obj?.common?.name;
199
+ if (!raw) {
200
+ return '';
201
+ }
202
+ if (typeof raw === 'string') {
203
+ return raw;
204
+ }
205
+ const lang = this.adapter.language || 'en';
206
+ return raw[lang] || raw.en || Object.values(raw).find(Boolean) || '';
207
+ }
208
+ /**
209
+ * Build alias→enum redirects, replace alias-folder categories with their enum.rooms.*
210
+ * counterpart (re-parenting sub-categories) so the merged room shows up only once.
211
+ */
212
+ mergeAliasFoldersWithRoomEnums() {
213
+ this.aliasFolderRedirects = new Map();
214
+ if (!this.categories) {
215
+ return;
216
+ }
217
+ // displayName -> enum.rooms.* id
218
+ const roomByName = new Map();
219
+ for (const enumId of this.enumIds) {
220
+ if (!enumId.startsWith('enum.rooms.')) {
221
+ continue;
222
+ }
223
+ const name = this.getObjectDisplayName(this.objects[enumId]);
224
+ if (name && !roomByName.has(name)) {
225
+ roomByName.set(name, enumId);
226
+ }
227
+ }
228
+ if (!roomByName.size) {
229
+ return;
230
+ }
231
+ for (const [id] of this.categories) {
232
+ if (!id.startsWith('alias.0.') || this.objects[id]?.type !== 'folder') {
233
+ continue;
234
+ }
235
+ const name = this.getObjectDisplayName(this.objects[id]);
236
+ const enumId = name ? roomByName.get(name) : undefined;
237
+ if (enumId && enumId !== id) {
238
+ this.aliasFolderRedirects.set(id, enumId);
239
+ }
240
+ }
241
+ for (const [aliasId, enumId] of this.aliasFolderRedirects) {
242
+ const aliasCat = this.categories.get(aliasId);
243
+ if (!aliasCat) {
244
+ continue;
245
+ }
246
+ if (!this.categories.has(enumId)) {
247
+ const enumObj = this.objects[enumId];
248
+ this.categories.set(enumId, {
249
+ type: 'category',
250
+ id: enumId,
251
+ name: enumObj ? enumObj.common.name || enumId.split('.').pop() || '' : aliasCat.name,
252
+ icon: enumObj?.common.icon ?? aliasCat.icon,
253
+ color: enumObj?.common.color ?? aliasCat.color,
254
+ parent: ROOT_CATEGORY,
255
+ custom: enumObj?.common.custom?.[this.adapter.namespace] ?? aliasCat.custom,
256
+ });
257
+ }
258
+ // Re-parent any direct children of the alias folder onto the enum category
259
+ for (const child of this.categories.values()) {
260
+ if (child.parent === aliasId) {
261
+ child.parent = enumId;
262
+ }
263
+ }
264
+ this.categories.delete(aliasId);
265
+ }
266
+ }
267
+ /**
268
+ * Rebuild idsInEnums by adding alias/linkeddevices channels/devices
269
+ * not already covered by enumMemberIds. Cheap — only scans the relevant key range.
270
+ */
271
+ rebuildAliasIds() {
272
+ const ids = [...this.enumMemberIds];
273
+ const keys = this.getSortedKeys();
274
+ const END = `${LINKEDDEVICES}\u9999`;
275
+ for (const key of keys) {
276
+ if (key < ALIAS) {
277
+ continue;
278
+ }
279
+ if (key > END) {
280
+ break;
281
+ }
282
+ if ((key.startsWith(ALIAS) || key.startsWith(LINKEDDEVICES)) && this.objects[key] && !ids.includes(key)) {
283
+ if (this.objects[key].type === 'device') {
284
+ ids.push(key);
285
+ }
286
+ else if (this.objects[key].type === 'channel') {
287
+ const parentId = getParentId(key);
288
+ if (!this.objects[parentId] || !ids.includes(parentId)) {
289
+ ids.push(key);
290
+ }
291
+ }
292
+ }
293
+ }
294
+ this.idsInEnums = ids.sort();
295
+ }
296
+ /**
297
+ * Full rebuild of idsInEnums (enum scan + alias scan). Only used for initial load.
298
+ */
299
+ rebuildIdsInEnums() {
300
+ this.rebuildEnumMemberIds();
301
+ this.rebuildAliasIds();
302
+ this.rebuildCategories();
303
+ }
304
+ /**
305
+ * Run ChannelDetector.detect() only for the given IDs. Returns newly detected devices.
306
+ */
307
+ detectForIds(ids) {
308
+ if (!ids.length) {
309
+ return [];
310
+ }
311
+ const keys = this.getSortedKeys();
312
+ const usedIds = [];
313
+ const result = [];
314
+ for (const id of ids) {
315
+ const detected = this.detector.detect({
316
+ id,
317
+ objects: this.objects,
318
+ _usedIdsOptional: usedIds,
319
+ _keysOptional: keys,
320
+ ignoreCache: true,
321
+ });
322
+ if (detected) {
323
+ for (const device of detected) {
324
+ const d = device;
325
+ this.resolveChannelId(d);
326
+ if (d.storeId) {
327
+ result.push(d);
328
+ break; // ignore "smaller" devices
329
+ }
330
+ }
331
+ }
332
+ }
333
+ return result;
334
+ }
335
+ /**
336
+ * Remove devices from allDevices whose channelId matches any of the given IDs.
337
+ */
338
+ removeDevicesForChannelIds(channelIds) {
339
+ if (!channelIds.length) {
340
+ return;
341
+ }
342
+ const set = new Set(channelIds);
343
+ this.allDevices = this.allDevices.filter(d => !set.has(d.storeId));
344
+ }
345
+ findIdInEnums(lookForId) {
346
+ // Try to find room to which this device belongs to
347
+ let useEnum = '';
348
+ for (const enumId of this.enumIds) {
349
+ if (enumId.startsWith('enum.rooms.')) {
350
+ const enumObj = this.objects[enumId];
351
+ if (enumObj?.common?.members?.includes(lookForId) ||
352
+ enumObj?.common?.members?.find(id => id.startsWith(`${lookForId}.`))) {
353
+ useEnum = enumId;
354
+ }
355
+ }
356
+ }
357
+ if (!useEnum) {
358
+ for (const enumId of this.enumIds) {
359
+ if (enumId.startsWith('enum.functions.')) {
360
+ const enumObj = this.objects[enumId];
361
+ if (enumObj?.common?.members?.includes(lookForId) ||
362
+ enumObj?.common?.members?.find(id => id.startsWith(`${lookForId}.`))) {
363
+ useEnum = enumId;
364
+ }
365
+ }
366
+ }
367
+ }
368
+ if (!useEnum) {
369
+ for (const enumId of this.enumIds) {
370
+ const enumObj = this.objects[enumId];
371
+ if (enumObj?.common?.members?.includes(lookForId) ||
372
+ enumObj?.common?.members?.find(id => id.startsWith(`${lookForId}.`))) {
373
+ useEnum = enumId;
374
+ }
375
+ }
376
+ }
377
+ return useEnum;
378
+ }
379
+ /**
380
+ * Rebuild enabledDevices from allDevices (cheap, no I/O, no detection).
381
+ * Only devices with `common.custom[namespace].enabled === true` are included.
382
+ */
383
+ rebuildEnabledDevices() {
384
+ const customKey = this.adapter.namespace;
385
+ const oldEnabledDevices = JSON.stringify(this.enabledDevices);
386
+ this.enabledDevices = this.allDevices.filter(d => d.storeId && this.objects[d.storeId]?.common?.custom?.[customKey]?.enabled);
387
+ // Special case: the devices not from alias.0
388
+ for (const device of this.enabledDevices) {
389
+ // try to find a parent category
390
+ const directParent = getParentId(device.storeId);
391
+ // If the device's alias folder has been merged into a same-named enum.rooms.*,
392
+ // route the device to the enum category instead.
393
+ let parentId = this.aliasFolderRedirects.get(directParent) || directParent;
394
+ if (parentId && this.categories?.has(parentId)) {
395
+ device.parentId = parentId;
396
+ continue;
397
+ }
398
+ if (parentId.startsWith(ALIAS) || parentId.startsWith(LINKEDDEVICES)) {
399
+ device.parentId = parentId;
400
+ let parentCategoryId = getParentId(parentId);
401
+ // Create virtual category
402
+ this.categories?.set(parentId, {
403
+ type: 'category',
404
+ id: parentId,
405
+ name: this.objects[parentId]
406
+ ? this.objects[parentId].common.name || parentId.split('.').pop() || ''
407
+ : '',
408
+ icon: this.objects[parentId] ? this.objects[parentId].common.icon : undefined,
409
+ color: this.objects[parentId] ? this.objects[parentId].common.color : undefined,
410
+ parent: parentCategoryId === 'alias.0' ? ROOT_CATEGORY : parentCategoryId,
411
+ custom: this.objects[parentId]?.common.custom?.[this.adapter.namespace],
412
+ });
413
+ parentId = parentCategoryId;
414
+ parentCategoryId = getParentId(parentId);
415
+ while (parentId !== 'alias.0' && parentId && !this.categories?.has(parentId)) {
416
+ // Create virtual category
417
+ this.categories?.set(parentId, {
418
+ type: 'category',
419
+ id: parentId,
420
+ name: this.objects[parentId]
421
+ ? this.objects[parentId].common.name || parentId.split('.').pop() || ''
422
+ : '',
423
+ icon: this.objects[parentId] ? this.objects[parentId].common.icon : undefined,
424
+ color: this.objects[parentId] ? this.objects[parentId].common.color : undefined,
425
+ parent: parentCategoryId === 'alias.0' ? ROOT_CATEGORY : parentCategoryId,
426
+ custom: this.objects[parentId]?.common.custom?.[this.adapter.namespace],
427
+ });
428
+ parentId = parentCategoryId;
429
+ parentCategoryId = getParentId(parentId);
430
+ }
431
+ }
432
+ else {
433
+ // Try to find room to which this device belongs to
434
+ let useEnum = this.findIdInEnums(device.storeId);
435
+ useEnum ||= this.findIdInEnums(device.channelId);
436
+ useEnum ||= this.findIdInEnums(device.deviceId);
437
+ if (useEnum) {
438
+ device.parentId = useEnum;
439
+ let parentCategoryId = getParentId(useEnum);
440
+ // Do not overwrite a pre-existing category (e.g. one created by
441
+ // mergeAliasFoldersWithRoomEnums, which may carry merged icon/color/custom).
442
+ if (!this.categories?.has(useEnum)) {
443
+ this.categories?.set(useEnum, {
444
+ type: 'category',
445
+ id: useEnum,
446
+ name: this.objects[useEnum]
447
+ ? this.objects[useEnum].common.name || useEnum.split('.').pop() || ''
448
+ : '',
449
+ icon: this.objects[useEnum] ? this.objects[useEnum].common.icon : undefined,
450
+ color: this.objects[useEnum] ? this.objects[useEnum].common.color : undefined,
451
+ parent: parentCategoryId.split('.').length > 2 ? parentCategoryId : ROOT_CATEGORY,
452
+ custom: this.objects[useEnum]?.common.custom?.[this.adapter.namespace],
453
+ });
454
+ }
455
+ parentId = parentCategoryId;
456
+ parentCategoryId = getParentId(parentId);
457
+ while (parentId.split('.').length > 2 && parentId && !this.categories?.has(parentId)) {
458
+ // Create virtual category
459
+ this.categories?.set(parentId, {
460
+ type: 'category',
461
+ id: parentId,
462
+ name: this.objects[parentId]
463
+ ? this.objects[parentId].common.name || parentId.split('.').pop() || ''
464
+ : '',
465
+ icon: this.objects[parentId] ? this.objects[parentId].common.icon : undefined,
466
+ color: this.objects[parentId] ? this.objects[parentId].common.color : undefined,
467
+ parent: parentCategoryId.split('.').length > 2 ? parentCategoryId : ROOT_CATEGORY,
468
+ custom: this.objects[parentId]?.common.custom?.[this.adapter.namespace],
469
+ });
470
+ parentId = parentCategoryId;
471
+ parentCategoryId = getParentId(parentId);
472
+ }
473
+ }
474
+ else {
475
+ this.adapter.log.warn(`Cannot find parent for "${device.storeId}"!`);
476
+ }
477
+ }
478
+ }
479
+ return JSON.stringify(this.enabledDevices) !== oldEnabledDevices;
480
+ }
481
+ /**
482
+ * For a given changed object ID, find the detection IDs (from idsInEnums) that are affected.
483
+ * A state change affects its parent channel/device.
484
+ */
485
+ getAffectedChannelIds(id) {
486
+ // Direct match in idsInEnums
487
+ if (this.idsInEnums.includes(id)) {
488
+ return [id];
489
+ }
490
+ // Parent is a detection ID (state changed under a channel)
491
+ const parentId = getParentId(id);
492
+ if (parentId && this.idsInEnums.includes(parentId)) {
493
+ return [parentId];
494
+ }
495
+ // Grandparent (state under device>channel hierarchy)
496
+ const grandParentId = getParentId(parentId);
497
+ if (grandParentId && this.idsInEnums.includes(grandParentId)) {
498
+ return [grandParentId];
499
+ }
500
+ // Check if any existing device references this ID in its states
501
+ const affected = [];
502
+ for (const device of this.allDevices) {
503
+ if (device.storeId === id || device.states.some(s => s.id === id)) {
504
+ if (!affected.includes(device.storeId)) {
505
+ affected.push(device.storeId);
506
+ }
507
+ }
508
+ }
509
+ return affected;
510
+ }
511
+ // ── GUI notification ───────────────────────────────────────────────
512
+ /**
513
+ * Debounced GUI notification (100ms) to batch rapid changes.
514
+ */
515
+ scheduleNotify() {
516
+ if (this.notifyTimeout) {
517
+ clearTimeout(this.notifyTimeout);
518
+ }
519
+ this.notifyTimeout = setTimeout(async () => {
520
+ this.notifyTimeout = null;
521
+ this.adapter.log.debug(`Update objects because of: ${this.invalidatedIds.join(', ')}`);
522
+ this.invalidatedIds = [];
523
+ try {
524
+ await this.sendCommandToGui({ command: 'all' });
525
+ }
526
+ catch {
527
+ // GUI may not be open — ignore
528
+ }
529
+ }, 100);
530
+ }
531
+ // ── Object change (incremental) ───────────────────────────────────
532
+ objectChange(id, obj) {
533
+ const oldObj = this.objects[id];
534
+ if (!oldObj && !obj) {
535
+ return;
536
+ }
537
+ // Update local cache
538
+ if (obj) {
539
+ // We must compare only common part
540
+ if (JSON.stringify(obj.common) !== JSON.stringify(this.objects[id]?.common)) {
541
+ this.objects[id] = obj;
542
+ }
543
+ }
544
+ else {
545
+ delete this.objects[id];
546
+ }
547
+ this.invalidateKeys();
548
+ // Case 1: Enum changed — rebuild enum member IDs + alias IDs, detect only the diff
549
+ if (oldObj?.type === 'enum' || obj?.type === 'enum' || obj?.type === 'folder' || oldObj?.type === 'folder') {
550
+ const oldIds = [...this.idsInEnums];
551
+ this.rebuildEnumMemberIds();
552
+ if (obj?.type === 'folder' || oldObj?.type === 'folder') {
553
+ this.rebuildCategories();
554
+ }
555
+ this.rebuildAliasIds();
556
+ const removed = oldIds.filter(x => !this.idsInEnums.includes(x));
557
+ const added = this.idsInEnums.filter(x => !oldIds.includes(x));
558
+ if (removed.length) {
559
+ this.removeDevicesForChannelIds(removed);
560
+ }
561
+ if (added.length) {
562
+ this.allDevices.push(...this.detectForIds(added));
563
+ }
564
+ if (this.rebuildEnabledDevices()) {
565
+ if (!this.invalidatedIds.includes(id)) {
566
+ this.invalidatedIds.push(id);
567
+ }
568
+ this.scheduleNotify();
569
+ }
570
+ return;
571
+ }
572
+ // Case 2: Object under alias.* or linkeddevices.* — only alias scan needed, enum members unchanged
573
+ if (id.startsWith(ALIAS) || id.startsWith(LINKEDDEVICES)) {
574
+ const oldIds = [...this.idsInEnums];
575
+ this.rebuildAliasIds();
576
+ const affected = this.getAffectedChannelIds(id);
577
+ const removedFromEnums = oldIds.filter(x => !this.idsInEnums.includes(x));
578
+ const addedToEnums = this.idsInEnums.filter(x => !oldIds.includes(x));
579
+ const toRemove = [...new Set([...affected, ...removedFromEnums])];
580
+ this.removeDevicesForChannelIds(toRemove);
581
+ const toDetect = [...new Set([...affected.filter(x => this.idsInEnums.includes(x)), ...addedToEnums])];
582
+ if (toDetect.length) {
583
+ this.allDevices.push(...this.detectForIds(toDetect));
584
+ }
585
+ if (this.rebuildEnabledDevices()) {
586
+ if (!this.invalidatedIds.includes(id)) {
587
+ this.invalidatedIds.push(id);
588
+ }
589
+ this.scheduleNotify();
590
+ }
591
+ return;
592
+ }
593
+ // Case 3: Only the enabled flag changed — just rebuild the filter (no detection)
594
+ const customKey = this.adapter.namespace;
595
+ const oldEnabled = oldObj?.common?.custom?.[customKey]?.enabled;
596
+ const newEnabled = obj?.common?.custom?.[customKey]?.enabled;
597
+ if (oldEnabled !== newEnabled) {
598
+ if (this.rebuildEnabledDevices()) {
599
+ if (!this.invalidatedIds.includes(id)) {
600
+ this.invalidatedIds.push(id);
601
+ }
602
+ this.scheduleNotify();
603
+ }
604
+ return;
605
+ }
606
+ // Case 4: Object referenced by idsInEnums or existing device changed
607
+ const affected = this.getAffectedChannelIds(id);
608
+ if (affected.length) {
609
+ this.removeDevicesForChannelIds(affected);
610
+ if (obj) {
611
+ this.allDevices.push(...this.detectForIds(affected));
612
+ }
613
+ if (this.rebuildEnabledDevices()) {
614
+ if (!this.invalidatedIds.includes(id)) {
615
+ this.invalidatedIds.push(id);
616
+ }
617
+ this.scheduleNotify();
618
+ }
619
+ }
620
+ }
621
+ // ── Initial full detection (called once) ──────────────────────────
622
+ async initialLoad() {
623
+ // Read all objects from DB — only done ONCE
624
+ const res = await this.adapter.getObjectListAsync({ include_docs: true });
625
+ const rows = res?.rows || [];
626
+ for (const row of rows) {
627
+ this.objects[row.doc._id] = row.doc;
628
+ }
629
+ this.invalidateKeys();
630
+ // Build idsInEnums
631
+ this.rebuildIdsInEnums();
632
+ // Detect ALL devices
633
+ this.allDevices = this.detectForIds(this.idsInEnums);
634
+ // Filter enabled
635
+ this.rebuildEnabledDevices();
636
+ // Subscribe to all object changes for incremental updates
637
+ await this.adapter.subscribeForeignObjectsAsync('*');
638
+ }
639
+ // ── loadDevices (called by dm-utils framework) ────────────────────
640
+ async loadItems() {
641
+ if (!this.loaded) {
642
+ this.loaded = this.initialLoad();
643
+ }
644
+ await this.loaded;
645
+ this.widgets = new Map();
646
+ for (const device of this.enabledDevices) {
647
+ const obj = this.objects[device.storeId];
648
+ if (!obj) {
649
+ continue;
650
+ }
651
+ const name = typeof obj.common.name === 'object'
652
+ ? obj.common.name[this.adapter.language || 'en'] || obj.common.name.en || device.storeId
653
+ : obj.common.name || device.storeId;
654
+ const icon = obj.common.icon || undefined;
655
+ const color = obj.common.color || undefined;
656
+ // delete all empty states
657
+ for (let i = device.states.length - 1; i >= 0; i--) {
658
+ // delete empty lines
659
+ if (!device.states[i].id) {
660
+ device.states.splice(i, 1);
661
+ continue;
662
+ }
663
+ // We must deliver role, as in some widgets it is used to determine the widget type
664
+ device.states[i].stateRole = this.objects[device.states[i].id]?.common?.role;
665
+ /** Remove useless for GUI information */
666
+ if (device.states[i].original) {
667
+ delete device.states[i].original;
668
+ }
669
+ if (device.states[i].role) {
670
+ delete device.states[i].role;
671
+ }
672
+ if (device.states[i].channelRole) {
673
+ delete device.states[i].channelRole;
674
+ }
675
+ if (device.states[i].ignoreRole) {
676
+ delete device.states[i].ignoreRole;
677
+ }
678
+ if (device.states[i].statesDefined) {
679
+ delete device.states[i].statesDefined;
680
+ }
681
+ if (device.states[i].searchInParent) {
682
+ delete device.states[i].searchInParent;
683
+ }
684
+ if (device.states[i].enums) {
685
+ delete device.states[i].enums;
686
+ }
687
+ if (device.states[i].noDeviceDetection) {
688
+ delete device.states[i].noDeviceDetection;
689
+ }
690
+ if (device.states[i].multiple) {
691
+ delete device.states[i].multiple;
692
+ }
693
+ if (device.states[i].stateName) {
694
+ delete device.states[i].stateName;
695
+ }
696
+ if (device.states[i].objectType) {
697
+ delete device.states[i].objectType;
698
+ }
699
+ if (device.states[i].state) {
700
+ delete device.states[i].state;
701
+ }
702
+ }
703
+ // Support of categories only in aliases.
704
+ const customData = obj.common.custom?.[this.adapter.namespace];
705
+ this.widgets.set(device.storeId, {
706
+ type: 'widget',
707
+ id: device.storeId,
708
+ name,
709
+ icon,
710
+ color,
711
+ parent: customData?.parent || device.parentId,
712
+ control: {
713
+ type: device.type,
714
+ states: device.states,
715
+ storeId: '',
716
+ parentId: '',
717
+ deviceId: '',
718
+ channelId: '',
719
+ },
720
+ custom: obj.common.custom?.[this.adapter.namespace],
721
+ });
722
+ }
723
+ let someDeleted = false;
724
+ do {
725
+ someDeleted = false;
726
+ // go through all categories and remove the empty one (except root)
727
+ this.categories?.forEach((category, id) => {
728
+ if (id === ROOT_CATEGORY) {
729
+ return;
730
+ }
731
+ let empty = true;
732
+ this.widgets?.forEach(widget => {
733
+ if (widget.parent === id) {
734
+ empty = false;
735
+ return;
736
+ }
737
+ });
738
+ this.categories?.forEach(c => {
739
+ if (c.parent === id) {
740
+ empty = false;
741
+ return;
742
+ }
743
+ });
744
+ if (empty) {
745
+ // Keep categories marked with showEmpty in custom settings,
746
+ // or that contain plugin / custom widgets.
747
+ const obj = this.objects[id];
748
+ const custom = obj?.common?.custom;
749
+ const keep = custom &&
750
+ Object.values(custom).some(c => c?.showEmpty || (Array.isArray(c?.customWidgets) && c.customWidgets.length > 0));
751
+ if (!keep) {
752
+ this.categories?.delete(id);
753
+ }
754
+ }
755
+ });
756
+ } while (someDeleted);
757
+ }
758
+ }
759
+ exports.default = DevicesWidgetsManagement;
760
+ //# sourceMappingURL=WidgetsManagement.js.map