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 +17 -12
- package/io-package.json +0 -52
- package/lib/billingManager.js +164 -31
- package/lib/calculator.js +27 -13
- package/lib/multiMeterManager.js +184 -0
- package/lib/utils/helpers.js +56 -0
- package/lib/utils/stateCache.js +147 -0
- package/main.js +2 -6
- package/package.json +1 -1
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 **
|
|
220
|
-
| `lastWeek` | Verbrauch **letzte Woche**
|
|
221
|
-
| `lastMonth` | Verbrauch **letzter Monat**
|
|
222
|
-
| `lastDayStart` | Letzter Tages-Reset (
|
|
223
|
-
| `lastWeekStart` | Letzter Wochen-Reset (
|
|
224
|
-
| `lastMonthStart` | Letzter Monats-Reset (
|
|
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.
|
|
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": {
|
package/lib/billingManager.js
CHANGED
|
@@ -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
|
-
|
|
425
|
-
|
|
426
|
-
//
|
|
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:
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
577
|
-
// Catch-up:
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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 -
|
|
8
|
-
* @param {number} brennwert - Calorific value
|
|
9
|
-
* @param {number} zZahl -
|
|
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
|
-
//
|
|
27
|
+
// Validate parameters
|
|
19
28
|
if (cleanM3 < 0 || cleanBrennwert <= 0 || cleanZZahl <= 0 || cleanZZahl > 1) {
|
|
20
|
-
throw new RangeError(
|
|
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
|
|
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 '
|
|
60
|
-
* @returns {boolean} True if current time is HT, false
|
|
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) {
|
package/lib/multiMeterManager.js
CHANGED
|
@@ -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
|
*
|
package/lib/utils/helpers.js
CHANGED
|
@@ -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
|
}
|