iobroker.utility-monitor 1.4.6 → 1.5.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.
Files changed (35) hide show
  1. package/README.md +98 -55
  2. package/admin/custom/.vite/manifest.json +90 -0
  3. package/admin/custom/@mf-types/Components.d.ts +2 -0
  4. package/admin/custom/@mf-types/compiled-types/Components/CSVImporter.d.ts +11 -0
  5. package/admin/custom/@mf-types/compiled-types/Components.d.ts +2 -0
  6. package/admin/custom/@mf-types.d.ts +3 -0
  7. package/admin/custom/@mf-types.zip +0 -0
  8. package/admin/custom/CSVImporter_v15_11.js +4415 -0
  9. package/admin/custom/assets/Components-i0AZ59nl.js +18887 -0
  10. package/admin/custom/assets/UtilityMonitor__loadShare__react__loadShare__-Da99Mak4.js +42 -0
  11. package/admin/custom/assets/UtilityMonitor__mf_v__runtimeInit__mf_v__-BmC4OGk6.js +16 -0
  12. package/admin/custom/assets/_commonjsHelpers-Dj2_voLF.js +30 -0
  13. package/admin/custom/assets/hostInit-DEXfeB0W.js +10 -0
  14. package/admin/custom/assets/index-B3WVNJTz.js +401 -0
  15. package/admin/custom/assets/index-VBwl8x_k.js +64 -0
  16. package/admin/custom/assets/preload-helper-BelkbqnE.js +61 -0
  17. package/admin/custom/assets/virtualExposes-CqCLUNLT.js +19 -0
  18. package/admin/custom/index.html +12 -0
  19. package/admin/custom/mf-manifest.json +1 -0
  20. package/admin/jsonConfig.json +90 -31
  21. package/io-package.json +39 -3
  22. package/lib/billingManager.js +235 -123
  23. package/lib/calculator.js +19 -138
  24. package/lib/consumptionManager.js +9 -252
  25. package/lib/importManager.js +300 -0
  26. package/lib/messagingHandler.js +4 -2
  27. package/lib/meter/MeterRegistry.js +110 -0
  28. package/lib/multiMeterManager.js +397 -174
  29. package/lib/stateManager.js +502 -31
  30. package/lib/utils/billingHelper.js +69 -0
  31. package/lib/utils/consumptionHelper.js +47 -0
  32. package/lib/utils/helpers.js +178 -0
  33. package/lib/utils/typeMapper.js +19 -0
  34. package/main.js +71 -8
  35. package/package.json +10 -4
@@ -0,0 +1,300 @@
1
+ 'use strict';
2
+
3
+ const calculator = require('./calculator');
4
+ const { getConfigType } = require('./utils/typeMapper');
5
+ const stateManager = require('./stateManager');
6
+
7
+ /**
8
+ * ImportManager handles CSV file parsing and data importing
9
+ * for utility-monitor historical data.
10
+ */
11
+ class ImportManager {
12
+ /**
13
+ * @param {object} adapter - ioBroker adapter instance
14
+ */
15
+ constructor(adapter) {
16
+ this.adapter = adapter;
17
+ }
18
+
19
+ /**
20
+ * Handles the 'importCSV' message from the Admin UI
21
+ *
22
+ * @param {Record<string, any>} obj - Message object
23
+ */
24
+ async handleImportCSV(obj) {
25
+ if (!obj || !obj.message) {
26
+ return;
27
+ }
28
+
29
+ const { type, meterName, content, format } = obj.message;
30
+
31
+ this.adapter.log.info(`[Import] Starting CSV import for ${type}.${meterName} (Format: ${format || 'generic'})`);
32
+
33
+ try {
34
+ const result = await this.processImport(type, meterName, content, format);
35
+
36
+ if (obj.callback) {
37
+ this.adapter.sendTo(obj.from, obj.command, result, obj.callback);
38
+ }
39
+ } catch (error) {
40
+ this.adapter.log.error(`[Import] Failed to process CSV: ${error.message}`);
41
+ if (obj.callback) {
42
+ this.adapter.sendTo(obj.from, obj.command, { error: error.message }, obj.callback);
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Parses and processes the CSV content
49
+ *
50
+ * @param {string} type - gas, water, electricity
51
+ * @param {string} meterName - technical name of the meter
52
+ * @param {string} content - raw CSV content (base64 or string)
53
+ * @param {string} _format - format profile (e.g. 'ehb')
54
+ */
55
+ async processImport(type, meterName, content, _format) {
56
+ // Decode base64 if necessary
57
+ let csvBody = content;
58
+ if (content.startsWith('data:') && content.includes('base64,')) {
59
+ const base64Data = content.split('base64,')[1];
60
+ csvBody = Buffer.from(base64Data, 'base64').toString('utf-8');
61
+ }
62
+
63
+ const lines = csvBody.split(/\r?\n/).filter(line => line.trim() !== '');
64
+ if (lines.length < 2) {
65
+ throw new Error('Die Datei ist leer oder enthält zu wenige Daten.');
66
+ }
67
+
68
+ // Detect separator by looking at the first 5 lines and picking the most frequent separator
69
+ const possibleSeparators = [';', ',', '|', '\t'];
70
+ let separator = ';';
71
+ let maxTotalCols = 0;
72
+
73
+ for (const sep of possibleSeparators) {
74
+ let totalCols = 0;
75
+ for (let i = 0; i < Math.min(lines.length, 5); i++) {
76
+ totalCols += lines[i].split(sep).length;
77
+ }
78
+ if (totalCols > maxTotalCols) {
79
+ maxTotalCols = totalCols;
80
+ separator = sep;
81
+ }
82
+ }
83
+
84
+ let headers = lines[0].split(separator).map(h => h.trim().toLowerCase().replace(/^"|"$/g, ''));
85
+
86
+ // Column detection
87
+ let dateIdx = -1;
88
+ let valueIdx = -1;
89
+
90
+ const dateHeaders = ['date', 'datum', 'zeit', 'timestamp', 'zeitstempel', 'day', 'tag', 'ablesedatum'];
91
+ const valueHeaders = [
92
+ 'value',
93
+ 'wert',
94
+ 'reading',
95
+ 'zählerstand',
96
+ 'stand',
97
+ 'verbrauch',
98
+ 'amount',
99
+ 'kwh',
100
+ 'm³',
101
+ 'm3',
102
+ 'ablesewert',
103
+ 'wasserstand',
104
+ 'gasstand',
105
+ 'stromstand',
106
+ 'kaltwasser',
107
+ 'warmwasser',
108
+ 'energie',
109
+ ];
110
+
111
+ dateIdx = headers.findIndex(h => dateHeaders.some(dh => h.includes(dh)));
112
+ valueIdx = headers.findIndex(h => valueHeaders.some(vh => h.includes(vh)));
113
+
114
+ // Special case: If header is just numbers or very common terms, try searching for the specific media name
115
+ if (valueIdx === -1) {
116
+ const mediaTerms = { gas: 'gas', water: 'wasser', electricity: 'strom', pv: 'pv' };
117
+ const term = mediaTerms[type];
118
+ valueIdx = headers.findIndex(h => h.includes(term));
119
+ }
120
+
121
+ // Fallback to defaults if not found by header
122
+ if (dateIdx === -1) {
123
+ dateIdx = 0;
124
+ }
125
+ if (valueIdx === -1) {
126
+ valueIdx = 1;
127
+ }
128
+
129
+ this.adapter.log.info(
130
+ `[Import] Found headers: [${headers.join(' | ')}]. Selected columns: Date="${headers[dateIdx]}" (Index ${dateIdx}), Value="${headers[valueIdx]}" (Index ${valueIdx}) (Separator: "${separator}")`,
131
+ );
132
+
133
+ // Check if first line is actually data (contains numbers or date-like string)
134
+ let startIndex = 1;
135
+ const firstLineCols = lines[0].split(separator).map(c => c.trim().replace(/^"|"$/g, ''));
136
+ const dateStrFirst = firstLineCols[dateIdx] || '';
137
+ const isFirstLineData = !isNaN(parseFloat(firstLineCols[valueIdx])) || dateStrFirst.includes(':');
138
+
139
+ if (isFirstLineData) {
140
+ const hasHeaderText = headers.some(h =>
141
+ [...dateHeaders, ...valueHeaders].some(term => h.includes(term) && h.length > 2),
142
+ );
143
+ if (!hasHeaderText) {
144
+ startIndex = 0;
145
+ this.adapter.log.info('[Import] First line appears to be data, including it.');
146
+ }
147
+ }
148
+
149
+ const dataPoints = [];
150
+ for (let i = startIndex; i < lines.length; i++) {
151
+ const columns = lines[i].split(separator).map(c => c.trim().replace(/^"|"$/g, ''));
152
+ if (columns.length <= Math.max(dateIdx, valueIdx)) {
153
+ continue;
154
+ }
155
+
156
+ const dateStr = columns[dateIdx];
157
+ const valueStr = columns[valueIdx];
158
+
159
+ let timestamp = null;
160
+ let value = calculator.ensureNumber(valueStr);
161
+
162
+ // Robust Date Parsing
163
+ timestamp = calculator.parseDateString(dateStr);
164
+
165
+ if (!timestamp && !isNaN(Number(dateStr)) && dateStr.length > 10) {
166
+ // Possibly Unix Timestamp (ms)
167
+ timestamp = new Date(Number(dateStr));
168
+ }
169
+
170
+ if (timestamp && !isNaN(timestamp.getTime()) && value > 0) {
171
+ dataPoints.push({ timestamp, value });
172
+ }
173
+ }
174
+
175
+ if (dataPoints.length === 0) {
176
+ this.adapter.log.warn(
177
+ `[Import] No valid data found. Headers: ${headers.join('|')}, First Data Line: ${lines[1]}`,
178
+ );
179
+ throw new Error(
180
+ 'Keine gültigen Datenpunkte gefunden. Bitte sicherstellen, dass die Datei Spalten für Datum und Wert enthält.',
181
+ );
182
+ }
183
+
184
+ // Sort by timestamp
185
+ dataPoints.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
186
+
187
+ this.adapter.log.info(
188
+ `[Import] Found ${dataPoints.length} valid records from ${dataPoints[0].timestamp.toLocaleDateString()} to ${dataPoints[dataPoints.length - 1].timestamp.toLocaleDateString()}`,
189
+ );
190
+
191
+ // Pure History Import: Aggregates only
192
+ const basePath = `${type}.${meterName}`;
193
+
194
+ // Ensure channel and metadata structure
195
+ await this.ensureMeterObjects(type, meterName);
196
+
197
+ const currentYear = new Date().getFullYear();
198
+ const yearStats = {};
199
+
200
+ let count = 0;
201
+ for (const dp of dataPoints) {
202
+ try {
203
+ const year = dp.timestamp.getFullYear();
204
+
205
+ // Group by year for history states
206
+ if (year < currentYear) {
207
+ if (!yearStats[year]) {
208
+ yearStats[year] = { consumption: 0, volume: 0, count: 0 };
209
+ await stateManager.createHistoryStructure(this.adapter, type, meterName, year);
210
+ }
211
+ yearStats[year].consumption += dp.value;
212
+ if (type === 'gas') {
213
+ const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
214
+ const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
215
+ yearStats[year].volume += dp.value / (brennwert * zZahl);
216
+ }
217
+ yearStats[year].count++;
218
+ }
219
+
220
+ // No individual writing to meterReading state needed for pure history
221
+ count++;
222
+ } catch (e) {
223
+ this.adapter.log.error(`[Import] Error writing data point ${dp.timestamp.toISOString()}: ${e.message}`);
224
+ }
225
+ }
226
+
227
+ // Write aggregated history states
228
+ for (const year in yearStats) {
229
+ const hPath = `${basePath}.history.${year}`;
230
+ const stats = yearStats[year];
231
+ await this.adapter.setStateAsync(
232
+ `${hPath}.consumption`,
233
+ calculator.roundToDecimals(stats.consumption, 2),
234
+ true,
235
+ );
236
+ if (type === 'gas') {
237
+ await this.adapter.setStateAsync(`${hPath}.volume`, calculator.roundToDecimals(stats.volume, 2), true);
238
+ }
239
+ // Recalculate costs for history year roughly using current price
240
+ const configType = getConfigType(type);
241
+ const price = this.adapter.config[`${configType}Preis`] || 0;
242
+ await this.adapter.setStateAsync(
243
+ `${hPath}.costs`,
244
+ calculator.roundToDecimals(stats.consumption * price, 2),
245
+ true,
246
+ );
247
+ }
248
+
249
+ // Update metadata for pure history import
250
+ await this.adapter.setStateAsync(`${basePath}.lastImport`, Date.now(), true);
251
+
252
+ return {
253
+ success: true,
254
+ count: count,
255
+ first: dataPoints[0].timestamp.toLocaleDateString(),
256
+ last: dataPoints[dataPoints.length - 1].timestamp.toLocaleDateString(),
257
+ };
258
+ }
259
+
260
+ /**
261
+ * Ensures that the minimal object structure for a meter exists (FLAT structure for imports)
262
+ *
263
+ * @param {string} type - gas, water, electricity, pv
264
+ * @param {string} meterName - name of the meter
265
+ */
266
+ async ensureMeterObjects(type, meterName) {
267
+ const basePath = `${type}.${meterName}`;
268
+ const typeDe = { gas: 'Gas', water: 'Wasser', electricity: 'Strom', pv: 'PV' };
269
+
270
+ // 1. Create Channel for Type (if not exists)
271
+ await this.adapter.setObjectNotExistsAsync(type, {
272
+ type: 'channel',
273
+ common: { name: typeDe[type] || type },
274
+ native: {},
275
+ });
276
+
277
+ // 2. Create Channel for Meter
278
+ await this.adapter.setObjectNotExistsAsync(basePath, {
279
+ type: 'channel',
280
+ common: { name: `Zähler: ${meterName}` },
281
+ native: { meterName },
282
+ });
283
+
284
+ // 3. Create metadata (last import timestamp)
285
+ await this.adapter.setObjectNotExistsAsync(`${basePath}.lastImport`, {
286
+ type: 'state',
287
+ common: {
288
+ name: 'Letzter Import',
289
+ type: 'number',
290
+ role: 'value.time',
291
+ read: true,
292
+ write: false,
293
+ def: 0,
294
+ },
295
+ native: {},
296
+ });
297
+ }
298
+ }
299
+
300
+ module.exports = ImportManager;
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { getConfigType } = require('./utils/typeMapper');
4
+
3
5
  /**
4
6
  * MessagingHandler handles all incoming adapter messages
5
7
  * and outgoing notifications.
@@ -185,7 +187,7 @@ class MessagingHandler {
185
187
  const typesDe = { gas: 'Gas', water: 'Wasser', electricity: 'Strom', pv: 'PV' };
186
188
 
187
189
  for (const type of types) {
188
- const configType = this.adapter.consumptionManager.getConfigType(type);
190
+ const configType = getConfigType(type);
189
191
  const enabledKey = `notification${configType.charAt(0).toUpperCase() + configType.slice(1)}Enabled`;
190
192
 
191
193
  if (!this.adapter.config[enabledKey] || !this.adapter.config[`${configType}Aktiv`]) {
@@ -290,7 +292,7 @@ class MessagingHandler {
290
292
  let hasData = false;
291
293
 
292
294
  for (const type of types) {
293
- const configType = this.adapter.consumptionManager.getConfigType(type); // strom, gas, wasser, pv
295
+ const configType = getConfigType(type); // strom, gas, wasser, pv
294
296
 
295
297
  if (!this.adapter.config[`${configType}Aktiv`]) {
296
298
  continue;
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MeterRegistry manages the mapping between sensor data points and meters.
5
+ * Each sensor can be associated with one or more meters of different types.
6
+ */
7
+ class MeterRegistry {
8
+ /**
9
+ * Initializes the registry
10
+ */
11
+ constructor() {
12
+ // Maps sensorDP to array of {type, meterName}
13
+ this.registry = {};
14
+ }
15
+
16
+ /**
17
+ * Registers a sensor with a meter
18
+ *
19
+ * @param {string} sensorDP - Sensor data point ID
20
+ * @param {string} type - Utility type (gas, water, electricity, pv)
21
+ * @param {string} meterName - Name of the meter
22
+ */
23
+ register(sensorDP, type, meterName) {
24
+ if (!sensorDP) {
25
+ return;
26
+ }
27
+
28
+ if (!this.registry[sensorDP]) {
29
+ this.registry[sensorDP] = [];
30
+ }
31
+
32
+ // Check if already registered
33
+ const exists = this.registry[sensorDP].some(entry => entry.type === type && entry.meterName === meterName);
34
+
35
+ if (!exists) {
36
+ this.registry[sensorDP].push({ type, meterName });
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Finds all meters associated with a sensor
42
+ *
43
+ * @param {string} sensorDP - Sensor data point ID
44
+ * @returns {Array<{type: string, meterName: string}>} Array of meter entries
45
+ */
46
+ findBySensor(sensorDP) {
47
+ return this.registry[sensorDP] || [];
48
+ }
49
+
50
+ /**
51
+ * Removes a meter from a sensor
52
+ *
53
+ * @param {string} sensorDP - Sensor data point ID
54
+ * @param {string} type - Utility type
55
+ * @param {string} meterName - Name of the meter
56
+ */
57
+ unregister(sensorDP, type, meterName) {
58
+ if (!this.registry[sensorDP]) {
59
+ return;
60
+ }
61
+
62
+ this.registry[sensorDP] = this.registry[sensorDP].filter(
63
+ entry => !(entry.type === type && entry.meterName === meterName),
64
+ );
65
+
66
+ // Clean up empty entries
67
+ if (this.registry[sensorDP].length === 0) {
68
+ delete this.registry[sensorDP];
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Clears all registrations for a sensor
74
+ *
75
+ * @param {string} sensorDP - Sensor data point ID
76
+ */
77
+ clearSensor(sensorDP) {
78
+ delete this.registry[sensorDP];
79
+ }
80
+
81
+ /**
82
+ * Gets all registered sensors
83
+ *
84
+ * @returns {string[]} Array of sensor data point IDs
85
+ */
86
+ getAllSensors() {
87
+ return Object.keys(this.registry);
88
+ }
89
+
90
+ /**
91
+ * Gets the complete registry
92
+ *
93
+ * @returns {object} The registry object
94
+ */
95
+ getRegistry() {
96
+ return this.registry;
97
+ }
98
+
99
+ /**
100
+ * Checks if a sensor is registered
101
+ *
102
+ * @param {string} sensorDP - Sensor data point ID
103
+ * @returns {boolean} True if sensor is registered
104
+ */
105
+ hasSensor(sensorDP) {
106
+ return !!this.registry[sensorDP];
107
+ }
108
+ }
109
+
110
+ module.exports = MeterRegistry;