iobroker.sun2000 2.4.2 → 2.4.3
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 +3 -260
- package/admin/i18n/de/translations.json +27 -27
- package/admin/i18n/es/translations.json +27 -27
- package/admin/i18n/fr/translations.json +27 -27
- package/admin/i18n/it/translations.json +27 -27
- package/admin/i18n/nl/translations.json +27 -27
- package/admin/i18n/pl/translations.json +27 -27
- package/admin/i18n/pt/translations.json +27 -27
- package/admin/i18n/ru/translations.json +27 -27
- package/admin/i18n/uk/translations.json +27 -27
- package/admin/i18n/zh-cn/translations.json +27 -27
- package/io-package.json +14 -14
- package/lib/modbus/modbus_server.js +27 -4
- package/lib/register.js +3 -0
- package/lib/statistics.js +576 -217
- package/lib/types.js +1 -0
- package/main.js +80 -0
- package/package.json +1 -1
package/lib/statistics.js
CHANGED
|
@@ -25,6 +25,7 @@ class statistics {
|
|
|
25
25
|
this.stateCache = stateCache;
|
|
26
26
|
this.taskTimer = null;
|
|
27
27
|
this._path = 'statistics';
|
|
28
|
+
this._initialized = false;
|
|
28
29
|
this.testing = false; // set to true for testing purposes
|
|
29
30
|
|
|
30
31
|
this.stats = [
|
|
@@ -32,7 +33,7 @@ class statistics {
|
|
|
32
33
|
sourceId: 'collected.consumptionToday',
|
|
33
34
|
targetPath: 'consumption',
|
|
34
35
|
unit: 'kWh',
|
|
35
|
-
type: statisticsType.deltaReset,
|
|
36
|
+
type: statisticsType.deltaReset,
|
|
36
37
|
},
|
|
37
38
|
{
|
|
38
39
|
sourceId: 'collected.dailySolarYield',
|
|
@@ -41,23 +42,50 @@ class statistics {
|
|
|
41
42
|
type: statisticsType.deltaReset,
|
|
42
43
|
},
|
|
43
44
|
{ sourceId: 'collected.dailyInputYield', targetPath: 'inputYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
44
|
-
{
|
|
45
|
-
sourceId: 'collected.dailyExternalYield',
|
|
46
|
-
targetPath: 'externalYield',
|
|
47
|
-
unit: 'kWh',
|
|
48
|
-
type: statisticsType.deltaReset,
|
|
49
|
-
},
|
|
45
|
+
{ sourceId: 'collected.dailyExternalYield', targetPath: 'externalYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
50
46
|
{ sourceId: 'collected.dailyEnergyYield', targetPath: 'energyYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
51
47
|
{
|
|
52
48
|
sourceId: 'collected.SOC',
|
|
53
49
|
targetPath: 'SOC',
|
|
54
50
|
unit: '%',
|
|
55
|
-
type: statisticsType.level,
|
|
51
|
+
type: statisticsType.level,
|
|
56
52
|
},
|
|
57
53
|
{ sourceId: 'collected.currentDayChargeCapacity', targetPath: 'chargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
|
|
58
54
|
{ sourceId: 'collected.currentDayDischargeCapacity', targetPath: 'dischargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
|
|
59
55
|
{ sourceId: 'collected.gridExportToday', targetPath: 'gridExport', unit: 'kWh', type: statisticsType.deltaReset },
|
|
60
56
|
{ sourceId: 'collected.gridImportToday', targetPath: 'gridImport', unit: 'kWh', type: statisticsType.deltaReset },
|
|
57
|
+
// --- Computed stats ---
|
|
58
|
+
{
|
|
59
|
+
targetPath: 'selfSufficiency',
|
|
60
|
+
unit: '%',
|
|
61
|
+
type: statisticsType.computed,
|
|
62
|
+
// unten im FusionSolar Fenster
|
|
63
|
+
// selfSufficiency = (consumption - gridImport)/consumption * 100
|
|
64
|
+
// selfSufficiency = (1 - gridImport / consumption) * 100
|
|
65
|
+
// If consumption = 0 → 100% (no consumption, fully self-sufficient)
|
|
66
|
+
compute: entry => {
|
|
67
|
+
const consumption = (entry.consumption?.total != null ? entry.consumption?.total : entry.consumption?.value) ?? 0;
|
|
68
|
+
const gridImport = (entry.gridImport?.total != null ? entry.gridImport?.total : entry.gridImport?.value) ?? 0;
|
|
69
|
+
if (consumption <= 0) return 100;
|
|
70
|
+
return Math.round(Math.max(0, Math.min(100, (1 - gridImport / consumption) * 100)) * 10) / 10;
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
targetPath: 'selfConsumption',
|
|
75
|
+
unit: '%',
|
|
76
|
+
type: statisticsType.computed,
|
|
77
|
+
// oben im FusionSolar Fenster
|
|
78
|
+
// selfConsumption = (solarYield - gridExport) / solarYield * 100
|
|
79
|
+
// selfConsumption = (1 - gridExport / solarYield) * 100
|
|
80
|
+
// If solarYield = 0 → 0% (no generation, no self-consumption possible)
|
|
81
|
+
compute: entry => {
|
|
82
|
+
let solarYield = (entry.solarYield?.total != null ? entry.solarYield?.total : entry.solarYield?.value) ?? 0;
|
|
83
|
+
solarYield += (entry.externalYield?.total != null ? entry.externalYield?.total : entry.externalYield?.value) ?? 0;
|
|
84
|
+
const gridExport = (entry.gridExport?.total != null ? entry.gridExport?.total : entry.gridExport?.value) ?? 0;
|
|
85
|
+
if (solarYield <= 0) return 0;
|
|
86
|
+
return Math.round(Math.max(0, Math.min(100, (1 - gridExport / solarYield) * 100)) * 10) / 10;
|
|
87
|
+
},
|
|
88
|
+
},
|
|
61
89
|
];
|
|
62
90
|
|
|
63
91
|
this.postProcessHooks = [
|
|
@@ -104,8 +132,16 @@ class statistics {
|
|
|
104
132
|
desc: 'Annual consumption per year',
|
|
105
133
|
initVal: '[]',
|
|
106
134
|
},
|
|
107
|
-
//
|
|
108
|
-
|
|
135
|
+
// Today summary state
|
|
136
|
+
{
|
|
137
|
+
id: 'statistics.jsonToday',
|
|
138
|
+
name: 'Today summary',
|
|
139
|
+
type: 'string',
|
|
140
|
+
role: 'json',
|
|
141
|
+
desc: "Live summary of today's energy values",
|
|
142
|
+
initVal: '{}',
|
|
143
|
+
},
|
|
144
|
+
// Templates: one per chart type
|
|
109
145
|
{
|
|
110
146
|
id: 'statistics.flexCharts.template.hourly',
|
|
111
147
|
name: 'Flexcharts template hourly',
|
|
@@ -151,7 +187,7 @@ class statistics {
|
|
|
151
187
|
write: true,
|
|
152
188
|
initVal: '{}',
|
|
153
189
|
},
|
|
154
|
-
//
|
|
190
|
+
// Output: one per chart type
|
|
155
191
|
{
|
|
156
192
|
id: 'statistics.flexCharts.jsonOutput.hourly',
|
|
157
193
|
name: 'Flexcharts output hourly',
|
|
@@ -228,12 +264,10 @@ class statistics {
|
|
|
228
264
|
* Generic function to calculate consumption statistics for different time periods.
|
|
229
265
|
*
|
|
230
266
|
* @param {string} stateId - The state ID for storing the JSON
|
|
231
|
-
* @param {string} consumptionKey - The state key for consumption value
|
|
232
267
|
* @param {Date} periodStart - The start of the current period
|
|
233
|
-
* @param {
|
|
234
|
-
* @returns {
|
|
268
|
+
* @param {Date} periodEnde - The end of the current period
|
|
269
|
+
* @returns {boolean} true if a new entry was appended, false otherwise.
|
|
235
270
|
*/
|
|
236
|
-
|
|
237
271
|
_calculateGeneric(stateId, periodStart, periodEnde) {
|
|
238
272
|
const toStr = this._localIsoWithOffset(periodEnde);
|
|
239
273
|
let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
|
|
@@ -248,7 +282,6 @@ class statistics {
|
|
|
248
282
|
let last = {};
|
|
249
283
|
if (arr.length > 0) {
|
|
250
284
|
last = arr[arr.length - 1];
|
|
251
|
-
// avoid duplicates
|
|
252
285
|
if (last.to === toStr) return false;
|
|
253
286
|
const lastToDate = new Date(last.to);
|
|
254
287
|
const toDate = new Date(toStr);
|
|
@@ -262,7 +295,10 @@ class statistics {
|
|
|
262
295
|
to: toStr,
|
|
263
296
|
};
|
|
264
297
|
|
|
298
|
+
// First pass: deltaReset, delta, level stats
|
|
265
299
|
for (const stat of this.stats) {
|
|
300
|
+
if (stat.type === statisticsType.computed) continue;
|
|
301
|
+
|
|
266
302
|
const source = this.stateCache.get(stat.sourceId)?.value;
|
|
267
303
|
if (source === null || source === undefined) {
|
|
268
304
|
this.adapter.logger.warn(`Source state ${stat.sourceId} not found statistic hook`);
|
|
@@ -272,42 +308,54 @@ class statistics {
|
|
|
272
308
|
if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
|
|
273
309
|
const lastTotal = Number(last[stat.targetPath]?.['total'] ?? 0);
|
|
274
310
|
if (stat.type === statisticsType.deltaReset) {
|
|
275
|
-
//if (value >= lastTotal * 0.5) {
|
|
276
311
|
if (fromDate.getTime() !== periodStart.getTime()) {
|
|
277
|
-
// Delta-Berechnung
|
|
278
312
|
value -= lastTotal;
|
|
279
313
|
}
|
|
280
314
|
} else {
|
|
281
|
-
// Ein lastTotal-Wert vorhanden –> normale Delta-Berechnung
|
|
282
315
|
if (last[stat.targetPath]?.['total'] === undefined) {
|
|
283
|
-
// Kein lastTotal-Wert vorhanden –> wahrscheinlich erster Eintrag, Delta-Berechnung nicht möglich
|
|
284
316
|
this.adapter.logger.debug(`No total value found for ${stat.targetPath} in last entry, setting delta to 0`);
|
|
285
317
|
value = 0;
|
|
286
318
|
} else {
|
|
287
|
-
// Delta-Berechnung
|
|
288
319
|
value -= lastTotal;
|
|
289
320
|
}
|
|
290
321
|
}
|
|
291
322
|
}
|
|
292
323
|
value = Math.round((Number(value) + Number.EPSILON) * 1000) / 1000;
|
|
293
|
-
entry[stat.targetPath] = {
|
|
294
|
-
value: Number(value.toFixed(3)),
|
|
295
|
-
};
|
|
296
|
-
|
|
324
|
+
entry[stat.targetPath] = { value: Number(value.toFixed(3)) };
|
|
297
325
|
if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
|
|
298
326
|
entry[stat.targetPath].total = Number(source.toFixed(3));
|
|
299
327
|
}
|
|
300
|
-
entry[stat.targetPath].unit = stat.unit || 'kWh';
|
|
328
|
+
entry[stat.targetPath].unit = stat.unit || 'kWh';
|
|
301
329
|
}
|
|
302
|
-
arr.push(entry);
|
|
303
330
|
|
|
304
|
-
|
|
331
|
+
// Second pass: computed stats (all other values are now in entry)
|
|
332
|
+
for (const stat of this.stats) {
|
|
333
|
+
if (stat.type !== statisticsType.computed) continue;
|
|
334
|
+
try {
|
|
335
|
+
const value = stat.compute(entry);
|
|
336
|
+
entry[stat.targetPath] = {
|
|
337
|
+
value: Number(Number(value).toFixed(3)),
|
|
338
|
+
unit: stat.unit || '%',
|
|
339
|
+
};
|
|
340
|
+
} catch (e) {
|
|
341
|
+
this.adapter.logger.warn(`statistics: error computing ${stat.targetPath}: ${e.message}`);
|
|
342
|
+
entry[stat.targetPath] = { value: 0, unit: stat.unit || '%' };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
305
345
|
|
|
346
|
+
arr.push(entry);
|
|
347
|
+
arr.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
|
|
306
348
|
this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
|
|
307
349
|
this.adapter.logger.debug(`Appended ${stateId} statistic ${toStr}`);
|
|
308
350
|
return arr.length > 0;
|
|
309
351
|
}
|
|
310
352
|
|
|
353
|
+
/**
|
|
354
|
+
* Removes entries older than periodStart from the given state.
|
|
355
|
+
*
|
|
356
|
+
* @param {string} stateId
|
|
357
|
+
* @param {Date} periodStart
|
|
358
|
+
*/
|
|
311
359
|
_clearGeneric(stateId, periodStart) {
|
|
312
360
|
let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
|
|
313
361
|
let arr = [];
|
|
@@ -317,29 +365,163 @@ class statistics {
|
|
|
317
365
|
} catch {
|
|
318
366
|
arr = [];
|
|
319
367
|
}
|
|
320
|
-
|
|
321
|
-
// Keep only entries within the window
|
|
322
368
|
arr = arr.filter(item => {
|
|
323
369
|
const ts = Date.parse(item.from);
|
|
324
370
|
return !Number.isNaN(ts) && ts >= periodStart.getTime();
|
|
325
371
|
});
|
|
326
|
-
|
|
327
372
|
this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
|
|
328
373
|
}
|
|
329
374
|
|
|
375
|
+
// eslint-disable-next-line jsdoc/require-returns-check
|
|
330
376
|
/**
|
|
331
|
-
* Calculates and aggregates
|
|
377
|
+
* Calculates and aggregates statistics for a given time window.
|
|
378
|
+
* If the window end is in the future (current period), the last entry
|
|
379
|
+
* is updated in place on every call — effectively a live running total.
|
|
380
|
+
* If the window end is in the past (completed period), a new entry is
|
|
381
|
+
* appended only once and never touched again.
|
|
332
382
|
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
* @param {string}
|
|
338
|
-
* @
|
|
339
|
-
* @param {string} periodType - The type of period for which the aggregation is performed.
|
|
340
|
-
* @returns {void}
|
|
383
|
+
* @param {string} sourceStateId
|
|
384
|
+
* @param {string} targetStateId
|
|
385
|
+
* @param {Function} getWindow - returns { from: Date, to: Date }
|
|
386
|
+
* where to is the natural end of the period (e.g. tomorrow 0:00)
|
|
387
|
+
* @param {string} periodType
|
|
388
|
+
* @returns {boolean} true if a new entry was appended, false otherwise
|
|
341
389
|
*/
|
|
342
390
|
_calculateAggregation(sourceStateId, targetStateId, getWindow, periodType) {
|
|
391
|
+
try {
|
|
392
|
+
const now = new Date();
|
|
393
|
+
const window = getWindow(now);
|
|
394
|
+
const fromDate = window.from;
|
|
395
|
+
const toDate = window.to;
|
|
396
|
+
|
|
397
|
+
// Effective end: either the natural period end (past) or now (running)
|
|
398
|
+
const isRunning = now < toDate;
|
|
399
|
+
const effectiveTo = isRunning ? now : toDate;
|
|
400
|
+
const toStr = this._localIsoWithOffset(effectiveTo);
|
|
401
|
+
|
|
402
|
+
let jsonTarget = this.stateCache.get(targetStateId)?.value ?? '[]';
|
|
403
|
+
let targetArray = [];
|
|
404
|
+
try {
|
|
405
|
+
targetArray = JSON.parse(jsonTarget);
|
|
406
|
+
if (!Array.isArray(targetArray)) targetArray = [];
|
|
407
|
+
} catch {
|
|
408
|
+
targetArray = [];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Find existing entry for this window (matched by from date)
|
|
412
|
+
const fromStr = this._localIsoWithOffset(fromDate);
|
|
413
|
+
const existingIdx = targetArray.findLastIndex(e => (isRunning ? e._live === true : e.from === fromStr));
|
|
414
|
+
//const existing = existingIdx >= 0 ? targetArray[existingIdx] : null;
|
|
415
|
+
|
|
416
|
+
// For completed periods: skip if already finalized (to matches natural end)
|
|
417
|
+
if (!isRunning && existingIdx >= 0) {
|
|
418
|
+
//const naturalToStr = this._localIsoWithOffset(toDate);
|
|
419
|
+
if (targetArray[existingIdx] === this._localIsoWithOffset(toDate)) {
|
|
420
|
+
this.adapter.logger.debug(`statistics.js: ${periodType} entry already finalized, skipping`);
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const target = {
|
|
426
|
+
from: fromStr,
|
|
427
|
+
to: toStr,
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
if (isRunning) {
|
|
431
|
+
target._live = true;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Load source entries within the window
|
|
435
|
+
let jsonStr = this.stateCache.get(sourceStateId)?.value ?? '[]';
|
|
436
|
+
let sourceEntries = [];
|
|
437
|
+
try {
|
|
438
|
+
sourceEntries = JSON.parse(jsonStr);
|
|
439
|
+
if (!Array.isArray(sourceEntries)) sourceEntries = [];
|
|
440
|
+
} catch {
|
|
441
|
+
sourceEntries = [];
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
sourceEntries = sourceEntries.filter(item => {
|
|
445
|
+
const ts = Date.parse(item.from);
|
|
446
|
+
//return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < effectiveTo.getTime(); //toDate ?
|
|
447
|
+
return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < toDate.getTime();
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
if (sourceEntries.length === 0) {
|
|
451
|
+
/*
|
|
452
|
+
this.adapter.logger.debug(
|
|
453
|
+
`statistics.js: No source entries for ${periodType} between ${fromDate.toISOString()} and ${effectiveTo.toISOString()}, skipping`,
|
|
454
|
+
);
|
|
455
|
+
*/
|
|
456
|
+
this.adapter.logger.debug(
|
|
457
|
+
`statistics.js: No source entries for ${periodType} between ${fromDate.toISOString()} and ${toDate.toISOString()}, skipping`,
|
|
458
|
+
);
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
this.adapter.logger.debug(
|
|
463
|
+
`statistics.js: ${sourceEntries.length} source entries for ${periodType} between ${fromDate.toISOString()} and ${effectiveTo.toISOString()} (running=${isRunning})`,
|
|
464
|
+
);
|
|
465
|
+
|
|
466
|
+
// First pass: sum delta/deltaReset stats
|
|
467
|
+
for (const stat of this.stats) {
|
|
468
|
+
if (stat.type === statisticsType.level) continue;
|
|
469
|
+
if (stat.type === statisticsType.computed) continue;
|
|
470
|
+
|
|
471
|
+
let sum = 0;
|
|
472
|
+
try {
|
|
473
|
+
sourceEntries.forEach(entry => {
|
|
474
|
+
sum += Number(entry[stat.targetPath]?.['value'] ?? 0);
|
|
475
|
+
});
|
|
476
|
+
} catch (e) {
|
|
477
|
+
this.adapter.logger.warn(`statistics.js: Error during ${periodType} aggregation: ${e.message}`);
|
|
478
|
+
}
|
|
479
|
+
sum = Math.round((Number(sum) + Number.EPSILON) * 1000) / 1000;
|
|
480
|
+
target[stat.targetPath] = { value: Number(sum.toFixed(3)), unit: stat.unit || 'kWh' };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Second pass: computed stats
|
|
484
|
+
for (const stat of this.stats) {
|
|
485
|
+
if (stat.type !== statisticsType.computed) continue;
|
|
486
|
+
try {
|
|
487
|
+
const value = stat.compute(target);
|
|
488
|
+
target[stat.targetPath] = {
|
|
489
|
+
value: Number(Number(value).toFixed(3)),
|
|
490
|
+
unit: stat.unit || '%',
|
|
491
|
+
};
|
|
492
|
+
} catch (e) {
|
|
493
|
+
this.adapter.logger.warn(`statistics: error computing ${stat.targetPath}: ${e.message}`);
|
|
494
|
+
target[stat.targetPath] = { value: 0, unit: stat.unit || '%' };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Update in place or append
|
|
499
|
+
if (existingIdx >= 0) {
|
|
500
|
+
targetArray[existingIdx] = target;
|
|
501
|
+
this.adapter.logger.debug(`statistics.js: Updated ${periodType} entry (running=${isRunning})`);
|
|
502
|
+
} else {
|
|
503
|
+
targetArray.push(target);
|
|
504
|
+
this.adapter.logger.debug(`statistics.js: Appended ${periodType} entry (running=${isRunning})`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
targetArray.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
|
|
508
|
+
this.stateCache.set(targetStateId, JSON.stringify(targetArray), { type: 'string' });
|
|
509
|
+
return targetArray.length > 0;
|
|
510
|
+
} catch (err) {
|
|
511
|
+
this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// eslint-disable-next-line jsdoc/require-returns-check
|
|
515
|
+
/**
|
|
516
|
+
* Calculates and aggregates consumption statistics based on the given parameters.
|
|
517
|
+
*
|
|
518
|
+
* @param {string} sourceStateId
|
|
519
|
+
* @param {string} targetStateId
|
|
520
|
+
* @param {Function} getWindow
|
|
521
|
+
* @param {string} periodType
|
|
522
|
+
* @returns {boolean} true if a new entry was appended, false otherwise.
|
|
523
|
+
*/
|
|
524
|
+
_calculateAggregation_old(sourceStateId, targetStateId, getWindow, periodType) {
|
|
343
525
|
try {
|
|
344
526
|
const now = new Date();
|
|
345
527
|
const window = getWindow(now);
|
|
@@ -350,8 +532,8 @@ class statistics {
|
|
|
350
532
|
return false;
|
|
351
533
|
}
|
|
352
534
|
const toStr = this._localIsoWithOffset(toDate);
|
|
535
|
+
//const fromStr = this._localIsoWithOffset(fromDate);
|
|
353
536
|
|
|
354
|
-
// Load target array
|
|
355
537
|
let jsonTarget = this.stateCache.get(targetStateId)?.value ?? '[]';
|
|
356
538
|
let targetArray = [];
|
|
357
539
|
try {
|
|
@@ -361,8 +543,8 @@ class statistics {
|
|
|
361
543
|
targetArray = [];
|
|
362
544
|
}
|
|
363
545
|
|
|
364
|
-
// Avoid duplicates
|
|
365
546
|
const last = targetArray.length > 0 ? targetArray[targetArray.length - 1] : {};
|
|
547
|
+
|
|
366
548
|
if (last.to === toStr) return false;
|
|
367
549
|
|
|
368
550
|
const target = {
|
|
@@ -370,7 +552,6 @@ class statistics {
|
|
|
370
552
|
to: toStr,
|
|
371
553
|
};
|
|
372
554
|
|
|
373
|
-
// Load source entries
|
|
374
555
|
let jsonStr = this.stateCache.get(sourceStateId)?.value ?? '[]';
|
|
375
556
|
let sourceEntries = [];
|
|
376
557
|
try {
|
|
@@ -380,27 +561,22 @@ class statistics {
|
|
|
380
561
|
sourceEntries = [];
|
|
381
562
|
}
|
|
382
563
|
|
|
383
|
-
// Keep only entries within the window
|
|
384
564
|
sourceEntries = sourceEntries.filter(item => {
|
|
385
565
|
const ts = Date.parse(item.from);
|
|
386
566
|
return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < toDate.getTime();
|
|
387
567
|
});
|
|
388
|
-
|
|
568
|
+
|
|
389
569
|
if (sourceEntries.length > 0) {
|
|
390
570
|
this.adapter.logger.debug(
|
|
391
571
|
`statistics.js: Found ${sourceEntries.length} source entries for ${periodType} aggregation between ${fromDate.toISOString()} and ${toDate.toISOString()}`,
|
|
392
572
|
);
|
|
393
573
|
|
|
574
|
+
// First pass: sum delta/deltaReset stats
|
|
394
575
|
for (const stat of this.stats) {
|
|
395
|
-
|
|
396
|
-
if (stat.type === statisticsType.
|
|
576
|
+
if (stat.type === statisticsType.level) continue;
|
|
577
|
+
if (stat.type === statisticsType.computed) continue;
|
|
397
578
|
|
|
398
579
|
let sum = 0;
|
|
399
|
-
/*
|
|
400
|
-
if (stat.type === statisticsType.average) {
|
|
401
|
-
stat.sum = sourceEntries.length > 0 ? sourceEntries[sourceEntries.length - 1]?.[stat.targetPath]?.['total'] : 0;
|
|
402
|
-
} else {
|
|
403
|
-
*/
|
|
404
580
|
try {
|
|
405
581
|
sourceEntries.forEach(entry => {
|
|
406
582
|
sum += Number(entry[stat.targetPath]?.['value'] ?? 0);
|
|
@@ -408,13 +584,23 @@ class statistics {
|
|
|
408
584
|
} catch (e) {
|
|
409
585
|
this.adapter.logger.warn(`statistics.js: Error during ${periodType} statistic aggregation: ${e.message}`);
|
|
410
586
|
}
|
|
411
|
-
|
|
412
587
|
sum = Math.round((Number(sum) + Number.EPSILON) * 1000) / 1000;
|
|
588
|
+
target[stat.targetPath] = { value: Number(sum.toFixed(3)), unit: stat.unit || 'kWh' };
|
|
589
|
+
}
|
|
413
590
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
591
|
+
// Second pass: compute derived stats from aggregated values
|
|
592
|
+
for (const stat of this.stats) {
|
|
593
|
+
if (stat.type !== statisticsType.computed) continue;
|
|
594
|
+
try {
|
|
595
|
+
const value = stat.compute(target);
|
|
596
|
+
target[stat.targetPath] = {
|
|
597
|
+
value: Number(Number(value).toFixed(3)),
|
|
598
|
+
unit: stat.unit || '%',
|
|
599
|
+
};
|
|
600
|
+
} catch (e) {
|
|
601
|
+
this.adapter.logger.warn(`statistics: error computing aggregated ${stat.targetPath}: ${e.message}`);
|
|
602
|
+
target[stat.targetPath] = { value: 0, unit: stat.unit || '%' };
|
|
603
|
+
}
|
|
418
604
|
}
|
|
419
605
|
|
|
420
606
|
targetArray.push(target);
|
|
@@ -422,89 +608,165 @@ class statistics {
|
|
|
422
608
|
|
|
423
609
|
targetArray.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
|
|
424
610
|
this.stateCache.set(targetStateId, JSON.stringify(targetArray), { type: 'string' });
|
|
425
|
-
this.adapter.logger.debug(`Appended ${periodType} statistic ${toStr}
|
|
611
|
+
this.adapter.logger.debug(`Appended ${periodType} statistic ${toStr}`);
|
|
426
612
|
return targetArray.length > 0;
|
|
427
613
|
} catch (err) {
|
|
428
614
|
this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
|
|
429
615
|
}
|
|
430
616
|
}
|
|
431
617
|
|
|
618
|
+
/**
|
|
619
|
+
* Updates the statistics.jsonToday state with the current live day values.
|
|
620
|
+
* Reads directly from the stateCache (collected.*) and computes derived values.
|
|
621
|
+
*/
|
|
622
|
+
updateJsonToday() {
|
|
623
|
+
if (!this._initialized) {
|
|
624
|
+
this.adapter.logger.debug('statistics: updateJsonToday called before initialization');
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
try {
|
|
629
|
+
const now = new Date();
|
|
630
|
+
const today = {};
|
|
631
|
+
|
|
632
|
+
// Read all non-computed stats directly from stateCache
|
|
633
|
+
for (const stat of this.stats) {
|
|
634
|
+
if (stat.type === statisticsType.computed) continue;
|
|
635
|
+
if (stat.type === statisticsType.level) {
|
|
636
|
+
const val = this.stateCache.get(stat.sourceId)?.value;
|
|
637
|
+
today[stat.targetPath] = {
|
|
638
|
+
value: val != null ? Number(Number(val).toFixed(3)) : null,
|
|
639
|
+
unit: stat.unit,
|
|
640
|
+
};
|
|
641
|
+
} else {
|
|
642
|
+
// deltaReset: value is the current day total from the source state
|
|
643
|
+
const val = this.stateCache.get(stat.sourceId)?.value;
|
|
644
|
+
today[stat.targetPath] = {
|
|
645
|
+
value: val != null ? Number(Number(val).toFixed(3)) : null,
|
|
646
|
+
unit: stat.unit,
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Compute derived stats
|
|
652
|
+
for (const stat of this.stats) {
|
|
653
|
+
if (stat.type !== statisticsType.computed) continue;
|
|
654
|
+
try {
|
|
655
|
+
// Build a proxy entry using the raw today values
|
|
656
|
+
const proxyEntry = {};
|
|
657
|
+
for (const key of Object.keys(today)) {
|
|
658
|
+
proxyEntry[key] = today[key];
|
|
659
|
+
}
|
|
660
|
+
const value = stat.compute(proxyEntry);
|
|
661
|
+
today[stat.targetPath] = {
|
|
662
|
+
value: Number(Number(value).toFixed(3)),
|
|
663
|
+
unit: stat.unit || '%',
|
|
664
|
+
};
|
|
665
|
+
} catch (e) {
|
|
666
|
+
this.adapter.logger.warn(`statistics: error computing today.${stat.targetPath}: ${e.message}`);
|
|
667
|
+
today[stat.targetPath] = { value: 0, unit: stat.unit || '%' };
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
today.updatedAt = this._localIsoWithOffset(now);
|
|
672
|
+
|
|
673
|
+
const todayStr = JSON.stringify(today);
|
|
674
|
+
this.stateCache.set('statistics.jsonToday', todayStr, { type: 'string' });
|
|
675
|
+
this.adapter.logger.debug('statistics: jsonToday state updated');
|
|
676
|
+
} catch (err) {
|
|
677
|
+
this.adapter.logger.warn(`statistics: error updating today state: ${err.message}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
432
681
|
/**
|
|
433
682
|
* Calculates and updates hourly consumption statistics.
|
|
434
|
-
*
|
|
435
|
-
* This function calculates the hourly consumption statistics based on the current day's data.
|
|
436
|
-
* It retrieves the consumption data and updates the hourly consumption JSON accordingly.
|
|
437
|
-
*
|
|
438
|
-
* @returns {void}
|
|
439
683
|
*/
|
|
440
684
|
_calculateHourly() {
|
|
441
685
|
const now = new Date();
|
|
442
686
|
if (this.testing) {
|
|
443
687
|
const state = this.adapter.getState('statistics.jsonHourly');
|
|
444
688
|
this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
445
|
-
now.setDate(now.getDate() + 1);
|
|
446
|
-
now.setHours(1, 0, 0, 1);
|
|
689
|
+
now.setDate(now.getDate() + 1);
|
|
690
|
+
now.setHours(1, 0, 0, 1);
|
|
447
691
|
}
|
|
448
692
|
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
449
693
|
const lastHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0);
|
|
450
|
-
this.adapter.log.debug(
|
|
694
|
+
this.adapter.log.debug(`### Hourly execution triggered with lastHour: ${lastHour.toLocaleTimeString()} ###`);
|
|
451
695
|
if (this._calculateGeneric('statistics.jsonHourly', startOfDay, lastHour)) {
|
|
452
696
|
this._buildFlexchart('hourly');
|
|
453
697
|
}
|
|
454
698
|
}
|
|
455
699
|
|
|
456
|
-
/**
|
|
457
|
-
* Calculates and updates daily consumption statistics from hourly data.
|
|
458
|
-
*
|
|
459
|
-
* @returns {void}
|
|
460
|
-
*/
|
|
461
700
|
_calculateDaily() {
|
|
462
701
|
this.adapter.log.debug('### Daily execution triggered ###');
|
|
702
|
+
|
|
703
|
+
// Abgeschlossener Vortag — normaler Eintrag
|
|
463
704
|
this._calculateAggregation(
|
|
464
705
|
'statistics.jsonHourly',
|
|
465
706
|
'statistics.jsonDaily',
|
|
466
707
|
now => {
|
|
467
|
-
// aggregation window: previous day (day that just ended)
|
|
468
708
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
469
709
|
const yesterday = new Date(today);
|
|
470
710
|
yesterday.setDate(today.getDate() - 1);
|
|
471
711
|
return { from: yesterday, to: today };
|
|
472
712
|
},
|
|
473
713
|
'daily',
|
|
474
|
-
)
|
|
714
|
+
);
|
|
715
|
+
// Laufender heutiger Tag — live entry aus jsonHourly aggregieren
|
|
716
|
+
this._calculateAggregation(
|
|
717
|
+
'statistics.jsonHourly',
|
|
718
|
+
'statistics.jsonDaily',
|
|
719
|
+
now => {
|
|
720
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
721
|
+
const tomorrow = new Date(today);
|
|
722
|
+
tomorrow.setDate(today.getDate() + 1);
|
|
723
|
+
return { from: today, to: tomorrow };
|
|
724
|
+
},
|
|
725
|
+
'daily-live',
|
|
726
|
+
);
|
|
727
|
+
this._buildFlexchart('daily');
|
|
475
728
|
}
|
|
476
729
|
|
|
477
730
|
/**
|
|
478
731
|
* Calculates and updates weekly consumption statistics from daily data.
|
|
479
|
-
*
|
|
480
|
-
* @returns {void}
|
|
481
732
|
*/
|
|
482
733
|
_calculateWeekly() {
|
|
483
734
|
this.adapter.log.debug('### Weekly execution triggered ###');
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
)
|
|
500
|
-
|
|
501
|
-
|
|
735
|
+
this._calculateAggregation(
|
|
736
|
+
'statistics.jsonDaily',
|
|
737
|
+
'statistics.jsonWeekly',
|
|
738
|
+
now => {
|
|
739
|
+
// Previous week (completed)
|
|
740
|
+
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
741
|
+
monday.setDate(now.getDate() - (now.getDay() || 7) + 1);
|
|
742
|
+
const prevMonday = new Date(monday);
|
|
743
|
+
prevMonday.setDate(monday.getDate() - 7);
|
|
744
|
+
const nextMonday = new Date(monday);
|
|
745
|
+
nextMonday.setDate(monday.getDate() + 7);
|
|
746
|
+
// Window: previous Monday → next Monday (covers both last and current week)
|
|
747
|
+
return { from: prevMonday, to: nextMonday };
|
|
748
|
+
},
|
|
749
|
+
'weekly',
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
// Laufende Woche — live entry
|
|
753
|
+
this._calculateAggregation(
|
|
754
|
+
'statistics.jsonDaily',
|
|
755
|
+
'statistics.jsonWeekly',
|
|
756
|
+
now => {
|
|
757
|
+
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
758
|
+
monday.setDate(now.getDate() - (now.getDay() || 7) + 1);
|
|
759
|
+
const nextMonday = new Date(monday);
|
|
760
|
+
nextMonday.setDate(monday.getDate() + 7);
|
|
761
|
+
return { from: monday, to: nextMonday };
|
|
762
|
+
},
|
|
763
|
+
'weekly-live',
|
|
764
|
+
);
|
|
765
|
+
this._buildFlexchart('weekly');
|
|
502
766
|
}
|
|
503
767
|
|
|
504
768
|
/**
|
|
505
769
|
* Calculates and updates monthly consumption statistics from daily data.
|
|
506
|
-
*
|
|
507
|
-
* @returns {void}
|
|
508
770
|
*/
|
|
509
771
|
_calculateMonthly() {
|
|
510
772
|
this.adapter.log.debug('### Monthly execution triggered ###');
|
|
@@ -512,19 +774,28 @@ class statistics {
|
|
|
512
774
|
'statistics.jsonDaily',
|
|
513
775
|
'statistics.jsonMonthly',
|
|
514
776
|
now => {
|
|
515
|
-
// aggregation windowStart: previous month (month that just ended)
|
|
516
777
|
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
|
|
517
778
|
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1, 0, 0, 0, 0);
|
|
518
779
|
return { from: prevMonth, to: thisMonth };
|
|
519
780
|
},
|
|
520
781
|
'monthly',
|
|
521
|
-
)
|
|
782
|
+
);
|
|
783
|
+
// Laufender Monat — live entry
|
|
784
|
+
this._calculateAggregation(
|
|
785
|
+
'statistics.jsonDaily',
|
|
786
|
+
'statistics.jsonMonthly',
|
|
787
|
+
now => {
|
|
788
|
+
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
|
|
789
|
+
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1, 0, 0, 0, 0);
|
|
790
|
+
return { from: thisMonth, to: nextMonth };
|
|
791
|
+
},
|
|
792
|
+
'monthly-live',
|
|
793
|
+
);
|
|
794
|
+
this._buildFlexchart('monthly');
|
|
522
795
|
}
|
|
523
796
|
|
|
524
797
|
/**
|
|
525
798
|
* Calculates and updates annual consumption statistics from daily data.
|
|
526
|
-
*
|
|
527
|
-
* @returns {void}
|
|
528
799
|
*/
|
|
529
800
|
_calculateAnnual() {
|
|
530
801
|
this.adapter.log.debug('### Annual execution triggered ###');
|
|
@@ -532,47 +803,57 @@ class statistics {
|
|
|
532
803
|
'statistics.jsonDaily',
|
|
533
804
|
'statistics.jsonAnnual',
|
|
534
805
|
now => {
|
|
535
|
-
// aggregation window: previous year (year that just ended)
|
|
536
806
|
const thisYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
|
|
537
807
|
const prevYear = new Date(now.getFullYear() - 1, 0, 1, 0, 0, 0, 0);
|
|
538
808
|
return { from: prevYear, to: thisYear };
|
|
539
809
|
},
|
|
540
810
|
'annual',
|
|
541
|
-
)
|
|
811
|
+
);
|
|
812
|
+
// Laufendes Jahr — live entry
|
|
813
|
+
this._calculateAggregation(
|
|
814
|
+
'statistics.jsonDaily',
|
|
815
|
+
'statistics.jsonAnnual',
|
|
816
|
+
now => {
|
|
817
|
+
const thisYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
|
|
818
|
+
const nextYear = new Date(now.getFullYear() + 1, 0, 1, 0, 0, 0, 0);
|
|
819
|
+
return { from: thisYear, to: nextYear };
|
|
820
|
+
},
|
|
821
|
+
'annual-live',
|
|
822
|
+
);
|
|
823
|
+
this._buildFlexchart('annual');
|
|
542
824
|
}
|
|
543
825
|
|
|
544
826
|
/**
|
|
545
827
|
* Initialize and schedule the unified task manager.
|
|
546
|
-
*
|
|
828
|
+
* Runs every full hour.
|
|
547
829
|
*/
|
|
548
830
|
_initializeTask() {
|
|
549
831
|
const scheduleNextRun = () => {
|
|
550
832
|
const now = new Date();
|
|
833
|
+
|
|
551
834
|
const next = new Date(now);
|
|
552
835
|
if (this.testing) {
|
|
553
|
-
next.setMinutes(now.getMinutes() + 1, 0, 0);
|
|
836
|
+
next.setMinutes(now.getMinutes() + 1, 0, 0);
|
|
554
837
|
} else {
|
|
555
|
-
next.setHours(next.getHours() + 1, 0, 0,
|
|
838
|
+
next.setHours(next.getHours() + 1, 0, 0, 100);
|
|
556
839
|
}
|
|
557
|
-
|
|
558
|
-
// Skip executing tasks exactly at midnight to avoid running aggregated jobs
|
|
559
840
|
if (next.getHours() === 0 && next.getMinutes() === 0) {
|
|
560
|
-
next.setHours(next.getHours() + 1, 0, 0, 0);
|
|
841
|
+
next.setHours(next.getHours() + 1, 0, 0, 0);
|
|
561
842
|
}
|
|
562
843
|
const msToNextHour = next.getTime() - now.getTime();
|
|
844
|
+
this.adapter.logger.debug(`### Statistics - Scheduler start ${now.toLocaleTimeString()} next ${next.toLocaleTimeString()}`);
|
|
563
845
|
|
|
564
846
|
if (this.taskTimer) {
|
|
565
847
|
this.adapter.clearTimeout(this.taskTimer);
|
|
566
848
|
}
|
|
567
|
-
|
|
568
849
|
this.taskTimer = this.adapter.setTimeout(() => {
|
|
569
850
|
this._executeScheduledTasks();
|
|
570
|
-
scheduleNextRun();
|
|
851
|
+
scheduleNextRun();
|
|
571
852
|
}, msToNextHour);
|
|
572
853
|
};
|
|
573
|
-
// Schedule the next run
|
|
574
854
|
scheduleNextRun();
|
|
575
855
|
}
|
|
856
|
+
|
|
576
857
|
_executeScheduledTasks() {
|
|
577
858
|
this._calculateHourly();
|
|
578
859
|
this._calculateDaily();
|
|
@@ -582,14 +863,13 @@ class statistics {
|
|
|
582
863
|
}
|
|
583
864
|
|
|
584
865
|
/**
|
|
585
|
-
* Executes every midnight
|
|
586
|
-
* -
|
|
587
|
-
* -
|
|
866
|
+
* Executes every midnight:
|
|
867
|
+
* - Runs all scheduled tasks
|
|
868
|
+
* - Clears old data based on retention policies
|
|
588
869
|
*/
|
|
589
870
|
mitNightProcess() {
|
|
590
871
|
const now = new Date();
|
|
591
872
|
this._executeScheduledTasks();
|
|
592
|
-
// Clear old data based on retention policies
|
|
593
873
|
const startOfYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
|
|
594
874
|
this._clearGeneric('statistics.jsonDaily', startOfYear);
|
|
595
875
|
this._clearGeneric('statistics.jsonWeekly', startOfYear);
|
|
@@ -598,7 +878,6 @@ class statistics {
|
|
|
598
878
|
}
|
|
599
879
|
|
|
600
880
|
async initialize() {
|
|
601
|
-
// load consumption JSON states (keep as string)
|
|
602
881
|
let state = await this.adapter.getState('statistics.jsonHourly');
|
|
603
882
|
this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
604
883
|
|
|
@@ -606,7 +885,7 @@ class statistics {
|
|
|
606
885
|
this.stateCache.set('statistics.jsonDaily', state?.val ?? '[]', { type: 'string', stored: true });
|
|
607
886
|
|
|
608
887
|
state = await this.adapter.getState('statistics.jsonWeekly');
|
|
609
|
-
this.stateCache.set('statistics.jsonWeekly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
888
|
+
this.stateCache.set('statistics.jsonWeekly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
610
889
|
|
|
611
890
|
state = await this.adapter.getState('statistics.jsonMonthly');
|
|
612
891
|
this.stateCache.set('statistics.jsonMonthly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
@@ -614,10 +893,12 @@ class statistics {
|
|
|
614
893
|
state = await this.adapter.getState('statistics.jsonAnnual');
|
|
615
894
|
this.stateCache.set('statistics.jsonAnnual', state?.val ?? '[]', { type: 'string', stored: true });
|
|
616
895
|
|
|
617
|
-
|
|
896
|
+
state = await this.adapter.getState('statistics.jsonToday');
|
|
897
|
+
this.stateCache.set('statistics.jsonToday', state?.val ?? '{}', { type: 'string', stored: true });
|
|
898
|
+
|
|
618
899
|
await tools.waitForValue(() => this.stateCache.get('collected.accumulatedEnergyYield')?.value, 60000);
|
|
619
900
|
|
|
620
|
-
//
|
|
901
|
+
// Load templates — one per chart type
|
|
621
902
|
for (const chartType of ['hourly', 'daily', 'weekly', 'monthly', 'annual']) {
|
|
622
903
|
const templateStateId = `statistics.flexCharts.template.${chartType}`;
|
|
623
904
|
state = await this.adapter.getState(templateStateId);
|
|
@@ -629,13 +910,22 @@ class statistics {
|
|
|
629
910
|
}
|
|
630
911
|
}
|
|
631
912
|
|
|
632
|
-
this.mitNightProcess();
|
|
913
|
+
this.mitNightProcess();
|
|
633
914
|
this._initializeTask();
|
|
634
915
|
this.adapter.subscribeStates(`${this._path}.*`);
|
|
916
|
+
this._initialized = true;
|
|
635
917
|
}
|
|
636
918
|
|
|
919
|
+
/**
|
|
920
|
+
* Builds and updates the Flexchart configuration for the specified chart type.
|
|
921
|
+
*
|
|
922
|
+
* @param {string} myChart - 'hourly' | 'daily' | 'weekly' | 'monthly' | 'annual'
|
|
923
|
+
* @param {string} [chartStyle] - 'line' | 'bar' — defaults to 'line' for hourly, 'bar' for others
|
|
924
|
+
* @returns {string} The generated chart configuration as a javascript-stringify string
|
|
925
|
+
*/
|
|
637
926
|
_buildFlexchart(myChart, chartStyle) {
|
|
638
|
-
chartStyle = chartStyle || (myChart === 'hourly' ? 'line' : 'bar');
|
|
927
|
+
chartStyle = chartStyle || (myChart === 'hourly' ? 'line' : 'bar');
|
|
928
|
+
|
|
639
929
|
const IDS = {
|
|
640
930
|
hourly: 'statistics.jsonHourly',
|
|
641
931
|
daily: 'statistics.jsonDaily',
|
|
@@ -663,9 +953,12 @@ class statistics {
|
|
|
663
953
|
})}`;
|
|
664
954
|
}
|
|
665
955
|
if (myChart === 'weekly') {
|
|
666
|
-
const
|
|
667
|
-
|
|
668
|
-
|
|
956
|
+
const toDay = new Date(to);
|
|
957
|
+
//mitnight -> the day before
|
|
958
|
+
if (toDay.getHours() === 0 && toDay.getMinutes() === 0) {
|
|
959
|
+
toDay.setDate(toDay.getDate() - 1);
|
|
960
|
+
}
|
|
961
|
+
return `${from.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}-${toDay.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}`;
|
|
669
962
|
}
|
|
670
963
|
if (myChart === 'monthly') {
|
|
671
964
|
return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit' });
|
|
@@ -673,22 +966,12 @@ class statistics {
|
|
|
673
966
|
if (myChart === 'annual') {
|
|
674
967
|
return from.toLocaleDateString('de-DE', { year: 'numeric' });
|
|
675
968
|
}
|
|
676
|
-
|
|
677
969
|
return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
678
|
-
/*
|
|
679
|
-
return myChart === 'hourly'
|
|
680
|
-
? `${to.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })} ${to.toLocaleTimeString('de-DE', {
|
|
681
|
-
hour12: false,
|
|
682
|
-
hour: '2-digit',
|
|
683
|
-
minute: '2-digit',
|
|
684
|
-
})}`
|
|
685
|
-
: from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
686
|
-
*/
|
|
687
970
|
});
|
|
688
971
|
|
|
689
972
|
const xAxisDataShort = myChart === 'hourly' ? xAxisData.map(label => label.split(' ')[1]) : xAxisData;
|
|
690
973
|
|
|
691
|
-
// ---
|
|
974
|
+
// --- Day areas (hourly only) ---
|
|
692
975
|
const dayAreas = [];
|
|
693
976
|
if (myChart === 'hourly' && xAxisData.length > 0) {
|
|
694
977
|
const dayBoundaries = [0];
|
|
@@ -734,19 +1017,15 @@ class statistics {
|
|
|
734
1017
|
const extract = key => data.map(e => Number(Number(e[key]?.value ?? 0).toFixed(3)));
|
|
735
1018
|
const negate = arr => arr.map(v => Number((-v).toFixed(3)));
|
|
736
1019
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
SOC: extract('SOC'),
|
|
745
|
-
gridExportNeg: negate(extract('gridExport')),
|
|
746
|
-
chargeCapacityNeg: negate(extract('chargeCapacity')),
|
|
747
|
-
};
|
|
1020
|
+
// Build seriesData from all this.stats entries (targetPath as key)
|
|
1021
|
+
const seriesData = {};
|
|
1022
|
+
for (const stat of this.stats) {
|
|
1023
|
+
const values = extract(stat.targetPath);
|
|
1024
|
+
seriesData[stat.targetPath] = values;
|
|
1025
|
+
seriesData[`${stat.targetPath}Neg`] = negate(values);
|
|
1026
|
+
}
|
|
748
1027
|
|
|
749
|
-
// --- Tooltip formatter
|
|
1028
|
+
// --- Tooltip formatter ---
|
|
750
1029
|
const tooltipFormatter = params => {
|
|
751
1030
|
if (!Array.isArray(params)) params = [params];
|
|
752
1031
|
return params
|
|
@@ -754,8 +1033,10 @@ class statistics {
|
|
|
754
1033
|
.map(p => {
|
|
755
1034
|
const negatedSeries = ['Grid Export', 'Charge'];
|
|
756
1035
|
const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
|
|
757
|
-
const unit =
|
|
758
|
-
|
|
1036
|
+
const unit = ['SOC', 'Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? ' %' : ' kWh';
|
|
1037
|
+
const seriesName =
|
|
1038
|
+
myChart === 'hourly' && ['Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? `${p.seriesName} today` : p.seriesName;
|
|
1039
|
+
return `${p.marker}${seriesName}: <b>${val}${unit}</b>`;
|
|
759
1040
|
})
|
|
760
1041
|
.join('<br/>');
|
|
761
1042
|
};
|
|
@@ -769,12 +1050,9 @@ class statistics {
|
|
|
769
1050
|
|
|
770
1051
|
try {
|
|
771
1052
|
const templ = JSON.parse(templateStr);
|
|
772
|
-
|
|
773
1053
|
if (Object.keys(templ).length === 0) {
|
|
774
|
-
// Kein Template → built-in Default
|
|
775
1054
|
chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
|
|
776
1055
|
} else {
|
|
777
|
-
// Template vorhanden → mit javascript-stringify serialisieren
|
|
778
1056
|
chartStr = stringify(templ);
|
|
779
1057
|
}
|
|
780
1058
|
} catch (e) {
|
|
@@ -782,30 +1060,21 @@ class statistics {
|
|
|
782
1060
|
chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
|
|
783
1061
|
}
|
|
784
1062
|
|
|
785
|
-
// --- Replace
|
|
1063
|
+
// --- Replace placeholders ---
|
|
786
1064
|
chartStr = chartStr
|
|
787
|
-
// X-Achse
|
|
788
1065
|
.replace("'%%xAxisData%%'", JSON.stringify(xAxisData))
|
|
789
1066
|
.replace("'%%xAxisDataShort%%'", JSON.stringify(xAxisDataShort))
|
|
790
1067
|
.replace("'%%xAxisMax%%'", String(xAxisData.length - 1))
|
|
791
|
-
// Originaldaten (immer positiv)
|
|
792
|
-
.replace("'%%solarYield%%'", JSON.stringify(seriesData.solarYield))
|
|
793
|
-
.replace("'%%consumption%%'", JSON.stringify(seriesData.consumption))
|
|
794
|
-
.replace("'%%gridExport%%'", JSON.stringify(seriesData.gridExport))
|
|
795
|
-
.replace("'%%gridImport%%'", JSON.stringify(seriesData.gridImport))
|
|
796
|
-
.replace("'%%chargeCapacity%%'", JSON.stringify(seriesData.chargeCapacity))
|
|
797
|
-
.replace("'%%dischargeCapacity%%'", JSON.stringify(seriesData.dischargeCapacity))
|
|
798
|
-
.replace("'%%SOC%%'", JSON.stringify(seriesData.SOC))
|
|
799
|
-
// Negierte Varianten für gegenläufige Darstellung
|
|
800
|
-
.replace("'%%gridExportNeg%%'", JSON.stringify(seriesData.gridExportNeg))
|
|
801
|
-
.replace("'%%chargeCapacityNeg%%'", JSON.stringify(seriesData.chargeCapacityNeg))
|
|
802
|
-
// Sonstiges
|
|
803
|
-
.replace("'%%dayAreas%%'", JSON.stringify(dayAreas))
|
|
804
1068
|
.replace("'%%chartTitle%%'", JSON.stringify(`PV Statistics — ${myChart}`))
|
|
805
|
-
|
|
1069
|
+
.replace("'%%dayAreas%%'", JSON.stringify(dayAreas))
|
|
806
1070
|
.replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
|
|
807
1071
|
|
|
808
|
-
//
|
|
1072
|
+
// All this.stats entries dynamically — both positive and negated
|
|
1073
|
+
for (const stat of this.stats) {
|
|
1074
|
+
const key = stat.targetPath;
|
|
1075
|
+
chartStr = chartStr.replace(`'%%${key}%%'`, JSON.stringify(seriesData[key])).replace(`'%%${key}Neg%%'`, JSON.stringify(seriesData[`${key}Neg`]));
|
|
1076
|
+
}
|
|
1077
|
+
|
|
809
1078
|
this.stateCache.set(outputStateId, chartStr, { type: 'string' });
|
|
810
1079
|
this.adapter.logger.debug(`statistics: flexCharts built for ${myChart}/${chartStyle}`);
|
|
811
1080
|
|
|
@@ -835,7 +1104,15 @@ class statistics {
|
|
|
835
1104
|
const negate = arr => arr.map(v => Number((-v).toFixed(3)));
|
|
836
1105
|
const showSOC = myChart === 'hourly';
|
|
837
1106
|
|
|
838
|
-
//
|
|
1107
|
+
// No-data hint — chart-type specific
|
|
1108
|
+
const noDataHints = {
|
|
1109
|
+
hourly: 'No data yet — first entry available after the next full hour.',
|
|
1110
|
+
daily: 'No data yet — first entry available tomorrow after midnight.',
|
|
1111
|
+
weekly: 'No data yet — first entry available after the current week ends.',
|
|
1112
|
+
monthly: 'No data yet — first entry available after the current month ends.',
|
|
1113
|
+
annual: 'No data yet — first entry available after the current year ends.',
|
|
1114
|
+
};
|
|
1115
|
+
|
|
839
1116
|
const tooltipFormatter = params => {
|
|
840
1117
|
if (!Array.isArray(params)) params = [params];
|
|
841
1118
|
return params
|
|
@@ -843,12 +1120,29 @@ class statistics {
|
|
|
843
1120
|
.map(p => {
|
|
844
1121
|
const negatedSeries = ['Grid Export', 'Charge'];
|
|
845
1122
|
const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
|
|
846
|
-
const unit =
|
|
847
|
-
|
|
1123
|
+
const unit = ['SOC', 'Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? ' %' : ' kWh';
|
|
1124
|
+
const seriesName =
|
|
1125
|
+
myChart === 'hourly' && ['Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? `${p.seriesName} today` : p.seriesName;
|
|
1126
|
+
return `${p.marker}${seriesName}: <b>${val}${unit}</b>`;
|
|
848
1127
|
})
|
|
849
1128
|
.join('<br/>');
|
|
850
1129
|
};
|
|
851
1130
|
|
|
1131
|
+
// --- Slider start position — show recent entries by default ---
|
|
1132
|
+
// For hourly: show last 25 entries (~1 day)
|
|
1133
|
+
// For daily: show last 7 entries (~1 week)
|
|
1134
|
+
// For weekly: show last 8 entries (~2 months)
|
|
1135
|
+
// For monthly: show last 13 entries (~1 year)
|
|
1136
|
+
// For annual: show full range
|
|
1137
|
+
const sliderDefaults = {
|
|
1138
|
+
hourly: { start: Math.max(0, Math.round((1 - 25 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
|
|
1139
|
+
daily: { start: Math.max(0, Math.round((1 - 7 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
|
|
1140
|
+
weekly: { start: Math.max(0, Math.round((1 - 8 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
|
|
1141
|
+
monthly: { start: Math.max(0, Math.round((1 - 13 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
|
|
1142
|
+
annual: { start: 0, end: 100 },
|
|
1143
|
+
};
|
|
1144
|
+
const slider = sliderDefaults[myChart] ?? { start: 0, end: 100 };
|
|
1145
|
+
|
|
852
1146
|
const chart = {
|
|
853
1147
|
backgroundColor: '#fff',
|
|
854
1148
|
animation: false,
|
|
@@ -859,7 +1153,17 @@ class statistics {
|
|
|
859
1153
|
legend: {
|
|
860
1154
|
top: 35,
|
|
861
1155
|
left: 'center',
|
|
862
|
-
data: [
|
|
1156
|
+
data: [
|
|
1157
|
+
'Solar Yield',
|
|
1158
|
+
'Grid Export',
|
|
1159
|
+
'Grid Import',
|
|
1160
|
+
'Charge',
|
|
1161
|
+
'Discharge',
|
|
1162
|
+
...(showSOC ? ['SOC'] : []),
|
|
1163
|
+
'Self-sufficiency',
|
|
1164
|
+
'Self-consumption',
|
|
1165
|
+
'Consumption',
|
|
1166
|
+
],
|
|
863
1167
|
},
|
|
864
1168
|
tooltip: {
|
|
865
1169
|
trigger: 'axis',
|
|
@@ -888,9 +1192,25 @@ class statistics {
|
|
|
888
1192
|
saveAsImage: { show: true },
|
|
889
1193
|
},
|
|
890
1194
|
},
|
|
1195
|
+
// No-data graphic — shown only when data is empty
|
|
1196
|
+
graphic:
|
|
1197
|
+
xAxisData.length === 0
|
|
1198
|
+
? [
|
|
1199
|
+
{
|
|
1200
|
+
type: 'text',
|
|
1201
|
+
left: 'center',
|
|
1202
|
+
top: 'middle',
|
|
1203
|
+
style: {
|
|
1204
|
+
text: noDataHints[myChart] || 'No data available yet.',
|
|
1205
|
+
fontSize: 14,
|
|
1206
|
+
fill: '#999',
|
|
1207
|
+
},
|
|
1208
|
+
},
|
|
1209
|
+
]
|
|
1210
|
+
: [],
|
|
891
1211
|
grid: [
|
|
892
|
-
{ left: '8%', right: showSOC ? '8%' : '4%', top: 80, height: '
|
|
893
|
-
{ left: '8%', right: showSOC ? '8%' : '4%', top: '
|
|
1212
|
+
{ left: '8%', right: showSOC ? '8%' : '4%', top: 80, height: '45%' },
|
|
1213
|
+
{ left: '8%', right: showSOC ? '8%' : '4%', top: '72%', height: '15%' },
|
|
894
1214
|
],
|
|
895
1215
|
xAxis: [
|
|
896
1216
|
{
|
|
@@ -915,7 +1235,7 @@ class statistics {
|
|
|
915
1235
|
gridIndex: 1,
|
|
916
1236
|
data: xAxisData,
|
|
917
1237
|
scale: true,
|
|
918
|
-
boundaryGap:
|
|
1238
|
+
boundaryGap: chartStyle !== 'line',
|
|
919
1239
|
axisLine: { onZero: false },
|
|
920
1240
|
axisTick: { show: false },
|
|
921
1241
|
splitLine: { show: false },
|
|
@@ -925,7 +1245,7 @@ class statistics {
|
|
|
925
1245
|
},
|
|
926
1246
|
],
|
|
927
1247
|
yAxis: [
|
|
928
|
-
// Index 0 —
|
|
1248
|
+
// Index 0 — Energy left axis
|
|
929
1249
|
{
|
|
930
1250
|
scale: false,
|
|
931
1251
|
splitArea: { show: true },
|
|
@@ -936,23 +1256,19 @@ class statistics {
|
|
|
936
1256
|
splitLine: { show: true },
|
|
937
1257
|
axisLine: { show: true },
|
|
938
1258
|
},
|
|
939
|
-
// Index 1 — SOC
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
},
|
|
953
|
-
]
|
|
954
|
-
: []),
|
|
955
|
-
// Index 1 oder 2 — Consumption unteres Grid
|
|
1259
|
+
// Index 1 — SOC / ratio right axis
|
|
1260
|
+
{
|
|
1261
|
+
type: 'value',
|
|
1262
|
+
min: 0,
|
|
1263
|
+
max: 100,
|
|
1264
|
+
name: showSOC ? 'SOC / Ratio (%)' : 'Ratio (%)',
|
|
1265
|
+
nameLocation: 'middle',
|
|
1266
|
+
nameGap: 50,
|
|
1267
|
+
axisLabel: { formatter: '{value} %' },
|
|
1268
|
+
splitLine: { show: false },
|
|
1269
|
+
axisLine: { show: true },
|
|
1270
|
+
},
|
|
1271
|
+
// Index 2 — Consumption lower grid
|
|
956
1272
|
{
|
|
957
1273
|
scale: true,
|
|
958
1274
|
gridIndex: 1,
|
|
@@ -967,11 +1283,23 @@ class statistics {
|
|
|
967
1283
|
},
|
|
968
1284
|
],
|
|
969
1285
|
dataZoom: [
|
|
970
|
-
{
|
|
971
|
-
|
|
1286
|
+
{
|
|
1287
|
+
type: 'inside',
|
|
1288
|
+
xAxisIndex: [0, 1],
|
|
1289
|
+
start: slider.start,
|
|
1290
|
+
end: slider.end,
|
|
1291
|
+
},
|
|
1292
|
+
{
|
|
1293
|
+
show: true,
|
|
1294
|
+
xAxisIndex: [0, 1],
|
|
1295
|
+
type: 'slider',
|
|
1296
|
+
bottom: 5,
|
|
1297
|
+
start: slider.start,
|
|
1298
|
+
end: slider.end,
|
|
1299
|
+
},
|
|
972
1300
|
],
|
|
973
1301
|
series: [
|
|
974
|
-
// Positive
|
|
1302
|
+
// Positive values (above zero line)
|
|
975
1303
|
{
|
|
976
1304
|
name: 'Solar Yield',
|
|
977
1305
|
type: seriesType,
|
|
@@ -980,40 +1308,40 @@ class statistics {
|
|
|
980
1308
|
emphasis: { focus: 'series' },
|
|
981
1309
|
...lineOptions,
|
|
982
1310
|
},
|
|
1311
|
+
// Negative values (below zero line)
|
|
983
1312
|
{
|
|
984
|
-
name: 'Grid
|
|
1313
|
+
name: 'Grid Export',
|
|
985
1314
|
type: seriesType,
|
|
986
|
-
data: seriesData.
|
|
987
|
-
itemStyle: { color: '#
|
|
1315
|
+
data: negate(seriesData.gridExport),
|
|
1316
|
+
itemStyle: { color: '#5cb85c' },
|
|
988
1317
|
emphasis: { focus: 'series' },
|
|
989
1318
|
...lineOptions,
|
|
990
1319
|
},
|
|
991
1320
|
{
|
|
992
|
-
name: '
|
|
1321
|
+
name: 'Grid Import',
|
|
993
1322
|
type: seriesType,
|
|
994
|
-
data: seriesData.
|
|
995
|
-
itemStyle: { color: '#
|
|
1323
|
+
data: seriesData.gridImport,
|
|
1324
|
+
itemStyle: { color: '#ec0000' },
|
|
996
1325
|
emphasis: { focus: 'series' },
|
|
997
1326
|
...lineOptions,
|
|
998
1327
|
},
|
|
999
|
-
// Negative Werte (unterhalb Nulllinie)
|
|
1000
1328
|
{
|
|
1001
|
-
name: '
|
|
1329
|
+
name: 'Charge',
|
|
1002
1330
|
type: seriesType,
|
|
1003
|
-
data: negate(seriesData.
|
|
1004
|
-
itemStyle: { color: '#
|
|
1331
|
+
data: negate(seriesData.chargeCapacity),
|
|
1332
|
+
itemStyle: { color: '#5bc0de' },
|
|
1005
1333
|
emphasis: { focus: 'series' },
|
|
1006
1334
|
...lineOptions,
|
|
1007
1335
|
},
|
|
1008
1336
|
{
|
|
1009
|
-
name: '
|
|
1337
|
+
name: 'Discharge',
|
|
1010
1338
|
type: seriesType,
|
|
1011
|
-
data:
|
|
1012
|
-
itemStyle: { color: '#
|
|
1339
|
+
data: seriesData.dischargeCapacity,
|
|
1340
|
+
itemStyle: { color: '#ed50e0' },
|
|
1013
1341
|
emphasis: { focus: 'series' },
|
|
1014
1342
|
...lineOptions,
|
|
1015
1343
|
},
|
|
1016
|
-
// SOC —
|
|
1344
|
+
// SOC — hourly only, on right axis
|
|
1017
1345
|
...(showSOC
|
|
1018
1346
|
? [
|
|
1019
1347
|
{
|
|
@@ -1028,18 +1356,41 @@ class statistics {
|
|
|
1028
1356
|
},
|
|
1029
1357
|
]
|
|
1030
1358
|
: []),
|
|
1031
|
-
//
|
|
1032
|
-
|
|
1359
|
+
// Self-sufficiency — right axis
|
|
1360
|
+
{
|
|
1361
|
+
name: 'Self-sufficiency',
|
|
1362
|
+
type: 'line',
|
|
1363
|
+
yAxisIndex: 1,
|
|
1364
|
+
data: seriesData.selfSufficiency,
|
|
1365
|
+
itemStyle: { color: '#9c27b0' },
|
|
1366
|
+
lineStyle: { width: 2, type: 'dashed' },
|
|
1367
|
+
symbol: 'circle',
|
|
1368
|
+
symbolSize: 4,
|
|
1369
|
+
smooth: true,
|
|
1370
|
+
},
|
|
1371
|
+
// Self-consumption — right axis
|
|
1372
|
+
{
|
|
1373
|
+
name: 'Self-consumption',
|
|
1374
|
+
type: 'line',
|
|
1375
|
+
yAxisIndex: 1,
|
|
1376
|
+
data: seriesData.selfConsumption,
|
|
1377
|
+
itemStyle: { color: '#ff9800' },
|
|
1378
|
+
lineStyle: { width: 2, type: 'dashed' },
|
|
1379
|
+
symbol: 'circle',
|
|
1380
|
+
symbolSize: 4,
|
|
1381
|
+
smooth: true,
|
|
1382
|
+
},
|
|
1383
|
+
// Consumption in lower grid — always yAxisIndex 2
|
|
1033
1384
|
{
|
|
1034
1385
|
name: 'Consumption',
|
|
1035
1386
|
type: seriesType,
|
|
1036
1387
|
data: seriesData.consumption,
|
|
1037
1388
|
itemStyle: { color: '#337ab7' },
|
|
1038
1389
|
xAxisIndex: 1,
|
|
1039
|
-
yAxisIndex:
|
|
1390
|
+
yAxisIndex: 2,
|
|
1040
1391
|
...lineOptions,
|
|
1041
1392
|
},
|
|
1042
|
-
//
|
|
1393
|
+
// Day-break areas (hourly only)
|
|
1043
1394
|
...(dayAreas.length > 0
|
|
1044
1395
|
? [
|
|
1045
1396
|
{
|
|
@@ -1059,6 +1410,11 @@ class statistics {
|
|
|
1059
1410
|
return stringify(chart).replace("'%%xAxisFormatter%%'", stringify(xAxisFormatterHourly)).replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
|
|
1060
1411
|
}
|
|
1061
1412
|
|
|
1413
|
+
/**
|
|
1414
|
+
* Merges two objects deeply.
|
|
1415
|
+
* @param target
|
|
1416
|
+
* @param source
|
|
1417
|
+
*/
|
|
1062
1418
|
_deepMerge(target, source) {
|
|
1063
1419
|
for (const key of Object.keys(source)) {
|
|
1064
1420
|
if (
|
|
@@ -1069,16 +1425,19 @@ class statistics {
|
|
|
1069
1425
|
typeof target[key] === 'object' &&
|
|
1070
1426
|
!Array.isArray(target[key])
|
|
1071
1427
|
) {
|
|
1072
|
-
// Rekursiv für verschachtelte Objekte
|
|
1073
1428
|
this._deepMerge(target[key], source[key]);
|
|
1074
1429
|
} else {
|
|
1075
|
-
// Primitive, Arrays direkt überschreiben
|
|
1076
1430
|
target[key] = source[key];
|
|
1077
1431
|
}
|
|
1078
1432
|
}
|
|
1079
1433
|
return target;
|
|
1080
1434
|
}
|
|
1081
1435
|
|
|
1436
|
+
/**
|
|
1437
|
+
* Handles a template state change — updates cache, acknowledges and rebuilds chart.
|
|
1438
|
+
* @param chartType
|
|
1439
|
+
* @param state
|
|
1440
|
+
*/
|
|
1082
1441
|
async handleTemplateChange(chartType, state) {
|
|
1083
1442
|
const templateStateId = `statistics.flexCharts.template.${chartType}`;
|
|
1084
1443
|
const template = this.stateCache.get(templateStateId)?.value;
|
|
@@ -1097,7 +1456,7 @@ class statistics {
|
|
|
1097
1456
|
/**
|
|
1098
1457
|
* Entry point for adapter to handle messages related to statistics/flexcharts.
|
|
1099
1458
|
*
|
|
1100
|
-
* @param {{chart?: string}} message
|
|
1459
|
+
* @param {{chart?: string, style?: string}} message
|
|
1101
1460
|
* @param {Function} callback
|
|
1102
1461
|
*/
|
|
1103
1462
|
handleFlexMessage(message, callback) {
|