iobroker.sun2000 2.4.0 → 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/lib/statistics.js CHANGED
@@ -15,6 +15,7 @@ in ioBroker VIS using the ioBroker.flexcharts adapter.
15
15
 
16
16
  'use strict';
17
17
 
18
+ const stringify = require('javascript-stringify').stringify;
18
19
  const { dataRefreshRate, statisticsType } = require(`${__dirname}/types.js`);
19
20
  const tools = require(`${__dirname}/tools.js`);
20
21
 
@@ -23,32 +24,17 @@ class statistics {
23
24
  this.adapter = adapterInstance;
24
25
  this.stateCache = stateCache;
25
26
  this.taskTimer = null;
27
+ this._path = 'statistics';
28
+ this._initialized = false;
26
29
  this.testing = false; // set to true for testing purposes
27
- // initialize to current time to avoid immediate backfill on startup
28
- //const nowInit = new Date();
29
- this.lastExecution = {
30
- hourly: undefined,
31
- daily: undefined,
32
- weekly: undefined,
33
- monthly: undefined,
34
- annual: undefined,
35
- };
36
30
 
37
31
  this.stats = [
38
32
  {
39
33
  sourceId: 'collected.consumptionToday',
40
34
  targetPath: 'consumption',
41
35
  unit: 'kWh',
42
- type: statisticsType.deltaReset, // value is a total that resets at the start of the period, so we need to calculate the delta to get the actual consumption for the period
43
- },
44
- /*
45
- {
46
- sourceId: 'collected.consumptionSum',
47
- targetPath: 'consumptionSum',
48
- unit: 'kWh',
49
- type: statisticsType.delta,
36
+ type: statisticsType.deltaReset,
50
37
  },
51
- */
52
38
  {
53
39
  sourceId: 'collected.dailySolarYield',
54
40
  targetPath: 'solarYield',
@@ -56,23 +42,50 @@ class statistics {
56
42
  type: statisticsType.deltaReset,
57
43
  },
58
44
  { sourceId: 'collected.dailyInputYield', targetPath: 'inputYield', unit: 'kWh', type: statisticsType.deltaReset },
59
- {
60
- sourceId: 'collected.dailyExternalYield',
61
- targetPath: 'externalYield',
62
- unit: 'kWh',
63
- type: statisticsType.deltaReset,
64
- },
45
+ { sourceId: 'collected.dailyExternalYield', targetPath: 'externalYield', unit: 'kWh', type: statisticsType.deltaReset },
65
46
  { sourceId: 'collected.dailyEnergyYield', targetPath: 'energyYield', unit: 'kWh', type: statisticsType.deltaReset },
66
47
  {
67
48
  sourceId: 'collected.SOC',
68
49
  targetPath: 'SOC',
69
50
  unit: '%',
70
- type: statisticsType.level, // value is a level that can go up and down, so we take the value as is without calculating delta
51
+ type: statisticsType.level,
71
52
  },
72
53
  { sourceId: 'collected.currentDayChargeCapacity', targetPath: 'chargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
73
54
  { sourceId: 'collected.currentDayDischargeCapacity', targetPath: 'dischargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
74
55
  { sourceId: 'collected.gridExportToday', targetPath: 'gridExport', unit: 'kWh', type: statisticsType.deltaReset },
75
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
+ },
76
89
  ];
77
90
 
78
91
  this.postProcessHooks = [
@@ -119,17 +132,102 @@ class statistics {
119
132
  desc: 'Annual consumption per year',
120
133
  initVal: '[]',
121
134
  },
122
- // a state where users may store a Flexcharts/eCharts options template
123
- /*
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
145
+ {
146
+ id: 'statistics.flexCharts.template.hourly',
147
+ name: 'Flexcharts template hourly',
148
+ type: 'string',
149
+ role: 'json',
150
+ desc: 'Optional eCharts template for hourly chart. Leave empty {} for built-in layout.',
151
+ write: true,
152
+ initVal: '{}',
153
+ },
124
154
  {
125
- id: 'statistics.flexChartTemplate',
126
- name: 'Flexcharts template',
155
+ id: 'statistics.flexCharts.template.daily',
156
+ name: 'Flexcharts template daily',
127
157
  type: 'string',
128
158
  role: 'json',
129
- desc: 'Optional eCharts option object that will be merged into the default chart. Leave empty for the builtin layout.',
159
+ desc: 'Optional eCharts template for daily chart. Leave empty {} for built-in layout.',
160
+ write: true,
161
+ initVal: '{}',
162
+ },
163
+ {
164
+ id: 'statistics.flexCharts.template.weekly',
165
+ name: 'Flexcharts template weekly',
166
+ type: 'string',
167
+ role: 'json',
168
+ desc: 'Optional eCharts template for weekly chart. Leave empty {} for built-in layout.',
169
+ write: true,
170
+ initVal: '{}',
171
+ },
172
+ {
173
+ id: 'statistics.flexCharts.template.monthly',
174
+ name: 'Flexcharts template monthly',
175
+ type: 'string',
176
+ role: 'json',
177
+ desc: 'Optional eCharts template for monthly chart. Leave empty {} for built-in layout.',
178
+ write: true,
179
+ initVal: '{}',
180
+ },
181
+ {
182
+ id: 'statistics.flexCharts.template.annual',
183
+ name: 'Flexcharts template annual',
184
+ type: 'string',
185
+ role: 'json',
186
+ desc: 'Optional eCharts template for annual chart. Leave empty {} for built-in layout.',
187
+ write: true,
188
+ initVal: '{}',
189
+ },
190
+ // Output: one per chart type
191
+ {
192
+ id: 'statistics.flexCharts.jsonOutput.hourly',
193
+ name: 'Flexcharts output hourly',
194
+ type: 'string',
195
+ role: 'json',
196
+ desc: 'ECharts configuration for hourly chart',
197
+ initVal: '{}',
198
+ },
199
+ {
200
+ id: 'statistics.flexCharts.jsonOutput.daily',
201
+ name: 'Flexcharts output daily',
202
+ type: 'string',
203
+ role: 'json',
204
+ desc: 'ECharts configuration for daily chart',
205
+ initVal: '{}',
206
+ },
207
+ {
208
+ id: 'statistics.flexCharts.jsonOutput.weekly',
209
+ name: 'Flexcharts output weekly',
210
+ type: 'string',
211
+ role: 'json',
212
+ desc: 'ECharts configuration for weekly chart',
213
+ initVal: '{}',
214
+ },
215
+ {
216
+ id: 'statistics.flexCharts.jsonOutput.monthly',
217
+ name: 'Flexcharts output monthly',
218
+ type: 'string',
219
+ role: 'json',
220
+ desc: 'ECharts configuration for monthly chart',
221
+ initVal: '{}',
222
+ },
223
+ {
224
+ id: 'statistics.flexCharts.jsonOutput.annual',
225
+ name: 'Flexcharts output annual',
226
+ type: 'string',
227
+ role: 'json',
228
+ desc: 'ECharts configuration for annual chart',
130
229
  initVal: '{}',
131
230
  },
132
- */
133
231
  ],
134
232
  },
135
233
  ];
@@ -166,13 +264,11 @@ class statistics {
166
264
  * Generic function to calculate consumption statistics for different time periods.
167
265
  *
168
266
  * @param {string} stateId - The state ID for storing the JSON
169
- * @param {string} consumptionKey - The state key for consumption value
170
267
  * @param {Date} periodStart - The start of the current period
171
- * @param {string} periodType - The type of period (hourly, daily, weekly, monthly, annual)
172
- * @returns {Promise<void>}
268
+ * @param {Date} periodEnde - The end of the current period
269
+ * @returns {boolean} true if a new entry was appended, false otherwise.
173
270
  */
174
-
175
- async _calculateGeneric(stateId, periodStart, periodEnde) {
271
+ _calculateGeneric(stateId, periodStart, periodEnde) {
176
272
  const toStr = this._localIsoWithOffset(periodEnde);
177
273
  let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
178
274
  let arr = [];
@@ -186,7 +282,6 @@ class statistics {
186
282
  let last = {};
187
283
  if (arr.length > 0) {
188
284
  last = arr[arr.length - 1];
189
- // avoid duplicates
190
285
  if (last.to === toStr) return false;
191
286
  const lastToDate = new Date(last.to);
192
287
  const toDate = new Date(toStr);
@@ -200,7 +295,10 @@ class statistics {
200
295
  to: toStr,
201
296
  };
202
297
 
298
+ // First pass: deltaReset, delta, level stats
203
299
  for (const stat of this.stats) {
300
+ if (stat.type === statisticsType.computed) continue;
301
+
204
302
  const source = this.stateCache.get(stat.sourceId)?.value;
205
303
  if (source === null || source === undefined) {
206
304
  this.adapter.logger.warn(`Source state ${stat.sourceId} not found statistic hook`);
@@ -210,64 +308,55 @@ class statistics {
210
308
  if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
211
309
  const lastTotal = Number(last[stat.targetPath]?.['total'] ?? 0);
212
310
  if (stat.type === statisticsType.deltaReset) {
213
- //if (value >= lastTotal * 0.5) {
214
311
  if (fromDate.getTime() !== periodStart.getTime()) {
215
- // Delta-Berechnung
216
312
  value -= lastTotal;
217
313
  }
218
314
  } else {
219
- // Ein lastTotal-Wert vorhanden –> normale Delta-Berechnung
220
315
  if (last[stat.targetPath]?.['total'] === undefined) {
221
- // Kein lastTotal-Wert vorhanden –> wahrscheinlich erster Eintrag, Delta-Berechnung nicht möglich
222
316
  this.adapter.logger.debug(`No total value found for ${stat.targetPath} in last entry, setting delta to 0`);
223
317
  value = 0;
224
318
  } else {
225
- // Delta-Berechnung
226
319
  value -= lastTotal;
227
320
  }
228
321
  }
229
322
  }
230
323
  value = Math.round((Number(value) + Number.EPSILON) * 1000) / 1000;
231
- entry[stat.targetPath] = {
232
- value: Number(value.toFixed(3)),
233
- };
234
-
324
+ entry[stat.targetPath] = { value: Number(value.toFixed(3)) };
235
325
  if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
236
326
  entry[stat.targetPath].total = Number(source.toFixed(3));
237
327
  }
238
- entry[stat.targetPath].unit = stat.unit || 'kWh'; // can be extended for other stats with different units
328
+ entry[stat.targetPath].unit = stat.unit || 'kWh';
239
329
  }
240
- arr.push(entry);
241
330
 
242
- arr.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
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
+ }
243
345
 
346
+ arr.push(entry);
347
+ arr.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
244
348
  this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
245
349
  this.adapter.logger.debug(`Appended ${stateId} statistic ${toStr}`);
350
+ return arr.length > 0;
246
351
  }
247
352
 
248
353
  /**
249
- * Calculates and updates hourly consumption statistics.
250
- *
251
- * This function calculates the hourly consumption statistics based on the current day's data.
252
- * It retrieves the consumption data and updates the hourly consumption JSON accordingly.
354
+ * Removes entries older than periodStart from the given state.
253
355
  *
254
- * @returns {void}
356
+ * @param {string} stateId
357
+ * @param {Date} periodStart
255
358
  */
256
- async _calculateHourly() {
257
- const now = new Date();
258
- if (this.testing) {
259
- const state = await this.adapter.getState('statistics.jsonHourly');
260
- this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
261
- now.setDate(now.getDate() + 1); // set to start of day for testing to have consistent results
262
- now.setHours(1, 0, 0, 1); // set to 1ms after midnight to trigger hourly calculation for the new day
263
- }
264
- const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
265
- const lastHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0);
266
- this.adapter.log.debug('### Hourly execution triggered ###');
267
- this._calculateGeneric('statistics.jsonHourly', startOfDay, lastHour) && (this.lastExecution.hourly = now); // only update last execution time if calculation was performed to avoid backfilling multiple hours at startup
268
- }
269
-
270
- async _clearGeneric(stateId, periodStart) {
359
+ _clearGeneric(stateId, periodStart) {
271
360
  let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
272
361
  let arr = [];
273
362
  try {
@@ -276,29 +365,163 @@ class statistics {
276
365
  } catch {
277
366
  arr = [];
278
367
  }
279
-
280
- // Keep only entries within the window
281
368
  arr = arr.filter(item => {
282
369
  const ts = Date.parse(item.from);
283
370
  return !Number.isNaN(ts) && ts >= periodStart.getTime();
284
371
  });
285
-
286
372
  this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
287
373
  }
288
374
 
375
+ // eslint-disable-next-line jsdoc/require-returns-check
289
376
  /**
290
- * Calculates and aggregates consumption statistics based on the given parameters.
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.
291
382
  *
292
- * This function calculates and aggregates the consumption statistics based on the source entries within a specific window.
293
- * It retrieves the source entries, filters them based on the window, calculates the sum of consumption, and appends the result to the target array.
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
389
+ */
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.
294
517
  *
295
- * @param {string} sourceStateId - The ID of the source state to retrieve entries from.
296
- * @param {string} targetStateId - The ID of the target state to append the aggregated result.
297
- * @param {Function} getWindow - A function that returns the start and end date of the window based on the current date.
298
- * @param {string} periodType - The type of period for which the aggregation is performed.
299
- * @returns {void}
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.
300
523
  */
301
- async _calculateAggregation(sourceStateId, targetStateId, getWindow, periodType) {
524
+ _calculateAggregation_old(sourceStateId, targetStateId, getWindow, periodType) {
302
525
  try {
303
526
  const now = new Date();
304
527
  const window = getWindow(now);
@@ -306,11 +529,11 @@ class statistics {
306
529
  const toDate = window.to;
307
530
  if (now < toDate) {
308
531
  this.adapter.logger.debug(`statistics.js: Skipping ${periodType} aggregation because current time is before end of aggregation window`);
309
- return;
532
+ return false;
310
533
  }
311
534
  const toStr = this._localIsoWithOffset(toDate);
535
+ //const fromStr = this._localIsoWithOffset(fromDate);
312
536
 
313
- // Load target array
314
537
  let jsonTarget = this.stateCache.get(targetStateId)?.value ?? '[]';
315
538
  let targetArray = [];
316
539
  try {
@@ -320,16 +543,15 @@ class statistics {
320
543
  targetArray = [];
321
544
  }
322
545
 
323
- // Avoid duplicates
324
546
  const last = targetArray.length > 0 ? targetArray[targetArray.length - 1] : {};
325
- if (last.to === toStr) return;
547
+
548
+ if (last.to === toStr) return false;
326
549
 
327
550
  const target = {
328
551
  from: this._localIsoWithOffset(fromDate),
329
552
  to: toStr,
330
553
  };
331
554
 
332
- // Load source entries
333
555
  let jsonStr = this.stateCache.get(sourceStateId)?.value ?? '[]';
334
556
  let sourceEntries = [];
335
557
  try {
@@ -339,27 +561,22 @@ class statistics {
339
561
  sourceEntries = [];
340
562
  }
341
563
 
342
- // Keep only entries within the window
343
564
  sourceEntries = sourceEntries.filter(item => {
344
565
  const ts = Date.parse(item.from);
345
566
  return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < toDate.getTime();
346
567
  });
347
- // If there are no source entries, we can skip the aggregation and avoid creating empty entries in the target array
568
+
348
569
  if (sourceEntries.length > 0) {
349
570
  this.adapter.logger.debug(
350
571
  `statistics.js: Found ${sourceEntries.length} source entries for ${periodType} aggregation between ${fromDate.toISOString()} and ${toDate.toISOString()}`,
351
572
  );
352
573
 
574
+ // First pass: sum delta/deltaReset stats
353
575
  for (const stat of this.stats) {
354
- // Sum consumption for the window
355
- if (stat.type === statisticsType.level) continue; // Skip level statistics
576
+ if (stat.type === statisticsType.level) continue;
577
+ if (stat.type === statisticsType.computed) continue;
356
578
 
357
579
  let sum = 0;
358
- /*
359
- if (stat.type === statisticsType.average) {
360
- stat.sum = sourceEntries.length > 0 ? sourceEntries[sourceEntries.length - 1]?.[stat.targetPath]?.['total'] : 0;
361
- } else {
362
- */
363
580
  try {
364
581
  sourceEntries.forEach(entry => {
365
582
  sum += Number(entry[stat.targetPath]?.['value'] ?? 0);
@@ -367,154 +584,277 @@ class statistics {
367
584
  } catch (e) {
368
585
  this.adapter.logger.warn(`statistics.js: Error during ${periodType} statistic aggregation: ${e.message}`);
369
586
  }
370
-
371
587
  sum = Math.round((Number(sum) + Number.EPSILON) * 1000) / 1000;
588
+ target[stat.targetPath] = { value: Number(sum.toFixed(3)), unit: stat.unit || 'kWh' };
589
+ }
372
590
 
373
- target[stat.targetPath] = {
374
- value: Number(sum.toFixed(3)),
375
- unit: stat.unit || 'kWh',
376
- };
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
+ }
377
604
  }
378
605
 
379
606
  targetArray.push(target);
380
607
  }
381
608
 
382
- // Retention: keep entries from retention start onwards
383
- /*
384
- const retentionStart = getRetentionStart(now);
385
- targetArray = targetArray.filter(item => {
386
- const ts = Date.parse(item.from);
387
- return !Number.isNaN(ts) && ts >= retentionStart.getTime();
388
- });
389
- */
390
-
391
609
  targetArray.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
392
610
  this.stateCache.set(targetStateId, JSON.stringify(targetArray), { type: 'string' });
393
- this.adapter.logger.debug(`Appended ${periodType} statistic ${toStr} `);
394
- return true;
611
+ this.adapter.logger.debug(`Appended ${periodType} statistic ${toStr}`);
612
+ return targetArray.length > 0;
395
613
  } catch (err) {
396
614
  this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
397
615
  }
398
616
  }
399
617
 
400
618
  /**
401
- * Calculates and updates daily consumption statistics from hourly data.
402
- *
403
- * @returns {void}
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
+
681
+ /**
682
+ * Calculates and updates hourly consumption statistics.
404
683
  */
405
- async _calculateDaily() {
684
+ _calculateHourly() {
685
+ const now = new Date();
686
+ if (this.testing) {
687
+ const state = this.adapter.getState('statistics.jsonHourly');
688
+ this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
689
+ now.setDate(now.getDate() + 1);
690
+ now.setHours(1, 0, 0, 1);
691
+ }
692
+ const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
693
+ const lastHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0);
694
+ this.adapter.log.debug(`### Hourly execution triggered with lastHour: ${lastHour.toLocaleTimeString()} ###`);
695
+ if (this._calculateGeneric('statistics.jsonHourly', startOfDay, lastHour)) {
696
+ this._buildFlexchart('hourly');
697
+ }
698
+ }
699
+
700
+ _calculateDaily() {
406
701
  this.adapter.log.debug('### Daily execution triggered ###');
702
+
703
+ // Abgeschlossener Vortag — normaler Eintrag
407
704
  this._calculateAggregation(
408
705
  'statistics.jsonHourly',
409
706
  'statistics.jsonDaily',
410
707
  now => {
411
- // aggregation window: previous day (day that just ended)
412
708
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
413
709
  const yesterday = new Date(today);
414
710
  yesterday.setDate(today.getDate() - 1);
415
711
  return { from: yesterday, to: today };
416
712
  },
417
713
  'daily',
418
- ) && (this.lastExecution.daily = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple days at startup
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');
419
728
  }
420
729
 
421
730
  /**
422
731
  * Calculates and updates weekly consumption statistics from daily data.
423
- *
424
- * @returns {void}
425
732
  */
426
- async _calculateWeekly() {
733
+ _calculateWeekly() {
427
734
  this.adapter.log.debug('### Weekly execution triggered ###');
428
735
  this._calculateAggregation(
429
736
  'statistics.jsonDaily',
430
737
  'statistics.jsonWeekly',
431
738
  now => {
432
- // aggregation window: Monday to Sunday of the previous week (week that just ended)
433
- const startday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
434
- const lastday = new Date(startday);
435
- // set to Monday of previous week
436
- lastday.setDate(now.getDate() - (now.getDay() || 7) + 1); // set to Monday of actual week
437
- startday.setDate(lastday.getDate() - 7); // set to Monday of previous week
438
- return { from: startday, to: lastday };
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 };
439
748
  },
440
749
  'weekly',
441
- ) && (this.lastExecution.weekly = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple weeks at startup
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');
442
766
  }
443
767
 
444
768
  /**
445
769
  * Calculates and updates monthly consumption statistics from daily data.
446
- *
447
- * @returns {void}
448
770
  */
449
- async _calculateMonthly() {
771
+ _calculateMonthly() {
450
772
  this.adapter.log.debug('### Monthly execution triggered ###');
451
773
  this._calculateAggregation(
452
774
  'statistics.jsonDaily',
453
775
  'statistics.jsonMonthly',
454
776
  now => {
455
- // aggregation windowStart: previous month (month that just ended)
456
777
  const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
457
778
  const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1, 0, 0, 0, 0);
458
779
  return { from: prevMonth, to: thisMonth };
459
780
  },
460
781
  'monthly',
461
- ) && (this.lastExecution.monthly = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple months at startup
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');
462
795
  }
463
796
 
464
797
  /**
465
798
  * Calculates and updates annual consumption statistics from daily data.
466
- *
467
- * @returns {void}
468
799
  */
469
- async _calculateAnnual() {
800
+ _calculateAnnual() {
470
801
  this.adapter.log.debug('### Annual execution triggered ###');
471
802
  this._calculateAggregation(
472
803
  'statistics.jsonDaily',
473
804
  'statistics.jsonAnnual',
474
805
  now => {
475
- // aggregation window: previous year (year that just ended)
476
806
  const thisYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
477
807
  const prevYear = new Date(now.getFullYear() - 1, 0, 1, 0, 0, 0, 0);
478
808
  return { from: prevYear, to: thisYear };
479
809
  },
480
810
  'annual',
481
- ) && (this.lastExecution.annual = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple years at startup
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');
482
824
  }
483
825
 
484
826
  /**
485
827
  * Initialize and schedule the unified task manager.
486
- * This task runs every minute and checks which statistics need to be calculated.
828
+ * Runs every full hour.
487
829
  */
488
- async _initializeTask() {
830
+ _initializeTask() {
489
831
  const scheduleNextRun = () => {
490
832
  const now = new Date();
833
+
491
834
  const next = new Date(now);
492
835
  if (this.testing) {
493
- next.setMinutes(now.getMinutes() + 1, 0, 0); //every minute
836
+ next.setMinutes(now.getMinutes() + 1, 0, 0);
494
837
  } else {
495
- next.setHours(next.getHours() + 1, 0, 0, 0); //every hour
838
+ next.setHours(next.getHours() + 1, 0, 0, 100);
496
839
  }
497
-
498
- // Skip executing tasks exactly at midnight to avoid running aggregated jobs
499
840
  if (next.getHours() === 0 && next.getMinutes() === 0) {
500
- next.setHours(next.getHours() + 1, 0, 0, 0); //every hour
841
+ next.setHours(next.getHours() + 1, 0, 0, 0);
501
842
  }
502
843
  const msToNextHour = next.getTime() - now.getTime();
844
+ this.adapter.logger.debug(`### Statistics - Scheduler start ${now.toLocaleTimeString()} next ${next.toLocaleTimeString()}`);
503
845
 
504
846
  if (this.taskTimer) {
505
847
  this.adapter.clearTimeout(this.taskTimer);
506
848
  }
507
-
508
- this.taskTimer = this.adapter.setTimeout(async () => {
509
- await this._executeScheduledTasks();
510
- scheduleNextRun(); // reschedule for next hour
849
+ this.taskTimer = this.adapter.setTimeout(() => {
850
+ this._executeScheduledTasks();
851
+ scheduleNextRun();
511
852
  }, msToNextHour);
512
853
  };
513
- //await this._executeScheduledTasks(); // execute immediately on startup to catch up on any missed runs while the adapter was not running
514
- // Schedule the next run
515
854
  scheduleNextRun();
516
855
  }
517
- async _executeScheduledTasks() {
856
+
857
+ _executeScheduledTasks() {
518
858
  this._calculateHourly();
519
859
  this._calculateDaily();
520
860
  this._calculateWeekly();
@@ -523,23 +863,21 @@ class statistics {
523
863
  }
524
864
 
525
865
  /**
526
- * Executes every midnight and performs the following tasks:
527
- * - Execute all scheduled tasks to ensure that statistics are up to date.
528
- * - Clear old data based on retention policies.
866
+ * Executes every midnight:
867
+ * - Runs all scheduled tasks
868
+ * - Clears old data based on retention policies
529
869
  */
530
- async mitNightProcess() {
870
+ mitNightProcess() {
531
871
  const now = new Date();
532
- await this._executeScheduledTasks();
533
- // Clear old data based on retention policies
872
+ this._executeScheduledTasks();
534
873
  const startOfYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
535
- await this._clearGeneric('statistics.jsonDaily', startOfYear);
536
- await this._clearGeneric('statistics.jsonWeekly', startOfYear);
537
- await this._clearGeneric('statistics.jsonMonthly', startOfYear);
538
- await this._clearGeneric('statistics.jsonHourly', new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0));
874
+ this._clearGeneric('statistics.jsonDaily', startOfYear);
875
+ this._clearGeneric('statistics.jsonWeekly', startOfYear);
876
+ this._clearGeneric('statistics.jsonMonthly', startOfYear);
877
+ this._clearGeneric('statistics.jsonHourly', new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0));
539
878
  }
540
879
 
541
880
  async initialize() {
542
- // load consumption JSON states (keep as string)
543
881
  let state = await this.adapter.getState('statistics.jsonHourly');
544
882
  this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
545
883
 
@@ -555,30 +893,39 @@ class statistics {
555
893
  state = await this.adapter.getState('statistics.jsonAnnual');
556
894
  this.stateCache.set('statistics.jsonAnnual', state?.val ?? '[]', { type: 'string', stored: true });
557
895
 
558
- // load optional flexchart template
559
- /*
560
- state = await this.adapter.getState('statistics.flexChartTemplate');
561
- this.stateCache.set('statistics.flexChartTemplate', state?.val ?? '{}', { type: 'string', stored: true });
562
- */
563
- // wait until consumptionToday and so on is available to avoid running the task before the initial state is loaded
564
- /*
565
- await tools.waitForValue(() => this.stateCache.get('collected.consumptionToday')?.value, 60000);
566
- await tools.waitForValue(() => this.stateCache.get('collected.dailySolarYield')?.value, 60000);
567
- await tools.waitForValue(() => this.stateCache.get('collected.SOC')?.value, 60000);
568
- */
896
+ state = await this.adapter.getState('statistics.jsonToday');
897
+ this.stateCache.set('statistics.jsonToday', state?.val ?? '{}', { type: 'string', stored: true });
898
+
569
899
  await tools.waitForValue(() => this.stateCache.get('collected.accumulatedEnergyYield')?.value, 60000);
570
- this.mitNightProcess(); // execute once on startup to catch up on any missed runs while the adapter was not running
900
+
901
+ // Load templates — one per chart type
902
+ for (const chartType of ['hourly', 'daily', 'weekly', 'monthly', 'annual']) {
903
+ const templateStateId = `statistics.flexCharts.template.${chartType}`;
904
+ state = await this.adapter.getState(templateStateId);
905
+ this.stateCache.set(templateStateId, state?.val ?? '{}', { type: 'string', stored: true });
906
+ if (state?.ack === false) {
907
+ this.stateCache.set(templateStateId, state.val, { type: 'string' });
908
+ await this.adapter.setState(templateStateId, { val: state.val, ack: true });
909
+ this._buildFlexchart(chartType);
910
+ }
911
+ }
912
+
913
+ this.mitNightProcess();
571
914
  this._initializeTask();
915
+ this.adapter.subscribeStates(`${this._path}.*`);
916
+ this._initialized = true;
572
917
  }
573
918
 
574
919
  /**
575
- * Build a flexcharts/eCharts option object from stored statistics.
576
- * The returned object may be sent to a callback for flexcharts' script source.
920
+ * Builds and updates the Flexchart configuration for the specified chart type.
577
921
  *
578
- * @param {string} myChart - one of 'hourly','daily','weekly','monthly','annual'
579
- * @returns {object} chart configuration
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
580
925
  */
581
- _buildFlexchart(myChart) {
926
+ _buildFlexchart(myChart, chartStyle) {
927
+ chartStyle = chartStyle || (myChart === 'hourly' ? 'line' : 'bar');
928
+
582
929
  const IDS = {
583
930
  hourly: 'statistics.jsonHourly',
584
931
  daily: 'statistics.jsonDaily',
@@ -594,134 +941,528 @@ class statistics {
594
941
  data = [];
595
942
  }
596
943
 
597
- // default chart configuration (based on flexcharts discussion example)
598
- const chart = {
599
- tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
600
- legend: { show: true, orient: 'horizontal', left: 'center', top: 25 },
601
- title: { left: 'center', text: 'Statistics ' },
602
- grid: { right: '20%' },
603
- toolbox: { feature: { dataView: { show: true, readOnly: false }, restore: { show: true }, saveAsImage: { show: true } } },
604
- xAxis: [{ type: 'category', axisTick: { alignWithLabel: true }, data: [] }],
605
- yAxis: [{ type: 'value', position: 'left', alignTicks: true, axisLine: { show: true }, axisLabel: { formatter: '{value}' } }],
606
- series: [],
607
- };
608
-
609
- // merge with user-provided template if available
610
- const templateStr = this.stateCache.get('statistics.flexChartTemplate')?.value;
611
- if (templateStr) {
612
- try {
613
- const templ = JSON.parse(templateStr);
614
- for (const key of Object.keys(templ)) {
615
- if (chart[key] && typeof chart[key] === 'object' && typeof templ[key] === 'object') {
616
- // merge sub-objects shallowly
617
- Object.assign(chart[key], templ[key]);
618
- } else {
619
- chart[key] = templ[key];
620
- }
944
+ // --- X-Axis labels ---
945
+ const xAxisData = data.map(entry => {
946
+ const from = new Date(entry.from);
947
+ const to = new Date(entry.to);
948
+ if (myChart === 'hourly') {
949
+ return `${to.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })} ${to.toLocaleTimeString('de-DE', {
950
+ hour12: false,
951
+ hour: '2-digit',
952
+ minute: '2-digit',
953
+ })}`;
954
+ }
955
+ if (myChart === 'weekly') {
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);
621
960
  }
622
- } catch (e) {
623
- this.adapter.logger.warn(`statistics: invalid flexChartTemplate JSON: ${e.message}`);
961
+ return `${from.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}-${toDay.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}`;
624
962
  }
963
+ if (myChart === 'monthly') {
964
+ return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit' });
965
+ }
966
+ if (myChart === 'annual') {
967
+ return from.toLocaleDateString('de-DE', { year: 'numeric' });
968
+ }
969
+ return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
970
+ });
971
+
972
+ const xAxisDataShort = myChart === 'hourly' ? xAxisData.map(label => label.split(' ')[1]) : xAxisData;
973
+
974
+ // --- Day areas (hourly only) ---
975
+ const dayAreas = [];
976
+ if (myChart === 'hourly' && xAxisData.length > 0) {
977
+ const dayBoundaries = [0];
978
+ xAxisData.forEach((label, i) => {
979
+ if (i === 0) return;
980
+ const date = label.split(' ')[0];
981
+ const prevDate = xAxisData[i - 1].split(' ')[0];
982
+ if (date !== prevDate) dayBoundaries.push(i);
983
+ });
984
+ dayBoundaries.push(xAxisData.length);
985
+
986
+ dayBoundaries.forEach((startIdx, d) => {
987
+ if (d >= dayBoundaries.length - 1) return;
988
+ const endIdx = dayBoundaries[d + 1];
989
+ const date = xAxisData[startIdx].split(' ')[0];
990
+ const shaded = d % 2 === 1;
991
+ dayAreas.push([
992
+ {
993
+ xAxis: startIdx - 0.5,
994
+ label: {
995
+ show: true,
996
+ position: 'insideTop',
997
+ formatter: date,
998
+ color: '#555',
999
+ fontSize: 11,
1000
+ fontWeight: 'bold',
1001
+ backgroundColor: 'rgba(255,255,255,0.7)',
1002
+ padding: [2, 4],
1003
+ borderRadius: 3,
1004
+ },
1005
+ },
1006
+ {
1007
+ xAxis: endIdx - 0.5,
1008
+ itemStyle: shaded
1009
+ ? { color: 'rgba(180,180,180,0.15)', borderColor: 'rgba(120,120,120,0.3)', borderWidth: 1, borderType: 'dashed' }
1010
+ : { color: 'rgba(255,255,255,0)' },
1011
+ },
1012
+ ]);
1013
+ });
625
1014
  }
626
1015
 
627
- // fill data arrays and collect units from stats definitions
628
- const xAxis = [];
629
- const seriesData = {};
630
- const unitMap = {}; // targetPath -> unit string
1016
+ // --- Series data extraction ---
1017
+ const extract = key => data.map(e => Number(Number(e[key]?.value ?? 0).toFixed(3)));
1018
+ const negate = arr => arr.map(v => Number((-v).toFixed(3)));
631
1019
 
1020
+ // Build seriesData from all this.stats entries (targetPath as key)
1021
+ const seriesData = {};
632
1022
  for (const stat of this.stats) {
633
- unitMap[stat.targetPath] = stat.unit || '';
1023
+ const values = extract(stat.targetPath);
1024
+ seriesData[stat.targetPath] = values;
1025
+ seriesData[`${stat.targetPath}Neg`] = negate(values);
634
1026
  }
635
1027
 
636
- for (const entry of data) {
637
- const from = new Date(entry.from);
638
- //const to = new Date(entry.to);
639
- const xVal =
640
- myChart === 'hourly'
641
- ? from.toLocaleTimeString('de-DE', { hour12: false, hour: '2-digit', minute: '2-digit' })
642
- : from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
643
- xAxis.push(xVal);
644
- for (const stat of this.stats) {
645
- const val = Number(entry[stat.targetPath]?.value ?? 0);
646
- if (!seriesData[stat.targetPath]) {
647
- seriesData[stat.targetPath] = [];
648
- }
649
- seriesData[stat.targetPath].push(Number(val.toFixed(3)));
1028
+ // --- Tooltip formatter ---
1029
+ const tooltipFormatter = params => {
1030
+ if (!Array.isArray(params)) params = [params];
1031
+ return params
1032
+ .filter(p => p.seriesName !== 'DayBreak')
1033
+ .map(p => {
1034
+ const negatedSeries = ['Grid Export', 'Charge'];
1035
+ const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
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>`;
1040
+ })
1041
+ .join('<br/>');
1042
+ };
1043
+
1044
+ // --- Load chart-type specific template ---
1045
+ const templateStateId = `statistics.flexCharts.template.${myChart}`;
1046
+ const outputStateId = `statistics.flexCharts.jsonOutput.${myChart}`;
1047
+
1048
+ const templateStr = this.stateCache.get(templateStateId)?.value ?? '{}';
1049
+ let chartStr = '{}';
1050
+
1051
+ try {
1052
+ const templ = JSON.parse(templateStr);
1053
+ if (Object.keys(templ).length === 0) {
1054
+ chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
1055
+ } else {
1056
+ chartStr = stringify(templ);
650
1057
  }
1058
+ } catch (e) {
1059
+ this.adapter.logger.warn(`statistics: invalid template for ${myChart}: ${e.message}`);
1060
+ chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
651
1061
  }
652
- chart.xAxis[0].data = xAxis;
653
-
654
- // apply unit information to axis/tooltip if there is a single unit or per-series
655
- const units = new Set(Object.values(unitMap).filter(u => u));
656
- if (units.size === 1) {
657
- const singleUnit = [...units][0];
658
- // update default yAxis label and tooltip formatter
659
- chart.yAxis[0].name = singleUnit ? `(${singleUnit})` : '';
660
- chart.yAxis[0].axisLabel.formatter = `{value}${singleUnit ? ` ${singleUnit}` : ''}`;
661
- chart.tooltip.formatter = params => {
662
- if (!Array.isArray(params)) params = [params];
663
- return params.map(p => `${p.seriesName}: ${p.value}${singleUnit ? ` ${singleUnit}` : ''}`).join('<br/>');
664
- };
665
- } else if (units.size > 1) {
666
- // multiple units – show per-series unit in tooltip
667
- chart.tooltip.formatter = params => {
668
- if (!Array.isArray(params)) params = [params];
669
- return params
670
- .map(p => {
671
- const u = unitMap[p.seriesName] || '';
672
- return `${p.seriesName}: ${p.value}${u ? ` ${u}` : ''}`;
673
- })
674
- .join('<br/>');
675
- };
1062
+
1063
+ // --- Replace placeholders ---
1064
+ chartStr = chartStr
1065
+ .replace("'%%xAxisData%%'", JSON.stringify(xAxisData))
1066
+ .replace("'%%xAxisDataShort%%'", JSON.stringify(xAxisDataShort))
1067
+ .replace("'%%xAxisMax%%'", String(xAxisData.length - 1))
1068
+ .replace("'%%chartTitle%%'", JSON.stringify(`PV Statistics ${myChart}`))
1069
+ .replace("'%%dayAreas%%'", JSON.stringify(dayAreas))
1070
+ .replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
1071
+
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`]));
676
1076
  }
677
1077
 
678
- // if chart.series was pre-populated by template use names, otherwise create default series
679
- if (chart.series && chart.series.length > 0) {
680
- chart.series.forEach(s => {
681
- // try exact name first, otherwise case-insensitive lookup
682
- let key = s.name;
683
- if (seriesData[key]) {
684
- s.data = seriesData[key];
685
- } else {
686
- // find matching key ignoring case
687
- const found = Object.keys(seriesData).find(k => k.toLowerCase() === String(key).toLowerCase());
688
- if (found) {
689
- s.data = seriesData[found];
690
- } else {
691
- s.data = [];
692
- }
693
- }
694
- // ensure unit is added from stats definitions if not present
695
- if (!s.unit) {
696
- let unitVal = unitMap[key];
697
- if (!unitVal) {
698
- const foundu = Object.keys(unitMap).find(k => k.toLowerCase() === String(key).toLowerCase());
699
- if (foundu) unitVal = unitMap[foundu];
700
- }
701
- s.unit = unitVal || '';
702
- }
703
- });
704
- } else {
705
- chart.series = Object.keys(seriesData).map(name => ({
706
- name,
707
- type: 'line',
708
- data: seriesData[name],
709
- unit: unitMap[name] || '',
710
- }));
1078
+ this.stateCache.set(outputStateId, chartStr, { type: 'string' });
1079
+ this.adapter.logger.debug(`statistics: flexCharts built for ${myChart}/${chartStyle}`);
1080
+
1081
+ return chartStr;
1082
+ }
1083
+
1084
+ /**
1085
+ * Build the default chart configuration as javascript-stringify string.
1086
+ * Used when no template is provided.
1087
+ * @param myChart
1088
+ * @param chartStyle
1089
+ * @param xAxisData
1090
+ * @param xAxisDataShort
1091
+ * @param dayAreas
1092
+ * @param seriesData
1093
+ */
1094
+ _buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData) {
1095
+ const xAxisFormatterHourly = value => {
1096
+ if (value.includes('|')) return value;
1097
+ return value.split(' ')[1] ?? value;
1098
+ };
1099
+
1100
+ const seriesType = chartStyle === 'line' ? 'line' : 'bar';
1101
+ const lineOptions =
1102
+ chartStyle === 'line' ? { smooth: true, symbol: 'circle', symbolSize: 4, lineStyle: { width: 2 }, areaStyle: { opacity: 0.15 } } : {};
1103
+
1104
+ const negate = arr => arr.map(v => Number((-v).toFixed(3)));
1105
+ const showSOC = myChart === 'hourly';
1106
+
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
+
1116
+ const tooltipFormatter = params => {
1117
+ if (!Array.isArray(params)) params = [params];
1118
+ return params
1119
+ .filter(p => p.seriesName !== 'DayBreak')
1120
+ .map(p => {
1121
+ const negatedSeries = ['Grid Export', 'Charge'];
1122
+ const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
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>`;
1127
+ })
1128
+ .join('<br/>');
1129
+ };
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
+
1146
+ const chart = {
1147
+ backgroundColor: '#fff',
1148
+ animation: false,
1149
+ title: {
1150
+ left: 'center',
1151
+ text: `SUN2000 - PV Statistics - ${myChart}`,
1152
+ },
1153
+ legend: {
1154
+ top: 35,
1155
+ left: 'center',
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
+ ],
1167
+ },
1168
+ tooltip: {
1169
+ trigger: 'axis',
1170
+ axisPointer: { type: 'cross' },
1171
+ backgroundColor: 'rgba(245,245,245,0.95)',
1172
+ borderWidth: 1,
1173
+ borderColor: '#ccc',
1174
+ padding: 10,
1175
+ textStyle: { color: '#000' },
1176
+ formatter: '%%tooltipFormatter%%',
1177
+ position: (pos, params, el, elRect, size) => {
1178
+ const obj = { top: 10 };
1179
+ obj[pos[0] < size.viewSize[0] / 2 ? 'left' : 'right'] = 30;
1180
+ return obj;
1181
+ },
1182
+ },
1183
+ axisPointer: {
1184
+ link: [{ xAxisIndex: 'all' }],
1185
+ label: { backgroundColor: '#777' },
1186
+ },
1187
+ toolbox: {
1188
+ feature: {
1189
+ dataZoom: { yAxisIndex: false },
1190
+ dataView: { show: true, readOnly: false },
1191
+ restore: { show: true },
1192
+ saveAsImage: { show: true },
1193
+ },
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
+ : [],
1211
+ grid: [
1212
+ { left: '8%', right: showSOC ? '8%' : '4%', top: 80, height: '45%' },
1213
+ { left: '8%', right: showSOC ? '8%' : '4%', top: '72%', height: '15%' },
1214
+ ],
1215
+ xAxis: [
1216
+ {
1217
+ type: 'category',
1218
+ data: xAxisDataShort,
1219
+ scale: true,
1220
+ boundaryGap: chartStyle !== 'line',
1221
+ axisLine: { onZero: false },
1222
+ splitLine: { show: false },
1223
+ axisPointer: { z: 100 },
1224
+ min: 0,
1225
+ max: xAxisDataShort.length - 1,
1226
+ axisLabel: {
1227
+ interval: 0,
1228
+ lineHeight: 16,
1229
+ fontSize: 11,
1230
+ formatter: '%%xAxisFormatter%%',
1231
+ },
1232
+ },
1233
+ {
1234
+ type: 'category',
1235
+ gridIndex: 1,
1236
+ data: xAxisData,
1237
+ scale: true,
1238
+ boundaryGap: chartStyle !== 'line',
1239
+ axisLine: { onZero: false },
1240
+ axisTick: { show: false },
1241
+ splitLine: { show: false },
1242
+ axisLabel: { show: false },
1243
+ min: 0,
1244
+ max: xAxisData.length - 1,
1245
+ },
1246
+ ],
1247
+ yAxis: [
1248
+ // Index 0 — Energy left axis
1249
+ {
1250
+ scale: false,
1251
+ splitArea: { show: true },
1252
+ name: 'Energy (kWh)',
1253
+ nameLocation: 'middle',
1254
+ nameGap: 50,
1255
+ axisLabel: { formatter: '{value} kWh' },
1256
+ splitLine: { show: true },
1257
+ axisLine: { show: true },
1258
+ },
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
1272
+ {
1273
+ scale: true,
1274
+ gridIndex: 1,
1275
+ splitNumber: 3,
1276
+ axisLine: { show: false },
1277
+ axisTick: { show: false },
1278
+ splitLine: { show: false },
1279
+ name: 'Consumption\n(kWh)',
1280
+ nameLocation: 'middle',
1281
+ nameGap: 50,
1282
+ axisLabel: { formatter: '{value}' },
1283
+ },
1284
+ ],
1285
+ dataZoom: [
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
+ },
1300
+ ],
1301
+ series: [
1302
+ // Positive values (above zero line)
1303
+ {
1304
+ name: 'Solar Yield',
1305
+ type: seriesType,
1306
+ data: seriesData.solarYield,
1307
+ itemStyle: { color: '#f6c94e' },
1308
+ emphasis: { focus: 'series' },
1309
+ ...lineOptions,
1310
+ },
1311
+ // Negative values (below zero line)
1312
+ {
1313
+ name: 'Grid Export',
1314
+ type: seriesType,
1315
+ data: negate(seriesData.gridExport),
1316
+ itemStyle: { color: '#5cb85c' },
1317
+ emphasis: { focus: 'series' },
1318
+ ...lineOptions,
1319
+ },
1320
+ {
1321
+ name: 'Grid Import',
1322
+ type: seriesType,
1323
+ data: seriesData.gridImport,
1324
+ itemStyle: { color: '#ec0000' },
1325
+ emphasis: { focus: 'series' },
1326
+ ...lineOptions,
1327
+ },
1328
+ {
1329
+ name: 'Charge',
1330
+ type: seriesType,
1331
+ data: negate(seriesData.chargeCapacity),
1332
+ itemStyle: { color: '#5bc0de' },
1333
+ emphasis: { focus: 'series' },
1334
+ ...lineOptions,
1335
+ },
1336
+ {
1337
+ name: 'Discharge',
1338
+ type: seriesType,
1339
+ data: seriesData.dischargeCapacity,
1340
+ itemStyle: { color: '#ed50e0' },
1341
+ emphasis: { focus: 'series' },
1342
+ ...lineOptions,
1343
+ },
1344
+ // SOC — hourly only, on right axis
1345
+ ...(showSOC
1346
+ ? [
1347
+ {
1348
+ name: 'SOC',
1349
+ type: 'line',
1350
+ yAxisIndex: 1,
1351
+ data: seriesData.SOC,
1352
+ itemStyle: { color: '#985e24' },
1353
+ lineStyle: { width: 2, type: 'dashed' },
1354
+ symbol: 'none',
1355
+ smooth: true,
1356
+ },
1357
+ ]
1358
+ : []),
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
1384
+ {
1385
+ name: 'Consumption',
1386
+ type: seriesType,
1387
+ data: seriesData.consumption,
1388
+ itemStyle: { color: '#337ab7' },
1389
+ xAxisIndex: 1,
1390
+ yAxisIndex: 2,
1391
+ ...lineOptions,
1392
+ },
1393
+ // Day-break areas (hourly only)
1394
+ ...(dayAreas.length > 0
1395
+ ? [
1396
+ {
1397
+ name: 'DayBreak',
1398
+ type: 'bar',
1399
+ barWidth: 0,
1400
+ data: [],
1401
+ legendHoverLink: false,
1402
+ silent: true,
1403
+ markArea: { silent: true, data: dayAreas },
1404
+ },
1405
+ ]
1406
+ : []),
1407
+ ],
1408
+ };
1409
+
1410
+ return stringify(chart).replace("'%%xAxisFormatter%%'", stringify(xAxisFormatterHourly)).replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
1411
+ }
1412
+
1413
+ /**
1414
+ * Merges two objects deeply.
1415
+ * @param target
1416
+ * @param source
1417
+ */
1418
+ _deepMerge(target, source) {
1419
+ for (const key of Object.keys(source)) {
1420
+ if (
1421
+ source[key] !== null &&
1422
+ typeof source[key] === 'object' &&
1423
+ !Array.isArray(source[key]) &&
1424
+ target[key] !== null &&
1425
+ typeof target[key] === 'object' &&
1426
+ !Array.isArray(target[key])
1427
+ ) {
1428
+ this._deepMerge(target[key], source[key]);
1429
+ } else {
1430
+ target[key] = source[key];
1431
+ }
1432
+ }
1433
+ return target;
1434
+ }
1435
+
1436
+ /**
1437
+ * Handles a template state change — updates cache, acknowledges and rebuilds chart.
1438
+ * @param chartType
1439
+ * @param state
1440
+ */
1441
+ async handleTemplateChange(chartType, state) {
1442
+ const templateStateId = `statistics.flexCharts.template.${chartType}`;
1443
+ const template = this.stateCache.get(templateStateId)?.value;
1444
+ if (template === null || template === undefined) {
1445
+ this.adapter.logger.warn(`Template state ${templateStateId} not found for handleTemplateChange`);
1446
+ return;
1447
+ }
1448
+ if (state?.val != null) {
1449
+ this.adapter.logger.debug(`statistics: Event - state: ${chartType} changed: ${state.val} ack: ${state.ack}`);
1450
+ this.stateCache.set(templateStateId, state.val, { type: 'string', stored: true });
1451
+ await this.adapter.setState(templateStateId, { val: state.val, ack: true });
1452
+ this._buildFlexchart(chartType);
711
1453
  }
712
- chart.title.text += myChart;
713
- return chart;
714
1454
  }
715
1455
 
716
1456
  /**
717
1457
  * Entry point for adapter to handle messages related to statistics/flexcharts.
718
1458
  *
719
- * @param {{chart?: string}} message
1459
+ * @param {{chart?: string, style?: string}} message
720
1460
  * @param {Function} callback
721
1461
  */
722
1462
  handleFlexMessage(message, callback) {
723
1463
  const chartType = message?.chart || 'hourly';
724
- const result = this._buildFlexchart(chartType);
1464
+ const chartStyle = message?.style;
1465
+ const result = this._buildFlexchart(chartType, chartStyle);
725
1466
  if (callback && typeof callback === 'function') {
726
1467
  callback(result);
727
1468
  }