iobroker.sun2000 2.4.4 → 2.4.5

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 CHANGED
@@ -69,6 +69,11 @@ The sun2000 adapter calculates how much of your self-generated solar energy is a
69
69
  Placeholder for the next version (at the beginning of the line):
70
70
  ### **WORK IN PROGRESS**
71
71
  -->
72
+ ### 2.4.5 (2026-05-14)
73
+ * statistics fix: return weekly range up to current Monday
74
+ * statistics: added support for generating statistics templates directly from built-in charts
75
+ * statistics: improved tooltip formatter - tooltip units are now provided explicitly via tooltip.valueFormatter
76
+
72
77
  ### 2.4.4 (2026-05-04)
73
78
  * statistics fix: add error handling for waitForValue function
74
79
 
@@ -91,9 +96,6 @@ The sun2000 adapter calculates how much of your self-generated solar energy is a
91
96
  * new state `inverter.x.emma.activeAlarmSN` and `inverter.x.emma.HistoricalAlarmSN` : emma alarms [#226](https://github.com/bolliy/ioBroker.sun2000/issues/226)
92
97
  * statistics: Aggregates historical collected datapoints into time-based summaries (e.g. hourly, daily, monthly, yearly). The data is stored in the path `statistics` as JSON.
93
98
 
94
- ### 2.3.7 (2026-02-01)
95
- * deleted deprecated state `collected.usableSurplusPower`
96
-
97
99
  ## License
98
100
  MIT License
99
101
 
package/io-package.json CHANGED
@@ -1,8 +1,21 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "sun2000",
4
- "version": "2.4.4",
4
+ "version": "2.4.5",
5
5
  "news": {
6
+ "2.4.5": {
7
+ "en": "statistics fix: return weekly range up to current Monday\nstatistics: added support for generating statistics templates directly from built-in charts\nstatistics: improved tooltip formatter - tooltip units are now provided explicitly via tooltip.valueFormatter",
8
+ "de": "statistik fix: wöchentliche Rückgabe bis zum aktuellen Montag\nstatistik: unterstützung für die erstellung von statistiken vorlagen direkt aus integrierten diagrammen\nstatistiken: verbesserter Tooltip-Formater - Tooltip-Einheiten werden nun explizit über tooltip.valueFormatter bereitgestellt",
9
+ "ru": "исправление статистики: еженедельный диапазон возврата до текущего понедельника\nстатистика: добавлена поддержка для создания шаблонов статистики непосредственно из встроенных диаграмм\nстатистика: улучшенный инструментальный формататор - инструментальные блоки теперь предоставляются явно через tooltip.value",
10
+ "pt": "estatística correção: retorno semanal intervalo até a segunda-feira atual\nestatística: suporte adicionado para gerar modelos estatísticos diretamente de gráficos embutidos\nestatísticas: a dica de formatação melhorada - as unidades de dica são agora fornecidas explicitamente via tooltip.valueFormatter",
11
+ "nl": "statistieken fix: retour wekelijkse bereik tot de huidige maandag\nstatistieken: extra ondersteuning voor het genereren van statistieken templates rechtstreeks uit ingebouwde grafieken\nstatistieken: verbeterde tooltip formatter - tooltip-eenheden worden nu expliciet geleverd via tooltip.valueFormatter",
12
+ "fr": "correction statistique: retour hebdomadaire jusqu'à lundi actuel\nstatistiques: appui supplémentaire à la production de modèles de statistiques directement à partir de graphiques intégrés\nstatistiques: amélioration de l'outil pour la matière - les unités d'outil sont désormais fournies explicitement via tooltip.valueFormatter",
13
+ "it": "statistiche fix: rientro settimanale fino al lunedì corrente\nstatistiche: aggiunto il supporto per la generazione di modelli di statistiche direttamente da grafici incorporati\nstatistiche: migliorato tooltip formatter - le unità tooltip sono ora fornite esplicitamente tramite tooltip.valueFormatter",
14
+ "es": "solucion de estadísticas: retorno semanal hasta el lunes actual\nestadística: apoyo añadido para generar plantillas de estadísticas directamente desde gráficos incorporados\nestadística: mejorada herramienta formatter - unidades de punta de herramientas se proporcionan ahora explícitamente a través de tooltip.valueFormatter",
15
+ "pl": "statystyki naprawić: wrócić tygodniowy zakres do bieżącego poniedziałku\nstatystyki: dodano wsparcie dla tworzenia szablonów statystycznych bezpośrednio z wykresów built- in\nstatystyki: ulepszone formatowanie podpowiedzi narzędziowej - jednostki podpowiedzi są teraz wyraźnie dostarczane za pośrednictwem tooltip.valueFormatter",
16
+ "uk": "статистика виправити: повернути щотижневий діапазон до поточного понеділка\nстатистика: додана підтримка створення шаблонів статистики безпосередньо з вбудованих діаграм\nстатистика: поліпшений форматувальник інструментів - блоки інструментів тепер забезпечують явно через tooltip.valueFormatter",
17
+ "zh-cn": "统计修补:截至本星期一的每周返回范围\n统计:为直接从内建图表生成统计模板提供更多支持\n统计:改进后的工具提示用于物质-工具提示单位现在通过工具提示明确提供. valueFormatter"
18
+ },
6
19
  "2.4.4": {
7
20
  "en": "statistics fix: add error handling for waitForValue function",
8
21
  "de": "statistik fix: Fehlerbehandlung für waitForValue-Funktion hinzufügen",
@@ -80,19 +93,6 @@
80
93
  "pl": "aktualizacje zależności i konfiguracji\nnowość \"inverter.x.derived.alarmsJSON\": tablica json z alarmami intverter (id, name, level) [# 226] (https: / / github.com / bolliy / ioBroker.sun2000 / issues / 226)\ndata umieszczenia w wykazie\ndodać minimalną i maksymalną temperaturę dla baterii [# 236] (https: / / github.com / bolliy / ioBroker.sun2000 / issues / 236)",
81
94
  "uk": "оновлення залежності та конфігурації\nновий стан `inverter.x.derived.alarmsJSON` : json array with intverter тривоги (id, назва, рівень) [#226](https://github.com/bolliy/ioBroker.sun2000/products/226)\nadd ChargeDischargePower для батарей [#234](https://github.com/bolliy/ioBroker.sun2000/products/234)\nдодайте мінімальну і максимальну температуру для акумуляторних пакетів [#236](https://github.com/bolliy/ioBroker.sun2000/products/236)",
82
95
  "zh-cn": "依赖和配置更新\nnew state `inverter.x. inducted.alarmsJSON ' : json 数组带内置式警报器(id, name, level) [# 226] (https://github.com/bolliy/ioBroker.sun2000/issues/226) (中文(简体) )\n添加电池单位充电放电器[#234](https://github.com/bolliy/ioBroker.sun2000/issues/234)\n增加电池包的最低和最高温度[第236号](https://github.com/bolliy/ioBroker.sun2000/issues/236)"
83
- },
84
- "2.3.5": {
85
- "en": "dependency and configuration updates\nBattery status check was suspended in inverter control [#220](https://github.com/bolliy/ioBroker.sun2000/issues/220)\nEmma: dynamic detection of sun2000 inverters and integration of devices such as sun2000\nallow Modbus ID 0 when using the sDongle [#218](https://github.com/bolliy/ioBroker.sun2000/issues/118)",
86
- "de": "abhängigkeits- und konfigurationsupdates\nDie Batteriestatusprüfung wurde in der Wechselrichtersteuerung [#220](https://github.com/bolliy/ioBroker.sun2000/issues/220) ausgesetzt\nEmma: dynamische Erkennung von Wechselrichtern von Sun2000 und Integration von Geräten wie sun2000\nerlauben Sie Modbus ID 0 bei Verwendung des sDongle [#218](https://github.com/bolliy/ioBroker.sun2000/issues/118)",
87
- "ru": "обновления зависимостей и конфигурации\nПроверка состояния батареи была приостановлена в инверторном управлении [#220] (https://github.com/bolliy/ioBroker.sun2000/issues/220)\nЭмма: динамическое обнаружение инверторов Sun2000 и интеграция таких устройств, как Sun2000\nразрешить Modbus ID 0 при использовании sDongle [#218] (https://github.com/bolliy/ioBroker.sun2000/issues/118)",
88
- "pt": "atualizações de dependência e configuração\nA verificação do estado da bateria foi suspensa no controle do inversor [#220](https://github.com/bolliy/ioBroker.sun2000/issues/220)\nEmma: detecção dinâmica de inversores sun2000 e integração de dispositivos como sun2000\npermitir Modbus ID 0 ao usar o sDongle [#218](https://github.com/bolliy/ioBroker.sun2000/issues/118)",
89
- "nl": "afhankelijkheid en configuratie-updates\nDe controle van de status van de batterij werd opgeschort bij controle van de inverter [#220](https://github.com/bolliy/ioBroker.sun2000/issues/220)\nEmma: dynamische detectie van zonne2000 omvormers en integratie van apparaten zoals sun2000\nmodbus ID 0 toestaan bij gebruik van sDongle [#218](https://github.com/bolliy/ioBroker.sun2000/issues/118)",
90
- "fr": "mises à jour de la dépendance et de la configuration\nLe contrôle de l'état de la batterie a été suspendu dans le contrôle de l'onduleur [#220](https://github.com/bolliy/ioBroker.sun2000/issues/220)\nEmma : détection dynamique des onduleurs Sun2000 et intégration de dispositifs tels que Sun2000\npermettre Modbus ID 0 lors de l'utilisation de sDongle [#218](https://github.com/bolliy/ioBroker.sun2000/issues/118)",
91
- "it": "aggiornamenti di dipendenza e configurazione\nIl controllo dello stato della batteria è stato sospeso nel controllo dell'inverter [#220](https://github.com/bolliy/ioBroker.sun2000/issues/220)\nEmma: rilevazione dinamica degli inverter Sun2000 e integrazione di dispositivi come sun2000\nconsentire Modbus ID 0 quando si utilizza lo sDongle [#218](https://github.com/bolliy/ioBroker.sun2000/issues/118)",
92
- "es": "actualizaciones de dependencia y configuración\nSe suspendió el control del estado de la batería [#220](https://github.com/bolliy/ioBroker.sun2000/issues/220)\nEmma: detección dinámica de inversores de sol2000 e integración de dispositivos como el sol2000\npermitir Modbus ID 0 al utilizar el sDongle [#218](https://github.com/bolliy/ioBroker.sun2000/issues/118)",
93
- "pl": "aktualizacje zależności i konfiguracji\nKontrola stanu baterii została zawieszona w kontrolach inwertera [# 220] (https: / / github.com / bolliy / ioBroker.sun2000 / issues / 220)\nEmma: dynamiczna detekcja inwerterów sun2000 i integracja urządzeń takich jak sun2000\nw przypadku stosowania sDongle [# 218] (https: / / github.com / bolliy / ioBroker.sun2000 / issues / 118)",
94
- "uk": "оновлення залежності та конфігурації\nПеревірка стану акумулятора була припинена в інверторному режимі [#220] (https://github.com/bolliy/ioBroker.sun2000/issues/220)\nЕмма: динамічне виявлення інверторів сонця2000 та інтеграції пристроїв, таких як сонце2000\nдозволити Modbus ID 0 при використанні sDongle [#218](https://github.com/bolliy/ioBroker.sun2000/issues/118)",
95
- "zh-cn": "依赖和配置更新\n在反转控制中暂停了电池状态检查[#220](https://github.com/bolliy/ioBroker.sun2000/issues/220)\n艾玛:对太阳2000反转器的动态检测和太阳2000等设备的集成\n允许在使用 sDongle [# 218] 时使用 Modbus ID (https:// github.com/ bolliy/ioBroker.sun2000/issues/118) "
96
96
  }
97
97
  },
98
98
  "titleLang": {
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Serializes an object to JSON, converting functions to their string representation.
5
+ *
6
+ * @param {any} obj - The object to serialize.
7
+ * @returns {string} JSON string where functions are represented as strings.
8
+ */
9
+ function stringifyWithFunctions(obj) {
10
+ return JSON.stringify(
11
+ obj,
12
+ (key, value) => {
13
+ if (typeof value === 'function') {
14
+ return value.toString();
15
+ }
16
+
17
+ if (value === undefined) {
18
+ return null;
19
+ }
20
+ return value;
21
+ },
22
+ 2,
23
+ );
24
+ }
25
+ /*
26
+ const FUNCTION_KEYS = ['formatter', 'render', 'parser'];
27
+
28
+ function reviveFunctions(obj, key = '') {
29
+ if (typeof obj === 'string' && FUNCTION_KEYS.includes(key)) {
30
+ const str = obj.trim();
31
+
32
+ try {
33
+ return new Function(`return (${str})`)();
34
+ } catch {
35
+ return obj;
36
+ }
37
+ }
38
+
39
+ if (typeof obj === 'object' && obj !== null) {
40
+ for (const k in obj) {
41
+ obj[k] = reviveFunctions(obj[k], k);
42
+ }
43
+ }
44
+
45
+ return obj;
46
+ }
47
+ */
48
+ const FUNCTION_KEYS = ['formatter', 'parser', 'render', 'labelFormatter', 'valueFormatter', 'position'];
49
+
50
+ function reviveFunctions(obj, key = '') {
51
+ if (typeof obj === 'string' && FUNCTION_KEYS.includes(key)) {
52
+ const str = obj.trim();
53
+
54
+ // Arrow Function erkennen
55
+ if (str.includes('=>')) {
56
+ try {
57
+ return new Function(`return (${str})`)();
58
+ } catch {
59
+ return obj;
60
+ }
61
+ }
62
+
63
+ // klassische function() {} erkennen
64
+ if (str.startsWith('function')) {
65
+ try {
66
+ return new Function(`return (${str})`)();
67
+ } catch {
68
+ return obj;
69
+ }
70
+ }
71
+ }
72
+
73
+ if (typeof obj === 'object' && obj !== null) {
74
+ for (const key in obj) {
75
+ obj[key] = reviveFunctions(obj[key], key);
76
+ }
77
+ }
78
+
79
+ return obj;
80
+ }
81
+
82
+ /**
83
+ * Deeply merges source object properties into the target object.
84
+ *
85
+ * @param {object} target - The target object to merge into.
86
+ * @param {object} source - The source object whose properties are merged.
87
+ * @returns {object} The merged target object.
88
+ */
89
+ function deepMerge(target, source) {
90
+ for (const key of Object.keys(source)) {
91
+ if (
92
+ source[key] !== null &&
93
+ typeof source[key] === 'object' &&
94
+ !Array.isArray(source[key]) &&
95
+ target[key] !== null &&
96
+ typeof target[key] === 'object' &&
97
+ !Array.isArray(target[key])
98
+ ) {
99
+ deepMerge(target[key], source[key]);
100
+ } else {
101
+ target[key] = source[key];
102
+ }
103
+ }
104
+ return target;
105
+ }
106
+
107
+ /**
108
+ * Sets a value on an object using a dot‑separated path, creating intermediate objects as needed.
109
+ *
110
+ * @param {object} obj - The object to modify.
111
+ * @param {string} path - Dot‑separated path (e.g., "a.b.c").
112
+ * @param {any} value - The value to set at the specified path.
113
+ */
114
+ function setByPath(obj, path, value) {
115
+ const keys = path.split('.');
116
+ let current = obj;
117
+
118
+ while (keys.length > 1) {
119
+ const key = keys.shift();
120
+
121
+ if (!(key in current)) {
122
+ current[key] = {};
123
+ }
124
+
125
+ current = current[key];
126
+ }
127
+
128
+ current[keys[0]] = value;
129
+ }
130
+
131
+ /**
132
+ * Applies a set of overrides to a target object based on dot‑separated paths.
133
+ *
134
+ * @param {object} target - The target object to which overrides are applied.
135
+ * @param {object} overrides - An object whose keys are dot‑separated paths and values are the overrides.
136
+ */
137
+ function applyOverrides(target, overrides) {
138
+ for (const path in overrides) {
139
+ setByPath(target, path, overrides[path]);
140
+ }
141
+ }
142
+
143
+ module.exports = {
144
+ stringifyWithFunctions,
145
+ reviveFunctions,
146
+ deepMerge,
147
+ applyOverrides,
148
+ };
package/lib/statistics.js CHANGED
@@ -18,6 +18,7 @@ in ioBroker VIS using the ioBroker.flexcharts adapter.
18
18
  const stringify = require('javascript-stringify').stringify;
19
19
  const { dataRefreshRate, statisticsType } = require(`${__dirname}/types.js`);
20
20
  const tools = require(`${__dirname}/tools.js`);
21
+ const { stringifyWithFunctions, reviveFunctions } = require(`${__dirname}/json_helper.js`);
21
22
 
22
23
  class statistics {
23
24
  constructor(adapterInstance, stateCache) {
@@ -511,109 +512,6 @@ class statistics {
511
512
  this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
512
513
  }
513
514
  }
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) {
525
- try {
526
- const now = new Date();
527
- const window = getWindow(now);
528
- const fromDate = window.from;
529
- const toDate = window.to;
530
- if (now < toDate) {
531
- this.adapter.logger.debug(`statistics.js: Skipping ${periodType} aggregation because current time is before end of aggregation window`);
532
- return false;
533
- }
534
- const toStr = this._localIsoWithOffset(toDate);
535
- //const fromStr = this._localIsoWithOffset(fromDate);
536
-
537
- let jsonTarget = this.stateCache.get(targetStateId)?.value ?? '[]';
538
- let targetArray = [];
539
- try {
540
- targetArray = JSON.parse(jsonTarget);
541
- if (!Array.isArray(targetArray)) targetArray = [];
542
- } catch {
543
- targetArray = [];
544
- }
545
-
546
- const last = targetArray.length > 0 ? targetArray[targetArray.length - 1] : {};
547
-
548
- if (last.to === toStr) return false;
549
-
550
- const target = {
551
- from: this._localIsoWithOffset(fromDate),
552
- to: toStr,
553
- };
554
-
555
- let jsonStr = this.stateCache.get(sourceStateId)?.value ?? '[]';
556
- let sourceEntries = [];
557
- try {
558
- sourceEntries = JSON.parse(jsonStr);
559
- if (!Array.isArray(sourceEntries)) sourceEntries = [];
560
- } catch {
561
- sourceEntries = [];
562
- }
563
-
564
- sourceEntries = sourceEntries.filter(item => {
565
- const ts = Date.parse(item.from);
566
- return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < toDate.getTime();
567
- });
568
-
569
- if (sourceEntries.length > 0) {
570
- this.adapter.logger.debug(
571
- `statistics.js: Found ${sourceEntries.length} source entries for ${periodType} aggregation between ${fromDate.toISOString()} and ${toDate.toISOString()}`,
572
- );
573
-
574
- // First pass: sum delta/deltaReset stats
575
- for (const stat of this.stats) {
576
- if (stat.type === statisticsType.level) continue;
577
- if (stat.type === statisticsType.computed) continue;
578
-
579
- let sum = 0;
580
- try {
581
- sourceEntries.forEach(entry => {
582
- sum += Number(entry[stat.targetPath]?.['value'] ?? 0);
583
- });
584
- } catch (e) {
585
- this.adapter.logger.warn(`statistics.js: Error during ${periodType} statistic aggregation: ${e.message}`);
586
- }
587
- sum = Math.round((Number(sum) + Number.EPSILON) * 1000) / 1000;
588
- target[stat.targetPath] = { value: Number(sum.toFixed(3)), unit: stat.unit || 'kWh' };
589
- }
590
-
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
- }
604
- }
605
-
606
- targetArray.push(target);
607
- }
608
-
609
- targetArray.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
610
- this.stateCache.set(targetStateId, JSON.stringify(targetArray), { type: 'string' });
611
- this.adapter.logger.debug(`Appended ${periodType} statistic ${toStr}`);
612
- return targetArray.length > 0;
613
- } catch (err) {
614
- this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
615
- }
616
- }
617
515
 
618
516
  /**
619
517
  * Updates the statistics.jsonToday state with the current live day values.
@@ -741,10 +639,12 @@ class statistics {
741
639
  monday.setDate(now.getDate() - (now.getDay() || 7) + 1);
742
640
  const prevMonday = new Date(monday);
743
641
  prevMonday.setDate(monday.getDate() - 7);
642
+ /*
744
643
  const nextMonday = new Date(monday);
745
644
  nextMonday.setDate(monday.getDate() + 7);
645
+ */
746
646
  // Window: previous Monday → next Monday (covers both last and current week)
747
- return { from: prevMonday, to: nextMonday };
647
+ return { from: prevMonday, to: monday };
748
648
  },
749
649
  'weekly',
750
650
  );
@@ -1030,12 +930,23 @@ class statistics {
1030
930
  seriesData[`${stat.targetPath}Neg`] = negate(values);
1031
931
  }
1032
932
 
933
+ // --- X-Axis label formatter (hourly only) ---
934
+ const xAxisFormatter = value => {
935
+ if (value.includes('|')) return value;
936
+ return value.split(' ')[1] ?? value;
937
+ };
938
+
1033
939
  // --- Tooltip formatter ---
1034
940
  const tooltipFormatter = params => {
1035
941
  if (!Array.isArray(params)) params = [params];
1036
942
  return params
1037
943
  .filter(p => p.seriesName !== 'DayBreak')
1038
944
  .map(p => {
945
+ if (typeof p.value === 'object') {
946
+ const val = p.value.value;
947
+ const unit = p.value.unit || 'kWh';
948
+ return `${p.marker}${p.seriesName}: <b>${val}${unit}</b>`;
949
+ }
1039
950
  const negatedSeries = ['Grid Export', 'Charge'];
1040
951
  const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
1041
952
  const unit = ['SOC', 'Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? ' %' : ' kWh';
@@ -1051,20 +962,94 @@ class statistics {
1051
962
  const outputStateId = `statistics.flexCharts.jsonOutput.${myChart}`;
1052
963
 
1053
964
  const templateStr = this.stateCache.get(templateStateId)?.value ?? '{}';
1054
- let chartStr = '{}';
965
+ let template = {}; //empty object as fallback if parsing fails or template is empty
966
+ let command = '';
1055
967
 
1056
968
  try {
1057
969
  const templ = JSON.parse(templateStr);
1058
- if (Object.keys(templ).length === 0) {
1059
- chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
970
+ command = templ.command || '';
971
+ if (Object.keys(templ).length === 0 || command === 'createTemplateFromBuiltin') {
972
+ template = this._buildDefaultTemplate(myChart, chartStyle);
1060
973
  } else {
1061
- chartStr = stringify(templ);
974
+ delete templ._meta;
975
+ template = reviveFunctions(templ); // Functions in templates are stored as strings and need to be revived before usage
1062
976
  }
1063
977
  } catch (e) {
1064
978
  this.adapter.logger.warn(`statistics: invalid template for ${myChart}: ${e.message}`);
1065
- chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
979
+ template = this._buildDefaultTemplate(myChart, chartStyle);
980
+ }
981
+ // No-data hint — chart-type specific
982
+ const noDataHints = {
983
+ hourly: 'No data yet — first entry available after the next full hour.',
984
+ daily: 'No data yet — first entry available tomorrow after midnight.',
985
+ weekly: 'No data yet — first entry available after the current week ends.',
986
+ monthly: 'No data yet — first entry available after the current month ends.',
987
+ annual: 'No data yet — first entry available after the current year ends.',
988
+ };
989
+
990
+ const chart = {
991
+ ...template,
992
+ series: [
993
+ ...(template.series ?? []),
994
+ // Day-break areas (hourly only)
995
+ ...(dayAreas.length > 0
996
+ ? [
997
+ {
998
+ name: 'DayBreak',
999
+ type: 'bar',
1000
+ barWidth: 0,
1001
+ data: [],
1002
+ legendHoverLink: false,
1003
+ silent: true,
1004
+ markArea: {
1005
+ silent: true,
1006
+ data: dayAreas,
1007
+ },
1008
+ },
1009
+ ]
1010
+ : []),
1011
+ ],
1012
+ // No-data graphic — shown only when data is empty
1013
+ graphic:
1014
+ xAxisData.length === 0
1015
+ ? [
1016
+ {
1017
+ type: 'text',
1018
+ left: 'center',
1019
+ top: 'middle',
1020
+ style: {
1021
+ text: noDataHints[myChart] || 'No data available yet.',
1022
+ fontSize: 14,
1023
+ fill: '#999',
1024
+ },
1025
+ },
1026
+ ]
1027
+ : [],
1028
+ };
1029
+
1030
+ // --- Slider start position — show recent entries by default ---
1031
+ // For hourly: show last 25 entries (~1 day)
1032
+ // For daily: show last 7 entries (~1 week)
1033
+ // For weekly: show last 8 entries (~2 months)
1034
+ // For monthly: show last 13 entries (~1 year)
1035
+ // For annual: show full range
1036
+ const sliderDefaults = {
1037
+ hourly: { start: Math.max(0, Math.round((1 - 25 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1038
+ daily: { start: Math.max(0, Math.round((1 - 7 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1039
+ weekly: { start: Math.max(0, Math.round((1 - 8 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1040
+ monthly: { start: Math.max(0, Math.round((1 - 13 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1041
+ annual: { start: 0, end: 100 },
1042
+ };
1043
+ const slider = sliderDefaults[myChart] ?? { start: 0, end: 100 };
1044
+
1045
+ // If template has a dataZoom component without start/end, apply defaults
1046
+ if (chart?.dataZoom?.[0] && !chart.dataZoom[0].start && !chart.dataZoom[0].end) {
1047
+ chart.dataZoom[0].start = slider.start;
1048
+ chart.dataZoom[0].end = slider.end;
1066
1049
  }
1067
1050
 
1051
+ let chartStr = stringify(chart);
1052
+
1068
1053
  // --- Replace placeholders ---
1069
1054
  chartStr = chartStr
1070
1055
  .replace("'%%xAxisData%%'", JSON.stringify(xAxisData))
@@ -1072,6 +1057,7 @@ class statistics {
1072
1057
  .replace("'%%xAxisMax%%'", String(xAxisData.length - 1))
1073
1058
  .replace("'%%chartTitle%%'", JSON.stringify(`PV Statistics — ${myChart}`))
1074
1059
  .replace("'%%dayAreas%%'", JSON.stringify(dayAreas))
1060
+ .replace("'%%xAxisFormatter%%'", stringify(xAxisFormatter))
1075
1061
  .replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
1076
1062
 
1077
1063
  // All this.stats entries dynamically — both positive and negated
@@ -1082,7 +1068,17 @@ class statistics {
1082
1068
 
1083
1069
  this.stateCache.set(outputStateId, chartStr, { type: 'string' });
1084
1070
  this.adapter.logger.debug(`statistics: flexCharts built for ${myChart}/${chartStyle}`);
1085
-
1071
+ if (command === 'createTemplateFromBuiltin') {
1072
+ this.adapter.logger.debug(`statistics: created new template for ${myChart} based on built-in`);
1073
+ template = {
1074
+ _meta: {
1075
+ generatedFrom: 'builtin',
1076
+ generatedAt: new Date().toISOString(),
1077
+ },
1078
+ ...template,
1079
+ };
1080
+ this.stateCache.set(templateStateId, stringifyWithFunctions(template), { type: 'string' }); // Store the final chart configuration back in the template state for persistence and debugging
1081
+ }
1086
1082
  return chartStr;
1087
1083
  }
1088
1084
 
@@ -1091,69 +1087,20 @@ class statistics {
1091
1087
  * Used when no template is provided.
1092
1088
  * @param myChart
1093
1089
  * @param chartStyle
1094
- * @param xAxisData
1095
- * @param xAxisDataShort
1096
- * @param dayAreas
1097
- * @param seriesData
1098
1090
  */
1099
- _buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData) {
1100
- const xAxisFormatterHourly = value => {
1101
- if (value.includes('|')) return value;
1102
- return value.split(' ')[1] ?? value;
1103
- };
1104
-
1091
+ _buildDefaultTemplate(myChart, chartStyle) {
1105
1092
  const seriesType = chartStyle === 'line' ? 'line' : 'bar';
1106
1093
  const lineOptions =
1107
1094
  chartStyle === 'line' ? { smooth: true, symbol: 'circle', symbolSize: 4, lineStyle: { width: 2 }, areaStyle: { opacity: 0.15 } } : {};
1108
1095
 
1109
- const negate = arr => arr.map(v => Number((-v).toFixed(3)));
1110
1096
  const showSOC = myChart === 'hourly';
1111
1097
 
1112
- // No-data hint — chart-type specific
1113
- const noDataHints = {
1114
- hourly: 'No data yet — first entry available after the next full hour.',
1115
- daily: 'No data yet — first entry available tomorrow after midnight.',
1116
- weekly: 'No data yet — first entry available after the current week ends.',
1117
- monthly: 'No data yet — first entry available after the current month ends.',
1118
- annual: 'No data yet — first entry available after the current year ends.',
1119
- };
1120
-
1121
- const tooltipFormatter = params => {
1122
- if (!Array.isArray(params)) params = [params];
1123
- return params
1124
- .filter(p => p.seriesName !== 'DayBreak')
1125
- .map(p => {
1126
- const negatedSeries = ['Grid Export', 'Charge'];
1127
- const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
1128
- const unit = ['SOC', 'Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? ' %' : ' kWh';
1129
- const seriesName =
1130
- myChart === 'hourly' && ['Self-sufficiency', 'Self-consumption'].includes(p.seriesName) ? `${p.seriesName} today` : p.seriesName;
1131
- return `${p.marker}${seriesName}: <b>${val}${unit}</b>`;
1132
- })
1133
- .join('<br/>');
1134
- };
1135
-
1136
- // --- Slider start position — show recent entries by default ---
1137
- // For hourly: show last 25 entries (~1 day)
1138
- // For daily: show last 7 entries (~1 week)
1139
- // For weekly: show last 8 entries (~2 months)
1140
- // For monthly: show last 13 entries (~1 year)
1141
- // For annual: show full range
1142
- const sliderDefaults = {
1143
- hourly: { start: Math.max(0, Math.round((1 - 25 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1144
- daily: { start: Math.max(0, Math.round((1 - 7 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1145
- weekly: { start: Math.max(0, Math.round((1 - 8 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1146
- monthly: { start: Math.max(0, Math.round((1 - 13 / Math.max(xAxisData.length, 1)) * 100)), end: 100 },
1147
- annual: { start: 0, end: 100 },
1148
- };
1149
- const slider = sliderDefaults[myChart] ?? { start: 0, end: 100 };
1150
-
1151
- const chart = {
1098
+ const template = {
1152
1099
  backgroundColor: '#fff',
1153
1100
  animation: false,
1154
1101
  title: {
1155
1102
  left: 'center',
1156
- text: `SUN2000 - PV Statistics - ${myChart}`,
1103
+ text: '%%chartTitle%%',
1157
1104
  },
1158
1105
  legend: {
1159
1106
  top: 35,
@@ -1197,22 +1144,6 @@ class statistics {
1197
1144
  saveAsImage: { show: true },
1198
1145
  },
1199
1146
  },
1200
- // No-data graphic — shown only when data is empty
1201
- graphic:
1202
- xAxisData.length === 0
1203
- ? [
1204
- {
1205
- type: 'text',
1206
- left: 'center',
1207
- top: 'middle',
1208
- style: {
1209
- text: noDataHints[myChart] || 'No data available yet.',
1210
- fontSize: 14,
1211
- fill: '#999',
1212
- },
1213
- },
1214
- ]
1215
- : [],
1216
1147
  grid: [
1217
1148
  { left: '8%', right: showSOC ? '8%' : '4%', top: 80, height: '45%' },
1218
1149
  { left: '8%', right: showSOC ? '8%' : '4%', top: '72%', height: '15%' },
@@ -1220,25 +1151,36 @@ class statistics {
1220
1151
  xAxis: [
1221
1152
  {
1222
1153
  type: 'category',
1223
- data: xAxisDataShort,
1154
+ data: '%%xAxisDataShort%%',
1224
1155
  scale: true,
1225
1156
  boundaryGap: chartStyle !== 'line',
1226
1157
  axisLine: { onZero: false },
1227
1158
  splitLine: { show: false },
1228
1159
  axisPointer: { z: 100 },
1229
- min: 0,
1230
- max: xAxisDataShort.length - 1,
1231
1160
  axisLabel: {
1232
1161
  interval: 0,
1233
1162
  lineHeight: 16,
1234
1163
  fontSize: 11,
1235
1164
  formatter: '%%xAxisFormatter%%',
1236
1165
  },
1166
+ min: 0,
1167
+ max: '%%xAxisMax%%',
1168
+ /*
1169
+ axisTick: { show: false },
1170
+ gridIndex: 0,
1171
+ splitNumber: 5,
1172
+ name: myChart === 'hourly' ? 'Time' : 'Date',
1173
+ nameLocation: 'middle',
1174
+ nameGap: 30,
1175
+ nameTextStyle: { fontSize: 12, fontWeight: 'bold' },
1176
+ splitLine: { show: false },
1177
+ axisLine: { show: true },
1178
+ */
1237
1179
  },
1238
1180
  {
1239
1181
  type: 'category',
1240
1182
  gridIndex: 1,
1241
- data: xAxisData,
1183
+ data: '%%xAxisData%%',
1242
1184
  scale: true,
1243
1185
  boundaryGap: chartStyle !== 'line',
1244
1186
  axisLine: { onZero: false },
@@ -1246,7 +1188,7 @@ class statistics {
1246
1188
  splitLine: { show: false },
1247
1189
  axisLabel: { show: false },
1248
1190
  min: 0,
1249
- max: xAxisData.length - 1,
1191
+ max: '%%xAxisMax%%',
1250
1192
  },
1251
1193
  ],
1252
1194
  yAxis: [
@@ -1291,16 +1233,12 @@ class statistics {
1291
1233
  {
1292
1234
  type: 'inside',
1293
1235
  xAxisIndex: [0, 1],
1294
- start: slider.start,
1295
- end: slider.end,
1296
1236
  },
1297
1237
  {
1298
1238
  show: true,
1299
1239
  xAxisIndex: [0, 1],
1300
1240
  type: 'slider',
1301
1241
  bottom: 5,
1302
- start: slider.start,
1303
- end: slider.end,
1304
1242
  },
1305
1243
  ],
1306
1244
  series: [
@@ -1308,42 +1246,57 @@ class statistics {
1308
1246
  {
1309
1247
  name: 'Solar Yield',
1310
1248
  type: seriesType,
1311
- data: seriesData.solarYield,
1249
+ data: '%%solarYield%%',
1312
1250
  itemStyle: { color: '#f6c94e' },
1313
1251
  emphasis: { focus: 'series' },
1252
+ tooltip: {
1253
+ valueFormatter: value => ({ value: value, unit: 'kWh' }),
1254
+ },
1314
1255
  ...lineOptions,
1315
1256
  },
1316
1257
  // Negative values (below zero line)
1317
1258
  {
1318
1259
  name: 'Grid Export',
1319
1260
  type: seriesType,
1320
- data: negate(seriesData.gridExport),
1261
+ data: '%%gridExportNeg%%',
1321
1262
  itemStyle: { color: '#5cb85c' },
1322
1263
  emphasis: { focus: 'series' },
1264
+ tooltip: {
1265
+ valueFormatter: value => ({ value: -value, unit: 'kWh' }),
1266
+ },
1323
1267
  ...lineOptions,
1324
1268
  },
1325
1269
  {
1326
1270
  name: 'Grid Import',
1327
1271
  type: seriesType,
1328
- data: seriesData.gridImport,
1272
+ data: '%%gridImport%%',
1329
1273
  itemStyle: { color: '#ec0000' },
1330
1274
  emphasis: { focus: 'series' },
1275
+ tooltip: {
1276
+ valueFormatter: value => ({ value: value, unit: 'kWh' }),
1277
+ },
1331
1278
  ...lineOptions,
1332
1279
  },
1333
1280
  {
1334
1281
  name: 'Charge',
1335
1282
  type: seriesType,
1336
- data: negate(seriesData.chargeCapacity),
1283
+ data: '%%chargeCapacityNeg%%',
1337
1284
  itemStyle: { color: '#5bc0de' },
1338
1285
  emphasis: { focus: 'series' },
1286
+ tooltip: {
1287
+ valueFormatter: value => ({ value: -value, unit: 'kWh' }),
1288
+ },
1339
1289
  ...lineOptions,
1340
1290
  },
1341
1291
  {
1342
1292
  name: 'Discharge',
1343
1293
  type: seriesType,
1344
- data: seriesData.dischargeCapacity,
1294
+ data: '%%dischargeCapacity%%',
1345
1295
  itemStyle: { color: '#ed50e0' },
1346
1296
  emphasis: { focus: 'series' },
1297
+ tooltip: {
1298
+ valueFormatter: value => ({ value: value, unit: 'kWh' }),
1299
+ },
1347
1300
  ...lineOptions,
1348
1301
  },
1349
1302
  // SOC — hourly only, on right axis
@@ -1353,11 +1306,14 @@ class statistics {
1353
1306
  name: 'SOC',
1354
1307
  type: 'line',
1355
1308
  yAxisIndex: 1,
1356
- data: seriesData.SOC,
1309
+ data: '%%SOC%%',
1357
1310
  itemStyle: { color: '#985e24' },
1358
1311
  lineStyle: { width: 2, type: 'dashed' },
1359
1312
  symbol: 'none',
1360
1313
  smooth: true,
1314
+ tooltip: {
1315
+ valueFormatter: value => ({ value: value, unit: '%' }),
1316
+ },
1361
1317
  },
1362
1318
  ]
1363
1319
  : []),
@@ -1366,76 +1322,53 @@ class statistics {
1366
1322
  name: 'Self-sufficiency',
1367
1323
  type: 'line',
1368
1324
  yAxisIndex: 1,
1369
- data: seriesData.selfSufficiency,
1325
+ data: '%%selfSufficiency%%',
1370
1326
  itemStyle: { color: '#9c27b0' },
1371
1327
  lineStyle: { width: 2, type: 'dashed' },
1372
1328
  symbol: 'circle',
1373
1329
  symbolSize: 4,
1374
1330
  smooth: true,
1331
+ tooltip: {
1332
+ valueFormatter: value => ({ value: value, unit: '%' }),
1333
+ },
1375
1334
  },
1376
1335
  // Self-consumption — right axis
1377
1336
  {
1378
1337
  name: 'Self-consumption',
1379
1338
  type: 'line',
1380
1339
  yAxisIndex: 1,
1381
- data: seriesData.selfConsumption,
1340
+ data: '%%selfConsumption%%',
1382
1341
  itemStyle: { color: '#ff9800' },
1383
1342
  lineStyle: { width: 2, type: 'dashed' },
1384
1343
  symbol: 'circle',
1385
1344
  symbolSize: 4,
1386
1345
  smooth: true,
1346
+ tooltip: {
1347
+ valueFormatter: value => ({ value: value, unit: '%' }),
1348
+ },
1387
1349
  },
1388
1350
  // Consumption in lower grid — always yAxisIndex 2
1389
1351
  {
1390
1352
  name: 'Consumption',
1391
1353
  type: seriesType,
1392
- data: seriesData.consumption,
1354
+ data: '%%consumption%%',
1393
1355
  itemStyle: { color: '#337ab7' },
1394
1356
  xAxisIndex: 1,
1395
1357
  yAxisIndex: 2,
1358
+ /*
1359
+ emphasis: {
1360
+ focus: 'series',
1361
+ },
1362
+ */
1363
+ tooltip: {
1364
+ valueFormatter: value => ({ value: value, unit: 'kWh' }),
1365
+ },
1396
1366
  ...lineOptions,
1397
1367
  },
1398
- // Day-break areas (hourly only)
1399
- ...(dayAreas.length > 0
1400
- ? [
1401
- {
1402
- name: 'DayBreak',
1403
- type: 'bar',
1404
- barWidth: 0,
1405
- data: [],
1406
- legendHoverLink: false,
1407
- silent: true,
1408
- markArea: { silent: true, data: dayAreas },
1409
- },
1410
- ]
1411
- : []),
1412
1368
  ],
1413
1369
  };
1414
1370
 
1415
- return stringify(chart).replace("'%%xAxisFormatter%%'", stringify(xAxisFormatterHourly)).replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
1416
- }
1417
-
1418
- /**
1419
- * Merges two objects deeply.
1420
- * @param target
1421
- * @param source
1422
- */
1423
- _deepMerge(target, source) {
1424
- for (const key of Object.keys(source)) {
1425
- if (
1426
- source[key] !== null &&
1427
- typeof source[key] === 'object' &&
1428
- !Array.isArray(source[key]) &&
1429
- target[key] !== null &&
1430
- typeof target[key] === 'object' &&
1431
- !Array.isArray(target[key])
1432
- ) {
1433
- this._deepMerge(target[key], source[key]);
1434
- } else {
1435
- target[key] = source[key];
1436
- }
1437
- }
1438
- return target;
1371
+ return template; //as object, not stringified, since we use reviveFunctions to restore the functions after stringification in the state
1439
1372
  }
1440
1373
 
1441
1374
  /**
@@ -1453,8 +1386,10 @@ class statistics {
1453
1386
  if (state?.val != null) {
1454
1387
  this.adapter.logger.debug(`statistics: Event - state: ${chartType} changed: ${state.val} ack: ${state.ack}`);
1455
1388
  this.stateCache.set(templateStateId, state.val, { type: 'string', stored: true });
1456
- await this.adapter.setState(templateStateId, { val: state.val, ack: true });
1389
+ //await this.adapter.setState(templateStateId, { val: state.val, ack: true });
1457
1390
  this._buildFlexchart(chartType);
1391
+ await this.adapter.setState(templateStateId, { val: this.stateCache.get(templateStateId)?.value, ack: true });
1392
+ //this.stateCache.set(templateStateId, this.stateCache.get(templateStateId), { type: 'string', renew: true });
1458
1393
  }
1459
1394
  }
1460
1395
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.sun2000",
3
- "version": "2.4.4",
3
+ "version": "2.4.5",
4
4
  "description": "sun2000",
5
5
  "author": {
6
6
  "name": "bolliy",