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.
- package/README.md +23 -2
- package/admin/assets/hostInit-LzITLWDa.js +1 -0
- package/admin/assets/index-By05vlt_.js +2783 -0
- package/admin/assets/index-D3eYsCtw.js +1 -0
- package/admin/assets/localSharedImportMap-CoMzwmdv.js +1 -0
- package/admin/assets/preload-helper-BDBacUwf.js +1 -0
- package/admin/assets/virtualExposes-BIHI7g3E.js +1 -0
- package/admin/assets/virtual_mf-REMOTE_ENTRY_ID_iobroker_devices__remoteEntry_js-D5_qLLYC.js +8 -0
- package/admin/index_m.html +5 -1
- package/admin/manifest.json +13 -13
- package/admin/mf-manifest.json +1 -0
- package/admin/remoteEntry.js +1 -0
- package/admin/tab.html +5 -1
- package/admin/vendor/socket.io.js +3446 -4
- package/build/lib/WidgetsManagement.js +159 -26
- package/build/lib/WidgetsManagement.js.map +1 -1
- package/build/packages/dm-widgets/src/StateContext.js +64 -0
- package/build/packages/dm-widgets/src/StateContext.js.map +1 -0
- package/build/packages/dm-widgets/src/WidgetGeneric.js +223 -0
- package/build/packages/dm-widgets/src/WidgetGeneric.js.map +1 -0
- package/build/packages/dm-widgets/src/index.js +99 -0
- package/build/packages/dm-widgets/src/index.js.map +1 -0
- package/build/packages/dm-widgets/src/types.js +3 -0
- package/build/packages/dm-widgets/src/types.js.map +1 -0
- package/build/src/lib/WidgetsManagement.js +760 -0
- package/build/src/lib/WidgetsManagement.js.map +1 -0
- package/build/src/main.js +53 -0
- package/build/src/main.js.map +1 -0
- package/build/src/widget-utils/WidgetsManagement.js +119 -0
- package/build/src/widget-utils/WidgetsManagement.js.map +1 -0
- package/build/src/widget-utils/index.js +18 -0
- package/build/src/widget-utils/index.js.map +1 -0
- package/build/src/widget-utils/types/api.js +3 -0
- package/build/src/widget-utils/types/api.js.map +1 -0
- package/build/src/widget-utils/types/base.js +3 -0
- package/build/src/widget-utils/types/base.js.map +1 -0
- package/build/src/widget-utils/types/index.js +3 -0
- package/build/src/widget-utils/types/index.js.map +1 -0
- package/build/widget-utils/WidgetsManagement.js +0 -2
- package/build/widget-utils/WidgetsManagement.js.map +1 -1
- package/io-package.json +25 -90
- package/package.json +9 -6
- package/www/assets/index-7xkEJSKC.js +1 -0
- package/www/assets/index-BoZ6w1gP.js +2815 -0
- package/www/index.html +2 -1
- package/admin/assets/index-BaeInhLO.js +0 -845
- 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
|