iobroker.utility-monitor 1.4.5 → 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 +159 -44
  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 +219 -35
  21. package/io-package.json +51 -2
  22. package/lib/billingManager.js +276 -170
  23. package/lib/calculator.js +19 -138
  24. package/lib/consumptionManager.js +48 -331
  25. package/lib/importManager.js +300 -0
  26. package/lib/messagingHandler.js +112 -49
  27. package/lib/meter/MeterRegistry.js +110 -0
  28. package/lib/multiMeterManager.js +410 -181
  29. package/lib/stateManager.js +508 -36
  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 +99 -36
  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.
@@ -24,7 +26,36 @@ class MessagingHandler {
24
26
 
25
27
  this.adapter.log.debug(`[onMessage] Received command: ${obj.command} from ${obj.from}`);
26
28
 
27
- if (obj.command === 'getInstances') {
29
+ if (obj.command === 'getMeters') {
30
+ try {
31
+ // Get the utility type from the message
32
+ const type = obj.message?.type;
33
+
34
+ if (!type) {
35
+ this.adapter.sendTo(obj.from, obj.command, [], obj.callback);
36
+ return;
37
+ }
38
+
39
+ // Get all meters for this type from multiMeterManager
40
+ const meters = this.adapter.multiMeterManager?.getMetersForType(type) || [];
41
+
42
+ if (meters.length === 0) {
43
+ this.adapter.sendTo(obj.from, obj.command, [], obj.callback);
44
+ return;
45
+ }
46
+
47
+ // Build options array: [{ value: "main", label: "Hauptzähler (main)" }, ...]
48
+ const result = meters.map(meter => ({
49
+ value: meter.name,
50
+ label: meter.displayName ? `${meter.displayName} (${meter.name})` : meter.name,
51
+ }));
52
+
53
+ this.adapter.sendTo(obj.from, obj.command, result, obj.callback);
54
+ } catch (error) {
55
+ this.adapter.log.error(`Error in getMeters callback: ${error.message}`);
56
+ this.adapter.sendTo(obj.from, obj.command, [], obj.callback);
57
+ }
58
+ } else if (obj.command === 'getInstances') {
28
59
  try {
29
60
  const instances = await this.adapter.getForeignObjectsAsync('system.adapter.*', 'instance');
30
61
  const messengerTypes = [
@@ -156,50 +187,73 @@ class MessagingHandler {
156
187
  const typesDe = { gas: 'Gas', water: 'Wasser', electricity: 'Strom', pv: 'PV' };
157
188
 
158
189
  for (const type of types) {
159
- const configType = this.adapter.consumptionManager.getConfigType(type);
190
+ const configType = getConfigType(type);
160
191
  const enabledKey = `notification${configType.charAt(0).toUpperCase() + configType.slice(1)}Enabled`;
161
192
 
162
193
  if (!this.adapter.config[enabledKey] || !this.adapter.config[`${configType}Aktiv`]) {
163
194
  continue;
164
195
  }
165
196
 
166
- // Get current days remaining
167
- const daysRemainingState = await this.adapter.getStateAsync(`${type}.billing.daysRemaining`);
168
- const daysRemaining = typeof daysRemainingState?.val === 'number' ? daysRemainingState.val : 999;
169
- const periodEndState = await this.adapter.getStateAsync(`${type}.billing.periodEnd`);
170
- const periodEnd = periodEndState?.val || '--.--.----';
171
-
172
- // 1. BILLING END REMINDER (Zählerstand ablesen)
173
- if (this.adapter.config.notificationBillingEnabled) {
174
- const billingSent = await this.adapter.getStateAsync(`${type}.billing.notificationSent`);
175
- const billingDaysThreshold = this.adapter.config.notificationBillingDays || 7;
176
-
177
- if (billingSent?.val !== true && daysRemaining <= billingDaysThreshold) {
178
- const message =
179
- `🔔 *Nebenkosten-Monitor: Zählerstand ablesen*\n\n` +
180
- `Dein Abrechnungszeitraum für *${typesDe[type]}* endet in ${daysRemaining} Tagen!\n\n` +
181
- `📅 Datum: ${periodEnd}\n\n` +
182
- `Bitte trage den Zählerstand rechtzeitig ein:\n` +
183
- `1️⃣ Datenpunkt: ${type}.billing.endReading\n` +
184
- `2️⃣ Zeitraum abschließen: ${type}.billing.closePeriod = true`;
185
-
186
- await this.sendNotification(type, message, 'billing');
187
- }
197
+ // Get all meters for this type (main + additional)
198
+ const allMeters = this.adapter.multiMeterManager?.getMetersForType(type) || [];
199
+ if (allMeters.length === 0) {
200
+ continue;
201
+ }
202
+
203
+ // Filter meters based on configuration
204
+ // If notificationXXXMeters is empty or undefined, notify ALL meters
205
+ const configKey = `notification${configType.charAt(0).toUpperCase() + configType.slice(1)}Meters`;
206
+ const selectedMeters = this.adapter.config[configKey];
207
+
208
+ let metersToNotify = allMeters;
209
+ if (selectedMeters && Array.isArray(selectedMeters) && selectedMeters.length > 0) {
210
+ // Filter: only notify selected meters
211
+ metersToNotify = allMeters.filter(meter => selectedMeters.includes(meter.name));
188
212
  }
189
213
 
190
- // 2. CONTRACT CHANGE REMINDER (Tarif wechseln / Kündigungsfrist)
191
- if (this.adapter.config.notificationChangeEnabled) {
192
- const changeSent = await this.adapter.getStateAsync(`${type}.billing.notificationChangeSent`);
193
- const changeDaysThreshold = this.adapter.config.notificationChangeDays || 60;
214
+ // Check notifications for each meter individually
215
+ for (const meter of metersToNotify) {
216
+ const basePath = `${type}.${meter.name}`;
217
+ const meterLabel = meter.displayName || meter.name;
218
+
219
+ // Get current days remaining for THIS meter
220
+ const daysRemainingState = await this.adapter.getStateAsync(`${basePath}.billing.daysRemaining`);
221
+ const daysRemaining = typeof daysRemainingState?.val === 'number' ? daysRemainingState.val : 999;
222
+ const periodEndState = await this.adapter.getStateAsync(`${basePath}.billing.periodEnd`);
223
+ const periodEnd = periodEndState?.val || '--.--.----';
224
+
225
+ // 1. BILLING END REMINDER (Zählerstand ablesen)
226
+ if (this.adapter.config.notificationBillingEnabled) {
227
+ const billingSent = await this.adapter.getStateAsync(`${basePath}.billing.notificationSent`);
228
+ const billingDaysThreshold = this.adapter.config.notificationBillingDays || 7;
229
+
230
+ if (billingSent?.val !== true && daysRemaining <= billingDaysThreshold) {
231
+ const message =
232
+ `🔔 *Nebenkosten-Monitor: Zählerstand ablesen*\n\n` +
233
+ `Dein Abrechnungszeitraum für *${typesDe[type]} (${meterLabel})* endet in ${daysRemaining} Tagen!\n\n` +
234
+ `📅 Datum: ${periodEnd}\n\n` +
235
+ `Bitte trage den Zählerstand rechtzeitig ein:\n` +
236
+ `1️⃣ Datenpunkt: ${basePath}.billing.endReading\n` +
237
+ `2️⃣ Zeitraum abschließen: ${basePath}.billing.closePeriod = true`;
238
+
239
+ await this.sendNotification(basePath, message, 'billing');
240
+ }
241
+ }
242
+
243
+ // 2. CONTRACT CHANGE REMINDER (Tarif wechseln / Kündigungsfrist)
244
+ if (this.adapter.config.notificationChangeEnabled) {
245
+ const changeSent = await this.adapter.getStateAsync(`${basePath}.billing.notificationChangeSent`);
246
+ const changeDaysThreshold = this.adapter.config.notificationChangeDays || 60;
194
247
 
195
- if (changeSent?.val !== true && daysRemaining <= changeDaysThreshold) {
196
- const message =
197
- `💡 *Nebenkosten-Monitor: Tarif-Check*\n\n` +
198
- `Dein Vertrag für *${typesDe[type]}* endet am ${periodEnd}.\n\n` +
199
- `⏰ Noch ${daysRemaining} Tage bis zum Ende des Zeitraums.\n\n` +
200
- `Jetzt ist ein guter Zeitpunkt, um Preise zu vergleichen oder die Kündigungsfrist zu prüfen! 💸`;
248
+ if (changeSent?.val !== true && daysRemaining <= changeDaysThreshold) {
249
+ const message =
250
+ `💡 *Nebenkosten-Monitor: Tarif-Check*\n\n` +
251
+ `Dein Vertrag für *${typesDe[type]} (${meterLabel})* endet am ${periodEnd}.\n\n` +
252
+ `⏰ Noch ${daysRemaining} Tage bis zum Ende des Zeitraums.\n\n` +
253
+ `Jetzt ist ein guter Zeitpunkt, um Preise zu vergleichen oder die Kündigungsfrist zu prüfen! 💸`;
201
254
 
202
- await this.sendNotification(type, message, 'change');
255
+ await this.sendNotification(basePath, message, 'change');
256
+ }
203
257
  }
204
258
  }
205
259
  }
@@ -238,7 +292,7 @@ class MessagingHandler {
238
292
  let hasData = false;
239
293
 
240
294
  for (const type of types) {
241
- const configType = this.adapter.consumptionManager.getConfigType(type); // strom, gas, wasser, pv
295
+ const configType = getConfigType(type); // strom, gas, wasser, pv
242
296
 
243
297
  if (!this.adapter.config[`${configType}Aktiv`]) {
244
298
  continue;
@@ -258,16 +312,24 @@ class MessagingHandler {
258
312
  // Multi-meter: use totals
259
313
  yearlyState = await this.adapter.getStateAsync(`${type}.totals.consumption.yearly`);
260
314
  totalYearlyState = await this.adapter.getStateAsync(`${type}.totals.costs.totalYearly`);
261
- // Balance/paidTotal not available in totals, use main meter as representative
262
- paidTotalState = await this.adapter.getStateAsync(`${type}.costs.paidTotal`);
263
- balanceState = await this.adapter.getStateAsync(`${type}.costs.balance`);
315
+ // Balance/paidTotal not available in totals, use first meter as representative
316
+ const firstMeter = meters[0];
317
+ if (firstMeter) {
318
+ paidTotalState = await this.adapter.getStateAsync(`${type}.${firstMeter.name}.costs.paidTotal`);
319
+ balanceState = await this.adapter.getStateAsync(`${type}.${firstMeter.name}.costs.balance`);
320
+ }
264
321
  message += `(${meters.length} Zähler gesamt)\\n`;
322
+ } else if (meters.length === 1) {
323
+ // Single meter: use first meter values (new path structure)
324
+ const meter = meters[0];
325
+ const basePath = `${type}.${meter.name}`;
326
+ yearlyState = await this.adapter.getStateAsync(`${basePath}.consumption.yearly`);
327
+ totalYearlyState = await this.adapter.getStateAsync(`${basePath}.costs.totalYearly`);
328
+ paidTotalState = await this.adapter.getStateAsync(`${basePath}.costs.paidTotal`);
329
+ balanceState = await this.adapter.getStateAsync(`${basePath}.costs.balance`);
265
330
  } else {
266
- // Single meter: use main meter values
267
- yearlyState = await this.adapter.getStateAsync(`${type}.consumption.yearly`);
268
- totalYearlyState = await this.adapter.getStateAsync(`${type}.costs.totalYearly`);
269
- paidTotalState = await this.adapter.getStateAsync(`${type}.costs.paidTotal`);
270
- balanceState = await this.adapter.getStateAsync(`${type}.costs.balance`);
331
+ // No meters configured - skip this type
332
+ continue;
271
333
  }
272
334
 
273
335
  let val = yearlyState?.val || 0;
@@ -310,14 +372,14 @@ class MessagingHandler {
310
372
  /**
311
373
  * Helper to send notification and mark as sent
312
374
  *
313
- * @param {string} type - gas, water, electricity
375
+ * @param {string} pathOrType - Full path like "gas.main" or just "system" for reports
314
376
  * @param {string} message - Message text
315
377
  * @param {string} reminderType - billing, change, or report
316
378
  */
317
- async sendNotification(type, message, reminderType) {
379
+ async sendNotification(pathOrType, message, reminderType) {
318
380
  try {
319
381
  const instance = this.adapter.config.notificationInstance;
320
- this.adapter.log.info(`Sending ${reminderType} notification for ${type} via ${instance}`);
382
+ this.adapter.log.info(`Sending ${reminderType} notification for ${pathOrType} via ${instance}`);
321
383
 
322
384
  await this.adapter.sendToAsync(instance, 'send', {
323
385
  text: message,
@@ -326,12 +388,13 @@ class MessagingHandler {
326
388
  });
327
389
 
328
390
  // Mark as sent (only for billing/change)
391
+ // pathOrType is now the full path like "gas.main" or "gas.werkstatt"
329
392
  if (reminderType !== 'report') {
330
393
  const stateKey = reminderType === 'change' ? 'notificationChangeSent' : 'notificationSent';
331
- await this.adapter.setStateAsync(`${type}.billing.${stateKey}`, true, true);
394
+ await this.adapter.setStateAsync(`${pathOrType}.billing.${stateKey}`, true, true);
332
395
  }
333
396
  } catch (error) {
334
- this.adapter.log.error(`Failed to send ${reminderType} notification for ${type}: ${error.message}`);
397
+ this.adapter.log.error(`Failed to send ${reminderType} notification for ${pathOrType}: ${error.message}`);
335
398
  }
336
399
  }
337
400
  }
@@ -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;