iobroker.autodoc 0.9.35

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 (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +126 -0
  3. package/admin/autodoc.png +0 -0
  4. package/admin/i18n/de.json +244 -0
  5. package/admin/i18n/en.json +241 -0
  6. package/admin/i18n/es.json +229 -0
  7. package/admin/i18n/fr.json +235 -0
  8. package/admin/i18n/it.json +229 -0
  9. package/admin/i18n/nl.json +229 -0
  10. package/admin/i18n/pl.json +229 -0
  11. package/admin/i18n/pt.json +229 -0
  12. package/admin/i18n/ru.json +229 -0
  13. package/admin/i18n/uk.json +229 -0
  14. package/admin/i18n/zh-cn.json +229 -0
  15. package/admin/jsonConfig.json +1490 -0
  16. package/io-package.json +253 -0
  17. package/lib/adapter-config.d.ts +19 -0
  18. package/lib/aiEnhancer.js +2114 -0
  19. package/lib/autoHostTopologyMermaid.js +195 -0
  20. package/lib/dependencyAnalyzer.js +83 -0
  21. package/lib/diagnosisSnapshot.js +32 -0
  22. package/lib/discovery.js +953 -0
  23. package/lib/docTemplateConfig.js +422 -0
  24. package/lib/documentModel.js +640 -0
  25. package/lib/forumCard.js +70 -0
  26. package/lib/guestHelpContent.js +93 -0
  27. package/lib/guestScriptPrivacy.js +14 -0
  28. package/lib/hostDisplay.js +19 -0
  29. package/lib/htmlRenderer.js +4108 -0
  30. package/lib/htmlThemePresets.js +79 -0
  31. package/lib/htmlToPdf.js +99 -0
  32. package/lib/i18n.js +1309 -0
  33. package/lib/markdownRenderer.js +2025 -0
  34. package/lib/mermaidAutodocPalette.js +165 -0
  35. package/lib/mermaidServerSvg.js +252 -0
  36. package/lib/notifier.js +124 -0
  37. package/lib/quickStartGuide.js +180 -0
  38. package/lib/roleMapper.js +90 -0
  39. package/lib/scriptGroups.js +78 -0
  40. package/lib/versionTracker.js +312 -0
  41. package/main.js +1368 -0
  42. package/package.json +88 -0
@@ -0,0 +1,953 @@
1
+ const { execSync } = require('node:child_process');
2
+
3
+ /**
4
+ * State roles used for opt-in live values in onboarding (order = pick preference when several match).
5
+ * Room enums often reference a channel/device id without role; actual `level.temperature` etc. sit on child states.
6
+ */
7
+ const LIVE_STATE_ROLE_PRIORITY = [
8
+ 'level.temperature',
9
+ 'value.temperature',
10
+ 'level.blind',
11
+ 'level.dimmer',
12
+ 'switch.light',
13
+ 'switch',
14
+ 'value.brightness',
15
+ 'value.humidity',
16
+ 'value.power',
17
+ 'switch.lock',
18
+ 'sensor.door',
19
+ 'sensor.window',
20
+ 'sensor.contact',
21
+ 'sensor.motion',
22
+ 'alarm',
23
+ 'sensor.alarm',
24
+ ];
25
+ const LIVE_STATE_ROLES = new Set(LIVE_STATE_ROLE_PRIORITY);
26
+
27
+ /**
28
+ * Roles that match by prefix / pattern (ioBroker adapters vary). `pri` = tie-break bucket in LIVE_STATE_ROLE_PRIORITY.
29
+ *
30
+ */
31
+ const LIVE_STATE_ROLE_REGEX = [
32
+ { re: /^level\.blind|^blind\./, pri: 'level.blind' },
33
+ { re: /^level\.dimmer/, pri: 'level.dimmer' },
34
+ { re: /^switch\.light/, pri: 'switch.light' },
35
+ { re: /^switch$|^switch\.plug|^switch\.socket/, pri: 'switch' },
36
+ { re: /^value\.brightness/, pri: 'value.brightness' },
37
+ { re: /^value\.(power|current|voltage)/, pri: 'value.power' },
38
+ { re: /^switch\.lock|^lock\./, pri: 'switch.lock' },
39
+ ];
40
+
41
+ /**
42
+ * @param {string} role - ioBroker state role
43
+ * @returns {boolean} whether live values may be read for this role
44
+ */
45
+ function isReadableLiveRole(role) {
46
+ if (!role) {
47
+ return false;
48
+ }
49
+ if (LIVE_STATE_ROLES.has(role)) {
50
+ return true;
51
+ }
52
+ return LIVE_STATE_ROLE_REGEX.some(x => x.re.test(role));
53
+ }
54
+
55
+ /**
56
+ * @param {string} role - ioBroker state role
57
+ * @returns {number} sort key for display ordering (lower first)
58
+ */
59
+ function liveRoleSortIndex(role) {
60
+ const idx = LIVE_STATE_ROLE_PRIORITY.indexOf(role);
61
+ if (idx !== -1) {
62
+ return idx;
63
+ }
64
+ for (const x of LIVE_STATE_ROLE_REGEX) {
65
+ if (x.re.test(role)) {
66
+ return LIVE_STATE_ROLE_PRIORITY.indexOf(x.pri);
67
+ }
68
+ }
69
+ return 999;
70
+ }
71
+
72
+ /**
73
+ * AutoDoc Discovery Module
74
+ * Handles automatic discovery of adapter instances, hosts, and system metadata
75
+ */
76
+ class Discovery {
77
+ /**
78
+ * @param {object} adapter ioBroker adapter instance
79
+ */
80
+ constructor(adapter) {
81
+ this.adapter = adapter;
82
+ }
83
+
84
+ /**
85
+ * Resolve a multilingual ioBroker string to a plain string.
86
+ * common.desc and common.titleLang can be either a plain string
87
+ * or an object like { en: "...", de: "..." }.
88
+ *
89
+ * @param {string|object} value The raw value from common
90
+ * @param {string} lang Preferred language code
91
+ * @returns {string} Resolved string or empty string
92
+ */
93
+ resolveI18nString(value, lang) {
94
+ if (!value) {
95
+ return '';
96
+ }
97
+ if (typeof value === 'string') {
98
+ return value;
99
+ }
100
+ if (typeof value === 'object') {
101
+ return value[lang] || value.en || Object.values(value)[0] || '';
102
+ }
103
+ return '';
104
+ }
105
+
106
+ /**
107
+ * Read all adapter instances from the system
108
+ *
109
+ * @returns {Promise<Array>} Array of adapter instance objects
110
+ */
111
+ async readAdapterInstances() {
112
+ try {
113
+ const instances = await this.adapter.getObjectViewAsync('system', 'instance', {});
114
+ const lang = this.adapter.config.language || 'en';
115
+ const result = [];
116
+
117
+ for (const obj of instances.rows) {
118
+ const instance = obj.value;
119
+ // Extract adapter name from common.name (e.g. "admin") or from _id (e.g. "system.adapter.admin.0" → "admin")
120
+ const adapterName = instance.common.name || instance._id.split('.')[2] || instance._id;
121
+
122
+ // Skip our own adapter instance
123
+ if (adapterName === 'autodoc') {
124
+ continue;
125
+ }
126
+
127
+ const scheduleCron = (instance.common && instance.common.schedule) || '';
128
+ const restartSchedule = (instance.common && instance.common.restartSchedule) || '';
129
+
130
+ result.push({
131
+ id: instance._id,
132
+ name: instance.common.name,
133
+ adapter: adapterName,
134
+ title:
135
+ this.resolveI18nString(instance.common.titleLang || instance.common.title, lang) || adapterName,
136
+ desc: this.resolveI18nString(instance.common.desc, lang),
137
+ enabled: instance.common.enabled,
138
+ host: instance.common.host,
139
+ mode: instance.common.mode,
140
+ /** ioBroker: CRON when common.mode === 'schedule' (elsewhere: state instanceId.schedule) */
141
+ scheduleCron,
142
+ /** ioBroker: optional CRON to restart daemon adapters */
143
+ restartSchedule,
144
+ version: instance.common.version,
145
+ config: this.filterNative(instance.native),
146
+ connectionType: instance.common.connectionType || '',
147
+ dataSource: instance.common.dataSource || '',
148
+ tier: instance.common.tier || 0,
149
+ type: instance.common.type || '',
150
+ });
151
+ }
152
+
153
+ // If mode is schedule but common.schedule is empty, try the companion state (some setups keep CRON only there).
154
+ const needScheduleFromState = result.filter(
155
+ i => i.mode === 'schedule' && (!i.scheduleCron || !String(i.scheduleCron).trim()),
156
+ );
157
+ if (needScheduleFromState.length > 0) {
158
+ await Promise.all(
159
+ needScheduleFromState.map(async inst => {
160
+ try {
161
+ const st = await this.adapter.getForeignStateAsync(`${inst.id}.schedule`);
162
+ if (st && st.val != null && String(st.val).trim()) {
163
+ inst.scheduleCron = String(st.val).trim();
164
+ }
165
+ } catch {
166
+ // ignore
167
+ }
168
+ }),
169
+ );
170
+ }
171
+
172
+ return result;
173
+ } catch (error) {
174
+ this.adapter.log.error(`Error reading adapter instances: ${error.message}`);
175
+ return [];
176
+ }
177
+ }
178
+
179
+ /**
180
+ * True when the object DB’s Couch-style design `system` defines a `schedule` search.
181
+ * Calling getObjectView without this triggers a controller/objects log on many installs (view added in newer js-controller).
182
+ *
183
+ * @returns {Promise<boolean>} whether the `schedule` object view is available
184
+ */
185
+ async systemDesignHasScheduleView() {
186
+ try {
187
+ const d = await this.adapter.getForeignObjectAsync('_design/system');
188
+ if (!d || typeof d !== 'object') {
189
+ return false;
190
+ }
191
+ const views = d.views || (d.native && d.native.views);
192
+ return !!(views && typeof views === 'object' && views.schedule);
193
+ } catch {
194
+ return false;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Objects of ioBroker type "schedule" (design system / search "schedule"), if the object DB exposes this view.
200
+ * Separate from JavaScript scripts and from adapter instances in mode "schedule".
201
+ *
202
+ * @returns {Promise<Array<{id: string, name: string, desc: string, enabled: boolean}>>} schedule objects from the object DB
203
+ */
204
+ async readScheduleDesignObjects() {
205
+ try {
206
+ if (!(await this.systemDesignHasScheduleView())) {
207
+ return [];
208
+ }
209
+ const res = await this.adapter.getObjectViewAsync('system', 'schedule', {});
210
+ if (!res || !Array.isArray(res.rows) || res.rows.length === 0) {
211
+ return [];
212
+ }
213
+ const lang = this.adapter.config.language || 'en';
214
+ const out = [];
215
+ for (const row of res.rows) {
216
+ const o = row.value;
217
+ const id = (o && o._id) || row.id;
218
+ if (!id) {
219
+ continue;
220
+ }
221
+ const name =
222
+ (o && o.common && (o.common.name || o.common.title)) || id.split('.').filter(Boolean).pop() || id;
223
+ const descRaw = o && o.common && o.common.desc;
224
+ const desc = this.resolveI18nString(descRaw, lang);
225
+ const enabled = !o || !o.common || o.common.enabled !== false;
226
+ out.push({ id, name: String(name), desc, enabled });
227
+ }
228
+ if (out.length > 0) {
229
+ this.adapter.log.debug(
230
+ `readScheduleDesignObjects: ${out.length} row(s) from getObjectView(system, schedule)`,
231
+ );
232
+ }
233
+ return out;
234
+ } catch (e) {
235
+ this.adapter.log.debug(`getObjectView(system, schedule) not available or empty: ${e.message}`);
236
+ return [];
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Filter native config object: remove sensitive keys, keep only scalar values.
242
+ *
243
+ * @param {object} native Raw native config from instance
244
+ * @returns {object} Filtered config object
245
+ */
246
+ filterNative(native) {
247
+ if (!native || typeof native !== 'object') {
248
+ return {};
249
+ }
250
+ const SENSITIVE = /password|passwd|token|secret|apikey|api_key|pass|key|auth|credential/i;
251
+ const result = {};
252
+ for (const [k, v] of Object.entries(native)) {
253
+ if (SENSITIVE.test(k)) {
254
+ continue;
255
+ }
256
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
257
+ result[k] = v;
258
+ }
259
+ }
260
+ return result;
261
+ }
262
+
263
+ /**
264
+ * Read state objects summary
265
+ *
266
+ * @returns {Promise<object>} State objects statistics
267
+ */
268
+ async readStateObjectsSummary() {
269
+ try {
270
+ const states = await this.adapter.getObjectViewAsync('system', 'state', {});
271
+ let total = 0;
272
+ let writable = 0;
273
+ let readonly = 0;
274
+
275
+ for (const obj of states.rows) {
276
+ const state = obj.value;
277
+ total++;
278
+
279
+ if (state.common.write) {
280
+ writable++;
281
+ } else {
282
+ readonly++;
283
+ }
284
+ }
285
+
286
+ return {
287
+ total,
288
+ writable,
289
+ readonly,
290
+ };
291
+ } catch (error) {
292
+ this.adapter.log.error(`Error reading state objects: ${error.message}`);
293
+ return { total: 0, writable: 0, readonly: 0 };
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Read host information
299
+ *
300
+ * @returns {Promise<Array>} Array of host objects
301
+ */
302
+ async readHosts() {
303
+ try {
304
+ const hosts = await this.adapter.getObjectViewAsync('system', 'host', {});
305
+ const result = [];
306
+ let localNpmProbed = false;
307
+ let localNpmVersion = '';
308
+
309
+ const probeLocalNpmOnce = () => {
310
+ if (localNpmProbed) {
311
+ return localNpmVersion;
312
+ }
313
+ localNpmProbed = true;
314
+ try {
315
+ localNpmVersion = execSync('npm -v', {
316
+ encoding: 'utf8',
317
+ windowsHide: true,
318
+ timeout: 8000,
319
+ }).trim();
320
+ } catch {
321
+ localNpmVersion = '';
322
+ }
323
+ return localNpmVersion;
324
+ };
325
+
326
+ const adapterHost = this.adapter.host || '';
327
+
328
+ for (const obj of hosts.rows) {
329
+ const host = obj.value;
330
+ // getObjectViewAsync may return a stripped native — fetch the full object
331
+ let fullNative = host.native || {};
332
+ try {
333
+ const full = await this.adapter.getForeignObjectAsync(host._id);
334
+ if (full && full.native) {
335
+ fullNative = full.native;
336
+ }
337
+ } catch {
338
+ // fall back to whatever getObjectViewAsync returned
339
+ }
340
+ const osInfo = fullNative.os || {};
341
+ const processInfo = fullNative.process || {};
342
+ const nodeVersion =
343
+ processInfo.version ||
344
+ (processInfo.versions && processInfo.versions.node ? `v${processInfo.versions.node}` : '') ||
345
+ host.common.nodeVersion ||
346
+ '';
347
+ const versions =
348
+ processInfo.versions && typeof processInfo.versions === 'object' ? processInfo.versions : {};
349
+ let npmVersion =
350
+ (versions.npm && String(versions.npm)) ||
351
+ (host.common.npmVersion && String(host.common.npmVersion)) ||
352
+ '';
353
+ const hostMatchesThisMachine =
354
+ (host.common.hostname && host.common.hostname === adapterHost) ||
355
+ (host.common.name && host.common.name === adapterHost);
356
+ // Probe locally if: host matches, OR only one host exists (must be this machine)
357
+ if (!npmVersion && (hostMatchesThisMachine || hosts.rows.length === 1)) {
358
+ npmVersion = probeLocalNpmOnce();
359
+ }
360
+ result.push({
361
+ id: host._id,
362
+ name: host.common.name,
363
+ hostname: host.common.hostname,
364
+ platform: host.common.platform,
365
+ type: host.common.type,
366
+ version: host.common.installedVersion,
367
+ nodeVersion,
368
+ npmVersion,
369
+ osRelease: osInfo.release || '',
370
+ osArch: osInfo.arch || '',
371
+ osType: osInfo.type || osInfo.platform || '',
372
+ });
373
+ }
374
+
375
+ return result;
376
+ } catch (error) {
377
+ this.adapter.log.error(`Error reading hosts: ${error.message}`);
378
+ return [];
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Read rooms (enum.rooms) with their assigned member IDs
384
+ *
385
+ * @returns {Promise<Array>} Array of room objects
386
+ */
387
+ async readRooms() {
388
+ try {
389
+ const enums = await this.adapter.getObjectViewAsync('system', 'enum', {
390
+ startkey: 'enum.rooms.',
391
+ endkey: 'enum.rooms.\u9999',
392
+ });
393
+ const lang = this.adapter.config.language || 'en';
394
+ const result = [];
395
+
396
+ for (const obj of enums.rows) {
397
+ const room = obj.value;
398
+ result.push({
399
+ id: room._id,
400
+ name: this.resolveI18nString(room.common.name, lang),
401
+ members: room.common.members || [],
402
+ });
403
+ }
404
+
405
+ return result;
406
+ } catch (error) {
407
+ this.adapter.log.error(`Error reading rooms: ${error.message}`);
408
+ return [];
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Read functions (enum.functions) with their assigned member IDs
414
+ *
415
+ * @returns {Promise<Array>} Array of function objects
416
+ */
417
+ async readFunctions() {
418
+ try {
419
+ const enums = await this.adapter.getObjectViewAsync('system', 'enum', {
420
+ startkey: 'enum.functions.',
421
+ endkey: 'enum.functions.\u9999',
422
+ });
423
+ const lang = this.adapter.config.language || 'en';
424
+ const result = [];
425
+
426
+ for (const obj of enums.rows) {
427
+ const fn = obj.value;
428
+ result.push({
429
+ id: fn._id,
430
+ name: this.resolveI18nString(fn.common.name, lang),
431
+ members: fn.common.members || [],
432
+ });
433
+ }
434
+
435
+ return result;
436
+ } catch (error) {
437
+ this.adapter.log.error(`Error reading functions: ${error.message}`);
438
+ return [];
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Read system.config for location and language settings
444
+ *
445
+ * @returns {Promise<object>} System config subset
446
+ */
447
+ async readSystemConfig() {
448
+ try {
449
+ const obj = await this.adapter.getForeignObjectAsync('system.config');
450
+ if (!obj || !obj.common) {
451
+ return {};
452
+ }
453
+ // activeRepo can be a string ("stable") or array (["stable","beta"]) in newer js-controller
454
+ const rawRepo = obj.common.activeRepo;
455
+ const activeRepo = Array.isArray(rawRepo) ? rawRepo.join(', ') : typeof rawRepo === 'string' ? rawRepo : '';
456
+
457
+ return {
458
+ city: obj.common.city || '',
459
+ country: obj.common.country || '',
460
+ language: obj.common.language || 'en',
461
+ latitude: obj.common.latitude || null,
462
+ longitude: obj.common.longitude || null,
463
+ timezone: obj.common.timezone || '',
464
+ activeRepo,
465
+ };
466
+ } catch (e) {
467
+ this.adapter.log.warn(`Could not read system.config: ${e.message}`);
468
+ return {};
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Read live resource states for all hosts (RAM, CPU, uptime).
474
+ *
475
+ * @param {Array} hosts Host objects from readHosts()
476
+ * @returns {Promise<object>} Map of hostName → { totalMem, freeMem, cpu, uptime }
477
+ */
478
+ async readHostResources(hosts) {
479
+ const result = {};
480
+ for (const host of hosts) {
481
+ const hostId = host.id.replace('system.host.', '');
482
+ try {
483
+ const [freemem, totalmem, memRss, memHeapUsed, cpu, uptime] = await Promise.all([
484
+ this.adapter.getForeignStateAsync(`system.host.${hostId}.freemem`).catch(() => null),
485
+ this.adapter.getForeignStateAsync(`system.host.${hostId}.totalmem`).catch(() => null),
486
+ this.adapter.getForeignStateAsync(`system.host.${hostId}.memRss`).catch(() => null),
487
+ this.adapter.getForeignStateAsync(`system.host.${hostId}.memHeapUsed`).catch(() => null),
488
+ this.adapter.getForeignStateAsync(`system.host.${hostId}.cpu`).catch(() => null),
489
+ this.adapter.getForeignStateAsync(`system.host.${hostId}.uptime`).catch(() => null),
490
+ ]);
491
+
492
+ // freemem/totalmem/memRss/memHeapUsed are all in MB in ioBroker JS-controller
493
+ const sysFreeMb =
494
+ freemem && freemem.val !== null && freemem.val !== undefined ? Number(freemem.val) : null;
495
+ const sysTotalMb =
496
+ totalmem && totalmem.val !== null && totalmem.val !== undefined && totalmem.val > 0
497
+ ? Number(totalmem.val)
498
+ : null;
499
+ // js-controller process RSS — MB, safety check for byte values
500
+ const rawProcMb =
501
+ memRss && memRss.val > 0 ? memRss.val : memHeapUsed && memHeapUsed.val > 0 ? memHeapUsed.val : null;
502
+ const procMb =
503
+ rawProcMb !== null
504
+ ? rawProcMb > 100000
505
+ ? Math.round(rawProcMb / 1048576)
506
+ : Math.round(rawProcMb)
507
+ : null;
508
+ this.adapter.log.debug(
509
+ `Host ${hostId} resources: freemem=${sysFreeMb} totalmem=${sysTotalMb} procMb=${procMb} cpu=${cpu && cpu.val}`,
510
+ );
511
+
512
+ // Sum memRss of all running adapter instances on this host
513
+ let adapterTotalMb = null;
514
+ try {
515
+ const adapterObjs = await this.adapter
516
+ .getForeignObjectsAsync(`system.adapter.*.*.memRss`, 'state')
517
+ .catch(() => null);
518
+ if (adapterObjs) {
519
+ const ids = Object.keys(adapterObjs);
520
+ let sum = 0;
521
+ let count = 0;
522
+ for (const id of ids) {
523
+ try {
524
+ const s = await this.adapter.getForeignStateAsync(id).catch(() => null);
525
+ if (s && s.val !== null && s.val !== undefined && Number(s.val) > 0) {
526
+ const mb =
527
+ Number(s.val) > 100000
528
+ ? Math.round(Number(s.val) / 1048576)
529
+ : Math.round(Number(s.val));
530
+ sum += mb;
531
+ count++;
532
+ }
533
+ } catch {
534
+ /* ignore individual state errors */
535
+ }
536
+ }
537
+ if (count > 0) {
538
+ adapterTotalMb = sum;
539
+ }
540
+ }
541
+ } catch {
542
+ /* adapter memory sum optional */
543
+ }
544
+
545
+ result[host.name] = {
546
+ sysFreeMb,
547
+ sysTotalMb,
548
+ procMb, // js-controller process RAM
549
+ adapterTotalMb, // sum of all adapter instance RSS (incl. js-controller)
550
+ cpu: cpu && cpu.val !== null && cpu.val !== undefined ? Number(cpu.val) : null,
551
+ uptime: uptime && uptime.val ? uptime.val : null,
552
+ };
553
+ } catch {
554
+ result[host.name] = {};
555
+ }
556
+ }
557
+ return result;
558
+ }
559
+
560
+ /**
561
+ * Read user-defined variables from the 0_userdata.0 namespace.
562
+ * Groups them by folder. Skips objects without common.name.
563
+ *
564
+ * @returns {Promise<Array>} Array of { id, name, folder, type, unit, desc, value }
565
+ */
566
+ async readUserData() {
567
+ try {
568
+ const objs = await this.adapter.getForeignObjectsAsync('0_userdata.0.*', 'state');
569
+ if (!objs) {
570
+ return [];
571
+ }
572
+ const result = [];
573
+ const lang = this.adapter.config.language || 'en';
574
+
575
+ // Also try to read current values for context
576
+ const ids = Object.keys(objs);
577
+ const states = {};
578
+ // Read in batches of 50 to avoid overload
579
+ for (let i = 0; i < ids.length; i += 50) {
580
+ const batch = ids.slice(i, i + 50);
581
+ try {
582
+ const batchStates = await this.adapter.getForeignStatesAsync(batch.join(','));
583
+ Object.assign(states, batchStates || {});
584
+ } catch {
585
+ // ignore batch errors
586
+ }
587
+ }
588
+
589
+ for (const [id, obj] of Object.entries(objs)) {
590
+ if (!obj || !obj.common) {
591
+ continue;
592
+ }
593
+ const nameParts = id.replace('0_userdata.0.', '').split('.');
594
+ const name = this.resolveI18nString(obj.common.name, lang) || nameParts[nameParts.length - 1];
595
+ const folder = nameParts.length > 1 ? nameParts.slice(0, -1).join('/') : null;
596
+ const stateVal = states[id];
597
+ result.push({
598
+ id,
599
+ name,
600
+ folder,
601
+ type: obj.common.type || 'mixed',
602
+ unit: obj.common.unit || '',
603
+ desc: this.resolveI18nString(obj.common.desc, lang) || '',
604
+ role: obj.common.role || '',
605
+ value: stateVal && stateVal.val !== undefined ? stateVal.val : null,
606
+ lastChange: stateVal && stateVal.ts ? new Date(stateVal.ts).toISOString() : null,
607
+ });
608
+ }
609
+ // Sort by folder then name
610
+ result.sort((a, b) => {
611
+ const fa = a.folder || '';
612
+ const fb = b.folder || '';
613
+ if (fa !== fb) {
614
+ return fa.localeCompare(fb);
615
+ }
616
+ return a.name.localeCompare(b.name);
617
+ });
618
+ return result;
619
+ } catch (e) {
620
+ this.adapter.log.debug(`Could not read 0_userdata.0: ${e.message}`);
621
+ return [];
622
+ }
623
+ }
624
+
625
+ /**
626
+ * Read alias datapoints from the alias.0 namespace.
627
+ * Extracts name, folder, type, and the read/write target IDs.
628
+ *
629
+ * @returns {Promise<Array>} Array of { id, name, folder, type, readTarget, writeTarget, desc }
630
+ */
631
+ async readAliases() {
632
+ try {
633
+ const objs = await this.adapter.getForeignObjectsAsync('alias.0.*', 'state');
634
+ if (!objs) {
635
+ return [];
636
+ }
637
+ const result = [];
638
+ const lang = this.adapter.config.language || 'en';
639
+
640
+ for (const [id, obj] of Object.entries(objs)) {
641
+ if (!obj || !obj.common) {
642
+ continue;
643
+ }
644
+ const nameParts = id.replace('alias.0.', '').split('.');
645
+ const name = this.resolveI18nString(obj.common.name, lang) || nameParts[nameParts.length - 1];
646
+ const folder = nameParts.length > 1 ? nameParts.slice(0, -1).join('/') : null;
647
+
648
+ // common.alias.id can be a string or { read, write }
649
+ let readTarget = null;
650
+ let writeTarget = null;
651
+ if (obj.common.alias) {
652
+ const aliasId = obj.common.alias.id;
653
+ if (typeof aliasId === 'string') {
654
+ readTarget = aliasId;
655
+ writeTarget = aliasId;
656
+ } else if (aliasId && typeof aliasId === 'object') {
657
+ readTarget = aliasId.read || null;
658
+ writeTarget = aliasId.write || null;
659
+ }
660
+ }
661
+
662
+ result.push({
663
+ id,
664
+ name,
665
+ folder,
666
+ type: obj.common.type || '—',
667
+ unit: obj.common.unit || '',
668
+ desc: this.resolveI18nString(obj.common.desc, lang) || '',
669
+ role: obj.common.role || '',
670
+ readTarget,
671
+ writeTarget,
672
+ });
673
+ }
674
+
675
+ result.sort((a, b) => {
676
+ const fa = a.folder || '';
677
+ const fb = b.folder || '';
678
+ if (fa !== fb) {
679
+ return fa.localeCompare(fb);
680
+ }
681
+ return a.name.localeCompare(b.name);
682
+ });
683
+ return result;
684
+ } catch (e) {
685
+ this.adapter.log.debug(`Could not read alias.0: ${e.message}`);
686
+ return [];
687
+ }
688
+ }
689
+
690
+ /**
691
+ * Resolve member objects for all rooms and return a map of memberId → device info
692
+ *
693
+ * @param {Array} rooms Array of room objects from readRooms()
694
+ * @returns {Promise<object>} Map of memberId → { deviceId, deviceName, role, type, unit }
695
+ */
696
+ async resolveRoomDevices(rooms) {
697
+ // collect unique member IDs
698
+ const allMembers = new Set();
699
+ for (const room of rooms) {
700
+ for (const memberId of room.members) {
701
+ allMembers.add(memberId);
702
+ }
703
+ }
704
+
705
+ const result = {};
706
+ for (const memberId of allMembers) {
707
+ try {
708
+ // Try the member object itself first
709
+ const obj = await this.adapter.getForeignObjectAsync(memberId);
710
+ if (obj && obj.common) {
711
+ const name =
712
+ this.resolveI18nString(obj.common.name, this.adapter.config.language || 'en') ||
713
+ memberId.split('.').pop();
714
+ result[memberId] = {
715
+ deviceId: memberId,
716
+ deviceName: name,
717
+ role: obj.common.role || '',
718
+ type: obj.common.type || '',
719
+ unit: obj.common.unit || '',
720
+ };
721
+ }
722
+ } catch {
723
+ // silently skip unresolvable members
724
+ }
725
+ }
726
+ return result;
727
+ }
728
+
729
+ /**
730
+ * Find a state id under a room member (channel/device/state) that carries a live-relevant role.
731
+ *
732
+ * @param {string} memberId Room enum member id
733
+ * @param {{ role?: string }} device Entry from resolveRoomDevices()
734
+ * @returns {Promise<string|null>} State id to pass to getForeignStateAsync, or null
735
+ */
736
+ async resolveLiveDatapointForRoomMember(memberId, device) {
737
+ const role = device && device.role ? device.role : '';
738
+ if (isReadableLiveRole(role)) {
739
+ return memberId;
740
+ }
741
+ try {
742
+ const res = await this.adapter.getObjectViewAsync('system', 'state', {
743
+ startkey: `${memberId}.`,
744
+ endkey: `${memberId}.\u9999`,
745
+ });
746
+ const candidates = [];
747
+ for (const row of res.rows || []) {
748
+ const o = row.value;
749
+ if (!o || o.type !== 'state') {
750
+ continue;
751
+ }
752
+ const id = row.id || o._id;
753
+ const r = o.common && o.common.role ? o.common.role : '';
754
+ if (!id || !isReadableLiveRole(r)) {
755
+ continue;
756
+ }
757
+ candidates.push({ id, role: r });
758
+ }
759
+ if (candidates.length === 0) {
760
+ return null;
761
+ }
762
+ candidates.sort((a, b) => liveRoleSortIndex(a.role) - liveRoleSortIndex(b.role));
763
+ return candidates[0].id;
764
+ } catch (e) {
765
+ this.adapter.log.debug(`resolveLiveDatapointForRoomMember(${memberId}): ${e.message}`);
766
+ return null;
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Optionally read live state values for key roles (opt-in via config.readLiveStates)
772
+ *
773
+ * @param {object} deviceMap Map from resolveRoomDevices()
774
+ * @returns {Promise<object>} Map of memberId → { val, ts }
775
+ */
776
+ async readLiveStates(deviceMap) {
777
+ if (!this.adapter.config.readLiveStates) {
778
+ return {};
779
+ }
780
+ const result = {};
781
+ const resolvedCache = new Map();
782
+
783
+ for (const [memberId, device] of Object.entries(deviceMap)) {
784
+ try {
785
+ if (!resolvedCache.has(memberId)) {
786
+ const sid = await this.resolveLiveDatapointForRoomMember(memberId, device);
787
+ resolvedCache.set(memberId, sid || null);
788
+ }
789
+ const stateId = resolvedCache.get(memberId);
790
+ if (!stateId) {
791
+ continue;
792
+ }
793
+ const state = await this.adapter.getForeignStateAsync(stateId);
794
+ if (state !== null && state !== undefined) {
795
+ result[memberId] = { val: state.val, ts: state.ts };
796
+ }
797
+ } catch {
798
+ // skip
799
+ }
800
+ }
801
+
802
+ const n = Object.keys(result).length;
803
+ if (n > 0) {
804
+ this.adapter.log.debug(`readLiveStates: ${n} room member(s) with live values (opt-in)`);
805
+ }
806
+ return result;
807
+ }
808
+
809
+ /**
810
+ * Read scripts from script.js.* namespace
811
+ *
812
+ * @returns {Promise<Array>} Array of script objects
813
+ */
814
+ async readScripts() {
815
+ try {
816
+ const scripts = await this.adapter.getObjectViewAsync('script', 'javascript', {});
817
+ const result = [];
818
+
819
+ for (const obj of scripts.rows) {
820
+ const script = obj.value;
821
+ if (!script || !script._id) {
822
+ continue;
823
+ }
824
+
825
+ // Derive a readable name from the object id: script.js.Folder.MyScript → Folder / MyScript
826
+ const idParts = script._id.replace('script.js.', '').split('.');
827
+ const name = script.common.name || idParts[idParts.length - 1] || script._id;
828
+ const folder = idParts.length > 1 ? idParts.slice(0, -1).join('/') : null;
829
+
830
+ result.push({
831
+ id: script._id,
832
+ name,
833
+ folder,
834
+ enabled: script.common.enabled !== false,
835
+ engineType: script.common.engineType || 'Javascript/js',
836
+ /** e.g. javascript.0 — which script engine runs this script */
837
+ engine: script.common.engine || '',
838
+ schedule: script.common.schedule || '',
839
+ /** ioBroker object schema: optional "group purpose description", not a required per-script user manual */
840
+ desc: script.common.desc || '',
841
+ source: script.common.source || '',
842
+ });
843
+ }
844
+
845
+ return result;
846
+ } catch (error) {
847
+ this.adapter.log.warn(`Error reading scripts (script adapter may not be installed): ${error.message}`);
848
+ return [];
849
+ }
850
+ }
851
+
852
+ /**
853
+ * Check how many adapters have updates available.
854
+ * Reads system.adapter.<name> objects and compares installedVersion to version from npm (if available).
855
+ * Fallback: counts adapters where common.installedVersion differs from common.version.
856
+ *
857
+ * @param {Array} instances Adapter instances already discovered
858
+ * @returns {Promise<number>} Count of adapters with pending updates
859
+ */
860
+ async readPendingUpdates(instances) {
861
+ try {
862
+ const adapterNames = [...new Set(instances.map(i => i.adapter))];
863
+ let updateCount = 0;
864
+ for (const name of adapterNames) {
865
+ try {
866
+ const obj = await this.adapter.getForeignObjectAsync(`system.adapter.${name}`);
867
+ if (obj && obj.common) {
868
+ const installed = obj.common.installedVersion || obj.common.version || '';
869
+ const available = obj.common.latestVersion || '';
870
+ if (available && installed && available !== installed) {
871
+ updateCount++;
872
+ }
873
+ }
874
+ } catch {
875
+ // ignore per-adapter errors
876
+ }
877
+ }
878
+ return updateCount;
879
+ } catch (e) {
880
+ this.adapter.log.debug(`Could not read pending updates: ${e.message}`);
881
+ return 0;
882
+ }
883
+ }
884
+
885
+ /**
886
+ * Try to read BackItUp last backup timestamp.
887
+ *
888
+ * @returns {Promise<string|null>} ISO string of last backup or null
889
+ */
890
+ async readLastBackup() {
891
+ try {
892
+ const state = await this.adapter.getForeignStateAsync('backitup.0.info.lastBackup');
893
+ if (state && state.val) {
894
+ return String(state.val);
895
+ }
896
+ } catch {
897
+ // BackItUp may not be installed
898
+ }
899
+ return null;
900
+ }
901
+
902
+ /**
903
+ * Collect all raw system data
904
+ *
905
+ * @returns {Promise<object>} Raw system data
906
+ */
907
+ async collectRawData() {
908
+ const [instances, stateSummary, hosts, rooms, functions, scripts, systemConfig] = await Promise.all([
909
+ this.readAdapterInstances(),
910
+ this.readStateObjectsSummary(),
911
+ this.readHosts(),
912
+ this.readRooms(),
913
+ this.readFunctions(),
914
+ this.readScripts(),
915
+ this.readSystemConfig(),
916
+ ]);
917
+
918
+ // Device resolution and live states depend on rooms → sequential
919
+ const deviceMap = await this.resolveRoomDevices(rooms);
920
+ const liveStates = await this.readLiveStates(deviceMap);
921
+
922
+ // Optional extras — soft failures allowed, all in parallel
923
+ const [pendingUpdates, lastBackup, hostResources, userData, aliases, scheduleObjects] = await Promise.all([
924
+ this.readPendingUpdates(instances),
925
+ this.readLastBackup(),
926
+ this.readHostResources(hosts),
927
+ this.readUserData(),
928
+ this.readAliases(),
929
+ this.readScheduleDesignObjects(),
930
+ ]);
931
+
932
+ return {
933
+ instances,
934
+ stateSummary,
935
+ hosts,
936
+ rooms,
937
+ functions,
938
+ scripts,
939
+ systemConfig,
940
+ deviceMap,
941
+ liveStates,
942
+ pendingUpdates,
943
+ lastBackup,
944
+ hostResources,
945
+ userData,
946
+ aliases,
947
+ scheduleObjects,
948
+ collectedAt: new Date().toISOString(),
949
+ };
950
+ }
951
+ }
952
+
953
+ module.exports = Discovery;