iobroker.utility-monitor 1.4.6 → 1.5.1

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 (36) hide show
  1. package/README.md +110 -62
  2. package/admin/custom/.vite/manifest.json +90 -0
  3. package/admin/custom/@mf-types/Components.d.ts +2 -0
  4. package/admin/custom/@mf-types/compiled-types/Components/CSVImporter.d.ts +11 -0
  5. package/admin/custom/@mf-types/compiled-types/Components.d.ts +2 -0
  6. package/admin/custom/@mf-types.d.ts +3 -0
  7. package/admin/custom/@mf-types.zip +0 -0
  8. package/admin/custom/CSVImporter_v15_11.js +4415 -0
  9. package/admin/custom/assets/Components-i0AZ59nl.js +18887 -0
  10. package/admin/custom/assets/UtilityMonitor__loadShare__react__loadShare__-Da99Mak4.js +42 -0
  11. package/admin/custom/assets/UtilityMonitor__mf_v__runtimeInit__mf_v__-BmC4OGk6.js +16 -0
  12. package/admin/custom/assets/_commonjsHelpers-Dj2_voLF.js +30 -0
  13. package/admin/custom/assets/hostInit-DEXfeB0W.js +10 -0
  14. package/admin/custom/assets/index-B3WVNJTz.js +401 -0
  15. package/admin/custom/assets/index-VBwl8x_k.js +64 -0
  16. package/admin/custom/assets/preload-helper-BelkbqnE.js +61 -0
  17. package/admin/custom/assets/virtualExposes-CqCLUNLT.js +19 -0
  18. package/admin/custom/index.html +12 -0
  19. package/admin/custom/mf-manifest.json +1 -0
  20. package/admin/jsonConfig.json +90 -31
  21. package/io-package.json +15 -31
  22. package/lib/billingManager.js +382 -137
  23. package/lib/calculator.js +41 -146
  24. package/lib/consumptionManager.js +9 -252
  25. package/lib/importManager.js +300 -0
  26. package/lib/messagingHandler.js +4 -2
  27. package/lib/meter/MeterRegistry.js +110 -0
  28. package/lib/multiMeterManager.js +580 -173
  29. package/lib/stateManager.js +502 -31
  30. package/lib/utils/billingHelper.js +69 -0
  31. package/lib/utils/consumptionHelper.js +47 -0
  32. package/lib/utils/helpers.js +234 -0
  33. package/lib/utils/stateCache.js +147 -0
  34. package/lib/utils/typeMapper.js +19 -0
  35. package/main.js +67 -8
  36. package/package.json +10 -4
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const calculator = require('./calculator');
4
+ const { getConfigType } = require('./utils/typeMapper');
5
+ const billingHelper = require('./utils/billingHelper');
4
6
 
5
7
  /**
6
8
  * BillingManager handles all cost calculations,
@@ -20,7 +22,7 @@ class BillingManager {
20
22
  * @param {string} type - Utility type
21
23
  */
22
24
  async updateCosts(type) {
23
- const configType = this.adapter.consumptionManager.getConfigType(type);
25
+ const configType = getConfigType(type);
24
26
 
25
27
  // Get price and basic charge from config
26
28
  const priceKey = `${configType}Preis`;
@@ -56,7 +58,7 @@ class BillingManager {
56
58
  const yearlyVolume = typeof yearlyVolumeState?.val === 'number' ? yearlyVolumeState.val : 0;
57
59
  const totalM3 = yearlyVolume + adjustment;
58
60
  const brennwert = this.adapter.config.gasBrennwert || 11.5;
59
- const zZahl = this.adapter.config.gasZahl || 0.95;
61
+ const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
60
62
  yearly = calculator.convertGasM3ToKWh(totalM3, brennwert, zZahl);
61
63
  } else {
62
64
  yearly += adjustment;
@@ -67,7 +69,6 @@ class BillingManager {
67
69
  let dailyConsumptionCost, monthlyConsumptionCost, yearlyConsumptionCost;
68
70
 
69
71
  if (htNtEnabled) {
70
- // HT/NT Calculation
71
72
  const htPrice = this.adapter.config[`${configType}HtPrice`] || 0;
72
73
  const ntPrice = this.adapter.config[`${configType}NtPrice`] || 0;
73
74
 
@@ -82,48 +83,27 @@ class BillingManager {
82
83
  if (adjustment !== 0) {
83
84
  if (type === 'gas') {
84
85
  const brennwert = this.adapter.config.gasBrennwert || 11.5;
85
- const zZahl = this.adapter.config.gasZahl || 0.95;
86
+ const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
86
87
  yearlyHT = Number(yearlyHT) + calculator.convertGasM3ToKWh(adjustment, brennwert, zZahl);
87
88
  } else {
88
89
  yearlyHT = Number(yearlyHT) + Number(adjustment);
89
90
  }
90
91
  }
91
92
 
92
- dailyConsumptionCost = Number(dailyHT) * parseFloat(htPrice) + Number(dailyNT) * parseFloat(ntPrice);
93
- monthlyConsumptionCost = Number(monthlyHT) * parseFloat(htPrice) + Number(monthlyNT) * parseFloat(ntPrice);
94
- yearlyConsumptionCost = Number(yearlyHT) * parseFloat(htPrice) + Number(yearlyNT) * parseFloat(ntPrice);
93
+ const dailyRes = billingHelper.calculateHTNTCosts(dailyHT, htPrice, dailyNT, ntPrice);
94
+ const monthlyRes = billingHelper.calculateHTNTCosts(monthlyHT, htPrice, monthlyNT, ntPrice);
95
+ const yearlyRes = billingHelper.calculateHTNTCosts(yearlyHT, htPrice, yearlyNT, ntPrice);
95
96
 
96
- // Update HT/NT specific cost states
97
- await this.adapter.setStateAsync(
98
- `${type}.costs.dailyHT`,
99
- calculator.roundToDecimals(Number(dailyHT) * parseFloat(htPrice), 2),
100
- true,
101
- );
102
- await this.adapter.setStateAsync(
103
- `${type}.costs.dailyNT`,
104
- calculator.roundToDecimals(Number(dailyNT) * calculator.ensureNumber(ntPrice), 2),
105
- true,
106
- );
107
- await this.adapter.setStateAsync(
108
- `${type}.costs.monthlyHT`,
109
- calculator.roundToDecimals(Number(monthlyHT) * calculator.ensureNumber(htPrice), 2),
110
- true,
111
- );
112
- await this.adapter.setStateAsync(
113
- `${type}.costs.monthlyNT`,
114
- calculator.roundToDecimals(Number(monthlyNT) * calculator.ensureNumber(ntPrice), 2),
115
- true,
116
- );
117
- await this.adapter.setStateAsync(
118
- `${type}.costs.yearlyHT`,
119
- calculator.roundToDecimals(Number(yearlyHT) * calculator.ensureNumber(htPrice), 2),
120
- true,
121
- );
122
- await this.adapter.setStateAsync(
123
- `${type}.costs.yearlyNT`,
124
- calculator.roundToDecimals(Number(yearlyNT) * calculator.ensureNumber(ntPrice), 2),
125
- true,
126
- );
97
+ dailyConsumptionCost = dailyRes.total;
98
+ monthlyConsumptionCost = monthlyRes.total;
99
+ yearlyConsumptionCost = yearlyRes.total;
100
+
101
+ await this.adapter.setStateAsync(`${type}.costs.dailyHT`, dailyRes.htCosts, true);
102
+ await this.adapter.setStateAsync(`${type}.costs.dailyNT`, dailyRes.ntCosts, true);
103
+ await this.adapter.setStateAsync(`${type}.costs.monthlyHT`, monthlyRes.htCosts, true);
104
+ await this.adapter.setStateAsync(`${type}.costs.monthlyNT`, monthlyRes.ntCosts, true);
105
+ await this.adapter.setStateAsync(`${type}.costs.yearlyHT`, yearlyRes.htCosts, true);
106
+ await this.adapter.setStateAsync(`${type}.costs.yearlyNT`, yearlyRes.ntCosts, true);
127
107
  } else {
128
108
  dailyConsumptionCost = calculator.calculateCost(daily, price);
129
109
  monthlyConsumptionCost = calculator.calculateCost(monthly, price);
@@ -131,40 +111,18 @@ class BillingManager {
131
111
  }
132
112
 
133
113
  // Basic charge calculation
134
- const contractStartKey = `${configType}ContractStart`;
135
- const contractStartDate = this.adapter.config[contractStartKey];
136
-
137
- let monthsSinceContract;
138
- if (contractStartDate) {
139
- const contractStart = calculator.parseGermanDate(contractStartDate);
140
- if (contractStart && !isNaN(contractStart.getTime())) {
141
- const now = new Date();
142
- const yDiff = now.getFullYear() - contractStart.getFullYear();
143
- const mDiff = now.getMonth() - contractStart.getMonth();
144
- monthsSinceContract = Math.max(1, yDiff * 12 + mDiff + 1);
145
- }
146
- }
147
-
148
- if (monthsSinceContract === undefined) {
149
- const yearStartState = await this.adapter.getStateAsync(`${type}.statistics.lastYearStart`);
150
- const yearStartTime = typeof yearStartState?.val === 'number' ? yearStartState.val : Date.now();
151
- const yearStart = new Date(yearStartTime);
152
- const now = new Date();
153
- const yDiff = now.getFullYear() - yearStart.getFullYear();
154
- const mDiff = now.getMonth() - yearStart.getMonth();
155
- monthsSinceContract = Math.max(1, yDiff * 12 + mDiff + 1);
156
- }
157
-
158
- // Calculate accumulated basic charge (monthly fee × months)
159
- const basicChargeAccumulated = basicChargeMonthly * monthsSinceContract;
114
+ const monthsSinceContract = await this._calculateMonthsSinceStart(type, configType);
160
115
 
161
- // Jahresgebühr ist ein FIXER Wert pro Jahr (z.B. 60€)
162
- // NICHT pro-rata nach Monaten/Tagen berechnen!
163
- const annualFeeAccumulated = annualFeePerYear;
164
-
165
- const totalYearlyCost = yearlyConsumptionCost + basicChargeAccumulated + annualFeeAccumulated;
116
+ const charges = billingHelper.calculateAccumulatedCharges(
117
+ basicChargeMonthly,
118
+ annualFeePerYear,
119
+ monthsSinceContract,
120
+ );
121
+ const basicChargeAccumulated = charges.basicCharge;
122
+ const annualFeeAccumulated = charges.annualFee;
123
+ const totalYearlyCost = yearlyConsumptionCost + charges.total;
166
124
 
167
- // Update states
125
+ // Update basic charge states
168
126
  await this.adapter.setStateAsync(
169
127
  `${type}.costs.daily`,
170
128
  calculator.roundToDecimals(dailyConsumptionCost, 2),
@@ -185,32 +143,39 @@ class BillingManager {
185
143
  calculator.roundToDecimals(totalYearlyCost, 2),
186
144
  true,
187
145
  );
188
- await this.adapter.setStateAsync(
189
- `${type}.costs.annualFee`,
190
- calculator.roundToDecimals(annualFeeAccumulated, 2),
191
- true,
192
- );
193
- // basicCharge enthält NUR die monatliche Grundgebühr (akkumuliert)
194
- // Jahresgebühr ist separat in annualFee
195
- await this.adapter.setStateAsync(
196
- `${type}.costs.basicCharge`,
197
- calculator.roundToDecimals(basicChargeAccumulated, 2),
198
- true,
199
- );
146
+ await this.adapter.setStateAsync(`${type}.costs.annualFee`, annualFeeAccumulated, true);
147
+ await this.adapter.setStateAsync(`${type}.costs.basicCharge`, basicChargeAccumulated, true);
200
148
 
201
- // Abschlag
149
+ // Abschlag / Installment
202
150
  const abschlagKey = `${configType}Abschlag`;
203
151
  const monthlyAbschlag = this.adapter.config[abschlagKey] || 0;
204
152
 
205
- if (monthlyAbschlag > 0) {
206
- const paidTotal = monthlyAbschlag * monthsSinceContract;
207
- const balance = totalYearlyCost - paidTotal;
208
- await this.adapter.setStateAsync(`${type}.costs.paidTotal`, calculator.roundToDecimals(paidTotal, 2), true);
209
- await this.adapter.setStateAsync(`${type}.costs.balance`, calculator.roundToDecimals(balance, 2), true);
210
- } else {
211
- await this.adapter.setStateAsync(`${type}.costs.paidTotal`, 0, true);
212
- await this.adapter.setStateAsync(`${type}.costs.balance`, 0, true);
153
+ const balanceRes = billingHelper.calculateBalance(monthlyAbschlag, monthsSinceContract, totalYearlyCost);
154
+ await this.adapter.setStateAsync(`${type}.costs.paidTotal`, balanceRes.paid, true);
155
+ await this.adapter.setStateAsync(`${type}.costs.balance`, balanceRes.balance, true);
156
+ }
157
+
158
+ /**
159
+ * Calculates months since contract or year start
160
+ *
161
+ * @param {string} type - Utility type
162
+ * @param {string} configType - Mapped config type
163
+ * @returns {Promise<number>} Number of months (at least 1)
164
+ */
165
+ async _calculateMonthsSinceStart(type, configType) {
166
+ const contractStartDate = this.adapter.config[`${configType}ContractStart`];
167
+ let startDate;
168
+
169
+ if (contractStartDate) {
170
+ startDate = calculator.parseGermanDate(contractStartDate);
171
+ }
172
+
173
+ if (!startDate || isNaN(startDate.getTime())) {
174
+ const lastYearStart = await this.adapter.getStateAsync(`${type}.statistics.lastYearStart`);
175
+ startDate = new Date(lastYearStart?.val || Date.now());
213
176
  }
177
+
178
+ return Math.max(1, calculator.getMonthsDifference(startDate, new Date()) + 1);
214
179
  }
215
180
 
216
181
  /**
@@ -232,7 +197,7 @@ class BillingManager {
232
197
  return;
233
198
  }
234
199
 
235
- const configType = this.adapter.consumptionManager.getConfigType(type);
200
+ const configType = getConfigType(type);
236
201
  const contractStartKey = `${configType}ContractStart`;
237
202
  const contractStart = this.adapter.config[contractStartKey];
238
203
 
@@ -454,11 +419,96 @@ class BillingManager {
454
419
  return;
455
420
  }
456
421
 
422
+ // Archive data for this meter
457
423
  const year = startDate.getFullYear();
458
424
 
459
- // Archive data for this meter
460
- // TODO: Implement full history archiving for individual meters
461
- // For now, just reset the meter
425
+ this.adapter.log.info(`Archiving data for ${basePath} year ${year}...`);
426
+
427
+ // Create history structure for this meter
428
+ await this.adapter.setObjectNotExistsAsync(`${basePath}.history`, {
429
+ type: 'channel',
430
+ common: { name: 'Historie' },
431
+ native: {},
432
+ });
433
+ await this.adapter.setObjectNotExistsAsync(`${basePath}.history.${year}`, {
434
+ type: 'channel',
435
+ common: { name: `Jahr ${year}` },
436
+ native: {},
437
+ });
438
+
439
+ // Get current values for archiving
440
+ const yearlyState = await this.adapter.getStateAsync(`${basePath}.consumption.yearly`);
441
+ const totalYearlyState = await this.adapter.getStateAsync(`${basePath}.costs.totalYearly`);
442
+ const balanceState = await this.adapter.getStateAsync(`${basePath}.costs.balance`);
443
+
444
+ const yearly = yearlyState?.val || 0;
445
+ const totalYearly = totalYearlyState?.val || 0;
446
+ const balance = balanceState?.val || 0;
447
+
448
+ const consumptionUnit = type === 'gas' ? 'kWh' : type === 'water' ? 'm³' : 'kWh';
449
+
450
+ // Archive yearly consumption
451
+ await this.adapter.setObjectNotExistsAsync(`${basePath}.history.${year}.yearly`, {
452
+ type: 'state',
453
+ common: {
454
+ name: `Jahresverbrauch ${year}`,
455
+ type: 'number',
456
+ role: 'value',
457
+ read: true,
458
+ write: false,
459
+ unit: consumptionUnit,
460
+ },
461
+ native: {},
462
+ });
463
+ await this.adapter.setStateAsync(`${basePath}.history.${year}.yearly`, yearly, true);
464
+
465
+ // Archive gas volume if applicable
466
+ if (type === 'gas') {
467
+ const yearlyVolume = (await this.adapter.getStateAsync(`${basePath}.consumption.yearlyVolume`))?.val || 0;
468
+ await this.adapter.setObjectNotExistsAsync(`${basePath}.history.${year}.yearlyVolume`, {
469
+ type: 'state',
470
+ common: {
471
+ name: `Jahresverbrauch ${year} (m³)`,
472
+ type: 'number',
473
+ role: 'value',
474
+ read: true,
475
+ write: false,
476
+ unit: 'm³',
477
+ },
478
+ native: {},
479
+ });
480
+ await this.adapter.setStateAsync(`${basePath}.history.${year}.yearlyVolume`, yearlyVolume, true);
481
+ }
482
+
483
+ // Archive total yearly costs
484
+ await this.adapter.setObjectNotExistsAsync(`${basePath}.history.${year}.totalYearly`, {
485
+ type: 'state',
486
+ common: {
487
+ name: `Gesamtkosten ${year}`,
488
+ type: 'number',
489
+ role: 'value.money',
490
+ read: true,
491
+ write: false,
492
+ unit: '€',
493
+ },
494
+ native: {},
495
+ });
496
+ await this.adapter.setStateAsync(`${basePath}.history.${year}.totalYearly`, totalYearly, true);
497
+
498
+ // Archive balance
499
+ await this.adapter.setObjectNotExistsAsync(`${basePath}.history.${year}.balance`, {
500
+ type: 'state',
501
+ common: {
502
+ name: `Bilanz ${year}`,
503
+ type: 'number',
504
+ role: 'value.money',
505
+ read: true,
506
+ write: false,
507
+ unit: '€',
508
+ },
509
+ native: {},
510
+ });
511
+ await this.adapter.setStateAsync(`${basePath}.history.${year}.balance`, balance, true);
462
512
 
463
513
  // Reset consumption and costs for this meter
464
514
  await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, 0, true);
@@ -548,7 +598,7 @@ class BillingManager {
548
598
  const types = ['gas', 'water', 'electricity', 'pv'];
549
599
 
550
600
  for (const type of types) {
551
- const configType = this.adapter.consumptionManager.getConfigType(type);
601
+ const configType = getConfigType(type);
552
602
  if (!this.adapter.config[`${configType}Aktiv`]) {
553
603
  continue;
554
604
  }
@@ -560,34 +610,126 @@ class BillingManager {
560
610
 
561
611
  const nowDate = new Date(now);
562
612
 
563
- // DAILY RESET: All meters reset together at midnight
564
- const lastDayStart = await this.adapter.getStateAsync(`${type}.statistics.lastDayStart`);
613
+ // Get all meters for this type (needed for all reset checks)
614
+ const meters = this.adapter.multiMeterManager?.getMetersForType(type) || [];
615
+
616
+ if (meters.length === 0) {
617
+ continue;
618
+ }
619
+
620
+ const firstMeter = meters[0];
621
+ const basePath = `${type}.${firstMeter.name}`;
622
+
623
+ // Reset time window: 23:59 (last minute of the day)
624
+ // This ensures History adapter sees clean day boundaries
625
+ const isResetTime = nowDate.getHours() === 23 && nowDate.getMinutes() === 59;
626
+
627
+ // Helper: Create normalized timestamp for 23:59:00 of a given date
628
+ // This ensures consistent timestamps regardless of actual execution time
629
+ const getNormalizedResetTime = date => {
630
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 0, 0).getTime();
631
+ };
632
+
633
+ // Helper: Check if timestamp is from a valid 23:59 reset today
634
+ // A valid reset must be from today AND from the 23:xx hour
635
+ // This prevents adapter restarts (e.g. at 00:01:03) from blocking the real 23:59 reset
636
+ const todayStart = new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate()).getTime();
637
+ const isValidResetToday = timestamp => {
638
+ if (timestamp < todayStart) {
639
+ return false; // Not from today
640
+ }
641
+ // Check if the timestamp was from the 23:xx hour (valid reset time)
642
+ const resetDate = new Date(timestamp);
643
+ return resetDate.getHours() === 23;
644
+ };
645
+
646
+ // DAILY RESET: Trigger at 23:59 if today's reset hasn't happened yet
647
+ const lastDayStart = await this.adapter.getStateAsync(`${basePath}.statistics.lastDayStart`);
565
648
  if (lastDayStart?.val) {
566
- const lastDay = new Date(lastDayStart.val);
567
- if (nowDate.getDate() !== lastDay.getDate()) {
568
- await this.resetDailyCounters(type);
649
+ const lastResetTime = lastDayStart.val;
650
+ const alreadyResetToday = isValidResetToday(lastResetTime);
651
+
652
+ // Reset at 23:59 if not yet reset today, OR catch up if we missed it (e.g. adapter was offline)
653
+ if (isResetTime && !alreadyResetToday) {
654
+ this.adapter.log.info(`Täglicher Reset für ${type} um 23:59`);
655
+ await this.resetDailyCounters(type, getNormalizedResetTime(nowDate));
656
+ } else if (!alreadyResetToday && nowDate.getTime() > lastResetTime + 24 * 60 * 60 * 1000) {
657
+ // Catch-up: More than 24h since last reset (adapter was offline)
658
+ // Calculate when the reset SHOULD have happened (yesterday 23:59)
659
+ const yesterday = new Date(nowDate);
660
+ yesterday.setDate(yesterday.getDate() - 1);
661
+ const catchUpTime = getNormalizedResetTime(yesterday);
662
+ this.adapter.log.info(`Täglicher Reset für ${type} (Nachholung - Adapter war offline)`);
663
+ await this.resetDailyCounters(type, catchUpTime);
569
664
  }
570
665
  }
571
666
 
572
- // MONTHLY RESET: All meters reset together on 1st of month
573
- const lastMonthStart = await this.adapter.getStateAsync(`${type}.statistics.lastMonthStart`);
574
- if (lastMonthStart?.val) {
575
- const lastMonth = new Date(lastMonthStart.val);
576
- if (nowDate.getMonth() !== lastMonth.getMonth()) {
577
- await this.resetMonthlyCounters(type);
667
+ // WEEKLY RESET: Trigger at 23:59 on Sunday if this week's reset hasn't happened yet
668
+ const lastWeekStart = await this.adapter.getStateAsync(`${basePath}.statistics.lastWeekStart`);
669
+ if (lastWeekStart?.val) {
670
+ const lastWeekTime = lastWeekStart.val;
671
+ const isSunday = nowDate.getDay() === 0; // 0 = Sunday
672
+
673
+ // Check if we're in a new week (more than 6 days since last reset)
674
+ // BUT also check if the last reset was actually a valid 23:59 reset
675
+ // to prevent adapter restarts from blocking the real weekly reset
676
+ const daysSinceLastReset = (nowDate.getTime() - lastWeekTime) / (24 * 60 * 60 * 1000);
677
+ const lastWeekResetDate = new Date(lastWeekTime);
678
+ const wasValidWeeklyReset = lastWeekResetDate.getHours() === 23;
679
+ const needsWeeklyReset = daysSinceLastReset >= 6 || (isSunday && !wasValidWeeklyReset);
680
+
681
+ if (isSunday && isResetTime && needsWeeklyReset) {
682
+ this.adapter.log.info(`Wöchentlicher Reset für ${type} um 23:59`);
683
+ await this.resetWeeklyCounters(type, getNormalizedResetTime(nowDate));
684
+ } else if (needsWeeklyReset && daysSinceLastReset >= 7) {
685
+ // Catch-up: 7 or more days since last reset (was offline or missed window)
686
+ // Calculate when the reset SHOULD have happened (last Sunday 23:59)
687
+ const lastSunday = new Date(nowDate);
688
+ lastSunday.setDate(lastSunday.getDate() - lastSunday.getDay()); // Go back to Sunday
689
+ const catchUpTime = getNormalizedResetTime(lastSunday);
690
+ this.adapter.log.info(`Wöchentlicher Reset für ${type} (Nachholung)`);
691
+ await this.resetWeeklyCounters(type, catchUpTime);
692
+ }
693
+ }
694
+
695
+ // MONTHLY RESET: Trigger at 23:59 on last day of month
696
+ if (meters.length > 0) {
697
+ const lastMonthStartState = await this.adapter.getStateAsync(`${basePath}.statistics.lastMonthStart`);
698
+ if (lastMonthStartState?.val) {
699
+ const lastMonthTime = lastMonthStartState.val;
700
+ const lastMonthDate = new Date(lastMonthTime);
701
+ const isLastDayOfMonth =
702
+ new Date(nowDate.getFullYear(), nowDate.getMonth() + 1, 0).getDate() === nowDate.getDate();
703
+ const monthChanged = nowDate.getMonth() !== lastMonthDate.getMonth();
704
+ // Also check if the last reset was a valid 23:59 reset
705
+ // to prevent adapter restarts from blocking the real monthly reset
706
+ const wasValidMonthlyReset = lastMonthDate.getHours() === 23;
707
+ const needsMonthlyReset = monthChanged || (isLastDayOfMonth && !wasValidMonthlyReset);
708
+
709
+ if (isLastDayOfMonth && isResetTime && needsMonthlyReset) {
710
+ this.adapter.log.info(`Monatlicher Reset für ${type} um 23:59`);
711
+ await this.resetMonthlyCounters(type, getNormalizedResetTime(nowDate));
712
+ } else if (monthChanged && nowDate.getDate() > 1) {
713
+ // Catch-up: We're past the 1st of a new month and haven't reset yet
714
+ // Calculate when the reset SHOULD have happened (last day of previous month 23:59)
715
+ const lastDayPrevMonth = new Date(nowDate.getFullYear(), nowDate.getMonth(), 0);
716
+ const catchUpTime = getNormalizedResetTime(lastDayPrevMonth);
717
+ this.adapter.log.info(`Monatlicher Reset für ${type} (Nachholung)`);
718
+ await this.resetMonthlyCounters(type, catchUpTime);
719
+ }
578
720
  }
579
721
  }
580
722
 
581
723
  // YEARLY RESET: Each meter resets individually based on ITS contract date
582
- const meters = this.adapter.multiMeterManager?.getMetersForType(type) || [];
724
+ // Trigger at 23:59 on the day BEFORE the anniversary (so the new year starts fresh)
583
725
  for (const meter of meters) {
584
- const basePath = `${type}.${meter.name}`;
585
- const lastYearStartState = await this.adapter.getStateAsync(`${basePath}.statistics.lastYearStart`);
726
+ const meterBasePath = `${type}.${meter.name}`;
727
+ const lastYearStartState = await this.adapter.getStateAsync(
728
+ `${meterBasePath}.statistics.lastYearStart`,
729
+ );
586
730
 
587
731
  if (lastYearStartState?.val) {
588
732
  const lastYearStartDate = new Date(lastYearStartState.val);
589
-
590
- // Get contract date for THIS specific meter (all meters have config.contractStart)
591
733
  const contractStartDate = meter.config?.contractStart;
592
734
 
593
735
  if (contractStartDate) {
@@ -595,30 +737,69 @@ class BillingManager {
595
737
  if (contractStart) {
596
738
  const annMonth = contractStart.getMonth();
597
739
  const annDay = contractStart.getDate();
598
- const isPast =
740
+
741
+ // Check if today is the day BEFORE the anniversary (for 23:59 reset)
742
+ // or if we're past it and haven't reset yet (catch-up)
743
+ const anniversaryThisYear = new Date(nowDate.getFullYear(), annMonth, annDay);
744
+ const dayBeforeAnniversary = new Date(anniversaryThisYear.getTime() - 24 * 60 * 60 * 1000);
745
+
746
+ const isTodayDayBefore =
747
+ nowDate.getMonth() === dayBeforeAnniversary.getMonth() &&
748
+ nowDate.getDate() === dayBeforeAnniversary.getDate();
749
+
750
+ const isPastAnniversary =
599
751
  nowDate.getMonth() > annMonth ||
600
752
  (nowDate.getMonth() === annMonth && nowDate.getDate() >= annDay);
601
753
 
602
- if (isPast && lastYearStartDate.getFullYear() !== nowDate.getFullYear()) {
754
+ const needsReset = lastYearStartDate.getFullYear() < nowDate.getFullYear();
755
+
756
+ if (isTodayDayBefore && isResetTime && needsReset) {
603
757
  this.adapter.log.info(
604
- `Yearly reset for ${basePath} (contract anniversary: ${contractStartDate})`,
758
+ `Yearly reset for ${meterBasePath} um 23:59 (Vertragsjubiläum morgen: ${contractStartDate})`,
605
759
  );
606
- await this.resetYearlyCountersForMeter(type, meter);
760
+ await this.resetYearlyCountersForMeter(type, meter, getNormalizedResetTime(nowDate));
761
+
762
+ if (meters.length > 1) {
763
+ await this.adapter.multiMeterManager.updateTotalCosts(type);
764
+ }
765
+ } else if (isPastAnniversary && needsReset) {
766
+ // Catch-up: Anniversary has passed but we haven't reset yet
767
+ // Use the day before anniversary 23:59 as the reset time
768
+ const catchUpTime = getNormalizedResetTime(dayBeforeAnniversary);
769
+ this.adapter.log.info(
770
+ `Yearly reset for ${meterBasePath} (Nachholung - Jubiläum: ${contractStartDate})`,
771
+ );
772
+ await this.resetYearlyCountersForMeter(type, meter, catchUpTime);
607
773
 
608
- // Update totals if multiple meters exist
609
774
  if (meters.length > 1) {
610
775
  await this.adapter.multiMeterManager.updateTotalCosts(type);
611
776
  }
612
777
  }
613
778
  }
614
- } else if (nowDate.getFullYear() !== lastYearStartDate.getFullYear()) {
615
- // No contract date: reset on January 1st
616
- this.adapter.log.info(`Yearly reset for ${basePath} (calendar year)`);
617
- await this.resetYearlyCountersForMeter(type, meter);
618
-
619
- // Update totals if multiple meters exist
620
- if (meters.length > 1) {
621
- await this.adapter.multiMeterManager.updateTotalCosts(type);
779
+ } else {
780
+ // No contract date: reset at 23:59 on December 31st (or catch up in January)
781
+ const isDecember31 = nowDate.getMonth() === 11 && nowDate.getDate() === 31;
782
+ const needsReset = nowDate.getFullYear() > lastYearStartDate.getFullYear();
783
+
784
+ if (isDecember31 && isResetTime && !needsReset) {
785
+ // Reset at 23:59 on Dec 31 for the upcoming year
786
+ this.adapter.log.info(`Yearly reset for ${meterBasePath} um 23:59 (Kalenderjahr)`);
787
+ await this.resetYearlyCountersForMeter(type, meter, getNormalizedResetTime(nowDate));
788
+
789
+ if (meters.length > 1) {
790
+ await this.adapter.multiMeterManager.updateTotalCosts(type);
791
+ }
792
+ } else if (needsReset) {
793
+ // Catch-up: We're in a new year but haven't reset yet
794
+ // Use Dec 31 of last year 23:59 as reset time
795
+ const dec31LastYear = new Date(nowDate.getFullYear() - 1, 11, 31);
796
+ const catchUpTime = getNormalizedResetTime(dec31LastYear);
797
+ this.adapter.log.info(`Yearly reset for ${meterBasePath} (Nachholung - Kalenderjahr)`);
798
+ await this.resetYearlyCountersForMeter(type, meter, catchUpTime);
799
+
800
+ if (meters.length > 1) {
801
+ await this.adapter.multiMeterManager.updateTotalCosts(type);
802
+ }
622
803
  }
623
804
  }
624
805
  }
@@ -630,8 +811,9 @@ class BillingManager {
630
811
  * Resets daily counters
631
812
  *
632
813
  * @param {string} type - Utility type
814
+ * @param {number} [resetTimestamp] - Optional normalized timestamp (defaults to Date.now())
633
815
  */
634
- async resetDailyCounters(type) {
816
+ async resetDailyCounters(type, resetTimestamp) {
635
817
  this.adapter.log.info(`Resetting daily counters for ${type}`);
636
818
 
637
819
  // Get all meters for this type (main + additional meters)
@@ -666,7 +848,7 @@ class BillingManager {
666
848
  }
667
849
 
668
850
  // Reset HT/NT daily counters if enabled
669
- const configType = this.adapter.consumptionManager.getConfigType(type);
851
+ const configType = getConfigType(type);
670
852
  const htNtEnabled = this.adapter.config[`${configType}HtNtEnabled`] || false;
671
853
  if (htNtEnabled) {
672
854
  const dailyHT = await this.adapter.getStateAsync(`${basePath}.consumption.dailyHT`);
@@ -679,8 +861,9 @@ class BillingManager {
679
861
 
680
862
  await this.adapter.setStateAsync(`${basePath}.costs.daily`, 0, true);
681
863
 
682
- // Update lastDayStart timestamp
683
- await this.adapter.setStateAsync(`${basePath}.statistics.lastDayStart`, Date.now(), true);
864
+ // Update lastDayStart timestamp (use normalized timestamp if provided)
865
+ const timestamp = resetTimestamp || Date.now();
866
+ await this.adapter.setStateAsync(`${basePath}.statistics.lastDayStart`, timestamp, true);
684
867
 
685
868
  await this.adapter.setStateAsync(
686
869
  `${basePath}.statistics.averageDaily`,
@@ -699,8 +882,9 @@ class BillingManager {
699
882
  * Resets monthly counters
700
883
  *
701
884
  * @param {string} type - Utility type
885
+ * @param {number} [resetTimestamp] - Optional normalized timestamp (defaults to Date.now())
702
886
  */
703
- async resetMonthlyCounters(type) {
887
+ async resetMonthlyCounters(type, resetTimestamp) {
704
888
  this.adapter.log.info(`Resetting monthly counters for ${type}`);
705
889
 
706
890
  // Get all meters for this type (main + additional meters)
@@ -718,17 +902,26 @@ class BillingManager {
718
902
 
719
903
  this.adapter.log.debug(`Resetting monthly counter for ${basePath} (${label})`);
720
904
 
905
+ // Get current values before reset
721
906
  const monthlyState = await this.adapter.getStateAsync(`${basePath}.consumption.monthly`);
722
907
  const monthlyValue = monthlyState?.val || 0;
723
908
 
724
- await this.adapter.setStateAsync(`${basePath}.consumption.monthly`, 0, true);
909
+ // Save last month consumption
910
+ await this.adapter.setStateAsync(`${basePath}.statistics.lastMonth`, monthlyValue, true);
725
911
 
912
+ // For gas: also save volume
726
913
  if (type === 'gas') {
914
+ const monthlyVolume = await this.adapter.getStateAsync(`${basePath}.consumption.monthlyVolume`);
915
+ const monthlyVolumeValue = monthlyVolume?.val || 0;
916
+ await this.adapter.setStateAsync(`${basePath}.statistics.lastMonthVolume`, monthlyVolumeValue, true);
727
917
  await this.adapter.setStateAsync(`${basePath}.consumption.monthlyVolume`, 0, true);
728
918
  }
729
919
 
920
+ // Reset monthly counters
921
+ await this.adapter.setStateAsync(`${basePath}.consumption.monthly`, 0, true);
922
+
730
923
  // Reset HT/NT monthly counters if enabled
731
- const configType = this.adapter.consumptionManager.getConfigType(type);
924
+ const configType = getConfigType(type);
732
925
  const htNtEnabled = this.adapter.config[`${configType}HtNtEnabled`] || false;
733
926
  if (htNtEnabled) {
734
927
  await this.adapter.setStateAsync(`${basePath}.consumption.monthlyHT`, 0, true);
@@ -737,8 +930,9 @@ class BillingManager {
737
930
 
738
931
  await this.adapter.setStateAsync(`${basePath}.costs.monthly`, 0, true);
739
932
 
740
- // Update lastMonthStart timestamp
741
- await this.adapter.setStateAsync(`${basePath}.statistics.lastMonthStart`, Date.now(), true);
933
+ // Update lastMonthStart timestamp (use normalized timestamp if provided)
934
+ const timestamp = resetTimestamp || Date.now();
935
+ await this.adapter.setStateAsync(`${basePath}.statistics.lastMonthStart`, timestamp, true);
742
936
 
743
937
  await this.adapter.setStateAsync(
744
938
  `${basePath}.statistics.averageMonthly`,
@@ -801,8 +995,9 @@ class BillingManager {
801
995
  *
802
996
  * @param {string} type - Utility type
803
997
  * @param {object} meter - Meter object from multiMeterManager
998
+ * @param {number} [resetTimestamp] - Optional normalized timestamp (defaults to Date.now())
804
999
  */
805
- async resetYearlyCountersForMeter(type, meter) {
1000
+ async resetYearlyCountersForMeter(type, meter, resetTimestamp) {
806
1001
  const basePath = `${type}.${meter.name}`;
807
1002
  const label = meter.displayName || meter.name;
808
1003
 
@@ -818,8 +1013,58 @@ class BillingManager {
818
1013
  await this.adapter.setStateAsync(`${basePath}.billing.notificationSent`, false, true);
819
1014
  await this.adapter.setStateAsync(`${basePath}.billing.notificationChangeSent`, false, true);
820
1015
 
821
- // Update lastYearStart timestamp
822
- await this.adapter.setStateAsync(`${basePath}.statistics.lastYearStart`, Date.now(), true);
1016
+ // Update lastYearStart timestamp (use normalized timestamp if provided)
1017
+ const timestamp = resetTimestamp || Date.now();
1018
+ await this.adapter.setStateAsync(`${basePath}.statistics.lastYearStart`, timestamp, true);
1019
+ }
1020
+
1021
+ /**
1022
+ * Resets weekly counters
1023
+ *
1024
+ * @param {string} type - Utility type
1025
+ * @param {number} [resetTimestamp] - Optional normalized timestamp (defaults to Date.now())
1026
+ */
1027
+ async resetWeeklyCounters(type, resetTimestamp) {
1028
+ this.adapter.log.info(`Resetting weekly counters for ${type}`);
1029
+
1030
+ const meters = this.adapter.multiMeterManager?.getMetersForType(type) || [];
1031
+ for (const meter of meters) {
1032
+ const basePath = `${type}.${meter.name}`;
1033
+
1034
+ // Save last week consumption before reset
1035
+ const weeklyState = await this.adapter.getStateAsync(`${basePath}.consumption.weekly`);
1036
+ const weeklyValue = weeklyState?.val || 0;
1037
+ await this.adapter.setStateAsync(`${basePath}.statistics.lastWeek`, weeklyValue, true);
1038
+
1039
+ // For gas: also save volume
1040
+ if (type === 'gas') {
1041
+ const weeklyVolume = await this.adapter.getStateAsync(`${basePath}.consumption.weeklyVolume`);
1042
+ const weeklyVolumeValue = weeklyVolume?.val || 0;
1043
+ await this.adapter.setStateAsync(`${basePath}.statistics.lastWeekVolume`, weeklyVolumeValue, true);
1044
+ await this.adapter.setStateAsync(`${basePath}.consumption.weeklyVolume`, 0, true);
1045
+ }
1046
+
1047
+ // Reset weekly counters
1048
+ await this.adapter.setStateAsync(`${basePath}.consumption.weekly`, 0, true);
1049
+ await this.adapter.setStateAsync(`${basePath}.costs.weekly`, 0, true);
1050
+
1051
+ const configType = getConfigType(type);
1052
+ const htNtEnabled = this.adapter.config[`${configType}HtNtEnabled`] || false;
1053
+ if (htNtEnabled) {
1054
+ await this.adapter.setStateAsync(`${basePath}.consumption.weeklyHT`, 0, true);
1055
+ await this.adapter.setStateAsync(`${basePath}.consumption.weeklyNT`, 0, true);
1056
+ await this.adapter.setStateAsync(`${basePath}.costs.weeklyHT`, 0, true);
1057
+ await this.adapter.setStateAsync(`${basePath}.costs.weeklyNT`, 0, true);
1058
+ }
1059
+
1060
+ // Update lastWeekStart timestamp (use normalized timestamp if provided)
1061
+ const timestamp = resetTimestamp || Date.now();
1062
+ await this.adapter.setStateAsync(`${basePath}.statistics.lastWeekStart`, timestamp, true);
1063
+ }
1064
+
1065
+ if (meters.length > 1) {
1066
+ await this.adapter.multiMeterManager.updateTotalCosts(type);
1067
+ }
823
1068
  }
824
1069
  }
825
1070