iobroker.utility-monitor 1.4.6 → 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 +98 -55
  2. package/admin/custom/.vite/manifest.json +90 -0
  3. package/admin/custom/@mf-types/Components.d.ts +2 -0
  4. package/admin/custom/@mf-types/compiled-types/Components/CSVImporter.d.ts +11 -0
  5. package/admin/custom/@mf-types/compiled-types/Components.d.ts +2 -0
  6. package/admin/custom/@mf-types.d.ts +3 -0
  7. package/admin/custom/@mf-types.zip +0 -0
  8. package/admin/custom/CSVImporter_v15_11.js +4415 -0
  9. package/admin/custom/assets/Components-i0AZ59nl.js +18887 -0
  10. package/admin/custom/assets/UtilityMonitor__loadShare__react__loadShare__-Da99Mak4.js +42 -0
  11. package/admin/custom/assets/UtilityMonitor__mf_v__runtimeInit__mf_v__-BmC4OGk6.js +16 -0
  12. package/admin/custom/assets/_commonjsHelpers-Dj2_voLF.js +30 -0
  13. package/admin/custom/assets/hostInit-DEXfeB0W.js +10 -0
  14. package/admin/custom/assets/index-B3WVNJTz.js +401 -0
  15. package/admin/custom/assets/index-VBwl8x_k.js +64 -0
  16. package/admin/custom/assets/preload-helper-BelkbqnE.js +61 -0
  17. package/admin/custom/assets/virtualExposes-CqCLUNLT.js +19 -0
  18. package/admin/custom/index.html +12 -0
  19. package/admin/custom/mf-manifest.json +1 -0
  20. package/admin/jsonConfig.json +90 -31
  21. package/io-package.json +39 -3
  22. package/lib/billingManager.js +235 -123
  23. package/lib/calculator.js +19 -138
  24. package/lib/consumptionManager.js +9 -252
  25. package/lib/importManager.js +300 -0
  26. package/lib/messagingHandler.js +4 -2
  27. package/lib/meter/MeterRegistry.js +110 -0
  28. package/lib/multiMeterManager.js +397 -174
  29. package/lib/stateManager.js +502 -31
  30. package/lib/utils/billingHelper.js +69 -0
  31. package/lib/utils/consumptionHelper.js +47 -0
  32. package/lib/utils/helpers.js +178 -0
  33. package/lib/utils/typeMapper.js +19 -0
  34. package/main.js +71 -8
  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
  /**
@@ -72,7 +58,7 @@ class MultiMeterManager {
72
58
  if (mainActive) {
73
59
  // Get main meter name from config and normalize
74
60
  const mainMeterName = this.adapter.config[`${configType}MainMeterName`] || 'main';
75
- const normalizedName = this.normalizeMeterName(mainMeterName);
61
+ const normalizedName = helpers.normalizeMeterName(mainMeterName);
76
62
  const displayName = mainMeterName; // Original name for display
77
63
 
78
64
  meters.push({
@@ -97,7 +83,7 @@ class MultiMeterManager {
97
83
  if (Array.isArray(additionalMeters)) {
98
84
  for (const meterConfig of additionalMeters) {
99
85
  if (meterConfig && meterConfig.name && meterConfig.sensorDP) {
100
- const normalizedName = this.normalizeMeterName(meterConfig.name);
86
+ const normalizedName = helpers.normalizeMeterName(meterConfig.name);
101
87
 
102
88
  // Debug: Log raw config for troubleshooting
103
89
  this.adapter.log.debug(
@@ -129,13 +115,13 @@ class MultiMeterManager {
129
115
  }
130
116
 
131
117
  /**
132
- * Finds meter by sensor datapoint
118
+ * Finds meters by sensor datapoint
133
119
  *
134
120
  * @param {string} sensorDP - Sensor datapoint ID
135
- * @returns {object|null} - {type, meterName} or null
121
+ * @returns {Array} - Array of {type, meterName} objects
136
122
  */
137
123
  findMeterBySensor(sensorDP) {
138
- return this.meterRegistry[sensorDP] || null;
124
+ return this.meterRegistry.findBySensor(sensorDP);
139
125
  }
140
126
 
141
127
  /**
@@ -186,15 +172,24 @@ class MultiMeterManager {
186
172
 
187
173
  if (!config.sensorDP) {
188
174
  this.adapter.log.warn(`${type} meter "${label}" has no sensor datapoint configured!`);
189
- 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
+ }
190
180
  return;
191
181
  }
192
182
 
193
- // Create state structure
194
- 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
+ }
195
190
 
196
- // Register sensor in registry
197
- this.meterRegistry[config.sensorDP] = { type, meterName };
191
+ // Register sensor mapping
192
+ this.meterRegistry.register(config.sensorDP, type, meterName);
198
193
 
199
194
  this.adapter.log.debug(`Using sensor datapoint for ${type}.${meterName}: ${config.sensorDP}`);
200
195
 
@@ -218,8 +213,10 @@ class MultiMeterManager {
218
213
  // Initialize with current sensor value
219
214
  try {
220
215
  const sensorState = await this.adapter.getForeignStateAsync(config.sensorDP);
221
- if (sensorState && sensorState.val !== null && typeof sensorState.val === 'number') {
222
- 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);
223
220
  }
224
221
  } catch (error) {
225
222
  this.adapter.log.warn(`Could not read initial value from ${config.sensorDP}: ${error.message}`);
@@ -227,7 +224,7 @@ class MultiMeterManager {
227
224
  }
228
225
 
229
226
  // Initialize period start timestamps
230
- const timestampRoles = ['lastDayStart', 'lastMonthStart', 'lastYearStart'];
227
+ const timestampRoles = ['lastDayStart', 'lastWeekStart', 'lastMonthStart', 'lastYearStart'];
231
228
 
232
229
  for (const role of timestampRoles) {
233
230
  const statePath = `${basePath}.statistics.${role}`;
@@ -270,37 +267,9 @@ class MultiMeterManager {
270
267
  }
271
268
  }
272
269
 
273
- // Initialize yearly consumption from initial reading if set
274
- if (config.initialReading > 0) {
275
- const sensorState = await this.adapter.getForeignStateAsync(config.sensorDP);
276
- if (sensorState && typeof sensorState.val === 'number') {
277
- let currentRaw = sensorState.val;
278
-
279
- if (config.offset !== 0) {
280
- currentRaw = currentRaw - config.offset;
281
- this.adapter.log.debug(
282
- `Applied offset for ${type}.${meterName}: -${config.offset}, new value: ${currentRaw}`,
283
- );
284
- }
285
- let yearlyConsumption = Math.max(0, currentRaw - config.initialReading);
286
-
287
- // For gas: convert m³ to kWh
288
- if (type === 'gas') {
289
- const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
290
- const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
291
- const yearlyVolume = yearlyConsumption;
292
- yearlyConsumption = calculator.convertGasM3ToKWh(yearlyConsumption, brennwert, zZahl);
293
- await this.adapter.setStateAsync(`${basePath}.consumption.yearlyVolume`, yearlyVolume, true);
294
- this.adapter.log.info(
295
- `Init yearly ${type}.${meterName}: ${yearlyConsumption.toFixed(2)} kWh = ${(currentRaw - config.initialReading).toFixed(2)} m³`,
296
- );
297
- } else {
298
- this.adapter.log.info(`Init yearly ${type}.${meterName}: ${yearlyConsumption.toFixed(2)}`);
299
- }
300
-
301
- await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, yearlyConsumption, true);
302
- }
303
- }
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
304
273
 
305
274
  // Update current price
306
275
  await this.updateCurrentPrice(type, meterName, config);
@@ -356,7 +325,9 @@ class MultiMeterManager {
356
325
  }
357
326
 
358
327
  const basePath = `${type}.${meterName}`;
359
- this.adapter.log.debug(`Sensor update for ${basePath}: ${value}`);
328
+ this.adapter.log.debug(`[${basePath}] handleSensorUpdate: value=${value}, sensorDP=${sensorDP}`);
329
+
330
+ const now = Date.now();
360
331
 
361
332
  // Get meter config
362
333
  const meters = this.getMetersForType(type);
@@ -367,92 +338,279 @@ class MultiMeterManager {
367
338
  }
368
339
 
369
340
  const config = meter.config;
370
- const now = Date.now();
371
- let consumption = value;
372
- let consumptionM3 = null;
373
341
 
374
- 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;
375
345
 
376
- // Apply offset FIRST
377
- if (config.offset !== 0) {
378
- consumption = consumption - config.offset;
379
- 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;
380
350
  }
381
351
 
382
- // For gas, convert to kWh
352
+ // 2. Update meter reading states
353
+ await this.adapter.setStateAsync(`${basePath}.info.meterReading`, consumption, true);
383
354
  if (type === 'gas') {
384
- const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
385
- const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
386
- consumptionM3 = consumption;
387
- await this.adapter.setStateAsync(`${basePath}.info.meterReadingVolume`, consumption, true);
388
- consumption = calculator.convertGasM3ToKWh(consumption, brennwert, zZahl);
389
- consumption = calculator.roundToDecimals(consumption, 2);
355
+ await this.adapter.setStateAsync(`${basePath}.info.meterReadingVolume`, consumptionM3 || 0, true);
390
356
  }
391
357
 
392
- // Update meter reading
393
- await this.adapter.setStateAsync(`${basePath}.info.meterReading`, consumption, true);
394
-
395
- // Calculate deltas
358
+ // 3. Delta Calibration & Spike Protection
396
359
  const lastValue = this.lastSensorValues[sensorDP];
397
360
  this.lastSensorValues[sensorDP] = consumption;
398
361
 
399
- if (lastValue === undefined || consumption <= lastValue) {
400
- if (lastValue !== undefined && consumption < lastValue) {
401
- this.adapter.log.warn(
402
- `${type}.${meterName}: Sensor value decreased (${lastValue} -> ${consumption}). Assuming meter reset.`,
403
- );
404
- }
405
- await this.updateCosts(type, meterName, config);
406
- 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
+ );
407
384
  return;
408
385
  }
409
386
 
410
- const delta = consumption - lastValue;
411
387
  this.adapter.log.debug(`${type}.${meterName} delta: ${delta}`);
412
388
 
413
- // 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
+
414
435
  if (type === 'gas') {
415
436
  const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
416
437
  const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
417
- const deltaVolume = delta / (brennwert * zZahl);
418
438
 
419
- const dailyVolume = await this.adapter.getStateAsync(`${basePath}.consumption.dailyVolume`);
420
- const monthlyVolume = await this.adapter.getStateAsync(`${basePath}.consumption.monthlyVolume`);
421
- 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
+ }
422
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}`);
423
540
  await this.adapter.setStateAsync(
424
- `${basePath}.consumption.dailyVolume`,
425
- calculator.roundToDecimals((dailyVolume?.val || 0) + deltaVolume, 2),
541
+ `${basePath}.consumption.${period}`,
542
+ calculator.roundToDecimals((state?.val || 0) + delta, 2),
426
543
  true,
427
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}`);
428
579
  await this.adapter.setStateAsync(
429
- `${basePath}.consumption.monthlyVolume`,
430
- calculator.roundToDecimals((monthlyVolume?.val || 0) + deltaVolume, 2),
580
+ `${basePath}.consumption.${period}${suffix}`,
581
+ calculator.roundToDecimals((state?.val || 0) + delta, 2),
431
582
  true,
432
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}`);
433
592
  await this.adapter.setStateAsync(
434
- `${basePath}.consumption.yearlyVolume`,
435
- calculator.roundToDecimals((yearlyVolume?.val || 0) + deltaVolume, 3),
593
+ `${basePath}.consumption.weeklyVolume${suffix}`,
594
+ calculator.roundToDecimals((state?.val || 0) + deltaVolume, 2),
436
595
  true,
437
596
  );
438
597
  }
598
+ }
439
599
 
440
- // Update consumption values
441
- const dailyState = await this.adapter.getStateAsync(`${basePath}.consumption.daily`);
442
- await this.adapter.setStateAsync(
443
- `${basePath}.consumption.daily`,
444
- calculator.roundToDecimals((dailyState?.val || 0) + delta, 2),
445
- true,
446
- );
447
-
448
- const monthlyState = await this.adapter.getStateAsync(`${basePath}.consumption.monthly`);
449
- await this.adapter.setStateAsync(
450
- `${basePath}.consumption.monthly`,
451
- calculator.roundToDecimals((monthlyState?.val || 0) + delta, 2),
452
- true,
453
- );
454
-
455
- // 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;
456
614
  if (config.initialReading > 0) {
457
615
  let yearlyAmount;
458
616
  if (type === 'gas') {
@@ -468,21 +626,55 @@ class MultiMeterManager {
468
626
  } else {
469
627
  yearlyAmount = Math.max(0, consumption - config.initialReading);
470
628
  }
471
- await this.adapter.setStateAsync(
472
- `${basePath}.consumption.yearly`,
473
- calculator.roundToDecimals(yearlyAmount, 2),
474
- true,
475
- );
629
+ yearlyAmountFinal = calculator.roundToDecimals(yearlyAmount, 2);
630
+ await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, yearlyAmountFinal, true);
476
631
  } else {
477
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
+ );
478
659
  await this.adapter.setStateAsync(
479
- `${basePath}.consumption.yearly`,
480
- calculator.roundToDecimals((yState?.val || 0) + delta, 2),
660
+ `${basePath}.consumption.yearlyVolume`,
661
+ calculator.roundToDecimals(yearlyM3, 2),
481
662
  true,
482
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
+ );
483
672
  }
673
+ yearlyAmountFinal = calculator.roundToDecimals(yearlyAmount, 2);
674
+ await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, yearlyAmountFinal, true);
484
675
 
485
- 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);
486
678
  await this.updateTotalCosts(type);
487
679
 
488
680
  await this.adapter.setStateAsync(`${basePath}.consumption.lastUpdate`, now, true);
@@ -522,6 +714,7 @@ class MultiMeterManager {
522
714
  true,
523
715
  );
524
716
  await this.adapter.setStateAsync(`${basePath}.info.currentTariff`, tariffName, true);
717
+ await this.adapter.setStateAsync(`${basePath}.info.lastSync`, Date.now(), true);
525
718
  }
526
719
 
527
720
  /**
@@ -530,14 +723,21 @@ class MultiMeterManager {
530
723
  * @param {string} type - Utility type
531
724
  * @param {string} meterName - Meter name
532
725
  * @param {object} config - Meter configuration
726
+ * @param {number} [forcedYearly] - Optional already calculated yearly consumption (avoids DB race)
533
727
  */
534
- async updateCosts(type, meterName, config) {
728
+ async updateCosts(type, meterName, config, forcedYearly) {
535
729
  const basePath = `${type}.${meterName}`;
536
730
 
537
731
  // Get consumption values
538
732
  const daily = (await this.adapter.getStateAsync(`${basePath}.consumption.daily`))?.val || 0;
539
733
  const monthly = (await this.adapter.getStateAsync(`${basePath}.consumption.monthly`))?.val || 0;
540
- 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
+ }
541
741
 
542
742
  // Get price
543
743
  const price = config.preis || 0;
@@ -548,67 +748,66 @@ class MultiMeterManager {
548
748
 
549
749
  // Calculate consumption costs
550
750
  const dailyCost = daily * price;
751
+ const weeklyState = await this.adapter.getStateAsync(`${basePath}.consumption.weekly`);
752
+ const weeklyCost = (weeklyState?.val || 0) * price;
551
753
  const monthlyCost = monthly * price;
552
754
  const yearlyCost = yearly * price;
553
755
 
554
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);
555
758
  await this.adapter.setStateAsync(`${basePath}.costs.monthly`, calculator.roundToDecimals(monthlyCost, 2), true);
556
759
  await this.adapter.setStateAsync(`${basePath}.costs.yearly`, calculator.roundToDecimals(yearlyCost, 2), true);
557
760
 
558
761
  // Calculate accumulated costs based on contract start
559
- const yearStartState = await this.adapter.getStateAsync(`${basePath}.statistics.lastYearStart`);
560
- let monthsSinceYearStart = 1;
561
- let basicChargeAccumulated = 0;
562
-
563
- if (yearStartState && yearStartState.val) {
564
- // yearStartState.val is a timestamp (number)
565
- const yearStartDate = new Date(yearStartState.val);
566
- if (!isNaN(yearStartDate.getTime())) {
567
- const now = new Date();
568
-
569
- // Calculate months since contract start (started months, including current)
570
- monthsSinceYearStart = calculator.getMonthsDifference(yearStartDate, now) + 1;
571
-
572
- // Calculate accumulated basic charge (monthly fee × months)
573
- basicChargeAccumulated = (config.grundgebuehr || 0) * monthsSinceYearStart;
574
- }
575
- }
576
-
577
- // Jahresgebühr ist ein FIXER Wert pro Jahr (z.B. 60€)
578
- // NICHT pro-rata nach Monaten/Tagen berechnen!
579
- const annualFeeAccumulated = config.jahresgebuehr || 0;
762
+ const monthsSinceYearStart = await this._calculateMonthsSinceYearStart(basePath);
580
763
 
581
- // Update basicCharge with accumulated value (not just monthly!)
582
- await this.adapter.setStateAsync(
583
- `${basePath}.costs.basicCharge`,
584
- calculator.roundToDecimals(basicChargeAccumulated, 2),
585
- true,
764
+ const charges = billingHelper.calculateAccumulatedCharges(
765
+ config.grundgebuehr,
766
+ config.jahresgebuehr,
767
+ monthsSinceYearStart,
586
768
  );
769
+ const basicChargeAccumulated = charges.basicCharge;
770
+ const annualFeeAccumulated = charges.annualFee;
587
771
 
588
- await this.adapter.setStateAsync(
589
- `${basePath}.costs.annualFee`,
590
- calculator.roundToDecimals(annualFeeAccumulated, 2),
591
- true,
592
- );
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);
593
775
 
594
776
  // Calculate total yearly costs and balance
595
- const totalYearlyCost = yearlyCost + basicChargeAccumulated + annualFeeAccumulated;
596
-
777
+ const totalYearlyCost = Math.max(0, yearlyCost + charges.total);
597
778
  await this.adapter.setStateAsync(
598
779
  `${basePath}.costs.totalYearly`,
599
780
  calculator.roundToDecimals(totalYearlyCost, 2),
600
781
  true,
601
782
  );
602
783
 
603
- const paidTotal = (config.abschlag || 0) * monthsSinceYearStart;
604
- const balance = totalYearlyCost - paidTotal;
784
+ const balanceRes = billingHelper.calculateBalance(config.abschlag, monthsSinceYearStart, totalYearlyCost);
605
785
 
606
786
  this.adapter.log.debug(
607
- `[${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}`,
608
788
  );
609
789
 
610
- await this.adapter.setStateAsync(`${basePath}.costs.paidTotal`, calculator.roundToDecimals(paidTotal, 2), true);
611
- 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);
612
811
  }
613
812
 
614
813
  /**
@@ -624,10 +823,22 @@ class MultiMeterManager {
624
823
  return;
625
824
  }
626
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
+
627
836
  let totalDaily = 0;
837
+ let totalWeekly = 0;
628
838
  let totalMonthly = 0;
629
839
  let totalYearly = 0;
630
840
  let totalCostsDaily = 0;
841
+ let totalCostsWeekly = 0;
631
842
  let totalCostsMonthly = 0;
632
843
  let totalCostsYearly = 0;
633
844
 
@@ -635,10 +846,12 @@ class MultiMeterManager {
635
846
  const basePath = `${type}.${meter.name}`;
636
847
 
637
848
  totalDaily += (await this.adapter.getStateAsync(`${basePath}.consumption.daily`))?.val || 0;
849
+ totalWeekly += (await this.adapter.getStateAsync(`${basePath}.consumption.weekly`))?.val || 0;
638
850
  totalMonthly += (await this.adapter.getStateAsync(`${basePath}.consumption.monthly`))?.val || 0;
639
851
  totalYearly += (await this.adapter.getStateAsync(`${basePath}.consumption.yearly`))?.val || 0;
640
852
 
641
853
  totalCostsDaily += (await this.adapter.getStateAsync(`${basePath}.costs.daily`))?.val || 0;
854
+ totalCostsWeekly += (await this.adapter.getStateAsync(`${basePath}.costs.weekly`))?.val || 0;
642
855
  totalCostsMonthly += (await this.adapter.getStateAsync(`${basePath}.costs.monthly`))?.val || 0;
643
856
  totalCostsYearly += (await this.adapter.getStateAsync(`${basePath}.costs.totalYearly`))?.val || 0;
644
857
  }
@@ -648,6 +861,11 @@ class MultiMeterManager {
648
861
  calculator.roundToDecimals(totalDaily, 2),
649
862
  true,
650
863
  );
864
+ await this.adapter.setStateAsync(
865
+ `${type}.totals.consumption.weekly`,
866
+ calculator.roundToDecimals(totalWeekly, 2),
867
+ true,
868
+ );
651
869
  await this.adapter.setStateAsync(
652
870
  `${type}.totals.consumption.monthly`,
653
871
  calculator.roundToDecimals(totalMonthly, 2),
@@ -664,6 +882,11 @@ class MultiMeterManager {
664
882
  calculator.roundToDecimals(totalCostsDaily, 2),
665
883
  true,
666
884
  );
885
+ await this.adapter.setStateAsync(
886
+ `${type}.totals.costs.weekly`,
887
+ calculator.roundToDecimals(totalCostsWeekly, 2),
888
+ true,
889
+ );
667
890
  await this.adapter.setStateAsync(
668
891
  `${type}.totals.costs.monthly`,
669
892
  calculator.roundToDecimals(totalCostsMonthly, 2),