iobroker.sun2000 2.3.7 → 2.4.0
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 +10 -1
- package/io-package.json +18 -15
- package/lib/drivers/driver_base.js +3 -2
- package/lib/drivers/driver_emma.js +16 -0
- package/lib/drivers/driver_inverter.js +15 -11
- package/lib/register.js +36 -24
- package/lib/statistics.js +731 -0
- package/lib/tools.js +39 -4
- package/lib/types.js +7 -0
- package/main.js +20 -14
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ Feel free to follow the discussions in the german [iobroker forum](https://forum
|
|
|
30
30
|
## Requirements
|
|
31
31
|
* Node.js 20 or higher
|
|
32
32
|
* ioBroker host (js-controller) 6.0.11 or higher
|
|
33
|
-
* ioBroker admin 7.6.
|
|
33
|
+
* ioBroker admin 7.6.20 or higher
|
|
34
34
|
|
|
35
35
|
## Documentation
|
|
36
36
|
|
|
@@ -59,12 +59,21 @@ browse in the [wiki](https://github.com/bolliy/ioBroker.sun2000/wiki)
|
|
|
59
59
|
* 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
60
|
* 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
61
|
* 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
|
+
|
|
65
|
+
|
|
62
66
|
|
|
63
67
|
## Changelog
|
|
64
68
|
<!--
|
|
65
69
|
Placeholder for the next version (at the beginning of the line):
|
|
66
70
|
### **WORK IN PROGRESS**
|
|
67
71
|
-->
|
|
72
|
+
### 2.4.0 (2026-03-14)
|
|
73
|
+
* fix: the order of bit assignment corrected of alarmsJSON
|
|
74
|
+
* new state `inverter.x.emma.activeAlarmSN` and `inverter.x.emma.HistoricalAlarmSN` : emma alarms [#226](https://github.com/bolliy/ioBroker.sun2000/issues/226)
|
|
75
|
+
* 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.
|
|
76
|
+
|
|
68
77
|
### 2.3.7 (2026-02-01)
|
|
69
78
|
* deleted deprecated state `collected.usableSurplusPower`
|
|
70
79
|
|
package/io-package.json
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"common": {
|
|
3
3
|
"name": "sun2000",
|
|
4
|
-
"version": "2.
|
|
4
|
+
"version": "2.4.0",
|
|
5
5
|
"news": {
|
|
6
|
+
"2.4.0": {
|
|
7
|
+
"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
|
+
"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.",
|
|
9
|
+
"ru": "исправление: порядок назначения битов исправлен сигнализацией Джон\nновое состояние 'inverter.x.emma.activeAlarmSN' и 'inverter.x.emma.HistoricalAlarmSN': emma alarms [#226] (https://github.com/bolliy/ioBroker.sun2000/issues/226)\nстатистика: Собирает исторические собранные точки данных в основанные на времени резюме (например, почасовые, ежедневные, ежемесячные, годовые). Данные хранятся в «статистике» пути JSON.",
|
|
10
|
+
"pt": "corrigir: a ordem de atribuição de bits corrigida dos alarmes JSON\nnovo estado `inverter.x.emma.activeAlarmSN` e `inverter.x.emma.HistoricAlarmSN` : alarmes emma [#226](https://github.com/bolliy/ioBroker.sun2000/issues/226)\nestatísticas: Agrega pontos de dados históricos recolhidos em resumos baseados no tempo (por exemplo, horários, diários, mensais, anuais). Os dados são armazenados no caminho `statistics` como JSON.",
|
|
11
|
+
"nl": "fix: de volgorde van bittoewijzing gecorrigeerd van alarmen JSON\nnieuwe staat \nstatistieken: Verzamelt historische verzamelde datapunten in tijdgebaseerde samenvattingen (bv. uur, dag, maand, jaar). De gegevens worden opgeslagen in het pad .",
|
|
12
|
+
"fr": "correction : l'ordre d'attribution des bits corrigé des alarmes JSON\nnouvel état `inverter.x.emma.activeAlarmSN` et `inverter.x.emma.HistoricalAlarmSN`: emma alarms [#226](https://github.com/bolliy/ioBroker.sun2000/issues/226)\nstatistiques : Agrége les points de données historiques collectés en résumés chronologiques (p. ex. horaires, quotidiens, mensuels, annuels). Les données sont stockées dans le chemin `statistique` comme JSON.",
|
|
13
|
+
"it": "fix: l'ordine di bit assegnazione corretto di allarmi JSON\nnuovo stato `inverter.x.emma.activeAlarmSN` e `inverter.x.emma.HistoricalAlarmSN` : allarme emma [#226](https://github.com/bolliy/ioBroker.sun2000/problems/226)\nstatistiche: Aggrega i datapoint storici raccolti in riassunti basati sul tempo (es. orario, giornaliero, mensile, annuale). I dati vengono memorizzati nel percorso `statistics` come JSON.",
|
|
14
|
+
"es": "fijación: el orden de asignación de bits corregido de alarmas JSON\nnuevo estado `inverter.x.emma.activeAlarmSN` e `inverter.x.emma.HistoricalAlarmSN` : emma alarms [#226](https://github.com/bolliy/ioBroker.sun2000/issues/226)\nestadística: Aggregates historical collected datapoints into time-based summaries (e.g. hourly, daily, monthly, yearly). Los datos se almacenan en el camino 'estadística' como JSON.",
|
|
15
|
+
"pl": "fix: kolejność przypisywania bitów poprawionych alarmów JSON\nw języku angielskim:\nstatystyki: Zagregowane historyczne zbierane punkty danych w skróty czasowe (np. godzinowe, dzienne, miesięczne, roczne). Dane są przechowywane w ścieżce \"statystyki\" jako JSON.",
|
|
16
|
+
"uk": "фіксувати: порядок відведення біту виправлено тривоги Сонце\nновий стан `inverter.x.emma.activeAlarmSN` і `inverter.x.emma.HistoricalAlarmSN` : emma тривоги [#226](https://github.com/bolliy/ioBroker.sun2000/products/226)\nстатистика: Агрегати історичних зібраних точок даних на часові суми (наприклад, час, щоденно, щомісяця, рік). Дані зберігаються на шляху `statistics` як JSON.",
|
|
17
|
+
"zh-cn": "固定: 修改提醒的位任务顺序 贾森\n新状态`inverter.x.emma.active AlarmSN ' 和`inverter.x.emma. Historical AlarmSN ' : emma警报[# 226](https://github.com/bolliy/ioBroker.sun2000/issues/226)\n统计: 将历史收集的数据点汇总为时间摘要(如小时、每日、每月、每年)。 数据作为JSON储存在路径`统计'中."
|
|
18
|
+
},
|
|
6
19
|
"2.3.7": {
|
|
7
20
|
"en": "deleted deprecated state `collected.usableSurplusPower`",
|
|
8
21
|
"de": "gelöschter deprecated state `collect.usableSurplusPower `",
|
|
@@ -80,19 +93,6 @@
|
|
|
80
93
|
"pl": "pozwala ponownie 'control.battery.chargeFromGridFunction' podczas korzystania z Emmy",
|
|
81
94
|
"uk": "дозволяє знову `control.battery.chargeЗ альбомуGridFunction` при використанні Емма",
|
|
82
95
|
"zh-cn": "允许在使用 Emma 时再次使用 \" control.battery. charge from GridFunction \" "
|
|
83
|
-
},
|
|
84
|
-
"2.3.1": {
|
|
85
|
-
"en": "fix: handle potential null values in set method of RegisterMap",
|
|
86
|
-
"de": "fix: griff potenzielle Nullwerte in der eingestellten Methode des Registrierens Landkarte",
|
|
87
|
-
"ru": "исправление: обработка потенциальных нулевых значений в установленном методе Регистра Карта",
|
|
88
|
-
"pt": "corrigir: manusear valores nulos potenciais no método definido de Registro Mapa",
|
|
89
|
-
"nl": "fix: handle potentiële nulwaarden in set methode van Register Kaart",
|
|
90
|
-
"fr": "fix: gérer les valeurs null potentielles dans la méthode définie de Register Carte",
|
|
91
|
-
"it": "fix: gestire potenziali valori null nel metodo impostato di Registrazione Mappa",
|
|
92
|
-
"es": "fijación: manejar valores nulos potenciales en el método set de Registro Mapa",
|
|
93
|
-
"pl": "fix: obsługi potencjalnych wartości null w ustawieniu metody rejestru Mapa",
|
|
94
|
-
"uk": "виправити: обробляти потенціал null значення в встановленому методі Реєстру Мапа",
|
|
95
|
-
"zh-cn": "固定值:在设定的登记方法中处理潜在的无效值 地图"
|
|
96
96
|
}
|
|
97
97
|
},
|
|
98
98
|
"titleLang": {
|
|
@@ -150,6 +150,7 @@
|
|
|
150
150
|
"compact": true,
|
|
151
151
|
"connectionType": "local",
|
|
152
152
|
"dataSource": "poll",
|
|
153
|
+
"messagebox": true,
|
|
153
154
|
"adminUI": {
|
|
154
155
|
"config": "json"
|
|
155
156
|
},
|
|
@@ -221,7 +222,7 @@
|
|
|
221
222
|
],
|
|
222
223
|
"globalDependencies": [
|
|
223
224
|
{
|
|
224
|
-
"admin": ">=7.6.
|
|
225
|
+
"admin": ">=7.6.20"
|
|
225
226
|
}
|
|
226
227
|
],
|
|
227
228
|
"plugins": {
|
|
@@ -247,6 +248,8 @@
|
|
|
247
248
|
"ms_log": false,
|
|
248
249
|
"sl_meterId": 11,
|
|
249
250
|
"ds_bu": true,
|
|
251
|
+
"ds_bp": false,
|
|
252
|
+
"cb_tou": false,
|
|
250
253
|
"integration": 0
|
|
251
254
|
},
|
|
252
255
|
"objects": [],
|
|
@@ -191,7 +191,7 @@ class DriverBase {
|
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
193
|
* Read the device list for a given modbusId.
|
|
194
|
-
* @param {
|
|
194
|
+
* @param {object} modbusClient - The modbus client to use.
|
|
195
195
|
* @param {number} [modbusId] - The modbus ID to query.
|
|
196
196
|
* @returns {Promise<[number, { [key: string]: string }]>}
|
|
197
197
|
* The first element of the array is the number of devices,
|
|
@@ -209,6 +209,7 @@ class DriverBase {
|
|
|
209
209
|
throw new Error(`readDeviceList: No answer for OID=0x${objectId.toString(16).toUpperCase()}: ${e.message}`);
|
|
210
210
|
}
|
|
211
211
|
const numDevices = parseInt(JSON.stringify(allInfo['135'] || '').replace(/[^0-9]/g, ''));
|
|
212
|
+
// @ts-expect-error - we know that the value is a string, but the type definition is incorrect
|
|
212
213
|
return [numDevices, allInfo];
|
|
213
214
|
}
|
|
214
215
|
|
|
@@ -293,7 +294,7 @@ class DriverBase {
|
|
|
293
294
|
* If the error is a connection error, it stops the update loop.
|
|
294
295
|
* Finally, it calls the _runPostUpdateHooks function to run any post update hooks and stores the states.
|
|
295
296
|
*
|
|
296
|
-
* @param {
|
|
297
|
+
* @param {object} modbusClient - The Modbus client to use for communication.
|
|
297
298
|
* @param {dataRefreshRate} refreshRate - The refresh rate to use for updating the states.
|
|
298
299
|
* @param {number} [duration] - The duration in milliseconds for which the states should be updated.
|
|
299
300
|
* @returns {Promise<number>} - A promise that resolves to the number of registers read.
|
|
@@ -784,6 +784,22 @@ class Emma extends DriverBase {
|
|
|
784
784
|
this.stateCache.set(`${path}emma.derived.systemTime`, fixTime(systemTime), { type: 'number' });
|
|
785
785
|
},
|
|
786
786
|
},
|
|
787
|
+
{
|
|
788
|
+
address: 65500,
|
|
789
|
+
length: 4,
|
|
790
|
+
info: 'Public Register Definitions',
|
|
791
|
+
refresh: dataRefreshRate.low,
|
|
792
|
+
states: [
|
|
793
|
+
{
|
|
794
|
+
state: { id: 'emma.activeAlarmSN', name: 'Active alarm SN', type: 'number', role: 'value', desc: 'reg:65500, len:2' },
|
|
795
|
+
register: { reg: 65500, type: dataType.uint32 },
|
|
796
|
+
},
|
|
797
|
+
{
|
|
798
|
+
state: { id: 'emma.HistoricalAlarmSN', name: 'Historical alarm SN', type: 'number', role: 'value', desc: 'reg:65502, len:2' },
|
|
799
|
+
register: { reg: 65502, type: dataType.uint32 },
|
|
800
|
+
},
|
|
801
|
+
],
|
|
802
|
+
},
|
|
787
803
|
];
|
|
788
804
|
|
|
789
805
|
this.registerFields.push.apply(this.registerFields, newFields);
|
|
@@ -1741,36 +1741,40 @@ class InverterSun2000 extends DriverBase {
|
|
|
1741
1741
|
//overload
|
|
1742
1742
|
get modbusAllowed() {
|
|
1743
1743
|
//if the modbus-device offline we cannot read or write anythink!
|
|
1744
|
-
let
|
|
1745
|
-
if
|
|
1744
|
+
let allowModbus = true; //allowed = true;
|
|
1745
|
+
//if integration = sDongle and only a slave inverter has not a meter.
|
|
1746
|
+
if (this.adapter.settings.integration === 0 && !this.deviceInfo.meter) {
|
|
1746
1747
|
//I am a slave inverter
|
|
1747
|
-
|
|
1748
|
-
|
|
1748
|
+
//If the master inverter has a meter, ask the master if modbus is allowed.
|
|
1749
|
+
if (this.adapter.devices[0].instance && this.adapter.devices[0].instance !== this.deviceInfo.instance) {
|
|
1750
|
+
if (this.adapter.devices[0].driverClass === driverClasses.inverter && this.adapter.devices[0].meter) {
|
|
1751
|
+
allowModbus = this.adapter.devices[0].instance.modbusAllowed; //first ask the master
|
|
1752
|
+
}
|
|
1749
1753
|
}
|
|
1750
1754
|
}
|
|
1751
|
-
if (
|
|
1755
|
+
if (allowModbus) {
|
|
1752
1756
|
//430 = SUN2000-8KTL-M2
|
|
1753
1757
|
if (this.deviceStatus === 0x0002) {
|
|
1754
|
-
if (this.deviceInfo.index > 0 && this._modelId < 430)
|
|
1758
|
+
if (this.deviceInfo.index > 0 && this._modelId < 430) allowModbus = false;
|
|
1755
1759
|
} //standby
|
|
1756
1760
|
if (this.deviceStatus >= 0x0300 && this.deviceStatus <= 0x0307) {
|
|
1757
|
-
|
|
1761
|
+
allowModbus = false;
|
|
1758
1762
|
} //shutdown
|
|
1759
1763
|
if (this._errorCount > 3) {
|
|
1760
|
-
|
|
1764
|
+
allowModbus = false;
|
|
1761
1765
|
}
|
|
1762
1766
|
}
|
|
1763
1767
|
|
|
1764
|
-
if (!
|
|
1768
|
+
if (!allowModbus && !this.log.quiet) {
|
|
1765
1769
|
this.log.info(`The inverter with modbus ID ${this._modbusId} is no longer accessible. That is why the logs are minimized.`);
|
|
1766
1770
|
this.log.beQuiet(true);
|
|
1767
1771
|
}
|
|
1768
|
-
if (
|
|
1772
|
+
if (allowModbus && this.log.quiet) {
|
|
1769
1773
|
this.log.beQuiet(false);
|
|
1770
1774
|
this.log.info(`The inverter with modbus ID ${this._modbusId} is accessible again.`);
|
|
1771
1775
|
//this._errorCount = 0;
|
|
1772
1776
|
}
|
|
1773
|
-
this._modbusAllowed =
|
|
1777
|
+
this._modbusAllowed = allowModbus;
|
|
1774
1778
|
return this._modbusAllowed;
|
|
1775
1779
|
}
|
|
1776
1780
|
|
package/lib/register.js
CHANGED
|
@@ -4,6 +4,7 @@ const { deviceType, driverClasses, dataRefreshRate } = require(`${__dirname}/typ
|
|
|
4
4
|
const { RiemannSum, StateMap } = require(`${__dirname}/tools.js`);
|
|
5
5
|
const getDriverHandler = require(`${__dirname}/drivers/index.js`);
|
|
6
6
|
const tools = require(`${__dirname}/tools.js`);
|
|
7
|
+
const statistics = require(`${__dirname}/statistics.js`);
|
|
7
8
|
|
|
8
9
|
class Registers {
|
|
9
10
|
constructor(adapterInstance) {
|
|
@@ -11,6 +12,7 @@ class Registers {
|
|
|
11
12
|
this.stateCache = new StateMap();
|
|
12
13
|
|
|
13
14
|
this.externalSum = new RiemannSum();
|
|
15
|
+
this.statistics = new statistics(adapterInstance, this.stateCache);
|
|
14
16
|
|
|
15
17
|
for (const device of this.adapter.devices) {
|
|
16
18
|
//DriverInfo Instance or Sdongle
|
|
@@ -19,7 +21,8 @@ class Registers {
|
|
|
19
21
|
device.instance = new handler(this, device);
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
|
-
|
|
24
|
+
|
|
25
|
+
//Upgrade to v2.3.7 - deleted deprecated states
|
|
23
26
|
if (
|
|
24
27
|
tools.existsState(this.adapter, `collected.usableSurplusPower`, (err, exists) => {
|
|
25
28
|
if (!err && exists) {
|
|
@@ -32,8 +35,8 @@ class Registers {
|
|
|
32
35
|
})
|
|
33
36
|
);
|
|
34
37
|
|
|
35
|
-
this.postProcessHooks = [];
|
|
36
|
-
this.
|
|
38
|
+
//this.postProcessHooks = [];
|
|
39
|
+
this.postProcessHooks = [
|
|
37
40
|
{
|
|
38
41
|
refresh: dataRefreshRate.high,
|
|
39
42
|
states: [
|
|
@@ -56,16 +59,6 @@ class Registers {
|
|
|
56
59
|
unit: 'kW',
|
|
57
60
|
role: 'value.power',
|
|
58
61
|
},
|
|
59
|
-
/*
|
|
60
|
-
{
|
|
61
|
-
id: 'collected.usableSurplusPower',
|
|
62
|
-
name: 'usable surplus power',
|
|
63
|
-
type: 'number',
|
|
64
|
-
unit: 'kW',
|
|
65
|
-
role: 'value.power',
|
|
66
|
-
desc: 'depreciated: Please use collected.surplus.usablePower instead',
|
|
67
|
-
},
|
|
68
|
-
*/
|
|
69
62
|
{
|
|
70
63
|
id: 'collected.surplus.power',
|
|
71
64
|
name: 'surplus power',
|
|
@@ -190,18 +183,12 @@ class Registers {
|
|
|
190
183
|
this.stateCache.set('collected.activePower', actPower, { type: 'number', renew: true });
|
|
191
184
|
this.stateCache.set('collected.houseConsumption', houseConsum, { type: 'number' });
|
|
192
185
|
this.stateCache.set('collected.chargeDischargePower', chargeDischarge, { type: 'number' });
|
|
193
|
-
/*
|
|
194
|
-
this.stateCache.set('collected.usableSurplusPower', surplusArray[1], {
|
|
195
|
-
type: 'number',
|
|
196
|
-
});
|
|
197
|
-
*/
|
|
198
186
|
this.stateCache.set('collected.surplus.power', surplusArray[0], {
|
|
199
187
|
type: 'number',
|
|
200
188
|
});
|
|
201
189
|
this.stateCache.set('collected.surplus.usablePower', surplusArray[1], {
|
|
202
190
|
type: 'number',
|
|
203
191
|
});
|
|
204
|
-
|
|
205
192
|
this.stateCache.set('collected.externalPower', extPower, { type: 'number' });
|
|
206
193
|
},
|
|
207
194
|
},
|
|
@@ -329,7 +316,17 @@ class Registers {
|
|
|
329
316
|
feedinEnergy = this.stateCache.get('meter.positiveActiveEnergy')?.value ?? 0;
|
|
330
317
|
supplyFromGrid = this.stateCache.get('meter.reverseActiveEnergy')?.value ?? 0;
|
|
331
318
|
}
|
|
332
|
-
//
|
|
319
|
+
//NEU - Initialisierung
|
|
320
|
+
const gridExportStart = this.stateCache.get('collected.gridExportStart')?.value;
|
|
321
|
+
if (!gridExportStart && feedinEnergy !== 0) {
|
|
322
|
+
this.stateCache.set('collected.gridExportStart', feedinEnergy, { type: 'number' });
|
|
323
|
+
}
|
|
324
|
+
const gridImportStart = this.stateCache.get('collected.gridImportStart')?.value;
|
|
325
|
+
if (!gridImportStart && supplyFromGrid !== 0) {
|
|
326
|
+
this.stateCache.set('collected.gridImportStart', supplyFromGrid, { type: 'number' });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
//stimmt wahrscheinlich nicht genau - bleibt aber erstmal bestehen
|
|
333
330
|
const conSum = enYield + supplyFromGrid - feedinEnergy;
|
|
334
331
|
this.stateCache.set('collected.consumptionSum', conSum, { type: 'number' });
|
|
335
332
|
// compute export and import today
|
|
@@ -360,7 +357,9 @@ class Registers {
|
|
|
360
357
|
];
|
|
361
358
|
|
|
362
359
|
//only Inverter
|
|
363
|
-
this.postProcessHooks.push.apply(this.postProcessHooks, this.inverterPostProcessHooks);
|
|
360
|
+
//this.postProcessHooks.push.apply(this.postProcessHooks, this.inverterPostProcessHooks);
|
|
361
|
+
this.postProcessHooks.push.apply(this.postProcessHooks, this.statistics.processHooks);
|
|
362
|
+
|
|
364
363
|
this._loadStates();
|
|
365
364
|
}
|
|
366
365
|
|
|
@@ -380,6 +379,17 @@ class Registers {
|
|
|
380
379
|
},
|
|
381
380
|
native: {},
|
|
382
381
|
});
|
|
382
|
+
|
|
383
|
+
if (state.initVal) {
|
|
384
|
+
const ret = await this.adapter.getState(state.id);
|
|
385
|
+
if (!ret || ret.val === null) {
|
|
386
|
+
try {
|
|
387
|
+
await this.adapter.setState(state.id, { val: state.initVal, ack: true });
|
|
388
|
+
} catch (err) {
|
|
389
|
+
this.adapter.log.warn(`Error while initializing ${state.id}, val=${state.initVal} err=${err.message}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
383
393
|
}
|
|
384
394
|
|
|
385
395
|
async storeStates() {
|
|
@@ -411,7 +421,7 @@ class Registers {
|
|
|
411
421
|
* its states. Logs an error if no device instance has been initialized.
|
|
412
422
|
*
|
|
413
423
|
* @param {object} device - The device object containing the instance and driverClass.
|
|
414
|
-
* @param {
|
|
424
|
+
* @param {object} modbusClient - The Modbus client used for communication.
|
|
415
425
|
* @param {string} refreshRate - The rate at which data should be refreshed.
|
|
416
426
|
* @param {number} duration - The duration for which the states should be updated.
|
|
417
427
|
* @returns {Promise<number>} - Returns a promise that resolves to the number of states updated.
|
|
@@ -449,7 +459,7 @@ class Registers {
|
|
|
449
459
|
}
|
|
450
460
|
}
|
|
451
461
|
hook.initState = true;
|
|
452
|
-
hook.fn(this.adapter.devices);
|
|
462
|
+
hook.fn && hook.fn(this.adapter.devices);
|
|
453
463
|
}
|
|
454
464
|
}
|
|
455
465
|
this.storeStates(); //fire and forget
|
|
@@ -459,6 +469,7 @@ class Registers {
|
|
|
459
469
|
async _loadStates() {
|
|
460
470
|
let state = await this.adapter.getState('collected.gridExportStart');
|
|
461
471
|
this.stateCache.set('collected.gridExportStart', state?.val, { type: 'number', stored: true });
|
|
472
|
+
this.stateCache.set('collected.gridImportStart', state?.val, { type: 'number', stored: true });
|
|
462
473
|
state = await this.adapter.getState('collected.gridImportStart');
|
|
463
474
|
this.stateCache.set('collected.gridImportStart', state?.val, { type: 'number', stored: true });
|
|
464
475
|
state = await this.adapter.getState('collected.consumptionStart');
|
|
@@ -525,6 +536,7 @@ class Registers {
|
|
|
525
536
|
// one minute before midnight - perform housekeeping actions
|
|
526
537
|
//state
|
|
527
538
|
async mitnightProcess() {
|
|
539
|
+
this.statistics.mitNightProcess();
|
|
528
540
|
// copy current export/import kWh - used to compute daily import/export in kWh
|
|
529
541
|
const sign = this.stateCache.get('meter.derived.signConventionForPowerFeed-in')?.value ?? 1;
|
|
530
542
|
if (sign === -1) {
|
|
@@ -543,7 +555,7 @@ class Registers {
|
|
|
543
555
|
await device.instance.mitnightProcess();
|
|
544
556
|
}
|
|
545
557
|
}
|
|
546
|
-
this.storeStates();
|
|
558
|
+
this.storeStates();
|
|
547
559
|
}
|
|
548
560
|
}
|
|
549
561
|
|
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
statistics.js
|
|
3
|
+
|
|
4
|
+
This module prepares statistical data based on historical datapoints from the
|
|
5
|
+
Huawei SUN2000 inverter states. It aggregates raw values into structured
|
|
6
|
+
time-based datasets (e.g., hourly, daily, monthly, yearly) that can be used
|
|
7
|
+
for further analysis or visualization.
|
|
8
|
+
|
|
9
|
+
The goal of this processing layer is to provide normalized statistical data
|
|
10
|
+
independent from the raw state history.
|
|
11
|
+
|
|
12
|
+
In the mid-term, these statistics are intended to be visualized graphically
|
|
13
|
+
in ioBroker VIS using the ioBroker.flexcharts adapter.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
'use strict';
|
|
17
|
+
|
|
18
|
+
const { dataRefreshRate, statisticsType } = require(`${__dirname}/types.js`);
|
|
19
|
+
const tools = require(`${__dirname}/tools.js`);
|
|
20
|
+
|
|
21
|
+
class statistics {
|
|
22
|
+
constructor(adapterInstance, stateCache) {
|
|
23
|
+
this.adapter = adapterInstance;
|
|
24
|
+
this.stateCache = stateCache;
|
|
25
|
+
this.taskTimer = null;
|
|
26
|
+
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
|
+
|
|
37
|
+
this.stats = [
|
|
38
|
+
{
|
|
39
|
+
sourceId: 'collected.consumptionToday',
|
|
40
|
+
targetPath: 'consumption',
|
|
41
|
+
unit: 'kWh',
|
|
42
|
+
type: statisticsType.deltaReset, // value is a total that resets at the start of the period, so we need to calculate the delta to get the actual consumption for the period
|
|
43
|
+
},
|
|
44
|
+
/*
|
|
45
|
+
{
|
|
46
|
+
sourceId: 'collected.consumptionSum',
|
|
47
|
+
targetPath: 'consumptionSum',
|
|
48
|
+
unit: 'kWh',
|
|
49
|
+
type: statisticsType.delta,
|
|
50
|
+
},
|
|
51
|
+
*/
|
|
52
|
+
{
|
|
53
|
+
sourceId: 'collected.dailySolarYield',
|
|
54
|
+
targetPath: 'solarYield',
|
|
55
|
+
unit: 'kWh',
|
|
56
|
+
type: statisticsType.deltaReset,
|
|
57
|
+
},
|
|
58
|
+
{ sourceId: 'collected.dailyInputYield', targetPath: 'inputYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
59
|
+
{
|
|
60
|
+
sourceId: 'collected.dailyExternalYield',
|
|
61
|
+
targetPath: 'externalYield',
|
|
62
|
+
unit: 'kWh',
|
|
63
|
+
type: statisticsType.deltaReset,
|
|
64
|
+
},
|
|
65
|
+
{ sourceId: 'collected.dailyEnergyYield', targetPath: 'energyYield', unit: 'kWh', type: statisticsType.deltaReset },
|
|
66
|
+
{
|
|
67
|
+
sourceId: 'collected.SOC',
|
|
68
|
+
targetPath: 'SOC',
|
|
69
|
+
unit: '%',
|
|
70
|
+
type: statisticsType.level, // value is a level that can go up and down, so we take the value as is without calculating delta
|
|
71
|
+
},
|
|
72
|
+
{ sourceId: 'collected.currentDayChargeCapacity', targetPath: 'chargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
|
|
73
|
+
{ sourceId: 'collected.currentDayDischargeCapacity', targetPath: 'dischargeCapacity', unit: 'kWh', type: statisticsType.deltaReset },
|
|
74
|
+
{ sourceId: 'collected.gridExportToday', targetPath: 'gridExport', unit: 'kWh', type: statisticsType.deltaReset },
|
|
75
|
+
{ sourceId: 'collected.gridImportToday', targetPath: 'gridImport', unit: 'kWh', type: statisticsType.deltaReset },
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
this.postProcessHooks = [
|
|
79
|
+
{
|
|
80
|
+
refresh: dataRefreshRate.low,
|
|
81
|
+
states: [
|
|
82
|
+
{
|
|
83
|
+
id: 'statistics.jsonHourly',
|
|
84
|
+
name: 'Hourly consumption JSON',
|
|
85
|
+
type: 'string',
|
|
86
|
+
role: 'json',
|
|
87
|
+
desc: 'Hourly consumption for last and current day per full hour',
|
|
88
|
+
initVal: '[]',
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
id: 'statistics.jsonDaily',
|
|
92
|
+
name: 'Daily consumption JSON',
|
|
93
|
+
type: 'string',
|
|
94
|
+
role: 'json',
|
|
95
|
+
desc: 'Daily consumption for current month per day',
|
|
96
|
+
initVal: '[]',
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'statistics.jsonWeekly',
|
|
100
|
+
name: 'Weekly consumption JSON',
|
|
101
|
+
type: 'string',
|
|
102
|
+
role: 'json',
|
|
103
|
+
desc: 'Weekly consumption for current year per week',
|
|
104
|
+
initVal: '[]',
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'statistics.jsonMonthly',
|
|
108
|
+
name: 'Monthly consumption JSON',
|
|
109
|
+
type: 'string',
|
|
110
|
+
role: 'json',
|
|
111
|
+
desc: 'Monthly consumption for current year per month',
|
|
112
|
+
initVal: '[]',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'statistics.jsonAnnual',
|
|
116
|
+
name: 'Annual consumption JSON',
|
|
117
|
+
type: 'string',
|
|
118
|
+
role: 'json',
|
|
119
|
+
desc: 'Annual consumption per year',
|
|
120
|
+
initVal: '[]',
|
|
121
|
+
},
|
|
122
|
+
// a state where users may store a Flexcharts/eCharts options template
|
|
123
|
+
/*
|
|
124
|
+
{
|
|
125
|
+
id: 'statistics.flexChartTemplate',
|
|
126
|
+
name: 'Flexcharts template',
|
|
127
|
+
type: 'string',
|
|
128
|
+
role: 'json',
|
|
129
|
+
desc: 'Optional eCharts option object that will be merged into the default chart. Leave empty for the built‑in layout.',
|
|
130
|
+
initVal: '{}',
|
|
131
|
+
},
|
|
132
|
+
*/
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
];
|
|
136
|
+
this.initialize();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get processHooks() {
|
|
140
|
+
return this.postProcessHooks;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Helper function to format date as ISO string with timezone offset.
|
|
145
|
+
*
|
|
146
|
+
* @param {Date} d - The date to format
|
|
147
|
+
* @returns {string} ISO formatted date string with timezone offset
|
|
148
|
+
*/
|
|
149
|
+
_localIsoWithOffset(d) {
|
|
150
|
+
const pad = n => String(n).padStart(2, '0');
|
|
151
|
+
const year = d.getFullYear();
|
|
152
|
+
const month = pad(d.getMonth() + 1);
|
|
153
|
+
const day = pad(d.getDate());
|
|
154
|
+
const hours = pad(d.getHours());
|
|
155
|
+
const minutes = pad(d.getMinutes());
|
|
156
|
+
const seconds = pad(d.getSeconds());
|
|
157
|
+
const tzOffset = -d.getTimezoneOffset();
|
|
158
|
+
const sign = tzOffset >= 0 ? '+' : '-';
|
|
159
|
+
const absMin = Math.abs(tzOffset);
|
|
160
|
+
const offH = pad(Math.floor(absMin / 60));
|
|
161
|
+
const offM = pad(absMin % 60);
|
|
162
|
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.000${sign}${offH}:${offM}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Generic function to calculate consumption statistics for different time periods.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} stateId - The state ID for storing the JSON
|
|
169
|
+
* @param {string} consumptionKey - The state key for consumption value
|
|
170
|
+
* @param {Date} periodStart - The start of the current period
|
|
171
|
+
* @param {string} periodType - The type of period (hourly, daily, weekly, monthly, annual)
|
|
172
|
+
* @returns {Promise<void>}
|
|
173
|
+
*/
|
|
174
|
+
|
|
175
|
+
async _calculateGeneric(stateId, periodStart, periodEnde) {
|
|
176
|
+
const toStr = this._localIsoWithOffset(periodEnde);
|
|
177
|
+
let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
|
|
178
|
+
let arr = [];
|
|
179
|
+
try {
|
|
180
|
+
arr = JSON.parse(jsonStr);
|
|
181
|
+
if (!Array.isArray(arr)) arr = [];
|
|
182
|
+
} catch {
|
|
183
|
+
arr = [];
|
|
184
|
+
}
|
|
185
|
+
let fromDate = periodStart;
|
|
186
|
+
let last = {};
|
|
187
|
+
if (arr.length > 0) {
|
|
188
|
+
last = arr[arr.length - 1];
|
|
189
|
+
// avoid duplicates
|
|
190
|
+
if (last.to === toStr) return false;
|
|
191
|
+
const lastToDate = new Date(last.to);
|
|
192
|
+
const toDate = new Date(toStr);
|
|
193
|
+
if (lastToDate >= periodStart || toDate <= periodStart) {
|
|
194
|
+
fromDate = lastToDate;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const entry = {
|
|
199
|
+
from: this._localIsoWithOffset(fromDate),
|
|
200
|
+
to: toStr,
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
for (const stat of this.stats) {
|
|
204
|
+
const source = this.stateCache.get(stat.sourceId)?.value;
|
|
205
|
+
if (source === null || source === undefined) {
|
|
206
|
+
this.adapter.logger.warn(`Source state ${stat.sourceId} not found statistic hook`);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
let value = Number(source);
|
|
210
|
+
if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
|
|
211
|
+
const lastTotal = Number(last[stat.targetPath]?.['total'] ?? 0);
|
|
212
|
+
if (stat.type === statisticsType.deltaReset) {
|
|
213
|
+
//if (value >= lastTotal * 0.5) {
|
|
214
|
+
if (fromDate.getTime() !== periodStart.getTime()) {
|
|
215
|
+
// Delta-Berechnung
|
|
216
|
+
value -= lastTotal;
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
// Ein lastTotal-Wert vorhanden –> normale Delta-Berechnung
|
|
220
|
+
if (last[stat.targetPath]?.['total'] === undefined) {
|
|
221
|
+
// Kein lastTotal-Wert vorhanden –> wahrscheinlich erster Eintrag, Delta-Berechnung nicht möglich
|
|
222
|
+
this.adapter.logger.debug(`No total value found for ${stat.targetPath} in last entry, setting delta to 0`);
|
|
223
|
+
value = 0;
|
|
224
|
+
} else {
|
|
225
|
+
// Delta-Berechnung
|
|
226
|
+
value -= lastTotal;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
value = Math.round((Number(value) + Number.EPSILON) * 1000) / 1000;
|
|
231
|
+
entry[stat.targetPath] = {
|
|
232
|
+
value: Number(value.toFixed(3)),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
if (stat.type === statisticsType.delta || stat.type === statisticsType.deltaReset) {
|
|
236
|
+
entry[stat.targetPath].total = Number(source.toFixed(3));
|
|
237
|
+
}
|
|
238
|
+
entry[stat.targetPath].unit = stat.unit || 'kWh'; // can be extended for other stats with different units
|
|
239
|
+
}
|
|
240
|
+
arr.push(entry);
|
|
241
|
+
|
|
242
|
+
arr.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
|
|
243
|
+
|
|
244
|
+
this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
|
|
245
|
+
this.adapter.logger.debug(`Appended ${stateId} statistic ${toStr}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
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) {
|
|
271
|
+
let jsonStr = this.stateCache.get(stateId)?.value ?? '[]';
|
|
272
|
+
let arr = [];
|
|
273
|
+
try {
|
|
274
|
+
arr = JSON.parse(jsonStr);
|
|
275
|
+
if (!Array.isArray(arr)) arr = [];
|
|
276
|
+
} catch {
|
|
277
|
+
arr = [];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Keep only entries within the window
|
|
281
|
+
arr = arr.filter(item => {
|
|
282
|
+
const ts = Date.parse(item.from);
|
|
283
|
+
return !Number.isNaN(ts) && ts >= periodStart.getTime();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
this.stateCache.set(stateId, JSON.stringify(arr), { type: 'string' });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Calculates and aggregates consumption statistics based on the given parameters.
|
|
291
|
+
*
|
|
292
|
+
* This function calculates and aggregates the consumption statistics based on the source entries within a specific window.
|
|
293
|
+
* It retrieves the source entries, filters them based on the window, calculates the sum of consumption, and appends the result to the target array.
|
|
294
|
+
*
|
|
295
|
+
* @param {string} sourceStateId - The ID of the source state to retrieve entries from.
|
|
296
|
+
* @param {string} targetStateId - The ID of the target state to append the aggregated result.
|
|
297
|
+
* @param {Function} getWindow - A function that returns the start and end date of the window based on the current date.
|
|
298
|
+
* @param {string} periodType - The type of period for which the aggregation is performed.
|
|
299
|
+
* @returns {void}
|
|
300
|
+
*/
|
|
301
|
+
async _calculateAggregation(sourceStateId, targetStateId, getWindow, periodType) {
|
|
302
|
+
try {
|
|
303
|
+
const now = new Date();
|
|
304
|
+
const window = getWindow(now);
|
|
305
|
+
const fromDate = window.from;
|
|
306
|
+
const toDate = window.to;
|
|
307
|
+
if (now < toDate) {
|
|
308
|
+
this.adapter.logger.debug(`statistics.js: Skipping ${periodType} aggregation because current time is before end of aggregation window`);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const toStr = this._localIsoWithOffset(toDate);
|
|
312
|
+
|
|
313
|
+
// Load target array
|
|
314
|
+
let jsonTarget = this.stateCache.get(targetStateId)?.value ?? '[]';
|
|
315
|
+
let targetArray = [];
|
|
316
|
+
try {
|
|
317
|
+
targetArray = JSON.parse(jsonTarget);
|
|
318
|
+
if (!Array.isArray(targetArray)) targetArray = [];
|
|
319
|
+
} catch {
|
|
320
|
+
targetArray = [];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Avoid duplicates
|
|
324
|
+
const last = targetArray.length > 0 ? targetArray[targetArray.length - 1] : {};
|
|
325
|
+
if (last.to === toStr) return;
|
|
326
|
+
|
|
327
|
+
const target = {
|
|
328
|
+
from: this._localIsoWithOffset(fromDate),
|
|
329
|
+
to: toStr,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Load source entries
|
|
333
|
+
let jsonStr = this.stateCache.get(sourceStateId)?.value ?? '[]';
|
|
334
|
+
let sourceEntries = [];
|
|
335
|
+
try {
|
|
336
|
+
sourceEntries = JSON.parse(jsonStr);
|
|
337
|
+
if (!Array.isArray(sourceEntries)) sourceEntries = [];
|
|
338
|
+
} catch {
|
|
339
|
+
sourceEntries = [];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Keep only entries within the window
|
|
343
|
+
sourceEntries = sourceEntries.filter(item => {
|
|
344
|
+
const ts = Date.parse(item.from);
|
|
345
|
+
return !Number.isNaN(ts) && ts >= fromDate.getTime() && ts < toDate.getTime();
|
|
346
|
+
});
|
|
347
|
+
// If there are no source entries, we can skip the aggregation and avoid creating empty entries in the target array
|
|
348
|
+
if (sourceEntries.length > 0) {
|
|
349
|
+
this.adapter.logger.debug(
|
|
350
|
+
`statistics.js: Found ${sourceEntries.length} source entries for ${periodType} aggregation between ${fromDate.toISOString()} and ${toDate.toISOString()}`,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
for (const stat of this.stats) {
|
|
354
|
+
// Sum consumption for the window
|
|
355
|
+
if (stat.type === statisticsType.level) continue; // Skip level statistics
|
|
356
|
+
|
|
357
|
+
let sum = 0;
|
|
358
|
+
/*
|
|
359
|
+
if (stat.type === statisticsType.average) {
|
|
360
|
+
stat.sum = sourceEntries.length > 0 ? sourceEntries[sourceEntries.length - 1]?.[stat.targetPath]?.['total'] : 0;
|
|
361
|
+
} else {
|
|
362
|
+
*/
|
|
363
|
+
try {
|
|
364
|
+
sourceEntries.forEach(entry => {
|
|
365
|
+
sum += Number(entry[stat.targetPath]?.['value'] ?? 0);
|
|
366
|
+
});
|
|
367
|
+
} catch (e) {
|
|
368
|
+
this.adapter.logger.warn(`statistics.js: Error during ${periodType} statistic aggregation: ${e.message}`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
sum = Math.round((Number(sum) + Number.EPSILON) * 1000) / 1000;
|
|
372
|
+
|
|
373
|
+
target[stat.targetPath] = {
|
|
374
|
+
value: Number(sum.toFixed(3)),
|
|
375
|
+
unit: stat.unit || 'kWh',
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
targetArray.push(target);
|
|
380
|
+
}
|
|
381
|
+
|
|
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
|
+
targetArray.sort((a, b) => Date.parse(a.to) - Date.parse(b.to));
|
|
392
|
+
this.stateCache.set(targetStateId, JSON.stringify(targetArray), { type: 'string' });
|
|
393
|
+
this.adapter.logger.debug(`Appended ${periodType} statistic ${toStr} `);
|
|
394
|
+
return true;
|
|
395
|
+
} catch (err) {
|
|
396
|
+
this.adapter.logger.warn(`Error during ${periodType} aggregation: ${err.message}`);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Calculates and updates daily consumption statistics from hourly data.
|
|
402
|
+
*
|
|
403
|
+
* @returns {void}
|
|
404
|
+
*/
|
|
405
|
+
async _calculateDaily() {
|
|
406
|
+
this.adapter.log.debug('### Daily execution triggered ###');
|
|
407
|
+
this._calculateAggregation(
|
|
408
|
+
'statistics.jsonHourly',
|
|
409
|
+
'statistics.jsonDaily',
|
|
410
|
+
now => {
|
|
411
|
+
// aggregation window: previous day (day that just ended)
|
|
412
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
|
|
413
|
+
const yesterday = new Date(today);
|
|
414
|
+
yesterday.setDate(today.getDate() - 1);
|
|
415
|
+
return { from: yesterday, to: today };
|
|
416
|
+
},
|
|
417
|
+
'daily',
|
|
418
|
+
) && (this.lastExecution.daily = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple days at startup
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Calculates and updates weekly consumption statistics from daily data.
|
|
423
|
+
*
|
|
424
|
+
* @returns {void}
|
|
425
|
+
*/
|
|
426
|
+
async _calculateWeekly() {
|
|
427
|
+
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
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Calculates and updates monthly consumption statistics from daily data.
|
|
446
|
+
*
|
|
447
|
+
* @returns {void}
|
|
448
|
+
*/
|
|
449
|
+
async _calculateMonthly() {
|
|
450
|
+
this.adapter.log.debug('### Monthly execution triggered ###');
|
|
451
|
+
this._calculateAggregation(
|
|
452
|
+
'statistics.jsonDaily',
|
|
453
|
+
'statistics.jsonMonthly',
|
|
454
|
+
now => {
|
|
455
|
+
// aggregation windowStart: previous month (month that just ended)
|
|
456
|
+
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
|
|
457
|
+
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1, 0, 0, 0, 0);
|
|
458
|
+
return { from: prevMonth, to: thisMonth };
|
|
459
|
+
},
|
|
460
|
+
'monthly',
|
|
461
|
+
) && (this.lastExecution.monthly = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple months at startup
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Calculates and updates annual consumption statistics from daily data.
|
|
466
|
+
*
|
|
467
|
+
* @returns {void}
|
|
468
|
+
*/
|
|
469
|
+
async _calculateAnnual() {
|
|
470
|
+
this.adapter.log.debug('### Annual execution triggered ###');
|
|
471
|
+
this._calculateAggregation(
|
|
472
|
+
'statistics.jsonDaily',
|
|
473
|
+
'statistics.jsonAnnual',
|
|
474
|
+
now => {
|
|
475
|
+
// aggregation window: previous year (year that just ended)
|
|
476
|
+
const thisYear = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0);
|
|
477
|
+
const prevYear = new Date(now.getFullYear() - 1, 0, 1, 0, 0, 0, 0);
|
|
478
|
+
return { from: prevYear, to: thisYear };
|
|
479
|
+
},
|
|
480
|
+
'annual',
|
|
481
|
+
) && (this.lastExecution.annual = new Date()); // only update last execution time if aggregation was performed to avoid backfilling multiple years at startup
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Initialize and schedule the unified task manager.
|
|
486
|
+
* This task runs every minute and checks which statistics need to be calculated.
|
|
487
|
+
*/
|
|
488
|
+
async _initializeTask() {
|
|
489
|
+
const scheduleNextRun = () => {
|
|
490
|
+
const now = new Date();
|
|
491
|
+
const next = new Date(now);
|
|
492
|
+
if (this.testing) {
|
|
493
|
+
next.setMinutes(now.getMinutes() + 1, 0, 0); //every minute
|
|
494
|
+
} else {
|
|
495
|
+
next.setHours(next.getHours() + 1, 0, 0, 0); //every hour
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Skip executing tasks exactly at midnight to avoid running aggregated jobs
|
|
499
|
+
if (next.getHours() === 0 && next.getMinutes() === 0) {
|
|
500
|
+
next.setHours(next.getHours() + 1, 0, 0, 0); //every hour
|
|
501
|
+
}
|
|
502
|
+
const msToNextHour = next.getTime() - now.getTime();
|
|
503
|
+
|
|
504
|
+
if (this.taskTimer) {
|
|
505
|
+
this.adapter.clearTimeout(this.taskTimer);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
this.taskTimer = this.adapter.setTimeout(async () => {
|
|
509
|
+
await this._executeScheduledTasks();
|
|
510
|
+
scheduleNextRun(); // reschedule for next hour
|
|
511
|
+
}, msToNextHour);
|
|
512
|
+
};
|
|
513
|
+
//await this._executeScheduledTasks(); // execute immediately on startup to catch up on any missed runs while the adapter was not running
|
|
514
|
+
// Schedule the next run
|
|
515
|
+
scheduleNextRun();
|
|
516
|
+
}
|
|
517
|
+
async _executeScheduledTasks() {
|
|
518
|
+
this._calculateHourly();
|
|
519
|
+
this._calculateDaily();
|
|
520
|
+
this._calculateWeekly();
|
|
521
|
+
this._calculateMonthly();
|
|
522
|
+
this._calculateAnnual();
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Executes every midnight and performs the following tasks:
|
|
527
|
+
* - Execute all scheduled tasks to ensure that statistics are up to date.
|
|
528
|
+
* - Clear old data based on retention policies.
|
|
529
|
+
*/
|
|
530
|
+
async mitNightProcess() {
|
|
531
|
+
const now = new Date();
|
|
532
|
+
await this._executeScheduledTasks();
|
|
533
|
+
// Clear old data based on retention policies
|
|
534
|
+
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));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async initialize() {
|
|
542
|
+
// load consumption JSON states (keep as string)
|
|
543
|
+
let state = await this.adapter.getState('statistics.jsonHourly');
|
|
544
|
+
this.stateCache.set('statistics.jsonHourly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
545
|
+
|
|
546
|
+
state = await this.adapter.getState('statistics.jsonDaily');
|
|
547
|
+
this.stateCache.set('statistics.jsonDaily', state?.val ?? '[]', { type: 'string', stored: true });
|
|
548
|
+
|
|
549
|
+
state = await this.adapter.getState('statistics.jsonWeekly');
|
|
550
|
+
this.stateCache.set('statistics.jsonWeekly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
551
|
+
|
|
552
|
+
state = await this.adapter.getState('statistics.jsonMonthly');
|
|
553
|
+
this.stateCache.set('statistics.jsonMonthly', state?.val ?? '[]', { type: 'string', stored: true });
|
|
554
|
+
|
|
555
|
+
state = await this.adapter.getState('statistics.jsonAnnual');
|
|
556
|
+
this.stateCache.set('statistics.jsonAnnual', state?.val ?? '[]', { type: 'string', stored: true });
|
|
557
|
+
|
|
558
|
+
// load optional flexchart template
|
|
559
|
+
/*
|
|
560
|
+
state = await this.adapter.getState('statistics.flexChartTemplate');
|
|
561
|
+
this.stateCache.set('statistics.flexChartTemplate', state?.val ?? '{}', { type: 'string', stored: true });
|
|
562
|
+
*/
|
|
563
|
+
// wait until consumptionToday and so on is available to avoid running the task before the initial state is loaded
|
|
564
|
+
/*
|
|
565
|
+
await tools.waitForValue(() => this.stateCache.get('collected.consumptionToday')?.value, 60000);
|
|
566
|
+
await tools.waitForValue(() => this.stateCache.get('collected.dailySolarYield')?.value, 60000);
|
|
567
|
+
await tools.waitForValue(() => this.stateCache.get('collected.SOC')?.value, 60000);
|
|
568
|
+
*/
|
|
569
|
+
await tools.waitForValue(() => this.stateCache.get('collected.accumulatedEnergyYield')?.value, 60000);
|
|
570
|
+
this.mitNightProcess(); // execute once on startup to catch up on any missed runs while the adapter was not running
|
|
571
|
+
this._initializeTask();
|
|
572
|
+
}
|
|
573
|
+
|
|
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) {
|
|
582
|
+
const IDS = {
|
|
583
|
+
hourly: 'statistics.jsonHourly',
|
|
584
|
+
daily: 'statistics.jsonDaily',
|
|
585
|
+
weekly: 'statistics.jsonWeekly',
|
|
586
|
+
monthly: 'statistics.jsonMonthly',
|
|
587
|
+
annual: 'statistics.jsonAnnual',
|
|
588
|
+
};
|
|
589
|
+
const id = IDS[myChart] || IDS.hourly;
|
|
590
|
+
let data = [];
|
|
591
|
+
try {
|
|
592
|
+
data = JSON.parse(this.stateCache.get(id)?.value ?? '[]');
|
|
593
|
+
} catch {
|
|
594
|
+
data = [];
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// default chart configuration (based on flexcharts discussion example)
|
|
598
|
+
const chart = {
|
|
599
|
+
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
|
600
|
+
legend: { show: true, orient: 'horizontal', left: 'center', top: 25 },
|
|
601
|
+
title: { left: 'center', text: 'Statistics ' },
|
|
602
|
+
grid: { right: '20%' },
|
|
603
|
+
toolbox: { feature: { dataView: { show: true, readOnly: false }, restore: { show: true }, saveAsImage: { show: true } } },
|
|
604
|
+
xAxis: [{ type: 'category', axisTick: { alignWithLabel: true }, data: [] }],
|
|
605
|
+
yAxis: [{ type: 'value', position: 'left', alignTicks: true, axisLine: { show: true }, axisLabel: { formatter: '{value}' } }],
|
|
606
|
+
series: [],
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
// merge with user-provided template if available
|
|
610
|
+
const templateStr = this.stateCache.get('statistics.flexChartTemplate')?.value;
|
|
611
|
+
if (templateStr) {
|
|
612
|
+
try {
|
|
613
|
+
const templ = JSON.parse(templateStr);
|
|
614
|
+
for (const key of Object.keys(templ)) {
|
|
615
|
+
if (chart[key] && typeof chart[key] === 'object' && typeof templ[key] === 'object') {
|
|
616
|
+
// merge sub-objects shallowly
|
|
617
|
+
Object.assign(chart[key], templ[key]);
|
|
618
|
+
} else {
|
|
619
|
+
chart[key] = templ[key];
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
} catch (e) {
|
|
623
|
+
this.adapter.logger.warn(`statistics: invalid flexChartTemplate JSON: ${e.message}`);
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// fill data arrays and collect units from stats definitions
|
|
628
|
+
const xAxis = [];
|
|
629
|
+
const seriesData = {};
|
|
630
|
+
const unitMap = {}; // targetPath -> unit string
|
|
631
|
+
|
|
632
|
+
for (const stat of this.stats) {
|
|
633
|
+
unitMap[stat.targetPath] = stat.unit || '';
|
|
634
|
+
}
|
|
635
|
+
|
|
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)));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
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
|
+
}
|
|
677
|
+
|
|
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
|
+
}));
|
|
711
|
+
}
|
|
712
|
+
chart.title.text += myChart;
|
|
713
|
+
return chart;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Entry point for adapter to handle messages related to statistics/flexcharts.
|
|
718
|
+
*
|
|
719
|
+
* @param {{chart?: string}} message
|
|
720
|
+
* @param {Function} callback
|
|
721
|
+
*/
|
|
722
|
+
handleFlexMessage(message, callback) {
|
|
723
|
+
const chartType = message?.chart || 'hourly';
|
|
724
|
+
const result = this._buildFlexchart(chartType);
|
|
725
|
+
if (callback && typeof callback === 'function') {
|
|
726
|
+
callback(result);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
module.exports = statistics;
|
package/lib/tools.js
CHANGED
|
@@ -70,8 +70,10 @@ class StateMap {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
set(id, value, options) {
|
|
73
|
-
if (options?.type == 'number'
|
|
74
|
-
|
|
73
|
+
if (options?.type == 'number') {
|
|
74
|
+
if (typeof value !== 'number' || isNaN(value)) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
75
77
|
}
|
|
76
78
|
if (value !== null) {
|
|
77
79
|
if (options?.type == 'number') {
|
|
@@ -170,6 +172,14 @@ class RiemannSum {
|
|
|
170
172
|
return this._lastDate ? this._lastDate : new Date();
|
|
171
173
|
}
|
|
172
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Adds the new value to calculate the Riemann sum.
|
|
177
|
+
*
|
|
178
|
+
* This function calculates the Riemann sum by adding the new value multiplied by the time elapsed since the last value,
|
|
179
|
+
* updating the total sum. If the auto reset at midnight is enabled, it resets the sum at midnight.
|
|
180
|
+
*
|
|
181
|
+
* @param {number} newValue - The new value to be added for Riemann sum calculation.
|
|
182
|
+
*/
|
|
173
183
|
add(newValue) {
|
|
174
184
|
//Obersumme bilden
|
|
175
185
|
const now = new Date();
|
|
@@ -182,12 +192,12 @@ class RiemannSum {
|
|
|
182
192
|
0,
|
|
183
193
|
0, // ...at 00:00:00 hours
|
|
184
194
|
);
|
|
185
|
-
if (this.lastDate?.getTime()
|
|
195
|
+
if (this.lastDate?.getTime() < lastnight.getTime()) {
|
|
186
196
|
this.reset();
|
|
187
197
|
}
|
|
188
198
|
}
|
|
189
199
|
if (!isNaN(newValue)) {
|
|
190
|
-
this._sum += (newValue * (now.getTime() - this.lastDate.getTime())) /
|
|
200
|
+
this._sum += (newValue * (now.getTime() - this.lastDate.getTime())) / 3600000; //Hour/Sekunden
|
|
191
201
|
this._lastDate = now;
|
|
192
202
|
}
|
|
193
203
|
}
|
|
@@ -240,6 +250,30 @@ const createAsyncLock = () => {
|
|
|
240
250
|
};
|
|
241
251
|
};
|
|
242
252
|
|
|
253
|
+
/**
|
|
254
|
+
* Warten auf einen Wert, der von einer Funktion zurückgegeben wird.
|
|
255
|
+
* Der Wert wird alle 100ms geprüft. Wenn der Wert innerhalb der angegebenen Zeit nicht gesetzt wurde, wird ein Timeout-Fehler zurückgegeben.
|
|
256
|
+
* @param {Function} func - Die Funktion, die den Wert zurückgibt.
|
|
257
|
+
* @param {number} [timeout] - Die maximale Wartezeit in ms.
|
|
258
|
+
* @returns {Promise} - Ein Promise, das den Wert zurückgibt oder einen Timeout-Fehler wirft.
|
|
259
|
+
*/
|
|
260
|
+
const waitForValue = (func, timeout = 5000) => {
|
|
261
|
+
return new Promise((resolve, reject) => {
|
|
262
|
+
const timer = setInterval(() => {
|
|
263
|
+
const variable = func();
|
|
264
|
+
if (variable !== undefined && variable !== null) {
|
|
265
|
+
clearInterval(timer);
|
|
266
|
+
resolve(variable);
|
|
267
|
+
}
|
|
268
|
+
timeout -= 200;
|
|
269
|
+
if (timeout <= 0) {
|
|
270
|
+
clearInterval(timer);
|
|
271
|
+
reject('Timeout: Wert wurde nicht rechtzeitig gesetzt.');
|
|
272
|
+
}
|
|
273
|
+
}, 200); // alle 200ms prüfen
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
|
|
243
277
|
// Get longitude an latidude from system config
|
|
244
278
|
async function getSystemData(adapter) {
|
|
245
279
|
const state = await adapter.getForeignObjectAsync('system.config');
|
|
@@ -343,6 +377,7 @@ module.exports = {
|
|
|
343
377
|
RegisterMap,
|
|
344
378
|
RiemannSum,
|
|
345
379
|
createAsyncLock,
|
|
380
|
+
waitForValue,
|
|
346
381
|
getSystemData,
|
|
347
382
|
getAstroDate,
|
|
348
383
|
isSunshine,
|
package/lib/types.js
CHANGED
|
@@ -258,6 +258,12 @@ const dataType = {
|
|
|
258
258
|
},
|
|
259
259
|
};
|
|
260
260
|
|
|
261
|
+
const statisticsType = {
|
|
262
|
+
deltaReset: 'deltaReset',
|
|
263
|
+
delta: 'delta',
|
|
264
|
+
level: 'level',
|
|
265
|
+
};
|
|
266
|
+
|
|
261
267
|
module.exports = {
|
|
262
268
|
modbusErrorMessages,
|
|
263
269
|
getDeviceStatusInfo,
|
|
@@ -267,4 +273,5 @@ module.exports = {
|
|
|
267
273
|
driverClasses,
|
|
268
274
|
storeType,
|
|
269
275
|
dataType,
|
|
276
|
+
statisticsType,
|
|
270
277
|
};
|
package/main.js
CHANGED
|
@@ -70,7 +70,8 @@ class Sun2000 extends utils.Adapter {
|
|
|
70
70
|
this.on('ready', this.onReady.bind(this));
|
|
71
71
|
this.on('stateChange', this.onStateChange.bind(this));
|
|
72
72
|
// this.on('objectChange', this.onObjectChange.bind(this));
|
|
73
|
-
//
|
|
73
|
+
// enable message handling for statistics/flexcharts requests
|
|
74
|
+
this.on('message', this.onMessage.bind(this));
|
|
74
75
|
this.on('unload', this.onUnload.bind(this));
|
|
75
76
|
}
|
|
76
77
|
|
|
@@ -356,9 +357,9 @@ class Sun2000 extends utils.Adapter {
|
|
|
356
357
|
this.config.timeout = this.config.timeout * 1000;
|
|
357
358
|
this.updateConfig(this.config);
|
|
358
359
|
}
|
|
359
|
-
if (this.config
|
|
360
|
+
if (this.config['sl_active']) {
|
|
360
361
|
//old Smartlogger
|
|
361
|
-
this.config
|
|
362
|
+
this.config['sl_active'] = false;
|
|
362
363
|
this.config.integration = 1;
|
|
363
364
|
this.updateConfig(this.config);
|
|
364
365
|
}
|
|
@@ -670,17 +671,22 @@ class Sun2000 extends utils.Adapter {
|
|
|
670
671
|
// * Using this method requires "common.messagebox" property to be set to true in io-package.json
|
|
671
672
|
// * @param {ioBroker.Message} obj
|
|
672
673
|
// */
|
|
673
|
-
|
|
674
|
-
//
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
674
|
+
|
|
675
|
+
// handle incoming messages (used by flexcharts script source)
|
|
676
|
+
onMessage(obj) {
|
|
677
|
+
if (typeof obj === 'object' && obj.message) {
|
|
678
|
+
// support a dedicated command 'statistics' to generate chart data
|
|
679
|
+
if (obj.command === 'statistics') {
|
|
680
|
+
if (this.state.statistics && typeof this.state.statistics.handleFlexMessage === 'function') {
|
|
681
|
+
this.state.statistics.handleFlexMessage(obj.message, result => {
|
|
682
|
+
if (obj.callback) this.sendTo(obj.from, obj.command, result, obj.callback);
|
|
683
|
+
});
|
|
684
|
+
} else {
|
|
685
|
+
if (obj.callback) this.sendTo(obj.from, obj.command, { error: 'statistics unavailable' }, obj.callback);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
684
690
|
}
|
|
685
691
|
|
|
686
692
|
if (require.main !== module) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iobroker.sun2000",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "sun2000",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "bolliy",
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
"tcp-port-used": "^1.0.2"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
|
-
"@alcalzone/release-script": "^5.
|
|
37
|
-
"@alcalzone/release-script-plugin-iobroker": "^
|
|
38
|
-
"@alcalzone/release-script-plugin-license": "^
|
|
39
|
-
"@alcalzone/release-script-plugin-manual-review": "^
|
|
36
|
+
"@alcalzone/release-script": "^5.1.1",
|
|
37
|
+
"@alcalzone/release-script-plugin-iobroker": "^5.1.2",
|
|
38
|
+
"@alcalzone/release-script-plugin-license": "^5.1.1",
|
|
39
|
+
"@alcalzone/release-script-plugin-manual-review": "^5.1.1",
|
|
40
40
|
"@iobroker/adapter-dev": "^1.5.0",
|
|
41
41
|
"@iobroker/eslint-config": "^2.2.0",
|
|
42
42
|
"@iobroker/testing": "^5.2.2",
|
|
43
|
-
"@tsconfig/node20": "^20.1.
|
|
44
|
-
"@types/node": "^25.0
|
|
43
|
+
"@tsconfig/node20": "^20.1.9",
|
|
44
|
+
"@types/node": "^25.5.0",
|
|
45
45
|
"globals": "^16.5.0",
|
|
46
46
|
"typescript": "~5.9.3"
|
|
47
47
|
},
|