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.
- package/README.md +159 -44
- package/admin/custom/.vite/manifest.json +90 -0
- package/admin/custom/@mf-types/Components.d.ts +2 -0
- package/admin/custom/@mf-types/compiled-types/Components/CSVImporter.d.ts +11 -0
- package/admin/custom/@mf-types/compiled-types/Components.d.ts +2 -0
- package/admin/custom/@mf-types.d.ts +3 -0
- package/admin/custom/@mf-types.zip +0 -0
- package/admin/custom/CSVImporter_v15_11.js +4415 -0
- package/admin/custom/assets/Components-i0AZ59nl.js +18887 -0
- package/admin/custom/assets/UtilityMonitor__loadShare__react__loadShare__-Da99Mak4.js +42 -0
- package/admin/custom/assets/UtilityMonitor__mf_v__runtimeInit__mf_v__-BmC4OGk6.js +16 -0
- package/admin/custom/assets/_commonjsHelpers-Dj2_voLF.js +30 -0
- package/admin/custom/assets/hostInit-DEXfeB0W.js +10 -0
- package/admin/custom/assets/index-B3WVNJTz.js +401 -0
- package/admin/custom/assets/index-VBwl8x_k.js +64 -0
- package/admin/custom/assets/preload-helper-BelkbqnE.js +61 -0
- package/admin/custom/assets/virtualExposes-CqCLUNLT.js +19 -0
- package/admin/custom/index.html +12 -0
- package/admin/custom/mf-manifest.json +1 -0
- package/admin/jsonConfig.json +219 -35
- package/io-package.json +51 -2
- package/lib/billingManager.js +276 -170
- package/lib/calculator.js +19 -138
- package/lib/consumptionManager.js +48 -331
- package/lib/importManager.js +300 -0
- package/lib/messagingHandler.js +112 -49
- package/lib/meter/MeterRegistry.js +110 -0
- package/lib/multiMeterManager.js +410 -181
- package/lib/stateManager.js +508 -36
- package/lib/utils/billingHelper.js +69 -0
- package/lib/utils/consumptionHelper.js +47 -0
- package/lib/utils/helpers.js +178 -0
- package/lib/utils/typeMapper.js +19 -0
- package/main.js +99 -36
- 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;
|
package/lib/messagingHandler.js
CHANGED
|
@@ -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 === '
|
|
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 =
|
|
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
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
//
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
const
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
//
|
|
267
|
-
|
|
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}
|
|
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(
|
|
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 ${
|
|
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(`${
|
|
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 ${
|
|
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;
|