iobroker.utility-monitor 1.4.6 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +110 -62
- package/admin/custom/.vite/manifest.json +90 -0
- package/admin/custom/@mf-types/Components.d.ts +2 -0
- package/admin/custom/@mf-types/compiled-types/Components/CSVImporter.d.ts +11 -0
- package/admin/custom/@mf-types/compiled-types/Components.d.ts +2 -0
- package/admin/custom/@mf-types.d.ts +3 -0
- package/admin/custom/@mf-types.zip +0 -0
- package/admin/custom/CSVImporter_v15_11.js +4415 -0
- package/admin/custom/assets/Components-i0AZ59nl.js +18887 -0
- package/admin/custom/assets/UtilityMonitor__loadShare__react__loadShare__-Da99Mak4.js +42 -0
- package/admin/custom/assets/UtilityMonitor__mf_v__runtimeInit__mf_v__-BmC4OGk6.js +16 -0
- package/admin/custom/assets/_commonjsHelpers-Dj2_voLF.js +30 -0
- package/admin/custom/assets/hostInit-DEXfeB0W.js +10 -0
- package/admin/custom/assets/index-B3WVNJTz.js +401 -0
- package/admin/custom/assets/index-VBwl8x_k.js +64 -0
- package/admin/custom/assets/preload-helper-BelkbqnE.js +61 -0
- package/admin/custom/assets/virtualExposes-CqCLUNLT.js +19 -0
- package/admin/custom/index.html +12 -0
- package/admin/custom/mf-manifest.json +1 -0
- package/admin/jsonConfig.json +90 -31
- package/io-package.json +15 -31
- package/lib/billingManager.js +382 -137
- package/lib/calculator.js +41 -146
- package/lib/consumptionManager.js +9 -252
- package/lib/importManager.js +300 -0
- package/lib/messagingHandler.js +4 -2
- package/lib/meter/MeterRegistry.js +110 -0
- package/lib/multiMeterManager.js +580 -173
- package/lib/stateManager.js +502 -31
- package/lib/utils/billingHelper.js +69 -0
- package/lib/utils/consumptionHelper.js +47 -0
- package/lib/utils/helpers.js +234 -0
- package/lib/utils/stateCache.js +147 -0
- package/lib/utils/typeMapper.js +19 -0
- package/main.js +67 -8
- package/package.json +10 -4
package/lib/multiMeterManager.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
118
|
+
* Finds meters by sensor datapoint
|
|
133
119
|
*
|
|
134
120
|
* @param {string} sensorDP - Sensor datapoint ID
|
|
135
|
-
* @returns {
|
|
121
|
+
* @returns {Array} - Array of {type, meterName} objects
|
|
136
122
|
*/
|
|
137
123
|
findMeterBySensor(sensorDP) {
|
|
138
|
-
return this.meterRegistry
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
197
|
-
this.meterRegistry
|
|
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
|
|
222
|
-
|
|
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
|
-
//
|
|
274
|
-
|
|
275
|
-
|
|
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);
|
|
@@ -338,9 +307,144 @@ class MultiMeterManager {
|
|
|
338
307
|
// Initial cost calculation
|
|
339
308
|
await this.updateCosts(type, meterName, config);
|
|
340
309
|
|
|
310
|
+
// Reconstruct weekly consumption from daily values if needed
|
|
311
|
+
await this.reconstructPeriodConsumption(type, meterName, basePath);
|
|
312
|
+
|
|
341
313
|
this.adapter.log.debug(`Meter initialization completed for ${type}.${meterName}`);
|
|
342
314
|
}
|
|
343
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
|
+
|
|
344
448
|
/**
|
|
345
449
|
* Handles sensor value updates
|
|
346
450
|
*
|
|
@@ -356,7 +460,9 @@ class MultiMeterManager {
|
|
|
356
460
|
}
|
|
357
461
|
|
|
358
462
|
const basePath = `${type}.${meterName}`;
|
|
359
|
-
this.adapter.log.debug(`
|
|
463
|
+
this.adapter.log.debug(`[${basePath}] handleSensorUpdate: value=${value}, sensorDP=${sensorDP}`);
|
|
464
|
+
|
|
465
|
+
const now = Date.now();
|
|
360
466
|
|
|
361
467
|
// Get meter config
|
|
362
468
|
const meters = this.getMetersForType(type);
|
|
@@ -367,92 +473,328 @@ class MultiMeterManager {
|
|
|
367
473
|
}
|
|
368
474
|
|
|
369
475
|
const config = meter.config;
|
|
370
|
-
|
|
476
|
+
|
|
477
|
+
// Pre-process consumption value (offset, gas conversion)
|
|
478
|
+
const processed = await this._preprocessValue(type, value, config);
|
|
479
|
+
const { consumption, consumptionM3 } = processed;
|
|
480
|
+
|
|
481
|
+
// 1. Initialization Logic (Per Session)
|
|
482
|
+
if (this.lastSensorValues[sensorDP] === undefined) {
|
|
483
|
+
await this._handleFirstSensorValue(type, meterName, sensorDP, processed, basePath, config, now);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// 2. Update meter reading states
|
|
488
|
+
await this.adapter.setStateAsync(`${basePath}.info.meterReading`, consumption, true);
|
|
489
|
+
if (type === 'gas') {
|
|
490
|
+
await this.adapter.setStateAsync(`${basePath}.info.meterReadingVolume`, consumptionM3 || 0, true);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 3. Delta Calibration & Spike Protection
|
|
494
|
+
const lastValue = this.lastSensorValues[sensorDP];
|
|
495
|
+
this.lastSensorValues[sensorDP] = consumption;
|
|
496
|
+
|
|
497
|
+
if (consumption < lastValue) {
|
|
498
|
+
await this._handleMeterReset(type, meterName, lastValue, consumption, config);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const delta = calculator.roundToDecimals(consumption - lastValue, 4);
|
|
503
|
+
if (delta <= 0) {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const spikeThreshold = this.adapter.config.sensorSpikeThreshold || DEFAULT_SPIKE_THRESHOLD;
|
|
508
|
+
if (delta > spikeThreshold) {
|
|
509
|
+
await this._handleSuspiciousDelta(
|
|
510
|
+
type,
|
|
511
|
+
meterName,
|
|
512
|
+
delta,
|
|
513
|
+
consumption,
|
|
514
|
+
consumptionM3,
|
|
515
|
+
config,
|
|
516
|
+
basePath,
|
|
517
|
+
now,
|
|
518
|
+
);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
this.adapter.log.debug(`${type}.${meterName} delta: ${delta}`);
|
|
523
|
+
|
|
524
|
+
// 4. Update Consumption Values (Daily, Weekly, Monthly)
|
|
525
|
+
const deltaVolume =
|
|
526
|
+
type === 'gas'
|
|
527
|
+
? delta /
|
|
528
|
+
((this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT) *
|
|
529
|
+
(this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL))
|
|
530
|
+
: 0;
|
|
531
|
+
await this._updateTotalConsumptionStates(basePath, type, delta, deltaVolume);
|
|
532
|
+
|
|
533
|
+
// 5. HT/NT Tracking
|
|
534
|
+
await this._updateHTNTConsumptionStates(basePath, type, delta, config);
|
|
535
|
+
|
|
536
|
+
// 6. Yearly & Costs
|
|
537
|
+
const yearlyAmountFinal = await this._updateYearlyConsumption(
|
|
538
|
+
type,
|
|
539
|
+
meterName,
|
|
540
|
+
config,
|
|
541
|
+
consumption,
|
|
542
|
+
consumptionM3,
|
|
543
|
+
delta,
|
|
544
|
+
basePath,
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
await this.updateCosts(type, meterName, config, yearlyAmountFinal);
|
|
548
|
+
await this.updateTotalCosts(type);
|
|
549
|
+
|
|
550
|
+
await this.adapter.setStateAsync(`${basePath}.consumption.lastUpdate`, now, true);
|
|
551
|
+
await this.adapter.setStateAsync(`${basePath}.info.lastSync`, now, true);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Preprocesses raw sensor value (offset, gas conversion)
|
|
556
|
+
*
|
|
557
|
+
* @param {string} type - Utility type
|
|
558
|
+
* @param {number} value - Raw sensor value
|
|
559
|
+
* @param {object} config - Meter configuration
|
|
560
|
+
* @returns {Promise<{consumption: number, consumptionM3: number|null}>} Processed values
|
|
561
|
+
*/
|
|
562
|
+
async _preprocessValue(type, value, config) {
|
|
371
563
|
let consumption = value;
|
|
372
564
|
let consumptionM3 = null;
|
|
373
565
|
|
|
374
|
-
this.adapter.log.debug(`[${basePath}] Sensor update: raw=${value}, offset=${config.offset}`);
|
|
375
|
-
|
|
376
|
-
// Apply offset FIRST
|
|
377
566
|
if (config.offset !== 0) {
|
|
378
567
|
consumption = consumption - config.offset;
|
|
379
|
-
this.adapter.log.debug(`[${basePath}] After offset: ${consumption}`);
|
|
380
568
|
}
|
|
381
569
|
|
|
382
|
-
// For gas, convert m³ to kWh
|
|
383
570
|
if (type === 'gas') {
|
|
384
571
|
const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
|
|
385
572
|
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);
|
|
390
|
-
}
|
|
391
573
|
|
|
392
|
-
|
|
393
|
-
|
|
574
|
+
const res = consumptionHelper.calculateGas(consumption, brennwert, zZahl);
|
|
575
|
+
consumptionM3 = res.volume;
|
|
576
|
+
consumption = res.energy;
|
|
577
|
+
}
|
|
394
578
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
this.lastSensorValues[sensorDP] = consumption;
|
|
579
|
+
return { consumption, consumptionM3 };
|
|
580
|
+
}
|
|
398
581
|
|
|
399
|
-
|
|
400
|
-
|
|
582
|
+
/**
|
|
583
|
+
* Handles the very first sensor value in a session (recovery or initial)
|
|
584
|
+
*
|
|
585
|
+
* @param {string} type - Utility type
|
|
586
|
+
* @param {string} meterName - Meter name
|
|
587
|
+
* @param {string} sensorDP - Sensor data point path
|
|
588
|
+
* @param {object} processed - Processed consumption values
|
|
589
|
+
* @param {string} basePath - State base path
|
|
590
|
+
* @param {object} config - Meter configuration
|
|
591
|
+
* @param {number} now - Current timestamp
|
|
592
|
+
*/
|
|
593
|
+
async _handleFirstSensorValue(type, meterName, sensorDP, processed, basePath, config, now) {
|
|
594
|
+
const { consumption, consumptionM3 } = processed;
|
|
595
|
+
const currentState = await this.adapter.getStateAsync(`${basePath}.info.meterReading`);
|
|
596
|
+
const recoveredValue = currentState?.val ?? 0;
|
|
597
|
+
|
|
598
|
+
if (recoveredValue > 0 && Math.abs(consumption - recoveredValue) < 100) {
|
|
599
|
+
this.adapter.log.info(`[${basePath}] Recovered persistent baseline: ${recoveredValue}`);
|
|
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);
|
|
605
|
+
} else {
|
|
606
|
+
if (recoveredValue > 0) {
|
|
401
607
|
this.adapter.log.warn(
|
|
402
|
-
|
|
608
|
+
`[${basePath}] Recovered state (${recoveredValue}) differs significantly from new value (${consumption}).`,
|
|
609
|
+
);
|
|
610
|
+
} else {
|
|
611
|
+
this.adapter.log.info(
|
|
612
|
+
`[${basePath}] No previous reading found. Setting initial baseline to ${consumption}`,
|
|
403
613
|
);
|
|
404
614
|
}
|
|
405
|
-
|
|
406
|
-
await this.
|
|
407
|
-
|
|
615
|
+
this.lastSensorValues[sensorDP] = consumption;
|
|
616
|
+
await this.adapter.setStateAsync(`${basePath}.info.meterReading`, consumption, true);
|
|
617
|
+
if (type === 'gas') {
|
|
618
|
+
await this.adapter.setStateAsync(`${basePath}.info.meterReadingVolume`, consumptionM3 || 0, true);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// On baseline reset, validate and potentially reset period consumption values
|
|
622
|
+
await this._validatePeriodConsumption(type, basePath, now);
|
|
623
|
+
|
|
624
|
+
if (config.initialReading > 0) {
|
|
625
|
+
await this.calculateAbsoluteYearly(type, meterName, config, consumption, consumptionM3 || 0, now);
|
|
626
|
+
await this.updateCosts(type, meterName, config);
|
|
627
|
+
await this.updateTotalCosts(type);
|
|
628
|
+
}
|
|
408
629
|
}
|
|
630
|
+
await this.adapter.setStateAsync(`${basePath}.info.lastSync`, now, true);
|
|
631
|
+
await this.adapter.setStateAsync(`${basePath}.consumption.lastUpdate`, now, true);
|
|
632
|
+
}
|
|
409
633
|
|
|
410
|
-
|
|
411
|
-
|
|
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);
|
|
412
657
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
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);
|
|
418
667
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Handles meter reset or replacement condition
|
|
678
|
+
*
|
|
679
|
+
* @param {string} type - Utility type
|
|
680
|
+
* @param {string} meterName - Meter name
|
|
681
|
+
* @param {number} lastValue - Previous sensor value
|
|
682
|
+
* @param {number} consumption - Current consumption value
|
|
683
|
+
* @param {object} config - Meter configuration
|
|
684
|
+
*/
|
|
685
|
+
async _handleMeterReset(type, meterName, lastValue, consumption, config) {
|
|
686
|
+
this.adapter.log.warn(
|
|
687
|
+
`${type}.${meterName}: Zählerstand gesunken (${lastValue} -> ${consumption}). Gehe von Zählerwechsel oder Reset aus.`,
|
|
688
|
+
);
|
|
689
|
+
await this.updateCosts(type, meterName, config);
|
|
690
|
+
await this.updateTotalCosts(type);
|
|
691
|
+
}
|
|
422
692
|
|
|
693
|
+
/**
|
|
694
|
+
* Handles suspicious delta (spike detection)
|
|
695
|
+
*
|
|
696
|
+
* @param {string} type - Utility type
|
|
697
|
+
* @param {string} meterName - Meter name
|
|
698
|
+
* @param {number} delta - Calculated delta
|
|
699
|
+
* @param {number} consumption - Current consumption value
|
|
700
|
+
* @param {number|null} consumptionM3 - Current gas volume
|
|
701
|
+
* @param {object} config - Meter configuration
|
|
702
|
+
* @param {string} basePath - State base path
|
|
703
|
+
* @param {number} now - Current timestamp
|
|
704
|
+
*/
|
|
705
|
+
async _handleSuspiciousDelta(type, meterName, delta, consumption, consumptionM3, config, basePath, now) {
|
|
706
|
+
this.adapter.log.warn(`[${basePath}] Discarding suspicious delta of ${delta}. Treating as baseline reset.`);
|
|
707
|
+
if (config.initialReading > 0) {
|
|
708
|
+
await this.calculateAbsoluteYearly(type, meterName, config, consumption, consumptionM3 || 0, now);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Updates all period consumption states
|
|
714
|
+
*
|
|
715
|
+
* @param {string} basePath - State base path
|
|
716
|
+
* @param {string} type - Utility type
|
|
717
|
+
* @param {number} delta - Consumption delta
|
|
718
|
+
* @param {number} deltaVolume - Gas volume delta
|
|
719
|
+
*/
|
|
720
|
+
async _updateTotalConsumptionStates(basePath, type, delta, deltaVolume) {
|
|
721
|
+
const periods = ['daily', 'weekly', 'monthly'];
|
|
722
|
+
for (const period of periods) {
|
|
723
|
+
const state = await this.adapter.getStateAsync(`${basePath}.consumption.${period}`);
|
|
423
724
|
await this.adapter.setStateAsync(
|
|
424
|
-
`${basePath}.consumption
|
|
425
|
-
calculator.roundToDecimals((
|
|
725
|
+
`${basePath}.consumption.${period}`,
|
|
726
|
+
calculator.roundToDecimals((state?.val || 0) + delta, 2),
|
|
426
727
|
true,
|
|
427
728
|
);
|
|
729
|
+
|
|
730
|
+
if (type === 'gas' && deltaVolume > 0) {
|
|
731
|
+
const volState = await this.adapter.getStateAsync(`${basePath}.consumption.${period}Volume`);
|
|
732
|
+
await this.adapter.setStateAsync(
|
|
733
|
+
`${basePath}.consumption.${period}Volume`,
|
|
734
|
+
calculator.roundToDecimals((volState?.val || 0) + deltaVolume, 2),
|
|
735
|
+
true,
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Updates HT/NT specific consumption states
|
|
743
|
+
*
|
|
744
|
+
* @param {string} basePath - State base path
|
|
745
|
+
* @param {string} type - Utility type
|
|
746
|
+
* @param {number} delta - Consumption delta
|
|
747
|
+
* @param {object} config - Meter configuration
|
|
748
|
+
*/
|
|
749
|
+
async _updateHTNTConsumptionStates(basePath, type, delta, config) {
|
|
750
|
+
if (!config.htNtEnabled) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const configType = this.getConfigType(type);
|
|
755
|
+
const suffix = consumptionHelper.getHTNTSuffix(this.adapter.config, configType);
|
|
756
|
+
if (!suffix) {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const periods = ['daily', 'weekly', 'monthly'];
|
|
761
|
+
for (const period of periods) {
|
|
762
|
+
const state = await this.adapter.getStateAsync(`${basePath}.consumption.${period}${suffix}`);
|
|
428
763
|
await this.adapter.setStateAsync(
|
|
429
|
-
`${basePath}.consumption
|
|
430
|
-
calculator.roundToDecimals((
|
|
764
|
+
`${basePath}.consumption.${period}${suffix}`,
|
|
765
|
+
calculator.roundToDecimals((state?.val || 0) + delta, 2),
|
|
431
766
|
true,
|
|
432
767
|
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
if (type === 'gas') {
|
|
771
|
+
const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
|
|
772
|
+
const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
|
|
773
|
+
const deltaVolume = delta / (brennwert * zZahl);
|
|
774
|
+
|
|
775
|
+
const state = await this.adapter.getStateAsync(`${basePath}.consumption.weeklyVolume${suffix}`);
|
|
433
776
|
await this.adapter.setStateAsync(
|
|
434
|
-
`${basePath}.consumption.
|
|
435
|
-
calculator.roundToDecimals((
|
|
777
|
+
`${basePath}.consumption.weeklyVolume${suffix}`,
|
|
778
|
+
calculator.roundToDecimals((state?.val || 0) + deltaVolume, 2),
|
|
436
779
|
true,
|
|
437
780
|
);
|
|
438
781
|
}
|
|
782
|
+
}
|
|
439
783
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
// Yearly consumption
|
|
784
|
+
/**
|
|
785
|
+
* Updates yearly consumption
|
|
786
|
+
*
|
|
787
|
+
* @param {string} type - Utility type
|
|
788
|
+
* @param {string} meterName - Meter name
|
|
789
|
+
* @param {object} config - Meter configuration
|
|
790
|
+
* @param {number} consumption - Current consumption value
|
|
791
|
+
* @param {number|null} consumptionM3 - Current gas volume
|
|
792
|
+
* @param {number} delta - Consumption delta
|
|
793
|
+
* @param {string} basePath - State base path
|
|
794
|
+
* @returns {Promise<number>} final yearly amount
|
|
795
|
+
*/
|
|
796
|
+
async _updateYearlyConsumption(type, meterName, config, consumption, consumptionM3, delta, basePath) {
|
|
797
|
+
let yearlyAmountFinal;
|
|
456
798
|
if (config.initialReading > 0) {
|
|
457
799
|
let yearlyAmount;
|
|
458
800
|
if (type === 'gas') {
|
|
@@ -468,21 +810,55 @@ class MultiMeterManager {
|
|
|
468
810
|
} else {
|
|
469
811
|
yearlyAmount = Math.max(0, consumption - config.initialReading);
|
|
470
812
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
calculator.roundToDecimals(yearlyAmount, 2),
|
|
474
|
-
true,
|
|
475
|
-
);
|
|
813
|
+
yearlyAmountFinal = calculator.roundToDecimals(yearlyAmount, 2);
|
|
814
|
+
await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, yearlyAmountFinal, true);
|
|
476
815
|
} else {
|
|
477
816
|
const yState = await this.adapter.getStateAsync(`${basePath}.consumption.yearly`);
|
|
817
|
+
yearlyAmountFinal = calculator.roundToDecimals((yState?.val || 0) + delta, 2);
|
|
818
|
+
await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, yearlyAmountFinal, true);
|
|
819
|
+
}
|
|
820
|
+
return yearlyAmountFinal;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Helper to calculate absolute yearly consumption (usually on start/reset)
|
|
825
|
+
*
|
|
826
|
+
* @param {string} type - Utility type
|
|
827
|
+
* @param {string} meterName - Meter name
|
|
828
|
+
* @param {object} config - Meter configuration
|
|
829
|
+
* @param {number} consumption - Current consumption value
|
|
830
|
+
* @param {number} consumptionM3 - Current consumption in m³ (for gas)
|
|
831
|
+
* @param {number} now - Current timestamp
|
|
832
|
+
*/
|
|
833
|
+
async calculateAbsoluteYearly(type, meterName, config, consumption, consumptionM3, now) {
|
|
834
|
+
const basePath = `${type}.${meterName}`;
|
|
835
|
+
let yearlyAmountFinal;
|
|
836
|
+
|
|
837
|
+
let yearlyAmount;
|
|
838
|
+
if (type === 'gas') {
|
|
839
|
+
const yearlyM3 = Math.max(0, (consumptionM3 || 0) - config.initialReading);
|
|
840
|
+
this.adapter.log.debug(
|
|
841
|
+
`[${basePath}] Yearly absolute (Gas): consumptionM3=${consumptionM3}, initialReading=${config.initialReading}, resultM3=${yearlyM3}`,
|
|
842
|
+
);
|
|
478
843
|
await this.adapter.setStateAsync(
|
|
479
|
-
`${basePath}.consumption.
|
|
480
|
-
calculator.roundToDecimals(
|
|
844
|
+
`${basePath}.consumption.yearlyVolume`,
|
|
845
|
+
calculator.roundToDecimals(yearlyM3, 2),
|
|
481
846
|
true,
|
|
482
847
|
);
|
|
848
|
+
const brennwert = this.adapter.config.gasBrennwert || calculator.DEFAULTS.GAS_BRENNWERT;
|
|
849
|
+
const zZahl = this.adapter.config.gasZahl || calculator.DEFAULTS.GAS_Z_ZAHL;
|
|
850
|
+
yearlyAmount = calculator.convertGasM3ToKWh(yearlyM3, brennwert, zZahl);
|
|
851
|
+
} else {
|
|
852
|
+
yearlyAmount = Math.max(0, consumption - config.initialReading);
|
|
853
|
+
this.adapter.log.debug(
|
|
854
|
+
`[${basePath}] Yearly absolute: consumption=${consumption}, initialReading=${config.initialReading}, result=${yearlyAmount}`,
|
|
855
|
+
);
|
|
483
856
|
}
|
|
857
|
+
yearlyAmountFinal = calculator.roundToDecimals(yearlyAmount, 2);
|
|
858
|
+
await this.adapter.setStateAsync(`${basePath}.consumption.yearly`, yearlyAmountFinal, true);
|
|
484
859
|
|
|
485
|
-
|
|
860
|
+
// Pass direct calculated values to updateCosts to avoid race condition with DB
|
|
861
|
+
await this.updateCosts(type, meterName, config, yearlyAmountFinal);
|
|
486
862
|
await this.updateTotalCosts(type);
|
|
487
863
|
|
|
488
864
|
await this.adapter.setStateAsync(`${basePath}.consumption.lastUpdate`, now, true);
|
|
@@ -522,6 +898,7 @@ class MultiMeterManager {
|
|
|
522
898
|
true,
|
|
523
899
|
);
|
|
524
900
|
await this.adapter.setStateAsync(`${basePath}.info.currentTariff`, tariffName, true);
|
|
901
|
+
await this.adapter.setStateAsync(`${basePath}.info.lastSync`, Date.now(), true);
|
|
525
902
|
}
|
|
526
903
|
|
|
527
904
|
/**
|
|
@@ -530,14 +907,21 @@ class MultiMeterManager {
|
|
|
530
907
|
* @param {string} type - Utility type
|
|
531
908
|
* @param {string} meterName - Meter name
|
|
532
909
|
* @param {object} config - Meter configuration
|
|
910
|
+
* @param {number} [forcedYearly] - Optional already calculated yearly consumption (avoids DB race)
|
|
533
911
|
*/
|
|
534
|
-
async updateCosts(type, meterName, config) {
|
|
912
|
+
async updateCosts(type, meterName, config, forcedYearly) {
|
|
535
913
|
const basePath = `${type}.${meterName}`;
|
|
536
914
|
|
|
537
915
|
// Get consumption values
|
|
538
916
|
const daily = (await this.adapter.getStateAsync(`${basePath}.consumption.daily`))?.val || 0;
|
|
539
917
|
const monthly = (await this.adapter.getStateAsync(`${basePath}.consumption.monthly`))?.val || 0;
|
|
540
|
-
|
|
918
|
+
|
|
919
|
+
let yearly;
|
|
920
|
+
if (forcedYearly !== undefined && forcedYearly !== null) {
|
|
921
|
+
yearly = forcedYearly;
|
|
922
|
+
} else {
|
|
923
|
+
yearly = (await this.adapter.getStateAsync(`${basePath}.consumption.yearly`))?.val || 0;
|
|
924
|
+
}
|
|
541
925
|
|
|
542
926
|
// Get price
|
|
543
927
|
const price = config.preis || 0;
|
|
@@ -548,67 +932,66 @@ class MultiMeterManager {
|
|
|
548
932
|
|
|
549
933
|
// Calculate consumption costs
|
|
550
934
|
const dailyCost = daily * price;
|
|
935
|
+
const weeklyState = await this.adapter.getStateAsync(`${basePath}.consumption.weekly`);
|
|
936
|
+
const weeklyCost = (weeklyState?.val || 0) * price;
|
|
551
937
|
const monthlyCost = monthly * price;
|
|
552
938
|
const yearlyCost = yearly * price;
|
|
553
939
|
|
|
554
940
|
await this.adapter.setStateAsync(`${basePath}.costs.daily`, calculator.roundToDecimals(dailyCost, 2), true);
|
|
941
|
+
await this.adapter.setStateAsync(`${basePath}.costs.weekly`, calculator.roundToDecimals(weeklyCost, 2), true);
|
|
555
942
|
await this.adapter.setStateAsync(`${basePath}.costs.monthly`, calculator.roundToDecimals(monthlyCost, 2), true);
|
|
556
943
|
await this.adapter.setStateAsync(`${basePath}.costs.yearly`, calculator.roundToDecimals(yearlyCost, 2), true);
|
|
557
944
|
|
|
558
945
|
// Calculate accumulated costs based on contract start
|
|
559
|
-
const
|
|
560
|
-
let monthsSinceYearStart = 1;
|
|
561
|
-
let basicChargeAccumulated = 0;
|
|
946
|
+
const monthsSinceYearStart = await this._calculateMonthsSinceYearStart(basePath);
|
|
562
947
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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;
|
|
580
|
-
|
|
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,
|
|
948
|
+
const charges = billingHelper.calculateAccumulatedCharges(
|
|
949
|
+
config.grundgebuehr,
|
|
950
|
+
config.jahresgebuehr,
|
|
951
|
+
monthsSinceYearStart,
|
|
586
952
|
);
|
|
953
|
+
const basicChargeAccumulated = charges.basicCharge;
|
|
954
|
+
const annualFeeAccumulated = charges.annualFee;
|
|
587
955
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
true,
|
|
592
|
-
);
|
|
956
|
+
// Update basicCharge and annualFee states
|
|
957
|
+
await this.adapter.setStateAsync(`${basePath}.costs.basicCharge`, basicChargeAccumulated, true);
|
|
958
|
+
await this.adapter.setStateAsync(`${basePath}.costs.annualFee`, annualFeeAccumulated, true);
|
|
593
959
|
|
|
594
960
|
// Calculate total yearly costs and balance
|
|
595
|
-
const totalYearlyCost = yearlyCost +
|
|
596
|
-
|
|
961
|
+
const totalYearlyCost = Math.max(0, yearlyCost + charges.total);
|
|
597
962
|
await this.adapter.setStateAsync(
|
|
598
963
|
`${basePath}.costs.totalYearly`,
|
|
599
964
|
calculator.roundToDecimals(totalYearlyCost, 2),
|
|
600
965
|
true,
|
|
601
966
|
);
|
|
602
967
|
|
|
603
|
-
const
|
|
604
|
-
const balance = totalYearlyCost - paidTotal;
|
|
968
|
+
const balanceRes = billingHelper.calculateBalance(config.abschlag, monthsSinceYearStart, totalYearlyCost);
|
|
605
969
|
|
|
606
970
|
this.adapter.log.debug(
|
|
607
|
-
`[${basePath}]
|
|
971
|
+
`[${basePath}] Cost calculation (MultiMeter): daily=${daily}, monthly=${monthly}, yearly=${yearly}, price=${price}, totalYearly=${totalYearlyCost}, paidTotal=${balanceRes.paid}, balance=${balanceRes.balance}`,
|
|
608
972
|
);
|
|
609
973
|
|
|
610
|
-
await this.adapter.setStateAsync(`${basePath}.costs.paidTotal`,
|
|
611
|
-
await this.adapter.setStateAsync(`${basePath}.costs.balance`,
|
|
974
|
+
await this.adapter.setStateAsync(`${basePath}.costs.paidTotal`, balanceRes.paid, true);
|
|
975
|
+
await this.adapter.setStateAsync(`${basePath}.costs.balance`, balanceRes.balance, true);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* Calculates months since year start for a meter
|
|
980
|
+
*
|
|
981
|
+
* @param {string} basePath - State base path
|
|
982
|
+
* @returns {Promise<number>} Months since start (at least 1)
|
|
983
|
+
*/
|
|
984
|
+
async _calculateMonthsSinceYearStart(basePath) {
|
|
985
|
+
const yearStartState = await this.adapter.getStateAsync(`${basePath}.statistics.lastYearStart`);
|
|
986
|
+
let monthsSinceYearStart = 1;
|
|
987
|
+
|
|
988
|
+
if (yearStartState && yearStartState.val) {
|
|
989
|
+
const yearStartDate = new Date(yearStartState.val);
|
|
990
|
+
if (!isNaN(yearStartDate.getTime())) {
|
|
991
|
+
monthsSinceYearStart = calculator.getMonthsDifference(yearStartDate, new Date()) + 1;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
return Math.max(1, monthsSinceYearStart);
|
|
612
995
|
}
|
|
613
996
|
|
|
614
997
|
/**
|
|
@@ -624,10 +1007,22 @@ class MultiMeterManager {
|
|
|
624
1007
|
return;
|
|
625
1008
|
}
|
|
626
1009
|
|
|
1010
|
+
// Check if totals structure exists before trying to update
|
|
1011
|
+
const totalsExists = await this.adapter.getObjectAsync(`${type}.totals`);
|
|
1012
|
+
if (!totalsExists) {
|
|
1013
|
+
// Totals structure not yet created, skip update
|
|
1014
|
+
// This happens during initialization when handleSensorUpdate is called
|
|
1015
|
+
// before createTotalsStructure has run
|
|
1016
|
+
this.adapter.log.debug(`Skipping total costs update for ${type} - totals structure not yet created`);
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
627
1020
|
let totalDaily = 0;
|
|
1021
|
+
let totalWeekly = 0;
|
|
628
1022
|
let totalMonthly = 0;
|
|
629
1023
|
let totalYearly = 0;
|
|
630
1024
|
let totalCostsDaily = 0;
|
|
1025
|
+
let totalCostsWeekly = 0;
|
|
631
1026
|
let totalCostsMonthly = 0;
|
|
632
1027
|
let totalCostsYearly = 0;
|
|
633
1028
|
|
|
@@ -635,10 +1030,12 @@ class MultiMeterManager {
|
|
|
635
1030
|
const basePath = `${type}.${meter.name}`;
|
|
636
1031
|
|
|
637
1032
|
totalDaily += (await this.adapter.getStateAsync(`${basePath}.consumption.daily`))?.val || 0;
|
|
1033
|
+
totalWeekly += (await this.adapter.getStateAsync(`${basePath}.consumption.weekly`))?.val || 0;
|
|
638
1034
|
totalMonthly += (await this.adapter.getStateAsync(`${basePath}.consumption.monthly`))?.val || 0;
|
|
639
1035
|
totalYearly += (await this.adapter.getStateAsync(`${basePath}.consumption.yearly`))?.val || 0;
|
|
640
1036
|
|
|
641
1037
|
totalCostsDaily += (await this.adapter.getStateAsync(`${basePath}.costs.daily`))?.val || 0;
|
|
1038
|
+
totalCostsWeekly += (await this.adapter.getStateAsync(`${basePath}.costs.weekly`))?.val || 0;
|
|
642
1039
|
totalCostsMonthly += (await this.adapter.getStateAsync(`${basePath}.costs.monthly`))?.val || 0;
|
|
643
1040
|
totalCostsYearly += (await this.adapter.getStateAsync(`${basePath}.costs.totalYearly`))?.val || 0;
|
|
644
1041
|
}
|
|
@@ -648,6 +1045,11 @@ class MultiMeterManager {
|
|
|
648
1045
|
calculator.roundToDecimals(totalDaily, 2),
|
|
649
1046
|
true,
|
|
650
1047
|
);
|
|
1048
|
+
await this.adapter.setStateAsync(
|
|
1049
|
+
`${type}.totals.consumption.weekly`,
|
|
1050
|
+
calculator.roundToDecimals(totalWeekly, 2),
|
|
1051
|
+
true,
|
|
1052
|
+
);
|
|
651
1053
|
await this.adapter.setStateAsync(
|
|
652
1054
|
`${type}.totals.consumption.monthly`,
|
|
653
1055
|
calculator.roundToDecimals(totalMonthly, 2),
|
|
@@ -664,6 +1066,11 @@ class MultiMeterManager {
|
|
|
664
1066
|
calculator.roundToDecimals(totalCostsDaily, 2),
|
|
665
1067
|
true,
|
|
666
1068
|
);
|
|
1069
|
+
await this.adapter.setStateAsync(
|
|
1070
|
+
`${type}.totals.costs.weekly`,
|
|
1071
|
+
calculator.roundToDecimals(totalCostsWeekly, 2),
|
|
1072
|
+
true,
|
|
1073
|
+
);
|
|
667
1074
|
await this.adapter.setStateAsync(
|
|
668
1075
|
`${type}.totals.costs.monthly`,
|
|
669
1076
|
calculator.roundToDecimals(totalCostsMonthly, 2),
|