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,749 @@
1
+ 'use strict';
2
+
3
+ const calculator = require('./calculator');
4
+ const stateManager = require('./stateManager');
5
+ const { parseConfigNumber } = require('./configParser');
6
+
7
+ /**
8
+ * MultiMeterManager handles multiple meters per utility type
9
+ * Supports unlimited custom-named meters (e.g., "Erdgeschoss", "Werkstatt")
10
+ */
11
+ class MultiMeterManager {
12
+ /**
13
+ * @param {object} adapter - ioBroker adapter instance
14
+ * @param {object} consumptionManager - ConsumptionManager instance
15
+ * @param {object} billingManager - BillingManager instance
16
+ */
17
+ constructor(adapter, consumptionManager, billingManager) {
18
+ this.adapter = adapter;
19
+ this.consumptionManager = consumptionManager;
20
+ this.billingManager = billingManager;
21
+ this.lastSensorValues = {};
22
+ this.meterRegistry = {}; // Maps sensorDP → {type, meterName}
23
+ }
24
+
25
+ /**
26
+ * Maps internal utility type to config/state name
27
+ *
28
+ * @param {string} type - gas, water, or electricity
29
+ * @returns {string} - gas, wasser, or strom
30
+ */
31
+ getConfigType(type) {
32
+ const mapping = {
33
+ electricity: 'strom',
34
+ water: 'wasser',
35
+ gas: 'gas',
36
+ };
37
+ return mapping[type] || type;
38
+ }
39
+
40
+ /**
41
+ * Normalizes meter name to valid folder name
42
+ * Rules: lowercase, alphanumeric only, max 20 chars
43
+ *
44
+ * @param {string} name - User-provided meter name
45
+ * @returns {string} - Normalized name
46
+ */
47
+ normalizeMeterName(name) {
48
+ if (!name || typeof name !== 'string') {
49
+ return 'unnamed';
50
+ }
51
+ return (
52
+ name
53
+ .toLowerCase()
54
+ .replace(/[^a-z0-9]/g, '')
55
+ .substring(0, 20) || 'unnamed'
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Gets all configured meters for a utility type
61
+ * Returns array with main meter + additional meters
62
+ *
63
+ * @param {string} type - Utility type
64
+ * @returns {Array} - Array of {name, config} objects
65
+ */
66
+ getMetersForType(type) {
67
+ const configType = this.getConfigType(type);
68
+ const meters = [];
69
+
70
+ // Main meter (always present if type is active)
71
+ const mainActive = this.adapter.config[`${configType}Aktiv`];
72
+ if (mainActive) {
73
+ meters.push({
74
+ name: 'main',
75
+ config: {
76
+ sensorDP: this.adapter.config[`${configType}SensorDP`],
77
+ preis: parseConfigNumber(this.adapter.config[`${configType}Preis`], 0),
78
+ offset: parseConfigNumber(this.adapter.config[`${configType}Offset`], 0),
79
+ initialReading: parseConfigNumber(this.adapter.config[`${configType}InitialReading`], 0),
80
+ contractStart: this.adapter.config[`${configType}ContractStart`],
81
+ grundgebuehr: parseConfigNumber(this.adapter.config[`${configType}Grundgebuehr`], 0),
82
+ jahresgebuehr: parseConfigNumber(this.adapter.config[`${configType}Jahresgebuehr`], 0),
83
+ abschlag: parseConfigNumber(this.adapter.config[`${configType}Abschlag`], 0),
84
+ htNtEnabled: this.adapter.config[`${configType}HtNtEnabled`] || false,
85
+ },
86
+ });
87
+ }
88
+
89
+ // Additional meters from array config
90
+ const additionalMeters = this.adapter.config[`${configType}AdditionalMeters`];
91
+ if (Array.isArray(additionalMeters)) {
92
+ for (const meterConfig of additionalMeters) {
93
+ if (meterConfig && meterConfig.name && meterConfig.sensorDP) {
94
+ const normalizedName = this.normalizeMeterName(meterConfig.name);
95
+
96
+ // Debug: Log raw config for troubleshooting
97
+ this.adapter.log.debug(
98
+ `[${type}] Meter "${normalizedName}": preis=${meterConfig.preis} (${typeof meterConfig.preis}), grundgebuehr=${meterConfig.grundgebuehr} (${typeof meterConfig.grundgebuehr}), abschlag=${meterConfig.abschlag} (${typeof meterConfig.abschlag})`,
99
+ );
100
+
101
+ const parsedConfig = {
102
+ sensorDP: meterConfig.sensorDP,
103
+ preis: parseConfigNumber(meterConfig.preis, 0),
104
+ offset: parseConfigNumber(meterConfig.offset, 0),
105
+ initialReading: parseConfigNumber(meterConfig.initialReading, 0),
106
+ contractStart: meterConfig.contractStart,
107
+ grundgebuehr: parseConfigNumber(meterConfig.grundgebuehr, 0),
108
+ jahresgebuehr: parseConfigNumber(meterConfig.jahresgebuehr, 0),
109
+ abschlag: parseConfigNumber(meterConfig.abschlag, 0),
110
+ htNtEnabled: false, // Additional meters don't support HT/NT
111
+ };
112
+
113
+ meters.push({
114
+ name: normalizedName,
115
+ displayName: meterConfig.name,
116
+ config: parsedConfig,
117
+ });
118
+ }
119
+ }
120
+ }
121
+
122
+ return meters;
123
+ }
124
+
125
+ /**
126
+ * Finds meter by sensor datapoint
127
+ *
128
+ * @param {string} sensorDP - Sensor datapoint ID
129
+ * @returns {object|null} - {type, meterName} or null
130
+ */
131
+ findMeterBySensor(sensorDP) {
132
+ return this.meterRegistry[sensorDP] || null;
133
+ }
134
+
135
+ /**
136
+ * Initializes all meters for a utility type
137
+ *
138
+ * @param {string} type - Utility type
139
+ * @returns {Promise<void>}
140
+ */
141
+ async initializeType(type) {
142
+ const meters = this.getMetersForType(type);
143
+
144
+ if (meters.length === 0) {
145
+ this.adapter.log.debug(`No meters configured for ${type}`);
146
+ return;
147
+ }
148
+
149
+ this.adapter.log.info(`Initializing ${meters.length} meter(s) for ${type}`);
150
+
151
+ // Initialize each meter
152
+ for (const meter of meters) {
153
+ await this.initializeMeter(type, meter.name, meter.config, meter.displayName);
154
+ }
155
+
156
+ // Create totals structure if multiple meters exist
157
+ if (meters.length > 1) {
158
+ await stateManager.createTotalsStructure(this.adapter, type);
159
+ await this.updateTotalCosts(type);
160
+ }
161
+
162
+ // Cleanup removed meters
163
+ await this.cleanupRemovedMeters(type, meters);
164
+ }
165
+
166
+ /**
167
+ * Initializes a specific meter
168
+ *
169
+ * @param {string} type - Utility type
170
+ * @param {string} meterName - Meter name ('main' or normalized custom name)
171
+ * @param {object} config - Meter configuration
172
+ * @param {string} displayName - Original display name (optional)
173
+ * @returns {Promise<void>}
174
+ */
175
+ async initializeMeter(type, meterName, config, displayName) {
176
+ const basePath = meterName === 'main' ? type : `${type}.${meterName}`;
177
+ const label = displayName || meterName;
178
+
179
+ this.adapter.log.info(`Initializing ${type} meter: ${label}`);
180
+
181
+ if (!config.sensorDP) {
182
+ this.adapter.log.warn(`${type} meter "${label}" has no sensor datapoint configured!`);
183
+ await this.adapter.setStateAsync(`${basePath}.info.sensorActive`, false, true);
184
+ return;
185
+ }
186
+
187
+ // Create state structure
188
+ await stateManager.createMeterStructure(this.adapter, type, meterName, config);
189
+
190
+ // Register sensor in registry
191
+ this.meterRegistry[config.sensorDP] = { type, meterName };
192
+
193
+ this.adapter.log.debug(`Using sensor datapoint for ${type}.${meterName}: ${config.sensorDP}`);
194
+
195
+ // Log configured contract start
196
+ if (config.contractStart) {
197
+ this.adapter.log.info(`${type}.${meterName}: Contract start: ${config.contractStart}`);
198
+ }
199
+
200
+ // Subscribe to sensor datapoint
201
+ this.adapter.subscribeForeignStates(config.sensorDP);
202
+ await this.adapter.setStateAsync(`${basePath}.info.sensorActive`, true, true);
203
+ this.adapter.log.debug(`Subscribed to ${type}.${meterName} sensor: ${config.sensorDP}`);
204
+
205
+ // Restore last sensor value from persistent state
206
+ const lastReading = await this.adapter.getStateAsync(`${basePath}.info.meterReading`);
207
+ if (lastReading && typeof lastReading.val === 'number') {
208
+ this.lastSensorValues[config.sensorDP] = lastReading.val;
209
+ this.adapter.log.debug(`${type}.${meterName}: Restored last sensor value: ${lastReading.val}`);
210
+ }
211
+
212
+ // Initialize with current sensor value
213
+ try {
214
+ const sensorState = await this.adapter.getForeignStateAsync(config.sensorDP);
215
+ if (sensorState && sensorState.val !== null && typeof sensorState.val === 'number') {
216
+ await this.handleSensorUpdate(type, meterName, config.sensorDP, sensorState.val);
217
+ }
218
+ } catch (error) {
219
+ this.adapter.log.warn(`Could not read initial value from ${config.sensorDP}: ${error.message}`);
220
+ await this.adapter.setStateAsync(`${basePath}.info.sensorActive`, false, true);
221
+ }
222
+
223
+ // Initialize period start timestamps
224
+ const nowIso = calculator.formatDateString(new Date());
225
+ const timestampRoles = ['lastDayStart', 'lastMonthStart', 'lastYearStart'];
226
+
227
+ for (const role of timestampRoles) {
228
+ const statePath = `${basePath}.statistics.${role}`;
229
+ const state = await this.adapter.getStateAsync(statePath);
230
+
231
+ if (!state || !state.val || typeof state.val === 'number') {
232
+ if (role === 'lastYearStart' && (!state || !state.val)) {
233
+ const contractStart = calculator.parseGermanDate(config.contractStart);
234
+ let yearStartDate;
235
+
236
+ if (contractStart && !isNaN(contractStart.getTime())) {
237
+ const now = new Date();
238
+ yearStartDate = new Date(
239
+ now.getFullYear(),
240
+ contractStart.getMonth(),
241
+ contractStart.getDate(),
242
+ 12,
243
+ 0,
244
+ 0,
245
+ );
246
+
247
+ if (yearStartDate > now) {
248
+ yearStartDate.setFullYear(now.getFullYear() - 1);
249
+ }
250
+ }
251
+
252
+ if (!yearStartDate) {
253
+ yearStartDate = new Date(new Date().getFullYear(), 0, 1, 12, 0, 0);
254
+ }
255
+ await this.adapter.setStateAsync(statePath, calculator.formatDateString(yearStartDate), true);
256
+ } else if (typeof state?.val === 'number') {
257
+ await this.adapter.setStateAsync(statePath, calculator.formatDateString(new Date(state.val)), true);
258
+ } else {
259
+ await this.adapter.setStateAsync(statePath, nowIso, true);
260
+ }
261
+ }
262
+ }
263
+
264
+ // Initialize yearly consumption from initial reading if set
265
+ if (config.initialReading > 0) {
266
+ const sensorState = await this.adapter.getForeignStateAsync(config.sensorDP);
267
+ if (sensorState && typeof sensorState.val === 'number') {
268
+ let currentRaw = sensorState.val;
269
+
270
+ if (config.offset !== 0) {
271
+ currentRaw = currentRaw - config.offset;
272
+ this.adapter.log.debug(
273
+ `Applied offset for ${type}.${meterName}: -${config.offset}, new value: ${currentRaw}`,
274
+ );
275
+ }
276
+ let yearlyConsumption = Math.max(0, currentRaw - config.initialReading);
277
+
278
+ // For gas: convert m³ to kWh
279
+ if (type === 'gas') {
280
+ const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
281
+ const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
282
+ const yearlyVolume = yearlyConsumption;
283
+ yearlyConsumption = calculator.convertGasM3ToKWh(yearlyConsumption, brennwert, zZahl);
284
+ await this.adapter.setStateAsync(`${basePath}.consumption.yearlyVolume`, yearlyVolume, true);
285
+ this.adapter.log.info(
286
+ `Init yearly ${type}.${meterName}: ${yearlyConsumption.toFixed(2)} kWh = ${(currentRaw - config.initialReading).toFixed(2)} m³`,
287
+ );
288
+ } else {
289
+ this.adapter.log.info(`Init yearly ${type}.${meterName}: ${yearlyConsumption.toFixed(2)}`);
290
+ }
291
+
292
+ await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, yearlyConsumption, true);
293
+ }
294
+ }
295
+
296
+ // Update current price
297
+ await this.updateCurrentPrice(type, meterName, config);
298
+
299
+ // Update billing countdown
300
+ if (config.contractStart) {
301
+ const startDate = calculator.parseGermanDate(config.contractStart);
302
+ if (startDate) {
303
+ const today = new Date();
304
+ const nextAnniversary = new Date(startDate);
305
+ nextAnniversary.setFullYear(today.getFullYear());
306
+
307
+ if (nextAnniversary < today) {
308
+ nextAnniversary.setFullYear(today.getFullYear() + 1);
309
+ }
310
+
311
+ const msPerDay = 1000 * 60 * 60 * 24;
312
+ const daysRemaining = Math.ceil((nextAnniversary.getTime() - today.getTime()) / msPerDay);
313
+ const displayPeriodEnd = new Date(nextAnniversary);
314
+ displayPeriodEnd.setDate(displayPeriodEnd.getDate() - 1);
315
+
316
+ this.adapter.log.debug(
317
+ `[${basePath}] Billing countdown: contractStart=${config.contractStart}, daysRemaining=${daysRemaining}, periodEnd=${displayPeriodEnd.toLocaleDateString('de-DE')}`,
318
+ );
319
+
320
+ await this.adapter.setStateAsync(`${basePath}.billing.daysRemaining`, daysRemaining, true);
321
+ await this.adapter.setStateAsync(
322
+ `${basePath}.billing.periodEnd`,
323
+ displayPeriodEnd.toLocaleDateString('de-DE'),
324
+ true,
325
+ );
326
+ }
327
+ }
328
+
329
+ // Initial cost calculation
330
+ await this.updateCosts(type, meterName, config);
331
+
332
+ this.adapter.log.debug(`Meter initialization completed for ${type}.${meterName}`);
333
+ }
334
+
335
+ /**
336
+ * Handles sensor value updates
337
+ *
338
+ * @param {string} type - Utility type
339
+ * @param {string} meterName - Meter name
340
+ * @param {string} sensorDP - Sensor datapoint ID
341
+ * @param {number} value - New sensor value
342
+ */
343
+ async handleSensorUpdate(type, meterName, sensorDP, value) {
344
+ if (typeof value !== 'number' || value < 0) {
345
+ this.adapter.log.warn(`Invalid sensor value for ${type}.${meterName}: ${value}`);
346
+ return;
347
+ }
348
+
349
+ const basePath = meterName === 'main' ? type : `${type}.${meterName}`;
350
+ this.adapter.log.debug(`Sensor update for ${basePath}: ${value}`);
351
+
352
+ // Get meter config
353
+ const meters = this.getMetersForType(type);
354
+ const meter = meters.find(m => m.name === meterName);
355
+ if (!meter) {
356
+ this.adapter.log.warn(`Meter ${type}.${meterName} not found in configuration`);
357
+ return;
358
+ }
359
+
360
+ const config = meter.config;
361
+ const now = Date.now();
362
+ let consumption = value;
363
+ let consumptionM3 = null;
364
+
365
+ this.adapter.log.debug(`[${basePath}] Sensor update: raw=${value}, offset=${config.offset}`);
366
+
367
+ // Apply offset FIRST
368
+ if (config.offset !== 0) {
369
+ consumption = consumption - config.offset;
370
+ this.adapter.log.debug(`[${basePath}] After offset: ${consumption}`);
371
+ }
372
+
373
+ // For gas, convert m³ to kWh
374
+ if (type === 'gas') {
375
+ const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
376
+ const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
377
+ consumptionM3 = consumption;
378
+ await this.adapter.setStateAsync(`${basePath}.info.meterReadingVolume`, consumption, true);
379
+ consumption = calculator.convertGasM3ToKWh(consumption, brennwert, zZahl);
380
+ consumption = calculator.roundToDecimals(consumption, 2);
381
+ }
382
+
383
+ // Update meter reading
384
+ await this.adapter.setStateAsync(`${basePath}.info.meterReading`, consumption, true);
385
+
386
+ // Calculate deltas
387
+ const lastValue = this.lastSensorValues[sensorDP];
388
+ this.lastSensorValues[sensorDP] = consumption;
389
+
390
+ if (lastValue === undefined || consumption <= lastValue) {
391
+ if (lastValue !== undefined && consumption < lastValue) {
392
+ this.adapter.log.warn(
393
+ `${type}.${meterName}: Sensor value decreased (${lastValue} -> ${consumption}). Assuming meter reset.`,
394
+ );
395
+ }
396
+ await this.updateCosts(type, meterName, config);
397
+ await this.updateTotalCosts(type);
398
+ return;
399
+ }
400
+
401
+ const delta = consumption - lastValue;
402
+ this.adapter.log.debug(`${type}.${meterName} delta: ${delta}`);
403
+
404
+ // Track volume for gas
405
+ if (type === 'gas') {
406
+ const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
407
+ const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
408
+ const deltaVolume = delta / (brennwert * zZahl);
409
+
410
+ const dailyVolume = await this.adapter.getStateAsync(`${basePath}.consumption.dailyVolume`);
411
+ const monthlyVolume = await this.adapter.getStateAsync(`${basePath}.consumption.monthlyVolume`);
412
+ const yearlyVolume = await this.adapter.getStateAsync(`${basePath}.consumption.yearlyVolume`);
413
+
414
+ await this.adapter.setStateAsync(
415
+ `${basePath}.consumption.dailyVolume`,
416
+ calculator.roundToDecimals((dailyVolume?.val || 0) + deltaVolume, 2),
417
+ true,
418
+ );
419
+ await this.adapter.setStateAsync(
420
+ `${basePath}.consumption.monthlyVolume`,
421
+ calculator.roundToDecimals((monthlyVolume?.val || 0) + deltaVolume, 2),
422
+ true,
423
+ );
424
+ await this.adapter.setStateAsync(
425
+ `${basePath}.consumption.yearlyVolume`,
426
+ calculator.roundToDecimals((yearlyVolume?.val || 0) + deltaVolume, 3),
427
+ true,
428
+ );
429
+ }
430
+
431
+ // Update consumption values
432
+ const dailyState = await this.adapter.getStateAsync(`${basePath}.consumption.daily`);
433
+ await this.adapter.setStateAsync(
434
+ `${basePath}.consumption.daily`,
435
+ calculator.roundToDecimals((dailyState?.val || 0) + delta, 2),
436
+ true,
437
+ );
438
+
439
+ const monthlyState = await this.adapter.getStateAsync(`${basePath}.consumption.monthly`);
440
+ await this.adapter.setStateAsync(
441
+ `${basePath}.consumption.monthly`,
442
+ calculator.roundToDecimals((monthlyState?.val || 0) + delta, 2),
443
+ true,
444
+ );
445
+
446
+ // Yearly consumption
447
+ if (config.initialReading > 0) {
448
+ let yearlyAmount;
449
+ if (type === 'gas') {
450
+ const yearlyM3 = Math.max(0, (consumptionM3 || 0) - config.initialReading);
451
+ await this.adapter.setStateAsync(
452
+ `${basePath}.consumption.yearlyVolume`,
453
+ calculator.roundToDecimals(yearlyM3, 2),
454
+ true,
455
+ );
456
+ const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
457
+ const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
458
+ yearlyAmount = calculator.convertGasM3ToKWh(yearlyM3, brennwert, zZahl);
459
+ } else {
460
+ yearlyAmount = Math.max(0, consumption - config.initialReading);
461
+ }
462
+ await this.adapter.setStateAsync(
463
+ `${basePath}.consumption.yearly`,
464
+ calculator.roundToDecimals(yearlyAmount, 2),
465
+ true,
466
+ );
467
+ } else {
468
+ const yState = await this.adapter.getStateAsync(`${basePath}.consumption.yearly`);
469
+ await this.adapter.setStateAsync(
470
+ `${basePath}.consumption.yearly`,
471
+ calculator.roundToDecimals((yState?.val || 0) + delta, 2),
472
+ true,
473
+ );
474
+ }
475
+
476
+ await this.updateCosts(type, meterName, config);
477
+ await this.updateTotalCosts(type);
478
+
479
+ await this.adapter.setStateAsync(`${basePath}.consumption.lastUpdate`, now, true);
480
+ await this.adapter.setStateAsync(`${basePath}.info.lastSync`, now, true);
481
+ }
482
+
483
+ /**
484
+ * Updates the current price display
485
+ *
486
+ * @param {string} type - Utility type
487
+ * @param {string} meterName - Meter name
488
+ * @param {object} config - Meter configuration
489
+ */
490
+ async updateCurrentPrice(type, meterName, config) {
491
+ const basePath = meterName === 'main' ? type : `${type}.${meterName}`;
492
+ const configType = this.getConfigType(type);
493
+
494
+ let tariffName = 'Standard';
495
+ let activePrice = config.preis || 0;
496
+
497
+ // Only main meter supports HT/NT
498
+ if (meterName === 'main' && config.htNtEnabled) {
499
+ const isHT = calculator.isHTTime(this.adapter.config, configType);
500
+ if (isHT) {
501
+ activePrice = this.adapter.config[`${configType}HtPrice`] || 0;
502
+ tariffName = 'Haupttarif (HT)';
503
+ } else {
504
+ activePrice = this.adapter.config[`${configType}NtPrice`] || 0;
505
+ tariffName = 'Nebentarif (NT)';
506
+ }
507
+ this.adapter.log.debug(`[${basePath}] Price update: tariff=${tariffName}, price=${activePrice}`);
508
+ }
509
+
510
+ await this.adapter.setStateAsync(
511
+ `${basePath}.info.currentPrice`,
512
+ calculator.roundToDecimals(activePrice, 4),
513
+ true,
514
+ );
515
+ await this.adapter.setStateAsync(`${basePath}.info.currentTariff`, tariffName, true);
516
+ }
517
+
518
+ /**
519
+ * Updates costs for a specific meter
520
+ *
521
+ * @param {string} type - Utility type
522
+ * @param {string} meterName - Meter name
523
+ * @param {object} config - Meter configuration
524
+ */
525
+ async updateCosts(type, meterName, config) {
526
+ const basePath = meterName === 'main' ? type : `${type}.${meterName}`;
527
+
528
+ // Get consumption values
529
+ const daily = (await this.adapter.getStateAsync(`${basePath}.consumption.daily`))?.val || 0;
530
+ const monthly = (await this.adapter.getStateAsync(`${basePath}.consumption.monthly`))?.val || 0;
531
+ const yearly = (await this.adapter.getStateAsync(`${basePath}.consumption.yearly`))?.val || 0;
532
+
533
+ // Get price
534
+ const price = config.preis || 0;
535
+
536
+ this.adapter.log.debug(
537
+ `[${basePath}] Cost update: daily=${daily}, monthly=${monthly}, yearly=${yearly}, price=${price}`,
538
+ );
539
+
540
+ // Calculate consumption costs
541
+ const dailyCost = daily * price;
542
+ const monthlyCost = monthly * price;
543
+ const yearlyCost = yearly * price;
544
+
545
+ await this.adapter.setStateAsync(`${basePath}.costs.daily`, calculator.roundToDecimals(dailyCost, 2), true);
546
+ await this.adapter.setStateAsync(`${basePath}.costs.monthly`, calculator.roundToDecimals(monthlyCost, 2), true);
547
+ await this.adapter.setStateAsync(`${basePath}.costs.yearly`, calculator.roundToDecimals(yearlyCost, 2), true);
548
+
549
+ await this.adapter.setStateAsync(`${basePath}.costs.basicCharge`, Number(config.grundgebuehr) || 0, true);
550
+
551
+ // Calculate annual fee (prorated)
552
+ const yearStartState = await this.adapter.getStateAsync(`${basePath}.statistics.lastYearStart`);
553
+ let annualFeeAccumulated = 0;
554
+
555
+ if (yearStartState && yearStartState.val) {
556
+ const yearStartDate = calculator.parseDateString(yearStartState.val);
557
+ if (yearStartDate && !isNaN(yearStartDate.getTime())) {
558
+ const now = new Date();
559
+ const yearStartTime = yearStartDate.getTime();
560
+ const nowTime = now.getTime();
561
+ const daysSinceYearStart = Math.floor((nowTime - yearStartTime) / (1000 * 60 * 60 * 24));
562
+ const daysInYear = calculator.isLeapYear(now.getFullYear()) ? 366 : 365;
563
+ annualFeeAccumulated = ((config.jahresgebuehr || 0) / daysInYear) * daysSinceYearStart;
564
+ }
565
+ }
566
+
567
+ await this.adapter.setStateAsync(
568
+ `${basePath}.costs.annualFee`,
569
+ calculator.roundToDecimals(annualFeeAccumulated, 2),
570
+ true,
571
+ );
572
+
573
+ // Calculate balance and total yearly costs
574
+ if (yearStartState && yearStartState.val) {
575
+ const yearStartDate = calculator.parseDateString(yearStartState.val);
576
+ if (yearStartDate && !isNaN(yearStartDate.getTime())) {
577
+ const now = new Date();
578
+ // Calculate paid total based on started months (not just completed months)
579
+ // If current month has started, count it as paid
580
+ const monthsSinceYearStart = calculator.getMonthsDifference(yearStartDate, now) + 1;
581
+
582
+ // Calculate total yearly costs with correct months
583
+ const basicChargeAccumulated = (config.grundgebuehr || 0) * monthsSinceYearStart;
584
+ const totalYearlyCost = yearlyCost + basicChargeAccumulated + annualFeeAccumulated;
585
+
586
+ await this.adapter.setStateAsync(
587
+ `${basePath}.costs.totalYearly`,
588
+ calculator.roundToDecimals(totalYearlyCost, 2),
589
+ true,
590
+ );
591
+
592
+ const paidTotal = (config.abschlag || 0) * monthsSinceYearStart;
593
+ const balance = paidTotal - totalYearlyCost;
594
+
595
+ this.adapter.log.debug(
596
+ `[${basePath}] Balance calculation: abschlag=${config.abschlag}, months=${monthsSinceYearStart}, paidTotal=${paidTotal.toFixed(2)}, totalYearly=${totalYearlyCost.toFixed(2)}, balance=${balance.toFixed(2)}`,
597
+ );
598
+
599
+ await this.adapter.setStateAsync(
600
+ `${basePath}.costs.paidTotal`,
601
+ calculator.roundToDecimals(paidTotal, 2),
602
+ true,
603
+ );
604
+ await this.adapter.setStateAsync(
605
+ `${basePath}.costs.balance`,
606
+ calculator.roundToDecimals(balance, 2),
607
+ true,
608
+ );
609
+ } else {
610
+ // Fallback if yearStartDate parsing fails
611
+ const totalYearlyCost = yearlyCost + annualFeeAccumulated;
612
+ await this.adapter.setStateAsync(
613
+ `${basePath}.costs.totalYearly`,
614
+ calculator.roundToDecimals(totalYearlyCost, 2),
615
+ true,
616
+ );
617
+ }
618
+ } else {
619
+ // Fallback if no yearStartState exists
620
+ const totalYearlyCost = yearlyCost + annualFeeAccumulated;
621
+ await this.adapter.setStateAsync(
622
+ `${basePath}.costs.totalYearly`,
623
+ calculator.roundToDecimals(totalYearlyCost, 2),
624
+ true,
625
+ );
626
+ }
627
+ }
628
+
629
+ /**
630
+ * Updates total costs (sum of all meters)
631
+ *
632
+ * @param {string} type - Utility type
633
+ */
634
+ async updateTotalCosts(type) {
635
+ const meters = this.getMetersForType(type);
636
+
637
+ if (meters.length <= 1) {
638
+ // No totals needed for single meter
639
+ return;
640
+ }
641
+
642
+ let totalDaily = 0;
643
+ let totalMonthly = 0;
644
+ let totalYearly = 0;
645
+ let totalCostsDaily = 0;
646
+ let totalCostsMonthly = 0;
647
+ let totalCostsYearly = 0;
648
+
649
+ for (const meter of meters) {
650
+ const basePath = meter.name === 'main' ? type : `${type}.${meter.name}`;
651
+
652
+ totalDaily += (await this.adapter.getStateAsync(`${basePath}.consumption.daily`))?.val || 0;
653
+ totalMonthly += (await this.adapter.getStateAsync(`${basePath}.consumption.monthly`))?.val || 0;
654
+ totalYearly += (await this.adapter.getStateAsync(`${basePath}.consumption.yearly`))?.val || 0;
655
+
656
+ totalCostsDaily += (await this.adapter.getStateAsync(`${basePath}.costs.daily`))?.val || 0;
657
+ totalCostsMonthly += (await this.adapter.getStateAsync(`${basePath}.costs.monthly`))?.val || 0;
658
+ totalCostsYearly += (await this.adapter.getStateAsync(`${basePath}.costs.totalYearly`))?.val || 0;
659
+ }
660
+
661
+ await this.adapter.setStateAsync(
662
+ `${type}.totals.consumption.daily`,
663
+ calculator.roundToDecimals(totalDaily, 2),
664
+ true,
665
+ );
666
+ await this.adapter.setStateAsync(
667
+ `${type}.totals.consumption.monthly`,
668
+ calculator.roundToDecimals(totalMonthly, 2),
669
+ true,
670
+ );
671
+ await this.adapter.setStateAsync(
672
+ `${type}.totals.consumption.yearly`,
673
+ calculator.roundToDecimals(totalYearly, 2),
674
+ true,
675
+ );
676
+
677
+ await this.adapter.setStateAsync(
678
+ `${type}.totals.costs.daily`,
679
+ calculator.roundToDecimals(totalCostsDaily, 2),
680
+ true,
681
+ );
682
+ await this.adapter.setStateAsync(
683
+ `${type}.totals.costs.monthly`,
684
+ calculator.roundToDecimals(totalCostsMonthly, 2),
685
+ true,
686
+ );
687
+ await this.adapter.setStateAsync(
688
+ `${type}.totals.costs.totalYearly`,
689
+ calculator.roundToDecimals(totalCostsYearly, 2),
690
+ true,
691
+ );
692
+ }
693
+
694
+ /**
695
+ * Cleanup removed meters
696
+ *
697
+ * @param {string} type - Utility type
698
+ * @param {Array} currentMeters - Currently configured meters
699
+ */
700
+ async cleanupRemovedMeters(type, currentMeters) {
701
+ // Get all existing meter folders
702
+ try {
703
+ const obj = await this.adapter.getObjectAsync(type);
704
+ if (!obj) {
705
+ return;
706
+ }
707
+
708
+ const children = await this.adapter.getObjectListAsync({
709
+ startkey: `${this.adapter.namespace}.${type}.`,
710
+ endkey: `${this.adapter.namespace}.${type}.\u9999`,
711
+ });
712
+
713
+ const PROTECTED_CATEGORIES = [
714
+ 'consumption',
715
+ 'costs',
716
+ 'billing',
717
+ 'info',
718
+ 'statistics',
719
+ 'adjustment',
720
+ 'history',
721
+ 'totals',
722
+ ];
723
+ const currentMeterNames = new Set(currentMeters.map(m => m.name));
724
+ const existingMeterFolders = new Set();
725
+
726
+ // Find all meter folders
727
+ for (const item of children.rows) {
728
+ const id = item.id.replace(`${this.adapter.namespace}.`, '');
729
+ const parts = id.split('.');
730
+ // Only consider it a meter folder if it's the second part and not a protected category
731
+ if (parts.length >= 2 && parts[0] === type && !PROTECTED_CATEGORIES.includes(parts[1])) {
732
+ existingMeterFolders.add(parts[1]);
733
+ }
734
+ }
735
+
736
+ // Delete meters that no longer exist in config
737
+ for (const folderName of existingMeterFolders) {
738
+ if (!currentMeterNames.has(folderName)) {
739
+ this.adapter.log.info(`Removing deleted meter: ${type}.${folderName}`);
740
+ await this.adapter.delObjectAsync(`${type}.${folderName}`, { recursive: true });
741
+ }
742
+ }
743
+ } catch (error) {
744
+ this.adapter.log.warn(`Could not cleanup removed meters for ${type}: ${error.message}`);
745
+ }
746
+ }
747
+ }
748
+
749
+ module.exports = MultiMeterManager;