iobroker.utility-monitor 1.4.5 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +159 -44
  2. package/admin/custom/.vite/manifest.json +90 -0
  3. package/admin/custom/@mf-types/Components.d.ts +2 -0
  4. package/admin/custom/@mf-types/compiled-types/Components/CSVImporter.d.ts +11 -0
  5. package/admin/custom/@mf-types/compiled-types/Components.d.ts +2 -0
  6. package/admin/custom/@mf-types.d.ts +3 -0
  7. package/admin/custom/@mf-types.zip +0 -0
  8. package/admin/custom/CSVImporter_v15_11.js +4415 -0
  9. package/admin/custom/assets/Components-i0AZ59nl.js +18887 -0
  10. package/admin/custom/assets/UtilityMonitor__loadShare__react__loadShare__-Da99Mak4.js +42 -0
  11. package/admin/custom/assets/UtilityMonitor__mf_v__runtimeInit__mf_v__-BmC4OGk6.js +16 -0
  12. package/admin/custom/assets/_commonjsHelpers-Dj2_voLF.js +30 -0
  13. package/admin/custom/assets/hostInit-DEXfeB0W.js +10 -0
  14. package/admin/custom/assets/index-B3WVNJTz.js +401 -0
  15. package/admin/custom/assets/index-VBwl8x_k.js +64 -0
  16. package/admin/custom/assets/preload-helper-BelkbqnE.js +61 -0
  17. package/admin/custom/assets/virtualExposes-CqCLUNLT.js +19 -0
  18. package/admin/custom/index.html +12 -0
  19. package/admin/custom/mf-manifest.json +1 -0
  20. package/admin/jsonConfig.json +219 -35
  21. package/io-package.json +51 -2
  22. package/lib/billingManager.js +276 -170
  23. package/lib/calculator.js +19 -138
  24. package/lib/consumptionManager.js +48 -331
  25. package/lib/importManager.js +300 -0
  26. package/lib/messagingHandler.js +112 -49
  27. package/lib/meter/MeterRegistry.js +110 -0
  28. package/lib/multiMeterManager.js +410 -181
  29. package/lib/stateManager.js +508 -36
  30. package/lib/utils/billingHelper.js +69 -0
  31. package/lib/utils/consumptionHelper.js +47 -0
  32. package/lib/utils/helpers.js +178 -0
  33. package/lib/utils/typeMapper.js +19 -0
  34. package/main.js +99 -36
  35. package/package.json +10 -4
@@ -3,6 +3,14 @@
3
3
  const calculator = require('./calculator');
4
4
  const stateManager = require('./stateManager');
5
5
  const { parseConfigNumber } = require('./configParser');
6
+ const { getConfigType } = require('./utils/typeMapper');
7
+ const MeterRegistry = require('./meter/MeterRegistry');
8
+ const helpers = require('./utils/helpers');
9
+ const consumptionHelper = require('./utils/consumptionHelper');
10
+ const billingHelper = require('./utils/billingHelper');
11
+
12
+ // Default constants
13
+ const DEFAULT_SPIKE_THRESHOLD = 500; // Default sensor spike detection limit
6
14
 
7
15
  /**
8
16
  * MultiMeterManager handles multiple meters per utility type
@@ -19,41 +27,19 @@ class MultiMeterManager {
19
27
  this.consumptionManager = consumptionManager;
20
28
  this.billingManager = billingManager;
21
29
  this.lastSensorValues = {};
22
- this.meterRegistry = {}; // Maps sensorDP → {type, meterName}
30
+ this.meterRegistry = new MeterRegistry();
31
+ this.tempBaselineStore = {}; // Tracks first value after start to prevent peaks
23
32
  }
24
33
 
25
34
  /**
26
35
  * Maps internal utility type to config/state name
27
36
  *
37
+ * @deprecated Use getConfigType from utils/typeMapper directly
28
38
  * @param {string} type - gas, water, or electricity
29
39
  * @returns {string} - gas, wasser, or strom
30
40
  */
31
41
  getConfigType(type) {
32
- const mapping = {
33
- electricity: 'strom',
34
- water: 'wasser',
35
- gas: 'gas',
36
- };
37
- return mapping[type] || type;
38
- }
39
-
40
- /**
41
- * Normalizes meter name to valid folder name
42
- * Rules: lowercase, alphanumeric only, max 20 chars
43
- *
44
- * @param {string} name - User-provided meter name
45
- * @returns {string} - Normalized name
46
- */
47
- normalizeMeterName(name) {
48
- if (!name || typeof name !== 'string') {
49
- return 'unnamed';
50
- }
51
- return (
52
- name
53
- .toLowerCase()
54
- .replace(/[^a-z0-9]/g, '')
55
- .substring(0, 20) || 'unnamed'
56
- );
42
+ return getConfigType(type);
57
43
  }
58
44
 
59
45
  /**
@@ -70,8 +56,14 @@ class MultiMeterManager {
70
56
  // Main meter (always present if type is active)
71
57
  const mainActive = this.adapter.config[`${configType}Aktiv`];
72
58
  if (mainActive) {
59
+ // Get main meter name from config and normalize
60
+ const mainMeterName = this.adapter.config[`${configType}MainMeterName`] || 'main';
61
+ const normalizedName = helpers.normalizeMeterName(mainMeterName);
62
+ const displayName = mainMeterName; // Original name for display
63
+
73
64
  meters.push({
74
- name: 'main',
65
+ name: normalizedName,
66
+ displayName: displayName,
75
67
  config: {
76
68
  sensorDP: this.adapter.config[`${configType}SensorDP`],
77
69
  preis: parseConfigNumber(this.adapter.config[`${configType}Preis`], 0),
@@ -91,7 +83,7 @@ class MultiMeterManager {
91
83
  if (Array.isArray(additionalMeters)) {
92
84
  for (const meterConfig of additionalMeters) {
93
85
  if (meterConfig && meterConfig.name && meterConfig.sensorDP) {
94
- const normalizedName = this.normalizeMeterName(meterConfig.name);
86
+ const normalizedName = helpers.normalizeMeterName(meterConfig.name);
95
87
 
96
88
  // Debug: Log raw config for troubleshooting
97
89
  this.adapter.log.debug(
@@ -123,13 +115,13 @@ class MultiMeterManager {
123
115
  }
124
116
 
125
117
  /**
126
- * Finds meter by sensor datapoint
118
+ * Finds meters by sensor datapoint
127
119
  *
128
120
  * @param {string} sensorDP - Sensor datapoint ID
129
- * @returns {object|null} - {type, meterName} or null
121
+ * @returns {Array} - Array of {type, meterName} objects
130
122
  */
131
123
  findMeterBySensor(sensorDP) {
132
- return this.meterRegistry[sensorDP] || null;
124
+ return this.meterRegistry.findBySensor(sensorDP);
133
125
  }
134
126
 
135
127
  /**
@@ -173,22 +165,31 @@ class MultiMeterManager {
173
165
  * @returns {Promise<void>}
174
166
  */
175
167
  async initializeMeter(type, meterName, config, displayName) {
176
- const basePath = meterName === 'main' ? type : `${type}.${meterName}`;
168
+ const basePath = `${type}.${meterName}`;
177
169
  const label = displayName || meterName;
178
170
 
179
171
  this.adapter.log.info(`Initializing ${type} meter: ${label}`);
180
172
 
181
173
  if (!config.sensorDP) {
182
174
  this.adapter.log.warn(`${type} meter "${label}" has no sensor datapoint configured!`);
183
- await this.adapter.setStateAsync(`${basePath}.info.sensorActive`, false, true);
175
+ try {
176
+ await this.adapter.setStateAsync(`${basePath}.info.sensorActive`, false, true);
177
+ } catch (error) {
178
+ this.adapter.log.warn(`Could not set sensorActive state for ${basePath}: ${error.message}`);
179
+ }
184
180
  return;
185
181
  }
186
182
 
187
- // Create state structure
188
- await stateManager.createMeterStructure(this.adapter, type, meterName, config);
183
+ // Create state structure with error handling
184
+ try {
185
+ await stateManager.createMeterStructure(this.adapter, type, meterName, config);
186
+ } catch (error) {
187
+ this.adapter.log.error(`Failed to create state structure for ${basePath}: ${error.message}`);
188
+ return;
189
+ }
189
190
 
190
- // Register sensor in registry
191
- this.meterRegistry[config.sensorDP] = { type, meterName };
191
+ // Register sensor mapping
192
+ this.meterRegistry.register(config.sensorDP, type, meterName);
192
193
 
193
194
  this.adapter.log.debug(`Using sensor datapoint for ${type}.${meterName}: ${config.sensorDP}`);
194
195
 
@@ -212,8 +213,10 @@ class MultiMeterManager {
212
213
  // Initialize with current sensor value
213
214
  try {
214
215
  const sensorState = await this.adapter.getForeignStateAsync(config.sensorDP);
215
- if (sensorState && sensorState.val !== null && typeof sensorState.val === 'number') {
216
- await this.handleSensorUpdate(type, meterName, config.sensorDP, sensorState.val);
216
+ if (sensorState && sensorState.val != null) {
217
+ // Convert to number (handles strings, German commas, etc.)
218
+ const numValue = calculator.ensureNumber(sensorState.val);
219
+ await this.handleSensorUpdate(type, meterName, config.sensorDP, numValue);
217
220
  }
218
221
  } catch (error) {
219
222
  this.adapter.log.warn(`Could not read initial value from ${config.sensorDP}: ${error.message}`);
@@ -221,7 +224,7 @@ class MultiMeterManager {
221
224
  }
222
225
 
223
226
  // Initialize period start timestamps
224
- const timestampRoles = ['lastDayStart', 'lastMonthStart', 'lastYearStart'];
227
+ const timestampRoles = ['lastDayStart', 'lastWeekStart', 'lastMonthStart', 'lastYearStart'];
225
228
 
226
229
  for (const role of timestampRoles) {
227
230
  const statePath = `${basePath}.statistics.${role}`;
@@ -264,37 +267,9 @@ class MultiMeterManager {
264
267
  }
265
268
  }
266
269
 
267
- // Initialize yearly consumption from initial reading if set
268
- if (config.initialReading > 0) {
269
- const sensorState = await this.adapter.getForeignStateAsync(config.sensorDP);
270
- if (sensorState && typeof sensorState.val === 'number') {
271
- let currentRaw = sensorState.val;
272
-
273
- if (config.offset !== 0) {
274
- currentRaw = currentRaw - config.offset;
275
- this.adapter.log.debug(
276
- `Applied offset for ${type}.${meterName}: -${config.offset}, new value: ${currentRaw}`,
277
- );
278
- }
279
- let yearlyConsumption = Math.max(0, currentRaw - config.initialReading);
280
-
281
- // For gas: convert m³ to kWh
282
- if (type === 'gas') {
283
- const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
284
- const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
285
- const yearlyVolume = yearlyConsumption;
286
- yearlyConsumption = calculator.convertGasM3ToKWh(yearlyConsumption, brennwert, zZahl);
287
- await this.adapter.setStateAsync(`${basePath}.consumption.yearlyVolume`, yearlyVolume, true);
288
- this.adapter.log.info(
289
- `Init yearly ${type}.${meterName}: ${yearlyConsumption.toFixed(2)} kWh = ${(currentRaw - config.initialReading).toFixed(2)} m³`,
290
- );
291
- } else {
292
- this.adapter.log.info(`Init yearly ${type}.${meterName}: ${yearlyConsumption.toFixed(2)}`);
293
- }
294
-
295
- await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, yearlyConsumption, true);
296
- }
297
- }
270
+ // NOTE: Initial yearly consumption is calculated in handleSensorUpdate()
271
+ // which is called immediately after this initialization (line 224)
272
+ // This avoids race conditions where state objects aren't fully created yet
298
273
 
299
274
  // Update current price
300
275
  await this.updateCurrentPrice(type, meterName, config);
@@ -349,8 +324,10 @@ class MultiMeterManager {
349
324
  return;
350
325
  }
351
326
 
352
- const basePath = meterName === 'main' ? type : `${type}.${meterName}`;
353
- this.adapter.log.debug(`Sensor update for ${basePath}: ${value}`);
327
+ const basePath = `${type}.${meterName}`;
328
+ this.adapter.log.debug(`[${basePath}] handleSensorUpdate: value=${value}, sensorDP=${sensorDP}`);
329
+
330
+ const now = Date.now();
354
331
 
355
332
  // Get meter config
356
333
  const meters = this.getMetersForType(type);
@@ -361,92 +338,279 @@ class MultiMeterManager {
361
338
  }
362
339
 
363
340
  const config = meter.config;
364
- const now = Date.now();
365
- let consumption = value;
366
- let consumptionM3 = null;
367
341
 
368
- this.adapter.log.debug(`[${basePath}] Sensor update: raw=${value}, offset=${config.offset}`);
342
+ // Pre-process consumption value (offset, gas conversion)
343
+ const processed = await this._preprocessValue(type, value, config);
344
+ const { consumption, consumptionM3 } = processed;
369
345
 
370
- // Apply offset FIRST
371
- if (config.offset !== 0) {
372
- consumption = consumption - config.offset;
373
- this.adapter.log.debug(`[${basePath}] After offset: ${consumption}`);
346
+ // 1. Initialization Logic (Per Session)
347
+ if (this.lastSensorValues[sensorDP] === undefined) {
348
+ await this._handleFirstSensorValue(type, meterName, sensorDP, processed, basePath, config, now);
349
+ return;
374
350
  }
375
351
 
376
- // For gas, convert to kWh
352
+ // 2. Update meter reading states
353
+ await this.adapter.setStateAsync(`${basePath}.info.meterReading`, consumption, true);
377
354
  if (type === 'gas') {
378
- const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
379
- const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
380
- consumptionM3 = consumption;
381
- await this.adapter.setStateAsync(`${basePath}.info.meterReadingVolume`, consumption, true);
382
- consumption = calculator.convertGasM3ToKWh(consumption, brennwert, zZahl);
383
- consumption = calculator.roundToDecimals(consumption, 2);
355
+ await this.adapter.setStateAsync(`${basePath}.info.meterReadingVolume`, consumptionM3 || 0, true);
384
356
  }
385
357
 
386
- // Update meter reading
387
- await this.adapter.setStateAsync(`${basePath}.info.meterReading`, consumption, true);
388
-
389
- // Calculate deltas
358
+ // 3. Delta Calibration & Spike Protection
390
359
  const lastValue = this.lastSensorValues[sensorDP];
391
360
  this.lastSensorValues[sensorDP] = consumption;
392
361
 
393
- if (lastValue === undefined || consumption <= lastValue) {
394
- if (lastValue !== undefined && consumption < lastValue) {
395
- this.adapter.log.warn(
396
- `${type}.${meterName}: Sensor value decreased (${lastValue} -> ${consumption}). Assuming meter reset.`,
397
- );
398
- }
399
- await this.updateCosts(type, meterName, config);
400
- await this.updateTotalCosts(type);
362
+ if (consumption < lastValue) {
363
+ await this._handleMeterReset(type, meterName, lastValue, consumption, config);
364
+ return;
365
+ }
366
+
367
+ const delta = calculator.roundToDecimals(consumption - lastValue, 4);
368
+ if (delta <= 0) {
369
+ return;
370
+ }
371
+
372
+ const spikeThreshold = this.adapter.config.sensorSpikeThreshold || DEFAULT_SPIKE_THRESHOLD;
373
+ if (delta > spikeThreshold) {
374
+ await this._handleSuspiciousDelta(
375
+ type,
376
+ meterName,
377
+ delta,
378
+ consumption,
379
+ consumptionM3,
380
+ config,
381
+ basePath,
382
+ now,
383
+ );
401
384
  return;
402
385
  }
403
386
 
404
- const delta = consumption - lastValue;
405
387
  this.adapter.log.debug(`${type}.${meterName} delta: ${delta}`);
406
388
 
407
- // Track volume for gas
389
+ // 4. Update Consumption Values (Daily, Weekly, Monthly)
390
+ const deltaVolume =
391
+ type === 'gas'
392
+ ? delta /
393
+ ((this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT) *
394
+ (this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL))
395
+ : 0;
396
+ await this._updateTotalConsumptionStates(basePath, type, delta, deltaVolume);
397
+
398
+ // 5. HT/NT Tracking
399
+ await this._updateHTNTConsumptionStates(basePath, type, delta, config);
400
+
401
+ // 6. Yearly & Costs
402
+ const yearlyAmountFinal = await this._updateYearlyConsumption(
403
+ type,
404
+ meterName,
405
+ config,
406
+ consumption,
407
+ consumptionM3,
408
+ delta,
409
+ basePath,
410
+ );
411
+
412
+ await this.updateCosts(type, meterName, config, yearlyAmountFinal);
413
+ await this.updateTotalCosts(type);
414
+
415
+ await this.adapter.setStateAsync(`${basePath}.consumption.lastUpdate`, now, true);
416
+ await this.adapter.setStateAsync(`${basePath}.info.lastSync`, now, true);
417
+ }
418
+
419
+ /**
420
+ * Preprocesses raw sensor value (offset, gas conversion)
421
+ *
422
+ * @param {string} type - Utility type
423
+ * @param {number} value - Raw sensor value
424
+ * @param {object} config - Meter configuration
425
+ * @returns {Promise<{consumption: number, consumptionM3: number|null}>} Processed values
426
+ */
427
+ async _preprocessValue(type, value, config) {
428
+ let consumption = value;
429
+ let consumptionM3 = null;
430
+
431
+ if (config.offset !== 0) {
432
+ consumption = consumption - config.offset;
433
+ }
434
+
408
435
  if (type === 'gas') {
409
436
  const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
410
437
  const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
411
- const deltaVolume = delta / (brennwert * zZahl);
412
438
 
413
- const dailyVolume = await this.adapter.getStateAsync(`${basePath}.consumption.dailyVolume`);
414
- const monthlyVolume = await this.adapter.getStateAsync(`${basePath}.consumption.monthlyVolume`);
415
- const yearlyVolume = await this.adapter.getStateAsync(`${basePath}.consumption.yearlyVolume`);
439
+ const res = consumptionHelper.calculateGas(consumption, brennwert, zZahl);
440
+ consumptionM3 = res.volume;
441
+ consumption = res.energy;
442
+ }
443
+
444
+ return { consumption, consumptionM3 };
445
+ }
446
+
447
+ /**
448
+ * Handles the very first sensor value in a session (recovery or initial)
449
+ *
450
+ * @param {string} type - Utility type
451
+ * @param {string} meterName - Meter name
452
+ * @param {string} sensorDP - Sensor data point path
453
+ * @param {object} processed - Processed consumption values
454
+ * @param {string} basePath - State base path
455
+ * @param {object} config - Meter configuration
456
+ * @param {number} now - Current timestamp
457
+ */
458
+ async _handleFirstSensorValue(type, meterName, sensorDP, processed, basePath, config, now) {
459
+ const { consumption, consumptionM3 } = processed;
460
+ const currentState = await this.adapter.getStateAsync(`${basePath}.info.meterReading`);
461
+ const recoveredValue = currentState?.val ?? 0;
462
+
463
+ if (recoveredValue > 0 && Math.abs(consumption - recoveredValue) < 100) {
464
+ this.adapter.log.info(`[${basePath}] Recovered persistent baseline: ${recoveredValue}`);
465
+ this.lastSensorValues[sensorDP] = recoveredValue;
466
+ } else {
467
+ if (recoveredValue > 0) {
468
+ this.adapter.log.warn(
469
+ `[${basePath}] Recovered state (${recoveredValue}) differs significantly from new value (${consumption}).`,
470
+ );
471
+ } else {
472
+ this.adapter.log.info(
473
+ `[${basePath}] No previous reading found. Setting initial baseline to ${consumption}`,
474
+ );
475
+ }
476
+ this.lastSensorValues[sensorDP] = consumption;
477
+ await this.adapter.setStateAsync(`${basePath}.info.meterReading`, consumption, true);
478
+ if (type === 'gas') {
479
+ await this.adapter.setStateAsync(`${basePath}.info.meterReadingVolume`, consumptionM3 || 0, true);
480
+ }
481
+
482
+ if (config.initialReading > 0) {
483
+ await this.calculateAbsoluteYearly(type, meterName, config, consumption, consumptionM3 || 0, now);
484
+ await this.updateCosts(type, meterName, config);
485
+ await this.updateTotalCosts(type);
486
+ }
487
+ }
488
+ await this.adapter.setStateAsync(`${basePath}.info.lastSync`, now, true);
489
+ await this.adapter.setStateAsync(`${basePath}.consumption.lastUpdate`, now, true);
490
+ }
491
+
492
+ /**
493
+ * Handles meter reset or replacement condition
494
+ *
495
+ * @param {string} type - Utility type
496
+ * @param {string} meterName - Meter name
497
+ * @param {number} lastValue - Previous sensor value
498
+ * @param {number} consumption - Current consumption value
499
+ * @param {object} config - Meter configuration
500
+ */
501
+ async _handleMeterReset(type, meterName, lastValue, consumption, config) {
502
+ this.adapter.log.warn(
503
+ `${type}.${meterName}: Zählerstand gesunken (${lastValue} -> ${consumption}). Gehe von Zählerwechsel oder Reset aus.`,
504
+ );
505
+ await this.updateCosts(type, meterName, config);
506
+ await this.updateTotalCosts(type);
507
+ }
416
508
 
509
+ /**
510
+ * Handles suspicious delta (spike detection)
511
+ *
512
+ * @param {string} type - Utility type
513
+ * @param {string} meterName - Meter name
514
+ * @param {number} delta - Calculated delta
515
+ * @param {number} consumption - Current consumption value
516
+ * @param {number|null} consumptionM3 - Current gas volume
517
+ * @param {object} config - Meter configuration
518
+ * @param {string} basePath - State base path
519
+ * @param {number} now - Current timestamp
520
+ */
521
+ async _handleSuspiciousDelta(type, meterName, delta, consumption, consumptionM3, config, basePath, now) {
522
+ this.adapter.log.warn(`[${basePath}] Discarding suspicious delta of ${delta}. Treating as baseline reset.`);
523
+ if (config.initialReading > 0) {
524
+ await this.calculateAbsoluteYearly(type, meterName, config, consumption, consumptionM3 || 0, now);
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Updates all period consumption states
530
+ *
531
+ * @param {string} basePath - State base path
532
+ * @param {string} type - Utility type
533
+ * @param {number} delta - Consumption delta
534
+ * @param {number} deltaVolume - Gas volume delta
535
+ */
536
+ async _updateTotalConsumptionStates(basePath, type, delta, deltaVolume) {
537
+ const periods = ['daily', 'weekly', 'monthly'];
538
+ for (const period of periods) {
539
+ const state = await this.adapter.getStateAsync(`${basePath}.consumption.${period}`);
417
540
  await this.adapter.setStateAsync(
418
- `${basePath}.consumption.dailyVolume`,
419
- calculator.roundToDecimals((dailyVolume?.val || 0) + deltaVolume, 2),
541
+ `${basePath}.consumption.${period}`,
542
+ calculator.roundToDecimals((state?.val || 0) + delta, 2),
420
543
  true,
421
544
  );
545
+
546
+ if (type === 'gas' && deltaVolume > 0) {
547
+ const volState = await this.adapter.getStateAsync(`${basePath}.consumption.${period}Volume`);
548
+ await this.adapter.setStateAsync(
549
+ `${basePath}.consumption.${period}Volume`,
550
+ calculator.roundToDecimals((volState?.val || 0) + deltaVolume, 2),
551
+ true,
552
+ );
553
+ }
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Updates HT/NT specific consumption states
559
+ *
560
+ * @param {string} basePath - State base path
561
+ * @param {string} type - Utility type
562
+ * @param {number} delta - Consumption delta
563
+ * @param {object} config - Meter configuration
564
+ */
565
+ async _updateHTNTConsumptionStates(basePath, type, delta, config) {
566
+ if (!config.htNtEnabled) {
567
+ return;
568
+ }
569
+
570
+ const configType = this.getConfigType(type);
571
+ const suffix = consumptionHelper.getHTNTSuffix(this.adapter.config, configType);
572
+ if (!suffix) {
573
+ return;
574
+ }
575
+
576
+ const periods = ['daily', 'weekly', 'monthly'];
577
+ for (const period of periods) {
578
+ const state = await this.adapter.getStateAsync(`${basePath}.consumption.${period}${suffix}`);
422
579
  await this.adapter.setStateAsync(
423
- `${basePath}.consumption.monthlyVolume`,
424
- calculator.roundToDecimals((monthlyVolume?.val || 0) + deltaVolume, 2),
580
+ `${basePath}.consumption.${period}${suffix}`,
581
+ calculator.roundToDecimals((state?.val || 0) + delta, 2),
425
582
  true,
426
583
  );
584
+ }
585
+
586
+ if (type === 'gas') {
587
+ const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
588
+ const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
589
+ const deltaVolume = delta / (brennwert * zZahl);
590
+
591
+ const state = await this.adapter.getStateAsync(`${basePath}.consumption.weeklyVolume${suffix}`);
427
592
  await this.adapter.setStateAsync(
428
- `${basePath}.consumption.yearlyVolume`,
429
- calculator.roundToDecimals((yearlyVolume?.val || 0) + deltaVolume, 3),
593
+ `${basePath}.consumption.weeklyVolume${suffix}`,
594
+ calculator.roundToDecimals((state?.val || 0) + deltaVolume, 2),
430
595
  true,
431
596
  );
432
597
  }
598
+ }
433
599
 
434
- // Update consumption values
435
- const dailyState = await this.adapter.getStateAsync(`${basePath}.consumption.daily`);
436
- await this.adapter.setStateAsync(
437
- `${basePath}.consumption.daily`,
438
- calculator.roundToDecimals((dailyState?.val || 0) + delta, 2),
439
- true,
440
- );
441
-
442
- const monthlyState = await this.adapter.getStateAsync(`${basePath}.consumption.monthly`);
443
- await this.adapter.setStateAsync(
444
- `${basePath}.consumption.monthly`,
445
- calculator.roundToDecimals((monthlyState?.val || 0) + delta, 2),
446
- true,
447
- );
448
-
449
- // Yearly consumption
600
+ /**
601
+ * Updates yearly consumption
602
+ *
603
+ * @param {string} type - Utility type
604
+ * @param {string} meterName - Meter name
605
+ * @param {object} config - Meter configuration
606
+ * @param {number} consumption - Current consumption value
607
+ * @param {number|null} consumptionM3 - Current gas volume
608
+ * @param {number} delta - Consumption delta
609
+ * @param {string} basePath - State base path
610
+ * @returns {Promise<number>} final yearly amount
611
+ */
612
+ async _updateYearlyConsumption(type, meterName, config, consumption, consumptionM3, delta, basePath) {
613
+ let yearlyAmountFinal;
450
614
  if (config.initialReading > 0) {
451
615
  let yearlyAmount;
452
616
  if (type === 'gas') {
@@ -462,21 +626,55 @@ class MultiMeterManager {
462
626
  } else {
463
627
  yearlyAmount = Math.max(0, consumption - config.initialReading);
464
628
  }
465
- await this.adapter.setStateAsync(
466
- `${basePath}.consumption.yearly`,
467
- calculator.roundToDecimals(yearlyAmount, 2),
468
- true,
469
- );
629
+ yearlyAmountFinal = calculator.roundToDecimals(yearlyAmount, 2);
630
+ await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, yearlyAmountFinal, true);
470
631
  } else {
471
632
  const yState = await this.adapter.getStateAsync(`${basePath}.consumption.yearly`);
633
+ yearlyAmountFinal = calculator.roundToDecimals((yState?.val || 0) + delta, 2);
634
+ await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, yearlyAmountFinal, true);
635
+ }
636
+ return yearlyAmountFinal;
637
+ }
638
+
639
+ /**
640
+ * Helper to calculate absolute yearly consumption (usually on start/reset)
641
+ *
642
+ * @param {string} type - Utility type
643
+ * @param {string} meterName - Meter name
644
+ * @param {object} config - Meter configuration
645
+ * @param {number} consumption - Current consumption value
646
+ * @param {number} consumptionM3 - Current consumption in m³ (for gas)
647
+ * @param {number} now - Current timestamp
648
+ */
649
+ async calculateAbsoluteYearly(type, meterName, config, consumption, consumptionM3, now) {
650
+ const basePath = `${type}.${meterName}`;
651
+ let yearlyAmountFinal;
652
+
653
+ let yearlyAmount;
654
+ if (type === 'gas') {
655
+ const yearlyM3 = Math.max(0, (consumptionM3 || 0) - config.initialReading);
656
+ this.adapter.log.debug(
657
+ `[${basePath}] Yearly absolute (Gas): consumptionM3=${consumptionM3}, initialReading=${config.initialReading}, resultM3=${yearlyM3}`,
658
+ );
472
659
  await this.adapter.setStateAsync(
473
- `${basePath}.consumption.yearly`,
474
- calculator.roundToDecimals((yState?.val || 0) + delta, 2),
660
+ `${basePath}.consumption.yearlyVolume`,
661
+ calculator.roundToDecimals(yearlyM3, 2),
475
662
  true,
476
663
  );
664
+ const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
665
+ const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
666
+ yearlyAmount = calculator.convertGasM3ToKWh(yearlyM3, brennwert, zZahl);
667
+ } else {
668
+ yearlyAmount = Math.max(0, consumption - config.initialReading);
669
+ this.adapter.log.debug(
670
+ `[${basePath}] Yearly absolute: consumption=${consumption}, initialReading=${config.initialReading}, result=${yearlyAmount}`,
671
+ );
477
672
  }
673
+ yearlyAmountFinal = calculator.roundToDecimals(yearlyAmount, 2);
674
+ await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, yearlyAmountFinal, true);
478
675
 
479
- await this.updateCosts(type, meterName, config);
676
+ // Pass direct calculated values to updateCosts to avoid race condition with DB
677
+ await this.updateCosts(type, meterName, config, yearlyAmountFinal);
480
678
  await this.updateTotalCosts(type);
481
679
 
482
680
  await this.adapter.setStateAsync(`${basePath}.consumption.lastUpdate`, now, true);
@@ -491,14 +689,14 @@ class MultiMeterManager {
491
689
  * @param {object} config - Meter configuration
492
690
  */
493
691
  async updateCurrentPrice(type, meterName, config) {
494
- const basePath = meterName === 'main' ? type : `${type}.${meterName}`;
692
+ const basePath = `${type}.${meterName}`;
495
693
  const configType = this.getConfigType(type);
496
694
 
497
695
  let tariffName = 'Standard';
498
696
  let activePrice = config.preis || 0;
499
697
 
500
- // Only main meter supports HT/NT
501
- if (meterName === 'main' && config.htNtEnabled) {
698
+ // Only meters with HT/NT enabled support it
699
+ if (config.htNtEnabled) {
502
700
  const isHT = calculator.isHTTime(this.adapter.config, configType);
503
701
  if (isHT) {
504
702
  activePrice = this.adapter.config[`${configType}HtPrice`] || 0;
@@ -516,6 +714,7 @@ class MultiMeterManager {
516
714
  true,
517
715
  );
518
716
  await this.adapter.setStateAsync(`${basePath}.info.currentTariff`, tariffName, true);
717
+ await this.adapter.setStateAsync(`${basePath}.info.lastSync`, Date.now(), true);
519
718
  }
520
719
 
521
720
  /**
@@ -524,14 +723,21 @@ class MultiMeterManager {
524
723
  * @param {string} type - Utility type
525
724
  * @param {string} meterName - Meter name
526
725
  * @param {object} config - Meter configuration
726
+ * @param {number} [forcedYearly] - Optional already calculated yearly consumption (avoids DB race)
527
727
  */
528
- async updateCosts(type, meterName, config) {
529
- const basePath = meterName === 'main' ? type : `${type}.${meterName}`;
728
+ async updateCosts(type, meterName, config, forcedYearly) {
729
+ const basePath = `${type}.${meterName}`;
530
730
 
531
731
  // Get consumption values
532
732
  const daily = (await this.adapter.getStateAsync(`${basePath}.consumption.daily`))?.val || 0;
533
733
  const monthly = (await this.adapter.getStateAsync(`${basePath}.consumption.monthly`))?.val || 0;
534
- const yearly = (await this.adapter.getStateAsync(`${basePath}.consumption.yearly`))?.val || 0;
734
+
735
+ let yearly;
736
+ if (forcedYearly !== undefined && forcedYearly !== null) {
737
+ yearly = forcedYearly;
738
+ } else {
739
+ yearly = (await this.adapter.getStateAsync(`${basePath}.consumption.yearly`))?.val || 0;
740
+ }
535
741
 
536
742
  // Get price
537
743
  const price = config.preis || 0;
@@ -542,67 +748,66 @@ class MultiMeterManager {
542
748
 
543
749
  // Calculate consumption costs
544
750
  const dailyCost = daily * price;
751
+ const weeklyState = await this.adapter.getStateAsync(`${basePath}.consumption.weekly`);
752
+ const weeklyCost = (weeklyState?.val || 0) * price;
545
753
  const monthlyCost = monthly * price;
546
754
  const yearlyCost = yearly * price;
547
755
 
548
756
  await this.adapter.setStateAsync(`${basePath}.costs.daily`, calculator.roundToDecimals(dailyCost, 2), true);
757
+ await this.adapter.setStateAsync(`${basePath}.costs.weekly`, calculator.roundToDecimals(weeklyCost, 2), true);
549
758
  await this.adapter.setStateAsync(`${basePath}.costs.monthly`, calculator.roundToDecimals(monthlyCost, 2), true);
550
759
  await this.adapter.setStateAsync(`${basePath}.costs.yearly`, calculator.roundToDecimals(yearlyCost, 2), true);
551
760
 
552
761
  // Calculate accumulated costs based on contract start
553
- const yearStartState = await this.adapter.getStateAsync(`${basePath}.statistics.lastYearStart`);
554
- let monthsSinceYearStart = 1;
555
- let basicChargeAccumulated = 0;
556
-
557
- if (yearStartState && yearStartState.val) {
558
- // yearStartState.val is a timestamp (number)
559
- const yearStartDate = new Date(yearStartState.val);
560
- if (!isNaN(yearStartDate.getTime())) {
561
- const now = new Date();
562
-
563
- // Calculate months since contract start (started months, including current)
564
- monthsSinceYearStart = calculator.getMonthsDifference(yearStartDate, now) + 1;
565
-
566
- // Calculate accumulated basic charge (monthly fee × months)
567
- basicChargeAccumulated = (config.grundgebuehr || 0) * monthsSinceYearStart;
568
- }
569
- }
570
-
571
- // Jahresgebühr ist ein FIXER Wert pro Jahr (z.B. 60€)
572
- // NICHT pro-rata nach Monaten/Tagen berechnen!
573
- const annualFeeAccumulated = config.jahresgebuehr || 0;
762
+ const monthsSinceYearStart = await this._calculateMonthsSinceYearStart(basePath);
574
763
 
575
- // Update basicCharge with accumulated value (not just monthly!)
576
- await this.adapter.setStateAsync(
577
- `${basePath}.costs.basicCharge`,
578
- calculator.roundToDecimals(basicChargeAccumulated, 2),
579
- true,
764
+ const charges = billingHelper.calculateAccumulatedCharges(
765
+ config.grundgebuehr,
766
+ config.jahresgebuehr,
767
+ monthsSinceYearStart,
580
768
  );
769
+ const basicChargeAccumulated = charges.basicCharge;
770
+ const annualFeeAccumulated = charges.annualFee;
581
771
 
582
- await this.adapter.setStateAsync(
583
- `${basePath}.costs.annualFee`,
584
- calculator.roundToDecimals(annualFeeAccumulated, 2),
585
- true,
586
- );
772
+ // Update basicCharge and annualFee states
773
+ await this.adapter.setStateAsync(`${basePath}.costs.basicCharge`, basicChargeAccumulated, true);
774
+ await this.adapter.setStateAsync(`${basePath}.costs.annualFee`, annualFeeAccumulated, true);
587
775
 
588
776
  // Calculate total yearly costs and balance
589
- const totalYearlyCost = yearlyCost + basicChargeAccumulated + annualFeeAccumulated;
590
-
777
+ const totalYearlyCost = Math.max(0, yearlyCost + charges.total);
591
778
  await this.adapter.setStateAsync(
592
779
  `${basePath}.costs.totalYearly`,
593
780
  calculator.roundToDecimals(totalYearlyCost, 2),
594
781
  true,
595
782
  );
596
783
 
597
- const paidTotal = (config.abschlag || 0) * monthsSinceYearStart;
598
- const balance = totalYearlyCost - paidTotal;
784
+ const balanceRes = billingHelper.calculateBalance(config.abschlag, monthsSinceYearStart, totalYearlyCost);
599
785
 
600
786
  this.adapter.log.debug(
601
- `[${basePath}] Balance calculation: grundgebuehr=${config.grundgebuehr}, jahresgebuehr=${config.jahresgebuehr}, abschlag=${config.abschlag}, months=${monthsSinceYearStart}, basicCharge=${basicChargeAccumulated.toFixed(2)}, annualFee=${annualFeeAccumulated.toFixed(2)}, paidTotal=${paidTotal.toFixed(2)}, totalYearly=${totalYearlyCost.toFixed(2)}, balance=${balance.toFixed(2)}`,
787
+ `[${basePath}] Cost calculation (MultiMeter): daily=${daily}, monthly=${monthly}, yearly=${yearly}, price=${price}, totalYearly=${totalYearlyCost}, paidTotal=${balanceRes.paid}, balance=${balanceRes.balance}`,
602
788
  );
603
789
 
604
- await this.adapter.setStateAsync(`${basePath}.costs.paidTotal`, calculator.roundToDecimals(paidTotal, 2), true);
605
- await this.adapter.setStateAsync(`${basePath}.costs.balance`, calculator.roundToDecimals(balance, 2), true);
790
+ await this.adapter.setStateAsync(`${basePath}.costs.paidTotal`, balanceRes.paid, true);
791
+ await this.adapter.setStateAsync(`${basePath}.costs.balance`, balanceRes.balance, true);
792
+ }
793
+
794
+ /**
795
+ * Calculates months since year start for a meter
796
+ *
797
+ * @param {string} basePath - State base path
798
+ * @returns {Promise<number>} Months since start (at least 1)
799
+ */
800
+ async _calculateMonthsSinceYearStart(basePath) {
801
+ const yearStartState = await this.adapter.getStateAsync(`${basePath}.statistics.lastYearStart`);
802
+ let monthsSinceYearStart = 1;
803
+
804
+ if (yearStartState && yearStartState.val) {
805
+ const yearStartDate = new Date(yearStartState.val);
806
+ if (!isNaN(yearStartDate.getTime())) {
807
+ monthsSinceYearStart = calculator.getMonthsDifference(yearStartDate, new Date()) + 1;
808
+ }
809
+ }
810
+ return Math.max(1, monthsSinceYearStart);
606
811
  }
607
812
 
608
813
  /**
@@ -618,21 +823,35 @@ class MultiMeterManager {
618
823
  return;
619
824
  }
620
825
 
826
+ // Check if totals structure exists before trying to update
827
+ const totalsExists = await this.adapter.getObjectAsync(`${type}.totals`);
828
+ if (!totalsExists) {
829
+ // Totals structure not yet created, skip update
830
+ // This happens during initialization when handleSensorUpdate is called
831
+ // before createTotalsStructure has run
832
+ this.adapter.log.debug(`Skipping total costs update for ${type} - totals structure not yet created`);
833
+ return;
834
+ }
835
+
621
836
  let totalDaily = 0;
837
+ let totalWeekly = 0;
622
838
  let totalMonthly = 0;
623
839
  let totalYearly = 0;
624
840
  let totalCostsDaily = 0;
841
+ let totalCostsWeekly = 0;
625
842
  let totalCostsMonthly = 0;
626
843
  let totalCostsYearly = 0;
627
844
 
628
845
  for (const meter of meters) {
629
- const basePath = meter.name === 'main' ? type : `${type}.${meter.name}`;
846
+ const basePath = `${type}.${meter.name}`;
630
847
 
631
848
  totalDaily += (await this.adapter.getStateAsync(`${basePath}.consumption.daily`))?.val || 0;
849
+ totalWeekly += (await this.adapter.getStateAsync(`${basePath}.consumption.weekly`))?.val || 0;
632
850
  totalMonthly += (await this.adapter.getStateAsync(`${basePath}.consumption.monthly`))?.val || 0;
633
851
  totalYearly += (await this.adapter.getStateAsync(`${basePath}.consumption.yearly`))?.val || 0;
634
852
 
635
853
  totalCostsDaily += (await this.adapter.getStateAsync(`${basePath}.costs.daily`))?.val || 0;
854
+ totalCostsWeekly += (await this.adapter.getStateAsync(`${basePath}.costs.weekly`))?.val || 0;
636
855
  totalCostsMonthly += (await this.adapter.getStateAsync(`${basePath}.costs.monthly`))?.val || 0;
637
856
  totalCostsYearly += (await this.adapter.getStateAsync(`${basePath}.costs.totalYearly`))?.val || 0;
638
857
  }
@@ -642,6 +861,11 @@ class MultiMeterManager {
642
861
  calculator.roundToDecimals(totalDaily, 2),
643
862
  true,
644
863
  );
864
+ await this.adapter.setStateAsync(
865
+ `${type}.totals.consumption.weekly`,
866
+ calculator.roundToDecimals(totalWeekly, 2),
867
+ true,
868
+ );
645
869
  await this.adapter.setStateAsync(
646
870
  `${type}.totals.consumption.monthly`,
647
871
  calculator.roundToDecimals(totalMonthly, 2),
@@ -658,6 +882,11 @@ class MultiMeterManager {
658
882
  calculator.roundToDecimals(totalCostsDaily, 2),
659
883
  true,
660
884
  );
885
+ await this.adapter.setStateAsync(
886
+ `${type}.totals.costs.weekly`,
887
+ calculator.roundToDecimals(totalCostsWeekly, 2),
888
+ true,
889
+ );
661
890
  await this.adapter.setStateAsync(
662
891
  `${type}.totals.costs.monthly`,
663
892
  calculator.roundToDecimals(totalCostsMonthly, 2),