iobroker.utility-monitor 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,407 @@
1
+ 'use strict';
2
+
3
+ const calculator = require('./calculator');
4
+ const stateManager = require('./stateManager');
5
+
6
+ /**
7
+ * ConsumptionManager handles all sensor-related logic,
8
+ * including initialization, unit conversion, and sensor updates.
9
+ */
10
+ class ConsumptionManager {
11
+ /**
12
+ * @param {object} adapter - ioBroker adapter instance
13
+ */
14
+ constructor(adapter) {
15
+ this.adapter = adapter;
16
+ this.lastSensorValues = {};
17
+ }
18
+
19
+ /**
20
+ * Maps internal utility type to config/state name
21
+ *
22
+ * @param {string} type - gas, water, or electricity
23
+ * @returns {string} - gas, wasser, or strom
24
+ */
25
+ getConfigType(type) {
26
+ const mapping = {
27
+ electricity: 'strom',
28
+ water: 'wasser',
29
+ gas: 'gas',
30
+ };
31
+ return mapping[type] || type;
32
+ }
33
+
34
+ /**
35
+ * Initializes a utility type (gas, water, or electricity)
36
+ *
37
+ * @param {string} type - Utility type
38
+ * @param {boolean} isActive - Whether this utility is active
39
+ */
40
+ async initializeUtility(type, isActive) {
41
+ if (!isActive) {
42
+ this.adapter.log.debug(`${type} monitoring is disabled`);
43
+ // Clean up states if utility was disabled
44
+ await stateManager.deleteUtilityStateStructure(this.adapter, type);
45
+ return;
46
+ }
47
+
48
+ this.adapter.log.info(`Initializing ${type} monitoring...`);
49
+
50
+ // Create state structure
51
+ await stateManager.createUtilityStateStructure(this.adapter, type, this.adapter.config);
52
+
53
+ const configType = this.getConfigType(type);
54
+ const sensorDPKey = `${configType}SensorDP`;
55
+ const sensorDP = this.adapter.config[sensorDPKey];
56
+
57
+ if (!sensorDP) {
58
+ this.adapter.log.warn(`${type} is active but no sensor datapoint configured!`);
59
+ await this.adapter.setStateAsync(`${type}.info.sensorActive`, false, true);
60
+ return;
61
+ }
62
+
63
+ this.adapter.log.debug(`Using sensor datapoint for ${type}: ${sensorDP}`);
64
+
65
+ // Log configured contract start for user verification
66
+ const contractStartKey = `${configType}ContractStart`;
67
+ const contractStartDateStr = this.adapter.config[contractStartKey];
68
+ if (contractStartDateStr) {
69
+ this.adapter.log.info(`${type}: Managed with contract start: ${contractStartDateStr}`);
70
+ }
71
+
72
+ // Subscribe to sensor datapoint
73
+ this.adapter.subscribeForeignStates(sensorDP);
74
+ await this.adapter.setStateAsync(`${type}.info.sensorActive`, true, true);
75
+ this.adapter.log.debug(`Subscribed to ${type} sensor: ${sensorDP}`);
76
+
77
+ // Initialize all meters (main + additional) via MultiMeterManager
78
+ if (this.adapter.multiMeterManager) {
79
+ await this.adapter.multiMeterManager.initializeType(type);
80
+ }
81
+
82
+ // Restore last sensor value from persistent state to prevent delta loss
83
+ const lastReading = await this.adapter.getStateAsync(`${type}.info.meterReading`);
84
+ if (lastReading && typeof lastReading.val === 'number') {
85
+ this.lastSensorValues[sensorDP] = lastReading.val;
86
+ this.adapter.log.debug(`${type}: Restored last sensor value: ${lastReading.val}`);
87
+ }
88
+
89
+ // Initialize with current sensor value
90
+ try {
91
+ const sensorState = await this.adapter.getForeignStateAsync(sensorDP);
92
+ if (sensorState && sensorState.val !== null && typeof sensorState.val === 'number') {
93
+ await this.handleSensorUpdate(type, sensorDP, sensorState.val);
94
+ }
95
+ } catch (error) {
96
+ this.adapter.log.warn(`Could not read initial value from ${sensorDP}: ${error.message}`);
97
+ }
98
+
99
+ // Initialize period start timestamps if not set
100
+ const now = Date.now();
101
+ const dayStart = await this.adapter.getStateAsync(`${type}.statistics.lastDayStart`);
102
+ if (!dayStart || !dayStart.val) {
103
+ await this.adapter.setStateAsync(`${type}.statistics.lastDayStart`, now, true);
104
+ }
105
+
106
+ const monthStart = await this.adapter.getStateAsync(`${type}.statistics.lastMonthStart`);
107
+ if (!monthStart || !monthStart.val) {
108
+ await this.adapter.setStateAsync(`${type}.statistics.lastMonthStart`, now, true);
109
+ }
110
+
111
+ const yearStart = await this.adapter.getStateAsync(`${type}.statistics.lastYearStart`);
112
+ if (!yearStart || !yearStart.val) {
113
+ // Determine year start based on contract date or January 1st
114
+ const contractStartKey = `${configType}ContractStart`;
115
+ const contractStartDateStr = this.adapter.config[contractStartKey];
116
+
117
+ let yearStartDate;
118
+ if (contractStartDateStr) {
119
+ const contractStart = calculator.parseGermanDate(contractStartDateStr);
120
+ if (contractStart && !isNaN(contractStart.getTime())) {
121
+ // Calculate last anniversary
122
+ const nowDate = new Date(now);
123
+ const currentYear = nowDate.getFullYear();
124
+ yearStartDate = new Date(currentYear, contractStart.getMonth(), contractStart.getDate(), 12, 0, 0);
125
+
126
+ // If anniversary is in the future this year, take last year
127
+ if (yearStartDate > nowDate) {
128
+ yearStartDate.setFullYear(currentYear - 1);
129
+ }
130
+ }
131
+ }
132
+
133
+ if (!yearStartDate) {
134
+ // Fallback: January 1st of current year
135
+ const nowDate = new Date(now);
136
+ yearStartDate = new Date(nowDate.getFullYear(), 0, 1, 12, 0, 0);
137
+ this.adapter.log.info(
138
+ `${type}: No contract start found. Setting initial year start to January 1st: ${yearStartDate.toLocaleDateString('de-DE')}`,
139
+ );
140
+ }
141
+
142
+ await this.adapter.setStateAsync(`${type}.statistics.lastYearStart`, yearStartDate.getTime(), true);
143
+ }
144
+ // Update current price
145
+ await this.updateCurrentPrice(type);
146
+
147
+ // Initial cost calculation (wichtig! Sonst bleiben Kosten bei 0)
148
+ if (typeof this.adapter.updateCosts === 'function') {
149
+ await this.adapter.updateCosts(type);
150
+ }
151
+
152
+ // Initialize yearly consumption from initial reading if set
153
+ const initialReadingKey = `${configType}InitialReading`;
154
+ const initialReading = this.adapter.config[initialReadingKey] || 0;
155
+
156
+ if (initialReading > 0) {
157
+ const sensorState = await this.adapter.getForeignStateAsync(sensorDP);
158
+ if (sensorState && typeof sensorState.val === 'number') {
159
+ let currentRaw = sensorState.val;
160
+
161
+ // Apply offset if configured (in original unit)
162
+ const offsetKey = `${configType}Offset`;
163
+ const offset = this.adapter.config[offsetKey] || 0;
164
+ if (offset !== 0) {
165
+ currentRaw = currentRaw - offset;
166
+ this.adapter.log.debug(`Applied offset for ${type}: -${offset}, new value: ${currentRaw}`);
167
+ }
168
+ let yearlyConsumption = Math.max(0, currentRaw - initialReading);
169
+
170
+ // For gas: convert m³ to kWh AFTER calculating the difference
171
+ if (type === 'gas') {
172
+ const brennwert = this.adapter.config.gasBrennwert || 11.5;
173
+ const zZahl = this.adapter.config.gasZahl || 0.95;
174
+ const yearlyVolume = yearlyConsumption;
175
+ yearlyConsumption = calculator.convertGasM3ToKWh(yearlyConsumption, brennwert, zZahl);
176
+ await this.adapter.setStateAsync(`${type}.consumption.yearlyVolume`, yearlyVolume, true);
177
+ this.adapter.log.info(
178
+ `Init yearly ${type}: ${yearlyConsumption.toFixed(2)} kWh = ${(currentRaw - initialReading).toFixed(2)} m³ (current: ${currentRaw.toFixed(2)} m³, initial: ${initialReading} m³)`,
179
+ );
180
+ } else {
181
+ this.adapter.log.info(
182
+ `Init yearly ${type}: ${yearlyConsumption.toFixed(2)} (current: ${currentRaw.toFixed(2)}, initial: ${initialReading})`,
183
+ );
184
+ }
185
+
186
+ await this.adapter.setStateAsync(`${type}.consumption.yearly`, yearlyConsumption, true);
187
+ if (typeof this.adapter.updateCosts === 'function') {
188
+ await this.adapter.updateCosts(type);
189
+ }
190
+ }
191
+ }
192
+
193
+ // Update billing countdown
194
+ if (typeof this.adapter.updateBillingCountdown === 'function') {
195
+ await this.adapter.updateBillingCountdown(type);
196
+ }
197
+
198
+ this.adapter.log.debug(`Initial cost calculation completed for ${type}`);
199
+ }
200
+
201
+ /**
202
+ * Handles sensor value updates
203
+ *
204
+ * @param {string} type - Utility type
205
+ * @param {string} sensorDP - Sensor datapoint ID
206
+ * @param {number} value - New sensor value
207
+ */
208
+ async handleSensorUpdate(type, sensorDP, value) {
209
+ if (typeof value !== 'number' || value < 0) {
210
+ this.adapter.log.warn(`Invalid sensor value for ${type}: ${value}`);
211
+ return;
212
+ }
213
+
214
+ this.adapter.log.debug(`Sensor update for ${type}: ${value}`);
215
+
216
+ const now = Date.now();
217
+ let consumption = value;
218
+ let consumptionM3 = null;
219
+
220
+ const configType = this.getConfigType(type);
221
+
222
+ // Apply offset FIRST
223
+ const offsetKey = `${configType}Offset`;
224
+ const offset = this.adapter.config[offsetKey] || 0;
225
+ if (offset !== 0) {
226
+ consumption = consumption - offset;
227
+ this.adapter.log.debug(`Applied offset for ${type}: -${offset}, new value: ${consumption}`);
228
+ }
229
+
230
+ // For gas, convert m³ to kWh
231
+ if (type === 'gas') {
232
+ const brennwert = this.adapter.config.gasBrennwert || 11.5;
233
+ const zZahl = this.adapter.config.gasZahl || 0.95;
234
+ consumptionM3 = consumption;
235
+ await this.adapter.setStateAsync(`${type}.info.meterReadingVolume`, consumption, true);
236
+ consumption = calculator.convertGasM3ToKWh(consumption, brennwert, zZahl);
237
+ consumption = calculator.roundToDecimals(consumption, 2);
238
+ }
239
+
240
+ // Update meter reading
241
+ await this.adapter.setStateAsync(`${type}.info.meterReading`, consumption, true);
242
+
243
+ // Calculate deltas
244
+ const lastValue = this.lastSensorValues[sensorDP];
245
+ this.lastSensorValues[sensorDP] = consumption;
246
+
247
+ if (lastValue === undefined || consumption <= lastValue) {
248
+ if (lastValue !== undefined && consumption < lastValue) {
249
+ this.adapter.log.warn(
250
+ `${type}: Sensor value decreased (${lastValue} -> ${consumption}). Assuming meter reset or replacement.`,
251
+ );
252
+ }
253
+ if (typeof this.adapter.updateCosts === 'function') {
254
+ await this.adapter.updateCosts(type);
255
+ }
256
+ return;
257
+ }
258
+
259
+ const delta = consumption - lastValue;
260
+ this.adapter.log.debug(`${type} delta: ${delta}`);
261
+
262
+ // Track volume for gas
263
+ if (type === 'gas') {
264
+ const brennwert = this.adapter.config.gasBrennwert || 11.5;
265
+ const zZahl = this.adapter.config.gasZahl || 0.95;
266
+ const deltaVolume = delta / (brennwert * zZahl);
267
+
268
+ const dailyVolume = await this.adapter.getStateAsync(`${type}.consumption.dailyVolume`);
269
+ const monthlyVolume = await this.adapter.getStateAsync(`${type}.consumption.monthlyVolume`);
270
+ const yearlyVolume = await this.adapter.getStateAsync(`${type}.consumption.yearlyVolume`);
271
+
272
+ await this.adapter.setStateAsync(
273
+ `${type}.consumption.dailyVolume`,
274
+ calculator.roundToDecimals((dailyVolume?.val || 0) + deltaVolume, 2),
275
+ true,
276
+ );
277
+ await this.adapter.setStateAsync(
278
+ `${type}.consumption.monthlyVolume`,
279
+ calculator.roundToDecimals((monthlyVolume?.val || 0) + deltaVolume, 2),
280
+ true,
281
+ );
282
+ await this.adapter.setStateAsync(
283
+ `${type}.consumption.yearlyVolume`,
284
+ calculator.roundToDecimals((yearlyVolume?.val || 0) + deltaVolume, 3),
285
+ true,
286
+ );
287
+ }
288
+
289
+ // Update consumption values
290
+ const dailyState = await this.adapter.getStateAsync(`${type}.consumption.daily`);
291
+ await this.adapter.setStateAsync(
292
+ `${type}.consumption.daily`,
293
+ calculator.roundToDecimals((dailyState?.val || 0) + delta, 2),
294
+ true,
295
+ );
296
+
297
+ const monthlyState = await this.adapter.getStateAsync(`${type}.consumption.monthly`);
298
+ await this.adapter.setStateAsync(
299
+ `${type}.consumption.monthly`,
300
+ calculator.roundToDecimals((monthlyState?.val || 0) + delta, 2),
301
+ true,
302
+ );
303
+
304
+ // HT/NT tracking
305
+ const htNtEnabledKey = `${configType}HtNtEnabled`;
306
+ if (this.adapter.config[htNtEnabledKey]) {
307
+ const isHT = calculator.isHTTime(this.adapter.config, configType);
308
+ const suffix = isHT ? 'HT' : 'NT';
309
+
310
+ const dHTNT = await this.adapter.getStateAsync(`${type}.consumption.daily${suffix}`);
311
+ await this.adapter.setStateAsync(
312
+ `${type}.consumption.daily${suffix}`,
313
+ calculator.roundToDecimals((dHTNT?.val || 0) + delta, 2),
314
+ true,
315
+ );
316
+
317
+ const mHTNT = await this.adapter.getStateAsync(`${type}.consumption.monthly${suffix}`);
318
+ await this.adapter.setStateAsync(
319
+ `${type}.consumption.monthly${suffix}`,
320
+ calculator.roundToDecimals((mHTNT?.val || 0) + delta, 2),
321
+ true,
322
+ );
323
+
324
+ const yHTNT = await this.adapter.getStateAsync(`${type}.consumption.yearly${suffix}`);
325
+ await this.adapter.setStateAsync(
326
+ `${type}.consumption.yearly${suffix}`,
327
+ calculator.roundToDecimals((yHTNT?.val || 0) + delta, 2),
328
+ true,
329
+ );
330
+ }
331
+
332
+ // Yearly consumption
333
+ const initialReadingKey = `${configType}InitialReading`;
334
+ const initialReading = this.adapter.config[initialReadingKey] || 0;
335
+
336
+ if (initialReading > 0) {
337
+ let yearlyAmount;
338
+ if (type === 'gas') {
339
+ const yearlyM3 = Math.max(0, (consumptionM3 || 0) - initialReading);
340
+ await this.adapter.setStateAsync(
341
+ `${type}.consumption.yearlyVolume`,
342
+ calculator.roundToDecimals(yearlyM3, 2),
343
+ true,
344
+ );
345
+ const brennwert = this.adapter.config.gasBrennwert || 11.5;
346
+ const zZahl = this.adapter.config.gasZahl || 0.95;
347
+ yearlyAmount = calculator.convertGasM3ToKWh(yearlyM3, brennwert, zZahl);
348
+ } else {
349
+ yearlyAmount = Math.max(0, consumption - initialReading);
350
+ }
351
+ await this.adapter.setStateAsync(
352
+ `${type}.consumption.yearly`,
353
+ calculator.roundToDecimals(yearlyAmount, 2),
354
+ true,
355
+ );
356
+ } else {
357
+ const yState = await this.adapter.getStateAsync(`${type}.consumption.yearly`);
358
+ await this.adapter.setStateAsync(
359
+ `${type}.consumption.yearly`,
360
+ calculator.roundToDecimals((yState?.val || 0) + delta, 2),
361
+ true,
362
+ );
363
+ }
364
+
365
+ if (typeof this.adapter.updateCosts === 'function') {
366
+ await this.adapter.updateCosts(type);
367
+ }
368
+
369
+ await this.adapter.setStateAsync(`${type}.consumption.lastUpdate`, now, true);
370
+ await this.adapter.setStateAsync(`${type}.info.lastSync`, now, true);
371
+ }
372
+
373
+ /**
374
+ * Updates the current price display
375
+ *
376
+ * @param {string} type - Utility type
377
+ */
378
+ async updateCurrentPrice(type) {
379
+ const configType = this.getConfigType(type);
380
+
381
+ // Check for HT/NT
382
+ const htNtEnabledKey = `${configType}HtNtEnabled`;
383
+ const htNtEnabled = this.adapter.config[htNtEnabledKey] || false;
384
+
385
+ let tariffName = 'Standard';
386
+ let activePrice = 0;
387
+
388
+ if (htNtEnabled) {
389
+ const isHT = calculator.isHTTime(this.adapter.config, configType);
390
+ if (isHT) {
391
+ activePrice = this.adapter.config[`${configType}HtPrice`] || 0;
392
+ tariffName = 'Haupttarif (HT)';
393
+ } else {
394
+ activePrice = this.adapter.config[`${configType}NtPrice`] || 0;
395
+ tariffName = 'Nebentarif (NT)';
396
+ }
397
+ } else {
398
+ const priceKey = `${configType}Preis`;
399
+ activePrice = this.adapter.config[priceKey] || 0;
400
+ }
401
+
402
+ await this.adapter.setStateAsync(`${type}.info.currentPrice`, calculator.roundToDecimals(activePrice, 4), true);
403
+ await this.adapter.setStateAsync(`${type}.info.currentTariff`, tariffName, true);
404
+ }
405
+ }
406
+
407
+ module.exports = ConsumptionManager;