iobroker.utility-monitor 1.5.0 → 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.
package/README.md CHANGED
@@ -212,17 +212,17 @@ Balance: +62,64 € → Nachzahlung
212
212
 
213
213
  ### 📈 **statistics** (Statistiken)
214
214
 
215
- | Datenpunkt | Beschreibung |
216
- | ---------------- | ------------------------------------ |
217
- | `averageDaily` | Durchschnittlicher Tagesverbrauch |
218
- | `averageMonthly` | Durchschnittlicher Monatsverbrauch |
219
- | `lastDay` | Verbrauch **gesten** (Vortag) |
220
- | `lastWeek` | Verbrauch **letzte Woche** |
221
- | `lastMonth` | Verbrauch **letzter Monat** |
222
- | `lastDayStart` | Letzter Tages-Reset (00:00 Uhr) |
223
- | `lastWeekStart` | Letzter Wochen-Reset (Montag) |
224
- | `lastMonthStart` | Letzter Monats-Reset (1. des Monats) |
225
- | `lastYearStart` | Vertragsbeginn / Jahresstart |
215
+ | Datenpunkt | Beschreibung |
216
+ | ---------------- | ---------------------------------------- |
217
+ | `averageDaily` | Durchschnittlicher Tagesverbrauch |
218
+ | `averageMonthly` | Durchschnittlicher Monatsverbrauch |
219
+ | `lastDay` | Verbrauch **gestern** (Vortag) |
220
+ | `lastWeek` | Verbrauch **letzte Woche** |
221
+ | `lastMonth` | Verbrauch **letzter Monat** |
222
+ | `lastDayStart` | Letzter Tages-Reset (23:59 Uhr) |
223
+ | `lastWeekStart` | Letzter Wochen-Reset (Sonntag 23:59) |
224
+ | `lastMonthStart` | Letzter Monats-Reset (letzter Tag 23:59) |
225
+ | `lastYearStart` | Vertragsbeginn / Jahresstart |
226
226
 
227
227
  ---
228
228
 
@@ -308,7 +308,12 @@ Der Adapter setzt Zähler automatisch zurück:
308
308
 
309
309
  ## Changelog
310
310
 
311
- ### 1.5.0 (2026-01-23)
311
+ ### 1.5.1 (2026-01-26)
312
+
313
+ - **FIX:** 🕛 **Reset-Timing** - Automatische Resets werden nun um 23:59 Uhr ausgeführt (statt 00:00 Uhr)
314
+ - **FIX:** Utopische Werte in Monthly/MonthlyVolume (DP Monthly)
315
+
316
+ ### 1.5.0 (2026-01-25)
312
317
 
313
318
  - **NEU:** 📥 **CSV Import** - Importiere historische Zählerstände einfach per Drag-and-Drop:
314
319
  - Neuer "Import"-Tab in der Konfiguration
package/io-package.json CHANGED
@@ -15,58 +15,6 @@
15
15
  "pl": "Nowość: funkcja importu CSV z obsługą przeciągnij i upuść. Nowość: tygodniowe punkty śledzenia. Naprawa: resety wykonywane teraz o 23:59. Refaktoryzacja: modułowa architektura backendu.",
16
16
  "uk": "Нове: функція імпорту CSV з підтримкою перетягування. Нове: щотижневі точки відстеження. Виправлення: скидання тепер виконується о 23:59. Рефакторинг: модульна архітектура бекенда.",
17
17
  "zh-cn": "新增:支持拖放的 CSV 导入功能。新增:每周跟踪点。修复:现在在 23:59 执行重置。重构:模块化后端架构。"
18
- },
19
- "1.4.7": {
20
- "en": "Fix: Removed redundant initialization code causing state object warnings. Fix: Race conditions in multi-meter initialization. New: Support for German umlauts (ä,ö,ü,ß) in meter names.",
21
- "de": "Fix: Redundanten Initialisierungscode entfernt, der State-Object-Warnungen verursachte. Fix: Race Conditions bei Multi-Meter-Initialisierung. Neu: Unterstützung für deutsche Umlaute (ä,ö,ü,ß) in Zählernamen.",
22
- "ru": "Исправление: удален избыточный код инициализации, вызывавший предупреждения об объектах состояния. Исправление: состояния гонки при инициализации нескольких счетчиков. Новое: поддержка немецких умляутов (ä, ö, ü, ß) в названиях счетчиков.",
23
- "pt": "Correção: código de inicialização redundante removido que causava avisos de objetos de estado. Correção: Condições de corrida na inicialização do multi-medidor. Novo: Suporte para tremas alemães (ä, ö, ü, ß) em nomes de medidores.",
24
- "nl": "Fix: Overbodige initialisatiecode verwijderd die waarschuwingen voor staatsobjecten veroorzaakte. Fix: Race-omstandigheden bij initialisatie van meerdere meters. Nieuw: Ondersteuning voor Duitse umlauten (ä, ö, ü, ß) in meternamen.",
25
- "fr": "Correction : suppression du code d'initialisation redondant provoquant des avertissements d'objet d'état. Correction : conditions de concurrence lors de l'initialisation de plusieurs compteurs. Nouveau : prise en charge des trémas allemands (ä, ö, ü, ß) dans les noms des compteurs.",
26
- "it": "Correzione: rimosso il codice di inizializzazione ridondante che causava avvisi sugli oggetti di stato. Correzione: race condition nell'inizializzazione di più contatori. Nuovo: supporto per le dieresi tedesche (ä, ö, ü, ß) nei nomi dei contatori.",
27
- "es": "Solución: se eliminó el código de inicialización redundante que causaba advertencias de objetos de estado. Solución: condiciones de carrera en la inicialización de múltiples medidores. Nuevo: soporte para diéresis alemanas (ä, ö, ü, ß) en nombres de medidores.",
28
- "pl": "Naprawa: usunięto nadmiarowy kod inicjalizacji powodujący ostrzeżenia o obiektach stanu. Naprawa: wyścigi podczas inicjalizacji wielu liczników. Nowość: obsługa niemieckich umlautów (ä, ö, ü, ß) w nazwach liczników.",
29
- "uk": "Виправлення: видалено надлишковий код ініціалізації, що викликав попередження про об'єкти стану. Виправлення: стан гонки при ініціалізації декількох лічильників. Нове: підтримка німецьких умляутів (ä, ö, ü, ß) у назвах лічильників.",
30
- "zh-cn": "修复:删除了导致状态对象警告的冗余初始化代码。修复:多表初始化中的竞态条件。新增:支持仪表名称中的德语变音符号 (ä, ö, ü, ß)。"
31
- },
32
- "1.4.6": {
33
- "en": "Breaking Change: Main meter now requires a name (default: 'main'). State paths changed from 'gas.*' to 'gas.METER_NAME.*'. All meters now use consistent naming structure.",
34
- "de": "Breaking Change: Hauptzähler benötigt jetzt einen Namen (Standard: 'main'). State-Pfade geändert von 'gas.*' zu 'gas.METER_NAME.*'. Alle Zähler nutzen jetzt konsistente Namensstruktur.",
35
- "ru": "Критическое изменение: для главного счетчика теперь требуется имя (по умолчанию — «main»). Пути состояний изменены с 'gas.*' на 'gas.METER_NAME.*'. Все счетчики теперь используют согласованную структуру именования.",
36
- "pt": "Mudança estrutural: o medidor principal agora requer um nome (padrão: 'main'). Os caminhos de estado mudaram de 'gas.*' para 'gas.METER_NAME.*'. Todos os medidores agora usam uma estrutura de nomenclatura consistente.",
37
- "nl": "Breaking Change: Hoofdmeter vereist nu een naam (standaard: 'main'). Staatspaden gewijzigd van 'gas.*' naar 'gas.METER_NAME.*'. Alle meters gebruiken nu een consistente naamstructuur.",
38
- "fr": "Changement radical : le compteur principal nécessite désormais un nom (par défaut : 'main'). Les chemins d'état sont passés de 'gas.*' à 'gas.METER_NAME.*'. Tous los compteurs utilisent désormais une structure de dénomination cohérente.",
39
- "it": "Cambiamento radicale: il contatore principale ora richiede un nome (predefinito: 'main'). I percorsi di stato sono cambiati da 'gas.*' a 'gas.METER_NAME.*'. Tutti i contatori ora utilizzano una struttura di denominazione coerente.",
40
- "es": "Cambio importante: el medidor principal ahora requiere un nombre (predeterminado: 'main'). Las rutas de estado cambiaron de 'gas.*' na 'gas.METER_NAME.*'. Todos los medidores ahora utilizan una estructura de nombres consistente.",
41
- "pl": "Przełomowa zmiana: licznik główny wymaga teraz nazwy (domyślnie „main”). Ścieżki stanów zmieniły się z „gas.*” na „gas.METER_NAME.*”. Wszystkie liczniki używają teraz spójnej struktury nazewnictwa.",
42
- "uk": "Критична зміна: для головного лічильника тепер потрібна назва (за замовчуванням — «main»). Шляхи статусів змінено з 'gas.*' на 'gas.METER_NAME.*'. Тепер усі лічильники використовують єдину структуру назв.",
43
- "zh-cn": "重大更改:主表现在需要一个名称(默认值:'main')。状态路径从 'gas.*' 更改为 'gas.METER_NAME.*'。所有计量表现在都使用一致的命名结构。"
44
- },
45
- "1.4.5": {
46
- "en": "Fix: Critical multi-meter cost calculation bugs (main meter sync, basicCharge/paidTotal accumulation, annualFee as fixed yearly value). Fix: Balance formula corrected. Fix: Removed duplicate initialization causing sync issues.",
47
- "de": "Fix: Kritische Multi-Meter Kostenberechnungsfehler (Hauptzähler-Sync, basicCharge/paidTotal Akkumulation, Jahresgebühr als fester Jahreswert). Fix: Balance-Formel korrigiert. Fix: Doppelte Initialisierung entfernt.",
48
- "ru": "Исправление: Критические ошибки расчета затрат multi-meter (синхронизация главного счетчика, накопление basicCharge/paidTotal, annualFee как фиксированное годовое значение). Исправление: Формула баланса исправлена. Исправление: Удалена дублирующая инициализация.",
49
- "pt": "Correção: Bugs críticos de cálculo de custos multi-medidor (sincronização medidor principal, acumulação basicCharge/paidTotal, annualFee como valor anual fixo). Correção: Fórmula de saldo corrigida. Correção: Inicialização duplicada removida.",
50
- "nl": "Fix: Kritieke multi-meter kostenberekeningsfouten (hoofdmeter sync, basicCharge/paidTotal accumulatie, annualFee als vaste jaarwaarde). Fix: Balans formule gecorrigeerd. Fix: Dubbele initialisatie verwijderd.",
51
- "fr": "Correction: Bugs critiques calcul coûts multi-compteurs (sync compteur principal, accumulation basicCharge/paidTotal, annualFee comme valeur annuelle fixe). Correction: Formule de solde corrigée. Correction: Initialisation en double supprimée.",
52
- "it": "Correzione: Bug critici calcolo costi multi-contatore (sync contatore principale, accumulazione basicCharge/paidTotal, annualFee come valore annuale fisso). Correzione: Formula bilancio corretta. Correzione: Inizializzazione duplicata rimossa.",
53
- "es": "Corrección: Bugs críticos cálculo costos multi-medidor (sincronización medidor principal, acumulación basicCharge/paidTotal, annualFee como valor anual fijo). Corrección: Fórmula balance corregida. Corrección: Inicialización duplicada eliminada.",
54
- "pl": "Naprawa: Krytyczne błędy obliczania kosztów multi-meter (synchronizacja głównego licznika, akumulacja basicCharge/paidTotal, annualFee jako stała wartość roczna). Naprawa: Formuła salda poprawiona. Naprawa: Usunięto podwójną inicjalizację.",
55
- "uk": "Виправлення: Критичні помилки розрахунку витрат multi-meter (синхронізація головного лічильника, накопичення basicCharge/paidTotal, annualFee як фіксоване річне значення). Виправлення: Формула балансу виправлена. Виправлення: Видалено подвійну ініціалізацію.",
56
- "zh-cn": "修复:关键多表成本计算错误(主表同步、basicCharge/paidTotal累积、annualFee作为固定年值)。修复:余额公式已更正。修复:删除重复初始化。"
57
- },
58
- "1.4.2": {
59
- "en": "Fix: Critical multi-meter balance bug (hardcoded 12 months). Fix: TypeScript errors resolved. New: Enhanced input validation. New: Extended constants. New: Error handling wrapper.",
60
- "de": "Fix: Kritischer Multi-Meter Balance-Bug (hardcodierte 12 Monate). Fix: TypeScript-Fehler behoben. Neu: Erweiterte Eingabevalidierung. Neu: Erweiterte Konstanten. Neu: Fehlerbehandlungs-Wrapper.",
61
- "ru": "Исправление: Критическая ошибка баланса multi-meter (жестко заданные 12 месяцев). Исправление: Устранены ошибки TypeScript. Новое: Расширенная проверка входных данных. Новое: Расширенные константы. Новое: Обработка ошибок.",
62
- "pt": "Correção: Bug crítico de saldo multi-medidor (12 meses fixos). Correção: Erros TypeScript resolvidos. Novo: Validação de entrada aprimorada. Novo: Constantes estendidas. Novo: Wrapper de tratamento de erros.",
63
- "nl": "Fix: Kritieke multi-meter balans bug (hardcoded 12 maanden). Fix: TypeScript-fouten opgelost. Nieuw: Verbeterde invoervalidatie. Nieuw: Uitgebreide constanten. Nieuw: Foutafhandeling wrapper.",
64
- "fr": "Correction: Bug critique de solde multi-compteurs (12 mois codés en dur). Correction: Erreurs TypeScript résolues. Nouveau: Validation d'entrée améliorée. Nouveau: Constantes étendues. Nouveau: Wrapper de gestion des erreurs.",
65
- "it": "Correzione: Bug critico bilancio multi-contatore (12 mesi hardcoded). Correzione: Errori TypeScript risolti. Nuovo: Validazione input migliorata. Nuovo: Costanti estese. Nuovo: Wrapper gestione errori.",
66
- "es": "Corrección: Bug crítico de saldo multi-medidor (12 meses fijos). Corrección: Errores TypeScript resueltos. Nuevo: Validación de entrada mejorada. Nuevo: Constantes extendidas. Nuevo: Wrapper manejo errores.",
67
- "pl": "Naprawa: Krytyczny błąd salda multi-meter (zakodowane 12 miesięcy). Naprawa: Naprawione błędy TypeScript. Nowe: Ulepszona walidacja danych. Nowe: Rozszerzone stałe. Nowe: Wrapper obsługi błędów.",
68
- "uk": "Виправлення: Критична помилка балансу multi-meter (жорстко задані 12 місяців). Виправлення: Усунено помилки TypeScript. Нове: Покращена перевірка даних. Нове: Розширені константи. Нове: Обробник помилок.",
69
- "zh-cn": "修复:关键多表余额错误(硬编码12个月)。修复:解决TypeScript错误。新增:增强输入验证。新增:扩展常量。新增:错误处理包装器。"
70
18
  }
71
19
  },
72
20
  "titleLang": {
@@ -419,11 +419,96 @@ class BillingManager {
419
419
  return;
420
420
  }
421
421
 
422
+ // Archive data for this meter
422
423
  const year = startDate.getFullYear();
423
424
 
424
- // Archive data for this meter
425
- // TODO: Implement full history archiving for individual meters
426
- // 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);
427
512
 
428
513
  // Reset consumption and costs for this meter
429
514
  await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, 0, true);
@@ -539,24 +624,43 @@ class BillingManager {
539
624
  // This ensures History adapter sees clean day boundaries
540
625
  const isResetTime = nowDate.getHours() === 23 && nowDate.getMinutes() === 59;
541
626
 
542
- // Helper: Check if timestamp is from today
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
543
636
  const todayStart = new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate()).getTime();
544
- const isFromToday = timestamp => timestamp >= todayStart;
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
+ };
545
645
 
546
646
  // DAILY RESET: Trigger at 23:59 if today's reset hasn't happened yet
547
647
  const lastDayStart = await this.adapter.getStateAsync(`${basePath}.statistics.lastDayStart`);
548
648
  if (lastDayStart?.val) {
549
649
  const lastResetTime = lastDayStart.val;
550
- const alreadyResetToday = isFromToday(lastResetTime);
650
+ const alreadyResetToday = isValidResetToday(lastResetTime);
551
651
 
552
652
  // Reset at 23:59 if not yet reset today, OR catch up if we missed it (e.g. adapter was offline)
553
653
  if (isResetTime && !alreadyResetToday) {
554
654
  this.adapter.log.info(`Täglicher Reset für ${type} um 23:59`);
555
- await this.resetDailyCounters(type);
655
+ await this.resetDailyCounters(type, getNormalizedResetTime(nowDate));
556
656
  } else if (!alreadyResetToday && nowDate.getTime() > lastResetTime + 24 * 60 * 60 * 1000) {
557
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);
558
662
  this.adapter.log.info(`Täglicher Reset für ${type} (Nachholung - Adapter war offline)`);
559
- await this.resetDailyCounters(type);
663
+ await this.resetDailyCounters(type, catchUpTime);
560
664
  }
561
665
  }
562
666
 
@@ -567,16 +671,24 @@ class BillingManager {
567
671
  const isSunday = nowDate.getDay() === 0; // 0 = Sunday
568
672
 
569
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
570
676
  const daysSinceLastReset = (nowDate.getTime() - lastWeekTime) / (24 * 60 * 60 * 1000);
571
- const needsWeeklyReset = daysSinceLastReset >= 6;
677
+ const lastWeekResetDate = new Date(lastWeekTime);
678
+ const wasValidWeeklyReset = lastWeekResetDate.getHours() === 23;
679
+ const needsWeeklyReset = daysSinceLastReset >= 6 || (isSunday && !wasValidWeeklyReset);
572
680
 
573
681
  if (isSunday && isResetTime && needsWeeklyReset) {
574
682
  this.adapter.log.info(`Wöchentlicher Reset für ${type} um 23:59`);
575
- await this.resetWeeklyCounters(type);
576
- } else if (needsWeeklyReset && daysSinceLastReset > 7) {
577
- // Catch-up: More than 7 days since last reset
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);
578
690
  this.adapter.log.info(`Wöchentlicher Reset für ${type} (Nachholung)`);
579
- await this.resetWeeklyCounters(type);
691
+ await this.resetWeeklyCounters(type, catchUpTime);
580
692
  }
581
693
  }
582
694
 
@@ -589,14 +701,21 @@ class BillingManager {
589
701
  const isLastDayOfMonth =
590
702
  new Date(nowDate.getFullYear(), nowDate.getMonth() + 1, 0).getDate() === nowDate.getDate();
591
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);
592
708
 
593
- if (isLastDayOfMonth && isResetTime && monthChanged) {
709
+ if (isLastDayOfMonth && isResetTime && needsMonthlyReset) {
594
710
  this.adapter.log.info(`Monatlicher Reset für ${type} um 23:59`);
595
- await this.resetMonthlyCounters(type);
711
+ await this.resetMonthlyCounters(type, getNormalizedResetTime(nowDate));
596
712
  } else if (monthChanged && nowDate.getDate() > 1) {
597
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);
598
717
  this.adapter.log.info(`Monatlicher Reset für ${type} (Nachholung)`);
599
- await this.resetMonthlyCounters(type);
718
+ await this.resetMonthlyCounters(type, catchUpTime);
600
719
  }
601
720
  }
602
721
  }
@@ -638,17 +757,19 @@ class BillingManager {
638
757
  this.adapter.log.info(
639
758
  `Yearly reset for ${meterBasePath} um 23:59 (Vertragsjubiläum morgen: ${contractStartDate})`,
640
759
  );
641
- await this.resetYearlyCountersForMeter(type, meter);
760
+ await this.resetYearlyCountersForMeter(type, meter, getNormalizedResetTime(nowDate));
642
761
 
643
762
  if (meters.length > 1) {
644
763
  await this.adapter.multiMeterManager.updateTotalCosts(type);
645
764
  }
646
765
  } else if (isPastAnniversary && needsReset) {
647
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);
648
769
  this.adapter.log.info(
649
770
  `Yearly reset for ${meterBasePath} (Nachholung - Jubiläum: ${contractStartDate})`,
650
771
  );
651
- await this.resetYearlyCountersForMeter(type, meter);
772
+ await this.resetYearlyCountersForMeter(type, meter, catchUpTime);
652
773
 
653
774
  if (meters.length > 1) {
654
775
  await this.adapter.multiMeterManager.updateTotalCosts(type);
@@ -663,15 +784,18 @@ class BillingManager {
663
784
  if (isDecember31 && isResetTime && !needsReset) {
664
785
  // Reset at 23:59 on Dec 31 for the upcoming year
665
786
  this.adapter.log.info(`Yearly reset for ${meterBasePath} um 23:59 (Kalenderjahr)`);
666
- await this.resetYearlyCountersForMeter(type, meter);
787
+ await this.resetYearlyCountersForMeter(type, meter, getNormalizedResetTime(nowDate));
667
788
 
668
789
  if (meters.length > 1) {
669
790
  await this.adapter.multiMeterManager.updateTotalCosts(type);
670
791
  }
671
792
  } else if (needsReset) {
672
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);
673
797
  this.adapter.log.info(`Yearly reset for ${meterBasePath} (Nachholung - Kalenderjahr)`);
674
- await this.resetYearlyCountersForMeter(type, meter);
798
+ await this.resetYearlyCountersForMeter(type, meter, catchUpTime);
675
799
 
676
800
  if (meters.length > 1) {
677
801
  await this.adapter.multiMeterManager.updateTotalCosts(type);
@@ -687,8 +811,9 @@ class BillingManager {
687
811
  * Resets daily counters
688
812
  *
689
813
  * @param {string} type - Utility type
814
+ * @param {number} [resetTimestamp] - Optional normalized timestamp (defaults to Date.now())
690
815
  */
691
- async resetDailyCounters(type) {
816
+ async resetDailyCounters(type, resetTimestamp) {
692
817
  this.adapter.log.info(`Resetting daily counters for ${type}`);
693
818
 
694
819
  // Get all meters for this type (main + additional meters)
@@ -736,8 +861,9 @@ class BillingManager {
736
861
 
737
862
  await this.adapter.setStateAsync(`${basePath}.costs.daily`, 0, true);
738
863
 
739
- // Update lastDayStart timestamp
740
- 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);
741
867
 
742
868
  await this.adapter.setStateAsync(
743
869
  `${basePath}.statistics.averageDaily`,
@@ -756,8 +882,9 @@ class BillingManager {
756
882
  * Resets monthly counters
757
883
  *
758
884
  * @param {string} type - Utility type
885
+ * @param {number} [resetTimestamp] - Optional normalized timestamp (defaults to Date.now())
759
886
  */
760
- async resetMonthlyCounters(type) {
887
+ async resetMonthlyCounters(type, resetTimestamp) {
761
888
  this.adapter.log.info(`Resetting monthly counters for ${type}`);
762
889
 
763
890
  // Get all meters for this type (main + additional meters)
@@ -803,8 +930,9 @@ class BillingManager {
803
930
 
804
931
  await this.adapter.setStateAsync(`${basePath}.costs.monthly`, 0, true);
805
932
 
806
- // Update lastMonthStart timestamp
807
- 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);
808
936
 
809
937
  await this.adapter.setStateAsync(
810
938
  `${basePath}.statistics.averageMonthly`,
@@ -867,8 +995,9 @@ class BillingManager {
867
995
  *
868
996
  * @param {string} type - Utility type
869
997
  * @param {object} meter - Meter object from multiMeterManager
998
+ * @param {number} [resetTimestamp] - Optional normalized timestamp (defaults to Date.now())
870
999
  */
871
- async resetYearlyCountersForMeter(type, meter) {
1000
+ async resetYearlyCountersForMeter(type, meter, resetTimestamp) {
872
1001
  const basePath = `${type}.${meter.name}`;
873
1002
  const label = meter.displayName || meter.name;
874
1003
 
@@ -884,16 +1013,18 @@ class BillingManager {
884
1013
  await this.adapter.setStateAsync(`${basePath}.billing.notificationSent`, false, true);
885
1014
  await this.adapter.setStateAsync(`${basePath}.billing.notificationChangeSent`, false, true);
886
1015
 
887
- // Update lastYearStart timestamp
888
- 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);
889
1019
  }
890
1020
 
891
1021
  /**
892
1022
  * Resets weekly counters
893
1023
  *
894
1024
  * @param {string} type - Utility type
1025
+ * @param {number} [resetTimestamp] - Optional normalized timestamp (defaults to Date.now())
895
1026
  */
896
- async resetWeeklyCounters(type) {
1027
+ async resetWeeklyCounters(type, resetTimestamp) {
897
1028
  this.adapter.log.info(`Resetting weekly counters for ${type}`);
898
1029
 
899
1030
  const meters = this.adapter.multiMeterManager?.getMetersForType(type) || [];
@@ -926,7 +1057,9 @@ class BillingManager {
926
1057
  await this.adapter.setStateAsync(`${basePath}.costs.weeklyNT`, 0, true);
927
1058
  }
928
1059
 
929
- await this.adapter.setStateAsync(`${basePath}.statistics.lastWeekStart`, Date.now(), true);
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);
930
1063
  }
931
1064
 
932
1065
  if (meters.length > 1) {
package/lib/calculator.js CHANGED
@@ -1,23 +1,34 @@
1
1
  const helpers = require('./utils/helpers');
2
2
 
3
3
  /**
4
- * Converts gas volume from to kWh
5
- * Formula: kWh = × Brennwert × Z-Zahl
4
+ * @file Calculator module for utility consumption and cost calculations.
5
+ * Contains functions for gas conversion, cost calculation, and tariff handling.
6
+ * @module calculator
7
+ */
8
+
9
+ /**
10
+ * Converts gas volume from cubic meters (m³) to kilowatt-hours (kWh).
11
+ * The conversion uses the standard German gas billing formula:
12
+ * kWh = m³ × Brennwert (calorific value) × Z-Zahl (state number)
13
+ * Brennwert: Energy content per cubic meter, typically 9.5-11.5 kWh/m³.
14
+ * Z-Zahl: Correction factor for temperature and pressure differences.
6
15
  *
7
- * @param {number} m3 - Volume in cubic meters
8
- * @param {number} brennwert - Calorific value (typically ~11.5 kWh/m³)
9
- * @param {number} zZahl - Z-number/state number (typically ~0.95)
10
- * @returns {number} Energy in kWh
16
+ * @param {number} m3 - Gas volume in cubic meters (must be >= 0)
17
+ * @param {number} brennwert - Calorific value in kWh/m³ (must be > 0)
18
+ * @param {number} zZahl - State number (must be > 0 and <= 1)
19
+ * @returns {number} Energy consumption in kWh
20
+ * @throws {RangeError} If parameters are outside valid ranges
11
21
  */
12
22
  function convertGasM3ToKWh(m3, brennwert = 11.5, zZahl = 0.95) {
13
- // Hier number zu string
14
23
  const cleanM3 = helpers.ensureNumber(m3);
15
24
  const cleanBrennwert = helpers.ensureNumber(brennwert);
16
25
  const cleanZZahl = helpers.ensureNumber(zZahl);
17
26
 
18
- // Validierung der Logik (jetzt mit den konvertierten Zahlen)
27
+ // Validate parameters
19
28
  if (cleanM3 < 0 || cleanBrennwert <= 0 || cleanZZahl <= 0 || cleanZZahl > 1) {
20
- throw new RangeError('Ungültige Parameterwerte für die Gas-Umrechnung');
29
+ throw new RangeError(
30
+ 'Invalid parameters for gas conversion: m3 must be >= 0, brennwert must be > 0, zZahl must be > 0 and <= 1',
31
+ );
21
32
  }
22
33
 
23
34
  return cleanM3 * cleanBrennwert * cleanZZahl;
@@ -53,11 +64,14 @@ function calculateCost(consumption, price) {
53
64
  }
54
65
 
55
66
  /**
56
- * Checks if the current time is within the High Tariff (HT) period
67
+ * Checks if the current time falls within the High Tariff (HT) period.
68
+ * German electricity providers often offer dual-tariff rates:
69
+ * HT (Haupttarif): Higher rate during peak hours (typically 6:00-22:00)
70
+ * NT (Nebentarif): Lower rate during off-peak hours (typically 22:00-6:00)
57
71
  *
58
- * @param {object} config - Adapter configuration
59
- * @param {string} type - Utility type: 'gas' or 'strom'
60
- * @returns {boolean} True if current time is HT, false if NT
72
+ * @param {object} config - Adapter configuration object with HT/NT settings
73
+ * @param {string} type - Utility type identifier: 'gas', 'strom', 'wasser', or 'pv'
74
+ * @returns {boolean} True if current time is within HT period, false for NT
61
75
  */
62
76
  function isHTTime(config, type) {
63
77
  if (!config || !type) {
@@ -307,9 +307,144 @@ class MultiMeterManager {
307
307
  // Initial cost calculation
308
308
  await this.updateCosts(type, meterName, config);
309
309
 
310
+ // Reconstruct weekly consumption from daily values if needed
311
+ await this.reconstructPeriodConsumption(type, meterName, basePath);
312
+
310
313
  this.adapter.log.debug(`Meter initialization completed for ${type}.${meterName}`);
311
314
  }
312
315
 
316
+ /**
317
+ * Reconstructs weekly and monthly consumption values after adapter restart
318
+ * This fixes data loss when adapter was offline and missed delta accumulation
319
+ *
320
+ * @param {string} type - Utility type
321
+ * @param {string} meterName - Meter name
322
+ * @param {string} basePath - State base path
323
+ */
324
+ async reconstructPeriodConsumption(type, meterName, basePath) {
325
+ const now = Date.now();
326
+
327
+ // Get period start timestamps
328
+ const lastWeekStartState = await this.adapter.getStateAsync(`${basePath}.statistics.lastWeekStart`);
329
+ const lastDayStartState = await this.adapter.getStateAsync(`${basePath}.statistics.lastDayStart`);
330
+
331
+ if (!lastWeekStartState?.val || !lastDayStartState?.val) {
332
+ this.adapter.log.debug(`[${basePath}] No period timestamps found, skipping reconstruction`);
333
+ return;
334
+ }
335
+
336
+ const lastWeekStart = lastWeekStartState.val;
337
+ const lastDayStart = lastDayStartState.val;
338
+
339
+ // Calculate days since week start
340
+ const daysSinceWeekStart = (now - lastWeekStart) / (24 * 60 * 60 * 1000);
341
+
342
+ // Only reconstruct if we're within a valid week (0-7 days)
343
+ if (daysSinceWeekStart < 0 || daysSinceWeekStart > 7) {
344
+ this.adapter.log.debug(
345
+ `[${basePath}] Week period out of range (${daysSinceWeekStart.toFixed(1)} days), skipping reconstruction`,
346
+ );
347
+ return;
348
+ }
349
+
350
+ // Get current consumption values
351
+ const weeklyState = await this.adapter.getStateAsync(`${basePath}.consumption.weekly`);
352
+ const dailyState = await this.adapter.getStateAsync(`${basePath}.consumption.daily`);
353
+ const lastDayState = await this.adapter.getStateAsync(`${basePath}.statistics.lastDay`);
354
+
355
+ const currentWeekly = weeklyState?.val || 0;
356
+ const currentDaily = dailyState?.val || 0;
357
+ const lastDay = lastDayState?.val || 0;
358
+
359
+ // Calculate expected weekly based on lastDay values accumulated since lastWeekStart
360
+ // Simple approach: If daily counter was reset today and we have lastDay,
361
+ // weekly should be at least lastDay + currentDaily
362
+ const daysSinceDayReset = (now - lastDayStart) / (24 * 60 * 60 * 1000);
363
+
364
+ // If daily was reset (daysSinceDayReset < 1) and weekly seems too low
365
+ if (daysSinceDayReset < 1 && currentWeekly < lastDay + currentDaily) {
366
+ // Weekly might have missed the lastDay value
367
+ // This can happen if adapter restarted after daily reset
368
+ const reconstructedWeekly = calculator.roundToDecimals(currentWeekly + lastDay, 2);
369
+
370
+ if (reconstructedWeekly > currentWeekly) {
371
+ this.adapter.log.info(
372
+ `[${basePath}] Reconstructing weekly: ${currentWeekly} -> ${reconstructedWeekly} (added lastDay: ${lastDay})`,
373
+ );
374
+ await this.adapter.setStateAsync(`${basePath}.consumption.weekly`, reconstructedWeekly, true);
375
+
376
+ // Also reconstruct gas volume if applicable
377
+ if (type === 'gas') {
378
+ const weeklyVolumeState = await this.adapter.getStateAsync(`${basePath}.consumption.weeklyVolume`);
379
+ const lastDayVolumeState = await this.adapter.getStateAsync(`${basePath}.statistics.lastDayVolume`);
380
+ const currentWeeklyVolume = weeklyVolumeState?.val || 0;
381
+ const lastDayVolume = lastDayVolumeState?.val || 0;
382
+
383
+ if (lastDayVolume > 0) {
384
+ const reconstructedWeeklyVolume = calculator.roundToDecimals(
385
+ currentWeeklyVolume + lastDayVolume,
386
+ 4,
387
+ );
388
+ await this.adapter.setStateAsync(
389
+ `${basePath}.consumption.weeklyVolume`,
390
+ reconstructedWeeklyVolume,
391
+ true,
392
+ );
393
+ }
394
+ }
395
+ }
396
+ }
397
+
398
+ // Similar logic for monthly reconstruction
399
+ const lastMonthStartState = await this.adapter.getStateAsync(`${basePath}.statistics.lastMonthStart`);
400
+ if (lastMonthStartState?.val) {
401
+ const lastMonthStart = lastMonthStartState.val;
402
+ const daysSinceMonthStart = (now - lastMonthStart) / (24 * 60 * 60 * 1000);
403
+
404
+ // Only if within valid month range (0-31 days)
405
+ if (daysSinceMonthStart >= 0 && daysSinceMonthStart <= 31) {
406
+ const monthlyState = await this.adapter.getStateAsync(`${basePath}.consumption.monthly`);
407
+ const currentMonthly = monthlyState?.val || 0;
408
+
409
+ // If daily was reset and monthly seems to be missing the lastDay
410
+ if (daysSinceDayReset < 1 && currentMonthly < lastDay + currentDaily) {
411
+ const reconstructedMonthly = calculator.roundToDecimals(currentMonthly + lastDay, 2);
412
+
413
+ if (reconstructedMonthly > currentMonthly) {
414
+ this.adapter.log.info(
415
+ `[${basePath}] Reconstructing monthly: ${currentMonthly} -> ${reconstructedMonthly} (added lastDay: ${lastDay})`,
416
+ );
417
+ await this.adapter.setStateAsync(`${basePath}.consumption.monthly`, reconstructedMonthly, true);
418
+
419
+ // Also reconstruct gas volume if applicable
420
+ if (type === 'gas') {
421
+ const monthlyVolumeState = await this.adapter.getStateAsync(
422
+ `${basePath}.consumption.monthlyVolume`,
423
+ );
424
+ const lastDayVolumeState = await this.adapter.getStateAsync(
425
+ `${basePath}.statistics.lastDayVolume`,
426
+ );
427
+ const currentMonthlyVolume = monthlyVolumeState?.val || 0;
428
+ const lastDayVolume = lastDayVolumeState?.val || 0;
429
+
430
+ if (lastDayVolume > 0) {
431
+ const reconstructedMonthlyVolume = calculator.roundToDecimals(
432
+ currentMonthlyVolume + lastDayVolume,
433
+ 4,
434
+ );
435
+ await this.adapter.setStateAsync(
436
+ `${basePath}.consumption.monthlyVolume`,
437
+ reconstructedMonthlyVolume,
438
+ true,
439
+ );
440
+ }
441
+ }
442
+ }
443
+ }
444
+ }
445
+ }
446
+ }
447
+
313
448
  /**
314
449
  * Handles sensor value updates
315
450
  *
@@ -463,6 +598,10 @@ class MultiMeterManager {
463
598
  if (recoveredValue > 0 && Math.abs(consumption - recoveredValue) < 100) {
464
599
  this.adapter.log.info(`[${basePath}] Recovered persistent baseline: ${recoveredValue}`);
465
600
  this.lastSensorValues[sensorDP] = recoveredValue;
601
+
602
+ // Validate period consumption values against spike threshold
603
+ // This catches cases where old consumption values are unrealistically high
604
+ await this._validatePeriodConsumption(type, basePath, now);
466
605
  } else {
467
606
  if (recoveredValue > 0) {
468
607
  this.adapter.log.warn(
@@ -479,6 +618,9 @@ class MultiMeterManager {
479
618
  await this.adapter.setStateAsync(`${basePath}.info.meterReadingVolume`, consumptionM3 || 0, true);
480
619
  }
481
620
 
621
+ // On baseline reset, validate and potentially reset period consumption values
622
+ await this._validatePeriodConsumption(type, basePath, now);
623
+
482
624
  if (config.initialReading > 0) {
483
625
  await this.calculateAbsoluteYearly(type, meterName, config, consumption, consumptionM3 || 0, now);
484
626
  await this.updateCosts(type, meterName, config);
@@ -489,6 +631,48 @@ class MultiMeterManager {
489
631
  await this.adapter.setStateAsync(`${basePath}.consumption.lastUpdate`, now, true);
490
632
  }
491
633
 
634
+ /**
635
+ * Validates period consumption values and resets them if they exceed the spike threshold.
636
+ * This prevents unrealistic values after adapter restart or database inconsistencies.
637
+ *
638
+ * @param {string} type - Utility type
639
+ * @param {string} basePath - State base path
640
+ * @param {number} now - Current timestamp
641
+ */
642
+ async _validatePeriodConsumption(type, basePath, now) {
643
+ const spikeThreshold = this.adapter.config.sensorSpikeThreshold || DEFAULT_SPIKE_THRESHOLD;
644
+
645
+ // Check and reset period consumption values that exceed the spike threshold
646
+ const periods = ['daily', 'weekly', 'monthly'];
647
+ for (const period of periods) {
648
+ const state = await this.adapter.getStateAsync(`${basePath}.consumption.${period}`);
649
+ const value = state?.val || 0;
650
+
651
+ // Get period start timestamp to calculate expected max consumption
652
+ const periodStartState = await this.adapter.getStateAsync(
653
+ `${basePath}.statistics.last${period.charAt(0).toUpperCase() + period.slice(1)}Start`,
654
+ );
655
+ const periodStart = periodStartState?.val || now;
656
+ const daysSincePeriodStart = (now - periodStart) / (24 * 60 * 60 * 1000);
657
+
658
+ // Calculate reasonable max: spike threshold per day * days in period
659
+ // Add buffer of 2x for safety
660
+ const maxReasonableConsumption = spikeThreshold * Math.max(1, daysSincePeriodStart) * 2;
661
+
662
+ if (value > maxReasonableConsumption) {
663
+ this.adapter.log.warn(
664
+ `[${basePath}] Resetting ${period} consumption: ${value} exceeds reasonable max of ${maxReasonableConsumption.toFixed(0)} (${daysSincePeriodStart.toFixed(1)} days * ${spikeThreshold} threshold * 2)`,
665
+ );
666
+ await this.adapter.setStateAsync(`${basePath}.consumption.${period}`, 0, true);
667
+
668
+ // Also reset volume states for gas
669
+ if (type === 'gas') {
670
+ await this.adapter.setStateAsync(`${basePath}.consumption.${period}Volume`, 0, true);
671
+ }
672
+ }
673
+ }
674
+ }
675
+
492
676
  /**
493
677
  * Handles meter reset or replacement condition
494
678
  *
@@ -166,6 +166,59 @@ async function safeSetObjectNotExists(adapter, id, obj) {
166
166
  }
167
167
  }
168
168
 
169
+ /**
170
+ * Safe execution wrapper for async operations with error handling.
171
+ * Catches errors and logs them without crashing the adapter.
172
+ *
173
+ * @param {object} adapter - Adapter instance
174
+ * @param {Function} fn - Async function to execute
175
+ * @param {string} context - Context description for error logging
176
+ * @param {any} fallback - Fallback value to return on error
177
+ * @returns {Promise<any>} Result of fn() or fallback on error
178
+ */
179
+ async function safeExecute(adapter, fn, context, fallback = null) {
180
+ try {
181
+ return await fn();
182
+ } catch (error) {
183
+ if (adapter && adapter.log) {
184
+ adapter.log.error(`[${context}] ${error.message}`);
185
+ }
186
+ return fallback;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Debounce function to limit execution frequency
192
+ *
193
+ * @param {Function} fn - Function to debounce
194
+ * @param {number} delay - Delay in milliseconds
195
+ * @returns {Function} Debounced function
196
+ */
197
+ function debounce(fn, delay) {
198
+ let timeoutId;
199
+ return function (...args) {
200
+ clearTimeout(timeoutId);
201
+ timeoutId = setTimeout(() => fn.apply(this, args), delay);
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Validates that a value is within a specified range
207
+ *
208
+ * @param {number} value - Value to validate
209
+ * @param {number} min - Minimum allowed value
210
+ * @param {number} max - Maximum allowed value
211
+ * @param {number} [defaultValue] - Default value if out of range
212
+ * @returns {number} Validated value or default
213
+ */
214
+ function validateRange(value, min, max, defaultValue = min) {
215
+ const num = ensureNumber(value);
216
+ if (num < min || num > max) {
217
+ return defaultValue;
218
+ }
219
+ return num;
220
+ }
221
+
169
222
  module.exports = {
170
223
  ensureNumber,
171
224
  roundToDecimals,
@@ -175,4 +228,7 @@ module.exports = {
175
228
  getMonthsDifference,
176
229
  normalizeMeterName,
177
230
  safeSetObjectNotExists,
231
+ safeExecute,
232
+ debounce,
233
+ validateRange,
178
234
  };
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * StateCache - Provides caching for adapter state operations.
5
+ * Reduces database queries by caching state values during update cycles.
6
+ */
7
+ class StateCache {
8
+ /**
9
+ * Creates a new StateCache instance
10
+ *
11
+ * @param {object} adapter - ioBroker adapter instance
12
+ * @param {object} [options] - Configuration options
13
+ * @param {number} [options.maxAge] - Maximum cache age in milliseconds (default: 60s)
14
+ * @param {number} [options.maxSize] - Maximum number of cached entries
15
+ */
16
+ constructor(adapter, options = {}) {
17
+ this.adapter = adapter;
18
+ this.cache = new Map();
19
+ this.maxAge = options.maxAge || 60000;
20
+ this.maxSize = options.maxSize || 1000;
21
+ this.hits = 0;
22
+ this.misses = 0;
23
+ }
24
+
25
+ /**
26
+ * Gets a state value, using cache if available
27
+ *
28
+ * @param {string} id - State ID
29
+ * @returns {Promise<any>} State value or null
30
+ */
31
+ async get(id) {
32
+ const cached = this.cache.get(id);
33
+ const now = Date.now();
34
+
35
+ if (cached && now - cached.timestamp < this.maxAge) {
36
+ this.hits++;
37
+ return cached.value;
38
+ }
39
+
40
+ this.misses++;
41
+ const state = await this.adapter.getStateAsync(id);
42
+ const value = state?.val ?? null;
43
+
44
+ this._set(id, value);
45
+ return value;
46
+ }
47
+
48
+ /**
49
+ * Gets a state object (with val, ack, ts), using cache if available
50
+ *
51
+ * @param {string} id - State ID
52
+ * @returns {Promise<object|null>} State object or null
53
+ */
54
+ async getState(id) {
55
+ const cached = this.cache.get(`${id}_state`);
56
+ const now = Date.now();
57
+
58
+ if (cached && now - cached.timestamp < this.maxAge) {
59
+ this.hits++;
60
+ return cached.value;
61
+ }
62
+
63
+ this.misses++;
64
+ const state = await this.adapter.getStateAsync(id);
65
+
66
+ this._set(`${id}_state`, state);
67
+ if (state) {
68
+ this._set(id, state.val);
69
+ }
70
+ return state;
71
+ }
72
+
73
+ /**
74
+ * Sets a state value and updates cache
75
+ *
76
+ * @param {string} id - State ID
77
+ * @param {any} value - Value to set
78
+ * @param {boolean} ack - Acknowledge flag
79
+ * @returns {Promise<void>}
80
+ */
81
+ async set(id, value, ack = true) {
82
+ await this.adapter.setStateAsync(id, value, ack);
83
+ this._set(id, value);
84
+ }
85
+
86
+ /**
87
+ * Internal method to add to cache
88
+ *
89
+ * @param {string} id - State ID
90
+ * @param {any} value - Value to cache
91
+ */
92
+ _set(id, value) {
93
+ // Evict oldest entries if cache is full
94
+ if (this.cache.size >= this.maxSize) {
95
+ const oldestKey = this.cache.keys().next().value;
96
+ this.cache.delete(oldestKey);
97
+ }
98
+
99
+ this.cache.set(id, {
100
+ value,
101
+ timestamp: Date.now(),
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Clears the entire cache.
107
+ * Should be called at the end of each update cycle.
108
+ */
109
+ clear() {
110
+ this.cache.clear();
111
+ }
112
+
113
+ /**
114
+ * Invalidates a specific cache entry
115
+ *
116
+ * @param {string} id - State ID to invalidate
117
+ */
118
+ invalidate(id) {
119
+ this.cache.delete(id);
120
+ this.cache.delete(`${id}_state`);
121
+ }
122
+
123
+ /**
124
+ * Gets cache statistics
125
+ *
126
+ * @returns {object} Cache statistics
127
+ */
128
+ getStats() {
129
+ const total = this.hits + this.misses;
130
+ return {
131
+ hits: this.hits,
132
+ misses: this.misses,
133
+ hitRate: total > 0 ? `${((this.hits / total) * 100).toFixed(1)}%` : '0%',
134
+ size: this.cache.size,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Resets cache statistics
140
+ */
141
+ resetStats() {
142
+ this.hits = 0;
143
+ this.misses = 0;
144
+ }
145
+ }
146
+
147
+ module.exports = StateCache;
package/main.js CHANGED
@@ -125,14 +125,10 @@ class UtilityMonitor extends utils.Adapter {
125
125
  for (const meter of additionalMeters) {
126
126
  if (meter && meter.name) {
127
127
  if (!meter.contractStart) {
128
- this.log.warn(
129
- `${type.label} Zähler "${meter.name}": Kein Vertragsbeginn konfiguriert!`,
130
- );
128
+ this.log.warn(`${type.label} Zähler "${meter.name}": Kein Vertragsbeginn konfiguriert!`);
131
129
  }
132
130
  if (!meter.sensorDP) {
133
- this.log.warn(
134
- `${type.label} Zähler "${meter.name}": Kein Sensor-Datenpunkt konfiguriert!`,
135
- );
131
+ this.log.warn(`${type.label} Zähler "${meter.name}": Kein Sensor-Datenpunkt konfiguriert!`);
136
132
  }
137
133
  }
138
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iobroker.utility-monitor",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "Monitor gas, water, and electricity consumption with cost calculation",
5
5
  "author": {
6
6
  "name": "fischi87",