iobroker.sun2000 2.4.0 → 2.4.2

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
@@ -17,7 +17,6 @@
17
17
 
18
18
  **This adapter uses Sentry libraries to automatically report exceptions and code errors to the developers.**\
19
19
  For more details and for information how to disable the error reporting see [Sentry-Plugin Documentation](https://github.com/ioBroker/plugin-sentry#plugin-sentry)!\
20
- Sentry reporting is used starting with js-controller 3.0.
21
20
 
22
21
  ## sun2000 adapter for ioBroker
23
22
 
@@ -59,9 +58,10 @@ browse in the [wiki](https://github.com/bolliy/ioBroker.sun2000/wiki)
59
58
  * Huawei [`SmartLogger`](https://github.com/bolliy/ioBroker.sun2000/wiki/SmartLogger) integration: Monitors and manages the PV power system. The adapter saves the collected data in the same way as it does when read out the inverter directly.
60
59
  * Huawei [`Emma`](https://github.com/bolliy/ioBroker.sun2000/wiki/Emma) integration: The Modbus access, network connectivity (WiFi and Ethernet) and the DDSU/DTSU-666H smart meter functions are integrated in one unit - the use of the Sdongle becomes redundant. In addition Huawei EV chargers and load shedding/control (via selected Shelly devices) are supported and "intelligent" controlled.
61
60
  * Huawei [`Charger`](https://github.com/bolliy/ioBroker.sun2000/issues/171) via Emma integration: The chargers are automatically recognized and the data is saved in their own path.
62
- * Statistics: Aggregates historical collected datapoints into time-based summaries (e.g. hourly, daily, monthly, yearly).
63
- In the medium term, these statistics should be able to be visualized in ioBroker VIS using the flexcharts adapter to create interactive diagrams for inverter performance and energy production.
64
-
61
+ * [`Statistics`](https://github.com/bolliy/ioBroker.sun2000/wiki/Statistk-(statistics)): Aggregates historical collected datapoints into time-based summaries (e.g. hourly, daily, monthly, yearly).
62
+ These statistics should be able to be visualized in ioBroker VIS using the flexcharts adapter to create interactive diagrams for inverter performance and energy production.
63
+ * [`Surplus Power Control`](https://github.com/bolliy/ioBroker.sun2000/wiki/%C3%9Cberschuss-(surplus))
64
+ The sun2000 adapter calculates how much of your self-generated solar energy is available to power devices in your home — instead of sending it to the grid.
65
65
 
66
66
 
67
67
  ## Changelog
@@ -69,6 +69,16 @@ In the medium term, these statistics should be able to be visualized in ioBroker
69
69
  Placeholder for the next version (at the beginning of the line):
70
70
  ### **WORK IN PROGRESS**
71
71
  -->
72
+ ### 2.4.2 (2026-04-04)
73
+ * fix test-and-release: deploy with 24.x
74
+
75
+ ### 2.4.1 (2026-04-04)
76
+ * statistics: flexcharts integration — built-in Apache ECharts configuration with bar and line chart support
77
+ * statistics: day-break visualization with alternating shaded areas for hourly charts
78
+ * statistics: per chart-type templates (`statistics.flexCharts.template.hourly` etc.) for full ECharts customization including functions
79
+ * statistics: data placeholders (`%%solarYield%%`, `%%gridExport%%` etc.) allow complete chart layout control via template states
80
+ * statistics: chart output states (`statistics.flexCharts.jsonOutput.hourly` etc.) updated automatically each hour
81
+
72
82
  ### 2.4.0 (2026-03-14)
73
83
  * fix: the order of bit assignment corrected of alarmsJSON
74
84
  * new state `inverter.x.emma.activeAlarmSN` and `inverter.x.emma.HistoricalAlarmSN` : emma alarms [#226](https://github.com/bolliy/ioBroker.sun2000/issues/226)
package/io-package.json CHANGED
@@ -1,8 +1,34 @@
1
1
  {
2
2
  "common": {
3
3
  "name": "sun2000",
4
- "version": "2.4.0",
4
+ "version": "2.4.2",
5
5
  "news": {
6
+ "2.4.2": {
7
+ "en": "fix test-and-release: deploy with 24.x",
8
+ "de": "fix test-and-release: einsatz mit 24.x",
9
+ "ru": "исправление тест-и-релиз: развертывание с 24.x",
10
+ "pt": "corrigir teste e liberação: implantar com 24.x",
11
+ "nl": "fix test-and-release: inzet met 24.x",
12
+ "fr": "correction test-and-release: déployer avec 24.x",
13
+ "it": "fix test-and-release: implementare con 24.x",
14
+ "es": "fijar prueba y liberación: desplegar con 24.x",
15
+ "pl": "fix test- and- release: rozmieszczanie z 24.x",
16
+ "uk": "виправити тест-і-випуск: розгортання з 24.x",
17
+ "zh-cn": "固定测试和释放:部署24x"
18
+ },
19
+ "2.4.1": {
20
+ "en": "statistics: flexcharts integration — built-in Apache ECharts configuration with bar and line chart support\nstatistics: day-break visualization with alternating shaded areas for hourly charts\nstatistics: per chart-type templates (`statistics.flexCharts.template.hourly` etc.) for full ECharts customization including functions\nstatistics: data placeholders (`%%solarYield%%`, `%%gridExport%%` etc.) allow complete chart layout control via template states\nstatistics: chart output states (`statistics.flexCharts.jsonOutput.hourly` etc.) updated automatically each hour",
21
+ "de": "statistik: flexcharts integration — integrierte Apache ECharts-Konfiguration mit Bar- und Liniendiagrammunterstützung\nstatistik: tages-früh-visualisierung mit alternierenden schattierten bereichen für stundendiagramme\nstatistiken: pro Diagrammvorlagen (`statistics.flexCharts.template.hourly` etc.) für die vollständige ECharts Anpassung einschließlich Funktionen\nstatistik: Datenplatzhalter (`%%solar%%%%`, `%%gridExport%% ` etc.) ermöglichen vollständige Chart-Layout-Kontrolle über Template-Staaten\nstatistiken: Diagrammausgabezustände (`statistics.flexCharts.jsonOutput.hourly` etc.) automatisch jede Stunde aktualisiert",
22
+ "ru": "статистика: интеграция flexcharts — встроенная конфигурация Apache ECharts с поддержкой штрих- и линейных графиков\nстатистика: визуализация на рассвете с чередующимися затененными областями для часовых графиков\nстатистика: на шаблоны типа диаграммы («statistics.flexCharts.template.hourly» и т. д.) для полной настройки EChart, включая функции\nстатистика: держатели данных («%%solarYield%%», «%%gridExport%%» и т.д.) позволяют полностью контролировать макет диаграммы через шаблонные состояния\nстатистика: состояния выхода диаграммы (statistics.flexCharts.jsonOutput.hourly и т. д.) обновляются автоматически каждый час",
23
+ "pt": "estatísticas: integração de flexcharts — configuração integrada do Apache ECharts com suporte a barras e gráficos de linha\nestatísticas: visualização diurna com áreas de sombra alternadas para gráficos horários\nestatísticas: por modelos de tipo gráfico (`statistics.flexCharts.template.hourly` etc.) para personalização completa de ECharts, incluindo funções\nestatísticas: placeholders de dados (` %s olarYield%% `, `% gridExport%% ` etc.) permitem o controle completo do layout do gráfico através de estados de modelo\nestatísticas: estados de saída do gráfico (`statistics.flexCharts.jsonOutput.hourly` etc.) atualizados automaticamente a cada hora",
24
+ "nl": "statistics: flexcharts integration Ingebouwde Apache ECharts configuratie met ondersteuning voor bar en lijndiagram\nstatistieken: dagvakvisualisatie met afwisselende schaduwgebieden voor uurkaarten\nstatistieken: per grafiek-type sjablonen (\nstatistieken: data placeholders (\nstatistieken: grafiek output states (",
25
+ "fr": "statistiques: intégration des flexcharts — configuration Apache ECharts intégrée avec support de la barre et de la ligne\nstatistiques: visualisation de la pause-jour avec zones ombrées alternées pour les graphiques horaires\nstatistiques: par modèle type de graphique (`statistics.flexCharts.template.hourly` etc.) pour la personnalisation complète d'ECharts, y compris les fonctions\nstatistiques : les détenteurs de place de données (`%%solarYield%%`, `%gridExport%%%` etc.) permettent un contrôle complet de la disposition des graphiques via les états de gabarit\nstatistiques: états de sortie du graphique (`statistics.flexCharts.jsonOutput.hourly` etc.) mis à jour automatiquement chaque heure",
26
+ "it": "statistiche: integrazione di flexcharts — configurazione integrata di Apache ECharts con supporto grafico a barre e linea\nstatistiche: visualizzazione day-break con aree ombreggiate alternate per grafici orari\nstatistiche: per modelli di tipo grafico (`statistics.flexCharts.template.hourly` ecc.) per la personalizzazione completa di ECharts comprese le funzioni\nstatistiche: i segnaposto dei dati (%solarYield%%, `%gridExport%%` ecc.) permettono il controllo completo del layout del grafico tramite gli stati del modello\nstatistiche: stati di output grafico (`statistics.flexCharts.jsonOutput.hourly` ecc.) aggiornati automaticamente ogni ora",
27
+ "es": "estadística: integración de flexcharts — configuración integrada de Apache ECharts con soporte de barras y gráficos\nestadística: visualización del día con áreas alternadas sombreadas para gráficos por hora\nestadística: por plantillas tipo gráfico (`statistics.flexCharts.template.hourly` etc.) para la personalización completa de ECharts incluyendo funciones\nestadística: marcadores de datos ( \"%solarYield%%% \" , \"%gridExport%%% \" , etc.) permiten un control completo de la distribución de gráficos a través de estados de plantilla\nestadística: estados de salida del gráfico (`statistics.flexCharts.jsonOutput.hourly` etc.) actualizados automáticamente cada hora",
28
+ "pl": "statystyki: integracja flexcharts - built- w konfiguracji Apache ECharts z obsługą wykresu paska i linii\nstatystyki: wizualizacja z naprzemiennymi zacienionymi obszarami dla wykresów godzinowych\nstatystyki: na szablony typu chart- ('statistics.flexCharts.template.hourly' itp.) dla pełnej personalizacji ECharts, w tym funkcji\nstatystyki: Posiadacze danych ('% %s olarYeld%%', '%% gridExport%%' itd.) pozwalają na pełną kontrolę układu wykresu za pomocą szablonów stanów\nstatystyki: stany wyjściowe wykresu ('statistics.flexCharts.jsonOutput.hourly' itp.) aktualizowane automatycznie co godzinę",
29
+ "uk": "статистика: інтеграція флекса — вбудована конфігурація Apache ECharts з підтримкою діаграми бару та лінії\nстатистика: візуалізація денного розриву з чергуванням затінених зон на часових графіках\nстатистика: за шаблони діаграм типу (`statistics.flexCharts.template.hrly` і т.д.) для повної настройки ECharts, включаючи функції\nстатистика: Держателі (`%solarYield%%`, `%gridExport%%` і т.д.) дозволяють повністю контролювати макети за допомогою шаблонних станів\nстатистика: діаграми вихідних станів (`statistics.flexCharts.jsonOutput.hrly` і т.д.) оновлено автоматично за кожну годину",
30
+ "zh-cn": "统计:弹性图集成——内置的Apache ECharts配置,并有栏和行图支持\n统计:日间可视化,小时图表可交替显示阴影区域\n统计:每个图表类型的模板(`Statistics.flex Charts.template.hourly'等),用于包括功能在内的全部ECharts定制\n统计:数据占位符(`%%solarYield%%`,`%%gridExport%%`等)允许通过模板状态进行完整的图表布局控制\n统计:图表输出状态(`STATistics.flexCHarts.jsonOutput.hourly'等)"
31
+ },
6
32
  "2.4.0": {
7
33
  "en": "fix: the order of bit assignment corrected of alarmsJSON\nnew state `inverter.x.emma.activeAlarmSN` and `inverter.x.emma.HistoricalAlarmSN` : emma alarms [#226](https://github.com/bolliy/ioBroker.sun2000/issues/226)\nstatistics: Aggregates historical collected datapoints into time-based summaries (e.g. hourly, daily, monthly, yearly). The data is stored in the path `statistics` as JSON.",
8
34
  "de": "fix: die Reihenfolge der Bitzuweisung korrigiert von Alarmen JSON\nneuer Zustand `inverter.x.emma.activeAlarmSN` und `inverter.x.emma.HistoricalAlarmSN`: Emma alarms [#226](https://github.com/bolliy/ioBroker.sun2000/issues/226)\nstatistik: Aggregate historische gesammelte Datenpunkte in zeitbasierte Zusammenfassungen (z.B. stündlich, täglich, monatlich, jährlich). Die Daten werden im Pfad `statistics` als JSON gespeichert.",
@@ -67,32 +93,6 @@
67
93
  "pl": "aktualizacje zależności i konfiguracji\nnowy stan \"collected.dailyExternalYield\" Riemann sum of 'collected.externalPower'",
68
94
  "uk": "оновлення залежності та конфігурації\nновий стан `collected.dailyExternalYield` Сума Riemann `collected.externalPower й",
69
95
  "zh-cn": "依赖和配置更新\n新国家`收集.每日外部耶尔德' 收集的里曼总和。 `"
70
- },
71
- "2.3.3": {
72
- "en": "Improved verification of the adapter configuration\nnew state `inverter.x.derived.dailyActiveEnergy` Inverter daily active energy, which is determined via the Riemann sum of `inverter.x.activePower`\nstate `collected.dailyInputYield` was redisigned based on inverter.[0..n-1].derived.dailyActiveEnergy",
73
- "de": "Verbesserte Überprüfung der Adapterkonfiguration\n`inverter.x.derived.dailyActiveEnergy` Inverter tägliche aktive Energie, die über die Riemann Summe von `inverter.x.activePower bestimmt wird `\nstate `collected.dailyInputYield` wurde basierend auf Inverter zurückgeteilt. [0.n-1].derived.dailyActiveEnergy",
74
- "ru": "Улучшенная проверка конфигурации адаптера\nновое состояние «inverter.x.derived.dailyActiveEnergy» Инверторная ежедневная активная энергия, которая определяется через сумму Римана 'inverter.x.activePower пункт\nstate 'collected.dailyInputYield' была переопределена на основе инвертора. [0..n-1].derived.dailyActiveEnergy",
75
- "pt": "Verificação melhorada da configuração do adaptador\nnovo estado «inverter.x.derived.dailyActiveEnergy» Energia ativa diária do inversor, que é determinada através da soma Riemann de `inverter.x.activePower `\nestado `colleted.dailyInputYield` foi reassinado com base no inversor. [0..n-1].derivado.diárioAtividadeEnergia",
76
- "nl": "Verbeterde verificatie van de configuratie van de adapter\nnieuwe staat Inverter dagelijkse actieve energie, die wordt bepaald via de Riemann som van Wat\nstaat [0..n-1].afgeleid.dailyActiveEnergy",
77
- "fr": "Amélioration de la vérification de la configuration de l'adaptateur\nnouvel état `inverter.x.derived.dailyActiveEnergy` Inverter l'énergie active quotidienne, qui est déterminée par la somme Riemann de `inverter.x.activePower \"\nl'état `collected.dailyInputYield` a été résigné sur la base de l'onduleur. Énergie active",
78
- "it": "Verifica migliorata della configurazione dell'adattatore\nnuovo stato `inverter.x.derived.dailyActiveEnergy` Inverter energia attiva quotidiana, che è determinata tramite la somma Riemann di `inverter.x.activePower #\nstato `collect.dailyInputYield` è stato ridisegnato sulla base di inverter. [0.n-1]",
79
- "es": "Mejor verificación de la configuración del adaptador\nnuevo estado `inverter.x.derived.dailyActiveEnergy` Inverter energía activa diaria, que se determina a través de la suma Riemann de `inverter.x.activePower `\nestado `colected.dailyInputYield` fue redisigned basado en inverter. [0.n-1].derived.dailyActiveEnergy",
80
- "pl": "Ulepszona weryfikacja konfiguracji adaptera\nnowy stan 'inverter.x.derived.dailyActiveEnergy' Inwerter dzienna aktywna energia, która jest określana za pomocą sumy Riemann 'inverter.x.activePower'\nstan 'collected.dailyInputYield' został wycofany na podstawie inwertera. [0.. n-1] .derived.dailyActiveEnergy",
81
- "uk": "Покращена перевірка конфігурації адаптера\nновий стан `inverter.x.derived.dailyActiveEnergy` Інверторна щоденна активна енергія, яка визначається за рахунок `inverter.x.activePower й\n`collected.dailyInputYield` був перерахований на основі інвертора. [0..n-1].derived.dailyActiveEnergy",
82
- "zh-cn": "改进适配器配置的核查\n新州`反转器.x.衍生的每日能源 ' 每日反转活性能量,通过`反转器.x.activePower的Riemann总和确定 `\ndaily InputYield)根据反差重新签名。 [0.n-1]. 衍生的每日能源"
83
- },
84
- "2.3.2": {
85
- "en": "allows again `control.battery.chargeFromGridFunction` when using the Emma",
86
- "de": "erlaubt wieder `control.battery.chargeFromGridFunction` bei Verwendung der Emma",
87
- "ru": "позволяет снова «control.battery.chargeFromGridFunction» при использовании Emma",
88
- "pt": "permite novamente `control.battery.chargeFromGridFunction` ao usar a Emma",
89
- "nl": "maakt het mogelijk opnieuw te controleren.battery.chargeFromGridFunction",
90
- "fr": "permet de nouveau `control.battery.chargeFromGridFunction` lors de l'utilisation de l'Emma",
91
- "it": "permette di nuovo `control.battery.chargeFromGridFunction` quando si utilizza Emma",
92
- "es": "permite de nuevo 'control.battery.chargeDesdeGridFunction' al utilizar Emma",
93
- "pl": "pozwala ponownie 'control.battery.chargeFromGridFunction' podczas korzystania z Emmy",
94
- "uk": "дозволяє знову `control.battery.chargeЗ альбомуGridFunction` при використанні Емма",
95
- "zh-cn": "允许在使用 Emma 时再次使用 \" control.battery. charge from GridFunction \" "
96
96
  }
97
97
  },
98
98
  "titleLang": {
@@ -129,6 +129,7 @@
129
129
  "modbus",
130
130
  "sun2000",
131
131
  "luna2000",
132
+ "emma",
132
133
  "inverter",
133
134
  "smartcharger",
134
135
  "sdongle"
package/lib/register.js CHANGED
@@ -375,7 +375,7 @@ class Registers {
375
375
  unit: state.unit,
376
376
  desc: state.desc,
377
377
  read: true,
378
- write: false,
378
+ write: state.write || false,
379
379
  },
380
380
  native: {},
381
381
  });
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,16 +24,8 @@ class statistics {
23
24
  this.adapter = adapterInstance;
24
25
  this.stateCache = stateCache;
25
26
  this.taskTimer = null;
27
+ this._path = 'statistics';
26
28
  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
29
 
37
30
  this.stats = [
38
31
  {
@@ -41,14 +34,6 @@ class statistics {
41
34
  unit: 'kWh',
42
35
  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
36
  },
44
- /*
45
- {
46
- sourceId: 'collected.consumptionSum',
47
- targetPath: 'consumptionSum',
48
- unit: 'kWh',
49
- type: statisticsType.delta,
50
- },
51
- */
52
37
  {
53
38
  sourceId: 'collected.dailySolarYield',
54
39
  targetPath: 'solarYield',
@@ -120,16 +105,93 @@ class statistics {
120
105
  initVal: '[]',
121
106
  },
122
107
  // a state where users may store a Flexcharts/eCharts options template
123
- /*
108
+ // --- Templates: eines pro Chart-Typ ---
124
109
  {
125
- id: 'statistics.flexChartTemplate',
126
- name: 'Flexcharts template',
110
+ id: 'statistics.flexCharts.template.hourly',
111
+ name: 'Flexcharts template hourly',
127
112
  type: 'string',
128
113
  role: 'json',
129
- desc: 'Optional eCharts option object that will be merged into the default chart. Leave empty for the builtin layout.',
114
+ desc: 'Optional eCharts template for hourly chart. Leave empty {} for built-in layout.',
115
+ write: true,
116
+ initVal: '{}',
117
+ },
118
+ {
119
+ id: 'statistics.flexCharts.template.daily',
120
+ name: 'Flexcharts template daily',
121
+ type: 'string',
122
+ role: 'json',
123
+ desc: 'Optional eCharts template for daily chart. Leave empty {} for built-in layout.',
124
+ write: true,
125
+ initVal: '{}',
126
+ },
127
+ {
128
+ id: 'statistics.flexCharts.template.weekly',
129
+ name: 'Flexcharts template weekly',
130
+ type: 'string',
131
+ role: 'json',
132
+ desc: 'Optional eCharts template for weekly chart. Leave empty {} for built-in layout.',
133
+ write: true,
134
+ initVal: '{}',
135
+ },
136
+ {
137
+ id: 'statistics.flexCharts.template.monthly',
138
+ name: 'Flexcharts template monthly',
139
+ type: 'string',
140
+ role: 'json',
141
+ desc: 'Optional eCharts template for monthly chart. Leave empty {} for built-in layout.',
142
+ write: true,
143
+ initVal: '{}',
144
+ },
145
+ {
146
+ id: 'statistics.flexCharts.template.annual',
147
+ name: 'Flexcharts template annual',
148
+ type: 'string',
149
+ role: 'json',
150
+ desc: 'Optional eCharts template for annual chart. Leave empty {} for built-in layout.',
151
+ write: true,
152
+ initVal: '{}',
153
+ },
154
+ // --- Output: eines pro Chart-Typ ---
155
+ {
156
+ id: 'statistics.flexCharts.jsonOutput.hourly',
157
+ name: 'Flexcharts output hourly',
158
+ type: 'string',
159
+ role: 'json',
160
+ desc: 'ECharts configuration for hourly chart',
161
+ initVal: '{}',
162
+ },
163
+ {
164
+ id: 'statistics.flexCharts.jsonOutput.daily',
165
+ name: 'Flexcharts output daily',
166
+ type: 'string',
167
+ role: 'json',
168
+ desc: 'ECharts configuration for daily chart',
169
+ initVal: '{}',
170
+ },
171
+ {
172
+ id: 'statistics.flexCharts.jsonOutput.weekly',
173
+ name: 'Flexcharts output weekly',
174
+ type: 'string',
175
+ role: 'json',
176
+ desc: 'ECharts configuration for weekly chart',
177
+ initVal: '{}',
178
+ },
179
+ {
180
+ id: 'statistics.flexCharts.jsonOutput.monthly',
181
+ name: 'Flexcharts output monthly',
182
+ type: 'string',
183
+ role: 'json',
184
+ desc: 'ECharts configuration for monthly chart',
185
+ initVal: '{}',
186
+ },
187
+ {
188
+ id: 'statistics.flexCharts.jsonOutput.annual',
189
+ name: 'Flexcharts output annual',
190
+ type: 'string',
191
+ role: 'json',
192
+ desc: 'ECharts configuration for annual chart',
130
193
  initVal: '{}',
131
194
  },
132
- */
133
195
  ],
134
196
  },
135
197
  ];
@@ -172,7 +234,7 @@ class statistics {
172
234
  * @returns {Promise<void>}
173
235
  */
174
236
 
175
- async _calculateGeneric(stateId, periodStart, periodEnde) {
237
+ _calculateGeneric(stateId, periodStart, periodEnde) {
176
238
  const toStr = this._localIsoWithOffset(periodEnde);
177
239
  let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
178
240
  let arr = [];
@@ -243,31 +305,10 @@ class statistics {
243
305
 
244
306
  this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
245
307
  this.adapter.logger.debug(`Appended ${stateId} statistic ${toStr}`);
308
+ return arr.length > 0;
246
309
  }
247
310
 
248
- /**
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.
253
- *
254
- * @returns {void}
255
- */
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) {
311
+ _clearGeneric(stateId, periodStart) {
271
312
  let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
272
313
  let arr = [];
273
314
  try {
@@ -298,7 +339,7 @@ class statistics {
298
339
  * @param {string} periodType - The type of period for which the aggregation is performed.
299
340
  * @returns {void}
300
341
  */
301
- async _calculateAggregation(sourceStateId, targetStateId, getWindow, periodType) {
342
+ _calculateAggregation(sourceStateId, targetStateId, getWindow, periodType) {
302
343
  try {
303
344
  const now = new Date();
304
345
  const window = getWindow(now);
@@ -306,7 +347,7 @@ class statistics {
306
347
  const toDate = window.to;
307
348
  if (now < toDate) {
308
349
  this.adapter.logger.debug(`statistics.js: Skipping ${periodType} aggregation because current time is before end of aggregation window`);
309
- return;
350
+ return false;
310
351
  }
311
352
  const toStr = this._localIsoWithOffset(toDate);
312
353
 
@@ -322,7 +363,7 @@ class statistics {
322
363
 
323
364
  // Avoid duplicates
324
365
  const last = targetArray.length > 0 ? targetArray[targetArray.length - 1] : {};
325
- if (last.to === toStr) return;
366
+ if (last.to === toStr) return false;
326
367
 
327
368
  const target = {
328
369
  from: this._localIsoWithOffset(fromDate),
@@ -379,30 +420,45 @@ class statistics {
379
420
  targetArray.push(target);
380
421
  }
381
422
 
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
423
  targetArray.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
392
424
  this.stateCache.set(targetStateId, JSON.stringify(targetArray), { type: 'string' });
393
425
  this.adapter.logger.debug(`Appended ${periodType} statistic ${toStr} `);
394
- return true;
426
+ return targetArray.length > 0;
395
427
  } catch (err) {
396
428
  this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
397
429
  }
398
430
  }
399
431
 
432
+ /**
433
+ * Calculates and updates hourly consumption statistics.
434
+ *
435
+ * This function calculates the hourly consumption statistics based on the current day's data.
436
+ * It retrieves the consumption data and updates the hourly consumption JSON accordingly.
437
+ *
438
+ * @returns {void}
439
+ */
440
+ _calculateHourly() {
441
+ const now = new Date();
442
+ if (this.testing) {
443
+ const state = this.adapter.getState('statistics.jsonHourly');
444
+ this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
445
+ now.setDate(now.getDate() + 1); // set to start of day for testing to have consistent results
446
+ now.setHours(1, 0, 0, 1); // set to 1ms after midnight to trigger hourly calculation for the new day
447
+ }
448
+ const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
449
+ const lastHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), 0, 0, 0);
450
+ this.adapter.log.debug('### Hourly execution triggered ###');
451
+ if (this._calculateGeneric('statistics.jsonHourly', startOfDay, lastHour)) {
452
+ this._buildFlexchart('hourly');
453
+ }
454
+ }
455
+
400
456
  /**
401
457
  * Calculates and updates daily consumption statistics from hourly data.
402
458
  *
403
459
  * @returns {void}
404
460
  */
405
- async _calculateDaily() {
461
+ _calculateDaily() {
406
462
  this.adapter.log.debug('### Daily execution triggered ###');
407
463
  this._calculateAggregation(
408
464
  'statistics.jsonHourly',
@@ -415,7 +471,7 @@ class statistics {
415
471
  return { from: yesterday, to: today };
416
472
  },
417
473
  'daily',
418
- ) && (this.lastExecution.daily = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple days at startup
474
+ ) && this._buildFlexchart('daily'); // only update last execution time if aggregation was performed to avoid backfilling multiple days at startup
419
475
  }
420
476
 
421
477
  /**
@@ -423,22 +479,26 @@ class statistics {
423
479
  *
424
480
  * @returns {void}
425
481
  */
426
- async _calculateWeekly() {
482
+ _calculateWeekly() {
427
483
  this.adapter.log.debug('### Weekly execution triggered ###');
428
- this._calculateAggregation(
429
- 'statistics.jsonDaily',
430
- 'statistics.jsonWeekly',
431
- 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 };
439
- },
440
- 'weekly',
441
- ) && (this.lastExecution.weekly = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple weeks at startup
484
+ if (
485
+ this._calculateAggregation(
486
+ 'statistics.jsonDaily',
487
+ 'statistics.jsonWeekly',
488
+ now => {
489
+ // aggregation window: Monday to Sunday of the previous week (week that just ended)
490
+ const startday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
491
+ const lastday = new Date(startday);
492
+ // set to Monday of previous week
493
+ lastday.setDate(now.getDate() - (now.getDay() || 7) + 1); // set to Monday of actual week
494
+ startday.setDate(lastday.getDate() - 7); // set to Monday of previous week
495
+ return { from: startday, to: lastday };
496
+ },
497
+ 'weekly',
498
+ )
499
+ ) {
500
+ this._buildFlexchart('weekly');
501
+ } // only update last execution time if aggregation was performed to avoid backfilling multiple weeks at startup
442
502
  }
443
503
 
444
504
  /**
@@ -446,7 +506,7 @@ class statistics {
446
506
  *
447
507
  * @returns {void}
448
508
  */
449
- async _calculateMonthly() {
509
+ _calculateMonthly() {
450
510
  this.adapter.log.debug('### Monthly execution triggered ###');
451
511
  this._calculateAggregation(
452
512
  'statistics.jsonDaily',
@@ -458,7 +518,7 @@ class statistics {
458
518
  return { from: prevMonth, to: thisMonth };
459
519
  },
460
520
  'monthly',
461
- ) && (this.lastExecution.monthly = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple months at startup
521
+ ) && this._buildFlexchart('monthly'); // only update last execution time if aggregation was performed to avoid backfilling multiple months at startup
462
522
  }
463
523
 
464
524
  /**
@@ -466,7 +526,7 @@ class statistics {
466
526
  *
467
527
  * @returns {void}
468
528
  */
469
- async _calculateAnnual() {
529
+ _calculateAnnual() {
470
530
  this.adapter.log.debug('### Annual execution triggered ###');
471
531
  this._calculateAggregation(
472
532
  'statistics.jsonDaily',
@@ -478,14 +538,14 @@ class statistics {
478
538
  return { from: prevYear, to: thisYear };
479
539
  },
480
540
  'annual',
481
- ) && (this.lastExecution.annual = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple years at startup
541
+ ) && this._buildFlexchart('annual'); // only update last execution time if aggregation was performed to avoid backfilling multiple years at startup
482
542
  }
483
543
 
484
544
  /**
485
545
  * Initialize and schedule the unified task manager.
486
546
  * This task runs every minute and checks which statistics need to be calculated.
487
547
  */
488
- async _initializeTask() {
548
+ _initializeTask() {
489
549
  const scheduleNextRun = () => {
490
550
  const now = new Date();
491
551
  const next = new Date(now);
@@ -505,16 +565,15 @@ class statistics {
505
565
  this.adapter.clearTimeout(this.taskTimer);
506
566
  }
507
567
 
508
- this.taskTimer = this.adapter.setTimeout(async () => {
509
- await this._executeScheduledTasks();
568
+ this.taskTimer = this.adapter.setTimeout(() => {
569
+ this._executeScheduledTasks();
510
570
  scheduleNextRun(); // reschedule for next hour
511
571
  }, msToNextHour);
512
572
  };
513
- //await this._executeScheduledTasks(); // execute immediately on startup to catch up on any missed runs while the adapter was not running
514
573
  // Schedule the next run
515
574
  scheduleNextRun();
516
575
  }
517
- async _executeScheduledTasks() {
576
+ _executeScheduledTasks() {
518
577
  this._calculateHourly();
519
578
  this._calculateDaily();
520
579
  this._calculateWeekly();
@@ -527,15 +586,15 @@ class statistics {
527
586
  * - Execute all scheduled tasks to ensure that statistics are up to date.
528
587
  * - Clear old data based on retention policies.
529
588
  */
530
- async mitNightProcess() {
589
+ mitNightProcess() {
531
590
  const now = new Date();
532
- await this._executeScheduledTasks();
591
+ this._executeScheduledTasks();
533
592
  // Clear old data based on retention policies
534
593
  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));
594
+ this._clearGeneric('statistics.jsonDaily', startOfYear);
595
+ this._clearGeneric('statistics.jsonWeekly', startOfYear);
596
+ this._clearGeneric('statistics.jsonMonthly', startOfYear);
597
+ this._clearGeneric('statistics.jsonHourly', new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0, 0));
539
598
  }
540
599
 
541
600
  async initialize() {
@@ -547,7 +606,7 @@ class statistics {
547
606
  this.stateCache.set('statistics.jsonDaily', state?.val ?? '[]', { type: 'string', stored: true });
548
607
 
549
608
  state = await this.adapter.getState('statistics.jsonWeekly');
550
- this.stateCache.set('statistics.jsonWeekly', state?.val ?? '[]', { type: 'string', stored: true });
609
+ this.stateCache.set('statistics.jsonWeekly', state?.val ?? '[]', { type: 'string', stored: true }); //is already stored
551
610
 
552
611
  state = await this.adapter.getState('statistics.jsonMonthly');
553
612
  this.stateCache.set('statistics.jsonMonthly', state?.val ?? '[]', { type: 'string', stored: true });
@@ -555,30 +614,28 @@ class statistics {
555
614
  state = await this.adapter.getState('statistics.jsonAnnual');
556
615
  this.stateCache.set('statistics.jsonAnnual', state?.val ?? '[]', { type: 'string', stored: true });
557
616
 
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
617
  // 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
- */
569
618
  await tools.waitForValue(() => this.stateCache.get('collected.accumulatedEnergyYield')?.value, 60000);
619
+
620
+ // load templates — eines pro Chart-Typ
621
+ for (const chartType of ['hourly', 'daily', 'weekly', 'monthly', 'annual']) {
622
+ const templateStateId = `statistics.flexCharts.template.${chartType}`;
623
+ state = await this.adapter.getState(templateStateId);
624
+ this.stateCache.set(templateStateId, state?.val ?? '{}', { type: 'string', stored: true });
625
+ if (state?.ack === false) {
626
+ this.stateCache.set(templateStateId, state.val, { type: 'string' });
627
+ await this.adapter.setState(templateStateId, { val: state.val, ack: true });
628
+ this._buildFlexchart(chartType);
629
+ }
630
+ }
631
+
570
632
  this.mitNightProcess(); // execute once on startup to catch up on any missed runs while the adapter was not running
571
633
  this._initializeTask();
634
+ this.adapter.subscribeStates(`${this._path}.*`);
572
635
  }
573
636
 
574
- /**
575
- * Build a flexcharts/eCharts option object from stored statistics.
576
- * The returned object may be sent to a callback for flexcharts' script source.
577
- *
578
- * @param {string} myChart - one of 'hourly','daily','weekly','monthly','annual'
579
- * @returns {object} chart configuration
580
- */
581
- _buildFlexchart(myChart) {
637
+ _buildFlexchart(myChart, chartStyle) {
638
+ chartStyle = chartStyle || (myChart === 'hourly' ? 'line' : 'bar'); // default styles: line for hourly (to better see the curve), bar for others
582
639
  const IDS = {
583
640
  hourly: 'statistics.jsonHourly',
584
641
  daily: 'statistics.jsonDaily',
@@ -594,123 +651,447 @@ class statistics {
594
651
  data = [];
595
652
  }
596
653
 
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: [],
654
+ // --- X-Axis labels ---
655
+ const xAxisData = data.map(entry => {
656
+ const from = new Date(entry.from);
657
+ const to = new Date(entry.to);
658
+ if (myChart === 'hourly') {
659
+ return `${to.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })} ${to.toLocaleTimeString('de-DE', {
660
+ hour12: false,
661
+ hour: '2-digit',
662
+ minute: '2-digit',
663
+ })}`;
664
+ }
665
+ if (myChart === 'weekly') {
666
+ const yesterday = new Date(to);
667
+ yesterday.setDate(yesterday.getDate() - 1);
668
+ return `${from.toLocaleDateString('de-DE', { month: '2-digit', day: '2-digit' })}..${yesterday.toLocaleTimeString('de-DE', { month: '2-digit', day: '2-digit' })}`;
669
+ }
670
+ if (myChart === 'monthly') {
671
+ return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit' });
672
+ }
673
+ if (myChart === 'annual') {
674
+ return from.toLocaleDateString('de-DE', { year: 'numeric' });
675
+ }
676
+
677
+ return from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
678
+ /*
679
+ return myChart === 'hourly'
680
+ ? `${to.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' })} ${to.toLocaleTimeString('de-DE', {
681
+ hour12: false,
682
+ hour: '2-digit',
683
+ minute: '2-digit',
684
+ })}`
685
+ : from.toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
686
+ */
687
+ });
688
+
689
+ const xAxisDataShort = myChart === 'hourly' ? xAxisData.map(label => label.split(' ')[1]) : xAxisData;
690
+
691
+ // --- Tagesbereiche ---
692
+ const dayAreas = [];
693
+ if (myChart === 'hourly' && xAxisData.length > 0) {
694
+ const dayBoundaries = [0];
695
+ xAxisData.forEach((label, i) => {
696
+ if (i === 0) return;
697
+ const date = label.split(' ')[0];
698
+ const prevDate = xAxisData[i - 1].split(' ')[0];
699
+ if (date !== prevDate) dayBoundaries.push(i);
700
+ });
701
+ dayBoundaries.push(xAxisData.length);
702
+
703
+ dayBoundaries.forEach((startIdx, d) => {
704
+ if (d >= dayBoundaries.length - 1) return;
705
+ const endIdx = dayBoundaries[d + 1];
706
+ const date = xAxisData[startIdx].split(' ')[0];
707
+ const shaded = d % 2 === 1;
708
+ dayAreas.push([
709
+ {
710
+ xAxis: startIdx - 0.5,
711
+ label: {
712
+ show: true,
713
+ position: 'insideTop',
714
+ formatter: date,
715
+ color: '#555',
716
+ fontSize: 11,
717
+ fontWeight: 'bold',
718
+ backgroundColor: 'rgba(255,255,255,0.7)',
719
+ padding: [2, 4],
720
+ borderRadius: 3,
721
+ },
722
+ },
723
+ {
724
+ xAxis: endIdx - 0.5,
725
+ itemStyle: shaded
726
+ ? { color: 'rgba(180,180,180,0.15)', borderColor: 'rgba(120,120,120,0.3)', borderWidth: 1, borderType: 'dashed' }
727
+ : { color: 'rgba(255,255,255,0)' },
728
+ },
729
+ ]);
730
+ });
731
+ }
732
+
733
+ // --- Series data extraction ---
734
+ const extract = key => data.map(e => Number(Number(e[key]?.value ?? 0).toFixed(3)));
735
+ const negate = arr => arr.map(v => Number((-v).toFixed(3)));
736
+
737
+ const seriesData = {
738
+ solarYield: extract('solarYield'),
739
+ consumption: extract('consumption'),
740
+ gridExport: extract('gridExport'),
741
+ gridImport: extract('gridImport'),
742
+ chargeCapacity: extract('chargeCapacity'),
743
+ dischargeCapacity: extract('dischargeCapacity'),
744
+ SOC: extract('SOC'),
745
+ gridExportNeg: negate(extract('gridExport')),
746
+ chargeCapacityNeg: negate(extract('chargeCapacity')),
607
747
  };
608
748
 
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
- }
621
- }
622
- } catch (e) {
623
- this.adapter.logger.warn(`statistics: invalid flexChartTemplate JSON: ${e.message}`);
749
+ // --- Tooltip formatter (zeigt immer positive Werte, filtert DayBreak heraus) ---
750
+ const tooltipFormatter = params => {
751
+ if (!Array.isArray(params)) params = [params];
752
+ return params
753
+ .filter(p => p.seriesName !== 'DayBreak')
754
+ .map(p => {
755
+ const negatedSeries = ['Grid Export', 'Charge'];
756
+ const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
757
+ const unit = p.seriesName === 'SOC' ? ' %' : ' kWh';
758
+ return `${p.marker}${p.seriesName}: <b>${val}${unit}</b>`;
759
+ })
760
+ .join('<br/>');
761
+ };
762
+
763
+ // --- Load chart-type specific template ---
764
+ const templateStateId = `statistics.flexCharts.template.${myChart}`;
765
+ const outputStateId = `statistics.flexCharts.jsonOutput.${myChart}`;
766
+
767
+ const templateStr = this.stateCache.get(templateStateId)?.value ?? '{}';
768
+ let chartStr = '{}';
769
+
770
+ try {
771
+ const templ = JSON.parse(templateStr);
772
+
773
+ if (Object.keys(templ).length === 0) {
774
+ // Kein Template → built-in Default
775
+ chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
776
+ } else {
777
+ // Template vorhanden → mit javascript-stringify serialisieren
778
+ chartStr = stringify(templ);
624
779
  }
780
+ } catch (e) {
781
+ this.adapter.logger.warn(`statistics: invalid template for ${myChart}: ${e.message}`);
782
+ chartStr = this._buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData);
625
783
  }
626
784
 
627
- // fill data arrays and collect units from stats definitions
628
- const xAxis = [];
629
- const seriesData = {};
630
- const unitMap = {}; // targetPath -> unit string
785
+ // --- Replace data placeholders ---
786
+ chartStr = chartStr
787
+ // X-Achse
788
+ .replace("'%%xAxisData%%'", JSON.stringify(xAxisData))
789
+ .replace("'%%xAxisDataShort%%'", JSON.stringify(xAxisDataShort))
790
+ .replace("'%%xAxisMax%%'", String(xAxisData.length - 1))
791
+ // Originaldaten (immer positiv)
792
+ .replace("'%%solarYield%%'", JSON.stringify(seriesData.solarYield))
793
+ .replace("'%%consumption%%'", JSON.stringify(seriesData.consumption))
794
+ .replace("'%%gridExport%%'", JSON.stringify(seriesData.gridExport))
795
+ .replace("'%%gridImport%%'", JSON.stringify(seriesData.gridImport))
796
+ .replace("'%%chargeCapacity%%'", JSON.stringify(seriesData.chargeCapacity))
797
+ .replace("'%%dischargeCapacity%%'", JSON.stringify(seriesData.dischargeCapacity))
798
+ .replace("'%%SOC%%'", JSON.stringify(seriesData.SOC))
799
+ // Negierte Varianten für gegenläufige Darstellung
800
+ .replace("'%%gridExportNeg%%'", JSON.stringify(seriesData.gridExportNeg))
801
+ .replace("'%%chargeCapacityNeg%%'", JSON.stringify(seriesData.chargeCapacityNeg))
802
+ // Sonstiges
803
+ .replace("'%%dayAreas%%'", JSON.stringify(dayAreas))
804
+ .replace("'%%chartTitle%%'", JSON.stringify(`PV Statistics — ${myChart}`))
805
+ // Funktionen
806
+ .replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
631
807
 
632
- for (const stat of this.stats) {
633
- unitMap[stat.targetPath] = stat.unit || '';
634
- }
808
+ // --- In chart-type specific output state speichern ---
809
+ this.stateCache.set(outputStateId, chartStr, { type: 'string' });
810
+ this.adapter.logger.debug(`statistics: flexCharts built for ${myChart}/${chartStyle}`);
635
811
 
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)));
812
+ return chartStr;
813
+ }
814
+
815
+ /**
816
+ * Build the default chart configuration as javascript-stringify string.
817
+ * Used when no template is provided.
818
+ * @param myChart
819
+ * @param chartStyle
820
+ * @param xAxisData
821
+ * @param xAxisDataShort
822
+ * @param dayAreas
823
+ * @param seriesData
824
+ */
825
+ _buildDefaultChart(myChart, chartStyle, xAxisData, xAxisDataShort, dayAreas, seriesData) {
826
+ const xAxisFormatterHourly = value => {
827
+ if (value.includes('|')) return value;
828
+ return value.split(' ')[1] ?? value;
829
+ };
830
+
831
+ const seriesType = chartStyle === 'line' ? 'line' : 'bar';
832
+ const lineOptions =
833
+ chartStyle === 'line' ? { smooth: true, symbol: 'circle', symbolSize: 4, lineStyle: { width: 2 }, areaStyle: { opacity: 0.15 } } : {};
834
+
835
+ const negate = arr => arr.map(v => Number((-v).toFixed(3)));
836
+ const showSOC = myChart === 'hourly';
837
+
838
+ // Tooltip formatter — zeigt immer positive Werte, filtert DayBreak heraus
839
+ const tooltipFormatter = params => {
840
+ if (!Array.isArray(params)) params = [params];
841
+ return params
842
+ .filter(p => p.seriesName !== 'DayBreak')
843
+ .map(p => {
844
+ const negatedSeries = ['Grid Export', 'Charge'];
845
+ const val = negatedSeries.includes(p.seriesName) ? Math.abs(p.value) : p.value;
846
+ const unit = p.seriesName === 'SOC' ? ' %' : ' kWh';
847
+ return `${p.marker}${p.seriesName}: <b>${val}${unit}</b>`;
848
+ })
849
+ .join('<br/>');
850
+ };
851
+
852
+ const chart = {
853
+ backgroundColor: '#fff',
854
+ animation: false,
855
+ title: {
856
+ left: 'center',
857
+ text: `SUN2000 - PV Statistics - ${myChart}`,
858
+ },
859
+ legend: {
860
+ top: 35,
861
+ left: 'center',
862
+ data: ['Solar Yield', 'Consumption', 'Grid Export', 'Grid Import', 'Charge', 'Discharge', ...(showSOC ? ['SOC'] : [])],
863
+ },
864
+ tooltip: {
865
+ trigger: 'axis',
866
+ axisPointer: { type: 'cross' },
867
+ backgroundColor: 'rgba(245,245,245,0.95)',
868
+ borderWidth: 1,
869
+ borderColor: '#ccc',
870
+ padding: 10,
871
+ textStyle: { color: '#000' },
872
+ formatter: '%%tooltipFormatter%%',
873
+ position: (pos, params, el, elRect, size) => {
874
+ const obj = { top: 10 };
875
+ obj[pos[0] < size.viewSize[0] / 2 ? 'left' : 'right'] = 30;
876
+ return obj;
877
+ },
878
+ },
879
+ axisPointer: {
880
+ link: [{ xAxisIndex: 'all' }],
881
+ label: { backgroundColor: '#777' },
882
+ },
883
+ toolbox: {
884
+ feature: {
885
+ dataZoom: { yAxisIndex: false },
886
+ dataView: { show: true, readOnly: false },
887
+ restore: { show: true },
888
+ saveAsImage: { show: true },
889
+ },
890
+ },
891
+ grid: [
892
+ { left: '8%', right: showSOC ? '8%' : '4%', top: 80, height: '50%' },
893
+ { left: '8%', right: showSOC ? '8%' : '4%', top: '75%', height: '15%' },
894
+ ],
895
+ xAxis: [
896
+ {
897
+ type: 'category',
898
+ data: xAxisDataShort,
899
+ scale: true,
900
+ boundaryGap: chartStyle !== 'line',
901
+ axisLine: { onZero: false },
902
+ splitLine: { show: false },
903
+ axisPointer: { z: 100 },
904
+ min: 0,
905
+ max: xAxisDataShort.length - 1,
906
+ axisLabel: {
907
+ interval: 0,
908
+ lineHeight: 16,
909
+ fontSize: 11,
910
+ formatter: '%%xAxisFormatter%%',
911
+ },
912
+ },
913
+ {
914
+ type: 'category',
915
+ gridIndex: 1,
916
+ data: xAxisData,
917
+ scale: true,
918
+ boundaryGap: false,
919
+ axisLine: { onZero: false },
920
+ axisTick: { show: false },
921
+ splitLine: { show: false },
922
+ axisLabel: { show: false },
923
+ min: 0,
924
+ max: xAxisData.length - 1,
925
+ },
926
+ ],
927
+ yAxis: [
928
+ // Index 0 — Energie links
929
+ {
930
+ scale: false,
931
+ splitArea: { show: true },
932
+ name: 'Energy (kWh)',
933
+ nameLocation: 'middle',
934
+ nameGap: 50,
935
+ axisLabel: { formatter: '{value} kWh' },
936
+ splitLine: { show: true },
937
+ axisLine: { show: true },
938
+ },
939
+ // Index 1 — SOC rechts (nur bei hourly)
940
+ ...(showSOC
941
+ ? [
942
+ {
943
+ type: 'value',
944
+ min: 0,
945
+ max: 100,
946
+ name: 'SOC (%)',
947
+ nameLocation: 'middle',
948
+ nameGap: 40,
949
+ axisLabel: { formatter: '{value} %' },
950
+ splitLine: { show: false },
951
+ axisLine: { show: true },
952
+ },
953
+ ]
954
+ : []),
955
+ // Index 1 oder 2 — Consumption unteres Grid
956
+ {
957
+ scale: true,
958
+ gridIndex: 1,
959
+ splitNumber: 3,
960
+ axisLine: { show: false },
961
+ axisTick: { show: false },
962
+ splitLine: { show: false },
963
+ name: 'Consumption\n(kWh)',
964
+ nameLocation: 'middle',
965
+ nameGap: 50,
966
+ axisLabel: { formatter: '{value}' },
967
+ },
968
+ ],
969
+ dataZoom: [
970
+ { type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 },
971
+ { show: true, xAxisIndex: [0, 1], type: 'slider', bottom: 5, start: 0, end: 100 },
972
+ ],
973
+ series: [
974
+ // Positive Werte
975
+ {
976
+ name: 'Solar Yield',
977
+ type: seriesType,
978
+ data: seriesData.solarYield,
979
+ itemStyle: { color: '#f6c94e' },
980
+ emphasis: { focus: 'series' },
981
+ ...lineOptions,
982
+ },
983
+ {
984
+ name: 'Grid Import',
985
+ type: seriesType,
986
+ data: seriesData.gridImport,
987
+ itemStyle: { color: '#ec0000' },
988
+ emphasis: { focus: 'series' },
989
+ ...lineOptions,
990
+ },
991
+ {
992
+ name: 'Discharge',
993
+ type: seriesType,
994
+ data: seriesData.dischargeCapacity,
995
+ itemStyle: { color: '#ed50e0' },
996
+ emphasis: { focus: 'series' },
997
+ ...lineOptions,
998
+ },
999
+ // Negative Werte (unterhalb Nulllinie)
1000
+ {
1001
+ name: 'Grid Export',
1002
+ type: seriesType,
1003
+ data: negate(seriesData.gridExport),
1004
+ itemStyle: { color: '#5cb85c' },
1005
+ emphasis: { focus: 'series' },
1006
+ ...lineOptions,
1007
+ },
1008
+ {
1009
+ name: 'Charge',
1010
+ type: seriesType,
1011
+ data: negate(seriesData.chargeCapacity),
1012
+ itemStyle: { color: '#5bc0de' },
1013
+ emphasis: { focus: 'series' },
1014
+ ...lineOptions,
1015
+ },
1016
+ // SOC — nur bei hourly
1017
+ ...(showSOC
1018
+ ? [
1019
+ {
1020
+ name: 'SOC',
1021
+ type: 'line',
1022
+ yAxisIndex: 1,
1023
+ data: seriesData.SOC,
1024
+ itemStyle: { color: '#985e24' },
1025
+ lineStyle: { width: 2, type: 'dashed' },
1026
+ symbol: 'none',
1027
+ smooth: true,
1028
+ },
1029
+ ]
1030
+ : []),
1031
+ // Consumption im unteren Grid
1032
+ // yAxisIndex passt sich an: 2 wenn SOC vorhanden, sonst 1
1033
+ {
1034
+ name: 'Consumption',
1035
+ type: seriesType,
1036
+ data: seriesData.consumption,
1037
+ itemStyle: { color: '#337ab7' },
1038
+ xAxisIndex: 1,
1039
+ yAxisIndex: showSOC ? 2 : 1,
1040
+ ...lineOptions,
1041
+ },
1042
+ // Tages-Bereiche
1043
+ ...(dayAreas.length > 0
1044
+ ? [
1045
+ {
1046
+ name: 'DayBreak',
1047
+ type: 'bar',
1048
+ barWidth: 0,
1049
+ data: [],
1050
+ legendHoverLink: false,
1051
+ silent: true,
1052
+ markArea: { silent: true, data: dayAreas },
1053
+ },
1054
+ ]
1055
+ : []),
1056
+ ],
1057
+ };
1058
+
1059
+ return stringify(chart).replace("'%%xAxisFormatter%%'", stringify(xAxisFormatterHourly)).replace("'%%tooltipFormatter%%'", stringify(tooltipFormatter));
1060
+ }
1061
+
1062
+ _deepMerge(target, source) {
1063
+ for (const key of Object.keys(source)) {
1064
+ if (
1065
+ source[key] !== null &&
1066
+ typeof source[key] === 'object' &&
1067
+ !Array.isArray(source[key]) &&
1068
+ target[key] !== null &&
1069
+ typeof target[key] === 'object' &&
1070
+ !Array.isArray(target[key])
1071
+ ) {
1072
+ // Rekursiv für verschachtelte Objekte
1073
+ this._deepMerge(target[key], source[key]);
1074
+ } else {
1075
+ // Primitive, Arrays direkt überschreiben
1076
+ target[key] = source[key];
650
1077
  }
651
1078
  }
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
- };
676
- }
1079
+ return target;
1080
+ }
677
1081
 
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
- }));
1082
+ async handleTemplateChange(chartType, state) {
1083
+ const templateStateId = `statistics.flexCharts.template.${chartType}`;
1084
+ const template = this.stateCache.get(templateStateId)?.value;
1085
+ if (template === null || template === undefined) {
1086
+ this.adapter.logger.warn(`Template state ${templateStateId} not found for handleTemplateChange`);
1087
+ return;
1088
+ }
1089
+ if (state?.val != null) {
1090
+ this.adapter.logger.debug(`statistics: Event - state: ${chartType} changed: ${state.val} ack: ${state.ack}`);
1091
+ this.stateCache.set(templateStateId, state.val, { type: 'string', stored: true });
1092
+ await this.adapter.setState(templateStateId, { val: state.val, ack: true });
1093
+ this._buildFlexchart(chartType);
711
1094
  }
712
- chart.title.text += myChart;
713
- return chart;
714
1095
  }
715
1096
 
716
1097
  /**
@@ -721,7 +1102,8 @@ class statistics {
721
1102
  */
722
1103
  handleFlexMessage(message, callback) {
723
1104
  const chartType = message?.chart || 'hourly';
724
- const result = this._buildFlexchart(chartType);
1105
+ const chartStyle = message?.style;
1106
+ const result = this._buildFlexchart(chartType, chartStyle);
725
1107
  if (callback && typeof callback === 'function') {
726
1108
  callback(result);
727
1109
  }
package/main.js CHANGED
@@ -659,6 +659,14 @@ class Sun2000 extends utils.Adapter {
659
659
  emma.instance.control.set(serviceId, state);
660
660
  }
661
661
  }
662
+
663
+ //sun2000.0.statistics.flexCharts.template
664
+ if (idArray[2] == 'statistics' && idArray[3] == 'flexCharts' && idArray[4] == 'template') {
665
+ const chartType = idArray[5];
666
+ if (this.state.statistics && typeof this.state.statistics.handleTemplateChange === 'function') {
667
+ this.state.statistics.handleTemplateChange(chartType, state);
668
+ }
669
+ }
662
670
  } else {
663
671
  // The state was deleted
664
672
  this.logger.info(`state ${id} deleted`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.sun2000",
3
- "version": "2.4.0",
3
+ "version": "2.4.2",
4
4
  "description": "sun2000",
5
5
  "author": {
6
6
  "name": "bolliy",
@@ -28,9 +28,10 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@iobroker/adapter-core": "^3.3.2",
31
- "modbus-serial": "^8.0.23",
31
+ "modbus-serial": "^8.0.25",
32
32
  "suncalc2": "^1.8.1",
33
- "tcp-port-used": "^1.0.2"
33
+ "tcp-port-used": "^1.0.2",
34
+ "javascript-stringify": "^2.1.0"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@alcalzone/release-script": "^5.1.1",
@@ -40,7 +41,7 @@
40
41
  "@iobroker/adapter-dev": "^1.5.0",
41
42
  "@iobroker/eslint-config": "^2.2.0",
42
43
  "@iobroker/testing": "^5.2.2",
43
- "@tsconfig/node20": "^20.1.9",
44
+ "@tsconfig/node22": "^22.0.5",
44
45
  "@types/node": "^25.5.0",
45
46
  "globals": "^16.5.0",
46
47
  "typescript": "~5.9.3"